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: 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"}) 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 @property
def current_day(self) -> int: def current_day(self) -> int:
from apps.operations.services.visibility import visible_through_date from apps.operations.services.visibility import visible_through_date
+14
View File
@@ -56,6 +56,20 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
"kandang_name", "kandang_name",
"feed_initial_balance", "feed_initial_balance",
"feed_initial_balance_date", "feed_initial_balance_date",
"status",
"created_at", "created_at",
"updated_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", response.data)
self.assertNotIn("flock_name", 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): def test_flock_persists_across_cycles(self):
flock = Flock.objects.create(flock_name="Lantai 1", kandang=self.kandang) flock = Flock.objects.create(flock_name="Lantai 1", kandang=self.kandang)
start = date.today() start = date.today()
@@ -88,7 +139,7 @@ class FarmAPITests(TestCase):
results = response.data["results"] if isinstance(response.data, dict) else response.data results = response.data["results"] if isinstance(response.data, dict) else response.data
self.assertEqual(len(results), 2) 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() start = date.today()
cycle = Cycle.objects.create( cycle = Cycle.objects.create(
kandang=self.kandang, kandang=self.kandang,
@@ -108,12 +159,12 @@ class FarmAPITests(TestCase):
) )
self.assertEqual(response.status_code, 200, response.data) self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["feed_initial_balance_date"], start.isoformat()) 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) def test_set_initial_balance_does_not_change_existing_feed_sack_totals(self):
self.assertEqual(row.date, start)
self.assertEqual(row.in_total, 120)
def test_set_initial_balance_recomputes_existing_feed_sack_in_total(self):
start = date.today() start = date.today()
cycle = Cycle.objects.create( cycle = Cycle.objects.create(
kandang=self.kandang, kandang=self.kandang,
@@ -144,7 +195,50 @@ class FarmAPITests(TestCase):
self.assertEqual(response.status_code, 200, response.data) self.assertEqual(response.status_code, 200, response.data)
feed_row.refresh_from_db() 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)) @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): 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.accounts.models import User
from apps.farms.models import Cycle, Flock, Kandang, Site from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.farms.serializers import CycleSerializer, FlockSerializer, KandangSerializer, SiteSerializer from apps.farms.serializers import CycleSerializer, FlockSerializer, KandangSerializer, SiteSerializer
from apps.operations.models import FeedSacks, ManualInput
from apps.operations.services.feed_sacks import ( from apps.operations.services.feed_sacks import (
ensure_opening_feed_sack_snapshot, cleanup_opening_balance_feed_rows,
recompute_feed_sack_in_totals, recompute_feed_sack_in_totals,
) )
from apps.operations.services.karung_web import ( from apps.operations.services.karung_web import (
initial_balance_accuracy, initial_balance_accuracy,
pull_initial_balance_iot, pull_initial_balance_iot,
) )
from apps.operations.services.manual_input import recompute_manual_totals
class IsSuperAdminForWrite(permissions.BasePermission): class IsSuperAdminForWrite(permissions.BasePermission):
@@ -113,7 +111,6 @@ class CycleViewSet(viewsets.ModelViewSet):
) )
iot_in, sync_error = pull_initial_balance_iot(cycle, balance_date) iot_in, sync_error = pull_initial_balance_iot(cycle, balance_date)
manual_row = ManualInput.objects.filter(cycle=cycle, date=balance_date).first()
return Response( return Response(
{ {
"cycle": cycle.pk, "cycle": cycle.pk,
@@ -125,7 +122,7 @@ class CycleViewSet(viewsets.ModelViewSet):
else None else None
), ),
"manual": feed_in_manual, "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, "iot": iot_in,
"accuracy": initial_balance_accuracy(feed_in_manual, iot_in), "accuracy": initial_balance_accuracy(feed_in_manual, iot_in),
"sync_error": sync_error, "sync_error": sync_error,
@@ -137,9 +134,10 @@ class CycleViewSet(viewsets.ModelViewSet):
"""Save the feed-in balance for the cycle (Saldo Awal). """Save the feed-in balance for the cycle (Saldo Awal).
Sets ``feed_initial_balance`` on the cycle (the only cycle field this Sets ``feed_initial_balance`` on the cycle (the only cycle field this
feature writes — never during cycle creation), records the manual feed-in feature writes — never during cycle creation). Opening balance is kept on
for the balance date, then pulls the IoT-detected "in" for that date from the cycle for saldo only and is not written into daily FeedSacks rows.
karung-web-admin ``/api/combined`` and returns a manual vs IoT accuracy figure. 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() cycle = self.get_object()
@@ -166,6 +164,9 @@ class CycleViewSet(viewsets.ModelViewSet):
status=status.HTTP_400_BAD_REQUEST, 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 = feed_in_manual
cycle.feed_initial_balance_date = balance_date cycle.feed_initial_balance_date = balance_date
cycle.save( cycle.save(
@@ -175,21 +176,17 @@ class CycleViewSet(viewsets.ModelViewSet):
# Pull IoT "in" at the balance date from karung-web-admin /api/combined. # Pull IoT "in" at the balance date from karung-web-admin /api/combined.
iot_in, sync_error = pull_initial_balance_iot(cycle, balance_date) iot_in, sync_error = pull_initial_balance_iot(cycle, balance_date)
# Record the manual feed-in for the balance date. cleanup_opening_balance_feed_rows(
ManualInput.objects.update_or_create( cycle,
cycle=cycle, previous_date=previous_date,
date=balance_date, previous_balance=previous_balance,
defaults={"feed_in_manual": feed_in_manual}, 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) recompute_feed_sack_in_totals(cycle)
ensure_opening_feed_sack_snapshot(cycle, balance_date)
accuracy = initial_balance_accuracy(feed_in_manual, iot_in) accuracy = initial_balance_accuracy(feed_in_manual, iot_in)
manual_row = ManualInput.objects.filter(cycle=cycle, date=balance_date).first()
return Response( return Response(
{ {
"cycle": cycle.pk, "cycle": cycle.pk,
@@ -201,7 +198,7 @@ class CycleViewSet(viewsets.ModelViewSet):
else None else None
), ),
"manual": feed_in_manual, "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, "iot": iot_in,
"accuracy": accuracy, "accuracy": accuracy,
"sync_error": sync_error, "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: def recompute_feed_sack_in_totals(cycle: Cycle) -> None:
"""Rebuild cumulative in_total from the cycle opening balance plus daily IoT in.""" """Rebuild cumulative in_total from daily IoT feed-in only (excludes opening balance)."""
running = cycle.feed_initial_balance running = 0
for fs_row in FeedSacks.objects.filter(cycle=cycle).order_by("date", "pk"): for fs_row in FeedSacks.objects.filter(cycle=cycle).order_by("date", "pk"):
running += fs_row.in_today running += fs_row.in_today
if fs_row.in_total != running: 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"]) fs_row.save(update_fields=["in_total", "updated_at"])
def ensure_opening_feed_sack_snapshot(cycle: Cycle, balance_date: date) -> FeedSacks | None: def remove_opening_balance_feed_row(
"""Seed a zero-activity snapshot when opening balance is saved before any IoT sync.""" cycle: Cycle,
if FeedSacks.objects.filter(cycle=cycle).exists(): *,
return None balance_date: date | None,
return FeedSacks.objects.create( 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, cycle=cycle,
date=balance_date, date=balance_date,
in_today=0, in_today=balance_amount,
out_today=0, out_today=0,
in_total=cycle.feed_initial_balance,
out_total=0,
feed_use_today=0, feed_use_today=0,
out_total=0,
feed_use_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 out_total = prior.out_total + out_today
feed_use_total = prior.feed_use_total + feed_use_today feed_use_total = prior.feed_use_total + feed_use_today
else: else:
in_total = cycle.feed_initial_balance + in_today in_total = in_today
out_total = out_today out_total = out_today
feed_use_total = feed_use_today feed_use_total = feed_use_today
+4 -3
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useFarm } from '../context/FarmContext.tsx'; 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'; import type { Cycle } from '../types/api.ts';
type Phase = { name: string; startDay: number; endDay: number; color: string }; type Phase = { name: string; startDay: number; endDay: number; color: string };
@@ -43,6 +43,7 @@ const statusConfig = {
const SingleCycleView: React.FC<{ cycle: Cycle }> = ({ cycle }) => { const SingleCycleView: React.FC<{ cycle: Cycle }> = ({ cycle }) => {
const phases = phasesFor(cycle.total_days); const phases = phasesFor(cycle.total_days);
const currentDay = Math.min(Math.max(cycle.current_day, 0), 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; const progress = cycle.total_days > 0 ? (currentDay / cycle.total_days) * 100 : 0;
return ( return (
@@ -73,7 +74,7 @@ const SingleCycleView: React.FC<{ cycle: Cycle }> = ({ cycle }) => {
); );
})} })}
</div> </div>
{cycle.status === 'active' && currentDay > 0 && ( {cycleStatus === 'active' && currentDay > 0 && (
<div <div
className="absolute top-0 transition-all duration-500 ease-out" className="absolute top-0 transition-all duration-500 ease-out"
style={{ left: `calc(${progress}% - 2.5rem)` }} style={{ left: `calc(${progress}% - 2.5rem)` }}
@@ -133,7 +134,7 @@ const CycleTimeline: React.FC = () => {
<div className="border-b border-gray-200 overflow-x-auto"> <div className="border-b border-gray-200 overflow-x-auto">
<nav className="-mb-px flex space-x-6 min-w-max" aria-label="Tabs"> <nav className="-mb-px flex space-x-6 min-w-max" aria-label="Tabs">
{cycles.map((cycle) => { {cycles.map((cycle) => {
const config = statusConfig[cycle.status]; const config = statusConfig[resolveCycleStatus(cycle)];
const isActive = cycle.id === selected?.id; const isActive = cycle.id === selected?.id;
return ( return (
<button <button
+6 -5
View File
@@ -12,7 +12,7 @@ import type { View } from '../types/common.ts';
import { useFarm } from '../context/FarmContext.tsx'; import { useFarm } from '../context/FarmContext.tsx';
import KpiCard, { type KpiCardModel } from './KpiCard.tsx'; import KpiCard, { type KpiCardModel } from './KpiCard.tsx';
import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.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 DashboardReportCtas from './dashboard/DashboardReportCtas.tsx';
import DashboardManualInputForm from './dashboard/DashboardManualInputForm.tsx'; import DashboardManualInputForm from './dashboard/DashboardManualInputForm.tsx';
import ReportPreviewOverlay from './dashboard/ReportPreviewOverlay.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 feedTotal = kpis[0]?.feed_total ?? 0;
const feedWeightKg = feedTotal * 50; const feedWeightKg = feedTotal * 50;
const feedInitialBalance = selectedCycle?.feed_initial_balance ?? 0; 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 feedOutTotal = karung?.out_total ?? 0;
const feedSaldo = feedInTotal - feedTotal - feedOutTotal; const feedSaldo = feedInTotal - feedTotal - feedOutTotal;
const feedSaldoColor = const feedSaldoColor =
feedSaldo > 0 ? 'text-green-600' : feedSaldo < 0 ? 'text-red-600' : 'text-gray-600'; feedSaldo > 0 ? 'text-green-600' : feedSaldo < 0 ? 'text-red-600' : 'text-gray-600';
const cycleStatus = resolveCycleStatus(selectedCycle);
return ( return (
<div> <div>
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4 mb-2"> <div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4 mb-2">
<PageHeader <PageHeader
title="Dashboard" 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 <DashboardReportCtas
cycleClosed={selectedCycle.status === 'closed'} cycleClosed={cycleStatus === 'closed'}
hasDailySnapshot={false} hasDailySnapshot={false}
hasEndCycleSnapshot={false} hasEndCycleSnapshot={false}
onOpenDaily={() => { onOpenDaily={() => {
@@ -315,7 +316,7 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
<DashboardManualInputForm <DashboardManualInputForm
cycleId={selectedCycle.id} cycleId={selectedCycle.id}
currentDay={selectedCycle.current_day} 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 type { AIInsight, Cycle, Karung, ManualInput } from '../types/api.ts';
import { useFarm } from '../context/FarmContext.tsx'; import { useFarm } from '../context/FarmContext.tsx';
import { Banner, EmptyFarm, Spinner } from './ui/Feedback.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 FeedStats from './feedSack/FeedStats.tsx';
import FeedSaldoCard from './feedSack/FeedSaldoCard.tsx'; import FeedSaldoCard from './feedSack/FeedSaldoCard.tsx';
import FeedTrendChart from './feedSack/FeedTrendChart.tsx'; import FeedTrendChart from './feedSack/FeedTrendChart.tsx';
@@ -117,6 +117,8 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />; if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
const cycleStatus = resolveCycleStatus(selectedCycle);
return ( return (
<div> <div>
{/* Page Title */} {/* Page Title */}
@@ -137,7 +139,7 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
<button <button
onClick={handleSync} 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" 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 ? ( {syncing ? (
@@ -168,12 +170,12 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
<span className="text-sm font-semibold text-gray-800"> <span className="text-sm font-semibold text-gray-800">
Siklus {formatCycleDateRange(selectedCycle)} Siklus {formatCycleDateRange(selectedCycle)}
</span> </span>
{selectedCycle.status === 'active' && ( {cycleStatus === 'active' && (
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs font-medium rounded"> <span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs font-medium rounded">
Aktif Aktif
</span> </span>
)} )}
{selectedCycle.status === 'closed' && ( {cycleStatus === 'closed' && (
<span className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs font-medium rounded"> <span className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs font-medium rounded">
Selesai Selesai
</span> </span>
@@ -188,7 +190,9 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
{/* Cycle Picker Dropdown */} {/* Cycle Picker Dropdown */}
{showCyclePicker && ( {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"> <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 <button
key={cycle.id} key={cycle.id}
onClick={() => { onClick={() => {
@@ -200,25 +204,26 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
}`} }`}
> >
<div className="flex items-center gap-2 mb-1"> <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="w-2 h-2 bg-blue-500 rounded-full"></span>
)} )}
<span className="text-sm font-semibold text-gray-800"> <span className="text-sm font-semibold text-gray-800">
Siklus {formatCycleDateRange(cycle)} Siklus {formatCycleDateRange(cycle)}
</span> </span>
{cycle.status === 'active' && ( {itemStatus === 'active' && (
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs font-medium rounded"> <span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs font-medium rounded">
Aktif Aktif
</span> </span>
)} )}
{cycle.status === 'closed' && ( {itemStatus === 'closed' && (
<span className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs font-medium rounded"> <span className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs font-medium rounded">
Selesai Selesai
</span> </span>
)} )}
</div> </div>
</button> </button>
))} );
})}
</div> </div>
)} )}
</div> </div>
+2 -2
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { useAuth } from '../context/AuthContext.tsx'; import { useAuth } from '../context/AuthContext.tsx';
import { useFarm } from '../context/FarmContext.tsx'; import { useFarm } from '../context/FarmContext.tsx';
import { formatDateId } from '../utils/format.ts'; import { formatDateId, resolveCycleStatus } from '../utils/format.ts';
type HeaderProps = { type HeaderProps = {
onToggleSidebar: () => void; onToggleSidebar: () => void;
@@ -93,7 +93,7 @@ const Header: React.FC<HeaderProps> = ({
{cycles.map((cycle) => ( {cycles.map((cycle) => (
<option key={cycle.id} value={cycle.id}> <option key={cycle.id} value={cycle.id}>
{formatDateId(cycle.start_date)} ( {formatDateId(cycle.start_date)} (
{cycle.status === 'active' ? 'Aktif' : 'Selesai'}) {resolveCycleStatus(cycle) === 'active' ? 'Aktif' : 'Selesai'})
</option> </option>
))} ))}
</select> </select>
+89 -16
View File
@@ -2,12 +2,27 @@ import React, { useState } from 'react';
import { api, errorMessage } from '../services/apiClient.ts'; import { api, errorMessage } from '../services/apiClient.ts';
import { useAuth } from '../context/AuthContext.tsx'; import { useAuth } from '../context/AuthContext.tsx';
import { useFarm } from '../context/FarmContext.tsx'; import { useFarm } from '../context/FarmContext.tsx';
import type { Cycle } from '../types/api.ts';
import { Banner, PageHeader } from './ui/Feedback.tsx'; 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 = const inputClass =
'border border-gray-300 rounded-md px-3 py-2 text-sm w-full focus:ring-red-500 focus:border-red-500'; '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 SettingsPage: React.FC = () => {
const { user } = useAuth(); const { user } = useAuth();
const { const {
@@ -28,10 +43,39 @@ const SettingsPage: React.FC = () => {
const [siteName, setSiteName] = useState(''); const [siteName, setSiteName] = useState('');
const [kandangName, setKandangName] = useState(''); const [kandangName, setKandangName] = useState('');
const [flockName, setFlockName] = useState(''); const [flockName, setFlockName] = useState('');
const [startDate, setStartDate] = useState(todayIso()); const [editingCycleId, setEditingCycleId] = useState<number | null>(null);
const [endDate, setEndDate] = useState(() => addDaysIso(todayIso(), 34)); const [startDate, setStartDate] = useState(defaultCycleForm().startDate);
const [docInCount, setDocInCount] = useState(20000); const [endDate, setEndDate] = useState(defaultCycleForm().endDate);
const [chickInWeight, setChickInWeight] = useState(35); 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) => { const flash = (msg: string) => {
setNotice(msg); setNotice(msg);
@@ -262,19 +306,24 @@ const SettingsPage: React.FC = () => {
e.preventDefault(); e.preventDefault();
if (!selectedKandangId) return; if (!selectedKandangId) return;
void wrap(async () => { void wrap(async () => {
await api.cycles.create({ const payload = buildCyclePayload();
kandang: selectedKandangId, if (editingCycleId) {
total_days: inclusiveDayCount(startDate, endDate), await api.cycles.update(editingCycleId, payload);
current_day: 1, flash('Siklus diperbarui.');
start_date: startDate, resetCycleForm();
end_date: endDate, } else {
chick_in_weight: chickInWeight, await api.cycles.create({ ...payload, current_day: 1 });
doc_in_count: docInCount,
});
flash('Siklus dibuat.'); 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"> <label className="block text-sm">
Mulai Mulai
<input <input
@@ -322,12 +371,24 @@ const SettingsPage: React.FC = () => {
}} }}
/> />
</label> </label>
<div className="flex gap-2">
<button <button
className="bg-red-600 text-white px-3 py-2 rounded-lg text-sm font-semibold" className="bg-red-600 text-white px-3 py-2 rounded-lg text-sm font-semibold"
disabled={saving} disabled={saving}
> >
Tambah siklus {editingCycleId ? 'Simpan perubahan' : 'Tambah siklus'}
</button> </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> </form>
<ul className="divide-y"> <ul className="divide-y">
{cycles.map((cycle) => ( {cycles.map((cycle) => (
@@ -335,12 +396,23 @@ const SettingsPage: React.FC = () => {
<div className="flex justify-between gap-2"> <div className="flex justify-between gap-2">
<button className="text-left" onClick={() => setSelectedCycleId(cycle.id)}> <button className="text-left" onClick={() => setSelectedCycleId(cycle.id)}>
{formatDateId(cycle.start_date)} – {formatDateId(cycle.end_date)} {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>
<button <button
className="text-xs text-red-500 hover:underline" className="text-xs text-red-500 hover:underline"
onClick={() => onClick={() =>
void wrap(async () => { void wrap(async () => {
if (editingCycleId === cycle.id) resetCycleForm();
await api.cycles.remove(cycle.id); await api.cycles.remove(cycle.id);
flash('Siklus dihapus.'); flash('Siklus dihapus.');
}) })
@@ -349,6 +421,7 @@ const SettingsPage: React.FC = () => {
Hapus Hapus
</button> </button>
</div> </div>
</div>
</li> </li>
))} ))}
</ul> </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 { beforeEach, describe, expect, it, vi } from 'vitest';
import SettingsPage from '../SettingsPage.tsx'; 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(), createKandangMock: vi.fn(),
createFlockMock: vi.fn(), createFlockMock: vi.fn(),
createCycleMock: vi.fn(), createCycleMock: vi.fn(),
updateCycleMock: vi.fn(),
reloadMock: vi.fn().mockResolvedValue(undefined), reloadMock: vi.fn().mockResolvedValue(undefined),
farmState: { farmState: {
sites: [{ id: 7, site_name: 'Site A', user: 99, created_at: '', updated_at: '' }], sites: [{ id: 7, site_name: 'Site A', user: 99, created_at: '', updated_at: '' }],
@@ -49,6 +50,7 @@ vi.mock('../../services/apiClient.ts', () => ({
}, },
cycles: { cycles: {
create: createCycleMock, create: createCycleMock,
update: updateCycleMock,
remove: vi.fn(), remove: vi.fn(),
}, },
}, },
@@ -89,6 +91,7 @@ describe('SettingsPage settings forms', () => {
createKandangMock.mockReset(); createKandangMock.mockReset();
createFlockMock.mockReset(); createFlockMock.mockReset();
createCycleMock.mockReset(); createCycleMock.mockReset();
updateCycleMock.mockReset();
reloadMock.mockClear(); reloadMock.mockClear();
farmState.kandangs = []; farmState.kandangs = [];
farmState.flocks = []; farmState.flocks = [];
@@ -124,6 +127,19 @@ describe('SettingsPage settings forms', () => {
created_at: '', created_at: '',
updated_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 () => { it('submits only backend-supported kandang fields', async () => {
@@ -188,4 +204,46 @@ describe('SettingsPage settings forms', () => {
doc_in_count: 20000, 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, formatDateId,
formatDateRange, formatDateRange,
formatNumber, formatNumber,
resolveCycleStatus,
} from '../../utils/format.ts'; } from '../../utils/format.ts';
import PopulationTrendChart from './PopulationTrendChart.tsx'; import PopulationTrendChart from './PopulationTrendChart.tsx';
@@ -41,7 +42,7 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
const [searchDay, setSearchDay] = useState<string>(''); const [searchDay, setSearchDay] = useState<string>('');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc'); const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
const isClosedCycle = !selectedCycle || selectedCycle.status !== 'active'; const isClosedCycle = !selectedCycle || resolveCycleStatus(selectedCycle) !== 'active';
const loadData = async (cycleId: number) => { const loadData = async (cycleId: number) => {
setLoading(true); setLoading(true);
+1 -1
View File
@@ -16,7 +16,7 @@ const FeedSaldoCard: React.FC<FeedSaldoCardProps> = ({
displayDate, displayDate,
}) => { }) => {
const saldoData = useMemo(() => { const saldoData = useMemo(() => {
const inbound = latest?.in_total ?? feedInitialBalance; const inbound = (latest?.in_total ?? 0) + feedInitialBalance;
const feedUse = latest?.feed_use_total ?? 0; const feedUse = latest?.feed_use_total ?? 0;
const keluar = latest?.out_total ?? 0; const keluar = latest?.out_total ?? 0;
const saldo = inbound - feedUse - keluar; 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 { export function inclusiveDayCount(startIso: string, endIso: string): number {
return Math.max(1, cycleDayForDate(startIso, endIso) + 1); 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';
}