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
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:
@@ -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">×</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"
|
||||
>
|
||||
×
|
||||
</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" : ""}`}>▶</span>
|
||||
<span
|
||||
className={`inline-block transition-transform ${line.spreadExpanded ? "rotate-90" : ""}`}
|
||||
>
|
||||
▶
|
||||
</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>
|
||||
|
||||
@@ -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) ·{" "}
|
||||
{formatMoney(baseline.totalCostCents)} total ·{" "}
|
||||
{baseline.totalHours.toFixed(0)}h
|
||||
{formatMoney(baseline.totalCostCents)} total · {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)} ·{" "}
|
||||
{a.hoursPerDay}h/d · {a.workingDays} days
|
||||
{formatDate(a.startDate)} - {formatDate(a.endDate)} · {a.hoursPerDay}
|
||||
h/d · {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"} · {d.headcount}x ·{" "}
|
||||
{d.hoursPerDay}h/d
|
||||
{d.roleName || "Unspecified"} · {d.headcount}x · {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"} — 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
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
×
|
||||
</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"
|
||||
>
|
||||
×
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user