diff --git a/.replit b/.replit index 82bac8f..8bec5c0 100644 --- a/.replit +++ b/.replit @@ -1,4 +1,4 @@ -modules = ["nodejs-24"] +modules = ["nodejs-24", "postgresql-16"] [deployment] router = "application" @@ -18,3 +18,18 @@ expertMode = true [postMerge] path = "scripts/post-merge.sh" timeoutMs = 20000 + +[[ports]] +localPort = 8080 +externalPort = 8080 + +[[ports]] +localPort = 8081 +externalPort = 80 + +[[ports]] +localPort = 24212 +externalPort = 3000 + +[nix] +channel = "stable-25_05" diff --git a/artifacts/api-server/src/routes/dashboard.ts b/artifacts/api-server/src/routes/dashboard.ts new file mode 100644 index 0000000..5f3d165 --- /dev/null +++ b/artifacts/api-server/src/routes/dashboard.ts @@ -0,0 +1,120 @@ +import { Router, type IRouter } from "express"; +import { eq, sql, and } from "drizzle-orm"; +import { db, timesheetsTable, timesheetLinesTable, timeEntriesTable, projectsTable } from "@workspace/db"; +import { + GetDashboardSummaryQueryParams, + GetDashboardSummaryResponse, + GetMonthlyHoursQueryParams, + GetMonthlyHoursResponse, + GetProjectHoursQueryParams, + GetProjectHoursResponse, +} from "@workspace/api-zod"; + +const router: IRouter = Router(); + +const MONTH_LABELS = [ + "", "Janvier", "Février", "Mars", "Avril", "Mai", "Juin", + "Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre", +]; + +router.get("/dashboard/summary", async (req, res): Promise => { + const query = GetDashboardSummaryQueryParams.safeParse(req.query); + if (!query.success) { + res.status(400).json({ error: query.error.message }); + return; + } + + const year = query.data.year ?? new Date().getFullYear(); + const currentMonth = new Date().getMonth() + 1; + + const allTimesheets = await db + .select() + .from(timesheetsTable) + .where(eq(timesheetsTable.year, year)); + + const currentMonthSheet = allTimesheets.find((t) => t.month === currentMonth); + const totalHoursThisMonth = currentMonthSheet?.totalHours ?? 0; + const totalHoursThisYear = allTimesheets.reduce((sum, t) => sum + t.totalHours, 0); + + const activeProjects = await db + .select({ count: sql`count(*)` }) + .from(projectsTable) + .where(eq(projectsTable.isActive, true)); + + const pendingTimesheets = allTimesheets.filter( + (t) => t.status === "draft" || t.status === "submitted" + ).length; + const validatedTimesheets = allTimesheets.filter( + (t) => t.status === "validated" + ).length; + + res.json( + GetDashboardSummaryResponse.parse({ + totalHoursThisMonth, + totalHoursThisYear, + activeProjects: Number(activeProjects[0]?.count ?? 0), + pendingTimesheets, + validatedTimesheets, + }) + ); +}); + +router.get("/dashboard/monthly-hours", async (req, res): Promise => { + const query = GetMonthlyHoursQueryParams.safeParse(req.query); + if (!query.success) { + res.status(400).json({ error: query.error.message }); + return; + } + + const year = query.data.year ?? new Date().getFullYear(); + + const timesheets = await db + .select() + .from(timesheetsTable) + .where(eq(timesheetsTable.year, year)) + .orderBy(timesheetsTable.month); + + const monthlyData = Array.from({ length: 12 }, (_, i) => { + const month = i + 1; + const ts = timesheets.find((t) => t.month === month); + return { + month, + totalHours: ts?.totalHours ?? 0, + label: MONTH_LABELS[month], + }; + }); + + res.json(GetMonthlyHoursResponse.parse(monthlyData)); +}); + +router.get("/dashboard/project-hours", async (req, res): Promise => { + const query = GetProjectHoursQueryParams.safeParse(req.query); + if (!query.success) { + res.status(400).json({ error: query.error.message }); + return; + } + + const year = query.data.year ?? new Date().getFullYear(); + + const conditions = [eq(timesheetsTable.year, year)]; + if (query.data.month) { + conditions.push(eq(timesheetsTable.month, query.data.month)); + } + + const result = await db + .select({ + projectId: projectsTable.id, + projectCode: projectsTable.code, + projectName: projectsTable.name, + totalHours: sql`COALESCE(SUM(${timesheetLinesTable.totalHours}), 0)`, + }) + .from(timesheetLinesTable) + .innerJoin(projectsTable, eq(timesheetLinesTable.projectId, projectsTable.id)) + .innerJoin(timesheetsTable, eq(timesheetLinesTable.timesheetId, timesheetsTable.id)) + .where(and(...conditions)) + .groupBy(projectsTable.id, projectsTable.code, projectsTable.name); + + res.json(GetProjectHoursResponse.parse(result)); +}); + +export default router; diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index 5a1f77a..3dd2068 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -1,8 +1,14 @@ import { Router, type IRouter } from "express"; import healthRouter from "./health"; +import projectsRouter from "./projects"; +import timesheetsRouter from "./timesheets"; +import dashboardRouter from "./dashboard"; const router: IRouter = Router(); router.use(healthRouter); +router.use(projectsRouter); +router.use(timesheetsRouter); +router.use(dashboardRouter); export default router; diff --git a/artifacts/api-server/src/routes/projects.ts b/artifacts/api-server/src/routes/projects.ts new file mode 100644 index 0000000..8640cb3 --- /dev/null +++ b/artifacts/api-server/src/routes/projects.ts @@ -0,0 +1,103 @@ +import { Router, type IRouter } from "express"; +import { eq } from "drizzle-orm"; +import { db, projectsTable } from "@workspace/db"; +import { + CreateProjectBody, + GetProjectParams, + GetProjectResponse, + UpdateProjectParams, + UpdateProjectBody, + UpdateProjectResponse, + DeleteProjectParams, + ListProjectsResponse, +} from "@workspace/api-zod"; + +const router: IRouter = Router(); + +router.get("/projects", async (_req, res): Promise => { + const projects = await db + .select() + .from(projectsTable) + .orderBy(projectsTable.code); + res.json(ListProjectsResponse.parse(projects)); +}); + +router.post("/projects", async (req, res): Promise => { + const parsed = CreateProjectBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + + const [project] = await db.insert(projectsTable).values(parsed.data).returning(); + res.status(201).json(GetProjectResponse.parse(project)); +}); + +router.get("/projects/:id", async (req, res): Promise => { + const params = GetProjectParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + + const [project] = await db + .select() + .from(projectsTable) + .where(eq(projectsTable.id, params.data.id)); + + if (!project) { + res.status(404).json({ error: "Project not found" }); + return; + } + + res.json(GetProjectResponse.parse(project)); +}); + +router.patch("/projects/:id", async (req, res): Promise => { + const params = UpdateProjectParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + + const parsed = UpdateProjectBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + + const [project] = await db + .update(projectsTable) + .set(parsed.data) + .where(eq(projectsTable.id, params.data.id)) + .returning(); + + if (!project) { + res.status(404).json({ error: "Project not found" }); + return; + } + + res.json(UpdateProjectResponse.parse(project)); +}); + +router.delete("/projects/:id", async (req, res): Promise => { + const params = DeleteProjectParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + + const [project] = await db + .delete(projectsTable) + .where(eq(projectsTable.id, params.data.id)) + .returning(); + + if (!project) { + res.status(404).json({ error: "Project not found" }); + return; + } + + res.sendStatus(204); +}); + +export default router; diff --git a/artifacts/api-server/src/routes/timesheets.ts b/artifacts/api-server/src/routes/timesheets.ts new file mode 100644 index 0000000..2275159 --- /dev/null +++ b/artifacts/api-server/src/routes/timesheets.ts @@ -0,0 +1,327 @@ +import { Router, type IRouter } from "express"; +import { eq, and, sql } from "drizzle-orm"; +import { db, timesheetsTable, timesheetLinesTable, timeEntriesTable, projectsTable } from "@workspace/db"; +import { + CreateTimesheetBody, + GetTimesheetParams, + GetTimesheetResponse, + UpdateTimesheetParams, + UpdateTimesheetBody, + UpdateTimesheetResponse, + DeleteTimesheetParams, + ListTimesheetsQueryParams, + ListTimesheetsResponse, + ListTimesheetLinesParams, + ListTimesheetLinesResponse, + CreateTimesheetLineParams, + CreateTimesheetLineBody, + DeleteTimesheetLineParams, + UpsertTimeEntriesParams, + UpsertTimeEntriesBody, + UpsertTimeEntriesResponse, +} from "@workspace/api-zod"; + +const router: IRouter = Router(); + +router.get("/timesheets", async (req, res): Promise => { + const query = ListTimesheetsQueryParams.safeParse(req.query); + if (!query.success) { + res.status(400).json({ error: query.error.message }); + return; + } + + const conditions = []; + if (query.data.year) { + conditions.push(eq(timesheetsTable.year, query.data.year)); + } + if (query.data.month) { + conditions.push(eq(timesheetsTable.month, query.data.month)); + } + + const timesheets = await db + .select() + .from(timesheetsTable) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(timesheetsTable.year, timesheetsTable.month); + + res.json(ListTimesheetsResponse.parse(timesheets)); +}); + +router.post("/timesheets", async (req, res): Promise => { + const parsed = CreateTimesheetBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + + const [timesheet] = await db.insert(timesheetsTable).values(parsed.data).returning(); + res.status(201).json(GetTimesheetResponse.parse({ ...timesheet, lines: [] })); +}); + +router.get("/timesheets/:id", async (req, res): Promise => { + const params = GetTimesheetParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + + const [timesheet] = await db + .select() + .from(timesheetsTable) + .where(eq(timesheetsTable.id, params.data.id)); + + if (!timesheet) { + res.status(404).json({ error: "Timesheet not found" }); + return; + } + + const lines = await db + .select({ + id: timesheetLinesTable.id, + timesheetId: timesheetLinesTable.timesheetId, + projectId: timesheetLinesTable.projectId, + totalHours: timesheetLinesTable.totalHours, + projectCode: projectsTable.code, + projectName: projectsTable.name, + client: projectsTable.client, + category: projectsTable.category, + }) + .from(timesheetLinesTable) + .innerJoin(projectsTable, eq(timesheetLinesTable.projectId, projectsTable.id)) + .where(eq(timesheetLinesTable.timesheetId, params.data.id)); + + const linesWithEntries = await Promise.all( + lines.map(async (line) => { + const entries = await db + .select() + .from(timeEntriesTable) + .where(eq(timeEntriesTable.timesheetLineId, line.id)); + return { ...line, entries }; + }) + ); + + res.json(GetTimesheetResponse.parse({ ...timesheet, lines: linesWithEntries })); +}); + +router.patch("/timesheets/:id", async (req, res): Promise => { + const params = UpdateTimesheetParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + + const parsed = UpdateTimesheetBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + + const [timesheet] = await db + .update(timesheetsTable) + .set({ ...parsed.data, updatedAt: new Date() }) + .where(eq(timesheetsTable.id, params.data.id)) + .returning(); + + if (!timesheet) { + res.status(404).json({ error: "Timesheet not found" }); + return; + } + + res.json(UpdateTimesheetResponse.parse(timesheet)); +}); + +router.delete("/timesheets/:id", async (req, res): Promise => { + const params = DeleteTimesheetParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + + const [timesheet] = await db + .delete(timesheetsTable) + .where(eq(timesheetsTable.id, params.data.id)) + .returning(); + + if (!timesheet) { + res.status(404).json({ error: "Timesheet not found" }); + return; + } + + res.sendStatus(204); +}); + +router.get("/timesheets/:timesheetId/lines", async (req, res): Promise => { + const params = ListTimesheetLinesParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + + const lines = await db + .select({ + id: timesheetLinesTable.id, + timesheetId: timesheetLinesTable.timesheetId, + projectId: timesheetLinesTable.projectId, + totalHours: timesheetLinesTable.totalHours, + projectCode: projectsTable.code, + projectName: projectsTable.name, + client: projectsTable.client, + category: projectsTable.category, + }) + .from(timesheetLinesTable) + .innerJoin(projectsTable, eq(timesheetLinesTable.projectId, projectsTable.id)) + .where(eq(timesheetLinesTable.timesheetId, params.data.timesheetId)); + + res.json(ListTimesheetLinesResponse.parse(lines)); +}); + +router.post("/timesheets/:timesheetId/lines", async (req, res): Promise => { + const params = CreateTimesheetLineParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + + const parsed = CreateTimesheetLineBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + + const [project] = await db + .select() + .from(projectsTable) + .where(eq(projectsTable.id, parsed.data.projectId)); + + if (!project) { + res.status(404).json({ error: "Project not found" }); + return; + } + + const [line] = await db + .insert(timesheetLinesTable) + .values({ + timesheetId: params.data.timesheetId, + projectId: parsed.data.projectId, + }) + .returning(); + + res.status(201).json({ + ...line, + projectCode: project.code, + projectName: project.name, + client: project.client, + category: project.category, + }); +}); + +router.delete("/timesheets/:timesheetId/lines/:lineId", async (req, res): Promise => { + const params = DeleteTimesheetLineParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + + const [line] = await db + .delete(timesheetLinesTable) + .where( + and( + eq(timesheetLinesTable.id, params.data.lineId), + eq(timesheetLinesTable.timesheetId, params.data.timesheetId) + ) + ) + .returning(); + + if (!line) { + res.status(404).json({ error: "Line not found" }); + return; + } + + await recalculateTimesheetTotal(params.data.timesheetId); + res.sendStatus(204); +}); + +router.put("/timesheets/:timesheetId/entries", async (req, res): Promise => { + const params = UpsertTimeEntriesParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + + const parsed = UpsertTimeEntriesBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + + const results = []; + for (const entry of parsed.data.entries) { + const dateStr = typeof entry.date === "string" ? entry.date : (entry.date as Date).toISOString().split("T")[0]; + const existing = await db + .select() + .from(timeEntriesTable) + .where( + and( + eq(timeEntriesTable.timesheetLineId, entry.timesheetLineId), + eq(timeEntriesTable.date, dateStr) + ) + ); + + if (existing.length > 0) { + if (entry.hours === 0) { + await db + .delete(timeEntriesTable) + .where(eq(timeEntriesTable.id, existing[0].id)); + results.push({ ...existing[0], hours: 0 }); + } else { + const [updated] = await db + .update(timeEntriesTable) + .set({ hours: entry.hours }) + .where(eq(timeEntriesTable.id, existing[0].id)) + .returning(); + results.push(updated); + } + } else if (entry.hours > 0) { + const [created] = await db + .insert(timeEntriesTable) + .values({ + timesheetLineId: entry.timesheetLineId, + date: dateStr, + hours: entry.hours, + }) + .returning(); + results.push(created); + } + } + + const affectedLineIds = [...new Set(parsed.data.entries.map((e) => e.timesheetLineId))]; + for (const lineId of affectedLineIds) { + const entries = await db + .select() + .from(timeEntriesTable) + .where(eq(timeEntriesTable.timesheetLineId, lineId)); + const total = entries.reduce((sum, e) => sum + e.hours, 0); + await db + .update(timesheetLinesTable) + .set({ totalHours: total }) + .where(eq(timesheetLinesTable.id, lineId)); + } + + await recalculateTimesheetTotal(params.data.timesheetId); + + res.json(UpsertTimeEntriesResponse.parse(results.filter((r) => r.hours > 0))); +}); + +async function recalculateTimesheetTotal(timesheetId: number) { + const lines = await db + .select() + .from(timesheetLinesTable) + .where(eq(timesheetLinesTable.timesheetId, timesheetId)); + const total = lines.reduce((sum, l) => sum + l.totalHours, 0); + await db + .update(timesheetsTable) + .set({ totalHours: total, updatedAt: new Date() }) + .where(eq(timesheetsTable.id, timesheetId)); +} + +export default router; diff --git a/artifacts/cra-app/.replit-artifact/artifact.toml b/artifacts/cra-app/.replit-artifact/artifact.toml new file mode 100644 index 0000000..96d60bd --- /dev/null +++ b/artifacts/cra-app/.replit-artifact/artifact.toml @@ -0,0 +1,31 @@ +kind = "web" +previewPath = "/" +title = "CRA - Compte Rendu d'Activité" +version = "1.0.0" +id = "artifacts/cra-app" +router = "path" + +[[integratedSkills]] +name = "react-vite" +version = "1.0.0" + +[[services]] +name = "web" +paths = [ "/" ] +localPort = 24212 + +[services.development] +run = "pnpm --filter @workspace/cra-app run dev" + +[services.production] +build = [ "pnpm", "--filter", "@workspace/cra-app", "run", "build" ] +publicDir = "artifacts/cra-app/dist/public" +serve = "static" + +[[services.production.rewrites]] +from = "/*" +to = "/index.html" + +[services.env] +PORT = "24212" +BASE_PATH = "/" diff --git a/artifacts/cra-app/components.json b/artifacts/cra-app/components.json new file mode 100644 index 0000000..3ff62cf --- /dev/null +++ b/artifacts/cra-app/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} \ No newline at end of file diff --git a/artifacts/cra-app/index.html b/artifacts/cra-app/index.html new file mode 100644 index 0000000..1575459 --- /dev/null +++ b/artifacts/cra-app/index.html @@ -0,0 +1,16 @@ + + + + + + CRA - Compte Rendu d'Activité + + + + + + +
+ + + diff --git a/artifacts/cra-app/package.json b/artifacts/cra-app/package.json new file mode 100644 index 0000000..8a62114 --- /dev/null +++ b/artifacts/cra-app/package.json @@ -0,0 +1,77 @@ +{ + "name": "@workspace/cra-app", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --config vite.config.ts --host 0.0.0.0", + "build": "vite build --config vite.config.ts", + "serve": "vite preview --config vite.config.ts --host 0.0.0.0", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@hookform/resolvers": "^3.10.0", + "@radix-ui/react-accordion": "^1.2.4", + "@radix-ui/react-alert-dialog": "^1.1.7", + "@radix-ui/react-aspect-ratio": "^1.1.3", + "@radix-ui/react-avatar": "^1.1.4", + "@radix-ui/react-checkbox": "^1.1.5", + "@radix-ui/react-collapsible": "^1.1.4", + "@radix-ui/react-context-menu": "^2.2.7", + "@radix-ui/react-dialog": "^1.1.7", + "@radix-ui/react-dropdown-menu": "^2.1.7", + "@radix-ui/react-hover-card": "^1.1.7", + "@radix-ui/react-label": "^2.1.3", + "@radix-ui/react-menubar": "^1.1.7", + "@radix-ui/react-navigation-menu": "^1.2.6", + "@radix-ui/react-popover": "^1.1.7", + "@radix-ui/react-progress": "^1.1.3", + "@radix-ui/react-radio-group": "^1.2.4", + "@radix-ui/react-scroll-area": "^1.2.4", + "@radix-ui/react-select": "^2.1.7", + "@radix-ui/react-separator": "^1.1.3", + "@radix-ui/react-slider": "^1.2.4", + "@radix-ui/react-slot": "^1.2.0", + "@radix-ui/react-switch": "^1.1.4", + "@radix-ui/react-tabs": "^1.1.4", + "@radix-ui/react-toast": "^1.2.7", + "@radix-ui/react-toggle": "^1.1.3", + "@radix-ui/react-toggle-group": "^1.1.3", + "@radix-ui/react-tooltip": "^1.2.0", + "@replit/vite-plugin-cartographer": "catalog:", + "@replit/vite-plugin-dev-banner": "catalog:", + "@replit/vite-plugin-runtime-error-modal": "catalog:", + "@tailwindcss/typography": "^0.5.15", + "@tailwindcss/vite": "catalog:", + "@tanstack/react-query": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "@workspace/api-client-react": "workspace:*", + "class-variance-authority": "catalog:", + "clsx": "catalog:", + "cmdk": "^1.1.1", + "date-fns": "^3.6.0", + "embla-carousel-react": "^8.6.0", + "framer-motion": "catalog:", + "input-otp": "^1.4.2", + "lucide-react": "catalog:", + "next-themes": "^0.4.6", + "react": "catalog:", + "react-day-picker": "^9.11.1", + "react-dom": "catalog:", + "react-hook-form": "^7.55.0", + "react-icons": "^5.4.0", + "react-resizable-panels": "^2.1.7", + "recharts": "^2.15.2", + "sonner": "^2.0.7", + "tailwind-merge": "catalog:", + "tailwindcss": "catalog:", + "tw-animate-css": "^1.4.0", + "vaul": "^1.1.2", + "vite": "catalog:", + "wouter": "^3.3.5", + "zod": "catalog:" + } +} diff --git a/artifacts/cra-app/public/favicon.svg b/artifacts/cra-app/public/favicon.svg new file mode 100644 index 0000000..4373d3c --- /dev/null +++ b/artifacts/cra-app/public/favicon.svg @@ -0,0 +1,3 @@ + + + diff --git a/artifacts/cra-app/public/opengraph.jpg b/artifacts/cra-app/public/opengraph.jpg new file mode 100644 index 0000000..2b60f32 Binary files /dev/null and b/artifacts/cra-app/public/opengraph.jpg differ diff --git a/artifacts/cra-app/src/App.tsx b/artifacts/cra-app/src/App.tsx new file mode 100644 index 0000000..efd15c3 --- /dev/null +++ b/artifacts/cra-app/src/App.tsx @@ -0,0 +1,21 @@ +import { AppLayout } from "@/components/layout/layout"; +import { Switch, Route } from "wouter"; +import DashboardPage from "@/pages/dashboard"; +import ProjectsPage from "@/pages/projects"; +import TimesheetsPage from "@/pages/timesheets"; +import TimesheetDetailPage from "@/pages/timesheet-detail"; +import NotFound from "@/pages/not-found"; + +export default function App() { + return ( + + + + + + + + + + ); +} diff --git a/artifacts/cra-app/src/components/layout/layout.tsx b/artifacts/cra-app/src/components/layout/layout.tsx new file mode 100644 index 0000000..31f2c2e --- /dev/null +++ b/artifacts/cra-app/src/components/layout/layout.tsx @@ -0,0 +1,14 @@ +import { AppSidebar } from "./sidebar"; + +export function AppLayout({ children }: { children: React.ReactNode }) { + return ( +
+ +
+
+ {children} +
+
+
+ ); +} diff --git a/artifacts/cra-app/src/components/layout/sidebar.tsx b/artifacts/cra-app/src/components/layout/sidebar.tsx new file mode 100644 index 0000000..21c4484 --- /dev/null +++ b/artifacts/cra-app/src/components/layout/sidebar.tsx @@ -0,0 +1,69 @@ +import { Link, useLocation } from "wouter"; +import { LayoutDashboard, Clock, FolderKanban, Settings } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const navItems = [ + { + title: "Tableau de bord", + href: "/", + icon: LayoutDashboard, + }, + { + title: "Feuilles de temps", + href: "/timesheets", + icon: Clock, + }, + { + title: "Projets", + href: "/projects", + icon: FolderKanban, + }, +]; + +export function AppSidebar() { + const [location] = useLocation(); + + return ( +
+
+
+ +
+

CRA Manager

+
+ + + +
+
+
+ JD +
+
+ Jean Dupont + Consultant +
+
+
+
+ ); +} diff --git a/artifacts/cra-app/src/components/ui/accordion.tsx b/artifacts/cra-app/src/components/ui/accordion.tsx new file mode 100644 index 0000000..e1797c9 --- /dev/null +++ b/artifacts/cra-app/src/components/ui/accordion.tsx @@ -0,0 +1,55 @@ +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/artifacts/cra-app/src/components/ui/alert-dialog.tsx b/artifacts/cra-app/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..fa2b442 --- /dev/null +++ b/artifacts/cra-app/src/components/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/artifacts/cra-app/src/components/ui/alert.tsx b/artifacts/cra-app/src/components/ui/alert.tsx new file mode 100644 index 0000000..5afd41d --- /dev/null +++ b/artifacts/cra-app/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/artifacts/cra-app/src/components/ui/aspect-ratio.tsx b/artifacts/cra-app/src/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..c4abbf3 --- /dev/null +++ b/artifacts/cra-app/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,5 @@ +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +const AspectRatio = AspectRatioPrimitive.Root + +export { AspectRatio } diff --git a/artifacts/cra-app/src/components/ui/avatar.tsx b/artifacts/cra-app/src/components/ui/avatar.tsx new file mode 100644 index 0000000..51e507b --- /dev/null +++ b/artifacts/cra-app/src/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/artifacts/cra-app/src/components/ui/badge.tsx b/artifacts/cra-app/src/components/ui/badge.tsx new file mode 100644 index 0000000..3f03665 --- /dev/null +++ b/artifacts/cra-app/src/components/ui/badge.tsx @@ -0,0 +1,43 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + // @replit + // Whitespace-nowrap: Badges should never wrap. + "whitespace-nowrap inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" + + " hover-elevate ", + { + variants: { + variant: { + default: + // @replit shadow-xs instead of shadow, no hover because we use hover-elevate + "border-transparent bg-primary text-primary-foreground shadow-xs", + secondary: + // @replit no hover because we use hover-elevate + "border-transparent bg-secondary text-secondary-foreground", + destructive: + // @replit shadow-xs instead of shadow, no hover because we use hover-elevate + "border-transparent bg-destructive text-destructive-foreground shadow-xs", + // @replit shadow-xs" - use badge outline variable + outline: "text-foreground border [border-color:var(--badge-outline)]", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/artifacts/cra-app/src/components/ui/breadcrumb.tsx b/artifacts/cra-app/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..60e6c96 --- /dev/null +++ b/artifacts/cra-app/src/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) =>