cd78f72f33
Complete rename of all technical identifiers across the codebase: Package names (11 packages): - @planarchy/* → @capakraken/* in all package.json, tsconfig, imports Import statements: 277 files, 548 occurrences replaced Database & Docker: - PostgreSQL user/db: planarchy → capakraken - Docker volumes: planarchy_pgdata → capakraken_pgdata - Connection strings updated in docker-compose, .env, CI CI/CD: - GitHub Actions workflow: all filter commands updated - Test database credentials updated Infrastructure: - Redis channel: planarchy:sse → capakraken:sse - Logger service name: planarchy-api → capakraken-api - Anonymization seed updated - Start/stop/restart scripts updated Test data: - Seed emails: @planarchy.dev → @capakraken.dev - E2E test credentials: all 11 spec files updated - Email defaults: @planarchy.app → @capakraken.app - localStorage keys: planarchy_* → capakraken_* Documentation: 30+ .md files updated Verification: - pnpm install: workspace resolution works - TypeScript: only pre-existing TS2589 (no new errors) - Engine: 310/310 tests pass - Staffing: 37/37 tests pass Co-Authored-By: claude-flow <ruv@ruv.net>
1513 lines
64 KiB
TypeScript
1513 lines
64 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect } from "react";
|
||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||
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);
|
||
|
||
// DALL-E settings
|
||
const [dalleDeployment, setDalleDeployment] = useState("");
|
||
const [dalleEndpoint, setDalleEndpoint] = useState("");
|
||
const [dalleApiKey, setDalleApiKey] = useState("");
|
||
|
||
// Gemini / Image generation settings
|
||
type ImageProvider = "dalle" | "gemini";
|
||
const [imageProvider, setImageProvider] = useState<ImageProvider>("dalle");
|
||
const [geminiApiKey, setGeminiApiKey] = useState("");
|
||
const [geminiModel, setGeminiModel] = useState("");
|
||
const [imageSaved, setImageSaved] = useState(false);
|
||
|
||
// 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);
|
||
|
||
// Timeline
|
||
const [undoMaxSteps, setUndoMaxSteps] = useState(50);
|
||
const [timelineSaved, setTimelineSaved] = 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[]);
|
||
}
|
||
// DALL-E
|
||
setDalleDeployment(settings.azureDalleDeployment ?? "");
|
||
setDalleEndpoint(settings.azureDalleEndpoint ?? "");
|
||
// Image provider / Gemini
|
||
setImageProvider((settings.imageProvider ?? "dalle") as ImageProvider);
|
||
setGeminiModel(settings.geminiModel ?? "");
|
||
// 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);
|
||
// Timeline
|
||
setUndoMaxSteps(settings.timelineUndoMaxSteps ?? 50);
|
||
}
|
||
}, [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);
|
||
},
|
||
});
|
||
|
||
const saveTimelineMutation = trpc.settings.updateSystemSettings.useMutation({
|
||
onSuccess: () => {
|
||
setTimelineSaved(true);
|
||
setTimeout(() => setTimelineSaved(false), 3000);
|
||
},
|
||
});
|
||
|
||
const saveImageMutation = trpc.settings.updateSystemSettings.useMutation({
|
||
onSuccess: () => {
|
||
setImageSaved(true);
|
||
setTimeout(() => setImageSaved(false), 3000);
|
||
},
|
||
});
|
||
|
||
const [geminiTestResult, setGeminiTestResult] = useState<{ ok: boolean; model?: string; error?: string } | null>(null);
|
||
const testGeminiMut = trpc.settings.testGeminiConnection.useMutation({
|
||
onSuccess: (data) => setGeminiTestResult(data as any),
|
||
onError: (err) => setGeminiTestResult({ ok: false, error: err.message }),
|
||
});
|
||
|
||
function handleSaveSmtp() {
|
||
saveSmtpMutation.mutate({
|
||
smtpHost: smtpHost || undefined,
|
||
smtpPort,
|
||
smtpUser: smtpUser || undefined,
|
||
...(smtpPassword ? { smtpPassword } : {}),
|
||
smtpFrom: smtpFrom || undefined,
|
||
smtpTls,
|
||
});
|
||
}
|
||
|
||
function handleSaveVacation() {
|
||
saveVacationMutation.mutate({ vacationDefaultDays });
|
||
}
|
||
|
||
function handleSaveTimeline() {
|
||
saveTimelineMutation.mutate({ timelineUndoMaxSteps: undoMaxSteps });
|
||
}
|
||
|
||
function handleSaveImage() {
|
||
saveImageMutation.mutate({
|
||
imageProvider,
|
||
// DALL-E fields
|
||
azureDalleDeployment: dalleDeployment || undefined,
|
||
azureDalleEndpoint: provider === "azure" && dalleEndpoint ? dalleEndpoint : undefined,
|
||
...(dalleApiKey ? { azureDalleApiKey: dalleApiKey } : {}),
|
||
// Gemini fields
|
||
...(geminiApiKey ? { geminiApiKey } : {}),
|
||
geminiModel: geminiModel || undefined,
|
||
});
|
||
}
|
||
|
||
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">
|
||
<div className="h-8 w-48 shimmer-skeleton rounded" />
|
||
</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 flex items-center">
|
||
AI Provider <InfoTooltip content="Configure the AI service used for generating resource skill profile summaries. Either OpenAI directly or Azure OpenAI Service." />
|
||
</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 flex items-center">
|
||
Generation Settings <InfoTooltip content="Fine-tune how the AI generates skill profile summaries. These settings affect output length, creativity, and the prompt template." />
|
||
</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>
|
||
|
||
{/* ── Image Generation ────────────────────────────────── */}
|
||
<div className={PANEL_CLASS}>
|
||
<div>
|
||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 flex items-center">
|
||
Image Generation <InfoTooltip content="Configure the image generation provider used for AI-generated project cover art." />
|
||
</h2>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||
Used to generate AI cover art for projects. Configure at least one provider below.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Provider selector */}
|
||
<div>
|
||
<label className={LABEL_CLASS}>Provider</label>
|
||
<div className="flex gap-4">
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="radio"
|
||
name="imageProvider"
|
||
value="dalle"
|
||
checked={imageProvider === "dalle"}
|
||
onChange={() => setImageProvider("dalle")}
|
||
className="accent-brand-600"
|
||
/>
|
||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">DALL-E (Azure OpenAI / OpenAI)</span>
|
||
</label>
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="radio"
|
||
name="imageProvider"
|
||
value="gemini"
|
||
checked={imageProvider === "gemini"}
|
||
onChange={() => setImageProvider("gemini")}
|
||
className="accent-brand-600"
|
||
/>
|
||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Google Gemini</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
{/* DALL-E settings (shown when DALL-E selected) */}
|
||
{imageProvider === "dalle" && (
|
||
<div className="space-y-4 rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50">
|
||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">DALL-E Configuration</h3>
|
||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||
<div>
|
||
<label className={LABEL_CLASS}>
|
||
<span className="flex items-center">
|
||
Deployment Name <InfoTooltip content="The DALL-E model deployment name (e.g. dall-e-3). For OpenAI this is the model name, for Azure it is the deployment name." />
|
||
</span>
|
||
</label>
|
||
<input
|
||
type="text"
|
||
className={INPUT_CLASS}
|
||
value={dalleDeployment}
|
||
onChange={(e) => setDalleDeployment(e.target.value)}
|
||
placeholder="dall-e-3"
|
||
/>
|
||
</div>
|
||
|
||
{provider === "azure" && (
|
||
<>
|
||
<div>
|
||
<label className={LABEL_CLASS}>
|
||
<span className="flex items-center">
|
||
Endpoint <InfoTooltip content="Azure endpoint for the DALL-E deployment. Leave empty to use the same endpoint as the chat model." />
|
||
</span>
|
||
</label>
|
||
<input
|
||
type="text"
|
||
className={INPUT_CLASS}
|
||
value={dalleEndpoint}
|
||
onChange={(e) => setDalleEndpoint(e.target.value)}
|
||
placeholder="Leave empty to use same endpoint as chat"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className={LABEL_CLASS}>
|
||
<span className="flex items-center">
|
||
API Key{" "}
|
||
<InfoTooltip content="API key for the DALL-E endpoint. Leave empty to use the same API key as the chat model." />
|
||
<span className="ml-1 text-xs font-normal text-gray-400">(optional)</span>
|
||
</span>
|
||
</label>
|
||
<input
|
||
type="password"
|
||
className={INPUT_CLASS}
|
||
value={dalleApiKey}
|
||
onChange={(e) => setDalleApiKey(e.target.value)}
|
||
placeholder="Leave empty to use same API key as chat"
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
{settings?.hasDalleApiKey && (
|
||
<p className="text-xs text-green-600 dark:text-green-400">A separate DALL-E API key is stored.</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Gemini settings (shown when Gemini selected) */}
|
||
{imageProvider === "gemini" && (
|
||
<div className="space-y-4 rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50">
|
||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">Google Gemini Configuration</h3>
|
||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||
<div>
|
||
<label className={LABEL_CLASS}>
|
||
<span className="flex items-center">
|
||
API Key <InfoTooltip content="Google Gemini API key from Google AI Studio (aistudio.google.com)." />
|
||
</span>
|
||
</label>
|
||
<input
|
||
type="password"
|
||
className={INPUT_CLASS}
|
||
value={geminiApiKey}
|
||
onChange={(e) => setGeminiApiKey(e.target.value)}
|
||
placeholder={settings?.hasGeminiApiKey ? "•••••••• (key is stored)" : "Enter Gemini API key"}
|
||
/>
|
||
{settings?.hasGeminiApiKey && !geminiApiKey && (
|
||
<p className="text-xs text-green-600 dark:text-green-400 mt-1">API key is stored.</p>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<label className={LABEL_CLASS}>
|
||
<span className="flex items-center">
|
||
Model <InfoTooltip content="Gemini model for image generation. The default model supports image output." />
|
||
</span>
|
||
</label>
|
||
<select
|
||
className={INPUT_CLASS}
|
||
value={geminiModel || "gemini-2.5-flash-image"}
|
||
onChange={(e) => setGeminiModel(e.target.value)}
|
||
>
|
||
<option value="gemini-2.5-flash-image">Gemini 2.5 Flash Image — fast, high-volume</option>
|
||
<option value="gemini-3-pro-image-preview">Gemini 3 Pro Image Preview — high-fidelity</option>
|
||
<option value="gemini-3.1-flash-image-preview">Gemini 3.1 Flash Image Preview — latest</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex flex-wrap items-center gap-3 pt-1">
|
||
<button
|
||
type="button"
|
||
className={PRIMARY_BUTTON_CLASS}
|
||
disabled={saveImageMutation.isPending}
|
||
onClick={handleSaveImage}
|
||
>
|
||
{saveImageMutation.isPending ? "Saving..." : "Save Image Settings"}
|
||
</button>
|
||
{imageProvider === "gemini" && (
|
||
<button
|
||
type="button"
|
||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-sm font-medium disabled:opacity-50"
|
||
disabled={testGeminiMut.isPending}
|
||
onClick={() => testGeminiMut.mutate()}
|
||
>
|
||
{testGeminiMut.isPending ? "Testing..." : "Test Gemini"}
|
||
</button>
|
||
)}
|
||
{imageSaved && (
|
||
<span className="text-sm font-medium text-green-600 dark:text-green-400">Saved</span>
|
||
)}
|
||
</div>
|
||
{geminiTestResult && (
|
||
<div className={`mt-3 rounded-lg px-3 py-2 text-sm ${
|
||
geminiTestResult.ok
|
||
? "bg-green-50 text-green-700 border border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-800"
|
||
: "bg-red-50 text-red-700 border border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-800"
|
||
}`}>
|
||
{geminiTestResult.ok
|
||
? `Gemini image generation works! Model: ${(geminiTestResult as any).model}`
|
||
: `Test failed: ${(geminiTestResult as any).error}`}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── SMTP / Email ──────────────────────────────────────────── */}
|
||
<div className={PANEL_CLASS}>
|
||
<div>
|
||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 flex items-center">
|
||
Email Notifications (SMTP) <InfoTooltip content="Configure SMTP to send email notifications for vacation approvals/rejections. Without SMTP, only in-app notifications are sent." />
|
||
</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}><span className="flex items-center">SMTP Host <InfoTooltip content="The SMTP server hostname (e.g. smtp.gmail.com, smtp.office365.com)." /></span></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}><span className="flex items-center">SMTP Port <InfoTooltip content="Common ports: 587 (STARTTLS), 465 (SSL/TLS), 25 (unencrypted). Use 587 for most providers." /></span></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}><span className="flex items-center">SMTP Username <InfoTooltip content="Authentication username for the SMTP server. Often the same as the email address." /></span></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}><span className="flex items-center">
|
||
SMTP Password <InfoTooltip content="The SMTP authentication password. Stored encrypted. Leave blank to keep the existing password." />{" "}</span>
|
||
{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}><span className="flex items-center">From Address <InfoTooltip content="The sender email address shown in notification emails (e.g. noreply@capakraken.app)." /></span></label>
|
||
<input
|
||
type="email"
|
||
className={INPUT_CLASS}
|
||
value={smtpFrom}
|
||
onChange={(e) => setSmtpFrom(e.target.value)}
|
||
placeholder="noreply@capakraken.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 flex items-center">
|
||
Vacation Defaults <InfoTooltip content="Sets the default vacation entitlement applied when creating new resources or using the bulk-set tool in Vacation Management." />
|
||
</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}><span className="flex items-center">Default Annual Leave Days <InfoTooltip content="The number of vacation days granted per year. In Germany, the legal minimum is 20 days; 28-30 is common. This value is used when creating new entitlement records." /></span></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 flex items-center">
|
||
Timeline <InfoTooltip content="Settings for the timeline view, including undo history depth." />
|
||
</h2>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||
Configure timeline behavior and undo/redo history.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="max-w-xs">
|
||
<label className={LABEL_CLASS}>Undo History Depth</label>
|
||
<input
|
||
type="number"
|
||
className={INPUT_CLASS}
|
||
value={undoMaxSteps}
|
||
onChange={(e) => setUndoMaxSteps(parseInt(e.target.value, 10) || 50)}
|
||
min={1}
|
||
max={200}
|
||
/>
|
||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||
Maximum number of undo steps for timeline operations (single moves and batch shifts). Default: 50.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={handleSaveTimeline}
|
||
disabled={saveTimelineMutation.isPending}
|
||
className={PRIMARY_BUTTON_CLASS}
|
||
>
|
||
{saveTimelineMutation.isPending ? "Saving…" : "Save Timeline Settings"}
|
||
</button>
|
||
{timelineSaved && (
|
||
<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 flex items-center">
|
||
Viewer Anonymization <InfoTooltip content="When enabled, all resource names, EIDs, and emails are replaced with stable fictional aliases (e.g. superhero names) in the UI. Real data stays in the database. Useful for demos and screenshots." />
|
||
</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>
|
||
);
|
||
}
|