feat: project cover art with AI generation, branding rename, RBAC fix, computation graph

- Add DALL-E cover art generation for projects (Azure OpenAI + standard OpenAI)
- CoverArtSection component with generate/upload/remove/focus-point controls
- Client-side image compression (10MB input → WebP/JPEG, max 1920px)
- DALL-E settings in admin panel (deployment, endpoint, API key)
- MCP assistant tools for cover art (generate_project_cover, remove_project_cover)
- Rename "Planarchy" → "plANARCHY" across all UI-facing text (13 files)
- Fix hardcoded canEdit={true} on project detail page — now checks user role
- Computation graph visualization (2D/3D) for calculation rules
- OG image and OpenGraph metadata

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-18 11:31:56 +01:00
parent 21af720f90
commit 093e13b88f
86 changed files with 5623 additions and 744 deletions
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { trpc } from "~/lib/trpc/client.js";
const TRIGGER_TYPES = ["SICK", "VACATION", "PUBLIC_HOLIDAY", "CUSTOM"] as const;
@@ -164,13 +165,27 @@ export function CalculationRulesClient() {
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Name</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Trigger</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Cost Effect</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Chargeability</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Scope</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Priority</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Active</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">
<span className="flex items-center">Name <InfoTooltip content="A descriptive label for this rule. Use clear names so admins can quickly identify what each rule does." /></span>
</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">
<span className="flex items-center">Trigger <InfoTooltip content="The absence type that activates this rule: Sick Leave, Vacation, Public Holiday, or Custom. Determines when the cost/chargeability logic applies." /></span>
</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">
<span className="flex items-center">Cost Effect <InfoTooltip content="How this absence affects project costs. 'Charge to Project' bills the project, 'No Project Cost' absorbs the cost centrally, 'Reduced Cost' applies a percentage discount." /></span>
</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">
<span className="flex items-center">Chargeability <InfoTooltip content="Whether the person's time counts as chargeable during this absence. 'Person Chargeable' includes the time in chargeability metrics; 'Not Chargeable' excludes it." /></span>
</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">
<span className="flex items-center">Scope <InfoTooltip content="Limits the rule to a specific project or order type (BD, Chargeable, Internal, Overhead). 'Global' means the rule applies to all projects." /></span>
</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">
<span className="flex items-center">Priority <InfoTooltip content="When multiple rules match the same absence, the one with the highest priority number wins. Use this to create specific overrides for certain projects." /></span>
</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">
<span className="flex items-center">Active <InfoTooltip content="Only active rules are evaluated. Deactivate a rule to temporarily disable it without deleting." /></span>
</th>
<th className="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500">Actions</th>
</tr>
</thead>
@@ -233,7 +248,7 @@ export function CalculationRulesClient() {
</h2>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
<label className="mb-1 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300">Name <InfoTooltip content="A unique, descriptive name for this calculation rule." /></label>
<input
value={editing.name}
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
@@ -241,7 +256,7 @@ export function CalculationRulesClient() {
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
<label className="mb-1 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300">Description <InfoTooltip content="Optional notes explaining when and why this rule exists." /></label>
<textarea
value={editing.description}
onChange={(e) => setEditing({ ...editing, description: e.target.value })}
@@ -251,7 +266,7 @@ export function CalculationRulesClient() {
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Trigger Type</label>
<label className="mb-1 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300">Trigger Type <InfoTooltip content="The type of absence event that activates this rule." /></label>
<select
value={editing.triggerType}
onChange={(e) => setEditing({ ...editing, triggerType: e.target.value })}
@@ -261,7 +276,7 @@ export function CalculationRulesClient() {
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Order Type (optional)</label>
<label className="mb-1 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300">Order Type (optional) <InfoTooltip content="Restricts this rule to a specific order type. Leave as 'All' to apply globally." /></label>
<select
value={editing.orderType}
onChange={(e) => setEditing({ ...editing, orderType: e.target.value })}
@@ -274,7 +289,7 @@ export function CalculationRulesClient() {
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Cost Effect</label>
<label className="mb-1 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300">Cost Effect <InfoTooltip content="Determines how costs are attributed during this absence. 'Charge' bills the project, 'Zero' removes cost, 'Reduce' applies a percentage reduction." /></label>
<select
value={editing.costEffect}
onChange={(e) => setEditing({ ...editing, costEffect: e.target.value })}
@@ -284,7 +299,7 @@ export function CalculationRulesClient() {
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Chargeability</label>
<label className="mb-1 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300">Chargeability <InfoTooltip content="Controls whether this absence counts toward the person's chargeability KPI." /></label>
<select
value={editing.chargeabilityEffect}
onChange={(e) => setEditing({ ...editing, chargeabilityEffect: e.target.value })}
@@ -296,8 +311,8 @@ export function CalculationRulesClient() {
</div>
{editing.costEffect === "REDUCE" && (
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Reduction Percent (0-100)
<label className="mb-1 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300">
Reduction Percent (0-100) <InfoTooltip content="The percentage by which the cost is reduced. E.g. 50 means the project is charged half the normal rate." />
</label>
<input
type="number"
@@ -311,7 +326,7 @@ export function CalculationRulesClient() {
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Priority</label>
<label className="mb-1 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300">Priority <InfoTooltip content="Higher numbers take precedence when multiple rules match. Use 0 for default rules and higher values for specific overrides." /></label>
<input
type="number"
min={0}
@@ -328,7 +343,7 @@ export function CalculationRulesClient() {
onChange={(e) => setEditing({ ...editing, isActive: e.target.checked })}
className="rounded border-gray-300"
/>
Active
Active <InfoTooltip content="Inactive rules are ignored during cost calculations." />
</label>
</div>
</div>
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { trpc } from "~/lib/trpc/client.js";
type ClientRow = {
@@ -166,7 +167,7 @@ export function ClientsAdminClient() {
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Clients</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Client hierarchy for project assignment and chargeability reporting
Client hierarchy for project assignment and chargeability reporting <InfoTooltip content="Clients are companies or brands that commission projects. The hierarchy supports parent/child relationships (e.g. BMW Group > BMW > MINI). Projects are assigned to clients for revenue tracking and chargeability reporting." />
</p>
</div>
<button
@@ -220,7 +221,7 @@ export function ClientsAdminClient() {
<div className="px-6 py-5 space-y-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name <InfoTooltip content="The full name of the client. Shown in project assignment dropdowns and reports." /></label>
<input
type="text"
value={editing.name}
@@ -232,7 +233,7 @@ export function ClientsAdminClient() {
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Code</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Code <InfoTooltip content="A short abbreviation for this client (e.g. BMW). Used in compact views and rate card assignments." /></label>
<input
type="text"
value={editing.code}
@@ -242,7 +243,7 @@ export function ClientsAdminClient() {
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Sort Order</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Sort Order <InfoTooltip content="Controls the display order. Lower numbers appear first." /></label>
<input
type="number"
value={editing.sortOrder}
@@ -253,7 +254,7 @@ export function ClientsAdminClient() {
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Parent Client</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Parent Client <InfoTooltip content="Set a parent to create a hierarchy (e.g. MINI under BMW Group). Child clients inherit the parent's reporting context." /></label>
<select
value={editing.parentId}
onChange={(e) => setEditing({ ...editing, parentId: e.target.value })}
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { trpc } from "~/lib/trpc/client.js";
type CountryRow = {
@@ -175,11 +176,11 @@ export function CountriesClient() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Code</th>
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Name</th>
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Daily Hours</th>
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Schedule</th>
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Cities</th>
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider"><span className="flex items-center">Code <InfoTooltip content="ISO country code (e.g. DE, ES, IN). Used to identify the country in exports and API calls." /></span></th>
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider"><span className="flex items-center">Name <InfoTooltip content="The full country name. Shown in dropdowns and resource location fields." /></span></th>
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider"><span className="flex items-center justify-center">Daily Hours <InfoTooltip content="Standard working hours per day for this country. Used in capacity calculations to convert between days and hours." /></span></th>
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider"><span className="flex items-center justify-center">Schedule <InfoTooltip content="Special schedule rules (e.g. Spain has reduced Friday hours and summer hours). 'Standard' uses the fixed daily hours value." /></span></th>
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider"><span className="flex items-center justify-center">Cities <InfoTooltip content="Metro cities within this country. Used for location-specific rate cards and resource assignment." /></span></th>
<th className="text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Actions</th>
</tr>
</thead>
@@ -286,7 +287,7 @@ export function CountriesClient() {
<div className="overflow-y-auto flex-1 px-6 py-5 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Code</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Code <InfoTooltip content="2-3 letter ISO country code. Auto-uppercased." /></label>
<input
type="text"
value={editing.code}
@@ -297,7 +298,7 @@ export function CountriesClient() {
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Daily Hours</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Daily Hours <InfoTooltip content="Standard working hours per day. Used to convert between hours and days in capacity calculations." /></label>
<input
type="number"
value={editing.dailyWorkingHours}
@@ -311,7 +312,7 @@ export function CountriesClient() {
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name <InfoTooltip content="Full country name shown in the UI." /></label>
<input
type="text"
value={editing.name}
@@ -330,14 +331,14 @@ export function CountriesClient() {
onChange={(e) => setEditing({ ...editing, hasSpainRules: e.target.checked })}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Variable schedule (Spain-type)
Variable schedule (Spain-type) <InfoTooltip content="Enable for countries with variable working hours (e.g. reduced Friday/summer hours). Overrides the fixed daily hours with day-specific rules." />
</label>
{editing.hasSpainRules && (
<div className="mt-3 space-y-3 pl-6 border-l-2 border-amber-300 dark:border-amber-700">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Friday Hours</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Friday Hours <InfoTooltip content="Working hours on Fridays. Typically shorter than regular days." /></label>
<input
type="number"
value={editing.fridayHours}
@@ -347,7 +348,7 @@ export function CountriesClient() {
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Regular Hours (Mon-Thu)</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Regular Hours (Mon-Thu) <InfoTooltip content="Working hours Monday through Thursday outside the summer period." /></label>
<input
type="number"
value={editing.regularHours}
@@ -359,7 +360,7 @@ export function CountriesClient() {
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Summer From</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Summer From <InfoTooltip content="Start of the summer period (MM-DD format). During summer, reduced hours apply." /></label>
<input
type="text"
value={editing.summerFrom}
@@ -369,7 +370,7 @@ export function CountriesClient() {
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Summer To</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Summer To <InfoTooltip content="End of the summer period (MM-DD format)." /></label>
<input
type="text"
value={editing.summerTo}
@@ -379,7 +380,7 @@ export function CountriesClient() {
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Summer Hours</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Summer Hours <InfoTooltip content="Reduced daily working hours during the summer period." /></label>
<input
type="number"
value={editing.summerHours}
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { trpc } from "~/lib/trpc/client.js";
type EffortUnitMode = "per_frame" | "per_item" | "flat";
@@ -184,7 +185,7 @@ export function EffortRulesClient() {
<div className="mb-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-medium text-gray-500 uppercase">Name</label>
<label className="mb-1 flex items-center text-xs font-medium text-gray-500 uppercase">Name <InfoTooltip content="A descriptive name for this rule set, e.g. 'CGI Standard Rules'. Used to identify the set when linking it to estimates." /></label>
<input
value={editing.name}
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
@@ -193,7 +194,7 @@ export function EffortRulesClient() {
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-500 uppercase">Description</label>
<label className="mb-1 flex items-center text-xs font-medium text-gray-500 uppercase">Description <InfoTooltip content="Optional notes about when this rule set should be used." /></label>
<input
value={editing.description}
onChange={(e) => setEditing({ ...editing, description: e.target.value })}
@@ -210,7 +211,7 @@ export function EffortRulesClient() {
onChange={(e) => setEditing({ ...editing, isDefault: e.target.checked })}
className="rounded border-gray-300"
/>
Default rule set (auto-selected for new estimates)
Default rule set (auto-selected for new estimates) <InfoTooltip content="When checked, this set is pre-selected when creating new estimates. Only one set can be default." />
</label>
{/* Rules table */}
@@ -234,11 +235,11 @@ export function EffortRulesClient() {
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-2 font-medium">Scope type</th>
<th className="px-2 py-2 font-medium">Discipline</th>
<th className="px-2 py-2 font-medium">Chapter</th>
<th className="px-2 py-2 font-medium">Unit mode</th>
<th className="px-2 py-2 text-right font-medium">Hours/unit</th>
<th className="py-2 pr-2 font-medium"><span className="flex items-center">Scope type <InfoTooltip content="The type of deliverable this rule applies to: Shot, Asset, Environment, Sequence, or Other." /></span></th>
<th className="px-2 py-2 font-medium"><span className="flex items-center">Discipline <InfoTooltip content="The production discipline (e.g. 3D Animation, Compositing) that this rule generates demand for." /></span></th>
<th className="px-2 py-2 font-medium"><span className="flex items-center">Chapter <InfoTooltip content="Optional grouping within a discipline. Used to organize demand lines in the estimate staffing tab." /></span></th>
<th className="px-2 py-2 font-medium"><span className="flex items-center">Unit mode <InfoTooltip content="How hours are calculated: 'Per frame' multiplies by frame count, 'Per item' by item count, 'Flat' is a fixed amount." /></span></th>
<th className="px-2 py-2 text-right font-medium"><span className="flex items-center justify-end">Hours/unit <InfoTooltip content="The number of hours per unit. Combined with the unit mode and scope item count, this pre-fills the total effort in estimates." /></span></th>
<th className="pl-2 py-2 font-medium w-10"></th>
</tr>
</thead>
@@ -392,11 +393,11 @@ export function EffortRulesClient() {
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-3 font-medium">Scope type</th>
<th className="px-3 py-2 font-medium">Discipline</th>
<th className="px-3 py-2 font-medium">Chapter</th>
<th className="px-3 py-2 font-medium">Unit mode</th>
<th className="pl-3 py-2 text-right font-medium">Hours/unit</th>
<th className="py-2 pr-3 font-medium"><span className="flex items-center">Scope type <InfoTooltip content="The deliverable type (Shot, Asset, etc.) this rule targets." /></span></th>
<th className="px-3 py-2 font-medium"><span className="flex items-center">Discipline <InfoTooltip content="The production discipline this demand line is for." /></span></th>
<th className="px-3 py-2 font-medium"><span className="flex items-center">Chapter <InfoTooltip content="Optional sub-grouping within the discipline." /></span></th>
<th className="px-3 py-2 font-medium"><span className="flex items-center">Unit mode <InfoTooltip content="Per frame, per item, or flat hours calculation mode." /></span></th>
<th className="pl-3 py-2 text-right font-medium"><span className="flex items-center justify-end">Hours/unit <InfoTooltip content="Hours multiplied by the scope item count to compute total effort." /></span></th>
</tr>
</thead>
<tbody>
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { trpc } from "~/lib/trpc/client.js";
type EditingRule = {
@@ -198,7 +199,7 @@ export function ExperienceMultipliersClient() {
<div className="mb-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-medium text-gray-500 uppercase">Name</label>
<label className="mb-1 flex items-center text-xs font-medium text-gray-500 uppercase">Name <InfoTooltip content="A descriptive name for this multiplier set. Used to identify it when applying multipliers to estimates." /></label>
<input
value={editing.name}
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
@@ -207,7 +208,7 @@ export function ExperienceMultipliersClient() {
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-500 uppercase">Description</label>
<label className="mb-1 flex items-center text-xs font-medium text-gray-500 uppercase">Description <InfoTooltip content="Optional explanation of when this multiplier set should be used." /></label>
<input
value={editing.description}
onChange={(e) => setEditing({ ...editing, description: e.target.value })}
@@ -224,7 +225,7 @@ export function ExperienceMultipliersClient() {
onChange={(e) => setEditing({ ...editing, isDefault: e.target.checked })}
className="rounded border-gray-300"
/>
Default set (auto-selected when applying multipliers)
Default set (auto-selected when applying multipliers) <InfoTooltip content="When checked, this set is automatically selected when applying experience multipliers. Only one set can be default." />
</label>
{/* Rules table */}
@@ -248,13 +249,13 @@ export function ExperienceMultipliersClient() {
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-2 font-medium">Chapter</th>
<th className="px-2 py-2 font-medium">Location</th>
<th className="px-2 py-2 font-medium">Level</th>
<th className="px-2 py-2 text-right font-medium">Cost mult.</th>
<th className="px-2 py-2 text-right font-medium">Bill mult.</th>
<th className="px-2 py-2 text-right font-medium">Shoring %</th>
<th className="px-2 py-2 text-right font-medium">Add. effort %</th>
<th className="py-2 pr-2 font-medium"><span className="flex items-center">Chapter <InfoTooltip content="The discipline/chapter this multiplier applies to. Leave blank to match all chapters." /></span></th>
<th className="px-2 py-2 font-medium"><span className="flex items-center">Location <InfoTooltip content="The country/location this multiplier targets. Used for nearshoring/offshoring cost adjustments." /></span></th>
<th className="px-2 py-2 font-medium"><span className="flex items-center">Level <InfoTooltip content="The seniority level (Junior, Mid, Senior, etc.). Juniors typically need a higher effort multiplier." /></span></th>
<th className="px-2 py-2 text-right font-medium"><span className="flex items-center justify-end">Cost mult. <InfoTooltip content="Multiplier applied to cost rates. E.g. 0.5 means 50% of the base cost rate (cheaper location)." /></span></th>
<th className="px-2 py-2 text-right font-medium"><span className="flex items-center justify-end">Bill mult. <InfoTooltip content="Multiplier applied to billing rates. E.g. 0.8 means the client is billed at 80% of the standard rate." /></span></th>
<th className="px-2 py-2 text-right font-medium"><span className="flex items-center justify-end">Shoring % <InfoTooltip content="Ratio of work done at the remote location (0-1). E.g. 0.7 means 70% remote, 30% local." /></span></th>
<th className="px-2 py-2 text-right font-medium"><span className="flex items-center justify-end">Add. effort % <InfoTooltip content="Additional effort overhead for coordination, e.g. 0.15 adds 15% extra hours for communication overhead." /></span></th>
<th className="pl-2 py-2 font-medium w-10"></th>
</tr>
</thead>
@@ -439,13 +440,13 @@ export function ExperienceMultipliersClient() {
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-3 font-medium">Chapter</th>
<th className="px-3 py-2 font-medium">Location</th>
<th className="px-3 py-2 font-medium">Level</th>
<th className="px-3 py-2 text-right font-medium">Cost mult.</th>
<th className="px-3 py-2 text-right font-medium">Bill mult.</th>
<th className="px-3 py-2 text-right font-medium">Shoring</th>
<th className="pl-3 py-2 text-right font-medium">Add. effort</th>
<th className="py-2 pr-3 font-medium"><span className="flex items-center">Chapter <InfoTooltip content="Discipline this multiplier applies to." /></span></th>
<th className="px-3 py-2 font-medium"><span className="flex items-center">Location <InfoTooltip content="Target country/region." /></span></th>
<th className="px-3 py-2 font-medium"><span className="flex items-center">Level <InfoTooltip content="Seniority level filter." /></span></th>
<th className="px-3 py-2 text-right font-medium"><span className="flex items-center justify-end">Cost mult. <InfoTooltip content="Factor applied to cost rates." /></span></th>
<th className="px-3 py-2 text-right font-medium"><span className="flex items-center justify-end">Bill mult. <InfoTooltip content="Factor applied to billing rates." /></span></th>
<th className="px-3 py-2 text-right font-medium"><span className="flex items-center justify-end">Shoring <InfoTooltip content="Share of work done remotely (0-100%)." /></span></th>
<th className="pl-3 py-2 text-right font-medium"><span className="flex items-center justify-end">Add. effort <InfoTooltip content="Extra effort overhead percentage." /></span></th>
</tr>
</thead>
<tbody>
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { trpc } from "~/lib/trpc/client.js";
type LevelRow = { id: string; name: string; groupId: string };
@@ -115,7 +116,7 @@ export function ManagementLevelsClient() {
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Management Levels</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Level groups with chargeability targets and individual levels
Level groups with chargeability targets and individual levels <InfoTooltip content="Management levels define seniority groups (e.g. Senior Management, Team Lead). Each group has a chargeability target that appears in chargeability reports. Individual levels within a group are assigned to resources." />
</p>
</div>
<button
@@ -146,6 +147,7 @@ export function ManagementLevelsClient() {
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400">
Target: {Math.round(group.targetPercentage * 100)}%
</span>
<InfoTooltip content="The chargeability target for this group. Resources in this group are expected to achieve this percentage of chargeable hours. Used in chargeability reports and dashboards." />
</div>
<div className="flex gap-2">
<button
@@ -217,7 +219,7 @@ export function ManagementLevelsClient() {
<div className="px-6 py-5 space-y-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name <InfoTooltip content="The name of this management level group (e.g. Senior Management, Team Leads)." /></label>
<input
type="text"
value={editingGroup.name}
@@ -229,7 +231,7 @@ export function ManagementLevelsClient() {
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Target % (0-100)</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Target % (0-100) <InfoTooltip content="The chargeability target for resources in this group. Enter as a percentage (e.g. 70 for 70%). Used in chargeability reports." /></label>
<input
type="number"
value={Math.round(editingGroup.targetPercentage * 100)}
@@ -240,7 +242,7 @@ export function ManagementLevelsClient() {
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Sort Order</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Sort Order <InfoTooltip content="Controls the display order of groups. Lower numbers appear first." /></label>
<input
type="number"
value={editingGroup.sortOrder}
@@ -279,7 +281,7 @@ export function ManagementLevelsClient() {
<div className="px-6 py-5 space-y-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Level Name</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Level Name <InfoTooltip content="The specific title within the group (e.g. Managing Director, VP). This is assigned to individual resources." /></label>
<input
type="text"
value={editingLevel.name}
@@ -290,7 +292,7 @@ export function ManagementLevelsClient() {
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Group</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Group <InfoTooltip content="The management level group this level belongs to. Determines chargeability target and reporting." /></label>
<select
value={editingLevel.groupId}
onChange={(e) => setEditingLevel({ ...editingLevel, groupId: e.target.value })}
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { trpc } from "~/lib/trpc/client.js";
type OrgUnitRow = {
@@ -166,7 +167,7 @@ export function OrgUnitsClient() {
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Org Units</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
3-level hierarchy: L5 (Division) L6 (Department) L7 (Team)
3-level hierarchy: L5 (Division) L6 (Department) L7 (Team) <InfoTooltip content="Org units define the organizational structure. Resources are assigned to L7 teams, which roll up to L6 departments and L5 divisions. Used for capacity planning, reporting, and access control." />
</p>
</div>
<div className="flex gap-2">
@@ -206,7 +207,7 @@ export function OrgUnitsClient() {
<div className="px-6 py-5 space-y-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name <InfoTooltip content="The full name of this organizational unit." /></label>
<input
type="text"
value={editing.name}
@@ -218,7 +219,7 @@ export function OrgUnitsClient() {
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Short Name</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Short Name <InfoTooltip content="An abbreviation for compact views and exports (e.g. 'CP' for Content Production)." /></label>
<input
type="text"
value={editing.shortName}
@@ -228,7 +229,7 @@ export function OrgUnitsClient() {
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Sort Order</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Sort Order <InfoTooltip content="Controls the display order among siblings. Lower numbers appear first." /></label>
<input
type="number"
value={editing.sortOrder}
@@ -240,7 +241,7 @@ export function OrgUnitsClient() {
{editing.level > 5 && (
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Parent Unit</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Parent Unit <InfoTooltip content="The parent org unit in the hierarchy. L6 departments must belong to an L5 division; L7 teams must belong to an L6 department." /></label>
<select
value={editing.parentId}
onChange={(e) => setEditing({ ...editing, parentId: e.target.value })}
@@ -2,6 +2,7 @@
import { useState } from "react";
import { formatCents } from "~/lib/format.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { trpc } from "~/lib/trpc/client.js";
// ─── Local types ────────────────────────────────────────────────────────────
@@ -486,14 +487,14 @@ export function RateCardsClient() {
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-gray-500 dark:text-gray-400 border-b border-gray-100 dark:border-gray-800">
<th className="px-4 py-2 font-medium">Role</th>
<th className="px-4 py-2 font-medium">Chapter</th>
<th className="px-4 py-2 font-medium">Location</th>
<th className="px-4 py-2 font-medium">Seniority</th>
<th className="px-4 py-2 font-medium">Work Type</th>
<th className="px-4 py-2 font-medium text-right">Cost Rate</th>
<th className="px-4 py-2 font-medium text-right">Bill Rate</th>
<th className="px-4 py-2 font-medium text-right">Machine Rate</th>
<th className="px-4 py-2 font-medium"><span className="flex items-center">Role <InfoTooltip content="The job role this rate applies to. Leave empty if the rate is defined by chapter/seniority instead." /></span></th>
<th className="px-4 py-2 font-medium"><span className="flex items-center">Chapter <InfoTooltip content="The production discipline (e.g. Animation, Compositing). Narrows which allocations use this rate." /></span></th>
<th className="px-4 py-2 font-medium"><span className="flex items-center">Location <InfoTooltip content="Geographic location filter. Used when rates vary by region (e.g. Munich vs. Barcelona)." /></span></th>
<th className="px-4 py-2 font-medium"><span className="flex items-center">Seniority <InfoTooltip content="Experience level (Junior, Mid, Senior, etc.). Higher seniority typically has higher rates." /></span></th>
<th className="px-4 py-2 font-medium"><span className="flex items-center">Work Type <InfoTooltip content="Type of work arrangement (e.g. Onsite, Remote). Can affect rate pricing." /></span></th>
<th className="px-4 py-2 font-medium text-right"><span className="flex items-center justify-end">Cost Rate <InfoTooltip content="Internal hourly cost in cents. This is what the company actually pays. Used in budget calculations and profitability analysis." /></span></th>
<th className="px-4 py-2 font-medium text-right"><span className="flex items-center justify-end">Bill Rate <InfoTooltip content="External hourly billing rate in cents. This is what the client is charged. The difference between bill rate and cost rate is the margin." /></span></th>
<th className="px-4 py-2 font-medium text-right"><span className="flex items-center justify-end">Machine Rate <InfoTooltip content="Hourly cost for compute/render resources in cents. Added on top of personnel costs for roles that require heavy rendering." /></span></th>
<th className="px-4 py-2 font-medium w-20"></th>
</tr>
</thead>
@@ -558,7 +559,7 @@ export function RateCardsClient() {
<div className="px-6 py-5 space-y-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name <InfoTooltip content="A descriptive name for this rate card, typically including the year or client name." /></label>
<input
type="text"
value={editingCard.name}
@@ -569,7 +570,7 @@ export function RateCardsClient() {
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Client</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Client <InfoTooltip content="Optionally tie this rate card to a specific client. Client-specific cards override the default rates for that client's projects." /></label>
<select
value={editingCard.clientId}
onChange={(e) => setEditingCard({ ...editingCard, clientId: e.target.value })}
@@ -586,7 +587,7 @@ export function RateCardsClient() {
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Currency</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Currency <InfoTooltip content="3-letter ISO currency code (e.g. EUR, USD). All rates in this card use this currency." /></label>
<input
type="text"
value={editingCard.currency}
@@ -597,7 +598,7 @@ export function RateCardsClient() {
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Source</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Source <InfoTooltip content="Where these rates came from (e.g. Finance dept, Client contract). For documentation only." /></label>
<input
type="text"
value={editingCard.source}
@@ -610,7 +611,7 @@ export function RateCardsClient() {
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Effective From</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Effective From <InfoTooltip content="Start date of this rate card's validity. Allocations before this date will not use these rates." /></label>
<input
type="date"
value={editingCard.effectiveFrom}
@@ -619,7 +620,7 @@ export function RateCardsClient() {
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Effective To</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Effective To <InfoTooltip content="End date of this rate card's validity. Leave empty for open-ended validity." /></label>
<input
type="date"
value={editingCard.effectiveTo}
@@ -658,7 +659,7 @@ export function RateCardsClient() {
<div className="px-6 py-5 space-y-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Role</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Role <InfoTooltip content="The job role this rate line applies to. Rates are matched to allocations by role, chapter, seniority, and location." /></label>
<select
value={editingLine.roleId}
onChange={(e) => setEditingLine({ ...editingLine, roleId: e.target.value })}
@@ -673,7 +674,7 @@ export function RateCardsClient() {
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Chapter</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Chapter <InfoTooltip content="Production discipline this rate applies to." /></label>
<input
type="text"
value={editingLine.chapter}
@@ -683,7 +684,7 @@ export function RateCardsClient() {
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Location</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Location <InfoTooltip content="Geographic location for region-specific rate pricing." /></label>
<input
type="text"
value={editingLine.location}
@@ -696,7 +697,7 @@ export function RateCardsClient() {
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Seniority</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Seniority <InfoTooltip content="Experience level filter for this rate line." /></label>
<input
type="text"
value={editingLine.seniority}
@@ -706,7 +707,7 @@ export function RateCardsClient() {
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Work Type</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Work Type <InfoTooltip content="Work arrangement type (e.g. Onsite, Remote)." /></label>
<input
type="text"
value={editingLine.workType}
@@ -718,7 +719,7 @@ export function RateCardsClient() {
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Service Group</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Service Group <InfoTooltip content="Broad service category (e.g. Post Production, VFX). Used for grouping rates in reports." /></label>
<input
type="text"
value={editingLine.serviceGroup}
@@ -730,7 +731,7 @@ export function RateCardsClient() {
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Cost Rate (cents)</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Cost Rate (cents) <InfoTooltip content="Internal hourly cost in cents. E.g. 7500 = 75.00 EUR/h. This is the company's actual cost and flows into budget calculations." /></label>
<input
type="number"
value={editingLine.costRateCents}
@@ -741,7 +742,7 @@ export function RateCardsClient() {
<span className="text-xs text-gray-400 mt-0.5 block">= {formatCents(editingLine.costRateCents)}</span>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Bill Rate (cents)</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Bill Rate (cents) <InfoTooltip content="Hourly rate charged to the client in cents. The margin is bill rate minus cost rate." /></label>
<input
type="number"
value={editingLine.billRateCents}
@@ -752,7 +753,7 @@ export function RateCardsClient() {
<span className="text-xs text-gray-400 mt-0.5 block">= {formatCents(editingLine.billRateCents)}</span>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Machine Rate (cents)</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Machine Rate (cents) <InfoTooltip content="Hourly compute/render cost in cents. Added on top of personnel costs for render-heavy roles." /></label>
<input
type="number"
value={editingLine.machineRateCents}
@@ -1,6 +1,7 @@
"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";
@@ -90,6 +91,11 @@ export function SystemSettingsClient() {
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("");
// SMTP settings
const [smtpHost, setSmtpHost] = useState("");
const [smtpPort, setSmtpPort] = useState(587);
@@ -131,6 +137,9 @@ export function SystemSettingsClient() {
if (settings.scoreVisibleRoles) {
setScoreVisibleRoles(settings.scoreVisibleRoles as SystemRole[]);
}
// DALL-E
setDalleDeployment(settings.azureDalleDeployment ?? "");
setDalleEndpoint(settings.azureDalleEndpoint ?? "");
// SMTP
setSmtpHost(settings.smtpHost ?? "");
setSmtpPort(settings.smtpPort ?? 587);
@@ -269,6 +278,9 @@ export function SystemSettingsClient() {
aiTemperature: temperature,
aiSummaryPrompt: summaryPrompt || undefined,
...(apiKey ? { azureOpenAiApiKey: apiKey } : {}),
azureDalleDeployment: dalleDeployment,
azureDalleEndpoint: provider === "azure" && dalleEndpoint ? dalleEndpoint : undefined,
...(dalleApiKey ? { azureDalleApiKey: dalleApiKey } : {}),
});
}
@@ -293,8 +305,8 @@ export function SystemSettingsClient() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
<div className={PANEL_CLASS}>
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-700 dark:text-gray-200">
AI Provider
<h2 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 */}
@@ -522,8 +534,8 @@ export function SystemSettingsClient() {
{/* Generation settings */}
<div className={PANEL_CLASS}>
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-700 dark:text-gray-200">
Generation Settings
<h2 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 */}
@@ -989,11 +1001,76 @@ export function SystemSettingsClient() {
</div>
</div>
{/* ── DALL-E Image Generation ────────────────────────────────── */}
<div className={PANEL_CLASS}>
<div>
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 flex items-center">
DALL-E Image Generation <InfoTooltip content="Configure the DALL-E model used for generating project cover art. Uses the same provider (OpenAI / Azure) as the chat model above." />
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Used to generate AI cover art for projects. Leave blank to disable AI cover generation.
</p>
</div>
<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>
</div>
{/* ── SMTP / Email ──────────────────────────────────────────── */}
<div className={PANEL_CLASS}>
<div>
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">
Email Notifications (SMTP)
<h2 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.
@@ -1002,7 +1079,7 @@ export function SystemSettingsClient() {
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className={LABEL_CLASS}>SMTP Host</label>
<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}
@@ -1012,7 +1089,7 @@ export function SystemSettingsClient() {
/>
</div>
<div>
<label className={LABEL_CLASS}>SMTP Port</label>
<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}
@@ -1023,7 +1100,7 @@ export function SystemSettingsClient() {
/>
</div>
<div>
<label className={LABEL_CLASS}>SMTP Username</label>
<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}
@@ -1034,8 +1111,8 @@ export function SystemSettingsClient() {
/>
</div>
<div>
<label className={LABEL_CLASS}>
SMTP Password{" "}
<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)
@@ -1052,7 +1129,7 @@ export function SystemSettingsClient() {
/>
</div>
<div>
<label className={LABEL_CLASS}>From Address</label>
<label className={LABEL_CLASS}><span className="flex items-center">From Address <InfoTooltip content="The sender email address shown in notification emails (e.g. noreply@planarchy.app)." /></span></label>
<input
type="email"
className={INPUT_CLASS}
@@ -1111,8 +1188,8 @@ export function SystemSettingsClient() {
{/* ── Vacation Defaults ─────────────────────────────────────── */}
<div className={PANEL_CLASS}>
<div>
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">
Vacation Defaults
<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.
@@ -1120,7 +1197,7 @@ export function SystemSettingsClient() {
</div>
<div className="max-w-xs">
<label className={LABEL_CLASS}>Default Annual Leave Days</label>
<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}
@@ -1151,8 +1228,8 @@ export function SystemSettingsClient() {
<div className={PANEL_CLASS}>
<div>
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">
Viewer Anonymization
<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
+19 -18
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import { SystemRole, PermissionKey, type PermissionOverrides } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { FilterChips } from "~/components/ui/FilterChips.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { useTableSort } from "~/hooks/useTableSort.js";
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
@@ -362,8 +363,8 @@ export function UsersClient() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<SortableColumnHeader label="Name" field="name" sortField={sortField} sortDir={sortDir} onSort={handleSort} />
<SortableColumnHeader label="Email" field="email" sortField={sortField} sortDir={sortDir} onSort={handleSort} />
<SortableColumnHeader label="Name" field="name" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="The user's display name. Shown in the UI and linked to a resource record if auto-linked." />
<SortableColumnHeader label="Email" field="email" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="Login email address. Also used to auto-link user accounts to resource records by matching email." />
<SortableColumnHeader label="Role" field="systemRole" sortField={sortField} sortDir={sortDir} onSort={handleSort} align="center" tooltip="System role determines default access level. ADMIN: full access · MANAGER: manage resources/projects · CONTROLLER: read + export costs · USER: standard access · VIEWER: read-only. Individual permissions can be overridden via the edit dialog." tooltipWidth="w-80" />
<SortableColumnHeader label="Created" field="createdAt" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="Account creation date." />
<th className="text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Actions</th>
@@ -445,8 +446,8 @@ export function UsersClient() {
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Name
<label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Name <InfoTooltip content="The display name for this user account." />
</label>
<input
type="text"
@@ -458,8 +459,8 @@ export function UsersClient() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email
<label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email <InfoTooltip content="Login email address. Also used to auto-link the user to a resource record." />
</label>
<input
type="email"
@@ -471,8 +472,8 @@ export function UsersClient() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Password
<label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Password <InfoTooltip content="Minimum 8 characters. Stored securely using Argon2 hashing." />
</label>
<input
type="password"
@@ -484,8 +485,8 @@ export function UsersClient() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Role
<label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Role <InfoTooltip content="ADMIN: full system access. MANAGER: manage resources, projects, allocations. CONTROLLER: read + export financial data. USER: standard access. VIEWER: read-only." />
</label>
<select
value={createState.systemRole}
@@ -547,8 +548,8 @@ export function UsersClient() {
<div className="overflow-y-auto flex-1 px-6 py-5 space-y-6">
{/* System Role */}
<section>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
System Role
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center">
System Role <InfoTooltip content="The base role determines default permissions. Change the role and click 'Save Role' to apply. Permission overrides below can further customize access." />
</h3>
<div className="flex items-center gap-3">
<select
@@ -578,8 +579,8 @@ export function UsersClient() {
{/* Effective Permissions */}
{effectivePerms && (
<section>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Effective Permissions
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center">
Effective Permissions <InfoTooltip content="The final set of permissions after combining the role's defaults with any overrides below. Green = granted, strikethrough = denied." />
</h3>
<div className="flex flex-wrap gap-1.5">
{ALL_PERMISSION_KEYS.map((key) => {
@@ -603,8 +604,8 @@ export function UsersClient() {
{/* Permission Overrides */}
<section>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Permission Overrides
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center">
Permission Overrides <InfoTooltip content="Override specific permissions for this user. Grants add permissions beyond the role default; Denials remove permissions the role would normally have." />
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Additional Grants */}
@@ -656,8 +657,8 @@ export function UsersClient() {
{/* Chapter Scope */}
<div className="mt-4">
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5">
Chapter Scope (comma-separated IDs, leave blank for all)
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5">
Chapter Scope (comma-separated IDs, leave blank for all) <InfoTooltip content="Restrict this user's access to specific chapters/disciplines only. Leave blank to allow access to all chapters." />
</label>
<input
type="text"
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { trpc } from "~/lib/trpc/client.js";
type CategoryRow = {
@@ -122,11 +123,11 @@ export function UtilizationCategoriesClient() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Code</th>
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Name</th>
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Description</th>
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Default</th>
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Order</th>
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider"><span className="flex items-center">Code <InfoTooltip content="A short unique identifier (e.g. 'Chg', 'Int', 'OH'). Used in reports and exports." /></span></th>
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider"><span className="flex items-center">Name <InfoTooltip content="The display name for this category (e.g. 'Chargeable', 'Internal', 'Overhead'). Shown in dropdowns and reports." /></span></th>
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider"><span className="flex items-center">Description <InfoTooltip content="Explains what type of work falls into this category. Helps users choose the right category for projects." /></span></th>
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider"><span className="flex items-center justify-center">Default <InfoTooltip content="The default category pre-selected when creating new projects. Only one category can be default." /></span></th>
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider"><span className="flex items-center justify-center">Order <InfoTooltip content="Controls the display order in dropdowns and reports. Lower numbers appear first." /></span></th>
<th className="text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Actions</th>
</tr>
</thead>
@@ -171,7 +172,7 @@ export function UtilizationCategoriesClient() {
<div className="px-6 py-5 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Code</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Code <InfoTooltip content="A short unique identifier for this category. Used in reports and data exports." /></label>
<input
type="text"
value={editing.code}
@@ -181,7 +182,7 @@ export function UtilizationCategoriesClient() {
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Sort Order</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Sort Order <InfoTooltip content="Controls display position. Lower numbers appear first." /></label>
<input
type="number"
value={editing.sortOrder}
@@ -192,7 +193,7 @@ export function UtilizationCategoriesClient() {
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name <InfoTooltip content="The display name shown in project dropdowns and chargeability reports." /></label>
<input
type="text"
value={editing.name}
@@ -203,7 +204,7 @@ export function UtilizationCategoriesClient() {
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Description</label>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Description <InfoTooltip content="Explains what type of work this category covers. Helps project managers choose the correct category." /></label>
<textarea
value={editing.description}
onChange={(e) => setEditing({ ...editing, description: e.target.value })}
@@ -220,7 +221,7 @@ export function UtilizationCategoriesClient() {
onChange={(e) => setEditing({ ...editing, isDefault: e.target.checked })}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Default category for new projects
Default category for new projects <InfoTooltip content="When checked, new projects are automatically assigned this category." />
</label>
</div>