1df208dbcc
Allocation bars that have active optimistic overrides (post-drag, awaiting server confirmation) now pulse subtly via animate-pulse. The pending set is derived from the existing optimisticAllocations map keys, requiring no additional state. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
587 lines
24 KiB
TypeScript
587 lines
24 KiB
TypeScript
"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<FormState>(() =>
|
|
project ? projectToForm(project) : getDefaultForm(),
|
|
);
|
|
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
|
|
const [serverError, setServerError] = useState<string | null>(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<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(),
|
|
...(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 (
|
|
<AnimatedModal open={true} onClose={onClose} maxWidth="max-w-xl" className="mx-4">
|
|
<div>
|
|
{/* 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>
|
|
<InfoTooltip content="Unique project identifier for time tracking and cost attribution. Cannot be changed after creation." />
|
|
</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>
|
|
<InfoTooltip content="Display name shown on the timeline and in reports." />
|
|
</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
|
|
<InfoTooltip content="BD = Business Development (pre-sales), CHARGEABLE = billable client work, INTERNAL = internal project, OVERHEAD = non-project overhead." />
|
|
</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
|
|
<InfoTooltip content="INT = staffed with internal resources, EXT = staffed with external contractors or freelancers." />
|
|
</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 %
|
|
<InfoTooltip content="Likelihood of winning this project (0-100%). Affects the weighted pipeline value: budget x 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
|
|
<InfoTooltip content="Groups projects for chargeability and utilization reporting. Determines how hours count toward resource utilization." />
|
|
</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
|
|
<InfoTooltip content="The client or customer this project is for. Used for filtering and reporting." />
|
|
</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 & Budget
|
|
</legend>
|
|
<div className="grid grid-cols-4 gap-4">
|
|
<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"
|
|
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
|
|
<InfoTooltip content="Last day of the project period. Must be on or after the start 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 (€)
|
|
<InfoTooltip content="Total project budget in EUR. Tracked against the sum of assignment costs (hours x daily rate)." />
|
|
</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>
|
|
<label className={labelClass} htmlFor="shoringThreshold">
|
|
Min Offshore %
|
|
<InfoTooltip content="Minimum offshore staffing target (0-100). Green when met, red when below. Higher offshore = more cost-efficient. Default: 55%." />
|
|
</label>
|
|
<input
|
|
id="shoringThreshold"
|
|
type="number"
|
|
min={0}
|
|
max={100}
|
|
value={form.shoringThreshold}
|
|
onChange={(e) => setField("shoringThreshold", e.target.value)}
|
|
placeholder="55"
|
|
className={inputClass}
|
|
/>
|
|
</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
|
|
<InfoTooltip content="DRAFT = not visible on timeline, ACTIVE = in progress, ON_HOLD = paused, COMPLETED = finished, CANCELLED = abandoned." />
|
|
</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 <span className="text-red-500">*</span>
|
|
<InfoTooltip content="Project lead or account manager responsible for this project. Required." />
|
|
</label>
|
|
<input
|
|
id="responsiblePerson"
|
|
type="text"
|
|
value={form.responsiblePerson}
|
|
onChange={(e) => setField("responsiblePerson", e.target.value)}
|
|
placeholder="Name or EID"
|
|
required
|
|
className={inputClass}
|
|
/>
|
|
</div>
|
|
<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
|
|
id="projectColor"
|
|
type="color"
|
|
value={form.color || "#3b82f6"}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<span className="text-xs text-gray-400 dark:text-gray-500">
|
|
{form.color || "Default"}
|
|
</span>
|
|
{form.color && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setField("color", "")}
|
|
className="text-xs text-gray-400 hover:text-red-500"
|
|
>
|
|
Clear
|
|
</button>
|
|
)}
|
|
</div>
|
|
</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>
|
|
</AnimatedModal>
|
|
);
|
|
}
|