mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-24 23:35:45 +00:00
feat: add figma make components
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,712 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/figma-make/components/base/card';
|
||||
import { Label } from '@/figma-make/components/base/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/figma-make/components/base/select';
|
||||
import { Input } from '@/figma-make/components/base/input';
|
||||
import { Badge } from '@/figma-make/components/base/badge';
|
||||
import {
|
||||
Calendar as CalendarIcon,
|
||||
Users,
|
||||
AlertCircle,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
import { DateRangePicker } from '@/figma-make/components/base/date-range-picker';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
import { supabase, isSupabaseConfigured } from '@/figma-make/lib/supabase';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface EmployeePerformance {
|
||||
employee_id: string;
|
||||
employee_name: string;
|
||||
kandang_id: string;
|
||||
kandang_name: string;
|
||||
total_activities_in_category: number; // Total aktivitas di kategori
|
||||
completed_activities: number; // Aktivitas yang sudah di-check
|
||||
completion_rate: number;
|
||||
last_activity_date: string | null;
|
||||
color: string; // Color based on kandang
|
||||
}
|
||||
|
||||
interface Kandang {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ChecklistKandang {
|
||||
id: string;
|
||||
date: string;
|
||||
kandang_id: string;
|
||||
category: string;
|
||||
kandang: {
|
||||
id: string;
|
||||
name: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface AssignmentEmployee {
|
||||
id: string;
|
||||
task_id: string;
|
||||
employee_id: string;
|
||||
checked: boolean;
|
||||
updated_at: string;
|
||||
employee: {
|
||||
id: string;
|
||||
name: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const KANDANG_COLORS = [
|
||||
'#0069e0', // Blue (primary)
|
||||
'#10B981', // Green
|
||||
'#F59E0B', // Amber
|
||||
'#EF4444', // Red
|
||||
'#8B5CF6', // Violet
|
||||
'#EC4899', // Pink
|
||||
'#14B8A6', // Teal
|
||||
'#F97316', // Orange
|
||||
];
|
||||
|
||||
const CATEGORY_LABELS: { [key: string]: string } = {
|
||||
pullet_open: 'Pullet Open',
|
||||
pullet_close: 'Pullet Close',
|
||||
produksi_open: 'Produksi Open',
|
||||
produksi_close: 'Produksi Close',
|
||||
};
|
||||
|
||||
export function Dashboard() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [employeePerformance, setEmployeePerformance] = useState<
|
||||
EmployeePerformance[]
|
||||
>([]);
|
||||
|
||||
// Master data
|
||||
const [kandangList, setKandangList] = useState<Kandang[]>([]);
|
||||
const [categoryList, setCategoryList] = useState<Category[]>([]);
|
||||
|
||||
// Filters
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
const [kandangFilter, setKandangFilter] = useState('ALL');
|
||||
const [categoryFilter, setCategoryFilter] = useState('ALL');
|
||||
|
||||
// Color mapping for kandang
|
||||
const [kandangColorMap, setKandangColorMap] = useState<{
|
||||
[key: string]: string;
|
||||
}>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetchMasterData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Only fetch when date filters are set
|
||||
if (dateFrom && dateTo) {
|
||||
fetchEmployeePerformance();
|
||||
} else {
|
||||
setEmployeePerformance([]);
|
||||
}
|
||||
}, [dateFrom, dateTo, kandangFilter, categoryFilter]);
|
||||
|
||||
const fetchMasterData = async () => {
|
||||
if (!isSupabaseConfigured()) return;
|
||||
|
||||
try {
|
||||
// Fetch kandang
|
||||
const { data: kandangData, error: kandangError } = await supabase
|
||||
.from('kandang')
|
||||
.select('id, name')
|
||||
.order('name', { ascending: true });
|
||||
|
||||
if (kandangError) {
|
||||
console.error('Error fetching kandang:', kandangError);
|
||||
} else {
|
||||
setKandangList(kandangData || []);
|
||||
|
||||
// Create color mapping
|
||||
const colorMap: { [key: string]: string } = {};
|
||||
(kandangData || []).forEach((k, index) => {
|
||||
colorMap[k.id] = KANDANG_COLORS[index % KANDANG_COLORS.length];
|
||||
});
|
||||
setKandangColorMap(colorMap);
|
||||
}
|
||||
|
||||
// Set categories from CATEGORY_LABELS (hardcoded list)
|
||||
const categories: Category[] = Object.keys(CATEGORY_LABELS).map((id) => ({
|
||||
id,
|
||||
name: CATEGORY_LABELS[id],
|
||||
}));
|
||||
setCategoryList(categories);
|
||||
} catch (error) {
|
||||
console.error('Error fetching master data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchEmployeePerformance = async () => {
|
||||
if (!isSupabaseConfigured() || !dateFrom || !dateTo) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Step 1: Get all checklists in date range + filters
|
||||
let checklistQuery = supabase
|
||||
.from('daily_checklists')
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
date,
|
||||
kandang_id,
|
||||
category,
|
||||
kandang:kandang_id (
|
||||
id,
|
||||
name
|
||||
)
|
||||
`
|
||||
)
|
||||
.gte('date', dateFrom)
|
||||
.lte('date', dateTo);
|
||||
|
||||
if (kandangFilter !== 'ALL') {
|
||||
checklistQuery = checklistQuery.eq('kandang_id', kandangFilter);
|
||||
}
|
||||
|
||||
if (categoryFilter !== 'ALL') {
|
||||
checklistQuery = checklistQuery.eq('category', categoryFilter);
|
||||
}
|
||||
|
||||
const { data: checklists, error: checklistError } = await checklistQuery;
|
||||
|
||||
if (checklistError) {
|
||||
console.error('Error fetching checklists:', checklistError);
|
||||
toast.error('Gagal memuat data checklist');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!checklists || checklists.length === 0) {
|
||||
setEmployeePerformance([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const checklistsData = checklists as unknown as ChecklistKandang[];
|
||||
|
||||
// Step 2: Get all tasks from these checklists
|
||||
const checklistIds = checklistsData.map((c) => c.id);
|
||||
const { data: tasks, error: tasksError } = await supabase
|
||||
.from('daily_checklist_activity_tasks')
|
||||
.select('id, checklist_id')
|
||||
.in('checklist_id', checklistIds);
|
||||
|
||||
if (tasksError) {
|
||||
console.error('Error fetching tasks:', tasksError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tasks || tasks.length === 0) {
|
||||
setEmployeePerformance([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const taskIds = tasks.map((t) => t.id);
|
||||
|
||||
// Step 3: Get all assignments for these tasks
|
||||
const { data: assignments, error: assignmentsError } = await supabase
|
||||
.from('daily_checklist_activity_task_assignments')
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
task_id,
|
||||
employee_id,
|
||||
checked,
|
||||
updated_at,
|
||||
employee:employee_id (
|
||||
id,
|
||||
name
|
||||
)
|
||||
`
|
||||
)
|
||||
.in('task_id', taskIds);
|
||||
|
||||
if (assignmentsError) {
|
||||
console.error('Error fetching assignments:', assignmentsError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!assignments || assignments.length === 0) {
|
||||
setEmployeePerformance([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const assignmentsData = assignments as unknown as AssignmentEmployee[];
|
||||
|
||||
// Step 4: Calculate total activities in selected category (if filtered)
|
||||
let totalActivitiesInCategory = 0;
|
||||
|
||||
if (categoryFilter !== 'ALL') {
|
||||
// Get total activities from master data for this category
|
||||
const { data: phases } = await supabase
|
||||
.from('phases')
|
||||
.select('id')
|
||||
.eq('category_id', categoryFilter);
|
||||
|
||||
if (phases && phases.length > 0) {
|
||||
const phaseIds = phases.map((p) => p.id);
|
||||
const { count } = await supabase
|
||||
.from('activities')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.in('phase_id', phaseIds);
|
||||
|
||||
totalActivitiesInCategory = count || 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Group by employee and calculate performance
|
||||
const employeeMap = new Map<
|
||||
string,
|
||||
{
|
||||
employee_id: string;
|
||||
employee_name: string;
|
||||
kandang_id: string;
|
||||
kandang_name: string;
|
||||
completed_count: number;
|
||||
total_count: number;
|
||||
last_activity_date: string | null;
|
||||
}
|
||||
>();
|
||||
|
||||
assignmentsData.forEach((assignment) => {
|
||||
const task = tasks.find((t) => t.id === assignment.task_id);
|
||||
if (!task) return;
|
||||
|
||||
const checklist = checklistsData.find(
|
||||
(c) => c.id === task.checklist_id
|
||||
);
|
||||
if (!checklist) return;
|
||||
|
||||
const employeeId = assignment.employee_id;
|
||||
const employeeName = assignment.employee?.name || 'Unknown';
|
||||
const kandangId = checklist.kandang_id;
|
||||
const kandangName = checklist.kandang?.name || 'Unknown';
|
||||
|
||||
if (!employeeMap.has(employeeId)) {
|
||||
employeeMap.set(employeeId, {
|
||||
employee_id: employeeId,
|
||||
employee_name: employeeName,
|
||||
kandang_id: kandangId,
|
||||
kandang_name: kandangName,
|
||||
completed_count: 0,
|
||||
total_count: 0,
|
||||
last_activity_date: null,
|
||||
});
|
||||
}
|
||||
|
||||
const empData = employeeMap.get(employeeId)!;
|
||||
empData.total_count += 1;
|
||||
|
||||
if (assignment.checked) {
|
||||
empData.completed_count += 1;
|
||||
}
|
||||
|
||||
// Update last activity date
|
||||
if (assignment.updated_at) {
|
||||
if (
|
||||
!empData.last_activity_date ||
|
||||
assignment.updated_at > empData.last_activity_date
|
||||
) {
|
||||
empData.last_activity_date = assignment.updated_at;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Step 6: Convert to array and add calculated fields
|
||||
const performanceData: EmployeePerformance[] = Array.from(
|
||||
employeeMap.values()
|
||||
).map((emp) => {
|
||||
// Use total activities in category if category is selected, otherwise use employee's assigned count
|
||||
const totalActivities =
|
||||
categoryFilter !== 'ALL' && totalActivitiesInCategory > 0
|
||||
? totalActivitiesInCategory
|
||||
: emp.total_count;
|
||||
|
||||
return {
|
||||
employee_id: emp.employee_id,
|
||||
employee_name: emp.employee_name,
|
||||
kandang_id: emp.kandang_id,
|
||||
kandang_name: emp.kandang_name,
|
||||
total_activities_in_category: totalActivities,
|
||||
completed_activities: emp.completed_count,
|
||||
completion_rate:
|
||||
totalActivities > 0
|
||||
? Math.round((emp.completed_count / totalActivities) * 100)
|
||||
: 0,
|
||||
last_activity_date: emp.last_activity_date,
|
||||
color: kandangColorMap[emp.kandang_id] || '#0069e0',
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by employee name
|
||||
performanceData.sort((a, b) =>
|
||||
a.employee_name.localeCompare(b.employee_name)
|
||||
);
|
||||
|
||||
setEmployeePerformance(performanceData);
|
||||
} catch (error) {
|
||||
console.error('Error fetching employee performance:', error);
|
||||
toast.error('Terjadi kesalahan saat memuat data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const hasFilters = dateFrom && dateTo;
|
||||
|
||||
// Prepare chart data
|
||||
const chartData = employeePerformance.map((emp) => ({
|
||||
name: emp.employee_name,
|
||||
completed: emp.completed_activities,
|
||||
remaining: emp.total_activities_in_category - emp.completed_activities,
|
||||
total: emp.total_activities_in_category,
|
||||
color: emp.color,
|
||||
kandang: emp.kandang_name,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className='min-h-screen'>
|
||||
<div className='p-6'>
|
||||
{/* Page Title */}
|
||||
<div className='mb-6'>
|
||||
<h1 className='text-2xl font-semibold text-gray-900'>Dashboard</h1>
|
||||
<p className='text-sm text-gray-600 mt-1'>
|
||||
Performance tracking per ABK
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters Card */}
|
||||
<Card className='border-gray-200/60 shadow-sm rounded-xl bg-white mb-6'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-lg'>Filter</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'>
|
||||
<div>
|
||||
<Label>
|
||||
Periode Tanggal <span className='text-red-500'>*</span>
|
||||
</Label>
|
||||
<div className='mt-1.5'>
|
||||
<DateRangePicker
|
||||
dateFrom={dateFrom}
|
||||
dateTo={dateTo}
|
||||
onDateChange={(from, to) => {
|
||||
setDateFrom(from);
|
||||
setDateTo(to);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor='kandang-filter-dashboard'>Kandang</Label>
|
||||
<div className='mt-1.5'>
|
||||
<Select
|
||||
value={kandangFilter}
|
||||
onValueChange={setKandangFilter}
|
||||
>
|
||||
<SelectTrigger
|
||||
id='kandang-filter-dashboard'
|
||||
className='border-gray-200'
|
||||
>
|
||||
<SelectValue placeholder='Semua Kandang' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='ALL'>Semua Kandang</SelectItem>
|
||||
{kandangList.map((kandang) => (
|
||||
<SelectItem key={kandang.id} value={kandang.id}>
|
||||
{kandang.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor='category-filter-dashboard'>Kategori</Label>
|
||||
<div className='mt-1.5'>
|
||||
<Select
|
||||
value={categoryFilter}
|
||||
onValueChange={setCategoryFilter}
|
||||
>
|
||||
<SelectTrigger
|
||||
id='category-filter-dashboard'
|
||||
className='border-gray-200'
|
||||
>
|
||||
<SelectValue placeholder='Semua Kategori' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='ALL'>Semua Kategori</SelectItem>
|
||||
{categoryList.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Performance Chart Card */}
|
||||
<Card className='border-gray-200/60 shadow-sm rounded-xl bg-white mb-6'>
|
||||
<CardHeader>
|
||||
<div className='flex items-center gap-2'>
|
||||
<CardTitle className='text-lg'>
|
||||
Performance Overview - Aktivitas per ABK
|
||||
</CardTitle>
|
||||
<Info className='w-4 h-4 text-gray-400' />
|
||||
</div>
|
||||
<p className='text-sm text-gray-500 mt-1'>
|
||||
Aktivitas yang telah diselesaikan vs total aktivitas di kategori
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!hasFilters ? (
|
||||
// Empty state - no filters
|
||||
<div className='flex flex-col items-center justify-center py-16 text-center'>
|
||||
<AlertCircle className='w-16 h-16 text-gray-300 mb-4' />
|
||||
<h3 className='text-lg font-semibold text-gray-700 mb-2'>
|
||||
Pilih Filter Terlebih Dahulu
|
||||
</h3>
|
||||
<p className='text-sm text-gray-500 max-w-md'>
|
||||
Silakan pilih{' '}
|
||||
<span className='font-semibold'>Tanggal Dari</span> dan{' '}
|
||||
<span className='font-semibold'>Tanggal Sampai</span> untuk
|
||||
melihat performance ABK.
|
||||
</p>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className='text-center py-16 text-gray-500'>
|
||||
Memuat data...
|
||||
</div>
|
||||
) : employeePerformance.length === 0 ? (
|
||||
<div className='flex flex-col items-center justify-center py-16 text-center'>
|
||||
<Users className='w-16 h-16 text-gray-300 mb-4' />
|
||||
<h3 className='text-lg font-semibold text-gray-700 mb-2'>
|
||||
Tidak Ada Data
|
||||
</h3>
|
||||
<p className='text-sm text-gray-500 max-w-md'>
|
||||
Tidak ada data aktivitas ABK pada periode yang dipilih.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width='100%' height={400}>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 20, right: 30, left: 20, bottom: 80 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray='3 3' stroke='#E5E7EB' />
|
||||
<XAxis
|
||||
dataKey='name'
|
||||
angle={-45}
|
||||
textAnchor='end'
|
||||
height={100}
|
||||
tick={{ fill: '#6B7280', fontSize: 12 }}
|
||||
/>
|
||||
<YAxis tick={{ fill: '#6B7280', fontSize: 12 }} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #E5E7EB',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
}}
|
||||
labelStyle={{ fontWeight: 600, marginBottom: '8px' }}
|
||||
formatter={(value, name) => {
|
||||
if (name === 'completed')
|
||||
return [value, 'Aktivitas Selesai'];
|
||||
if (name === 'remaining')
|
||||
return [value, 'Aktivitas Tersisa'];
|
||||
return [value, name];
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ paddingTop: '20px' }}
|
||||
formatter={(value: string) => {
|
||||
if (value === 'completed') return 'Aktivitas Selesai';
|
||||
if (value === 'remaining') return 'Aktivitas Tersisa';
|
||||
return value;
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey='completed'
|
||||
stackId='a'
|
||||
fill='#10B981'
|
||||
radius={[0, 0, 0, 0]}
|
||||
>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-completed-${index}`}
|
||||
fill={entry.color}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
<Bar
|
||||
dataKey='remaining'
|
||||
stackId='a'
|
||||
fill='#E5E7EB'
|
||||
radius={[4, 4, 0, 0]}
|
||||
>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-remaining-${index}`}
|
||||
fill={`${entry.color}33`}
|
||||
opacity={0.3}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Employee Tracking Table */}
|
||||
{hasFilters && employeePerformance.length > 0 && (
|
||||
<Card className='border-gray-200/60 shadow-sm rounded-xl bg-white'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-lg'>Tracking ABK</CardTitle>
|
||||
<p className='text-sm text-gray-500 mt-1'>
|
||||
Detail performance masing-masing ABK
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='overflow-x-auto'>
|
||||
<table className='w-full border border-gray-200 rounded-lg'>
|
||||
<thead>
|
||||
<tr className='bg-gray-50 border-b border-gray-200'>
|
||||
<th className='text-left py-3 px-4 text-sm font-semibold text-gray-700'>
|
||||
Nama ABK
|
||||
</th>
|
||||
<th className='text-left py-3 px-4 text-sm font-semibold text-gray-700'>
|
||||
Kandang
|
||||
</th>
|
||||
<th className='text-center py-3 px-4 text-sm font-semibold text-gray-700'>
|
||||
Total Aktivitas
|
||||
</th>
|
||||
<th className='text-center py-3 px-4 text-sm font-semibold text-gray-700'>
|
||||
Aktivitas Selesai
|
||||
</th>
|
||||
<th className='text-center py-3 px-4 text-sm font-semibold text-gray-700'>
|
||||
Aktivitas Tersisa
|
||||
</th>
|
||||
<th className='text-center py-3 px-4 text-sm font-semibold text-gray-700'>
|
||||
Completion Rate
|
||||
</th>
|
||||
<th className='text-left py-3 px-4 text-sm font-semibold text-gray-700'>
|
||||
Last Activity
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{employeePerformance.map((emp, index) => (
|
||||
<tr
|
||||
key={emp.employee_id}
|
||||
className={
|
||||
index % 2 === 0 ? 'bg-white' : 'bg-gray-50/50'
|
||||
}
|
||||
>
|
||||
<td className='py-3 px-4 text-sm text-gray-900 font-medium'>
|
||||
{emp.employee_name}
|
||||
</td>
|
||||
<td className='py-3 px-4'>
|
||||
<Badge
|
||||
style={{
|
||||
backgroundColor: `${emp.color}15`,
|
||||
color: emp.color,
|
||||
borderColor: `${emp.color}30`,
|
||||
}}
|
||||
className='border'
|
||||
>
|
||||
{emp.kandang_name}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className='py-3 px-4 text-center text-sm text-gray-900'>
|
||||
{emp.total_activities_in_category}
|
||||
</td>
|
||||
<td className='py-3 px-4 text-center text-sm font-semibold text-green-700'>
|
||||
{emp.completed_activities}
|
||||
</td>
|
||||
<td className='py-3 px-4 text-center text-sm text-gray-600'>
|
||||
{emp.total_activities_in_category -
|
||||
emp.completed_activities}
|
||||
</td>
|
||||
<td className='py-3 px-4 text-center'>
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
<div className='w-24 bg-gray-200 rounded-full h-2'>
|
||||
<div
|
||||
className='h-2 rounded-full transition-all'
|
||||
style={{
|
||||
width: `${emp.completion_rate}%`,
|
||||
backgroundColor: emp.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className='text-sm text-gray-700 font-medium'>
|
||||
{emp.completion_rate}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className='py-3 px-4 text-sm text-gray-600'>
|
||||
{formatDate(emp.last_activity_date)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,938 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Eye, CheckCircle, XCircle, Search, Trash2 } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/figma-make/components/base/card';
|
||||
import { Button } from '@/figma-make/components/base/button';
|
||||
import { Badge } from '@/figma-make/components/base/badge';
|
||||
import { Input } from '@/figma-make/components/base/input';
|
||||
import { Label } from '@/figma-make/components/base/label';
|
||||
import { Textarea } from '@/figma-make/components/base/textarea';
|
||||
import { DateRangePicker } from '@/figma-make/components/base/date-range-picker';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/figma-make/components/base/select';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/figma-make/components/base/dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { supabase, isSupabaseConfigured } from '@/figma-make/lib/supabase';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface ChecklistItem {
|
||||
checklist_id: string;
|
||||
date: string;
|
||||
kandang_name: string;
|
||||
kandang_id: string; // ✅ Add kandang_id
|
||||
category: string;
|
||||
status: string;
|
||||
progress_percent: number;
|
||||
total_phases: number;
|
||||
total_activities: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface Kandang {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ChecklistQueryResult {
|
||||
id: string;
|
||||
date: string;
|
||||
kandang_id: string;
|
||||
category: string;
|
||||
status: string;
|
||||
updated_at: string;
|
||||
kandang: {
|
||||
id: string;
|
||||
name: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'ALL', label: 'Semua Status' },
|
||||
{ value: 'DRAFT', label: 'Draft' },
|
||||
{ value: 'SUBMITTED', label: 'Submitted' },
|
||||
{ value: 'APPROVED', label: 'Approved' },
|
||||
{ value: 'REJECTED', label: 'Rejected' },
|
||||
];
|
||||
|
||||
const CATEGORY_LABELS: { [key: string]: string } = {
|
||||
pullet_open: 'Pullet Open',
|
||||
pullet_close: 'Pullet Close',
|
||||
produksi_open: 'Produksi Open',
|
||||
produksi_close: 'Produksi Close',
|
||||
};
|
||||
|
||||
export function ListDailyChecklistContent() {
|
||||
const router = useRouter();
|
||||
const [checklistList, setChecklistList] = useState<ChecklistItem[]>([]);
|
||||
const [filteredList, setFilteredList] = useState<ChecklistItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Master data
|
||||
const [kandangList, setKandangList] = useState<Kandang[]>([]);
|
||||
|
||||
// Filters
|
||||
const [statusFilter, setStatusFilter] = useState('ALL');
|
||||
const [kandangFilter, setKandangFilter] = useState('ALL');
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
|
||||
// Modals
|
||||
const [showApproveModal, setShowApproveModal] = useState(false);
|
||||
const [showRejectModal, setShowRejectModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState<ChecklistItem | null>(null);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchKandangList();
|
||||
fetchChecklistList();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
applyFilters();
|
||||
}, [
|
||||
checklistList,
|
||||
statusFilter,
|
||||
kandangFilter,
|
||||
searchText,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
]);
|
||||
|
||||
const fetchKandangList = async () => {
|
||||
if (!isSupabaseConfigured()) return;
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('kandang')
|
||||
.select('id, name')
|
||||
.order('name', { ascending: true });
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching kandang:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
setKandangList(data || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching kandang:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchChecklistList = async () => {
|
||||
if (!isSupabaseConfigured()) {
|
||||
console.warn('Supabase not configured');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// ✅ Fetch checklists with joins to get complete data
|
||||
const { data: checklists, error } = await supabase
|
||||
.from('daily_checklists')
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
date,
|
||||
kandang_id,
|
||||
category,
|
||||
status,
|
||||
updated_at,
|
||||
kandang:kandang_id (
|
||||
id,
|
||||
name
|
||||
)
|
||||
`
|
||||
)
|
||||
.order('date', { ascending: false })
|
||||
.order('updated_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching checklist list:', error);
|
||||
toast.error('Gagal memuat data checklist');
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ For each checklist, fetch phases, activities, and assignments count
|
||||
const enrichedData: ChecklistItem[] = await Promise.all(
|
||||
((checklists as unknown as ChecklistQueryResult[]) || [])
|
||||
.filter((checklist) => checklist.id) // ✅ Skip checklists with null ID
|
||||
.map(async (checklist) => {
|
||||
// Count phases
|
||||
const { count: phaseCount } = await supabase
|
||||
.from('daily_checklist_phases')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('checklist_id', checklist.id);
|
||||
|
||||
// Count activities (tasks)
|
||||
const { count: activityCount } = await supabase
|
||||
.from('daily_checklist_activity_tasks')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('checklist_id', checklist.id);
|
||||
|
||||
// ✅ NEW LOGIC: Calculate progress based on phase coverage
|
||||
// Step 1: Get total phases in master data for this category
|
||||
const { count: totalPhasesInMaster } = await supabase
|
||||
.from('phases')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('category_id', checklist.category);
|
||||
|
||||
// Step 2: Get phases that have at least 1 CHECKED assignment
|
||||
// First, get all tasks for this checklist
|
||||
const { data: tasks } = await supabase
|
||||
.from('daily_checklist_activity_tasks')
|
||||
.select('id, phase_id')
|
||||
.eq('checklist_id', checklist.id);
|
||||
|
||||
const taskIds = (tasks || []).map((t) => t.id);
|
||||
const uniquePhasesWithChecked = new Set<string>();
|
||||
|
||||
if (taskIds.length > 0) {
|
||||
// Get assignments that are CHECKED
|
||||
const { data: checkedAssignments } = await supabase
|
||||
.from('daily_checklist_activity_task_assignments')
|
||||
.select('task_id')
|
||||
.in('task_id', taskIds)
|
||||
.eq('checked', true); // ✅ Only get checked assignments
|
||||
|
||||
if (checkedAssignments && checkedAssignments.length > 0) {
|
||||
// Map task_ids back to phase_ids
|
||||
const checkedTaskIds = new Set(
|
||||
checkedAssignments.map((a) => a.task_id)
|
||||
);
|
||||
tasks?.forEach((task) => {
|
||||
if (checkedTaskIds.has(task.id)) {
|
||||
uniquePhasesWithChecked.add(task.phase_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const phasesWithCheckedCount = uniquePhasesWithChecked.size;
|
||||
|
||||
// Step 3: Calculate progress
|
||||
const progressPercent =
|
||||
totalPhasesInMaster && totalPhasesInMaster > 0
|
||||
? Math.round(
|
||||
(phasesWithCheckedCount / totalPhasesInMaster) * 100
|
||||
)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
checklist_id: checklist.id,
|
||||
date: checklist.date,
|
||||
kandang_name: checklist.kandang?.name || '-',
|
||||
kandang_id: checklist.kandang_id,
|
||||
category: checklist.category,
|
||||
status: checklist.status,
|
||||
progress_percent: progressPercent,
|
||||
total_phases: phaseCount || 0,
|
||||
total_activities: activityCount || 0,
|
||||
updated_at: checklist.updated_at,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
setChecklistList(enrichedData);
|
||||
} catch (error) {
|
||||
console.error('Error fetching checklist list:', error);
|
||||
toast.error('Terjadi kesalahan');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyFilters = () => {
|
||||
let filtered = [...checklistList];
|
||||
|
||||
// Filter by status
|
||||
if (statusFilter && statusFilter !== 'ALL') {
|
||||
filtered = filtered.filter((item) => item.status === statusFilter);
|
||||
}
|
||||
|
||||
// ✅ Filter by kandang - use kandang_id directly from item
|
||||
if (kandangFilter && kandangFilter !== 'ALL') {
|
||||
filtered = filtered.filter((item) => item.kandang_id === kandangFilter);
|
||||
}
|
||||
|
||||
// Filter by search text (kandang_name or category)
|
||||
if (searchText) {
|
||||
const searchLower = searchText.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(item) =>
|
||||
item.kandang_name.toLowerCase().includes(searchLower) ||
|
||||
item.category.toLowerCase().includes(searchLower) ||
|
||||
(CATEGORY_LABELS[item.category] || '')
|
||||
.toLowerCase()
|
||||
.includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by date range
|
||||
if (dateFrom) {
|
||||
filtered = filtered.filter((item) => item.date >= dateFrom);
|
||||
}
|
||||
if (dateTo) {
|
||||
filtered = filtered.filter((item) => item.date <= dateTo);
|
||||
}
|
||||
|
||||
setFilteredList(filtered);
|
||||
};
|
||||
|
||||
const handleDetail = (item: ChecklistItem) => {
|
||||
router.push(
|
||||
`/daily-checklist/list-daily-checklist/detail?checklistId=${item.checklist_id}`
|
||||
);
|
||||
};
|
||||
|
||||
const handleApprove = (item: ChecklistItem) => {
|
||||
setSelectedItem(item);
|
||||
setShowApproveModal(true);
|
||||
};
|
||||
|
||||
const handleReject = (item: ChecklistItem) => {
|
||||
setSelectedItem(item);
|
||||
setRejectReason('');
|
||||
setShowRejectModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = (item: ChecklistItem) => {
|
||||
// ✅ VALIDATION: Only DRAFT can be deleted
|
||||
if (item.status !== 'DRAFT') {
|
||||
toast.error('Hanya checklist dengan status DRAFT yang bisa dihapus', {
|
||||
description: `Status saat ini: ${item.status}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedItem(item);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const confirmApprove = async () => {
|
||||
if (!selectedItem || !isSupabaseConfigured()) return;
|
||||
|
||||
try {
|
||||
setActionLoading(true);
|
||||
|
||||
const { error } = await supabase
|
||||
.from('daily_checklists')
|
||||
.update({
|
||||
status: 'APPROVED',
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', selectedItem.checklist_id);
|
||||
|
||||
if (error) {
|
||||
console.error('Error approving checklist:', error);
|
||||
toast.error('Gagal approve checklist');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Checklist berhasil di-approve');
|
||||
setShowApproveModal(false);
|
||||
setSelectedItem(null);
|
||||
await fetchChecklistList();
|
||||
} catch (error) {
|
||||
console.error('Error approving checklist:', error);
|
||||
toast.error('Terjadi kesalahan');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmReject = async () => {
|
||||
if (!selectedItem || !isSupabaseConfigured()) return;
|
||||
|
||||
if (!rejectReason.trim()) {
|
||||
toast.error('Alasan reject harus diisi');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setActionLoading(true);
|
||||
|
||||
const { error } = await supabase
|
||||
.from('daily_checklists')
|
||||
.update({
|
||||
status: 'REJECTED',
|
||||
reject_reason: rejectReason,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', selectedItem.checklist_id);
|
||||
|
||||
if (error) {
|
||||
console.error('Error rejecting checklist:', error);
|
||||
toast.error('Gagal reject checklist');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Checklist berhasil di-reject');
|
||||
setShowRejectModal(false);
|
||||
setSelectedItem(null);
|
||||
setRejectReason('');
|
||||
await fetchChecklistList();
|
||||
} catch (error) {
|
||||
console.error('Error rejecting checklist:', error);
|
||||
toast.error('Terjadi kesalahan');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!selectedItem || !isSupabaseConfigured()) return;
|
||||
|
||||
try {
|
||||
setActionLoading(true);
|
||||
|
||||
const { error } = await supabase
|
||||
.from('daily_checklists')
|
||||
.delete()
|
||||
.eq('id', selectedItem.checklist_id);
|
||||
|
||||
if (error) {
|
||||
console.error('Error deleting checklist:', error);
|
||||
toast.error('Gagal hapus checklist');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Checklist berhasil dihapus');
|
||||
setShowDeleteModal(false);
|
||||
setSelectedItem(null);
|
||||
await fetchChecklistList();
|
||||
} catch (error) {
|
||||
console.error('Error deleting checklist:', error);
|
||||
toast.error('Terjadi kesalahan');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'DRAFT':
|
||||
return (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-gray-300 text-gray-700 bg-white'
|
||||
>
|
||||
Draft
|
||||
</Badge>
|
||||
);
|
||||
case 'SUBMITTED':
|
||||
return (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-orange-300 text-orange-700 bg-white'
|
||||
>
|
||||
Submitted
|
||||
</Badge>
|
||||
);
|
||||
case 'APPROVED':
|
||||
return (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-green-300 text-green-700 bg-white'
|
||||
>
|
||||
Approved
|
||||
</Badge>
|
||||
);
|
||||
case 'REJECTED':
|
||||
return (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-red-300 text-red-700 bg-white'
|
||||
>
|
||||
Rejected
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-gray-300 text-gray-700 bg-white'
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='min-h-screen'>
|
||||
<div className='p-6'>
|
||||
{/* Page Title */}
|
||||
<div className='mb-6'>
|
||||
<h1 className='text-2xl font-semibold text-gray-900'>
|
||||
List Daily Checklist
|
||||
</h1>
|
||||
<p className='text-sm text-gray-600 mt-1'>
|
||||
Daftar semua checklist harian
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Main Card */}
|
||||
<Card className='border-gray-200/60 shadow-sm rounded-xl bg-white'>
|
||||
<CardContent className='p-6'>
|
||||
{/* Filters Section */}
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6 pb-6 border-b border-gray-200'>
|
||||
<div>
|
||||
<Label>Periode Tanggal</Label>
|
||||
<div className='mt-1.5'>
|
||||
<DateRangePicker
|
||||
dateFrom={dateFrom}
|
||||
dateTo={dateTo}
|
||||
onDateChange={(from, to) => {
|
||||
setDateFrom(from);
|
||||
setDateTo(to);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor='kandang-filter'>Kandang</Label>
|
||||
<div className='mt-1.5'>
|
||||
<Select
|
||||
value={kandangFilter}
|
||||
onValueChange={setKandangFilter}
|
||||
>
|
||||
<SelectTrigger
|
||||
id='kandang-filter'
|
||||
className='border-gray-200'
|
||||
>
|
||||
<SelectValue placeholder='Semua Kandang' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='ALL'>Semua Kandang</SelectItem>
|
||||
{kandangList.map((kandang) => (
|
||||
<SelectItem key={kandang.id} value={kandang.id}>
|
||||
{kandang.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor='status-filter'>Status</Label>
|
||||
<div className='mt-1.5'>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger
|
||||
id='status-filter'
|
||||
className='border-gray-200'
|
||||
>
|
||||
<SelectValue placeholder='Semua Status' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor='search-text'>Cari</Label>
|
||||
<div className='relative mt-1.5'>
|
||||
<Input
|
||||
id='search-text'
|
||||
type='text'
|
||||
placeholder='Kandang / Kategori...'
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className='border-gray-200 pl-9'
|
||||
/>
|
||||
<Search className='absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Section */}
|
||||
{loading ? (
|
||||
<div className='text-center py-12 text-gray-500'>
|
||||
Memuat data...
|
||||
</div>
|
||||
) : filteredList.length > 0 ? (
|
||||
<div className='overflow-x-auto'>
|
||||
<table className='w-full border border-gray-200 rounded-lg'>
|
||||
<thead>
|
||||
<tr className='bg-gray-50 border-b border-gray-200'>
|
||||
<th className='text-left py-3 px-4 text-sm font-semibold text-gray-700'>
|
||||
Tanggal
|
||||
</th>
|
||||
<th className='text-left py-3 px-4 text-sm font-semibold text-gray-700'>
|
||||
Kandang
|
||||
</th>
|
||||
<th className='text-left py-3 px-4 text-sm font-semibold text-gray-700'>
|
||||
Kategori
|
||||
</th>
|
||||
<th className='text-left py-3 px-4 text-sm font-semibold text-gray-700'>
|
||||
Status
|
||||
</th>
|
||||
<th className='text-center py-3 px-4 text-sm font-semibold text-gray-700'>
|
||||
Total Phase
|
||||
</th>
|
||||
<th className='text-center py-3 px-4 text-sm font-semibold text-gray-700'>
|
||||
Total Aktivitas
|
||||
</th>
|
||||
<th className='text-center py-3 px-4 text-sm font-semibold text-gray-700'>
|
||||
Progress
|
||||
</th>
|
||||
<th className='text-left py-3 px-4 text-sm font-semibold text-gray-700'>
|
||||
Updated At
|
||||
</th>
|
||||
<th className='text-center py-3 px-4 text-sm font-semibold text-gray-700'>
|
||||
Aksi
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredList.map((item, index) => (
|
||||
<tr
|
||||
key={`${item.checklist_id}-${index}`}
|
||||
className={
|
||||
index % 2 === 0 ? 'bg-white' : 'bg-gray-50/50'
|
||||
}
|
||||
>
|
||||
<td className='py-3 px-4 text-sm text-gray-900'>
|
||||
{formatDate(item.date)}
|
||||
</td>
|
||||
<td className='py-3 px-4 text-sm text-gray-900'>
|
||||
{item.kandang_name}
|
||||
</td>
|
||||
<td className='py-3 px-4 text-sm text-gray-900'>
|
||||
{CATEGORY_LABELS[item.category] || item.category}
|
||||
</td>
|
||||
<td className='py-3 px-4'>
|
||||
{getStatusBadge(item.status)}
|
||||
</td>
|
||||
<td className='py-3 px-4 text-center text-sm text-gray-900'>
|
||||
{item.total_phases}
|
||||
</td>
|
||||
<td className='py-3 px-4 text-center text-sm text-gray-900'>
|
||||
{item.total_activities}
|
||||
</td>
|
||||
<td className='py-3 px-4 text-center'>
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
<div className='w-24 bg-gray-200 rounded-full h-2'>
|
||||
<div
|
||||
className='bg-[#0069e0] h-2 rounded-full transition-all'
|
||||
style={{ width: `${item.progress_percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className='text-sm text-gray-700 font-medium'>
|
||||
{item.progress_percent}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className='py-3 px-4 text-sm text-gray-600'>
|
||||
{formatDateTime(item.updated_at)}
|
||||
</td>
|
||||
<td className='py-3 px-4'>
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => handleDetail(item)}
|
||||
className='border-gray-200 text-gray-700 hover:bg-gray-50'
|
||||
>
|
||||
<Eye className='w-4 h-4 mr-1' />
|
||||
Detail
|
||||
</Button>
|
||||
{item.status === 'SUBMITTED' && (
|
||||
<>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={() => handleApprove(item)}
|
||||
className='bg-green-600 hover:bg-green-700 text-white'
|
||||
>
|
||||
<CheckCircle className='w-4 h-4 mr-1' />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='destructive'
|
||||
onClick={() => handleReject(item)}
|
||||
className='bg-red-600 hover:bg-red-700 text-white'
|
||||
>
|
||||
<XCircle className='w-4 h-4 mr-1' />
|
||||
Reject
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
size='sm'
|
||||
variant='destructive'
|
||||
onClick={() => handleDelete(item)}
|
||||
className='bg-red-600 hover:bg-red-700 text-white'
|
||||
>
|
||||
<Trash2 className='w-4 h-4 mr-1' />
|
||||
Hapus
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-center py-12 text-gray-500'>
|
||||
{searchText ||
|
||||
dateFrom ||
|
||||
dateTo ||
|
||||
statusFilter !== 'ALL' ||
|
||||
kandangFilter !== 'ALL'
|
||||
? 'Tidak ada data yang sesuai dengan filter'
|
||||
: 'Belum ada data checklist'}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Approve Modal */}
|
||||
<Dialog open={showApproveModal} onOpenChange={setShowApproveModal}>
|
||||
<DialogContent className='sm:max-w-md bg-white rounded-xl shadow-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Approve Checklist</DialogTitle>
|
||||
<DialogDescription>
|
||||
Apakah Anda yakin ingin approve checklist ini?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedItem && (
|
||||
<div className='bg-gray-50 rounded-lg p-4 space-y-2'>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<span className='text-gray-600'>Tanggal:</span>
|
||||
<span className='font-medium text-gray-900'>
|
||||
{formatDate(selectedItem.date)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<span className='text-gray-600'>Kandang:</span>
|
||||
<span className='font-medium text-gray-900'>
|
||||
{selectedItem.kandang_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<span className='text-gray-600'>Kategori:</span>
|
||||
<span className='font-medium text-gray-900'>
|
||||
{CATEGORY_LABELS[selectedItem.category] ||
|
||||
selectedItem.category}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<span className='text-gray-600'>Progress:</span>
|
||||
<span className='font-medium text-gray-900'>
|
||||
{selectedItem.progress_percent}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => setShowApproveModal(false)}
|
||||
disabled={actionLoading}
|
||||
className='border-gray-200'
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
onClick={confirmApprove}
|
||||
disabled={actionLoading}
|
||||
className='bg-green-600 hover:bg-green-700 text-white'
|
||||
>
|
||||
{actionLoading ? 'Memproses...' : 'Ya, Approve'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Reject Modal */}
|
||||
<Dialog open={showRejectModal} onOpenChange={setShowRejectModal}>
|
||||
<DialogContent className='sm:max-w-md bg-white rounded-xl shadow-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reject Checklist</DialogTitle>
|
||||
<DialogDescription>
|
||||
Berikan alasan reject untuk checklist ini
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedItem && (
|
||||
<div className='bg-gray-50 rounded-lg p-4 space-y-2 mb-4'>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<span className='text-gray-600'>Tanggal:</span>
|
||||
<span className='font-medium text-gray-900'>
|
||||
{formatDate(selectedItem.date)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<span className='text-gray-600'>Kandang:</span>
|
||||
<span className='font-medium text-gray-900'>
|
||||
{selectedItem.kandang_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<span className='text-gray-600'>Kategori:</span>
|
||||
<span className='font-medium text-gray-900'>
|
||||
{CATEGORY_LABELS[selectedItem.category] ||
|
||||
selectedItem.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor='reject-reason'>
|
||||
Alasan Reject <span className='text-red-500'>*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id='reject-reason'
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
placeholder='Tuliskan alasan reject...'
|
||||
className='mt-1.5 border-gray-200 min-h-[100px]'
|
||||
disabled={actionLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => setShowRejectModal(false)}
|
||||
disabled={actionLoading}
|
||||
className='border-gray-200'
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
onClick={confirmReject}
|
||||
disabled={actionLoading}
|
||||
variant='destructive'
|
||||
className='bg-red-600 hover:bg-red-700 text-white'
|
||||
>
|
||||
{actionLoading ? 'Memproses...' : 'Ya, Reject'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Modal */}
|
||||
<Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
|
||||
<DialogContent className='sm:max-w-md bg-white rounded-xl shadow-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='text-red-600'>
|
||||
⚠️ Hapus Checklist
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Apakah Anda yakin ingin menghapus checklist ini? Data yang dihapus
|
||||
tidak dapat dikembalikan.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedItem && (
|
||||
<>
|
||||
<div className='bg-red-50 border border-red-200 rounded-lg p-4 space-y-2 mb-2'>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<span className='text-gray-600'>Tanggal:</span>
|
||||
<span className='font-medium text-gray-900'>
|
||||
{formatDate(selectedItem.date)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<span className='text-gray-600'>Kandang:</span>
|
||||
<span className='font-medium text-gray-900'>
|
||||
{selectedItem.kandang_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<span className='text-gray-600'>Kategori:</span>
|
||||
<span className='font-medium text-gray-900'>
|
||||
{CATEGORY_LABELS[selectedItem.category] ||
|
||||
selectedItem.category}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<span className='text-gray-600'>Status:</span>
|
||||
{getStatusBadge(selectedItem.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='bg-yellow-50 border border-yellow-200 rounded-lg p-3'>
|
||||
<p className='text-xs text-yellow-800'>
|
||||
<strong>Peringatan:</strong> Semua data terkait (phases,
|
||||
activities, assignments) akan ikut terhapus secara permanen.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => setShowDeleteModal(false)}
|
||||
disabled={actionLoading}
|
||||
className='border-gray-200'
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
onClick={confirmDelete}
|
||||
disabled={actionLoading}
|
||||
variant='destructive'
|
||||
className='bg-red-600 hover:bg-red-700 text-white'
|
||||
>
|
||||
{actionLoading ? 'Memproses...' : 'Ya, Hapus'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1040
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,633 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Download,
|
||||
ChevronDown,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/figma-make/components/base/card';
|
||||
import { Button } from '@/figma-make/components/base/button';
|
||||
import { Label } from '@/figma-make/components/base/label';
|
||||
import { Input } from '@/figma-make/components/base/input';
|
||||
import { Badge } from '@/figma-make/components/base/badge';
|
||||
import { MultiSelect } from '@/figma-make/components/base/multi-select';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/figma-make/components/base/select';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/figma-make/components/base/dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/figma-make/components/base/alert-dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/figma-make/components/base/dropdown-menu';
|
||||
import { toast } from 'sonner';
|
||||
import { supabase, isSupabaseConfigured } from '@/figma-make/lib/supabase';
|
||||
import useSWR from 'swr';
|
||||
import { EmployeeApi } from '@/services/api/daily-checklist/employee';
|
||||
|
||||
interface Employee {
|
||||
id: string;
|
||||
name: string;
|
||||
kandang_id: string;
|
||||
is_active: boolean;
|
||||
kandang?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Kandang {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function MasterEmployeeContent() {
|
||||
const { data: employeesTest, isLoading: isLoadingEmployees } = useSWR(
|
||||
EmployeeApi.basePath,
|
||||
EmployeeApi.getAllFetcher,
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
);
|
||||
|
||||
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||
const [kandangList, setKandangList] = useState<Kandang[]>([]);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [employeeToDelete, setEmployeeToDelete] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [kandangFilter, setKandangFilter] = useState<string>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [modalMode, setModalMode] = useState<'create' | 'edit'>('create');
|
||||
const [employeeForm, setEmployeeForm] = useState({
|
||||
id: '',
|
||||
name: '',
|
||||
kandang_ids: [] as string[],
|
||||
status: 'Active' as 'Active' | 'Non Active',
|
||||
});
|
||||
|
||||
const fetchEmployees = async () => {
|
||||
if (!isSupabaseConfigured()) {
|
||||
console.warn(
|
||||
'Supabase not configured. Please add environment variables.'
|
||||
);
|
||||
setInitialLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('employees')
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
name,
|
||||
kandang_id,
|
||||
is_active,
|
||||
kandang:kandang_id (
|
||||
id,
|
||||
name
|
||||
)
|
||||
`
|
||||
)
|
||||
.order('name', { ascending: true });
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching employees:', error);
|
||||
toast.error('Gagal memuat data ABK');
|
||||
return;
|
||||
}
|
||||
|
||||
setEmployees((data as unknown as Employee[]) || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching employees:', error);
|
||||
toast.error('Gagal memuat data ABK');
|
||||
} finally {
|
||||
setInitialLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchKandang = async () => {
|
||||
if (!isSupabaseConfigured()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('kandang')
|
||||
.select('id, name')
|
||||
.order('name', { ascending: true });
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching kandang:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
setKandangList(data || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching kandang:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchEmployees();
|
||||
fetchKandang();
|
||||
}, []);
|
||||
|
||||
const handleAdd = () => {
|
||||
setModalMode('create');
|
||||
setEmployeeForm({ id: '', name: '', kandang_ids: [], status: 'Active' });
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleEdit = (employee: Employee) => {
|
||||
setModalMode('edit');
|
||||
setEmployeeForm({
|
||||
id: employee.id,
|
||||
name: employee.name,
|
||||
kandang_ids: employee.kandang_id ? [employee.kandang_id] : [],
|
||||
status: employee.is_active ? 'Active' : 'Non Active',
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!employeeForm.name.trim() || employeeForm.kandang_ids.length === 0) {
|
||||
toast.error('Nama ABK dan minimal satu Kandang harus diisi');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSupabaseConfigured()) {
|
||||
toast.error(
|
||||
'Supabase belum dikonfigurasi. Tambahkan environment variables.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Create payload - taking the first selected kandang for now as schema supports single FK
|
||||
// TODO: Support multiple kandangs in backend
|
||||
const kandangIdToSave = employeeForm.kandang_ids[0];
|
||||
|
||||
if (modalMode === 'create') {
|
||||
const { error } = await supabase.from('employees').insert([
|
||||
{
|
||||
name: employeeForm.name.trim(),
|
||||
kandang_id: kandangIdToSave,
|
||||
is_active: employeeForm.status === 'Active',
|
||||
},
|
||||
]);
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating employee:', error);
|
||||
toast.error('Gagal menambahkan ABK');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('ABK berhasil ditambahkan');
|
||||
} else {
|
||||
const { error } = await supabase
|
||||
.from('employees')
|
||||
.update({
|
||||
name: employeeForm.name.trim(),
|
||||
kandang_id: kandangIdToSave,
|
||||
is_active: employeeForm.status === 'Active',
|
||||
})
|
||||
.eq('id', employeeForm.id);
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating employee:', error);
|
||||
toast.error('Gagal mengubah ABK');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('ABK berhasil diubah');
|
||||
}
|
||||
|
||||
setShowModal(false);
|
||||
setEmployeeForm({ id: '', name: '', kandang_ids: [], status: 'Active' });
|
||||
await fetchEmployees();
|
||||
} catch (error) {
|
||||
console.error('Error saving employee:', error);
|
||||
toast.error('Terjadi kesalahan saat menyimpan ABK');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = (employeeId: string) => {
|
||||
setEmployeeToDelete(employeeId);
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!employeeToDelete) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('employees')
|
||||
.delete()
|
||||
.eq('id', employeeToDelete);
|
||||
|
||||
if (error) {
|
||||
console.error('Error deleting employee:', error);
|
||||
toast.error('Gagal menghapus ABK');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('ABK berhasil dihapus');
|
||||
setShowDeleteConfirm(false);
|
||||
setEmployeeToDelete(null);
|
||||
await fetchEmployees();
|
||||
} catch (error) {
|
||||
console.error('Error deleting employee:', error);
|
||||
toast.error('Terjadi kesalahan saat menghapus ABK');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = (format: string) => {
|
||||
toast.success(`Data berhasil diekspor ke ${format}`);
|
||||
};
|
||||
|
||||
const filteredEmployees = employees.filter((emp) => {
|
||||
const kandangName = emp.kandang?.name || '';
|
||||
const matchesSearch =
|
||||
emp.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
kandangName.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesKandang =
|
||||
kandangFilter === 'all' || emp.kandang_id === kandangFilter;
|
||||
|
||||
const matchesStatus =
|
||||
statusFilter === 'all' || emp.is_active === (statusFilter === 'active');
|
||||
|
||||
return matchesSearch && matchesKandang && matchesStatus;
|
||||
});
|
||||
|
||||
if (initialLoading) {
|
||||
return (
|
||||
<div className='min-h-screen'>
|
||||
<div className='p-6'>
|
||||
<div className='mb-6'>
|
||||
<h1 className='text-2xl font-semibold text-gray-900'>
|
||||
Master Employee (ABK)
|
||||
</h1>
|
||||
<p className='text-sm text-gray-600 mt-1'>
|
||||
Master Data •{' '}
|
||||
<span className='text-[#0069e0]'>Employee (ABK)</span>
|
||||
</p>
|
||||
</div>
|
||||
<Card className='border-gray-200/60 shadow-sm rounded-xl bg-white'>
|
||||
<CardContent className='p-12 text-center text-gray-500'>
|
||||
Memuat data...
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='min-h-screen'>
|
||||
<div className='p-6'>
|
||||
{/* Page Title */}
|
||||
<div className='mb-6'>
|
||||
<h1 className='text-2xl font-semibold text-gray-900'>
|
||||
Master Employee (ABK)
|
||||
</h1>
|
||||
<p className='text-sm text-gray-600 mt-1'>
|
||||
Master Data • <span className='text-[#0069e0]'>Employee (ABK)</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Main Card */}
|
||||
<Card className='border-gray-200/60 shadow-sm rounded-xl bg-white'>
|
||||
<CardContent className='p-0'>
|
||||
{/* Single Toolbar Row */}
|
||||
<div className='flex items-center justify-between gap-4 p-6 border-b border-gray-200/60'>
|
||||
{/* LEFT: Search + Filters */}
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='relative'>
|
||||
<Search className='absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4' />
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='Cari nama ABK atau kandang...'
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className='pl-10 w-[280px] border-gray-200'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={kandangFilter} onValueChange={setKandangFilter}>
|
||||
<SelectTrigger className='w-[180px] border-gray-200'>
|
||||
<SelectValue placeholder='Semua Kandang' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='all'>Semua Kandang</SelectItem>
|
||||
{kandangList.map((kandang) => (
|
||||
<SelectItem key={kandang.id} value={kandang.id}>
|
||||
{kandang.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className='w-[160px] border-gray-200'>
|
||||
<SelectValue placeholder='Semua Status' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='all'>Semua Status</SelectItem>
|
||||
<SelectItem value='active'>Active</SelectItem>
|
||||
<SelectItem value='non_active'>Non Active</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* RIGHT: Export + Add */}
|
||||
<div className='flex items-center gap-2'>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='border-gray-200 text-gray-700'
|
||||
>
|
||||
<Download className='w-4 h-4 mr-2' />
|
||||
Export
|
||||
<ChevronDown className='w-4 h-4 ml-2' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem onClick={() => handleExport('CSV')}>
|
||||
Export CSV
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleExport('Excel')}>
|
||||
Export Excel
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
className='bg-[#0069e0] hover:bg-[#0052b3] text-white'
|
||||
>
|
||||
<Plus className='w-4 h-4 mr-2' />
|
||||
Tambah ABK
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className='overflow-x-auto'>
|
||||
<table className='w-full'>
|
||||
<thead>
|
||||
<tr className='border-b border-gray-200/60 bg-gray-50/50'>
|
||||
<th className='text-left py-3.5 px-6 text-sm font-semibold text-gray-700'>
|
||||
Nama ABK
|
||||
</th>
|
||||
<th className='text-left py-3.5 px-6 text-sm font-semibold text-gray-700'>
|
||||
Kandang
|
||||
</th>
|
||||
<th className='text-left py-3.5 px-6 text-sm font-semibold text-gray-700'>
|
||||
Status
|
||||
</th>
|
||||
<th className='text-center py-3.5 px-6 text-sm font-semibold text-gray-700 w-[80px]'>
|
||||
Aksi
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className='divide-y divide-gray-200/60'>
|
||||
{filteredEmployees.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={4}
|
||||
className='text-center py-12 text-gray-500'
|
||||
>
|
||||
{searchQuery ||
|
||||
kandangFilter !== 'all' ||
|
||||
statusFilter !== 'all'
|
||||
? 'Tidak ada ABK yang ditemukan'
|
||||
: 'Belum ada data ABK'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredEmployees.map((employee) => (
|
||||
<tr
|
||||
key={employee.id}
|
||||
className='hover:bg-blue-50/30 transition-colors'
|
||||
>
|
||||
<td className='py-3.5 px-6 text-sm text-gray-900'>
|
||||
{employee.name}
|
||||
</td>
|
||||
<td className='py-3.5 px-6 text-sm text-gray-700'>
|
||||
{employee.kandang?.name || '-'}
|
||||
</td>
|
||||
<td className='py-3.5 px-6 text-sm'>
|
||||
<Badge
|
||||
variant={
|
||||
employee.is_active ? 'success' : 'secondary'
|
||||
}
|
||||
>
|
||||
{employee.is_active ? 'Active' : 'Non Active'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className='py-3.5 px-6 text-center'>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-8 w-8 p-0 hover:bg-gray-100'
|
||||
>
|
||||
<MoreVertical className='h-4 w-4 text-gray-600' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleEdit(employee)}
|
||||
>
|
||||
<Pencil className='mr-2 h-4 w-4' />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteClick(employee.id)}
|
||||
className='text-red-600'
|
||||
>
|
||||
<Trash2 className='mr-2 h-4 w-4' />
|
||||
Hapus
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
<Dialog open={showModal} onOpenChange={setShowModal}>
|
||||
<DialogContent className='sm:max-w-md bg-white rounded-xl shadow-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{modalMode === 'create' ? 'Tambah ABK' : 'Edit ABK'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{modalMode === 'create'
|
||||
? 'Masukkan detail ABK baru'
|
||||
: 'Ubah detail ABK'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='space-y-4 py-4'>
|
||||
<div>
|
||||
<Label htmlFor='nama-abk'>
|
||||
Nama ABK <span className='text-red-500'>*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id='nama-abk'
|
||||
value={employeeForm.name}
|
||||
onChange={(e) =>
|
||||
setEmployeeForm({ ...employeeForm, name: e.target.value })
|
||||
}
|
||||
placeholder='Masukkan nama ABK'
|
||||
className='mt-1.5'
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor='kandang'>
|
||||
Kandang <span className='text-red-500'>*</span>
|
||||
</Label>
|
||||
<MultiSelect
|
||||
options={kandangList.map((k) => ({
|
||||
value: k.id,
|
||||
label: k.name,
|
||||
}))}
|
||||
selected={employeeForm.kandang_ids}
|
||||
onChange={(selected) =>
|
||||
setEmployeeForm({ ...employeeForm, kandang_ids: selected })
|
||||
}
|
||||
// onSearchChange={(val) =>
|
||||
// console.log({
|
||||
// test: val,
|
||||
// })
|
||||
// }
|
||||
placeholder='Pilih kandang'
|
||||
className='mt-1.5'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor='status'>
|
||||
Status <span className='text-red-500'>*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={employeeForm.status}
|
||||
onValueChange={(value: 'Active' | 'Non Active') =>
|
||||
setEmployeeForm({ ...employeeForm, status: value })
|
||||
}
|
||||
disabled={loading}
|
||||
>
|
||||
<SelectTrigger id='status' className='mt-1.5'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='Active'>
|
||||
<div className='flex items-center'>
|
||||
<Badge variant='success' className='mr-2'>
|
||||
Active
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value='Non Active'>
|
||||
<div className='flex items-center'>
|
||||
<Badge variant='secondary' className='mr-2'>
|
||||
Non Active
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => setShowModal(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
className='bg-[#0069e0] hover:bg-[#0052b3] text-white'
|
||||
>
|
||||
{loading ? 'Menyimpan...' : 'Simpan'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<AlertDialogContent className='bg-white rounded-xl shadow-lg sm:max-w-md'>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Hapus ABK?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Data ABK akan dihapus secara permanen.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>Batal</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={loading}
|
||||
className='bg-red-600 hover:bg-red-700 text-white'
|
||||
>
|
||||
{loading ? 'Menghapus...' : 'Hapus'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Eye, Download, Search } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/figma-make/components/base/card';
|
||||
import { Button } from '@/figma-make/components/base/button';
|
||||
import { Badge } from '@/figma-make/components/base/badge';
|
||||
import { Input } from '@/figma-make/components/base/input';
|
||||
import { Label } from '@/figma-make/components/base/label';
|
||||
import { DateRangePicker } from '@/figma-make/components/base/date-range-picker';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/figma-make/components/base/select';
|
||||
import { toast } from 'sonner';
|
||||
import { supabase, isSupabaseConfigured } from '@/figma-make/lib/supabase';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface SubmissionReportItem {
|
||||
checklist_id: string;
|
||||
date: string;
|
||||
kandang_id: string;
|
||||
kandang_name: string;
|
||||
category: string;
|
||||
status: string;
|
||||
progress_percent: number;
|
||||
total_phases: number;
|
||||
total_activities: number;
|
||||
total_employees: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface Kandang {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ReportQueryResult {
|
||||
id: string;
|
||||
date: string;
|
||||
kandang_id: string;
|
||||
category: string;
|
||||
status: string;
|
||||
updated_at: string;
|
||||
kandang: {
|
||||
id: string;
|
||||
name: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'ALL', label: 'Semua Status' },
|
||||
{ value: 'DRAFT', label: 'Draft' },
|
||||
{ value: 'SUBMITTED', label: 'Submitted' },
|
||||
{ value: 'APPROVED', label: 'Approved' },
|
||||
{ value: 'REJECTED', label: 'Rejected' },
|
||||
];
|
||||
|
||||
const CATEGORY_LABELS: { [key: string]: string } = {
|
||||
pullet_open: 'Pullet Open',
|
||||
pullet_close: 'Pullet Close',
|
||||
produksi_open: 'Produksi Open',
|
||||
produksi_close: 'Produksi Close',
|
||||
};
|
||||
|
||||
export function DailyChecklistReportsContent() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Report State
|
||||
const [reportList, setReportList] = useState<SubmissionReportItem[]>([]);
|
||||
const [filteredReportList, setFilteredReportList] = useState<
|
||||
SubmissionReportItem[]
|
||||
>([]);
|
||||
|
||||
// Master data
|
||||
const [kandangList, setKandangList] = useState<Kandang[]>([]);
|
||||
|
||||
// Filters
|
||||
const [statusFilter, setStatusFilter] = useState('ALL');
|
||||
const [kandangFilter, setKandangFilter] = useState('ALL');
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchKandangList();
|
||||
fetchReports();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
applyFilters();
|
||||
}, [reportList, statusFilter, kandangFilter, searchText, dateFrom, dateTo]);
|
||||
|
||||
const fetchKandangList = async () => {
|
||||
if (!isSupabaseConfigured()) return;
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('kandang')
|
||||
.select('id, name')
|
||||
.order('name', { ascending: true });
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching kandang:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
setKandangList(data || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching kandang:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchReports = async () => {
|
||||
if (!isSupabaseConfigured()) {
|
||||
console.warn('Supabase not configured');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch checklists directly from daily_checklists table
|
||||
const { data: checklists, error } = await supabase
|
||||
.from('daily_checklists')
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
date,
|
||||
kandang_id,
|
||||
category,
|
||||
status,
|
||||
updated_at,
|
||||
kandang:kandang_id (
|
||||
id,
|
||||
name
|
||||
)
|
||||
`
|
||||
)
|
||||
.order('date', { ascending: false })
|
||||
.order('updated_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching reports:', error);
|
||||
toast.error('Gagal memuat data reports');
|
||||
return;
|
||||
}
|
||||
|
||||
// Enrich data with calculations
|
||||
const enrichedData = await Promise.all(
|
||||
((checklists as unknown as ReportQueryResult[]) || [])
|
||||
.filter((checklist) => checklist.id)
|
||||
.map(async (checklist) => {
|
||||
// Count phases
|
||||
const { count: phaseCount } = await supabase
|
||||
.from('daily_checklist_phases')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('checklist_id', checklist.id);
|
||||
|
||||
// Count activities (tasks)
|
||||
const { count: activityCount } = await supabase
|
||||
.from('daily_checklist_activity_tasks')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('checklist_id', checklist.id);
|
||||
|
||||
// Count unique employees
|
||||
const { data: tasks } = await supabase
|
||||
.from('daily_checklist_activity_tasks')
|
||||
.select('id')
|
||||
.eq('checklist_id', checklist.id);
|
||||
|
||||
const taskIds = (tasks || []).map((t) => t.id);
|
||||
let uniqueEmployees = new Set<string>();
|
||||
|
||||
if (taskIds.length > 0) {
|
||||
const { data: assignments } = await supabase
|
||||
.from('daily_checklist_activity_task_assignments')
|
||||
.select('employee_id')
|
||||
.in('task_id', taskIds);
|
||||
|
||||
uniqueEmployees = new Set(
|
||||
(assignments || []).map((a) => a.employee_id)
|
||||
);
|
||||
}
|
||||
|
||||
// ✅ Calculate progress based on phase coverage
|
||||
const { count: totalPhasesInMaster } = await supabase
|
||||
.from('phases')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('category_id', checklist.category);
|
||||
|
||||
const { data: checklistTasks } = await supabase
|
||||
.from('daily_checklist_activity_tasks')
|
||||
.select('id, phase_id')
|
||||
.eq('checklist_id', checklist.id);
|
||||
|
||||
const checklistTaskIds = (checklistTasks || []).map((t) => t.id);
|
||||
const uniquePhasesWithChecked = new Set<string>();
|
||||
|
||||
if (checklistTaskIds.length > 0) {
|
||||
const { data: checkedAssignments } = await supabase
|
||||
.from('daily_checklist_activity_task_assignments')
|
||||
.select('task_id')
|
||||
.in('task_id', checklistTaskIds)
|
||||
.eq('checked', true);
|
||||
|
||||
if (checkedAssignments && checkedAssignments.length > 0) {
|
||||
const checkedTaskIds = new Set(
|
||||
checkedAssignments.map((a) => a.task_id)
|
||||
);
|
||||
checklistTasks?.forEach((task) => {
|
||||
if (checkedTaskIds.has(task.id)) {
|
||||
uniquePhasesWithChecked.add(task.phase_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const phasesWithCheckedCount = uniquePhasesWithChecked.size;
|
||||
const progressPercent =
|
||||
totalPhasesInMaster && totalPhasesInMaster > 0
|
||||
? Math.round(
|
||||
(phasesWithCheckedCount / totalPhasesInMaster) * 100
|
||||
)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
checklist_id: checklist.id,
|
||||
date: checklist.date,
|
||||
kandang_id: checklist.kandang_id,
|
||||
kandang_name: checklist.kandang?.name || '-',
|
||||
category: checklist.category,
|
||||
status: checklist.status,
|
||||
progress_percent: progressPercent,
|
||||
total_phases: phaseCount || 0,
|
||||
total_activities: activityCount || 0,
|
||||
total_employees: uniqueEmployees.size,
|
||||
updated_at: checklist.updated_at,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
setReportList(enrichedData);
|
||||
} catch (error) {
|
||||
console.error('Error fetching reports:', error);
|
||||
toast.error('Terjadi kesalahan');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyFilters = () => {
|
||||
let filtered = [...reportList];
|
||||
|
||||
if (statusFilter && statusFilter !== 'ALL') {
|
||||
filtered = filtered.filter((item) => item.status === statusFilter);
|
||||
}
|
||||
|
||||
if (kandangFilter && kandangFilter !== 'ALL') {
|
||||
filtered = filtered.filter((item) => item.kandang_id === kandangFilter);
|
||||
}
|
||||
|
||||
if (searchText) {
|
||||
filtered = filtered.filter(
|
||||
(item) =>
|
||||
item.kandang_name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
item.category.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
(CATEGORY_LABELS[item.category] || '')
|
||||
.toLowerCase()
|
||||
.includes(searchText.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (dateFrom) {
|
||||
filtered = filtered.filter(
|
||||
(item) => new Date(item.date) >= new Date(dateFrom)
|
||||
);
|
||||
}
|
||||
if (dateTo) {
|
||||
filtered = filtered.filter(
|
||||
(item) => new Date(item.date) <= new Date(dateTo)
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredReportList(filtered);
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'DRAFT':
|
||||
return (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-gray-300 text-gray-700 bg-white'
|
||||
>
|
||||
Draft
|
||||
</Badge>
|
||||
);
|
||||
case 'SUBMITTED':
|
||||
return (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-orange-300 text-orange-700 bg-white'
|
||||
>
|
||||
Submitted
|
||||
</Badge>
|
||||
);
|
||||
case 'APPROVED':
|
||||
return (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-green-300 text-green-700 bg-white'
|
||||
>
|
||||
Approved
|
||||
</Badge>
|
||||
);
|
||||
case 'REJECTED':
|
||||
return (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-red-300 text-red-700 bg-white'
|
||||
>
|
||||
Rejected
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-gray-300 text-gray-700 bg-white'
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const handleViewDetail = (checklistId: string) => {
|
||||
// Navigate to detail page (same as List Daily Checklist)
|
||||
router.push(`/list-daily-checklist/detail?checklistId=${checklistId}`);
|
||||
};
|
||||
|
||||
const exportToCSV = () => {
|
||||
toast.info('Export CSV akan segera tersedia');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='min-h-screen'>
|
||||
<div className='p-6'>
|
||||
{/* Page Title */}
|
||||
<div className='mb-6 flex items-center justify-between'>
|
||||
<div>
|
||||
<h1 className='text-2xl font-semibold text-gray-900'>Reports</h1>
|
||||
<p className='text-sm text-gray-600 mt-1'>
|
||||
Laporan lengkap checklist harian
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={exportToCSV}
|
||||
className='bg-[#0069e0] hover:bg-[#0058c0] text-white'
|
||||
>
|
||||
<Download className='w-4 h-4 mr-2' />
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Main Card */}
|
||||
<Card className='border-gray-200/60 shadow-sm rounded-xl bg-white'>
|
||||
<CardContent className='p-6'>
|
||||
{/* Filters Section */}
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6 pb-6 border-b border-gray-200'>
|
||||
<div>
|
||||
<Label>Periode Tanggal</Label>
|
||||
<div className='mt-1.5'>
|
||||
<DateRangePicker
|
||||
dateFrom={dateFrom}
|
||||
dateTo={dateTo}
|
||||
onDateChange={(from, to) => {
|
||||
setDateFrom(from);
|
||||
setDateTo(to);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor='kandang-filter-report'>Kandang</Label>
|
||||
<Select value={kandangFilter} onValueChange={setKandangFilter}>
|
||||
<SelectTrigger
|
||||
id='kandang-filter-report'
|
||||
className='mt-1.5 border-gray-200'
|
||||
>
|
||||
<SelectValue placeholder='Semua Kandang' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='ALL'>Semua Kandang</SelectItem>
|
||||
{kandangList.map((kandang) => (
|
||||
<SelectItem key={kandang.id} value={kandang.id}>
|
||||
{kandang.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor='status-filter-report'>Status</Label>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger
|
||||
id='status-filter-report'
|
||||
className='mt-1.5 border-gray-200'
|
||||
>
|
||||
<SelectValue placeholder='Semua Status' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor='search-text-report'>Cari</Label>
|
||||
<div className='relative mt-1.5'>
|
||||
<Input
|
||||
id='search-text-report'
|
||||
type='text'
|
||||
placeholder='Kandang / Kategori...'
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className='border-gray-200 pl-9'
|
||||
/>
|
||||
<Search className='absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reports Table */}
|
||||
{loading ? (
|
||||
<div className='text-center py-12 text-gray-500'>
|
||||
Memuat data...
|
||||
</div>
|
||||
) : filteredReportList.length > 0 ? (
|
||||
<div className='overflow-x-auto'>
|
||||
<table className='w-full border border-gray-200 rounded-lg'>
|
||||
<thead>
|
||||
<tr className='bg-gray-50 border-b border-gray-200'>
|
||||
<th className='text-left py-3 px-4 text-sm font-semibold text-gray-700'>
|
||||
Tanggal
|
||||
</th>
|
||||
<th className='text-left py-3 px-4 text-sm font-semibold text-gray-700'>
|
||||
Kandang
|
||||
</th>
|
||||
<th className='text-left py-3 px-4 text-sm font-semibold text-gray-700'>
|
||||
Kategori
|
||||
</th>
|
||||
<th className='text-left py-3 px-4 text-sm font-semibold text-gray-700'>
|
||||
Status
|
||||
</th>
|
||||
<th className='text-center py-3 px-4 text-sm font-semibold text-gray-700'>
|
||||
Phase
|
||||
</th>
|
||||
<th className='text-center py-3 px-4 text-sm font-semibold text-gray-700'>
|
||||
Aktivitas
|
||||
</th>
|
||||
<th className='text-center py-3 px-4 text-sm font-semibold text-gray-700'>
|
||||
ABK
|
||||
</th>
|
||||
<th className='text-center py-3 px-4 text-sm font-semibold text-gray-700'>
|
||||
Progress
|
||||
</th>
|
||||
<th className='text-left py-3 px-4 text-sm font-semibold text-gray-700'>
|
||||
Updated At
|
||||
</th>
|
||||
<th className='text-center py-3 px-4 text-sm font-semibold text-gray-700'>
|
||||
Aksi
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredReportList.map((item, index) => (
|
||||
<tr
|
||||
key={`${item.checklist_id}-${item.date}-${index}`}
|
||||
className={
|
||||
index % 2 === 0 ? 'bg-white' : 'bg-gray-50/50'
|
||||
}
|
||||
>
|
||||
<td className='py-3 px-4 text-sm text-gray-900'>
|
||||
{formatDate(item.date)}
|
||||
</td>
|
||||
<td className='py-3 px-4 text-sm text-gray-900'>
|
||||
{item.kandang_name}
|
||||
</td>
|
||||
<td className='py-3 px-4 text-sm text-gray-900'>
|
||||
{CATEGORY_LABELS[item.category] || item.category}
|
||||
</td>
|
||||
<td className='py-3 px-4'>
|
||||
{getStatusBadge(item.status)}
|
||||
</td>
|
||||
<td className='py-3 px-4 text-center text-sm text-gray-900'>
|
||||
{item.total_phases}
|
||||
</td>
|
||||
<td className='py-3 px-4 text-center text-sm text-gray-900'>
|
||||
{item.total_activities}
|
||||
</td>
|
||||
<td className='py-3 px-4 text-center text-sm text-gray-900'>
|
||||
{item.total_employees}
|
||||
</td>
|
||||
<td className='py-3 px-4 text-center'>
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
<div className='w-20 bg-gray-200 rounded-full h-2'>
|
||||
<div
|
||||
className='bg-[#0069e0] h-2 rounded-full transition-all'
|
||||
style={{ width: `${item.progress_percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className='text-sm text-gray-700 font-medium'>
|
||||
{item.progress_percent}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className='py-3 px-4 text-sm text-gray-600'>
|
||||
{formatDateTime(item.updated_at)}
|
||||
</td>
|
||||
<td className='py-3 px-4'>
|
||||
<div className='flex items-center justify-center'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() =>
|
||||
handleViewDetail(item.checklist_id)
|
||||
}
|
||||
className='border-gray-200 text-gray-700 hover:bg-gray-50'
|
||||
>
|
||||
<Eye className='w-4 h-4 mr-1' />
|
||||
Detail
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-center py-12 text-gray-500'>
|
||||
{searchText ||
|
||||
dateFrom ||
|
||||
dateTo ||
|
||||
statusFilter !== 'ALL' ||
|
||||
kandangFilter !== 'ALL'
|
||||
? 'Tidak ada data yang sesuai dengan filter'
|
||||
: 'Belum ada data checklist'}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user