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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,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 />;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">×</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. "Welche Ressourcen gibt es?" oder "Budget von Z033T593?"</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 €/h <span className="text-red-500">*</span>
|
||||
LCR €/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 €/h <span className="text-red-500">*</span>
|
||||
UCR €/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,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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user