refactor(admin): split system settings into section modules
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,405 @@
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import {
|
||||
INPUT_CLASS,
|
||||
LABEL_CLASS,
|
||||
PANEL_CLASS,
|
||||
PRIMARY_BUTTON_CLASS,
|
||||
SECONDARY_BUTTON_CLASS,
|
||||
RuntimeSecretCard,
|
||||
type Provider,
|
||||
type RuntimeSecretStatus,
|
||||
type SaveResult,
|
||||
type UrlParsedType,
|
||||
} from "./shared.js";
|
||||
|
||||
type AiProviderPanelProps = {
|
||||
provider: Provider;
|
||||
endpoint: string;
|
||||
model: string;
|
||||
apiVersion: string;
|
||||
urlPasteValue: string;
|
||||
urlParseError: boolean;
|
||||
urlParsedType: UrlParsedType;
|
||||
runtimeSecret: RuntimeSecretStatus;
|
||||
testResult: SaveResult | null;
|
||||
isSaving: boolean;
|
||||
isTesting: boolean;
|
||||
saved: boolean;
|
||||
onProviderChange: (provider: Provider) => void;
|
||||
onEndpointChange: (value: string) => void;
|
||||
onModelChange: (value: string) => void;
|
||||
onApiVersionChange: (value: string) => void;
|
||||
onUrlPaste: (value: string) => void;
|
||||
onSave: () => void;
|
||||
onTest: () => void;
|
||||
};
|
||||
|
||||
export function AiProviderPanel({
|
||||
provider,
|
||||
endpoint,
|
||||
model,
|
||||
apiVersion,
|
||||
urlPasteValue,
|
||||
urlParseError,
|
||||
urlParsedType,
|
||||
runtimeSecret,
|
||||
testResult,
|
||||
isSaving,
|
||||
isTesting,
|
||||
saved,
|
||||
onProviderChange,
|
||||
onEndpointChange,
|
||||
onModelChange,
|
||||
onApiVersionChange,
|
||||
onUrlPaste,
|
||||
onSave,
|
||||
onTest,
|
||||
}: AiProviderPanelProps) {
|
||||
return (
|
||||
<div className={PANEL_CLASS}>
|
||||
<h2 className="flex items-center text-sm font-semibold uppercase tracking-[0.18em] text-gray-700 dark:text-gray-200">
|
||||
AI Provider{" "}
|
||||
<InfoTooltip content="Configure the AI service used for generating resource skill profile summaries. Either OpenAI directly or Azure OpenAI Service." />
|
||||
</h2>
|
||||
|
||||
<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={() => onProviderChange("openai")}
|
||||
className={`px-4 py-2 text-sm font-semibold transition-colors ${
|
||||
provider === "openai"
|
||||
? "bg-brand-600 text-white"
|
||||
: "bg-white text-gray-600 hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
}`}
|
||||
>
|
||||
OpenAI
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onProviderChange("azure")}
|
||||
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 text-gray-600 hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
}`}
|
||||
>
|
||||
Azure OpenAI
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs text-gray-400 dark:text-gray-500">
|
||||
{provider === "openai"
|
||||
? "Use a standard OpenAI API key from platform.openai.com."
|
||||
: "Use a deployment on your own Azure OpenAI resource."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{provider === "azure" ? (
|
||||
<>
|
||||
<div className="space-y-2 rounded-2xl border border-blue-200 bg-blue-50 px-4 py-3 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={(event) => onUrlPaste(event.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>
|
||||
) : null}
|
||||
{urlParsedType === "responses" ? (
|
||||
<p className="text-xs text-amber-700 dark:text-amber-400">
|
||||
Responses API URL detected. Endpoint and api-version were filled in, but the
|
||||
deployment or model name still has to be entered manually below.
|
||||
</p>
|
||||
) : null}
|
||||
{urlParsedType === "completions" ? (
|
||||
<p className="text-xs text-green-700 dark:text-green-400">All fields filled from URL.</p>
|
||||
) : null}
|
||||
</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={(event) => onEndpointChange(event.target.value)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
Everything up to (not including) <code className="font-mono">/openai/…</code>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<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={(event) => onModelChange(event.target.value)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
{provider === "azure"
|
||||
? "The deployment name chosen when deploying the model in Azure."
|
||||
: "The model identifier, for example gpt-4o-mini or gpt-4o."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{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={(event) => onApiVersionChange(event.target.value)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
The <code className="font-mono">api-version</code> query parameter from the endpoint
|
||||
URL. Default: <code className="font-mono">2025-01-01-preview</code>
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<RuntimeSecretCard
|
||||
title="Primary AI API Key"
|
||||
description={
|
||||
provider === "openai"
|
||||
? "The runtime reads the OpenAI key directly from deployment secrets. Saving this form does not store or rotate secrets."
|
||||
: "The runtime reads the Azure OpenAI key directly from deployment secrets. Saving this form only updates non-secret metadata."
|
||||
}
|
||||
secret={runtimeSecret}
|
||||
optionalNote={
|
||||
provider === "openai"
|
||||
? "Expected source: OPENAI_API_KEY. AZURE_OPENAI_API_KEY is also accepted as a fallback."
|
||||
: "Expected source: AZURE_OPENAI_API_KEY. OPENAI_API_KEY is also accepted as a fallback."
|
||||
}
|
||||
/>
|
||||
|
||||
{testResult ? (
|
||||
<div
|
||||
className={`rounded-2xl border px-4 py-3 text-sm ${
|
||||
testResult.ok
|
||||
? "border-green-200 bg-green-50 text-green-700 dark:border-green-700 dark:bg-green-900/30 dark:text-green-300"
|
||||
: "border-red-200 bg-red-50 text-red-700 dark:border-red-700 dark:bg-red-900/30 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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
className={PRIMARY_BUTTON_CLASS}
|
||||
>
|
||||
{isSaving ? "Saving…" : "Save Settings"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTest}
|
||||
disabled={isTesting}
|
||||
className={SECONDARY_BUTTON_CLASS}
|
||||
>
|
||||
{isTesting ? "Testing…" : "Test Connection"}
|
||||
</button>
|
||||
{saved ? (
|
||||
<span className="text-sm font-medium text-green-600 dark:text-green-400">Saved!</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type GenerationSettingsPanelProps = {
|
||||
maxTokens: number;
|
||||
temperature: number;
|
||||
summaryPrompt: string;
|
||||
defaultSummaryPrompt: string;
|
||||
isSaving: boolean;
|
||||
saved: boolean;
|
||||
onMaxTokensChange: (value: number) => void;
|
||||
onTemperatureChange: (value: number) => void;
|
||||
onSummaryPromptChange: (value: string) => void;
|
||||
onSave: () => void;
|
||||
onResetSummaryPrompt: () => void;
|
||||
};
|
||||
|
||||
export function GenerationSettingsPanel({
|
||||
maxTokens,
|
||||
temperature,
|
||||
summaryPrompt,
|
||||
defaultSummaryPrompt,
|
||||
isSaving,
|
||||
saved,
|
||||
onMaxTokensChange,
|
||||
onTemperatureChange,
|
||||
onSummaryPromptChange,
|
||||
onSave,
|
||||
onResetSummaryPrompt,
|
||||
}: GenerationSettingsPanelProps) {
|
||||
return (
|
||||
<div className={PANEL_CLASS}>
|
||||
<h2 className="flex items-center text-sm font-semibold uppercase tracking-[0.18em] text-gray-700 dark:text-gray-200">
|
||||
Generation Settings{" "}
|
||||
<InfoTooltip content="Fine-tune how the AI generates skill profile summaries. These settings affect output length, creativity, and the prompt template." />
|
||||
</h2>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<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={(event) => onMaxTokensChange(Number(event.target.value))}
|
||||
className="w-full accent-brand-600"
|
||||
/>
|
||||
<div className="mt-1 flex justify-between text-xs text-gray-400">
|
||||
<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="mt-1 text-xs text-amber-600 dark:text-amber-400">
|
||||
Reasoning models consume tokens internally before writing output. Keep this at 2000 or
|
||||
above to avoid empty responses.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<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={(event) => onTemperatureChange(Number(event.target.value))}
|
||||
className="w-full accent-brand-600"
|
||||
/>
|
||||
<div className="mt-1 flex justify-between text-xs text-gray-400">
|
||||
<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="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
Some models only accept the default value of 1. If generation fails with a temperature
|
||||
error, the system retries automatically without it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<label className={LABEL_CLASS} htmlFor="ai-prompt">
|
||||
Profile Summary Prompt
|
||||
</label>
|
||||
{summaryPrompt ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResetSummaryPrompt}
|
||||
className="text-xs text-brand-600 hover:underline"
|
||||
>
|
||||
Reset to default
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<textarea
|
||||
id="ai-prompt"
|
||||
rows={10}
|
||||
value={summaryPrompt || defaultSummaryPrompt}
|
||||
onChange={(event) => onSummaryPromptChange(event.target.value)}
|
||||
className={`${INPUT_CLASS} resize-y font-mono text-xs leading-relaxed`}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
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={onSave}
|
||||
disabled={isSaving}
|
||||
className={PRIMARY_BUTTON_CLASS}
|
||||
>
|
||||
{isSaving ? "Saving…" : "Save Settings"}
|
||||
</button>
|
||||
{saved ? (
|
||||
<span className="text-sm font-medium text-green-600 dark:text-green-400">Saved!</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import {
|
||||
INPUT_CLASS,
|
||||
LABEL_CLASS,
|
||||
PANEL_CLASS,
|
||||
PRIMARY_BUTTON_CLASS,
|
||||
RuntimeSecretCard,
|
||||
type RuntimeSecrets,
|
||||
} from "./shared.js";
|
||||
|
||||
type AnonymizationSettingsPanelProps = {
|
||||
anonymizationEnabled: boolean;
|
||||
anonymizationDomain: string;
|
||||
anonymizationSaved: boolean;
|
||||
anonymizationSecret: RuntimeSecrets["anonymizationSeed"];
|
||||
isSaving: boolean;
|
||||
onAnonymizationEnabledChange: (value: boolean) => void;
|
||||
onAnonymizationDomainChange: (value: string) => void;
|
||||
onSave: () => void;
|
||||
};
|
||||
|
||||
export function AnonymizationSettingsPanel({
|
||||
anonymizationEnabled,
|
||||
anonymizationDomain,
|
||||
anonymizationSaved,
|
||||
anonymizationSecret,
|
||||
isSaving,
|
||||
onAnonymizationEnabledChange,
|
||||
onAnonymizationDomainChange,
|
||||
onSave,
|
||||
}: AnonymizationSettingsPanelProps) {
|
||||
return (
|
||||
<div className={PANEL_CLASS}>
|
||||
<div>
|
||||
<h2 className="flex items-center text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||
Viewer Anonymization{" "}
|
||||
<InfoTooltip content="Replace displayed identities with deterministic aliases for demos and screenshots." />
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Global debug mode that keeps real identities in the database but replaces displayed
|
||||
resource names, EIDs, and emails with stable 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={(event) => onAnonymizationEnabledChange(event.target.checked)}
|
||||
className="mt-0.5 rounded border-gray-300 text-brand-600"
|
||||
/>
|
||||
<span>
|
||||
Enable global stable anonymization
|
||||
<span className="mt-1 block text-xs text-gray-500 dark:text-gray-400">
|
||||
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 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>Alias Email Domain</label>
|
||||
<input
|
||||
type="text"
|
||||
className={INPUT_CLASS}
|
||||
value={anonymizationDomain}
|
||||
onChange={(event) => onAnonymizationDomainChange(event.target.value)}
|
||||
placeholder="superhartmut.de"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
Used for generated alias emails only. Stored resource emails stay unchanged.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>Optional Seed Override</label>
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
The optional seed is managed as a deployment secret instead of an in-app value.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RuntimeSecretCard
|
||||
title="Anonymization Seed"
|
||||
description="The stable anonymization seed is resolved from runtime secret management."
|
||||
secret={anonymizationSecret}
|
||||
optionalNote="Provision ANONYMIZATION_SEED only when you need a non-default, deployment-specific alias mapping."
|
||||
/>
|
||||
|
||||
<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={onSave}
|
||||
disabled={isSaving}
|
||||
className={PRIMARY_BUTTON_CLASS}
|
||||
>
|
||||
{isSaving ? "Saving…" : "Save Anonymization Settings"}
|
||||
</button>
|
||||
{anonymizationSaved ? (
|
||||
<span className="text-sm font-medium text-green-600 dark:text-green-400">Saved!</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import {
|
||||
INPUT_CLASS,
|
||||
LABEL_CLASS,
|
||||
PANEL_CLASS,
|
||||
PRIMARY_BUTTON_CLASS,
|
||||
RuntimeSecretCard,
|
||||
type GeminiTestResult,
|
||||
type ImageProvider,
|
||||
type Provider,
|
||||
type RuntimeSecrets,
|
||||
} from "./shared.js";
|
||||
|
||||
type ImageGenerationPanelProps = {
|
||||
provider: Provider;
|
||||
imageProvider: ImageProvider;
|
||||
dalleDeployment: string;
|
||||
dalleEndpoint: string;
|
||||
geminiModel: string;
|
||||
imageSaved: boolean;
|
||||
geminiTestResult: GeminiTestResult | null;
|
||||
runtimeSecrets: RuntimeSecrets;
|
||||
isSaving: boolean;
|
||||
isTestingGemini: boolean;
|
||||
onImageProviderChange: (provider: ImageProvider) => void;
|
||||
onDalleDeploymentChange: (value: string) => void;
|
||||
onDalleEndpointChange: (value: string) => void;
|
||||
onGeminiModelChange: (value: string) => void;
|
||||
onSave: () => void;
|
||||
onTestGemini: () => void;
|
||||
};
|
||||
|
||||
export function ImageGenerationPanel({
|
||||
provider,
|
||||
imageProvider,
|
||||
dalleDeployment,
|
||||
dalleEndpoint,
|
||||
geminiModel,
|
||||
imageSaved,
|
||||
geminiTestResult,
|
||||
runtimeSecrets,
|
||||
isSaving,
|
||||
isTestingGemini,
|
||||
onImageProviderChange,
|
||||
onDalleDeploymentChange,
|
||||
onDalleEndpointChange,
|
||||
onGeminiModelChange,
|
||||
onSave,
|
||||
onTestGemini,
|
||||
}: ImageGenerationPanelProps) {
|
||||
return (
|
||||
<div className={PANEL_CLASS}>
|
||||
<div>
|
||||
<h2 className="flex items-center text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||
Image Generation{" "}
|
||||
<InfoTooltip content="Configure the image generation provider used for AI-generated project cover art." />
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Used to generate AI cover art for projects. Configure at least one provider below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>Provider</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="imageProvider"
|
||||
value="dalle"
|
||||
checked={imageProvider === "dalle"}
|
||||
onChange={() => onImageProviderChange("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 cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="imageProvider"
|
||||
value="gemini"
|
||||
checked={imageProvider === "gemini"}
|
||||
onChange={() => onImageProviderChange("gemini")}
|
||||
className="accent-brand-600"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Google Gemini
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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="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={(event) => onDalleDeploymentChange(event.target.value)}
|
||||
placeholder="dall-e-3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{provider === "azure" ? (
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>
|
||||
<span className="flex items-center">
|
||||
Endpoint{" "}
|
||||
<InfoTooltip content="Leave empty to use the same endpoint as the chat model." />
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className={INPUT_CLASS}
|
||||
value={dalleEndpoint}
|
||||
onChange={(event) => onDalleEndpointChange(event.target.value)}
|
||||
placeholder="Leave empty to use same endpoint as chat"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<RuntimeSecretCard
|
||||
title="Dedicated DALL-E Key"
|
||||
description="Optional override for image generation. If unset, runtime falls back to the primary AI key when possible."
|
||||
secret={runtimeSecrets.azureDalleApiKey}
|
||||
optionalNote="Use AZURE_DALLE_API_KEY only when image generation should be isolated from the primary AI credential."
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{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">
|
||||
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={(event) => onGeminiModelChange(event.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>
|
||||
<RuntimeSecretCard
|
||||
title="Gemini API Key"
|
||||
description="Gemini credentials are resolved from deployment secrets only."
|
||||
secret={runtimeSecrets.geminiApiKey}
|
||||
optionalNote="Provision GEMINI_API_KEY in the target environment before using Gemini image generation."
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
className={PRIMARY_BUTTON_CLASS}
|
||||
disabled={isSaving}
|
||||
onClick={onSave}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save Image Settings"}
|
||||
</button>
|
||||
{imageProvider === "gemini" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
disabled={isTestingGemini}
|
||||
onClick={onTestGemini}
|
||||
>
|
||||
{isTestingGemini ? "Testing..." : "Test Gemini"}
|
||||
</button>
|
||||
) : null}
|
||||
{imageSaved ? (
|
||||
<span className="text-sm font-medium text-green-600 dark:text-green-400">Saved</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{geminiTestResult ? (
|
||||
<div
|
||||
className={`mt-3 rounded-lg border px-3 py-2 text-sm ${
|
||||
geminiTestResult.ok
|
||||
? "border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-900/20 dark:text-green-300"
|
||||
: "border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300"
|
||||
}`}
|
||||
>
|
||||
{geminiTestResult.ok
|
||||
? `Gemini image generation works. Model: ${geminiTestResult.model}`
|
||||
: `Test failed: ${geminiTestResult.error}`}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { SECONDARY_BUTTON_CLASS } from "./shared.js";
|
||||
|
||||
type LegacyRuntimeSecretsNoticeProps = {
|
||||
fields: string[];
|
||||
result: string | null;
|
||||
isPending: boolean;
|
||||
onClear: () => void;
|
||||
};
|
||||
|
||||
export function LegacyRuntimeSecretsNotice({
|
||||
fields,
|
||||
result,
|
||||
isPending,
|
||||
onClear,
|
||||
}: LegacyRuntimeSecretsNoticeProps) {
|
||||
if (fields.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-5 py-4 dark:border-amber-800 dark:bg-amber-950/30">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-[0.16em] text-amber-900 dark:text-amber-200">
|
||||
Legacy Runtime Secrets Detected
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-amber-900/90 dark:text-amber-100/90">
|
||||
This installation still has database-stored runtime secrets. New secrets are no longer
|
||||
persisted in the application. Move them to deployment-level secret management first,
|
||||
then clear the legacy residue here.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-amber-800 dark:text-amber-300">
|
||||
Affected fields:{" "}
|
||||
{fields.map((field) => (
|
||||
<code key={field} className="mr-1 font-mono">
|
||||
{field}
|
||||
</code>
|
||||
))}
|
||||
</p>
|
||||
{result ? (
|
||||
<p className="mt-2 text-xs text-amber-900 dark:text-amber-200">{result}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
disabled={isPending}
|
||||
className={SECONDARY_BUTTON_CLASS}
|
||||
>
|
||||
{isPending ? "Clearing…" : "Clear Legacy DB Secrets"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import {
|
||||
CHECKBOX_ROW_CLASS,
|
||||
INPUT_CLASS,
|
||||
LABEL_CLASS,
|
||||
PANEL_CLASS,
|
||||
PRIMARY_BUTTON_CLASS,
|
||||
SECONDARY_BUTTON_CLASS,
|
||||
RuntimeSecretCard,
|
||||
type RuntimeSecrets,
|
||||
type SaveResult,
|
||||
} from "./shared.js";
|
||||
|
||||
type SmtpSettingsPanelProps = {
|
||||
smtpHost: string;
|
||||
smtpPort: number;
|
||||
smtpUser: string;
|
||||
smtpFrom: string;
|
||||
smtpTls: boolean;
|
||||
smtpSaved: boolean;
|
||||
smtpTestResult: SaveResult | null;
|
||||
smtpSecret: RuntimeSecrets["smtpPassword"];
|
||||
isSaving: boolean;
|
||||
isTesting: boolean;
|
||||
onSmtpHostChange: (value: string) => void;
|
||||
onSmtpPortChange: (value: number) => void;
|
||||
onSmtpUserChange: (value: string) => void;
|
||||
onSmtpFromChange: (value: string) => void;
|
||||
onSmtpTlsChange: (value: boolean) => void;
|
||||
onSave: () => void;
|
||||
onTest: () => void;
|
||||
};
|
||||
|
||||
export function SmtpSettingsPanel({
|
||||
smtpHost,
|
||||
smtpPort,
|
||||
smtpUser,
|
||||
smtpFrom,
|
||||
smtpTls,
|
||||
smtpSaved,
|
||||
smtpTestResult,
|
||||
smtpSecret,
|
||||
isSaving,
|
||||
isTesting,
|
||||
onSmtpHostChange,
|
||||
onSmtpPortChange,
|
||||
onSmtpUserChange,
|
||||
onSmtpFromChange,
|
||||
onSmtpTlsChange,
|
||||
onSave,
|
||||
onTest,
|
||||
}: SmtpSettingsPanelProps) {
|
||||
return (
|
||||
<div className={PANEL_CLASS}>
|
||||
<div>
|
||||
<h2 className="flex items-center text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||
Email Notifications (SMTP){" "}
|
||||
<InfoTooltip content="Configure SMTP to send email notifications for vacation approvals and rejections." />
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
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, for example smtp.gmail.com or smtp.office365.com." />
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className={INPUT_CLASS}
|
||||
value={smtpHost}
|
||||
onChange={(event) => onSmtpHostChange(event.target.value)}
|
||||
placeholder="smtp.example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>
|
||||
<span className="flex items-center">
|
||||
SMTP Port{" "}
|
||||
<InfoTooltip content="Common ports: 587 for STARTTLS, 465 for SSL/TLS, 25 for unencrypted." />
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className={INPUT_CLASS}
|
||||
value={smtpPort}
|
||||
onChange={(event) => onSmtpPortChange(parseInt(event.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." />
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className={INPUT_CLASS}
|
||||
value={smtpUser}
|
||||
onChange={(event) => onSmtpUserChange(event.target.value)}
|
||||
placeholder="user@example.com"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>
|
||||
<span className="flex items-center">
|
||||
From Address{" "}
|
||||
<InfoTooltip content="The sender email address shown in notification emails." />
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
className={INPUT_CLASS}
|
||||
value={smtpFrom}
|
||||
onChange={(event) => onSmtpFromChange(event.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={(event) => onSmtpTlsChange(event.target.checked)}
|
||||
className="rounded border-gray-300 text-brand-600"
|
||||
/>
|
||||
<label
|
||||
htmlFor="smtpTls"
|
||||
className="cursor-pointer text-sm text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Use TLS
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RuntimeSecretCard
|
||||
title="SMTP Password"
|
||||
description="SMTP credentials are provisioned outside the application and injected at runtime."
|
||||
secret={smtpSecret}
|
||||
optionalNote="Provision SMTP_PASSWORD in the deployment target used by the API service."
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
className={PRIMARY_BUTTON_CLASS}
|
||||
>
|
||||
{isSaving ? "Saving…" : "Save SMTP Settings"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTest}
|
||||
disabled={isTesting}
|
||||
className={SECONDARY_BUTTON_CLASS}
|
||||
>
|
||||
{isTesting ? "Testing…" : "Test Connection"}
|
||||
</button>
|
||||
{smtpSaved ? (
|
||||
<span className="text-sm font-medium text-green-600 dark:text-green-400">Saved!</span>
|
||||
) : null}
|
||||
{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>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { INPUT_CLASS, LABEL_CLASS, PANEL_CLASS, PRIMARY_BUTTON_CLASS } from "./shared.js";
|
||||
|
||||
type TimelineSettingsPanelProps = {
|
||||
undoMaxSteps: number;
|
||||
timelineSaved: boolean;
|
||||
isSaving: boolean;
|
||||
onUndoMaxStepsChange: (value: number) => void;
|
||||
onSave: () => void;
|
||||
};
|
||||
|
||||
export function TimelineSettingsPanel({
|
||||
undoMaxSteps,
|
||||
timelineSaved,
|
||||
isSaving,
|
||||
onUndoMaxStepsChange,
|
||||
onSave,
|
||||
}: TimelineSettingsPanelProps) {
|
||||
return (
|
||||
<div className={PANEL_CLASS}>
|
||||
<div>
|
||||
<h2 className="flex items-center text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||
Timeline{" "}
|
||||
<InfoTooltip content="Settings for the timeline view, including undo history depth." />
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Configure timeline behavior and undo or 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={(event) => onUndoMaxStepsChange(parseInt(event.target.value, 10) || 50)}
|
||||
min={1}
|
||||
max={200}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
Maximum number of undo steps for timeline operations. Default: 50.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
className={PRIMARY_BUTTON_CLASS}
|
||||
>
|
||||
{isSaving ? "Saving…" : "Save Timeline Settings"}
|
||||
</button>
|
||||
{timelineSaved ? (
|
||||
<span className="text-sm font-medium text-green-600 dark:text-green-400">Saved!</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { INPUT_CLASS, LABEL_CLASS, PANEL_CLASS, PRIMARY_BUTTON_CLASS } from "./shared.js";
|
||||
|
||||
type VacationSettingsPanelProps = {
|
||||
vacationDefaultDays: number;
|
||||
vacationSaved: boolean;
|
||||
isSaving: boolean;
|
||||
onVacationDefaultDaysChange: (value: number) => void;
|
||||
onSave: () => void;
|
||||
};
|
||||
|
||||
export function VacationSettingsPanel({
|
||||
vacationDefaultDays,
|
||||
vacationSaved,
|
||||
isSaving,
|
||||
onVacationDefaultDaysChange,
|
||||
onSave,
|
||||
}: VacationSettingsPanelProps) {
|
||||
return (
|
||||
<div className={PANEL_CLASS}>
|
||||
<div>
|
||||
<h2 className="flex items-center text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||
Vacation Defaults{" "}
|
||||
<InfoTooltip content="Sets the default vacation entitlement applied when creating new resources." />
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
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." />
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className={INPUT_CLASS}
|
||||
value={vacationDefaultDays}
|
||||
onChange={(event) => onVacationDefaultDaysChange(parseInt(event.target.value, 10))}
|
||||
min={0}
|
||||
max={365}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
Applied when creating new entitlement records for resources.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
className={PRIMARY_BUTTON_CLASS}
|
||||
>
|
||||
{isSaving ? "Saving…" : "Save Vacation Settings"}
|
||||
</button>
|
||||
{vacationSaved ? (
|
||||
<span className="text-sm font-medium text-green-600 dark:text-green-400">Saved!</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { CHECKBOX_ROW_CLASS, LABEL_CLASS, PANEL_STRONG_CLASS, PRIMARY_BUTTON_CLASS, SECONDARY_BUTTON_CLASS, ALL_ROLES, type ScoreWeights, type SystemRole } from "./shared.js";
|
||||
|
||||
type WeightKey = keyof ScoreWeights;
|
||||
|
||||
type WeightDefinition = {
|
||||
key: WeightKey;
|
||||
code: string;
|
||||
title: string;
|
||||
details: string;
|
||||
formula: string;
|
||||
example: string;
|
||||
range: string;
|
||||
warning?: string;
|
||||
};
|
||||
|
||||
const WEIGHT_DEFINITIONS: WeightDefinition[] = [
|
||||
{
|
||||
key: "skillDepth",
|
||||
code: "D",
|
||||
title: "Skill Depth",
|
||||
details: "Average proficiency across all skills, scaled to 0–100. Expert-heavy profiles score near 100.",
|
||||
formula: "D = round((avg_proficiency / 5) × 100)",
|
||||
example: "avg 4.0/5 → 80",
|
||||
range: "0 = all Beginner · 100 = all Expert",
|
||||
},
|
||||
{
|
||||
key: "skillBreadth",
|
||||
code: "B",
|
||||
title: "Skill Breadth",
|
||||
details: "Number of distinct skills listed. Ten points per skill, capped at 100 for ten or more skills.",
|
||||
formula: "B = min(100, skill_count × 10)",
|
||||
example: "7 skills → 70",
|
||||
range: "0 = no skills · 100 = 10+ skills",
|
||||
},
|
||||
{
|
||||
key: "costEfficiency",
|
||||
code: "C",
|
||||
title: "Cost Efficiency",
|
||||
details: "Inverse LCR versus the organizational maximum. Cheapest resource scores 100, most expensive scores 0.",
|
||||
formula: "C = round((1 − LCR / max_LCR) × 100)",
|
||||
example: "60€ vs 120€ → 50",
|
||||
range: "0 = highest LCR · 100 = lowest LCR",
|
||||
warning: "If all resources share the same LCR, everyone scores 0 on this dimension.",
|
||||
},
|
||||
{
|
||||
key: "chargeability",
|
||||
code: "A",
|
||||
title: "Chargeability",
|
||||
details: "Distance from the personal chargeability target over the last 90 days. On target scores 100.",
|
||||
formula: "A = max(0, 100 − |target% − actual%| × 2)",
|
||||
example: "10 pp off → 80",
|
||||
range: "0 = 50+ pp off target · 100 = exactly on target",
|
||||
},
|
||||
{
|
||||
key: "experience",
|
||||
code: "E",
|
||||
title: "Experience",
|
||||
details: "Average years of experience across skills with explicit years data, capped at 10 years.",
|
||||
formula: "E = min(100, avg_years × 10)",
|
||||
example: "6.5 yrs → 65 · 10+ yrs → 100",
|
||||
range: "0 = no years data · 100 = 10+ years average",
|
||||
},
|
||||
];
|
||||
|
||||
type ValueScorePanelProps = {
|
||||
scoreWeights: ScoreWeights;
|
||||
scoreVisibleRoles: SystemRole[];
|
||||
weightSum: number;
|
||||
weightSumOk: boolean;
|
||||
scoreSaved: boolean;
|
||||
recomputeResult: { updated: number } | null;
|
||||
isSaving: boolean;
|
||||
isRecomputing: boolean;
|
||||
onUpdateWeight: (key: keyof ScoreWeights, value: number) => void;
|
||||
onToggleRole: (role: SystemRole) => void;
|
||||
onRecompute: () => void;
|
||||
onSave: () => void;
|
||||
};
|
||||
|
||||
export function ValueScorePanel({
|
||||
scoreWeights,
|
||||
scoreVisibleRoles,
|
||||
weightSum,
|
||||
weightSumOk,
|
||||
scoreSaved,
|
||||
recomputeResult,
|
||||
isSaving,
|
||||
isRecomputing,
|
||||
onUpdateWeight,
|
||||
onToggleRole,
|
||||
onRecompute,
|
||||
onSave,
|
||||
}: ValueScorePanelProps) {
|
||||
return (
|
||||
<div className={PANEL_STRONG_CLASS}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="mb-1 text-sm font-semibold uppercase tracking-wider text-gray-800 dark:text-gray-200">
|
||||
Value Score
|
||||
</h2>
|
||||
<p className="text-xs leading-relaxed text-gray-500 dark:text-gray-400">
|
||||
A persistent 0–100 price/quality metric per resource across five weighted dimensions.
|
||||
Recompute on demand after changing weights or importing new skill matrices.
|
||||
</p>
|
||||
</div>
|
||||
<div className="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>
|
||||
|
||||
<div>
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Dimension Weights — must sum to 100%
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||
{WEIGHT_DEFINITIONS.map((definition) => {
|
||||
const value = Math.round(scoreWeights[definition.key] * 100);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={definition.key}
|
||||
className="rounded-2xl border border-gray-200 px-4 py-3 dark:border-gray-600"
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="rounded bg-brand-50 px-1.5 py-0.5 font-mono text-[10px] font-bold text-brand-700 dark:bg-brand-900/30 dark:text-brand-300">
|
||||
{definition.code}
|
||||
</span>
|
||||
<span className="flex-1 text-sm font-semibold text-gray-800 dark:text-gray-100">
|
||||
{definition.title}
|
||||
</span>
|
||||
<span className="w-10 text-right font-mono text-sm font-bold tabular-nums text-brand-600 dark:text-brand-400">
|
||||
{value}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={value}
|
||||
onChange={(event) => onUpdateWeight(definition.key, Number(event.target.value) / 100)}
|
||||
className="w-full accent-brand-600"
|
||||
/>
|
||||
<details className="mt-1.5">
|
||||
<summary className="cursor-pointer select-none text-[11px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
Show details
|
||||
</summary>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<p className="text-xs leading-relaxed text-gray-600 dark:text-gray-400">
|
||||
{definition.details}
|
||||
</p>
|
||||
<div className="rounded bg-gray-50 px-2 py-1.5 font-mono text-[11px] text-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
{definition.formula}
|
||||
<span className="ml-2 text-gray-400">{definition.example}</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-400 dark:text-gray-500">
|
||||
{definition.range}
|
||||
</div>
|
||||
{definition.warning ? (
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-500">
|
||||
{definition.warning}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-2xl border px-4 py-2.5 text-sm font-medium ${
|
||||
weightSumOk
|
||||
? "border-green-200 bg-green-50 text-green-700 dark:border-green-700 dark:bg-green-900/20 dark:text-green-300"
|
||||
: "border-red-200 bg-red-50 text-red-700 dark:border-red-700 dark:bg-red-900/20 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>
|
||||
|
||||
<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="mt-2 flex flex-wrap gap-3">
|
||||
{ALL_ROLES.map((role) => (
|
||||
<label key={role} className={`${CHECKBOX_ROW_CLASS} cursor-pointer select-none`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scoreVisibleRoles.includes(role)}
|
||||
onChange={() => onToggleRole(role)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
{role}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 border-t border-gray-100 pt-5 dark:border-gray-700">
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">
|
||||
Recompute Scores
|
||||
</p>
|
||||
<p className="mb-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
Scores are not updated automatically. Run this after changing weights or after importing
|
||||
new skill matrices.
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRecompute}
|
||||
disabled={isRecomputing}
|
||||
className={SECONDARY_BUTTON_CLASS}
|
||||
>
|
||||
{isRecomputing ? "Recomputing…" : "Recompute All Scores"}
|
||||
</button>
|
||||
{recomputeResult ? (
|
||||
<span className="text-sm font-medium text-green-600 dark:text-green-400">
|
||||
Updated {recomputeResult.updated} resource
|
||||
{recomputeResult.updated !== 1 ? "s" : ""}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={isSaving || !weightSumOk}
|
||||
className={PRIMARY_BUTTON_CLASS}
|
||||
>
|
||||
{isSaving ? "Saving…" : "Save Score Settings"}
|
||||
</button>
|
||||
{scoreSaved ? (
|
||||
<span className="text-sm font-medium text-green-600 dark:text-green-400">Saved!</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const INPUT_CLASS = "app-input";
|
||||
export const LABEL_CLASS = "app-label";
|
||||
export const PANEL_CLASS = "app-surface p-6 space-y-5";
|
||||
export const PANEL_STRONG_CLASS = "app-surface-strong p-6 space-y-6";
|
||||
export 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";
|
||||
export 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";
|
||||
export 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";
|
||||
|
||||
export type Provider = "openai" | "azure";
|
||||
export type ImageProvider = "dalle" | "gemini";
|
||||
export type RuntimeSecretSource = "environment" | "database" | "none";
|
||||
export type UrlParsedType = "completions" | "responses" | null;
|
||||
|
||||
export type RuntimeSecretStatus = {
|
||||
configured: boolean;
|
||||
activeSource: RuntimeSecretSource;
|
||||
hasStoredValue: boolean;
|
||||
envVarNames: string[];
|
||||
};
|
||||
|
||||
export type RuntimeSecrets = {
|
||||
azureOpenAiApiKey: RuntimeSecretStatus;
|
||||
azureDalleApiKey: RuntimeSecretStatus;
|
||||
geminiApiKey: RuntimeSecretStatus;
|
||||
smtpPassword: RuntimeSecretStatus;
|
||||
anonymizationSeed: RuntimeSecretStatus;
|
||||
};
|
||||
|
||||
export type ParsedAzureUrl = {
|
||||
endpoint: string;
|
||||
apiVersion: string;
|
||||
deployment: string | null;
|
||||
urlType: "completions" | "responses";
|
||||
};
|
||||
|
||||
export type SaveResult = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type GeminiTestResult = {
|
||||
ok: boolean;
|
||||
model?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const ALL_ROLES = ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] as const;
|
||||
export type SystemRole = (typeof ALL_ROLES)[number];
|
||||
|
||||
export interface ScoreWeights {
|
||||
skillDepth: number;
|
||||
skillBreadth: number;
|
||||
costEfficiency: number;
|
||||
chargeability: number;
|
||||
experience: number;
|
||||
}
|
||||
|
||||
export 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";
|
||||
const completionsMatch = url.pathname.match(/\/openai\/deployments\/([^/]+)\//);
|
||||
|
||||
if (completionsMatch) {
|
||||
return { endpoint, apiVersion, deployment: completionsMatch[1]!, urlType: "completions" };
|
||||
}
|
||||
|
||||
if (url.pathname.includes("/openai/responses")) {
|
||||
return { endpoint, apiVersion, deployment: null, urlType: "responses" };
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSecretStatusTone(source: RuntimeSecretSource): string {
|
||||
if (source === "environment") {
|
||||
return "border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950/30 dark:text-green-300";
|
||||
}
|
||||
if (source === "database") {
|
||||
return "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300";
|
||||
}
|
||||
return "border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-700 dark:bg-gray-900/60 dark:text-gray-300";
|
||||
}
|
||||
|
||||
function getSecretStatusLabel(source: RuntimeSecretSource): string {
|
||||
if (source === "environment") return "Environment";
|
||||
if (source === "database") return "Legacy DB";
|
||||
return "Missing";
|
||||
}
|
||||
|
||||
export function RuntimeSecretCard({
|
||||
title,
|
||||
description,
|
||||
secret,
|
||||
optionalNote,
|
||||
}: {
|
||||
title: string;
|
||||
description: ReactNode;
|
||||
secret: RuntimeSecretStatus;
|
||||
optionalNote?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-700 dark:bg-gray-900/50">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">{title}</h3>
|
||||
<p className="mt-1 text-xs leading-relaxed text-gray-500 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.12em] ${getSecretStatusTone(secret.activeSource)}`}
|
||||
>
|
||||
{getSecretStatusLabel(secret.activeSource)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<p>
|
||||
Runtime status:{" "}
|
||||
<span className="font-medium">
|
||||
{secret.configured ? "configured" : "not configured"}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
Provision via{" "}
|
||||
{secret.envVarNames.map((name) => (
|
||||
<code key={name} className="mr-1 font-mono">
|
||||
{name}
|
||||
</code>
|
||||
))}
|
||||
</p>
|
||||
{optionalNote ? <p>{optionalNote}</p> : null}
|
||||
{secret.activeSource === "environment" && secret.hasStoredValue ? (
|
||||
<p className="text-amber-700 dark:text-amber-400">
|
||||
An older database value still exists, but the environment value currently overrides it.
|
||||
</p>
|
||||
) : null}
|
||||
{secret.activeSource === "database" ? (
|
||||
<p className="text-amber-700 dark:text-amber-400">
|
||||
Runtime currently still depends on a legacy database secret. Migrate it to deployment
|
||||
secrets and clear the stored value afterwards.
|
||||
</p>
|
||||
) : null}
|
||||
{secret.activeSource === "none" ? (
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
No runtime secret is available yet. The related integration will stay disabled or fail
|
||||
connectivity checks until the deployment secret is set.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user