fix initial balance logic and add edit for cycle data
This commit is contained in:
1 parent
7a9d600aa6
commit
697feb206c
15 files changed
+402
-93
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:
|
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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+106
-33
@@ -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.');
|
||||||
});
|
resetCycleForm();
|
||||||
flash('Siklus dibuat.');
|
}
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{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>
|
||||||
<button
|
<div className="flex gap-2">
|
||||||
className="bg-red-600 text-white px-3 py-2 rounded-lg text-sm font-semibold"
|
<button
|
||||||
disabled={saving}
|
className="bg-red-600 text-white px-3 py-2 rounded-lg text-sm font-semibold"
|
||||||
>
|
disabled={saving}
|
||||||
Tambah siklus
|
>
|
||||||
</button>
|
{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>
|
</form>
|
||||||
<ul className="divide-y">
|
<ul className="divide-y">
|
||||||
{cycles.map((cycle) => (
|
{cycles.map((cycle) => (
|
||||||
@@ -335,19 +396,31 @@ 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">
|
||||||
</button>
|
{resolveCycleStatus(cycle) === 'active' ? 'Aktif' : 'Selesai'} ·{' '}
|
||||||
<button
|
{cycle.doc_in_count} ekor · {cycle.chick_in_weight} g
|
||||||
className="text-xs text-red-500 hover:underline"
|
</span>
|
||||||
onClick={() =>
|
|
||||||
void wrap(async () => {
|
|
||||||
await api.cycles.remove(cycle.id);
|
|
||||||
flash('Siklus dihapus.');
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Hapus
|
|
||||||
</button>
|
</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.');
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Hapus
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
Reference in new issue
Block a user