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.
|
# If not set, cron endpoints are disabled.
|
||||||
# CRON_SECRET=
|
# CRON_SECRET=
|
||||||
|
|
||||||
|
# ─── Error Tracking (Sentry) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Sentry DSN for client-side and server-side error reporting.
|
||||||
|
# Create a Next.js project at https://sentry.io and copy the DSN here.
|
||||||
|
# If not set, Sentry is disabled (SDK is installed but sends nothing).
|
||||||
|
# NEXT_PUBLIC_SENTRY_DSN=
|
||||||
|
|
||||||
# ─── Testing (never enable in production) ────────────────────────────────────
|
# ─── Testing (never enable in production) ────────────────────────────────────
|
||||||
|
|
||||||
# Disables rate limiting and session tracking during end-to-end tests.
|
# Disables rate limiting and session tracking during end-to-end tests.
|
||||||
|
|||||||
@@ -198,6 +198,20 @@ jobs:
|
|||||||
pnpm --filter @capakraken/shared exec vitest run --coverage
|
pnpm --filter @capakraken/shared exec vitest run --coverage
|
||||||
pnpm --filter @capakraken/db test:unit
|
pnpm --filter @capakraken/db test:unit
|
||||||
|
|
||||||
|
- name: Upload coverage reports
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
|
with:
|
||||||
|
name: coverage-reports
|
||||||
|
path: |
|
||||||
|
apps/web/coverage/
|
||||||
|
packages/engine/coverage/
|
||||||
|
packages/staffing/coverage/
|
||||||
|
packages/api/coverage/
|
||||||
|
packages/application/coverage/
|
||||||
|
packages/shared/coverage/
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
# Build — depends on typecheck passing
|
# Build — depends on typecheck passing
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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",
|
"dev": "next dev -p 3100",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 3100",
|
"start": "next start -p 3100",
|
||||||
"lint": "next lint",
|
"lint": "eslint src/",
|
||||||
"typecheck": "tsc --project tsconfig.typecheck.json --noEmit",
|
"typecheck": "tsc --project tsconfig.typecheck.json --noEmit",
|
||||||
"test:unit": "vitest run",
|
"test:unit": "vitest run",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
@@ -49,6 +49,7 @@
|
|||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@capakraken/eslint-config": "workspace:*",
|
||||||
"@capakraken/tsconfig": "workspace:*",
|
"@capakraken/tsconfig": "workspace:*",
|
||||||
"@playwright/test": "^1.49.1",
|
"@playwright/test": "^1.49.1",
|
||||||
"@types/dompurify": "^3.2.0",
|
"@types/dompurify": "^3.2.0",
|
||||||
@@ -59,6 +60,7 @@
|
|||||||
"@types/three": "^0.183.1",
|
"@types/three": "^0.183.1",
|
||||||
"@vitest/coverage-v8": "^2.1.9",
|
"@vitest/coverage-v8": "^2.1.9",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
"eslint": "^10.2.0",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { useUrlFilters } from "~/hooks/useUrlFilters.js";
|
|||||||
import { useDebounce } from "~/hooks/useDebounce.js";
|
import { useDebounce } from "~/hooks/useDebounce.js";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { formatDate, formatMoney } from "~/lib/format.js";
|
import { formatDate, formatMoney } from "~/lib/format.js";
|
||||||
import type { Project, ColumnDef } from "@capakraken/shared";
|
import type { Project, ColumnDef, ProjectStatus } from "@capakraken/shared";
|
||||||
import { ProjectStatus, PROJECT_COLUMNS, BlueprintTarget } from "@capakraken/shared";
|
import { PROJECT_COLUMNS, BlueprintTarget } from "@capakraken/shared";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
@@ -32,7 +32,10 @@ import { DraggableTableRow } from "~/components/ui/DraggableTableRow.js";
|
|||||||
import { ShoringBadge } from "~/components/projects/ShoringIndicator.js";
|
import { ShoringBadge } from "~/components/projects/ShoringIndicator.js";
|
||||||
import { SuccessToast } from "~/components/ui/SuccessToast.js";
|
import { SuccessToast } from "~/components/ui/SuccessToast.js";
|
||||||
|
|
||||||
import { PROJECT_STATUS_BADGE as STATUS_COLORS, ORDER_TYPE_BADGE as ORDER_TYPE_COLORS } from "~/lib/status-styles.js";
|
import {
|
||||||
|
PROJECT_STATUS_BADGE as STATUS_COLORS,
|
||||||
|
ORDER_TYPE_BADGE as ORDER_TYPE_COLORS,
|
||||||
|
} from "~/lib/status-styles.js";
|
||||||
|
|
||||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -53,7 +56,13 @@ const ALL_ORDER_TYPES = [
|
|||||||
|
|
||||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function BudgetBar({ utilizationPercent, budgetCents }: { utilizationPercent: number; budgetCents: number }) {
|
function BudgetBar({
|
||||||
|
utilizationPercent,
|
||||||
|
budgetCents,
|
||||||
|
}: {
|
||||||
|
utilizationPercent: number;
|
||||||
|
budgetCents: number;
|
||||||
|
}) {
|
||||||
if (budgetCents === 0) {
|
if (budgetCents === 0) {
|
||||||
return <div className="text-xs text-gray-400">No budget</div>;
|
return <div className="text-xs text-gray-400">No budget</div>;
|
||||||
}
|
}
|
||||||
@@ -66,14 +75,27 @@ function BudgetBar({ utilizationPercent, budgetCents }: { utilizationPercent: nu
|
|||||||
return (
|
return (
|
||||||
<div className="min-w-[104px] space-y-1">
|
<div className="min-w-[104px] space-y-1">
|
||||||
<div className="h-2 w-full overflow-hidden rounded-full bg-gray-200/80 dark:bg-gray-700/80">
|
<div className="h-2 w-full overflow-hidden rounded-full bg-gray-200/80 dark:bg-gray-700/80">
|
||||||
<div className={clsx("h-full rounded-full transition-all duration-700 ease-out", barColor)} style={{ width: `${cappedPercent}%` }} />
|
<div
|
||||||
|
className={clsx("h-full rounded-full transition-all duration-700 ease-out", barColor)}
|
||||||
|
style={{ width: `${cappedPercent}%` }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500">{utilizationPercent.toFixed(0)}% used</div>
|
<div className="text-xs text-gray-500">{utilizationPercent.toFixed(0)}% used</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: ProjectRow; isOpen: boolean; onOpen: () => void; onClose: () => void }) {
|
function StatusDropdown({
|
||||||
|
project,
|
||||||
|
isOpen,
|
||||||
|
onOpen,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
project: ProjectRow;
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpen: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -110,7 +132,10 @@ function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: Project
|
|||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => { e.stopPropagation(); isOpen ? onClose() : onOpen(); }}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
isOpen ? onClose() : onOpen();
|
||||||
|
}}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"inline-flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium transition",
|
"inline-flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium transition",
|
||||||
STATUS_COLORS[project.status] ?? "bg-gray-100 text-gray-700",
|
STATUS_COLORS[project.status] ?? "bg-gray-100 text-gray-700",
|
||||||
@@ -118,40 +143,54 @@ function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: Project
|
|||||||
title="Click to change status"
|
title="Click to change status"
|
||||||
>
|
>
|
||||||
{project.status}
|
{project.status}
|
||||||
<svg className="w-2.5 h-2.5 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
|
className="w-2.5 h-2.5 opacity-60"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{isOpen && createPortal(
|
{isOpen &&
|
||||||
<motion.div
|
createPortal(
|
||||||
ref={panelRef}
|
<motion.div
|
||||||
initial={{ opacity: 0, scaleY: 0.9 }}
|
ref={panelRef}
|
||||||
animate={{ opacity: 1, scaleY: 1 }}
|
initial={{ opacity: 0, scaleY: 0.9 }}
|
||||||
transition={{ duration: 0.12, ease: "easeOut" }}
|
animate={{ opacity: 1, scaleY: 1 }}
|
||||||
className="fixed z-[9999] min-w-[160px] rounded-2xl border border-gray-200 bg-white p-2 shadow-xl dark:border-gray-700 dark:bg-gray-900 origin-top"
|
transition={{ duration: 0.12, ease: "easeOut" }}
|
||||||
style={{ top: pos.top, left: pos.left }}
|
className="fixed z-[9999] min-w-[160px] rounded-2xl border border-gray-200 bg-white p-2 shadow-xl dark:border-gray-700 dark:bg-gray-900 origin-top"
|
||||||
>
|
style={{ top: pos.top, left: pos.left }}
|
||||||
{ALL_STATUSES.map((s) => (
|
>
|
||||||
<button
|
{ALL_STATUSES.map((s) => (
|
||||||
key={s.value}
|
<button
|
||||||
type="button"
|
key={s.value}
|
||||||
disabled={s.value === project.status || updateStatus.isPending}
|
type="button"
|
||||||
onClick={(e) => { e.stopPropagation(); updateStatus.mutate({ id: project.id, status: s.value as never }); }}
|
disabled={s.value === project.status || updateStatus.isPending}
|
||||||
className={clsx(
|
onClick={(e) => {
|
||||||
"w-full rounded-xl px-3 py-2 text-left text-xs transition",
|
e.stopPropagation();
|
||||||
s.value === project.status
|
updateStatus.mutate({ id: project.id, status: s.value as never });
|
||||||
? "cursor-default font-semibold text-gray-400"
|
}}
|
||||||
: "cursor-pointer text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800",
|
className={clsx(
|
||||||
)}
|
"w-full rounded-xl px-3 py-2 text-left text-xs transition",
|
||||||
>
|
s.value === project.status
|
||||||
<span className={clsx("inline-block px-1.5 py-0.5 rounded-full", STATUS_COLORS[s.value] ?? "bg-gray-100 text-gray-700")}>
|
? "cursor-default font-semibold text-gray-400"
|
||||||
{s.label}
|
: "cursor-pointer text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800",
|
||||||
</span>
|
)}
|
||||||
</button>
|
>
|
||||||
))}
|
<span
|
||||||
</motion.div>,
|
className={clsx(
|
||||||
document.body,
|
"inline-block px-1.5 py-0.5 rounded-full",
|
||||||
)}
|
STATUS_COLORS[s.value] ?? "bg-gray-100 text-gray-700",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</motion.div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -190,13 +229,12 @@ export function ProjectsClient() {
|
|||||||
// Flush debounced input to URL
|
// Flush debounced input to URL
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFilters({ search: debouncedSearch });
|
setFilters({ search: debouncedSearch });
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [debouncedSearch]);
|
}, [debouncedSearch]);
|
||||||
|
|
||||||
// Keep local input in sync when URL changes externally (e.g. back/forward)
|
// Keep local input in sync when URL changes externally (e.g. back/forward)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSearchInput(filters.search);
|
setSearchInput(filters.search);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [filters.search]);
|
}, [filters.search]);
|
||||||
|
|
||||||
const setStatusFilter = useCallback((v: string) => setFilters({ status: v }), [setFilters]);
|
const setStatusFilter = useCallback((v: string) => setFilters({ status: v }), [setFilters]);
|
||||||
@@ -207,7 +245,10 @@ export function ProjectsClient() {
|
|||||||
const [successToast, setSuccessToast] = useState<string | null>(null);
|
const [successToast, setSuccessToast] = useState<string | null>(null);
|
||||||
const [openStatusProjectId, setOpenStatusProjectId] = useState<string | null>(null);
|
const [openStatusProjectId, setOpenStatusProjectId] = useState<string | null>(null);
|
||||||
const [batchStatusPicker, setBatchStatusPicker] = useState(false);
|
const [batchStatusPicker, setBatchStatusPicker] = useState(false);
|
||||||
const [confirmBatchStatus, setConfirmBatchStatus] = useState<{ ids: string[]; status: string } | null>(null);
|
const [confirmBatchStatus, setConfirmBatchStatus] = useState<{
|
||||||
|
ids: string[];
|
||||||
|
status: string;
|
||||||
|
} | null>(null);
|
||||||
const [confirmBatchDelete, setConfirmBatchDelete] = useState<string[] | null>(null);
|
const [confirmBatchDelete, setConfirmBatchDelete] = useState<string[] | null>(null);
|
||||||
|
|
||||||
const selection = useSelection();
|
const selection = useSelection();
|
||||||
@@ -229,7 +270,9 @@ export function ProjectsClient() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─── Favorites ──────────────────────────────────────────────────────────
|
// ─── Favorites ──────────────────────────────────────────────────────────
|
||||||
const { data: favoriteIds } = trpc.user.getFavoriteProjectIds.useQuery(undefined, { staleTime: 30_000 });
|
const { data: favoriteIds } = trpc.user.getFavoriteProjectIds.useQuery(undefined, {
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
const favSet = useMemo(() => new Set(favoriteIds ?? []), [favoriteIds]);
|
const favSet = useMemo(() => new Set(favoriteIds ?? []), [favoriteIds]);
|
||||||
const toggleFavMutation = trpc.user.toggleFavoriteProject.useMutation({
|
const toggleFavMutation = trpc.user.toggleFavoriteProject.useMutation({
|
||||||
onMutate: async ({ projectId }) => {
|
onMutate: async ({ projectId }) => {
|
||||||
@@ -300,7 +343,7 @@ export function ProjectsClient() {
|
|||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
// Keep this boundary shallow; full TRPC inference here can trip TS depth limits.
|
// Keep this boundary shallow; full TRPC inference here can trip TS depth limits.
|
||||||
} = (trpc.project.listWithCosts.useInfiniteQuery as any)(
|
} = (trpc.project.listWithCosts.useInfiniteQuery as any)(
|
||||||
{
|
{
|
||||||
search: search || undefined,
|
search: search || undefined,
|
||||||
@@ -308,9 +351,12 @@ export function ProjectsClient() {
|
|||||||
limit: 50,
|
limit: 50,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
getNextPageParam: (lastPage: { nextCursor?: string | null }) => lastPage.nextCursor ?? undefined,
|
getNextPageParam: (lastPage: { nextCursor?: string | null }) =>
|
||||||
|
lastPage.nextCursor ?? undefined,
|
||||||
initialCursor: undefined,
|
initialCursor: undefined,
|
||||||
placeholderData: (prev: { pages: { projects: ProjectRow[]; nextCursor?: string | null }[] } | undefined) => prev,
|
placeholderData: (
|
||||||
|
prev: { pages: { projects: ProjectRow[]; nextCursor?: string | null }[] } | undefined,
|
||||||
|
) => prev,
|
||||||
staleTime: 15_000,
|
staleTime: 15_000,
|
||||||
},
|
},
|
||||||
) as {
|
) as {
|
||||||
@@ -332,7 +378,8 @@ export function ProjectsClient() {
|
|||||||
|
|
||||||
// Client-side orderType filter
|
// Client-side orderType filter
|
||||||
const filteredProjects = useMemo(
|
const filteredProjects = useMemo(
|
||||||
() => (orderTypeFilter ? allProjects.filter((p) => p.orderType === orderTypeFilter) : allProjects),
|
() =>
|
||||||
|
orderTypeFilter ? allProjects.filter((p) => p.orderType === orderTypeFilter) : allProjects,
|
||||||
[allProjects, orderTypeFilter],
|
[allProjects, orderTypeFilter],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -345,29 +392,41 @@ export function ProjectsClient() {
|
|||||||
viewPrefs.setSavedSort(field && dir ? { field, dir } : null);
|
viewPrefs.setSavedSort(field && dir ? { field, dir } : null);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { orderedRows: projects, reorder, isCustomOrder, resetOrder } = useRowOrder(
|
const {
|
||||||
sorted,
|
orderedRows: projects,
|
||||||
viewPrefs,
|
reorder,
|
||||||
sortField,
|
isCustomOrder,
|
||||||
reset,
|
resetOrder,
|
||||||
);
|
} = useRowOrder(sorted, viewPrefs, sortField, reset);
|
||||||
const rowDragRef = useRef<string | null>(null);
|
const rowDragRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const projectIds = projects.map((p) => p.id);
|
const projectIds = projects.map((p) => p.id);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
selection.clear();
|
selection.clear();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [search, statusFilter, orderTypeFilter]);
|
}, [search, statusFilter, orderTypeFilter]);
|
||||||
|
|
||||||
const handleFetchNext = useCallback(() => {
|
const handleFetchNext = useCallback(() => {
|
||||||
if (hasNextPage && !isFetchingNextPage) void fetchNextPage();
|
if (hasNextPage && !isFetchingNextPage) void fetchNextPage();
|
||||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
function openNewModal() { setEditingProject(null); setModalOpen(true); }
|
function openNewModal() {
|
||||||
function openEditModal(project: Project) { setEditingProject(project); setModalOpen(true); }
|
setEditingProject(null);
|
||||||
function closeModal() { setModalOpen(false); setEditingProject(null); }
|
setModalOpen(true);
|
||||||
function clearAll() { setSearchInput(""); setFilters({ search: "", status: "", orderType: "" }); }
|
}
|
||||||
|
function openEditModal(project: Project) {
|
||||||
|
setEditingProject(project);
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
function closeModal() {
|
||||||
|
setModalOpen(false);
|
||||||
|
setEditingProject(null);
|
||||||
|
}
|
||||||
|
function clearAll() {
|
||||||
|
setSearchInput("");
|
||||||
|
setFilters({ search: "", status: "", orderType: "" });
|
||||||
|
}
|
||||||
|
|
||||||
const exportSelectedCsv = useCallback(() => {
|
const exportSelectedCsv = useCallback(() => {
|
||||||
const selected = projects.filter((p) => selection.selectedIds.has(p.id));
|
const selected = projects.filter((p) => selection.selectedIds.has(p.id));
|
||||||
@@ -389,9 +448,23 @@ export function ProjectsClient() {
|
|||||||
}, [projects, selection.selectedIds]);
|
}, [projects, selection.selectedIds]);
|
||||||
|
|
||||||
const chips = [
|
const chips = [
|
||||||
...(search ? [{ label: `Search: "${search}"`, onRemove: () => { setSearchInput(""); setFilters({ search: "" }); } }] : []),
|
...(search
|
||||||
...(statusFilter ? [{ label: `Status: ${statusFilter}`, onRemove: () => setStatusFilter("") }] : []),
|
? [
|
||||||
...(orderTypeFilter ? [{ label: `Type: ${orderTypeFilter}`, onRemove: () => setOrderTypeFilter("") }] : []),
|
{
|
||||||
|
label: `Search: "${search}"`,
|
||||||
|
onRemove: () => {
|
||||||
|
setSearchInput("");
|
||||||
|
setFilters({ search: "" });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(statusFilter
|
||||||
|
? [{ label: `Status: ${statusFilter}`, onRemove: () => setStatusFilter("") }]
|
||||||
|
: []),
|
||||||
|
...(orderTypeFilter
|
||||||
|
? [{ label: `Type: ${orderTypeFilter}`, onRemove: () => setOrderTypeFilter("") }]
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── Cell renderer ────────────────────────────────────────────────────────
|
// ─── Cell renderer ────────────────────────────────────────────────────────
|
||||||
@@ -401,18 +474,42 @@ export function ProjectsClient() {
|
|||||||
if (col.isCustom) {
|
if (col.isCustom) {
|
||||||
const fieldKey = col.key.replace(/^custom_/, "");
|
const fieldKey = col.key.replace(/^custom_/, "");
|
||||||
const val = dynFields[fieldKey];
|
const val = dynFields[fieldKey];
|
||||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">{val != null ? String(val) : "—"}</td>;
|
return (
|
||||||
|
<td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{val != null ? String(val) : "—"}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (col.key) {
|
switch (col.key) {
|
||||||
case "shortCode":
|
case "shortCode":
|
||||||
return <td key={col.key} className="px-4 py-3 text-sm font-mono font-medium text-gray-900 dark:text-gray-100">{project.shortCode}</td>;
|
return (
|
||||||
|
<td
|
||||||
|
key={col.key}
|
||||||
|
className="px-4 py-3 text-sm font-mono font-medium text-gray-900 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
{project.shortCode}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
case "name":
|
case "name":
|
||||||
return (
|
return (
|
||||||
<td key={col.key} className="max-w-xs truncate px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">
|
<td
|
||||||
<Link href={`/projects/${project.id}`} className="inline-flex items-center gap-2 transition hover:text-brand-600 hover:underline">
|
key={col.key}
|
||||||
|
className="max-w-xs truncate px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/projects/${project.id}`}
|
||||||
|
className="inline-flex items-center gap-2 transition hover:text-brand-600 hover:underline"
|
||||||
|
>
|
||||||
{project.coverImageUrl ? (
|
{project.coverImageUrl ? (
|
||||||
<Image src={project.coverImageUrl} alt={project.name} width={24} height={24} className="h-6 w-6 flex-shrink-0 rounded object-cover" unoptimized={project.coverImageUrl.startsWith("data:")} />
|
<Image
|
||||||
|
src={project.coverImageUrl}
|
||||||
|
alt={project.name}
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className="h-6 w-6 flex-shrink-0 rounded object-cover"
|
||||||
|
unoptimized={project.coverImageUrl.startsWith("data:")}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded text-[9px] font-bold opacity-60"
|
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded text-[9px] font-bold opacity-60"
|
||||||
@@ -421,7 +518,13 @@ export function ProjectsClient() {
|
|||||||
color: project.color ?? "#6366f1",
|
color: project.color ?? "#6366f1",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{project.name.split(/\s+/).map((w) => w[0]).filter(Boolean).slice(0, 2).join("").toUpperCase()}
|
{project.name
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((w) => w[0])
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.join("")
|
||||||
|
.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="truncate">{project.name}</span>
|
<span className="truncate">{project.name}</span>
|
||||||
@@ -442,14 +545,19 @@ export function ProjectsClient() {
|
|||||||
case "orderType":
|
case "orderType":
|
||||||
return (
|
return (
|
||||||
<td key={col.key} className="px-4 py-3">
|
<td key={col.key} className="px-4 py-3">
|
||||||
<span className={`inline-block px-2 py-0.5 text-xs rounded-full ${ORDER_TYPE_COLORS[project.orderType] ?? ""}`}>
|
<span
|
||||||
|
className={`inline-block px-2 py-0.5 text-xs rounded-full ${ORDER_TYPE_COLORS[project.orderType] ?? ""}`}
|
||||||
|
>
|
||||||
{project.orderType}
|
{project.orderType}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
case "dates":
|
case "dates":
|
||||||
return (
|
return (
|
||||||
<td key={col.key} className="whitespace-nowrap px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
|
<td
|
||||||
|
key={col.key}
|
||||||
|
className="whitespace-nowrap px-4 py-3 text-xs text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
{formatDate(project.startDate)} – {formatDate(project.endDate)}
|
{formatDate(project.startDate)} – {formatDate(project.endDate)}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
@@ -459,7 +567,10 @@ export function ProjectsClient() {
|
|||||||
<div className="mb-0.5 text-sm text-gray-900 dark:text-gray-100">
|
<div className="mb-0.5 text-sm text-gray-900 dark:text-gray-100">
|
||||||
{formatMoney(project.budgetCents)}
|
{formatMoney(project.budgetCents)}
|
||||||
</div>
|
</div>
|
||||||
<BudgetBar utilizationPercent={project.utilizationPercent ?? 0} budgetCents={project.budgetCents} />
|
<BudgetBar
|
||||||
|
utilizationPercent={project.utilizationPercent ?? 0}
|
||||||
|
budgetCents={project.budgetCents}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
case "allocations":
|
case "allocations":
|
||||||
@@ -467,8 +578,18 @@ export function ProjectsClient() {
|
|||||||
<td key={col.key} className="px-4 py-3 text-right text-sm">
|
<td key={col.key} className="px-4 py-3 text-right text-sm">
|
||||||
{project.totalPersonDays > 0 ? (
|
{project.totalPersonDays > 0 ? (
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-700 dark:bg-brand-900/30 dark:text-brand-300">
|
<span className="inline-flex items-center gap-1 rounded-full bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-700 dark:bg-brand-900/30 dark:text-brand-300">
|
||||||
<svg className="h-3 w-3 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
className="h-3 w-3 opacity-60"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{project.totalPersonDays}d
|
{project.totalPersonDays}d
|
||||||
</span>
|
</span>
|
||||||
@@ -484,14 +605,30 @@ export function ProjectsClient() {
|
|||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
case "responsible":
|
case "responsible":
|
||||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">—</td>;
|
return (
|
||||||
|
<td key={col.key} className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
—
|
||||||
|
</td>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">—</td>;
|
return (
|
||||||
|
<td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
—
|
||||||
|
</td>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Header renderer ──────────────────────────────────────────────────────
|
// ─── Header renderer ──────────────────────────────────────────────────────
|
||||||
const SORTABLE_PROJECT_COLS = new Set(["shortCode", "name", "status", "orderType", "dates", "budget", "allocations"]);
|
const SORTABLE_PROJECT_COLS = new Set([
|
||||||
|
"shortCode",
|
||||||
|
"name",
|
||||||
|
"status",
|
||||||
|
"orderType",
|
||||||
|
"dates",
|
||||||
|
"budget",
|
||||||
|
"allocations",
|
||||||
|
]);
|
||||||
function renderHeader(col: ColumnDef) {
|
function renderHeader(col: ColumnDef) {
|
||||||
if (SORTABLE_PROJECT_COLS.has(col.key)) {
|
if (SORTABLE_PROJECT_COLS.has(col.key)) {
|
||||||
return (
|
return (
|
||||||
@@ -506,7 +643,10 @@ export function ProjectsClient() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<th key={col.key} className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
<th
|
||||||
|
key={col.key}
|
||||||
|
className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||||
|
>
|
||||||
{col.label}
|
{col.label}
|
||||||
</th>
|
</th>
|
||||||
);
|
);
|
||||||
@@ -531,7 +671,12 @@ export function ProjectsClient() {
|
|||||||
className="inline-flex items-center gap-2 rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-brand-700"
|
className="inline-flex items-center gap-2 rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-brand-700"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
New Project Wizard
|
New Project Wizard
|
||||||
</button>
|
</button>
|
||||||
@@ -541,7 +686,12 @@ export function ProjectsClient() {
|
|||||||
className="inline-flex items-center gap-2 rounded-xl border border-gray-300 bg-white px-4 py-2.5 text-sm font-medium text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
|
className="inline-flex items-center gap-2 rounded-xl border border-gray-300 bg-white px-4 py-2.5 text-sm font-medium text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 4v16m8-8H4"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Quick Add
|
Quick Add
|
||||||
</button>
|
</button>
|
||||||
@@ -563,7 +713,9 @@ export function ProjectsClient() {
|
|||||||
>
|
>
|
||||||
<option value="">All Statuses</option>
|
<option value="">All Statuses</option>
|
||||||
{ALL_STATUSES.map((s) => (
|
{ALL_STATUSES.map((s) => (
|
||||||
<option key={s.value} value={s.value}>{s.label}</option>
|
<option key={s.value} value={s.value}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
@@ -573,7 +725,9 @@ export function ProjectsClient() {
|
|||||||
>
|
>
|
||||||
<option value="">All Types</option>
|
<option value="">All Types</option>
|
||||||
{ALL_ORDER_TYPES.map((t) => (
|
{ALL_ORDER_TYPES.map((t) => (
|
||||||
<option key={t.value} value={t.value}>{t.label}</option>
|
<option key={t.value} value={t.value}>
|
||||||
|
{t.label}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<ColumnTogglePanel
|
<ColumnTogglePanel
|
||||||
@@ -602,7 +756,9 @@ export function ProjectsClient() {
|
|||||||
|
|
||||||
<div className="app-data-table">
|
<div className="app-data-table">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="py-16 text-center text-sm text-gray-500 shimmer-skeleton">Loading projects…</div>
|
<div className="py-16 text-center text-sm text-gray-500 shimmer-skeleton">
|
||||||
|
Loading projects…
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -644,9 +800,15 @@ export function ProjectsClient() {
|
|||||||
<td className="px-2 py-3 w-8">
|
<td className="px-2 py-3 w-8">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => { e.stopPropagation(); e.preventDefault(); toggleFavMutation.mutate({ projectId: project.id }); }}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
toggleFavMutation.mutate({ projectId: project.id });
|
||||||
|
}}
|
||||||
className={`text-sm transition-colors ${favSet.has(project.id) ? "text-amber-500 hover:text-amber-600" : "text-gray-300 hover:text-amber-400 dark:text-gray-600 dark:hover:text-amber-500"}`}
|
className={`text-sm transition-colors ${favSet.has(project.id) ? "text-amber-500 hover:text-amber-600" : "text-gray-300 hover:text-amber-400 dark:text-gray-600 dark:hover:text-amber-500"}`}
|
||||||
title={favSet.has(project.id) ? "Remove from favorites" : "Add to favorites"}
|
title={
|
||||||
|
favSet.has(project.id) ? "Remove from favorites" : "Add to favorites"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{favSet.has(project.id) ? "★" : "☆"}
|
{favSet.has(project.id) ? "★" : "☆"}
|
||||||
</button>
|
</button>
|
||||||
@@ -669,7 +831,10 @@ export function ProjectsClient() {
|
|||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<Link href={`/projects/${project.id}`} className="app-action-edit link-hover-underline">
|
<Link
|
||||||
|
href={`/projects/${project.id}`}
|
||||||
|
className="app-action-edit link-hover-underline"
|
||||||
|
>
|
||||||
View →
|
View →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -684,25 +849,34 @@ export function ProjectsClient() {
|
|||||||
{projects.length === 0 && (
|
{projects.length === 0 && (
|
||||||
<div className="py-14 text-center text-sm text-gray-500">
|
<div className="py-14 text-center text-sm text-gray-500">
|
||||||
No projects found.{" "}
|
No projects found.{" "}
|
||||||
<button type="button" onClick={openNewModal} className="text-brand-600 hover:underline font-medium">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openNewModal}
|
||||||
|
className="text-brand-600 hover:underline font-medium"
|
||||||
|
>
|
||||||
Create your first project.
|
Create your first project.
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<InfiniteScrollSentinel
|
<InfiniteScrollSentinel onVisible={handleFetchNext} isLoading={isFetchingNextPage} />
|
||||||
onVisible={handleFetchNext}
|
|
||||||
isLoading={isFetchingNextPage}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Batch Status Picker */}
|
{/* Batch Status Picker */}
|
||||||
{batchStatusPicker && (
|
{batchStatusPicker && (
|
||||||
<div className="fixed inset-0 bg-black/30 z-50 flex items-center justify-center p-4" onClick={() => setBatchStatusPicker(false)}>
|
<div
|
||||||
<div className="min-w-[220px] rounded-2xl bg-white p-5 shadow-2xl dark:bg-gray-900" onClick={(e) => e.stopPropagation()}>
|
className="fixed inset-0 bg-black/30 z-50 flex items-center justify-center p-4"
|
||||||
<h3 className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">Set status for {selection.count} projects</h3>
|
onClick={() => setBatchStatusPicker(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="min-w-[220px] rounded-2xl bg-white p-5 shadow-2xl dark:bg-gray-900"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Set status for {selection.count} projects
|
||||||
|
</h3>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{ALL_STATUSES.map((s) => (
|
{ALL_STATUSES.map((s) => (
|
||||||
<button
|
<button
|
||||||
@@ -714,7 +888,12 @@ export function ProjectsClient() {
|
|||||||
}}
|
}}
|
||||||
className="w-full rounded-xl px-3 py-2 text-left text-sm transition-colors hover:bg-gray-50 dark:hover:bg-gray-800"
|
className="w-full rounded-xl px-3 py-2 text-left text-sm transition-colors hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||||
>
|
>
|
||||||
<span className={clsx("inline-block px-2 py-0.5 text-xs rounded-full", STATUS_COLORS[s.value])}>
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"inline-block px-2 py-0.5 text-xs rounded-full",
|
||||||
|
STATUS_COLORS[s.value],
|
||||||
|
)}
|
||||||
|
>
|
||||||
{s.label}
|
{s.label}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -732,7 +911,10 @@ export function ProjectsClient() {
|
|||||||
confirmLabel="Update"
|
confirmLabel="Update"
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
if (confirmBatchStatus) {
|
if (confirmBatchStatus) {
|
||||||
batchUpdateStatus.mutate({ ids: confirmBatchStatus.ids, status: confirmBatchStatus.status as never });
|
batchUpdateStatus.mutate({
|
||||||
|
ids: confirmBatchStatus.ids,
|
||||||
|
status: confirmBatchStatus.status as never,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
setConfirmBatchStatus(null);
|
setConfirmBatchStatus(null);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export function ResourcesClient() {
|
|||||||
const departedFilter = resourceUrlFilters.departed as BooleanFilter;
|
const departedFilter = resourceUrlFilters.departed as BooleanFilter;
|
||||||
// chapters stored as comma-separated string; empty string means "all chapters visible"
|
// chapters stored as comma-separated string; empty string means "all chapters visible"
|
||||||
const chaptersParam = resourceUrlFilters.chapters;
|
const chaptersParam = resourceUrlFilters.chapters;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
const chapterFilter: string[] = useMemo(
|
const chapterFilter: string[] = useMemo(
|
||||||
() => (chaptersParam ? chaptersParam.split(",").filter(Boolean) : []),
|
() => (chaptersParam ? chaptersParam.split(",").filter(Boolean) : []),
|
||||||
[chaptersParam],
|
[chaptersParam],
|
||||||
@@ -175,21 +175,32 @@ export function ResourcesClient() {
|
|||||||
// Flush debounced search input to URL
|
// Flush debounced search input to URL
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setResourceUrlFilters({ search: debouncedSearch });
|
setResourceUrlFilters({ search: debouncedSearch });
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [debouncedSearch]);
|
}, [debouncedSearch]);
|
||||||
|
|
||||||
// Keep local search input in sync when URL changes externally
|
// Keep local search input in sync when URL changes externally
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSearchInput(resourceUrlFilters.search);
|
setSearchInput(resourceUrlFilters.search);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [resourceUrlFilters.search]);
|
}, [resourceUrlFilters.search]);
|
||||||
|
|
||||||
const setIsActiveFilter = useCallback((v: ActiveFilter) => setResourceUrlFilters({ activeFilter: v }), [setResourceUrlFilters]);
|
const setIsActiveFilter = useCallback(
|
||||||
const setRolledOffFilter = useCallback((v: BooleanFilter) => setResourceUrlFilters({ rolledOff: v }), [setResourceUrlFilters]);
|
(v: ActiveFilter) => setResourceUrlFilters({ activeFilter: v }),
|
||||||
const setDepartedFilter = useCallback((v: BooleanFilter) => setResourceUrlFilters({ departed: v }), [setResourceUrlFilters]);
|
[setResourceUrlFilters],
|
||||||
const setChapterFilter = useCallback((v: string[]) => {
|
);
|
||||||
setResourceUrlFilters({ chapters: v.join(",") });
|
const setRolledOffFilter = useCallback(
|
||||||
}, [setResourceUrlFilters]);
|
(v: BooleanFilter) => setResourceUrlFilters({ rolledOff: v }),
|
||||||
|
[setResourceUrlFilters],
|
||||||
|
);
|
||||||
|
const setDepartedFilter = useCallback(
|
||||||
|
(v: BooleanFilter) => setResourceUrlFilters({ departed: v }),
|
||||||
|
[setResourceUrlFilters],
|
||||||
|
);
|
||||||
|
const setChapterFilter = useCallback(
|
||||||
|
(v: string[]) => {
|
||||||
|
setResourceUrlFilters({ chapters: v.join(",") });
|
||||||
|
},
|
||||||
|
[setResourceUrlFilters],
|
||||||
|
);
|
||||||
|
|
||||||
const [includeProposedChargeability, setIncludeProposedChargeability] = useState(false);
|
const [includeProposedChargeability, setIncludeProposedChargeability] = useState(false);
|
||||||
const [hiddenCountryIds, setHiddenCountryIds] = useState<string[]>([]);
|
const [hiddenCountryIds, setHiddenCountryIds] = useState<string[]>([]);
|
||||||
@@ -412,7 +423,13 @@ export function ResourcesClient() {
|
|||||||
|
|
||||||
function clearAll() {
|
function clearAll() {
|
||||||
setSearchInput("");
|
setSearchInput("");
|
||||||
setResourceUrlFilters({ search: "", activeFilter: "active", rolledOff: DEFAULT_BOOLEAN_FILTER, departed: DEFAULT_BOOLEAN_FILTER, chapters: "" });
|
setResourceUrlFilters({
|
||||||
|
search: "",
|
||||||
|
activeFilter: "active",
|
||||||
|
rolledOff: DEFAULT_BOOLEAN_FILTER,
|
||||||
|
departed: DEFAULT_BOOLEAN_FILTER,
|
||||||
|
chapters: "",
|
||||||
|
});
|
||||||
setHiddenCountryIds([]);
|
setHiddenCountryIds([]);
|
||||||
setIncludeWithoutCountry(true);
|
setIncludeWithoutCountry(true);
|
||||||
setHiddenResourceTypes([...DEFAULT_HIDDEN_RESOURCE_TYPES]);
|
setHiddenResourceTypes([...DEFAULT_HIDDEN_RESOURCE_TYPES]);
|
||||||
@@ -468,7 +485,9 @@ export function ResourcesClient() {
|
|||||||
if (next.length === chapters.length) {
|
if (next.length === chapters.length) {
|
||||||
setChapterFilter([]);
|
setChapterFilter([]);
|
||||||
} else {
|
} else {
|
||||||
setChapterFilter(next.sort((left, right) => chapters.indexOf(left) - chapters.indexOf(right)));
|
setChapterFilter(
|
||||||
|
next.sort((left, right) => chapters.indexOf(left) - chapters.indexOf(right)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[chapters, chapterFilter, setChapterFilter],
|
[chapters, chapterFilter, setChapterFilter],
|
||||||
@@ -533,13 +552,23 @@ export function ResourcesClient() {
|
|||||||
{ header: "LCR (cents)", accessor: (r) => r.lcrCents },
|
{ header: "LCR (cents)", accessor: (r) => r.lcrCents },
|
||||||
{ header: "Currency", accessor: (r) => r.currency },
|
{ header: "Currency", accessor: (r) => r.currency },
|
||||||
{ header: "Chargeability Target", accessor: (r) => r.chargeabilityTarget },
|
{ header: "Chargeability Target", accessor: (r) => r.chargeabilityTarget },
|
||||||
{ header: "Active", accessor: (r) => r.isActive ? "Yes" : "No" },
|
{ header: "Active", accessor: (r) => (r.isActive ? "Yes" : "No") },
|
||||||
]);
|
]);
|
||||||
downloadCsv(csv, `resources-export-${new Date().toISOString().slice(0, 10)}.csv`);
|
downloadCsv(csv, `resources-export-${new Date().toISOString().slice(0, 10)}.csv`);
|
||||||
}, [displayedResources, selection.selectedIds]);
|
}, [displayedResources, selection.selectedIds]);
|
||||||
|
|
||||||
const chips = [
|
const chips = [
|
||||||
...(search ? [{ label: `Search: "${search}"`, onRemove: () => { setSearchInput(""); setResourceUrlFilters({ search: "" }); } }] : []),
|
...(search
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: `Search: "${search}"`,
|
||||||
|
onRemove: () => {
|
||||||
|
setSearchInput("");
|
||||||
|
setResourceUrlFilters({ search: "" });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
...(chapterFilter.length > 0
|
...(chapterFilter.length > 0
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -1303,7 +1332,12 @@ export function ResourcesClient() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{isOverflow && (
|
{isOverflow && (
|
||||||
<span className="text-[9px] font-bold text-green-600 dark:text-green-400" title={`${actual}% actual`}>+</span>
|
<span
|
||||||
|
className="text-[9px] font-bold text-green-600 dark:text-green-400"
|
||||||
|
title={`${actual}% actual`}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -115,7 +115,6 @@ export async function GET(request: Request) {
|
|||||||
)
|
)
|
||||||
.join("; ");
|
.join("; ");
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
await createNotificationsForUsers({
|
await createNotificationsForUsers({
|
||||||
db: prisma as any,
|
db: prisma as any,
|
||||||
userIds: adminUsers.map((u) => u.id),
|
userIds: adminUsers.map((u) => u.id),
|
||||||
@@ -128,7 +127,10 @@ export async function GET(request: Request) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ anomalies: report.anomalies, window: { start: report.windowStartedAt, end: report.windowEndedAt } },
|
{
|
||||||
|
anomalies: report.anomalies,
|
||||||
|
window: { start: report.windowStartedAt, end: report.windowEndedAt },
|
||||||
|
},
|
||||||
"Auth anomaly cron: anomalies detected and admins notified",
|
"Auth anomaly cron: anomalies detected and admins notified",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -140,9 +142,6 @@ export async function GET(request: Request) {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error, route: "/api/cron/auth-anomaly-check" }, "Auth anomaly cron failed");
|
logger.error({ error, route: "/api/cron/auth-anomaly-check" }, "Auth anomaly cron failed");
|
||||||
return NextResponse.json(
|
return NextResponse.json({ ok: false, error: "Internal error" }, { status: 500 });
|
||||||
{ ok: false, error: "Internal error" },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,10 +73,7 @@ export async function GET(request: Request) {
|
|||||||
if (deny) return deny;
|
if (deny) return deny;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [postgres, redis] = await Promise.all([
|
const [postgres, redis] = await Promise.all([checkPostgres(), checkRedis()]);
|
||||||
checkPostgres(),
|
|
||||||
checkRedis(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const allHealthy = postgres.status === "ok" && redis.status === "ok";
|
const allHealthy = postgres.status === "ok" && redis.status === "ok";
|
||||||
|
|
||||||
@@ -92,7 +89,6 @@ export async function GET(request: Request) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (adminUsers.length > 0) {
|
if (adminUsers.length > 0) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
await createNotificationsForUsers({
|
await createNotificationsForUsers({
|
||||||
db: prisma as any,
|
db: prisma as any,
|
||||||
userIds: adminUsers.map((u) => u.id),
|
userIds: adminUsers.map((u) => u.id),
|
||||||
@@ -121,9 +117,6 @@ export async function GET(request: Request) {
|
|||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error, route: "/api/cron/health-check" }, "Health check cron failed");
|
logger.error({ error, route: "/api/cron/health-check" }, "Health check cron failed");
|
||||||
return NextResponse.json(
|
return NextResponse.json({ ok: false, error: "Internal error" }, { status: 500 });
|
||||||
{ ok: false, error: "Internal error" },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const MINIMUM_SAFE_VERSIONS: Record<string, { minVersion: string; advisory?: str
|
|||||||
next: { minVersion: "15.0.0", advisory: "Keep Next.js on latest 15.x" },
|
next: { minVersion: "15.0.0", advisory: "Keep Next.js on latest 15.x" },
|
||||||
prisma: { minVersion: "6.0.0", advisory: "Prisma 6.x for latest security patches" },
|
prisma: { minVersion: "6.0.0", advisory: "Prisma 6.x for latest security patches" },
|
||||||
"@sentry/nextjs": { minVersion: "8.0.0", advisory: "Sentry v8 for latest fixes" },
|
"@sentry/nextjs": { minVersion: "8.0.0", advisory: "Sentry v8 for latest fixes" },
|
||||||
"ioredis": { minVersion: "5.4.0", advisory: "ioredis 5.4+ for connection security" },
|
ioredis: { minVersion: "5.4.0", advisory: "ioredis 5.4+ for connection security" },
|
||||||
"next-auth": { minVersion: "5.0.0", advisory: "Auth.js v5 for security hardening" },
|
"next-auth": { minVersion: "5.0.0", advisory: "Auth.js v5 for security hardening" },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,7 +89,10 @@ function scanPackageJson(): Finding[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error, route: "/api/cron/security-audit" }, "Failed to scan package manifests for security audit");
|
logger.error(
|
||||||
|
{ error, route: "/api/cron/security-audit" },
|
||||||
|
"Failed to scan package manifests for security audit",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return findings;
|
return findings;
|
||||||
@@ -124,7 +127,6 @@ export async function GET(request: Request) {
|
|||||||
.map((f) => `${f.package}@${f.currentVersion} (need >=${f.minimumVersion})`)
|
.map((f) => `${f.package}@${f.currentVersion} (need >=${f.minimumVersion})`)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
await createNotificationsForUsers({
|
await createNotificationsForUsers({
|
||||||
db: prisma as any,
|
db: prisma as any,
|
||||||
userIds: adminUsers.map((u) => u.id),
|
userIds: adminUsers.map((u) => u.id),
|
||||||
@@ -147,9 +149,6 @@ export async function GET(request: Request) {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error, route: "/api/cron/security-audit" }, "Security audit cron failed");
|
logger.error({ error, route: "/api/cron/security-audit" }, "Security audit cron failed");
|
||||||
return NextResponse.json(
|
return NextResponse.json({ ok: false, error: "Internal error" }, { status: 500 });
|
||||||
{ ok: false, error: "Internal error" },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { loadRoleDefaults } from "@capakraken/api";
|
|||||||
import { deriveUserSseSubscription, eventBus } from "@capakraken/api/sse";
|
import { deriveUserSseSubscription, eventBus } from "@capakraken/api/sse";
|
||||||
import { startReminderScheduler } from "@capakraken/api/lib/reminder-scheduler";
|
import { startReminderScheduler } from "@capakraken/api/lib/reminder-scheduler";
|
||||||
import { prisma } from "@capakraken/db";
|
import { prisma } from "@capakraken/db";
|
||||||
import { SSE_EVENT_TYPES, SystemRole, type PermissionOverrides } from "@capakraken/shared";
|
import type { SystemRole } from "@capakraken/shared";
|
||||||
|
import { SSE_EVENT_TYPES, type PermissionOverrides } from "@capakraken/shared";
|
||||||
import { auth } from "~/server/auth.js";
|
import { auth } from "~/server/auth.js";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -59,26 +60,27 @@ export async function GET() {
|
|||||||
start(controller) {
|
start(controller) {
|
||||||
// Send initial connection confirmation
|
// Send initial connection confirmation
|
||||||
controller.enqueue(
|
controller.enqueue(
|
||||||
encoder.encode(`data: ${JSON.stringify({ type: SSE_EVENT_TYPES.PING, timestamp: new Date().toISOString() })}\n\n`),
|
encoder.encode(
|
||||||
|
`data: ${JSON.stringify({ type: SSE_EVENT_TYPES.PING, timestamp: new Date().toISOString() })}\n\n`,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Subscribe to event bus
|
// Subscribe to event bus
|
||||||
const unsubscribe = eventBus.subscribe(
|
const unsubscribe = eventBus.subscribe((event) => {
|
||||||
(event) => {
|
try {
|
||||||
try {
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
||||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
} catch {
|
||||||
} catch {
|
// Client disconnected
|
||||||
// Client disconnected
|
}
|
||||||
}
|
}, subscription);
|
||||||
},
|
|
||||||
subscription,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Heartbeat every 30 seconds
|
// Heartbeat every 30 seconds
|
||||||
const heartbeat = setInterval(() => {
|
const heartbeat = setInterval(() => {
|
||||||
try {
|
try {
|
||||||
controller.enqueue(
|
controller.enqueue(
|
||||||
encoder.encode(`data: ${JSON.stringify({ type: SSE_EVENT_TYPES.PING, timestamp: new Date().toISOString() })}\n\n`),
|
encoder.encode(
|
||||||
|
`data: ${JSON.stringify({ type: SSE_EVENT_TYPES.PING, timestamp: new Date().toISOString() })}\n\n`,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
clearInterval(heartbeat);
|
clearInterval(heartbeat);
|
||||||
|
|||||||
@@ -45,7 +45,12 @@ function parseSpainRules(rules: unknown): Partial<EditingCountry> {
|
|||||||
if (!rules || typeof rules !== "object") return { hasSpainRules: false };
|
if (!rules || typeof rules !== "object") return { hasSpainRules: false };
|
||||||
const r = rules as Record<string, unknown>;
|
const r = rules as Record<string, unknown>;
|
||||||
if (r.type !== "spain") return { hasSpainRules: false };
|
if (r.type !== "spain") return { hasSpainRules: false };
|
||||||
const sp = r as { fridayHours?: number; summerPeriod?: { from?: string; to?: string }; summerHours?: number; regularHours?: number };
|
const sp = r as {
|
||||||
|
fridayHours?: number;
|
||||||
|
summerPeriod?: { from?: string; to?: string };
|
||||||
|
summerHours?: number;
|
||||||
|
regularHours?: number;
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
hasSpainRules: true,
|
hasSpainRules: true,
|
||||||
fridayHours: sp.fridayHours ?? 6.5,
|
fridayHours: sp.fridayHours ?? 6.5,
|
||||||
@@ -66,17 +71,26 @@ export function CountriesClient() {
|
|||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
const { data: countries, isLoading } = trpc.country.list.useQuery();
|
const { data: countries, isLoading } = trpc.country.list.useQuery();
|
||||||
|
|
||||||
// @ts-ignore TS2589: tRPC infers union type too deeply for nullable JSONB scheduleRules schema
|
// @ts-expect-error TS2589: tRPC infers union type too deeply for nullable JSONB scheduleRules schema
|
||||||
const createMut = trpc.country.create.useMutation({
|
const createMut = trpc.country.create.useMutation({
|
||||||
onSuccess: () => { void utils.country.list.invalidate(); setEditing(null); },
|
onSuccess: () => {
|
||||||
|
void utils.country.list.invalidate();
|
||||||
|
setEditing(null);
|
||||||
|
},
|
||||||
onError: (e) => setError(e.message),
|
onError: (e) => setError(e.message),
|
||||||
});
|
});
|
||||||
const updateMut = trpc.country.update.useMutation({
|
const updateMut = trpc.country.update.useMutation({
|
||||||
onSuccess: () => { void utils.country.list.invalidate(); setEditing(null); },
|
onSuccess: () => {
|
||||||
|
void utils.country.list.invalidate();
|
||||||
|
setEditing(null);
|
||||||
|
},
|
||||||
onError: (e) => setError(e.message),
|
onError: (e) => setError(e.message),
|
||||||
});
|
});
|
||||||
const createCityMut = trpc.country.createCity.useMutation({
|
const createCityMut = trpc.country.createCity.useMutation({
|
||||||
onSuccess: () => { void utils.country.list.invalidate(); setCityName(""); },
|
onSuccess: () => {
|
||||||
|
void utils.country.list.invalidate();
|
||||||
|
setCityName("");
|
||||||
|
},
|
||||||
onError: (e) => setError(e.message),
|
onError: (e) => setError(e.message),
|
||||||
});
|
});
|
||||||
const deleteCityMut = trpc.country.deleteCity.useMutation({
|
const deleteCityMut = trpc.country.deleteCity.useMutation({
|
||||||
@@ -170,7 +184,13 @@ export function CountriesClient() {
|
|||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400 flex items-center justify-between">
|
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400 flex items-center justify-between">
|
||||||
{error}
|
{error}
|
||||||
<button type="button" onClick={() => setError(null)} className="text-red-400 hover:text-red-600 text-lg leading-none">×</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setError(null)}
|
||||||
|
className="text-red-400 hover:text-red-600 text-lg leading-none"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -179,29 +199,75 @@ export function CountriesClient() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
||||||
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider"><span className="flex items-center">Code <InfoTooltip content="ISO country code (e.g. DE, ES, IN). Used to identify the country in exports and API calls." /></span></th>
|
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">
|
||||||
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider"><span className="flex items-center">Name <InfoTooltip content="The full country name. Shown in dropdowns and resource location fields." /></span></th>
|
<span className="flex items-center">
|
||||||
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider"><span className="flex items-center justify-center">Daily Hours <InfoTooltip content="Standard working hours per day for this country. Used in capacity calculations to convert between days and hours." /></span></th>
|
Code{" "}
|
||||||
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider"><span className="flex items-center justify-center">Schedule <InfoTooltip content="Special schedule rules (e.g. Spain has reduced Friday hours and summer hours). 'Standard' uses the fixed daily hours value." /></span></th>
|
<InfoTooltip content="ISO country code (e.g. DE, ES, IN). Used to identify the country in exports and API calls." />
|
||||||
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider"><span className="flex items-center justify-center">Cities <InfoTooltip content="Metro cities within this country. Used for location-specific rate cards and resource assignment." /></span></th>
|
</span>
|
||||||
<th className="text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Actions</th>
|
</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">
|
||||||
|
<span className="flex items-center">
|
||||||
|
Name{" "}
|
||||||
|
<InfoTooltip content="The full country name. Shown in dropdowns and resource location fields." />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">
|
||||||
|
<span className="flex items-center justify-center">
|
||||||
|
Daily Hours{" "}
|
||||||
|
<InfoTooltip content="Standard working hours per day for this country. Used in capacity calculations to convert between days and hours." />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">
|
||||||
|
<span className="flex items-center justify-center">
|
||||||
|
Schedule{" "}
|
||||||
|
<InfoTooltip content="Special schedule rules (e.g. Spain has reduced Friday hours and summer hours). 'Standard' uses the fixed daily hours value." />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">
|
||||||
|
<span className="flex items-center justify-center">
|
||||||
|
Cities{" "}
|
||||||
|
<InfoTooltip content="Metro cities within this country. Used for location-specific rate cards and resource assignment." />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<tr><td colSpan={6} className="text-center py-8 text-gray-400">Loading...</td></tr>
|
<tr>
|
||||||
|
<td colSpan={6} className="text-center py-8 text-gray-400">
|
||||||
|
Loading...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
)}
|
)}
|
||||||
{!isLoading && rows.length === 0 && (
|
{!isLoading && rows.length === 0 && (
|
||||||
<tr><td colSpan={6} className="text-center py-8 text-gray-400">No countries yet.</td></tr>
|
<tr>
|
||||||
|
<td colSpan={6} className="text-center py-8 text-gray-400">
|
||||||
|
No countries yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
)}
|
)}
|
||||||
{rows.map((c) => (
|
{rows.map((c) => (
|
||||||
<tr key={c.id} className="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/30">
|
<tr
|
||||||
<td className="px-4 py-3 font-mono font-medium text-gray-900 dark:text-gray-100">{c.code}</td>
|
key={c.id}
|
||||||
|
className="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/30"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-mono font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{c.code}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{c.name}</td>
|
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{c.name}</td>
|
||||||
<td className="px-4 py-3 text-center text-gray-600 dark:text-gray-400">{c.dailyWorkingHours}h</td>
|
<td className="px-4 py-3 text-center text-gray-600 dark:text-gray-400">
|
||||||
|
{c.dailyWorkingHours}h
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3 text-center">
|
<td className="px-4 py-3 text-center">
|
||||||
{c.scheduleRules && typeof c.scheduleRules === "object" && (c.scheduleRules as Record<string, unknown>).type === "spain" ? (
|
{c.scheduleRules &&
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400">Spain</span>
|
typeof c.scheduleRules === "object" &&
|
||||||
|
(c.scheduleRules as Record<string, unknown>).type === "spain" ? (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400">
|
||||||
|
Spain
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-400 text-xs">Standard</span>
|
<span className="text-gray-400 text-xs">Standard</span>
|
||||||
)}
|
)}
|
||||||
@@ -216,7 +282,13 @@ export function CountriesClient() {
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
<button type="button" onClick={() => openEdit(c)} className="text-xs text-brand-600 hover:text-brand-800 font-medium">Edit</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openEdit(c)}
|
||||||
|
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -225,67 +297,87 @@ export function CountriesClient() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expanded Metro Cities */}
|
{/* Expanded Metro Cities */}
|
||||||
{expandedId && (() => {
|
{expandedId &&
|
||||||
const country = rows.find((c) => c.id === expandedId);
|
(() => {
|
||||||
if (!country) return null;
|
const country = rows.find((c) => c.id === expandedId);
|
||||||
return (
|
if (!country) return null;
|
||||||
<div className="mt-4 bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
return (
|
||||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
<div className="mt-4 bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
Metro Cities for {country.name}
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||||
</h3>
|
Metro Cities for {country.name}
|
||||||
<div className="flex flex-wrap gap-2 mb-3">
|
</h3>
|
||||||
{country.metroCities.map((city) => (
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
<span key={city.id} className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300">
|
{country.metroCities.map((city) => (
|
||||||
{city.name}
|
<span
|
||||||
<button
|
key={city.id}
|
||||||
type="button"
|
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300"
|
||||||
onClick={() => setConfirmDeleteCity(city.id)}
|
|
||||||
className="text-gray-400 hover:text-red-500 text-xs leading-none ml-1"
|
|
||||||
>
|
>
|
||||||
×
|
{city.name}
|
||||||
</button>
|
<button
|
||||||
</span>
|
type="button"
|
||||||
))}
|
onClick={() => setConfirmDeleteCity(city.id)}
|
||||||
{country.metroCities.length === 0 && (
|
className="text-gray-400 hover:text-red-500 text-xs leading-none ml-1"
|
||||||
<span className="text-sm text-gray-400">No metro cities yet</span>
|
>
|
||||||
)}
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{country.metroCities.length === 0 && (
|
||||||
|
<span className="text-sm text-gray-400">No metro cities yet</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={cityName}
|
||||||
|
onChange={(e) => setCityName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleAddCity(country.id);
|
||||||
|
}}
|
||||||
|
placeholder="New city name..."
|
||||||
|
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400 flex-1"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAddCity(country.id)}
|
||||||
|
disabled={createCityMut.isPending || !cityName.trim()}
|
||||||
|
className="px-3 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
);
|
||||||
<input
|
})()}
|
||||||
type="text"
|
|
||||||
value={cityName}
|
|
||||||
onChange={(e) => setCityName(e.target.value)}
|
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") handleAddCity(country.id); }}
|
|
||||||
placeholder="New city name..."
|
|
||||||
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400 flex-1"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleAddCity(country.id)}
|
|
||||||
disabled={createCityMut.isPending || !cityName.trim()}
|
|
||||||
className="px-3 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
{/* Create/Edit Modal */}
|
||||||
<AnimatedModal open={editing !== null} onClose={() => setEditing(null)} maxWidth="max-w-lg" className="flex flex-col max-h-[90vh]">
|
<AnimatedModal
|
||||||
{editing && (<>
|
open={editing !== null}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
maxWidth="max-w-lg"
|
||||||
|
className="flex flex-col max-h-[90vh]"
|
||||||
|
>
|
||||||
|
{editing && (
|
||||||
|
<>
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{editing.id ? "Edit Country" : "Add Country"}
|
{editing.id ? "Edit Country" : "Add Country"}
|
||||||
</h2>
|
</h2>
|
||||||
<button type="button" onClick={() => setEditing(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none">×</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>
|
||||||
|
|
||||||
<div className="overflow-y-auto flex-1 px-6 py-5 space-y-4">
|
<div className="overflow-y-auto flex-1 px-6 py-5 space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Code <InfoTooltip content="2-3 letter ISO country code. Auto-uppercased." /></label>
|
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
Code <InfoTooltip content="2-3 letter ISO country code. Auto-uppercased." />
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editing.code}
|
value={editing.code}
|
||||||
@@ -296,11 +388,16 @@ export function CountriesClient() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Daily Hours <InfoTooltip content="Standard working hours per day. Used to convert between hours and days in capacity calculations." /></label>
|
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
Daily Hours{" "}
|
||||||
|
<InfoTooltip content="Standard working hours per day. Used to convert between hours and days in capacity calculations." />
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={editing.dailyWorkingHours}
|
value={editing.dailyWorkingHours}
|
||||||
onChange={(e) => setEditing({ ...editing, dailyWorkingHours: parseFloat(e.target.value) || 8 })}
|
onChange={(e) =>
|
||||||
|
setEditing({ ...editing, dailyWorkingHours: parseFloat(e.target.value) || 8 })
|
||||||
|
}
|
||||||
min={1}
|
min={1}
|
||||||
max={24}
|
max={24}
|
||||||
step={0.5}
|
step={0.5}
|
||||||
@@ -310,7 +407,9 @@ export function CountriesClient() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name <InfoTooltip content="Full country name shown in the UI." /></label>
|
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
Name <InfoTooltip content="Full country name shown in the UI." />
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editing.name}
|
value={editing.name}
|
||||||
@@ -329,28 +428,45 @@ export function CountriesClient() {
|
|||||||
onChange={(e) => setEditing({ ...editing, hasSpainRules: e.target.checked })}
|
onChange={(e) => setEditing({ ...editing, hasSpainRules: e.target.checked })}
|
||||||
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||||
/>
|
/>
|
||||||
Variable schedule (Spain-type) <InfoTooltip content="Enable for countries with variable working hours (e.g. reduced Friday/summer hours). Overrides the fixed daily hours with day-specific rules." />
|
Variable schedule (Spain-type){" "}
|
||||||
|
<InfoTooltip content="Enable for countries with variable working hours (e.g. reduced Friday/summer hours). Overrides the fixed daily hours with day-specific rules." />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{editing.hasSpainRules && (
|
{editing.hasSpainRules && (
|
||||||
<div className="mt-3 space-y-3 pl-6 border-l-2 border-amber-300 dark:border-amber-700">
|
<div className="mt-3 space-y-3 pl-6 border-l-2 border-amber-300 dark:border-amber-700">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Friday Hours <InfoTooltip content="Working hours on Fridays. Typically shorter than regular days." /></label>
|
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
Friday Hours{" "}
|
||||||
|
<InfoTooltip content="Working hours on Fridays. Typically shorter than regular days." />
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={editing.fridayHours}
|
value={editing.fridayHours}
|
||||||
onChange={(e) => setEditing({ ...editing, fridayHours: parseFloat(e.target.value) || 6.5 })}
|
onChange={(e) =>
|
||||||
|
setEditing({
|
||||||
|
...editing,
|
||||||
|
fridayHours: parseFloat(e.target.value) || 6.5,
|
||||||
|
})
|
||||||
|
}
|
||||||
step={0.5}
|
step={0.5}
|
||||||
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Regular Hours (Mon-Thu) <InfoTooltip content="Working hours Monday through Thursday outside the summer period." /></label>
|
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
Regular Hours (Mon-Thu){" "}
|
||||||
|
<InfoTooltip content="Working hours Monday through Thursday outside the summer period." />
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={editing.regularHours}
|
value={editing.regularHours}
|
||||||
onChange={(e) => setEditing({ ...editing, regularHours: parseFloat(e.target.value) || 9 })}
|
onChange={(e) =>
|
||||||
|
setEditing({
|
||||||
|
...editing,
|
||||||
|
regularHours: parseFloat(e.target.value) || 9,
|
||||||
|
})
|
||||||
|
}
|
||||||
step={0.5}
|
step={0.5}
|
||||||
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||||
/>
|
/>
|
||||||
@@ -358,7 +474,10 @@ export function CountriesClient() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Summer From <InfoTooltip content="Start of the summer period (MM-DD format). During summer, reduced hours apply." /></label>
|
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
Summer From{" "}
|
||||||
|
<InfoTooltip content="Start of the summer period (MM-DD format). During summer, reduced hours apply." />
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editing.summerFrom}
|
value={editing.summerFrom}
|
||||||
@@ -368,7 +487,10 @@ export function CountriesClient() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Summer To <InfoTooltip content="End of the summer period (MM-DD format)." /></label>
|
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
Summer To{" "}
|
||||||
|
<InfoTooltip content="End of the summer period (MM-DD format)." />
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editing.summerTo}
|
value={editing.summerTo}
|
||||||
@@ -378,11 +500,19 @@ export function CountriesClient() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Summer Hours <InfoTooltip content="Reduced daily working hours during the summer period." /></label>
|
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
Summer Hours{" "}
|
||||||
|
<InfoTooltip content="Reduced daily working hours during the summer period." />
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={editing.summerHours}
|
value={editing.summerHours}
|
||||||
onChange={(e) => setEditing({ ...editing, summerHours: parseFloat(e.target.value) || 6.5 })}
|
onChange={(e) =>
|
||||||
|
setEditing({
|
||||||
|
...editing,
|
||||||
|
summerHours: parseFloat(e.target.value) || 6.5,
|
||||||
|
})
|
||||||
|
}
|
||||||
step={0.5}
|
step={0.5}
|
||||||
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||||
/>
|
/>
|
||||||
@@ -394,7 +524,13 @@ export function CountriesClient() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
|
<div className="flex items-center justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
|
||||||
<button type="button" onClick={() => setEditing(null)} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">Cancel</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditing(null)}
|
||||||
|
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
@@ -404,7 +540,8 @@ export function CountriesClient() {
|
|||||||
{isPending ? "Saving..." : editing.id ? "Update" : "Create"}
|
{isPending ? "Saving..." : editing.id ? "Update" : "Create"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>)}
|
</>
|
||||||
|
)}
|
||||||
</AnimatedModal>
|
</AnimatedModal>
|
||||||
|
|
||||||
{confirmDeleteCity && (
|
{confirmDeleteCity && (
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ const PERMISSION_LABELS: Record<string, string> = {
|
|||||||
const PERMISSION_DESCRIPTIONS: Record<string, string> = {
|
const PERMISSION_DESCRIPTIONS: Record<string, string> = {
|
||||||
viewPlanning: "Read project and allocation planning views without mutation access",
|
viewPlanning: "Read project and allocation planning views without mutation access",
|
||||||
viewCosts: "Access to cost data, budget views, and financial reports",
|
viewCosts: "Access to cost data, budget views, and financial reports",
|
||||||
useAssistantAdvancedTools: "Unlocks advanced AI assistant workflows for complex cross-entity analyses",
|
useAssistantAdvancedTools:
|
||||||
|
"Unlocks advanced AI assistant workflows for complex cross-entity analyses",
|
||||||
exportData: "Export data to Excel, CSV, or PDF formats",
|
exportData: "Export data to Excel, CSV, or PDF formats",
|
||||||
importData: "Import data from external sources (Dispo, Excel)",
|
importData: "Import data from external sources (Dispo, Excel)",
|
||||||
approveVacations: "Approve or reject vacation requests",
|
approveVacations: "Approve or reject vacation requests",
|
||||||
@@ -101,8 +102,7 @@ export function SystemRolesClient() {
|
|||||||
staleTime: 10_000,
|
staleTime: 10_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// @ts-expect-error TS2589: tRPC infers union type too deeply for the role config update payload
|
||||||
// @ts-ignore TS2589: tRPC infers union type too deeply for the role config update payload
|
|
||||||
const updateMutation = trpc.systemRoleConfig.update.useMutation({
|
const updateMutation = trpc.systemRoleConfig.update.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await utils.systemRoleConfig.list.invalidate();
|
await utils.systemRoleConfig.list.invalidate();
|
||||||
@@ -164,9 +164,12 @@ export function SystemRolesClient() {
|
|||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-4xl mx-auto">
|
<div className="p-6 max-w-4xl mx-auto">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">System Role Management</h1>
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">
|
||||||
|
System Role Management
|
||||||
|
</h1>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
Configure default permissions for each system role. Changes apply to all users with that role.
|
Configure default permissions for each system role. Changes apply to all users with that
|
||||||
|
role.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -179,7 +182,11 @@ export function SystemRolesClient() {
|
|||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{[...Array(5)].map((_, i) => (
|
{[...Array(5)].map((_, i) => (
|
||||||
<div key={i} className="h-24 shimmer-skeleton rounded-xl animate-row-enter" style={{ animationDelay: `${i * 50}ms` }} />
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-24 shimmer-skeleton rounded-xl animate-row-enter"
|
||||||
|
style={{ animationDelay: `${i * 50}ms` }}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -197,7 +204,9 @@ export function SystemRolesClient() {
|
|||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-3 mb-1">
|
<div className="flex items-center gap-3 mb-1">
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold ${ROLE_BADGE_MAP[color] ?? ROLE_BADGE_MAP.gray}`}>
|
<span
|
||||||
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold ${ROLE_BADGE_MAP[color] ?? ROLE_BADGE_MAP.gray}`}
|
||||||
|
>
|
||||||
{config.label}
|
{config.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-400 dark:text-gray-500 font-mono">
|
<span className="text-xs text-gray-400 dark:text-gray-500 font-mono">
|
||||||
@@ -211,7 +220,9 @@ export function SystemRolesClient() {
|
|||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
{perms.length === 0 ? (
|
{perms.length === 0 ? (
|
||||||
<span className="text-xs text-gray-400 dark:text-gray-500 italic">No default permissions</span>
|
<span className="text-xs text-gray-400 dark:text-gray-500 italic">
|
||||||
|
No default permissions
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
perms.map((p) => (
|
perms.map((p) => (
|
||||||
<span
|
<span
|
||||||
@@ -241,15 +252,21 @@ export function SystemRolesClient() {
|
|||||||
{configs.length > 0 && (
|
{configs.length > 0 && (
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50 mb-3 flex items-center">
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50 mb-3 flex items-center">
|
||||||
Permission Matrix <InfoTooltip content="Overview of which permissions each role has by default." />
|
Permission Matrix{" "}
|
||||||
|
<InfoTooltip content="Overview of which permissions each role has by default." />
|
||||||
</h2>
|
</h2>
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-x-auto">
|
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-x-auto">
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
||||||
<th className="px-3 py-2 text-left font-medium text-gray-600 dark:text-gray-400 sticky left-0 bg-gray-50 dark:bg-gray-800/50">Permission</th>
|
<th className="px-3 py-2 text-left font-medium text-gray-600 dark:text-gray-400 sticky left-0 bg-gray-50 dark:bg-gray-800/50">
|
||||||
|
Permission
|
||||||
|
</th>
|
||||||
{configs.map((c) => (
|
{configs.map((c) => (
|
||||||
<th key={c.role} className="px-3 py-2 text-center font-medium text-gray-600 dark:text-gray-400 min-w-[80px]">
|
<th
|
||||||
|
key={c.role}
|
||||||
|
className="px-3 py-2 text-center font-medium text-gray-600 dark:text-gray-400 min-w-[80px]"
|
||||||
|
>
|
||||||
{c.label}
|
{c.label}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
@@ -269,7 +286,11 @@ export function SystemRolesClient() {
|
|||||||
{has ? (
|
{has ? (
|
||||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-green-100 dark:bg-green-900/40 text-green-600 dark:text-green-400">
|
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-green-100 dark:bg-green-900/40 text-green-600 dark:text-green-400">
|
||||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -303,7 +324,10 @@ export function SystemRolesClient() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setEditingRole(null); setActionError(null); }}
|
onClick={() => {
|
||||||
|
setEditingRole(null);
|
||||||
|
setActionError(null);
|
||||||
|
}}
|
||||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
@@ -370,7 +394,8 @@ export function SystemRolesClient() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
Default Permissions ({editingRole.permissions.size}/{ALL_PERMISSION_KEYS.length})
|
Default Permissions ({editingRole.permissions.size}/{ALL_PERMISSION_KEYS.length}
|
||||||
|
)
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -403,19 +428,31 @@ export function SystemRolesClient() {
|
|||||||
: "bg-gray-50 border-gray-200 dark:bg-gray-800/50 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800"
|
: "bg-gray-50 border-gray-200 dark:bg-gray-800/50 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className={`flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center ${
|
<span
|
||||||
isActive
|
className={`flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center ${
|
||||||
? "text-green-600 border-green-300 bg-green-100 dark:bg-green-900/40"
|
isActive
|
||||||
: "border-gray-300 dark:border-gray-600"
|
? "text-green-600 border-green-300 bg-green-100 dark:bg-green-900/40"
|
||||||
}`}>
|
: "border-gray-300 dark:border-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<span className={isActive ? "text-gray-900 dark:text-gray-100 font-medium" : "text-gray-500 dark:text-gray-400"}>
|
<span
|
||||||
|
className={
|
||||||
|
isActive
|
||||||
|
? "text-gray-900 dark:text-gray-100 font-medium"
|
||||||
|
: "text-gray-500 dark:text-gray-400"
|
||||||
|
}
|
||||||
|
>
|
||||||
{PERMISSION_LABELS[key] ?? key}
|
{PERMISSION_LABELS[key] ?? key}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-[11px] text-gray-400 dark:text-gray-500 mt-0.5 truncate">
|
<p className="text-[11px] text-gray-400 dark:text-gray-500 mt-0.5 truncate">
|
||||||
@@ -432,7 +469,10 @@ export function SystemRolesClient() {
|
|||||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
|
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setEditingRole(null); setActionError(null); }}
|
onClick={() => {
|
||||||
|
setEditingRole(null);
|
||||||
|
setActionError(null);
|
||||||
|
}}
|
||||||
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
|
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { SystemRole, PermissionKey, ROLE_DEFAULT_PERMISSIONS, MILLISECONDS_PER_DAY, type PermissionOverrides } from "@capakraken/shared";
|
import {
|
||||||
|
SystemRole,
|
||||||
|
PermissionKey,
|
||||||
|
ROLE_DEFAULT_PERMISSIONS,
|
||||||
|
MILLISECONDS_PER_DAY,
|
||||||
|
type PermissionOverrides,
|
||||||
|
} from "@capakraken/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
|
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
|
||||||
import { InviteUserModal } from "./InviteUserModal.js";
|
import { InviteUserModal } from "./InviteUserModal.js";
|
||||||
@@ -99,13 +105,17 @@ export function UsersClient() {
|
|||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [roleFilter, setRoleFilter] = useState<SystemRole | "">("");
|
const [roleFilter, setRoleFilter] = useState<SystemRole | "">("");
|
||||||
const [editingName, setEditingName] = useState<{ userId: string; name: string } | null>(null);
|
const [editingName, setEditingName] = useState<{ userId: string; name: string } | null>(null);
|
||||||
const [passwordTarget, setPasswordTarget] = useState<{ userId: string; userName: string } | null>(null);
|
const [passwordTarget, setPasswordTarget] = useState<{ userId: string; userName: string } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||||
const [passwordSuccess, setPasswordSuccess] = useState(false);
|
const [passwordSuccess, setPasswordSuccess] = useState(false);
|
||||||
const [inviteOpen, setInviteOpen] = useState(false);
|
const [inviteOpen, setInviteOpen] = useState(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<{ userId: string; userName: string } | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<{ userId: string; userName: string } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
@@ -145,8 +155,7 @@ export function UsersClient() {
|
|||||||
onError: (err) => setActionError(err.message),
|
onError: (err) => setActionError(err.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// @ts-expect-error TS2589: tRPC infers union type too deeply for nullable overrides schema
|
||||||
// @ts-ignore TS2589: tRPC infers union type too deeply for nullable overrides schema
|
|
||||||
const setPermissionsMutation = trpc.user.setPermissions.useMutation({
|
const setPermissionsMutation = trpc.user.setPermissions.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await utils.user.list.invalidate();
|
await utils.user.list.invalidate();
|
||||||
@@ -322,7 +331,10 @@ export function UsersClient() {
|
|||||||
async function handleSaveRole() {
|
async function handleSaveRole() {
|
||||||
if (!editState) return;
|
if (!editState) return;
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
await updateRoleMutation.mutateAsync({ id: editState.userId, systemRole: editState.systemRole });
|
await updateRoleMutation.mutateAsync({
|
||||||
|
id: editState.userId,
|
||||||
|
systemRole: editState.systemRole,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSavePermissions() {
|
async function handleSavePermissions() {
|
||||||
@@ -358,7 +370,8 @@ export function UsersClient() {
|
|||||||
const filteredUsers = allUsers.filter((u) => {
|
const filteredUsers = allUsers.filter((u) => {
|
||||||
if (search) {
|
if (search) {
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
if (!(u.name ?? "").toLowerCase().includes(q) && !u.email.toLowerCase().includes(q)) return false;
|
if (!(u.name ?? "").toLowerCase().includes(q) && !u.email.toLowerCase().includes(q))
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
if (roleFilter && u.systemRole !== roleFilter) return false;
|
if (roleFilter && u.systemRole !== roleFilter) return false;
|
||||||
return true;
|
return true;
|
||||||
@@ -408,7 +421,9 @@ export function UsersClient() {
|
|||||||
|
|
||||||
const chips = [
|
const chips = [
|
||||||
...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []),
|
...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []),
|
||||||
...(roleFilter ? [{ label: `Role: ${SYSTEM_ROLE_LABELS[roleFilter]}`, onRemove: () => setRoleFilter("") }] : []),
|
...(roleFilter
|
||||||
|
? [{ label: `Role: ${SYSTEM_ROLE_LABELS[roleFilter]}`, onRemove: () => setRoleFilter("") }]
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
function isOnline(user: UserRow) {
|
function isOnline(user: UserRow) {
|
||||||
@@ -431,9 +446,7 @@ export function UsersClient() {
|
|||||||
<div className="app-page-header mb-6">
|
<div className="app-page-header mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="app-page-title">User Management</h1>
|
<h1 className="app-page-title">User Management</h1>
|
||||||
<p className="app-page-subtitle mt-1">
|
<p className="app-page-subtitle mt-1">Manage user roles and permission overrides</p>
|
||||||
Manage user roles and permission overrides
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{activeData && (
|
{activeData && (
|
||||||
@@ -449,16 +462,25 @@ export function UsersClient() {
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void autoLinkMutation.mutateAsync().then((r) => {
|
onClick={() =>
|
||||||
setActionError(r.linked > 0 ? null : `No unlinked accounts found (checked ${r.checked})`);
|
void autoLinkMutation.mutateAsync().then((r) => {
|
||||||
if (r.linked > 0) setActionError(null);
|
setActionError(
|
||||||
})}
|
r.linked > 0 ? null : `No unlinked accounts found (checked ${r.checked})`,
|
||||||
|
);
|
||||||
|
if (r.linked > 0) setActionError(null);
|
||||||
|
})
|
||||||
|
}
|
||||||
disabled={autoLinkMutation.isPending}
|
disabled={autoLinkMutation.isPending}
|
||||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 dark:border-gray-600 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors disabled:opacity-50"
|
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 dark:border-gray-600 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors disabled:opacity-50"
|
||||||
title="Auto-link user accounts to resources by matching email addresses"
|
title="Auto-link user accounts to resources by matching email addresses"
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{autoLinkMutation.isPending ? "Linking..." : "Auto-link Resources"}
|
{autoLinkMutation.isPending ? "Linking..." : "Auto-link Resources"}
|
||||||
</button>
|
</button>
|
||||||
@@ -468,17 +490,30 @@ export function UsersClient() {
|
|||||||
className="inline-flex items-center gap-2 rounded-lg border border-brand-300 dark:border-brand-600 px-4 py-2 text-sm font-medium text-brand-700 dark:text-brand-300 hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors"
|
className="inline-flex items-center gap-2 rounded-lg border border-brand-300 dark:border-brand-600 px-4 py-2 text-sm font-medium text-brand-700 dark:text-brand-300 hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors"
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Invite User
|
Invite User
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setCreateState({ ...EMPTY_CREATE }); setActionError(null); }}
|
onClick={() => {
|
||||||
|
setCreateState({ ...EMPTY_CREATE });
|
||||||
|
setActionError(null);
|
||||||
|
}}
|
||||||
className="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-brand-700 transition-colors"
|
className="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-brand-700 transition-colors"
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 4v16m8-8H4"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Create User
|
Create User
|
||||||
</button>
|
</button>
|
||||||
@@ -501,7 +536,9 @@ export function UsersClient() {
|
|||||||
>
|
>
|
||||||
<option value="">All Roles</option>
|
<option value="">All Roles</option>
|
||||||
{Object.values(SystemRole).map((role) => (
|
{Object.values(SystemRole).map((role) => (
|
||||||
<option key={role} value={role}>{SYSTEM_ROLE_LABELS[role]}</option>
|
<option key={role} value={role}>
|
||||||
|
{SYSTEM_ROLE_LABELS[role]}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -530,13 +567,54 @@ export function UsersClient() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
||||||
<SortableColumnHeader label="Name" field="name" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="The user's display name. Shown in the UI and linked to a resource record if auto-linked." />
|
<SortableColumnHeader
|
||||||
<SortableColumnHeader label="Email" field="email" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="Login email address. Also used to auto-link user accounts to resource records by matching email." />
|
label="Name"
|
||||||
<SortableColumnHeader label="Role" field="systemRole" sortField={sortField} sortDir={sortDir} onSort={handleSort} align="center" tooltip="System role determines default access level. ADMIN: full access · MANAGER: manage resources/projects · CONTROLLER: read + export costs · USER: standard access · VIEWER: read-only. Individual permissions can be overridden via the edit dialog." tooltipWidth="w-80" />
|
field="name"
|
||||||
<th className="px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider text-center">Status</th>
|
sortField={sortField}
|
||||||
<SortableColumnHeader label="Last Login" field="lastLoginAt" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="When the user last signed in." />
|
sortDir={sortDir}
|
||||||
<SortableColumnHeader label="Created" field="createdAt" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="Account creation date." />
|
onSort={handleSort}
|
||||||
<th className="text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Actions</th>
|
tooltip="The user's display name. Shown in the UI and linked to a resource record if auto-linked."
|
||||||
|
/>
|
||||||
|
<SortableColumnHeader
|
||||||
|
label="Email"
|
||||||
|
field="email"
|
||||||
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
tooltip="Login email address. Also used to auto-link user accounts to resource records by matching email."
|
||||||
|
/>
|
||||||
|
<SortableColumnHeader
|
||||||
|
label="Role"
|
||||||
|
field="systemRole"
|
||||||
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
align="center"
|
||||||
|
tooltip="System role determines default access level. ADMIN: full access · MANAGER: manage resources/projects · CONTROLLER: read + export costs · USER: standard access · VIEWER: read-only. Individual permissions can be overridden via the edit dialog."
|
||||||
|
tooltipWidth="w-80"
|
||||||
|
/>
|
||||||
|
<th className="px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider text-center">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<SortableColumnHeader
|
||||||
|
label="Last Login"
|
||||||
|
field="lastLoginAt"
|
||||||
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
tooltip="When the user last signed in."
|
||||||
|
/>
|
||||||
|
<SortableColumnHeader
|
||||||
|
label="Created"
|
||||||
|
field="createdAt"
|
||||||
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
tooltip="Account creation date."
|
||||||
|
/>
|
||||||
|
<th className="text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -566,7 +644,8 @@ export function UsersClient() {
|
|||||||
<td className="px-4 py-3 text-center">
|
<td className="px-4 py-3 text-center">
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
ROLE_BADGE_COLORS[user.systemRole as SystemRole] ?? ROLE_BADGE_COLORS[SystemRole.USER]
|
ROLE_BADGE_COLORS[user.systemRole as SystemRole] ??
|
||||||
|
ROLE_BADGE_COLORS[SystemRole.USER]
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{SYSTEM_ROLE_LABELS[user.systemRole as SystemRole] ?? user.systemRole}
|
{SYSTEM_ROLE_LABELS[user.systemRole as SystemRole] ?? user.systemRole}
|
||||||
@@ -591,7 +670,10 @@ export function UsersClient() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{user.totpEnabled && (
|
{user.totpEnabled && (
|
||||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-400" title="TOTP MFA enabled">
|
<span
|
||||||
|
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-400"
|
||||||
|
title="TOTP MFA enabled"
|
||||||
|
>
|
||||||
MFA
|
MFA
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -611,8 +693,18 @@ export function UsersClient() {
|
|||||||
className="inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 font-medium"
|
className="inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 font-medium"
|
||||||
title="Set password"
|
title="Set password"
|
||||||
>
|
>
|
||||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
className="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Password
|
Password
|
||||||
</button>
|
</button>
|
||||||
@@ -628,8 +720,18 @@ export function UsersClient() {
|
|||||||
className="inline-flex items-center gap-1 text-xs text-amber-600 hover:text-amber-800 dark:text-amber-400 dark:hover:text-amber-300 font-medium"
|
className="inline-flex items-center gap-1 text-xs text-amber-600 hover:text-amber-800 dark:text-amber-400 dark:hover:text-amber-300 font-medium"
|
||||||
title="Disable TOTP MFA for this user"
|
title="Disable TOTP MFA for this user"
|
||||||
>
|
>
|
||||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
className="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Disable MFA
|
Disable MFA
|
||||||
</button>
|
</button>
|
||||||
@@ -645,7 +747,11 @@ export function UsersClient() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm(`Deactivate ${user.name ?? user.email}? They will be logged out immediately and cannot log in until reactivated.`)) {
|
if (
|
||||||
|
confirm(
|
||||||
|
`Deactivate ${user.name ?? user.email}? They will be logged out immediately and cannot log in until reactivated.`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
void deactivateMutation.mutateAsync({ userId: user.id });
|
void deactivateMutation.mutateAsync({ userId: user.id });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -668,7 +774,9 @@ export function UsersClient() {
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDeleteTarget({ userId: user.id, userName: user.name ?? user.email })}
|
onClick={() =>
|
||||||
|
setDeleteTarget({ userId: user.id, userName: user.name ?? user.email })
|
||||||
|
}
|
||||||
className="app-action-delete"
|
className="app-action-delete"
|
||||||
title="Permanently delete user"
|
title="Permanently delete user"
|
||||||
>
|
>
|
||||||
@@ -711,7 +819,8 @@ export function UsersClient() {
|
|||||||
/>
|
/>
|
||||||
{newPassword.length > 0 && newPassword.length < 8 && (
|
{newPassword.length > 0 && newPassword.length < 8 && (
|
||||||
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
|
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
|
||||||
{8 - newPassword.length} more character{8 - newPassword.length !== 1 ? "s" : ""} needed
|
{8 - newPassword.length} more character{8 - newPassword.length !== 1 ? "s" : ""}{" "}
|
||||||
|
needed
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -729,9 +838,7 @@ export function UsersClient() {
|
|||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
{confirmPassword.length > 0 && newPassword !== confirmPassword && (
|
{confirmPassword.length > 0 && newPassword !== confirmPassword && (
|
||||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
|
<p className="mt-1 text-xs text-red-600 dark:text-red-400">Passwords do not match</p>
|
||||||
Passwords do not match
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -747,7 +854,11 @@ export function UsersClient() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleSetPassword()}
|
onClick={() => void handleSetPassword()}
|
||||||
disabled={setPasswordMutation.isPending || newPassword.length < 8 || newPassword !== confirmPassword}
|
disabled={
|
||||||
|
setPasswordMutation.isPending ||
|
||||||
|
newPassword.length < 8 ||
|
||||||
|
newPassword !== confirmPassword
|
||||||
|
}
|
||||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{setPasswordMutation.isPending ? "Saving..." : "Set Password"}
|
{setPasswordMutation.isPending ? "Saving..." : "Set Password"}
|
||||||
@@ -770,12 +881,13 @@ export function UsersClient() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-5 space-y-3">
|
<div className="px-6 py-5 space-y-3">
|
||||||
<p className="text-sm text-gray-700 dark:text-gray-300">
|
<p className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
Are you sure you want to permanently delete{" "}
|
Are you sure you want to permanently delete <strong>{deleteTarget.userName}</strong>
|
||||||
<strong>{deleteTarget.userName}</strong>?
|
?
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-red-600 dark:text-red-400">
|
<p className="text-sm text-red-600 dark:text-red-400">
|
||||||
This will permanently remove their account, sessions, vacation records, and notifications.
|
This will permanently remove their account, sessions, vacation records, and
|
||||||
Audit history entries will be retained but anonymised. This action cannot be undone.
|
notifications. Audit history entries will be retained but anonymised. This action
|
||||||
|
cannot be undone.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
|
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
@@ -810,7 +922,10 @@ export function UsersClient() {
|
|||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setCreateState(null); setActionError(null); }}
|
onClick={() => {
|
||||||
|
setCreateState(null);
|
||||||
|
setActionError(null);
|
||||||
|
}}
|
||||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
@@ -839,7 +954,8 @@ export function UsersClient() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Email <InfoTooltip content="Login email address. Also used to auto-link the user to a resource record." />
|
Email{" "}
|
||||||
|
<InfoTooltip content="Login email address. Also used to auto-link the user to a resource record." />
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
@@ -852,7 +968,8 @@ export function UsersClient() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Password <InfoTooltip content="Minimum 8 characters. Stored securely using Argon2 hashing." />
|
Password{" "}
|
||||||
|
<InfoTooltip content="Minimum 8 characters. Stored securely using Argon2 hashing." />
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
@@ -866,15 +983,20 @@ export function UsersClient() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Role <InfoTooltip content="ADMIN: full system access. MANAGER: manage resources, projects, allocations. CONTROLLER: read + export financial data. USER: standard access. VIEWER: read-only." />
|
Role{" "}
|
||||||
|
<InfoTooltip content="ADMIN: full system access. MANAGER: manage resources, projects, allocations. CONTROLLER: read + export financial data. USER: standard access. VIEWER: read-only." />
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={createState.systemRole}
|
value={createState.systemRole}
|
||||||
onChange={(e) => setCreateState({ ...createState, systemRole: e.target.value as SystemRole })}
|
onChange={(e) =>
|
||||||
|
setCreateState({ ...createState, systemRole: e.target.value as SystemRole })
|
||||||
|
}
|
||||||
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||||
>
|
>
|
||||||
{Object.values(SystemRole).map((role) => (
|
{Object.values(SystemRole).map((role) => (
|
||||||
<option key={role} value={role}>{SYSTEM_ROLE_LABELS[role]}</option>
|
<option key={role} value={role}>
|
||||||
|
{SYSTEM_ROLE_LABELS[role]}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -883,7 +1005,10 @@ export function UsersClient() {
|
|||||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
|
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setCreateState(null); setActionError(null); }}
|
onClick={() => {
|
||||||
|
setCreateState(null);
|
||||||
|
setActionError(null);
|
||||||
|
}}
|
||||||
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
|
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -891,7 +1016,12 @@ export function UsersClient() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleCreateUser()}
|
onClick={() => void handleCreateUser()}
|
||||||
disabled={isPending || !createState.name.trim() || !createState.email.trim() || createState.password.length < 8}
|
disabled={
|
||||||
|
isPending ||
|
||||||
|
!createState.name.trim() ||
|
||||||
|
!createState.email.trim() ||
|
||||||
|
createState.password.length < 8
|
||||||
|
}
|
||||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{createUserMutation.isPending ? "Creating..." : "Create User"}
|
{createUserMutation.isPending ? "Creating..." : "Create User"}
|
||||||
@@ -941,14 +1071,22 @@ export function UsersClient() {
|
|||||||
autoFocus
|
autoFocus
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && editingName.name.trim()) {
|
if (e.key === "Enter" && editingName.name.trim()) {
|
||||||
updateNameMutation.mutate({ id: editingName.userId, name: editingName.name.trim() });
|
updateNameMutation.mutate({
|
||||||
|
id: editingName.userId,
|
||||||
|
name: editingName.name.trim(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (e.key === "Escape") setEditingName(null);
|
if (e.key === "Escape") setEditingName(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => updateNameMutation.mutate({ id: editingName.userId, name: editingName.name.trim() })}
|
onClick={() =>
|
||||||
|
updateNameMutation.mutate({
|
||||||
|
id: editingName.userId,
|
||||||
|
name: editingName.name.trim(),
|
||||||
|
})
|
||||||
|
}
|
||||||
disabled={!editingName.name.trim() || updateNameMutation.isPending}
|
disabled={!editingName.name.trim() || updateNameMutation.isPending}
|
||||||
className="px-3 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700 disabled:opacity-50"
|
className="px-3 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
@@ -984,7 +1122,8 @@ export function UsersClient() {
|
|||||||
{/* System Role */}
|
{/* System Role */}
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center">
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center">
|
||||||
System Role <InfoTooltip content="The base role determines default permissions. Change the role and click 'Save Role' to apply. Permission overrides below can further customize access." />
|
System Role{" "}
|
||||||
|
<InfoTooltip content="The base role determines default permissions. Change the role and click 'Save Role' to apply. Permission overrides below can further customize access." />
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<select
|
<select
|
||||||
@@ -1014,17 +1153,25 @@ export function UsersClient() {
|
|||||||
{/* Permissions */}
|
{/* Permissions */}
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center">
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center">
|
||||||
Permissions <InfoTooltip content="Permissions inherited from the role are shown with a filled checkbox. Click to override: grant additional permissions or deny role defaults." />
|
Permissions{" "}
|
||||||
|
<InfoTooltip content="Permissions inherited from the role are shown with a filled checkbox. Click to override: grant additional permissions or deny role defaults." />
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex gap-1.5 mb-3 text-[11px]">
|
<div className="flex gap-1.5 mb-3 text-[11px]">
|
||||||
<span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
|
<span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
|
||||||
<span className="inline-block w-3 h-3 rounded border border-green-400 bg-green-100 dark:bg-green-900/40" /> Role default
|
<span className="inline-block w-3 h-3 rounded border border-green-400 bg-green-100 dark:bg-green-900/40" />{" "}
|
||||||
|
Role default
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
|
<span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
|
||||||
<span className="inline-block w-3 h-3 rounded border border-blue-400 bg-blue-100 dark:bg-blue-900/40" /> Extra grant
|
<span className="inline-block w-3 h-3 rounded border border-blue-400 bg-blue-100 dark:bg-blue-900/40" />{" "}
|
||||||
|
Extra grant
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
|
<span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
|
||||||
<span className="inline-block w-3 h-3 rounded border border-red-400 bg-red-100 dark:bg-red-900/40 relative"><span className="absolute inset-0 flex items-center justify-center text-red-500 text-[9px] leading-none">×</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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -1067,8 +1214,10 @@ export function UsersClient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stateStyles = {
|
const stateStyles = {
|
||||||
default: "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800",
|
default:
|
||||||
granted: "bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800",
|
"bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800",
|
||||||
|
granted:
|
||||||
|
"bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800",
|
||||||
denied: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800",
|
denied: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800",
|
||||||
off: "bg-gray-50 border-gray-200 dark:bg-gray-800/50 dark:border-gray-700",
|
off: "bg-gray-50 border-gray-200 dark:bg-gray-800/50 dark:border-gray-700",
|
||||||
};
|
};
|
||||||
@@ -1087,28 +1236,50 @@ export function UsersClient() {
|
|||||||
onClick={cycleState}
|
onClick={cycleState}
|
||||||
className={`flex items-center gap-2.5 w-full px-3 py-1.5 rounded-lg border text-sm text-left transition-colors ${stateStyles[state]} hover:opacity-80`}
|
className={`flex items-center gap-2.5 w-full px-3 py-1.5 rounded-lg border text-sm text-left transition-colors ${stateStyles[state]} hover:opacity-80`}
|
||||||
>
|
>
|
||||||
<span className={`flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center ${checkStyles[state]}`}>
|
<span
|
||||||
|
className={`flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center ${checkStyles[state]}`}
|
||||||
|
>
|
||||||
{state === "default" && (
|
{state === "default" && (
|
||||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /></svg>
|
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
)}
|
)}
|
||||||
{state === "granted" && (
|
{state === "granted" && (
|
||||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" /></svg>
|
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
)}
|
)}
|
||||||
{state === "denied" && (
|
{state === "denied" && (
|
||||||
<span className="text-xs font-bold leading-none">×</span>
|
<span className="text-xs font-bold leading-none">×</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className={`flex-1 ${state === "denied" ? "line-through text-red-500 dark:text-red-400" : state === "off" ? "text-gray-500 dark:text-gray-400" : "text-gray-900 dark:text-gray-100"}`}>
|
<span
|
||||||
|
className={`flex-1 ${state === "denied" ? "line-through text-red-500 dark:text-red-400" : state === "off" ? "text-gray-500 dark:text-gray-400" : "text-gray-900 dark:text-gray-100"}`}
|
||||||
|
>
|
||||||
{PERMISSION_LABELS[key] ?? key}
|
{PERMISSION_LABELS[key] ?? key}
|
||||||
</span>
|
</span>
|
||||||
{state === "default" && (
|
{state === "default" && (
|
||||||
<span className="text-[10px] text-green-600 dark:text-green-400 font-medium uppercase tracking-wide">Role</span>
|
<span className="text-[10px] text-green-600 dark:text-green-400 font-medium uppercase tracking-wide">
|
||||||
|
Role
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{state === "granted" && (
|
{state === "granted" && (
|
||||||
<span className="text-[10px] text-blue-600 dark:text-blue-400 font-medium uppercase tracking-wide">Extra</span>
|
<span className="text-[10px] text-blue-600 dark:text-blue-400 font-medium uppercase tracking-wide">
|
||||||
|
Extra
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{state === "denied" && (
|
{state === "denied" && (
|
||||||
<span className="text-[10px] text-red-600 dark:text-red-400 font-medium uppercase tracking-wide">Denied</span>
|
<span className="text-[10px] text-red-600 dark:text-red-400 font-medium uppercase tracking-wide">
|
||||||
|
Denied
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -1118,7 +1289,8 @@ export function UsersClient() {
|
|||||||
{/* Chapter Scope */}
|
{/* Chapter Scope */}
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5">
|
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5">
|
||||||
Chapter Scope (comma-separated IDs, leave blank for all) <InfoTooltip content="Restrict this user's access to specific chapters/disciplines only. Leave blank to allow access to all chapters." />
|
Chapter Scope (comma-separated IDs, leave blank for all){" "}
|
||||||
|
<InfoTooltip content="Restrict this user's access to specific chapters/disciplines only. Leave blank to allow access to all chapters." />
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -6,8 +6,14 @@ import { useLocalStorage } from "~/hooks/useLocalStorage.js";
|
|||||||
import { formatDate } from "~/lib/format.js";
|
import { formatDate } from "~/lib/format.js";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { AllocationModal } from "./AllocationModal.js";
|
import { AllocationModal } from "./AllocationModal.js";
|
||||||
import type { AllocationLike, AllocationReadModel, AllocationWithDetails, ColumnDef } from "@capakraken/shared";
|
import type {
|
||||||
import { AllocationStatus, ALLOCATION_COLUMNS } from "@capakraken/shared";
|
AllocationLike,
|
||||||
|
AllocationReadModel,
|
||||||
|
AllocationWithDetails,
|
||||||
|
ColumnDef,
|
||||||
|
AllocationStatus,
|
||||||
|
} from "@capakraken/shared";
|
||||||
|
import { ALLOCATION_COLUMNS } from "@capakraken/shared";
|
||||||
import { useSelection } from "~/hooks/useSelection.js";
|
import { useSelection } from "~/hooks/useSelection.js";
|
||||||
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
|
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
|
||||||
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
||||||
@@ -34,7 +40,10 @@ import {
|
|||||||
toggleCollapsedAllocationGroup,
|
toggleCollapsedAllocationGroup,
|
||||||
type CollapsedAllocationGroups,
|
type CollapsedAllocationGroups,
|
||||||
} from "./allocationGroupState.js";
|
} from "./allocationGroupState.js";
|
||||||
import { getAllocationEmptyState, shouldAutoRelaxAllocationFilters } from "./allocationVisibilityState.js";
|
import {
|
||||||
|
getAllocationEmptyState,
|
||||||
|
shouldAutoRelaxAllocationFilters,
|
||||||
|
} from "./allocationVisibilityState.js";
|
||||||
|
|
||||||
/** Left-border color by allocation status for instant visual scanning */
|
/** Left-border color by allocation status for instant visual scanning */
|
||||||
const STATUS_LEFT_BORDER: Record<string, string> = {
|
const STATUS_LEFT_BORDER: Record<string, string> = {
|
||||||
@@ -124,15 +133,39 @@ export function AllocationsClient() {
|
|||||||
const hideCompletedProjects = allocFilters.hideCompleted === "true";
|
const hideCompletedProjects = allocFilters.hideCompleted === "true";
|
||||||
const hideDraftProjects = allocFilters.hideDraft === "true";
|
const hideDraftProjects = allocFilters.hideDraft === "true";
|
||||||
|
|
||||||
const setFilterProjectId = useCallback((v: string) => setAllocFilters({ projectId: v }), [setAllocFilters]);
|
const setFilterProjectId = useCallback(
|
||||||
const setFilterResourceId = useCallback((v: string) => setAllocFilters({ resourceId: v }), [setAllocFilters]);
|
(v: string) => setAllocFilters({ projectId: v }),
|
||||||
const setFilterStatus = useCallback((v: string) => setAllocFilters({ status: v }), [setAllocFilters]);
|
[setAllocFilters],
|
||||||
const setHidePastProjects = useCallback((v: boolean) => setAllocFilters({ hidePast: v ? "true" : "false" }), [setAllocFilters]);
|
);
|
||||||
const setHideCompletedProjects = useCallback((v: boolean) => setAllocFilters({ hideCompleted: v ? "true" : "false" }), [setAllocFilters]);
|
const setFilterResourceId = useCallback(
|
||||||
const setHideDraftProjects = useCallback((v: boolean) => setAllocFilters({ hideDraft: v ? "true" : "false" }), [setAllocFilters]);
|
(v: string) => setAllocFilters({ resourceId: v }),
|
||||||
const [confirmDelete, setConfirmDelete] = useState<{ single?: AllocationWithDetails; ids?: string[] } | null>(null);
|
[setAllocFilters],
|
||||||
|
);
|
||||||
|
const setFilterStatus = useCallback(
|
||||||
|
(v: string) => setAllocFilters({ status: v }),
|
||||||
|
[setAllocFilters],
|
||||||
|
);
|
||||||
|
const setHidePastProjects = useCallback(
|
||||||
|
(v: boolean) => setAllocFilters({ hidePast: v ? "true" : "false" }),
|
||||||
|
[setAllocFilters],
|
||||||
|
);
|
||||||
|
const setHideCompletedProjects = useCallback(
|
||||||
|
(v: boolean) => setAllocFilters({ hideCompleted: v ? "true" : "false" }),
|
||||||
|
[setAllocFilters],
|
||||||
|
);
|
||||||
|
const setHideDraftProjects = useCallback(
|
||||||
|
(v: boolean) => setAllocFilters({ hideDraft: v ? "true" : "false" }),
|
||||||
|
[setAllocFilters],
|
||||||
|
);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<{
|
||||||
|
single?: AllocationWithDetails;
|
||||||
|
ids?: string[];
|
||||||
|
} | null>(null);
|
||||||
const [batchStatusPicker, setBatchStatusPicker] = useState(false);
|
const [batchStatusPicker, setBatchStatusPicker] = useState(false);
|
||||||
const [confirmBatchStatus, setConfirmBatchStatus] = useState<{ ids: string[]; status: string } | null>(null);
|
const [confirmBatchStatus, setConfirmBatchStatus] = useState<{
|
||||||
|
ids: string[];
|
||||||
|
status: string;
|
||||||
|
} | null>(null);
|
||||||
const [showStatusToast, setShowStatusToast] = useState(false);
|
const [showStatusToast, setShowStatusToast] = useState(false);
|
||||||
const [showDateShiftModal, setShowDateShiftModal] = useState(false);
|
const [showDateShiftModal, setShowDateShiftModal] = useState(false);
|
||||||
|
|
||||||
@@ -145,8 +178,14 @@ export function AllocationsClient() {
|
|||||||
() => (canViewCosts ? ALLOCATION_COLUMNS : ALLOCATION_COLUMNS.filter((c) => c.key !== "cost")),
|
() => (canViewCosts ? ALLOCATION_COLUMNS : ALLOCATION_COLUMNS.filter((c) => c.key !== "cost")),
|
||||||
[canViewCosts],
|
[canViewCosts],
|
||||||
);
|
);
|
||||||
const { allColumns, visibleColumns, visibleKeys, setVisible } = useColumnConfig("allocations", baseColumns);
|
const { allColumns, visibleColumns, visibleKeys, setVisible } = useColumnConfig(
|
||||||
const defaultKeys = useMemo(() => baseColumns.filter((c) => c.defaultVisible).map((c) => c.key), [baseColumns]);
|
"allocations",
|
||||||
|
baseColumns,
|
||||||
|
);
|
||||||
|
const defaultKeys = useMemo(
|
||||||
|
() => baseColumns.filter((c) => c.defaultVisible).map((c) => c.key),
|
||||||
|
[baseColumns],
|
||||||
|
);
|
||||||
|
|
||||||
const allocationQuery = trpc.allocation.listView.useQuery(
|
const allocationQuery = trpc.allocation.listView.useQuery(
|
||||||
{
|
{
|
||||||
@@ -207,17 +246,28 @@ export function AllocationsClient() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
selection.clear();
|
selection.clear();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [filterProjectId, filterResourceId, filterStatus, hidePastProjects, hideCompletedProjects, hideDraftProjects]);
|
}, [
|
||||||
|
filterProjectId,
|
||||||
|
filterResourceId,
|
||||||
|
filterStatus,
|
||||||
|
hidePastProjects,
|
||||||
|
hideCompletedProjects,
|
||||||
|
hideDraftProjects,
|
||||||
|
]);
|
||||||
|
|
||||||
function handleExportExcel() {
|
function handleExportExcel() {
|
||||||
const rows: (string | number | null)[][] = [
|
const rows: (string | number | null)[][] = [
|
||||||
["Resource", "Project", "Role", "Start Date", "End Date", "Hours/Day", "Status"],
|
["Resource", "Project", "Role", "Start Date", "End Date", "Hours/Day", "Status"],
|
||||||
...sorted.map((a) => [
|
...sorted.map((a) => [
|
||||||
(a.resource as { displayName?: string } | null | undefined)?.displayName ?? "Unassigned",
|
(a.resource as { displayName?: string } | null | undefined)?.displayName ?? "Unassigned",
|
||||||
(a.project as { shortCode?: string; name?: string } | null | undefined) ? `${(a.project as { shortCode: string }).shortCode} — ${(a.project as { name: string }).name}` : "",
|
(a.project as { shortCode?: string; name?: string } | null | undefined)
|
||||||
|
? `${(a.project as { shortCode: string }).shortCode} — ${(a.project as { name: string }).name}`
|
||||||
|
: "",
|
||||||
a.role ?? "",
|
a.role ?? "",
|
||||||
typeof a.startDate === "string" ? a.startDate : (a.startDate as Date).toISOString().slice(0, 10),
|
typeof a.startDate === "string"
|
||||||
|
? a.startDate
|
||||||
|
: (a.startDate as Date).toISOString().slice(0, 10),
|
||||||
typeof a.endDate === "string" ? a.endDate : (a.endDate as Date).toISOString().slice(0, 10),
|
typeof a.endDate === "string" ? a.endDate : (a.endDate as Date).toISOString().slice(0, 10),
|
||||||
a.hoursPerDay,
|
a.hoursPerDay,
|
||||||
a.status,
|
a.status,
|
||||||
@@ -248,16 +298,28 @@ export function AllocationsClient() {
|
|||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
const filteredAllocations = assignmentList.filter((alloc) => {
|
const filteredAllocations = assignmentList.filter((alloc) => {
|
||||||
if (hidePastProjects && alloc.project?.endDate && new Date(alloc.project.endDate) < today) return false;
|
if (hidePastProjects && alloc.project?.endDate && new Date(alloc.project.endDate) < today)
|
||||||
if (hideCompletedProjects && alloc.project?.status && ["COMPLETED", "CANCELLED"].includes(alloc.project.status)) return false;
|
return false;
|
||||||
|
if (
|
||||||
|
hideCompletedProjects &&
|
||||||
|
alloc.project?.status &&
|
||||||
|
["COMPLETED", "CANCELLED"].includes(alloc.project.status)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
if (hideDraftProjects && alloc.project?.status === "DRAFT") return false;
|
if (hideDraftProjects && alloc.project?.status === "DRAFT") return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredDemands = demandList.filter((alloc) => {
|
const filteredDemands = demandList.filter((alloc) => {
|
||||||
if (filterResourceId) return false;
|
if (filterResourceId) return false;
|
||||||
if (hidePastProjects && alloc.project?.endDate && new Date(alloc.project.endDate) < today) return false;
|
if (hidePastProjects && alloc.project?.endDate && new Date(alloc.project.endDate) < today)
|
||||||
if (hideCompletedProjects && alloc.project?.status && ["COMPLETED", "CANCELLED"].includes(alloc.project.status)) return false;
|
return false;
|
||||||
|
if (
|
||||||
|
hideCompletedProjects &&
|
||||||
|
alloc.project?.status &&
|
||||||
|
["COMPLETED", "CANCELLED"].includes(alloc.project.status)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
if (hideDraftProjects && alloc.project?.status === "DRAFT") return false;
|
if (hideDraftProjects && alloc.project?.status === "DRAFT") return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -273,9 +335,7 @@ export function AllocationsClient() {
|
|||||||
const allocationIds = sorted.map((a) => a.id);
|
const allocationIds = sorted.map((a) => a.id);
|
||||||
const allocationMutationIdsByDisplayId = useMemo(
|
const allocationMutationIdsByDisplayId = useMemo(
|
||||||
() =>
|
() =>
|
||||||
new Map(
|
new Map(sorted.map((allocation) => [allocation.id, getPlanningEntryMutationId(allocation)])),
|
||||||
sorted.map((allocation) => [allocation.id, getPlanningEntryMutationId(allocation)]),
|
|
||||||
),
|
|
||||||
[sorted],
|
[sorted],
|
||||||
);
|
);
|
||||||
const selectedMutationIds = useMemo(
|
const selectedMutationIds = useMemo(
|
||||||
@@ -288,16 +348,19 @@ export function AllocationsClient() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ─── View mode: grouped (default) vs flat ──────────────────────────────────
|
// ─── View mode: grouped (default) vs flat ──────────────────────────────────
|
||||||
const [viewMode, setViewMode] = useLocalStorage<"grouped" | "flat">("capakraken:allocations:viewMode", "grouped");
|
const [viewMode, setViewMode] = useLocalStorage<"grouped" | "flat">(
|
||||||
const [collapsedGroups, setCollapsedGroups] = useState<CollapsedAllocationGroups>(
|
"capakraken:allocations:viewMode",
|
||||||
() => createInitialCollapsedAllocationGroups(),
|
"grouped",
|
||||||
|
);
|
||||||
|
const [collapsedGroups, setCollapsedGroups] = useState<CollapsedAllocationGroups>(() =>
|
||||||
|
createInitialCollapsedAllocationGroups(),
|
||||||
);
|
);
|
||||||
// Track expanded project sub-groups: key = "resourceId::projectId"
|
// Track expanded project sub-groups: key = "resourceId::projectId"
|
||||||
const [expandedSubGroups, setExpandedSubGroups] = useState<Set<string>>(new Set());
|
const [expandedSubGroups, setExpandedSubGroups] = useState<Set<string>>(new Set());
|
||||||
const hasEvaluatedInitialVisibility = useRef(false);
|
const hasEvaluatedInitialVisibility = useRef(false);
|
||||||
|
|
||||||
const toggleViewMode = useCallback(() => {
|
const toggleViewMode = useCallback(() => {
|
||||||
setViewMode((prev) => prev === "grouped" ? "flat" : "grouped");
|
setViewMode((prev) => (prev === "grouped" ? "flat" : "grouped"));
|
||||||
}, [setViewMode]);
|
}, [setViewMode]);
|
||||||
|
|
||||||
type ProjectSubGroup = {
|
type ProjectSubGroup = {
|
||||||
@@ -344,7 +407,10 @@ export function AllocationsClient() {
|
|||||||
for (const alloc of group.allocations) {
|
for (const alloc of group.allocations) {
|
||||||
const pid = alloc.project?.id ?? "__no_project__";
|
const pid = alloc.project?.id ?? "__no_project__";
|
||||||
let list = projMap.get(pid);
|
let list = projMap.get(pid);
|
||||||
if (!list) { list = []; projMap.set(pid, list); }
|
if (!list) {
|
||||||
|
list = [];
|
||||||
|
projMap.set(pid, list);
|
||||||
|
}
|
||||||
list.push(alloc);
|
list.push(alloc);
|
||||||
}
|
}
|
||||||
group.projectSubGroups = [...projMap.entries()].map(([pid, allocs]) => {
|
group.projectSubGroups = [...projMap.entries()].map(([pid, allocs]) => {
|
||||||
@@ -364,7 +430,10 @@ export function AllocationsClient() {
|
|||||||
let typicalH = first.hoursPerDay;
|
let typicalH = first.hoursPerDay;
|
||||||
let maxCount = 0;
|
let maxCount = 0;
|
||||||
for (const [h, count] of hpdCounts) {
|
for (const [h, count] of hpdCounts) {
|
||||||
if (count > maxCount) { typicalH = h; maxCount = count; }
|
if (count > maxCount) {
|
||||||
|
typicalH = h;
|
||||||
|
maxCount = count;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
projectId: pid,
|
projectId: pid,
|
||||||
@@ -390,9 +459,12 @@ export function AllocationsClient() {
|
|||||||
|
|
||||||
const groupIds = useMemo(() => groups.map((g) => g.resourceId), [groups]);
|
const groupIds = useMemo(() => groups.map((g) => g.resourceId), [groups]);
|
||||||
|
|
||||||
const toggleGroup = useCallback((resourceId: string) => {
|
const toggleGroup = useCallback(
|
||||||
setCollapsedGroups((prev) => toggleCollapsedAllocationGroup(prev, groupIds, resourceId));
|
(resourceId: string) => {
|
||||||
}, [groupIds]);
|
setCollapsedGroups((prev) => toggleCollapsedAllocationGroup(prev, groupIds, resourceId));
|
||||||
|
},
|
||||||
|
[groupIds],
|
||||||
|
);
|
||||||
|
|
||||||
const collapseAll = useCallback(() => {
|
const collapseAll = useCallback(() => {
|
||||||
setCollapsedGroups(collapseAllAllocationGroups());
|
setCollapsedGroups(collapseAllAllocationGroups());
|
||||||
@@ -423,16 +495,35 @@ export function AllocationsClient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearAll() {
|
function clearAll() {
|
||||||
setAllocFilters({ projectId: "", resourceId: "", status: "", hidePast: "false", hideCompleted: "false", hideDraft: "false" });
|
setAllocFilters({
|
||||||
|
projectId: "",
|
||||||
|
resourceId: "",
|
||||||
|
status: "",
|
||||||
|
hidePast: "false",
|
||||||
|
hideCompleted: "false",
|
||||||
|
hideDraft: "false",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const chips = [
|
const chips = [
|
||||||
...(filterProjectId ? [{ label: `Project filter active`, onRemove: () => setFilterProjectId("") }] : []),
|
...(filterProjectId
|
||||||
...(filterResourceId ? [{ label: `Resource filter active`, onRemove: () => setFilterResourceId("") }] : []),
|
? [{ label: `Project filter active`, onRemove: () => setFilterProjectId("") }]
|
||||||
...(filterStatus ? [{ label: `Status: ${filterStatus}`, onRemove: () => setFilterStatus("") }] : []),
|
: []),
|
||||||
...(hidePastProjects ? [{ label: "Hiding past projects", onRemove: () => setHidePastProjects(false) }] : []),
|
...(filterResourceId
|
||||||
...(hideCompletedProjects ? [{ label: "Hiding completed/cancelled", onRemove: () => setHideCompletedProjects(false) }] : []),
|
? [{ label: `Resource filter active`, onRemove: () => setFilterResourceId("") }]
|
||||||
...(hideDraftProjects ? [{ label: "Hiding draft projects", onRemove: () => setHideDraftProjects(false) }] : []),
|
: []),
|
||||||
|
...(filterStatus
|
||||||
|
? [{ label: `Status: ${filterStatus}`, onRemove: () => setFilterStatus("") }]
|
||||||
|
: []),
|
||||||
|
...(hidePastProjects
|
||||||
|
? [{ label: "Hiding past projects", onRemove: () => setHidePastProjects(false) }]
|
||||||
|
: []),
|
||||||
|
...(hideCompletedProjects
|
||||||
|
? [{ label: "Hiding completed/cancelled", onRemove: () => setHideCompletedProjects(false) }]
|
||||||
|
: []),
|
||||||
|
...(hideDraftProjects
|
||||||
|
? [{ label: "Hiding draft projects", onRemove: () => setHideDraftProjects(false) }]
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const emptyState = getAllocationEmptyState({
|
const emptyState = getAllocationEmptyState({
|
||||||
@@ -518,41 +609,80 @@ export function AllocationsClient() {
|
|||||||
switch (col.key) {
|
switch (col.key) {
|
||||||
case "resource":
|
case "resource":
|
||||||
return (
|
return (
|
||||||
<td key={col.key} className="px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">
|
<td
|
||||||
{isGrouped ? <span className="text-gray-400 dark:text-gray-500">↳</span> : (alloc.resource?.displayName ?? "—")}
|
key={col.key}
|
||||||
|
className="px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
{isGrouped ? (
|
||||||
|
<span className="text-gray-400 dark:text-gray-500">↳</span>
|
||||||
|
) : (
|
||||||
|
(alloc.resource?.displayName ?? "—")
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
case "project":
|
case "project":
|
||||||
return (
|
return (
|
||||||
<td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
|
<td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
|
||||||
{alloc.project ? (
|
{alloc.project ? (
|
||||||
<><span className="font-mono text-xs">{alloc.project.shortCode}</span> {alloc.project.name}</>
|
<>
|
||||||
) : "—"}
|
<span className="font-mono text-xs">{alloc.project.shortCode}</span>{" "}
|
||||||
|
{alloc.project.name}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
case "role":
|
case "role":
|
||||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">{alloc.role}</td>;
|
return (
|
||||||
|
<td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{alloc.role}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
case "dates":
|
case "dates":
|
||||||
return <td key={col.key} className="whitespace-nowrap px-4 py-3 text-xs text-gray-500 dark:text-gray-400">{formatPeriod(alloc)}</td>;
|
return (
|
||||||
|
<td
|
||||||
|
key={col.key}
|
||||||
|
className="whitespace-nowrap px-4 py-3 text-xs text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{formatPeriod(alloc)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
case "hoursPerDay":
|
case "hoursPerDay":
|
||||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">{alloc.hoursPerDay}h</td>;
|
return (
|
||||||
|
<td key={col.key} className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
|
||||||
|
{alloc.hoursPerDay}h
|
||||||
|
</td>
|
||||||
|
);
|
||||||
case "cost":
|
case "cost":
|
||||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">{(alloc.dailyCostCents / 100).toFixed(0)} €</td>;
|
return (
|
||||||
|
<td key={col.key} className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
|
||||||
|
{(alloc.dailyCostCents / 100).toFixed(0)} €
|
||||||
|
</td>
|
||||||
|
);
|
||||||
case "status":
|
case "status":
|
||||||
return (
|
return (
|
||||||
<td key={col.key} className="px-4 py-3">
|
<td key={col.key} className="px-4 py-3">
|
||||||
<span className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${STATUS_BADGE[alloc.status] ?? "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400"}`}>
|
<span
|
||||||
|
className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${STATUS_BADGE[alloc.status] ?? "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400"}`}
|
||||||
|
>
|
||||||
{alloc.status}
|
{alloc.status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">—</td>;
|
return (
|
||||||
|
<td key={col.key} className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
—
|
||||||
|
</td>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<button type="button" onClick={() => openEdit(alloc)} className="app-action-edit">Edit</button>
|
<button type="button" onClick={() => openEdit(alloc)} className="app-action-edit">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setConfirmDelete({ single: alloc })}
|
onClick={() => setConfirmDelete({ single: alloc })}
|
||||||
@@ -569,7 +699,11 @@ export function AllocationsClient() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-page space-y-5 pb-24">
|
<div className="app-page space-y-5 pb-24">
|
||||||
<SuccessToast show={showStatusToast} message="Allocation status updated" onDone={() => setShowStatusToast(false)} />
|
<SuccessToast
|
||||||
|
show={showStatusToast}
|
||||||
|
message="Allocation status updated"
|
||||||
|
onDone={() => setShowStatusToast(false)}
|
||||||
|
/>
|
||||||
<div className="app-page-header gap-4">
|
<div className="app-page-header gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="app-page-title">Allocations</h1>
|
<h1 className="app-page-title">Allocations</h1>
|
||||||
@@ -578,7 +712,7 @@ export function AllocationsClient() {
|
|||||||
? "Loading…"
|
? "Loading…"
|
||||||
: allocationQueryFailure
|
: allocationQueryFailure
|
||||||
? allocationQueryFailure.title
|
? allocationQueryFailure.title
|
||||||
: `${filteredAllocations.length} assignment${filteredAllocations.length !== 1 ? "s" : ""}${filteredDemands.length > 0 ? ` · ${filteredDemands.length} open demand${filteredDemands.length !== 1 ? "s" : ""}` : ""}`}
|
: `${filteredAllocations.length} assignment${filteredAllocations.length !== 1 ? "s" : ""}${filteredDemands.length > 0 ? ` · ${filteredDemands.length} open demand${filteredDemands.length !== 1 ? "s" : ""}` : ""}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -629,7 +763,9 @@ export function AllocationsClient() {
|
|||||||
>
|
>
|
||||||
<option value="">All Statuses</option>
|
<option value="">All Statuses</option>
|
||||||
{ALL_ALLOC_STATUSES.map((s) => (
|
{ALL_ALLOC_STATUSES.map((s) => (
|
||||||
<option key={s.value} value={s.value}>{s.label}</option>
|
<option key={s.value} value={s.value}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
@@ -678,7 +814,12 @@ export function AllocationsClient() {
|
|||||||
className={`px-2.5 py-2 text-sm transition-colors ${viewMode === "grouped" ? "bg-brand-600 text-white" : "bg-white text-gray-600 hover:bg-gray-50 dark:bg-gray-900 dark:text-gray-300 dark:hover:bg-gray-800"}`}
|
className={`px-2.5 py-2 text-sm transition-colors ${viewMode === "grouped" ? "bg-brand-600 text-white" : "bg-white text-gray-600 hover:bg-gray-50 dark:bg-gray-900 dark:text-gray-300 dark:hover:bg-gray-800"}`}
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -688,7 +829,12 @@ export function AllocationsClient() {
|
|||||||
className={`px-2.5 py-2 text-sm transition-colors ${viewMode === "flat" ? "bg-brand-600 text-white" : "bg-white text-gray-600 hover:bg-gray-50 dark:bg-gray-900 dark:text-gray-300 dark:hover:bg-gray-800"}`}
|
className={`px-2.5 py-2 text-sm transition-colors ${viewMode === "flat" ? "bg-brand-600 text-white" : "bg-white text-gray-600 hover:bg-gray-50 dark:bg-gray-900 dark:text-gray-300 dark:hover:bg-gray-800"}`}
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 6h16M4 10h16M4 14h16M4 18h16"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -701,7 +847,12 @@ export function AllocationsClient() {
|
|||||||
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 dark:border-gray-600 px-2.5 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 transition hover:border-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
|
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 dark:border-gray-600 px-2.5 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 transition hover:border-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Export
|
Export
|
||||||
</button>
|
</button>
|
||||||
@@ -709,11 +860,19 @@ export function AllocationsClient() {
|
|||||||
|
|
||||||
{viewMode === "grouped" && groups.length > 1 && (
|
{viewMode === "grouped" && groups.length > 1 && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button type="button" onClick={expandAll} className="text-xs text-brand-600 hover:text-brand-800 dark:text-brand-400 dark:hover:text-brand-200 whitespace-nowrap">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={expandAll}
|
||||||
|
className="text-xs text-brand-600 hover:text-brand-800 dark:text-brand-400 dark:hover:text-brand-200 whitespace-nowrap"
|
||||||
|
>
|
||||||
Expand all
|
Expand all
|
||||||
</button>
|
</button>
|
||||||
<span className="text-gray-300 dark:text-gray-600">|</span>
|
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||||
<button type="button" onClick={collapseAll} className="text-xs text-brand-600 hover:text-brand-800 dark:text-brand-400 dark:hover:text-brand-200 whitespace-nowrap">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={collapseAll}
|
||||||
|
className="text-xs text-brand-600 hover:text-brand-800 dark:text-brand-400 dark:hover:text-brand-200 whitespace-nowrap"
|
||||||
|
>
|
||||||
Collapse all
|
Collapse all
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -744,16 +903,34 @@ export function AllocationsClient() {
|
|||||||
</th>
|
</th>
|
||||||
{visibleColumns.map((col) => {
|
{visibleColumns.map((col) => {
|
||||||
const tooltips: Record<string, { tip: string; width?: string }> = {
|
const tooltips: Record<string, { tip: string; width?: string }> = {
|
||||||
resource: { tip: "The person assigned to this time block. Grouped view clusters entries by resource." },
|
resource: {
|
||||||
project: { tip: "The project this allocation belongs to, identified by short code and name." },
|
tip: "The person assigned to this time block. Grouped view clusters entries by resource.",
|
||||||
role: { tip: "The role this allocation was created for. May differ from the resource's primary role." },
|
},
|
||||||
|
project: {
|
||||||
|
tip: "The project this allocation belongs to, identified by short code and name.",
|
||||||
|
},
|
||||||
|
role: {
|
||||||
|
tip: "The role this allocation was created for. May differ from the resource's primary role.",
|
||||||
|
},
|
||||||
dates: { tip: "Start and end date of this allocation period (inclusive)." },
|
dates: { tip: "Start and end date of this allocation period (inclusive)." },
|
||||||
hoursPerDay: { tip: "Planned working hours per calendar day for this allocation." },
|
hoursPerDay: {
|
||||||
cost: { tip: "Daily cost = resource LCR x hours per day. Total cost = daily cost x working days.", width: "w-72" },
|
tip: "Planned working hours per calendar day for this allocation.",
|
||||||
status: { tip: "PROPOSED = requested · CONFIRMED = approved · ACTIVE = ongoing · COMPLETED = finished · CANCELLED = removed.", width: "w-72" },
|
},
|
||||||
|
cost: {
|
||||||
|
tip: "Daily cost = resource LCR x hours per day. Total cost = daily cost x working days.",
|
||||||
|
width: "w-72",
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
tip: "PROPOSED = requested · CONFIRMED = approved · ACTIVE = ongoing · COMPLETED = finished · CANCELLED = removed.",
|
||||||
|
width: "w-72",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const t = tooltips[col.key];
|
const t = tooltips[col.key];
|
||||||
const fieldMap: Record<string, string> = { dates: "startDate", hoursPerDay: "hoursPerDay", cost: "dailyCostCents" };
|
const fieldMap: Record<string, string> = {
|
||||||
|
dates: "startDate",
|
||||||
|
hoursPerDay: "hoursPerDay",
|
||||||
|
cost: "dailyCostCents",
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<SortableColumnHeader
|
<SortableColumnHeader
|
||||||
key={col.key}
|
key={col.key}
|
||||||
@@ -773,15 +950,28 @@ export function AllocationsClient() {
|
|||||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={totalColSpan} className="py-12 text-center text-sm text-gray-500 dark:text-gray-400">Loading allocations…</td>
|
<td
|
||||||
|
colSpan={totalColSpan}
|
||||||
|
className="py-12 text-center text-sm text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
Loading allocations…
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && allocationQueryFailure && (
|
{!isLoading && allocationQueryFailure && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={totalColSpan} className="py-12 text-center text-sm text-gray-500 dark:text-gray-400">
|
<td
|
||||||
<div data-testid={allocationQueryFailure.testId} className="flex flex-col items-center gap-2">
|
colSpan={totalColSpan}
|
||||||
<p className="font-medium text-gray-700 dark:text-gray-200">{allocationQueryFailure.title}</p>
|
className="py-12 text-center text-sm text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-testid={allocationQueryFailure.testId}
|
||||||
|
className="flex flex-col items-center gap-2"
|
||||||
|
>
|
||||||
|
<p className="font-medium text-gray-700 dark:text-gray-200">
|
||||||
|
{allocationQueryFailure.title}
|
||||||
|
</p>
|
||||||
<p>{allocationQueryFailure.detail}</p>
|
<p>{allocationQueryFailure.detail}</p>
|
||||||
{allocationQueryFailure.actionLabel && allocationQueryFailure.actionHref && (
|
{allocationQueryFailure.actionLabel && allocationQueryFailure.actionHref && (
|
||||||
<a
|
<a
|
||||||
@@ -817,15 +1007,21 @@ export function AllocationsClient() {
|
|||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && !allocationQueryFailure && viewMode === "flat" &&
|
{!isLoading &&
|
||||||
|
!allocationQueryFailure &&
|
||||||
|
viewMode === "flat" &&
|
||||||
sorted.map((alloc, index) => renderAllocRow(alloc, false, index))}
|
sorted.map((alloc, index) => renderAllocRow(alloc, false, index))}
|
||||||
|
|
||||||
{!isLoading && !allocationQueryFailure && viewMode === "grouped" &&
|
{!isLoading &&
|
||||||
|
!allocationQueryFailure &&
|
||||||
|
viewMode === "grouped" &&
|
||||||
groups.map((group) => {
|
groups.map((group) => {
|
||||||
const isCollapsed = collapsedGroups === "all" || collapsedGroups.has(group.resourceId);
|
const isCollapsed =
|
||||||
|
collapsedGroups === "all" || collapsedGroups.has(group.resourceId);
|
||||||
const groupAllocIds = group.allocations.map((a) => a.id);
|
const groupAllocIds = group.allocations.map((a) => a.id);
|
||||||
const allGroupSelected = selection.isAllSelected(groupAllocIds);
|
const allGroupSelected = selection.isAllSelected(groupAllocIds);
|
||||||
const groupIndeterminate = !allGroupSelected && groupAllocIds.some((id) => selection.selectedIds.has(id));
|
const groupIndeterminate =
|
||||||
|
!allGroupSelected && groupAllocIds.some((id) => selection.selectedIds.has(id));
|
||||||
return (
|
return (
|
||||||
<GroupRows key={group.resourceId}>
|
<GroupRows key={group.resourceId}>
|
||||||
{/* Group header */}
|
{/* Group header */}
|
||||||
@@ -833,7 +1029,12 @@ export function AllocationsClient() {
|
|||||||
data-testid="allocation-group-header"
|
data-testid="allocation-group-header"
|
||||||
className="bg-gray-50 dark:bg-gray-800/50 cursor-pointer select-none hover:bg-gray-100 dark:hover:bg-gray-800/80 transition-colors"
|
className="bg-gray-50 dark:bg-gray-800/50 cursor-pointer select-none hover:bg-gray-100 dark:hover:bg-gray-800/80 transition-colors"
|
||||||
onClick={() => toggleGroup(group.resourceId)}
|
onClick={() => toggleGroup(group.resourceId)}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleGroup(group.resourceId); } }}
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleGroup(group.resourceId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
role="button"
|
role="button"
|
||||||
aria-expanded={!isCollapsed}
|
aria-expanded={!isCollapsed}
|
||||||
@@ -842,7 +1043,9 @@ export function AllocationsClient() {
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={allGroupSelected}
|
checked={allGroupSelected}
|
||||||
ref={(el) => { if (el) el.indeterminate = groupIndeterminate; }}
|
ref={(el) => {
|
||||||
|
if (el) el.indeterminate = groupIndeterminate;
|
||||||
|
}}
|
||||||
onChange={() => selection.toggleAll(groupAllocIds)}
|
onChange={() => selection.toggleAll(groupAllocIds)}
|
||||||
className="rounded border-gray-300 dark:border-gray-600"
|
className="rounded border-gray-300 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
@@ -872,64 +1075,88 @@ export function AllocationsClient() {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/* Project sub-groups within person */}
|
{/* Project sub-groups within person */}
|
||||||
{!isCollapsed && group.projectSubGroups.map((subGroup) => {
|
{!isCollapsed &&
|
||||||
const subKey = `${group.resourceId}::${subGroup.projectId}`;
|
group.projectSubGroups.map((subGroup) => {
|
||||||
const isSubExpanded = expandedSubGroups.has(subKey);
|
const subKey = `${group.resourceId}::${subGroup.projectId}`;
|
||||||
|
const isSubExpanded = expandedSubGroups.has(subKey);
|
||||||
|
|
||||||
// Single allocation for this project — render directly, no sub-group header
|
// Single allocation for this project — render directly, no sub-group header
|
||||||
if (subGroup.allocations.length === 1) {
|
if (subGroup.allocations.length === 1) {
|
||||||
return <GroupRows key={subKey}>{renderAllocRow(subGroup.allocations[0]!, true, 0)}</GroupRows>;
|
return (
|
||||||
}
|
<GroupRows key={subKey}>
|
||||||
|
{renderAllocRow(subGroup.allocations[0]!, true, 0)}
|
||||||
|
</GroupRows>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Multiple allocations — show collapsible project sub-group
|
// Multiple allocations — show collapsible project sub-group
|
||||||
return (
|
return (
|
||||||
<GroupRows key={subKey}>
|
<GroupRows key={subKey}>
|
||||||
<tr
|
<tr
|
||||||
data-testid="allocation-subgroup-header"
|
data-testid="allocation-subgroup-header"
|
||||||
className="bg-gray-25 dark:bg-gray-850/30 cursor-pointer select-none hover:bg-gray-100/60 dark:hover:bg-gray-800/40 transition-colors"
|
className="bg-gray-25 dark:bg-gray-850/30 cursor-pointer select-none hover:bg-gray-100/60 dark:hover:bg-gray-800/40 transition-colors"
|
||||||
onClick={() => toggleSubGroup(group.resourceId, subGroup.projectId)}
|
onClick={() => toggleSubGroup(group.resourceId, subGroup.projectId)}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
role="button"
|
role="button"
|
||||||
aria-expanded={isSubExpanded}
|
aria-expanded={isSubExpanded}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleSubGroup(group.resourceId, subGroup.projectId); } }}
|
onKeyDown={(e) => {
|
||||||
>
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
<td className="px-4 py-2" onClick={(e) => e.stopPropagation()}>
|
e.preventDefault();
|
||||||
<input
|
toggleSubGroup(group.resourceId, subGroup.projectId);
|
||||||
type="checkbox"
|
}
|
||||||
checked={selection.isAllSelected(subGroup.allocations.map((a) => a.id))}
|
}}
|
||||||
ref={(el) => {
|
>
|
||||||
if (el) {
|
<td className="px-4 py-2" onClick={(e) => e.stopPropagation()}>
|
||||||
const ids = subGroup.allocations.map((a) => a.id);
|
<input
|
||||||
el.indeterminate = !selection.isAllSelected(ids) && ids.some((id) => selection.selectedIds.has(id));
|
type="checkbox"
|
||||||
|
checked={selection.isAllSelected(
|
||||||
|
subGroup.allocations.map((a) => a.id),
|
||||||
|
)}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) {
|
||||||
|
const ids = subGroup.allocations.map((a) => a.id);
|
||||||
|
el.indeterminate =
|
||||||
|
!selection.isAllSelected(ids) &&
|
||||||
|
ids.some((id) => selection.selectedIds.has(id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onChange={() =>
|
||||||
|
selection.toggleAll(subGroup.allocations.map((a) => a.id))
|
||||||
}
|
}
|
||||||
}}
|
className="rounded border-gray-300 dark:border-gray-600"
|
||||||
onChange={() => selection.toggleAll(subGroup.allocations.map((a) => a.id))}
|
/>
|
||||||
className="rounded border-gray-300 dark:border-gray-600"
|
</td>
|
||||||
/>
|
<td colSpan={visibleColumns.length + 1} className="px-4 py-2">
|
||||||
</td>
|
<div className="flex items-center gap-2 pl-4">
|
||||||
<td colSpan={visibleColumns.length + 1} className="px-4 py-2">
|
<span className="text-gray-400 dark:text-gray-500 text-xs">
|
||||||
<div className="flex items-center gap-2 pl-4">
|
{isSubExpanded ? "▾" : "▸"}
|
||||||
<span className="text-gray-400 dark:text-gray-500 text-xs">
|
</span>
|
||||||
{isSubExpanded ? "▾" : "▸"}
|
<span className="font-mono text-xs text-gray-400 dark:text-gray-500">
|
||||||
</span>
|
{subGroup.projectCode}
|
||||||
<span className="font-mono text-xs text-gray-400 dark:text-gray-500">{subGroup.projectCode}</span>
|
</span>
|
||||||
<span className="text-sm text-gray-700 dark:text-gray-300">{subGroup.projectName}</span>
|
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
{subGroup.projectName}
|
||||||
{formatDate(subGroup.earliestStart)} → {formatDate(subGroup.latestEnd)}
|
</span>
|
||||||
</span>
|
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
{formatDate(subGroup.earliestStart)} →{" "}
|
||||||
{subGroup.typicalHoursPerDay}h/day
|
{formatDate(subGroup.latestEnd)}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center rounded-full bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-gray-700 dark:text-gray-400">
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{subGroup.allocations.length}
|
{subGroup.typicalHoursPerDay}h/day
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span className="inline-flex items-center rounded-full bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-gray-700 dark:text-gray-400">
|
||||||
</td>
|
{subGroup.allocations.length}
|
||||||
</tr>
|
</span>
|
||||||
{isSubExpanded && subGroup.allocations.map((alloc, idx) => renderAllocRow(alloc, true, idx))}
|
</div>
|
||||||
</GroupRows>
|
</td>
|
||||||
);
|
</tr>
|
||||||
})}
|
{isSubExpanded &&
|
||||||
|
subGroup.allocations.map((alloc, idx) =>
|
||||||
|
renderAllocRow(alloc, true, idx),
|
||||||
|
)}
|
||||||
|
</GroupRows>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</GroupRows>
|
</GroupRows>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -941,7 +1168,9 @@ export function AllocationsClient() {
|
|||||||
<div className="app-surface mt-6 overflow-hidden border-amber-200 dark:border-amber-900/70">
|
<div className="app-surface mt-6 overflow-hidden border-amber-200 dark:border-amber-900/70">
|
||||||
<div className="flex items-center justify-between border-b border-amber-200 bg-amber-50/70 px-4 py-3 dark:border-amber-900/70 dark:bg-amber-950/20">
|
<div className="flex items-center justify-between border-b border-amber-200 bg-amber-50/70 px-4 py-3 dark:border-amber-900/70 dark:bg-amber-950/20">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold text-amber-900 dark:text-amber-200">Open Demands</h2>
|
<h2 className="text-sm font-semibold text-amber-900 dark:text-amber-200">
|
||||||
|
Open Demands
|
||||||
|
</h2>
|
||||||
<p className="text-xs text-amber-700 dark:text-amber-300/80">
|
<p className="text-xs text-amber-700 dark:text-amber-300/80">
|
||||||
Placeholder demand rows not yet assigned to a resource.
|
Placeholder demand rows not yet assigned to a resource.
|
||||||
</p>
|
</p>
|
||||||
@@ -959,18 +1188,27 @@ export function AllocationsClient() {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||||
{demand.project ? (
|
{demand.project ? (
|
||||||
<><span className="font-mono text-xs">{demand.project.shortCode}</span> {demand.project.name}</>
|
<>
|
||||||
) : "Unknown project"}
|
<span className="font-mono text-xs">{demand.project.shortCode}</span>{" "}
|
||||||
|
{demand.project.name}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Unknown project"
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
{(demand.role ?? "Placeholder role")} · {formatPeriod(demand)} · {demand.hoursPerDay}h/day
|
{demand.role ?? "Placeholder role"} · {formatPeriod(demand)} ·{" "}
|
||||||
|
{demand.hoursPerDay}h/day
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-shrink-0 items-center gap-4">
|
<div className="flex flex-shrink-0 items-center gap-4">
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-xs uppercase tracking-wide text-amber-700 dark:text-amber-300">Unfilled</div>
|
<div className="text-xs uppercase tracking-wide text-amber-700 dark:text-amber-300">
|
||||||
|
Unfilled
|
||||||
|
</div>
|
||||||
<div className="text-sm font-semibold text-amber-900 dark:text-amber-200">
|
<div className="text-sm font-semibold text-amber-900 dark:text-amber-200">
|
||||||
{demand.unfilledHeadcount ?? demand.headcount} / {demand.requestedHeadcount ?? demand.headcount}
|
{demand.unfilledHeadcount ?? demand.headcount} /{" "}
|
||||||
|
{demand.requestedHeadcount ?? demand.headcount}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -999,9 +1237,17 @@ export function AllocationsClient() {
|
|||||||
|
|
||||||
{/* Batch Status Picker */}
|
{/* Batch Status Picker */}
|
||||||
{batchStatusPicker && (
|
{batchStatusPicker && (
|
||||||
<div className="fixed inset-0 bg-black/30 z-50 flex items-center justify-center p-4" onClick={() => setBatchStatusPicker(false)}>
|
<div
|
||||||
<div className="min-w-[220px] rounded-2xl bg-white p-5 shadow-2xl dark:bg-gray-900" onClick={(e) => e.stopPropagation()}>
|
className="fixed inset-0 bg-black/30 z-50 flex items-center justify-center p-4"
|
||||||
<h3 className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">Set status for {selection.count} allocations</h3>
|
onClick={() => setBatchStatusPicker(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="min-w-[220px] rounded-2xl bg-white p-5 shadow-2xl dark:bg-gray-900"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Set status for {selection.count} allocations
|
||||||
|
</h3>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{ALL_ALLOC_STATUSES.map((s) => (
|
{ALL_ALLOC_STATUSES.map((s) => (
|
||||||
<button
|
<button
|
||||||
@@ -1013,7 +1259,9 @@ export function AllocationsClient() {
|
|||||||
}}
|
}}
|
||||||
className="w-full rounded-xl px-3 py-2 text-left text-sm transition-colors hover:bg-gray-50 dark:hover:bg-gray-800"
|
className="w-full rounded-xl px-3 py-2 text-left text-sm transition-colors hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||||
>
|
>
|
||||||
<span className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${STATUS_BADGE[s.value]}`}>
|
<span
|
||||||
|
className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${STATUS_BADGE[s.value]}`}
|
||||||
|
>
|
||||||
{s.label}
|
{s.label}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -1060,7 +1308,10 @@ export function AllocationsClient() {
|
|||||||
message={`Set ${confirmBatchStatus.ids.length} allocation${confirmBatchStatus.ids.length !== 1 ? "s" : ""} to "${confirmBatchStatus.status}"?`}
|
message={`Set ${confirmBatchStatus.ids.length} allocation${confirmBatchStatus.ids.length !== 1 ? "s" : ""} to "${confirmBatchStatus.status}"?`}
|
||||||
confirmLabel="Update"
|
confirmLabel="Update"
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
batchStatusMutation.mutate({ ids: confirmBatchStatus.ids, status: confirmBatchStatus.status as never });
|
batchStatusMutation.mutate({
|
||||||
|
ids: confirmBatchStatus.ids,
|
||||||
|
status: confirmBatchStatus.status as never,
|
||||||
|
});
|
||||||
setConfirmBatchStatus(null);
|
setConfirmBatchStatus(null);
|
||||||
}}
|
}}
|
||||||
onCancel={() => setConfirmBatchStatus(null)}
|
onCancel={() => setConfirmBatchStatus(null)}
|
||||||
@@ -1097,7 +1348,11 @@ export function AllocationsClient() {
|
|||||||
count={selection.count}
|
count={selection.count}
|
||||||
isPending={batchDateShiftMutation.isPending}
|
isPending={batchDateShiftMutation.isPending}
|
||||||
onConfirm={(daysDelta) =>
|
onConfirm={(daysDelta) =>
|
||||||
batchDateShiftMutation.mutate({ allocationIds: selectedMutationIds, daysDelta, mode: "move" })
|
batchDateShiftMutation.mutate({
|
||||||
|
allocationIds: selectedMutationIds,
|
||||||
|
daysDelta,
|
||||||
|
mode: "move",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
onClose={() => setShowDateShiftModal(false)}
|
onClose={() => setShowDateShiftModal(false)}
|
||||||
/>
|
/>
|
||||||
@@ -1105,7 +1360,11 @@ export function AllocationsClient() {
|
|||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
{modalOpen && (
|
{modalOpen && (
|
||||||
<AllocationModal allocation={editingAllocation} onClose={closeModal} onSuccess={closeModal} />
|
<AllocationModal
|
||||||
|
allocation={editingAllocation}
|
||||||
|
onClose={closeModal}
|
||||||
|
onSuccess={closeModal}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import type { FormEvent } from "react";
|
import type { FormEvent } from "react";
|
||||||
import { BlueprintTarget } from "@capakraken/shared";
|
import type { BlueprintTarget } from "@capakraken/shared";
|
||||||
import type { BlueprintFieldDefinition } from "@capakraken/shared";
|
import type { BlueprintFieldDefinition } from "@capakraken/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { BlueprintFieldCatalog } from "./BlueprintFieldCatalog.js";
|
import { BlueprintFieldCatalog } from "./BlueprintFieldCatalog.js";
|
||||||
@@ -68,27 +68,59 @@ function NewBlueprintModal({ onClose, onCreated }: NewBlueprintModalProps) {
|
|||||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md mx-4">
|
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md mx-4">
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">New Blueprint</h2>
|
<h2 className="text-lg font-semibold text-gray-900">New Blueprint</h2>
|
||||||
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl leading-none" aria-label="Close">×</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 text-2xl leading-none"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
|
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-sm font-medium text-gray-700">Name <span className="text-red-500">*</span></label>
|
<label className="text-sm font-medium text-gray-700">
|
||||||
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Resource Extended Fields" className="app-input" autoFocus />
|
Name <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g. Resource Extended Fields"
|
||||||
|
className="app-input"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-sm font-medium text-gray-700">Description</label>
|
<label className="text-sm font-medium text-gray-700">Description</label>
|
||||||
<textarea value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Optional description" className="app-input resize-none" rows={2} />
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Optional description"
|
||||||
|
className="app-input resize-none"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-sm font-medium text-gray-700">Target</label>
|
<label className="text-sm font-medium text-gray-700">Target</label>
|
||||||
<select value={target} onChange={(e) => setTarget(e.target.value as BlueprintTargetValue)} className="app-input">
|
<select
|
||||||
|
value={target}
|
||||||
|
onChange={(e) => setTarget(e.target.value as BlueprintTargetValue)}
|
||||||
|
className="app-input"
|
||||||
|
>
|
||||||
<option value="RESOURCE">Resource</option>
|
<option value="RESOURCE">Resource</option>
|
||||||
<option value="PROJECT">Project</option>
|
<option value="PROJECT">Project</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">{error}</p>}
|
{error && (
|
||||||
|
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="flex items-center justify-end gap-3 pt-2">
|
<div className="flex items-center justify-end gap-3 pt-2">
|
||||||
<button type="button" onClick={onClose} className={BTN_SECONDARY}>Cancel</button>
|
<button type="button" onClick={onClose} className={BTN_SECONDARY}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
<button type="submit" disabled={createMutation.isPending} className={BTN_PRIMARY}>
|
<button type="submit" disabled={createMutation.isPending} className={BTN_PRIMARY}>
|
||||||
{createMutation.isPending ? "Creating…" : "Create Blueprint"}
|
{createMutation.isPending ? "Creating…" : "Create Blueprint"}
|
||||||
</button>
|
</button>
|
||||||
@@ -128,14 +160,20 @@ function BlueprintCard({
|
|||||||
isSelected,
|
isSelected,
|
||||||
onToggleSelect,
|
onToggleSelect,
|
||||||
}: BlueprintCardProps) {
|
}: BlueprintCardProps) {
|
||||||
const fieldDefs = Array.isArray(blueprint.fieldDefs) ? (blueprint.fieldDefs as BlueprintFieldDefinition[]) : [];
|
const fieldDefs = Array.isArray(blueprint.fieldDefs)
|
||||||
const rolePresets = Array.isArray(blueprint.rolePresets) ? (blueprint.rolePresets as unknown[]) : [];
|
? (blueprint.fieldDefs as BlueprintFieldDefinition[])
|
||||||
|
: [];
|
||||||
|
const rolePresets = Array.isArray(blueprint.rolePresets)
|
||||||
|
? (blueprint.rolePresets as unknown[])
|
||||||
|
: [];
|
||||||
const fieldCount = fieldDefs.length;
|
const fieldCount = fieldDefs.length;
|
||||||
const presetCount = rolePresets.length;
|
const presetCount = rolePresets.length;
|
||||||
const isProject = blueprint.target === "PROJECT";
|
const isProject = blueprint.target === "PROJECT";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`bg-white rounded-xl border p-5 flex flex-col gap-3 hover:shadow-sm transition-shadow ${isSelected ? "border-brand-400 bg-brand-50" : "border-gray-200"}`}>
|
<div
|
||||||
|
className={`bg-white rounded-xl border p-5 flex flex-col gap-3 hover:shadow-sm transition-shadow ${isSelected ? "border-brand-400 bg-brand-50" : "border-gray-200"}`}
|
||||||
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex items-start gap-3 flex-1 min-w-0">
|
<div className="flex items-start gap-3 flex-1 min-w-0">
|
||||||
<input
|
<input
|
||||||
@@ -151,29 +189,45 @@ function BlueprintCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={`shrink-0 inline-block px-2 py-0.5 text-xs rounded-full font-medium ${isProject ? "bg-purple-50 text-purple-700" : "bg-blue-50 text-blue-700"}`}>
|
<span
|
||||||
|
className={`shrink-0 inline-block px-2 py-0.5 text-xs rounded-full font-medium ${isProject ? "bg-purple-50 text-purple-700" : "bg-blue-50 text-blue-700"}`}
|
||||||
|
>
|
||||||
{blueprint.target}
|
{blueprint.target}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3 text-sm text-gray-500">
|
<div className="flex flex-wrap gap-3 text-sm text-gray-500">
|
||||||
<span>{fieldCount === 0 ? "No fields" : `${fieldCount} field${fieldCount === 1 ? "" : "s"}`}</span>
|
<span>
|
||||||
|
{fieldCount === 0 ? "No fields" : `${fieldCount} field${fieldCount === 1 ? "" : "s"}`}
|
||||||
|
</span>
|
||||||
{isProject && (
|
{isProject && (
|
||||||
<span className={presetCount > 0 ? "text-brand-600 font-medium" : ""}>
|
<span className={presetCount > 0 ? "text-brand-600 font-medium" : ""}>
|
||||||
{presetCount === 0 ? "No staffing presets" : `${presetCount} staffing preset${presetCount === 1 ? "" : "s"}`}
|
{presetCount === 0
|
||||||
|
? "No staffing presets"
|
||||||
|
: `${presetCount} staffing preset${presetCount === 1 ? "" : "s"}`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{blueprint.isGlobal && (
|
{blueprint.isGlobal && (
|
||||||
<span className="inline-block px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-700 font-medium">Global</span>
|
<span className="inline-block px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-700 font-medium">
|
||||||
|
Global
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2 pt-1 border-t border-gray-100">
|
<div className="flex flex-wrap items-center gap-2 pt-1 border-t border-gray-100">
|
||||||
<button type="button" onClick={onEditFields} className="px-3 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium transition-colors">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEditFields}
|
||||||
|
className="px-3 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
Edit Fields
|
Edit Fields
|
||||||
</button>
|
</button>
|
||||||
{isProject && (
|
{isProject && (
|
||||||
<button type="button" onClick={onEditStaffing} className="px-3 py-1.5 border border-brand-300 text-brand-700 rounded-lg hover:bg-brand-50 text-sm font-medium transition-colors">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEditStaffing}
|
||||||
|
className="px-3 py-1.5 border border-brand-300 text-brand-700 rounded-lg hover:bg-brand-50 text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
Edit Staffing Presets
|
Edit Staffing Presets
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -185,7 +239,11 @@ function BlueprintCard({
|
|||||||
? "border border-amber-300 text-amber-700 hover:bg-amber-50"
|
? "border border-amber-300 text-amber-700 hover:bg-amber-50"
|
||||||
: "border border-gray-200 text-gray-500 hover:bg-gray-50"
|
: "border border-gray-200 text-gray-500 hover:bg-gray-50"
|
||||||
}`}
|
}`}
|
||||||
title={blueprint.isGlobal ? "Remove from global columns" : "Make fields available as global columns"}
|
title={
|
||||||
|
blueprint.isGlobal
|
||||||
|
? "Remove from global columns"
|
||||||
|
: "Make fields available as global columns"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{blueprint.isGlobal ? "Unglobalize" : "Make Global"}
|
{blueprint.isGlobal ? "Unglobalize" : "Make Global"}
|
||||||
</button>
|
</button>
|
||||||
@@ -227,11 +285,16 @@ export function BlueprintsClient() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
selection.clear();
|
selection.clear();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [targetFilter]);
|
}, [targetFilter]);
|
||||||
|
|
||||||
const blueprints: BlueprintRow[] = data ?? [];
|
const blueprints: BlueprintRow[] = data ?? [];
|
||||||
const { sorted: sortedBlueprints, sortField, sortDir, toggle } = useTableSort<BlueprintRow, BlueprintSortField>(blueprints, {
|
const {
|
||||||
|
sorted: sortedBlueprints,
|
||||||
|
sortField,
|
||||||
|
sortDir,
|
||||||
|
toggle,
|
||||||
|
} = useTableSort<BlueprintRow, BlueprintSortField>(blueprints, {
|
||||||
initialField: (viewPrefs.savedSort?.field as BlueprintSortField | undefined) ?? null,
|
initialField: (viewPrefs.savedSort?.field as BlueprintSortField | undefined) ?? null,
|
||||||
initialDir: viewPrefs.savedSort?.dir ?? null,
|
initialDir: viewPrefs.savedSort?.dir ?? null,
|
||||||
onSortChange: (field, dir) => {
|
onSortChange: (field, dir) => {
|
||||||
@@ -287,17 +350,16 @@ export function BlueprintsClient() {
|
|||||||
<div className="flex items-start justify-between mb-6 gap-4">
|
<div className="flex items-start justify-between mb-6 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Blueprints</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Blueprints</h1>
|
||||||
<p className="text-gray-500 text-sm mt-1">Configure dynamic fields for resources and projects</p>
|
<p className="text-gray-500 text-sm mt-1">
|
||||||
|
Configure dynamic fields for resources and projects
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={() => setShowNewModal(true)} className={BTN_PRIMARY}>
|
<button type="button" onClick={() => setShowNewModal(true)} className={BTN_PRIMARY}>
|
||||||
+ New Blueprint
|
+ New Blueprint
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FilterBar
|
<FilterBar hasActiveFilters={!!targetFilter} onClearFilters={() => setTargetFilter("")}>
|
||||||
hasActiveFilters={!!targetFilter}
|
|
||||||
onClearFilters={() => setTargetFilter("")}
|
|
||||||
>
|
|
||||||
<select
|
<select
|
||||||
value={targetFilter}
|
value={targetFilter}
|
||||||
onChange={(e) => setTargetFilter(e.target.value)}
|
onChange={(e) => setTargetFilter(e.target.value)}
|
||||||
@@ -312,7 +374,10 @@ export function BlueprintsClient() {
|
|||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{[1, 2, 3].map((i) => (
|
{[1, 2, 3].map((i) => (
|
||||||
<div key={i} className="bg-white rounded-xl border border-gray-200 p-5 shimmer-skeleton h-36" />
|
<div
|
||||||
|
key={i}
|
||||||
|
className="bg-white rounded-xl border border-gray-200 p-5 shimmer-skeleton h-36"
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -325,7 +390,9 @@ export function BlueprintsClient() {
|
|||||||
|
|
||||||
{!isLoading && !isError && blueprints.length === 0 && (
|
{!isLoading && !isError && blueprints.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||||
<p className="text-gray-400 text-sm mb-4">No blueprints yet. Create one to start defining dynamic fields.</p>
|
<p className="text-gray-400 text-sm mb-4">
|
||||||
|
No blueprints yet. Create one to start defining dynamic fields.
|
||||||
|
</p>
|
||||||
<button type="button" onClick={() => setShowNewModal(true)} className={BTN_PRIMARY}>
|
<button type="button" onClick={() => setShowNewModal(true)} className={BTN_PRIMARY}>
|
||||||
+ New Blueprint
|
+ New Blueprint
|
||||||
</button>
|
</button>
|
||||||
@@ -347,12 +414,52 @@ export function BlueprintsClient() {
|
|||||||
aria-label="Select all blueprints"
|
aria-label="Select all blueprints"
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
<SortableColumnHeader label="Name" field="name" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} tooltip="Blueprint name. Defines a template of dynamic fields for resources or projects." />
|
<SortableColumnHeader
|
||||||
<SortableColumnHeader label="Target" field="target" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} tooltip="Whether this blueprint applies to Resource or Project entities." />
|
label="Name"
|
||||||
<SortableColumnHeader label="Fields" field="fieldCount" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} align="center" tooltip="Number of custom dynamic fields defined in this blueprint." />
|
field="name"
|
||||||
<SortableColumnHeader label="Staffing Presets" field="presetCount" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} align="center" tooltip="Role presets for project staffing demands. Only applicable to PROJECT blueprints." />
|
sortField={sortField}
|
||||||
<SortableColumnHeader label="Global" field="global" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} align="center" tooltip="Global blueprints expose their fields as columns across all entities of the target type." />
|
sortDir={sortDir}
|
||||||
<th className="px-3 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
onSort={handleSortRequest}
|
||||||
|
tooltip="Blueprint name. Defines a template of dynamic fields for resources or projects."
|
||||||
|
/>
|
||||||
|
<SortableColumnHeader
|
||||||
|
label="Target"
|
||||||
|
field="target"
|
||||||
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSortRequest}
|
||||||
|
tooltip="Whether this blueprint applies to Resource or Project entities."
|
||||||
|
/>
|
||||||
|
<SortableColumnHeader
|
||||||
|
label="Fields"
|
||||||
|
field="fieldCount"
|
||||||
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSortRequest}
|
||||||
|
align="center"
|
||||||
|
tooltip="Number of custom dynamic fields defined in this blueprint."
|
||||||
|
/>
|
||||||
|
<SortableColumnHeader
|
||||||
|
label="Staffing Presets"
|
||||||
|
field="presetCount"
|
||||||
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSortRequest}
|
||||||
|
align="center"
|
||||||
|
tooltip="Role presets for project staffing demands. Only applicable to PROJECT blueprints."
|
||||||
|
/>
|
||||||
|
<SortableColumnHeader
|
||||||
|
label="Global"
|
||||||
|
field="global"
|
||||||
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSortRequest}
|
||||||
|
align="center"
|
||||||
|
tooltip="Global blueprints expose their fields as columns across all entities of the target type."
|
||||||
|
/>
|
||||||
|
<th className="px-3 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -362,7 +469,10 @@ export function BlueprintsClient() {
|
|||||||
const isProject = bp.target === "PROJECT";
|
const isProject = bp.target === "PROJECT";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={bp.id} className="border-b border-gray-100 dark:border-gray-700/50 last:border-b-0 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
|
<tr
|
||||||
|
key={bp.id}
|
||||||
|
className="border-b border-gray-100 dark:border-gray-700/50 last:border-b-0 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
|
||||||
|
>
|
||||||
<td className="px-3 py-3">
|
<td className="px-3 py-3">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -373,17 +483,29 @@ export function BlueprintsClient() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3">
|
<td className="px-3 py-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="font-medium text-gray-900 dark:text-gray-100">{bp.name}</div>
|
<div className="font-medium text-gray-900 dark:text-gray-100">
|
||||||
{bp.description && <div className="text-xs text-gray-500 mt-0.5 truncate">{bp.description}</div>}
|
{bp.name}
|
||||||
|
</div>
|
||||||
|
{bp.description && (
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5 truncate">
|
||||||
|
{bp.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3">
|
<td className="px-3 py-3">
|
||||||
<span className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${isProject ? "bg-purple-50 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300" : "bg-blue-50 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300"}`}>
|
<span
|
||||||
|
className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${isProject ? "bg-purple-50 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300" : "bg-blue-50 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300"}`}
|
||||||
|
>
|
||||||
{bp.target}
|
{bp.target}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3 text-center text-gray-600 dark:text-gray-400">{fieldCount}</td>
|
<td className="px-3 py-3 text-center text-gray-600 dark:text-gray-400">
|
||||||
<td className="px-3 py-3 text-center text-gray-600 dark:text-gray-400">{isProject ? presetCount : "—"}</td>
|
{fieldCount}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 text-center text-gray-600 dark:text-gray-400">
|
||||||
|
{isProject ? presetCount : "—"}
|
||||||
|
</td>
|
||||||
<td className="px-3 py-3 text-center">
|
<td className="px-3 py-3 text-center">
|
||||||
{bp.isGlobal ? (
|
{bp.isGlobal ? (
|
||||||
<span className="inline-block px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300 font-medium">
|
<span className="inline-block px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300 font-medium">
|
||||||
@@ -397,7 +519,10 @@ export function BlueprintsClient() {
|
|||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setEditingTab("fields"); setEditingBlueprint(bp); }}
|
onClick={() => {
|
||||||
|
setEditingTab("fields");
|
||||||
|
setEditingBlueprint(bp);
|
||||||
|
}}
|
||||||
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
|
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
|
||||||
>
|
>
|
||||||
Edit Fields
|
Edit Fields
|
||||||
@@ -405,7 +530,10 @@ export function BlueprintsClient() {
|
|||||||
{isProject && (
|
{isProject && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setEditingTab("presets"); setEditingBlueprint(bp); }}
|
onClick={() => {
|
||||||
|
setEditingTab("presets");
|
||||||
|
setEditingBlueprint(bp);
|
||||||
|
}}
|
||||||
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
|
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
|
||||||
>
|
>
|
||||||
Presets
|
Presets
|
||||||
@@ -445,8 +573,14 @@ export function BlueprintsClient() {
|
|||||||
<BlueprintCard
|
<BlueprintCard
|
||||||
key={bp.id}
|
key={bp.id}
|
||||||
blueprint={bp}
|
blueprint={bp}
|
||||||
onEditFields={() => { setEditingTab("fields"); setEditingBlueprint(bp); }}
|
onEditFields={() => {
|
||||||
onEditStaffing={() => { setEditingTab("presets"); setEditingBlueprint(bp); }}
|
setEditingTab("fields");
|
||||||
|
setEditingBlueprint(bp);
|
||||||
|
}}
|
||||||
|
onEditStaffing={() => {
|
||||||
|
setEditingTab("presets");
|
||||||
|
setEditingBlueprint(bp);
|
||||||
|
}}
|
||||||
onToggleGlobal={() => handleToggleGlobal(bp.id, bp.isGlobal)}
|
onToggleGlobal={() => handleToggleGlobal(bp.id, bp.isGlobal)}
|
||||||
onDelete={() => handleDelete(bp.id)}
|
onDelete={() => handleDelete(bp.id)}
|
||||||
isSelected={selection.selectedIds.has(bp.id)}
|
isSelected={selection.selectedIds.has(bp.id)}
|
||||||
@@ -485,7 +619,10 @@ export function BlueprintsClient() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showNewModal && (
|
{showNewModal && (
|
||||||
<NewBlueprintModal onClose={() => setShowNewModal(false)} onCreated={() => setShowNewModal(false)} />
|
<NewBlueprintModal
|
||||||
|
onClose={() => setShowNewModal(false)}
|
||||||
|
onCreated={() => setShowNewModal(false)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editingBlueprint && (
|
{editingBlueprint && (
|
||||||
@@ -493,8 +630,16 @@ export function BlueprintsClient() {
|
|||||||
blueprintId={editingBlueprint.id}
|
blueprintId={editingBlueprint.id}
|
||||||
blueprintName={editingBlueprint.name}
|
blueprintName={editingBlueprint.name}
|
||||||
blueprintTarget={editingBlueprint.target}
|
blueprintTarget={editingBlueprint.target}
|
||||||
initialFieldDefs={Array.isArray(editingBlueprint.fieldDefs) ? (editingBlueprint.fieldDefs as BlueprintFieldDefinition[]) : []}
|
initialFieldDefs={
|
||||||
initialRolePresets={Array.isArray(editingBlueprint.rolePresets) ? (editingBlueprint.rolePresets as import("@capakraken/shared").StaffingRequirement[]) : []}
|
Array.isArray(editingBlueprint.fieldDefs)
|
||||||
|
? (editingBlueprint.fieldDefs as BlueprintFieldDefinition[])
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
initialRolePresets={
|
||||||
|
Array.isArray(editingBlueprint.rolePresets)
|
||||||
|
? (editingBlueprint.rolePresets as import("@capakraken/shared").StaffingRequirement[])
|
||||||
|
: []
|
||||||
|
}
|
||||||
initialTab={editingTab}
|
initialTab={editingTab}
|
||||||
onClose={() => setEditingBlueprint(null)}
|
onClose={() => setEditingBlueprint(null)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { trpc } from "~/lib/trpc/client.js";
|
|||||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||||
import { TaskCard } from "~/components/notifications/TaskCard.js";
|
import { TaskCard } from "~/components/notifications/TaskCard.js";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
export function TaskWidget(_props: Partial<WidgetProps> = {}) {
|
export function TaskWidget(_props: Partial<WidgetProps> = {}) {
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
@@ -15,10 +14,13 @@ export function TaskWidget(_props: Partial<WidgetProps> = {}) {
|
|||||||
{ staleTime: 30_000, placeholderData: (prev) => prev },
|
{ staleTime: 30_000, placeholderData: (prev) => prev },
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: taskCounts, isLoading: loadingCounts } = trpc.notification.taskCounts.useQuery(undefined, {
|
const { data: taskCounts, isLoading: loadingCounts } = trpc.notification.taskCounts.useQuery(
|
||||||
staleTime: 30_000,
|
undefined,
|
||||||
placeholderData: (prev) => prev,
|
{
|
||||||
});
|
staleTime: 30_000,
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const updateTaskStatus = trpc.notification.updateTaskStatus.useMutation({
|
const updateTaskStatus = trpc.notification.updateTaskStatus.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -30,7 +32,10 @@ export function TaskWidget(_props: Partial<WidgetProps> = {}) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function handleStatusChange(id: string, status: string) {
|
function handleStatusChange(id: string, status: string) {
|
||||||
updateTaskStatus.mutate({ id, status: status as "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED" });
|
updateTaskStatus.mutate({
|
||||||
|
id,
|
||||||
|
status: status as "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const openCount = (taskCounts?.open ?? 0) + (taskCounts?.inProgress ?? 0);
|
const openCount = (taskCounts?.open ?? 0) + (taskCounts?.inProgress ?? 0);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { EstimateExportFormat } from "@capakraken/shared";
|
import type { EstimateExportFormat } from "@capakraken/shared";
|
||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import type {
|
import type {
|
||||||
@@ -23,52 +23,80 @@ const TabSkeleton = () => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const EstimateWorkspaceDraftEditor = dynamic(
|
const EstimateWorkspaceDraftEditor = dynamic(
|
||||||
() => import("~/components/estimates/EstimateWorkspaceDraftEditor.js").then((mod) => ({ default: mod.EstimateWorkspaceDraftEditor })),
|
() =>
|
||||||
|
import("~/components/estimates/EstimateWorkspaceDraftEditor.js").then((mod) => ({
|
||||||
|
default: mod.EstimateWorkspaceDraftEditor,
|
||||||
|
})),
|
||||||
{ loading: TabSkeleton },
|
{ loading: TabSkeleton },
|
||||||
);
|
);
|
||||||
|
|
||||||
const WeeklyPhasingView = dynamic(
|
const WeeklyPhasingView = dynamic(
|
||||||
() => import("~/components/estimates/WeeklyPhasingView.js").then((mod) => ({ default: mod.WeeklyPhasingView })),
|
() =>
|
||||||
|
import("~/components/estimates/WeeklyPhasingView.js").then((mod) => ({
|
||||||
|
default: mod.WeeklyPhasingView,
|
||||||
|
})),
|
||||||
{ loading: TabSkeleton },
|
{ loading: TabSkeleton },
|
||||||
);
|
);
|
||||||
|
|
||||||
const OverviewTab = dynamic(
|
const OverviewTab = dynamic(
|
||||||
() => import("~/components/estimates/tabs/OverviewTab.js").then((mod) => ({ default: mod.OverviewTab })),
|
() =>
|
||||||
|
import("~/components/estimates/tabs/OverviewTab.js").then((mod) => ({
|
||||||
|
default: mod.OverviewTab,
|
||||||
|
})),
|
||||||
{ loading: TabSkeleton },
|
{ loading: TabSkeleton },
|
||||||
);
|
);
|
||||||
|
|
||||||
const AssumptionsTab = dynamic(
|
const AssumptionsTab = dynamic(
|
||||||
() => import("~/components/estimates/tabs/AssumptionsTab.js").then((mod) => ({ default: mod.AssumptionsTab })),
|
() =>
|
||||||
|
import("~/components/estimates/tabs/AssumptionsTab.js").then((mod) => ({
|
||||||
|
default: mod.AssumptionsTab,
|
||||||
|
})),
|
||||||
{ loading: TabSkeleton },
|
{ loading: TabSkeleton },
|
||||||
);
|
);
|
||||||
|
|
||||||
const ScopeTab = dynamic(
|
const ScopeTab = dynamic(
|
||||||
() => import("~/components/estimates/tabs/ScopeTab.js").then((mod) => ({ default: mod.ScopeTab })),
|
() =>
|
||||||
|
import("~/components/estimates/tabs/ScopeTab.js").then((mod) => ({ default: mod.ScopeTab })),
|
||||||
{ loading: TabSkeleton },
|
{ loading: TabSkeleton },
|
||||||
);
|
);
|
||||||
|
|
||||||
const StaffingTab = dynamic(
|
const StaffingTab = dynamic(
|
||||||
() => import("~/components/estimates/tabs/StaffingTab.js").then((mod) => ({ default: mod.StaffingTab })),
|
() =>
|
||||||
|
import("~/components/estimates/tabs/StaffingTab.js").then((mod) => ({
|
||||||
|
default: mod.StaffingTab,
|
||||||
|
})),
|
||||||
{ loading: TabSkeleton },
|
{ loading: TabSkeleton },
|
||||||
);
|
);
|
||||||
|
|
||||||
const FinancialsTab = dynamic(
|
const FinancialsTab = dynamic(
|
||||||
() => import("~/components/estimates/tabs/FinancialsTab.js").then((mod) => ({ default: mod.FinancialsTab })),
|
() =>
|
||||||
|
import("~/components/estimates/tabs/FinancialsTab.js").then((mod) => ({
|
||||||
|
default: mod.FinancialsTab,
|
||||||
|
})),
|
||||||
{ loading: TabSkeleton },
|
{ loading: TabSkeleton },
|
||||||
);
|
);
|
||||||
|
|
||||||
const VersionsTab = dynamic(
|
const VersionsTab = dynamic(
|
||||||
() => import("~/components/estimates/tabs/VersionsTab.js").then((mod) => ({ default: mod.VersionsTab })),
|
() =>
|
||||||
|
import("~/components/estimates/tabs/VersionsTab.js").then((mod) => ({
|
||||||
|
default: mod.VersionsTab,
|
||||||
|
})),
|
||||||
{ loading: TabSkeleton },
|
{ loading: TabSkeleton },
|
||||||
);
|
);
|
||||||
|
|
||||||
const ExportsTab = dynamic(
|
const ExportsTab = dynamic(
|
||||||
() => import("~/components/estimates/tabs/ExportsTab.js").then((mod) => ({ default: mod.ExportsTab })),
|
() =>
|
||||||
|
import("~/components/estimates/tabs/ExportsTab.js").then((mod) => ({
|
||||||
|
default: mod.ExportsTab,
|
||||||
|
})),
|
||||||
{ loading: TabSkeleton },
|
{ loading: TabSkeleton },
|
||||||
);
|
);
|
||||||
|
|
||||||
const CommentThread = dynamic(
|
const CommentThread = dynamic(
|
||||||
() => import("~/components/comments/CommentThread.js").then((mod) => ({ default: mod.CommentThread })),
|
() =>
|
||||||
|
import("~/components/comments/CommentThread.js").then((mod) => ({
|
||||||
|
default: mod.CommentThread,
|
||||||
|
})),
|
||||||
{ loading: TabSkeleton },
|
{ loading: TabSkeleton },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -137,20 +165,22 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
|
|||||||
const createPlanningHandoffMutation = trpc.estimate.createPlanningHandoff.useMutation();
|
const createPlanningHandoffMutation = trpc.estimate.createPlanningHandoff.useMutation();
|
||||||
const estimateCommentTarget = { entityType: "estimate" as const, entityId: estimateId };
|
const estimateCommentTarget = { entityType: "estimate" as const, entityId: estimateId };
|
||||||
const canLoadCommentCount =
|
const canLoadCommentCount =
|
||||||
canViewCosts
|
canViewCosts &&
|
||||||
&& !isPermissionsLoading
|
!isPermissionsLoading &&
|
||||||
&& detailQuery.status === "success"
|
detailQuery.status === "success" &&
|
||||||
&& detailQuery.data != null;
|
detailQuery.data != null;
|
||||||
|
|
||||||
const commentCountQuery = trpc.comment.count.useQuery(
|
const commentCountQuery = trpc.comment.count.useQuery(estimateCommentTarget, {
|
||||||
estimateCommentTarget,
|
enabled: canLoadCommentCount,
|
||||||
{ enabled: canLoadCommentCount, staleTime: 30_000 },
|
staleTime: 30_000,
|
||||||
);
|
});
|
||||||
const commentCount = commentCountQuery.data ?? 0;
|
const commentCount = commentCountQuery.data ?? 0;
|
||||||
|
|
||||||
const estimate = (detailQuery.data as EstimateWorkspaceView | undefined) ?? null;
|
const estimate = (detailQuery.data as EstimateWorkspaceView | undefined) ?? null;
|
||||||
const hasWorkingVersion = estimate?.versions.some((version) => version.status === "WORKING") ?? false;
|
const hasWorkingVersion =
|
||||||
const editableTab = tab === "overview" || tab === "assumptions" || tab === "scope" || tab === "staffing";
|
estimate?.versions.some((version) => version.status === "WORKING") ?? false;
|
||||||
|
const editableTab =
|
||||||
|
tab === "overview" || tab === "assumptions" || tab === "scope" || tab === "staffing";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
@@ -258,19 +288,27 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
|
|||||||
<div className="rounded-[28px] border border-gray-200 dark:border-gray-700 bg-gradient-to-br from-white via-white to-brand-50 dark:from-gray-900 dark:via-gray-900 dark:to-gray-900 p-6 shadow-sm dark:shadow-black/20">
|
<div className="rounded-[28px] border border-gray-200 dark:border-gray-700 bg-gradient-to-br from-white via-white to-brand-50 dark:from-gray-900 dark:via-gray-900 dark:to-gray-900 p-6 shadow-sm dark:shadow-black/20">
|
||||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600 dark:text-sky-400">Estimate Workspace <InfoTooltip content="Central workspace for inspecting and editing an estimate's scope, staffing, financials, and version history." /></p>
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600 dark:text-sky-400">
|
||||||
|
Estimate Workspace{" "}
|
||||||
|
<InfoTooltip content="Central workspace for inspecting and editing an estimate's scope, staffing, financials, and version history." />
|
||||||
|
</p>
|
||||||
<h1 className="mt-2 text-3xl font-semibold text-gray-900 dark:text-gray-50">
|
<h1 className="mt-2 text-3xl font-semibold text-gray-900 dark:text-gray-50">
|
||||||
{estimate?.name ?? "Loading estimate"}
|
{estimate?.name ?? "Loading estimate"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 max-w-3xl text-sm text-gray-600 dark:text-gray-300">
|
<p className="mt-2 max-w-3xl text-sm text-gray-600 dark:text-gray-300">
|
||||||
Use the tabs below to inspect the connected estimate structure, version context, and staffing breakdown without relying on spreadsheet tabs.
|
Use the tabs below to inspect the connected estimate structure, version context, and
|
||||||
|
staffing breakdown without relying on spreadsheet tabs.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{estimate && (
|
{estimate && (
|
||||||
<div className="flex flex-col gap-3 lg:items-end">
|
<div className="flex flex-col gap-3 lg:items-end">
|
||||||
<div className="grid gap-2 text-sm text-gray-500 dark:text-gray-400 lg:text-right">
|
<div className="grid gap-2 text-sm text-gray-500 dark:text-gray-400 lg:text-right">
|
||||||
<span>{estimate.project ? `${estimate.project.shortCode} - ${estimate.project.name}` : "Standalone estimate"}</span>
|
<span>
|
||||||
|
{estimate.project
|
||||||
|
? `${estimate.project.shortCode} - ${estimate.project.name}`
|
||||||
|
: "Standalone estimate"}
|
||||||
|
</span>
|
||||||
<span>Updated {formatDateLong(estimate.updatedAt)}</span>
|
<span>Updated {formatDateLong(estimate.updatedAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
{canEdit && hasWorkingVersion && (
|
{canEdit && hasWorkingVersion && (
|
||||||
@@ -282,7 +320,11 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
|
|||||||
}}
|
}}
|
||||||
className="rounded-2xl border border-brand-200 dark:border-sky-700 bg-white dark:bg-gray-800 px-4 py-2 text-sm font-semibold text-brand-700 dark:text-sky-300 transition hover:border-brand-300 dark:hover:border-sky-600 hover:bg-brand-50 dark:hover:bg-gray-700"
|
className="rounded-2xl border border-brand-200 dark:border-sky-700 bg-white dark:bg-gray-800 px-4 py-2 text-sm font-semibold text-brand-700 dark:text-sky-300 transition hover:border-brand-300 dark:hover:border-sky-600 hover:bg-brand-50 dark:hover:bg-gray-700"
|
||||||
>
|
>
|
||||||
{isEditing ? "Close editor" : editableTab ? "Edit working draft" : "Draft editor available in editable tabs"}
|
{isEditing
|
||||||
|
? "Close editor"
|
||||||
|
: editableTab
|
||||||
|
? "Edit working draft"
|
||||||
|
: "Draft editor available in editable tabs"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -293,7 +335,9 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
|
|||||||
{isPermissionsLoading ? (
|
{isPermissionsLoading ? (
|
||||||
<EmptyState>Loading estimate workspace...</EmptyState>
|
<EmptyState>Loading estimate workspace...</EmptyState>
|
||||||
) : !canViewCosts ? (
|
) : !canViewCosts ? (
|
||||||
<EmptyState>Your role can access the estimate list, but not the detailed financial workspace.</EmptyState>
|
<EmptyState>
|
||||||
|
Your role can access the estimate list, but not the detailed financial workspace.
|
||||||
|
</EmptyState>
|
||||||
) : detailQuery.isLoading ? (
|
) : detailQuery.isLoading ? (
|
||||||
<EmptyState>Loading estimate workspace...</EmptyState>
|
<EmptyState>Loading estimate workspace...</EmptyState>
|
||||||
) : detailQuery.error ? (
|
) : detailQuery.error ? (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
import { EstimateStatus, EstimateVersionStatus } from "@capakraken/shared";
|
import type { EstimateStatus, EstimateVersionStatus } from "@capakraken/shared";
|
||||||
import type {
|
import type {
|
||||||
EstimateMetricView,
|
EstimateMetricView,
|
||||||
EstimateVersionView,
|
EstimateVersionView,
|
||||||
@@ -45,7 +45,12 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
|
|||||||
<section className="space-y-6">
|
<section className="space-y-6">
|
||||||
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 shadow-sm">
|
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 shadow-sm">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className={clsx("rounded-full px-3 py-1 text-xs font-semibold", STATUS_STYLES[estimate.status])}>
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"rounded-full px-3 py-1 text-xs font-semibold",
|
||||||
|
STATUS_STYLES[estimate.status],
|
||||||
|
)}
|
||||||
|
>
|
||||||
{estimate.status.replace("_", " ")}
|
{estimate.status.replace("_", " ")}
|
||||||
</span>
|
</span>
|
||||||
{estimate.project && (
|
{estimate.project && (
|
||||||
@@ -57,22 +62,39 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
|
|||||||
|
|
||||||
<div className="mt-5 grid gap-4 md:grid-cols-2">
|
<div className="mt-5 grid gap-4 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-wide text-gray-400">Opportunity <InfoTooltip content="External CRM or sales reference linking this estimate to a sales opportunity." /></p>
|
<p className="text-xs uppercase tracking-wide text-gray-400">
|
||||||
<p className="mt-1 text-sm text-gray-800 dark:text-gray-200">{estimate.opportunityId ?? "Not set"}</p>
|
Opportunity{" "}
|
||||||
</div>
|
<InfoTooltip content="External CRM or sales reference linking this estimate to a sales opportunity." />
|
||||||
<div>
|
</p>
|
||||||
<p className="text-xs uppercase tracking-wide text-gray-400">Base currency <InfoTooltip content="The primary currency for all monetary calculations in this estimate." /></p>
|
|
||||||
<p className="mt-1 text-sm text-gray-800 dark:text-gray-200">{estimate.baseCurrency}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase tracking-wide text-gray-400">Latest version <InfoTooltip content="The most recent version snapshot. Each version captures a full copy of scope, demand, and financials." /></p>
|
|
||||||
<p className="mt-1 text-sm text-gray-800 dark:text-gray-200">
|
<p className="mt-1 text-sm text-gray-800 dark:text-gray-200">
|
||||||
{latestVersion ? `v${latestVersion.versionNumber}${latestVersion.label ? ` - ${latestVersion.label}` : ""}` : "No version"}
|
{estimate.opportunityId ?? "Not set"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-gray-400">
|
||||||
|
Base currency{" "}
|
||||||
|
<InfoTooltip content="The primary currency for all monetary calculations in this estimate." />
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-gray-800 dark:text-gray-200">
|
||||||
|
{estimate.baseCurrency}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-gray-400">
|
||||||
|
Latest version{" "}
|
||||||
|
<InfoTooltip content="The most recent version snapshot. Each version captures a full copy of scope, demand, and financials." />
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-gray-800 dark:text-gray-200">
|
||||||
|
{latestVersion
|
||||||
|
? `v${latestVersion.versionNumber}${latestVersion.label ? ` - ${latestVersion.label}` : ""}`
|
||||||
|
: "No version"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-wide text-gray-400">Updated</p>
|
<p className="text-xs uppercase tracking-wide text-gray-400">Updated</p>
|
||||||
<p className="mt-1 text-sm text-gray-800 dark:text-gray-200">{formatDateLong(estimate.updatedAt)}</p>
|
<p className="mt-1 text-sm text-gray-800 dark:text-gray-200">
|
||||||
|
{formatDateLong(estimate.updatedAt)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -87,37 +109,61 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
|
|||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">Scope items <InfoTooltip content="Deliverables or work packages included in this estimate version." /></p>
|
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Scope items{" "}
|
||||||
|
<InfoTooltip content="Deliverables or work packages included in this estimate version." />
|
||||||
|
</p>
|
||||||
<span className="text-xs text-gray-400">{latestVersion?.scopeItems.length ?? 0}</span>
|
<span className="text-xs text-gray-400">{latestVersion?.scopeItems.length ?? 0}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 space-y-2">
|
<div className="mt-4 space-y-2">
|
||||||
{(latestVersion?.scopeItems ?? []).slice(0, 4).map((item) => (
|
{(latestVersion?.scopeItems ?? []).slice(0, 4).map((item) => (
|
||||||
<div key={item.id} className="rounded-2xl border border-gray-100 dark:border-gray-700/50 px-4 py-3">
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="rounded-2xl border border-gray-100 dark:border-gray-700/50 px-4 py-3"
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{item.name}</p>
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{item.name}
|
||||||
|
</p>
|
||||||
<span className="text-xs text-gray-400">{item.scopeType}</span>
|
<span className="text-xs text-gray-400">{item.scopeType}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{(latestVersion?.scopeItems.length ?? 0) === 0 && <p className="text-sm text-gray-400">No scope rows captured yet.</p>}
|
{(latestVersion?.scopeItems.length ?? 0) === 0 && (
|
||||||
|
<p className="text-sm text-gray-400">No scope rows captured yet.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">Demand lines <InfoTooltip content="Staffing demand rows with hours, cost rate, and sell rate per role or resource." /></p>
|
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
<span className="text-xs text-gray-400">{latestVersion?.demandLines.length ?? 0}</span>
|
Demand lines{" "}
|
||||||
|
<InfoTooltip content="Staffing demand rows with hours, cost rate, and sell rate per role or resource." />
|
||||||
|
</p>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{latestVersion?.demandLines.length ?? 0}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 space-y-2">
|
<div className="mt-4 space-y-2">
|
||||||
{(latestVersion?.demandLines ?? []).slice(0, 4).map((line) => (
|
{(latestVersion?.demandLines ?? []).slice(0, 4).map((line) => (
|
||||||
<div key={line.id} className="rounded-2xl border border-gray-100 dark:border-gray-700/50 px-4 py-3">
|
<div
|
||||||
|
key={line.id}
|
||||||
|
className="rounded-2xl border border-gray-100 dark:border-gray-700/50 px-4 py-3"
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{line.name}</p>
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">{line.hours.toFixed(1)} h</span>
|
{line.name}
|
||||||
|
</p>
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{line.hours.toFixed(1)} h
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{(latestVersion?.demandLines.length ?? 0) === 0 && <p className="text-sm text-gray-400">No demand lines captured yet.</p>}
|
{(latestVersion?.demandLines.length ?? 0) === 0 && (
|
||||||
|
<p className="text-sm text-gray-400">No demand lines captured yet.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,15 +171,25 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
|
|||||||
|
|
||||||
<aside className="space-y-4">
|
<aside className="space-y-4">
|
||||||
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
||||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">Summary metrics <InfoTooltip content="Key financial indicators derived from the latest version's demand lines." /></p>
|
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Summary metrics{" "}
|
||||||
|
<InfoTooltip content="Key financial indicators derived from the latest version's demand lines." />
|
||||||
|
</p>
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
{latestMetrics.length === 0 ? (
|
{latestMetrics.length === 0 ? (
|
||||||
<p className="text-sm text-gray-400">No derived metrics available yet.</p>
|
<p className="text-sm text-gray-400">No derived metrics available yet.</p>
|
||||||
) : (
|
) : (
|
||||||
latestMetrics.map((metric) => (
|
latestMetrics.map((metric) => (
|
||||||
<div key={metric.id} className="flex items-center justify-between gap-3 rounded-2xl bg-gray-50 dark:bg-gray-900 px-4 py-3">
|
<div
|
||||||
<span className="text-xs uppercase tracking-wide text-gray-400">{metric.label}</span>
|
key={metric.id}
|
||||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">{formatMetricValue(metric)}</span>
|
className="flex items-center justify-between gap-3 rounded-2xl bg-gray-50 dark:bg-gray-900 px-4 py-3"
|
||||||
|
>
|
||||||
|
<span className="text-xs uppercase tracking-wide text-gray-400">
|
||||||
|
{metric.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{formatMetricValue(metric)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -141,27 +197,41 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
||||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">Version context <InfoTooltip content="Metadata about the latest version, including its workflow status and linked records." /></p>
|
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Version context{" "}
|
||||||
|
<InfoTooltip content="Metadata about the latest version, including its workflow status and linked records." />
|
||||||
|
</p>
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
{latestVersion ? (
|
{latestVersion ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">Status</span>
|
<span className="text-sm text-gray-500 dark:text-gray-400">Status</span>
|
||||||
<span className={clsx("rounded-full px-2.5 py-1 text-xs font-medium", VERSION_STYLES[latestVersion.status])}>
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"rounded-full px-2.5 py-1 text-xs font-medium",
|
||||||
|
VERSION_STYLES[latestVersion.status],
|
||||||
|
)}
|
||||||
|
>
|
||||||
{latestVersion.status}
|
{latestVersion.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">Assumptions</span>
|
<span className="text-sm text-gray-500 dark:text-gray-400">Assumptions</span>
|
||||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">{latestVersion.assumptions.length}</span>
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{latestVersion.assumptions.length}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">Snapshots</span>
|
<span className="text-sm text-gray-500 dark:text-gray-400">Snapshots</span>
|
||||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">{latestVersion.resourceSnapshots.length}</span>
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{latestVersion.resourceSnapshots.length}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">Exports</span>
|
<span className="text-sm text-gray-500 dark:text-gray-400">Exports</span>
|
||||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">{latestVersion.exports.length}</span>
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{latestVersion.exports.length}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { OrderType, AllocationType, ProjectStatus } from "@capakraken/shared";
|
import type { OrderType, AllocationType, ProjectStatus } from "@capakraken/shared";
|
||||||
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
|
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
|
||||||
import type { Project } from "@capakraken/shared";
|
import type { Project } from "@capakraken/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
@@ -79,9 +79,12 @@ function projectToForm(project: Project): FormState {
|
|||||||
status: project.status,
|
status: project.status,
|
||||||
responsiblePerson: project.responsiblePerson ?? "",
|
responsiblePerson: project.responsiblePerson ?? "",
|
||||||
color: (project as unknown as { color?: string | null }).color ?? "",
|
color: (project as unknown as { color?: string | null }).color ?? "",
|
||||||
utilizationCategoryId: (project as unknown as { utilizationCategoryId?: string | null }).utilizationCategoryId ?? "",
|
utilizationCategoryId:
|
||||||
|
(project as unknown as { utilizationCategoryId?: string | null }).utilizationCategoryId ?? "",
|
||||||
clientId: (project as unknown as { clientId?: string | null }).clientId ?? "",
|
clientId: (project as unknown as { clientId?: string | null }).clientId ?? "",
|
||||||
shoringThreshold: String((project as unknown as { shoringThreshold?: number | null }).shoringThreshold ?? 55),
|
shoringThreshold: String(
|
||||||
|
(project as unknown as { shoringThreshold?: number | null }).shoringThreshold ?? 55,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,10 +104,12 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
|||||||
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
|
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
|
||||||
const [serverError, setServerError] = useState<string | null>(null);
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data: utilizationCategories } = trpc.utilizationCategory.list.useQuery(undefined, { staleTime: 60_000 });
|
const { data: utilizationCategories } = trpc.utilizationCategory.list.useQuery(undefined, {
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
const { data: clientList } = trpc.clientEntity.list.useQuery(undefined, { staleTime: 60_000 });
|
const { data: clientList } = trpc.clientEntity.list.useQuery(undefined, { staleTime: 60_000 });
|
||||||
|
|
||||||
// @ts-ignore TS2589: tRPC infers union type too deeply for CreateProjectSchema with .refine()
|
// @ts-expect-error TS2589: tRPC infers union type too deeply for CreateProjectSchema with .refine()
|
||||||
const createMutation = trpc.project.create.useMutation({
|
const createMutation = trpc.project.create.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await utils.project.listWithCosts.invalidate();
|
await utils.project.listWithCosts.invalidate();
|
||||||
@@ -193,7 +198,9 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
|||||||
status: form.status as unknown as ProjectStatus,
|
status: form.status as unknown as ProjectStatus,
|
||||||
responsiblePerson: form.responsiblePerson.trim(),
|
responsiblePerson: form.responsiblePerson.trim(),
|
||||||
...(form.color ? { color: form.color } : {}),
|
...(form.color ? { color: form.color } : {}),
|
||||||
...(form.utilizationCategoryId ? { utilizationCategoryId: form.utilizationCategoryId } : {}),
|
...(form.utilizationCategoryId
|
||||||
|
? { utilizationCategoryId: form.utilizationCategoryId }
|
||||||
|
: {}),
|
||||||
...(form.clientId ? { clientId: form.clientId } : {}),
|
...(form.clientId ? { clientId: form.clientId } : {}),
|
||||||
shoringThreshold: Number(form.shoringThreshold),
|
shoringThreshold: Number(form.shoringThreshold),
|
||||||
},
|
},
|
||||||
@@ -213,7 +220,9 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
|||||||
dynamicFields: {},
|
dynamicFields: {},
|
||||||
responsiblePerson: form.responsiblePerson.trim(),
|
responsiblePerson: form.responsiblePerson.trim(),
|
||||||
...(form.color ? { color: form.color } : {}),
|
...(form.color ? { color: form.color } : {}),
|
||||||
...(form.utilizationCategoryId ? { utilizationCategoryId: form.utilizationCategoryId } : {}),
|
...(form.utilizationCategoryId
|
||||||
|
? { utilizationCategoryId: form.utilizationCategoryId }
|
||||||
|
: {}),
|
||||||
...(form.clientId ? { clientId: form.clientId } : {}),
|
...(form.clientId ? { clientId: form.clientId } : {}),
|
||||||
shoringThreshold: Number(form.shoringThreshold),
|
shoringThreshold: Number(form.shoringThreshold),
|
||||||
});
|
});
|
||||||
@@ -241,7 +250,12 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
|||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -282,7 +296,9 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{errors.shortCode && (
|
{errors.shortCode && (
|
||||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.shortCode}</p>
|
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
|
||||||
|
{errors.shortCode}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -362,7 +378,9 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
|||||||
className={errors.winProbability ? inputErrorClass : inputClass}
|
className={errors.winProbability ? inputErrorClass : inputClass}
|
||||||
/>
|
/>
|
||||||
{errors.winProbability && (
|
{errors.winProbability && (
|
||||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.winProbability}</p>
|
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
|
||||||
|
{errors.winProbability}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -388,7 +406,8 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
|||||||
<option value="">— Not specified —</option>
|
<option value="">— Not specified —</option>
|
||||||
{(utilizationCategories ?? []).map((cat) => (
|
{(utilizationCategories ?? []).map((cat) => (
|
||||||
<option key={cat.id} value={cat.id}>
|
<option key={cat.id} value={cat.id}>
|
||||||
{(cat as unknown as { code: string }).code} — {(cat as unknown as { name: string }).name}
|
{(cat as unknown as { code: string }).code} —{" "}
|
||||||
|
{(cat as unknown as { name: string }).name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -408,7 +427,9 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
|||||||
{(clientList ?? []).map((c) => (
|
{(clientList ?? []).map((c) => (
|
||||||
<option key={c.id} value={c.id}>
|
<option key={c.id} value={c.id}>
|
||||||
{(c as unknown as { name: string }).name}
|
{(c as unknown as { name: string }).name}
|
||||||
{(c as unknown as { code: string | null }).code ? ` [${(c as unknown as { code: string }).code}]` : ""}
|
{(c as unknown as { code: string | null }).code
|
||||||
|
? ` [${(c as unknown as { code: string }).code}]`
|
||||||
|
: ""}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -434,7 +455,9 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
|||||||
className={errors.startDate ? inputErrorClass : inputClass}
|
className={errors.startDate ? inputErrorClass : inputClass}
|
||||||
/>
|
/>
|
||||||
{errors.startDate && (
|
{errors.startDate && (
|
||||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.startDate}</p>
|
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
|
||||||
|
{errors.startDate}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -469,7 +492,9 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
|||||||
className={errors.budgetEur ? inputErrorClass : inputClass}
|
className={errors.budgetEur ? inputErrorClass : inputClass}
|
||||||
/>
|
/>
|
||||||
{errors.budgetEur && (
|
{errors.budgetEur && (
|
||||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.budgetEur}</p>
|
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
|
||||||
|
{errors.budgetEur}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -576,7 +601,13 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 transition-colors"
|
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
{isLoading ? (isEdit ? "Saving…" : "Creating…") : isEdit ? "Save Changes" : "Create Project"}
|
{isLoading
|
||||||
|
? isEdit
|
||||||
|
? "Saving…"
|
||||||
|
: "Creating…"
|
||||||
|
: isEdit
|
||||||
|
? "Save Changes"
|
||||||
|
: "Create Project"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -3,8 +3,19 @@
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
import type { StaffingRequirement, BlueprintFieldDefinition } from "@capakraken/shared";
|
import type {
|
||||||
import { BlueprintTarget, FieldType, OrderType, AllocationType, ProjectStatus, AllocationStatus, RolePresetsSchema } from "@capakraken/shared";
|
StaffingRequirement,
|
||||||
|
BlueprintFieldDefinition,
|
||||||
|
OrderType,
|
||||||
|
AllocationType,
|
||||||
|
} from "@capakraken/shared";
|
||||||
|
import {
|
||||||
|
BlueprintTarget,
|
||||||
|
FieldType,
|
||||||
|
ProjectStatus,
|
||||||
|
AllocationStatus,
|
||||||
|
RolePresetsSchema,
|
||||||
|
} from "@capakraken/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { uuid } from "~/lib/uuid.js";
|
import { uuid } from "~/lib/uuid.js";
|
||||||
import { DateInput } from "~/components/ui/DateInput.js";
|
import { DateInput } from "~/components/ui/DateInput.js";
|
||||||
@@ -200,12 +211,7 @@ function DynamicFieldInput({
|
|||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
case FieldType.DATE:
|
case FieldType.DATE:
|
||||||
return (
|
return <DateInput value={strVal} onChange={(v) => onChange(field.key, v)} />;
|
||||||
<DateInput
|
|
||||||
value={strVal}
|
|
||||||
onChange={(v) => onChange(field.key, v)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case FieldType.SELECT:
|
case FieldType.SELECT:
|
||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
@@ -249,7 +255,9 @@ function DynamicFieldInput({
|
|||||||
// TEXT, URL, EMAIL
|
// TEXT, URL, EMAIL
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type={field.type === FieldType.EMAIL ? "email" : field.type === FieldType.URL ? "url" : "text"}
|
type={
|
||||||
|
field.type === FieldType.EMAIL ? "email" : field.type === FieldType.URL ? "url" : "text"
|
||||||
|
}
|
||||||
value={strVal}
|
value={strVal}
|
||||||
onChange={(e) => onChange(field.key, e.target.value)}
|
onChange={(e) => onChange(field.key, e.target.value)}
|
||||||
placeholder={field.placeholder}
|
placeholder={field.placeholder}
|
||||||
@@ -270,14 +278,28 @@ function Step1({ state, onChange }: Step1Props) {
|
|||||||
const { data: blueprints } = trpc.blueprint.list.useQuery(
|
const { data: blueprints } = trpc.blueprint.list.useQuery(
|
||||||
{ target: BlueprintTarget.PROJECT, isActive: true },
|
{ target: BlueprintTarget.PROJECT, isActive: true },
|
||||||
{ staleTime: 30_000 },
|
{ staleTime: 30_000 },
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
) as {
|
||||||
) as { data: Array<{ id: string; name: string; description?: string | null; rolePresets?: unknown; fieldDefs?: unknown }> | undefined };
|
data:
|
||||||
|
| Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
rolePresets?: unknown;
|
||||||
|
fieldDefs?: unknown;
|
||||||
|
}>
|
||||||
|
| undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const selectedBp = blueprints?.find((b) => b.id === state.blueprintId);
|
const selectedBp = blueprints?.find((b) => b.id === state.blueprintId);
|
||||||
|
|
||||||
function selectBlueprint(id: string | null) {
|
function selectBlueprint(id: string | null) {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
onChange({ blueprintId: null, blueprintName: null, blueprintFieldDefs: [], dynamicFields: {} });
|
onChange({
|
||||||
|
blueprintId: null,
|
||||||
|
blueprintName: null,
|
||||||
|
blueprintFieldDefs: [],
|
||||||
|
dynamicFields: {},
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const bp = blueprints?.find((b) => b.id === id);
|
const bp = blueprints?.find((b) => b.id === id);
|
||||||
@@ -285,7 +307,9 @@ function Step1({ state, onChange }: Step1Props) {
|
|||||||
const parsedPresets = RolePresetsSchema.safeParse(
|
const parsedPresets = RolePresetsSchema.safeParse(
|
||||||
Array.isArray(bp?.rolePresets) ? bp.rolePresets : [],
|
Array.isArray(bp?.rolePresets) ? bp.rolePresets : [],
|
||||||
);
|
);
|
||||||
const presets = (parsedPresets.success ? parsedPresets.data : []) as unknown as StaffingRequirement[];
|
const presets = (parsedPresets.success
|
||||||
|
? parsedPresets.data
|
||||||
|
: []) as unknown as StaffingRequirement[];
|
||||||
// Parse fieldDefs from blueprint
|
// Parse fieldDefs from blueprint
|
||||||
const rawFieldDefs = Array.isArray(bp?.fieldDefs) ? (bp.fieldDefs as unknown[]) : [];
|
const rawFieldDefs = Array.isArray(bp?.fieldDefs) ? (bp.fieldDefs as unknown[]) : [];
|
||||||
const fieldDefs = rawFieldDefs.filter(
|
const fieldDefs = rawFieldDefs.filter(
|
||||||
@@ -312,7 +336,10 @@ function Step1({ state, onChange }: Step1Props) {
|
|||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* Blueprint picker */}
|
{/* Blueprint picker */}
|
||||||
<div>
|
<div>
|
||||||
<label className="app-label">Project Blueprint (optional)<InfoTooltip content="Blueprints are templates that pre-fill role presets and default settings. Selecting one loads staffing requirements into Step 3." /></label>
|
<label className="app-label">
|
||||||
|
Project Blueprint (optional)
|
||||||
|
<InfoTooltip content="Blueprints are templates that pre-fill role presets and default settings. Selecting one loads staffing requirements into Step 3." />
|
||||||
|
</label>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mb-2">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mb-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -364,7 +391,10 @@ function Step1({ state, onChange }: Step1Props) {
|
|||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
{/* Short code */}
|
{/* Short code */}
|
||||||
<div>
|
<div>
|
||||||
<label className="app-label">Short Code *<InfoTooltip content="Unique chargecode for this project, used for time tracking and cost attribution. Must be uppercase alphanumeric." /></label>
|
<label className="app-label">
|
||||||
|
Short Code *
|
||||||
|
<InfoTooltip content="Unique chargecode for this project, used for time tracking and cost attribution. Must be uppercase alphanumeric." />
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={state.shortCode}
|
value={state.shortCode}
|
||||||
@@ -377,7 +407,10 @@ function Step1({ state, onChange }: Step1Props) {
|
|||||||
|
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
<div>
|
<div>
|
||||||
<label className="app-label">Project Name *<InfoTooltip content="Display name shown on the timeline and in reports." /></label>
|
<label className="app-label">
|
||||||
|
Project Name *
|
||||||
|
<InfoTooltip content="Display name shown on the timeline and in reports." />
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={state.name}
|
value={state.name}
|
||||||
@@ -389,7 +422,10 @@ function Step1({ state, onChange }: Step1Props) {
|
|||||||
|
|
||||||
{/* Order type */}
|
{/* Order type */}
|
||||||
<div>
|
<div>
|
||||||
<label className="app-label">Order Type *<InfoTooltip content="BD = Business Development (pre-sales), CHARGEABLE = billable client work, INTERNAL = internal project, OVERHEAD = non-project overhead." /></label>
|
<label className="app-label">
|
||||||
|
Order Type *
|
||||||
|
<InfoTooltip content="BD = Business Development (pre-sales), CHARGEABLE = billable client work, INTERNAL = internal project, OVERHEAD = non-project overhead." />
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={state.orderType}
|
value={state.orderType}
|
||||||
onChange={(e) => onChange({ orderType: e.target.value })}
|
onChange={(e) => onChange({ orderType: e.target.value })}
|
||||||
@@ -405,7 +441,10 @@ function Step1({ state, onChange }: Step1Props) {
|
|||||||
|
|
||||||
{/* Allocation type */}
|
{/* Allocation type */}
|
||||||
<div>
|
<div>
|
||||||
<label className="app-label">Allocation Type *<InfoTooltip content="INT = staffed with internal resources, EXT = staffed with external contractors or freelancers." /></label>
|
<label className="app-label">
|
||||||
|
Allocation Type *
|
||||||
|
<InfoTooltip content="INT = staffed with internal resources, EXT = staffed with external contractors or freelancers." />
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={state.allocationType}
|
value={state.allocationType}
|
||||||
onChange={(e) => onChange({ allocationType: e.target.value })}
|
onChange={(e) => onChange({ allocationType: e.target.value })}
|
||||||
@@ -430,13 +469,14 @@ function Step1({ state, onChange }: Step1Props) {
|
|||||||
{[...state.blueprintFieldDefs]
|
{[...state.blueprintFieldDefs]
|
||||||
.sort((a, b) => a.order - b.order)
|
.sort((a, b) => a.order - b.order)
|
||||||
.map((field) => (
|
.map((field) => (
|
||||||
<div key={field.key} className={field.type === FieldType.TEXTAREA ? "col-span-2" : ""}>
|
<div
|
||||||
|
key={field.key}
|
||||||
|
className={field.type === FieldType.TEXTAREA ? "col-span-2" : ""}
|
||||||
|
>
|
||||||
<label className="app-label">
|
<label className="app-label">
|
||||||
{field.label}
|
{field.label}
|
||||||
{field.required && " *"}
|
{field.required && " *"}
|
||||||
{field.description && (
|
{field.description && <InfoTooltip content={field.description} />}
|
||||||
<InfoTooltip content={field.description} />
|
|
||||||
)}
|
|
||||||
</label>
|
</label>
|
||||||
<DynamicFieldInput
|
<DynamicFieldInput
|
||||||
field={field}
|
field={field}
|
||||||
@@ -456,7 +496,13 @@ function Step1({ state, onChange }: Step1Props) {
|
|||||||
|
|
||||||
// ─── Responsible Person Picker ────────────────────────────────────────────────
|
// ─── Responsible Person Picker ────────────────────────────────────────────────
|
||||||
|
|
||||||
function ResourcePersonPicker({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
function ResourcePersonPicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
}) {
|
||||||
const [query, setQuery] = useState(value);
|
const [query, setQuery] = useState(value);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||||
@@ -478,7 +524,8 @@ function ResourcePersonPicker({ value, onChange }: { value: string; onChange: (v
|
|||||||
{ staleTime: 15_000, placeholderData: (prev: any) => prev },
|
{ staleTime: 15_000, placeholderData: (prev: any) => prev },
|
||||||
);
|
);
|
||||||
const filtered = useMemo(
|
const filtered = useMemo(
|
||||||
() => (data?.resources ?? []) as unknown as Array<{ id: string; displayName: string; eid: string }>,
|
() =>
|
||||||
|
(data?.resources ?? []) as unknown as Array<{ id: string; displayName: string; eid: string }>,
|
||||||
[data],
|
[data],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -516,7 +563,12 @@ function ResourcePersonPicker({ value, onChange }: { value: string; onChange: (v
|
|||||||
{isConfirmed && (
|
{isConfirmed && (
|
||||||
<span className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-green-500">
|
<span className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-green-500">
|
||||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -571,24 +623,27 @@ function Step2({ state, onChange }: Step2Props) {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="app-label">Start Date *<InfoTooltip content="First day of the project period. Assignments and budget tracking begin from this date." /></label>
|
<label className="app-label">
|
||||||
<DateInput
|
Start Date *
|
||||||
value={state.startDate}
|
<InfoTooltip content="First day of the project period. Assignments and budget tracking begin from this date." />
|
||||||
onChange={(v) => onChange({ startDate: v })}
|
</label>
|
||||||
/>
|
<DateInput value={state.startDate} onChange={(v) => onChange({ startDate: v })} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="app-label">End Date *<InfoTooltip content="Last day of the project period. Defines the timeline boundary for all assignments." /></label>
|
<label className="app-label">
|
||||||
<DateInput
|
End Date *
|
||||||
value={state.endDate}
|
<InfoTooltip content="Last day of the project period. Defines the timeline boundary for all assignments." />
|
||||||
onChange={(v) => onChange({ endDate: v })}
|
</label>
|
||||||
/>
|
<DateInput value={state.endDate} onChange={(v) => onChange({ endDate: v })} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="app-label">Budget (EUR)<InfoTooltip content="Total project budget in EUR. Stored internally as cents. Used to track spending against assignments." /></label>
|
<label className="app-label">
|
||||||
|
Budget (EUR)
|
||||||
|
<InfoTooltip content="Total project budget in EUR. Stored internally as cents. Used to track spending against assignments." />
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
@@ -600,7 +655,10 @@ function Step2({ state, onChange }: Step2Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="app-label">Responsible Person<InfoTooltip content="Project lead or account manager. Search by name or employee ID." /></label>
|
<label className="app-label">
|
||||||
|
Responsible Person
|
||||||
|
<InfoTooltip content="Project lead or account manager. Search by name or employee ID." />
|
||||||
|
</label>
|
||||||
<ResourcePersonPicker
|
<ResourcePersonPicker
|
||||||
value={state.responsiblePerson}
|
value={state.responsiblePerson}
|
||||||
onChange={(v) => onChange({ responsiblePerson: v })}
|
onChange={(v) => onChange({ responsiblePerson: v })}
|
||||||
@@ -639,19 +697,14 @@ interface Step3Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Step3({ state, onChange }: Step3Props) {
|
function Step3({ state, onChange }: Step3Props) {
|
||||||
const { data: rolesData } = trpc.role.list.useQuery(
|
const { data: rolesData } = trpc.role.list.useQuery({ isActive: true }, { staleTime: 30_000 });
|
||||||
{ isActive: true },
|
|
||||||
{ staleTime: 30_000 },
|
|
||||||
);
|
|
||||||
const roles = rolesData ?? [];
|
const roles = rolesData ?? [];
|
||||||
|
|
||||||
const { data: chaptersData } = trpc.resource.chapters.useQuery(undefined, { staleTime: 60_000 });
|
const { data: chaptersData } = trpc.resource.chapters.useQuery(undefined, { staleTime: 60_000 });
|
||||||
const chapters = (chaptersData ?? []) as string[];
|
const chapters = (chaptersData ?? []) as string[];
|
||||||
|
|
||||||
function updateReq(idx: number, patch: Partial<StaffingRequirement>) {
|
function updateReq(idx: number, patch: Partial<StaffingRequirement>) {
|
||||||
const next = state.staffingReqs.map((r, i) =>
|
const next = state.staffingReqs.map((r, i) => (i === idx ? { ...r, ...patch } : r));
|
||||||
i === idx ? { ...r, ...patch } : r,
|
|
||||||
);
|
|
||||||
onChange({ staffingReqs: next });
|
onChange({ staffingReqs: next });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -672,28 +725,40 @@ function Step3({ state, onChange }: Step3Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Budget allocation summary */}
|
{/* Budget allocation summary */}
|
||||||
{state.budgetEur && parseFloat(state.budgetEur) > 0 && state.staffingReqs.length > 0 && (() => {
|
{state.budgetEur &&
|
||||||
const projectBudgetCents = Math.round(parseFloat(state.budgetEur || "0") * 100);
|
parseFloat(state.budgetEur) > 0 &&
|
||||||
const allocatedCents = state.staffingReqs.reduce((sum, r) => sum + (r.budgetCents ?? 0), 0);
|
state.staffingReqs.length > 0 &&
|
||||||
const remainingCents = projectBudgetCents - allocatedCents;
|
(() => {
|
||||||
const pct = projectBudgetCents > 0 ? Math.round((allocatedCents / projectBudgetCents) * 100) : 0;
|
const projectBudgetCents = Math.round(parseFloat(state.budgetEur || "0") * 100);
|
||||||
return (
|
const allocatedCents = state.staffingReqs.reduce(
|
||||||
<div className={`mb-3 rounded-lg border p-3 text-xs ${remainingCents < 0 ? "bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-800 text-red-700 dark:text-red-400" : remainingCents === 0 ? "bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-800 text-green-700 dark:text-green-400" : "bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-800 text-amber-700 dark:text-amber-300"}`}>
|
(sum, r) => sum + (r.budgetCents ?? 0),
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
0,
|
||||||
<span className="font-semibold">Budget Allocation</span>
|
);
|
||||||
<span>{pct}% allocated</span>
|
const remainingCents = projectBudgetCents - allocatedCents;
|
||||||
|
const pct =
|
||||||
|
projectBudgetCents > 0 ? Math.round((allocatedCents / projectBudgetCents) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`mb-3 rounded-lg border p-3 text-xs ${remainingCents < 0 ? "bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-800 text-red-700 dark:text-red-400" : remainingCents === 0 ? "bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-800 text-green-700 dark:text-green-400" : "bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-800 text-amber-700 dark:text-amber-300"}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<span className="font-semibold">Budget Allocation</span>
|
||||||
|
<span>{pct}% allocated</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-1.5 bg-gray-200 rounded-full overflow-hidden mb-1.5">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${remainingCents < 0 ? "bg-red-500" : remainingCents === 0 ? "bg-green-500" : "bg-amber-500"}`}
|
||||||
|
style={{ width: `${Math.min(100, pct)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Project: {formatCents(projectBudgetCents)} EUR</span>
|
||||||
|
<span>Allocated: {formatCents(allocatedCents)} EUR</span>
|
||||||
|
<span className="font-semibold">Remaining: {formatCents(remainingCents)} EUR</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full h-1.5 bg-gray-200 rounded-full overflow-hidden mb-1.5">
|
);
|
||||||
<div className={`h-full rounded-full transition-all ${remainingCents < 0 ? "bg-red-500" : remainingCents === 0 ? "bg-green-500" : "bg-amber-500"}`} style={{ width: `${Math.min(100, pct)}%` }} />
|
})()}
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Project: {formatCents(projectBudgetCents)} EUR</span>
|
|
||||||
<span>Allocated: {formatCents(allocatedCents)} EUR</span>
|
|
||||||
<span className="font-semibold">Remaining: {formatCents(remainingCents)} EUR</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
<div className="space-y-3 max-h-[45vh] overflow-y-auto pr-1">
|
<div className="space-y-3 max-h-[45vh] overflow-y-auto pr-1">
|
||||||
{state.staffingReqs.length === 0 && (
|
{state.staffingReqs.length === 0 && (
|
||||||
@@ -705,7 +770,10 @@ function Step3({ state, onChange }: Step3Props) {
|
|||||||
<div key={req.id} className="border border-gray-200 rounded-lg p-3 bg-white">
|
<div key={req.id} className="border border-gray-200 rounded-lg p-3 bg-white">
|
||||||
<div className="flex flex-wrap items-start gap-2">
|
<div className="flex flex-wrap items-start gap-2">
|
||||||
<div className="flex-1 min-w-32">
|
<div className="flex-1 min-w-32">
|
||||||
<label className="text-xs text-gray-400">Role *<InfoTooltip content="Select a predefined role or enter a custom role name. Defines the skill profile for this staffing demand." /></label>
|
<label className="text-xs text-gray-400">
|
||||||
|
Role *
|
||||||
|
<InfoTooltip content="Select a predefined role or enter a custom role name. Defines the skill profile for this staffing demand." />
|
||||||
|
</label>
|
||||||
{roles.length > 0 ? (
|
{roles.length > 0 ? (
|
||||||
<select
|
<select
|
||||||
value={req.roleId ?? ""}
|
value={req.roleId ?? ""}
|
||||||
@@ -718,18 +786,22 @@ function Step3({ state, onChange }: Step3Props) {
|
|||||||
// Clear roleId — rebuild without the key
|
// Clear roleId — rebuild without the key
|
||||||
const { roleId: _r, ...rest } = state.staffingReqs[idx]!;
|
const { roleId: _r, ...rest } = state.staffingReqs[idx]!;
|
||||||
void _r;
|
void _r;
|
||||||
onChange({ staffingReqs: state.staffingReqs.map((r, i) => i === idx ? rest : r) });
|
onChange({
|
||||||
|
staffingReqs: state.staffingReqs.map((r, i) => (i === idx ? rest : r)),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="app-select w-full"
|
className="app-select w-full"
|
||||||
>
|
>
|
||||||
<option value="">Custom / Free text…</option>
|
<option value="">Custom / Free text…</option>
|
||||||
{roles.map((ro) => (
|
{roles.map((ro) => (
|
||||||
<option key={ro.id} value={ro.id}>{ro.name}</option>
|
<option key={ro.id} value={ro.id}>
|
||||||
|
{ro.name}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
) : null}
|
) : null}
|
||||||
{(!req.roleId) && (
|
{!req.roleId && (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={req.role}
|
value={req.role}
|
||||||
@@ -740,7 +812,10 @@ function Step3({ state, onChange }: Step3Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-20">
|
<div className="w-20">
|
||||||
<label className="text-xs text-gray-400">h/day<InfoTooltip content="Planned working hours per day for this role." /></label>
|
<label className="text-xs text-gray-400">
|
||||||
|
h/day
|
||||||
|
<InfoTooltip content="Planned working hours per day for this role." />
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={req.hoursPerDay}
|
value={req.hoursPerDay}
|
||||||
@@ -752,7 +827,10 @@ function Step3({ state, onChange }: Step3Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-16">
|
<div className="w-16">
|
||||||
<label className="text-xs text-gray-400">Count<InfoTooltip content="Number of people needed for this role. Unfilled seats become placeholder demands until assigned." /></label>
|
<label className="text-xs text-gray-400">
|
||||||
|
Count
|
||||||
|
<InfoTooltip content="Number of people needed for this role. Unfilled seats become placeholder demands until assigned." />
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={req.headcount}
|
value={req.headcount}
|
||||||
@@ -763,7 +841,10 @@ function Step3({ state, onChange }: Step3Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-28">
|
<div className="w-28">
|
||||||
<label className="text-xs text-gray-400">Budget (EUR)<InfoTooltip content="Optional budget cap for this role. Tracked against actual assignment costs." /></label>
|
<label className="text-xs text-gray-400">
|
||||||
|
Budget (EUR)
|
||||||
|
<InfoTooltip content="Optional budget cap for this role. Tracked against actual assignment costs." />
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={req.budgetCents ? req.budgetCents / 100 : ""}
|
value={req.budgetCents ? req.budgetCents / 100 : ""}
|
||||||
@@ -771,7 +852,9 @@ function Step3({ state, onChange }: Step3Props) {
|
|||||||
step={100}
|
step={100}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const val = parseFloat(e.target.value);
|
const val = parseFloat(e.target.value);
|
||||||
updateReq(idx, { budgetCents: Number.isFinite(val) && val > 0 ? Math.round(val * 100) : 0 } as Partial<StaffingRequirement>);
|
updateReq(idx, {
|
||||||
|
budgetCents: Number.isFinite(val) && val > 0 ? Math.round(val * 100) : 0,
|
||||||
|
} as Partial<StaffingRequirement>);
|
||||||
}}
|
}}
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
className="app-input"
|
className="app-input"
|
||||||
@@ -789,7 +872,10 @@ function Step3({ state, onChange }: Step3Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400">Required skills<InfoTooltip content="Skills a resource must have to be suggested for this role." /></label>
|
<label className="text-xs text-gray-400">
|
||||||
|
Required skills
|
||||||
|
<InfoTooltip content="Skills a resource must have to be suggested for this role." />
|
||||||
|
</label>
|
||||||
<SkillTagInput
|
<SkillTagInput
|
||||||
value={req.requiredSkills}
|
value={req.requiredSkills}
|
||||||
onChange={(skills) => updateReq(idx, { requiredSkills: skills })}
|
onChange={(skills) => updateReq(idx, { requiredSkills: skills })}
|
||||||
@@ -797,7 +883,10 @@ function Step3({ state, onChange }: Step3Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400">Preferred skills (optional)<InfoTooltip content="Nice-to-have skills that boost a resource's match score but are not mandatory." /></label>
|
<label className="text-xs text-gray-400">
|
||||||
|
Preferred skills (optional)
|
||||||
|
<InfoTooltip content="Nice-to-have skills that boost a resource's match score but are not mandatory." />
|
||||||
|
</label>
|
||||||
<SkillTagInput
|
<SkillTagInput
|
||||||
value={req.preferredSkills ?? []}
|
value={req.preferredSkills ?? []}
|
||||||
onChange={(skills) => updateReq(idx, { preferredSkills: skills })}
|
onChange={(skills) => updateReq(idx, { preferredSkills: skills })}
|
||||||
@@ -805,20 +894,27 @@ function Step3({ state, onChange }: Step3Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400">Chapter filter (optional)<InfoTooltip content="Restrict suggestions to resources from a specific chapter/department." /></label>
|
<label className="text-xs text-gray-400">
|
||||||
|
Chapter filter (optional)
|
||||||
|
<InfoTooltip content="Restrict suggestions to resources from a specific chapter/department." />
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
list="chapter-options"
|
list="chapter-options"
|
||||||
value={req.chapter ?? ""}
|
value={req.chapter ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateReq(idx, { chapter: e.target.value || undefined } as Partial<StaffingRequirement>)
|
updateReq(idx, {
|
||||||
|
chapter: e.target.value || undefined,
|
||||||
|
} as Partial<StaffingRequirement>)
|
||||||
}
|
}
|
||||||
placeholder="e.g. Art Direction"
|
placeholder="e.g. Art Direction"
|
||||||
className="app-input"
|
className="app-input"
|
||||||
/>
|
/>
|
||||||
{chapters.length > 0 && (
|
{chapters.length > 0 && (
|
||||||
<datalist id="chapter-options">
|
<datalist id="chapter-options">
|
||||||
{chapters.map((ch) => <option key={ch} value={ch} />)}
|
{chapters.map((ch) => (
|
||||||
|
<option key={ch} value={ch} />
|
||||||
|
))}
|
||||||
</datalist>
|
</datalist>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -890,7 +986,10 @@ function ReqSuggestions({
|
|||||||
|
|
||||||
if (!req.requiredSkills.length) {
|
if (!req.requiredSkills.length) {
|
||||||
return (
|
return (
|
||||||
<p className="text-xs text-amber-600">No skills defined for this demand yet. Go back to Step 3 and add required skills — the AI will then suggest matching resources here.</p>
|
<p className="text-xs text-amber-600">
|
||||||
|
No skills defined for this demand yet. Go back to Step 3 and add required skills — the AI
|
||||||
|
will then suggest matching resources here.
|
||||||
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -957,8 +1056,8 @@ function ReqSuggestions({
|
|||||||
item.valueScore >= 70
|
item.valueScore >= 70
|
||||||
? "bg-green-100 text-green-700"
|
? "bg-green-100 text-green-700"
|
||||||
: item.valueScore >= 40
|
: item.valueScore >= 40
|
||||||
? "bg-amber-100 text-amber-700"
|
? "bg-amber-100 text-amber-700"
|
||||||
: "bg-red-100 text-red-700"
|
: "bg-red-100 text-red-700"
|
||||||
}`}
|
}`}
|
||||||
title="Value Score (price/quality)"
|
title="Value Score (price/quality)"
|
||||||
>
|
>
|
||||||
@@ -1002,10 +1101,7 @@ interface Step4Props {
|
|||||||
function Step4({ state, onChange }: Step4Props) {
|
function Step4({ state, onChange }: Step4Props) {
|
||||||
function assign(requirementId: string, resourceId: string, resourceName: string, role: string) {
|
function assign(requirementId: string, resourceId: string, resourceName: string, role: string) {
|
||||||
onChange({
|
onChange({
|
||||||
assignments: [
|
assignments: [...state.assignments, { requirementId, resourceId, resourceName, role }],
|
||||||
...state.assignments,
|
|
||||||
{ requirementId, resourceId, resourceName, role },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1066,7 +1162,14 @@ interface Step5Props {
|
|||||||
submitWarnings: string[];
|
submitWarnings: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function Step5({ state, onChange, onSubmit, isSubmitting, submitError, submitWarnings }: Step5Props) {
|
function Step5({
|
||||||
|
state,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
isSubmitting,
|
||||||
|
submitError,
|
||||||
|
submitWarnings,
|
||||||
|
}: Step5Props) {
|
||||||
const totalAssignedCostHint = useMemo(() => {
|
const totalAssignedCostHint = useMemo(() => {
|
||||||
// Very rough hint: sum hoursPerDay * headcount across all requirements
|
// Very rough hint: sum hoursPerDay * headcount across all requirements
|
||||||
return state.staffingReqs.reduce((sum, r) => sum + r.hoursPerDay * r.headcount, 0);
|
return state.staffingReqs.reduce((sum, r) => sum + r.hoursPerDay * r.headcount, 0);
|
||||||
@@ -1076,7 +1179,9 @@ function Step5({ state, onChange, onSubmit, isSubmitting, submitError, submitWar
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Project summary */}
|
{/* Project summary */}
|
||||||
<div className="flex items-center gap-1 mb-1">
|
<div className="flex items-center gap-1 mb-1">
|
||||||
<p className="text-xs font-medium text-gray-600 uppercase tracking-wider">Project Summary</p>
|
<p className="text-xs font-medium text-gray-600 uppercase tracking-wider">
|
||||||
|
Project Summary
|
||||||
|
</p>
|
||||||
<InfoTooltip content="Review all project details before creation. The project, staffing demands, and any pre-assigned resources will be created together." />
|
<InfoTooltip content="Review all project details before creation. The project, staffing demands, and any pre-assigned resources will be created together." />
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-50 rounded-lg p-4 text-sm space-y-2">
|
<div className="bg-gray-50 rounded-lg p-4 text-sm space-y-2">
|
||||||
@@ -1104,8 +1209,7 @@ function Step5({ state, onChange, onSubmit, isSubmitting, submitError, submitWar
|
|||||||
{state.budgetEur ? `€${parseFloat(state.budgetEur).toLocaleString()}` : "—"}
|
{state.budgetEur ? `€${parseFloat(state.budgetEur).toLocaleString()}` : "—"}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500">Dates:</span>{" "}
|
<span className="text-gray-500">Dates:</span> {state.startDate} → {state.endDate}
|
||||||
{state.startDate} → {state.endDate}
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500">Win %:</span> {state.winProbability}%
|
<span className="text-gray-500">Win %:</span> {state.winProbability}%
|
||||||
@@ -1157,15 +1261,14 @@ function Step5({ state, onChange, onSubmit, isSubmitting, submitError, submitWar
|
|||||||
const assignedNames = assigned.map((a) => a.resourceName);
|
const assignedNames = assigned.map((a) => a.resourceName);
|
||||||
const unassigned = req.headcount - assigned.length;
|
const unassigned = req.headcount - assigned.length;
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={req.id} className="px-3 py-2 rounded-lg bg-gray-50 text-sm">
|
||||||
key={req.id}
|
|
||||||
className="px-3 py-2 rounded-lg bg-gray-50 text-sm"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="flex-1 font-medium">{req.role || "Unnamed"}</span>
|
<span className="flex-1 font-medium">{req.role || "Unnamed"}</span>
|
||||||
<span className="text-gray-400 text-xs">{req.hoursPerDay}h/day</span>
|
<span className="text-gray-400 text-xs">{req.hoursPerDay}h/day</span>
|
||||||
{req.budgetCents ? (
|
{req.budgetCents ? (
|
||||||
<span className="text-xs text-gray-400">{formatCents(req.budgetCents)} EUR</span>
|
<span className="text-xs text-gray-400">
|
||||||
|
{formatCents(req.budgetCents)} EUR
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span
|
<span
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -1179,10 +1282,20 @@ function Step5({ state, onChange, onSubmit, isSubmitting, submitError, submitWar
|
|||||||
{(req.requiredSkills.length > 0 || (req.preferredSkills ?? []).length > 0) && (
|
{(req.requiredSkills.length > 0 || (req.preferredSkills ?? []).length > 0) && (
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
{req.requiredSkills.map((s) => (
|
{req.requiredSkills.map((s) => (
|
||||||
<span key={s} className="px-1.5 py-0.5 rounded text-[10px] bg-brand-100 text-brand-700">{s}</span>
|
<span
|
||||||
|
key={s}
|
||||||
|
className="px-1.5 py-0.5 rounded text-[10px] bg-brand-100 text-brand-700"
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</span>
|
||||||
))}
|
))}
|
||||||
{(req.preferredSkills ?? []).map((s) => (
|
{(req.preferredSkills ?? []).map((s) => (
|
||||||
<span key={s} className="px-1.5 py-0.5 rounded text-[10px] bg-gray-100 text-gray-500">{s}</span>
|
<span
|
||||||
|
key={s}
|
||||||
|
className="px-1.5 py-0.5 rounded text-[10px] bg-gray-100 text-gray-500"
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1204,9 +1317,13 @@ function Step5({ state, onChange, onSubmit, isSubmitting, submitError, submitWar
|
|||||||
<div className="px-3 py-2 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 rounded-lg text-sm text-amber-800 dark:text-amber-300 space-y-1">
|
<div className="px-3 py-2 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 rounded-lg text-sm text-amber-800 dark:text-amber-300 space-y-1">
|
||||||
<p className="font-medium">Project created with warnings:</p>
|
<p className="font-medium">Project created with warnings:</p>
|
||||||
{submitWarnings.map((w, i) => (
|
{submitWarnings.map((w, i) => (
|
||||||
<p key={i} className="text-xs">{w}</p>
|
<p key={i} className="text-xs">
|
||||||
|
{w}
|
||||||
|
</p>
|
||||||
))}
|
))}
|
||||||
<p className="text-xs mt-1 text-amber-600 dark:text-amber-400">You can fix these staffing items from the project detail page.</p>
|
<p className="text-xs mt-1 text-amber-600 dark:text-amber-400">
|
||||||
|
You can fix these staffing items from the project detail page.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1253,12 +1370,7 @@ function Step5({ state, onChange, onSubmit, isSubmitting, submitError, submitWar
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button
|
<button type="button" onClick={onSubmit} disabled={isSubmitting} className={BTN_PRIMARY}>
|
||||||
type="button"
|
|
||||||
onClick={onSubmit}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className={BTN_PRIMARY}
|
|
||||||
>
|
|
||||||
{isSubmitting
|
{isSubmitting
|
||||||
? "Creating…"
|
? "Creating…"
|
||||||
: state.saveAsDraft
|
: state.saveAsDraft
|
||||||
@@ -1294,7 +1406,9 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
|
|||||||
mutateAsync: (input: unknown) => Promise<unknown>;
|
mutateAsync: (input: unknown) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const createDemandRequirement = (trpc.allocation.createDemandRequirement.useMutation as any)() as {
|
const createDemandRequirement = (
|
||||||
|
trpc.allocation.createDemandRequirement.useMutation as any
|
||||||
|
)() as {
|
||||||
mutateAsync: (input: unknown) => Promise<unknown>;
|
mutateAsync: (input: unknown) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1321,7 +1435,12 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
|
|||||||
const missingRequired = state.blueprintFieldDefs.some((f) => {
|
const missingRequired = state.blueprintFieldDefs.some((f) => {
|
||||||
if (!f.required) return false;
|
if (!f.required) return false;
|
||||||
const val = state.dynamicFields[f.key];
|
const val = state.dynamicFields[f.key];
|
||||||
return val === undefined || val === null || val === "" || (Array.isArray(val) && val.length === 0);
|
return (
|
||||||
|
val === undefined ||
|
||||||
|
val === null ||
|
||||||
|
val === "" ||
|
||||||
|
(Array.isArray(val) && val.length === 0)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
state.shortCode.trim().length > 0 &&
|
state.shortCode.trim().length > 0 &&
|
||||||
@@ -1343,9 +1462,7 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
|
|||||||
// any requirement that exists must be valid: must have a role and positive hours/headcount.
|
// any requirement that exists must be valid: must have a role and positive hours/headcount.
|
||||||
return state.staffingReqs.every(
|
return state.staffingReqs.every(
|
||||||
(r) =>
|
(r) =>
|
||||||
(r.roleId != null || r.role.trim().length > 0) &&
|
(r.roleId != null || r.role.trim().length > 0) && r.hoursPerDay > 0 && r.headcount >= 1,
|
||||||
r.hoursPerDay > 0 &&
|
|
||||||
r.headcount >= 1,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -1419,7 +1536,9 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
warnings.push(`Demand for "${req.role || "Unnamed Role"}" (${unassigned} seat${unassigned > 1 ? "s" : ""}) failed: ${msg}`);
|
warnings.push(
|
||||||
|
`Demand for "${req.role || "Unnamed Role"}" (${unassigned} seat${unassigned > 1 ? "s" : ""}) failed: ${msg}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1461,9 +1580,7 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
|
|||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8 px-4">
|
||||||
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8 px-4"
|
|
||||||
>
|
|
||||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl relative">
|
<div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl relative">
|
||||||
{/* Celebration effects */}
|
{/* Celebration effects */}
|
||||||
<ConfettiBurst trigger={showConfetti} />
|
<ConfettiBurst trigger={showConfetti} />
|
||||||
@@ -1527,11 +1644,7 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
|
|||||||
)}
|
)}
|
||||||
{step === 4 && (
|
{step === 4 && (
|
||||||
<div className="flex items-center px-6 py-4 border-t border-gray-200">
|
<div className="flex items-center px-6 py-4 border-t border-gray-200">
|
||||||
<button
|
<button type="button" onClick={() => setStep((s) => s - 1)} className={BTN_SECONDARY}>
|
||||||
type="button"
|
|
||||||
onClick={() => setStep((s) => s - 1)}
|
|
||||||
className={BTN_SECONDARY}
|
|
||||||
>
|
|
||||||
← Back
|
← Back
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,10 +18,16 @@ export function MfaSetup() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!uri) return;
|
if (!uri) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
QRCode.toDataURL(uri, { width: 200, margin: 2 }).then((dataUrl) => {
|
QRCode.toDataURL(uri, { width: 200, margin: 2 })
|
||||||
if (!cancelled) setQrDataUrl(dataUrl);
|
.then((dataUrl) => {
|
||||||
}).catch(() => {/* ignore — manual key is shown as fallback */});
|
if (!cancelled) setQrDataUrl(dataUrl);
|
||||||
return () => { cancelled = true; };
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* ignore — manual key is shown as fallback */
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [uri]);
|
}, [uri]);
|
||||||
|
|
||||||
const { data: mfaStatus, refetch } = trpc.user.getMfaStatus.useQuery();
|
const { data: mfaStatus, refetch } = trpc.user.getMfaStatus.useQuery();
|
||||||
@@ -60,12 +66,24 @@ export function MfaSetup() {
|
|||||||
<div className="rounded-xl border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 p-6">
|
<div className="rounded-xl border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 p-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/40">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/40">
|
||||||
<svg className="h-5 w-5 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
className="h-5 w-5 text-green-600 dark:text-green-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-green-800 dark:text-green-300">MFA Enabled</h3>
|
<h3 className="text-sm font-semibold text-green-800 dark:text-green-300">
|
||||||
|
MFA Enabled
|
||||||
|
</h3>
|
||||||
<p className="text-sm text-green-700 dark:text-green-400">
|
<p className="text-sm text-green-700 dark:text-green-400">
|
||||||
Two-factor authentication is active on your account.
|
Two-factor authentication is active on your account.
|
||||||
</p>
|
</p>
|
||||||
@@ -92,14 +110,27 @@ export function MfaSetup() {
|
|||||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6">
|
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/40">
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/40">
|
||||||
<svg className="h-5 w-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
className="h-5 w-5 text-amber-600 dark:text-amber-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Two-Factor Authentication (TOTP)</h3>
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Two-Factor Authentication (TOTP)
|
||||||
|
</h3>
|
||||||
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
Add an extra layer of security by requiring a code from your authenticator app when signing in.
|
Add an extra layer of security by requiring a code from your authenticator app when
|
||||||
|
signing in.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -116,17 +147,25 @@ export function MfaSetup() {
|
|||||||
|
|
||||||
{step === "show-secret" && (
|
{step === "show-secret" && (
|
||||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6 space-y-5">
|
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6 space-y-5">
|
||||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Step 1: Scan the QR code</h3>
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Step 1: Scan the QR code
|
||||||
|
</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.).
|
Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password,
|
||||||
|
etc.).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* QR Code — rendered locally, no external service */}
|
{/* QR Code — rendered locally, no external service */}
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white p-3">
|
<div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white p-3">
|
||||||
{qrDataUrl ? (
|
{qrDataUrl ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
<img
|
||||||
<img src={qrDataUrl} alt="TOTP QR Code" width={200} height={200} className="rounded" />
|
src={qrDataUrl}
|
||||||
|
alt="TOTP QR Code"
|
||||||
|
width={200}
|
||||||
|
height={200}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-[200px] w-[200px] flex items-center justify-center text-xs text-gray-400">
|
<div className="h-[200px] w-[200px] flex items-center justify-center text-xs text-gray-400">
|
||||||
Generating…
|
Generating…
|
||||||
@@ -146,7 +185,10 @@ export function MfaSetup() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setStep("verify"); setError(null); }}
|
onClick={() => {
|
||||||
|
setStep("verify");
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
className="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-brand-700 transition-colors"
|
className="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-brand-700 transition-colors"
|
||||||
>
|
>
|
||||||
Continue
|
Continue
|
||||||
@@ -156,13 +198,18 @@ export function MfaSetup() {
|
|||||||
|
|
||||||
{step === "verify" && (
|
{step === "verify" && (
|
||||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6 space-y-5">
|
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6 space-y-5">
|
||||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Step 2: Verify your code</h3>
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Step 2: Verify your code
|
||||||
|
</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
Enter the 6-digit code from your authenticator app to confirm setup.
|
Enter the 6-digit code from your authenticator app to confirm setup.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="mfa-verify-token" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label
|
||||||
|
htmlFor="mfa-verify-token"
|
||||||
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
|
>
|
||||||
Verification Code
|
Verification Code
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -191,7 +238,11 @@ export function MfaSetup() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setStep("show-secret"); setToken(""); setError(null); }}
|
onClick={() => {
|
||||||
|
setStep("show-secret");
|
||||||
|
setToken("");
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
|
|||||||
@@ -7,7 +7,15 @@ import {
|
|||||||
type Assignment,
|
type Assignment,
|
||||||
type DemandRequirement,
|
type DemandRequirement,
|
||||||
} from "@capakraken/shared";
|
} from "@capakraken/shared";
|
||||||
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useTimelineSSE } from "~/hooks/useTimelineSSE.js";
|
import { useTimelineSSE } from "~/hooks/useTimelineSSE.js";
|
||||||
@@ -77,7 +85,9 @@ export type TimelineProjectEntry = TimelineAssignmentEntry | TimelineDemandEntry
|
|||||||
|
|
||||||
export type ViewMode = "resource" | "project";
|
export type ViewMode = "resource" | "project";
|
||||||
|
|
||||||
function buildTimelineFiltersFromSearchParams(searchParams: ReturnType<typeof useSearchParams>): TimelineFilters {
|
function buildTimelineFiltersFromSearchParams(
|
||||||
|
searchParams: ReturnType<typeof useSearchParams>,
|
||||||
|
): TimelineFilters {
|
||||||
const savedPrefs = readAppPreferences();
|
const savedPrefs = readAppPreferences();
|
||||||
const next: TimelineFilters = {
|
const next: TimelineFilters = {
|
||||||
...DEFAULT_FILTERS,
|
...DEFAULT_FILTERS,
|
||||||
@@ -236,9 +246,10 @@ export function TimelineProvider({
|
|||||||
}: TimelineProviderProps) {
|
}: TimelineProviderProps) {
|
||||||
const { data: session, status: sessionStatus } = useSession();
|
const { data: session, status: sessionStatus } = useSession();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const role = sessionStatus === "authenticated"
|
const role =
|
||||||
? ((session.user as { role?: string } | undefined)?.role ?? "USER")
|
sessionStatus === "authenticated"
|
||||||
: null;
|
? ((session.user as { role?: string } | undefined)?.role ?? "USER")
|
||||||
|
: null;
|
||||||
const isSelfServiceTimeline = role === "USER" || role === "VIEWER";
|
const isSelfServiceTimeline = role === "USER" || role === "VIEWER";
|
||||||
const isRoleLoading = sessionStatus === "loading";
|
const isRoleLoading = sessionStatus === "loading";
|
||||||
|
|
||||||
@@ -268,7 +279,9 @@ export function TimelineProvider({
|
|||||||
const viewEnd = addDays(viewStart, viewDays);
|
const viewEnd = addDays(viewStart, viewDays);
|
||||||
|
|
||||||
// Support URL params: ?eids=EMP-001,EMP-002&projectIds=id1,id2&chapters=ch1
|
// Support URL params: ?eids=EMP-001,EMP-002&projectIds=id1,id2&chapters=ch1
|
||||||
const [filters, setFilters] = useState<TimelineFilters>(() => buildTimelineFiltersFromSearchParams(searchParams));
|
const [filters, setFilters] = useState<TimelineFilters>(() =>
|
||||||
|
buildTimelineFiltersFromSearchParams(searchParams),
|
||||||
|
);
|
||||||
|
|
||||||
// Sync filters/viewStart/viewDays from URL params on mount and after later changes
|
// Sync filters/viewStart/viewDays from URL params on mount and after later changes
|
||||||
// (e.g. direct nav from another page or router.push("/timeline?eids=...") while already on /timeline)
|
// (e.g. direct nav from another page or router.push("/timeline?eids=...") while already on /timeline)
|
||||||
@@ -318,14 +331,13 @@ export function TimelineProvider({
|
|||||||
|
|
||||||
const staffEntriesViewQuery = trpc.timeline.getEntriesView.useQuery(
|
const staffEntriesViewQuery = trpc.timeline.getEntriesView.useQuery(
|
||||||
timelineQueryInput,
|
timelineQueryInput,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
{
|
{
|
||||||
enabled: !isRoleLoading && !isSelfServiceTimeline,
|
enabled: !isRoleLoading && !isSelfServiceTimeline,
|
||||||
placeholderData: (prev: any) => prev,
|
placeholderData: (prev: any) => prev,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
staleTime: 90_000,
|
staleTime: 90_000,
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
) as {
|
) as {
|
||||||
data: TimelineEntriesView | undefined;
|
data: TimelineEntriesView | undefined;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -335,14 +347,13 @@ export function TimelineProvider({
|
|||||||
|
|
||||||
const selfEntriesViewQuery = trpc.timeline.getMyEntriesView.useQuery(
|
const selfEntriesViewQuery = trpc.timeline.getMyEntriesView.useQuery(
|
||||||
timelineQueryInput,
|
timelineQueryInput,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
{
|
{
|
||||||
enabled: !isRoleLoading && isSelfServiceTimeline,
|
enabled: !isRoleLoading && isSelfServiceTimeline,
|
||||||
placeholderData: (prev: any) => prev,
|
placeholderData: (prev: any) => prev,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
staleTime: 90_000,
|
staleTime: 90_000,
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
) as {
|
) as {
|
||||||
data: TimelineEntriesView | undefined;
|
data: TimelineEntriesView | undefined;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -351,7 +362,12 @@ export function TimelineProvider({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const entriesViewQuery = isSelfServiceTimeline ? selfEntriesViewQuery : staffEntriesViewQuery;
|
const entriesViewQuery = isSelfServiceTimeline ? selfEntriesViewQuery : staffEntriesViewQuery;
|
||||||
const { data: entriesView, isLoading, isError: isEntriesError, refetch: refetchEntriesView } = entriesViewQuery;
|
const {
|
||||||
|
data: entriesView,
|
||||||
|
isLoading,
|
||||||
|
isError: isEntriesError,
|
||||||
|
refetch: refetchEntriesView,
|
||||||
|
} = entriesViewQuery;
|
||||||
|
|
||||||
const assignments = entriesView?.assignments ?? [];
|
const assignments = entriesView?.assignments ?? [];
|
||||||
const demands = entriesView?.demands ?? [];
|
const demands = entriesView?.demands ?? [];
|
||||||
@@ -374,37 +390,33 @@ export function TimelineProvider({
|
|||||||
refetch: () => Promise<unknown>;
|
refetch: () => Promise<unknown>;
|
||||||
};
|
};
|
||||||
const vacationEntriesQuery = vacationListQuery(
|
const vacationEntriesQuery = vacationListQuery(
|
||||||
{ startDate: viewStart, endDate: viewEnd, status: [VacationStatus.APPROVED, VacationStatus.PENDING], limit: 500 },
|
{
|
||||||
|
startDate: viewStart,
|
||||||
|
endDate: viewEnd,
|
||||||
|
status: [VacationStatus.APPROVED, VacationStatus.PENDING],
|
||||||
|
limit: 500,
|
||||||
|
},
|
||||||
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
|
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
|
||||||
);
|
);
|
||||||
const {
|
const { data: vacationEntries = [], refetch: refetchVacations } = vacationEntriesQuery;
|
||||||
data: vacationEntries = [],
|
|
||||||
refetch: refetchVacations,
|
|
||||||
} = vacationEntriesQuery;
|
|
||||||
|
|
||||||
const staffHolidayOverlayQuery = trpc.timeline.getHolidayOverlays.useQuery(
|
const staffHolidayOverlayQuery = trpc.timeline.getHolidayOverlays.useQuery(timelineQueryInput, {
|
||||||
timelineQueryInput,
|
enabled: !isRoleLoading && !isSelfServiceTimeline,
|
||||||
{
|
placeholderData: (prev) => prev,
|
||||||
enabled: !isRoleLoading && !isSelfServiceTimeline,
|
refetchOnWindowFocus: false,
|
||||||
placeholderData: (prev) => prev,
|
staleTime: 90_000,
|
||||||
refetchOnWindowFocus: false,
|
});
|
||||||
staleTime: 90_000,
|
const selfHolidayOverlayQuery = trpc.timeline.getMyHolidayOverlays.useQuery(timelineQueryInput, {
|
||||||
},
|
enabled: !isRoleLoading && isSelfServiceTimeline,
|
||||||
);
|
placeholderData: (prev) => prev,
|
||||||
const selfHolidayOverlayQuery = trpc.timeline.getMyHolidayOverlays.useQuery(
|
refetchOnWindowFocus: false,
|
||||||
timelineQueryInput,
|
staleTime: 90_000,
|
||||||
{
|
});
|
||||||
enabled: !isRoleLoading && isSelfServiceTimeline,
|
const activeHolidayOverlayQuery = isSelfServiceTimeline
|
||||||
placeholderData: (prev) => prev,
|
? selfHolidayOverlayQuery
|
||||||
refetchOnWindowFocus: false,
|
: staffHolidayOverlayQuery;
|
||||||
staleTime: 90_000,
|
const { data: holidayOverlayEntries = [], refetch: refetchHolidayOverlays } =
|
||||||
},
|
activeHolidayOverlayQuery;
|
||||||
);
|
|
||||||
const activeHolidayOverlayQuery = isSelfServiceTimeline ? selfHolidayOverlayQuery : staffHolidayOverlayQuery;
|
|
||||||
const {
|
|
||||||
data: holidayOverlayEntries = [],
|
|
||||||
refetch: refetchHolidayOverlays,
|
|
||||||
} = activeHolidayOverlayQuery;
|
|
||||||
|
|
||||||
const initialRefreshKey = useMemo(
|
const initialRefreshKey = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -510,7 +522,8 @@ export function TimelineProvider({
|
|||||||
// Hide fully-filled demands (status COMPLETED or unfilledHeadcount <= 0)
|
// Hide fully-filled demands (status COMPLETED or unfilledHeadcount <= 0)
|
||||||
const demandEntry = entry as { status?: string; unfilledHeadcount?: number };
|
const demandEntry = entry as { status?: string; unfilledHeadcount?: number };
|
||||||
if (demandEntry.status === "COMPLETED") return false;
|
if (demandEntry.status === "COMPLETED") return false;
|
||||||
if (typeof demandEntry.unfilledHeadcount === "number" && demandEntry.unfilledHeadcount <= 0) return false;
|
if (typeof demandEntry.unfilledHeadcount === "number" && demandEntry.unfilledHeadcount <= 0)
|
||||||
|
return false;
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
[demands, filters.hideCompletedProjects, filters.showDrafts, filters.showPlaceholders],
|
[demands, filters.hideCompletedProjects, filters.showDrafts, filters.showPlaceholders],
|
||||||
@@ -642,7 +655,7 @@ export function TimelineProvider({
|
|||||||
filters.eids,
|
filters.eids,
|
||||||
filters.projectIds,
|
filters.projectIds,
|
||||||
filters.clientIds,
|
filters.clientIds,
|
||||||
]); // eslint-disable-line react-hooks/exhaustive-deps
|
]);
|
||||||
|
|
||||||
// ─── Project groups (for project view) ────────────────────────────────────
|
// ─── Project groups (for project view) ────────────────────────────────────
|
||||||
const projectGroups = useMemo(() => {
|
const projectGroups = useMemo(() => {
|
||||||
@@ -714,18 +727,9 @@ export function TimelineProvider({
|
|||||||
.sort((a, b) => a.startDate.getTime() - b.startDate.getTime())
|
.sort((a, b) => a.startDate.getTime() - b.startDate.getTime())
|
||||||
.filter((pg) => {
|
.filter((pg) => {
|
||||||
if (projectFilter.size > 0 && !projectFilter.has(pg.id)) return false;
|
if (projectFilter.size > 0 && !projectFilter.has(pg.id)) return false;
|
||||||
if (
|
if (clientFilter.size > 0 && (!pg.clientId || !clientFilter.has(pg.clientId))) return false;
|
||||||
clientFilter.size > 0 &&
|
if (chapterFilter.size > 0 && pg.resourceRows.length === 0) return false;
|
||||||
(!pg.clientId || !clientFilter.has(pg.clientId))
|
if (eidFilter.size > 0 && pg.resourceRows.length === 0) return false;
|
||||||
)
|
|
||||||
return false;
|
|
||||||
if (
|
|
||||||
chapterFilter.size > 0 &&
|
|
||||||
pg.resourceRows.length === 0
|
|
||||||
)
|
|
||||||
return false;
|
|
||||||
if (eidFilter.size > 0 && pg.resourceRows.length === 0)
|
|
||||||
return false;
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
@@ -736,7 +740,7 @@ export function TimelineProvider({
|
|||||||
filters.clientIds,
|
filters.clientIds,
|
||||||
filters.chapters,
|
filters.chapters,
|
||||||
filters.eids,
|
filters.eids,
|
||||||
]); // eslint-disable-line react-hooks/exhaustive-deps
|
]);
|
||||||
|
|
||||||
// ─── Derived counts ───────────────────────────────────────────────────────
|
// ─── Derived counts ───────────────────────────────────────────────────────
|
||||||
const isInitialLoading = (isRoleLoading || isLoading) && !entriesView;
|
const isInitialLoading = (isRoleLoading || isLoading) && !entriesView;
|
||||||
|
|||||||
@@ -48,13 +48,21 @@ import { InlineAllocationEditor } from "./InlineAllocationEditor.js";
|
|||||||
export function TimelineView() {
|
export function TimelineView() {
|
||||||
const { data: session, status: sessionStatus } = useSession();
|
const { data: session, status: sessionStatus } = useSession();
|
||||||
const mousePosRef = useRef({ x: 0, y: 0 });
|
const mousePosRef = useRef({ x: 0, y: 0 });
|
||||||
const role = sessionStatus === "authenticated"
|
const role =
|
||||||
? ((session.user as { role?: string } | undefined)?.role ?? "USER")
|
sessionStatus === "authenticated"
|
||||||
: null;
|
? ((session.user as { role?: string } | undefined)?.role ?? "USER")
|
||||||
|
: null;
|
||||||
const isSelfServiceTimeline = role === "USER" || role === "VIEWER";
|
const isSelfServiceTimeline = role === "USER" || role === "VIEWER";
|
||||||
const canManageTimeline = !isSelfServiceTimeline;
|
const canManageTimeline = !isSelfServiceTimeline;
|
||||||
|
|
||||||
const { push: pushHistory, pushBatch: pushBatchHistory, undo, redo, canUndo, canRedo } = useAllocationHistory();
|
const {
|
||||||
|
push: pushHistory,
|
||||||
|
pushBatch: pushBatchHistory,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
} = useAllocationHistory();
|
||||||
const pushHistoryRef = useRef(pushHistory);
|
const pushHistoryRef = useRef(pushHistory);
|
||||||
pushHistoryRef.current = pushHistory;
|
pushHistoryRef.current = pushHistory;
|
||||||
const pushBatchHistoryRef = useRef(pushBatchHistory);
|
const pushBatchHistoryRef = useRef(pushBatchHistory);
|
||||||
@@ -145,7 +153,7 @@ export function TimelineView() {
|
|||||||
pushHistoryRef.current(snapshot);
|
pushHistoryRef.current(snapshot);
|
||||||
},
|
},
|
||||||
onShiftClickAlloc: (allocationId: string) => {
|
onShiftClickAlloc: (allocationId: string) => {
|
||||||
setMultiSelectState(prev => {
|
setMultiSelectState((prev) => {
|
||||||
const ids = new Set(prev.selectedAllocationIds);
|
const ids = new Set(prev.selectedAllocationIds);
|
||||||
if (ids.has(allocationId)) {
|
if (ids.has(allocationId)) {
|
||||||
ids.delete(allocationId);
|
ids.delete(allocationId);
|
||||||
@@ -169,61 +177,64 @@ export function TimelineView() {
|
|||||||
const [openPanelProjectId, setOpenPanelProjectId] = useState<string | null>(null);
|
const [openPanelProjectId, setOpenPanelProjectId] = useState<string | null>(null);
|
||||||
const dragProjectId = dragState.isDragging ? dragState.projectId : null;
|
const dragProjectId = dragState.isDragging ? dragState.projectId : null;
|
||||||
const contextProjectId = canManageTimeline ? (dragProjectId ?? openPanelProjectId) : null;
|
const contextProjectId = canManageTimeline ? (dragProjectId ?? openPanelProjectId) : null;
|
||||||
const { contextResourceIds, contextAllocations } = useProjectDragContext(contextProjectId, canManageTimeline);
|
const { contextResourceIds, contextAllocations } = useProjectDragContext(
|
||||||
|
contextProjectId,
|
||||||
|
canManageTimeline,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SuccessToast
|
<SuccessToast
|
||||||
show={dragErrorToast !== null}
|
show={dragErrorToast !== null}
|
||||||
message={dragErrorToast ?? ""}
|
message={dragErrorToast ?? ""}
|
||||||
variant="warning"
|
variant="warning"
|
||||||
onDone={() => setDragErrorToast(null)}
|
onDone={() => setDragErrorToast(null)}
|
||||||
/>
|
|
||||||
<TimelineProvider
|
|
||||||
isDragging={dragState.isDragging}
|
|
||||||
contextAllocations={contextAllocations as TimelineAssignmentEntry[]}
|
|
||||||
>
|
|
||||||
<TimelineViewContent
|
|
||||||
mousePosRef={mousePosRef}
|
|
||||||
cellWidthRef={cellWidthRef}
|
|
||||||
dragState={dragState}
|
|
||||||
allocDragState={allocDragState}
|
|
||||||
rangeState={rangeState}
|
|
||||||
multiSelectState={multiSelectState}
|
|
||||||
setMultiSelectState={setMultiSelectState}
|
|
||||||
optimisticAllocations={optimisticAllocations}
|
|
||||||
reconcileOptimisticAllocations={reconcileOptimisticAllocations}
|
|
||||||
onCanvasRightMouseDown={onCanvasRightMouseDown}
|
|
||||||
clearMultiSelect={clearMultiSelect}
|
|
||||||
shiftPreview={shiftPreview}
|
|
||||||
isPreviewLoading={isPreviewLoading}
|
|
||||||
isApplying={isApplying}
|
|
||||||
isAllocSaving={isAllocSaving}
|
|
||||||
onProjectBarMouseDown={onProjectBarMouseDown}
|
|
||||||
onAllocMouseDown={onAllocMouseDown}
|
|
||||||
onRowMouseDown={onRowMouseDown}
|
|
||||||
onCanvasMouseMove={onCanvasMouseMove}
|
|
||||||
onCanvasMouseUp={onCanvasMouseUp}
|
|
||||||
onCanvasMouseLeave={onCanvasMouseLeave}
|
|
||||||
onProjectBarTouchStart={onProjectBarTouchStart}
|
|
||||||
onAllocTouchStart={onAllocTouchStart}
|
|
||||||
onRowTouchStart={onRowTouchStart}
|
|
||||||
onCanvasTouchMove={onCanvasTouchMove}
|
|
||||||
onCanvasTouchEnd={onCanvasTouchEnd}
|
|
||||||
contextResourceIds={contextResourceIds}
|
|
||||||
popover={popover}
|
|
||||||
setPopover={setPopover}
|
|
||||||
newAllocPopover={newAllocPopover}
|
|
||||||
setNewAllocPopover={setNewAllocPopover}
|
|
||||||
openPanelProjectId={openPanelProjectId}
|
|
||||||
setOpenPanelProjectId={setOpenPanelProjectId}
|
|
||||||
canUndo={canUndo}
|
|
||||||
canRedo={canRedo}
|
|
||||||
isSelfServiceTimeline={isSelfServiceTimeline}
|
|
||||||
undo={undo}
|
|
||||||
redo={redo}
|
|
||||||
/>
|
/>
|
||||||
</TimelineProvider>
|
<TimelineProvider
|
||||||
|
isDragging={dragState.isDragging}
|
||||||
|
contextAllocations={contextAllocations as TimelineAssignmentEntry[]}
|
||||||
|
>
|
||||||
|
<TimelineViewContent
|
||||||
|
mousePosRef={mousePosRef}
|
||||||
|
cellWidthRef={cellWidthRef}
|
||||||
|
dragState={dragState}
|
||||||
|
allocDragState={allocDragState}
|
||||||
|
rangeState={rangeState}
|
||||||
|
multiSelectState={multiSelectState}
|
||||||
|
setMultiSelectState={setMultiSelectState}
|
||||||
|
optimisticAllocations={optimisticAllocations}
|
||||||
|
reconcileOptimisticAllocations={reconcileOptimisticAllocations}
|
||||||
|
onCanvasRightMouseDown={onCanvasRightMouseDown}
|
||||||
|
clearMultiSelect={clearMultiSelect}
|
||||||
|
shiftPreview={shiftPreview}
|
||||||
|
isPreviewLoading={isPreviewLoading}
|
||||||
|
isApplying={isApplying}
|
||||||
|
isAllocSaving={isAllocSaving}
|
||||||
|
onProjectBarMouseDown={onProjectBarMouseDown}
|
||||||
|
onAllocMouseDown={onAllocMouseDown}
|
||||||
|
onRowMouseDown={onRowMouseDown}
|
||||||
|
onCanvasMouseMove={onCanvasMouseMove}
|
||||||
|
onCanvasMouseUp={onCanvasMouseUp}
|
||||||
|
onCanvasMouseLeave={onCanvasMouseLeave}
|
||||||
|
onProjectBarTouchStart={onProjectBarTouchStart}
|
||||||
|
onAllocTouchStart={onAllocTouchStart}
|
||||||
|
onRowTouchStart={onRowTouchStart}
|
||||||
|
onCanvasTouchMove={onCanvasTouchMove}
|
||||||
|
onCanvasTouchEnd={onCanvasTouchEnd}
|
||||||
|
contextResourceIds={contextResourceIds}
|
||||||
|
popover={popover}
|
||||||
|
setPopover={setPopover}
|
||||||
|
newAllocPopover={newAllocPopover}
|
||||||
|
setNewAllocPopover={setNewAllocPopover}
|
||||||
|
openPanelProjectId={openPanelProjectId}
|
||||||
|
setOpenPanelProjectId={setOpenPanelProjectId}
|
||||||
|
canUndo={canUndo}
|
||||||
|
canRedo={canRedo}
|
||||||
|
isSelfServiceTimeline={isSelfServiceTimeline}
|
||||||
|
undo={undo}
|
||||||
|
redo={redo}
|
||||||
|
/>
|
||||||
|
</TimelineProvider>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -278,7 +289,9 @@ function TimelineViewContent({
|
|||||||
multiSelectState: ReturnType<typeof useTimelineDrag>["multiSelectState"];
|
multiSelectState: ReturnType<typeof useTimelineDrag>["multiSelectState"];
|
||||||
setMultiSelectState: ReturnType<typeof useTimelineDrag>["setMultiSelectState"];
|
setMultiSelectState: ReturnType<typeof useTimelineDrag>["setMultiSelectState"];
|
||||||
optimisticAllocations: TimelineVisualOverrides;
|
optimisticAllocations: TimelineVisualOverrides;
|
||||||
reconcileOptimisticAllocations: ReturnType<typeof useTimelineDrag>["reconcileOptimisticAllocations"];
|
reconcileOptimisticAllocations: ReturnType<
|
||||||
|
typeof useTimelineDrag
|
||||||
|
>["reconcileOptimisticAllocations"];
|
||||||
onCanvasRightMouseDown: ReturnType<typeof useTimelineDrag>["onCanvasRightMouseDown"];
|
onCanvasRightMouseDown: ReturnType<typeof useTimelineDrag>["onCanvasRightMouseDown"];
|
||||||
clearMultiSelect: ReturnType<typeof useTimelineDrag>["clearMultiSelect"];
|
clearMultiSelect: ReturnType<typeof useTimelineDrag>["clearMultiSelect"];
|
||||||
shiftPreview: ReturnType<typeof useTimelineDrag>["shiftPreview"];
|
shiftPreview: ReturnType<typeof useTimelineDrag>["shiftPreview"];
|
||||||
@@ -410,17 +423,15 @@ function TimelineViewContent({
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const hasActivePointerOverlay =
|
const hasActivePointerOverlay =
|
||||||
dragState.isDragging || allocDragState.isActive || rangeState.isSelecting || multiSelectState.isMultiDragging;
|
dragState.isDragging ||
|
||||||
|
allocDragState.isActive ||
|
||||||
|
rangeState.isSelecting ||
|
||||||
|
multiSelectState.isMultiDragging;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (optimisticAllocations.size === 0) return;
|
if (optimisticAllocations.size === 0) return;
|
||||||
reconcileOptimisticAllocations([...visibleAssignments, ...visibleDemands]);
|
reconcileOptimisticAllocations([...visibleAssignments, ...visibleDemands]);
|
||||||
}, [
|
}, [optimisticAllocations, reconcileOptimisticAllocations, visibleAssignments, visibleDemands]);
|
||||||
optimisticAllocations,
|
|
||||||
reconcileOptimisticAllocations,
|
|
||||||
visibleAssignments,
|
|
||||||
visibleDemands,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasActivePointerOverlay) return;
|
if (!hasActivePointerOverlay) return;
|
||||||
@@ -473,12 +484,18 @@ function TimelineViewContent({
|
|||||||
if (!allocs || allocs.length === 0) return null;
|
if (!allocs || allocs.length === 0) return null;
|
||||||
const projectHours = new Map<string, number>();
|
const projectHours = new Map<string, number>();
|
||||||
for (const alloc of allocs) {
|
for (const alloc of allocs) {
|
||||||
projectHours.set(alloc.projectId, (projectHours.get(alloc.projectId) ?? 0) + alloc.hoursPerDay);
|
projectHours.set(
|
||||||
|
alloc.projectId,
|
||||||
|
(projectHours.get(alloc.projectId) ?? 0) + alloc.hoursPerDay,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let maxPid: string | null = null;
|
let maxPid: string | null = null;
|
||||||
let maxH = 0;
|
let maxH = 0;
|
||||||
for (const [pid, h] of projectHours) {
|
for (const [pid, h] of projectHours) {
|
||||||
if (h > maxH) { maxH = h; maxPid = pid; }
|
if (h > maxH) {
|
||||||
|
maxH = h;
|
||||||
|
maxPid = pid;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return maxPid;
|
return maxPid;
|
||||||
}, [newAllocPopover, allocsByResource]);
|
}, [newAllocPopover, allocsByResource]);
|
||||||
@@ -516,7 +533,7 @@ function TimelineViewContent({
|
|||||||
const target: EventTarget = multiSelectState.isMultiDragging ? document : el;
|
const target: EventTarget = multiSelectState.isMultiDragging ? document : el;
|
||||||
target.addEventListener("mousemove", handler as EventListener, { passive: true });
|
target.addEventListener("mousemove", handler as EventListener, { passive: true });
|
||||||
return () => target.removeEventListener("mousemove", handler as EventListener);
|
return () => target.removeEventListener("mousemove", handler as EventListener);
|
||||||
}, [hasActivePointerOverlay, isLoading, mousePosRef, multiSelectState.isMultiDragging]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [hasActivePointerOverlay, isLoading, mousePosRef, multiSelectState.isMultiDragging]);
|
||||||
|
|
||||||
// ─── Shift+wheel → horizontal scroll ──────────────────────────────────────
|
// ─── Shift+wheel → horizontal scroll ──────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -530,7 +547,7 @@ function TimelineViewContent({
|
|||||||
};
|
};
|
||||||
el.addEventListener("wheel", handler, { passive: false });
|
el.addEventListener("wheel", handler, { passive: false });
|
||||||
return () => el.removeEventListener("wheel", handler);
|
return () => el.removeEventListener("wheel", handler);
|
||||||
}, [isLoading]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [isLoading]);
|
||||||
|
|
||||||
// ─── Keyboard undo/redo ───────────────────────────────────────────────────
|
// ─── Keyboard undo/redo ───────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -555,7 +572,10 @@ function TimelineViewContent({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
if (e.key !== "Escape") return;
|
if (e.key !== "Escape") return;
|
||||||
if (multiSelectState.selectedAllocationIds.length > 0 || multiSelectState.selectedResourceIds.length > 0) {
|
if (
|
||||||
|
multiSelectState.selectedAllocationIds.length > 0 ||
|
||||||
|
multiSelectState.selectedResourceIds.length > 0
|
||||||
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
clearMultiSelect();
|
clearMultiSelect();
|
||||||
return;
|
return;
|
||||||
@@ -579,7 +599,19 @@ function TimelineViewContent({
|
|||||||
};
|
};
|
||||||
window.addEventListener("keydown", handler);
|
window.addEventListener("keydown", handler);
|
||||||
return () => window.removeEventListener("keydown", handler);
|
return () => window.removeEventListener("keydown", handler);
|
||||||
}, [demandPopover, popover, newAllocPopover, openDemandToAssign, openPanelProjectId, setPopover, setNewAllocPopover, setOpenPanelProjectId, multiSelectState.selectedAllocationIds.length, multiSelectState.selectedResourceIds.length, clearMultiSelect]);
|
}, [
|
||||||
|
demandPopover,
|
||||||
|
popover,
|
||||||
|
newAllocPopover,
|
||||||
|
openDemandToAssign,
|
||||||
|
openPanelProjectId,
|
||||||
|
setPopover,
|
||||||
|
setNewAllocPopover,
|
||||||
|
setOpenPanelProjectId,
|
||||||
|
multiSelectState.selectedAllocationIds.length,
|
||||||
|
multiSelectState.selectedResourceIds.length,
|
||||||
|
clearMultiSelect,
|
||||||
|
]);
|
||||||
|
|
||||||
// ─── Resource hover card — event delegation on label columns ──────────────
|
// ─── Resource hover card — event delegation on label columns ──────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -623,7 +655,11 @@ function TimelineViewContent({
|
|||||||
if (hasActivePointerOverlay) return;
|
if (hasActivePointerOverlay) return;
|
||||||
const related = e.relatedTarget as HTMLElement | null;
|
const related = e.relatedTarget as HTMLElement | null;
|
||||||
// Don't close if moving into another resource-hover target or the hover card itself
|
// Don't close if moving into another resource-hover target or the hover card itself
|
||||||
if (related?.closest?.("[data-resource-hover-id]") || related?.closest?.("[data-resource-hover-card]")) return;
|
if (
|
||||||
|
related?.closest?.("[data-resource-hover-id]") ||
|
||||||
|
related?.closest?.("[data-resource-hover-card]")
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
if (resourceHoverTimerRef.current) {
|
if (resourceHoverTimerRef.current) {
|
||||||
clearTimeout(resourceHoverTimerRef.current);
|
clearTimeout(resourceHoverTimerRef.current);
|
||||||
@@ -646,7 +682,7 @@ function TimelineViewContent({
|
|||||||
resourceHoverTimerRef.current = null;
|
resourceHoverTimerRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [resourceHover?.resourceId, isInitialLoading, hasActivePointerOverlay]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [resourceHover?.resourceId, isInitialLoading, hasActivePointerOverlay]);
|
||||||
|
|
||||||
// ─── Scroll-left tracking for horizontal virtualization ────────────────────
|
// ─── Scroll-left tracking for horizontal virtualization ────────────────────
|
||||||
// Updated via RAF so React state only updates after a frame, not on every
|
// Updated via RAF so React state only updates after a frame, not on every
|
||||||
@@ -655,7 +691,6 @@ function TimelineViewContent({
|
|||||||
const scrollRafRef = useRef<number | null>(null);
|
const scrollRafRef = useRef<number | null>(null);
|
||||||
const [scrollLeft, setScrollLeft] = useState(0);
|
const [scrollLeft, setScrollLeft] = useState(0);
|
||||||
|
|
||||||
|
|
||||||
// ─── Navigation callbacks for TimelineToolbar ────────────────────────────
|
// ─── Navigation callbacks for TimelineToolbar ────────────────────────────
|
||||||
const handleNavigateBack = useCallback(
|
const handleNavigateBack = useCallback(
|
||||||
() => setViewStart((v) => addDays(v, -28)),
|
() => setViewStart((v) => addDays(v, -28)),
|
||||||
@@ -669,8 +704,12 @@ function TimelineViewContent({
|
|||||||
() => setViewStart((v) => addDays(v, 28)),
|
() => setViewStart((v) => addDays(v, 28)),
|
||||||
[setViewStart],
|
[setViewStart],
|
||||||
);
|
);
|
||||||
const handleUndo = useCallback(() => { void undo(); }, [undo]);
|
const handleUndo = useCallback(() => {
|
||||||
const handleRedo = useCallback(() => { void redo(); }, [redo]);
|
void undo();
|
||||||
|
}, [undo]);
|
||||||
|
const handleRedo = useCallback(() => {
|
||||||
|
void redo();
|
||||||
|
}, [redo]);
|
||||||
|
|
||||||
// ─── Scroll handler — extends date range and tracks scroll offset ─────────
|
// ─── Scroll handler — extends date range and tracks scroll offset ─────────
|
||||||
const handleContainerScroll = useCallback(() => {
|
const handleContainerScroll = useCallback(() => {
|
||||||
@@ -712,12 +751,14 @@ function TimelineViewContent({
|
|||||||
setDemandPopover({ demand, x: anchorX, y: anchorY });
|
setDemandPopover({ demand, x: anchorX, y: anchorY });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const allocation = visibleAssignments.find((entry) => (
|
const allocation =
|
||||||
entry.id === info.allocationId
|
visibleAssignments.find(
|
||||||
|| entry.entityId === info.allocationId
|
(entry) =>
|
||||||
|| entry.sourceAllocationId === info.allocationId
|
entry.id === info.allocationId ||
|
||||||
|| getPlanningEntryMutationId(entry) === info.allocationId
|
entry.entityId === info.allocationId ||
|
||||||
)) ?? null;
|
entry.sourceAllocationId === info.allocationId ||
|
||||||
|
getPlanningEntryMutationId(entry) === info.allocationId,
|
||||||
|
) ?? null;
|
||||||
setPopover({
|
setPopover({
|
||||||
allocationId: info.allocationId,
|
allocationId: info.allocationId,
|
||||||
projectId: info.projectId,
|
projectId: info.projectId,
|
||||||
@@ -754,15 +795,33 @@ function TimelineViewContent({
|
|||||||
// memo() on ResourcePanel/ProjectPanel is not defeated by new fn refs.
|
// memo() on ResourcePanel/ProjectPanel is not defeated by new fn refs.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const stableNoop = useCallback((..._args: any[]) => undefined, []);
|
const stableNoop = useCallback((..._args: any[]) => undefined, []);
|
||||||
const panelOnAllocMouseDown = (isSelfServiceTimeline ? stableNoop : onAllocMouseDown) as typeof onAllocMouseDown;
|
const panelOnAllocMouseDown = (
|
||||||
const panelOnAllocTouchStart = (isSelfServiceTimeline ? stableNoop : onAllocTouchStart) as typeof onAllocTouchStart;
|
isSelfServiceTimeline ? stableNoop : onAllocMouseDown
|
||||||
const panelOnRowMouseDown = (isSelfServiceTimeline ? stableNoop : onRowMouseDown) as typeof onRowMouseDown;
|
) as typeof onAllocMouseDown;
|
||||||
const panelOnRowTouchStart = (isSelfServiceTimeline ? stableNoop : onRowTouchStart) as typeof onRowTouchStart;
|
const panelOnAllocTouchStart = (
|
||||||
const panelOnAllocationContextMenu = (isSelfServiceTimeline ? stableNoop : openAllocationPopoverAt) as typeof openAllocationPopoverAt;
|
isSelfServiceTimeline ? stableNoop : onAllocTouchStart
|
||||||
const panelOnProjectBarMouseDown = (isSelfServiceTimeline ? stableNoop : onProjectBarMouseDown) as typeof onProjectBarMouseDown;
|
) as typeof onAllocTouchStart;
|
||||||
const panelOnProjectBarTouchStart = (isSelfServiceTimeline ? stableNoop : onProjectBarTouchStart) as typeof onProjectBarTouchStart;
|
const panelOnRowMouseDown = (
|
||||||
const panelOnOpenPanel = (isSelfServiceTimeline ? stableNoop : setOpenPanelProjectId) as typeof setOpenPanelProjectId;
|
isSelfServiceTimeline ? stableNoop : onRowMouseDown
|
||||||
const panelOnOpenDemandClick = (isSelfServiceTimeline ? stableNoop : handleOpenDemandClick) as typeof handleOpenDemandClick;
|
) as typeof onRowMouseDown;
|
||||||
|
const panelOnRowTouchStart = (
|
||||||
|
isSelfServiceTimeline ? stableNoop : onRowTouchStart
|
||||||
|
) as typeof onRowTouchStart;
|
||||||
|
const panelOnAllocationContextMenu = (
|
||||||
|
isSelfServiceTimeline ? stableNoop : openAllocationPopoverAt
|
||||||
|
) as typeof openAllocationPopoverAt;
|
||||||
|
const panelOnProjectBarMouseDown = (
|
||||||
|
isSelfServiceTimeline ? stableNoop : onProjectBarMouseDown
|
||||||
|
) as typeof onProjectBarMouseDown;
|
||||||
|
const panelOnProjectBarTouchStart = (
|
||||||
|
isSelfServiceTimeline ? stableNoop : onProjectBarTouchStart
|
||||||
|
) as typeof onProjectBarTouchStart;
|
||||||
|
const panelOnOpenPanel = (
|
||||||
|
isSelfServiceTimeline ? stableNoop : setOpenPanelProjectId
|
||||||
|
) as typeof setOpenPanelProjectId;
|
||||||
|
const panelOnOpenDemandClick = (
|
||||||
|
isSelfServiceTimeline ? stableNoop : handleOpenDemandClick
|
||||||
|
) as typeof handleOpenDemandClick;
|
||||||
|
|
||||||
// ─── Multi-select intersection computation ────────────────────────────────
|
// ─── Multi-select intersection computation ────────────────────────────────
|
||||||
useMultiSelectIntersection({
|
useMultiSelectIntersection({
|
||||||
@@ -854,7 +913,10 @@ function TimelineViewContent({
|
|||||||
}}
|
}}
|
||||||
onTouchEnd={(e) => void onCanvasTouchEnd(e)}
|
onTouchEnd={(e) => void onCanvasTouchEnd(e)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
(dragState.isDragging || allocDragState.isActive || multiSelectState.isMultiDragging) && "cursor-grabbing select-none",
|
(dragState.isDragging ||
|
||||||
|
allocDragState.isActive ||
|
||||||
|
multiSelectState.isMultiDragging) &&
|
||||||
|
"cursor-grabbing select-none",
|
||||||
rangeState.isSelecting && "cursor-crosshair select-none",
|
rangeState.isSelecting && "cursor-crosshair select-none",
|
||||||
multiSelectState.isSelecting && "cursor-crosshair select-none",
|
multiSelectState.isSelecting && "cursor-crosshair select-none",
|
||||||
)}
|
)}
|
||||||
@@ -1014,65 +1076,73 @@ function TimelineViewContent({
|
|||||||
className="fixed z-50 bg-sky-700 text-white text-xs px-2.5 py-1.5 rounded-lg pointer-events-none shadow-lg font-medium"
|
className="fixed z-50 bg-sky-700 text-white text-xs px-2.5 py-1.5 rounded-lg pointer-events-none shadow-lg font-medium"
|
||||||
style={{ left: mousePosRef.current.x + 14, top: mousePosRef.current.y - 36 }}
|
style={{ left: mousePosRef.current.x + 14, top: mousePosRef.current.y - 36 }}
|
||||||
>
|
>
|
||||||
{multiSelectState.multiDragMode === "resize-start" ? "Start " : multiSelectState.multiDragMode === "resize-end" ? "End " : ""}
|
{multiSelectState.multiDragMode === "resize-start"
|
||||||
|
? "Start "
|
||||||
|
: multiSelectState.multiDragMode === "resize-end"
|
||||||
|
? "End "
|
||||||
|
: ""}
|
||||||
{multiSelectState.multiDragDaysDelta > 0 ? "+" : ""}
|
{multiSelectState.multiDragDaysDelta > 0 ? "+" : ""}
|
||||||
{multiSelectState.multiDragDaysDelta}d
|
{multiSelectState.multiDragDaysDelta}d ({multiSelectState.selectedAllocationIds.length}{" "}
|
||||||
{" "}
|
allocations)
|
||||||
({multiSelectState.selectedAllocationIds.length} allocations)
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Allocation / Demand popover (click path) */}
|
{/* Allocation / Demand popover (click path) */}
|
||||||
{!isSelfServiceTimeline && !hasActivePointerOverlay && popover && (() => {
|
{!isSelfServiceTimeline &&
|
||||||
// Check if clicked allocation is actually a demand
|
!hasActivePointerOverlay &&
|
||||||
const clickedDemand = openDemandsByProject.get(popover.projectId)?.find((d) => d.id === popover.allocationId);
|
popover &&
|
||||||
if (clickedDemand) {
|
(() => {
|
||||||
|
// Check if clicked allocation is actually a demand
|
||||||
|
const clickedDemand = openDemandsByProject
|
||||||
|
.get(popover.projectId)
|
||||||
|
?.find((d) => d.id === popover.allocationId);
|
||||||
|
if (clickedDemand) {
|
||||||
|
return (
|
||||||
|
<DemandPopover
|
||||||
|
demand={clickedDemand}
|
||||||
|
onClose={() => setPopover(null)}
|
||||||
|
onOpenPanel={(pid) => {
|
||||||
|
setPopover(null);
|
||||||
|
setOpenPanelProjectId(pid);
|
||||||
|
}}
|
||||||
|
onFillDemand={(d) => {
|
||||||
|
setPopover(null);
|
||||||
|
setOpenDemandToAssign({
|
||||||
|
id: d.id,
|
||||||
|
projectId: d.projectId,
|
||||||
|
roleId: d.roleId,
|
||||||
|
role: d.role,
|
||||||
|
headcount: d.requestedHeadcount,
|
||||||
|
startDate: new Date(d.startDate),
|
||||||
|
endDate: new Date(d.endDate),
|
||||||
|
hoursPerDay: d.hoursPerDay,
|
||||||
|
...(d.roleEntity !== undefined ? { roleEntity: d.roleEntity } : {}),
|
||||||
|
...(d.project !== undefined ? { project: d.project } : {}),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
anchorX={popover.x}
|
||||||
|
anchorY={popover.y}
|
||||||
|
ignoreScrollContainers={[scrollContainerRef]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<DemandPopover
|
<AllocationPopover
|
||||||
demand={clickedDemand}
|
allocationId={popover.allocationId}
|
||||||
|
projectId={popover.projectId}
|
||||||
|
initialAllocation={popover.allocation ?? null}
|
||||||
onClose={() => setPopover(null)}
|
onClose={() => setPopover(null)}
|
||||||
onOpenPanel={(pid) => {
|
onOpenPanel={(pid) => {
|
||||||
setPopover(null);
|
setPopover(null);
|
||||||
setOpenPanelProjectId(pid);
|
setOpenPanelProjectId(pid);
|
||||||
}}
|
}}
|
||||||
onFillDemand={(d) => {
|
|
||||||
setPopover(null);
|
|
||||||
setOpenDemandToAssign({
|
|
||||||
id: d.id,
|
|
||||||
projectId: d.projectId,
|
|
||||||
roleId: d.roleId,
|
|
||||||
role: d.role,
|
|
||||||
headcount: d.requestedHeadcount,
|
|
||||||
startDate: new Date(d.startDate),
|
|
||||||
endDate: new Date(d.endDate),
|
|
||||||
hoursPerDay: d.hoursPerDay,
|
|
||||||
...(d.roleEntity !== undefined ? { roleEntity: d.roleEntity } : {}),
|
|
||||||
...(d.project !== undefined ? { project: d.project } : {}),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
anchorX={popover.x}
|
anchorX={popover.x}
|
||||||
anchorY={popover.y}
|
anchorY={popover.y}
|
||||||
ignoreScrollContainers={[scrollContainerRef]}
|
ignoreScrollContainers={[scrollContainerRef]}
|
||||||
|
{...(popover.contextDate ? { contextDate: popover.contextDate } : {})}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
})()}
|
||||||
return (
|
|
||||||
<AllocationPopover
|
|
||||||
allocationId={popover.allocationId}
|
|
||||||
projectId={popover.projectId}
|
|
||||||
initialAllocation={popover.allocation ?? null}
|
|
||||||
onClose={() => setPopover(null)}
|
|
||||||
onOpenPanel={(pid) => {
|
|
||||||
setPopover(null);
|
|
||||||
setOpenPanelProjectId(pid);
|
|
||||||
}}
|
|
||||||
anchorX={popover.x}
|
|
||||||
anchorY={popover.y}
|
|
||||||
ignoreScrollContainers={[scrollContainerRef]}
|
|
||||||
{...(popover.contextDate ? { contextDate: popover.contextDate } : {})}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Demand popover */}
|
{/* Demand popover */}
|
||||||
{!isSelfServiceTimeline && !hasActivePointerOverlay && demandPopover && (
|
{!isSelfServiceTimeline && !hasActivePointerOverlay && demandPopover && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Children, cloneElement, isValidElement, ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { Children, cloneElement, isValidElement } from "react";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* ShimmerSkeleton */
|
/* ShimmerSkeleton */
|
||||||
@@ -44,21 +45,15 @@ export function ShimmerSkeleton({
|
|||||||
const resolvedWidth = width ?? defaults?.width ?? "100%";
|
const resolvedWidth = width ?? defaults?.width ?? "100%";
|
||||||
const resolvedHeight = height ?? defaults?.height ?? 40;
|
const resolvedHeight = height ?? defaults?.height ?? 40;
|
||||||
const resolvedRounded = rounded
|
const resolvedRounded = rounded
|
||||||
? roundedMap[rounded] ?? "rounded-md"
|
? (roundedMap[rounded] ?? "rounded-md")
|
||||||
: defaults?.rounded ?? "rounded-md";
|
: (defaults?.rounded ?? "rounded-md");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`shimmer-skeleton ${resolvedRounded} ${className}`}
|
className={`shimmer-skeleton ${resolvedRounded} ${className}`}
|
||||||
style={{
|
style={{
|
||||||
width:
|
width: typeof resolvedWidth === "number" ? `${resolvedWidth}px` : resolvedWidth,
|
||||||
typeof resolvedWidth === "number"
|
height: typeof resolvedHeight === "number" ? `${resolvedHeight}px` : resolvedHeight,
|
||||||
? `${resolvedWidth}px`
|
|
||||||
: resolvedWidth,
|
|
||||||
height:
|
|
||||||
typeof resolvedHeight === "number"
|
|
||||||
? `${resolvedHeight}px`
|
|
||||||
: resolvedHeight,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -74,11 +69,7 @@ interface ShimmerGroupProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ShimmerGroup({
|
export function ShimmerGroup({ children, staggerMs = 50, className }: ShimmerGroupProps) {
|
||||||
children,
|
|
||||||
staggerMs = 50,
|
|
||||||
className,
|
|
||||||
}: ShimmerGroupProps) {
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
{Children.map(children, (child, index) => {
|
{Children.map(children, (child, index) => {
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
|||||||
import { VACATION_TYPE_LABELS } from "~/lib/status-styles.js";
|
import { VACATION_TYPE_LABELS } from "~/lib/status-styles.js";
|
||||||
|
|
||||||
const VACATION_TYPES = Object.values(VacationType);
|
const VACATION_TYPES = Object.values(VacationType);
|
||||||
const REQUESTABLE_VACATION_TYPES = VACATION_TYPES.filter((type) => type !== VacationType.PUBLIC_HOLIDAY);
|
const REQUESTABLE_VACATION_TYPES = VACATION_TYPES.filter(
|
||||||
|
(type) => type !== VacationType.PUBLIC_HOLIDAY,
|
||||||
|
);
|
||||||
|
|
||||||
const HOLIDAY_SOURCE_LABELS = {
|
const HOLIDAY_SOURCE_LABELS = {
|
||||||
CALENDAR: "Calendar",
|
CALENDAR: "Calendar",
|
||||||
@@ -75,7 +77,11 @@ function getHolidaySourceLabel(source: string): string {
|
|||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VacationModal({ resourceId: initialResourceId, onClose, onSuccess }: VacationModalProps) {
|
export function VacationModal({
|
||||||
|
resourceId: initialResourceId,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
}: VacationModalProps) {
|
||||||
const [resourceId, setResourceId] = useState(initialResourceId ?? "");
|
const [resourceId, setResourceId] = useState(initialResourceId ?? "");
|
||||||
const [type, setType] = useState<VacationType>(VacationType.ANNUAL);
|
const [type, setType] = useState<VacationType>(VacationType.ANNUAL);
|
||||||
const [startDate, setStartDate] = useState("");
|
const [startDate, setStartDate] = useState("");
|
||||||
@@ -126,17 +132,17 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled:
|
enabled:
|
||||||
!!resourceId
|
!!resourceId &&
|
||||||
&& !!debouncedStart
|
!!debouncedStart &&
|
||||||
&& !!debouncedEnd
|
!!debouncedEnd &&
|
||||||
&& (!isHalfDay || debouncedStart === debouncedEnd),
|
(!isHalfDay || debouncedStart === debouncedEnd),
|
||||||
staleTime: 10_000,
|
staleTime: 10_000,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
// @ts-ignore TS2589: tRPC infers union type too deeply for CreateVacationRequestSchema with .superRefine()
|
// @ts-expect-error TS2589: tRPC infers union type too deeply for CreateVacationRequestSchema with .superRefine()
|
||||||
const createMutation = trpc.vacation.create.useMutation({
|
const createMutation = trpc.vacation.create.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await utils.vacation.list.invalidate();
|
await utils.vacation.list.invalidate();
|
||||||
@@ -177,14 +183,17 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
|||||||
const inputClass =
|
const inputClass =
|
||||||
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100";
|
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100";
|
||||||
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
|
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
|
||||||
const resourceList: { id: string; displayName: string; eid: string }[] = resources?.resources ?? [];
|
const resourceList: { id: string; displayName: string; eid: string }[] =
|
||||||
|
resources?.resources ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedModal open={true} onClose={onClose} maxWidth="max-w-lg" className="mx-4">
|
<AnimatedModal open={true} onClose={onClose} maxWidth="max-w-lg" className="mx-4">
|
||||||
<div>
|
<div>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Request Vacation</h2>
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Request Vacation
|
||||||
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -200,7 +209,8 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
|||||||
{!initialResourceId && (
|
{!initialResourceId && (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="vac-resource" className={labelClass}>
|
<label htmlFor="vac-resource" className={labelClass}>
|
||||||
Resource <span className="text-red-500">*</span><InfoTooltip content="The employee this vacation request is for." />
|
Resource <span className="text-red-500">*</span>
|
||||||
|
<InfoTooltip content="The employee this vacation request is for." />
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="vac-resource"
|
id="vac-resource"
|
||||||
@@ -222,7 +232,8 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
|||||||
{/* Type */}
|
{/* Type */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="vac-type" className={labelClass}>
|
<label htmlFor="vac-type" className={labelClass}>
|
||||||
Type <span className="text-red-500">*</span><InfoTooltip content="ANNUAL = paid leave (deducted from entitlement) · SICK = sick leave · OTHER = special leave. Public holidays come from Holiday Calendars and are excluded automatically." />
|
Type <span className="text-red-500">*</span>
|
||||||
|
<InfoTooltip content="ANNUAL = paid leave (deducted from entitlement) · SICK = sick leave · OTHER = special leave. Public holidays come from Holiday Calendars and are excluded automatically." />
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="vac-type"
|
id="vac-type"
|
||||||
@@ -242,7 +253,8 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="vac-start" className={labelClass}>
|
<label htmlFor="vac-start" className={labelClass}>
|
||||||
Start Date <span className="text-red-500">*</span><InfoTooltip content="First day of leave (inclusive)." />
|
Start Date <span className="text-red-500">*</span>
|
||||||
|
<InfoTooltip content="First day of leave (inclusive)." />
|
||||||
</label>
|
</label>
|
||||||
<DateInput
|
<DateInput
|
||||||
id="vac-start"
|
id="vac-start"
|
||||||
@@ -254,7 +266,8 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="vac-end" className={labelClass}>
|
<label htmlFor="vac-end" className={labelClass}>
|
||||||
End Date <span className="text-red-500">*</span><InfoTooltip content="Last day of leave (inclusive)." />
|
End Date <span className="text-red-500">*</span>
|
||||||
|
<InfoTooltip content="Last day of leave (inclusive)." />
|
||||||
</label>
|
</label>
|
||||||
<DateInput
|
<DateInput
|
||||||
id="vac-end"
|
id="vac-end"
|
||||||
@@ -276,7 +289,8 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
|||||||
onChange={(e) => setIsHalfDay(e.target.checked)}
|
onChange={(e) => setIsHalfDay(e.target.checked)}
|
||||||
className="rounded border-gray-300 dark:border-gray-600 text-brand-600 focus:ring-brand-500"
|
className="rounded border-gray-300 dark:border-gray-600 text-brand-600 focus:ring-brand-500"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Half day</span><InfoTooltip content="Request only half a day off (morning or afternoon). Counts as 0.5 days against entitlement." />
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Half day</span>
|
||||||
|
<InfoTooltip content="Request only half a day off (morning or afternoon). Counts as 0.5 days against entitlement." />
|
||||||
</label>
|
</label>
|
||||||
{isHalfDay && (
|
{isHalfDay && (
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
@@ -330,7 +344,10 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
|||||||
return (
|
return (
|
||||||
<li key={v.id}>
|
<li key={v.id}>
|
||||||
{r?.displayName ?? "—"}{" "}
|
{r?.displayName ?? "—"}{" "}
|
||||||
<span className="text-blue-500">({new Date(v.startDate).toLocaleDateString("en-GB")} – {new Date(v.endDate).toLocaleDateString("en-GB")})</span>
|
<span className="text-blue-500">
|
||||||
|
({new Date(v.startDate).toLocaleDateString("en-GB")} –{" "}
|
||||||
|
{new Date(v.endDate).toLocaleDateString("en-GB")})
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -374,26 +391,46 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{buildHolidayBasisLabel(previewQuery.data).length > 0 && (
|
{buildHolidayBasisLabel(previewQuery.data).length > 0 && (
|
||||||
<div data-testid="vacation-preview-holiday-basis" className="rounded-md bg-white/70 px-3 py-2 text-xs sm:text-sm">
|
<div
|
||||||
|
data-testid="vacation-preview-holiday-basis"
|
||||||
|
className="rounded-md bg-white/70 px-3 py-2 text-xs sm:text-sm"
|
||||||
|
>
|
||||||
<span className="font-medium">Holiday basis:</span>{" "}
|
<span className="font-medium">Holiday basis:</span>{" "}
|
||||||
{buildHolidayBasisLabel(previewQuery.data).join(" / ")}
|
{buildHolidayBasisLabel(previewQuery.data).join(" / ")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(previewQuery.data.holidayContext.sources.hasCalendarHolidays || previewQuery.data.holidayContext.sources.hasLegacyPublicHolidayEntries) && (
|
{(previewQuery.data.holidayContext.sources.hasCalendarHolidays ||
|
||||||
<div data-testid="vacation-preview-holiday-sources" className="rounded-md bg-white/70 px-3 py-2 text-xs sm:text-sm">
|
previewQuery.data.holidayContext.sources.hasLegacyPublicHolidayEntries) && (
|
||||||
|
<div
|
||||||
|
data-testid="vacation-preview-holiday-sources"
|
||||||
|
className="rounded-md bg-white/70 px-3 py-2 text-xs sm:text-sm"
|
||||||
|
>
|
||||||
<span className="font-medium">Sources:</span>{" "}
|
<span className="font-medium">Sources:</span>{" "}
|
||||||
{[
|
{[
|
||||||
previewQuery.data.holidayContext.sources.hasCalendarHolidays ? "Holiday Calendar" : null,
|
previewQuery.data.holidayContext.sources.hasCalendarHolidays
|
||||||
previewQuery.data.holidayContext.sources.hasLegacyPublicHolidayEntries ? "Legacy public holiday entries" : null,
|
? "Holiday Calendar"
|
||||||
].filter(Boolean).join(" + ")}
|
: null,
|
||||||
|
previewQuery.data.holidayContext.sources.hasLegacyPublicHolidayEntries
|
||||||
|
? "Legacy public holiday entries"
|
||||||
|
: null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" + ")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{previewQuery.data.publicHolidayDates.length > 0 && (
|
{previewQuery.data.publicHolidayDates.length > 0 && (
|
||||||
<div data-testid="vacation-preview-public-holidays" className="text-xs sm:text-sm">
|
<div
|
||||||
|
data-testid="vacation-preview-public-holidays"
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
>
|
||||||
<span className="font-medium">Excluded public holidays:</span>{" "}
|
<span className="font-medium">Excluded public holidays:</span>{" "}
|
||||||
{previewQuery.data.holidayDetails.map((holiday) => `${holiday.date} (${getHolidaySourceLabel(holiday.source)})`).join(", ")}
|
{previewQuery.data.holidayDetails
|
||||||
|
.map(
|
||||||
|
(holiday) => `${holiday.date} (${getHolidaySourceLabel(holiday.source)})`,
|
||||||
|
)
|
||||||
|
.join(", ")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -406,9 +443,7 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{previewQuery.error && (
|
{previewQuery.error && (
|
||||||
<div className="mt-2 text-xs text-red-700">
|
<div className="mt-2 text-xs text-red-700">{previewQuery.error.message}</div>
|
||||||
{previewQuery.error.message}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, useRef } from "react";
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
import {
|
import { type DashboardLayoutConfig, type DashboardWidgetType } from "@capakraken/shared/types";
|
||||||
type DashboardLayoutConfig,
|
|
||||||
type DashboardWidgetType,
|
|
||||||
} from "@capakraken/shared/types";
|
|
||||||
import {
|
import {
|
||||||
createDashboardWidget,
|
createDashboardWidget,
|
||||||
createDefaultDashboardLayout,
|
createDefaultDashboardLayout,
|
||||||
@@ -46,14 +43,15 @@ export function shouldHydrateDashboardFromDb(params: {
|
|||||||
hasLocalChangesBeforeHydration: boolean;
|
hasLocalChangesBeforeHydration: boolean;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
const { remoteLayout, hasHydratedFromDb, hasLocalChangesBeforeHydration } = params;
|
const { remoteLayout, hasHydratedFromDb, hasLocalChangesBeforeHydration } = params;
|
||||||
return remoteLayout !== null
|
return (
|
||||||
&& remoteLayout !== undefined
|
remoteLayout !== null &&
|
||||||
&& !hasHydratedFromDb
|
remoteLayout !== undefined &&
|
||||||
&& !hasLocalChangesBeforeHydration;
|
!hasHydratedFromDb &&
|
||||||
|
!hasLocalChangesBeforeHydration
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDashboardLayout() {
|
export function useDashboardLayout() {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const { data: meData } = trpc.user.me.useQuery() as { data: { id?: string } | null | undefined };
|
const { data: meData } = trpc.user.me.useQuery() as { data: { id?: string } | null | undefined };
|
||||||
const userId = meData?.id ?? null;
|
const userId = meData?.id ?? null;
|
||||||
|
|
||||||
@@ -74,7 +72,12 @@ export function useDashboardLayout() {
|
|||||||
|
|
||||||
// Once userId is known, hydrate from user-scoped localStorage (if no DB data yet).
|
// Once userId is known, hydrate from user-scoped localStorage (if no DB data yet).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userId || hasHydratedFromStorageRef.current || hasHydratedFromDbRef.current || hasLocalChangesBeforeHydrationRef.current) {
|
if (
|
||||||
|
!userId ||
|
||||||
|
hasHydratedFromStorageRef.current ||
|
||||||
|
hasHydratedFromDbRef.current ||
|
||||||
|
hasLocalChangesBeforeHydrationRef.current
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const stored = loadFromStorage(userId);
|
const stored = loadFromStorage(userId);
|
||||||
@@ -90,7 +93,6 @@ export function useDashboardLayout() {
|
|||||||
setIsHydrated(true);
|
setIsHydrated(true);
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const { data: dbData } = trpc.user.getDashboardLayout.useQuery(undefined, {
|
const { data: dbData } = trpc.user.getDashboardLayout.useQuery(undefined, {
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
}) as { data: { layout: DashboardLayoutConfig | null; updatedAt: unknown } | null | undefined };
|
}) as { data: { layout: DashboardLayoutConfig | null; updatedAt: unknown } | null | undefined };
|
||||||
@@ -122,11 +124,13 @@ export function useDashboardLayout() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!shouldHydrateDashboardFromDb({
|
if (
|
||||||
remoteLayout,
|
!shouldHydrateDashboardFromDb({
|
||||||
hasHydratedFromDb: hasHydratedFromDbRef.current,
|
remoteLayout,
|
||||||
hasLocalChangesBeforeHydration: hasLocalChangesBeforeHydrationRef.current,
|
hasHydratedFromDb: hasHydratedFromDbRef.current,
|
||||||
})) {
|
hasLocalChangesBeforeHydration: hasLocalChangesBeforeHydrationRef.current,
|
||||||
|
})
|
||||||
|
) {
|
||||||
// DB data present but local changes already in-flight — keep local state, mark done.
|
// DB data present but local changes already in-flight — keep local state, mark done.
|
||||||
hasHydratedFromDbRef.current = true;
|
hasHydratedFromDbRef.current = true;
|
||||||
setIsHydrated(true);
|
setIsHydrated(true);
|
||||||
@@ -159,69 +163,84 @@ export function useDashboardLayout() {
|
|||||||
|
|
||||||
// Flush any pending debounced DB save when the component unmounts so that
|
// Flush any pending debounced DB save when the component unmounts so that
|
||||||
// navigating away within the 2-second window doesn't silently lose changes.
|
// navigating away within the 2-second window doesn't silently lose changes.
|
||||||
useEffect(() => () => {
|
useEffect(
|
||||||
if (saveTimeoutRef.current) {
|
() => () => {
|
||||||
clearTimeout(saveTimeoutRef.current);
|
if (saveTimeoutRef.current) {
|
||||||
saveTimeoutRef.current = null;
|
clearTimeout(saveTimeoutRef.current);
|
||||||
if (pendingLayoutSaveRef.current) {
|
saveTimeoutRef.current = null;
|
||||||
saveMutationRef.current.mutate({ layout: pendingLayoutSaveRef.current });
|
if (pendingLayoutSaveRef.current) {
|
||||||
pendingLayoutSaveRef.current = null;
|
saveMutationRef.current.mutate({ layout: pendingLayoutSaveRef.current });
|
||||||
|
pendingLayoutSaveRef.current = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}, []);
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const persist = useCallback((nextConfig: DashboardLayoutConfig) => {
|
const persist = useCallback(
|
||||||
if (!hasHydratedFromDbRef.current) {
|
(nextConfig: DashboardLayoutConfig) => {
|
||||||
hasLocalChangesBeforeHydrationRef.current = true;
|
if (!hasHydratedFromDbRef.current) {
|
||||||
}
|
hasLocalChangesBeforeHydrationRef.current = true;
|
||||||
const newConfig = normalizeDashboardLayout(nextConfig);
|
}
|
||||||
if (userId) saveToStorage(userId, newConfig);
|
const newConfig = normalizeDashboardLayout(nextConfig);
|
||||||
pendingLayoutSaveRef.current = newConfig;
|
if (userId) saveToStorage(userId, newConfig);
|
||||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
pendingLayoutSaveRef.current = newConfig;
|
||||||
saveTimeoutRef.current = setTimeout(() => {
|
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||||
pendingLayoutSaveRef.current = null;
|
saveTimeoutRef.current = setTimeout(() => {
|
||||||
saveMutation.mutate({ layout: newConfig });
|
pendingLayoutSaveRef.current = null;
|
||||||
}, 2000);
|
saveMutation.mutate({ layout: newConfig });
|
||||||
}, [saveMutation, userId]);
|
}, 2000);
|
||||||
|
},
|
||||||
|
[saveMutation, userId],
|
||||||
|
);
|
||||||
|
|
||||||
const addWidget = useCallback((type: DashboardWidgetType) => {
|
const addWidget = useCallback(
|
||||||
setConfig((prev) => {
|
(type: DashboardWidgetType) => {
|
||||||
const newConfig = {
|
setConfig((prev) => {
|
||||||
...prev,
|
const newConfig = {
|
||||||
widgets: [
|
...prev,
|
||||||
...prev.widgets,
|
widgets: [
|
||||||
createDashboardWidget(type, {
|
...prev.widgets,
|
||||||
id: generateWidgetId(),
|
createDashboardWidget(type, {
|
||||||
x: 0,
|
id: generateWidgetId(),
|
||||||
y: getNextDashboardWidgetY(prev.widgets),
|
x: 0,
|
||||||
}),
|
y: getNextDashboardWidgetY(prev.widgets),
|
||||||
],
|
}),
|
||||||
};
|
],
|
||||||
persist(newConfig);
|
};
|
||||||
return newConfig;
|
persist(newConfig);
|
||||||
});
|
return newConfig;
|
||||||
}, [persist]);
|
});
|
||||||
|
},
|
||||||
|
[persist],
|
||||||
|
);
|
||||||
|
|
||||||
const removeWidget = useCallback((id: string) => {
|
const removeWidget = useCallback(
|
||||||
setConfig((prev) => {
|
(id: string) => {
|
||||||
const newConfig = { ...prev, widgets: prev.widgets.filter((w) => w.id !== id) };
|
setConfig((prev) => {
|
||||||
persist(newConfig);
|
const newConfig = { ...prev, widgets: prev.widgets.filter((w) => w.id !== id) };
|
||||||
return newConfig;
|
persist(newConfig);
|
||||||
});
|
return newConfig;
|
||||||
}, [persist]);
|
});
|
||||||
|
},
|
||||||
|
[persist],
|
||||||
|
);
|
||||||
|
|
||||||
const updateWidgetConfig = useCallback((id: string, configUpdate: Record<string, unknown>) => {
|
const updateWidgetConfig = useCallback(
|
||||||
setConfig((prev) => {
|
(id: string, configUpdate: Record<string, unknown>) => {
|
||||||
const newConfig = {
|
setConfig((prev) => {
|
||||||
...prev,
|
const newConfig = {
|
||||||
widgets: prev.widgets.map((w) =>
|
...prev,
|
||||||
w.id === id ? { ...w, config: { ...w.config, ...configUpdate } } : w,
|
widgets: prev.widgets.map((w) =>
|
||||||
),
|
w.id === id ? { ...w, config: { ...w.config, ...configUpdate } } : w,
|
||||||
};
|
),
|
||||||
persist(newConfig);
|
};
|
||||||
return newConfig;
|
persist(newConfig);
|
||||||
});
|
return newConfig;
|
||||||
}, [persist]);
|
});
|
||||||
|
},
|
||||||
|
[persist],
|
||||||
|
);
|
||||||
|
|
||||||
const onLayoutChange = useCallback(
|
const onLayoutChange = useCallback(
|
||||||
(layout: { i: string; x: number; y: number; w: number; h: number }[]) => {
|
(layout: { i: string; x: number; y: number; w: number; h: number }[]) => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { trpc } from "~/lib/trpc/client.js";
|
|||||||
* Fetches full project context when a project is being dragged or the panel opens.
|
* Fetches full project context when a project is being dragged or the panel opens.
|
||||||
* Returns the project's resources, their own allocations, and all cross-project allocations.
|
* Returns the project's resources, their own allocations, and all cross-project allocations.
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
type ProjectDragContextResult = {
|
type ProjectDragContextResult = {
|
||||||
contextResourceIds: string[];
|
contextResourceIds: string[];
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -18,8 +18,10 @@ type ProjectDragContextResult = {
|
|||||||
project: any | null;
|
project: any | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useProjectDragContext(projectId: string | null, enabled = true): ProjectDragContextResult {
|
export function useProjectDragContext(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
projectId: string | null,
|
||||||
|
enabled = true,
|
||||||
|
): ProjectDragContextResult {
|
||||||
const { data } = trpc.timeline.getProjectContext.useQuery(
|
const { data } = trpc.timeline.getProjectContext.useQuery(
|
||||||
{ projectId: projectId! },
|
{ projectId: projectId! },
|
||||||
{ enabled: enabled && !!projectId, staleTime: 10_000 },
|
{ enabled: enabled && !!projectId, staleTime: 10_000 },
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
import type React from "react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { RefObject } from "react";
|
import type { RefObject } from "react";
|
||||||
|
|
||||||
interface UseTimelineKeyboardOptions {
|
interface UseTimelineKeyboardOptions {
|
||||||
|
|||||||
@@ -35,23 +35,29 @@ export function useTimelineLayout(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Grid lines — memoized; identical for every row
|
// Grid lines — memoized; identical for every row
|
||||||
const gridLines = useMemo(() => dates.map((date, i) => {
|
const gridLines = useMemo(
|
||||||
const isToday = date.toDateString() === today.toDateString();
|
() =>
|
||||||
const dow = date.getDay();
|
dates.map((date, i) => {
|
||||||
const isWeekend = dow === 0 || dow === 6;
|
const isToday = date.toDateString() === today.toDateString();
|
||||||
return (
|
const dow = date.getDay();
|
||||||
<div
|
const isWeekend = dow === 0 || dow === 6;
|
||||||
key={i}
|
return (
|
||||||
className={clsx(
|
<div
|
||||||
"absolute top-0 bottom-0 border-r",
|
key={i}
|
||||||
isToday ? "border-brand-300 dark:border-brand-700 border-r-2" :
|
className={clsx(
|
||||||
isWeekend ? "border-brand-200 dark:border-brand-800 bg-brand-50/40 dark:bg-brand-950/20" :
|
"absolute top-0 bottom-0 border-r",
|
||||||
"border-gray-100 dark:border-gray-800",
|
isToday
|
||||||
)}
|
? "border-brand-300 dark:border-brand-700 border-r-2"
|
||||||
style={{ left: i * CELL_WIDTH, width: CELL_WIDTH }}
|
: isWeekend
|
||||||
/>
|
? "border-brand-200 dark:border-brand-800 bg-brand-50/40 dark:bg-brand-950/20"
|
||||||
);
|
: "border-gray-100 dark:border-gray-800",
|
||||||
}), [dates, CELL_WIDTH, today]); // eslint-disable-line react-hooks/exhaustive-deps
|
)}
|
||||||
|
style={{ left: i * CELL_WIDTH, width: CELL_WIDTH }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
[dates, CELL_WIDTH, today],
|
||||||
|
);
|
||||||
|
|
||||||
// Month groups for the month header
|
// Month groups for the month header
|
||||||
const monthGroups = useMemo(() => {
|
const monthGroups = useMemo(() => {
|
||||||
@@ -72,5 +78,15 @@ export function useTimelineLayout(
|
|||||||
return dates[colIndex] ?? today;
|
return dates[colIndex] ?? today;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { CELL_WIDTH, dates, visibleDays, totalCanvasWidth, toLeft, toWidth, gridLines, monthGroups, xToDate };
|
return {
|
||||||
|
CELL_WIDTH,
|
||||||
|
dates,
|
||||||
|
visibleDays,
|
||||||
|
totalCanvasWidth,
|
||||||
|
toLeft,
|
||||||
|
toWidth,
|
||||||
|
gridLines,
|
||||||
|
monthGroups,
|
||||||
|
xToDate,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useRef, useState, type CSSProperties } from "react";
|
import type React from "react";
|
||||||
|
import { useEffect, useRef, useState, type CSSProperties } from "react";
|
||||||
|
|
||||||
type PopoverAnchor =
|
type PopoverAnchor =
|
||||||
| { kind: "point"; x: number; y: number }
|
| { kind: "point"; x: number; y: number }
|
||||||
@@ -113,8 +114,8 @@ export function useViewportPopover({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
target instanceof Element
|
target instanceof Element &&
|
||||||
&& ignoreSelectors.some((selector) => target.closest(selector) !== null)
|
ignoreSelectors.some((selector) => target.closest(selector) !== null)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -190,9 +191,7 @@ export function useViewportPopover({
|
|||||||
if (
|
if (
|
||||||
ignoreScrollContainers?.some(
|
ignoreScrollContainers?.some(
|
||||||
(r) =>
|
(r) =>
|
||||||
r.current != null &&
|
r.current != null && scrollTarget instanceof Node && r.current.contains(scrollTarget),
|
||||||
scrollTarget instanceof Node &&
|
|
||||||
r.current.contains(scrollTarget),
|
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ services:
|
|||||||
DATABASE_URL: postgresql://capakraken:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/capakraken
|
DATABASE_URL: postgresql://capakraken:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/capakraken
|
||||||
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
|
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
|
||||||
RATE_LIMIT_BACKEND: ${RATE_LIMIT_BACKEND:-redis}
|
RATE_LIMIT_BACKEND: ${RATE_LIMIT_BACKEND:-redis}
|
||||||
|
NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN:-}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
+4
-1
@@ -33,11 +33,14 @@
|
|||||||
"db:import:dispo": "pnpm --filter @capakraken/db db:import:dispo",
|
"db:import:dispo": "pnpm --filter @capakraken/db db:import:dispo",
|
||||||
"db:readiness:demand-assignment": "pnpm --filter @capakraken/db db:readiness:demand-assignment",
|
"db:readiness:demand-assignment": "pnpm --filter @capakraken/db db:readiness:demand-assignment",
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,md,json}\"",
|
"format": "prettier --write \"**/*.{ts,tsx,md,json}\"",
|
||||||
"typecheck": "node ./scripts/run-from-workspace-root.mjs turbo typecheck"
|
"typecheck": "node ./scripts/run-from-workspace-root.mjs turbo typecheck",
|
||||||
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capakraken/eslint-config": "workspace:*",
|
"@capakraken/eslint-config": "workspace:*",
|
||||||
"@capakraken/tsconfig": "workspace:*",
|
"@capakraken/tsconfig": "workspace:*",
|
||||||
|
"husky": "^9.1.7",
|
||||||
|
"lint-staged": "^16.4.0",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"turbo": "^2.3.3",
|
"turbo": "^2.3.3",
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3"
|
||||||
|
|||||||
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/eslint-plugin": "^8.18.0",
|
||||||
"@typescript-eslint/parser": "^8.18.0",
|
"@typescript-eslint/parser": "^8.18.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-import": "^2.31.0"
|
"eslint-plugin-import": "^2.31.0",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
|
|||||||
@@ -15,12 +15,21 @@ export default [
|
|||||||
project: true,
|
project: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
linterOptions: {
|
||||||
|
reportUnusedDisableDirectives: "error",
|
||||||
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...tsPlugin.configs["recommended"].rules,
|
...tsPlugin.configs["recommended"].rules,
|
||||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||||
"@typescript-eslint/consistent-type-imports": "error",
|
"@typescript-eslint/consistent-type-imports": "error",
|
||||||
"@typescript-eslint/no-explicit-any": "error",
|
"@typescript-eslint/no-explicit-any": "error",
|
||||||
"no-console": ["warn", { allow: ["warn", "error"] }],
|
"@typescript-eslint/ban-ts-comment": ["error", {
|
||||||
|
"ts-ignore": true,
|
||||||
|
"ts-expect-error": "allow-with-description",
|
||||||
|
"ts-nocheck": true,
|
||||||
|
"ts-check": false,
|
||||||
|
}],
|
||||||
|
"no-console": ["error", { allow: ["warn", "error"] }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
prettierConfig,
|
prettierConfig,
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import baseConfig from "./base.js";
|
import baseConfig from "./base.js";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
|
||||||
/** @type {import("eslint").Linter.FlatConfig[]} */
|
/** @type {import("eslint").Linter.FlatConfig[]} */
|
||||||
export default [
|
export default [
|
||||||
...baseConfig,
|
...baseConfig,
|
||||||
{
|
{
|
||||||
|
plugins: {
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
"react-hooks/rules-of-hooks": "error",
|
||||||
|
"react-hooks/exhaustive-deps": "warn",
|
||||||
"@typescript-eslint/no-misused-promises": [
|
"@typescript-eslint/no-misused-promises": [
|
||||||
"error",
|
"error",
|
||||||
{ checksVoidReturn: { attributes: false } },
|
{ checksVoidReturn: { attributes: false } },
|
||||||
|
|||||||
Reference in New Issue
Block a user