bbox_model / ap.jsx
jkushwaha's picture
Create ap.jsx
ff268db verified
Raw
History Blame Contribute Delete
200 kB
import React, { useState, useCallback, useEffect, useRef } from "react";
import { QUESTIONS } from "./data/questions";
import LoginPage from "./LoginPage";
import {
getSession, logout, startTesting, terminateTesting, resetTesting,
getScores, saveScores, clearScores,
saveSessionQuestions, getSessionQuestions, clearSessionQuestions,
} from "./auth";
import {
dbGetAllUsers, dbUpdateRole, dbResetPassword, dbDeleteUser, dbSignup,
dbGetSubmissions, dbSaveSubmission, dbDeleteSubmission,
dbGetProfiles, dbSaveProfile, dbDeleteProfile,
dbGetProducts, dbSaveProduct, dbDeleteProduct,
dbGetUserProducts, dbSetUserProducts,
dbGetTasks, dbSaveTask, dbDeleteTask,
dbGetTickets, dbSaveTicket, dbDeleteTicket,
dbGetNotifications, dbSaveNotification, dbMarkNotificationRead, dbMarkAllNotificationsRead, dbClearNotifications,
dbAppendLog, dbReadLogs, dbPruneLogs,
} from "./db";
import { driveEnabled, uploadPhoto, deleteSubmissionPhotos } from "./drive";
import logo from "./assets/logo.png";
import "./App.css";
// ── Constants ─────────────────────────────────────────────────────────────────
const SESSION_DURATION = 12 * 60 * 60 * 1000;
const TESTER_DURATION = 60 * 60 * 1000;
function getDuration(s) {
return s?.mode === "tester" ? TESTER_DURATION : SESSION_DURATION;
}
const CATEGORY_COLORS = {
FUNC: "#4f86c6", UI: "#6bbf8e", API: "#e08c4a", DATA: "#9b73c8",
PERF: "#e05c5c", SEC: "#c8a73a", INT: "#4ab8c8", REG: "#888",
};
// Fixed "random-looking" preset marks for the default question set
const PRESET_MARKS = [10,15,10,20,15,10,10,15,10,20,15,10,10,20,15,10,15,20,10,15,20,10,15,10,15,20,10,15,10,15];
function getActiveQuestions() {
return QUESTIONS;
}
function blankRow(q) {
return {
id: q.id, standard: q.standard, observation: q.observation,
possibleMarks: q.possibleMarks ?? PRESET_MARKS[q.id - 1] ?? 10,
earnedScore: null, evalNote: "", screenshot: null,
};
}
function parseCSVLine(line) {
const result = []; let cur = ""; let inQ = false;
for (const ch of line) {
if (ch === '"') { inQ = !inQ; }
else if (ch === ',' && !inQ) { result.push(cur); cur = ""; }
else cur += ch;
}
result.push(cur);
return result;
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function fmtDuration(ms) {
const t = Math.max(0, Math.floor(ms / 1000));
const h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), s = t % 60;
return `${String(h).padStart(2,"0")}:${String(m).padStart(2,"0")}:${String(s).padStart(2,"0")}`;
}
function fmtDateTime(ts) {
return new Date(ts).toLocaleString("en-US", {
year:"numeric", month:"long", day:"numeric", hour:"2-digit", minute:"2-digit",
});
}
function fileToBase64(file) {
return new Promise((resolve) => {
const r = new FileReader();
r.onload = (e) => resolve(e.target.result);
r.readAsDataURL(file);
});
}
function dataURLtoBlob(dataURL) {
const [header, data] = dataURL.split(",");
const mime = header.match(/:(.*?);/)[1];
const binary = atob(data);
const arr = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) arr[i] = binary.charCodeAt(i);
return new Blob([arr], { type: mime });
}
function getMissingFields(rows) {
return rows.reduce((acc, r) => {
const f = [];
if (r.earnedScore === null) f.push("earned score");
if (!r.screenshot) f.push("screenshot");
if (f.length) acc.push({ id: r.id, standard: r.standard, fields: f });
return acc;
}, []);
}
// ── Report builder ────────────────────────────────────────────────────────────
function buildReportHTML(rows, session) {
const { username, testingStart, testingEnd } = session;
const totalPossible = rows.reduce((s, r) => s + (r.possibleMarks ?? 0), 0);
const totalEarned = rows.reduce((s, r) => s + (r.earnedScore ?? 0), 0);
const pct = totalPossible > 0 ? ((totalEarned / totalPossible) * 100).toFixed(1) : "β€”";
const passed = rows.filter(r => r.possibleMarks && r.earnedScore !== null && r.earnedScore / r.possibleMarks >= 0.8).length;
const warned = rows.filter(r => { if (!r.possibleMarks || r.earnedScore === null) return false; const p = r.earnedScore / r.possibleMarks; return p >= 0.5 && p < 0.8; }).length;
const failed = rows.filter(r => r.possibleMarks && r.earnedScore !== null && r.earnedScore / r.possibleMarks < 0.5).length;
const rowsHTML = rows.map((row) => {
const rowPct = row.possibleMarks && row.earnedScore !== null ? Math.round((row.earnedScore / row.possibleMarks) * 100) : null;
const status = rowPct !== null ? (rowPct >= 80 ? "PASS" : rowPct < 50 ? "FAIL" : "WARN") : "N/A";
const imgSrc = row.screenshot?.base64 ?? row.screenshot?.url;
const imgHTML = imgSrc
? `<img src="${imgSrc}" alt="${row.screenshot.name}" />`
: `<span class="no-img">β€”</span>`;
const color = CATEGORY_COLORS[row.standard.split("-")[0]] ?? "#888";
const noteHTML = row.evalNote?.trim()
? `<tr class="note-report-row"><td colspan="7" class="note-report-cell"><span class="note-report-label">Observation:</span> ${row.evalNote.trim()}</td></tr>`
: "";
return `
<tr class="${status.toLowerCase()}-row">
<td class="tc">${row.id}</td>
<td><span class="badge" style="background:${color}">${row.standard}</span></td>
<td>${row.observation}</td>
<td class="tc">${row.possibleMarks ?? "β€”"}</td>
<td class="tc">${row.earnedScore !== null ? `<strong>${row.earnedScore}</strong> / ${row.possibleMarks ?? "β€”"}<br/><small>${rowPct ?? "β€”"}%</small>` : "β€”"}</td>
<td class="tc"><span class="st ${status.toLowerCase()}">${status}</span></td>
<td class="tc img-cell">${imgHTML}</td>
</tr>${noteHTML}`;
}).join("\n");
return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"/>
<title>QA Report β€” ${username}</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;font-size:13px;color:#1a1a2e;background:#fff;padding:32px}
h1{font-size:22px;font-weight:700}
h2{font-size:13px;font-weight:700;margin:24px 0 10px;text-transform:uppercase;letter-spacing:.5px;color:#555}
.hdr{display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:12px;border-bottom:3px solid #1a1a2e;padding-bottom:16px;margin-bottom:24px}
.hdr .meta{font-size:12px;color:#555;line-height:1.9} .hdr .meta strong{color:#1a1a2e}
.grid{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin-bottom:28px}
.card{border-radius:8px;padding:12px 16px;border:1px solid #e0e0e0}
.card .v{font-size:26px;font-weight:700;line-height:1} .card .l{font-size:11px;color:#666;text-transform:uppercase;letter-spacing:.4px;margin-top:4px}
.card.bl{border-top:4px solid #4f86c6}.card.gr{border-top:4px solid #2d9e5a}.card.pu{border-top:4px solid #9b73c8}.card.te{border-top:4px solid #4ab8c8}.card.re{border-top:4px solid #e05c5c}
table{width:100%;border-collapse:collapse;font-size:12px}
thead tr{background:#1a1a2e;color:#fff}
thead th{padding:10px 12px;text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:.5px;white-space:nowrap}
tbody tr{border-bottom:1px solid #f0f2f5} td{padding:9px 12px;vertical-align:middle} .tc{text-align:center}
.pass-row{border-left:4px solid #2d9e5a}.warn-row{border-left:4px solid #e08c4a}.fail-row{border-left:4px solid #e05c5c}.n\/a-row{border-left:4px solid #ccc}
.badge{display:inline-block;padding:2px 8px;border-radius:4px;color:#fff;font-size:10px;font-weight:700;white-space:nowrap}
.st{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700}
.st.pass{background:#e6f7ee;color:#2d9e5a;border:1px solid #2d9e5a}.st.warn{background:#fff4e5;color:#e08c4a;border:1px solid #e08c4a}.st.fail{background:#fde8e8;color:#e05c5c;border:1px solid #e05c5c}.st.n\/a{background:#f5f5f5;color:#888;border:1px solid #ccc}
.img-cell img{max-width:380px;max-height:260px;border-radius:6px;border:1px solid #ddd;object-fit:contain}
.no-img{color:#bbb;font-style:italic} small{color:#888}
.note-report-row{background:#f9f9fb}
.note-report-cell{padding:6px 12px 10px 20px;font-size:12px;color:#444;border-left:3px solid #d0d4db;font-style:italic}
.note-report-label{font-weight:700;font-style:normal;color:#1a1a2e;margin-right:6px}
.footer{margin-top:32px;border-top:1px solid #e0e0e0;padding-top:12px;font-size:11px;color:#aaa;text-align:center}
@media print{body{padding:16px} .grid{grid-template-columns:repeat(5,1fr)}}
</style></head><body>
<div class="hdr">
<h1>QA Automation Test Report</h1>
<div class="meta">
<div><strong>Evaluator:</strong> ${username}</div>
<div><strong>Testing Started:</strong> ${fmtDateTime(testingStart)}</div>
<div><strong>Terminated:</strong> ${fmtDateTime(testingEnd)}</div>
<div><strong>Duration:</strong> ${fmtDuration(testingEnd - testingStart)}</div>
</div>
</div>
<h2>Summary</h2>
<div class="grid">
<div class="card bl"><div class="v">${rows.length}</div><div class="l">Questions</div></div>
<div class="card gr"><div class="v">${totalEarned}/${totalPossible}</div><div class="l">Marks Earned</div></div>
<div class="card pu"><div class="v">${pct}%</div><div class="l">Overall Score</div></div>
<div class="card te"><div class="v">${passed}</div><div class="l">Passed (β‰₯80%)</div></div>
<div class="card re"><div class="v">${failed}</div><div class="l">Failed (<50%)</div></div>
</div>
<h2>Question Results</h2>
<table>
<thead><tr><th>#</th><th>Standard</th><th>Observation</th><th>Possible Marks</th><th>Earned Score</th><th>Status</th><th>Screenshot</th></tr></thead>
<tbody>${rowsHTML}</tbody>
</table>
<div class="footer">Report generated ${fmtDateTime(Date.now())} &bull; ${passed} passed &bull; ${warned} warned &bull; ${failed} failed</div>
<script>window.onload=()=>window.print();<\/script>
</body></html>`;
}
function openReport(rows, session) {
const html = buildReportHTML(rows, session);
window.open(URL.createObjectURL(new Blob([html], { type: "text/html" })), "_blank");
}
// ── ProfileMenu ───────────────────────────────────────────────────────────────
function ProfileMenu({ username, role, onLogout }) {
const [open, setOpen] = useState(false);
const [confirmLogout, setConfirmLogout] = useState(false);
const ref = useRef(null);
useEffect(() => {
const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
return (
<>
<div className="profile-menu-wrap">
<div className="profile-menu" ref={ref}>
<button className="profile-btn" onClick={() => setOpen(o => !o)}>
<span className="profile-avatar">{username.charAt(0).toUpperCase()}</span>
<span className="profile-name">{username}</span>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" style={{ marginLeft:2, opacity:0.7 }}>
<path d="M1 3l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
</button>
{open && (
<div className="profile-dropdown">
<div className="profile-dd-info">
<span className="profile-dd-avatar">{username.charAt(0).toUpperCase()}</span>
<span className="profile-dd-name">{username}</span>
</div>
<div className="profile-dd-divider" />
<button className="profile-dd-item" onClick={() => { setOpen(false); setConfirmLogout(true); }}>
Log Out
</button>
</div>
)}
</div>
{role && (
<div className="logged-in-label">
Logged in as <strong>{username}</strong> Β· <span className={`role-badge role-badge--${role}`}>{role}</span>
</div>
)}
</div>
{confirmLogout && (
<div className="modal-overlay">
<div className="modal">
<h3>Log Out?</h3>
<p>Are you sure you want to log out? Any unsaved progress will be lost.</p>
<div className="modal-actions">
<button className="btn-cancel" onClick={() => setConfirmLogout(false)}>Cancel</button>
<button className="btn-danger" onClick={() => { setConfirmLogout(false); onLogout(); }}>Yes, Log Out</button>
</div>
</div>
</div>
)}
</>
);
}
function playNotifSound() {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = "sine";
osc.frequency.setValueAtTime(880, ctx.currentTime);
osc.frequency.setValueAtTime(1100, ctx.currentTime + 0.12);
osc.frequency.setValueAtTime(880, ctx.currentTime + 0.24);
gain.gain.setValueAtTime(0.25, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.5);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.5);
} catch {}
}
// ── NotificationBell ──────────────────────────────────────────────────────────
function NotificationBell({ username, onNavigate }) {
const [notifs, setNotifs] = useState([]);
const [open, setOpen] = useState(false);
const ref = useRef(null);
const prevUnreadRef = useRef(0);
const load = () => dbGetNotifications(username).then(setNotifs).catch(() => {});
useEffect(() => {
load();
const id = setInterval(load, 30000);
return () => clearInterval(id);
}, [username]);
useEffect(() => {
const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const unread = notifs.filter(n => !n.read).length;
useEffect(() => {
if (unread > prevUnreadRef.current) playNotifSound();
prevUnreadRef.current = unread;
}, [unread]);
const handleClick = (n) => {
if (!n.read) dbMarkNotificationRead(n.id).then(load);
setOpen(false);
if (onNavigate) {
if (n.refType === "task") onNavigate("planner");
else if (n.refType === "ticket") onNavigate("tickets");
else if (n.refType === "submission") onNavigate("review");
}
};
const handleMarkAll = () => { dbMarkAllNotificationsRead(username).then(load); };
const handleClear = () => { dbClearNotifications(username).then(load); };
return (
<div className="notif-bell-wrap" ref={ref}>
<button className="notif-bell-btn" onClick={() => setOpen(o => !o)} title="Notifications">
πŸ””
{unread > 0 && <span className="notif-badge">{unread > 9 ? "9+" : unread}</span>}
</button>
{open && (
<div className="notif-dropdown">
<div className="notif-dd-header">
<span>Notifications</span>
<div style={{ display: "flex", gap: 8 }}>
{unread > 0 && <button className="notif-mark-all" onClick={handleMarkAll}>Mark all read</button>}
<button className="notif-mark-all" style={{ color: notifs.length > 0 ? "#e05c5c" : "#ccc", cursor: notifs.length > 0 ? "pointer" : "default" }}
onClick={notifs.length > 0 ? handleClear : undefined}>Clear all</button>
</div>
</div>
{notifs.length === 0
? <div className="notif-empty">No notifications yet.</div>
: notifs.slice(0, 20).map(n => (
<div key={n.id} className={`notif-item${n.read ? "" : " unread"}`} onClick={() => handleClick(n)}>
<div className="notif-msg">{n.message}</div>
<div className="notif-time">{fmtDateTime(n.createdAt)}</div>
</div>
))
}
</div>
)}
</div>
);
}
// ── PossibleMarksInput ────────────────────────────────────────────────────────
function PossibleMarksInput({ value, onChange, disabled }) {
return (
<input
type="number" min={1} max={100}
value={value ?? ""} placeholder="β€”" disabled={disabled}
onChange={(e) => {
if (e.target.value === "") { onChange(null); return; }
onChange(Math.max(1, parseInt(e.target.value) || 1));
}}
className={`marks-input${value === null ? " empty" : ""}`}
/>
);
}
// ── ScoreInput ────────────────────────────────────────────────────────────────
function ScoreInput({ value, max, onChange, disabled }) {
const pct = max > 0 && value !== null ? (value / max) * 100 : 0;
const color = value === null || max === null ? "#d0d4db"
: pct >= 80 ? "#2d9e5a" : pct >= 50 ? "#e08c4a" : "#e05c5c";
return (
<div className="score-cell">
<div className="score-row">
<input
type="number" min={0} max={max ?? undefined}
value={value ?? ""} placeholder="β€”"
disabled={disabled || max === null}
onChange={(e) => {
if (e.target.value === "") { onChange(null); return; }
const v = max !== null
? Math.min(max, Math.max(0, Number(e.target.value)))
: Math.max(0, Number(e.target.value));
onChange(v);
}}
className={`score-input${value === null ? " empty" : ""}`}
style={{ borderColor: color, opacity: (disabled || max === null) ? 0.6 : 1 }}
/>
<span className="score-max">/ {max ?? "β€”"}</span>
</div>
{max !== null && value !== null && (
<div className="score-bar-bg">
<div className="score-bar-fill" style={{ width:`${pct}%`, background:color }} />
</div>
)}
</div>
);
}
// ── CameraModal (webcam/desktop) ──────────────────────────────────────────────
function CameraModal({ onCapture, onClose }) {
const videoRef = useRef(null);
const streamRef = useRef(null);
const [ready, setReady] = useState(false);
const [error, setError] = useState(null);
const [facing, setFacing] = useState("environment");
const startStream = (facingMode) => {
streamRef.current?.getTracks().forEach((t) => t.stop());
setReady(false);
setError(null);
if (!navigator.mediaDevices?.getUserMedia) {
setError("Camera not supported in this browser. Use 'Live Photo' instead.");
return;
}
if (location.protocol !== "https:" && location.hostname !== "localhost") {
setError("Camera requires HTTPS. Use 'Live Photo' to take photos on this device.");
return;
}
navigator.mediaDevices
.getUserMedia({ video: { facingMode: { ideal: facingMode } }, audio: false })
.then((stream) => {
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
videoRef.current.onloadedmetadata = () => setReady(true);
}
})
.catch((err) => {
if (err.name === "NotAllowedError") setError("Camera permission denied. Allow camera access and try again.");
else if (err.name === "NotFoundError") setError("No camera found on this device.");
else setError("Camera unavailable. Use 'Live Photo' instead.");
});
};
useEffect(() => { startStream(facing); return () => streamRef.current?.getTracks().forEach((t) => t.stop()); }, []);
const switchCamera = () => {
const next = facing === "environment" ? "user" : "environment";
setFacing(next);
startStream(next);
};
const capture = async () => {
const video = videoRef.current;
const canvas = document.createElement("canvas");
const isFront = facing === "user";
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext("2d");
if (isFront) { ctx.translate(canvas.width, 0); ctx.scale(-1, 1); }
ctx.drawImage(video, 0, 0);
if (isFront) { ctx.setTransform(1, 0, 0, 1, 0, 0); }
// Watermark
const now = new Date();
const stamp = `${isFront ? "Front" : "Back"} | ${now.toLocaleString()}`;
const fontSize = Math.max(14, Math.round(canvas.width * 0.022));
const pad = Math.round(fontSize * 0.6);
ctx.font = `bold ${fontSize}px monospace`;
const textW = ctx.measureText(stamp).width;
const boxX = canvas.width - textW - pad * 2 - 10;
const boxY = canvas.height - fontSize - pad * 2 - 10;
ctx.fillStyle = "rgba(0,0,0,0.45)";
ctx.fillRect(boxX, boxY, textW + pad * 2, fontSize + pad * 2);
ctx.fillStyle = "#ffffff";
ctx.fillText(stamp, boxX + pad, boxY + pad + fontSize * 0.85);
const base64 = canvas.toDataURL("image/jpeg", 0.92);
const name = `photo_${Date.now()}.jpg`;
streamRef.current?.getTracks().forEach((t) => t.stop());
// Always show the photo immediately with a local blob URL
const blobUrl = URL.createObjectURL(dataURLtoBlob(base64));
onCapture({ url: blobUrl, base64, name });
// If Drive is configured, upload in the background.
// Keep base64 for display (never expose the Drive URL in img src).
if (driveEnabled) {
uploadPhoto(base64, name)
.then(drive => onCapture({ url: null, base64, name, driveFileId: drive.fileId }))
.catch(() => {}); // keep blob URL if Drive upload fails
}
};
return (
<div className="modal-overlay">
<div className="camera-modal">
<div className="camera-modal-header">
<h3>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" style={{ verticalAlign: "middle", marginRight: 6 }}>
<circle cx="12" cy="12" r="3.5" fill="#FFD600"/>
<circle cx="12" cy="12" r="6.5" stroke="#FFD600" strokeWidth="1.5" fill="none" opacity="0.6"/>
<circle cx="12" cy="12" r="10" stroke="#FFD600" strokeWidth="1.2" fill="none" opacity="0.3"/>
</svg>
Live Photo
</h3>
{!error && ready && (
<button className="camera-switch-btn" onClick={switchCamera} title="Switch camera">
πŸ”„ {facing === "environment" ? "Front" : "Back"}
</button>
)}
</div>
{error
? <p className="camera-error">{error}</p>
: <video ref={videoRef} autoPlay playsInline muted className={`camera-preview${facing === "user" ? " camera-mirror" : ""}`} />
}
<div className="modal-actions">
<button className="btn-cancel" onClick={onClose}>Cancel</button>
{!error && (
<button className="btn-confirm" onClick={capture} disabled={!ready}>
πŸ“· Capture
</button>
)}
</div>
</div>
</div>
);
}
// ── ScreenshotCell ────────────────────────────────────────────────────────────
function ScreenshotCell({ screenshot, onUpload, onDiscard, disabled, required, livePhotoOnly, onZoom }) {
const [ddOpen, setDdOpen] = useState(false);
const [showCamera, setShowCamera] = useState(false);
const ddRef = useRef(null);
useEffect(() => {
const handler = (e) => { if (ddRef.current && !ddRef.current.contains(e.target)) setDdOpen(false); };
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const handleChange = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setDdOpen(false);
const base64 = await fileToBase64(file);
const localUrl = URL.createObjectURL(file);
// Show immediately
onUpload({ url: localUrl, base64, name: file.name });
// Upload to Drive in background.
// Keep base64 for display (never expose the Drive URL in img src).
if (driveEnabled) {
uploadPhoto(base64, file.name)
.then(drive => onUpload({ url: null, base64, name: file.name, driveFileId: drive.fileId }))
.catch(() => {}); // keep local URL if Drive fails
}
e.target.value = "";
};
// When livePhotoOnly (tester mode): single button, no dropdown
if (livePhotoOnly) {
const shotButton = screenshot
? <button className="btn-upload small" onClick={() => setShowCamera(true)}>πŸ“· Replace</button>
: <button className={`btn-upload${required ? " required" : ""}`} onClick={() => setShowCamera(true)}>
πŸ“· Live Photo {required && <span className="req-dot">*</span>}
</button>;
return (
<>
{showCamera && (
<CameraModal
onCapture={(s) => { setShowCamera(false); onUpload(s); }}
onClose={() => setShowCamera(false)}
/>
)}
{screenshot ? (
<div className="screenshot-cell has-image">
<div className="screenshot-thumb-wrap">
<img src={screenshot.base64 ?? screenshot.url} alt={screenshot.name} className="screenshot-thumb"
onClick={() => onZoom ? onZoom(screenshot) : window.open(screenshot.base64 ?? screenshot.url, "_blank")}
title={onZoom ? "Click to zoom" : "Click to expand"} style={{ cursor: "pointer" }} />
{!disabled && (
<button className="btn-discard-x" onClick={onDiscard} title="Remove screenshot">βœ•</button>
)}
</div>
<div className="screenshot-info">
<span className="screenshot-name">{screenshot.name}</span>
{!disabled && shotButton}
</div>
</div>
) : (
<div className="screenshot-cell">
{disabled ? <span className="no-screenshot">β€”</span> : shotButton}
</div>
)}
</>
);
}
// Admin / reviewer: dropdown with both Upload and Live Photo
const shotDropdown = (
<div className="shot-dd-wrap" ref={ddRef}>
{screenshot
? <button className="btn-upload small" onClick={() => setDdOpen(o => !o)}>Replace β–Ύ</button>
: <button className={`btn-upload${required ? " required" : ""}`} onClick={() => setDdOpen(o => !o)}>
+ Add {required && <span className="req-dot">*</span>} β–Ύ
</button>
}
{ddOpen && (
<div className="shot-dropdown">
<label className="shot-dd-item">
πŸ“ Upload Image
<input type="file" accept="image/*" onChange={handleChange} hidden />
</label>
<div className="shot-dd-item" onMouseDown={() => { setDdOpen(false); setShowCamera(true); }}>
πŸ“· Live Photo
</div>
</div>
)}
</div>
);
return (
<>
{showCamera && (
<CameraModal
onCapture={(s) => { setShowCamera(false); onUpload(s); }}
onClose={() => setShowCamera(false)}
/>
)}
{screenshot ? (
<div className="screenshot-cell has-image">
<div className="screenshot-thumb-wrap">
<img src={screenshot.base64 ?? screenshot.url} alt={screenshot.name} className="screenshot-thumb"
onClick={() => onZoom ? onZoom(screenshot) : window.open(screenshot.base64 ?? screenshot.url, "_blank")}
title={onZoom ? "Click to zoom" : "Click to expand"} style={{ cursor: "pointer" }} />
{!disabled && (
<button className="btn-discard-x" onClick={onDiscard} title="Remove screenshot">βœ•</button>
)}
</div>
<div className="screenshot-info">
<span className="screenshot-name">{screenshot.name}</span>
{!disabled && shotDropdown}
</div>
</div>
) : (
<div className="screenshot-cell">
{disabled ? <span className="no-screenshot">β€”</span> : shotDropdown}
</div>
)}
</>
);
}
// ── SummaryBar ────────────────────────────────────────────────────────────────
function SummaryBar({ rows }) {
const totalPossible = rows.reduce((s, r) => s + (r.possibleMarks ?? 0), 0);
const totalEarned = rows.reduce((s, r) => s + (r.earnedScore ?? 0), 0);
const pct = totalPossible > 0 ? ((totalEarned / totalPossible) * 100).toFixed(1) : "β€”";
const passed = rows.filter(r => r.possibleMarks && r.earnedScore !== null && r.earnedScore / r.possibleMarks >= 0.8).length;
const failed = rows.filter(r => r.possibleMarks && r.earnedScore !== null && r.earnedScore / r.possibleMarks < 0.5).length;
return (
<div className="summary-bar">
<div className="summary-card blue"><span className="summary-value">{rows.length}</span><span className="summary-label">Total Questions</span></div>
<div className="summary-card green"><span className="summary-value">{totalEarned}<small>/{totalPossible}</small></span><span className="summary-label">Marks Earned</span></div>
<div className="summary-card purple"><span className="summary-value">{pct}{pct !== "β€”" ? "%" : ""}</span><span className="summary-label">Overall Score</span></div>
<div className="summary-card teal"><span className="summary-value">{passed}</span><span className="summary-label">Passed (β‰₯ 80%)</span></div>
<div className="summary-card red"><span className="summary-value">{failed}</span><span className="summary-label">Failed (&lt; 50%)</span></div>
</div>
);
}
// ── ValidationModal ───────────────────────────────────────────────────────────
function ValidationModal({ missing, onClose }) {
return (
<div className="modal-overlay">
<div className="modal">
<h3>Required Fields Missing</h3>
<p>Complete the following before proceeding ({missing.length} question{missing.length > 1 ? "s" : ""}):</p>
<ul className="validation-list">
{missing.slice(0, 8).map(m => (
<li key={m.id}>
<span className="val-badge">{m.standard}</span>
<span className="val-fields">Missing: {m.fields.join(", ")}</span>
</li>
))}
{missing.length > 8 && <li className="val-more">…and {missing.length - 8} more</li>}
</ul>
<div className="modal-actions">
<button className="btn-confirm" onClick={onClose}>OK, I'll fix these</button>
</div>
</div>
</div>
);
}
// ── TerminateModal ────────────────────────────────────────────────────────────
function TerminateModal({ onConfirm, onCancel }) {
return (
<div className="modal-overlay">
<div className="modal">
<h3>Terminate Testing Session?</h3>
<p>This will end the session and take you to the completion screen. You will not be able to edit scores after termination.</p>
<div className="modal-actions">
<button className="btn-cancel" onClick={onCancel}>Cancel</button>
<button className="btn-danger" onClick={onConfirm}>Yes, Terminate</button>
</div>
</div>
</div>
);
}
// ── Tester report ─────────────────────────────────────────────────────────────
function buildTesterReportHTML(rows, session) {
const { username, testingStart, testingEnd } = session;
const uploaded = rows.filter(r => r.screenshot).length;
const missing = rows.length - uploaded;
const rowsHTML = rows.map((row) => {
const color = CATEGORY_COLORS[row.standard.split("-")[0]] ?? "#888";
const imgSrc2 = row.screenshot?.base64 ?? row.screenshot?.url;
const imgHTML = imgSrc2
? `<img src="${imgSrc2}" alt="${row.screenshot.name}" />`
: `<span class="no-img">Not uploaded</span>`;
const status = row.screenshot ? "DONE" : "MISSING";
return `
<tr class="${row.screenshot ? "done-row" : "miss-row"}">
<td class="tc">${row.id}</td>
<td><span class="badge" style="background:${color}">${row.standard}</span></td>
<td>${row.observation}</td>
<td class="tc"><span class="st ${status.toLowerCase()}">${status}</span></td>
<td class="tc img-cell">${imgHTML}</td>
</tr>`;
}).join("\n");
return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"/>
<title>Tester Report β€” ${username}</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;font-size:13px;color:#1a1a2e;background:#fff;padding:32px}
h1{font-size:22px;font-weight:700} h2{font-size:13px;font-weight:700;margin:24px 0 10px;text-transform:uppercase;letter-spacing:.5px;color:#555}
.hdr{display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:12px;border-bottom:3px solid #1a1a2e;padding-bottom:16px;margin-bottom:24px}
.hdr .meta{font-size:12px;color:#555;line-height:1.9} .hdr .meta strong{color:#1a1a2e}
.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:28px}
.card{border-radius:8px;padding:12px 16px;border:1px solid #e0e0e0}
.card .v{font-size:26px;font-weight:700;line-height:1} .card .l{font-size:11px;color:#666;text-transform:uppercase;letter-spacing:.4px;margin-top:4px}
.card.bl{border-top:4px solid #4f86c6}.card.te{border-top:4px solid #2d9e5a}.card.re{border-top:4px solid #e05c5c}.card.or{border-top:4px solid #e08c4a}
table{width:100%;border-collapse:collapse;font-size:12px}
thead tr{background:#1a1a2e;color:#fff} thead th{padding:10px 12px;text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:.5px;white-space:nowrap}
tbody tr{border-bottom:1px solid #f0f2f5} td{padding:9px 12px;vertical-align:middle} .tc{text-align:center}
.done-row{border-left:4px solid #2d9e5a} .miss-row{border-left:4px solid #e05c5c}
.badge{display:inline-block;padding:2px 8px;border-radius:4px;color:#fff;font-size:10px;font-weight:700;white-space:nowrap}
.st{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700}
.st.done{background:#e6f7ee;color:#2d9e5a;border:1px solid #2d9e5a} .st.missing{background:#fde8e8;color:#e05c5c;border:1px solid #e05c5c}
.img-cell img{max-width:380px;max-height:260px;border-radius:6px;border:1px solid #ddd;object-fit:contain}
.no-img{color:#bbb;font-style:italic}
.footer{margin-top:32px;border-top:1px solid #e0e0e0;padding-top:12px;font-size:11px;color:#aaa;text-align:center}
@media print{body{padding:16px}}
</style></head><body>
<div class="hdr">
<div><h1>Tester Screenshot Report</h1><p style="color:#555;font-size:13px;margin-top:4px">Screenshot submission by ${username}</p></div>
<div class="meta">
<div><strong>Tester:</strong> ${username}</div>
<div><strong>Session Started:</strong> ${fmtDateTime(testingStart)}</div>
<div><strong>Submitted:</strong> ${fmtDateTime(testingEnd)}</div>
<div><strong>Duration:</strong> ${fmtDuration(testingEnd - testingStart)}</div>
</div>
</div>
<h2>Summary</h2>
<div class="grid">
<div class="card bl"><div class="v">${rows.length}</div><div class="l">Total Questions</div></div>
<div class="card te"><div class="v">${uploaded}</div><div class="l">Uploaded</div></div>
<div class="card re"><div class="v">${missing}</div><div class="l">Missing</div></div>
<div class="card or"><div class="v">${((uploaded/rows.length)*100).toFixed(0)}%</div><div class="l">Completion</div></div>
</div>
<h2>Question Screenshots</h2>
<table>
<thead><tr><th>#</th><th>Standard</th><th>Observation</th><th>Status</th><th>Screenshot</th></tr></thead>
<tbody>${rowsHTML}</tbody>
</table>
<div class="footer">Report generated ${fmtDateTime(Date.now())} &bull; ${uploaded} uploaded &bull; ${missing} missing</div>
<script>window.onload=()=>window.print();<\/script>
</body></html>`;
}
function openTesterReport(rows, session) {
const html = buildTesterReportHTML(rows, session);
window.open(URL.createObjectURL(new Blob([html], { type: "text/html" })), "_blank");
}
function fmtReportName(sub) {
const d = new Date(sub.endTime);
const date = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
const time = `${String(d.getHours()).padStart(2,"0")}-${String(d.getMinutes()).padStart(2,"0")}`;
const type = sub.type === "tester" ? "by_tester" : "automation_testing";
return `${sub.username}_${date}_${time}_${type}`;
}
function makeSubmission(type, username, session, rows) {
return {
id: `${username}_${Date.now()}`,
type,
username,
profileName: session.profileName ?? null,
productId: session.productId ?? null,
productName: session.productName ?? null,
startTime: session.testingStart,
endTime: Date.now(),
rows: rows.map(r => ({
...r,
screenshot: r.screenshot ? {
// Blob URLs are session-only; strip them so the display falls back to base64
url: r.screenshot.url?.startsWith("blob:") ? null : (r.screenshot.url ?? null),
base64: r.screenshot.base64 ?? null,
name: r.screenshot.name,
driveFileId: r.screenshot.driveFileId ?? null,
} : null,
})),
review: null,
};
}
function buildReviewedReportHTML(sub) {
const { username, startTime, endTime, review } = sub;
const rowsHTML = sub.rows.map((row) => {
const color = CATEGORY_COLORS[row.standard.split("-")[0]] ?? "#888";
const rv = review.rows.find(r => r.id === row.id) ?? {};
const imgSrc3 = row.screenshot?.base64 ?? row.screenshot?.url;
const imgHTML = imgSrc3
? `<img src="${imgSrc3}" alt="${row.screenshot.name}" />`
: `<span class="no-img">β€”</span>`;
return `
<tr>
<td class="tc">${row.id}</td>
<td><span class="badge" style="background:${color}">${row.standard}</span></td>
<td>${row.observation}</td>
<td class="tc img-cell">${imgHTML}</td>
<td class="tc"><strong>${rv.marks ?? "β€”"}</strong> / ${row.possibleMarks}</td>
<td>${rv.comment ? rv.comment : '<span style="color:#bbb">β€”</span>'}</td>
</tr>`;
}).join("\n");
const total = sub.rows.reduce((s, r) => s + (r.possibleMarks ?? 0), 0);
const earned = review.rows.reduce((s, r) => s + (r.marks ?? 0), 0);
const pct = total > 0 ? ((earned/total)*100).toFixed(1) : "β€”";
return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"/>
<title>Review Report β€” ${username}</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;font-size:13px;color:#1a1a2e;padding:32px}
h1{font-size:22px;font-weight:700}h2{font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#555;margin:24px 0 10px}
.hdr{display:flex;justify-content:space-between;flex-wrap:wrap;gap:12px;border-bottom:3px solid #1a1a2e;padding-bottom:16px;margin-bottom:24px}
.meta{font-size:12px;color:#555;line-height:1.9}.meta strong{color:#1a1a2e}
.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:28px}
.card{border-radius:8px;padding:12px 16px;border:1px solid #e0e0e0}.card .v{font-size:24px;font-weight:700}.card .l{font-size:11px;color:#666;text-transform:uppercase;margin-top:4px}
.card.bl{border-top:4px solid #4f86c6}.card.gr{border-top:4px solid #2d9e5a}.card.pu{border-top:4px solid #9b73c8}.card.or{border-top:4px solid #e08c4a}
table{width:100%;border-collapse:collapse;font-size:12px}thead tr{background:#1a1a2e;color:#fff}
thead th{padding:10px 12px;text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:.5px;white-space:nowrap}
tbody tr{border-bottom:1px solid #f0f2f5}td{padding:9px 12px;vertical-align:middle}.tc{text-align:center}
.badge{display:inline-block;padding:2px 8px;border-radius:4px;color:#fff;font-size:10px;font-weight:700;white-space:nowrap}
.img-cell img{max-width:380px;max-height:260px;border-radius:6px;border:1px solid #ddd;object-fit:contain}.no-img{color:#bbb;font-style:italic}
.footer{margin-top:32px;border-top:1px solid #e0e0e0;padding-top:12px;font-size:11px;color:#aaa;text-align:center}
</style></head><body>
<div class="hdr">
<h1>Reviewed Tester Report</h1>
<div class="meta">
<div><strong>Tester:</strong> ${username}</div>
<div><strong>Reviewer:</strong> ${review.reviewerUsername}</div>
<div><strong>Reviewed:</strong> ${fmtDateTime(review.reviewedAt)}</div>
<div><strong>Session:</strong> ${fmtDateTime(startTime)} β†’ ${fmtDateTime(endTime)}</div>
</div>
</div>
<h2>Summary</h2>
<div class="grid">
<div class="card bl"><div class="v">${sub.rows.length}</div><div class="l">Questions</div></div>
<div class="card gr"><div class="v">${earned}/${total}</div><div class="l">Marks Earned</div></div>
<div class="card pu"><div class="v">${pct}%</div><div class="l">Score</div></div>
<div class="card or"><div class="v">${sub.rows.filter(r=>r.screenshot).length}</div><div class="l">Photos Submitted</div></div>
</div>
<h2>Question Results</h2>
<table>
<thead><tr><th>#</th><th>Standard</th><th>Observation</th><th>Photo</th><th>Marks</th><th>Reviewer Comment</th></tr></thead>
<tbody>${rowsHTML}</tbody>
</table>
<div class="footer">Report generated ${fmtDateTime(Date.now())}</div>
<script>window.onload=()=>window.print();<\/script>
</body></html>`;
}
function openReviewedReport(sub) {
window.open(URL.createObjectURL(new Blob([buildReviewedReportHTML(sub)], { type: "text/html" })), "_blank");
}
// ── ImageZoomModal ────────────────────────────────────────────────────────────
function ImageZoomModal({ src, alt, onClose }) {
const [zoom, setZoom] = useState(1);
const [pos, setPos] = useState({ x: 0, y: 0 });
const [dragging, setDragging] = useState(false);
const dragOrigin = useRef(null);
const bodyRef = useRef(null);
// Reset pan when zoom returns to 1
useEffect(() => { if (zoom <= 1) setPos({ x: 0, y: 0 }); }, [zoom]);
// Escape key
useEffect(() => {
const onKey = (e) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
// Scroll-wheel zoom
useEffect(() => {
const el = bodyRef.current;
if (!el) return;
const onWheel = (e) => {
e.preventDefault();
setZoom(z => Math.min(5, Math.max(0.25, parseFloat((z + (e.deltaY < 0 ? 0.25 : -0.25)).toFixed(2)))));
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, []);
const onMouseDown = (e) => {
if (zoom <= 1) return;
setDragging(true);
dragOrigin.current = { x: e.clientX - pos.x, y: e.clientY - pos.y };
e.preventDefault();
};
const onMouseMove = (e) => {
if (!dragging || !dragOrigin.current) return;
setPos({ x: e.clientX - dragOrigin.current.x, y: e.clientY - dragOrigin.current.y });
};
const stopDrag = () => { setDragging(false); dragOrigin.current = null; };
const changeZoom = (next) => {
setZoom(next);
if (next <= 1) setPos({ x: 0, y: 0 });
};
return (
<div className="modal-overlay zoom-overlay" onClick={onClose}>
<div className="zoom-modal" onClick={e => e.stopPropagation()}>
<div className="zoom-toolbar">
<button className="zoom-btn" onClick={() => changeZoom(Math.max(0.25, parseFloat((zoom - 0.25).toFixed(2))))}>-</button>
<span className="zoom-pct">{Math.round(zoom * 100)}%</span>
<button className="zoom-btn" onClick={() => changeZoom(Math.min(5, parseFloat((zoom + 0.25).toFixed(2))))}>οΌ‹</button>
<button className="zoom-btn zoom-reset-btn" onClick={() => changeZoom(1)}>Reset</button>
<button className="zoom-close-btn" onClick={onClose}>βœ•</button>
</div>
<div
className="zoom-body"
ref={bodyRef}
style={{ cursor: zoom > 1 ? (dragging ? "grabbing" : "grab") : "default" }}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={stopDrag}
onMouseLeave={stopDrag}
>
<img
src={src}
alt={alt || "screenshot"}
draggable={false}
style={{
transform: `translate(${pos.x}px, ${pos.y}px) scale(${zoom})`,
transformOrigin: "center center",
transition: dragging ? "none" : "transform 0.15s ease",
userSelect: "none",
pointerEvents: "none",
}}
/>
</div>
<p className="zoom-hint">Scroll or οΌ‹/- to zoom Β· Drag to pan when zoomed Β· Esc to close</p>
</div>
</div>
);
}
// ── ReviewPortal ──────────────────────────────────────────────────────────────
function ReviewPortal({ currentUser, currentRole, onBack, onLogout, onNavigate }) {
const [allSubs, setAllSubs] = useState([]);
const [loading, setLoading] = useState(true);
const [userProducts, setUserProducts] = useState(null);
const [selected, setSelected] = useState(null);
const [reviewData, setReviewData] = useState([]);
const [reviewRows, setReviewRows] = useState([]);
const [zoomImg, setZoomImg] = useState(null);
const [sortCol, setSortCol] = useState("date");
const [sortDir, setSortDir] = useState("desc");
const isAdmin = currentRole === "admin";
useEffect(() => {
const tasks = [dbGetSubmissions()];
if (!isAdmin) tasks.push(dbGetUserProducts(currentUser));
Promise.all(tasks).then(([subs, prodIds]) => {
const testerSubs = subs.filter(s => s.type === "tester");
const filtered = isAdmin || !prodIds || prodIds.length === 0
? testerSubs
: testerSubs.filter(s => !s.productId || prodIds.includes(s.productId));
setAllSubs(filtered);
setUserProducts(prodIds ?? null);
setLoading(false);
}).catch(() => setLoading(false));
}, []);
const openReview = (sub) => {
setSelected(sub);
setReviewRows(sub.rows);
setReviewData(sub.rows.map(r => ({
id: r.id,
marks: sub.review?.rows.find(rv => rv.id === r.id)?.marks ?? null,
comment: sub.review?.rows.find(rv => rv.id === r.id)?.comment ?? "",
})));
};
const submitReview = async () => {
const updated = {
...selected,
rows: reviewRows,
review: { reviewerUsername: currentUser, reviewedAt: Date.now(), rows: reviewData },
};
await dbSaveSubmission(updated);
const [subs, prodIds] = await Promise.all([
dbGetSubmissions(),
isAdmin ? Promise.resolve(null) : dbGetUserProducts(currentUser),
]);
const testerSubs = subs.filter(s => s.type === "tester");
const filtered = isAdmin || !prodIds || prodIds.length === 0
? testerSubs
: testerSubs.filter(s => !s.productId || prodIds.includes(s.productId));
setAllSubs(filtered);
setSelected(null);
};
if (selected) {
return (
<div className="app">
{zoomImg && <ImageZoomModal src={zoomImg.src} alt={zoomImg.alt} onClose={() => setZoomImg(null)} />}
<header className="app-header">
<div className="header-left">
<h1>Reviewing: {selected.username}'s Submission</h1>
<p className="header-sub">Submitted: {fmtDateTime(selected.endTime)}</p>
</div>
<div className="header-right">
<button className="btn-secondary-sm" onClick={() => setSelected(null)}>← Back</button>
<button className="btn-view-report" onClick={submitReview}>Submit Review</button>
<ProfileMenu username={currentUser} role={currentRole} onLogout={onLogout} />
</div>
</header>
<main className="app-main">
<div className="table-wrap">
<table className="qa-table review-table">
<thead>
<tr>
<th className="col-num">#</th>
<th className="col-standard">Standard</th>
<th className="col-obs">Observation</th>
<th className="col-shot">Photo</th>
<th className="col-score">Marks</th>
<th className="col-comment">Comment</th>
</tr>
</thead>
<tbody>
{selected.rows.map((row, i) => {
const color = CATEGORY_COLORS[row.standard.split("-")[0]] ?? "#888";
const rd = reviewData[i] ?? { marks: null, comment: "" };
return (
<tr key={row.id} className={rd.marks !== null ? "row-pass" : "row-neutral"}>
<td className="col-num">{row.id}</td>
<td><span className="std-badge" style={{ background:color }}>{row.standard}</span></td>
<td className="col-obs">{row.observation}</td>
<td>
<ScreenshotCell
screenshot={reviewRows[i]?.screenshot}
onUpload={(s) => setReviewRows(p => p.map((r, j) => j === i ? { ...r, screenshot: s } : r))}
onDiscard={() => setReviewRows(p => p.map((r, j) => j === i ? { ...r, screenshot: null } : r))}
onZoom={(s) => setZoomImg({ src: s.base64 ?? s.url, alt: s.name })}
/>
</td>
<td>
<div className="score-row">
<input type="number" min={0} max={row.possibleMarks}
value={rd.marks ?? ""} placeholder="β€”"
className={`score-input${rd.marks === null ? " empty" : ""}`}
onChange={e => {
const v = e.target.value === "" ? null : Math.min(row.possibleMarks, Math.max(0, Number(e.target.value)));
setReviewData(p => p.map((r, j) => j === i ? { ...r, marks: v } : r));
}} />
<span className="score-max">/ {row.possibleMarks}</span>
</div>
</td>
<td>
<textarea className="note-textarea review-comment" rows={2}
placeholder="Add comment…"
value={rd.comment}
onChange={e => setReviewData(p => p.map((r, j) => j === i ? { ...r, comment: e.target.value } : r))} />
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</main>
</div>
);
}
const toggleSort = (col) => {
if (sortCol === col) setSortDir(d => d === "asc" ? "desc" : "asc");
else { setSortCol(col); setSortDir("asc"); }
};
const sortKey = (sub) => {
const uploaded = sub.rows.filter(r => r.screenshot).length;
if (sortCol === "tester") return sub.username.toLowerCase();
if (sortCol === "date") return sub.endTime;
if (sortCol === "photos") return uploaded / sub.rows.length;
if (sortCol === "status") return sub.review ? 0 : 1;
if (sortCol === "reviewer") return (sub.review?.reviewerUsername ?? "").toLowerCase();
return 0;
};
const sorted = [...allSubs].sort((a, b) => {
const ka = sortKey(a), kb = sortKey(b);
const cmp = ka < kb ? -1 : ka > kb ? 1 : 0;
return sortDir === "asc" ? cmp : -cmp;
});
const SortTh = ({ col, children, style }) => {
const active = sortCol === col;
return (
<th style={{ cursor: "pointer", userSelect: "none", whiteSpace: "nowrap", ...style }}
onClick={() => toggleSort(col)}>
{children}
<span className={`sort-arrow ${active ? "sort-active" : ""}`}>
{active ? (sortDir === "asc" ? " β–²" : " β–Ό") : " β‡…"}
</span>
</th>
);
};
return (
<div className="app">
<header className="app-header">
<div className="header-left"><h1>Review Tester Submissions</h1></div>
<div className="header-right">
<button className="btn-secondary-sm" onClick={onBack}>← Portal</button>
<NotificationBell username={currentUser} onNavigate={onNavigate} />
<ProfileMenu username={currentUser} role={currentRole} onLogout={onLogout} />
</div>
</header>
<main className="app-main">
{loading ? <div className="portal-empty"><p>Loading submissions…</p></div> : allSubs.length === 0
? <div className="portal-empty">
<div className="portal-empty-icon">πŸ“­</div>
<h3>No Tester Submissions Yet</h3>
<p>Submissions will appear here once testers submit their screenshots.</p>
</div>
: <div className="table-wrap">
<table className="qa-table">
<thead>
<tr>
<SortTh col="tester">Tester</SortTh>
<SortTh col="date">Submitted At</SortTh>
<th>Product</th>
<th>Test</th>
<SortTh col="photos">Photos</SortTh>
<SortTh col="status">Status</SortTh>
<SortTh col="reviewer">Reviewed By</SortTh>
<th>Action</th>
</tr>
</thead>
<tbody>
{sorted.map(sub => {
const uploaded = sub.rows.filter(r => r.screenshot).length;
const reviewed = !!sub.review;
return (
<tr key={sub.id}>
<td><strong>{sub.username}</strong></td>
<td>{fmtDateTime(sub.endTime)}</td>
<td>{sub.productName ? <span className="product-chip">{sub.productName}</span> : <span style={{ color: "#bbb" }}>β€”</span>}</td>
<td><span className="tp-name-badge">{sub.profileName ?? "β€”"}</span></td>
<td><span className={`upload-count ${uploaded === sub.rows.length ? "full" : "partial"}`}>{uploaded}/{sub.rows.length}</span></td>
<td><span className={`status-badge ${reviewed ? "badge-done" : "badge-pending"}`}>{reviewed ? "Reviewed" : "Pending"}</span></td>
<td>{sub.review?.reviewerUsername ?? "β€”"}</td>
<td><button className="btn-view-report sm" onClick={() => openReview(sub)}>{reviewed ? "Edit Review" : "Start Review"}</button></td>
</tr>
);
})}
</tbody>
</table>
</div>
}
</main>
</div>
);
}
// ── Download helper ───────────────────────────────────────────────────────────
function downloadReportFile(html, filename) {
const a = Object.assign(document.createElement("a"), {
href: URL.createObjectURL(new Blob([html], { type: "text/html" })),
download: filename,
});
a.click();
}
// ── InlineReportViewer ────────────────────────────────────────────────────────
function InlineReportViewer({ sub, currentUser, currentRole, onBack, onLogout }) {
const [zoomImg, setZoomImg] = useState(null);
const [showReview, setShowReview] = useState(false);
const sess = { username: sub.username, testingStart: sub.startTime, testingEnd: sub.endTime };
const isAdmin = sub.type === "admin";
const isTester = sub.type === "tester";
const totalPossible = sub.rows.reduce((s, r) => s + (r.possibleMarks ?? 0), 0);
const totalEarned = sub.rows.reduce((s, r) => s + (r.earnedScore ?? 0), 0);
const pct = totalPossible > 0 ? ((totalEarned / totalPossible) * 100).toFixed(1) : "β€”";
const passed = sub.rows.filter(r => r.possibleMarks && r.earnedScore !== null && r.earnedScore / r.possibleMarks >= 0.8).length;
const failed = sub.rows.filter(r => r.possibleMarks && r.earnedScore !== null && r.earnedScore / r.possibleMarks < 0.5).length;
const uploaded = sub.rows.filter(r => r.screenshot).length;
const handleDownload = () => {
if (isTester) openTesterReport(sub.rows, { ...sess, mode: "tester" });
else openReport(sub.rows, sess);
};
const reviewEarned = sub.review?.rows.reduce((s, r) => s + (r.marks ?? 0), 0) ?? 0;
return (
<div className="app">
{zoomImg && <ImageZoomModal src={zoomImg.src} alt={zoomImg.alt} onClose={() => setZoomImg(null)} />}
<header className="app-header">
<div className="header-left">
<h1 style={{ fontSize: "1rem", wordBreak: "break-all" }}>{fmtReportName(sub)}</h1>
<p className="header-sub">
{sub.username} Β· {fmtDateTime(sub.endTime)} Β· {fmtDuration(sub.endTime - sub.startTime)}
</p>
</div>
<div className="header-right">
<button className="btn-pdf btn-pdf-lg" onClick={handleDownload}>⬇ Download PDF</button>
{isTester && sub.review && (
<button className="btn-secondary-sm" onClick={() => setShowReview(v => !v)}>
{showReview ? "Hide Review" : "View Review"}
</button>
)}
<button className="btn-secondary-sm" onClick={onBack}>← All Reports</button>
<ProfileMenu username={currentUser} role={currentRole} onLogout={onLogout} />
</div>
</header>
<main className="app-main">
{/* Summary */}
{isAdmin && (
<div className="summary-bar">
<div className="summary-card blue"><span className="summary-value">{sub.rows.length}</span><span className="summary-label">Questions</span></div>
<div className="summary-card green"><span className="summary-value">{totalEarned}<small>/{totalPossible}</small></span><span className="summary-label">Marks</span></div>
<div className="summary-card purple"><span className="summary-value">{pct}{pct !== "β€”" ? "%" : ""}</span><span className="summary-label">Score</span></div>
<div className="summary-card teal"><span className="summary-value">{passed}</span><span className="summary-label">Passed</span></div>
<div className="summary-card red"><span className="summary-value">{failed}</span><span className="summary-label">Failed</span></div>
</div>
)}
{isTester && (
<div className="summary-bar">
<div className="summary-card blue"><span className="summary-value">{sub.rows.length}</span><span className="summary-label">Questions</span></div>
<div className="summary-card green"><span className="summary-value">{uploaded}</span><span className="summary-label">Uploaded</span></div>
<div className="summary-card red"><span className="summary-value">{sub.rows.length - uploaded}</span><span className="summary-label">Missing</span></div>
<div className="summary-card purple"><span className="summary-value">{((uploaded / sub.rows.length) * 100).toFixed(0)}%</span><span className="summary-label">Completion</span></div>
{sub.review && (
<div className="summary-card teal">
<span className="summary-value">{reviewEarned}<small>/{totalPossible}</small></span>
<span className="summary-label">Review Score</span>
</div>
)}
</div>
)}
{/* Main question table */}
<div className="table-wrap">
<table className="qa-table rv-table">
<thead>
<tr>
<th className="col-num">#</th>
<th className="col-standard">Standard</th>
<th className="col-obs">Observation</th>
{isAdmin && <th style={{ width: 110, textAlign: "center" }}>Marks</th>}
{isAdmin && <th style={{ width: 80, textAlign: "center" }}>Status</th>}
<th className="col-shot">Screenshot</th>
</tr>
</thead>
<tbody>
{sub.rows.map(row => {
const color = CATEGORY_COLORS[row.standard.split("-")[0]] ?? "#888";
const p = row.possibleMarks && row.earnedScore !== null ? row.earnedScore / row.possibleMarks : null;
const status = p !== null ? (p >= 0.8 ? "PASS" : p < 0.5 ? "FAIL" : "WARN") : null;
const rowCls = status === "PASS" ? "row-pass" : status === "FAIL" ? "row-fail" : status === "WARN" ? "row-warn" : "row-neutral";
return (
<tr key={row.id} className={rowCls}>
<td className="col-num">{row.id}</td>
<td><span className="std-badge" style={{ background: color }}>{row.standard}</span></td>
<td className="col-obs">
{row.observation}
{row.evalNote?.trim() && (
<div className="rv-note">{row.evalNote.trim()}</div>
)}
</td>
{isAdmin && (
<td style={{ textAlign: "center" }}>
<strong>{row.earnedScore ?? "β€”"}</strong>
<span className="score-max"> / {row.possibleMarks ?? "β€”"}</span>
</td>
)}
{isAdmin && (
<td style={{ textAlign: "center" }}>
{status && <span className={`rv-status rv-${status.toLowerCase()}`}>{status}</span>}
</td>
)}
<td>
{(row.screenshot?.base64 ?? row.screenshot?.url)
? <div className="screenshot-thumb-wrap rv-thumb-wrap">
<img src={row.screenshot.base64 ?? row.screenshot.url} className="screenshot-thumb rv-thumb"
onClick={() => setZoomImg({ src: row.screenshot.base64 ?? row.screenshot.url, alt: row.screenshot.name })}
title="Click to zoom" />
<div className="review-img-hint">Click to zoom</div>
</div>
: <span className="no-screenshot">β€”</span>
}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Review section */}
{isTester && sub.review && showReview && (
<div className="rv-review-section">
<h3 className="rv-review-title">
Review by <strong>{sub.review.reviewerUsername}</strong> Β· {fmtDateTime(sub.review.reviewedAt)}
</h3>
<div className="table-wrap">
<table className="qa-table">
<thead>
<tr>
<th className="col-num">#</th>
<th className="col-standard">Standard</th>
<th style={{ width: 110, textAlign: "center" }}>Marks</th>
<th>Comment</th>
</tr>
</thead>
<tbody>
{sub.review.rows.map(rv => {
const q = sub.rows.find(r => r.id === rv.id);
const color = CATEGORY_COLORS[q?.standard.split("-")[0]] ?? "#888";
return (
<tr key={rv.id}>
<td className="col-num">{rv.id}</td>
<td><span className="std-badge" style={{ background: color }}>{q?.standard}</span></td>
<td style={{ textAlign: "center" }}>
<strong>{rv.marks ?? "β€”"}</strong>
<span className="score-max"> / {q?.possibleMarks ?? "β€”"}</span>
</td>
<td style={{ color: rv.comment ? "#333" : "#bbb", fontStyle: rv.comment ? "normal" : "italic", fontSize: "13px" }}>
{rv.comment || "β€”"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</main>
</div>
);
}
// ── ReportsPortal ─────────────────────────────────────────────────────────────
function ReportsPortal({ currentUser, currentRole, onBack, onLogout, onNavigate }) {
const [allSubs, setAllSubs] = useState([]);
const [loading, setLoading] = useState(true);
const [viewingSub, setViewingSub] = useState(null);
const [sortCol, setSortCol] = useState("date");
const [sortDir, setSortDir] = useState("desc");
const [deleteConfirm, setDeleteConfirm] = useState(null);
const isAdmin = currentRole === "admin";
const isReviewer = currentRole === "reviewer";
const isTester = currentRole === "tester";
const canManage = isAdmin || isReviewer;
useEffect(() => {
dbGetSubmissions().then(subs => {
const filtered = isTester ? subs.filter(s => s.review) : subs;
setAllSubs(filtered);
setLoading(false);
}).catch(() => setLoading(false));
}, []);
const handleDelete = async () => {
deleteSubmissionPhotos(deleteConfirm);
await dbDeleteSubmission(deleteConfirm.id);
setAllSubs(prev => prev.filter(s => s.id !== deleteConfirm.id));
setDeleteConfirm(null);
};
if (viewingSub) {
return <InlineReportViewer sub={viewingSub} currentUser={currentUser} currentRole={currentRole}
onBack={() => setViewingSub(null)} onLogout={onLogout} />;
}
const downloadSub = (sub) => {
const sess = { username: sub.username, testingStart: sub.startTime, testingEnd: sub.endTime };
if (sub.type === "tester") openTesterReport(sub.rows, { ...sess, mode: "tester" });
else openReport(sub.rows, sess);
};
const toggleSort = (col) => {
if (sortCol === col) setSortDir(d => d === "asc" ? "desc" : "asc");
else { setSortCol(col); setSortDir("asc"); }
};
const sortKey = (sub) => {
if (sortCol === "name") return fmtReportName(sub).toLowerCase();
if (sortCol === "type") return sub.type;
if (sortCol === "date") return sub.endTime;
if (sortCol === "duration") return sub.endTime - sub.startTime;
if (sortCol === "status") {
if (sub.type === "admin") return "0_complete";
if (sub.review) return "1_reviewed";
return "2_pending";
}
return 0;
};
const sorted = [...allSubs].sort((a, b) => {
const ka = sortKey(a), kb = sortKey(b);
const cmp = ka < kb ? -1 : ka > kb ? 1 : 0;
return sortDir === "asc" ? cmp : -cmp;
});
const SortTh = ({ col, children, style }) => {
const active = sortCol === col;
return (
<th style={{ cursor: "pointer", userSelect: "none", whiteSpace: "nowrap", ...style }}
onClick={() => toggleSort(col)}>
{children}
<span className={`sort-arrow ${active ? "sort-active" : ""}`}>
{active ? (sortDir === "asc" ? " β–²" : " β–Ό") : " β‡…"}
</span>
</th>
);
};
return (
<div className="app">
<header className="app-header">
<div className="header-left"><h1>All Reports</h1></div>
<div className="header-right">
<button className="btn-secondary-sm" onClick={onBack}>← Portal</button>
<NotificationBell username={currentUser} onNavigate={onNavigate} />
<ProfileMenu username={currentUser} role={currentRole} onLogout={onLogout} />
</div>
</header>
<main className="app-main">
{loading ? <div className="portal-empty"><p>Loading reports…</p></div> : allSubs.length === 0
? <div className="portal-empty">
<div className="portal-empty-icon">πŸ“‚</div>
<h3>No Reports Yet</h3>
<p>Completed testing sessions will appear here.</p>
</div>
: <div className="table-wrap">
<table className="qa-table">
<thead>
<tr>
<SortTh col="name">Report Name</SortTh>
<SortTh col="type">Type</SortTh>
<th>Product</th>
<th>Test</th>
<SortTh col="date">Date &amp; Time</SortTh>
<SortTh col="duration">Duration</SortTh>
<SortTh col="status">Status</SortTh>
<th>Actions</th>
</tr>
</thead>
<tbody>
{sorted.map(sub => (
<tr key={sub.id}>
<td><code className="report-name">{fmtReportName(sub)}</code></td>
<td><span className={`portal-type-badge type-${sub.type}`}>{sub.type === "tester" ? "By Tester" : "Automation"}</span></td>
<td>{sub.productName ? <span className="product-chip">{sub.productName}</span> : <span style={{ color: "#bbb" }}>β€”</span>}</td>
<td><span className="tp-name-badge">{sub.profileName ?? "β€”"}</span></td>
<td>{fmtDateTime(sub.endTime)}</td>
<td>{fmtDuration(sub.endTime - sub.startTime)}</td>
<td>
{sub.type === "tester"
? <span className={`status-badge ${sub.review ? "badge-done" : "badge-pending"}`}>{sub.review ? "Reviewed" : "Pending Review"}</span>
: <span className="status-badge badge-done">Complete</span>
}
</td>
<td>
<div className="report-actions">
<button className="btn-view-report sm" onClick={() => setViewingSub(sub)}>View</button>
<button className="btn-pdf" onClick={() => downloadSub(sub)}>⬇ PDF</button>
{sub.review && <button className="btn-pdf btn-pdf-review" onClick={() => openReviewedReport(sub)}>⬇ Review PDF</button>}
{canManage && <button className="btn-delete-report sm" onClick={() => setDeleteConfirm(sub)}>Delete</button>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
}
</main>
{deleteConfirm && (
<div className="modal-overlay" onClick={() => setDeleteConfirm(null)}>
<div className="modal-box" onClick={e => e.stopPropagation()}>
<h3>Delete Report?</h3>
<p>Permanently delete report <strong>{fmtReportName(deleteConfirm)}</strong>? This cannot be undone.</p>
<div className="modal-actions">
<button className="btn-secondary" onClick={() => setDeleteConfirm(null)}>Cancel</button>
<button className="btn-danger" onClick={handleDelete}>Delete</button>
</div>
</div>
</div>
)}
</div>
);
}
// ── BrandTitle ────────────────────────────────────────────────────────────────
function BrandTitle() {
return (
<div className="brand-title">
<img src={logo} alt="Logo" className="brand-logo" />
<div className="brand-text">
<span className="brand-main">Automation Testing</span>
<span className="brand-sub">Dashboard</span>
</div>
</div>
);
}
// ── SubmitSuccessModal ────────────────────────────────────────────────────────
function SubmitSuccessModal({ isTester, onBackToPortal, onViewReports }) {
return (
<div className="modal-overlay">
<div className="modal submit-success-modal">
<div className="submit-success-icon">βœ…</div>
<h3>{isTester ? "Screenshots Submitted!" : "Report Submitted!"}</h3>
<p>
{isTester
? "Your screenshots have been submitted for review. You can track the status in All Reports."
: "Your report has been saved and is now available in the All Reports panel."
}
</p>
<div className="modal-actions">
<button className="btn-cancel" onClick={onBackToPortal}>Back to Portal</button>
<button className="btn-confirm" onClick={onViewReports}>View All Reports</button>
</div>
</div>
</div>
);
}
// ── CountdownBadge ────────────────────────────────────────────────────────────
function CountdownBadge({ remaining, urgent, warning }) {
return (
<div className={`countdown ${urgent ? "urgent" : warning ? "warning" : ""}`}>
<span className="countdown-label">Time Remaining</span>
<span className="countdown-time">{fmtDuration(remaining)}</span>
</div>
);
}
// ── TestingProfilesPortal ─────────────────────────────────────────────────────
function TestingProfilesPortal({ currentUser, currentRole, onEdit, onBack, onLogout }) {
const [profiles, setProfiles] = useState([]);
const [loading, setLoading] = useState(true);
const [deleteTarget, setDeleteTarget] = useState(null);
const [renaming, setRenaming] = useState(null);
const [msg, setMsg] = useState(null);
const notify = (type, text) => { setMsg({ type, text }); setTimeout(() => setMsg(null), 3500); };
useEffect(() => {
dbGetProfiles().then(ps => { setProfiles(ps); setLoading(false); }).catch(() => setLoading(false));
}, []);
const handleCreate = async () => {
const id = `p_${Date.now()}`;
const name = `Testing ${profiles.length + 1}`;
const profile = { id, name, createdAt: Date.now(), questions: QUESTIONS.map(q => ({ ...q })) };
await dbSaveProfile(profile);
const ps = await dbGetProfiles();
setProfiles(ps);
notify("ok", `"${name}" created with ${QUESTIONS.length} default questions.`);
};
const handleDelete = async () => {
const target = deleteTarget;
setDeleteTarget(null);
await dbDeleteProfile(target.id);
setProfiles(await dbGetProfiles());
notify("ok", `"${target.name}" deleted.`);
};
const handleRename = async () => {
if (!renaming.name.trim()) return;
const p = profiles.find(x => x.id === renaming.id);
if (p) {
await dbSaveProfile({ ...p, name: renaming.name.trim() });
setProfiles(await dbGetProfiles());
}
setRenaming(null);
};
return (
<div className="app">
{deleteTarget && (
<div className="modal-overlay">
<div className="modal">
<h3>Delete Profile?</h3>
<p>Permanently delete <strong>{deleteTarget.name}</strong>? This cannot be undone.</p>
<div className="modal-actions">
<button className="btn-cancel" onClick={() => setDeleteTarget(null)}>Cancel</button>
<button className="btn-danger" onClick={handleDelete}>Yes, Delete</button>
</div>
</div>
</div>
)}
{renaming && (
<div className="modal-overlay">
<div className="modal">
<h3>Rename Profile</h3>
<input className="login-input" style={{ width: "100%", marginTop: 10 }}
value={renaming.name} autoFocus
onChange={e => setRenaming(r => ({ ...r, name: e.target.value }))}
onKeyDown={e => e.key === "Enter" && handleRename()} />
<div className="modal-actions">
<button className="btn-cancel" onClick={() => setRenaming(null)}>Cancel</button>
<button className="btn-confirm" onClick={handleRename}>Rename</button>
</div>
</div>
</div>
)}
<header className="app-header">
<div className="header-left">
<h1>Testing Profiles</h1>
<p className="header-sub">{profiles.length} profile{profiles.length !== 1 ? "s" : ""} Β· select a profile when starting a session</p>
</div>
<div className="header-right">
<button className="btn-start btn-start-gray" style={{ padding: "9px 18px", fontSize: 13 }} onClick={handleCreate}>+ New Testing</button>
<button className="btn-secondary-sm" onClick={onBack}>← Portal</button>
<ProfileMenu username={currentUser} role={currentRole} onLogout={onLogout} />
</div>
</header>
<main className="app-main">
{msg && <div className={`qp-alert ${msg.type}`}>{msg.text}</div>}
{loading ? <div className="portal-empty"><p>Loading profiles…</p></div> : profiles.length === 0 ? (
<div className="tp-empty">
<div className="tp-empty-icon">πŸ“‹</div>
<h3>No Testing Profiles Yet</h3>
<p>Create profiles to define custom question sets for different testing scenarios.</p>
<button className="btn-start" style={{ marginTop: 20 }} onClick={handleCreate}>+ Create First Profile</button>
</div>
) : (
<div className="tp-grid">
{profiles.map(p => {
const totalMarks = (p.questions ?? []).reduce((s, q) => s + (q.possibleMarks ?? 0), 0);
return (
<div className="tp-card" key={p.id}>
<div className="tp-card-top">
<span className="tp-card-icon">πŸ“‹</span>
<div className="tp-card-name">{p.name}</div>
</div>
<div className="tp-card-meta">
<span>{(p.questions ?? []).length} questions</span>
<span className="tp-dot">Β·</span>
<span>{totalMarks} total marks</span>
</div>
<div className="tp-card-date">Created {fmtDateTime(p.createdAt)}</div>
<div className="tp-card-actions">
<button className="btn-view-report" onClick={() => onEdit(p)}>✎ Edit Questions</button>
<button className="btn-upload small" onClick={() => setRenaming({ id: p.id, name: p.name })}>Rename</button>
<button className="btn-danger-sm" onClick={() => setDeleteTarget(p)}>Delete</button>
</div>
</div>
);
})}
</div>
)}
</main>
</div>
);
}
// ── ProfileEditorPortal ───────────────────────────────────────────────────────
function ProfileEditorPortal({ profile: initialProfile, currentUser, currentRole, onBack, onLogout }) {
const [profile, setProfile] = useState(initialProfile);
const [qs, setQs] = useState(() => initialProfile.questions ?? []);
const [dirty, setDirty] = useState(false);
const [msg, setMsg] = useState(null);
const notify = (type, text) => { setMsg({ type, text }); setTimeout(() => setMsg(null), 4000); };
const updateField = (idx, field, value) => {
setQs(prev => prev.map((q, i) => i === idx ? { ...q, [field]: value } : q));
setDirty(true);
};
const addQuestion = () => {
const nextId = qs.length > 0 ? Math.max(...qs.map(q => q.id ?? 0)) + 1 : 1;
setQs(prev => [...prev, { id: nextId, standard: "", observation: "", possibleMarks: 10 }]);
setDirty(true);
};
const deleteQuestion = (idx) => { setQs(prev => prev.filter((_, i) => i !== idx)); setDirty(true); };
const saveChanges = async () => {
for (let i = 0; i < qs.length; i++) {
if (!qs[i].standard?.trim()) { notify("err", `Row ${i+1}: Standard is required.`); return; }
if (!qs[i].observation?.trim()) { notify("err", `Row ${i+1}: Observation is required.`); return; }
}
const saved = qs.map((q, i) => ({
id: q.id ?? i + 1, standard: q.standard.trim(),
observation: q.observation.trim(),
possibleMarks: q.possibleMarks != null ? Number(q.possibleMarks) : 10,
}));
const updated = { ...profile, questions: saved };
await dbSaveProfile(updated);
setProfile(updated);
setQs(saved);
setDirty(false);
notify("ok", `${saved.length} questions saved to "${profile.name}".`);
};
const resetToDefault = () => { setQs(QUESTIONS.map(q => ({ ...q }))); setDirty(true); notify("ok", "Reset to default questions β€” click Save to apply."); };
const handleJSON = (e) => {
const file = e.target.files?.[0]; if (!file) return; e.target.value = "";
const r = new FileReader();
r.onload = (ev) => {
try {
const data = JSON.parse(ev.target.result);
if (!Array.isArray(data) || data.length === 0) throw new Error("Must be a non-empty array.");
data.forEach((q, i) => { if (!q.standard || !q.observation) throw new Error(`Item ${i+1}: missing standard or observation.`); });
const mapped = data.map((q, i) => ({ id: q.id ?? i+1, standard: String(q.standard).trim(), observation: String(q.observation).trim(), possibleMarks: q.possibleMarks ? Number(q.possibleMarks) : 10 }));
setQs(mapped); setDirty(true); notify("ok", `${mapped.length} questions loaded β€” click Save to apply.`);
} catch (err) { notify("err", `JSON error: ${err.message}`); }
};
r.readAsText(file);
};
const handleCSV = (e) => {
const file = e.target.files?.[0]; if (!file) return; e.target.value = "";
const r = new FileReader();
r.onload = (ev) => {
try {
const lines = ev.target.result.trim().split(/\r?\n/);
const headers = parseCSVLine(lines[0]).map(h => h.replace(/^"|"$/g,"").trim().toLowerCase());
if (!headers.includes("standard") || !headers.includes("observation")) throw new Error('CSV must have "standard" and "observation" columns.');
const mapped = lines.slice(1).filter(l => l.trim()).map((line, i) => {
const vals = parseCSVLine(line).map(v => v.replace(/^"|"$/g,"").trim());
const obj = Object.fromEntries(headers.map((h, j) => [h, vals[j] ?? ""]));
if (!obj.standard || !obj.observation) throw new Error(`Row ${i+2}: missing standard or observation.`);
return { id: parseInt(obj.id) || i+1, standard: obj.standard, observation: obj.observation, possibleMarks: obj.possiblemarks ? Number(obj.possiblemarks) : 10 };
});
setQs(mapped); setDirty(true); notify("ok", `${mapped.length} questions loaded β€” click Save to apply.`);
} catch (err) { notify("err", `CSV error: ${err.message}`); }
};
r.readAsText(file);
};
const totalMarks = qs.reduce((s, q) => s + (Number(q.possibleMarks) || 0), 0);
return (
<div className="app">
<header className="app-header">
<div className="header-left">
<h1>{profile.name}</h1>
<p className="header-sub">{qs.length} question{qs.length !== 1 ? "s" : ""} Β· {totalMarks} total marks</p>
</div>
<div className="header-right">
{dirty && <button className="btn-view-report" onClick={saveChanges}>Save Changes</button>}
<button className="btn-secondary-sm" onClick={onBack}>← Profiles</button>
<ProfileMenu username={currentUser} role={currentRole} onLogout={onLogout} />
</div>
</header>
<main className="app-main">
{msg && <div className={`qp-alert ${msg.type}`}>{msg.text}</div>}
<div className="qp-toolbar">
<div className="qp-actions">
<button className="btn-upload qp-add-btn" onClick={addQuestion}>+ Add Question</button>
<label className="btn-upload">πŸ“„ Upload JSON<input type="file" accept=".json,application/json" onChange={handleJSON} hidden /></label>
<label className="btn-upload">πŸ“Š Upload CSV<input type="file" accept=".csv,text/csv" onChange={handleCSV} hidden /></label>
<button className="btn-discard" onClick={resetToDefault}>Reset to Default</button>
</div>
{dirty && <span className="status-badge badge-pending">Unsaved changes</span>}
</div>
<div className="table-wrap">
<table className="qa-table qp-edit-table">
<thead>
<tr>
<th className="col-num">#</th>
<th style={{ width: 130 }}>Standard</th>
<th>Observation</th>
<th style={{ width: 90, textAlign: "center" }}>Marks</th>
<th style={{ width: 48 }}></th>
</tr>
</thead>
<tbody>
{qs.map((q, i) => (
<tr key={q.id ?? i} className="row-neutral">
<td className="col-num">{i + 1}</td>
<td><input className="qp-inline-input qp-std-input" value={q.standard} placeholder="e.g. FUNC-001" onChange={e => updateField(i, "standard", e.target.value)} /></td>
<td><textarea className="qp-inline-textarea" value={q.observation} placeholder="Describe the test observation…" rows={2} onChange={e => updateField(i, "observation", e.target.value)} /></td>
<td style={{ textAlign: "center" }}>
<input type="number" min={1} max={100} className="qp-marks-input" value={q.possibleMarks ?? ""} placeholder="10"
onChange={e => updateField(i, "possibleMarks", e.target.value === "" ? "" : Math.max(1, parseInt(e.target.value) || 1))} />
</td>
<td><button className="qp-delete-btn" title="Delete" onClick={() => deleteQuestion(i)}>βœ•</button></td>
</tr>
))}
{qs.length === 0 && <tr><td colSpan={5} className="empty-row">No questions. Click "+ Add Question" to create one.</td></tr>}
</tbody>
</table>
</div>
<div className="qp-footer-row">
<button className="btn-upload qp-add-btn" onClick={addQuestion}>+ Add Question</button>
{dirty && <button className="btn-view-report" onClick={saveChanges}>Save Changes</button>}
</div>
</main>
</div>
);
}
// ── ProfilePickerPortal ── two-step: Product β†’ Profile ───────────────────────
function ProfilePickerPortal({ mode, session, onPick, onCancel, onLogout }) {
const [products, setProducts] = useState([]);
const [profiles, setProfiles] = useState([]);
const [loading, setLoading] = useState(true);
const [step, setStep] = useState("product"); // "product" | "profile"
const [selectedProduct, setSelectedProduct] = useState(null);
const [skipProducts, setSkipProducts] = useState(false);
const [noAccess, setNoAccess] = useState(false);
const role = session.role ?? "tester";
const isAdmin = role === "admin";
const modeLabel = mode === "tester" ? "Tester Session" : "Automation Testing";
useEffect(() => {
Promise.all([
dbGetProducts(),
dbGetProfiles(),
isAdmin ? Promise.resolve(null) : dbGetUserProducts(session.username),
]).then(([allProds, profs, userProdIds]) => {
const accessible = isAdmin ? allProds : allProds.filter(p => userProdIds.includes(p.id));
setProfiles(profs);
setProducts(accessible);
if (accessible.length === 0 && !isAdmin && allProds.length > 0) {
setNoAccess(true);
} else if (accessible.length === 0) {
setSkipProducts(true);
setStep("profile");
}
setLoading(false);
}).catch(() => setLoading(false));
}, []);
const handlePickProduct = (product) => {
if (profiles.length === 0) { onPick(null, null, product.id, product.name); return; }
setSelectedProduct(product);
setStep("profile");
};
const handleBack = () => {
if (step === "profile" && !skipProducts) { setStep("product"); setSelectedProduct(null); }
else onCancel();
};
const subtitle = step === "product"
? `Starting ${modeLabel} β€” select a product`
: selectedProduct ? `${selectedProduct.name} Β· select a testing profile` : `Starting ${modeLabel} β€” select a profile`;
if (loading) {
return (
<div className="idle-page">
<header className="app-header">
<div className="header-left"><BrandTitle /><p className="header-sub">Loading…</p></div>
<div className="header-right"><ProfileMenu username={session.username} role={session.role} onLogout={onLogout} /></div>
</header>
<div className="idle-body"><p style={{ color: "#aaa" }}>Loading…</p></div>
</div>
);
}
if (noAccess) {
return (
<div className="idle-page">
<header className="app-header">
<div className="header-left"><BrandTitle /><p className="header-sub">Starting {modeLabel}</p></div>
<div className="header-right">
<button className="btn-secondary-sm" onClick={onCancel}>← Back</button>
<ProfileMenu username={session.username} role={session.role} onLogout={onLogout} />
</div>
</header>
<div className="idle-body">
<div className="tp-empty">
<div className="tp-empty-icon">πŸ”’</div>
<h3>No Products Assigned</h3>
<p>You don't have access to any products yet. Contact your admin to get product access assigned to your account.</p>
<button className="btn-secondary-sm" style={{ marginTop: 20 }} onClick={onCancel}>← Back to Portal</button>
</div>
</div>
</div>
);
}
return (
<div className="idle-page">
<header className="app-header">
<div className="header-left"><BrandTitle /><p className="header-sub">{subtitle}</p></div>
<div className="header-right">
<button className="btn-secondary-sm" onClick={handleBack}>← Back</button>
<ProfileMenu username={session.username} role={session.role} onLogout={onLogout} />
</div>
</header>
<div className="idle-body" style={{ alignItems: "flex-start" }}>
<div style={{ width: "100%", maxWidth: 720, margin: "0 auto" }}>
{step === "product" && (
<>
<div className="portal-welcome" style={{ marginBottom: 24 }}>
<h2>Select Product</h2>
<p>Choose which product to test for this {modeLabel.toLowerCase()}.</p>
</div>
<div className="prod-picker-grid">
{products.map(p => (
<button key={p.id} className="prod-picker-card" onClick={() => handlePickProduct(p)}>
<span className="prod-picker-icon">🏷️</span>
<strong className="prod-picker-name">{p.name}</strong>
{p.description && <span className="prod-picker-desc">{p.description}</span>}
<span className="tp-picker-arrow">β†’</span>
</button>
))}
</div>
</>
)}
{step === "profile" && (
<>
<div className="portal-welcome" style={{ marginBottom: 24 }}>
<h2>Select Testing Profile</h2>
<p>Choose which question set to use{selectedProduct ? ` for ${selectedProduct.name}` : ""}.</p>
</div>
{profiles.length === 0
? <div className="tp-empty">
<div className="tp-empty-icon">πŸ“‹</div>
<h3>No Profiles Yet</h3>
<p>Admin needs to create testing profiles in Testing Profiles portal.</p>
</div>
: <div className="tp-picker-list">
{profiles.map(p => (
<button key={p.id} className="tp-picker-item"
onClick={() => onPick(p.questions, p.name, selectedProduct?.id ?? null, selectedProduct?.name ?? null)}>
<span className="tp-picker-icon">πŸ“‹</span>
<div className="tp-picker-info">
<strong>{p.name}</strong>
<span>{(p.questions ?? []).length} questions Β· {(p.questions ?? []).reduce((s, q) => s + (q.possibleMarks ?? 0), 0)} total marks</span>
</div>
<span className="tp-picker-arrow">β†’</span>
</button>
))}
</div>
}
</>
)}
</div>
</div>
</div>
);
}
// ── ProductsPortal ────────────────────────────────────────────────────────────
function ProductsPortal({ currentUser, currentRole, onBack, onLogout }) {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [deleteTarget, setDeleteTarget] = useState(null);
const [editing, setEditing] = useState(null);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState({ name: "", description: "" });
const [formErr, setFormErr] = useState("");
const [msg, setMsg] = useState(null);
const notify = (type, text) => { setMsg({ type, text }); setTimeout(() => setMsg(null), 3500); };
useEffect(() => {
dbGetProducts().then(ps => { setProducts(ps); setLoading(false); }).catch(() => setLoading(false));
}, []);
const handleCreate = async () => {
if (!form.name.trim()) { setFormErr("Product name is required."); return; }
const slug = form.name.trim().toLowerCase().replace(/\s+/g, "_").replace(/[^a-z0-9_]/g, "") || `prod_${Date.now()}`;
const id = products.find(p => p.id === slug) ? `${slug}_${Date.now()}` : slug;
await dbSaveProduct({ id, name: form.name.trim(), description: form.description.trim(), createdAt: Date.now() });
setProducts(await dbGetProducts());
setCreating(false); setForm({ name: "", description: "" }); setFormErr("");
notify("ok", `Product "${form.name.trim()}" created.`);
};
const handleEdit = async () => {
if (!editing.name.trim()) return;
const p = products.find(x => x.id === editing.id);
await dbSaveProduct({ ...p, name: editing.name.trim(), description: editing.desc.trim() });
setProducts(await dbGetProducts()); setEditing(null);
notify("ok", "Product updated.");
};
const handleDelete = async () => {
const target = deleteTarget; setDeleteTarget(null);
await dbDeleteProduct(target.id);
setProducts(await dbGetProducts());
notify("ok", `"${target.name}" deleted.`);
};
return (
<div className="app">
{deleteTarget && (
<div className="modal-overlay">
<div className="modal">
<h3>Delete Product?</h3>
<p>Permanently delete <strong>{deleteTarget.name}</strong>? Users will lose access and submissions won't be filterable by this product.</p>
<div className="modal-actions">
<button className="btn-cancel" onClick={() => setDeleteTarget(null)}>Cancel</button>
<button className="btn-danger" onClick={handleDelete}>Yes, Delete</button>
</div>
</div>
</div>
)}
{editing && (
<div className="modal-overlay">
<div className="modal">
<h3>Edit Product</h3>
<label className="login-label" style={{ marginTop: 12, display: "block" }}>Name</label>
<input className="login-input" style={{ width: "100%", marginTop: 6 }} value={editing.name} autoFocus
onChange={e => setEditing(v => ({ ...v, name: e.target.value }))}
onKeyDown={e => e.key === "Enter" && handleEdit()} />
<label className="login-label" style={{ marginTop: 12, display: "block" }}>Description</label>
<input className="login-input" style={{ width: "100%", marginTop: 6 }} value={editing.desc} placeholder="Optional description…"
onChange={e => setEditing(v => ({ ...v, desc: e.target.value }))} />
<div className="modal-actions">
<button className="btn-cancel" onClick={() => setEditing(null)}>Cancel</button>
<button className="btn-confirm" onClick={handleEdit}>Save</button>
</div>
</div>
</div>
)}
{creating && (
<div className="modal-overlay">
<div className="modal">
<h3>New Product</h3>
{formErr && <div className="qp-alert err" style={{ marginTop: 8 }}>{formErr}</div>}
<label className="login-label" style={{ marginTop: 12, display: "block" }}>Name <span style={{ color: "#e05c5c" }}>*</span></label>
<input className="login-input" style={{ width: "100%", marginTop: 6 }} value={form.name} autoFocus placeholder="e.g. PE, PT, PL…"
onChange={e => { setForm(f => ({ ...f, name: e.target.value })); setFormErr(""); }}
onKeyDown={e => e.key === "Enter" && handleCreate()} />
<label className="login-label" style={{ marginTop: 12, display: "block" }}>Description</label>
<input className="login-input" style={{ width: "100%", marginTop: 6 }} value={form.description} placeholder="Optional description…"
onChange={e => setForm(f => ({ ...f, description: e.target.value }))} />
<div className="modal-actions">
<button className="btn-cancel" onClick={() => { setCreating(false); setFormErr(""); setForm({ name: "", description: "" }); }}>Cancel</button>
<button className="btn-confirm" onClick={handleCreate}>Create</button>
</div>
</div>
</div>
)}
<header className="app-header">
<div className="header-left">
<h1>Manage Products</h1>
<p className="header-sub">{products.length} product{products.length !== 1 ? "s" : ""} Β· assign access per user in Manage Accounts</p>
</div>
<div className="header-right">
<button className="btn-start btn-start-red" style={{ padding: "9px 18px", fontSize: 13 }} onClick={() => setCreating(true)}>+ New Product</button>
<button className="btn-secondary-sm" onClick={onBack}>← Portal</button>
<ProfileMenu username={currentUser} role={currentRole} onLogout={onLogout} />
</div>
</header>
<main className="app-main">
{msg && <div className={`qp-alert ${msg.type}`}>{msg.text}</div>}
{loading ? <div className="portal-empty"><p>Loading products…</p></div> : products.length === 0 ? (
<div className="tp-empty">
<div className="tp-empty-icon">🏷️</div>
<h3>No Products Yet</h3>
<p>Create products (PE, PT, PL…) then assign users to each product in Manage Accounts.</p>
<button className="btn-start btn-start-red" style={{ marginTop: 20 }} onClick={() => setCreating(true)}>+ Create First Product</button>
</div>
) : (
<div className="tp-grid">
{products.map(p => (
<div className="tp-card prod-card" key={p.id}>
<div className="tp-card-top">
<span className="tp-card-icon">🏷️</span>
<div className="tp-card-name">{p.name}</div>
</div>
{p.description && <div className="tp-card-meta" style={{ fontStyle: "italic" }}>{p.description}</div>}
<div className="tp-card-meta"><span className="prod-id-badge">{p.id}</span></div>
<div className="tp-card-actions">
<button className="btn-upload small" onClick={() => setEditing({ id: p.id, name: p.name, desc: p.description })}>Edit</button>
<button className="btn-danger-sm" onClick={() => setDeleteTarget(p)}>Delete</button>
</div>
</div>
))}
</div>
)}
</main>
</div>
);
}
// ── AccountsPortal ────────────────────────────────────────────────────────────
const ROLES = ["admin", "reviewer", "tester"];
const ROLE_LABELS = { admin: "Admin", reviewer: "Reviewer", tester: "Tester" };
const ROLE_COLORS = { admin: "#4f86c6", reviewer: "#9b73c8", tester: "#4ab8c8" };
const ROLE_DESC = {
admin: "Full access β€” all portals including Manage Accounts",
reviewer: "Can review tester submissions and view all reports",
tester: "Tester portal only β€” upload screenshots per session",
};
function AccountsPortal({ currentUser, currentRole, onBack, onLogout }) {
const [users, setUsers] = useState([]);
const [allProducts, setAllProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [resetTarget, setResetTarget] = useState(null);
const [newPw, setNewPw] = useState("");
const [pwError, setPwError] = useState("");
const [deleteTarget, setDeleteTarget] = useState(null);
const [msg, setMsg] = useState(null);
const [showCreate, setShowCreate] = useState(false);
const [createForm, setCreateForm] = useState({ username: "", password: "", role: "tester", name: "", phone: "" });
const [createError, setCreateError] = useState("");
const [accessModal, setAccessModal] = useState(null); // { username, productIds: [] }
const [accessLoading, setAccessLoading] = useState(false);
const notify = (type, text) => { setMsg({ type, text }); setTimeout(() => setMsg(null), 3500); };
useEffect(() => {
Promise.all([dbGetAllUsers(), dbGetProducts()])
.then(([us, ps]) => { setUsers(us); setAllProducts(ps); setLoading(false); })
.catch(() => setLoading(false));
}, []);
const reloadUsers = () => dbGetAllUsers().then(us => setUsers(us));
const openAccessModal = async (username) => {
setAccessLoading(true);
const ids = await dbGetUserProducts(username);
setAccessModal({ username, productIds: ids });
setAccessLoading(false);
};
const saveAccess = async () => {
await dbSetUserProducts(accessModal.username, accessModal.productIds);
setAccessModal(null);
notify("ok", `Product access updated for ${accessModal.username}.`);
};
const toggleProduct = (pid) => {
setAccessModal(m => ({
...m,
productIds: m.productIds.includes(pid)
? m.productIds.filter(id => id !== pid)
: [...m.productIds, pid],
}));
};
const handleRoleChange = async (username, role) => {
await dbUpdateRole(username, role);
await reloadUsers();
notify("ok", `Privilege updated for ${username}.`);
};
const handleResetPw = async () => {
const res = await dbResetPassword(resetTarget, newPw);
if (res.error) { setPwError(res.error); return; }
setResetTarget(null); setNewPw(""); setPwError("");
notify("ok", `Password reset for ${resetTarget}.`);
};
const handleDelete = async () => {
const target = deleteTarget;
setDeleteTarget(null);
await dbDeleteUser(target);
await reloadUsers();
notify("ok", `Account "${target}" deleted.`);
};
const handleCreate = async () => {
setCreateError("");
const res = await dbSignup(createForm.username, createForm.password, { name: createForm.name, phone: createForm.phone });
if (res.error) { setCreateError(res.error); return; }
if (createForm.role !== "tester") await dbUpdateRole(createForm.username.trim(), createForm.role);
await reloadUsers();
setShowCreate(false);
const createdName = createForm.username.trim();
const createdRole = createForm.role;
setCreateForm({ username: "", password: "", role: "tester", name: "", phone: "" });
notify("ok", `Account "${createdName}" created as ${ROLE_LABELS[createdRole]}.`);
};
return (
<div className="app">
{/* Create Account Modal */}
{showCreate && (
<div className="modal-overlay">
<div className="modal">
<h3>Create New Account</h3>
<p style={{ marginBottom: 4 }}>Add a new user with a specified role.</p>
{createError && <div className="qp-alert err" style={{ marginTop: 12 }}>{createError}</div>}
<label className="login-label" style={{ marginTop: 14, display: "block" }}>Full Name</label>
<input className="login-input" style={{ width: "100%", marginTop: 6 }}
value={createForm.name} placeholder="Full name…"
onChange={e => { setCreateForm(f => ({ ...f, name: e.target.value })); setCreateError(""); }} />
<label className="login-label" style={{ marginTop: 14, display: "block" }}>Phone Number</label>
<div className="acct-phone-wrap" style={{ marginTop: 6 }}>
<span className="acct-phone-prefix">+91</span>
<input className="login-input acct-phone-input"
value={createForm.phone} placeholder="10-digit mobile number"
maxLength={10}
onChange={e => { setCreateForm(f => ({ ...f, phone: e.target.value.replace(/\D/g, "") })); setCreateError(""); }} />
</div>
<label className="login-label" style={{ marginTop: 14, display: "block" }}>Username</label>
<input className="login-input" style={{ width: "100%", marginTop: 6 }}
value={createForm.username} placeholder="Username (min 3 chars)…"
onChange={e => { setCreateForm(f => ({ ...f, username: e.target.value })); setCreateError(""); }} />
<label className="login-label" style={{ marginTop: 14, display: "block" }}>Password</label>
<input type="password" className="login-input" style={{ width: "100%", marginTop: 6 }}
value={createForm.password} placeholder="Password (min 6 chars)…"
onChange={e => { setCreateForm(f => ({ ...f, password: e.target.value })); setCreateError(""); }} />
<label className="login-label" style={{ marginTop: 14, display: "block" }}>Role</label>
<select className="acct-role-select" style={{ width: "100%", marginTop: 6, padding: "9px 12px" }}
value={createForm.role}
onChange={e => setCreateForm(f => ({ ...f, role: e.target.value }))}>
{ROLES.map(r => <option key={r} value={r}>{ROLE_LABELS[r]} β€” {ROLE_DESC[r]}</option>)}
</select>
<div className="modal-actions">
<button className="btn-cancel" onClick={() => { setShowCreate(false); setCreateForm({ username: "", password: "", role: "tester", name: "", phone: "" }); setCreateError(""); }}>Cancel</button>
<button className="btn-confirm" onClick={handleCreate}>Create Account</button>
</div>
</div>
</div>
)}
{/* Product Access Modal */}
{accessModal && (
<div className="modal-overlay">
<div className="modal">
<h3>Product Access β€” {accessModal.username}</h3>
<p style={{ marginBottom: 12 }}>Select which products this user can test and see reports for.</p>
{allProducts.length === 0
? <p style={{ color: "#aaa", fontSize: 13 }}>No products created yet. Create products in Manage Products first.</p>
: <div className="prod-access-list">
{allProducts.map(p => (
<label key={p.id} className="prod-access-item">
<input type="checkbox"
checked={accessModal.productIds.includes(p.id)}
onChange={() => toggleProduct(p.id)} />
<span className="prod-access-name">{p.name}</span>
{p.description && <span className="prod-access-desc">{p.description}</span>}
</label>
))}
</div>
}
<div className="modal-actions">
<button className="btn-cancel" onClick={() => setAccessModal(null)}>Cancel</button>
<button className="btn-confirm" onClick={saveAccess}>Save Access</button>
</div>
</div>
</div>
)}
{/* Reset Password Modal */}
{resetTarget && (
<div className="modal-overlay">
<div className="modal">
<h3>Reset Password β€” {resetTarget}</h3>
<p>Set a new password (minimum 6 characters).</p>
{pwError && <div className="qp-alert err" style={{ marginTop: 12 }}>{pwError}</div>}
<label className="login-label" style={{ marginTop: 14, display: "block" }}>New Password</label>
<input type="password" className="login-input" style={{ width: "100%", marginTop: 6 }}
value={newPw} placeholder="New password…"
onChange={e => { setNewPw(e.target.value); setPwError(""); }} />
<div className="modal-actions">
<button className="btn-cancel" onClick={() => { setResetTarget(null); setNewPw(""); setPwError(""); }}>Cancel</button>
<button className="btn-confirm" onClick={handleResetPw}>Reset Password</button>
</div>
</div>
</div>
)}
{/* Delete Confirm Modal */}
{deleteTarget && (
<div className="modal-overlay">
<div className="modal">
<h3>Delete Account?</h3>
<p>Permanently delete <strong>{deleteTarget}</strong>? All their data stays in reports.</p>
<div className="modal-actions">
<button className="btn-cancel" onClick={() => setDeleteTarget(null)}>Cancel</button>
<button className="btn-danger" onClick={handleDelete}>Yes, Delete</button>
</div>
</div>
</div>
)}
<header className="app-header">
<div className="header-left">
<h1>Manage Accounts</h1>
<p className="header-sub">{users.length} registered account{users.length !== 1 ? "s" : ""}</p>
</div>
<div className="header-right">
<button className="btn-start btn-start-indigo" style={{ padding: "9px 18px", fontSize: 13 }}
onClick={() => setShowCreate(true)}>+ Create Account</button>
<button className="btn-secondary-sm" onClick={onBack}>← Portal</button>
<ProfileMenu username={currentUser} role={currentRole} onLogout={onLogout} />
</div>
</header>
<main className="app-main">
{msg && <div className={`qp-alert ${msg.type}`}>{msg.text}</div>}
{loading && <div className="portal-empty"><p>Loading accounts…</p></div>}
<div className="table-wrap" style={{ display: loading ? "none" : undefined }}>
<table className="qa-table acct-table">
<thead>
<tr>
<th>User</th>
<th>Contact</th>
<th>Role / Privilege</th>
<th>Access</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.username} className={u.username === currentUser ? "row-pass" : "row-neutral"}>
<td>
<div className="acct-user">
<span className="acct-avatar" style={{ background: ROLE_COLORS[u.role] ?? "#888" }}>
{(u.name || u.username).charAt(0).toUpperCase()}
</span>
<div>
{u.name && <div style={{ fontWeight: 700, fontSize: 14 }}>{u.name}</div>}
<div style={{ color: u.name ? "#666" : undefined, fontSize: u.name ? 12 : 14 }}>
<strong style={{ fontWeight: u.name ? 400 : 700 }}>@{u.username}</strong>
{u.username === currentUser && <span className="acct-you-badge">You</span>}
</div>
</div>
</div>
</td>
<td style={{ fontSize: 13, color: "#555" }}>
{u.phone ? <span>+91 {u.phone}</span> : <span style={{ color: "#bbb" }}>β€”</span>}
</td>
<td>
<select className="acct-role-select"
value={u.role}
style={{ borderColor: ROLE_COLORS[u.role] ?? "#d0d4db", color: ROLE_COLORS[u.role] ?? "#333" }}
onChange={e => handleRoleChange(u.username, e.target.value)}
>
{ROLES.map(r => <option key={r} value={r}>{ROLE_LABELS[r]}</option>)}
</select>
</td>
<td className="acct-desc">{ROLE_DESC[u.role] ?? "β€”"}</td>
<td>
<div className="report-actions">
<button className="btn-upload small" disabled={accessLoading}
onClick={() => openAccessModal(u.username)}>
🏷️ Products
</button>
<button className="btn-upload small"
onClick={() => { setResetTarget(u.username); setNewPw(""); setPwError(""); }}>
πŸ”‘ Reset Password
</button>
{u.username !== currentUser && (
<button className="btn-danger-sm" onClick={() => setDeleteTarget(u.username)}>Delete</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="acct-legend">
{ROLES.map(r => (
<div key={r} className="acct-legend-item">
<span className="acct-legend-dot" style={{ background: ROLE_COLORS[r] }} />
<strong>{ROLE_LABELS[r]}</strong>
<span>{ROLE_DESC[r]}</span>
</div>
))}
</div>
</main>
</div>
);
}
// ── LogPanel ──────────────────────────────────────────────────────────────────
function LogPanel({ refType, onClose }) {
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
dbReadLogs()
.then(all => {
const filtered = all
.filter(l => l.refType === refType)
.sort((a, b) => b.createdAt - a.createdAt);
setLogs(filtered);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [refType]);
const actionLabel = (l) => {
if (l.action === "created") return `Created "${l.refTitle}"`;
if (l.action === "deleted") return `Deleted "${l.refTitle}"`;
if (l.action === "status_changed") return `"${l.refTitle}" β€” ${l.detail}`;
if (l.action === "edited") return `Edited "${l.refTitle}"${l.detail ? ` β€” ${l.detail}` : ""}`;
return `${l.action} "${l.refTitle}"`;
};
return (
<div className="log-panel-overlay" onClick={onClose}>
<div className="log-panel" onClick={e => e.stopPropagation()}>
<div className="log-panel-header">
<span>Activity Log</span>
<button className="log-panel-close" onClick={onClose}>Γ—</button>
</div>
<div className="log-panel-body">
{loading ? (
<p className="log-empty">Loading…</p>
) : logs.length === 0 ? (
<p className="log-empty">No activity recorded yet.</p>
) : logs.map(l => (
<div key={l.id} className="log-entry">
<div className="log-entry-action">{actionLabel(l)}</div>
<div className="log-entry-meta">
<strong>{l.actor}</strong> Β· {fmtDateTime(l.createdAt)}
</div>
</div>
))}
</div>
</div>
</div>
);
}
// ── KanbanPortal ─────────────────────────────────────────────────────────────
const TASK_COLUMNS = [
{ id: "backlog", label: "Backlog" },
{ id: "in-progress", label: "In Progress" },
{ id: "in-review", label: "In Review" },
{ id: "done", label: "Done" },
];
function KanbanPortal({ currentUser, currentRole, onBack, onLogout, onNavigate }) {
const [tasks, setTasks] = useState([]);
const [products, setProducts] = useState([]);
const [allUsers, setAllUsers] = useState([]);
const [testerProducts, setTesterProducts] = useState(null); // null = all, array = filtered
const [loading, setLoading] = useState(true);
const [modal, setModal] = useState(null);
const [deleteId, setDeleteId] = useState(null);
const [showLog, setShowLog] = useState(false);
const isAdmin = currentRole === "admin";
const isReviewer = currentRole === "reviewer";
const isTester = currentRole === "tester";
const canCreate = isAdmin || isReviewer;
useEffect(() => {
dbPruneLogs().catch(() => {});
const TODAY = new Date().toISOString().slice(0, 10);
const TEN_AGO = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
const spawnDailyTasks = async (allTasks) => {
const templates = allTasks.filter(t => t.label === "daily" && !t.templateId);
for (const tpl of templates) {
const alreadyExists = allTasks.some(t => t.templateId === tpl.id && t.dueDate === TODAY);
if (!alreadyExists) {
await dbSaveTask({
...tpl,
id: `task_daily_${tpl.id}_${TODAY}`,
label: "once",
templateId: tpl.id,
dueDate: TODAY,
status: "in-progress",
createdAt: Date.now(),
updatedAt: Date.now(),
});
}
}
};
const load = async () => {
try {
const [allTasks, p, u, myProds] = await Promise.all([
dbGetTasks(),
dbGetProducts(),
dbGetAllUsers(),
isTester ? dbGetUserProducts(currentUser) : Promise.resolve(null),
]);
// Delete daily instances older than 10 days
const stale = allTasks.filter(t => t.templateId && t.dueDate && t.dueDate < TEN_AGO);
if (stale.length > 0) await Promise.all(stale.map(t => dbDeleteTask(t.id)));
await spawnDailyTasks(allTasks);
// Reload tasks after spawning so new daily instances appear
const freshTasks = await dbGetTasks();
setTasks(freshTasks); setProducts(p); setAllUsers(u);
setTesterProducts(myProds);
} catch {}
setLoading(false);
};
load();
}, []);
const openCreate = () => setModal({ mode: "create", task: {
id: `task_${Date.now()}`, title: "", description: "", status: "backlog",
productId: null, productName: null, assignee: null,
createdBy: currentUser, tags: [], images: [],
createdAt: Date.now(), updatedAt: Date.now(), dueDate: null,
label: "once", recurTime: null, templateId: null, level: "III",
}});
const openEdit = (task) => setModal({ mode: "edit", task: { ...task } });
const handleSave = (task) => {
const orig = tasks.find(t => t.id === task.id);
task.updatedAt = Date.now();
if (task.label === "daily" && task.status === "backlog") task.status = "in-progress";
// Optimistic update β€” show card and close modal instantly
setTasks(prev => {
const idx = prev.findIndex(t => t.id === task.id);
return idx >= 0 ? prev.map(t => t.id === task.id ? task : t) : [...prev, task];
});
setModal(null);
// Persist to DB in background
dbSaveTask(task).catch(() => {});
// Activity log + notifications fire in background
const isNew = !orig;
const changes = [];
if (!isNew && orig.status !== task.status) changes.push(`status: ${orig.status} β†’ ${task.status}`);
if (!isNew && orig.assignee !== task.assignee) changes.push(`assignee: ${orig.assignee ?? "β€”"} β†’ ${task.assignee ?? "β€”"}`);
if (!isNew && orig.level !== task.level) changes.push(`level: ${orig.level ?? "III"} β†’ ${task.level ?? "III"}`);
dbAppendLog({
id: `log_${Date.now()}_${Math.random().toString(36).slice(2)}`,
refType: "task", refId: task.id, refTitle: task.title,
actor: currentUser, action: isNew ? "created" : "edited",
detail: changes.join("; "), createdAt: Date.now(),
});
if (task.assignee && task.assignee !== currentUser) {
dbSaveNotification({
id: `notif_${Date.now()}_a_${task.assignee}`,
toUsername: task.assignee,
message: `${currentUser} assigned you task: "${task.title}"`,
type: "task_assigned", refId: task.id, refType: "task",
read: false, createdAt: Date.now(),
}).catch(() => {});
}
const prevTags = orig?.tags ?? [];
const newTags = (task.tags ?? []).filter(u => !prevTags.includes(u) && u !== currentUser);
newTags.forEach(u => dbSaveNotification({
id: `notif_${Date.now()}_tag_${u}`,
toUsername: u,
message: `${currentUser} tagged you in task: "${task.title}"`,
type: "task_tagged", refId: task.id, refType: "task",
read: false, createdAt: Date.now(),
}).catch(() => {}));
};
const handleDelete = async () => {
const t = tasks.find(t => t.id === deleteId);
dbAppendLog({
id: `log_${Date.now()}_${Math.random().toString(36).slice(2)}`,
refType: "task", refId: deleteId, refTitle: t?.title ?? deleteId,
actor: currentUser, action: "deleted", detail: "", createdAt: Date.now(),
});
await dbDeleteTask(deleteId);
setTasks(prev => prev.filter(t => t.id !== deleteId));
setDeleteId(null);
};
const handleStatusChange = async (task, newStatus) => {
dbAppendLog({
id: `log_${Date.now()}_${Math.random().toString(36).slice(2)}`,
refType: "task", refId: task.id, refTitle: task.title,
actor: currentUser, action: "status_changed",
detail: `${task.status} β†’ ${newStatus}`, createdAt: Date.now(),
});
const updated = { ...task, status: newStatus, updatedAt: Date.now() };
await dbSaveTask(updated);
setTasks(prev => prev.map(t => t.id === task.id ? updated : t));
};
if (loading) return (
<div className="app">
<header className="app-header">
<div className="header-left"><h1>Planner Board</h1></div>
<div className="header-right">
<button className="btn-secondary-sm" onClick={onBack}>← Portal</button>
<NotificationBell username={currentUser} onNavigate={onNavigate} />
<ProfileMenu username={currentUser} role={currentRole} onLogout={onLogout} />
</div>
</header>
<div className="portal-empty"><p>Loading…</p></div>
</div>
);
return (
<div className="app">
{modal && (
<TaskModal
task={modal.task}
mode={modal.mode}
currentUser={currentUser}
currentRole={currentRole}
products={products}
allUsers={allUsers}
onSave={handleSave}
onClose={() => setModal(null)}
/>
)}
{deleteId && (
<div className="modal-overlay">
<div className="modal">
<h3>Delete Task?</h3>
<p>This cannot be undone.</p>
<div className="modal-actions">
<button className="btn-cancel" onClick={() => setDeleteId(null)}>Cancel</button>
<button className="btn-danger" onClick={handleDelete}>Delete</button>
</div>
</div>
</div>
)}
{showLog && <LogPanel refType="task" onClose={() => setShowLog(false)} />}
<header className="app-header">
<div className="header-left">
<img src={logo} alt="Logo" className="brand-logo" />
<h1>Planner Board</h1>
</div>
<div className="header-right">
{canCreate && <button className="btn-view-report" onClick={openCreate}>+ New Task</button>}
{isAdmin && <button className="btn-secondary-sm" onClick={() => setShowLog(true)}>πŸ“‹ Log</button>}
<button className="btn-secondary-sm" onClick={onBack}>← Portal</button>
<NotificationBell username={currentUser} onNavigate={onNavigate} />
<ProfileMenu username={currentUser} role={currentRole} onLogout={onLogout} />
</div>
</header>
<main className="app-main">
<div className="kanban-board">
{TASK_COLUMNS.map(col => {
const visibleTasks = (testerProducts !== null
? tasks.filter(t => t.productId && testerProducts.includes(t.productId))
: tasks
).filter(t => {
if (isAdmin) return true;
if (isReviewer) return t.level !== "I";
return t.level === "III"; // tester
});
const colTasks = visibleTasks
.filter(t => t.status === col.id)
.sort((a, b) => {
// daily first
if (a.label === "daily" && b.label !== "daily") return -1;
if (a.label !== "daily" && b.label === "daily") return 1;
// within daily: sort by recurTime
if (a.label === "daily") {
return (a.recurTime ?? "99:99").localeCompare(b.recurTime ?? "99:99");
}
// within once: sort by dueDate ascending, null last
if (!a.dueDate && !b.dueDate) return 0;
if (!a.dueDate) return 1;
if (!b.dueDate) return -1;
return a.dueDate.localeCompare(b.dueDate);
});
return (
<div key={col.id} className="kanban-col" data-col={col.id}
onDragOver={canCreate ? (e) => { e.preventDefault(); e.currentTarget.classList.add("drag-over"); } : undefined}
onDragLeave={canCreate ? (e) => { e.currentTarget.classList.remove("drag-over"); } : undefined}
onDrop={canCreate ? (e) => {
e.currentTarget.classList.remove("drag-over");
const taskId = e.dataTransfer.getData("taskId");
const task = tasks.find(t => t.id === taskId);
if (!task || task.status === col.id) return;
if (task.label === "daily" && col.id === "backlog") return;
if (isTester && col.id === "done") return;
handleStatusChange(task, col.id);
} : undefined}>
<div className="kanban-col-header">
<span>{col.label}</span>
<span className="kanban-col-count">{colTasks.length}</span>
</div>
{colTasks.map(task => (
<KanbanCard
key={task.id}
task={task}
currentUser={currentUser}
currentRole={currentRole}
allUsers={allUsers}
onEdit={() => openEdit(task)}
onDelete={() => (isAdmin || isReviewer) && setDeleteId(task.id)}
onStatusChange={(s) => handleStatusChange(task, s)}
canDrag={canCreate}
/>
))}
{colTasks.length === 0 && <div className="kanban-empty">No tasks</div>}
</div>
);
})}
</div>
</main>
</div>
);
}
function KanbanCard({ task, currentUser, currentRole, allUsers, onEdit, onDelete, onStatusChange, canDrag }) {
const isAdmin = currentRole === "admin";
const isReviewer = currentRole === "reviewer";
const isTester = currentRole === "tester";
const isMine = task.assignee === currentUser;
const canEdit = isAdmin || isReviewer || isMine || isTester;
const canDelete = isAdmin || (isReviewer && task.createdBy !== undefined);
const statusOptions = TASK_COLUMNS.map(c => c.id).filter(s => s !== task.status);
return (
<div className={`kanban-card ${task.label === "daily" ? "card-daily" : "card-once"}`}
draggable={canDrag && !(isTester && task.status === "done")}
onDragStart={(canDrag && !(isTester && task.status === "done")) ? (e) => { e.dataTransfer.setData("taskId", task.id); e.currentTarget.classList.add("dragging"); } : undefined}
onDragEnd={canDrag ? (e) => e.currentTarget.classList.remove("dragging") : undefined}
onClick={canEdit ? onEdit : undefined}
style={{ cursor: canDrag ? "grab" : canEdit ? "pointer" : "default" }}>
<div style={{ display: "flex", gap: 4, flexWrap: "wrap", marginBottom: 4 }}>
{task.productName && <span className="kanban-card-product">{task.productName}</span>}
<span className={`ticket-level-badge level-${task.level ?? "III"}`}>Level {task.level ?? "III"}</span>
</div>
<div className="kanban-card-title">
<span title={task.label === "daily" ? "Daily recurring template" : "One-time task"} style={{ marginRight: 4 }}>
{task.label === "daily" ? "πŸ”" : "πŸ“Œ"}
</span>
{task.title}
</div>
{task.description && <div className="kanban-card-desc">{task.description.slice(0, 80)}{task.description.length > 80 ? "…" : ""}</div>}
<div className="kanban-card-meta">
{task.assignee && <span className="kanban-card-assignee">πŸ‘€ {task.assignee}</span>}
{task.tags?.length > 0 && <span className="kanban-card-tags">🏷 {task.tags.length}</span>}
{task.images?.length > 0 && <span className="kanban-card-imgs">πŸ–Ό {task.images.length}</span>}
</div>
<div className="kanban-card-dates">
<span className="kanban-date-created">πŸ“… {new Date(task.createdAt).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" })}</span>
{task.dueDate && task.label !== "daily" && (
<span className={`kanban-date-due${new Date(task.dueDate) < new Date() && task.status !== "done" ? " overdue" : ""}`}>
⏰ {new Date(task.dueDate).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" })}
</span>
)}
{task.label === "daily" && task.recurTime && (
<span className="kanban-date-due">⏰ Daily {(() => { const [h, m] = task.recurTime.split(":"); const hr = +h; return `${hr % 12 || 12}:${m} ${hr < 12 ? "AM" : "PM"}`; })()}</span>
)}
</div>
<div className="kanban-card-footer" onClick={e => e.stopPropagation()}>
{(isAdmin || isReviewer) && (
<select className="kanban-status-select" value={task.status}
onChange={e => onStatusChange(e.target.value)}>
{TASK_COLUMNS.filter(c => {
if (task.label === "daily" && c.id === "backlog") return false;
if (isTester && c.id === "done") return false;
return true;
}).map(c => <option key={c.id} value={c.id}>{c.label}</option>)}
</select>
)}
{canDelete && <button className="kanban-del-btn" onClick={onDelete}>βœ•</button>}
</div>
</div>
);
}
function TaskModal({ task: initTask, mode, currentUser, currentRole, products, allUsers, onSave, onClose }) {
const [task, setTask] = useState({ ...initTask });
const [saving, setSaving] = useState(false);
const [assigneeProducts, setAssigneeProducts] = useState(null);
const isAdmin = currentRole === "admin";
const isReviewer = currentRole === "reviewer";
const isTester = currentRole === "tester";
const wordCount = (task.description ?? "").trim() === "" ? 0 : (task.description ?? "").trim().split(/\s+/).length;
const assignableUsers = allUsers.filter(u => {
const lvl = task.level ?? "III";
if (lvl === "I" && u.role !== "admin") return false;
if (lvl === "II" && u.role !== "admin" && u.role !== "reviewer") return false;
if (isAdmin) return true;
if (isReviewer) return true;
if (isTester) return u.role === "reviewer" || u.role === "admin" || u.username === currentUser;
return false;
});
useEffect(() => {
if (task.assignee) {
const assigneeUser = allUsers.find(u => u.username === task.assignee);
if (assigneeUser?.role === "tester") {
dbGetUserProducts(task.assignee).then(ids => setAssigneeProducts(ids)).catch(() => setAssigneeProducts(null));
} else {
setAssigneeProducts(null);
}
} else {
setAssigneeProducts(null);
}
}, [task.assignee, allUsers]);
const filteredProducts = assigneeProducts !== null
? products.filter(p => assigneeProducts.includes(p.id))
: products;
const toggleTag = (username) => {
setTask(t => ({
...t,
tags: t.tags.includes(username) ? t.tags.filter(x => x !== username) : [...t.tags, username],
}));
};
const isValid = task.title.trim() &&
(isTester || task.assignee) &&
(isTester || task.productId) &&
(isTester || (task.label === "daily" ? !!task.recurTime : !!task.dueDate));
const handleSubmit = (e) => {
e.preventDefault();
if (!isValid) return;
onSave(task);
};
return (
<div className="modal-overlay">
<div className="modal task-modal task-modal--planner">
<div className="task-modal-header"><h3>{mode === "create" ? "New Task" : "Edit Task"}</h3></div>
<form onSubmit={handleSubmit}>
<div className="task-modal-body">
<div className="task-modal-grid">
{/* Left column */}
<div className="task-modal-left">
<label className="login-label">Title *</label>
<input className="login-input" value={task.title}
onChange={e => setTask(t => ({ ...t, title: e.target.value }))}
placeholder="Task title" required
disabled={currentRole === "tester"} />
<label className="login-label" style={{ marginTop: 12 }}>Description</label>
<div style={{ position: "relative" }}>
<textarea className="login-input" rows={5}
style={{ resize: "vertical", fontFamily: "inherit", fontSize: 13, paddingBottom: 24 }}
value={task.description}
onChange={e => {
const words = e.target.value.trim() === "" ? 0 : e.target.value.trim().split(/\s+/).length;
if (words <= 200) setTask(t => ({ ...t, description: e.target.value }));
}}
placeholder="Description… (max 200 words)" />
<span className={`word-count ${wordCount >= 200 ? "at-limit" : wordCount >= 180 ? "near-limit" : ""}`}
style={{ position: "absolute", bottom: 6, right: 8 }}>
{wordCount} / 200
</span>
</div>
{!isTester && (
<>
<label className="login-label" style={{ marginTop: 12 }}>Tags (notify users)</label>
<div className="tag-chip-wrap">
{allUsers.filter(u => u.username !== currentUser).map(u => (
<span key={u.username}
className={`tag-chip${task.tags.includes(u.username) ? " active" : ""}`}
onClick={() => toggleTag(u.username)}>
{u.username}
</span>
))}
{allUsers.filter(u => u.username !== currentUser).length === 0 && (
<span style={{ fontSize: 12, color: "#aaa" }}>No other users</span>
)}
</div>
</>
)}
</div>
{/* Right column */}
<div className="task-modal-right">
<label className="login-label">Ticket Level</label>
<select className="login-input" value={task.level ?? "III"}
disabled={isTester}
onChange={e => {
const newLevel = e.target.value;
setTask(t => {
const assigneeUser = allUsers.find(u => u.username === t.assignee);
const roleOk = newLevel === "III" ? true
: newLevel === "II" ? (assigneeUser?.role === "admin" || assigneeUser?.role === "reviewer")
: assigneeUser?.role === "admin";
return { ...t, level: newLevel, assignee: roleOk ? t.assignee : null, productId: roleOk ? t.productId : null, productName: roleOk ? t.productName : null };
});
}}>
{isAdmin && <option value="I">Level I (Admin)</option>}
{(isAdmin || isReviewer) && <option value="II">Level II (Reviewer)</option>}
<option value="III">Level III (Tester)</option>
</select>
<label className="login-label" style={{ marginTop: 12 }}>Type *</label>
<select className="login-input" value={task.label ?? "once"}
disabled={isTester}
onChange={e => setTask(t => ({ ...t, label: e.target.value, recurTime: e.target.value === "once" ? null : t.recurTime, dueDate: e.target.value === "daily" ? null : t.dueDate }))}>
<option value="once">πŸ“Œ Once</option>
<option value="daily">πŸ” Daily</option>
</select>
{task.label === "daily" && (
<>
<label className="login-label" style={{ marginTop: 12 }}>Due Time * <span style={{ fontSize: 11, color: "#888" }}>(same every day)</span></label>
<input type="time" className="login-input" required
value={task.recurTime ?? ""}
disabled={isTester}
onChange={e => setTask(t => ({ ...t, recurTime: e.target.value || null }))} />
</>
)}
{(!isTester || task.status === "in-review") && (
<>
<label className="login-label" style={{ marginTop: 12 }}>Assignee *</label>
<select className="login-input" value={task.assignee ?? ""}
onChange={e => setTask(t => ({ ...t, assignee: e.target.value || null, ...(isTester ? {} : { productId: null, productName: null }) }))}>
<option value="">{isTester ? "β€” assign to reviewer / admin β€”" : "Unassigned"}</option>
{assignableUsers.map(u => <option key={u.username} value={u.username}>{u.username} ({u.role})</option>)}
</select>
</>
)}
<label className="login-label" style={{ marginTop: 12 }}>Product *</label>
<select className="login-input" value={task.productId ?? ""}
disabled={isTester}
onChange={e => {
const p = filteredProducts.find(x => x.id === e.target.value);
setTask(t => ({ ...t, productId: p?.id ?? null, productName: p?.name ?? null }));
}}>
<option value="">Select product</option>
{filteredProducts.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
{task.label !== "daily" && (
<>
<label className="login-label" style={{ marginTop: 12 }}>Due Date *</label>
<input type="date" className="login-input" required
value={task.dueDate ?? ""}
disabled={isTester}
onChange={e => setTask(t => ({ ...t, dueDate: e.target.value || null }))} />
</>
)}
{mode === "edit" && (
<>
<label className="login-label" style={{ marginTop: 12 }}>Status</label>
<select className="login-input" value={task.status}
disabled={isTester && task.status === "done"}
onChange={e => setTask(t => ({ ...t, status: e.target.value }))}>
{TASK_COLUMNS.filter(c => {
if (task.label === "daily" && c.id === "backlog") return false;
if (isTester && c.id === "done") return false;
return true;
}).map(c => <option key={c.id} value={c.id}>{c.label}</option>)}
</select>
</>
)}
{mode === "edit" && (
<div style={{ marginTop: 10, fontSize: 12, color: "#888" }}>
Created: {new Date(task.createdAt).toLocaleDateString()}
</div>
)}
{!isTester && (
<>
<label className="login-label" style={{ marginTop: 12 }}>Images</label>
{task.images?.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, marginBottom: 8 }}>
{task.images.map((img, i) => (
<div key={i} style={{ position: "relative" }}>
<img src={img.base64 ?? img.url} alt="" style={{ width: 64, height: 64, objectFit: "cover", borderRadius: 6 }} />
<button type="button" style={{ position: "absolute", top: 0, right: 0, background: "rgba(0,0,0,0.6)", color: "#fff", border: "none", borderRadius: "0 6px 0 6px", cursor: "pointer", fontSize: 11, padding: "1px 5px" }}
onClick={() => setTask(t => ({ ...t, images: t.images.filter((_, j) => j !== i) }))}>βœ•</button>
</div>
))}
</div>
)}
<TaskImageUpload onAdd={img => setTask(t => ({ ...t, images: [...t.images, img] }))} />
</>
)}
</div>
</div>
</div>
<div className="modal-actions" style={{ margin: "16px 28px 24px" }}>
<button type="button" className="btn-cancel" onClick={onClose}>Cancel</button>
<button type="submit" className="btn-primary" disabled={!isValid}>
{mode === "create" ? "Create Task" : "Save Changes"}
</button>
</div>
</form>
</div>
</div>
);
}
function TaskImageUpload({ onAdd }) {
const inputRef = useRef(null);
const handleFile = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
onAdd({ base64: ev.target.result, name: file.name, url: null });
};
reader.readAsDataURL(file);
e.target.value = "";
};
return (
<div style={{ marginTop: 8 }}>
<input type="file" accept="image/*" ref={inputRef} style={{ display: "none" }} onChange={handleFile} />
<button type="button" className="btn-secondary-sm" onClick={() => inputRef.current.click()}>+ Add Image</button>
</div>
);
}
// ── TicketsPortal ─────────────────────────────────────────────────────────────
const TICKET_STATUSES = ["open", "in-progress", "resolved", "closed"];
const TICKET_PRIORITIES = ["low", "medium", "high"];
function TicketsPortal({ currentUser, currentRole, onBack, onLogout, onNavigate }) {
const [tickets, setTickets] = useState([]);
const [products, setProducts] = useState([]);
const [allUsers, setAllUsers] = useState([]);
const [testerProducts, setTesterProducts] = useState(null);
const [loading, setLoading] = useState(true);
const [modal, setModal] = useState(null);
const [deleteId, setDeleteId] = useState(null);
const [filterSt, setFilterSt] = useState("all");
const [filterPr, setFilterPr] = useState("all");
const [search, setSearch] = useState("");
const [showLog, setShowLog] = useState(false);
const isAdmin = currentRole === "admin";
const isReviewer = currentRole === "reviewer";
const isTester = currentRole === "tester";
useEffect(() => {
dbPruneLogs().catch(() => {});
const base = Promise.all([dbGetTickets(), dbGetProducts(), dbGetAllUsers()]);
const prodIds = isTester ? dbGetUserProducts(currentUser) : Promise.resolve(null);
Promise.all([base, prodIds])
.then(([[t, p, u], myProds]) => {
setTickets(t); setProducts(p); setAllUsers(u);
setTesterProducts(myProds);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
const openCreate = () => setModal({ mode: "create", ticket: {
id: `ticket_${Date.now()}`, title: "", description: "", status: "open",
priority: "medium", productId: null, productName: null,
reporter: currentUser, assignee: null, images: [],
createdAt: Date.now(), updatedAt: Date.now(), dueDate: null, level: "III",
}});
const openEdit = (ticket) => setModal({ mode: "edit", ticket: { ...ticket } });
const handleSave = (ticket) => {
const orig = tickets.find(t => t.id === ticket.id);
ticket.updatedAt = Date.now();
// Optimistic update β€” show ticket and close modal instantly
setTickets(prev => {
const idx = prev.findIndex(t => t.id === ticket.id);
return idx >= 0 ? prev.map(t => t.id === ticket.id ? ticket : t) : [...prev, ticket];
});
setModal(null);
// Persist to DB in background
dbSaveTicket(ticket).catch(() => {});
// Activity log + notification fire in background
const isNew = !orig;
const changes = [];
if (!isNew && orig.status !== ticket.status) changes.push(`status: ${orig.status} β†’ ${ticket.status}`);
if (!isNew && orig.assignee !== ticket.assignee) changes.push(`assignee: ${orig.assignee ?? "β€”"} β†’ ${ticket.assignee ?? "β€”"}`);
if (!isNew && orig.priority !== ticket.priority) changes.push(`priority: ${orig.priority} β†’ ${ticket.priority}`);
if (!isNew && orig.level !== ticket.level) changes.push(`level: ${orig.level ?? "III"} β†’ ${ticket.level ?? "III"}`);
dbAppendLog({
id: `log_${Date.now()}_${Math.random().toString(36).slice(2)}`,
refType: "ticket", refId: ticket.id, refTitle: ticket.title,
actor: currentUser, action: isNew ? "created" : "edited",
detail: changes.join("; "), createdAt: Date.now(),
});
if (ticket.assignee && ticket.assignee !== currentUser) {
dbSaveNotification({
id: `notif_${Date.now()}_tick_${ticket.assignee}`,
toUsername: ticket.assignee,
message: `${currentUser} assigned you a ticket: "${ticket.title}"`,
type: "ticket_assigned", refId: ticket.id, refType: "ticket",
read: false, createdAt: Date.now(),
}).catch(() => {});
}
};
const handleDelete = async () => {
const t = tickets.find(t => t.id === deleteId);
dbAppendLog({
id: `log_${Date.now()}_${Math.random().toString(36).slice(2)}`,
refType: "ticket", refId: deleteId, refTitle: t?.title ?? deleteId,
actor: currentUser, action: "deleted", detail: "", createdAt: Date.now(),
});
await dbDeleteTicket(deleteId);
setTickets(prev => prev.filter(t => t.id !== deleteId));
setDeleteId(null);
};
const visible = tickets.filter(t => {
if (isAdmin ? false : isReviewer ? t.level === "I" : t.level !== "III") return false;
if (!isAdmin && !isReviewer && t.reporter !== currentUser && t.assignee !== currentUser) return false;
if (!isAdmin && testerProducts !== null && t.productId && !testerProducts.includes(t.productId)) return false;
if (filterSt !== "all" && t.status !== filterSt) return false;
if (filterPr !== "all" && t.priority !== filterPr) return false;
if (search && !t.title.toLowerCase().includes(search.toLowerCase())) return false;
return true;
});
if (loading) return (
<div className="app">
<header className="app-header">
<div className="header-left"><h1>Tickets</h1></div>
<div className="header-right">
<button className="btn-secondary-sm" onClick={onBack}>← Portal</button>
<NotificationBell username={currentUser} onNavigate={onNavigate} />
<ProfileMenu username={currentUser} role={currentRole} onLogout={onLogout} />
</div>
</header>
<div className="portal-empty"><p>Loading…</p></div>
</div>
);
return (
<div className="app">
{modal && (
<TicketModal
ticket={modal.ticket}
mode={modal.mode}
currentUser={currentUser}
currentRole={currentRole}
products={products}
allUsers={allUsers}
onSave={handleSave}
onClose={() => setModal(null)}
/>
)}
{deleteId && (
<div className="modal-overlay">
<div className="modal">
<h3>Delete Ticket?</h3>
<p>This cannot be undone.</p>
<div className="modal-actions">
<button className="btn-cancel" onClick={() => setDeleteId(null)}>Cancel</button>
<button className="btn-danger" onClick={handleDelete}>Delete</button>
</div>
</div>
</div>
)}
{showLog && <LogPanel refType="ticket" onClose={() => setShowLog(false)} />}
<header className="app-header">
<div className="header-left"><h1>Tickets</h1></div>
<div className="header-right">
<button className="btn-view-report" onClick={openCreate}>+ New Ticket</button>
{isAdmin && <button className="btn-secondary-sm" onClick={() => setShowLog(true)}>πŸ“‹ Log</button>}
<button className="btn-secondary-sm" onClick={onBack}>← Portal</button>
<NotificationBell username={currentUser} onNavigate={onNavigate} />
<ProfileMenu username={currentUser} role={currentRole} onLogout={onLogout} />
</div>
</header>
<main className="app-main">
<div className="toolbar" style={{ flexWrap: "wrap", gap: 8 }}>
<div className="filter-chips">
<button className={`chip ${filterSt === "all" ? "active" : ""}`} onClick={() => setFilterSt("all")}>All Status</button>
{TICKET_STATUSES.map(s => (
<button key={s} className={`chip ${filterSt === s ? "active" : ""}`} onClick={() => setFilterSt(s)}>
{s.charAt(0).toUpperCase() + s.slice(1).replace("-", " ")}
</button>
))}
</div>
<div className="filter-chips">
<button className={`chip ${filterPr === "all" ? "active" : ""}`} onClick={() => setFilterPr("all")}>All Priority</button>
{TICKET_PRIORITIES.map(p => (
<button key={p} className={`chip ${filterPr === p ? "active" : ""}`} onClick={() => setFilterPr(p)}>
{p.charAt(0).toUpperCase() + p.slice(1)}
</button>
))}
</div>
<input type="search" placeholder="Search tickets…" className="search-box"
value={search} onChange={e => setSearch(e.target.value)} />
</div>
<div className="table-wrap">
<table className="qa-table">
<thead>
<tr>
<th>#</th>
<th>Title</th>
<th>Level</th>
<th>Product</th>
<th>Priority</th>
<th>Reporter</th>
<th>Assignee</th>
<th>Status</th>
<th>Created</th>
<th>Due Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{visible.map((t, i) => (
<tr key={t.id}>
<td>{i + 1}</td>
<td style={{ maxWidth: 200, wordBreak: "break-word" }}>{t.title}</td>
<td><span className={`ticket-level-badge level-${t.level ?? "III"}`}>Level {t.level ?? "III"}</span></td>
<td>{t.productName ?? "β€”"}</td>
<td><span className={`priority-${t.priority}`}>{t.priority.charAt(0).toUpperCase() + t.priority.slice(1)}</span></td>
<td>{t.reporter}</td>
<td>{t.assignee ?? "β€”"}</td>
<td><span className="status-pill">{t.status.replace("-", " ")}</span></td>
<td style={{ whiteSpace: "nowrap" }}>{fmtDateTime(t.createdAt)}</td>
<td style={{ whiteSpace: "nowrap" }}>
{t.dueDate
? <span className={new Date(t.dueDate) < new Date() && t.status !== "closed" && t.status !== "resolved" ? "due-date-overdue" : "due-date"}>
{new Date(t.dueDate).toLocaleDateString()}
</span>
: "β€”"}
</td>
<td>
{(isAdmin || isReviewer || t.reporter === currentUser || t.assignee === currentUser) &&
<button className="btn-view-report sm" onClick={() => openEdit(t)}>Edit</button>}
{isAdmin &&
<button className="btn-danger-outline sm" style={{ marginLeft: 4 }} onClick={() => setDeleteId(t.id)}>Del</button>}
</td>
</tr>
))}
{visible.length === 0 && (
<tr><td colSpan={9} className="empty-row">No tickets match the current filter.</td></tr>
)}
</tbody>
</table>
</div>
</main>
</div>
);
}
function TicketModal({ ticket: initTicket, mode, currentUser, currentRole, products, allUsers, onSave, onClose }) {
const [ticket, setTicket] = useState({ ...initTicket });
const [saving, setSaving] = useState(false);
const [assigneeProducts, setAssigneeProducts] = useState(null); // null = all
const isAdmin = currentRole === "admin";
const isReviewer = currentRole === "reviewer";
const isTester = currentRole === "tester";
useEffect(() => {
if (ticket.assignee) {
const assigneeUser = allUsers.find(u => u.username === ticket.assignee);
if (assigneeUser?.role === "tester") {
dbGetUserProducts(ticket.assignee).then(setAssigneeProducts).catch(() => setAssigneeProducts(null));
} else {
setAssigneeProducts(null); // admin/reviewer: show all products
}
} else {
setAssigneeProducts(null);
}
}, [ticket.assignee, allUsers]);
const wordCount = (ticket.description ?? "").trim() === "" ? 0 : (ticket.description ?? "").trim().split(/\s+/).length;
const assignableUsers = allUsers.filter(u => {
const lvl = ticket.level ?? "III";
if (lvl === "I" && u.role !== "admin") return false;
if (lvl === "II" && u.role !== "admin" && u.role !== "reviewer") return false;
if (isAdmin) return true;
if (isReviewer) return true;
return false;
});
const filteredProducts = assigneeProducts !== null
? products.filter(p => assigneeProducts.includes(p.id))
: products;
const canAssign = isAdmin || isReviewer;
const canChangeStatus = isAdmin || isReviewer;
const handleSubmit = (e) => {
e.preventDefault();
if (!ticket.title.trim()) return;
onSave(ticket);
};
return (
<div className="modal-overlay">
<div className="modal task-modal task-modal--ticket">
<div className="task-modal-header"><h3>{mode === "create" ? "New Ticket" : "Edit Ticket"}</h3></div>
<form onSubmit={handleSubmit}>
<div className="task-modal-body">
<div className="task-modal-grid">
{/* Left column */}
<div className="task-modal-left">
<label className="login-label">Title *</label>
<input className="login-input" value={ticket.title}
onChange={e => setTicket(t => ({ ...t, title: e.target.value }))}
placeholder="Ticket title" required
/>
<label className="login-label" style={{ marginTop: 12 }}>Description</label>
<div style={{ position: "relative" }}>
<textarea className="login-input" rows={5}
style={{ resize: "vertical", fontFamily: "inherit", fontSize: 13, paddingBottom: 24 }}
value={ticket.description}
onChange={e => {
const words = e.target.value.trim() === "" ? 0 : e.target.value.trim().split(/\s+/).length;
if (words <= 200) setTicket(t => ({ ...t, description: e.target.value }));
}}
placeholder="Description… (max 200 words)" />
<span className={`word-count ${wordCount >= 200 ? "at-limit" : wordCount >= 180 ? "near-limit" : ""}`}
style={{ position: "absolute", bottom: 6, right: 8 }}>
{wordCount} / 200
</span>
</div>
</div>
{/* Right column */}
<div className="task-modal-right">
<label className="login-label">Ticket Level</label>
<select className="login-input" value={ticket.level ?? "III"}
disabled={isTester}
onChange={e => {
const newLevel = e.target.value;
setTicket(t => {
const assigneeUser = allUsers.find(u => u.username === t.assignee);
const roleOk = newLevel === "III" ? true
: newLevel === "II" ? (assigneeUser?.role === "admin" || assigneeUser?.role === "reviewer")
: assigneeUser?.role === "admin";
return { ...t, level: newLevel, assignee: roleOk ? t.assignee : null };
});
}}>
{isAdmin && <option value="I">Level I (Admin)</option>}
{(isAdmin || isReviewer) && <option value="II">Level II (Reviewer)</option>}
<option value="III">Level III (Tester)</option>
</select>
<label className="login-label" style={{ marginTop: 12 }}>Priority</label>
<select className="login-input" value={ticket.priority}
onChange={e => setTicket(t => ({ ...t, priority: e.target.value }))}
>
{TICKET_PRIORITIES.map(p => <option key={p} value={p}>{p.charAt(0).toUpperCase() + p.slice(1)}</option>)}
</select>
{canAssign && (
<>
<label className="login-label" style={{ marginTop: 12 }}>Assignee</label>
<select className="login-input" value={ticket.assignee ?? ""}
onChange={e => {
const newAssignee = e.target.value || null;
setTicket(t => ({ ...t, assignee: newAssignee, productId: null, productName: null }));
}}>
<option value="">Unassigned</option>
{assignableUsers.map(u => <option key={u.username} value={u.username}>{u.username} ({u.role})</option>)}
</select>
</>
)}
<label className="login-label" style={{ marginTop: 12 }}>
Product {ticket.assignee && assigneeProducts !== null && <span style={{ fontSize: 11, color: "#888" }}>(for {ticket.assignee})</span>}
</label>
<select className="login-input" value={ticket.productId ?? ""}
onChange={e => {
const p = filteredProducts.find(x => x.id === e.target.value);
setTicket(t => ({ ...t, productId: p?.id ?? null, productName: p?.name ?? null }));
}}
>
<option value="">None</option>
{filteredProducts.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
{canChangeStatus && mode === "edit" && (
<>
<label className="login-label" style={{ marginTop: 12 }}>Status</label>
<select className="login-input" value={ticket.status}
onChange={e => setTicket(t => ({ ...t, status: e.target.value }))}
disabled={isReviewer && ticket.status === "closed"}>
{TICKET_STATUSES.filter(s => !(isReviewer && s === "closed")).map(s => (
<option key={s} value={s}>{s.replace("-", " ").charAt(0).toUpperCase() + s.replace("-", " ").slice(1)}</option>
))}
</select>
</>
)}
<label className="login-label" style={{ marginTop: 12 }}>Due Date</label>
<input type="date" className="login-input"
value={ticket.dueDate ?? ""}
onChange={e => setTicket(t => ({ ...t, dueDate: e.target.value || null }))} />
{mode === "edit" && (
<div style={{ marginTop: 10, fontSize: 12, color: "#888" }}>
Created: {new Date(ticket.createdAt).toLocaleDateString()}
</div>
)}
<label className="login-label" style={{ marginTop: 12 }}>Images</label>
{ticket.images?.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, marginBottom: 8 }}>
{ticket.images.map((img, i) => (
<div key={i} style={{ position: "relative" }}>
<img src={img.base64 ?? img.url} alt="" style={{ width: 64, height: 64, objectFit: "cover", borderRadius: 6 }} />
<button type="button" style={{ position: "absolute", top: 0, right: 0, background: "rgba(0,0,0,0.6)", color: "#fff", border: "none", borderRadius: "0 6px 0 6px", cursor: "pointer", fontSize: 11, padding: "1px 5px" }}
onClick={() => setTicket(t => ({ ...t, images: t.images.filter((_, j) => j !== i) }))}>βœ•</button>
</div>
))}
</div>
)}
<TaskImageUpload onAdd={img => setTicket(t => ({ ...t, images: [...t.images, img] }))} />
</div>
</div>
</div>
<div className="modal-actions" style={{ margin: "16px 28px 24px" }}>
<button type="button" className="btn-cancel" onClick={onClose}>Cancel</button>
<button type="submit" className="btn-primary" disabled={!ticket.title.trim()}>
{mode === "create" ? "Create Ticket" : "Save Changes"}
</button>
</div>
</form>
</div>
</div>
);
}
// ── IdleScreen ────────────────────────────────────────────────────────────────
function IdleScreen({ session, onStart, onNavigate, onLogout }) {
const role = session.role ?? "tester";
const isAdmin = role === "admin";
const canReview = role === "admin" || role === "reviewer";
return (
<div className="idle-page">
<header className="app-header">
<div className="header-left">
<BrandTitle />
</div>
<div className="header-right">
<NotificationBell username={session.username} onNavigate={onNavigate} />
<ProfileMenu username={session.username} role={session.role} onLogout={onLogout} />
</div>
</header>
<div className="idle-body portal-body">
<div className="portal-welcome">
<h2>Welcome, {session.username}</h2>
<p>Select a portal to begin. Logged in at {fmtDateTime(session.loginTime)}.</p>
</div>
<div className="portal-cards">
{canReview && (
<div className="portal-card">
<div className="portal-card-icon">πŸ–₯️</div>
<h3>Automation Testing</h3>
<p>Full QA evaluation with scoring, notes, and screenshots.</p>
<ul className="portal-features">
<li>Score each question (0–max marks)</li>
<li>Add evaluation notes</li>
<li>Upload or capture screenshots</li>
<li>12-hour session window</li>
</ul>
<button className="btn-start" onClick={() => onStart("admin")}>Start Automation Testing</button>
</div>
)}
<div className="portal-card portal-card-teal">
<div className="portal-card-icon">πŸ“Έ</div>
<h3>Tester Portal</h3>
<p>Upload screenshots for each question β€” no scoring required.</p>
<ul className="portal-features">
<li>Upload or capture screenshot per question</li>
<li>No scoring or notes needed</li>
<li>60-minute session window</li>
</ul>
<button className="btn-start btn-start-teal" onClick={() => onStart("tester")}>Start Tester Session</button>
</div>
{canReview && (
<div className="portal-card portal-card-orange">
<div className="portal-card-icon">πŸ”</div>
<h3>Review Submissions</h3>
<p>Assess tester-submitted screenshots, assign marks and add comments.</p>
<ul className="portal-features">
<li>View each tester's uploaded photos</li>
<li>Assign marks per question</li>
<li>Add reviewer comments</li>
<li>Generate reviewed report</li>
</ul>
<button className="btn-start btn-start-orange" onClick={() => onNavigate("review")}>Review Tester Submissions</button>
</div>
)}
{(canReview || role === "tester") && (
<div className="portal-card portal-card-purple">
<div className="portal-card-icon">πŸ“‹</div>
<h3>All Reports</h3>
<p>View all completed testing sessions and reviewed reports.</p>
<ul className="portal-features">
<li>Automation testing reports</li>
<li>Tester submissions with review status</li>
<li>Named by user, date and time</li>
</ul>
<button className="btn-start btn-start-purple" onClick={() => onNavigate("reports")}>View All Reports</button>
</div>
)}
{isAdmin && (
<div className="portal-card portal-card-red">
<div className="portal-card-icon">🏷️</div>
<h3>Manage Products</h3>
<p>Create and manage products (PE, PT, PL…) and assign user access.</p>
<ul className="portal-features">
<li>Create PE, PT, PL and custom products</li>
<li>Assign product access per user</li>
<li>Testers &amp; reviewers see their products only</li>
</ul>
<button className="btn-start btn-start-red" onClick={() => onNavigate("products")}>Manage Products</button>
</div>
)}
{isAdmin && (
<div className="portal-card portal-card-gray">
<div className="portal-card-icon">βš™οΈ</div>
<h3>Testing Profiles</h3>
<p>Create and manage named question sets for different testing scenarios.</p>
<ul className="portal-features">
<li>Create Testing 1, Testing 2…</li>
<li>Custom questions &amp; marks per profile</li>
<li>Select profile when starting a session</li>
<li>Reset to default 30 questions</li>
</ul>
<button className="btn-start btn-start-gray" onClick={() => onNavigate("profiles")}>Manage Testing Profiles</button>
</div>
)}
{isAdmin && (
<div className="portal-card portal-card-indigo">
<div className="portal-card-icon">πŸ‘₯</div>
<h3>Manage Accounts</h3>
<p>View all registered users, assign roles, and manage product access.</p>
<ul className="portal-features">
<li>Assign Admin, Reviewer, or Tester role</li>
<li>Assign product access per user</li>
<li>Reset any user's password</li>
<li>Delete inactive accounts</li>
</ul>
<button className="btn-start btn-start-indigo" onClick={() => onNavigate("accounts")}>Manage Accounts</button>
</div>
)}
<div className="portal-card portal-card-violet">
<div className="portal-card-icon">πŸ“‹</div>
<h3>Planner Board</h3>
<p>Kanban-style task board for tracking work across products and teams.</p>
<ul className="portal-features">
<li>Backlog β†’ In Progress β†’ In Review β†’ Done</li>
<li>Assign tasks to team members</li>
<li>Tag users for notifications</li>
<li>Attach images per task</li>
</ul>
<button className="btn-start btn-start-violet" onClick={() => onNavigate("planner")}>Open Planner Board</button>
</div>
<div className="portal-card portal-card-amber">
<div className="portal-card-icon">🎫</div>
<h3>Tickets</h3>
<p>Raise and track change requests or issues against any product.</p>
<ul className="portal-features">
<li>Open β†’ In Progress β†’ Resolved β†’ Closed</li>
<li>Low / Medium / High priority</li>
<li>Assign to team members</li>
<li>Attach images per ticket</li>
</ul>
<button className="btn-start btn-start-amber" onClick={() => onNavigate("tickets")}>Open Tickets</button>
</div>
</div>
</div>
</div>
);
}
// ── TerminatedScreen ──────────────────────────────────────────────────────────
function TerminatedScreen({ session, rows, onSubmitReport, onNewTest, onLogout }) {
const totalPossible = rows.reduce((s, r) => s + (r.possibleMarks ?? 0), 0);
const totalEarned = rows.reduce((s, r) => s + (r.earnedScore ?? 0), 0);
const pct = totalPossible > 0 ? ((totalEarned / totalPossible) * 100).toFixed(1) : "β€”";
const passed = rows.filter(r => r.possibleMarks && r.earnedScore !== null && r.earnedScore / r.possibleMarks >= 0.8).length;
const failed = rows.filter(r => r.possibleMarks && r.earnedScore !== null && r.earnedScore / r.possibleMarks < 0.5).length;
return (
<div className="idle-page">
<header className="app-header">
<div className="header-left"><BrandTitle /></div>
<div className="header-right"><ProfileMenu username={session.username} role={session.role} onLogout={onLogout} /></div>
</header>
<div className="idle-body">
<div className="idle-card">
<div className="idle-icon">βœ…</div>
<h2>Testing Complete</h2>
<div className="idle-meta">
<div><span>Started at</span><strong>{fmtDateTime(session.testingStart)}</strong></div>
<div><span>Terminated at</span><strong>{fmtDateTime(session.testingEnd)}</strong></div>
<div><span>Duration</span><strong>{fmtDuration(session.testingEnd - session.testingStart)}</strong></div>
</div>
<div className="term-stats">
<div className="t-stat blue"><span className="t-val">{rows.length}</span><span className="t-lbl">Questions</span></div>
<div className="t-stat green"><span className="t-val">{totalEarned}/{totalPossible}</span><span className="t-lbl">Marks</span></div>
<div className="t-stat purple"><span className="t-val">{pct}{pct !== "β€”" ? "%" : ""}</span><span className="t-lbl">Score</span></div>
<div className="t-stat teal"><span className="t-val">{passed}</span><span className="t-lbl">Passed</span></div>
<div className="t-stat red"><span className="t-val">{failed}</span><span className="t-lbl">Failed</span></div>
</div>
<div className="term-actions">
<button className="btn-start" onClick={onSubmitReport}>Submit Report</button>
<button className="btn-secondary" onClick={onNewTest}>Start New Testing</button>
</div>
</div>
</div>
</div>
);
}
// ── ExpiredOverlay ────────────────────────────────────────────────────────────
function ExpiredOverlay({ session, rows, onLogout }) {
const isTester = session.mode === "tester";
const dur = getDuration(session);
const label = isTester ? "60-minute" : "12-hour";
const handleReport = () => {
const end = session.testingStart + dur;
if (isTester) openTesterReport(rows, { ...session, testingEnd: end });
else openReport(rows, { ...session, testingEnd: end });
};
return (
<div className="expired-overlay">
<div className="expired-box">
<div className="expired-icon">⏱</div>
<h2>Time Limit Reached</h2>
<p>Your {label} testing window has ended.</p>
<div style={{ display:"flex", gap:12, justifyContent:"center", flexWrap:"wrap", marginTop:24 }}>
<button className="btn-view-report" onClick={handleReport}>View Report</button>
<button className="btn-logout-lg" onClick={onLogout}>Log Out</button>
</div>
</div>
</div>
);
}
// ── Main App ──────────────────────────────────────────────────────────────────
export default function App() {
const [questions, setQuestions] = useState(() => getSessionQuestions() ?? getActiveQuestions());
const [session, setSession] = useState(() => getSession());
const [rows, setRows] = useState(() => {
const s = getSession();
const qs = getSessionQuestions() ?? getActiveQuestions();
const stored = s ? getScores(s.username) : {};
return qs.map((q) => ({
...blankRow(q),
possibleMarks: stored[q.id]?.possibleMarks ?? null,
earnedScore: stored[q.id]?.score ?? null,
evalNote: stored[q.id]?.evalNote ?? "",
}));
});
const [filter, setFilter] = useState("ALL");
const [search, setSearch] = useState("");
const [remaining, setRemaining] = useState(() => {
const s = getSession();
if (!s?.testingStart || s?.testingEnd) return getDuration(s);
return Math.max(0, getDuration(s) - (Date.now() - s.testingStart));
});
const [expired, setExpired] = useState(false);
const [showTerminate, setShowTerminate] = useState(false);
const [validationErrors, setValidationErrors] = useState(null);
const [submitSuccess, setSubmitSuccess] = useState(null); // { isTester: bool }
const [view, setView] = useState(null);
const [editingProfile, setEditingProfile] = useState(null);
const [profilePicker, setProfilePicker] = useState(null); // mode string while picker is open
const timerRef = useRef(null);
// Auto-clean photos older than 7 days
useEffect(() => {
const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000;
const cutoff = Date.now() - SEVEN_DAYS;
dbGetSubmissions().then(subs => {
const stale = subs.filter(s =>
s.endTime < cutoff && s.rows?.some(r => r.screenshot)
);
stale.forEach(s => {
deleteSubmissionPhotos(s);
dbSaveSubmission({ ...s, rows: s.rows.map(r => ({ ...r, screenshot: null })) });
});
}).catch(() => {});
}, []);
// Overdue due-date notifications
useEffect(() => {
if (!session) return;
const DEDUP_KEY = "qa_overdue_notified";
const getSent = () => { try { return JSON.parse(localStorage.getItem(DEDUP_KEY) || "{}"); } catch { return {}; } };
const markSent = (id) => { const s = getSent(); s[id] = true; localStorage.setItem(DEDUP_KEY, JSON.stringify(s)); };
const checkOverdue = async () => {
try {
const [tasks, tickets, allUsers] = await Promise.all([dbGetTasks(), dbGetTickets(), dbGetAllUsers()]);
const now = new Date();
const sent = getSent();
const overdueTasks = tasks.filter(t =>
t.dueDate && new Date(t.dueDate) < now && t.status !== "done" && !sent[`task_${t.id}`]
);
const overdueTickets = tickets.filter(t =>
t.dueDate && new Date(t.dueDate) < now && t.status !== "closed" && t.status !== "resolved" && !sent[`ticket_${t.id}`]
);
const adminsReviewers = allUsers.filter(u => u.role === "admin" || u.role === "reviewer");
for (const task of overdueTasks) {
const recipients = new Set([
...adminsReviewers.map(u => u.username),
...(task.assignee ? [task.assignee] : []),
...(task.tags ?? []),
task.createdBy,
]);
const ts = Date.now();
await Promise.all([...recipients].map(username =>
dbSaveNotification({
id: `overdue_task_${task.id}_${username}`,
toUsername: username,
message: `Task overdue: "${task.title}" was due on ${new Date(task.dueDate).toLocaleDateString()}`,
type: "task_overdue", refId: task.id, refType: "task",
read: false, createdAt: ts,
})
));
markSent(`task_${task.id}`);
}
for (const ticket of overdueTickets) {
const recipients = new Set([
...adminsReviewers.map(u => u.username),
...(ticket.assignee ? [ticket.assignee] : []),
ticket.reporter,
]);
const ts = Date.now();
await Promise.all([...recipients].map(username =>
dbSaveNotification({
id: `overdue_ticket_${ticket.id}_${username}`,
toUsername: username,
message: `Ticket overdue: "${ticket.title}" was due on ${new Date(ticket.dueDate).toLocaleDateString()}`,
type: "ticket_overdue", refId: ticket.id, refType: "ticket",
read: false, createdAt: ts,
})
));
markSent(`ticket_${ticket.id}`);
}
} catch { /* silent */ }
};
checkOverdue();
const interval = setInterval(checkOverdue, 5 * 60 * 1000); // re-check every 5 minutes
return () => clearInterval(interval);
}, [session?.username]);
// Countdown
useEffect(() => {
if (!session?.testingStart || session?.testingEnd) return;
const duration = getDuration(session);
const tick = () => {
const left = duration - (Date.now() - session.testingStart);
if (left <= 0) { setRemaining(0); setExpired(true); clearInterval(timerRef.current); }
else setRemaining(left);
};
tick();
timerRef.current = setInterval(tick, 1000);
return () => clearInterval(timerRef.current);
}, [session?.testingStart, session?.testingEnd, session?.mode]);
// Persist rows
useEffect(() => {
if (!session?.username || !session?.testingStart) return;
const data = Object.fromEntries(rows.map(r => [r.id, {
score: r.earnedScore, evalNote: r.evalNote, possibleMarks: r.possibleMarks,
}]));
saveScores(session.username, data);
}, [rows, session?.username, session?.testingStart]);
const handleLogin = (s) => setSession(s);
const handleNavigate = (v) => setView(v);
const handleLogout = () => {
logout();
clearSessionQuestions();
setSession(null);
setView(null);
setRows(questions.map(q => blankRow(q)));
setExpired(false);
setRemaining(SESSION_DURATION);
clearInterval(timerRef.current);
};
const handleStart = (mode, profileQs = null, profileName = null, productId = null, productName = null) => {
const qs = profileQs ?? getActiveQuestions();
saveSessionQuestions(qs);
setQuestions(qs);
setRows(qs.map(q => blankRow(q)));
setSession(startTesting(session, mode, profileName, productId, productName));
setProfilePicker(null);
setExpired(false);
};
const handleStartRequest = async (mode) => {
const [products, profiles] = await Promise.all([dbGetProducts(), dbGetProfiles()]);
if (products.length > 0 || profiles.length > 0) setProfilePicker(mode);
else handleStart(mode, null);
};
const handleNewTest = () => {
const updated = resetTesting(session);
clearScores(session.username);
clearSessionQuestions();
setSession(updated);
setRows(questions.map(q => blankRow(q)));
setRemaining(SESSION_DURATION);
setExpired(false);
clearInterval(timerRef.current);
};
const handleSubmitReport = async () => {
const isTester = session.mode === "tester";
const missing = isTester
? rows.filter(r => !r.screenshot).map(r => ({ id: r.id, standard: r.standard, fields: ["screenshot"] }))
: getMissingFields(rows);
if (missing.length > 0) { setValidationErrors(missing); return; }
const end = Date.now();
const sub = makeSubmission(isTester ? "tester" : "admin", session.username, session, rows);
sub.endTime = end;
await dbSaveSubmission(sub);
// Notify admins and reviewers about new report
dbGetAllUsers().then(users => {
const now = Date.now();
Promise.all(
users
.filter(u => u.role === "admin" || u.role === "reviewer")
.filter(u => u.username !== session.username)
.map(u => dbSaveNotification({
id: `notif_${now}_${u.username}_${sub.id}`,
toUsername: u.username,
message: `${session.username} submitted a report`,
type: "report_submitted", refId: sub.id, refType: "submission",
read: false, createdAt: now,
}))
);
}).catch(() => {});
clearScores(session.username);
clearSessionQuestions();
setSubmitSuccess({ isTester });
};
const handleSubmitSuccessBack = () => {
const updated = resetTesting(session);
setSession(updated);
setRows(questions.map(q => blankRow(q)));
setRemaining(getDuration(updated));
setSubmitSuccess(null);
clearInterval(timerRef.current);
};
const handleSubmitSuccessReports = () => {
const updated = resetTesting(session);
setSession(updated);
setRows(questions.map(q => blankRow(q)));
setRemaining(getDuration(updated));
setSubmitSuccess(null);
setView("reports");
clearInterval(timerRef.current);
};
const handleConfirmTerminate = () => {
const updated = resetTesting(session);
clearScores(session.username);
clearSessionQuestions();
setSession(updated);
setRows(questions.map(q => blankRow(q)));
setRemaining(SESSION_DURATION);
setShowTerminate(false);
clearInterval(timerRef.current);
};
const updatePossibleMarks = useCallback((id, v) =>
setRows(p => p.map(r => r.id === id ? { ...r, possibleMarks: v, earnedScore: v === null ? null : Math.min(r.earnedScore ?? 0, v) } : r)), []);
const updateScore = useCallback((id, v) =>
setRows(p => p.map(r => r.id === id ? { ...r, earnedScore: v } : r)), []);
const updateEvalNote = useCallback((id, text) => {
if (text.trim().split(/\s+/).filter(Boolean).length > 30) return;
setRows(p => p.map(r => r.id === id ? { ...r, evalNote: text } : r));
}, []);
const updateScreenshot = useCallback((id, s) =>
setRows(p => p.map(r => r.id === id ? { ...r, screenshot: s } : r)), []);
// ── State machine ──────────────────────────────────────────────────────────
if (!session) return <LoginPage onLogin={handleLogin} />;
if (view === "review") return <ReviewPortal currentUser={session.username} currentRole={session.role} onBack={() => setView(null)} onLogout={handleLogout} onNavigate={handleNavigate} />;
if (view === "reports") return <ReportsPortal currentUser={session.username} currentRole={session.role} onBack={() => setView(null)} onLogout={handleLogout} onNavigate={handleNavigate} />;
if (view === "accounts") return <AccountsPortal currentUser={session.username} currentRole={session.role} onBack={() => setView(null)} onLogout={handleLogout} />;
if (view === "products") return <ProductsPortal currentUser={session.username} currentRole={session.role} onBack={() => setView(null)} onLogout={handleLogout} />;
if (view === "planner") return <KanbanPortal currentUser={session.username} currentRole={session.role} onBack={() => setView(null)} onLogout={handleLogout} onNavigate={handleNavigate} />;
if (view === "tickets") return <TicketsPortal currentUser={session.username} currentRole={session.role} onBack={() => setView(null)} onLogout={handleLogout} onNavigate={handleNavigate} />;
if (view === "profiles") return <TestingProfilesPortal currentUser={session.username} currentRole={session.role}
onEdit={p => { setEditingProfile(p); setView("profile-editor"); }}
onBack={() => setView(null)} onLogout={handleLogout} />;
if (view === "profile-editor" && editingProfile) return <ProfileEditorPortal
profile={editingProfile} currentUser={session.username} currentRole={session.role}
onBack={() => setView("profiles")} onLogout={handleLogout} />;
if (session.testingStart && session.testingEnd)
return <TerminatedScreen session={session} rows={rows}
onSubmitReport={() => session.mode === "tester" ? openTesterReport(rows, session) : openReport(rows, session)}
onNewTest={handleNewTest} onLogout={handleLogout} />;
if (!session.testingStart) {
if (profilePicker) return <ProfilePickerPortal
mode={profilePicker} session={session}
onPick={(qs, name, prodId, prodName) => handleStart(profilePicker, qs, name, prodId, prodName)}
onCancel={() => setProfilePicker(null)} onLogout={handleLogout} />;
return <IdleScreen session={session} onStart={handleStartRequest} onNavigate={handleNavigate} onLogout={handleLogout} />;
}
if (expired)
return <ExpiredOverlay session={session} rows={rows} onLogout={handleLogout} />;
const isTester = session.mode === "tester";
const dur = getDuration(session);
const urgentTime = isTester ? remaining < 5 * 60 * 1000 : remaining < 60 * 60 * 1000;
const warningTime = isTester ? remaining < 15 * 60 * 1000 : remaining < 3 * 60 * 60 * 1000;
const categories = ["ALL", ...Array.from(new Set(rows.map(r => r.standard.split("-")[0])))];
const visible = rows.filter((r) => {
const prefix = r.standard.split("-")[0];
return (filter === "ALL" || prefix === filter) &&
(!search || r.standard.toLowerCase().includes(search.toLowerCase()) ||
r.observation.toLowerCase().includes(search.toLowerCase()));
});
// ── Tester dashboard ────────────────────────────────────────────────────────
if (isTester) {
const uploaded = rows.filter(r => r.screenshot).length;
return (
<div className="app">
{showTerminate && <TerminateModal onConfirm={handleConfirmTerminate} onCancel={() => setShowTerminate(false)} />}
{validationErrors && <ValidationModal missing={validationErrors} onClose={() => setValidationErrors(null)} />}
{submitSuccess && <SubmitSuccessModal isTester={submitSuccess.isTester} onBackToPortal={handleSubmitSuccessBack} onViewReports={handleSubmitSuccessReports} />}
<header className="app-header">
<div className="header-left">
<BrandTitle />
<p className="header-sub">Started: {fmtDateTime(session.testingStart)}</p>
</div>
<div className="header-right">
<CountdownBadge remaining={remaining} urgent={urgentTime} warning={warningTime} />
<span className="upload-progress">{uploaded}/{rows.length} uploaded</span>
<button className="btn-view-report" onClick={handleSubmitReport}>Submit Report</button>
<button className="btn-danger-outline" onClick={() => setShowTerminate(true)}>Terminate</button>
<NotificationBell username={session.username} onNavigate={() => {}} />
<ProfileMenu username={session.username} role={session.role} onLogout={handleLogout} />
</div>
</header>
<main className="app-main">
<div className="toolbar">
<div className="filter-chips">
{categories.map((cat) => (
<button key={cat}
className={`chip ${filter === cat ? "active" : ""}`}
style={filter === cat && cat !== "ALL" ? { background: CATEGORY_COLORS[cat], borderColor: CATEGORY_COLORS[cat] } : {}}
onClick={() => setFilter(cat)}
>{cat}</button>
))}
</div>
<input type="search" placeholder="Search questions…" className="search-box"
value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<div className="table-wrap">
<table className="qa-table">
<thead>
<tr>
<th className="col-num">#</th>
<th className="col-standard">Standard</th>
<th className="col-obs">Observation</th>
<th className="col-shot">Screenshot <span className="req-star">*</span></th>
</tr>
</thead>
<tbody>
{visible.map((row) => {
const color = CATEGORY_COLORS[row.standard.split("-")[0]] ?? "#888";
const rowClass = row.screenshot ? "row-pass" : "row-neutral";
return (
<tr key={row.id} className={rowClass}>
<td className="col-num">{row.id}</td>
<td className="col-standard"><span className="std-badge" style={{ background:color }}>{row.standard}</span></td>
<td className="col-obs">{row.observation}</td>
<td className="col-shot">
<ScreenshotCell screenshot={row.screenshot} required={!row.screenshot}
livePhotoOnly={session.role !== "admin" && session.role !== "reviewer"}
onUpload={(s) => updateScreenshot(row.id, s)}
onDiscard={() => updateScreenshot(row.id, null)} />
</td>
</tr>
);
})}
{visible.length === 0 && (
<tr><td colSpan={4} className="empty-row">No questions match the current filter.</td></tr>
)}
</tbody>
</table>
</div>
<p className="req-note"><span className="req-star">*</span> All screenshots are required to submit the report.</p>
</main>
</div>
);
}
// ── Admin dashboard ─────────────────────────────────────────────────────────
return (
<div className="app">
{showTerminate && <TerminateModal onConfirm={handleConfirmTerminate} onCancel={() => setShowTerminate(false)} />}
{validationErrors && <ValidationModal missing={validationErrors} onClose={() => setValidationErrors(null)} />}
{submitSuccess && <SubmitSuccessModal isTester={submitSuccess.isTester} onBackToPortal={handleSubmitSuccessBack} onViewReports={handleSubmitSuccessReports} />}
<header className="app-header">
<div className="header-left">
<BrandTitle />
<p className="header-sub">Started: {fmtDateTime(session.testingStart)}</p>
</div>
<div className="header-right">
<CountdownBadge remaining={remaining} urgent={urgentTime} warning={warningTime} />
<button className="btn-view-report" onClick={handleSubmitReport}>Submit Report</button>
<button className="btn-danger-outline" onClick={() => setShowTerminate(true)}>Terminate Testing</button>
<ProfileMenu username={session.username} onLogout={handleLogout} />
</div>
</header>
<main className="app-main">
<SummaryBar rows={rows} />
<div className="toolbar">
<div className="filter-chips">
{categories.map((cat) => (
<button key={cat}
className={`chip ${filter === cat ? "active" : ""}`}
style={filter === cat && cat !== "ALL" ? { background: CATEGORY_COLORS[cat], borderColor: CATEGORY_COLORS[cat] } : {}}
onClick={() => setFilter(cat)}
>{cat}</button>
))}
</div>
<input type="search" placeholder="Search questions…" className="search-box"
value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<div className="table-wrap">
<table className="qa-table">
<thead>
<tr>
<th className="col-num">#</th>
<th className="col-standard">Standard</th>
<th className="col-obs">Observation</th>
<th className="col-marks">Possible Marks</th>
<th className="col-score">Earned Score <span className="req-star">*</span></th>
<th className="col-shot">Screenshot <span className="req-star">*</span></th>
</tr>
</thead>
<tbody>
{visible.map((row) => {
const color = CATEGORY_COLORS[row.standard.split("-")[0]] ?? "#888";
const pct = row.possibleMarks && row.earnedScore !== null ? row.earnedScore / row.possibleMarks : null;
const rowClass = pct === null ? "row-neutral" : pct >= 0.8 ? "row-pass" : pct < 0.5 ? "row-fail" : "row-warn";
const note = row.evalNote ?? "";
const wordCount = note.trim() === "" ? 0 : note.trim().split(/\s+/).length;
return (
<React.Fragment key={row.id}>
<tr className={rowClass}>
<td className="col-num">{row.id}</td>
<td className="col-standard"><span className="std-badge" style={{ background:color }}>{row.standard}</span></td>
<td className="col-obs">{row.observation}</td>
<td className="col-marks">
<span className="marks-static">{row.possibleMarks}</span>
</td>
<td className="col-score">
<ScoreInput value={row.earnedScore} max={row.possibleMarks}
onChange={(v) => updateScore(row.id, v)} />
</td>
<td className="col-shot">
<ScreenshotCell screenshot={row.screenshot} required={!row.screenshot}
onUpload={(s) => updateScreenshot(row.id, s)}
onDiscard={() => updateScreenshot(row.id, null)} />
</td>
</tr>
<tr className={`note-row ${rowClass}`}>
<td colSpan={6} className="note-cell">
<div className="note-wrap">
<span className="note-label">Observation</span>
<textarea className="note-textarea" rows={2}
placeholder="Enter your observation… (max 30 words)"
value={note} onChange={(e) => updateEvalNote(row.id, e.target.value)} />
<span className={`word-count ${wordCount >= 30 ? "at-limit" : wordCount >= 25 ? "near-limit" : ""}`}>
{wordCount} / 30
</span>
</div>
</td>
</tr>
</React.Fragment>
);
})}
{visible.length === 0 && (
<tr><td colSpan={6} className="empty-row">No questions match the current filter.</td></tr>
)}
</tbody>
</table>
</div>
<p className="req-note"><span className="req-star">*</span> Earned score and screenshot are required to submit or terminate.</p>
</main>
</div>
);
}