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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

@@ -1,7 +1,7 @@
import { PublicHolidayBatch } from "~/components/vacations/PublicHolidayBatch.js";
import { EntitlementManager } from "~/components/vacations/EntitlementManager.js";
export const metadata = { title: "Vacation Management — Planarchy" };
export const metadata = { title: "Vacation Management — plANARCHY" };
export default function AdminVacationsPage() {
return (
@@ -0,0 +1,5 @@
import ComputationGraphClient from "~/components/analytics/ComputationGraphClient";
export default function ComputationGraphPage() {
return <ComputationGraphClient />;
}
@@ -5,6 +5,7 @@ import Link from "next/link";
import { EstimateStatus, type EstimateVersionStatus } from "@planarchy/shared";
import { clsx } from "clsx";
import { EstimateWizard } from "~/components/estimates/EstimateWizard.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { formatDateLong, formatMoney } from "~/lib/format.js";
import { trpc } from "~/lib/trpc/client.js";
@@ -121,7 +122,7 @@ function EstimateDetailPanel({
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-400">
Estimate detail
Estimate detail <InfoTooltip content="Pre-project cost and effort calculation. Estimates model staffing demand, scope, and financials before work begins." />
</p>
<h2 className="mt-2 text-xl font-semibold text-gray-900 dark:text-gray-50">
{estimate.name}
@@ -205,7 +206,7 @@ function EstimateDetailPanel({
<section>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Scope items
Scope items <InfoTooltip content="Deliverables or work packages that define what is included in this estimate." />
</h3>
<span className="text-xs text-gray-400">{latestVersion.scopeItems.length}</span>
</div>
@@ -238,7 +239,7 @@ function EstimateDetailPanel({
<section>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Demand lines
Demand lines <InfoTooltip content="Staffing demand rows. Each line represents a role or resource with hours, cost rate, and sell rate." />
</h3>
<span className="text-xs text-gray-400">{latestVersion.demandLines.length}</span>
</div>
@@ -344,13 +345,13 @@ function EstimateCard({
<div className="mt-5 grid gap-3 sm:grid-cols-2">
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Opportunity</p>
<p className="text-xs uppercase tracking-wide text-gray-400">Opportunity <InfoTooltip content="External CRM or sales reference ID linking this estimate to a sales opportunity." /></p>
<p className="mt-1 text-sm text-gray-700 dark:text-gray-200">
{estimate.opportunityId ?? "Not set"}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Updated</p>
<p className="text-xs uppercase tracking-wide text-gray-400">Updated <InfoTooltip content="When this estimate or any of its versions was last modified." /></p>
<p className="mt-1 text-sm text-gray-700 dark:text-gray-200">
{formatDateLong(estimate.updatedAt)}
</p>
@@ -465,7 +466,7 @@ export function EstimatesClient() {
No estimates yet
</p>
<p className="mt-2 text-sm text-gray-400 dark:text-gray-500">
Start with the wizard to create a connected estimate from Planarchy data.
Start with the wizard to create a connected estimate from plANARCHY data.
</p>
</div>
) : (
@@ -162,6 +162,8 @@ interface ProjectRow {
totalPersonDays: number;
utilizationPercent: number;
dynamicFields?: Record<string, unknown> | null;
coverImageUrl?: string | null;
color?: string | null;
}
// ─── Main component ───────────────────────────────────────────────────────────
@@ -351,8 +353,21 @@ export function ProjectsClient() {
case "name":
return (
<td key={col.key} className="max-w-xs truncate px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">
<Link href={`/projects/${project.id}`} className="transition hover:text-brand-600 hover:underline">
{project.name}
<Link href={`/projects/${project.id}`} className="inline-flex items-center gap-2 transition hover:text-brand-600 hover:underline">
{project.coverImageUrl ? (
<img src={project.coverImageUrl} alt="" className="h-6 w-6 flex-shrink-0 rounded object-cover" />
) : (
<span
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded text-[9px] font-bold opacity-60"
style={{
backgroundColor: (project.color ?? "#6366f1") + "22",
color: project.color ?? "#6366f1",
}}
>
{project.name.split(/\s+/).map((w) => w[0]).filter(Boolean).slice(0, 2).join("").toUpperCase()}
</span>
)}
<span className="truncate">{project.name}</span>
</Link>
</td>
);
+28 -9
View File
@@ -2,11 +2,16 @@ import { notFound } from "next/navigation";
import { formatDate } from "~/lib/format.js";
import Link from "next/link";
import { createCaller } from "~/server/trpc.js";
import { auth } from "~/server/auth.js";
import { BudgetStatusCard } from "~/components/projects/BudgetStatusCard.js";
import { ProjectDetailActions } from "~/components/projects/ProjectDetailClient.js";
import { ProjectDemandsTable } from "~/components/projects/ProjectDemandsTable.js";
import { ProjectAssignmentsTable } from "~/components/projects/ProjectAssignmentsTable.js";
import { PROJECT_STATUS_BADGE as STATUS_COLORS, ORDER_TYPE_BADGE as ORDER_TYPE_COLORS } from "~/lib/status-styles.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { CoverArtSection } from "~/components/projects/CoverArtSection.js";
const EDIT_ROLES = new Set(["ADMIN", "MANAGER"]);
interface ProjectDetailPageProps {
params: Promise<{ id: string }>;
@@ -14,7 +19,9 @@ interface ProjectDetailPageProps {
export default async function ProjectDetailPage({ params }: ProjectDetailPageProps) {
const { id } = await params;
const trpc = await createCaller();
const [trpc, session] = await Promise.all([createCaller(), auth()]);
const userRole = (session?.user as { role?: string } | undefined)?.role ?? "USER";
const canEditProject = EDIT_ROLES.has(userRole);
let project: Awaited<ReturnType<typeof trpc.project.getById>>;
try {
@@ -41,8 +48,18 @@ export default async function ProjectDetailPage({ params }: ProjectDetailPagePro
Back to Projects
</Link>
{/* Cover Art */}
<CoverArtSection
projectId={project.id}
coverImageUrl={project.coverImageUrl}
coverFocusY={project.coverFocusY}
projectColor={project.color}
projectName={project.name}
canEdit={canEditProject}
/>
{/* Project header */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="bg-white rounded-xl border border-gray-200 p-6 dark:bg-gray-900 dark:border-gray-700">
<div className="flex items-start justify-between gap-4 mb-4">
<div>
<div className="flex items-center gap-3 mb-1">
@@ -50,9 +67,11 @@ export default async function ProjectDetailPage({ params }: ProjectDetailPagePro
<span className={`inline-block px-2 py-0.5 text-xs rounded-full ${STATUS_COLORS[project.status] ?? ""}`}>
{project.status}
</span>
<InfoTooltip content="Project lifecycle status: DRAFT = not yet visible, ACTIVE = in progress, ON_HOLD = paused, COMPLETED = finished, CANCELLED = abandoned." />
<span className={`inline-block px-2 py-0.5 text-xs rounded-full ${ORDER_TYPE_COLORS[project.orderType] ?? ""}`}>
{project.orderType}
</span>
<InfoTooltip content="BD = Business Development (pre-sales), CHARGEABLE = billable client work, INTERNAL = internal project, OVERHEAD = non-project overhead." />
</div>
<h1 className="text-2xl font-bold text-gray-900">{project.name}</h1>
</div>
@@ -63,7 +82,7 @@ export default async function ProjectDetailPage({ params }: ProjectDetailPagePro
{" — "}
{formatDate(project.endDate)}
</div>
<div className="mt-0.5">Win probability: {project.winProbability}%</div>
<div className="mt-0.5 flex items-center">Win probability: {project.winProbability}%<InfoTooltip content="Likelihood of winning this project (0-100%). Used to calculate weighted pipeline value (budget x probability)." /></div>
</div>
<ProjectDetailActions project={project as never} />
</div>
@@ -71,30 +90,30 @@ export default async function ProjectDetailPage({ params }: ProjectDetailPagePro
<dl className="grid grid-cols-2 gap-x-8 gap-y-3 sm:grid-cols-4 mt-4 pt-4 border-t border-gray-100">
<div>
<dt className="text-xs text-gray-500">Chargecode</dt>
<dt className="text-xs text-gray-500 flex items-center">Chargecode<InfoTooltip content="Unique project identifier used for time tracking and cost attribution." /></dt>
<dd className="mt-0.5 text-sm font-mono font-medium text-gray-900">{project.shortCode}</dd>
</div>
<div>
<dt className="text-xs text-gray-500">Order Type</dt>
<dt className="text-xs text-gray-500 flex items-center">Order Type<InfoTooltip content="BD = Business Development, CHARGEABLE = billable client work, INTERNAL = internal project, OVERHEAD = overhead costs." /></dt>
<dd className="mt-0.5 text-sm text-gray-900">{project.orderType}</dd>
</div>
<div>
<dt className="text-xs text-gray-500">Allocation Type</dt>
<dt className="text-xs text-gray-500 flex items-center">Allocation Type<InfoTooltip content="INT = staffed with internal resources, EXT = staffed with external contractors." /></dt>
<dd className="mt-0.5 text-sm text-gray-900">{project.allocationType}</dd>
</div>
<div>
<dt className="text-xs text-gray-500">Assignments</dt>
<dt className="text-xs text-gray-500 flex items-center">Assignments<InfoTooltip content="Number of active resource assignments (confirmed or in-progress allocations) on this project." /></dt>
<dd className="mt-0.5 text-sm text-gray-900">{activeAssignments.length} active</dd>
</div>
<div>
<dt className="text-xs text-gray-500">Open Demands</dt>
<dt className="text-xs text-gray-500 flex items-center">Open Demands<InfoTooltip content="Staffing requirements that still need resources. Unfilled seats are demand positions not yet assigned to a person." /></dt>
<dd className="mt-0.5 text-sm text-gray-900">
{activeDemands.length} items · {unfilledSeats}/{requestedSeats} seats unfilled
</dd>
</div>
{project.responsiblePerson && (
<div className="sm:col-span-2">
<dt className="text-xs text-gray-500">Responsible Person</dt>
<dt className="text-xs text-gray-500 flex items-center">Responsible Person<InfoTooltip content="The project lead or account manager responsible for this project." /></dt>
<dd className="mt-0.5 text-sm font-medium text-gray-900">{project.responsiblePerson}</dd>
</div>
)}
@@ -978,7 +978,7 @@ export function ResourcesClient() {
sortField={sortField}
sortDir={sortDir}
onSort={toggle}
tooltip="Unique employee identifier used across all Planarchy records."
tooltip="Unique employee identifier used across all plANARCHY records."
/>
);
case "displayName":
@@ -9,9 +9,9 @@ export async function generateMetadata(
try {
const trpc = await createCaller();
const resource = await trpc.resource.getById({ id });
return { title: `${resource.displayName} — Resources | Planarchy` };
return { title: `${resource.displayName} — Resources | plANARCHY` };
} catch {
return { title: "Resource — Planarchy" };
return { title: "Resource — plANARCHY" };
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { MyVacationsClient } from "~/components/vacations/MyVacationsClient.js";
export const metadata = { title: "My Vacations — Planarchy" };
export const metadata = { title: "My Vacations — plANARCHY" };
export default function MyVacationsPage() {
return <MyVacationsClient />;
+3 -11
View File
@@ -37,7 +37,7 @@ export default function SignInPage() {
<div className="hidden rounded-[2rem] border border-white/70 bg-white/75 p-10 shadow-2xl backdrop-blur lg:flex lg:flex-col lg:justify-between dark:border-slate-800 dark:bg-slate-950/60">
<div>
<span className="inline-flex rounded-full border border-brand-200 bg-brand-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-brand-700 dark:border-brand-900/50 dark:bg-brand-900/20 dark:text-brand-300">
Planarchy Control Center
plANARCHY Control Center
</span>
<h1 className="mt-6 font-display text-5xl font-semibold leading-tight text-gray-900 dark:text-gray-50">
Resource planning that stays readable under pressure.
@@ -66,7 +66,7 @@ export default function SignInPage() {
<div className="app-surface-strong p-8">
<div className="mb-8">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-brand-600">Welcome Back</p>
<h2 className="mt-3 font-display text-4xl font-semibold text-gray-900 dark:text-gray-50">Sign in to Planarchy</h2>
<h2 className="mt-3 font-display text-4xl font-semibold text-gray-900 dark:text-gray-50">Sign in to plANARCHY</h2>
<p className="mt-2 text-sm text-gray-500">Resource Planning, staffing, and forecasting.</p>
</div>
@@ -87,7 +87,7 @@ export default function SignInPage() {
value={email}
onChange={(e) => setEmail(e.target.value)}
className="app-input"
placeholder="admin@planarchy.dev"
placeholder="you@company.com"
required
/>
</div>
@@ -116,14 +116,6 @@ export default function SignInPage() {
</button>
</form>
<div className="mt-6 rounded-2xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-700 dark:bg-gray-900/70">
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-gray-500">Demo accounts</p>
<div className="space-y-1.5 text-sm text-gray-600 dark:text-gray-300">
<p><span className="font-mono text-xs">admin@planarchy.dev</span> / admin123</p>
<p><span className="font-mono text-xs">manager@planarchy.dev</span> / manager123</p>
<p><span className="font-mono text-xs">viewer@planarchy.dev</span> / viewer123</p>
</div>
</div>
</div>
</div>
</div>
+14 -1
View File
@@ -16,8 +16,21 @@ const displayFont = Manrope({
});
export const metadata: Metadata = {
title: "Planarchy — Resource Planning",
metadataBase: new URL("https://planarchy.hartmut-noerenberg.com"),
title: "plANARCHY — Resource Planning",
description: "Interactive resource planning and project staffing tool",
openGraph: {
title: "plANARCHY — Resource Planning",
description: "Estimates, staffing, chargeability, and timelines in one workspace.",
images: [{ url: "/og-image.png", width: 1024, height: 1024, alt: "plANARCHY Logo" }],
type: "website",
},
twitter: {
card: "summary_large_image",
title: "plANARCHY — Resource Planning",
description: "Estimates, staffing, chargeability, and timelines in one workspace.",
images: ["/og-image.png"],
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
@@ -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>
@@ -9,6 +9,7 @@ import { trpc } from "~/lib/trpc/client.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { DateInput } from "~/components/ui/DateInput.js";
import { RecurrenceEditor } from "./RecurrenceEditor.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
const ALLOCATION_STATUSES = Object.values(AllocationStatus);
type EntryKind = "demand" | "assignment";
@@ -308,7 +309,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
{!isDemandEntry && (
<div>
<label htmlFor="modal-resource" className={labelClass}>
Resource <span className="text-red-500">*</span>
Resource <span className="text-red-500">*</span><InfoTooltip content="The person to assign. Their LCR determines the daily cost of this allocation." />
</label>
<select
id="modal-resource"
@@ -330,7 +331,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
{/* Project */}
<div>
<label htmlFor="modal-project" className={labelClass}>
Project <span className="text-red-500">*</span>
Project <span className="text-red-500">*</span><InfoTooltip content="The project this time block is allocated to. Costs roll up to the project budget." />
</label>
<select
id="modal-project"
@@ -350,7 +351,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
{/* Role */}
<div>
<label htmlFor="modal-role" className={labelClass}>Role</label>
<label htmlFor="modal-role" className={labelClass}>Role<InfoTooltip content="Role for this allocation. Pick a predefined role or type a custom one." /></label>
<select
id="modal-role"
value={roleId}
@@ -380,7 +381,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="modal-start" className={labelClass}>
Start Date <span className="text-red-500">*</span>
Start Date <span className="text-red-500">*</span><InfoTooltip content="First day of this allocation period (inclusive)." />
</label>
<DateInput
id="modal-start"
@@ -392,7 +393,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
</div>
<div>
<label htmlFor="modal-end" className={labelClass}>
End Date <span className="text-red-500">*</span>
End Date <span className="text-red-500">*</span><InfoTooltip content="Last day of this allocation period (inclusive)." />
</label>
<DateInput
id="modal-end"
@@ -409,7 +410,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="modal-hours" className={labelClass}>
Hours / Day
Hours / Day<InfoTooltip content="Working hours per day. Total cost = LCR x hours/day x working days. Vacation days are excluded." />
</label>
<input
id="modal-hours"
@@ -424,7 +425,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
</div>
<div>
<label htmlFor="modal-status" className={labelClass}>
Status
Status<InfoTooltip content="PROPOSED = draft/request · CONFIRMED = approved · ACTIVE = in progress · COMPLETED = done · CANCELLED = removed." />
</label>
<select
id="modal-status"
@@ -453,7 +454,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
}}
className="rounded border-gray-300 dark:border-gray-600"
/>
<span className="font-medium text-gray-700 dark:text-gray-300">Recurring schedule</span>
<span className="font-medium text-gray-700 dark:text-gray-300">Recurring schedule</span><InfoTooltip content="Enable to repeat this allocation on specific days (e.g. every Monday/Wednesday). Hours per day applies on active days only." />
</label>
{isRecurring && (
<div className="mt-2">
@@ -586,9 +586,12 @@ export function AllocationsClient() {
</th>
{visibleColumns.map((col) => {
const tooltips: Record<string, { tip: string; width?: string }> = {
resource: { tip: "The person assigned to this time block. Grouped view clusters entries by resource." },
project: { tip: "The project this allocation belongs to, identified by short code and name." },
role: { tip: "The role this allocation was created for. May differ from the resource's primary role." },
dates: { tip: "Start and end date of this allocation period (inclusive)." },
hoursPerDay: { tip: "Planned working hours per calendar day for this allocation." },
cost: { tip: "Resource LCR × hours per day. Reflects the cost of one day of work for this allocation." },
cost: { tip: "Daily cost = resource LCR x hours per day. Total cost = daily cost x working days.", width: "w-72" },
status: { tip: "PROPOSED = requested · CONFIRMED = approved · ACTIVE = ongoing · COMPLETED = finished · CANCELLED = removed.", width: "w-72" },
};
const t = tooltips[col.key];
@@ -6,6 +6,7 @@ import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import { formatDateMedium } from "~/lib/format.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { trpc } from "~/lib/trpc/client.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
interface OpenDemandAllocation {
id: string;
@@ -192,8 +193,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-1">
{phase === "plan" ? "Plan Demand Assignment" : "Confirm Assignments"}
<InfoTooltip content="Fill an open demand by assigning one or more real resources to a placeholder staffing requirement. Each assignment creates a new allocation." />
</h2>
<button type="button" onClick={onClose} disabled={submitting} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-xl leading-none disabled:opacity-30">&times;</button>
</div>
@@ -399,8 +401,8 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">Resource</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">h/day</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">Hours</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">Est. Cost</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">Coverage</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400"><span className="inline-flex items-center justify-end gap-0.5">Est. Cost<InfoTooltip content="Estimated cost = resource LCR x available hours in the demand period." /></span></th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400"><span className="inline-flex items-center justify-end gap-0.5">Coverage<InfoTooltip content="Percentage of the demand period this resource can cover, accounting for existing bookings." /></span></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
@@ -2,6 +2,7 @@
import { RecurrenceFrequency } from "@planarchy/shared";
import type { RecurrencePattern } from "@planarchy/shared";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
@@ -38,7 +39,7 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
<div className="space-y-3 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700">
{/* Frequency selector */}
<div>
<span className={labelClass}>Frequency</span>
<span className={labelClass}>Frequency<InfoTooltip content="How often the allocation repeats: weekly, biweekly, monthly, or a custom pattern." /></span>
<div className="flex gap-2 flex-wrap">
{Object.values(RecurrenceFrequency).map((f) => (
<button
@@ -66,7 +67,7 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
{/* Weekday picker — WEEKLY and BIWEEKLY */}
{(freq === RecurrenceFrequency.WEEKLY || freq === RecurrenceFrequency.BIWEEKLY) && (
<div>
<span className={labelClass}>Days of week</span>
<span className={labelClass}>Days of week<InfoTooltip content="Select which days of the week this allocation is active. Hours per day applies only on selected days." /></span>
<div className="flex gap-1">
{WEEKDAY_LABELS.map((label, dow) => {
const selected = (value?.weekdays ?? []).includes(dow);
@@ -138,7 +139,7 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
{/* Optional hours override for WEEKLY/BIWEEKLY/MONTHLY */}
{freq !== RecurrenceFrequency.CUSTOM && (
<div>
<label className={labelClass}>Hours per recurring day (optional override)</label>
<label className={labelClass}>Hours per recurring day (optional override)<InfoTooltip content="Override the allocation's default hours for recurring days only. Leave empty to use the allocation's hours/day." /></label>
<input
type="number"
min={0.5}
@@ -0,0 +1,540 @@
"use client";
import { useCallback, useMemo, useRef, useEffect, useState } from "react";
import { DOMAIN_COLORS, DOMAIN_LABELS, type Domain } from "~/components/analytics/computation-graph/domain-colors";
import type { PositionedNode } from "~/components/analytics/computation-graph/graph-data";
import type { ComputationGraphState } from "~/components/analytics/computation-graph/useComputationGraphData";
// ─── Layout constants ────────────────────────────────────────────────────────
const NODE_W = 220;
const NODE_H = 88;
const H_GAP = 50;
const V_GAP = 140;
const PADDING = 60;
const MIN_ZOOM = 0.2;
const MAX_ZOOM = 3;
const ZOOM_STEP = 0.12;
// ─── 2D DAG layout ──────────────────────────────────────────────────────────
interface PlacedNode extends PositionedNode {
px: number;
py: number;
}
function build2DLayout(nodes: PositionedNode[]): { placed: PlacedNode[]; width: number; height: number } {
if (nodes.length === 0) return { placed: [], width: 800, height: 400 };
const byLevel = new Map<number, PositionedNode[]>();
for (const n of nodes) {
const arr = byLevel.get(n.level) ?? [];
arr.push(n);
byLevel.set(n.level, arr);
}
const levels = [...byLevel.keys()].sort((a, b) => a - b);
const domainOrder: Domain[] = [
"EFFORT", "SAH", "ESTIMATE", "INPUT", "ALLOCATION", "COMMERCIAL",
"RULES", "EXPERIENCE", "CHARGEABILITY", "SPREAD", "BUDGET",
];
for (const [, arr] of byLevel) {
arr.sort((a, b) => {
const ai = domainOrder.indexOf(a.domain as Domain);
const bi = domainOrder.indexOf(b.domain as Domain);
return ai - bi;
});
}
let maxRowNodes = 0;
for (const arr of byLevel.values()) {
if (arr.length > maxRowNodes) maxRowNodes = arr.length;
}
const svgW = Math.max(800, maxRowNodes * (NODE_W + H_GAP) - H_GAP + PADDING * 2);
const svgH = levels.length * (NODE_H + V_GAP) - V_GAP + PADDING * 2;
const placed: PlacedNode[] = [];
for (let li = 0; li < levels.length; li++) {
const level = levels[li]!;
const row = byLevel.get(level)!;
const rowWidth = row.length * (NODE_W + H_GAP) - H_GAP;
const offsetX = (svgW - rowWidth) / 2;
for (let ni = 0; ni < row.length; ni++) {
placed.push({
...row[ni]!,
px: offsetX + ni * (NODE_W + H_GAP) + NODE_W / 2,
py: PADDING + li * (NODE_H + V_GAP) + NODE_H / 2,
});
}
}
return { placed, width: svgW, height: svgH };
}
// ─── Edge path ───────────────────────────────────────────────────────────────
function edgePath(src: PlacedNode, tgt: PlacedNode): string {
const sx = src.px;
const sy = src.py + NODE_H / 2;
const ex = tgt.px;
const ey = tgt.py - NODE_H / 2;
const midY = (sy + ey) / 2;
return `M ${sx} ${sy} C ${sx} ${midY}, ${ex} ${midY}, ${ex} ${ey}`;
}
// ─── Pre-computed edge label data ────────────────────────────────────────────
interface EdgeLabel {
x: number;
y: number;
anchor: "start" | "middle" | "end";
offsetX: number;
text: string;
pillW: number;
pillX: number;
}
function computeEdgeLabel(src: PlacedNode, tgt: PlacedNode, formula: string): EdgeLabel {
const labelX = (src.px + tgt.px) / 2;
const labelY = (src.py + NODE_H / 2 + tgt.py - NODE_H / 2) / 2;
const offsetX = src.px === tgt.px ? 14 : 0;
const anchor = src.px === tgt.px ? "start" : "middle";
const text = formula.length > 28 ? formula.slice(0, 26) + "..." : formula;
const pillW = text.length * 7 + 12;
const pillX = anchor === "middle" ? labelX - pillW / 2 + offsetX : labelX + offsetX - 4;
return { x: labelX, y: labelY, anchor, offsetX, text, pillW, pillX };
}
// ─── Component ───────────────────────────────────────────────────────────────
interface Props {
state: ComputationGraphState;
}
export default function ComputationGraph2D({ state }: Props) {
const { graphData, highlightedNodes, handleNodeClick } = state;
const containerRef = useRef<HTMLDivElement>(null);
const transformRef = useRef<HTMLDivElement>(null);
const [isDark, setIsDark] = useState(false);
// Pan & zoom via refs — zero React re-renders during interaction
const viewState = useRef({ zoom: 1, panX: 0, panY: 0 });
const zoomLabelRef = useRef<HTMLSpanElement>(null);
const isPanning = useRef(false);
const panStart = useRef({ x: 0, y: 0, panX: 0, panY: 0 });
const mousePosRef = useRef({ x: 0, y: 0 });
const tooltipRef = useRef<HTMLDivElement>(null);
const hoveredNodeRef = useRef<PositionedNode | null>(null);
useEffect(() => {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
setIsDark(mq.matches || document.documentElement.classList.contains("dark"));
const handler = () => setIsDark(mq.matches || document.documentElement.classList.contains("dark"));
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
const { placed, width, height } = useMemo(
() => build2DLayout(graphData.nodes),
[graphData.nodes],
);
const nodeMap = useMemo(() => {
const m = new Map<string, PlacedNode>();
for (const n of placed) m.set(n.id, n);
return m;
}, [placed]);
// ── Apply CSS transform to the wrapper div (GPU-composited, no SVG repaint) ──
const applyTransform = useCallback(() => {
const el = transformRef.current;
if (!el) return;
const { zoom, panX, panY } = viewState.current;
el.style.transform = `translate(${panX}px, ${panY}px) scale(${zoom})`;
}, []);
const updateZoomLabel = useCallback((zoom: number) => {
const el = zoomLabelRef.current;
if (el) el.textContent = `${Math.round(zoom * 100)}%`;
}, []);
// Update tooltip content + position via DOM (no React re-render)
const updateTooltip = useCallback((node: PositionedNode | null) => {
const el = tooltipRef.current;
if (!el) return;
if (!node) {
el.style.display = "none";
return;
}
el.style.display = "block";
el.style.top = `${mousePosRef.current.y + 16}px`;
el.style.left = `${mousePosRef.current.x + 16}px`;
const label = el.querySelector("[data-tt-label]");
const dot = el.querySelector("[data-tt-dot]") as HTMLElement | null;
const domain = el.querySelector("[data-tt-domain]");
const value = el.querySelector("[data-tt-value]");
const unit = el.querySelector("[data-tt-unit]");
const desc = el.querySelector("[data-tt-desc]");
const formula = el.querySelector("[data-tt-formula]");
if (label) label.textContent = node.label;
if (dot) dot.style.backgroundColor = node.color;
if (domain) domain.textContent = DOMAIN_LABELS[node.domain];
if (value) value.textContent = String(node.value);
if (unit) unit.textContent = node.unit;
if (desc) desc.textContent = node.description;
if (formula) {
formula.textContent = node.formula ?? "";
(formula as HTMLElement).style.display = node.formula ? "block" : "none";
}
}, []);
// Fit to view
const fitToView = useCallback(() => {
const el = containerRef.current;
if (!el || placed.length === 0) return;
const cw = el.clientWidth;
const ch = el.clientHeight;
const scaleX = cw / width;
const scaleY = ch / height;
const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, Math.min(scaleX, scaleY) * 0.92));
viewState.current = {
zoom: newZoom,
panX: (cw - width * newZoom) / 2,
panY: (ch - height * newZoom) / 2,
};
applyTransform();
updateZoomLabel(newZoom);
}, [width, height, placed.length, applyTransform, updateZoomLabel]);
useEffect(() => {
if (placed.length > 0) fitToView();
}, [placed.length, fitToView]);
// Focus on a node
const focusNode = useCallback((node: PlacedNode) => {
const el = containerRef.current;
if (!el) return;
const cw = el.clientWidth;
const ch = el.clientHeight;
const targetZoom = 1.2;
viewState.current = {
zoom: targetZoom,
panX: cw / 2 - node.px * targetZoom,
panY: ch / 2 - node.py * targetZoom,
};
applyTransform();
updateZoomLabel(targetZoom);
}, [applyTransform, updateZoomLabel]);
// Wheel zoom — native event, direct DOM mutation
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const handler = (e: WheelEvent) => {
e.preventDefault();
const rect = el.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
const vs = viewState.current;
const direction = e.deltaY < 0 ? 1 : -1;
const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, vs.zoom * (1 + direction * ZOOM_STEP)));
const scale = newZoom / vs.zoom;
vs.panX = cx - scale * (cx - vs.panX);
vs.panY = cy - scale * (cy - vs.panY);
vs.zoom = newZoom;
applyTransform();
updateZoomLabel(newZoom);
};
el.addEventListener("wheel", handler, { passive: false });
return () => el.removeEventListener("wheel", handler);
}, [applyTransform, updateZoomLabel]);
// Pointer handlers — native events, direct DOM mutation, no React state
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const onDown = (e: PointerEvent) => {
const tag = (e.target as Element).tagName;
const isBg = tag === "svg" || tag === "DIV" || !!(e.target as Element).closest?.("[data-bg]");
if (e.button === 1 || isBg) {
isPanning.current = true;
const vs = viewState.current;
panStart.current = { x: e.clientX, y: e.clientY, panX: vs.panX, panY: vs.panY };
(e.target as Element).setPointerCapture?.(e.pointerId);
e.preventDefault();
el.style.cursor = "grabbing";
}
};
const onMove = (e: PointerEvent) => {
mousePosRef.current.x = e.clientX;
mousePosRef.current.y = e.clientY;
if (hoveredNodeRef.current) {
const tip = tooltipRef.current;
if (tip) {
tip.style.top = `${e.clientY + 16}px`;
tip.style.left = `${e.clientX + 16}px`;
}
}
if (!isPanning.current) return;
viewState.current.panX = panStart.current.panX + (e.clientX - panStart.current.x);
viewState.current.panY = panStart.current.panY + (e.clientY - panStart.current.y);
applyTransform();
};
const onUp = () => {
isPanning.current = false;
el.style.cursor = "grab";
};
el.addEventListener("pointerdown", onDown);
el.addEventListener("pointermove", onMove);
el.addEventListener("pointerup", onUp);
el.addEventListener("pointerleave", onUp);
return () => {
el.removeEventListener("pointerdown", onDown);
el.removeEventListener("pointermove", onMove);
el.removeEventListener("pointerup", onUp);
el.removeEventListener("pointerleave", onUp);
};
}, [applyTransform, updateTooltip]);
// Zoom controls
const zoomBy = useCallback((direction: 1 | -1) => {
const el = containerRef.current;
if (!el) return;
const cw = el.clientWidth / 2;
const ch = el.clientHeight / 2;
const vs = viewState.current;
const next = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, vs.zoom * (1 + direction * ZOOM_STEP)));
const scale = next / vs.zoom;
vs.panX = cw - scale * (cw - vs.panX);
vs.panY = ch - scale * (ch - vs.panY);
vs.zoom = next;
applyTransform();
updateZoomLabel(next);
}, [applyTransform, updateZoomLabel]);
if (graphData.nodes.length === 0) {
return (
<div className="flex h-full items-center justify-center text-zinc-500">
{state.viewMode === "resource" ? "Select a resource and month" : "Select a project"}
</div>
);
}
const cardBg = isDark ? "#1e293b" : "#ffffff";
const cardTextPrimary = isDark ? "#f1f5f9" : "#1e293b";
const cardTextSecondary = isDark ? "#94a3b8" : "#64748b";
const canvasBg = isDark ? "#0f172a" : "#f8fafc";
const gridLine = isDark ? "#1e293b" : "#e2e8f0";
const pillBg = isDark ? "#1e293b" : "#ffffff";
const pillStroke = isDark ? "#334155" : "#e2e8f0";
const pillText = isDark ? "#94a3b8" : "#475569";
return (
<div
ref={containerRef}
className="relative h-full w-full overflow-hidden"
style={{ backgroundColor: canvasBg, cursor: "grab" }}
>
{/* Wrapper div for CSS transform — GPU-composited, no SVG repaint */}
<div
ref={transformRef}
style={{
position: "absolute",
width: `${width}px`,
height: `${height}px`,
transformOrigin: "0 0",
willChange: "transform",
}}
>
<svg
width={width}
height={height}
style={{ position: "absolute", top: 0, left: 0 }}
>
<defs>
<marker id="arrow-default" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<path d="M 0 0 L 10 3.5 L 0 7 Z" fill={isDark ? "#475569" : "#94a3b8"} />
</marker>
<marker id="arrow-highlight" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<path d="M 0 0 L 10 3.5 L 0 7 Z" fill="#3b82f6" />
</marker>
</defs>
{/* Grid lines */}
{[...new Set(placed.map((n) => n.py))].map((y) => (
<line
key={`grid-${y}`}
x1="0" y1={y} x2={width} y2={y}
stroke={gridLine} strokeWidth="1" strokeDasharray="4 4" opacity="0.4"
/>
))}
{/* ── Links ── */}
{graphData.links.map((link) => {
const sourceId = typeof link.source === "object" ? (link.source as { id: string }).id : link.source;
const targetId = typeof link.target === "object" ? (link.target as { id: string }).id : link.target;
const src = nodeMap.get(sourceId);
const tgt = nodeMap.get(targetId);
if (!src || !tgt) return null;
const isHighlighted = highlightedNodes
? highlightedNodes.has(sourceId) && highlightedNodes.has(targetId)
: false;
const isNeutral = !highlightedNodes;
const isDimmed = highlightedNodes && !isHighlighted;
const showLabel = (isNeutral || isHighlighted) && link.formula;
return (
<g key={`${sourceId}-${targetId}`}>
<path
d={edgePath(src, tgt)}
fill="none"
stroke={isDimmed ? (isDark ? "#334155" : "#d1d5db") : src.color}
strokeWidth={isDimmed ? 1 : isHighlighted ? (link.weight ?? 1) * 2.5 : (link.weight ?? 1) * 1.5}
strokeOpacity={isDimmed ? 0.2 : isHighlighted ? 0.8 : 0.4}
markerEnd={isDimmed ? undefined : (isHighlighted ? "url(#arrow-highlight)" : "url(#arrow-default)")}
/>
{showLabel && (() => {
const lbl = computeEdgeLabel(src, tgt, link.formula);
return (
<>
<rect
x={lbl.pillX} y={lbl.y - 10}
width={lbl.pillW} height={20} rx="4"
fill={pillBg} stroke={pillStroke} strokeWidth="1" opacity="0.95"
/>
<text
x={lbl.x + lbl.offsetX} y={lbl.y + 1}
fontSize="12" fill={pillText}
textAnchor={lbl.anchor} dominantBaseline="central"
className="pointer-events-none select-none"
fontFamily="ui-monospace, monospace"
>
{lbl.text}
</text>
</>
);
})()}
</g>
);
})}
{/* ── Nodes ── */}
{placed.map((node) => {
const isActive = !highlightedNodes || highlightedNodes.has(node.id);
const isDimmed = highlightedNodes && !isActive;
const color = DOMAIN_COLORS[node.domain as Domain] ?? "#6b7280";
const valueStr = typeof node.value === "number"
? node.value.toFixed(1)
: String(node.value);
const displayValue = valueStr.length > 16 ? valueStr.slice(0, 14) + "..." : valueStr;
const displayLabel = node.label.length > 22 ? node.label.slice(0, 20) + "..." : node.label;
return (
<g
key={node.id}
transform={`translate(${node.px - NODE_W / 2}, ${node.py - NODE_H / 2})`}
onClick={(e) => {
e.stopPropagation();
if (e.detail === 2) {
focusNode(node);
} else {
handleNodeClick(node.id);
}
}}
onMouseEnter={() => { hoveredNodeRef.current = node; updateTooltip(node); }}
onMouseLeave={() => { hoveredNodeRef.current = null; updateTooltip(null); }}
className="cursor-pointer"
opacity={isDimmed ? 0.2 : 1}
>
<rect
width={NODE_W} height={NODE_H} rx="10"
fill={cardBg}
stroke={isDark ? "#334155" : "#e2e8f0"}
strokeWidth="1"
/>
<path
d={`M 10 0 H ${NODE_W - 10} Q ${NODE_W} 0 ${NODE_W} 10 V 4 H 0 V 10 Q 0 0 10 0 Z`}
fill={color}
/>
<text x="12" y="22" fontSize="11" fontWeight="500" fill={cardTextSecondary} fontFamily="system-ui, sans-serif">
{DOMAIN_LABELS[node.domain as Domain]}
</text>
<text x="12" y="44" fontSize="14" fontWeight="600" fill={cardTextPrimary} fontFamily="system-ui, sans-serif">
{displayLabel}
</text>
<text x="12" y="68" fontSize="18" fontWeight="700" fill={color} fontFamily="ui-monospace, monospace">
{displayValue}
</text>
<text x={NODE_W - 12} y="68" fontSize="12" fontWeight="400" fill={cardTextSecondary} textAnchor="end" fontFamily="system-ui, sans-serif">
{node.unit}
</text>
</g>
);
})}
</svg>
</div>
{/* Background click target for pan (covers viewport) */}
<div data-bg="true" className="absolute inset-0" style={{ zIndex: -1 }} />
{/* ── Zoom controls (bottom-right) ── */}
<div className="absolute bottom-4 right-4 flex flex-col gap-1">
<button
onClick={() => zoomBy(1)}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-zinc-300 bg-white text-sm font-bold text-zinc-700 shadow-sm hover:bg-zinc-100 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700"
title="Zoom in"
>
+
</button>
<button
onClick={() => zoomBy(-1)}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-zinc-300 bg-white text-sm font-bold text-zinc-700 shadow-sm hover:bg-zinc-100 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700"
title="Zoom out"
>
-
</button>
<button
onClick={fitToView}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-zinc-300 bg-white text-xs font-medium text-zinc-700 shadow-sm hover:bg-zinc-100 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700"
title="Fit to view"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="1" y="1" width="12" height="12" rx="2" />
<path d="M 4 7 L 1 7 M 10 7 L 13 7 M 7 4 L 7 1 M 7 10 L 7 13" />
</svg>
</button>
<span ref={zoomLabelRef} className="mt-1 text-center text-[10px] text-zinc-400">
100%
</span>
</div>
{/* Hover Tooltip — always rendered, shown/hidden via DOM */}
<div
ref={tooltipRef}
className="pointer-events-none fixed z-50 max-w-sm rounded-lg border border-zinc-300 bg-white px-4 py-3 text-sm text-zinc-800 shadow-xl dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-200"
style={{ display: "none", top: 0, left: 0 }}
>
<div className="flex items-center gap-2">
<span data-tt-dot className="inline-block h-2.5 w-2.5 rounded-full" />
<span data-tt-label className="font-semibold" />
<span data-tt-domain className="ml-auto text-xs text-zinc-500" />
</div>
<div className="mt-1 text-lg font-bold">
<span data-tt-value /> <span data-tt-unit className="text-sm font-normal text-zinc-400" />
</div>
<div data-tt-desc className="mt-1 text-xs text-zinc-500 dark:text-zinc-400" />
<div data-tt-formula className="mt-1 font-mono text-xs text-blue-500" />
</div>
</div>
);
}
@@ -0,0 +1,126 @@
"use client";
import { useCallback, useState } from "react";
import dynamic from "next/dynamic";
import { DOMAIN_LABELS } from "~/components/analytics/computation-graph/domain-colors";
import { createNodeSprite, createDimmedNodeSprite, getLinkColor } from "~/components/analytics/computation-graph/node-renderer";
import type { PositionedNode } from "~/components/analytics/computation-graph/graph-data";
import type { ComputationGraphState } from "~/components/analytics/computation-graph/useComputationGraphData";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ForceGraph3D = dynamic(() => import("react-force-graph-3d"), { ssr: false }) as any;
interface Props {
state: ComputationGraphState;
}
export default function ComputationGraph3DView({ state }: Props) {
const { graphData, highlightedNodes, handleNodeClick, setHoveredNode } = state;
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const onNodeClick = useCallback((node: any) => {
handleNodeClick((node as PositionedNode).id);
}, [handleNodeClick]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const onNodeHover = useCallback((node: any) => {
setHoveredNode(node as PositionedNode | null);
}, [setHoveredNode]);
const nodeThreeObject = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(node: any) => {
const n = node as PositionedNode;
if (highlightedNodes && !highlightedNodes.has(n.id)) {
return createDimmedNodeSprite(n);
}
return createNodeSprite(n);
},
[highlightedNodes],
);
const linkColorFn = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(link: any) => {
if (!highlightedNodes) return getLinkColor(link, 0.4);
const sourceId = typeof link.source === "object" ? link.source.id : link.source;
const targetId = typeof link.target === "object" ? link.target.id : link.target;
if (highlightedNodes.has(sourceId) && highlightedNodes.has(targetId)) {
return getLinkColor(link, 0.9);
}
return getLinkColor(link, 0.08);
},
[highlightedNodes],
);
const linkWidthFn = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(link: any) => {
if (!highlightedNodes) return link.weight ?? 1;
const sourceId = typeof link.source === "object" ? link.source.id : link.source;
const targetId = typeof link.target === "object" ? link.target.id : link.target;
if (highlightedNodes.has(sourceId) && highlightedNodes.has(targetId)) {
return (link.weight ?? 1) * 2.5;
}
return 0.3;
},
[highlightedNodes],
);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
setMousePos({ x: e.clientX, y: e.clientY });
}, []);
if (graphData.nodes.length === 0) {
return (
<div className="flex h-full items-center justify-center text-zinc-500">
{state.viewMode === "resource" ? "Select a resource and month" : "Select a project"}
</div>
);
}
return (
<div className="relative h-full w-full" onMouseMove={handleMouseMove}>
<ForceGraph3D
graphData={graphData}
nodeThreeObject={nodeThreeObject}
nodeThreeObjectExtend={false}
onNodeClick={onNodeClick}
onNodeHover={onNodeHover}
linkColor={linkColorFn}
linkWidth={linkWidthFn}
linkDirectionalArrowLength={4}
linkDirectionalArrowRelPos={0.8}
linkCurvature={0.1}
backgroundColor="#0f172a"
showNavInfo={false}
warmupTicks={50}
cooldownTicks={0}
/>
{/* Hover Tooltip */}
{state.hoveredNode && (
<div
className="pointer-events-none fixed z-50 max-w-sm rounded-lg border border-zinc-600 bg-zinc-900 px-4 py-3 text-sm text-zinc-200 shadow-xl"
style={{ top: mousePos.y + 16, left: mousePos.x + 16 }}
>
<div className="flex items-center gap-2">
<span
className="inline-block h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: state.hoveredNode.color }}
/>
<span className="font-semibold">{state.hoveredNode.label}</span>
<span className="ml-auto text-xs text-zinc-500">{DOMAIN_LABELS[state.hoveredNode.domain]}</span>
</div>
<div className="mt-1 text-lg font-bold">
{state.hoveredNode.value} <span className="text-sm font-normal text-zinc-400">{state.hoveredNode.unit}</span>
</div>
<div className="mt-1 text-xs text-zinc-400">{state.hoveredNode.description}</div>
{state.hoveredNode.formula && (
<div className="mt-1 font-mono text-xs text-blue-400">{state.hoveredNode.formula}</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,179 @@
"use client";
import { useState } from "react";
import {
DOMAIN_COLORS,
DOMAIN_LABELS,
} from "~/components/analytics/computation-graph/domain-colors";
import { useComputationGraphData } from "~/components/analytics/computation-graph/useComputationGraphData";
import ComputationGraph2D from "~/components/analytics/ComputationGraph2D";
import ComputationGraph3D from "~/components/analytics/ComputationGraph3D";
type Dimension = "2d" | "3d";
export default function ComputationGraphClient() {
const state = useComputationGraphData();
const [dimension, setDimension] = useState<Dimension>("2d");
const {
viewMode, setViewMode,
resourceId, setResourceId,
month, setMonth,
projectId, setProjectId,
resources, projects,
isLoading,
activeDomains,
graphData,
highlightedNodes, setHighlightedNodes,
domainFilter, toggleDomain,
} = state;
return (
<div className="flex h-[calc(100vh-4rem)] flex-col">
{/* ── Header Bar ── */}
<div className="flex flex-wrap items-center gap-3 border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900">
{/* 2D / 3D Toggle */}
<div className="flex rounded-lg border border-zinc-300 dark:border-zinc-600">
<button
onClick={() => setDimension("2d")}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
dimension === "2d"
? "bg-zinc-800 text-white dark:bg-zinc-200 dark:text-zinc-900"
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
} rounded-l-lg`}
>
2D
</button>
<button
onClick={() => setDimension("3d")}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
dimension === "3d"
? "bg-zinc-800 text-white dark:bg-zinc-200 dark:text-zinc-900"
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
} rounded-r-lg`}
>
3D
</button>
</div>
{/* View Mode Toggle */}
<div className="flex rounded-lg border border-zinc-300 dark:border-zinc-600">
<button
onClick={() => setViewMode("resource")}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
viewMode === "resource"
? "bg-blue-600 text-white"
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
} rounded-l-lg`}
>
Resource View
</button>
<button
onClick={() => setViewMode("project")}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
viewMode === "project"
? "bg-blue-600 text-white"
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
} rounded-r-lg`}
>
Project View
</button>
</div>
{/* Selectors */}
{viewMode === "resource" ? (
<>
<select
value={resourceId}
onChange={(e) => setResourceId(e.target.value)}
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200"
>
<option value="">Select Resource...</option>
{resources.map((r: { id: string; displayName: string; eid: string }) => (
<option key={r.id} value={r.id}>
{r.displayName} ({r.eid})
</option>
))}
</select>
<input
type="month"
value={month}
onChange={(e) => setMonth(e.target.value)}
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200"
/>
</>
) : (
<select
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200"
>
<option value="">Select Project...</option>
{(Array.isArray(projects) ? projects : []).map((p: { id: string; name: string; shortCode?: string | null }) => (
<option key={p.id} value={p.id}>
{p.shortCode ? `${p.shortCode}` : ""}{p.name}
</option>
))}
</select>
)}
{/* Meta info */}
{graphData.nodes.length > 0 && (
<span className="ml-auto text-xs text-zinc-500">
{graphData.nodes.length} nodes, {graphData.links.length} links
</span>
)}
{/* Clear highlight */}
{highlightedNodes && (
<button
onClick={() => setHighlightedNodes(null)}
className="rounded bg-zinc-200 px-2 py-1 text-xs text-zinc-700 hover:bg-zinc-300 dark:bg-zinc-700 dark:text-zinc-300"
>
Clear highlight
</button>
)}
</div>
{/* ── Main Area ── */}
<div className="relative flex flex-1 overflow-hidden">
{/* Domain Filter Sidebar */}
<div className="flex w-48 flex-col gap-1 border-r border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-900">
<span className="mb-1 text-xs font-semibold uppercase text-zinc-500">Domains</span>
{activeDomains.map((domain) => (
<button
key={domain}
onClick={() => toggleDomain(domain)}
className={`flex items-center gap-2 rounded px-2 py-1 text-left text-sm transition-colors ${
domainFilter.has(domain)
? "text-zinc-400 line-through"
: "text-zinc-700 dark:text-zinc-300"
} hover:bg-zinc-200 dark:hover:bg-zinc-800`}
>
<span
className="inline-block h-3 w-3 rounded-full"
style={{
backgroundColor: domainFilter.has(domain) ? "#9ca3af" : DOMAIN_COLORS[domain],
}}
/>
{DOMAIN_LABELS[domain]}
</button>
))}
</div>
{/* Graph View */}
<div className="flex-1 overflow-hidden">
{isLoading ? (
<div className="flex h-full items-center justify-center text-zinc-500">
Loading computation graph...
</div>
) : dimension === "2d" ? (
<ComputationGraph2D state={state} />
) : (
<ComputationGraph3D state={state} />
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,92 @@
// ─── Domain Types & Colors for the 3D Computation Graph ────────────────────
export type Domain =
| "INPUT"
| "SAH"
| "ALLOCATION"
| "RULES"
| "CHARGEABILITY"
| "BUDGET"
| "ESTIMATE"
| "COMMERCIAL"
| "EXPERIENCE"
| "EFFORT"
| "SPREAD";
export const DOMAIN_COLORS: Record<Domain, string> = {
INPUT: "#94a3b8",
SAH: "#3b82f6",
ALLOCATION: "#f97316",
RULES: "#8b5cf6",
CHARGEABILITY: "#22c55e",
BUDGET: "#ef4444",
ESTIMATE: "#06b6d4",
COMMERCIAL: "#f59e0b",
EXPERIENCE: "#ec4899",
EFFORT: "#14b8a6",
SPREAD: "#6366f1",
};
export const DOMAIN_LABELS: Record<Domain, string> = {
INPUT: "Inputs",
SAH: "SAH",
ALLOCATION: "Allocation",
RULES: "Rules Engine",
CHARGEABILITY: "Chargeability",
BUDGET: "Budget",
ESTIMATE: "Estimates",
COMMERCIAL: "Commercial",
EXPERIENCE: "Experience Mult.",
EFFORT: "Effort Rules",
SPREAD: "Monthly Spread",
};
// ─── Graph Node / Link Types ────────────────────────────────────────────────
export interface GraphNode {
id: string;
label: string;
value: number | string;
unit: string;
domain: Domain;
description: string;
formula?: string;
level: number; // 0=Input, 1=Intermediate, 2=Derived, 3=Output
// react-force-graph position hints
fx?: number;
fy?: number;
fz?: number;
}
export interface GraphLink {
source: string;
target: string;
formula: string;
weight: number; // 1-3 for line thickness
}
export interface GraphData {
nodes: GraphNode[];
links: GraphLink[];
}
// ─── Resource View Mode ─────────────────────────────────────────────────────
export const RESOURCE_VIEW_DOMAINS: Domain[] = [
"INPUT",
"SAH",
"ALLOCATION",
"RULES",
"CHARGEABILITY",
"BUDGET",
];
export const PROJECT_VIEW_DOMAINS: Domain[] = [
"INPUT",
"ESTIMATE",
"COMMERCIAL",
"EXPERIENCE",
"EFFORT",
"SPREAD",
"BUDGET",
];
@@ -0,0 +1,125 @@
import type { GraphNode, GraphLink, Domain } from "./domain-colors";
import { DOMAIN_COLORS } from "./domain-colors";
// ─── Layout Constants ───────────────────────────────────────────────────────
const LEVEL_Y_SPACING = 120;
const DOMAIN_X_SPACING = 200;
const NODE_Z_JITTER = 40;
// Domain → X position offset for clustering
const DOMAIN_X_OFFSETS: Partial<Record<Domain, number>> = {
INPUT: 0,
SAH: -300,
ALLOCATION: -100,
RULES: 100,
CHARGEABILITY: 300,
BUDGET: 500,
ESTIMATE: -200,
COMMERCIAL: 0,
EXPERIENCE: 200,
EFFORT: -400,
SPREAD: 400,
};
// ─── Position Calculator ────────────────────────────────────────────────────
export interface PositionedNode extends GraphNode {
fx: number;
fy: number;
fz: number;
color: string;
}
export interface ForceGraphData {
nodes: PositionedNode[];
links: GraphLink[];
}
/**
* Assigns 3D positions to nodes based on their level (Y) and domain (X),
* with slight Z jitter to prevent overlap.
*/
export function buildForceGraphData(
nodes: GraphNode[],
links: GraphLink[],
): ForceGraphData {
// Group nodes by domain+level to spread within each cluster
const groups = new Map<string, GraphNode[]>();
for (const node of nodes) {
const key = `${node.domain}:${node.level}`;
const arr = groups.get(key) ?? [];
arr.push(node);
groups.set(key, arr);
}
const positionedNodes: PositionedNode[] = nodes.map((node) => {
const key = `${node.domain}:${node.level}`;
const siblings = groups.get(key) ?? [node];
const idx = siblings.indexOf(node);
const count = siblings.length;
// Center siblings around the domain X offset
const baseX = DOMAIN_X_OFFSETS[node.domain] ?? 0;
const spreadX = count > 1 ? (idx - (count - 1) / 2) * 80 : 0;
return {
...node,
fx: baseX + spreadX,
fy: node.level * LEVEL_Y_SPACING,
fz: (idx % 2 === 0 ? 1 : -1) * NODE_Z_JITTER * (Math.floor(idx / 2) + 1) * 0.5,
color: DOMAIN_COLORS[node.domain],
};
});
// Filter links to only reference existing node IDs
const nodeIds = new Set(positionedNodes.map((n) => n.id));
const validLinks = links.filter((link) => nodeIds.has(link.source) && nodeIds.has(link.target));
return { nodes: positionedNodes, links: validLinks };
}
// ─── Highlight Helpers ──────────────────────────────────────────────────────
/**
* Given a clicked node, returns the set of node IDs in its
* upstream (ancestors) and downstream (descendants) path.
*/
export function getConnectedNodeIds(
nodeId: string,
links: GraphLink[],
): Set<string> {
const connected = new Set<string>([nodeId]);
// BFS upstream (nodes that feed into this one)
const queue = [nodeId];
while (queue.length > 0) {
const current = queue.shift()!;
for (const link of links) {
const targetId = typeof link.target === "object" ? (link.target as { id: string }).id : link.target;
const sourceId = typeof link.source === "object" ? (link.source as { id: string }).id : link.source;
if (targetId === current && !connected.has(sourceId)) {
connected.add(sourceId);
queue.push(sourceId);
}
}
}
// BFS downstream (nodes this one feeds into)
const queue2 = [nodeId];
const visited = new Set<string>([nodeId]);
while (queue2.length > 0) {
const current = queue2.shift()!;
for (const link of links) {
const targetId = typeof link.target === "object" ? (link.target as { id: string }).id : link.target;
const sourceId = typeof link.source === "object" ? (link.source as { id: string }).id : link.source;
if (sourceId === current && !visited.has(targetId)) {
connected.add(targetId);
visited.add(targetId);
queue2.push(targetId);
}
}
}
return connected;
}
@@ -0,0 +1,120 @@
import * as THREE from "three";
import type { PositionedNode } from "./graph-data";
// ─── Canvas-based node sprites ──────────────────────────────────────────────
const spriteCache = new Map<string, THREE.Sprite>();
/**
* Creates a Three.js sprite for a graph node: colored circle with value label.
*/
export function createNodeSprite(node: PositionedNode): THREE.Sprite {
const cacheKey = `${node.id}:${node.value}:${node.color}`;
const cached = spriteCache.get(cacheKey);
if (cached) return cached.clone();
const canvas = document.createElement("canvas");
const size = 512;
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d")!;
const cx = size / 2;
const cy = size / 2;
const radius = size / 2 - 16;
// Outer glow ring
const gradient = ctx.createRadialGradient(cx, cy, radius * 0.8, cx, cy, radius);
gradient.addColorStop(0, node.color + "aa");
gradient.addColorStop(1, node.color + "00");
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fillStyle = gradient;
ctx.fill();
// Dark background circle for contrast
ctx.beginPath();
ctx.arc(cx, cy, radius * 0.78, 0, Math.PI * 2);
ctx.fillStyle = "#0f172a";
ctx.fill();
// Colored border ring
ctx.beginPath();
ctx.arc(cx, cy, radius * 0.78, 0, Math.PI * 2);
ctx.strokeStyle = node.color;
ctx.lineWidth = 8;
ctx.stroke();
// Subtle inner fill
ctx.beginPath();
ctx.arc(cx, cy, radius * 0.74, 0, Math.PI * 2);
ctx.fillStyle = node.color + "20";
ctx.fill();
// Label (top)
ctx.fillStyle = "#ffffff";
ctx.font = "bold 36px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.shadowColor = "#000000";
ctx.shadowBlur = 6;
const label = node.label.length > 16 ? node.label.slice(0, 14) + "..." : node.label;
ctx.fillText(label, cx, cy - 28);
// Value (bottom) — brighter, larger
ctx.fillStyle = node.color;
ctx.font = "bold 44px system-ui, sans-serif";
ctx.shadowBlur = 4;
const valueStr = typeof node.value === "number" ? node.value.toFixed(1) : String(node.value);
const displayValue = valueStr.length > 12 ? valueStr.slice(0, 10) + "..." : valueStr;
ctx.fillText(displayValue, cx, cy + 24);
// Unit (small, below value)
if (node.unit) {
ctx.fillStyle = "#94a3b8";
ctx.font = "24px system-ui, sans-serif";
ctx.shadowBlur = 0;
ctx.fillText(node.unit, cx, cy + 60);
}
const texture = new THREE.CanvasTexture(canvas);
const material = new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthWrite: false,
});
const sprite = new THREE.Sprite(material);
sprite.scale.set(50, 50, 1);
spriteCache.set(cacheKey, sprite);
return sprite;
}
/**
* Creates a dimmed version of a node sprite (for non-highlighted nodes).
*/
export function createDimmedNodeSprite(node: PositionedNode): THREE.Sprite {
const sprite = createNodeSprite({ ...node, color: "#4b5563" });
sprite.material.opacity = 0.3;
return sprite;
}
// ─── Link particle rendering ───────────────────────────────────────────────
/**
* Returns a color for a link based on the source node's domain color.
*/
export function getLinkColor(
link: { source: string | { color?: string }; weight: number },
opacity = 0.4,
): string {
const sourceColor = typeof link.source === "object" && link.source.color
? link.source.color
: "#6b7280";
// Convert hex to rgba
const r = parseInt(sourceColor.slice(1, 3), 16);
const g = parseInt(sourceColor.slice(3, 5), 16);
const b = parseInt(sourceColor.slice(5, 7), 16);
return `rgba(${r},${g},${b},${opacity})`;
}
@@ -0,0 +1,155 @@
"use client";
import { useState, useMemo, useEffect, useCallback } from "react";
import { trpc } from "~/lib/trpc/client.js";
import {
RESOURCE_VIEW_DOMAINS,
PROJECT_VIEW_DOMAINS,
type Domain,
type GraphNode,
} from "./domain-colors";
import { buildForceGraphData, getConnectedNodeIds, type PositionedNode, type ForceGraphData } from "./graph-data";
export type ViewMode = "resource" | "project";
export interface ComputationGraphState {
viewMode: ViewMode;
setViewMode: (m: ViewMode) => void;
resourceId: string;
setResourceId: (id: string) => void;
month: string;
setMonth: (m: string) => void;
projectId: string;
setProjectId: (id: string) => void;
resources: Array<{ id: string; displayName: string; eid: string }>;
projects: Array<{ id: string; name: string; shortCode?: string | null }>;
isLoading: boolean;
activeDomains: Domain[];
graphData: ForceGraphData;
highlightedNodes: Set<string> | null;
setHighlightedNodes: (s: Set<string> | null) => void;
hoveredNode: PositionedNode | null;
setHoveredNode: (n: PositionedNode | null) => void;
domainFilter: Set<Domain>;
toggleDomain: (domain: Domain) => void;
handleNodeClick: (nodeId: string) => void;
}
export function useComputationGraphData(): ComputationGraphState {
const [viewMode, setViewMode] = useState<ViewMode>("resource");
const [resourceId, setResourceId] = useState<string>("");
const [month, setMonth] = useState(() => {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
});
const [projectId, setProjectId] = useState<string>("");
const [highlightedNodes, setHighlightedNodes] = useState<Set<string> | null>(null);
const [hoveredNode, setHoveredNode] = useState<PositionedNode | null>(null);
const [domainFilter, setDomainFilter] = useState<Set<Domain>>(new Set());
// Load selectors
const { data: resourceData } = trpc.resource.list.useQuery(
{ isActive: true, limit: 500 },
{ staleTime: 60_000 },
);
const resources = resourceData?.resources ?? [];
const { data: projectData } = trpc.project.list.useQuery(
{},
{ staleTime: 60_000 },
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const projects: Array<{ id: string; name: string; shortCode?: string | null }> = (projectData as any)?.projects ?? (projectData as any) ?? [];
// Auto-select first resource/project
useEffect(() => {
if (!resourceId && resources.length > 0) {
setResourceId(resources[0]!.id);
}
}, [resources, resourceId]);
useEffect(() => {
if (!projectId && Array.isArray(projects) && projects.length > 0) {
setProjectId(projects[0]!.id);
}
}, [projects, projectId]);
// Fetch graph data
const { data: resourceGraphData, isLoading: resourceLoading } = trpc.computationGraph.getResourceData.useQuery(
{ resourceId, month },
{ enabled: viewMode === "resource" && !!resourceId, staleTime: 30_000 },
);
const { data: projectGraphData, isLoading: projectLoading } = trpc.computationGraph.getProjectData.useQuery(
{ projectId },
{ enabled: viewMode === "project" && !!projectId, staleTime: 30_000 },
);
const rawData = viewMode === "resource" ? resourceGraphData : projectGraphData;
const isLoading = viewMode === "resource" ? resourceLoading : projectLoading;
const activeDomains = viewMode === "resource" ? RESOURCE_VIEW_DOMAINS : PROJECT_VIEW_DOMAINS;
// Build graph data with positions
const graphData = useMemo(() => {
if (!rawData) return { nodes: [], links: [] };
let filteredNodes = rawData.nodes as GraphNode[];
if (domainFilter.size > 0) {
filteredNodes = filteredNodes.filter((nd: GraphNode) => !domainFilter.has(nd.domain as Domain));
}
const filteredNodeIds = new Set(filteredNodes.map((nd: GraphNode) => nd.id));
const filteredLinks = rawData.links.filter(
(lk: { source: string; target: string }) =>
filteredNodeIds.has(lk.source) && filteredNodeIds.has(lk.target),
);
return buildForceGraphData(filteredNodes, filteredLinks);
}, [rawData, domainFilter]);
// Domain filter toggle
const toggleDomain = useCallback((domain: Domain) => {
setDomainFilter((prev) => {
const next = new Set(prev);
if (next.has(domain)) {
next.delete(domain);
} else {
next.add(domain);
}
return next;
});
}, []);
// Node click → highlight path
const handleNodeClick = useCallback(
(nodeId: string) => {
if (highlightedNodes?.has(nodeId)) {
setHighlightedNodes(null);
} else {
const connected = getConnectedNodeIds(nodeId, graphData.links);
setHighlightedNodes(connected);
}
},
[graphData.links, highlightedNodes],
);
return {
viewMode,
setViewMode,
resourceId,
setResourceId,
month,
setMonth,
projectId,
setProjectId,
resources,
projects,
isLoading,
activeDomains,
graphData,
highlightedNodes,
setHighlightedNodes,
hoveredNode,
setHoveredNode,
domainFilter,
toggleDomain,
handleNodeClick,
};
}
@@ -1,181 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { usePathname } from "next/navigation";
import { trpc } from "~/lib/trpc/client.js";
import { ChatMessage, TypingIndicator } from "./ChatMessage.js";
/** Map route prefixes to human-readable page context for the AI */
const ROUTE_CONTEXT: Record<string, string> = {
"/dashboard": "Dashboard — Übersicht mit KPIs, aktive Projekte, Ressourcen-Auslastung",
"/timeline": "Timeline — Gantt-artige Ansicht aller Allokationen und Projekte",
"/allocations": "Allokationen — Liste aller Zuweisungen von Ressourcen zu Projekten",
"/staffing": "Staffing — Projektbesetzung und Kapazitätsplanung",
"/resources": "Ressourcen — Liste aller Mitarbeiter mit Details (FTE, LCR, Skills, Chapter)",
"/projects": "Projekte — Liste aller Projekte mit Budget, Status, Zeitraum",
"/roles": "Rollen — Verwaltung der verfügbaren Rollen",
"/estimates": "Estimating — Aufwandsschätzungen für Projekte",
"/vacations/my": "Meine Urlaube — Eigene Urlaubsanträge und Saldo",
"/vacations": "Urlaubsverwaltung — Alle Urlaubsanträge, Genehmigungen, Team-Kalender",
"/analytics/skills": "Skills Analytics — Skill-Verteilung und -Analyse über alle Ressourcen",
"/analytics/computation-graph": "Computation Graph — Berechnungsvisualisierung für Budget/Kosten",
"/reports/chargeability": "Chargeability Report — Auslastungsanalyse pro Ressource",
"/admin/settings": "Admin-Einstellungen — System-Konfiguration, AI-Credentials, SMTP",
"/admin/users": "Benutzerverwaltung — Rollen, Berechtigungen, Zugänge",
};
function resolvePageContext(pathname: string): string {
// Try exact match first, then prefix match (longest first)
const exact = ROUTE_CONTEXT[pathname];
if (exact) return exact;
const sorted = Object.keys(ROUTE_CONTEXT).sort((a, b) => b.length - a.length);
for (const prefix of sorted) {
const ctx = ROUTE_CONTEXT[prefix];
if (pathname.startsWith(prefix) && ctx) return ctx;
}
return pathname;
}
interface Message {
role: "user" | "assistant";
content: string;
}
export function ChatDrawer({ onClose }: { onClose: () => void }) {
const pathname = usePathname();
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const chatMutation = trpc.assistant.chat.useMutation();
// Auto-scroll to bottom on new messages
useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [messages, isLoading]);
// Focus input on mount
useEffect(() => {
inputRef.current?.focus();
}, []);
const sendMessage = useCallback(async () => {
const text = input.trim();
if (!text || isLoading) return;
setInput("");
setError(null);
const userMsg: Message = { role: "user", content: text };
const updated = [...messages, userMsg];
setMessages(updated);
setIsLoading(true);
try {
const reply = await chatMutation.mutateAsync({
messages: updated.map((m) => ({ role: m.role, content: m.content })),
...(pathname ? { pageContext: resolvePageContext(pathname) } : {}),
});
setMessages((prev) => [...prev, { role: "assistant", content: reply.content }]);
} catch (err) {
const msg = err instanceof Error ? err.message : "Something went wrong";
setError(msg);
} finally {
setIsLoading(false);
}
}, [input, isLoading, messages, chatMutation]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void sendMessage();
}
};
return (
<>
{/* Backdrop */}
<div className="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm" onClick={onClose} />
{/* Panel */}
<div className="fixed right-0 top-0 z-50 flex h-full w-full max-w-md flex-col border-l border-gray-200 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900">
{/* Header */}
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-slate-700">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-brand-600 text-white">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
</div>
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Planarchy Assistant</h2>
</div>
<button
type="button"
onClick={onClose}
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-slate-800 dark:hover:text-gray-300"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Messages */}
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4 space-y-3">
{messages.length === 0 && !isLoading && (
<div className="flex flex-col items-center justify-center py-16 text-center text-sm text-gray-400 dark:text-gray-500">
<svg className="mb-3 h-10 w-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
<p className="font-medium">Frag mich etwas!</p>
<p className="mt-1 text-xs">z.B. &quot;Welche Ressourcen gibt es?&quot; oder &quot;Budget von Z033T593?&quot;</p>
</div>
)}
{messages.map((msg, i) => (
<ChatMessage key={i} role={msg.role} content={msg.content} />
))}
{isLoading && <TypingIndicator />}
{error && (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-2.5 text-sm text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-400">
{error}
</div>
)}
</div>
{/* Input */}
<div className="border-t border-gray-200 px-4 py-3 dark:border-slate-700">
<div className="flex items-end gap-2">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Nachricht eingeben..."
rows={1}
className="flex-1 resize-none rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500 dark:border-slate-600 dark:bg-slate-800 dark:text-gray-100 dark:placeholder:text-gray-500 dark:focus:border-brand-500"
style={{ maxHeight: "120px" }}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = "auto";
target.style.height = `${Math.min(target.scrollHeight, 120)}px`;
}}
/>
<button
type="button"
onClick={() => void sendMessage()}
disabled={!input.trim() || isLoading}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-brand-600 text-white transition-colors hover:bg-brand-700 disabled:opacity-40 disabled:cursor-not-allowed"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M12 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</div>
</>
);
}
@@ -354,11 +354,11 @@ export function BlueprintsClient() {
aria-label="Select all blueprints"
/>
</th>
<SortableColumnHeader label="Name" field="name" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} />
<SortableColumnHeader label="Target" field="target" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} />
<SortableColumnHeader label="Fields" field="fieldCount" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} align="center" />
<SortableColumnHeader label="Staffing Presets" field="presetCount" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} align="center" />
<SortableColumnHeader label="Global" field="global" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} align="center" />
<SortableColumnHeader label="Name" field="name" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} tooltip="Blueprint name. Defines a template of dynamic fields for resources or projects." />
<SortableColumnHeader label="Target" field="target" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} tooltip="Whether this blueprint applies to Resource or Project entities." />
<SortableColumnHeader label="Fields" field="fieldCount" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} align="center" tooltip="Number of custom dynamic fields defined in this blueprint." />
<SortableColumnHeader label="Staffing Presets" field="presetCount" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} align="center" tooltip="Role presets for project staffing demands. Only applicable to PROJECT blueprints." />
<SortableColumnHeader label="Global" field="global" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} align="center" tooltip="Global blueprints expose their fields as columns across all entities of the target type." />
<th className="px-3 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
@@ -237,6 +237,7 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
className="rounded border-gray-300"
/>
Include proposed
<InfoTooltip content="When enabled, PROPOSED bookings and imported TBD planning rows are also counted toward chargeability." />
</label>
</div>
<div className="flex flex-wrap items-center gap-2">
@@ -248,6 +249,7 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
className="rounded border-gray-300"
/>
Show departed
<InfoTooltip content="When enabled, resources who have left the company are included in the lists." />
</label>
<FilterDropdown label={selectedCountryLabel}>
<div className="space-y-2">
@@ -295,8 +297,9 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
handleSectionScroll(event, topVisibleCount, top.length, setTopVisibleCount)
}
>
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider px-1 mb-1 sticky top-0 bg-white">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider px-1 mb-1 sticky top-0 bg-white flex items-center">
Top Chargeability
<InfoTooltip content="Resources ranked by highest actual chargeability this month. Chargeability = chargeable booked hours / total available hours." />
<span className="ml-1 font-normal normal-case text-gray-400">
{visibleTop.length}/{top.length}
</span>
@@ -380,8 +383,9 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
handleSectionScroll(event, watchVisibleCount, watchlist.length, setWatchVisibleCount)
}
>
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider px-1 mb-1 sticky top-0 bg-white">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider px-1 mb-1 sticky top-0 bg-white flex items-center">
Watchlist <span className="font-normal text-gray-400">(below target)</span>
<InfoTooltip content="Resources whose actual chargeability is more than 15 percentage points below their individual target. These may need more project assignments." />
<span className="ml-1 font-normal normal-case text-gray-400">
{visibleWatchlist.length}/{watchlist.length}
</span>
@@ -92,10 +92,15 @@ export function DemandWidget({ config, onConfigChange }: WidgetProps) {
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<button type="button" onClick={() => toggleSort("name")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
{groupBy === "project" ? "Project" : groupBy === "person" ? "Person" : "Chapter"}
<Ind k="name" />
</button>
<span className="inline-flex items-center">
<button type="button" onClick={() => toggleSort("name")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
{groupBy === "project" ? "Project" : groupBy === "person" ? "Person" : "Chapter"}
<Ind k="name" />
</button>
{groupBy === "project" && <InfoTooltip content="Active projects with allocations in the current quarter. Short code shown before the name." />}
{groupBy === "person" && <InfoTooltip content="Resources with active allocations in the current quarter." />}
{groupBy === "chapter" && <InfoTooltip content="Organizational chapters (teams/departments) with active allocations in the current quarter." />}
</span>
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">
<span className="inline-flex items-center justify-end">
@@ -4,6 +4,7 @@ import { useMemo } from "react";
import Link from "next/link";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "../widget-registry.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
const STATUS_DOT: Record<string, string> = {
ACTIVE: "bg-green-500",
@@ -78,8 +79,9 @@ export function MyProjectsWidget({ config }: WidgetProps) {
<div className="flex-1 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800">
{favoriteProjects.length > 0 && (
<div>
<div className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400 bg-amber-50/50 dark:bg-amber-900/10">
<div className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400 bg-amber-50/50 dark:bg-amber-900/10 flex items-center">
Favorites
<InfoTooltip content="Projects you have starred. Click the star icon on any project to add or remove it from your favorites." />
</div>
{favoriteProjects.map((p) => (
<ProjectRow key={p.id} project={p} isFavorite onToggleFavorite={() => toggleMutation.mutate({ projectId: p.id })} />
@@ -89,8 +91,9 @@ export function MyProjectsWidget({ config }: WidgetProps) {
{responsibleProjects.length > 0 && (
<div>
<div className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-brand-600 dark:text-brand-400 bg-brand-50/50 dark:bg-brand-900/10">
<div className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-brand-600 dark:text-brand-400 bg-brand-50/50 dark:bg-brand-900/10 flex items-center">
Responsible
<InfoTooltip content="Projects where you are listed as the responsible person. These are automatically shown based on your user name." />
</div>
{responsibleProjects.map((p) => (
<ProjectRow key={p.id} project={p} isFavorite={false} onToggleFavorite={() => toggleMutation.mutate({ projectId: p.id })} />
@@ -130,7 +133,7 @@ function ProjectRow({
<span className="font-mono text-xs text-gray-400 dark:text-gray-500 mr-1.5">{project.shortCode}</span>
{project.name}
</Link>
<span className="flex-shrink-0 text-[10px] text-gray-400 dark:text-gray-500 uppercase">{project.status}</span>
<span className="flex-shrink-0 text-[10px] text-gray-400 dark:text-gray-500 uppercase" title={`Project status: ${project.status}`}>{project.status}</span>
</div>
);
}
@@ -121,64 +121,73 @@ export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) {
<thead className="sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<button
type="button"
onClick={() => toggleSort("code")}
className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer"
>
Code
<span className="text-[10px] ml-0.5">
{sortKey === "code" ? (
sortDir === "asc" ? (
"▲"
<span className="inline-flex items-center">
<button
type="button"
onClick={() => toggleSort("code")}
className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer"
>
Code
<span className="text-[10px] ml-0.5">
{sortKey === "code" ? (
sortDir === "asc" ? (
"▲"
) : (
"▼"
)
) : (
"▼"
)
) : (
<span className="text-gray-300"></span>
)}
</span>
</button>
<span className="text-gray-300"></span>
)}
</span>
</button>
<InfoTooltip content="Unique short code identifying the project (e.g. PRJ-001)." />
</span>
</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<button
type="button"
onClick={() => toggleSort("name")}
className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer"
>
Name
<span className="text-[10px] ml-0.5">
{sortKey === "name" ? (
sortDir === "asc" ? (
"▲"
<span className="inline-flex items-center">
<button
type="button"
onClick={() => toggleSort("name")}
className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer"
>
Name
<span className="text-[10px] ml-0.5">
{sortKey === "name" ? (
sortDir === "asc" ? (
"▲"
) : (
"▼"
)
) : (
"▼"
)
) : (
<span className="text-gray-300"></span>
)}
</span>
</button>
<span className="text-gray-300"></span>
)}
</span>
</button>
<InfoTooltip content="Project name. Click to open the project detail page." />
</span>
</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<button
type="button"
onClick={() => toggleSort("status")}
className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer"
>
Status
<span className="text-[10px] ml-0.5">
{sortKey === "status" ? (
sortDir === "asc" ? (
"▲"
<span className="inline-flex items-center">
<button
type="button"
onClick={() => toggleSort("status")}
className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer"
>
Status
<span className="text-[10px] ml-0.5">
{sortKey === "status" ? (
sortDir === "asc" ? (
"▲"
) : (
"▼"
)
) : (
"▼"
)
) : (
<span className="text-gray-300"></span>
)}
</span>
</button>
<span className="text-gray-300"></span>
)}
</span>
</button>
<InfoTooltip content="Current project lifecycle status: DRAFT, ACTIVE, ON_HOLD, COMPLETED, or CANCELLED." />
</span>
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">
<span className="inline-flex items-center justify-end">
@@ -230,7 +239,10 @@ export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) {
</span>
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">
Budget
<span className="inline-flex items-center justify-end">
Budget
<InfoTooltip content="Project budget in EUR. The colored dot indicates utilization: green = healthy, amber = above 80%, red = over budget." />
</span>
</th>
</tr>
</thead>
@@ -122,6 +122,7 @@ export function ResourceTableWidget({ config, onConfigChange }: WidgetProps) {
className="rounded border-gray-300"
/>
Include proposed
<InfoTooltip content="When enabled, PROPOSED bookings are counted toward booking count and utilization." />
</label>
</div>
@@ -154,44 +155,50 @@ export function ResourceTableWidget({ config, onConfigChange }: WidgetProps) {
</span>
</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<button
type="button"
onClick={() => toggleSort("name")}
className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer"
>
Name
<span className="text-[10px] ml-0.5">
{sortKey === "name" ? (
sortDir === "asc" ? (
"▲"
<span className="inline-flex items-center">
<button
type="button"
onClick={() => toggleSort("name")}
className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer"
>
Name
<span className="text-[10px] ml-0.5">
{sortKey === "name" ? (
sortDir === "asc" ? (
"▲"
) : (
"▼"
)
) : (
"▼"
)
) : (
<span className="text-gray-300"></span>
)}
</span>
</button>
<span className="text-gray-300"></span>
)}
</span>
</button>
<InfoTooltip content="Display name of the resource. Rows highlighted in amber indicate overbooking." />
</span>
</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<button
type="button"
onClick={() => toggleSort("chapter")}
className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer"
>
Chapter
<span className="text-[10px] ml-0.5">
{sortKey === "chapter" ? (
sortDir === "asc" ? (
"▲"
<span className="inline-flex items-center">
<button
type="button"
onClick={() => toggleSort("chapter")}
className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer"
>
Chapter
<span className="text-[10px] ml-0.5">
{sortKey === "chapter" ? (
sortDir === "asc" ? (
"▲"
) : (
"▼"
)
) : (
"▼"
)
) : (
<span className="text-gray-300"></span>
)}
</span>
</button>
<span className="text-gray-300"></span>
)}
</span>
</button>
<InfoTooltip content="Organizational chapter (team/department) the resource belongs to." />
</span>
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">
<span className="inline-flex items-center justify-end">
@@ -74,21 +74,35 @@ export function TopValueWidget({ config }: WidgetProps) {
<table className="w-full text-xs">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500 w-8">#</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<button type="button" onClick={() => toggleSort("eid")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
EID<Ind k="eid" />
</button>
<th className="px-3 py-2 text-left font-medium text-gray-500 w-8">
<span className="inline-flex items-center">
#
<InfoTooltip content="Rank position based on the current sort order." />
</span>
</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<button type="button" onClick={() => toggleSort("name")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
Name<Ind k="name" />
</button>
<span className="inline-flex items-center">
<button type="button" onClick={() => toggleSort("eid")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
EID<Ind k="eid" />
</button>
<InfoTooltip content="Employee ID — unique identifier for each resource." />
</span>
</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<button type="button" onClick={() => toggleSort("chapter")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
Chapter<Ind k="chapter" />
</button>
<span className="inline-flex items-center">
<button type="button" onClick={() => toggleSort("name")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
Name<Ind k="name" />
</button>
<InfoTooltip content="Display name of the resource." />
</span>
</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<span className="inline-flex items-center">
<button type="button" onClick={() => toggleSort("chapter")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
Chapter<Ind k="chapter" />
</button>
<InfoTooltip content="Organizational chapter (team/department) the resource belongs to." />
</span>
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">
<span className="inline-flex items-center justify-end">
@@ -2,6 +2,7 @@
import { useState } from "react";
import { clsx } from "clsx";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatCents } from "~/lib/format.js";
import { trpc } from "~/lib/trpc/client.js";
@@ -63,11 +64,11 @@ export function ApplyExperienceMultipliers({ estimateId, canEdit, onApplied }: A
return (
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-3 text-base font-semibold text-gray-900">Apply experience multipliers</h3>
<h3 className="mb-3 text-base font-semibold text-gray-900">Apply experience multipliers <InfoTooltip content="Experience multipliers adjust hours, cost rates, and bill rates based on rules like seniority level or chapter. A multiplier >1 increases effort, <1 decreases it." /></h3>
<div className="flex flex-wrap items-end gap-4">
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Multiplier set</span>
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Multiplier set <InfoTooltip content="Select which set of rules to apply. Each set contains rules that match by chapter, role, or level." /></span>
<select
value={selectedSetId}
onChange={(e) => {
@@ -8,6 +8,7 @@ import {
validatePaymentMilestones,
} from "@planarchy/engine";
import { clsx } from "clsx";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatMoney } from "~/lib/format.js";
import { trpc } from "~/lib/trpc/client.js";
@@ -99,7 +100,7 @@ export function CommercialTermsEditor({
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">
Adjusted Cost
Adjusted Cost <InfoTooltip content="Base cost + contingency. Adjusted cost = base cost x (1 + contingency %)." />
</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">
{formatMoney(summary.adjustedCostCents, baseCurrency)}
@@ -112,7 +113,7 @@ export function CommercialTermsEditor({
</div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">
Adjusted Price
Adjusted Price <InfoTooltip content="Base price minus discount. Adjusted price = base price x (1 - discount %)." />
</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">
{formatMoney(summary.adjustedPriceCents, baseCurrency)}
@@ -125,7 +126,7 @@ export function CommercialTermsEditor({
</div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">
Adjusted Margin
Adjusted Margin <InfoTooltip content="Adjusted margin = adjusted price - adjusted cost. Margin % = margin / adjusted price x 100." />
</p>
<p
className={clsx(
@@ -143,7 +144,7 @@ export function CommercialTermsEditor({
</div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">
Pricing Model
Pricing Model <InfoTooltip content="Fixed Price: agreed total. Time & Materials: billed per actual hour. Hybrid: mix of both." />
</p>
<p className="mt-2 text-lg font-semibold text-gray-900">
{PRICING_MODELS.find((m) => m.value === terms.pricingModel)?.label ??
@@ -179,7 +180,7 @@ export function CommercialTermsEditor({
{/* Pricing Model */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Pricing Model
Pricing Model <InfoTooltip content="How the project will be billed to the client." />
</label>
<select
value={terms.pricingModel}
@@ -200,7 +201,7 @@ export function CommercialTermsEditor({
{/* Contingency % */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Contingency %
Contingency % <InfoTooltip content="Risk buffer added to the base cost. Adjusted cost = base cost x (1 + contingency %)." />
</label>
<input
type="number"
@@ -219,7 +220,7 @@ export function CommercialTermsEditor({
{/* Discount % */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Discount %
Discount % <InfoTooltip content="Client discount applied to the base price. Adjusted price = base price x (1 - discount %)." />
</label>
<input
type="number"
@@ -238,7 +239,7 @@ export function CommercialTermsEditor({
{/* Payment Terms */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Payment Terms (days)
Payment Terms (days) <InfoTooltip content="Number of days after invoice date within which payment is due." />
</label>
<input
type="number"
@@ -256,7 +257,7 @@ export function CommercialTermsEditor({
{/* Warranty */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Warranty (months)
Warranty (months) <InfoTooltip content="Post-delivery warranty period during which defects are covered at no extra cost." />
</label>
<input
type="number"
@@ -275,7 +276,7 @@ export function CommercialTermsEditor({
{/* Notes */}
<div className="mt-4">
<label className="block text-xs font-medium text-gray-500 mb-1">
Notes
Notes <InfoTooltip content="Free-text notes about the commercial terms, e.g. special conditions or negotiation context." />
</label>
<textarea
value={terms.notes ?? ""}
@@ -294,7 +295,7 @@ export function CommercialTermsEditor({
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-semibold text-gray-900">
Payment Milestones
Payment Milestones <InfoTooltip content="Define when payments are due as a percentage of the adjusted price. Milestones should sum to 100%." />
</h3>
{canEdit && (
<button
@@ -8,6 +8,7 @@ import { parseScopeImport } from "~/lib/scopeImportParser.js";
import { clsx } from "clsx";
import { formatMoney } from "~/lib/format.js";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js";
import { ResourceCombobox } from "~/components/ui/ResourceCombobox.js";
import { trpc } from "~/lib/trpc/client.js";
@@ -440,7 +441,7 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600">Estimate Wizard</p>
<h2 className="mt-2 text-2xl font-semibold text-gray-900">Create a connected estimate</h2>
<p className="mt-1 text-sm text-gray-500">
Rates, resource snapshots, and project linkage are pulled from existing Planarchy data.
Rates, resource snapshots, and project linkage are pulled from existing plANARCHY data.
</p>
</div>
<button
@@ -485,19 +486,19 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<div className="space-y-5">
<div className="grid gap-5 md:grid-cols-2">
<div>
<label className={LABEL_CLS}>Estimate Name</label>
<label className={LABEL_CLS}>Estimate Name <InfoTooltip content="A descriptive name for this estimate, e.g. project name + scope qualifier." /></label>
<input value={name} onChange={(event) => setName(event.target.value)} className={INPUT_CLS} placeholder="CGI Breakdown Q2 2026" />
</div>
<div>
<label className={LABEL_CLS}>Linked Project</label>
<label className={LABEL_CLS}>Linked Project <InfoTooltip content="Link to an existing plANARCHY project. This enables automatic date-based phasing and planning handoff." /></label>
<ProjectCombobox value={projectId} onChange={setProjectId} placeholder="Link to project" />
</div>
<div>
<label className={LABEL_CLS}>Opportunity ID</label>
<label className={LABEL_CLS}>Opportunity ID <InfoTooltip content="Optional external reference from your CRM or sales system to track this opportunity." /></label>
<input value={opportunityId} onChange={(event) => setOpportunityId(event.target.value)} className={INPUT_CLS} placeholder="Optional CRM or sales reference" />
</div>
<div>
<label className={LABEL_CLS}>Estimate Status</label>
<label className={LABEL_CLS}>Estimate Status <InfoTooltip content="DRAFT: work in progress. IN_REVIEW: submitted for approval. APPROVED: locked and ready for handoff. ARCHIVED: no longer active." /></label>
<select value={status} onChange={(event) => setStatus(event.target.value as EstimateStatus)} className={SELECT_CLS}>
{Object.values(EstimateStatus).map((value) => (
<option key={value} value={value}>
@@ -507,17 +508,17 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
</select>
</div>
<div>
<label className={LABEL_CLS}>Base Currency</label>
<label className={LABEL_CLS}>Base Currency <InfoTooltip content="ISO 4217 currency code (e.g. EUR, USD) used for all monetary values in this estimate." /></label>
<input value={baseCurrency} onChange={(event) => setBaseCurrency(event.target.value.toUpperCase())} className={INPUT_CLS} maxLength={3} />
</div>
<div>
<label className={LABEL_CLS}>Version Label</label>
<label className={LABEL_CLS}>Version Label <InfoTooltip content="A label for the initial version snapshot. Use labels like 'Initial', 'Client revision 2', etc." /></label>
<input value={versionLabel} onChange={(event) => setVersionLabel(event.target.value)} className={INPUT_CLS} placeholder="Initial" />
</div>
</div>
<div>
<label className={LABEL_CLS}>Version Notes</label>
<label className={LABEL_CLS}>Version Notes <InfoTooltip content="Free-text notes for this version. Document assumptions, exclusions, or client comments." /></label>
<textarea
value={versionNotes}
onChange={(event) => setVersionNotes(event.target.value)}
@@ -551,7 +552,7 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">Commercial and delivery assumptions</h3>
<h3 className="text-lg font-semibold text-gray-900">Commercial and delivery assumptions <InfoTooltip content="Preconditions that affect the estimate validity. If an assumption changes, the estimate may need revision." /></h3>
<p className="text-sm text-gray-500">These rows replace free-form spreadsheet notes with structured data.</p>
</div>
<button type="button" onClick={() => setAssumptions((current) => [...current, makeAssumption()])} className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
@@ -579,7 +580,7 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">Scope breakdown</h3>
<h3 className="text-lg font-semibold text-gray-900">Scope breakdown <InfoTooltip content="Deliverables and work packages that define what is included in this estimate." /></h3>
<p className="text-sm text-gray-500">Create structured work packages that can later evolve into versioned estimate scope.</p>
</div>
<div className="flex gap-2">
@@ -621,7 +622,7 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">Staffing and rate lines</h3>
<h3 className="text-lg font-semibold text-gray-900">Staffing and rate lines <InfoTooltip content="Each line represents a staffing need. Line cost = hours x cost rate. Line price = hours x sell rate." /></h3>
<p className="text-sm text-gray-500">Selecting a resource pre-fills cost rate, sell rate, chapter, and role from live data.</p>
</div>
<button type="button" onClick={() => setDemandLines((current) => [...current, makeDemand()])} className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
@@ -639,11 +640,11 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<div key={line.id} className="rounded-3xl border border-gray-100 p-4">
<div className="grid gap-4 lg:grid-cols-2">
<div>
<label className={LABEL_CLS}>Resource</label>
<label className={LABEL_CLS}>Resource <InfoTooltip content="Link to a live plANARCHY resource. Auto-fills rates, chapter, and role." /></label>
<ResourceCombobox value={line.resourceId} onChange={(resourceId) => applyResource(resourceId, line.id)} placeholder="Search resource" />
</div>
<div>
<label className={LABEL_CLS}>Role</label>
<label className={LABEL_CLS}>Role <InfoTooltip content="The production role for this demand line (e.g. Compositor, Animator)." /></label>
<select value={line.roleId ?? ""} onChange={(event) => updateDemandLine(line.id, { roleId: event.target.value || null })} className={SELECT_CLS}>
<option value="">Unassigned</option>
{roles.map((role) => (
@@ -654,27 +655,27 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
</select>
</div>
<div>
<label className={LABEL_CLS}>Line Name</label>
<label className={LABEL_CLS}>Line Name <InfoTooltip content="Descriptive name for this staffing line, e.g. 'Compositing Lead' or 'PM overhead'." /></label>
<input value={line.name} onChange={(event) => updateDemandLine(line.id, { name: event.target.value })} className={INPUT_CLS} placeholder="Compositing, lighting, PM, ..." />
</div>
<div>
<label className={LABEL_CLS}>Chapter</label>
<label className={LABEL_CLS}>Chapter <InfoTooltip content="Department or cost center. Auto-filled when a resource is linked." /></label>
<input value={line.chapter} onChange={(event) => updateDemandLine(line.id, { chapter: event.target.value })} className={INPUT_CLS} placeholder="Auto-filled from resource when linked" />
</div>
<div>
<label className={LABEL_CLS}>Hours</label>
<label className={LABEL_CLS}>Hours <InfoTooltip content="Total estimated effort in hours. Used to calculate line cost and price." /></label>
<input value={line.hours} onChange={(event) => updateDemandLine(line.id, { hours: event.target.value })} className={INPUT_CLS} inputMode="decimal" />
</div>
<div>
<label className={LABEL_CLS}>Currency</label>
<label className={LABEL_CLS}>Currency <InfoTooltip content="ISO 4217 currency code for this line's rates." /></label>
<input value={line.currency} onChange={(event) => updateDemandLine(line.id, { currency: event.target.value.toUpperCase() })} className={INPUT_CLS} maxLength={3} />
</div>
<div>
<label className={LABEL_CLS}>Cost Rate / h</label>
<label className={LABEL_CLS}>Cost Rate / h <InfoTooltip content="Internal hourly cost rate in EUR. Line cost = hours x cost rate." /></label>
<input value={line.costRate} onChange={(event) => updateDemandLine(line.id, { costRate: event.target.value })} className={INPUT_CLS} inputMode="decimal" />
</div>
<div>
<label className={LABEL_CLS}>Sell Rate / h</label>
<label className={LABEL_CLS}>Sell Rate / h <InfoTooltip content="Client-facing hourly rate. Line price = hours x sell rate." /></label>
<input value={line.billRate} onChange={(event) => updateDemandLine(line.id, { billRate: event.target.value })} className={INPUT_CLS} inputMode="decimal" />
</div>
</div>
@@ -711,19 +712,19 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
<p className="text-xs uppercase tracking-wide text-gray-400">Total Hours</p>
<p className="text-xs uppercase tracking-wide text-gray-400">Total Hours <InfoTooltip content="Sum of all demand line hours across the estimate." /></p>
<p className="mt-2 text-2xl font-semibold text-gray-900">{summary.totalHours.toFixed(1)}</p>
</div>
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
<p className="text-xs uppercase tracking-wide text-gray-400">Total Cost</p>
<p className="text-xs uppercase tracking-wide text-gray-400">Total Cost <InfoTooltip content="Sum of (hours x cost rate) for each demand line. Stored in cents, displayed in EUR." /></p>
<p className="mt-2 text-2xl font-semibold text-gray-900">{formatMoney(summary.totalCostCents, baseCurrency)}</p>
</div>
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
<p className="text-xs uppercase tracking-wide text-gray-400">Total Price</p>
<p className="text-xs uppercase tracking-wide text-gray-400">Total Price <InfoTooltip content="Sum of (hours x sell rate) for each demand line. This is the client-facing revenue." /></p>
<p className="mt-2 text-2xl font-semibold text-gray-900">{formatMoney(summary.totalPriceCents, baseCurrency)}</p>
</div>
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
<p className="text-xs uppercase tracking-wide text-gray-400">Margin</p>
<p className="text-xs uppercase tracking-wide text-gray-400">Margin <InfoTooltip content="Margin = Total Price - Total Cost. Margin % = Margin / Total Price x 100." /></p>
<p className="mt-2 text-2xl font-semibold text-gray-900">
{formatMoney(marginCents, baseCurrency)} ({marginPercent}%)
</p>
@@ -17,6 +17,7 @@ import { StaffingTab } from "~/components/estimates/tabs/StaffingTab.js";
import { FinancialsTab } from "~/components/estimates/tabs/FinancialsTab.js";
import { VersionsTab } from "~/components/estimates/tabs/VersionsTab.js";
import { ExportsTab } from "~/components/estimates/tabs/ExportsTab.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { formatDateLong } from "~/lib/format.js";
import { trpc } from "~/lib/trpc/client.js";
@@ -192,7 +193,7 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
<div className="rounded-[28px] border border-gray-200 bg-gradient-to-br from-white via-white to-brand-50 p-6 shadow-sm">
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600">Estimate Workspace</p>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600">Estimate Workspace <InfoTooltip content="Central workspace for inspecting and editing an estimate's scope, staffing, financials, and version history." /></p>
<h1 className="mt-2 text-3xl font-semibold text-gray-900">
{estimate?.name ?? "Loading estimate"}
</h1>
@@ -10,6 +10,7 @@ import {
type ScopeItemDiff,
} from "@planarchy/engine";
import type { EstimateVersionView } from "~/components/estimates/EstimateWorkspace.types.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatMoney } from "~/lib/format.js";
function formatDelta(value: number, formatter: (v: number) => string) {
@@ -134,10 +135,10 @@ export function VersionCompare({ versions }: { versions: EstimateVersionView[] }
<div className="space-y-6">
{/* Version selectors */}
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-4 text-base font-semibold text-gray-900">Compare versions</h3>
<h3 className="mb-4 text-base font-semibold text-gray-900">Compare versions <InfoTooltip content="Select two version snapshots to see what changed in demand lines, scope, assumptions, and resource rates between them." /></h3>
<div className="flex flex-wrap items-end gap-4">
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Base (A)</span>
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Base (A) <InfoTooltip content="The older or reference version to compare from." /></span>
<select
value={aId}
onChange={(e) => setAId(e.target.value)}
@@ -154,7 +155,7 @@ export function VersionCompare({ versions }: { versions: EstimateVersionView[] }
<span className="pb-2 text-sm text-gray-400">vs</span>
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Compare (B)</span>
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Compare (B) <InfoTooltip content="The newer or target version to compare against." /></span>
<select
value={bId}
onChange={(e) => setBId(e.target.value)}
@@ -1,5 +1,7 @@
"use client";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
const INPUT_CLS =
"w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100";
const LABEL_CLS = "mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-500";
@@ -37,29 +39,29 @@ export function AssumptionEditor({ assumptions, onChange }: AssumptionEditorProp
<div key={assumption.id ?? `new-${index}`} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<label>
<span className={LABEL_CLS}>Category</span>
<span className={LABEL_CLS}>Category <InfoTooltip content="Groups assumptions by topic, e.g. 'commercial', 'delivery', 'technical'." /></span>
<input className={INPUT_CLS} value={assumption.category} onChange={(event) => onChange((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, category: event.target.value } : item))} />
</label>
<label>
<span className={LABEL_CLS}>Key</span>
<span className={LABEL_CLS}>Key <InfoTooltip content="Machine-readable identifier, auto-generated from label if left empty." /></span>
<input className={INPUT_CLS} value={assumption.key} onChange={(event) => onChange((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, key: event.target.value } : item))} />
</label>
<label>
<span className={LABEL_CLS}>Label</span>
<span className={LABEL_CLS}>Label <InfoTooltip content="Human-readable name for this assumption." /></span>
<input className={INPUT_CLS} value={assumption.label} onChange={(event) => onChange((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, label: event.target.value } : item))} />
</label>
<label>
<span className={LABEL_CLS}>Type</span>
<span className={LABEL_CLS}>Type <InfoTooltip content="Data type of the value, e.g. 'string', 'number', 'boolean'." /></span>
<input className={INPUT_CLS} value={assumption.valueType} onChange={(event) => onChange((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, valueType: event.target.value } : item))} />
</label>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,1fr),220px]">
<label className="block">
<span className={LABEL_CLS}>Value</span>
<span className={LABEL_CLS}>Value <InfoTooltip content="The concrete value or condition for this assumption." /></span>
<textarea className={`${INPUT_CLS} min-h-24`} value={assumption.value} onChange={(event) => onChange((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, value: event.target.value } : item))} />
</label>
<label className="block">
<span className={LABEL_CLS}>Notes</span>
<span className={LABEL_CLS}>Notes <InfoTooltip content="Additional context or rationale for this assumption." /></span>
<textarea className={`${INPUT_CLS} min-h-24`} value={assumption.notes} onChange={(event) => onChange((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, notes: event.target.value } : item))} />
</label>
</div>
@@ -10,6 +10,7 @@ import {
getEffectiveDemandLineValues,
} from "~/components/estimates/EstimateWorkspace.calculations.js";
import type { EstimateResourceSnapshotView } from "~/components/estimates/EstimateWorkspace.types.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatMoney } from "~/lib/format.js";
const INPUT_CLS =
@@ -335,7 +336,7 @@ export function DemandLineEditor({
<div className="mb-4 grid gap-4 md:grid-cols-2">
<label>
<span className={LABEL_CLS}>Linked resource</span>
<span className={LABEL_CLS}>Linked resource <InfoTooltip content="Link to a plANARCHY resource. Live-linked rates refresh automatically; manual overrides are persisted." /></span>
<select
className={INPUT_CLS}
value={line.resourceId ?? ""}
@@ -352,34 +353,34 @@ export function DemandLineEditor({
<div className="rounded-2xl bg-gray-50 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Snapshot behavior</p>
<p className="mt-1 text-sm text-gray-700">
Linked resources refresh from live Planarchy rates when a rate is set to live mode. Manual overrides are persisted on the demand line.
Linked resources refresh from live plANARCHY rates when a rate is set to live mode. Manual overrides are persisted on the demand line.
</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<label>
<span className={LABEL_CLS}>Name</span>
<span className={LABEL_CLS}>Name <InfoTooltip content="Descriptive label for this demand line, e.g. role name or resource name." /></span>
<input className={INPUT_CLS} value={line.name} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, name: event.target.value }))} />
</label>
<label>
<span className={LABEL_CLS}>Line type</span>
<span className={LABEL_CLS}>Line type <InfoTooltip content="Classification of the demand, typically LABOR. Can also be EXPENSE or SUBCONTRACTOR." /></span>
<input className={INPUT_CLS} value={line.lineType} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, lineType: event.target.value }))} />
</label>
<label>
<span className={LABEL_CLS}>Chapter</span>
<span className={LABEL_CLS}>Chapter <InfoTooltip content="Department or cost center. Auto-filled when a resource is linked." /></span>
<input className={INPUT_CLS} value={line.chapter} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, chapter: event.target.value }))} />
</label>
<label>
<span className={LABEL_CLS}>Hours</span>
<span className={LABEL_CLS}>Hours <InfoTooltip content="Estimated effort in hours. Cost total = hours x cost rate. Price total = hours x sell rate." /></span>
<input className={INPUT_CLS} value={line.hours} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, hours: event.target.value }))} />
</label>
<label>
<span className={LABEL_CLS}>Currency</span>
<span className={LABEL_CLS}>Currency <InfoTooltip content="ISO 4217 currency code for this line's rates (e.g. EUR, USD)." /></span>
<input className={INPUT_CLS} maxLength={3} value={line.currency} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, currency: event.target.value.toUpperCase() }))} />
</label>
<label>
<span className={LABEL_CLS}>Cost rate</span>
<span className={LABEL_CLS}>Cost rate <InfoTooltip content="Internal hourly cost rate. 'Live' syncs from the resource; 'Manual' allows a custom override. Line cost = hours x cost rate." /></span>
<div className="space-y-2">
<select
className={INPUT_CLS}
@@ -413,7 +414,7 @@ export function DemandLineEditor({
</div>
</label>
<label>
<span className={LABEL_CLS}>Bill rate</span>
<span className={LABEL_CLS}>Bill rate <InfoTooltip content="Client-facing hourly sell rate. 'Live' syncs from the resource; 'Manual' allows a custom override. Line price = hours x bill rate." /></span>
<div className="space-y-2">
<select
className={INPUT_CLS}
@@ -447,11 +448,11 @@ export function DemandLineEditor({
</div>
</label>
<div className="rounded-2xl bg-gray-50 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Cost total</p>
<p className="text-xs uppercase tracking-wide text-gray-400">Cost total <InfoTooltip content="hours x cost rate, stored in cents." /></p>
<p className="mt-1 text-sm font-semibold text-gray-900">
{formatMoney(effectiveValues.costTotalCents, effectiveValues.currency)}
</p>
<p className="mt-3 text-xs uppercase tracking-wide text-gray-400">Price total</p>
<p className="mt-3 text-xs uppercase tracking-wide text-gray-400">Price total <InfoTooltip content="hours x sell rate, stored in cents." /></p>
<p className="mt-1 text-sm font-semibold text-gray-900">
{formatMoney(effectiveValues.priceTotalCents, effectiveValues.currency)}
</p>
@@ -1,5 +1,6 @@
"use client";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { isSpreadsheetFile } from "~/lib/excel.js";
import { parseScopeImport } from "~/lib/scopeImportParser.js";
@@ -81,25 +82,25 @@ export function ScopeItemEditor({
<div key={item.id ?? `scope-${index}`} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="grid gap-4 md:grid-cols-3">
<label>
<span className={LABEL_CLS}>Sequence</span>
<span className={LABEL_CLS}>Sequence <InfoTooltip content="Ordering number for this scope item within the estimate." /></span>
<input className={INPUT_CLS} value={item.sequenceNo} onChange={(event) => onChange((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, sequenceNo: event.target.value } : entry))} />
</label>
<label>
<span className={LABEL_CLS}>Scope type</span>
<span className={LABEL_CLS}>Scope type <InfoTooltip content="Category of deliverable, e.g. SHOT, SEQUENCE, ASSET, or a custom type." /></span>
<input className={INPUT_CLS} value={item.scopeType} onChange={(event) => onChange((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, scopeType: event.target.value } : entry))} />
</label>
<label>
<span className={LABEL_CLS}>Package code</span>
<span className={LABEL_CLS}>Package code <InfoTooltip content="Optional reference code for tracking this scope item in external systems." /></span>
<input className={INPUT_CLS} value={item.packageCode} onChange={(event) => onChange((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, packageCode: event.target.value } : entry))} />
</label>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,0.9fr),minmax(0,1.1fr)]">
<label>
<span className={LABEL_CLS}>Name</span>
<span className={LABEL_CLS}>Name <InfoTooltip content="Short descriptive name for this deliverable or work package." /></span>
<input className={INPUT_CLS} value={item.name} onChange={(event) => onChange((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, name: event.target.value } : entry))} />
</label>
<label>
<span className={LABEL_CLS}>Description</span>
<span className={LABEL_CLS}>Description <InfoTooltip content="Detailed description of what this scope item includes." /></span>
<textarea className={`${INPUT_CLS} min-h-24`} value={item.description} onChange={(event) => onChange((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, description: event.target.value } : entry))} />
</label>
</div>
@@ -1,5 +1,6 @@
"use client";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import type {
EstimateVersionView,
EstimateWorkspaceView,
@@ -25,22 +26,22 @@ export function AssumptionsTab({ estimate }: { estimate: EstimateWorkspaceView }
return (
<div className="rounded-3xl border border-gray-200 bg-white shadow-sm">
<div className="border-b border-gray-100 px-6 py-4">
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600">Commercial and delivery assumptions</h2>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600">Commercial and delivery assumptions <InfoTooltip content="Preconditions that affect the estimate validity. If any assumption changes, the estimate may need revision." /></h2>
</div>
<div className="divide-y divide-gray-100">
{assumptions.map((assumption) => (
<div key={assumption.id} className="grid gap-3 px-6 py-4 md:grid-cols-[160px,1fr,1fr]">
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Category</p>
<p className="text-xs uppercase tracking-wide text-gray-400">Category <InfoTooltip content="Groups assumptions by topic, e.g. 'commercial', 'delivery', 'technical'." /></p>
<p className="mt-1 text-sm font-medium text-gray-900">{assumption.category}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Label</p>
<p className="text-xs uppercase tracking-wide text-gray-400">Label <InfoTooltip content="Human-readable description of the assumption. The key below is the machine-readable identifier." /></p>
<p className="mt-1 text-sm text-gray-800">{assumption.label}</p>
<p className="mt-1 text-xs text-gray-400">{assumption.key}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Value</p>
<p className="text-xs uppercase tracking-wide text-gray-400">Value <InfoTooltip content="The concrete value or condition for this assumption." /></p>
<p className="mt-1 text-sm text-gray-800">{String(assumption.value)}</p>
</div>
</div>
@@ -9,6 +9,7 @@ import type {
EstimateVersionView,
EstimateWorkspaceView,
} from "~/components/estimates/EstimateWorkspace.types.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatDateLong, formatMoney } from "~/lib/format.js";
const EXPORT_FORMATS: EstimateExportFormat[] = [
@@ -100,7 +101,7 @@ export function ExportsTab({
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600">Export delivery</h2>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600">Export delivery <InfoTooltip content="Generate downloadable files from the current estimate version. Each format includes demand lines, scope, and financial summaries." /></h2>
<p className="mt-2 text-sm text-gray-500">
Generate format-specific artifacts from the current version and download them directly from the stored serializer payload.
</p>
@@ -125,7 +126,7 @@ export function ExportsTab({
<div className="rounded-3xl border border-gray-200 bg-white shadow-sm">
<div className="border-b border-gray-100 px-6 py-4">
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600">Generated exports</h2>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600">Generated exports <InfoTooltip content="Previously generated export artifacts. XLSX/CSV contain tabular data; JSON is machine-readable; SAP/MMP are ERP integration formats." /></h2>
</div>
{exports.length === 0 ? (
<div className="px-6 py-8">
@@ -6,6 +6,7 @@ import type {
EstimateVersionView,
EstimateWorkspaceView,
} from "~/components/estimates/EstimateWorkspace.types.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatMoney } from "~/lib/format.js";
function EmptyState({ children }: { children: React.ReactNode }) {
@@ -77,24 +78,24 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
{/* Summary cards */}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">Total Cost</p>
<p className="text-xs uppercase tracking-wide text-gray-400">Total Cost <InfoTooltip content="Sum of (hours x cost rate) for all demand lines. Avg shows weighted average cost per hour." /></p>
<p className="mt-2 text-2xl font-semibold text-gray-900">{formatMoney(totals.costCents, estimate.baseCurrency)}</p>
<p className="mt-1 text-xs text-gray-500">Avg {formatMoney(Math.round(avgCostRate), estimate.baseCurrency)}/h</p>
</div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">Total Price</p>
<p className="text-xs uppercase tracking-wide text-gray-400">Total Price <InfoTooltip content="Sum of (hours x sell rate) for all demand lines. This is the total client-facing revenue." /></p>
<p className="mt-2 text-2xl font-semibold text-gray-900">{formatMoney(totals.priceCents, estimate.baseCurrency)}</p>
<p className="mt-1 text-xs text-gray-500">Avg {formatMoney(Math.round(avgBillRate), estimate.baseCurrency)}/h</p>
</div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">Margin</p>
<p className="text-xs uppercase tracking-wide text-gray-400">Margin <InfoTooltip content="Margin = Total Price - Total Cost. Margin % = Margin / Total Price x 100. Green = positive, red = negative." /></p>
<p className={clsx("mt-2 text-2xl font-semibold", marginCents >= 0 ? "text-emerald-700" : "text-red-700")}>
{formatMoney(marginCents, estimate.baseCurrency)}
</p>
<p className="mt-1 text-xs text-gray-500">{marginPercent.toFixed(1)}% of price</p>
</div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">Total Hours</p>
<p className="text-xs uppercase tracking-wide text-gray-400">Total Hours <InfoTooltip content="Sum of all demand line hours. Each demand line contributes its hours to this total." /></p>
<p className="mt-2 text-2xl font-semibold text-gray-900">{totals.hours.toFixed(1)} h</p>
<p className="mt-1 text-xs text-gray-500">{demandLines.length} demand lines</p>
</div>
@@ -102,7 +103,7 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
{/* Margin waterfall: Cost -> Margin -> Price */}
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-4 text-base font-semibold text-gray-900">Cost to price bridge</h3>
<h3 className="mb-4 text-base font-semibold text-gray-900">Cost to price bridge <InfoTooltip content="Visual waterfall: internal cost + margin = client price. Bar heights are proportional." /></h3>
<div className="flex items-end gap-1 h-32">
{(() => {
const maxVal = Math.max(totals.costCents, totals.priceCents, 1);
@@ -137,7 +138,7 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
{/* Chapter breakdown */}
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-3 text-base font-semibold text-gray-900">Breakdown by chapter</h3>
<h3 className="mb-3 text-base font-semibold text-gray-900">Breakdown by chapter <InfoTooltip content="Financial aggregation by department/chapter. Chapter margin % = (price - cost) / price x 100." /></h3>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
@@ -192,7 +193,7 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
{/* Monthly cost/price phasing */}
{sortedMonths.length > 0 && (
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-3 text-base font-semibold text-gray-900">Monthly financial phasing</h3>
<h3 className="mb-3 text-base font-semibold text-gray-900">Monthly financial phasing <InfoTooltip content="Monthly cost and price derived from each line's hourly spread. Cost = monthly hours x line cost rate. Price = monthly hours x line sell rate." /></h3>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
@@ -7,6 +7,7 @@ import type {
EstimateVersionView,
EstimateWorkspaceView,
} from "~/components/estimates/EstimateWorkspace.types.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatDateLong, formatMoney } from "~/lib/format.js";
const STATUS_STYLES: Record<EstimateStatus, string> = {
@@ -56,15 +57,15 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
<div className="mt-5 grid gap-4 md:grid-cols-2">
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Opportunity</p>
<p className="text-xs uppercase tracking-wide text-gray-400">Opportunity <InfoTooltip content="External CRM or sales reference linking this estimate to a sales opportunity." /></p>
<p className="mt-1 text-sm text-gray-800">{estimate.opportunityId ?? "Not set"}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Base currency</p>
<p className="text-xs uppercase tracking-wide text-gray-400">Base currency <InfoTooltip content="The primary currency for all monetary calculations in this estimate." /></p>
<p className="mt-1 text-sm text-gray-800">{estimate.baseCurrency}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Latest version</p>
<p className="text-xs uppercase tracking-wide text-gray-400">Latest version <InfoTooltip content="The most recent version snapshot. Each version captures a full copy of scope, demand, and financials." /></p>
<p className="mt-1 text-sm text-gray-800">
{latestVersion ? `v${latestVersion.versionNumber}${latestVersion.label ? ` - ${latestVersion.label}` : ""}` : "No version"}
</p>
@@ -86,7 +87,7 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-gray-900">Scope items</p>
<p className="text-sm font-semibold text-gray-900">Scope items <InfoTooltip content="Deliverables or work packages included in this estimate version." /></p>
<span className="text-xs text-gray-400">{latestVersion?.scopeItems.length ?? 0}</span>
</div>
<div className="mt-4 space-y-2">
@@ -104,7 +105,7 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-gray-900">Demand lines</p>
<p className="text-sm font-semibold text-gray-900">Demand lines <InfoTooltip content="Staffing demand rows with hours, cost rate, and sell rate per role or resource." /></p>
<span className="text-xs text-gray-400">{latestVersion?.demandLines.length ?? 0}</span>
</div>
<div className="mt-4 space-y-2">
@@ -124,7 +125,7 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
<aside className="space-y-4">
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-sm font-semibold text-gray-900">Summary metrics</p>
<p className="text-sm font-semibold text-gray-900">Summary metrics <InfoTooltip content="Key financial indicators derived from the latest version's demand lines." /></p>
<div className="mt-4 space-y-3">
{latestMetrics.length === 0 ? (
<p className="text-sm text-gray-400">No derived metrics available yet.</p>
@@ -140,7 +141,7 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
</div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-sm font-semibold text-gray-900">Version context</p>
<p className="text-sm font-semibold text-gray-900">Version context <InfoTooltip content="Metadata about the latest version, including its workflow status and linked records." /></p>
<div className="mt-4 space-y-3">
{latestVersion ? (
<>
@@ -1,5 +1,6 @@
"use client";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import type {
EstimateVersionView,
EstimateWorkspaceView,
@@ -24,6 +25,10 @@ export function ScopeTab({ estimate }: { estimate: EstimateWorkspaceView }) {
return (
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm text-gray-500">
<span>Scope items define the deliverables and work packages in this estimate.</span>
<InfoTooltip content="Each scope item represents a distinct deliverable (e.g. a shot, sequence, or asset). Scope items organize the estimate but do not directly affect cost calculations." />
</div>
{scopeItems.map((item) => (
<div key={item.id} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-3">
@@ -13,6 +13,7 @@ import type {
EstimateVersionView,
EstimateWorkspaceView,
} from "~/components/estimates/EstimateWorkspace.types.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatMoney } from "~/lib/format.js";
function EmptyState({ children }: { children: React.ReactNode }) {
@@ -109,7 +110,7 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
<div className="mt-5 grid gap-3 md:grid-cols-4">
<div className="rounded-2xl bg-gray-50 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Cost rate</p>
<p className="text-xs uppercase tracking-wide text-gray-400">Cost rate <InfoTooltip content="Internal hourly cost rate. Can be synced from the live resource or manually overridden." /></p>
<p className="mt-1 text-sm font-medium text-gray-900">{formatMoney(line.costRateCents, line.currency)}</p>
{linkedSnapshot && calculation.costRateMode === "manual" && (
<p className="mt-1 text-xs text-gray-500">
@@ -118,7 +119,7 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
)}
</div>
<div className="rounded-2xl bg-gray-50 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Sell rate</p>
<p className="text-xs uppercase tracking-wide text-gray-400">Sell rate <InfoTooltip content="Client-facing hourly rate. Can be synced from the live resource or manually overridden." /></p>
<p className="mt-1 text-sm font-medium text-gray-900">{formatMoney(line.billRateCents, line.currency)}</p>
{linkedSnapshot && calculation.billRateMode === "manual" && (
<p className="mt-1 text-xs text-gray-500">
@@ -127,11 +128,11 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
)}
</div>
<div className="rounded-2xl bg-gray-50 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Cost total</p>
<p className="text-xs uppercase tracking-wide text-gray-400">Cost total <InfoTooltip content="Line cost total = hours x cost rate. Stored in cents." /></p>
<p className="mt-1 text-sm font-medium text-gray-900">{formatMoney(effectiveValues.costTotalCents, effectiveValues.currency)}</p>
</div>
<div className="rounded-2xl bg-gray-50 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Price total</p>
<p className="text-xs uppercase tracking-wide text-gray-400">Price total <InfoTooltip content="Line price total = hours x sell rate. This is the client-facing revenue for this line." /></p>
<p className="mt-1 text-sm font-medium text-gray-900">{formatMoney(effectiveValues.priceTotalCents, effectiveValues.currency)}</p>
</div>
</div>
@@ -165,7 +166,7 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
if (months.length === 0) return null;
return (
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="mb-3 text-sm font-semibold text-gray-900">Aggregated monthly phasing</p>
<p className="mb-3 text-sm font-semibold text-gray-900">Aggregated monthly phasing <InfoTooltip content="Sum of hours across all demand lines per month, based on the project date range." /></p>
<div className="flex flex-wrap gap-2">
{months.map((month) => (
<div key={month} className="rounded-xl bg-gray-50 px-3 py-2 text-sm">
@@ -8,6 +8,7 @@ import type {
EstimateVersionView,
EstimateWorkspaceView,
} from "~/components/estimates/EstimateWorkspace.types.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatDateLong, formatMoney } from "~/lib/format.js";
const VERSION_STYLES: Record<EstimateVersionStatus, string> = {
@@ -74,6 +75,10 @@ export function VersionsTab({
return (
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm text-gray-500">
<span>Versions are immutable snapshots of the estimate for comparison and audit.</span>
<InfoTooltip content="Each version captures a full copy of scope, assumptions, demand lines, and metrics. WORKING versions can be edited; SUBMITTED and APPROVED versions are locked." />
</div>
{versions.map((version) => (
<div key={version.id} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-4">
@@ -2,6 +2,7 @@
import { clsx } from "clsx";
import { formatMoney } from "~/lib/format.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
interface Warning {
level: string;
@@ -66,6 +67,10 @@ export function BudgetStatusBar({
return (
<div className={clsx("space-y-1.5", className)}>
{/* Progress bar with stacked segments */}
<div className="flex items-center gap-1 mb-0.5">
<span className="text-xs text-gray-500">Budget utilization</span>
<InfoTooltip content="Visual budget bar: the solid segment shows confirmed costs, the lighter segment shows proposed costs. Green = within budget, yellow = approaching limit, red = over budget." />
</div>
<div className="relative h-3 bg-gray-100 rounded-full overflow-hidden">
{/* Confirmed segment */}
<div
@@ -4,6 +4,7 @@ import { clsx } from "clsx";
import { formatMoney } from "~/lib/format.js";
import { trpc } from "~/lib/trpc/client.js";
import { BudgetStatusBar } from "./BudgetStatusBar.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
interface BudgetStatusCardProps {
projectId: string;
@@ -104,19 +105,19 @@ export function BudgetStatusCard({ projectId }: BudgetStatusCardProps) {
{/* Numeric details grid */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Total Budget</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1 flex items-center">Total Budget<InfoTooltip content="The total approved budget for this project as set during creation or editing." /></p>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">{formatEur(budgetCents)}</p>
</div>
<div className="bg-green-50 dark:bg-green-900/30 rounded-lg p-3">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Confirmed</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1 flex items-center">Confirmed<InfoTooltip content="Sum of costs from confirmed assignments (working days x daily rate)." /></p>
<p className="text-sm font-semibold text-green-800 dark:text-green-400">{formatEur(confirmedCents)}</p>
</div>
<div className="bg-yellow-50 dark:bg-yellow-900/30 rounded-lg p-3">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Proposed</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1 flex items-center">Proposed<InfoTooltip content="Sum of costs from proposed (not yet confirmed) assignments." /></p>
<p className="text-sm font-semibold text-yellow-800 dark:text-yellow-400">{formatEur(proposedCents)}</p>
</div>
<div className={clsx("rounded-lg p-3", remainingCents < 0 ? "bg-red-50 dark:bg-red-900/30" : "bg-blue-50 dark:bg-blue-900/30")}>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Remaining</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1 flex items-center">Remaining<InfoTooltip content="Total budget minus all allocated costs (confirmed + proposed). Negative means over budget." /></p>
<p className={clsx("text-sm font-semibold", remainingCents < 0 ? "text-red-800 dark:text-red-400" : "text-blue-800 dark:text-blue-400")}>
{formatEur(remainingCents)}
</p>
@@ -125,7 +126,7 @@ export function BudgetStatusCard({ projectId }: BudgetStatusCardProps) {
{/* Win-probability weighted amount */}
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 border-t border-gray-100 dark:border-gray-700 pt-3">
<span className="text-gray-400 dark:text-gray-500">Win-probability weighted cost:</span>
<span className="text-gray-400 dark:text-gray-500 flex items-center">Win-probability weighted cost:<InfoTooltip content="Allocated cost multiplied by the project's win probability. Reflects expected cost in the pipeline." /></span>
<span className="font-medium text-gray-800 dark:text-gray-100">{formatEur(winProbabilityWeightedCents)}</span>
</div>
@@ -0,0 +1,384 @@
"use client";
import { useState, useRef } from "react";
import { trpc } from "~/lib/trpc/client.js";
interface CoverArtSectionProps {
projectId: string;
coverImageUrl?: string | null;
coverFocusY?: number;
projectColor?: string | null;
projectName: string;
canEdit: boolean;
}
export function CoverArtSection({ projectId, coverImageUrl, coverFocusY = 50, projectColor, projectName, canEdit }: CoverArtSectionProps) {
const [imageUrl, setImageUrl] = useState(coverImageUrl ?? null);
const [focusY, setFocusY] = useState(coverFocusY);
const [generating, setGenerating] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [customPrompt, setCustomPrompt] = useState("");
const [showPromptInput, setShowPromptInput] = useState(false);
const [showFocusSlider, setShowFocusSlider] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const utils = trpc.useUtils();
const { data: dalleStatus } = trpc.project.isDalleConfigured.useQuery();
const generateMutation = trpc.project.generateCover.useMutation();
const uploadMutation = trpc.project.uploadCover.useMutation();
const removeMutation = trpc.project.removeCover.useMutation();
const focusMutation = trpc.project.updateCoverFocus.useMutation();
const handleGenerate = async () => {
setError(null);
setGenerating(true);
try {
const result = await generateMutation.mutateAsync({
projectId,
...(customPrompt.trim() ? { prompt: customPrompt.trim() } : {}),
});
setImageUrl(result.coverImageUrl);
setShowPromptInput(false);
setCustomPrompt("");
void utils.project.getById.invalidate({ id: projectId });
void utils.project.listWithCosts.invalidate();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to generate cover");
} finally {
setGenerating(false);
}
};
/** Compress an image file to WebP/JPEG via canvas, targeting max 1920px and ~200-400KB output */
const compressImage = (file: File, maxDim = 1920, quality = 0.82): Promise<string> =>
new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
let { width, height } = img;
if (width > maxDim || height > maxDim) {
const scale = maxDim / Math.max(width, height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx2d = canvas.getContext("2d");
if (!ctx2d) { reject(new Error("Canvas not supported")); return; }
ctx2d.drawImage(img, 0, 0, width, height);
// Prefer WebP, fall back to JPEG
let dataUrl = canvas.toDataURL("image/webp", quality);
if (!dataUrl.startsWith("data:image/webp")) {
dataUrl = canvas.toDataURL("image/jpeg", quality);
}
resolve(dataUrl);
};
img.onerror = () => reject(new Error("Failed to load image"));
img.src = URL.createObjectURL(file);
});
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
setError("Please select an image file (PNG, JPG, WebP, etc.)");
return;
}
if (file.size > 10 * 1024 * 1024) {
setError("Image too large. Maximum upload size is 10 MB.");
return;
}
setError(null);
setUploading(true);
try {
const dataUrl = await compressImage(file);
const result = await uploadMutation.mutateAsync({
projectId,
imageDataUrl: dataUrl,
});
setImageUrl(result.coverImageUrl);
void utils.project.getById.invalidate({ id: projectId });
void utils.project.listWithCosts.invalidate();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to upload image");
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
const handleRemove = async () => {
setError(null);
try {
await removeMutation.mutateAsync({ projectId });
setImageUrl(null);
setShowFocusSlider(false);
void utils.project.getById.invalidate({ id: projectId });
void utils.project.listWithCosts.invalidate();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to remove cover");
}
};
const handleFocusSave = async () => {
try {
await focusMutation.mutateAsync({ projectId, coverFocusY: focusY });
setShowFocusSlider(false);
void utils.project.getById.invalidate({ id: projectId });
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save focus point");
}
};
const initials = projectName
.split(/\s+/)
.map((w) => w[0])
.filter(Boolean)
.slice(0, 2)
.join("")
.toUpperCase();
return (
<div className="relative overflow-hidden rounded-xl border border-gray-200 dark:border-gray-700">
{/* Cover image or placeholder */}
{imageUrl ? (
<div className="relative">
<img
src={imageUrl}
alt={`Cover art for ${projectName}`}
className="w-full object-cover"
style={{
height: "clamp(16rem, 22vw, 22rem)",
objectPosition: `center ${focusY}%`,
}}
/>
{/* Gradient overlay at bottom for readability */}
<div className="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-black/40 to-transparent" />
</div>
) : (
<div
className="flex flex-col items-center justify-center gap-1"
style={{
height: "clamp(10rem, 16vw, 14rem)",
background: projectColor
? `linear-gradient(135deg, ${projectColor}22, ${projectColor}44)`
: "linear-gradient(135deg, #e0e7ff, #c7d2fe)",
}}
>
<span
className="text-4xl font-bold opacity-30"
style={{ color: projectColor ?? "#6366f1" }}
>
{initials}
</span>
<span className="text-[10px] font-medium tracking-wide text-gray-400 dark:text-gray-500">
1024 × 1024 px
</span>
</div>
)}
{/* Controls overlay */}
{canEdit && (
<div className="absolute right-3 top-3 flex items-center gap-2">
{/* Focus point adjuster — only when image exists */}
{imageUrl && (
<button
type="button"
onClick={() => setShowFocusSlider((v) => !v)}
className="inline-flex items-center gap-1.5 rounded-lg bg-white/90 px-3 py-1.5 text-xs font-medium text-gray-700 shadow-sm backdrop-blur transition hover:bg-white dark:bg-gray-800/90 dark:text-gray-200 dark:hover:bg-gray-800"
title="Adjust vertical focus point"
>
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
Focus
</button>
)}
{/* Generate with AI */}
{dalleStatus?.configured && (
<button
type="button"
onClick={() => {
if (showPromptInput) {
handleGenerate();
} else {
setShowPromptInput(true);
}
}}
disabled={generating}
className="inline-flex items-center gap-1.5 rounded-lg bg-white/90 px-3 py-1.5 text-xs font-medium text-gray-700 shadow-sm backdrop-blur transition hover:bg-white disabled:opacity-50 dark:bg-gray-800/90 dark:text-gray-200 dark:hover:bg-gray-800"
title="Generate cover art with AI"
>
{generating ? (
<>
<svg className="h-3.5 w-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Generating...
</>
) : (
<>
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
{showPromptInput ? "Generate" : "AI Cover"}
</>
)}
</button>
)}
{/* Upload */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="inline-flex items-center gap-1.5 rounded-lg bg-white/90 px-3 py-1.5 text-xs font-medium text-gray-700 shadow-sm backdrop-blur transition hover:bg-white disabled:opacity-50 dark:bg-gray-800/90 dark:text-gray-200 dark:hover:bg-gray-800"
>
{uploading ? (
<>
<svg className="h-3.5 w-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Uploading...
</>
) : (
<>
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Upload
</>
)}
</button>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/tiff,image/bmp"
className="hidden"
onChange={handleFileSelect}
/>
{/* Remove */}
{imageUrl && (
<button
type="button"
onClick={handleRemove}
disabled={removeMutation.isPending}
className="inline-flex items-center gap-1.5 rounded-lg bg-red-500/80 px-3 py-1.5 text-xs font-medium text-white shadow-sm backdrop-blur transition hover:bg-red-600 disabled:opacity-50"
title="Remove cover art"
>
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
)}
{/* Focus point slider */}
{showFocusSlider && imageUrl && canEdit && (
<div className="absolute inset-x-0 bottom-0 bg-white/95 px-4 py-3 backdrop-blur dark:bg-gray-900/95">
<div className="flex items-center gap-3">
<label className="text-xs font-medium text-gray-600 dark:text-gray-300 whitespace-nowrap">
Focus point
</label>
<span className="text-[10px] text-gray-400">Top</span>
<input
type="range"
min={0}
max={100}
value={focusY}
onChange={(e) => setFocusY(Number(e.target.value))}
className="flex-1 accent-brand-600"
/>
<span className="text-[10px] text-gray-400">Bottom</span>
<span className="w-8 text-center text-xs tabular-nums text-gray-500">{focusY}%</span>
<button
type="button"
onClick={handleFocusSave}
disabled={focusMutation.isPending}
className="rounded-lg bg-brand-600 px-3 py-1 text-xs font-medium text-white hover:bg-brand-700 disabled:opacity-50"
>
{focusMutation.isPending ? "..." : "Save"}
</button>
<button
type="button"
onClick={() => {
setFocusY(coverFocusY);
setShowFocusSlider(false);
}}
className="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400"
>
Cancel
</button>
</div>
</div>
)}
{/* Custom prompt input (shown when AI Cover is clicked) */}
{showPromptInput && !showFocusSlider && canEdit && (
<div className="absolute inset-x-0 bottom-0 bg-white/95 p-3 backdrop-blur dark:bg-gray-900/95">
<div className="flex items-center gap-2">
<input
type="text"
value={customPrompt}
onChange={(e) => setCustomPrompt(e.target.value)}
placeholder="Optional: describe the style you want..."
className="flex-1 rounded-lg border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
onKeyDown={(e) => {
if (e.key === "Enter") handleGenerate();
if (e.key === "Escape") {
setShowPromptInput(false);
setCustomPrompt("");
}
}}
autoFocus
/>
<button
type="button"
onClick={handleGenerate}
disabled={generating}
className="rounded-lg bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
>
{generating ? "..." : "Generate"}
</button>
<button
type="button"
onClick={() => {
setShowPromptInput(false);
setCustomPrompt("");
}}
className="rounded-lg px-2 py-1.5 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400"
>
Cancel
</button>
</div>
</div>
)}
{/* Error message */}
{error && (
<div className="absolute inset-x-0 bottom-0 bg-red-50/95 px-3 py-2 text-xs text-red-700 backdrop-blur dark:bg-red-950/90 dark:text-red-300">
{error}
<button
type="button"
onClick={() => setError(null)}
className="ml-2 font-medium underline"
>
Dismiss
</button>
</div>
)}
</div>
);
}
@@ -73,7 +73,9 @@ export function ProjectAssignmentsTable({ assignments }: ProjectAssignmentsTable
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Resource</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Resource <InfoTooltip content="The person assigned to this project for the given period and role." />
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Role <InfoTooltip content="Role this allocation was created for." />
</th>
@@ -69,10 +69,10 @@ export function ProjectDemandsTable({ demands, project }: ProjectDemandsTablePro
<thead className="bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Role
Role <InfoTooltip content="The role or skill profile required for this demand position." />
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Period
Period <InfoTooltip content="Time range during which this role is needed on the project." />
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<span className="inline-flex items-center justify-end gap-0.5">
@@ -80,7 +80,9 @@ export function ProjectDemandsTable({ demands, project }: ProjectDemandsTablePro
</span>
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Hours/Day
<span className="inline-flex items-center justify-end gap-0.5">
Hours/Day <InfoTooltip content="Planned working hours per day for this demand position." />
</span>
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<span className="inline-flex items-center justify-end gap-0.5">
@@ -88,7 +90,7 @@ export function ProjectDemandsTable({ demands, project }: ProjectDemandsTablePro
</span>
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
Status <InfoTooltip content="PROPOSED = requested, CONFIRMED = approved, ACTIVE = ongoing, COMPLETED = filled, CANCELLED = removed." />
</th>
{canEdit && (
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
@@ -6,6 +6,7 @@ import { OrderType, AllocationType, ProjectStatus } from "@planarchy/shared";
import type { Project } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { DateInput } from "~/components/ui/DateInput.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
const ORDER_TYPE_OPTIONS = [
{ value: "BD", label: "BD" },
@@ -283,6 +284,7 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
<div>
<label className={labelClass} htmlFor="shortCode">
Chargecode <span className="text-red-500">*</span>
<InfoTooltip content="Unique project identifier for time tracking and cost attribution. Cannot be changed after creation." />
</label>
<input
id="shortCode"
@@ -306,6 +308,7 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
<div>
<label className={labelClass} htmlFor="name">
Name <span className="text-red-500">*</span>
<InfoTooltip content="Display name shown on the timeline and in reports." />
</label>
<input
id="name"
@@ -331,6 +334,7 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
<div>
<label className={labelClass} htmlFor="orderType">
Order Type
<InfoTooltip content="BD = Business Development (pre-sales), CHARGEABLE = billable client work, INTERNAL = internal project, OVERHEAD = non-project overhead." />
</label>
<select
id="orderType"
@@ -348,6 +352,7 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
<div>
<label className={labelClass} htmlFor="allocationType">
Allocation
<InfoTooltip content="INT = staffed with internal resources, EXT = staffed with external contractors or freelancers." />
</label>
<select
id="allocationType"
@@ -365,6 +370,7 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
<div>
<label className={labelClass} htmlFor="winProbability">
Win Probability %
<InfoTooltip content="Likelihood of winning this project (0-100%). Affects the weighted pipeline value: budget x probability." />
</label>
<input
id="winProbability"
@@ -391,6 +397,7 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
<div>
<label className={labelClass} htmlFor="utilizationCategoryId">
Utilization Category
<InfoTooltip content="Groups projects for chargeability and utilization reporting. Determines how hours count toward resource utilization." />
</label>
<select
id="utilizationCategoryId"
@@ -409,6 +416,7 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
<div>
<label className={labelClass} htmlFor="clientId">
Client
<InfoTooltip content="The client or customer this project is for. Used for filtering and reporting." />
</label>
<select
id="clientId"
@@ -437,6 +445,7 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
<div>
<label className={labelClass} htmlFor="startDate">
Start Date
<InfoTooltip content="First day of the project period. Assignments begin from this date." />
</label>
<DateInput
id="startDate"
@@ -451,6 +460,7 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
<div>
<label className={labelClass} htmlFor="endDate">
End Date
<InfoTooltip content="Last day of the project period. Must be on or after the start date." />
</label>
<DateInput
id="endDate"
@@ -466,6 +476,7 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
<div>
<label className={labelClass} htmlFor="budgetEur">
Budget ()
<InfoTooltip content="Total project budget in EUR. Tracked against the sum of assignment costs (hours x daily rate)." />
</label>
<input
id="budgetEur"
@@ -493,6 +504,7 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
<div>
<label className={labelClass} htmlFor="status">
Status
<InfoTooltip content="DRAFT = not visible on timeline, ACTIVE = in progress, ON_HOLD = paused, COMPLETED = finished, CANCELLED = abandoned." />
</label>
<select
id="status"
@@ -510,6 +522,7 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
<div>
<label className={labelClass} htmlFor="responsiblePerson">
Responsible Person
<InfoTooltip content="Project lead or account manager responsible for this project." />
</label>
<input
id="responsiblePerson"
@@ -523,6 +536,7 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
<div>
<label className={labelClass} htmlFor="projectColor">
Timeline Color
<InfoTooltip content="Custom color for this project's bars on the timeline view. Leave empty for the default color." />
</label>
<div className="flex items-center gap-2">
<input
@@ -9,6 +9,7 @@ import { uuid } from "~/lib/uuid.js";
import { DateInput } from "~/components/ui/DateInput.js";
import { SkillTagInput } from "~/components/ui/SkillTagInput.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
// ─── Constants ────────────────────────────────────────────────────────────────
@@ -186,7 +187,7 @@ function Step1({ state, onChange }: Step1Props) {
<div className="space-y-5">
{/* Blueprint picker */}
<div>
<label className={LABEL_CLS}>Project Blueprint (optional)</label>
<label className={LABEL_CLS}>Project Blueprint (optional)<InfoTooltip content="Blueprints are templates that pre-fill role presets and default settings. Selecting one loads staffing requirements into Step 3." /></label>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mb-2">
<button
type="button"
@@ -238,7 +239,7 @@ function Step1({ state, onChange }: Step1Props) {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Short code */}
<div>
<label className={LABEL_CLS}>Short Code *</label>
<label className={LABEL_CLS}>Short Code *<InfoTooltip content="Unique chargecode for this project, used for time tracking and cost attribution. Must be uppercase alphanumeric." /></label>
<input
type="text"
value={state.shortCode}
@@ -251,7 +252,7 @@ function Step1({ state, onChange }: Step1Props) {
{/* Name */}
<div>
<label className={LABEL_CLS}>Project Name *</label>
<label className={LABEL_CLS}>Project Name *<InfoTooltip content="Display name shown on the timeline and in reports." /></label>
<input
type="text"
value={state.name}
@@ -263,7 +264,7 @@ function Step1({ state, onChange }: Step1Props) {
{/* Order type */}
<div>
<label className={LABEL_CLS}>Order Type *</label>
<label className={LABEL_CLS}>Order Type *<InfoTooltip content="BD = Business Development (pre-sales), CHARGEABLE = billable client work, INTERNAL = internal project, OVERHEAD = non-project overhead." /></label>
<select
value={state.orderType}
onChange={(e) => onChange({ orderType: e.target.value })}
@@ -279,7 +280,7 @@ function Step1({ state, onChange }: Step1Props) {
{/* Allocation type */}
<div>
<label className={LABEL_CLS}>Allocation Type *</label>
<label className={LABEL_CLS}>Allocation Type *<InfoTooltip content="INT = staffed with internal resources, EXT = staffed with external contractors or freelancers." /></label>
<select
value={state.allocationType}
onChange={(e) => onChange({ allocationType: e.target.value })}
@@ -392,14 +393,14 @@ function Step2({ state, onChange }: Step2Props) {
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className={LABEL_CLS}>Start Date *</label>
<label className={LABEL_CLS}>Start Date *<InfoTooltip content="First day of the project period. Assignments and budget tracking begin from this date." /></label>
<DateInput
value={state.startDate}
onChange={(v) => onChange({ startDate: v })}
/>
</div>
<div>
<label className={LABEL_CLS}>End Date *</label>
<label className={LABEL_CLS}>End Date *<InfoTooltip content="Last day of the project period. Defines the timeline boundary for all assignments." /></label>
<DateInput
value={state.endDate}
onChange={(v) => onChange({ endDate: v })}
@@ -409,7 +410,7 @@ function Step2({ state, onChange }: Step2Props) {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className={LABEL_CLS}>Budget (EUR) *</label>
<label className={LABEL_CLS}>Budget (EUR) *<InfoTooltip content="Total project budget in EUR. Stored internally as cents. Used to track spending against assignments." /></label>
<input
type="number"
min={0}
@@ -421,7 +422,7 @@ function Step2({ state, onChange }: Step2Props) {
/>
</div>
<div>
<label className={LABEL_CLS}>Responsible Person</label>
<label className={LABEL_CLS}>Responsible Person<InfoTooltip content="Project lead or account manager. Search by name or employee ID." /></label>
<ResourcePersonPicker
value={state.responsiblePerson}
onChange={(v) => onChange({ responsiblePerson: v })}
@@ -432,6 +433,7 @@ function Step2({ state, onChange }: Step2Props) {
<div>
<label className={LABEL_CLS}>
Win Probability: <strong>{state.winProbability}%</strong>
<InfoTooltip content="Likelihood of winning this project (0-100%). Affects the weighted pipeline value: budget x probability." />
</label>
<input
type="range"
@@ -522,7 +524,7 @@ function Step3({ state, onChange }: Step3Props) {
<div key={req.id} className="border border-gray-200 rounded-lg p-3 bg-white">
<div className="flex flex-wrap items-start gap-2">
<div className="flex-1 min-w-32">
<label className="text-xs text-gray-400">Role *</label>
<label className="text-xs text-gray-400">Role *<InfoTooltip content="Select a predefined role or enter a custom role name. Defines the skill profile for this staffing demand." /></label>
{roles.length > 0 ? (
<select
value={req.roleId ?? ""}
@@ -557,7 +559,7 @@ function Step3({ state, onChange }: Step3Props) {
)}
</div>
<div className="w-20">
<label className="text-xs text-gray-400">h/day</label>
<label className="text-xs text-gray-400">h/day<InfoTooltip content="Planned working hours per day for this role." /></label>
<input
type="number"
value={req.hoursPerDay}
@@ -569,7 +571,7 @@ function Step3({ state, onChange }: Step3Props) {
/>
</div>
<div className="w-16">
<label className="text-xs text-gray-400">Count</label>
<label className="text-xs text-gray-400">Count<InfoTooltip content="Number of people needed for this role. Unfilled seats become placeholder demands until assigned." /></label>
<input
type="number"
value={req.headcount}
@@ -580,7 +582,7 @@ function Step3({ state, onChange }: Step3Props) {
/>
</div>
<div className="w-28">
<label className="text-xs text-gray-400">Budget (EUR)</label>
<label className="text-xs text-gray-400">Budget (EUR)<InfoTooltip content="Optional budget cap for this role. Tracked against actual assignment costs." /></label>
<input
type="number"
value={req.budgetCents ? req.budgetCents / 100 : ""}
@@ -606,7 +608,7 @@ function Step3({ state, onChange }: Step3Props) {
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2">
<div>
<label className="text-xs text-gray-400">Required skills</label>
<label className="text-xs text-gray-400">Required skills<InfoTooltip content="Skills a resource must have to be suggested for this role." /></label>
<SkillTagInput
value={req.requiredSkills}
onChange={(skills) => updateReq(idx, { requiredSkills: skills })}
@@ -614,7 +616,7 @@ function Step3({ state, onChange }: Step3Props) {
/>
</div>
<div>
<label className="text-xs text-gray-400">Preferred skills (optional)</label>
<label className="text-xs text-gray-400">Preferred skills (optional)<InfoTooltip content="Nice-to-have skills that boost a resource's match score but are not mandatory." /></label>
<SkillTagInput
value={req.preferredSkills ?? []}
onChange={(skills) => updateReq(idx, { preferredSkills: skills })}
@@ -622,7 +624,7 @@ function Step3({ state, onChange }: Step3Props) {
/>
</div>
<div>
<label className="text-xs text-gray-400">Chapter filter (optional)</label>
<label className="text-xs text-gray-400">Chapter filter (optional)<InfoTooltip content="Restrict suggestions to resources from a specific chapter/department." /></label>
<input
type="text"
value={req.chapter ?? ""}
@@ -838,6 +840,10 @@ function Step4({ state, onChange }: Step4Props) {
return (
<div className="space-y-5 max-h-[55vh] overflow-y-auto pr-1">
<p className="text-xs text-gray-500 flex items-center gap-1">
AI-powered resource suggestions based on skills, availability, and utilization.
<InfoTooltip content="Resources are ranked by skill match score, current utilization, and availability in the project period. Assign resources here or leave unfilled to create placeholder demands." />
</p>
{state.staffingReqs.map((req) => (
<div key={req.id} className="border border-gray-200 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
@@ -876,6 +882,10 @@ function Step5({ state, onChange, onSubmit, isSubmitting, submitError }: Step5Pr
return (
<div className="space-y-4">
{/* Project summary */}
<div className="flex items-center gap-1 mb-1">
<p className="text-xs font-medium text-gray-600 uppercase tracking-wider">Project Summary</p>
<InfoTooltip content="Review all project details before creation. The project, staffing demands, and any pre-assigned resources will be created together." />
</div>
<div className="bg-gray-50 rounded-lg p-4 text-sm space-y-2">
<div className="grid grid-cols-2 gap-x-6 gap-y-1">
<div>
@@ -941,8 +951,9 @@ function Step5({ state, onChange, onSubmit, isSubmitting, submitError }: Step5Pr
{/* Draft toggle */}
<div className="border border-gray-200 rounded-lg p-4">
<p className="text-xs font-medium text-gray-600 uppercase tracking-wider mb-3">
<p className="text-xs font-medium text-gray-600 uppercase tracking-wider mb-3 flex items-center gap-1">
Save as
<InfoTooltip content="Draft projects are hidden from the timeline until activated. Active projects appear on the timeline immediately." />
</p>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
@@ -63,7 +63,7 @@ export function AllocationReport({ title, generatedAt, rows }: AllocationReportP
))}
</View>
<Text style={styles.footer}>Planarchy · Confidential · {rows.length} allocations</Text>
<Text style={styles.footer}>plANARCHY · Confidential · {rows.length} allocations</Text>
</Page>
</Document>
);
@@ -2,6 +2,7 @@
import React, { useState, useMemo, useCallback } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
// ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -333,13 +334,13 @@ export function ChargeabilityReportClient() {
<div className="space-y-1 text-xs text-gray-500 dark:text-gray-400">
<div>Mgmt: {r.mgmtGroup ?? "—"} / {r.mgmtLevel ?? "—"}</div>
<div className="mt-2 grid grid-cols-7 gap-1 text-[10px] uppercase tracking-[0.16em] text-gray-400 dark:text-gray-500">
<span className="font-medium">Chg</span>
<span className="font-medium">BD</span>
<span className="font-medium">MD&I</span>
<span className="font-medium">M&O</span>
<span className="font-medium">PD&R</span>
<span className="font-medium">Abs</span>
<span className="font-medium">Free</span>
<span className="font-medium inline-flex items-center gap-0.5">Chg<InfoTooltip content="Chargeability: % of available hours on chargeable work." /></span>
<span className="font-medium inline-flex items-center gap-0.5">BD<InfoTooltip content="Business Development hours as % of available time." /></span>
<span className="font-medium inline-flex items-center gap-0.5">MD&I<InfoTooltip content="Market Development & Innovation hours %." /></span>
<span className="font-medium inline-flex items-center gap-0.5">M&O<InfoTooltip content="Management & Overhead hours %." /></span>
<span className="font-medium inline-flex items-center gap-0.5">PD&R<InfoTooltip content="People Development & Recruiting hours %." /></span>
<span className="font-medium inline-flex items-center gap-0.5">Abs<InfoTooltip content="Absence (vacation, sick) as % of working time." /></span>
<span className="font-medium inline-flex items-center gap-0.5">Free<InfoTooltip content="Unassigned / free capacity as % of available hours." /></span>
</div>
</div>
</td>
@@ -438,22 +439,22 @@ export function ChargeabilityReportClient() {
{data ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="app-surface p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Resources</div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500 inline-flex items-center gap-0.5">Resources<InfoTooltip content="Number of active resources matching the current filters." /></div>
<div className="mt-2 text-3xl font-semibold text-gray-900 dark:text-gray-100">{filteredResources.length}</div>
<div className="mt-1 text-sm text-gray-500">People in the current filter scope</div>
</div>
<div className="app-surface p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Average Chargeability</div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500 inline-flex items-center gap-0.5">Average Chargeability<InfoTooltip content="FTE-weighted average chargeability across all visible resources. Formula: sum(FTE x Chg%) / sum(FTE)." /></div>
<div className={`mt-2 text-3xl font-semibold ${chgColor(averageChargeability, averageTarget)}`}>{pct(averageChargeability)}</div>
<div className="mt-1 text-sm text-gray-500">Weighted across visible resources</div>
</div>
<div className="app-surface p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Average Target</div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500 inline-flex items-center gap-0.5">Average Target<InfoTooltip content="FTE-weighted average chargeability target set per resource. The benchmark for actual performance." /></div>
<div className="mt-2 text-3xl font-semibold text-gray-900 dark:text-gray-100">{pct(averageTarget)}</div>
<div className="mt-1 text-sm text-gray-500">Planning target for the same population</div>
</div>
<div className="app-surface p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Average Gap</div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500 inline-flex items-center gap-0.5">Average Gap<InfoTooltip content="Chargeability minus target. Green = above target, red = below target." /></div>
<div className={`mt-2 text-3xl font-semibold ${averageGap >= 0 ? "text-green-700 dark:text-green-400" : "text-red-700 dark:text-red-400"}`}>
{averageGap > 0 ? "+" : ""}{pct(averageGap)}
</div>
@@ -585,8 +586,8 @@ export function ChargeabilityReportClient() {
<th className="sticky left-0 z-10 min-w-[240px] bg-gray-50/95 px-4 py-3 text-left backdrop-blur dark:bg-gray-800/95">
Resource
</th>
<th className="w-20 px-3 py-3 text-center">FTE</th>
<th className="w-24 px-3 py-3 text-center">Target</th>
<th className="w-20 px-3 py-3 text-center"><span className="inline-flex items-center justify-center gap-0.5">FTE<InfoTooltip content="Full-Time Equivalent. 1.0 = full-time, 0.5 = half-time. Used to weight chargeability averages." /></span></th>
<th className="w-24 px-3 py-3 text-center"><span className="inline-flex items-center justify-center gap-0.5">Target<InfoTooltip content="Chargeability target for this resource. Cells are green/yellow/red relative to this target." /></span></th>
{data.monthKeys.map((key) => (
<th key={key} className="min-w-[96px] px-3 py-3 text-center">
{formatMonth(key)}
@@ -10,6 +10,7 @@ import { SkillRadarChart } from "./SkillRadarChart.js";
import { AiSummaryCard } from "./AiSummaryCard.js";
import { SkillMatrixUpload } from "./SkillMatrixUpload.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
interface ResourceDetailProps {
resourceId: string;
@@ -46,10 +47,10 @@ const allocationStatusColor: Record<string, string> = {
CANCELLED: "bg-red-100 text-red-500",
};
function StatCard({ label, value, sub }: { label: string; value: string | number; sub?: string }) {
function StatCard({ label, value, sub, tooltip }: { label: string; value: string | number; sub?: string; tooltip?: string }) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-xs text-gray-500 mb-1">{label}</div>
<div className="text-xs text-gray-500 mb-1 flex items-center">{label}{tooltip && <InfoTooltip content={tooltip} />}</div>
<div className="text-xl font-bold text-gray-900">{value}</div>
{sub && <div className="text-xs text-gray-400 mt-0.5">{sub}</div>}
</div>
@@ -276,22 +277,26 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
<StatCard
label="LCR"
value={`${(resource.lcrCents / 100).toFixed(0)} ${resource.currency}/h`}
tooltip="Loaded Cost Rate: fully-loaded hourly cost including salary, benefits, and overhead. Used in budget calculations."
/>
)}
{canViewCosts && (
<StatCard
label="UCR"
value={`${(resource.ucrCents / 100).toFixed(0)} ${resource.currency}/h`}
tooltip="Unit Cost Rate: the rate charged to the client or project for this resource's time."
/>
)}
<StatCard
label="Chargeability Target"
value={`${resource.chargeabilityTarget}%`}
tooltip="The percentage of working time this resource is expected to spend on chargeable/billable work."
/>
{canViewCosts && (
<StatCard
label="Actual (this month)"
value={chargeStats != null ? `${chargeStats.actualChargeability}%` : "—"}
tooltip="Actual chargeability = chargeable hours / total available hours x 100 for the current month."
sub={
includeProposedChargeability
? "Incl. proposed + imported TBD planning"
@@ -303,12 +308,14 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
<StatCard
label="Expected (this month)"
value={chargeStats != null ? `${chargeStats.expectedChargeability}%` : "—"}
tooltip="Expected chargeability based on all non-cancelled bookings for the current month."
sub="All non-cancelled bookings"
/>
)}
<StatCard
label="Hours This Month"
value={`${Math.round(totalHoursThisMonth)}h`}
tooltip="Sum of allocated hours for all active projects in the current calendar month."
sub={`${activeProjectIds.size} active project${activeProjectIds.size !== 1 ? "s" : ""}`}
/>
{canViewScores && resourceWithMeta.valueScore != null && (
@@ -316,6 +323,7 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
<StatCard
label="Value Score"
value={resourceWithMeta.valueScore}
tooltip="Composite score (0-100) combining skill depth, breadth, cost efficiency, chargeability, and experience. Hover for breakdown."
sub="Price/Quality"
/>
{resourceWithMeta.valueScoreBreakdown && (
@@ -391,7 +399,7 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{/* Main Skills Badges */}
{mainSkills.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h2 className="text-sm font-semibold text-gray-800 mb-3">Main Skills</h2>
<h2 className="text-sm font-semibold text-gray-800 mb-3 flex items-center">Main Skills<InfoTooltip content="Up to 2 skills flagged as primary strengths. Used for staffing matching priority." /></h2>
<div className="flex flex-wrap gap-2">
{mainSkills.map((s) => (
<span
@@ -415,7 +423,7 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{/* Roles */}
{resourceRoles.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h2 className="text-sm font-semibold text-gray-800 mb-3">Roles</h2>
<h2 className="text-sm font-semibold text-gray-800 mb-3 flex items-center">Roles<InfoTooltip content="Job functions assigned to this resource. The primary role is used in staffing and timeline displays." /></h2>
<div className="flex flex-wrap gap-2">
{resourceRoles.map((rr) => (
<span
@@ -438,7 +446,7 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{/* Skills */}
{skills.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h2 className="text-sm font-semibold text-gray-800 mb-3">Skills</h2>
<h2 className="text-sm font-semibold text-gray-800 mb-3 flex items-center">Skills<InfoTooltip content="Full skill inventory with proficiency level (1-5) and years of experience. Imported via skill matrix XLSX." /></h2>
<div className="flex flex-wrap gap-2">
{skills.map((s) => (
<span
@@ -5,6 +5,7 @@ import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import type { Resource, SkillEntry } from "@planarchy/shared";
import { GERMAN_FEDERAL_STATES, inferStateFromPostalCode, ResourceType } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
interface RoleAssignment {
roleId: string;
@@ -372,7 +373,7 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
<div className="grid grid-cols-2 gap-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-eid">
Employee ID <span className="text-red-500">*</span>
Employee ID <span className="text-red-500">*</span><InfoTooltip content="Unique employee identifier (e.g. EMP-042). Used for imports and cross-referencing." />
</label>
<input
id="rm-eid"
@@ -386,7 +387,7 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-displayName">
Display Name <span className="text-red-500">*</span>
Display Name <span className="text-red-500">*</span><InfoTooltip content="Full name shown in the timeline, reports, and staffing views." />
</label>
<input
id="rm-displayName"
@@ -444,7 +445,7 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-roleId">
Area of Expertise <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
Area of Expertise <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span><InfoTooltip content="The resource's primary area role. Used for skill matrix grouping and AI summary generation." />
</label>
<select
id="rm-roleId"
@@ -464,7 +465,7 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-postalCode">
Postal Code (PLZ) <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
Postal Code (PLZ) <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span><InfoTooltip content="German postal code. Used to auto-derive the federal state for public holiday calculations." />
</label>
<input
id="rm-postalCode"
@@ -487,7 +488,7 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-federalState">
Federal State <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
Federal State <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span><InfoTooltip content="Determines which public holidays apply (e.g. Bavaria has extra holidays). Auto-derived from postal code." />
</label>
<select
id="rm-federalState"
@@ -509,7 +510,7 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
<div className="grid grid-cols-2 gap-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-enterpriseId">
Enterprise ID <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
Enterprise ID <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span><InfoTooltip content="Corporate directory ID for cross-system integration (e.g. a.kasperovich)." />
</label>
<input
id="rm-enterpriseId"
@@ -522,7 +523,7 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-fte">
FTE
FTE<InfoTooltip content="Full-Time Equivalent (0.01-1.0). A value of 0.5 means the resource works 50% of standard hours." />
</label>
<input
id="rm-fte"
@@ -608,7 +609,7 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-mgmtGroupId">Management Level Group</label>
<label className={LABEL_CLASS} htmlFor="rm-mgmtGroupId">Management Level Group<InfoTooltip content="Seniority grouping (e.g. Associate, Manager, Director). Determines the available management levels." /></label>
<select
id="rm-mgmtGroupId"
className={INPUT_CLASS}
@@ -625,7 +626,7 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
</select>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-mgmtLevelId">Management Level</label>
<label className={LABEL_CLASS} htmlFor="rm-mgmtLevelId">Management Level<InfoTooltip content="Specific seniority level within the group. Used in chargeability reports and cost analysis." /></label>
<select
id="rm-mgmtLevelId"
className={INPUT_CLASS}
@@ -643,7 +644,7 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
<div className="grid grid-cols-4 gap-4 mt-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-resourceType">Resource Type</label>
<label className={LABEL_CLASS} htmlFor="rm-resourceType">Resource Type<InfoTooltip content="Employee, contractor, or freelancer. Affects cost attribution rules." /></label>
<select
id="rm-resourceType"
className={INPUT_CLASS}
@@ -696,7 +697,7 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
<div className="grid grid-cols-2 gap-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-lcr">
LCR &euro;/h <span className="text-red-500">*</span>
LCR &euro;/h <span className="text-red-500">*</span><InfoTooltip content="Loaded Cost Rate in EUR per hour. E.g. 85 = 85.00 EUR/h. Stored internally as integer cents (8500)." />
</label>
<input
id="rm-lcr"
@@ -712,7 +713,7 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-ucr">
UCR &euro;/h <span className="text-red-500">*</span>
UCR &euro;/h <span className="text-red-500">*</span><InfoTooltip content="Unit Cost Rate in EUR per hour. The rate billed to the project or client." />
</label>
<input
id="rm-ucr"
@@ -743,7 +744,7 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-chargeability">
Chargeability Target %
Chargeability Target %<InfoTooltip content="Target % of working time on chargeable projects. E.g. 80 means 80% of hours should be billable." />
</label>
<input
id="rm-chargeability"
@@ -169,7 +169,7 @@ export function SkillMatrixUpload({ resourceId, isOwner, onClose, onSuccess }: P
{preview.matchedRoleName && (
<p className="text-xs text-gray-600">
<span className="font-medium">Area of expertise</span> matched to Planarchy role:{" "}
<span className="font-medium">Area of expertise</span> matched to plANARCHY role:{" "}
<span className="font-semibold text-brand-700">{preview.matchedRoleName}</span>
</p>
)}
+4 -3
View File
@@ -4,6 +4,7 @@ import { useRef, useState } from "react";
import type { RoleWithResourceCount } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
const PRESET_COLORS = [
"#6366f1",
@@ -110,7 +111,7 @@ export function RoleModal({ role, onClose, onSuccess }: RoleModalProps) {
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
<div>
<label className={labelClass}>
Name <span className="text-red-500">*</span>
Name <span className="text-red-500">*</span><InfoTooltip content="Role name shown in timelines, allocation pickers, and staffing demands (e.g. 3D Artist, Producer)." />
</label>
<input
type="text"
@@ -127,7 +128,7 @@ export function RoleModal({ role, onClose, onSuccess }: RoleModalProps) {
</div>
<div>
<label className={labelClass}>Description</label>
<label className={labelClass}>Description<InfoTooltip content="Optional description of this role's responsibilities." /></label>
<input
type="text"
value={description}
@@ -139,7 +140,7 @@ export function RoleModal({ role, onClose, onSuccess }: RoleModalProps) {
</div>
<div>
<label className={labelClass}>Color</label>
<label className={labelClass}>Color<InfoTooltip content="Color used in timeline bars and badge chips for this role." /></label>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/70">
<div className="mb-3 flex items-center gap-3">
<div
@@ -8,6 +8,7 @@ import { FilterChips } from "~/components/ui/FilterChips.js";
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { useTableSort } from "~/hooks/useTableSort.js";
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
export function RolesClient() {
const [search, setSearch] = useState("");
@@ -146,9 +147,10 @@ export function RolesClient() {
sortField={sortField}
sortDir={sortDir}
onSort={toggle}
tooltip="Role name (e.g. 3D Artist, Producer). Shown in timeline bars and staffing views."
/>
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">
Description
<span className="inline-flex items-center gap-0.5">Description<InfoTooltip content="Optional description of the role's responsibilities and typical work." /></span>
</th>
<SortableColumnHeader
label="Resources"
@@ -4,6 +4,7 @@ import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { DateInput } from "~/components/ui/DateInput.js";
import { SkillTagInput } from "~/components/ui/SkillTagInput.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
export function StaffingPanel() {
const [requiredSkills, setRequiredSkills] = useState<string[]>(["TypeScript", "React"]);
@@ -41,7 +42,7 @@ export function StaffingPanel() {
<div className="app-surface max-w-xl p-4">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">How scoring works</p>
<p className="mt-1 text-sm text-gray-500">
Planarchy blends skill fit, free capacity, cost, and current utilization. Add the must-have skills first, then narrow the date window to get cleaner results.
plANARCHY blends skill fit, free capacity, cost, and current utilization. Add the must-have skills first, then narrow the date window to get cleaner results.
</p>
</div>
</div>
@@ -54,7 +55,7 @@ export function StaffingPanel() {
<div className="mt-6 space-y-5">
<div>
<label className="app-label">Required Skills</label>
<label className="app-label">Required Skills<InfoTooltip content="Skills the candidate must have. The engine scores overlap and proficiency against this list." /></label>
<SkillTagInput
value={requiredSkills}
onChange={setRequiredSkills}
@@ -84,7 +85,7 @@ export function StaffingPanel() {
</div>
<div>
<label className="app-label">Hours per Day</label>
<label className="app-label">Hours per Day<InfoTooltip content="Required hours per day for the role. Used to check conflicts and estimate capacity." /></label>
<input
type="number"
value={hoursPerDay}
@@ -150,16 +151,16 @@ export function StaffingPanel() {
</div>
</div>
<div className="rounded-2xl border border-brand-200 bg-brand-50 px-4 py-3 text-right dark:border-brand-900/50 dark:bg-brand-900/20">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-brand-700 dark:text-brand-200">Match Score</div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-brand-700 dark:text-brand-200 inline-flex items-center gap-0.5">Match Score<InfoTooltip content="Composite score (0-100) blending skill fit, free capacity, cost efficiency, and current utilization." /></div>
<div className="mt-1 text-3xl font-semibold text-brand-700 dark:text-brand-100">{suggestion.score}</div>
</div>
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<ScoreBar label="Skills" value={suggestion.scoreBreakdown.skillScore} />
<ScoreBar label="Availability" value={suggestion.scoreBreakdown.availabilityScore} />
<ScoreBar label="Cost" value={suggestion.scoreBreakdown.costScore} />
<ScoreBar label="Utilization" value={suggestion.scoreBreakdown.utilizationScore} />
<ScoreBar label="Skills" value={suggestion.scoreBreakdown.skillScore} tooltip="Quality of skill overlap with the requested stack, weighted by proficiency level." />
<ScoreBar label="Availability" value={suggestion.scoreBreakdown.availabilityScore} tooltip="Free capacity during the selected period, accounting for existing bookings and vacations." />
<ScoreBar label="Cost" value={suggestion.scoreBreakdown.costScore} tooltip="Cost efficiency based on the resource's LCR relative to the team average." />
<ScoreBar label="Utilization" value={suggestion.scoreBreakdown.utilizationScore} tooltip="Current workload. Higher score means more capacity available (lower utilization)." />
</div>
<div className="mt-4 flex flex-wrap gap-2">
@@ -205,10 +206,10 @@ export function StaffingPanel() {
);
}
function ScoreBar({ label, value }: { label: string; value: number }) {
function ScoreBar({ label, value, tooltip }: { label: string; value: number; tooltip?: string }) {
return (
<div className="rounded-2xl border border-gray-200 bg-gray-50/70 p-3 dark:border-gray-700 dark:bg-gray-900/40">
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">{label}</div>
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500 inline-flex items-center gap-0.5">{label}{tooltip && <InfoTooltip content={tooltip} />}</div>
<div className="h-2 rounded-full bg-gray-200/80 dark:bg-gray-800">
<div
className="h-full rounded-full bg-brand-500"
@@ -1,6 +1,7 @@
"use client";
import { trpc } from "~/lib/trpc/client.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
interface BalanceCardProps {
resourceId: string;
@@ -52,15 +53,15 @@ export function BalanceCard({ resourceId, year = new Date().getFullYear(), compa
Vacation Balance {year}
</h3>
{balance.carryoverDays > 0 && (
<span className="text-xs text-gray-400 dark:text-gray-500">+{balance.carryoverDays}d carried over</span>
<span className="text-xs text-gray-400 dark:text-gray-500 inline-flex items-center">+{balance.carryoverDays}d carried over<InfoTooltip content="Unused days from the previous year. Automatically calculated on first access." /></span>
)}
</div>
<div className="grid grid-cols-4 gap-3">
<Stat label="Entitled" value={balance.entitledDays} color="text-gray-900" />
<Stat label="Used" value={balance.usedDays} color="text-gray-600" />
<Stat label="Pending" value={balance.pendingDays} color="text-amber-600" />
<Stat label="Remaining" value={balance.remainingDays} color={balance.remainingDays < 5 ? "text-red-600" : "text-emerald-600"} />
<Stat label="Entitled" value={balance.entitledDays} color="text-gray-900" tooltip="Total vacation days granted for this year, including carryover from previous year." />
<Stat label="Used" value={balance.usedDays} color="text-gray-600" tooltip="Days already consumed by approved vacations that have passed." />
<Stat label="Pending" value={balance.pendingDays} color="text-amber-600" tooltip="Days reserved by approved future vacations not yet started." />
<Stat label="Remaining" value={balance.remainingDays} color={balance.remainingDays < 5 ? "text-red-600" : "text-emerald-600"} tooltip="Entitled - Used - Pending. Red if fewer than 5 days remain." />
</div>
{/* Progress bar */}
@@ -89,11 +90,11 @@ export function BalanceCard({ resourceId, year = new Date().getFullYear(), compa
);
}
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
function Stat({ label, value, color, tooltip }: { label: string; value: number; color: string; tooltip?: string }) {
return (
<div className="text-center">
<p className={`text-xl font-bold ${color}`}>{value}</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{label}</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5 inline-flex items-center">{label}{tooltip && <InfoTooltip content={tooltip} />}</p>
</div>
);
}
@@ -3,6 +3,7 @@
import { useState } from "react";
import { VacationStatus } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
const TYPE_COLOR: Record<string, string> = {
ANNUAL: "bg-brand-500",
@@ -119,7 +120,7 @@ export function TeamCalendar() {
<thead>
<tr className="bg-gray-50 dark:bg-gray-900 border-b border-gray-100 dark:border-gray-700">
<th className="sticky left-0 z-10 bg-gray-50 dark:bg-gray-900 px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 w-36 border-r border-gray-100 dark:border-gray-700">
Resource
<span className="inline-flex items-center gap-0.5">Resource<InfoTooltip content="Matrix view: each row is a resource, each column is a calendar day. Colored cells indicate vacation days (color = type, faded = pending)." width="w-72" /></span>
</th>
{days.map((d) => {
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
@@ -331,7 +331,7 @@ export function VacationClient() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
<SortableColumnHeader label="Resource" field="resource" sortField={sortField} sortDir={sortDir} onSort={handleSort} />
<SortableColumnHeader label="Resource" field="resource" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="The employee this vacation entry belongs to." />
<th className="px-3 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">
<span className="inline-flex items-center gap-0.5">
<button
@@ -344,8 +344,8 @@ export function VacationClient() {
<InfoTooltip content="ANNUAL = paid annual leave · SICK = sick leave · PUBLIC_HOLIDAY = public holiday · OTHER = other leave types." />
</span>
</th>
<SortableColumnHeader label="Start" field="startDate" sortField={sortField} sortDir={sortDir} onSort={handleSort} />
<SortableColumnHeader label="End" field="endDate" sortField={sortField} sortDir={sortDir} onSort={handleSort} />
<SortableColumnHeader label="Start" field="startDate" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="First day of the leave period (inclusive). Shows a half-day indicator if applicable." />
<SortableColumnHeader label="End" field="endDate" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="Last day of the leave period (inclusive)." />
<th className="px-3 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">
<span className="inline-flex items-center gap-0.5">
<button
@@ -6,6 +6,7 @@ import { VacationType } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { DateInput } from "~/components/ui/DateInput.js";
import { useDebounce } from "~/hooks/useDebounce.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
const VACATION_TYPES = Object.values(VacationType);
@@ -146,7 +147,7 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
{!initialResourceId && (
<div>
<label htmlFor="vac-resource" className={labelClass}>
Resource <span className="text-red-500">*</span>
Resource <span className="text-red-500">*</span><InfoTooltip content="The employee this vacation request is for." />
</label>
<select
id="vac-resource"
@@ -168,7 +169,7 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
{/* Type */}
<div>
<label htmlFor="vac-type" className={labelClass}>
Type <span className="text-red-500">*</span>
Type <span className="text-red-500">*</span><InfoTooltip content="ANNUAL = paid leave (deducted from entitlement) · SICK = sick leave · PUBLIC_HOLIDAY = national/regional holiday · OTHER = special leave." />
</label>
<select
id="vac-type"
@@ -188,7 +189,7 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="vac-start" className={labelClass}>
Start Date <span className="text-red-500">*</span>
Start Date <span className="text-red-500">*</span><InfoTooltip content="First day of leave (inclusive)." />
</label>
<DateInput
id="vac-start"
@@ -200,7 +201,7 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
</div>
<div>
<label htmlFor="vac-end" className={labelClass}>
End Date <span className="text-red-500">*</span>
End Date <span className="text-red-500">*</span><InfoTooltip content="Last day of leave (inclusive)." />
</label>
<DateInput
id="vac-end"
@@ -222,7 +223,7 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
onChange={(e) => setIsHalfDay(e.target.checked)}
className="rounded border-gray-300 dark:border-gray-600 text-brand-600 focus:ring-brand-500"
/>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Half day</span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Half day</span><InfoTooltip content="Request only half a day off (morning or afternoon). Counts as 0.5 days against entitlement." />
</label>
{isHalfDay && (
<div className="flex gap-3">
+1 -1
View File
@@ -13,7 +13,7 @@ export interface ParsedSkillMatrix {
skills: SkillEntry[];
}
// Maps Excel proficiency (1-4) → Planarchy proficiency (2-5)
// Maps Excel proficiency (1-4) → plANARCHY proficiency (2-5)
function mapProficiency(raw: string): 1 | 2 | 3 | 4 | 5 | null {
const n = parseInt(raw, 10);
if (isNaN(n) || n === 0) return null;
+29
View File
@@ -8,6 +8,9 @@ type AiSettings = {
azureApiVersion?: string | null;
aiMaxCompletionTokens?: number | null;
aiTemperature?: number | null;
azureDalleDeployment?: string | null;
azureDalleEndpoint?: string | null;
azureDalleApiKey?: string | null;
};
/** Returns true if the settings have enough information to make an API call. */
@@ -31,6 +34,32 @@ export function createAiClient(settings: AiSettings): OpenAI {
return new OpenAI({ apiKey: settings.azureOpenAiApiKey! });
}
/** Returns true if DALL-E image generation is configured. */
export function isDalleConfigured(settings: AiSettings | null | undefined): boolean {
if (!settings) return false;
// DALL-E needs its own deployment (or a non-Azure key with model name)
if (settings.aiProvider === "azure") {
return !!(settings.azureDalleDeployment && (settings.azureDalleEndpoint || settings.azureOpenAiEndpoint) && (settings.azureDalleApiKey || settings.azureOpenAiApiKey));
}
// For direct OpenAI, the chat API key works for DALL-E too
return !!settings.azureOpenAiApiKey;
}
/** Creates an OpenAI client configured for DALL-E image generation. */
export function createDalleClient(settings: AiSettings): OpenAI {
if (settings.aiProvider === "azure") {
const endpoint = settings.azureDalleEndpoint || settings.azureOpenAiEndpoint!;
const apiKey = settings.azureDalleApiKey || settings.azureOpenAiApiKey!;
return new AzureOpenAI({
endpoint,
apiKey,
apiVersion: settings.azureApiVersion ?? "2025-01-01-preview",
deployment: settings.azureDalleDeployment!,
});
}
return new OpenAI({ apiKey: settings.azureOpenAiApiKey! });
}
/** Turns raw API errors into actionable human-readable messages. */
export function parseAiError(err: unknown): string {
const msg = err instanceof Error ? err.message : String(err);
File diff suppressed because it is too large Load Diff
+37 -6
View File
@@ -1,6 +1,6 @@
/**
* AI Assistant router provides a chat endpoint that uses OpenAI Function Calling
* to answer questions about Planarchy data and modify resources/projects.
* to answer questions about plANARCHY data and modify resources/projects.
*/
import { z } from "zod";
@@ -12,13 +12,19 @@ import { TOOL_DEFINITIONS, executeTool, type ToolContext, type ToolAction } from
const MAX_TOOL_ITERATIONS = 8;
const SYSTEM_PROMPT = `Du bist der Planarchy-Assistent — ein hilfreicher AI-Assistent für Ressourcenplanung und Projektmanagement in einer 3D-Produktionsumgebung.
const SYSTEM_PROMPT = `Du bist der plANARCHY-Assistent — ein hilfreicher AI-Assistent für Ressourcenplanung und Projektmanagement in einer 3D-Produktionsumgebung.
Deine Fähigkeiten:
- Fragen über Ressourcen, Projekte, Allokationen, Budget, Urlaub, Estimates, Org-Struktur beantworten
- Chargeability-Analysen, Urlaubsübersichten, Budget-Analysen
- Ressourcen/Projekte aktualisieren, Allokationen erstellen/stornieren (nur mit Berechtigung + expliziter Bestätigung)
- Fragen über Ressourcen, Projekte, Allokationen, Budget, Urlaub, Estimates, Org-Struktur, Rollen, Blueprints, Rate Cards beantworten
- Chargeability-Analysen, Urlaubsübersichten, Budget-Analysen, Staffing-Vorschläge, Kapazitätssuche
- Ressourcen erstellen/aktualisieren/deaktivieren, Projekte erstellen/aktualisieren/löschen
- Allokationen erstellen/stornieren, Demands erstellen/besetzen, Staffing-Vorschläge abrufen
- Urlaub erstellen/genehmigen/ablehnen/stornieren, Ansprüche verwalten
- Rollen, Clients, Org-Units erstellen/aktualisieren/löschen
- Estimates erstellen, Rate Cards abrufen, Blueprints anzeigen
- Notifications anzeigen, Dashboard-Details abrufen
- Den User zu relevanten Seiten navigieren (Timeline, Dashboard, etc. mit Filtern)
- Verfügbarkeit von Ressourcen prüfen, Kapazitäten suchen
Wichtige Regeln:
- Antworte in der Sprache des Users (Deutsch oder Englisch)
@@ -39,15 +45,40 @@ Datenmodell:
/** Map tool names to the permission required to use them */
const TOOL_PERMISSION_MAP: Record<string, string> = {
// Resource management
update_resource: "manageResources",
create_resource: "manageResources",
deactivate_resource: "manageResources",
create_role: "manageResources",
update_role: "manageResources",
delete_role: "manageResources",
create_org_unit: "manageResources",
update_org_unit: "manageResources",
// Project management
update_project: "manageProjects",
create_project: "manageProjects",
delete_project: "manageProjects",
create_client: "manageProjects",
update_client: "manageProjects",
create_estimate: "manageProjects",
generate_project_cover: "manageProjects",
remove_project_cover: "manageProjects",
// Allocation management
create_allocation: "manageAllocations",
cancel_allocation: "manageAllocations",
update_allocation_status: "manageAllocations",
create_demand: "manageAllocations",
fill_demand: "manageAllocations",
// Vacation management
create_vacation: "manageVacations",
approve_vacation: "manageVacations",
reject_vacation: "manageVacations",
cancel_vacation: "manageVacations",
set_entitlement: "manageVacations",
};
/** Tools that require cost visibility */
const COST_TOOLS = new Set(["get_budget_status", "get_chargeability"]);
const COST_TOOLS = new Set(["get_budget_status", "get_chargeability", "resolve_rate", "list_rate_cards", "get_estimate_detail"]);
export const assistantRouter = createTRPCRouter({
chat: protectedProcedure
@@ -0,0 +1,619 @@
import {
calculateSAH,
calculateAllocation,
deriveResourceForecast,
computeBudgetStatus,
getMonthRange,
countWorkingDaysInOverlap,
DEFAULT_CALCULATION_RULES,
type AssignmentSlice,
} from "@planarchy/engine";
import type { CalculationRule, AbsenceDay, SpainScheduleRule, WeekdayAvailability } from "@planarchy/shared";
import { VacationStatus } from "@planarchy/db";
import { z } from "zod";
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
// ─── Graph Types (mirrored from client for API response) ────────────────────
type Domain =
| "INPUT" | "SAH" | "ALLOCATION" | "RULES" | "CHARGEABILITY" | "BUDGET"
| "ESTIMATE" | "COMMERCIAL" | "EXPERIENCE" | "EFFORT" | "SPREAD";
export interface GraphNode {
id: string;
label: string;
value: number | string;
unit: string;
domain: Domain;
description: string;
formula?: string;
level: number;
}
export interface GraphLink {
source: string;
target: string;
formula: string;
weight: number;
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function n(
id: string, label: string, value: number | string, unit: string,
domain: Domain, description: string, level: number, formula?: string,
): GraphNode {
return { id, label, value, unit, domain, description, level, ...(formula ? { formula } : {}) };
}
function l(source: string, target: string, formula: string, weight = 1): GraphLink {
return { source, target, formula, weight };
}
function fmtEur(cents: number): string {
return `${(cents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR`;
}
function fmtPct(ratio: number): string {
return `${(ratio * 100).toFixed(1)}%`;
}
function fmtNum(v: number, decimals = 1): string {
return v.toFixed(decimals);
}
// ─── Router ─────────────────────────────────────────────────────────────────
export const computationGraphRouter = createTRPCRouter({
/**
* Resource View: SAH, Allocation, Rules, Chargeability, Budget
* for a single resource in a single month.
*/
getResourceData: controllerProcedure
.input(z.object({
resourceId: z.string(),
month: z.string().regex(/^\d{4}-\d{2}$/),
}))
.query(async ({ ctx, input }) => {
const [year, month] = input.month.split("-").map(Number) as [number, number];
const { start: monthStart, end: monthEnd } = getMonthRange(year, month);
// ── 1. Load resource ──
const resource = await ctx.db.resource.findUniqueOrThrow({
where: { id: input.resourceId },
select: {
id: true,
displayName: true,
eid: true,
fte: true,
lcrCents: true,
chargeabilityTarget: true,
availability: true,
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } },
},
});
const dailyHours = resource.country?.dailyWorkingHours ?? 8;
const scheduleRules = resource.country?.scheduleRules as SpainScheduleRule | null;
const targetPct = resource.managementLevelGroup?.targetPercentage ?? (resource.chargeabilityTarget / 100);
// Resource weekly availability (per-day hours)
const avail = resource.availability as WeekdayAvailability | null;
const weeklyAvailability: WeekdayAvailability = avail ?? {
monday: dailyHours, tuesday: dailyHours, wednesday: dailyHours,
thursday: dailyHours, friday: dailyHours, saturday: 0, sunday: 0,
};
// ── 2. Load assignments in month ──
const assignments = await ctx.db.assignment.findMany({
where: {
resourceId: input.resourceId,
startDate: { lte: monthEnd },
endDate: { gte: monthStart },
status: { in: ["CONFIRMED", "ACTIVE", "PROPOSED"] },
},
select: {
id: true,
hoursPerDay: true,
startDate: true,
endDate: true,
dailyCostCents: true,
status: true,
project: {
select: {
id: true,
name: true,
shortCode: true,
budgetCents: true,
winProbability: true,
utilizationCategory: { select: { code: true } },
},
},
},
});
// ── 3. Load absences ──
const vacations = await ctx.db.vacation.findMany({
where: {
resourceId: input.resourceId,
status: VacationStatus.APPROVED,
startDate: { lte: monthEnd },
endDate: { gte: monthStart },
},
select: { startDate: true, endDate: true, type: true, isHalfDay: true },
});
// Build absence dates for SAH (ISO strings)
const absenceDateStrings: string[] = [];
const absenceDays: AbsenceDay[] = [];
for (const v of vacations) {
const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
if (vStart > vEnd) continue;
const cursor = new Date(vStart);
cursor.setUTCHours(0, 0, 0, 0);
const endNorm = new Date(vEnd);
endNorm.setUTCHours(0, 0, 0, 0);
const triggerType = v.type === "SICK" ? "SICK" as const
: v.type === "PUBLIC_HOLIDAY" ? "PUBLIC_HOLIDAY" as const
: "VACATION" as const;
while (cursor <= endNorm) {
absenceDateStrings.push(cursor.toISOString().slice(0, 10));
absenceDays.push({
date: new Date(cursor),
type: triggerType,
...(v.isHalfDay ? { isHalfDay: true } : {}),
});
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
}
// ── 4. Load calculation rules ──
let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES;
try {
const dbRules = await ctx.db.calculationRule.findMany({
where: { isActive: true },
orderBy: [{ priority: "desc" }],
});
if (dbRules.length > 0) {
calcRules = dbRules as unknown as CalculationRule[];
}
} catch {
// table may not exist yet
}
// ── 5. Calculate SAH ──
const sahResult = calculateSAH({
dailyWorkingHours: dailyHours,
scheduleRules,
fte: resource.fte,
periodStart: monthStart,
periodEnd: monthEnd,
publicHolidays: [],
absenceDays: absenceDateStrings,
});
// ── 6. Calculate allocations + chargeability slices ──
const slices: AssignmentSlice[] = [];
let totalAllocHours = 0;
let totalAllocCostCents = 0;
let totalChargeableHours = 0;
let totalProjectCostCents = 0;
let hasRulesEffect = false;
for (const a of assignments) {
const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate);
if (workingDays <= 0) continue;
const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime()));
const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime()));
const categoryCode = a.project.utilizationCategory?.code ?? "Chg";
const calcResult = calculateAllocation({
lcrCents: resource.lcrCents,
hoursPerDay: a.hoursPerDay,
startDate: overlapStart,
endDate: overlapEnd,
availability: weeklyAvailability,
absenceDays,
calculationRules: calcRules,
});
totalAllocHours += calcResult.totalHours;
totalAllocCostCents += calcResult.totalCostCents;
if (calcResult.totalChargeableHours !== undefined) {
totalChargeableHours += calcResult.totalChargeableHours;
totalProjectCostCents += calcResult.totalProjectCostCents ?? calcResult.totalCostCents;
hasRulesEffect = true;
} else {
totalChargeableHours += calcResult.totalHours;
totalProjectCostCents += calcResult.totalCostCents;
}
slices.push({
hoursPerDay: a.hoursPerDay,
workingDays,
categoryCode,
...(calcResult.totalChargeableHours !== undefined
? { totalChargeableHours: calcResult.totalChargeableHours }
: {}),
});
}
// ── 7. Calculate chargeability forecast ──
const forecast = deriveResourceForecast({
fte: resource.fte,
targetPercentage: targetPct,
assignments: slices,
sah: sahResult.standardAvailableHours,
});
// ── 8. Build budget status for first project with budget ──
const budgetProject = assignments.find((a) => a.project.budgetCents != null && a.project.budgetCents > 0)?.project;
let budgetNodes: GraphNode[] = [];
let budgetLinks: GraphLink[] = [];
if (budgetProject && budgetProject.budgetCents != null) {
// Load all allocations for this project to compute budget
const projectAllocs = await ctx.db.assignment.findMany({
where: { projectId: budgetProject.id },
select: { status: true, dailyCostCents: true, startDate: true, endDate: true, hoursPerDay: true },
});
const budgetStatus = computeBudgetStatus(
budgetProject.budgetCents,
budgetProject.winProbability,
projectAllocs.map((pa) => ({
status: pa.status as unknown as string,
dailyCostCents: pa.dailyCostCents,
startDate: pa.startDate,
endDate: pa.endDate,
hoursPerDay: pa.hoursPerDay,
})) as Parameters<typeof computeBudgetStatus>[2],
monthStart,
monthEnd,
);
budgetNodes = [
n("input.budgetCents", "Project Budget", fmtEur(budgetProject.budgetCents), "EUR", "INPUT", `Budget for ${budgetProject.name}`, 0),
n("input.winProbability", "Win Probability", `${budgetProject.winProbability}%`, "%", "INPUT", "Project win probability", 0),
n("budget.confirmedCents", "Confirmed", fmtEur(budgetStatus.confirmedCents), "EUR", "BUDGET", "Sum of CONFIRMED/ACTIVE allocation costs", 2, "Σ(confirmed allocs)"),
n("budget.proposedCents", "Proposed", fmtEur(budgetStatus.proposedCents), "EUR", "BUDGET", "Sum of PROPOSED allocation costs", 2, "Σ(proposed allocs)"),
n("budget.allocatedCents", "Allocated", fmtEur(budgetStatus.allocatedCents), "EUR", "BUDGET", "Total allocated budget", 2, "confirmed + proposed"),
n("budget.remainingCents", "Remaining", fmtEur(budgetStatus.remainingCents), "EUR", "BUDGET", "Remaining budget", 3, "budget - allocated"),
n("budget.utilizationPct", "Utilization", `${budgetStatus.utilizationPercent.toFixed(1)}%`, "%", "BUDGET", "Budget utilization percentage", 3, "allocated / budget × 100"),
n("budget.weightedCents", "Win-Weighted", fmtEur(budgetStatus.winProbabilityWeightedCents), "EUR", "BUDGET", "Win-probability-weighted cost", 3, "allocated × winProb / 100"),
];
budgetLinks = [
l("alloc.totalCostCents", "budget.confirmedCents", "per assignment", 1),
l("budget.confirmedCents", "budget.allocatedCents", "+", 2),
l("budget.proposedCents", "budget.allocatedCents", "+", 2),
l("input.budgetCents", "budget.remainingCents", "", 2),
l("budget.allocatedCents", "budget.remainingCents", "", 2),
l("budget.allocatedCents", "budget.utilizationPct", "÷ budget × 100", 2),
l("input.budgetCents", "budget.utilizationPct", "÷", 1),
l("budget.allocatedCents", "budget.weightedCents", "× winProb / 100", 1),
l("input.winProbability", "budget.weightedCents", "×", 1),
];
}
// ── 9. Build graph nodes + links ──
const dailyCostCents = assignments.length > 0
? Math.round(assignments[0]!.hoursPerDay * resource.lcrCents)
: 0;
const avgHoursPerDay = assignments.length > 0
? assignments.reduce((sum, a) => sum + a.hoursPerDay, 0) / assignments.length
: 0;
// Format weekly availability for display
const weekdayLabels = ["Mo", "Tu", "We", "Th", "Fr"];
const weekdayValues = [weeklyAvailability.monday, weeklyAvailability.tuesday, weeklyAvailability.wednesday, weeklyAvailability.thursday, weeklyAvailability.friday];
const weeklyTotalHours = weekdayValues.reduce((s, v) => s + v, 0);
const allSame = weekdayValues.every((v) => v === weekdayValues[0]);
const availabilityLabel = allSame
? `${weekdayValues[0]}h/day`
: weekdayLabels.map((d, i) => `${d}:${weekdayValues[i]}`).join(" ");
const nodes: GraphNode[] = [
// INPUT
n("input.fte", "FTE", fmtNum(resource.fte, 2), "ratio", "INPUT", `Resource FTE factor`, 0),
n("input.dailyHours", "Country Hours", `${dailyHours} h`, "hours", "INPUT", `Base daily working hours (${resource.country?.code ?? "?"})`, 0),
n("input.weeklyAvail", "Weekly Avail.", `${weeklyTotalHours}h`, "h/week", "INPUT", `Resource availability: ${availabilityLabel}`, 0),
n("input.lcrCents", "LCR", fmtEur(resource.lcrCents), "cents/h", "INPUT", "Loaded Cost Rate per hour", 0),
n("input.hoursPerDay", "Hours/Day", fmtNum(avgHoursPerDay), "hours", "INPUT", "Average hours/day across assignments", 0),
n("input.absences", "Absences", `${absenceDays.length}`, "count", "INPUT", `Absence days in ${input.month}`, 0),
n("input.calcRules", "Active Rules", `${calcRules.length}`, "count", "INPUT", "Active calculation rules", 0),
n("input.targetPct", "Target", fmtPct(targetPct), "%", "INPUT", `Chargeability target (${resource.managementLevelGroup?.name ?? "legacy"})`, 0),
// SAH
n("sah.calendarDays", "Calendar Days", `${sahResult.calendarDays}`, "days", "SAH", "Total calendar days in period", 1),
n("sah.weekendDays", "Weekend Days", `${sahResult.weekendDays}`, "days", "SAH", "Saturday + Sunday count", 1),
n("sah.grossWorkingDays", "Gross Work Days", `${sahResult.grossWorkingDays}`, "days", "SAH", "Calendar days minus weekends", 1, "calendarDays - weekendDays"),
n("sah.absenceDays", "Absence Ded.", `${sahResult.absenceDays}`, "days", "SAH", "Absences falling on working days", 1),
n("sah.netWorkingDays", "Net Work Days", `${sahResult.netWorkingDays}`, "days", "SAH", "Working days after deductions", 2, "gross - absences"),
n("sah.effectiveHoursPerDay", "Eff. Hrs/Day", fmtNum(sahResult.effectiveHoursPerDay), "hours", "SAH", "Average effective hours per net working day", 2, "Σ(dailyHours × FTE) / netDays"),
n("sah.sah", "SAH", fmtNum(sahResult.standardAvailableHours), "hours", "SAH", "Standard Available Hours — chargeability denominator", 2, "Σ(dailyHours × FTE) per net day"),
// ALLOCATION
n("alloc.totalHours", "Total Hours", fmtNum(totalAllocHours), "hours", "ALLOCATION", "Sum of effective hours across assignments", 2, "Σ(effectiveHours/day)"),
n("alloc.dailyCostCents", "Daily Cost", fmtEur(dailyCostCents), "EUR", "ALLOCATION", "Cost per working day", 1, "hoursPerDay × LCR"),
n("alloc.totalCostCents", "Total Cost", fmtEur(totalAllocCostCents), "EUR", "ALLOCATION", "Sum of daily costs", 2, "Σ(dailyCost)"),
...(hasRulesEffect ? [
n("alloc.chargeableHours", "Chargeable Hrs", fmtNum(totalChargeableHours), "hours", "ALLOCATION", "Rules-adjusted chargeable hours", 2, "rules-adjusted"),
n("alloc.projectCostCents", "Project Cost", fmtEur(totalProjectCostCents), "EUR", "ALLOCATION", "Rules-adjusted project cost", 2, "rules-adjusted"),
] : []),
// RULES (only if absences exist)
...(absenceDays.length > 0 ? [
n("rules.activeRules", "Matched Rules", `${calcRules.length} rules`, "—", "RULES", "Rules evaluated for absence days", 1),
n("rules.costEffect", "Cost Effect", hasRulesEffect ? "ZERO" : "—", "—", "RULES", "How absent days affect project cost", 1, "CHARGE / ZERO / REDUCE"),
n("rules.chgEffect", "Chg Effect", hasRulesEffect ? "COUNT" : "—", "—", "RULES", "How absent days affect chargeability", 1, "COUNT / SKIP"),
] : []),
// CHARGEABILITY
n("chg.chgHours", "Chg Hours", fmtNum(forecast.chg * sahResult.standardAvailableHours), "hours", "CHARGEABILITY", "Total chargeable hours", 2, "Σ(Chg-category slices)"),
n("chg.chg", "Chargeability", fmtPct(forecast.chg), "%", "CHARGEABILITY", "Chargeability ratio", 3, "chgHours / SAH"),
n("chg.unassigned", "Unassigned", fmtPct(forecast.unassigned), "%", "CHARGEABILITY", `${fmtNum(forecast.unassigned * sahResult.standardAvailableHours)}h of ${fmtNum(sahResult.standardAvailableHours)}h SAH not assigned`, 3, "max(0, SAH - assigned) / SAH"),
n("chg.target", "Target", fmtPct(targetPct), "%", "CHARGEABILITY", "Chargeability target from management level", 3),
n("chg.gap", "Gap to Target", `${forecast.chg - targetPct >= 0 ? "+" : ""}${((forecast.chg - targetPct) * 100).toFixed(1)} pp`, "pp", "CHARGEABILITY", `Chargeability (${fmtPct(forecast.chg)}) vs. target (${fmtPct(targetPct)})`, 3, "chargeability target"),
// Budget nodes (conditionally added above)
...budgetNodes,
];
const links: GraphLink[] = [
// INPUT → SAH
l("input.dailyHours", "sah.grossWorkingDays", "base hours", 1),
l("sah.calendarDays", "sah.grossWorkingDays", " weekends", 2),
l("sah.weekendDays", "sah.grossWorkingDays", "", 1),
l("input.absences", "sah.absenceDays", "∩ workdays", 1),
l("sah.grossWorkingDays", "sah.netWorkingDays", "", 2),
l("sah.absenceDays", "sah.netWorkingDays", "", 1),
l("input.dailyHours", "sah.effectiveHoursPerDay", "×", 1),
l("input.fte", "sah.effectiveHoursPerDay", "× FTE", 2),
l("sah.netWorkingDays", "sah.effectiveHoursPerDay", "÷", 1),
l("sah.effectiveHoursPerDay", "sah.sah", "× netDays", 2),
l("sah.netWorkingDays", "sah.sah", "×", 2),
// INPUT → ALLOCATION
l("input.weeklyAvail", "alloc.totalHours", "caps h/day", 2),
l("input.hoursPerDay", "alloc.dailyCostCents", "×", 1),
l("input.lcrCents", "alloc.dailyCostCents", "× LCR", 2),
l("input.hoursPerDay", "alloc.totalHours", "× workdays", 1),
l("alloc.dailyCostCents", "alloc.totalCostCents", "Σ", 2),
// RULES → ALLOCATION (if absences)
...(absenceDays.length > 0 ? [
l("input.calcRules", "rules.activeRules", "filter active", 1),
l("input.absences", "rules.activeRules", "match trigger", 1),
l("rules.activeRules", "rules.costEffect", "→ effect", 1),
l("rules.activeRules", "rules.chgEffect", "→ effect", 1),
] : []),
...(hasRulesEffect ? [
l("rules.costEffect", "alloc.projectCostCents", "apply", 2),
l("alloc.totalCostCents", "alloc.projectCostCents", "adjust", 1),
l("rules.chgEffect", "alloc.chargeableHours", "apply", 2),
l("alloc.totalHours", "alloc.chargeableHours", "adjust", 1),
] : []),
// ALLOCATION + SAH → CHARGEABILITY
l(hasRulesEffect ? "alloc.chargeableHours" : "alloc.totalHours", "chg.chgHours", "Σ Chg", 2),
l("chg.chgHours", "chg.chg", "÷ SAH", 2),
l("sah.sah", "chg.chg", "÷", 2),
l("sah.sah", "chg.unassigned", " assigned ÷ SAH", 1),
l("chg.chgHours", "chg.unassigned", "SAH Σ", 1),
l("input.targetPct", "chg.target", "=", 1),
l("chg.chg", "chg.gap", "", 2),
l("chg.target", "chg.gap", "", 1),
// Budget links (conditionally added above)
...budgetLinks,
];
return {
nodes,
links,
meta: {
resourceName: resource.displayName,
resourceEid: resource.eid,
month: input.month,
assignmentCount: assignments.length,
},
};
}),
/**
* Project View: Estimate, Commercial, Budget
*/
getProjectData: controllerProcedure
.input(z.object({
projectId: z.string(),
}))
.query(async ({ ctx, input }) => {
const project = await ctx.db.project.findUniqueOrThrow({
where: { id: input.projectId },
select: {
id: true,
name: true,
shortCode: true,
budgetCents: true,
winProbability: true,
startDate: true,
endDate: true,
},
});
// Load latest estimate version with demand lines
const estimate = await ctx.db.estimate.findFirst({
where: { projectId: input.projectId },
select: {
id: true,
versions: {
orderBy: { versionNumber: "desc" },
take: 1,
select: {
id: true,
commercialTerms: true,
demandLines: {
select: {
hours: true,
costRateCents: true,
billRateCents: true,
costTotalCents: true,
priceTotalCents: true,
},
},
},
},
},
orderBy: { updatedAt: "desc" },
});
const latestVersion = estimate?.versions[0];
const nodes: GraphNode[] = [];
const links: GraphLink[] = [];
// Budget inputs
const hasBudget = project.budgetCents > 0;
nodes.push(
n("input.budgetCents", "Project Budget", hasBudget ? fmtEur(project.budgetCents) : "Not set", hasBudget ? "EUR" : "—", "INPUT", hasBudget ? `Budget for ${project.name}` : `No budget defined for ${project.name}`, 0),
n("input.winProbability", "Win Probability", `${project.winProbability}%`, "%", "INPUT", "Project win probability", 0),
);
if (latestVersion && latestVersion.demandLines.length > 0) {
const lines = latestVersion.demandLines;
const totalHours = lines.reduce((s, dl) => s + dl.hours, 0);
const totalCostCents = lines.reduce((s, dl) => s + dl.costTotalCents, 0);
const totalPriceCents = lines.reduce((s, dl) => s + dl.priceTotalCents, 0);
const marginCents = totalPriceCents - totalCostCents;
const marginPct = totalPriceCents > 0 ? (marginCents / totalPriceCents) * 100 : 0;
// Average rates
const avgCostRate = totalHours > 0 ? Math.round(totalCostCents / totalHours) : 0;
const avgBillRate = totalHours > 0 ? Math.round(totalPriceCents / totalHours) : 0;
nodes.push(
n("input.estLines", "Demand Lines", `${lines.length}`, "count", "INPUT", "Estimate demand line count", 0),
n("input.avgCostRate", "Avg Cost Rate", fmtEur(avgCostRate), "cents/h", "INPUT", "Average cost rate across demand lines", 0),
n("input.avgBillRate", "Avg Bill Rate", fmtEur(avgBillRate), "cents/h", "INPUT", "Average bill rate across demand lines", 0),
n("est.totalHours", "Est. Hours", fmtNum(totalHours), "hours", "ESTIMATE", "Total estimated hours", 2, "Σ(line.hours)"),
n("est.totalCostCents", "Est. Cost", fmtEur(totalCostCents), "EUR", "ESTIMATE", "Total estimated cost", 2, "Σ(hours × costRate)"),
n("est.totalPriceCents", "Est. Price", fmtEur(totalPriceCents), "EUR", "ESTIMATE", "Total estimated price", 2, "Σ(hours × billRate)"),
n("est.marginCents", "Margin", fmtEur(marginCents), "EUR", "ESTIMATE", "Price minus cost", 3, "price - cost"),
n("est.marginPercent", "Margin %", `${marginPct.toFixed(1)}%`, "%", "ESTIMATE", "Margin as percentage of price", 3, "margin / price × 100"),
);
links.push(
l("input.estLines", "est.totalHours", "Σ hours", 1),
l("input.avgCostRate", "est.totalCostCents", "× hours", 2),
l("est.totalHours", "est.totalCostCents", "× costRate", 2),
l("input.avgBillRate", "est.totalPriceCents", "× hours", 2),
l("est.totalHours", "est.totalPriceCents", "× billRate", 2),
l("est.totalPriceCents", "est.marginCents", "", 2),
l("est.totalCostCents", "est.marginCents", "", 2),
l("est.marginCents", "est.marginPercent", "÷ price × 100", 2),
l("est.totalPriceCents", "est.marginPercent", "÷", 1),
);
// Commercial terms
const terms = latestVersion.commercialTerms as { contingencyPercent?: number; discountPercent?: number } | null;
if (terms && (terms.contingencyPercent || terms.discountPercent)) {
const contingencyPct = terms.contingencyPercent ?? 0;
const discountPct = terms.discountPercent ?? 0;
const contingencyCents = Math.round(totalCostCents * contingencyPct / 100);
const discountCents = Math.round(totalPriceCents * discountPct / 100);
const adjCost = totalCostCents + contingencyCents;
const adjPrice = totalPriceCents - discountCents;
const adjMargin = adjPrice - adjCost;
const adjMarginPct = adjPrice > 0 ? (adjMargin / adjPrice) * 100 : 0;
nodes.push(
n("input.contingencyPct", "Contingency %", `${contingencyPct}%`, "%", "INPUT", "Contingency percentage", 0),
n("input.discountPct", "Discount %", `${discountPct}%`, "%", "INPUT", "Discount percentage", 0),
n("comm.contingencyCents", "Contingency", fmtEur(contingencyCents), "EUR", "COMMERCIAL", "Contingency surcharge", 2, "baseCost × contingency%"),
n("comm.discountCents", "Discount", fmtEur(discountCents), "EUR", "COMMERCIAL", "Discount deduction", 2, "basePrice × discount%"),
n("comm.adjustedCost", "Adj. Cost", fmtEur(adjCost), "EUR", "COMMERCIAL", "Cost plus contingency", 3, "baseCost + contingency"),
n("comm.adjustedPrice", "Adj. Price", fmtEur(adjPrice), "EUR", "COMMERCIAL", "Price minus discount", 3, "basePrice - discount"),
n("comm.adjustedMargin", "Adj. Margin", fmtEur(adjMargin), "EUR", "COMMERCIAL", "Adjusted margin", 3, "adjPrice - adjCost"),
n("comm.adjustedMarginPct", "Adj. Margin %", `${adjMarginPct.toFixed(1)}%`, "%", "COMMERCIAL", "Adjusted margin percentage", 3, "adjMargin / adjPrice × 100"),
);
links.push(
l("est.totalCostCents", "comm.contingencyCents", "×", 1),
l("input.contingencyPct", "comm.contingencyCents", "× %", 1),
l("est.totalPriceCents", "comm.discountCents", "×", 1),
l("input.discountPct", "comm.discountCents", "× %", 1),
l("est.totalCostCents", "comm.adjustedCost", "+", 2),
l("comm.contingencyCents", "comm.adjustedCost", "+", 2),
l("est.totalPriceCents", "comm.adjustedPrice", "", 2),
l("comm.discountCents", "comm.adjustedPrice", "", 2),
l("comm.adjustedPrice", "comm.adjustedMargin", "", 2),
l("comm.adjustedCost", "comm.adjustedMargin", "", 2),
l("comm.adjustedMargin", "comm.adjustedMarginPct", "÷ price × 100", 2),
l("comm.adjustedPrice", "comm.adjustedMarginPct", "÷", 1),
);
}
}
// Budget status — always show allocation totals; remaining/utilization only when budget > 0
const projectAllocs = await ctx.db.assignment.findMany({
where: { projectId: input.projectId },
select: { status: true, dailyCostCents: true, startDate: true, endDate: true, hoursPerDay: true },
});
if (projectAllocs.length > 0) {
const budgetStatus = computeBudgetStatus(
project.budgetCents,
project.winProbability,
projectAllocs.map((pa) => ({
status: pa.status as unknown as string,
dailyCostCents: pa.dailyCostCents,
startDate: pa.startDate,
endDate: pa.endDate,
hoursPerDay: pa.hoursPerDay,
})) as Parameters<typeof computeBudgetStatus>[2],
project.startDate ?? new Date(),
project.endDate ?? new Date(),
);
nodes.push(
n("budget.confirmedCents", "Confirmed", fmtEur(budgetStatus.confirmedCents), "EUR", "BUDGET", "Confirmed allocation costs", 2, "Σ(CONFIRMED allocs)"),
n("budget.proposedCents", "Proposed", fmtEur(budgetStatus.proposedCents), "EUR", "BUDGET", "Proposed allocation costs", 2, "Σ(PROPOSED allocs)"),
n("budget.allocatedCents", "Allocated", fmtEur(budgetStatus.allocatedCents), "EUR", "BUDGET", "Total allocated", 2, "confirmed + proposed"),
n("budget.remainingCents", "Remaining",
hasBudget ? fmtEur(budgetStatus.remainingCents) : "N/A",
hasBudget ? "EUR" : "—", "BUDGET",
hasBudget ? "Remaining budget" : "Cannot compute — no budget set",
3, hasBudget ? "budget - allocated" : "needs budget"),
n("budget.utilizationPct", "Utilization",
hasBudget ? `${budgetStatus.utilizationPercent.toFixed(1)}%` : "N/A",
hasBudget ? "%" : "—", "BUDGET",
hasBudget ? "Budget utilization" : "Cannot compute — no budget set",
3, hasBudget ? "allocated / budget × 100" : "needs budget"),
n("budget.weightedCents", "Win-Weighted", fmtEur(budgetStatus.winProbabilityWeightedCents), "EUR", "BUDGET", "Win-weighted cost", 3, "allocated × winProb / 100"),
);
links.push(
l("budget.confirmedCents", "budget.allocatedCents", "+", 2),
l("budget.proposedCents", "budget.allocatedCents", "+", 2),
l("input.budgetCents", "budget.remainingCents", "", 2),
l("budget.allocatedCents", "budget.remainingCents", "", 2),
l("budget.allocatedCents", "budget.utilizationPct", "÷ budget × 100", 2),
l("input.budgetCents", "budget.utilizationPct", "÷", 1),
l("budget.allocatedCents", "budget.weightedCents", "× winProb / 100", 1),
l("input.winProbability", "budget.weightedCents", "×", 1),
);
}
return {
nodes,
links,
meta: {
projectName: project.name,
projectCode: project.shortCode,
},
};
}),
});
+151
View File
@@ -11,6 +11,9 @@ import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
import { loadProjectPlanningReadModel } from "./project-planning-read-model.js";
import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
import { createDalleClient, isDalleConfigured, parseAiError } from "../ai-client.js";
const MAX_COVER_SIZE = 4 * 1024 * 1024; // 4 MB base64 string length limit (client compresses before upload)
export const projectRouter = createTRPCRouter({
list: protectedProcedure
@@ -348,4 +351,152 @@ export const projectRouter = createTRPCRouter({
return { id: input.id, name: project.name };
}),
// ─── Cover Art ──────────────────────────────────────────────────────────────
generateCover: managerProcedure
.input(z.object({
projectId: z.string(),
prompt: z.string().max(500).optional(),
}))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
const project = await findUniqueOrThrow(
ctx.db.project.findUnique({
where: { id: input.projectId },
include: { client: { select: { name: true } } },
}),
"Project",
);
const settings = await ctx.db.systemSettings.findUnique({
where: { id: "singleton" },
});
if (!isDalleConfigured(settings)) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "DALL-E is not configured. Set up the DALL-E deployment in Admin → Settings.",
});
}
const clientName = project.client?.name ? ` for ${project.client.name}` : "";
const basePrompt = `Professional cover art for a 3D automotive visualization project: "${project.name}"${clientName}. Style: cinematic, modern, photorealistic CGI rendering, dramatic lighting, studio environment. No text or typography in the image.`;
const finalPrompt = input.prompt
? `${basePrompt} Additional direction: ${input.prompt}`
: basePrompt;
const dalleClient = createDalleClient(settings!);
const model = settings!.aiProvider === "azure" ? settings!.azureDalleDeployment! : "dall-e-3";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let response: any;
try {
response = await dalleClient.images.generate({
model,
prompt: finalPrompt,
size: "1024x1024",
n: 1,
response_format: "b64_json",
});
} catch (err) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `DALL-E error: ${parseAiError(err)}`,
});
}
const b64 = response.data?.[0]?.b64_json;
if (!b64) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "No image data returned from DALL-E",
});
}
const coverImageUrl = `data:image/png;base64,${b64}`;
await ctx.db.project.update({
where: { id: input.projectId },
data: { coverImageUrl },
});
return { coverImageUrl };
}),
uploadCover: managerProcedure
.input(z.object({
projectId: z.string(),
imageDataUrl: z.string(),
}))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
if (!input.imageDataUrl.startsWith("data:image/")) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid image format. Must be a data URL starting with 'data:image/'.",
});
}
if (input.imageDataUrl.length > MAX_COVER_SIZE) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Image too large. Maximum compressed size is 4 MB.",
});
}
await findUniqueOrThrow(
ctx.db.project.findUnique({ where: { id: input.projectId } }),
"Project",
);
await ctx.db.project.update({
where: { id: input.projectId },
data: { coverImageUrl: input.imageDataUrl },
});
return { coverImageUrl: input.imageDataUrl };
}),
removeCover: managerProcedure
.input(z.object({ projectId: z.string() }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
await findUniqueOrThrow(
ctx.db.project.findUnique({ where: { id: input.projectId } }),
"Project",
);
await ctx.db.project.update({
where: { id: input.projectId },
data: { coverImageUrl: null },
});
return { ok: true };
}),
updateCoverFocus: managerProcedure
.input(z.object({
projectId: z.string(),
coverFocusY: z.number().int().min(0).max(100),
}))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
await ctx.db.project.update({
where: { id: input.projectId },
data: { coverFocusY: input.coverFocusY },
});
return { ok: true };
}),
isDalleConfigured: protectedProcedure
.query(async ({ ctx }) => {
const settings = await ctx.db.systemSettings.findUnique({
where: { id: "singleton" },
});
return { configured: isDalleConfigured(settings) };
}),
});
+15
View File
@@ -42,6 +42,10 @@ export const settingsRouter = createTRPCRouter({
anonymizationEnabled: settings?.anonymizationEnabled ?? false,
anonymizationDomain: settings?.anonymizationDomain ?? "superhartmut.de",
anonymizationMode: settings?.anonymizationMode ?? "global",
// DALL-E
azureDalleDeployment: settings?.azureDalleDeployment ?? null,
azureDalleEndpoint: settings?.azureDalleEndpoint ?? null,
hasDalleApiKey: !!settings?.azureDalleApiKey,
// Vacation defaults
vacationDefaultDays: settings?.vacationDefaultDays ?? 28,
};
@@ -84,6 +88,10 @@ export const settingsRouter = createTRPCRouter({
anonymizationDomain: z.string().trim().min(1).optional(),
anonymizationSeed: z.string().trim().min(1).optional().or(z.literal("")),
anonymizationMode: z.enum(["global"]).optional(),
// DALL-E image generation
azureDalleDeployment: z.string().optional(),
azureDalleEndpoint: z.string().url().optional().or(z.literal("")),
azureDalleApiKey: z.string().optional(),
// Vacation
vacationDefaultDays: z.number().int().min(0).max(365).optional(),
}),
@@ -127,6 +135,13 @@ export const settingsRouter = createTRPCRouter({
data.anonymizationMode = input.anonymizationMode;
data.anonymizationAliases = null;
}
// DALL-E
if (input.azureDalleDeployment !== undefined)
data.azureDalleDeployment = input.azureDalleDeployment || null;
if (input.azureDalleEndpoint !== undefined)
data.azureDalleEndpoint = input.azureDalleEndpoint || null;
if (input.azureDalleApiKey !== undefined)
data.azureDalleApiKey = input.azureDalleApiKey || null;
// Vacation
if (input.vacationDefaultDays !== undefined) data.vacationDefaultDays = input.vacationDefaultDays;
+6
View File
@@ -807,6 +807,8 @@ model Project {
status ProjectStatus @default(DRAFT)
responsiblePerson String?
color String? // Hex color for timeline display, e.g. "#3b82f6"
coverImageUrl String? @db.Text // Base64 data-URL for project cover art
coverFocusY Int @default(50) // Vertical focus point 0-100 (% from top)
// staffingReqs: StaffingRequirement[]
staffingReqs Json @db.JsonB @default("[]")
@@ -1332,6 +1334,10 @@ model SystemSettings {
anonymizationAliases Json? @db.JsonB
// Vacation defaults
vacationDefaultDays Int? @default(28) // default annual entitlement
// DALL-E image generation (Azure requires separate deployment)
azureDalleDeployment String? // e.g. "dall-e-3" — Azure DALL-E deployment name
azureDalleEndpoint String? // Optional: separate endpoint for DALL-E (if different from chat)
azureDalleApiKey String? // Optional: separate API key for DALL-E
updatedAt DateTime @updatedAt
@@map("system_settings")
@@ -32,6 +32,7 @@ export const CreateProjectBaseSchema = z.object({
color: z.string().regex(/^#[0-9a-fA-F]{6}$/, "Must be a hex color like #3b82f6").optional(),
utilizationCategoryId: z.string().optional(),
clientId: z.string().optional(),
coverImageUrl: z.string().optional(),
});
// Full schema with date-range validation
+1
View File
@@ -30,6 +30,7 @@ export interface Project {
blueprintId?: string | null;
status: ProjectStatus;
responsiblePerson?: string | null;
coverImageUrl?: string | null;
createdAt: Date;
updatedAt: Date;
}
+348 -74
View File
@@ -1,123 +1,397 @@
# Plan: Budget per Role / Demand
# Enterprise Notification & Task Management System
## Anforderungsanalyse
Jede Staffing-Demand (Rolle) in einem Projekt soll ein eigenes Budget bekommen. Aktuell gibt es nur ein einziges `budgetCents` auf Projektebene. Ziel:
### Was wird gebaut?
Ein mehrstufiges Notification- und Task-Management-System, das die bestehende Notification-Infrastruktur (Prisma-Model, Bell-Icon, SSE, SMTP) zu einem vollwertigen Enterprise-System ausbaut. Vier Kernfähigkeiten:
1. **DemandRequirement** bekommt ein `budgetCents` Feld (wie viel Budget ist dieser Rolle zugewiesen)
2. **StaffingRequirement** (JSONB auf Project) bekommt ein optionales `budgetCents` Feld fuer den Wizard
3. **Project Wizard Step 3** zeigt Budget-Input pro Rolle + verbleibendes unverteiltes Projekt-Budget
4. **Project Detail Page** zeigt pro Demand: zugewiesenes Budget vs. gebuchtes Budget (aus Assignments berechnet)
5. **Fill Demand Modal** zeigt verbleibendes Rollen-Budget beim Zuweisen von Ressourcen
1. **Personal Reminders** — User legen eigene Erinnerungen an (Datum/Zeit, optionale Wiederholung, verknüpft mit Entity)
2. **Targeted Notifications** — Admins/Manager senden Notifications an User, Rollen, Projektbeteiligte, OrgUnits
3. **Task Management** — Actionable Tasks mit Status-Tracking, Dashboard-Widget, Entity-Verknüpfung
4. **AI Assistant Integration** — Assistent liest offene Tasks, führt sie aus (Urlaub genehmigen, Allokation erstellen, etc.)
### Architektur-Entscheidung
### Bestehende Infrastruktur (wiederverwendbar)
| Komponente | Status | Datei |
|------------|--------|-------|
| `Notification` Prisma-Model | Vorhanden (einfach) | `packages/db/prisma/schema.prisma:1291` |
| Notification tRPC-Router | list, unreadCount, markRead, create | `packages/api/src/router/notification.ts` |
| NotificationBell + Drawer | Bell-Icon mit Badge, Dropdown-Panel | `apps/web/src/components/notifications/NotificationBell.tsx` |
| SSE EventBus (Redis Pub/Sub) | `NOTIFICATION_CREATED` Event | `packages/api/src/sse/event-bus.ts` |
| SMTP Email | `sendEmail()` + SystemSettings | `packages/api/src/lib/email.ts` |
| AI Assistant Tools | `list_notifications`, `mark_notification_read` | `packages/api/src/router/assistant-tools.ts` |
| Dashboard Widget-Registry | 8 Widgets, Pattern etabliert | `apps/web/src/components/dashboard/widgets/` |
`budgetCents` als **explizite Spalte** auf `DemandRequirement` (nicht in `metadata` JSONB), weil:
- Typsicher, indizierbar, aggregierbar via SQL
- Konsistent mit dem Muster auf `Project.budgetCents`
- Default `0` = kein Budget zugewiesen (abwaertskompatibel)
### Betroffene Pakete
- **packages/db** — Schema-Erweiterung (Notification -> Task-Felder, neues Broadcast-Model)
- **packages/shared** — Enums, Typen, Zod-Schemas
- **packages/api** — Router-Erweiterung (notification.ts, assistant-tools.ts), Targeting-Logik, Scheduler
- **apps/web** — UI (Task-Widget, Reminder-UI, Notification-Center, Admin-Panel)
---
## Datenmodell-Design
### Erweiterung des bestehenden `Notification`-Models
Das bestehende Model wird um Task-/Reminder-/Targeting-Felder erweitert. Kein neues Model nötig — ein einheitliches System für Notifications + Tasks + Reminders.
```prisma
model Notification {
id String @id @default(cuid())
userId String
// -- Typ & Kategorie --
category NotificationCategory @default(NOTIFICATION) // NEU
type String // z.B. "VACATION_REQUESTED", "TASK_ASSIGNED", "REMINDER"
priority NotificationPriority @default(NORMAL) // NEU
// -- Inhalt --
title String
body String?
entityId String?
entityType String?
link String? // NEU: Deep-Link zur relevanten Seite
// -- Task-Felder (nur fuer category TASK / APPROVAL) --
taskStatus TaskStatus? // NEU: OPEN / IN_PROGRESS / DONE / DISMISSED
taskAction String? // NEU: maschinenlesbare Aktion z.B. "approve_vacation:clxyz123"
assigneeId String? // NEU: wem der Task zugewiesen ist
dueDate DateTime? // NEU: Faelligkeitsdatum
completedAt DateTime? // NEU: Zeitpunkt der Erledigung
completedBy String? // NEU: wer hat erledigt (User-ID, oder "ai-assistant")
// -- Reminder-Felder --
remindAt DateTime? // NEU: wann soll erinnert werden
recurrence String? // NEU: "daily" | "weekly" | "monthly" | null
nextRemindAt DateTime? // NEU: naechster Erinnerungszeitpunkt (berechnet)
// -- Targeting-Metadaten (fuer Bulk-Sends) --
sourceId String? // NEU: Referenz auf die urspruengliche Broadcast-Nachricht
senderId String? // NEU: wer hat die Notification erstellt (User-ID)
channel String @default("in_app") // NEU: "in_app" | "email" | "both"
// -- Timestamps --
readAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt // NEU
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
assignee User? @relation("taskAssignee", fields: [assigneeId], references: [id])
sender User? @relation("notificationSender", fields: [senderId], references: [id])
@@index([userId, readAt])
@@index([userId, category, taskStatus]) // NEU: Task-Queries
@@index([nextRemindAt]) // NEU: Reminder-Scheduler
@@index([assigneeId, taskStatus]) // NEU: Assigned-Tasks
@@map("notifications")
}
enum NotificationCategory {
NOTIFICATION // System/Admin-Benachrichtigung (read-only)
REMINDER // Persoenliche Erinnerung (self-created)
TASK // Actionable Task mit Status-Tracking
APPROVAL // Genehmigungsworkflow (approve/reject)
}
enum NotificationPriority {
LOW
NORMAL
HIGH
URGENT
}
enum TaskStatus {
OPEN
IN_PROGRESS
DONE
DISMISSED
}
```
### Broadcast-Model (fuer Gruppen-Notifications)
```prisma
model NotificationBroadcast {
id String @id @default(cuid())
senderId String
title String
body String?
link String?
category NotificationCategory @default(NOTIFICATION)
priority NotificationPriority @default(NORMAL)
channel String @default("in_app")
// -- Targeting --
targetType String // "user" | "role" | "project" | "orgUnit" | "all"
targetValue String? // Role-Name, Project-ID, OrgUnit-ID, oder null fuer "all"
// -- Scheduling --
scheduledAt DateTime? // null = sofort
sentAt DateTime?
recipientCount Int @default(0)
createdAt DateTime @default(now())
sender User @relation(fields: [senderId], references: [id])
@@index([senderId])
@@index([scheduledAt, sentAt])
@@map("notification_broadcasts")
}
```
### Task-Action Registry (Enterprise-Pattern)
Maschinenlesbare Aktionen ermoeglichen dem AI-Assistenten, Tasks direkt zu erledigen:
```typescript
// packages/api/src/lib/task-actions.ts
const TASK_ACTION_REGISTRY: Record<string, TaskActionHandler> = {
"approve_vacation": { permission: "manageVacations", execute: ... },
"reject_vacation": { permission: "manageVacations", execute: ... },
"fill_demand": { permission: "manageAllocations", execute: ... },
"confirm_allocation":{ permission: "manageAllocations", execute: ... },
"review_budget": { permission: "manageProjects", execute: ... },
};
```
Format: `"action_name:entity_id"` — einfach parsbar, erweiterbar.
---
## Betroffene Pakete & Dateien
| Paket | Dateien | Art der Aenderung |
|-------|---------|-----------------|
| `packages/db` | `prisma/schema.prisma` | **edit**`budgetCents` auf DemandRequirement |
| `packages/shared` | `src/types/project.ts` | **edit**`budgetCents?` auf StaffingRequirement |
| `packages/shared` | `src/types/allocation.ts` | **edit**`budgetCents` auf DemandRequirementRecord |
| `packages/shared` | `src/schemas/allocation.schema.ts` | **edit**`budgetCents` in CreateDemandRequirementSchema |
| `packages/api` | `src/router/allocation.ts` | **edit** — budgetCents durchreichen in create/update |
| `packages/api` | `src/router/project.ts` | **edit** — bei Demand-Erstellung aus Wizard budgetCents uebernehmen |
| `apps/web` | `src/components/projects/ProjectWizard.tsx` | **edit** — Step 3: Budget-Input pro Rolle + Restbudget-Anzeige |
| `apps/web` | `src/components/projects/ProjectDemandsTable.tsx` | **edit** — Spalten: Allocated Budget, Booked Budget |
| `apps/web` | `src/components/allocations/FillOpenDemandModal.tsx` | **edit** — Rollen-Budget-Anzeige |
| Paket | Dateien | Art |
|-------|---------|-----|
| `packages/db` | `prisma/schema.prisma` | edit — Notification erweitern, Enums, Broadcast-Model |
| `packages/shared` | `src/types/notification.ts` | create — Typen, Enums, Zod-Schemas |
| `packages/shared` | `src/types/enums.ts` | edit — re-exportieren |
| `packages/api` | `src/router/notification.ts` | edit — Task-CRUD, Reminder-CRUD, Broadcast, Targeting |
| `packages/api` | `src/router/index.ts` | edit — ggf. neuen Router registrieren |
| `packages/api` | `src/router/assistant-tools.ts` | edit — neue Tools: list_tasks, execute_task_action, etc. |
| `packages/api` | `src/router/assistant.ts` | edit — TOOL_PERMISSION_MAP + System-Prompt |
| `packages/api` | `src/sse/event-bus.ts` | edit — neue Event-Types |
| `packages/api` | `src/lib/email.ts` | edit — Notification-Email-Templates |
| `packages/api` | `src/lib/notification-targeting.ts` | create — Recipient-Aufloesung |
| `packages/api` | `src/lib/task-actions.ts` | create — Action-Registry |
| `packages/api` | `src/lib/reminder-scheduler.ts` | create — Reminder-Dispatcher |
| `apps/web` | `src/components/notifications/NotificationBell.tsx` | edit — Tabs, Task-Badge |
| `apps/web` | `src/components/notifications/NotificationCenter.tsx` | create — Full-Page |
| `apps/web` | `src/components/notifications/ReminderModal.tsx` | create |
| `apps/web` | `src/components/notifications/BroadcastModal.tsx` | create |
| `apps/web` | `src/components/notifications/TaskCard.tsx` | create |
| `apps/web` | `src/components/dashboard/widgets/TaskWidget.tsx` | create |
| `apps/web` | `src/app/(app)/notifications/page.tsx` | create |
| `apps/web` | `src/app/(app)/admin/notifications/page.tsx` | create |
| `apps/web` | `src/components/layout/AppShell.tsx` | edit — Nav-Links |
| `apps/web` | `src/hooks/useTimelineSSE.ts` | edit — Task-Events |
---
## Task-Liste
## Task-Liste (atomare Schritte)
### Task 1: Prisma Schema — `budgetCents` auf DemandRequirement
### Phase N.1 — Datenmodell & Shared Types
Datei: `packages/db/prisma/schema.prisma`
- [ ] **Task 1:** Shared-Typen erstellen -> `packages/shared/src/types/notification.ts`
- NotificationCategory, NotificationPriority, TaskStatus Enums
- CreateReminderSchema, CreateBroadcastSchema, UpdateTaskStatusSchema (Zod)
- TaskAction Interface
- [ ] `budgetCents Int @default(0)` auf DemandRequirement hinzufuegen
- [ ] `pnpm db:push` ausfuehren (generiert Prisma Client)
- [ ] Dev-Server neustarten (`.next/` Cache loeschen)
- [ ] **Task 2:** Prisma-Schema erweitern -> `packages/db/prisma/schema.prisma`
- Notification-Model: category, priority, taskStatus, taskAction, assigneeId, dueDate, completedAt, completedBy, remindAt, recurrence, nextRemindAt, sourceId, senderId, channel, link, updatedAt
- Enums: NotificationCategory, NotificationPriority, TaskStatus
- Model: NotificationBroadcast
- User-Relations: taskAssignee, notificationSender, broadcasts
- Indexes: [userId, category, taskStatus], [nextRemindAt], [assigneeId, taskStatus]
### Task 2: Shared Types aktualisieren
- [ ] **Task 3:** `pnpm db:push` + Dev-Server neu starten
Dateien:
- `packages/shared/src/types/project.ts``budgetCents?: number` auf `StaffingRequirement`
- `packages/shared/src/types/allocation.ts``budgetCents: number` auf `DemandRequirementRecord`
- `packages/shared/src/schemas/allocation.schema.ts``budgetCents: z.number().int().min(0).default(0)` in `CreateDemandRequirementBaseSchema`
### Phase N.2 — API: Router + Targeting + Scheduler
### Task 3: API — budgetCents durchreichen
- [ ] **Task 4:** SSE Event-Types erweitern -> `packages/api/src/sse/event-bus.ts`
- TASK_ASSIGNED, TASK_COMPLETED, TASK_STATUS_CHANGED, REMINDER_DUE, BROADCAST_SENT
- Emit-Helper: emitTaskAssigned(), emitTaskCompleted(), emitReminderDue()
Datei: `packages/api/src/router/allocation.ts`
- [ ] **Task 5:** Notification-Router erweitern -> `packages/api/src/router/notification.ts`
- list: Filter nach category, taskStatus, priority
- listTasks (protectedProcedure): offene Tasks + zugewiesene Tasks
- taskCounts (protectedProcedure): Counts nach Status
- updateTaskStatus (protectedProcedure): OPEN->IN_PROGRESS->DONE/DISMISSED
- createReminder (protectedProcedure): eigene Erinnerung anlegen
- updateReminder / deleteReminder (protectedProcedure)
- createBroadcast (managerProcedure): Targeted Notification an Gruppe
- listBroadcasts (managerProcedure)
- createTask (managerProcedure): Task fuer User/Gruppe
- assignTask (managerProcedure): Task zuweisen
- delete (protectedProcedure): eigene Notifications loeschen
- [ ] `createDemandRequirement``budgetCents` aus Input an Prisma create weitergeben
- [ ] `updateDemandRequirement``budgetCents` updatebar machen
- [ ] `checkResourceAvailability` — optional: gebuchte Kosten vs. Rollen-Budget zurueckgeben
- [ ] **Task 6:** Broadcast-Targeting -> `packages/api/src/lib/notification-targeting.ts` (create)
- resolveRecipients(targetType, targetValue, db): User-IDs aufloesen
- "user" -> einzelner User
- "role" -> alle User mit SystemRole
- "project" -> Ressourcen mit aktiver Allokation -> verknuepfte User
- "orgUnit" -> Ressourcen in OrgUnit -> verknuepfte User
- "all" -> alle aktiven User
Datei: `packages/api/src/router/project.ts`
- [ ] **Task 7:** Email-Templates -> `packages/api/src/lib/email.ts` (edit)
- sendNotificationEmail(userId, notification): HTML mit Title, Body, Deep-Link
- sendTaskEmail(userId, task): Template mit Task-Details + Action-Link
- [ ] Bei Projekt-Erstellung mit StaffingReqs: wenn `staffingReq.budgetCents` vorhanden, an DemandRequirement weitergeben
- [ ] **Task 8:** Task-Action-Registry -> `packages/api/src/lib/task-actions.ts` (create)
- Registry-Pattern: action_name -> { permission, execute(entityId, ctx) }
- Initiale Actions: approve_vacation, reject_vacation, fill_demand, confirm_allocation
### Task 4: Project Wizard Step 3 — Budget-Input pro Rolle
- [ ] **Task 9:** Reminder-Scheduler -> `packages/api/src/lib/reminder-scheduler.ts` (create)
- Intervall (60s): WHERE nextRemindAt <= NOW()
- Fuer jeden faelligen Reminder: In-App Notification + optional Email
- nextRemindAt neu berechnen oder null setzen
- Catch-up bei Start (ueberfaellige sofort ausloesen)
Datei: `apps/web/src/components/projects/ProjectWizard.tsx`
### Phase N.3 — AI Assistant Integration
- [ ] Pro StaffingRequirement-Karte: neues Feld "Role Budget (EUR)" (Input, konvertiert zu Cents)
- [ ] Oben im Step: Anzeige "Project Budget: X EUR | Allocated: Y EUR | Remaining: Z EUR"
- [ ] Farbkodierung: gruen wenn alles verteilt, amber wenn Rest, rot wenn ueberallokiert
- [ ] Budget-Wert wird in `state.staffingReqs[i].budgetCents` gespeichert
- [ ] **Task 10:** Neue Tool-Definitionen -> `packages/api/src/router/assistant-tools.ts`
- list_tasks: offene Tasks/Approvals mit Filter
- get_task_detail: Details inkl. verknuepfter Entity
- update_task_status: Status aendern
- execute_task_action: maschinenlesbare Aktion ausfuehren
- create_reminder: Erinnerung anlegen
- create_task_for_user: Task fuer anderen User (Manager-only)
- send_broadcast: Notification an Gruppe (Manager-only)
### Task 5: Project Detail Page — Budget-Spalten pro Demand
- [ ] **Task 11:** Tool-Executors implementieren -> `packages/api/src/router/assistant-tools.ts`
- execute_task_action: parst taskAction-String, dispatcht an Action-Registry
- Permission-Check pro Action (nicht pauschal)
Datei: `apps/web/src/components/projects/ProjectDemandsTable.tsx`
- [ ] **Task 12:** Permission-Map + Prompt -> `packages/api/src/router/assistant.ts`
- TOOL_PERMISSION_MAP erweitern
- System-Prompt: Tasks/Reminders als Faehigkeit beschreiben
- [ ] Neue Spalte "Allocated Budget" — zeigt `demand.budgetCents` formatiert als EUR
- [ ] Neue Spalte "Booked Budget" — berechnet: Summe der `dailyCostCents * Arbeitstage` aller Assignments dieses Demands
- Hinweis: Die Demand-Daten vom Server enthalten `assignments[]` — daraus berechnen
- [ ] Neue Spalte "Remaining" — Allocated minus Booked
- [ ] Farbkodierung: gruen wenn unter Budget, rot wenn ueber Budget
### Phase N.4 — Frontend
### Task 6: Fill Demand Modal — Rollen-Budget anzeigen
- [ ] **Task 13:** NotificationBell erweitern -> `apps/web/src/components/notifications/NotificationBell.tsx`
- Zweiter Badge: Task-Count (orange) neben Notification-Count (rot)
- Tabs: "Alle" | "Tasks" | "Erinnerungen"
- Task-Items mit Quick-Actions (Done/Dismiss)
- Link zu "/notifications"
Datei: `apps/web/src/components/allocations/FillOpenDemandModal.tsx`
- [ ] **Task 14:** TaskCard-Komponente -> `apps/web/src/components/notifications/TaskCard.tsx` (create)
- Titel, Body, Due-Date, Priority-Badge, Entity-Link
- Aktionen: "Start" / "Done" / "Dismiss"
- Approval-Variante: "Approve" / "Reject"
- Priority-farbcodiert (URGENT=rot, HIGH=orange, NORMAL=blau, LOW=grau)
- [ ] Im Demand-Summary oben: "Role Budget: X EUR | Booked: Y EUR | Remaining: Z EUR"
- [ ] Beim Hinzufuegen einer Ressource zum Plan: geschaetzte Kosten anzeigen (LCR * verfuegbare Stunden)
- [ ] Warnung wenn geplante Kosten das Rollen-Budget ueberschreiten
- [ ] **Task 15:** ReminderModal -> `apps/web/src/components/notifications/ReminderModal.tsx` (create)
- Titel, Body, Datum/Uhrzeit, Wiederholung (keine/taeglich/woechentlich/monatlich)
- Optional: Entity-Verknuepfung (Projekt/Ressource Dropdown)
- [ ] **Task 16:** BroadcastModal -> `apps/web/src/components/notifications/BroadcastModal.tsx` (create)
- Manager/Admin-only
- Targeting: Dropdown (Alle/Rolle/Projekt/OrgUnit) + Wert-Auswahl
- Inhalt: Titel, Body, Priority, Kategorie
- Kanal: In-App / Email / Beides
- Scheduling: Sofort / Zeitgesteuert
- Vorschau: "Wird an X Empfaenger gesendet"
- [ ] **Task 17:** NotificationCenter -> `apps/web/src/app/(app)/notifications/page.tsx` (create)
- Tabs: Alle | Notifications | Tasks | Reminders | Approvals
- Filter: Status, Priority, Zeitraum
- Bulk: "Alle lesen", "Alle erledigt"
- "Neue Erinnerung" Button
- [ ] **Task 18:** TaskWidget -> `apps/web/src/components/dashboard/widgets/TaskWidget.tsx` (create)
- Kompakte Liste offener Tasks (max 5-7)
- Sortiert: Priority -> Due-Date
- Quick-Actions: Done/Dismiss
- Footer: "X offene Tasks — Alle anzeigen"
- In Widget-Registry eintragen
- [ ] **Task 19:** Admin Broadcast-Seite -> `apps/web/src/app/(app)/admin/notifications/page.tsx` (create)
- Liste gesendeter Broadcasts
- "Neue Benachrichtigung senden" Button
- Statistiken: gesendet/gelesen pro Broadcast
- [ ] **Task 20:** AppShell Navigation -> `apps/web/src/components/layout/AppShell.tsx` (edit)
- "Notifications" fuer alle Rollen
- "Broadcast" unter Admin (ADMIN/MANAGER)
- [ ] **Task 21:** SSE-Hook -> `apps/web/src/hooks/useTimelineSSE.ts` (edit)
- Auf TASK_ASSIGNED, TASK_COMPLETED, REMINDER_DUE reagieren
- React-Query invalidieren: notification.listTasks, notification.taskCounts
### Phase N.5 — Auto-Tasks & Audit
- [ ] **Task 22:** Automatische Task-Erzeugung bei Business-Events
- vacation.create -> Task "Urlaubsantrag genehmigen" an Manager (APPROVAL)
- Ueberallokation -> Task "Ueberallokation aufloesen" an Manager
- Projekt-Deadline < 30 Tage + offene Demands -> Task "Demands besetzen"
- demand.create -> Task "Demand besetzen" an Manager
- [ ] **Task 23:** Audit-Trail -> `packages/api/src/lib/audit.ts` (create)
- logTaskAction(taskId, userId, action, details)
- completedBy: "ai-assistant" fuer AI-erledigte Tasks
---
## Abhaengigkeiten
- **Task 1 → Task 2 → Task 3** (sequentiell: Schema → Types → API)
- **Task 4** benoetigt Task 2 (StaffingRequirement-Typ mit budgetCents)
- **Task 5** benoetigt Task 1+3 (budgetCents auf DemandRequirement + API liefert es)
- **Task 6** benoetigt Task 5 (gleiche Berechnung)
- Task 4 und Task 5 koennen **parallel** nach Task 3
```
Task 1 (Shared Types) ---+
Task 2 (Schema) ---------+--> Task 3 (db:push)
|
Task 3 --> Task 4 (SSE) |
Task 3 --> Task 5 (Router)|
Task 3 --> Task 6 (Targeting)
Task 3 --> Task 7 (Email) |
Task 3 --> Task 8 (Actions)|
|
Task 5 + 6 --> Task 9 (Scheduler)
Task 5 + 8 --> Task 10-12 (AI)
|
Task 5 --> Task 13-21 (Frontend, parallel moeglich)
Task 5 --> Task 22 (Auto-Tasks)
```
**Parallel:**
- Task 4 + 5 + 6 + 7 + 8 (verschiedene Dateien)
- Task 13-21 (verschiedene Dateien, 13+14 vor 17+18 empfohlen)
**Sequentiell:**
- Task 1 -> 2 -> 3 (Schema)
- Task 5 -> 10 -> 11 (Router -> Tools -> Executors)
---
## Akzeptanzkriterien
- [ ] `pnpm db:push` laeuft ohne Fehler
- [ ] `pnpm db:push` ohne Fehler
- [ ] `pnpm --filter @planarchy/api exec tsc --noEmit` — 0 Errors
- [ ] `pnpm --filter @planarchy/web exec tsc --noEmit` — 0 Errors
- [ ] `pnpm test:unit` — alle Tests gruen
- [ ] `pnpm --filter @planarchy/web exec tsc --noEmit` — keine neuen Errors
- [ ] Project Wizard Step 3: Budget-Input pro Rolle sichtbar, Restbudget wird live berechnet
- [ ] Project Detail `/projects/[id]`: Demands-Tabelle zeigt Allocated / Booked / Remaining Budget
- [ ] Fill Demand Modal: Rollen-Budget und geschaetzte Kosten sichtbar
- [ ] Bestehende Projekte/Demands funktionieren weiterhin (budgetCents default 0)
- [ ] User kann eigene Erinnerung anlegen (Datum, Wiederholung, Entity)
- [ ] Admin/Manager kann Broadcast an Rolle/Projekt/OrgUnit senden
- [ ] Broadcast erzeugt individuelle Notifications pro Empfaenger
- [ ] Tasks im Dashboard-Widget, sortiert nach Priority + Due-Date
- [ ] Task-Status aenderbar ueber UI (Open -> In Progress -> Done/Dismissed)
- [ ] AI-Assistent kann list_tasks aufrufen und offene Tasks anzeigen
- [ ] AI-Assistent kann execute_task_action ausfuehren (z.B. Urlaub genehmigen)
- [ ] Erledigte Tasks zeigen completedBy (User oder "AI-Assistent")
- [ ] Email-Versand bei channel "email" oder "both"
- [ ] SSE-Events invalidieren React-Query-Caches
- [ ] Reminder-Scheduler erzeugt puenktlich Notifications
- [ ] RBAC: User sehen nur eigene; Manager zugewiesene; Admin Broadcasts
---
## Risiken & offene Fragen
1. **Abwaertskompatibilitaet:** `@default(0)` stellt sicher, dass bestehende Demands kein Budget haben (0 = nicht gesetzt). UI sollte "Not set" anzeigen wenn 0.
2. **Budget-Berechnung Booked:** `dailyCostCents` ist pro Tag. Gebuchte Kosten = `dailyCostCents * Anzahl Arbeitstage im Zeitraum`. Diese Berechnung existiert bereits im Engine-Paket (`computeBudgetStatus`).
3. **StaffingReqs JSONB:** Die `staffingReqs` auf Project sind JSONB. Aeltere Projekte haben kein `budgetCents` darin — der Wizard muss `budgetCents ?? 0` defaulten.
4. **Budget-Ueberschreitung:** Soll weiterhin erlaubt sein (Warnung, kein Block) — konsistent mit dem bestehenden Ansatz bei Projekt-Budget.
### Risiken
1. **Reminder-Scheduler Zuverlaessigkeit**: Node.js-setInterval kann bei Restart verpassen. Mitigation: Catch-up bei Start (alle ueberfaelligen sofort ausloesen).
2. **Broadcast-Skalierung**: "An alle" mit 500 Usern = 500 Rows. Mitigation: Batch-Insert (createMany).
3. **Task-Action-Sicherheit**: Permissions pro Action pruefen, nicht pauschal. Mitigation: Action-Registry mit Permission pro Handler.
4. **Schema-Migration**: Neue Felder nullable oder mit Default -> bestehende Notifications funktionieren weiter.
### Offene Fragen
1. **Scheduler**: setInterval im SSE-Handler oder separater Worker/Cron? Empfehlung: setInterval (reicht fuer <1000 User)
2. **Task-Delegation**: User duerfen Tasks an andere weiterdelegieren? Empfehlung: Ja (Manager-only)
3. **Retention**: Wie lange alte Notifications aufbewahren? Empfehlung: 90 Tage Auto-Cleanup
4. **Recurring Tasks**: Tasks wiederkehrend wie Reminders? Empfehlung: Phase 2
5. **Approval-Chains**: Mehrstufige Genehmigung? Empfehlung: Phase 2, erstmal einstufig