747 lines
41 KiB
TypeScript
747 lines
41 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, Tags } 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;
|
|
profile_picture?: string | null;
|
|
roles?: { id: number; name: 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">
|
|
{selectedEmployee.profile_picture && (
|
|
<AvatarImage src={selectedEmployee.profile_picture} alt={selectedEmployee.name} className="object-cover" />
|
|
)}
|
|
<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="sm:max-w-2xl max-h-[85vh] flex flex-col p-0 gap-0">
|
|
<DialogHeader className="px-6 py-4 border-b">
|
|
<DialogTitle className="text-base font-semibold">Select Project Manager</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="px-6 py-3 border-b bg-muted/20">
|
|
<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 text-sm h-9"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-4 max-h-[420px]">
|
|
{filteredEmployees.length === 0 ? (
|
|
<div className="text-center py-8 text-sm text-muted-foreground">
|
|
No project managers found matching your search.
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
{filteredEmployees.map((emp) => {
|
|
const isSelected = tempSelectedId === emp.ulid;
|
|
return (
|
|
<div
|
|
key={emp.ulid}
|
|
onClick={() => setTempSelectedId(emp.ulid)}
|
|
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${
|
|
isSelected
|
|
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
|
: 'border-border hover:bg-accent/40'
|
|
}`}
|
|
>
|
|
<Avatar className="h-10 w-10 shrink-0">
|
|
{emp.profile_picture && (
|
|
<AvatarImage src={emp.profile_picture} alt={emp.name} className="object-cover" />
|
|
)}
|
|
<AvatarFallback className="bg-primary/10 text-primary text-xs font-semibold">
|
|
{emp.name.substring(0, 2).toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex-1 min-w-0 pr-1">
|
|
<div className="flex items-center justify-between gap-1">
|
|
<p className="text-sm font-semibold truncate text-foreground">{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 || (emp.roles && emp.roles.length > 0)) && (
|
|
<div className="mt-1">
|
|
<span className="inline-block rounded-md bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground truncate max-w-full">
|
|
{emp.employee_profile?.position || (emp.roles?.[0]?.name === 'Main Contractor Admin' ? 'Contractor Admin' : emp.roles?.[0]?.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>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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 ProjectClassificationItem {
|
|
id: number;
|
|
code?: string;
|
|
name: string;
|
|
description?: string;
|
|
}
|
|
|
|
export const DEFAULT_PROJECT_CLASSIFICATIONS: ProjectClassificationItem[] = [
|
|
{ id: 1, code: 'ROAD-HWY', name: 'Road, Highway, Pavement, Railways, Airport Horizontal Structures and Bridges' },
|
|
{ id: 2, code: 'IRR-FLD', name: 'Irrigation and Flood Control' },
|
|
{ id: 3, code: 'DAM-RES', name: 'Dam, Reservoir, and Tunneling' },
|
|
{ id: 4, code: 'WAT-SUP', name: 'Water Supply' },
|
|
{ id: 5, code: 'PORT-ENG', name: 'Port, Harbor and Offshore Engineering' },
|
|
{ id: 6, code: 'BLD-PLANT', name: 'Building and Industrial Plant' },
|
|
{ id: 7, code: 'SEW-TREAT', name: 'Sewerage Treatment / Disposal Plant' },
|
|
{ id: 8, code: 'WTR-TREAT', name: 'Water Treatment Plant and System' },
|
|
{ id: 9, code: 'REC-PARK', name: 'Park, Playground and Recreational Work' },
|
|
{ id: 10, code: 'ELEC-WRK', name: 'Electrical Work' },
|
|
];
|
|
|
|
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;
|
|
classifications?: 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 }[];
|
|
classifications?: ProjectClassificationItem[];
|
|
isLocked?: boolean;
|
|
}
|
|
|
|
export function ProjectForm({ data, setData, errors, employees, projects = [], classifications = [], isLocked = false }: Props) {
|
|
const [showAdvanced, setShowAdvanced] = useState(true);
|
|
|
|
const activeClassificationOptions = useMemo(() => {
|
|
return classifications && classifications.length > 0 ? classifications : DEFAULT_PROJECT_CLASSIFICATIONS;
|
|
}, [classifications]);
|
|
|
|
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 (!isNaN(diffMs) && diffMs >= 0) {
|
|
const days = Math.max(1, Math.round(diffMs / (1000 * 60 * 60 * 24)) + 1); // inclusive of start & end
|
|
setData('contract_duration', String(days));
|
|
} else if (diffMs < 0) {
|
|
setData('contract_duration', '0');
|
|
}
|
|
}
|
|
}, [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', '');
|
|
}
|
|
};
|
|
|
|
const toggleClassification = (name: string) => {
|
|
if (isLocked) return;
|
|
const current = Array.isArray(data.classifications) ? [...data.classifications] : [];
|
|
const index = current.indexOf(name);
|
|
if (index > -1) {
|
|
current.splice(index, 1);
|
|
} else {
|
|
current.push(name);
|
|
}
|
|
setData('classifications', current);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Primary Fields — 1.1 to 1.7 */}
|
|
<Card className="border-slate-200/80 shadow-xs overflow-hidden">
|
|
<CardHeader className="bg-slate-50/50 border-b border-slate-100 py-3 px-5">
|
|
<CardTitle className="text-sm font-bold text-slate-800">Project Core Parameters</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-5 p-5">
|
|
{/* 1.1 Project Name */}
|
|
<div>
|
|
<Label htmlFor="name" className="text-xs font-bold text-slate-700 mb-1.5 block">
|
|
<span className="text-xs text-slate-400 mr-1.5 font-normal">1.1</span>
|
|
Project Name <span className="text-red-500 font-bold">*</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}
|
|
className="h-10 text-sm border-slate-200"
|
|
/>
|
|
{errors.name && <p className="mt-1 text-xs font-medium text-red-500">{errors.name}</p>}
|
|
</div>
|
|
|
|
{/* Project Classification Multi-Tag Checkbox Group */}
|
|
<div className="space-y-3 p-4 bg-slate-50/60 border border-slate-200/80 rounded-xl">
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-1 pb-2 border-b border-slate-200/60">
|
|
<div className="flex items-center gap-2">
|
|
<Tags className="h-4 w-4 text-emerald-600" />
|
|
<Label className="text-xs font-bold text-slate-800">
|
|
Project Classification Tagging <span className="text-red-500 font-bold">*</span> <span className="text-emerald-700 font-bold">({(data.classifications || []).length} selected)</span>
|
|
</Label>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setData('classifications', activeClassificationOptions.map(c => c.name))}
|
|
disabled={isLocked}
|
|
className="text-[11px] font-semibold text-emerald-600 hover:text-emerald-700 disabled:opacity-50 cursor-pointer"
|
|
>
|
|
Select All
|
|
</button>
|
|
<span className="text-slate-300 text-xs">|</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => setData('classifications', [])}
|
|
disabled={isLocked}
|
|
className="text-[11px] font-semibold text-slate-500 hover:text-slate-700 disabled:opacity-50 cursor-pointer"
|
|
>
|
|
Clear All
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<p className="text-[11px] text-slate-500 font-medium">
|
|
Select one or more construction classification tags applicable to this project <span className="text-red-500 font-semibold">(minimum 1 required)</span>:
|
|
</p>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2.5">
|
|
{activeClassificationOptions.map((item, idx) => {
|
|
const isChecked = Array.isArray(data.classifications) && data.classifications.includes(item.name);
|
|
return (
|
|
<div
|
|
key={item.id || idx}
|
|
onClick={() => toggleClassification(item.name)}
|
|
className={`flex items-start gap-3 p-3 rounded-lg border text-left transition-all select-none cursor-pointer ${
|
|
isChecked
|
|
? 'bg-emerald-50/90 border-emerald-300 ring-1 ring-emerald-400/40 text-emerald-950 shadow-2xs'
|
|
: 'bg-white hover:bg-slate-50 border-slate-200/80 text-slate-700'
|
|
} ${isLocked ? 'opacity-60 cursor-not-allowed' : ''}`}
|
|
>
|
|
<div className="pt-0.5 shrink-0">
|
|
<input
|
|
type="checkbox"
|
|
checked={isChecked}
|
|
onChange={() => {}} // Handled by parent container click
|
|
disabled={isLocked}
|
|
className="h-4 w-4 rounded border-slate-300 text-emerald-600 focus:ring-emerald-500 cursor-pointer"
|
|
/>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-start gap-1.5">
|
|
<span className={`inline-flex items-center justify-center h-4 min-w-4 px-1 text-[10px] font-bold rounded-full shrink-0 mt-0.5 ${
|
|
isChecked ? 'bg-emerald-200 text-emerald-800' : 'bg-slate-100 text-slate-600 border border-slate-200'
|
|
}`}>
|
|
{idx + 1}
|
|
</span>
|
|
<span className="text-xs font-semibold leading-snug">
|
|
{item.name}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
{errors.classifications && <p className="mt-1 text-xs font-medium text-red-500">{errors.classifications}</p>}
|
|
</div>
|
|
|
|
{/* Project Type & Scope Selection */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<Label htmlFor="project_type" className="text-xs font-bold text-slate-700 mb-1.5 block">
|
|
Project Type / Scope <span className="text-red-500 font-bold">*</span>
|
|
</Label>
|
|
<Select
|
|
value={data.project_type || 'standard'}
|
|
onValueChange={handleProjectTypeChange}
|
|
disabled={isLocked}
|
|
>
|
|
<SelectTrigger id="project_type" className="h-10 text-sm border-slate-200">
|
|
<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-xs font-medium text-red-500">{errors.project_type}</p>}
|
|
</div>
|
|
|
|
{data.project_type === 'extension' && (
|
|
<div>
|
|
<Label htmlFor="parent_project_id" className="text-xs font-bold text-slate-700 mb-1.5 block">
|
|
Parent Project <span className="text-red-500 font-bold">*</span>
|
|
</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-xs font-medium text-red-500">{errors.parent_project_id}</p>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Unprofitable Flag Checkbox */}
|
|
<div className="flex items-center gap-3 p-3.5 bg-amber-50/40 border border-amber-200/60 rounded-lg">
|
|
<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-amber-600 focus:ring-amber-500 h-4 w-4 cursor-pointer"
|
|
disabled={isLocked}
|
|
/>
|
|
<div className="flex flex-col">
|
|
<Label htmlFor="is_unprofitable" className="font-bold text-xs text-amber-900 cursor-pointer">
|
|
Unprofitable / Loss-Making Project Flag
|
|
</Label>
|
|
<span className="text-[11px] text-amber-700/80">
|
|
Displays budget warning highlights on procurement approval panels and executive dashboards.
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 1.2 Project Location & 1.3 Client */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<Label htmlFor="location" className="text-xs font-bold text-slate-700 mb-1.5 block">
|
|
<span className="text-xs text-slate-400 mr-1.5 font-normal">1.2</span>
|
|
Project Location <span className="text-red-500 font-bold">*</span>
|
|
</Label>
|
|
<Input
|
|
id="location"
|
|
value={data.location}
|
|
onChange={(e) => setData('location', e.target.value)}
|
|
placeholder="e.g., Bolbok, Batangas City"
|
|
disabled={isLocked}
|
|
className="h-10 text-sm border-slate-200"
|
|
/>
|
|
{errors.location && <p className="mt-1 text-xs font-medium text-red-500">{errors.location}</p>}
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="client_name" className="text-xs font-bold text-slate-700 mb-1.5 block">
|
|
<span className="text-xs text-slate-400 mr-1.5 font-normal">1.3</span>
|
|
Client Name <span className="text-red-500 font-bold">*</span>
|
|
</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}
|
|
className="h-10 text-sm border-slate-200"
|
|
/>
|
|
{errors.client_name && <p className="mt-1 text-xs font-medium text-red-500">{errors.client_name}</p>}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 1.4 Main Contractor — Hard-coded */}
|
|
<div>
|
|
<Label className="text-xs font-bold text-slate-700 mb-1.5 block">
|
|
<span className="text-xs text-slate-400 mr-1.5 font-normal">1.4</span>
|
|
Main Contractor
|
|
</Label>
|
|
<div className="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3.5 py-2.5 text-sm">
|
|
<Building2 className="h-4 w-4 text-slate-500 shrink-0" />
|
|
<span className="font-semibold text-slate-800">{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" className="text-xs font-bold text-slate-700 mb-1.5 block">
|
|
<span className="text-xs text-slate-400 mr-1.5 font-normal">1.5</span>
|
|
Target Start Date <span className="text-red-500 font-bold">*</span>
|
|
</Label>
|
|
<Input
|
|
id="start_date"
|
|
type="date"
|
|
value={data.start_date}
|
|
onChange={(e) => setData('start_date', e.target.value)}
|
|
disabled={isLocked}
|
|
className="h-10 text-sm border-slate-200"
|
|
/>
|
|
{errors.start_date && <p className="mt-1 text-xs font-medium text-red-500">{errors.start_date}</p>}
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="target_end_date" className="text-xs font-bold text-slate-700 mb-1.5 block">
|
|
<span className="text-xs text-slate-400 mr-1.5 font-normal">1.6</span>
|
|
Target Completion Date <span className="text-red-500 font-bold">*</span>
|
|
</Label>
|
|
<Input
|
|
id="target_end_date"
|
|
type="date"
|
|
value={data.target_end_date}
|
|
onChange={(e) => setData('target_end_date', e.target.value)}
|
|
disabled={isLocked}
|
|
className="h-10 text-sm border-slate-200"
|
|
/>
|
|
{errors.target_end_date && <p className="mt-1 text-xs font-medium text-red-500">{errors.target_end_date}</p>}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 1.7 Contract Duration */}
|
|
<div>
|
|
<Label htmlFor="contract_duration" className="text-xs font-bold text-slate-700 mb-1.5 block">
|
|
<span className="text-xs text-slate-400 mr-1.5 font-normal">1.7</span>
|
|
Contract Duration (Days) <span className="text-red-500 font-bold">*</span> <span className="text-[11px] font-normal text-slate-400">(Auto-calculated from Start & Completion dates)</span>
|
|
</Label>
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
id="contract_duration"
|
|
type="number"
|
|
min="1"
|
|
value={data.contract_duration}
|
|
onChange={(e) => setData('contract_duration', e.target.value)}
|
|
className="max-w-[160px] h-10 text-sm border-slate-200 bg-slate-50/50 font-semibold"
|
|
placeholder="0"
|
|
disabled={isLocked}
|
|
/>
|
|
<span className="text-sm font-medium text-slate-500">calendar days</span>
|
|
</div>
|
|
{errors.contract_duration && <p className="mt-1 text-xs font-medium text-red-500">{errors.contract_duration}</p>}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Additional Project Parameters */}
|
|
<Card className="border-slate-200/80 shadow-xs">
|
|
<CardHeader
|
|
className="cursor-pointer select-none bg-slate-50/50 py-3.5 px-6 border-b border-slate-100"
|
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-base font-bold text-slate-800">Additional Project Parameters</CardTitle>
|
|
<Button variant="ghost" size="icon" type="button" className="h-8 w-8">
|
|
{showAdvanced
|
|
? <ChevronUp className="h-4 w-4 text-slate-500" />
|
|
: <ChevronDown className="h-4 w-4 text-slate-500" />}
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
{showAdvanced && (
|
|
<CardContent className="space-y-5 p-6">
|
|
{/* Description */}
|
|
<div>
|
|
<Label htmlFor="description" className="text-xs font-bold text-slate-700 mb-1.5 block">
|
|
Project Description / Scope <span className="text-red-500 font-bold">*</span>
|
|
</Label>
|
|
<Textarea
|
|
id="description"
|
|
value={data.description}
|
|
onChange={(e) => setData('description', e.target.value)}
|
|
rows={3}
|
|
placeholder="Enter detailed project scope, specifications, and objectives..."
|
|
disabled={isLocked}
|
|
className="text-sm border-slate-200"
|
|
/>
|
|
{errors.description && <p className="mt-1 text-xs font-medium text-red-500">{errors.description}</p>}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
{/* Project Manager */}
|
|
<div>
|
|
<Label className="text-xs font-bold text-slate-700 mb-1.5 block">
|
|
Assigned Project Manager <span className="text-red-500 font-bold">*</span>
|
|
</Label>
|
|
<div>
|
|
<ProjectManagerLookup
|
|
employees={employees}
|
|
selectedId={data.pm_id}
|
|
onSelect={(v) => setData('pm_id', v)}
|
|
disabled={isLocked}
|
|
/>
|
|
</div>
|
|
{errors.pm_id && <p className="mt-1 text-xs font-medium text-red-500">{errors.pm_id}</p>}
|
|
</div>
|
|
|
|
{/* Contract Value */}
|
|
<div>
|
|
<Label htmlFor="contract_value" className="text-xs font-bold text-slate-700 mb-1.5 block">
|
|
Contract Value (₱) <span className="text-red-500 font-bold">*</span>
|
|
</Label>
|
|
<Input
|
|
id="contract_value"
|
|
type="number"
|
|
step="0.01"
|
|
min="0.01"
|
|
value={data.contract_value}
|
|
onChange={(e) => setData('contract_value', e.target.value)}
|
|
placeholder="0.00"
|
|
disabled={isLocked}
|
|
className="h-10 text-sm border-slate-200 font-mono"
|
|
/>
|
|
{errors.contract_value && <p className="mt-1 text-xs font-medium text-red-500">{errors.contract_value}</p>}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|