chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
@@ -0,0 +1,928 @@
"use client";
import { useState, useEffect } from "react";
import { trpc } from "~/lib/trpc/client.js";
const INPUT_CLASS =
"w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white dark:bg-gray-800 dark:border-gray-600 dark:text-gray-100";
const LABEL_CLASS = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
type Provider = "openai" | "azure";
const ALL_ROLES = ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] as const;
type SystemRole = typeof ALL_ROLES[number];
interface ScoreWeights {
skillDepth: number;
skillBreadth: number;
costEfficiency: number;
chargeability: number;
experience: number;
}
type ParsedAzureUrl = {
endpoint: string;
apiVersion: string;
deployment: string | null; // null for Responses API URLs (deployment not in path)
urlType: "completions" | "responses";
};
/** Parse endpoint, deployment, and api-version out of an Azure URL.
* Supports both Chat Completions and Responses API formats. */
function parseAzureUrl(raw: string): ParsedAzureUrl | null {
try {
const url = new URL(raw);
const endpoint = `${url.protocol}//${url.host}`;
const apiVersion = url.searchParams.get("api-version") ?? "2025-01-01-preview";
// Chat Completions: /openai/deployments/{name}/chat/completions
const completionsMatch = url.pathname.match(/\/openai\/deployments\/([^/]+)\//);
if (completionsMatch) {
return { endpoint, apiVersion, deployment: completionsMatch[1]!, urlType: "completions" };
}
// Responses API: /openai/responses
if (url.pathname.includes("/openai/responses")) {
return { endpoint, apiVersion, deployment: null, urlType: "responses" };
}
return null;
} catch {
return null;
}
}
export function SystemSettingsClient() {
const [provider, setProvider] = useState<Provider>("openai");
const [endpoint, setEndpoint] = useState("");
const [model, setModel] = useState("");
const [apiVersion, setApiVersion] = useState("2025-01-01-preview");
const [apiKey, setApiKey] = useState("");
const [maxTokens, setMaxTokens] = useState(2000);
const [temperature, setTemperature] = useState(1);
const [summaryPrompt, setSummaryPrompt] = useState("");
const [saved, setSaved] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; error?: string; raw?: string | null } | null>(null);
const [urlPasteValue, setUrlPasteValue] = useState("");
const [urlParseError, setUrlParseError] = useState(false);
const [urlParsedType, setUrlParsedType] = useState<"completions" | "responses" | null>(null);
// Value Score settings
const [scoreWeights, setScoreWeights] = useState<ScoreWeights>({
skillDepth: 0.30,
skillBreadth: 0.15,
costEfficiency: 0.25,
chargeability: 0.15,
experience: 0.15,
});
const [scoreVisibleRoles, setScoreVisibleRoles] = useState<SystemRole[]>(["ADMIN", "MANAGER"]);
const [scoreSaved, setScoreSaved] = useState(false);
const [recomputeResult, setRecomputeResult] = useState<{ updated: number } | null>(null);
// SMTP settings
const [smtpHost, setSmtpHost] = useState("");
const [smtpPort, setSmtpPort] = useState(587);
const [smtpUser, setSmtpUser] = useState("");
const [smtpPassword, setSmtpPassword] = useState("");
const [smtpFrom, setSmtpFrom] = useState("");
const [smtpTls, setSmtpTls] = useState(true);
const [smtpSaved, setSmtpSaved] = useState(false);
const [smtpTestResult, setSmtpTestResult] = useState<{ ok: boolean; error?: string } | null>(null);
// Vacation defaults
const [vacationDefaultDays, setVacationDefaultDays] = useState(28);
const [vacationSaved, setVacationSaved] = useState(false);
const { data: settings, isLoading } = trpc.settings.getSystemSettings.useQuery(undefined, {
staleTime: 0,
});
useEffect(() => {
if (settings) {
setProvider((settings.aiProvider ?? "openai") as Provider);
setEndpoint(settings.azureOpenAiEndpoint ?? "");
setModel(settings.azureOpenAiDeployment ?? "");
setApiVersion(settings.azureApiVersion ?? "2025-01-01-preview");
setMaxTokens(settings.aiMaxCompletionTokens ?? 2000);
setTemperature(settings.aiTemperature ?? 1);
setSummaryPrompt(settings.aiSummaryPrompt ?? "");
if (settings.scoreWeights) {
setScoreWeights(settings.scoreWeights as ScoreWeights);
}
if (settings.scoreVisibleRoles) {
setScoreVisibleRoles(settings.scoreVisibleRoles as SystemRole[]);
}
// SMTP
setSmtpHost(settings.smtpHost ?? "");
setSmtpPort(settings.smtpPort ?? 587);
setSmtpUser(settings.smtpUser ?? "");
setSmtpFrom(settings.smtpFrom ?? "");
setSmtpTls(settings.smtpTls ?? true);
// Vacation
setVacationDefaultDays(settings.vacationDefaultDays ?? 28);
}
}, [settings]);
function handleUrlPaste(raw: string) {
setUrlPasteValue(raw);
if (!raw) { setUrlParseError(false); setUrlParsedType(null); return; }
const parsed = parseAzureUrl(raw);
if (parsed) {
setEndpoint(parsed.endpoint);
setApiVersion(parsed.apiVersion);
if (parsed.deployment) setModel(parsed.deployment);
setUrlParseError(false);
setUrlParsedType(parsed.urlType);
setUrlPasteValue("");
} else {
setUrlParseError(true);
setUrlParsedType(null);
}
}
const updateMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => {
setSaved(true);
setTestResult(null);
setTimeout(() => setSaved(false), 3000);
},
});
const testMutation = trpc.settings.testAiConnection.useMutation({
onSuccess: (data) => setTestResult(data),
onError: (err) => setTestResult({ ok: false, error: err.message }),
});
const saveScoreMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => {
setScoreSaved(true);
setTimeout(() => setScoreSaved(false), 3000);
},
});
const recomputeMutation = trpc.resource.recomputeValueScores.useMutation({
onSuccess: (data) => setRecomputeResult(data),
});
const saveSmtpMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => {
setSmtpSaved(true);
setSmtpTestResult(null);
setTimeout(() => setSmtpSaved(false), 3000);
},
});
const testSmtpMutation = trpc.settings.testSmtpConnection.useMutation({
onSuccess: (data) => setSmtpTestResult(data),
onError: (err) => setSmtpTestResult({ ok: false, error: err.message }),
});
const saveVacationMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => {
setVacationSaved(true);
setTimeout(() => setVacationSaved(false), 3000);
},
});
function handleSaveSmtp() {
saveSmtpMutation.mutate({
smtpHost: smtpHost || undefined,
smtpPort,
smtpUser: smtpUser || undefined,
...(smtpPassword ? { smtpPassword } : {}),
smtpFrom: smtpFrom || undefined,
smtpTls,
});
}
function handleSaveVacation() {
saveVacationMutation.mutate({ vacationDefaultDays });
}
function handleSaveScoreSettings() {
saveScoreMutation.mutate({ scoreWeights, scoreVisibleRoles });
}
function updateWeight(key: keyof ScoreWeights, value: number) {
setScoreWeights((prev) => ({ ...prev, [key]: value }));
}
function toggleRole(role: SystemRole) {
setScoreVisibleRoles((prev) =>
prev.includes(role) ? prev.filter((r) => r !== role) : [...prev, role],
);
}
const weightSum = Object.values(scoreWeights).reduce((s, v) => s + v, 0);
const weightSumOk = Math.abs(weightSum - 1.0) < 0.01;
function handleSave() {
updateMutation.mutate({
aiProvider: provider,
azureOpenAiEndpoint: provider === "azure" ? endpoint : "",
azureOpenAiDeployment: model,
azureApiVersion: provider === "azure" ? apiVersion : undefined,
aiMaxCompletionTokens: maxTokens,
aiTemperature: temperature,
aiSummaryPrompt: summaryPrompt || undefined,
...(apiKey ? { azureOpenAiApiKey: apiKey } : {}),
});
}
if (isLoading) {
return <div className="p-6 animate-pulse"><div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48" /></div>;
}
return (
<div className="p-4 sm:p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">System Settings</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Configure AI integration for skill profile generation.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-5">
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wider">AI Provider</h2>
{/* Provider toggle */}
<div>
<label className={LABEL_CLASS}>Provider</label>
<div className="flex rounded-lg overflow-hidden border border-gray-200 dark:border-gray-600 w-fit">
<button
type="button"
onClick={() => { setProvider("openai"); setTestResult(null); }}
className={`px-4 py-2 text-sm font-medium transition-colors ${
provider === "openai"
? "bg-brand-600 text-white"
: "bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600"
}`}
>
OpenAI
</button>
<button
type="button"
onClick={() => { setProvider("azure"); setTestResult(null); }}
className={`px-4 py-2 text-sm font-medium border-l border-gray-200 dark:border-gray-600 transition-colors ${
provider === "azure"
? "bg-brand-600 text-white"
: "bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600"
}`}
>
Azure OpenAI
</button>
</div>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1.5">
{provider === "openai"
? "Use a standard OpenAI API key from platform.openai.com."
: "Use a deployment on your own Azure OpenAI resource."}
</p>
</div>
{/* Azure-only fields */}
{provider === "azure" && (
<>
{/* Paste full URL shortcut */}
<div className="rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 px-4 py-3 space-y-2">
<p className="text-xs font-medium text-blue-800 dark:text-blue-300">
Paste a full completion URL to auto-fill all fields below:
</p>
<input
type="url"
className={`${INPUT_CLASS} border-blue-300 dark:border-blue-600`}
placeholder="https://…cognitiveservices.azure.com/openai/deployments/gpt-5/chat/completions?api-version=2025-01-01-preview"
value={urlPasteValue}
onChange={(e) => handleUrlPaste(e.target.value)}
/>
{urlParseError && (
<p className="text-xs text-red-600 dark:text-red-400">
Could not parse URL expected either a Chat Completions URL
(<code className="font-mono">/openai/deployments//chat/completions</code>)
or a Responses API URL (<code className="font-mono">/openai/responses</code>).
</p>
)}
{urlParsedType === "responses" && (
<p className="text-xs text-amber-700 dark:text-amber-400">
Responses API URL detected endpoint and api-version filled in.
Enter the <strong>deployment/model name</strong> manually below (it is not part of this URL).
</p>
)}
{urlParsedType === "completions" && (
<p className="text-xs text-green-700 dark:text-green-400">
All fields filled from URL.
</p>
)}
</div>
<div>
<label className={LABEL_CLASS} htmlFor="ai-endpoint">Endpoint (base URL)</label>
<input
id="ai-endpoint"
type="url"
className={INPUT_CLASS}
placeholder="https://myinstance.cognitiveservices.azure.com"
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
/>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Everything up to (not including) <code className="font-mono">/openai/</code>
</p>
</div>
</>
)}
{/* Model / deployment name */}
<div>
<label className={LABEL_CLASS} htmlFor="ai-model">
{provider === "azure" ? "Deployment Name" : "Model Name"}
</label>
<input
id="ai-model"
type="text"
className={INPUT_CLASS}
placeholder={provider === "azure" ? "my-gpt4o-deployment" : "gpt-4o-mini"}
value={model}
onChange={(e) => setModel(e.target.value)}
/>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
{provider === "azure"
? "The deployment name you chose when deploying the model in Azure."
: "The model identifier, e.g. gpt-4o-mini, gpt-4o, gpt-3.5-turbo."}
</p>
</div>
{/* Azure-only: api version */}
{provider === "azure" && (
<div>
<label className={LABEL_CLASS} htmlFor="ai-api-version">API Version</label>
<input
id="ai-api-version"
type="text"
className={INPUT_CLASS}
placeholder="2025-01-01-preview"
value={apiVersion}
onChange={(e) => setApiVersion(e.target.value)}
/>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
The <code className="font-mono">api-version</code> query parameter from your endpoint URL. Default: <code className="font-mono">2025-01-01-preview</code>
</p>
</div>
)}
{/* API key */}
<div>
<label className={LABEL_CLASS} htmlFor="ai-key">API Key</label>
<input
id="ai-key"
type="password"
className={INPUT_CLASS}
placeholder={
settings?.hasApiKey
? "●●●●●●●●●●●● (already set — enter new value to replace)"
: provider === "openai"
? "sk-..."
: "Enter Azure API key"
}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
autoComplete="new-password"
/>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
{provider === "openai"
? "Your secret key from platform.openai.com → API keys. Starts with sk-."
: "One of the two keys from Azure Portal → your resource → Keys and Endpoint."}
{settings?.hasApiKey && " Leave blank to keep the existing key."}
</p>
</div>
{/* Test result */}
{testResult && (
<div
className={`rounded-lg px-4 py-3 text-sm ${
testResult.ok
? "bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-700 text-green-700 dark:text-green-300"
: "bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 text-red-700 dark:text-red-300"
}`}
>
{testResult.ok ? (
<span className="font-medium">Connection successful AI summaries are ready to use.</span>
) : (
<div className="space-y-2">
<p><span className="font-medium">Connection failed:</span> {testResult.error}</p>
{testResult.raw && (
<details className="text-xs">
<summary className="cursor-pointer opacity-70 hover:opacity-100">Show raw error</summary>
<pre className="mt-1 p-2 bg-red-100 dark:bg-red-950 rounded text-red-800 dark:text-red-200 whitespace-pre-wrap break-all font-mono">
{testResult.raw}
</pre>
</details>
)}
</div>
)}
</div>
)}
<div className="flex items-center gap-3 pt-2">
<button
type="button"
onClick={handleSave}
disabled={updateMutation.isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{updateMutation.isPending ? "Saving…" : "Save Settings"}
</button>
<button
type="button"
onClick={() => { setTestResult(null); testMutation.mutate(); }}
disabled={testMutation.isPending}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-sm font-medium disabled:opacity-50"
>
{testMutation.isPending ? "Testing…" : "Test Connection"}
</button>
{saved && <span className="text-sm text-green-600 dark:text-green-400 font-medium">Saved!</span>}
</div>
</div>
{/* Generation settings */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-5">
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wider">Generation Settings</h2>
{/* Max completion tokens */}
<div>
<div className="flex items-center justify-between mb-1">
<label className={LABEL_CLASS} htmlFor="ai-max-tokens">Max Completion Tokens</label>
<span className="text-sm font-mono text-brand-600 dark:text-brand-400">{maxTokens}</span>
</div>
<input
id="ai-max-tokens"
type="range"
min={50}
max={2000}
step={50}
value={maxTokens}
onChange={(e) => setMaxTokens(Number(e.target.value))}
className="w-full accent-brand-600"
/>
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>500</span>
<span className="text-gray-500 dark:text-gray-400">
{maxTokens < 1000 ? "⚠ May be empty for reasoning models (GPT-5, o1, o3)" :
maxTokens <= 2000 ? "Recommended for reasoning models ✓" :
maxTokens <= 4000 ? "High — allows longer bios" :
"Very high"}
</span>
<span>16000</span>
</div>
<p className="text-xs text-amber-600 dark:text-amber-400 mt-1">
Reasoning models (GPT-5, o1, o3) consume tokens internally before writing output. Set to at least 2000 to avoid empty responses.
</p>
</div>
{/* Temperature */}
<div>
<div className="flex items-center justify-between mb-1">
<label className={LABEL_CLASS} htmlFor="ai-temperature">Temperature</label>
<span className="text-sm font-mono text-brand-600 dark:text-brand-400">{temperature.toFixed(1)}</span>
</div>
<input
id="ai-temperature"
type="range"
min={0}
max={2}
step={0.1}
value={temperature}
onChange={(e) => setTemperature(Number(e.target.value))}
className="w-full accent-brand-600"
/>
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>0 deterministic</span>
<span className="text-gray-500 dark:text-gray-400">
{temperature <= 0.3 ? "Factual & consistent" :
temperature <= 0.9 ? "Balanced" :
temperature <= 1.0 ? "Default (1) — recommended ✓" :
temperature <= 1.2 ? "Creative" :
"Very creative / unpredictable"}
</span>
<span>2 creative</span>
</div>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Some models (e.g. GPT-5) only accept the default value of 1. If generation fails with a temperature error, the system retries automatically without it.
</p>
</div>
{/* Summary prompt */}
<div>
<div className="flex items-center justify-between mb-1">
<label className={LABEL_CLASS} htmlFor="ai-prompt">Profile Summary Prompt</label>
{summaryPrompt && (
<button
type="button"
onClick={() => setSummaryPrompt("")}
className="text-xs text-brand-600 hover:underline"
>
Reset to default
</button>
)}
</div>
<textarea
id="ai-prompt"
rows={10}
value={summaryPrompt || (settings?.defaultSummaryPrompt ?? "")}
onChange={(e) => setSummaryPrompt(e.target.value)}
className={`${INPUT_CLASS} font-mono text-xs leading-relaxed resize-y`}
spellCheck={false}
/>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Available placeholders: <code className="font-mono">{"{role}"}</code> <code className="font-mono">{"{chapter}"}</code> <code className="font-mono">{"{mainSkills}"}</code> <code className="font-mono">{"{topSkills}"}</code>
</p>
</div>
<div className="flex items-center gap-3 pt-1">
<button
type="button"
onClick={handleSave}
disabled={updateMutation.isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{updateMutation.isPending ? "Saving…" : "Save Settings"}
</button>
{saved && <span className="text-sm text-green-600 dark:text-green-400 font-medium">Saved!</span>}
</div>
</div>
</div>{/* end 2-col grid */}
{/* Value Score Settings */}
<div className="mt-6 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wider mb-1">Value Score</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 leading-relaxed">
A persistent 0100 <em>price/quality</em> metric per resource five weighted dimensions combined.
Recompute on demand after changing weights or importing new skill matrices.
</p>
</div>
<div className="flex-shrink-0 rounded-lg bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 px-3 py-2 font-mono text-[11px] text-gray-700 dark:text-gray-300 whitespace-nowrap">
round(<span className="text-brand-600 dark:text-brand-400">D</span>·w +{" "}
<span className="text-brand-600 dark:text-brand-400">B</span>·w +{" "}
<span className="text-brand-600 dark:text-brand-400">C</span>·w +{" "}
<span className="text-brand-600 dark:text-brand-400">A</span>·w +{" "}
<span className="text-brand-600 dark:text-brand-400">E</span>·w)
</div>
</div>
{/* Weight sliders — compact grid */}
<div>
<p className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Dimension Weights must sum to 100%
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-3">
{/* Skill Depth */}
<div className="rounded-lg border border-gray-200 dark:border-gray-600 px-4 py-3">
<div className="flex items-center gap-2 mb-2">
<span className="px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300 text-[10px] font-mono rounded font-bold">D</span>
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100 flex-1">Skill Depth</span>
<span className="text-sm font-bold font-mono text-brand-600 dark:text-brand-400 tabular-nums w-10 text-right">
{Math.round(scoreWeights.skillDepth * 100)}%
</span>
</div>
<input
type="range" min={0} max={100} step={5}
value={Math.round(scoreWeights.skillDepth * 100)}
onChange={(e) => updateWeight("skillDepth", Number(e.target.value) / 100)}
className="w-full accent-brand-600"
/>
<details className="mt-1.5">
<summary className="cursor-pointer text-[11px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 select-none">Show details</summary>
<div className="mt-2 space-y-1.5">
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
Average proficiency (15) across all skills, scaled to 0100. Expert-heavy profiles score near 100.
</p>
<div className="rounded bg-gray-50 dark:bg-gray-700 px-2 py-1.5 font-mono text-[11px] text-gray-600 dark:text-gray-300">
D = round((avg_proficiency / 5) × 100)
<span className="ml-2 text-gray-400">avg 4.0/5 80</span>
</div>
<div className="text-[11px] text-gray-400 dark:text-gray-500">
0 = all Beginner · 100 = all Expert
</div>
</div>
</details>
</div>
{/* Skill Breadth */}
<div className="rounded-lg border border-gray-200 dark:border-gray-600 px-4 py-3">
<div className="flex items-center gap-2 mb-2">
<span className="px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300 text-[10px] font-mono rounded font-bold">B</span>
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100 flex-1">Skill Breadth</span>
<span className="text-sm font-bold font-mono text-brand-600 dark:text-brand-400 tabular-nums w-10 text-right">
{Math.round(scoreWeights.skillBreadth * 100)}%
</span>
</div>
<input
type="range" min={0} max={100} step={5}
value={Math.round(scoreWeights.skillBreadth * 100)}
onChange={(e) => updateWeight("skillBreadth", Number(e.target.value) / 100)}
className="w-full accent-brand-600"
/>
<details className="mt-1.5">
<summary className="cursor-pointer text-[11px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 select-none">Show details</summary>
<div className="mt-2 space-y-1.5">
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
Number of distinct skills listed. 10 pts per skill, caps at 100 (10+ skills). Rewards versatile generalists.
</p>
<div className="rounded bg-gray-50 dark:bg-gray-700 px-2 py-1.5 font-mono text-[11px] text-gray-600 dark:text-gray-300">
B = min(100, skill_count × 10)
<span className="ml-2 text-gray-400">7 skills 70</span>
</div>
<div className="text-[11px] text-gray-400 dark:text-gray-500">
0 = no skills · 100 = 10+ skills
</div>
</div>
</details>
</div>
{/* Cost Efficiency */}
<div className="rounded-lg border border-gray-200 dark:border-gray-600 px-4 py-3">
<div className="flex items-center gap-2 mb-2">
<span className="px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300 text-[10px] font-mono rounded font-bold">C</span>
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100 flex-1">Cost Efficiency</span>
<span className="text-sm font-bold font-mono text-brand-600 dark:text-brand-400 tabular-nums w-10 text-right">
{Math.round(scoreWeights.costEfficiency * 100)}%
</span>
</div>
<input
type="range" min={0} max={100} step={5}
value={Math.round(scoreWeights.costEfficiency * 100)}
onChange={(e) => updateWeight("costEfficiency", Number(e.target.value) / 100)}
className="w-full accent-brand-600"
/>
<details className="mt-1.5">
<summary className="cursor-pointer text-[11px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 select-none">Show details</summary>
<div className="mt-2 space-y-1.5">
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
Inverse LCR vs org-wide max. Cheapest resource = 100, most expensive = 0. Core "price" component.
</p>
<div className="rounded bg-gray-50 dark:bg-gray-700 px-2 py-1.5 font-mono text-[11px] text-gray-600 dark:text-gray-300">
C = round((1 LCR / max_LCR) × 100)
<span className="ml-2 text-gray-400">60 vs 120 50</span>
</div>
<div className="text-[11px] text-gray-400 dark:text-gray-500">
0 = highest LCR · 100 = lowest LCR
</div>
<p className="text-[11px] text-amber-600 dark:text-amber-500">
If all resources share the same LCR, everyone scores 0 on this dimension.
</p>
</div>
</details>
</div>
{/* Chargeability */}
<div className="rounded-lg border border-gray-200 dark:border-gray-600 px-4 py-3">
<div className="flex items-center gap-2 mb-2">
<span className="px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300 text-[10px] font-mono rounded font-bold">A</span>
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100 flex-1">Chargeability</span>
<span className="text-sm font-bold font-mono text-brand-600 dark:text-brand-400 tabular-nums w-10 text-right">
{Math.round(scoreWeights.chargeability * 100)}%
</span>
</div>
<input
type="range" min={0} max={100} step={5}
value={Math.round(scoreWeights.chargeability * 100)}
onChange={(e) => updateWeight("chargeability", Number(e.target.value) / 100)}
className="w-full accent-brand-600"
/>
<details className="mt-1.5">
<summary className="cursor-pointer text-[11px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 select-none">Show details</summary>
<div className="mt-2 space-y-1.5">
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
Distance from personal chargeability target (90-day window). On target = 100; 2 pts per pp off.
</p>
<div className="rounded bg-gray-50 dark:bg-gray-700 px-2 py-1.5 font-mono text-[11px] text-gray-600 dark:text-gray-300">
A = max(0, 100 |target% actual%| × 2)
<span className="ml-2 text-gray-400">10 pp off 80</span>
</div>
<div className="text-[11px] text-gray-400 dark:text-gray-500">
0 = 50+ pp off target · 100 = exactly on target
</div>
<p className="text-[11px] text-gray-400 dark:text-gray-500">
New resources with no allocations: actual = 0%, score reflects gap from target.
</p>
</div>
</details>
</div>
{/* Experience */}
<div className="rounded-lg border border-gray-200 dark:border-gray-600 px-4 py-3">
<div className="flex items-center gap-2 mb-2">
<span className="px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300 text-[10px] font-mono rounded font-bold">E</span>
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100 flex-1">Experience</span>
<span className="text-sm font-bold font-mono text-brand-600 dark:text-brand-400 tabular-nums w-10 text-right">
{Math.round(scoreWeights.experience * 100)}%
</span>
</div>
<input
type="range" min={0} max={100} step={5}
value={Math.round(scoreWeights.experience * 100)}
onChange={(e) => updateWeight("experience", Number(e.target.value) / 100)}
className="w-full accent-brand-600"
/>
<details className="mt-1.5">
<summary className="cursor-pointer text-[11px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 select-none">Show details</summary>
<div className="mt-2 space-y-1.5">
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
Average years of experience across skills with explicit years data from skill-matrix imports. Capped at 10 years.
</p>
<div className="rounded bg-gray-50 dark:bg-gray-700 px-2 py-1.5 font-mono text-[11px] text-gray-600 dark:text-gray-300">
E = min(100, avg_years × 10)
<span className="ml-2 text-gray-400">6.5 yrs 65 · 10+ yrs 100</span>
</div>
<div className="text-[11px] text-gray-400 dark:text-gray-500">
0 = no years data · 100 = 10+ years average
</div>
</div>
</details>
</div>
</div>
</div>
{/* Weight sum indicator */}
<div className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border ${
weightSumOk
? "bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-700 text-green-700 dark:text-green-300"
: "bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-700 text-red-700 dark:text-red-300"
}`}>
<span>{weightSumOk ? "✓" : "✗"}</span>
<span>
Total weight: <span className="font-mono">{Math.round(weightSum * 100)}%</span>
{weightSumOk ? " — valid" : " — must be exactly 100% to save"}
</span>
</div>
{/* Visibility roles */}
<div className="space-y-2">
<label className={LABEL_CLASS}>Score visibility which roles can see the Value Score</label>
<p className="text-xs text-gray-500 dark:text-gray-400">
Controls who sees the Score column on the Resources list, the breakdown on the Resource Detail page,
and the Top Value Resources dashboard widget.
</p>
<div className="flex flex-wrap gap-3 mt-2">
{ALL_ROLES.map((role) => (
<label key={role} className="flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none">
<input
type="checkbox"
checked={scoreVisibleRoles.includes(role)}
onChange={() => toggleRole(role)}
className="rounded border-gray-300"
/>
{role}
</label>
))}
</div>
</div>
{/* Recompute */}
<div className="border-t border-gray-100 dark:border-gray-700 pt-5 space-y-3">
<div>
<p className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider mb-2">Recompute Scores</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
Scores are <strong>not updated automatically</strong> run this after changing weights or after importing
new skill matrices. The computation fetches all active resources and their last 90 days of allocations,
then writes the result back to each resource record.
</p>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => { setRecomputeResult(null); recomputeMutation.mutate(); }}
disabled={recomputeMutation.isPending}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-sm font-medium disabled:opacity-50"
>
{recomputeMutation.isPending ? "Recomputing…" : "Recompute All Scores"}
</button>
{recomputeResult && (
<span className="text-sm text-green-600 dark:text-green-400 font-medium">
Updated {recomputeResult.updated} resource{recomputeResult.updated !== 1 ? "s" : ""}
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-3 pt-1">
<button
type="button"
onClick={handleSaveScoreSettings}
disabled={saveScoreMutation.isPending || !weightSumOk}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{saveScoreMutation.isPending ? "Saving…" : "Save Score Settings"}
</button>
{scoreSaved && <span className="text-sm text-green-600 dark:text-green-400 font-medium">Saved!</span>}
</div>
</div>
{/* ── SMTP / Email ──────────────────────────────────────────── */}
<div className="rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-5">
<div>
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">Email Notifications (SMTP)</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Used to send email notifications when vacation requests are approved or rejected.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={LABEL_CLASS}>SMTP Host</label>
<input type="text" className={INPUT_CLASS} value={smtpHost} onChange={(e) => setSmtpHost(e.target.value)} placeholder="smtp.example.com" />
</div>
<div>
<label className={LABEL_CLASS}>SMTP Port</label>
<input type="number" className={INPUT_CLASS} value={smtpPort} onChange={(e) => setSmtpPort(parseInt(e.target.value, 10))} min={1} max={65535} />
</div>
<div>
<label className={LABEL_CLASS}>SMTP Username</label>
<input type="text" className={INPUT_CLASS} value={smtpUser} onChange={(e) => setSmtpUser(e.target.value)} placeholder="user@example.com" autoComplete="off" />
</div>
<div>
<label className={LABEL_CLASS}>
SMTP Password{" "}
{settings?.hasSmtpPassword && <span className="text-gray-400 font-normal text-xs">(set leave blank to keep)</span>}
</label>
<input type="password" className={INPUT_CLASS} value={smtpPassword} onChange={(e) => setSmtpPassword(e.target.value)} placeholder="••••••••" autoComplete="new-password" />
</div>
<div>
<label className={LABEL_CLASS}>From Address</label>
<input type="email" className={INPUT_CLASS} value={smtpFrom} onChange={(e) => setSmtpFrom(e.target.value)} placeholder="noreply@planarchy.app" />
</div>
<div className="flex items-center gap-2 pt-6">
<input type="checkbox" id="smtpTls" checked={smtpTls} onChange={(e) => setSmtpTls(e.target.checked)} className="rounded border-gray-300 text-brand-600" />
<label htmlFor="smtpTls" className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer">Use TLS</label>
</div>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleSaveSmtp}
disabled={saveSmtpMutation.isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{saveSmtpMutation.isPending ? "Saving…" : "Save SMTP Settings"}
</button>
<button
type="button"
onClick={() => testSmtpMutation.mutate()}
disabled={testSmtpMutation.isPending}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-sm font-medium disabled:opacity-50"
>
{testSmtpMutation.isPending ? "Testing…" : "Test Connection"}
</button>
{smtpSaved && <span className="text-sm text-green-600 dark:text-green-400 font-medium">Saved!</span>}
{smtpTestResult && (
<span className={`text-sm font-medium ${smtpTestResult.ok ? "text-green-600 dark:text-green-400" : "text-red-500 dark:text-red-400"}`}>
{smtpTestResult.ok ? "✓ Connection successful" : `${smtpTestResult.error}`}
</span>
)}
</div>
</div>
{/* ── Vacation Defaults ─────────────────────────────────────── */}
<div className="rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-5">
<div>
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">Vacation Defaults</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Default annual leave entitlement for new resources and the entitlement bulk-set tool.
</p>
</div>
<div className="max-w-xs">
<label className={LABEL_CLASS}>Default Annual Leave Days</label>
<input
type="number"
className={INPUT_CLASS}
value={vacationDefaultDays}
onChange={(e) => setVacationDefaultDays(parseInt(e.target.value, 10))}
min={0}
max={365}
/>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">Applied when creating new entitlement records for resources.</p>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleSaveVacation}
disabled={saveVacationMutation.isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{saveVacationMutation.isPending ? "Saving…" : "Save Vacation Settings"}
</button>
{vacationSaved && <span className="text-sm text-green-600 dark:text-green-400 font-medium">Saved!</span>}
</div>
</div>
</div>
);
}