diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index 3dd2068..045cfa2 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -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; diff --git a/artifacts/api-server/src/routes/quickEntry.ts b/artifacts/api-server/src/routes/quickEntry.ts new file mode 100644 index 0000000..5e64a89 --- /dev/null +++ b/artifacts/api-server/src/routes/quickEntry.ts @@ -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 => { + 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; diff --git a/artifacts/cra-app/src/components/layout/layout.tsx b/artifacts/cra-app/src/components/layout/layout.tsx index 31f2c2e..aa42483 100644 --- a/artifacts/cra-app/src/components/layout/layout.tsx +++ b/artifacts/cra-app/src/components/layout/layout.tsx @@ -1,13 +1,17 @@ import { AppSidebar } from "./sidebar"; +import { QuickEntryButton } from "@/components/quick-entry"; export function AppLayout({ children }: { children: React.ReactNode }) { return (
-
+
{children}
+
+ +
); diff --git a/artifacts/cra-app/src/components/quick-entry.tsx b/artifacts/cra-app/src/components/quick-entry.tsx new file mode 100644 index 0000000..2be13f0 --- /dev/null +++ b/artifacts/cra-app/src/components/quick-entry.tsx @@ -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 ( + <> + + + + ); +} + +function QuickEntryDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [projectId, setProjectId] = useState(""); + const [date, setDate] = useState(format(new Date(), "yyyy-MM-dd")); + const [hours, setHours] = useState(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 ( + + + + + + Saisie rapide + + + + {success && lastEntry ? ( +
+
+ +
+
+

Saisie enregistrée

+

+ {lastEntry.hours}h sur{" "} + + {lastEntry.projectName} + +

+

{formattedDate}

+
+
+ + +
+
+ ) : ( + <> +
+
+ + + {selectedProject?.client && ( +

+ Client: {selectedProject.client} +

+ )} +
+ +
+ + 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" + /> +

+ {formattedDate} +

+
+ +
+ +
+ {HOUR_OPTIONS.map((h) => ( + + ))} +
+
+ + {selectedProject && ( +
+

Récapitulatif:

+

+ {hours}h sur{" "} + + {selectedProject.code} + {" "} + {selectedProject.name} +

+

+ {formattedDate} +

+
+ )} +
+ + + + + + + )} +
+
+ ); +} diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index e50345f..4fc809c 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -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; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index 43408c7..1faea9d 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -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 => { + return customFetch(getQuickAddTimeUrl(), { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(quickAddTimeBody), + }); +}; + +export const getQuickAddTimeMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + 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>, + { data: BodyType } + > = (props) => { + const { data } = props ?? {}; + + return quickAddTime(data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type QuickAddTimeMutationResult = NonNullable< + Awaited> +>; +export type QuickAddTimeMutationBody = BodyType; +export type QuickAddTimeMutationError = ErrorType; + +/** + * @summary Quick add time - auto-creates timesheet and line if needed + */ +export const useQuickAddTime = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { data: BodyType }, + TContext +> => { + return useMutation(getQuickAddTimeMutationOptions(options)); +}; + /** * @summary Get summary stats for the dashboard */ diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index e194628..b833045 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -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 diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index a6cf503..538b501 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -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 */ diff --git a/lib/api-zod/src/generated/types/index.ts b/lib/api-zod/src/generated/types/index.ts index 9fea561..56d9667 100644 --- a/lib/api-zod/src/generated/types/index.ts +++ b/lib/api-zod/src/generated/types/index.ts @@ -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"; diff --git a/lib/api-zod/src/generated/types/quickAddTimeBody.ts b/lib/api-zod/src/generated/types/quickAddTimeBody.ts new file mode 100644 index 0000000..fa7655d --- /dev/null +++ b/lib/api-zod/src/generated/types/quickAddTimeBody.ts @@ -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; +} diff --git a/lib/api-zod/src/generated/types/quickAddTimeResponse.ts b/lib/api-zod/src/generated/types/quickAddTimeResponse.ts new file mode 100644 index 0000000..82b2722 --- /dev/null +++ b/lib/api-zod/src/generated/types/quickAddTimeResponse.ts @@ -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; +}