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:
@@ -83,6 +83,13 @@ PGADMIN_PASSWORD=
|
||||
# If not set, cron endpoints are disabled.
|
||||
# CRON_SECRET=
|
||||
|
||||
# ─── Error Tracking (Sentry) ─────────────────────────────────────────────────
|
||||
|
||||
# Sentry DSN for client-side and server-side error reporting.
|
||||
# Create a Next.js project at https://sentry.io and copy the DSN here.
|
||||
# If not set, Sentry is disabled (SDK is installed but sends nothing).
|
||||
# NEXT_PUBLIC_SENTRY_DSN=
|
||||
|
||||
# ─── Testing (never enable in production) ────────────────────────────────────
|
||||
|
||||
# Disables rate limiting and session tracking during end-to-end tests.
|
||||
|
||||
@@ -198,6 +198,20 @@ jobs:
|
||||
pnpm --filter @capakraken/shared exec vitest run --coverage
|
||||
pnpm --filter @capakraken/db test:unit
|
||||
|
||||
- name: Upload coverage reports
|
||||
uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: coverage-reports
|
||||
path: |
|
||||
apps/web/coverage/
|
||||
packages/engine/coverage/
|
||||
packages/staffing/coverage/
|
||||
packages/api/coverage/
|
||||
packages/application/coverage/
|
||||
packages/shared/coverage/
|
||||
retention-days: 14
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Build — depends on typecheck passing
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pnpm exec lint-staged
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"*.{ts,tsx}": ["eslint --fix", "prettier --write"],
|
||||
"*.{json,md}": ["prettier --write"]
|
||||
}
|
||||
@@ -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,
|
||||
}],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -6,7 +6,7 @@
|
||||
"dev": "next dev -p 3100",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3100",
|
||||
"lint": "next lint",
|
||||
"lint": "eslint src/",
|
||||
"typecheck": "tsc --project tsconfig.typecheck.json --noEmit",
|
||||
"test:unit": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
@@ -49,6 +49,7 @@
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capakraken/eslint-config": "workspace:*",
|
||||
"@capakraken/tsconfig": "workspace:*",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
@@ -59,6 +60,7 @@
|
||||
"@types/three": "^0.183.1",
|
||||
"@vitest/coverage-v8": "^2.1.9",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^10.2.0",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.6.3",
|
||||
|
||||
@@ -5,8 +5,8 @@ import { useUrlFilters } from "~/hooks/useUrlFilters.js";
|
||||
import { useDebounce } from "~/hooks/useDebounce.js";
|
||||
import { createPortal } from "react-dom";
|
||||
import { formatDate, formatMoney } from "~/lib/format.js";
|
||||
import type { Project, ColumnDef } from "@capakraken/shared";
|
||||
import { ProjectStatus, PROJECT_COLUMNS, BlueprintTarget } from "@capakraken/shared";
|
||||
import type { Project, ColumnDef, ProjectStatus } from "@capakraken/shared";
|
||||
import { PROJECT_COLUMNS, BlueprintTarget } from "@capakraken/shared";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { clsx } from "clsx";
|
||||
@@ -32,7 +32,10 @@ import { DraggableTableRow } from "~/components/ui/DraggableTableRow.js";
|
||||
import { ShoringBadge } from "~/components/projects/ShoringIndicator.js";
|
||||
import { SuccessToast } from "~/components/ui/SuccessToast.js";
|
||||
|
||||
import { PROJECT_STATUS_BADGE as STATUS_COLORS, ORDER_TYPE_BADGE as ORDER_TYPE_COLORS } from "~/lib/status-styles.js";
|
||||
import {
|
||||
PROJECT_STATUS_BADGE as STATUS_COLORS,
|
||||
ORDER_TYPE_BADGE as ORDER_TYPE_COLORS,
|
||||
} from "~/lib/status-styles.js";
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -53,7 +56,13 @@ const ALL_ORDER_TYPES = [
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
function BudgetBar({ utilizationPercent, budgetCents }: { utilizationPercent: number; budgetCents: number }) {
|
||||
function BudgetBar({
|
||||
utilizationPercent,
|
||||
budgetCents,
|
||||
}: {
|
||||
utilizationPercent: number;
|
||||
budgetCents: number;
|
||||
}) {
|
||||
if (budgetCents === 0) {
|
||||
return <div className="text-xs text-gray-400">No budget</div>;
|
||||
}
|
||||
@@ -66,14 +75,27 @@ function BudgetBar({ utilizationPercent, budgetCents }: { utilizationPercent: nu
|
||||
return (
|
||||
<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={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 className="text-xs text-gray-500">{utilizationPercent.toFixed(0)}% used</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 triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -110,7 +132,10 @@ function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: Project
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); isOpen ? onClose() : onOpen(); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
isOpen ? onClose() : onOpen();
|
||||
}}
|
||||
className={clsx(
|
||||
"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",
|
||||
@@ -118,40 +143,54 @@ function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: Project
|
||||
title="Click to change 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" />
|
||||
</svg>
|
||||
</button>
|
||||
{isOpen && createPortal(
|
||||
<motion.div
|
||||
ref={panelRef}
|
||||
initial={{ opacity: 0, scaleY: 0.9 }}
|
||||
animate={{ opacity: 1, scaleY: 1 }}
|
||||
transition={{ duration: 0.12, ease: "easeOut" }}
|
||||
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
|
||||
key={s.value}
|
||||
type="button"
|
||||
disabled={s.value === project.status || updateStatus.isPending}
|
||||
onClick={(e) => { e.stopPropagation(); updateStatus.mutate({ id: project.id, status: s.value as never }); }}
|
||||
className={clsx(
|
||||
"w-full rounded-xl px-3 py-2 text-left text-xs transition",
|
||||
s.value === project.status
|
||||
? "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",
|
||||
)}
|
||||
>
|
||||
<span className={clsx("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,
|
||||
)}
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<motion.div
|
||||
ref={panelRef}
|
||||
initial={{ opacity: 0, scaleY: 0.9 }}
|
||||
animate={{ opacity: 1, scaleY: 1 }}
|
||||
transition={{ duration: 0.12, ease: "easeOut" }}
|
||||
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
|
||||
key={s.value}
|
||||
type="button"
|
||||
disabled={s.value === project.status || updateStatus.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateStatus.mutate({ id: project.id, status: s.value as never });
|
||||
}}
|
||||
className={clsx(
|
||||
"w-full rounded-xl px-3 py-2 text-left text-xs transition",
|
||||
s.value === project.status
|
||||
? "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",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
"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
|
||||
useEffect(() => {
|
||||
setFilters({ search: debouncedSearch });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearch]);
|
||||
|
||||
// Keep local input in sync when URL changes externally (e.g. back/forward)
|
||||
useEffect(() => {
|
||||
setSearchInput(filters.search);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filters.search]);
|
||||
|
||||
const setStatusFilter = useCallback((v: string) => setFilters({ status: v }), [setFilters]);
|
||||
@@ -207,7 +245,10 @@ export function ProjectsClient() {
|
||||
const [successToast, setSuccessToast] = useState<string | null>(null);
|
||||
const [openStatusProjectId, setOpenStatusProjectId] = useState<string | null>(null);
|
||||
const [batchStatusPicker, setBatchStatusPicker] = useState(false);
|
||||
const [confirmBatchStatus, setConfirmBatchStatus] = useState<{ ids: string[]; status: string } | null>(null);
|
||||
const [confirmBatchStatus, setConfirmBatchStatus] = useState<{
|
||||
ids: string[];
|
||||
status: string;
|
||||
} | null>(null);
|
||||
const [confirmBatchDelete, setConfirmBatchDelete] = useState<string[] | null>(null);
|
||||
|
||||
const selection = useSelection();
|
||||
@@ -229,7 +270,9 @@ export function ProjectsClient() {
|
||||
});
|
||||
|
||||
// ─── Favorites ──────────────────────────────────────────────────────────
|
||||
const { data: favoriteIds } = trpc.user.getFavoriteProjectIds.useQuery(undefined, { staleTime: 30_000 });
|
||||
const { data: favoriteIds } = trpc.user.getFavoriteProjectIds.useQuery(undefined, {
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const favSet = useMemo(() => new Set(favoriteIds ?? []), [favoriteIds]);
|
||||
const toggleFavMutation = trpc.user.toggleFavoriteProject.useMutation({
|
||||
onMutate: async ({ projectId }) => {
|
||||
@@ -300,7 +343,7 @@ export function ProjectsClient() {
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
// Keep this boundary shallow; full TRPC inference here can trip TS depth limits.
|
||||
// Keep this boundary shallow; full TRPC inference here can trip TS depth limits.
|
||||
} = (trpc.project.listWithCosts.useInfiniteQuery as any)(
|
||||
{
|
||||
search: search || undefined,
|
||||
@@ -308,9 +351,12 @@ export function ProjectsClient() {
|
||||
limit: 50,
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage: { nextCursor?: string | null }) => lastPage.nextCursor ?? undefined,
|
||||
getNextPageParam: (lastPage: { nextCursor?: string | null }) =>
|
||||
lastPage.nextCursor ?? undefined,
|
||||
initialCursor: undefined,
|
||||
placeholderData: (prev: { pages: { projects: ProjectRow[]; nextCursor?: string | null }[] } | undefined) => prev,
|
||||
placeholderData: (
|
||||
prev: { pages: { projects: ProjectRow[]; nextCursor?: string | null }[] } | undefined,
|
||||
) => prev,
|
||||
staleTime: 15_000,
|
||||
},
|
||||
) as {
|
||||
@@ -332,7 +378,8 @@ export function ProjectsClient() {
|
||||
|
||||
// Client-side orderType filter
|
||||
const filteredProjects = useMemo(
|
||||
() => (orderTypeFilter ? allProjects.filter((p) => p.orderType === orderTypeFilter) : allProjects),
|
||||
() =>
|
||||
orderTypeFilter ? allProjects.filter((p) => p.orderType === orderTypeFilter) : allProjects,
|
||||
[allProjects, orderTypeFilter],
|
||||
);
|
||||
|
||||
@@ -345,29 +392,41 @@ export function ProjectsClient() {
|
||||
viewPrefs.setSavedSort(field && dir ? { field, dir } : null);
|
||||
},
|
||||
});
|
||||
const { orderedRows: projects, reorder, isCustomOrder, resetOrder } = useRowOrder(
|
||||
sorted,
|
||||
viewPrefs,
|
||||
sortField,
|
||||
reset,
|
||||
);
|
||||
const {
|
||||
orderedRows: projects,
|
||||
reorder,
|
||||
isCustomOrder,
|
||||
resetOrder,
|
||||
} = useRowOrder(sorted, viewPrefs, sortField, reset);
|
||||
const rowDragRef = useRef<string | null>(null);
|
||||
|
||||
const projectIds = projects.map((p) => p.id);
|
||||
|
||||
useEffect(() => {
|
||||
selection.clear();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [search, statusFilter, orderTypeFilter]);
|
||||
|
||||
const handleFetchNext = useCallback(() => {
|
||||
if (hasNextPage && !isFetchingNextPage) void fetchNextPage();
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
function openNewModal() { setEditingProject(null); setModalOpen(true); }
|
||||
function openEditModal(project: Project) { setEditingProject(project); setModalOpen(true); }
|
||||
function closeModal() { setModalOpen(false); setEditingProject(null); }
|
||||
function clearAll() { setSearchInput(""); setFilters({ search: "", status: "", orderType: "" }); }
|
||||
function openNewModal() {
|
||||
setEditingProject(null);
|
||||
setModalOpen(true);
|
||||
}
|
||||
function openEditModal(project: Project) {
|
||||
setEditingProject(project);
|
||||
setModalOpen(true);
|
||||
}
|
||||
function closeModal() {
|
||||
setModalOpen(false);
|
||||
setEditingProject(null);
|
||||
}
|
||||
function clearAll() {
|
||||
setSearchInput("");
|
||||
setFilters({ search: "", status: "", orderType: "" });
|
||||
}
|
||||
|
||||
const exportSelectedCsv = useCallback(() => {
|
||||
const selected = projects.filter((p) => selection.selectedIds.has(p.id));
|
||||
@@ -389,9 +448,23 @@ export function ProjectsClient() {
|
||||
}, [projects, selection.selectedIds]);
|
||||
|
||||
const chips = [
|
||||
...(search ? [{ label: `Search: "${search}"`, onRemove: () => { setSearchInput(""); setFilters({ search: "" }); } }] : []),
|
||||
...(statusFilter ? [{ label: `Status: ${statusFilter}`, onRemove: () => setStatusFilter("") }] : []),
|
||||
...(orderTypeFilter ? [{ label: `Type: ${orderTypeFilter}`, onRemove: () => setOrderTypeFilter("") }] : []),
|
||||
...(search
|
||||
? [
|
||||
{
|
||||
label: `Search: "${search}"`,
|
||||
onRemove: () => {
|
||||
setSearchInput("");
|
||||
setFilters({ search: "" });
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(statusFilter
|
||||
? [{ label: `Status: ${statusFilter}`, onRemove: () => setStatusFilter("") }]
|
||||
: []),
|
||||
...(orderTypeFilter
|
||||
? [{ label: `Type: ${orderTypeFilter}`, onRemove: () => setOrderTypeFilter("") }]
|
||||
: []),
|
||||
];
|
||||
|
||||
// ─── Cell renderer ────────────────────────────────────────────────────────
|
||||
@@ -401,18 +474,42 @@ export function ProjectsClient() {
|
||||
if (col.isCustom) {
|
||||
const fieldKey = col.key.replace(/^custom_/, "");
|
||||
const val = dynFields[fieldKey];
|
||||
return <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) {
|
||||
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":
|
||||
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">
|
||||
<Link href={`/projects/${project.id}`} className="inline-flex items-center gap-2 transition hover:text-brand-600 hover:underline">
|
||||
<td
|
||||
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 ? (
|
||||
<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
|
||||
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",
|
||||
}}
|
||||
>
|
||||
{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 className="truncate">{project.name}</span>
|
||||
@@ -442,14 +545,19 @@ export function ProjectsClient() {
|
||||
case "orderType":
|
||||
return (
|
||||
<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}
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
case "dates":
|
||||
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)}
|
||||
</td>
|
||||
);
|
||||
@@ -459,7 +567,10 @@ export function ProjectsClient() {
|
||||
<div className="mb-0.5 text-sm text-gray-900 dark:text-gray-100">
|
||||
{formatMoney(project.budgetCents)}
|
||||
</div>
|
||||
<BudgetBar utilizationPercent={project.utilizationPercent ?? 0} budgetCents={project.budgetCents} />
|
||||
<BudgetBar
|
||||
utilizationPercent={project.utilizationPercent ?? 0}
|
||||
budgetCents={project.budgetCents}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
case "allocations":
|
||||
@@ -467,8 +578,18 @@ export function ProjectsClient() {
|
||||
<td key={col.key} className="px-4 py-3 text-right text-sm">
|
||||
{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">
|
||||
<svg 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
|
||||
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>
|
||||
{project.totalPersonDays}d
|
||||
</span>
|
||||
@@ -484,14 +605,30 @@ export function ProjectsClient() {
|
||||
</td>
|
||||
);
|
||||
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:
|
||||
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 ──────────────────────────────────────────────────────
|
||||
const SORTABLE_PROJECT_COLS = new Set(["shortCode", "name", "status", "orderType", "dates", "budget", "allocations"]);
|
||||
const SORTABLE_PROJECT_COLS = new Set([
|
||||
"shortCode",
|
||||
"name",
|
||||
"status",
|
||||
"orderType",
|
||||
"dates",
|
||||
"budget",
|
||||
"allocations",
|
||||
]);
|
||||
function renderHeader(col: ColumnDef) {
|
||||
if (SORTABLE_PROJECT_COLS.has(col.key)) {
|
||||
return (
|
||||
@@ -506,7 +643,10 @@ export function ProjectsClient() {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<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}
|
||||
</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"
|
||||
>
|
||||
<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>
|
||||
New Project Wizard
|
||||
</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"
|
||||
>
|
||||
<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>
|
||||
Quick Add
|
||||
</button>
|
||||
@@ -563,7 +713,9 @@ export function ProjectsClient() {
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
{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
|
||||
@@ -573,7 +725,9 @@ export function ProjectsClient() {
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
{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>
|
||||
<ColumnTogglePanel
|
||||
@@ -602,7 +756,9 @@ export function ProjectsClient() {
|
||||
|
||||
<div className="app-data-table">
|
||||
{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">
|
||||
@@ -644,9 +800,15 @@ export function ProjectsClient() {
|
||||
<td className="px-2 py-3 w-8">
|
||||
<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"}`}
|
||||
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) ? "★" : "☆"}
|
||||
</button>
|
||||
@@ -669,7 +831,10 @@ export function ProjectsClient() {
|
||||
>
|
||||
Edit
|
||||
</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 →
|
||||
</Link>
|
||||
</div>
|
||||
@@ -684,25 +849,34 @@ export function ProjectsClient() {
|
||||
{projects.length === 0 && (
|
||||
<div className="py-14 text-center text-sm text-gray-500">
|
||||
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.
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InfiniteScrollSentinel
|
||||
onVisible={handleFetchNext}
|
||||
isLoading={isFetchingNextPage}
|
||||
/>
|
||||
<InfiniteScrollSentinel onVisible={handleFetchNext} isLoading={isFetchingNextPage} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Batch Status Picker */}
|
||||
{batchStatusPicker && (
|
||||
<div className="fixed inset-0 bg-black/30 z-50 flex items-center justify-center p-4" 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="fixed inset-0 bg-black/30 z-50 flex items-center justify-center p-4"
|
||||
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">
|
||||
{ALL_STATUSES.map((s) => (
|
||||
<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"
|
||||
>
|
||||
<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}
|
||||
</span>
|
||||
</button>
|
||||
@@ -732,7 +911,10 @@ export function ProjectsClient() {
|
||||
confirmLabel="Update"
|
||||
onConfirm={() => {
|
||||
if (confirmBatchStatus) {
|
||||
batchUpdateStatus.mutate({ ids: confirmBatchStatus.ids, status: confirmBatchStatus.status as never });
|
||||
batchUpdateStatus.mutate({
|
||||
ids: confirmBatchStatus.ids,
|
||||
status: confirmBatchStatus.status as never,
|
||||
});
|
||||
}
|
||||
setConfirmBatchStatus(null);
|
||||
}}
|
||||
|
||||
@@ -166,7 +166,7 @@ export function ResourcesClient() {
|
||||
const departedFilter = resourceUrlFilters.departed as BooleanFilter;
|
||||
// chapters stored as comma-separated string; empty string means "all chapters visible"
|
||||
const chaptersParam = resourceUrlFilters.chapters;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
const chapterFilter: string[] = useMemo(
|
||||
() => (chaptersParam ? chaptersParam.split(",").filter(Boolean) : []),
|
||||
[chaptersParam],
|
||||
@@ -175,21 +175,32 @@ export function ResourcesClient() {
|
||||
// Flush debounced search input to URL
|
||||
useEffect(() => {
|
||||
setResourceUrlFilters({ search: debouncedSearch });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearch]);
|
||||
|
||||
// Keep local search input in sync when URL changes externally
|
||||
useEffect(() => {
|
||||
setSearchInput(resourceUrlFilters.search);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [resourceUrlFilters.search]);
|
||||
|
||||
const setIsActiveFilter = useCallback((v: ActiveFilter) => setResourceUrlFilters({ activeFilter: v }), [setResourceUrlFilters]);
|
||||
const setRolledOffFilter = useCallback((v: BooleanFilter) => setResourceUrlFilters({ rolledOff: v }), [setResourceUrlFilters]);
|
||||
const setDepartedFilter = useCallback((v: BooleanFilter) => setResourceUrlFilters({ departed: v }), [setResourceUrlFilters]);
|
||||
const setChapterFilter = useCallback((v: string[]) => {
|
||||
setResourceUrlFilters({ chapters: v.join(",") });
|
||||
}, [setResourceUrlFilters]);
|
||||
const setIsActiveFilter = useCallback(
|
||||
(v: ActiveFilter) => setResourceUrlFilters({ activeFilter: v }),
|
||||
[setResourceUrlFilters],
|
||||
);
|
||||
const setRolledOffFilter = useCallback(
|
||||
(v: BooleanFilter) => setResourceUrlFilters({ rolledOff: v }),
|
||||
[setResourceUrlFilters],
|
||||
);
|
||||
const setDepartedFilter = useCallback(
|
||||
(v: BooleanFilter) => setResourceUrlFilters({ departed: v }),
|
||||
[setResourceUrlFilters],
|
||||
);
|
||||
const setChapterFilter = useCallback(
|
||||
(v: string[]) => {
|
||||
setResourceUrlFilters({ chapters: v.join(",") });
|
||||
},
|
||||
[setResourceUrlFilters],
|
||||
);
|
||||
|
||||
const [includeProposedChargeability, setIncludeProposedChargeability] = useState(false);
|
||||
const [hiddenCountryIds, setHiddenCountryIds] = useState<string[]>([]);
|
||||
@@ -412,7 +423,13 @@ export function ResourcesClient() {
|
||||
|
||||
function clearAll() {
|
||||
setSearchInput("");
|
||||
setResourceUrlFilters({ search: "", activeFilter: "active", rolledOff: DEFAULT_BOOLEAN_FILTER, departed: DEFAULT_BOOLEAN_FILTER, chapters: "" });
|
||||
setResourceUrlFilters({
|
||||
search: "",
|
||||
activeFilter: "active",
|
||||
rolledOff: DEFAULT_BOOLEAN_FILTER,
|
||||
departed: DEFAULT_BOOLEAN_FILTER,
|
||||
chapters: "",
|
||||
});
|
||||
setHiddenCountryIds([]);
|
||||
setIncludeWithoutCountry(true);
|
||||
setHiddenResourceTypes([...DEFAULT_HIDDEN_RESOURCE_TYPES]);
|
||||
@@ -468,7 +485,9 @@ export function ResourcesClient() {
|
||||
if (next.length === chapters.length) {
|
||||
setChapterFilter([]);
|
||||
} else {
|
||||
setChapterFilter(next.sort((left, right) => chapters.indexOf(left) - chapters.indexOf(right)));
|
||||
setChapterFilter(
|
||||
next.sort((left, right) => chapters.indexOf(left) - chapters.indexOf(right)),
|
||||
);
|
||||
}
|
||||
},
|
||||
[chapters, chapterFilter, setChapterFilter],
|
||||
@@ -533,13 +552,23 @@ export function ResourcesClient() {
|
||||
{ header: "LCR (cents)", accessor: (r) => r.lcrCents },
|
||||
{ header: "Currency", accessor: (r) => r.currency },
|
||||
{ header: "Chargeability Target", accessor: (r) => r.chargeabilityTarget },
|
||||
{ header: "Active", accessor: (r) => r.isActive ? "Yes" : "No" },
|
||||
{ header: "Active", accessor: (r) => (r.isActive ? "Yes" : "No") },
|
||||
]);
|
||||
downloadCsv(csv, `resources-export-${new Date().toISOString().slice(0, 10)}.csv`);
|
||||
}, [displayedResources, selection.selectedIds]);
|
||||
|
||||
const chips = [
|
||||
...(search ? [{ label: `Search: "${search}"`, onRemove: () => { setSearchInput(""); setResourceUrlFilters({ search: "" }); } }] : []),
|
||||
...(search
|
||||
? [
|
||||
{
|
||||
label: `Search: "${search}"`,
|
||||
onRemove: () => {
|
||||
setSearchInput("");
|
||||
setResourceUrlFilters({ search: "" });
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(chapterFilter.length > 0
|
||||
? [
|
||||
{
|
||||
@@ -1303,7 +1332,12 @@ export function ResourcesClient() {
|
||||
/>
|
||||
</div>
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -115,7 +115,6 @@ export async function GET(request: Request) {
|
||||
)
|
||||
.join("; ");
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await createNotificationsForUsers({
|
||||
db: prisma as any,
|
||||
userIds: adminUsers.map((u) => u.id),
|
||||
@@ -128,7 +127,10 @@ export async function GET(request: Request) {
|
||||
});
|
||||
|
||||
logger.warn(
|
||||
{ anomalies: report.anomalies, window: { start: report.windowStartedAt, end: report.windowEndedAt } },
|
||||
{
|
||||
anomalies: report.anomalies,
|
||||
window: { start: report.windowStartedAt, end: report.windowEndedAt },
|
||||
},
|
||||
"Auth anomaly cron: anomalies detected and admins notified",
|
||||
);
|
||||
}
|
||||
@@ -140,9 +142,6 @@ export async function GET(request: Request) {
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error, route: "/api/cron/auth-anomaly-check" }, "Auth anomaly cron failed");
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Internal error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return NextResponse.json({ ok: false, error: "Internal error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,10 +73,7 @@ export async function GET(request: Request) {
|
||||
if (deny) return deny;
|
||||
|
||||
try {
|
||||
const [postgres, redis] = await Promise.all([
|
||||
checkPostgres(),
|
||||
checkRedis(),
|
||||
]);
|
||||
const [postgres, redis] = await Promise.all([checkPostgres(), checkRedis()]);
|
||||
|
||||
const allHealthy = postgres.status === "ok" && redis.status === "ok";
|
||||
|
||||
@@ -92,7 +89,6 @@ export async function GET(request: Request) {
|
||||
});
|
||||
|
||||
if (adminUsers.length > 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await createNotificationsForUsers({
|
||||
db: prisma as any,
|
||||
userIds: adminUsers.map((u) => u.id),
|
||||
@@ -121,9 +117,6 @@ export async function GET(request: Request) {
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error({ error, route: "/api/cron/health-check" }, "Health check cron failed");
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Internal error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return NextResponse.json({ ok: false, error: "Internal error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
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" },
|
||||
"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" },
|
||||
};
|
||||
|
||||
@@ -89,7 +89,10 @@ function scanPackageJson(): Finding[] {
|
||||
}
|
||||
}
|
||||
} 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;
|
||||
@@ -124,7 +127,6 @@ export async function GET(request: Request) {
|
||||
.map((f) => `${f.package}@${f.currentVersion} (need >=${f.minimumVersion})`)
|
||||
.join(", ");
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await createNotificationsForUsers({
|
||||
db: prisma as any,
|
||||
userIds: adminUsers.map((u) => u.id),
|
||||
@@ -147,9 +149,6 @@ export async function GET(request: Request) {
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error, route: "/api/cron/security-audit" }, "Security audit cron failed");
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Internal error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return NextResponse.json({ ok: false, error: "Internal error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { loadRoleDefaults } from "@capakraken/api";
|
||||
import { deriveUserSseSubscription, eventBus } from "@capakraken/api/sse";
|
||||
import { startReminderScheduler } from "@capakraken/api/lib/reminder-scheduler";
|
||||
import { prisma } from "@capakraken/db";
|
||||
import { SSE_EVENT_TYPES, SystemRole, type PermissionOverrides } from "@capakraken/shared";
|
||||
import type { SystemRole } from "@capakraken/shared";
|
||||
import { SSE_EVENT_TYPES, type PermissionOverrides } from "@capakraken/shared";
|
||||
import { auth } from "~/server/auth.js";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -59,26 +60,27 @@ export async function GET() {
|
||||
start(controller) {
|
||||
// Send initial connection confirmation
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: SSE_EVENT_TYPES.PING, timestamp: new Date().toISOString() })}\n\n`),
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ type: SSE_EVENT_TYPES.PING, timestamp: new Date().toISOString() })}\n\n`,
|
||||
),
|
||||
);
|
||||
|
||||
// Subscribe to event bus
|
||||
const unsubscribe = eventBus.subscribe(
|
||||
(event) => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
||||
} catch {
|
||||
// Client disconnected
|
||||
}
|
||||
},
|
||||
subscription,
|
||||
);
|
||||
const unsubscribe = eventBus.subscribe((event) => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
||||
} catch {
|
||||
// Client disconnected
|
||||
}
|
||||
}, subscription);
|
||||
|
||||
// Heartbeat every 30 seconds
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: SSE_EVENT_TYPES.PING, timestamp: new Date().toISOString() })}\n\n`),
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ type: SSE_EVENT_TYPES.PING, timestamp: new Date().toISOString() })}\n\n`,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
clearInterval(heartbeat);
|
||||
|
||||
@@ -45,7 +45,12 @@ function parseSpainRules(rules: unknown): Partial<EditingCountry> {
|
||||
if (!rules || typeof rules !== "object") return { hasSpainRules: false };
|
||||
const r = rules as Record<string, unknown>;
|
||||
if (r.type !== "spain") return { hasSpainRules: false };
|
||||
const sp = r as { fridayHours?: number; summerPeriod?: { from?: string; to?: string }; summerHours?: number; regularHours?: number };
|
||||
const sp = r as {
|
||||
fridayHours?: number;
|
||||
summerPeriod?: { from?: string; to?: string };
|
||||
summerHours?: number;
|
||||
regularHours?: number;
|
||||
};
|
||||
return {
|
||||
hasSpainRules: true,
|
||||
fridayHours: sp.fridayHours ?? 6.5,
|
||||
@@ -66,17 +71,26 @@ export function CountriesClient() {
|
||||
const utils = trpc.useUtils();
|
||||
const { data: countries, isLoading } = trpc.country.list.useQuery();
|
||||
|
||||
// @ts-ignore TS2589: tRPC infers union type too deeply for nullable JSONB scheduleRules schema
|
||||
// @ts-expect-error TS2589: tRPC infers union type too deeply for nullable JSONB scheduleRules schema
|
||||
const createMut = trpc.country.create.useMutation({
|
||||
onSuccess: () => { void utils.country.list.invalidate(); setEditing(null); },
|
||||
onSuccess: () => {
|
||||
void utils.country.list.invalidate();
|
||||
setEditing(null);
|
||||
},
|
||||
onError: (e) => setError(e.message),
|
||||
});
|
||||
const updateMut = trpc.country.update.useMutation({
|
||||
onSuccess: () => { void utils.country.list.invalidate(); setEditing(null); },
|
||||
onSuccess: () => {
|
||||
void utils.country.list.invalidate();
|
||||
setEditing(null);
|
||||
},
|
||||
onError: (e) => setError(e.message),
|
||||
});
|
||||
const createCityMut = trpc.country.createCity.useMutation({
|
||||
onSuccess: () => { void utils.country.list.invalidate(); setCityName(""); },
|
||||
onSuccess: () => {
|
||||
void utils.country.list.invalidate();
|
||||
setCityName("");
|
||||
},
|
||||
onError: (e) => setError(e.message),
|
||||
});
|
||||
const deleteCityMut = trpc.country.deleteCity.useMutation({
|
||||
@@ -170,7 +184,13 @@ export function CountriesClient() {
|
||||
{error && (
|
||||
<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}
|
||||
<button type="button" onClick={() => setError(null)} className="text-red-400 hover:text-red-600 text-lg leading-none">×</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setError(null)}
|
||||
className="text-red-400 hover:text-red-600 text-lg leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -179,29 +199,75 @@ export function CountriesClient() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<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"><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>
|
||||
<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">
|
||||
<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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 && (
|
||||
<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) => (
|
||||
<tr 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>
|
||||
<tr
|
||||
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-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">
|
||||
{c.scheduleRules && 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>
|
||||
{c.scheduleRules &&
|
||||
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>
|
||||
)}
|
||||
@@ -216,7 +282,13 @@ export function CountriesClient() {
|
||||
</button>
|
||||
</td>
|
||||
<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>
|
||||
</tr>
|
||||
))}
|
||||
@@ -225,67 +297,87 @@ export function CountriesClient() {
|
||||
</div>
|
||||
|
||||
{/* Expanded Metro Cities */}
|
||||
{expandedId && (() => {
|
||||
const country = rows.find((c) => c.id === expandedId);
|
||||
if (!country) return null;
|
||||
return (
|
||||
<div className="mt-4 bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||
Metro Cities for {country.name}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{country.metroCities.map((city) => (
|
||||
<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">
|
||||
{city.name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDeleteCity(city.id)}
|
||||
className="text-gray-400 hover:text-red-500 text-xs leading-none ml-1"
|
||||
{expandedId &&
|
||||
(() => {
|
||||
const country = rows.find((c) => c.id === expandedId);
|
||||
if (!country) return null;
|
||||
return (
|
||||
<div className="mt-4 bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||
Metro Cities for {country.name}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{country.metroCities.map((city) => (
|
||||
<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"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{country.metroCities.length === 0 && (
|
||||
<span className="text-sm text-gray-400">No metro cities yet</span>
|
||||
)}
|
||||
{city.name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDeleteCity(city.id)}
|
||||
className="text-gray-400 hover:text-red-500 text-xs leading-none ml-1"
|
||||
>
|
||||
×
|
||||
</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 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 */}
|
||||
<AnimatedModal open={editing !== null} onClose={() => setEditing(null)} maxWidth="max-w-lg" className="flex flex-col max-h-[90vh]">
|
||||
{editing && (<>
|
||||
<AnimatedModal
|
||||
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">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{editing.id ? "Edit Country" : "Add Country"}
|
||||
</h2>
|
||||
<button type="button" onClick={() => setEditing(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none">×</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(null)}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1 px-6 py-5 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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
|
||||
type="text"
|
||||
value={editing.code}
|
||||
@@ -296,11 +388,16 @@ export function CountriesClient() {
|
||||
/>
|
||||
</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
|
||||
type="number"
|
||||
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}
|
||||
max={24}
|
||||
step={0.5}
|
||||
@@ -310,7 +407,9 @@ export function CountriesClient() {
|
||||
</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
|
||||
type="text"
|
||||
value={editing.name}
|
||||
@@ -329,28 +428,45 @@ export function CountriesClient() {
|
||||
onChange={(e) => setEditing({ ...editing, hasSpainRules: e.target.checked })}
|
||||
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>
|
||||
|
||||
{editing.hasSpainRules && (
|
||||
<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>
|
||||
<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
|
||||
type="number"
|
||||
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}
|
||||
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>
|
||||
<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
|
||||
type="number"
|
||||
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}
|
||||
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 className="grid grid-cols-3 gap-3">
|
||||
<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
|
||||
type="text"
|
||||
value={editing.summerFrom}
|
||||
@@ -368,7 +487,10 @@ export function CountriesClient() {
|
||||
/>
|
||||
</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
|
||||
type="text"
|
||||
value={editing.summerTo}
|
||||
@@ -378,11 +500,19 @@ export function CountriesClient() {
|
||||
/>
|
||||
</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
|
||||
type="number"
|
||||
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}
|
||||
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 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
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
@@ -404,7 +540,8 @@ export function CountriesClient() {
|
||||
{isPending ? "Saving..." : editing.id ? "Update" : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</>)}
|
||||
</>
|
||||
)}
|
||||
</AnimatedModal>
|
||||
|
||||
{confirmDeleteCity && (
|
||||
|
||||
@@ -27,7 +27,8 @@ const PERMISSION_LABELS: Record<string, string> = {
|
||||
const PERMISSION_DESCRIPTIONS: Record<string, string> = {
|
||||
viewPlanning: "Read project and allocation planning views without mutation access",
|
||||
viewCosts: "Access to cost data, budget views, and financial reports",
|
||||
useAssistantAdvancedTools: "Unlocks advanced AI assistant workflows for complex cross-entity analyses",
|
||||
useAssistantAdvancedTools:
|
||||
"Unlocks advanced AI assistant workflows for complex cross-entity analyses",
|
||||
exportData: "Export data to Excel, CSV, or PDF formats",
|
||||
importData: "Import data from external sources (Dispo, Excel)",
|
||||
approveVacations: "Approve or reject vacation requests",
|
||||
@@ -101,8 +102,7 @@ export function SystemRolesClient() {
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore TS2589: tRPC infers union type too deeply for the role config update payload
|
||||
// @ts-expect-error TS2589: tRPC infers union type too deeply for the role config update payload
|
||||
const updateMutation = trpc.systemRoleConfig.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.systemRoleConfig.list.invalidate();
|
||||
@@ -164,9 +164,12 @@ export function SystemRolesClient() {
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<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">
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -179,7 +182,11 @@ export function SystemRolesClient() {
|
||||
{isLoading && (
|
||||
<div className="space-y-3">
|
||||
{[...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>
|
||||
)}
|
||||
@@ -197,7 +204,9 @@ export function SystemRolesClient() {
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-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}
|
||||
</span>
|
||||
<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">
|
||||
{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) => (
|
||||
<span
|
||||
@@ -241,15 +252,21 @@ export function SystemRolesClient() {
|
||||
{configs.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<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>
|
||||
<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">
|
||||
<thead>
|
||||
<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) => (
|
||||
<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}
|
||||
</th>
|
||||
))}
|
||||
@@ -269,7 +286,11 @@ export function SystemRolesClient() {
|
||||
{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">
|
||||
<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>
|
||||
</span>
|
||||
) : (
|
||||
@@ -303,7 +324,10 @@ export function SystemRolesClient() {
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
×
|
||||
@@ -370,7 +394,8 @@ export function SystemRolesClient() {
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<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>
|
||||
<div className="flex gap-2">
|
||||
<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"
|
||||
}`}
|
||||
>
|
||||
<span className={`flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center ${
|
||||
isActive
|
||||
? "text-green-600 border-green-300 bg-green-100 dark:bg-green-900/40"
|
||||
: "border-gray-300 dark:border-gray-600"
|
||||
}`}>
|
||||
<span
|
||||
className={`flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center ${
|
||||
isActive
|
||||
? "text-green-600 border-green-300 bg-green-100 dark:bg-green-900/40"
|
||||
: "border-gray-300 dark:border-gray-600"
|
||||
}`}
|
||||
>
|
||||
{isActive && (
|
||||
<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>
|
||||
)}
|
||||
</span>
|
||||
<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}
|
||||
</span>
|
||||
<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">
|
||||
<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"
|
||||
>
|
||||
Cancel
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
"use client";
|
||||
|
||||
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 { AnimatedModal } from "~/components/ui/AnimatedModal.js";
|
||||
import { InviteUserModal } from "./InviteUserModal.js";
|
||||
@@ -99,13 +105,17 @@ export function UsersClient() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [roleFilter, setRoleFilter] = useState<SystemRole | "">("");
|
||||
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 [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||
const [passwordSuccess, setPasswordSuccess] = 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();
|
||||
|
||||
@@ -145,8 +155,7 @@ export function UsersClient() {
|
||||
onError: (err) => setActionError(err.message),
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore TS2589: tRPC infers union type too deeply for nullable overrides schema
|
||||
// @ts-expect-error TS2589: tRPC infers union type too deeply for nullable overrides schema
|
||||
const setPermissionsMutation = trpc.user.setPermissions.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.user.list.invalidate();
|
||||
@@ -322,7 +331,10 @@ export function UsersClient() {
|
||||
async function handleSaveRole() {
|
||||
if (!editState) return;
|
||||
setActionError(null);
|
||||
await updateRoleMutation.mutateAsync({ id: editState.userId, systemRole: editState.systemRole });
|
||||
await updateRoleMutation.mutateAsync({
|
||||
id: editState.userId,
|
||||
systemRole: editState.systemRole,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSavePermissions() {
|
||||
@@ -358,7 +370,8 @@ export function UsersClient() {
|
||||
const filteredUsers = allUsers.filter((u) => {
|
||||
if (search) {
|
||||
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;
|
||||
return true;
|
||||
@@ -408,7 +421,9 @@ export function UsersClient() {
|
||||
|
||||
const chips = [
|
||||
...(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) {
|
||||
@@ -431,9 +446,7 @@ export function UsersClient() {
|
||||
<div className="app-page-header mb-6">
|
||||
<div>
|
||||
<h1 className="app-page-title">User Management</h1>
|
||||
<p className="app-page-subtitle mt-1">
|
||||
Manage user roles and permission overrides
|
||||
</p>
|
||||
<p className="app-page-subtitle mt-1">Manage user roles and permission overrides</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{activeData && (
|
||||
@@ -449,16 +462,25 @@ export function UsersClient() {
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void autoLinkMutation.mutateAsync().then((r) => {
|
||||
setActionError(r.linked > 0 ? null : `No unlinked accounts found (checked ${r.checked})`);
|
||||
if (r.linked > 0) setActionError(null);
|
||||
})}
|
||||
onClick={() =>
|
||||
void autoLinkMutation.mutateAsync().then((r) => {
|
||||
setActionError(
|
||||
r.linked > 0 ? null : `No unlinked accounts found (checked ${r.checked})`,
|
||||
);
|
||||
if (r.linked > 0) setActionError(null);
|
||||
})
|
||||
}
|
||||
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"
|
||||
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">
|
||||
<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>
|
||||
{autoLinkMutation.isPending ? "Linking..." : "Auto-link Resources"}
|
||||
</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"
|
||||
>
|
||||
<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>
|
||||
Invite User
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
Create User
|
||||
</button>
|
||||
@@ -501,7 +536,9 @@ export function UsersClient() {
|
||||
>
|
||||
<option value="">All Roles</option>
|
||||
{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>
|
||||
</div>
|
||||
@@ -530,13 +567,54 @@ export function UsersClient() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<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 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>
|
||||
<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
|
||||
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>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -566,7 +644,8 @@ export function UsersClient() {
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span
|
||||
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}
|
||||
@@ -591,7 +670,10 @@ export function UsersClient() {
|
||||
</span>
|
||||
)}
|
||||
{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
|
||||
</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"
|
||||
title="Set password"
|
||||
>
|
||||
<svg 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
|
||||
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>
|
||||
Password
|
||||
</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"
|
||||
title="Disable TOTP MFA for this user"
|
||||
>
|
||||
<svg 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
|
||||
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>
|
||||
Disable MFA
|
||||
</button>
|
||||
@@ -645,7 +747,11 @@ export function UsersClient() {
|
||||
<button
|
||||
type="button"
|
||||
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 });
|
||||
}
|
||||
}}
|
||||
@@ -668,7 +774,9 @@ export function UsersClient() {
|
||||
)}
|
||||
<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"
|
||||
title="Permanently delete user"
|
||||
>
|
||||
@@ -711,7 +819,8 @@ export function UsersClient() {
|
||||
/>
|
||||
{newPassword.length > 0 && newPassword.length < 8 && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
@@ -729,9 +838,7 @@ export function UsersClient() {
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
{confirmPassword.length > 0 && newPassword !== confirmPassword && (
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
|
||||
Passwords do not match
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">Passwords do not match</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -747,7 +854,11 @@ export function UsersClient() {
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
{setPasswordMutation.isPending ? "Saving..." : "Set Password"}
|
||||
@@ -770,12 +881,13 @@ export function UsersClient() {
|
||||
</div>
|
||||
<div className="px-6 py-5 space-y-3">
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Are you sure you want to permanently delete{" "}
|
||||
<strong>{deleteTarget.userName}</strong>?
|
||||
Are you sure you want to permanently delete <strong>{deleteTarget.userName}</strong>
|
||||
?
|
||||
</p>
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
This will permanently remove their account, sessions, vacation records, and notifications.
|
||||
Audit history entries will be retained but anonymised. This action cannot be undone.
|
||||
This will permanently remove their account, sessions, vacation records, and
|
||||
notifications. Audit history entries will be retained but anonymised. This action
|
||||
cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
<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"
|
||||
>
|
||||
×
|
||||
@@ -839,7 +954,8 @@ export function UsersClient() {
|
||||
|
||||
<div>
|
||||
<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>
|
||||
<input
|
||||
type="email"
|
||||
@@ -852,7 +968,8 @@ export function UsersClient() {
|
||||
|
||||
<div>
|
||||
<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>
|
||||
<input
|
||||
type="password"
|
||||
@@ -866,15 +983,20 @@ export function UsersClient() {
|
||||
|
||||
<div>
|
||||
<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>
|
||||
<select
|
||||
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"
|
||||
>
|
||||
{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>
|
||||
</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">
|
||||
<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"
|
||||
>
|
||||
Cancel
|
||||
@@ -891,7 +1016,12 @@ export function UsersClient() {
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
{createUserMutation.isPending ? "Creating..." : "Create User"}
|
||||
@@ -941,14 +1071,22 @@ export function UsersClient() {
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
<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}
|
||||
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 */}
|
||||
<section>
|
||||
<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>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
@@ -1014,17 +1153,25 @@ export function UsersClient() {
|
||||
{/* Permissions */}
|
||||
<section>
|
||||
<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>
|
||||
<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-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 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 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">×</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">
|
||||
×
|
||||
</span>
|
||||
</span>{" "}
|
||||
Denied
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -1067,8 +1214,10 @@ export function UsersClient() {
|
||||
}
|
||||
|
||||
const stateStyles = {
|
||||
default: "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",
|
||||
default:
|
||||
"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",
|
||||
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}
|
||||
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" && (
|
||||
<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" && (
|
||||
<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" && (
|
||||
<span className="text-xs font-bold leading-none">×</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}
|
||||
</span>
|
||||
{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" && (
|
||||
<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" && (
|
||||
<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>
|
||||
);
|
||||
@@ -1118,7 +1289,8 @@ export function UsersClient() {
|
||||
{/* Chapter Scope */}
|
||||
<div className="mt-4">
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -6,8 +6,14 @@ import { useLocalStorage } from "~/hooks/useLocalStorage.js";
|
||||
import { formatDate } from "~/lib/format.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { AllocationModal } from "./AllocationModal.js";
|
||||
import type { AllocationLike, AllocationReadModel, AllocationWithDetails, ColumnDef } from "@capakraken/shared";
|
||||
import { AllocationStatus, ALLOCATION_COLUMNS } from "@capakraken/shared";
|
||||
import type {
|
||||
AllocationLike,
|
||||
AllocationReadModel,
|
||||
AllocationWithDetails,
|
||||
ColumnDef,
|
||||
AllocationStatus,
|
||||
} from "@capakraken/shared";
|
||||
import { ALLOCATION_COLUMNS } from "@capakraken/shared";
|
||||
import { useSelection } from "~/hooks/useSelection.js";
|
||||
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
|
||||
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
||||
@@ -34,7 +40,10 @@ import {
|
||||
toggleCollapsedAllocationGroup,
|
||||
type CollapsedAllocationGroups,
|
||||
} 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 */
|
||||
const STATUS_LEFT_BORDER: Record<string, string> = {
|
||||
@@ -124,15 +133,39 @@ export function AllocationsClient() {
|
||||
const hideCompletedProjects = allocFilters.hideCompleted === "true";
|
||||
const hideDraftProjects = allocFilters.hideDraft === "true";
|
||||
|
||||
const setFilterProjectId = useCallback((v: string) => setAllocFilters({ projectId: v }), [setAllocFilters]);
|
||||
const setFilterResourceId = useCallback((v: string) => setAllocFilters({ resourceId: v }), [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 setFilterProjectId = useCallback(
|
||||
(v: string) => setAllocFilters({ projectId: v }),
|
||||
[setAllocFilters],
|
||||
);
|
||||
const setFilterResourceId = useCallback(
|
||||
(v: string) => setAllocFilters({ resourceId: v }),
|
||||
[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 [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 [showDateShiftModal, setShowDateShiftModal] = useState(false);
|
||||
|
||||
@@ -145,8 +178,14 @@ export function AllocationsClient() {
|
||||
() => (canViewCosts ? ALLOCATION_COLUMNS : ALLOCATION_COLUMNS.filter((c) => c.key !== "cost")),
|
||||
[canViewCosts],
|
||||
);
|
||||
const { allColumns, visibleColumns, visibleKeys, setVisible } = useColumnConfig("allocations", baseColumns);
|
||||
const defaultKeys = useMemo(() => baseColumns.filter((c) => c.defaultVisible).map((c) => c.key), [baseColumns]);
|
||||
const { allColumns, visibleColumns, visibleKeys, setVisible } = useColumnConfig(
|
||||
"allocations",
|
||||
baseColumns,
|
||||
);
|
||||
const defaultKeys = useMemo(
|
||||
() => baseColumns.filter((c) => c.defaultVisible).map((c) => c.key),
|
||||
[baseColumns],
|
||||
);
|
||||
|
||||
const allocationQuery = trpc.allocation.listView.useQuery(
|
||||
{
|
||||
@@ -207,17 +246,28 @@ export function AllocationsClient() {
|
||||
|
||||
useEffect(() => {
|
||||
selection.clear();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filterProjectId, filterResourceId, filterStatus, hidePastProjects, hideCompletedProjects, hideDraftProjects]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
filterProjectId,
|
||||
filterResourceId,
|
||||
filterStatus,
|
||||
hidePastProjects,
|
||||
hideCompletedProjects,
|
||||
hideDraftProjects,
|
||||
]);
|
||||
|
||||
function handleExportExcel() {
|
||||
const rows: (string | number | null)[][] = [
|
||||
["Resource", "Project", "Role", "Start Date", "End Date", "Hours/Day", "Status"],
|
||||
...sorted.map((a) => [
|
||||
(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 ?? "",
|
||||
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),
|
||||
a.hoursPerDay,
|
||||
a.status,
|
||||
@@ -248,16 +298,28 @@ export function AllocationsClient() {
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const filteredAllocations = assignmentList.filter((alloc) => {
|
||||
if (hidePastProjects && alloc.project?.endDate && new Date(alloc.project.endDate) < today) return false;
|
||||
if (hideCompletedProjects && alloc.project?.status && ["COMPLETED", "CANCELLED"].includes(alloc.project.status)) return false;
|
||||
if (hidePastProjects && alloc.project?.endDate && new Date(alloc.project.endDate) < today)
|
||||
return false;
|
||||
if (
|
||||
hideCompletedProjects &&
|
||||
alloc.project?.status &&
|
||||
["COMPLETED", "CANCELLED"].includes(alloc.project.status)
|
||||
)
|
||||
return false;
|
||||
if (hideDraftProjects && alloc.project?.status === "DRAFT") return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const filteredDemands = demandList.filter((alloc) => {
|
||||
if (filterResourceId) return false;
|
||||
if (hidePastProjects && alloc.project?.endDate && new Date(alloc.project.endDate) < today) return false;
|
||||
if (hideCompletedProjects && alloc.project?.status && ["COMPLETED", "CANCELLED"].includes(alloc.project.status)) return false;
|
||||
if (hidePastProjects && alloc.project?.endDate && new Date(alloc.project.endDate) < today)
|
||||
return false;
|
||||
if (
|
||||
hideCompletedProjects &&
|
||||
alloc.project?.status &&
|
||||
["COMPLETED", "CANCELLED"].includes(alloc.project.status)
|
||||
)
|
||||
return false;
|
||||
if (hideDraftProjects && alloc.project?.status === "DRAFT") return false;
|
||||
return true;
|
||||
});
|
||||
@@ -273,9 +335,7 @@ export function AllocationsClient() {
|
||||
const allocationIds = sorted.map((a) => a.id);
|
||||
const allocationMutationIdsByDisplayId = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
sorted.map((allocation) => [allocation.id, getPlanningEntryMutationId(allocation)]),
|
||||
),
|
||||
new Map(sorted.map((allocation) => [allocation.id, getPlanningEntryMutationId(allocation)])),
|
||||
[sorted],
|
||||
);
|
||||
const selectedMutationIds = useMemo(
|
||||
@@ -288,16 +348,19 @@ export function AllocationsClient() {
|
||||
);
|
||||
|
||||
// ─── View mode: grouped (default) vs flat ──────────────────────────────────
|
||||
const [viewMode, setViewMode] = useLocalStorage<"grouped" | "flat">("capakraken:allocations:viewMode", "grouped");
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<CollapsedAllocationGroups>(
|
||||
() => createInitialCollapsedAllocationGroups(),
|
||||
const [viewMode, setViewMode] = useLocalStorage<"grouped" | "flat">(
|
||||
"capakraken:allocations:viewMode",
|
||||
"grouped",
|
||||
);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<CollapsedAllocationGroups>(() =>
|
||||
createInitialCollapsedAllocationGroups(),
|
||||
);
|
||||
// Track expanded project sub-groups: key = "resourceId::projectId"
|
||||
const [expandedSubGroups, setExpandedSubGroups] = useState<Set<string>>(new Set());
|
||||
const hasEvaluatedInitialVisibility = useRef(false);
|
||||
|
||||
const toggleViewMode = useCallback(() => {
|
||||
setViewMode((prev) => prev === "grouped" ? "flat" : "grouped");
|
||||
setViewMode((prev) => (prev === "grouped" ? "flat" : "grouped"));
|
||||
}, [setViewMode]);
|
||||
|
||||
type ProjectSubGroup = {
|
||||
@@ -344,7 +407,10 @@ export function AllocationsClient() {
|
||||
for (const alloc of group.allocations) {
|
||||
const pid = alloc.project?.id ?? "__no_project__";
|
||||
let list = projMap.get(pid);
|
||||
if (!list) { list = []; projMap.set(pid, list); }
|
||||
if (!list) {
|
||||
list = [];
|
||||
projMap.set(pid, list);
|
||||
}
|
||||
list.push(alloc);
|
||||
}
|
||||
group.projectSubGroups = [...projMap.entries()].map(([pid, allocs]) => {
|
||||
@@ -364,7 +430,10 @@ export function AllocationsClient() {
|
||||
let typicalH = first.hoursPerDay;
|
||||
let maxCount = 0;
|
||||
for (const [h, count] of hpdCounts) {
|
||||
if (count > maxCount) { typicalH = h; maxCount = count; }
|
||||
if (count > maxCount) {
|
||||
typicalH = h;
|
||||
maxCount = count;
|
||||
}
|
||||
}
|
||||
return {
|
||||
projectId: pid,
|
||||
@@ -390,9 +459,12 @@ export function AllocationsClient() {
|
||||
|
||||
const groupIds = useMemo(() => groups.map((g) => g.resourceId), [groups]);
|
||||
|
||||
const toggleGroup = useCallback((resourceId: string) => {
|
||||
setCollapsedGroups((prev) => toggleCollapsedAllocationGroup(prev, groupIds, resourceId));
|
||||
}, [groupIds]);
|
||||
const toggleGroup = useCallback(
|
||||
(resourceId: string) => {
|
||||
setCollapsedGroups((prev) => toggleCollapsedAllocationGroup(prev, groupIds, resourceId));
|
||||
},
|
||||
[groupIds],
|
||||
);
|
||||
|
||||
const collapseAll = useCallback(() => {
|
||||
setCollapsedGroups(collapseAllAllocationGroups());
|
||||
@@ -423,16 +495,35 @@ export function AllocationsClient() {
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
setAllocFilters({ projectId: "", resourceId: "", status: "", hidePast: "false", hideCompleted: "false", hideDraft: "false" });
|
||||
setAllocFilters({
|
||||
projectId: "",
|
||||
resourceId: "",
|
||||
status: "",
|
||||
hidePast: "false",
|
||||
hideCompleted: "false",
|
||||
hideDraft: "false",
|
||||
});
|
||||
}
|
||||
|
||||
const chips = [
|
||||
...(filterProjectId ? [{ label: `Project filter active`, onRemove: () => setFilterProjectId("") }] : []),
|
||||
...(filterResourceId ? [{ label: `Resource filter active`, onRemove: () => setFilterResourceId("") }] : []),
|
||||
...(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) }] : []),
|
||||
...(filterProjectId
|
||||
? [{ label: `Project filter active`, onRemove: () => setFilterProjectId("") }]
|
||||
: []),
|
||||
...(filterResourceId
|
||||
? [{ label: `Resource filter active`, onRemove: () => setFilterResourceId("") }]
|
||||
: []),
|
||||
...(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({
|
||||
@@ -518,41 +609,80 @@ export function AllocationsClient() {
|
||||
switch (col.key) {
|
||||
case "resource":
|
||||
return (
|
||||
<td 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
|
||||
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>
|
||||
);
|
||||
case "project":
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
{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>
|
||||
);
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
return (
|
||||
<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}
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
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">
|
||||
<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
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete({ single: alloc })}
|
||||
@@ -569,7 +699,11 @@ export function AllocationsClient() {
|
||||
|
||||
return (
|
||||
<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>
|
||||
<h1 className="app-page-title">Allocations</h1>
|
||||
@@ -578,7 +712,7 @@ export function AllocationsClient() {
|
||||
? "Loading…"
|
||||
: allocationQueryFailure
|
||||
? 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>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -629,7 +763,9 @@ export function AllocationsClient() {
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
{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>
|
||||
|
||||
@@ -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"}`}
|
||||
>
|
||||
<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>
|
||||
</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"}`}
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
</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"
|
||||
>
|
||||
<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>
|
||||
Export
|
||||
</button>
|
||||
@@ -709,11 +860,19 @@ export function AllocationsClient() {
|
||||
|
||||
{viewMode === "grouped" && groups.length > 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
|
||||
</button>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
@@ -744,16 +903,34 @@ export function AllocationsClient() {
|
||||
</th>
|
||||
{visibleColumns.map((col) => {
|
||||
const tooltips: Record<string, { tip: string; width?: string }> = {
|
||||
resource: { tip: "The person assigned to this time block. Grouped view clusters entries by resource." },
|
||||
project: { tip: "The project this allocation belongs to, identified by short code and name." },
|
||||
role: { tip: "The role this allocation was created for. May differ from the resource's primary role." },
|
||||
resource: {
|
||||
tip: "The person assigned to this time block. Grouped view clusters entries by resource.",
|
||||
},
|
||||
project: {
|
||||
tip: "The project this allocation belongs to, identified by short code and name.",
|
||||
},
|
||||
role: {
|
||||
tip: "The role this allocation was created for. May differ from the resource's primary role.",
|
||||
},
|
||||
dates: { tip: "Start and end date of this allocation period (inclusive)." },
|
||||
hoursPerDay: { tip: "Planned working hours per calendar day for this allocation." },
|
||||
cost: { tip: "Daily cost = resource LCR x hours per day. Total cost = daily cost x working days.", width: "w-72" },
|
||||
status: { tip: "PROPOSED = requested · CONFIRMED = approved · ACTIVE = ongoing · COMPLETED = finished · CANCELLED = removed.", width: "w-72" },
|
||||
hoursPerDay: {
|
||||
tip: "Planned working hours per calendar day for this allocation.",
|
||||
},
|
||||
cost: {
|
||||
tip: "Daily cost = resource LCR x hours per day. Total cost = daily cost x working days.",
|
||||
width: "w-72",
|
||||
},
|
||||
status: {
|
||||
tip: "PROPOSED = requested · CONFIRMED = approved · ACTIVE = ongoing · COMPLETED = finished · CANCELLED = removed.",
|
||||
width: "w-72",
|
||||
},
|
||||
};
|
||||
const t = tooltips[col.key];
|
||||
const fieldMap: Record<string, string> = { dates: "startDate", hoursPerDay: "hoursPerDay", cost: "dailyCostCents" };
|
||||
const fieldMap: Record<string, string> = {
|
||||
dates: "startDate",
|
||||
hoursPerDay: "hoursPerDay",
|
||||
cost: "dailyCostCents",
|
||||
};
|
||||
return (
|
||||
<SortableColumnHeader
|
||||
key={col.key}
|
||||
@@ -773,15 +950,28 @@ export function AllocationsClient() {
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{isLoading && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{!isLoading && allocationQueryFailure && (
|
||||
<tr>
|
||||
<td colSpan={totalColSpan} 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>
|
||||
<td
|
||||
colSpan={totalColSpan}
|
||||
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>
|
||||
{allocationQueryFailure.actionLabel && allocationQueryFailure.actionHref && (
|
||||
<a
|
||||
@@ -817,15 +1007,21 @@ export function AllocationsClient() {
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{!isLoading && !allocationQueryFailure && viewMode === "flat" &&
|
||||
{!isLoading &&
|
||||
!allocationQueryFailure &&
|
||||
viewMode === "flat" &&
|
||||
sorted.map((alloc, index) => renderAllocRow(alloc, false, index))}
|
||||
|
||||
{!isLoading && !allocationQueryFailure && viewMode === "grouped" &&
|
||||
{!isLoading &&
|
||||
!allocationQueryFailure &&
|
||||
viewMode === "grouped" &&
|
||||
groups.map((group) => {
|
||||
const isCollapsed = collapsedGroups === "all" || collapsedGroups.has(group.resourceId);
|
||||
const isCollapsed =
|
||||
collapsedGroups === "all" || collapsedGroups.has(group.resourceId);
|
||||
const groupAllocIds = group.allocations.map((a) => a.id);
|
||||
const allGroupSelected = selection.isAllSelected(groupAllocIds);
|
||||
const groupIndeterminate = !allGroupSelected && groupAllocIds.some((id) => selection.selectedIds.has(id));
|
||||
const groupIndeterminate =
|
||||
!allGroupSelected && groupAllocIds.some((id) => selection.selectedIds.has(id));
|
||||
return (
|
||||
<GroupRows key={group.resourceId}>
|
||||
{/* Group header */}
|
||||
@@ -833,7 +1029,12 @@ export function AllocationsClient() {
|
||||
data-testid="allocation-group-header"
|
||||
className="bg-gray-50 dark:bg-gray-800/50 cursor-pointer select-none hover:bg-gray-100 dark:hover:bg-gray-800/80 transition-colors"
|
||||
onClick={() => toggleGroup(group.resourceId)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleGroup(group.resourceId); } }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
toggleGroup(group.resourceId);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-expanded={!isCollapsed}
|
||||
@@ -842,7 +1043,9 @@ export function AllocationsClient() {
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allGroupSelected}
|
||||
ref={(el) => { if (el) el.indeterminate = groupIndeterminate; }}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = groupIndeterminate;
|
||||
}}
|
||||
onChange={() => selection.toggleAll(groupAllocIds)}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
@@ -872,64 +1075,88 @@ export function AllocationsClient() {
|
||||
</td>
|
||||
</tr>
|
||||
{/* Project sub-groups within person */}
|
||||
{!isCollapsed && group.projectSubGroups.map((subGroup) => {
|
||||
const subKey = `${group.resourceId}::${subGroup.projectId}`;
|
||||
const isSubExpanded = expandedSubGroups.has(subKey);
|
||||
{!isCollapsed &&
|
||||
group.projectSubGroups.map((subGroup) => {
|
||||
const subKey = `${group.resourceId}::${subGroup.projectId}`;
|
||||
const isSubExpanded = expandedSubGroups.has(subKey);
|
||||
|
||||
// Single allocation for this project — render directly, no sub-group header
|
||||
if (subGroup.allocations.length === 1) {
|
||||
return <GroupRows key={subKey}>{renderAllocRow(subGroup.allocations[0]!, true, 0)}</GroupRows>;
|
||||
}
|
||||
// Single allocation for this project — render directly, no sub-group header
|
||||
if (subGroup.allocations.length === 1) {
|
||||
return (
|
||||
<GroupRows key={subKey}>
|
||||
{renderAllocRow(subGroup.allocations[0]!, true, 0)}
|
||||
</GroupRows>
|
||||
);
|
||||
}
|
||||
|
||||
// Multiple allocations — show collapsible project sub-group
|
||||
return (
|
||||
<GroupRows key={subKey}>
|
||||
<tr
|
||||
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"
|
||||
onClick={() => toggleSubGroup(group.resourceId, subGroup.projectId)}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-expanded={isSubExpanded}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleSubGroup(group.resourceId, subGroup.projectId); } }}
|
||||
>
|
||||
<td className="px-4 py-2" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
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));
|
||||
// Multiple allocations — show collapsible project sub-group
|
||||
return (
|
||||
<GroupRows key={subKey}>
|
||||
<tr
|
||||
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"
|
||||
onClick={() => toggleSubGroup(group.resourceId, subGroup.projectId)}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-expanded={isSubExpanded}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
toggleSubGroup(group.resourceId, subGroup.projectId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td className="px-4 py-2" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
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))
|
||||
}
|
||||
}}
|
||||
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">
|
||||
<div className="flex items-center gap-2 pl-4">
|
||||
<span className="text-gray-400 dark:text-gray-500 text-xs">
|
||||
{isSubExpanded ? "▾" : "▸"}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-gray-400 dark:text-gray-500">{subGroup.projectCode}</span>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">{subGroup.projectName}</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{formatDate(subGroup.earliestStart)} → {formatDate(subGroup.latestEnd)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{subGroup.typicalHoursPerDay}h/day
|
||||
</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">
|
||||
{subGroup.allocations.length}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{isSubExpanded && subGroup.allocations.map((alloc, idx) => renderAllocRow(alloc, true, idx))}
|
||||
</GroupRows>
|
||||
);
|
||||
})}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
</td>
|
||||
<td colSpan={visibleColumns.length + 1} className="px-4 py-2">
|
||||
<div className="flex items-center gap-2 pl-4">
|
||||
<span className="text-gray-400 dark:text-gray-500 text-xs">
|
||||
{isSubExpanded ? "▾" : "▸"}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-gray-400 dark:text-gray-500">
|
||||
{subGroup.projectCode}
|
||||
</span>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{subGroup.projectName}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{formatDate(subGroup.earliestStart)} →{" "}
|
||||
{formatDate(subGroup.latestEnd)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{subGroup.typicalHoursPerDay}h/day
|
||||
</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">
|
||||
{subGroup.allocations.length}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{isSubExpanded &&
|
||||
subGroup.allocations.map((alloc, idx) =>
|
||||
renderAllocRow(alloc, true, idx),
|
||||
)}
|
||||
</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="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>
|
||||
<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">
|
||||
Placeholder demand rows not yet assigned to a resource.
|
||||
</p>
|
||||
@@ -959,18 +1188,27 @@ export function AllocationsClient() {
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{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 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 className="flex flex-shrink-0 items-center gap-4">
|
||||
<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">
|
||||
{demand.unfilledHeadcount ?? demand.headcount} / {demand.requestedHeadcount ?? demand.headcount}
|
||||
{demand.unfilledHeadcount ?? demand.headcount} /{" "}
|
||||
{demand.requestedHeadcount ?? demand.headcount}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -999,9 +1237,17 @@ export function AllocationsClient() {
|
||||
|
||||
{/* Batch Status Picker */}
|
||||
{batchStatusPicker && (
|
||||
<div className="fixed inset-0 bg-black/30 z-50 flex items-center justify-center p-4" 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="fixed inset-0 bg-black/30 z-50 flex items-center justify-center p-4"
|
||||
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">
|
||||
{ALL_ALLOC_STATUSES.map((s) => (
|
||||
<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"
|
||||
>
|
||||
<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}
|
||||
</span>
|
||||
</button>
|
||||
@@ -1060,7 +1308,10 @@ export function AllocationsClient() {
|
||||
message={`Set ${confirmBatchStatus.ids.length} allocation${confirmBatchStatus.ids.length !== 1 ? "s" : ""} to "${confirmBatchStatus.status}"?`}
|
||||
confirmLabel="Update"
|
||||
onConfirm={() => {
|
||||
batchStatusMutation.mutate({ ids: confirmBatchStatus.ids, status: confirmBatchStatus.status as never });
|
||||
batchStatusMutation.mutate({
|
||||
ids: confirmBatchStatus.ids,
|
||||
status: confirmBatchStatus.status as never,
|
||||
});
|
||||
setConfirmBatchStatus(null);
|
||||
}}
|
||||
onCancel={() => setConfirmBatchStatus(null)}
|
||||
@@ -1097,7 +1348,11 @@ export function AllocationsClient() {
|
||||
count={selection.count}
|
||||
isPending={batchDateShiftMutation.isPending}
|
||||
onConfirm={(daysDelta) =>
|
||||
batchDateShiftMutation.mutate({ allocationIds: selectedMutationIds, daysDelta, mode: "move" })
|
||||
batchDateShiftMutation.mutate({
|
||||
allocationIds: selectedMutationIds,
|
||||
daysDelta,
|
||||
mode: "move",
|
||||
})
|
||||
}
|
||||
onClose={() => setShowDateShiftModal(false)}
|
||||
/>
|
||||
@@ -1105,7 +1360,11 @@ export function AllocationsClient() {
|
||||
|
||||
{/* Modal */}
|
||||
{modalOpen && (
|
||||
<AllocationModal allocation={editingAllocation} onClose={closeModal} onSuccess={closeModal} />
|
||||
<AllocationModal
|
||||
allocation={editingAllocation}
|
||||
onClose={closeModal}
|
||||
onSuccess={closeModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { BlueprintTarget } from "@capakraken/shared";
|
||||
import type { BlueprintTarget } from "@capakraken/shared";
|
||||
import type { BlueprintFieldDefinition } from "@capakraken/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { BlueprintFieldCatalog } from "./BlueprintFieldCatalog.js";
|
||||
@@ -68,27 +68,59 @@ function NewBlueprintModal({ onClose, onCreated }: NewBlueprintModalProps) {
|
||||
<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">
|
||||
<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>
|
||||
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium text-gray-700">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 />
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
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 className="flex flex-col gap-1">
|
||||
<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 className="flex flex-col gap-1">
|
||||
<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="PROJECT">Project</option>
|
||||
</select>
|
||||
</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">
|
||||
<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}>
|
||||
{createMutation.isPending ? "Creating…" : "Create Blueprint"}
|
||||
</button>
|
||||
@@ -128,14 +160,20 @@ function BlueprintCard({
|
||||
isSelected,
|
||||
onToggleSelect,
|
||||
}: BlueprintCardProps) {
|
||||
const fieldDefs = Array.isArray(blueprint.fieldDefs) ? (blueprint.fieldDefs as BlueprintFieldDefinition[]) : [];
|
||||
const rolePresets = Array.isArray(blueprint.rolePresets) ? (blueprint.rolePresets as unknown[]) : [];
|
||||
const fieldDefs = Array.isArray(blueprint.fieldDefs)
|
||||
? (blueprint.fieldDefs as BlueprintFieldDefinition[])
|
||||
: [];
|
||||
const rolePresets = Array.isArray(blueprint.rolePresets)
|
||||
? (blueprint.rolePresets as unknown[])
|
||||
: [];
|
||||
const fieldCount = fieldDefs.length;
|
||||
const presetCount = rolePresets.length;
|
||||
const isProject = blueprint.target === "PROJECT";
|
||||
|
||||
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 gap-3 flex-1 min-w-0">
|
||||
<input
|
||||
@@ -151,29 +189,45 @@ function BlueprintCard({
|
||||
)}
|
||||
</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}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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 && (
|
||||
<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>
|
||||
)}
|
||||
{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 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
|
||||
</button>
|
||||
{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
|
||||
</button>
|
||||
)}
|
||||
@@ -185,7 +239,11 @@ function BlueprintCard({
|
||||
? "border border-amber-300 text-amber-700 hover:bg-amber-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"}
|
||||
</button>
|
||||
@@ -227,11 +285,16 @@ export function BlueprintsClient() {
|
||||
|
||||
useEffect(() => {
|
||||
selection.clear();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [targetFilter]);
|
||||
|
||||
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,
|
||||
initialDir: viewPrefs.savedSort?.dir ?? null,
|
||||
onSortChange: (field, dir) => {
|
||||
@@ -287,17 +350,16 @@ export function BlueprintsClient() {
|
||||
<div className="flex items-start justify-between mb-6 gap-4">
|
||||
<div>
|
||||
<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>
|
||||
<button type="button" onClick={() => setShowNewModal(true)} className={BTN_PRIMARY}>
|
||||
+ New Blueprint
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<FilterBar
|
||||
hasActiveFilters={!!targetFilter}
|
||||
onClearFilters={() => setTargetFilter("")}
|
||||
>
|
||||
<FilterBar hasActiveFilters={!!targetFilter} onClearFilters={() => setTargetFilter("")}>
|
||||
<select
|
||||
value={targetFilter}
|
||||
onChange={(e) => setTargetFilter(e.target.value)}
|
||||
@@ -312,7 +374,10 @@ export function BlueprintsClient() {
|
||||
{isLoading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{[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>
|
||||
)}
|
||||
@@ -325,7 +390,9 @@ export function BlueprintsClient() {
|
||||
|
||||
{!isLoading && !isError && blueprints.length === 0 && (
|
||||
<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}>
|
||||
+ New Blueprint
|
||||
</button>
|
||||
@@ -347,12 +414,52 @@ export function BlueprintsClient() {
|
||||
aria-label="Select all blueprints"
|
||||
/>
|
||||
</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 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>
|
||||
<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
|
||||
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>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -362,7 +469,10 @@ export function BlueprintsClient() {
|
||||
const isProject = bp.target === "PROJECT";
|
||||
|
||||
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">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -373,17 +483,29 @@ export function BlueprintsClient() {
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100">{bp.name}</div>
|
||||
{bp.description && <div className="text-xs text-gray-500 mt-0.5 truncate">{bp.description}</div>}
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{bp.name}
|
||||
</div>
|
||||
{bp.description && (
|
||||
<div className="text-xs text-gray-500 mt-0.5 truncate">
|
||||
{bp.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<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}
|
||||
</span>
|
||||
</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">{isProject ? presetCount : "—"}</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">
|
||||
{isProject ? presetCount : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-center">
|
||||
{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">
|
||||
@@ -397,7 +519,10 @@ export function BlueprintsClient() {
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<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"
|
||||
>
|
||||
Edit Fields
|
||||
@@ -405,7 +530,10 @@ export function BlueprintsClient() {
|
||||
{isProject && (
|
||||
<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"
|
||||
>
|
||||
Presets
|
||||
@@ -445,8 +573,14 @@ export function BlueprintsClient() {
|
||||
<BlueprintCard
|
||||
key={bp.id}
|
||||
blueprint={bp}
|
||||
onEditFields={() => { setEditingTab("fields"); setEditingBlueprint(bp); }}
|
||||
onEditStaffing={() => { setEditingTab("presets"); setEditingBlueprint(bp); }}
|
||||
onEditFields={() => {
|
||||
setEditingTab("fields");
|
||||
setEditingBlueprint(bp);
|
||||
}}
|
||||
onEditStaffing={() => {
|
||||
setEditingTab("presets");
|
||||
setEditingBlueprint(bp);
|
||||
}}
|
||||
onToggleGlobal={() => handleToggleGlobal(bp.id, bp.isGlobal)}
|
||||
onDelete={() => handleDelete(bp.id)}
|
||||
isSelected={selection.selectedIds.has(bp.id)}
|
||||
@@ -485,7 +619,10 @@ export function BlueprintsClient() {
|
||||
)}
|
||||
|
||||
{showNewModal && (
|
||||
<NewBlueprintModal onClose={() => setShowNewModal(false)} onCreated={() => setShowNewModal(false)} />
|
||||
<NewBlueprintModal
|
||||
onClose={() => setShowNewModal(false)}
|
||||
onCreated={() => setShowNewModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingBlueprint && (
|
||||
@@ -493,8 +630,16 @@ export function BlueprintsClient() {
|
||||
blueprintId={editingBlueprint.id}
|
||||
blueprintName={editingBlueprint.name}
|
||||
blueprintTarget={editingBlueprint.target}
|
||||
initialFieldDefs={Array.isArray(editingBlueprint.fieldDefs) ? (editingBlueprint.fieldDefs as BlueprintFieldDefinition[]) : []}
|
||||
initialRolePresets={Array.isArray(editingBlueprint.rolePresets) ? (editingBlueprint.rolePresets as import("@capakraken/shared").StaffingRequirement[]) : []}
|
||||
initialFieldDefs={
|
||||
Array.isArray(editingBlueprint.fieldDefs)
|
||||
? (editingBlueprint.fieldDefs as BlueprintFieldDefinition[])
|
||||
: []
|
||||
}
|
||||
initialRolePresets={
|
||||
Array.isArray(editingBlueprint.rolePresets)
|
||||
? (editingBlueprint.rolePresets as import("@capakraken/shared").StaffingRequirement[])
|
||||
: []
|
||||
}
|
||||
initialTab={editingTab}
|
||||
onClose={() => setEditingBlueprint(null)}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { trpc } from "~/lib/trpc/client.js";
|
||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||
import { TaskCard } from "~/components/notifications/TaskCard.js";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export function TaskWidget(_props: Partial<WidgetProps> = {}) {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
@@ -15,10 +14,13 @@ export function TaskWidget(_props: Partial<WidgetProps> = {}) {
|
||||
{ staleTime: 30_000, placeholderData: (prev) => prev },
|
||||
);
|
||||
|
||||
const { data: taskCounts, isLoading: loadingCounts } = trpc.notification.taskCounts.useQuery(undefined, {
|
||||
staleTime: 30_000,
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
const { data: taskCounts, isLoading: loadingCounts } = trpc.notification.taskCounts.useQuery(
|
||||
undefined,
|
||||
{
|
||||
staleTime: 30_000,
|
||||
placeholderData: (prev) => prev,
|
||||
},
|
||||
);
|
||||
|
||||
const updateTaskStatus = trpc.notification.updateTaskStatus.useMutation({
|
||||
onSuccess: () => {
|
||||
@@ -30,7 +32,10 @@ export function TaskWidget(_props: Partial<WidgetProps> = {}) {
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import dynamic from "next/dynamic";
|
||||
import { EstimateExportFormat } from "@capakraken/shared";
|
||||
import type { EstimateExportFormat } from "@capakraken/shared";
|
||||
import { clsx } from "clsx";
|
||||
import { useSession } from "next-auth/react";
|
||||
import type {
|
||||
@@ -23,52 +23,80 @@ const TabSkeleton = () => (
|
||||
);
|
||||
|
||||
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 },
|
||||
);
|
||||
|
||||
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 },
|
||||
);
|
||||
|
||||
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 },
|
||||
);
|
||||
|
||||
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 },
|
||||
);
|
||||
|
||||
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 },
|
||||
);
|
||||
|
||||
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 },
|
||||
);
|
||||
|
||||
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 },
|
||||
);
|
||||
|
||||
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 },
|
||||
);
|
||||
|
||||
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 },
|
||||
);
|
||||
|
||||
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 },
|
||||
);
|
||||
|
||||
@@ -137,20 +165,22 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
|
||||
const createPlanningHandoffMutation = trpc.estimate.createPlanningHandoff.useMutation();
|
||||
const estimateCommentTarget = { entityType: "estimate" as const, entityId: estimateId };
|
||||
const canLoadCommentCount =
|
||||
canViewCosts
|
||||
&& !isPermissionsLoading
|
||||
&& detailQuery.status === "success"
|
||||
&& detailQuery.data != null;
|
||||
canViewCosts &&
|
||||
!isPermissionsLoading &&
|
||||
detailQuery.status === "success" &&
|
||||
detailQuery.data != null;
|
||||
|
||||
const commentCountQuery = trpc.comment.count.useQuery(
|
||||
estimateCommentTarget,
|
||||
{ enabled: canLoadCommentCount, staleTime: 30_000 },
|
||||
);
|
||||
const commentCountQuery = trpc.comment.count.useQuery(estimateCommentTarget, {
|
||||
enabled: canLoadCommentCount,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const commentCount = commentCountQuery.data ?? 0;
|
||||
|
||||
const estimate = (detailQuery.data as EstimateWorkspaceView | undefined) ?? null;
|
||||
const hasWorkingVersion = estimate?.versions.some((version) => version.status === "WORKING") ?? false;
|
||||
const editableTab = tab === "overview" || tab === "assumptions" || tab === "scope" || tab === "staffing";
|
||||
const hasWorkingVersion =
|
||||
estimate?.versions.some((version) => version.status === "WORKING") ?? false;
|
||||
const editableTab =
|
||||
tab === "overview" || tab === "assumptions" || tab === "scope" || tab === "staffing";
|
||||
|
||||
useEffect(() => {
|
||||
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="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
|
||||
<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">
|
||||
{estimate?.name ?? "Loading estimate"}
|
||||
</h1>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{estimate && (
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
{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"
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
@@ -293,7 +335,9 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
|
||||
{isPermissionsLoading ? (
|
||||
<EmptyState>Loading estimate workspace...</EmptyState>
|
||||
) : !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 ? (
|
||||
<EmptyState>Loading estimate workspace...</EmptyState>
|
||||
) : detailQuery.error ? (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { EstimateStatus, EstimateVersionStatus } from "@capakraken/shared";
|
||||
import type { EstimateStatus, EstimateVersionStatus } from "@capakraken/shared";
|
||||
import type {
|
||||
EstimateMetricView,
|
||||
EstimateVersionView,
|
||||
@@ -45,7 +45,12 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
|
||||
<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="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("_", " ")}
|
||||
</span>
|
||||
{estimate.project && (
|
||||
@@ -57,22 +62,39 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
|
||||
|
||||
<div className="mt-5 grid gap-4 md:grid-cols-2">
|
||||
<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="mt-1 text-sm text-gray-800 dark:text-gray-200">{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="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="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>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
@@ -87,37 +109,61 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
|
||||
<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="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>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
{(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">
|
||||
<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>
|
||||
</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 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">
|
||||
<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>
|
||||
<span className="text-xs text-gray-400">{latestVersion?.demandLines.length ?? 0}</span>
|
||||
<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>
|
||||
<span className="text-xs text-gray-400">
|
||||
{latestVersion?.demandLines.length ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
{(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">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{line.name}</p>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{line.hours.toFixed(1)} h</span>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{line.name}
|
||||
</p>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{line.hours.toFixed(1)} h
|
||||
</span>
|
||||
</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>
|
||||
@@ -125,15 +171,25 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
|
||||
|
||||
<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">
|
||||
<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">
|
||||
{latestMetrics.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">No derived metrics available yet.</p>
|
||||
) : (
|
||||
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">
|
||||
<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
|
||||
key={metric.id}
|
||||
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>
|
||||
))
|
||||
)}
|
||||
@@ -141,27 +197,41 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{latestVersion ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
<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 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 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 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 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 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>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 type { Project } from "@capakraken/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
@@ -79,9 +79,12 @@ function projectToForm(project: Project): FormState {
|
||||
status: project.status,
|
||||
responsiblePerson: project.responsiblePerson ?? "",
|
||||
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 ?? "",
|
||||
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 [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 });
|
||||
|
||||
// @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({
|
||||
onSuccess: async () => {
|
||||
await utils.project.listWithCosts.invalidate();
|
||||
@@ -193,7 +198,9 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
||||
status: form.status as unknown as ProjectStatus,
|
||||
responsiblePerson: form.responsiblePerson.trim(),
|
||||
...(form.color ? { color: form.color } : {}),
|
||||
...(form.utilizationCategoryId ? { utilizationCategoryId: form.utilizationCategoryId } : {}),
|
||||
...(form.utilizationCategoryId
|
||||
? { utilizationCategoryId: form.utilizationCategoryId }
|
||||
: {}),
|
||||
...(form.clientId ? { clientId: form.clientId } : {}),
|
||||
shoringThreshold: Number(form.shoringThreshold),
|
||||
},
|
||||
@@ -213,7 +220,9 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
||||
dynamicFields: {},
|
||||
responsiblePerson: form.responsiblePerson.trim(),
|
||||
...(form.color ? { color: form.color } : {}),
|
||||
...(form.utilizationCategoryId ? { utilizationCategoryId: form.utilizationCategoryId } : {}),
|
||||
...(form.utilizationCategoryId
|
||||
? { utilizationCategoryId: form.utilizationCategoryId }
|
||||
: {}),
|
||||
...(form.clientId ? { clientId: form.clientId } : {}),
|
||||
shoringThreshold: Number(form.shoringThreshold),
|
||||
});
|
||||
@@ -241,7 +250,12 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
||||
aria-label="Close"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -282,7 +296,9 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
||||
}
|
||||
/>
|
||||
{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>
|
||||
@@ -362,7 +378,9 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
||||
className={errors.winProbability ? inputErrorClass : inputClass}
|
||||
/>
|
||||
{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>
|
||||
@@ -388,7 +406,8 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
||||
<option value="">— Not specified —</option>
|
||||
{(utilizationCategories ?? []).map((cat) => (
|
||||
<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>
|
||||
))}
|
||||
</select>
|
||||
@@ -408,7 +427,9 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
||||
{(clientList ?? []).map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{(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>
|
||||
))}
|
||||
</select>
|
||||
@@ -434,7 +455,9 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
||||
className={errors.startDate ? inputErrorClass : inputClass}
|
||||
/>
|
||||
{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>
|
||||
@@ -469,7 +492,9 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
||||
className={errors.budgetEur ? inputErrorClass : inputClass}
|
||||
/>
|
||||
{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>
|
||||
@@ -576,7 +601,13 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
||||
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"
|
||||
>
|
||||
{isLoading ? (isEdit ? "Saving…" : "Creating…") : isEdit ? "Save Changes" : "Create Project"}
|
||||
{isLoading
|
||||
? isEdit
|
||||
? "Saving…"
|
||||
: "Creating…"
|
||||
: isEdit
|
||||
? "Save Changes"
|
||||
: "Create Project"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -3,8 +3,19 @@
|
||||
import { createPortal } from "react-dom";
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { clsx } from "clsx";
|
||||
import type { StaffingRequirement, BlueprintFieldDefinition } from "@capakraken/shared";
|
||||
import { BlueprintTarget, FieldType, OrderType, AllocationType, ProjectStatus, AllocationStatus, RolePresetsSchema } from "@capakraken/shared";
|
||||
import type {
|
||||
StaffingRequirement,
|
||||
BlueprintFieldDefinition,
|
||||
OrderType,
|
||||
AllocationType,
|
||||
} from "@capakraken/shared";
|
||||
import {
|
||||
BlueprintTarget,
|
||||
FieldType,
|
||||
ProjectStatus,
|
||||
AllocationStatus,
|
||||
RolePresetsSchema,
|
||||
} from "@capakraken/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { uuid } from "~/lib/uuid.js";
|
||||
import { DateInput } from "~/components/ui/DateInput.js";
|
||||
@@ -200,12 +211,7 @@ function DynamicFieldInput({
|
||||
</label>
|
||||
);
|
||||
case FieldType.DATE:
|
||||
return (
|
||||
<DateInput
|
||||
value={strVal}
|
||||
onChange={(v) => onChange(field.key, v)}
|
||||
/>
|
||||
);
|
||||
return <DateInput value={strVal} onChange={(v) => onChange(field.key, v)} />;
|
||||
case FieldType.SELECT:
|
||||
return (
|
||||
<select
|
||||
@@ -249,7 +255,9 @@ function DynamicFieldInput({
|
||||
// TEXT, URL, EMAIL
|
||||
return (
|
||||
<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}
|
||||
onChange={(e) => onChange(field.key, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
@@ -270,14 +278,28 @@ function Step1({ state, onChange }: Step1Props) {
|
||||
const { data: blueprints } = trpc.blueprint.list.useQuery(
|
||||
{ target: BlueprintTarget.PROJECT, isActive: true },
|
||||
{ staleTime: 30_000 },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) as { data: Array<{ id: string; name: string; description?: string | null; rolePresets?: unknown; fieldDefs?: unknown }> | undefined };
|
||||
) as {
|
||||
data:
|
||||
| Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
rolePresets?: unknown;
|
||||
fieldDefs?: unknown;
|
||||
}>
|
||||
| undefined;
|
||||
};
|
||||
|
||||
const selectedBp = blueprints?.find((b) => b.id === state.blueprintId);
|
||||
|
||||
function selectBlueprint(id: string | null) {
|
||||
if (!id) {
|
||||
onChange({ blueprintId: null, blueprintName: null, blueprintFieldDefs: [], dynamicFields: {} });
|
||||
onChange({
|
||||
blueprintId: null,
|
||||
blueprintName: null,
|
||||
blueprintFieldDefs: [],
|
||||
dynamicFields: {},
|
||||
});
|
||||
return;
|
||||
}
|
||||
const bp = blueprints?.find((b) => b.id === id);
|
||||
@@ -285,7 +307,9 @@ function Step1({ state, onChange }: Step1Props) {
|
||||
const parsedPresets = RolePresetsSchema.safeParse(
|
||||
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
|
||||
const rawFieldDefs = Array.isArray(bp?.fieldDefs) ? (bp.fieldDefs as unknown[]) : [];
|
||||
const fieldDefs = rawFieldDefs.filter(
|
||||
@@ -312,7 +336,10 @@ function Step1({ state, onChange }: Step1Props) {
|
||||
<div className="space-y-5">
|
||||
{/* Blueprint picker */}
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
@@ -364,7 +391,10 @@ function Step1({ state, onChange }: Step1Props) {
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Short code */}
|
||||
<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
|
||||
type="text"
|
||||
value={state.shortCode}
|
||||
@@ -377,7 +407,10 @@ function Step1({ state, onChange }: Step1Props) {
|
||||
|
||||
{/* Name */}
|
||||
<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
|
||||
type="text"
|
||||
value={state.name}
|
||||
@@ -389,7 +422,10 @@ function Step1({ state, onChange }: Step1Props) {
|
||||
|
||||
{/* Order type */}
|
||||
<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
|
||||
value={state.orderType}
|
||||
onChange={(e) => onChange({ orderType: e.target.value })}
|
||||
@@ -405,7 +441,10 @@ function Step1({ state, onChange }: Step1Props) {
|
||||
|
||||
{/* Allocation type */}
|
||||
<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
|
||||
value={state.allocationType}
|
||||
onChange={(e) => onChange({ allocationType: e.target.value })}
|
||||
@@ -430,13 +469,14 @@ function Step1({ state, onChange }: Step1Props) {
|
||||
{[...state.blueprintFieldDefs]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.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">
|
||||
{field.label}
|
||||
{field.required && " *"}
|
||||
{field.description && (
|
||||
<InfoTooltip content={field.description} />
|
||||
)}
|
||||
{field.description && <InfoTooltip content={field.description} />}
|
||||
</label>
|
||||
<DynamicFieldInput
|
||||
field={field}
|
||||
@@ -456,7 +496,13 @@ function Step1({ state, onChange }: Step1Props) {
|
||||
|
||||
// ─── 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 [open, setOpen] = useState(false);
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
@@ -478,7 +524,8 @@ function ResourcePersonPicker({ value, onChange }: { value: string; onChange: (v
|
||||
{ staleTime: 15_000, placeholderData: (prev: any) => prev },
|
||||
);
|
||||
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],
|
||||
);
|
||||
|
||||
@@ -516,7 +563,12 @@ function ResourcePersonPicker({ value, onChange }: { value: string; onChange: (v
|
||||
{isConfirmed && (
|
||||
<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">
|
||||
<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>
|
||||
</span>
|
||||
)}
|
||||
@@ -571,24 +623,27 @@ function Step2({ state, onChange }: Step2Props) {
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="app-label">Start Date *<InfoTooltip content="First day of the project period. Assignments and budget tracking begin from this date." /></label>
|
||||
<DateInput
|
||||
value={state.startDate}
|
||||
onChange={(v) => onChange({ startDate: v })}
|
||||
/>
|
||||
<label className="app-label">
|
||||
Start Date *
|
||||
<InfoTooltip content="First day of the project period. Assignments and budget tracking begin from this date." />
|
||||
</label>
|
||||
<DateInput value={state.startDate} onChange={(v) => onChange({ startDate: v })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="app-label">End Date *<InfoTooltip content="Last day of the project period. Defines the timeline boundary for all assignments." /></label>
|
||||
<DateInput
|
||||
value={state.endDate}
|
||||
onChange={(v) => onChange({ endDate: v })}
|
||||
/>
|
||||
<label className="app-label">
|
||||
End Date *
|
||||
<InfoTooltip content="Last day of the project period. Defines the timeline boundary for all assignments." />
|
||||
</label>
|
||||
<DateInput value={state.endDate} onChange={(v) => onChange({ endDate: v })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<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
|
||||
type="number"
|
||||
min={0}
|
||||
@@ -600,7 +655,10 @@ function Step2({ state, onChange }: Step2Props) {
|
||||
/>
|
||||
</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
|
||||
value={state.responsiblePerson}
|
||||
onChange={(v) => onChange({ responsiblePerson: v })}
|
||||
@@ -639,19 +697,14 @@ interface Step3Props {
|
||||
}
|
||||
|
||||
function Step3({ state, onChange }: Step3Props) {
|
||||
const { data: rolesData } = trpc.role.list.useQuery(
|
||||
{ isActive: true },
|
||||
{ staleTime: 30_000 },
|
||||
);
|
||||
const { data: rolesData } = trpc.role.list.useQuery({ isActive: true }, { staleTime: 30_000 });
|
||||
const roles = rolesData ?? [];
|
||||
|
||||
const { data: chaptersData } = trpc.resource.chapters.useQuery(undefined, { staleTime: 60_000 });
|
||||
const chapters = (chaptersData ?? []) as string[];
|
||||
|
||||
function updateReq(idx: number, patch: Partial<StaffingRequirement>) {
|
||||
const next = state.staffingReqs.map((r, i) =>
|
||||
i === idx ? { ...r, ...patch } : r,
|
||||
);
|
||||
const next = state.staffingReqs.map((r, i) => (i === idx ? { ...r, ...patch } : r));
|
||||
onChange({ staffingReqs: next });
|
||||
}
|
||||
|
||||
@@ -672,28 +725,40 @@ function Step3({ state, onChange }: Step3Props) {
|
||||
)}
|
||||
|
||||
{/* Budget allocation summary */}
|
||||
{state.budgetEur && parseFloat(state.budgetEur) > 0 && state.staffingReqs.length > 0 && (() => {
|
||||
const projectBudgetCents = Math.round(parseFloat(state.budgetEur || "0") * 100);
|
||||
const allocatedCents = state.staffingReqs.reduce((sum, r) => sum + (r.budgetCents ?? 0), 0);
|
||||
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>
|
||||
{state.budgetEur &&
|
||||
parseFloat(state.budgetEur) > 0 &&
|
||||
state.staffingReqs.length > 0 &&
|
||||
(() => {
|
||||
const projectBudgetCents = Math.round(parseFloat(state.budgetEur || "0") * 100);
|
||||
const allocatedCents = state.staffingReqs.reduce(
|
||||
(sum, r) => sum + (r.budgetCents ?? 0),
|
||||
0,
|
||||
);
|
||||
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 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">
|
||||
{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 className="flex flex-wrap items-start gap-2">
|
||||
<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 ? (
|
||||
<select
|
||||
value={req.roleId ?? ""}
|
||||
@@ -718,18 +786,22 @@ function Step3({ state, onChange }: Step3Props) {
|
||||
// Clear roleId — rebuild without the key
|
||||
const { roleId: _r, ...rest } = state.staffingReqs[idx]!;
|
||||
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"
|
||||
>
|
||||
<option value="">Custom / Free text…</option>
|
||||
{roles.map((ro) => (
|
||||
<option key={ro.id} value={ro.id}>{ro.name}</option>
|
||||
<option key={ro.id} value={ro.id}>
|
||||
{ro.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
{(!req.roleId) && (
|
||||
{!req.roleId && (
|
||||
<input
|
||||
type="text"
|
||||
value={req.role}
|
||||
@@ -740,7 +812,10 @@ function Step3({ state, onChange }: Step3Props) {
|
||||
)}
|
||||
</div>
|
||||
<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
|
||||
type="number"
|
||||
value={req.hoursPerDay}
|
||||
@@ -752,7 +827,10 @@ function Step3({ state, onChange }: Step3Props) {
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
type="number"
|
||||
value={req.headcount}
|
||||
@@ -763,7 +841,10 @@ function Step3({ state, onChange }: Step3Props) {
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
type="number"
|
||||
value={req.budgetCents ? req.budgetCents / 100 : ""}
|
||||
@@ -771,7 +852,9 @@ function Step3({ state, onChange }: Step3Props) {
|
||||
step={100}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
className="app-input"
|
||||
@@ -789,7 +872,10 @@ function Step3({ state, onChange }: Step3Props) {
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2">
|
||||
<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
|
||||
value={req.requiredSkills}
|
||||
onChange={(skills) => updateReq(idx, { requiredSkills: skills })}
|
||||
@@ -797,7 +883,10 @@ function Step3({ state, onChange }: Step3Props) {
|
||||
/>
|
||||
</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
|
||||
value={req.preferredSkills ?? []}
|
||||
onChange={(skills) => updateReq(idx, { preferredSkills: skills })}
|
||||
@@ -805,20 +894,27 @@ function Step3({ state, onChange }: Step3Props) {
|
||||
/>
|
||||
</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
|
||||
type="text"
|
||||
list="chapter-options"
|
||||
value={req.chapter ?? ""}
|
||||
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"
|
||||
className="app-input"
|
||||
/>
|
||||
{chapters.length > 0 && (
|
||||
<datalist id="chapter-options">
|
||||
{chapters.map((ch) => <option key={ch} value={ch} />)}
|
||||
{chapters.map((ch) => (
|
||||
<option key={ch} value={ch} />
|
||||
))}
|
||||
</datalist>
|
||||
)}
|
||||
</div>
|
||||
@@ -890,7 +986,10 @@ function ReqSuggestions({
|
||||
|
||||
if (!req.requiredSkills.length) {
|
||||
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
|
||||
? "bg-green-100 text-green-700"
|
||||
: item.valueScore >= 40
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: "bg-red-100 text-red-700"
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: "bg-red-100 text-red-700"
|
||||
}`}
|
||||
title="Value Score (price/quality)"
|
||||
>
|
||||
@@ -1002,10 +1101,7 @@ interface Step4Props {
|
||||
function Step4({ state, onChange }: Step4Props) {
|
||||
function assign(requirementId: string, resourceId: string, resourceName: string, role: string) {
|
||||
onChange({
|
||||
assignments: [
|
||||
...state.assignments,
|
||||
{ requirementId, resourceId, resourceName, role },
|
||||
],
|
||||
assignments: [...state.assignments, { requirementId, resourceId, resourceName, role }],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1066,7 +1162,14 @@ interface Step5Props {
|
||||
submitWarnings: string[];
|
||||
}
|
||||
|
||||
function Step5({ state, onChange, onSubmit, isSubmitting, submitError, submitWarnings }: Step5Props) {
|
||||
function Step5({
|
||||
state,
|
||||
onChange,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
submitError,
|
||||
submitWarnings,
|
||||
}: Step5Props) {
|
||||
const totalAssignedCostHint = useMemo(() => {
|
||||
// Very rough hint: sum hoursPerDay * headcount across all requirements
|
||||
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">
|
||||
{/* Project summary */}
|
||||
<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." />
|
||||
</div>
|
||||
<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()}` : "—"}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Dates:</span>{" "}
|
||||
{state.startDate} → {state.endDate}
|
||||
<span className="text-gray-500">Dates:</span> {state.startDate} → {state.endDate}
|
||||
</div>
|
||||
<div>
|
||||
<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 unassigned = req.headcount - assigned.length;
|
||||
return (
|
||||
<div
|
||||
key={req.id}
|
||||
className="px-3 py-2 rounded-lg bg-gray-50 text-sm"
|
||||
>
|
||||
<div key={req.id} className="px-3 py-2 rounded-lg bg-gray-50 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex-1 font-medium">{req.role || "Unnamed"}</span>
|
||||
<span className="text-gray-400 text-xs">{req.hoursPerDay}h/day</span>
|
||||
{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}
|
||||
<span
|
||||
className={clsx(
|
||||
@@ -1179,10 +1282,20 @@ function Step5({ state, onChange, onSubmit, isSubmitting, submitError, submitWar
|
||||
{(req.requiredSkills.length > 0 || (req.preferredSkills ?? []).length > 0) && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{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) => (
|
||||
<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>
|
||||
)}
|
||||
@@ -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">
|
||||
<p className="font-medium">Project created with warnings:</p>
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -1253,12 +1370,7 @@ function Step5({ state, onChange, onSubmit, isSubmitting, submitError, submitWar
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={isSubmitting}
|
||||
className={BTN_PRIMARY}
|
||||
>
|
||||
<button type="button" onClick={onSubmit} disabled={isSubmitting} className={BTN_PRIMARY}>
|
||||
{isSubmitting
|
||||
? "Creating…"
|
||||
: state.saveAsDraft
|
||||
@@ -1294,7 +1406,9 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
|
||||
mutateAsync: (input: unknown) => Promise<unknown>;
|
||||
};
|
||||
// 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>;
|
||||
};
|
||||
|
||||
@@ -1321,7 +1435,12 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
|
||||
const missingRequired = state.blueprintFieldDefs.some((f) => {
|
||||
if (!f.required) return false;
|
||||
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 (
|
||||
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.
|
||||
return state.staffingReqs.every(
|
||||
(r) =>
|
||||
(r.roleId != null || r.role.trim().length > 0) &&
|
||||
r.hoursPerDay > 0 &&
|
||||
r.headcount >= 1,
|
||||
(r.roleId != null || r.role.trim().length > 0) && r.hoursPerDay > 0 && r.headcount >= 1,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
@@ -1419,7 +1536,9 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
|
||||
});
|
||||
} catch (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;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8 px-4"
|
||||
>
|
||||
<div 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">
|
||||
{/* Celebration effects */}
|
||||
<ConfettiBurst trigger={showConfetti} />
|
||||
@@ -1527,11 +1644,7 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
|
||||
)}
|
||||
{step === 4 && (
|
||||
<div className="flex items-center px-6 py-4 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep((s) => s - 1)}
|
||||
className={BTN_SECONDARY}
|
||||
>
|
||||
<button type="button" onClick={() => setStep((s) => s - 1)} className={BTN_SECONDARY}>
|
||||
← Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -18,10 +18,16 @@ export function MfaSetup() {
|
||||
useEffect(() => {
|
||||
if (!uri) return;
|
||||
let cancelled = false;
|
||||
QRCode.toDataURL(uri, { width: 200, margin: 2 }).then((dataUrl) => {
|
||||
if (!cancelled) setQrDataUrl(dataUrl);
|
||||
}).catch(() => {/* ignore — manual key is shown as fallback */});
|
||||
return () => { cancelled = true; };
|
||||
QRCode.toDataURL(uri, { width: 200, margin: 2 })
|
||||
.then((dataUrl) => {
|
||||
if (!cancelled) setQrDataUrl(dataUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
/* ignore — manual key is shown as fallback */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [uri]);
|
||||
|
||||
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="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">
|
||||
<svg 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
|
||||
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>
|
||||
</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">
|
||||
Two-factor authentication is active on your account.
|
||||
</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="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">
|
||||
<svg 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
|
||||
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>
|
||||
</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">
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
@@ -116,17 +147,25 @@ export function MfaSetup() {
|
||||
|
||||
{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">
|
||||
<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">
|
||||
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>
|
||||
|
||||
{/* QR Code — rendered locally, no external service */}
|
||||
<div className="flex justify-center">
|
||||
<div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white p-3">
|
||||
{qrDataUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={qrDataUrl} alt="TOTP QR Code" width={200} height={200} className="rounded" />
|
||||
<img
|
||||
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">
|
||||
Generating…
|
||||
@@ -146,7 +185,10 @@ export function MfaSetup() {
|
||||
|
||||
<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"
|
||||
>
|
||||
Continue
|
||||
@@ -156,13 +198,18 @@ export function MfaSetup() {
|
||||
|
||||
{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">
|
||||
<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">
|
||||
Enter the 6-digit code from your authenticator app to confirm setup.
|
||||
</p>
|
||||
|
||||
<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
|
||||
</label>
|
||||
<input
|
||||
@@ -191,7 +238,11 @@ export function MfaSetup() {
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
Back
|
||||
|
||||
@@ -7,7 +7,15 @@ import {
|
||||
type Assignment,
|
||||
type DemandRequirement,
|
||||
} 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 { useSearchParams } from "next/navigation";
|
||||
import { useTimelineSSE } from "~/hooks/useTimelineSSE.js";
|
||||
@@ -77,7 +85,9 @@ export type TimelineProjectEntry = TimelineAssignmentEntry | TimelineDemandEntry
|
||||
|
||||
export type ViewMode = "resource" | "project";
|
||||
|
||||
function buildTimelineFiltersFromSearchParams(searchParams: ReturnType<typeof useSearchParams>): TimelineFilters {
|
||||
function buildTimelineFiltersFromSearchParams(
|
||||
searchParams: ReturnType<typeof useSearchParams>,
|
||||
): TimelineFilters {
|
||||
const savedPrefs = readAppPreferences();
|
||||
const next: TimelineFilters = {
|
||||
...DEFAULT_FILTERS,
|
||||
@@ -236,9 +246,10 @@ export function TimelineProvider({
|
||||
}: TimelineProviderProps) {
|
||||
const { data: session, status: sessionStatus } = useSession();
|
||||
const searchParams = useSearchParams();
|
||||
const role = sessionStatus === "authenticated"
|
||||
? ((session.user as { role?: string } | undefined)?.role ?? "USER")
|
||||
: null;
|
||||
const role =
|
||||
sessionStatus === "authenticated"
|
||||
? ((session.user as { role?: string } | undefined)?.role ?? "USER")
|
||||
: null;
|
||||
const isSelfServiceTimeline = role === "USER" || role === "VIEWER";
|
||||
const isRoleLoading = sessionStatus === "loading";
|
||||
|
||||
@@ -268,7 +279,9 @@ export function TimelineProvider({
|
||||
const viewEnd = addDays(viewStart, viewDays);
|
||||
|
||||
// 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
|
||||
// (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(
|
||||
timelineQueryInput,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
{
|
||||
enabled: !isRoleLoading && !isSelfServiceTimeline,
|
||||
placeholderData: (prev: any) => prev,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 90_000,
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) as {
|
||||
data: TimelineEntriesView | undefined;
|
||||
isLoading: boolean;
|
||||
@@ -335,14 +347,13 @@ export function TimelineProvider({
|
||||
|
||||
const selfEntriesViewQuery = trpc.timeline.getMyEntriesView.useQuery(
|
||||
timelineQueryInput,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
{
|
||||
enabled: !isRoleLoading && isSelfServiceTimeline,
|
||||
placeholderData: (prev: any) => prev,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 90_000,
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) as {
|
||||
data: TimelineEntriesView | undefined;
|
||||
isLoading: boolean;
|
||||
@@ -351,7 +362,12 @@ export function TimelineProvider({
|
||||
};
|
||||
|
||||
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 demands = entriesView?.demands ?? [];
|
||||
@@ -374,37 +390,33 @@ export function TimelineProvider({
|
||||
refetch: () => Promise<unknown>;
|
||||
};
|
||||
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 },
|
||||
);
|
||||
const {
|
||||
data: vacationEntries = [],
|
||||
refetch: refetchVacations,
|
||||
} = vacationEntriesQuery;
|
||||
const { data: vacationEntries = [], refetch: refetchVacations } = vacationEntriesQuery;
|
||||
|
||||
const staffHolidayOverlayQuery = trpc.timeline.getHolidayOverlays.useQuery(
|
||||
timelineQueryInput,
|
||||
{
|
||||
enabled: !isRoleLoading && !isSelfServiceTimeline,
|
||||
placeholderData: (prev) => prev,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 90_000,
|
||||
},
|
||||
);
|
||||
const selfHolidayOverlayQuery = trpc.timeline.getMyHolidayOverlays.useQuery(
|
||||
timelineQueryInput,
|
||||
{
|
||||
enabled: !isRoleLoading && isSelfServiceTimeline,
|
||||
placeholderData: (prev) => prev,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 90_000,
|
||||
},
|
||||
);
|
||||
const activeHolidayOverlayQuery = isSelfServiceTimeline ? selfHolidayOverlayQuery : staffHolidayOverlayQuery;
|
||||
const {
|
||||
data: holidayOverlayEntries = [],
|
||||
refetch: refetchHolidayOverlays,
|
||||
} = activeHolidayOverlayQuery;
|
||||
const staffHolidayOverlayQuery = trpc.timeline.getHolidayOverlays.useQuery(timelineQueryInput, {
|
||||
enabled: !isRoleLoading && !isSelfServiceTimeline,
|
||||
placeholderData: (prev) => prev,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 90_000,
|
||||
});
|
||||
const selfHolidayOverlayQuery = trpc.timeline.getMyHolidayOverlays.useQuery(timelineQueryInput, {
|
||||
enabled: !isRoleLoading && isSelfServiceTimeline,
|
||||
placeholderData: (prev) => prev,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 90_000,
|
||||
});
|
||||
const activeHolidayOverlayQuery = isSelfServiceTimeline
|
||||
? selfHolidayOverlayQuery
|
||||
: staffHolidayOverlayQuery;
|
||||
const { data: holidayOverlayEntries = [], refetch: refetchHolidayOverlays } =
|
||||
activeHolidayOverlayQuery;
|
||||
|
||||
const initialRefreshKey = useMemo(
|
||||
() =>
|
||||
@@ -510,7 +522,8 @@ export function TimelineProvider({
|
||||
// Hide fully-filled demands (status COMPLETED or unfilledHeadcount <= 0)
|
||||
const demandEntry = entry as { status?: string; unfilledHeadcount?: number };
|
||||
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;
|
||||
}),
|
||||
[demands, filters.hideCompletedProjects, filters.showDrafts, filters.showPlaceholders],
|
||||
@@ -642,7 +655,7 @@ export function TimelineProvider({
|
||||
filters.eids,
|
||||
filters.projectIds,
|
||||
filters.clientIds,
|
||||
]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
]);
|
||||
|
||||
// ─── Project groups (for project view) ────────────────────────────────────
|
||||
const projectGroups = useMemo(() => {
|
||||
@@ -714,18 +727,9 @@ export function TimelineProvider({
|
||||
.sort((a, b) => a.startDate.getTime() - b.startDate.getTime())
|
||||
.filter((pg) => {
|
||||
if (projectFilter.size > 0 && !projectFilter.has(pg.id)) return false;
|
||||
if (
|
||||
clientFilter.size > 0 &&
|
||||
(!pg.clientId || !clientFilter.has(pg.clientId))
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
chapterFilter.size > 0 &&
|
||||
pg.resourceRows.length === 0
|
||||
)
|
||||
return false;
|
||||
if (eidFilter.size > 0 && pg.resourceRows.length === 0)
|
||||
return false;
|
||||
if (clientFilter.size > 0 && (!pg.clientId || !clientFilter.has(pg.clientId))) return false;
|
||||
if (chapterFilter.size > 0 && pg.resourceRows.length === 0) return false;
|
||||
if (eidFilter.size > 0 && pg.resourceRows.length === 0) return false;
|
||||
return true;
|
||||
});
|
||||
}, [
|
||||
@@ -736,7 +740,7 @@ export function TimelineProvider({
|
||||
filters.clientIds,
|
||||
filters.chapters,
|
||||
filters.eids,
|
||||
]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
]);
|
||||
|
||||
// ─── Derived counts ───────────────────────────────────────────────────────
|
||||
const isInitialLoading = (isRoleLoading || isLoading) && !entriesView;
|
||||
|
||||
@@ -48,13 +48,21 @@ import { InlineAllocationEditor } from "./InlineAllocationEditor.js";
|
||||
export function TimelineView() {
|
||||
const { data: session, status: sessionStatus } = useSession();
|
||||
const mousePosRef = useRef({ x: 0, y: 0 });
|
||||
const role = sessionStatus === "authenticated"
|
||||
? ((session.user as { role?: string } | undefined)?.role ?? "USER")
|
||||
: null;
|
||||
const role =
|
||||
sessionStatus === "authenticated"
|
||||
? ((session.user as { role?: string } | undefined)?.role ?? "USER")
|
||||
: null;
|
||||
const isSelfServiceTimeline = role === "USER" || role === "VIEWER";
|
||||
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);
|
||||
pushHistoryRef.current = pushHistory;
|
||||
const pushBatchHistoryRef = useRef(pushBatchHistory);
|
||||
@@ -145,7 +153,7 @@ export function TimelineView() {
|
||||
pushHistoryRef.current(snapshot);
|
||||
},
|
||||
onShiftClickAlloc: (allocationId: string) => {
|
||||
setMultiSelectState(prev => {
|
||||
setMultiSelectState((prev) => {
|
||||
const ids = new Set(prev.selectedAllocationIds);
|
||||
if (ids.has(allocationId)) {
|
||||
ids.delete(allocationId);
|
||||
@@ -169,61 +177,64 @@ export function TimelineView() {
|
||||
const [openPanelProjectId, setOpenPanelProjectId] = useState<string | null>(null);
|
||||
const dragProjectId = dragState.isDragging ? dragState.projectId : null;
|
||||
const contextProjectId = canManageTimeline ? (dragProjectId ?? openPanelProjectId) : null;
|
||||
const { contextResourceIds, contextAllocations } = useProjectDragContext(contextProjectId, canManageTimeline);
|
||||
const { contextResourceIds, contextAllocations } = useProjectDragContext(
|
||||
contextProjectId,
|
||||
canManageTimeline,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SuccessToast
|
||||
show={dragErrorToast !== null}
|
||||
message={dragErrorToast ?? ""}
|
||||
variant="warning"
|
||||
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}
|
||||
<SuccessToast
|
||||
show={dragErrorToast !== null}
|
||||
message={dragErrorToast ?? ""}
|
||||
variant="warning"
|
||||
onDone={() => setDragErrorToast(null)}
|
||||
/>
|
||||
</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"];
|
||||
setMultiSelectState: ReturnType<typeof useTimelineDrag>["setMultiSelectState"];
|
||||
optimisticAllocations: TimelineVisualOverrides;
|
||||
reconcileOptimisticAllocations: ReturnType<typeof useTimelineDrag>["reconcileOptimisticAllocations"];
|
||||
reconcileOptimisticAllocations: ReturnType<
|
||||
typeof useTimelineDrag
|
||||
>["reconcileOptimisticAllocations"];
|
||||
onCanvasRightMouseDown: ReturnType<typeof useTimelineDrag>["onCanvasRightMouseDown"];
|
||||
clearMultiSelect: ReturnType<typeof useTimelineDrag>["clearMultiSelect"];
|
||||
shiftPreview: ReturnType<typeof useTimelineDrag>["shiftPreview"];
|
||||
@@ -410,17 +423,15 @@ function TimelineViewContent({
|
||||
} | null>(null);
|
||||
|
||||
const hasActivePointerOverlay =
|
||||
dragState.isDragging || allocDragState.isActive || rangeState.isSelecting || multiSelectState.isMultiDragging;
|
||||
dragState.isDragging ||
|
||||
allocDragState.isActive ||
|
||||
rangeState.isSelecting ||
|
||||
multiSelectState.isMultiDragging;
|
||||
|
||||
useEffect(() => {
|
||||
if (optimisticAllocations.size === 0) return;
|
||||
reconcileOptimisticAllocations([...visibleAssignments, ...visibleDemands]);
|
||||
}, [
|
||||
optimisticAllocations,
|
||||
reconcileOptimisticAllocations,
|
||||
visibleAssignments,
|
||||
visibleDemands,
|
||||
]);
|
||||
}, [optimisticAllocations, reconcileOptimisticAllocations, visibleAssignments, visibleDemands]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasActivePointerOverlay) return;
|
||||
@@ -473,12 +484,18 @@ function TimelineViewContent({
|
||||
if (!allocs || allocs.length === 0) return null;
|
||||
const projectHours = new Map<string, number>();
|
||||
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 maxH = 0;
|
||||
for (const [pid, h] of projectHours) {
|
||||
if (h > maxH) { maxH = h; maxPid = pid; }
|
||||
if (h > maxH) {
|
||||
maxH = h;
|
||||
maxPid = pid;
|
||||
}
|
||||
}
|
||||
return maxPid;
|
||||
}, [newAllocPopover, allocsByResource]);
|
||||
@@ -516,7 +533,7 @@ function TimelineViewContent({
|
||||
const target: EventTarget = multiSelectState.isMultiDragging ? document : el;
|
||||
target.addEventListener("mousemove", handler as EventListener, { passive: true });
|
||||
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 ──────────────────────────────────────
|
||||
useEffect(() => {
|
||||
@@ -530,7 +547,7 @@ function TimelineViewContent({
|
||||
};
|
||||
el.addEventListener("wheel", handler, { passive: false });
|
||||
return () => el.removeEventListener("wheel", handler);
|
||||
}, [isLoading]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [isLoading]);
|
||||
|
||||
// ─── Keyboard undo/redo ───────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
@@ -555,7 +572,10 @@ function TimelineViewContent({
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
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();
|
||||
clearMultiSelect();
|
||||
return;
|
||||
@@ -579,7 +599,19 @@ function TimelineViewContent({
|
||||
};
|
||||
window.addEventListener("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 ──────────────
|
||||
useEffect(() => {
|
||||
@@ -623,7 +655,11 @@ function TimelineViewContent({
|
||||
if (hasActivePointerOverlay) return;
|
||||
const related = e.relatedTarget as HTMLElement | null;
|
||||
// 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) {
|
||||
clearTimeout(resourceHoverTimerRef.current);
|
||||
@@ -646,7 +682,7 @@ function TimelineViewContent({
|
||||
resourceHoverTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [resourceHover?.resourceId, isInitialLoading, hasActivePointerOverlay]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [resourceHover?.resourceId, isInitialLoading, hasActivePointerOverlay]);
|
||||
|
||||
// ─── Scroll-left tracking for horizontal virtualization ────────────────────
|
||||
// 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 [scrollLeft, setScrollLeft] = useState(0);
|
||||
|
||||
|
||||
// ─── Navigation callbacks for TimelineToolbar ────────────────────────────
|
||||
const handleNavigateBack = useCallback(
|
||||
() => setViewStart((v) => addDays(v, -28)),
|
||||
@@ -669,8 +704,12 @@ function TimelineViewContent({
|
||||
() => setViewStart((v) => addDays(v, 28)),
|
||||
[setViewStart],
|
||||
);
|
||||
const handleUndo = useCallback(() => { void undo(); }, [undo]);
|
||||
const handleRedo = useCallback(() => { void redo(); }, [redo]);
|
||||
const handleUndo = useCallback(() => {
|
||||
void undo();
|
||||
}, [undo]);
|
||||
const handleRedo = useCallback(() => {
|
||||
void redo();
|
||||
}, [redo]);
|
||||
|
||||
// ─── Scroll handler — extends date range and tracks scroll offset ─────────
|
||||
const handleContainerScroll = useCallback(() => {
|
||||
@@ -712,12 +751,14 @@ function TimelineViewContent({
|
||||
setDemandPopover({ demand, x: anchorX, y: anchorY });
|
||||
return;
|
||||
}
|
||||
const allocation = visibleAssignments.find((entry) => (
|
||||
entry.id === info.allocationId
|
||||
|| entry.entityId === info.allocationId
|
||||
|| entry.sourceAllocationId === info.allocationId
|
||||
|| getPlanningEntryMutationId(entry) === info.allocationId
|
||||
)) ?? null;
|
||||
const allocation =
|
||||
visibleAssignments.find(
|
||||
(entry) =>
|
||||
entry.id === info.allocationId ||
|
||||
entry.entityId === info.allocationId ||
|
||||
entry.sourceAllocationId === info.allocationId ||
|
||||
getPlanningEntryMutationId(entry) === info.allocationId,
|
||||
) ?? null;
|
||||
setPopover({
|
||||
allocationId: info.allocationId,
|
||||
projectId: info.projectId,
|
||||
@@ -754,15 +795,33 @@ function TimelineViewContent({
|
||||
// memo() on ResourcePanel/ProjectPanel is not defeated by new fn refs.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const stableNoop = useCallback((..._args: any[]) => undefined, []);
|
||||
const panelOnAllocMouseDown = (isSelfServiceTimeline ? stableNoop : onAllocMouseDown) as typeof onAllocMouseDown;
|
||||
const panelOnAllocTouchStart = (isSelfServiceTimeline ? stableNoop : onAllocTouchStart) as typeof onAllocTouchStart;
|
||||
const panelOnRowMouseDown = (isSelfServiceTimeline ? stableNoop : onRowMouseDown) 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;
|
||||
const panelOnAllocMouseDown = (
|
||||
isSelfServiceTimeline ? stableNoop : onAllocMouseDown
|
||||
) as typeof onAllocMouseDown;
|
||||
const panelOnAllocTouchStart = (
|
||||
isSelfServiceTimeline ? stableNoop : onAllocTouchStart
|
||||
) as typeof onAllocTouchStart;
|
||||
const panelOnRowMouseDown = (
|
||||
isSelfServiceTimeline ? stableNoop : onRowMouseDown
|
||||
) 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 ────────────────────────────────
|
||||
useMultiSelectIntersection({
|
||||
@@ -854,7 +913,10 @@ function TimelineViewContent({
|
||||
}}
|
||||
onTouchEnd={(e) => void onCanvasTouchEnd(e)}
|
||||
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",
|
||||
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"
|
||||
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}d
|
||||
{" "}
|
||||
({multiSelectState.selectedAllocationIds.length} allocations)
|
||||
{multiSelectState.multiDragDaysDelta}d ({multiSelectState.selectedAllocationIds.length}{" "}
|
||||
allocations)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Allocation / Demand popover (click path) */}
|
||||
{!isSelfServiceTimeline && !hasActivePointerOverlay && popover && (() => {
|
||||
// Check if clicked allocation is actually a demand
|
||||
const clickedDemand = openDemandsByProject.get(popover.projectId)?.find((d) => d.id === popover.allocationId);
|
||||
if (clickedDemand) {
|
||||
{!isSelfServiceTimeline &&
|
||||
!hasActivePointerOverlay &&
|
||||
popover &&
|
||||
(() => {
|
||||
// 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 (
|
||||
<DemandPopover
|
||||
demand={clickedDemand}
|
||||
<AllocationPopover
|
||||
allocationId={popover.allocationId}
|
||||
projectId={popover.projectId}
|
||||
initialAllocation={popover.allocation ?? null}
|
||||
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]}
|
||||
{...(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 */}
|
||||
{!isSelfServiceTimeline && !hasActivePointerOverlay && demandPopover && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Children, cloneElement, isValidElement, ReactNode } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Children, cloneElement, isValidElement } from "react";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* ShimmerSkeleton */
|
||||
@@ -44,21 +45,15 @@ export function ShimmerSkeleton({
|
||||
const resolvedWidth = width ?? defaults?.width ?? "100%";
|
||||
const resolvedHeight = height ?? defaults?.height ?? 40;
|
||||
const resolvedRounded = rounded
|
||||
? roundedMap[rounded] ?? "rounded-md"
|
||||
: defaults?.rounded ?? "rounded-md";
|
||||
? (roundedMap[rounded] ?? "rounded-md")
|
||||
: (defaults?.rounded ?? "rounded-md");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`shimmer-skeleton ${resolvedRounded} ${className}`}
|
||||
style={{
|
||||
width:
|
||||
typeof resolvedWidth === "number"
|
||||
? `${resolvedWidth}px`
|
||||
: resolvedWidth,
|
||||
height:
|
||||
typeof resolvedHeight === "number"
|
||||
? `${resolvedHeight}px`
|
||||
: resolvedHeight,
|
||||
width: typeof resolvedWidth === "number" ? `${resolvedWidth}px` : resolvedWidth,
|
||||
height: typeof resolvedHeight === "number" ? `${resolvedHeight}px` : resolvedHeight,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -74,11 +69,7 @@ interface ShimmerGroupProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ShimmerGroup({
|
||||
children,
|
||||
staggerMs = 50,
|
||||
className,
|
||||
}: ShimmerGroupProps) {
|
||||
export function ShimmerGroup({ children, staggerMs = 50, className }: ShimmerGroupProps) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{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";
|
||||
|
||||
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 = {
|
||||
CALENDAR: "Calendar",
|
||||
@@ -75,7 +77,11 @@ function getHolidaySourceLabel(source: string): string {
|
||||
return source;
|
||||
}
|
||||
|
||||
export function VacationModal({ resourceId: initialResourceId, onClose, onSuccess }: VacationModalProps) {
|
||||
export function VacationModal({
|
||||
resourceId: initialResourceId,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: VacationModalProps) {
|
||||
const [resourceId, setResourceId] = useState(initialResourceId ?? "");
|
||||
const [type, setType] = useState<VacationType>(VacationType.ANNUAL);
|
||||
const [startDate, setStartDate] = useState("");
|
||||
@@ -126,17 +132,17 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
||||
},
|
||||
{
|
||||
enabled:
|
||||
!!resourceId
|
||||
&& !!debouncedStart
|
||||
&& !!debouncedEnd
|
||||
&& (!isHalfDay || debouncedStart === debouncedEnd),
|
||||
!!resourceId &&
|
||||
!!debouncedStart &&
|
||||
!!debouncedEnd &&
|
||||
(!isHalfDay || debouncedStart === debouncedEnd),
|
||||
staleTime: 10_000,
|
||||
},
|
||||
);
|
||||
|
||||
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({
|
||||
onSuccess: async () => {
|
||||
await utils.vacation.list.invalidate();
|
||||
@@ -177,14 +183,17 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
||||
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";
|
||||
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 (
|
||||
<AnimatedModal open={true} onClose={onClose} maxWidth="max-w-lg" className="mx-4">
|
||||
<div>
|
||||
{/* Header */}
|
||||
<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
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
@@ -200,7 +209,8 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
||||
{!initialResourceId && (
|
||||
<div>
|
||||
<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>
|
||||
<select
|
||||
id="vac-resource"
|
||||
@@ -222,7 +232,8 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
||||
{/* Type */}
|
||||
<div>
|
||||
<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>
|
||||
<select
|
||||
id="vac-type"
|
||||
@@ -242,7 +253,8 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<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>
|
||||
<DateInput
|
||||
id="vac-start"
|
||||
@@ -254,7 +266,8 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<DateInput
|
||||
id="vac-end"
|
||||
@@ -276,7 +289,8 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
||||
onChange={(e) => setIsHalfDay(e.target.checked)}
|
||||
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>
|
||||
{isHalfDay && (
|
||||
<div className="flex gap-3">
|
||||
@@ -330,7 +344,10 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
||||
return (
|
||||
<li key={v.id}>
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
@@ -374,26 +391,46 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
||||
</div>
|
||||
|
||||
{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>{" "}
|
||||
{buildHolidayBasisLabel(previewQuery.data).join(" / ")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(previewQuery.data.holidayContext.sources.hasCalendarHolidays || 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">
|
||||
{(previewQuery.data.holidayContext.sources.hasCalendarHolidays ||
|
||||
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>{" "}
|
||||
{[
|
||||
previewQuery.data.holidayContext.sources.hasCalendarHolidays ? "Holiday Calendar" : null,
|
||||
previewQuery.data.holidayContext.sources.hasLegacyPublicHolidayEntries ? "Legacy public holiday entries" : null,
|
||||
].filter(Boolean).join(" + ")}
|
||||
previewQuery.data.holidayContext.sources.hasCalendarHolidays
|
||||
? "Holiday Calendar"
|
||||
: null,
|
||||
previewQuery.data.holidayContext.sources.hasLegacyPublicHolidayEntries
|
||||
? "Legacy public holiday entries"
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" + ")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>{" "}
|
||||
{previewQuery.data.holidayDetails.map((holiday) => `${holiday.date} (${getHolidaySourceLabel(holiday.source)})`).join(", ")}
|
||||
{previewQuery.data.holidayDetails
|
||||
.map(
|
||||
(holiday) => `${holiday.date} (${getHolidaySourceLabel(holiday.source)})`,
|
||||
)
|
||||
.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -406,9 +443,7 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
||||
)}
|
||||
|
||||
{previewQuery.error && (
|
||||
<div className="mt-2 text-xs text-red-700">
|
||||
{previewQuery.error.message}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-red-700">{previewQuery.error.message}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import {
|
||||
type DashboardLayoutConfig,
|
||||
type DashboardWidgetType,
|
||||
} from "@capakraken/shared/types";
|
||||
import { type DashboardLayoutConfig, type DashboardWidgetType } from "@capakraken/shared/types";
|
||||
import {
|
||||
createDashboardWidget,
|
||||
createDefaultDashboardLayout,
|
||||
@@ -46,14 +43,15 @@ export function shouldHydrateDashboardFromDb(params: {
|
||||
hasLocalChangesBeforeHydration: boolean;
|
||||
}): boolean {
|
||||
const { remoteLayout, hasHydratedFromDb, hasLocalChangesBeforeHydration } = params;
|
||||
return remoteLayout !== null
|
||||
&& remoteLayout !== undefined
|
||||
&& !hasHydratedFromDb
|
||||
&& !hasLocalChangesBeforeHydration;
|
||||
return (
|
||||
remoteLayout !== null &&
|
||||
remoteLayout !== undefined &&
|
||||
!hasHydratedFromDb &&
|
||||
!hasLocalChangesBeforeHydration
|
||||
);
|
||||
}
|
||||
|
||||
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 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).
|
||||
useEffect(() => {
|
||||
if (!userId || hasHydratedFromStorageRef.current || hasHydratedFromDbRef.current || hasLocalChangesBeforeHydrationRef.current) {
|
||||
if (
|
||||
!userId ||
|
||||
hasHydratedFromStorageRef.current ||
|
||||
hasHydratedFromDbRef.current ||
|
||||
hasLocalChangesBeforeHydrationRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const stored = loadFromStorage(userId);
|
||||
@@ -90,7 +93,6 @@ export function useDashboardLayout() {
|
||||
setIsHydrated(true);
|
||||
}, [userId]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data: dbData } = trpc.user.getDashboardLayout.useQuery(undefined, {
|
||||
staleTime: 30_000,
|
||||
}) as { data: { layout: DashboardLayoutConfig | null; updatedAt: unknown } | null | undefined };
|
||||
@@ -122,11 +124,13 @@ export function useDashboardLayout() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldHydrateDashboardFromDb({
|
||||
remoteLayout,
|
||||
hasHydratedFromDb: hasHydratedFromDbRef.current,
|
||||
hasLocalChangesBeforeHydration: hasLocalChangesBeforeHydrationRef.current,
|
||||
})) {
|
||||
if (
|
||||
!shouldHydrateDashboardFromDb({
|
||||
remoteLayout,
|
||||
hasHydratedFromDb: hasHydratedFromDbRef.current,
|
||||
hasLocalChangesBeforeHydration: hasLocalChangesBeforeHydrationRef.current,
|
||||
})
|
||||
) {
|
||||
// DB data present but local changes already in-flight — keep local state, mark done.
|
||||
hasHydratedFromDbRef.current = true;
|
||||
setIsHydrated(true);
|
||||
@@ -159,69 +163,84 @@ export function useDashboardLayout() {
|
||||
|
||||
// Flush any pending debounced DB save when the component unmounts so that
|
||||
// navigating away within the 2-second window doesn't silently lose changes.
|
||||
useEffect(() => () => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = null;
|
||||
if (pendingLayoutSaveRef.current) {
|
||||
saveMutationRef.current.mutate({ layout: pendingLayoutSaveRef.current });
|
||||
pendingLayoutSaveRef.current = null;
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = null;
|
||||
if (pendingLayoutSaveRef.current) {
|
||||
saveMutationRef.current.mutate({ layout: pendingLayoutSaveRef.current });
|
||||
pendingLayoutSaveRef.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const persist = useCallback((nextConfig: DashboardLayoutConfig) => {
|
||||
if (!hasHydratedFromDbRef.current) {
|
||||
hasLocalChangesBeforeHydrationRef.current = true;
|
||||
}
|
||||
const newConfig = normalizeDashboardLayout(nextConfig);
|
||||
if (userId) saveToStorage(userId, newConfig);
|
||||
pendingLayoutSaveRef.current = newConfig;
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = setTimeout(() => {
|
||||
pendingLayoutSaveRef.current = null;
|
||||
saveMutation.mutate({ layout: newConfig });
|
||||
}, 2000);
|
||||
}, [saveMutation, userId]);
|
||||
const persist = useCallback(
|
||||
(nextConfig: DashboardLayoutConfig) => {
|
||||
if (!hasHydratedFromDbRef.current) {
|
||||
hasLocalChangesBeforeHydrationRef.current = true;
|
||||
}
|
||||
const newConfig = normalizeDashboardLayout(nextConfig);
|
||||
if (userId) saveToStorage(userId, newConfig);
|
||||
pendingLayoutSaveRef.current = newConfig;
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = setTimeout(() => {
|
||||
pendingLayoutSaveRef.current = null;
|
||||
saveMutation.mutate({ layout: newConfig });
|
||||
}, 2000);
|
||||
},
|
||||
[saveMutation, userId],
|
||||
);
|
||||
|
||||
const addWidget = useCallback((type: DashboardWidgetType) => {
|
||||
setConfig((prev) => {
|
||||
const newConfig = {
|
||||
...prev,
|
||||
widgets: [
|
||||
...prev.widgets,
|
||||
createDashboardWidget(type, {
|
||||
id: generateWidgetId(),
|
||||
x: 0,
|
||||
y: getNextDashboardWidgetY(prev.widgets),
|
||||
}),
|
||||
],
|
||||
};
|
||||
persist(newConfig);
|
||||
return newConfig;
|
||||
});
|
||||
}, [persist]);
|
||||
const addWidget = useCallback(
|
||||
(type: DashboardWidgetType) => {
|
||||
setConfig((prev) => {
|
||||
const newConfig = {
|
||||
...prev,
|
||||
widgets: [
|
||||
...prev.widgets,
|
||||
createDashboardWidget(type, {
|
||||
id: generateWidgetId(),
|
||||
x: 0,
|
||||
y: getNextDashboardWidgetY(prev.widgets),
|
||||
}),
|
||||
],
|
||||
};
|
||||
persist(newConfig);
|
||||
return newConfig;
|
||||
});
|
||||
},
|
||||
[persist],
|
||||
);
|
||||
|
||||
const removeWidget = useCallback((id: string) => {
|
||||
setConfig((prev) => {
|
||||
const newConfig = { ...prev, widgets: prev.widgets.filter((w) => w.id !== id) };
|
||||
persist(newConfig);
|
||||
return newConfig;
|
||||
});
|
||||
}, [persist]);
|
||||
const removeWidget = useCallback(
|
||||
(id: string) => {
|
||||
setConfig((prev) => {
|
||||
const newConfig = { ...prev, widgets: prev.widgets.filter((w) => w.id !== id) };
|
||||
persist(newConfig);
|
||||
return newConfig;
|
||||
});
|
||||
},
|
||||
[persist],
|
||||
);
|
||||
|
||||
const updateWidgetConfig = useCallback((id: string, configUpdate: Record<string, unknown>) => {
|
||||
setConfig((prev) => {
|
||||
const newConfig = {
|
||||
...prev,
|
||||
widgets: prev.widgets.map((w) =>
|
||||
w.id === id ? { ...w, config: { ...w.config, ...configUpdate } } : w,
|
||||
),
|
||||
};
|
||||
persist(newConfig);
|
||||
return newConfig;
|
||||
});
|
||||
}, [persist]);
|
||||
const updateWidgetConfig = useCallback(
|
||||
(id: string, configUpdate: Record<string, unknown>) => {
|
||||
setConfig((prev) => {
|
||||
const newConfig = {
|
||||
...prev,
|
||||
widgets: prev.widgets.map((w) =>
|
||||
w.id === id ? { ...w, config: { ...w.config, ...configUpdate } } : w,
|
||||
),
|
||||
};
|
||||
persist(newConfig);
|
||||
return newConfig;
|
||||
});
|
||||
},
|
||||
[persist],
|
||||
);
|
||||
|
||||
const onLayoutChange = useCallback(
|
||||
(layout: { i: string; x: number; y: number; w: number; h: number }[]) => {
|
||||
|
||||
@@ -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.
|
||||
* Returns the project's resources, their own allocations, and all cross-project allocations.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
type ProjectDragContextResult = {
|
||||
contextResourceIds: string[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -18,8 +18,10 @@ type ProjectDragContextResult = {
|
||||
project: any | null;
|
||||
};
|
||||
|
||||
export function useProjectDragContext(projectId: string | null, enabled = true): ProjectDragContextResult {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function useProjectDragContext(
|
||||
projectId: string | null,
|
||||
enabled = true,
|
||||
): ProjectDragContextResult {
|
||||
const { data } = trpc.timeline.getProjectContext.useQuery(
|
||||
{ projectId: projectId! },
|
||||
{ enabled: enabled && !!projectId, staleTime: 10_000 },
|
||||
|
||||
@@ -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";
|
||||
|
||||
interface UseTimelineKeyboardOptions {
|
||||
|
||||
@@ -35,23 +35,29 @@ export function useTimelineLayout(
|
||||
);
|
||||
|
||||
// Grid lines — memoized; identical for every row
|
||||
const gridLines = useMemo(() => dates.map((date, i) => {
|
||||
const isToday = date.toDateString() === today.toDateString();
|
||||
const dow = date.getDay();
|
||||
const isWeekend = dow === 0 || dow === 6;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={clsx(
|
||||
"absolute top-0 bottom-0 border-r",
|
||||
isToday ? "border-brand-300 dark:border-brand-700 border-r-2" :
|
||||
isWeekend ? "border-brand-200 dark:border-brand-800 bg-brand-50/40 dark:bg-brand-950/20" :
|
||||
"border-gray-100 dark:border-gray-800",
|
||||
)}
|
||||
style={{ left: i * CELL_WIDTH, width: CELL_WIDTH }}
|
||||
/>
|
||||
);
|
||||
}), [dates, CELL_WIDTH, today]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const gridLines = useMemo(
|
||||
() =>
|
||||
dates.map((date, i) => {
|
||||
const isToday = date.toDateString() === today.toDateString();
|
||||
const dow = date.getDay();
|
||||
const isWeekend = dow === 0 || dow === 6;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={clsx(
|
||||
"absolute top-0 bottom-0 border-r",
|
||||
isToday
|
||||
? "border-brand-300 dark:border-brand-700 border-r-2"
|
||||
: isWeekend
|
||||
? "border-brand-200 dark:border-brand-800 bg-brand-50/40 dark:bg-brand-950/20"
|
||||
: "border-gray-100 dark:border-gray-800",
|
||||
)}
|
||||
style={{ left: i * CELL_WIDTH, width: CELL_WIDTH }}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
[dates, CELL_WIDTH, today],
|
||||
);
|
||||
|
||||
// Month groups for the month header
|
||||
const monthGroups = useMemo(() => {
|
||||
@@ -72,5 +78,15 @@ export function useTimelineLayout(
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
| { kind: "point"; x: number; y: number }
|
||||
@@ -113,8 +114,8 @@ export function useViewportPopover({
|
||||
return;
|
||||
}
|
||||
if (
|
||||
target instanceof Element
|
||||
&& ignoreSelectors.some((selector) => target.closest(selector) !== null)
|
||||
target instanceof Element &&
|
||||
ignoreSelectors.some((selector) => target.closest(selector) !== null)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -190,9 +191,7 @@ export function useViewportPopover({
|
||||
if (
|
||||
ignoreScrollContainers?.some(
|
||||
(r) =>
|
||||
r.current != null &&
|
||||
scrollTarget instanceof Node &&
|
||||
r.current.contains(scrollTarget),
|
||||
r.current != null && scrollTarget instanceof Node && r.current.contains(scrollTarget),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
|
||||
@@ -70,6 +70,7 @@ services:
|
||||
DATABASE_URL: postgresql://capakraken:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/capakraken
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
|
||||
RATE_LIMIT_BACKEND: ${RATE_LIMIT_BACKEND:-redis}
|
||||
NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN:-}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
+4
-1
@@ -33,11 +33,14 @@
|
||||
"db:import:dispo": "pnpm --filter @capakraken/db db:import:dispo",
|
||||
"db:readiness:demand-assignment": "pnpm --filter @capakraken/db db:readiness:demand-assignment",
|
||||
"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": {
|
||||
"@capakraken/eslint-config": "workspace:*",
|
||||
"@capakraken/tsconfig": "workspace:*",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.4.0",
|
||||
"prettier": "^3.3.3",
|
||||
"turbo": "^2.3.3",
|
||||
"typescript": "^5.6.3"
|
||||
|
||||
Generated
+353
-194
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,8 @@
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.0",
|
||||
"@typescript-eslint/parser": "^8.18.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": {
|
||||
"eslint": "^9.0.0",
|
||||
|
||||
@@ -15,12 +15,21 @@ export default [
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: "error",
|
||||
},
|
||||
rules: {
|
||||
...tsPlugin.configs["recommended"].rules,
|
||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||
"@typescript-eslint/consistent-type-imports": "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,
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import baseConfig from "./base.js";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
|
||||
/** @type {import("eslint").Linter.FlatConfig[]} */
|
||||
export default [
|
||||
...baseConfig,
|
||||
{
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
},
|
||||
rules: {
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"@typescript-eslint/no-misused-promises": [
|
||||
"error",
|
||||
{ checksVoidReturn: { attributes: false } },
|
||||
|
||||
Reference in New Issue
Block a user