chore: add pre-commit hooks, tighten ESLint, activate Sentry DSN, publish CI coverage (Phase 1)

- Install husky v9 + lint-staged: pre-commit runs eslint --fix and prettier on staged files
- Tighten ESLint base config: no-console→error, ban-ts-comment (ts-ignore banned, ts-expect-error with description allowed), reportUnusedDisableDirectives→error
- Migrate web app from deprecated `next lint` to `eslint src/` with flat config and react-hooks plugin
- Convert all 5 @ts-ignore to @ts-expect-error with descriptions, remove stale disable comments
- Add NEXT_PUBLIC_SENTRY_DSN to docker-compose.prod.yml and .env.example
- Add coverage artifact upload step to CI test job
- Pre-existing violations (102 warnings) downgraded to warn in web config for Phase 2 cleanup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 14:49:29 +02:00
parent 605fd7cea1
commit 82acc56b8d
38 changed files with 2901 additions and 1251 deletions
+7
View File
@@ -83,6 +83,13 @@ PGADMIN_PASSWORD=
# If not set, cron endpoints are disabled. # If not set, cron endpoints are disabled.
# CRON_SECRET= # 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) ──────────────────────────────────── # ─── Testing (never enable in production) ────────────────────────────────────
# Disables rate limiting and session tracking during end-to-end tests. # Disables rate limiting and session tracking during end-to-end tests.
+14
View File
@@ -198,6 +198,20 @@ jobs:
pnpm --filter @capakraken/shared exec vitest run --coverage pnpm --filter @capakraken/shared exec vitest run --coverage
pnpm --filter @capakraken/db test:unit 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 # Build — depends on typecheck passing
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
+1
View File
@@ -0,0 +1 @@
pnpm exec lint-staged
+4
View File
@@ -0,0 +1,4 @@
{
"*.{ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{json,md}": ["prettier --write"]
}
+30
View File
@@ -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,
}],
},
},
];
+3 -1
View File
@@ -6,7 +6,7 @@
"dev": "next dev -p 3100", "dev": "next dev -p 3100",
"build": "next build", "build": "next build",
"start": "next start -p 3100", "start": "next start -p 3100",
"lint": "next lint", "lint": "eslint src/",
"typecheck": "tsc --project tsconfig.typecheck.json --noEmit", "typecheck": "tsc --project tsconfig.typecheck.json --noEmit",
"test:unit": "vitest run", "test:unit": "vitest run",
"test:e2e": "playwright test", "test:e2e": "playwright test",
@@ -49,6 +49,7 @@
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@capakraken/eslint-config": "workspace:*",
"@capakraken/tsconfig": "workspace:*", "@capakraken/tsconfig": "workspace:*",
"@playwright/test": "^1.49.1", "@playwright/test": "^1.49.1",
"@types/dompurify": "^3.2.0", "@types/dompurify": "^3.2.0",
@@ -59,6 +60,7 @@
"@types/three": "^0.183.1", "@types/three": "^0.183.1",
"@vitest/coverage-v8": "^2.1.9", "@vitest/coverage-v8": "^2.1.9",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^10.2.0",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.6.3", "typescript": "^5.6.3",
@@ -5,8 +5,8 @@ import { useUrlFilters } from "~/hooks/useUrlFilters.js";
import { useDebounce } from "~/hooks/useDebounce.js"; import { useDebounce } from "~/hooks/useDebounce.js";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { formatDate, formatMoney } from "~/lib/format.js"; import { formatDate, formatMoney } from "~/lib/format.js";
import type { Project, ColumnDef } from "@capakraken/shared"; import type { Project, ColumnDef, ProjectStatus } from "@capakraken/shared";
import { ProjectStatus, PROJECT_COLUMNS, BlueprintTarget } from "@capakraken/shared"; import { PROJECT_COLUMNS, BlueprintTarget } from "@capakraken/shared";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { clsx } from "clsx"; import { clsx } from "clsx";
@@ -32,7 +32,10 @@ import { DraggableTableRow } from "~/components/ui/DraggableTableRow.js";
import { ShoringBadge } from "~/components/projects/ShoringIndicator.js"; import { ShoringBadge } from "~/components/projects/ShoringIndicator.js";
import { SuccessToast } from "~/components/ui/SuccessToast.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 ──────────────────────────────────────────────────────────────── // ─── Constants ────────────────────────────────────────────────────────────────
@@ -53,7 +56,13 @@ const ALL_ORDER_TYPES = [
// ─── Sub-components ─────────────────────────────────────────────────────────── // ─── Sub-components ───────────────────────────────────────────────────────────
function BudgetBar({ utilizationPercent, budgetCents }: { utilizationPercent: number; budgetCents: number }) { function BudgetBar({
utilizationPercent,
budgetCents,
}: {
utilizationPercent: number;
budgetCents: number;
}) {
if (budgetCents === 0) { if (budgetCents === 0) {
return <div className="text-xs text-gray-400">No budget</div>; return <div className="text-xs text-gray-400">No budget</div>;
} }
@@ -66,14 +75,27 @@ function BudgetBar({ utilizationPercent, budgetCents }: { utilizationPercent: nu
return ( return (
<div className="min-w-[104px] space-y-1"> <div className="min-w-[104px] space-y-1">
<div className="h-2 w-full overflow-hidden rounded-full bg-gray-200/80 dark:bg-gray-700/80"> <div className="h-2 w-full overflow-hidden rounded-full bg-gray-200/80 dark:bg-gray-700/80">
<div className={clsx("h-full rounded-full transition-all duration-700 ease-out", barColor)} style={{ width: `${cappedPercent}%` }} /> <div
className={clsx("h-full rounded-full transition-all duration-700 ease-out", barColor)}
style={{ width: `${cappedPercent}%` }}
/>
</div> </div>
<div className="text-xs text-gray-500">{utilizationPercent.toFixed(0)}% used</div> <div className="text-xs text-gray-500">{utilizationPercent.toFixed(0)}% used</div>
</div> </div>
); );
} }
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 utils = trpc.useUtils();
const triggerRef = useRef<HTMLButtonElement>(null); const triggerRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
@@ -110,7 +132,10 @@ function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: Project
<button <button
ref={triggerRef} ref={triggerRef}
type="button" type="button"
onClick={(e) => { e.stopPropagation(); isOpen ? onClose() : onOpen(); }} onClick={(e) => {
e.stopPropagation();
isOpen ? onClose() : onOpen();
}}
className={clsx( className={clsx(
"inline-flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium transition", "inline-flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium transition",
STATUS_COLORS[project.status] ?? "bg-gray-100 text-gray-700", STATUS_COLORS[project.status] ?? "bg-gray-100 text-gray-700",
@@ -118,40 +143,54 @@ function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: Project
title="Click to change status" title="Click to change status"
> >
{project.status} {project.status}
<svg className="w-2.5 h-2.5 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
className="w-2.5 h-2.5 opacity-60"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg> </svg>
</button> </button>
{isOpen && createPortal( {isOpen &&
<motion.div createPortal(
ref={panelRef} <motion.div
initial={{ opacity: 0, scaleY: 0.9 }} ref={panelRef}
animate={{ opacity: 1, scaleY: 1 }} initial={{ opacity: 0, scaleY: 0.9 }}
transition={{ duration: 0.12, ease: "easeOut" }} animate={{ opacity: 1, scaleY: 1 }}
className="fixed z-[9999] min-w-[160px] rounded-2xl border border-gray-200 bg-white p-2 shadow-xl dark:border-gray-700 dark:bg-gray-900 origin-top" transition={{ duration: 0.12, ease: "easeOut" }}
style={{ top: pos.top, left: pos.left }} className="fixed z-[9999] min-w-[160px] rounded-2xl border border-gray-200 bg-white p-2 shadow-xl dark:border-gray-700 dark:bg-gray-900 origin-top"
> style={{ top: pos.top, left: pos.left }}
{ALL_STATUSES.map((s) => ( >
<button {ALL_STATUSES.map((s) => (
key={s.value} <button
type="button" key={s.value}
disabled={s.value === project.status || updateStatus.isPending} type="button"
onClick={(e) => { e.stopPropagation(); updateStatus.mutate({ id: project.id, status: s.value as never }); }} disabled={s.value === project.status || updateStatus.isPending}
className={clsx( onClick={(e) => {
"w-full rounded-xl px-3 py-2 text-left text-xs transition", e.stopPropagation();
s.value === project.status updateStatus.mutate({ id: project.id, status: s.value as never });
? "cursor-default font-semibold text-gray-400" }}
: "cursor-pointer text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800", className={clsx(
)} "w-full rounded-xl px-3 py-2 text-left text-xs transition",
> s.value === project.status
<span className={clsx("inline-block px-1.5 py-0.5 rounded-full", STATUS_COLORS[s.value] ?? "bg-gray-100 text-gray-700")}> ? "cursor-default font-semibold text-gray-400"
{s.label} : "cursor-pointer text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800",
</span> )}
</button> >
))} <span
</motion.div>, className={clsx(
document.body, "inline-block px-1.5 py-0.5 rounded-full",
)} STATUS_COLORS[s.value] ?? "bg-gray-100 text-gray-700",
)}
>
{s.label}
</span>
</button>
))}
</motion.div>,
document.body,
)}
</> </>
); );
} }
@@ -190,13 +229,12 @@ export function ProjectsClient() {
// Flush debounced input to URL // Flush debounced input to URL
useEffect(() => { useEffect(() => {
setFilters({ search: debouncedSearch }); setFilters({ search: debouncedSearch });
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearch]); }, [debouncedSearch]);
// Keep local input in sync when URL changes externally (e.g. back/forward) // Keep local input in sync when URL changes externally (e.g. back/forward)
useEffect(() => { useEffect(() => {
setSearchInput(filters.search); setSearchInput(filters.search);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters.search]); }, [filters.search]);
const setStatusFilter = useCallback((v: string) => setFilters({ status: v }), [setFilters]); const setStatusFilter = useCallback((v: string) => setFilters({ status: v }), [setFilters]);
@@ -207,7 +245,10 @@ export function ProjectsClient() {
const [successToast, setSuccessToast] = useState<string | null>(null); const [successToast, setSuccessToast] = useState<string | null>(null);
const [openStatusProjectId, setOpenStatusProjectId] = useState<string | null>(null); const [openStatusProjectId, setOpenStatusProjectId] = useState<string | null>(null);
const [batchStatusPicker, setBatchStatusPicker] = useState(false); 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<string[] | null>(null); const [confirmBatchDelete, setConfirmBatchDelete] = useState<string[] | null>(null);
const selection = useSelection(); const selection = useSelection();
@@ -229,7 +270,9 @@ export function ProjectsClient() {
}); });
// ─── Favorites ────────────────────────────────────────────────────────── // ─── 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 favSet = useMemo(() => new Set(favoriteIds ?? []), [favoriteIds]);
const toggleFavMutation = trpc.user.toggleFavoriteProject.useMutation({ const toggleFavMutation = trpc.user.toggleFavoriteProject.useMutation({
onMutate: async ({ projectId }) => { onMutate: async ({ projectId }) => {
@@ -300,7 +343,7 @@ export function ProjectsClient() {
isFetchingNextPage, isFetchingNextPage,
fetchNextPage, fetchNextPage,
hasNextPage, 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)( } = (trpc.project.listWithCosts.useInfiniteQuery as any)(
{ {
search: search || undefined, search: search || undefined,
@@ -308,9 +351,12 @@ export function ProjectsClient() {
limit: 50, limit: 50,
}, },
{ {
getNextPageParam: (lastPage: { nextCursor?: string | null }) => lastPage.nextCursor ?? undefined, getNextPageParam: (lastPage: { nextCursor?: string | null }) =>
lastPage.nextCursor ?? undefined,
initialCursor: 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, staleTime: 15_000,
}, },
) as { ) as {
@@ -332,7 +378,8 @@ export function ProjectsClient() {
// Client-side orderType filter // Client-side orderType filter
const filteredProjects = useMemo( const filteredProjects = useMemo(
() => (orderTypeFilter ? allProjects.filter((p) => p.orderType === orderTypeFilter) : allProjects), () =>
orderTypeFilter ? allProjects.filter((p) => p.orderType === orderTypeFilter) : allProjects,
[allProjects, orderTypeFilter], [allProjects, orderTypeFilter],
); );
@@ -345,29 +392,41 @@ export function ProjectsClient() {
viewPrefs.setSavedSort(field && dir ? { field, dir } : null); viewPrefs.setSavedSort(field && dir ? { field, dir } : null);
}, },
}); });
const { orderedRows: projects, reorder, isCustomOrder, resetOrder } = useRowOrder( const {
sorted, orderedRows: projects,
viewPrefs, reorder,
sortField, isCustomOrder,
reset, resetOrder,
); } = useRowOrder(sorted, viewPrefs, sortField, reset);
const rowDragRef = useRef<string | null>(null); const rowDragRef = useRef<string | null>(null);
const projectIds = projects.map((p) => p.id); const projectIds = projects.map((p) => p.id);
useEffect(() => { useEffect(() => {
selection.clear(); selection.clear();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [search, statusFilter, orderTypeFilter]); }, [search, statusFilter, orderTypeFilter]);
const handleFetchNext = useCallback(() => { const handleFetchNext = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) void fetchNextPage(); if (hasNextPage && !isFetchingNextPage) void fetchNextPage();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]); }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
function openNewModal() { setEditingProject(null); setModalOpen(true); } function openNewModal() {
function openEditModal(project: Project) { setEditingProject(project); setModalOpen(true); } setEditingProject(null);
function closeModal() { setModalOpen(false); setEditingProject(null); } setModalOpen(true);
function clearAll() { setSearchInput(""); setFilters({ search: "", status: "", orderType: "" }); } }
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 exportSelectedCsv = useCallback(() => {
const selected = projects.filter((p) => selection.selectedIds.has(p.id)); const selected = projects.filter((p) => selection.selectedIds.has(p.id));
@@ -389,9 +448,23 @@ export function ProjectsClient() {
}, [projects, selection.selectedIds]); }, [projects, selection.selectedIds]);
const chips = [ const chips = [
...(search ? [{ label: `Search: "${search}"`, onRemove: () => { setSearchInput(""); setFilters({ search: "" }); } }] : []), ...(search
...(statusFilter ? [{ label: `Status: ${statusFilter}`, onRemove: () => setStatusFilter("") }] : []), ? [
...(orderTypeFilter ? [{ label: `Type: ${orderTypeFilter}`, onRemove: () => setOrderTypeFilter("") }] : []), {
label: `Search: "${search}"`,
onRemove: () => {
setSearchInput("");
setFilters({ search: "" });
},
},
]
: []),
...(statusFilter
? [{ label: `Status: ${statusFilter}`, onRemove: () => setStatusFilter("") }]
: []),
...(orderTypeFilter
? [{ label: `Type: ${orderTypeFilter}`, onRemove: () => setOrderTypeFilter("") }]
: []),
]; ];
// ─── Cell renderer ──────────────────────────────────────────────────────── // ─── Cell renderer ────────────────────────────────────────────────────────
@@ -401,18 +474,42 @@ export function ProjectsClient() {
if (col.isCustom) { if (col.isCustom) {
const fieldKey = col.key.replace(/^custom_/, ""); const fieldKey = col.key.replace(/^custom_/, "");
const val = dynFields[fieldKey]; const val = dynFields[fieldKey];
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">{val != null ? String(val) : "—"}</td>; return (
<td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
{val != null ? String(val) : "—"}
</td>
);
} }
switch (col.key) { switch (col.key) {
case "shortCode": case "shortCode":
return <td key={col.key} className="px-4 py-3 text-sm font-mono font-medium text-gray-900 dark:text-gray-100">{project.shortCode}</td>; return (
<td
key={col.key}
className="px-4 py-3 text-sm font-mono font-medium text-gray-900 dark:text-gray-100"
>
{project.shortCode}
</td>
);
case "name": case "name":
return ( return (
<td key={col.key} className="max-w-xs truncate px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100"> <td
<Link href={`/projects/${project.id}`} className="inline-flex items-center gap-2 transition hover:text-brand-600 hover:underline"> key={col.key}
className="max-w-xs truncate px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100"
>
<Link
href={`/projects/${project.id}`}
className="inline-flex items-center gap-2 transition hover:text-brand-600 hover:underline"
>
{project.coverImageUrl ? ( {project.coverImageUrl ? (
<Image src={project.coverImageUrl} alt={project.name} width={24} height={24} className="h-6 w-6 flex-shrink-0 rounded object-cover" unoptimized={project.coverImageUrl.startsWith("data:")} /> <Image
src={project.coverImageUrl}
alt={project.name}
width={24}
height={24}
className="h-6 w-6 flex-shrink-0 rounded object-cover"
unoptimized={project.coverImageUrl.startsWith("data:")}
/>
) : ( ) : (
<span <span
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded text-[9px] font-bold opacity-60" className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded text-[9px] font-bold opacity-60"
@@ -421,7 +518,13 @@ export function ProjectsClient() {
color: project.color ?? "#6366f1", color: project.color ?? "#6366f1",
}} }}
> >
{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()}
</span> </span>
)} )}
<span className="truncate">{project.name}</span> <span className="truncate">{project.name}</span>
@@ -442,14 +545,19 @@ export function ProjectsClient() {
case "orderType": case "orderType":
return ( return (
<td key={col.key} className="px-4 py-3"> <td key={col.key} className="px-4 py-3">
<span className={`inline-block px-2 py-0.5 text-xs rounded-full ${ORDER_TYPE_COLORS[project.orderType] ?? ""}`}> <span
className={`inline-block px-2 py-0.5 text-xs rounded-full ${ORDER_TYPE_COLORS[project.orderType] ?? ""}`}
>
{project.orderType} {project.orderType}
</span> </span>
</td> </td>
); );
case "dates": case "dates":
return ( return (
<td key={col.key} className="whitespace-nowrap px-4 py-3 text-xs text-gray-500 dark:text-gray-400"> <td
key={col.key}
className="whitespace-nowrap px-4 py-3 text-xs text-gray-500 dark:text-gray-400"
>
{formatDate(project.startDate)} {formatDate(project.endDate)} {formatDate(project.startDate)} {formatDate(project.endDate)}
</td> </td>
); );
@@ -459,7 +567,10 @@ export function ProjectsClient() {
<div className="mb-0.5 text-sm text-gray-900 dark:text-gray-100"> <div className="mb-0.5 text-sm text-gray-900 dark:text-gray-100">
{formatMoney(project.budgetCents)} {formatMoney(project.budgetCents)}
</div> </div>
<BudgetBar utilizationPercent={project.utilizationPercent ?? 0} budgetCents={project.budgetCents} /> <BudgetBar
utilizationPercent={project.utilizationPercent ?? 0}
budgetCents={project.budgetCents}
/>
</td> </td>
); );
case "allocations": case "allocations":
@@ -467,8 +578,18 @@ export function ProjectsClient() {
<td key={col.key} className="px-4 py-3 text-right text-sm"> <td key={col.key} className="px-4 py-3 text-right text-sm">
{project.totalPersonDays > 0 ? ( {project.totalPersonDays > 0 ? (
<span className="inline-flex items-center gap-1 rounded-full bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-700 dark:bg-brand-900/30 dark:text-brand-300"> <span className="inline-flex items-center gap-1 rounded-full bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-700 dark:bg-brand-900/30 dark:text-brand-300">
<svg className="h-3 w-3 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /> className="h-3 w-3 opacity-60"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg> </svg>
{project.totalPersonDays}d {project.totalPersonDays}d
</span> </span>
@@ -484,14 +605,30 @@ export function ProjectsClient() {
</td> </td>
); );
case "responsible": case "responsible":
return <td key={col.key} className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400"></td>; return (
<td key={col.key} className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
</td>
);
default: default:
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300"></td>; return (
<td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
</td>
);
} }
} }
// ─── Header renderer ────────────────────────────────────────────────────── // ─── 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) { function renderHeader(col: ColumnDef) {
if (SORTABLE_PROJECT_COLS.has(col.key)) { if (SORTABLE_PROJECT_COLS.has(col.key)) {
return ( return (
@@ -506,7 +643,10 @@ export function ProjectsClient() {
); );
} }
return ( return (
<th key={col.key} className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"> <th
key={col.key}
className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
>
{col.label} {col.label}
</th> </th>
); );
@@ -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" 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"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
/>
</svg> </svg>
New Project Wizard New Project Wizard
</button> </button>
@@ -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" 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"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg> </svg>
Quick Add Quick Add
</button> </button>
@@ -563,7 +713,9 @@ export function ProjectsClient() {
> >
<option value="">All Statuses</option> <option value="">All Statuses</option>
{ALL_STATUSES.map((s) => ( {ALL_STATUSES.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option> <option key={s.value} value={s.value}>
{s.label}
</option>
))} ))}
</select> </select>
<select <select
@@ -573,7 +725,9 @@ export function ProjectsClient() {
> >
<option value="">All Types</option> <option value="">All Types</option>
{ALL_ORDER_TYPES.map((t) => ( {ALL_ORDER_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option> <option key={t.value} value={t.value}>
{t.label}
</option>
))} ))}
</select> </select>
<ColumnTogglePanel <ColumnTogglePanel
@@ -602,7 +756,9 @@ export function ProjectsClient() {
<div className="app-data-table"> <div className="app-data-table">
{isLoading ? ( {isLoading ? (
<div className="py-16 text-center text-sm text-gray-500 shimmer-skeleton">Loading projects</div> <div className="py-16 text-center text-sm text-gray-500 shimmer-skeleton">
Loading projects
</div>
) : ( ) : (
<> <>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@@ -644,9 +800,15 @@ export function ProjectsClient() {
<td className="px-2 py-3 w-8"> <td className="px-2 py-3 w-8">
<button <button
type="button" type="button"
onClick={(e) => { e.stopPropagation(); e.preventDefault(); toggleFavMutation.mutate({ projectId: project.id }); }} onClick={(e) => {
e.stopPropagation();
e.preventDefault();
toggleFavMutation.mutate({ projectId: project.id });
}}
className={`text-sm transition-colors ${favSet.has(project.id) ? "text-amber-500 hover:text-amber-600" : "text-gray-300 hover:text-amber-400 dark:text-gray-600 dark:hover:text-amber-500"}`} className={`text-sm transition-colors ${favSet.has(project.id) ? "text-amber-500 hover:text-amber-600" : "text-gray-300 hover:text-amber-400 dark:text-gray-600 dark:hover:text-amber-500"}`}
title={favSet.has(project.id) ? "Remove from favorites" : "Add to favorites"} title={
favSet.has(project.id) ? "Remove from favorites" : "Add to favorites"
}
> >
{favSet.has(project.id) ? "★" : "☆"} {favSet.has(project.id) ? "★" : "☆"}
</button> </button>
@@ -669,7 +831,10 @@ export function ProjectsClient() {
> >
Edit Edit
</button> </button>
<Link href={`/projects/${project.id}`} className="app-action-edit link-hover-underline"> <Link
href={`/projects/${project.id}`}
className="app-action-edit link-hover-underline"
>
View View
</Link> </Link>
</div> </div>
@@ -684,25 +849,34 @@ export function ProjectsClient() {
{projects.length === 0 && ( {projects.length === 0 && (
<div className="py-14 text-center text-sm text-gray-500"> <div className="py-14 text-center text-sm text-gray-500">
No projects found.{" "} No projects found.{" "}
<button type="button" onClick={openNewModal} className="text-brand-600 hover:underline font-medium"> <button
type="button"
onClick={openNewModal}
className="text-brand-600 hover:underline font-medium"
>
Create your first project. Create your first project.
</button> </button>
</div> </div>
)} )}
<InfiniteScrollSentinel <InfiniteScrollSentinel onVisible={handleFetchNext} isLoading={isFetchingNextPage} />
onVisible={handleFetchNext}
isLoading={isFetchingNextPage}
/>
</> </>
)} )}
</div> </div>
{/* Batch Status Picker */} {/* Batch Status Picker */}
{batchStatusPicker && ( {batchStatusPicker && (
<div className="fixed inset-0 bg-black/30 z-50 flex items-center justify-center p-4" onClick={() => setBatchStatusPicker(false)}> <div
<div className="min-w-[220px] rounded-2xl bg-white p-5 shadow-2xl dark:bg-gray-900" onClick={(e) => e.stopPropagation()}> className="fixed inset-0 bg-black/30 z-50 flex items-center justify-center p-4"
<h3 className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">Set status for {selection.count} projects</h3> onClick={() => setBatchStatusPicker(false)}
>
<div
className="min-w-[220px] rounded-2xl bg-white p-5 shadow-2xl dark:bg-gray-900"
onClick={(e) => e.stopPropagation()}
>
<h3 className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">
Set status for {selection.count} projects
</h3>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{ALL_STATUSES.map((s) => ( {ALL_STATUSES.map((s) => (
<button <button
@@ -714,7 +888,12 @@ export function ProjectsClient() {
}} }}
className="w-full rounded-xl px-3 py-2 text-left text-sm transition-colors hover:bg-gray-50 dark:hover:bg-gray-800" className="w-full rounded-xl px-3 py-2 text-left text-sm transition-colors hover:bg-gray-50 dark:hover:bg-gray-800"
> >
<span className={clsx("inline-block px-2 py-0.5 text-xs rounded-full", STATUS_COLORS[s.value])}> <span
className={clsx(
"inline-block px-2 py-0.5 text-xs rounded-full",
STATUS_COLORS[s.value],
)}
>
{s.label} {s.label}
</span> </span>
</button> </button>
@@ -732,7 +911,10 @@ export function ProjectsClient() {
confirmLabel="Update" confirmLabel="Update"
onConfirm={() => { onConfirm={() => {
if (confirmBatchStatus) { if (confirmBatchStatus) {
batchUpdateStatus.mutate({ ids: confirmBatchStatus.ids, status: confirmBatchStatus.status as never }); batchUpdateStatus.mutate({
ids: confirmBatchStatus.ids,
status: confirmBatchStatus.status as never,
});
} }
setConfirmBatchStatus(null); setConfirmBatchStatus(null);
}} }}
@@ -166,7 +166,7 @@ export function ResourcesClient() {
const departedFilter = resourceUrlFilters.departed as BooleanFilter; const departedFilter = resourceUrlFilters.departed as BooleanFilter;
// chapters stored as comma-separated string; empty string means "all chapters visible" // chapters stored as comma-separated string; empty string means "all chapters visible"
const chaptersParam = resourceUrlFilters.chapters; const chaptersParam = resourceUrlFilters.chapters;
// eslint-disable-next-line react-hooks/exhaustive-deps
const chapterFilter: string[] = useMemo( const chapterFilter: string[] = useMemo(
() => (chaptersParam ? chaptersParam.split(",").filter(Boolean) : []), () => (chaptersParam ? chaptersParam.split(",").filter(Boolean) : []),
[chaptersParam], [chaptersParam],
@@ -175,21 +175,32 @@ export function ResourcesClient() {
// Flush debounced search input to URL // Flush debounced search input to URL
useEffect(() => { useEffect(() => {
setResourceUrlFilters({ search: debouncedSearch }); setResourceUrlFilters({ search: debouncedSearch });
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearch]); }, [debouncedSearch]);
// Keep local search input in sync when URL changes externally // Keep local search input in sync when URL changes externally
useEffect(() => { useEffect(() => {
setSearchInput(resourceUrlFilters.search); setSearchInput(resourceUrlFilters.search);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resourceUrlFilters.search]); }, [resourceUrlFilters.search]);
const setIsActiveFilter = useCallback((v: ActiveFilter) => setResourceUrlFilters({ activeFilter: v }), [setResourceUrlFilters]); const setIsActiveFilter = useCallback(
const setRolledOffFilter = useCallback((v: BooleanFilter) => setResourceUrlFilters({ rolledOff: v }), [setResourceUrlFilters]); (v: ActiveFilter) => setResourceUrlFilters({ activeFilter: v }),
const setDepartedFilter = useCallback((v: BooleanFilter) => setResourceUrlFilters({ departed: v }), [setResourceUrlFilters]); [setResourceUrlFilters],
const setChapterFilter = useCallback((v: string[]) => { );
setResourceUrlFilters({ chapters: v.join(",") }); const setRolledOffFilter = useCallback(
}, [setResourceUrlFilters]); (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 [includeProposedChargeability, setIncludeProposedChargeability] = useState(false);
const [hiddenCountryIds, setHiddenCountryIds] = useState<string[]>([]); const [hiddenCountryIds, setHiddenCountryIds] = useState<string[]>([]);
@@ -412,7 +423,13 @@ export function ResourcesClient() {
function clearAll() { function clearAll() {
setSearchInput(""); 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([]); setHiddenCountryIds([]);
setIncludeWithoutCountry(true); setIncludeWithoutCountry(true);
setHiddenResourceTypes([...DEFAULT_HIDDEN_RESOURCE_TYPES]); setHiddenResourceTypes([...DEFAULT_HIDDEN_RESOURCE_TYPES]);
@@ -468,7 +485,9 @@ export function ResourcesClient() {
if (next.length === chapters.length) { if (next.length === chapters.length) {
setChapterFilter([]); setChapterFilter([]);
} else { } 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], [chapters, chapterFilter, setChapterFilter],
@@ -533,13 +552,23 @@ export function ResourcesClient() {
{ header: "LCR (cents)", accessor: (r) => r.lcrCents }, { header: "LCR (cents)", accessor: (r) => r.lcrCents },
{ header: "Currency", accessor: (r) => r.currency }, { header: "Currency", accessor: (r) => r.currency },
{ header: "Chargeability Target", accessor: (r) => r.chargeabilityTarget }, { 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`); downloadCsv(csv, `resources-export-${new Date().toISOString().slice(0, 10)}.csv`);
}, [displayedResources, selection.selectedIds]); }, [displayedResources, selection.selectedIds]);
const chips = [ const chips = [
...(search ? [{ label: `Search: "${search}"`, onRemove: () => { setSearchInput(""); setResourceUrlFilters({ search: "" }); } }] : []), ...(search
? [
{
label: `Search: "${search}"`,
onRemove: () => {
setSearchInput("");
setResourceUrlFilters({ search: "" });
},
},
]
: []),
...(chapterFilter.length > 0 ...(chapterFilter.length > 0
? [ ? [
{ {
@@ -1303,7 +1332,12 @@ export function ResourcesClient() {
/> />
</div> </div>
{isOverflow && ( {isOverflow && (
<span className="text-[9px] font-bold text-green-600 dark:text-green-400" title={`${actual}% actual`}>+</span> <span
className="text-[9px] font-bold text-green-600 dark:text-green-400"
title={`${actual}% actual`}
>
+
</span>
)} )}
</div> </div>
)} )}
@@ -115,7 +115,6 @@ export async function GET(request: Request) {
) )
.join("; "); .join("; ");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await createNotificationsForUsers({ await createNotificationsForUsers({
db: prisma as any, db: prisma as any,
userIds: adminUsers.map((u) => u.id), userIds: adminUsers.map((u) => u.id),
@@ -128,7 +127,10 @@ export async function GET(request: Request) {
}); });
logger.warn( 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", "Auth anomaly cron: anomalies detected and admins notified",
); );
} }
@@ -140,9 +142,6 @@ export async function GET(request: Request) {
}); });
} catch (error) { } catch (error) {
logger.error({ error, route: "/api/cron/auth-anomaly-check" }, "Auth anomaly cron failed"); logger.error({ error, route: "/api/cron/auth-anomaly-check" }, "Auth anomaly cron failed");
return NextResponse.json( return NextResponse.json({ ok: false, error: "Internal error" }, { status: 500 });
{ ok: false, error: "Internal error" },
{ status: 500 },
);
} }
} }
@@ -73,10 +73,7 @@ export async function GET(request: Request) {
if (deny) return deny; if (deny) return deny;
try { try {
const [postgres, redis] = await Promise.all([ const [postgres, redis] = await Promise.all([checkPostgres(), checkRedis()]);
checkPostgres(),
checkRedis(),
]);
const allHealthy = postgres.status === "ok" && redis.status === "ok"; const allHealthy = postgres.status === "ok" && redis.status === "ok";
@@ -92,7 +89,6 @@ export async function GET(request: Request) {
}); });
if (adminUsers.length > 0) { if (adminUsers.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await createNotificationsForUsers({ await createNotificationsForUsers({
db: prisma as any, db: prisma as any,
userIds: adminUsers.map((u) => u.id), userIds: adminUsers.map((u) => u.id),
@@ -121,9 +117,6 @@ export async function GET(request: Request) {
); );
} catch (error) { } catch (error) {
logger.error({ error, route: "/api/cron/health-check" }, "Health check cron failed"); logger.error({ error, route: "/api/cron/health-check" }, "Health check cron failed");
return NextResponse.json( return NextResponse.json({ ok: false, error: "Internal error" }, { status: 500 });
{ ok: false, error: "Internal error" },
{ status: 500 },
);
} }
} }
@@ -17,7 +17,7 @@ const MINIMUM_SAFE_VERSIONS: Record<string, { minVersion: string; advisory?: str
next: { minVersion: "15.0.0", advisory: "Keep Next.js on latest 15.x" }, next: { minVersion: "15.0.0", advisory: "Keep Next.js on latest 15.x" },
prisma: { minVersion: "6.0.0", advisory: "Prisma 6.x for latest security patches" }, prisma: { minVersion: "6.0.0", advisory: "Prisma 6.x for latest security patches" },
"@sentry/nextjs": { minVersion: "8.0.0", advisory: "Sentry v8 for latest fixes" }, "@sentry/nextjs": { minVersion: "8.0.0", advisory: "Sentry v8 for latest fixes" },
"ioredis": { minVersion: "5.4.0", advisory: "ioredis 5.4+ for connection security" }, ioredis: { minVersion: "5.4.0", advisory: "ioredis 5.4+ for connection security" },
"next-auth": { minVersion: "5.0.0", advisory: "Auth.js v5 for security hardening" }, "next-auth": { minVersion: "5.0.0", advisory: "Auth.js v5 for security hardening" },
}; };
@@ -89,7 +89,10 @@ function scanPackageJson(): Finding[] {
} }
} }
} catch (error) { } catch (error) {
logger.error({ error, route: "/api/cron/security-audit" }, "Failed to scan package manifests for security audit"); logger.error(
{ error, route: "/api/cron/security-audit" },
"Failed to scan package manifests for security audit",
);
} }
return findings; return findings;
@@ -124,7 +127,6 @@ export async function GET(request: Request) {
.map((f) => `${f.package}@${f.currentVersion} (need >=${f.minimumVersion})`) .map((f) => `${f.package}@${f.currentVersion} (need >=${f.minimumVersion})`)
.join(", "); .join(", ");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await createNotificationsForUsers({ await createNotificationsForUsers({
db: prisma as any, db: prisma as any,
userIds: adminUsers.map((u) => u.id), userIds: adminUsers.map((u) => u.id),
@@ -147,9 +149,6 @@ export async function GET(request: Request) {
}); });
} catch (error) { } catch (error) {
logger.error({ error, route: "/api/cron/security-audit" }, "Security audit cron failed"); logger.error({ error, route: "/api/cron/security-audit" }, "Security audit cron failed");
return NextResponse.json( return NextResponse.json({ ok: false, error: "Internal error" }, { status: 500 });
{ ok: false, error: "Internal error" },
{ status: 500 },
);
} }
} }
+15 -13
View File
@@ -2,7 +2,8 @@ import { loadRoleDefaults } from "@capakraken/api";
import { deriveUserSseSubscription, eventBus } from "@capakraken/api/sse"; import { deriveUserSseSubscription, eventBus } from "@capakraken/api/sse";
import { startReminderScheduler } from "@capakraken/api/lib/reminder-scheduler"; import { startReminderScheduler } from "@capakraken/api/lib/reminder-scheduler";
import { prisma } from "@capakraken/db"; 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"; import { auth } from "~/server/auth.js";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -59,26 +60,27 @@ export async function GET() {
start(controller) { start(controller) {
// Send initial connection confirmation // Send initial connection confirmation
controller.enqueue( 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 // Subscribe to event bus
const unsubscribe = eventBus.subscribe( const unsubscribe = eventBus.subscribe((event) => {
(event) => { try {
try { controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); } catch {
} catch { // Client disconnected
// Client disconnected }
} }, subscription);
},
subscription,
);
// Heartbeat every 30 seconds // Heartbeat every 30 seconds
const heartbeat = setInterval(() => { const heartbeat = setInterval(() => {
try { try {
controller.enqueue( 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 { } catch {
clearInterval(heartbeat); clearInterval(heartbeat);
+219 -82
View File
@@ -45,7 +45,12 @@ function parseSpainRules(rules: unknown): Partial<EditingCountry> {
if (!rules || typeof rules !== "object") return { hasSpainRules: false }; if (!rules || typeof rules !== "object") return { hasSpainRules: false };
const r = rules as Record<string, unknown>; const r = rules as Record<string, unknown>;
if (r.type !== "spain") return { hasSpainRules: false }; 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 { return {
hasSpainRules: true, hasSpainRules: true,
fridayHours: sp.fridayHours ?? 6.5, fridayHours: sp.fridayHours ?? 6.5,
@@ -66,17 +71,26 @@ export function CountriesClient() {
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const { data: countries, isLoading } = trpc.country.list.useQuery(); 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({ 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), onError: (e) => setError(e.message),
}); });
const updateMut = trpc.country.update.useMutation({ 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), onError: (e) => setError(e.message),
}); });
const createCityMut = trpc.country.createCity.useMutation({ 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), onError: (e) => setError(e.message),
}); });
const deleteCityMut = trpc.country.deleteCity.useMutation({ const deleteCityMut = trpc.country.deleteCity.useMutation({
@@ -170,7 +184,13 @@ export function CountriesClient() {
{error && ( {error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400 flex items-center justify-between"> <div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400 flex items-center justify-between">
{error} {error}
<button type="button" onClick={() => setError(null)} className="text-red-400 hover:text-red-600 text-lg leading-none">&times;</button> <button
type="button"
onClick={() => setError(null)}
className="text-red-400 hover:text-red-600 text-lg leading-none"
>
&times;
</button>
</div> </div>
)} )}
@@ -179,29 +199,75 @@ export function CountriesClient() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50"> <tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider"><span className="flex items-center">Code <InfoTooltip content="ISO country code (e.g. DE, ES, IN). Used to identify the country in exports and API calls." /></span></th> <th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider"><span className="flex items-center">Name <InfoTooltip content="The full country name. Shown in dropdowns and resource location fields." /></span></th> <span className="flex items-center">
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider"><span className="flex items-center justify-center">Daily Hours <InfoTooltip content="Standard working hours per day for this country. Used in capacity calculations to convert between days and hours." /></span></th> Code{" "}
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider"><span className="flex items-center justify-center">Schedule <InfoTooltip content="Special schedule rules (e.g. Spain has reduced Friday hours and summer hours). 'Standard' uses the fixed daily hours value." /></span></th> <InfoTooltip content="ISO country code (e.g. DE, ES, IN). Used to identify the country in exports and API calls." />
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider"><span className="flex items-center justify-center">Cities <InfoTooltip content="Metro cities within this country. Used for location-specific rate cards and resource assignment." /></span></th> </span>
<th className="text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Actions</th> </th>
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">
<span className="flex items-center">
Name{" "}
<InfoTooltip content="The full country name. Shown in dropdowns and resource location fields." />
</span>
</th>
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">
<span className="flex items-center justify-center">
Daily Hours{" "}
<InfoTooltip content="Standard working hours per day for this country. Used in capacity calculations to convert between days and hours." />
</span>
</th>
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">
<span className="flex items-center justify-center">
Schedule{" "}
<InfoTooltip content="Special schedule rules (e.g. Spain has reduced Friday hours and summer hours). 'Standard' uses the fixed daily hours value." />
</span>
</th>
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">
<span className="flex items-center justify-center">
Cities{" "}
<InfoTooltip content="Metro cities within this country. Used for location-specific rate cards and resource assignment." />
</span>
</th>
<th className="text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">
Actions
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{isLoading && ( {isLoading && (
<tr><td colSpan={6} className="text-center py-8 text-gray-400">Loading...</td></tr> <tr>
<td colSpan={6} className="text-center py-8 text-gray-400">
Loading...
</td>
</tr>
)} )}
{!isLoading && rows.length === 0 && ( {!isLoading && rows.length === 0 && (
<tr><td colSpan={6} className="text-center py-8 text-gray-400">No countries yet.</td></tr> <tr>
<td colSpan={6} className="text-center py-8 text-gray-400">
No countries yet.
</td>
</tr>
)} )}
{rows.map((c) => ( {rows.map((c) => (
<tr key={c.id} className="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/30"> <tr
<td className="px-4 py-3 font-mono font-medium text-gray-900 dark:text-gray-100">{c.code}</td> key={c.id}
className="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/30"
>
<td className="px-4 py-3 font-mono font-medium text-gray-900 dark:text-gray-100">
{c.code}
</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{c.name}</td> <td className="px-4 py-3 text-gray-900 dark:text-gray-100">{c.name}</td>
<td className="px-4 py-3 text-center text-gray-600 dark:text-gray-400">{c.dailyWorkingHours}h</td> <td className="px-4 py-3 text-center text-gray-600 dark:text-gray-400">
{c.dailyWorkingHours}h
</td>
<td className="px-4 py-3 text-center"> <td className="px-4 py-3 text-center">
{c.scheduleRules && typeof c.scheduleRules === "object" && (c.scheduleRules as Record<string, unknown>).type === "spain" ? ( {c.scheduleRules &&
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400">Spain</span> typeof c.scheduleRules === "object" &&
(c.scheduleRules as Record<string, unknown>).type === "spain" ? (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400">
Spain
</span>
) : ( ) : (
<span className="text-gray-400 text-xs">Standard</span> <span className="text-gray-400 text-xs">Standard</span>
)} )}
@@ -216,7 +282,13 @@ export function CountriesClient() {
</button> </button>
</td> </td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
<button type="button" onClick={() => openEdit(c)} className="text-xs text-brand-600 hover:text-brand-800 font-medium">Edit</button> <button
type="button"
onClick={() => openEdit(c)}
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
>
Edit
</button>
</td> </td>
</tr> </tr>
))} ))}
@@ -225,67 +297,87 @@ export function CountriesClient() {
</div> </div>
{/* Expanded Metro Cities */} {/* Expanded Metro Cities */}
{expandedId && (() => { {expandedId &&
const country = rows.find((c) => c.id === expandedId); (() => {
if (!country) return null; const country = rows.find((c) => c.id === expandedId);
return ( if (!country) return null;
<div className="mt-4 bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4"> return (
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3"> <div className="mt-4 bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
Metro Cities for {country.name} <h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
</h3> Metro Cities for {country.name}
<div className="flex flex-wrap gap-2 mb-3"> </h3>
{country.metroCities.map((city) => ( <div className="flex flex-wrap gap-2 mb-3">
<span key={city.id} className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300"> {country.metroCities.map((city) => (
{city.name} <span
<button key={city.id}
type="button" className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300"
onClick={() => setConfirmDeleteCity(city.id)}
className="text-gray-400 hover:text-red-500 text-xs leading-none ml-1"
> >
&times; {city.name}
</button> <button
</span> type="button"
))} onClick={() => setConfirmDeleteCity(city.id)}
{country.metroCities.length === 0 && ( className="text-gray-400 hover:text-red-500 text-xs leading-none ml-1"
<span className="text-sm text-gray-400">No metro cities yet</span> >
)} &times;
</button>
</span>
))}
{country.metroCities.length === 0 && (
<span className="text-sm text-gray-400">No metro cities yet</span>
)}
</div>
<div className="flex gap-2">
<input
type="text"
value={cityName}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => handleAddCity(country.id)}
disabled={createCityMut.isPending || !cityName.trim()}
className="px-3 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
Add
</button>
</div>
</div> </div>
<div className="flex gap-2"> );
<input })()}
type="text"
value={cityName}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => handleAddCity(country.id)}
disabled={createCityMut.isPending || !cityName.trim()}
className="px-3 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
Add
</button>
</div>
</div>
);
})()}
{/* Create/Edit Modal */} {/* Create/Edit Modal */}
<AnimatedModal open={editing !== null} onClose={() => setEditing(null)} maxWidth="max-w-lg" className="flex flex-col max-h-[90vh]"> <AnimatedModal
{editing && (<> open={editing !== null}
onClose={() => setEditing(null)}
maxWidth="max-w-lg"
className="flex flex-col max-h-[90vh]"
>
{editing && (
<>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700"> <div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100"> <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editing.id ? "Edit Country" : "Add Country"} {editing.id ? "Edit Country" : "Add Country"}
</h2> </h2>
<button type="button" onClick={() => setEditing(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none">&times;</button> <button
type="button"
onClick={() => setEditing(null)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
>
&times;
</button>
</div> </div>
<div className="overflow-y-auto flex-1 px-6 py-5 space-y-4"> <div className="overflow-y-auto flex-1 px-6 py-5 space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Code <InfoTooltip content="2-3 letter ISO country code. Auto-uppercased." /></label> <label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Code <InfoTooltip content="2-3 letter ISO country code. Auto-uppercased." />
</label>
<input <input
type="text" type="text"
value={editing.code} value={editing.code}
@@ -296,11 +388,16 @@ export function CountriesClient() {
/> />
</div> </div>
<div> <div>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Daily Hours <InfoTooltip content="Standard working hours per day. Used to convert between hours and days in capacity calculations." /></label> <label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Daily Hours{" "}
<InfoTooltip content="Standard working hours per day. Used to convert between hours and days in capacity calculations." />
</label>
<input <input
type="number" type="number"
value={editing.dailyWorkingHours} value={editing.dailyWorkingHours}
onChange={(e) => setEditing({ ...editing, dailyWorkingHours: parseFloat(e.target.value) || 8 })} onChange={(e) =>
setEditing({ ...editing, dailyWorkingHours: parseFloat(e.target.value) || 8 })
}
min={1} min={1}
max={24} max={24}
step={0.5} step={0.5}
@@ -310,7 +407,9 @@ export function CountriesClient() {
</div> </div>
<div> <div>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name <InfoTooltip content="Full country name shown in the UI." /></label> <label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Name <InfoTooltip content="Full country name shown in the UI." />
</label>
<input <input
type="text" type="text"
value={editing.name} value={editing.name}
@@ -329,28 +428,45 @@ export function CountriesClient() {
onChange={(e) => setEditing({ ...editing, hasSpainRules: e.target.checked })} onChange={(e) => setEditing({ ...editing, hasSpainRules: e.target.checked })}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500" className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/> />
Variable schedule (Spain-type) <InfoTooltip content="Enable for countries with variable working hours (e.g. reduced Friday/summer hours). Overrides the fixed daily hours with day-specific rules." /> Variable schedule (Spain-type){" "}
<InfoTooltip content="Enable for countries with variable working hours (e.g. reduced Friday/summer hours). Overrides the fixed daily hours with day-specific rules." />
</label> </label>
{editing.hasSpainRules && ( {editing.hasSpainRules && (
<div className="mt-3 space-y-3 pl-6 border-l-2 border-amber-300 dark:border-amber-700"> <div className="mt-3 space-y-3 pl-6 border-l-2 border-amber-300 dark:border-amber-700">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Friday Hours <InfoTooltip content="Working hours on Fridays. Typically shorter than regular days." /></label> <label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Friday Hours{" "}
<InfoTooltip content="Working hours on Fridays. Typically shorter than regular days." />
</label>
<input <input
type="number" type="number"
value={editing.fridayHours} value={editing.fridayHours}
onChange={(e) => setEditing({ ...editing, fridayHours: parseFloat(e.target.value) || 6.5 })} onChange={(e) =>
setEditing({
...editing,
fridayHours: parseFloat(e.target.value) || 6.5,
})
}
step={0.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" 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"
/> />
</div> </div>
<div> <div>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Regular Hours (Mon-Thu) <InfoTooltip content="Working hours Monday through Thursday outside the summer period." /></label> <label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Regular Hours (Mon-Thu){" "}
<InfoTooltip content="Working hours Monday through Thursday outside the summer period." />
</label>
<input <input
type="number" type="number"
value={editing.regularHours} value={editing.regularHours}
onChange={(e) => setEditing({ ...editing, regularHours: parseFloat(e.target.value) || 9 })} onChange={(e) =>
setEditing({
...editing,
regularHours: parseFloat(e.target.value) || 9,
})
}
step={0.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" 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() {
</div> </div>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div> <div>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Summer From <InfoTooltip content="Start of the summer period (MM-DD format). During summer, reduced hours apply." /></label> <label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Summer From{" "}
<InfoTooltip content="Start of the summer period (MM-DD format). During summer, reduced hours apply." />
</label>
<input <input
type="text" type="text"
value={editing.summerFrom} value={editing.summerFrom}
@@ -368,7 +487,10 @@ export function CountriesClient() {
/> />
</div> </div>
<div> <div>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Summer To <InfoTooltip content="End of the summer period (MM-DD format)." /></label> <label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Summer To{" "}
<InfoTooltip content="End of the summer period (MM-DD format)." />
</label>
<input <input
type="text" type="text"
value={editing.summerTo} value={editing.summerTo}
@@ -378,11 +500,19 @@ export function CountriesClient() {
/> />
</div> </div>
<div> <div>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Summer Hours <InfoTooltip content="Reduced daily working hours during the summer period." /></label> <label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Summer Hours{" "}
<InfoTooltip content="Reduced daily working hours during the summer period." />
</label>
<input <input
type="number" type="number"
value={editing.summerHours} value={editing.summerHours}
onChange={(e) => setEditing({ ...editing, summerHours: parseFloat(e.target.value) || 6.5 })} onChange={(e) =>
setEditing({
...editing,
summerHours: parseFloat(e.target.value) || 6.5,
})
}
step={0.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" 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() {
</div> </div>
<div className="flex items-center justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3"> <div className="flex items-center justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
<button type="button" onClick={() => setEditing(null)} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">Cancel</button> <button
type="button"
onClick={() => setEditing(null)}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
>
Cancel
</button>
<button <button
type="button" type="button"
onClick={handleSave} onClick={handleSave}
@@ -404,7 +540,8 @@ export function CountriesClient() {
{isPending ? "Saving..." : editing.id ? "Update" : "Create"} {isPending ? "Saving..." : editing.id ? "Update" : "Create"}
</button> </button>
</div> </div>
</>)} </>
)}
</AnimatedModal> </AnimatedModal>
{confirmDeleteCity && ( {confirmDeleteCity && (
@@ -27,7 +27,8 @@ const PERMISSION_LABELS: Record<string, string> = {
const PERMISSION_DESCRIPTIONS: Record<string, string> = { const PERMISSION_DESCRIPTIONS: Record<string, string> = {
viewPlanning: "Read project and allocation planning views without mutation access", viewPlanning: "Read project and allocation planning views without mutation access",
viewCosts: "Access to cost data, budget views, and financial reports", 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", exportData: "Export data to Excel, CSV, or PDF formats",
importData: "Import data from external sources (Dispo, Excel)", importData: "Import data from external sources (Dispo, Excel)",
approveVacations: "Approve or reject vacation requests", approveVacations: "Approve or reject vacation requests",
@@ -101,8 +102,7 @@ export function SystemRolesClient() {
staleTime: 10_000, staleTime: 10_000,
}); });
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error TS2589: tRPC infers union type too deeply for the role config update payload
// @ts-ignore TS2589: tRPC infers union type too deeply for the role config update payload
const updateMutation = trpc.systemRoleConfig.update.useMutation({ const updateMutation = trpc.systemRoleConfig.update.useMutation({
onSuccess: async () => { onSuccess: async () => {
await utils.systemRoleConfig.list.invalidate(); await utils.systemRoleConfig.list.invalidate();
@@ -164,9 +164,12 @@ export function SystemRolesClient() {
return ( return (
<div className="p-6 max-w-4xl mx-auto"> <div className="p-6 max-w-4xl mx-auto">
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">System Role Management</h1> <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">
System Role Management
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1"> <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
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.
</p> </p>
</div> </div>
@@ -179,7 +182,11 @@ export function SystemRolesClient() {
{isLoading && ( {isLoading && (
<div className="space-y-3"> <div className="space-y-3">
{[...Array(5)].map((_, i) => ( {[...Array(5)].map((_, i) => (
<div key={i} className="h-24 shimmer-skeleton rounded-xl animate-row-enter" style={{ animationDelay: `${i * 50}ms` }} /> <div
key={i}
className="h-24 shimmer-skeleton rounded-xl animate-row-enter"
style={{ animationDelay: `${i * 50}ms` }}
/>
))} ))}
</div> </div>
)} )}
@@ -197,7 +204,9 @@ export function SystemRolesClient() {
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-3 mb-1"> <div className="flex items-center gap-3 mb-1">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold ${ROLE_BADGE_MAP[color] ?? ROLE_BADGE_MAP.gray}`}> <span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold ${ROLE_BADGE_MAP[color] ?? ROLE_BADGE_MAP.gray}`}
>
{config.label} {config.label}
</span> </span>
<span className="text-xs text-gray-400 dark:text-gray-500 font-mono"> <span className="text-xs text-gray-400 dark:text-gray-500 font-mono">
@@ -211,7 +220,9 @@ export function SystemRolesClient() {
)} )}
<div className="flex flex-wrap gap-1 mt-2"> <div className="flex flex-wrap gap-1 mt-2">
{perms.length === 0 ? ( {perms.length === 0 ? (
<span className="text-xs text-gray-400 dark:text-gray-500 italic">No default permissions</span> <span className="text-xs text-gray-400 dark:text-gray-500 italic">
No default permissions
</span>
) : ( ) : (
perms.map((p) => ( perms.map((p) => (
<span <span
@@ -241,15 +252,21 @@ export function SystemRolesClient() {
{configs.length > 0 && ( {configs.length > 0 && (
<div className="mt-8"> <div className="mt-8">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50 mb-3 flex items-center"> <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50 mb-3 flex items-center">
Permission Matrix <InfoTooltip content="Overview of which permissions each role has by default." /> Permission Matrix{" "}
<InfoTooltip content="Overview of which permissions each role has by default." />
</h2> </h2>
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-x-auto"> <div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-x-auto">
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead> <thead>
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50"> <tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<th className="px-3 py-2 text-left font-medium text-gray-600 dark:text-gray-400 sticky left-0 bg-gray-50 dark:bg-gray-800/50">Permission</th> <th className="px-3 py-2 text-left font-medium text-gray-600 dark:text-gray-400 sticky left-0 bg-gray-50 dark:bg-gray-800/50">
Permission
</th>
{configs.map((c) => ( {configs.map((c) => (
<th key={c.role} className="px-3 py-2 text-center font-medium text-gray-600 dark:text-gray-400 min-w-[80px]"> <th
key={c.role}
className="px-3 py-2 text-center font-medium text-gray-600 dark:text-gray-400 min-w-[80px]"
>
{c.label} {c.label}
</th> </th>
))} ))}
@@ -269,7 +286,11 @@ export function SystemRolesClient() {
{has ? ( {has ? (
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-green-100 dark:bg-green-900/40 text-green-600 dark:text-green-400"> <span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-green-100 dark:bg-green-900/40 text-green-600 dark:text-green-400">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /> <path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg> </svg>
</span> </span>
) : ( ) : (
@@ -303,7 +324,10 @@ export function SystemRolesClient() {
</div> </div>
<button <button
type="button" type="button"
onClick={() => { setEditingRole(null); setActionError(null); }} onClick={() => {
setEditingRole(null);
setActionError(null);
}}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none" className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
> >
&times; &times;
@@ -370,7 +394,8 @@ export function SystemRolesClient() {
<div> <div>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300"> <label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Default Permissions ({editingRole.permissions.size}/{ALL_PERMISSION_KEYS.length}) Default Permissions ({editingRole.permissions.size}/{ALL_PERMISSION_KEYS.length}
)
</label> </label>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
@@ -403,19 +428,31 @@ export function SystemRolesClient() {
: "bg-gray-50 border-gray-200 dark:bg-gray-800/50 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800" : "bg-gray-50 border-gray-200 dark:bg-gray-800/50 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800"
}`} }`}
> >
<span className={`flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center ${ <span
isActive className={`flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center ${
? "text-green-600 border-green-300 bg-green-100 dark:bg-green-900/40" isActive
: "border-gray-300 dark:border-gray-600" ? "text-green-600 border-green-300 bg-green-100 dark:bg-green-900/40"
}`}> : "border-gray-300 dark:border-gray-600"
}`}
>
{isActive && ( {isActive && (
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /> <path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg> </svg>
)} )}
</span> </span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<span className={isActive ? "text-gray-900 dark:text-gray-100 font-medium" : "text-gray-500 dark:text-gray-400"}> <span
className={
isActive
? "text-gray-900 dark:text-gray-100 font-medium"
: "text-gray-500 dark:text-gray-400"
}
>
{PERMISSION_LABELS[key] ?? key} {PERMISSION_LABELS[key] ?? key}
</span> </span>
<p className="text-[11px] text-gray-400 dark:text-gray-500 mt-0.5 truncate"> <p className="text-[11px] text-gray-400 dark:text-gray-500 mt-0.5 truncate">
@@ -432,7 +469,10 @@ export function SystemRolesClient() {
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700"> <div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
<button <button
type="button" type="button"
onClick={() => { setEditingRole(null); setActionError(null); }} onClick={() => {
setEditingRole(null);
setActionError(null);
}}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200" className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
> >
Cancel Cancel
+241 -69
View File
@@ -1,7 +1,13 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { SystemRole, PermissionKey, ROLE_DEFAULT_PERMISSIONS, MILLISECONDS_PER_DAY, type PermissionOverrides } from "@capakraken/shared"; import {
SystemRole,
PermissionKey,
ROLE_DEFAULT_PERMISSIONS,
MILLISECONDS_PER_DAY,
type PermissionOverrides,
} from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { AnimatedModal } from "~/components/ui/AnimatedModal.js"; import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
import { InviteUserModal } from "./InviteUserModal.js"; import { InviteUserModal } from "./InviteUserModal.js";
@@ -99,13 +105,17 @@ export function UsersClient() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [roleFilter, setRoleFilter] = useState<SystemRole | "">(""); const [roleFilter, setRoleFilter] = useState<SystemRole | "">("");
const [editingName, setEditingName] = useState<{ userId: string; name: string } | null>(null); const [editingName, setEditingName] = useState<{ userId: string; name: string } | null>(null);
const [passwordTarget, setPasswordTarget] = useState<{ userId: string; userName: string } | null>(null); const [passwordTarget, setPasswordTarget] = useState<{ userId: string; userName: string } | null>(
null,
);
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [passwordError, setPasswordError] = useState<string | null>(null); const [passwordError, setPasswordError] = useState<string | null>(null);
const [passwordSuccess, setPasswordSuccess] = useState(false); const [passwordSuccess, setPasswordSuccess] = useState(false);
const [inviteOpen, setInviteOpen] = useState(false); const [inviteOpen, setInviteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<{ userId: string; userName: string } | null>(null); const [deleteTarget, setDeleteTarget] = useState<{ userId: string; userName: string } | null>(
null,
);
const utils = trpc.useUtils(); const utils = trpc.useUtils();
@@ -145,8 +155,7 @@ export function UsersClient() {
onError: (err) => setActionError(err.message), onError: (err) => setActionError(err.message),
}); });
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error TS2589: tRPC infers union type too deeply for nullable overrides schema
// @ts-ignore TS2589: tRPC infers union type too deeply for nullable overrides schema
const setPermissionsMutation = trpc.user.setPermissions.useMutation({ const setPermissionsMutation = trpc.user.setPermissions.useMutation({
onSuccess: async () => { onSuccess: async () => {
await utils.user.list.invalidate(); await utils.user.list.invalidate();
@@ -322,7 +331,10 @@ export function UsersClient() {
async function handleSaveRole() { async function handleSaveRole() {
if (!editState) return; if (!editState) return;
setActionError(null); setActionError(null);
await updateRoleMutation.mutateAsync({ id: editState.userId, systemRole: editState.systemRole }); await updateRoleMutation.mutateAsync({
id: editState.userId,
systemRole: editState.systemRole,
});
} }
async function handleSavePermissions() { async function handleSavePermissions() {
@@ -358,7 +370,8 @@ export function UsersClient() {
const filteredUsers = allUsers.filter((u) => { const filteredUsers = allUsers.filter((u) => {
if (search) { if (search) {
const q = search.toLowerCase(); const q = search.toLowerCase();
if (!(u.name ?? "").toLowerCase().includes(q) && !u.email.toLowerCase().includes(q)) return false; if (!(u.name ?? "").toLowerCase().includes(q) && !u.email.toLowerCase().includes(q))
return false;
} }
if (roleFilter && u.systemRole !== roleFilter) return false; if (roleFilter && u.systemRole !== roleFilter) return false;
return true; return true;
@@ -408,7 +421,9 @@ export function UsersClient() {
const chips = [ const chips = [
...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []), ...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []),
...(roleFilter ? [{ label: `Role: ${SYSTEM_ROLE_LABELS[roleFilter]}`, onRemove: () => setRoleFilter("") }] : []), ...(roleFilter
? [{ label: `Role: ${SYSTEM_ROLE_LABELS[roleFilter]}`, onRemove: () => setRoleFilter("") }]
: []),
]; ];
function isOnline(user: UserRow) { function isOnline(user: UserRow) {
@@ -431,9 +446,7 @@ export function UsersClient() {
<div className="app-page-header mb-6"> <div className="app-page-header mb-6">
<div> <div>
<h1 className="app-page-title">User Management</h1> <h1 className="app-page-title">User Management</h1>
<p className="app-page-subtitle mt-1"> <p className="app-page-subtitle mt-1">Manage user roles and permission overrides</p>
Manage user roles and permission overrides
</p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{activeData && ( {activeData && (
@@ -449,16 +462,25 @@ export function UsersClient() {
)} )}
<button <button
type="button" type="button"
onClick={() => void autoLinkMutation.mutateAsync().then((r) => { onClick={() =>
setActionError(r.linked > 0 ? null : `No unlinked accounts found (checked ${r.checked})`); void autoLinkMutation.mutateAsync().then((r) => {
if (r.linked > 0) setActionError(null); setActionError(
})} r.linked > 0 ? null : `No unlinked accounts found (checked ${r.checked})`,
);
if (r.linked > 0) setActionError(null);
})
}
disabled={autoLinkMutation.isPending} disabled={autoLinkMutation.isPending}
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 dark:border-gray-600 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors disabled:opacity-50" className="inline-flex items-center gap-2 rounded-lg border border-gray-300 dark:border-gray-600 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors disabled:opacity-50"
title="Auto-link user accounts to resources by matching email addresses" title="Auto-link user accounts to resources by matching email addresses"
> >
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
/>
</svg> </svg>
{autoLinkMutation.isPending ? "Linking..." : "Auto-link Resources"} {autoLinkMutation.isPending ? "Linking..." : "Auto-link Resources"}
</button> </button>
@@ -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" 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"
> >
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg> </svg>
Invite User Invite User
</button> </button>
<button <button
type="button" type="button"
onClick={() => { setCreateState({ ...EMPTY_CREATE }); setActionError(null); }} onClick={() => {
setCreateState({ ...EMPTY_CREATE });
setActionError(null);
}}
className="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-brand-700 transition-colors" className="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-brand-700 transition-colors"
> >
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg> </svg>
Create User Create User
</button> </button>
@@ -501,7 +536,9 @@ export function UsersClient() {
> >
<option value="">All Roles</option> <option value="">All Roles</option>
{Object.values(SystemRole).map((role) => ( {Object.values(SystemRole).map((role) => (
<option key={role} value={role}>{SYSTEM_ROLE_LABELS[role]}</option> <option key={role} value={role}>
{SYSTEM_ROLE_LABELS[role]}
</option>
))} ))}
</select> </select>
</div> </div>
@@ -530,13 +567,54 @@ export function UsersClient() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50"> <tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<SortableColumnHeader label="Name" field="name" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="The user's display name. Shown in the UI and linked to a resource record if auto-linked." /> <SortableColumnHeader
<SortableColumnHeader label="Email" field="email" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="Login email address. Also used to auto-link user accounts to resource records by matching email." /> label="Name"
<SortableColumnHeader label="Role" field="systemRole" sortField={sortField} sortDir={sortDir} onSort={handleSort} align="center" tooltip="System role determines default access level. ADMIN: full access · MANAGER: manage resources/projects · CONTROLLER: read + export costs · USER: standard access · VIEWER: read-only. Individual permissions can be overridden via the edit dialog." tooltipWidth="w-80" /> field="name"
<th className="px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider text-center">Status</th> sortField={sortField}
<SortableColumnHeader label="Last Login" field="lastLoginAt" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="When the user last signed in." /> sortDir={sortDir}
<SortableColumnHeader label="Created" field="createdAt" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="Account creation date." /> onSort={handleSort}
<th className="text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Actions</th> tooltip="The user's display name. Shown in the UI and linked to a resource record if auto-linked."
/>
<SortableColumnHeader
label="Email"
field="email"
sortField={sortField}
sortDir={sortDir}
onSort={handleSort}
tooltip="Login email address. Also used to auto-link user accounts to resource records by matching email."
/>
<SortableColumnHeader
label="Role"
field="systemRole"
sortField={sortField}
sortDir={sortDir}
onSort={handleSort}
align="center"
tooltip="System role determines default access level. ADMIN: full access · MANAGER: manage resources/projects · CONTROLLER: read + export costs · USER: standard access · VIEWER: read-only. Individual permissions can be overridden via the edit dialog."
tooltipWidth="w-80"
/>
<th className="px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider text-center">
Status
</th>
<SortableColumnHeader
label="Last Login"
field="lastLoginAt"
sortField={sortField}
sortDir={sortDir}
onSort={handleSort}
tooltip="When the user last signed in."
/>
<SortableColumnHeader
label="Created"
field="createdAt"
sortField={sortField}
sortDir={sortDir}
onSort={handleSort}
tooltip="Account creation date."
/>
<th className="text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">
Actions
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -566,7 +644,8 @@ export function UsersClient() {
<td className="px-4 py-3 text-center"> <td className="px-4 py-3 text-center">
<span <span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${ className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
ROLE_BADGE_COLORS[user.systemRole as SystemRole] ?? ROLE_BADGE_COLORS[SystemRole.USER] ROLE_BADGE_COLORS[user.systemRole as SystemRole] ??
ROLE_BADGE_COLORS[SystemRole.USER]
}`} }`}
> >
{SYSTEM_ROLE_LABELS[user.systemRole as SystemRole] ?? user.systemRole} {SYSTEM_ROLE_LABELS[user.systemRole as SystemRole] ?? user.systemRole}
@@ -591,7 +670,10 @@ export function UsersClient() {
</span> </span>
)} )}
{user.totpEnabled && ( {user.totpEnabled && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-400" title="TOTP MFA enabled"> <span
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-400"
title="TOTP MFA enabled"
>
MFA MFA
</span> </span>
)} )}
@@ -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" 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" title="Set password"
> >
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /> className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
/>
</svg> </svg>
Password Password
</button> </button>
@@ -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" 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" title="Disable TOTP MFA for this user"
> >
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /> className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg> </svg>
Disable MFA Disable MFA
</button> </button>
@@ -645,7 +747,11 @@ export function UsersClient() {
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
if (confirm(`Deactivate ${user.name ?? user.email}? They will be logged out immediately and cannot log in until reactivated.`)) { if (
confirm(
`Deactivate ${user.name ?? user.email}? They will be logged out immediately and cannot log in until reactivated.`,
)
) {
void deactivateMutation.mutateAsync({ userId: user.id }); void deactivateMutation.mutateAsync({ userId: user.id });
} }
}} }}
@@ -668,7 +774,9 @@ export function UsersClient() {
)} )}
<button <button
type="button" type="button"
onClick={() => setDeleteTarget({ userId: user.id, userName: user.name ?? user.email })} onClick={() =>
setDeleteTarget({ userId: user.id, userName: user.name ?? user.email })
}
className="app-action-delete" className="app-action-delete"
title="Permanently delete user" title="Permanently delete user"
> >
@@ -711,7 +819,8 @@ export function UsersClient() {
/> />
{newPassword.length > 0 && newPassword.length < 8 && ( {newPassword.length > 0 && newPassword.length < 8 && (
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400"> <p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
{8 - newPassword.length} more character{8 - newPassword.length !== 1 ? "s" : ""} needed {8 - newPassword.length} more character{8 - newPassword.length !== 1 ? "s" : ""}{" "}
needed
</p> </p>
)} )}
</div> </div>
@@ -729,9 +838,7 @@ export function UsersClient() {
autoComplete="new-password" autoComplete="new-password"
/> />
{confirmPassword.length > 0 && newPassword !== confirmPassword && ( {confirmPassword.length > 0 && newPassword !== confirmPassword && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400"> <p className="mt-1 text-xs text-red-600 dark:text-red-400">Passwords do not match</p>
Passwords do not match
</p>
)} )}
</div> </div>
</div> </div>
@@ -747,7 +854,11 @@ export function UsersClient() {
<button <button
type="button" type="button"
onClick={() => void handleSetPassword()} onClick={() => void handleSetPassword()}
disabled={setPasswordMutation.isPending || newPassword.length < 8 || newPassword !== confirmPassword} disabled={
setPasswordMutation.isPending ||
newPassword.length < 8 ||
newPassword !== confirmPassword
}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed" className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
> >
{setPasswordMutation.isPending ? "Saving..." : "Set Password"} {setPasswordMutation.isPending ? "Saving..." : "Set Password"}
@@ -770,12 +881,13 @@ export function UsersClient() {
</div> </div>
<div className="px-6 py-5 space-y-3"> <div className="px-6 py-5 space-y-3">
<p className="text-sm text-gray-700 dark:text-gray-300"> <p className="text-sm text-gray-700 dark:text-gray-300">
Are you sure you want to permanently delete{" "} Are you sure you want to permanently delete <strong>{deleteTarget.userName}</strong>
<strong>{deleteTarget.userName}</strong>? ?
</p> </p>
<p className="text-sm text-red-600 dark:text-red-400"> <p className="text-sm text-red-600 dark:text-red-400">
This will permanently remove their account, sessions, vacation records, and notifications. This will permanently remove their account, sessions, vacation records, and
Audit history entries will be retained but anonymised. This action cannot be undone. notifications. Audit history entries will be retained but anonymised. This action
cannot be undone.
</p> </p>
</div> </div>
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700"> <div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
@@ -810,7 +922,10 @@ export function UsersClient() {
</h2> </h2>
<button <button
type="button" type="button"
onClick={() => { setCreateState(null); setActionError(null); }} onClick={() => {
setCreateState(null);
setActionError(null);
}}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none" className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
> >
&times; &times;
@@ -839,7 +954,8 @@ export function UsersClient() {
<div> <div>
<label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email <InfoTooltip content="Login email address. Also used to auto-link the user to a resource record." /> Email{" "}
<InfoTooltip content="Login email address. Also used to auto-link the user to a resource record." />
</label> </label>
<input <input
type="email" type="email"
@@ -852,7 +968,8 @@ export function UsersClient() {
<div> <div>
<label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Password <InfoTooltip content="Minimum 8 characters. Stored securely using Argon2 hashing." /> Password{" "}
<InfoTooltip content="Minimum 8 characters. Stored securely using Argon2 hashing." />
</label> </label>
<input <input
type="password" type="password"
@@ -866,15 +983,20 @@ export function UsersClient() {
<div> <div>
<label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Role <InfoTooltip content="ADMIN: full system access. MANAGER: manage resources, projects, allocations. CONTROLLER: read + export financial data. USER: standard access. VIEWER: read-only." /> Role{" "}
<InfoTooltip content="ADMIN: full system access. MANAGER: manage resources, projects, allocations. CONTROLLER: read + export financial data. USER: standard access. VIEWER: read-only." />
</label> </label>
<select <select
value={createState.systemRole} value={createState.systemRole}
onChange={(e) => setCreateState({ ...createState, systemRole: e.target.value as SystemRole })} onChange={(e) =>
setCreateState({ ...createState, systemRole: e.target.value as SystemRole })
}
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" 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"
> >
{Object.values(SystemRole).map((role) => ( {Object.values(SystemRole).map((role) => (
<option key={role} value={role}>{SYSTEM_ROLE_LABELS[role]}</option> <option key={role} value={role}>
{SYSTEM_ROLE_LABELS[role]}
</option>
))} ))}
</select> </select>
</div> </div>
@@ -883,7 +1005,10 @@ export function UsersClient() {
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700"> <div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
<button <button
type="button" type="button"
onClick={() => { setCreateState(null); setActionError(null); }} onClick={() => {
setCreateState(null);
setActionError(null);
}}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200" className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
> >
Cancel Cancel
@@ -891,7 +1016,12 @@ export function UsersClient() {
<button <button
type="button" type="button"
onClick={() => void handleCreateUser()} onClick={() => void handleCreateUser()}
disabled={isPending || !createState.name.trim() || !createState.email.trim() || createState.password.length < 8} disabled={
isPending ||
!createState.name.trim() ||
!createState.email.trim() ||
createState.password.length < 8
}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed" className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
> >
{createUserMutation.isPending ? "Creating..." : "Create User"} {createUserMutation.isPending ? "Creating..." : "Create User"}
@@ -941,14 +1071,22 @@ export function UsersClient() {
autoFocus autoFocus
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" && editingName.name.trim()) { if (e.key === "Enter" && editingName.name.trim()) {
updateNameMutation.mutate({ id: editingName.userId, name: editingName.name.trim() }); updateNameMutation.mutate({
id: editingName.userId,
name: editingName.name.trim(),
});
} }
if (e.key === "Escape") setEditingName(null); if (e.key === "Escape") setEditingName(null);
}} }}
/> />
<button <button
type="button" type="button"
onClick={() => updateNameMutation.mutate({ id: editingName.userId, name: editingName.name.trim() })} onClick={() =>
updateNameMutation.mutate({
id: editingName.userId,
name: editingName.name.trim(),
})
}
disabled={!editingName.name.trim() || updateNameMutation.isPending} disabled={!editingName.name.trim() || updateNameMutation.isPending}
className="px-3 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700 disabled:opacity-50" className="px-3 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700 disabled:opacity-50"
> >
@@ -984,7 +1122,8 @@ export function UsersClient() {
{/* System Role */} {/* System Role */}
<section> <section>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center"> <h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center">
System Role <InfoTooltip content="The base role determines default permissions. Change the role and click 'Save Role' to apply. Permission overrides below can further customize access." /> System Role{" "}
<InfoTooltip content="The base role determines default permissions. Change the role and click 'Save Role' to apply. Permission overrides below can further customize access." />
</h3> </h3>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<select <select
@@ -1014,17 +1153,25 @@ export function UsersClient() {
{/* Permissions */} {/* Permissions */}
<section> <section>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center"> <h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center">
Permissions <InfoTooltip content="Permissions inherited from the role are shown with a filled checkbox. Click to override: grant additional permissions or deny role defaults." /> Permissions{" "}
<InfoTooltip content="Permissions inherited from the role are shown with a filled checkbox. Click to override: grant additional permissions or deny role defaults." />
</h3> </h3>
<div className="flex gap-1.5 mb-3 text-[11px]"> <div className="flex gap-1.5 mb-3 text-[11px]">
<span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400"> <span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
<span className="inline-block w-3 h-3 rounded border border-green-400 bg-green-100 dark:bg-green-900/40" /> Role default <span className="inline-block w-3 h-3 rounded border border-green-400 bg-green-100 dark:bg-green-900/40" />{" "}
Role default
</span> </span>
<span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400"> <span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
<span className="inline-block w-3 h-3 rounded border border-blue-400 bg-blue-100 dark:bg-blue-900/40" /> Extra grant <span className="inline-block w-3 h-3 rounded border border-blue-400 bg-blue-100 dark:bg-blue-900/40" />{" "}
Extra grant
</span> </span>
<span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400"> <span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
<span className="inline-block w-3 h-3 rounded border border-red-400 bg-red-100 dark:bg-red-900/40 relative"><span className="absolute inset-0 flex items-center justify-center text-red-500 text-[9px] leading-none">&times;</span></span> Denied <span className="inline-block w-3 h-3 rounded border border-red-400 bg-red-100 dark:bg-red-900/40 relative">
<span className="absolute inset-0 flex items-center justify-center text-red-500 text-[9px] leading-none">
&times;
</span>
</span>{" "}
Denied
</span> </span>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
@@ -1067,8 +1214,10 @@ export function UsersClient() {
} }
const stateStyles = { const stateStyles = {
default: "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800", default:
granted: "bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800", "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800",
granted:
"bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800",
denied: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800", denied: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800",
off: "bg-gray-50 border-gray-200 dark:bg-gray-800/50 dark:border-gray-700", off: "bg-gray-50 border-gray-200 dark:bg-gray-800/50 dark:border-gray-700",
}; };
@@ -1087,28 +1236,50 @@ export function UsersClient() {
onClick={cycleState} onClick={cycleState}
className={`flex items-center gap-2.5 w-full px-3 py-1.5 rounded-lg border text-sm text-left transition-colors ${stateStyles[state]} hover:opacity-80`} className={`flex items-center gap-2.5 w-full px-3 py-1.5 rounded-lg border text-sm text-left transition-colors ${stateStyles[state]} hover:opacity-80`}
> >
<span className={`flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center ${checkStyles[state]}`}> <span
className={`flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center ${checkStyles[state]}`}
>
{state === "default" && ( {state === "default" && (
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /></svg> <svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
)} )}
{state === "granted" && ( {state === "granted" && (
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" /></svg> <svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
clipRule="evenodd"
/>
</svg>
)} )}
{state === "denied" && ( {state === "denied" && (
<span className="text-xs font-bold leading-none">&times;</span> <span className="text-xs font-bold leading-none">&times;</span>
)} )}
</span> </span>
<span className={`flex-1 ${state === "denied" ? "line-through text-red-500 dark:text-red-400" : state === "off" ? "text-gray-500 dark:text-gray-400" : "text-gray-900 dark:text-gray-100"}`}> <span
className={`flex-1 ${state === "denied" ? "line-through text-red-500 dark:text-red-400" : state === "off" ? "text-gray-500 dark:text-gray-400" : "text-gray-900 dark:text-gray-100"}`}
>
{PERMISSION_LABELS[key] ?? key} {PERMISSION_LABELS[key] ?? key}
</span> </span>
{state === "default" && ( {state === "default" && (
<span className="text-[10px] text-green-600 dark:text-green-400 font-medium uppercase tracking-wide">Role</span> <span className="text-[10px] text-green-600 dark:text-green-400 font-medium uppercase tracking-wide">
Role
</span>
)} )}
{state === "granted" && ( {state === "granted" && (
<span className="text-[10px] text-blue-600 dark:text-blue-400 font-medium uppercase tracking-wide">Extra</span> <span className="text-[10px] text-blue-600 dark:text-blue-400 font-medium uppercase tracking-wide">
Extra
</span>
)} )}
{state === "denied" && ( {state === "denied" && (
<span className="text-[10px] text-red-600 dark:text-red-400 font-medium uppercase tracking-wide">Denied</span> <span className="text-[10px] text-red-600 dark:text-red-400 font-medium uppercase tracking-wide">
Denied
</span>
)} )}
</button> </button>
); );
@@ -1118,7 +1289,8 @@ export function UsersClient() {
{/* Chapter Scope */} {/* Chapter Scope */}
<div className="mt-4"> <div className="mt-4">
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5"> <label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5">
Chapter Scope (comma-separated IDs, leave blank for all) <InfoTooltip content="Restrict this user's access to specific chapters/disciplines only. Leave blank to allow access to all chapters." /> Chapter Scope (comma-separated IDs, leave blank for all){" "}
<InfoTooltip content="Restrict this user's access to specific chapters/disciplines only. Leave blank to allow access to all chapters." />
</label> </label>
<input <input
type="text" type="text"
@@ -6,8 +6,14 @@ import { useLocalStorage } from "~/hooks/useLocalStorage.js";
import { formatDate } from "~/lib/format.js"; import { formatDate } from "~/lib/format.js";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { AllocationModal } from "./AllocationModal.js"; import { AllocationModal } from "./AllocationModal.js";
import type { AllocationLike, AllocationReadModel, AllocationWithDetails, ColumnDef } from "@capakraken/shared"; import type {
import { AllocationStatus, ALLOCATION_COLUMNS } from "@capakraken/shared"; AllocationLike,
AllocationReadModel,
AllocationWithDetails,
ColumnDef,
AllocationStatus,
} from "@capakraken/shared";
import { ALLOCATION_COLUMNS } from "@capakraken/shared";
import { useSelection } from "~/hooks/useSelection.js"; import { useSelection } from "~/hooks/useSelection.js";
import { BatchActionBar } from "~/components/ui/BatchActionBar.js"; import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js"; import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
@@ -34,7 +40,10 @@ import {
toggleCollapsedAllocationGroup, toggleCollapsedAllocationGroup,
type CollapsedAllocationGroups, type CollapsedAllocationGroups,
} from "./allocationGroupState.js"; } from "./allocationGroupState.js";
import { getAllocationEmptyState, shouldAutoRelaxAllocationFilters } from "./allocationVisibilityState.js"; import {
getAllocationEmptyState,
shouldAutoRelaxAllocationFilters,
} from "./allocationVisibilityState.js";
/** Left-border color by allocation status for instant visual scanning */ /** Left-border color by allocation status for instant visual scanning */
const STATUS_LEFT_BORDER: Record<string, string> = { const STATUS_LEFT_BORDER: Record<string, string> = {
@@ -124,15 +133,39 @@ export function AllocationsClient() {
const hideCompletedProjects = allocFilters.hideCompleted === "true"; const hideCompletedProjects = allocFilters.hideCompleted === "true";
const hideDraftProjects = allocFilters.hideDraft === "true"; const hideDraftProjects = allocFilters.hideDraft === "true";
const setFilterProjectId = useCallback((v: string) => setAllocFilters({ projectId: v }), [setAllocFilters]); const setFilterProjectId = useCallback(
const setFilterResourceId = useCallback((v: string) => setAllocFilters({ resourceId: v }), [setAllocFilters]); (v: string) => setAllocFilters({ projectId: v }),
const setFilterStatus = useCallback((v: string) => setAllocFilters({ status: v }), [setAllocFilters]); [setAllocFilters],
const setHidePastProjects = useCallback((v: boolean) => setAllocFilters({ hidePast: v ? "true" : "false" }), [setAllocFilters]); );
const setHideCompletedProjects = useCallback((v: boolean) => setAllocFilters({ hideCompleted: v ? "true" : "false" }), [setAllocFilters]); const setFilterResourceId = useCallback(
const setHideDraftProjects = useCallback((v: boolean) => setAllocFilters({ hideDraft: v ? "true" : "false" }), [setAllocFilters]); (v: string) => setAllocFilters({ resourceId: v }),
const [confirmDelete, setConfirmDelete] = useState<{ single?: AllocationWithDetails; ids?: string[] } | null>(null); [setAllocFilters],
);
const setFilterStatus = useCallback(
(v: string) => setAllocFilters({ status: v }),
[setAllocFilters],
);
const setHidePastProjects = useCallback(
(v: boolean) => setAllocFilters({ hidePast: v ? "true" : "false" }),
[setAllocFilters],
);
const setHideCompletedProjects = useCallback(
(v: boolean) => setAllocFilters({ hideCompleted: v ? "true" : "false" }),
[setAllocFilters],
);
const setHideDraftProjects = useCallback(
(v: boolean) => setAllocFilters({ hideDraft: v ? "true" : "false" }),
[setAllocFilters],
);
const [confirmDelete, setConfirmDelete] = useState<{
single?: AllocationWithDetails;
ids?: string[];
} | null>(null);
const [batchStatusPicker, setBatchStatusPicker] = useState(false); 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 [showStatusToast, setShowStatusToast] = useState(false); const [showStatusToast, setShowStatusToast] = useState(false);
const [showDateShiftModal, setShowDateShiftModal] = useState(false); const [showDateShiftModal, setShowDateShiftModal] = useState(false);
@@ -145,8 +178,14 @@ export function AllocationsClient() {
() => (canViewCosts ? ALLOCATION_COLUMNS : ALLOCATION_COLUMNS.filter((c) => c.key !== "cost")), () => (canViewCosts ? ALLOCATION_COLUMNS : ALLOCATION_COLUMNS.filter((c) => c.key !== "cost")),
[canViewCosts], [canViewCosts],
); );
const { allColumns, visibleColumns, visibleKeys, setVisible } = useColumnConfig("allocations", baseColumns); const { allColumns, visibleColumns, visibleKeys, setVisible } = useColumnConfig(
const defaultKeys = useMemo(() => baseColumns.filter((c) => c.defaultVisible).map((c) => c.key), [baseColumns]); "allocations",
baseColumns,
);
const defaultKeys = useMemo(
() => baseColumns.filter((c) => c.defaultVisible).map((c) => c.key),
[baseColumns],
);
const allocationQuery = trpc.allocation.listView.useQuery( const allocationQuery = trpc.allocation.listView.useQuery(
{ {
@@ -207,17 +246,28 @@ export function AllocationsClient() {
useEffect(() => { useEffect(() => {
selection.clear(); selection.clear();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [filterProjectId, filterResourceId, filterStatus, hidePastProjects, hideCompletedProjects, hideDraftProjects]); }, [
filterProjectId,
filterResourceId,
filterStatus,
hidePastProjects,
hideCompletedProjects,
hideDraftProjects,
]);
function handleExportExcel() { function handleExportExcel() {
const rows: (string | number | null)[][] = [ const rows: (string | number | null)[][] = [
["Resource", "Project", "Role", "Start Date", "End Date", "Hours/Day", "Status"], ["Resource", "Project", "Role", "Start Date", "End Date", "Hours/Day", "Status"],
...sorted.map((a) => [ ...sorted.map((a) => [
(a.resource as { displayName?: string } | null | undefined)?.displayName ?? "Unassigned", (a.resource as { displayName?: string } | null | undefined)?.displayName ?? "Unassigned",
(a.project as { shortCode?: string; name?: string } | null | undefined) ? `${(a.project as { shortCode: string }).shortCode} — ${(a.project as { name: string }).name}` : "", (a.project as { shortCode?: string; name?: string } | null | undefined)
? `${(a.project as { shortCode: string }).shortCode} — ${(a.project as { name: string }).name}`
: "",
a.role ?? "", a.role ?? "",
typeof a.startDate === "string" ? a.startDate : (a.startDate as Date).toISOString().slice(0, 10), typeof a.startDate === "string"
? a.startDate
: (a.startDate as Date).toISOString().slice(0, 10),
typeof a.endDate === "string" ? a.endDate : (a.endDate as Date).toISOString().slice(0, 10), typeof a.endDate === "string" ? a.endDate : (a.endDate as Date).toISOString().slice(0, 10),
a.hoursPerDay, a.hoursPerDay,
a.status, a.status,
@@ -248,16 +298,28 @@ export function AllocationsClient() {
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
const filteredAllocations = assignmentList.filter((alloc) => { const filteredAllocations = assignmentList.filter((alloc) => {
if (hidePastProjects && alloc.project?.endDate && new Date(alloc.project.endDate) < today) return false; if (hidePastProjects && alloc.project?.endDate && new Date(alloc.project.endDate) < today)
if (hideCompletedProjects && alloc.project?.status && ["COMPLETED", "CANCELLED"].includes(alloc.project.status)) return false; return false;
if (
hideCompletedProjects &&
alloc.project?.status &&
["COMPLETED", "CANCELLED"].includes(alloc.project.status)
)
return false;
if (hideDraftProjects && alloc.project?.status === "DRAFT") return false; if (hideDraftProjects && alloc.project?.status === "DRAFT") return false;
return true; return true;
}); });
const filteredDemands = demandList.filter((alloc) => { const filteredDemands = demandList.filter((alloc) => {
if (filterResourceId) return false; if (filterResourceId) return false;
if (hidePastProjects && alloc.project?.endDate && new Date(alloc.project.endDate) < today) return false; if (hidePastProjects && alloc.project?.endDate && new Date(alloc.project.endDate) < today)
if (hideCompletedProjects && alloc.project?.status && ["COMPLETED", "CANCELLED"].includes(alloc.project.status)) return false; return false;
if (
hideCompletedProjects &&
alloc.project?.status &&
["COMPLETED", "CANCELLED"].includes(alloc.project.status)
)
return false;
if (hideDraftProjects && alloc.project?.status === "DRAFT") return false; if (hideDraftProjects && alloc.project?.status === "DRAFT") return false;
return true; return true;
}); });
@@ -273,9 +335,7 @@ export function AllocationsClient() {
const allocationIds = sorted.map((a) => a.id); const allocationIds = sorted.map((a) => a.id);
const allocationMutationIdsByDisplayId = useMemo( const allocationMutationIdsByDisplayId = useMemo(
() => () =>
new Map( new Map(sorted.map((allocation) => [allocation.id, getPlanningEntryMutationId(allocation)])),
sorted.map((allocation) => [allocation.id, getPlanningEntryMutationId(allocation)]),
),
[sorted], [sorted],
); );
const selectedMutationIds = useMemo( const selectedMutationIds = useMemo(
@@ -288,16 +348,19 @@ export function AllocationsClient() {
); );
// ─── View mode: grouped (default) vs flat ────────────────────────────────── // ─── View mode: grouped (default) vs flat ──────────────────────────────────
const [viewMode, setViewMode] = useLocalStorage<"grouped" | "flat">("capakraken:allocations:viewMode", "grouped"); const [viewMode, setViewMode] = useLocalStorage<"grouped" | "flat">(
const [collapsedGroups, setCollapsedGroups] = useState<CollapsedAllocationGroups>( "capakraken:allocations:viewMode",
() => createInitialCollapsedAllocationGroups(), "grouped",
);
const [collapsedGroups, setCollapsedGroups] = useState<CollapsedAllocationGroups>(() =>
createInitialCollapsedAllocationGroups(),
); );
// Track expanded project sub-groups: key = "resourceId::projectId" // Track expanded project sub-groups: key = "resourceId::projectId"
const [expandedSubGroups, setExpandedSubGroups] = useState<Set<string>>(new Set()); const [expandedSubGroups, setExpandedSubGroups] = useState<Set<string>>(new Set());
const hasEvaluatedInitialVisibility = useRef(false); const hasEvaluatedInitialVisibility = useRef(false);
const toggleViewMode = useCallback(() => { const toggleViewMode = useCallback(() => {
setViewMode((prev) => prev === "grouped" ? "flat" : "grouped"); setViewMode((prev) => (prev === "grouped" ? "flat" : "grouped"));
}, [setViewMode]); }, [setViewMode]);
type ProjectSubGroup = { type ProjectSubGroup = {
@@ -344,7 +407,10 @@ export function AllocationsClient() {
for (const alloc of group.allocations) { for (const alloc of group.allocations) {
const pid = alloc.project?.id ?? "__no_project__"; const pid = alloc.project?.id ?? "__no_project__";
let list = projMap.get(pid); let list = projMap.get(pid);
if (!list) { list = []; projMap.set(pid, list); } if (!list) {
list = [];
projMap.set(pid, list);
}
list.push(alloc); list.push(alloc);
} }
group.projectSubGroups = [...projMap.entries()].map(([pid, allocs]) => { group.projectSubGroups = [...projMap.entries()].map(([pid, allocs]) => {
@@ -364,7 +430,10 @@ export function AllocationsClient() {
let typicalH = first.hoursPerDay; let typicalH = first.hoursPerDay;
let maxCount = 0; let maxCount = 0;
for (const [h, count] of hpdCounts) { for (const [h, count] of hpdCounts) {
if (count > maxCount) { typicalH = h; maxCount = count; } if (count > maxCount) {
typicalH = h;
maxCount = count;
}
} }
return { return {
projectId: pid, projectId: pid,
@@ -390,9 +459,12 @@ export function AllocationsClient() {
const groupIds = useMemo(() => groups.map((g) => g.resourceId), [groups]); const groupIds = useMemo(() => groups.map((g) => g.resourceId), [groups]);
const toggleGroup = useCallback((resourceId: string) => { const toggleGroup = useCallback(
setCollapsedGroups((prev) => toggleCollapsedAllocationGroup(prev, groupIds, resourceId)); (resourceId: string) => {
}, [groupIds]); setCollapsedGroups((prev) => toggleCollapsedAllocationGroup(prev, groupIds, resourceId));
},
[groupIds],
);
const collapseAll = useCallback(() => { const collapseAll = useCallback(() => {
setCollapsedGroups(collapseAllAllocationGroups()); setCollapsedGroups(collapseAllAllocationGroups());
@@ -423,16 +495,35 @@ export function AllocationsClient() {
} }
function clearAll() { function clearAll() {
setAllocFilters({ projectId: "", resourceId: "", status: "", hidePast: "false", hideCompleted: "false", hideDraft: "false" }); setAllocFilters({
projectId: "",
resourceId: "",
status: "",
hidePast: "false",
hideCompleted: "false",
hideDraft: "false",
});
} }
const chips = [ const chips = [
...(filterProjectId ? [{ label: `Project filter active`, onRemove: () => setFilterProjectId("") }] : []), ...(filterProjectId
...(filterResourceId ? [{ label: `Resource filter active`, onRemove: () => setFilterResourceId("") }] : []), ? [{ label: `Project filter active`, onRemove: () => setFilterProjectId("") }]
...(filterStatus ? [{ label: `Status: ${filterStatus}`, onRemove: () => setFilterStatus("") }] : []), : []),
...(hidePastProjects ? [{ label: "Hiding past projects", onRemove: () => setHidePastProjects(false) }] : []), ...(filterResourceId
...(hideCompletedProjects ? [{ label: "Hiding completed/cancelled", onRemove: () => setHideCompletedProjects(false) }] : []), ? [{ label: `Resource filter active`, onRemove: () => setFilterResourceId("") }]
...(hideDraftProjects ? [{ label: "Hiding draft projects", onRemove: () => setHideDraftProjects(false) }] : []), : []),
...(filterStatus
? [{ label: `Status: ${filterStatus}`, onRemove: () => setFilterStatus("") }]
: []),
...(hidePastProjects
? [{ label: "Hiding past projects", onRemove: () => setHidePastProjects(false) }]
: []),
...(hideCompletedProjects
? [{ label: "Hiding completed/cancelled", onRemove: () => setHideCompletedProjects(false) }]
: []),
...(hideDraftProjects
? [{ label: "Hiding draft projects", onRemove: () => setHideDraftProjects(false) }]
: []),
]; ];
const emptyState = getAllocationEmptyState({ const emptyState = getAllocationEmptyState({
@@ -518,41 +609,80 @@ export function AllocationsClient() {
switch (col.key) { switch (col.key) {
case "resource": case "resource":
return ( return (
<td key={col.key} className="px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100"> <td
{isGrouped ? <span className="text-gray-400 dark:text-gray-500"></span> : (alloc.resource?.displayName ?? "—")} key={col.key}
className="px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100"
>
{isGrouped ? (
<span className="text-gray-400 dark:text-gray-500"></span>
) : (
(alloc.resource?.displayName ?? "—")
)}
</td> </td>
); );
case "project": case "project":
return ( return (
<td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300"> <td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
{alloc.project ? ( {alloc.project ? (
<><span className="font-mono text-xs">{alloc.project.shortCode}</span> {alloc.project.name}</> <>
) : ""} <span className="font-mono text-xs">{alloc.project.shortCode}</span>{" "}
{alloc.project.name}
</>
) : (
"—"
)}
</td> </td>
); );
case "role": case "role":
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">{alloc.role}</td>; return (
<td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
{alloc.role}
</td>
);
case "dates": case "dates":
return <td key={col.key} className="whitespace-nowrap px-4 py-3 text-xs text-gray-500 dark:text-gray-400">{formatPeriod(alloc)}</td>; return (
<td
key={col.key}
className="whitespace-nowrap px-4 py-3 text-xs text-gray-500 dark:text-gray-400"
>
{formatPeriod(alloc)}
</td>
);
case "hoursPerDay": case "hoursPerDay":
return <td key={col.key} className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">{alloc.hoursPerDay}h</td>; return (
<td key={col.key} className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
{alloc.hoursPerDay}h
</td>
);
case "cost": case "cost":
return <td key={col.key} className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">{(alloc.dailyCostCents / 100).toFixed(0)} </td>; return (
<td key={col.key} className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
{(alloc.dailyCostCents / 100).toFixed(0)}
</td>
);
case "status": case "status":
return ( return (
<td key={col.key} className="px-4 py-3"> <td key={col.key} className="px-4 py-3">
<span className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${STATUS_BADGE[alloc.status] ?? "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400"}`}> <span
className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${STATUS_BADGE[alloc.status] ?? "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400"}`}
>
{alloc.status} {alloc.status}
</span> </span>
</td> </td>
); );
default: default:
return <td key={col.key} className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400"></td>; return (
<td key={col.key} className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
</td>
);
} }
})} })}
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<button type="button" onClick={() => openEdit(alloc)} className="app-action-edit">Edit</button> <button type="button" onClick={() => openEdit(alloc)} className="app-action-edit">
Edit
</button>
<button <button
type="button" type="button"
onClick={() => setConfirmDelete({ single: alloc })} onClick={() => setConfirmDelete({ single: alloc })}
@@ -569,7 +699,11 @@ export function AllocationsClient() {
return ( return (
<div className="app-page space-y-5 pb-24"> <div className="app-page space-y-5 pb-24">
<SuccessToast show={showStatusToast} message="Allocation status updated" onDone={() => setShowStatusToast(false)} /> <SuccessToast
show={showStatusToast}
message="Allocation status updated"
onDone={() => setShowStatusToast(false)}
/>
<div className="app-page-header gap-4"> <div className="app-page-header gap-4">
<div> <div>
<h1 className="app-page-title">Allocations</h1> <h1 className="app-page-title">Allocations</h1>
@@ -578,7 +712,7 @@ export function AllocationsClient() {
? "Loading…" ? "Loading…"
: allocationQueryFailure : allocationQueryFailure
? allocationQueryFailure.title ? allocationQueryFailure.title
: `${filteredAllocations.length} assignment${filteredAllocations.length !== 1 ? "s" : ""}${filteredDemands.length > 0 ? ` · ${filteredDemands.length} open demand${filteredDemands.length !== 1 ? "s" : ""}` : ""}`} : `${filteredAllocations.length} assignment${filteredAllocations.length !== 1 ? "s" : ""}${filteredDemands.length > 0 ? ` · ${filteredDemands.length} open demand${filteredDemands.length !== 1 ? "s" : ""}` : ""}`}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -629,7 +763,9 @@ export function AllocationsClient() {
> >
<option value="">All Statuses</option> <option value="">All Statuses</option>
{ALL_ALLOC_STATUSES.map((s) => ( {ALL_ALLOC_STATUSES.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option> <option key={s.value} value={s.value}>
{s.label}
</option>
))} ))}
</select> </select>
@@ -678,7 +814,12 @@ export function AllocationsClient() {
className={`px-2.5 py-2 text-sm transition-colors ${viewMode === "grouped" ? "bg-brand-600 text-white" : "bg-white text-gray-600 hover:bg-gray-50 dark:bg-gray-900 dark:text-gray-300 dark:hover:bg-gray-800"}`} className={`px-2.5 py-2 text-sm transition-colors ${viewMode === "grouped" ? "bg-brand-600 text-white" : "bg-white text-gray-600 hover:bg-gray-50 dark:bg-gray-900 dark:text-gray-300 dark:hover:bg-gray-800"}`}
> >
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg> </svg>
</button> </button>
<button <button
@@ -688,7 +829,12 @@ export function AllocationsClient() {
className={`px-2.5 py-2 text-sm transition-colors ${viewMode === "flat" ? "bg-brand-600 text-white" : "bg-white text-gray-600 hover:bg-gray-50 dark:bg-gray-900 dark:text-gray-300 dark:hover:bg-gray-800"}`} className={`px-2.5 py-2 text-sm transition-colors ${viewMode === "flat" ? "bg-brand-600 text-white" : "bg-white text-gray-600 hover:bg-gray-50 dark:bg-gray-900 dark:text-gray-300 dark:hover:bg-gray-800"}`}
> >
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 10h16M4 14h16M4 18h16"
/>
</svg> </svg>
</button> </button>
</div> </div>
@@ -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" 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"
> >
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg> </svg>
Export Export
</button> </button>
@@ -709,11 +860,19 @@ export function AllocationsClient() {
{viewMode === "grouped" && groups.length > 1 && ( {viewMode === "grouped" && groups.length > 1 && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button type="button" onClick={expandAll} className="text-xs text-brand-600 hover:text-brand-800 dark:text-brand-400 dark:hover:text-brand-200 whitespace-nowrap"> <button
type="button"
onClick={expandAll}
className="text-xs text-brand-600 hover:text-brand-800 dark:text-brand-400 dark:hover:text-brand-200 whitespace-nowrap"
>
Expand all Expand all
</button> </button>
<span className="text-gray-300 dark:text-gray-600">|</span> <span className="text-gray-300 dark:text-gray-600">|</span>
<button type="button" onClick={collapseAll} className="text-xs text-brand-600 hover:text-brand-800 dark:text-brand-400 dark:hover:text-brand-200 whitespace-nowrap"> <button
type="button"
onClick={collapseAll}
className="text-xs text-brand-600 hover:text-brand-800 dark:text-brand-400 dark:hover:text-brand-200 whitespace-nowrap"
>
Collapse all Collapse all
</button> </button>
</div> </div>
@@ -744,16 +903,34 @@ export function AllocationsClient() {
</th> </th>
{visibleColumns.map((col) => { {visibleColumns.map((col) => {
const tooltips: Record<string, { tip: string; width?: string }> = { const tooltips: Record<string, { tip: string; width?: string }> = {
resource: { tip: "The person assigned to this time block. Grouped view clusters entries by resource." }, resource: {
project: { tip: "The project this allocation belongs to, identified by short code and name." }, tip: "The person assigned to this time block. Grouped view clusters entries by resource.",
role: { tip: "The role this allocation was created for. May differ from the resource's primary role." }, },
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)." }, dates: { tip: "Start and end date of this allocation period (inclusive)." },
hoursPerDay: { tip: "Planned working hours per calendar day for this allocation." }, hoursPerDay: {
cost: { tip: "Daily cost = resource LCR x hours per day. Total cost = daily cost x working days.", width: "w-72" }, tip: "Planned working hours per calendar day for this allocation.",
status: { tip: "PROPOSED = requested · CONFIRMED = approved · ACTIVE = ongoing · COMPLETED = finished · CANCELLED = removed.", width: "w-72" }, },
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 t = tooltips[col.key];
const fieldMap: Record<string, string> = { dates: "startDate", hoursPerDay: "hoursPerDay", cost: "dailyCostCents" }; const fieldMap: Record<string, string> = {
dates: "startDate",
hoursPerDay: "hoursPerDay",
cost: "dailyCostCents",
};
return ( return (
<SortableColumnHeader <SortableColumnHeader
key={col.key} key={col.key}
@@ -773,15 +950,28 @@ export function AllocationsClient() {
<tbody className="divide-y divide-gray-100 dark:divide-gray-800"> <tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{isLoading && ( {isLoading && (
<tr> <tr>
<td colSpan={totalColSpan} className="py-12 text-center text-sm text-gray-500 dark:text-gray-400">Loading allocations</td> <td
colSpan={totalColSpan}
className="py-12 text-center text-sm text-gray-500 dark:text-gray-400"
>
Loading allocations
</td>
</tr> </tr>
)} )}
{!isLoading && allocationQueryFailure && ( {!isLoading && allocationQueryFailure && (
<tr> <tr>
<td colSpan={totalColSpan} className="py-12 text-center text-sm text-gray-500 dark:text-gray-400"> <td
<div data-testid={allocationQueryFailure.testId} className="flex flex-col items-center gap-2"> colSpan={totalColSpan}
<p className="font-medium text-gray-700 dark:text-gray-200">{allocationQueryFailure.title}</p> className="py-12 text-center text-sm text-gray-500 dark:text-gray-400"
>
<div
data-testid={allocationQueryFailure.testId}
className="flex flex-col items-center gap-2"
>
<p className="font-medium text-gray-700 dark:text-gray-200">
{allocationQueryFailure.title}
</p>
<p>{allocationQueryFailure.detail}</p> <p>{allocationQueryFailure.detail}</p>
{allocationQueryFailure.actionLabel && allocationQueryFailure.actionHref && ( {allocationQueryFailure.actionLabel && allocationQueryFailure.actionHref && (
<a <a
@@ -817,15 +1007,21 @@ export function AllocationsClient() {
</tr> </tr>
)} )}
{!isLoading && !allocationQueryFailure && viewMode === "flat" && {!isLoading &&
!allocationQueryFailure &&
viewMode === "flat" &&
sorted.map((alloc, index) => renderAllocRow(alloc, false, index))} sorted.map((alloc, index) => renderAllocRow(alloc, false, index))}
{!isLoading && !allocationQueryFailure && viewMode === "grouped" && {!isLoading &&
!allocationQueryFailure &&
viewMode === "grouped" &&
groups.map((group) => { 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 groupAllocIds = group.allocations.map((a) => a.id);
const allGroupSelected = selection.isAllSelected(groupAllocIds); 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 ( return (
<GroupRows key={group.resourceId}> <GroupRows key={group.resourceId}>
{/* Group header */} {/* Group header */}
@@ -833,7 +1029,12 @@ export function AllocationsClient() {
data-testid="allocation-group-header" 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" 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)} 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} tabIndex={0}
role="button" role="button"
aria-expanded={!isCollapsed} aria-expanded={!isCollapsed}
@@ -842,7 +1043,9 @@ export function AllocationsClient() {
<input <input
type="checkbox" type="checkbox"
checked={allGroupSelected} checked={allGroupSelected}
ref={(el) => { if (el) el.indeterminate = groupIndeterminate; }} ref={(el) => {
if (el) el.indeterminate = groupIndeterminate;
}}
onChange={() => selection.toggleAll(groupAllocIds)} onChange={() => selection.toggleAll(groupAllocIds)}
className="rounded border-gray-300 dark:border-gray-600" className="rounded border-gray-300 dark:border-gray-600"
/> />
@@ -872,64 +1075,88 @@ export function AllocationsClient() {
</td> </td>
</tr> </tr>
{/* Project sub-groups within person */} {/* Project sub-groups within person */}
{!isCollapsed && group.projectSubGroups.map((subGroup) => { {!isCollapsed &&
const subKey = `${group.resourceId}::${subGroup.projectId}`; group.projectSubGroups.map((subGroup) => {
const isSubExpanded = expandedSubGroups.has(subKey); const subKey = `${group.resourceId}::${subGroup.projectId}`;
const isSubExpanded = expandedSubGroups.has(subKey);
// Single allocation for this project — render directly, no sub-group header // Single allocation for this project — render directly, no sub-group header
if (subGroup.allocations.length === 1) { if (subGroup.allocations.length === 1) {
return <GroupRows key={subKey}>{renderAllocRow(subGroup.allocations[0]!, true, 0)}</GroupRows>; return (
} <GroupRows key={subKey}>
{renderAllocRow(subGroup.allocations[0]!, true, 0)}
</GroupRows>
);
}
// Multiple allocations — show collapsible project sub-group // Multiple allocations — show collapsible project sub-group
return ( return (
<GroupRows key={subKey}> <GroupRows key={subKey}>
<tr <tr
data-testid="allocation-subgroup-header" data-testid="allocation-subgroup-header"
className="bg-gray-25 dark:bg-gray-850/30 cursor-pointer select-none hover:bg-gray-100/60 dark:hover:bg-gray-800/40 transition-colors" className="bg-gray-25 dark:bg-gray-850/30 cursor-pointer select-none hover:bg-gray-100/60 dark:hover:bg-gray-800/40 transition-colors"
onClick={() => toggleSubGroup(group.resourceId, subGroup.projectId)} onClick={() => toggleSubGroup(group.resourceId, subGroup.projectId)}
tabIndex={0} tabIndex={0}
role="button" role="button"
aria-expanded={isSubExpanded} aria-expanded={isSubExpanded}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleSubGroup(group.resourceId, subGroup.projectId); } }} onKeyDown={(e) => {
> if (e.key === "Enter" || e.key === " ") {
<td className="px-4 py-2" onClick={(e) => e.stopPropagation()}> e.preventDefault();
<input toggleSubGroup(group.resourceId, subGroup.projectId);
type="checkbox" }
checked={selection.isAllSelected(subGroup.allocations.map((a) => a.id))} }}
ref={(el) => { >
if (el) { <td className="px-4 py-2" onClick={(e) => e.stopPropagation()}>
const ids = subGroup.allocations.map((a) => a.id); <input
el.indeterminate = !selection.isAllSelected(ids) && ids.some((id) => selection.selectedIds.has(id)); type="checkbox"
checked={selection.isAllSelected(
subGroup.allocations.map((a) => 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))
} }
}} className="rounded border-gray-300 dark:border-gray-600"
onChange={() => selection.toggleAll(subGroup.allocations.map((a) => a.id))} />
className="rounded border-gray-300 dark:border-gray-600" </td>
/> <td colSpan={visibleColumns.length + 1} className="px-4 py-2">
</td> <div className="flex items-center gap-2 pl-4">
<td colSpan={visibleColumns.length + 1} className="px-4 py-2"> <span className="text-gray-400 dark:text-gray-500 text-xs">
<div className="flex items-center gap-2 pl-4"> {isSubExpanded ? "▾" : "▸"}
<span className="text-gray-400 dark:text-gray-500 text-xs"> </span>
{isSubExpanded ? "▾" : "▸"} <span className="font-mono text-xs text-gray-400 dark:text-gray-500">
</span> {subGroup.projectCode}
<span className="font-mono text-xs text-gray-400 dark:text-gray-500">{subGroup.projectCode}</span> </span>
<span className="text-sm text-gray-700 dark:text-gray-300">{subGroup.projectName}</span> <span className="text-sm text-gray-700 dark:text-gray-300">
<span className="text-xs text-gray-400 dark:text-gray-500"> {subGroup.projectName}
{formatDate(subGroup.earliestStart)} {formatDate(subGroup.latestEnd)} </span>
</span> <span className="text-xs text-gray-400 dark:text-gray-500">
<span className="text-xs text-gray-500 dark:text-gray-400"> {formatDate(subGroup.earliestStart)} {" "}
{subGroup.typicalHoursPerDay}h/day {formatDate(subGroup.latestEnd)}
</span> </span>
<span className="inline-flex items-center rounded-full bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-gray-700 dark:text-gray-400"> <span className="text-xs text-gray-500 dark:text-gray-400">
{subGroup.allocations.length} {subGroup.typicalHoursPerDay}h/day
</span> </span>
</div> <span className="inline-flex items-center rounded-full bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-gray-700 dark:text-gray-400">
</td> {subGroup.allocations.length}
</tr> </span>
{isSubExpanded && subGroup.allocations.map((alloc, idx) => renderAllocRow(alloc, true, idx))} </div>
</GroupRows> </td>
); </tr>
})} {isSubExpanded &&
subGroup.allocations.map((alloc, idx) =>
renderAllocRow(alloc, true, idx),
)}
</GroupRows>
);
})}
</GroupRows> </GroupRows>
); );
})} })}
@@ -941,7 +1168,9 @@ export function AllocationsClient() {
<div className="app-surface mt-6 overflow-hidden border-amber-200 dark:border-amber-900/70"> <div className="app-surface mt-6 overflow-hidden border-amber-200 dark:border-amber-900/70">
<div className="flex items-center justify-between border-b border-amber-200 bg-amber-50/70 px-4 py-3 dark:border-amber-900/70 dark:bg-amber-950/20"> <div className="flex items-center justify-between border-b border-amber-200 bg-amber-50/70 px-4 py-3 dark:border-amber-900/70 dark:bg-amber-950/20">
<div> <div>
<h2 className="text-sm font-semibold text-amber-900 dark:text-amber-200">Open Demands</h2> <h2 className="text-sm font-semibold text-amber-900 dark:text-amber-200">
Open Demands
</h2>
<p className="text-xs text-amber-700 dark:text-amber-300/80"> <p className="text-xs text-amber-700 dark:text-amber-300/80">
Placeholder demand rows not yet assigned to a resource. Placeholder demand rows not yet assigned to a resource.
</p> </p>
@@ -959,18 +1188,27 @@ export function AllocationsClient() {
<div className="min-w-0"> <div className="min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate"> <div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{demand.project ? ( {demand.project ? (
<><span className="font-mono text-xs">{demand.project.shortCode}</span> {demand.project.name}</> <>
) : "Unknown project"} <span className="font-mono text-xs">{demand.project.shortCode}</span>{" "}
{demand.project.name}
</>
) : (
"Unknown project"
)}
</div> </div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400"> <div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{(demand.role ?? "Placeholder role")} · {formatPeriod(demand)} · {demand.hoursPerDay}h/day {demand.role ?? "Placeholder role"} · {formatPeriod(demand)} ·{" "}
{demand.hoursPerDay}h/day
</div> </div>
</div> </div>
<div className="flex flex-shrink-0 items-center gap-4"> <div className="flex flex-shrink-0 items-center gap-4">
<div className="text-right"> <div className="text-right">
<div className="text-xs uppercase tracking-wide text-amber-700 dark:text-amber-300">Unfilled</div> <div className="text-xs uppercase tracking-wide text-amber-700 dark:text-amber-300">
Unfilled
</div>
<div className="text-sm font-semibold text-amber-900 dark:text-amber-200"> <div className="text-sm font-semibold text-amber-900 dark:text-amber-200">
{demand.unfilledHeadcount ?? demand.headcount} / {demand.requestedHeadcount ?? demand.headcount} {demand.unfilledHeadcount ?? demand.headcount} /{" "}
{demand.requestedHeadcount ?? demand.headcount}
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -999,9 +1237,17 @@ export function AllocationsClient() {
{/* Batch Status Picker */} {/* Batch Status Picker */}
{batchStatusPicker && ( {batchStatusPicker && (
<div className="fixed inset-0 bg-black/30 z-50 flex items-center justify-center p-4" onClick={() => setBatchStatusPicker(false)}> <div
<div className="min-w-[220px] rounded-2xl bg-white p-5 shadow-2xl dark:bg-gray-900" onClick={(e) => e.stopPropagation()}> className="fixed inset-0 bg-black/30 z-50 flex items-center justify-center p-4"
<h3 className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">Set status for {selection.count} allocations</h3> onClick={() => setBatchStatusPicker(false)}
>
<div
className="min-w-[220px] rounded-2xl bg-white p-5 shadow-2xl dark:bg-gray-900"
onClick={(e) => e.stopPropagation()}
>
<h3 className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">
Set status for {selection.count} allocations
</h3>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{ALL_ALLOC_STATUSES.map((s) => ( {ALL_ALLOC_STATUSES.map((s) => (
<button <button
@@ -1013,7 +1259,9 @@ export function AllocationsClient() {
}} }}
className="w-full rounded-xl px-3 py-2 text-left text-sm transition-colors hover:bg-gray-50 dark:hover:bg-gray-800" className="w-full rounded-xl px-3 py-2 text-left text-sm transition-colors hover:bg-gray-50 dark:hover:bg-gray-800"
> >
<span className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${STATUS_BADGE[s.value]}`}> <span
className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${STATUS_BADGE[s.value]}`}
>
{s.label} {s.label}
</span> </span>
</button> </button>
@@ -1060,7 +1308,10 @@ export function AllocationsClient() {
message={`Set ${confirmBatchStatus.ids.length} allocation${confirmBatchStatus.ids.length !== 1 ? "s" : ""} to "${confirmBatchStatus.status}"?`} message={`Set ${confirmBatchStatus.ids.length} allocation${confirmBatchStatus.ids.length !== 1 ? "s" : ""} to "${confirmBatchStatus.status}"?`}
confirmLabel="Update" confirmLabel="Update"
onConfirm={() => { onConfirm={() => {
batchStatusMutation.mutate({ ids: confirmBatchStatus.ids, status: confirmBatchStatus.status as never }); batchStatusMutation.mutate({
ids: confirmBatchStatus.ids,
status: confirmBatchStatus.status as never,
});
setConfirmBatchStatus(null); setConfirmBatchStatus(null);
}} }}
onCancel={() => setConfirmBatchStatus(null)} onCancel={() => setConfirmBatchStatus(null)}
@@ -1097,7 +1348,11 @@ export function AllocationsClient() {
count={selection.count} count={selection.count}
isPending={batchDateShiftMutation.isPending} isPending={batchDateShiftMutation.isPending}
onConfirm={(daysDelta) => onConfirm={(daysDelta) =>
batchDateShiftMutation.mutate({ allocationIds: selectedMutationIds, daysDelta, mode: "move" }) batchDateShiftMutation.mutate({
allocationIds: selectedMutationIds,
daysDelta,
mode: "move",
})
} }
onClose={() => setShowDateShiftModal(false)} onClose={() => setShowDateShiftModal(false)}
/> />
@@ -1105,7 +1360,11 @@ export function AllocationsClient() {
{/* Modal */} {/* Modal */}
{modalOpen && ( {modalOpen && (
<AllocationModal allocation={editingAllocation} onClose={closeModal} onSuccess={closeModal} /> <AllocationModal
allocation={editingAllocation}
onClose={closeModal}
onSuccess={closeModal}
/>
)} )}
</div> </div>
); );
@@ -2,7 +2,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import type { FormEvent } 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 type { BlueprintFieldDefinition } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { BlueprintFieldCatalog } from "./BlueprintFieldCatalog.js"; import { BlueprintFieldCatalog } from "./BlueprintFieldCatalog.js";
@@ -68,27 +68,59 @@ function NewBlueprintModal({ onClose, onCreated }: NewBlueprintModalProps) {
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md mx-4"> <div className="bg-white rounded-xl shadow-2xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200"> <div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">New Blueprint</h2> <h2 className="text-lg font-semibold text-gray-900">New Blueprint</h2>
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl leading-none" aria-label="Close">×</button> <button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-2xl leading-none"
aria-label="Close"
>
×
</button>
</div> </div>
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4"> <form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Name <span className="text-red-500">*</span></label> <label className="text-sm font-medium text-gray-700">
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Resource Extended Fields" className="app-input" autoFocus /> Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Resource Extended Fields"
className="app-input"
autoFocus
/>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Description</label> <label className="text-sm font-medium text-gray-700">Description</label>
<textarea value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Optional description" className="app-input resize-none" rows={2} /> <textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional description"
className="app-input resize-none"
rows={2}
/>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Target</label> <label className="text-sm font-medium text-gray-700">Target</label>
<select value={target} onChange={(e) => setTarget(e.target.value as BlueprintTargetValue)} className="app-input"> <select
value={target}
onChange={(e) => setTarget(e.target.value as BlueprintTargetValue)}
className="app-input"
>
<option value="RESOURCE">Resource</option> <option value="RESOURCE">Resource</option>
<option value="PROJECT">Project</option> <option value="PROJECT">Project</option>
</select> </select>
</div> </div>
{error && <p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">{error}</p>} {error && (
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">
{error}
</p>
)}
<div className="flex items-center justify-end gap-3 pt-2"> <div className="flex items-center justify-end gap-3 pt-2">
<button type="button" onClick={onClose} className={BTN_SECONDARY}>Cancel</button> <button type="button" onClick={onClose} className={BTN_SECONDARY}>
Cancel
</button>
<button type="submit" disabled={createMutation.isPending} className={BTN_PRIMARY}> <button type="submit" disabled={createMutation.isPending} className={BTN_PRIMARY}>
{createMutation.isPending ? "Creating…" : "Create Blueprint"} {createMutation.isPending ? "Creating…" : "Create Blueprint"}
</button> </button>
@@ -128,14 +160,20 @@ function BlueprintCard({
isSelected, isSelected,
onToggleSelect, onToggleSelect,
}: BlueprintCardProps) { }: BlueprintCardProps) {
const fieldDefs = Array.isArray(blueprint.fieldDefs) ? (blueprint.fieldDefs as BlueprintFieldDefinition[]) : []; const fieldDefs = Array.isArray(blueprint.fieldDefs)
const rolePresets = Array.isArray(blueprint.rolePresets) ? (blueprint.rolePresets as unknown[]) : []; ? (blueprint.fieldDefs as BlueprintFieldDefinition[])
: [];
const rolePresets = Array.isArray(blueprint.rolePresets)
? (blueprint.rolePresets as unknown[])
: [];
const fieldCount = fieldDefs.length; const fieldCount = fieldDefs.length;
const presetCount = rolePresets.length; const presetCount = rolePresets.length;
const isProject = blueprint.target === "PROJECT"; const isProject = blueprint.target === "PROJECT";
return ( return (
<div className={`bg-white rounded-xl border p-5 flex flex-col gap-3 hover:shadow-sm transition-shadow ${isSelected ? "border-brand-400 bg-brand-50" : "border-gray-200"}`}> <div
className={`bg-white rounded-xl border p-5 flex flex-col gap-3 hover:shadow-sm transition-shadow ${isSelected ? "border-brand-400 bg-brand-50" : "border-gray-200"}`}
>
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 flex-1 min-w-0"> <div className="flex items-start gap-3 flex-1 min-w-0">
<input <input
@@ -151,29 +189,45 @@ function BlueprintCard({
)} )}
</div> </div>
</div> </div>
<span className={`shrink-0 inline-block px-2 py-0.5 text-xs rounded-full font-medium ${isProject ? "bg-purple-50 text-purple-700" : "bg-blue-50 text-blue-700"}`}> <span
className={`shrink-0 inline-block px-2 py-0.5 text-xs rounded-full font-medium ${isProject ? "bg-purple-50 text-purple-700" : "bg-blue-50 text-blue-700"}`}
>
{blueprint.target} {blueprint.target}
</span> </span>
</div> </div>
<div className="flex flex-wrap gap-3 text-sm text-gray-500"> <div className="flex flex-wrap gap-3 text-sm text-gray-500">
<span>{fieldCount === 0 ? "No fields" : `${fieldCount} field${fieldCount === 1 ? "" : "s"}`}</span> <span>
{fieldCount === 0 ? "No fields" : `${fieldCount} field${fieldCount === 1 ? "" : "s"}`}
</span>
{isProject && ( {isProject && (
<span className={presetCount > 0 ? "text-brand-600 font-medium" : ""}> <span className={presetCount > 0 ? "text-brand-600 font-medium" : ""}>
{presetCount === 0 ? "No staffing presets" : `${presetCount} staffing preset${presetCount === 1 ? "" : "s"}`} {presetCount === 0
? "No staffing presets"
: `${presetCount} staffing preset${presetCount === 1 ? "" : "s"}`}
</span> </span>
)} )}
{blueprint.isGlobal && ( {blueprint.isGlobal && (
<span className="inline-block px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-700 font-medium">Global</span> <span className="inline-block px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-700 font-medium">
Global
</span>
)} )}
</div> </div>
<div className="flex flex-wrap items-center gap-2 pt-1 border-t border-gray-100"> <div className="flex flex-wrap items-center gap-2 pt-1 border-t border-gray-100">
<button type="button" onClick={onEditFields} className="px-3 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium transition-colors"> <button
type="button"
onClick={onEditFields}
className="px-3 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium transition-colors"
>
Edit Fields Edit Fields
</button> </button>
{isProject && ( {isProject && (
<button type="button" onClick={onEditStaffing} className="px-3 py-1.5 border border-brand-300 text-brand-700 rounded-lg hover:bg-brand-50 text-sm font-medium transition-colors"> <button
type="button"
onClick={onEditStaffing}
className="px-3 py-1.5 border border-brand-300 text-brand-700 rounded-lg hover:bg-brand-50 text-sm font-medium transition-colors"
>
Edit Staffing Presets Edit Staffing Presets
</button> </button>
)} )}
@@ -185,7 +239,11 @@ function BlueprintCard({
? "border border-amber-300 text-amber-700 hover:bg-amber-50" ? "border border-amber-300 text-amber-700 hover:bg-amber-50"
: "border border-gray-200 text-gray-500 hover:bg-gray-50" : "border border-gray-200 text-gray-500 hover:bg-gray-50"
}`} }`}
title={blueprint.isGlobal ? "Remove from global columns" : "Make fields available as global columns"} title={
blueprint.isGlobal
? "Remove from global columns"
: "Make fields available as global columns"
}
> >
{blueprint.isGlobal ? "Unglobalize" : "Make Global"} {blueprint.isGlobal ? "Unglobalize" : "Make Global"}
</button> </button>
@@ -227,11 +285,16 @@ export function BlueprintsClient() {
useEffect(() => { useEffect(() => {
selection.clear(); selection.clear();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [targetFilter]); }, [targetFilter]);
const blueprints: BlueprintRow[] = data ?? []; const blueprints: BlueprintRow[] = data ?? [];
const { sorted: sortedBlueprints, sortField, sortDir, toggle } = useTableSort<BlueprintRow, BlueprintSortField>(blueprints, { const {
sorted: sortedBlueprints,
sortField,
sortDir,
toggle,
} = useTableSort<BlueprintRow, BlueprintSortField>(blueprints, {
initialField: (viewPrefs.savedSort?.field as BlueprintSortField | undefined) ?? null, initialField: (viewPrefs.savedSort?.field as BlueprintSortField | undefined) ?? null,
initialDir: viewPrefs.savedSort?.dir ?? null, initialDir: viewPrefs.savedSort?.dir ?? null,
onSortChange: (field, dir) => { onSortChange: (field, dir) => {
@@ -287,17 +350,16 @@ export function BlueprintsClient() {
<div className="flex items-start justify-between mb-6 gap-4"> <div className="flex items-start justify-between mb-6 gap-4">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">Blueprints</h1> <h1 className="text-2xl font-bold text-gray-900">Blueprints</h1>
<p className="text-gray-500 text-sm mt-1">Configure dynamic fields for resources and projects</p> <p className="text-gray-500 text-sm mt-1">
Configure dynamic fields for resources and projects
</p>
</div> </div>
<button type="button" onClick={() => setShowNewModal(true)} className={BTN_PRIMARY}> <button type="button" onClick={() => setShowNewModal(true)} className={BTN_PRIMARY}>
+ New Blueprint + New Blueprint
</button> </button>
</div> </div>
<FilterBar <FilterBar hasActiveFilters={!!targetFilter} onClearFilters={() => setTargetFilter("")}>
hasActiveFilters={!!targetFilter}
onClearFilters={() => setTargetFilter("")}
>
<select <select
value={targetFilter} value={targetFilter}
onChange={(e) => setTargetFilter(e.target.value)} onChange={(e) => setTargetFilter(e.target.value)}
@@ -312,7 +374,10 @@ export function BlueprintsClient() {
{isLoading && ( {isLoading && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[1, 2, 3].map((i) => ( {[1, 2, 3].map((i) => (
<div key={i} className="bg-white rounded-xl border border-gray-200 p-5 shimmer-skeleton h-36" /> <div
key={i}
className="bg-white rounded-xl border border-gray-200 p-5 shimmer-skeleton h-36"
/>
))} ))}
</div> </div>
)} )}
@@ -325,7 +390,9 @@ export function BlueprintsClient() {
{!isLoading && !isError && blueprints.length === 0 && ( {!isLoading && !isError && blueprints.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-center"> <div className="flex flex-col items-center justify-center py-20 text-center">
<p className="text-gray-400 text-sm mb-4">No blueprints yet. Create one to start defining dynamic fields.</p> <p className="text-gray-400 text-sm mb-4">
No blueprints yet. Create one to start defining dynamic fields.
</p>
<button type="button" onClick={() => setShowNewModal(true)} className={BTN_PRIMARY}> <button type="button" onClick={() => setShowNewModal(true)} className={BTN_PRIMARY}>
+ New Blueprint + New Blueprint
</button> </button>
@@ -347,12 +414,52 @@ export function BlueprintsClient() {
aria-label="Select all blueprints" aria-label="Select all blueprints"
/> />
</th> </th>
<SortableColumnHeader label="Name" field="name" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} tooltip="Blueprint name. Defines a template of dynamic fields for resources or projects." /> <SortableColumnHeader
<SortableColumnHeader label="Target" field="target" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} tooltip="Whether this blueprint applies to Resource or Project entities." /> label="Name"
<SortableColumnHeader label="Fields" field="fieldCount" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} align="center" tooltip="Number of custom dynamic fields defined in this blueprint." /> field="name"
<SortableColumnHeader label="Staffing Presets" field="presetCount" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} align="center" tooltip="Role presets for project staffing demands. Only applicable to PROJECT blueprints." /> sortField={sortField}
<SortableColumnHeader label="Global" field="global" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} align="center" tooltip="Global blueprints expose their fields as columns across all entities of the target type." /> sortDir={sortDir}
<th className="px-3 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th> onSort={handleSortRequest}
tooltip="Blueprint name. Defines a template of dynamic fields for resources or projects."
/>
<SortableColumnHeader
label="Target"
field="target"
sortField={sortField}
sortDir={sortDir}
onSort={handleSortRequest}
tooltip="Whether this blueprint applies to Resource or Project entities."
/>
<SortableColumnHeader
label="Fields"
field="fieldCount"
sortField={sortField}
sortDir={sortDir}
onSort={handleSortRequest}
align="center"
tooltip="Number of custom dynamic fields defined in this blueprint."
/>
<SortableColumnHeader
label="Staffing Presets"
field="presetCount"
sortField={sortField}
sortDir={sortDir}
onSort={handleSortRequest}
align="center"
tooltip="Role presets for project staffing demands. Only applicable to PROJECT blueprints."
/>
<SortableColumnHeader
label="Global"
field="global"
sortField={sortField}
sortDir={sortDir}
onSort={handleSortRequest}
align="center"
tooltip="Global blueprints expose their fields as columns across all entities of the target type."
/>
<th className="px-3 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -362,7 +469,10 @@ export function BlueprintsClient() {
const isProject = bp.target === "PROJECT"; const isProject = bp.target === "PROJECT";
return ( return (
<tr key={bp.id} className="border-b border-gray-100 dark:border-gray-700/50 last:border-b-0 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"> <tr
key={bp.id}
className="border-b border-gray-100 dark:border-gray-700/50 last:border-b-0 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
>
<td className="px-3 py-3"> <td className="px-3 py-3">
<input <input
type="checkbox" type="checkbox"
@@ -373,17 +483,29 @@ export function BlueprintsClient() {
</td> </td>
<td className="px-3 py-3"> <td className="px-3 py-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="font-medium text-gray-900 dark:text-gray-100">{bp.name}</div> <div className="font-medium text-gray-900 dark:text-gray-100">
{bp.description && <div className="text-xs text-gray-500 mt-0.5 truncate">{bp.description}</div>} {bp.name}
</div>
{bp.description && (
<div className="text-xs text-gray-500 mt-0.5 truncate">
{bp.description}
</div>
)}
</div> </div>
</td> </td>
<td className="px-3 py-3"> <td className="px-3 py-3">
<span className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${isProject ? "bg-purple-50 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300" : "bg-blue-50 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300"}`}> <span
className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${isProject ? "bg-purple-50 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300" : "bg-blue-50 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300"}`}
>
{bp.target} {bp.target}
</span> </span>
</td> </td>
<td className="px-3 py-3 text-center text-gray-600 dark:text-gray-400">{fieldCount}</td> <td className="px-3 py-3 text-center text-gray-600 dark:text-gray-400">
<td className="px-3 py-3 text-center text-gray-600 dark:text-gray-400">{isProject ? presetCount : "—"}</td> {fieldCount}
</td>
<td className="px-3 py-3 text-center text-gray-600 dark:text-gray-400">
{isProject ? presetCount : "—"}
</td>
<td className="px-3 py-3 text-center"> <td className="px-3 py-3 text-center">
{bp.isGlobal ? ( {bp.isGlobal ? (
<span className="inline-block px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300 font-medium"> <span className="inline-block px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300 font-medium">
@@ -397,7 +519,10 @@ export function BlueprintsClient() {
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<button <button
type="button" type="button"
onClick={() => { setEditingTab("fields"); setEditingBlueprint(bp); }} onClick={() => {
setEditingTab("fields");
setEditingBlueprint(bp);
}}
className="text-xs text-brand-600 hover:text-brand-800 font-medium" className="text-xs text-brand-600 hover:text-brand-800 font-medium"
> >
Edit Fields Edit Fields
@@ -405,7 +530,10 @@ export function BlueprintsClient() {
{isProject && ( {isProject && (
<button <button
type="button" type="button"
onClick={() => { setEditingTab("presets"); setEditingBlueprint(bp); }} onClick={() => {
setEditingTab("presets");
setEditingBlueprint(bp);
}}
className="text-xs text-brand-600 hover:text-brand-800 font-medium" className="text-xs text-brand-600 hover:text-brand-800 font-medium"
> >
Presets Presets
@@ -445,8 +573,14 @@ export function BlueprintsClient() {
<BlueprintCard <BlueprintCard
key={bp.id} key={bp.id}
blueprint={bp} blueprint={bp}
onEditFields={() => { setEditingTab("fields"); setEditingBlueprint(bp); }} onEditFields={() => {
onEditStaffing={() => { setEditingTab("presets"); setEditingBlueprint(bp); }} setEditingTab("fields");
setEditingBlueprint(bp);
}}
onEditStaffing={() => {
setEditingTab("presets");
setEditingBlueprint(bp);
}}
onToggleGlobal={() => handleToggleGlobal(bp.id, bp.isGlobal)} onToggleGlobal={() => handleToggleGlobal(bp.id, bp.isGlobal)}
onDelete={() => handleDelete(bp.id)} onDelete={() => handleDelete(bp.id)}
isSelected={selection.selectedIds.has(bp.id)} isSelected={selection.selectedIds.has(bp.id)}
@@ -485,7 +619,10 @@ export function BlueprintsClient() {
)} )}
{showNewModal && ( {showNewModal && (
<NewBlueprintModal onClose={() => setShowNewModal(false)} onCreated={() => setShowNewModal(false)} /> <NewBlueprintModal
onClose={() => setShowNewModal(false)}
onCreated={() => setShowNewModal(false)}
/>
)} )}
{editingBlueprint && ( {editingBlueprint && (
@@ -493,8 +630,16 @@ export function BlueprintsClient() {
blueprintId={editingBlueprint.id} blueprintId={editingBlueprint.id}
blueprintName={editingBlueprint.name} blueprintName={editingBlueprint.name}
blueprintTarget={editingBlueprint.target} blueprintTarget={editingBlueprint.target}
initialFieldDefs={Array.isArray(editingBlueprint.fieldDefs) ? (editingBlueprint.fieldDefs as BlueprintFieldDefinition[]) : []} initialFieldDefs={
initialRolePresets={Array.isArray(editingBlueprint.rolePresets) ? (editingBlueprint.rolePresets as import("@capakraken/shared").StaffingRequirement[]) : []} Array.isArray(editingBlueprint.fieldDefs)
? (editingBlueprint.fieldDefs as BlueprintFieldDefinition[])
: []
}
initialRolePresets={
Array.isArray(editingBlueprint.rolePresets)
? (editingBlueprint.rolePresets as import("@capakraken/shared").StaffingRequirement[])
: []
}
initialTab={editingTab} initialTab={editingTab}
onClose={() => setEditingBlueprint(null)} onClose={() => setEditingBlueprint(null)}
/> />
@@ -6,7 +6,6 @@ import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js"; import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { TaskCard } from "~/components/notifications/TaskCard.js"; import { TaskCard } from "~/components/notifications/TaskCard.js";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function TaskWidget(_props: Partial<WidgetProps> = {}) { export function TaskWidget(_props: Partial<WidgetProps> = {}) {
const utils = trpc.useUtils(); const utils = trpc.useUtils();
@@ -15,10 +14,13 @@ export function TaskWidget(_props: Partial<WidgetProps> = {}) {
{ staleTime: 30_000, placeholderData: (prev) => prev }, { staleTime: 30_000, placeholderData: (prev) => prev },
); );
const { data: taskCounts, isLoading: loadingCounts } = trpc.notification.taskCounts.useQuery(undefined, { const { data: taskCounts, isLoading: loadingCounts } = trpc.notification.taskCounts.useQuery(
staleTime: 30_000, undefined,
placeholderData: (prev) => prev, {
}); staleTime: 30_000,
placeholderData: (prev) => prev,
},
);
const updateTaskStatus = trpc.notification.updateTaskStatus.useMutation({ const updateTaskStatus = trpc.notification.updateTaskStatus.useMutation({
onSuccess: () => { onSuccess: () => {
@@ -30,7 +32,10 @@ export function TaskWidget(_props: Partial<WidgetProps> = {}) {
}); });
function handleStatusChange(id: string, status: string) { function handleStatusChange(id: string, status: string) {
updateTaskStatus.mutate({ id, status: status as "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED" }); updateTaskStatus.mutate({
id,
status: status as "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED",
});
} }
const openCount = (taskCounts?.open ?? 0) + (taskCounts?.inProgress ?? 0); const openCount = (taskCounts?.open ?? 0) + (taskCounts?.inProgress ?? 0);
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { EstimateExportFormat } from "@capakraken/shared"; import type { EstimateExportFormat } from "@capakraken/shared";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import type { import type {
@@ -23,52 +23,80 @@ const TabSkeleton = () => (
); );
const EstimateWorkspaceDraftEditor = dynamic( const EstimateWorkspaceDraftEditor = dynamic(
() => import("~/components/estimates/EstimateWorkspaceDraftEditor.js").then((mod) => ({ default: mod.EstimateWorkspaceDraftEditor })), () =>
import("~/components/estimates/EstimateWorkspaceDraftEditor.js").then((mod) => ({
default: mod.EstimateWorkspaceDraftEditor,
})),
{ loading: TabSkeleton }, { loading: TabSkeleton },
); );
const WeeklyPhasingView = dynamic( const WeeklyPhasingView = dynamic(
() => import("~/components/estimates/WeeklyPhasingView.js").then((mod) => ({ default: mod.WeeklyPhasingView })), () =>
import("~/components/estimates/WeeklyPhasingView.js").then((mod) => ({
default: mod.WeeklyPhasingView,
})),
{ loading: TabSkeleton }, { loading: TabSkeleton },
); );
const OverviewTab = dynamic( const OverviewTab = dynamic(
() => import("~/components/estimates/tabs/OverviewTab.js").then((mod) => ({ default: mod.OverviewTab })), () =>
import("~/components/estimates/tabs/OverviewTab.js").then((mod) => ({
default: mod.OverviewTab,
})),
{ loading: TabSkeleton }, { loading: TabSkeleton },
); );
const AssumptionsTab = dynamic( const AssumptionsTab = dynamic(
() => import("~/components/estimates/tabs/AssumptionsTab.js").then((mod) => ({ default: mod.AssumptionsTab })), () =>
import("~/components/estimates/tabs/AssumptionsTab.js").then((mod) => ({
default: mod.AssumptionsTab,
})),
{ loading: TabSkeleton }, { loading: TabSkeleton },
); );
const ScopeTab = dynamic( const ScopeTab = dynamic(
() => import("~/components/estimates/tabs/ScopeTab.js").then((mod) => ({ default: mod.ScopeTab })), () =>
import("~/components/estimates/tabs/ScopeTab.js").then((mod) => ({ default: mod.ScopeTab })),
{ loading: TabSkeleton }, { loading: TabSkeleton },
); );
const StaffingTab = dynamic( const StaffingTab = dynamic(
() => import("~/components/estimates/tabs/StaffingTab.js").then((mod) => ({ default: mod.StaffingTab })), () =>
import("~/components/estimates/tabs/StaffingTab.js").then((mod) => ({
default: mod.StaffingTab,
})),
{ loading: TabSkeleton }, { loading: TabSkeleton },
); );
const FinancialsTab = dynamic( const FinancialsTab = dynamic(
() => import("~/components/estimates/tabs/FinancialsTab.js").then((mod) => ({ default: mod.FinancialsTab })), () =>
import("~/components/estimates/tabs/FinancialsTab.js").then((mod) => ({
default: mod.FinancialsTab,
})),
{ loading: TabSkeleton }, { loading: TabSkeleton },
); );
const VersionsTab = dynamic( const VersionsTab = dynamic(
() => import("~/components/estimates/tabs/VersionsTab.js").then((mod) => ({ default: mod.VersionsTab })), () =>
import("~/components/estimates/tabs/VersionsTab.js").then((mod) => ({
default: mod.VersionsTab,
})),
{ loading: TabSkeleton }, { loading: TabSkeleton },
); );
const ExportsTab = dynamic( const ExportsTab = dynamic(
() => import("~/components/estimates/tabs/ExportsTab.js").then((mod) => ({ default: mod.ExportsTab })), () =>
import("~/components/estimates/tabs/ExportsTab.js").then((mod) => ({
default: mod.ExportsTab,
})),
{ loading: TabSkeleton }, { loading: TabSkeleton },
); );
const CommentThread = dynamic( const CommentThread = dynamic(
() => import("~/components/comments/CommentThread.js").then((mod) => ({ default: mod.CommentThread })), () =>
import("~/components/comments/CommentThread.js").then((mod) => ({
default: mod.CommentThread,
})),
{ loading: TabSkeleton }, { loading: TabSkeleton },
); );
@@ -137,20 +165,22 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
const createPlanningHandoffMutation = trpc.estimate.createPlanningHandoff.useMutation(); const createPlanningHandoffMutation = trpc.estimate.createPlanningHandoff.useMutation();
const estimateCommentTarget = { entityType: "estimate" as const, entityId: estimateId }; const estimateCommentTarget = { entityType: "estimate" as const, entityId: estimateId };
const canLoadCommentCount = const canLoadCommentCount =
canViewCosts canViewCosts &&
&& !isPermissionsLoading !isPermissionsLoading &&
&& detailQuery.status === "success" detailQuery.status === "success" &&
&& detailQuery.data != null; detailQuery.data != null;
const commentCountQuery = trpc.comment.count.useQuery( const commentCountQuery = trpc.comment.count.useQuery(estimateCommentTarget, {
estimateCommentTarget, enabled: canLoadCommentCount,
{ enabled: canLoadCommentCount, staleTime: 30_000 }, staleTime: 30_000,
); });
const commentCount = commentCountQuery.data ?? 0; const commentCount = commentCountQuery.data ?? 0;
const estimate = (detailQuery.data as EstimateWorkspaceView | undefined) ?? null; const estimate = (detailQuery.data as EstimateWorkspaceView | undefined) ?? null;
const hasWorkingVersion = estimate?.versions.some((version) => version.status === "WORKING") ?? false; const hasWorkingVersion =
const editableTab = tab === "overview" || tab === "assumptions" || tab === "scope" || tab === "staffing"; estimate?.versions.some((version) => version.status === "WORKING") ?? false;
const editableTab =
tab === "overview" || tab === "assumptions" || tab === "scope" || tab === "staffing";
useEffect(() => { useEffect(() => {
setIsEditing(false); setIsEditing(false);
@@ -258,19 +288,27 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
<div className="rounded-[28px] border border-gray-200 dark:border-gray-700 bg-gradient-to-br from-white via-white to-brand-50 dark:from-gray-900 dark:via-gray-900 dark:to-gray-900 p-6 shadow-sm dark:shadow-black/20"> <div className="rounded-[28px] border border-gray-200 dark:border-gray-700 bg-gradient-to-br from-white via-white to-brand-50 dark:from-gray-900 dark:via-gray-900 dark:to-gray-900 p-6 shadow-sm dark:shadow-black/20">
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600 dark:text-sky-400">Estimate Workspace <InfoTooltip content="Central workspace for inspecting and editing an estimate's scope, staffing, financials, and version history." /></p> <p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600 dark:text-sky-400">
Estimate Workspace{" "}
<InfoTooltip content="Central workspace for inspecting and editing an estimate's scope, staffing, financials, and version history." />
</p>
<h1 className="mt-2 text-3xl font-semibold text-gray-900 dark:text-gray-50"> <h1 className="mt-2 text-3xl font-semibold text-gray-900 dark:text-gray-50">
{estimate?.name ?? "Loading estimate"} {estimate?.name ?? "Loading estimate"}
</h1> </h1>
<p className="mt-2 max-w-3xl text-sm text-gray-600 dark:text-gray-300"> <p className="mt-2 max-w-3xl text-sm text-gray-600 dark:text-gray-300">
Use the tabs below to inspect the connected estimate structure, version context, and staffing breakdown without relying on spreadsheet tabs. Use the tabs below to inspect the connected estimate structure, version context, and
staffing breakdown without relying on spreadsheet tabs.
</p> </p>
</div> </div>
{estimate && ( {estimate && (
<div className="flex flex-col gap-3 lg:items-end"> <div className="flex flex-col gap-3 lg:items-end">
<div className="grid gap-2 text-sm text-gray-500 dark:text-gray-400 lg:text-right"> <div className="grid gap-2 text-sm text-gray-500 dark:text-gray-400 lg:text-right">
<span>{estimate.project ? `${estimate.project.shortCode} - ${estimate.project.name}` : "Standalone estimate"}</span> <span>
{estimate.project
? `${estimate.project.shortCode} - ${estimate.project.name}`
: "Standalone estimate"}
</span>
<span>Updated {formatDateLong(estimate.updatedAt)}</span> <span>Updated {formatDateLong(estimate.updatedAt)}</span>
</div> </div>
{canEdit && hasWorkingVersion && ( {canEdit && hasWorkingVersion && (
@@ -282,7 +320,11 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
}} }}
className="rounded-2xl border border-brand-200 dark:border-sky-700 bg-white dark:bg-gray-800 px-4 py-2 text-sm font-semibold text-brand-700 dark:text-sky-300 transition hover:border-brand-300 dark:hover:border-sky-600 hover:bg-brand-50 dark:hover:bg-gray-700" className="rounded-2xl border border-brand-200 dark:border-sky-700 bg-white dark:bg-gray-800 px-4 py-2 text-sm font-semibold text-brand-700 dark:text-sky-300 transition hover:border-brand-300 dark:hover:border-sky-600 hover:bg-brand-50 dark:hover:bg-gray-700"
> >
{isEditing ? "Close editor" : editableTab ? "Edit working draft" : "Draft editor available in editable tabs"} {isEditing
? "Close editor"
: editableTab
? "Edit working draft"
: "Draft editor available in editable tabs"}
</button> </button>
)} )}
</div> </div>
@@ -293,7 +335,9 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
{isPermissionsLoading ? ( {isPermissionsLoading ? (
<EmptyState>Loading estimate workspace...</EmptyState> <EmptyState>Loading estimate workspace...</EmptyState>
) : !canViewCosts ? ( ) : !canViewCosts ? (
<EmptyState>Your role can access the estimate list, but not the detailed financial workspace.</EmptyState> <EmptyState>
Your role can access the estimate list, but not the detailed financial workspace.
</EmptyState>
) : detailQuery.isLoading ? ( ) : detailQuery.isLoading ? (
<EmptyState>Loading estimate workspace...</EmptyState> <EmptyState>Loading estimate workspace...</EmptyState>
) : detailQuery.error ? ( ) : detailQuery.error ? (
@@ -1,7 +1,7 @@
"use client"; "use client";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { EstimateStatus, EstimateVersionStatus } from "@capakraken/shared"; import type { EstimateStatus, EstimateVersionStatus } from "@capakraken/shared";
import type { import type {
EstimateMetricView, EstimateMetricView,
EstimateVersionView, EstimateVersionView,
@@ -45,7 +45,12 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
<section className="space-y-6"> <section className="space-y-6">
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 shadow-sm"> <div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 shadow-sm">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className={clsx("rounded-full px-3 py-1 text-xs font-semibold", STATUS_STYLES[estimate.status])}> <span
className={clsx(
"rounded-full px-3 py-1 text-xs font-semibold",
STATUS_STYLES[estimate.status],
)}
>
{estimate.status.replace("_", " ")} {estimate.status.replace("_", " ")}
</span> </span>
{estimate.project && ( {estimate.project && (
@@ -57,22 +62,39 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
<div className="mt-5 grid gap-4 md:grid-cols-2"> <div className="mt-5 grid gap-4 md:grid-cols-2">
<div> <div>
<p className="text-xs uppercase tracking-wide text-gray-400">Opportunity <InfoTooltip content="External CRM or sales reference linking this estimate to a sales opportunity." /></p> <p className="text-xs uppercase tracking-wide text-gray-400">
<p className="mt-1 text-sm text-gray-800 dark:text-gray-200">{estimate.opportunityId ?? "Not set"}</p> Opportunity{" "}
</div> <InfoTooltip content="External CRM or sales reference linking this estimate to a sales opportunity." />
<div> </p>
<p className="text-xs uppercase tracking-wide text-gray-400">Base currency <InfoTooltip content="The primary currency for all monetary calculations in this estimate." /></p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-200">{estimate.baseCurrency}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Latest version <InfoTooltip content="The most recent version snapshot. Each version captures a full copy of scope, demand, and financials." /></p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-200"> <p className="mt-1 text-sm text-gray-800 dark:text-gray-200">
{latestVersion ? `v${latestVersion.versionNumber}${latestVersion.label ? ` - ${latestVersion.label}` : ""}` : "No version"} {estimate.opportunityId ?? "Not set"}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">
Base currency{" "}
<InfoTooltip content="The primary currency for all monetary calculations in this estimate." />
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-200">
{estimate.baseCurrency}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">
Latest version{" "}
<InfoTooltip content="The most recent version snapshot. Each version captures a full copy of scope, demand, and financials." />
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-200">
{latestVersion
? `v${latestVersion.versionNumber}${latestVersion.label ? ` - ${latestVersion.label}` : ""}`
: "No version"}
</p> </p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-gray-400">Updated</p> <p className="text-xs uppercase tracking-wide text-gray-400">Updated</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-200">{formatDateLong(estimate.updatedAt)}</p> <p className="mt-1 text-sm text-gray-800 dark:text-gray-200">
{formatDateLong(estimate.updatedAt)}
</p>
</div> </div>
</div> </div>
@@ -87,37 +109,61 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm"> <div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">Scope items <InfoTooltip content="Deliverables or work packages included in this estimate version." /></p> <p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Scope items{" "}
<InfoTooltip content="Deliverables or work packages included in this estimate version." />
</p>
<span className="text-xs text-gray-400">{latestVersion?.scopeItems.length ?? 0}</span> <span className="text-xs text-gray-400">{latestVersion?.scopeItems.length ?? 0}</span>
</div> </div>
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">
{(latestVersion?.scopeItems ?? []).slice(0, 4).map((item) => ( {(latestVersion?.scopeItems ?? []).slice(0, 4).map((item) => (
<div key={item.id} className="rounded-2xl border border-gray-100 dark:border-gray-700/50 px-4 py-3"> <div
key={item.id}
className="rounded-2xl border border-gray-100 dark:border-gray-700/50 px-4 py-3"
>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{item.name}</p> <p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{item.name}
</p>
<span className="text-xs text-gray-400">{item.scopeType}</span> <span className="text-xs text-gray-400">{item.scopeType}</span>
</div> </div>
</div> </div>
))} ))}
{(latestVersion?.scopeItems.length ?? 0) === 0 && <p className="text-sm text-gray-400">No scope rows captured yet.</p>} {(latestVersion?.scopeItems.length ?? 0) === 0 && (
<p className="text-sm text-gray-400">No scope rows captured yet.</p>
)}
</div> </div>
</div> </div>
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm"> <div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">Demand lines <InfoTooltip content="Staffing demand rows with hours, cost rate, and sell rate per role or resource." /></p> <p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
<span className="text-xs text-gray-400">{latestVersion?.demandLines.length ?? 0}</span> Demand lines{" "}
<InfoTooltip content="Staffing demand rows with hours, cost rate, and sell rate per role or resource." />
</p>
<span className="text-xs text-gray-400">
{latestVersion?.demandLines.length ?? 0}
</span>
</div> </div>
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">
{(latestVersion?.demandLines ?? []).slice(0, 4).map((line) => ( {(latestVersion?.demandLines ?? []).slice(0, 4).map((line) => (
<div key={line.id} className="rounded-2xl border border-gray-100 dark:border-gray-700/50 px-4 py-3"> <div
key={line.id}
className="rounded-2xl border border-gray-100 dark:border-gray-700/50 px-4 py-3"
>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{line.name}</p> <p className="text-sm font-medium text-gray-900 dark:text-gray-100">
<span className="text-xs text-gray-500 dark:text-gray-400">{line.hours.toFixed(1)} h</span> {line.name}
</p>
<span className="text-xs text-gray-500 dark:text-gray-400">
{line.hours.toFixed(1)} h
</span>
</div> </div>
</div> </div>
))} ))}
{(latestVersion?.demandLines.length ?? 0) === 0 && <p className="text-sm text-gray-400">No demand lines captured yet.</p>} {(latestVersion?.demandLines.length ?? 0) === 0 && (
<p className="text-sm text-gray-400">No demand lines captured yet.</p>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -125,15 +171,25 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
<aside className="space-y-4"> <aside className="space-y-4">
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm"> <div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">Summary metrics <InfoTooltip content="Key financial indicators derived from the latest version's demand lines." /></p> <p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Summary metrics{" "}
<InfoTooltip content="Key financial indicators derived from the latest version's demand lines." />
</p>
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
{latestMetrics.length === 0 ? ( {latestMetrics.length === 0 ? (
<p className="text-sm text-gray-400">No derived metrics available yet.</p> <p className="text-sm text-gray-400">No derived metrics available yet.</p>
) : ( ) : (
latestMetrics.map((metric) => ( latestMetrics.map((metric) => (
<div key={metric.id} className="flex items-center justify-between gap-3 rounded-2xl bg-gray-50 dark:bg-gray-900 px-4 py-3"> <div
<span className="text-xs uppercase tracking-wide text-gray-400">{metric.label}</span> key={metric.id}
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">{formatMetricValue(metric)}</span> className="flex items-center justify-between gap-3 rounded-2xl bg-gray-50 dark:bg-gray-900 px-4 py-3"
>
<span className="text-xs uppercase tracking-wide text-gray-400">
{metric.label}
</span>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{formatMetricValue(metric)}
</span>
</div> </div>
)) ))
)} )}
@@ -141,27 +197,41 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
</div> </div>
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm"> <div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">Version context <InfoTooltip content="Metadata about the latest version, including its workflow status and linked records." /></p> <p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Version context{" "}
<InfoTooltip content="Metadata about the latest version, including its workflow status and linked records." />
</p>
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
{latestVersion ? ( {latestVersion ? (
<> <>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<span className="text-sm text-gray-500 dark:text-gray-400">Status</span> <span className="text-sm text-gray-500 dark:text-gray-400">Status</span>
<span className={clsx("rounded-full px-2.5 py-1 text-xs font-medium", VERSION_STYLES[latestVersion.status])}> <span
className={clsx(
"rounded-full px-2.5 py-1 text-xs font-medium",
VERSION_STYLES[latestVersion.status],
)}
>
{latestVersion.status} {latestVersion.status}
</span> </span>
</div> </div>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<span className="text-sm text-gray-500 dark:text-gray-400">Assumptions</span> <span className="text-sm text-gray-500 dark:text-gray-400">Assumptions</span>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">{latestVersion.assumptions.length}</span> <span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{latestVersion.assumptions.length}
</span>
</div> </div>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<span className="text-sm text-gray-500 dark:text-gray-400">Snapshots</span> <span className="text-sm text-gray-500 dark:text-gray-400">Snapshots</span>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">{latestVersion.resourceSnapshots.length}</span> <span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{latestVersion.resourceSnapshots.length}
</span>
</div> </div>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<span className="text-sm text-gray-500 dark:text-gray-400">Exports</span> <span className="text-sm text-gray-500 dark:text-gray-400">Exports</span>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">{latestVersion.exports.length}</span> <span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{latestVersion.exports.length}
</span>
</div> </div>
</> </>
) : ( ) : (
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { OrderType, AllocationType, ProjectStatus } from "@capakraken/shared"; import type { OrderType, AllocationType, ProjectStatus } from "@capakraken/shared";
import { AnimatedModal } from "~/components/ui/AnimatedModal.js"; import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
import type { Project } from "@capakraken/shared"; import type { Project } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
@@ -79,9 +79,12 @@ function projectToForm(project: Project): FormState {
status: project.status, status: project.status,
responsiblePerson: project.responsiblePerson ?? "", responsiblePerson: project.responsiblePerson ?? "",
color: (project as unknown as { color?: string | null }).color ?? "", color: (project as unknown as { color?: string | null }).color ?? "",
utilizationCategoryId: (project as unknown as { utilizationCategoryId?: string | null }).utilizationCategoryId ?? "", utilizationCategoryId:
(project as unknown as { utilizationCategoryId?: string | null }).utilizationCategoryId ?? "",
clientId: (project as unknown as { clientId?: string | null }).clientId ?? "", clientId: (project as unknown as { clientId?: string | null }).clientId ?? "",
shoringThreshold: String((project as unknown as { shoringThreshold?: number | null }).shoringThreshold ?? 55), shoringThreshold: String(
(project as unknown as { shoringThreshold?: number | null }).shoringThreshold ?? 55,
),
}; };
} }
@@ -101,10 +104,12 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({}); const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
const [serverError, setServerError] = useState<string | null>(null); const [serverError, setServerError] = useState<string | null>(null);
const { data: utilizationCategories } = trpc.utilizationCategory.list.useQuery(undefined, { staleTime: 60_000 }); const { data: utilizationCategories } = trpc.utilizationCategory.list.useQuery(undefined, {
staleTime: 60_000,
});
const { data: clientList } = trpc.clientEntity.list.useQuery(undefined, { staleTime: 60_000 }); const { data: clientList } = trpc.clientEntity.list.useQuery(undefined, { staleTime: 60_000 });
// @ts-ignore TS2589: tRPC infers union type too deeply for CreateProjectSchema with .refine() // @ts-expect-error TS2589: tRPC infers union type too deeply for CreateProjectSchema with .refine()
const createMutation = trpc.project.create.useMutation({ const createMutation = trpc.project.create.useMutation({
onSuccess: async () => { onSuccess: async () => {
await utils.project.listWithCosts.invalidate(); await utils.project.listWithCosts.invalidate();
@@ -193,7 +198,9 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
status: form.status as unknown as ProjectStatus, status: form.status as unknown as ProjectStatus,
responsiblePerson: form.responsiblePerson.trim(), responsiblePerson: form.responsiblePerson.trim(),
...(form.color ? { color: form.color } : {}), ...(form.color ? { color: form.color } : {}),
...(form.utilizationCategoryId ? { utilizationCategoryId: form.utilizationCategoryId } : {}), ...(form.utilizationCategoryId
? { utilizationCategoryId: form.utilizationCategoryId }
: {}),
...(form.clientId ? { clientId: form.clientId } : {}), ...(form.clientId ? { clientId: form.clientId } : {}),
shoringThreshold: Number(form.shoringThreshold), shoringThreshold: Number(form.shoringThreshold),
}, },
@@ -213,7 +220,9 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
dynamicFields: {}, dynamicFields: {},
responsiblePerson: form.responsiblePerson.trim(), responsiblePerson: form.responsiblePerson.trim(),
...(form.color ? { color: form.color } : {}), ...(form.color ? { color: form.color } : {}),
...(form.utilizationCategoryId ? { utilizationCategoryId: form.utilizationCategoryId } : {}), ...(form.utilizationCategoryId
? { utilizationCategoryId: form.utilizationCategoryId }
: {}),
...(form.clientId ? { clientId: form.clientId } : {}), ...(form.clientId ? { clientId: form.clientId } : {}),
shoringThreshold: Number(form.shoringThreshold), shoringThreshold: Number(form.shoringThreshold),
}); });
@@ -241,7 +250,12 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
aria-label="Close" aria-label="Close"
> >
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg> </svg>
</button> </button>
</div> </div>
@@ -282,7 +296,9 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
} }
/> />
{errors.shortCode && ( {errors.shortCode && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.shortCode}</p> <p className="mt-1 text-xs text-red-600 dark:text-red-400">
{errors.shortCode}
</p>
)} )}
</div> </div>
<div> <div>
@@ -362,7 +378,9 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
className={errors.winProbability ? inputErrorClass : inputClass} className={errors.winProbability ? inputErrorClass : inputClass}
/> />
{errors.winProbability && ( {errors.winProbability && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.winProbability}</p> <p className="mt-1 text-xs text-red-600 dark:text-red-400">
{errors.winProbability}
</p>
)} )}
</div> </div>
</div> </div>
@@ -388,7 +406,8 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
<option value=""> Not specified </option> <option value=""> Not specified </option>
{(utilizationCategories ?? []).map((cat) => ( {(utilizationCategories ?? []).map((cat) => (
<option key={cat.id} value={cat.id}> <option key={cat.id} value={cat.id}>
{(cat as unknown as { code: string }).code} {(cat as unknown as { name: string }).name} {(cat as unknown as { code: string }).code} {" "}
{(cat as unknown as { name: string }).name}
</option> </option>
))} ))}
</select> </select>
@@ -408,7 +427,9 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
{(clientList ?? []).map((c) => ( {(clientList ?? []).map((c) => (
<option key={c.id} value={c.id}> <option key={c.id} value={c.id}>
{(c as unknown as { name: string }).name} {(c as unknown as { name: string }).name}
{(c as unknown as { code: string | null }).code ? ` [${(c as unknown as { code: string }).code}]` : ""} {(c as unknown as { code: string | null }).code
? ` [${(c as unknown as { code: string }).code}]`
: ""}
</option> </option>
))} ))}
</select> </select>
@@ -434,7 +455,9 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
className={errors.startDate ? inputErrorClass : inputClass} className={errors.startDate ? inputErrorClass : inputClass}
/> />
{errors.startDate && ( {errors.startDate && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.startDate}</p> <p className="mt-1 text-xs text-red-600 dark:text-red-400">
{errors.startDate}
</p>
)} )}
</div> </div>
<div> <div>
@@ -469,7 +492,9 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
className={errors.budgetEur ? inputErrorClass : inputClass} className={errors.budgetEur ? inputErrorClass : inputClass}
/> />
{errors.budgetEur && ( {errors.budgetEur && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.budgetEur}</p> <p className="mt-1 text-xs text-red-600 dark:text-red-400">
{errors.budgetEur}
</p>
)} )}
</div> </div>
<div> <div>
@@ -576,7 +601,13 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
disabled={isLoading} disabled={isLoading}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 transition-colors" className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 transition-colors"
> >
{isLoading ? (isEdit ? "Saving…" : "Creating…") : isEdit ? "Save Changes" : "Create Project"} {isLoading
? isEdit
? "Saving…"
: "Creating…"
: isEdit
? "Save Changes"
: "Create Project"}
</button> </button>
</div> </div>
</form> </form>
+231 -118
View File
@@ -3,8 +3,19 @@
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { clsx } from "clsx"; import { clsx } from "clsx";
import type { StaffingRequirement, BlueprintFieldDefinition } from "@capakraken/shared"; import type {
import { BlueprintTarget, FieldType, OrderType, AllocationType, ProjectStatus, AllocationStatus, RolePresetsSchema } from "@capakraken/shared"; StaffingRequirement,
BlueprintFieldDefinition,
OrderType,
AllocationType,
} from "@capakraken/shared";
import {
BlueprintTarget,
FieldType,
ProjectStatus,
AllocationStatus,
RolePresetsSchema,
} from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { uuid } from "~/lib/uuid.js"; import { uuid } from "~/lib/uuid.js";
import { DateInput } from "~/components/ui/DateInput.js"; import { DateInput } from "~/components/ui/DateInput.js";
@@ -200,12 +211,7 @@ function DynamicFieldInput({
</label> </label>
); );
case FieldType.DATE: case FieldType.DATE:
return ( return <DateInput value={strVal} onChange={(v) => onChange(field.key, v)} />;
<DateInput
value={strVal}
onChange={(v) => onChange(field.key, v)}
/>
);
case FieldType.SELECT: case FieldType.SELECT:
return ( return (
<select <select
@@ -249,7 +255,9 @@ function DynamicFieldInput({
// TEXT, URL, EMAIL // TEXT, URL, EMAIL
return ( return (
<input <input
type={field.type === FieldType.EMAIL ? "email" : field.type === FieldType.URL ? "url" : "text"} type={
field.type === FieldType.EMAIL ? "email" : field.type === FieldType.URL ? "url" : "text"
}
value={strVal} value={strVal}
onChange={(e) => onChange(field.key, e.target.value)} onChange={(e) => onChange(field.key, e.target.value)}
placeholder={field.placeholder} placeholder={field.placeholder}
@@ -270,14 +278,28 @@ function Step1({ state, onChange }: Step1Props) {
const { data: blueprints } = trpc.blueprint.list.useQuery( const { data: blueprints } = trpc.blueprint.list.useQuery(
{ target: BlueprintTarget.PROJECT, isActive: true }, { target: BlueprintTarget.PROJECT, isActive: true },
{ staleTime: 30_000 }, { staleTime: 30_000 },
// eslint-disable-next-line @typescript-eslint/no-explicit-any ) as {
) as { data: Array<{ id: string; name: string; description?: string | null; rolePresets?: unknown; fieldDefs?: unknown }> | undefined }; data:
| Array<{
id: string;
name: string;
description?: string | null;
rolePresets?: unknown;
fieldDefs?: unknown;
}>
| undefined;
};
const selectedBp = blueprints?.find((b) => b.id === state.blueprintId); const selectedBp = blueprints?.find((b) => b.id === state.blueprintId);
function selectBlueprint(id: string | null) { function selectBlueprint(id: string | null) {
if (!id) { if (!id) {
onChange({ blueprintId: null, blueprintName: null, blueprintFieldDefs: [], dynamicFields: {} }); onChange({
blueprintId: null,
blueprintName: null,
blueprintFieldDefs: [],
dynamicFields: {},
});
return; return;
} }
const bp = blueprints?.find((b) => b.id === id); const bp = blueprints?.find((b) => b.id === id);
@@ -285,7 +307,9 @@ function Step1({ state, onChange }: Step1Props) {
const parsedPresets = RolePresetsSchema.safeParse( const parsedPresets = RolePresetsSchema.safeParse(
Array.isArray(bp?.rolePresets) ? bp.rolePresets : [], Array.isArray(bp?.rolePresets) ? bp.rolePresets : [],
); );
const presets = (parsedPresets.success ? parsedPresets.data : []) as unknown as StaffingRequirement[]; const presets = (parsedPresets.success
? parsedPresets.data
: []) as unknown as StaffingRequirement[];
// Parse fieldDefs from blueprint // Parse fieldDefs from blueprint
const rawFieldDefs = Array.isArray(bp?.fieldDefs) ? (bp.fieldDefs as unknown[]) : []; const rawFieldDefs = Array.isArray(bp?.fieldDefs) ? (bp.fieldDefs as unknown[]) : [];
const fieldDefs = rawFieldDefs.filter( const fieldDefs = rawFieldDefs.filter(
@@ -312,7 +336,10 @@ function Step1({ state, onChange }: Step1Props) {
<div className="space-y-5"> <div className="space-y-5">
{/* Blueprint picker */} {/* Blueprint picker */}
<div> <div>
<label className="app-label">Project Blueprint (optional)<InfoTooltip content="Blueprints are templates that pre-fill role presets and default settings. Selecting one loads staffing requirements into Step 3." /></label> <label className="app-label">
Project Blueprint (optional)
<InfoTooltip content="Blueprints are templates that pre-fill role presets and default settings. Selecting one loads staffing requirements into Step 3." />
</label>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mb-2"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mb-2">
<button <button
type="button" type="button"
@@ -364,7 +391,10 @@ function Step1({ state, onChange }: Step1Props) {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Short code */} {/* Short code */}
<div> <div>
<label className="app-label">Short Code *<InfoTooltip content="Unique chargecode for this project, used for time tracking and cost attribution. Must be uppercase alphanumeric." /></label> <label className="app-label">
Short Code *
<InfoTooltip content="Unique chargecode for this project, used for time tracking and cost attribution. Must be uppercase alphanumeric." />
</label>
<input <input
type="text" type="text"
value={state.shortCode} value={state.shortCode}
@@ -377,7 +407,10 @@ function Step1({ state, onChange }: Step1Props) {
{/* Name */} {/* Name */}
<div> <div>
<label className="app-label">Project Name *<InfoTooltip content="Display name shown on the timeline and in reports." /></label> <label className="app-label">
Project Name *
<InfoTooltip content="Display name shown on the timeline and in reports." />
</label>
<input <input
type="text" type="text"
value={state.name} value={state.name}
@@ -389,7 +422,10 @@ function Step1({ state, onChange }: Step1Props) {
{/* Order type */} {/* Order type */}
<div> <div>
<label className="app-label">Order Type *<InfoTooltip content="BD = Business Development (pre-sales), CHARGEABLE = billable client work, INTERNAL = internal project, OVERHEAD = non-project overhead." /></label> <label className="app-label">
Order Type *
<InfoTooltip content="BD = Business Development (pre-sales), CHARGEABLE = billable client work, INTERNAL = internal project, OVERHEAD = non-project overhead." />
</label>
<select <select
value={state.orderType} value={state.orderType}
onChange={(e) => onChange({ orderType: e.target.value })} onChange={(e) => onChange({ orderType: e.target.value })}
@@ -405,7 +441,10 @@ function Step1({ state, onChange }: Step1Props) {
{/* Allocation type */} {/* Allocation type */}
<div> <div>
<label className="app-label">Allocation Type *<InfoTooltip content="INT = staffed with internal resources, EXT = staffed with external contractors or freelancers." /></label> <label className="app-label">
Allocation Type *
<InfoTooltip content="INT = staffed with internal resources, EXT = staffed with external contractors or freelancers." />
</label>
<select <select
value={state.allocationType} value={state.allocationType}
onChange={(e) => onChange({ allocationType: e.target.value })} onChange={(e) => onChange({ allocationType: e.target.value })}
@@ -430,13 +469,14 @@ function Step1({ state, onChange }: Step1Props) {
{[...state.blueprintFieldDefs] {[...state.blueprintFieldDefs]
.sort((a, b) => a.order - b.order) .sort((a, b) => a.order - b.order)
.map((field) => ( .map((field) => (
<div key={field.key} className={field.type === FieldType.TEXTAREA ? "col-span-2" : ""}> <div
key={field.key}
className={field.type === FieldType.TEXTAREA ? "col-span-2" : ""}
>
<label className="app-label"> <label className="app-label">
{field.label} {field.label}
{field.required && " *"} {field.required && " *"}
{field.description && ( {field.description && <InfoTooltip content={field.description} />}
<InfoTooltip content={field.description} />
)}
</label> </label>
<DynamicFieldInput <DynamicFieldInput
field={field} field={field}
@@ -456,7 +496,13 @@ function Step1({ state, onChange }: Step1Props) {
// ─── Responsible Person Picker ──────────────────────────────────────────────── // ─── Responsible Person Picker ────────────────────────────────────────────────
function ResourcePersonPicker({ value, onChange }: { value: string; onChange: (v: string) => void }) { function ResourcePersonPicker({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
const [query, setQuery] = useState(value); const [query, setQuery] = useState(value);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [debouncedSearch, setDebouncedSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState("");
@@ -478,7 +524,8 @@ function ResourcePersonPicker({ value, onChange }: { value: string; onChange: (v
{ staleTime: 15_000, placeholderData: (prev: any) => prev }, { staleTime: 15_000, placeholderData: (prev: any) => prev },
); );
const filtered = useMemo( const filtered = useMemo(
() => (data?.resources ?? []) as unknown as Array<{ id: string; displayName: string; eid: string }>, () =>
(data?.resources ?? []) as unknown as Array<{ id: string; displayName: string; eid: string }>,
[data], [data],
); );
@@ -516,7 +563,12 @@ function ResourcePersonPicker({ value, onChange }: { value: string; onChange: (v
{isConfirmed && ( {isConfirmed && (
<span className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-green-500"> <span className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-green-500">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.5}
d="M5 13l4 4L19 7"
/>
</svg> </svg>
</span> </span>
)} )}
@@ -571,24 +623,27 @@ function Step2({ state, onChange }: Step2Props) {
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="app-label">Start Date *<InfoTooltip content="First day of the project period. Assignments and budget tracking begin from this date." /></label> <label className="app-label">
<DateInput Start Date *
value={state.startDate} <InfoTooltip content="First day of the project period. Assignments and budget tracking begin from this date." />
onChange={(v) => onChange({ startDate: v })} </label>
/> <DateInput value={state.startDate} onChange={(v) => onChange({ startDate: v })} />
</div> </div>
<div> <div>
<label className="app-label">End Date *<InfoTooltip content="Last day of the project period. Defines the timeline boundary for all assignments." /></label> <label className="app-label">
<DateInput End Date *
value={state.endDate} <InfoTooltip content="Last day of the project period. Defines the timeline boundary for all assignments." />
onChange={(v) => onChange({ endDate: v })} </label>
/> <DateInput value={state.endDate} onChange={(v) => onChange({ endDate: v })} />
</div> </div>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="app-label">Budget (EUR)<InfoTooltip content="Total project budget in EUR. Stored internally as cents. Used to track spending against assignments." /></label> <label className="app-label">
Budget (EUR)
<InfoTooltip content="Total project budget in EUR. Stored internally as cents. Used to track spending against assignments." />
</label>
<input <input
type="number" type="number"
min={0} min={0}
@@ -600,7 +655,10 @@ function Step2({ state, onChange }: Step2Props) {
/> />
</div> </div>
<div> <div>
<label className="app-label">Responsible Person<InfoTooltip content="Project lead or account manager. Search by name or employee ID." /></label> <label className="app-label">
Responsible Person
<InfoTooltip content="Project lead or account manager. Search by name or employee ID." />
</label>
<ResourcePersonPicker <ResourcePersonPicker
value={state.responsiblePerson} value={state.responsiblePerson}
onChange={(v) => onChange({ responsiblePerson: v })} onChange={(v) => onChange({ responsiblePerson: v })}
@@ -639,19 +697,14 @@ interface Step3Props {
} }
function Step3({ state, onChange }: Step3Props) { function Step3({ state, onChange }: Step3Props) {
const { data: rolesData } = trpc.role.list.useQuery( const { data: rolesData } = trpc.role.list.useQuery({ isActive: true }, { staleTime: 30_000 });
{ isActive: true },
{ staleTime: 30_000 },
);
const roles = rolesData ?? []; const roles = rolesData ?? [];
const { data: chaptersData } = trpc.resource.chapters.useQuery(undefined, { staleTime: 60_000 }); const { data: chaptersData } = trpc.resource.chapters.useQuery(undefined, { staleTime: 60_000 });
const chapters = (chaptersData ?? []) as string[]; const chapters = (chaptersData ?? []) as string[];
function updateReq(idx: number, patch: Partial<StaffingRequirement>) { function updateReq(idx: number, patch: Partial<StaffingRequirement>) {
const next = state.staffingReqs.map((r, i) => const next = state.staffingReqs.map((r, i) => (i === idx ? { ...r, ...patch } : r));
i === idx ? { ...r, ...patch } : r,
);
onChange({ staffingReqs: next }); onChange({ staffingReqs: next });
} }
@@ -672,28 +725,40 @@ function Step3({ state, onChange }: Step3Props) {
)} )}
{/* Budget allocation summary */} {/* Budget allocation summary */}
{state.budgetEur && parseFloat(state.budgetEur) > 0 && state.staffingReqs.length > 0 && (() => { {state.budgetEur &&
const projectBudgetCents = Math.round(parseFloat(state.budgetEur || "0") * 100); parseFloat(state.budgetEur) > 0 &&
const allocatedCents = state.staffingReqs.reduce((sum, r) => sum + (r.budgetCents ?? 0), 0); state.staffingReqs.length > 0 &&
const remainingCents = projectBudgetCents - allocatedCents; (() => {
const pct = projectBudgetCents > 0 ? Math.round((allocatedCents / projectBudgetCents) * 100) : 0; const projectBudgetCents = Math.round(parseFloat(state.budgetEur || "0") * 100);
return ( const allocatedCents = state.staffingReqs.reduce(
<div className={`mb-3 rounded-lg border p-3 text-xs ${remainingCents < 0 ? "bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-800 text-red-700 dark:text-red-400" : remainingCents === 0 ? "bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-800 text-green-700 dark:text-green-400" : "bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-800 text-amber-700 dark:text-amber-300"}`}> (sum, r) => sum + (r.budgetCents ?? 0),
<div className="flex items-center justify-between mb-1.5"> 0,
<span className="font-semibold">Budget Allocation</span> );
<span>{pct}% allocated</span> const remainingCents = projectBudgetCents - allocatedCents;
const pct =
projectBudgetCents > 0 ? Math.round((allocatedCents / projectBudgetCents) * 100) : 0;
return (
<div
className={`mb-3 rounded-lg border p-3 text-xs ${remainingCents < 0 ? "bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-800 text-red-700 dark:text-red-400" : remainingCents === 0 ? "bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-800 text-green-700 dark:text-green-400" : "bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-800 text-amber-700 dark:text-amber-300"}`}
>
<div className="flex items-center justify-between mb-1.5">
<span className="font-semibold">Budget Allocation</span>
<span>{pct}% allocated</span>
</div>
<div className="w-full h-1.5 bg-gray-200 rounded-full overflow-hidden mb-1.5">
<div
className={`h-full rounded-full transition-all ${remainingCents < 0 ? "bg-red-500" : remainingCents === 0 ? "bg-green-500" : "bg-amber-500"}`}
style={{ width: `${Math.min(100, pct)}%` }}
/>
</div>
<div className="flex justify-between">
<span>Project: {formatCents(projectBudgetCents)} EUR</span>
<span>Allocated: {formatCents(allocatedCents)} EUR</span>
<span className="font-semibold">Remaining: {formatCents(remainingCents)} EUR</span>
</div>
</div> </div>
<div className="w-full h-1.5 bg-gray-200 rounded-full overflow-hidden mb-1.5"> );
<div className={`h-full rounded-full transition-all ${remainingCents < 0 ? "bg-red-500" : remainingCents === 0 ? "bg-green-500" : "bg-amber-500"}`} style={{ width: `${Math.min(100, pct)}%` }} /> })()}
</div>
<div className="flex justify-between">
<span>Project: {formatCents(projectBudgetCents)} EUR</span>
<span>Allocated: {formatCents(allocatedCents)} EUR</span>
<span className="font-semibold">Remaining: {formatCents(remainingCents)} EUR</span>
</div>
</div>
);
})()}
<div className="space-y-3 max-h-[45vh] overflow-y-auto pr-1"> <div className="space-y-3 max-h-[45vh] overflow-y-auto pr-1">
{state.staffingReqs.length === 0 && ( {state.staffingReqs.length === 0 && (
@@ -705,7 +770,10 @@ function Step3({ state, onChange }: Step3Props) {
<div key={req.id} className="border border-gray-200 rounded-lg p-3 bg-white"> <div key={req.id} className="border border-gray-200 rounded-lg p-3 bg-white">
<div className="flex flex-wrap items-start gap-2"> <div className="flex flex-wrap items-start gap-2">
<div className="flex-1 min-w-32"> <div className="flex-1 min-w-32">
<label className="text-xs text-gray-400">Role *<InfoTooltip content="Select a predefined role or enter a custom role name. Defines the skill profile for this staffing demand." /></label> <label className="text-xs text-gray-400">
Role *
<InfoTooltip content="Select a predefined role or enter a custom role name. Defines the skill profile for this staffing demand." />
</label>
{roles.length > 0 ? ( {roles.length > 0 ? (
<select <select
value={req.roleId ?? ""} value={req.roleId ?? ""}
@@ -718,18 +786,22 @@ function Step3({ state, onChange }: Step3Props) {
// Clear roleId — rebuild without the key // Clear roleId — rebuild without the key
const { roleId: _r, ...rest } = state.staffingReqs[idx]!; const { roleId: _r, ...rest } = state.staffingReqs[idx]!;
void _r; void _r;
onChange({ staffingReqs: state.staffingReqs.map((r, i) => i === idx ? rest : r) }); onChange({
staffingReqs: state.staffingReqs.map((r, i) => (i === idx ? rest : r)),
});
} }
}} }}
className="app-select w-full" className="app-select w-full"
> >
<option value="">Custom / Free text</option> <option value="">Custom / Free text</option>
{roles.map((ro) => ( {roles.map((ro) => (
<option key={ro.id} value={ro.id}>{ro.name}</option> <option key={ro.id} value={ro.id}>
{ro.name}
</option>
))} ))}
</select> </select>
) : null} ) : null}
{(!req.roleId) && ( {!req.roleId && (
<input <input
type="text" type="text"
value={req.role} value={req.role}
@@ -740,7 +812,10 @@ function Step3({ state, onChange }: Step3Props) {
)} )}
</div> </div>
<div className="w-20"> <div className="w-20">
<label className="text-xs text-gray-400">h/day<InfoTooltip content="Planned working hours per day for this role." /></label> <label className="text-xs text-gray-400">
h/day
<InfoTooltip content="Planned working hours per day for this role." />
</label>
<input <input
type="number" type="number"
value={req.hoursPerDay} value={req.hoursPerDay}
@@ -752,7 +827,10 @@ function Step3({ state, onChange }: Step3Props) {
/> />
</div> </div>
<div className="w-16"> <div className="w-16">
<label className="text-xs text-gray-400">Count<InfoTooltip content="Number of people needed for this role. Unfilled seats become placeholder demands until assigned." /></label> <label className="text-xs text-gray-400">
Count
<InfoTooltip content="Number of people needed for this role. Unfilled seats become placeholder demands until assigned." />
</label>
<input <input
type="number" type="number"
value={req.headcount} value={req.headcount}
@@ -763,7 +841,10 @@ function Step3({ state, onChange }: Step3Props) {
/> />
</div> </div>
<div className="w-28"> <div className="w-28">
<label className="text-xs text-gray-400">Budget (EUR)<InfoTooltip content="Optional budget cap for this role. Tracked against actual assignment costs." /></label> <label className="text-xs text-gray-400">
Budget (EUR)
<InfoTooltip content="Optional budget cap for this role. Tracked against actual assignment costs." />
</label>
<input <input
type="number" type="number"
value={req.budgetCents ? req.budgetCents / 100 : ""} value={req.budgetCents ? req.budgetCents / 100 : ""}
@@ -771,7 +852,9 @@ function Step3({ state, onChange }: Step3Props) {
step={100} step={100}
onChange={(e) => { onChange={(e) => {
const val = parseFloat(e.target.value); const val = parseFloat(e.target.value);
updateReq(idx, { budgetCents: Number.isFinite(val) && val > 0 ? Math.round(val * 100) : 0 } as Partial<StaffingRequirement>); updateReq(idx, {
budgetCents: Number.isFinite(val) && val > 0 ? Math.round(val * 100) : 0,
} as Partial<StaffingRequirement>);
}} }}
placeholder="0" placeholder="0"
className="app-input" className="app-input"
@@ -789,7 +872,10 @@ function Step3({ state, onChange }: Step3Props) {
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2">
<div> <div>
<label className="text-xs text-gray-400">Required skills<InfoTooltip content="Skills a resource must have to be suggested for this role." /></label> <label className="text-xs text-gray-400">
Required skills
<InfoTooltip content="Skills a resource must have to be suggested for this role." />
</label>
<SkillTagInput <SkillTagInput
value={req.requiredSkills} value={req.requiredSkills}
onChange={(skills) => updateReq(idx, { requiredSkills: skills })} onChange={(skills) => updateReq(idx, { requiredSkills: skills })}
@@ -797,7 +883,10 @@ function Step3({ state, onChange }: Step3Props) {
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400">Preferred skills (optional)<InfoTooltip content="Nice-to-have skills that boost a resource's match score but are not mandatory." /></label> <label className="text-xs text-gray-400">
Preferred skills (optional)
<InfoTooltip content="Nice-to-have skills that boost a resource's match score but are not mandatory." />
</label>
<SkillTagInput <SkillTagInput
value={req.preferredSkills ?? []} value={req.preferredSkills ?? []}
onChange={(skills) => updateReq(idx, { preferredSkills: skills })} onChange={(skills) => updateReq(idx, { preferredSkills: skills })}
@@ -805,20 +894,27 @@ function Step3({ state, onChange }: Step3Props) {
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400">Chapter filter (optional)<InfoTooltip content="Restrict suggestions to resources from a specific chapter/department." /></label> <label className="text-xs text-gray-400">
Chapter filter (optional)
<InfoTooltip content="Restrict suggestions to resources from a specific chapter/department." />
</label>
<input <input
type="text" type="text"
list="chapter-options" list="chapter-options"
value={req.chapter ?? ""} value={req.chapter ?? ""}
onChange={(e) => onChange={(e) =>
updateReq(idx, { chapter: e.target.value || undefined } as Partial<StaffingRequirement>) updateReq(idx, {
chapter: e.target.value || undefined,
} as Partial<StaffingRequirement>)
} }
placeholder="e.g. Art Direction" placeholder="e.g. Art Direction"
className="app-input" className="app-input"
/> />
{chapters.length > 0 && ( {chapters.length > 0 && (
<datalist id="chapter-options"> <datalist id="chapter-options">
{chapters.map((ch) => <option key={ch} value={ch} />)} {chapters.map((ch) => (
<option key={ch} value={ch} />
))}
</datalist> </datalist>
)} )}
</div> </div>
@@ -890,7 +986,10 @@ function ReqSuggestions({
if (!req.requiredSkills.length) { if (!req.requiredSkills.length) {
return ( return (
<p className="text-xs text-amber-600">No skills defined for this demand yet. Go back to Step 3 and add required skills the AI will then suggest matching resources here.</p> <p className="text-xs text-amber-600">
No skills defined for this demand yet. Go back to Step 3 and add required skills the AI
will then suggest matching resources here.
</p>
); );
} }
@@ -957,8 +1056,8 @@ function ReqSuggestions({
item.valueScore >= 70 item.valueScore >= 70
? "bg-green-100 text-green-700" ? "bg-green-100 text-green-700"
: item.valueScore >= 40 : item.valueScore >= 40
? "bg-amber-100 text-amber-700" ? "bg-amber-100 text-amber-700"
: "bg-red-100 text-red-700" : "bg-red-100 text-red-700"
}`} }`}
title="Value Score (price/quality)" title="Value Score (price/quality)"
> >
@@ -1002,10 +1101,7 @@ interface Step4Props {
function Step4({ state, onChange }: Step4Props) { function Step4({ state, onChange }: Step4Props) {
function assign(requirementId: string, resourceId: string, resourceName: string, role: string) { function assign(requirementId: string, resourceId: string, resourceName: string, role: string) {
onChange({ onChange({
assignments: [ assignments: [...state.assignments, { requirementId, resourceId, resourceName, role }],
...state.assignments,
{ requirementId, resourceId, resourceName, role },
],
}); });
} }
@@ -1066,7 +1162,14 @@ interface Step5Props {
submitWarnings: string[]; submitWarnings: string[];
} }
function Step5({ state, onChange, onSubmit, isSubmitting, submitError, submitWarnings }: Step5Props) { function Step5({
state,
onChange,
onSubmit,
isSubmitting,
submitError,
submitWarnings,
}: Step5Props) {
const totalAssignedCostHint = useMemo(() => { const totalAssignedCostHint = useMemo(() => {
// Very rough hint: sum hoursPerDay * headcount across all requirements // Very rough hint: sum hoursPerDay * headcount across all requirements
return state.staffingReqs.reduce((sum, r) => sum + r.hoursPerDay * r.headcount, 0); return state.staffingReqs.reduce((sum, r) => sum + r.hoursPerDay * r.headcount, 0);
@@ -1076,7 +1179,9 @@ function Step5({ state, onChange, onSubmit, isSubmitting, submitError, submitWar
<div className="space-y-4"> <div className="space-y-4">
{/* Project summary */} {/* Project summary */}
<div className="flex items-center gap-1 mb-1"> <div className="flex items-center gap-1 mb-1">
<p className="text-xs font-medium text-gray-600 uppercase tracking-wider">Project Summary</p> <p className="text-xs font-medium text-gray-600 uppercase tracking-wider">
Project Summary
</p>
<InfoTooltip content="Review all project details before creation. The project, staffing demands, and any pre-assigned resources will be created together." /> <InfoTooltip content="Review all project details before creation. The project, staffing demands, and any pre-assigned resources will be created together." />
</div> </div>
<div className="bg-gray-50 rounded-lg p-4 text-sm space-y-2"> <div className="bg-gray-50 rounded-lg p-4 text-sm space-y-2">
@@ -1104,8 +1209,7 @@ function Step5({ state, onChange, onSubmit, isSubmitting, submitError, submitWar
{state.budgetEur ? `${parseFloat(state.budgetEur).toLocaleString()}` : "—"} {state.budgetEur ? `${parseFloat(state.budgetEur).toLocaleString()}` : "—"}
</div> </div>
<div> <div>
<span className="text-gray-500">Dates:</span>{" "} <span className="text-gray-500">Dates:</span> {state.startDate} {state.endDate}
{state.startDate} {state.endDate}
</div> </div>
<div> <div>
<span className="text-gray-500">Win %:</span> {state.winProbability}% <span className="text-gray-500">Win %:</span> {state.winProbability}%
@@ -1157,15 +1261,14 @@ function Step5({ state, onChange, onSubmit, isSubmitting, submitError, submitWar
const assignedNames = assigned.map((a) => a.resourceName); const assignedNames = assigned.map((a) => a.resourceName);
const unassigned = req.headcount - assigned.length; const unassigned = req.headcount - assigned.length;
return ( return (
<div <div key={req.id} className="px-3 py-2 rounded-lg bg-gray-50 text-sm">
key={req.id}
className="px-3 py-2 rounded-lg bg-gray-50 text-sm"
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="flex-1 font-medium">{req.role || "Unnamed"}</span> <span className="flex-1 font-medium">{req.role || "Unnamed"}</span>
<span className="text-gray-400 text-xs">{req.hoursPerDay}h/day</span> <span className="text-gray-400 text-xs">{req.hoursPerDay}h/day</span>
{req.budgetCents ? ( {req.budgetCents ? (
<span className="text-xs text-gray-400">{formatCents(req.budgetCents)} EUR</span> <span className="text-xs text-gray-400">
{formatCents(req.budgetCents)} EUR
</span>
) : null} ) : null}
<span <span
className={clsx( className={clsx(
@@ -1179,10 +1282,20 @@ function Step5({ state, onChange, onSubmit, isSubmitting, submitError, submitWar
{(req.requiredSkills.length > 0 || (req.preferredSkills ?? []).length > 0) && ( {(req.requiredSkills.length > 0 || (req.preferredSkills ?? []).length > 0) && (
<div className="mt-1 flex flex-wrap gap-1"> <div className="mt-1 flex flex-wrap gap-1">
{req.requiredSkills.map((s) => ( {req.requiredSkills.map((s) => (
<span key={s} className="px-1.5 py-0.5 rounded text-[10px] bg-brand-100 text-brand-700">{s}</span> <span
key={s}
className="px-1.5 py-0.5 rounded text-[10px] bg-brand-100 text-brand-700"
>
{s}
</span>
))} ))}
{(req.preferredSkills ?? []).map((s) => ( {(req.preferredSkills ?? []).map((s) => (
<span key={s} className="px-1.5 py-0.5 rounded text-[10px] bg-gray-100 text-gray-500">{s}</span> <span
key={s}
className="px-1.5 py-0.5 rounded text-[10px] bg-gray-100 text-gray-500"
>
{s}
</span>
))} ))}
</div> </div>
)} )}
@@ -1204,9 +1317,13 @@ function Step5({ state, onChange, onSubmit, isSubmitting, submitError, submitWar
<div className="px-3 py-2 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 rounded-lg text-sm text-amber-800 dark:text-amber-300 space-y-1"> <div className="px-3 py-2 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 rounded-lg text-sm text-amber-800 dark:text-amber-300 space-y-1">
<p className="font-medium">Project created with warnings:</p> <p className="font-medium">Project created with warnings:</p>
{submitWarnings.map((w, i) => ( {submitWarnings.map((w, i) => (
<p key={i} className="text-xs">{w}</p> <p key={i} className="text-xs">
{w}
</p>
))} ))}
<p className="text-xs mt-1 text-amber-600 dark:text-amber-400">You can fix these staffing items from the project detail page.</p> <p className="text-xs mt-1 text-amber-600 dark:text-amber-400">
You can fix these staffing items from the project detail page.
</p>
</div> </div>
)} )}
@@ -1253,12 +1370,7 @@ function Step5({ state, onChange, onSubmit, isSubmitting, submitError, submitWar
)} )}
<div className="flex justify-end"> <div className="flex justify-end">
<button <button type="button" onClick={onSubmit} disabled={isSubmitting} className={BTN_PRIMARY}>
type="button"
onClick={onSubmit}
disabled={isSubmitting}
className={BTN_PRIMARY}
>
{isSubmitting {isSubmitting
? "Creating…" ? "Creating…"
: state.saveAsDraft : state.saveAsDraft
@@ -1294,7 +1406,9 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
mutateAsync: (input: unknown) => Promise<unknown>; mutateAsync: (input: unknown) => Promise<unknown>;
}; };
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const createDemandRequirement = (trpc.allocation.createDemandRequirement.useMutation as any)() as { const createDemandRequirement = (
trpc.allocation.createDemandRequirement.useMutation as any
)() as {
mutateAsync: (input: unknown) => Promise<unknown>; mutateAsync: (input: unknown) => Promise<unknown>;
}; };
@@ -1321,7 +1435,12 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
const missingRequired = state.blueprintFieldDefs.some((f) => { const missingRequired = state.blueprintFieldDefs.some((f) => {
if (!f.required) return false; if (!f.required) return false;
const val = state.dynamicFields[f.key]; const val = state.dynamicFields[f.key];
return val === undefined || val === null || val === "" || (Array.isArray(val) && val.length === 0); return (
val === undefined ||
val === null ||
val === "" ||
(Array.isArray(val) && val.length === 0)
);
}); });
return ( return (
state.shortCode.trim().length > 0 && state.shortCode.trim().length > 0 &&
@@ -1343,9 +1462,7 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
// any requirement that exists must be valid: must have a role and positive hours/headcount. // any requirement that exists must be valid: must have a role and positive hours/headcount.
return state.staffingReqs.every( return state.staffingReqs.every(
(r) => (r) =>
(r.roleId != null || r.role.trim().length > 0) && (r.roleId != null || r.role.trim().length > 0) && r.hoursPerDay > 0 && r.headcount >= 1,
r.hoursPerDay > 0 &&
r.headcount >= 1,
); );
} }
return true; return true;
@@ -1419,7 +1536,9 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
}); });
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
warnings.push(`Demand for "${req.role || "Unnamed Role"}" (${unassigned} seat${unassigned > 1 ? "s" : ""}) failed: ${msg}`); warnings.push(
`Demand for "${req.role || "Unnamed Role"}" (${unassigned} seat${unassigned > 1 ? "s" : ""}) failed: ${msg}`,
);
} }
} }
@@ -1461,9 +1580,7 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
if (!open) return null; if (!open) return null;
return ( return (
<div <div className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8 px-4">
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8 px-4"
>
<div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl relative"> <div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl relative">
{/* Celebration effects */} {/* Celebration effects */}
<ConfettiBurst trigger={showConfetti} /> <ConfettiBurst trigger={showConfetti} />
@@ -1527,11 +1644,7 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
)} )}
{step === 4 && ( {step === 4 && (
<div className="flex items-center px-6 py-4 border-t border-gray-200"> <div className="flex items-center px-6 py-4 border-t border-gray-200">
<button <button type="button" onClick={() => setStep((s) => s - 1)} className={BTN_SECONDARY}>
type="button"
onClick={() => setStep((s) => s - 1)}
className={BTN_SECONDARY}
>
Back Back
</button> </button>
</div> </div>
+70 -19
View File
@@ -18,10 +18,16 @@ export function MfaSetup() {
useEffect(() => { useEffect(() => {
if (!uri) return; if (!uri) return;
let cancelled = false; let cancelled = false;
QRCode.toDataURL(uri, { width: 200, margin: 2 }).then((dataUrl) => { QRCode.toDataURL(uri, { width: 200, margin: 2 })
if (!cancelled) setQrDataUrl(dataUrl); .then((dataUrl) => {
}).catch(() => {/* ignore — manual key is shown as fallback */}); if (!cancelled) setQrDataUrl(dataUrl);
return () => { cancelled = true; }; })
.catch(() => {
/* ignore — manual key is shown as fallback */
});
return () => {
cancelled = true;
};
}, [uri]); }, [uri]);
const { data: mfaStatus, refetch } = trpc.user.getMfaStatus.useQuery(); const { data: mfaStatus, refetch } = trpc.user.getMfaStatus.useQuery();
@@ -60,12 +66,24 @@ export function MfaSetup() {
<div className="rounded-xl border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 p-6"> <div className="rounded-xl border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 p-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/40"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/40">
<svg className="h-5 w-5 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /> className="h-5 w-5 text-green-600 dark:text-green-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg> </svg>
</div> </div>
<div> <div>
<h3 className="text-sm font-semibold text-green-800 dark:text-green-300">MFA Enabled</h3> <h3 className="text-sm font-semibold text-green-800 dark:text-green-300">
MFA Enabled
</h3>
<p className="text-sm text-green-700 dark:text-green-400"> <p className="text-sm text-green-700 dark:text-green-400">
Two-factor authentication is active on your account. Two-factor authentication is active on your account.
</p> </p>
@@ -92,14 +110,27 @@ export function MfaSetup() {
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6"> <div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/40"> <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/40">
<svg className="h-5 w-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /> className="h-5 w-5 text-amber-600 dark:text-amber-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg> </svg>
</div> </div>
<div> <div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Two-Factor Authentication (TOTP)</h3> <h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Two-Factor Authentication (TOTP)
</h3>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400"> <p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Add an extra layer of security by requiring a code from your authenticator app when signing in. Add an extra layer of security by requiring a code from your authenticator app when
signing in.
</p> </p>
<button <button
type="button" type="button"
@@ -116,17 +147,25 @@ export function MfaSetup() {
{step === "show-secret" && ( {step === "show-secret" && (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6 space-y-5"> <div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6 space-y-5">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Step 1: Scan the QR code</h3> <h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Step 1: Scan the QR code
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400"> <p className="text-sm text-gray-600 dark:text-gray-400">
Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.). Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password,
etc.).
</p> </p>
{/* QR Code — rendered locally, no external service */} {/* QR Code — rendered locally, no external service */}
<div className="flex justify-center"> <div className="flex justify-center">
<div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white p-3"> <div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white p-3">
{qrDataUrl ? ( {qrDataUrl ? (
// eslint-disable-next-line @next/next/no-img-element <img
<img src={qrDataUrl} alt="TOTP QR Code" width={200} height={200} className="rounded" /> src={qrDataUrl}
alt="TOTP QR Code"
width={200}
height={200}
className="rounded"
/>
) : ( ) : (
<div className="h-[200px] w-[200px] flex items-center justify-center text-xs text-gray-400"> <div className="h-[200px] w-[200px] flex items-center justify-center text-xs text-gray-400">
Generating Generating
@@ -146,7 +185,10 @@ export function MfaSetup() {
<button <button
type="button" type="button"
onClick={() => { setStep("verify"); setError(null); }} onClick={() => {
setStep("verify");
setError(null);
}}
className="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-brand-700 transition-colors" className="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-brand-700 transition-colors"
> >
Continue Continue
@@ -156,13 +198,18 @@ export function MfaSetup() {
{step === "verify" && ( {step === "verify" && (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6 space-y-5"> <div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6 space-y-5">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Step 2: Verify your code</h3> <h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Step 2: Verify your code
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400"> <p className="text-sm text-gray-600 dark:text-gray-400">
Enter the 6-digit code from your authenticator app to confirm setup. Enter the 6-digit code from your authenticator app to confirm setup.
</p> </p>
<div> <div>
<label htmlFor="mfa-verify-token" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label
htmlFor="mfa-verify-token"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Verification Code Verification Code
</label> </label>
<input <input
@@ -191,7 +238,11 @@ export function MfaSetup() {
</button> </button>
<button <button
type="button" type="button"
onClick={() => { setStep("show-secret"); setToken(""); setError(null); }} onClick={() => {
setStep("show-secret");
setToken("");
setError(null);
}}
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
> >
Back Back
@@ -7,7 +7,15 @@ import {
type Assignment, type Assignment,
type DemandRequirement, type DemandRequirement,
} from "@capakraken/shared"; } from "@capakraken/shared";
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import {
createContext,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useTimelineSSE } from "~/hooks/useTimelineSSE.js"; import { useTimelineSSE } from "~/hooks/useTimelineSSE.js";
@@ -77,7 +85,9 @@ export type TimelineProjectEntry = TimelineAssignmentEntry | TimelineDemandEntry
export type ViewMode = "resource" | "project"; export type ViewMode = "resource" | "project";
function buildTimelineFiltersFromSearchParams(searchParams: ReturnType<typeof useSearchParams>): TimelineFilters { function buildTimelineFiltersFromSearchParams(
searchParams: ReturnType<typeof useSearchParams>,
): TimelineFilters {
const savedPrefs = readAppPreferences(); const savedPrefs = readAppPreferences();
const next: TimelineFilters = { const next: TimelineFilters = {
...DEFAULT_FILTERS, ...DEFAULT_FILTERS,
@@ -236,9 +246,10 @@ export function TimelineProvider({
}: TimelineProviderProps) { }: TimelineProviderProps) {
const { data: session, status: sessionStatus } = useSession(); const { data: session, status: sessionStatus } = useSession();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const role = sessionStatus === "authenticated" const role =
? ((session.user as { role?: string } | undefined)?.role ?? "USER") sessionStatus === "authenticated"
: null; ? ((session.user as { role?: string } | undefined)?.role ?? "USER")
: null;
const isSelfServiceTimeline = role === "USER" || role === "VIEWER"; const isSelfServiceTimeline = role === "USER" || role === "VIEWER";
const isRoleLoading = sessionStatus === "loading"; const isRoleLoading = sessionStatus === "loading";
@@ -268,7 +279,9 @@ export function TimelineProvider({
const viewEnd = addDays(viewStart, viewDays); const viewEnd = addDays(viewStart, viewDays);
// Support URL params: ?eids=EMP-001,EMP-002&projectIds=id1,id2&chapters=ch1 // Support URL params: ?eids=EMP-001,EMP-002&projectIds=id1,id2&chapters=ch1
const [filters, setFilters] = useState<TimelineFilters>(() => buildTimelineFiltersFromSearchParams(searchParams)); const [filters, setFilters] = useState<TimelineFilters>(() =>
buildTimelineFiltersFromSearchParams(searchParams),
);
// Sync filters/viewStart/viewDays from URL params on mount and after later changes // Sync filters/viewStart/viewDays from URL params on mount and after later changes
// (e.g. direct nav from another page or router.push("/timeline?eids=...") while already on /timeline) // (e.g. direct nav from another page or router.push("/timeline?eids=...") while already on /timeline)
@@ -318,14 +331,13 @@ export function TimelineProvider({
const staffEntriesViewQuery = trpc.timeline.getEntriesView.useQuery( const staffEntriesViewQuery = trpc.timeline.getEntriesView.useQuery(
timelineQueryInput, timelineQueryInput,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ {
enabled: !isRoleLoading && !isSelfServiceTimeline, enabled: !isRoleLoading && !isSelfServiceTimeline,
placeholderData: (prev: any) => prev, placeholderData: (prev: any) => prev,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
staleTime: 90_000, staleTime: 90_000,
}, },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as { ) as {
data: TimelineEntriesView | undefined; data: TimelineEntriesView | undefined;
isLoading: boolean; isLoading: boolean;
@@ -335,14 +347,13 @@ export function TimelineProvider({
const selfEntriesViewQuery = trpc.timeline.getMyEntriesView.useQuery( const selfEntriesViewQuery = trpc.timeline.getMyEntriesView.useQuery(
timelineQueryInput, timelineQueryInput,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ {
enabled: !isRoleLoading && isSelfServiceTimeline, enabled: !isRoleLoading && isSelfServiceTimeline,
placeholderData: (prev: any) => prev, placeholderData: (prev: any) => prev,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
staleTime: 90_000, staleTime: 90_000,
}, },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as { ) as {
data: TimelineEntriesView | undefined; data: TimelineEntriesView | undefined;
isLoading: boolean; isLoading: boolean;
@@ -351,7 +362,12 @@ export function TimelineProvider({
}; };
const entriesViewQuery = isSelfServiceTimeline ? selfEntriesViewQuery : staffEntriesViewQuery; const entriesViewQuery = isSelfServiceTimeline ? selfEntriesViewQuery : staffEntriesViewQuery;
const { data: entriesView, isLoading, isError: isEntriesError, refetch: refetchEntriesView } = entriesViewQuery; const {
data: entriesView,
isLoading,
isError: isEntriesError,
refetch: refetchEntriesView,
} = entriesViewQuery;
const assignments = entriesView?.assignments ?? []; const assignments = entriesView?.assignments ?? [];
const demands = entriesView?.demands ?? []; const demands = entriesView?.demands ?? [];
@@ -374,37 +390,33 @@ export function TimelineProvider({
refetch: () => Promise<unknown>; refetch: () => Promise<unknown>;
}; };
const vacationEntriesQuery = vacationListQuery( const vacationEntriesQuery = vacationListQuery(
{ startDate: viewStart, endDate: viewEnd, status: [VacationStatus.APPROVED, VacationStatus.PENDING], limit: 500 }, {
startDate: viewStart,
endDate: viewEnd,
status: [VacationStatus.APPROVED, VacationStatus.PENDING],
limit: 500,
},
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 }, { placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
); );
const { const { data: vacationEntries = [], refetch: refetchVacations } = vacationEntriesQuery;
data: vacationEntries = [],
refetch: refetchVacations,
} = vacationEntriesQuery;
const staffHolidayOverlayQuery = trpc.timeline.getHolidayOverlays.useQuery( const staffHolidayOverlayQuery = trpc.timeline.getHolidayOverlays.useQuery(timelineQueryInput, {
timelineQueryInput, enabled: !isRoleLoading && !isSelfServiceTimeline,
{ placeholderData: (prev) => prev,
enabled: !isRoleLoading && !isSelfServiceTimeline, refetchOnWindowFocus: false,
placeholderData: (prev) => prev, staleTime: 90_000,
refetchOnWindowFocus: false, });
staleTime: 90_000, const selfHolidayOverlayQuery = trpc.timeline.getMyHolidayOverlays.useQuery(timelineQueryInput, {
}, enabled: !isRoleLoading && isSelfServiceTimeline,
); placeholderData: (prev) => prev,
const selfHolidayOverlayQuery = trpc.timeline.getMyHolidayOverlays.useQuery( refetchOnWindowFocus: false,
timelineQueryInput, staleTime: 90_000,
{ });
enabled: !isRoleLoading && isSelfServiceTimeline, const activeHolidayOverlayQuery = isSelfServiceTimeline
placeholderData: (prev) => prev, ? selfHolidayOverlayQuery
refetchOnWindowFocus: false, : staffHolidayOverlayQuery;
staleTime: 90_000, const { data: holidayOverlayEntries = [], refetch: refetchHolidayOverlays } =
}, activeHolidayOverlayQuery;
);
const activeHolidayOverlayQuery = isSelfServiceTimeline ? selfHolidayOverlayQuery : staffHolidayOverlayQuery;
const {
data: holidayOverlayEntries = [],
refetch: refetchHolidayOverlays,
} = activeHolidayOverlayQuery;
const initialRefreshKey = useMemo( const initialRefreshKey = useMemo(
() => () =>
@@ -510,7 +522,8 @@ export function TimelineProvider({
// Hide fully-filled demands (status COMPLETED or unfilledHeadcount <= 0) // Hide fully-filled demands (status COMPLETED or unfilledHeadcount <= 0)
const demandEntry = entry as { status?: string; unfilledHeadcount?: number }; const demandEntry = entry as { status?: string; unfilledHeadcount?: number };
if (demandEntry.status === "COMPLETED") return false; if (demandEntry.status === "COMPLETED") return false;
if (typeof demandEntry.unfilledHeadcount === "number" && demandEntry.unfilledHeadcount <= 0) return false; if (typeof demandEntry.unfilledHeadcount === "number" && demandEntry.unfilledHeadcount <= 0)
return false;
return true; return true;
}), }),
[demands, filters.hideCompletedProjects, filters.showDrafts, filters.showPlaceholders], [demands, filters.hideCompletedProjects, filters.showDrafts, filters.showPlaceholders],
@@ -642,7 +655,7 @@ export function TimelineProvider({
filters.eids, filters.eids,
filters.projectIds, filters.projectIds,
filters.clientIds, filters.clientIds,
]); // eslint-disable-line react-hooks/exhaustive-deps ]);
// ─── Project groups (for project view) ──────────────────────────────────── // ─── Project groups (for project view) ────────────────────────────────────
const projectGroups = useMemo(() => { const projectGroups = useMemo(() => {
@@ -714,18 +727,9 @@ export function TimelineProvider({
.sort((a, b) => a.startDate.getTime() - b.startDate.getTime()) .sort((a, b) => a.startDate.getTime() - b.startDate.getTime())
.filter((pg) => { .filter((pg) => {
if (projectFilter.size > 0 && !projectFilter.has(pg.id)) return false; if (projectFilter.size > 0 && !projectFilter.has(pg.id)) return false;
if ( if (clientFilter.size > 0 && (!pg.clientId || !clientFilter.has(pg.clientId))) return false;
clientFilter.size > 0 && if (chapterFilter.size > 0 && pg.resourceRows.length === 0) return false;
(!pg.clientId || !clientFilter.has(pg.clientId)) if (eidFilter.size > 0 && pg.resourceRows.length === 0) return false;
)
return false;
if (
chapterFilter.size > 0 &&
pg.resourceRows.length === 0
)
return false;
if (eidFilter.size > 0 && pg.resourceRows.length === 0)
return false;
return true; return true;
}); });
}, [ }, [
@@ -736,7 +740,7 @@ export function TimelineProvider({
filters.clientIds, filters.clientIds,
filters.chapters, filters.chapters,
filters.eids, filters.eids,
]); // eslint-disable-line react-hooks/exhaustive-deps ]);
// ─── Derived counts ─────────────────────────────────────────────────────── // ─── Derived counts ───────────────────────────────────────────────────────
const isInitialLoading = (isRoleLoading || isLoading) && !entriesView; const isInitialLoading = (isRoleLoading || isLoading) && !entriesView;
+204 -134
View File
@@ -48,13 +48,21 @@ import { InlineAllocationEditor } from "./InlineAllocationEditor.js";
export function TimelineView() { export function TimelineView() {
const { data: session, status: sessionStatus } = useSession(); const { data: session, status: sessionStatus } = useSession();
const mousePosRef = useRef({ x: 0, y: 0 }); const mousePosRef = useRef({ x: 0, y: 0 });
const role = sessionStatus === "authenticated" const role =
? ((session.user as { role?: string } | undefined)?.role ?? "USER") sessionStatus === "authenticated"
: null; ? ((session.user as { role?: string } | undefined)?.role ?? "USER")
: null;
const isSelfServiceTimeline = role === "USER" || role === "VIEWER"; const isSelfServiceTimeline = role === "USER" || role === "VIEWER";
const canManageTimeline = !isSelfServiceTimeline; const canManageTimeline = !isSelfServiceTimeline;
const { push: pushHistory, pushBatch: pushBatchHistory, undo, redo, canUndo, canRedo } = useAllocationHistory(); const {
push: pushHistory,
pushBatch: pushBatchHistory,
undo,
redo,
canUndo,
canRedo,
} = useAllocationHistory();
const pushHistoryRef = useRef(pushHistory); const pushHistoryRef = useRef(pushHistory);
pushHistoryRef.current = pushHistory; pushHistoryRef.current = pushHistory;
const pushBatchHistoryRef = useRef(pushBatchHistory); const pushBatchHistoryRef = useRef(pushBatchHistory);
@@ -145,7 +153,7 @@ export function TimelineView() {
pushHistoryRef.current(snapshot); pushHistoryRef.current(snapshot);
}, },
onShiftClickAlloc: (allocationId: string) => { onShiftClickAlloc: (allocationId: string) => {
setMultiSelectState(prev => { setMultiSelectState((prev) => {
const ids = new Set(prev.selectedAllocationIds); const ids = new Set(prev.selectedAllocationIds);
if (ids.has(allocationId)) { if (ids.has(allocationId)) {
ids.delete(allocationId); ids.delete(allocationId);
@@ -169,61 +177,64 @@ export function TimelineView() {
const [openPanelProjectId, setOpenPanelProjectId] = useState<string | null>(null); const [openPanelProjectId, setOpenPanelProjectId] = useState<string | null>(null);
const dragProjectId = dragState.isDragging ? dragState.projectId : null; const dragProjectId = dragState.isDragging ? dragState.projectId : null;
const contextProjectId = canManageTimeline ? (dragProjectId ?? openPanelProjectId) : null; const contextProjectId = canManageTimeline ? (dragProjectId ?? openPanelProjectId) : null;
const { contextResourceIds, contextAllocations } = useProjectDragContext(contextProjectId, canManageTimeline); const { contextResourceIds, contextAllocations } = useProjectDragContext(
contextProjectId,
canManageTimeline,
);
return ( return (
<> <>
<SuccessToast <SuccessToast
show={dragErrorToast !== null} show={dragErrorToast !== null}
message={dragErrorToast ?? ""} message={dragErrorToast ?? ""}
variant="warning" variant="warning"
onDone={() => setDragErrorToast(null)} onDone={() => setDragErrorToast(null)}
/>
<TimelineProvider
isDragging={dragState.isDragging}
contextAllocations={contextAllocations as TimelineAssignmentEntry[]}
>
<TimelineViewContent
mousePosRef={mousePosRef}
cellWidthRef={cellWidthRef}
dragState={dragState}
allocDragState={allocDragState}
rangeState={rangeState}
multiSelectState={multiSelectState}
setMultiSelectState={setMultiSelectState}
optimisticAllocations={optimisticAllocations}
reconcileOptimisticAllocations={reconcileOptimisticAllocations}
onCanvasRightMouseDown={onCanvasRightMouseDown}
clearMultiSelect={clearMultiSelect}
shiftPreview={shiftPreview}
isPreviewLoading={isPreviewLoading}
isApplying={isApplying}
isAllocSaving={isAllocSaving}
onProjectBarMouseDown={onProjectBarMouseDown}
onAllocMouseDown={onAllocMouseDown}
onRowMouseDown={onRowMouseDown}
onCanvasMouseMove={onCanvasMouseMove}
onCanvasMouseUp={onCanvasMouseUp}
onCanvasMouseLeave={onCanvasMouseLeave}
onProjectBarTouchStart={onProjectBarTouchStart}
onAllocTouchStart={onAllocTouchStart}
onRowTouchStart={onRowTouchStart}
onCanvasTouchMove={onCanvasTouchMove}
onCanvasTouchEnd={onCanvasTouchEnd}
contextResourceIds={contextResourceIds}
popover={popover}
setPopover={setPopover}
newAllocPopover={newAllocPopover}
setNewAllocPopover={setNewAllocPopover}
openPanelProjectId={openPanelProjectId}
setOpenPanelProjectId={setOpenPanelProjectId}
canUndo={canUndo}
canRedo={canRedo}
isSelfServiceTimeline={isSelfServiceTimeline}
undo={undo}
redo={redo}
/> />
</TimelineProvider> <TimelineProvider
isDragging={dragState.isDragging}
contextAllocations={contextAllocations as TimelineAssignmentEntry[]}
>
<TimelineViewContent
mousePosRef={mousePosRef}
cellWidthRef={cellWidthRef}
dragState={dragState}
allocDragState={allocDragState}
rangeState={rangeState}
multiSelectState={multiSelectState}
setMultiSelectState={setMultiSelectState}
optimisticAllocations={optimisticAllocations}
reconcileOptimisticAllocations={reconcileOptimisticAllocations}
onCanvasRightMouseDown={onCanvasRightMouseDown}
clearMultiSelect={clearMultiSelect}
shiftPreview={shiftPreview}
isPreviewLoading={isPreviewLoading}
isApplying={isApplying}
isAllocSaving={isAllocSaving}
onProjectBarMouseDown={onProjectBarMouseDown}
onAllocMouseDown={onAllocMouseDown}
onRowMouseDown={onRowMouseDown}
onCanvasMouseMove={onCanvasMouseMove}
onCanvasMouseUp={onCanvasMouseUp}
onCanvasMouseLeave={onCanvasMouseLeave}
onProjectBarTouchStart={onProjectBarTouchStart}
onAllocTouchStart={onAllocTouchStart}
onRowTouchStart={onRowTouchStart}
onCanvasTouchMove={onCanvasTouchMove}
onCanvasTouchEnd={onCanvasTouchEnd}
contextResourceIds={contextResourceIds}
popover={popover}
setPopover={setPopover}
newAllocPopover={newAllocPopover}
setNewAllocPopover={setNewAllocPopover}
openPanelProjectId={openPanelProjectId}
setOpenPanelProjectId={setOpenPanelProjectId}
canUndo={canUndo}
canRedo={canRedo}
isSelfServiceTimeline={isSelfServiceTimeline}
undo={undo}
redo={redo}
/>
</TimelineProvider>
</> </>
); );
} }
@@ -278,7 +289,9 @@ function TimelineViewContent({
multiSelectState: ReturnType<typeof useTimelineDrag>["multiSelectState"]; multiSelectState: ReturnType<typeof useTimelineDrag>["multiSelectState"];
setMultiSelectState: ReturnType<typeof useTimelineDrag>["setMultiSelectState"]; setMultiSelectState: ReturnType<typeof useTimelineDrag>["setMultiSelectState"];
optimisticAllocations: TimelineVisualOverrides; optimisticAllocations: TimelineVisualOverrides;
reconcileOptimisticAllocations: ReturnType<typeof useTimelineDrag>["reconcileOptimisticAllocations"]; reconcileOptimisticAllocations: ReturnType<
typeof useTimelineDrag
>["reconcileOptimisticAllocations"];
onCanvasRightMouseDown: ReturnType<typeof useTimelineDrag>["onCanvasRightMouseDown"]; onCanvasRightMouseDown: ReturnType<typeof useTimelineDrag>["onCanvasRightMouseDown"];
clearMultiSelect: ReturnType<typeof useTimelineDrag>["clearMultiSelect"]; clearMultiSelect: ReturnType<typeof useTimelineDrag>["clearMultiSelect"];
shiftPreview: ReturnType<typeof useTimelineDrag>["shiftPreview"]; shiftPreview: ReturnType<typeof useTimelineDrag>["shiftPreview"];
@@ -410,17 +423,15 @@ function TimelineViewContent({
} | null>(null); } | null>(null);
const hasActivePointerOverlay = const hasActivePointerOverlay =
dragState.isDragging || allocDragState.isActive || rangeState.isSelecting || multiSelectState.isMultiDragging; dragState.isDragging ||
allocDragState.isActive ||
rangeState.isSelecting ||
multiSelectState.isMultiDragging;
useEffect(() => { useEffect(() => {
if (optimisticAllocations.size === 0) return; if (optimisticAllocations.size === 0) return;
reconcileOptimisticAllocations([...visibleAssignments, ...visibleDemands]); reconcileOptimisticAllocations([...visibleAssignments, ...visibleDemands]);
}, [ }, [optimisticAllocations, reconcileOptimisticAllocations, visibleAssignments, visibleDemands]);
optimisticAllocations,
reconcileOptimisticAllocations,
visibleAssignments,
visibleDemands,
]);
useEffect(() => { useEffect(() => {
if (!hasActivePointerOverlay) return; if (!hasActivePointerOverlay) return;
@@ -473,12 +484,18 @@ function TimelineViewContent({
if (!allocs || allocs.length === 0) return null; if (!allocs || allocs.length === 0) return null;
const projectHours = new Map<string, number>(); const projectHours = new Map<string, number>();
for (const alloc of allocs) { for (const alloc of allocs) {
projectHours.set(alloc.projectId, (projectHours.get(alloc.projectId) ?? 0) + alloc.hoursPerDay); projectHours.set(
alloc.projectId,
(projectHours.get(alloc.projectId) ?? 0) + alloc.hoursPerDay,
);
} }
let maxPid: string | null = null; let maxPid: string | null = null;
let maxH = 0; let maxH = 0;
for (const [pid, h] of projectHours) { for (const [pid, h] of projectHours) {
if (h > maxH) { maxH = h; maxPid = pid; } if (h > maxH) {
maxH = h;
maxPid = pid;
}
} }
return maxPid; return maxPid;
}, [newAllocPopover, allocsByResource]); }, [newAllocPopover, allocsByResource]);
@@ -516,7 +533,7 @@ function TimelineViewContent({
const target: EventTarget = multiSelectState.isMultiDragging ? document : el; const target: EventTarget = multiSelectState.isMultiDragging ? document : el;
target.addEventListener("mousemove", handler as EventListener, { passive: true }); target.addEventListener("mousemove", handler as EventListener, { passive: true });
return () => target.removeEventListener("mousemove", handler as EventListener); return () => target.removeEventListener("mousemove", handler as EventListener);
}, [hasActivePointerOverlay, isLoading, mousePosRef, multiSelectState.isMultiDragging]); // eslint-disable-line react-hooks/exhaustive-deps }, [hasActivePointerOverlay, isLoading, mousePosRef, multiSelectState.isMultiDragging]);
// ─── Shift+wheel → horizontal scroll ────────────────────────────────────── // ─── Shift+wheel → horizontal scroll ──────────────────────────────────────
useEffect(() => { useEffect(() => {
@@ -530,7 +547,7 @@ function TimelineViewContent({
}; };
el.addEventListener("wheel", handler, { passive: false }); el.addEventListener("wheel", handler, { passive: false });
return () => el.removeEventListener("wheel", handler); return () => el.removeEventListener("wheel", handler);
}, [isLoading]); // eslint-disable-line react-hooks/exhaustive-deps }, [isLoading]);
// ─── Keyboard undo/redo ─────────────────────────────────────────────────── // ─── Keyboard undo/redo ───────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
@@ -555,7 +572,10 @@ function TimelineViewContent({
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if (e.key !== "Escape") return; if (e.key !== "Escape") return;
if (multiSelectState.selectedAllocationIds.length > 0 || multiSelectState.selectedResourceIds.length > 0) { if (
multiSelectState.selectedAllocationIds.length > 0 ||
multiSelectState.selectedResourceIds.length > 0
) {
e.preventDefault(); e.preventDefault();
clearMultiSelect(); clearMultiSelect();
return; return;
@@ -579,7 +599,19 @@ function TimelineViewContent({
}; };
window.addEventListener("keydown", handler); window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler);
}, [demandPopover, popover, newAllocPopover, openDemandToAssign, openPanelProjectId, setPopover, setNewAllocPopover, setOpenPanelProjectId, multiSelectState.selectedAllocationIds.length, multiSelectState.selectedResourceIds.length, clearMultiSelect]); }, [
demandPopover,
popover,
newAllocPopover,
openDemandToAssign,
openPanelProjectId,
setPopover,
setNewAllocPopover,
setOpenPanelProjectId,
multiSelectState.selectedAllocationIds.length,
multiSelectState.selectedResourceIds.length,
clearMultiSelect,
]);
// ─── Resource hover card — event delegation on label columns ────────────── // ─── Resource hover card — event delegation on label columns ──────────────
useEffect(() => { useEffect(() => {
@@ -623,7 +655,11 @@ function TimelineViewContent({
if (hasActivePointerOverlay) return; if (hasActivePointerOverlay) return;
const related = e.relatedTarget as HTMLElement | null; const related = e.relatedTarget as HTMLElement | null;
// Don't close if moving into another resource-hover target or the hover card itself // Don't close if moving into another resource-hover target or the hover card itself
if (related?.closest?.("[data-resource-hover-id]") || related?.closest?.("[data-resource-hover-card]")) return; if (
related?.closest?.("[data-resource-hover-id]") ||
related?.closest?.("[data-resource-hover-card]")
)
return;
if (resourceHoverTimerRef.current) { if (resourceHoverTimerRef.current) {
clearTimeout(resourceHoverTimerRef.current); clearTimeout(resourceHoverTimerRef.current);
@@ -646,7 +682,7 @@ function TimelineViewContent({
resourceHoverTimerRef.current = null; resourceHoverTimerRef.current = null;
} }
}; };
}, [resourceHover?.resourceId, isInitialLoading, hasActivePointerOverlay]); // eslint-disable-line react-hooks/exhaustive-deps }, [resourceHover?.resourceId, isInitialLoading, hasActivePointerOverlay]);
// ─── Scroll-left tracking for horizontal virtualization ──────────────────── // ─── Scroll-left tracking for horizontal virtualization ────────────────────
// Updated via RAF so React state only updates after a frame, not on every // Updated via RAF so React state only updates after a frame, not on every
@@ -655,7 +691,6 @@ function TimelineViewContent({
const scrollRafRef = useRef<number | null>(null); const scrollRafRef = useRef<number | null>(null);
const [scrollLeft, setScrollLeft] = useState(0); const [scrollLeft, setScrollLeft] = useState(0);
// ─── Navigation callbacks for TimelineToolbar ──────────────────────────── // ─── Navigation callbacks for TimelineToolbar ────────────────────────────
const handleNavigateBack = useCallback( const handleNavigateBack = useCallback(
() => setViewStart((v) => addDays(v, -28)), () => setViewStart((v) => addDays(v, -28)),
@@ -669,8 +704,12 @@ function TimelineViewContent({
() => setViewStart((v) => addDays(v, 28)), () => setViewStart((v) => addDays(v, 28)),
[setViewStart], [setViewStart],
); );
const handleUndo = useCallback(() => { void undo(); }, [undo]); const handleUndo = useCallback(() => {
const handleRedo = useCallback(() => { void redo(); }, [redo]); void undo();
}, [undo]);
const handleRedo = useCallback(() => {
void redo();
}, [redo]);
// ─── Scroll handler — extends date range and tracks scroll offset ───────── // ─── Scroll handler — extends date range and tracks scroll offset ─────────
const handleContainerScroll = useCallback(() => { const handleContainerScroll = useCallback(() => {
@@ -712,12 +751,14 @@ function TimelineViewContent({
setDemandPopover({ demand, x: anchorX, y: anchorY }); setDemandPopover({ demand, x: anchorX, y: anchorY });
return; return;
} }
const allocation = visibleAssignments.find((entry) => ( const allocation =
entry.id === info.allocationId visibleAssignments.find(
|| entry.entityId === info.allocationId (entry) =>
|| entry.sourceAllocationId === info.allocationId entry.id === info.allocationId ||
|| getPlanningEntryMutationId(entry) === info.allocationId entry.entityId === info.allocationId ||
)) ?? null; entry.sourceAllocationId === info.allocationId ||
getPlanningEntryMutationId(entry) === info.allocationId,
) ?? null;
setPopover({ setPopover({
allocationId: info.allocationId, allocationId: info.allocationId,
projectId: info.projectId, projectId: info.projectId,
@@ -754,15 +795,33 @@ function TimelineViewContent({
// memo() on ResourcePanel/ProjectPanel is not defeated by new fn refs. // memo() on ResourcePanel/ProjectPanel is not defeated by new fn refs.
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const stableNoop = useCallback((..._args: any[]) => undefined, []); const stableNoop = useCallback((..._args: any[]) => undefined, []);
const panelOnAllocMouseDown = (isSelfServiceTimeline ? stableNoop : onAllocMouseDown) as typeof onAllocMouseDown; const panelOnAllocMouseDown = (
const panelOnAllocTouchStart = (isSelfServiceTimeline ? stableNoop : onAllocTouchStart) as typeof onAllocTouchStart; isSelfServiceTimeline ? stableNoop : onAllocMouseDown
const panelOnRowMouseDown = (isSelfServiceTimeline ? stableNoop : onRowMouseDown) as typeof onRowMouseDown; ) as typeof onAllocMouseDown;
const panelOnRowTouchStart = (isSelfServiceTimeline ? stableNoop : onRowTouchStart) as typeof onRowTouchStart; const panelOnAllocTouchStart = (
const panelOnAllocationContextMenu = (isSelfServiceTimeline ? stableNoop : openAllocationPopoverAt) as typeof openAllocationPopoverAt; isSelfServiceTimeline ? stableNoop : onAllocTouchStart
const panelOnProjectBarMouseDown = (isSelfServiceTimeline ? stableNoop : onProjectBarMouseDown) as typeof onProjectBarMouseDown; ) as typeof onAllocTouchStart;
const panelOnProjectBarTouchStart = (isSelfServiceTimeline ? stableNoop : onProjectBarTouchStart) as typeof onProjectBarTouchStart; const panelOnRowMouseDown = (
const panelOnOpenPanel = (isSelfServiceTimeline ? stableNoop : setOpenPanelProjectId) as typeof setOpenPanelProjectId; isSelfServiceTimeline ? stableNoop : onRowMouseDown
const panelOnOpenDemandClick = (isSelfServiceTimeline ? stableNoop : handleOpenDemandClick) as typeof handleOpenDemandClick; ) as typeof onRowMouseDown;
const panelOnRowTouchStart = (
isSelfServiceTimeline ? stableNoop : onRowTouchStart
) as typeof onRowTouchStart;
const panelOnAllocationContextMenu = (
isSelfServiceTimeline ? stableNoop : openAllocationPopoverAt
) as typeof openAllocationPopoverAt;
const panelOnProjectBarMouseDown = (
isSelfServiceTimeline ? stableNoop : onProjectBarMouseDown
) as typeof onProjectBarMouseDown;
const panelOnProjectBarTouchStart = (
isSelfServiceTimeline ? stableNoop : onProjectBarTouchStart
) as typeof onProjectBarTouchStart;
const panelOnOpenPanel = (
isSelfServiceTimeline ? stableNoop : setOpenPanelProjectId
) as typeof setOpenPanelProjectId;
const panelOnOpenDemandClick = (
isSelfServiceTimeline ? stableNoop : handleOpenDemandClick
) as typeof handleOpenDemandClick;
// ─── Multi-select intersection computation ──────────────────────────────── // ─── Multi-select intersection computation ────────────────────────────────
useMultiSelectIntersection({ useMultiSelectIntersection({
@@ -854,7 +913,10 @@ function TimelineViewContent({
}} }}
onTouchEnd={(e) => void onCanvasTouchEnd(e)} onTouchEnd={(e) => void onCanvasTouchEnd(e)}
className={clsx( className={clsx(
(dragState.isDragging || allocDragState.isActive || multiSelectState.isMultiDragging) && "cursor-grabbing select-none", (dragState.isDragging ||
allocDragState.isActive ||
multiSelectState.isMultiDragging) &&
"cursor-grabbing select-none",
rangeState.isSelecting && "cursor-crosshair select-none", rangeState.isSelecting && "cursor-crosshair select-none",
multiSelectState.isSelecting && "cursor-crosshair select-none", multiSelectState.isSelecting && "cursor-crosshair select-none",
)} )}
@@ -1014,65 +1076,73 @@ function TimelineViewContent({
className="fixed z-50 bg-sky-700 text-white text-xs px-2.5 py-1.5 rounded-lg pointer-events-none shadow-lg font-medium" className="fixed z-50 bg-sky-700 text-white text-xs px-2.5 py-1.5 rounded-lg pointer-events-none shadow-lg font-medium"
style={{ left: mousePosRef.current.x + 14, top: mousePosRef.current.y - 36 }} style={{ left: mousePosRef.current.x + 14, top: mousePosRef.current.y - 36 }}
> >
{multiSelectState.multiDragMode === "resize-start" ? "Start " : multiSelectState.multiDragMode === "resize-end" ? "End " : ""} {multiSelectState.multiDragMode === "resize-start"
? "Start "
: multiSelectState.multiDragMode === "resize-end"
? "End "
: ""}
{multiSelectState.multiDragDaysDelta > 0 ? "+" : ""} {multiSelectState.multiDragDaysDelta > 0 ? "+" : ""}
{multiSelectState.multiDragDaysDelta}d {multiSelectState.multiDragDaysDelta}d ({multiSelectState.selectedAllocationIds.length}{" "}
{" "} allocations)
({multiSelectState.selectedAllocationIds.length} allocations)
</div> </div>
)} )}
{/* Allocation / Demand popover (click path) */} {/* Allocation / Demand popover (click path) */}
{!isSelfServiceTimeline && !hasActivePointerOverlay && popover && (() => { {!isSelfServiceTimeline &&
// Check if clicked allocation is actually a demand !hasActivePointerOverlay &&
const clickedDemand = openDemandsByProject.get(popover.projectId)?.find((d) => d.id === popover.allocationId); popover &&
if (clickedDemand) { (() => {
// Check if clicked allocation is actually a demand
const clickedDemand = openDemandsByProject
.get(popover.projectId)
?.find((d) => d.id === popover.allocationId);
if (clickedDemand) {
return (
<DemandPopover
demand={clickedDemand}
onClose={() => setPopover(null)}
onOpenPanel={(pid) => {
setPopover(null);
setOpenPanelProjectId(pid);
}}
onFillDemand={(d) => {
setPopover(null);
setOpenDemandToAssign({
id: d.id,
projectId: d.projectId,
roleId: d.roleId,
role: d.role,
headcount: d.requestedHeadcount,
startDate: new Date(d.startDate),
endDate: new Date(d.endDate),
hoursPerDay: d.hoursPerDay,
...(d.roleEntity !== undefined ? { roleEntity: d.roleEntity } : {}),
...(d.project !== undefined ? { project: d.project } : {}),
});
}}
anchorX={popover.x}
anchorY={popover.y}
ignoreScrollContainers={[scrollContainerRef]}
/>
);
}
return ( return (
<DemandPopover <AllocationPopover
demand={clickedDemand} allocationId={popover.allocationId}
projectId={popover.projectId}
initialAllocation={popover.allocation ?? null}
onClose={() => setPopover(null)} onClose={() => setPopover(null)}
onOpenPanel={(pid) => { onOpenPanel={(pid) => {
setPopover(null); setPopover(null);
setOpenPanelProjectId(pid); setOpenPanelProjectId(pid);
}} }}
onFillDemand={(d) => {
setPopover(null);
setOpenDemandToAssign({
id: d.id,
projectId: d.projectId,
roleId: d.roleId,
role: d.role,
headcount: d.requestedHeadcount,
startDate: new Date(d.startDate),
endDate: new Date(d.endDate),
hoursPerDay: d.hoursPerDay,
...(d.roleEntity !== undefined ? { roleEntity: d.roleEntity } : {}),
...(d.project !== undefined ? { project: d.project } : {}),
});
}}
anchorX={popover.x} anchorX={popover.x}
anchorY={popover.y} anchorY={popover.y}
ignoreScrollContainers={[scrollContainerRef]} ignoreScrollContainers={[scrollContainerRef]}
{...(popover.contextDate ? { contextDate: popover.contextDate } : {})}
/> />
); );
} })()}
return (
<AllocationPopover
allocationId={popover.allocationId}
projectId={popover.projectId}
initialAllocation={popover.allocation ?? null}
onClose={() => setPopover(null)}
onOpenPanel={(pid) => {
setPopover(null);
setOpenPanelProjectId(pid);
}}
anchorX={popover.x}
anchorY={popover.y}
ignoreScrollContainers={[scrollContainerRef]}
{...(popover.contextDate ? { contextDate: popover.contextDate } : {})}
/>
);
})()}
{/* Demand popover */} {/* Demand popover */}
{!isSelfServiceTimeline && !hasActivePointerOverlay && demandPopover && ( {!isSelfServiceTimeline && !hasActivePointerOverlay && demandPopover && (
+7 -16
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { Children, cloneElement, isValidElement, ReactNode } from "react"; import type { ReactNode } from "react";
import { Children, cloneElement, isValidElement } from "react";
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* ShimmerSkeleton */ /* ShimmerSkeleton */
@@ -44,21 +45,15 @@ export function ShimmerSkeleton({
const resolvedWidth = width ?? defaults?.width ?? "100%"; const resolvedWidth = width ?? defaults?.width ?? "100%";
const resolvedHeight = height ?? defaults?.height ?? 40; const resolvedHeight = height ?? defaults?.height ?? 40;
const resolvedRounded = rounded const resolvedRounded = rounded
? roundedMap[rounded] ?? "rounded-md" ? (roundedMap[rounded] ?? "rounded-md")
: defaults?.rounded ?? "rounded-md"; : (defaults?.rounded ?? "rounded-md");
return ( return (
<div <div
className={`shimmer-skeleton ${resolvedRounded} ${className}`} className={`shimmer-skeleton ${resolvedRounded} ${className}`}
style={{ style={{
width: width: typeof resolvedWidth === "number" ? `${resolvedWidth}px` : resolvedWidth,
typeof resolvedWidth === "number" height: typeof resolvedHeight === "number" ? `${resolvedHeight}px` : resolvedHeight,
? `${resolvedWidth}px`
: resolvedWidth,
height:
typeof resolvedHeight === "number"
? `${resolvedHeight}px`
: resolvedHeight,
}} }}
/> />
); );
@@ -74,11 +69,7 @@ interface ShimmerGroupProps {
className?: string; className?: string;
} }
export function ShimmerGroup({ export function ShimmerGroup({ children, staggerMs = 50, className }: ShimmerGroupProps) {
children,
staggerMs = 50,
className,
}: ShimmerGroupProps) {
return ( return (
<div className={className}> <div className={className}>
{Children.map(children, (child, index) => { {Children.map(children, (child, index) => {
@@ -10,7 +10,9 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { VACATION_TYPE_LABELS } from "~/lib/status-styles.js"; import { VACATION_TYPE_LABELS } from "~/lib/status-styles.js";
const VACATION_TYPES = Object.values(VacationType); const VACATION_TYPES = Object.values(VacationType);
const REQUESTABLE_VACATION_TYPES = VACATION_TYPES.filter((type) => type !== VacationType.PUBLIC_HOLIDAY); const REQUESTABLE_VACATION_TYPES = VACATION_TYPES.filter(
(type) => type !== VacationType.PUBLIC_HOLIDAY,
);
const HOLIDAY_SOURCE_LABELS = { const HOLIDAY_SOURCE_LABELS = {
CALENDAR: "Calendar", CALENDAR: "Calendar",
@@ -75,7 +77,11 @@ function getHolidaySourceLabel(source: string): string {
return source; return source;
} }
export function VacationModal({ resourceId: initialResourceId, onClose, onSuccess }: VacationModalProps) { export function VacationModal({
resourceId: initialResourceId,
onClose,
onSuccess,
}: VacationModalProps) {
const [resourceId, setResourceId] = useState(initialResourceId ?? ""); const [resourceId, setResourceId] = useState(initialResourceId ?? "");
const [type, setType] = useState<VacationType>(VacationType.ANNUAL); const [type, setType] = useState<VacationType>(VacationType.ANNUAL);
const [startDate, setStartDate] = useState(""); const [startDate, setStartDate] = useState("");
@@ -126,17 +132,17 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
}, },
{ {
enabled: enabled:
!!resourceId !!resourceId &&
&& !!debouncedStart !!debouncedStart &&
&& !!debouncedEnd !!debouncedEnd &&
&& (!isHalfDay || debouncedStart === debouncedEnd), (!isHalfDay || debouncedStart === debouncedEnd),
staleTime: 10_000, staleTime: 10_000,
}, },
); );
const utils = trpc.useUtils(); const utils = trpc.useUtils();
// @ts-ignore TS2589: tRPC infers union type too deeply for CreateVacationRequestSchema with .superRefine() // @ts-expect-error TS2589: tRPC infers union type too deeply for CreateVacationRequestSchema with .superRefine()
const createMutation = trpc.vacation.create.useMutation({ const createMutation = trpc.vacation.create.useMutation({
onSuccess: async () => { onSuccess: async () => {
await utils.vacation.list.invalidate(); await utils.vacation.list.invalidate();
@@ -177,14 +183,17 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
const inputClass = const inputClass =
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100"; "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100";
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"; const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
const resourceList: { id: string; displayName: string; eid: string }[] = resources?.resources ?? []; const resourceList: { id: string; displayName: string; eid: string }[] =
resources?.resources ?? [];
return ( return (
<AnimatedModal open={true} onClose={onClose} maxWidth="max-w-lg" className="mx-4"> <AnimatedModal open={true} onClose={onClose} maxWidth="max-w-lg" className="mx-4">
<div> <div>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700"> <div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Request Vacation</h2> <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Request Vacation
</h2>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
@@ -200,7 +209,8 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
{!initialResourceId && ( {!initialResourceId && (
<div> <div>
<label htmlFor="vac-resource" className={labelClass}> <label htmlFor="vac-resource" className={labelClass}>
Resource <span className="text-red-500">*</span><InfoTooltip content="The employee this vacation request is for." /> Resource <span className="text-red-500">*</span>
<InfoTooltip content="The employee this vacation request is for." />
</label> </label>
<select <select
id="vac-resource" id="vac-resource"
@@ -222,7 +232,8 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
{/* Type */} {/* Type */}
<div> <div>
<label htmlFor="vac-type" className={labelClass}> <label htmlFor="vac-type" className={labelClass}>
Type <span className="text-red-500">*</span><InfoTooltip content="ANNUAL = paid leave (deducted from entitlement) · SICK = sick leave · OTHER = special leave. Public holidays come from Holiday Calendars and are excluded automatically." /> Type <span className="text-red-500">*</span>
<InfoTooltip content="ANNUAL = paid leave (deducted from entitlement) · SICK = sick leave · OTHER = special leave. Public holidays come from Holiday Calendars and are excluded automatically." />
</label> </label>
<select <select
id="vac-type" id="vac-type"
@@ -242,7 +253,8 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label htmlFor="vac-start" className={labelClass}> <label htmlFor="vac-start" className={labelClass}>
Start Date <span className="text-red-500">*</span><InfoTooltip content="First day of leave (inclusive)." /> Start Date <span className="text-red-500">*</span>
<InfoTooltip content="First day of leave (inclusive)." />
</label> </label>
<DateInput <DateInput
id="vac-start" id="vac-start"
@@ -254,7 +266,8 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
</div> </div>
<div> <div>
<label htmlFor="vac-end" className={labelClass}> <label htmlFor="vac-end" className={labelClass}>
End Date <span className="text-red-500">*</span><InfoTooltip content="Last day of leave (inclusive)." /> End Date <span className="text-red-500">*</span>
<InfoTooltip content="Last day of leave (inclusive)." />
</label> </label>
<DateInput <DateInput
id="vac-end" id="vac-end"
@@ -276,7 +289,8 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
onChange={(e) => setIsHalfDay(e.target.checked)} onChange={(e) => setIsHalfDay(e.target.checked)}
className="rounded border-gray-300 dark:border-gray-600 text-brand-600 focus:ring-brand-500" className="rounded border-gray-300 dark:border-gray-600 text-brand-600 focus:ring-brand-500"
/> />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Half day</span><InfoTooltip content="Request only half a day off (morning or afternoon). Counts as 0.5 days against entitlement." /> <span className="text-sm font-medium text-gray-700 dark:text-gray-300">Half day</span>
<InfoTooltip content="Request only half a day off (morning or afternoon). Counts as 0.5 days against entitlement." />
</label> </label>
{isHalfDay && ( {isHalfDay && (
<div className="flex gap-3"> <div className="flex gap-3">
@@ -330,7 +344,10 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
return ( return (
<li key={v.id}> <li key={v.id}>
{r?.displayName ?? "—"}{" "} {r?.displayName ?? "—"}{" "}
<span className="text-blue-500">({new Date(v.startDate).toLocaleDateString("en-GB")} {new Date(v.endDate).toLocaleDateString("en-GB")})</span> <span className="text-blue-500">
({new Date(v.startDate).toLocaleDateString("en-GB")} {" "}
{new Date(v.endDate).toLocaleDateString("en-GB")})
</span>
</li> </li>
); );
})} })}
@@ -374,26 +391,46 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
</div> </div>
{buildHolidayBasisLabel(previewQuery.data).length > 0 && ( {buildHolidayBasisLabel(previewQuery.data).length > 0 && (
<div data-testid="vacation-preview-holiday-basis" className="rounded-md bg-white/70 px-3 py-2 text-xs sm:text-sm"> <div
data-testid="vacation-preview-holiday-basis"
className="rounded-md bg-white/70 px-3 py-2 text-xs sm:text-sm"
>
<span className="font-medium">Holiday basis:</span>{" "} <span className="font-medium">Holiday basis:</span>{" "}
{buildHolidayBasisLabel(previewQuery.data).join(" / ")} {buildHolidayBasisLabel(previewQuery.data).join(" / ")}
</div> </div>
)} )}
{(previewQuery.data.holidayContext.sources.hasCalendarHolidays || previewQuery.data.holidayContext.sources.hasLegacyPublicHolidayEntries) && ( {(previewQuery.data.holidayContext.sources.hasCalendarHolidays ||
<div data-testid="vacation-preview-holiday-sources" className="rounded-md bg-white/70 px-3 py-2 text-xs sm:text-sm"> previewQuery.data.holidayContext.sources.hasLegacyPublicHolidayEntries) && (
<div
data-testid="vacation-preview-holiday-sources"
className="rounded-md bg-white/70 px-3 py-2 text-xs sm:text-sm"
>
<span className="font-medium">Sources:</span>{" "} <span className="font-medium">Sources:</span>{" "}
{[ {[
previewQuery.data.holidayContext.sources.hasCalendarHolidays ? "Holiday Calendar" : null, previewQuery.data.holidayContext.sources.hasCalendarHolidays
previewQuery.data.holidayContext.sources.hasLegacyPublicHolidayEntries ? "Legacy public holiday entries" : null, ? "Holiday Calendar"
].filter(Boolean).join(" + ")} : null,
previewQuery.data.holidayContext.sources.hasLegacyPublicHolidayEntries
? "Legacy public holiday entries"
: null,
]
.filter(Boolean)
.join(" + ")}
</div> </div>
)} )}
{previewQuery.data.publicHolidayDates.length > 0 && ( {previewQuery.data.publicHolidayDates.length > 0 && (
<div data-testid="vacation-preview-public-holidays" className="text-xs sm:text-sm"> <div
data-testid="vacation-preview-public-holidays"
className="text-xs sm:text-sm"
>
<span className="font-medium">Excluded public holidays:</span>{" "} <span className="font-medium">Excluded public holidays:</span>{" "}
{previewQuery.data.holidayDetails.map((holiday) => `${holiday.date} (${getHolidaySourceLabel(holiday.source)})`).join(", ")} {previewQuery.data.holidayDetails
.map(
(holiday) => `${holiday.date} (${getHolidaySourceLabel(holiday.source)})`,
)
.join(", ")}
</div> </div>
)} )}
@@ -406,9 +443,7 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
)} )}
{previewQuery.error && ( {previewQuery.error && (
<div className="mt-2 text-xs text-red-700"> <div className="mt-2 text-xs text-red-700">{previewQuery.error.message}</div>
{previewQuery.error.message}
</div>
)} )}
</div> </div>
)} )}
+93 -74
View File
@@ -1,10 +1,7 @@
"use client"; "use client";
import { useState, useCallback, useEffect, useRef } from "react"; import { useState, useCallback, useEffect, useRef } from "react";
import { import { type DashboardLayoutConfig, type DashboardWidgetType } from "@capakraken/shared/types";
type DashboardLayoutConfig,
type DashboardWidgetType,
} from "@capakraken/shared/types";
import { import {
createDashboardWidget, createDashboardWidget,
createDefaultDashboardLayout, createDefaultDashboardLayout,
@@ -46,14 +43,15 @@ export function shouldHydrateDashboardFromDb(params: {
hasLocalChangesBeforeHydration: boolean; hasLocalChangesBeforeHydration: boolean;
}): boolean { }): boolean {
const { remoteLayout, hasHydratedFromDb, hasLocalChangesBeforeHydration } = params; const { remoteLayout, hasHydratedFromDb, hasLocalChangesBeforeHydration } = params;
return remoteLayout !== null return (
&& remoteLayout !== undefined remoteLayout !== null &&
&& !hasHydratedFromDb remoteLayout !== undefined &&
&& !hasLocalChangesBeforeHydration; !hasHydratedFromDb &&
!hasLocalChangesBeforeHydration
);
} }
export function useDashboardLayout() { export function useDashboardLayout() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: meData } = trpc.user.me.useQuery() as { data: { id?: string } | null | undefined }; const { data: meData } = trpc.user.me.useQuery() as { data: { id?: string } | null | undefined };
const userId = meData?.id ?? null; const userId = meData?.id ?? null;
@@ -74,7 +72,12 @@ export function useDashboardLayout() {
// Once userId is known, hydrate from user-scoped localStorage (if no DB data yet). // Once userId is known, hydrate from user-scoped localStorage (if no DB data yet).
useEffect(() => { useEffect(() => {
if (!userId || hasHydratedFromStorageRef.current || hasHydratedFromDbRef.current || hasLocalChangesBeforeHydrationRef.current) { if (
!userId ||
hasHydratedFromStorageRef.current ||
hasHydratedFromDbRef.current ||
hasLocalChangesBeforeHydrationRef.current
) {
return; return;
} }
const stored = loadFromStorage(userId); const stored = loadFromStorage(userId);
@@ -90,7 +93,6 @@ export function useDashboardLayout() {
setIsHydrated(true); setIsHydrated(true);
}, [userId]); }, [userId]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: dbData } = trpc.user.getDashboardLayout.useQuery(undefined, { const { data: dbData } = trpc.user.getDashboardLayout.useQuery(undefined, {
staleTime: 30_000, staleTime: 30_000,
}) as { data: { layout: DashboardLayoutConfig | null; updatedAt: unknown } | null | undefined }; }) as { data: { layout: DashboardLayoutConfig | null; updatedAt: unknown } | null | undefined };
@@ -122,11 +124,13 @@ export function useDashboardLayout() {
return; return;
} }
if (!shouldHydrateDashboardFromDb({ if (
remoteLayout, !shouldHydrateDashboardFromDb({
hasHydratedFromDb: hasHydratedFromDbRef.current, remoteLayout,
hasLocalChangesBeforeHydration: hasLocalChangesBeforeHydrationRef.current, hasHydratedFromDb: hasHydratedFromDbRef.current,
})) { hasLocalChangesBeforeHydration: hasLocalChangesBeforeHydrationRef.current,
})
) {
// DB data present but local changes already in-flight — keep local state, mark done. // DB data present but local changes already in-flight — keep local state, mark done.
hasHydratedFromDbRef.current = true; hasHydratedFromDbRef.current = true;
setIsHydrated(true); setIsHydrated(true);
@@ -159,69 +163,84 @@ export function useDashboardLayout() {
// Flush any pending debounced DB save when the component unmounts so that // Flush any pending debounced DB save when the component unmounts so that
// navigating away within the 2-second window doesn't silently lose changes. // navigating away within the 2-second window doesn't silently lose changes.
useEffect(() => () => { useEffect(
if (saveTimeoutRef.current) { () => () => {
clearTimeout(saveTimeoutRef.current); if (saveTimeoutRef.current) {
saveTimeoutRef.current = null; clearTimeout(saveTimeoutRef.current);
if (pendingLayoutSaveRef.current) { saveTimeoutRef.current = null;
saveMutationRef.current.mutate({ layout: pendingLayoutSaveRef.current }); if (pendingLayoutSaveRef.current) {
pendingLayoutSaveRef.current = null; saveMutationRef.current.mutate({ layout: pendingLayoutSaveRef.current });
pendingLayoutSaveRef.current = null;
}
} }
} },
}, []); [],
);
const persist = useCallback((nextConfig: DashboardLayoutConfig) => { const persist = useCallback(
if (!hasHydratedFromDbRef.current) { (nextConfig: DashboardLayoutConfig) => {
hasLocalChangesBeforeHydrationRef.current = true; if (!hasHydratedFromDbRef.current) {
} hasLocalChangesBeforeHydrationRef.current = true;
const newConfig = normalizeDashboardLayout(nextConfig); }
if (userId) saveToStorage(userId, newConfig); const newConfig = normalizeDashboardLayout(nextConfig);
pendingLayoutSaveRef.current = newConfig; if (userId) saveToStorage(userId, newConfig);
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); pendingLayoutSaveRef.current = newConfig;
saveTimeoutRef.current = setTimeout(() => { if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
pendingLayoutSaveRef.current = null; saveTimeoutRef.current = setTimeout(() => {
saveMutation.mutate({ layout: newConfig }); pendingLayoutSaveRef.current = null;
}, 2000); saveMutation.mutate({ layout: newConfig });
}, [saveMutation, userId]); }, 2000);
},
[saveMutation, userId],
);
const addWidget = useCallback((type: DashboardWidgetType) => { const addWidget = useCallback(
setConfig((prev) => { (type: DashboardWidgetType) => {
const newConfig = { setConfig((prev) => {
...prev, const newConfig = {
widgets: [ ...prev,
...prev.widgets, widgets: [
createDashboardWidget(type, { ...prev.widgets,
id: generateWidgetId(), createDashboardWidget(type, {
x: 0, id: generateWidgetId(),
y: getNextDashboardWidgetY(prev.widgets), x: 0,
}), y: getNextDashboardWidgetY(prev.widgets),
], }),
}; ],
persist(newConfig); };
return newConfig; persist(newConfig);
}); return newConfig;
}, [persist]); });
},
[persist],
);
const removeWidget = useCallback((id: string) => { const removeWidget = useCallback(
setConfig((prev) => { (id: string) => {
const newConfig = { ...prev, widgets: prev.widgets.filter((w) => w.id !== id) }; setConfig((prev) => {
persist(newConfig); const newConfig = { ...prev, widgets: prev.widgets.filter((w) => w.id !== id) };
return newConfig; persist(newConfig);
}); return newConfig;
}, [persist]); });
},
[persist],
);
const updateWidgetConfig = useCallback((id: string, configUpdate: Record<string, unknown>) => { const updateWidgetConfig = useCallback(
setConfig((prev) => { (id: string, configUpdate: Record<string, unknown>) => {
const newConfig = { setConfig((prev) => {
...prev, const newConfig = {
widgets: prev.widgets.map((w) => ...prev,
w.id === id ? { ...w, config: { ...w.config, ...configUpdate } } : w, widgets: prev.widgets.map((w) =>
), w.id === id ? { ...w, config: { ...w.config, ...configUpdate } } : w,
}; ),
persist(newConfig); };
return newConfig; persist(newConfig);
}); return newConfig;
}, [persist]); });
},
[persist],
);
const onLayoutChange = useCallback( const onLayoutChange = useCallback(
(layout: { i: string; x: number; y: number; w: number; h: number }[]) => { (layout: { i: string; x: number; y: number; w: number; h: number }[]) => {
+5 -3
View File
@@ -7,7 +7,7 @@ import { trpc } from "~/lib/trpc/client.js";
* Fetches full project context when a project is being dragged or the panel opens. * Fetches full project context when a project is being dragged or the panel opens.
* Returns the project's resources, their own allocations, and all cross-project allocations. * Returns the project's resources, their own allocations, and all cross-project allocations.
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type ProjectDragContextResult = { type ProjectDragContextResult = {
contextResourceIds: string[]; contextResourceIds: string[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -18,8 +18,10 @@ type ProjectDragContextResult = {
project: any | null; project: any | null;
}; };
export function useProjectDragContext(projectId: string | null, enabled = true): ProjectDragContextResult { export function useProjectDragContext(
// eslint-disable-next-line @typescript-eslint/no-explicit-any projectId: string | null,
enabled = true,
): ProjectDragContextResult {
const { data } = trpc.timeline.getProjectContext.useQuery( const { data } = trpc.timeline.getProjectContext.useQuery(
{ projectId: projectId! }, { projectId: projectId! },
{ enabled: enabled && !!projectId, staleTime: 10_000 }, { enabled: enabled && !!projectId, staleTime: 10_000 },
+2 -1
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState } from "react"; import type React from "react";
import { useEffect, useRef, useState } from "react";
import type { RefObject } from "react"; import type { RefObject } from "react";
interface UseTimelineKeyboardOptions { interface UseTimelineKeyboardOptions {
+34 -18
View File
@@ -35,23 +35,29 @@ export function useTimelineLayout(
); );
// Grid lines — memoized; identical for every row // Grid lines — memoized; identical for every row
const gridLines = useMemo(() => dates.map((date, i) => { const gridLines = useMemo(
const isToday = date.toDateString() === today.toDateString(); () =>
const dow = date.getDay(); dates.map((date, i) => {
const isWeekend = dow === 0 || dow === 6; const isToday = date.toDateString() === today.toDateString();
return ( const dow = date.getDay();
<div const isWeekend = dow === 0 || dow === 6;
key={i} return (
className={clsx( <div
"absolute top-0 bottom-0 border-r", key={i}
isToday ? "border-brand-300 dark:border-brand-700 border-r-2" : className={clsx(
isWeekend ? "border-brand-200 dark:border-brand-800 bg-brand-50/40 dark:bg-brand-950/20" : "absolute top-0 bottom-0 border-r",
"border-gray-100 dark:border-gray-800", isToday
)} ? "border-brand-300 dark:border-brand-700 border-r-2"
style={{ left: i * CELL_WIDTH, width: CELL_WIDTH }} : isWeekend
/> ? "border-brand-200 dark:border-brand-800 bg-brand-50/40 dark:bg-brand-950/20"
); : "border-gray-100 dark:border-gray-800",
}), [dates, CELL_WIDTH, today]); // eslint-disable-line react-hooks/exhaustive-deps )}
style={{ left: i * CELL_WIDTH, width: CELL_WIDTH }}
/>
);
}),
[dates, CELL_WIDTH, today],
);
// Month groups for the month header // Month groups for the month header
const monthGroups = useMemo(() => { const monthGroups = useMemo(() => {
@@ -72,5 +78,15 @@ export function useTimelineLayout(
return dates[colIndex] ?? today; return dates[colIndex] ?? today;
} }
return { CELL_WIDTH, dates, visibleDays, totalCanvasWidth, toLeft, toWidth, gridLines, monthGroups, xToDate }; return {
CELL_WIDTH,
dates,
visibleDays,
totalCanvasWidth,
toLeft,
toWidth,
gridLines,
monthGroups,
xToDate,
};
} }
+5 -6
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState, type CSSProperties } from "react"; import type React from "react";
import { useEffect, useRef, useState, type CSSProperties } from "react";
type PopoverAnchor = type PopoverAnchor =
| { kind: "point"; x: number; y: number } | { kind: "point"; x: number; y: number }
@@ -113,8 +114,8 @@ export function useViewportPopover({
return; return;
} }
if ( if (
target instanceof Element target instanceof Element &&
&& ignoreSelectors.some((selector) => target.closest(selector) !== null) ignoreSelectors.some((selector) => target.closest(selector) !== null)
) { ) {
return; return;
} }
@@ -190,9 +191,7 @@ export function useViewportPopover({
if ( if (
ignoreScrollContainers?.some( ignoreScrollContainers?.some(
(r) => (r) =>
r.current != null && r.current != null && scrollTarget instanceof Node && r.current.contains(scrollTarget),
scrollTarget instanceof Node &&
r.current.contains(scrollTarget),
) )
) { ) {
return; return;
+1
View File
@@ -70,6 +70,7 @@ services:
DATABASE_URL: postgresql://capakraken:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/capakraken DATABASE_URL: postgresql://capakraken:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/capakraken
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
RATE_LIMIT_BACKEND: ${RATE_LIMIT_BACKEND:-redis} RATE_LIMIT_BACKEND: ${RATE_LIMIT_BACKEND:-redis}
NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN:-}
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
+4 -1
View File
@@ -33,11 +33,14 @@
"db:import:dispo": "pnpm --filter @capakraken/db db:import:dispo", "db:import:dispo": "pnpm --filter @capakraken/db db:import:dispo",
"db:readiness:demand-assignment": "pnpm --filter @capakraken/db db:readiness:demand-assignment", "db:readiness:demand-assignment": "pnpm --filter @capakraken/db db:readiness:demand-assignment",
"format": "prettier --write \"**/*.{ts,tsx,md,json}\"", "format": "prettier --write \"**/*.{ts,tsx,md,json}\"",
"typecheck": "node ./scripts/run-from-workspace-root.mjs turbo typecheck" "typecheck": "node ./scripts/run-from-workspace-root.mjs turbo typecheck",
"prepare": "husky"
}, },
"devDependencies": { "devDependencies": {
"@capakraken/eslint-config": "workspace:*", "@capakraken/eslint-config": "workspace:*",
"@capakraken/tsconfig": "workspace:*", "@capakraken/tsconfig": "workspace:*",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"turbo": "^2.3.3", "turbo": "^2.3.3",
"typescript": "^5.6.3" "typescript": "^5.6.3"
+353 -194
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -12,7 +12,8 @@
"@typescript-eslint/eslint-plugin": "^8.18.0", "@typescript-eslint/eslint-plugin": "^8.18.0",
"@typescript-eslint/parser": "^8.18.0", "@typescript-eslint/parser": "^8.18.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.31.0" "eslint-plugin-import": "^2.31.0",
"eslint-plugin-react-hooks": "^7.0.1"
}, },
"peerDependencies": { "peerDependencies": {
"eslint": "^9.0.0", "eslint": "^9.0.0",
+10 -1
View File
@@ -15,12 +15,21 @@ export default [
project: true, project: true,
}, },
}, },
linterOptions: {
reportUnusedDisableDirectives: "error",
},
rules: { rules: {
...tsPlugin.configs["recommended"].rules, ...tsPlugin.configs["recommended"].rules,
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/consistent-type-imports": "error", "@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/no-explicit-any": "error", "@typescript-eslint/no-explicit-any": "error",
"no-console": ["warn", { allow: ["warn", "error"] }], "@typescript-eslint/ban-ts-comment": ["error", {
"ts-ignore": true,
"ts-expect-error": "allow-with-description",
"ts-nocheck": true,
"ts-check": false,
}],
"no-console": ["error", { allow: ["warn", "error"] }],
}, },
}, },
prettierConfig, prettierConfig,
+6
View File
@@ -1,10 +1,16 @@
import baseConfig from "./base.js"; import baseConfig from "./base.js";
import reactHooks from "eslint-plugin-react-hooks";
/** @type {import("eslint").Linter.FlatConfig[]} */ /** @type {import("eslint").Linter.FlatConfig[]} */
export default [ export default [
...baseConfig, ...baseConfig,
{ {
plugins: {
"react-hooks": reactHooks,
},
rules: { rules: {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"@typescript-eslint/no-misused-promises": [ "@typescript-eslint/no-misused-promises": [
"error", "error",
{ checksVoidReturn: { attributes: false } }, { checksVoidReturn: { attributes: false } },