Files
GSB-Construction/Modules/ProjectManagement/resources/js/Components/ProjectForm.tsx
Christopher Boyles 64d4b331a8
Some checks failed
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
Tests / PHP 8.5 (push) Has been cancelled
Additional Changes
2026-06-02 22:04:03 +08:00

604 lines
30 KiB
TypeScript

import { Input } from '@/Components/ui/input';
import { Label } from '@/Components/ui/label';
import { Textarea } from '@/Components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/Components/ui/select';
import { Button } from '@/Components/ui/button';
import { ChevronDown, ChevronUp, Building2, Search, CheckCircle2 } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from '@/Components/ui/dialog';
import { Avatar, AvatarFallback, AvatarImage } from '@/Components/ui/avatar';
import { Badge } from '@/Components/ui/badge';
import { useEffect, useMemo, useState } from 'react';
const MAIN_CONTRACTOR = 'Great SwissMetal Builders Corporation';
interface Employee {
id: number;
ulid: string;
name: string;
email?: string;
employee_profile?: {
department?: string;
position?: string;
phone?: string;
};
}
function ProjectManagerLookup({ employees, selectedId, onSelect, disabled }: { employees: Employee[], selectedId: string, onSelect: (id: string) => void, disabled?: boolean }) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const [tempSelectedId, setTempSelectedId] = useState(selectedId);
const filteredEmployees = useMemo(() => {
if (!search) return employees;
const lowerSearch = search.toLowerCase();
return employees.filter(e =>
e.name.toLowerCase().includes(lowerSearch) ||
(e.email && e.email.toLowerCase().includes(lowerSearch)) ||
(e.employee_profile?.position && e.employee_profile.position.toLowerCase().includes(lowerSearch))
);
}, [employees, search]);
const handleConfirm = () => {
onSelect(tempSelectedId);
setOpen(false);
};
const handleOpenChange = (newOpen: boolean) => {
setOpen(newOpen);
if (newOpen) {
setTempSelectedId(selectedId);
setSearch('');
}
};
const selectedEmployee = employees.find(e => e.ulid === selectedId);
return (
<>
<Button
variant="outline"
role="combobox"
onClick={() => !disabled && handleOpenChange(true)}
className={`w-full justify-between font-normal ${!selectedId ? 'text-muted-foreground' : ''}`}
disabled={disabled}
>
{selectedEmployee ? (
<div className="flex items-center gap-2 truncate">
<Avatar className="h-6 w-6">
<AvatarFallback className="text-[10px]">{selectedEmployee.name.substring(0,2).toUpperCase()}</AvatarFallback>
</Avatar>
<span className="truncate">{selectedEmployee.name}</span>
{selectedEmployee.employee_profile?.position && (
<span className="text-xs text-muted-foreground hidden sm:inline-block truncate">
- {selectedEmployee.employee_profile.position}
</span>
)}
</div>
) : (
"Select Project Manager"
)}
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
</Button>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-4xl max-h-[85vh] flex flex-col p-0 gap-0">
<DialogHeader className="px-6 py-5 border-b">
<DialogTitle>Select Project Manager</DialogTitle>
</DialogHeader>
<div className="px-6 py-5 border-b bg-muted/30">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by name, email, or position..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 bg-background"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto p-6 max-h-[500px]">
{filteredEmployees.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No project managers found matching your search.
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredEmployees.map((emp) => {
const isSelected = tempSelectedId === emp.ulid;
return (
<div
key={emp.ulid}
onClick={() => setTempSelectedId(emp.ulid)}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
isSelected
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-border hover:bg-accent/50'
}`}
>
<Avatar className="h-10 w-10 shrink-0">
<AvatarFallback className="bg-primary/10 text-primary font-medium">
{emp.name.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-1">
<p className="text-sm font-medium truncate">{emp.name}</p>
{isSelected && <CheckCircle2 className="h-4 w-4 text-primary shrink-0" />}
</div>
<p className="text-xs text-muted-foreground truncate" title={emp.email}>
{emp.email || 'No email provided'}
</p>
{emp.employee_profile?.position && (
<Badge variant="secondary" className="mt-1.5 text-[10px] font-normal px-1.5 py-0">
{emp.employee_profile.position}
</Badge>
)}
</div>
</div>
);
})}
</div>
)}
</div>
<DialogFooter className="mx-0 mb-0 px-6 py-4 border-t bg-muted/20">
<Button variant="outline" type="button" onClick={() => setOpen(false)}>Cancel</Button>
<Button type="button" onClick={handleConfirm} disabled={!tempSelectedId}>Confirm Selection</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
interface ParentProject {
id: number;
ulid: string;
name: string;
code: string;
project_type?: string;
parent_project?: { id: number; ulid: string; name: string; code: string } | null;
}
function ParentProjectLookup({ projects, selectedId, onSelect, disabled }: { projects: ParentProject[], selectedId: string, onSelect: (id: string) => void, disabled?: boolean }) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const [tempSelectedId, setTempSelectedId] = useState(selectedId);
const filteredProjects = useMemo(() => {
if (!search) return projects;
const lowerSearch = search.toLowerCase();
return projects.filter(p =>
p.name.toLowerCase().includes(lowerSearch) ||
p.code.toLowerCase().includes(lowerSearch)
);
}, [projects, search]);
const handleConfirm = () => {
onSelect(tempSelectedId);
setOpen(false);
};
const handleOpenChange = (newOpen: boolean) => {
setOpen(newOpen);
if (newOpen) {
setTempSelectedId(selectedId);
setSearch('');
}
};
const selectedProject = projects.find(p => p.ulid === selectedId);
return (
<>
<Button
variant="outline"
role="combobox"
onClick={() => !disabled && handleOpenChange(true)}
className={`w-full justify-between font-normal ${!selectedId ? 'text-muted-foreground' : ''}`}
type="button"
disabled={disabled}
>
{selectedProject ? (
<div className="flex items-center gap-2 truncate">
<span className="font-semibold text-xs bg-slate-100 text-slate-700 px-1.5 py-0.5 rounded shrink-0">
{selectedProject.code}
</span>
<span className="truncate">{selectedProject.name}</span>
</div>
) : (
"Select Parent Project"
)}
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
</Button>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-4xl max-h-[85vh] flex flex-col p-0 gap-0">
<DialogHeader className="px-6 py-5 border-b">
<DialogTitle>Select Parent Project</DialogTitle>
</DialogHeader>
<div className="px-6 py-5 border-b bg-muted/30">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by project name or code..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 bg-background"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto p-6 max-h-[500px]">
{filteredProjects.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No active projects found matching your search.
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredProjects.map((proj) => {
const isSelected = tempSelectedId === proj.ulid;
return (
<div
key={proj.ulid}
onClick={() => setTempSelectedId(proj.ulid)}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
isSelected
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-border hover:bg-accent/50'
}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-1">
<span className="font-semibold text-xs bg-slate-100 text-slate-700 px-1.5 py-0.5 rounded shrink-0">
{proj.code}
</span>
{isSelected && <CheckCircle2 className="h-4 w-4 text-primary shrink-0" />}
</div>
<p className="text-sm font-medium mt-1 truncate" title={proj.name}>
{proj.name}
</p>
{proj.project_type === 'extension' && proj.parent_project && (
<div className="mt-1.5 flex items-center gap-1.5 text-[10px] text-muted-foreground min-w-0">
<span className="shrink-0 bg-slate-50 border border-slate-200 px-1 py-0.5 rounded font-mono text-[9px]">
Ext of {proj.parent_project.code}
</span>
<span className="truncate" title={proj.parent_project.name}>
{proj.parent_project.name}
</span>
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
<DialogFooter className="mx-0 mb-0 px-6 py-4 border-t bg-muted/20">
<Button variant="outline" type="button" onClick={() => setOpen(false)}>Cancel</Button>
<Button type="button" onClick={handleConfirm} disabled={!tempSelectedId}>Confirm Selection</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
export interface ProjectFormData {
name: string;
client_name: string;
location: string;
start_date: string;
target_end_date: string;
contract_duration: string;
// Additional details
description: string;
pm_id: string;
contract_value: string;
project_type: string;
parent_project_id: string;
is_unprofitable: boolean;
}
interface Props {
data: ProjectFormData;
setData: <K extends keyof ProjectFormData>(key: K, value: ProjectFormData[K]) => void;
errors: Partial<Record<keyof ProjectFormData, string>>;
employees: Employee[];
projects?: { id: number; ulid: string; name: string; code: string }[];
isLocked?: boolean;
}
export function ProjectForm({ data, setData, errors, employees, projects = [], isLocked = false }: Props) {
const [showAdvanced, setShowAdvanced] = useState(false);
const employeeSelectItems = useMemo(
() => employees.map(e => ({ value: e.ulid, label: e.name })),
[employees],
);
// Auto-calculate contract duration when both dates are set
useEffect(() => {
if (data.start_date && data.target_end_date) {
const start = new Date(data.start_date);
const end = new Date(data.target_end_date);
const diffMs = end.getTime() - start.getTime();
if (diffMs >= 0) {
const days = Math.round(diffMs / (1000 * 60 * 60 * 24)) + 1; // inclusive of start & end
setData('contract_duration', String(days));
}
}
}, [data.start_date, data.target_end_date]);
const handleProjectTypeChange = (val: string | null) => {
const typeVal = val || 'standard';
setData('project_type', typeVal);
if (typeVal === 'special') {
setData('is_unprofitable', true);
} else if (typeVal === 'standard') {
setData('is_unprofitable', false);
}
if (typeVal !== 'extension') {
setData('parent_project_id', '');
}
};
return (
<div className="space-y-6">
{/* Primary Fields — 1.1 to 1.7 */}
<Card>
<CardHeader>
<CardTitle>Project Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 1.1 Project Name */}
<div>
<Label htmlFor="name">
<span className="text-xs text-muted-foreground mr-1.5">1.1</span>
Project Name <span className="text-red-500">*</span>
</Label>
<Input
id="name"
value={data.name}
onChange={(e) => setData('name', e.target.value)}
placeholder="e.g., Construction of FGEN-PPA Access Road"
disabled={isLocked}
/>
{errors.name && <p className="mt-1 text-sm text-red-500">{errors.name}</p>}
</div>
{/* Project Type Selection */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="project_type">Project Type</Label>
<Select
value={data.project_type || 'standard'}
onValueChange={handleProjectTypeChange}
disabled={isLocked}
>
<SelectTrigger id="project_type">
<SelectValue placeholder="Select Project Type">
{data.project_type === 'standard' && "Standard Project"}
{data.project_type === 'special' && "Special Project (Unprofitable Flag)"}
{data.project_type === 'extension' && "Extension Project (Variation Order)"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="standard">Standard Project</SelectItem>
<SelectItem value="special">Special Project (Unprofitable Flag)</SelectItem>
<SelectItem value="extension">Extension Project (Variation Order)</SelectItem>
</SelectContent>
</Select>
{errors.project_type && <p className="mt-1 text-sm text-red-500">{errors.project_type}</p>}
</div>
{data.project_type === 'extension' && (
<div>
<Label htmlFor="parent_project_id">Parent Project</Label>
<div className="mt-1">
<ParentProjectLookup
projects={projects}
selectedId={data.parent_project_id || ''}
onSelect={val => setData('parent_project_id', val || '')}
disabled={isLocked}
/>
</div>
{errors.parent_project_id && <p className="mt-1 text-sm text-red-500">{errors.parent_project_id}</p>}
</div>
)}
</div>
{/* Unprofitable Flag Checkbox */}
<div className="flex items-center gap-2 py-2 px-3 bg-slate-50 border border-slate-200/60 rounded-md">
<input
type="checkbox"
id="is_unprofitable"
checked={data.is_unprofitable || false}
onChange={e => setData('is_unprofitable', e.target.checked)}
className="rounded border-slate-300 text-emerald-600 focus:ring-emerald-500 h-4 w-4"
disabled={isLocked}
/>
<div className="flex flex-col">
<Label htmlFor="is_unprofitable" className="font-semibold text-slate-800 cursor-pointer">
Unprofitable / Loss-Making Project Flag
</Label>
<span className="text-[11px] text-slate-500">
Displays budget warning highlights on procurement approval panels and dashboards.
</span>
</div>
</div>
{/* 1.2 Project Location */}
<div>
<Label htmlFor="location">
<span className="text-xs text-muted-foreground mr-1.5">1.2</span>
Project Location
</Label>
<Input
id="location"
value={data.location}
onChange={(e) => setData('location', e.target.value)}
placeholder="e.g., Bolbok, Batangas City"
disabled={isLocked}
/>
</div>
{/* 1.3 Client */}
<div>
<Label htmlFor="client_name">
<span className="text-xs text-muted-foreground mr-1.5">1.3</span>
Client
</Label>
<Input
id="client_name"
value={data.client_name}
onChange={(e) => setData('client_name', e.target.value)}
placeholder="e.g., First Gen Corporation"
disabled={isLocked}
/>
</div>
{/* 1.4 Main Contractor — Hard-coded */}
<div>
<Label>
<span className="text-xs text-muted-foreground mr-1.5">1.4</span>
Main Contractor
</Label>
<div className="flex items-center gap-2 rounded-md border border-input bg-muted/50 px-3 py-2 text-sm">
<Building2 className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="font-medium">{MAIN_CONTRACTOR}</span>
</div>
</div>
{/* 1.5 & 1.6 — Start Date / Project Completion */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<Label htmlFor="start_date">
<span className="text-xs text-muted-foreground mr-1.5">1.5</span>
Start Date
</Label>
<Input
id="start_date"
type="date"
value={data.start_date}
onChange={(e) => setData('start_date', e.target.value)}
disabled={isLocked}
/>
</div>
<div>
<Label htmlFor="target_end_date">
<span className="text-xs text-muted-foreground mr-1.5">1.6</span>
Project Completion
</Label>
<Input
id="target_end_date"
type="date"
value={data.target_end_date}
onChange={(e) => setData('target_end_date', e.target.value)}
disabled={isLocked}
/>
{errors.target_end_date && <p className="mt-1 text-sm text-red-500">{errors.target_end_date}</p>}
</div>
</div>
{/* 1.7 Contract Duration */}
<div>
<Label htmlFor="contract_duration">
<span className="text-xs text-muted-foreground mr-1.5">1.7</span>
Contract Duration
</Label>
<div className="flex items-center gap-2">
<Input
id="contract_duration"
type="number"
min="0"
value={data.contract_duration}
onChange={(e) => setData('contract_duration', e.target.value)}
className="max-w-[140px]"
placeholder="0"
disabled={isLocked}
/>
<span className="text-sm text-muted-foreground">days</span>
</div>
{data.start_date && data.target_end_date && (
<p className="mt-1 text-xs text-muted-foreground">
Auto-calculated from dates. You can override manually.
</p>
)}
</div>
</CardContent>
</Card>
{/* Collapsible Additional Details */}
<Card>
<CardHeader
className="cursor-pointer select-none"
onClick={() => setShowAdvanced(!showAdvanced)}
>
<div className="flex items-center justify-between">
<CardTitle className="text-base">Additional Details</CardTitle>
<Button variant="ghost" size="icon-sm" type="button">
{showAdvanced
? <ChevronUp className="h-4 w-4" />
: <ChevronDown className="h-4 w-4" />}
</Button>
</div>
</CardHeader>
{showAdvanced && (
<CardContent className="space-y-4 pt-0">
{/* Description */}
<div>
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={data.description}
onChange={(e) => setData('description', e.target.value)}
rows={3}
placeholder="Brief project description..."
disabled={isLocked}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{/* Project Manager */}
<div>
<Label>Project Manager</Label>
<div className="mt-1">
<ProjectManagerLookup
employees={employees}
selectedId={data.pm_id}
onSelect={(v) => setData('pm_id', v)}
disabled={isLocked}
/>
</div>
</div>
{/* Contract Value */}
<div>
<Label htmlFor="contract_value">Contract Value (PHP)</Label>
<Input
id="contract_value"
type="number"
step="0.01"
value={data.contract_value}
onChange={(e) => setData('contract_value', e.target.value)}
placeholder="0.00"
disabled={isLocked}
/>
{errors.contract_value && <p className="mt-1 text-sm text-red-500">{errors.contract_value}</p>}
</div>
</div>
</CardContent>
)}
</Card>
</div>
);
}