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:
parent
0b0385df95
commit
bf72bbb7bb
@ -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 |
@ -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}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -11,4 +11,5 @@ export interface TimeEntry {
|
||||
timesheetLineId: number;
|
||||
date: Date;
|
||||
hours: number;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
@ -10,4 +10,5 @@ export type UpsertTimeEntriesBodyEntriesItem = {
|
||||
timesheetLineId: number;
|
||||
date: Date;
|
||||
hours: number;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export * from "./generated/api";
|
||||
export * from "./generated/types";
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user