diff --git a/artifacts/api-server/src/routes/timesheets.ts b/artifacts/api-server/src/routes/timesheets.ts index 66a0d79..6911577 100644 --- a/artifacts/api-server/src/routes/timesheets.ts +++ b/artifacts/api-server/src/routes/timesheets.ts @@ -290,7 +290,7 @@ router.put("/timesheets/:timesheetId/entries", async (req, res): Promise = } else { const [updated] = await db .update(timeEntriesTable) - .set({ hours: entry.hours }) + .set({ hours: entry.hours, description: entry.description ?? null }) .where(eq(timeEntriesTable.id, existing[0].id)) .returning(); results.push(updated); @@ -302,6 +302,7 @@ router.put("/timesheets/:timesheetId/entries", async (req, res): Promise = timesheetLineId: entry.timesheetLineId, date: dateStr, hours: entry.hours, + description: entry.description ?? null, }) .returning(); results.push(created); diff --git a/artifacts/cra-app/public/opengraph.jpg b/artifacts/cra-app/public/opengraph.jpg index 4f199d5..6aba47d 100644 Binary files a/artifacts/cra-app/public/opengraph.jpg and b/artifacts/cra-app/public/opengraph.jpg differ diff --git a/artifacts/cra-app/src/pages/timesheet-detail.tsx b/artifacts/cra-app/src/pages/timesheet-detail.tsx index 956b0cc..756e9cc 100644 --- a/artifacts/cra-app/src/pages/timesheet-detail.tsx +++ b/artifacts/cra-app/src/pages/timesheet-detail.tsx @@ -17,7 +17,8 @@ import { Plus, Trash2, CheckCircle, - AlertCircle + AlertCircle, + MessageSquare } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -39,6 +40,7 @@ type LocalEntry = { timesheetLineId: number; date: string; hours: number; + description?: string | null; }; export default function TimesheetDetailPage() { @@ -59,6 +61,7 @@ export default function TimesheetDetailPage() { const upsertEntries = useUpsertTimeEntries(); const [localEntries, setLocalEntries] = useState>({}); + const [localDescriptions, setLocalDescriptions] = useState>({}); const [isAddingLine, setIsAddingLine] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const dirtyKeysRef = useRef>(new Set()); @@ -68,15 +71,20 @@ export default function TimesheetDetailPage() { useEffect(() => { if (timesheet?.lines) { const initial: Record = {}; + const initialDesc: Record = {}; timesheet.lines.forEach(line => { line.entries?.forEach(entry => { const dateStr = typeof entry.date === "string" && entry.date.length > 10 ? entry.date.slice(0, 10) : entry.date; initial[`${line.id}-${dateStr}`] = entry.hours; + if (entry.description) { + initialDesc[`${line.id}-${dateStr}`] = entry.description; + } }); }); setLocalEntries(initial); + setLocalDescriptions(initialDesc); setHasUnsavedChanges(false); dirtyKeysRef.current.clear(); } @@ -109,10 +117,13 @@ export default function TimesheetDetailPage() { const saveTimerRef = useRef | null>(null); const localEntriesRef = useRef(localEntries); localEntriesRef.current = localEntries; + const localDescriptionsRef = useRef(localDescriptions); + localDescriptionsRef.current = localDescriptions; const doSave = useCallback(() => { if (!timesheetId) return; const entries = localEntriesRef.current; + const descriptions = localDescriptionsRef.current; const entriesToSave: LocalEntry[] = []; const dirty = dirtyKeysRef.current; @@ -123,7 +134,8 @@ export default function TimesheetDetailPage() { entriesToSave.push({ timesheetLineId: parseInt(lineIdStr), date: fullDate, - hours + hours, + description: descriptions[key] || null }); } }); @@ -157,6 +169,16 @@ export default function TimesheetDetailPage() { saveTimerRef.current = setTimeout(() => doSave(), 800); }; + const handleSetDescription = (lineId: number, dateStr: string, desc: string) => { + const key = `${lineId}-${dateStr}`; + setLocalDescriptions(prev => ({ ...prev, [key]: desc })); + setHasUnsavedChanges(true); + dirtyKeysRef.current.add(key); + + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + saveTimerRef.current = setTimeout(() => doSave(), 800); + }; + useEffect(() => { return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current); @@ -185,13 +207,14 @@ export default function TimesheetDetailPage() { if (hasUnsavedChanges) { const entries = localEntriesRef.current; + const descriptions = localDescriptionsRef.current; const dirty = dirtyKeysRef.current; const entriesToSave: LocalEntry[] = []; Object.entries(entries).forEach(([key, hours]) => { const lineIdStr = key.split("-")[0]; const fullDate = key.substring(lineIdStr.length + 1); if (hours > 0 || dirty.has(key)) { - entriesToSave.push({ timesheetLineId: parseInt(lineIdStr), date: fullDate, hours }); + entriesToSave.push({ timesheetLineId: parseInt(lineIdStr), date: fullDate, hours, description: descriptions[key] || null }); } }); setSaveStatus("saving"); @@ -416,10 +439,14 @@ export default function TimesheetDetailPage() { {daysArray.map(day => { const key = `${line.id}-${day.dateStr}`; const val = localEntries[key] || 0; + const desc = localDescriptions[key] || ""; const cellContent = ( -
+
{val === 0 ? "" : val} + {desc && ( + + )}
); @@ -432,13 +459,14 @@ export default function TimesheetDetailPage() { isEditable ? "cursor-pointer hover:bg-primary/10" : "", val > 0 ? "bg-primary/5 text-primary font-semibold" : "" )} + title={desc || undefined} > {isEditable ? ( {cellContent} - +
{HOUR_OPTIONS.map(h => (
+
+ + handleSetDescription(line.id, day.dateStr, e.target.value)} + onClick={(e) => e.stopPropagation()} + className="w-full text-xs bg-transparent border-none outline-none placeholder:text-muted-foreground/50" + /> +
) : cellContent} diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 61e6d43..d2cdbb3 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -100,6 +100,7 @@ export interface TimeEntry { timesheetLineId: number; date: string; hours: number; + description?: string | null; } export interface TimesheetLineWithEntries { @@ -149,6 +150,7 @@ export type UpsertTimeEntriesBodyEntriesItem = { timesheetLineId: number; date: string; hours: number; + description?: string | null; }; export interface UpsertTimeEntriesBody { diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 50552dd..aea180a 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -636,6 +636,9 @@ components: format: date hours: type: number + description: + type: string + nullable: true required: - id - timesheetLineId @@ -657,6 +660,9 @@ components: format: date hours: type: number + description: + type: string + nullable: true required: - timesheetLineId - date diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 130721a..70cbdbf 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -154,6 +154,7 @@ export const GetTimesheetResponse = zod.object({ timesheetLineId: zod.number(), date: zod.coerce.date(), hours: zod.number(), + description: zod.string().nullish(), }), ), }), @@ -243,6 +244,7 @@ export const UpsertTimeEntriesBody = zod.object({ timesheetLineId: zod.number(), date: zod.coerce.date(), hours: zod.number(), + description: zod.string().nullish(), }), ), }); @@ -252,6 +254,7 @@ export const UpsertTimeEntriesResponseItem = zod.object({ timesheetLineId: zod.number(), date: zod.coerce.date(), hours: zod.number(), + description: zod.string().nullish(), }); export const UpsertTimeEntriesResponse = zod.array( UpsertTimeEntriesResponseItem, diff --git a/lib/api-zod/src/generated/types/timeEntry.ts b/lib/api-zod/src/generated/types/timeEntry.ts index aab8a5e..82cb77d 100644 --- a/lib/api-zod/src/generated/types/timeEntry.ts +++ b/lib/api-zod/src/generated/types/timeEntry.ts @@ -11,4 +11,5 @@ export interface TimeEntry { timesheetLineId: number; date: Date; hours: number; + description?: string | null; } diff --git a/lib/api-zod/src/generated/types/upsertTimeEntriesBodyEntriesItem.ts b/lib/api-zod/src/generated/types/upsertTimeEntriesBodyEntriesItem.ts index 55f7072..7114cac 100644 --- a/lib/api-zod/src/generated/types/upsertTimeEntriesBodyEntriesItem.ts +++ b/lib/api-zod/src/generated/types/upsertTimeEntriesBodyEntriesItem.ts @@ -10,4 +10,5 @@ export type UpsertTimeEntriesBodyEntriesItem = { timesheetLineId: number; date: Date; hours: number; + description?: string | null; }; diff --git a/lib/api-zod/src/index.ts b/lib/api-zod/src/index.ts index 47a637d..ac442e7 100644 --- a/lib/api-zod/src/index.ts +++ b/lib/api-zod/src/index.ts @@ -1 +1,2 @@ export * from "./generated/api"; +export * from "./generated/types"; diff --git a/replit.md b/replit.md index 6b729a2..20d6c39 100644 --- a/replit.md +++ b/replit.md @@ -23,7 +23,7 @@ A French timesheet management application (CRA - Compte Rendu d'Activité) built - **Dashboard**: Overview with monthly hours chart, project breakdown, active project count, timesheet status - **Timesheet Management**: Create, view, edit monthly timesheets (CRA) -- **CRA Grid**: Interactive calendar grid where rows = projects, columns = days of month. Click cells to cycle 0 → 0.5 → 1 hours. Weekend distinction, row/column totals +- **CRA Grid**: Interactive calendar grid where rows = projects, columns = days of month. Click cells to open popover with hour options [0, 0.5, 1, 2, 3, 4, 5, 6, 7, 7.7]. Includes optional description per cell (amber dot indicator). Auto-save with debounce. Weekend distinction, row/column totals - **Project Management**: CRUD for projects with code, name, client, category - **Timesheet Workflow**: Draft → Submitted → Validated status flow @@ -32,7 +32,7 @@ A French timesheet management application (CRA - Compte Rendu d'Activité) built - `projects` — Project definitions (code, name, client, category) - `timesheets` — Monthly timesheet headers (year, month, status, collaborator) - `timesheet_lines` — Project assignments within a timesheet -- `time_entries` — Daily hour entries per line (date, hours) +- `time_entries` — Daily hour entries per line (date, hours, description) ## Key Commands