chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -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 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 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 (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-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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user