Files
dashboard-cpsp/components/CycleTimeline.tsx
T

175 lines
6.5 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import { useFarm } from '../context/FarmContext.tsx';
import { formatDateId, formatDateRange, resolveCycleStatus } from '../utils/format.ts';
import type { Cycle } from '../types/api.ts';
type Phase = { name: string; startDay: number; endDay: number; color: string };
function phasesFor(totalDays: number): Phase[] {
const starterEnd = Math.min(10, totalDays);
const growerEnd = Math.min(24, totalDays);
const phases: Phase[] = [
{ name: 'Starter', startDay: 1, endDay: starterEnd, color: 'bg-red-400' },
];
if (growerEnd > starterEnd) {
phases.push({
name: 'Grower',
startDay: starterEnd + 1,
endDay: growerEnd,
color: 'bg-orange-400',
});
}
if (totalDays > growerEnd) {
phases.push({
name: 'Finisher',
startDay: growerEnd + 1,
endDay: totalDays,
color: 'bg-yellow-500',
});
}
return phases;
}
const statusConfig = {
active: { label: 'Active', bg: 'bg-red-100', text: 'text-red-800', icon: 'fa-running' },
closed: {
label: 'Completed',
bg: 'bg-green-100',
text: 'text-green-800',
icon: 'fa-check-circle',
},
};
const SingleCycleView: React.FC<{ cycle: Cycle }> = ({ cycle }) => {
const phases = phasesFor(cycle.total_days);
const currentDay = Math.min(Math.max(cycle.current_day, 0), cycle.total_days);
const cycleStatus = resolveCycleStatus(cycle);
const progress = cycle.total_days > 0 ? (currentDay / cycle.total_days) * 100 : 0;
return (
<div className="pt-4">
<div className="flex flex-col sm:flex-row justify-between sm:items-center mb-4 gap-4">
<p className="text-sm text-gray-500">
Siklus:{' '}
<span className="font-semibold">{formatDateRange(cycle.start_date, cycle.end_date)}</span>
</p>
<div className="text-sm sm:text-right">
<p className="font-semibold text-gray-700">Estimasi Panen</p>
<p className="text-red-600 font-bold">{formatDateId(cycle.end_date)}</p>
</div>
</div>
<div className="relative pt-8">
<div className="relative h-8 bg-gray-200 rounded-lg flex items-center shadow-inner overflow-hidden">
{phases.map((phase) => {
const width = ((phase.endDay - phase.startDay + 1) / cycle.total_days) * 100;
return (
<div
key={phase.name}
className={`h-full ${phase.color} flex items-center justify-center text-white text-sm font-bold`}
style={{ width: `${width}%` }}
title={`${phase.name} (Hari ${phase.startDay}-${phase.endDay})`}
>
<span className="hidden sm:inline opacity-90">{phase.name}</span>
</div>
);
})}
</div>
{cycleStatus === 'active' && (
<div
className="absolute top-0 transition-all duration-500 ease-out"
style={{ left: `calc(${progress}% - 2.5rem)` }}
>
<div className="relative w-24 text-center">
<div className="font-bold text-red-600 bg-red-100 px-3 py-1.5 rounded-md shadow-sm border border-red-200 text-base">
Hari ke-{currentDay}
</div>
<div className="absolute left-1/2 -translate-x-1/2 w-0 h-0 border-l-8 border-l-transparent border-r-8 border-r-transparent border-t-8 border-t-red-100" />
</div>
</div>
)}
<div className="flex justify-between items-center text-sm font-semibold text-gray-500 mt-2 px-1">
<div className="flex flex-col items-start">
<span>Hari 1</span>
<span className="text-xs font-normal text-gray-400">
{formatDateId(cycle.start_date)}
</span>
<span className="text-xs font-normal text-gray-400">
Berat DOC: {cycle.chick_in_weight} gram
</span>
<span className="text-xs font-normal text-gray-400">
DOC In: {cycle.doc_in_count.toLocaleString('id-ID')} ekor
</span>
</div>
<div className="flex flex-col items-end">
<span>Hari {cycle.total_days}</span>
<span className="text-xs font-normal text-gray-400">
{formatDateId(cycle.end_date)}
</span>
</div>
</div>
</div>
</div>
);
};
const CycleTimeline: React.FC = () => {
const { cycles, selectedCycleId, setSelectedCycleId } = useFarm();
const [localId, setLocalId] = useState<number | null>(selectedCycleId);
useEffect(() => {
setLocalId(selectedCycleId);
}, [selectedCycleId]);
const selected = cycles.find((c) => c.id === localId) ?? null;
return (
<div className="bg-white p-6 sm:p-8 rounded-xl shadow-sm mb-8">
<h2 className="text-2xl font-bold text-gray-800 mb-4">Linimasa Siklus Produksi</h2>
{cycles.length === 0 ? (
<p className="text-sm text-gray-500">
Belum ada siklus untuk kandang ini. Buat di Pengaturan.
</p>
) : (
<>
<div className="border-b border-gray-200 overflow-x-auto">
<nav className="-mb-px flex space-x-6 min-w-max" aria-label="Tabs">
{cycles.map((cycle) => {
const config = statusConfig[resolveCycleStatus(cycle)];
const isActive = cycle.id === selected?.id;
return (
<button
key={cycle.id}
onClick={() => {
setLocalId(cycle.id);
setSelectedCycleId(cycle.id);
}}
className={`flex items-center gap-2 px-3 py-2 text-sm font-semibold rounded-t-lg transition-colors border-b-2 whitespace-nowrap ${
isActive
? 'border-red-500 text-red-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<i className={`fa-solid ${config.icon}`} />
<span>
{formatDateId(cycle.start_date)} ({config.label})
</span>
</button>
);
})}
</nav>
</div>
{selected ? (
<SingleCycleView cycle={selected} />
) : (
<p className="text-sm text-gray-500 mt-4">
Pilih tab siklus di atas, atau buat di Pengaturan.
</p>
)}
</>
)}
</div>
);
};
export default CycleTimeline;