chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
|
||||
interface Warning {
|
||||
level: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface BudgetStatusBarProps {
|
||||
budgetCents: number;
|
||||
allocatedCents: number;
|
||||
confirmedCents: number;
|
||||
proposedCents: number;
|
||||
warnings: Warning[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function formatEur(cents: number): string {
|
||||
return (cents / 100).toLocaleString("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function getConfirmedBarColor(utilizationPercent: number): string {
|
||||
if (utilizationPercent > 95) return "bg-red-600";
|
||||
if (utilizationPercent > 85) return "bg-orange-600";
|
||||
if (utilizationPercent > 70) return "bg-yellow-600";
|
||||
return "bg-green-600";
|
||||
}
|
||||
|
||||
function getProposedBarColor(utilizationPercent: number): string {
|
||||
if (utilizationPercent > 95) return "bg-red-300";
|
||||
if (utilizationPercent > 85) return "bg-orange-300";
|
||||
if (utilizationPercent > 70) return "bg-yellow-300";
|
||||
return "bg-green-300";
|
||||
}
|
||||
|
||||
function getWarningBadgeStyle(level: string): string {
|
||||
if (level === "critical") return "bg-red-100 text-red-700 border border-red-200";
|
||||
if (level === "warning") return "bg-orange-100 text-orange-700 border border-orange-200";
|
||||
return "bg-yellow-100 text-yellow-700 border border-yellow-200";
|
||||
}
|
||||
|
||||
export function BudgetStatusBar({
|
||||
budgetCents,
|
||||
allocatedCents,
|
||||
confirmedCents,
|
||||
proposedCents,
|
||||
warnings,
|
||||
className,
|
||||
}: BudgetStatusBarProps) {
|
||||
const utilizationPercent = budgetCents > 0 ? (allocatedCents / budgetCents) * 100 : 0;
|
||||
const confirmedPercent = budgetCents > 0 ? (confirmedCents / budgetCents) * 100 : 0;
|
||||
const proposedPercent = budgetCents > 0 ? (proposedCents / budgetCents) * 100 : 0;
|
||||
const remainingCents = budgetCents - allocatedCents;
|
||||
|
||||
// Cap visual bar segments at 100% total
|
||||
const cappedConfirmedPercent = Math.min(confirmedPercent, 100);
|
||||
const cappedProposedPercent = Math.min(proposedPercent, Math.max(0, 100 - cappedConfirmedPercent));
|
||||
|
||||
const highestWarning = warnings.length > 0
|
||||
? warnings.reduce((prev, curr) => {
|
||||
const levels: Record<string, number> = { info: 0, warning: 1, critical: 2 };
|
||||
return (levels[curr.level] ?? 0) > (levels[prev.level] ?? 0) ? curr : prev;
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className={clsx("space-y-1.5", className)}>
|
||||
{/* Progress bar with stacked segments */}
|
||||
<div className="relative h-3 bg-gray-100 rounded-full overflow-hidden">
|
||||
{/* Confirmed segment */}
|
||||
<div
|
||||
className={clsx("absolute left-0 top-0 h-full transition-all", getConfirmedBarColor(utilizationPercent))}
|
||||
style={{ width: `${cappedConfirmedPercent}%` }}
|
||||
/>
|
||||
{/* Proposed segment */}
|
||||
<div
|
||||
className={clsx("absolute top-0 h-full transition-all", getProposedBarColor(utilizationPercent))}
|
||||
style={{ left: `${cappedConfirmedPercent}%`, width: `${cappedProposedPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Labels row */}
|
||||
<div className="flex items-center justify-between text-xs text-gray-600">
|
||||
<span>
|
||||
<span className="font-medium">{formatEur(allocatedCents)}</span>
|
||||
{" / "}
|
||||
<span>{formatEur(budgetCents)}</span>
|
||||
{" "}
|
||||
<span className="text-gray-400">({utilizationPercent.toFixed(1)}%)</span>
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{highestWarning && (
|
||||
<span
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium",
|
||||
getWarningBadgeStyle(highestWarning.level),
|
||||
)}
|
||||
>
|
||||
{highestWarning.level === "critical" ? "⚠" : highestWarning.level === "warning" ? "!" : "i"}
|
||||
{warnings.length > 1 ? `${warnings.length} warnings` : "Warning"}
|
||||
</span>
|
||||
)}
|
||||
<span className={clsx("font-medium", remainingCents < 0 ? "text-red-600" : "text-gray-700")}>
|
||||
{remainingCents >= 0 ? `${formatEur(remainingCents)} left` : `${formatEur(Math.abs(remainingCents))} over`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className={clsx("inline-block w-2.5 h-2.5 rounded-sm", getConfirmedBarColor(utilizationPercent))} />
|
||||
Confirmed {formatEur(confirmedCents)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className={clsx("inline-block w-2.5 h-2.5 rounded-sm", getProposedBarColor(utilizationPercent))} />
|
||||
Proposed {formatEur(proposedCents)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { BudgetStatusBar } from "./BudgetStatusBar.js";
|
||||
|
||||
interface BudgetStatusCardProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
function formatEur(cents: number): string {
|
||||
return (cents / 100).toLocaleString("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function WarningIcon({ level }: { level: string }) {
|
||||
if (level === "critical") {
|
||||
return (
|
||||
<svg className="w-4 h-4 text-red-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (level === "warning") {
|
||||
return (
|
||||
<svg className="w-4 h-4 text-orange-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg className="w-4 h-4 text-blue-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function getWarningRowStyle(level: string): string {
|
||||
if (level === "critical") return "bg-red-50 dark:bg-red-900/30 border border-red-100 dark:border-red-800 text-red-800 dark:text-red-400";
|
||||
if (level === "warning") return "bg-orange-50 dark:bg-orange-900/30 border border-orange-100 dark:border-orange-800 text-orange-800 dark:text-orange-400";
|
||||
return "bg-blue-50 dark:bg-blue-900/30 border border-blue-100 dark:border-blue-800 text-blue-800 dark:text-blue-400";
|
||||
}
|
||||
|
||||
export function BudgetStatusCard({ projectId }: BudgetStatusCardProps) {
|
||||
const { data, isLoading, error } = trpc.timeline.getBudgetStatus.useQuery({ projectId });
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 animate-pulse">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/3 mb-4" />
|
||||
<div className="h-3 bg-gray-100 dark:bg-gray-700 rounded-full mb-3" />
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-14 bg-gray-100 dark:bg-gray-700 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-red-200 dark:border-red-800 p-6">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">Failed to load budget status: {error.message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const {
|
||||
budgetCents,
|
||||
allocatedCents,
|
||||
confirmedCents,
|
||||
proposedCents,
|
||||
remainingCents,
|
||||
winProbabilityWeightedCents,
|
||||
warnings,
|
||||
} = data;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-5">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">Budget Status</h3>
|
||||
|
||||
{/* Progress bar */}
|
||||
<BudgetStatusBar
|
||||
budgetCents={budgetCents}
|
||||
allocatedCents={allocatedCents}
|
||||
confirmedCents={confirmedCents}
|
||||
proposedCents={proposedCents}
|
||||
warnings={warnings}
|
||||
/>
|
||||
|
||||
{/* 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-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-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-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={clsx("text-sm font-semibold", remainingCents < 0 ? "text-red-800 dark:text-red-400" : "text-blue-800 dark:text-blue-400")}>
|
||||
{formatEur(remainingCents)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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="font-medium text-gray-800 dark:text-gray-100">{formatEur(winProbabilityWeightedCents)}</span>
|
||||
</div>
|
||||
|
||||
{/* Warnings list */}
|
||||
{warnings.length > 0 && (
|
||||
<div className="space-y-2 border-t border-gray-100 dark:border-gray-700 pt-3">
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Warnings</p>
|
||||
{warnings.map((warning, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={clsx(
|
||||
"flex items-start gap-2 rounded-lg px-3 py-2 text-sm",
|
||||
getWarningRowStyle(warning.level),
|
||||
)}
|
||||
>
|
||||
<WarningIcon level={warning.level} />
|
||||
<span>{warning.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user