updating cycle close request feature to get approval from national dashboard instead auto from system
This commit is contained in:
1 parent
3a868cf1db
commit
e4dbfd0252
31 files changed
+1322
-182
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user