updating cycle close request feature to get approval from national dashboard instead auto from system

This commit is contained in:
Alberto-Audrix committed 2026-09-09 14:18:39 +07:00
1 parent 3a868cf1db
commit e4dbfd0252
31 files changed
+1322 -182

No files matched your search

+32 -14
View File
@@ -1,13 +1,20 @@
import React, { useEffect, useState } from 'react';
import { useFarm } from '../context/FarmContext.tsx';
import { formatDateId, formatDateRange, resolveCycleStatus } from '../utils/format.ts';
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 starterEnd = Math.min(10, totalDays);
const growerEnd = Math.min(24, totalDays);
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' },
];
@@ -19,11 +26,11 @@ function phasesFor(totalDays: number): Phase[] {
color: 'bg-orange-400',
});
}
if (totalDays > growerEnd) {
if (safeDays > growerEnd) {
phases.push({
name: 'Finisher',
startDay: growerEnd + 1,
endDay: totalDays,
endDay: safeDays,
color: 'bg-yellow-500',
});
}
@@ -32,6 +39,12 @@ function phasesFor(totalDays: number): Phase[] {
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',
@@ -41,10 +54,13 @@ const statusConfig = {
};
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 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 = cycle.total_days > 0 ? (currentDay / cycle.total_days) * 100 : 0;
const progress = displayTotalDays > 0 ? (currentDay / displayTotalDays) * 100 : 0;
const showMarker = cycleStatus === 'active' || cycleStatus === 'pending_close';
return (
<div className="pt-4">
@@ -54,14 +70,16 @@ const SingleCycleView: React.FC<{ cycle: Cycle }> = ({ cycle }) => {
<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>
<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) / cycle.total_days) * 100;
const width = ((phase.endDay - phase.startDay + 1) / displayTotalDays) * 100;
return (
<div
key={phase.name}
@@ -74,7 +92,7 @@ const SingleCycleView: React.FC<{ cycle: Cycle }> = ({ cycle }) => {
);
})}
</div>
{cycleStatus === 'active' && (
{showMarker && (
<div
className="absolute top-0 transition-all duration-500 ease-out"
style={{ left: `calc(${progress}% - 2.5rem)` }}
@@ -101,9 +119,9 @@ const SingleCycleView: React.FC<{ cycle: Cycle }> = ({ cycle }) => {
</span>
</div>
<div className="flex flex-col items-end">
<span>Hari {cycle.total_days}</span>
<span>Hari {displayTotalDays}</span>
<span className="text-xs font-normal text-gray-400">
{formatDateId(cycle.end_date)}
{formatDateId(cycle.end_date ?? boundDate)}
</span>
</div>
</div>
+1 -1
View File
@@ -226,7 +226,7 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4 mb-2">
<PageHeader
title="Dashboard"
subtitle={`${selectedSite?.site_name ?? ''} · ${selectedKandang?.kandang_name ?? ''} · Siklus ${formatDateRange(selectedCycle.start_date, selectedCycle.end_date)} (${cycleStatus === 'active' ? 'aktif' : 'selesai'})`}
subtitle={`${selectedSite?.site_name ?? ''} · ${selectedKandang?.kandang_name ?? ''} · Siklus ${formatDateRange(selectedCycle.start_date, selectedCycle.end_date)} (${cycleStatus === 'active' ? 'aktif' : cycleStatus === 'pending_close' ? 'menunggu GM' : 'selesai'})`}
/>
<DashboardReportCtas
cycleClosed={cycleStatus === 'closed'}
+11 -1
View File
@@ -145,7 +145,7 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
) : (
<>
<i className="fa-solid fa-arrows-rotate mr-2"></i>
Sync hari ini
Sync
</>
)}
</button>
@@ -170,6 +170,11 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
Aktif
</span>
)}
{cycleStatus === 'pending_close' && (
<span className="px-2 py-0.5 bg-amber-100 text-amber-700 text-xs font-medium rounded">
Menunggu GM
</span>
)}
{cycleStatus === 'closed' && (
<span className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs font-medium rounded">
Selesai
@@ -210,6 +215,11 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
Aktif
</span>
)}
{itemStatus === 'pending_close' && (
<span className="px-2 py-0.5 bg-amber-100 text-amber-700 text-xs font-medium rounded">
Menunggu GM
</span>
)}
{itemStatus === 'closed' && (
<span className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs font-medium rounded">
Selesai
+6 -1
View File
@@ -93,7 +93,12 @@ const Header: React.FC<HeaderProps> = ({
{cycles.map((cycle) => (
<option key={cycle.id} value={cycle.id}>
{formatDateId(cycle.start_date)} (
{resolveCycleStatus(cycle) === 'active' ? 'Aktif' : 'Selesai'})
{resolveCycleStatus(cycle) === 'active'
? 'Aktif'
: resolveCycleStatus(cycle) === 'pending_close'
? 'Menunggu GM'
: 'Selesai'}
)
</option>
))}
</select>
+73 -30
View File
@@ -4,21 +4,19 @@ import { useAuth } from '../context/AuthContext.tsx';
import { useFarm } from '../context/FarmContext.tsx';
import type { Cycle, FeedInButtonUrl } from '../types/api.ts';
import { Banner, PageHeader } from './ui/Feedback.tsx';
import { addDaysIso, formatDateId, inclusiveDayCount, resolveCycleStatus, todayIso } from '../utils/format.ts';
import { formatDateId, resolveCycleStatus, todayIso } from '../utils/format.ts';
const inputClass =
'border border-gray-300 rounded-md px-3 py-2 text-sm w-full focus:ring-red-500 focus:border-red-500';
const defaultCycleForm = () => ({
startDate: todayIso(),
endDate: addDaysIso(todayIso(), 34),
docInCount: 20000,
chickInWeight: 35,
});
const cycleToForm = (cycle: Cycle) => ({
startDate: cycle.start_date,
endDate: cycle.end_date,
docInCount: cycle.doc_in_count,
chickInWeight: cycle.chick_in_weight,
});
@@ -48,7 +46,7 @@ const SettingsPage: React.FC = () => {
const [flockName, setFlockName] = useState('');
const [editingCycleId, setEditingCycleId] = useState<number | null>(null);
const [startDate, setStartDate] = useState(defaultCycleForm().startDate);
const [endDate, setEndDate] = useState(defaultCycleForm().endDate);
const [proposedCloseDate, setProposedCloseDate] = useState(todayIso());
const [docInCount, setDocInCount] = useState(defaultCycleForm().docInCount);
const [chickInWeight, setChickInWeight] = useState(defaultCycleForm().chickInWeight);
@@ -56,7 +54,6 @@ const SettingsPage: React.FC = () => {
const defaults = defaultCycleForm();
setEditingCycleId(null);
setStartDate(defaults.startDate);
setEndDate(defaults.endDate);
setDocInCount(defaults.docInCount);
setChickInWeight(defaults.chickInWeight);
};
@@ -65,7 +62,6 @@ const SettingsPage: React.FC = () => {
const form = cycleToForm(cycle);
setEditingCycleId(cycle.id);
setStartDate(form.startDate);
setEndDate(form.endDate);
setDocInCount(form.docInCount);
setChickInWeight(form.chickInWeight);
setSelectedCycleId(cycle.id);
@@ -73,9 +69,8 @@ const SettingsPage: React.FC = () => {
const buildCyclePayload = () => ({
kandang: selectedKandangId!,
total_days: inclusiveDayCount(startDate, endDate),
total_days: 1,
start_date: startDate,
end_date: endDate,
chick_in_weight: chickInWeight,
doc_in_count: docInCount,
});
@@ -86,8 +81,19 @@ const SettingsPage: React.FC = () => {
};
const isSuperAdmin = user?.status === 'superadmin';
const canRequestClose = user?.status === 'active';
const statusLabel = (cycle: Cycle) => {
const status = resolveCycleStatus(cycle);
if (status === 'pending_close') return 'Menunggu persetujuan GM';
if (status === 'closed') return 'Selesai';
return 'Aktif';
};
const selectedKandang = kandangs.find((k) => k.id === selectedKandangId) ?? null;
const editingCycle = editingCycleId
? (cycles.find((c) => c.id === editingCycleId) ?? null)
: null;
React.useEffect(() => {
const urls = selectedKandang?.feed_in_button_urls ?? [];
@@ -419,32 +425,25 @@ const SettingsPage: React.FC = () => {
});
}}
>
{editingCycleId && (
{editingCycle && (
<p className="text-xs font-medium text-blue-700 bg-blue-50 border border-blue-100 rounded-md px-3 py-2">
Mengedit siklus {formatDateId(startDate)} – {formatDateId(endDate)}
Mengedit siklus mulai {formatDateId(startDate)}
{editingCycle.end_date
? ` (selesai ${formatDateId(editingCycle.end_date)}, hanya via GM)`
: ' — tanggal selesai ditetapkan setelah persetujuan GM'}
</p>
)}
<p className="text-xs text-gray-500">
Tanggal selesai tidak diisi di sini. Ajukan tutup siklus setelah produksi selesai; GM
yang menyetujui end date.
</p>
<label className="block text-sm">
Mulai
<input
type="date"
className={`${inputClass} mt-1`}
value={startDate}
onChange={(e) => {
const nextStart = e.target.value;
setStartDate(nextStart);
if (endDate < nextStart) setEndDate(nextStart);
}}
/>
</label>
<label className="block text-sm">
Selesai
<input
type="date"
className={`${inputClass} mt-1`}
min={startDate}
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
onChange={(e) => setStartDate(e.target.value)}
/>
</label>
<label className="block text-sm">
@@ -490,17 +489,60 @@ const SettingsPage: React.FC = () => {
</div>
</form>
<ul className="divide-y">
{cycles.map((cycle) => (
{cycles.map((cycle) => {
const status = resolveCycleStatus(cycle);
return (
<li key={cycle.id} className="py-2 text-sm text-gray-700">
<div className="flex justify-between gap-2">
<button className="text-left" onClick={() => setSelectedCycleId(cycle.id)}>
{formatDateId(cycle.start_date)} – {formatDateId(cycle.end_date)}
{formatDateId(cycle.start_date)}
{cycle.end_date ? ` – ${formatDateId(cycle.end_date)}` : ''}
<span className="block text-xs text-gray-400">
{resolveCycleStatus(cycle) === 'active' ? 'Aktif' : 'Selesai'} ·{' '}
{cycle.doc_in_count} ekor · {cycle.chick_in_weight} g
{statusLabel(cycle)}
{status === 'pending_close' && cycle.proposed_end_date
? ` · usulan tutup ${formatDateId(cycle.proposed_end_date)}`
: ''}{' '}
· {cycle.doc_in_count} ekor · {cycle.chick_in_weight} g
</span>
</button>
<div className="flex flex-col items-end gap-1">
{canRequestClose && status === 'active' && (
<div className="flex items-center gap-1">
<input
type="date"
className="border border-gray-300 rounded px-1 py-0.5 text-xs"
min={cycle.start_date}
value={proposedCloseDate}
onChange={(e) => setProposedCloseDate(e.target.value)}
/>
<button
className="text-xs text-red-600 hover:underline"
onClick={() =>
void wrap(async () => {
await api.cycles.requestClose(cycle.id, {
proposed_end_date: proposedCloseDate,
});
flash('Permintaan tutup dikirim ke pusat.');
})
}
>
Ajukan tutup
</button>
</div>
)}
{canRequestClose && status === 'pending_close' && (
<button
className="text-xs text-amber-700 hover:underline"
onClick={() =>
void wrap(async () => {
await api.cycles.cancelCloseRequest(cycle.id);
flash('Permintaan tutup dibatalkan.');
})
}
>
Batalkan permintaan
</button>
)}
<button
className="text-xs text-blue-600 hover:underline"
onClick={() => beginCycleEdit(cycle)}
@@ -522,7 +564,8 @@ const SettingsPage: React.FC = () => {
</div>
</div>
</li>
))}
);
})}
</ul>
</section>
</div>
+49 -42
View File
@@ -2,8 +2,8 @@ import React, { useEffect, useMemo, useState } from 'react';
import { api, ApiError, errorMessage } from '../services/apiClient.ts';
import type { AIInsight, ChickenWeight, LatestAvg } from '../types/api.ts';
import { useFarm } from '../context/FarmContext.tsx';
import { Banner, EmptyFarm, Spinner } from './ui/Feedback.tsx';
import { formatDateId, formatNumber } from '../utils/format.ts';
import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx';
import { formatDateId, formatDateRange, formatNumber, resolveCycleStatus } from '../utils/format.ts';
import { formatWeight } from '../utils/weight.ts';
import WeightStats from './weight/WeightStats.tsx';
import DailyWeightProgressionChart from './weight/DailyWeightProgressionChart.tsx';
@@ -11,15 +11,18 @@ import DailyWeightGainChart from './weight/DailyWeightGainChart.tsx';
import DailyDetailPanel from './weight/DailyDetailPanel.tsx';
const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
const { selectedCycle } = useFarm();
const { selectedCycle, selectedSite, selectedKandang } = useFarm();
const [rows, setRows] = useState<ChickenWeight[]>([]);
const [latest, setLatest] = useState<LatestAvg | null>(null);
const [insights, setInsights] = useState<AIInsight[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [syncing, setSyncing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState('');
const isClosedCycle = !selectedCycle || resolveCycleStatus(selectedCycle) !== 'active';
const reload = async (cycleId: number) => {
setError(null);
try {
@@ -51,7 +54,6 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings
let cancelled = false;
(async () => {
setLoading(true);
setRefreshing(false);
try {
await reload(selectedCycle.id);
} finally {
@@ -78,15 +80,19 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings
}
}, [rows, chartRows, selectedDate]);
const handleRefresh = async () => {
if (!selectedCycle) return;
setRefreshing(true);
const handleSync = async () => {
if (!selectedCycle || isClosedCycle) return;
setSyncing(true);
setError(null);
setNotice(null);
try {
await api.weights.sync({ cycle_id: selectedCycle.id });
setNotice('Sinkron berat edge berhasil.');
await reload(selectedCycle.id);
} catch {
// error state is set inside reload
} catch (err) {
setError(errorMessage(err));
} finally {
setRefreshing(false);
setSyncing(false);
}
};
@@ -130,41 +136,42 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings
return (
<main className="pb-8">
{/* Header */}
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mb-6">
<div className="flex items-center">
<div className="bg-red-600 text-white p-3 rounded-lg mr-4">
<i className="fa-solid fa-chart-line text-2xl sm:text-3xl"></i>
</div>
<div>
<h1 className="text-2xl lg:text-3xl font-bold text-gray-800">Dashboard Berat Ayam</h1>
<p className="mt-1 text-gray-600">
Analisis pertumbuhan dan kesejahteraan bobot ayam secara real-time.
</p>
{!loading && latest?.date && (
<p className="mt-1 text-sm text-gray-500">
<i className="fa-solid fa-clock mr-1"></i>
Terakhir diperbarui: {formatDateId(latest.date)}
</p>
<PageHeader
title="Dashboard Berat Ayam"
icon={<i className="fa-solid fa-chart-line" />}
subtitle={`Analisis pertumbuhan bobot ayam secara real-time · ${selectedSite?.site_name ?? ''} · ${selectedKandang?.kandang_name ?? ''} · Siklus ${formatDateRange(selectedCycle.start_date, selectedCycle.end_date)}`}
actions={
<button
type="button"
onClick={handleSync}
disabled={syncing || isClosedCycle}
className="bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-semibold disabled:opacity-60 hover:bg-red-700 transition-colors"
>
{syncing ? (
<>
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
Sync...
</>
) : (
<>
<i className="fa-solid fa-arrows-rotate mr-2"></i>
Sync
</>
)}
</div>
</div>
<button
onClick={handleRefresh}
disabled={refreshing || loading}
className="flex items-center space-x-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
title="Refresh data manual"
>
<i className={`fa-solid fa-refresh ${refreshing || loading ? 'fa-spin' : ''}`}></i>
<span>{refreshing || loading ? 'Memuat...' : 'Refresh'}</span>
</button>
</div>
</button>
}
/>
{error && (
<div className="mb-4">
<Banner kind="error">{error}</Banner>
</div>
)}
{notice && (
<div className="mb-4">
<Banner kind="success">{notice}</Banner>
</div>
)}
{loading && rows.length === 0 ? (
<Spinner />
@@ -297,9 +304,9 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings
<div>
<h3 className="font-semibold text-blue-900 mb-2">Informasi Pembaruan Data</h3>
<ul className="text-sm text-blue-800 space-y-1">
<li>• Klik tombol "Refresh" untuk memperbarui data secara manual</li>
<li>• Grafik menampilkan data dari awal siklus hingga hari ini</li>
<li>• Target berat menggunakan standar performa Ross 308</li>
<li>• Klik "Sync" untuk menarik prediksi berat dari edge vision</li>
<li>• Hari 0 memakai berat DOC dan uniformity 0% (bukan data edge)</li>
<li>• Target berat menggunakan standar performa berdasarkan DOC</li>
</ul>
</div>
</div>
+4 -6
View File
@@ -188,18 +188,17 @@ describe('SettingsPage settings forms', () => {
expect(screen.queryByLabelText(/^Siklus$/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/Total hari/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/Selesai/i)).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/Mulai/i), { target: { value: '2026-08-01' } });
fireEvent.change(screen.getByLabelText(/Selesai/i), { target: { value: '2026-09-04' } });
fireEvent.click(screen.getByRole('button', { name: 'Tambah siklus' }));
await waitFor(() => expect(createCycleMock).toHaveBeenCalledTimes(1));
expect(createCycleMock).toHaveBeenCalledWith({
kandang: 11,
total_days: 35,
total_days: 1,
current_day: 1,
start_date: '2026-08-01',
end_date: '2026-09-04',
chick_in_weight: 35,
doc_in_count: 20000,
});
@@ -219,7 +218,7 @@ describe('SettingsPage settings forms', () => {
current_day: 1,
visible_through_date: '2026-08-18',
start_date: '2026-08-01',
end_date: '2026-09-04',
end_date: null,
chick_in_weight: 35,
doc_in_count: 20000,
feed_initial_balance: 0,
@@ -239,9 +238,8 @@ describe('SettingsPage settings forms', () => {
await waitFor(() => expect(updateCycleMock).toHaveBeenCalledTimes(1));
expect(updateCycleMock).toHaveBeenCalledWith(22, {
kandang: 11,
total_days: 35,
total_days: 1,
start_date: '2026-08-01',
end_date: '2026-09-04',
chick_in_weight: 38,
doc_in_count: 18000,
});
+2 -1
View File
@@ -6,6 +6,7 @@ import { Banner, EmptyFarm, PageHeader, Spinner } from '../ui/Feedback.tsx';
import {
addDaysIso,
cycleDayForDate,
cycleLastDayIndex,
formatDateId,
formatDateRange,
formatNumber,
@@ -116,7 +117,7 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
const kpiByDate = new Map(kpis.map((k) => [k.date, k]));
const lastCycleDay = Math.max(0, (selectedCycle.total_days || 1) - 1);
const endDay = cycleDayForDate(selectedCycle.start_date, selectedCycle.end_date);
const endDay = cycleLastDayIndex(selectedCycle);
const calendarDay = cycleDayForDate(selectedCycle.start_date, todayIso());
const maxDay = Math.min(calendarDay, lastCycleDay, endDay);
const today = todayIso();
@@ -11,7 +11,7 @@ import {
feedUseTotals,
} from '../../utils/reportTotals.ts';
import {
cycleDayForDate,
cycleLastDayIndex,
formatDateId,
formatDateRange,
formatNumber,
@@ -63,10 +63,7 @@ const ReportPreviewOverlay: React.FC<Props> = ({ open, reportType, onClose }) =>
const latestDay = selectedCycle ? Math.max(0, selectedCycle.current_day) : 0;
const endCycleDay = selectedCycle
? Math.min(
selectedCycle.total_days,
cycleDayForDate(selectedCycle.start_date, selectedCycle.end_date)
)
? Math.min(selectedCycle.total_days, cycleLastDayIndex(selectedCycle))
: 0;
const effectiveUpToDay = isEndCycle ? endCycleDay : draftDay;