// ============ TaskQuest — Timesheets (weekly grid · capture · approval) ============ const { useState: useStateT, useEffect: useEffectT } = React; // re-render whenever the timesheet store changes function useTimeVersion() { const [, force] = useStateT(0); useEffectT(() => window.TQ_TIME.subscribe(() => force(v => v + 1)), []); } function TsStatusBadge({ status }) { const meta = { draft: { c: "#878b94", t: "Draft" }, submitted: { c: "#f2c94c", t: "Submitted" }, approved: { c: "#4cb782", t: "Approved" }, rejected: { c: "#eb5757", t: "Rejected" }, }[status] || { c: "#878b94", t: status }; return {meta.t}; } // ---------- week navigator ---------- function WeekNav({ weekStart, setWeekStart }) { const T = window.TQ_TIME; const isThis = weekStart === T.thisWeekStart(); return (
{T.fmtWeekRange(weekStart)}
{isThis ? "This week" : "Week of " + T.fmtDay(weekStart)}
{!isThis && }
); } // ---------- a single editable / read-only hour cell ---------- function HourCell({ consultant, task, project, date, weekend, editable }) { const T = window.TQ_TIME; const stored = T.getHours(consultant, task, date); const [val, setVal] = useStateT(stored ? String(stored) : ""); if (!editable) { return {stored || "·"}; } function commit(s) { setVal(s); const n = s === "" ? 0 : Number(s); if (!isNaN(n)) T.setHours(consultant, task, project, date, n); } return ( commit(e.target.value)} onFocus={e => e.target.select()} /> ); } // ---------- the weekly grid (shared: consultant edits, manager reads) ---------- function WeeklyGrid({ consultant, weekStart, tasks, editable }) { const T = window.TQ_TIME; const model = T.weekModel(consultant, weekStart, tasks); const warn = T.warnings(model); const days = model.days; const todayI = T.todayISO(); if (model.projects.length === 0) { return (

No tasks to log against

{editable ? "Hours can be logged once you have assigned tasks for this week." : "Nothing was logged this week."}
); } return (
{/* header */}
Task
Activity
Billable
{days.map((d, i) => (
= 5 ? "weekend" : ""} ${d === todayI ? "is-today" : ""}`}> {T.dayLabel(d)} {T.dayNum(d)}
))}
Total
{/* projects + tasks */} {model.projects.map(g => (
{g.name} {g.tasks.length} task{g.tasks.length === 1 ? "" : "s"}
{g.total ? g.total + "h" : "—"}
{g.tasks.map(row => (
{row.task.name}
{row.task.id}
{days.map((d, i) => (
= 5} editable={editable} key={consultant + "|" + row.task.id + "|" + d} />
))}
{row.total || "—"}
))}
))} {/* footer totals */}
Daily total
{days.map(d => (
{model.dayTotals[d] || "—"}
))}
{model.weekTotal || 0}h
); } // ---------- CONSULTANT: my weekly timesheet ---------- function ConsultantTimesheet({ role, tasks, addToast, pushNotif }) { useTimeVersion(); const T = window.TQ_TIME; const [weekStart, setWeekStart] = useStateT(() => T.thisWeekStart()); const model = T.weekModel(role, weekStart, tasks); const warn = T.warnings(model); const sheet = T.statusOf(role, weekStart); const locked = T.isLocked(role, weekStart); const ROLES = window.TQ.ROLES; function submit() { if (model.weekTotal <= 0) { addToast("Log some hours before submitting", Ic.Alert, "#eb5757"); return; } T.submitWeek(role, weekStart, ""); const proj = model.projects[0] ? model.projects[0].key : null; pushNotif({ type: "done", who: role, project: proj, forRoles: ["pm", "dm"], text: `${ROLES[role] ? ROLES[role].name : "A consultant"} submitted a timesheet (${T.fmtWeekRange(weekStart)}) — ${model.weekTotal}h, needs approval` }); addToast("Timesheet submitted for approval", Ic.Send, "#f2c94c"); } const statusBanner = (() => { if (sheet.status === "submitted") return (
Submitted — awaiting manager approval
{model.weekTotal}h logged · submitted for {T.fmtWeekRange(weekStart)}. Locked until reviewed.
); if (sheet.status === "approved") return (
Approved by {ROLES[sheet.decidedBy] ? ROLES[sheet.decidedBy].name : "your manager"}
{model.weekTotal}h signed off for {T.fmtWeekRange(weekStart)}.
); if (sheet.status === "rejected") return (
Returned for changes by {ROLES[sheet.decidedBy] ? ROLES[sheet.decidedBy].name : "your manager"}
{sheet.reason ? "“" + sheet.reason + "” — " : ""}Update your hours and resubmit.
); return (
Draft — log your hours, then submit the week
Type hours into each day. {model.weekTotal}h so far this week.
); })(); return (
{statusBanner} {(warn.week || Object.keys(warn.days).length > 0) && (
{warn.week ? `This week exceeds ${T.WARN_WEEK}h.` : `One or more days exceed ${T.WARN_DAY}h.`} You can still submit — just confirming it's intentional.
)}
Week total {model.weekTotal}h · {model.billable}h billable {!locked && ( )} {sheet.status === "submitted" && Locked — awaiting review}
); } // ---------- MANAGER: review queue + export ---------- function ManagerTimesheets({ role, project, tasks, addToast, pushNotif }) { useTimeVersion(); const T = window.TQ_TIME; const { ROLES, PROJECTS, CONSULTANTS, projectsOf } = window.TQ; const [weekStart, setWeekStart] = useStateT(() => T.thisWeekStart()); const [openId, setOpenId] = useStateT(null); // consultants in scope const consultants = CONSULTANTS.filter(c => { if (project === "all") return true; return projectsOf(ROLES[c]).indexOf(project) >= 0; }); const rows = consultants.map(c => { const model = T.weekModel(c, weekStart, tasks); const sheet = T.statusOf(c, weekStart); // scope hours to the selected project when not 'all' let hours = model.weekTotal, billable = model.billable; if (project !== "all") { const g = model.projects.find(p => p.key === project); hours = g ? g.total : 0; billable = g ? g.tasks.reduce((a, r) => a + (r.meta.billable ? r.total : 0), 0) : 0; } return { c, model, sheet, hours, billable }; }).filter(r => r.hours > 0 || r.sheet.status !== "draft"); const order = { submitted: 0, rejected: 1, draft: 2, approved: 3 }; rows.sort((a, b) => (order[a.sheet.status] - order[b.sheet.status]) || (ROLES[a.c] ? ROLES[a.c].name : a.c).localeCompare(ROLES[b.c] ? ROLES[b.c].name : b.c)); const submittedCount = rows.filter(r => r.sheet.status === "submitted").length; const totalHours = rows.reduce((a, r) => a + r.hours, 0); const billableHours = rows.reduce((a, r) => a + r.billable, 0); const open = openId ? rows.find(r => r.c === openId) : null; function decideApprove(c) { T.approveWeek(c, weekStart, role); pushNotif({ type: "approved", who: role, project: project === "all" ? null : project, forRoles: [c], text: `Your timesheet for ${T.fmtWeekRange(weekStart)} was approved by ${ROLES[role] ? ROLES[role].name : "your manager"}` }); addToast(`Approved ${ROLES[c] ? ROLES[c].short : ""}'s timesheet`, Ic.CheckCircle, "#4cb782"); setOpenId(null); } function decideReject(c, reason) { T.rejectWeek(c, weekStart, role, reason); pushNotif({ type: "rejected", who: role, project: project === "all" ? null : project, forRoles: [c], text: `Your timesheet for ${T.fmtWeekRange(weekStart)} was returned for changes${reason ? " — “" + reason + "”" : ""}` }); addToast(`Returned ${ROLES[c] ? ROLES[c].short : ""}'s timesheet`, Ic.Arrow, "#eb5757"); setOpenId(null); } function exportCsv() { const n = T.exportCSV(project, weekStart, tasks, consultants); addToast(n ? `Exported ${n} row${n === 1 ? "" : "s"} to CSV` : "No hours to export this week", Ic.Send, "#16a394"); } const scopeName = project === "all" ? "All projects" : (PROJECTS[project] ? PROJECTS[project].name : ""); return (
{submittedCount}
To review
{totalHours}h
Logged
{billableHours}h
Billable
{rows.length === 0 ? (

Nothing logged yet

Timesheets appear here as consultants log and submit hours for this week.
) : (
{rows.map(r => { const projs = projectsOf(ROLES[r.c]).map(k => PROJECTS[k]).filter(Boolean); const pj = projs[0]; return (
setOpenId(r.c)}>
{ROLES[r.c] ? ROLES[r.c].name : r.c}
{pj &&
{projs.map(p => p.name).join(", ")}
}
{r.hours}h · {r.billable}h bill
{r.sheet.status === "submitted" ? "Awaiting your review" : r.sheet.status === "approved" ? "Signed off" : r.sheet.status === "rejected" ? "Returned for changes" : "In progress"}
); })}
)} {open && ( setOpenId(null)} /> )}
); } // ---------- review modal (read-only grid + approve/reject) ---------- function TimesheetReviewModal({ consultant, weekStart, tasks, sheet, canDecide, onApprove, onReject, onClose }) { const T = window.TQ_TIME; const ROLES = window.TQ.ROLES; const [rejecting, setRejecting] = useStateT(false); const [reason, setReason] = useStateT(""); const model = T.weekModel(consultant, weekStart, tasks); const who = ROLES[consultant]; const canAct = canDecide && sheet.status === "submitted"; return ( {T.fmtWeekRange(weekStart)}·{model.weekTotal}h · {model.billable}h billable
} onClose={onClose} footer={ canAct ? ( rejecting ? ( <> ) : ( <> ) ) : ( <> ) }> {sheet.status === "approved" && (
Approved
Signed off by {ROLES[sheet.decidedBy] ? ROLES[sheet.decidedBy].name : "a manager"}.
)} {sheet.status === "rejected" && (
Returned for changes
{sheet.reason ? "“" + sheet.reason + "”" : "Sent back to the consultant to revise."}
)} {sheet.status === "submitted" && sheet.note && (
Note from {who ? who.short : "consultant"}
{sheet.note}
)}
{rejecting && (