Compare commits
10 Commits
5a4836d292
...
dc1e0bfb28
| Author | SHA1 | Date | |
|---|---|---|---|
| dc1e0bfb28 | |||
| 622c4135f5 | |||
| a1f79f6ccc | |||
| 43bfd9ed0a | |||
| 8f7c69056f | |||
| e08ee94546 | |||
| 85c064ba32 | |||
| 74ed45ddfc | |||
| c9be7c9bbf | |||
| bfcadd2c52 |
@@ -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:*",
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
const [smtpPort, setSmtpPort] = useState(587);
|
||||||
|
const [smtpUser, setSmtpUser] = useState("");
|
||||||
|
const [smtpFrom, setSmtpFrom] = useState("");
|
||||||
|
const [smtpTls, setSmtpTls] = useState(true);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [testResult, setTestResult] = useState<SaveResult | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSmtpHost(initialSettings.smtpHost ?? "");
|
||||||
|
setSmtpPort(initialSettings.smtpPort ?? 587);
|
||||||
|
setSmtpUser(initialSettings.smtpUser ?? "");
|
||||||
|
setSmtpFrom(initialSettings.smtpFrom ?? "");
|
||||||
|
setSmtpTls(initialSettings.smtpTls ?? true);
|
||||||
|
}, [initialSettings]);
|
||||||
|
|
||||||
|
const saveMutation = trpc.settings.updateSystemSettings.useMutation({
|
||||||
|
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,
|
smtpPort,
|
||||||
smtpUser,
|
smtpUser: smtpUser || undefined,
|
||||||
smtpFrom,
|
smtpFrom: smtpFrom || undefined,
|
||||||
smtpTls,
|
smtpTls,
|
||||||
smtpSaved,
|
});
|
||||||
smtpTestResult,
|
}
|
||||||
smtpSecret,
|
|
||||||
isSaving,
|
|
||||||
isTesting,
|
|
||||||
onSmtpHostChange,
|
|
||||||
onSmtpPortChange,
|
|
||||||
onSmtpUserChange,
|
|
||||||
onSmtpFromChange,
|
|
||||||
onSmtpTlsChange,
|
|
||||||
onSave,
|
|
||||||
onTest,
|
|
||||||
}: SmtpSettingsPanelProps) {
|
|
||||||
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,24 +60,31 @@ 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],
|
["Mo", resourceFactors.weeklyAvailability.monday],
|
||||||
["Di", resourceFactors.weeklyAvailability.tuesday],
|
["Di", resourceFactors.weeklyAvailability.tuesday],
|
||||||
@@ -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(
|
||||||
|
(p: { id: string; name: string; shortCode?: string | null }) => (
|
||||||
<option key={p.id} value={p.id}>
|
<option key={p.id} value={p.id}>
|
||||||
{p.shortCode ? `${p.shortCode} — ` : ""}{p.name}
|
{p.shortCode ? `${p.shortCode} — ` : ""}
|
||||||
|
{p.name}
|
||||||
</option>
|
</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 ? (
|
||||||
|
topHolidays.map((holiday) => (
|
||||||
<div
|
<div
|
||||||
key={`${holiday.date}-${holiday.name}`}
|
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"
|
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="font-medium text-zinc-900 dark:text-zinc-100">
|
||||||
|
{holiday.name}
|
||||||
|
</div>
|
||||||
<div className="text-xs text-zinc-500">
|
<div className="text-xs text-zinc-500">
|
||||||
{holiday.date} · {holiday.scope} · {holiday.calendarName ?? "Kalender"}
|
{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,5 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
const STATUS_BADGE: Record<string, string> = {
|
const STATUS_BADGE: Record<string, string> = {
|
||||||
@@ -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,9 +53,13 @@ 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.length > 0
|
||||||
? warnings.reduce((prev, curr) => {
|
? warnings.reduce((prev, curr) => {
|
||||||
const levels: Record<string, number> = { info: 0, warning: 1, critical: 2 };
|
const levels: Record<string, number> = { info: 0, warning: 1, critical: 2 };
|
||||||
return (levels[curr.level] ?? 0) > (levels[prev.level] ?? 0) ? curr : prev;
|
return (levels[curr.level] ?? 0) > (levels[prev.level] ?? 0) ? curr : prev;
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
summarizeMissing={summarizeMissingColumns}
|
||||||
/>
|
/>
|
||||||
</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">
|
|
||||||
{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 & 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 & Chargeability</p>
|
<p className={SECTION_HEADER_CLASS}>Cost & 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 €/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 €/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 €/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 €/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(
|
||||||
|
"roles",
|
||||||
|
form.roles.map((r) =>
|
||||||
r.roleId === role.id
|
r.roleId === role.id
|
||||||
? { ...r, isPrimary: true }
|
? { ...r, isPrimary: true }
|
||||||
: { ...r, isPrimary: false },
|
: { ...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 & 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"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
@@ -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"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
|
|||||||
@@ -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
|
||||||
|
className={clsx(
|
||||||
"font-medium leading-none",
|
"font-medium leading-none",
|
||||||
isToday ? "text-brand-600" : isWeekend ? "text-brand-600 dark:text-brand-400" : "text-gray-600 dark:text-gray-300",
|
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
|
||||||
|
className={clsx(
|
||||||
"text-[9px] leading-none mt-0.5",
|
"text-[9px] leading-none mt-0.5",
|
||||||
isWeekend ? "text-brand-400 dark:text-brand-500" : "text-gray-300 dark:text-gray-600",
|
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"
|
||||||
>
|
>
|
||||||
↪
|
↪
|
||||||
|
|||||||
@@ -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}
|
||||||
|
openDemandsByProject={openDemandsByProject}
|
||||||
|
scrollContainerRef={scrollContainerRef}
|
||||||
|
multiSelectState={multiSelectState}
|
||||||
|
clearMultiSelect={clearMultiSelect}
|
||||||
|
handleBatchDelete={handleBatchDelete}
|
||||||
|
handleShowBatchAssign={handleShowBatchAssign}
|
||||||
|
isDeleting={batchDeleteMutation.isPending}
|
||||||
|
showBatchAssign={showBatchAssign}
|
||||||
|
setShowBatchAssign={setShowBatchAssign}
|
||||||
|
resourceHover={resourceHover}
|
||||||
|
setResourceHover={setResourceHover}
|
||||||
|
inlineEditTarget={inlineEditTarget}
|
||||||
|
setInlineEditTarget={setInlineEditTarget}
|
||||||
|
showShortcuts={showShortcuts}
|
||||||
|
setShowShortcuts={setShowShortcuts}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
</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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,16 +319,19 @@ export const resourceMutationProcedures = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
batchUpdateCustomFields: managerProcedure
|
batchUpdateCustomFields: managerProcedure
|
||||||
.input(z.object({
|
.input(
|
||||||
|
z.object({
|
||||||
ids: z.array(z.string()).min(1).max(100),
|
ids: z.array(z.string()).min(1).max(100),
|
||||||
fields: z.record(z.string(), z.unknown()),
|
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(
|
||||||
|
(id) =>
|
||||||
tx.$executeRaw`
|
tx.$executeRaw`
|
||||||
UPDATE "Resource"
|
UPDATE "Resource"
|
||||||
SET "dynamicFields" = "dynamicFields" || ${JSON.stringify(input.fields)}::jsonb
|
SET "dynamicFields" = "dynamicFields" || ${JSON.stringify(input.fields)}::jsonb
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user