10 Commits

Author SHA1 Message Date
Hartmut dc1e0bfb28 fix(auth): use full-page navigation after sign-in to prevent stale dashboard
CI / Architecture Guardrails (push) Failing after 2m25s
CI / Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Typecheck (push) Has started running
CI / Assistant Split Regression (push) Has started running
CI / Build (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Release Image / Build And Push Images (push) Has been cancelled
Docker Deploy Test / Fresh-Linux Docker Deploy (push) Has been cancelled
router.refresh() + router.push() left the React tree (incl. QueryClient
with staleTime: 60_000 and cached pre-auth query errors) and the Next.js
Router Cache alive across the login boundary. This caused the recurring
bug where the dashboard rendered with empty widgets until the user
pressed Ctrl+R. A full-page navigation guarantees a fresh server request
with the new session cookie and a clean client state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 10:00:07 +02:00
Hartmut 622c4135f5 fix(web): align @next/bundle-analyzer version with lockfile
package.json requested ^15.5.15 but pnpm-lock.yaml had ^16.2.3,
breaking container startup under --frozen-lockfile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 09:56:16 +02:00
Hartmut a1f79f6ccc fix(web): replace "as any" with safer cast in DemandPopover
The useQuery type cast was using `as any` behind a blanket eslint-disable.
Using an explicit function-shape cast is both safer and removes the lint
error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 07:48:33 +02:00
Hartmut 43bfd9ed0a test(api): add test coverage for project and resource mutation routers
Tests auth gates (unauthenticated, wrong role, missing permissions),
input validation (duplicate shortCodes/EIDs, primary role limits, schema
enforcement), and success paths with audit logging for create, update,
deactivate, batchUpdateCustomFields, and hardDelete procedures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 23:42:36 +02:00
Hartmut 8f7c69056f refactor(web): remove unnecessary "use client" from 6 pure-render components
BenchResourceCard, MobileProjectCard, MobileCapacityCard, DynamicFieldRenderer,
BudgetStatusBar, and TimelineHeader use no hooks, event handlers, or browser APIs —
they can be server components, reducing client bundle size.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 23:36:34 +02:00
Hartmut e08ee94546 fix(web): accessibility pass — add aria-labels, dialog roles, and pressed states
- KeyboardShortcutOverlay: add role="dialog", aria-modal, aria-labelledby, close button aria-label
- Timeline popovers (5 files): add aria-label="Close" to symbol-only close buttons
- TimelineToolbar: add aria-label to navigation and undo/redo icon buttons
- ComputationGraphClient: add aria-pressed to 2D/3D and view mode toggle buttons
- BulkEditModal: fix type mismatch from jsonb field hardening

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 23:27:56 +02:00
Hartmut 85c064ba32 fix(api): harden raw SQL jsonb field validation in batchUpdateCustomFields
Replace z.unknown() with z.union([z.string(), z.number(), z.boolean(), z.null()])
to constrain what values can be written into the dynamicFields jsonb column via
the $executeRaw path. Prevents arbitrary nested structures from being serialized.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 23:23:43 +02:00
Hartmut 74ed45ddfc fix(web): add missing loading and error states to MfaPromptBanner, Step1Identity, MobileSummaryClient
- MfaPromptBanner: silently hide on query error (non-critical advisory banner)
- Step1Identity: show skeleton placeholders while blueprint list loads
- MobileSummaryClient: add error state with retry button for dashboard queries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 23:22:18 +02:00
Hartmut c9be7c9bbf refactor(web): make SmtpSettingsPanel self-contained, eliminating prop drilling
SmtpSettingsPanel now owns its form state, save/test mutations, and feedback state
internally. Props reduced from 17 to 2 (initialSettings + onSettingsSaved callback).
Removes 7 useState declarations, 2 mutation definitions, and 1 handler from the parent.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 23:20:36 +02:00
Hartmut bfcadd2c52 refactor(web): decompose TimelineView, ReportBuilder, and ResourceModal into focused components
Extract overlay/popover JSX from TimelineView (1268→1037 lines) into TimelineDragOverlays and
TimelinePopovers. Extract ResourceMonthConfigSection from ReportBuilder (1132→1018 lines).
Extract ResourceSkillsEditor and ResourceOrgClassification from ResourceModal (1035→714 lines).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 23:16:38 +02:00
33 changed files with 2423 additions and 1111 deletions
+1 -1
View File
@@ -49,7 +49,7 @@
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@next/bundle-analyzer": "^15.5.15", "@next/bundle-analyzer": "^16.2.3",
"@axe-core/playwright": "^4.11.1", "@axe-core/playwright": "^4.11.1",
"@capakraken/eslint-config": "workspace:*", "@capakraken/eslint-config": "workspace:*",
"@capakraken/tsconfig": "workspace:*", "@capakraken/tsconfig": "workspace:*",
+25 -11
View File
@@ -59,10 +59,15 @@ export default function SignInPage() {
setTotp(""); setTotp("");
} }
} else { } else {
// Invalidate the Next.js Router Cache so (app)/layout.tsx re-renders // Full-page navigation instead of router.push to guarantee a fresh
// with the fresh session, then navigate to the dashboard. // server request with the new session cookie. Soft navigation keeps
router.refresh(); // the React tree (incl. QueryClient with cached pre-auth errors and
router.push("/dashboard"); // the Next.js Router Cache) alive, which caused the recurring bug
// where the dashboard rendered with empty widgets until the user
// pressed Ctrl+R. Skipping setLoading(false) prevents a visual flash
// while the navigation happens.
window.location.assign("/dashboard");
return;
} }
setLoading(false); setLoading(false);
@@ -86,21 +91,28 @@ export default function SignInPage() {
Resource planning that stays readable under pressure. Resource planning that stays readable under pressure.
</h1> </h1>
<p className="mt-5 max-w-xl text-lg text-gray-600 dark:text-gray-300"> <p className="mt-5 max-w-xl text-lg text-gray-600 dark:text-gray-300">
Estimates, staffing, chargeability, and timelines in one workspace with sharper structure for day-to-day planning. Estimates, staffing, chargeability, and timelines in one workspace with sharper
structure for day-to-day planning.
</p> </p>
</div> </div>
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<div className="app-surface p-5"> <div className="app-surface p-5">
<p className="app-label">Visibility</p> <p className="app-label">Visibility</p>
<p className="text-sm text-gray-700 dark:text-gray-300">Clearer data density, stronger contrast, faster scanning.</p> <p className="text-sm text-gray-700 dark:text-gray-300">
Clearer data density, stronger contrast, faster scanning.
</p>
</div> </div>
<div className="app-surface p-5"> <div className="app-surface p-5">
<p className="app-label">Planning</p> <p className="app-label">Planning</p>
<p className="text-sm text-gray-700 dark:text-gray-300">Dynamic staffing, resources, and chargeability in one flow.</p> <p className="text-sm text-gray-700 dark:text-gray-300">
Dynamic staffing, resources, and chargeability in one flow.
</p>
</div> </div>
<div className="app-surface p-5"> <div className="app-surface p-5">
<p className="app-label">Control</p> <p className="app-label">Control</p>
<p className="text-sm text-gray-700 dark:text-gray-300">Theme-aware UI that works in bright and dark environments.</p> <p className="text-sm text-gray-700 dark:text-gray-300">
Theme-aware UI that works in bright and dark environments.
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -108,7 +120,9 @@ export default function SignInPage() {
<div className="w-full max-w-md lg:ml-auto lg:max-w-lg"> <div className="w-full max-w-md lg:ml-auto lg:max-w-lg">
<div className="app-surface-strong p-8"> <div className="app-surface-strong p-8">
<div className="mb-8"> <div className="mb-8">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-brand-600">Welcome Back</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-brand-600">
Welcome Back
</p>
<h2 className="mt-3 font-display text-4xl font-semibold text-gray-900 dark:text-gray-50"> <h2 className="mt-3 font-display text-4xl font-semibold text-gray-900 dark:text-gray-50">
{mfaRequired ? "Two-Factor Authentication" : "Sign in to CapaKraken"} {mfaRequired ? "Two-Factor Authentication" : "Sign in to CapaKraken"}
</h2> </h2>
@@ -189,7 +203,8 @@ export default function SignInPage() {
required required
/> />
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400"> <p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Open your authenticator app (e.g. Google Authenticator, Authy) and enter the current code. Open your authenticator app (e.g. Google Authenticator, Authy) and enter the
current code.
</p> </p>
</div> </div>
)} )}
@@ -212,7 +227,6 @@ export default function SignInPage() {
</button> </button>
)} )}
</form> </form>
</div> </div>
</div> </div>
</div> </div>
@@ -3,10 +3,7 @@
import { DEFAULT_OPENAI_MODEL } from "@capakraken/shared"; import { DEFAULT_OPENAI_MODEL } from "@capakraken/shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { import { AiProviderPanel, GenerationSettingsPanel } from "./system-settings/AiSettingsPanels.js";
AiProviderPanel,
GenerationSettingsPanel,
} from "./system-settings/AiSettingsPanels.js";
import { LegacyRuntimeSecretsNotice } from "./system-settings/LegacyRuntimeSecretsNotice.js"; import { LegacyRuntimeSecretsNotice } from "./system-settings/LegacyRuntimeSecretsNotice.js";
import { import {
type ImageProvider, type ImageProvider,
@@ -52,13 +49,6 @@ export function SystemSettingsClient() {
const [imageProvider, setImageProvider] = useState<ImageProvider>("dalle"); const [imageProvider, setImageProvider] = useState<ImageProvider>("dalle");
const [geminiModel, setGeminiModel] = useState(""); const [geminiModel, setGeminiModel] = useState("");
const [imageSaved, setImageSaved] = useState(false); const [imageSaved, setImageSaved] = useState(false);
const [smtpHost, setSmtpHost] = useState("");
const [smtpPort, setSmtpPort] = useState(587);
const [smtpUser, setSmtpUser] = useState("");
const [smtpFrom, setSmtpFrom] = useState("");
const [smtpTls, setSmtpTls] = useState(true);
const [smtpSaved, setSmtpSaved] = useState(false);
const [smtpTestResult, setSmtpTestResult] = useState<SaveResult | null>(null);
const [anonymizationEnabled, setAnonymizationEnabled] = useState(false); const [anonymizationEnabled, setAnonymizationEnabled] = useState(false);
const [anonymizationDomain, setAnonymizationDomain] = useState("superhartmut.de"); const [anonymizationDomain, setAnonymizationDomain] = useState("superhartmut.de");
const [anonymizationSaved, setAnonymizationSaved] = useState(false); const [anonymizationSaved, setAnonymizationSaved] = useState(false);
@@ -96,11 +86,6 @@ export function SystemSettingsClient() {
setDalleEndpoint(settings.azureDalleEndpoint ?? ""); setDalleEndpoint(settings.azureDalleEndpoint ?? "");
setImageProvider((settings.imageProvider ?? "dalle") as ImageProvider); setImageProvider((settings.imageProvider ?? "dalle") as ImageProvider);
setGeminiModel(settings.geminiModel ?? ""); setGeminiModel(settings.geminiModel ?? "");
setSmtpHost(settings.smtpHost ?? "");
setSmtpPort(settings.smtpPort ?? 587);
setSmtpUser(settings.smtpUser ?? "");
setSmtpFrom(settings.smtpFrom ?? "");
setSmtpTls(settings.smtpTls ?? true);
setAnonymizationEnabled(settings.anonymizationEnabled ?? false); setAnonymizationEnabled(settings.anonymizationEnabled ?? false);
setAnonymizationDomain(settings.anonymizationDomain ?? "superhartmut.de"); setAnonymizationDomain(settings.anonymizationDomain ?? "superhartmut.de");
setVacationDefaultDays(settings.vacationDefaultDays ?? 28); setVacationDefaultDays(settings.vacationDefaultDays ?? 28);
@@ -163,21 +148,6 @@ export function SystemSettingsClient() {
onSuccess: (data) => setRecomputeResult(data), onSuccess: (data) => setRecomputeResult(data),
}); });
const saveSmtpMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => {
setSmtpSaved(true);
setSmtpTestResult(null);
setLegacyCleanupResult(null);
invalidateSystemSettings();
setTimeout(() => setSmtpSaved(false), 3000);
},
});
const testSmtpMutation = trpc.settings.testSmtpConnection.useMutation({
onSuccess: (data) => setSmtpTestResult(data),
onError: (error) => setSmtpTestResult({ ok: false, error: error.message }),
});
const saveAnonymizationMutation = trpc.settings.updateSystemSettings.useMutation({ const saveAnonymizationMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => { onSuccess: () => {
setAnonymizationSaved(true); setAnonymizationSaved(true);
@@ -254,16 +224,6 @@ export function SystemSettingsClient() {
}); });
} }
function handleSaveSmtp() {
saveSmtpMutation.mutate({
smtpHost: smtpHost || undefined,
smtpPort,
smtpUser: smtpUser || undefined,
smtpFrom: smtpFrom || undefined,
smtpTls,
});
}
function handleSaveVacation() { function handleSaveVacation() {
saveVacationMutation.mutate({ vacationDefaultDays }); saveVacationMutation.mutate({ vacationDefaultDays });
} }
@@ -292,8 +252,8 @@ export function SystemSettingsClient() {
function handleClearLegacyRuntimeSecrets() { function handleClearLegacyRuntimeSecrets() {
if ( if (
typeof window !== "undefined" typeof window !== "undefined" &&
&& !window.confirm( !window.confirm(
"Clear all legacy runtime secrets from database storage? Environment-based deployment secrets must already be configured.", "Clear all legacy runtime secrets from database storage? Environment-based deployment secrets must already be configured.",
) )
) { ) {
@@ -423,25 +383,7 @@ export function SystemSettingsClient() {
onTestGemini={() => testGeminiMutation.mutate()} onTestGemini={() => testGeminiMutation.mutate()}
/> />
<SmtpSettingsPanel <SmtpSettingsPanel initialSettings={settings} onSettingsSaved={invalidateSystemSettings} />
smtpHost={smtpHost}
smtpPort={smtpPort}
smtpUser={smtpUser}
smtpFrom={smtpFrom}
smtpTls={smtpTls}
smtpSaved={smtpSaved}
smtpTestResult={smtpTestResult}
smtpSecret={settings.runtimeSecrets.smtpPassword}
isSaving={saveSmtpMutation.isPending}
isTesting={testSmtpMutation.isPending}
onSmtpHostChange={setSmtpHost}
onSmtpPortChange={setSmtpPort}
onSmtpUserChange={setSmtpUser}
onSmtpFromChange={setSmtpFrom}
onSmtpTlsChange={setSmtpTls}
onSave={handleSaveSmtp}
onTest={() => testSmtpMutation.mutate()}
/>
<VacationSettingsPanel <VacationSettingsPanel
vacationDefaultDays={vacationDefaultDays} vacationDefaultDays={vacationDefaultDays}
@@ -1,3 +1,5 @@
import { useState, useEffect } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { import {
CHECKBOX_ROW_CLASS, CHECKBOX_ROW_CLASS,
@@ -12,44 +14,58 @@ import {
} from "./shared.js"; } from "./shared.js";
type SmtpSettingsPanelProps = { type SmtpSettingsPanelProps = {
smtpHost: string; initialSettings: {
smtpPort: number; smtpHost: string | null;
smtpUser: string; smtpPort: number | null;
smtpFrom: string; smtpUser: string | null;
smtpTls: boolean; smtpFrom: string | null;
smtpSaved: boolean; smtpTls: boolean | null;
smtpTestResult: SaveResult | null; runtimeSecrets: { smtpPassword: RuntimeSecrets["smtpPassword"] };
smtpSecret: RuntimeSecrets["smtpPassword"]; };
isSaving: boolean; onSettingsSaved: () => void;
isTesting: boolean;
onSmtpHostChange: (value: string) => void;
onSmtpPortChange: (value: number) => void;
onSmtpUserChange: (value: string) => void;
onSmtpFromChange: (value: string) => void;
onSmtpTlsChange: (value: boolean) => void;
onSave: () => void;
onTest: () => void;
}; };
export function SmtpSettingsPanel({ export function SmtpSettingsPanel({ initialSettings, onSettingsSaved }: SmtpSettingsPanelProps) {
smtpHost, const [smtpHost, setSmtpHost] = useState("");
smtpPort, const [smtpPort, setSmtpPort] = useState(587);
smtpUser, const [smtpUser, setSmtpUser] = useState("");
smtpFrom, const [smtpFrom, setSmtpFrom] = useState("");
smtpTls, const [smtpTls, setSmtpTls] = useState(true);
smtpSaved, const [saved, setSaved] = useState(false);
smtpTestResult, const [testResult, setTestResult] = useState<SaveResult | null>(null);
smtpSecret,
isSaving, useEffect(() => {
isTesting, setSmtpHost(initialSettings.smtpHost ?? "");
onSmtpHostChange, setSmtpPort(initialSettings.smtpPort ?? 587);
onSmtpPortChange, setSmtpUser(initialSettings.smtpUser ?? "");
onSmtpUserChange, setSmtpFrom(initialSettings.smtpFrom ?? "");
onSmtpFromChange, setSmtpTls(initialSettings.smtpTls ?? true);
onSmtpTlsChange, }, [initialSettings]);
onSave,
onTest, const saveMutation = trpc.settings.updateSystemSettings.useMutation({
}: SmtpSettingsPanelProps) { onSuccess: () => {
setSaved(true);
setTestResult(null);
onSettingsSaved();
setTimeout(() => setSaved(false), 3000);
},
});
const testMutation = trpc.settings.testSmtpConnection.useMutation({
onSuccess: (data) => setTestResult(data),
onError: (error) => setTestResult({ ok: false, error: error.message }),
});
function handleSave() {
saveMutation.mutate({
smtpHost: smtpHost || undefined,
smtpPort,
smtpUser: smtpUser || undefined,
smtpFrom: smtpFrom || undefined,
smtpTls,
});
}
return ( return (
<div className={PANEL_CLASS}> <div className={PANEL_CLASS}>
<div> <div>
@@ -74,7 +90,7 @@ export function SmtpSettingsPanel({
type="text" type="text"
className={INPUT_CLASS} className={INPUT_CLASS}
value={smtpHost} value={smtpHost}
onChange={(event) => onSmtpHostChange(event.target.value)} onChange={(event) => setSmtpHost(event.target.value)}
placeholder="smtp.example.com" placeholder="smtp.example.com"
/> />
</div> </div>
@@ -89,7 +105,7 @@ export function SmtpSettingsPanel({
type="number" type="number"
className={INPUT_CLASS} className={INPUT_CLASS}
value={smtpPort} value={smtpPort}
onChange={(event) => onSmtpPortChange(parseInt(event.target.value, 10))} onChange={(event) => setSmtpPort(parseInt(event.target.value, 10))}
min={1} min={1}
max={65535} max={65535}
/> />
@@ -97,15 +113,14 @@ export function SmtpSettingsPanel({
<div> <div>
<label className={LABEL_CLASS}> <label className={LABEL_CLASS}>
<span className="flex items-center"> <span className="flex items-center">
SMTP Username{" "} SMTP Username <InfoTooltip content="Authentication username for the SMTP server." />
<InfoTooltip content="Authentication username for the SMTP server." />
</span> </span>
</label> </label>
<input <input
type="text" type="text"
className={INPUT_CLASS} className={INPUT_CLASS}
value={smtpUser} value={smtpUser}
onChange={(event) => onSmtpUserChange(event.target.value)} onChange={(event) => setSmtpUser(event.target.value)}
placeholder="user@example.com" placeholder="user@example.com"
autoComplete="off" autoComplete="off"
/> />
@@ -121,7 +136,7 @@ export function SmtpSettingsPanel({
type="email" type="email"
className={INPUT_CLASS} className={INPUT_CLASS}
value={smtpFrom} value={smtpFrom}
onChange={(event) => onSmtpFromChange(event.target.value)} onChange={(event) => setSmtpFrom(event.target.value)}
placeholder="noreply@capakraken.app" placeholder="noreply@capakraken.app"
/> />
</div> </div>
@@ -130,7 +145,7 @@ export function SmtpSettingsPanel({
type="checkbox" type="checkbox"
id="smtpTls" id="smtpTls"
checked={smtpTls} checked={smtpTls}
onChange={(event) => onSmtpTlsChange(event.target.checked)} onChange={(event) => setSmtpTls(event.target.checked)}
className="rounded border-gray-300 text-brand-600" className="rounded border-gray-300 text-brand-600"
/> />
<label <label
@@ -145,39 +160,39 @@ export function SmtpSettingsPanel({
<RuntimeSecretCard <RuntimeSecretCard
title="SMTP Password" title="SMTP Password"
description="SMTP credentials are provisioned outside the application and injected at runtime." description="SMTP credentials are provisioned outside the application and injected at runtime."
secret={smtpSecret} secret={initialSettings.runtimeSecrets.smtpPassword}
optionalNote="Provision SMTP_PASSWORD in the deployment target used by the API service." optionalNote="Provision SMTP_PASSWORD in the deployment target used by the API service."
/> />
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
type="button" type="button"
onClick={onSave} onClick={handleSave}
disabled={isSaving} disabled={saveMutation.isPending}
className={PRIMARY_BUTTON_CLASS} className={PRIMARY_BUTTON_CLASS}
> >
{isSaving ? "Saving" : "Save SMTP Settings"} {saveMutation.isPending ? "Saving\u2026" : "Save SMTP Settings"}
</button> </button>
<button <button
type="button" type="button"
onClick={onTest} onClick={() => testMutation.mutate()}
disabled={isTesting} disabled={testMutation.isPending}
className={SECONDARY_BUTTON_CLASS} className={SECONDARY_BUTTON_CLASS}
> >
{isTesting ? "Testing" : "Test Connection"} {testMutation.isPending ? "Testing\u2026" : "Test Connection"}
</button> </button>
{smtpSaved ? ( {saved ? (
<span className="text-sm font-medium text-green-600 dark:text-green-400">Saved!</span> <span className="text-sm font-medium text-green-600 dark:text-green-400">Saved!</span>
) : null} ) : null}
{smtpTestResult ? ( {testResult ? (
<span <span
className={`text-sm font-medium ${ className={`text-sm font-medium ${
smtpTestResult.ok testResult.ok
? "text-green-600 dark:text-green-400" ? "text-green-600 dark:text-green-400"
: "text-red-500 dark:text-red-400" : "text-red-500 dark:text-red-400"
}`} }`}
> >
{smtpTestResult.ok ? " Connection successful" : ` ${smtpTestResult.error}`} {testResult.ok ? "\u2713 Connection successful" : `\u2717 ${testResult.error}`}
</span> </span>
) : null} ) : null}
</div> </div>
@@ -60,39 +60,46 @@ export default function ComputationGraphClient() {
const [dimension, setDimension] = useState<Dimension>("2d"); const [dimension, setDimension] = useState<Dimension>("2d");
const { const {
viewMode, setViewMode, viewMode,
resourceId, setResourceId, setViewMode,
month, setMonth, resourceId,
projectId, setProjectId, setResourceId,
resources, projects, month,
setMonth,
projectId,
setProjectId,
resources,
projects,
isLoading, isLoading,
activeDomains, activeDomains,
graphData, graphData,
rawData, rawData,
highlightedNodes, setHighlightedNodes, highlightedNodes,
domainFilter, toggleDomain, setHighlightedNodes,
domainFilter,
toggleDomain,
} = state; } = state;
const resourceMeta = viewMode === "resource" const resourceMeta =
? (rawData?.meta as ResourceGraphMeta | undefined) viewMode === "resource" ? (rawData?.meta as ResourceGraphMeta | undefined) : undefined;
: undefined;
const resourceFactors = resourceMeta?.factors; const resourceFactors = resourceMeta?.factors;
const weeklyAvailabilityEntries: Array<[string, number | undefined]> = resourceFactors?.weeklyAvailability const weeklyAvailabilityEntries: Array<[string, number | undefined]> =
? [ resourceFactors?.weeklyAvailability
["Mo", resourceFactors.weeklyAvailability.monday], ? [
["Di", resourceFactors.weeklyAvailability.tuesday], ["Mo", resourceFactors.weeklyAvailability.monday],
["Mi", resourceFactors.weeklyAvailability.wednesday], ["Di", resourceFactors.weeklyAvailability.tuesday],
["Do", resourceFactors.weeklyAvailability.thursday], ["Mi", resourceFactors.weeklyAvailability.wednesday],
["Fr", resourceFactors.weeklyAvailability.friday], ["Do", resourceFactors.weeklyAvailability.thursday],
["Sa", resourceFactors.weeklyAvailability.saturday], ["Fr", resourceFactors.weeklyAvailability.friday],
["So", resourceFactors.weeklyAvailability.sunday], ["Sa", resourceFactors.weeklyAvailability.saturday],
] ["So", resourceFactors.weeklyAvailability.sunday],
: []; ]
: [];
const weeklyAvailability = resourceFactors?.weeklyAvailability const weeklyAvailability = resourceFactors?.weeklyAvailability
? weeklyAvailabilityEntries ? weeklyAvailabilityEntries
.filter((entry): entry is [string, number] => typeof entry[1] === "number" && entry[1] > 0) .filter((entry): entry is [string, number] => typeof entry[1] === "number" && entry[1] > 0)
.map(([label, hours]) => `${label} ${formatNumber(hours, 1)}h`) .map(([label, hours]) => `${label} ${formatNumber(hours, 1)}h`)
.join(" · ") .join(" · ")
: "—"; : "—";
const topHolidays = resourceMeta?.resolvedHolidays?.slice(0, 6) ?? []; const topHolidays = resourceMeta?.resolvedHolidays?.slice(0, 6) ?? [];
@@ -104,6 +111,7 @@ export default function ComputationGraphClient() {
<div className="flex rounded-lg border border-zinc-300 dark:border-zinc-600"> <div className="flex rounded-lg border border-zinc-300 dark:border-zinc-600">
<button <button
onClick={() => setDimension("2d")} onClick={() => setDimension("2d")}
aria-pressed={dimension === "2d"}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${ className={`px-3 py-1.5 text-sm font-medium transition-colors ${
dimension === "2d" dimension === "2d"
? "bg-zinc-800 text-white dark:bg-zinc-200 dark:text-zinc-900" ? "bg-zinc-800 text-white dark:bg-zinc-200 dark:text-zinc-900"
@@ -114,6 +122,7 @@ export default function ComputationGraphClient() {
</button> </button>
<button <button
onClick={() => setDimension("3d")} onClick={() => setDimension("3d")}
aria-pressed={dimension === "3d"}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${ className={`px-3 py-1.5 text-sm font-medium transition-colors ${
dimension === "3d" dimension === "3d"
? "bg-zinc-800 text-white dark:bg-zinc-200 dark:text-zinc-900" ? "bg-zinc-800 text-white dark:bg-zinc-200 dark:text-zinc-900"
@@ -128,6 +137,7 @@ export default function ComputationGraphClient() {
<div className="flex rounded-lg border border-zinc-300 dark:border-zinc-600"> <div className="flex rounded-lg border border-zinc-300 dark:border-zinc-600">
<button <button
onClick={() => setViewMode("resource")} onClick={() => setViewMode("resource")}
aria-pressed={viewMode === "resource"}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${ className={`px-3 py-1.5 text-sm font-medium transition-colors ${
viewMode === "resource" viewMode === "resource"
? "bg-blue-600 text-white" ? "bg-blue-600 text-white"
@@ -138,6 +148,7 @@ export default function ComputationGraphClient() {
</button> </button>
<button <button
onClick={() => setViewMode("project")} onClick={() => setViewMode("project")}
aria-pressed={viewMode === "project"}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${ className={`px-3 py-1.5 text-sm font-medium transition-colors ${
viewMode === "project" viewMode === "project"
? "bg-blue-600 text-white" ? "bg-blue-600 text-white"
@@ -177,11 +188,14 @@ export default function ComputationGraphClient() {
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200" className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200"
> >
<option value="">Select Project...</option> <option value="">Select Project...</option>
{(Array.isArray(projects) ? projects : []).map((p: { id: string; name: string; shortCode?: string | null }) => ( {(Array.isArray(projects) ? projects : []).map(
<option key={p.id} value={p.id}> (p: { id: string; name: string; shortCode?: string | null }) => (
{p.shortCode ? `${p.shortCode}` : ""}{p.name} <option key={p.id} value={p.id}>
</option> {p.shortCode ? `${p.shortCode}` : ""}
))} {p.name}
</option>
),
)}
</select> </select>
)} )}
@@ -246,15 +260,22 @@ export default function ComputationGraphClient() {
<aside className="w-[24rem] overflow-y-auto border-l border-zinc-200 bg-white/90 p-4 dark:border-zinc-700 dark:bg-zinc-950/90"> <aside className="w-[24rem] overflow-y-auto border-l border-zinc-200 bg-white/90 p-4 dark:border-zinc-700 dark:bg-zinc-950/90">
<div className="space-y-4"> <div className="space-y-4">
<section className="rounded-xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-900"> <section className="rounded-xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-900">
<div className="text-xs font-semibold uppercase tracking-wide text-zinc-500">Bezugsgroessen</div> <div className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
Bezugsgroessen
</div>
<div className="mt-2 text-lg font-semibold text-zinc-900 dark:text-zinc-100"> <div className="mt-2 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
{resourceMeta.resourceName ?? "Resource"} {resourceMeta.resourceName ?? "Resource"}
</div> </div>
<div className="text-sm text-zinc-500">{resourceMeta.resourceEid ?? "—"} · {resourceMeta.month ?? month}</div> <div className="text-sm text-zinc-500">
{resourceMeta.resourceEid ?? "—"} · {resourceMeta.month ?? month}
</div>
<div className="mt-3 grid grid-cols-1 gap-2 text-sm text-zinc-700 dark:text-zinc-300"> <div className="mt-3 grid grid-cols-1 gap-2 text-sm text-zinc-700 dark:text-zinc-300">
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950"> <div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
<div className="text-xs uppercase text-zinc-500">Land</div> <div className="text-xs uppercase text-zinc-500">Land</div>
<div>{resourceMeta.countryName ?? resourceMeta.countryCode ?? "—"}{resourceMeta.countryCode ? ` (${resourceMeta.countryCode})` : ""}</div> <div>
{resourceMeta.countryName ?? resourceMeta.countryCode ?? "—"}
{resourceMeta.countryCode ? ` (${resourceMeta.countryCode})` : ""}
</div>
</div> </div>
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950"> <div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
<div className="text-xs uppercase text-zinc-500">Bundesland / Region</div> <div className="text-xs uppercase text-zinc-500">Bundesland / Region</div>
@@ -273,23 +294,30 @@ export default function ComputationGraphClient() {
<section className="rounded-xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-900"> <section className="rounded-xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-900">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="text-xs font-semibold uppercase tracking-wide text-zinc-500">Feiertagsbasis</div> <div className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
Feiertagsbasis
</div>
<div className="text-xs text-zinc-500"> <div className="text-xs text-zinc-500">
{resourceFactors?.publicHolidayCount ?? 0} Feiertage, {resourceFactors?.publicHolidayWorkdayCount ?? 0} wirksam {resourceFactors?.publicHolidayCount ?? 0} Feiertage,{" "}
{resourceFactors?.publicHolidayWorkdayCount ?? 0} wirksam
</div> </div>
</div> </div>
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
{topHolidays.length > 0 ? topHolidays.map((holiday) => ( {topHolidays.length > 0 ? (
<div topHolidays.map((holiday) => (
key={`${holiday.date}-${holiday.name}`} <div
className="rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm dark:border-zinc-800 dark:bg-zinc-950" key={`${holiday.date}-${holiday.name}`}
> className="rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm dark:border-zinc-800 dark:bg-zinc-950"
<div className="font-medium text-zinc-900 dark:text-zinc-100">{holiday.name}</div> >
<div className="text-xs text-zinc-500"> <div className="font-medium text-zinc-900 dark:text-zinc-100">
{holiday.date} · {holiday.scope} · {holiday.calendarName ?? "Kalender"} {holiday.name}
</div>
<div className="text-xs text-zinc-500">
{holiday.date} · {holiday.scope} · {holiday.calendarName ?? "Kalender"}
</div>
</div> </div>
</div> ))
)) : ( ) : (
<div className="rounded-lg border border-dashed border-zinc-200 px-3 py-2 text-sm text-zinc-500 dark:border-zinc-800"> <div className="rounded-lg border border-dashed border-zinc-200 px-3 py-2 text-sm text-zinc-500 dark:border-zinc-800">
Keine aufgeloesten Feiertage im gewaehlten Monat. Keine aufgeloesten Feiertage im gewaehlten Monat.
</div> </div>
@@ -298,12 +326,17 @@ export default function ComputationGraphClient() {
</section> </section>
<section className="rounded-xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-900"> <section className="rounded-xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-900">
<div className="text-xs font-semibold uppercase tracking-wide text-zinc-500">Herleitung</div> <div className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
Herleitung
</div>
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
<div className="rounded-lg bg-white px-3 py-2 text-sm dark:bg-zinc-950"> <div className="rounded-lg bg-white px-3 py-2 text-sm dark:bg-zinc-950">
<div className="text-xs uppercase text-zinc-500">SAH Formel</div> <div className="text-xs uppercase text-zinc-500">SAH Formel</div>
<div className="font-medium text-zinc-900 dark:text-zinc-100"> <div className="font-medium text-zinc-900 dark:text-zinc-100">
{formatNumber(resourceFactors?.baseAvailableHours)}h - {formatNumber(resourceFactors?.publicHolidayHoursDeduction)}h - {formatNumber(resourceFactors?.absenceHoursDeduction)}h = {formatNumber(resourceFactors?.effectiveAvailableHours)}h {formatNumber(resourceFactors?.baseAvailableHours)}h -{" "}
{formatNumber(resourceFactors?.publicHolidayHoursDeduction)}h -{" "}
{formatNumber(resourceFactors?.absenceHoursDeduction)}h ={" "}
{formatNumber(resourceFactors?.effectiveAvailableHours)}h
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-2 text-sm"> <div className="grid grid-cols-2 gap-2 text-sm">
@@ -1,5 +1,3 @@
"use client";
import Link from "next/link"; import Link from "next/link";
interface BenchResourceCardProps { interface BenchResourceCardProps {
@@ -29,11 +27,7 @@ export function BenchResourceCard({
.join(""); .join("");
const availabilityLevel = const availabilityLevel =
availableHoursPerDay >= 6 availableHoursPerDay >= 6 ? "high" : availableHoursPerDay >= 3 ? "medium" : "low";
? "high"
: availableHoursPerDay >= 3
? "medium"
: "low";
const levelClass = const levelClass =
availabilityLevel === "high" availabilityLevel === "high"
@@ -55,10 +49,14 @@ export function BenchResourceCard({
<div className={`rounded-xl border p-4 space-y-3 ${levelClass}`}> <div className={`rounded-xl border p-4 space-y-3 ${levelClass}`}>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="h-10 w-10 shrink-0 rounded-full bg-brand-100 dark:bg-brand-900/40 flex items-center justify-center"> <div className="h-10 w-10 shrink-0 rounded-full bg-brand-100 dark:bg-brand-900/40 flex items-center justify-center">
<span className="text-sm font-semibold text-brand-700 dark:text-brand-300">{initials}</span> <span className="text-sm font-semibold text-brand-700 dark:text-brand-300">
{initials}
</span>
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="font-medium text-sm text-gray-900 dark:text-gray-100 truncate">{name}</div> <div className="font-medium text-sm text-gray-900 dark:text-gray-100 truncate">
{name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{eid}</div> <div className="text-xs text-gray-500 dark:text-gray-400">{eid}</div>
</div> </div>
</div> </div>
@@ -1,5 +1,3 @@
"use client";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { formatDateLong } from "~/lib/format.js"; import { formatDateLong } from "~/lib/format.js";
import { FieldType } from "@capakraken/shared"; import { FieldType } from "@capakraken/shared";
@@ -36,9 +34,7 @@ function renderValue(fieldDef: BlueprintFieldDefinition, value: unknown): React.
<span <span
className={clsx( className={clsx(
"inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium", "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium",
bool bool ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500",
? "bg-green-100 text-green-700"
: "bg-gray-100 text-gray-500",
)} )}
> >
{bool ? "Yes" : "No"} {bool ? "Yes" : "No"}
@@ -100,9 +96,7 @@ function FieldRow({ fieldDef, value }: { fieldDef: BlueprintFieldDefinition; val
{fieldDef.label} {fieldDef.label}
</dt> </dt>
<dd className="text-sm">{renderValue(fieldDef, value)}</dd> <dd className="text-sm">{renderValue(fieldDef, value)}</dd>
{fieldDef.description && ( {fieldDef.description && <p className="text-xs text-gray-400">{fieldDef.description}</p>}
<p className="text-xs text-gray-400">{fieldDef.description}</p>
)}
</div> </div>
); );
} }
@@ -1,5 +1,3 @@
"use client";
interface MobileCapacityCardProps { interface MobileCapacityCardProps {
totalResources: number; totalResources: number;
activeResources: number; activeResources: number;
@@ -16,8 +14,7 @@ export function MobileCapacityCard({
const pct = Math.min(100, Math.max(0, avgUtilizationPct)); const pct = Math.min(100, Math.max(0, avgUtilizationPct));
const circumference = 2 * Math.PI * 34; // radius = 34 const circumference = 2 * Math.PI * 34; // radius = 34
const dashOffset = circumference * (1 - pct / 100); const dashOffset = circumference * (1 - pct / 100);
const color = const color = pct >= 90 ? "#d97706" : pct >= 70 ? "#059669" : "#6b7280";
pct >= 90 ? "#d97706" : pct >= 70 ? "#059669" : "#6b7280";
return ( return (
<div className="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-5"> <div className="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-5">
@@ -27,7 +24,15 @@ export function MobileCapacityCard({
<div className="flex items-center gap-5"> <div className="flex items-center gap-5">
{/* CSS-only donut */} {/* CSS-only donut */}
<svg width="80" height="80" viewBox="0 0 80 80" className="shrink-0"> <svg width="80" height="80" viewBox="0 0 80 80" className="shrink-0">
<circle cx="40" cy="40" r="34" fill="none" stroke="#e5e7eb" strokeWidth="8" className="dark:stroke-gray-700" /> <circle
cx="40"
cy="40"
r="34"
fill="none"
stroke="#e5e7eb"
strokeWidth="8"
className="dark:stroke-gray-700"
/>
<circle <circle
cx="40" cx="40"
cy="40" cy="40"
@@ -40,7 +45,15 @@ export function MobileCapacityCard({
strokeLinecap="round" strokeLinecap="round"
transform="rotate(-90 40 40)" transform="rotate(-90 40 40)"
/> />
<text x="40" y="40" textAnchor="middle" dominantBaseline="middle" fontSize="15" fontWeight="700" fill={color}> <text
x="40"
y="40"
textAnchor="middle"
dominantBaseline="middle"
fontSize="15"
fontWeight="700"
fill={color}
>
{Math.round(pct)}% {Math.round(pct)}%
</text> </text>
</svg> </svg>
@@ -54,7 +67,9 @@ export function MobileCapacityCard({
{overbookedCount > 0 && ( {overbookedCount > 0 && (
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-amber-600 dark:text-amber-400">Overbooked</span> <span className="text-amber-600 dark:text-amber-400">Overbooked</span>
<span className="font-semibold text-amber-600 dark:text-amber-400">{overbookedCount}</span> <span className="font-semibold text-amber-600 dark:text-amber-400">
{overbookedCount}
</span>
</div> </div>
)} )}
</div> </div>
@@ -1,11 +1,9 @@
"use client";
import Link from "next/link"; import Link from "next/link";
const STATUS_BADGE: Record<string, string> = { const STATUS_BADGE: Record<string, string> = {
ACTIVE: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300", ACTIVE: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300",
DRAFT: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400", DRAFT: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400",
ON_HOLD: "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300", ON_HOLD: "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300",
COMPLETED: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300", COMPLETED: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
CANCELLED: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300", CANCELLED: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300",
}; };
@@ -18,20 +16,32 @@ interface MobileProjectCardProps {
allocationsCount?: number; allocationsCount?: number;
} }
export function MobileProjectCard({ id, shortCode, name, status, allocationsCount }: MobileProjectCardProps) { export function MobileProjectCard({
id,
shortCode,
name,
status,
allocationsCount,
}: MobileProjectCardProps) {
return ( return (
<Link <Link
href={`/projects/${id}`} href={`/projects/${id}`}
className="flex items-center gap-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors" className="flex items-center gap-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
> >
<div className="font-mono text-xs text-gray-500 dark:text-gray-400 w-16 shrink-0">{shortCode}</div> <div className="font-mono text-xs text-gray-500 dark:text-gray-400 w-16 shrink-0">
{shortCode}
</div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{name}</div> <div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{name}</div>
{allocationsCount !== undefined && ( {allocationsCount !== undefined && (
<div className="text-xs text-gray-500 dark:text-gray-400">{allocationsCount} allocation{allocationsCount !== 1 ? "s" : ""}</div> <div className="text-xs text-gray-500 dark:text-gray-400">
{allocationsCount} allocation{allocationsCount !== 1 ? "s" : ""}
</div>
)} )}
</div> </div>
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium ${STATUS_BADGE[status] ?? STATUS_BADGE["DRAFT"]}`}> <span
className={`shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium ${STATUS_BADGE[status] ?? STATUS_BADGE["DRAFT"]}`}
>
{status.charAt(0) + status.slice(1).toLowerCase().replace("_", " ")} {status.charAt(0) + status.slice(1).toLowerCase().replace("_", " ")}
</span> </span>
</Link> </Link>
@@ -8,7 +8,12 @@ import { MobileProjectCard } from "./MobileProjectCard.js";
import { EmptyState } from "~/components/ui/EmptyState.js"; import { EmptyState } from "~/components/ui/EmptyState.js";
export function MobileSummaryClient() { export function MobileSummaryClient() {
const { data: overview, isLoading: overviewLoading } = trpc.dashboard.getOverview.useQuery(undefined, { const {
data: overview,
isLoading: overviewLoading,
isError: overviewError,
refetch: refetchOverview,
} = trpc.dashboard.getOverview.useQuery(undefined, {
staleTime: 60_000, staleTime: 60_000,
}); });
@@ -16,18 +21,23 @@ export function MobileSummaryClient() {
const { data: projectsData, isLoading: projectsLoading } = (trpc.project.list.useQuery as any)( const { data: projectsData, isLoading: projectsLoading } = (trpc.project.list.useQuery as any)(
{ limit: 5, status: "ACTIVE" }, { limit: 5, status: "ACTIVE" },
{ staleTime: 60_000 }, { staleTime: 60_000 },
) as { data: { projects: Array<{ id: string; shortCode: string; name: string; status: string }> } | undefined; isLoading: boolean }; ) as {
data:
| { projects: Array<{ id: string; shortCode: string; name: string; status: string }> }
| undefined;
isLoading: boolean;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: demandData } = (trpc.dashboard.getDemand.useQuery as any)( const { data: demandData } = (trpc.dashboard.getDemand.useQuery as any)(undefined, {
undefined, staleTime: 60_000,
{ staleTime: 60_000 }, }) as { data: { openDemandCount?: number; openDemands?: unknown[] } | undefined };
) as { data: { openDemandCount?: number; openDemands?: unknown[] } | undefined };
const projects = projectsData?.projects ?? []; const projects = projectsData?.projects ?? [];
const openDemandCount = demandData?.openDemandCount ?? demandData?.openDemands?.length ?? 0; const openDemandCount = demandData?.openDemandCount ?? demandData?.openDemands?.length ?? 0;
const isLoading = overviewLoading || projectsLoading; const isLoading = overviewLoading || projectsLoading;
const isError = overviewError;
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950"> <div className="min-h-screen bg-gray-50 dark:bg-gray-950">
@@ -40,7 +50,20 @@ export function MobileSummaryClient() {
</div> </div>
<div className="max-w-[428px] mx-auto px-4 py-5 space-y-4"> <div className="max-w-[428px] mx-auto px-4 py-5 space-y-4">
{isLoading ? ( {isError ? (
<div className="rounded-2xl border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-950/30 p-6 text-center">
<p className="text-sm font-medium text-red-700 dark:text-red-300">
Failed to load dashboard data
</p>
<button
type="button"
onClick={() => void refetchOverview()}
className="mt-3 rounded-lg bg-red-600 px-4 py-2 text-xs font-medium text-white hover:bg-red-700"
>
Retry
</button>
</div>
) : isLoading ? (
<div className="space-y-4"> <div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => ( {Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-32 shimmer-skeleton rounded-2xl" /> <div key={i} className="h-32 shimmer-skeleton rounded-2xl" />
@@ -64,7 +87,9 @@ export function MobileSummaryClient() {
className="flex items-center gap-3 rounded-xl border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950/30 px-4 py-3" className="flex items-center gap-3 rounded-xl border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950/30 px-4 py-3"
> >
<div className="h-8 w-8 shrink-0 rounded-full bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center"> <div className="h-8 w-8 shrink-0 rounded-full bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center">
<span className="text-sm font-bold text-amber-700 dark:text-amber-300">{openDemandCount}</span> <span className="text-sm font-bold text-amber-700 dark:text-amber-300">
{openDemandCount}
</span>
</div> </div>
<div> <div>
<div className="text-sm font-semibold text-amber-800 dark:text-amber-300"> <div className="text-sm font-semibold text-amber-800 dark:text-amber-300">
@@ -1,5 +1,3 @@
"use client";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { formatMoney } from "~/lib/format.js"; import { formatMoney } from "~/lib/format.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
@@ -55,14 +53,18 @@ export function BudgetStatusBar({
// Cap visual bar segments at 100% total // Cap visual bar segments at 100% total
const cappedConfirmedPercent = Math.min(confirmedPercent, 100); const cappedConfirmedPercent = Math.min(confirmedPercent, 100);
const cappedProposedPercent = Math.min(proposedPercent, Math.max(0, 100 - cappedConfirmedPercent)); const cappedProposedPercent = Math.min(
proposedPercent,
Math.max(0, 100 - cappedConfirmedPercent),
);
const highestWarning = warnings.length > 0 const highestWarning =
? warnings.reduce((prev, curr) => { warnings.length > 0
const levels: Record<string, number> = { info: 0, warning: 1, critical: 2 }; ? warnings.reduce((prev, curr) => {
return (levels[curr.level] ?? 0) > (levels[prev.level] ?? 0) ? curr : prev; const levels: Record<string, number> = { info: 0, warning: 1, critical: 2 };
}) return (levels[curr.level] ?? 0) > (levels[prev.level] ?? 0) ? curr : prev;
: null; })
: null;
return ( return (
<div className={clsx("space-y-1.5", className)}> <div className={clsx("space-y-1.5", className)}>
@@ -74,12 +76,18 @@ export function BudgetStatusBar({
<div className="relative h-3 bg-gray-100 rounded-full overflow-hidden"> <div className="relative h-3 bg-gray-100 rounded-full overflow-hidden">
{/* Confirmed segment */} {/* Confirmed segment */}
<div <div
className={clsx("absolute left-0 top-0 h-full transition-all", getConfirmedBarColor(utilizationPercent))} className={clsx(
"absolute left-0 top-0 h-full transition-all",
getConfirmedBarColor(utilizationPercent),
)}
style={{ width: `${cappedConfirmedPercent}%` }} style={{ width: `${cappedConfirmedPercent}%` }}
/> />
{/* Proposed segment */} {/* Proposed segment */}
<div <div
className={clsx("absolute top-0 h-full transition-all", getProposedBarColor(utilizationPercent))} className={clsx(
"absolute top-0 h-full transition-all",
getProposedBarColor(utilizationPercent),
)}
style={{ left: `${cappedConfirmedPercent}%`, width: `${cappedProposedPercent}%` }} style={{ left: `${cappedConfirmedPercent}%`, width: `${cappedProposedPercent}%` }}
/> />
</div> </div>
@@ -89,8 +97,7 @@ export function BudgetStatusBar({
<span> <span>
<span className="font-medium">{formatEur(allocatedCents)}</span> <span className="font-medium">{formatEur(allocatedCents)}</span>
{" / "} {" / "}
<span>{formatEur(budgetCents)}</span> <span>{formatEur(budgetCents)}</span>{" "}
{" "}
<span className="text-gray-400">({utilizationPercent.toFixed(1)}%)</span> <span className="text-gray-400">({utilizationPercent.toFixed(1)}%)</span>
</span> </span>
@@ -102,12 +109,20 @@ export function BudgetStatusBar({
getWarningBadgeStyle(highestWarning.level), getWarningBadgeStyle(highestWarning.level),
)} )}
> >
{highestWarning.level === "critical" ? "⚠" : highestWarning.level === "warning" ? "!" : "i"} {highestWarning.level === "critical"
? "⚠"
: highestWarning.level === "warning"
? "!"
: "i"}
{warnings.length > 1 ? `${warnings.length} warnings` : "Warning"} {warnings.length > 1 ? `${warnings.length} warnings` : "Warning"}
</span> </span>
)} )}
<span className={clsx("font-medium", remainingCents < 0 ? "text-red-600" : "text-gray-700")}> <span
{remainingCents >= 0 ? `${formatEur(remainingCents)} left` : `${formatEur(Math.abs(remainingCents))} over`} className={clsx("font-medium", remainingCents < 0 ? "text-red-600" : "text-gray-700")}
>
{remainingCents >= 0
? `${formatEur(remainingCents)} left`
: `${formatEur(Math.abs(remainingCents))} over`}
</span> </span>
</div> </div>
</div> </div>
@@ -115,11 +130,21 @@ export function BudgetStatusBar({
{/* Legend */} {/* Legend */}
<div className="flex items-center gap-3 text-xs text-gray-500"> <div className="flex items-center gap-3 text-xs text-gray-500">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<span className={clsx("inline-block w-2.5 h-2.5 rounded-sm", getConfirmedBarColor(utilizationPercent))} /> <span
className={clsx(
"inline-block w-2.5 h-2.5 rounded-sm",
getConfirmedBarColor(utilizationPercent),
)}
/>
Confirmed {formatEur(confirmedCents)} Confirmed {formatEur(confirmedCents)}
</span> </span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<span className={clsx("inline-block w-2.5 h-2.5 rounded-sm", getProposedBarColor(utilizationPercent))} /> <span
className={clsx(
"inline-block w-2.5 h-2.5 rounded-sm",
getProposedBarColor(utilizationPercent),
)}
/>
Proposed {formatEur(proposedCents)} Proposed {formatEur(proposedCents)}
</span> </span>
</div> </div>
@@ -12,10 +12,11 @@ interface Step1Props {
} }
export function Step1Identity({ state, onChange }: Step1Props) { export function Step1Identity({ state, onChange }: Step1Props) {
const { data: blueprints } = trpc.blueprint.list.useQuery( const { data: blueprints, isLoading: blueprintsLoading } = trpc.blueprint.list.useQuery(
{ target: BlueprintTarget.PROJECT, isActive: true }, { target: BlueprintTarget.PROJECT, isActive: true },
{ staleTime: 30_000 }, { staleTime: 30_000 },
) as { ) as {
isLoading: boolean;
data: data:
| Array<{ | Array<{
id: string; id: string;
@@ -88,6 +89,13 @@ export function Step1Identity({ state, onChange }: Step1Props) {
<div className="font-medium">No Blueprint</div> <div className="font-medium">No Blueprint</div>
<div className="text-xs text-gray-400 mt-0.5">Start blank</div> <div className="text-xs text-gray-400 mt-0.5">Start blank</div>
</button> </button>
{blueprintsLoading &&
Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="h-16 animate-pulse rounded-lg border border-gray-200 bg-gray-100 dark:border-gray-700 dark:bg-gray-800"
/>
))}
{(blueprints ?? []).map((bp) => ( {(blueprints ?? []).map((bp) => (
<button <button
key={bp.id} key={bp.id}
+15 -129
View File
@@ -10,6 +10,7 @@ import {
type ReportExplainability, type ReportExplainability,
} from "./reportBuilderExplainability.js"; } from "./reportBuilderExplainability.js";
import { ReportResultsPanel } from "./ReportResultsPanel.js"; import { ReportResultsPanel } from "./ReportResultsPanel.js";
import { ResourceMonthConfigSection } from "./ResourceMonthConfigSection.js";
// ─── Types ────────────────────────────────────────────────────────────────── // ─── Types ──────────────────────────────────────────────────────────────────
@@ -753,135 +754,20 @@ export function ReportBuilder() {
))} ))}
</div> </div>
{entity === "resource_month" && ( {entity === "resource_month" && (
<div className="mt-4 space-y-4 rounded-2xl border border-emerald-200 bg-emerald-50/70 p-4 dark:border-emerald-900/60 dark:bg-emerald-950/20"> <ResourceMonthConfigSection
<div className="flex flex-wrap items-end gap-4"> periodMonth={periodMonth}
<div> onPeriodMonthChange={setPeriodMonth}
<label className="mb-1 block text-sm font-medium text-emerald-900 dark:text-emerald-200"> blueprints={resourceMonthBlueprints}
Period month onApplyBlueprint={applyBlueprint}
</label> completeness={displayedResourceMonthCompleteness}
<input selectedTemplate={selectedTemplate}
type="month" hasTemplateDraftChanges={hasTemplateDraftChanges}
value={periodMonth} selectedColumns={selectedColumns}
onChange={(e) => setPeriodMonth(e.target.value)} onToggleColumn={toggleColumn}
className="rounded-xl border border-emerald-300 bg-white px-3 py-2 text-sm text-gray-700 focus:border-emerald-500 focus:ring-emerald-500 dark:border-emerald-900 dark:bg-slate-950 dark:text-gray-300" columnLabelMap={columnLabelMap}
/> recommendedColumns={RESOURCE_MONTH_RECOMMENDED_COLUMNS}
</div> summarizeMissing={summarizeMissingColumns}
<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.
</p>
</div>
<div className="grid gap-3 lg:grid-cols-3">
{resourceMonthBlueprints.map((blueprint) => (
<button
key={blueprint.id}
type="button"
onClick={() => applyBlueprint(blueprint)}
className="rounded-2xl border border-emerald-200 bg-white/80 p-4 text-left transition hover:border-emerald-400 hover:bg-white dark:border-emerald-900/70 dark:bg-slate-950/60 dark:hover:border-emerald-700"
>
<div className="text-sm font-semibold text-emerald-950 dark:text-emerald-100">
{blueprint.label}
</div>
<p className="mt-1 text-xs leading-5 text-emerald-900/75 dark:text-emerald-200/75">
{blueprint.description}
</p>
</button>
))}
</div>
<div className="rounded-2xl border border-emerald-200/80 bg-white/60 p-4 dark:border-emerald-900/60 dark:bg-slate-950/40">
{displayedResourceMonthCompleteness ? (
<div className="mb-4 rounded-2xl border border-emerald-200/80 bg-emerald-50/80 p-4 dark:border-emerald-900/60 dark:bg-emerald-950/20">
<div className="flex flex-wrap items-center gap-2">
<span
className={clsx(
"rounded-full px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em]",
displayedResourceMonthCompleteness.isAuditReady
? "bg-emerald-500 text-white"
: "bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-200",
)}
>
{displayedResourceMonthCompleteness.isAuditReady
? "Audit ready"
: "Audit gap"}
</span>
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
{displayedResourceMonthCompleteness.selectedMinimumAuditColumnCount}/
{displayedResourceMonthCompleteness.minimumAuditColumnCount} minimum audit
columns
</span>
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
{displayedResourceMonthCompleteness.selectedRecommendedColumnCount}/
{displayedResourceMonthCompleteness.recommendedColumnCount} recommended
columns
</span>
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] text-emerald-900/80 dark:bg-slate-950 dark:text-emerald-200/80">
{selectedTemplate && !hasTemplateDraftChanges
? "Saved template status"
: "Current builder status"}
</span>
</div>
{displayedResourceMonthCompleteness.missingMinimumAuditColumns.length > 0 ? (
<p className="mt-3 text-xs text-amber-800 dark:text-amber-200">
Missing audit/export basis columns:{" "}
{summarizeMissingColumns(
displayedResourceMonthCompleteness.missingMinimumAuditColumns,
columnLabelMap,
)}
</p>
) : displayedResourceMonthCompleteness.missingRecommendedColumns.length > 0 ? (
<p className="mt-3 text-xs text-emerald-900/80 dark:text-emerald-200/80">
Audit-ready, but still missing recommended basis columns:{" "}
{summarizeMissingColumns(
displayedResourceMonthCompleteness.missingRecommendedColumns,
columnLabelMap,
)}
</p>
) : (
<p className="mt-3 text-xs text-emerald-900/80 dark:text-emerald-200/80">
This view includes the full recommended audit/export basis set for monthly
SAH and chargeability checks.
</p>
)}
</div>
) : null}
<div className="text-sm font-medium text-emerald-950 dark:text-emerald-100">
Recommended transparency columns
</div>
<div className="mt-2 flex flex-wrap gap-2">
{RESOURCE_MONTH_RECOMMENDED_COLUMNS.map((column) => (
<button
key={column}
type="button"
onClick={() => toggleColumn(column)}
className={clsx(
"rounded-full border px-3 py-1 text-xs font-medium transition",
selectedColumns.has(column)
? "border-emerald-500 bg-emerald-500 text-white"
: "border-emerald-200 bg-white text-emerald-900 hover:border-emerald-400 dark:border-emerald-900 dark:bg-slate-950 dark:text-emerald-200 dark:hover:border-emerald-700",
)}
>
{columnLabelMap.get(column) ?? column}
</button>
))}
</div>
<p className="mt-3 text-xs text-emerald-900/75 dark:text-emerald-200/75">
Formula reference: base available hours - holiday deduction - absence deduction =
monthly SAH. Chargeability uses booked hours divided by monthly SAH.
</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.
</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, target hours, booked hours and unassigned hours.
</p>
</div>
</div>
)} )}
</div> </div>
@@ -0,0 +1,168 @@
import { clsx } from "clsx";
interface ResourceMonthTemplateCompleteness {
scope: "resource_month";
isAuditReady: boolean;
isRecommendedComplete: boolean;
recommendedColumnCount: number;
selectedRecommendedColumnCount: number;
minimumAuditColumnCount: number;
selectedMinimumAuditColumnCount: number;
missingRecommendedColumns: string[];
missingMinimumAuditColumns: string[];
}
interface ResourceMonthConfigSectionProps<
TBlueprint extends { id: string; label: string; description: string },
> {
periodMonth: string;
onPeriodMonthChange: (value: string) => void;
blueprints: TBlueprint[];
onApplyBlueprint: (blueprint: TBlueprint) => void;
completeness: ResourceMonthTemplateCompleteness | null;
selectedTemplate: { isShared?: boolean; isOwner?: boolean } | null;
hasTemplateDraftChanges: boolean;
selectedColumns: Set<string>;
onToggleColumn: (column: string) => void;
columnLabelMap: Map<string, string>;
recommendedColumns: readonly string[];
summarizeMissing: (columns: string[], labelMap: Map<string, string>) => string;
}
export function ResourceMonthConfigSection<
TBlueprint extends { id: string; label: string; description: string },
>({
periodMonth,
onPeriodMonthChange,
blueprints,
onApplyBlueprint,
completeness,
selectedTemplate,
hasTemplateDraftChanges,
selectedColumns,
onToggleColumn,
columnLabelMap,
recommendedColumns,
summarizeMissing,
}: ResourceMonthConfigSectionProps<TBlueprint>) {
return (
<div className="mt-4 space-y-4 rounded-2xl border border-emerald-200 bg-emerald-50/70 p-4 dark:border-emerald-900/60 dark:bg-emerald-950/20">
<div className="flex flex-wrap items-end gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-emerald-900 dark:text-emerald-200">
Period month
</label>
<input
type="month"
value={periodMonth}
onChange={(e) => onPeriodMonthChange(e.target.value)}
className="rounded-xl border border-emerald-300 bg-white px-3 py-2 text-sm text-gray-700 focus:border-emerald-500 focus:ring-emerald-500 dark:border-emerald-900 dark:bg-slate-950 dark:text-gray-300"
/>
</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.
</p>
</div>
<div className="grid gap-3 lg:grid-cols-3">
{blueprints.map((blueprint) => (
<button
key={blueprint.id}
type="button"
onClick={() => onApplyBlueprint(blueprint)}
className="rounded-2xl border border-emerald-200 bg-white/80 p-4 text-left transition hover:border-emerald-400 hover:bg-white dark:border-emerald-900/70 dark:bg-slate-950/60 dark:hover:border-emerald-700"
>
<div className="text-sm font-semibold text-emerald-950 dark:text-emerald-100">
{blueprint.label}
</div>
<p className="mt-1 text-xs leading-5 text-emerald-900/75 dark:text-emerald-200/75">
{blueprint.description}
</p>
</button>
))}
</div>
<div className="rounded-2xl border border-emerald-200/80 bg-white/60 p-4 dark:border-emerald-900/60 dark:bg-slate-950/40">
{completeness ? (
<div className="mb-4 rounded-2xl border border-emerald-200/80 bg-emerald-50/80 p-4 dark:border-emerald-900/60 dark:bg-emerald-950/20">
<div className="flex flex-wrap items-center gap-2">
<span
className={clsx(
"rounded-full px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em]",
completeness.isAuditReady
? "bg-emerald-500 text-white"
: "bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-200",
)}
>
{completeness.isAuditReady ? "Audit ready" : "Audit gap"}
</span>
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
{completeness.selectedMinimumAuditColumnCount}/
{completeness.minimumAuditColumnCount} minimum audit columns
</span>
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
{completeness.selectedRecommendedColumnCount}/{completeness.recommendedColumnCount}{" "}
recommended columns
</span>
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] text-emerald-900/80 dark:bg-slate-950 dark:text-emerald-200/80">
{selectedTemplate && !hasTemplateDraftChanges
? "Saved template status"
: "Current builder status"}
</span>
</div>
{completeness.missingMinimumAuditColumns.length > 0 ? (
<p className="mt-3 text-xs text-amber-800 dark:text-amber-200">
Missing audit/export basis columns:{" "}
{summarizeMissing(completeness.missingMinimumAuditColumns, columnLabelMap)}
</p>
) : completeness.missingRecommendedColumns.length > 0 ? (
<p className="mt-3 text-xs text-emerald-900/80 dark:text-emerald-200/80">
Audit-ready, but still missing recommended basis columns:{" "}
{summarizeMissing(completeness.missingRecommendedColumns, columnLabelMap)}
</p>
) : (
<p className="mt-3 text-xs text-emerald-900/80 dark:text-emerald-200/80">
This view includes the full recommended audit/export basis set for monthly SAH and
chargeability checks.
</p>
)}
</div>
) : null}
<div className="text-sm font-medium text-emerald-950 dark:text-emerald-100">
Recommended transparency columns
</div>
<div className="mt-2 flex flex-wrap gap-2">
{recommendedColumns.map((column) => (
<button
key={column}
type="button"
onClick={() => onToggleColumn(column)}
className={clsx(
"rounded-full border px-3 py-1 text-xs font-medium transition",
selectedColumns.has(column)
? "border-emerald-500 bg-emerald-500 text-white"
: "border-emerald-200 bg-white text-emerald-900 hover:border-emerald-400 dark:border-emerald-900 dark:bg-slate-950 dark:text-emerald-200 dark:hover:border-emerald-700",
)}
>
{columnLabelMap.get(column) ?? column}
</button>
))}
</div>
<p className="mt-3 text-xs text-emerald-900/75 dark:text-emerald-200/75">
Formula reference: base available hours - holiday deduction - absence deduction = monthly
SAH. Chargeability uses booked hours divided by monthly SAH.
</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.
</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,
target hours, booked hours and unassigned hours.
</p>
</div>
</div>
);
}
@@ -32,7 +32,11 @@ export function BulkEditModal({ selectedIds, fieldDefs, onClose, onSuccess }: Pr
function toggleInclude(key: string) { function toggleInclude(key: string) {
setIncluded((prev) => { setIncluded((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(key)) { next.delete(key); } else { next.add(key); } if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next; return next;
}); });
} }
@@ -43,9 +47,13 @@ export function BulkEditModal({ selectedIds, fieldDefs, onClose, onSuccess }: Pr
function handleSave() { function handleSave() {
setError(null); setError(null);
const fields: Record<string, unknown> = {}; const fields: Record<string, string | number | boolean | null> = {};
for (const key of included) { for (const key of included) {
fields[key] = values[key] ?? ""; const val = values[key] ?? "";
fields[key] =
typeof val === "string" || typeof val === "number" || typeof val === "boolean"
? val
: String(val);
} }
if (Object.keys(fields).length === 0) { if (Object.keys(fields).length === 0) {
setError("Select at least one field to update."); setError("Select at least one field to update.");
@@ -73,15 +81,27 @@ export function BulkEditModal({ selectedIds, fieldDefs, onClose, onSuccess }: Pr
Updating {selectedIds.length} resource{selectedIds.length !== 1 ? "s" : ""} Updating {selectedIds.length} resource{selectedIds.length !== 1 ? "s" : ""}
</p> </p>
</div> </div>
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl leading-none" aria-label="Close">×</button> <button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-2xl leading-none"
aria-label="Close"
>
×
</button>
</div> </div>
<div className="px-6 py-4 space-y-3 max-h-[60vh] overflow-y-auto"> <div className="px-6 py-4 space-y-3 max-h-[60vh] overflow-y-auto">
{fieldDefs.length === 0 && ( {fieldDefs.length === 0 && (
<p className="text-sm text-gray-400 text-center py-6">No custom fields defined. Configure them in Admin Blueprints.</p> <p className="text-sm text-gray-400 text-center py-6">
No custom fields defined. Configure them in Admin Blueprints.
</p>
)} )}
{fieldDefs.map((field) => ( {fieldDefs.map((field) => (
<div key={field.key} className={`border rounded-lg p-3 transition-colors ${included.has(field.key) ? "border-brand-300 bg-brand-50" : "border-gray-200"}`}> <div
key={field.key}
className={`border rounded-lg p-3 transition-colors ${included.has(field.key) ? "border-brand-300 bg-brand-50" : "border-gray-200"}`}
>
<label className="flex items-center gap-2 mb-2 cursor-pointer"> <label className="flex items-center gap-2 mb-2 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
@@ -105,13 +125,21 @@ export function BulkEditModal({ selectedIds, fieldDefs, onClose, onSuccess }: Pr
</div> </div>
{error && ( {error && (
<div className="mx-6 mb-2 px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div> <div className="mx-6 mb-2 px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{error}
</div>
)} )}
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200"> <div className="flex items-center justify-between px-6 py-4 border-t border-gray-200">
<p className="text-xs text-gray-400">{included.size} field{included.size !== 1 ? "s" : ""} selected</p> <p className="text-xs text-gray-400">
{included.size} field{included.size !== 1 ? "s" : ""} selected
</p>
<div className="flex gap-3"> <div className="flex gap-3">
<button type="button" onClick={onClose} className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium"> <button
type="button"
onClick={onClose}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium"
>
Cancel Cancel
</button> </button>
<button <button
@@ -120,7 +148,9 @@ export function BulkEditModal({ selectedIds, fieldDefs, onClose, onSuccess }: Pr
disabled={mutation.isPending || included.size === 0} disabled={mutation.isPending || included.size === 0}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50" className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
> >
{mutation.isPending ? "Saving…" : `Apply to ${selectedIds.length} resource${selectedIds.length !== 1 ? "s" : ""}`} {mutation.isPending
? "Saving…"
: `Apply to ${selectedIds.length} resource${selectedIds.length !== 1 ? "s" : ""}`}
</button> </button>
</div> </div>
</div> </div>
@@ -129,12 +159,24 @@ export function BulkEditModal({ selectedIds, fieldDefs, onClose, onSuccess }: Pr
); );
} }
function FieldInput({ field, value, onChange }: { field: BlueprintFieldDefinition; value: unknown; onChange: (v: unknown) => void }) { function FieldInput({
field,
value,
onChange,
}: {
field: BlueprintFieldDefinition;
value: unknown;
onChange: (v: unknown) => void;
}) {
const str = value !== undefined && value !== null ? String(value) : ""; const str = value !== undefined && value !== null ? String(value) : "";
if (field.type === FieldType.BOOLEAN) { if (field.type === FieldType.BOOLEAN) {
return ( return (
<select value={str} onChange={(e) => onChange(e.target.value === "true")} className="app-input"> <select
value={str}
onChange={(e) => onChange(e.target.value === "true")}
className="app-input"
>
<option value=""> select </option> <option value=""> select </option>
<option value="true">Yes</option> <option value="true">Yes</option>
<option value="false">No</option> <option value="false">No</option>
@@ -146,7 +188,11 @@ function FieldInput({ field, value, onChange }: { field: BlueprintFieldDefinitio
return ( return (
<select value={str} onChange={(e) => onChange(e.target.value)} className="app-input"> <select value={str} onChange={(e) => onChange(e.target.value)} className="app-input">
<option value=""> select </option> <option value=""> select </option>
{field.options.map((o) => <option key={o.value} value={o.value}>{o.label || o.value}</option>)} {field.options.map((o) => (
<option key={o.value} value={o.value}>
{o.label || o.value}
</option>
))}
</select> </select>
); );
} }
@@ -164,7 +210,14 @@ function FieldInput({ field, value, onChange }: { field: BlueprintFieldDefinitio
} }
if (field.type === FieldType.DATE) { if (field.type === FieldType.DATE) {
return <input type="date" value={str} onChange={(e) => onChange(e.target.value)} className="app-input" />; return (
<input
type="date"
value={str}
onChange={(e) => onChange(e.target.value)}
className="app-input"
/>
);
} }
if (field.type === FieldType.TEXTAREA) { if (field.type === FieldType.TEXTAREA) {
@@ -181,7 +234,9 @@ function FieldInput({ field, value, onChange }: { field: BlueprintFieldDefinitio
return ( return (
<input <input
type={field.type === FieldType.EMAIL ? "email" : field.type === FieldType.URL ? "url" : "text"} type={
field.type === FieldType.EMAIL ? "email" : field.type === FieldType.URL ? "url" : "text"
}
value={str} value={str}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder} placeholder={field.placeholder}
@@ -2,11 +2,12 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useFocusTrap } from "~/hooks/useFocusTrap.js"; import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import type { Resource, SkillEntry } from "@capakraken/shared"; import type { Resource, SkillEntry, ResourceType } from "@capakraken/shared";
import { GERMAN_FEDERAL_STATES, inferStateFromPostalCode, ResourceType } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { usePermissions } from "~/hooks/usePermissions.js"; import { usePermissions } from "~/hooks/usePermissions.js";
import { ResourceOrgClassification } from "./ResourceOrgClassification.js";
import { ResourceSkillsEditor } from "./ResourceSkillsEditor.js";
interface RoleAssignment { interface RoleAssignment {
roleId: string; roleId: string;
@@ -105,10 +106,14 @@ function resourceToFormState(resource: Resource): FormState {
countryId: (resource as unknown as { countryId?: string | null }).countryId ?? "", countryId: (resource as unknown as { countryId?: string | null }).countryId ?? "",
metroCityId: (resource as unknown as { metroCityId?: string | null }).metroCityId ?? "", metroCityId: (resource as unknown as { metroCityId?: string | null }).metroCityId ?? "",
orgUnitId: (resource as unknown as { orgUnitId?: string | null }).orgUnitId ?? "", orgUnitId: (resource as unknown as { orgUnitId?: string | null }).orgUnitId ?? "",
managementLevelGroupId: (resource as unknown as { managementLevelGroupId?: string | null }).managementLevelGroupId ?? "", managementLevelGroupId:
managementLevelId: (resource as unknown as { managementLevelId?: string | null }).managementLevelId ?? "", (resource as unknown as { managementLevelGroupId?: string | null }).managementLevelGroupId ??
"",
managementLevelId:
(resource as unknown as { managementLevelId?: string | null }).managementLevelId ?? "",
resourceType: (resource as unknown as { resourceType?: string }).resourceType ?? "EMPLOYEE", resourceType: (resource as unknown as { resourceType?: string }).resourceType ?? "EMPLOYEE",
chgResponsibility: (resource as unknown as { chgResponsibility?: boolean }).chgResponsibility ?? true, chgResponsibility:
(resource as unknown as { chgResponsibility?: boolean }).chgResponsibility ?? true,
rolledOff: (resource as unknown as { rolledOff?: boolean }).rolledOff ?? false, rolledOff: (resource as unknown as { rolledOff?: boolean }).rolledOff ?? false,
departed: (resource as unknown as { departed?: boolean }).departed ?? false, departed: (resource as unknown as { departed?: boolean }).departed ?? false,
enterpriseId: (resource as unknown as { enterpriseId?: string | null }).enterpriseId ?? "", enterpriseId: (resource as unknown as { enterpriseId?: string | null }).enterpriseId ?? "",
@@ -154,7 +159,14 @@ function defaultFormState(): FormState {
} }
function defaultSkillRow(): SkillRow { function defaultSkillRow(): SkillRow {
return { skill: "", proficiency: 3, yearsExperience: "", category: "", certified: false, isMainSkill: false }; return {
skill: "",
proficiency: 3,
yearsExperience: "",
category: "",
certified: false,
isMainSkill: false,
};
} }
interface ResourceModalProps { interface ResourceModalProps {
@@ -167,7 +179,8 @@ interface ResourceModalProps {
const INPUT_CLASS = const INPUT_CLASS =
"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 bg-white dark:bg-gray-900 dark:text-gray-100"; "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white dark:bg-gray-900 dark:text-gray-100";
const LABEL_CLASS = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"; const LABEL_CLASS = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
const SECTION_HEADER_CLASS = "text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 mt-4"; const SECTION_HEADER_CLASS =
"text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 mt-4";
const PRIMARY_BTN = const PRIMARY_BTN =
"px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"; "px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50";
@@ -211,7 +224,9 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
const { data: countries } = trpc.country.list.useQuery(undefined, { staleTime: 60_000 }); const { data: countries } = trpc.country.list.useQuery(undefined, { staleTime: 60_000 });
const { data: orgUnits } = trpc.orgUnit.list.useQuery(undefined, { staleTime: 60_000 }); const { data: orgUnits } = trpc.orgUnit.list.useQuery(undefined, { staleTime: 60_000 });
const { data: mgmtGroups } = trpc.managementLevel.listGroups.useQuery(undefined, { staleTime: 60_000 }); const { data: mgmtGroups } = trpc.managementLevel.listGroups.useQuery(undefined, {
staleTime: 60_000,
});
const { data: clients } = trpc.clientEntity.list.useQuery(undefined, { staleTime: 60_000 }); const { data: clients } = trpc.clientEntity.list.useQuery(undefined, { staleTime: 60_000 });
const roleOptions = (availableRoles ?? []) as unknown as RoleOption[]; const roleOptions = (availableRoles ?? []) as unknown as RoleOption[];
@@ -220,14 +235,6 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
const managementGroupOptions = (mgmtGroups ?? []) as unknown as ManagementGroupOption[]; const managementGroupOptions = (mgmtGroups ?? []) as unknown as ManagementGroupOption[];
const clientOptions = (clients ?? []) as unknown as ClientOption[]; const clientOptions = (clients ?? []) as unknown as ClientOption[];
// Derive metro cities from selected country
const selectedCountry = countryOptions.find((c) => c.id === form.countryId);
const metroCities = selectedCountry?.metroCities ?? [];
// Derive levels from selected group
const selectedGroup = managementGroupOptions.find((g) => g.id === form.managementLevelGroupId);
const mgmtLevels = selectedGroup?.levels ?? [];
const createMutation = trpc.resource.create.useMutation(); const createMutation = trpc.resource.create.useMutation();
const updateMutation = trpc.resource.update.useMutation(); const updateMutation = trpc.resource.update.useMutation();
const hardDeleteMutation = trpc.resource.hardDelete.useMutation({ const hardDeleteMutation = trpc.resource.hardDelete.useMutation({
@@ -240,7 +247,8 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
}, },
}); });
const isMutating = createMutation.isPending || updateMutation.isPending || hardDeleteMutation.isPending; const isMutating =
createMutation.isPending || updateMutation.isPending || hardDeleteMutation.isPending;
function setField<K extends keyof FormState>(key: K, value: FormState[K]) { function setField<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((prev) => ({ ...prev, [key]: value })); setForm((prev) => ({ ...prev, [key]: value }));
@@ -306,7 +314,9 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
...(form.countryId ? { countryId: form.countryId } : {}), ...(form.countryId ? { countryId: form.countryId } : {}),
...(form.metroCityId ? { metroCityId: form.metroCityId } : {}), ...(form.metroCityId ? { metroCityId: form.metroCityId } : {}),
...(form.orgUnitId ? { orgUnitId: form.orgUnitId } : {}), ...(form.orgUnitId ? { orgUnitId: form.orgUnitId } : {}),
...(form.managementLevelGroupId ? { managementLevelGroupId: form.managementLevelGroupId } : {}), ...(form.managementLevelGroupId
? { managementLevelGroupId: form.managementLevelGroupId }
: {}),
...(form.managementLevelId ? { managementLevelId: form.managementLevelId } : {}), ...(form.managementLevelId ? { managementLevelId: form.managementLevelId } : {}),
resourceType: form.resourceType as ResourceType, resourceType: form.resourceType as ResourceType,
chgResponsibility: form.chgResponsibility, chgResponsibility: form.chgResponsibility,
@@ -345,14 +355,6 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
} }
} }
const proficiencyLabels: Record<number, string> = {
1: "1 Beginner",
2: "2 Elementary",
3: "3 Intermediate",
4: "4 Advanced",
5: "5 Expert",
};
return ( return (
<div <div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8" className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
@@ -363,7 +365,9 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
<div <div
ref={panelRef} ref={panelRef}
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-2xl mx-4" className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-2xl mx-4"
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }} onKeyDown={(e) => {
if (e.key === "Escape") onClose();
}}
> >
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700"> <div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
@@ -376,7 +380,13 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors" className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
aria-label="Close modal" aria-label="Close modal"
> >
<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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
@@ -391,7 +401,8 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className={LABEL_CLASS} htmlFor="rm-eid"> <label className={LABEL_CLASS} htmlFor="rm-eid">
Employee ID <span className="text-red-500">*</span><InfoTooltip content="Unique employee identifier (e.g. EMP-042). Used for imports and cross-referencing." /> Employee ID <span className="text-red-500">*</span>
<InfoTooltip content="Unique employee identifier (e.g. EMP-042). Used for imports and cross-referencing." />
</label> </label>
<input <input
id="rm-eid" id="rm-eid"
@@ -405,7 +416,8 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
</div> </div>
<div> <div>
<label className={LABEL_CLASS} htmlFor="rm-displayName"> <label className={LABEL_CLASS} htmlFor="rm-displayName">
Display Name <span className="text-red-500">*</span><InfoTooltip content="Full name shown in the timeline, reports, and staffing views." /> Display Name <span className="text-red-500">*</span>
<InfoTooltip content="Full name shown in the timeline, reports, and staffing views." />
</label> </label>
<input <input
id="rm-displayName" id="rm-displayName"
@@ -433,7 +445,8 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
</div> </div>
<div> <div>
<label className={LABEL_CLASS} htmlFor="rm-chapter"> <label className={LABEL_CLASS} htmlFor="rm-chapter">
Chapter <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span> Chapter{" "}
<span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
</label> </label>
<input <input
id="rm-chapter" id="rm-chapter"
@@ -445,7 +458,9 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
list="rm-chapter-list" list="rm-chapter-list"
/> />
<datalist id="rm-chapter-list"> <datalist id="rm-chapter-list">
{chapters?.map((c) => <option key={c} value={c} />)} {chapters?.map((c) => (
<option key={c} value={c} />
))}
</datalist> </datalist>
</div> </div>
</div> </div>
@@ -454,7 +469,8 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
<div className="grid grid-cols-2 gap-4 mt-4"> <div className="grid grid-cols-2 gap-4 mt-4">
<div> <div>
<label className={LABEL_CLASS} htmlFor="rm-portfolioUrl"> <label className={LABEL_CLASS} htmlFor="rm-portfolioUrl">
Portfolio URL <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span> Portfolio URL{" "}
<span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
</label> </label>
<input <input
id="rm-portfolioUrl" id="rm-portfolioUrl"
@@ -467,7 +483,9 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
</div> </div>
<div> <div>
<label className={LABEL_CLASS} htmlFor="rm-roleId"> <label className={LABEL_CLASS} htmlFor="rm-roleId">
Area of Expertise <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span><InfoTooltip content="The resource's primary area role. Used for skill matrix grouping and AI summary generation." /> Area of Expertise{" "}
<span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
<InfoTooltip content="The resource's primary area role. Used for skill matrix grouping and AI summary generation." />
</label> </label>
<select <select
id="rm-roleId" id="rm-roleId"
@@ -477,241 +495,25 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
> >
<option value=""> Not specified </option> <option value=""> Not specified </option>
{roleOptions.map((r) => ( {roleOptions.map((r) => (
<option key={r.id} value={r.id}>{r.name}</option> <option key={r.id} value={r.id}>
{r.name}
</option>
))} ))}
</select> </select>
</div> </div>
</div> </div>
{/* Postal Code & Federal State */} <ResourceOrgClassification
<div className="grid grid-cols-2 gap-4 mt-4"> form={form}
<div> onSetField={setField as (key: string, value: string | boolean) => void}
<label className={LABEL_CLASS} htmlFor="rm-postalCode"> countryOptions={countryOptions}
Postal Code (PLZ) <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span><InfoTooltip content="German postal code. Used to auto-derive the federal state for public holiday calculations." /> orgUnitOptions={orgUnitOptions}
</label> clientOptions={clientOptions}
<input managementGroupOptions={managementGroupOptions}
id="rm-postalCode" inputClass={INPUT_CLASS}
type="text" labelClass={LABEL_CLASS}
className={INPUT_CLASS} sectionHeaderClass={SECTION_HEADER_CLASS}
placeholder="80331" />
maxLength={5}
value={form.postalCode}
onChange={(e) => {
const plz = e.target.value;
setField("postalCode", plz);
if (/^\d{5}$/.test(plz)) {
const inferred = inferStateFromPostalCode(plz);
if (inferred && !form.federalState) {
setField("federalState", inferred);
}
}
}}
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-federalState">
Federal State <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span><InfoTooltip content="Determines which public holidays apply (e.g. Bavaria has extra holidays). Auto-derived from postal code." />
</label>
<select
id="rm-federalState"
className={INPUT_CLASS}
value={form.federalState}
onChange={(e) => setField("federalState", e.target.value)}
>
<option value=""> Not specified </option>
{Object.entries(GERMAN_FEDERAL_STATES).map(([abbr, name]) => (
<option key={abbr} value={abbr}>{name} ({abbr})</option>
))}
</select>
</div>
</div>
{/* Section: Organization & Classification */}
<p className={SECTION_HEADER_CLASS}>Organization &amp; Classification</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-enterpriseId">
Enterprise ID <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span><InfoTooltip content="Corporate directory ID for cross-system integration (e.g. a.kasperovich)." />
</label>
<input
id="rm-enterpriseId"
type="text"
className={INPUT_CLASS}
placeholder="a.kasperovich"
value={form.enterpriseId}
onChange={(e) => setField("enterpriseId", e.target.value)}
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-fte">
FTE<InfoTooltip content="Full-Time Equivalent (0.01-1.0). A value of 0.5 means the resource works 50% of standard hours." />
</label>
<input
id="rm-fte"
type="number"
min="0.01"
max="1"
step="0.01"
className={INPUT_CLASS}
placeholder="1.0"
value={form.fte}
onChange={(e) => setField("fte", e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-countryId">Country</label>
<select
id="rm-countryId"
className={INPUT_CLASS}
value={form.countryId}
onChange={(e) => {
setField("countryId", e.target.value);
setField("metroCityId", ""); // reset city when country changes
}}
>
<option value=""> Not specified </option>
{countryOptions.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-metroCityId">Metro City</label>
<select
id="rm-metroCityId"
className={INPUT_CLASS}
value={form.metroCityId}
onChange={(e) => setField("metroCityId", e.target.value)}
disabled={!form.countryId}
>
<option value=""> Not specified </option>
{metroCities.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-orgUnitId">Org Unit (L7 Team)</label>
<select
id="rm-orgUnitId"
className={INPUT_CLASS}
value={form.orgUnitId}
onChange={(e) => setField("orgUnitId", e.target.value)}
>
<option value=""> Not specified </option>
{orgUnitOptions
.filter((u) => u.level === 7 && u.isActive)
.map((u) => (
<option key={u.id} value={u.id}>{u.name}</option>
))}
</select>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-clientUnitId">Client Unit</label>
<select
id="rm-clientUnitId"
className={INPUT_CLASS}
value={form.clientUnitId}
onChange={(e) => setField("clientUnitId", e.target.value)}
>
<option value=""> Not specified </option>
{clientOptions.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-mgmtGroupId">Management Level Group<InfoTooltip content="Seniority grouping (e.g. Associate, Manager, Director). Determines the available management levels." /></label>
<select
id="rm-mgmtGroupId"
className={INPUT_CLASS}
value={form.managementLevelGroupId}
onChange={(e) => {
setField("managementLevelGroupId", e.target.value);
setField("managementLevelId", ""); // reset level when group changes
}}
>
<option value=""> Not specified </option>
{managementGroupOptions.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-mgmtLevelId">Management Level<InfoTooltip content="Specific seniority level within the group. Used in chargeability reports and cost analysis." /></label>
<select
id="rm-mgmtLevelId"
className={INPUT_CLASS}
value={form.managementLevelId}
onChange={(e) => setField("managementLevelId", e.target.value)}
disabled={!form.managementLevelGroupId}
>
<option value=""> Not specified </option>
{mgmtLevels.map((l) => (
<option key={l.id} value={l.id}>{l.name}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-4 gap-4 mt-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-resourceType">Resource Type<InfoTooltip content="Employee, contractor, or freelancer. Affects cost attribution rules." /></label>
<select
id="rm-resourceType"
className={INPUT_CLASS}
value={form.resourceType}
onChange={(e) => setField("resourceType", e.target.value)}
>
{Object.values(ResourceType).map((t) => (
<option key={t} value={t}>{t.charAt(0) + t.slice(1).toLowerCase()}</option>
))}
</select>
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={form.chgResponsibility}
onChange={(e) => setField("chgResponsibility", e.target.checked)}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Chg Responsibility
</label>
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={form.rolledOff}
onChange={(e) => setField("rolledOff", e.target.checked)}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Rolled Off
</label>
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={form.departed}
onChange={(e) => setField("departed", e.target.checked)}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Departed
</label>
</div>
</div>
{/* Section 2: Cost & Chargeability */} {/* Section 2: Cost & Chargeability */}
<p className={SECTION_HEADER_CLASS}>Cost &amp; Chargeability</p> <p className={SECTION_HEADER_CLASS}>Cost &amp; Chargeability</p>
@@ -719,7 +521,8 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className={LABEL_CLASS} htmlFor="rm-lcr"> <label className={LABEL_CLASS} htmlFor="rm-lcr">
LCR &euro;/h <span className="text-red-500">*</span><InfoTooltip content="Loaded Cost Rate in EUR per hour. E.g. 85 = 85.00 EUR/h. Stored internally as integer cents (8500)." /> LCR &euro;/h <span className="text-red-500">*</span>
<InfoTooltip content="Loaded Cost Rate in EUR per hour. E.g. 85 = 85.00 EUR/h. Stored internally as integer cents (8500)." />
</label> </label>
<input <input
id="rm-lcr" id="rm-lcr"
@@ -735,7 +538,8 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
</div> </div>
<div> <div>
<label className={LABEL_CLASS} htmlFor="rm-ucr"> <label className={LABEL_CLASS} htmlFor="rm-ucr">
UCR &euro;/h <span className="text-red-500">*</span><InfoTooltip content="Unit Cost Rate in EUR per hour. The rate billed to the project or client." /> UCR &euro;/h <span className="text-red-500">*</span>
<InfoTooltip content="Unit Cost Rate in EUR per hour. The rate billed to the project or client." />
</label> </label>
<input <input
id="rm-ucr" id="rm-ucr"
@@ -766,7 +570,8 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
</div> </div>
<div> <div>
<label className={LABEL_CLASS} htmlFor="rm-chargeability"> <label className={LABEL_CLASS} htmlFor="rm-chargeability">
Chargeability Target %<InfoTooltip content="Target % of working time on chargeable projects. E.g. 80 means 80% of hours should be billable." /> Chargeability Target %
<InfoTooltip content="Target % of working time on chargeable projects. E.g. 80 means 80% of hours should be billable." />
</label> </label>
<input <input
id="rm-chargeability" id="rm-chargeability"
@@ -815,103 +620,14 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
{/* Section 4: Skills */} {/* Section 4: Skills */}
<p className={SECTION_HEADER_CLASS}>Skills</p> <p className={SECTION_HEADER_CLASS}>Skills</p>
<div className="space-y-3"> <ResourceSkillsEditor
{form.skills.map((skillRow, idx) => { skills={form.skills}
const mainSkillCount = form.skills.filter((s) => s.isMainSkill).length; onSetSkillField={setSkillField}
const canToggleMain = skillRow.isMainSkill || mainSkillCount < 2; onAddSkill={addSkill}
return ( onRemoveSkill={removeSkill}
<div inputClass={INPUT_CLASS}
key={idx} labelClass={LABEL_CLASS}
className={`grid gap-2 items-end border rounded-lg p-3 ${skillRow.isMainSkill ? "border-amber-200 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20" : "border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900"}`} />
>
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-2 items-end">
<div>
<label className={LABEL_CLASS} htmlFor={`rm-skill-name-${idx}`}>
Skill
</label>
<input
id={`rm-skill-name-${idx}`}
type="text"
className={INPUT_CLASS}
placeholder="e.g. 3ds Max"
value={skillRow.skill}
onChange={(e) => setSkillField(idx, "skill", e.target.value)}
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor={`rm-skill-prof-${idx}`}>
Proficiency
</label>
<select
id={`rm-skill-prof-${idx}`}
className={INPUT_CLASS}
value={skillRow.proficiency}
onChange={(e) =>
setSkillField(idx, "proficiency", parseInt(e.target.value, 10) as 1 | 2 | 3 | 4 | 5)
}
>
{[1, 2, 3, 4, 5].map((p) => (
<option key={p} value={p}>
{proficiencyLabels[p]}
</option>
))}
</select>
</div>
<div>
<label className={LABEL_CLASS} htmlFor={`rm-skill-years-${idx}`}>
Years
</label>
<input
id={`rm-skill-years-${idx}`}
type="number"
min="0"
max="50"
step="1"
className={INPUT_CLASS}
placeholder="—"
value={skillRow.yearsExperience}
onChange={(e) => setSkillField(idx, "yearsExperience", e.target.value)}
/>
</div>
<div className="flex flex-col items-center gap-1 pb-0.5">
<span className="text-[10px] text-gray-500 dark:text-gray-400 leading-none"> Main</span>
<input
type="checkbox"
checked={skillRow.isMainSkill}
disabled={!canToggleMain}
title={!canToggleMain ? "Max 2 main skills" : "Mark as main skill"}
onChange={(e) => setSkillField(idx, "isMainSkill", e.target.checked)}
className="rounded border-gray-300 disabled:opacity-40"
/>
</div>
<div className="flex items-end pb-0.5">
<button
type="button"
onClick={() => removeSkill(idx)}
className="px-2 py-2 text-red-400 hover:text-red-600 transition-colors"
aria-label={`Remove skill ${idx + 1}`}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
);
})}
<button
type="button"
onClick={addSkill}
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-800 font-medium transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
Add skill
</button>
</div>
{/* Section 5: Roles */} {/* Section 5: Roles */}
<p className={SECTION_HEADER_CLASS}>Roles</p> <p className={SECTION_HEADER_CLASS}>Roles</p>
@@ -931,7 +647,10 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
if (e.target.checked) { if (e.target.checked) {
setField("roles", [...form.roles, { roleId: role.id, isPrimary: false }]); setField("roles", [...form.roles, { roleId: role.id, isPrimary: false }]);
} else { } else {
setField("roles", form.roles.filter((r) => r.roleId !== role.id)); setField(
"roles",
form.roles.filter((r) => r.roleId !== role.id),
);
} }
}} }}
className="rounded border-gray-300" className="rounded border-gray-300"
@@ -940,7 +659,10 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
className="w-3 h-3 rounded-full flex-shrink-0" className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: role.color ?? "#6366f1" }} style={{ backgroundColor: role.color ?? "#6366f1" }}
/> />
<label htmlFor={`role-${role.id}`} className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer flex-1"> <label
htmlFor={`role-${role.id}`}
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer flex-1"
>
{role.name} {role.name}
</label> </label>
{isChecked && ( {isChecked && (
@@ -950,11 +672,14 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
name="primary-role" name="primary-role"
checked={assignment?.isPrimary ?? false} checked={assignment?.isPrimary ?? false}
onChange={() => { onChange={() => {
setField("roles", form.roles.map((r) => setField(
r.roleId === role.id "roles",
? { ...r, isPrimary: true } form.roles.map((r) =>
: { ...r, isPrimary: false }, r.roleId === role.id
)); ? { ...r, isPrimary: true }
: { ...r, isPrimary: false },
),
);
}} }}
className="border-gray-300" className="border-gray-300"
/> />
@@ -965,7 +690,9 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
); );
})} })}
{roleOptions.length === 0 && ( {roleOptions.length === 0 && (
<p className="text-sm text-gray-400 italic">No roles defined yet. Create roles on the Roles page.</p> <p className="text-sm text-gray-400 italic">
No roles defined yet. Create roles on the Roles page.
</p>
)} )}
</div> </div>
@@ -980,10 +707,14 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
{/* Footer */} {/* Footer */}
<div className="flex items-center justify-between gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 rounded-b-xl"> <div className="flex items-center justify-between gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 rounded-b-xl">
<div> <div>
{mode === "edit" && canManageUsers && resource && ( {mode === "edit" &&
confirmDelete ? ( canManageUsers &&
resource &&
(confirmDelete ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-red-600 dark:text-red-400 font-medium">Permanently delete this resource?</span> <span className="text-xs text-red-600 dark:text-red-400 font-medium">
Permanently delete this resource?
</span>
<button <button
type="button" type="button"
onClick={() => void hardDeleteMutation.mutateAsync({ id: resource.id })} onClick={() => void hardDeleteMutation.mutateAsync({ id: resource.id })}
@@ -1010,8 +741,7 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
> >
Delete Resource Delete Resource
</button> </button>
) ))}
)}
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
@@ -0,0 +1,325 @@
import { GERMAN_FEDERAL_STATES, inferStateFromPostalCode, ResourceType } from "@capakraken/shared";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
type CountryOption = { id: string; name: string; metroCities: { id: string; name: string }[] };
type OrgUnitOption = { id: string; name: string; level: number; isActive: boolean };
type ClientOption = { id: string; name: string };
type ManagementGroupOption = { id: string; name: string; levels: { id: string; name: string }[] };
interface ResourceOrgClassificationProps {
form: {
postalCode: string;
federalState: string;
countryId: string;
metroCityId: string;
orgUnitId: string;
clientUnitId: string;
managementLevelGroupId: string;
managementLevelId: string;
resourceType: string;
chgResponsibility: boolean;
rolledOff: boolean;
departed: boolean;
enterpriseId: string;
fte: string;
};
onSetField: (key: string, value: string | boolean) => void;
countryOptions: CountryOption[];
orgUnitOptions: OrgUnitOption[];
clientOptions: ClientOption[];
managementGroupOptions: ManagementGroupOption[];
inputClass: string;
labelClass: string;
sectionHeaderClass: string;
}
export function ResourceOrgClassification({
form,
onSetField,
countryOptions,
orgUnitOptions,
clientOptions,
managementGroupOptions,
inputClass,
labelClass,
sectionHeaderClass,
}: ResourceOrgClassificationProps) {
const selectedCountry = countryOptions.find((c) => c.id === form.countryId);
const metroCities = selectedCountry?.metroCities ?? [];
const selectedGroup = managementGroupOptions.find((g) => g.id === form.managementLevelGroupId);
const mgmtLevels = selectedGroup?.levels ?? [];
return (
<>
{/* Postal Code & Federal State */}
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<label className={labelClass} htmlFor="rm-postalCode">
Postal Code (PLZ){" "}
<span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
<InfoTooltip content="German postal code. Used to auto-derive the federal state for public holiday calculations." />
</label>
<input
id="rm-postalCode"
type="text"
className={inputClass}
placeholder="80331"
maxLength={5}
value={form.postalCode}
onChange={(e) => {
const plz = e.target.value;
onSetField("postalCode", plz);
if (/^\d{5}$/.test(plz)) {
const inferred = inferStateFromPostalCode(plz);
if (inferred && !form.federalState) {
onSetField("federalState", inferred);
}
}
}}
/>
</div>
<div>
<label className={labelClass} htmlFor="rm-federalState">
Federal State{" "}
<span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
<InfoTooltip content="Determines which public holidays apply (e.g. Bavaria has extra holidays). Auto-derived from postal code." />
</label>
<select
id="rm-federalState"
className={inputClass}
value={form.federalState}
onChange={(e) => onSetField("federalState", e.target.value)}
>
<option value=""> Not specified </option>
{Object.entries(GERMAN_FEDERAL_STATES).map(([abbr, name]) => (
<option key={abbr} value={abbr}>
{name} ({abbr})
</option>
))}
</select>
</div>
</div>
{/* Section: Organization & Classification */}
<p className={sectionHeaderClass}>Organization &amp; Classification</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass} htmlFor="rm-enterpriseId">
Enterprise ID{" "}
<span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
<InfoTooltip content="Corporate directory ID for cross-system integration (e.g. a.kasperovich)." />
</label>
<input
id="rm-enterpriseId"
type="text"
className={inputClass}
placeholder="a.kasperovich"
value={form.enterpriseId}
onChange={(e) => onSetField("enterpriseId", e.target.value)}
/>
</div>
<div>
<label className={labelClass} htmlFor="rm-fte">
FTE
<InfoTooltip content="Full-Time Equivalent (0.01-1.0). A value of 0.5 means the resource works 50% of standard hours." />
</label>
<input
id="rm-fte"
type="number"
min="0.01"
max="1"
step="0.01"
className={inputClass}
placeholder="1.0"
value={form.fte}
onChange={(e) => onSetField("fte", e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<label className={labelClass} htmlFor="rm-countryId">
Country
</label>
<select
id="rm-countryId"
className={inputClass}
value={form.countryId}
onChange={(e) => {
onSetField("countryId", e.target.value);
onSetField("metroCityId", "");
}}
>
<option value=""> Not specified </option>
{countryOptions.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div>
<label className={labelClass} htmlFor="rm-metroCityId">
Metro City
</label>
<select
id="rm-metroCityId"
className={inputClass}
value={form.metroCityId}
onChange={(e) => onSetField("metroCityId", e.target.value)}
disabled={!form.countryId}
>
<option value=""> Not specified </option>
{metroCities.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<label className={labelClass} htmlFor="rm-orgUnitId">
Org Unit (L7 Team)
</label>
<select
id="rm-orgUnitId"
className={inputClass}
value={form.orgUnitId}
onChange={(e) => onSetField("orgUnitId", e.target.value)}
>
<option value=""> Not specified </option>
{orgUnitOptions
.filter((u) => u.level === 7 && u.isActive)
.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</div>
<div>
<label className={labelClass} htmlFor="rm-clientUnitId">
Client Unit
</label>
<select
id="rm-clientUnitId"
className={inputClass}
value={form.clientUnitId}
onChange={(e) => onSetField("clientUnitId", e.target.value)}
>
<option value=""> Not specified </option>
{clientOptions.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<label className={labelClass} htmlFor="rm-mgmtGroupId">
Management Level Group
<InfoTooltip content="Seniority grouping (e.g. Associate, Manager, Director). Determines the available management levels." />
</label>
<select
id="rm-mgmtGroupId"
className={inputClass}
value={form.managementLevelGroupId}
onChange={(e) => {
onSetField("managementLevelGroupId", e.target.value);
onSetField("managementLevelId", "");
}}
>
<option value=""> Not specified </option>
{managementGroupOptions.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
</div>
<div>
<label className={labelClass} htmlFor="rm-mgmtLevelId">
Management Level
<InfoTooltip content="Specific seniority level within the group. Used in chargeability reports and cost analysis." />
</label>
<select
id="rm-mgmtLevelId"
className={inputClass}
value={form.managementLevelId}
onChange={(e) => onSetField("managementLevelId", e.target.value)}
disabled={!form.managementLevelGroupId}
>
<option value=""> Not specified </option>
{mgmtLevels.map((l) => (
<option key={l.id} value={l.id}>
{l.name}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-4 gap-4 mt-4">
<div>
<label className={labelClass} htmlFor="rm-resourceType">
Resource Type
<InfoTooltip content="Employee, contractor, or freelancer. Affects cost attribution rules." />
</label>
<select
id="rm-resourceType"
className={inputClass}
value={form.resourceType}
onChange={(e) => onSetField("resourceType", e.target.value)}
>
{Object.values(ResourceType).map((t) => (
<option key={t} value={t}>
{t.charAt(0) + t.slice(1).toLowerCase()}
</option>
))}
</select>
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={form.chgResponsibility}
onChange={(e) => onSetField("chgResponsibility", e.target.checked)}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Chg Responsibility
</label>
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={form.rolledOff}
onChange={(e) => onSetField("rolledOff", e.target.checked)}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Rolled Off
</label>
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={form.departed}
onChange={(e) => onSetField("departed", e.target.checked)}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Departed
</label>
</div>
</div>
</>
);
}
@@ -0,0 +1,152 @@
interface SkillRow {
skill: string;
proficiency: 1 | 2 | 3 | 4 | 5;
yearsExperience: string;
category: string;
certified: boolean;
isMainSkill: boolean;
}
const proficiencyLabels: Record<number, string> = {
1: "1 \u2013 Beginner",
2: "2 \u2013 Elementary",
3: "3 \u2013 Intermediate",
4: "4 \u2013 Advanced",
5: "5 \u2013 Expert",
};
interface ResourceSkillsEditorProps {
skills: SkillRow[];
onSetSkillField: (index: number, key: keyof SkillRow, value: string | number | boolean) => void;
onAddSkill: () => void;
onRemoveSkill: (index: number) => void;
inputClass: string;
labelClass: string;
}
export function ResourceSkillsEditor({
skills,
onSetSkillField,
onAddSkill,
onRemoveSkill,
inputClass,
labelClass,
}: ResourceSkillsEditorProps) {
return (
<div className="space-y-3">
{skills.map((skillRow, idx) => {
const mainSkillCount = skills.filter((s) => s.isMainSkill).length;
const canToggleMain = skillRow.isMainSkill || mainSkillCount < 2;
return (
<div
key={idx}
className={`grid gap-2 items-end border rounded-lg p-3 ${skillRow.isMainSkill ? "border-amber-200 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20" : "border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900"}`}
>
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-2 items-end">
<div>
<label className={labelClass} htmlFor={`rm-skill-name-${idx}`}>
Skill
</label>
<input
id={`rm-skill-name-${idx}`}
type="text"
className={inputClass}
placeholder="e.g. 3ds Max"
value={skillRow.skill}
onChange={(e) => onSetSkillField(idx, "skill", e.target.value)}
/>
</div>
<div>
<label className={labelClass} htmlFor={`rm-skill-prof-${idx}`}>
Proficiency
</label>
<select
id={`rm-skill-prof-${idx}`}
className={inputClass}
value={skillRow.proficiency}
onChange={(e) =>
onSetSkillField(
idx,
"proficiency",
parseInt(e.target.value, 10) as 1 | 2 | 3 | 4 | 5,
)
}
>
{[1, 2, 3, 4, 5].map((p) => (
<option key={p} value={p}>
{proficiencyLabels[p]}
</option>
))}
</select>
</div>
<div>
<label className={labelClass} htmlFor={`rm-skill-years-${idx}`}>
Years
</label>
<input
id={`rm-skill-years-${idx}`}
type="number"
min="0"
max="50"
step="1"
className={inputClass}
placeholder="\u2014"
value={skillRow.yearsExperience}
onChange={(e) => onSetSkillField(idx, "yearsExperience", e.target.value)}
/>
</div>
<div className="flex flex-col items-center gap-1 pb-0.5">
<span className="text-[10px] text-gray-500 dark:text-gray-400 leading-none">
\u2605 Main
</span>
<input
type="checkbox"
checked={skillRow.isMainSkill}
disabled={!canToggleMain}
title={!canToggleMain ? "Max 2 main skills" : "Mark as main skill"}
onChange={(e) => onSetSkillField(idx, "isMainSkill", e.target.checked)}
className="rounded border-gray-300 disabled:opacity-40"
/>
</div>
<div className="flex items-end pb-0.5">
<button
type="button"
onClick={() => onRemoveSkill(idx)}
className="px-2 py-2 text-red-400 hover:text-red-600 transition-colors"
aria-label={`Remove skill ${idx + 1}`}
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
);
})}
<button
type="button"
onClick={onAddSkill}
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-800 font-medium transition-colors"
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
Add skill
</button>
</div>
);
}
@@ -15,7 +15,7 @@ const SNOOZE_DAYS = 7;
* Snooze state is scoped by userId to prevent cross-user leakage on shared browsers. * Snooze state is scoped by userId to prevent cross-user leakage on shared browsers.
*/ */
export function MfaPromptBanner() { export function MfaPromptBanner() {
const { data: mfaStatus } = trpc.user.getMfaStatus.useQuery(); const { data: mfaStatus, isError } = trpc.user.getMfaStatus.useQuery();
const { data: session } = useSession(); const { data: session } = useSession();
const userId = (session?.user as { id?: string } | undefined)?.id ?? ""; const userId = (session?.user as { id?: string } | undefined)?.id ?? "";
const [snoozed, setSnoozed] = useState<boolean | null>(null); const [snoozed, setSnoozed] = useState<boolean | null>(null);
@@ -48,8 +48,8 @@ export function MfaPromptBanner() {
setSnoozed(true); setSnoozed(true);
} }
// Don't render until we know the MFA status and snooze state // Don't render until we know the MFA status and snooze state; silently hide on error
if (mfaStatus === undefined || snoozed === null) return null; if (isError || mfaStatus === undefined || snoozed === null) return null;
// Already enabled — no banner needed // Already enabled — no banner needed
if (mfaStatus.totpEnabled) return null; if (mfaStatus.totpEnabled) return null;
// Snoozed // Snoozed
@@ -62,8 +62,8 @@ export function MfaPromptBanner() {
className="flex items-center justify-between gap-4 bg-amber-50 px-4 py-2.5 text-sm text-amber-900 dark:bg-amber-900/20 dark:text-amber-200 border-b border-amber-200 dark:border-amber-700/50" className="flex items-center justify-between gap-4 bg-amber-50 px-4 py-2.5 text-sm text-amber-900 dark:bg-amber-900/20 dark:text-amber-200 border-b border-amber-200 dark:border-amber-700/50"
> >
<span> <span>
<strong className="font-semibold">Protect your account:</strong>{" "} <strong className="font-semibold">Protect your account:</strong> Your role has elevated
Your role has elevated permissions. We recommend enabling multi-factor authentication (MFA). permissions. We recommend enabling multi-factor authentication (MFA).
</span> </span>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<Link <Link
@@ -221,6 +221,7 @@ export function AllocationPopover({
</div> </div>
<button <button
onClick={onClose} onClick={onClose}
aria-label="Close"
className="text-gray-400 hover:text-gray-600 text-lg leading-none" className="text-gray-400 hover:text-gray-600 text-lg leading-none"
> >
&times; &times;
@@ -105,6 +105,7 @@ export function BatchAssignPopover({
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
aria-label="Close"
className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 text-lg leading-none" className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 text-lg leading-none"
> >
&times; &times;
@@ -39,12 +39,18 @@ export function DemandPopover({
const roleColor = demand.roleEntity?.color ?? "#f59e0b"; const roleColor = demand.roleEntity?.color ?? "#f59e0b";
const startDate = new Date(demand.startDate); const startDate = new Date(demand.startDate);
const endDate = new Date(demand.endDate); 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,
);
const totalHours = demand.hoursPerDay * days; const totalHours = demand.hoursPerDay * days;
const budgetCents = demand.dailyCostCents * days; const budgetCents = demand.dailyCostCents * days;
// eslint-disable-next-line @typescript-eslint/no-explicit-any const { data: suggestionData, isLoading: loadingSuggestions } = (
const { data: suggestionData, isLoading: loadingSuggestions } = (trpc.staffing.getProjectStaffingSuggestions.useQuery as any)( trpc.staffing.getProjectStaffingSuggestions.useQuery as unknown as (
...args: unknown[]
) => unknown
)(
{ {
projectId: demand.projectId, projectId: demand.projectId,
roleName: demand.role ?? undefined, roleName: demand.role ?? undefined,
@@ -53,7 +59,20 @@ export function DemandPopover({
limit: 3, limit: 3,
}, },
{ staleTime: 60_000, retry: false }, { staleTime: 60_000, retry: false },
) as { data: { suggestions: Array<{ id: string; name: string; eid: string; availableHoursPerDay: number; utilization: number }> } | undefined; isLoading: boolean }; ) as {
data:
| {
suggestions: Array<{
id: string;
name: string;
eid: string;
availableHoursPerDay: number;
utilization: number;
}>;
}
| undefined;
isLoading: boolean;
};
const suggestions = suggestionData?.suggestions ?? []; const suggestions = suggestionData?.suggestions ?? [];
const popover = ( const popover = (
@@ -78,6 +97,7 @@ export function DemandPopover({
</div> </div>
<button <button
onClick={onClose} onClick={onClose}
aria-label="Close"
className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 text-lg leading-none ml-2" className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 text-lg leading-none ml-2"
> >
&times; &times;
@@ -90,8 +110,7 @@ export function DemandPopover({
Project:{" "} Project:{" "}
<span className="font-medium text-gray-700 dark:text-gray-200"> <span className="font-medium text-gray-700 dark:text-gray-200">
{demand.project.name} {demand.project.name}
</span> </span>{" "}
{" "}
<span className="text-gray-400 dark:text-gray-500">({demand.project.shortCode})</span> <span className="text-gray-400 dark:text-gray-500">({demand.project.shortCode})</span>
</div> </div>
@@ -100,9 +119,7 @@ export function DemandPopover({
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400 border border-dashed border-amber-300 dark:border-amber-700"> <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400 border border-dashed border-amber-300 dark:border-amber-700">
Open Demand Open Demand
</span> </span>
<span className="text-[11px] text-gray-400 dark:text-gray-500"> <span className="text-[11px] text-gray-400 dark:text-gray-500">{demand.status}</span>
{demand.status}
</span>
</div> </div>
{/* Headcount */} {/* Headcount */}
@@ -137,11 +154,15 @@ export function DemandPopover({
{/* Hours */} {/* Hours */}
<div> <div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Hours / day</div> <div className="text-gray-400 dark:text-gray-500 mb-0.5">Hours / day</div>
<div className="font-medium text-gray-800 dark:text-gray-200">{demand.hoursPerDay}h</div> <div className="font-medium text-gray-800 dark:text-gray-200">
{demand.hoursPerDay}h
</div>
</div> </div>
<div> <div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Total hours</div> <div className="text-gray-400 dark:text-gray-500 mb-0.5">Total hours</div>
<div className="font-medium text-gray-800 dark:text-gray-200">{totalHours}h ({days}d)</div> <div className="font-medium text-gray-800 dark:text-gray-200">
{totalHours}h ({days}d)
</div>
</div> </div>
{/* Budget */} {/* Budget */}
@@ -166,7 +187,9 @@ export function DemandPopover({
{demand.percentage > 0 && ( {demand.percentage > 0 && (
<div> <div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Percentage</div> <div className="text-gray-400 dark:text-gray-500 mb-0.5">Percentage</div>
<div className="font-medium text-gray-800 dark:text-gray-200">{demand.percentage}%</div> <div className="font-medium text-gray-800 dark:text-gray-200">
{demand.percentage}%
</div>
</div> </div>
)} )}
</div> </div>
@@ -175,8 +198,18 @@ export function DemandPopover({
{(loadingSuggestions || suggestions.length > 0) && ( {(loadingSuggestions || suggestions.length > 0) && (
<div className="pt-2 border-t border-gray-100 dark:border-gray-700"> <div className="pt-2 border-t border-gray-100 dark:border-gray-700">
<div className="flex items-center gap-1 mb-2"> <div className="flex items-center gap-1 mb-2">
<svg className="h-3.5 w-3.5 text-brand-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /> className="h-3.5 w-3.5 text-brand-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg> </svg>
<span className="text-[11px] font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide"> <span className="text-[11px] font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
Suggested Resources Suggested Resources
@@ -205,13 +238,19 @@ export function DemandPopover({
</span> </span>
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-xs font-medium text-gray-800 dark:text-gray-200 truncate">{s.name}</div> <div className="text-xs font-medium text-gray-800 dark:text-gray-200 truncate">
{s.name}
</div>
<div className="text-[11px] text-gray-400 dark:text-gray-500"> <div className="text-[11px] text-gray-400 dark:text-gray-500">
{Math.round(s.utilization)}% utilized · {s.availableHoursPerDay.toFixed(1)}h/d free {Math.round(s.utilization)}% utilized · {s.availableHoursPerDay.toFixed(1)}
h/d free
</div> </div>
</div> </div>
<button <button
onClick={() => { onClose(); onFillDemand(demand); }} onClick={() => {
onClose();
onFillDemand(demand);
}}
className="shrink-0 rounded px-2 py-1 text-[11px] font-medium bg-brand-50 text-brand-700 hover:bg-brand-100 dark:bg-brand-900/30 dark:text-brand-300 dark:hover:bg-brand-900/50 transition-colors" className="shrink-0 rounded px-2 py-1 text-[11px] font-medium bg-brand-50 text-brand-700 hover:bg-brand-100 dark:bg-brand-900/30 dark:text-brand-300 dark:hover:bg-brand-900/50 transition-colors"
title={`Assign ${s.name}`} title={`Assign ${s.name}`}
> >
@@ -228,14 +267,20 @@ export function DemandPopover({
<div className="flex items-center gap-2 pt-2 border-t border-gray-100 dark:border-gray-700"> <div className="flex items-center gap-2 pt-2 border-t border-gray-100 dark:border-gray-700">
{demand.unfilledHeadcount > 0 && ( {demand.unfilledHeadcount > 0 && (
<button <button
onClick={() => { onClose(); onFillDemand(demand); }} onClick={() => {
onClose();
onFillDemand(demand);
}}
className="flex-1 py-1.5 rounded-lg text-sm font-medium bg-amber-500 text-white hover:bg-amber-600 transition-colors" className="flex-1 py-1.5 rounded-lg text-sm font-medium bg-amber-500 text-white hover:bg-amber-600 transition-colors"
> >
Fill Demand Fill Demand
</button> </button>
)} )}
<button <button
onClick={() => { onClose(); onOpenPanel(demand.projectId); }} onClick={() => {
onClose();
onOpenPanel(demand.projectId);
}}
className="flex-1 py-1.5 rounded-lg text-sm font-medium border border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" className="flex-1 py-1.5 rounded-lg text-sm font-medium border border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
> >
Open Project Open Project
@@ -1,8 +1,8 @@
"use client"; "use client";
const SHORTCUTS: { keys: string; description: string }[] = [ const SHORTCUTS: { keys: string; description: string }[] = [
{ keys: "← / →", description: "Scroll timeline 1 day" }, { keys: "\u2190 / \u2192", description: "Scroll timeline 1 day" },
{ keys: "Shift + ← / →", description: "Scroll timeline 1 week" }, { keys: "Shift + \u2190 / \u2192", description: "Scroll timeline 1 week" },
{ keys: "Delete / Backspace", description: "Delete selected allocations" }, { keys: "Delete / Backspace", description: "Delete selected allocations" },
{ keys: "Ctrl / Cmd + Z", description: "Undo last action" }, { keys: "Ctrl / Cmd + Z", description: "Undo last action" },
{ keys: "Ctrl / Cmd + Shift + Z", description: "Redo" }, { keys: "Ctrl / Cmd + Shift + Z", description: "Redo" },
@@ -17,15 +17,27 @@ interface KeyboardShortcutOverlayProps {
export function KeyboardShortcutOverlay({ onClose }: KeyboardShortcutOverlayProps) { export function KeyboardShortcutOverlay({ onClose }: KeyboardShortcutOverlayProps) {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}> <div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-labelledby="keyboard-shortcuts-title"
>
<div <div
className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-sm mx-4 overflow-hidden" className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-sm mx-4 overflow-hidden"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 dark:border-gray-700"> <div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 dark:border-gray-700">
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Keyboard Shortcuts</h2> <h2
id="keyboard-shortcuts-title"
className="text-sm font-semibold text-gray-900 dark:text-gray-100"
>
Keyboard Shortcuts
</h2>
<button <button
onClick={onClose} onClick={onClose}
aria-label="Close keyboard shortcuts"
className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 text-lg leading-none" className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 text-lg leading-none"
> >
&times; &times;
@@ -96,6 +96,7 @@ export function NewAllocationPopover({
</span> </span>
<button <button
onClick={onClose} onClick={onClose}
aria-label="Close"
className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 text-lg leading-none" className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 text-lg leading-none"
> >
&times; &times;
@@ -584,6 +584,7 @@ function PanelShell({ children, onClose }: { children: React.ReactNode; onClose:
<span className="text-sm font-semibold text-gray-700">Project Details</span> <span className="text-sm font-semibold text-gray-700">Project Details</span>
<button <button
onClick={onClose} onClick={onClose}
aria-label="Close panel"
className="w-7 h-7 rounded-lg flex items-center justify-center text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors text-lg leading-none" className="w-7 h-7 rounded-lg flex items-center justify-center text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors text-lg leading-none"
> >
&times; &times;
@@ -0,0 +1,147 @@
import { MILLISECONDS_PER_DAY } from "@capakraken/shared";
import type { useTimelineDrag } from "~/hooks/useTimelineDrag.js";
import { formatDateShort } from "~/lib/format.js";
import { ShiftPreviewTooltip } from "./ShiftPreviewTooltip.js";
interface TimelineDragOverlaysProps {
dragState: ReturnType<typeof useTimelineDrag>["dragState"];
allocDragState: ReturnType<typeof useTimelineDrag>["allocDragState"];
rangeState: ReturnType<typeof useTimelineDrag>["rangeState"];
multiSelectState: ReturnType<typeof useTimelineDrag>["multiSelectState"];
shiftPreview: ReturnType<typeof useTimelineDrag>["shiftPreview"];
isPreviewLoading: boolean;
isApplying: boolean;
isAllocSaving: boolean;
mousePosRef: React.RefObject<{ x: number; y: number }>;
dragTooltipRef: React.RefObject<HTMLDivElement | null>;
allocTooltipRef: React.RefObject<HTMLDivElement | null>;
rangeHintRef: React.RefObject<HTMLDivElement | null>;
multiDragTooltipRef: React.RefObject<HTMLDivElement | null>;
today: Date;
}
export function TimelineDragOverlays({
dragState,
allocDragState,
rangeState,
multiSelectState,
shiftPreview,
isPreviewLoading,
isApplying,
isAllocSaving,
mousePosRef,
dragTooltipRef,
allocTooltipRef,
rangeHintRef,
multiDragTooltipRef,
today,
}: TimelineDragOverlaysProps) {
return (
<>
{/* Multi-select rectangle overlay */}
{multiSelectState.isSelecting && (
<div
className="fixed border-2 border-sky-500 bg-sky-500/10 pointer-events-none z-30 rounded"
style={{
left: Math.min(multiSelectState.startX, multiSelectState.currentX),
top: Math.min(multiSelectState.startY, multiSelectState.currentY),
width: Math.abs(multiSelectState.currentX - multiSelectState.startX),
height: Math.abs(multiSelectState.currentY - multiSelectState.startY),
}}
/>
)}
{/* Saving indicators */}
{(isApplying || isAllocSaving) && (
<div className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center rounded-2xl bg-white/50 dark:bg-gray-950/50">
<div className="app-surface px-5 py-3 text-sm font-medium text-gray-700 dark:text-gray-200">
{isApplying ? "Applying shift…" : "Saving…"}
</div>
</div>
)}
{/* Drag preview tooltip */}
{dragState.isDragging && dragState.daysDelta !== 0 && (
<div
ref={dragTooltipRef}
className="fixed z-50 pointer-events-none"
style={{ left: mousePosRef.current.x + 12, top: mousePosRef.current.y - 8 }}
>
<ShiftPreviewTooltip
preview={
shiftPreview ?? {
valid: true,
deltaCents: 0,
wouldExceedBudget: false,
budgetUtilizationAfter: 0,
conflictCount: 0,
errors: [],
warnings: [],
}
}
projectName={dragState.projectName ?? ""}
newStartDate={dragState.currentStartDate ?? today}
newEndDate={dragState.currentEndDate ?? today}
isLoading={isPreviewLoading}
/>
</div>
)}
{/* Alloc drag tooltip */}
{allocDragState.isActive &&
allocDragState.daysDelta !== 0 &&
allocDragState.currentStartDate &&
allocDragState.currentEndDate && (
<div
ref={allocTooltipRef}
className="fixed z-40 bg-gray-800 text-white text-xs px-2.5 py-1.5 rounded-lg pointer-events-none shadow-lg space-y-0.5"
style={{ left: mousePosRef.current.x + 14, top: mousePosRef.current.y - 36 }}
>
<div className="font-semibold">{allocDragState.projectName}</div>
<div className="opacity-80">
{formatDateShort(allocDragState.currentStartDate)}
{" "}
{formatDateShort(allocDragState.currentEndDate)}
</div>
</div>
)}
{/* Range-select hint */}
{rangeState.isSelecting && rangeState.startDate && rangeState.currentDate && (
<div
ref={rangeHintRef}
className="fixed z-40 bg-brand-700 text-white text-xs px-2 py-1 rounded-lg pointer-events-none shadow"
style={{ left: mousePosRef.current.x + 12, top: mousePosRef.current.y - 28 }}
>
{(() => {
const end = rangeState.currentDate;
const [s, e] =
rangeState.startDate <= end
? [rangeState.startDate, end]
: [end, rangeState.startDate];
const days = Math.round((e.getTime() - s.getTime()) / MILLISECONDS_PER_DAY) + 1;
return `${days} day${days !== 1 ? "s" : ""}`;
})()}
</div>
)}
{/* Multi-drag tooltip */}
{multiSelectState.isMultiDragging && multiSelectState.multiDragDaysDelta !== 0 && (
<div
ref={multiDragTooltipRef}
className="fixed z-50 bg-sky-700 text-white text-xs px-2.5 py-1.5 rounded-lg pointer-events-none shadow-lg font-medium"
style={{ left: mousePosRef.current.x + 14, top: mousePosRef.current.y - 36 }}
>
{multiSelectState.multiDragMode === "resize-start"
? "Start "
: multiSelectState.multiDragMode === "resize-end"
? "End "
: ""}
{multiSelectState.multiDragDaysDelta > 0 ? "+" : ""}
{multiSelectState.multiDragDaysDelta}d ({multiSelectState.selectedAllocationIds.length}{" "}
allocations)
</div>
)}
</>
);
}
@@ -1,5 +1,3 @@
"use client";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { MONTHS_SHORT } from "./timelineConstants.js"; import { MONTHS_SHORT } from "./timelineConstants.js";
@@ -33,7 +31,10 @@ export function TimelineHeader({
className="sticky top-0 z-40 flex bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800" className="sticky top-0 z-40 flex bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800"
style={{ height: HEADER_MONTH_HEIGHT }} style={{ height: HEADER_MONTH_HEIGHT }}
> >
<div className="flex-shrink-0 border-r border-gray-200 dark:border-gray-700" style={{ width: LABEL_WIDTH }} /> <div
className="flex-shrink-0 border-r border-gray-200 dark:border-gray-700"
style={{ width: LABEL_WIDTH }}
/>
<div className="flex"> <div className="flex">
{monthGroups.map((m, i) => ( {monthGroups.map((m, i) => (
<div <div
@@ -72,27 +73,41 @@ export function TimelineHeader({
key={i} key={i}
className={clsx( className={clsx(
"flex-shrink-0 border-r flex flex-col items-center justify-center text-xs overflow-hidden", "flex-shrink-0 border-r flex flex-col items-center justify-center text-xs overflow-hidden",
isToday ? "bg-brand-50 dark:bg-brand-950/40 border-brand-200 dark:border-brand-800" : isToday
isWeekend ? "bg-brand-50/60 dark:bg-brand-950/30 border-brand-200 dark:border-brand-800" : ? "bg-brand-50 dark:bg-brand-950/40 border-brand-200 dark:border-brand-800"
isMonday ? "border-gray-200 dark:border-gray-700" : "border-gray-100 dark:border-gray-800", : isWeekend
? "bg-brand-50/60 dark:bg-brand-950/30 border-brand-200 dark:border-brand-800"
: isMonday
? "border-gray-200 dark:border-gray-700"
: "border-gray-100 dark:border-gray-800",
)} )}
style={{ width: CELL_WIDTH, height: HEADER_DAY_HEIGHT }} style={{ width: CELL_WIDTH, height: HEADER_DAY_HEIGHT }}
> >
{showLabel && ( {showLabel && (
<> <>
<span className={clsx( <span
"font-medium leading-none", className={clsx(
isToday ? "text-brand-600" : isWeekend ? "text-brand-600 dark:text-brand-400" : "text-gray-600 dark:text-gray-300", "font-medium leading-none",
)}> isToday
? "text-brand-600"
: isWeekend
? "text-brand-600 dark:text-brand-400"
: "text-gray-600 dark:text-gray-300",
)}
>
{zoom === "week" {zoom === "week"
? `${date.getDate()} ${MONTHS_SHORT[date.getMonth()]}` ? `${date.getDate()} ${MONTHS_SHORT[date.getMonth()]}`
: date.getDate()} : date.getDate()}
</span> </span>
{zoom === "day" && ( {zoom === "day" && (
<span className={clsx( <span
"text-[9px] leading-none mt-0.5", className={clsx(
isWeekend ? "text-brand-400 dark:text-brand-500" : "text-gray-300 dark:text-gray-600", "text-[9px] leading-none mt-0.5",
)}> isWeekend
? "text-brand-400 dark:text-brand-500"
: "text-gray-300 dark:text-gray-600",
)}
>
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"][dow]} {["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"][dow]}
</span> </span>
)} )}
@@ -0,0 +1,262 @@
import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js";
import { AllocationPopover } from "./AllocationPopover.js";
import { BatchAssignPopover } from "./BatchAssignPopover.js";
import { DemandPopover } from "./DemandPopover.js";
import { InlineAllocationEditor } from "./InlineAllocationEditor.js";
import { KeyboardShortcutOverlay } from "./KeyboardShortcutOverlay.js";
import { NewAllocationPopover } from "./NewAllocationPopover.js";
import { ProjectPanel } from "./ProjectPanel.js";
import { ResourceHoverCard } from "./ResourceHoverCard.js";
import type { TimelineDemandEntry, TimelineAssignmentEntry } from "./TimelineContext.js";
import type { OpenDemandAssignment } from "./TimelineProjectPanel.js";
import type { useTimelineDrag } from "~/hooks/useTimelineDrag.js";
interface TimelinePopoversProps {
isSelfServiceTimeline: boolean;
hasActivePointerOverlay: boolean;
popover: {
allocationId: string;
projectId: string;
allocation?: TimelineAssignmentEntry | null;
x: number;
y: number;
contextDate?: Date;
} | null;
setPopover: React.Dispatch<React.SetStateAction<TimelinePopoversProps["popover"]>>;
demandPopover: { demand: TimelineDemandEntry; x: number; y: number } | null;
setDemandPopover: React.Dispatch<React.SetStateAction<TimelinePopoversProps["demandPopover"]>>;
newAllocPopover: {
resourceId: string;
startDate: Date;
endDate: Date;
suggestedProjectId: string | null;
anchorX: number;
anchorY: number;
selectionResourceId: string;
selectionStart: Date;
selectionEnd: Date;
} | null;
setNewAllocPopover: React.Dispatch<
React.SetStateAction<TimelinePopoversProps["newAllocPopover"]>
>;
enrichedSuggestedProjectId: string | null;
openPanelProjectId: string | null;
setOpenPanelProjectId: React.Dispatch<React.SetStateAction<string | null>>;
openDemandToAssign: OpenDemandAssignment | null;
setOpenDemandToAssign: React.Dispatch<React.SetStateAction<OpenDemandAssignment | null>>;
openDemandsByProject: Map<string, TimelineDemandEntry[]>;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
multiSelectState: ReturnType<typeof useTimelineDrag>["multiSelectState"];
clearMultiSelect: ReturnType<typeof useTimelineDrag>["clearMultiSelect"];
handleBatchDelete: () => void;
handleShowBatchAssign: () => void;
isDeleting: boolean;
showBatchAssign: boolean;
setShowBatchAssign: React.Dispatch<React.SetStateAction<boolean>>;
resourceHover: { resourceId: string; anchorEl: HTMLElement } | null;
setResourceHover: React.Dispatch<React.SetStateAction<TimelinePopoversProps["resourceHover"]>>;
inlineEditTarget: {
allocationId: string;
startDate: Date;
endDate: Date;
hoursPerDay: number;
barRect: DOMRect;
} | null;
setInlineEditTarget: React.Dispatch<
React.SetStateAction<TimelinePopoversProps["inlineEditTarget"]>
>;
showShortcuts: boolean;
setShowShortcuts: React.Dispatch<React.SetStateAction<boolean>>;
}
function buildDemandAssignment(d: TimelineDemandEntry): OpenDemandAssignment {
return {
id: d.id,
projectId: d.projectId,
roleId: d.roleId,
role: d.role,
headcount: d.requestedHeadcount,
startDate: new Date(d.startDate),
endDate: new Date(d.endDate),
hoursPerDay: d.hoursPerDay,
...(d.roleEntity !== undefined ? { roleEntity: d.roleEntity } : {}),
...(d.project !== undefined ? { project: d.project } : {}),
};
}
export function TimelinePopovers({
isSelfServiceTimeline,
hasActivePointerOverlay,
popover,
setPopover,
demandPopover,
setDemandPopover,
newAllocPopover,
setNewAllocPopover,
enrichedSuggestedProjectId,
openPanelProjectId,
setOpenPanelProjectId,
openDemandToAssign,
setOpenDemandToAssign,
openDemandsByProject,
scrollContainerRef,
multiSelectState,
clearMultiSelect,
handleBatchDelete,
handleShowBatchAssign,
isDeleting,
showBatchAssign,
setShowBatchAssign,
resourceHover,
setResourceHover,
inlineEditTarget,
setInlineEditTarget,
showShortcuts,
setShowShortcuts,
}: TimelinePopoversProps) {
return (
<>
{/* Allocation / Demand popover (click path) */}
{!isSelfServiceTimeline &&
!hasActivePointerOverlay &&
popover &&
(() => {
const clickedDemand = openDemandsByProject
.get(popover.projectId)
?.find((d) => d.id === popover.allocationId);
if (clickedDemand) {
return (
<DemandPopover
demand={clickedDemand}
onClose={() => setPopover(null)}
onOpenPanel={(pid) => {
setPopover(null);
setOpenPanelProjectId(pid);
}}
onFillDemand={(d) => {
setPopover(null);
setOpenDemandToAssign(buildDemandAssignment(d));
}}
anchorX={popover.x}
anchorY={popover.y}
ignoreScrollContainers={[scrollContainerRef]}
/>
);
}
return (
<AllocationPopover
allocationId={popover.allocationId}
projectId={popover.projectId}
initialAllocation={popover.allocation ?? null}
onClose={() => setPopover(null)}
onOpenPanel={(pid) => {
setPopover(null);
setOpenPanelProjectId(pid);
}}
anchorX={popover.x}
anchorY={popover.y}
ignoreScrollContainers={[scrollContainerRef]}
{...(popover.contextDate ? { contextDate: popover.contextDate } : {})}
/>
);
})()}
{/* Demand popover (context menu path) */}
{!isSelfServiceTimeline && !hasActivePointerOverlay && demandPopover && (
<DemandPopover
demand={demandPopover.demand}
onClose={() => setDemandPopover(null)}
onOpenPanel={(pid) => {
setDemandPopover(null);
setOpenPanelProjectId(pid);
}}
onFillDemand={(d) => {
setDemandPopover(null);
setOpenDemandToAssign(buildDemandAssignment(d));
}}
anchorX={demandPopover.x}
anchorY={demandPopover.y}
ignoreScrollContainers={[scrollContainerRef]}
/>
)}
{/* New allocation popover */}
{!isSelfServiceTimeline && newAllocPopover && (
<NewAllocationPopover
resourceId={newAllocPopover.resourceId}
startDate={newAllocPopover.startDate}
endDate={newAllocPopover.endDate}
suggestedProjectId={enrichedSuggestedProjectId}
anchorX={newAllocPopover.anchorX}
anchorY={newAllocPopover.anchorY}
onClose={() => setNewAllocPopover(null)}
onCreated={() => setNewAllocPopover(null)}
ignoreScrollContainers={[scrollContainerRef]}
/>
)}
{/* Project side panel */}
{!isSelfServiceTimeline && openPanelProjectId && (
<ProjectPanel projectId={openPanelProjectId} onClose={() => setOpenPanelProjectId(null)} />
)}
{/* Open-demand assignment modal */}
{!isSelfServiceTimeline && openDemandToAssign && (
<FillOpenDemandModal
allocation={openDemandToAssign}
onClose={() => setOpenDemandToAssign(null)}
onSuccess={() => setOpenDemandToAssign(null)}
/>
)}
{/* Multi-select floating action bar + batch assign */}
{showBatchAssign && multiSelectState.dateRange && (
<BatchAssignPopover
resourceIds={multiSelectState.selectedResourceIds}
startDate={multiSelectState.dateRange.start}
endDate={multiSelectState.dateRange.end}
onClose={() => setShowBatchAssign(false)}
onCreated={() => {
setShowBatchAssign(false);
clearMultiSelect();
}}
/>
)}
{/* Resource hover card */}
{!hasActivePointerOverlay && resourceHover && (
<ResourceHoverCard
resourceId={resourceHover.resourceId}
anchorEl={resourceHover.anchorEl}
onClose={() => setResourceHover(null)}
/>
)}
{/* Inline allocation editor */}
{inlineEditTarget && (
<InlineAllocationEditor
allocationId={inlineEditTarget.allocationId}
initialStartDate={inlineEditTarget.startDate}
initialEndDate={inlineEditTarget.endDate}
initialHoursPerDay={inlineEditTarget.hoursPerDay}
barRect={inlineEditTarget.barRect}
onClose={() => setInlineEditTarget(null)}
onSaved={() => setInlineEditTarget(null)}
/>
)}
{/* Keyboard shortcut overlay */}
{showShortcuts && <KeyboardShortcutOverlay onClose={() => setShowShortcuts(false)} />}
{/* Keyboard shortcut hint button */}
<button
type="button"
onClick={() => setShowShortcuts((prev) => !prev)}
title="Keyboard shortcuts (?)"
className="fixed bottom-6 right-6 z-40 rounded-full w-8 h-8 flex items-center justify-center bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 text-sm font-medium"
>
?
</button>
</>
);
}
@@ -132,6 +132,7 @@ export function TimelineToolbar({
onClick={onNavigateBack} onClick={onNavigateBack}
className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800" className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
title="Previous 4 weeks" title="Previous 4 weeks"
aria-label="Previous 4 weeks"
> >
</button> </button>
@@ -147,6 +148,7 @@ export function TimelineToolbar({
onClick={onNavigateForward} onClick={onNavigateForward}
className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800" className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
title="Next 4 weeks" title="Next 4 weeks"
aria-label="Next 4 weeks"
> >
</button> </button>
@@ -160,6 +162,7 @@ export function TimelineToolbar({
onClick={onUndo} onClick={onUndo}
disabled={!canUndo} disabled={!canUndo}
title="Undo (Ctrl+Z)" title="Undo (Ctrl+Z)"
aria-label="Undo"
className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800" className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
> >
@@ -169,6 +172,7 @@ export function TimelineToolbar({
onClick={onRedo} onClick={onRedo}
disabled={!canRedo} disabled={!canRedo}
title="Redo (Ctrl+Shift+Z / Ctrl+Y)" title="Redo (Ctrl+Shift+Z / Ctrl+Y)"
aria-label="Redo"
className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800" className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
> >
+48 -279
View File
@@ -1,6 +1,5 @@
"use client"; "use client";
import { MILLISECONDS_PER_DAY } from "@capakraken/shared";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -11,21 +10,14 @@ import { useTimelineLayout } from "~/hooks/useTimelineLayout.js";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js"; import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js";
import { AllocationPopover } from "./AllocationPopover.js";
import { DemandPopover } from "./DemandPopover.js";
import { ResourceHoverCard } from "./ResourceHoverCard.js";
import type { TimelineDemandEntry } from "./TimelineContext.js"; import type { TimelineDemandEntry } from "./TimelineContext.js";
import { BatchAssignPopover } from "./BatchAssignPopover.js";
import { FloatingActionBar } from "./FloatingActionBar.js"; import { FloatingActionBar } from "./FloatingActionBar.js";
import { NewAllocationPopover } from "./NewAllocationPopover.js"; import { TimelineDragOverlays } from "./TimelineDragOverlays.js";
import { ProjectPanel } from "./ProjectPanel.js";
import { ShiftPreviewTooltip } from "./ShiftPreviewTooltip.js";
import { TimelineHeader } from "./TimelineHeader.js"; import { TimelineHeader } from "./TimelineHeader.js";
import { TimelinePopovers } from "./TimelinePopovers.js";
import { TimelineToolbar } from "./TimelineToolbar.js"; import { TimelineToolbar } from "./TimelineToolbar.js";
import { addDays } from "./utils.js"; import { addDays } from "./utils.js";
import { HEADER_DAY_HEIGHT, HEADER_MONTH_HEIGHT, LABEL_WIDTH } from "./timelineConstants.js"; import { HEADER_DAY_HEIGHT, HEADER_MONTH_HEIGHT, LABEL_WIDTH } from "./timelineConstants.js";
import { formatDateShort } from "~/lib/format.js";
import { import {
TimelineProvider, TimelineProvider,
useTimelineData, useTimelineData,
@@ -984,228 +976,23 @@ function TimelineViewContent({
)} )}
</div> </div>
{/* Multi-select rectangle overlay */} <TimelineDragOverlays
{multiSelectState.isSelecting && ( dragState={dragState}
<div allocDragState={allocDragState}
className="fixed border-2 border-sky-500 bg-sky-500/10 pointer-events-none z-30 rounded" rangeState={rangeState}
style={{ multiSelectState={multiSelectState}
left: Math.min(multiSelectState.startX, multiSelectState.currentX), shiftPreview={shiftPreview}
top: Math.min(multiSelectState.startY, multiSelectState.currentY), isPreviewLoading={isPreviewLoading}
width: Math.abs(multiSelectState.currentX - multiSelectState.startX), isApplying={isApplying}
height: Math.abs(multiSelectState.currentY - multiSelectState.startY), isAllocSaving={isAllocSaving}
}} mousePosRef={mousePosRef}
/> dragTooltipRef={dragTooltipRef}
)} allocTooltipRef={allocTooltipRef}
rangeHintRef={rangeHintRef}
multiDragTooltipRef={multiDragTooltipRef}
today={today}
/>
{/* Saving indicators */}
{(isApplying || isAllocSaving) && (
<div className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center rounded-2xl bg-white/50 dark:bg-gray-950/50">
<div className="app-surface px-5 py-3 text-sm font-medium text-gray-700 dark:text-gray-200">
{isApplying ? "Applying shift…" : "Saving…"}
</div>
</div>
)}
{/* Drag preview tooltip */}
{dragState.isDragging && dragState.daysDelta !== 0 && (
<div
ref={dragTooltipRef}
className="fixed z-50 pointer-events-none"
style={{ left: mousePosRef.current.x + 12, top: mousePosRef.current.y - 8 }}
>
<ShiftPreviewTooltip
preview={
shiftPreview ?? {
valid: true,
deltaCents: 0,
wouldExceedBudget: false,
budgetUtilizationAfter: 0,
conflictCount: 0,
errors: [],
warnings: [],
}
}
projectName={dragState.projectName ?? ""}
newStartDate={dragState.currentStartDate ?? today}
newEndDate={dragState.currentEndDate ?? today}
isLoading={isPreviewLoading}
/>
</div>
)}
{/* Alloc drag tooltip */}
{allocDragState.isActive &&
allocDragState.daysDelta !== 0 &&
allocDragState.currentStartDate &&
allocDragState.currentEndDate && (
<div
ref={allocTooltipRef}
className="fixed z-40 bg-gray-800 text-white text-xs px-2.5 py-1.5 rounded-lg pointer-events-none shadow-lg space-y-0.5"
style={{ left: mousePosRef.current.x + 14, top: mousePosRef.current.y - 36 }}
>
<div className="font-semibold">{allocDragState.projectName}</div>
<div className="opacity-80">
{formatDateShort(allocDragState.currentStartDate)}
{" "}
{formatDateShort(allocDragState.currentEndDate)}
</div>
</div>
)}
{/* Range-select hint */}
{rangeState.isSelecting && rangeState.startDate && rangeState.currentDate && (
<div
ref={rangeHintRef}
className="fixed z-40 bg-brand-700 text-white text-xs px-2 py-1 rounded-lg pointer-events-none shadow"
style={{ left: mousePosRef.current.x + 12, top: mousePosRef.current.y - 28 }}
>
{(() => {
const end = rangeState.currentDate;
const [s, e] =
rangeState.startDate <= end
? [rangeState.startDate, end]
: [end, rangeState.startDate];
const days = Math.round((e.getTime() - s.getTime()) / MILLISECONDS_PER_DAY) + 1;
return `${days} day${days !== 1 ? "s" : ""}`;
})()}
</div>
)}
{/* Multi-drag tooltip */}
{multiSelectState.isMultiDragging && multiSelectState.multiDragDaysDelta !== 0 && (
<div
ref={multiDragTooltipRef}
className="fixed z-50 bg-sky-700 text-white text-xs px-2.5 py-1.5 rounded-lg pointer-events-none shadow-lg font-medium"
style={{ left: mousePosRef.current.x + 14, top: mousePosRef.current.y - 36 }}
>
{multiSelectState.multiDragMode === "resize-start"
? "Start "
: multiSelectState.multiDragMode === "resize-end"
? "End "
: ""}
{multiSelectState.multiDragDaysDelta > 0 ? "+" : ""}
{multiSelectState.multiDragDaysDelta}d ({multiSelectState.selectedAllocationIds.length}{" "}
allocations)
</div>
)}
{/* Allocation / Demand popover (click path) */}
{!isSelfServiceTimeline &&
!hasActivePointerOverlay &&
popover &&
(() => {
// Check if clicked allocation is actually a demand
const clickedDemand = openDemandsByProject
.get(popover.projectId)
?.find((d) => d.id === popover.allocationId);
if (clickedDemand) {
return (
<DemandPopover
demand={clickedDemand}
onClose={() => setPopover(null)}
onOpenPanel={(pid) => {
setPopover(null);
setOpenPanelProjectId(pid);
}}
onFillDemand={(d) => {
setPopover(null);
setOpenDemandToAssign({
id: d.id,
projectId: d.projectId,
roleId: d.roleId,
role: d.role,
headcount: d.requestedHeadcount,
startDate: new Date(d.startDate),
endDate: new Date(d.endDate),
hoursPerDay: d.hoursPerDay,
...(d.roleEntity !== undefined ? { roleEntity: d.roleEntity } : {}),
...(d.project !== undefined ? { project: d.project } : {}),
});
}}
anchorX={popover.x}
anchorY={popover.y}
ignoreScrollContainers={[scrollContainerRef]}
/>
);
}
return (
<AllocationPopover
allocationId={popover.allocationId}
projectId={popover.projectId}
initialAllocation={popover.allocation ?? null}
onClose={() => setPopover(null)}
onOpenPanel={(pid) => {
setPopover(null);
setOpenPanelProjectId(pid);
}}
anchorX={popover.x}
anchorY={popover.y}
ignoreScrollContainers={[scrollContainerRef]}
{...(popover.contextDate ? { contextDate: popover.contextDate } : {})}
/>
);
})()}
{/* Demand popover */}
{!isSelfServiceTimeline && !hasActivePointerOverlay && demandPopover && (
<DemandPopover
demand={demandPopover.demand}
onClose={() => setDemandPopover(null)}
onOpenPanel={(pid) => {
setDemandPopover(null);
setOpenPanelProjectId(pid);
}}
onFillDemand={(d) => {
setDemandPopover(null);
setOpenDemandToAssign({
id: d.id,
projectId: d.projectId,
roleId: d.roleId,
role: d.role,
headcount: d.requestedHeadcount,
startDate: new Date(d.startDate),
endDate: new Date(d.endDate),
hoursPerDay: d.hoursPerDay,
...(d.roleEntity !== undefined ? { roleEntity: d.roleEntity } : {}),
...(d.project !== undefined ? { project: d.project } : {}),
});
}}
anchorX={demandPopover.x}
anchorY={demandPopover.y}
ignoreScrollContainers={[scrollContainerRef]}
/>
)}
{/* New allocation popover */}
{!isSelfServiceTimeline && newAllocPopover && (
<NewAllocationPopover
resourceId={newAllocPopover.resourceId}
startDate={newAllocPopover.startDate}
endDate={newAllocPopover.endDate}
suggestedProjectId={enrichedSuggestedProjectId}
anchorX={newAllocPopover.anchorX}
anchorY={newAllocPopover.anchorY}
onClose={() => setNewAllocPopover(null)}
onCreated={() => setNewAllocPopover(null)}
ignoreScrollContainers={[scrollContainerRef]}
/>
)}
{/* Project side panel */}
{!isSelfServiceTimeline && openPanelProjectId && (
<ProjectPanel projectId={openPanelProjectId} onClose={() => setOpenPanelProjectId(null)} />
)}
{/* Open-demand assignment modal */}
{!isSelfServiceTimeline && openDemandToAssign && (
<FillOpenDemandModal
allocation={openDemandToAssign}
onClose={() => setOpenDemandToAssign(null)}
onSuccess={() => setOpenDemandToAssign(null)}
/>
)}
{/* Multi-select floating action bar */}
<FloatingActionBar <FloatingActionBar
selectedAllocationCount={multiSelectState.selectedAllocationIds.length} selectedAllocationCount={multiSelectState.selectedAllocationIds.length}
selectedResourceCount={multiSelectState.selectedResourceIds.length} selectedResourceCount={multiSelectState.selectedResourceIds.length}
@@ -1215,54 +1002,36 @@ function TimelineViewContent({
isDeleting={batchDeleteMutation.isPending} isDeleting={batchDeleteMutation.isPending}
/> />
{/* Batch assign popover */} <TimelinePopovers
{showBatchAssign && multiSelectState.dateRange && ( isSelfServiceTimeline={isSelfServiceTimeline}
<BatchAssignPopover hasActivePointerOverlay={hasActivePointerOverlay}
resourceIds={multiSelectState.selectedResourceIds} popover={popover}
startDate={multiSelectState.dateRange.start} setPopover={setPopover}
endDate={multiSelectState.dateRange.end} demandPopover={demandPopover}
onClose={() => setShowBatchAssign(false)} setDemandPopover={setDemandPopover}
onCreated={() => { newAllocPopover={newAllocPopover}
setShowBatchAssign(false); setNewAllocPopover={setNewAllocPopover}
clearMultiSelect(); enrichedSuggestedProjectId={enrichedSuggestedProjectId}
}} openPanelProjectId={openPanelProjectId}
/> setOpenPanelProjectId={setOpenPanelProjectId}
)} openDemandToAssign={openDemandToAssign}
setOpenDemandToAssign={setOpenDemandToAssign}
{/* Resource hover card */} openDemandsByProject={openDemandsByProject}
{!hasActivePointerOverlay && resourceHover && ( scrollContainerRef={scrollContainerRef}
<ResourceHoverCard multiSelectState={multiSelectState}
resourceId={resourceHover.resourceId} clearMultiSelect={clearMultiSelect}
anchorEl={resourceHover.anchorEl} handleBatchDelete={handleBatchDelete}
onClose={() => setResourceHover(null)} handleShowBatchAssign={handleShowBatchAssign}
/> isDeleting={batchDeleteMutation.isPending}
)} showBatchAssign={showBatchAssign}
setShowBatchAssign={setShowBatchAssign}
{/* Inline allocation editor */} resourceHover={resourceHover}
{inlineEditTarget && ( setResourceHover={setResourceHover}
<InlineAllocationEditor inlineEditTarget={inlineEditTarget}
allocationId={inlineEditTarget.allocationId} setInlineEditTarget={setInlineEditTarget}
initialStartDate={inlineEditTarget.startDate} showShortcuts={showShortcuts}
initialEndDate={inlineEditTarget.endDate} setShowShortcuts={setShowShortcuts}
initialHoursPerDay={inlineEditTarget.hoursPerDay} />
barRect={inlineEditTarget.barRect}
onClose={() => setInlineEditTarget(null)}
onSaved={() => setInlineEditTarget(null)}
/>
)}
{/* Keyboard shortcut overlay */}
{showShortcuts && <KeyboardShortcutOverlay onClose={() => setShowShortcuts(false)} />}
{/* Keyboard shortcut hint button */}
<button
type="button"
onClick={() => setShowShortcuts((prev) => !prev)}
title="Keyboard shortcuts (?)"
className="fixed bottom-6 right-6 z-40 rounded-full w-8 h-8 flex items-center justify-center bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 text-sm font-medium"
>
?
</button>
</div> </div>
); );
} }
@@ -0,0 +1,236 @@
import { PermissionKey, SystemRole } from "@capakraken/shared";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { projectRouter } from "../router/project.js";
import { createCallerFactory } from "../trpc.js";
vi.mock("../lib/cache.js", () => ({
invalidateDashboardCache: vi.fn(),
}));
vi.mock("../lib/webhook-dispatcher.js", () => ({
dispatchWebhooks: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../lib/logger.js", () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
},
}));
const createCaller = createCallerFactory(projectRouter);
beforeEach(() => {
vi.clearAllMocks();
});
function createManagerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "mgr@example.com", name: "Manager", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_mgr",
systemRole: SystemRole.MANAGER,
permissionOverrides: null,
},
});
}
function createUserCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "user@example.com", name: "User", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_1",
systemRole: SystemRole.USER,
permissionOverrides: null,
},
});
}
function createUnauthenticatedCaller(db: Record<string, unknown>) {
return createCaller({
session: null,
db: db as never,
dbUser: null,
});
}
const VALID_CREATE_INPUT = {
shortCode: "PROJ-001",
name: "Test Project",
orderType: "CHARGEABLE" as const,
allocationType: "INT" as const,
winProbability: 100,
budgetCents: 500000,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-12-31"),
status: "ACTIVE" as const,
responsiblePerson: "Jane Doe",
staffingReqs: [],
dynamicFields: {},
};
function mockDbForCreate(overrides: Record<string, unknown> = {}) {
return {
project: {
findUnique: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({
id: "proj_new",
...VALID_CREATE_INPUT,
}),
},
blueprint: {
findUnique: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => {
const tx = {
project: {
create: vi.fn().mockResolvedValue({ id: "proj_new", ...VALID_CREATE_INPUT }),
update: vi.fn().mockResolvedValue({ id: "proj_1", ...VALID_CREATE_INPUT }),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
return fn(tx);
}),
...overrides,
};
}
describe("project create", () => {
it("rejects unauthenticated requests", async () => {
const caller = createUnauthenticatedCaller(mockDbForCreate());
await expect(caller.create(VALID_CREATE_INPUT)).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
});
it("rejects non-manager users", async () => {
const caller = createUserCaller(mockDbForCreate());
await expect(caller.create(VALID_CREATE_INPUT)).rejects.toMatchObject({
code: "FORBIDDEN",
});
});
it("rejects duplicate short codes", async () => {
const db = mockDbForCreate({
project: {
findUnique: vi.fn().mockResolvedValue({ id: "existing", shortCode: "PROJ-001" }),
create: vi.fn(),
},
});
const caller = createManagerCaller(db);
await expect(caller.create(VALID_CREATE_INPUT)).rejects.toMatchObject({
code: "CONFLICT",
});
});
it("creates project with audit log for managers", async () => {
const db = mockDbForCreate();
const caller = createManagerCaller(db);
const result = await caller.create(VALID_CREATE_INPUT);
expect(result).toMatchObject({ id: "proj_new" });
// Verify transaction was called (audit log + project creation)
expect(db.$transaction).toHaveBeenCalled();
});
it("rejects invalid budget (negative cents)", async () => {
const db = mockDbForCreate();
const caller = createManagerCaller(db);
await expect(caller.create({ ...VALID_CREATE_INPUT, budgetCents: -100 })).rejects.toThrow();
});
});
describe("project update", () => {
it("rejects unauthenticated requests", async () => {
const db = mockDbForCreate();
const caller = createUnauthenticatedCaller(db);
await expect(caller.update({ id: "proj_1", data: { name: "Updated" } })).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
});
it("rejects non-manager users", async () => {
const db = mockDbForCreate();
const caller = createUserCaller(db);
await expect(caller.update({ id: "proj_1", data: { name: "Updated" } })).rejects.toMatchObject({
code: "FORBIDDEN",
});
});
it("throws NOT_FOUND for non-existent project", async () => {
const db = mockDbForCreate({
project: {
findUnique: vi.fn().mockResolvedValue(null),
},
});
const caller = createManagerCaller(db);
await expect(
caller.update({ id: "proj_missing", data: { name: "Updated" } }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("updates project and creates audit log", async () => {
const existing = {
id: "proj_1",
...VALID_CREATE_INPUT,
blueprintId: null,
dynamicFields: {},
};
const db = mockDbForCreate({
project: {
findUnique: vi.fn().mockResolvedValue(existing),
},
});
const caller = createManagerCaller(db);
const result = await caller.update({
id: "proj_1",
data: { name: "Renamed Project" },
});
expect(result).toMatchObject({ id: "proj_1" });
expect(db.$transaction).toHaveBeenCalled();
});
it("allows partial updates (only budget)", async () => {
const existing = {
id: "proj_1",
...VALID_CREATE_INPUT,
blueprintId: null,
dynamicFields: {},
};
const db = mockDbForCreate({
project: {
findUnique: vi.fn().mockResolvedValue(existing),
},
});
const caller = createManagerCaller(db);
const result = await caller.update({
id: "proj_1",
data: { budgetCents: 1000000 },
});
expect(result).toBeDefined();
});
});
@@ -0,0 +1,344 @@
import { PermissionKey, SystemRole } from "@capakraken/shared";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resourceRouter } from "../router/resource.js";
import { createCallerFactory } from "../trpc.js";
vi.mock("../lib/logger.js", () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
},
}));
const createCaller = createCallerFactory(resourceRouter);
beforeEach(() => {
vi.clearAllMocks();
});
function createManagerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "mgr@example.com", name: "Manager", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_mgr",
systemRole: SystemRole.MANAGER,
permissionOverrides: null,
},
});
}
function createAdminCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "admin@example.com", name: "Admin", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_admin",
systemRole: SystemRole.ADMIN,
permissionOverrides: null,
},
});
}
function createUserCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "user@example.com", name: "User", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_1",
systemRole: SystemRole.USER,
permissionOverrides: null,
},
});
}
const VALID_CREATE_INPUT = {
eid: "EMP-001",
displayName: "Jane Doe",
email: "jane@example.com",
chapter: "Engineering",
lcrCents: 5000,
ucrCents: 8000,
currency: "EUR",
chargeabilityTarget: 80,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
},
skills: [],
dynamicFields: {},
};
const MOCK_CREATED_RESOURCE = {
id: "res_new",
...VALID_CREATE_INPUT,
resourceRoles: [],
};
function mockDb(overrides: Record<string, unknown> = {}) {
return {
resource: {
findFirst: vi.fn().mockResolvedValue(null),
findUnique: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
update: vi.fn().mockResolvedValue({ id: "res_1", isActive: false }),
delete: vi.fn().mockResolvedValue({}),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
blueprint: {
findUnique: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
},
assignment: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
vacation: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
resourceRole: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
},
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => {
const tx = {
resource: {
create: vi.fn().mockResolvedValue(MOCK_CREATED_RESOURCE),
update: vi
.fn()
.mockResolvedValue({ id: "res_1", ...VALID_CREATE_INPUT, resourceRoles: [] }),
delete: vi.fn().mockResolvedValue({}),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
},
resourceRole: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
},
assignment: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
vacation: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
$executeRaw: vi.fn().mockResolvedValue(1),
};
return fn(tx);
}),
$executeRaw: vi.fn().mockResolvedValue(1),
...overrides,
};
}
describe("resource create", () => {
it("rejects non-manager users", async () => {
const caller = createUserCaller(mockDb());
await expect(caller.create(VALID_CREATE_INPUT)).rejects.toMatchObject({
code: "FORBIDDEN",
});
});
it("rejects duplicate EID or email", async () => {
const db = mockDb({
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "existing", eid: "EMP-001" }),
},
});
const caller = createManagerCaller(db);
await expect(caller.create(VALID_CREATE_INPUT)).rejects.toMatchObject({
code: "CONFLICT",
});
});
it("rejects more than one primary role", async () => {
const caller = createManagerCaller(mockDb());
await expect(
caller.create({
...VALID_CREATE_INPUT,
roles: [
{ roleId: "role_1", isPrimary: true },
{ roleId: "role_2", isPrimary: true },
],
}),
).rejects.toMatchObject({
code: "BAD_REQUEST",
message: expect.stringContaining("primary role"),
});
});
it("creates resource with audit log for managers", async () => {
const db = mockDb();
const caller = createManagerCaller(db);
const result = await caller.create(VALID_CREATE_INPUT);
expect(result).toMatchObject({ id: "res_new" });
expect(db.$transaction).toHaveBeenCalled();
});
});
describe("resource update", () => {
it("rejects non-manager users", async () => {
const caller = createUserCaller(mockDb());
await expect(
caller.update({ id: "res_1", data: { displayName: "Updated" } }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("throws NOT_FOUND for non-existent resource", async () => {
const db = mockDb({
resource: {
...mockDb().resource,
findUnique: vi.fn().mockResolvedValue(null),
},
});
const caller = createManagerCaller(db);
await expect(
caller.update({ id: "res_missing", data: { displayName: "Updated" } }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("rejects multiple primary roles on update", async () => {
const db = mockDb({
resource: {
...mockDb().resource,
findUnique: vi.fn().mockResolvedValue({
id: "res_1",
...VALID_CREATE_INPUT,
blueprintId: null,
dynamicFields: {},
}),
},
});
const caller = createManagerCaller(db);
await expect(
caller.update({
id: "res_1",
data: {
roles: [
{ roleId: "role_1", isPrimary: true },
{ roleId: "role_2", isPrimary: true },
],
},
}),
).rejects.toMatchObject({
code: "BAD_REQUEST",
message: expect.stringContaining("primary role"),
});
});
});
describe("resource deactivate", () => {
it("rejects non-manager users", async () => {
const caller = createUserCaller(mockDb());
await expect(caller.deactivate({ id: "res_1" })).rejects.toMatchObject({
code: "FORBIDDEN",
});
});
it("soft-deletes resource for managers", async () => {
const db = mockDb();
const caller = createManagerCaller(db);
const result = await caller.deactivate({ id: "res_1" });
expect(result).toBeDefined();
expect(db.$transaction).toHaveBeenCalled();
});
});
describe("resource batchUpdateCustomFields", () => {
it("rejects non-manager users", async () => {
const caller = createUserCaller(mockDb());
await expect(
caller.batchUpdateCustomFields({
ids: ["res_1"],
fields: { department: "Engineering" },
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("validates field types (rejects invalid values)", async () => {
const caller = createManagerCaller(mockDb());
// The hardened schema only accepts string | number | boolean | null
await expect(
caller.batchUpdateCustomFields({
ids: ["res_1"],
// @ts-expect-error — intentionally passing an array to test schema validation
fields: { department: ["nested", "array"] },
}),
).rejects.toThrow();
});
it("executes batch update with audit log", async () => {
const db = mockDb();
const caller = createManagerCaller(db);
const result = await caller.batchUpdateCustomFields({
ids: ["res_1", "res_2"],
fields: { department: "Engineering", level: 3 },
});
expect(result).toEqual({ updated: 2 });
expect(db.$transaction).toHaveBeenCalled();
});
});
describe("resource hardDelete", () => {
it("rejects non-admin users", async () => {
const caller = createManagerCaller(mockDb());
await expect(caller.hardDelete({ id: "res_1" })).rejects.toMatchObject({
code: "FORBIDDEN",
});
});
it("throws NOT_FOUND for missing resource", async () => {
const db = mockDb({
resource: {
...mockDb().resource,
findUnique: vi.fn().mockResolvedValue(null),
},
});
const caller = createAdminCaller(db);
await expect(caller.hardDelete({ id: "res_missing" })).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
it("deletes resource and cascades for admin", async () => {
const db = mockDb({
resource: {
...mockDb().resource,
findUnique: vi.fn().mockResolvedValue({ id: "res_1", displayName: "Jane", eid: "EMP-001" }),
},
});
const caller = createAdminCaller(db);
const result = await caller.hardDelete({ id: "res_1" });
expect(result).toEqual({ deleted: true });
expect(db.$transaction).toHaveBeenCalled();
});
});
+114 -35
View File
@@ -1,4 +1,11 @@
import { BlueprintTarget, CreateResourceSchema, PermissionKey, ResourceRoleSchema, UpdateResourceSchema, inferStateFromPostalCode } from "@capakraken/shared"; import {
BlueprintTarget,
CreateResourceSchema,
PermissionKey,
ResourceRoleSchema,
UpdateResourceSchema,
inferStateFromPostalCode,
} from "@capakraken/shared";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js"; import { findUniqueOrThrow } from "../db/helpers.js";
@@ -31,7 +38,10 @@ export const resourceMutationProcedures = {
const primaryCount = (input.roles ?? []).filter((role) => role.isPrimary).length; const primaryCount = (input.roles ?? []).filter((role) => role.isPrimary).length;
if (primaryCount > 1) { if (primaryCount > 1) {
throw new TRPCError({ code: "BAD_REQUEST", message: "A resource can have at most one primary role" }); throw new TRPCError({
code: "BAD_REQUEST",
message: "A resource can have at most one primary role",
});
} }
const resource = await ctx.db.$transaction(async (tx) => { const resource = await ctx.db.$transaction(async (tx) => {
@@ -47,7 +57,8 @@ export const resourceMutationProcedures = {
chargeabilityTarget: input.chargeabilityTarget, chargeabilityTarget: input.chargeabilityTarget,
availability: input.availability, availability: input.availability,
skills: input.skills as unknown as import("@capakraken/db").Prisma.InputJsonValue, skills: input.skills as unknown as import("@capakraken/db").Prisma.InputJsonValue,
dynamicFields: input.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue, dynamicFields:
input.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue,
blueprintId: input.blueprintId, blueprintId: input.blueprintId,
portfolioUrl: input.portfolioUrl || undefined, portfolioUrl: input.portfolioUrl || undefined,
roleId: input.roleId || undefined, roleId: input.roleId || undefined,
@@ -60,14 +71,24 @@ export const resourceMutationProcedures = {
...(input.countryId !== undefined ? { countryId: input.countryId || null } : {}), ...(input.countryId !== undefined ? { countryId: input.countryId || null } : {}),
...(input.metroCityId !== undefined ? { metroCityId: input.metroCityId || null } : {}), ...(input.metroCityId !== undefined ? { metroCityId: input.metroCityId || null } : {}),
...(input.orgUnitId !== undefined ? { orgUnitId: input.orgUnitId || null } : {}), ...(input.orgUnitId !== undefined ? { orgUnitId: input.orgUnitId || null } : {}),
...(input.managementLevelGroupId !== undefined ? { managementLevelGroupId: input.managementLevelGroupId || null } : {}), ...(input.managementLevelGroupId !== undefined
...(input.managementLevelId !== undefined ? { managementLevelId: input.managementLevelId || null } : {}), ? { managementLevelGroupId: input.managementLevelGroupId || null }
: {}),
...(input.managementLevelId !== undefined
? { managementLevelId: input.managementLevelId || null }
: {}),
...(input.resourceType !== undefined ? { resourceType: input.resourceType } : {}), ...(input.resourceType !== undefined ? { resourceType: input.resourceType } : {}),
...(input.chgResponsibility !== undefined ? { chgResponsibility: input.chgResponsibility } : {}), ...(input.chgResponsibility !== undefined
? { chgResponsibility: input.chgResponsibility }
: {}),
...(input.rolledOff !== undefined ? { rolledOff: input.rolledOff } : {}), ...(input.rolledOff !== undefined ? { rolledOff: input.rolledOff } : {}),
...(input.departed !== undefined ? { departed: input.departed } : {}), ...(input.departed !== undefined ? { departed: input.departed } : {}),
...(input.enterpriseId !== undefined ? { enterpriseId: input.enterpriseId || null } : {}), ...(input.enterpriseId !== undefined
...(input.clientUnitId !== undefined ? { clientUnitId: input.clientUnitId || null } : {}), ? { enterpriseId: input.enterpriseId || null }
: {}),
...(input.clientUnitId !== undefined
? { clientUnitId: input.clientUnitId || null }
: {}),
...(input.fte !== undefined ? { fte: input.fte } : {}), ...(input.fte !== undefined ? { fte: input.fte } : {}),
resourceRoles: input.roles?.length resourceRoles: input.roles?.length
? { ? {
@@ -100,7 +121,12 @@ export const resourceMutationProcedures = {
}), }),
update: managerProcedure update: managerProcedure
.input(z.object({ id: z.string(), data: UpdateResourceSchema.extend({ roles: z.array(ResourceRoleSchema).optional() }) })) .input(
z.object({
id: z.string(),
data: UpdateResourceSchema.extend({ roles: z.array(ResourceRoleSchema).optional() }),
}),
)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES); requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
const existing = await findUniqueOrThrow( const existing = await findUniqueOrThrow(
@@ -109,7 +135,9 @@ export const resourceMutationProcedures = {
); );
const nextBlueprintId = input.data.blueprintId ?? existing.blueprintId ?? undefined; const nextBlueprintId = input.data.blueprintId ?? existing.blueprintId ?? undefined;
const nextDynamicFields = (input.data.dynamicFields ?? existing.dynamicFields ?? {}) as Record<string, unknown>; const nextDynamicFields = (input.data.dynamicFields ??
existing.dynamicFields ??
{}) as Record<string, unknown>;
await assertBlueprintDynamicFields({ await assertBlueprintDynamicFields({
db: ctx.db, db: ctx.db,
@@ -121,7 +149,10 @@ export const resourceMutationProcedures = {
if (input.data.roles !== undefined) { if (input.data.roles !== undefined) {
const primaryCount = input.data.roles.filter((role) => role.isPrimary).length; const primaryCount = input.data.roles.filter((role) => role.isPrimary).length;
if (primaryCount > 1) { if (primaryCount > 1) {
throw new TRPCError({ code: "BAD_REQUEST", message: "A resource can have at most one primary role" }); throw new TRPCError({
code: "BAD_REQUEST",
message: "A resource can have at most one primary role",
});
} }
} }
@@ -129,19 +160,42 @@ export const resourceMutationProcedures = {
const result = await tx.resource.update({ const result = await tx.resource.update({
where: { id: input.id }, where: { id: input.id },
data: { data: {
...(input.data.displayName !== undefined ? { displayName: input.data.displayName } : {}), ...(input.data.displayName !== undefined
? { displayName: input.data.displayName }
: {}),
...(input.data.email !== undefined ? { email: input.data.email } : {}), ...(input.data.email !== undefined ? { email: input.data.email } : {}),
...(input.data.chapter !== undefined ? { chapter: input.data.chapter } : {}), ...(input.data.chapter !== undefined ? { chapter: input.data.chapter } : {}),
...(input.data.lcrCents !== undefined ? { lcrCents: input.data.lcrCents } : {}), ...(input.data.lcrCents !== undefined ? { lcrCents: input.data.lcrCents } : {}),
...(input.data.ucrCents !== undefined ? { ucrCents: input.data.ucrCents } : {}), ...(input.data.ucrCents !== undefined ? { ucrCents: input.data.ucrCents } : {}),
...(input.data.currency !== undefined ? { currency: input.data.currency } : {}), ...(input.data.currency !== undefined ? { currency: input.data.currency } : {}),
...(input.data.chargeabilityTarget !== undefined ? { chargeabilityTarget: input.data.chargeabilityTarget } : {}), ...(input.data.chargeabilityTarget !== undefined
...(input.data.availability !== undefined ? { availability: input.data.availability as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}), ? { chargeabilityTarget: input.data.chargeabilityTarget }
...(input.data.skills !== undefined ? { skills: input.data.skills as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}), : {}),
...(input.data.dynamicFields !== undefined ? { dynamicFields: input.data.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}), ...(input.data.availability !== undefined
...(input.data.blueprintId !== undefined ? { blueprintId: input.data.blueprintId } : {}), ? {
availability: input.data
.availability as unknown as import("@capakraken/db").Prisma.InputJsonValue,
}
: {}),
...(input.data.skills !== undefined
? {
skills: input.data
.skills as unknown as import("@capakraken/db").Prisma.InputJsonValue,
}
: {}),
...(input.data.dynamicFields !== undefined
? {
dynamicFields: input.data
.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue,
}
: {}),
...(input.data.blueprintId !== undefined
? { blueprintId: input.data.blueprintId }
: {}),
...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}), ...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}),
...(input.data.portfolioUrl !== undefined ? { portfolioUrl: input.data.portfolioUrl || null } : {}), ...(input.data.portfolioUrl !== undefined
? { portfolioUrl: input.data.portfolioUrl || null }
: {}),
...(input.data.roleId !== undefined ? { roleId: input.data.roleId || null } : {}), ...(input.data.roleId !== undefined ? { roleId: input.data.roleId || null } : {}),
...(input.data.postalCode !== undefined ? { postalCode: input.data.postalCode } : {}), ...(input.data.postalCode !== undefined ? { postalCode: input.data.postalCode } : {}),
...(input.data.postalCode && !input.data.federalState ...(input.data.postalCode && !input.data.federalState
@@ -149,17 +203,35 @@ export const resourceMutationProcedures = {
: input.data.federalState !== undefined : input.data.federalState !== undefined
? { federalState: input.data.federalState } ? { federalState: input.data.federalState }
: {}), : {}),
...(input.data.countryId !== undefined ? { countryId: input.data.countryId || null } : {}), ...(input.data.countryId !== undefined
...(input.data.metroCityId !== undefined ? { metroCityId: input.data.metroCityId || null } : {}), ? { countryId: input.data.countryId || null }
...(input.data.orgUnitId !== undefined ? { orgUnitId: input.data.orgUnitId || null } : {}), : {}),
...(input.data.managementLevelGroupId !== undefined ? { managementLevelGroupId: input.data.managementLevelGroupId || null } : {}), ...(input.data.metroCityId !== undefined
...(input.data.managementLevelId !== undefined ? { managementLevelId: input.data.managementLevelId || null } : {}), ? { metroCityId: input.data.metroCityId || null }
...(input.data.resourceType !== undefined ? { resourceType: input.data.resourceType } : {}), : {}),
...(input.data.chgResponsibility !== undefined ? { chgResponsibility: input.data.chgResponsibility } : {}), ...(input.data.orgUnitId !== undefined
? { orgUnitId: input.data.orgUnitId || null }
: {}),
...(input.data.managementLevelGroupId !== undefined
? { managementLevelGroupId: input.data.managementLevelGroupId || null }
: {}),
...(input.data.managementLevelId !== undefined
? { managementLevelId: input.data.managementLevelId || null }
: {}),
...(input.data.resourceType !== undefined
? { resourceType: input.data.resourceType }
: {}),
...(input.data.chgResponsibility !== undefined
? { chgResponsibility: input.data.chgResponsibility }
: {}),
...(input.data.rolledOff !== undefined ? { rolledOff: input.data.rolledOff } : {}), ...(input.data.rolledOff !== undefined ? { rolledOff: input.data.rolledOff } : {}),
...(input.data.departed !== undefined ? { departed: input.data.departed } : {}), ...(input.data.departed !== undefined ? { departed: input.data.departed } : {}),
...(input.data.enterpriseId !== undefined ? { enterpriseId: input.data.enterpriseId || null } : {}), ...(input.data.enterpriseId !== undefined
...(input.data.clientUnitId !== undefined ? { clientUnitId: input.data.clientUnitId || null } : {}), ? { enterpriseId: input.data.enterpriseId || null }
: {}),
...(input.data.clientUnitId !== undefined
? { clientUnitId: input.data.clientUnitId || null }
: {}),
...(input.data.fte !== undefined ? { fte: input.data.fte } : {}), ...(input.data.fte !== undefined ? { fte: input.data.fte } : {}),
} as unknown as Parameters<typeof tx.resource.update>[0]["data"], } as unknown as Parameters<typeof tx.resource.update>[0]["data"],
include: { include: {
@@ -247,17 +319,20 @@ export const resourceMutationProcedures = {
}), }),
batchUpdateCustomFields: managerProcedure batchUpdateCustomFields: managerProcedure
.input(z.object({ .input(
ids: z.array(z.string()).min(1).max(100), z.object({
fields: z.record(z.string(), z.unknown()), ids: z.array(z.string()).min(1).max(100),
})) fields: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])),
}),
)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES); requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
await ctx.db.$transaction(async (tx) => { await ctx.db.$transaction(async (tx) => {
await Promise.all( await Promise.all(
input.ids.map((id) => input.ids.map(
tx.$executeRaw` (id) =>
tx.$executeRaw`
UPDATE "Resource" UPDATE "Resource"
SET "dynamicFields" = "dynamicFields" || ${JSON.stringify(input.fields)}::jsonb SET "dynamicFields" = "dynamicFields" || ${JSON.stringify(input.fields)}::jsonb
WHERE id = ${id} WHERE id = ${id}
@@ -270,7 +345,9 @@ export const resourceMutationProcedures = {
entityType: "Resource", entityType: "Resource",
entityId: input.ids.join(","), entityId: input.ids.join(","),
action: "UPDATE", action: "UPDATE",
changes: { after: { dynamicFields: input.fields, ids: input.ids } } as unknown as import("@capakraken/db").Prisma.InputJsonValue, changes: {
after: { dynamicFields: input.fields, ids: input.ids },
} as unknown as import("@capakraken/db").Prisma.InputJsonValue,
}, },
}); });
}); });
@@ -300,7 +377,9 @@ export const resourceMutationProcedures = {
entityId: input.id, entityId: input.id,
action: "DELETE", action: "DELETE",
userId: ctx.dbUser?.id, userId: ctx.dbUser?.id,
changes: { before: resource } as unknown as import("@capakraken/db").Prisma.InputJsonValue, changes: {
before: resource,
} as unknown as import("@capakraken/db").Prisma.InputJsonValue,
}, },
}); });
}); });