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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user