update add feed initial balance on cycle and fix its feature

This commit is contained in:
Alberto-Audrix committed 2026-09-01 14:02:41 +07:00
1 parent 91444faa31
commit 7a9d600aa6
16 files changed
+439 -88

No files matched your search

+1 -1
View File
@@ -105,7 +105,7 @@ Login, Dashboard (KPI + AI insight + Total Pakan section + in-memory Harian/Akhi
Reports are generated in the browser from live `/api/v1/` data and exportable as **PDF** (multi-page A4, block-aware page breaks, compressed JPEG) and **Excel** (Chicken Counting, Weight, Feed In, Feed Use, Feed Out, FCR, EEF sheets with IoT-vs-manual accuracy). Closing the overlay drops the result; nothing is saved on Django.
The **Saldo Awal** feature on Hitung Karung posts `feed_initial_balance` to the cycle and compares the manual feed-in against the IoT-detected "in" for that date with an accuracy figure.
The **Saldo Awal** feature on Hitung Karung posts `feed_initial_balance` to the cycle and compares the manual feed-in against the IoT-detected "in" for that date (from karung-web-admin `GET /api/combined`) with an accuracy figure.
Karung Sync calls karung-web-admin. If that host is down the API often returns 502; stored rows still render.
+1
View File
@@ -51,6 +51,7 @@ See [../DOCKER.md](../DOCKER.md) for ports, cron, and troubleshooting.
## External integrations
- karung-web-admin → `POST /api/v1/karungs/request/` (`KARUNG_WEB_ADMIN_BASE_URL`)
- Saldo Awal IoT compare → karung-web-admin `GET /api/combined`
## Docs
@@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("farms", "0011_flock_belongs_to_kandang"),
]
operations = [
migrations.AddField(
model_name="cycle",
name="feed_initial_balance_date",
field=models.DateField(blank=True, null=True),
),
]
+1
View File
@@ -70,6 +70,7 @@ class Cycle(models.Model):
doc_in_weight = models.IntegerField()
doc_in_count = models.IntegerField()
feed_initial_balance = models.IntegerField(default=0)
feed_initial_balance_date = models.DateField(null=True, blank=True)
status = models.CharField(
max_length=30, choices=STATUS_CHOICES, default=STATUS_ACTIVE, db_index=True
)
+2
View File
@@ -46,6 +46,7 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
"chick_in_weight",
"doc_in_count",
"feed_initial_balance",
"feed_initial_balance_date",
"status",
"created_at",
"updated_at",
@@ -54,6 +55,7 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
"id",
"kandang_name",
"feed_initial_balance",
"feed_initial_balance_date",
"created_at",
"updated_at",
]
+50
View File
@@ -1,4 +1,5 @@
from datetime import date, timedelta
from unittest.mock import patch
from django.test import TestCase
from rest_framework.test import APIClient
@@ -106,6 +107,7 @@ class FarmAPITests(TestCase):
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["feed_initial_balance_date"], start.isoformat())
row = FeedSacks.objects.get(cycle=cycle)
self.assertEqual(row.date, start)
@@ -143,3 +145,51 @@ class FarmAPITests(TestCase):
feed_row.refresh_from_db()
self.assertEqual(feed_row.in_total, 124)
@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):
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,
)
self.client.force_authenticate(self.user)
response = self.client.post(
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
{"date": start.isoformat(), "feed_in_manual": 120},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["iot"], 115)
self.assertEqual(response.data["accuracy"], 95.8)
mock_pull.assert_called_once_with(cycle, start)
@patch("apps.farms.views.pull_initial_balance_iot", return_value=(112, None))
def test_compare_initial_balance_returns_iot_and_accuracy(self, mock_pull):
start = date(2026, 8, 27)
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,
feed_initial_balance=120,
status=Cycle.STATUS_ACTIVE,
)
self.client.force_authenticate(self.user)
response = self.client.get(
f"/api/v1/cycles/{cycle.pk}/initial-balance-compare/",
{"date": start.isoformat(), "manual": 120},
)
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["iot"], 112)
self.assertEqual(response.data["accuracy"], 93.3)
mock_pull.assert_called_once_with(cycle, start)
+64 -28
View File
@@ -1,6 +1,3 @@
from datetime import date, timedelta
from django.core.exceptions import ValidationError
from django.utils.dateparse import parse_date
from rest_framework import permissions, status, viewsets
from rest_framework.decorators import action
@@ -14,7 +11,10 @@ from apps.operations.services.feed_sacks import (
ensure_opening_feed_sack_snapshot,
recompute_feed_sack_in_totals,
)
from apps.operations.services.karung_web import KarungWebError, request_karung
from apps.operations.services.karung_web import (
initial_balance_accuracy,
pull_initial_balance_iot,
)
from apps.operations.services.manual_input import recompute_manual_totals
@@ -85,6 +85,53 @@ class CycleViewSet(viewsets.ModelViewSet):
qs = qs.filter(status=params["status"])
return qs
@action(detail=True, methods=["get"], url_path="initial-balance-compare")
def compare_initial_balance(self, request, pk=None):
"""Pull IoT masuk for a date and compare against the cycle saldo awal."""
cycle = self.get_object()
date_str = request.query_params.get("date")
if not date_str:
return Response({"detail": "date is required."}, status=status.HTTP_400_BAD_REQUEST)
balance_date = parse_date(str(date_str))
if balance_date is None:
return Response(
{"detail": "date must be in YYYY-MM-DD format."},
status=status.HTTP_400_BAD_REQUEST,
)
manual_raw = request.query_params.get("manual")
if manual_raw is None:
feed_in_manual = cycle.feed_initial_balance
else:
try:
feed_in_manual = int(manual_raw)
except (TypeError, ValueError):
return Response(
{"detail": "manual must be an integer."},
status=status.HTTP_400_BAD_REQUEST,
)
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,
"date": balance_date.isoformat(),
"feed_initial_balance": cycle.feed_initial_balance,
"feed_initial_balance_date": (
cycle.feed_initial_balance_date.isoformat()
if cycle.feed_initial_balance_date
else None
),
"manual": feed_in_manual,
"manual_total": manual_row.feed_in_manual_total if manual_row else feed_in_manual,
"iot": iot_in,
"accuracy": initial_balance_accuracy(feed_in_manual, iot_in),
"sync_error": sync_error,
}
)
@action(detail=True, methods=["post"], url_path="initial-balance")
def set_initial_balance(self, request, pk=None):
"""Save the feed-in balance for the cycle (Saldo Awal).
@@ -92,7 +139,7 @@ class CycleViewSet(viewsets.ModelViewSet):
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
the karung-web-admin feed and returns a manual vs IoT accuracy figure.
karung-web-admin ``/api/combined`` and returns a manual vs IoT accuracy figure.
"""
cycle = self.get_object()
@@ -120,25 +167,13 @@ class CycleViewSet(viewsets.ModelViewSet):
)
cycle.feed_initial_balance = feed_in_manual
cycle.save(update_fields=["feed_initial_balance", "updated_at"])
cycle.feed_initial_balance_date = balance_date
cycle.save(
update_fields=["feed_initial_balance", "feed_initial_balance_date", "updated_at"]
)
# Pull IoT "in" at the balance date (karung-web-admin syncs today/yesterday).
feed_sack = FeedSacks.objects.filter(cycle=cycle, date=balance_date).first()
sync_error = None
if feed_sack is None:
try:
today = date.today()
section = None
if balance_date == today:
section = "today"
elif balance_date == today - timedelta(days=1):
section = "yesterday_history"
if section:
request_karung(cycle, section=section)
feed_sack = FeedSacks.objects.filter(cycle=cycle, date=balance_date).first()
except (KarungWebError, ValidationError) as exc:
sync_error = str(exc)
iot_in = feed_sack.in_today if feed_sack is not None else None
# 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(
@@ -152,11 +187,7 @@ class CycleViewSet(viewsets.ModelViewSet):
recompute_feed_sack_in_totals(cycle)
ensure_opening_feed_sack_snapshot(cycle, balance_date)
accuracy = None
if feed_in_manual > 0 and iot_in is not None and iot_in > 0:
accuracy = round(min(feed_in_manual, iot_in) / max(feed_in_manual, iot_in) * 1000) / 10
elif feed_in_manual == 0 and iot_in == 0:
accuracy = 100.0
accuracy = initial_balance_accuracy(feed_in_manual, iot_in)
manual_row = ManualInput.objects.filter(cycle=cycle, date=balance_date).first()
return Response(
@@ -164,6 +195,11 @@ class CycleViewSet(viewsets.ModelViewSet):
"cycle": cycle.pk,
"date": balance_date.isoformat(),
"feed_initial_balance": cycle.feed_initial_balance,
"feed_initial_balance_date": (
cycle.feed_initial_balance_date.isoformat()
if cycle.feed_initial_balance_date
else None
),
"manual": feed_in_manual,
"manual_total": manual_row.feed_in_manual_total if manual_row else feed_in_manual,
"iot": iot_in,
+70 -4
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import re
from datetime import date
from typing import Any
from urllib.parse import urljoin
@@ -18,17 +19,34 @@ class KarungWebError(Exception):
self.status_code = status_code
def masuk_source_for_kandang(kandang_name: str) -> str:
"""Map a kandang label like ``Kandang 1`` to the karung-web-admin source ``K1 - In``."""
match = re.search(r"(\d+)", kandang_name or "")
if not match:
raise ValidationError(
f"Cannot derive IoT masuk source from kandang name: {kandang_name!r}"
)
return f"K{match.group(1)} - In"
def _normalize_source(value: str | None) -> str:
return (value or "").strip().casefold()
class KarungWebClient:
"""Outbound client for karung-web-admin — base URL from settings only."""
PATH = "/api/v1/cycles/active/sections"
SECTIONS_PATH = "/api/v1/cycles/active/sections"
COMBINED_PATH = "/api/combined"
def __init__(self, base_url: str | None = None, timeout: float | None = None):
self.base_url = (base_url or settings.KARUNG_WEB_ADMIN_BASE_URL).rstrip("/")
self.timeout = timeout or settings.KARUNG_WEB_ADMIN_TIMEOUT_SECONDS
def fetch_active_sections(self) -> dict[str, Any]:
url = urljoin(self.base_url + "/", self.PATH.lstrip("/"))
def _get_json(self, path: str) -> dict[str, Any]:
if not self.base_url:
raise KarungWebError("KARUNG_WEB_ADMIN_BASE_URL is not configured")
url = urljoin(self.base_url + "/", path.lstrip("/"))
try:
with httpx.Client(timeout=self.timeout) as client:
response = client.get(url)
@@ -40,11 +58,59 @@ class KarungWebClient:
status_code=response.status_code,
)
payload = response.json()
if not payload.get("success", True):
if not payload.get("success", True) and payload.get("error"):
error = payload.get("error") or {}
raise KarungWebError(error.get("message") or "karung-web-admin returned failure")
return payload.get("data") or payload
def fetch_active_sections(self) -> dict[str, Any]:
return self._get_json(self.SECTIONS_PATH)
def fetch_combined(self) -> dict[str, Any]:
return self._get_json(self.COMBINED_PATH)
def fetch_iot_masuk_for_date(target_date: date, *, kandang_name: str) -> int | None:
"""Return IoT karung-masuk for ``target_date`` and kandang from karung-web-admin /api/combined."""
expected_source = _normalize_source(masuk_source_for_kandang(kandang_name))
target = target_date.isoformat()
client = KarungWebClient()
data = client.fetch_combined()
masuk = data.get("masuk") or {}
for row in masuk.get("history") or []:
if row.get("date") == target and _normalize_source(row.get("source")) == expected_source:
return int(row.get("counter_value") or 0)
if target_date == date.today():
for row in masuk.get("today") or []:
if _normalize_source(row.get("source")) == expected_source:
return int(row.get("karung") or 0)
return None
def initial_balance_accuracy(manual: int, iot_in: int | None) -> float | None:
if manual > 0 and iot_in is not None and iot_in > 0:
return round(min(manual, iot_in) / max(manual, iot_in) * 1000) / 10
if manual == 0 and iot_in == 0:
return 100.0
return None
def pull_initial_balance_iot(cycle: Cycle, balance_date: date) -> tuple[int | None, str | None]:
sync_error = None
iot_in = None
try:
iot_in = fetch_iot_masuk_for_date(
balance_date,
kandang_name=cycle.kandang.kandang_name,
)
except KarungWebError as exc:
sync_error = str(exc)
return iot_in, sync_error
def request_karung(
cycle: Cycle,
@@ -0,0 +1,99 @@
from datetime import date
from unittest.mock import patch
from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings
from apps.operations.services.karung_web import (
KarungWebError,
fetch_iot_masuk_for_date,
initial_balance_accuracy,
masuk_source_for_kandang,
)
COMBINED_SAMPLE = {
"masuk": {
"history": [
{"counter_value": 160, "date": "2026-09-02", "id": 17, "source": "K1 - In"},
{"counter_value": 80, "date": "2026-09-02", "id": 19, "source": "K2 - In"},
{"counter_value": 112, "date": "2026-08-27", "id": 9, "source": "K1 - In"},
{"counter_value": 32, "date": "2026-08-27", "id": 11, "source": "K2 - In"},
],
"today": [
{"karung": 160, "source": "K1 - In"},
{"karung": 80, "source": "K2 - In"},
],
}
}
class MasukSourceForKandangTests(TestCase):
def test_maps_kandang_number_to_iot_source(self):
self.assertEqual(masuk_source_for_kandang("Kandang 1"), "K1 - In")
self.assertEqual(masuk_source_for_kandang("Kandang 2"), "K2 - In")
def test_rejects_kandang_name_without_number(self):
with self.assertRaises(ValidationError):
masuk_source_for_kandang("Utara")
class InitialBalanceAccuracyTests(TestCase):
def test_returns_ratio_when_both_values_positive(self):
self.assertEqual(initial_balance_accuracy(120, 115), 95.8)
def test_returns_100_when_both_zero(self):
self.assertEqual(initial_balance_accuracy(0, 0), 100.0)
def test_returns_none_when_iot_missing(self):
self.assertIsNone(initial_balance_accuracy(120, None))
@override_settings(KARUNG_WEB_ADMIN_BASE_URL="http://karung.internal")
class KarungWebCombinedTests(TestCase):
@patch("apps.operations.services.karung_web.KarungWebClient.fetch_combined")
def test_fetch_iot_masuk_for_date_uses_kandang_specific_history_row(self, mock_fetch):
mock_fetch.return_value = COMBINED_SAMPLE
result = fetch_iot_masuk_for_date(date(2026, 8, 27), kandang_name="Kandang 2")
self.assertEqual(result, 32)
@patch("apps.operations.services.karung_web.KarungWebClient.fetch_combined")
def test_fetch_iot_masuk_for_date_matches_source_case_insensitively(self, mock_fetch):
mock_fetch.return_value = {
"masuk": {
"history": [
{"counter_value": 99, "date": "2026-08-27", "source": "K1 - in"},
]
}
}
result = fetch_iot_masuk_for_date(date(2026, 8, 27), kandang_name="Kandang 1")
self.assertEqual(result, 99)
@patch("apps.operations.services.karung_web.date")
@patch("apps.operations.services.karung_web.KarungWebClient.fetch_combined")
def test_fetch_iot_masuk_for_date_falls_back_to_today_live_rows(self, mock_fetch, mock_date):
mock_date.today.return_value = date(2026, 9, 2)
mock_fetch.return_value = {"masuk": {"history": [], "today": COMBINED_SAMPLE["masuk"]["today"]}}
result = fetch_iot_masuk_for_date(date(2026, 9, 2), kandang_name="Kandang 1")
self.assertEqual(result, 160)
@patch("apps.operations.services.karung_web.KarungWebClient.fetch_combined")
def test_fetch_iot_masuk_for_date_returns_none_when_day_missing(self, mock_fetch):
mock_fetch.return_value = COMBINED_SAMPLE
result = fetch_iot_masuk_for_date(date(2026, 8, 26), kandang_name="Kandang 1")
self.assertIsNone(result)
@patch(
"apps.operations.services.karung_web.KarungWebClient.fetch_combined",
side_effect=KarungWebError("upstream down"),
)
def test_fetch_iot_masuk_for_date_propagates_upstream_errors(self, _mock_fetch):
with self.assertRaises(KarungWebError):
fetch_iot_masuk_for_date(date(2026, 8, 27), kandang_name="Kandang 1")
Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

After

Width:  |  Height:  |  Size: 300 KiB

+1
View File
@@ -88,6 +88,7 @@ CREATE TABLE IF NOT EXISTS cycles (
doc_in_weight INTEGER NOT NULL,
doc_in_count INTEGER NOT NULL,
feed_initial_balance INTEGER NOT NULL DEFAULT 0,
feed_initial_balance_date DATE,
status VARCHAR(30) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
+4 -1
View File
@@ -278,7 +278,10 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
<InitialBalanceCard
cycle={selectedCycle}
onSaved={(saved) => {
updateCycle(saved.cycle, { feed_initial_balance: saved.feed_initial_balance });
updateCycle(saved.cycle, {
feed_initial_balance: saved.feed_initial_balance,
feed_initial_balance_date: saved.feed_initial_balance_date,
});
void reload(selectedCycle.id);
}}
/>
+68 -54
View File
@@ -1,7 +1,8 @@
import React, { useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { api, errorMessage } from '../../services/apiClient.ts';
import type { Cycle, InitialBalanceResult } from '../../types/api.ts';
import { formatNumber } from '../../utils/format.ts';
import InitialBalanceComparison from './InitialBalanceComparison.tsx';
interface InitialBalanceCardProps {
cycle: Cycle;
@@ -9,19 +10,59 @@ interface InitialBalanceCardProps {
}
const InitialBalanceCard: React.FC<InitialBalanceCardProps> = ({ cycle, onSaved }) => {
const [balanceDate, setBalanceDate] = useState(cycle.start_date);
const savedBalanceDate = cycle.feed_initial_balance_date ?? cycle.start_date;
const [balanceDate, setBalanceDate] = useState(savedBalanceDate);
const [manualEntry, setManualEntry] = useState<string>(
cycle.feed_initial_balance > 0 ? String(cycle.feed_initial_balance) : ''
);
const [saving, setSaving] = useState(false);
const [comparing, setComparing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<InitialBalanceResult | null>(null);
const [comparison, setComparison] = useState<InitialBalanceResult | null>(null);
useEffect(() => {
setBalanceDate(cycle.feed_initial_balance_date ?? cycle.start_date);
setManualEntry(cycle.feed_initial_balance > 0 ? String(cycle.feed_initial_balance) : '');
setComparison(null);
setError(null);
}, [cycle.id, cycle.feed_initial_balance, cycle.feed_initial_balance_date, cycle.start_date]);
const manualValue = manualEntry === '' ? null : Number(manualEntry);
const canSave = useMemo(
() => balanceDate !== '' && manualEntry !== '' && Number(manualEntry) >= 0 && !saving,
[balanceDate, manualEntry, saving]
);
const loadComparison = useCallback(async () => {
if (balanceDate === '' || manualValue == null || Number.isNaN(manualValue) || manualValue < 0) {
setComparison(null);
return;
}
setComparing(true);
try {
const compared = await api.cycles.initialBalanceCompare(cycle.id, {
date: balanceDate,
manual: manualValue,
});
setComparison(compared);
} catch (err) {
setComparison(null);
setError(errorMessage(err));
} finally {
setComparing(false);
}
}, [balanceDate, cycle.id, manualValue]);
useEffect(() => {
if (manualValue == null || manualValue < 0) {
setComparison(null);
return;
}
void loadComparison();
}, [loadComparison, manualValue]);
const handleSave = async () => {
if (!canSave) return;
setSaving(true);
@@ -31,7 +72,7 @@ const InitialBalanceCard: React.FC<InitialBalanceCardProps> = ({ cycle, onSaved
date: balanceDate,
feed_in_manual: Number(manualEntry),
});
setResult(saved);
setComparison(saved);
onSaved(saved);
} catch (err) {
setError(errorMessage(err));
@@ -40,17 +81,6 @@ const InitialBalanceCard: React.FC<InitialBalanceCardProps> = ({ cycle, onSaved
}
};
const accuracy = result?.accuracy;
const accuracyLabel = accuracy == null ? 'N/A' : `${formatNumber(accuracy, 1)}%`;
const accuracyColor =
accuracy == null
? 'text-gray-500'
: accuracy >= 95
? 'text-green-600'
: accuracy >= 80
? 'text-yellow-600'
: 'text-red-600';
return (
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 p-6 rounded-xl shadow-sm border-2 border-blue-200">
<div className="flex items-center gap-3 mb-4">
@@ -61,7 +91,7 @@ const InitialBalanceCard: React.FC<InitialBalanceCardProps> = ({ cycle, onSaved
<h2 className="text-xl font-bold text-gray-800">Saldo Awal</h2>
<p className="text-sm text-gray-600">
Masukkan saldo awal karung pakan pada tanggal tertentu, lalu bandingkan dengan deteksi
IoT.
IoT dari karung-web-admin.
</p>
</div>
</div>
@@ -109,16 +139,27 @@ const InitialBalanceCard: React.FC<InitialBalanceCardProps> = ({ cycle, onSaved
)}
<div className="mt-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<button
type="button"
onClick={handleSave}
disabled={!canSave}
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
>
<i className={`fa-solid fa-floppy-disk ${saving ? 'fa-spin' : ''}`} />
<span>{saving ? 'Menyimpan...' : 'Simpan Saldo Awal'}</span>
</button>
{cycle.feed_initial_balance > 0 && result == null && (
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={handleSave}
disabled={!canSave}
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
>
<i className={`fa-solid fa-floppy-disk ${saving ? 'fa-spin' : ''}`} />
<span>{saving ? 'Menyimpan...' : 'Simpan Saldo Awal'}</span>
</button>
<button
type="button"
onClick={() => void loadComparison()}
disabled={manualValue == null || manualValue < 0 || comparing}
className="inline-flex items-center gap-2 rounded-lg border border-blue-300 bg-white px-4 py-2 text-sm font-semibold text-blue-700 transition-colors hover:bg-blue-50 disabled:cursor-not-allowed disabled:text-gray-400"
>
<i className={`fa-solid fa-satellite-dish ${comparing ? 'fa-spin' : ''}`} />
<span>{comparing ? 'Memuat IoT...' : 'Refresh IoT'}</span>
</button>
</div>
{cycle.feed_initial_balance > 0 && comparison == null && (
<p className="text-sm text-gray-600">
Saldo awal tersimpan:{' '}
<strong className="text-blue-700">
@@ -128,34 +169,7 @@ const InitialBalanceCard: React.FC<InitialBalanceCardProps> = ({ cycle, onSaved
)}
</div>
{result && (
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50/50 p-4">
<p className="text-sm font-semibold text-gray-800 mb-3">Perbandingan {result.date}</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-center">
<div className="rounded-lg bg-white border border-gray-200 p-3">
<p className="text-xs font-semibold text-blue-700">Manual</p>
<p className="text-2xl font-bold text-gray-800">
{formatNumber(result.manual)} karung
</p>
</div>
<div className="rounded-lg bg-white border border-gray-200 p-3">
<p className="text-xs font-semibold text-purple-700">IOT</p>
<p className="text-2xl font-bold text-gray-800">
{result.iot == null ? 'N/A' : `${formatNumber(result.iot)} karung`}
</p>
</div>
<div className="rounded-lg bg-white border border-gray-200 p-3">
<p className="text-xs font-semibold text-gray-600">Akurasi</p>
<p className={`text-2xl font-bold ${accuracyColor}`}>{accuracyLabel}</p>
</div>
</div>
{result.sync_error && (
<p className="mt-3 text-xs text-yellow-700 bg-yellow-50 border border-yellow-200 rounded-lg p-2">
Gagal menarik data IoT dari karung-web-admin: {result.sync_error}
</p>
)}
</div>
)}
{comparison && <InitialBalanceComparison result={comparison} loading={comparing} />}
</div>
</div>
);
@@ -0,0 +1,56 @@
import React from 'react';
import type { InitialBalanceResult } from '../../types/api.ts';
import { formatNumber } from '../../utils/format.ts';
interface InitialBalanceComparisonProps {
result: InitialBalanceResult;
loading?: boolean;
}
const InitialBalanceComparison: React.FC<InitialBalanceComparisonProps> = ({
result,
loading = false,
}) => {
const accuracy = result.accuracy;
const accuracyLabel = accuracy == null ? 'N/A' : `${formatNumber(accuracy, 1)}%`;
const accuracyColor =
accuracy == null
? 'text-gray-500'
: accuracy >= 95
? 'text-green-600'
: accuracy >= 80
? 'text-yellow-600'
: 'text-red-600';
return (
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50/50 p-4">
<p className="text-sm font-semibold text-gray-800 mb-3">
Perbandingan {result.date}
{loading ? ' · memuat IoT...' : ''}
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-center">
<div className="rounded-lg bg-white border border-gray-200 p-3">
<p className="text-xs font-semibold text-blue-700">Manual</p>
<p className="text-2xl font-bold text-gray-800">{formatNumber(result.manual)} karung</p>
</div>
<div className="rounded-lg bg-white border border-gray-200 p-3">
<p className="text-xs font-semibold text-purple-700">IOT</p>
<p className="text-2xl font-bold text-gray-800">
{result.iot == null ? 'N/A' : `${formatNumber(result.iot)} karung`}
</p>
</div>
<div className="rounded-lg bg-white border border-gray-200 p-3">
<p className="text-xs font-semibold text-gray-600">Akurasi</p>
<p className={`text-2xl font-bold ${accuracyColor}`}>{accuracyLabel}</p>
</div>
</div>
{result.sync_error && (
<p className="mt-3 text-xs text-yellow-700 bg-yellow-50 border border-yellow-200 rounded-lg p-2">
Gagal menarik data IoT dari karung-web-admin: {result.sync_error}
</p>
)}
</div>
);
};
export default InitialBalanceComparison;
+4
View File
@@ -178,6 +178,10 @@ export const api = {
remove: (id: number) => del(`/cycles/${id}/`),
initialBalance: (id: number, body: { date: string; feed_in_manual: number }) =>
post<InitialBalanceResult>(`/cycles/${id}/initial-balance/`, body),
initialBalanceCompare: (
id: number,
query: { date: string; manual?: number }
) => get<InitialBalanceResult>(`/cycles/${id}/initial-balance-compare/`, query),
},
kpis: {
list: async (query?: CycleFilter) =>
+3
View File
@@ -53,6 +53,7 @@ export type Cycle = {
chick_in_weight: number;
doc_in_count: number;
feed_initial_balance: number;
feed_initial_balance_date?: string | null;
status: CycleStatus;
created_at: string;
updated_at: string;
@@ -232,6 +233,7 @@ export type CycleWrite = {
chick_in_weight: number;
doc_in_count: number;
feed_initial_balance?: number;
feed_initial_balance_date?: string | null;
status?: CycleStatus;
};
@@ -239,6 +241,7 @@ export type InitialBalanceResult = {
cycle: number;
date: string;
feed_initial_balance: number;
feed_initial_balance_date: string | null;
manual: number;
manual_total: number;
iot: number | null;