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:
parent
aca76666d9
commit
dd2974c148
@ -3,6 +3,7 @@ import healthRouter from "./health";
|
|||||||
import projectsRouter from "./projects";
|
import projectsRouter from "./projects";
|
||||||
import timesheetsRouter from "./timesheets";
|
import timesheetsRouter from "./timesheets";
|
||||||
import dashboardRouter from "./dashboard";
|
import dashboardRouter from "./dashboard";
|
||||||
|
import quickEntryRouter from "./quickEntry";
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
@ -10,5 +11,6 @@ router.use(healthRouter);
|
|||||||
router.use(projectsRouter);
|
router.use(projectsRouter);
|
||||||
router.use(timesheetsRouter);
|
router.use(timesheetsRouter);
|
||||||
router.use(dashboardRouter);
|
router.use(dashboardRouter);
|
||||||
|
router.use(quickEntryRouter);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
128
artifacts/api-server/src/routes/quickEntry.ts
Normal file
128
artifacts/api-server/src/routes/quickEntry.ts
Normal 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;
|
||||||
@ -1,13 +1,17 @@
|
|||||||
import { AppSidebar } from "./sidebar";
|
import { AppSidebar } from "./sidebar";
|
||||||
|
import { QuickEntryButton } from "@/components/quick-entry";
|
||||||
|
|
||||||
export function AppLayout({ children }: { children: React.ReactNode }) {
|
export function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-full bg-background overflow-hidden">
|
<div className="flex h-screen w-full bg-background overflow-hidden">
|
||||||
<AppSidebar />
|
<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">
|
<div className="flex-1 p-8 container mx-auto max-w-7xl">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="fixed bottom-6 right-6 z-50">
|
||||||
|
<QuickEntryButton />
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
303
artifacts/cra-app/src/components/quick-entry.tsx
Normal file
303
artifacts/cra-app/src/components/quick-entry.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -176,6 +176,23 @@ export interface ProjectHours {
|
|||||||
totalHours: number;
|
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 = {
|
export type ListTimesheetsParams = {
|
||||||
year?: number;
|
year?: number;
|
||||||
month?: number;
|
month?: number;
|
||||||
|
|||||||
@ -29,6 +29,8 @@ import type {
|
|||||||
MonthlyHours,
|
MonthlyHours,
|
||||||
Project,
|
Project,
|
||||||
ProjectHours,
|
ProjectHours,
|
||||||
|
QuickAddTimeBody,
|
||||||
|
QuickAddTimeResponse,
|
||||||
TimeEntry,
|
TimeEntry,
|
||||||
Timesheet,
|
Timesheet,
|
||||||
TimesheetDetail,
|
TimesheetDetail,
|
||||||
@ -1331,6 +1333,92 @@ export const useUpsertTimeEntries = <
|
|||||||
return useMutation(getUpsertTimeEntriesMutationOptions(options));
|
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
|
* @summary Get summary stats for the dashboard
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -314,6 +314,25 @@ paths:
|
|||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/TimeEntry"
|
$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:
|
/dashboard/summary:
|
||||||
get:
|
get:
|
||||||
operationId: getDashboardSummary
|
operationId: getDashboardSummary
|
||||||
@ -695,3 +714,48 @@ components:
|
|||||||
- projectCode
|
- projectCode
|
||||||
- projectName
|
- projectName
|
||||||
- totalHours
|
- 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
|
||||||
|
|||||||
@ -257,6 +257,26 @@ export const UpsertTimeEntriesResponse = zod.array(
|
|||||||
UpsertTimeEntriesResponseItem,
|
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
|
* @summary Get summary stats for the dashboard
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -18,6 +18,8 @@ export * from "./listTimesheetsParams";
|
|||||||
export * from "./monthlyHours";
|
export * from "./monthlyHours";
|
||||||
export * from "./project";
|
export * from "./project";
|
||||||
export * from "./projectHours";
|
export * from "./projectHours";
|
||||||
|
export * from "./quickAddTimeBody";
|
||||||
|
export * from "./quickAddTimeResponse";
|
||||||
export * from "./timeEntry";
|
export * from "./timeEntry";
|
||||||
export * from "./timesheet";
|
export * from "./timesheet";
|
||||||
export * from "./timesheetDetail";
|
export * from "./timesheetDetail";
|
||||||
|
|||||||
14
lib/api-zod/src/generated/types/quickAddTimeBody.ts
Normal file
14
lib/api-zod/src/generated/types/quickAddTimeBody.ts
Normal 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;
|
||||||
|
}
|
||||||
17
lib/api-zod/src/generated/types/quickAddTimeResponse.ts
Normal file
17
lib/api-zod/src/generated/types/quickAddTimeResponse.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user