fix initial balance logic and add edit for cycle data

This commit is contained in:
Alberto-Audrix committed 2026-09-01 14:59:47 +07:00
1 parent 7a9d600aa6
commit 697feb206c
15 files changed
+384 -75

No files matched your search

+19
View File
@@ -95,6 +95,25 @@ class Cycle(models.Model):
if self.end_date and self.start_date and self.end_date < self.start_date:
raise ValidationError({"end_date": "Must be on or after start_date"})
def resolved_status(self) -> str:
"""Close the cycle once dashboard data through the end date is visible."""
from apps.operations.services.visibility import visible_through_date
if visible_through_date() >= self.end_date:
return self.STATUS_CLOSED
return self.STATUS_ACTIVE
def sync_status(self) -> bool:
resolved = self.resolved_status()
if self.status == resolved:
return False
self.status = resolved
return True
def save(self, *args, **kwargs):
self.sync_status()
super().save(*args, **kwargs)
@property
def current_day(self) -> int:
from apps.operations.services.visibility import visible_through_date
+14
View File
@@ -56,6 +56,20 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
"kandang_name",
"feed_initial_balance",
"feed_initial_balance_date",
"status",
"created_at",
"updated_at",
]
def validate(self, attrs):
attrs.pop("status", None)
start = attrs.get("start_date", getattr(self.instance, "start_date", None))
end = attrs.get("end_date", getattr(self.instance, "end_date", None))
if start and end and end < start:
raise serializers.ValidationError({"end_date": "Must be on or after start_date."})
return attrs
def to_representation(self, instance):
if instance.sync_status():
instance.save(update_fields=["status", "updated_at"])
return super().to_representation(instance)
+101 -7
View File
@@ -61,6 +61,57 @@ class FarmAPITests(TestCase):
self.assertNotIn("flock", response.data)
self.assertNotIn("flock_name", response.data)
def test_update_cycle(self):
self.client.force_authenticate(self.user)
start = date(2026, 8, 1)
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=start,
end_date=start + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
new_end = start + timedelta(days=39)
response = self.client.patch(
f"/api/v1/cycles/{cycle.pk}/",
{
"end_date": new_end.isoformat(),
"total_days": 40,
"doc_in_count": 9500,
"chick_in_weight": 42,
},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
cycle.refresh_from_db()
self.assertEqual(cycle.end_date, new_end)
self.assertEqual(cycle.total_days, 40)
self.assertEqual(cycle.doc_in_count, 9500)
self.assertEqual(cycle.doc_in_weight, 42)
@patch("apps.operations.services.visibility.visible_through_date", return_value=date(2026, 9, 10))
def test_cycle_status_closes_when_end_date_is_visible(self, _mock_visible):
self.client.force_authenticate(self.user)
start = date(2026, 8, 1)
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=start,
end_date=start + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
response = self.client.get(f"/api/v1/cycles/{cycle.pk}/")
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["status"], Cycle.STATUS_CLOSED)
cycle.refresh_from_db()
self.assertEqual(cycle.status, Cycle.STATUS_CLOSED)
def test_flock_persists_across_cycles(self):
flock = Flock.objects.create(flock_name="Lantai 1", kandang=self.kandang)
start = date.today()
@@ -88,7 +139,7 @@ class FarmAPITests(TestCase):
results = response.data["results"] if isinstance(response.data, dict) else response.data
self.assertEqual(len(results), 2)
def test_set_initial_balance_seeds_feed_sack_snapshot_when_none_exist(self):
def test_set_initial_balance_does_not_create_feed_sack_row(self):
start = date.today()
cycle = Cycle.objects.create(
kandang=self.kandang,
@@ -108,12 +159,12 @@ class FarmAPITests(TestCase):
)
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["feed_initial_balance_date"], start.isoformat())
cycle.refresh_from_db()
self.assertEqual(cycle.feed_initial_balance, 120)
self.assertFalse(FeedSacks.objects.filter(cycle=cycle).exists())
self.assertFalse(ManualInput.objects.filter(cycle=cycle).exists())
row = FeedSacks.objects.get(cycle=cycle)
self.assertEqual(row.date, start)
self.assertEqual(row.in_total, 120)
def test_set_initial_balance_recomputes_existing_feed_sack_in_total(self):
def test_set_initial_balance_does_not_change_existing_feed_sack_totals(self):
start = date.today()
cycle = Cycle.objects.create(
kandang=self.kandang,
@@ -144,7 +195,50 @@ class FarmAPITests(TestCase):
self.assertEqual(response.status_code, 200, response.data)
feed_row.refresh_from_db()
self.assertEqual(feed_row.in_total, 124)
self.assertEqual(feed_row.in_total, 4)
def test_set_initial_balance_twice_on_different_dates_keeps_daily_totals(self):
start = date(2026, 8, 26)
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=start,
end_date=start + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
feed_row = FeedSacks.objects.create(
cycle=cycle,
date=start + timedelta(days=2),
in_today=6,
in_total=6,
out_today=0,
out_total=0,
feed_use_today=0,
feed_use_total=0,
)
self.client.force_authenticate(self.user)
first = self.client.post(
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
{"date": start.isoformat(), "feed_in_manual": 120},
format="json",
)
self.assertEqual(first.status_code, 200, first.data)
second = self.client.post(
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
{"date": (start + timedelta(days=1)).isoformat(), "feed_in_manual": 80},
format="json",
)
self.assertEqual(second.status_code, 200, second.data)
cycle.refresh_from_db()
self.assertEqual(cycle.feed_initial_balance, 80)
self.assertEqual(cycle.feed_initial_balance_date, start + timedelta(days=1))
self.assertEqual(FeedSacks.objects.filter(cycle=cycle).count(), 1)
feed_row.refresh_from_db()
self.assertEqual(feed_row.in_total, 6)
@patch("apps.farms.views.pull_initial_balance_iot", return_value=(115, None))
def test_set_initial_balance_compares_manual_against_combined_api_iot(self, mock_pull):
+16 -19
View File
@@ -6,16 +6,14 @@ from rest_framework.response import Response
from apps.accounts.models import User
from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.farms.serializers import CycleSerializer, FlockSerializer, KandangSerializer, SiteSerializer
from apps.operations.models import FeedSacks, ManualInput
from apps.operations.services.feed_sacks import (
ensure_opening_feed_sack_snapshot,
cleanup_opening_balance_feed_rows,
recompute_feed_sack_in_totals,
)
from apps.operations.services.karung_web import (
initial_balance_accuracy,
pull_initial_balance_iot,
)
from apps.operations.services.manual_input import recompute_manual_totals
class IsSuperAdminForWrite(permissions.BasePermission):
@@ -113,7 +111,6 @@ class CycleViewSet(viewsets.ModelViewSet):
)
iot_in, sync_error = pull_initial_balance_iot(cycle, balance_date)
manual_row = ManualInput.objects.filter(cycle=cycle, date=balance_date).first()
return Response(
{
"cycle": cycle.pk,
@@ -125,7 +122,7 @@ class CycleViewSet(viewsets.ModelViewSet):
else None
),
"manual": feed_in_manual,
"manual_total": manual_row.feed_in_manual_total if manual_row else feed_in_manual,
"manual_total": feed_in_manual,
"iot": iot_in,
"accuracy": initial_balance_accuracy(feed_in_manual, iot_in),
"sync_error": sync_error,
@@ -137,9 +134,10 @@ class CycleViewSet(viewsets.ModelViewSet):
"""Save the feed-in balance for the cycle (Saldo Awal).
Sets ``feed_initial_balance`` on the cycle (the only cycle field this
feature writes — never during cycle creation), records the manual feed-in
for the balance date, then pulls the IoT-detected "in" for that date from
karung-web-admin ``/api/combined`` and returns a manual vs IoT accuracy figure.
feature writes — never during cycle creation). Opening balance is kept on
the cycle for saldo only and is not written into daily FeedSacks rows.
Pulls the IoT-detected "in" for that date from karung-web-admin
``/api/combined`` and returns a manual vs IoT accuracy figure.
"""
cycle = self.get_object()
@@ -166,6 +164,9 @@ class CycleViewSet(viewsets.ModelViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
previous_date = cycle.feed_initial_balance_date
previous_balance = cycle.feed_initial_balance
cycle.feed_initial_balance = feed_in_manual
cycle.feed_initial_balance_date = balance_date
cycle.save(
@@ -175,21 +176,17 @@ class CycleViewSet(viewsets.ModelViewSet):
# Pull IoT "in" at the balance date from karung-web-admin /api/combined.
iot_in, sync_error = pull_initial_balance_iot(cycle, balance_date)
# Record the manual feed-in for the balance date.
ManualInput.objects.update_or_create(
cycle=cycle,
date=balance_date,
defaults={"feed_in_manual": feed_in_manual},
cleanup_opening_balance_feed_rows(
cycle,
previous_date=previous_date,
previous_balance=previous_balance,
balance_date=balance_date,
balance_amount=feed_in_manual,
)
recompute_manual_totals(cycle)
# FeedSacks in_total always includes the initial balance as its base.
recompute_feed_sack_in_totals(cycle)
ensure_opening_feed_sack_snapshot(cycle, balance_date)
accuracy = initial_balance_accuracy(feed_in_manual, iot_in)
manual_row = ManualInput.objects.filter(cycle=cycle, date=balance_date).first()
return Response(
{
"cycle": cycle.pk,
@@ -201,7 +198,7 @@ class CycleViewSet(viewsets.ModelViewSet):
else None
),
"manual": feed_in_manual,
"manual_total": manual_row.feed_in_manual_total if manual_row else feed_in_manual,
"manual_total": feed_in_manual,
"iot": iot_in,
"accuracy": accuracy,
"sync_error": sync_error,
+49 -10
View File
@@ -7,8 +7,8 @@ from apps.operations.models import FeedSacks
def recompute_feed_sack_in_totals(cycle: Cycle) -> None:
"""Rebuild cumulative in_total from the cycle opening balance plus daily IoT in."""
running = cycle.feed_initial_balance
"""Rebuild cumulative in_total from daily IoT feed-in only (excludes opening balance)."""
running = 0
for fs_row in FeedSacks.objects.filter(cycle=cycle).order_by("date", "pk"):
running += fs_row.in_today
if fs_row.in_total != running:
@@ -16,17 +16,56 @@ def recompute_feed_sack_in_totals(cycle: Cycle) -> None:
fs_row.save(update_fields=["in_total", "updated_at"])
def ensure_opening_feed_sack_snapshot(cycle: Cycle, balance_date: date) -> FeedSacks | None:
"""Seed a zero-activity snapshot when opening balance is saved before any IoT sync."""
if FeedSacks.objects.filter(cycle=cycle).exists():
return None
return FeedSacks.objects.create(
def remove_opening_balance_feed_row(
cycle: Cycle,
*,
balance_date: date | None,
balance_amount: int,
) -> None:
"""Drop a FeedSacks row that only existed to carry opening balance."""
if balance_date is None or balance_amount <= 0:
return
FeedSacks.objects.filter(
cycle=cycle,
date=balance_date,
in_today=0,
in_today=balance_amount,
out_today=0,
in_total=cycle.feed_initial_balance,
out_total=0,
feed_use_today=0,
out_total=0,
feed_use_total=0,
).delete()
def cleanup_opening_balance_feed_rows(
cycle: Cycle,
*,
previous_date: date | None,
previous_balance: int,
balance_date: date,
balance_amount: int,
) -> None:
"""Remove opening-balance snapshot rows for the previous and current save."""
for row_date, amount in (
(previous_date, previous_balance),
(balance_date, balance_amount),
):
remove_opening_balance_feed_row(
cycle,
balance_date=row_date,
balance_amount=amount,
)
# Legacy cleanup: drop opening-only rows left from earlier saves on other dates.
opening_amounts = {
amount for amount in (previous_balance, balance_amount) if amount > 0
}
if not opening_amounts:
return
FeedSacks.objects.filter(
cycle=cycle,
in_today__in=opening_amounts,
out_today=0,
feed_use_today=0,
out_total=0,
feed_use_total=0,
).delete()
@@ -146,7 +146,7 @@ def request_karung(
out_total = prior.out_total + out_today
feed_use_total = prior.feed_use_total + feed_use_today
else:
in_total = cycle.feed_initial_balance + in_today
in_total = in_today
out_total = out_today
feed_use_total = feed_use_today
+4 -3
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { useFarm } from '../context/FarmContext.tsx';
import { formatDateId, formatDateRange } from '../utils/format.ts';
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 };
@@ -43,6 +43,7 @@ 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 cycleStatus = resolveCycleStatus(cycle);
const progress = cycle.total_days > 0 ? (currentDay / cycle.total_days) * 100 : 0;
return (
@@ -73,7 +74,7 @@ const SingleCycleView: React.FC<{ cycle: Cycle }> = ({ cycle }) => {
);
})}
</div>
{cycle.status === 'active' && currentDay > 0 && (
{cycleStatus === 'active' && currentDay > 0 && (
<div
className="absolute top-0 transition-all duration-500 ease-out"
style={{ left: `calc(${progress}% - 2.5rem)` }}
@@ -133,7 +134,7 @@ const CycleTimeline: React.FC = () => {
<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[cycle.status];
const config = statusConfig[resolveCycleStatus(cycle)];
const isActive = cycle.id === selected?.id;
return (
<button
+6 -5
View File
@@ -12,7 +12,7 @@ import type { View } from '../types/common.ts';
import { useFarm } from '../context/FarmContext.tsx';
import KpiCard, { type KpiCardModel } from './KpiCard.tsx';
import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx';
import { formatNumber, formatDateRange } from '../utils/format.ts';
import { formatNumber, formatDateRange, resolveCycleStatus } from '../utils/format.ts';
import DashboardReportCtas from './dashboard/DashboardReportCtas.tsx';
import DashboardManualInputForm from './dashboard/DashboardManualInputForm.tsx';
import ReportPreviewOverlay from './dashboard/ReportPreviewOverlay.tsx';
@@ -162,21 +162,22 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
const feedTotal = kpis[0]?.feed_total ?? 0;
const feedWeightKg = feedTotal * 50;
const feedInitialBalance = selectedCycle?.feed_initial_balance ?? 0;
const feedInTotal = karung?.in_total ?? feedInitialBalance;
const feedInTotal = (karung?.in_total ?? 0) + feedInitialBalance;
const feedOutTotal = karung?.out_total ?? 0;
const feedSaldo = feedInTotal - feedTotal - feedOutTotal;
const feedSaldoColor =
feedSaldo > 0 ? 'text-green-600' : feedSaldo < 0 ? 'text-red-600' : 'text-gray-600';
const cycleStatus = resolveCycleStatus(selectedCycle);
return (
<div>
<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)} (${selectedCycle.status === 'active' ? 'aktif' : 'selesai'})`}
subtitle={`${selectedSite?.site_name ?? ''} · ${selectedKandang?.kandang_name ?? ''} · Siklus ${formatDateRange(selectedCycle.start_date, selectedCycle.end_date)} (${cycleStatus === 'active' ? 'aktif' : 'selesai'})`}
/>
<DashboardReportCtas
cycleClosed={selectedCycle.status === 'closed'}
cycleClosed={cycleStatus === 'closed'}
hasDailySnapshot={false}
hasEndCycleSnapshot={false}
onOpenDaily={() => {
@@ -315,7 +316,7 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
<DashboardManualInputForm
cycleId={selectedCycle.id}
currentDay={selectedCycle.current_day}
readOnly={selectedCycle.status !== 'active'}
readOnly={cycleStatus !== 'active'}
/>
</>
)}
+14 -9
View File
@@ -3,7 +3,7 @@ import { api, errorMessage } from '../services/apiClient.ts';
import type { AIInsight, Cycle, Karung, ManualInput } from '../types/api.ts';
import { useFarm } from '../context/FarmContext.tsx';
import { Banner, EmptyFarm, Spinner } from './ui/Feedback.tsx';
import { addDaysIso, cycleDayForDate, formatDateId, todayIso } from '../utils/format.ts';
import { addDaysIso, cycleDayForDate, formatDateId, resolveCycleStatus, todayIso } from '../utils/format.ts';
import FeedStats from './feedSack/FeedStats.tsx';
import FeedSaldoCard from './feedSack/FeedSaldoCard.tsx';
import FeedTrendChart from './feedSack/FeedTrendChart.tsx';
@@ -117,6 +117,8 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
const cycleStatus = resolveCycleStatus(selectedCycle);
return (
<div>
{/* Page Title */}
@@ -137,7 +139,7 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
<div className="ml-auto flex items-center gap-2">
<button
onClick={handleSync}
disabled={syncing || selectedCycle.status !== 'active'}
disabled={syncing || cycleStatus !== 'active'}
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 ? (
@@ -168,12 +170,12 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
<span className="text-sm font-semibold text-gray-800">
Siklus {formatCycleDateRange(selectedCycle)}
</span>
{selectedCycle.status === 'active' && (
{cycleStatus === 'active' && (
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs font-medium rounded">
Aktif
</span>
)}
{selectedCycle.status === 'closed' && (
{cycleStatus === 'closed' && (
<span className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs font-medium rounded">
Selesai
</span>
@@ -188,7 +190,9 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
{/* Cycle Picker Dropdown */}
{showCyclePicker && (
<div className="absolute top-full left-0 mt-2 bg-white border border-gray-200 rounded-lg shadow-lg z-10 min-w-[340px] sm:min-w-[400px] max-h-[400px] overflow-y-auto">
{sortedCycles.map((cycle) => (
{sortedCycles.map((cycle) => {
const itemStatus = resolveCycleStatus(cycle);
return (
<button
key={cycle.id}
onClick={() => {
@@ -200,25 +204,26 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
}`}
>
<div className="flex items-center gap-2 mb-1">
{cycle.status === 'active' && (
{itemStatus === 'active' && (
<span className="w-2 h-2 bg-blue-500 rounded-full"></span>
)}
<span className="text-sm font-semibold text-gray-800">
Siklus {formatCycleDateRange(cycle)}
</span>
{cycle.status === 'active' && (
{itemStatus === 'active' && (
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs font-medium rounded">
Aktif
</span>
)}
{cycle.status === 'closed' && (
{itemStatus === 'closed' && (
<span className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs font-medium rounded">
Selesai
</span>
)}
</div>
</button>
))}
);
})}
</div>
)}
</div>
+2 -2
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useRef, useState } from 'react';
import { useAuth } from '../context/AuthContext.tsx';
import { useFarm } from '../context/FarmContext.tsx';
import { formatDateId } from '../utils/format.ts';
import { formatDateId, resolveCycleStatus } from '../utils/format.ts';
type HeaderProps = {
onToggleSidebar: () => void;
@@ -93,7 +93,7 @@ const Header: React.FC<HeaderProps> = ({
{cycles.map((cycle) => (
<option key={cycle.id} value={cycle.id}>
{formatDateId(cycle.start_date)} (
{cycle.status === 'active' ? 'Aktif' : 'Selesai'})
{resolveCycleStatus(cycle) === 'active' ? 'Aktif' : 'Selesai'})
</option>
))}
</select>
+89 -16
View File
@@ -2,12 +2,27 @@ import React, { useState } from 'react';
import { api, errorMessage } from '../services/apiClient.ts';
import { useAuth } from '../context/AuthContext.tsx';
import { useFarm } from '../context/FarmContext.tsx';
import type { Cycle } from '../types/api.ts';
import { Banner, PageHeader } from './ui/Feedback.tsx';
import { addDaysIso, formatDateId, inclusiveDayCount, todayIso } from '../utils/format.ts';
import { addDaysIso, formatDateId, inclusiveDayCount, 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,
});
const SettingsPage: React.FC = () => {
const { user } = useAuth();
const {
@@ -28,10 +43,39 @@ const SettingsPage: React.FC = () => {
const [siteName, setSiteName] = useState('');
const [kandangName, setKandangName] = useState('');
const [flockName, setFlockName] = useState('');
const [startDate, setStartDate] = useState(todayIso());
const [endDate, setEndDate] = useState(() => addDaysIso(todayIso(), 34));
const [docInCount, setDocInCount] = useState(20000);
const [chickInWeight, setChickInWeight] = useState(35);
const [editingCycleId, setEditingCycleId] = useState<number | null>(null);
const [startDate, setStartDate] = useState(defaultCycleForm().startDate);
const [endDate, setEndDate] = useState(defaultCycleForm().endDate);
const [docInCount, setDocInCount] = useState(defaultCycleForm().docInCount);
const [chickInWeight, setChickInWeight] = useState(defaultCycleForm().chickInWeight);
const resetCycleForm = () => {
const defaults = defaultCycleForm();
setEditingCycleId(null);
setStartDate(defaults.startDate);
setEndDate(defaults.endDate);
setDocInCount(defaults.docInCount);
setChickInWeight(defaults.chickInWeight);
};
const beginCycleEdit = (cycle: Cycle) => {
const form = cycleToForm(cycle);
setEditingCycleId(cycle.id);
setStartDate(form.startDate);
setEndDate(form.endDate);
setDocInCount(form.docInCount);
setChickInWeight(form.chickInWeight);
setSelectedCycleId(cycle.id);
};
const buildCyclePayload = () => ({
kandang: selectedKandangId!,
total_days: inclusiveDayCount(startDate, endDate),
start_date: startDate,
end_date: endDate,
chick_in_weight: chickInWeight,
doc_in_count: docInCount,
});
const flash = (msg: string) => {
setNotice(msg);
@@ -262,19 +306,24 @@ const SettingsPage: React.FC = () => {
e.preventDefault();
if (!selectedKandangId) return;
void wrap(async () => {
await api.cycles.create({
kandang: selectedKandangId,
total_days: inclusiveDayCount(startDate, endDate),
current_day: 1,
start_date: startDate,
end_date: endDate,
chick_in_weight: chickInWeight,
doc_in_count: docInCount,
});
const payload = buildCyclePayload();
if (editingCycleId) {
await api.cycles.update(editingCycleId, payload);
flash('Siklus diperbarui.');
resetCycleForm();
} else {
await api.cycles.create({ ...payload, current_day: 1 });
flash('Siklus dibuat.');
resetCycleForm();
}
});
}}
>
{editingCycleId && (
<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)}
</p>
)}
<label className="block text-sm">
Mulai
<input
@@ -322,12 +371,24 @@ const SettingsPage: React.FC = () => {
}}
/>
</label>
<div className="flex gap-2">
<button
className="bg-red-600 text-white px-3 py-2 rounded-lg text-sm font-semibold"
disabled={saving}
>
Tambah siklus
{editingCycleId ? 'Simpan perubahan' : 'Tambah siklus'}
</button>
{editingCycleId && (
<button
type="button"
className="border border-gray-300 text-gray-700 px-3 py-2 rounded-lg text-sm font-semibold hover:bg-gray-50"
disabled={saving}
onClick={resetCycleForm}
>
Batal
</button>
)}
</div>
</form>
<ul className="divide-y">
{cycles.map((cycle) => (
@@ -335,12 +396,23 @@ const SettingsPage: React.FC = () => {
<div className="flex justify-between gap-2">
<button className="text-left" onClick={() => setSelectedCycleId(cycle.id)}>
{formatDateId(cycle.start_date)} – {formatDateId(cycle.end_date)}
<span className="block text-xs text-gray-400">{cycle.status}</span>
<span className="block text-xs text-gray-400">
{resolveCycleStatus(cycle) === 'active' ? 'Aktif' : 'Selesai'} ·{' '}
{cycle.doc_in_count} ekor · {cycle.chick_in_weight} g
</span>
</button>
<div className="flex flex-col items-end gap-1">
<button
className="text-xs text-blue-600 hover:underline"
onClick={() => beginCycleEdit(cycle)}
>
Ubah
</button>
<button
className="text-xs text-red-500 hover:underline"
onClick={() =>
void wrap(async () => {
if (editingCycleId === cycle.id) resetCycleForm();
await api.cycles.remove(cycle.id);
flash('Siklus dihapus.');
})
@@ -349,6 +421,7 @@ const SettingsPage: React.FC = () => {
Hapus
</button>
</div>
</div>
</li>
))}
</ul>
+59 -1
View File
@@ -2,11 +2,12 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import SettingsPage from '../SettingsPage.tsx';
const { createKandangMock, createFlockMock, createCycleMock, reloadMock, farmState } = vi.hoisted(
const { createKandangMock, createFlockMock, createCycleMock, updateCycleMock, reloadMock, farmState } = vi.hoisted(
() => ({
createKandangMock: vi.fn(),
createFlockMock: vi.fn(),
createCycleMock: vi.fn(),
updateCycleMock: vi.fn(),
reloadMock: vi.fn().mockResolvedValue(undefined),
farmState: {
sites: [{ id: 7, site_name: 'Site A', user: 99, created_at: '', updated_at: '' }],
@@ -49,6 +50,7 @@ vi.mock('../../services/apiClient.ts', () => ({
},
cycles: {
create: createCycleMock,
update: updateCycleMock,
remove: vi.fn(),
},
},
@@ -89,6 +91,7 @@ describe('SettingsPage settings forms', () => {
createKandangMock.mockReset();
createFlockMock.mockReset();
createCycleMock.mockReset();
updateCycleMock.mockReset();
reloadMock.mockClear();
farmState.kandangs = [];
farmState.flocks = [];
@@ -124,6 +127,19 @@ describe('SettingsPage settings forms', () => {
created_at: '',
updated_at: '',
});
updateCycleMock.mockResolvedValue({
id: 22,
kandang: 11,
total_days: 40,
current_day: 1,
start_date: '2026-08-01',
end_date: '2026-09-09',
chick_in_weight: 38,
doc_in_count: 18000,
status: 'closed',
created_at: '',
updated_at: '',
});
});
it('submits only backend-supported kandang fields', async () => {
@@ -188,4 +204,46 @@ describe('SettingsPage settings forms', () => {
doc_in_count: 20000,
});
});
it('updates an existing cycle from the edit form', async () => {
farmState.kandangs = [
{ id: 11, kandang_name: 'Kandang 1', site: 7, created_at: '', updated_at: '' },
];
farmState.selectedKandangId = 11;
farmState.cycles = [
{
id: 22,
kandang: 11,
kandang_name: 'Kandang 1',
total_days: 35,
current_day: 1,
visible_through_date: '2026-08-18',
start_date: '2026-08-01',
end_date: '2026-09-04',
chick_in_weight: 35,
doc_in_count: 20000,
feed_initial_balance: 0,
status: 'active',
created_at: '',
updated_at: '',
},
];
render(<SettingsPage />);
fireEvent.click(screen.getByRole('button', { name: 'Ubah' }));
fireEvent.change(screen.getByLabelText(/DOC in/i), { target: { value: '18000' } });
fireEvent.change(screen.getByLabelText(/Berat DOC/i), { target: { value: '38' } });
fireEvent.click(screen.getByRole('button', { name: 'Simpan perubahan' }));
await waitFor(() => expect(updateCycleMock).toHaveBeenCalledTimes(1));
expect(updateCycleMock).toHaveBeenCalledWith(22, {
kandang: 11,
total_days: 35,
start_date: '2026-08-01',
end_date: '2026-09-04',
chick_in_weight: 38,
doc_in_count: 18000,
});
});
});
+2 -1
View File
@@ -8,6 +8,7 @@ import {
formatDateId,
formatDateRange,
formatNumber,
resolveCycleStatus,
} from '../../utils/format.ts';
import PopulationTrendChart from './PopulationTrendChart.tsx';
@@ -41,7 +42,7 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
const [searchDay, setSearchDay] = useState<string>('');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
const isClosedCycle = !selectedCycle || selectedCycle.status !== 'active';
const isClosedCycle = !selectedCycle || resolveCycleStatus(selectedCycle) !== 'active';
const loadData = async (cycleId: number) => {
setLoading(true);
+1 -1
View File
@@ -16,7 +16,7 @@ const FeedSaldoCard: React.FC<FeedSaldoCardProps> = ({
displayDate,
}) => {
const saldoData = useMemo(() => {
const inbound = latest?.in_total ?? feedInitialBalance;
const inbound = (latest?.in_total ?? 0) + feedInitialBalance;
const feedUse = latest?.feed_use_total ?? 0;
const keluar = latest?.out_total ?? 0;
const saldo = inbound - feedUse - keluar;
+7
View File
@@ -54,3 +54,10 @@ export function cycleDayForDate(startDate: string, isoDate: string): number {
export function inclusiveDayCount(startIso: string, endIso: string): number {
return Math.max(1, cycleDayForDate(startIso, endIso) + 1);
}
export function resolveCycleStatus(cycle: {
end_date: string;
visible_through_date: string;
}): 'active' | 'closed' {
return cycle.visible_through_date >= cycle.end_date ? 'closed' : 'active';
}