193 lines
7.0 KiB
TypeScript
193 lines
7.0 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import { useFarm } from '../context/FarmContext.tsx';
|
|
import {
|
|
formatDateId,
|
|
formatDateRange,
|
|
resolveCycleBoundDate,
|
|
resolveCycleStatus,
|
|
cycleLastDayIndex,
|
|
} 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 safeDays = Math.max(totalDays, 1);
|
|
const starterEnd = Math.min(10, safeDays);
|
|
const growerEnd = Math.min(24, safeDays);
|
|
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 (safeDays > growerEnd) {
|
|
phases.push({
|
|
name: 'Finisher',
|
|
startDay: growerEnd + 1,
|
|
endDay: safeDays,
|
|
color: 'bg-yellow-500',
|
|
});
|
|
}
|
|
return phases;
|
|
}
|
|
|
|
const statusConfig = {
|
|
active: { label: 'Active', bg: 'bg-red-100', text: 'text-red-800', icon: 'fa-running' },
|
|
pending_close: {
|
|
label: 'Menunggu GM',
|
|
bg: 'bg-amber-100',
|
|
text: 'text-amber-800',
|
|
icon: 'fa-hourglass-half',
|
|
},
|
|
closed: {
|
|
label: 'Completed',
|
|
bg: 'bg-green-100',
|
|
text: 'text-green-800',
|
|
icon: 'fa-check-circle',
|
|
},
|
|
};
|
|
|
|
const SingleCycleView: React.FC<{ cycle: Cycle }> = ({ cycle }) => {
|
|
const boundDate = resolveCycleBoundDate(cycle);
|
|
const displayTotalDays = Math.max(cycle.total_days || 1, cycleLastDayIndex(cycle) + 1);
|
|
const phases = phasesFor(displayTotalDays);
|
|
const currentDay = Math.min(Math.max(cycle.current_day, 0), displayTotalDays);
|
|
const cycleStatus = resolveCycleStatus(cycle);
|
|
const progress = displayTotalDays > 0 ? (currentDay / displayTotalDays) * 100 : 0;
|
|
const showMarker = cycleStatus === 'active' || cycleStatus === 'pending_close';
|
|
|
|
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">
|
|
{cycle.end_date ? 'Estimasi Panen' : 'Batas data'}
|
|
</p>
|
|
<p className="text-red-600 font-bold">{formatDateId(cycle.end_date ?? boundDate)}</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) / displayTotalDays) * 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>
|
|
{showMarker && (
|
|
<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 {displayTotalDays}</span>
|
|
<span className="text-xs font-normal text-gray-400">
|
|
{formatDateId(cycle.end_date ?? boundDate)}
|
|
</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;
|