Add quick time entry functionality for faster project time logging

Implements a new quick entry feature with a dedicated API route and frontend component, allowing users to rapidly log time against projects by selecting a project, date, and predefined hour increments, with automatic creation of timesheets and lines if they don't exist.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 55837015-10e9-4be9-b857-7f5e6be73772
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: c01c2c6b-f846-4f1b-b865-e90687db6de1
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/1cc377db-7ea0-49f2-97ce-c3e87e0228cc/55837015-10e9-4be9-b857-7f5e6be73772/cI64U4O
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
SylvainP1 2026-04-14 08:12:22 +00:00
parent aca76666d9
commit dd2974c148
11 changed files with 660 additions and 1 deletions

View File

@ -3,6 +3,7 @@ import healthRouter from "./health";
import projectsRouter from "./projects";
import timesheetsRouter from "./timesheets";
import dashboardRouter from "./dashboard";
import quickEntryRouter from "./quickEntry";
const router: IRouter = Router();
@ -10,5 +11,6 @@ router.use(healthRouter);
router.use(projectsRouter);
router.use(timesheetsRouter);
router.use(dashboardRouter);
router.use(quickEntryRouter);
export default router;

View File

@ -0,0 +1,128 @@
import { Router, type IRouter } from "express";
import { eq, and } from "drizzle-orm";
import { db, timesheetsTable, timesheetLinesTable, timeEntriesTable, projectsTable } from "@workspace/db";
import {
QuickAddTimeBody,
QuickAddTimeResponse,
} from "@workspace/api-zod";
const router: IRouter = Router();
router.post("/quick-entry", async (req, res): Promise<void> => {
const parsed = QuickAddTimeBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const { projectId, hours, collaborator } = parsed.data;
const rawDate = parsed.data.date;
const dateStr = rawDate instanceof Date
? rawDate.toISOString().split("T")[0]
: (String(rawDate).length > 10 ? String(rawDate).slice(0, 10) : String(rawDate));
const dateParts = dateStr.split("-");
const year = parseInt(dateParts[0]);
const month = parseInt(dateParts[1]);
const [project] = await db
.select()
.from(projectsTable)
.where(eq(projectsTable.id, projectId));
if (!project) {
res.status(404).json({ error: "Project not found" });
return;
}
let [timesheet] = await db
.select()
.from(timesheetsTable)
.where(and(
eq(timesheetsTable.year, year),
eq(timesheetsTable.month, month),
eq(timesheetsTable.collaborator, collaborator)
));
if (!timesheet) {
[timesheet] = await db
.insert(timesheetsTable)
.values({ year, month, collaborator, status: "draft" })
.returning();
}
if (timesheet.status !== "draft") {
res.status(400).json({ error: "Ce CRA est déjà soumis ou validé, impossible de modifier" });
return;
}
let [line] = await db
.select()
.from(timesheetLinesTable)
.where(and(
eq(timesheetLinesTable.timesheetId, timesheet.id),
eq(timesheetLinesTable.projectId, projectId)
));
if (!line) {
[line] = await db
.insert(timesheetLinesTable)
.values({ timesheetId: timesheet.id, projectId })
.returning();
}
const [existing] = await db
.select()
.from(timeEntriesTable)
.where(and(
eq(timeEntriesTable.timesheetLineId, line.id),
eq(timeEntriesTable.date, dateStr)
));
let entry;
if (existing) {
if (hours === 0) {
await db.delete(timeEntriesTable).where(eq(timeEntriesTable.id, existing.id));
entry = { ...existing, hours: 0 };
} else {
[entry] = await db
.update(timeEntriesTable)
.set({ hours })
.where(eq(timeEntriesTable.id, existing.id))
.returning();
}
} else if (hours > 0) {
[entry] = await db
.insert(timeEntriesTable)
.values({ timesheetLineId: line.id, date: dateStr, hours })
.returning();
} else {
entry = { id: 0, timesheetLineId: line.id, date: dateStr, hours: 0 };
}
const allEntries = await db
.select()
.from(timeEntriesTable)
.where(eq(timeEntriesTable.timesheetLineId, line.id));
const lineTotal = allEntries.reduce((sum, e) => sum + e.hours, 0);
await db.update(timesheetLinesTable).set({ totalHours: lineTotal }).where(eq(timesheetLinesTable.id, line.id));
const allLines = await db
.select()
.from(timesheetLinesTable)
.where(eq(timesheetLinesTable.timesheetId, timesheet.id));
const sheetTotal = allLines.reduce((sum, l) => sum + l.totalHours, 0);
await db.update(timesheetsTable).set({ totalHours: sheetTotal, updatedAt: new Date() }).where(eq(timesheetsTable.id, timesheet.id));
res.json(QuickAddTimeResponse.parse({
timesheetId: timesheet.id,
timesheetLineId: line.id,
entryId: entry.id,
date: dateStr,
hours,
projectCode: project.code,
projectName: project.name,
}));
});
export default router;

View File

@ -1,13 +1,17 @@
import { AppSidebar } from "./sidebar";
import { QuickEntryButton } from "@/components/quick-entry";
export function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen w-full bg-background overflow-hidden">
<AppSidebar />
<main className="flex-1 flex flex-col min-w-0 overflow-y-auto">
<main className="flex-1 flex flex-col min-w-0 overflow-y-auto relative">
<div className="flex-1 p-8 container mx-auto max-w-7xl">
{children}
</div>
<div className="fixed bottom-6 right-6 z-50">
<QuickEntryButton />
</div>
</main>
</div>
);

View File

@ -0,0 +1,303 @@
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
useListProjects,
getListProjectsQueryKey,
useQuickAddTime,
getGetDashboardSummaryQueryKey,
getGetMonthlyHoursQueryKey,
getGetProjectHoursQueryKey,
getListTimesheetsQueryKey,
} from "@workspace/api-client-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { Zap, Clock, Check } from "lucide-react";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
const HOUR_OPTIONS = [0.5, 1, 2, 3, 4, 5, 6, 7, 7.7];
const COLLABORATOR = "PHAM Sylvain";
export function QuickEntryButton() {
const [open, setOpen] = useState(false);
return (
<>
<Button
onClick={() => setOpen(true)}
className="gap-2 bg-primary shadow-lg hover:shadow-xl transition-all"
data-testid="button-quick-entry"
>
<Zap className="h-4 w-4" />
Saisie rapide
</Button>
<QuickEntryDialog open={open} onOpenChange={setOpen} />
</>
);
}
function QuickEntryDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [projectId, setProjectId] = useState<string>("");
const [date, setDate] = useState(format(new Date(), "yyyy-MM-dd"));
const [hours, setHours] = useState<number>(1);
const [success, setSuccess] = useState(false);
const [lastEntry, setLastEntry] = useState<{
projectName: string;
hours: number;
date: string;
} | null>(null);
const { toast } = useToast();
const queryClient = useQueryClient();
const { data: projects } = useListProjects({
query: { queryKey: getListProjectsQueryKey() },
});
const quickAdd = useQuickAddTime();
const handleSubmit = () => {
if (!projectId) {
toast({
title: "Veuillez sélectionner un projet",
variant: "destructive",
});
return;
}
quickAdd.mutate(
{
data: {
projectId: parseInt(projectId),
date,
hours,
collaborator: COLLABORATOR,
},
},
{
onSuccess: (result) => {
setLastEntry({
projectName: result.projectName,
hours: result.hours,
date: result.date as string,
});
setSuccess(true);
queryClient.invalidateQueries({
queryKey: getGetDashboardSummaryQueryKey(),
});
queryClient.invalidateQueries({
queryKey: getGetMonthlyHoursQueryKey(),
});
queryClient.invalidateQueries({
queryKey: getGetProjectHoursQueryKey(),
});
queryClient.invalidateQueries({
queryKey: getListTimesheetsQueryKey(),
});
setTimeout(() => {
setSuccess(false);
}, 2000);
},
onError: (err: any) => {
const message =
err?.data?.error || err?.message || "Erreur lors de la saisie";
toast({ title: message, variant: "destructive" });
},
}
);
};
const handleClose = () => {
setSuccess(false);
setProjectId("");
setHours(1);
setDate(format(new Date(), "yyyy-MM-dd"));
onOpenChange(false);
};
const handleAnother = () => {
setSuccess(false);
setProjectId("");
setHours(1);
};
const activeProjects = projects?.filter((p) => p.isActive) ?? [];
const selectedProject = activeProjects.find(
(p) => p.id.toString() === projectId
);
const formattedDate = (() => {
try {
const d = new Date(date + "T00:00:00");
return format(d, "EEEE d MMMM yyyy", { locale: fr });
} catch {
return date;
}
})();
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-lg">
<Zap className="h-5 w-5 text-primary" />
Saisie rapide
</DialogTitle>
</DialogHeader>
{success && lastEntry ? (
<div className="py-8 flex flex-col items-center gap-4 text-center animate-in fade-in zoom-in-95">
<div className="h-16 w-16 rounded-full bg-green-100 flex items-center justify-center">
<Check className="h-8 w-8 text-green-600" />
</div>
<div>
<p className="font-semibold text-lg">Saisie enregistrée</p>
<p className="text-muted-foreground mt-1">
{lastEntry.hours}h sur{" "}
<span className="font-medium text-foreground">
{lastEntry.projectName}
</span>
</p>
<p className="text-sm text-muted-foreground">{formattedDate}</p>
</div>
<div className="flex gap-3 mt-2">
<Button variant="outline" onClick={handleClose}>
Fermer
</Button>
<Button onClick={handleAnother} className="gap-2">
<Zap className="h-4 w-4" />
Autre saisie
</Button>
</div>
</div>
) : (
<>
<div className="space-y-5 py-2">
<div className="space-y-2">
<label className="text-sm font-medium">Projet</label>
<Select value={projectId} onValueChange={setProjectId}>
<SelectTrigger data-testid="select-quick-project">
<SelectValue placeholder="Sélectionner un projet..." />
</SelectTrigger>
<SelectContent>
{activeProjects.map((p) => (
<SelectItem key={p.id} value={p.id.toString()}>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs font-mono">
{p.code}
</span>
<span>{p.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{selectedProject?.client && (
<p className="text-xs text-muted-foreground pl-1">
Client: {selectedProject.client}
</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Date</label>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
data-testid="input-quick-date"
/>
<p className="text-xs text-muted-foreground pl-1 capitalize">
{formattedDate}
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
Heures
</label>
<div className="flex flex-wrap gap-2">
{HOUR_OPTIONS.map((h) => (
<button
key={h}
onClick={() => setHours(h)}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-all ${
hours === h
? "bg-primary text-primary-foreground border-primary shadow-sm"
: "bg-background border-border hover:border-primary/50 hover:bg-muted"
}`}
data-testid={`button-hours-${h}`}
>
{h}h
</button>
))}
</div>
</div>
{selectedProject && (
<div className="bg-muted/50 rounded-lg p-3 text-sm">
<p className="text-muted-foreground">Récapitulatif:</p>
<p className="font-medium mt-1">
{hours}h sur{" "}
<Badge variant="secondary" className="font-mono text-xs">
{selectedProject.code}
</Badge>{" "}
{selectedProject.name}
</p>
<p className="text-muted-foreground capitalize">
{formattedDate}
</p>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose}>
Annuler
</Button>
<Button
onClick={handleSubmit}
disabled={!projectId || quickAdd.isPending}
className="gap-2"
data-testid="button-submit-quick-entry"
>
{quickAdd.isPending ? (
"Enregistrement..."
) : (
<>
<Check className="h-4 w-4" />
Enregistrer
</>
)}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}

View File

@ -176,6 +176,23 @@ export interface ProjectHours {
totalHours: number;
}
export interface QuickAddTimeBody {
projectId: number;
date: string;
hours: number;
collaborator: string;
}
export interface QuickAddTimeResponse {
timesheetId: number;
timesheetLineId: number;
entryId: number;
date: string;
hours: number;
projectCode: string;
projectName: string;
}
export type ListTimesheetsParams = {
year?: number;
month?: number;

View File

@ -29,6 +29,8 @@ import type {
MonthlyHours,
Project,
ProjectHours,
QuickAddTimeBody,
QuickAddTimeResponse,
TimeEntry,
Timesheet,
TimesheetDetail,
@ -1331,6 +1333,92 @@ export const useUpsertTimeEntries = <
return useMutation(getUpsertTimeEntriesMutationOptions(options));
};
/**
* @summary Quick add time - auto-creates timesheet and line if needed
*/
export const getQuickAddTimeUrl = () => {
return `/api/quick-entry`;
};
export const quickAddTime = async (
quickAddTimeBody: QuickAddTimeBody,
options?: RequestInit,
): Promise<QuickAddTimeResponse> => {
return customFetch<QuickAddTimeResponse>(getQuickAddTimeUrl(), {
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(quickAddTimeBody),
});
};
export const getQuickAddTimeMutationOptions = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof quickAddTime>>,
TError,
{ data: BodyType<QuickAddTimeBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof quickAddTime>>,
TError,
{ data: BodyType<QuickAddTimeBody> },
TContext
> => {
const mutationKey = ["quickAddTime"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof quickAddTime>>,
{ data: BodyType<QuickAddTimeBody> }
> = (props) => {
const { data } = props ?? {};
return quickAddTime(data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type QuickAddTimeMutationResult = NonNullable<
Awaited<ReturnType<typeof quickAddTime>>
>;
export type QuickAddTimeMutationBody = BodyType<QuickAddTimeBody>;
export type QuickAddTimeMutationError = ErrorType<unknown>;
/**
* @summary Quick add time - auto-creates timesheet and line if needed
*/
export const useQuickAddTime = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof quickAddTime>>,
TError,
{ data: BodyType<QuickAddTimeBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof quickAddTime>>,
TError,
{ data: BodyType<QuickAddTimeBody> },
TContext
> => {
return useMutation(getQuickAddTimeMutationOptions(options));
};
/**
* @summary Get summary stats for the dashboard
*/

View File

@ -314,6 +314,25 @@ paths:
items:
$ref: "#/components/schemas/TimeEntry"
/quick-entry:
post:
operationId: quickAddTime
tags: [time-entries]
summary: Quick add time - auto-creates timesheet and line if needed
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/QuickAddTimeBody"
responses:
"200":
description: Time entry saved
content:
application/json:
schema:
$ref: "#/components/schemas/QuickAddTimeResponse"
/dashboard/summary:
get:
operationId: getDashboardSummary
@ -695,3 +714,48 @@ components:
- projectCode
- projectName
- totalHours
QuickAddTimeBody:
type: object
properties:
projectId:
type: integer
date:
type: string
format: date
hours:
type: number
collaborator:
type: string
required:
- projectId
- date
- hours
- collaborator
QuickAddTimeResponse:
type: object
properties:
timesheetId:
type: integer
timesheetLineId:
type: integer
entryId:
type: integer
date:
type: string
format: date
hours:
type: number
projectCode:
type: string
projectName:
type: string
required:
- timesheetId
- timesheetLineId
- entryId
- date
- hours
- projectCode
- projectName

View File

@ -257,6 +257,26 @@ export const UpsertTimeEntriesResponse = zod.array(
UpsertTimeEntriesResponseItem,
);
/**
* @summary Quick add time - auto-creates timesheet and line if needed
*/
export const QuickAddTimeBody = zod.object({
projectId: zod.number(),
date: zod.coerce.date(),
hours: zod.number(),
collaborator: zod.string(),
});
export const QuickAddTimeResponse = zod.object({
timesheetId: zod.number(),
timesheetLineId: zod.number(),
entryId: zod.number(),
date: zod.coerce.date(),
hours: zod.number(),
projectCode: zod.string(),
projectName: zod.string(),
});
/**
* @summary Get summary stats for the dashboard
*/

View File

@ -18,6 +18,8 @@ export * from "./listTimesheetsParams";
export * from "./monthlyHours";
export * from "./project";
export * from "./projectHours";
export * from "./quickAddTimeBody";
export * from "./quickAddTimeResponse";
export * from "./timeEntry";
export * from "./timesheet";
export * from "./timesheetDetail";

View File

@ -0,0 +1,14 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* Api
* CRA (Compte Rendu d'Activité) API
* OpenAPI spec version: 0.1.0
*/
export interface QuickAddTimeBody {
projectId: number;
date: Date;
hours: number;
collaborator: string;
}

View File

@ -0,0 +1,17 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* Api
* CRA (Compte Rendu d'Activité) API
* OpenAPI spec version: 0.1.0
*/
export interface QuickAddTimeResponse {
timesheetId: number;
timesheetLineId: number;
entryId: number;
date: Date;
hours: number;
projectCode: string;
projectName: string;
}