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

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

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-18 11:31:56 +01:00
parent 21af720f90
commit 093e13b88f
86 changed files with 5623 additions and 744 deletions
@@ -1,7 +1,7 @@
import { PublicHolidayBatch } from "~/components/vacations/PublicHolidayBatch.js";
import { EntitlementManager } from "~/components/vacations/EntitlementManager.js";
export const metadata = { title: "Vacation Management — Planarchy" };
export const metadata = { title: "Vacation Management — plANARCHY" };
export default function AdminVacationsPage() {
return (
@@ -0,0 +1,5 @@
import ComputationGraphClient from "~/components/analytics/ComputationGraphClient";
export default function ComputationGraphPage() {
return <ComputationGraphClient />;
}
@@ -5,6 +5,7 @@ import Link from "next/link";
import { EstimateStatus, type EstimateVersionStatus } from "@planarchy/shared";
import { clsx } from "clsx";
import { EstimateWizard } from "~/components/estimates/EstimateWizard.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { formatDateLong, formatMoney } from "~/lib/format.js";
import { trpc } from "~/lib/trpc/client.js";
@@ -121,7 +122,7 @@ function EstimateDetailPanel({
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-400">
Estimate detail
Estimate detail <InfoTooltip content="Pre-project cost and effort calculation. Estimates model staffing demand, scope, and financials before work begins." />
</p>
<h2 className="mt-2 text-xl font-semibold text-gray-900 dark:text-gray-50">
{estimate.name}
@@ -205,7 +206,7 @@ function EstimateDetailPanel({
<section>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Scope items
Scope items <InfoTooltip content="Deliverables or work packages that define what is included in this estimate." />
</h3>
<span className="text-xs text-gray-400">{latestVersion.scopeItems.length}</span>
</div>
@@ -238,7 +239,7 @@ function EstimateDetailPanel({
<section>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Demand lines
Demand lines <InfoTooltip content="Staffing demand rows. Each line represents a role or resource with hours, cost rate, and sell rate." />
</h3>
<span className="text-xs text-gray-400">{latestVersion.demandLines.length}</span>
</div>
@@ -344,13 +345,13 @@ function EstimateCard({
<div className="mt-5 grid gap-3 sm:grid-cols-2">
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Opportunity</p>
<p className="text-xs uppercase tracking-wide text-gray-400">Opportunity <InfoTooltip content="External CRM or sales reference ID linking this estimate to a sales opportunity." /></p>
<p className="mt-1 text-sm text-gray-700 dark:text-gray-200">
{estimate.opportunityId ?? "Not set"}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Updated</p>
<p className="text-xs uppercase tracking-wide text-gray-400">Updated <InfoTooltip content="When this estimate or any of its versions was last modified." /></p>
<p className="mt-1 text-sm text-gray-700 dark:text-gray-200">
{formatDateLong(estimate.updatedAt)}
</p>
@@ -465,7 +466,7 @@ export function EstimatesClient() {
No estimates yet
</p>
<p className="mt-2 text-sm text-gray-400 dark:text-gray-500">
Start with the wizard to create a connected estimate from Planarchy data.
Start with the wizard to create a connected estimate from plANARCHY data.
</p>
</div>
) : (
@@ -162,6 +162,8 @@ interface ProjectRow {
totalPersonDays: number;
utilizationPercent: number;
dynamicFields?: Record<string, unknown> | null;
coverImageUrl?: string | null;
color?: string | null;
}
// ─── Main component ───────────────────────────────────────────────────────────
@@ -351,8 +353,21 @@ export function ProjectsClient() {
case "name":
return (
<td key={col.key} className="max-w-xs truncate px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">
<Link href={`/projects/${project.id}`} className="transition hover:text-brand-600 hover:underline">
{project.name}
<Link href={`/projects/${project.id}`} className="inline-flex items-center gap-2 transition hover:text-brand-600 hover:underline">
{project.coverImageUrl ? (
<img src={project.coverImageUrl} alt="" className="h-6 w-6 flex-shrink-0 rounded object-cover" />
) : (
<span
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded text-[9px] font-bold opacity-60"
style={{
backgroundColor: (project.color ?? "#6366f1") + "22",
color: project.color ?? "#6366f1",
}}
>
{project.name.split(/\s+/).map((w) => w[0]).filter(Boolean).slice(0, 2).join("").toUpperCase()}
</span>
)}
<span className="truncate">{project.name}</span>
</Link>
</td>
);
+28 -9
View File
@@ -2,11 +2,16 @@ import { notFound } from "next/navigation";
import { formatDate } from "~/lib/format.js";
import Link from "next/link";
import { createCaller } from "~/server/trpc.js";
import { auth } from "~/server/auth.js";
import { BudgetStatusCard } from "~/components/projects/BudgetStatusCard.js";
import { ProjectDetailActions } from "~/components/projects/ProjectDetailClient.js";
import { ProjectDemandsTable } from "~/components/projects/ProjectDemandsTable.js";
import { ProjectAssignmentsTable } from "~/components/projects/ProjectAssignmentsTable.js";
import { PROJECT_STATUS_BADGE as STATUS_COLORS, ORDER_TYPE_BADGE as ORDER_TYPE_COLORS } from "~/lib/status-styles.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { CoverArtSection } from "~/components/projects/CoverArtSection.js";
const EDIT_ROLES = new Set(["ADMIN", "MANAGER"]);
interface ProjectDetailPageProps {
params: Promise<{ id: string }>;
@@ -14,7 +19,9 @@ interface ProjectDetailPageProps {
export default async function ProjectDetailPage({ params }: ProjectDetailPageProps) {
const { id } = await params;
const trpc = await createCaller();
const [trpc, session] = await Promise.all([createCaller(), auth()]);
const userRole = (session?.user as { role?: string } | undefined)?.role ?? "USER";
const canEditProject = EDIT_ROLES.has(userRole);
let project: Awaited<ReturnType<typeof trpc.project.getById>>;
try {
@@ -41,8 +48,18 @@ export default async function ProjectDetailPage({ params }: ProjectDetailPagePro
Back to Projects
</Link>
{/* Cover Art */}
<CoverArtSection
projectId={project.id}
coverImageUrl={project.coverImageUrl}
coverFocusY={project.coverFocusY}
projectColor={project.color}
projectName={project.name}
canEdit={canEditProject}
/>
{/* Project header */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="bg-white rounded-xl border border-gray-200 p-6 dark:bg-gray-900 dark:border-gray-700">
<div className="flex items-start justify-between gap-4 mb-4">
<div>
<div className="flex items-center gap-3 mb-1">
@@ -50,9 +67,11 @@ export default async function ProjectDetailPage({ params }: ProjectDetailPagePro
<span className={`inline-block px-2 py-0.5 text-xs rounded-full ${STATUS_COLORS[project.status] ?? ""}`}>
{project.status}
</span>
<InfoTooltip content="Project lifecycle status: DRAFT = not yet visible, ACTIVE = in progress, ON_HOLD = paused, COMPLETED = finished, CANCELLED = abandoned." />
<span className={`inline-block px-2 py-0.5 text-xs rounded-full ${ORDER_TYPE_COLORS[project.orderType] ?? ""}`}>
{project.orderType}
</span>
<InfoTooltip content="BD = Business Development (pre-sales), CHARGEABLE = billable client work, INTERNAL = internal project, OVERHEAD = non-project overhead." />
</div>
<h1 className="text-2xl font-bold text-gray-900">{project.name}</h1>
</div>
@@ -63,7 +82,7 @@ export default async function ProjectDetailPage({ params }: ProjectDetailPagePro
{" — "}
{formatDate(project.endDate)}
</div>
<div className="mt-0.5">Win probability: {project.winProbability}%</div>
<div className="mt-0.5 flex items-center">Win probability: {project.winProbability}%<InfoTooltip content="Likelihood of winning this project (0-100%). Used to calculate weighted pipeline value (budget x probability)." /></div>
</div>
<ProjectDetailActions project={project as never} />
</div>
@@ -71,30 +90,30 @@ export default async function ProjectDetailPage({ params }: ProjectDetailPagePro
<dl className="grid grid-cols-2 gap-x-8 gap-y-3 sm:grid-cols-4 mt-4 pt-4 border-t border-gray-100">
<div>
<dt className="text-xs text-gray-500">Chargecode</dt>
<dt className="text-xs text-gray-500 flex items-center">Chargecode<InfoTooltip content="Unique project identifier used for time tracking and cost attribution." /></dt>
<dd className="mt-0.5 text-sm font-mono font-medium text-gray-900">{project.shortCode}</dd>
</div>
<div>
<dt className="text-xs text-gray-500">Order Type</dt>
<dt className="text-xs text-gray-500 flex items-center">Order Type<InfoTooltip content="BD = Business Development, CHARGEABLE = billable client work, INTERNAL = internal project, OVERHEAD = overhead costs." /></dt>
<dd className="mt-0.5 text-sm text-gray-900">{project.orderType}</dd>
</div>
<div>
<dt className="text-xs text-gray-500">Allocation Type</dt>
<dt className="text-xs text-gray-500 flex items-center">Allocation Type<InfoTooltip content="INT = staffed with internal resources, EXT = staffed with external contractors." /></dt>
<dd className="mt-0.5 text-sm text-gray-900">{project.allocationType}</dd>
</div>
<div>
<dt className="text-xs text-gray-500">Assignments</dt>
<dt className="text-xs text-gray-500 flex items-center">Assignments<InfoTooltip content="Number of active resource assignments (confirmed or in-progress allocations) on this project." /></dt>
<dd className="mt-0.5 text-sm text-gray-900">{activeAssignments.length} active</dd>
</div>
<div>
<dt className="text-xs text-gray-500">Open Demands</dt>
<dt className="text-xs text-gray-500 flex items-center">Open Demands<InfoTooltip content="Staffing requirements that still need resources. Unfilled seats are demand positions not yet assigned to a person." /></dt>
<dd className="mt-0.5 text-sm text-gray-900">
{activeDemands.length} items · {unfilledSeats}/{requestedSeats} seats unfilled
</dd>
</div>
{project.responsiblePerson && (
<div className="sm:col-span-2">
<dt className="text-xs text-gray-500">Responsible Person</dt>
<dt className="text-xs text-gray-500 flex items-center">Responsible Person<InfoTooltip content="The project lead or account manager responsible for this project." /></dt>
<dd className="mt-0.5 text-sm font-medium text-gray-900">{project.responsiblePerson}</dd>
</div>
)}
@@ -978,7 +978,7 @@ export function ResourcesClient() {
sortField={sortField}
sortDir={sortDir}
onSort={toggle}
tooltip="Unique employee identifier used across all Planarchy records."
tooltip="Unique employee identifier used across all plANARCHY records."
/>
);
case "displayName":
@@ -9,9 +9,9 @@ export async function generateMetadata(
try {
const trpc = await createCaller();
const resource = await trpc.resource.getById({ id });
return { title: `${resource.displayName} — Resources | Planarchy` };
return { title: `${resource.displayName} — Resources | plANARCHY` };
} catch {
return { title: "Resource — Planarchy" };
return { title: "Resource — plANARCHY" };
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { MyVacationsClient } from "~/components/vacations/MyVacationsClient.js";
export const metadata = { title: "My Vacations — Planarchy" };
export const metadata = { title: "My Vacations — plANARCHY" };
export default function MyVacationsPage() {
return <MyVacationsClient />;
+3 -11
View File
@@ -37,7 +37,7 @@ export default function SignInPage() {
<div className="hidden rounded-[2rem] border border-white/70 bg-white/75 p-10 shadow-2xl backdrop-blur lg:flex lg:flex-col lg:justify-between dark:border-slate-800 dark:bg-slate-950/60">
<div>
<span className="inline-flex rounded-full border border-brand-200 bg-brand-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-brand-700 dark:border-brand-900/50 dark:bg-brand-900/20 dark:text-brand-300">
Planarchy Control Center
plANARCHY Control Center
</span>
<h1 className="mt-6 font-display text-5xl font-semibold leading-tight text-gray-900 dark:text-gray-50">
Resource planning that stays readable under pressure.
@@ -66,7 +66,7 @@ export default function SignInPage() {
<div className="app-surface-strong p-8">
<div className="mb-8">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-brand-600">Welcome Back</p>
<h2 className="mt-3 font-display text-4xl font-semibold text-gray-900 dark:text-gray-50">Sign in to Planarchy</h2>
<h2 className="mt-3 font-display text-4xl font-semibold text-gray-900 dark:text-gray-50">Sign in to plANARCHY</h2>
<p className="mt-2 text-sm text-gray-500">Resource Planning, staffing, and forecasting.</p>
</div>
@@ -87,7 +87,7 @@ export default function SignInPage() {
value={email}
onChange={(e) => setEmail(e.target.value)}
className="app-input"
placeholder="admin@planarchy.dev"
placeholder="you@company.com"
required
/>
</div>
@@ -116,14 +116,6 @@ export default function SignInPage() {
</button>
</form>
<div className="mt-6 rounded-2xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-700 dark:bg-gray-900/70">
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-gray-500">Demo accounts</p>
<div className="space-y-1.5 text-sm text-gray-600 dark:text-gray-300">
<p><span className="font-mono text-xs">admin@planarchy.dev</span> / admin123</p>
<p><span className="font-mono text-xs">manager@planarchy.dev</span> / manager123</p>
<p><span className="font-mono text-xs">viewer@planarchy.dev</span> / viewer123</p>
</div>
</div>
</div>
</div>
</div>
+14 -1
View File
@@ -16,8 +16,21 @@ const displayFont = Manrope({
});
export const metadata: Metadata = {
title: "Planarchy — Resource Planning",
metadataBase: new URL("https://planarchy.hartmut-noerenberg.com"),
title: "plANARCHY — Resource Planning",
description: "Interactive resource planning and project staffing tool",
openGraph: {
title: "plANARCHY — Resource Planning",
description: "Estimates, staffing, chargeability, and timelines in one workspace.",
images: [{ url: "/og-image.png", width: 1024, height: 1024, alt: "plANARCHY Logo" }],
type: "website",
},
twitter: {
card: "summary_large_image",
title: "plANARCHY — Resource Planning",
description: "Estimates, staffing, chargeability, and timelines in one workspace.",
images: ["/og-image.png"],
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {