"use client"; import { useState } from "react"; import { OrderType, AllocationType, ProjectStatus } from "@capakraken/shared"; import { AnimatedModal } from "~/components/ui/AnimatedModal.js"; import type { Project } from "@capakraken/shared"; import { trpc } from "~/lib/trpc/client.js"; import { DateInput } from "~/components/ui/DateInput.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { toDateInputValue } from "~/lib/format.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; interface FormState { shortCode: string; name: string; orderType: string; allocationType: string; winProbability: string; budgetEur: string; startDate: string; endDate: string; status: string; responsiblePerson: string; color: string; utilizationCategoryId: string; clientId: string; shoringThreshold: string; } function getDefaultForm(): FormState { const today = toDateInputValue(new Date()); return { shortCode: "", name: "", orderType: "CHARGEABLE", allocationType: "INT", winProbability: "100", budgetEur: "", startDate: today, endDate: today, status: "DRAFT", responsiblePerson: "", color: "", utilizationCategoryId: "", clientId: "", shoringThreshold: "55", }; } 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: toDateInputValue(project.startDate), endDate: toDateInputValue(project.endDate), status: project.status, responsiblePerson: project.responsiblePerson ?? "", color: (project as unknown as { color?: string | null }).color ?? "", utilizationCategoryId: (project as unknown as { utilizationCategoryId?: string | null }).utilizationCategoryId ?? "", clientId: (project as unknown as { clientId?: string | null }).clientId ?? "", shoringThreshold: String((project as unknown as { shoringThreshold?: number | null }).shoringThreshold ?? 55), }; } interface ProjectModalProps { project?: Project | null; onClose: () => void; onSuccess?: (name: string) => void; } export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps) { const isEdit = !!project; const utils = trpc.useUtils(); const [form, setForm] = useState(() => project ? projectToForm(project) : getDefaultForm(), ); const [errors, setErrors] = useState>>({}); const [serverError, setServerError] = useState(null); 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.listWithCosts.invalidate(); onSuccess?.(form.name.trim()); onClose(); }, onError: (err) => { setServerError(err.message); }, }); const updateMutation = trpc.project.update.useMutation({ onSuccess: async () => { await utils.project.listWithCosts.invalidate(); onSuccess?.(form.name.trim()); onClose(); }, onError: (err) => { setServerError(err.message); }, }); const isLoading = createMutation.isPending || updateMutation.isPending; function setField(key: K, value: FormState[K]) { setForm((prev) => ({ ...prev, [key]: value })); setErrors((prev) => ({ ...prev, [key]: undefined })); setServerError(null); } function validate(): boolean { const newErrors: Partial> = {}; 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(), ...(form.color ? { color: form.color } : {}), ...(form.utilizationCategoryId ? { utilizationCategoryId: form.utilizationCategoryId } : {}), ...(form.clientId ? { clientId: form.clientId } : {}), shoringThreshold: Number(form.shoringThreshold), }, }); } 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(), ...(form.color ? { color: form.color } : {}), ...(form.utilizationCategoryId ? { utilizationCategoryId: form.utilizationCategoryId } : {}), ...(form.clientId ? { clientId: form.clientId } : {}), shoringThreshold: Number(form.shoringThreshold), }); } } 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 (
{/* Header */}

{isEdit ? "Edit Project" : "New Project"}

{/* Form */}
{serverError && (
{serverError}
)} {/* Section 1: Identity */}
Identity
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 && (

{errors.shortCode}

)}
setField("name", e.target.value)} placeholder="Project name" className={errors.name ? inputErrorClass : inputClass} /> {errors.name && (

{errors.name}

)}
{/* Section 2: Classification */}
Classification
setField("winProbability", e.target.value)} className={errors.winProbability ? inputErrorClass : inputClass} /> {errors.winProbability && (

{errors.winProbability}

)}
{/* Section: Categorization */}
Categorization
{/* Section 3: Timeline & Budget */}
Timeline & Budget
setField("startDate", v)} className={errors.startDate ? inputErrorClass : inputClass} /> {errors.startDate && (

{errors.startDate}

)}
setField("endDate", v)} className={errors.endDate ? inputErrorClass : inputClass} /> {errors.endDate && (

{errors.endDate}

)}
setField("budgetEur", e.target.value)} placeholder="0.00" className={errors.budgetEur ? inputErrorClass : inputClass} /> {errors.budgetEur && (

{errors.budgetEur}

)}
setField("shoringThreshold", e.target.value)} placeholder="55" className={inputClass} />
{/* Section 4: Status */}
Status
setField("responsiblePerson", e.target.value)} placeholder="Name or EID" required className={inputClass} />
setField("color", e.target.value)} className="w-10 h-10 rounded-lg border border-gray-300 dark:border-gray-600 cursor-pointer p-0.5" /> {form.color || "Default"} {form.color && ( )}
{/* Footer */}
); }