mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
435 lines
17 KiB
TypeScript
435 lines
17 KiB
TypeScript
'use client';
|
|
|
|
import { useState } 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 { Badge } from '@/figma-make/components/base/badge';
|
|
import { Users, AlertCircle, Info, Loader2 } 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 { toast } from 'sonner';
|
|
import useSWR from 'swr';
|
|
import { BaseApiResponse } from '@/types/api/api-general';
|
|
import { DailyChecklistSummary } from '@/types/api/daily-checklist/daily-checklist';
|
|
import { AxiosError } from 'axios';
|
|
import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
|
|
import { DailyChecklistApi } from '@/services/api/daily-checklist/daily-checklist';
|
|
import { useSelect } from '@/components/input/SelectInput';
|
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
|
import { formatDate } from '@/lib/helper';
|
|
import { DailyChecklistKandangApi } from '@/services/api/daily-checklist/kandang';
|
|
|
|
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() {
|
|
// Filters
|
|
const [dateFrom, setDateFrom] = useState('');
|
|
const [dateTo, setDateTo] = useState('');
|
|
const [kandangFilter, setKandangFilter] = useState('ALL');
|
|
const [categoryFilter, setCategoryFilter] = useState('ALL');
|
|
|
|
const { data: summaryResponse, isLoading: isLoadingSummary } = useSWR<
|
|
BaseApiResponse<DailyChecklistSummary | undefined>,
|
|
AxiosError<BaseApiResponse>,
|
|
SWRHttpKey
|
|
>(
|
|
dateFrom && dateTo
|
|
? `${DailyChecklistApi.basePath}/summary?date_from=${dateFrom}&date_to=${dateTo}&kandang_id=${kandangFilter === 'ALL' ? '' : kandangFilter}&category=${categoryFilter === 'ALL' ? '' : categoryFilter}`
|
|
: '',
|
|
httpClientFetcher
|
|
);
|
|
|
|
const {
|
|
options: kandangOptions,
|
|
loadMore: loadMoreKandang,
|
|
isLoadingMore: isLoadingMoreKandang,
|
|
} = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name');
|
|
|
|
const handleKandangScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
|
const target = e.target as HTMLDivElement;
|
|
if (target.scrollHeight - target.scrollTop <= target.clientHeight + 10) {
|
|
if (!isLoadingMoreKandang) {
|
|
loadMoreKandang();
|
|
}
|
|
}
|
|
};
|
|
|
|
const kandangColorMap: { [key: string]: string } = {};
|
|
(kandangOptions || []).forEach((k, index) => {
|
|
kandangColorMap[k.value] = KANDANG_COLORS[index % KANDANG_COLORS.length];
|
|
});
|
|
|
|
const employeePerformance = isResponseSuccess(summaryResponse)
|
|
? summaryResponse.data?.tracking_abk.map((abk) => {
|
|
return {
|
|
...abk,
|
|
color: kandangColorMap[abk.kandang_id] || '#0069e0',
|
|
};
|
|
})
|
|
: [];
|
|
|
|
const chartData = employeePerformance?.map((emp) => ({
|
|
name: emp.employee_name,
|
|
completed: emp.activity_done,
|
|
remaining: emp.activity_left,
|
|
total: emp.total_activity,
|
|
color: emp.color,
|
|
kandang: emp.kandang_name,
|
|
}));
|
|
|
|
const hasFilters = dateFrom && dateTo;
|
|
|
|
if (summaryResponse && isResponseError(summaryResponse)) {
|
|
toast.error('Gagal memuat data: ' + summaryResponse.message);
|
|
}
|
|
|
|
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 onScroll={handleKandangScroll}>
|
|
<SelectItem value='ALL'>Semua Kandang</SelectItem>
|
|
{kandangOptions.map((kandang) => (
|
|
<SelectItem
|
|
key={kandang.value}
|
|
value={String(kandang.value)}
|
|
>
|
|
{kandang.label}
|
|
</SelectItem>
|
|
))}
|
|
{isLoadingMoreKandang && (
|
|
<div className='flex justify-center p-2'>
|
|
<Loader2 className='h-4 w-4 animate-spin text-gray-500' />
|
|
</div>
|
|
)}
|
|
</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>
|
|
{Object.keys(CATEGORY_LABELS).map((category) => (
|
|
<SelectItem key={category} value={category}>
|
|
{CATEGORY_LABELS[category]}
|
|
</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>
|
|
) : isLoadingSummary ? (
|
|
<div className='text-center py-16 text-gray-500'>
|
|
Memuat data...
|
|
</div>
|
|
) : employeePerformance && 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='#878c96'
|
|
radius={[4, 4, 0, 0]}
|
|
>
|
|
{chartData?.map((entry, index) => (
|
|
<Cell
|
|
key={`cell-remaining-${index}`}
|
|
fill={`${entry.color}70`}
|
|
opacity={0.7}
|
|
/>
|
|
))}
|
|
</Bar>
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Employee Tracking Table */}
|
|
{hasFilters &&
|
|
employeePerformance &&
|
|
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={index}
|
|
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_activity}
|
|
</td>
|
|
<td className='py-3 px-4 text-center text-sm font-semibold text-green-700'>
|
|
{emp.activity_done}
|
|
</td>
|
|
<td className='py-3 px-4 text-center text-sm text-gray-600'>
|
|
{emp.activity_left}
|
|
</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, 'DD MMM YYYY')}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|