625a842d89
Dashboard: expanded chargeability widget, resource/project table widgets with sorting and filters, stat cards with formatMoney integration. Chargeability: new report client with filtering, chargeability-bookings use case, updated dashboard overview logic. Dispo import: TBD project handling, parse-dispo-matrix improvements, stage-dispo-projects resource value scores, new tests. Estimates: CommercialTermsEditor component, commercial-terms engine module, expanded estimate schemas and types. UI: AppShell navigation updates, timeline filter/toolbar enhancements, role management improvements, signin page redesign, Tailwind/globals polish, SystemSettings SMTP section, anonymization support. Tests: new router tests (anonymization, chargeability, effort-rule, entitlement, estimate, experience-multiplier, notification, resource, staffing, vacation). Co-Authored-By: claude-flow <ruv@ruv.net>
1231 lines
50 KiB
TypeScript
1231 lines
50 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect } from "react";
|
||
import { trpc } from "~/lib/trpc/client.js";
|
||
|
||
const INPUT_CLASS = "app-input";
|
||
const LABEL_CLASS = "app-label";
|
||
const PANEL_CLASS = "app-surface p-6 space-y-5";
|
||
const PANEL_STRONG_CLASS = "app-surface-strong p-6 space-y-6";
|
||
const PRIMARY_BUTTON_CLASS =
|
||
"rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700 disabled:opacity-50";
|
||
const SECONDARY_BUTTON_CLASS =
|
||
"rounded-xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800";
|
||
const CHECKBOX_ROW_CLASS =
|
||
"flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-300";
|
||
|
||
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.3,
|
||
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,
|
||
);
|
||
|
||
// Global anonymization
|
||
const [anonymizationEnabled, setAnonymizationEnabled] = useState(false);
|
||
const [anonymizationDomain, setAnonymizationDomain] = useState("superhartmut.de");
|
||
const [anonymizationSeed, setAnonymizationSeed] = useState("");
|
||
const [anonymizationSaved, setAnonymizationSaved] = useState(false);
|
||
|
||
// 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);
|
||
// Global anonymization
|
||
setAnonymizationEnabled(settings.anonymizationEnabled ?? false);
|
||
setAnonymizationDomain(settings.anonymizationDomain ?? "superhartmut.de");
|
||
setAnonymizationSeed("");
|
||
// 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 saveAnonymizationMutation = trpc.settings.updateSystemSettings.useMutation({
|
||
onSuccess: () => {
|
||
setAnonymizationSaved(true);
|
||
setTimeout(() => setAnonymizationSaved(false), 3000);
|
||
},
|
||
});
|
||
|
||
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 handleSaveAnonymization() {
|
||
saveAnonymizationMutation.mutate({
|
||
anonymizationEnabled,
|
||
anonymizationDomain: anonymizationDomain.trim() || "superhartmut.de",
|
||
...(anonymizationSeed.trim() ? { anonymizationSeed: anonymizationSeed.trim() } : {}),
|
||
anonymizationMode: "global",
|
||
});
|
||
}
|
||
|
||
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="app-page animate-pulse">
|
||
<div className="h-8 w-48 rounded bg-gray-200 dark:bg-gray-700" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="app-page space-y-6">
|
||
<div className="app-page-header gap-4">
|
||
<div>
|
||
<h1 className="app-page-title">System Settings</h1>
|
||
<p className="app-page-subtitle mt-1">
|
||
Configure AI integration for skill profile generation.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||
<div className={PANEL_CLASS}>
|
||
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-700 dark:text-gray-200">
|
||
AI Provider
|
||
</h2>
|
||
|
||
{/* Provider toggle */}
|
||
<div>
|
||
<label className={LABEL_CLASS}>Provider</label>
|
||
<div className="flex w-fit overflow-hidden rounded-xl border border-gray-200 dark:border-gray-600">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setProvider("openai");
|
||
setTestResult(null);
|
||
}}
|
||
className={`px-4 py-2 text-sm font-semibold 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={`border-l border-gray-200 px-4 py-2 text-sm font-semibold transition-colors dark:border-gray-600 ${
|
||
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-2xl border border-blue-200 bg-blue-50 px-4 py-3 space-y-2 dark:border-blue-700 dark:bg-blue-900/20">
|
||
<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-2xl 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={PRIMARY_BUTTON_CLASS}
|
||
>
|
||
{updateMutation.isPending ? "Saving…" : "Save Settings"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setTestResult(null);
|
||
testMutation.mutate();
|
||
}}
|
||
disabled={testMutation.isPending}
|
||
className={SECONDARY_BUTTON_CLASS}
|
||
>
|
||
{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={PANEL_CLASS}>
|
||
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-700 dark:text-gray-200">
|
||
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={PRIMARY_BUTTON_CLASS}
|
||
>
|
||
{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={PANEL_STRONG_CLASS}>
|
||
<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 0–100 <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 whitespace-nowrap rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 font-mono text-[11px] text-gray-700 dark:border-gray-600 dark:bg-gray-700/50 dark:text-gray-300">
|
||
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-2xl border border-gray-200 px-4 py-3 dark:border-gray-600">
|
||
<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 (1–5) across all skills, scaled to 0–100. 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-2xl border border-gray-200 px-4 py-3 dark:border-gray-600">
|
||
<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-2xl border border-gray-200 px-4 py-3 dark:border-gray-600">
|
||
<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-2xl border border-gray-200 px-4 py-3 dark:border-gray-600">
|
||
<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-2xl border border-gray-200 px-4 py-3 dark:border-gray-600">
|
||
<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 rounded-2xl border px-4 py-2.5 text-sm font-medium ${
|
||
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={`${CHECKBOX_ROW_CLASS} 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={SECONDARY_BUTTON_CLASS}
|
||
>
|
||
{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={PRIMARY_BUTTON_CLASS}
|
||
>
|
||
{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={PANEL_CLASS}>
|
||
<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-1 gap-4 md:grid-cols-2">
|
||
<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={`${CHECKBOX_ROW_CLASS} pt-0 md:mt-[1.65rem]`}>
|
||
<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={PRIMARY_BUTTON_CLASS}
|
||
>
|
||
{saveSmtpMutation.isPending ? "Saving…" : "Save SMTP Settings"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => testSmtpMutation.mutate()}
|
||
disabled={testSmtpMutation.isPending}
|
||
className={SECONDARY_BUTTON_CLASS}
|
||
>
|
||
{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={PANEL_CLASS}>
|
||
<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={PRIMARY_BUTTON_CLASS}
|
||
>
|
||
{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 className={PANEL_CLASS}>
|
||
<div>
|
||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">
|
||
Viewer Anonymization
|
||
</h2>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||
Global debug mode that keeps real identities in the database but replaces displayed
|
||
resource names, EIDs, and emails with stable character aliases across sessions.
|
||
</p>
|
||
</div>
|
||
|
||
<label className="flex items-start gap-3 rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-300">
|
||
<input
|
||
type="checkbox"
|
||
checked={anonymizationEnabled}
|
||
onChange={(e) => setAnonymizationEnabled(e.target.checked)}
|
||
className="mt-0.5 rounded border-gray-300 text-brand-600"
|
||
/>
|
||
<span>
|
||
Enable global stable anonymization
|
||
<span className="block text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||
API responses switch to deterministic aliases like <code>Iron Man</code>,{" "}
|
||
<code>iron.man</code>, and <code>iron.man@superhartmut.de</code>.
|
||
</span>
|
||
</span>
|
||
</label>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div>
|
||
<label className={LABEL_CLASS}>Alias Email Domain</label>
|
||
<input
|
||
type="text"
|
||
className={INPUT_CLASS}
|
||
value={anonymizationDomain}
|
||
onChange={(e) => setAnonymizationDomain(e.target.value)}
|
||
placeholder="superhartmut.de"
|
||
/>
|
||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||
Used for generated alias emails only. Stored resource emails stay unchanged.
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<label className={LABEL_CLASS}>Optional Seed Override</label>
|
||
<input
|
||
type="text"
|
||
className={INPUT_CLASS}
|
||
value={anonymizationSeed}
|
||
onChange={(e) => setAnonymizationSeed(e.target.value)}
|
||
placeholder="Leave blank to keep the current stable mapping"
|
||
/>
|
||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||
Changing the seed intentionally reshuffles aliases. Leave blank to preserve the
|
||
existing mapping.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
||
High-LCR resources receive more iconic characters first. Real EIDs and emails are never
|
||
rewritten in Prisma.
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={handleSaveAnonymization}
|
||
disabled={saveAnonymizationMutation.isPending}
|
||
className={PRIMARY_BUTTON_CLASS}
|
||
>
|
||
{saveAnonymizationMutation.isPending ? "Saving…" : "Save Anonymization Settings"}
|
||
</button>
|
||
{anonymizationSaved && (
|
||
<span className="text-sm text-green-600 dark:text-green-400 font-medium">Saved!</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|