rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)
CI / Architecture Guardrails (push) Successful in 2m38s
CI / Assistant Split Regression (push) Successful in 3m33s
CI / Typecheck (push) Successful in 3m51s
CI / Lint (push) Successful in 5m2s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled

rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)

Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
This commit was merged in pull request #61.
This commit is contained in:
2026-05-21 16:28:40 +02:00
committed by Hartmut
parent d9a7ec0338
commit b41c1d2501
943 changed files with 24548 additions and 16832 deletions
@@ -4,11 +4,11 @@ import { useState, useRef } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { parseSkillMatrixWorkbook, matchRoleName } from "~/lib/skillMatrixParser.js";
import { assertSpreadsheetFile } from "~/lib/excel.js";
import type { SkillEntry } from "@capakraken/shared";
import type { SkillEntry } from "@nexus/shared";
interface ParsedEntry {
fileName: string;
candidateEid: string; // guessed from filename (no extension, lowercased)
candidateEid: string; // guessed from filename (no extension, lowercased)
selectedEid: string;
skills: SkillEntry[];
employeeInfo: Record<string, string>;
@@ -30,8 +30,14 @@ export function BatchSkillImport() {
);
const batchMutation = trpc.resource.batchImportSkillMatrices.useMutation({
onSuccess: (data) => { setResult(data); setSubmitting(false); },
onError: (err) => { setError(err.message); setSubmitting(false); },
onSuccess: (data) => {
setResult(data);
setSubmitting(false);
},
onError: (err) => {
setError(err.message);
setSubmitting(false);
},
});
async function handleFiles(e: React.ChangeEvent<HTMLInputElement>) {
@@ -72,7 +78,8 @@ export function BatchSkillImport() {
const empInfo: Record<string, string> = {};
if (roleId) empInfo["roleId"] = roleId;
if (result.employeeInfo.portfolioUrl) empInfo["portfolioUrl"] = result.employeeInfo.portfolioUrl;
if (result.employeeInfo.portfolioUrl)
empInfo["portfolioUrl"] = result.employeeInfo.portfolioUrl;
return {
fileName: file.name,
@@ -124,7 +131,9 @@ export function BatchSkillImport() {
skills: e.skills,
employeeInfo: {
...(e.employeeInfo["roleId"] ? { roleId: e.employeeInfo["roleId"] } : {}),
...(e.employeeInfo["portfolioUrl"] ? { portfolioUrl: e.employeeInfo["portfolioUrl"] } : {}),
...(e.employeeInfo["portfolioUrl"]
? { portfolioUrl: e.employeeInfo["portfolioUrl"] }
: {}),
},
})),
});
@@ -138,7 +147,9 @@ export function BatchSkillImport() {
return (
<div className="p-6 max-w-4xl">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Batch Skill Matrix Import</h1>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Batch Skill Matrix Import
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Upload multiple skill matrix files at once. Files are matched to resources by filename.
</p>
@@ -149,12 +160,33 @@ export function BatchSkillImport() {
className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl p-8 text-center cursor-pointer hover:border-brand-400 transition-colors mb-6 bg-white dark:bg-gray-800"
onClick={() => fileRef.current?.click()}
>
<svg className="w-10 h-10 text-gray-300 dark:text-gray-600 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
<svg
className="w-10 h-10 text-gray-300 dark:text-gray-600 mx-auto mb-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Click to select multiple .xlsx files</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">Name files after resource EID or display name for automatic matching</p>
<input ref={fileRef} type="file" accept=".xlsx" multiple className="hidden" onChange={handleFiles} />
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
Click to select multiple .xlsx files
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Name files after resource EID or display name for automatic matching
</p>
<input
ref={fileRef}
type="file"
accept=".xlsx"
multiple
className="hidden"
onChange={handleFiles}
/>
</div>
{/* Summary */}
@@ -166,7 +198,9 @@ export function BatchSkillImport() {
</div>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-lg px-4 py-2 text-sm">
<span className="font-semibold text-yellow-700 dark:text-yellow-400">{unmatched}</span>
<span className="text-yellow-600 dark:text-yellow-400 ml-1">unmatched (select EID manually)</span>
<span className="text-yellow-600 dark:text-yellow-400 ml-1">
unmatched (select EID manually)
</span>
</div>
</div>
)}
@@ -177,20 +211,39 @@ export function BatchSkillImport() {
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">File</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Resource EID</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Skills</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Role Match</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Status</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
File
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
Resource EID
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
Skills
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
Role Match
</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
Status
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{entries.map((entry, idx) => (
<tr key={idx} className={entry.status === "unmatched" ? "bg-yellow-50 dark:bg-yellow-900/10" : ""}>
<td className="px-4 py-3 text-xs text-gray-600 dark:text-gray-400 font-mono">{entry.fileName}</td>
<tr
key={idx}
className={
entry.status === "unmatched" ? "bg-yellow-50 dark:bg-yellow-900/10" : ""
}
>
<td className="px-4 py-3 text-xs text-gray-600 dark:text-gray-400 font-mono">
{entry.fileName}
</td>
<td className="px-4 py-3">
{entry.status === "matched" ? (
<span className="font-mono text-sm text-gray-800 dark:text-gray-100">{entry.selectedEid}</span>
<span className="font-mono text-sm text-gray-800 dark:text-gray-100">
{entry.selectedEid}
</span>
) : (
<select
className="w-full px-2 py-1.5 border border-yellow-300 dark:border-yellow-600 rounded text-sm bg-white dark:bg-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-500"
@@ -199,17 +252,27 @@ export function BatchSkillImport() {
>
<option value=""> Select resource </option>
{resourceList.map((r) => (
<option key={r.eid} value={r.eid}>{r.displayName} ({r.eid})</option>
<option key={r.eid} value={r.eid}>
{r.displayName} ({r.eid})
</option>
))}
</select>
)}
</td>
<td className="px-4 py-3 text-right text-gray-700 dark:text-gray-300">{entry.skills.length}</td>
<td className="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">{entry.matchedRoleName ?? "—"}</td>
<td className="px-4 py-3 text-right text-gray-700 dark:text-gray-300">
{entry.skills.length}
</td>
<td className="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
{entry.matchedRoleName ?? "—"}
</td>
<td className="px-4 py-3 text-center">
<span className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${
entry.status === "matched" ? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400" : "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400"
}`}>
<span
className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${
entry.status === "matched"
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400"
: "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400"
}`}
>
{entry.status}
</span>
</td>
@@ -221,12 +284,15 @@ export function BatchSkillImport() {
)}
{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">{error}</div>
<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">
{error}
</div>
)}
{result && (
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 px-4 py-3 text-sm text-green-700 dark:text-green-400">
Import complete: <strong>{result.updated}</strong> updated, <strong>{result.notFound}</strong> not found.
Import complete: <strong>{result.updated}</strong> updated,{" "}
<strong>{result.notFound}</strong> not found.
</div>
)}
@@ -237,7 +303,9 @@ export function BatchSkillImport() {
disabled={submitting || matched === 0}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{submitting ? "Importing…" : `Import ${entries.filter((e) => e.selectedEid && e.skills.length > 0).length} Files`}
{submitting
? "Importing…"
: `Import ${entries.filter((e) => e.selectedEid && e.skills.length > 0).length} Files`}
</button>
)}
</div>
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { SystemRole } from "@capakraken/shared";
import { SystemRole } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
@@ -51,7 +51,10 @@ export function InviteUserModal({ open, onClose }: InviteUserModalProps) {
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!email) { setError("Email is required."); return; }
if (!email) {
setError("Email is required.");
return;
}
await inviteMutation.mutateAsync({ email, role });
}
@@ -96,7 +99,9 @@ export function InviteUserModal({ open, onClose }: InviteUserModalProps) {
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
>
{ROLE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { PermissionKey } from "@capakraken/shared";
import { PermissionKey } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
@@ -1,6 +1,6 @@
"use client";
import { DEFAULT_OPENAI_MODEL } from "@capakraken/shared";
import { DEFAULT_OPENAI_MODEL } from "@nexus/shared";
import { useEffect, useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { AiProviderPanel, GenerationSettingsPanel } from "./system-settings/AiSettingsPanels.js";
@@ -1,4 +1,4 @@
import { PASSWORD_MIN_LENGTH, SystemRole } from "@capakraken/shared";
import { PASSWORD_MIN_LENGTH, SystemRole } from "@nexus/shared";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
const SYSTEM_ROLE_LABELS: Record<SystemRole, string> = {
@@ -1,4 +1,4 @@
import { SystemRole, PermissionKey, type PermissionOverrides } from "@capakraken/shared";
import { SystemRole, PermissionKey, type PermissionOverrides } from "@nexus/shared";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
const ALL_PERMISSION_KEYS = Object.values(PermissionKey);
@@ -1,13 +1,13 @@
"use client";
import { useState, useMemo } from "react";
import type { PermissionKey } from "@capakraken/shared";
import type { PermissionKey } from "@nexus/shared";
import {
SystemRole,
ROLE_DEFAULT_PERMISSIONS,
MILLISECONDS_PER_DAY,
type PermissionOverrides,
} from "@capakraken/shared";
} from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
import { InviteUserModal } from "./InviteUserModal.js";
@@ -176,7 +176,7 @@ export function WebhooksClient() {
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Webhooks</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Configure outbound webhooks to notify external services about events in CapaKraken.
Configure outbound webhooks to notify external services about events in Nexus.
</p>
</div>
<button className={PRIMARY_BUTTON} onClick={openCreateModal}>
@@ -194,10 +194,7 @@ export function WebhooksClient() {
) : (
<div className="space-y-3">
{webhooks.map((wh) => (
<div
key={wh.id}
className="app-surface flex items-center gap-4 p-4"
>
<div key={wh.id} className="app-surface flex items-center gap-4 p-4">
{/* Active indicator */}
<div
className={`h-3 w-3 shrink-0 rounded-full ${
@@ -209,9 +206,7 @@ export function WebhooksClient() {
{/* Info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-white">
{wh.name}
</span>
<span className="font-medium text-gray-900 dark:text-white">{wh.name}</span>
{wh.url.includes("hooks.slack.com") && (
<span className="rounded bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-300">
Slack
@@ -257,17 +252,12 @@ export function WebhooksClient() {
</button>
<button
className={SECONDARY_BUTTON}
onClick={() =>
handleToggleActive(wh.id, wh.isActive)
}
onClick={() => handleToggleActive(wh.id, wh.isActive)}
disabled={updateMut.isPending}
>
{wh.isActive ? "Disable" : "Enable"}
</button>
<button
className={SECONDARY_BUTTON}
onClick={() => openEditModal(wh)}
>
<button className={SECONDARY_BUTTON} onClick={() => openEditModal(wh)}>
Edit
</button>
{deleteConfirmId === wh.id ? (
@@ -282,18 +272,12 @@ export function WebhooksClient() {
>
Confirm
</button>
<button
className={SECONDARY_BUTTON}
onClick={() => setDeleteConfirmId(null)}
>
<button className={SECONDARY_BUTTON} onClick={() => setDeleteConfirmId(null)}>
Cancel
</button>
</div>
) : (
<button
className={SECONDARY_BUTTON}
onClick={() => setDeleteConfirmId(wh.id)}
>
<button className={SECONDARY_BUTTON} onClick={() => setDeleteConfirmId(wh.id)}>
Delete
</button>
)}
@@ -335,9 +319,7 @@ export function WebhooksClient() {
{/* Secret */}
<div>
<label className={LABEL_CLASS}>
Secret (optional)
</label>
<label className={LABEL_CLASS}>Secret (optional)</label>
<input
className={INPUT_CLASS}
type="password"
@@ -1,4 +1,4 @@
import { DEFAULT_OPENAI_MODEL } from "@capakraken/shared";
import { DEFAULT_OPENAI_MODEL } from "@nexus/shared";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import {
INPUT_CLASS,
@@ -123,7 +123,9 @@ export function AiProviderPanel({
</p>
) : null}
{urlParsedType === "completions" ? (
<p className="text-xs text-green-700 dark:text-green-400">All fields filled from URL.</p>
<p className="text-xs text-green-700 dark:text-green-400">
All fields filled from URL.
</p>
) : null}
</div>
@@ -154,7 +156,7 @@ export function AiProviderPanel({
id="ai-model"
type="text"
className={INPUT_CLASS}
placeholder={provider === "azure" ? "capakraken-gpt-5-4" : DEFAULT_OPENAI_MODEL}
placeholder={provider === "azure" ? "nexus-gpt-5-4" : DEFAULT_OPENAI_MODEL}
value={model}
onChange={(event) => onModelChange(event.target.value)}
/>
@@ -223,12 +225,7 @@ export function AiProviderPanel({
) : null}
<div className="flex items-center gap-3 pt-2">
<button
type="button"
onClick={onSave}
disabled={isSaving}
className={PRIMARY_BUTTON_CLASS}
>
<button type="button" onClick={onSave} disabled={isSaving} className={PRIMARY_BUTTON_CLASS}>
{isSaving ? "Saving…" : "Save Settings"}
</button>
<button
@@ -389,12 +386,7 @@ export function GenerationSettingsPanel({
</div>
<div className="flex items-center gap-3 pt-1">
<button
type="button"
onClick={onSave}
disabled={isSaving}
className={PRIMARY_BUTTON_CLASS}
>
<button type="button" onClick={onSave} disabled={isSaving} className={PRIMARY_BUTTON_CLASS}>
{isSaving ? "Saving…" : "Save Settings"}
</button>
{saved ? (
@@ -137,7 +137,7 @@ export function SmtpSettingsPanel({ initialSettings, onSettingsSaved }: SmtpSett
className={INPUT_CLASS}
value={smtpFrom}
onChange={(event) => setSmtpFrom(event.target.value)}
placeholder="noreply@capakraken.app"
placeholder="noreply@nexus.app"
/>
</div>
<div className={`${CHECKBOX_ROW_CLASS} pt-0 md:mt-[1.65rem]`}>
@@ -1,4 +1,4 @@
import type { AllocationWithDetails, AllocationStatus } from "@capakraken/shared";
import type { AllocationWithDetails, AllocationStatus } from "@nexus/shared";
import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
@@ -1,4 +1,4 @@
import type { AllocationWithDetails, ColumnDef } from "@capakraken/shared";
import type { AllocationWithDetails, ColumnDef } from "@nexus/shared";
import type { CollapsedAllocationGroups } from "./allocationGroupState.js";
import { formatDate } from "~/lib/format.js";
import { AllocationRow } from "./AllocationRow.js";
@@ -4,8 +4,8 @@ import { useState, useEffect, useMemo } from "react";
import { useDebounce } from "~/hooks/useDebounce.js";
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
import { AllocationStatus } from "@capakraken/shared";
import type { AllocationWithDetails, RecurrencePattern } from "@capakraken/shared";
import { AllocationStatus } from "@nexus/shared";
import type { AllocationWithDetails, RecurrencePattern } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { toDateInputValue } from "~/lib/format.js";
@@ -26,7 +26,8 @@ interface AllocationModalProps {
export function AllocationModal({ allocation, onClose, onSuccess }: AllocationModalProps) {
const isEditing = Boolean(allocation);
const initialEntryKind: EntryKind = allocation && !allocation.resourceId ? "demand" : "assignment";
const initialEntryKind: EntryKind =
allocation && !allocation.resourceId ? "demand" : "assignment";
const [entryKind, setEntryKind] = useState<EntryKind>(initialEntryKind);
const isDemandEntry = entryKind === "demand";
@@ -57,14 +58,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
{ isActive: true, limit: 500 },
{ staleTime: 60_000 },
);
const { data: projects } = trpc.project.list.useQuery(
{ limit: 500 },
{ staleTime: 60_000 },
);
const { data: rolesData } = trpc.role.list.useQuery(
{ isActive: true },
{ staleTime: 60_000 },
);
const { data: projects } = trpc.project.list.useQuery({ limit: 500 }, { staleTime: 60_000 });
const { data: rolesData } = trpc.role.list.useQuery({ isActive: true }, { staleTime: 60_000 });
// Fetch existing allocations for the selected resource+project to detect overlaps
const shouldCheckOverlap = !isDemandEntry && !!resourceId && !!projectId;
@@ -85,20 +80,26 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
const shouldCheckConflicts =
!isDemandEntry &&
!!debouncedResourceId &&
conflictCheckStart !== null && !isNaN(conflictCheckStart.getTime()) &&
conflictCheckEnd !== null && !isNaN(conflictCheckEnd.getTime()) &&
conflictCheckStart !== null &&
!isNaN(conflictCheckStart.getTime()) &&
conflictCheckEnd !== null &&
!isNaN(conflictCheckEnd.getTime()) &&
debouncedHoursPerDay > 0;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: conflictResult, isFetching: checkingConflicts } = (trpc.allocation.checkConflicts.useQuery as any)(
{
resourceId: debouncedResourceId,
startDate: conflictCheckStart,
endDate: conflictCheckEnd,
hoursPerDay: debouncedHoursPerDay,
excludeAssignmentId: isEditing && allocation?.id ? allocation.id : undefined,
},
{ enabled: shouldCheckConflicts, staleTime: 15_000 },
) as { data: import("@capakraken/shared").AllocationConflictCheckResult | undefined; isFetching: boolean };
const { data: conflictResult, isFetching: checkingConflicts } =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(trpc.allocation.checkConflicts.useQuery as any)(
{
resourceId: debouncedResourceId,
startDate: conflictCheckStart,
endDate: conflictCheckEnd,
hoursPerDay: debouncedHoursPerDay,
excludeAssignmentId: isEditing && allocation?.id ? allocation.id : undefined,
},
{ enabled: shouldCheckConflicts, staleTime: 15_000 },
) as {
data: import("@nexus/shared").AllocationConflictCheckResult | undefined;
isFetching: boolean;
};
const overlapWarning = useMemo(() => {
if (!shouldCheckOverlap || !existingAllocations || !startDate || !endDate) return null;
@@ -106,7 +107,17 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
const formEnd = new Date(endDate);
if (isNaN(formStart.getTime()) || isNaN(formEnd.getTime())) return null;
const allocList = (existingAllocations as { allocations?: Array<{ id: string; resourceId?: string | null; startDate: string | Date; endDate: string | Date }> }).allocations ?? [];
const allocList =
(
existingAllocations as {
allocations?: Array<{
id: string;
resourceId?: string | null;
startDate: string | Date;
endDate: string | Date;
}>;
}
).allocations ?? [];
for (const existing of allocList) {
// Skip the allocation being edited
if (isEditing && allocation && existing.id === allocation.id) continue;
@@ -121,7 +132,15 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
}
}
return null;
}, [shouldCheckOverlap, existingAllocations, startDate, endDate, isEditing, allocation, resourceId]);
}, [
shouldCheckOverlap,
existingAllocations,
startDate,
endDate,
isEditing,
allocation,
resourceId,
]);
const invalidatePlanningViews = useInvalidatePlanningViews();
@@ -185,7 +204,17 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
useEffect(() => {
setServerError(null);
setOverbookingAcknowledged(false);
}, [resourceId, projectId, roleId, roleFreeText, startDate, endDate, hoursPerDay, status, entryKind]);
}, [
resourceId,
projectId,
roleId,
roleFreeText,
startDate,
endDate,
hoursPerDay,
status,
entryKind,
]);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -222,7 +251,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
// Determine role string from roleId if set
const rolesList = rolesData ?? [];
const selectedRole = rolesList.find((r) => r.id === roleId);
const roleString = selectedRole ? selectedRole.name : (roleFreeText || undefined);
const roleString = selectedRole ? selectedRole.name : roleFreeText || undefined;
const percentage = Math.min(100, Math.round((hoursPerDay / 8) * 100));
@@ -230,12 +259,14 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
updateMutation.mutate({
id: getPlanningEntryMutationId(allocation),
data: {
resourceId: isDemandEntry ? undefined : (resourceId || undefined),
resourceId: isDemandEntry ? undefined : resourceId || undefined,
projectId,
role: roleString,
roleId: roleId || undefined,
headcount: isDemandEntry ? headcount : 1,
...(isDemandEntry && budgetEur ? { budgetCents: Math.round(parseFloat(budgetEur) * 100) } : {}),
...(isDemandEntry && budgetEur
? { budgetCents: Math.round(parseFloat(budgetEur) * 100) }
: {}),
startDate: start,
endDate: end,
hoursPerDay,
@@ -279,18 +310,22 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100";
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
const resourceList = (resources?.resources ?? []) as Array<{ id: string; displayName: string; eid: string }>;
const projectList = (projects?.projects ?? []) as Array<{ id: string; name: string; shortCode: string }>;
const resourceList = (resources?.resources ?? []) as Array<{
id: string;
displayName: string;
eid: string;
}>;
const projectList = (projects?.projects ?? []) as Array<{
id: string;
name: string;
shortCode: string;
}>;
const rolesList = (rolesData ?? []) as Array<{ id: string; name: string; color: string | null }>;
const entryLabel = isDemandEntry ? "Open Demand" : "Assignment";
return (
<AnimatedModal open={true} onClose={onClose} maxWidth="max-w-xl" className="mx-4">
<div
role="dialog"
aria-modal="true"
data-testid="allocation-modal"
>
<div role="dialog" aria-modal="true" data-testid="allocation-modal">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
@@ -333,7 +368,9 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
{isDemandEntry && (
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">Headcount:</label>
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">
Headcount:
</label>
<input
type="number"
value={headcount}
@@ -344,7 +381,9 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">Budget (EUR):</label>
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">
Budget (EUR):
</label>
<input
type="number"
value={budgetEur}
@@ -363,7 +402,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
{!isDemandEntry && (
<div>
<label htmlFor="modal-resource" className={labelClass}>
Resource <span className="text-red-500">*</span><InfoTooltip content="The person to assign. Their LCR determines the daily cost of this allocation." />
Resource <span className="text-red-500">*</span>
<InfoTooltip content="The person to assign. Their LCR determines the daily cost of this allocation." />
</label>
<select
id="modal-resource"
@@ -385,7 +425,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
{/* Project */}
<div>
<label htmlFor="modal-project" className={labelClass}>
Project <span className="text-red-500">*</span><InfoTooltip content="The project this time block is allocated to. Costs roll up to the project budget." />
Project <span className="text-red-500">*</span>
<InfoTooltip content="The project this time block is allocated to. Costs roll up to the project budget." />
</label>
<select
id="modal-project"
@@ -405,7 +446,10 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
{/* Role */}
<div>
<label htmlFor="modal-role" className={labelClass}>Role<InfoTooltip content="Role for this allocation. Pick a predefined role or type a custom one." /></label>
<label htmlFor="modal-role" className={labelClass}>
Role
<InfoTooltip content="Role for this allocation. Pick a predefined role or type a custom one." />
</label>
<select
id="modal-role"
value={roleId}
@@ -434,35 +478,43 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
{/* Dates */}
<div>
<div className="flex items-center justify-between mb-1">
<span className={labelClass}>Date Range <span className="text-red-500">*</span></span>
<DateRangePresets onSelect={(s, e) => { setStartDate(s); setEndDate(e); }} />
<span className={labelClass}>
Date Range <span className="text-red-500">*</span>
</span>
<DateRangePresets
onSelect={(s, e) => {
setStartDate(s);
setEndDate(e);
}}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="modal-start" className={labelClass}>
Start Date <InfoTooltip content="First day of this allocation period (inclusive)." />
</label>
<DateInput
id="modal-start"
value={startDate}
onChange={setStartDate}
className={inputClass}
required
/>
</div>
<div>
<label htmlFor="modal-end" className={labelClass}>
End Date <InfoTooltip content="Last day of this allocation period (inclusive)." />
</label>
<DateInput
id="modal-end"
value={endDate}
onChange={setEndDate}
min={startDate}
className={inputClass}
required
/>
</div>
<div>
<label htmlFor="modal-start" className={labelClass}>
Start Date{" "}
<InfoTooltip content="First day of this allocation period (inclusive)." />
</label>
<DateInput
id="modal-start"
value={startDate}
onChange={setStartDate}
className={inputClass}
required
/>
</div>
<div>
<label htmlFor="modal-end" className={labelClass}>
End Date <InfoTooltip content="Last day of this allocation period (inclusive)." />
</label>
<DateInput
id="modal-end"
value={endDate}
onChange={setEndDate}
min={startDate}
className={inputClass}
required
/>
</div>
</div>
</div>
@@ -470,7 +522,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="modal-hours" className={labelClass}>
Hours / Day<InfoTooltip content="Working hours per day. Total cost = LCR x hours/day x working days. Vacation days are excluded." />
Hours / Day
<InfoTooltip content="Working hours per day. Total cost = LCR x hours/day x working days. Vacation days are excluded." />
</label>
<input
id="modal-hours"
@@ -485,7 +538,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
</div>
<div>
<label htmlFor="modal-status" className={labelClass}>
Status<InfoTooltip content="PROPOSED = draft/request · CONFIRMED = approved · ACTIVE = in progress · COMPLETED = done · CANCELLED = removed." />
Status
<InfoTooltip content="PROPOSED = draft/request · CONFIRMED = approved · ACTIVE = in progress · COMPLETED = done · CANCELLED = removed." />
</label>
<select
id="modal-status"
@@ -514,7 +568,10 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
}}
className="rounded border-gray-300 dark:border-gray-600"
/>
<span className="font-medium text-gray-700 dark:text-gray-300">Recurring schedule</span><InfoTooltip content="Enable to repeat this allocation on specific days (e.g. every Monday/Wednesday). Hours per day applies on active days only." />
<span className="font-medium text-gray-700 dark:text-gray-300">
Recurring schedule
</span>
<InfoTooltip content="Enable to repeat this allocation on specific days (e.g. every Monday/Wednesday). Hours per day applies on active days only." />
</label>
{isRecurring && (
<div className="mt-2">
@@ -548,7 +605,12 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
)}
{!conflictResult && checkingConflicts && (
<ConflictWarningPanel
result={{ isOverbooking: false, overbooking: null, vacationOverlap: [], hasVacationOverlap: false }}
result={{
isOverbooking: false,
overbooking: null,
vacationOverlap: [],
hasVacationOverlap: false,
}}
isLoading={true}
acknowledged={false}
onAcknowledge={() => {}}
@@ -568,7 +630,11 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
<button
type="submit"
disabled={isPending || hasUnacknowledgedOverbooking}
title={hasUnacknowledgedOverbooking ? "Acknowledge the overbooking warning above to proceed" : undefined}
title={
hasUnacknowledgedOverbooking
? "Acknowledge the overbooking warning above to proceed"
: undefined
}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{isPending ? "Saving…" : "Save"}
@@ -1,4 +1,4 @@
import type { AllocationWithDetails, ColumnDef } from "@capakraken/shared";
import type { AllocationWithDetails, ColumnDef } from "@nexus/shared";
import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js";
const STATUS_LEFT_BORDER: Record<string, string> = {
@@ -13,8 +13,8 @@ import type {
AllocationWithDetails,
ColumnDef,
AllocationStatus,
} from "@capakraken/shared";
import { ALLOCATION_COLUMNS } from "@capakraken/shared";
} from "@nexus/shared";
import { ALLOCATION_COLUMNS } from "@nexus/shared";
import { useSelection } from "~/hooks/useSelection.js";
import { FilterBar } from "~/components/ui/FilterBar.js";
import { FilterChips } from "~/components/ui/FilterChips.js";
@@ -328,7 +328,7 @@ export function AllocationsClient() {
// ─── View mode: grouped (default) vs flat ──────────────────────────────────
const [viewMode, setViewMode] = useLocalStorage<"grouped" | "flat">(
"capakraken:allocations:viewMode",
"nexus:allocations:viewMode",
"grouped",
);
const [collapsedGroups, setCollapsedGroups] = useState<CollapsedAllocationGroups>(() =>
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import type { AllocationConflictCheckResult } from "@capakraken/shared";
import type { AllocationConflictCheckResult } from "@nexus/shared";
const INITIAL_ROWS_SHOWN = 5;
@@ -43,12 +43,12 @@ export function ConflictWarningPanel({
<div className="rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950/30 p-4 text-sm">
<p className="font-semibold text-amber-800 dark:text-amber-300">
Overbooking on {result.overbooking.totalConflictDays} day
{result.overbooking.totalConflictDays !== 1 ? "s" : ""}
{" "}(up to {result.overbooking.maxOverbookPercent}% over capacity)
{result.overbooking.totalConflictDays !== 1 ? "s" : ""} (up to{" "}
{result.overbooking.maxOverbookPercent}% over capacity)
</p>
<p className="mt-1 text-amber-700 dark:text-amber-400">
The resource already has allocations that exceed their daily capacity on the following days.
You can still save check the box below to confirm.
The resource already has allocations that exceed their daily capacity on the following
days. You can still save check the box below to confirm.
</p>
{/* Day-by-day table */}
@@ -65,7 +65,10 @@ export function ConflictWarningPanel({
</thead>
<tbody>
{visibleDays.map((day) => (
<tr key={day.date} className="border-b border-amber-100 dark:border-amber-900/50 last:border-0">
<tr
key={day.date}
className="border-b border-amber-100 dark:border-amber-900/50 last:border-0"
>
<td className="py-1 pr-4">{day.date}</td>
<td className="py-1 pr-4 text-right">{day.availableHours}h</td>
<td className="py-1 pr-4 text-right">{day.existingHours}h</td>
@@ -85,7 +88,9 @@ export function ConflictWarningPanel({
onClick={() => setShowAllDays((v) => !v)}
className="mt-2 text-xs font-medium text-amber-700 dark:text-amber-400 underline underline-offset-2"
>
{showAllDays ? "Show less" : `Show ${hiddenCount} more day${hiddenCount !== 1 ? "s" : ""}`}
{showAllDays
? "Show less"
: `Show ${hiddenCount} more day${hiddenCount !== 1 ? "s" : ""}`}
</button>
)}
@@ -115,11 +120,18 @@ export function ConflictWarningPanel({
</p>
<ul className="mt-2 space-y-1">
{result.vacationOverlap.map((v, i) => (
<li key={i} className="flex items-center gap-2 text-xs text-sky-700 dark:text-sky-400">
<li
key={i}
className="flex items-center gap-2 text-xs text-sky-700 dark:text-sky-400"
>
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-sky-400" />
<span className="font-medium capitalize">{v.type.replace(/_/g, " ").toLowerCase()}</span>
<span className="font-medium capitalize">
{v.type.replace(/_/g, " ").toLowerCase()}
</span>
{v.isHalfDay && <span className="text-sky-500">(half-day)</span>}
<span>{v.startDate === v.endDate ? v.startDate : `${v.startDate} ${v.endDate}`}</span>
<span>
{v.startDate === v.endDate ? v.startDate : `${v.startDate} ${v.endDate}`}
</span>
</li>
))}
</ul>
@@ -1,7 +1,7 @@
"use client";
import { useRef, useState, useMemo } from "react";
import { AllocationStatus } from "@capakraken/shared";
import { AllocationStatus } from "@nexus/shared";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import { formatCents, formatDateMedium } from "~/lib/format.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
@@ -75,7 +75,11 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
const { data: resources } = trpc.resource.listStaff.useQuery(
{ isActive: true, search: debouncedSearch || undefined, limit: 50 },
{ staleTime: 15_000 },
) as { data: { resources: Array<{ id: string; displayName: string; eid: string; lcrCents: number }> } | undefined };
) as {
data:
| { resources: Array<{ id: string; displayName: string; eid: string; lcrCents: number }> }
| undefined;
};
const availabilityQuery = trpc.allocation.checkResourceAvailability.useQuery(
{
@@ -118,17 +122,20 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
const lcrCents = selectedResource.lcrCents ?? 0;
const estimatedCostCents = Math.round(lcrCents * avail.totalAvailableHours);
setPlanned((prev) => [...prev, {
resourceId: selectedResource.id,
resourceName: selectedResource.displayName,
eid: selectedResource.eid,
hoursPerDay,
availableHours: avail.totalAvailableHours,
availableDays: avail.availableDays,
conflictDays: avail.conflictDays,
coveragePercent: avail.coveragePercent,
estimatedCostCents,
}]);
setPlanned((prev) => [
...prev,
{
resourceId: selectedResource.id,
resourceName: selectedResource.displayName,
eid: selectedResource.eid,
hoursPerDay,
availableHours: avail.totalAvailableHours,
availableDays: avail.availableDays,
conflictDays: avail.conflictDays,
coveragePercent: avail.coveragePercent,
estimatedCostCents,
},
]);
// Reset for next resource
setResourceId("");
@@ -160,7 +167,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
status: AllocationStatus.PROPOSED,
});
} catch (err) {
setServerError(`Failed to assign ${p.resourceName}: ${err instanceof Error ? err.message : String(err)}`);
setServerError(
`Failed to assign ${p.resourceName}: ${err instanceof Error ? err.message : String(err)}`,
);
setSubmitting(false);
return;
}
@@ -177,12 +186,16 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
onClick={(e) => { if (e.target === e.currentTarget && !submitting) onClose(); }}
onClick={(e) => {
if (e.target === e.currentTarget && !submitting) onClose();
}}
>
<div
ref={panelRef}
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4"
onKeyDown={(e) => { if (e.key === "Escape" && !submitting) onClose(); }}
onKeyDown={(e) => {
if (e.key === "Escape" && !submitting) onClose();
}}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
@@ -190,21 +203,34 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{phase === "plan" ? "Plan Demand Assignment" : "Confirm Assignments"}
<InfoTooltip content="Fill an open demand by assigning one or more real resources to a placeholder staffing requirement. Each assignment creates a new allocation." />
</h2>
<button type="button" onClick={onClose} disabled={submitting} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-xl leading-none disabled:opacity-30">&times;</button>
<button
type="button"
onClick={onClose}
disabled={submitting}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-xl leading-none disabled:opacity-30"
>
&times;
</button>
</div>
<div className="px-6 pt-4 pb-2 space-y-3">
{/* Demand summary */}
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3 flex items-start gap-3">
<div className="w-3 h-3 rounded-full mt-1 flex-shrink-0" style={{ backgroundColor: roleColor }} />
<div
className="w-3 h-3 rounded-full mt-1 flex-shrink-0"
style={{ backgroundColor: roleColor }}
/>
<div className="flex-1">
<div className="font-medium text-gray-900 dark:text-gray-100">{roleName}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{allocation.project?.name} · {formatDateMedium(allocation.startDate)} {formatDateMedium(allocation.endDate)}
{allocation.project?.name} · {formatDateMedium(allocation.startDate)} {" "}
{formatDateMedium(allocation.endDate)}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{allocation.hoursPerDay}h/day · {totalDemandHours.toLocaleString()}h total
{allocation.budgetCents && allocation.budgetCents > 0 ? ` · Budget: ${formatCents(allocation.budgetCents)} EUR` : ""}
{allocation.budgetCents && allocation.budgetCents > 0
? ` · Budget: ${formatCents(allocation.budgetCents)} EUR`
: ""}
</div>
</div>
</div>
@@ -213,7 +239,10 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mb-1.5">
<span>Demand coverage</span>
<span>{Math.round(consumedHours)}h / {totalDemandHours}h ({totalDemandHours > 0 ? Math.round((consumedHours / totalDemandHours) * 100) : 0}%)</span>
<span>
{Math.round(consumedHours)}h / {totalDemandHours}h (
{totalDemandHours > 0 ? Math.round((consumedHours / totalDemandHours) * 100) : 0}%)
</span>
</div>
<div className="w-full h-2.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden flex">
{planned.map((r, i) => (
@@ -234,11 +263,18 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
<div className="mt-2 space-y-1">
{planned.map((r, i) => (
<div key={r.resourceId} className="flex items-center gap-2 text-xs group">
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: `hsl(${(i * 60 + 200) % 360}, 60%, 55%)` }} />
<span className="text-gray-700 dark:text-gray-300 font-medium">{r.resourceName}</span>
<div
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: `hsl(${(i * 60 + 200) % 360}, 60%, 55%)` }}
/>
<span className="text-gray-700 dark:text-gray-300 font-medium">
{r.resourceName}
</span>
<span className="text-gray-400">({r.eid})</span>
<span className="text-gray-500">{r.hoursPerDay}h/day</span>
<span className="ml-auto text-gray-500">{Math.round(r.availableHours)}h · {r.coveragePercent}%</span>
<span className="ml-auto text-gray-500">
{Math.round(r.availableHours)}h · {r.coveragePercent}%
</span>
{phase === "plan" && (
<button
type="button"
@@ -254,7 +290,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{remainingHours > 0 && (
<div className="flex items-center gap-2 text-xs">
<div className="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600 flex-shrink-0" />
<span className="text-amber-600 dark:text-amber-400 font-medium">Remaining: {Math.round(remainingHours)}h</span>
<span className="text-amber-600 dark:text-amber-400 font-medium">
Remaining: {Math.round(remainingHours)}h
</span>
</div>
)}
</div>
@@ -266,7 +304,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{phase === "plan" && (
<div className="px-6 pb-5 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Search Resource</label>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Search Resource
</label>
<input
type="text"
placeholder="Search by name or EID..."
@@ -277,7 +317,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Select Resource</label>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Select Resource
</label>
<select
value={resourceId}
onChange={(e) => setResourceId(e.target.value)}
@@ -297,7 +339,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Hours / Day</label>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Hours / Day
</label>
<input
type="number"
value={hoursPerDay}
@@ -311,41 +355,53 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{/* Availability preview */}
{resourceId && avail && (
<div className={`rounded-lg p-3 border text-sm ${
avail.coveragePercent >= 100
? "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800"
: avail.coveragePercent >= 50
? "bg-amber-50 border-amber-200 dark:bg-amber-900/20 dark:border-amber-800"
: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800"
}`}>
<div
className={`rounded-lg p-3 border text-sm ${
avail.coveragePercent >= 100
? "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800"
: avail.coveragePercent >= 50
? "bg-amber-50 border-amber-200 dark:bg-amber-900/20 dark:border-amber-800"
: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800"
}`}
>
<div className="font-medium text-gray-900 dark:text-gray-100 mb-1.5">
Availability: {avail.resource.name}
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div>
<span className="text-gray-500 dark:text-gray-400">Available</span>
<div className="font-semibold text-green-700 dark:text-green-400">{avail.availableDays} days</div>
<div className="font-semibold text-green-700 dark:text-green-400">
{avail.availableDays} days
</div>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">Conflicts</span>
<div className="font-semibold text-red-700 dark:text-red-400">{avail.conflictDays} days</div>
<div className="font-semibold text-red-700 dark:text-red-400">
{avail.conflictDays} days
</div>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">Hours</span>
<div className="font-semibold text-gray-900 dark:text-gray-100">{avail.totalAvailableHours}h / {avail.totalRequestedHours}h</div>
<div className="font-semibold text-gray-900 dark:text-gray-100">
{avail.totalAvailableHours}h / {avail.totalRequestedHours}h
</div>
</div>
</div>
{avail.existingAssignments.length > 0 && (
<div className="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Existing bookings:</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
Existing bookings:
</div>
{avail.existingAssignments.slice(0, 4).map((a, i) => (
<div key={i} className="text-xs text-gray-600 dark:text-gray-300">
{a.code} · {a.hoursPerDay}h/day · {a.start} {a.end}
</div>
))}
{avail.existingAssignments.length > 4 && (
<div className="text-xs text-gray-400">+{avail.existingAssignments.length - 4} more</div>
<div className="text-xs text-gray-400">
+{avail.existingAssignments.length - 4} more
</div>
)}
</div>
)}
@@ -353,12 +409,18 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
)}
{resourceId && availabilityQuery.isLoading && (
<div className="text-xs text-gray-400 dark:text-gray-500 animate-pulse">Checking availability...</div>
<div className="text-xs text-gray-400 dark:text-gray-500 animate-pulse">
Checking availability...
</div>
)}
{/* Action buttons */}
<div className="flex items-center justify-between gap-3 pt-2">
<button type="button" onClick={onClose} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
>
Cancel
</button>
<div className="flex items-center gap-2">
@@ -391,11 +453,27 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">Resource</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">h/day</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">Hours</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400"><span className="inline-flex items-center justify-end gap-0.5">Est. Cost<InfoTooltip content="Estimated cost = resource LCR x available hours in the demand period." /></span></th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400"><span className="inline-flex items-center justify-end gap-0.5">Coverage<InfoTooltip content="Percentage of the demand period this resource can cover, accounting for existing bookings." /></span></th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">
Resource
</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">
h/day
</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">
Hours
</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">
<span className="inline-flex items-center justify-end gap-0.5">
Est. Cost
<InfoTooltip content="Estimated cost = resource LCR x available hours in the demand period." />
</span>
</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">
<span className="inline-flex items-center justify-end gap-0.5">
Coverage
<InfoTooltip content="Percentage of the demand period this resource can cover, accounting for existing bookings." />
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
@@ -405,11 +483,19 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{r.resourceName}
<span className="ml-1 text-xs text-gray-400 font-mono">{r.eid}</span>
</td>
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{r.hoursPerDay}h</td>
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{Math.round(r.availableHours)}h</td>
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{formatCents(r.estimatedCostCents)} EUR</td>
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">
{r.hoursPerDay}h
</td>
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">
{Math.round(r.availableHours)}h
</td>
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">
{formatCents(r.estimatedCostCents)} EUR
</td>
<td className="px-3 py-2 text-right">
<span className={`font-medium ${r.coveragePercent >= 100 ? "text-green-600" : r.coveragePercent >= 50 ? "text-amber-600" : "text-red-600"}`}>
<span
className={`font-medium ${r.coveragePercent >= 100 ? "text-green-600" : r.coveragePercent >= 50 ? "text-amber-600" : "text-red-600"}`}
>
{r.coveragePercent}%
</span>
</td>
@@ -418,7 +504,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
</tbody>
<tfoot className="bg-gray-50 dark:bg-gray-900">
<tr>
<td className="px-3 py-2 text-xs font-semibold text-gray-700 dark:text-gray-300">Total</td>
<td className="px-3 py-2 text-xs font-semibold text-gray-700 dark:text-gray-300">
Total
</td>
<td className="px-3 py-2" />
<td className="px-3 py-2 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
{Math.round(consumedHours)}h / {totalDemandHours}h
@@ -427,12 +515,20 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{formatCents(planned.reduce((s, r) => s + r.estimatedCostCents, 0))} EUR
</td>
<td className="px-3 py-2 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
{totalDemandHours > 0 ? Math.round((consumedHours / totalDemandHours) * 100) : 0}%
{totalDemandHours > 0
? Math.round((consumedHours / totalDemandHours) * 100)
: 0}
%
</td>
</tr>
{allocation.budgetCents && allocation.budgetCents > 0 && (
<tr>
<td colSpan={3} className="px-3 py-1.5 text-right text-xs text-gray-500 dark:text-gray-400">Role Budget:</td>
<td
colSpan={3}
className="px-3 py-1.5 text-right text-xs text-gray-500 dark:text-gray-400"
>
Role Budget:
</td>
<td className="px-3 py-1.5 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
{formatCents(allocation.budgetCents)} EUR
</td>
@@ -441,8 +537,12 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
const totalCost = planned.reduce((s, r) => s + r.estimatedCostCents, 0);
const remain = allocation.budgetCents! - totalCost;
return (
<span className={remain < 0 ? "text-red-600 font-medium" : "text-green-600"}>
{remain < 0 ? `${formatCents(Math.abs(remain))} over` : `${formatCents(remain)} left`}
<span
className={remain < 0 ? "text-red-600 font-medium" : "text-green-600"}
>
{remain < 0
? `${formatCents(Math.abs(remain))} over`
: `${formatCents(remain)} left`}
</span>
);
})()}
@@ -455,7 +555,8 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{remainingHours > 0 && (
<div className="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded-lg px-3 py-2 border border-amber-200 dark:border-amber-800">
{Math.round(remainingHours)}h remain uncovered. You can add more resources or assign partially.
{Math.round(remainingHours)}h remain uncovered. You can add more resources or assign
partially.
</div>
)}
@@ -486,7 +587,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
disabled={submitting || planned.length === 0}
className="px-5 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-semibold disabled:opacity-50"
>
{submitting ? `Assigning ${submitProgress}/${planned.length}...` : `Confirm & Assign ${planned.length} Resource${planned.length !== 1 ? "s" : ""}`}
{submitting
? `Assigning ${submitProgress}/${planned.length}...`
: `Confirm & Assign ${planned.length} Resource${planned.length !== 1 ? "s" : ""}`}
</button>
</div>
</div>
@@ -1,4 +1,4 @@
import type { AllocationWithDetails } from "@capakraken/shared";
import type { AllocationWithDetails } from "@nexus/shared";
type DemandRow = AllocationWithDetails & {
sourceAllocationId?: string;
@@ -1,7 +1,7 @@
"use client";
import { RecurrenceFrequency } from "@capakraken/shared";
import type { RecurrencePattern } from "@capakraken/shared";
import { RecurrenceFrequency } from "@nexus/shared";
import type { RecurrencePattern } from "@nexus/shared";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
@@ -39,7 +39,10 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
<div className="space-y-3 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700">
{/* Frequency selector */}
<div>
<span className={labelClass}>Frequency<InfoTooltip content="How often the allocation repeats: weekly, biweekly, monthly, or a custom pattern." /></span>
<span className={labelClass}>
Frequency
<InfoTooltip content="How often the allocation repeats: weekly, biweekly, monthly, or a custom pattern." />
</span>
<div className="flex gap-2 flex-wrap">
{Object.values(RecurrenceFrequency).map((f) => (
<button
@@ -55,10 +58,10 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
{f === RecurrenceFrequency.WEEKLY
? "Weekly"
: f === RecurrenceFrequency.BIWEEKLY
? "Biweekly"
: f === RecurrenceFrequency.MONTHLY
? "Monthly"
: "Custom"}
? "Biweekly"
: f === RecurrenceFrequency.MONTHLY
? "Monthly"
: "Custom"}
</button>
))}
</div>
@@ -67,7 +70,10 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
{/* Weekday picker — WEEKLY and BIWEEKLY */}
{(freq === RecurrenceFrequency.WEEKLY || freq === RecurrenceFrequency.BIWEEKLY) && (
<div>
<span className={labelClass}>Days of week<InfoTooltip content="Select which days of the week this allocation is active. Hours per day applies only on selected days." /></span>
<span className={labelClass}>
Days of week
<InfoTooltip content="Select which days of the week this allocation is active. Hours per day applies only on selected days." />
</span>
<div className="flex gap-1">
{WEEKDAY_LABELS.map((label, dow) => {
const selected = (value?.weekdays ?? []).includes(dow);
@@ -139,7 +145,10 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
{/* Optional hours override for WEEKLY/BIWEEKLY/MONTHLY */}
{freq !== RecurrenceFrequency.CUSTOM && (
<div>
<label className={labelClass}>Hours per recurring day (optional override)<InfoTooltip content="Override the allocation's default hours for recurring days only. Leave empty to use the allocation's hours/day." /></label>
<label className={labelClass}>
Hours per recurring day (optional override)
<InfoTooltip content="Override the allocation's default hours for recurring days only. Leave empty to use the allocation's hours/day." />
</label>
<input
type="number"
min={0.5}
@@ -71,8 +71,8 @@ interface AssistantInsight {
sections?: AssistantInsightSection[];
}
const STORAGE_KEY = "capakraken-chat-messages";
const CONVERSATION_ID_KEY = "capakraken-chat-conversation-id";
const STORAGE_KEY = "nexus-chat-messages";
const CONVERSATION_ID_KEY = "nexus-chat-conversation-id";
function isAssistantApproval(value: unknown): value is AssistantApproval {
if (!value || typeof value !== "object") return false;
@@ -1,8 +1,8 @@
"use client";
import { useState, useMemo, useCallback } from "react";
import { FieldType } from "@capakraken/shared";
import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@capakraken/shared";
import { FieldType } from "@nexus/shared";
import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { RolePresetsEditor } from "./RolePresetsEditor.js";
import { FieldCard } from "./FieldCard.js";
@@ -48,10 +48,7 @@ interface FieldState {
// Helpers: Convert between FieldState and BlueprintFieldDefinition
// ---------------------------------------------------------------------------
function fieldDefToState(
def: BlueprintFieldDefinition,
target: BlueprintTargetValue,
): FieldState {
function fieldDefToState(def: BlueprintFieldDefinition, target: BlueprintTargetValue): FieldState {
const catalogField = findCatalogField(target, def.key);
if (catalogField) {
return {
@@ -186,9 +183,7 @@ export function BlueprintFieldCatalog({
// Build initial state from existing fieldDefs + catalog
// ---------------------------------------------------------------------------
const [catalogOverrides, setCatalogOverrides] = useState<
Record<string, FieldOverrides>
>(() => {
const [catalogOverrides, setCatalogOverrides] = useState<Record<string, FieldOverrides>>(() => {
const map: Record<string, FieldOverrides> = {};
// Start with all catalog fields disabled
for (const cf of catalog) {
@@ -269,21 +264,13 @@ export function BlueprintFieldCatalog({
// Handlers
// ---------------------------------------------------------------------------
const handleCatalogFieldChange = useCallback(
(key: string, overrides: FieldOverrides) => {
setCatalogOverrides((prev) => ({ ...prev, [key]: overrides }));
},
[],
);
const handleCatalogFieldChange = useCallback((key: string, overrides: FieldOverrides) => {
setCatalogOverrides((prev) => ({ ...prev, [key]: overrides }));
}, []);
const handleCustomFieldChange = useCallback(
(idx: number, overrides: FieldOverrides) => {
setCustomFields((prev) =>
prev.map((f, i) => (i === idx ? { ...f, overrides } : f)),
);
},
[],
);
const handleCustomFieldChange = useCallback((idx: number, overrides: FieldOverrides) => {
setCustomFields((prev) => prev.map((f, i) => (i === idx ? { ...f, overrides } : f)));
}, []);
function removeCustomField(idx: number) {
setCustomFields((prev) => prev.filter((_, i) => i !== idx));
@@ -370,9 +357,7 @@ export function BlueprintFieldCatalog({
// Collapsed categories
// ---------------------------------------------------------------------------
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(
new Set(),
);
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set());
function toggleCategory(name: string) {
setCollapsedCategories((prev) => {
@@ -502,15 +487,16 @@ export function BlueprintFieldCatalog({
{/* Field cards */}
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-6">
{categories
.filter(
(cat) =>
activeCategory === null ||
activeCategory === cat.name,
)
.filter((cat) => activeCategory === null || activeCategory === cat.name)
.map((cat) => {
const fields = fieldsByCategory.get(cat.name) ?? [];
if (fields.length === 0 && searchQuery.trim()) return null;
if (fields.length === 0 && activeCategory !== null && activeCategory !== cat.name) return null;
if (
fields.length === 0 &&
activeCategory !== null &&
activeCategory !== cat.name
)
return null;
const isCollapsed = collapsedCategories.has(cat.name);
@@ -527,9 +513,7 @@ export function BlueprintFieldCatalog({
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
{cat.name}
</h3>
<span className="text-xs text-gray-400">
{cat.description}
</span>
<span className="text-xs text-gray-400">{cat.description}</span>
</button>
{!isCollapsed && (
<div className="grid grid-cols-1 gap-2">
@@ -538,9 +522,7 @@ export function BlueprintFieldCatalog({
key={field.key}
field={field}
overrides={catalogOverrides[field.key]!}
onChange={(ov) =>
handleCatalogFieldChange(field.key, ov)
}
onChange={(ov) => handleCatalogFieldChange(field.key, ov)}
/>
))}
{fields.length === 0 && (
@@ -555,8 +537,7 @@ export function BlueprintFieldCatalog({
})}
{/* Custom Fields section */}
{(activeCategory === null ||
activeCategory === "Custom Fields") && (
{(activeCategory === null || activeCategory === "Custom Fields") && (
<div>
<button
type="button"
@@ -564,9 +545,7 @@ export function BlueprintFieldCatalog({
className="flex items-center gap-2 mb-3 w-full text-left group"
>
<span className="text-xs text-gray-400 transition-transform group-hover:text-gray-600">
{collapsedCategories.has("Custom Fields")
? "\u25B6"
: "\u25BC"}
{collapsedCategories.has("Custom Fields") ? "\u25B6" : "\u25BC"}
</span>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
Custom Fields
@@ -585,8 +564,7 @@ export function BlueprintFieldCatalog({
label: cf.custom.label,
type: cf.custom.type,
category: "Custom Fields",
description:
cf.overrides.description || "Custom field",
description: cf.overrides.description || "Custom field",
...(cf.custom.options.length > 0
? { options: cf.custom.options }
: {}),
@@ -597,9 +575,7 @@ export function BlueprintFieldCatalog({
<FieldCard
field={pseudoCatalog}
overrides={cf.overrides}
onChange={(ov) =>
handleCustomFieldChange(idx, ov)
}
onChange={(ov) => handleCustomFieldChange(idx, ov)}
/>
<button
type="button"
@@ -619,19 +595,13 @@ export function BlueprintFieldCatalog({
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-600">
Key{" "}
<span className="text-red-500">*</span>
Key <span className="text-red-500">*</span>
</label>
<input
type="text"
value={customKey}
onChange={(e) =>
setCustomKey(
e.target.value.replace(
/[^a-zA-Z0-9_]/g,
"",
),
)
setCustomKey(e.target.value.replace(/[^a-zA-Z0-9_]/g, ""))
}
placeholder="field_key"
className="app-input font-mono"
@@ -639,30 +609,21 @@ export function BlueprintFieldCatalog({
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-600">
Label{" "}
<span className="text-red-500">*</span>
Label <span className="text-red-500">*</span>
</label>
<input
type="text"
value={customLabel}
onChange={(e) =>
setCustomLabel(e.target.value)
}
onChange={(e) => setCustomLabel(e.target.value)}
placeholder="Display Label"
className="app-input"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-600">
Type
</label>
<label className="text-xs font-medium text-gray-600">Type</label>
<select
value={customType}
onChange={(e) =>
setCustomType(
e.target.value as FieldType,
)
}
onChange={(e) => setCustomType(e.target.value as FieldType)}
className="app-input"
>
{FIELD_TYPES.map((ft) => (
@@ -677,9 +638,7 @@ export function BlueprintFieldCatalog({
<button
type="button"
onClick={addCustomField}
disabled={
!customKey.trim() || !customLabel.trim()
}
disabled={!customKey.trim() || !customLabel.trim()}
className={BTN_PRIMARY}
>
Add
@@ -704,8 +663,7 @@ export function BlueprintFieldCatalog({
onClick={() => setShowCustomForm(true)}
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-800 font-medium py-2"
>
<span className="text-lg leading-none">+</span>{" "}
Add Custom Field
<span className="text-lg leading-none">+</span> Add Custom Field
</button>
)}
</div>
@@ -726,8 +684,7 @@ export function BlueprintFieldCatalog({
{/* Footer */}
<div className="flex items-center justify-between gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 shrink-0">
<span className="text-xs text-gray-400">
{enabledCount} field{enabledCount !== 1 ? "s" : ""} will be
saved
{enabledCount} field{enabledCount !== 1 ? "s" : ""} will be saved
</span>
<div className="flex items-center gap-3">
<button type="button" onClick={onClose} className={BTN_SECONDARY}>
@@ -747,8 +704,8 @@ export function BlueprintFieldCatalog({
) : (
<div className="px-6 py-4 overflow-y-auto">
<p className="text-xs text-gray-500 mb-4">
Role presets are auto-loaded in Step 3 of the Project Creation
Wizard when this blueprint is selected.
Role presets are auto-loaded in Step 3 of the Project Creation Wizard when this
blueprint is selected.
</p>
<RolePresetsEditor
initialPresets={initialRolePresets}
@@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
import { FieldType } from "@capakraken/shared";
import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@capakraken/shared";
import { FieldType } from "@nexus/shared";
import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { RolePresetsEditor } from "./RolePresetsEditor.js";
@@ -53,9 +53,7 @@ function OptionsEditor({ options, onChange }: OptionsEditorProps) {
}
function updateOption(idx: number, field: "value" | "label", val: string) {
const next = options.map((o, i) =>
i === idx ? { ...o, [field]: val } : o,
);
const next = options.map((o, i) => (i === idx ? { ...o, [field]: val } : o));
onChange(next);
}
@@ -111,8 +109,7 @@ interface FieldRowProps {
function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
const [expanded, setExpanded] = useState(false);
const needsOptions =
field.type === FieldType.SELECT || field.type === FieldType.MULTI_SELECT;
const needsOptions = field.type === FieldType.SELECT || field.type === FieldType.MULTI_SELECT;
function update<K extends keyof BlueprintFieldDefinition>(
key: K,
@@ -126,9 +123,7 @@ function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
{/* Main row */}
<div className="flex flex-wrap items-center gap-2">
{/* Drag handle placeholder */}
<span className="text-gray-300 cursor-grab select-none text-lg leading-none">
</span>
<span className="text-gray-300 cursor-grab select-none text-lg leading-none"></span>
{/* Key */}
<input
@@ -158,7 +153,7 @@ function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
// Clear options when switching away from select types
const clearedOptions =
t === FieldType.SELECT || t === FieldType.MULTI_SELECT
? field.options ?? []
? (field.options ?? [])
: undefined;
onChange({ ...field, type: t, options: clearedOptions } as BlueprintFieldDefinition);
}}
@@ -218,29 +213,21 @@ function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500 font-medium">
Placeholder
</label>
<label className="text-xs text-gray-500 font-medium">Placeholder</label>
<input
type="text"
value={field.placeholder ?? ""}
onChange={(e) =>
update("placeholder", e.target.value || undefined)
}
onChange={(e) => update("placeholder", e.target.value || undefined)}
placeholder="Placeholder text"
className="app-input"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500 font-medium">
Description
</label>
<label className="text-xs text-gray-500 font-medium">Description</label>
<input
type="text"
value={field.description ?? ""}
onChange={(e) =>
update("description", e.target.value || undefined)
}
onChange={(e) => update("description", e.target.value || undefined)}
placeholder="Helper text"
className="app-input"
/>
@@ -311,9 +298,8 @@ export function BlueprintFieldEditor({
const utils = trpc.useUtils();
const [activeTab, setActiveTab] = useState<"fields" | "presets">(initialTab);
const [fields, setFields] = useState<BlueprintFieldDefinition[]>(
() =>
[...initialFieldDefs].sort((a, b) => a.order - b.order),
const [fields, setFields] = useState<BlueprintFieldDefinition[]>(() =>
[...initialFieldDefs].sort((a, b) => a.order - b.order),
);
const [saveError, setSaveError] = useState<string | null>(null);
const [presetSaveError, setPresetSaveError] = useState<string | null>(null);
@@ -327,17 +313,11 @@ export function BlueprintFieldEditor({
}
function removeField(idx: number) {
setFields((prev) =>
prev
.filter((_, i) => i !== idx)
.map((f, i) => ({ ...f, order: i })),
);
setFields((prev) => prev.filter((_, i) => i !== idx).map((f, i) => ({ ...f, order: i })));
}
function updateField(idx: number, updated: BlueprintFieldDefinition) {
setFields((prev) =>
prev.map((f, i) => (i === idx ? updated : f)),
);
setFields((prev) => prev.map((f, i) => (i === idx ? updated : f)));
}
function handleSave() {
@@ -375,8 +355,7 @@ export function BlueprintFieldEditor({
{/* Header */}
<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">
Edit Fields:{" "}
<span className="text-gray-600 font-normal">{blueprintName}</span>
Edit Fields: <span className="text-gray-600 font-normal">{blueprintName}</span>
</h2>
<button
type="button"
@@ -461,7 +440,8 @@ export function BlueprintFieldEditor({
) : (
<div className="px-6 py-4">
<p className="text-xs text-gray-500 mb-4">
Role presets are auto-loaded in Step 3 of the Project Creation Wizard when this blueprint is selected.
Role presets are auto-loaded in Step 3 of the Project Creation Wizard when this
blueprint is selected.
</p>
<RolePresetsEditor
initialPresets={initialRolePresets}
@@ -2,8 +2,8 @@
import { useState, useEffect } from "react";
import type { FormEvent } from "react";
import type { BlueprintTarget } from "@capakraken/shared";
import type { BlueprintFieldDefinition } from "@capakraken/shared";
import type { BlueprintTarget } from "@nexus/shared";
import type { BlueprintFieldDefinition } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { BlueprintFieldCatalog } from "./BlueprintFieldCatalog.js";
import { useSelection } from "~/hooks/useSelection.js";
@@ -637,7 +637,7 @@ export function BlueprintsClient() {
}
initialRolePresets={
Array.isArray(editingBlueprint.rolePresets)
? (editingBlueprint.rolePresets as import("@capakraken/shared").StaffingRequirement[])
? (editingBlueprint.rolePresets as import("@nexus/shared").StaffingRequirement[])
: []
}
initialTab={editingTab}
@@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
import { FieldType } from "@capakraken/shared";
import type { FieldOption } from "@capakraken/shared";
import { FieldType } from "@nexus/shared";
import type { FieldOption } from "@nexus/shared";
import type { CatalogField } from "~/lib/blueprint-field-catalog.js";
// ---------------------------------------------------------------------------
@@ -234,9 +234,7 @@ function DefaultValueInput({
<input
type="number"
value={value != null ? String(value) : ""}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : Number(e.target.value))
}
onChange={(e) => onChange(e.target.value === "" ? undefined : Number(e.target.value))}
placeholder="No default"
className="app-input"
/>
@@ -247,9 +245,7 @@ function DefaultValueInput({
<input
type="date"
value={typeof value === "string" ? value : ""}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : e.target.value)
}
onChange={(e) => onChange(e.target.value === "" ? undefined : e.target.value)}
className="app-input"
/>
);
@@ -258,9 +254,7 @@ function DefaultValueInput({
return (
<select
value={typeof value === "string" ? value : ""}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : e.target.value)
}
onChange={(e) => onChange(e.target.value === "" ? undefined : e.target.value)}
className="app-input"
>
<option value="">No default</option>
@@ -286,9 +280,7 @@ function DefaultValueInput({
<input
type="url"
value={typeof value === "string" ? value : ""}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : e.target.value)
}
onChange={(e) => onChange(e.target.value === "" ? undefined : e.target.value)}
placeholder="https://..."
className="app-input"
/>
@@ -299,9 +291,7 @@ function DefaultValueInput({
<input
type="email"
value={typeof value === "string" ? value : ""}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : e.target.value)
}
onChange={(e) => onChange(e.target.value === "" ? undefined : e.target.value)}
placeholder="name@example.com"
className="app-input"
/>
@@ -311,9 +301,7 @@ function DefaultValueInput({
return (
<textarea
value={typeof value === "string" ? value : ""}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : e.target.value)
}
onChange={(e) => onChange(e.target.value === "" ? undefined : e.target.value)}
placeholder="No default"
className="app-input resize-none"
rows={2}
@@ -325,9 +313,7 @@ function DefaultValueInput({
<input
type="text"
value={typeof value === "string" ? value : ""}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : e.target.value)
}
onChange={(e) => onChange(e.target.value === "" ? undefined : e.target.value)}
placeholder="No default"
className="app-input"
/>
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import type { StaffingRequirement } from "@capakraken/shared";
import type { StaffingRequirement } from "@nexus/shared";
import { uuid } from "~/lib/uuid.js";
function makeEmptyPreset(): StaffingRequirement {
@@ -1,6 +1,6 @@
"use client";
import type { CommentEntityType } from "@capakraken/shared";
import type { CommentEntityType } from "@nexus/shared";
import { createPortal } from "react-dom";
import { useCallback, useEffect, useRef, useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
@@ -39,14 +39,17 @@ export function CommentInput({
const [cursorPosition, setCursorPosition] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const usersQuery = trpc.comment.listMentionCandidates.useQuery({
entityType,
entityId,
...(mentionQuery && mentionQuery.length > 0 ? { query: mentionQuery } : {}),
}, {
enabled: mentionQuery !== null,
staleTime: 60_000,
});
const usersQuery = trpc.comment.listMentionCandidates.useQuery(
{
entityType,
entityId,
...(mentionQuery && mentionQuery.length > 0 ? { query: mentionQuery } : {}),
},
{
enabled: mentionQuery !== null,
staleTime: 60_000,
},
);
const filteredUsers: MentionCandidate[] =
mentionQuery !== null ? (usersQuery.data ?? []).slice(0, 8) : [];
@@ -63,25 +66,22 @@ export function CommentInput({
setMentionIndex(0);
}, [mentionQuery]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const cursor = e.target.selectionStart ?? value.length;
setBody(value);
setCursorPosition(cursor);
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const cursor = e.target.selectionStart ?? value.length;
setBody(value);
setCursorPosition(cursor);
// Detect if we are in a @mention context
const textBeforeCursor = value.slice(0, cursor);
const atMatch = textBeforeCursor.match(/@([^\s@]*)$/);
// Detect if we are in a @mention context
const textBeforeCursor = value.slice(0, cursor);
const atMatch = textBeforeCursor.match(/@([^\s@]*)$/);
if (atMatch) {
setMentionQuery(atMatch[1]!);
} else {
setMentionQuery(null);
}
},
[],
);
if (atMatch) {
setMentionQuery(atMatch[1]!);
} else {
setMentionQuery(null);
}
}, []);
const insertMention = useCallback(
(user: MentionCandidate) => {
@@ -96,8 +96,7 @@ export function CommentInput({
const displayName = user.name ?? user.email;
const mentionText = `@[${displayName}](${user.id}) `;
const newBody =
textBeforeCursor.slice(0, atStart) + mentionText + textAfterCursor;
const newBody = textBeforeCursor.slice(0, atStart) + mentionText + textAfterCursor;
setBody(newBody);
setMentionQuery(null);
@@ -121,16 +120,12 @@ export function CommentInput({
if (mentionQuery !== null && filteredUsers.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setMentionIndex((prev) =>
prev < filteredUsers.length - 1 ? prev + 1 : 0,
);
setMentionIndex((prev) => (prev < filteredUsers.length - 1 ? prev + 1 : 0));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setMentionIndex((prev) =>
prev > 0 ? prev - 1 : filteredUsers.length - 1,
);
setMentionIndex((prev) => (prev > 0 ? prev - 1 : filteredUsers.length - 1));
return;
}
if (e.key === "Enter" || e.key === "Tab") {
@@ -218,9 +213,7 @@ export function CommentInput({
: null}
<div className="mt-2 flex items-center justify-between">
<span className="text-xs text-gray-400">
Ctrl+Enter to submit
</span>
<span className="text-xs text-gray-400">Ctrl+Enter to submit</span>
<div className="flex gap-2">
{onCancel && (
<button
@@ -1,6 +1,6 @@
"use client";
import type { CommentEntityType } from "@capakraken/shared";
import type { CommentEntityType } from "@nexus/shared";
import { useState } from "react";
import { clsx } from "clsx";
import { trpc } from "~/lib/trpc/client.js";
@@ -150,12 +150,7 @@ function SingleComment({
const isResolved = comment.resolved;
return (
<div
className={clsx(
"group relative",
isResolved && "opacity-60",
)}
>
<div className={clsx("group relative", isResolved && "opacity-60")}>
<div className={clsx("flex gap-3", isReply && "ml-10")}>
<AuthorAvatar author={comment.author} />
<div className="min-w-0 flex-1">
@@ -163,9 +158,7 @@ function SingleComment({
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{comment.author.name ?? comment.author.email}
</span>
<span className="text-xs text-gray-400">
{formatRelativeTime(comment.createdAt)}
</span>
<span className="text-xs text-gray-400">{formatRelativeTime(comment.createdAt)}</span>
{isResolved && (
<span className="rounded-full bg-emerald-100 dark:bg-emerald-900/50 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:text-emerald-300">
Resolved
@@ -173,7 +166,11 @@ function SingleComment({
)}
</div>
<div className={clsx(isResolved && "line-through decoration-gray-300 dark:decoration-gray-600")}>
<div
className={clsx(
isResolved && "line-through decoration-gray-300 dark:decoration-gray-600",
)}
>
<CommentBody body={comment.body} />
</div>
@@ -216,17 +213,17 @@ function SingleComment({
{/* Inline reply input */}
{showReplyInput && (
<div className="mt-3">
<CommentInput
entityType={commentTarget.entityType}
entityId={commentTarget.entityId}
parentId={comment.id}
onSubmit={(replyBody) => {
createMutation.mutate({
entityType: commentTarget.entityType,
entityId: commentTarget.entityId,
parentId: comment.id,
body: replyBody,
});
<CommentInput
entityType={commentTarget.entityType}
entityId={commentTarget.entityId}
parentId={comment.id}
onSubmit={(replyBody) => {
createMutation.mutate({
entityType: commentTarget.entityType,
entityId: commentTarget.entityId,
parentId: comment.id,
body: replyBody,
});
}}
onCancel={() => setShowReplyInput(false)}
isSubmitting={createMutation.isPending}
@@ -256,12 +253,7 @@ function SingleComment({
{"replies" in comment && comment.replies.length > 0 && (
<div className="mt-3 space-y-3 border-l-2 border-gray-100 dark:border-gray-700 pl-2">
{comment.replies.map((reply) => (
<SingleComment
key={reply.id}
comment={reply}
commentTarget={commentTarget}
isReply
/>
<SingleComment key={reply.id} comment={reply} commentTarget={commentTarget} isReply />
))}
</div>
)}
@@ -272,10 +264,7 @@ function SingleComment({
export function CommentThread({ commentTarget }: CommentThreadProps) {
const utils = trpc.useUtils();
const commentsQuery = trpc.comment.list.useQuery(
commentTarget,
{ staleTime: 10_000 },
);
const commentsQuery = trpc.comment.list.useQuery(commentTarget, { staleTime: 10_000 });
const createMutation = trpc.comment.create.useMutation({
onSuccess: () => {
@@ -308,11 +297,7 @@ export function CommentThread({ commentTarget }: CommentThreadProps) {
) : (
<div className="space-y-5">
{comments.map((comment) => (
<SingleComment
key={comment.id}
comment={comment}
commentTarget={commentTarget}
/>
<SingleComment key={comment.id} comment={comment} commentTarget={commentTarget} />
))}
</div>
)}
@@ -1,6 +1,6 @@
"use client";
import type { DashboardWidgetType } from "@capakraken/shared/types";
import type { DashboardWidgetType } from "@nexus/shared/types";
import { WIDGET_CATALOG } from "./widget-registry.js";
interface AddWidgetModalProps {
@@ -44,8 +44,12 @@ export function AddWidgetModal({ onAdd, onClose }: AddWidgetModalProps) {
>
<span className="text-3xl shrink-0">{def.icon}</span>
<div>
<div className="font-semibold text-gray-900 dark:text-gray-100 text-sm">{def.label}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">{def.description}</div>
<div className="font-semibold text-gray-900 dark:text-gray-100 text-sm">
{def.label}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{def.description}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Default: {def.defaultSize.w}×{def.defaultSize.h} grid units
</div>
@@ -1,6 +1,6 @@
"use client";
import type { DashboardWidgetConfig, DashboardWidgetType } from "@capakraken/shared/types";
import type { DashboardWidgetConfig, DashboardWidgetType } from "@nexus/shared/types";
import { verticalCompactor, horizontalCompactor, type Compactor } from "react-grid-layout";
// Runs vertical compaction first (float up), then horizontal (float left).
@@ -152,13 +152,25 @@ function DeferredWidgetBody({
};
}, [activationRank, isActive, isPriority]);
return <div ref={containerRef} className="h-full">{isActive ? renderWidget(type, config, onConfigChange) : <DeferredWidgetFallback />}</div>;
return (
<div ref={containerRef} className="h-full">
{isActive ? renderWidget(type, config, onConfigChange) : <DeferredWidgetFallback />}
</div>
);
}
export function DashboardClient() {
const [addModalOpen, setAddModalOpen] = useState(false);
const { config, isHydrated, saveStatus, addWidget, removeWidget, updateWidgetConfig, onLayoutChange, resetLayout } =
useDashboardLayout();
const {
config,
isHydrated,
saveStatus,
addWidget,
removeWidget,
updateWidgetConfig,
onLayoutChange,
resetLayout,
} = useDashboardLayout();
// Measure grid container width so Responsive knows the column size.
// We can't use WidthProvider (uses findDOMNode, deprecated in React 18).
@@ -2,7 +2,7 @@ import {
DASHBOARD_WIDGET_CATALOG,
type DashboardWidgetCatalogEntry,
type DashboardWidgetType,
} from "@capakraken/shared/types";
} from "@nexus/shared/types";
import { lazy, type ComponentType, type LazyExoticComponent } from "react";
type WidgetUpdate = Record<string, unknown>;
@@ -23,47 +23,71 @@ export const WIDGET_CATALOG = DASHBOARD_WIDGET_CATALOG;
export const WIDGET_REGISTRY: Record<DashboardWidgetType, WidgetDefinition> = {
"stat-cards": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "stat-cards")!,
component: lazy(() => import("./widgets/StatCardsWidget.js").then((m) => ({ default: m.StatCardsWidget }))),
component: lazy(() =>
import("./widgets/StatCardsWidget.js").then((m) => ({ default: m.StatCardsWidget })),
),
},
"resource-table": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "resource-table")!,
component: lazy(() => import("./widgets/ResourceTableWidget.js").then((m) => ({ default: m.ResourceTableWidget }))),
component: lazy(() =>
import("./widgets/ResourceTableWidget.js").then((m) => ({ default: m.ResourceTableWidget })),
),
},
"project-table": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "project-table")!,
component: lazy(() => import("./widgets/ProjectTableWidget.js").then((m) => ({ default: m.ProjectTableWidget }))),
component: lazy(() =>
import("./widgets/ProjectTableWidget.js").then((m) => ({ default: m.ProjectTableWidget })),
),
},
"peak-times-chart": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "peak-times-chart")!,
component: lazy(() => import("./widgets/PeakTimesWidget.js").then((m) => ({ default: m.PeakTimesWidget }))),
component: lazy(() =>
import("./widgets/PeakTimesWidget.js").then((m) => ({ default: m.PeakTimesWidget })),
),
},
"demand-view": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "demand-view")!,
component: lazy(() => import("./widgets/DemandWidget.js").then((m) => ({ default: m.DemandWidget }))),
component: lazy(() =>
import("./widgets/DemandWidget.js").then((m) => ({ default: m.DemandWidget })),
),
},
"top-value-resources": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "top-value-resources")!,
component: lazy(() => import("./widgets/TopValueWidget.js").then((m) => ({ default: m.TopValueWidget }))),
component: lazy(() =>
import("./widgets/TopValueWidget.js").then((m) => ({ default: m.TopValueWidget })),
),
},
"chargeability-overview": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "chargeability-overview")!,
component: lazy(() => import("./widgets/ChargeabilityWidget.js").then((m) => ({ default: m.ChargeabilityWidget }))),
component: lazy(() =>
import("./widgets/ChargeabilityWidget.js").then((m) => ({ default: m.ChargeabilityWidget })),
),
},
"my-projects": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "my-projects")!,
component: lazy(() => import("./widgets/MyProjectsWidget.js").then((m) => ({ default: m.MyProjectsWidget }))),
component: lazy(() =>
import("./widgets/MyProjectsWidget.js").then((m) => ({ default: m.MyProjectsWidget })),
),
},
"budget-forecast": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "budget-forecast")!,
component: lazy(() => import("./widgets/BudgetForecastWidget.js").then((m) => ({ default: m.BudgetForecastWidget }))),
component: lazy(() =>
import("./widgets/BudgetForecastWidget.js").then((m) => ({
default: m.BudgetForecastWidget,
})),
),
},
"skill-gap": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "skill-gap")!,
component: lazy(() => import("./widgets/SkillGapWidget.js").then((m) => ({ default: m.SkillGapWidget }))),
component: lazy(() =>
import("./widgets/SkillGapWidget.js").then((m) => ({ default: m.SkillGapWidget })),
),
},
"project-health": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "project-health")!,
component: lazy(() => import("./widgets/ProjectHealthWidget.js").then((m) => ({ default: m.ProjectHealthWidget }))),
component: lazy(() =>
import("./widgets/ProjectHealthWidget.js").then((m) => ({ default: m.ProjectHealthWidget })),
),
},
};
@@ -5,7 +5,7 @@ import Link from "next/link";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { formatCents, formatMoney } from "~/lib/format.js";
import { ProjectStatus } from "@capakraken/shared/types";
import { ProjectStatus } from "@nexus/shared/types";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { PROJECT_STATUS_BADGE as STATUS_COLORS } from "~/lib/status-styles.js";
import { AnimatedNumber } from "~/components/ui/AnimatedNumber.js";
@@ -37,11 +37,7 @@ export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) {
{/* header row */}
<div className="flex gap-3 px-3 py-2">
{[40, 120, 80, 60, 60].map((w, i) => (
<div
key={i}
className="h-2.5 shimmer-skeleton rounded"
style={{ width: w }}
/>
<div key={i} className="h-2.5 shimmer-skeleton rounded" style={{ width: w }} />
))}
</div>
{/* data rows */}
@@ -2,8 +2,8 @@
import { clsx } from "clsx";
import { DateInput } from "~/components/ui/DateInput.js";
import { FieldType } from "@capakraken/shared";
import type { BlueprintFieldDefinition } from "@capakraken/shared";
import { FieldType } from "@nexus/shared";
import type { BlueprintFieldDefinition } from "@nexus/shared";
interface Props {
fieldDefs: BlueprintFieldDefinition[];
@@ -16,7 +16,8 @@ interface Props {
const INPUT_BASE =
"w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 transition-colors";
const INPUT_NORMAL = "border-gray-300 bg-white text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100";
const INPUT_NORMAL =
"border-gray-300 bg-white text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100";
const INPUT_ERROR = "border-red-400 bg-red-50 text-gray-900 dark:border-red-500 dark:text-gray-100";
function inputClass(hasError: boolean) {
@@ -39,7 +40,7 @@ function FieldInput({ fieldDef, value, onChange, hasError }: FieldInputProps) {
<input
type="text"
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
value={typeof value === "string" ? value : ((value ?? "") as string)}
placeholder={placeholder}
maxLength={validation?.maxLength}
minLength={validation?.minLength}
@@ -52,7 +53,7 @@ function FieldInput({ fieldDef, value, onChange, hasError }: FieldInputProps) {
return (
<textarea
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
value={typeof value === "string" ? value : ((value ?? "") as string)}
placeholder={placeholder}
maxLength={validation?.maxLength}
onChange={(e) => onChange(key, e.target.value)}
@@ -70,9 +71,7 @@ function FieldInput({ fieldDef, value, onChange, hasError }: FieldInputProps) {
placeholder={placeholder}
min={validation?.min}
max={validation?.max}
onChange={(e) =>
onChange(key, e.target.value === "" ? "" : Number(e.target.value))
}
onChange={(e) => onChange(key, e.target.value === "" ? "" : Number(e.target.value))}
className={inputClass(hasError)}
/>
);
@@ -88,9 +87,7 @@ function FieldInput({ fieldDef, value, onChange, hasError }: FieldInputProps) {
onChange={(e) => onChange(key, e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<span className="text-sm text-gray-700">
{checked ? "Yes" : "No"}
</span>
<span className="text-sm text-gray-700">{checked ? "Yes" : "No"}</span>
</label>
);
}
@@ -99,7 +96,7 @@ function FieldInput({ fieldDef, value, onChange, hasError }: FieldInputProps) {
return (
<DateInput
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
value={typeof value === "string" ? value : ((value ?? "") as string)}
onChange={(v) => onChange(key, v)}
className={inputClass(hasError)}
/>
@@ -109,7 +106,7 @@ function FieldInput({ fieldDef, value, onChange, hasError }: FieldInputProps) {
return (
<select
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
value={typeof value === "string" ? value : ((value ?? "") as string)}
onChange={(e) => onChange(key, e.target.value)}
className={inputClass(hasError)}
>
@@ -155,7 +152,7 @@ function FieldInput({ fieldDef, value, onChange, hasError }: FieldInputProps) {
<input
type="url"
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
value={typeof value === "string" ? value : ((value ?? "") as string)}
placeholder={placeholder ?? "https://"}
onChange={(e) => onChange(key, e.target.value)}
className={inputClass(hasError)}
@@ -167,7 +164,7 @@ function FieldInput({ fieldDef, value, onChange, hasError }: FieldInputProps) {
<input
type="email"
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
value={typeof value === "string" ? value : ((value ?? "") as string)}
placeholder={placeholder ?? "email@example.com"}
onChange={(e) => onChange(key, e.target.value)}
className={inputClass(hasError)}
@@ -179,7 +176,7 @@ function FieldInput({ fieldDef, value, onChange, hasError }: FieldInputProps) {
<input
type="text"
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
value={typeof value === "string" ? value : ((value ?? "") as string)}
placeholder={placeholder}
onChange={(e) => onChange(key, e.target.value)}
className={inputClass(hasError)}
@@ -199,10 +196,7 @@ function FieldWrapper({ fieldDef, value, onChange, error }: FieldWrapperProps) {
const hasError = Boolean(error);
return (
<div className="flex flex-col gap-1">
<label
htmlFor={fieldDef.key}
className="text-sm font-medium text-gray-700"
>
<label htmlFor={fieldDef.key} className="text-sm font-medium text-gray-700">
{fieldDef.label}
{fieldDef.required && (
<span className="ml-0.5 text-red-500" aria-hidden="true">
@@ -211,12 +205,7 @@ function FieldWrapper({ fieldDef, value, onChange, error }: FieldWrapperProps) {
)}
</label>
<FieldInput
fieldDef={fieldDef}
value={value}
onChange={onChange}
hasError={hasError}
/>
<FieldInput fieldDef={fieldDef} value={value} onChange={onChange} hasError={hasError} />
{fieldDef.description && !error && (
<p className="text-xs text-gray-400">{fieldDef.description}</p>
@@ -262,13 +251,7 @@ function FieldGroup({
);
}
export function DynamicFieldEditor({
fieldDefs,
values,
onChange,
errors,
className,
}: Props) {
export function DynamicFieldEditor({ fieldDefs, values, onChange, errors, className }: Props) {
const sorted = [...fieldDefs].sort((a, b) => a.order - b.order);
const ungrouped = sorted.filter((f) => !f.group);
@@ -1,7 +1,7 @@
import { clsx } from "clsx";
import { formatDateLong } from "~/lib/format.js";
import { FieldType } from "@capakraken/shared";
import type { BlueprintFieldDefinition } from "@capakraken/shared";
import { FieldType } from "@nexus/shared";
import type { BlueprintFieldDefinition } from "@nexus/shared";
interface Props {
fieldDefs: BlueprintFieldDefinition[];
@@ -1,12 +1,12 @@
"use client";
import { useEffect, useState } from "react";
import type { CommercialTerms, PaymentMilestone, PricingModel } from "@capakraken/shared";
import type { CommercialTerms, PaymentMilestone, PricingModel } from "@nexus/shared";
import {
computeCommercialTermsSummary,
computeMilestoneAmounts,
validatePaymentMilestones,
} from "@capakraken/engine";
} from "@nexus/engine";
import { clsx } from "clsx";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatMoney } from "~/lib/format.js";
@@ -100,7 +100,8 @@ export function CommercialTermsEditor({
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">
Adjusted Cost <InfoTooltip content="Base cost + contingency. Adjusted cost = base cost x (1 + contingency %)." />
Adjusted Cost{" "}
<InfoTooltip content="Base cost + contingency. Adjusted cost = base cost x (1 + contingency %)." />
</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">
{formatMoney(summary.adjustedCostCents, baseCurrency)}
@@ -113,7 +114,8 @@ export function CommercialTermsEditor({
</div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">
Adjusted Price <InfoTooltip content="Base price minus discount. Adjusted price = base price x (1 - discount %)." />
Adjusted Price{" "}
<InfoTooltip content="Base price minus discount. Adjusted price = base price x (1 - discount %)." />
</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">
{formatMoney(summary.adjustedPriceCents, baseCurrency)}
@@ -126,14 +128,13 @@ export function CommercialTermsEditor({
</div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">
Adjusted Margin <InfoTooltip content="Adjusted margin = adjusted price - adjusted cost. Margin % = margin / adjusted price x 100." />
Adjusted Margin{" "}
<InfoTooltip content="Adjusted margin = adjusted price - adjusted cost. Margin % = margin / adjusted price x 100." />
</p>
<p
className={clsx(
"mt-2 text-2xl font-semibold",
summary.adjustedMarginCents >= 0
? "text-emerald-700"
: "text-red-700",
summary.adjustedMarginCents >= 0 ? "text-emerald-700" : "text-red-700",
)}
>
{formatMoney(summary.adjustedMarginCents, baseCurrency)}
@@ -144,16 +145,15 @@ export function CommercialTermsEditor({
</div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">
Pricing Model <InfoTooltip content="Fixed Price: agreed total. Time & Materials: billed per actual hour. Hybrid: mix of both." />
Pricing Model{" "}
<InfoTooltip content="Fixed Price: agreed total. Time & Materials: billed per actual hour. Hybrid: mix of both." />
</p>
<p className="mt-2 text-lg font-semibold text-gray-900">
{PRICING_MODELS.find((m) => m.value === terms.pricingModel)?.label ??
terms.pricingModel}
</p>
{terms.warrantyMonths > 0 && (
<p className="mt-1 text-xs text-gray-500">
{terms.warrantyMonths} mo warranty
</p>
<p className="mt-1 text-xs text-gray-500">{terms.warrantyMonths} mo warranty</p>
)}
</div>
</div>
@@ -161,9 +161,7 @@ export function CommercialTermsEditor({
{/* Terms editor */}
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-semibold text-gray-900">
Commercial Terms
</h3>
<h3 className="text-base font-semibold text-gray-900">Commercial Terms</h3>
{canEdit && dirty && (
<button
type="button"
@@ -184,9 +182,7 @@ export function CommercialTermsEditor({
</label>
<select
value={terms.pricingModel}
onChange={(e) =>
update({ pricingModel: e.target.value as PricingModel })
}
onChange={(e) => update({ pricingModel: e.target.value as PricingModel })}
disabled={!canEdit}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm disabled:bg-gray-50"
>
@@ -201,7 +197,8 @@ export function CommercialTermsEditor({
{/* Contingency % */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Contingency % <InfoTooltip content="Risk buffer added to the base cost. Adjusted cost = base cost x (1 + contingency %)." />
Contingency %{" "}
<InfoTooltip content="Risk buffer added to the base cost. Adjusted cost = base cost x (1 + contingency %)." />
</label>
<input
type="number"
@@ -209,9 +206,7 @@ export function CommercialTermsEditor({
max={100}
step={0.5}
value={terms.contingencyPercent}
onChange={(e) =>
update({ contingencyPercent: parseFloat(e.target.value) || 0 })
}
onChange={(e) => update({ contingencyPercent: parseFloat(e.target.value) || 0 })}
disabled={!canEdit}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums disabled:bg-gray-50"
/>
@@ -220,7 +215,8 @@ export function CommercialTermsEditor({
{/* Discount % */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Discount % <InfoTooltip content="Client discount applied to the base price. Adjusted price = base price x (1 - discount %)." />
Discount %{" "}
<InfoTooltip content="Client discount applied to the base price. Adjusted price = base price x (1 - discount %)." />
</label>
<input
type="number"
@@ -228,9 +224,7 @@ export function CommercialTermsEditor({
max={100}
step={0.5}
value={terms.discountPercent}
onChange={(e) =>
update({ discountPercent: parseFloat(e.target.value) || 0 })
}
onChange={(e) => update({ discountPercent: parseFloat(e.target.value) || 0 })}
disabled={!canEdit}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums disabled:bg-gray-50"
/>
@@ -239,16 +233,15 @@ export function CommercialTermsEditor({
{/* Payment Terms */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Payment Terms (days) <InfoTooltip content="Number of days after invoice date within which payment is due." />
Payment Terms (days){" "}
<InfoTooltip content="Number of days after invoice date within which payment is due." />
</label>
<input
type="number"
min={0}
max={365}
value={terms.paymentTermDays}
onChange={(e) =>
update({ paymentTermDays: parseInt(e.target.value) || 0 })
}
onChange={(e) => update({ paymentTermDays: parseInt(e.target.value) || 0 })}
disabled={!canEdit}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums disabled:bg-gray-50"
/>
@@ -257,16 +250,15 @@ export function CommercialTermsEditor({
{/* Warranty */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Warranty (months) <InfoTooltip content="Post-delivery warranty period during which defects are covered at no extra cost." />
Warranty (months){" "}
<InfoTooltip content="Post-delivery warranty period during which defects are covered at no extra cost." />
</label>
<input
type="number"
min={0}
max={60}
value={terms.warrantyMonths}
onChange={(e) =>
update({ warrantyMonths: parseInt(e.target.value) || 0 })
}
onChange={(e) => update({ warrantyMonths: parseInt(e.target.value) || 0 })}
disabled={!canEdit}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums disabled:bg-gray-50"
/>
@@ -276,13 +268,12 @@ export function CommercialTermsEditor({
{/* Notes */}
<div className="mt-4">
<label className="block text-xs font-medium text-gray-500 mb-1">
Notes <InfoTooltip content="Free-text notes about the commercial terms, e.g. special conditions or negotiation context." />
Notes{" "}
<InfoTooltip content="Free-text notes about the commercial terms, e.g. special conditions or negotiation context." />
</label>
<textarea
value={terms.notes ?? ""}
onChange={(e) =>
update({ notes: e.target.value || null })
}
onChange={(e) => update({ notes: e.target.value || null })}
disabled={!canEdit}
rows={2}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm disabled:bg-gray-50"
@@ -295,17 +286,15 @@ export function CommercialTermsEditor({
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-semibold text-gray-900">
Payment Milestones <InfoTooltip content="Define when payments are due as a percentage of the adjusted price. Milestones should sum to 100%." />
Payment Milestones{" "}
<InfoTooltip content="Define when payments are due as a percentage of the adjusted price. Milestones should sum to 100%." />
</h3>
{canEdit && (
<button
type="button"
onClick={() =>
update({
paymentMilestones: [
...terms.paymentMilestones,
{ label: "", percent: 0 },
],
paymentMilestones: [...terms.paymentMilestones, { label: "", percent: 0 }],
})
}
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
@@ -328,9 +317,7 @@ export function CommercialTermsEditor({
)}
{terms.paymentMilestones.length === 0 ? (
<p className="text-sm text-gray-400">
No payment milestones defined.
</p>
<p className="text-sm text-gray-400">No payment milestones defined.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
@@ -340,9 +327,7 @@ export function CommercialTermsEditor({
<th className="px-3 py-2 text-right font-medium w-24">%</th>
<th className="px-3 py-2 text-right font-medium">Amount</th>
<th className="px-3 py-2 font-medium w-36">Due Date</th>
{canEdit && (
<th className="pl-3 py-2 font-medium w-12" />
)}
{canEdit && <th className="pl-3 py-2 font-medium w-12" />}
</tr>
</thead>
<tbody>
@@ -386,15 +371,11 @@ export function CommercialTermsEditor({
className="w-20 rounded border border-gray-200 px-2 py-1 text-right text-sm tabular-nums"
/>
) : (
<span className="tabular-nums text-gray-700">
{ms.percent}%
</span>
<span className="tabular-nums text-gray-700">{ms.percent}%</span>
)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{amount
? formatMoney(amount.amountCents, baseCurrency)
: "—"}
{amount ? formatMoney(amount.amountCents, baseCurrency) : "—"}
</td>
<td className="px-3 py-2">
{canEdit ? (
@@ -412,9 +393,7 @@ export function CommercialTermsEditor({
className="rounded border border-gray-200 px-2 py-1 text-sm"
/>
) : (
<span className="text-gray-700">
{ms.dueDate ?? "—"}
</span>
<span className="text-gray-700">{ms.dueDate ?? "—"}</span>
)}
</td>
{canEdit && (
@@ -422,9 +401,7 @@ export function CommercialTermsEditor({
<button
type="button"
onClick={() => {
const updated = terms.paymentMilestones.filter(
(_, i) => i !== idx,
);
const updated = terms.paymentMilestones.filter((_, i) => i !== idx);
update({ paymentMilestones: updated });
}}
className="text-red-400 hover:text-red-600 text-xs"
@@ -441,10 +418,7 @@ export function CommercialTermsEditor({
<tr className="border-t-2 border-gray-300 font-semibold">
<td className="py-2 pr-3 text-gray-900">Total</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-900">
{terms.paymentMilestones
.reduce((sum, m) => sum + m.percent, 0)
.toFixed(1)}
%
{terms.paymentMilestones.reduce((sum, m) => sum + m.percent, 0).toFixed(1)}%
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-900">
{formatMoney(
@@ -1,8 +1,8 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { EstimateStatus } from "@capakraken/shared";
import { computeEvenSpread } from "@capakraken/engine";
import { EstimateStatus } from "@nexus/shared";
import { computeEvenSpread } from "@nexus/engine";
import { isSpreadsheetFile } from "~/lib/excel.js";
import { parseScopeImport } from "~/lib/scopeImportParser.js";
import { clsx } from "clsx";
@@ -189,7 +189,7 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
}));
const selectedProject = projectId
? projects.find((project) => project.id === projectId) ?? null
? (projects.find((project) => project.id === projectId) ?? null)
: null;
const summary = useMemo(() => {
@@ -210,9 +210,8 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
}, [demandLines]);
const marginCents = summary.totalPriceCents - summary.totalCostCents;
const marginPercent = summary.totalPriceCents > 0
? Math.round((marginCents / summary.totalPriceCents) * 100)
: 0;
const marginPercent =
summary.totalPriceCents > 0 ? Math.round((marginCents / summary.totalPriceCents) * 100) : 0;
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
@@ -226,27 +225,19 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
}, [onClose]);
function updateAssumption(id: string, patch: Partial<AssumptionRow>) {
setAssumptions((current) =>
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
);
setAssumptions((current) => current.map((row) => (row.id === id ? { ...row, ...patch } : row)));
}
function updateScopeItem(id: string, patch: Partial<ScopeRow>) {
setScopeItems((current) =>
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
);
setScopeItems((current) => current.map((row) => (row.id === id ? { ...row, ...patch } : row)));
}
function updateDemandLine(id: string, patch: Partial<DemandRow>) {
setDemandLines((current) =>
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
);
setDemandLines((current) => current.map((row) => (row.id === id ? { ...row, ...patch } : row)));
}
function applyResource(resourceId: string | null, demandLineId: string) {
const resource = resourceId
? resources.find((item) => item.id === resourceId) ?? null
: null;
const resource = resourceId ? (resources.find((item) => item.id === resourceId) ?? null) : null;
updateDemandLine(demandLineId, {
resourceId,
@@ -342,15 +333,14 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
const normalizedDemandLines = demandLines
.map((line, index) => {
const resource = line.resourceId
? resources.find((item) => item.id === line.resourceId) ?? null
: null;
const role = line.roleId
? roles.find((item) => item.id === line.roleId) ?? null
? (resources.find((item) => item.id === line.resourceId) ?? null)
: null;
const role = line.roleId ? (roles.find((item) => item.id === line.roleId) ?? null) : null;
const hours = toHours(line.hours);
const costRateCents = toCents(line.costRate);
const billRateCents = toCents(line.billRate);
const displayName = line.name.trim() || resource?.displayName || role?.name || `Line ${index + 1}`;
const displayName =
line.name.trim() || resource?.displayName || role?.name || `Line ${index + 1}`;
return {
resourceId: line.resourceId ?? undefined,
@@ -449,14 +439,21 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-950/45 p-4">
<div ref={panelRef} className="flex max-h-[92vh] w-full max-w-6xl flex-col overflow-hidden rounded-[32px] bg-white shadow-2xl">
<div
ref={panelRef}
className="flex max-h-[92vh] w-full max-w-6xl flex-col overflow-hidden rounded-[32px] bg-white shadow-2xl"
>
<div className="border-b border-gray-100 px-6 py-5">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600">Estimate Wizard</p>
<h2 className="mt-2 text-2xl font-semibold text-gray-900">Create a connected estimate</h2>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600">
Estimate Wizard
</p>
<h2 className="mt-2 text-2xl font-semibold text-gray-900">
Create a connected estimate
</h2>
<p className="mt-1 text-sm text-gray-500">
Rates, resource snapshots, and project linkage are pulled from existing CapaKraken data.
Rates, resource snapshots, and project linkage are pulled from existing Nexus data.
</p>
</div>
<button
@@ -501,20 +498,50 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<div className="space-y-5">
<div className="grid gap-5 md:grid-cols-2">
<div>
<label className="app-label">Estimate Name <InfoTooltip content="A descriptive name for this estimate, e.g. project name + scope qualifier." /></label>
<input value={name} onChange={(event) => setName(event.target.value)} className="app-input" placeholder="CGI Breakdown Q2 2026" />
<label className="app-label">
Estimate Name{" "}
<InfoTooltip content="A descriptive name for this estimate, e.g. project name + scope qualifier." />
</label>
<input
value={name}
onChange={(event) => setName(event.target.value)}
className="app-input"
placeholder="CGI Breakdown Q2 2026"
/>
</div>
<div>
<label className="app-label">Linked Project <InfoTooltip content="Link to an existing CapaKraken project. This enables automatic date-based phasing and planning handoff." /></label>
<ProjectCombobox value={projectId} onChange={setProjectId} placeholder="Link to project" />
<label className="app-label">
Linked Project{" "}
<InfoTooltip content="Link to an existing Nexus project. This enables automatic date-based phasing and planning handoff." />
</label>
<ProjectCombobox
value={projectId}
onChange={setProjectId}
placeholder="Link to project"
/>
</div>
<div>
<label className="app-label">Opportunity ID <InfoTooltip content="Optional external reference from your CRM or sales system to track this opportunity." /></label>
<input value={opportunityId} onChange={(event) => setOpportunityId(event.target.value)} className="app-input" placeholder="Optional CRM or sales reference" />
<label className="app-label">
Opportunity ID{" "}
<InfoTooltip content="Optional external reference from your CRM or sales system to track this opportunity." />
</label>
<input
value={opportunityId}
onChange={(event) => setOpportunityId(event.target.value)}
className="app-input"
placeholder="Optional CRM or sales reference"
/>
</div>
<div>
<label className="app-label">Estimate Status <InfoTooltip content="DRAFT: work in progress. IN_REVIEW: submitted for approval. APPROVED: locked and ready for handoff. ARCHIVED: no longer active." /></label>
<select value={status} onChange={(event) => setStatus(event.target.value as EstimateStatus)} className="app-select w-full">
<label className="app-label">
Estimate Status{" "}
<InfoTooltip content="DRAFT: work in progress. IN_REVIEW: submitted for approval. APPROVED: locked and ready for handoff. ARCHIVED: no longer active." />
</label>
<select
value={status}
onChange={(event) => setStatus(event.target.value as EstimateStatus)}
className="app-select w-full"
>
{Object.values(EstimateStatus).map((value) => (
<option key={value} value={value}>
{value.replace("_", " ")}
@@ -523,17 +550,36 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
</select>
</div>
<div>
<label className="app-label">Base Currency <InfoTooltip content="ISO 4217 currency code (e.g. EUR, USD) used for all monetary values in this estimate." /></label>
<input value={baseCurrency} onChange={(event) => setBaseCurrency(event.target.value.toUpperCase())} className="app-input" maxLength={3} />
<label className="app-label">
Base Currency{" "}
<InfoTooltip content="ISO 4217 currency code (e.g. EUR, USD) used for all monetary values in this estimate." />
</label>
<input
value={baseCurrency}
onChange={(event) => setBaseCurrency(event.target.value.toUpperCase())}
className="app-input"
maxLength={3}
/>
</div>
<div>
<label className="app-label">Version Label <InfoTooltip content="A label for the initial version snapshot. Use labels like 'Initial', 'Client revision 2', etc." /></label>
<input value={versionLabel} onChange={(event) => setVersionLabel(event.target.value)} className="app-input" placeholder="Initial" />
<label className="app-label">
Version Label{" "}
<InfoTooltip content="A label for the initial version snapshot. Use labels like 'Initial', 'Client revision 2', etc." />
</label>
<input
value={versionLabel}
onChange={(event) => setVersionLabel(event.target.value)}
className="app-input"
placeholder="Initial"
/>
</div>
</div>
<div>
<label className="app-label">Version Notes <InfoTooltip content="Free-text notes for this version. Document assumptions, exclusions, or client comments." /></label>
<label className="app-label">
Version Notes{" "}
<InfoTooltip content="Free-text notes for this version. Document assumptions, exclusions, or client comments." />
</label>
<textarea
value={versionNotes}
onChange={(event) => setVersionNotes(event.target.value)}
@@ -547,13 +593,19 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<p className="text-sm font-semibold text-gray-900">Live connection preview</p>
<div className="mt-4 grid gap-3 md:grid-cols-2">
<div className="rounded-2xl bg-white px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Project source</p>
<p className="text-xs uppercase tracking-wide text-gray-400">
Project source
</p>
<p className="mt-1 text-sm text-gray-700">
{selectedProject ? `${selectedProject.shortCode} - ${selectedProject.name}` : "Not linked yet"}
{selectedProject
? `${selectedProject.shortCode} - ${selectedProject.name}`
: "Not linked yet"}
</p>
</div>
<div className="rounded-2xl bg-white px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Live catalogs</p>
<p className="text-xs uppercase tracking-wide text-gray-400">
Live catalogs
</p>
<p className="mt-1 text-sm text-gray-700">
{roles.length} roles, {resources.length} active resources available
</p>
@@ -567,22 +619,70 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">Commercial and delivery assumptions <InfoTooltip content="Preconditions that affect the estimate validity. If an assumption changes, the estimate may need revision." /></h3>
<p className="text-sm text-gray-500">These rows replace free-form spreadsheet notes with structured data.</p>
<h3 className="text-lg font-semibold text-gray-900">
Commercial and delivery assumptions{" "}
<InfoTooltip content="Preconditions that affect the estimate validity. If an assumption changes, the estimate may need revision." />
</h3>
<p className="text-sm text-gray-500">
These rows replace free-form spreadsheet notes with structured data.
</p>
</div>
<button type="button" onClick={() => setAssumptions((current) => [...current, makeAssumption()])} className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
<button
type="button"
onClick={() => setAssumptions((current) => [...current, makeAssumption()])}
className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900"
>
Add assumption
</button>
</div>
<div className="space-y-3">
{assumptions.map((row) => (
<div key={row.id} className="grid gap-3 rounded-2xl border border-gray-100 p-4 md:grid-cols-[140px,1fr,1fr,1.2fr,auto]">
<input value={row.category} onChange={(event) => updateAssumption(row.id, { category: event.target.value })} className="app-input" placeholder="Category" />
<input value={row.label} onChange={(event) => updateAssumption(row.id, { label: event.target.value })} className="app-input" placeholder="Label" />
<input value={row.key} onChange={(event) => updateAssumption(row.id, { key: event.target.value })} className="app-input" placeholder="Key (optional)" />
<input value={row.value} onChange={(event) => updateAssumption(row.id, { value: event.target.value })} className="app-input" placeholder="Value" />
<button type="button" onClick={() => setAssumptions((current) => current.filter((item) => item.id !== row.id))} className="rounded-xl border border-transparent px-3 py-2 text-sm text-red-500 transition hover:border-red-100 hover:bg-red-50">
<div
key={row.id}
className="grid gap-3 rounded-2xl border border-gray-100 p-4 md:grid-cols-[140px,1fr,1fr,1.2fr,auto]"
>
<input
value={row.category}
onChange={(event) =>
updateAssumption(row.id, { category: event.target.value })
}
className="app-input"
placeholder="Category"
/>
<input
value={row.label}
onChange={(event) =>
updateAssumption(row.id, { label: event.target.value })
}
className="app-input"
placeholder="Label"
/>
<input
value={row.key}
onChange={(event) =>
updateAssumption(row.id, { key: event.target.value })
}
className="app-input"
placeholder="Key (optional)"
/>
<input
value={row.value}
onChange={(event) =>
updateAssumption(row.id, { value: event.target.value })
}
className="app-input"
placeholder="Value"
/>
<button
type="button"
onClick={() =>
setAssumptions((current) =>
current.filter((item) => item.id !== row.id),
)
}
className="rounded-xl border border-transparent px-3 py-2 text-sm text-red-500 transition hover:border-red-100 hover:bg-red-50"
>
Remove
</button>
</div>
@@ -595,15 +695,32 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">Scope breakdown <InfoTooltip content="Deliverables and work packages that define what is included in this estimate." /></h3>
<p className="text-sm text-gray-500">Create structured work packages that can later evolve into versioned estimate scope.</p>
<h3 className="text-lg font-semibold text-gray-900">
Scope breakdown{" "}
<InfoTooltip content="Deliverables and work packages that define what is included in this estimate." />
</h3>
<p className="text-sm text-gray-500">
Create structured work packages that can later evolve into versioned
estimate scope.
</p>
</div>
<div className="flex gap-2">
<label className="cursor-pointer rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
Import XLSX
<input type="file" accept=".xlsx,.csv" onChange={handleScopeImport} className="hidden" />
<input
type="file"
accept=".xlsx,.csv"
onChange={handleScopeImport}
className="hidden"
/>
</label>
<button type="button" onClick={() => setScopeItems((current) => [...current, makeScope(current.length + 1)])} className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
<button
type="button"
onClick={() =>
setScopeItems((current) => [...current, makeScope(current.length + 1)])
}
className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900"
>
Add scope row
</button>
</div>
@@ -619,12 +736,46 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<div className="space-y-3">
{scopeItems.map((item, index) => (
<div key={item.id} className="grid gap-3 rounded-2xl border border-gray-100 p-4 md:grid-cols-[90px,120px,1fr,1.2fr,auto]">
<input value={String(index + 1)} readOnly className={clsx("app-input", "bg-gray-50 text-gray-500")} />
<input value={item.scopeType} onChange={(event) => updateScopeItem(item.id, { scopeType: event.target.value })} className="app-input" placeholder="Type" />
<input value={item.name} onChange={(event) => updateScopeItem(item.id, { name: event.target.value })} className="app-input" placeholder="Name" />
<input value={item.description} onChange={(event) => updateScopeItem(item.id, { description: event.target.value })} className="app-input" placeholder="Description" />
<button type="button" onClick={() => setScopeItems((current) => current.filter((row) => row.id !== item.id))} className="rounded-xl border border-transparent px-3 py-2 text-sm text-red-500 transition hover:border-red-100 hover:bg-red-50">
<div
key={item.id}
className="grid gap-3 rounded-2xl border border-gray-100 p-4 md:grid-cols-[90px,120px,1fr,1.2fr,auto]"
>
<input
value={String(index + 1)}
readOnly
className={clsx("app-input", "bg-gray-50 text-gray-500")}
/>
<input
value={item.scopeType}
onChange={(event) =>
updateScopeItem(item.id, { scopeType: event.target.value })
}
className="app-input"
placeholder="Type"
/>
<input
value={item.name}
onChange={(event) =>
updateScopeItem(item.id, { name: event.target.value })
}
className="app-input"
placeholder="Name"
/>
<input
value={item.description}
onChange={(event) =>
updateScopeItem(item.id, { description: event.target.value })
}
className="app-input"
placeholder="Description"
/>
<button
type="button"
onClick={() =>
setScopeItems((current) => current.filter((row) => row.id !== item.id))
}
className="rounded-xl border border-transparent px-3 py-2 text-sm text-red-500 transition hover:border-red-100 hover:bg-red-50"
>
Remove
</button>
</div>
@@ -637,10 +788,20 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">Staffing and rate lines <InfoTooltip content="Each line represents a staffing need. Line cost = hours x cost rate. Line price = hours x sell rate." /></h3>
<p className="text-sm text-gray-500">Selecting a resource pre-fills cost rate, sell rate, chapter, and role from live data.</p>
<h3 className="text-lg font-semibold text-gray-900">
Staffing and rate lines{" "}
<InfoTooltip content="Each line represents a staffing need. Line cost = hours x cost rate. Line price = hours x sell rate." />
</h3>
<p className="text-sm text-gray-500">
Selecting a resource pre-fills cost rate, sell rate, chapter, and role from
live data.
</p>
</div>
<button type="button" onClick={() => setDemandLines((current) => [...current, makeDemand()])} className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
<button
type="button"
onClick={() => setDemandLines((current) => [...current, makeDemand()])}
className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900"
>
Add staffing line
</button>
</div>
@@ -648,19 +809,35 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<div className="space-y-4">
{demandLines.map((line) => {
const resource = line.resourceId
? resources.find((item) => item.id === line.resourceId) ?? null
? (resources.find((item) => item.id === line.resourceId) ?? null)
: null;
return (
<div key={line.id} className="rounded-3xl border border-gray-100 p-4">
<div className="grid gap-4 lg:grid-cols-2">
<div>
<label className="app-label">Resource <InfoTooltip content="Link to a live CapaKraken resource. Auto-fills rates, chapter, and role." /></label>
<ResourceCombobox value={line.resourceId} onChange={(resourceId) => applyResource(resourceId, line.id)} placeholder="Search resource" />
<label className="app-label">
Resource{" "}
<InfoTooltip content="Link to a live Nexus resource. Auto-fills rates, chapter, and role." />
</label>
<ResourceCombobox
value={line.resourceId}
onChange={(resourceId) => applyResource(resourceId, line.id)}
placeholder="Search resource"
/>
</div>
<div>
<label className="app-label">Role <InfoTooltip content="The production role for this demand line (e.g. Compositor, Animator)." /></label>
<select value={line.roleId ?? ""} onChange={(event) => updateDemandLine(line.id, { roleId: event.target.value || null })} className="app-select w-full">
<label className="app-label">
Role{" "}
<InfoTooltip content="The production role for this demand line (e.g. Compositor, Animator)." />
</label>
<select
value={line.roleId ?? ""}
onChange={(event) =>
updateDemandLine(line.id, { roleId: event.target.value || null })
}
className="app-select w-full"
>
<option value="">Unassigned</option>
{roles.map((role) => (
<option key={role.id} value={role.id}>
@@ -670,44 +847,124 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
</select>
</div>
<div>
<label className="app-label">Line Name <InfoTooltip content="Descriptive name for this staffing line, e.g. 'Compositing Lead' or 'PM overhead'." /></label>
<input value={line.name} onChange={(event) => updateDemandLine(line.id, { name: event.target.value })} className="app-input" placeholder="Compositing, lighting, PM, ..." />
<label className="app-label">
Line Name{" "}
<InfoTooltip content="Descriptive name for this staffing line, e.g. 'Compositing Lead' or 'PM overhead'." />
</label>
<input
value={line.name}
onChange={(event) =>
updateDemandLine(line.id, { name: event.target.value })
}
className="app-input"
placeholder="Compositing, lighting, PM, ..."
/>
</div>
<div>
<label className="app-label">Chapter <InfoTooltip content="Department or cost center. Auto-filled when a resource is linked." /></label>
<input value={line.chapter} onChange={(event) => updateDemandLine(line.id, { chapter: event.target.value })} className="app-input" placeholder="Auto-filled from resource when linked" />
<label className="app-label">
Chapter{" "}
<InfoTooltip content="Department or cost center. Auto-filled when a resource is linked." />
</label>
<input
value={line.chapter}
onChange={(event) =>
updateDemandLine(line.id, { chapter: event.target.value })
}
className="app-input"
placeholder="Auto-filled from resource when linked"
/>
</div>
<div>
<label className="app-label">Hours <InfoTooltip content="Total estimated effort in hours. Used to calculate line cost and price." /></label>
<input value={line.hours} onChange={(event) => updateDemandLine(line.id, { hours: event.target.value })} className="app-input" inputMode="decimal" />
<label className="app-label">
Hours{" "}
<InfoTooltip content="Total estimated effort in hours. Used to calculate line cost and price." />
</label>
<input
value={line.hours}
onChange={(event) =>
updateDemandLine(line.id, { hours: event.target.value })
}
className="app-input"
inputMode="decimal"
/>
</div>
<div>
<label className="app-label">Currency <InfoTooltip content="ISO 4217 currency code for this line's rates." /></label>
<input value={line.currency} onChange={(event) => updateDemandLine(line.id, { currency: event.target.value.toUpperCase() })} className="app-input" maxLength={3} />
<label className="app-label">
Currency{" "}
<InfoTooltip content="ISO 4217 currency code for this line's rates." />
</label>
<input
value={line.currency}
onChange={(event) =>
updateDemandLine(line.id, {
currency: event.target.value.toUpperCase(),
})
}
className="app-input"
maxLength={3}
/>
</div>
<div>
<label className="app-label">Cost Rate / h <InfoTooltip content="Internal hourly cost rate in EUR. Line cost = hours x cost rate." /></label>
<input value={line.costRate} onChange={(event) => updateDemandLine(line.id, { costRate: event.target.value })} className="app-input" inputMode="decimal" />
<label className="app-label">
Cost Rate / h{" "}
<InfoTooltip content="Internal hourly cost rate in EUR. Line cost = hours x cost rate." />
</label>
<input
value={line.costRate}
onChange={(event) =>
updateDemandLine(line.id, { costRate: event.target.value })
}
className="app-input"
inputMode="decimal"
/>
</div>
<div>
<label className="app-label">Sell Rate / h <InfoTooltip content="Client-facing hourly rate. Line price = hours x sell rate." /></label>
<input value={line.billRate} onChange={(event) => updateDemandLine(line.id, { billRate: event.target.value })} className="app-input" inputMode="decimal" />
<label className="app-label">
Sell Rate / h{" "}
<InfoTooltip content="Client-facing hourly rate. Line price = hours x sell rate." />
</label>
<input
value={line.billRate}
onChange={(event) =>
updateDemandLine(line.id, { billRate: event.target.value })
}
className="app-input"
inputMode="decimal"
/>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-2xl bg-gray-50 px-4 py-3">
<div className="text-sm text-gray-600">
{resource ? `Linked to ${resource.displayName} (${resource.eid})` : "Manual line"}
{resource
? `Linked to ${resource.displayName} (${resource.eid})`
: "Manual line"}
</div>
<div className="flex flex-wrap gap-4 text-sm">
<span className="font-medium text-gray-700">
Cost {formatMoney(Math.round(toHours(line.hours) * toCents(line.costRate)), line.currency)}
Cost{" "}
{formatMoney(
Math.round(toHours(line.hours) * toCents(line.costRate)),
line.currency,
)}
</span>
<span className="font-medium text-gray-700">
Price {formatMoney(Math.round(toHours(line.hours) * toCents(line.billRate)), line.currency)}
Price{" "}
{formatMoney(
Math.round(toHours(line.hours) * toCents(line.billRate)),
line.currency,
)}
</span>
</div>
<button type="button" onClick={() => setDemandLines((current) => current.filter((item) => item.id !== line.id))} className="rounded-xl border border-transparent px-3 py-2 text-sm text-red-500 transition hover:border-red-100 hover:bg-red-50">
<button
type="button"
onClick={() =>
setDemandLines((current) =>
current.filter((item) => item.id !== line.id),
)
}
className="rounded-xl border border-transparent px-3 py-2 text-sm text-red-500 transition hover:border-red-100 hover:bg-red-50"
>
Remove
</button>
</div>
@@ -722,24 +979,45 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-gray-900">Review</h3>
<p className="text-sm text-gray-500">The summary metrics below are recalculated from the demand rows and persisted on create.</p>
<p className="text-sm text-gray-500">
The summary metrics below are recalculated from the demand rows and persisted
on create.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
<p className="text-xs uppercase tracking-wide text-gray-400">Total Hours <InfoTooltip content="Sum of all demand line hours across the estimate." /></p>
<p className="mt-2 text-2xl font-semibold text-gray-900">{summary.totalHours.toFixed(1)}</p>
<p className="text-xs uppercase tracking-wide text-gray-400">
Total Hours{" "}
<InfoTooltip content="Sum of all demand line hours across the estimate." />
</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">
{summary.totalHours.toFixed(1)}
</p>
</div>
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
<p className="text-xs uppercase tracking-wide text-gray-400">Total Cost <InfoTooltip content="Sum of (hours x cost rate) for each demand line. Stored in cents, displayed in EUR." /></p>
<p className="mt-2 text-2xl font-semibold text-gray-900">{formatMoney(summary.totalCostCents, baseCurrency)}</p>
<p className="text-xs uppercase tracking-wide text-gray-400">
Total Cost{" "}
<InfoTooltip content="Sum of (hours x cost rate) for each demand line. Stored in cents, displayed in EUR." />
</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">
{formatMoney(summary.totalCostCents, baseCurrency)}
</p>
</div>
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
<p className="text-xs uppercase tracking-wide text-gray-400">Total Price <InfoTooltip content="Sum of (hours x sell rate) for each demand line. This is the client-facing revenue." /></p>
<p className="mt-2 text-2xl font-semibold text-gray-900">{formatMoney(summary.totalPriceCents, baseCurrency)}</p>
<p className="text-xs uppercase tracking-wide text-gray-400">
Total Price{" "}
<InfoTooltip content="Sum of (hours x sell rate) for each demand line. This is the client-facing revenue." />
</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">
{formatMoney(summary.totalPriceCents, baseCurrency)}
</p>
</div>
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
<p className="text-xs uppercase tracking-wide text-gray-400">Margin <InfoTooltip content="Margin = Total Price - Total Cost. Margin % = Margin / Total Price x 100." /></p>
<p className="text-xs uppercase tracking-wide text-gray-400">
Margin{" "}
<InfoTooltip content="Margin = Total Price - Total Cost. Margin % = Margin / Total Price x 100." />
</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">
{formatMoney(marginCents, baseCurrency)} ({marginPercent}%)
</p>
@@ -756,7 +1034,9 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
</div>
<div className="flex justify-between gap-4">
<dt>Project</dt>
<dd className="text-right text-gray-900">{selectedProject ? `${selectedProject.shortCode}` : "Standalone"}</dd>
<dd className="text-right text-gray-900">
{selectedProject ? `${selectedProject.shortCode}` : "Standalone"}
</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Status</dt>
@@ -774,19 +1054,27 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<dl className="mt-4 space-y-2 text-sm text-gray-600">
<div className="flex justify-between gap-4">
<dt>Assumptions</dt>
<dd className="text-right text-gray-900">{assumptions.filter((row) => row.label.trim()).length}</dd>
<dd className="text-right text-gray-900">
{assumptions.filter((row) => row.label.trim()).length}
</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Scope items</dt>
<dd className="text-right text-gray-900">{scopeItems.filter((row) => row.name.trim()).length}</dd>
<dd className="text-right text-gray-900">
{scopeItems.filter((row) => row.name.trim()).length}
</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Demand lines</dt>
<dd className="text-right text-gray-900">{demandLines.filter((row) => toHours(row.hours) > 0).length}</dd>
<dd className="text-right text-gray-900">
{demandLines.filter((row) => toHours(row.hours) > 0).length}
</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Resource snapshots</dt>
<dd className="text-right text-gray-900">{new Set(demandLines.map((row) => row.resourceId).filter(Boolean)).size}</dd>
<dd className="text-right text-gray-900">
{new Set(demandLines.map((row) => row.resourceId).filter(Boolean)).size}
</dd>
</div>
</dl>
</div>
@@ -796,25 +1084,36 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
</div>
<aside className="border-t border-gray-100 bg-gray-50 px-6 py-6 lg:border-l lg:border-t-0">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-gray-500">Dynamic summary</p>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-gray-500">
Dynamic summary
</p>
<div className="mt-4 space-y-3">
<div className="rounded-2xl bg-white px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Project link</p>
<p className="mt-1 text-sm text-gray-800">
{selectedProject ? `${selectedProject.shortCode} - ${selectedProject.name}` : "No linked project"}
{selectedProject
? `${selectedProject.shortCode} - ${selectedProject.name}`
: "No linked project"}
</p>
</div>
<div className="rounded-2xl bg-white px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Resource-linked demand</p>
<p className="text-xs uppercase tracking-wide text-gray-400">
Resource-linked demand
</p>
<p className="mt-1 text-sm text-gray-800">
{demandLines.filter((line) => line.resourceId).length} of {demandLines.length} rows tied to live resources
{demandLines.filter((line) => line.resourceId).length} of {demandLines.length}{" "}
rows tied to live resources
</p>
</div>
<div className="rounded-2xl bg-white px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Calculated totals</p>
<p className="mt-1 text-sm text-gray-800">{summary.totalHours.toFixed(1)} h</p>
<p className="mt-1 text-sm text-gray-800">{formatMoney(summary.totalCostCents, baseCurrency)} cost</p>
<p className="mt-1 text-sm text-gray-800">{formatMoney(summary.totalPriceCents, baseCurrency)} price</p>
<p className="mt-1 text-sm text-gray-800">
{formatMoney(summary.totalCostCents, baseCurrency)} cost
</p>
<p className="mt-1 text-sm text-gray-800">
{formatMoney(summary.totalPriceCents, baseCurrency)} price
</p>
</div>
</div>
@@ -827,15 +1126,27 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
</div>
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4">
<button type="button" onClick={step === 0 ? onClose : goBack} className="rounded-xl border border-gray-200 px-4 py-2 text-sm font-medium text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
<button
type="button"
onClick={step === 0 ? onClose : goBack}
className="rounded-xl border border-gray-200 px-4 py-2 text-sm font-medium text-gray-600 transition hover:border-gray-300 hover:text-gray-900"
>
{step === 0 ? "Cancel" : "Back"}
</button>
{step < STEP_LABELS.length - 1 ? (
<button type="button" onClick={goNext} className="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700">
<button
type="button"
onClick={goNext}
className="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700"
>
Next
</button>
) : (
<button type="submit" disabled={createMutation.isPending} className="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-60">
<button
type="submit"
disabled={createMutation.isPending}
className="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{createMutation.isPending ? "Creating..." : "Create Estimate"}
</button>
)}
@@ -2,7 +2,7 @@ import type {
EstimateDemandLineCalculationMetadata,
EstimateDemandLineMetadata,
EstimateDemandLineRateMode,
} from "@capakraken/shared";
} from "@nexus/shared";
interface ResourceRateSnapshotLike {
lcrCents: number;
@@ -33,8 +33,7 @@ export function resolveDemandLineCalculationMetadata(options: {
const resourceSnapshot = options.resourceSnapshot;
const parsedMetadata = parseDemandLineMetadata(options.metadata);
const calculation =
typeof parsedMetadata.calculation === "object" &&
parsedMetadata.calculation !== null
typeof parsedMetadata.calculation === "object" && parsedMetadata.calculation !== null
? parsedMetadata.calculation
: undefined;
const costRateMode =
@@ -7,7 +7,7 @@ import type {
EstimateExportFormat,
EstimateStatus,
EstimateVersionStatus,
} from "@capakraken/shared";
} from "@nexus/shared";
export interface EstimateMetricView {
id: string;
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import dynamic from "next/dynamic";
import type { EstimateExportFormat } from "@capakraken/shared";
import type { EstimateExportFormat } from "@nexus/shared";
import { clsx } from "clsx";
import { useSession } from "next-auth/react";
import type {
@@ -1,13 +1,13 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import type { EstimateDemandLineRateMode } from "@capakraken/shared";
import type { EstimateDemandLineRateMode } from "@nexus/shared";
import {
computeEvenSpread,
getEstimateMonthRange,
rebalanceSpread,
summarizeMonthlySpread,
} from "@capakraken/engine";
} from "@nexus/engine";
import {
buildDemandLineMetadata,
getEffectiveDemandLineValues,
@@ -104,9 +104,7 @@ export function EstimateWorkspaceDraftEditor({
{ staleTime: 15_000 },
);
const workingVersion =
versions.find((version) => version.status === "WORKING") ??
versions[0] ??
null;
versions.find((version) => version.status === "WORKING") ?? versions[0] ?? null;
const [name, setName] = useState(estimate.name);
const [opportunityId, setOpportunityId] = useState(estimate.opportunityId ?? "");
@@ -148,9 +146,7 @@ export function EstimateWorkspaceDraftEditor({
new Map(
(workingVersion?.resourceSnapshots ?? [])
.filter(
(
snapshot,
): snapshot is EstimateResourceSnapshotView & { resourceId: string } =>
(snapshot): snapshot is EstimateResourceSnapshotView & { resourceId: string } =>
typeof snapshot.resourceId === "string" && snapshot.resourceId.length > 0,
)
.map((snapshot) => [snapshot.resourceId, snapshot]),
@@ -196,7 +192,8 @@ export function EstimateWorkspaceDraftEditor({
billRateCents: line.billRateCents,
});
const existingSpread = (line as { monthlySpread?: Record<string, number> }).monthlySpread ?? {};
const existingSpread =
(line as { monthlySpread?: Record<string, number> }).monthlySpread ?? {};
return {
id: line.id,
...(line.scopeItemId ? { scopeItemId: line.scopeItemId } : {}),
@@ -226,7 +223,9 @@ export function EstimateWorkspaceDraftEditor({
const hours = toNumber(line.hours);
const resourceSnapshot =
line.resourceId != null
? resourceMap.get(line.resourceId) ?? snapshotByResourceId.get(line.resourceId) ?? null
? (resourceMap.get(line.resourceId) ??
snapshotByResourceId.get(line.resourceId) ??
null)
: null;
const effectiveValues = getEffectiveDemandLineValues({
resourceSnapshot,
@@ -290,9 +289,7 @@ export function EstimateWorkspaceDraftEditor({
const projectStartDate = estimate.project?.startDate
? new Date(estimate.project.startDate)
: null;
const projectEndDate = estimate.project?.endDate
? new Date(estimate.project.endDate)
: null;
const projectEndDate = estimate.project?.endDate ? new Date(estimate.project.endDate) : null;
const hasProjectDates = projectStartDate !== null && projectEndDate !== null;
function computeLineSpread(line: EditableDemandLine): Record<string, number> {
@@ -317,10 +314,9 @@ export function EstimateWorkspaceDraftEditor({
}).spread;
}
const spreadMonths =
hasProjectDates
? getEstimateMonthRange(projectStartDate, projectEndDate)
: [];
const spreadMonths = hasProjectDates
? getEstimateMonthRange(projectStartDate, projectEndDate)
: [];
const aggregatedSpread = hasProjectDates
? summarizeMonthlySpread(demandLines.map(computeLineSpread))
@@ -396,7 +392,10 @@ export function EstimateWorkspaceDraftEditor({
...new Set(
sanitizedDemandLines
.map((line) => line.resourceId)
.filter((resourceId): resourceId is string => typeof resourceId === "string" && resourceId.length > 0),
.filter(
(resourceId): resourceId is string =>
typeof resourceId === "string" && resourceId.length > 0,
),
),
];
@@ -472,19 +471,36 @@ export function EstimateWorkspaceDraftEditor({
<div className="grid gap-4 md:grid-cols-2">
<label>
<span className="app-label">Estimate name</span>
<input className="app-input" value={name} onChange={(event) => setName(event.target.value)} />
<input
className="app-input"
value={name}
onChange={(event) => setName(event.target.value)}
/>
</label>
<label>
<span className="app-label">Opportunity ID</span>
<input className="app-input" value={opportunityId} onChange={(event) => setOpportunityId(event.target.value)} />
<input
className="app-input"
value={opportunityId}
onChange={(event) => setOpportunityId(event.target.value)}
/>
</label>
<label>
<span className="app-label">Base currency</span>
<input className="app-input" maxLength={3} value={baseCurrency} onChange={(event) => setBaseCurrency(event.target.value.toUpperCase())} />
<input
className="app-input"
maxLength={3}
value={baseCurrency}
onChange={(event) => setBaseCurrency(event.target.value.toUpperCase())}
/>
</label>
<label>
<span className="app-label">Version label</span>
<input className="app-input" value={versionLabel} onChange={(event) => setVersionLabel(event.target.value)} />
<input
className="app-input"
value={versionLabel}
onChange={(event) => setVersionLabel(event.target.value)}
/>
</label>
</div>
@@ -503,15 +519,21 @@ export function EstimateWorkspaceDraftEditor({
<div className="mt-4 space-y-3">
<div className="flex items-center justify-between rounded-2xl bg-gray-50 px-4 py-3">
<span className="text-xs uppercase tracking-wide text-gray-400">Total Hours</span>
<span className="text-sm font-semibold text-gray-900">{summary.totalHours.toFixed(1)}</span>
<span className="text-sm font-semibold text-gray-900">
{summary.totalHours.toFixed(1)}
</span>
</div>
<div className="flex items-center justify-between rounded-2xl bg-gray-50 px-4 py-3">
<span className="text-xs uppercase tracking-wide text-gray-400">Total Cost</span>
<span className="text-sm font-semibold text-gray-900">{formatMoney(summary.totalCostCents, baseCurrency)}</span>
<span className="text-sm font-semibold text-gray-900">
{formatMoney(summary.totalCostCents, baseCurrency)}
</span>
</div>
<div className="flex items-center justify-between rounded-2xl bg-gray-50 px-4 py-3">
<span className="text-xs uppercase tracking-wide text-gray-400">Total Price</span>
<span className="text-sm font-semibold text-gray-900">{formatMoney(summary.totalPriceCents, baseCurrency)}</span>
<span className="text-sm font-semibold text-gray-900">
{formatMoney(summary.totalPriceCents, baseCurrency)}
</span>
</div>
</div>
</aside>
@@ -524,13 +546,24 @@ export function EstimateWorkspaceDraftEditor({
<div className="flex flex-wrap items-center justify-between gap-3 rounded-3xl border border-brand-200 bg-brand-50 px-5 py-4">
<div>
<p className="text-sm font-semibold text-brand-800">Editing working draft</p>
<p className="text-sm text-brand-700">Changes overwrite the current working version and refresh summary metrics on save.</p>
<p className="text-sm text-brand-700">
Changes overwrite the current working version and refresh summary metrics on save.
</p>
</div>
<div className="flex gap-2">
<button type="button" className="rounded-2xl border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700" onClick={onCancel}>
<button
type="button"
className="rounded-2xl border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700"
onClick={onCancel}
>
Cancel
</button>
<button type="button" className="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60" disabled={updateMutation.isPending} onClick={() => void handleSave()}>
<button
type="button"
className="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
disabled={updateMutation.isPending}
onClick={() => void handleSave()}
>
{updateMutation.isPending ? "Saving..." : "Save draft"}
</button>
</div>
@@ -544,10 +577,7 @@ export function EstimateWorkspaceDraftEditor({
{tab === "overview" && renderOverviewEditor()}
{tab === "assumptions" && (
<AssumptionEditor
assumptions={assumptions}
onChange={setAssumptions}
/>
<AssumptionEditor assumptions={assumptions} onChange={setAssumptions} />
)}
{tab === "scope" && (
<ScopeItemEditor
@@ -8,7 +8,7 @@ import {
type ChapterSubtotal,
type ResourceSnapshotDiff,
type ScopeItemDiff,
} from "@capakraken/engine";
} from "@nexus/engine";
import type { EstimateVersionView } from "~/components/estimates/EstimateWorkspace.types.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatMoney } from "~/lib/format.js";
@@ -135,10 +135,15 @@ export function VersionCompare({ versions }: { versions: EstimateVersionView[] }
<div className="space-y-6">
{/* Version selectors */}
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-4 text-base font-semibold text-gray-900">Compare versions <InfoTooltip content="Select two version snapshots to see what changed in demand lines, scope, assumptions, and resource rates between them." /></h3>
<h3 className="mb-4 text-base font-semibold text-gray-900">
Compare versions{" "}
<InfoTooltip content="Select two version snapshots to see what changed in demand lines, scope, assumptions, and resource rates between them." />
</h3>
<div className="flex flex-wrap items-end gap-4">
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Base (A) <InfoTooltip content="The older or reference version to compare from." /></span>
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">
Base (A) <InfoTooltip content="The older or reference version to compare from." />
</span>
<select
value={aId}
onChange={(e) => setAId(e.target.value)}
@@ -155,7 +160,9 @@ export function VersionCompare({ versions }: { versions: EstimateVersionView[] }
<span className="pb-2 text-sm text-gray-400">vs</span>
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Compare (B) <InfoTooltip content="The newer or target version to compare against." /></span>
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">
Compare (B) <InfoTooltip content="The newer or target version to compare against." />
</span>
<select
value={bId}
onChange={(e) => setBId(e.target.value)}
@@ -210,9 +217,21 @@ export function VersionCompare({ versions }: { versions: EstimateVersionView[] }
positive={diff.summary.marginPercentDelta >= 0}
/>
<SummaryCard label="Lines +" value={`+${diff.summary.linesAdded}`} positive />
<SummaryCard label="Lines -" value={`-${diff.summary.linesRemoved}`} positive={diff.summary.linesRemoved === 0} />
<SummaryCard label="Lines ~" value={String(diff.summary.linesChanged)} positive={diff.summary.linesChanged === 0} />
<SummaryCard label="Resources ~" value={String(diff.summary.resourceSnapshotsChanged)} positive={diff.summary.resourceSnapshotsChanged === 0} />
<SummaryCard
label="Lines -"
value={`-${diff.summary.linesRemoved}`}
positive={diff.summary.linesRemoved === 0}
/>
<SummaryCard
label="Lines ~"
value={String(diff.summary.linesChanged)}
positive={diff.summary.linesChanged === 0}
/>
<SummaryCard
label="Resources ~"
value={String(diff.summary.resourceSnapshotsChanged)}
positive={diff.summary.resourceSnapshotsChanged === 0}
/>
</div>
{/* Demand line diffs */}
@@ -242,16 +261,33 @@ export function VersionCompare({ versions }: { versions: EstimateVersionView[] }
</thead>
<tbody>
{filteredDemandDiffs.map((d, i) => (
<tr key={i} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
<tr
key={i}
className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}
>
<td className="py-2 pr-3 font-medium text-gray-900">{d.name}</td>
<td className="px-3 py-2">
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
<span
className={clsx(
"rounded-full px-2 py-0.5 text-xs font-medium",
STATUS_BADGE_STYLES[d.status],
)}
>
{d.status}
</span>
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a?.hours.toFixed(1) ?? "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b?.hours.toFixed(1) ?? "\u2014"}</td>
<td className={clsx("px-3 py-2 text-right tabular-nums", deltaColor(d.hoursDelta))}>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.a?.hours.toFixed(1) ?? "\u2014"}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.b?.hours.toFixed(1) ?? "\u2014"}
</td>
<td
className={clsx(
"px-3 py-2 text-right tabular-nums",
deltaColor(d.hoursDelta),
)}
>
{d.hoursDelta != null ? formatHoursDelta(d.hoursDelta) : "\u2014"}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
@@ -260,8 +296,15 @@ export function VersionCompare({ versions }: { versions: EstimateVersionView[] }
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.b ? formatMoney(d.b.costTotalCents) : "\u2014"}
</td>
<td className={clsx("px-3 py-2 text-right tabular-nums", deltaColor(d.costDelta))}>
{d.costDelta != null ? formatDelta(d.costDelta, (v) => formatMoney(v)) : "\u2014"}
<td
className={clsx(
"px-3 py-2 text-right tabular-nums",
deltaColor(d.costDelta),
)}
>
{d.costDelta != null
? formatDelta(d.costDelta, (v) => formatMoney(v))
: "\u2014"}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.a ? formatMoney(d.a.priceTotalCents) : "\u2014"}
@@ -269,8 +312,15 @@ export function VersionCompare({ versions }: { versions: EstimateVersionView[] }
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.b ? formatMoney(d.b.priceTotalCents) : "\u2014"}
</td>
<td className={clsx("pl-3 py-2 text-right tabular-nums", deltaColor(d.priceDelta))}>
{d.priceDelta != null ? formatDelta(d.priceDelta, (v) => formatMoney(v)) : "\u2014"}
<td
className={clsx(
"pl-3 py-2 text-right tabular-nums",
deltaColor(d.priceDelta),
)}
>
{d.priceDelta != null
? formatDelta(d.priceDelta, (v) => formatMoney(v))
: "\u2014"}
</td>
</tr>
))}
@@ -301,15 +351,27 @@ export function VersionCompare({ versions }: { versions: EstimateVersionView[] }
</thead>
<tbody>
{filteredAssumptionDiffs.map((d) => (
<tr key={d.key} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
<tr
key={d.key}
className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}
>
<td className="py-2 pr-3 font-medium text-gray-900">{d.label}</td>
<td className="px-3 py-2">
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
<span
className={clsx(
"rounded-full px-2 py-0.5 text-xs font-medium",
STATUS_BADGE_STYLES[d.status],
)}
>
{d.status}
</span>
</td>
<td className="px-3 py-2 text-gray-700">{formatAssumptionValue(d.aValue)}</td>
<td className="pl-3 py-2 text-gray-700">{formatAssumptionValue(d.bValue)}</td>
<td className="px-3 py-2 text-gray-700">
{formatAssumptionValue(d.aValue)}
</td>
<td className="pl-3 py-2 text-gray-700">
{formatAssumptionValue(d.bValue)}
</td>
</tr>
))}
</tbody>
@@ -338,16 +400,40 @@ export function VersionCompare({ versions }: { versions: EstimateVersionView[] }
</thead>
<tbody>
{diff.chapterSubtotals.map((ch) => (
<tr key={ch.chapter} className={clsx("border-b border-gray-100", ch.costDelta !== 0 ? "bg-amber-50" : "")}>
<tr
key={ch.chapter}
className={clsx(
"border-b border-gray-100",
ch.costDelta !== 0 ? "bg-amber-50" : "",
)}
>
<td className="py-2 pr-3 font-medium text-gray-900">{ch.chapter}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{ch.hoursA.toFixed(1)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{ch.hoursB.toFixed(1)}</td>
<td className={clsx("px-3 py-2 text-right tabular-nums", deltaColor(ch.hoursDelta))}>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{ch.hoursA.toFixed(1)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{ch.hoursB.toFixed(1)}
</td>
<td
className={clsx(
"px-3 py-2 text-right tabular-nums",
deltaColor(ch.hoursDelta),
)}
>
{formatHoursDelta(ch.hoursDelta)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(ch.costA)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(ch.costB)}</td>
<td className={clsx("pl-3 py-2 text-right tabular-nums", deltaColor(ch.costDelta))}>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{formatMoney(ch.costA)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{formatMoney(ch.costB)}
</td>
<td
className={clsx(
"pl-3 py-2 text-right tabular-nums",
deltaColor(ch.costDelta),
)}
>
{formatDelta(ch.costDelta, (v) => formatMoney(v))}
</td>
</tr>
@@ -363,11 +449,19 @@ export function VersionCompare({ versions }: { versions: EstimateVersionView[] }
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-3 text-base font-semibold text-gray-900">
Scope items
{(diff.summary.scopeItemsAdded > 0 || diff.summary.scopeItemsRemoved > 0 || diff.summary.scopeItemsChanged > 0) && (
{(diff.summary.scopeItemsAdded > 0 ||
diff.summary.scopeItemsRemoved > 0 ||
diff.summary.scopeItemsChanged > 0) && (
<span className="ml-2 text-sm font-normal text-gray-500">
{diff.summary.scopeItemsAdded > 0 && <span className="text-emerald-600">+{diff.summary.scopeItemsAdded}</span>}
{diff.summary.scopeItemsRemoved > 0 && <span className="ml-2 text-red-600">-{diff.summary.scopeItemsRemoved}</span>}
{diff.summary.scopeItemsChanged > 0 && <span className="ml-2 text-amber-600">~{diff.summary.scopeItemsChanged}</span>}
{diff.summary.scopeItemsAdded > 0 && (
<span className="text-emerald-600">+{diff.summary.scopeItemsAdded}</span>
)}
{diff.summary.scopeItemsRemoved > 0 && (
<span className="ml-2 text-red-600">-{diff.summary.scopeItemsRemoved}</span>
)}
{diff.summary.scopeItemsChanged > 0 && (
<span className="ml-2 text-amber-600">~{diff.summary.scopeItemsChanged}</span>
)}
</span>
)}
</h3>
@@ -386,18 +480,34 @@ export function VersionCompare({ versions }: { versions: EstimateVersionView[] }
</thead>
<tbody>
{filteredScopeDiffs.map((d, i) => (
<tr key={i} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
<tr
key={i}
className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}
>
<td className="py-2 pr-3 font-medium text-gray-900">{d.name}</td>
<td className="px-3 py-2 text-gray-600">{d.scopeType}</td>
<td className="px-3 py-2">
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
<span
className={clsx(
"rounded-full px-2 py-0.5 text-xs font-medium",
STATUS_BADGE_STYLES[d.status],
)}
>
{d.status}
</span>
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a?.frameCount ?? "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b?.frameCount ?? "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a?.itemCount ?? "\u2014"}</td>
<td className="pl-3 py-2 text-right tabular-nums text-gray-700">{d.b?.itemCount ?? "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.a?.frameCount ?? "\u2014"}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.b?.frameCount ?? "\u2014"}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.a?.itemCount ?? "\u2014"}
</td>
<td className="pl-3 py-2 text-right tabular-nums text-gray-700">
{d.b?.itemCount ?? "\u2014"}
</td>
</tr>
))}
</tbody>
@@ -426,17 +536,33 @@ export function VersionCompare({ versions }: { versions: EstimateVersionView[] }
</thead>
<tbody>
{filteredResourceDiffs.map((d, i) => (
<tr key={i} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
<tr
key={i}
className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}
>
<td className="py-2 pr-3 font-medium text-gray-900">{d.displayName}</td>
<td className="px-3 py-2">
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
<span
className={clsx(
"rounded-full px-2 py-0.5 text-xs font-medium",
STATUS_BADGE_STYLES[d.status],
)}
>
{d.status}
</span>
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a ? formatMoney(d.a.lcrCents) : "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b ? formatMoney(d.b.lcrCents) : "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a ? formatMoney(d.a.ucrCents) : "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b ? formatMoney(d.b.ucrCents) : "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.a ? formatMoney(d.a.lcrCents) : "\u2014"}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.b ? formatMoney(d.b.lcrCents) : "\u2014"}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.a ? formatMoney(d.a.ucrCents) : "\u2014"}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.b ? formatMoney(d.b.ucrCents) : "\u2014"}
</td>
<td className="px-3 py-2 text-gray-600">{d.a?.location ?? "\u2014"}</td>
<td className="pl-3 py-2 text-gray-600">{d.b?.location ?? "\u2014"}</td>
</tr>
@@ -464,7 +590,12 @@ function SummaryCard({
return (
<div className="rounded-3xl border border-gray-200 bg-gray-50 p-4 text-center shadow-sm">
<p className="text-xs font-medium uppercase tracking-wider text-gray-500">{label}</p>
<p className={clsx("mt-1 text-lg font-semibold tabular-nums", positive ? "text-emerald-700" : "text-red-700")}>
<p
className={clsx(
"mt-1 text-lg font-semibold tabular-nums",
positive ? "text-emerald-700" : "text-red-700",
)}
>
{value}
</p>
</div>
@@ -1,14 +1,9 @@
"use client";
import { useMemo } from "react";
import type { EstimateDemandLineRateMode } from "@capakraken/shared";
import {
computeEvenSpread,
rebalanceSpread,
} from "@capakraken/engine";
import {
getEffectiveDemandLineValues,
} from "~/components/estimates/EstimateWorkspace.calculations.js";
import type { EstimateDemandLineRateMode } from "@nexus/shared";
import { computeEvenSpread, rebalanceSpread } from "@nexus/engine";
import { getEffectiveDemandLineValues } from "~/components/estimates/EstimateWorkspace.calculations.js";
import type { EstimateResourceSnapshotView } from "~/components/estimates/EstimateWorkspace.types.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatMoney } from "~/lib/format.js";
@@ -121,7 +116,10 @@ export function DemandLineEditor({
});
}
function updateDemandLine(index: number, updater: (line: EditableDemandLine) => EditableDemandLine) {
function updateDemandLine(
index: number,
updater: (line: EditableDemandLine) => EditableDemandLine,
) {
onChange((current) =>
current.map((entry, entryIndex) => (entryIndex === index ? updater(entry) : entry)),
);
@@ -296,10 +294,15 @@ export function DemandLineEditor({
linkedResource != null ? toCents(line.billRate) - linkedResource.ucrCents : 0;
return (
<div key={line.id ?? `line-${index}`} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div
key={line.id ?? `line-${index}`}
className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm"
>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-2xl bg-gray-50 px-4 py-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500">Resource link</p>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500">
Resource link
</p>
<p className="mt-1 text-sm text-gray-700">
{linkedResource
? `${linkedResource.displayName} (${("eid" in linkedResource ? linkedResource.eid : (linkedResource as EstimateResourceSnapshotView).sourceEid) ?? "snapshot"})`
@@ -332,7 +335,10 @@ export function DemandLineEditor({
<div className="mb-4 grid gap-4 md:grid-cols-2">
<label>
<span className="app-label">Linked resource <InfoTooltip content="Link to a CapaKraken resource. Live-linked rates refresh automatically; manual overrides are persisted." /></span>
<span className="app-label">
Linked resource{" "}
<InfoTooltip content="Link to a Nexus resource. Live-linked rates refresh automatically; manual overrides are persisted." />
</span>
<select
className="app-input"
value={line.resourceId ?? ""}
@@ -349,43 +355,102 @@ export function DemandLineEditor({
<div className="rounded-2xl bg-gray-50 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Snapshot behavior</p>
<p className="mt-1 text-sm text-gray-700">
Linked resources refresh from live CapaKraken rates when a rate is set to live mode. Manual overrides are persisted on the demand line.
Linked resources refresh from live Nexus rates when a rate is set to live mode.
Manual overrides are persisted on the demand line.
</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<label>
<span className="app-label">Name <InfoTooltip content="Descriptive label for this demand line, e.g. role name or resource name." /></span>
<input className="app-input" value={line.name} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, name: event.target.value }))} />
<span className="app-label">
Name{" "}
<InfoTooltip content="Descriptive label for this demand line, e.g. role name or resource name." />
</span>
<input
className="app-input"
value={line.name}
onChange={(event) =>
updateDemandLine(index, (entry) => ({ ...entry, name: event.target.value }))
}
/>
</label>
<label>
<span className="app-label">Line type <InfoTooltip content="Classification of the demand, typically LABOR. Can also be EXPENSE or SUBCONTRACTOR." /></span>
<input className="app-input" value={line.lineType} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, lineType: event.target.value }))} />
<span className="app-label">
Line type{" "}
<InfoTooltip content="Classification of the demand, typically LABOR. Can also be EXPENSE or SUBCONTRACTOR." />
</span>
<input
className="app-input"
value={line.lineType}
onChange={(event) =>
updateDemandLine(index, (entry) => ({ ...entry, lineType: event.target.value }))
}
/>
</label>
<label>
<span className="app-label">Chapter <InfoTooltip content="Department or cost center. Auto-filled when a resource is linked." /></span>
<input className="app-input" value={line.chapter} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, chapter: event.target.value }))} />
<span className="app-label">
Chapter{" "}
<InfoTooltip content="Department or cost center. Auto-filled when a resource is linked." />
</span>
<input
className="app-input"
value={line.chapter}
onChange={(event) =>
updateDemandLine(index, (entry) => ({ ...entry, chapter: event.target.value }))
}
/>
</label>
<label>
<span className="app-label">Hours <InfoTooltip content="Estimated effort in hours. Cost total = hours x cost rate. Price total = hours x sell rate." /></span>
<input className="app-input" value={line.hours} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, hours: event.target.value }))} />
<span className="app-label">
Hours{" "}
<InfoTooltip content="Estimated effort in hours. Cost total = hours x cost rate. Price total = hours x sell rate." />
</span>
<input
className="app-input"
value={line.hours}
onChange={(event) =>
updateDemandLine(index, (entry) => ({ ...entry, hours: event.target.value }))
}
/>
</label>
<label>
<span className="app-label">Currency <InfoTooltip content="ISO 4217 currency code for this line's rates (e.g. EUR, USD)." /></span>
<input className="app-input" maxLength={3} value={line.currency} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, currency: event.target.value.toUpperCase() }))} />
<span className="app-label">
Currency{" "}
<InfoTooltip content="ISO 4217 currency code for this line's rates (e.g. EUR, USD)." />
</span>
<input
className="app-input"
maxLength={3}
value={line.currency}
onChange={(event) =>
updateDemandLine(index, (entry) => ({
...entry,
currency: event.target.value.toUpperCase(),
}))
}
/>
</label>
<label>
<span className="app-label">Cost rate <InfoTooltip content="Internal hourly cost rate. 'Live' syncs from the resource; 'Manual' allows a custom override. Line cost = hours x cost rate." /></span>
<span className="app-label">
Cost rate{" "}
<InfoTooltip content="Internal hourly cost rate. 'Live' syncs from the resource; 'Manual' allows a custom override. Line cost = hours x cost rate." />
</span>
<div className="space-y-2">
<select
className="app-input"
value={line.costRateMode}
onChange={(event) =>
setDemandLineRateMode(index, "costRateMode", event.target.value as EstimateDemandLineRateMode)
setDemandLineRateMode(
index,
"costRateMode",
event.target.value as EstimateDemandLineRateMode,
)
}
>
{getLineResourceSnapshot(line) && <option value="resource">Use live resource rate</option>}
{getLineResourceSnapshot(line) && (
<option value="resource">Use live resource rate</option>
)}
<option value="manual">Manual override</option>
</select>
<input
@@ -410,16 +475,25 @@ export function DemandLineEditor({
</div>
</label>
<label>
<span className="app-label">Bill rate <InfoTooltip content="Client-facing hourly sell rate. 'Live' syncs from the resource; 'Manual' allows a custom override. Line price = hours x bill rate." /></span>
<span className="app-label">
Bill rate{" "}
<InfoTooltip content="Client-facing hourly sell rate. 'Live' syncs from the resource; 'Manual' allows a custom override. Line price = hours x bill rate." />
</span>
<div className="space-y-2">
<select
className="app-input"
value={line.billRateMode}
onChange={(event) =>
setDemandLineRateMode(index, "billRateMode", event.target.value as EstimateDemandLineRateMode)
setDemandLineRateMode(
index,
"billRateMode",
event.target.value as EstimateDemandLineRateMode,
)
}
>
{getLineResourceSnapshot(line) && <option value="resource">Use live resource rate</option>}
{getLineResourceSnapshot(line) && (
<option value="resource">Use live resource rate</option>
)}
<option value="manual">Manual override</option>
</select>
<input
@@ -444,11 +518,15 @@ export function DemandLineEditor({
</div>
</label>
<div className="rounded-2xl bg-gray-50 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Cost total <InfoTooltip content="hours x cost rate, stored in cents." /></p>
<p className="text-xs uppercase tracking-wide text-gray-400">
Cost total <InfoTooltip content="hours x cost rate, stored in cents." />
</p>
<p className="mt-1 text-sm font-semibold text-gray-900">
{formatMoney(effectiveValues.costTotalCents, effectiveValues.currency)}
</p>
<p className="mt-3 text-xs uppercase tracking-wide text-gray-400">Price total <InfoTooltip content="hours x sell rate, stored in cents." /></p>
<p className="mt-3 text-xs uppercase tracking-wide text-gray-400">
Price total <InfoTooltip content="hours x sell rate, stored in cents." />
</p>
<p className="mt-1 text-sm font-semibold text-gray-900">
{formatMoney(effectiveValues.priceTotalCents, effectiveValues.currency)}
</p>
@@ -459,71 +537,99 @@ export function DemandLineEditor({
<button
type="button"
className="flex items-center gap-1.5 text-xs font-medium text-gray-600"
onClick={() => updateDemandLine(index, (entry) => ({ ...entry, spreadExpanded: !entry.spreadExpanded }))}
onClick={() =>
updateDemandLine(index, (entry) => ({
...entry,
spreadExpanded: !entry.spreadExpanded,
}))
}
>
<span className={`inline-block transition-transform ${line.spreadExpanded ? "rotate-90" : ""}`}>&#9654;</span>
<span
className={`inline-block transition-transform ${line.spreadExpanded ? "rotate-90" : ""}`}
>
&#9654;
</span>
Monthly phasing ({spreadMonths.length} months)
</button>
{line.spreadExpanded && (() => {
const lineSpread = computeLineSpread(line);
return (
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="px-2 py-1.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-400">Month</th>
<th className="px-2 py-1.5 text-right text-xs font-semibold uppercase tracking-wide text-gray-400">Hours</th>
<th className="px-2 py-1.5 text-center text-xs font-semibold uppercase tracking-wide text-gray-400">Lock</th>
</tr>
</thead>
<tbody>
{spreadMonths.map((monthKey) => {
const isLocked = monthKey in line.lockedMonths;
const value = lineSpread[monthKey] ?? 0;
return (
<tr key={monthKey} className="border-b border-gray-100">
<td className="px-2 py-1.5 text-gray-700">{monthKey}</td>
<td className="px-2 py-1.5 text-right">
{isLocked ? (
<input
className="w-20 rounded border border-amber-300 bg-amber-50 px-2 py-1 text-right text-sm text-gray-900"
value={line.lockedMonths[monthKey]}
onChange={(event) => setLockedMonthValue(index, monthKey, event.target.value)}
/>
) : (
<span className="text-gray-700">{value.toFixed(1)}</span>
)}
</td>
<td className="px-2 py-1.5 text-center">
<button
type="button"
className={`rounded px-2 py-0.5 text-xs font-medium ${isLocked ? "bg-amber-100 text-amber-700" : "bg-gray-100 text-gray-500"}`}
onClick={() => toggleMonthLock(index, monthKey, value)}
>
{isLocked ? "Locked" : "Auto"}
</button>
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t border-gray-300">
<td className="px-2 py-1.5 text-xs font-semibold uppercase text-gray-500">Total</td>
<td className="px-2 py-1.5 text-right text-sm font-semibold text-gray-900">
{Object.values(lineSpread).reduce((a, b) => a + b, 0).toFixed(1)}
</td>
<td />
</tr>
</tfoot>
</table>
</div>
);
})()}
{line.spreadExpanded &&
(() => {
const lineSpread = computeLineSpread(line);
return (
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="px-2 py-1.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-400">
Month
</th>
<th className="px-2 py-1.5 text-right text-xs font-semibold uppercase tracking-wide text-gray-400">
Hours
</th>
<th className="px-2 py-1.5 text-center text-xs font-semibold uppercase tracking-wide text-gray-400">
Lock
</th>
</tr>
</thead>
<tbody>
{spreadMonths.map((monthKey) => {
const isLocked = monthKey in line.lockedMonths;
const value = lineSpread[monthKey] ?? 0;
return (
<tr key={monthKey} className="border-b border-gray-100">
<td className="px-2 py-1.5 text-gray-700">{monthKey}</td>
<td className="px-2 py-1.5 text-right">
{isLocked ? (
<input
className="w-20 rounded border border-amber-300 bg-amber-50 px-2 py-1 text-right text-sm text-gray-900"
value={line.lockedMonths[monthKey]}
onChange={(event) =>
setLockedMonthValue(index, monthKey, event.target.value)
}
/>
) : (
<span className="text-gray-700">{value.toFixed(1)}</span>
)}
</td>
<td className="px-2 py-1.5 text-center">
<button
type="button"
className={`rounded px-2 py-0.5 text-xs font-medium ${isLocked ? "bg-amber-100 text-amber-700" : "bg-gray-100 text-gray-500"}`}
onClick={() => toggleMonthLock(index, monthKey, value)}
>
{isLocked ? "Locked" : "Auto"}
</button>
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t border-gray-300">
<td className="px-2 py-1.5 text-xs font-semibold uppercase text-gray-500">
Total
</td>
<td className="px-2 py-1.5 text-right text-sm font-semibold text-gray-900">
{Object.values(lineSpread)
.reduce((a, b) => a + b, 0)
.toFixed(1)}
</td>
<td />
</tr>
</tfoot>
</table>
</div>
);
})()}
</div>
)}
<div className="mt-4 flex justify-end">
<button type="button" className="text-sm font-medium text-rose-600" onClick={() => onChange((current) => current.filter((_, entryIndex) => entryIndex !== index))}>
<button
type="button"
className="text-sm font-medium text-rose-600"
onClick={() =>
onChange((current) => current.filter((_, entryIndex) => entryIndex !== index))
}
>
Remove demand line
</button>
</div>
@@ -531,7 +637,11 @@ export function DemandLineEditor({
);
})}
<button type="button" className="rounded-2xl border border-dashed border-gray-300 px-4 py-3 text-sm font-medium text-gray-600" onClick={() => onChange((current) => [...current, makeDemandLine()])}>
<button
type="button"
className="rounded-2xl border border-dashed border-gray-300 px-4 py-3 text-sm font-medium text-gray-600"
onClick={() => onChange((current) => [...current, makeDemandLine()])}
>
Add demand line
</button>
@@ -542,23 +652,33 @@ export function DemandLineEditor({
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="px-2 py-1.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-400">Month</th>
<th className="px-2 py-1.5 text-right text-xs font-semibold uppercase tracking-wide text-gray-400">Total hours</th>
<th className="px-2 py-1.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-400">
Month
</th>
<th className="px-2 py-1.5 text-right text-xs font-semibold uppercase tracking-wide text-gray-400">
Total hours
</th>
</tr>
</thead>
<tbody>
{spreadMonths.map((monthKey) => (
<tr key={monthKey} className="border-b border-gray-100">
<td className="px-2 py-1.5 text-gray-700">{monthKey}</td>
<td className="px-2 py-1.5 text-right text-gray-900">{(aggregatedSpread[monthKey] ?? 0).toFixed(1)}</td>
<td className="px-2 py-1.5 text-right text-gray-900">
{(aggregatedSpread[monthKey] ?? 0).toFixed(1)}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t border-gray-300">
<td className="px-2 py-1.5 text-xs font-semibold uppercase text-gray-500">Grand total</td>
<td className="px-2 py-1.5 text-xs font-semibold uppercase text-gray-500">
Grand total
</td>
<td className="px-2 py-1.5 text-right text-sm font-semibold text-gray-900">
{Object.values(aggregatedSpread).reduce((a, b) => a + b, 0).toFixed(1)}
{Object.values(aggregatedSpread)
.reduce((a, b) => a + b, 0)
.toFixed(1)}
</td>
</tr>
</tfoot>
@@ -1,9 +1,6 @@
"use client";
import {
type EstimateExportArtifactPayload,
EstimateExportFormat,
} from "@capakraken/shared";
import { type EstimateExportArtifactPayload, EstimateExportFormat } from "@nexus/shared";
import type {
EstimateExportView,
EstimateVersionView,
@@ -101,9 +98,13 @@ export function ExportsTab({
<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 flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">Export delivery <InfoTooltip content="Generate downloadable files from the current estimate version. Each format includes demand lines, scope, and financial summaries." /></h2>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">
Export delivery{" "}
<InfoTooltip content="Generate downloadable files from the current estimate version. Each format includes demand lines, scope, and financial summaries." />
</h2>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
Generate format-specific artifacts from the current version and download them directly from the stored serializer payload.
Generate format-specific artifacts from the current version and download them directly
from the stored serializer payload.
</p>
</div>
{latestVersion && canEdit && (
@@ -126,11 +127,16 @@ export function ExportsTab({
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm">
<div className="border-b border-gray-100 dark:border-gray-700/50 px-6 py-4">
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">Generated exports <InfoTooltip content="Previously generated export artifacts. XLSX/CSV contain tabular data; JSON is machine-readable; SAP/MMP are ERP integration formats." /></h2>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">
Generated exports{" "}
<InfoTooltip content="Previously generated export artifacts. XLSX/CSV contain tabular data; JSON is machine-readable; SAP/MMP are ERP integration formats." />
</h2>
</div>
{exports.length === 0 ? (
<div className="px-6 py-8">
<p className="text-sm text-gray-400">No exports have been generated for the current version yet.</p>
<p className="text-sm text-gray-400">
No exports have been generated for the current version yet.
</p>
</div>
) : (
<div className="divide-y divide-gray-100 dark:divide-gray-700/50">
@@ -144,7 +150,9 @@ export function ExportsTab({
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{estimateExport.fileName}</p>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{estimateExport.fileName}
</p>
<span className="rounded-full bg-gray-100 dark:bg-gray-800 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">
{estimateExport.format}
</span>
@@ -195,7 +203,8 @@ export function ExportsTab({
</div>
) : (
<p className="mt-3 text-xs text-amber-700 dark:text-amber-300">
Legacy export record detected. Regenerate it to get downloadable serializer output.
Legacy export record detected. Regenerate it to get downloadable
serializer output.
</p>
)}
</div>
@@ -1,7 +1,7 @@
"use client";
import { clsx } from "clsx";
import type { EstimateStatus, EstimateVersionStatus } from "@capakraken/shared";
import type { EstimateStatus, EstimateVersionStatus } from "@nexus/shared";
import type {
EstimateMetricView,
EstimateVersionView,
@@ -1,7 +1,7 @@
"use client";
import { EstimateVersionStatus } from "@capakraken/shared";
import { summarizeMonthlySpread } from "@capakraken/engine";
import { EstimateVersionStatus } from "@nexus/shared";
import { summarizeMonthlySpread } from "@nexus/engine";
import { clsx } from "clsx";
import {
getEffectiveDemandLineValues,
@@ -24,7 +24,13 @@ function EmptyState({ children }: { children: React.ReactNode }) {
);
}
export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspaceView; canEdit: boolean }) {
export function StaffingTab({
estimate,
canEdit,
}: {
estimate: EstimateWorkspaceView;
canEdit: boolean;
}) {
const versions = estimate.versions as EstimateVersionView[];
const latestVersion = versions[0] ?? null;
const demandLines = latestVersion?.demandLines ?? [];
@@ -35,14 +41,8 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
<div className="space-y-3">
{isWorking && (
<>
<ApplyEffortRules
estimateId={estimate.id}
canEdit={canEdit}
/>
<ApplyExperienceMultipliers
estimateId={estimate.id}
canEdit={canEdit}
/>
<ApplyEffortRules estimateId={estimate.id} canEdit={canEdit} />
<ApplyExperienceMultipliers estimateId={estimate.id} canEdit={canEdit} />
</>
)}
@@ -51,7 +51,7 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
)}
{demandLines.map((line) => {
const linkedSnapshot = line.resourceId
? snapshots.find((snapshot) => snapshot.resourceId === line.resourceId) ?? null
? (snapshots.find((snapshot) => snapshot.resourceId === line.resourceId) ?? null)
: null;
const calculation = resolveDemandLineCalculationMetadata({
resourceSnapshot: linkedSnapshot,
@@ -71,10 +71,15 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
});
return (
<div key={line.id} className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<div
key={line.id}
className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm"
>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">{line.name}</h3>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{line.name}
</h3>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500 dark:text-gray-400">
<span>{line.lineType}</span>
{line.chapter && <span>{line.chapter}</span>}
@@ -103,15 +108,24 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
</div>
</div>
<div className="text-right">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">{line.hours.toFixed(1)} h</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">{effectiveValues.currency}</p>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{line.hours.toFixed(1)} h
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{effectiveValues.currency}
</p>
</div>
</div>
<div className="mt-5 grid gap-3 md:grid-cols-4">
<div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Cost rate <InfoTooltip content="Internal hourly cost rate. Can be synced from the live resource or manually overridden." /></p>
<p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">{formatMoney(line.costRateCents, line.currency)}</p>
<p className="text-xs uppercase tracking-wide text-gray-400">
Cost rate{" "}
<InfoTooltip content="Internal hourly cost rate. Can be synced from the live resource or manually overridden." />
</p>
<p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
{formatMoney(line.costRateCents, line.currency)}
</p>
{linkedSnapshot && calculation.costRateMode === "manual" && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Live snapshot {formatMoney(linkedSnapshot.lcrCents, linkedSnapshot.currency)}
@@ -119,8 +133,13 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
)}
</div>
<div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Sell rate <InfoTooltip content="Client-facing hourly rate. Can be synced from the live resource or manually overridden." /></p>
<p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">{formatMoney(line.billRateCents, line.currency)}</p>
<p className="text-xs uppercase tracking-wide text-gray-400">
Sell rate{" "}
<InfoTooltip content="Client-facing hourly rate. Can be synced from the live resource or manually overridden." />
</p>
<p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
{formatMoney(line.billRateCents, line.currency)}
</p>
{linkedSnapshot && calculation.billRateMode === "manual" && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Live snapshot {formatMoney(linkedSnapshot.ucrCents, linkedSnapshot.currency)}
@@ -128,25 +147,42 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
)}
</div>
<div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Cost total <InfoTooltip content="Line cost total = hours x cost rate. Stored in cents." /></p>
<p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">{formatMoney(effectiveValues.costTotalCents, effectiveValues.currency)}</p>
<p className="text-xs uppercase tracking-wide text-gray-400">
Cost total{" "}
<InfoTooltip content="Line cost total = hours x cost rate. Stored in cents." />
</p>
<p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
{formatMoney(effectiveValues.costTotalCents, effectiveValues.currency)}
</p>
</div>
<div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Price total <InfoTooltip content="Line price total = hours x sell rate. This is the client-facing revenue for this line." /></p>
<p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">{formatMoney(effectiveValues.priceTotalCents, effectiveValues.currency)}</p>
<p className="text-xs uppercase tracking-wide text-gray-400">
Price total{" "}
<InfoTooltip content="Line price total = hours x sell rate. This is the client-facing revenue for this line." />
</p>
<p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
{formatMoney(effectiveValues.priceTotalCents, effectiveValues.currency)}
</p>
</div>
</div>
{line.monthlySpread && Object.keys(line.monthlySpread).length > 0 && (
<div className="mt-4">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-400">Monthly phasing</p>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-400">
Monthly phasing
</p>
<div className="flex flex-wrap gap-2">
{Object.entries(line.monthlySpread)
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, hours]) => (
<div key={month} className="rounded-xl bg-gray-50 dark:bg-gray-900 px-3 py-1.5 text-xs">
<div
key={month}
className="rounded-xl bg-gray-50 dark:bg-gray-900 px-3 py-1.5 text-xs"
>
<span className="text-gray-500 dark:text-gray-400">{month}</span>
<span className="ml-1.5 font-medium text-gray-900 dark:text-gray-100">{hours.toFixed(1)} h</span>
<span className="ml-1.5 font-medium text-gray-900 dark:text-gray-100">
{hours.toFixed(1)} h
</span>
</div>
))}
</div>
@@ -159,24 +195,39 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
{(() => {
const spreads = demandLines
.map((line) => line.monthlySpread)
.filter((spread): spread is Record<string, number> => spread != null && Object.keys(spread).length > 0);
.filter(
(spread): spread is Record<string, number> =>
spread != null && Object.keys(spread).length > 0,
);
if (spreads.length === 0) return null;
const aggregated = summarizeMonthlySpread(spreads);
const months = Object.keys(aggregated).sort();
if (months.length === 0) return null;
return (
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<p className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">Aggregated monthly phasing <InfoTooltip content="Sum of hours across all demand lines per month, based on the project date range." /></p>
<p className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">
Aggregated monthly phasing{" "}
<InfoTooltip content="Sum of hours across all demand lines per month, based on the project date range." />
</p>
<div className="flex flex-wrap gap-2">
{months.map((month) => (
<div key={month} className="rounded-xl bg-gray-50 dark:bg-gray-900 px-3 py-2 text-sm">
<div
key={month}
className="rounded-xl bg-gray-50 dark:bg-gray-900 px-3 py-2 text-sm"
>
<span className="text-gray-500 dark:text-gray-400">{month}</span>
<span className="ml-2 font-semibold text-gray-900 dark:text-gray-100">{(aggregated[month] ?? 0).toFixed(1)} h</span>
<span className="ml-2 font-semibold text-gray-900 dark:text-gray-100">
{(aggregated[month] ?? 0).toFixed(1)} h
</span>
</div>
))}
</div>
<div className="mt-3 text-right text-sm font-semibold text-gray-700 dark:text-gray-300">
Total: {Object.values(aggregated).reduce((a, b) => a + b, 0).toFixed(1)} h
Total:{" "}
{Object.values(aggregated)
.reduce((a, b) => a + b, 0)
.toFixed(1)}{" "}
h
</div>
</div>
);
@@ -1,6 +1,6 @@
"use client";
import { EstimateVersionStatus } from "@capakraken/shared";
import { EstimateVersionStatus } from "@nexus/shared";
import { clsx } from "clsx";
import { VersionCompare } from "~/components/estimates/VersionCompare.js";
import type {
@@ -80,17 +80,31 @@ export function VersionsTab({
<InfoTooltip content="Each version captures a full copy of scope, assumptions, demand lines, and metrics. WORKING versions can be edited; SUBMITTED and APPROVED versions are locked." />
</div>
{versions.map((version) => (
<div key={version.id} className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<div
key={version.id}
className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm"
>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<span className="text-lg font-semibold text-gray-900 dark:text-gray-100">v{version.versionNumber}</span>
<span className={clsx("rounded-full px-2.5 py-1 text-xs font-medium", VERSION_STYLES[version.status])}>
<span className="text-lg font-semibold text-gray-900 dark:text-gray-100">
v{version.versionNumber}
</span>
<span
className={clsx(
"rounded-full px-2.5 py-1 text-xs font-medium",
VERSION_STYLES[version.status],
)}
>
{version.status}
</span>
</div>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">{version.label ?? "Unlabeled version"}</p>
{version.notes && <p className="mt-2 text-sm text-gray-500 dark:text-gray-400">{version.notes}</p>}
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
{version.label ?? "Unlabeled version"}
</p>
{version.notes && (
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">{version.notes}</p>
)}
</div>
<div className="text-right text-sm text-gray-500 dark:text-gray-400">
<p>Updated {formatDateLong(version.updatedAt)}</p>
@@ -129,7 +143,9 @@ export function VersionsTab({
<button
type="button"
onClick={() => onCreateRevision(version.id)}
disabled={isSubmitting || isApproving || isCreatingRevision || isCreatingPlanningHandoff}
disabled={
isSubmitting || isApproving || isCreatingRevision || isCreatingPlanningHandoff
}
className="rounded-2xl border border-brand-200 bg-white dark:bg-gray-800 px-3 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-50 disabled:cursor-not-allowed disabled:opacity-60"
>
{isCreatingRevision ? "Creating revision..." : "Create working revision"}
@@ -170,7 +186,9 @@ export function VersionsTab({
{version.metrics.map((metric) => (
<div key={metric.id} className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">{metric.label}</p>
<p className="mt-1 text-sm font-semibold text-gray-900 dark:text-gray-100">{formatMetricValue(metric)}</p>
<p className="mt-1 text-sm font-semibold text-gray-900 dark:text-gray-100">
{formatMetricValue(metric)}
</p>
</div>
))}
</div>
+1 -1
View File
@@ -55,7 +55,7 @@ import {
CloseIcon,
} from "./nav-icons.js";
const SIDEBAR_COLLAPSED_KEY = "capakraken_sidebar_collapsed";
const SIDEBAR_COLLAPSED_KEY = "nexus_sidebar_collapsed";
type NavItem = { href: string; label: string; icon: ReactNode; roles: string[] };
type NavSection = { label: string; collapsed?: boolean; items: NavItem[] };
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
const DISMISS_KEY = "capakraken_pwa_dismiss";
const DISMISS_KEY = "nexus_pwa_dismiss";
const DISMISS_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
interface BeforeInstallPromptEvent extends Event {
@@ -69,13 +69,16 @@ export function InstallPrompt() {
<div className="flex items-center gap-3 rounded-2xl border border-brand-200/60 bg-white/95 px-4 py-3 shadow-lg backdrop-blur-xl dark:border-brand-900/40 dark:bg-slate-900/95">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-600 text-white shadow-md shadow-brand-600/25">
<svg className="h-5 w-5" 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>
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900 dark:text-gray-50">
Install CapaKraken
</p>
<p className="text-sm font-medium text-gray-900 dark:text-gray-50">Install Nexus</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
Add to home screen for quick access
</p>
@@ -9,7 +9,7 @@ import { useEffect } from "react";
export function ThemeProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
try {
const raw = localStorage.getItem("capakraken_theme");
const raw = localStorage.getItem("nexus_theme");
if (!raw) return;
const prefs = JSON.parse(raw) as { mode?: string; accent?: string };
const html = document.documentElement;
@@ -43,7 +43,7 @@ export function MobileSummaryClient() {
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
{/* Top nav bar */}
<div className="sticky top-0 z-10 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 px-4 py-3 flex items-center justify-between">
<h1 className="text-base font-semibold text-gray-900 dark:text-gray-100">CapaKraken</h1>
<h1 className="text-base font-semibold text-gray-900 dark:text-gray-100">Nexus</h1>
<Link href="/dashboard" className="text-xs font-medium text-brand-600 dark:text-brand-400">
Full Dashboard
</Link>
@@ -7,7 +7,7 @@ import { formatCents, formatDate } from "~/lib/format.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js";
import { AllocationModal } from "~/components/allocations/AllocationModal.js";
import type { AllocationWithDetails } from "@capakraken/shared";
import type { AllocationWithDetails } from "@nexus/shared";
import type { OpenDemandAssignment } from "~/components/timeline/TimelineProjectPanel.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { trpc } from "~/lib/trpc/client.js";
@@ -28,7 +28,12 @@ interface DemandRow {
unfilledHeadcount: number;
status: string;
project?: { id: string; name: string; shortCode: string };
assignments?: Array<{ dailyCostCents: number; startDate: Date | string; endDate: Date | string; status: string }>;
assignments?: Array<{
dailyCostCents: number;
startDate: Date | string;
endDate: Date | string;
status: string;
}>;
}
interface ProjectDemandsTableProps {
@@ -80,10 +85,12 @@ export function ProjectDemandsTable({ demands, project }: ProjectDemandsTablePro
<thead className="bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Role <InfoTooltip content="The role or skill profile required for this demand position." />
Role{" "}
<InfoTooltip content="The role or skill profile required for this demand position." />
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Period <InfoTooltip content="Time range during which this role is needed on the project." />
Period{" "}
<InfoTooltip content="Time range during which this role is needed on the project." />
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<span className="inline-flex items-center justify-end gap-0.5">
@@ -92,16 +99,19 @@ export function ProjectDemandsTable({ demands, project }: ProjectDemandsTablePro
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<span className="inline-flex items-center justify-end gap-0.5">
Hours/Day <InfoTooltip content="Planned working hours per day for this demand position." />
Hours/Day{" "}
<InfoTooltip content="Planned working hours per day for this demand position." />
</span>
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<span className="inline-flex items-center justify-end gap-0.5">
Budget <InfoTooltip content="Allocated role budget vs. booked cost from assignments." />
Budget{" "}
<InfoTooltip content="Allocated role budget vs. booked cost from assignments." />
</span>
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status <InfoTooltip content="PROPOSED = requested, CONFIRMED = approved, ACTIVE = ongoing, COMPLETED = filled, CANCELLED = removed." />
Status{" "}
<InfoTooltip content="PROPOSED = requested, CONFIRMED = approved, ACTIVE = ongoing, COMPLETED = filled, CANCELLED = removed." />
</th>
{canEdit && (
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
@@ -112,9 +122,15 @@ export function ProjectDemandsTable({ demands, project }: ProjectDemandsTablePro
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{allDemands.map((demand) => {
const isFillable = demand.status !== "CANCELLED" && demand.status !== "COMPLETED" && demand.unfilledHeadcount > 0;
const isFillable =
demand.status !== "CANCELLED" &&
demand.status !== "COMPLETED" &&
demand.unfilledHeadcount > 0;
return (
<tr key={demand.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors">
<tr
key={demand.id}
className="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors"
>
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
{demand.roleEntity?.name ?? demand.role ?? "Unassigned"}
</td>
@@ -125,38 +141,51 @@ export function ProjectDemandsTable({ demands, project }: ProjectDemandsTablePro
<span className="font-medium">{demand.unfilledHeadcount}</span>
<span className="text-gray-400"> / {demand.requestedHeadcount}</span>
</td>
<td className="px-4 py-3 text-sm text-right text-gray-900 dark:text-gray-100">{demand.hoursPerDay}h</td>
<td className="px-4 py-3 text-sm text-right text-gray-900 dark:text-gray-100">
{demand.hoursPerDay}h
</td>
<td className="px-4 py-3 text-right text-sm">
{demand.budgetCents && demand.budgetCents > 0 ? (() => {
// Calculate booked cost from assignments
const bookedCents = (demand.assignments ?? [])
.filter((a) => a.status !== "CANCELLED")
.reduce((sum, a) => {
const s = new Date(a.startDate);
const e = new Date(a.endDate);
let days = 0;
const cur = new Date(s);
while (cur <= e) { if (cur.getDay() !== 0 && cur.getDay() !== 6) days++; cur.setDate(cur.getDate() + 1); }
return sum + a.dailyCostCents * days;
}, 0);
const remainCents = demand.budgetCents! - bookedCents;
return (
<div>
<div className="text-gray-900 dark:text-gray-100">
{formatCents(demand.budgetCents!)} EUR
{demand.budgetCents && demand.budgetCents > 0 ? (
(() => {
// Calculate booked cost from assignments
const bookedCents = (demand.assignments ?? [])
.filter((a) => a.status !== "CANCELLED")
.reduce((sum, a) => {
const s = new Date(a.startDate);
const e = new Date(a.endDate);
let days = 0;
const cur = new Date(s);
while (cur <= e) {
if (cur.getDay() !== 0 && cur.getDay() !== 6) days++;
cur.setDate(cur.getDate() + 1);
}
return sum + a.dailyCostCents * days;
}, 0);
const remainCents = demand.budgetCents! - bookedCents;
return (
<div>
<div className="text-gray-900 dark:text-gray-100">
{formatCents(demand.budgetCents!)} EUR
</div>
<div
className={`text-xs ${remainCents < 0 ? "text-red-500" : "text-gray-400"}`}
>
{bookedCents > 0 ? `${formatCents(bookedCents)} booked` : ""}
{remainCents < 0
? ` (${formatCents(Math.abs(remainCents))} over)`
: ""}
</div>
</div>
<div className={`text-xs ${remainCents < 0 ? "text-red-500" : "text-gray-400"}`}>
{bookedCents > 0 ? `${formatCents(bookedCents)} booked` : ""}
{remainCents < 0 ? ` (${formatCents(Math.abs(remainCents))} over)` : ""}
</div>
</div>
);
})() : (
);
})()
) : (
<span className="text-gray-400 text-xs"></span>
)}
</td>
<td className="px-4 py-3">
<span className={`inline-block px-2 py-0.5 text-xs rounded-full ${ALLOC_STATUS_COLORS[demand.status] ?? "bg-gray-100 text-gray-600"}`}>
<span
className={`inline-block px-2 py-0.5 text-xs rounded-full ${ALLOC_STATUS_COLORS[demand.status] ?? "bg-gray-100 text-gray-600"}`}
>
{demand.status}
</span>
</td>
@@ -173,23 +202,35 @@ export function ProjectDemandsTable({ demands, project }: ProjectDemandsTablePro
{isFillable && (
<button
type="button"
onClick={() => setFillTarget({
id: demand.id,
projectId: demand.projectId,
roleId: demand.roleId,
role: demand.role,
headcount: demand.headcount,
...(demand.budgetCents ? { budgetCents: demand.budgetCents } : {}),
startDate: new Date(demand.startDate),
endDate: new Date(demand.endDate),
hoursPerDay: demand.hoursPerDay,
roleEntity: demand.roleEntity ?? null,
project,
})}
onClick={() =>
setFillTarget({
id: demand.id,
projectId: demand.projectId,
roleId: demand.roleId,
role: demand.role,
headcount: demand.headcount,
...(demand.budgetCents ? { budgetCents: demand.budgetCents } : {}),
startDate: new Date(demand.startDate),
endDate: new Date(demand.endDate),
hoursPerDay: demand.hoursPerDay,
roleEntity: demand.roleEntity ?? null,
project,
})
}
className="inline-flex items-center gap-1 text-xs font-medium text-brand-600 hover:text-brand-800 dark:text-brand-400 dark:hover:text-brand-200"
>
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
<svg
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
/>
</svg>
Assign
</button>
@@ -208,7 +249,10 @@ export function ProjectDemandsTable({ demands, project }: ProjectDemandsTablePro
{canEdit && (
<p>
Create staffing entries via{" "}
<Link href="/allocations" className="text-brand-600 hover:underline dark:text-brand-400">
<Link
href="/allocations"
className="text-brand-600 hover:underline dark:text-brand-400"
>
Allocations New Planning Entry
</Link>
.
@@ -221,16 +265,28 @@ export function ProjectDemandsTable({ demands, project }: ProjectDemandsTablePro
{fillTarget && (
<FillOpenDemandModal
allocation={fillTarget as never}
onClose={() => { setFillTarget(null); handleMutationSuccess(); }}
onSuccess={() => { setFillTarget(null); handleMutationSuccess(); }}
onClose={() => {
setFillTarget(null);
handleMutationSuccess();
}}
onSuccess={() => {
setFillTarget(null);
handleMutationSuccess();
}}
/>
)}
{editTarget && (
<AllocationModal
allocation={editTarget}
onClose={() => { setEditTarget(null); handleMutationSuccess(); }}
onSuccess={() => { setEditTarget(null); handleMutationSuccess(); }}
onClose={() => {
setEditTarget(null);
handleMutationSuccess();
}}
onSuccess={() => {
setEditTarget(null);
handleMutationSuccess();
}}
/>
)}
</>
@@ -2,7 +2,7 @@
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import type { Project } from "@capakraken/shared";
import type { Project } from "@nexus/shared";
import { ProjectModal } from "./ProjectModal.js";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { usePermissions } from "~/hooks/usePermissions.js";
@@ -19,8 +19,13 @@ export function ProjectDetailActions({ project }: ProjectDetailActionsProps) {
const isAdmin = role === "ADMIN";
const router = useRouter();
const { data: favoriteIds } = trpc.user.getFavoriteProjectIds.useQuery(undefined, { staleTime: 30_000 });
const isFavorite = useMemo(() => (favoriteIds ?? []).includes(project.id), [favoriteIds, project.id]);
const { data: favoriteIds } = trpc.user.getFavoriteProjectIds.useQuery(undefined, {
staleTime: 30_000,
});
const isFavorite = useMemo(
() => (favoriteIds ?? []).includes(project.id),
[favoriteIds, project.id],
);
const utils = trpc.useUtils();
const toggleFav = trpc.user.toggleFavoriteProject.useMutation({
onSuccess: () => void utils.user.getFavoriteProjectIds.invalidate(),
@@ -50,7 +55,12 @@ export function ProjectDetailActions({ project }: ProjectDetailActionsProps) {
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 transition dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
Edit
</button>
@@ -64,7 +74,12 @@ export function ProjectDetailActions({ project }: ProjectDetailActionsProps) {
className="inline-flex items-center gap-2 rounded-lg border border-red-300 bg-white px-3 py-2 text-sm font-medium text-red-600 shadow-sm hover:bg-red-50 transition disabled:opacity-50 dark:border-red-700 dark:bg-gray-800 dark:text-red-400 dark:hover:bg-red-900/20"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
Delete
</button>
@@ -1,9 +1,9 @@
"use client";
import { useState } from "react";
import type { OrderType, AllocationType, ProjectStatus } from "@capakraken/shared";
import type { OrderType, AllocationType, ProjectStatus } from "@nexus/shared";
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
import type { Project } from "@capakraken/shared";
import type { Project } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { DateInput } from "~/components/ui/DateInput.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
@@ -1,7 +1,7 @@
"use client";
import { useState, useCallback, useMemo } from "react";
import { toIsoDate } from "@capakraken/shared";
import { toIsoDate } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { DateInput } from "~/components/ui/DateInput.js";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
@@ -268,8 +268,7 @@ export function ScenarioPlanner({ projectId, baseline, resources, roles }: Scena
</h2>
<p className="text-xs text-gray-500 mb-4">
{baseline.assignments.length} assignment(s) &middot;{" "}
{formatMoney(baseline.totalCostCents)} total &middot;{" "}
{baseline.totalHours.toFixed(0)}h
{formatMoney(baseline.totalCostCents)} total &middot; {baseline.totalHours.toFixed(0)}h
</p>
{baseline.assignments.length === 0 ? (
@@ -297,8 +296,8 @@ export function ScenarioPlanner({ projectId, baseline, resources, roles }: Scena
)}
</div>
<div className="text-xs text-gray-500 mt-0.5">
{formatDate(a.startDate)} - {formatDate(a.endDate)} &middot;{" "}
{a.hoursPerDay}h/d &middot; {a.workingDays} days
{formatDate(a.startDate)} - {formatDate(a.endDate)} &middot; {a.hoursPerDay}
h/d &middot; {a.workingDays} days
</div>
</div>
<div className="text-right flex-shrink-0 ml-3">
@@ -324,8 +323,8 @@ export function ScenarioPlanner({ projectId, baseline, resources, roles }: Scena
className="inline-block w-2 h-2 rounded-full"
style={{ backgroundColor: d.roleColor ?? "#9ca3af" }}
/>
{d.roleName || "Unspecified"} &middot; {d.headcount}x &middot;{" "}
{d.hoursPerDay}h/d
{d.roleName || "Unspecified"} &middot; {d.headcount}x &middot; {d.hoursPerDay}
h/d
</div>
))}
</div>
@@ -343,9 +342,7 @@ export function ScenarioPlanner({ projectId, baseline, resources, roles }: Scena
<p className="text-xs text-gray-500">
{activeRows.length} allocation(s)
{removedRows.length > 0 && (
<span className="text-red-500 ml-1">
({removedRows.length} removed)
</span>
<span className="text-red-500 ml-1">({removedRows.length} removed)</span>
)}
</p>
</div>
@@ -365,7 +362,12 @@ export function ScenarioPlanner({ projectId, baseline, resources, roles }: Scena
className="inline-flex items-center gap-1 rounded-lg bg-brand-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-brand-700 transition"
>
<svg className="w-3.5 h-3.5" 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>
Add Resource
</button>
@@ -404,12 +406,28 @@ export function ScenarioPlanner({ projectId, baseline, resources, roles }: Scena
>
{simulateMut.isPending ? (
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
)}
Simulate
@@ -498,11 +516,7 @@ function ScenarioRowEditor({
<span className="text-sm text-red-600 line-through">
{resource?.displayName ?? "Unknown"} &mdash; removed
</span>
<button
type="button"
onClick={() => onRestore(row.key)}
className="app-action-edit"
>
<button type="button" onClick={() => onRestore(row.key)} className="app-action-edit">
Restore
</button>
</div>
@@ -543,7 +557,12 @@ function ScenarioRowEditor({
title="Remove from scenario"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
@@ -574,16 +593,14 @@ function ScenarioRowEditor({
max={24}
step={0.5}
value={row.hoursPerDay}
onChange={(e) => onUpdate(row.key, { hoursPerDay: parseFloat(e.target.value) || 0 })}
onChange={(e) =>
onUpdate(row.key, { hoursPerDay: parseFloat(e.target.value) || 0 })
}
className="w-16 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-xs text-gray-900 dark:text-gray-100 text-center"
/>
</div>
{lcrDisplay && (
<span className="text-xs text-gray-400 ml-auto">{lcrDisplay}</span>
)}
{!row.assignmentId && (
<span className="text-xs text-blue-500 font-medium">NEW</span>
)}
{lcrDisplay && <span className="text-xs text-gray-400 ml-auto">{lcrDisplay}</span>}
{!row.assignmentId && <span className="text-xs text-blue-500 font-medium">NEW</span>}
</div>
</div>
)}
@@ -617,10 +634,13 @@ function ImpactSummary({ result, budgetCents }: { result: SimulationResult; budg
const hoursSign = delta.hours > 0 ? "+" : "";
const headcountSign = delta.headcount > 0 ? "+" : "";
const costColor = delta.costCents > 0 ? "text-red-600" : delta.costCents < 0 ? "text-green-600" : "text-gray-500";
const hoursColor = delta.hours > 0 ? "text-amber-600" : delta.hours < 0 ? "text-blue-600" : "text-gray-500";
const costColor =
delta.costCents > 0 ? "text-red-600" : delta.costCents < 0 ? "text-green-600" : "text-gray-500";
const hoursColor =
delta.hours > 0 ? "text-amber-600" : delta.hours < 0 ? "text-blue-600" : "text-gray-500";
const budgetUsedPct = budgetCents > 0 ? Math.round((scenario.totalCostCents / budgetCents) * 100) : null;
const budgetUsedPct =
budgetCents > 0 ? Math.round((scenario.totalCostCents / budgetCents) * 100) : null;
return (
<div className="space-y-4">
@@ -657,14 +677,20 @@ function ImpactSummary({ result, budgetCents }: { result: SimulationResult; budg
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-gray-600 dark:text-gray-400">Budget Usage</span>
<span className={`font-medium ${budgetUsedPct > 100 ? "text-red-600" : "text-gray-900 dark:text-gray-100"}`}>
<span
className={`font-medium ${budgetUsedPct > 100 ? "text-red-600" : "text-gray-900 dark:text-gray-100"}`}
>
{budgetUsedPct}% of {formatMoney(budgetCents)}
</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all ${
budgetUsedPct > 100 ? "bg-red-500" : budgetUsedPct > 80 ? "bg-amber-500" : "bg-green-500"
budgetUsedPct > 100
? "bg-red-500"
: budgetUsedPct > 80
? "bg-amber-500"
: "bg-green-500"
}`}
style={{ width: `${Math.min(budgetUsedPct, 100)}%` }}
/>
@@ -678,9 +704,20 @@ function ImpactSummary({ result, budgetCents }: { result: SimulationResult; budg
<h3 className="text-sm font-medium text-amber-800 dark:text-amber-200 mb-2">Warnings</h3>
<ul className="space-y-1">
{warnings.map((w, i) => (
<li key={i} className="text-sm text-amber-700 dark:text-amber-300 flex items-start gap-2">
<svg className="w-4 h-4 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
<li
key={i}
className="text-sm text-amber-700 dark:text-amber-300 flex items-start gap-2"
>
<svg
className="w-4 h-4 mt-0.5 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
{w}
</li>
@@ -717,19 +754,30 @@ function ImpactSummary({ result, budgetCents }: { result: SimulationResult; budg
<td className="py-2 pr-4 font-medium text-gray-900 dark:text-gray-100">
{ri.resourceName}
{ri.isOverallocated && (
<span className="ml-2 text-xs text-red-500 font-normal">over-allocated</span>
<span className="ml-2 text-xs text-red-500 font-normal">
over-allocated
</span>
)}
</td>
<td className="text-right py-2 px-3 text-gray-600 dark:text-gray-400">
{ri.currentUtilization.toFixed(1)}%
</td>
<td className={`text-right py-2 px-3 font-medium ${ri.isOverallocated ? "text-red-600" : "text-gray-900 dark:text-gray-100"}`}>
<td
className={`text-right py-2 px-3 font-medium ${ri.isOverallocated ? "text-red-600" : "text-gray-900 dark:text-gray-100"}`}
>
{ri.scenarioUtilization.toFixed(1)}%
</td>
<td className={`text-right py-2 px-3 ${
ri.utilizationDelta > 0 ? "text-amber-600" : ri.utilizationDelta < 0 ? "text-blue-600" : "text-gray-500"
}`}>
{ri.utilizationDelta > 0 ? "+" : ""}{ri.utilizationDelta.toFixed(1)}%
<td
className={`text-right py-2 px-3 ${
ri.utilizationDelta > 0
? "text-amber-600"
: ri.utilizationDelta < 0
? "text-blue-600"
: "text-gray-500"
}`}
>
{ri.utilizationDelta > 0 ? "+" : ""}
{ri.utilizationDelta.toFixed(1)}%
</td>
<td className="text-right py-2 pl-3 text-gray-500">
{ri.chargeabilityTarget}%
@@ -1,5 +1,5 @@
import type { BlueprintFieldDefinition } from "@capakraken/shared";
import { FieldType } from "@capakraken/shared";
import type { BlueprintFieldDefinition } from "@nexus/shared";
import { FieldType } from "@nexus/shared";
import { DateInput } from "~/components/ui/DateInput.js";
export function DynamicFieldInput({
@@ -1,6 +1,6 @@
import { clsx } from "clsx";
import type { StaffingRequirement, BlueprintFieldDefinition } from "@capakraken/shared";
import { BlueprintTarget, FieldType, RolePresetsSchema } from "@capakraken/shared";
import type { StaffingRequirement, BlueprintFieldDefinition } from "@nexus/shared";
import { BlueprintTarget, FieldType, RolePresetsSchema } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { DynamicFieldInput } from "./DynamicFieldInput.js";
@@ -1,5 +1,5 @@
import { clsx } from "clsx";
import type { StaffingRequirement } from "@capakraken/shared";
import type { StaffingRequirement } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { SkillTagInput } from "~/components/ui/SkillTagInput.js";
@@ -1,5 +1,5 @@
import { clsx } from "clsx";
import type { StaffingRequirement } from "@capakraken/shared";
import type { StaffingRequirement } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
@@ -1,4 +1,4 @@
import type { StaffingRequirement, BlueprintFieldDefinition } from "@capakraken/shared";
import type { StaffingRequirement, BlueprintFieldDefinition } from "@nexus/shared";
import { toDateInputValue } from "~/lib/format.js";
import { uuid } from "~/lib/uuid.js";
@@ -1,6 +1,6 @@
import { useState, useCallback } from "react";
import type { OrderType, AllocationType } from "@capakraken/shared";
import { ProjectStatus, AllocationStatus } from "@capakraken/shared";
import type { OrderType, AllocationType } from "@nexus/shared";
import { ProjectStatus, AllocationStatus } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { makeDefaultState, type WizardState } from "./types.js";
@@ -6,7 +6,12 @@ const styles = StyleSheet.create({
title: { fontSize: 18, marginBottom: 4, fontFamily: "Helvetica-Bold" },
subtitle: { fontSize: 11, color: "#6b7280", marginBottom: 20 },
table: { marginTop: 10 },
tableHeader: { flexDirection: "row", backgroundColor: "#f3f4f6", padding: "6 8", borderBottom: "1 solid #e5e7eb" },
tableHeader: {
flexDirection: "row",
backgroundColor: "#f3f4f6",
padding: "6 8",
borderBottom: "1 solid #e5e7eb",
},
tableRow: { flexDirection: "row", padding: "5 8", borderBottom: "1 solid #f3f4f6" },
col1: { width: "25%" },
col2: { width: "20%" },
@@ -16,7 +21,15 @@ const styles = StyleSheet.create({
col6: { width: "10%" },
headerText: { fontFamily: "Helvetica-Bold", color: "#374151", fontSize: 9 },
cellText: { color: "#4b5563", fontSize: 9 },
footer: { position: "absolute", bottom: 20, left: 30, right: 30, textAlign: "center", color: "#9ca3af", fontSize: 8 },
footer: {
position: "absolute",
bottom: 20,
left: 30,
right: 30,
textAlign: "center",
color: "#9ca3af",
fontSize: 8,
},
});
interface AllocationRow {
@@ -52,7 +65,10 @@ export function AllocationReport({ title, generatedAt, rows }: AllocationReportP
<Text style={[styles.col6, styles.headerText]}>h/day</Text>
</View>
{rows.map((row, i) => (
<View key={i} style={[styles.tableRow, i % 2 === 1 ? { backgroundColor: "#f9fafb" } : {}]}>
<View
key={i}
style={[styles.tableRow, i % 2 === 1 ? { backgroundColor: "#f9fafb" } : {}]}
>
<Text style={[styles.col1, styles.cellText]}>{row.resourceName}</Text>
<Text style={[styles.col2, styles.cellText]}>{row.projectName}</Text>
<Text style={[styles.col3, styles.cellText]}>{row.role ?? "—"}</Text>
@@ -63,7 +79,7 @@ export function AllocationReport({ title, generatedAt, rows }: AllocationReportP
))}
</View>
<Text style={styles.footer}>CapaKraken · Confidential · {rows.length} allocations</Text>
<Text style={styles.footer}>Nexus · Confidential · {rows.length} allocations</Text>
</Page>
</Document>
);
@@ -86,7 +86,7 @@ export function ReportResultsPanel({
<p className="text-xs text-gray-500 dark:text-gray-400">
{explainability?.entity === "resource_month"
? "Exports include the report sheet plus an Explainability sheet with location, holiday, absence and SAH basis."
: "CSV exports include the selected basis columns and computed CapaKraken metrics exactly as shown here."}
: "CSV exports include the selected basis columns and computed Nexus metrics exactly as shown here."}
</p>
{groupBy && rows.length > 0 ? (
<p className="text-xs text-gray-500 dark:text-gray-400">
@@ -60,9 +60,8 @@ export function ResourceMonthConfigSection<
/>
</div>
<p className="max-w-2xl text-sm text-emerald-900/80 dark:text-emerald-200/80">
Resource Months uses the CapaKraken holiday and absence logic directly. SAH, booked hours
and chargeability are calculated per resource and month with country, state and city
context.
Resource Months uses the Nexus holiday and absence logic directly. SAH, booked hours and
chargeability are calculated per resource and month with country, state and city context.
</p>
</div>
@@ -156,7 +155,7 @@ export function ResourceMonthConfigSection<
</p>
<p className="mt-2 text-xs text-emerald-900/75 dark:text-emerald-200/75">
Export recommendation: include both basis columns and computed metrics in the CSV. That
keeps Excel as a review layer instead of rebuilding CapaKraken logic outside the product.
keeps Excel as a review layer instead of rebuilding Nexus logic outside the product.
</p>
<p className="mt-2 text-xs text-emerald-900/75 dark:text-emerald-200/75">
Minimum audit set: month, location context, SAH, holiday deductions, absence deductions,
@@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
import { FieldType } from "@capakraken/shared";
import type { BlueprintFieldDefinition } from "@capakraken/shared";
import { FieldType } from "@nexus/shared";
import type { BlueprintFieldDefinition } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
interface Props {
@@ -9,7 +9,7 @@ import type {
AllocationWithDetails,
Resource,
SkillEntry,
} from "@capakraken/shared";
} from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { formatDate, formatMoney } from "~/lib/format.js";
import { ResourceModal } from "./ResourceModal.js";
@@ -2,7 +2,7 @@
import { useRef, useState } from "react";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import type { Resource, SkillEntry, ResourceType } from "@capakraken/shared";
import type { Resource, SkillEntry, ResourceType } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { usePermissions } from "~/hooks/usePermissions.js";
@@ -1,4 +1,4 @@
import { GERMAN_FEDERAL_STATES, inferStateFromPostalCode, ResourceType } from "@capakraken/shared";
import { GERMAN_FEDERAL_STATES, inferStateFromPostalCode, ResourceType } from "@nexus/shared";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
type CountryOption = { id: string; name: string; metroCities: { id: string; name: string }[] };
@@ -4,11 +4,11 @@ import { useState, useRef } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { parseSkillMatrixWorkbook, matchRoleName } from "~/lib/skillMatrixParser.js";
import { assertSpreadsheetFile } from "~/lib/excel.js";
import type { SkillEntry } from "@capakraken/shared";
import type { SkillEntry } from "@nexus/shared";
interface Props {
resourceId: string;
isOwner: boolean; // true = self-service, false = manager import
isOwner: boolean; // true = self-service, false = manager import
onClose: () => void;
onSuccess: () => void;
}
@@ -31,13 +31,25 @@ export function SkillMatrixUpload({ resourceId, isOwner, onClose, onSuccess }: P
const { data: roles } = trpc.role.list.useQuery({ isActive: true }, { staleTime: 60_000 });
const selfMutation = trpc.resource.importSkillMatrix.useMutation({
onSuccess: () => { setSubmitting(false); onSuccess(); },
onError: (err) => { setSubmitting(false); setParseError(err.message); },
onSuccess: () => {
setSubmitting(false);
onSuccess();
},
onError: (err) => {
setSubmitting(false);
setParseError(err.message);
},
});
const managerMutation = trpc.resource.importSkillMatrixForResource.useMutation({
onSuccess: () => { setSubmitting(false); onSuccess(); },
onError: (err) => { setSubmitting(false); setParseError(err.message); },
onSuccess: () => {
setSubmitting(false);
onSuccess();
},
onError: (err) => {
setSubmitting(false);
setParseError(err.message);
},
});
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
@@ -68,7 +80,9 @@ export function SkillMatrixUpload({ resourceId, isOwner, onClose, onSuccess }: P
skills: parsed.skills,
employeeInfo: {
...(roleId !== undefined ? { roleId } : {}),
...(parsed.employeeInfo.portfolioUrl !== undefined ? { portfolioUrl: parsed.employeeInfo.portfolioUrl } : {}),
...(parsed.employeeInfo.portfolioUrl !== undefined
? { portfolioUrl: parsed.employeeInfo.portfolioUrl }
: {}),
},
...(matchedRoleName !== undefined ? { matchedRoleName } : {}),
});
@@ -101,14 +115,22 @@ export function SkillMatrixUpload({ resourceId, isOwner, onClose, onSuccess }: P
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-base font-semibold text-gray-900">Update Skill Matrix</h2>
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
@@ -121,8 +143,18 @@ export function SkillMatrixUpload({ resourceId, isOwner, onClose, onSuccess }: P
className="border-2 border-dashed border-gray-300 rounded-xl p-8 text-center cursor-pointer hover:border-brand-400 transition-colors"
onClick={() => fileRef.current?.click()}
>
<svg className="w-10 h-10 text-gray-300 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
<svg
className="w-10 h-10 text-gray-300 mx-auto mb-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p className="text-sm font-medium text-gray-700">Click to select skill matrix file</p>
<p className="text-xs text-gray-400 mt-1">.xlsx accepted</p>
@@ -161,7 +193,10 @@ export function SkillMatrixUpload({ resourceId, isOwner, onClose, onSuccess }: P
<p className="text-xs font-medium text-gray-500 mb-1.5">Main skills:</p>
<div className="flex flex-wrap gap-1.5">
{mainSkills.map((s) => (
<span key={s.skill} className="px-2.5 py-0.5 text-xs font-medium rounded-full bg-amber-50 text-amber-700 border border-amber-200">
<span
key={s.skill}
className="px-2.5 py-0.5 text-xs font-medium rounded-full bg-amber-50 text-amber-700 border border-amber-200"
>
{s.skill}
</span>
))}
@@ -171,7 +206,7 @@ export function SkillMatrixUpload({ resourceId, isOwner, onClose, onSuccess }: P
{preview.matchedRoleName && (
<p className="text-xs text-gray-600">
<span className="font-medium">Area of expertise</span> matched to CapaKraken role:{" "}
<span className="font-medium">Area of expertise</span> matched to Nexus role:{" "}
<span className="font-semibold text-brand-700">{preview.matchedRoleName}</span>
</p>
)}
@@ -179,7 +214,12 @@ export function SkillMatrixUpload({ resourceId, isOwner, onClose, onSuccess }: P
{preview.employeeInfo.portfolioUrl && (
<p className="text-xs text-gray-600 truncate">
<span className="font-medium">Portfolio URL:</span>{" "}
<a href={preview.employeeInfo.portfolioUrl} target="_blank" rel="noopener noreferrer" className="text-brand-600 hover:underline">
<a
href={preview.employeeInfo.portfolioUrl}
target="_blank"
rel="noopener noreferrer"
className="text-brand-600 hover:underline"
>
{preview.employeeInfo.portfolioUrl}
</a>
</p>
@@ -191,7 +231,11 @@ export function SkillMatrixUpload({ resourceId, isOwner, onClose, onSuccess }: P
<button
type="button"
onClick={() => { setPreview(null); setParseError(null); if (fileRef.current) fileRef.current.value = ""; }}
onClick={() => {
setPreview(null);
setParseError(null);
if (fileRef.current) fileRef.current.value = "";
}}
className="text-xs text-gray-400 hover:text-gray-600 underline"
>
Choose a different file
@@ -1,14 +1,8 @@
"use client";
import { useRef, useEffect, useState } from "react";
import {
RadarChart,
PolarGrid,
PolarAngleAxis,
Radar,
Tooltip,
} from "recharts";
import type { SkillEntry } from "@capakraken/shared";
import { RadarChart, PolarGrid, PolarAngleAxis, Radar, Tooltip } from "recharts";
import type { SkillEntry } from "@nexus/shared";
interface Props {
skills: SkillEntry[];
@@ -32,7 +26,9 @@ export function SkillRadarChart({ skills }: Props) {
if (skills.length === 0) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-4">Skill Profile</h2>
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-4">
Skill Profile
</h2>
<div className="flex items-center justify-center h-48 text-sm text-gray-400 dark:text-gray-500">
No skills recorded yet
</div>
@@ -69,12 +65,11 @@ export function SkillRadarChart({ skills }: Props) {
margin={{ top: 10, right: 30, bottom: 10, left: 30 }}
>
<PolarGrid stroke="#e5e7eb" />
<PolarAngleAxis
dataKey="category"
tick={{ fontSize: 11, fill: "#6b7280" }}
/>
<PolarAngleAxis dataKey="category" tick={{ fontSize: 11, fill: "#6b7280" }} />
<Tooltip
formatter={(value: number | undefined) => [`${value ?? 0}%`, "Avg proficiency"] as [string, string]}
formatter={(value: number | undefined) =>
[`${value ?? 0}%`, "Avg proficiency"] as [string, string]
}
contentStyle={{ fontSize: 12, borderRadius: 8 }}
/>
<Radar
+106 -99
View File
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import type { RoleWithResourceCount } from "@capakraken/shared";
import type { RoleWithResourceCount } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
@@ -80,110 +80,117 @@ export function RoleModal({ role, onClose, onSuccess }: RoleModalProps) {
return (
<AnimatedModal open onClose={onClose} maxWidth="max-w-md">
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{isEditing ? "Edit Role" : "New Role"}
</h2>
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{isEditing ? "Edit Role" : "New Role"}
</h2>
<button
type="button"
onClick={onClose}
className="text-xl leading-none text-gray-400 transition hover:text-gray-600 dark:hover:text-gray-200"
>
&times;
</button>
</div>
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
<div>
<label className={labelClass}>
Name <span className="text-red-500">*</span>
<InfoTooltip content="Role name shown in timelines, allocation pickers, and staffing demands (e.g. 3D Artist, Producer)." />
</label>
<input
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
setServerError(null);
}}
placeholder="e.g. 3D Artist"
className={inputClass}
maxLength={100}
required
/>
</div>
<div>
<label className={labelClass}>
Description
<InfoTooltip content="Optional description of this role's responsibilities." />
</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional description"
className={inputClass}
maxLength={500}
/>
</div>
<div>
<label className={labelClass}>
Color
<InfoTooltip content="Color used in timeline bars and badge chips for this role." />
</label>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/70">
<div className="mb-3 flex items-center gap-3">
<div
className="h-8 w-8 flex-shrink-0 rounded-full border-2 border-gray-200 dark:border-gray-600"
style={{ backgroundColor: color }}
/>
<p className="text-sm text-gray-600 dark:text-gray-300">
Pick a color that stays readable in timelines and chips.
</p>
</div>
<div className="flex flex-wrap gap-2 flex-1">
{PRESET_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className="w-6 h-6 rounded-full border-2 transition-transform hover:scale-110"
style={{
backgroundColor: c,
borderColor: color === c ? "#1f2937" : "transparent",
}}
/>
))}
</div>
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="mt-3 h-8 w-10 cursor-pointer rounded border border-gray-300 bg-transparent dark:border-gray-600"
title="Custom color"
/>
</div>
</div>
{serverError && (
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-300">
{serverError}
</div>
)}
<div className="flex items-center justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="text-xl leading-none text-gray-400 transition hover:text-gray-600 dark:hover:text-gray-200"
disabled={isPending}
className="rounded-xl px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-100 hover:text-gray-900 disabled:opacity-50 dark:text-gray-300 dark:hover:bg-gray-800"
>
&times;
Cancel
</button>
<button
type="submit"
disabled={isPending}
className="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700 disabled:opacity-50"
>
{isPending ? "Saving…" : "Save"}
</button>
</div>
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
<div>
<label className={labelClass}>
Name <span className="text-red-500">*</span><InfoTooltip content="Role name shown in timelines, allocation pickers, and staffing demands (e.g. 3D Artist, Producer)." />
</label>
<input
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
setServerError(null);
}}
placeholder="e.g. 3D Artist"
className={inputClass}
maxLength={100}
required
/>
</div>
<div>
<label className={labelClass}>Description<InfoTooltip content="Optional description of this role's responsibilities." /></label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional description"
className={inputClass}
maxLength={500}
/>
</div>
<div>
<label className={labelClass}>Color<InfoTooltip content="Color used in timeline bars and badge chips for this role." /></label>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/70">
<div className="mb-3 flex items-center gap-3">
<div
className="h-8 w-8 flex-shrink-0 rounded-full border-2 border-gray-200 dark:border-gray-600"
style={{ backgroundColor: color }}
/>
<p className="text-sm text-gray-600 dark:text-gray-300">
Pick a color that stays readable in timelines and chips.
</p>
</div>
<div className="flex flex-wrap gap-2 flex-1">
{PRESET_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className="w-6 h-6 rounded-full border-2 transition-transform hover:scale-110"
style={{
backgroundColor: c,
borderColor: color === c ? "#1f2937" : "transparent",
}}
/>
))}
</div>
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="mt-3 h-8 w-10 cursor-pointer rounded border border-gray-300 bg-transparent dark:border-gray-600"
title="Custom color"
/>
</div>
</div>
{serverError && (
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-300">
{serverError}
</div>
)}
<div className="flex items-center justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
disabled={isPending}
className="rounded-xl px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-100 hover:text-gray-900 disabled:opacity-50 dark:text-gray-300 dark:hover:bg-gray-800"
>
Cancel
</button>
<button
type="submit"
disabled={isPending}
className="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700 disabled:opacity-50"
>
{isPending ? "Saving…" : "Save"}
</button>
</div>
</form>
</form>
</AnimatedModal>
);
}
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import type { RoleWithResourceCount } from "@capakraken/shared";
import type { RoleWithResourceCount } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { RoleModal } from "./RoleModal.js";
import { FilterChips } from "~/components/ui/FilterChips.js";
@@ -150,7 +150,10 @@ export function RolesClient() {
tooltip="Role name (e.g. 3D Artist, Producer). Shown in timeline bars and staffing views."
/>
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">
<span className="inline-flex items-center gap-0.5">Description<InfoTooltip content="Optional description of the role's responsibilities and typical work." /></span>
<span className="inline-flex items-center gap-0.5">
Description
<InfoTooltip content="Optional description of the role's responsibilities and typical work." />
</span>
</th>
<SortableColumnHeader
label="Resources"
@@ -5,7 +5,7 @@ import { useSession } from "next-auth/react";
import { useEffect, useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
const SNOOZE_KEY = "capakraken_mfa_prompt_snoozed_until";
const SNOOZE_KEY = "nexus_mfa_prompt_snoozed_until";
const SNOOZE_DAYS = 7;
/**
@@ -90,14 +90,14 @@ export function MfaSetup() {
if (!backupCodes) return;
const blob = new Blob(
[
`CapaKraken MFA Backup Codes\nGenerated: ${new Date().toISOString()}\n\nEach code works exactly once. Keep this file somewhere safe.\n\n${backupCodes.join("\n")}\n`,
`Nexus MFA Backup Codes\nGenerated: ${new Date().toISOString()}\n\nEach code works exactly once. Keep this file somewhere safe.\n\n${backupCodes.join("\n")}\n`,
],
{ type: "text/plain" },
);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "capakraken-backup-codes.txt";
a.download = "nexus-backup-codes.txt";
a.click();
URL.revokeObjectURL(url);
}
@@ -20,7 +20,11 @@ export function StaffingPanel() {
const [hoursPerDay, setHoursPerDay] = useState(8);
const [submitted, setSubmitted] = useState(false);
const [assignedIds, setAssignedIds] = useState<Set<string>>(new Set());
const [toast, setToast] = useState<{ show: boolean; message: string; variant: "success" | "warning" }>({
const [toast, setToast] = useState<{
show: boolean;
message: string;
variant: "success" | "warning";
}>({
show: false,
message: "",
variant: "success",
@@ -47,7 +51,12 @@ export function StaffingPanel() {
return (
<div className="app-page space-y-6">
<SuccessToast show={toast.show} message={toast.message} variant={toast.variant} onDone={clearToast} />
<SuccessToast
show={toast.show}
message={toast.message}
variant={toast.variant}
onDone={clearToast}
/>
<div className="app-page-header gap-4">
<div className="space-y-3">
@@ -57,14 +66,16 @@ export function StaffingPanel() {
<div>
<h1 className="app-page-title">Staffing Suggestions</h1>
<p className="app-page-subtitle max-w-2xl">
Match open work with the strongest available people based on skills, availability, utilization, and cost.
Match open work with the strongest available people based on skills, availability,
utilization, and cost.
</p>
</div>
</div>
<div className="app-surface max-w-xl p-4">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">How scoring works</p>
<p className="mt-1 text-sm text-gray-500">
CapaKraken blends skill fit, free capacity, cost, and current utilization. Add the must-have skills first, then narrow the date window to get cleaner results.
Nexus blends skill fit, free capacity, cost, and current utilization. Add the must-have
skills first, then narrow the date window to get cleaner results.
</p>
</div>
</div>
@@ -3,7 +3,7 @@
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
import { AllocationStatus } from "@capakraken/shared";
import { AllocationStatus } from "@nexus/shared";
import { DateInput } from "~/components/ui/DateInput.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { Button } from "~/components/ui/Button.js";
@@ -92,12 +92,22 @@ interface StaffingResultCardProps {
onError: (message: string) => void;
}
export function StaffingResultCard({ suggestion, rank, searchCriteria, onAssigned, onError }: StaffingResultCardProps) {
export function StaffingResultCard({
suggestion,
rank,
searchCriteria,
onAssigned,
onError,
}: StaffingResultCardProps) {
const [showDetails, setShowDetails] = useState(false);
const [showAssignForm, setShowAssignForm] = useState(false);
const locationLabel =
suggestion.location?.label ||
[suggestion.location?.countryCode, suggestion.location?.federalState, suggestion.location?.metroCityName]
[
suggestion.location?.countryCode,
suggestion.location?.federalState,
suggestion.location?.metroCityName,
]
.filter(Boolean)
.join(" / ") ||
"No location";
@@ -105,10 +115,13 @@ export function StaffingResultCard({ suggestion, rank, searchCriteria, onAssigne
const conflicts = suggestion.conflicts;
const conflictCount = conflicts?.count ?? suggestion.availabilityConflicts.length;
const remainingHours = capacity?.remainingHours ?? suggestion.remainingHours ?? 0;
const remainingHoursPerDay = capacity?.remainingHoursPerDay ?? suggestion.remainingHoursPerDay ?? 0;
const remainingHoursPerDay =
capacity?.remainingHoursPerDay ?? suggestion.remainingHoursPerDay ?? 0;
const baseAvailableHours = capacity?.baseAvailableHours ?? suggestion.baseAvailableHours ?? 0;
const effectiveAvailableHours = capacity?.effectiveAvailableHours ?? suggestion.effectiveAvailableHours ?? 0;
const holidayHoursDeduction = capacity?.holidayHoursDeduction ?? suggestion.holidayHoursDeduction ?? 0;
const effectiveAvailableHours =
capacity?.effectiveAvailableHours ?? suggestion.effectiveAvailableHours ?? 0;
const holidayHoursDeduction =
capacity?.holidayHoursDeduction ?? suggestion.holidayHoursDeduction ?? 0;
return (
<div data-suggestion className="app-surface p-5">
@@ -118,7 +131,9 @@ export function StaffingResultCard({ suggestion, rank, searchCriteria, onAssigne
{rank}
</div>
<div>
<div className="text-base font-semibold text-gray-900 dark:text-gray-100">{suggestion.resourceName}</div>
<div className="text-base font-semibold text-gray-900 dark:text-gray-100">
{suggestion.resourceName}
</div>
<div className="text-sm text-gray-500">{suggestion.eid}</div>
<div className="mt-1 text-xs text-gray-500">{locationLabel}</div>
</div>
@@ -135,19 +150,27 @@ export function StaffingResultCard({ suggestion, rank, searchCriteria, onAssigne
Match Score
<InfoTooltip content="Composite score (0-100) blending skill fit, free capacity, cost efficiency, and current utilization." />
</div>
<div className="mt-1 text-3xl font-semibold text-brand-700 dark:text-brand-100">{suggestion.score}</div>
<div className="mt-1 text-3xl font-semibold text-brand-700 dark:text-brand-100">
{suggestion.score}
</div>
</div>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{suggestion.matchedSkills.map((skill) => (
<span key={skill} className="rounded-full bg-green-50 px-2.5 py-1 text-xs font-medium text-green-700 dark:bg-green-950/30 dark:text-green-300">
<span
key={skill}
className="rounded-full bg-green-50 px-2.5 py-1 text-xs font-medium text-green-700 dark:bg-green-950/30 dark:text-green-300"
>
{skill}
</span>
))}
{suggestion.missingSkills.map((skill) => (
<span key={skill} className="rounded-full bg-red-50 px-2.5 py-1 text-xs font-medium text-red-600 dark:bg-red-950/30 dark:text-red-300">
<span
key={skill}
className="rounded-full bg-red-50 px-2.5 py-1 text-xs font-medium text-red-600 dark:bg-red-950/30 dark:text-red-300"
>
{skill} missing
</span>
))}
@@ -169,13 +192,21 @@ export function StaffingResultCard({ suggestion, rank, searchCriteria, onAssigne
label="Holiday Deduction"
value={holidayHoursDeduction > 0 ? formatHours(holidayHoursDeduction) : "0h"}
tone={holidayHoursDeduction > 0 ? "warn" : "neutral"}
helper={capacity ? `${capacity.holidayWorkdayCount} affected workdays` : "No local holiday impact"}
helper={
capacity
? `${capacity.holidayWorkdayCount} affected workdays`
: "No local holiday impact"
}
/>
<StatCard
label="Conflicts"
value={String(conflictCount)}
tone={conflictCount > 0 ? "warn" : "good"}
helper={conflictCount > 0 ? `${conflictCount} overloaded day${conflictCount === 1 ? "" : "s"}` : "No day-level overloads"}
helper={
conflictCount > 0
? `${conflictCount} overloaded day${conflictCount === 1 ? "" : "s"}`
: "No day-level overloads"
}
/>
</div>
@@ -193,38 +224,81 @@ export function StaffingResultCard({ suggestion, rank, searchCriteria, onAssigne
{showDetails && (
<div className="mt-5 space-y-4 rounded-2xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-950/40">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<ScoreBar label="Skills" value={suggestion.scoreBreakdown.skillScore} tooltip="Quality of skill overlap with the requested stack, weighted by proficiency level." />
<ScoreBar label="Availability" value={suggestion.scoreBreakdown.availabilityScore} tooltip="Free capacity during the selected period, accounting for existing bookings and vacations." />
<ScoreBar label="Cost" value={suggestion.scoreBreakdown.costScore} tooltip="Cost efficiency based on the resource's LCR relative to the team average." />
<ScoreBar label="Utilization" value={suggestion.scoreBreakdown.utilizationScore} tooltip="Current workload. Higher score means more capacity available (lower utilization)." />
<ScoreBar
label="Skills"
value={suggestion.scoreBreakdown.skillScore}
tooltip="Quality of skill overlap with the requested stack, weighted by proficiency level."
/>
<ScoreBar
label="Availability"
value={suggestion.scoreBreakdown.availabilityScore}
tooltip="Free capacity during the selected period, accounting for existing bookings and vacations."
/>
<ScoreBar
label="Cost"
value={suggestion.scoreBreakdown.costScore}
tooltip="Cost efficiency based on the resource's LCR relative to the team average."
/>
<ScoreBar
label="Utilization"
value={suggestion.scoreBreakdown.utilizationScore}
tooltip="Current workload. Higher score means more capacity available (lower utilization)."
/>
</div>
<div className="grid gap-4 xl:grid-cols-[1.15fr_1fr]">
<div className="rounded-2xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950/60">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Capacity Basis</div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">
Capacity Basis
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<MetricLine label="Requested load" value={`${formatHours(capacity?.requestedHoursPerDay ?? searchCriteria.hoursPerDay)} / day`} />
<MetricLine label="Requested total" value={formatHours(capacity?.requestedHoursTotal ?? 0)} />
<MetricLine label="Base working days" value={String(capacity?.baseWorkingDays ?? 0)} />
<MetricLine label="Effective working days" value={String(capacity?.effectiveWorkingDays ?? 0)} />
<MetricLine
label="Requested load"
value={`${formatHours(capacity?.requestedHoursPerDay ?? searchCriteria.hoursPerDay)} / day`}
/>
<MetricLine
label="Requested total"
value={formatHours(capacity?.requestedHoursTotal ?? 0)}
/>
<MetricLine
label="Base working days"
value={String(capacity?.baseWorkingDays ?? 0)}
/>
<MetricLine
label="Effective working days"
value={String(capacity?.effectiveWorkingDays ?? 0)}
/>
<MetricLine label="Base available hours" value={formatHours(baseAvailableHours)} />
<MetricLine label="Effective available hours" value={formatHours(effectiveAvailableHours)} />
<MetricLine
label="Effective available hours"
value={formatHours(effectiveAvailableHours)}
/>
<MetricLine label="Booked hours" value={formatHours(capacity?.bookedHours ?? 0)} />
<MetricLine label="Remaining hours" value={formatHours(remainingHours)} />
<MetricLine label="Holiday deduction" value={formatHours(holidayHoursDeduction)} />
<MetricLine label="Absence deduction" value={formatHours(capacity?.absenceHoursDeduction ?? 0)} />
<MetricLine
label="Absence deduction"
value={formatHours(capacity?.absenceHoursDeduction ?? 0)}
/>
</div>
</div>
<div className="space-y-4">
<div className="rounded-2xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950/60">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Ranking Basis</div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">
Ranking Basis
</div>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
{suggestion.ranking?.model ?? "Composite ranking across skill fit, availability, cost, and utilization."}
{suggestion.ranking?.model ??
"Composite ranking across skill fit, availability, cost, and utilization."}
</p>
<div className="mt-3 space-y-2 text-sm text-gray-600 dark:text-gray-300">
{(suggestion.ranking?.components ?? []).map((component) => (
<MetricLine key={component.key} label={component.label} value={`${component.score}`} />
<MetricLine
key={component.key}
label={component.label}
value={`${component.score}`}
/>
))}
{suggestion.ranking?.tieBreakerReason && (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
@@ -235,12 +309,20 @@ export function StaffingResultCard({ suggestion, rank, searchCriteria, onAssigne
</div>
<div className="rounded-2xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950/60">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Location + Calendar</div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">
Location + Calendar
</div>
<div className="mt-3 space-y-2 text-sm text-gray-600 dark:text-gray-300">
<MetricLine label="Location" value={locationLabel} />
<MetricLine label="Holiday dates" value={String(capacity?.holidayCount ?? 0)} />
<MetricLine label="Holiday workdays" value={String(capacity?.holidayWorkdayCount ?? 0)} />
<MetricLine label="Absence days" value={String(capacity?.absenceDayEquivalent ?? 0)} />
<MetricLine
label="Holiday workdays"
value={String(capacity?.holidayWorkdayCount ?? 0)}
/>
<MetricLine
label="Absence days"
value={String(capacity?.absenceDayEquivalent ?? 0)}
/>
</div>
</div>
</div>
@@ -248,9 +330,12 @@ export function StaffingResultCard({ suggestion, rank, searchCriteria, onAssigne
<div className="rounded-2xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950/60">
<div className="flex items-center justify-between gap-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Conflict Check</div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">
Conflict Check
</div>
<div className="text-xs text-gray-500">
Requested {formatHours(searchCriteria.hoursPerDay)} / day between {searchCriteria.startDate} and {searchCriteria.endDate}
Requested {formatHours(searchCriteria.hoursPerDay)} / day between{" "}
{searchCriteria.startDate} and {searchCriteria.endDate}
</div>
</div>
{conflictCount === 0 ? (
@@ -260,13 +345,19 @@ export function StaffingResultCard({ suggestion, rank, searchCriteria, onAssigne
) : (
<div className="mt-3 space-y-2">
{(conflicts?.details ?? []).slice(0, 6).map((item) => (
<div key={item.date} className="rounded-xl border border-amber-200 bg-amber-50/80 px-3 py-2 text-sm text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-100">
<div
key={item.date}
className="rounded-xl border border-amber-200 bg-amber-50/80 px-3 py-2 text-sm text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-100"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-medium">{item.date}</span>
<span>Short by {formatHours(item.shortageHours)}</span>
</div>
<div className="mt-1 text-xs">
Base {formatHours(item.baseHours)} | Effective {formatHours(item.effectiveHours)} | Already booked {formatHours(item.allocatedHours)} | Remaining {formatHours(item.remainingHours)}
Base {formatHours(item.baseHours)} | Effective{" "}
{formatHours(item.effectiveHours)} | Already booked{" "}
{formatHours(item.allocatedHours)} | Remaining{" "}
{formatHours(item.remainingHours)}
</div>
</div>
))}
@@ -308,7 +399,14 @@ interface AssignFormProps {
onCancel: () => void;
}
function AssignForm({ resourceId, resourceName, searchCriteria, onAssigned, onError, onCancel }: AssignFormProps) {
function AssignForm({
resourceId,
resourceName,
searchCriteria,
onAssigned,
onError,
onCancel,
}: AssignFormProps) {
const [projectId, setProjectId] = useState("");
const [assignStartDate, setAssignStartDate] = useState(searchCriteria.startDate);
const [assignEndDate, setAssignEndDate] = useState(searchCriteria.endDate);
@@ -350,24 +448,36 @@ function AssignForm({ resourceId, resourceName, searchCriteria, onAssigned, onEr
});
};
const projectList = (projects as { projects?: Array<{ id: string; name: string; shortCode?: string | null }> } | undefined)?.projects ?? [];
const projectList =
(
projects as
| { projects?: Array<{ id: string; name: string; shortCode?: string | null }> }
| undefined
)?.projects ?? [];
const rolesList = (roles ?? []) as Array<{ id: string; name: string }>;
const selectedProject = projectList.find((p) => p.id === projectId);
return (
<div className="mt-4 rounded-xl border border-brand-200 bg-brand-50/50 p-4 dark:border-brand-900/40 dark:bg-brand-950/30">
<div className="mb-3 flex items-center justify-between">
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Assign {resourceName}</h4>
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Assign {resourceName}
</h4>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="app-label">Project *</label>
<select value={projectId} onChange={(e) => setProjectId(e.target.value)} className="app-input">
<select
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
className="app-input"
>
<option value="">Select project...</option>
{projectList.map((p) => (
<option key={p.id} value={p.id}>
{p.shortCode ? `[${p.shortCode}] ` : ""}{p.name}
{p.shortCode ? `[${p.shortCode}] ` : ""}
{p.name}
</option>
))}
</select>
@@ -380,7 +490,12 @@ function AssignForm({ resourceId, resourceName, searchCriteria, onAssigned, onEr
<div>
<label className="app-label">End Date</label>
<DateInput value={assignEndDate} onChange={setAssignEndDate} min={assignStartDate} className="app-input" />
<DateInput
value={assignEndDate}
onChange={setAssignEndDate}
min={assignStartDate}
className="app-input"
/>
</div>
<div>
@@ -399,7 +514,11 @@ function AssignForm({ resourceId, resourceName, searchCriteria, onAssigned, onEr
<div>
<label className="app-label">Role</label>
{rolesList.length > 0 ? (
<select value={roleId} onChange={(e) => setRoleId(e.target.value)} className="app-input">
<select
value={roleId}
onChange={(e) => setRoleId(e.target.value)}
className="app-input"
>
<option value="">No role</option>
{rolesList.map((r) => (
<option key={r.id} value={r.id}>
@@ -422,13 +541,20 @@ function AssignForm({ resourceId, resourceName, searchCriteria, onAssigned, onEr
{selectedProject && (
<div className="mt-3 text-xs text-gray-500">
Assigning to{" "}
<span className="font-medium text-gray-700 dark:text-gray-300">{selectedProject.name}</span>
{" "}from {assignStartDate} to {assignEndDate} at {assignHours}h/day
<span className="font-medium text-gray-700 dark:text-gray-300">
{selectedProject.name}
</span>{" "}
from {assignStartDate} to {assignEndDate} at {assignHours}h/day
</div>
)}
<div className="mt-4 flex items-center gap-2">
<Button variant="primary" size="sm" disabled={!canSubmit || createAssignment.isPending} onClick={handleSubmit}>
<Button
variant="primary"
size="sm"
disabled={!canSubmit || createAssignment.isPending}
onClick={handleSubmit}
>
{createAssignment.isPending ? "Assigning..." : "Confirm Assignment"}
</Button>
<Button variant="ghost" size="sm" onClick={onCancel} disabled={createAssignment.isPending}>
@@ -487,7 +613,9 @@ function StatCard({
return (
<div className={`rounded-2xl border p-3 ${toneClass}`}>
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">{label}</div>
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">
{label}
</div>
<div className="mt-2 text-lg font-semibold text-gray-900 dark:text-gray-100">{value}</div>
{helper && <div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{helper}</div>}
</div>
@@ -4,7 +4,7 @@ import React, { type RefObject } from "react";
import { clsx } from "clsx";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import type { AllocationLike, Assignment } from "@capakraken/shared";
import type { AllocationLike, Assignment } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
@@ -3,7 +3,7 @@
import { clsx } from "clsx";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { AllocationStatus } from "@capakraken/shared";
import { AllocationStatus } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js";
@@ -1,6 +1,6 @@
"use client";
import { MILLISECONDS_PER_DAY } from "@capakraken/shared";
import { MILLISECONDS_PER_DAY } from "@nexus/shared";
import type { RefObject } from "react";
import { createPortal } from "react-dom";
import type { TimelineDemandEntry } from "./TimelineContext.js";
@@ -4,7 +4,7 @@ import { clsx } from "clsx";
import type { RefObject } from "react";
import { useState } from "react";
import { createPortal } from "react-dom";
import { AllocationStatus } from "@capakraken/shared";
import { AllocationStatus } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
@@ -2,7 +2,7 @@
import { clsx } from "clsx";
import { useEffect, useState } from "react";
import { AllocationStatus, type StaffingRequirement } from "@capakraken/shared";
import { AllocationStatus, type StaffingRequirement } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
@@ -3,7 +3,7 @@
import { createPortal } from "react-dom";
import { trpc } from "~/lib/trpc/client.js";
import { formatCents } from "~/lib/format.js";
import type { SkillEntry } from "@capakraken/shared";
import type { SkillEntry } from "@nexus/shared";
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
interface ResourceHoverCardProps {
@@ -66,10 +66,15 @@ export function ResourceHoverCard({ resourceId, anchorEl, onClose }: ResourceHov
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5">
{data.areaRole && (
<div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">Role</div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">
Role
</div>
<div className="font-medium text-gray-700 dark:text-gray-200 flex items-center gap-1">
{data.areaRole.color && (
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: data.areaRole.color }} />
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: data.areaRole.color }}
/>
)}
{data.areaRole.name}
</div>
@@ -77,20 +82,30 @@ export function ResourceHoverCard({ resourceId, anchorEl, onClose }: ResourceHov
)}
{data.chapter && (
<div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">Chapter</div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">
Chapter
</div>
<div className="font-medium text-gray-700 dark:text-gray-200">{data.chapter}</div>
</div>
)}
{data.country && (
<div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">Location</div>
<div className="font-medium text-gray-700 dark:text-gray-200">{data.country.name}</div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">
Location
</div>
<div className="font-medium text-gray-700 dark:text-gray-200">
{data.country.name}
</div>
</div>
)}
{data.managementLevel && (
<div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">Level</div>
<div className="font-medium text-gray-700 dark:text-gray-200">{data.managementLevel.name}</div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">
Level
</div>
<div className="font-medium text-gray-700 dark:text-gray-200">
{data.managementLevel.name}
</div>
</div>
)}
</div>
@@ -98,29 +113,39 @@ export function ResourceHoverCard({ resourceId, anchorEl, onClose }: ResourceHov
{/* Rates */}
<div className="flex items-center gap-3 py-1.5 px-2 rounded-lg bg-gray-50 dark:bg-gray-750">
<div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">LCR</div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">
LCR
</div>
<div className="font-semibold text-gray-700 dark:text-gray-200">
{formatCents(data.lcrCents)} {data.currency}/h
</div>
</div>
<div className="w-px h-6 bg-gray-200 dark:bg-gray-600" />
<div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">UCR</div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">
UCR
</div>
<div className="font-semibold text-gray-700 dark:text-gray-200">
{formatCents(data.ucrCents)} {data.currency}/h
</div>
</div>
<div className="w-px h-6 bg-gray-200 dark:bg-gray-600" />
<div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">Chg%</div>
<div className="font-semibold text-gray-700 dark:text-gray-200">{data.chargeabilityTarget}%</div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">
Chg%
</div>
<div className="font-semibold text-gray-700 dark:text-gray-200">
{data.chargeabilityTarget}%
</div>
</div>
</div>
{/* Main Skills */}
{mainSkills.length > 0 && (
<div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider mb-1">Main Skills</div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider mb-1">
Main Skills
</div>
<div className="flex flex-wrap gap-1">
{mainSkills.map((s) => (
<span
@@ -137,7 +162,9 @@ export function ResourceHoverCard({ resourceId, anchorEl, onClose }: ResourceHov
{/* Top Skills */}
{topSkills.length > 0 && (
<div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider mb-1">Top Skills</div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider mb-1">
Top Skills
</div>
<div className="flex flex-wrap gap-1">
{topSkills.map((s) => (
<span
@@ -145,7 +172,9 @@ export function ResourceHoverCard({ resourceId, anchorEl, onClose }: ResourceHov
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-md text-[11px] bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
>
{s.skill}
<span className="text-[9px] text-gray-400 dark:text-gray-500">L{s.proficiency}</span>
<span className="text-[9px] text-gray-400 dark:text-gray-500">
L{s.proficiency}
</span>
</span>
))}
</div>
@@ -154,7 +183,9 @@ export function ResourceHoverCard({ resourceId, anchorEl, onClose }: ResourceHov
{/* No skills */}
{skills.length === 0 && (
<div className="text-[11px] text-gray-400 dark:text-gray-500 italic">No skills imported yet.</div>
<div className="text-[11px] text-gray-400 dark:text-gray-500 italic">
No skills imported yet.
</div>
)}
</div>
</>
@@ -6,7 +6,7 @@ import {
type AllocationReadModel,
type Assignment,
type DemandRequirement,
} from "@capakraken/shared";
} from "@nexus/shared";
import {
createContext,
useContext,
@@ -1,4 +1,4 @@
import { MILLISECONDS_PER_DAY } from "@capakraken/shared";
import { MILLISECONDS_PER_DAY } from "@nexus/shared";
import type { useTimelineDrag } from "~/hooks/useTimelineDrag.js";
import { formatDateShort } from "~/lib/format.js";
import { ShiftPreviewTooltip } from "./ShiftPreviewTooltip.js";
@@ -1,6 +1,6 @@
"use client";
import { GERMAN_FEDERAL_STATES } from "@capakraken/shared";
import { GERMAN_FEDERAL_STATES } from "@nexus/shared";
import { createPortal } from "react-dom";
import { formatCents, formatDateLong } from "~/lib/format.js";
@@ -204,7 +204,9 @@ function HeatmapBreakdownList({ breakdown }: { breakdown: HeatmapHoverData["brea
entry.role,
entry.responsiblePerson ? `Lead: ${entry.responsiblePerson}` : null,
entry.orderType,
].filter(Boolean).join(" · ")}
]
.filter(Boolean)
.join(" · ")}
</div>
{entry.startDate && entry.endDate ? (
<div className="text-[10px] text-gray-500">
@@ -248,16 +250,12 @@ function VacationSummary({
<div className="mt-0.5 text-[11px] text-brand-200/80">
{formatDateLong(vacation.startDate)} to {formatDateLong(vacation.endDate)}
</div>
{holidayMeta ? (
<div className="mt-1 text-[11px] text-brand-100/75">{holidayMeta}</div>
) : null}
{holidayMeta ? <div className="mt-1 text-[11px] text-brand-100/75">{holidayMeta}</div> : null}
{holidayLocationBasis ? (
<div className="mt-1 text-[11px] text-brand-100/85">{holidayLocationBasis}</div>
) : null}
{isPublicHoliday && vacation.calendarName ? (
<div className="mt-1 text-[11px] text-brand-200/60">
Calendar: {vacation.calendarName}
</div>
<div className="mt-1 text-[11px] text-brand-200/60">Calendar: {vacation.calendarName}</div>
) : null}
{vacation.note && !isPublicHoliday ? (
<div className="mt-1 text-[11px] text-brand-200/60">{vacation.note}</div>
@@ -1,4 +1,4 @@
import { DAY_KEYS, type WeekdayAvailability } from "@capakraken/shared";
import { DAY_KEYS, type WeekdayAvailability } from "@nexus/shared";
import type { TimelineAssignmentEntry } from "./TimelineContext.js";
export const DEFAULT_TIMELINE_AVAILABILITY: WeekdayAvailability = {
@@ -73,7 +73,9 @@ export function isAllocationScheduledOnDate(
return false;
}
return getTimelineAvailabilityHoursForDate(getEffectiveAllocationAvailability(allocation), target) > 0;
return (
getTimelineAvailabilityHoursForDate(getEffectiveAllocationAvailability(allocation), target) > 0
);
}
export function buildAllocationWorkingDaySegments(
@@ -1,4 +1,4 @@
import type { WeekdayAvailability } from "@capakraken/shared";
import type { WeekdayAvailability } from "@nexus/shared";
import type { TimelineAssignmentEntry, VacationEntry } from "./TimelineContext.js";
import {
DEFAULT_TIMELINE_AVAILABILITY,
@@ -54,16 +54,10 @@ function buildAbsenceFractionsByDate(
}
const overlapStart = new Date(
Math.max(
toLocalStartOfDay(vacation.startDate).getTime(),
normalizedStart.getTime(),
),
Math.max(toLocalStartOfDay(vacation.startDate).getTime(), normalizedStart.getTime()),
);
const overlapEnd = new Date(
Math.min(
toLocalStartOfDay(vacation.endDate).getTime(),
normalizedEnd.getTime(),
),
Math.min(toLocalStartOfDay(vacation.endDate).getTime(), normalizedEnd.getTime()),
);
if (overlapStart > overlapEnd) {
@@ -1,4 +1,4 @@
import { MILLISECONDS_PER_DAY } from "@capakraken/shared";
import { MILLISECONDS_PER_DAY } from "@nexus/shared";
import type { MutableRefObject, RefObject } from "react";
import type { TimelineDemandEntry, VacationEntry } from "./TimelineContext.js";
import type { DemandHoverData } from "./TimelineTooltip.js";
@@ -50,9 +50,7 @@ export function findVacationHit<T extends { startDate: Date | string; endDate: D
);
}
export function collectResourcesWithVacations(
vacationsByResource: Map<string, VacationEntry[]>,
) {
export function collectResourcesWithVacations(vacationsByResource: Map<string, VacationEntry[]>) {
const result = new Set<string>();
for (const [resourceId, vacations] of vacationsByResource) {
if (vacations.length > 0) {
@@ -62,28 +60,20 @@ export function collectResourcesWithVacations(
return result;
}
export function scheduleVacationHoverUpdate<T extends { id: string; startDate: Date | string; endDate: Date | string }>(
args: {
frameRef: MutableRefObject<number | null>;
hoveredKeyRef: MutableRefObject<string | null>;
resourceId: string;
clientX: number;
rect: DOMRect;
xToDate: (clientX: number, rect: DOMRect) => Date;
vacations: T[];
onHoverChange: (vacation: T | null) => void;
},
) {
const {
frameRef,
hoveredKeyRef,
resourceId,
clientX,
rect,
xToDate,
vacations,
onHoverChange,
} = args;
export function scheduleVacationHoverUpdate<
T extends { id: string; startDate: Date | string; endDate: Date | string },
>(args: {
frameRef: MutableRefObject<number | null>;
hoveredKeyRef: MutableRefObject<string | null>;
resourceId: string;
clientX: number;
rect: DOMRect;
xToDate: (clientX: number, rect: DOMRect) => Date;
vacations: T[];
onHoverChange: (vacation: T | null) => void;
}) {
const { frameRef, hoveredKeyRef, resourceId, clientX, rect, xToDate, vacations, onHoverChange } =
args;
if (frameRef.current !== null) return;
@@ -102,7 +92,10 @@ export function scheduleVacationHoverUpdate<T extends { id: string; startDate: D
export function buildDemandHoverData(demand: TimelineDemandEntry): DemandHoverData {
const startDate = new Date(demand.startDate);
const endDate = new Date(demand.endDate);
const days = Math.max(1, Math.round((endDate.getTime() - startDate.getTime()) / MILLISECONDS_PER_DAY) + 1);
const days = Math.max(
1,
Math.round((endDate.getTime() - startDate.getTime()) / MILLISECONDS_PER_DAY) + 1,
);
return {
roleName: demand.roleEntity?.name ?? demand.role ?? "Open demand",
@@ -1,6 +1,6 @@
"use client";
import { MILLISECONDS_PER_DAY } from "@capakraken/shared";
import { MILLISECONDS_PER_DAY } from "@nexus/shared";
import { clsx } from "clsx";
import React from "react";
import type { TimelineAssignmentEntry } from "./TimelineContext.js";
@@ -2,7 +2,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen, within } from "~/test-utils.js";
import userEvent from "@testing-library/user-event";
import { ColumnTogglePanel } from "./ColumnTogglePanel.js";
import type { ColumnDef } from "@capakraken/shared";
import type { ColumnDef } from "@nexus/shared";
// Mock useAnchoredOverlay in jsdom there is no real layout engine, so we
// just return stable refs and a no-op handleOpenChange.
@@ -2,7 +2,7 @@
import { createPortal } from "react-dom";
import { useState, useRef, useCallback } from "react";
import type { ColumnDef } from "@capakraken/shared";
import type { ColumnDef } from "@nexus/shared";
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
interface ColumnTogglePanelProps {
@@ -19,11 +19,12 @@ export function ColumnTogglePanel({
defaultKeys,
}: ColumnTogglePanelProps) {
const [open, setOpen] = useState(false);
const { triggerRef, panelRef, position, handleOpenChange } = useAnchoredOverlay<HTMLButtonElement>({
open,
onClose: () => setOpen(false),
align: "end",
});
const { triggerRef, panelRef, position, handleOpenChange } =
useAnchoredOverlay<HTMLButtonElement>({
open,
onClose: () => setOpen(false),
align: "end",
});
const dragKey = useRef<string | null>(null);
@@ -40,19 +41,22 @@ export function ColumnTogglePanel({
onSetVisible(defaultKeys);
}
const reorder = useCallback((fromKey: string, toKey: string) => {
if (fromKey === toKey) return;
const next = [...visibleKeys];
const from = next.indexOf(fromKey);
const to = next.indexOf(toKey);
if (from === -1 || to === -1) return;
next.splice(from, 1);
next.splice(to, 0, fromKey);
onSetVisible(next);
}, [visibleKeys, onSetVisible]);
const reorder = useCallback(
(fromKey: string, toKey: string) => {
if (fromKey === toKey) return;
const next = [...visibleKeys];
const from = next.indexOf(fromKey);
const to = next.indexOf(toKey);
if (from === -1 || to === -1) return;
next.splice(from, 1);
next.splice(to, 0, fromKey);
onSetVisible(next);
},
[visibleKeys, onSetVisible],
);
const builtins = allColumns.filter((c) => !c.isCustom);
const customs = allColumns.filter((c) => c.isCustom);
const customs = allColumns.filter((c) => c.isCustom);
const handleToggleOpen = useCallback(() => {
setOpen((current) => {
@@ -78,9 +82,9 @@ export function ColumnTogglePanel({
>
{/* columns icon */}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
<rect x="1" y="3" width="4" height="10" rx="1" stroke="currentColor" strokeWidth="1.5"/>
<rect x="6" y="3" width="4" height="10" rx="1" stroke="currentColor" strokeWidth="1.5"/>
<rect x="11" y="3" width="4" height="10" rx="1" stroke="currentColor" strokeWidth="1.5"/>
<rect x="1" y="3" width="4" height="10" rx="1" stroke="currentColor" strokeWidth="1.5" />
<rect x="6" y="3" width="4" height="10" rx="1" stroke="currentColor" strokeWidth="1.5" />
<rect x="11" y="3" width="4" height="10" rx="1" stroke="currentColor" strokeWidth="1.5" />
</svg>
</button>
@@ -1,7 +1,7 @@
"use client";
import { FieldType } from "@capakraken/shared";
import type { BlueprintFieldDefinition } from "@capakraken/shared";
import { FieldType } from "@nexus/shared";
import type { BlueprintFieldDefinition } from "@nexus/shared";
import type { CustomFieldFilter } from "~/hooks/useFilters.js";
interface Props {
@@ -51,7 +51,9 @@ export function CustomFieldFilterBar({ filterableFields, activeFilters, onSetFil
>
<option value="">{field.label}: any</option>
{field.options.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label || opt.value}</option>
<option key={opt.value} value={opt.value}>
{opt.label || opt.value}
</option>
))}
</select>
);
+7 -14
View File
@@ -2,7 +2,7 @@
import { useCallback } from "react";
import { trpc } from "~/lib/trpc/client.js";
import type { ProjectStatus } from "@capakraken/shared";
import type { ProjectStatus } from "@nexus/shared";
import { EntityCombobox } from "./EntityCombobox.js";
type ProjectItem = { id: string; shortCode: string; name: string };
@@ -16,10 +16,7 @@ interface ProjectComboboxProps {
className?: string;
}
export function ProjectCombobox({
status,
...props
}: ProjectComboboxProps) {
export function ProjectCombobox({ status, ...props }: ProjectComboboxProps) {
const useSearchQuery = (search: string, enabled: boolean) => {
const { data } = trpc.project.list.useQuery(
{ search: search || undefined, limit: 15, ...(status ? { status } : {}) },
@@ -29,22 +26,18 @@ export function ProjectCombobox({
};
const useSelectedQuery = (_id: string | null, enabled: boolean) => {
const { data } = trpc.project.list.useQuery(
{ limit: 500 },
{ enabled, staleTime: 60_000 },
);
const { data } = trpc.project.list.useQuery({ limit: 500 }, { enabled, staleTime: 60_000 });
return { data: (data?.projects ?? []) as ProjectItem[] };
};
const getLabel = useCallback(
(p: ProjectItem) => `${p.shortCode} \u2014 ${p.name}`,
[],
);
const getLabel = useCallback((p: ProjectItem) => `${p.shortCode} \u2014 ${p.name}`, []);
const renderItem = useCallback(
(p: ProjectItem) => (
<>
<span className="font-medium text-xs text-gray-400 dark:text-gray-500 mr-1.5">{p.shortCode}</span>
<span className="font-medium text-xs text-gray-400 dark:text-gray-500 mr-1.5">
{p.shortCode}
</span>
<span>{p.name}</span>
</>
),
@@ -1,14 +1,23 @@
"use client";
import { useState } from "react";
import { VacationStatus, VacationType } from "@capakraken/shared";
import { VacationStatus, VacationType } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { VacationModal } from "./VacationModal.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { BalanceCard } from "./BalanceCard.js";
import { VacationCalendar } from "./VacationCalendar.js";
import { VACATION_STATUS_BADGE as STATUS_BADGE, VACATION_TYPE_LABELS as TYPE_LABELS, VACATION_TYPE_BADGE } from "~/lib/status-styles.js";
import { getHolidayBasis, getHolidayBreakdown, getRequestedDays, type VacationExplainabilityEntry } from "./vacationExplainability.js";
import {
VACATION_STATUS_BADGE as STATUS_BADGE,
VACATION_TYPE_LABELS as TYPE_LABELS,
VACATION_TYPE_BADGE,
} from "~/lib/status-styles.js";
import {
getHolidayBasis,
getHolidayBreakdown,
getRequestedDays,
type VacationExplainabilityEntry,
} from "./vacationExplainability.js";
type VacationListItem = VacationExplainabilityEntry & {
id: string;
@@ -41,7 +50,11 @@ export function MyVacationsClient() {
refetch: () => Promise<unknown>;
};
const { data: vacations, isLoading, refetch } = vacationListQuery(
const {
data: vacations,
isLoading,
refetch,
} = vacationListQuery(
{ limit: 200, ...(resourceId ? { resourceId } : {}) },
{ enabled: !!resourceId, staleTime: 15_000 },
);
@@ -61,7 +74,9 @@ export function MyVacationsClient() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">My Vacations</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Manage your personal vacation requests</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Manage your personal vacation requests
</p>
</div>
<button
type="button"
@@ -75,13 +90,27 @@ export function MyVacationsClient() {
{!resourceId && (
<div className="rounded-xl border border-amber-200 bg-amber-50 dark:bg-amber-950/20 dark:border-amber-800 p-8 flex flex-col items-center text-center gap-3">
<svg className="h-10 w-10 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
<svg
className="h-10 w-10 text-amber-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
/>
</svg>
<div>
<p className="text-sm font-semibold text-amber-800 dark:text-amber-300">Account not linked to a resource</p>
<p className="text-sm font-semibold text-amber-800 dark:text-amber-300">
Account not linked to a resource
</p>
<p className="text-sm text-amber-700 dark:text-amber-400 mt-1">
Your user account has not been connected to a resource record yet. Vacation tracking requires this link.<br />
Your user account has not been connected to a resource record yet. Vacation tracking
requires this link.
<br />
Please ask an administrator to open your resource profile and link it to your account.
</p>
</div>
@@ -89,14 +118,10 @@ export function MyVacationsClient() {
)}
{/* Balance card */}
{resourceId && (
<BalanceCard resourceId={resourceId} />
)}
{resourceId && <BalanceCard resourceId={resourceId} />}
{/* Calendar */}
{resourceId && vacationList.length > 0 && (
<VacationCalendar vacations={vacationList} />
)}
{resourceId && vacationList.length > 0 && <VacationCalendar vacations={vacationList} />}
{/* Vacation list */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
@@ -109,20 +134,28 @@ export function MyVacationsClient() {
<thead>
<tr className="border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
<th className="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">
Type <InfoTooltip content="ANNUAL = paid annual leave · SICK = sick leave · PUBLIC_HOLIDAY = public holiday · OTHER = other leave types." />
Type{" "}
<InfoTooltip content="ANNUAL = paid annual leave · SICK = sick leave · PUBLIC_HOLIDAY = public holiday · OTHER = other leave types." />
</th>
<th className="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">
Start
</th>
<th className="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">
End
</th>
<th className="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">Start</th>
<th className="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">End</th>
<th className="text-right px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">
<span className="inline-flex items-center justify-end gap-0.5">
Days <InfoTooltip content="Calendar days from start to end date (inclusive). Shows 0.5 for half-day requests (½ indicator on start date)." />
Days{" "}
<InfoTooltip content="Calendar days from start to end date (inclusive). Shows 0.5 for half-day requests (½ indicator on start date)." />
</span>
</th>
<th className="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">
Status <InfoTooltip content="PENDING = awaiting manager approval · APPROVED = confirmed leave · REJECTED = declined by manager · CANCELLED = withdrawn by employee." />
Status{" "}
<InfoTooltip content="PENDING = awaiting manager approval · APPROVED = confirmed leave · REJECTED = declined by manager · CANCELLED = withdrawn by employee." />
</th>
<th className="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">
Note <InfoTooltip content="Your note on the request, or the manager's rejection reason if declined." />
Note{" "}
<InfoTooltip content="Your note on the request, or the manager's rejection reason if declined." />
</th>
<th className="px-4 py-3" />
</tr>
@@ -142,12 +175,18 @@ export function MyVacationsClient() {
return (
<tr key={v.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="px-4 py-3">
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${VACATION_TYPE_BADGE[type] ?? "bg-gray-100 text-gray-600"}`}>
<span
className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${VACATION_TYPE_BADGE[type] ?? "bg-gray-100 text-gray-600"}`}
>
{TYPE_LABELS[type] ?? type}
</span>
</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{start.toLocaleDateString("en-GB")}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{end.toLocaleDateString("en-GB")}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
{start.toLocaleDateString("en-GB")}
</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
{end.toLocaleDateString("en-GB")}
</td>
<td className="px-4 py-3 text-right text-gray-600 dark:text-gray-400">
<div className="space-y-1">
<div>{requestedDays}</div>
@@ -164,7 +203,9 @@ export function MyVacationsClient() {
</div>
</td>
<td className="px-4 py-3">
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[status] ?? ""}`}>
<span
className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[status] ?? ""}`}
>
{status}
</span>
</td>
@@ -181,16 +222,23 @@ export function MyVacationsClient() {
)}
{affectsBalance && holidayBreakdown.length > 0 && (
<div className="text-[11px] text-gray-500 dark:text-gray-400">
Excluded holidays: {holidayBreakdown.map((holiday) => `${holiday.date} (${holiday.source})`).join(", ")}
Excluded holidays:{" "}
{holidayBreakdown
.map((holiday) => `${holiday.date} (${holiday.source})`)
.join(", ")}
</div>
)}
</div>
)}
{!v.rejectionReason && affectsBalance && deductedDays !== null && deductedDays !== requestedDays && holidayBreakdown.length === 0 && (
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
Local holiday-adjusted deduction snapshot applied.
</div>
)}
{!v.rejectionReason &&
affectsBalance &&
deductedDays !== null &&
deductedDays !== requestedDays &&
holidayBreakdown.length === 0 && (
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
Local holiday-adjusted deduction snapshot applied.
</div>
)}
{!v.rejectionReason && !affectsBalance && (
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
This leave type does not reduce annual vacation entitlement.
@@ -198,7 +246,8 @@ export function MyVacationsClient() {
)}
</td>
<td className="px-4 py-3 text-right">
{(status === VacationStatus.PENDING || status === VacationStatus.APPROVED) && (
{(status === VacationStatus.PENDING ||
status === VacationStatus.APPROVED) && (
<button
type="button"
onClick={() => cancelMutation.mutate({ id: v.id })}
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { GERMAN_FEDERAL_STATES } from "@capakraken/shared";
import { GERMAN_FEDERAL_STATES } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
export function PublicHolidayBatch() {
@@ -9,16 +9,23 @@ export function PublicHolidayBatch() {
const [federalState, setFederalState] = useState("BY");
const [chapter, setChapter] = useState("");
const [replaceExisting, setReplaceExisting] = useState(false);
const [result, setResult] = useState<{ created: number; holidays?: number; resources?: number } | null>(null);
const [result, setResult] = useState<{
created: number;
holidays?: number;
resources?: number;
} | null>(null);
const { data: resources } = trpc.resource.directory.useQuery(
{ isActive: true, limit: 500 },
{ staleTime: 60_000 },
);
const resourceList = (resources?.resources ?? []) as Array<{ id: string; chapter?: string | null }>;
const resourceList = (resources?.resources ?? []) as Array<{
id: string;
chapter?: string | null;
}>;
const chapters = Array.from(
new Set(resourceList.map((r) => r.chapter).filter(Boolean) as string[])
new Set(resourceList.map((r) => r.chapter).filter(Boolean) as string[]),
).sort();
const mutation = trpc.vacation.batchCreatePublicHolidays.useMutation({
@@ -38,7 +45,9 @@ export function PublicHolidayBatch() {
return (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 space-y-4">
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100">Batch Create Public Holidays</h3>
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100">
Batch Create Public Holidays
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
Creates public holidays as APPROVED vacation entries for all resources (or a chapter).
</p>
@@ -46,7 +55,9 @@ export function PublicHolidayBatch() {
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Year</label>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Year
</label>
<input
type="number"
value={year}
@@ -57,7 +68,9 @@ export function PublicHolidayBatch() {
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Federal State</label>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Federal State
</label>
<select
value={federalState}
onChange={(e) => setFederalState(e.target.value)}
@@ -65,12 +78,16 @@ export function PublicHolidayBatch() {
>
<option value="">Federal only</option>
{Object.entries(GERMAN_FEDERAL_STATES).map(([abbr, name]) => (
<option key={abbr} value={abbr}>{name} ({abbr})</option>
<option key={abbr} value={abbr}>
{name} ({abbr})
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Chapter (optional)</label>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Chapter (optional)
</label>
<select
value={chapter}
onChange={(e) => setChapter(e.target.value)}
@@ -78,7 +95,9 @@ export function PublicHolidayBatch() {
>
<option value="">All chapters</option>
{chapters.map((c) => (
<option key={c} value={c}>{c}</option>
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
@@ -105,13 +124,14 @@ export function PublicHolidayBatch() {
{result && (
<span className="text-sm text-emerald-600 dark:text-emerald-400">
Created {result.created} entries{result.holidays ? ` (${result.holidays} holidays × ${result.resources ?? 0} resources)` : ""}
Created {result.created} entries
{result.holidays
? ` (${result.holidays} holidays × ${result.resources ?? 0} resources)`
: ""}
</span>
)}
{mutation.error && (
<span className="text-sm text-red-500">{mutation.error.message}</span>
)}
{mutation.error && <span className="text-sm text-red-500">{mutation.error.message}</span>}
</div>
</form>
</div>
@@ -1,14 +1,24 @@
"use client";
import { useState } from "react";
import { VacationStatus } from "@capakraken/shared";
import { VacationStatus } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { VACATION_CALENDAR_COLORS } from "~/lib/status-styles.js";
const MONTH_NAMES = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
function isoDate(d: Date | string): string {
@@ -71,26 +81,38 @@ export function TeamCalendar() {
const today = now.toISOString().slice(0, 10);
function prevMonth() {
if (month === 0) { setMonth(11); setYear(y => y - 1); }
else setMonth(m => m - 1);
if (month === 0) {
setMonth(11);
setYear((y) => y - 1);
} else setMonth((m) => m - 1);
}
function nextMonth() {
if (month === 11) { setMonth(0); setYear(y => y + 1); }
else setMonth(m => m + 1);
if (month === 11) {
setMonth(0);
setYear((y) => y + 1);
} else setMonth((m) => m + 1);
}
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Toolbar */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-100 dark:border-gray-700 flex-wrap">
<button type="button" onClick={prevMonth} className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-500 dark:text-gray-400">
<button
type="button"
onClick={prevMonth}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-500 dark:text-gray-400"
>
</button>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100 min-w-[120px] text-center">
{MONTH_NAMES[month]} {year}
</span>
<button type="button" onClick={nextMonth} className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-500 dark:text-gray-400">
<button
type="button"
onClick={nextMonth}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-500 dark:text-gray-400"
>
</button>
@@ -103,7 +125,9 @@ export function TeamCalendar() {
>
<option value="">All chapters</option>
{chapters.map((c) => (
<option key={c} value={c}>{c}</option>
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
@@ -114,7 +138,13 @@ export function TeamCalendar() {
<thead>
<tr className="bg-gray-50 dark:bg-gray-900 border-b border-gray-100 dark:border-gray-700">
<th className="sticky left-0 z-10 bg-gray-50 dark:bg-gray-900 px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 w-36 border-r border-gray-100 dark:border-gray-700">
<span className="inline-flex items-center gap-0.5">Resource<InfoTooltip content="Matrix view: each row is a resource, each column is a calendar day. Colored cells indicate vacation days (color = type, faded = pending)." width="w-72" /></span>
<span className="inline-flex items-center gap-0.5">
Resource
<InfoTooltip
content="Matrix view: each row is a resource, each column is a calendar day. Colored cells indicate vacation days (color = type, faded = pending)."
width="w-72"
/>
</span>
</th>
{days.map((d) => {
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
@@ -181,7 +211,10 @@ export function TeamCalendar() {
{/* Legend */}
<div className="px-4 py-2 border-t border-gray-100 dark:border-gray-700 flex gap-4 flex-wrap">
{Object.entries(VACATION_CALENDAR_COLORS).map(([type, color]) => (
<span key={type} className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<span
key={type}
className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400"
>
<span className={`${color} w-3 h-3 rounded-sm inline-block`} />
{type.replace("_", " ")}
</span>
@@ -1,9 +1,12 @@
"use client";
import { useState } from "react";
import { VacationStatus } from "@capakraken/shared";
import { VacationStatus } from "@nexus/shared";
import { VACATION_CALENDAR_COLORS } from "~/lib/status-styles.js";
import { buildVacationExplainabilityTooltip, type VacationExplainabilityEntry } from "./vacationExplainability.js";
import {
buildVacationExplainabilityTooltip,
type VacationExplainabilityEntry,
} from "./vacationExplainability.js";
interface VacationEntry extends VacationExplainabilityEntry {
id: string;
@@ -29,8 +32,18 @@ const STATUS_OPACITY: Record<string, string> = {
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const MONTH_NAMES = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
function isoDate(d: Date | string): string {
@@ -55,24 +68,33 @@ function getDatesInRange(start: Date | string, end: Date | string): Set<string>
return dates;
}
export function VacationCalendar({ vacations, year = new Date().getFullYear(), initialMonth = new Date().getMonth() }: VacationCalendarProps) {
export function VacationCalendar({
vacations,
year = new Date().getFullYear(),
initialMonth = new Date().getMonth(),
}: VacationCalendarProps) {
const [month, setMonth] = useState(initialMonth);
const [currentYear, setCurrentYear] = useState(year);
function prevMonth() {
if (month === 0) { setMonth(11); setCurrentYear(y => y - 1); }
else setMonth(m => m - 1);
if (month === 0) {
setMonth(11);
setCurrentYear((y) => y - 1);
} else setMonth((m) => m - 1);
}
function nextMonth() {
if (month === 11) { setMonth(0); setCurrentYear(y => y + 1); }
else setMonth(m => m + 1);
if (month === 11) {
setMonth(0);
setCurrentYear((y) => y + 1);
} else setMonth((m) => m + 1);
}
// Build a set of date → vacation entries for fast lookup
const dateMap = new Map<string, VacationEntry[]>();
for (const v of vacations) {
if ([VacationStatus.CANCELLED, VacationStatus.REJECTED].includes(v.status as VacationStatus)) continue;
if ([VacationStatus.CANCELLED, VacationStatus.REJECTED].includes(v.status as VacationStatus))
continue;
const dates = getDatesInRange(v.startDate, v.endDate);
for (const d of dates) {
const existing = dateMap.get(d) ?? [];
@@ -98,13 +120,21 @@ export function VacationCalendar({ vacations, year = new Date().getFullYear(), i
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-700">
<button type="button" onClick={prevMonth} className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-500 dark:text-gray-400">
<button
type="button"
onClick={prevMonth}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-500 dark:text-gray-400"
>
</button>
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{MONTH_NAMES[month]} {currentYear}
</h3>
<button type="button" onClick={nextMonth} className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-500 dark:text-gray-400">
<button
type="button"
onClick={nextMonth}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-500 dark:text-gray-400"
>
</button>
</div>
@@ -112,7 +142,10 @@ export function VacationCalendar({ vacations, year = new Date().getFullYear(), i
{/* Day names */}
<div className="grid grid-cols-7 border-b border-gray-100 dark:border-gray-700">
{DAYS.map((d) => (
<div key={d} className="px-2 py-1.5 text-center text-xs font-medium text-gray-400 dark:text-gray-500">
<div
key={d}
className="px-2 py-1.5 text-center text-xs font-medium text-gray-400 dark:text-gray-500"
>
{d}
</div>
))}
@@ -122,7 +155,12 @@ export function VacationCalendar({ vacations, year = new Date().getFullYear(), i
<div className="grid grid-cols-7">
{cells.map((day, idx) => {
if (!day) {
return <div key={`empty-${idx}`} className="p-1 min-h-[60px] border-b border-r border-gray-50 dark:border-gray-700 bg-gray-50/30 dark:bg-gray-900/30" />;
return (
<div
key={`empty-${idx}`}
className="p-1 min-h-[60px] border-b border-r border-gray-50 dark:border-gray-700 bg-gray-50/30 dark:bg-gray-900/30"
/>
);
}
const dateStr = `${currentYear}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
@@ -135,7 +173,9 @@ export function VacationCalendar({ vacations, year = new Date().getFullYear(), i
key={dateStr}
className={`p-1 min-h-[60px] border-b border-r border-gray-50 dark:border-gray-700 ${isToday ? "bg-brand-50" : ""}`}
>
<span className={`text-xs font-medium block mb-1 ${isToday ? "text-brand-700" : "text-gray-500 dark:text-gray-400"}`}>
<span
className={`text-xs font-medium block mb-1 ${isToday ? "text-brand-700" : "text-gray-500 dark:text-gray-400"}`}
>
{day}
</span>
<div className="space-y-0.5">
@@ -148,14 +188,18 @@ export function VacationCalendar({ vacations, year = new Date().getFullYear(), i
<div
key={v.id + dateStr}
className={`${colorClass} ${opacityClass} text-white text-xs px-1 rounded truncate`}
title={[`${name}${v.type} (${v.status})`, explainabilityTitle].filter(Boolean).join("\n")}
title={[`${name}${v.type} (${v.status})`, explainabilityTitle]
.filter(Boolean)
.join("\n")}
>
{name.split(" ")[0]}
</div>
);
})}
{dayVacations.length > 3 && (
<div className="text-xs text-gray-400 dark:text-gray-500 pl-1">+{dayVacations.length - 3}</div>
<div className="text-xs text-gray-400 dark:text-gray-500 pl-1">
+{dayVacations.length - 3}
</div>
)}
</div>
</div>
@@ -166,7 +210,10 @@ export function VacationCalendar({ vacations, year = new Date().getFullYear(), i
{/* Legend */}
<div className="px-4 py-2 border-t border-gray-100 dark:border-gray-700 flex gap-4 flex-wrap">
{Object.entries(VACATION_CALENDAR_COLORS).map(([type, color]) => (
<span key={type} className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<span
key={type}
className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400"
>
<span className={`${color} w-3 h-3 rounded-sm inline-block`} />
{type.replace("_", " ")}
</span>
@@ -2,7 +2,7 @@
import { useState, useCallback } from "react";
import Link from "next/link";
import { VacationStatus, VacationType } from "@capakraken/shared";
import { VacationStatus, VacationType } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { VacationModal } from "./VacationModal.js";
import { TeamCalendar } from "./TeamCalendar.js";
@@ -11,7 +11,11 @@ import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { useTableSort } from "~/hooks/useTableSort.js";
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { VACATION_STATUS_BADGE as STATUS_BADGE, VACATION_TYPE_LABELS as TYPE_LABELS, VACATION_TYPE_BADGE } from "~/lib/status-styles.js";
import {
VACATION_STATUS_BADGE as STATUS_BADGE,
VACATION_TYPE_LABELS as TYPE_LABELS,
VACATION_TYPE_BADGE,
} from "~/lib/status-styles.js";
import { SuccessToast } from "~/components/ui/SuccessToast.js";
type VacationStatusFilter = VacationStatus | "ALL";
@@ -27,14 +31,23 @@ export function VacationClient() {
const [selected, setSelected] = useState<Set<string>>(new Set());
const [batchRejectReason, setBatchRejectReason] = useState("");
const [showBatchRejectInput, setShowBatchRejectInput] = useState(false);
const [toast, setToast] = useState<{ show: boolean; message: string; variant: "success" | "warning" }>({
const [toast, setToast] = useState<{
show: boolean;
message: string;
variant: "success" | "warning";
}>({
show: false,
message: "",
variant: "success",
});
const clearToast = useCallback(() => setToast((t) => ({ ...t, show: false })), []);
const { data: vacations, isLoading, error: vacationError, refetch } = trpc.vacation.list.useQuery(
const {
data: vacations,
isLoading,
error: vacationError,
refetch,
} = trpc.vacation.list.useQuery(
{
...(statusFilter !== "ALL" ? { status: statusFilter } : {}),
...(typeFilter !== "ALL" ? { type: typeFilter } : {}),
@@ -65,7 +78,9 @@ export function VacationClient() {
const approveMutation = trpc.vacation.approve.useMutation({ onSuccess: invalidateAll });
const rejectMutation = trpc.vacation.reject.useMutation({ onSuccess: invalidateAll });
const cancelMutation = trpc.vacation.cancel.useMutation({ onSuccess: () => utils.vacation.list.invalidate() });
const cancelMutation = trpc.vacation.cancel.useMutation({
onSuccess: () => utils.vacation.list.invalidate(),
});
const batchApproveMutation = trpc.vacation.batchApprove.useMutation({
onSuccess: async () => {
setSelected(new Set());
@@ -83,7 +98,11 @@ export function VacationClient() {
},
});
const resourceList = (resources?.resources ?? []) as unknown as Array<{ id: string; displayName: string; eid: string }>;
const resourceList = (resources?.resources ?? []) as unknown as Array<{
id: string;
displayName: string;
eid: string;
}>;
const vacationList = vacations ?? [];
const pendingList = pending ?? [];
@@ -98,7 +117,10 @@ export function VacationClient() {
function handleSort(field: string) {
if (field === "resource") {
toggle("resource", (v) => (v.resource as { displayName: string } | undefined)?.displayName ?? null);
toggle(
"resource",
(v) => (v.resource as { displayName: string } | undefined)?.displayName ?? null,
);
} else {
toggle(field);
}
@@ -110,12 +132,25 @@ export function VacationClient() {
setResourceFilter("");
}
const selectedResourceName = resourceFilter ? resourceList.find((r) => r.id === resourceFilter)?.displayName : null;
const selectedResourceName = resourceFilter
? resourceList.find((r) => r.id === resourceFilter)?.displayName
: null;
const chips = [
...(statusFilter !== "ALL" ? [{ label: `Status: ${statusFilter}`, onRemove: () => setStatusFilter("ALL") }] : []),
...(typeFilter !== "ALL" ? [{ label: `Type: ${TYPE_LABELS[typeFilter]}`, onRemove: () => setTypeFilter("ALL") }] : []),
...(resourceFilter ? [{ label: `Resource: ${selectedResourceName ?? resourceFilter}`, onRemove: () => setResourceFilter("") }] : []),
...(statusFilter !== "ALL"
? [{ label: `Status: ${statusFilter}`, onRemove: () => setStatusFilter("ALL") }]
: []),
...(typeFilter !== "ALL"
? [{ label: `Type: ${TYPE_LABELS[typeFilter]}`, onRemove: () => setTypeFilter("ALL") }]
: []),
...(resourceFilter
? [
{
label: `Resource: ${selectedResourceName ?? resourceFilter}`,
onRemove: () => setResourceFilter(""),
},
]
: []),
];
const pendingIds = pendingList.map((v) => v.id);
@@ -132,15 +167,25 @@ export function VacationClient() {
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
<SuccessToast show={toast.show} message={toast.message} variant={toast.variant} onDone={clearToast} />
<SuccessToast
show={toast.show}
message={toast.message}
variant={toast.variant}
onDone={clearToast}
/>
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Vacations</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Manage vacation requests and approvals</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Manage vacation requests and approvals
</p>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Regional public holidays are maintained in{" "}
<Link href="/admin/vacations" className="font-medium text-brand-700 hover:text-brand-800 dark:text-brand-400 dark:hover:text-brand-300">
<Link
href="/admin/vacations"
className="font-medium text-brand-700 hover:text-brand-800 dark:text-brand-400 dark:hover:text-brand-300"
>
Holiday Calendars
</Link>
.
@@ -232,7 +277,9 @@ export function VacationClient() {
onClick={() =>
batchRejectMutation.mutate({
ids: selectedPending,
...(batchRejectReason.trim() ? { rejectionReason: batchRejectReason.trim() } : {}),
...(batchRejectReason.trim()
? { rejectionReason: batchRejectReason.trim() }
: {}),
})
}
disabled={batchRejectMutation.isPending}
@@ -261,10 +308,18 @@ export function VacationClient() {
}}
className="rounded border-gray-300 dark:border-gray-600 text-amber-600 shrink-0"
/>
<span className="font-medium text-sm text-gray-900 dark:text-gray-100">{v.resource.displayName}</span>
<span className="text-xs text-gray-500 dark:text-gray-400">({v.resource.eid})</span>
<span className="font-medium text-sm text-gray-900 dark:text-gray-100">
{v.resource.displayName}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
({v.resource.eid})
</span>
<span className="mx-1 text-gray-300 dark:text-gray-600">·</span>
<span className={`inline-flex px-1.5 py-0.5 rounded-full text-xs font-medium ${VACATION_TYPE_BADGE[v.type as string] ?? "bg-gray-100 text-gray-600"}`}>{TYPE_LABELS[v.type as VacationType]}</span>
<span
className={`inline-flex px-1.5 py-0.5 rounded-full text-xs font-medium ${VACATION_TYPE_BADGE[v.type as string] ?? "bg-gray-100 text-gray-600"}`}
>
{TYPE_LABELS[v.type as VacationType]}
</span>
<span className="mx-1 text-gray-300 dark:text-gray-600">·</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{new Date(v.startDate).toLocaleDateString("en-GB")} {" "}
@@ -307,7 +362,9 @@ export function VacationClient() {
>
<option value="ALL">All statuses</option>
{Object.values(VacationStatus).map((s) => (
<option key={s} value={s}>{s}</option>
<option key={s} value={s}>
{s}
</option>
))}
</select>
<select
@@ -317,7 +374,9 @@ export function VacationClient() {
>
<option value="ALL">All types</option>
{Object.values(VacationType).map((t) => (
<option key={t} value={t}>{TYPE_LABELS[t]}</option>
<option key={t} value={t}>
{TYPE_LABELS[t]}
</option>
))}
</select>
<select
@@ -327,29 +386,38 @@ export function VacationClient() {
>
<option value="">All resources</option>
{resourceList.map((r) => (
<option key={r.id} value={r.id}>{r.displayName} ({r.eid})</option>
<option key={r.id} value={r.id}>
{r.displayName} ({r.eid})
</option>
))}
</select>
</div>
{/* Filter chips */}
{chips.length > 0 && (
<FilterChips chips={chips} onClearAll={clearAll} />
)}
{chips.length > 0 && <FilterChips chips={chips} onClearAll={clearAll} />}
{/* List */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{isLoading ? (
<div className="p-8 text-center text-sm text-gray-400">Loading</div>
) : vacationError ? (
<div className="p-8 text-center text-sm text-red-500">Error: {vacationError.message}</div>
<div className="p-8 text-center text-sm text-red-500">
Error: {vacationError.message}
</div>
) : vacationList.length === 0 ? (
<div className="p-8 text-center text-sm text-gray-400">No vacations found.</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
<SortableColumnHeader label="Resource" field="resource" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="The employee this vacation entry belongs to." />
<SortableColumnHeader
label="Resource"
field="resource"
sortField={sortField}
sortDir={sortDir}
onSort={handleSort}
tooltip="The employee this vacation entry belongs to."
/>
<th className="px-3 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">
<span className="inline-flex items-center gap-0.5">
<button
@@ -362,8 +430,22 @@ export function VacationClient() {
<InfoTooltip content="ANNUAL = paid annual leave · SICK = sick leave · PUBLIC_HOLIDAY = public holiday · OTHER = other leave types." />
</span>
</th>
<SortableColumnHeader label="Start" field="startDate" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="First day of the leave period (inclusive). Shows a half-day indicator if applicable." />
<SortableColumnHeader label="End" field="endDate" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="Last day of the leave period (inclusive)." />
<SortableColumnHeader
label="Start"
field="startDate"
sortField={sortField}
sortDir={sortDir}
onSort={handleSort}
tooltip="First day of the leave period (inclusive). Shows a half-day indicator if applicable."
/>
<SortableColumnHeader
label="End"
field="endDate"
sortField={sortField}
sortDir={sortDir}
onSort={handleSort}
tooltip="Last day of the leave period (inclusive)."
/>
<th className="px-3 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">
<span className="inline-flex items-center gap-0.5">
<button
@@ -377,7 +459,8 @@ export function VacationClient() {
</span>
</th>
<th className="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">
Note / Reason <InfoTooltip content="Employee's leave note, or manager's rejection reason if status is REJECTED." />
Note / Reason{" "}
<InfoTooltip content="Employee's leave note, or manager's rejection reason if status is REJECTED." />
</th>
<th className="px-4 py-3" />
</tr>
@@ -387,7 +470,10 @@ export function VacationClient() {
const type = v.type as VacationType;
const status = v.status as VacationStatus;
const resource = v.resource as { displayName: string; eid: string } | undefined;
const vExtra = v as unknown as { rejectionReason?: string | null; isHalfDay?: boolean };
const vExtra = v as unknown as {
rejectionReason?: string | null;
isHalfDay?: boolean;
};
return (
<tr key={v.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="px-4 py-3">
@@ -395,17 +481,23 @@ export function VacationClient() {
{resource?.displayName ?? "—"}
</span>
{resource?.eid && (
<span className="text-xs text-gray-400 dark:text-gray-500 ml-1">({resource.eid})</span>
<span className="text-xs text-gray-400 dark:text-gray-500 ml-1">
({resource.eid})
</span>
)}
</td>
<td className="px-4 py-3">
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${VACATION_TYPE_BADGE[type] ?? "bg-gray-100 text-gray-600"}`}>
<span
className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${VACATION_TYPE_BADGE[type] ?? "bg-gray-100 text-gray-600"}`}
>
{TYPE_LABELS[type] ?? type}
</span>
</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
{new Date(v.startDate).toLocaleDateString("en-GB")}
{vExtra.isHalfDay && <span className="ml-1 text-xs text-gray-400 dark:text-gray-500">½</span>}
{vExtra.isHalfDay && (
<span className="ml-1 text-xs text-gray-400 dark:text-gray-500">½</span>
)}
</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
{new Date(v.endDate).toLocaleDateString("en-GB")}
@@ -420,7 +512,9 @@ export function VacationClient() {
<td className="px-4 py-3 text-gray-400 dark:text-gray-500 text-xs max-w-xs truncate">
{vExtra.rejectionReason ? (
<span className="text-red-500">{vExtra.rejectionReason}</span>
) : (v.note ?? "—")}
) : (
(v.note ?? "—")
)}
</td>
<td className="px-4 py-3 text-right space-x-3">
{status === VacationStatus.CANCELLED ? (
@@ -2,7 +2,7 @@
import { useState } from "react";
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
import { VacationType } from "@capakraken/shared";
import { VacationType } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { DateInput } from "~/components/ui/DateInput.js";
import { useDebounce } from "~/hooks/useDebounce.js";
@@ -1,6 +1,6 @@
"use client";
import { MILLISECONDS_PER_DAY } from "@capakraken/shared";
import { MILLISECONDS_PER_DAY } from "@nexus/shared";
export const HOLIDAY_SOURCE_LABELS = {
CALENDAR: "Holiday Calendar",
@@ -27,7 +27,9 @@ function toSortedDateList(value: unknown): string[] {
: [];
}
export function getRequestedDays(vacation: Pick<VacationExplainabilityEntry, "startDate" | "endDate" | "isHalfDay">): number {
export function getRequestedDays(
vacation: Pick<VacationExplainabilityEntry, "startDate" | "endDate" | "isHalfDay">,
): number {
if (vacation.isHalfDay) {
return 0.5;
}
@@ -45,7 +47,9 @@ export function getHolidayBasis(vacation: VacationExplainabilityEntry): string[]
].filter((value): value is string => Boolean(value));
}
export function getHolidayBreakdown(vacation: VacationExplainabilityEntry): Array<{ date: string; source: string }> {
export function getHolidayBreakdown(
vacation: VacationExplainabilityEntry,
): Array<{ date: string; source: string }> {
const calendarDates = toSortedDateList(vacation.holidayCalendarDates);
const legacyDates = toSortedDateList(vacation.holidayLegacyPublicHolidayDates);
const uniqueDates = [...new Set([...calendarDates, ...legacyDates])].sort();
@@ -79,7 +83,9 @@ export function buildVacationExplainabilityTooltip(
}
if (holidayBreakdown.length > 0) {
lines.push(`Excluded holidays: ${holidayBreakdown.map((holiday) => `${holiday.date} (${holiday.source})`).join(", ")}`);
lines.push(
`Excluded holidays: ${holidayBreakdown.map((holiday) => `${holiday.date} (${holiday.source})`).join(", ")}`,
);
}
if ((vacation.type === "SICK" || vacation.type === "PUBLIC_HOLIDAY") && deductedDays === 0) {