diff --git a/artifacts/api-server/src/routes/admin.ts b/artifacts/api-server/src/routes/admin.ts new file mode 100644 index 0000000..c0e0aa8 --- /dev/null +++ b/artifacts/api-server/src/routes/admin.ts @@ -0,0 +1,120 @@ +import { Router, type IRouter } from "express"; +import { sql } from "drizzle-orm"; +import { + db, + projectsTable, + timesheetsTable, + timesheetLinesTable, + timeEntriesTable, +} from "@workspace/db"; + +const router: IRouter = Router(); + +type ImportPayload = { + projects: any[]; + timesheets: any[]; + timesheetLines: any[]; + timeEntries: any[]; +}; + +function readImportPayload(body: unknown): ImportPayload | null { + if (!body || typeof body !== "object") return null; + const data = body as Record; + return { + projects: Array.isArray(data.projects) ? data.projects : [], + timesheets: Array.isArray(data.timesheets) ? data.timesheets : [], + timesheetLines: Array.isArray(data.timesheetLines) ? data.timesheetLines : [], + timeEntries: Array.isArray(data.timeEntries) ? data.timeEntries : [], + }; +} + +function asDate(value: unknown) { + return value ? new Date(String(value)) : new Date(); +} + +router.get("/admin/export", async (_req, res): Promise => { + const [projects, timesheets, timesheetLines, timeEntries] = await Promise.all([ + db.select().from(projectsTable), + db.select().from(timesheetsTable), + db.select().from(timesheetLinesTable), + db.select().from(timeEntriesTable), + ]); + + res.json({ + exportedAt: new Date().toISOString(), + version: 1, + projects, + timesheets, + timesheetLines, + timeEntries, + }); +}); + +router.post("/admin/import", async (req, res): Promise => { + const data = readImportPayload(req.body); + if (!data) { + res.status(400).json({ error: "Fichier d'import invalide" }); + return; + } + + await db.transaction(async (tx) => { + await tx.delete(timeEntriesTable); + await tx.delete(timesheetLinesTable); + await tx.delete(timesheetsTable); + await tx.delete(projectsTable); + + if (data.projects.length > 0) { + await tx.insert(projectsTable).values(data.projects.map((project) => ({ + id: project.id, + code: project.code, + name: project.name, + parentProjectId: project.parentProjectId ?? null, + client: project.client ?? null, + category: project.category ?? null, + isActive: project.isActive ?? true, + createdAt: asDate(project.createdAt), + }))); + } + + if (data.timesheets.length > 0) { + await tx.insert(timesheetsTable).values(data.timesheets.map((timesheet) => ({ + id: timesheet.id, + year: timesheet.year, + month: timesheet.month, + status: timesheet.status, + collaborator: timesheet.collaborator, + totalHours: timesheet.totalHours ?? 0, + createdAt: asDate(timesheet.createdAt), + updatedAt: asDate(timesheet.updatedAt), + }))); + } + + if (data.timesheetLines.length > 0) { + await tx.insert(timesheetLinesTable).values(data.timesheetLines.map((line) => ({ + id: line.id, + timesheetId: line.timesheetId, + projectId: line.projectId, + totalHours: line.totalHours ?? 0, + }))); + } + + if (data.timeEntries.length > 0) { + await tx.insert(timeEntriesTable).values(data.timeEntries.map((entry) => ({ + id: entry.id, + timesheetLineId: entry.timesheetLineId, + date: entry.date.slice(0, 10), + hours: entry.hours, + description: entry.description ?? null, + }))); + } + + await tx.execute(sql`SELECT setval(pg_get_serial_sequence('projects', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM projects), 1), 1), (SELECT COUNT(*) > 0 FROM projects))`); + await tx.execute(sql`SELECT setval(pg_get_serial_sequence('timesheets', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM timesheets), 1), 1), (SELECT COUNT(*) > 0 FROM timesheets))`); + await tx.execute(sql`SELECT setval(pg_get_serial_sequence('timesheet_lines', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM timesheet_lines), 1), 1), (SELECT COUNT(*) > 0 FROM timesheet_lines))`); + await tx.execute(sql`SELECT setval(pg_get_serial_sequence('time_entries', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM time_entries), 1), 1), (SELECT COUNT(*) > 0 FROM time_entries))`); + }); + + res.json({ ok: true }); +}); + +export default router; \ No newline at end of file diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index 045cfa2..e78d8ef 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -4,6 +4,7 @@ import projectsRouter from "./projects"; import timesheetsRouter from "./timesheets"; import dashboardRouter from "./dashboard"; import quickEntryRouter from "./quickEntry"; +import adminRouter from "./admin"; const router: IRouter = Router(); @@ -12,5 +13,6 @@ router.use(projectsRouter); router.use(timesheetsRouter); router.use(dashboardRouter); router.use(quickEntryRouter); +router.use(adminRouter); export default router; diff --git a/artifacts/cra-app/src/components/layout/layout.tsx b/artifacts/cra-app/src/components/layout/layout.tsx index 65e11e1..524ef5b 100644 --- a/artifacts/cra-app/src/components/layout/layout.tsx +++ b/artifacts/cra-app/src/components/layout/layout.tsx @@ -1,10 +1,17 @@ import { AppSidebar } from "./sidebar"; import { Github } from "lucide-react"; - -const REPO_URL = "https://github.com/sylvainp/cra-app"; -const DEPLOY_DATE = "14 avril 2025"; +import { useEffect, useState } from "react"; +import { APP_INFO_EVENT, getAppInfo, type AppInfo } from "@/lib/app-info"; export function AppLayout({ children }: { children: React.ReactNode }) { + const [info, setInfo] = useState(() => getAppInfo()); + + useEffect(() => { + const handleUpdate = () => setInfo(getAppInfo()); + window.addEventListener(APP_INFO_EVENT, handleUpdate); + return () => window.removeEventListener(APP_INFO_EVENT, handleUpdate); + }, []); + return (
@@ -14,7 +21,7 @@ export function AppLayout({ children }: { children: React.ReactNode }) {
Repo GitHub - Déployé le {DEPLOY_DATE} + Déployé le {info.deployDate}
diff --git a/artifacts/cra-app/src/components/layout/sidebar.tsx b/artifacts/cra-app/src/components/layout/sidebar.tsx index e4654fa..f9e3db0 100644 --- a/artifacts/cra-app/src/components/layout/sidebar.tsx +++ b/artifacts/cra-app/src/components/layout/sidebar.tsx @@ -1,7 +1,14 @@ +import { useRef, useState } from "react"; import { Link, useLocation } from "wouter"; -import { LayoutDashboard, Clock, FolderKanban, Zap } from "lucide-react"; +import { Download, FolderKanban, LayoutDashboard, Settings, Upload, Clock } from "lucide-react"; import { cn } from "@/lib/utils"; import { QuickEntryButton } from "@/components/quick-entry"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { getAppInfo, saveAppInfo, type AppInfo } from "@/lib/app-info"; +import { useToast } from "@/hooks/use-toast"; const navItems = [ { @@ -23,6 +30,62 @@ const navItems = [ export function AppSidebar() { const [location] = useLocation(); + const [isAdminOpen, setIsAdminOpen] = useState(false); + const [info, setInfo] = useState(() => getAppInfo()); + const [isImporting, setIsImporting] = useState(false); + const fileInputRef = useRef(null); + const { toast } = useToast(); + + const handleOpenAdmin = () => { + setInfo(getAppInfo()); + setIsAdminOpen(true); + }; + + const handleSaveInfo = () => { + saveAppInfo(info); + toast({ title: "Informations mises à jour" }); + }; + + const handleExport = async () => { + const response = await fetch("/api/admin/export"); + if (!response.ok) { + toast({ title: "Export impossible", variant: "destructive" }); + return; + } + + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `cra-export-${new Date().toISOString().slice(0, 10)}.json`; + link.click(); + URL.revokeObjectURL(url); + }; + + const handleImport = async (file: File | undefined) => { + if (!file) return; + if (!confirm("Importer ce fichier remplacera toutes les données actuelles. Continuer ?")) return; + + setIsImporting(true); + try { + const data = JSON.parse(await file.text()); + const response = await fetch("/api/admin/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + + if (!response.ok) throw new Error("Import impossible"); + + toast({ title: "Import terminé" }); + setTimeout(() => window.location.reload(), 500); + } catch { + toast({ title: "Fichier invalide ou import impossible", variant: "destructive" }); + } finally { + setIsImporting(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + }; return (
@@ -56,19 +119,103 @@ export function AppSidebar() {
+ +
- JD + {info.userInitials}
- Jean Dupont - Consultant + {info.userName} + {info.userRole}
+ + + + + Administration + + +
+
+

Informations affichées

+
+
+ + setInfo({ ...info, userName: e.target.value })} /> +
+
+ + setInfo({ ...info, userRole: e.target.value })} /> +
+
+ + setInfo({ ...info, userInitials: e.target.value })} /> +
+
+ + setInfo({ ...info, deployDate: e.target.value })} /> +
+
+ + setInfo({ ...info, repoUrl: e.target.value })} /> +
+
+ +
+ +
+

Données

+
+ + + handleImport(e.target.files?.[0])} + /> +
+

+ L'import remplace les projets, CRA, lignes et saisies existants. +

+
+
+ + + + +
+
); } diff --git a/artifacts/cra-app/src/lib/app-info.ts b/artifacts/cra-app/src/lib/app-info.ts new file mode 100644 index 0000000..d49c830 --- /dev/null +++ b/artifacts/cra-app/src/lib/app-info.ts @@ -0,0 +1,36 @@ +export type AppInfo = { + repoUrl: string; + deployDate: string; + userName: string; + userRole: string; + userInitials: string; +}; + +export const APP_INFO_EVENT = "cra-app-info-updated"; + +export const DEFAULT_APP_INFO: AppInfo = { + repoUrl: "https://github.com/sylvainp/cra-app", + deployDate: "14 avril 2025", + userName: "Jean Dupont", + userRole: "Consultant", + userInitials: "JD", +}; + +const STORAGE_KEY = "cra-app-info"; + +export function getAppInfo(): AppInfo { + if (typeof window === "undefined") return DEFAULT_APP_INFO; + + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return DEFAULT_APP_INFO; + return { ...DEFAULT_APP_INFO, ...JSON.parse(raw) }; + } catch { + return DEFAULT_APP_INFO; + } +} + +export function saveAppInfo(info: AppInfo) { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(info)); + window.dispatchEvent(new CustomEvent(APP_INFO_EVENT, { detail: info })); +} \ No newline at end of file diff --git a/replit.md b/replit.md index c203b26..e4b1a45 100644 --- a/replit.md +++ b/replit.md @@ -26,6 +26,7 @@ A French timesheet management application (CRA - Compte Rendu d'Activité) built - **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, 8]. 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 +- **Inline Administration**: Sidebar Admin dialog to edit displayed app/user metadata and export/import project, CRA, line, and time-entry data as JSON ## Database Schema