fix initial balance logic and add edit for cycle data
This commit is contained in:
1 parent
7a9d600aa6
commit
697feb206c
15 files changed
+384
-75
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
Reference in new issue
Block a user