923 lines
58 KiB
TypeScript
923 lines
58 KiB
TypeScript
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
|
import { Head, useForm, Link } from '@inertiajs/react';
|
|
import { Button } from '@/Components/ui/button';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/Components/ui/card';
|
|
import { Input } from '@/Components/ui/input';
|
|
import { Label } from '@/Components/ui/label';
|
|
import { Badge } from '@/Components/ui/badge';
|
|
import { Textarea } from '@/Components/ui/textarea';
|
|
import {
|
|
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
|
} from '@/Components/ui/table';
|
|
import {
|
|
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
|
} from '@/Components/ui/dialog';
|
|
import {
|
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
|
} from '@/Components/ui/select';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/Components/ui/tabs';
|
|
import {
|
|
Users, Plus, Pencil, Trash2, ShieldAlert, Sparkles, BookOpen, Check,
|
|
Layers, HardHat, Wrench, Search, DollarSign, Activity, Tag, PackageCheck
|
|
} from 'lucide-react';
|
|
import { ConfirmModal } from '@/Components/ConfirmModal';
|
|
import { useState, useMemo } from 'react';
|
|
|
|
interface Skill {
|
|
id: number;
|
|
ulid: string;
|
|
name: string;
|
|
description: string | null;
|
|
}
|
|
|
|
interface Labor {
|
|
id: number;
|
|
ulid: string;
|
|
name: string;
|
|
category: 'skilled' | 'unskilled';
|
|
hourly_rate: string;
|
|
status: string;
|
|
skills: Skill[];
|
|
}
|
|
|
|
interface Props {
|
|
labors: Labor[];
|
|
skills: Skill[];
|
|
}
|
|
|
|
export default function Index({ labors = [], skills = [] }: Props) {
|
|
// Tab State
|
|
const [activeTab, setActiveTab] = useState<'labor' | 'skill'>('labor');
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
|
const [categoryFilter, setCategoryFilter] = useState<'all' | 'skilled' | 'unskilled'>('all');
|
|
|
|
// Dialog state for Labor
|
|
const [laborModalOpen, setLaborModalOpen] = useState(false);
|
|
const [editingLabor, setEditingLabor] = useState<Labor | null>(null);
|
|
|
|
// Dialog state for Skill
|
|
const [skillModalOpen, setSkillModalOpen] = useState(false);
|
|
const [editingSkill, setEditingSkill] = useState<Skill | null>(null);
|
|
|
|
// Inertia forms
|
|
const laborForm = useForm({
|
|
name: '',
|
|
category: 'skilled' as 'skilled' | 'unskilled',
|
|
hourly_rate: '',
|
|
status: 'active',
|
|
skills: [] as number[],
|
|
});
|
|
|
|
const skillForm = useForm({
|
|
name: '',
|
|
description: '',
|
|
});
|
|
|
|
const formatCurrency = (v: string | number) => {
|
|
return new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(Number(v));
|
|
};
|
|
|
|
// Calculate Summary Metrics
|
|
const stats = useMemo(() => {
|
|
const activeLabors = labors.filter(l => l.status === 'active');
|
|
const avgLaborRate = activeLabors.length > 0
|
|
? activeLabors.reduce((acc, l) => acc + parseFloat(l.hourly_rate || '0'), 0) / activeLabors.length
|
|
: 0;
|
|
|
|
const skilledCount = labors.filter(l => l.category === 'skilled').length;
|
|
const unskilledCount = labors.filter(l => l.category === 'unskilled').length;
|
|
|
|
return {
|
|
totalLabors: labors.length,
|
|
activeLaborsCount: activeLabors.length,
|
|
avgLaborRate,
|
|
skilledCount,
|
|
unskilledCount,
|
|
totalSkills: skills.length,
|
|
};
|
|
}, [labors, skills]);
|
|
|
|
// Filtered Single Labor Profiles
|
|
const filteredLabors = useMemo(() => {
|
|
return labors.filter((labor) => {
|
|
if (statusFilter !== 'all' && labor.status !== statusFilter) return false;
|
|
if (categoryFilter !== 'all' && labor.category !== categoryFilter) return false;
|
|
if (searchQuery.trim()) {
|
|
const q = searchQuery.toLowerCase();
|
|
const nameMatch = labor.name.toLowerCase().includes(q);
|
|
const rateMatch = labor.hourly_rate.toString().includes(q);
|
|
const skillMatch = labor.skills.some(s => s.name.toLowerCase().includes(q));
|
|
return nameMatch || rateMatch || skillMatch;
|
|
}
|
|
return true;
|
|
});
|
|
}, [labors, statusFilter, categoryFilter, searchQuery]);
|
|
|
|
// Filtered Skills
|
|
const filteredSkills = useMemo(() => {
|
|
return skills.filter((skill) => {
|
|
if (searchQuery.trim()) {
|
|
const q = searchQuery.toLowerCase();
|
|
const nameMatch = skill.name.toLowerCase().includes(q);
|
|
const descMatch = (skill.description || '').toLowerCase().includes(q);
|
|
return nameMatch || descMatch;
|
|
}
|
|
return true;
|
|
});
|
|
}, [skills, searchQuery]);
|
|
|
|
// Labor actions
|
|
const openAddLabor = () => {
|
|
setEditingLabor(null);
|
|
laborForm.setData({
|
|
name: '',
|
|
category: 'skilled',
|
|
hourly_rate: '',
|
|
status: 'active',
|
|
skills: [],
|
|
});
|
|
laborForm.clearErrors();
|
|
setLaborModalOpen(true);
|
|
};
|
|
|
|
const openEditLabor = (labor: Labor) => {
|
|
setEditingLabor(labor);
|
|
laborForm.setData({
|
|
name: labor.name,
|
|
category: labor.category,
|
|
hourly_rate: labor.hourly_rate,
|
|
status: labor.status,
|
|
skills: labor.skills.map(s => s.id),
|
|
});
|
|
laborForm.clearErrors();
|
|
setLaborModalOpen(true);
|
|
};
|
|
|
|
const handleLaborSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (editingLabor) {
|
|
laborForm.put(route('labors.update', editingLabor.ulid || editingLabor.id), {
|
|
onSuccess: () => {
|
|
setLaborModalOpen(false);
|
|
laborForm.reset();
|
|
},
|
|
});
|
|
} else {
|
|
laborForm.post(route('labors.store'), {
|
|
onSuccess: () => {
|
|
setLaborModalOpen(false);
|
|
laborForm.reset();
|
|
},
|
|
});
|
|
}
|
|
};
|
|
|
|
const [deleteLaborModalOpen, setDeleteLaborModalOpen] = useState(false);
|
|
const [laborToDelete, setLaborToDelete] = useState<Labor | null>(null);
|
|
|
|
const promptDeleteLabor = (labor: Labor) => {
|
|
setLaborToDelete(labor);
|
|
setDeleteLaborModalOpen(true);
|
|
};
|
|
|
|
const handleConfirmLaborDelete = () => {
|
|
if (laborToDelete) {
|
|
laborForm.delete(route('labors.destroy', laborToDelete.ulid || laborToDelete.id), {
|
|
onSuccess: () => {
|
|
setDeleteLaborModalOpen(false);
|
|
setLaborToDelete(null);
|
|
},
|
|
});
|
|
}
|
|
};
|
|
|
|
// Skill actions
|
|
const openAddSkill = () => {
|
|
setEditingSkill(null);
|
|
skillForm.setData({
|
|
name: '',
|
|
description: '',
|
|
});
|
|
skillForm.clearErrors();
|
|
setSkillModalOpen(true);
|
|
};
|
|
|
|
const openEditSkill = (skill: Skill) => {
|
|
setEditingSkill(skill);
|
|
skillForm.setData({
|
|
name: skill.name,
|
|
description: skill.description || '',
|
|
});
|
|
skillForm.clearErrors();
|
|
setSkillModalOpen(true);
|
|
};
|
|
|
|
const handleSkillSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (editingSkill) {
|
|
skillForm.put(route('skills.update', editingSkill.ulid || editingSkill.id), {
|
|
onSuccess: () => {
|
|
setSkillModalOpen(false);
|
|
skillForm.reset();
|
|
},
|
|
});
|
|
} else {
|
|
skillForm.post(route('skills.store'), {
|
|
onSuccess: () => {
|
|
setSkillModalOpen(false);
|
|
skillForm.reset();
|
|
},
|
|
});
|
|
}
|
|
};
|
|
|
|
const [deleteSkillModalOpen, setDeleteSkillModalOpen] = useState(false);
|
|
const [skillToDelete, setSkillToDelete] = useState<Skill | null>(null);
|
|
|
|
const promptDeleteSkill = (skill: Skill) => {
|
|
setSkillToDelete(skill);
|
|
setDeleteSkillModalOpen(true);
|
|
};
|
|
|
|
const handleConfirmSkillDelete = () => {
|
|
if (skillToDelete) {
|
|
skillForm.delete(route('skills.destroy', skillToDelete.ulid || skillToDelete.id), {
|
|
onSuccess: () => {
|
|
setDeleteSkillModalOpen(false);
|
|
setSkillToDelete(null);
|
|
},
|
|
});
|
|
}
|
|
};
|
|
|
|
const toggleSkillSelection = (skillId: number) => {
|
|
const current = [...laborForm.data.skills];
|
|
const index = current.indexOf(skillId);
|
|
if (index > -1) {
|
|
current.splice(index, 1);
|
|
} else {
|
|
current.push(skillId);
|
|
}
|
|
laborForm.setData('skills', current);
|
|
};
|
|
|
|
return (
|
|
<AuthenticatedLayout
|
|
header={
|
|
<div>
|
|
<h2 className="text-xl font-bold leading-tight text-gray-900 dark:text-white flex items-center gap-2.5">
|
|
<div className="p-2 rounded-lg bg-emerald-50 dark:bg-emerald-950/50 text-emerald-600 dark:text-emerald-400 border border-emerald-100 dark:border-emerald-900/50">
|
|
<Users className="h-5 w-5" />
|
|
</div>
|
|
Single Labor Profiles & Skill Dictionary
|
|
</h2>
|
|
<p className="mt-1 text-xs text-gray-500 font-medium">
|
|
Standardized labor trades, classification grades, standard hourly rates, and trade competency dictionary
|
|
</p>
|
|
</div>
|
|
}
|
|
>
|
|
<Head title="Labor Catalog & Trades" />
|
|
|
|
<div className="py-6 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 space-y-6">
|
|
|
|
{/* Sub-Navigation Tabs */}
|
|
<div className="flex items-center justify-between border-b border-border/80 pb-3">
|
|
<div className="flex items-center gap-1.5 p-1 bg-muted/60 dark:bg-muted/30 rounded-xl border border-border/50">
|
|
<Link href={route('labors.index')}>
|
|
<button
|
|
type="button"
|
|
className="flex items-center gap-2 px-3.5 py-1.5 text-xs font-bold rounded-lg bg-background text-foreground shadow-2xs border border-border/60 transition-all"
|
|
>
|
|
<Users className="w-3.5 h-3.5 text-emerald-500" />
|
|
<span>Single Labor Profiles & Skills</span>
|
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4 bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 font-semibold border-none">
|
|
{stats.totalLabors}
|
|
</Badge>
|
|
</button>
|
|
</Link>
|
|
<Link href={route('labors.bundles.index')}>
|
|
<button
|
|
type="button"
|
|
className="flex items-center gap-2 px-3.5 py-1.5 text-xs font-semibold rounded-lg text-muted-foreground hover:text-foreground hover:bg-background/50 transition-all cursor-pointer"
|
|
>
|
|
<PackageCheck className="w-3.5 h-3.5 text-indigo-500" />
|
|
<span>Job Bundles & Crew Packages</span>
|
|
</button>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 1. Summary KPI Cards */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
|
|
{/* Labor Workforce KPI */}
|
|
<Card className="bg-gradient-to-br from-white to-emerald-50/30 dark:from-gray-900 dark:to-emerald-950/20 border border-gray-100 dark:border-gray-800 shadow-2xs">
|
|
<CardHeader className="pb-2 flex flex-row items-center justify-between">
|
|
<CardTitle className="text-xs font-bold tracking-wider uppercase text-gray-500 dark:text-gray-400">
|
|
Single Labor Workforce
|
|
</CardTitle>
|
|
<div className="p-2 rounded-lg bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
|
|
<Users className="w-4 h-4" />
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="pt-0">
|
|
<div className="text-2xl font-black text-gray-900 dark:text-white tracking-tight">
|
|
{stats.totalLabors} <span className="text-sm font-semibold text-gray-400">Trades</span>
|
|
</div>
|
|
<p className="text-xs text-gray-500 mt-1 flex items-center gap-1.5">
|
|
<span className="font-bold text-emerald-600 dark:text-emerald-400">{stats.activeLaborsCount} active</span>
|
|
<span>•</span>
|
|
<span>Avg: <strong className="font-mono text-gray-700 dark:text-gray-300">{formatCurrency(stats.avgLaborRate)}/hr</strong></span>
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Skill Breakdown KPI */}
|
|
<Card className="bg-gradient-to-br from-white to-blue-50/30 dark:from-gray-900 dark:to-blue-950/20 border border-gray-100 dark:border-gray-800 shadow-2xs">
|
|
<CardHeader className="pb-2 flex flex-row items-center justify-between">
|
|
<CardTitle className="text-xs font-bold tracking-wider uppercase text-gray-500 dark:text-gray-400">
|
|
Classification Breakdown
|
|
</CardTitle>
|
|
<div className="p-2 rounded-lg bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">
|
|
<HardHat className="w-4 h-4" />
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="pt-0">
|
|
<div className="text-2xl font-black text-gray-900 dark:text-white tracking-tight">
|
|
{stats.skilledCount} <span className="text-sm font-semibold text-gray-400">Skilled</span>
|
|
<span className="text-gray-300 dark:text-gray-700 mx-2">/</span>
|
|
<span className="text-lg font-bold text-amber-600 dark:text-amber-400">{stats.unskilledCount} Unskilled</span>
|
|
</div>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
Master craftsman vs apprentice manpower ratios
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Master Skills Dictionary KPI */}
|
|
<Card className="bg-gradient-to-br from-white to-indigo-50/30 dark:from-gray-900 dark:to-indigo-950/20 border border-gray-100 dark:border-gray-800 shadow-2xs">
|
|
<CardHeader className="pb-2 flex flex-row items-center justify-between">
|
|
<CardTitle className="text-xs font-bold tracking-wider uppercase text-gray-500 dark:text-gray-400">
|
|
Skill Dictionary
|
|
</CardTitle>
|
|
<div className="p-2 rounded-lg bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400">
|
|
<BookOpen className="w-4 h-4" />
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="pt-0">
|
|
<div className="text-2xl font-black text-gray-900 dark:text-white tracking-tight">
|
|
{stats.totalSkills} <span className="text-sm font-semibold text-gray-400">Skills</span>
|
|
</div>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
Standard trade competencies & certifications
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
</div>
|
|
|
|
{/* 2. Main Tabs & Table View */}
|
|
<Tabs value={activeTab} onValueChange={(val) => setActiveTab(val as 'labor' | 'skill')} className="space-y-4">
|
|
|
|
{/* Navigation Header Toolbar */}
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3 bg-white dark:bg-gray-900 p-3 rounded-xl border border-gray-100 dark:border-gray-800 shadow-2xs">
|
|
|
|
<TabsList className="bg-gray-100 dark:bg-gray-800 p-1 rounded-lg">
|
|
<TabsTrigger value="labor" className="flex items-center gap-2 px-3.5 py-1.5 text-xs font-semibold rounded-md data-[state=active]:bg-white data-[state=active]:text-gray-900 dark:data-[state=active]:bg-gray-900 dark:data-[state=active]:text-white data-[state=active]:shadow-2xs cursor-pointer">
|
|
<Users className="h-3.5 w-3.5 text-emerald-500" />
|
|
Single Labor Profiles
|
|
<span className="ml-1 text-[10px] px-1.5 py-0.2 rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300 font-bold">
|
|
{labors.length}
|
|
</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="skill" className="flex items-center gap-2 px-3.5 py-1.5 text-xs font-semibold rounded-md data-[state=active]:bg-white data-[state=active]:text-gray-900 dark:data-[state=active]:bg-gray-900 dark:data-[state=active]:text-white data-[state=active]:shadow-2xs cursor-pointer">
|
|
<BookOpen className="h-3.5 w-3.5 text-indigo-500" />
|
|
Skill Dictionary
|
|
<span className="ml-1 text-[10px] px-1.5 py-0.2 rounded-full bg-indigo-100 text-indigo-700 dark:bg-indigo-950 dark:text-indigo-300 font-bold">
|
|
{skills.length}
|
|
</span>
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* Search & Category/Status Filters */}
|
|
<div className="flex items-center gap-2.5">
|
|
{/* Category Filter Buttons (for Labor Tab) */}
|
|
{activeTab === 'labor' && (
|
|
<div className="flex items-center bg-gray-100 dark:bg-gray-800 p-0.5 rounded-lg">
|
|
{(['all', 'skilled', 'unskilled'] as const).map((cat) => (
|
|
<button
|
|
key={cat}
|
|
type="button"
|
|
onClick={() => setCategoryFilter(cat)}
|
|
className={`text-xs px-2.5 py-1 rounded-md font-semibold capitalize transition-all cursor-pointer ${
|
|
categoryFilter === cat
|
|
? 'bg-white text-gray-900 dark:bg-gray-900 dark:text-white shadow-2xs'
|
|
: 'text-gray-500 hover:text-gray-900 dark:hover:text-white'
|
|
}`}
|
|
>
|
|
{cat}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Search Input */}
|
|
<div className="relative min-w-[220px]">
|
|
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder={
|
|
activeTab === 'labor' ? 'Search trades, skills...' : 'Search master skills...'
|
|
}
|
|
className="w-full text-xs pl-8 pr-3 py-1.5 rounded-md border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-hidden focus:ring-1 focus:ring-emerald-500 focus:border-emerald-500 transition-all"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{/* 1. Single Labor Profiles Tab Content */}
|
|
<TabsContent value="labor" className="mt-0">
|
|
<Card className="shadow-xs bg-white dark:bg-gray-900 border border-gray-100 dark:border-gray-800 overflow-hidden">
|
|
<CardHeader className="border-b border-gray-100 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-800/50 py-3.5 px-5">
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
|
<div>
|
|
<CardTitle className="text-sm font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
|
<Users className="w-4 h-4 text-emerald-500" /> Single Labor Profiles
|
|
</CardTitle>
|
|
<CardDescription className="text-xs text-gray-500 mt-0.5">
|
|
Individual labor records, classification grades, standard hourly rates, and skills.
|
|
</CardDescription>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-xs text-gray-400 font-mono hidden sm:inline">
|
|
Showing {filteredLabors.length} of {labors.length}
|
|
</span>
|
|
<Button
|
|
onClick={openAddLabor}
|
|
className="bg-emerald-600 hover:bg-emerald-700 text-white font-semibold text-xs shadow-2xs flex items-center gap-1.5 h-8 px-3 cursor-pointer shrink-0"
|
|
>
|
|
<Plus className="h-3.5 w-3.5" /> Add Labor Profile
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-xs">
|
|
<thead>
|
|
<tr className="border-b border-gray-100 dark:border-gray-800 bg-gray-50/40 dark:bg-gray-800/30 text-gray-400 uppercase tracking-wider text-[10px] font-bold">
|
|
<th className="py-3 px-5">Labor Trade Name</th>
|
|
<th className="py-3 px-4">Category</th>
|
|
<th className="py-3 px-4">Standard Hourly Rate</th>
|
|
<th className="py-3 px-4">Grouped Skillsets</th>
|
|
<th className="py-3 px-4">Status</th>
|
|
<th className="py-3 px-5 text-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
|
{filteredLabors.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={6} className="text-center py-12 text-gray-400">
|
|
<div className="flex flex-col items-center justify-center gap-2">
|
|
<div className="p-3 rounded-full bg-gray-50 dark:bg-gray-800 text-gray-400 border border-gray-100 dark:border-gray-700">
|
|
<Users className="w-6 h-6" />
|
|
</div>
|
|
<p className="font-semibold text-gray-700 dark:text-gray-300 text-sm">
|
|
{searchQuery || categoryFilter !== 'all' ? 'No matching labor profiles found.' : 'No labor profiles registered yet.'}
|
|
</p>
|
|
<p className="text-xs text-gray-400">
|
|
{searchQuery || categoryFilter !== 'all' ? 'Try adjusting your search query or category filter.' : 'Click "Add Labor Profile" above to register your first trade.'}
|
|
</p>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
filteredLabors.map((labor) => (
|
|
<tr key={labor.id} className="group hover:bg-emerald-50/20 dark:hover:bg-emerald-950/10 transition-colors">
|
|
<td className="py-3.5 px-5 align-middle">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="p-1.5 rounded-md bg-emerald-50 dark:bg-emerald-950/50 text-emerald-600 dark:text-emerald-400 border border-emerald-100 dark:border-emerald-900/50">
|
|
<HardHat className="w-3.5 h-3.5" />
|
|
</div>
|
|
<span className="font-bold text-gray-900 dark:text-white text-xs group-hover:text-emerald-600 dark:group-hover:text-emerald-400 transition-colors">
|
|
{labor.name}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td className="py-3.5 px-4 align-middle">
|
|
<Badge className={
|
|
labor.category === 'skilled'
|
|
? 'bg-blue-50 text-blue-700 dark:bg-blue-950/60 dark:text-blue-300 border-blue-200 dark:border-blue-800 font-bold uppercase text-[10px]'
|
|
: 'bg-amber-50 text-amber-700 dark:bg-amber-950/60 dark:text-amber-300 border-amber-200 dark:border-amber-800 font-bold uppercase text-[10px]'
|
|
}>
|
|
{labor.category}
|
|
</Badge>
|
|
</td>
|
|
<td className="py-3.5 px-4 align-middle">
|
|
<span className="font-mono font-black text-xs text-emerald-700 dark:text-emerald-400">
|
|
{formatCurrency(labor.hourly_rate)}
|
|
</span>
|
|
<span className="text-gray-400 text-[11px] ml-1">/ hr</span>
|
|
</td>
|
|
<td className="py-3.5 px-4 align-middle max-w-[280px]">
|
|
<div className="flex flex-wrap gap-1">
|
|
{labor.skills.length === 0 ? (
|
|
<span className="text-[11px] text-gray-400 italic">No skills grouped</span>
|
|
) : (
|
|
labor.skills.map(s => (
|
|
<span
|
|
key={s.id}
|
|
className="inline-flex items-center gap-1 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-[10px] font-medium py-0.5 px-2 rounded-md border border-gray-200 dark:border-gray-700"
|
|
>
|
|
<Wrench className="w-2.5 h-2.5 text-gray-500" />
|
|
{s.name}
|
|
</span>
|
|
))
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="py-3.5 px-4 align-middle">
|
|
<Badge className={
|
|
labor.status === 'active'
|
|
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/60 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800 font-semibold'
|
|
: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400 border-gray-200 dark:border-gray-700 font-medium'
|
|
}>
|
|
<span className={`inline-block w-1.5 h-1.5 rounded-full mr-1.5 ${labor.status === 'active' ? 'bg-emerald-500 animate-pulse' : 'bg-gray-400'}`} />
|
|
{labor.status}
|
|
</Badge>
|
|
</td>
|
|
<td className="py-3.5 px-5 align-middle text-right">
|
|
<div className="flex justify-end gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => openEditLabor(labor)}
|
|
className="h-7 w-7 text-gray-500 hover:text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-950/50 cursor-pointer"
|
|
title="Edit Labor Profile"
|
|
>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => promptDeleteLabor(labor)}
|
|
className="h-7 w-7 text-rose-500 hover:text-rose-700 hover:bg-rose-50 dark:hover:bg-rose-950/50 cursor-pointer"
|
|
title="Delete Labor Profile"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div className="p-3 bg-gray-50/50 dark:bg-gray-800/40 border-t border-gray-100 dark:border-gray-800 flex items-center justify-between text-xs text-gray-500">
|
|
<span>Total Registered Profiles: <strong>{labors.length} trades</strong></span>
|
|
<span>Skilled vs Unskilled: <strong>{stats.skilledCount} Skilled / {stats.unskilledCount} Unskilled</strong></span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
{/* 2. Master Skill Dictionary Tab Content */}
|
|
<TabsContent value="skill" className="mt-0">
|
|
<Card className="shadow-xs bg-white dark:bg-gray-900 border border-gray-100 dark:border-gray-800 overflow-hidden">
|
|
<CardHeader className="border-b border-gray-100 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-800/50 py-3.5 px-5">
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
|
<div>
|
|
<CardTitle className="text-sm font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
|
<BookOpen className="w-4 h-4 text-indigo-500" /> Master Skill Dictionary
|
|
</CardTitle>
|
|
<CardDescription className="text-xs text-gray-500 mt-0.5">
|
|
Dictionary of construction trades, certifications, and specialized capabilities.
|
|
</CardDescription>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-xs text-gray-400 font-mono hidden sm:inline">
|
|
Showing {filteredSkills.length} of {skills.length}
|
|
</span>
|
|
<Button
|
|
onClick={openAddSkill}
|
|
className="bg-emerald-600 hover:bg-emerald-700 text-white font-semibold text-xs shadow-2xs flex items-center gap-1.5 h-8 px-3 cursor-pointer shrink-0"
|
|
>
|
|
<Plus className="h-3.5 w-3.5" /> Add Master Skill
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-xs">
|
|
<thead>
|
|
<tr className="border-b border-gray-100 dark:border-gray-800 bg-gray-50/40 dark:bg-gray-800/30 text-gray-400 uppercase tracking-wider text-[10px] font-bold">
|
|
<th className="py-3 px-5">Skill Name</th>
|
|
<th className="py-3 px-4">Description & Scope</th>
|
|
<th className="py-3 px-4">Associated Trades</th>
|
|
<th className="py-3 px-5 text-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
|
{filteredSkills.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={4} className="text-center py-12 text-gray-400">
|
|
<div className="flex flex-col items-center justify-center gap-2">
|
|
<div className="p-3 rounded-full bg-gray-50 dark:bg-gray-800 text-gray-400 border border-gray-100 dark:border-gray-700">
|
|
<BookOpen className="w-6 h-6" />
|
|
</div>
|
|
<p className="font-semibold text-gray-700 dark:text-gray-300 text-sm">
|
|
{searchQuery ? 'No matching skills found.' : 'No master skills registered yet.'}
|
|
</p>
|
|
<p className="text-xs text-gray-400">
|
|
{searchQuery ? 'Try adjusting your search query.' : 'Click "Add Master Skill" above to register trade capabilities.'}
|
|
</p>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
filteredSkills.map((skill) => {
|
|
const assignedCount = labors.filter(l => l.skills.some(s => s.id === skill.id)).length;
|
|
return (
|
|
<tr key={skill.id} className="group hover:bg-indigo-50/20 dark:hover:bg-indigo-950/10 transition-colors">
|
|
<td className="py-3.5 px-5 align-middle">
|
|
<div className="flex items-center gap-2">
|
|
<div className="p-1 rounded-md bg-indigo-50 dark:bg-indigo-950/50 text-indigo-600 dark:text-indigo-400 border border-indigo-100 dark:border-indigo-900/50">
|
|
<Tag className="w-3 h-3" />
|
|
</div>
|
|
<span className="font-bold text-gray-900 dark:text-white text-xs group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors">
|
|
{skill.name}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td className="py-3.5 px-4 align-middle text-gray-500 dark:text-gray-400 text-xs max-w-md">
|
|
{skill.description || <span className="italic text-gray-400">No description provided</span>}
|
|
</td>
|
|
<td className="py-3.5 px-4 align-middle">
|
|
{assignedCount > 0 ? (
|
|
<span className="inline-flex items-center gap-1 bg-emerald-50 dark:bg-emerald-950/50 text-emerald-700 dark:text-emerald-300 text-[11px] font-semibold py-0.5 px-2 rounded-md border border-emerald-200 dark:border-emerald-800">
|
|
<Users className="w-3 h-3 text-emerald-500" />
|
|
{assignedCount} {assignedCount === 1 ? 'Trade' : 'Trades'}
|
|
</span>
|
|
) : (
|
|
<span className="text-gray-400 italic text-[11px]">Unassigned</span>
|
|
)}
|
|
</td>
|
|
<td className="py-3.5 px-5 align-middle text-right">
|
|
<div className="flex justify-end gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => openEditSkill(skill)}
|
|
className="h-7 w-7 text-gray-500 hover:text-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-950/50 cursor-pointer"
|
|
title="Edit Skill"
|
|
>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => promptDeleteSkill(skill)}
|
|
className="h-7 w-7 text-rose-500 hover:text-rose-700 hover:bg-rose-50 dark:hover:bg-rose-950/50 cursor-pointer"
|
|
title="Delete Skill"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div className="p-3 bg-gray-50/50 dark:bg-gray-800/40 border-t border-gray-100 dark:border-gray-800 flex items-center justify-between text-xs text-gray-500">
|
|
<span>Registered Master Skills: <strong>{skills.length} skills</strong></span>
|
|
<span>Active Filtered: <strong>{filteredSkills.length}</strong></span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
|
|
{/* Labor Modal */}
|
|
<Dialog open={laborModalOpen} onOpenChange={setLaborModalOpen}>
|
|
<DialogContent className="sm:max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2 text-base font-bold text-gray-900 dark:text-white">
|
|
<Users className="h-5 w-5 text-emerald-600" />
|
|
{editingLabor ? 'Edit Labor Profile' : 'Add Labor Profile'}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs text-gray-500">
|
|
Define labor record details, classification category, and select capability skillsets.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<form onSubmit={handleLaborSubmit} className="space-y-4 pt-2">
|
|
<div className="space-y-1">
|
|
<Label htmlFor="name" className="text-xs font-semibold text-gray-700 dark:text-gray-300">Labor Trade Name *</Label>
|
|
<Input
|
|
id="name"
|
|
value={laborForm.data.name}
|
|
onChange={e => laborForm.setData('name', e.target.value)}
|
|
placeholder="e.g. Master Welder, Mason - Class A"
|
|
required
|
|
className="text-xs"
|
|
/>
|
|
{laborForm.errors.name && (
|
|
<p className="text-xs text-rose-500 mt-1 flex items-center gap-1">
|
|
<ShieldAlert className="h-3 w-3" /> {laborForm.errors.name}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1">
|
|
<Label htmlFor="category" className="text-xs font-semibold text-gray-700 dark:text-gray-300">Classification *</Label>
|
|
<Select
|
|
value={laborForm.data.category}
|
|
onValueChange={(value) => laborForm.setData('category', (value as 'skilled' | 'unskilled') || 'skilled')}
|
|
>
|
|
<SelectTrigger id="category" className="text-xs">
|
|
<SelectValue placeholder="Select Category" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="skilled">Skilled Labor</SelectItem>
|
|
<SelectItem value="unskilled">Unskilled / Helper</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label htmlFor="hourly_rate" className="text-xs font-semibold text-gray-700 dark:text-gray-300">Hourly Rate (₱) *</Label>
|
|
<div className="relative">
|
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 font-bold text-sm">₱</span>
|
|
<Input
|
|
id="hourly_rate"
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
value={laborForm.data.hourly_rate}
|
|
onChange={e => laborForm.setData('hourly_rate', e.target.value)}
|
|
placeholder="120.00"
|
|
required
|
|
className="pl-8 font-mono text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label htmlFor="labor_status" className="text-xs font-semibold text-gray-700 dark:text-gray-300">Status *</Label>
|
|
<Select
|
|
value={laborForm.data.status}
|
|
onValueChange={value => laborForm.setData('status', value || 'active')}
|
|
>
|
|
<SelectTrigger id="labor_status" className="text-xs">
|
|
<SelectValue placeholder="Select Status" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="active">Active</SelectItem>
|
|
<SelectItem value="inactive">Inactive</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Skill Selector */}
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold text-gray-700 dark:text-gray-300 flex justify-between">
|
|
<span>Assign Skillsets ({laborForm.data.skills.length} selected)</span>
|
|
<span className="text-[10px] text-gray-400 font-normal">Click to toggle</span>
|
|
</Label>
|
|
|
|
{skills.length === 0 ? (
|
|
<div className="p-3 border border-dashed border-gray-200 dark:border-gray-700 rounded-lg text-center bg-gray-50/50 dark:bg-gray-800/50">
|
|
<p className="text-[11px] text-gray-500">No master skills found. Create them in the dictionary tab first.</p>
|
|
</div>
|
|
) : (
|
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg max-h-[140px] overflow-y-auto p-2 bg-gray-50/40 dark:bg-gray-800/40 space-y-1">
|
|
{skills.map((skill) => {
|
|
const isSelected = laborForm.data.skills.includes(skill.id);
|
|
return (
|
|
<div
|
|
key={skill.id}
|
|
onClick={() => toggleSkillSelection(skill.id)}
|
|
className={`flex items-center justify-between p-2 rounded-md cursor-pointer transition-colors text-xs select-none ${
|
|
isSelected
|
|
? 'bg-emerald-50 dark:bg-emerald-950/60 text-emerald-900 dark:text-emerald-200 font-semibold'
|
|
: 'hover:bg-white dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300'
|
|
}`}
|
|
>
|
|
<span className="flex items-center gap-1.5">
|
|
<Wrench className={`w-3 h-3 ${isSelected ? 'text-emerald-600 dark:text-emerald-400' : 'text-gray-400'}`} />
|
|
{skill.name}
|
|
</span>
|
|
{isSelected && <Check className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400 stroke-[3px]" />}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter className="pt-3 border-t border-gray-100 dark:border-gray-800">
|
|
<Button type="button" variant="outline" onClick={() => setLaborModalOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" disabled={laborForm.processing} className="bg-emerald-600 hover:bg-emerald-700 text-white font-semibold">
|
|
{laborForm.processing ? 'Saving...' : 'Save Labor Profile'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Skill Modal */}
|
|
<Dialog open={skillModalOpen} onOpenChange={setSkillModalOpen}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2 text-base font-bold text-gray-900 dark:text-white">
|
|
<BookOpen className="h-5 w-5 text-indigo-600" />
|
|
{editingSkill ? 'Edit Master Skill' : 'Add Master Skill'}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs text-gray-500">
|
|
Define new construction trade competency, certification, or capability category.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<form onSubmit={handleSkillSubmit} className="space-y-4 pt-2">
|
|
<div className="space-y-1">
|
|
<Label htmlFor="skill_name" className="text-xs font-semibold text-gray-700 dark:text-gray-300">Skill Name *</Label>
|
|
<Input
|
|
id="skill_name"
|
|
value={skillForm.data.name}
|
|
onChange={e => skillForm.setData('name', e.target.value)}
|
|
placeholder="e.g. Rebar Tying, 6G Pipe Welding, Concrete Finishing"
|
|
required
|
|
className="text-xs"
|
|
/>
|
|
{skillForm.errors.name && (
|
|
<p className="text-xs text-rose-500 mt-1 flex items-center gap-1">
|
|
<ShieldAlert className="h-3 w-3" /> {skillForm.errors.name}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label htmlFor="skill_desc" className="text-xs font-semibold text-gray-700 dark:text-gray-300">Description</Label>
|
|
<Textarea
|
|
id="skill_desc"
|
|
value={skillForm.data.description}
|
|
onChange={e => skillForm.setData('description', e.target.value)}
|
|
placeholder="Brief description of the skill competency or certification requirements."
|
|
className="text-xs"
|
|
/>
|
|
</div>
|
|
|
|
<DialogFooter className="pt-3 border-t border-gray-100 dark:border-gray-800">
|
|
<Button type="button" variant="outline" onClick={() => setSkillModalOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" disabled={skillForm.processing} className="bg-emerald-600 hover:bg-emerald-700 text-white font-semibold">
|
|
{skillForm.processing ? 'Saving...' : 'Save Skill'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Confirm Delete Labor Modal */}
|
|
<ConfirmModal
|
|
open={deleteLaborModalOpen}
|
|
onOpenChange={setDeleteLaborModalOpen}
|
|
title="Delete Labor Profile"
|
|
message={`Are you sure you want to delete "${laborToDelete?.name}"? This will remove this trade profile and its associated task allocations.`}
|
|
confirmText="Delete Labor"
|
|
cancelText="Cancel"
|
|
variant="destructive"
|
|
loading={laborForm.processing}
|
|
onConfirm={handleConfirmLaborDelete}
|
|
/>
|
|
|
|
{/* Confirm Delete Skill Modal */}
|
|
<ConfirmModal
|
|
open={deleteSkillModalOpen}
|
|
onOpenChange={setDeleteSkillModalOpen}
|
|
title="Delete Master Skill"
|
|
message={`Are you sure you want to delete skill "${skillToDelete?.name}"? It will be unlinked from all assigned labor profiles.`}
|
|
confirmText="Delete Skill"
|
|
cancelText="Cancel"
|
|
variant="destructive"
|
|
loading={skillForm.processing}
|
|
onConfirm={handleConfirmSkillDelete}
|
|
/>
|
|
</AuthenticatedLayout>
|
|
);
|
|
}
|