// ============ 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 (
setWeekStart(T.addDaysISO(weekStart, -7))} title="Previous week">
{T.fmtWeekRange(weekStart)}
{isThis ? "This week" : "Week of " + T.fmtDay(weekStart)}
setWeekStart(T.addDaysISO(weekStart, 7))} title="Next week">
{!isThis &&
setWeekStart(T.thisWeekStart())}>This week }
);
}
// ---------- 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}
T.setMeta(consultant, row.task.id, { activity: e.target.value })}>
— Select —
{T.ACTIVITIES.map(a => {a.name} )}
editable && T.setMeta(consultant, row.task.id, { billable: !row.meta.billable })}
title={row.meta.billable ? "Billable" : "Non-billable"}>
{row.meta.billable ? <> Bill> : "Non-bill"}
{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 === "rejected" ? "Resubmit week" : "Submit week for approval"}
)}
{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
Export {scopeName} · CSV
{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 ? (
<>
setRejecting(false)}>Cancel
onReject(consultant, reason.trim())}> Return for changes
>
) : (
<>
setRejecting(true)}> Reject
onApprove(consultant)}> Approve timesheet
>
)
) : (
<>Close >
)
}>
{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 && (
Reason for returning · optional
)}
);
}
// ---------- per-card mini logger (inside the task detail modal) ----------
function TaskWeekLogger({ task, role }) {
useTimeVersion();
const T = window.TQ_TIME;
const weekStart = T.thisWeekStart();
const locked = T.isLocked(role, weekStart);
const days = T.weekDays(weekStart);
const todayI = T.todayISO();
let weekTotal = 0;
days.forEach(d => { weekTotal += T.getHours(role, task.id, d); });
const sheet = T.statusOf(role, weekStart);
return (
Log hours · this week
{days.map((d, i) => (
= 5 ? "weekend" : ""} ${d === todayI ? "is-today" : ""}`}>
{T.dayLabel(d)} {T.dayNum(d)}
))}
Total
{days.map((d, i) => (
= 5}
editable={!locked} key={role + "|" + task.id + "|" + d} />
))}
{weekTotal || "—"}
{locked ? "This week's timesheet is locked while it's under review." : "Hours roll up into your weekly timesheet. Submit the full week from the Timesheet tab."}
);
}
Object.assign(window, {
ConsultantTimesheet, ManagerTimesheets, TimesheetReviewModal, TaskWeekLogger,
WeeklyGrid, WeekNav, TsStatusBadge, HourCell,
});