chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
@@ -0,0 +1,544 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
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";
const ORDER_TYPE_OPTIONS = [
{ value: "BD", label: "BD" },
{ value: "CHARGEABLE", label: "Chargeable" },
{ value: "INTERNAL", label: "Internal" },
{ value: "OVERHEAD", label: "Overhead" },
] as const;
const ALLOCATION_TYPE_OPTIONS = [
{ value: "INT", label: "INT" },
{ value: "EXT", label: "EXT" },
] as const;
const STATUS_OPTIONS = [
{ value: "DRAFT", label: "Draft" },
{ value: "ACTIVE", label: "Active" },
{ value: "ON_HOLD", label: "On Hold" },
{ value: "COMPLETED", label: "Completed" },
{ value: "CANCELLED", label: "Cancelled" },
] as const;
function formatDateForInput(date: Date | string): string {
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
interface FormState {
shortCode: string;
name: string;
orderType: string;
allocationType: string;
winProbability: string;
budgetEur: string;
startDate: string;
endDate: string;
status: string;
responsiblePerson: string;
utilizationCategoryId: string;
clientId: string;
}
function getDefaultForm(): FormState {
const today = formatDateForInput(new Date());
return {
shortCode: "",
name: "",
orderType: "CHARGEABLE",
allocationType: "INT",
winProbability: "100",
budgetEur: "",
startDate: today,
endDate: today,
status: "DRAFT",
responsiblePerson: "",
utilizationCategoryId: "",
clientId: "",
};
}
function projectToForm(project: Project): FormState {
return {
shortCode: project.shortCode,
name: project.name,
orderType: project.orderType,
allocationType: project.allocationType,
winProbability: String(project.winProbability),
budgetEur: String(Math.round(project.budgetCents) / 100),
startDate: formatDateForInput(project.startDate),
endDate: formatDateForInput(project.endDate),
status: project.status,
responsiblePerson: project.responsiblePerson ?? "",
utilizationCategoryId: (project as unknown as { utilizationCategoryId?: string | null }).utilizationCategoryId ?? "",
clientId: (project as unknown as { clientId?: string | null }).clientId ?? "",
};
}
interface ProjectModalProps {
project?: Project | null;
onClose: () => void;
}
export function ProjectModal({ project, onClose }: ProjectModalProps) {
const isEdit = !!project;
const utils = trpc.useUtils();
const [form, setForm] = useState<FormState>(() =>
project ? projectToForm(project) : getDefaultForm(),
);
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
const [serverError, setServerError] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
useFocusTrap(panelRef, true);
const { data: utilizationCategories } = trpc.utilizationCategory.list.useQuery(undefined, { staleTime: 60_000 });
const { data: clientList } = trpc.clientEntity.list.useQuery(undefined, { staleTime: 60_000 });
// @ts-ignore TS2589: tRPC infers union type too deeply for CreateProjectSchema with .refine()
const createMutation = trpc.project.create.useMutation({
onSuccess: async () => {
await utils.project.list.invalidate();
onClose();
},
onError: (err) => {
setServerError(err.message);
},
});
const updateMutation = trpc.project.update.useMutation({
onSuccess: async () => {
await utils.project.list.invalidate();
onClose();
},
onError: (err) => {
setServerError(err.message);
},
});
const isLoading = createMutation.isPending || updateMutation.isPending;
// Close on Escape key
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
function setField<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((prev) => ({ ...prev, [key]: value }));
setErrors((prev) => ({ ...prev, [key]: undefined }));
setServerError(null);
}
function validate(): boolean {
const newErrors: Partial<Record<keyof FormState, string>> = {};
if (!isEdit && !form.shortCode.trim()) {
newErrors.shortCode = "Short code is required.";
} else if (!isEdit && !/^[A-Z0-9_-]+$/.test(form.shortCode.trim())) {
newErrors.shortCode = "Must be uppercase alphanumeric (A-Z, 0-9, _, -).";
}
if (!form.name.trim()) {
newErrors.name = "Name is required.";
}
const winProb = Number(form.winProbability);
if (isNaN(winProb) || winProb < 0 || winProb > 100) {
newErrors.winProbability = "Must be between 0 and 100.";
}
const budget = parseFloat(form.budgetEur);
if (isNaN(budget) || budget < 0) {
newErrors.budgetEur = "Must be a positive number.";
}
if (!form.startDate) {
newErrors.startDate = "Start date is required.";
}
if (!form.endDate) {
newErrors.endDate = "End date is required.";
} else if (form.startDate && form.endDate < form.startDate) {
newErrors.endDate = "End date must be on or after start date.";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
const budgetCents = Math.round(parseFloat(form.budgetEur) * 100);
const winProbability = Number(form.winProbability);
if (isEdit && project) {
updateMutation.mutate({
id: project.id,
data: {
name: form.name.trim(),
orderType: form.orderType as unknown as OrderType,
allocationType: form.allocationType as unknown as AllocationType,
winProbability,
budgetCents,
startDate: new Date(form.startDate),
endDate: new Date(form.endDate),
status: form.status as unknown as ProjectStatus,
responsiblePerson: form.responsiblePerson.trim() || undefined,
...(form.utilizationCategoryId ? { utilizationCategoryId: form.utilizationCategoryId } : {}),
...(form.clientId ? { clientId: form.clientId } : {}),
},
});
} else {
createMutation.mutate({
shortCode: form.shortCode.trim(),
name: form.name.trim(),
orderType: form.orderType as unknown as OrderType,
allocationType: form.allocationType as unknown as AllocationType,
winProbability,
budgetCents,
startDate: new Date(form.startDate),
endDate: new Date(form.endDate),
status: form.status as unknown as ProjectStatus,
staffingReqs: [],
dynamicFields: {},
responsiblePerson: form.responsiblePerson.trim() || undefined,
...(form.utilizationCategoryId ? { utilizationCategoryId: form.utilizationCategoryId } : {}),
...(form.clientId ? { clientId: form.clientId } : {}),
});
}
}
const inputClass =
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100";
const inputErrorClass =
"w-full px-3 py-2 border border-red-400 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-400 text-sm dark:bg-gray-900 dark:text-gray-100";
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={panelRef}
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-xl mx-4"
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
>
{/* 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">
{isEdit ? "Edit Project" : "New Project"}
</h2>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Close"
>
<svg className="w-5 h-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>
{/* Form */}
<form onSubmit={handleSubmit} noValidate>
<div className="px-6 py-5 space-y-6">
{serverError && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg px-4 py-3 text-sm text-red-700 dark:text-red-400">
{serverError}
</div>
)}
{/* Section 1: Identity */}
<fieldset>
<legend className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Identity
</legend>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass} htmlFor="shortCode">
Chargecode <span className="text-red-500">*</span>
</label>
<input
id="shortCode"
type="text"
value={form.shortCode}
onChange={(e) => setField("shortCode", e.target.value.toUpperCase())}
disabled={isEdit}
placeholder="PRJ-001"
className={
isEdit
? `${inputClass} bg-gray-50 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed`
: errors.shortCode
? inputErrorClass
: inputClass
}
/>
{errors.shortCode && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.shortCode}</p>
)}
</div>
<div>
<label className={labelClass} htmlFor="name">
Name <span className="text-red-500">*</span>
</label>
<input
id="name"
type="text"
value={form.name}
onChange={(e) => setField("name", e.target.value)}
placeholder="Project name"
className={errors.name ? inputErrorClass : inputClass}
/>
{errors.name && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.name}</p>
)}
</div>
</div>
</fieldset>
{/* Section 2: Classification */}
<fieldset>
<legend className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Classification
</legend>
<div className="grid grid-cols-3 gap-4">
<div>
<label className={labelClass} htmlFor="orderType">
Order Type
</label>
<select
id="orderType"
value={form.orderType}
onChange={(e) => setField("orderType", e.target.value)}
className={inputClass}
>
{ORDER_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className={labelClass} htmlFor="allocationType">
Allocation
</label>
<select
id="allocationType"
value={form.allocationType}
onChange={(e) => setField("allocationType", e.target.value)}
className={inputClass}
>
{ALLOCATION_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className={labelClass} htmlFor="winProbability">
Win Probability %
</label>
<input
id="winProbability"
type="number"
min={0}
max={100}
value={form.winProbability}
onChange={(e) => setField("winProbability", e.target.value)}
className={errors.winProbability ? inputErrorClass : inputClass}
/>
{errors.winProbability && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.winProbability}</p>
)}
</div>
</div>
</fieldset>
{/* Section: Categorization */}
<fieldset>
<legend className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Categorization
</legend>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass} htmlFor="utilizationCategoryId">
Utilization Category
</label>
<select
id="utilizationCategoryId"
value={form.utilizationCategoryId}
onChange={(e) => setField("utilizationCategoryId", e.target.value)}
className={inputClass}
>
<option value=""> Not specified </option>
{(utilizationCategories ?? []).map((cat) => (
<option key={cat.id} value={cat.id}>
{(cat as unknown as { code: string }).code} {(cat as unknown as { name: string }).name}
</option>
))}
</select>
</div>
<div>
<label className={labelClass} htmlFor="clientId">
Client
</label>
<select
id="clientId"
value={form.clientId}
onChange={(e) => setField("clientId", e.target.value)}
className={inputClass}
>
<option value=""> Not specified </option>
{(clientList ?? []).map((c) => (
<option key={c.id} value={c.id}>
{(c as unknown as { name: string }).name}
{(c as unknown as { code: string | null }).code ? ` [${(c as unknown as { code: string }).code}]` : ""}
</option>
))}
</select>
</div>
</div>
</fieldset>
{/* Section 3: Timeline & Budget */}
<fieldset>
<legend className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Timeline &amp; Budget
</legend>
<div className="grid grid-cols-3 gap-4">
<div>
<label className={labelClass} htmlFor="startDate">
Start Date
</label>
<DateInput
id="startDate"
value={form.startDate}
onChange={(v) => setField("startDate", v)}
className={errors.startDate ? inputErrorClass : inputClass}
/>
{errors.startDate && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.startDate}</p>
)}
</div>
<div>
<label className={labelClass} htmlFor="endDate">
End Date
</label>
<DateInput
id="endDate"
value={form.endDate}
min={form.startDate}
onChange={(v) => setField("endDate", v)}
className={errors.endDate ? inputErrorClass : inputClass}
/>
{errors.endDate && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.endDate}</p>
)}
</div>
<div>
<label className={labelClass} htmlFor="budgetEur">
Budget ()
</label>
<input
id="budgetEur"
type="number"
min={0}
step="0.01"
value={form.budgetEur}
onChange={(e) => setField("budgetEur", e.target.value)}
placeholder="0.00"
className={errors.budgetEur ? inputErrorClass : inputClass}
/>
{errors.budgetEur && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.budgetEur}</p>
)}
</div>
</div>
</fieldset>
{/* Section 4: Status */}
<fieldset>
<legend className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Status
</legend>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass} htmlFor="status">
Status
</label>
<select
id="status"
value={form.status}
onChange={(e) => setField("status", e.target.value)}
className={inputClass}
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className={labelClass} htmlFor="responsiblePerson">
Responsible Person
</label>
<input
id="responsiblePerson"
type="text"
value={form.responsiblePerson}
onChange={(e) => setField("responsiblePerson", e.target.value)}
placeholder="Name or EID"
className={inputClass}
/>
</div>
</div>
</fieldset>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 rounded-b-xl">
<button
type="button"
onClick={onClose}
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 transition-colors"
>
{isLoading ? (isEdit ? "Saving…" : "Creating…") : isEdit ? "Save Changes" : "Create Project"}
</button>
</div>
</form>
</div>
</div>
);
}