diff --git a/.env.example b/.env.example index bdfffd8..f005187 100644 --- a/.env.example +++ b/.env.example @@ -83,6 +83,13 @@ PGADMIN_PASSWORD= # If not set, cron endpoints are disabled. # CRON_SECRET= +# ─── Error Tracking (Sentry) ───────────────────────────────────────────────── + +# Sentry DSN for client-side and server-side error reporting. +# Create a Next.js project at https://sentry.io and copy the DSN here. +# If not set, Sentry is disabled (SDK is installed but sends nothing). +# NEXT_PUBLIC_SENTRY_DSN= + # ─── Testing (never enable in production) ──────────────────────────────────── # Disables rate limiting and session tracking during end-to-end tests. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 911b5c1..873f228 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -198,6 +198,20 @@ jobs: pnpm --filter @capakraken/shared exec vitest run --coverage pnpm --filter @capakraken/db test:unit + - name: Upload coverage reports + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: coverage-reports + path: | + apps/web/coverage/ + packages/engine/coverage/ + packages/staffing/coverage/ + packages/api/coverage/ + packages/application/coverage/ + packages/shared/coverage/ + retention-days: 14 + # ────────────────────────────────────────────── # Build — depends on typecheck passing # ────────────────────────────────────────────── diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..5ee7abd --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm exec lint-staged diff --git a/.lintstagedrc.json b/.lintstagedrc.json new file mode 100644 index 0000000..a0c8bea --- /dev/null +++ b/.lintstagedrc.json @@ -0,0 +1,4 @@ +{ + "*.{ts,tsx}": ["eslint --fix", "prettier --write"], + "*.{json,md}": ["prettier --write"] +} diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs new file mode 100644 index 0000000..0579d6e --- /dev/null +++ b/apps/web/eslint.config.mjs @@ -0,0 +1,30 @@ +import nextjsConfig from "@capakraken/eslint-config/nextjs"; + +/** @type {import("eslint").Linter.FlatConfig[]} */ +export default [ + ...nextjsConfig, + { + ignores: ["e2e/**"], + }, + { + // Temporary overrides for pre-existing violations surfaced by switching + // from `next lint` to the shared eslint config. Each rule should be + // upgraded back to "error" as the violations are resolved: + // - no-explicit-any: Phase 2 (reduce `as any` casts) + // - no-unused-vars: Phase 2 (remove dead code) + // - no-misused-promises: Phase 2 (fix async event handlers) + // - no-unsafe-function-type: Phase 2 (replace `Function` type) + // - no-unused-expressions: Phase 2 + rules: { + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], + "@typescript-eslint/no-misused-promises": "warn", + "@typescript-eslint/no-unsafe-function-type": "warn", + "@typescript-eslint/no-unused-expressions": "warn", + "@typescript-eslint/consistent-type-imports": ["error", { + fixStyle: "separate-type-imports", + disallowTypeAnnotations: false, + }], + }, + }, +]; diff --git a/apps/web/package.json b/apps/web/package.json index 3006308..ad59d31 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,7 +6,7 @@ "dev": "next dev -p 3100", "build": "next build", "start": "next start -p 3100", - "lint": "next lint", + "lint": "eslint src/", "typecheck": "tsc --project tsconfig.typecheck.json --noEmit", "test:unit": "vitest run", "test:e2e": "playwright test", @@ -49,6 +49,7 @@ "zod": "^3.23.8" }, "devDependencies": { + "@capakraken/eslint-config": "workspace:*", "@capakraken/tsconfig": "workspace:*", "@playwright/test": "^1.49.1", "@types/dompurify": "^3.2.0", @@ -59,6 +60,7 @@ "@types/three": "^0.183.1", "@vitest/coverage-v8": "^2.1.9", "autoprefixer": "^10.4.20", + "eslint": "^10.2.0", "postcss": "^8.4.49", "tailwindcss": "^3.4.17", "typescript": "^5.6.3", diff --git a/apps/web/src/app/(app)/projects/ProjectsClient.tsx b/apps/web/src/app/(app)/projects/ProjectsClient.tsx index 8c41373..e917cfb 100644 --- a/apps/web/src/app/(app)/projects/ProjectsClient.tsx +++ b/apps/web/src/app/(app)/projects/ProjectsClient.tsx @@ -5,8 +5,8 @@ import { useUrlFilters } from "~/hooks/useUrlFilters.js"; import { useDebounce } from "~/hooks/useDebounce.js"; import { createPortal } from "react-dom"; import { formatDate, formatMoney } from "~/lib/format.js"; -import type { Project, ColumnDef } from "@capakraken/shared"; -import { ProjectStatus, PROJECT_COLUMNS, BlueprintTarget } from "@capakraken/shared"; +import type { Project, ColumnDef, ProjectStatus } from "@capakraken/shared"; +import { PROJECT_COLUMNS, BlueprintTarget } from "@capakraken/shared"; import Link from "next/link"; import Image from "next/image"; import { clsx } from "clsx"; @@ -32,7 +32,10 @@ import { DraggableTableRow } from "~/components/ui/DraggableTableRow.js"; import { ShoringBadge } from "~/components/projects/ShoringIndicator.js"; import { SuccessToast } from "~/components/ui/SuccessToast.js"; -import { PROJECT_STATUS_BADGE as STATUS_COLORS, ORDER_TYPE_BADGE as ORDER_TYPE_COLORS } from "~/lib/status-styles.js"; +import { + PROJECT_STATUS_BADGE as STATUS_COLORS, + ORDER_TYPE_BADGE as ORDER_TYPE_COLORS, +} from "~/lib/status-styles.js"; // ─── Constants ──────────────────────────────────────────────────────────────── @@ -53,7 +56,13 @@ const ALL_ORDER_TYPES = [ // ─── Sub-components ─────────────────────────────────────────────────────────── -function BudgetBar({ utilizationPercent, budgetCents }: { utilizationPercent: number; budgetCents: number }) { +function BudgetBar({ + utilizationPercent, + budgetCents, +}: { + utilizationPercent: number; + budgetCents: number; +}) { if (budgetCents === 0) { return
No budget
; } @@ -66,14 +75,27 @@ function BudgetBar({ utilizationPercent, budgetCents }: { utilizationPercent: nu return (
-
+
{utilizationPercent.toFixed(0)}% used
); } -function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: ProjectRow; isOpen: boolean; onOpen: () => void; onClose: () => void }) { +function StatusDropdown({ + project, + isOpen, + onOpen, + onClose, +}: { + project: ProjectRow; + isOpen: boolean; + onOpen: () => void; + onClose: () => void; +}) { const utils = trpc.useUtils(); const triggerRef = useRef(null); const panelRef = useRef(null); @@ -110,7 +132,10 @@ function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: Project - {isOpen && createPortal( - - {ALL_STATUSES.map((s) => ( - - ))} - , - document.body, - )} + {isOpen && + createPortal( + + {ALL_STATUSES.map((s) => ( + + ))} + , + document.body, + )} ); } @@ -190,13 +229,12 @@ export function ProjectsClient() { // Flush debounced input to URL useEffect(() => { setFilters({ search: debouncedSearch }); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [debouncedSearch]); // Keep local input in sync when URL changes externally (e.g. back/forward) useEffect(() => { setSearchInput(filters.search); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [filters.search]); const setStatusFilter = useCallback((v: string) => setFilters({ status: v }), [setFilters]); @@ -207,7 +245,10 @@ export function ProjectsClient() { const [successToast, setSuccessToast] = useState(null); const [openStatusProjectId, setOpenStatusProjectId] = useState(null); const [batchStatusPicker, setBatchStatusPicker] = useState(false); - const [confirmBatchStatus, setConfirmBatchStatus] = useState<{ ids: string[]; status: string } | null>(null); + const [confirmBatchStatus, setConfirmBatchStatus] = useState<{ + ids: string[]; + status: string; + } | null>(null); const [confirmBatchDelete, setConfirmBatchDelete] = useState(null); const selection = useSelection(); @@ -229,7 +270,9 @@ export function ProjectsClient() { }); // ─── Favorites ────────────────────────────────────────────────────────── - const { data: favoriteIds } = trpc.user.getFavoriteProjectIds.useQuery(undefined, { staleTime: 30_000 }); + const { data: favoriteIds } = trpc.user.getFavoriteProjectIds.useQuery(undefined, { + staleTime: 30_000, + }); const favSet = useMemo(() => new Set(favoriteIds ?? []), [favoriteIds]); const toggleFavMutation = trpc.user.toggleFavoriteProject.useMutation({ onMutate: async ({ projectId }) => { @@ -300,7 +343,7 @@ export function ProjectsClient() { isFetchingNextPage, fetchNextPage, hasNextPage, - // Keep this boundary shallow; full TRPC inference here can trip TS depth limits. + // Keep this boundary shallow; full TRPC inference here can trip TS depth limits. } = (trpc.project.listWithCosts.useInfiniteQuery as any)( { search: search || undefined, @@ -308,9 +351,12 @@ export function ProjectsClient() { limit: 50, }, { - getNextPageParam: (lastPage: { nextCursor?: string | null }) => lastPage.nextCursor ?? undefined, + getNextPageParam: (lastPage: { nextCursor?: string | null }) => + lastPage.nextCursor ?? undefined, initialCursor: undefined, - placeholderData: (prev: { pages: { projects: ProjectRow[]; nextCursor?: string | null }[] } | undefined) => prev, + placeholderData: ( + prev: { pages: { projects: ProjectRow[]; nextCursor?: string | null }[] } | undefined, + ) => prev, staleTime: 15_000, }, ) as { @@ -332,7 +378,8 @@ export function ProjectsClient() { // Client-side orderType filter const filteredProjects = useMemo( - () => (orderTypeFilter ? allProjects.filter((p) => p.orderType === orderTypeFilter) : allProjects), + () => + orderTypeFilter ? allProjects.filter((p) => p.orderType === orderTypeFilter) : allProjects, [allProjects, orderTypeFilter], ); @@ -345,29 +392,41 @@ export function ProjectsClient() { viewPrefs.setSavedSort(field && dir ? { field, dir } : null); }, }); - const { orderedRows: projects, reorder, isCustomOrder, resetOrder } = useRowOrder( - sorted, - viewPrefs, - sortField, - reset, - ); + const { + orderedRows: projects, + reorder, + isCustomOrder, + resetOrder, + } = useRowOrder(sorted, viewPrefs, sortField, reset); const rowDragRef = useRef(null); const projectIds = projects.map((p) => p.id); useEffect(() => { selection.clear(); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [search, statusFilter, orderTypeFilter]); const handleFetchNext = useCallback(() => { if (hasNextPage && !isFetchingNextPage) void fetchNextPage(); }, [hasNextPage, isFetchingNextPage, fetchNextPage]); - function openNewModal() { setEditingProject(null); setModalOpen(true); } - function openEditModal(project: Project) { setEditingProject(project); setModalOpen(true); } - function closeModal() { setModalOpen(false); setEditingProject(null); } - function clearAll() { setSearchInput(""); setFilters({ search: "", status: "", orderType: "" }); } + function openNewModal() { + setEditingProject(null); + setModalOpen(true); + } + function openEditModal(project: Project) { + setEditingProject(project); + setModalOpen(true); + } + function closeModal() { + setModalOpen(false); + setEditingProject(null); + } + function clearAll() { + setSearchInput(""); + setFilters({ search: "", status: "", orderType: "" }); + } const exportSelectedCsv = useCallback(() => { const selected = projects.filter((p) => selection.selectedIds.has(p.id)); @@ -389,9 +448,23 @@ export function ProjectsClient() { }, [projects, selection.selectedIds]); const chips = [ - ...(search ? [{ label: `Search: "${search}"`, onRemove: () => { setSearchInput(""); setFilters({ search: "" }); } }] : []), - ...(statusFilter ? [{ label: `Status: ${statusFilter}`, onRemove: () => setStatusFilter("") }] : []), - ...(orderTypeFilter ? [{ label: `Type: ${orderTypeFilter}`, onRemove: () => setOrderTypeFilter("") }] : []), + ...(search + ? [ + { + label: `Search: "${search}"`, + onRemove: () => { + setSearchInput(""); + setFilters({ search: "" }); + }, + }, + ] + : []), + ...(statusFilter + ? [{ label: `Status: ${statusFilter}`, onRemove: () => setStatusFilter("") }] + : []), + ...(orderTypeFilter + ? [{ label: `Type: ${orderTypeFilter}`, onRemove: () => setOrderTypeFilter("") }] + : []), ]; // ─── Cell renderer ──────────────────────────────────────────────────────── @@ -401,18 +474,42 @@ export function ProjectsClient() { if (col.isCustom) { const fieldKey = col.key.replace(/^custom_/, ""); const val = dynFields[fieldKey]; - return {val != null ? String(val) : "—"}; + return ( + + {val != null ? String(val) : "—"} + + ); } switch (col.key) { case "shortCode": - return {project.shortCode}; + return ( + + {project.shortCode} + + ); case "name": return ( - - + + {project.coverImageUrl ? ( - {project.name} + {project.name} ) : ( - {project.name.split(/\s+/).map((w) => w[0]).filter(Boolean).slice(0, 2).join("").toUpperCase()} + {project.name + .split(/\s+/) + .map((w) => w[0]) + .filter(Boolean) + .slice(0, 2) + .join("") + .toUpperCase()} )} {project.name} @@ -442,14 +545,19 @@ export function ProjectsClient() { case "orderType": return ( - + {project.orderType} ); case "dates": return ( - + {formatDate(project.startDate)} – {formatDate(project.endDate)} ); @@ -459,7 +567,10 @@ export function ProjectsClient() {
{formatMoney(project.budgetCents)}
- + ); case "allocations": @@ -467,8 +578,18 @@ export function ProjectsClient() { {project.totalPersonDays > 0 ? ( - - + + {project.totalPersonDays}d @@ -484,14 +605,30 @@ export function ProjectsClient() { ); case "responsible": - return —; + return ( + + — + + ); default: - return —; + return ( + + — + + ); } } // ─── Header renderer ────────────────────────────────────────────────────── - const SORTABLE_PROJECT_COLS = new Set(["shortCode", "name", "status", "orderType", "dates", "budget", "allocations"]); + const SORTABLE_PROJECT_COLS = new Set([ + "shortCode", + "name", + "status", + "orderType", + "dates", + "budget", + "allocations", + ]); function renderHeader(col: ColumnDef) { if (SORTABLE_PROJECT_COLS.has(col.key)) { return ( @@ -506,7 +643,10 @@ export function ProjectsClient() { ); } return ( - + {col.label} ); @@ -531,7 +671,12 @@ export function ProjectsClient() { className="inline-flex items-center gap-2 rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-brand-700" > - + New Project Wizard @@ -541,7 +686,12 @@ export function ProjectsClient() { className="inline-flex items-center gap-2 rounded-xl border border-gray-300 bg-white px-4 py-2.5 text-sm font-medium text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800" > - + Quick Add @@ -563,7 +713,9 @@ export function ProjectsClient() { > {ALL_STATUSES.map((s) => ( - + ))} {isLoading ? ( -
Loading projects…
+
+ Loading projects… +
) : ( <>
@@ -644,9 +800,15 @@ export function ProjectsClient() { @@ -669,7 +831,10 @@ export function ProjectsClient() { > Edit - + View →
@@ -684,25 +849,34 @@ export function ProjectsClient() { {projects.length === 0 && (
No projects found.{" "} -
)} - + )}
{/* Batch Status Picker */} {batchStatusPicker && ( -
setBatchStatusPicker(false)}> -
e.stopPropagation()}> -

Set status for {selection.count} projects

+
setBatchStatusPicker(false)} + > +
e.stopPropagation()} + > +

+ Set status for {selection.count} projects +

{ALL_STATUSES.map((s) => ( @@ -732,7 +911,10 @@ export function ProjectsClient() { confirmLabel="Update" onConfirm={() => { if (confirmBatchStatus) { - batchUpdateStatus.mutate({ ids: confirmBatchStatus.ids, status: confirmBatchStatus.status as never }); + batchUpdateStatus.mutate({ + ids: confirmBatchStatus.ids, + status: confirmBatchStatus.status as never, + }); } setConfirmBatchStatus(null); }} diff --git a/apps/web/src/app/(app)/resources/ResourcesClient.tsx b/apps/web/src/app/(app)/resources/ResourcesClient.tsx index 7466c07..510d341 100644 --- a/apps/web/src/app/(app)/resources/ResourcesClient.tsx +++ b/apps/web/src/app/(app)/resources/ResourcesClient.tsx @@ -166,7 +166,7 @@ export function ResourcesClient() { const departedFilter = resourceUrlFilters.departed as BooleanFilter; // chapters stored as comma-separated string; empty string means "all chapters visible" const chaptersParam = resourceUrlFilters.chapters; - // eslint-disable-next-line react-hooks/exhaustive-deps + const chapterFilter: string[] = useMemo( () => (chaptersParam ? chaptersParam.split(",").filter(Boolean) : []), [chaptersParam], @@ -175,21 +175,32 @@ export function ResourcesClient() { // Flush debounced search input to URL useEffect(() => { setResourceUrlFilters({ search: debouncedSearch }); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [debouncedSearch]); // Keep local search input in sync when URL changes externally useEffect(() => { setSearchInput(resourceUrlFilters.search); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [resourceUrlFilters.search]); - const setIsActiveFilter = useCallback((v: ActiveFilter) => setResourceUrlFilters({ activeFilter: v }), [setResourceUrlFilters]); - const setRolledOffFilter = useCallback((v: BooleanFilter) => setResourceUrlFilters({ rolledOff: v }), [setResourceUrlFilters]); - const setDepartedFilter = useCallback((v: BooleanFilter) => setResourceUrlFilters({ departed: v }), [setResourceUrlFilters]); - const setChapterFilter = useCallback((v: string[]) => { - setResourceUrlFilters({ chapters: v.join(",") }); - }, [setResourceUrlFilters]); + const setIsActiveFilter = useCallback( + (v: ActiveFilter) => setResourceUrlFilters({ activeFilter: v }), + [setResourceUrlFilters], + ); + const setRolledOffFilter = useCallback( + (v: BooleanFilter) => setResourceUrlFilters({ rolledOff: v }), + [setResourceUrlFilters], + ); + const setDepartedFilter = useCallback( + (v: BooleanFilter) => setResourceUrlFilters({ departed: v }), + [setResourceUrlFilters], + ); + const setChapterFilter = useCallback( + (v: string[]) => { + setResourceUrlFilters({ chapters: v.join(",") }); + }, + [setResourceUrlFilters], + ); const [includeProposedChargeability, setIncludeProposedChargeability] = useState(false); const [hiddenCountryIds, setHiddenCountryIds] = useState([]); @@ -412,7 +423,13 @@ export function ResourcesClient() { function clearAll() { setSearchInput(""); - setResourceUrlFilters({ search: "", activeFilter: "active", rolledOff: DEFAULT_BOOLEAN_FILTER, departed: DEFAULT_BOOLEAN_FILTER, chapters: "" }); + setResourceUrlFilters({ + search: "", + activeFilter: "active", + rolledOff: DEFAULT_BOOLEAN_FILTER, + departed: DEFAULT_BOOLEAN_FILTER, + chapters: "", + }); setHiddenCountryIds([]); setIncludeWithoutCountry(true); setHiddenResourceTypes([...DEFAULT_HIDDEN_RESOURCE_TYPES]); @@ -468,7 +485,9 @@ export function ResourcesClient() { if (next.length === chapters.length) { setChapterFilter([]); } else { - setChapterFilter(next.sort((left, right) => chapters.indexOf(left) - chapters.indexOf(right))); + setChapterFilter( + next.sort((left, right) => chapters.indexOf(left) - chapters.indexOf(right)), + ); } }, [chapters, chapterFilter, setChapterFilter], @@ -533,13 +552,23 @@ export function ResourcesClient() { { header: "LCR (cents)", accessor: (r) => r.lcrCents }, { header: "Currency", accessor: (r) => r.currency }, { header: "Chargeability Target", accessor: (r) => r.chargeabilityTarget }, - { header: "Active", accessor: (r) => r.isActive ? "Yes" : "No" }, + { header: "Active", accessor: (r) => (r.isActive ? "Yes" : "No") }, ]); downloadCsv(csv, `resources-export-${new Date().toISOString().slice(0, 10)}.csv`); }, [displayedResources, selection.selectedIds]); const chips = [ - ...(search ? [{ label: `Search: "${search}"`, onRemove: () => { setSearchInput(""); setResourceUrlFilters({ search: "" }); } }] : []), + ...(search + ? [ + { + label: `Search: "${search}"`, + onRemove: () => { + setSearchInput(""); + setResourceUrlFilters({ search: "" }); + }, + }, + ] + : []), ...(chapterFilter.length > 0 ? [ { @@ -1303,7 +1332,12 @@ export function ResourcesClient() { />
{isOverflow && ( - + + + + + )}
)} diff --git a/apps/web/src/app/api/cron/auth-anomaly-check/route.ts b/apps/web/src/app/api/cron/auth-anomaly-check/route.ts index 958f135..12798b9 100644 --- a/apps/web/src/app/api/cron/auth-anomaly-check/route.ts +++ b/apps/web/src/app/api/cron/auth-anomaly-check/route.ts @@ -115,7 +115,6 @@ export async function GET(request: Request) { ) .join("; "); - // eslint-disable-next-line @typescript-eslint/no-explicit-any await createNotificationsForUsers({ db: prisma as any, userIds: adminUsers.map((u) => u.id), @@ -128,7 +127,10 @@ export async function GET(request: Request) { }); logger.warn( - { anomalies: report.anomalies, window: { start: report.windowStartedAt, end: report.windowEndedAt } }, + { + anomalies: report.anomalies, + window: { start: report.windowStartedAt, end: report.windowEndedAt }, + }, "Auth anomaly cron: anomalies detected and admins notified", ); } @@ -140,9 +142,6 @@ export async function GET(request: Request) { }); } catch (error) { logger.error({ error, route: "/api/cron/auth-anomaly-check" }, "Auth anomaly cron failed"); - return NextResponse.json( - { ok: false, error: "Internal error" }, - { status: 500 }, - ); + return NextResponse.json({ ok: false, error: "Internal error" }, { status: 500 }); } } diff --git a/apps/web/src/app/api/cron/health-check/route.ts b/apps/web/src/app/api/cron/health-check/route.ts index 5437aef..a2d2a8f 100644 --- a/apps/web/src/app/api/cron/health-check/route.ts +++ b/apps/web/src/app/api/cron/health-check/route.ts @@ -73,10 +73,7 @@ export async function GET(request: Request) { if (deny) return deny; try { - const [postgres, redis] = await Promise.all([ - checkPostgres(), - checkRedis(), - ]); + const [postgres, redis] = await Promise.all([checkPostgres(), checkRedis()]); const allHealthy = postgres.status === "ok" && redis.status === "ok"; @@ -92,7 +89,6 @@ export async function GET(request: Request) { }); if (adminUsers.length > 0) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any await createNotificationsForUsers({ db: prisma as any, userIds: adminUsers.map((u) => u.id), @@ -121,9 +117,6 @@ export async function GET(request: Request) { ); } catch (error) { logger.error({ error, route: "/api/cron/health-check" }, "Health check cron failed"); - return NextResponse.json( - { ok: false, error: "Internal error" }, - { status: 500 }, - ); + return NextResponse.json({ ok: false, error: "Internal error" }, { status: 500 }); } } diff --git a/apps/web/src/app/api/cron/security-audit/route.ts b/apps/web/src/app/api/cron/security-audit/route.ts index 6a3c0ca..7939af7 100644 --- a/apps/web/src/app/api/cron/security-audit/route.ts +++ b/apps/web/src/app/api/cron/security-audit/route.ts @@ -17,7 +17,7 @@ const MINIMUM_SAFE_VERSIONS: Record `${f.package}@${f.currentVersion} (need >=${f.minimumVersion})`) .join(", "); - // eslint-disable-next-line @typescript-eslint/no-explicit-any await createNotificationsForUsers({ db: prisma as any, userIds: adminUsers.map((u) => u.id), @@ -147,9 +149,6 @@ export async function GET(request: Request) { }); } catch (error) { logger.error({ error, route: "/api/cron/security-audit" }, "Security audit cron failed"); - return NextResponse.json( - { ok: false, error: "Internal error" }, - { status: 500 }, - ); + return NextResponse.json({ ok: false, error: "Internal error" }, { status: 500 }); } } diff --git a/apps/web/src/app/api/sse/timeline/route.ts b/apps/web/src/app/api/sse/timeline/route.ts index acaf4d4..0122a11 100644 --- a/apps/web/src/app/api/sse/timeline/route.ts +++ b/apps/web/src/app/api/sse/timeline/route.ts @@ -2,7 +2,8 @@ import { loadRoleDefaults } from "@capakraken/api"; import { deriveUserSseSubscription, eventBus } from "@capakraken/api/sse"; import { startReminderScheduler } from "@capakraken/api/lib/reminder-scheduler"; import { prisma } from "@capakraken/db"; -import { SSE_EVENT_TYPES, SystemRole, type PermissionOverrides } from "@capakraken/shared"; +import type { SystemRole } from "@capakraken/shared"; +import { SSE_EVENT_TYPES, type PermissionOverrides } from "@capakraken/shared"; import { auth } from "~/server/auth.js"; export const dynamic = "force-dynamic"; @@ -59,26 +60,27 @@ export async function GET() { start(controller) { // Send initial connection confirmation controller.enqueue( - encoder.encode(`data: ${JSON.stringify({ type: SSE_EVENT_TYPES.PING, timestamp: new Date().toISOString() })}\n\n`), + encoder.encode( + `data: ${JSON.stringify({ type: SSE_EVENT_TYPES.PING, timestamp: new Date().toISOString() })}\n\n`, + ), ); // Subscribe to event bus - const unsubscribe = eventBus.subscribe( - (event) => { - try { - controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); - } catch { - // Client disconnected - } - }, - subscription, - ); + const unsubscribe = eventBus.subscribe((event) => { + try { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + } catch { + // Client disconnected + } + }, subscription); // Heartbeat every 30 seconds const heartbeat = setInterval(() => { try { controller.enqueue( - encoder.encode(`data: ${JSON.stringify({ type: SSE_EVENT_TYPES.PING, timestamp: new Date().toISOString() })}\n\n`), + encoder.encode( + `data: ${JSON.stringify({ type: SSE_EVENT_TYPES.PING, timestamp: new Date().toISOString() })}\n\n`, + ), ); } catch { clearInterval(heartbeat); diff --git a/apps/web/src/components/admin/CountriesClient.tsx b/apps/web/src/components/admin/CountriesClient.tsx index d6f5c48..641592e 100644 --- a/apps/web/src/components/admin/CountriesClient.tsx +++ b/apps/web/src/components/admin/CountriesClient.tsx @@ -45,7 +45,12 @@ function parseSpainRules(rules: unknown): Partial { if (!rules || typeof rules !== "object") return { hasSpainRules: false }; const r = rules as Record; if (r.type !== "spain") return { hasSpainRules: false }; - const sp = r as { fridayHours?: number; summerPeriod?: { from?: string; to?: string }; summerHours?: number; regularHours?: number }; + const sp = r as { + fridayHours?: number; + summerPeriod?: { from?: string; to?: string }; + summerHours?: number; + regularHours?: number; + }; return { hasSpainRules: true, fridayHours: sp.fridayHours ?? 6.5, @@ -66,17 +71,26 @@ export function CountriesClient() { const utils = trpc.useUtils(); const { data: countries, isLoading } = trpc.country.list.useQuery(); - // @ts-ignore TS2589: tRPC infers union type too deeply for nullable JSONB scheduleRules schema + // @ts-expect-error TS2589: tRPC infers union type too deeply for nullable JSONB scheduleRules schema const createMut = trpc.country.create.useMutation({ - onSuccess: () => { void utils.country.list.invalidate(); setEditing(null); }, + onSuccess: () => { + void utils.country.list.invalidate(); + setEditing(null); + }, onError: (e) => setError(e.message), }); const updateMut = trpc.country.update.useMutation({ - onSuccess: () => { void utils.country.list.invalidate(); setEditing(null); }, + onSuccess: () => { + void utils.country.list.invalidate(); + setEditing(null); + }, onError: (e) => setError(e.message), }); const createCityMut = trpc.country.createCity.useMutation({ - onSuccess: () => { void utils.country.list.invalidate(); setCityName(""); }, + onSuccess: () => { + void utils.country.list.invalidate(); + setCityName(""); + }, onError: (e) => setError(e.message), }); const deleteCityMut = trpc.country.deleteCity.useMutation({ @@ -170,7 +184,13 @@ export function CountriesClient() { {error && (
{error} - +
)} @@ -179,29 +199,75 @@ export function CountriesClient() { - - - - - - + + + + + + {isLoading && ( - + + + )} {!isLoading && rows.length === 0 && ( - + + + )} {rows.map((c) => ( - - + + - + ))} @@ -225,67 +297,87 @@ export function CountriesClient() { {/* Expanded Metro Cities */} - {expandedId && (() => { - const country = rows.find((c) => c.id === expandedId); - if (!country) return null; - return ( -
-

- Metro Cities for {country.name} -

-
- {country.metroCities.map((city) => ( - - {city.name} - - - ))} - {country.metroCities.length === 0 && ( - No metro cities yet - )} + {city.name} + + + ))} + {country.metroCities.length === 0 && ( + No metro cities yet + )} +
+
+ setCityName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleAddCity(country.id); + }} + placeholder="New city name..." + className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400 flex-1" + /> + +
-
- setCityName(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") handleAddCity(country.id); }} - placeholder="New city name..." - className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400 flex-1" - /> - -
- - ); - })()} + ); + })()} {/* Create/Edit Modal */} - setEditing(null)} maxWidth="max-w-lg" className="flex flex-col max-h-[90vh]"> - {editing && (<> + setEditing(null)} + maxWidth="max-w-lg" + className="flex flex-col max-h-[90vh]" + > + {editing && ( + <>

{editing.id ? "Edit Country" : "Add Country"}

- +
- +
- + setEditing({ ...editing, dailyWorkingHours: parseFloat(e.target.value) || 8 })} + onChange={(e) => + setEditing({ ...editing, dailyWorkingHours: parseFloat(e.target.value) || 8 }) + } min={1} max={24} step={0.5} @@ -310,7 +407,9 @@ export function CountriesClient() {
- + setEditing({ ...editing, hasSpainRules: e.target.checked })} className="rounded border-gray-300 text-brand-600 focus:ring-brand-500" /> - Variable schedule (Spain-type) + Variable schedule (Spain-type){" "} + {editing.hasSpainRules && (
- + setEditing({ ...editing, fridayHours: parseFloat(e.target.value) || 6.5 })} + onChange={(e) => + setEditing({ + ...editing, + fridayHours: parseFloat(e.target.value) || 6.5, + }) + } step={0.5} className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" />
- + setEditing({ ...editing, regularHours: parseFloat(e.target.value) || 9 })} + onChange={(e) => + setEditing({ + ...editing, + regularHours: parseFloat(e.target.value) || 9, + }) + } step={0.5} className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" /> @@ -358,7 +474,10 @@ export function CountriesClient() {
- +
- +
- + setEditing({ ...editing, summerHours: parseFloat(e.target.value) || 6.5 })} + onChange={(e) => + setEditing({ + ...editing, + summerHours: parseFloat(e.target.value) || 6.5, + }) + } step={0.5} className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" /> @@ -394,7 +524,13 @@ export function CountriesClient() {
- +
- )} + + )} {confirmDeleteCity && ( diff --git a/apps/web/src/components/admin/SystemRolesClient.tsx b/apps/web/src/components/admin/SystemRolesClient.tsx index 5bd4385..ea040c9 100644 --- a/apps/web/src/components/admin/SystemRolesClient.tsx +++ b/apps/web/src/components/admin/SystemRolesClient.tsx @@ -27,7 +27,8 @@ const PERMISSION_LABELS: Record = { const PERMISSION_DESCRIPTIONS: Record = { viewPlanning: "Read project and allocation planning views without mutation access", viewCosts: "Access to cost data, budget views, and financial reports", - useAssistantAdvancedTools: "Unlocks advanced AI assistant workflows for complex cross-entity analyses", + useAssistantAdvancedTools: + "Unlocks advanced AI assistant workflows for complex cross-entity analyses", exportData: "Export data to Excel, CSV, or PDF formats", importData: "Import data from external sources (Dispo, Excel)", approveVacations: "Approve or reject vacation requests", @@ -101,8 +102,7 @@ export function SystemRolesClient() { staleTime: 10_000, }); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore TS2589: tRPC infers union type too deeply for the role config update payload + // @ts-expect-error TS2589: tRPC infers union type too deeply for the role config update payload const updateMutation = trpc.systemRoleConfig.update.useMutation({ onSuccess: async () => { await utils.systemRoleConfig.list.invalidate(); @@ -164,9 +164,12 @@ export function SystemRolesClient() { return (
-

System Role Management

+

+ System Role Management +

- Configure default permissions for each system role. Changes apply to all users with that role. + Configure default permissions for each system role. Changes apply to all users with that + role.

@@ -179,7 +182,11 @@ export function SystemRolesClient() { {isLoading && (
{[...Array(5)].map((_, i) => ( -
+
))}
)} @@ -197,7 +204,9 @@ export function SystemRolesClient() {
- + {config.label} @@ -211,7 +220,9 @@ export function SystemRolesClient() { )}
{perms.length === 0 ? ( - No default permissions + + No default permissions + ) : ( perms.map((p) => ( 0 && (

- Permission Matrix + Permission Matrix{" "} +

Code Name Daily Hours Schedule Cities Actions + + Code{" "} + + + + + Name{" "} + + + + + Daily Hours{" "} + + + + + Schedule{" "} + + + + + Cities{" "} + + + + Actions +
Loading...
+ Loading... +
No countries yet.
+ No countries yet. +
{c.code}
+ {c.code} + {c.name}{c.dailyWorkingHours}h + {c.dailyWorkingHours}h + - {c.scheduleRules && typeof c.scheduleRules === "object" && (c.scheduleRules as Record).type === "spain" ? ( - Spain + {c.scheduleRules && + typeof c.scheduleRules === "object" && + (c.scheduleRules as Record).type === "spain" ? ( + + Spain + ) : ( Standard )} @@ -216,7 +282,13 @@ export function CountriesClient() { - +
- + {configs.map((c) => ( - ))} @@ -269,7 +286,11 @@ export function SystemRolesClient() { {has ? ( - + ) : ( @@ -303,7 +324,10 @@ export function SystemRolesClient() { @@ -468,17 +490,30 @@ export function UsersClient() { className="inline-flex items-center gap-2 rounded-lg border border-brand-300 dark:border-brand-600 px-4 py-2 text-sm font-medium text-brand-700 dark:text-brand-300 hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors" > - + Invite User @@ -501,7 +536,9 @@ export function UsersClient() { > {Object.values(SystemRole).map((role) => ( - + ))} @@ -530,13 +567,54 @@ export function UsersClient() {
Permission + Permission + + {c.label}
- - - - - - - + + + + + + + @@ -566,7 +644,8 @@ export function UsersClient() { ); case "project": return ( ); case "role": - return ; + return ( + + ); case "dates": - return ; + return ( + + ); case "hoursPerDay": - return ; + return ( + + ); case "cost": - return ; + return ( + + ); case "status": return ( ); default: - return ; + return ( + + ); } })} - + )} {!isLoading && allocationQueryFailure && ( - {/* Project sub-groups within person */} - {!isCollapsed && group.projectSubGroups.map((subGroup) => { - const subKey = `${group.resourceId}::${subGroup.projectId}`; - const isSubExpanded = expandedSubGroups.has(subKey); + {!isCollapsed && + group.projectSubGroups.map((subGroup) => { + const subKey = `${group.resourceId}::${subGroup.projectId}`; + const isSubExpanded = expandedSubGroups.has(subKey); - // Single allocation for this project — render directly, no sub-group header - if (subGroup.allocations.length === 1) { - return {renderAllocRow(subGroup.allocations[0]!, true, 0)}; - } + // Single allocation for this project — render directly, no sub-group header + if (subGroup.allocations.length === 1) { + return ( + + {renderAllocRow(subGroup.allocations[0]!, true, 0)} + + ); + } - // Multiple allocations — show collapsible project sub-group - return ( - - toggleSubGroup(group.resourceId, subGroup.projectId)} - tabIndex={0} - role="button" - aria-expanded={isSubExpanded} - onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleSubGroup(group.resourceId, subGroup.projectId); } }} - > - toggleSubGroup(group.resourceId, subGroup.projectId)} + tabIndex={0} + role="button" + aria-expanded={isSubExpanded} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleSubGroup(group.resourceId, subGroup.projectId); + } + }} + > + - - - {isSubExpanded && subGroup.allocations.map((alloc, idx) => renderAllocRow(alloc, true, idx))} - - ); - })} + className="rounded border-gray-300 dark:border-gray-600" + /> + + + + {isSubExpanded && + subGroup.allocations.map((alloc, idx) => + renderAllocRow(alloc, true, idx), + )} + + ); + })} ); })} @@ -941,7 +1168,9 @@ export function AllocationsClient() {
-

Open Demands

+

+ Open Demands +

Placeholder demand rows not yet assigned to a resource.

@@ -959,18 +1188,27 @@ export function AllocationsClient() {
{demand.project ? ( - <>{demand.project.shortCode} {demand.project.name} - ) : "Unknown project"} + <> + {demand.project.shortCode}{" "} + {demand.project.name} + + ) : ( + "Unknown project" + )}
- {(demand.role ?? "Placeholder role")} · {formatPeriod(demand)} · {demand.hoursPerDay}h/day + {demand.role ?? "Placeholder role"} · {formatPeriod(demand)} ·{" "} + {demand.hoursPerDay}h/day
-
Unfilled
+
+ Unfilled +
- {demand.unfilledHeadcount ?? demand.headcount} / {demand.requestedHeadcount ?? demand.headcount} + {demand.unfilledHeadcount ?? demand.headcount} /{" "} + {demand.requestedHeadcount ?? demand.headcount}
@@ -999,9 +1237,17 @@ export function AllocationsClient() { {/* Batch Status Picker */} {batchStatusPicker && ( -
setBatchStatusPicker(false)}> -
e.stopPropagation()}> -

Set status for {selection.count} allocations

+
setBatchStatusPicker(false)} + > +
e.stopPropagation()} + > +

+ Set status for {selection.count} allocations +

{ALL_ALLOC_STATUSES.map((s) => ( @@ -1060,7 +1308,10 @@ export function AllocationsClient() { message={`Set ${confirmBatchStatus.ids.length} allocation${confirmBatchStatus.ids.length !== 1 ? "s" : ""} to "${confirmBatchStatus.status}"?`} confirmLabel="Update" onConfirm={() => { - batchStatusMutation.mutate({ ids: confirmBatchStatus.ids, status: confirmBatchStatus.status as never }); + batchStatusMutation.mutate({ + ids: confirmBatchStatus.ids, + status: confirmBatchStatus.status as never, + }); setConfirmBatchStatus(null); }} onCancel={() => setConfirmBatchStatus(null)} @@ -1097,7 +1348,11 @@ export function AllocationsClient() { count={selection.count} isPending={batchDateShiftMutation.isPending} onConfirm={(daysDelta) => - batchDateShiftMutation.mutate({ allocationIds: selectedMutationIds, daysDelta, mode: "move" }) + batchDateShiftMutation.mutate({ + allocationIds: selectedMutationIds, + daysDelta, + mode: "move", + }) } onClose={() => setShowDateShiftModal(false)} /> @@ -1105,7 +1360,11 @@ export function AllocationsClient() { {/* Modal */} {modalOpen && ( - + )}
); diff --git a/apps/web/src/components/blueprints/BlueprintsClient.tsx b/apps/web/src/components/blueprints/BlueprintsClient.tsx index 5488b63..b7c0886 100644 --- a/apps/web/src/components/blueprints/BlueprintsClient.tsx +++ b/apps/web/src/components/blueprints/BlueprintsClient.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import type { FormEvent } from "react"; -import { BlueprintTarget } from "@capakraken/shared"; +import type { BlueprintTarget } from "@capakraken/shared"; import type { BlueprintFieldDefinition } from "@capakraken/shared"; import { trpc } from "~/lib/trpc/client.js"; import { BlueprintFieldCatalog } from "./BlueprintFieldCatalog.js"; @@ -68,27 +68,59 @@ function NewBlueprintModal({ onClose, onCreated }: NewBlueprintModalProps) {

New Blueprint

- +
- - setName(e.target.value)} placeholder="e.g. Resource Extended Fields" className="app-input" autoFocus /> + + setName(e.target.value)} + placeholder="e.g. Resource Extended Fields" + className="app-input" + autoFocus + />
-
StatusActions + Status + + Actions +
{SYSTEM_ROLE_LABELS[user.systemRole as SystemRole] ?? user.systemRole} @@ -591,7 +670,10 @@ export function UsersClient() { )} {user.totpEnabled && ( - + MFA )} @@ -611,8 +693,18 @@ export function UsersClient() { className="inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 font-medium" title="Set password" > - - + + Password @@ -628,8 +720,18 @@ export function UsersClient() { className="inline-flex items-center gap-1 text-xs text-amber-600 hover:text-amber-800 dark:text-amber-400 dark:hover:text-amber-300 font-medium" title="Disable TOTP MFA for this user" > - - + + Disable MFA @@ -645,7 +747,11 @@ export function UsersClient() {

- {8 - newPassword.length} more character{8 - newPassword.length !== 1 ? "s" : ""} needed + {8 - newPassword.length} more character{8 - newPassword.length !== 1 ? "s" : ""}{" "} + needed

)} @@ -729,9 +838,7 @@ export function UsersClient() { autoComplete="new-password" /> {confirmPassword.length > 0 && newPassword !== confirmPassword && ( -

- Passwords do not match -

+

Passwords do not match

)} @@ -747,7 +854,11 @@ export function UsersClient() {
- {isGrouped ? : (alloc.resource?.displayName ?? "—")} + + {isGrouped ? ( + + ) : ( + (alloc.resource?.displayName ?? "—") + )} {alloc.project ? ( - <>{alloc.project.shortCode} {alloc.project.name} - ) : "—"} + <> + {alloc.project.shortCode}{" "} + {alloc.project.name} + + ) : ( + "—" + )} {alloc.role} + {alloc.role} + {formatPeriod(alloc)} + {formatPeriod(alloc)} + {alloc.hoursPerDay}h + {alloc.hoursPerDay}h + {(alloc.dailyCostCents / 100).toFixed(0)} € + {(alloc.dailyCostCents / 100).toFixed(0)} € + - + {alloc.status} + — +
- +
@@ -701,7 +847,12 @@ export function AllocationsClient() { className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 dark:border-gray-600 px-2.5 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 transition hover:border-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800" > - + Export @@ -709,11 +860,19 @@ export function AllocationsClient() { {viewMode === "grouped" && groups.length > 1 && (
- | -
@@ -744,16 +903,34 @@ export function AllocationsClient() { {visibleColumns.map((col) => { const tooltips: Record = { - resource: { tip: "The person assigned to this time block. Grouped view clusters entries by resource." }, - project: { tip: "The project this allocation belongs to, identified by short code and name." }, - role: { tip: "The role this allocation was created for. May differ from the resource's primary role." }, + resource: { + tip: "The person assigned to this time block. Grouped view clusters entries by resource.", + }, + project: { + tip: "The project this allocation belongs to, identified by short code and name.", + }, + role: { + tip: "The role this allocation was created for. May differ from the resource's primary role.", + }, dates: { tip: "Start and end date of this allocation period (inclusive)." }, - hoursPerDay: { tip: "Planned working hours per calendar day for this allocation." }, - cost: { tip: "Daily cost = resource LCR x hours per day. Total cost = daily cost x working days.", width: "w-72" }, - status: { tip: "PROPOSED = requested · CONFIRMED = approved · ACTIVE = ongoing · COMPLETED = finished · CANCELLED = removed.", width: "w-72" }, + hoursPerDay: { + tip: "Planned working hours per calendar day for this allocation.", + }, + cost: { + tip: "Daily cost = resource LCR x hours per day. Total cost = daily cost x working days.", + width: "w-72", + }, + status: { + tip: "PROPOSED = requested · CONFIRMED = approved · ACTIVE = ongoing · COMPLETED = finished · CANCELLED = removed.", + width: "w-72", + }, }; const t = tooltips[col.key]; - const fieldMap: Record = { dates: "startDate", hoursPerDay: "hoursPerDay", cost: "dailyCostCents" }; + const fieldMap: Record = { + dates: "startDate", + hoursPerDay: "hoursPerDay", + cost: "dailyCostCents", + }; return ( {isLoading && (
Loading allocations… + Loading allocations… +
-
-

{allocationQueryFailure.title}

+
+
+

+ {allocationQueryFailure.title} +

{allocationQueryFailure.detail}

{allocationQueryFailure.actionLabel && allocationQueryFailure.actionHref && ( )} - {!isLoading && !allocationQueryFailure && viewMode === "flat" && + {!isLoading && + !allocationQueryFailure && + viewMode === "flat" && sorted.map((alloc, index) => renderAllocRow(alloc, false, index))} - {!isLoading && !allocationQueryFailure && viewMode === "grouped" && + {!isLoading && + !allocationQueryFailure && + viewMode === "grouped" && groups.map((group) => { - const isCollapsed = collapsedGroups === "all" || collapsedGroups.has(group.resourceId); + const isCollapsed = + collapsedGroups === "all" || collapsedGroups.has(group.resourceId); const groupAllocIds = group.allocations.map((a) => a.id); const allGroupSelected = selection.isAllSelected(groupAllocIds); - const groupIndeterminate = !allGroupSelected && groupAllocIds.some((id) => selection.selectedIds.has(id)); + const groupIndeterminate = + !allGroupSelected && groupAllocIds.some((id) => selection.selectedIds.has(id)); return ( {/* Group header */} @@ -833,7 +1029,12 @@ export function AllocationsClient() { data-testid="allocation-group-header" className="bg-gray-50 dark:bg-gray-800/50 cursor-pointer select-none hover:bg-gray-100 dark:hover:bg-gray-800/80 transition-colors" onClick={() => toggleGroup(group.resourceId)} - onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleGroup(group.resourceId); } }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleGroup(group.resourceId); + } + }} tabIndex={0} role="button" aria-expanded={!isCollapsed} @@ -842,7 +1043,9 @@ export function AllocationsClient() { { if (el) el.indeterminate = groupIndeterminate; }} + ref={(el) => { + if (el) el.indeterminate = groupIndeterminate; + }} onChange={() => selection.toggleAll(groupAllocIds)} className="rounded border-gray-300 dark:border-gray-600" /> @@ -872,64 +1075,88 @@ export function AllocationsClient() {
e.stopPropagation()}> - a.id))} - ref={(el) => { - if (el) { - const ids = subGroup.allocations.map((a) => a.id); - el.indeterminate = !selection.isAllSelected(ids) && ids.some((id) => selection.selectedIds.has(id)); + // Multiple allocations — show collapsible project sub-group + return ( + +
e.stopPropagation()}> + a.id), + )} + ref={(el) => { + if (el) { + const ids = subGroup.allocations.map((a) => a.id); + el.indeterminate = + !selection.isAllSelected(ids) && + ids.some((id) => selection.selectedIds.has(id)); + } + }} + onChange={() => + selection.toggleAll(subGroup.allocations.map((a) => a.id)) } - }} - onChange={() => selection.toggleAll(subGroup.allocations.map((a) => a.id))} - className="rounded border-gray-300 dark:border-gray-600" - /> - -
- - {isSubExpanded ? "▾" : "▸"} - - {subGroup.projectCode} - {subGroup.projectName} - - {formatDate(subGroup.earliestStart)} → {formatDate(subGroup.latestEnd)} - - - {subGroup.typicalHoursPerDay}h/day - - - {subGroup.allocations.length} - -
-
+
+ + {isSubExpanded ? "▾" : "▸"} + + + {subGroup.projectCode} + + + {subGroup.projectName} + + + {formatDate(subGroup.earliestStart)} →{" "} + {formatDate(subGroup.latestEnd)} + + + {subGroup.typicalHoursPerDay}h/day + + + {subGroup.allocations.length} + +
+