Add ability to include descriptions for time entries

Updates the API and UI to allow users to add optional descriptions to time entries, with a visual indicator for cells containing descriptions.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 55837015-10e9-4be9-b857-7f5e6be73772
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 3bc0faf3-6f86-46e5-8577-13a2e2e88062
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/1cc377db-7ea0-49f2-97ce-c3e87e0228cc/55837015-10e9-4be9-b857-7f5e6be73772/Dzp7voC
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
SylvainP1 2026-04-14 12:55:38 +00:00
parent 0b0385df95
commit bf72bbb7bb
10 changed files with 62 additions and 8 deletions

View File

@ -290,7 +290,7 @@ router.put("/timesheets/:timesheetId/entries", async (req, res): Promise<void> =
} 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<void> =
timesheetLineId: entry.timesheetLineId,
date: dateStr,
hours: entry.hours,
description: entry.description ?? null,
})
.returning();
results.push(created);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 60 KiB

View File

@ -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<Record<string, number>>({});
const [localDescriptions, setLocalDescriptions] = useState<Record<string, string>>({});
const [isAddingLine, setIsAddingLine] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const dirtyKeysRef = useRef<Set<string>>(new Set());
@ -68,15 +71,20 @@ export default function TimesheetDetailPage() {
useEffect(() => {
if (timesheet?.lines) {
const initial: Record<string, number> = {};
const initialDesc: Record<string, string> = {};
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<ReturnType<typeof setTimeout> | 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 = (
<div className="flex items-center justify-center h-9 w-full text-xs">
<div className="relative flex items-center justify-center h-9 w-full text-xs">
{val === 0 ? "" : val}
{desc && (
<span className="absolute top-0.5 right-0.5 h-1.5 w-1.5 rounded-full bg-amber-400" />
)}
</div>
);
@ -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 ? (
<Popover>
<PopoverTrigger asChild>
{cellContent}
</PopoverTrigger>
<PopoverContent className="w-auto p-1.5" side="bottom" align="center">
<PopoverContent className="w-56 p-2 space-y-2" side="bottom" align="center">
<div className="grid grid-cols-5 gap-1">
{HOUR_OPTIONS.map(h => (
<button
@ -457,6 +485,17 @@ export default function TimesheetDetailPage() {
</button>
))}
</div>
<div className="flex items-center gap-1.5 pt-1 border-t">
<MessageSquare className="h-3 w-3 text-muted-foreground shrink-0" />
<input
type="text"
placeholder="Note (optionnel)"
value={desc}
onChange={(e) => 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"
/>
</div>
</PopoverContent>
</Popover>
) : cellContent}

View File

@ -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 {

View File

@ -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

View File

@ -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,

View File

@ -11,4 +11,5 @@ export interface TimeEntry {
timesheetLineId: number;
date: Date;
hours: number;
description?: string | null;
}

View File

@ -10,4 +10,5 @@ export type UpsertTimeEntriesBodyEntriesItem = {
timesheetLineId: number;
date: Date;
hours: number;
description?: string | null;
};

View File

@ -1 +1,2 @@
export * from "./generated/api";
export * from "./generated/types";

View File

@ -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