update applying current ai insight from dashboard-cpsp
This commit is contained in:
1 parent
31272035e9
commit
b87f20e3b3
27 files changed
+5300
-343
No files matched your search
@@ -0,0 +1,77 @@
|
|||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("farms", "0020_kandang_flock_source_ids"),
|
||||||
|
("operations", "0011_aiinsight_unique_cycle_date_section_session"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveConstraint(
|
||||||
|
model_name="aiinsight",
|
||||||
|
name="uniq_ai_insight_cycle_date_section_session",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="aiinsight",
|
||||||
|
name="section",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="aiinsight",
|
||||||
|
name="session",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="aiinsight",
|
||||||
|
name="kandang",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="ai_insights",
|
||||||
|
to="farms.kandang",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="aiinsight",
|
||||||
|
name="topic",
|
||||||
|
field=models.CharField(blank=True, db_index=True, default="", max_length=64),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="aiinsight",
|
||||||
|
name="report_type",
|
||||||
|
field=models.CharField(blank=True, db_index=True, default="page", max_length=32),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="aiinsight",
|
||||||
|
name="report_period",
|
||||||
|
field=models.CharField(blank=True, db_index=True, default="current", max_length=64),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="aiinsight",
|
||||||
|
name="summary",
|
||||||
|
field=models.TextField(blank=True, default=""),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="aiinsight",
|
||||||
|
name="citations",
|
||||||
|
field=models.TextField(blank=True, default="[]"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="aiinsight",
|
||||||
|
name="alert",
|
||||||
|
field=models.CharField(blank=True, default="", max_length=100),
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="aiinsight",
|
||||||
|
options={"ordering": ["-date", "-created_at"]},
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="aiinsight",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("cycle", "kandang", "topic", "report_type", "report_period"),
|
||||||
|
name="uniq_ai_insight_cache_scope",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -29,21 +29,31 @@ class IotPanel(models.Model):
|
|||||||
class AIInsight(models.Model):
|
class AIInsight(models.Model):
|
||||||
date = models.DateField()
|
date = models.DateField()
|
||||||
insight_text = models.TextField()
|
insight_text = models.TextField()
|
||||||
alert = models.CharField(max_length=100)
|
alert = models.CharField(max_length=100, blank=True, default="")
|
||||||
section = models.CharField(max_length=100)
|
|
||||||
session = models.CharField(max_length=100)
|
|
||||||
cycle = models.ForeignKey(Cycle, on_delete=models.CASCADE, related_name="ai_insights")
|
cycle = models.ForeignKey(Cycle, on_delete=models.CASCADE, related_name="ai_insights")
|
||||||
|
kandang = models.ForeignKey(
|
||||||
|
"farms.Kandang",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="ai_insights",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
topic = models.CharField(max_length=64, blank=True, default="", db_index=True)
|
||||||
|
report_type = models.CharField(max_length=32, blank=True, default="page", db_index=True)
|
||||||
|
report_period = models.CharField(max_length=64, blank=True, default="current", db_index=True)
|
||||||
|
summary = models.TextField(blank=True, default="")
|
||||||
|
citations = models.TextField(blank=True, default="[]")
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "ai_insight"
|
db_table = "ai_insight"
|
||||||
ordering = ["-date"]
|
ordering = ["-date", "-created_at"]
|
||||||
constraints = [
|
constraints = [
|
||||||
models.UniqueConstraint(
|
models.UniqueConstraint(
|
||||||
fields=["cycle", "date", "section", "session"],
|
fields=["cycle", "kandang", "topic", "report_type", "report_period"],
|
||||||
name="uniq_ai_insight_cycle_date_section_session",
|
name="uniq_ai_insight_cache_scope",
|
||||||
),
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -136,3 +136,18 @@ class AIInsightSerializer(PkAsIdMixin, CycleContextMixin, serializers.ModelSeria
|
|||||||
model = AIInsight
|
model = AIInsight
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
read_only_fields = ["id", "created_at", "updated_at"]
|
read_only_fields = ["id", "created_at", "updated_at"]
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
import json
|
||||||
|
data = super().to_representation(instance)
|
||||||
|
raw_citations = getattr(instance, "citations", "")
|
||||||
|
if isinstance(raw_citations, list):
|
||||||
|
data["citations"] = raw_citations
|
||||||
|
elif isinstance(raw_citations, str) and raw_citations.strip():
|
||||||
|
try:
|
||||||
|
data["citations"] = json.loads(raw_citations)
|
||||||
|
except Exception:
|
||||||
|
data["citations"] = []
|
||||||
|
else:
|
||||||
|
data["citations"] = []
|
||||||
|
return data
|
||||||
@@ -236,9 +236,118 @@ class IotPanelViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
|
|
||||||
class AIInsightViewSet(CycleScopedViewSet):
|
class AIInsightViewSet(CycleScopedViewSet):
|
||||||
queryset = AIInsight.objects.select_related("cycle").all()
|
queryset = AIInsight.objects.select_related("cycle", "kandang").all()
|
||||||
serializer_class = AIInsightSerializer
|
serializer_class = AIInsightSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
qs = super().get_queryset()
|
||||||
|
params = self.request.query_params
|
||||||
|
if params.get("kandang_id"):
|
||||||
|
qs = qs.filter(kandang_id=params["kandang_id"])
|
||||||
|
if params.get("topic"):
|
||||||
|
qs = qs.filter(topic=params["topic"])
|
||||||
|
if params.get("report_type"):
|
||||||
|
qs = qs.filter(report_type=params["report_type"])
|
||||||
|
if params.get("report_period"):
|
||||||
|
qs = qs.filter(report_period=params["report_period"])
|
||||||
|
return qs
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"], url_path="cached")
|
||||||
|
def cached(self, request):
|
||||||
|
import json
|
||||||
|
params = request.query_params
|
||||||
|
cycle_id = params.get("cycle_id")
|
||||||
|
kandang_id = params.get("kandang_id")
|
||||||
|
topic = params.get("topic")
|
||||||
|
if not cycle_id or not kandang_id or not topic:
|
||||||
|
return Response(
|
||||||
|
{"detail": "cycle_id, kandang_id, and topic are required."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
report_type = params.get("report_type") or "page"
|
||||||
|
report_period = params.get("report_period") or "current"
|
||||||
|
row = (
|
||||||
|
AIInsight.objects.filter(
|
||||||
|
cycle_id=cycle_id,
|
||||||
|
kandang_id=kandang_id,
|
||||||
|
topic=topic,
|
||||||
|
report_type=report_type,
|
||||||
|
report_period=report_period,
|
||||||
|
)
|
||||||
|
.order_by("-updated_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
return Response({"detail": "No cached insight."}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
raw_cites = row.citations
|
||||||
|
if isinstance(raw_cites, list):
|
||||||
|
cites = raw_cites
|
||||||
|
elif isinstance(raw_cites, str) and raw_cites.strip():
|
||||||
|
try:
|
||||||
|
cites = json.loads(raw_cites)
|
||||||
|
except Exception:
|
||||||
|
cites = []
|
||||||
|
else:
|
||||||
|
cites = []
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
"success": True,
|
||||||
|
"insight_text": row.insight_text,
|
||||||
|
"summary": row.summary,
|
||||||
|
"alert": row.alert,
|
||||||
|
"citations": cites,
|
||||||
|
"topic": row.topic,
|
||||||
|
"report_type": row.report_type,
|
||||||
|
"report_period": row.report_period,
|
||||||
|
})
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"], url_path="generate")
|
||||||
|
def generate(self, request):
|
||||||
|
params = request.data or {}
|
||||||
|
cycle_id = params.get("cycle_id")
|
||||||
|
kandang_id = params.get("kandang_id")
|
||||||
|
topic = params.get("topic")
|
||||||
|
if not cycle_id or not kandang_id or not topic:
|
||||||
|
return Response(
|
||||||
|
{"detail": "cycle_id, kandang_id, and topic are required."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
report_type = params.get("report_type") or "page"
|
||||||
|
report_period = params.get("report_period") or "current"
|
||||||
|
row = (
|
||||||
|
AIInsight.objects.filter(
|
||||||
|
cycle_id=cycle_id,
|
||||||
|
kandang_id=kandang_id,
|
||||||
|
topic=topic,
|
||||||
|
report_type=report_type,
|
||||||
|
report_period=report_period,
|
||||||
|
)
|
||||||
|
.order_by("-updated_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if row is not None:
|
||||||
|
import json
|
||||||
|
raw_cites = row.citations
|
||||||
|
try:
|
||||||
|
cites = json.loads(raw_cites) if isinstance(raw_cites, str) else (raw_cites or [])
|
||||||
|
except Exception:
|
||||||
|
cites = []
|
||||||
|
return Response({
|
||||||
|
"success": True,
|
||||||
|
"insight_text": row.insight_text,
|
||||||
|
"summary": row.summary,
|
||||||
|
"alert": row.alert,
|
||||||
|
"citations": cites,
|
||||||
|
"topic": row.topic,
|
||||||
|
"report_type": row.report_type,
|
||||||
|
"report_period": row.report_period,
|
||||||
|
})
|
||||||
|
return Response(
|
||||||
|
{"detail": "Insight belum tersedia pada mirror HQ. Pastikan insight telah di-generate pada site dashboard dan disinkronkan."},
|
||||||
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class HealthView(APIView):
|
class HealthView(APIView):
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
|
|||||||
@@ -421,21 +421,36 @@ def _upsert_kpis(cycle: Cycle, rows: list[dict[str, Any]]) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _upsert_ai_insights(cycle: Cycle, rows: list[dict[str, Any]]) -> int:
|
def _upsert_ai_insights(cycle: Cycle, rows: list[dict[str, Any]]) -> int:
|
||||||
|
import json
|
||||||
count = 0
|
count = 0
|
||||||
for raw in rows:
|
for raw in rows:
|
||||||
row_date = _as_date(raw.get("date"))
|
row_date = _as_date(raw.get("date")) or cycle.start_date
|
||||||
section = str(raw.get("section") or "")[:100]
|
topic = str(raw.get("topic") or "")[:64]
|
||||||
session = str(raw.get("session") or "")[:100]
|
report_type = str(raw.get("report_type") or "page")[:32]
|
||||||
if row_date is None:
|
report_period = str(raw.get("report_period") or "current")[:64]
|
||||||
continue
|
insight_text = str(raw.get("insight_text") or "")
|
||||||
|
summary = str(raw.get("summary") or "")
|
||||||
|
alert = str(raw.get("alert") or "")[:100]
|
||||||
|
raw_citations = raw.get("citations")
|
||||||
|
if isinstance(raw_citations, (list, dict)):
|
||||||
|
citations_str = json.dumps(raw_citations, ensure_ascii=False)
|
||||||
|
elif isinstance(raw_citations, str):
|
||||||
|
citations_str = raw_citations
|
||||||
|
else:
|
||||||
|
citations_str = "[]"
|
||||||
|
|
||||||
AIInsight.objects.update_or_create(
|
AIInsight.objects.update_or_create(
|
||||||
cycle=cycle,
|
cycle=cycle,
|
||||||
date=row_date,
|
kandang=cycle.kandang,
|
||||||
section=section,
|
topic=topic,
|
||||||
session=session,
|
report_type=report_type,
|
||||||
|
report_period=report_period,
|
||||||
defaults={
|
defaults={
|
||||||
"insight_text": str(raw.get("insight_text") or ""),
|
"date": row_date,
|
||||||
"alert": str(raw.get("alert") or "")[:100],
|
"insight_text": insight_text,
|
||||||
|
"summary": summary,
|
||||||
|
"alert": alert,
|
||||||
|
"citations": citations_str,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
count += 1
|
count += 1
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
+117
-33
@@ -1,7 +1,6 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { api, ApiError, errorMessage } from '../services/apiClient.ts';
|
import { api, ApiError, errorMessage } from '../services/apiClient.ts';
|
||||||
import type {
|
import type {
|
||||||
AIInsight,
|
|
||||||
ChickenCounting,
|
ChickenCounting,
|
||||||
ChickenWeight,
|
ChickenWeight,
|
||||||
Karung,
|
Karung,
|
||||||
@@ -21,12 +20,20 @@ import {
|
|||||||
} from '../utils/format.ts';
|
} from '../utils/format.ts';
|
||||||
import { throughVisibleDate } from '../utils/visibility.ts';
|
import { throughVisibleDate } from '../utils/visibility.ts';
|
||||||
import { getCountingAccuracyWarning } from '../utils/countingAccuracyWarning.ts';
|
import { getCountingAccuracyWarning } from '../utils/countingAccuracyWarning.ts';
|
||||||
|
import { getTempStandardByDay } from '../utils/cp707EnvStandards.ts';
|
||||||
|
import {
|
||||||
|
buildDashboardInsightContext,
|
||||||
|
buildDashboardInsightMetrics,
|
||||||
|
buildIotDayAggregates,
|
||||||
|
type EndCycleIotDayAggregate,
|
||||||
|
} from '../utils/insightContextBuilders.ts';
|
||||||
import DashboardReportCtas from './dashboard/DashboardReportCtas.tsx';
|
import DashboardReportCtas from './dashboard/DashboardReportCtas.tsx';
|
||||||
import ReportPreviewOverlay from './dashboard/ReportPreviewOverlay.tsx';
|
import ReportPreviewOverlay from './dashboard/ReportPreviewOverlay.tsx';
|
||||||
import CountingAccuracyWarningBanner from './counting/CountingAccuracyWarningBanner.tsx';
|
import CountingAccuracyWarningBanner from './counting/CountingAccuracyWarningBanner.tsx';
|
||||||
import PendingCloseQueue from './PendingCloseQueue.tsx';
|
import PendingCloseQueue from './PendingCloseQueue.tsx';
|
||||||
import KandangRankingTop5 from './dashboard/KandangRankingTop5.tsx';
|
import KandangRankingTop5 from './dashboard/KandangRankingTop5.tsx';
|
||||||
import type { DashboardReportType } from '../types/dashboardReport.ts';
|
import type { DashboardReportType } from '../types/dashboardReport.ts';
|
||||||
|
import { AiInsightCard } from './AiInsightCard.tsx';
|
||||||
|
|
||||||
type Props = { setView: (view: View) => void };
|
type Props = { setView: (view: View) => void };
|
||||||
|
|
||||||
@@ -35,7 +42,7 @@ function chronological<T>(rows: T[]): T[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Dashboard: React.FC<Props> = ({ setView }) => {
|
const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||||
const { selectedCycle, selectedKandang, selectedSite } = useFarm();
|
const { selectedCycle, selectedKandang, selectedSite, selectedKandangId } = useFarm();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [kpis, setKpis] = useState<KPI[]>([]);
|
const [kpis, setKpis] = useState<KPI[]>([]);
|
||||||
@@ -43,7 +50,7 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
|||||||
const [manualInputs, setManualInputs] = useState<ManualInput[]>([]);
|
const [manualInputs, setManualInputs] = useState<ManualInput[]>([]);
|
||||||
const [weights, setWeights] = useState<ChickenWeight[]>([]);
|
const [weights, setWeights] = useState<ChickenWeight[]>([]);
|
||||||
const [karung, setKarung] = useState<Karung | null>(null);
|
const [karung, setKarung] = useState<Karung | null>(null);
|
||||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
const [iotDayAggregates, setIotDayAggregates] = useState<EndCycleIotDayAggregate[]>([]);
|
||||||
const [reportOpen, setReportOpen] = useState(false);
|
const [reportOpen, setReportOpen] = useState(false);
|
||||||
const [reportType, setReportType] = useState<DashboardReportType>('daily');
|
const [reportType, setReportType] = useState<DashboardReportType>('daily');
|
||||||
|
|
||||||
@@ -54,7 +61,7 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
|||||||
setManualInputs([]);
|
setManualInputs([]);
|
||||||
setWeights([]);
|
setWeights([]);
|
||||||
setKarung(null);
|
setKarung(null);
|
||||||
setInsights([]);
|
setIotDayAggregates([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const cycleId = selectedCycle.id;
|
const cycleId = selectedCycle.id;
|
||||||
@@ -63,12 +70,11 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const [kpiRows, countingRows, manualRows, weightRows, insightRows] = await Promise.all([
|
const [kpiRows, countingRows, manualRows, weightRows] = await Promise.all([
|
||||||
api.kpis.list({ cycle_id: cycleId }),
|
api.kpis.list({ cycle_id: cycleId }),
|
||||||
api.countings.list({ cycle_id: cycleId }),
|
api.countings.list({ cycle_id: cycleId }),
|
||||||
api.manualInputs.list({ cycle_id: cycleId }),
|
api.manualInputs.list({ cycle_id: cycleId }),
|
||||||
api.weights.list({ cycle_id: cycleId }),
|
api.weights.list({ cycle_id: cycleId }),
|
||||||
api.insights.list({ cycle_id: cycleId }),
|
|
||||||
]);
|
]);
|
||||||
let latestKarung: Karung | null = null;
|
let latestKarung: Karung | null = null;
|
||||||
try {
|
try {
|
||||||
@@ -82,7 +88,31 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
|||||||
setManualInputs(manualRows);
|
setManualInputs(manualRows);
|
||||||
setWeights(weightRows);
|
setWeights(weightRows);
|
||||||
setKarung(latestKarung);
|
setKarung(latestKarung);
|
||||||
setInsights(insightRows);
|
|
||||||
|
// Best-effort IoT aggregates for end-cycle root cause (non-blocking on failure).
|
||||||
|
try {
|
||||||
|
const flockId =
|
||||||
|
kpiRows.find((row) => row.flock_id != null)?.flock_id ??
|
||||||
|
(selectedKandang
|
||||||
|
? (await api.flocks.list({ kandang_id: selectedKandang.id }))[0]?.id
|
||||||
|
: null);
|
||||||
|
if (flockId != null) {
|
||||||
|
const panels = await api.panelIot.list({ flock_id: flockId });
|
||||||
|
if (!cancelled) {
|
||||||
|
setIotDayAggregates(
|
||||||
|
buildIotDayAggregates({
|
||||||
|
rows: panels,
|
||||||
|
cycleStartDate: selectedCycle.start_date,
|
||||||
|
getTempTarget: getTempStandardByDay,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!cancelled) setIotDayAggregates([]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setIotDayAggregates([]);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) setError(errorMessage(err));
|
if (!cancelled) setError(errorMessage(err));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -92,7 +122,72 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [selectedCycle]);
|
}, [selectedCycle, selectedKandang]);
|
||||||
|
|
||||||
|
const insightMetrics = useMemo(() => {
|
||||||
|
if (selectedKandangId == null) return null;
|
||||||
|
const visibleThrough = selectedCycle?.visible_through_date;
|
||||||
|
const visibleKpis = throughVisibleDate(kpis, visibleThrough);
|
||||||
|
const latest = visibleKpis[0] ?? null;
|
||||||
|
const anchorDate = latest?.date ?? null;
|
||||||
|
const weightsThroughAnchor = anchorDate
|
||||||
|
? throughVisibleDate(weights, visibleThrough).filter((row) => row.date <= anchorDate)
|
||||||
|
: throughVisibleDate(weights, visibleThrough);
|
||||||
|
|
||||||
|
return buildDashboardInsightMetrics({
|
||||||
|
kpis: visibleKpis,
|
||||||
|
weights: weightsThroughAnchor,
|
||||||
|
currentDay: latest?.age ?? selectedCycle?.current_day ?? null,
|
||||||
|
docInCount: selectedCycle?.doc_in_count ?? null,
|
||||||
|
pakanKumulatifFallback: karung?.feed_use_total ?? latest?.feed_total ?? null,
|
||||||
|
});
|
||||||
|
}, [selectedKandangId, selectedCycle, kpis, weights, karung]);
|
||||||
|
|
||||||
|
const insightContext = useMemo(() => {
|
||||||
|
if (selectedKandangId == null) return null;
|
||||||
|
const visibleThrough = selectedCycle?.visible_through_date;
|
||||||
|
const visibleKpis = throughVisibleDate(kpis, visibleThrough);
|
||||||
|
const latest = visibleKpis[0] ?? null;
|
||||||
|
const anchorDate = latest?.date ?? null;
|
||||||
|
const weightsThroughAnchor = anchorDate
|
||||||
|
? throughVisibleDate(weights, visibleThrough).filter((row) => row.date <= anchorDate)
|
||||||
|
: [];
|
||||||
|
const weightOnAnchor = anchorDate
|
||||||
|
? (weightsThroughAnchor.find((row) => row.date === anchorDate) ?? weightsThroughAnchor[0])
|
||||||
|
: null;
|
||||||
|
const bobotAvg =
|
||||||
|
latest && latest.iot_weight > 0
|
||||||
|
? latest.iot_weight
|
||||||
|
: (weightOnAnchor?.average_weight ?? null);
|
||||||
|
|
||||||
|
return buildDashboardInsightContext({
|
||||||
|
kandangId: selectedKandangId,
|
||||||
|
kandangName: selectedKandang?.kandang_name,
|
||||||
|
cycleId: selectedCycle?.id,
|
||||||
|
hari_ke: latest?.age ?? selectedCycle?.current_day ?? null,
|
||||||
|
totalDays: selectedCycle?.total_days ?? null,
|
||||||
|
fcr_terakhir: latest?.fcr ?? null,
|
||||||
|
eef_terakhir: latest?.eef ?? null,
|
||||||
|
bobot_avg_gram: bobotAvg,
|
||||||
|
mortalitas_hari_ini_ekor: latest?.mortality ?? null,
|
||||||
|
mortalitas_kumulatif_persen:
|
||||||
|
latest?.chicken_life_percentage == null
|
||||||
|
? null
|
||||||
|
: Math.round((100 - latest.chicken_life_percentage) * 100) / 100,
|
||||||
|
karung_dituang_hari_ini:
|
||||||
|
latest != null ? latest.feed : (karung?.feed_use_today ?? karung?.out_today ?? null),
|
||||||
|
pakan_kumulatif_karung: karung?.feed_use_total ?? latest?.feed_total ?? null,
|
||||||
|
ayam_hidup_ekor:
|
||||||
|
latest != null ? Math.max(0, latest.chicken_life - (latest.harvest_total ?? 0)) : null,
|
||||||
|
persen_hidup: latest?.chicken_life_percentage ?? null,
|
||||||
|
doc_in_ekor: selectedCycle?.doc_in_count ?? null,
|
||||||
|
kematian_kumulatif_ekor: latest?.mortality_total ?? null,
|
||||||
|
panen_kumulatif_ekor: latest?.harvest_total ?? null,
|
||||||
|
tonase_panen_kg:
|
||||||
|
latest?.harvest_weight_total != null ? latest.harvest_weight_total / 1000 : null,
|
||||||
|
uniformity_persen: weightOnAnchor?.uniformity ?? null,
|
||||||
|
});
|
||||||
|
}, [selectedKandangId, selectedKandang, selectedCycle, kpis, weights, karung]);
|
||||||
|
|
||||||
const cards: KpiCardModel[] = useMemo(() => {
|
const cards: KpiCardModel[] = useMemo(() => {
|
||||||
// All cards share the latest complete KPI day (not calendar current_day / live IoT).
|
// All cards share the latest complete KPI day (not calendar current_day / live IoT).
|
||||||
@@ -352,32 +447,21 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
|||||||
</div>
|
</div>
|
||||||
<KandangRankingTop5 setView={setView} />
|
<KandangRankingTop5 setView={setView} />
|
||||||
<section className="mt-8">
|
<section className="mt-8">
|
||||||
<h2 className="text-lg font-bold text-gray-800 mb-3">AI Insight</h2>
|
{selectedKandangId != null ? (
|
||||||
{insights.length === 0 ? (
|
<AiInsightCard
|
||||||
<Banner>Belum ada insight untuk siklus ini.</Banner>
|
kandangId={selectedKandangId}
|
||||||
|
context={insightContext ?? undefined}
|
||||||
|
dashboardMetrics={insightMetrics}
|
||||||
|
iotDayAggregates={iotDayAggregates}
|
||||||
|
page="dashboard"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="bg-white border border-gray-100 rounded-xl shadow-sm p-6">
|
||||||
{insights.slice(0, 4).map((item) => (
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<article
|
<i className="fa-solid fa-brain text-red-600 text-xl" />
|
||||||
key={item.id}
|
<h2 className="text-lg font-bold text-gray-900">AI Insight</h2>
|
||||||
className="bg-white rounded-xl shadow-sm p-5 border border-gray-100"
|
</div>
|
||||||
>
|
<Banner>Pilih satu kandang untuk melihat AI Insight.</Banner>
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase tracking-wide text-gray-400">
|
|
||||||
{item.section}
|
|
||||||
</p>
|
|
||||||
<h3 className="font-semibold text-gray-800 mt-1">
|
|
||||||
{item.alert || 'Insight'}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-gray-400">{item.session}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-600 mt-3 whitespace-pre-wrap">
|
|
||||||
{item.insight_text}
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import {
|
|||||||
YAxis,
|
YAxis,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { api, errorMessage } from '../services/apiClient.ts';
|
import { api, errorMessage } from '../services/apiClient.ts';
|
||||||
import type { AIInsight, KPI } from '../types/api.ts';
|
import type { KPI } from '../types/api.ts';
|
||||||
import { useFarm } from '../context/FarmContext.tsx';
|
import { useFarm } from '../context/FarmContext.tsx';
|
||||||
import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx';
|
import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx';
|
||||||
import { formatNumber, formatRatio } from '../utils/format.ts';
|
import { formatNumber, formatRatio } from '../utils/format.ts';
|
||||||
|
import { buildEefInsightContext } from '../utils/insightContextBuilders.ts';
|
||||||
|
import { PageAiInsight } from './shared/PageAiInsight.tsx';
|
||||||
|
|
||||||
type EefRow = {
|
type EefRow = {
|
||||||
day: number;
|
day: number;
|
||||||
@@ -164,9 +166,8 @@ const SupportingMetricChart: React.FC<{
|
|||||||
);
|
);
|
||||||
|
|
||||||
const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||||
const { selectedCycle } = useFarm();
|
const { selectedCycle, selectedKandang } = useFarm();
|
||||||
const [rows, setRows] = useState<KPI[]>([]);
|
const [rows, setRows] = useState<KPI[]>([]);
|
||||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||||
@@ -175,12 +176,8 @@ const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
|
|||||||
if (!selectedCycle) return;
|
if (!selectedCycle) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const [kpiRows, insightRows] = await Promise.all([
|
const kpiRows = await api.kpis.list({ cycle_id: selectedCycle.id });
|
||||||
api.kpis.list({ cycle_id: selectedCycle.id }),
|
|
||||||
api.insights.list({ cycle_id: selectedCycle.id }),
|
|
||||||
]);
|
|
||||||
setRows(kpiRows);
|
setRows(kpiRows);
|
||||||
setInsights(insightRows);
|
|
||||||
setLastUpdated(new Date());
|
setLastUpdated(new Date());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(errorMessage(err));
|
setError(errorMessage(err));
|
||||||
@@ -190,7 +187,6 @@ const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedCycle) {
|
if (!selectedCycle) {
|
||||||
setRows([]);
|
setRows([]);
|
||||||
setInsights([]);
|
|
||||||
setLastUpdated(null);
|
setLastUpdated(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -252,21 +248,16 @@ const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
|
|||||||
|
|
||||||
const latestEef = getLatestValid('eef');
|
const latestEef = getLatestValid('eef');
|
||||||
|
|
||||||
const eefInsights = useMemo(() => {
|
const insightContext = useMemo(() => {
|
||||||
const matched = insights.filter((item) => {
|
if (!selectedKandang) return null;
|
||||||
const text =
|
return buildEefInsightContext({
|
||||||
`${item.section ?? ''} ${item.alert ?? ''} ${item.insight_text ?? ''}`.toLowerCase();
|
kandangId: selectedKandang.id,
|
||||||
return (
|
kandangName: selectedKandang.kandang_name,
|
||||||
text.includes('eef') ||
|
cycleId: selectedCycle?.id,
|
||||||
text.includes('fcr') ||
|
totalDays: selectedCycle?.total_days ?? null,
|
||||||
text.includes('hidup') ||
|
eefRows,
|
||||||
text.includes('live') ||
|
|
||||||
text.includes('panen') ||
|
|
||||||
text.includes('bw')
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
return (matched.length > 0 ? matched : insights).slice(0, 4);
|
}, [selectedKandang, selectedCycle, eefRows]);
|
||||||
}, [insights]);
|
|
||||||
|
|
||||||
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
|
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
|
||||||
|
|
||||||
@@ -286,37 +277,64 @@ const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section>
|
{selectedKandang && insightContext ? (
|
||||||
<h2 className="text-lg font-bold text-gray-800 mb-3">
|
<PageAiInsight
|
||||||
<i className="fa-solid fa-brain text-red-600 mr-2" />
|
topic="eef"
|
||||||
AI Insight EEF
|
cacheKey={`eef-k${selectedKandang.id}-c${selectedCycle.id}-rows${eefRows.length}-eef${String(latestEef ?? 'null').replace('.', '_')}`}
|
||||||
</h2>
|
contextData={insightContext}
|
||||||
{loading ? (
|
chartSpec={{
|
||||||
<Spinner />
|
type: 'trend',
|
||||||
) : eefInsights.length === 0 ? (
|
historyKey: 'tren_eef_harian',
|
||||||
<Banner>Belum ada insight untuk siklus ini.</Banner>
|
title: 'Tren EEF',
|
||||||
) : (
|
series: [{ field: 'eef', label: 'EEF', color: '#dc2626' }],
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
}}
|
||||||
{eefInsights.map((item) => (
|
scalarScope={{
|
||||||
<article
|
historyKey: 'tren_eef_harian',
|
||||||
key={item.id}
|
derive: {
|
||||||
className="bg-white rounded-xl shadow-sm p-5 border border-gray-100"
|
eefTerakhir_indeksTanpaSatuan: {
|
||||||
>
|
field: 'eef',
|
||||||
<div className="flex items-start justify-between gap-3">
|
agg: 'lastValid',
|
||||||
<div>
|
replaces: 'eef_terakhir',
|
||||||
<p className="text-xs uppercase tracking-wide text-gray-400">{item.section}</p>
|
},
|
||||||
<h3 className="font-semibold text-gray-800 mt-1">{item.alert || 'Insight'}</h3>
|
fcrTerakhir_rasio: {
|
||||||
</div>
|
field: 'fcr',
|
||||||
<span className="text-xs text-gray-400">{item.session}</span>
|
agg: 'lastValid',
|
||||||
</div>
|
replaces: 'fcr_terakhir',
|
||||||
<p className="text-sm text-gray-600 mt-3 whitespace-pre-wrap">
|
},
|
||||||
{item.insight_text}
|
persenHidupTerakhir_persen: {
|
||||||
</p>
|
field: 'persen_hidup',
|
||||||
</article>
|
agg: 'lastValid',
|
||||||
))}
|
replaces: 'persen_hidup',
|
||||||
</div>
|
},
|
||||||
)}
|
bobotPanenTerakhir_gram: {
|
||||||
</section>
|
field: 'bw_panen',
|
||||||
|
agg: 'lastValid',
|
||||||
|
replaces: 'bw_panen_gram',
|
||||||
|
},
|
||||||
|
umurPanenTerakhir_hari: {
|
||||||
|
field: 'umur_panen_hari',
|
||||||
|
agg: 'lastValid',
|
||||||
|
replaces: 'umur_panen_rata_hari',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
drop: [
|
||||||
|
'eef_terakhir',
|
||||||
|
'fcr_terakhir',
|
||||||
|
'persen_hidup',
|
||||||
|
'bw_panen_gram',
|
||||||
|
'umur_panen_rata_hari',
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-bold text-gray-800 mb-3">
|
||||||
|
<i className="fa-solid fa-brain text-red-600 mr-2" />
|
||||||
|
AI Insight EEF
|
||||||
|
</h2>
|
||||||
|
<Banner>Pilih kandang untuk melihat AI Insight.</Banner>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Spinner />
|
<Spinner />
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import {
|
|||||||
YAxis,
|
YAxis,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { api, errorMessage } from '../services/apiClient.ts';
|
import { api, errorMessage } from '../services/apiClient.ts';
|
||||||
import type { AIInsight, ChickenWeight, KPI } from '../types/api.ts';
|
import type { ChickenWeight, KPI } from '../types/api.ts';
|
||||||
import { useFarm } from '../context/FarmContext.tsx';
|
import { useFarm } from '../context/FarmContext.tsx';
|
||||||
import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx';
|
import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx';
|
||||||
import { formatNumber, formatRatio } from '../utils/format.ts';
|
import { formatNumber, formatRatio } from '../utils/format.ts';
|
||||||
|
import { buildFcrInsightContext } from '../utils/insightContextBuilders.ts';
|
||||||
|
import { PageAiInsight } from './shared/PageAiInsight.tsx';
|
||||||
|
|
||||||
type FcrRow = {
|
type FcrRow = {
|
||||||
day: number;
|
day: number;
|
||||||
@@ -155,10 +157,9 @@ const SupportingMetricChart: React.FC<{
|
|||||||
);
|
);
|
||||||
|
|
||||||
const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||||
const { selectedCycle } = useFarm();
|
const { selectedCycle, selectedKandang } = useFarm();
|
||||||
const [rows, setRows] = useState<KPI[]>([]);
|
const [rows, setRows] = useState<KPI[]>([]);
|
||||||
const [weightRows, setWeightRows] = useState<ChickenWeight[]>([]);
|
const [weightRows, setWeightRows] = useState<ChickenWeight[]>([]);
|
||||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||||
@@ -167,13 +168,11 @@ const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
|
|||||||
if (!selectedCycle) return;
|
if (!selectedCycle) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const [kpiRows, insightRows, weightList] = await Promise.all([
|
const [kpiRows, weightList] = await Promise.all([
|
||||||
api.kpis.list({ cycle_id: selectedCycle.id }),
|
api.kpis.list({ cycle_id: selectedCycle.id }),
|
||||||
api.insights.list({ cycle_id: selectedCycle.id }),
|
|
||||||
api.weights.list({ cycle_id: selectedCycle.id }),
|
api.weights.list({ cycle_id: selectedCycle.id }),
|
||||||
]);
|
]);
|
||||||
setRows(kpiRows);
|
setRows(kpiRows);
|
||||||
setInsights(insightRows);
|
|
||||||
setWeightRows(weightList);
|
setWeightRows(weightList);
|
||||||
setLastUpdated(new Date());
|
setLastUpdated(new Date());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -185,7 +184,6 @@ const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
|
|||||||
if (!selectedCycle) {
|
if (!selectedCycle) {
|
||||||
setRows([]);
|
setRows([]);
|
||||||
setWeightRows([]);
|
setWeightRows([]);
|
||||||
setInsights([]);
|
|
||||||
setLastUpdated(null);
|
setLastUpdated(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -258,20 +256,16 @@ const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
|
|||||||
const latestFcr = getLatestValid('fcr');
|
const latestFcr = getLatestValid('fcr');
|
||||||
const latestIotWeight = getLatestValid('iotWeight');
|
const latestIotWeight = getLatestValid('iotWeight');
|
||||||
|
|
||||||
const fcrInsights = useMemo(() => {
|
const insightContext = useMemo(() => {
|
||||||
const matched = insights.filter((item) => {
|
if (!selectedKandang) return null;
|
||||||
const text =
|
return buildFcrInsightContext({
|
||||||
`${item.section ?? ''} ${item.alert ?? ''} ${item.insight_text ?? ''}`.toLowerCase();
|
kandangId: selectedKandang.id,
|
||||||
return (
|
kandangName: selectedKandang.kandang_name,
|
||||||
text.includes('fcr') ||
|
cycleId: selectedCycle?.id,
|
||||||
text.includes('pakan') ||
|
totalDays: selectedCycle?.total_days ?? null,
|
||||||
text.includes('feed') ||
|
fcrRows,
|
||||||
text.includes('konversi') ||
|
|
||||||
text.includes('panen')
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
return (matched.length > 0 ? matched : insights).slice(0, 4);
|
}, [selectedKandang, selectedCycle, fcrRows]);
|
||||||
}, [insights]);
|
|
||||||
|
|
||||||
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
|
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
|
||||||
|
|
||||||
@@ -291,37 +285,56 @@ const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section>
|
{selectedKandang && insightContext ? (
|
||||||
<h2 className="text-lg font-bold text-gray-800 mb-3">
|
<PageAiInsight
|
||||||
<i className="fa-solid fa-brain text-red-600 mr-2" />
|
topic="fcr"
|
||||||
AI Insight FCR
|
cacheKey={`fcr-k${selectedKandang.id}-c${selectedCycle.id}-rows${fcrRows.length}-fcr${String(latestFcr ?? 'null').replace('.', '_')}`}
|
||||||
</h2>
|
contextData={insightContext}
|
||||||
{loading ? (
|
chartSpec={{
|
||||||
<Spinner />
|
type: 'trend',
|
||||||
) : fcrInsights.length === 0 ? (
|
historyKey: 'tren_fcr_harian',
|
||||||
<Banner>Belum ada insight untuk siklus ini.</Banner>
|
title: 'Tren FCR',
|
||||||
) : (
|
series: [{ field: 'fcr_aktual', label: 'FCR aktual', color: '#16a34a' }],
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
}}
|
||||||
{fcrInsights.map((item) => (
|
scalarScope={{
|
||||||
<article
|
historyKey: 'tren_fcr_harian',
|
||||||
key={item.id}
|
derive: {
|
||||||
className="bg-white rounded-xl shadow-sm p-5 border border-gray-100"
|
fcrTerakhir: { field: 'fcr_aktual', agg: 'lastValid', replaces: 'latestFcr' },
|
||||||
>
|
totalPakan_karung: {
|
||||||
<div className="flex items-start justify-between gap-3">
|
field: 'total_pakan_karung',
|
||||||
<div>
|
agg: 'last',
|
||||||
<p className="text-xs uppercase tracking-wide text-gray-400">{item.section}</p>
|
replaces: 'latestFeedTotal',
|
||||||
<h3 className="font-semibold text-gray-800 mt-1">{item.alert || 'Insight'}</h3>
|
emptyAs: 0,
|
||||||
</div>
|
},
|
||||||
<span className="text-xs text-gray-400">{item.session}</span>
|
bobotIot_gram: {
|
||||||
</div>
|
field: 'bobot_iot_gram',
|
||||||
<p className="text-sm text-gray-600 mt-3 whitespace-pre-wrap">
|
agg: 'lastValid',
|
||||||
{item.insight_text}
|
replaces: 'latestIotWeight',
|
||||||
</p>
|
},
|
||||||
</article>
|
ayamHidup_ekor: {
|
||||||
))}
|
field: 'ayam_hidup_ekor',
|
||||||
</div>
|
agg: 'last',
|
||||||
)}
|
replaces: 'latestChickenLife',
|
||||||
</section>
|
emptyAs: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
drop: [
|
||||||
|
'latestFcr',
|
||||||
|
'latestFeedTotal',
|
||||||
|
'latestIotWeight',
|
||||||
|
'latestChickenLife',
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-bold text-gray-800 mb-3">
|
||||||
|
<i className="fa-solid fa-brain text-red-600 mr-2" />
|
||||||
|
AI Insight FCR
|
||||||
|
</h2>
|
||||||
|
<Banner>Pilih kandang untuk melihat AI Insight.</Banner>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Spinner />
|
<Spinner />
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { api, errorMessage } from '../services/apiClient.ts';
|
import { api, errorMessage } from '../services/apiClient.ts';
|
||||||
import type { AIInsight, Cycle, Karung, ManualInput } from '../types/api.ts';
|
import type { 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, resolveCycleStatus, todayIso } from '../utils/format.ts';
|
import { addDaysIso, cycleDayForDate, formatDateId, resolveCycleStatus, todayIso } from '../utils/format.ts';
|
||||||
|
import { buildFeedSackInsightContext } from '../utils/insightContextBuilders.ts';
|
||||||
|
import { PageAiInsight } from './shared/PageAiInsight.tsx';
|
||||||
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';
|
||||||
@@ -15,7 +17,6 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
|||||||
useFarm();
|
useFarm();
|
||||||
const [rows, setRows] = useState<Karung[]>([]);
|
const [rows, setRows] = useState<Karung[]>([]);
|
||||||
const [manualRows, setManualRows] = useState<ManualInput[]>([]);
|
const [manualRows, setManualRows] = useState<ManualInput[]>([]);
|
||||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showCyclePicker, setShowCyclePicker] = useState(false);
|
const [showCyclePicker, setShowCyclePicker] = useState(false);
|
||||||
@@ -24,13 +25,11 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const [karungRows, insightRows, manualList] = await Promise.all([
|
const [karungRows, manualList] = await Promise.all([
|
||||||
api.karungs.list({ cycle_id: cycleId }),
|
api.karungs.list({ cycle_id: cycleId }),
|
||||||
api.insights.list({ cycle_id: cycleId }),
|
|
||||||
api.manualInputs.list({ cycle_id: cycleId }),
|
api.manualInputs.list({ cycle_id: cycleId }),
|
||||||
]);
|
]);
|
||||||
setRows(karungRows);
|
setRows(karungRows);
|
||||||
setInsights(insightRows);
|
|
||||||
setManualRows(manualList);
|
setManualRows(manualList);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(errorMessage(err));
|
setError(errorMessage(err));
|
||||||
@@ -42,7 +41,6 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedCycle) {
|
if (!selectedCycle) {
|
||||||
setRows([]);
|
setRows([]);
|
||||||
setInsights([]);
|
|
||||||
setManualRows([]);
|
setManualRows([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -92,6 +90,36 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
|||||||
})} ${date.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' })}`;
|
})} ${date.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' })}`;
|
||||||
}, [latest]);
|
}, [latest]);
|
||||||
|
|
||||||
|
const insightContext = useMemo(() => {
|
||||||
|
if (!selectedKandang || !selectedCycle) return null;
|
||||||
|
const manualByDate = new Map(sortedManualRows.map((r) => [r.date, r]));
|
||||||
|
const dailyRows = sortedRows.map((r) => {
|
||||||
|
const manual = manualByDate.get(r.date);
|
||||||
|
return {
|
||||||
|
date: r.date,
|
||||||
|
day: cycleDayForDate(selectedCycle.start_date, r.date),
|
||||||
|
masuk: r.in_today,
|
||||||
|
dituang: r.feed_use_today,
|
||||||
|
keluar: r.out_today,
|
||||||
|
masukManual: manual?.feed_in_manual ?? 0,
|
||||||
|
dituangManual: manual?.feed_use_manual ?? 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return buildFeedSackInsightContext({
|
||||||
|
kandangId: selectedKandang.id,
|
||||||
|
kandangName: selectedKandang.kandang_name,
|
||||||
|
cycleId: selectedCycle.id,
|
||||||
|
totalDays: selectedCycle.total_days,
|
||||||
|
dailyRows,
|
||||||
|
saldo_awal_karung: selectedCycle.feed_initial_balance ?? null,
|
||||||
|
total_karung_masuk_iot: latest?.in_total ?? null,
|
||||||
|
total_karung_dituang_iot: latest?.feed_use_total ?? null,
|
||||||
|
total_karung_keluar_iot: latest?.out_total ?? null,
|
||||||
|
total_karung_masuk_manual: latestManual?.feed_in_manual_total ?? null,
|
||||||
|
total_karung_dituang_manual: latestManual?.feed_use_manual_total ?? null,
|
||||||
|
});
|
||||||
|
}, [selectedKandang, selectedCycle, sortedRows, sortedManualRows, latest, latestManual]);
|
||||||
|
|
||||||
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
|
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
|
||||||
|
|
||||||
const cycleStatus = resolveCycleStatus(selectedCycle);
|
const cycleStatus = resolveCycleStatus(selectedCycle);
|
||||||
@@ -197,40 +225,70 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
|||||||
<Spinner />
|
<Spinner />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* AI Insight */}
|
{selectedKandang && insightContext ? (
|
||||||
<section>
|
<PageAiInsight
|
||||||
<h2 className="text-lg font-bold text-gray-800 mb-3">
|
topic="hitung_karung"
|
||||||
<i className="fa-solid fa-brain text-red-600 mr-2"></i>
|
cacheKey={`pakan-k${selectedKandang.id}-c${selectedCycle.id}-day${currentDay}-rows${sortedRows.length}`}
|
||||||
AI Insight Hitung Karung
|
contextData={insightContext}
|
||||||
</h2>
|
chartSpec={{
|
||||||
{insights.length === 0 ? (
|
type: 'trend',
|
||||||
<Banner>Belum ada insight untuk siklus ini.</Banner>
|
historyKey: 'tren_harian',
|
||||||
) : (
|
title: 'Karung dituang vs masuk per hari',
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
unit: 'karung',
|
||||||
{insights.slice(0, 4).map((item) => (
|
series: [
|
||||||
<article
|
{ field: 'dituang', label: 'Dituang (IOT)', color: '#dc2626' },
|
||||||
key={item.id}
|
{ field: 'masuk', label: 'Masuk (IOT)', color: '#3b82f6' },
|
||||||
className="bg-white rounded-xl shadow-sm p-5 border border-gray-100"
|
{ field: 'masukManual', label: 'Masuk (Manual)', color: '#0ea5e9' },
|
||||||
>
|
],
|
||||||
<div className="flex items-start justify-between gap-3">
|
}}
|
||||||
<div>
|
scalarScope={{
|
||||||
<p className="text-xs uppercase tracking-wide text-gray-400">
|
historyKey: 'tren_harian',
|
||||||
{item.section}
|
derive: {
|
||||||
</p>
|
karungMasuk_karung: {
|
||||||
<h3 className="font-semibold text-gray-800 mt-1">
|
field: 'masuk',
|
||||||
{item.alert || 'Insight'}
|
agg: 'sum',
|
||||||
</h3>
|
replaces: 'total_karung_masuk_iot',
|
||||||
</div>
|
},
|
||||||
<span className="text-xs text-gray-400">{item.session}</span>
|
karungDituang_karung: {
|
||||||
</div>
|
field: 'dituang',
|
||||||
<p className="text-sm text-gray-600 mt-3 whitespace-pre-wrap">
|
agg: 'sum',
|
||||||
{item.insight_text}
|
replaces: 'total_karung_dituang_iot',
|
||||||
</p>
|
},
|
||||||
</article>
|
karungKeluar_karung: { field: 'keluar', agg: 'sum' },
|
||||||
))}
|
karungMasukManual_karung: {
|
||||||
</div>
|
field: 'masukManual',
|
||||||
)}
|
agg: 'sum',
|
||||||
</section>
|
replaces: 'total_karung_masuk_manual',
|
||||||
|
},
|
||||||
|
karungDituangManual_karung: {
|
||||||
|
field: 'dituangManual',
|
||||||
|
agg: 'sum',
|
||||||
|
replaces: 'total_karung_dituang_manual',
|
||||||
|
},
|
||||||
|
rataKarungDituangPerHari_karung: {
|
||||||
|
field: 'dituang',
|
||||||
|
agg: 'mean',
|
||||||
|
replaces: 'rata_karung_per_hari',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
drop: [
|
||||||
|
'total_karung_masuk_iot',
|
||||||
|
'total_karung_dituang_iot',
|
||||||
|
'total_karung_masuk_manual',
|
||||||
|
'total_karung_dituang_manual',
|
||||||
|
'rata_karung_per_hari',
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-bold text-gray-800 mb-3">
|
||||||
|
<i className="fa-solid fa-brain text-red-600 mr-2"></i>
|
||||||
|
AI Insight Hitung Karung
|
||||||
|
</h2>
|
||||||
|
<Banner>Pilih kandang untuk melihat AI Insight.</Banner>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Saldo Awal */}
|
{/* Saldo Awal */}
|
||||||
<InitialBalanceCard cycle={selectedCycle} />
|
<InitialBalanceCard cycle={selectedCycle} />
|
||||||
|
|||||||
+83
-56
@@ -9,13 +9,20 @@ import {
|
|||||||
YAxis,
|
YAxis,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { api, errorMessage } from '../services/apiClient.ts';
|
import { api, errorMessage } from '../services/apiClient.ts';
|
||||||
import type { AIInsight, Flock, PanelIoT } from '../types/api.ts';
|
import type { Flock, PanelIoT } from '../types/api.ts';
|
||||||
import { useFarm } from '../context/FarmContext.tsx';
|
import { useFarm } from '../context/FarmContext.tsx';
|
||||||
import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx';
|
import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx';
|
||||||
import { cycleDayForDate, formatDateId, formatDateRange, formatNumber } from '../utils/format.ts';
|
import { cycleDayForDate, formatDateId, formatDateRange, formatNumber } from '../utils/format.ts';
|
||||||
|
import {
|
||||||
|
getTempStandardByDay,
|
||||||
|
getWaterStandardByDay,
|
||||||
|
HUMIDITY_RANGE_PCT,
|
||||||
|
} from '../utils/cp707EnvStandards.ts';
|
||||||
|
import { buildIotInsightContext } from '../utils/insightContextBuilders.ts';
|
||||||
import { computeExperienceCalc } from './iotPanel/chillFactor.ts';
|
import { computeExperienceCalc } from './iotPanel/chillFactor.ts';
|
||||||
import IotPanelChillFactorCard from './iotPanel/IotPanelChillFactorCard.tsx';
|
import IotPanelChillFactorCard from './iotPanel/IotPanelChillFactorCard.tsx';
|
||||||
import IotPanelExperienceTempCard from './iotPanel/IotPanelExperienceTempCard.tsx';
|
import IotPanelExperienceTempCard from './iotPanel/IotPanelExperienceTempCard.tsx';
|
||||||
|
import { PageAiInsight } from './shared/PageAiInsight.tsx';
|
||||||
|
|
||||||
const cardClass = 'rounded-xl border border-slate-200 bg-white p-4 shadow-sm';
|
const cardClass = 'rounded-xl border border-slate-200 bg-white p-4 shadow-sm';
|
||||||
|
|
||||||
@@ -57,7 +64,6 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
|||||||
const [dates, setDates] = useState<string[]>([]);
|
const [dates, setDates] = useState<string[]>([]);
|
||||||
const [selectedDate, setSelectedDate] = useState('');
|
const [selectedDate, setSelectedDate] = useState('');
|
||||||
const [dayRows, setDayRows] = useState<PanelIoT[]>([]);
|
const [dayRows, setDayRows] = useState<PanelIoT[]>([]);
|
||||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedChartKey, setSelectedChartKey] = useState<'humidity' | 'wind' | 'water' | null>(
|
const [selectedChartKey, setSelectedChartKey] = useState<'humidity' | 'wind' | 'water' | null>(
|
||||||
@@ -80,7 +86,6 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
|||||||
setDates([]);
|
setDates([]);
|
||||||
setSelectedDate('');
|
setSelectedDate('');
|
||||||
setDayRows([]);
|
setDayRows([]);
|
||||||
setInsights([]);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -107,7 +112,6 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
|||||||
setDates([]);
|
setDates([]);
|
||||||
setSelectedDate('');
|
setSelectedDate('');
|
||||||
setDayRows([]);
|
setDayRows([]);
|
||||||
setInsights([]);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -115,14 +119,13 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const [dateList, insightRows] = await Promise.all([
|
const dateList = await api.panelIot.dates({
|
||||||
api.panelIot.dates({ cycle_id: selectedCycle.id, flock_id: selectedFlockId }),
|
cycle_id: selectedCycle.id,
|
||||||
api.insights.list({ cycle_id: selectedCycle.id }),
|
flock_id: selectedFlockId,
|
||||||
]);
|
});
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const sortedDates = [...dateList].sort();
|
const sortedDates = [...dateList].sort();
|
||||||
setDates(sortedDates);
|
setDates(sortedDates);
|
||||||
setInsights(insightRows);
|
|
||||||
setSelectedDate((previous) =>
|
setSelectedDate((previous) =>
|
||||||
previous && sortedDates.includes(previous)
|
previous && sortedDates.includes(previous)
|
||||||
? previous
|
? previous
|
||||||
@@ -222,21 +225,26 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
|||||||
[summary, dayAge]
|
[summary, dayAge]
|
||||||
);
|
);
|
||||||
|
|
||||||
const iotInsights = useMemo(() => {
|
const insightContext = useMemo(() => {
|
||||||
const matched = insights.filter((item) => {
|
if (!selectedKandang) return null;
|
||||||
const text =
|
return buildIotInsightContext({
|
||||||
`${item.section ?? ''} ${item.alert ?? ''} ${item.insight_text ?? ''}`.toLowerCase();
|
kandangId: selectedKandang.id,
|
||||||
return (
|
kandangName: selectedKandang.kandang_name,
|
||||||
text.includes('iot') ||
|
cycleId: selectedCycle?.id,
|
||||||
text.includes('suhu') ||
|
hari_ke: dayAge,
|
||||||
text.includes('kelembapan') ||
|
totalDays: selectedCycle?.total_days ?? null,
|
||||||
text.includes('humidity') ||
|
suhu_rata_rata_C: summary?.temp ?? null,
|
||||||
text.includes('temperature') ||
|
experience_suhu_C: summary?.experienceTemp ?? null,
|
||||||
text.includes('lingkungan')
|
kelembapan_persen: summary?.humidity ?? null,
|
||||||
);
|
kecepatan_angin_mPerDetik: summary?.windSpeed ?? null,
|
||||||
|
konsumsi_air_L: summary?.waterTotal ?? null,
|
||||||
|
setpoint_C: getTempStandardByDay(dayAge),
|
||||||
|
suhu_min_C: summary?.minTemp ?? null,
|
||||||
|
suhu_max_C: summary?.maxTemp ?? null,
|
||||||
|
experience_suhu_min_C: summary?.minExp ?? null,
|
||||||
|
experience_suhu_max_C: summary?.maxExp ?? null,
|
||||||
});
|
});
|
||||||
return (matched.length > 0 ? matched : insights).slice(0, 4);
|
}, [selectedKandang, selectedCycle, dayAge, summary]);
|
||||||
}, [insights]);
|
|
||||||
|
|
||||||
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
|
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
|
||||||
|
|
||||||
@@ -266,43 +274,62 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{error && <Banner kind="error">{error}</Banner>}
|
{error && <Banner kind="error">{error}</Banner>}
|
||||||
|
|
||||||
|
{selectedKandang && insightContext ? (
|
||||||
|
<PageAiInsight
|
||||||
|
topic="iot_panel"
|
||||||
|
cacheKey={`iot-k${selectedKandang.id}-c${selectedCycle.id}-d${selectedDate}-t${summary?.temp ?? 'na'}`}
|
||||||
|
contextData={insightContext}
|
||||||
|
chartSpec={{
|
||||||
|
type: 'condition',
|
||||||
|
title: 'Kondisi kandang vs standar CP 707',
|
||||||
|
bars: [
|
||||||
|
{
|
||||||
|
label: 'Suhu rata-rata',
|
||||||
|
actual: summary?.temp ?? null,
|
||||||
|
standard: getTempStandardByDay(dayAge),
|
||||||
|
unit: '°C',
|
||||||
|
note:
|
||||||
|
getTempStandardByDay(dayAge) === null
|
||||||
|
? 'Umur tidak diketahui, standar suhu tidak dapat ditentukan.'
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Kelembapan',
|
||||||
|
actual: summary?.humidity ?? null,
|
||||||
|
standard: HUMIDITY_RANGE_PCT.max,
|
||||||
|
unit: '%',
|
||||||
|
note: `Rentang ideal buku ${HUMIDITY_RANGE_PCT.min}–${HUMIDITY_RANGE_PCT.max}%; batang biru menandai batas atas.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Experience Suhu',
|
||||||
|
actual: summary?.experienceTemp ?? null,
|
||||||
|
standard: getTempStandardByDay(dayAge),
|
||||||
|
unit: '°C',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Konsumsi Air',
|
||||||
|
actual: summary?.waterTotal ?? null,
|
||||||
|
standard: getWaterStandardByDay(dayAge)?.max ?? null,
|
||||||
|
unit: 'L',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-bold text-gray-800 mb-3">
|
||||||
|
<i className="fa-solid fa-brain text-red-600 mr-2" />
|
||||||
|
AI Insight Panel IoT
|
||||||
|
</h2>
|
||||||
|
<Banner>Pilih kandang untuk melihat AI Insight.</Banner>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading && dayRows.length === 0 ? (
|
{loading && dayRows.length === 0 ? (
|
||||||
<Spinner />
|
<Spinner />
|
||||||
) : summary ? (
|
) : summary ? (
|
||||||
<>
|
<>
|
||||||
<section>
|
|
||||||
<h2 className="mb-3 text-lg font-bold text-gray-800">
|
|
||||||
<i className="fa-solid fa-brain mr-2 text-red-600" />
|
|
||||||
AI Insight Panel IoT
|
|
||||||
</h2>
|
|
||||||
{iotInsights.length === 0 ? (
|
|
||||||
<Banner>Belum ada insight untuk siklus ini.</Banner>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
|
||||||
{iotInsights.map((item) => (
|
|
||||||
<article
|
|
||||||
key={item.id}
|
|
||||||
className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase tracking-wide text-gray-400">
|
|
||||||
{item.section}
|
|
||||||
</p>
|
|
||||||
<h3 className="mt-1 font-semibold text-gray-800">
|
|
||||||
{item.alert || 'Insight'}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-gray-400">{item.session}</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 whitespace-pre-wrap text-sm text-gray-600">
|
|
||||||
{item.insight_text}
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { api, ApiError, errorMessage } from '../services/apiClient.ts';
|
import { api, ApiError, errorMessage } from '../services/apiClient.ts';
|
||||||
import type { AIInsight, ChickenWeight, LatestAvg } from '../types/api.ts';
|
import type { ChickenWeight, LatestAvg } 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 { formatDateId, formatNumber } from '../utils/format.ts';
|
import { formatDateId, formatNumber } from '../utils/format.ts';
|
||||||
import { formatWeight } from '../utils/weight.ts';
|
import { formatWeight } from '../utils/weight.ts';
|
||||||
|
import { buildWeightInsightContext } from '../utils/insightContextBuilders.ts';
|
||||||
|
import { PageAiInsight } from './shared/PageAiInsight.tsx';
|
||||||
import WeightStats from './weight/WeightStats.tsx';
|
import WeightStats from './weight/WeightStats.tsx';
|
||||||
import DailyWeightProgressionChart from './weight/DailyWeightProgressionChart.tsx';
|
import DailyWeightProgressionChart from './weight/DailyWeightProgressionChart.tsx';
|
||||||
import DailyWeightGainChart from './weight/DailyWeightGainChart.tsx';
|
import DailyWeightGainChart from './weight/DailyWeightGainChart.tsx';
|
||||||
import DailyDetailPanel from './weight/DailyDetailPanel.tsx';
|
import DailyDetailPanel from './weight/DailyDetailPanel.tsx';
|
||||||
|
|
||||||
const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||||
const { selectedCycle } = useFarm();
|
const { selectedCycle, selectedKandang } = useFarm();
|
||||||
const [rows, setRows] = useState<ChickenWeight[]>([]);
|
const [rows, setRows] = useState<ChickenWeight[]>([]);
|
||||||
const [latest, setLatest] = useState<LatestAvg | null>(null);
|
const [latest, setLatest] = useState<LatestAvg | null>(null);
|
||||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedDate, setSelectedDate] = useState('');
|
const [selectedDate, setSelectedDate] = useState('');
|
||||||
@@ -22,12 +23,8 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
|||||||
const reload = async (cycleId: number) => {
|
const reload = async (cycleId: number) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const [list, insightRows] = await Promise.all([
|
const list = await api.weights.list({ cycle_id: cycleId });
|
||||||
api.weights.list({ cycle_id: cycleId }),
|
|
||||||
api.insights.list({ cycle_id: cycleId }),
|
|
||||||
]);
|
|
||||||
setRows(list);
|
setRows(list);
|
||||||
setInsights(insightRows);
|
|
||||||
try {
|
try {
|
||||||
setLatest(await api.weights.latestAvg({ cycle_id: cycleId }));
|
setLatest(await api.weights.latestAvg({ cycle_id: cycleId }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -43,7 +40,6 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
|||||||
if (!selectedCycle) {
|
if (!selectedCycle) {
|
||||||
setRows([]);
|
setRows([]);
|
||||||
setLatest(null);
|
setLatest(null);
|
||||||
setInsights([]);
|
|
||||||
setSelectedDate('');
|
setSelectedDate('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -86,14 +82,6 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
|||||||
const minDate = rows.length > 0 ? (rows[rows.length - 1]?.date ?? '') : '';
|
const minDate = rows.length > 0 ? (rows[rows.length - 1]?.date ?? '') : '';
|
||||||
const maxDate = rows.length > 0 ? (rows[0]?.date ?? '') : '';
|
const maxDate = rows.length > 0 ? (rows[0]?.date ?? '') : '';
|
||||||
|
|
||||||
const weightInsights = useMemo(() => {
|
|
||||||
const matched = insights.filter((item) => {
|
|
||||||
const section = `${item.section ?? ''} ${item.alert ?? ''}`.toLowerCase();
|
|
||||||
return section.includes('berat') || section.includes('weight') || section.includes('timbang');
|
|
||||||
});
|
|
||||||
return (matched.length > 0 ? matched : insights).slice(0, 4);
|
|
||||||
}, [insights]);
|
|
||||||
|
|
||||||
// Prefer the latest non-zero row for the KPI card so a closed cycle with
|
// Prefer the latest non-zero row for the KPI card so a closed cycle with
|
||||||
// trailing empty days does not read as "0 g".
|
// trailing empty days does not read as "0 g".
|
||||||
const kpiLatest = useMemo<LatestAvg | null>(() => {
|
const kpiLatest = useMemo<LatestAvg | null>(() => {
|
||||||
@@ -112,6 +100,21 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
|||||||
};
|
};
|
||||||
}, [latest, rows]);
|
}, [latest, rows]);
|
||||||
|
|
||||||
|
const insightContext = useMemo(() => {
|
||||||
|
if (!selectedKandang) return null;
|
||||||
|
return buildWeightInsightContext({
|
||||||
|
kandangId: selectedKandang.id,
|
||||||
|
kandangName: selectedKandang.kandang_name,
|
||||||
|
cycleId: selectedCycle?.id,
|
||||||
|
totalDays: selectedCycle?.total_days ?? null,
|
||||||
|
docWeight: docWeight,
|
||||||
|
weightRows: chartRows,
|
||||||
|
averageWeight: kpiLatest?.average_weight ?? null,
|
||||||
|
uniformity: kpiLatest?.uniformity ?? null,
|
||||||
|
adg: kpiLatest?.average_daily_gain ?? null,
|
||||||
|
});
|
||||||
|
}, [selectedKandang, selectedCycle, docWeight, chartRows, kpiLatest]);
|
||||||
|
|
||||||
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
|
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -147,37 +150,55 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
|||||||
<Spinner />
|
<Spinner />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* AI Insight */}
|
{selectedKandang && insightContext ? (
|
||||||
<section>
|
<PageAiInsight
|
||||||
<h2 className="text-lg font-bold text-gray-800 mb-3">AI Insight</h2>
|
topic="berat_ayam"
|
||||||
{weightInsights.length === 0 ? (
|
cacheKey={`wm-k${selectedKandang.id}-c${selectedCycle.id}-day${selectedCycle.current_day}-rows${chartRows.length}`}
|
||||||
<Banner>Belum ada insight untuk siklus ini.</Banner>
|
contextData={insightContext}
|
||||||
) : (
|
chartSpec={{
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
type: 'trend',
|
||||||
{weightInsights.map((item) => (
|
historyKey: 'tren_harian',
|
||||||
<article
|
title: 'Bobot aktual vs target',
|
||||||
key={item.id}
|
unit: 'gram',
|
||||||
className="bg-white rounded-xl shadow-sm p-5 border border-gray-100"
|
gap: { actualField: 'bobot_gram', targetField: 'target_gram' },
|
||||||
>
|
series: [
|
||||||
<div className="flex items-start justify-between gap-3">
|
{ field: 'bobot_gram', label: 'Bobot aktual', color: '#7c3aed' },
|
||||||
<div>
|
{ field: 'target_gram', label: 'Target', color: '#9ca3af', dashed: true },
|
||||||
<p className="text-xs uppercase tracking-wide text-gray-400">
|
],
|
||||||
{item.section}
|
}}
|
||||||
</p>
|
scalarScope={{
|
||||||
<h3 className="font-semibold text-gray-800 mt-1">
|
historyKey: 'tren_harian',
|
||||||
{item.alert || 'Insight'}
|
derive: {
|
||||||
</h3>
|
bobotTerakhir_gram: {
|
||||||
</div>
|
field: 'bobot_gram',
|
||||||
<span className="text-xs text-gray-400">{item.session}</span>
|
agg: 'lastValid',
|
||||||
</div>
|
replaces: 'averageWeight',
|
||||||
<p className="text-sm text-gray-600 mt-3 whitespace-pre-wrap">
|
},
|
||||||
{item.insight_text}
|
targetBobot_gram: {
|
||||||
</p>
|
field: 'target_gram',
|
||||||
</article>
|
agg: 'last',
|
||||||
))}
|
replaces: 'targetWeight',
|
||||||
</div>
|
emptyAs: 0,
|
||||||
)}
|
},
|
||||||
</section>
|
pertambahanHarian_gramPerHari: {
|
||||||
|
field: 'adg_gram_per_hari',
|
||||||
|
agg: 'last',
|
||||||
|
replaces: 'adg',
|
||||||
|
emptyAs: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
drop: ['averageWeight', 'targetWeight', 'adg'],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-bold text-gray-800 mb-3">
|
||||||
|
<i className="fa-solid fa-brain text-red-600 mr-2" />
|
||||||
|
AI Insight Berat Ayam
|
||||||
|
</h2>
|
||||||
|
<Banner>Pilih kandang untuk melihat AI Insight.</Banner>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* TOP SECTION: DATA SELURUH SIKLUS */}
|
{/* TOP SECTION: DATA SELURUH SIKLUS */}
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { api, errorMessage } from '../../services/apiClient.ts';
|
import { api, errorMessage } from '../../services/apiClient.ts';
|
||||||
import type { AIInsight, ChickenCounting, KPI, ManualInput } from '../../types/api.ts';
|
import type { ChickenCounting, KPI, ManualInput } from '../../types/api.ts';
|
||||||
import { useFarm } from '../../context/FarmContext.tsx';
|
import { useFarm } from '../../context/FarmContext.tsx';
|
||||||
import { Banner, EmptyFarm, PageHeader, Spinner } from '../ui/Feedback.tsx';
|
import { Banner, EmptyFarm, PageHeader, Spinner } from '../ui/Feedback.tsx';
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
resolveCycleStatus,
|
resolveCycleStatus,
|
||||||
todayIso,
|
todayIso,
|
||||||
} from '../../utils/format.ts';
|
} from '../../utils/format.ts';
|
||||||
|
import { buildCountingInsightContext } from '../../utils/insightContextBuilders.ts';
|
||||||
|
import { PageAiInsight } from '../shared/PageAiInsight.tsx';
|
||||||
import PopulationTrendChart from './PopulationTrendChart.tsx';
|
import PopulationTrendChart from './PopulationTrendChart.tsx';
|
||||||
import CountingAccuracyWarningBanner from './CountingAccuracyWarningBanner.tsx';
|
import CountingAccuracyWarningBanner from './CountingAccuracyWarningBanner.tsx';
|
||||||
import { getCountingAccuracyWarning } from '../../utils/countingAccuracyWarning.ts';
|
import { getCountingAccuracyWarning } from '../../utils/countingAccuracyWarning.ts';
|
||||||
@@ -49,7 +51,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
|||||||
const [kpis, setKpis] = useState<KPI[]>([]);
|
const [kpis, setKpis] = useState<KPI[]>([]);
|
||||||
const [countings, setCountings] = useState<ChickenCounting[]>([]);
|
const [countings, setCountings] = useState<ChickenCounting[]>([]);
|
||||||
const [manualRows, setManualRows] = useState<ManualInput[]>([]);
|
const [manualRows, setManualRows] = useState<ManualInput[]>([]);
|
||||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [searchDay, setSearchDay] = useState<string>('');
|
const [searchDay, setSearchDay] = useState<string>('');
|
||||||
@@ -61,16 +62,14 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const [k, c, m, i] = await Promise.all([
|
const [k, c, m] = await Promise.all([
|
||||||
api.kpis.list({ cycle_id: cycleId }),
|
api.kpis.list({ cycle_id: cycleId }),
|
||||||
api.countings.list({ cycle_id: cycleId }),
|
api.countings.list({ cycle_id: cycleId }),
|
||||||
api.manualInputs.list({ cycle_id: cycleId }),
|
api.manualInputs.list({ cycle_id: cycleId }),
|
||||||
api.insights.list({ cycle_id: cycleId }),
|
|
||||||
]);
|
]);
|
||||||
setKpis(k);
|
setKpis(k);
|
||||||
setCountings(c);
|
setCountings(c);
|
||||||
setManualRows(m);
|
setManualRows(m);
|
||||||
setInsights(i);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(errorMessage(err));
|
setError(errorMessage(err));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -83,7 +82,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
|||||||
setKpis([]);
|
setKpis([]);
|
||||||
setCountings([]);
|
setCountings([]);
|
||||||
setManualRows([]);
|
setManualRows([]);
|
||||||
setInsights([]);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void loadData(selectedCycle.id);
|
void loadData(selectedCycle.id);
|
||||||
@@ -175,9 +173,7 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
|||||||
const totalPanen = latest?.harvestTotal ?? 0;
|
const totalPanen = latest?.harvestTotal ?? 0;
|
||||||
const totalBeratPanen = latest?.harvestWeightTotal ?? 0;
|
const totalBeratPanen = latest?.harvestWeightTotal ?? 0;
|
||||||
const currentPopulation = latest?.stockAkhir ?? initialPopulation;
|
const currentPopulation = latest?.stockAkhir ?? initialPopulation;
|
||||||
const mortalityRate = initialPopulation
|
const mortalityRate = initialPopulation > 0 ? (totalMortality / initialPopulation) * 100 : null;
|
||||||
? ((totalMortality / initialPopulation) * 100).toFixed(2)
|
|
||||||
: '0.00';
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
initialPopulation,
|
initialPopulation,
|
||||||
@@ -189,6 +185,33 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
|||||||
};
|
};
|
||||||
}, [rows, selectedCycle]);
|
}, [rows, selectedCycle]);
|
||||||
|
|
||||||
|
const insightContext = useMemo(() => {
|
||||||
|
if (!selectedKandang || !selectedCycle) return null;
|
||||||
|
const lastKpiAge = kpis.length > 0 ? Math.max(...kpis.map((k) => k.age)) : null;
|
||||||
|
const lastCountingDay =
|
||||||
|
countings.length > 0
|
||||||
|
? Math.max(...countings.map((c) => cycleDayForDate(selectedCycle.start_date, c.date)))
|
||||||
|
: null;
|
||||||
|
const lastDataDay =
|
||||||
|
lastKpiAge != null || lastCountingDay != null
|
||||||
|
? Math.max(lastKpiAge ?? 0, lastCountingDay ?? 0)
|
||||||
|
: null;
|
||||||
|
const insightRows = lastDataDay != null ? rows.filter((row) => row.day <= lastDataDay) : rows;
|
||||||
|
return buildCountingInsightContext({
|
||||||
|
kandangId: selectedKandang.id,
|
||||||
|
kandangName: selectedKandang.kandang_name,
|
||||||
|
cycleId: selectedCycle.id,
|
||||||
|
totalDays: selectedCycle.total_days ?? null,
|
||||||
|
dailyRows: insightRows,
|
||||||
|
populasi_awal_ekor: stats.initialPopulation > 0 ? stats.initialPopulation : null,
|
||||||
|
populasi_kini_ekor: stats.currentPopulation,
|
||||||
|
mortalitas_kumulatif_ekor: stats.totalMortality,
|
||||||
|
mortalitas_persen: stats.mortalityRate,
|
||||||
|
panen_kumulatif_ekor: stats.totalPanen,
|
||||||
|
berat_panen_kumulatif_kg: stats.totalBeratPanen,
|
||||||
|
});
|
||||||
|
}, [selectedKandang, selectedCycle, rows, stats, kpis, countings]);
|
||||||
|
|
||||||
const chartData = useMemo(
|
const chartData = useMemo(
|
||||||
() =>
|
() =>
|
||||||
rows.map((r) => ({
|
rows.map((r) => ({
|
||||||
@@ -269,36 +292,70 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
|||||||
<CountingAccuracyWarningBanner warning={countingAccuracyWarning} />
|
<CountingAccuracyWarningBanner warning={countingAccuracyWarning} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* AI Insight */}
|
{selectedKandang && insightContext ? (
|
||||||
<section className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="text-lg font-bold text-gray-800 mb-3">
|
<PageAiInsight
|
||||||
<i className="fa-solid fa-wand-magic-sparkles text-purple-500 mr-2"></i>
|
topic="hitung_ayam"
|
||||||
AI Insight
|
cacheKey={`hitung-k${selectedKandang.id}-c${selectedCycle.id}-day${selectedCycle.current_day}-rows${rows.length}`}
|
||||||
</h2>
|
contextData={insightContext}
|
||||||
{insights.length === 0 ? (
|
chartSpec={{
|
||||||
<Banner>Belum ada insight untuk siklus ini.</Banner>
|
type: 'trend',
|
||||||
) : (
|
historyKey: 'tren_populasi_harian',
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
title: 'Tren populasi',
|
||||||
{insights.slice(0, 4).map((item) => (
|
unit: 'ekor',
|
||||||
<article
|
series: [{ field: 'populasi', label: 'Populasi', color: '#7c3aed' }],
|
||||||
key={item.id}
|
}}
|
||||||
className="bg-white rounded-xl shadow-sm p-5 border border-gray-100"
|
scalarScope={{
|
||||||
>
|
historyKey: 'tren_populasi_harian',
|
||||||
<div className="flex items-start justify-between gap-3">
|
derive: {
|
||||||
<div>
|
populasiKini_ekor: {
|
||||||
<p className="text-xs uppercase tracking-wide text-gray-400">{item.section}</p>
|
field: 'populasi',
|
||||||
<h3 className="font-semibold text-gray-800 mt-1">{item.alert || 'Insight'}</h3>
|
agg: 'lastValid',
|
||||||
</div>
|
replaces: 'populasi_kini_ekor',
|
||||||
<span className="text-xs text-gray-400">{item.session}</span>
|
},
|
||||||
</div>
|
mortalitasKumulatif_ekor: {
|
||||||
<p className="text-sm text-gray-600 mt-3 whitespace-pre-wrap">
|
field: 'mortalitas_kumulatif_ekor',
|
||||||
{item.insight_text}
|
agg: 'last',
|
||||||
</p>
|
replaces: 'mortalitas_kumulatif_ekor',
|
||||||
</article>
|
emptyAs: 0,
|
||||||
))}
|
},
|
||||||
</div>
|
panenKumulatif_ekor: {
|
||||||
)}
|
field: 'panen_kumulatif_ekor',
|
||||||
</section>
|
agg: 'last',
|
||||||
|
replaces: 'panen_kumulatif_ekor',
|
||||||
|
emptyAs: 0,
|
||||||
|
},
|
||||||
|
beratPanenKumulatif_kg: {
|
||||||
|
field: 'berat_panen_kumulatif_kg',
|
||||||
|
agg: 'last',
|
||||||
|
replaces: 'berat_panen_kumulatif_kg',
|
||||||
|
emptyAs: 0,
|
||||||
|
},
|
||||||
|
mortalitasPersen: {
|
||||||
|
field: 'mortalitas_persen',
|
||||||
|
agg: 'last',
|
||||||
|
replaces: 'mortalitas_persen',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
drop: [
|
||||||
|
'populasi_kini_ekor',
|
||||||
|
'mortalitas_kumulatif_ekor',
|
||||||
|
'panen_kumulatif_ekor',
|
||||||
|
'berat_panen_kumulatif_kg',
|
||||||
|
'mortalitas_persen',
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<section className="mb-6">
|
||||||
|
<h2 className="text-lg font-bold text-gray-800 mb-3">
|
||||||
|
<i className="fa-solid fa-wand-magic-sparkles text-purple-500 mr-2"></i>
|
||||||
|
AI Insight
|
||||||
|
</h2>
|
||||||
|
<Banner>Pilih kandang untuk melihat AI Insight.</Banner>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Population Chart */}
|
{/* Population Chart */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export interface ConditionBar {
|
||||||
|
label: string;
|
||||||
|
actual: number | null;
|
||||||
|
standard: number | null;
|
||||||
|
unit: string;
|
||||||
|
/** Optional note shown under the bar, e.g. why a standard is absent. */
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InsightConditionChartProps {
|
||||||
|
bars: ConditionBar[];
|
||||||
|
title?: string;
|
||||||
|
emptyLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AKTUAL = '#dc2626';
|
||||||
|
const STANDAR = '#3b82f6';
|
||||||
|
|
||||||
|
const fmt = (value: number, unit: string) =>
|
||||||
|
`${Number(value).toLocaleString('id-ID', { maximumFractionDigits: 1 })}${unit ? ` ${unit}` : ''}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current readings against their CP 707 standard, one row per metric.
|
||||||
|
* Used when there is no per-day history (e.g. live IoT snapshot).
|
||||||
|
*/
|
||||||
|
const InsightConditionChart: React.FC<InsightConditionChartProps> = ({
|
||||||
|
bars,
|
||||||
|
title,
|
||||||
|
emptyLabel = 'Belum ada pembacaan sensor untuk digambarkan.',
|
||||||
|
}) => {
|
||||||
|
const usable = bars.filter((bar) => bar.actual !== null || bar.standard !== null);
|
||||||
|
|
||||||
|
if (usable.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-gray-100 bg-gray-50/60 p-4 text-center text-sm text-gray-500">
|
||||||
|
{emptyLabel}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
|
||||||
|
{title && <h4 className="mb-1 text-sm font-bold text-gray-800">{title}</h4>}
|
||||||
|
<div className="mb-3 flex items-center gap-4 text-xs text-gray-500">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: AKTUAL }} />
|
||||||
|
Aktual
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: STANDAR }} />
|
||||||
|
Standar CP 707
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{usable.map((bar) => {
|
||||||
|
const max = Math.max(bar.actual ?? 0, bar.standard ?? 0, 1);
|
||||||
|
const pct = (value: number | null) =>
|
||||||
|
value === null ? 0 : Math.max(2, Math.round((value / max) * 100));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={bar.label}>
|
||||||
|
<div className="mb-1 flex items-baseline justify-between">
|
||||||
|
<span className="text-xs font-medium text-gray-600">{bar.label}</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{bar.actual === null ? 'data tidak tersedia' : fmt(bar.actual, bar.unit)}
|
||||||
|
{bar.standard !== null && (
|
||||||
|
<span className="text-gray-400"> · standar {fmt(bar.standard, bar.unit)}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="h-2.5 w-full rounded-sm bg-gray-100">
|
||||||
|
{bar.actual !== null && (
|
||||||
|
<div
|
||||||
|
className="h-full rounded-sm"
|
||||||
|
style={{ width: `${pct(bar.actual)}%`, background: AKTUAL }}
|
||||||
|
title={`Aktual ${fmt(bar.actual, bar.unit)}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="h-2.5 w-full rounded-sm bg-gray-100">
|
||||||
|
{bar.standard !== null && (
|
||||||
|
<div
|
||||||
|
className="h-full rounded-sm"
|
||||||
|
style={{ width: `${pct(bar.standard)}%`, background: STANDAR }}
|
||||||
|
title={`Standar ${fmt(bar.standard, bar.unit)}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bar.note && <p className="mt-1 text-[11px] text-gray-400">{bar.note}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InsightConditionChart;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { InsightJobStage } from '../../types/api.ts';
|
||||||
|
|
||||||
|
/** Phase 4 pipeline step indicator for async AI Insight generation. */
|
||||||
|
const STEPS: { stage: InsightJobStage; label: string; icon: string }[] = [
|
||||||
|
{ stage: 'grading', label: 'Evaluating CP 707 rules...', icon: 'fa-solid fa-scale-balanced' },
|
||||||
|
{ stage: 'retrieving', label: 'Fetching SOP guidelines...', icon: 'fa-solid fa-book-open' },
|
||||||
|
{
|
||||||
|
stage: 'synthesizing',
|
||||||
|
label: 'Synthesizing recommendations...',
|
||||||
|
icon: 'fa-solid fa-wand-magic-sparkles',
|
||||||
|
},
|
||||||
|
{ stage: 'saving', label: 'Saving insight...', icon: 'fa-solid fa-floppy-disk' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ORDER: InsightJobStage[] = ['queued', 'starting', ...STEPS.map((s) => s.stage), 'done'];
|
||||||
|
|
||||||
|
function stageIndex(stage: InsightJobStage | null): number {
|
||||||
|
if (!stage) return 0;
|
||||||
|
const i = ORDER.indexOf(stage);
|
||||||
|
return i < 0 ? 0 : i;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InsightProgressSteps({ stage }: { stage: InsightJobStage | null }) {
|
||||||
|
const current = stageIndex(stage);
|
||||||
|
return (
|
||||||
|
<ol className="space-y-1.5 text-xs text-gray-600" aria-live="polite">
|
||||||
|
{STEPS.map((step, i) => {
|
||||||
|
const state = current > i ? 'done' : current === i ? 'active' : 'pending';
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={step.stage}
|
||||||
|
className={`flex items-center gap-2 ${state === 'pending' ? 'opacity-40' : ''}`}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className={`${step.icon} ${
|
||||||
|
state === 'done' ? 'text-green-600' : 'text-blue-600'
|
||||||
|
} ${state === 'active' ? 'fa-spin' : ''}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span>{step.label}</span>
|
||||||
|
{state === 'done' && (
|
||||||
|
<i className="fa-solid fa-check text-green-600" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Headline figures for AI Insight cards.
|
||||||
|
* Matches NEW dashboard gray card patterns (white + border-gray-100).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type InsightStatus = 'ok' | 'warning' | 'critical' | 'unknown';
|
||||||
|
|
||||||
|
const STATUS_CHIP: Record<InsightStatus, { cls: string; glyph: string }> = {
|
||||||
|
ok: { cls: 'bg-green-50 text-green-800 border-green-200', glyph: '✓' },
|
||||||
|
warning: { cls: 'bg-amber-50 text-amber-800 border-amber-200', glyph: '!' },
|
||||||
|
critical: { cls: 'bg-red-50 text-red-800 border-red-200', glyph: '⚠' },
|
||||||
|
unknown: { cls: 'bg-gray-50 text-gray-500 border-gray-200', glyph: '–' },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DeltaChipProps {
|
||||||
|
status: InsightStatus;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeltaChip: React.FC<DeltaChipProps> = ({ status, label }) => {
|
||||||
|
const chip = STATUS_CHIP[status];
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-semibold ${chip.cls}`}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">{chip.glyph}</span>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Axis-less sparkline — shape only; the tile number carries the value. */
|
||||||
|
export const Sparkline: React.FC<{ values: number[]; className?: string }> = ({
|
||||||
|
values,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const clean = values.filter((v) => typeof v === 'number' && Number.isFinite(v));
|
||||||
|
if (clean.length < 2) return null;
|
||||||
|
|
||||||
|
const width = 88;
|
||||||
|
const height = 28;
|
||||||
|
const min = Math.min(...clean);
|
||||||
|
const max = Math.max(...clean);
|
||||||
|
const span = max - min || 1;
|
||||||
|
const step = width / (clean.length - 1);
|
||||||
|
const points = clean
|
||||||
|
.map((value, index) => {
|
||||||
|
const x = index * step;
|
||||||
|
const y = height - 2 - ((value - min) / span) * (height - 4);
|
||||||
|
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
className={className}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
>
|
||||||
|
<polyline
|
||||||
|
points={points}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface HeroFigureProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
unit?: string;
|
||||||
|
context?: string | null;
|
||||||
|
status?: InsightStatus;
|
||||||
|
deltaLabel?: string | null;
|
||||||
|
sparkline?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HeroFigure: React.FC<HeroFigureProps> = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
unit,
|
||||||
|
context,
|
||||||
|
status = 'unknown',
|
||||||
|
deltaLabel,
|
||||||
|
sparkline,
|
||||||
|
}) => (
|
||||||
|
<div className="flex items-end justify-between gap-4 rounded-xl border border-gray-100 bg-white px-5 py-4 shadow-sm">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500">{label}</div>
|
||||||
|
<div className="mt-1 flex items-baseline gap-1.5 pb-0.5">
|
||||||
|
<span className="text-5xl font-bold leading-[1.15] tracking-tight text-gray-900">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
{unit ? <span className="text-sm font-medium text-gray-500">{unit}</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
{deltaLabel ? <DeltaChip status={status} label={deltaLabel} /> : null}
|
||||||
|
{context ? <span className="text-xs text-gray-500">{context}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{sparkline && sparkline.length > 1 ? (
|
||||||
|
<Sparkline values={sparkline} className="shrink-0 text-red-400" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface StatTileProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
unit?: string;
|
||||||
|
hint?: string | null;
|
||||||
|
status?: InsightStatus;
|
||||||
|
deltaLabel?: string | null;
|
||||||
|
sparkline?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatTile: React.FC<StatTileProps> = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
unit,
|
||||||
|
hint,
|
||||||
|
status = 'unknown',
|
||||||
|
deltaLabel,
|
||||||
|
sparkline,
|
||||||
|
}) => (
|
||||||
|
<div className="rounded-xl border border-gray-100 bg-white px-4 py-3 shadow-sm">
|
||||||
|
<div className="text-xs font-medium text-gray-500">
|
||||||
|
{label}
|
||||||
|
{hint ? <span className="ml-1 text-[11px] text-gray-400">{hint}</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-baseline gap-1">
|
||||||
|
<span className="text-xl font-bold leading-[1.3] text-gray-900">{value}</span>
|
||||||
|
{unit ? <span className="text-xs font-medium text-gray-500">{unit}</span> : null}
|
||||||
|
</div>
|
||||||
|
{deltaLabel ? (
|
||||||
|
<div className="mt-1.5">
|
||||||
|
<DeltaChip status={status} label={deltaLabel} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{sparkline && sparkline.length > 1 ? (
|
||||||
|
<Sparkline values={sparkline} className="mt-2 text-gray-300" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Secondary figures: subordinate to hero / tiles. */
|
||||||
|
export const MutedStatRow: React.FC<{ label: string; hint?: string | null; value: string }> = ({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
value,
|
||||||
|
}) => (
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-50 py-1.5">
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{label}
|
||||||
|
{hint ? <span className="ml-1 text-xs text-gray-400">{hint}</span> : null}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-gray-900">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
CartesianGrid,
|
||||||
|
ComposedChart,
|
||||||
|
Legend,
|
||||||
|
Line,
|
||||||
|
ReferenceArea,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
/** One plotted line: which field in the row, how to name it, what colour. */
|
||||||
|
export interface InsightSeries {
|
||||||
|
field: string;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
/** Draw as a dashed reference line — for a target or standard, not a measurement. */
|
||||||
|
dashed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shade the distance between a measurement and the standard it is judged against.
|
||||||
|
*
|
||||||
|
* Recharts has no between-two-series area, so the band is two stacked areas: a
|
||||||
|
* transparent base up to the lower of the pair, then the gap itself.
|
||||||
|
*/
|
||||||
|
export interface InsightGapSpec {
|
||||||
|
actualField: string;
|
||||||
|
targetField: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InsightTrendChartProps {
|
||||||
|
/** FULL history — the analysed window is highlighted, not isolated. */
|
||||||
|
rows: Array<Record<string, unknown>>;
|
||||||
|
series: InsightSeries[];
|
||||||
|
/** Inclusive day range the insight text actually covers. */
|
||||||
|
highlight?: { firstDay: number; lastDay: number } | null;
|
||||||
|
unit?: string;
|
||||||
|
title?: string;
|
||||||
|
emptyLabel?: string;
|
||||||
|
gap?: InsightGapSpec | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayOf = (row: Record<string, unknown>): number | null => {
|
||||||
|
for (const key of ['hari', 'day']) {
|
||||||
|
const value = row[key];
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const numOrNull = (value: unknown) =>
|
||||||
|
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trend behind an insight, with the analysed period marked.
|
||||||
|
*/
|
||||||
|
const InsightTrendChart: React.FC<InsightTrendChartProps> = ({
|
||||||
|
rows,
|
||||||
|
series,
|
||||||
|
highlight,
|
||||||
|
unit,
|
||||||
|
title,
|
||||||
|
emptyLabel = 'Belum ada data untuk digambarkan.',
|
||||||
|
gap = null,
|
||||||
|
}) => {
|
||||||
|
const data = (Array.isArray(rows) ? rows : [])
|
||||||
|
.map((row) => {
|
||||||
|
const day = dayOf(row);
|
||||||
|
if (day === null) return null;
|
||||||
|
const point: Record<string, number | null> = { hari: day };
|
||||||
|
for (const s of series) point[s.field] = numOrNull(row[s.field]);
|
||||||
|
if (gap) {
|
||||||
|
const actual = numOrNull(row[gap.actualField]);
|
||||||
|
const target = numOrNull(row[gap.targetField]);
|
||||||
|
point.bandBase = actual !== null && target !== null ? Math.min(actual, target) : null;
|
||||||
|
point.bandSize = actual !== null && target !== null ? Math.abs(actual - target) : null;
|
||||||
|
}
|
||||||
|
return point;
|
||||||
|
})
|
||||||
|
.filter((point): point is Record<string, number | null> & { hari: number } => point !== null)
|
||||||
|
.sort((a, b) => a.hari - b.hari);
|
||||||
|
|
||||||
|
const drawn = series.filter((s) => data.some((point) => point[s.field] !== null));
|
||||||
|
|
||||||
|
if (data.length === 0 || drawn.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-gray-100 bg-gray-50/60 p-4 text-center text-sm text-gray-500">
|
||||||
|
{emptyLabel}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = data.map((point) => point.hari);
|
||||||
|
const step = Math.max(1, Math.ceil(days.length / 10));
|
||||||
|
const ticks = days.filter((_, i) => i % step === 0 || i === days.length - 1);
|
||||||
|
|
||||||
|
const band =
|
||||||
|
highlight && Number.isFinite(highlight.firstDay) && Number.isFinite(highlight.lastDay)
|
||||||
|
? highlight
|
||||||
|
: null;
|
||||||
|
const bandFrom = band
|
||||||
|
? band.firstDay === band.lastDay
|
||||||
|
? band.firstDay - 0.4
|
||||||
|
: band.firstDay
|
||||||
|
: 0;
|
||||||
|
const bandTo = band ? (band.firstDay === band.lastDay ? band.lastDay + 0.4 : band.lastDay) : 0;
|
||||||
|
|
||||||
|
const gapFill = (() => {
|
||||||
|
if (!gap) return null;
|
||||||
|
const last = [...data]
|
||||||
|
.reverse()
|
||||||
|
.find((point) => point[gap.actualField] !== null && point[gap.targetField] !== null);
|
||||||
|
const actual = last?.[gap.actualField];
|
||||||
|
const target = last?.[gap.targetField];
|
||||||
|
if (typeof actual !== 'number' || typeof target !== 'number' || target === 0) {
|
||||||
|
return 'rgba(107, 114, 128, 0.12)';
|
||||||
|
}
|
||||||
|
const deviation = ((actual - target) / target) * 100;
|
||||||
|
if (deviation >= -5) return 'rgba(12, 163, 12, 0.12)';
|
||||||
|
if (deviation >= -15) return 'rgba(250, 178, 25, 0.18)';
|
||||||
|
return 'rgba(208, 59, 59, 0.14)';
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
|
||||||
|
{title && <h4 className="mb-1 text-sm font-bold text-gray-800">{title}</h4>}
|
||||||
|
{band && (
|
||||||
|
<p className="mb-3 text-xs text-gray-500">
|
||||||
|
Area berbayang = periode yang dibahas insight
|
||||||
|
{band.firstDay === band.lastDay
|
||||||
|
? ` (hari ke-${band.lastDay})`
|
||||||
|
: ` (hari ke-${band.firstDay} s/d ke-${band.lastDay})`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<ResponsiveContainer width="100%" height={230}>
|
||||||
|
<ComposedChart data={data} margin={{ top: 8, right: 12, bottom: 4, left: 0 }}>
|
||||||
|
<CartesianGrid stroke="#e5e7eb" vertical={false} />
|
||||||
|
{band && (
|
||||||
|
<ReferenceArea
|
||||||
|
x1={bandFrom}
|
||||||
|
x2={bandTo}
|
||||||
|
fill="#6b7280"
|
||||||
|
fillOpacity={0.12}
|
||||||
|
stroke="#6b7280"
|
||||||
|
strokeOpacity={0.25}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<XAxis
|
||||||
|
dataKey="hari"
|
||||||
|
type="number"
|
||||||
|
domain={[(days[0] ?? 0) - 0.5, (days[days.length - 1] ?? 0) + 0.5]}
|
||||||
|
ticks={ticks}
|
||||||
|
allowDecimals={false}
|
||||||
|
tick={{ fontSize: 11, fill: '#6b7280' }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: '#e5e7eb' }}
|
||||||
|
tickFormatter={(value) => `H${value}`}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 11, fill: '#6b7280' }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={52}
|
||||||
|
tickFormatter={(value) => Number(value).toLocaleString('id-ID')}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
fontSize: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
}}
|
||||||
|
labelFormatter={(value) => `Hari ke-${value}`}
|
||||||
|
formatter={(value, name) => {
|
||||||
|
const label = String(name ?? '');
|
||||||
|
if (label === 'bandBase' || label === 'bandSize') return null;
|
||||||
|
if (value == null || Array.isArray(value)) return null;
|
||||||
|
return [`${Number(value).toLocaleString('id-ID')}${unit ? ` ${unit}` : ''}`, label];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{drawn.length > 1 && <Legend wrapperStyle={{ fontSize: 12 }} iconType="plainline" />}
|
||||||
|
{gap && gapFill && (
|
||||||
|
<>
|
||||||
|
<Area
|
||||||
|
dataKey="bandBase"
|
||||||
|
stackId="gap"
|
||||||
|
stroke="none"
|
||||||
|
fill="none"
|
||||||
|
legendType="none"
|
||||||
|
isAnimationActive={false}
|
||||||
|
connectNulls
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
dataKey="bandSize"
|
||||||
|
stackId="gap"
|
||||||
|
stroke="none"
|
||||||
|
fill={gapFill}
|
||||||
|
fillOpacity={1}
|
||||||
|
legendType="none"
|
||||||
|
isAnimationActive={false}
|
||||||
|
connectNulls
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{drawn.map((s) => (
|
||||||
|
<Line
|
||||||
|
key={s.field}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={s.field}
|
||||||
|
name={s.label}
|
||||||
|
stroke={s.color}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray={s.dashed ? '5 4' : undefined}
|
||||||
|
dot={!s.dashed && data.length <= 20 ? { r: 3, strokeWidth: 0, fill: s.color } : false}
|
||||||
|
activeDot={{ r: 5, strokeWidth: 2, stroke: '#ffffff' }}
|
||||||
|
connectNulls
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InsightTrendChart;
|
||||||
@@ -0,0 +1,960 @@
|
|||||||
|
/**
|
||||||
|
* Per-page / per-topic AI Insight shell (FCR, weight, counting, IoT, …).
|
||||||
|
*
|
||||||
|
* Builds/scopes context, then loads the insight from the DB on mount
|
||||||
|
* (`GET /ai-insights/cached/`). LLM generate runs only when the user clicks
|
||||||
|
* Generate (force=true). No client memory/sessionStorage for insight text —
|
||||||
|
* the DB row is the source of truth.
|
||||||
|
* Distinct from `AiInsightCard` (Dashboard report modes).
|
||||||
|
*/
|
||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useFarm } from '../../context/FarmContext.tsx';
|
||||||
|
import { api, errorMessage } from '../../services/apiClient.ts';
|
||||||
|
import type {
|
||||||
|
InsightCitation,
|
||||||
|
InsightContext,
|
||||||
|
InsightTopic,
|
||||||
|
} from '../../types/api.ts';
|
||||||
|
import { formatNumber, formatRatio } from '../../utils/format.ts';
|
||||||
|
import { getCobbStandardForDay } from '../../utils/insightHelpers.ts';
|
||||||
|
import { parseAiResult, type AiResult } from '../../utils/insightParse.ts';
|
||||||
|
import {
|
||||||
|
scopeContextToTimeframe,
|
||||||
|
scopeScalarsToTimeframe,
|
||||||
|
UNAVAILABLE,
|
||||||
|
type ScalarScopeSpec,
|
||||||
|
} from '../../utils/insightScope.ts';
|
||||||
|
import InsightTrendChart, { type InsightSeries } from './InsightTrendChart.tsx';
|
||||||
|
import InsightConditionChart, { type ConditionBar } from './InsightConditionChart.tsx';
|
||||||
|
import { HeroFigure, type InsightStatus } from './InsightStatTiles.tsx';
|
||||||
|
|
||||||
|
type PageInsightResult = AiResult & {
|
||||||
|
citations?: InsightCitation[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Dedupes concurrent soft DB loads for the same scope (not a content cache). */
|
||||||
|
const _insightInFlight = new Map<string, Promise<PageInsightResult | null>>();
|
||||||
|
|
||||||
|
export type { InsightTopic };
|
||||||
|
|
||||||
|
export interface PageAiInsightProps {
|
||||||
|
topic: InsightTopic;
|
||||||
|
/** Curated context — must include kandangId (never null "all kandang"). */
|
||||||
|
contextData: InsightContext | Record<string, unknown>;
|
||||||
|
cacheKey: string;
|
||||||
|
scalarScope?: ScalarScopeSpec;
|
||||||
|
chartSpec?:
|
||||||
|
| {
|
||||||
|
type: 'trend';
|
||||||
|
historyKey: string;
|
||||||
|
series: InsightSeries[];
|
||||||
|
unit?: string;
|
||||||
|
title?: string;
|
||||||
|
gap?: { actualField: string; targetField: string };
|
||||||
|
}
|
||||||
|
| { type: 'condition'; bars: ConditionBar[]; title?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOPIC_META: Record<InsightTopic, { title: string; icon: string }> = {
|
||||||
|
hitung_ayam: { title: 'AI Insight — Hitung Ayam', icon: 'fa-kiwi-bird' },
|
||||||
|
berat_ayam: { title: 'AI Insight — Berat Ayam', icon: 'fa-weight-scale' },
|
||||||
|
fcr: { title: 'AI Insight — FCR', icon: 'fa-wheat-awn' },
|
||||||
|
eef: { title: 'AI Insight — EEF', icon: 'fa-chart-line' },
|
||||||
|
iot_panel: { title: 'AI Insight — Panel IoT', icon: 'fa-temperature-half' },
|
||||||
|
hitung_karung: { title: 'AI Insight — Hitung Karung', icon: 'fa-boxes-stacked' },
|
||||||
|
dashboard: { title: 'AI Insight — Dashboard', icon: 'fa-brain' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderValue = (val: unknown, suffix: string = ''): string => {
|
||||||
|
if (val === undefined || val === null || val === '') return 'N/A';
|
||||||
|
if (val === UNAVAILABLE) return UNAVAILABLE;
|
||||||
|
return `${val} ${suffix}`.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Format a day's min–max range for IoT panel insight metrics (no rounding). */
|
||||||
|
const renderDayRange = (minVal: unknown, maxVal: unknown, suffix: string): string => {
|
||||||
|
const lo = typeof minVal === 'number' && Number.isFinite(minVal) ? minVal : null;
|
||||||
|
const hi = typeof maxVal === 'number' && Number.isFinite(maxVal) ? maxVal : null;
|
||||||
|
// Keep full precision — do not toFixed / cap fraction digits.
|
||||||
|
const fmt = (n: number) =>
|
||||||
|
n.toLocaleString('id-ID', { maximumFractionDigits: 20, useGrouping: false });
|
||||||
|
if (lo === null && hi === null) return 'N/A';
|
||||||
|
if (lo === null) return `${fmt(hi!)} ${suffix}`.trim();
|
||||||
|
if (hi === null) return `${fmt(lo)} ${suffix}`.trim();
|
||||||
|
if (lo === hi) return `${fmt(lo)} ${suffix}`.trim();
|
||||||
|
return `${fmt(lo)} – ${fmt(hi)} ${suffix}`.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Match FeedSaldoCard: masuk IOT + saldo awal. */
|
||||||
|
const feedMasukTotal = (ctx: Record<string, unknown>): number | null => {
|
||||||
|
const masuk =
|
||||||
|
typeof ctx.total_karung_masuk_iot === 'number' && Number.isFinite(ctx.total_karung_masuk_iot)
|
||||||
|
? ctx.total_karung_masuk_iot
|
||||||
|
: null;
|
||||||
|
const saldo =
|
||||||
|
typeof ctx.saldo_awal_karung === 'number' && Number.isFinite(ctx.saldo_awal_karung)
|
||||||
|
? ctx.saldo_awal_karung
|
||||||
|
: null;
|
||||||
|
if (masuk === null && saldo === null) return null;
|
||||||
|
return (masuk ?? 0) + (saldo ?? 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requireKandangId = (ctx: Record<string, unknown>): number => {
|
||||||
|
const raw = ctx.kandangId ?? ctx.kandang_id;
|
||||||
|
const id = typeof raw === 'number' ? raw : Number(raw);
|
||||||
|
if (!Number.isFinite(id) || id <= 0) {
|
||||||
|
throw new Error('kandangId wajib — AI Insight tidak boleh digenerate untuk semua kandang');
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CitationsBlock: React.FC<{ citations?: InsightCitation[] }> = ({ citations }) => {
|
||||||
|
if (!citations || citations.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="mt-4 rounded-lg border border-gray-100 bg-gray-50 p-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-2">
|
||||||
|
Sumber referensi
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{citations.map((c, i) => (
|
||||||
|
<li key={c.id ?? `${c.title ?? 'cite'}-${i}`} className="text-xs text-gray-600">
|
||||||
|
<span className="font-medium text-gray-800">
|
||||||
|
{c.chapter || c.title || c.source || `Referensi ${i + 1}`}
|
||||||
|
</span>
|
||||||
|
{c.excerpt ? <span className="text-gray-500"> — {c.excerpt}</span> : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||||
|
topic,
|
||||||
|
contextData,
|
||||||
|
cacheKey,
|
||||||
|
scalarScope,
|
||||||
|
chartSpec,
|
||||||
|
}) => {
|
||||||
|
const { selectedCycle, selectedKandang, selectedSite, selectedKandangId } = useFarm();
|
||||||
|
|
||||||
|
const [result, setResult] = useState<PageInsightResult | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const supportsTimeframe = Boolean(scalarScope);
|
||||||
|
const timeframe: 'harian' | 'mingguan' = 'harian';
|
||||||
|
|
||||||
|
const selectedDayKey = `insight-day-${topic}`;
|
||||||
|
const [selectedDay, setSelectedDayState] = useState<number | null>(() => {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(selectedDayKey);
|
||||||
|
const parsed = raw === null ? NaN : parseInt(raw, 10);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const setSelectedDay = (next: number | null) => {
|
||||||
|
setSelectedDayState(next);
|
||||||
|
try {
|
||||||
|
if (next === null) sessionStorage.removeItem(selectedDayKey);
|
||||||
|
else sessionStorage.setItem(selectedDayKey, String(next));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const availableDays = React.useMemo(() => {
|
||||||
|
const key =
|
||||||
|
scalarScope?.historyKey ?? (chartSpec?.type === 'trend' ? chartSpec.historyKey : null);
|
||||||
|
if (!key) return [];
|
||||||
|
const rows = (contextData as Record<string, unknown>)[key];
|
||||||
|
if (!Array.isArray(rows)) return [];
|
||||||
|
const days = new Set<number>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (typeof row !== 'object' || row === null) continue;
|
||||||
|
const r = row as Record<string, unknown>;
|
||||||
|
for (const field of ['hari', 'day']) {
|
||||||
|
const value = r[field];
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) days.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...days].sort((a, b) => a - b);
|
||||||
|
}, [contextData, scalarScope, chartSpec]);
|
||||||
|
|
||||||
|
const contextDay: number | null = React.useMemo(() => {
|
||||||
|
const raw = contextData as Record<string, unknown> | null | undefined;
|
||||||
|
if (!raw) return null;
|
||||||
|
for (const key of ['hari_ke', 'hari', 'day', 'hari_terakhir']) {
|
||||||
|
const val = raw[key];
|
||||||
|
if (typeof val === 'number' && Number.isFinite(val)) return val;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [contextData]);
|
||||||
|
|
||||||
|
const lastAvailableDay: number | null =
|
||||||
|
availableDays.length > 0 ? availableDays[availableDays.length - 1]! : null;
|
||||||
|
|
||||||
|
// Dropdown shows last available day when nothing is selected — generate/cache
|
||||||
|
// must use that same day, not cycle.current_day (calendar can be ahead of data).
|
||||||
|
const activeDay: number | null =
|
||||||
|
timeframe === 'harian' && selectedDay !== null && availableDays.includes(selectedDay)
|
||||||
|
? selectedDay
|
||||||
|
: null;
|
||||||
|
const effectiveDay: number | null =
|
||||||
|
timeframe === 'harian' ? (activeDay ?? lastAvailableDay ?? contextDay) : null;
|
||||||
|
|
||||||
|
const scopeKey = `${cacheKey}-${timeframe}${effectiveDay === null ? '' : `-h${effectiveDay}`}`;
|
||||||
|
|
||||||
|
const inFlightRef = useRef(false);
|
||||||
|
const lastCacheKeyRef = useRef<string | null>(null);
|
||||||
|
const desiredKeyRef = useRef<string>('');
|
||||||
|
const requestIdRef = useRef(0);
|
||||||
|
const generateRef = useRef<(force?: boolean) => Promise<void>>(async () => {});
|
||||||
|
|
||||||
|
const chartHighlight = React.useMemo(() => {
|
||||||
|
if (!chartSpec || chartSpec.type !== 'trend') return null;
|
||||||
|
const rows = (contextData as Record<string, unknown>)[chartSpec.historyKey];
|
||||||
|
if (!Array.isArray(rows)) return null;
|
||||||
|
const days = rows
|
||||||
|
.map((row) => {
|
||||||
|
const r = row as Record<string, unknown>;
|
||||||
|
for (const key of ['hari', 'day']) {
|
||||||
|
const value = r[key];
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter((day): day is number => day !== null);
|
||||||
|
if (days.length === 0) return null;
|
||||||
|
if (timeframe === 'harian' && effectiveDay !== null) {
|
||||||
|
return { firstDay: 0, lastDay: effectiveDay };
|
||||||
|
}
|
||||||
|
const lastDay = Math.max(...days);
|
||||||
|
const firstDay = timeframe === 'harian' ? lastDay : Math.max(Math.min(...days), lastDay - 6);
|
||||||
|
return { firstDay, lastDay };
|
||||||
|
}, [chartSpec, contextData, timeframe, effectiveDay]);
|
||||||
|
|
||||||
|
const displayContext = React.useMemo(() => {
|
||||||
|
const raw = contextData as Record<string, unknown>;
|
||||||
|
if (!scalarScope) return raw;
|
||||||
|
const sliced = scopeContextToTimeframe(raw, timeframe, effectiveDay);
|
||||||
|
const scoped = scopeScalarsToTimeframe(
|
||||||
|
sliced as Record<string, unknown>,
|
||||||
|
timeframe,
|
||||||
|
scalarScope,
|
||||||
|
effectiveDay
|
||||||
|
);
|
||||||
|
const out: Record<string, unknown> = { ...raw };
|
||||||
|
for (const [outKey, rule] of Object.entries(scalarScope.derive)) {
|
||||||
|
if (rule.replaces) out[rule.replaces] = scoped[outKey];
|
||||||
|
}
|
||||||
|
for (const key of scalarScope.unavailable ?? []) out[key] = UNAVAILABLE;
|
||||||
|
out.periode = scoped.periode;
|
||||||
|
return out;
|
||||||
|
}, [contextData, timeframe, effectiveDay, scalarScope]);
|
||||||
|
|
||||||
|
const periodeLabel = typeof displayContext.periode === 'string' ? displayContext.periode : null;
|
||||||
|
|
||||||
|
const trendChartRows = React.useMemo(() => {
|
||||||
|
if (!chartSpec || chartSpec.type !== 'trend') return [];
|
||||||
|
const rows = (contextData as Record<string, unknown>)[chartSpec.historyKey];
|
||||||
|
if (!Array.isArray(rows)) return [];
|
||||||
|
if (timeframe !== 'harian' || effectiveDay === null) {
|
||||||
|
return rows as Array<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
return (rows as Array<Record<string, unknown>>).filter((row) => {
|
||||||
|
for (const field of ['hari', 'day']) {
|
||||||
|
const value = row[field];
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value <= effectiveDay;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [chartSpec, contextData, timeframe, effectiveDay]);
|
||||||
|
|
||||||
|
const currentDay = selectedCycle?.current_day ?? null;
|
||||||
|
|
||||||
|
const heroSpec = React.useMemo(() => {
|
||||||
|
const n = (value: unknown): number | null =>
|
||||||
|
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||||
|
const standardDay = effectiveDay ?? currentDay ?? null;
|
||||||
|
const standard = standardDay ? getCobbStandardForDay(standardDay) : null;
|
||||||
|
const dayLabel = standardDay ? `hari ke-${standardDay}` : null;
|
||||||
|
|
||||||
|
if (topic === 'berat_ayam') {
|
||||||
|
const actual = n(displayContext.averageWeight);
|
||||||
|
const target = n(displayContext.targetWeight) ?? standard?.weight ?? null;
|
||||||
|
if (actual === null) return null;
|
||||||
|
const dev = target ? ((actual - target) / target) * 100 : null;
|
||||||
|
return {
|
||||||
|
label: 'Rata-rata Bobot Aktual',
|
||||||
|
value: Math.round(actual).toLocaleString('id-ID'),
|
||||||
|
unit: 'g',
|
||||||
|
context: target
|
||||||
|
? `Target CP 707${dayLabel ? ` ${dayLabel}` : ''}: ${Math.round(target).toLocaleString('id-ID')} g`
|
||||||
|
: null,
|
||||||
|
status: (dev === null
|
||||||
|
? 'unknown'
|
||||||
|
: dev >= -5
|
||||||
|
? 'ok'
|
||||||
|
: dev >= -15
|
||||||
|
? 'warning'
|
||||||
|
: 'critical') as InsightStatus,
|
||||||
|
deltaLabel: dev === null ? null : `${dev.toFixed(0)}% vs target`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (topic === 'fcr') {
|
||||||
|
const actual = n(displayContext.fcr_terakhir);
|
||||||
|
if (actual === null) return null;
|
||||||
|
const std = standard?.fcr ?? null;
|
||||||
|
return {
|
||||||
|
label: 'FCR Aktual',
|
||||||
|
value: formatRatio(actual, 3),
|
||||||
|
unit: undefined,
|
||||||
|
context: std
|
||||||
|
? `Standar CP 707${dayLabel ? ` ${dayLabel}` : ''}: ${formatRatio(std, 3)}`
|
||||||
|
: null,
|
||||||
|
status: (std === null
|
||||||
|
? 'unknown'
|
||||||
|
: actual <= std
|
||||||
|
? 'ok'
|
||||||
|
: actual <= std * 1.1
|
||||||
|
? 'warning'
|
||||||
|
: 'critical') as InsightStatus,
|
||||||
|
deltaLabel:
|
||||||
|
std === null
|
||||||
|
? null
|
||||||
|
: `${actual > std ? '+' : ''}${formatRatio(actual - std, 3)} vs standar`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (topic === 'eef') {
|
||||||
|
const actual = n(displayContext.eef_terakhir);
|
||||||
|
if (actual === null) return null;
|
||||||
|
const atHarvest = (currentDay ?? 0) >= 35;
|
||||||
|
return {
|
||||||
|
label: 'EEF',
|
||||||
|
value: formatRatio(actual, 3),
|
||||||
|
unit: undefined,
|
||||||
|
context: atHarvest ? 'Standar umur panen: >300' : 'Belum umur panen — wajar masih rendah',
|
||||||
|
status: (!atHarvest
|
||||||
|
? 'unknown'
|
||||||
|
: actual >= 300
|
||||||
|
? 'ok'
|
||||||
|
: actual >= 250
|
||||||
|
? 'warning'
|
||||||
|
: 'critical') as InsightStatus,
|
||||||
|
deltaLabel: atHarvest ? (actual >= 300 ? 'di atas standar' : 'di bawah standar') : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (topic === 'iot_panel') {
|
||||||
|
const minTemp = n(displayContext.suhu_min_C);
|
||||||
|
const maxTemp = n(displayContext.suhu_max_C);
|
||||||
|
const lastTemp = n(displayContext.suhu_rata_rata_C);
|
||||||
|
const setpoint = n(displayContext.setpoint_C);
|
||||||
|
if (minTemp === null && maxTemp === null && lastTemp === null) return null;
|
||||||
|
const lo = minTemp ?? lastTemp!;
|
||||||
|
const hi = maxTemp ?? lastTemp!;
|
||||||
|
// Grade against the farther extreme from setpoint so a bad stretch that day surfaces.
|
||||||
|
const worstDiff =
|
||||||
|
setpoint === null ? null : Math.max(Math.abs(lo - setpoint), Math.abs(hi - setpoint));
|
||||||
|
const signedWorst =
|
||||||
|
setpoint === null
|
||||||
|
? null
|
||||||
|
: Math.abs(lo - setpoint) >= Math.abs(hi - setpoint)
|
||||||
|
? lo - setpoint
|
||||||
|
: hi - setpoint;
|
||||||
|
return {
|
||||||
|
label: 'Suhu Rata-rata Kandang',
|
||||||
|
value: renderDayRange(lo, hi, ''),
|
||||||
|
unit: '°C',
|
||||||
|
context: setpoint === null ? null : `Setpoint: ${formatNumber(setpoint, 2, 0)} °C`,
|
||||||
|
status: (worstDiff === null
|
||||||
|
? 'unknown'
|
||||||
|
: worstDiff <= 1
|
||||||
|
? 'ok'
|
||||||
|
: worstDiff <= 2.5
|
||||||
|
? 'warning'
|
||||||
|
: 'critical') as InsightStatus,
|
||||||
|
deltaLabel:
|
||||||
|
signedWorst === null
|
||||||
|
? null
|
||||||
|
: `${signedWorst > 0 ? '+' : ''}${formatNumber(signedWorst, 2, 0)} °C vs setpoint`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (topic === 'hitung_karung') {
|
||||||
|
const actual = n(displayContext.total_karung_dituang_iot);
|
||||||
|
if (actual === null) return null;
|
||||||
|
const manual = n(displayContext.total_karung_dituang_manual);
|
||||||
|
const perDay = n(displayContext.rata_karung_per_hari);
|
||||||
|
const masukTotal = feedMasukTotal(displayContext);
|
||||||
|
const parts = [
|
||||||
|
masukTotal === null ? null : `Masuk: ${masukTotal.toLocaleString('id-ID')} karung`,
|
||||||
|
manual === null ? null : `Manual dituang: ${manual.toLocaleString('id-ID')} karung`,
|
||||||
|
perDay === null ? null : `${perDay.toLocaleString('id-ID')} karung/hari`,
|
||||||
|
].filter(Boolean);
|
||||||
|
return {
|
||||||
|
label: 'Pakan Dituang (IOT)',
|
||||||
|
value: actual.toLocaleString('id-ID'),
|
||||||
|
unit: 'karung',
|
||||||
|
context: parts.length > 0 ? parts.join(' · ') : null,
|
||||||
|
status: 'unknown' as InsightStatus,
|
||||||
|
deltaLabel: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (topic === 'hitung_ayam') {
|
||||||
|
const actual = n(displayContext.populasi_kini_ekor);
|
||||||
|
if (actual === null) return null;
|
||||||
|
const awal = n(displayContext.populasi_awal_ekor);
|
||||||
|
const mortPct = n(displayContext.mortalitas_persen);
|
||||||
|
return {
|
||||||
|
label: 'Populasi Kini',
|
||||||
|
value: Math.round(actual).toLocaleString('id-ID'),
|
||||||
|
unit: 'ekor',
|
||||||
|
context:
|
||||||
|
awal === null ? null : `Populasi awal: ${Math.round(awal).toLocaleString('id-ID')} ekor`,
|
||||||
|
status: (mortPct === null
|
||||||
|
? 'unknown'
|
||||||
|
: mortPct < 5
|
||||||
|
? 'ok'
|
||||||
|
: mortPct <= 7
|
||||||
|
? 'warning'
|
||||||
|
: 'critical') as InsightStatus,
|
||||||
|
deltaLabel:
|
||||||
|
mortPct === null
|
||||||
|
? null
|
||||||
|
: `Mortalitas ${mortPct.toLocaleString('id-ID', { maximumFractionDigits: 2 })}%`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [topic, displayContext, currentDay, effectiveDay]);
|
||||||
|
|
||||||
|
const meta = TOPIC_META[topic] ?? TOPIC_META.dashboard;
|
||||||
|
|
||||||
|
const generate = async (force = false) => {
|
||||||
|
const storageKey = `page-insight-v1-${scopeKey}`;
|
||||||
|
const requestId = ++requestIdRef.current;
|
||||||
|
const isCurrent = () => requestIdRef.current === requestId;
|
||||||
|
|
||||||
|
// Soft load: share one in-flight DB GET for the same scope (no client content cache).
|
||||||
|
if (!force && _insightInFlight.has(storageKey)) {
|
||||||
|
const shared = await _insightInFlight.get(storageKey);
|
||||||
|
if (!isCurrent()) return;
|
||||||
|
setResult(shared ?? null);
|
||||||
|
setLoading(false);
|
||||||
|
lastCacheKeyRef.current = scopeKey;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inFlightRef.current && !force) return;
|
||||||
|
inFlightRef.current = true;
|
||||||
|
// Only show the LLM loading UI when the user clicks Generate.
|
||||||
|
if (force) {
|
||||||
|
setLoading(true);
|
||||||
|
setResult(null);
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const fetchPromise = (async (): Promise<PageInsightResult | null> => {
|
||||||
|
try {
|
||||||
|
if (selectedKandangId == null && !(contextData as Record<string, unknown>).kandangId) {
|
||||||
|
throw new Error(
|
||||||
|
'Pilih kandang terlebih dahulu. AI Insight digenerate per kandang, bukan semua kandang.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!selectedCycle?.id) {
|
||||||
|
throw new Error('Data siklus tidak tersedia. Pastikan ada siklus aktif.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawCtx = contextData as Record<string, unknown>;
|
||||||
|
const kandangId = requireKandangId({
|
||||||
|
...rawCtx,
|
||||||
|
kandangId: rawCtx.kandangId ?? selectedKandangId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawCtxDay =
|
||||||
|
effectiveDay ?? rawCtx.hari_ke ?? rawCtx.hari_terakhir ?? rawCtx.currentDay ?? currentDay;
|
||||||
|
const maxDays = (rawCtx.totalDays as number | undefined) ?? selectedCycle.total_days;
|
||||||
|
const currentDayVal =
|
||||||
|
typeof rawCtxDay === 'number' && maxDays && rawCtxDay > maxDays ? maxDays : rawCtxDay;
|
||||||
|
|
||||||
|
const reportPeriod =
|
||||||
|
timeframe === 'harian'
|
||||||
|
? effectiveDay !== null
|
||||||
|
? `Hari ${effectiveDay}`
|
||||||
|
: currentDayVal
|
||||||
|
? `Hari ${currentDayVal}`
|
||||||
|
: null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
let apiResult = null as Awaited<ReturnType<typeof api.insights.generate>> | null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
apiResult = await api.insights.cached({
|
||||||
|
cycle_id: selectedCycle.id,
|
||||||
|
kandang_id: kandangId,
|
||||||
|
topic,
|
||||||
|
report_type: 'page',
|
||||||
|
report_period: reportPeriod,
|
||||||
|
});
|
||||||
|
if (!apiResult?.insight_text && !apiResult?.summary) apiResult = null;
|
||||||
|
} catch {
|
||||||
|
apiResult = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiResult) {
|
||||||
|
if (!isCurrent()) return null;
|
||||||
|
setResult(null);
|
||||||
|
setLoading(false);
|
||||||
|
lastCacheKeyRef.current = scopeKey;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCurrent()) return null;
|
||||||
|
|
||||||
|
if (!apiResult?.insight_text && !apiResult?.summary && !apiResult?.insight) {
|
||||||
|
throw new Error(apiResult?.message || 'Gagal memuat AI Insight.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = apiResult.insight_text ?? '';
|
||||||
|
let parsed: { summary: string; insight: string } | null = null;
|
||||||
|
if (apiResult.summary || apiResult.insight) {
|
||||||
|
parsed = {
|
||||||
|
summary: apiResult.summary || '',
|
||||||
|
insight: apiResult.insight || apiResult.insight_text || '',
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
parsed = parseAiResult(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed || (!parsed.summary && !parsed.insight)) {
|
||||||
|
throw new Error('Respons AI tidak dapat diproses.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrapped: PageInsightResult = {
|
||||||
|
summary: parsed.summary,
|
||||||
|
insight: parsed.insight,
|
||||||
|
citations: apiResult.citations,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isCurrent()) return wrapped;
|
||||||
|
setResult(wrapped);
|
||||||
|
lastCacheKeyRef.current = scopeKey;
|
||||||
|
return wrapped;
|
||||||
|
} catch (err) {
|
||||||
|
if (!isCurrent()) return null;
|
||||||
|
console.error(`[PageAiInsight:${topic}] request failed:`, err);
|
||||||
|
if (force) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
setResult(null);
|
||||||
|
} else {
|
||||||
|
setResult(null);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (isCurrent()) {
|
||||||
|
setLoading(false);
|
||||||
|
inFlightRef.current = false;
|
||||||
|
}
|
||||||
|
_insightInFlight.delete(storageKey);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (!force) {
|
||||||
|
_insightInFlight.set(storageKey, fetchPromise);
|
||||||
|
}
|
||||||
|
await fetchPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
desiredKeyRef.current = scopeKey;
|
||||||
|
generateRef.current = generate;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Day / scope change: clear UI and soft-load from DB (no generate).
|
||||||
|
useEffect(() => {
|
||||||
|
if (lastCacheKeyRef.current === scopeKey) return;
|
||||||
|
requestIdRef.current += 1;
|
||||||
|
setResult(null);
|
||||||
|
setError(null);
|
||||||
|
setLoading(false);
|
||||||
|
inFlightRef.current = false;
|
||||||
|
const timer = setTimeout(() => generateRef.current(false), 50);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [scopeKey]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-gray-100 bg-white shadow-sm overflow-hidden mb-6">
|
||||||
|
<div className="flex items-center justify-between px-5 py-3.5 border-b border-gray-100 bg-gray-50/80">
|
||||||
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
|
<div className="p-1.5 bg-red-50 rounded-lg">
|
||||||
|
<i className={`fa-solid ${meta.icon} text-red-600 text-sm`} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="text-sm font-bold text-gray-900">{meta.title}</span>
|
||||||
|
<p className="text-xs text-gray-400 truncate">
|
||||||
|
{selectedKandang?.kandang_name ?? 'Kandang'}
|
||||||
|
{selectedSite?.site_name ? ` · ${selectedSite.site_name}` : ''}
|
||||||
|
{effectiveDay != null
|
||||||
|
? ` · Hari ke-${effectiveDay}`
|
||||||
|
: currentDay != null
|
||||||
|
? ` · Hari ke-${currentDay}`
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{supportsTimeframe && timeframe === 'harian' && availableDays.length > 1 && (
|
||||||
|
<select
|
||||||
|
value={effectiveDay ?? availableDays[availableDays.length - 1]}
|
||||||
|
onChange={(e) => setSelectedDay(parseInt(e.target.value, 10))}
|
||||||
|
className="text-[11px] font-semibold border border-gray-200 bg-white text-gray-700 rounded-lg px-2 py-1 shadow-sm"
|
||||||
|
title="Pilih hari siklus"
|
||||||
|
>
|
||||||
|
{availableDays.map((d) => (
|
||||||
|
<option key={d} value={d}>
|
||||||
|
Hari {d}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
{selectedKandangId == null && (
|
||||||
|
<div className="flex items-start gap-2 bg-amber-50 border border-amber-100 rounded-xl p-3">
|
||||||
|
<i className="fa-solid fa-triangle-exclamation text-amber-500 mt-0.5" />
|
||||||
|
<p className="text-xs text-amber-800">
|
||||||
|
Pilih satu kandang untuk melihat AI Insight.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && !result && (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-8 justify-center">
|
||||||
|
<div className="w-8 h-8 rounded-full border-4 border-gray-100 border-t-red-600 animate-spin" />
|
||||||
|
<p className="text-sm text-gray-500 text-center max-w-sm">
|
||||||
|
Memuat data AI Insight…
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && !result && (
|
||||||
|
<div className="flex items-start gap-2 bg-red-50 border border-red-100 rounded-xl p-3">
|
||||||
|
<i className="fa-solid fa-triangle-exclamation text-red-400 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-red-600">Gagal memuat insight</p>
|
||||||
|
<p className="text-xs text-red-500 mt-0.5">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => generate(true)}
|
||||||
|
className="text-xs text-red-600 underline mt-1 hover:text-red-800"
|
||||||
|
>
|
||||||
|
Coba lagi
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && !result && selectedKandangId != null && (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 py-8 text-center">
|
||||||
|
<i className="fa-solid fa-brain text-gray-300 text-2xl" />
|
||||||
|
<p className="text-sm text-gray-500">Belum ada AI Insight untuk periode ini.</p>
|
||||||
|
<p className="text-xs text-gray-400">Insight akan tampil setelah disinkronkan dari farm site.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{heroSpec && (
|
||||||
|
<HeroFigure
|
||||||
|
label={heroSpec.label}
|
||||||
|
value={heroSpec.value}
|
||||||
|
unit={heroSpec.unit}
|
||||||
|
context={heroSpec.context}
|
||||||
|
status={heroSpec.status}
|
||||||
|
deltaLabel={heroSpec.deltaLabel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-5">
|
||||||
|
{periodeLabel && (
|
||||||
|
<p className="mb-3 text-xs font-medium text-gray-500">
|
||||||
|
Angka di bawah dihitung dari periode{' '}
|
||||||
|
<span className="text-gray-700">{periodeLabel}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-3 gap-x-6">
|
||||||
|
{topic === 'fcr' && (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||||
|
<span className="text-sm text-gray-600 font-medium">
|
||||||
|
Konsumsi Pakan Kumulatif
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-bold text-gray-900">
|
||||||
|
{renderValue(displayContext.pakan_karung_terakhir, 'Karung')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||||
|
<span className="text-sm text-gray-600 font-medium">Rata-rata Bobot</span>
|
||||||
|
<span className="text-sm font-bold text-gray-900">
|
||||||
|
{renderValue(displayContext.bobot_iot_gram_terakhir, 'g')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||||
|
<span className="text-sm text-gray-600 font-medium">Populasi Hidup</span>
|
||||||
|
<span className="text-sm font-bold text-gray-900">
|
||||||
|
{renderValue(displayContext.ayam_hidup_ekor, 'Ekor')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||||
|
<span className="text-sm text-gray-600 font-medium">Total Bobot Panen</span>
|
||||||
|
<span className="text-sm font-bold text-gray-900">
|
||||||
|
{renderValue(displayContext.beratPanen_kg, 'kg')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{topic === 'berat_ayam' && (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||||
|
<span className="text-sm text-gray-600 font-medium">Target Bobot</span>
|
||||||
|
<span className="text-sm font-bold text-gray-900">
|
||||||
|
{renderValue(displayContext.targetWeight, 'g')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||||
|
<span className="text-sm text-gray-600 font-medium">Uniformity</span>
|
||||||
|
<span className="text-sm font-bold text-gray-900">
|
||||||
|
{renderValue(displayContext.uniformity, '%')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||||
|
<span className="text-sm text-gray-600 font-medium">
|
||||||
|
Average Daily Gain (ADG)
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-bold text-gray-900">
|
||||||
|
{renderValue(displayContext.adg, 'g/hari')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{topic === 'eef' && (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||||
|
<span className="text-sm text-gray-600 font-medium">FCR Aktual</span>
|
||||||
|
<span className="text-sm font-bold text-gray-900">
|
||||||
|
{typeof displayContext.fcr_terakhir === 'number'
|
||||||
|
? formatRatio(displayContext.fcr_terakhir, 3)
|
||||||
|
: renderValue(displayContext.fcr_terakhir)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||||
|
<span className="text-sm text-gray-600 font-medium">Persentase Hidup</span>
|
||||||
|
<span className="text-sm font-bold text-gray-900">
|
||||||
|
{typeof displayContext.persen_hidup === 'number'
|
||||||
|
? `${displayContext.persen_hidup.toLocaleString('id-ID', {
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})} %`
|
||||||
|
: renderValue(displayContext.persen_hidup, '%')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||||
|
<span className="text-sm text-gray-600 font-medium">BW Rata-rata Panen</span>
|
||||||
|
<span className="text-sm font-bold text-gray-900">
|
||||||
|
{typeof displayContext.bw_panen_gram === 'number'
|
||||||
|
? `${displayContext.bw_panen_gram.toLocaleString('id-ID', {
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})} g`
|
||||||
|
: renderValue(displayContext.bw_panen_gram, 'g')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||||
|
<span className="text-sm text-gray-600 font-medium">
|
||||||
|
Umur Rata-rata Panen
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-bold text-gray-900">
|
||||||
|
{typeof displayContext.umur_panen_rata_hari === 'number'
|
||||||
|
? `${displayContext.umur_panen_rata_hari.toLocaleString('id-ID', {
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})} hari`
|
||||||
|
: renderValue(displayContext.umur_panen_rata_hari, 'hari')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{topic === 'iot_panel' && (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||||
|
<span className="text-sm text-gray-600 font-medium">Experience Suhu</span>
|
||||||
|
<span className="text-sm font-bold text-gray-900">
|
||||||
|
{renderDayRange(
|
||||||
|
displayContext.experience_suhu_min_C ?? displayContext.experience_suhu_C,
|
||||||
|
displayContext.experience_suhu_max_C ?? displayContext.experience_suhu_C,
|
||||||
|
'°C'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||||
|
<span className="text-sm text-gray-600 font-medium">Kelembapan</span>
|
||||||
|
<span className="text-sm font-bold text-gray-900">
|
||||||
|
{renderDayRange(
|
||||||
|
displayContext.kelembapan_min_persen ?? displayContext.kelembapan_persen,
|
||||||
|
displayContext.kelembapan_max_persen ?? displayContext.kelembapan_persen,
|
||||||
|
'%'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||||
|
<span className="text-sm text-gray-600 font-medium">Kecepatan Angin</span>
|
||||||
|
<span className="text-sm font-bold text-gray-900">
|
||||||
|
{renderDayRange(
|
||||||
|
displayContext.kecepatan_angin_min_mPerDetik ??
|
||||||
|
displayContext.kecepatan_angin_mPerDetik,
|
||||||
|
displayContext.kecepatan_angin_max_mPerDetik ??
|
||||||
|
displayContext.kecepatan_angin_mPerDetik,
|
||||||
|
'm/s'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{topic === 'hitung_karung' && (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-gray-600 font-medium block">
|
||||||
|
Total Karung Masuk
|
||||||
|
</span>
|
||||||
|
{(typeof displayContext.saldo_awal_karung === 'number' ||
|
||||||
|
typeof displayContext.total_karung_masuk_iot === 'number') && (
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{[
|
||||||
|
typeof displayContext.saldo_awal_karung === 'number'
|
||||||
|
? `Saldo Awal: ${displayContext.saldo_awal_karung.toLocaleString('id-ID')}`
|
||||||
|
: null,
|
||||||
|
typeof displayContext.total_karung_masuk_iot === 'number'
|
||||||
|
? `Masuk IoT: ${displayContext.total_karung_masuk_iot.toLocaleString('id-ID')}`
|
||||||
|
: null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' + ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-bold text-gray-900">
|
||||||
|
{renderValue(feedMasukTotal(displayContext), 'Karung')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||||
|
<span className="text-sm text-gray-600 font-medium">
|
||||||
|
Total Karung Pakan Dituang
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-bold text-gray-900">
|
||||||
|
{renderValue(displayContext.total_karung_dituang_iot, 'Karung')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{topic === 'hitung_ayam' && (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||||
|
<span className="text-sm text-gray-600 font-medium">Populasi Awal</span>
|
||||||
|
<span className="text-sm font-bold text-gray-900">
|
||||||
|
{renderValue(displayContext.populasi_awal_ekor, 'Ekor')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||||
|
<span className="text-sm text-gray-600 font-medium">Populasi Kini</span>
|
||||||
|
<span className="text-sm font-bold text-gray-900">
|
||||||
|
{renderValue(displayContext.populasi_kini_ekor, 'Ekor')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||||
|
<span className="text-sm text-gray-600 font-medium">
|
||||||
|
Mortalitas Kumulatif
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-bold text-gray-900">
|
||||||
|
{renderValue(displayContext.mortalitas_kumulatif_ekor, 'Ekor')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||||
|
<span className="text-sm text-gray-600 font-medium">Mortalitas</span>
|
||||||
|
<span className="text-sm font-bold text-gray-900">
|
||||||
|
{typeof displayContext.mortalitas_persen === 'number'
|
||||||
|
? `${displayContext.mortalitas_persen.toLocaleString('id-ID', {
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})} %`
|
||||||
|
: renderValue(displayContext.mortalitas_persen, '%')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||||
|
<span className="text-sm text-gray-600 font-medium">Panen Kumulatif</span>
|
||||||
|
<span className="text-sm font-bold text-gray-900">
|
||||||
|
{renderValue(displayContext.panen_kumulatif_ekor, 'Ekor')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||||
|
<span className="text-sm text-gray-600 font-medium">Berat Panen</span>
|
||||||
|
<span className="text-sm font-bold text-gray-900">
|
||||||
|
{renderValue(displayContext.berat_panen_kumulatif_kg, 'kg')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{chartSpec && (
|
||||||
|
<div className="mt-6 pt-4 border-t border-gray-100">
|
||||||
|
{chartSpec.type === 'trend' ? (
|
||||||
|
<InsightTrendChart
|
||||||
|
rows={trendChartRows}
|
||||||
|
series={chartSpec.series}
|
||||||
|
unit={chartSpec.unit}
|
||||||
|
title={chartSpec.title}
|
||||||
|
highlight={chartHighlight}
|
||||||
|
gap={chartSpec.gap ?? null}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<InsightConditionChart bars={chartSpec.bars} title={chartSpec.title} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 pt-4 border-t border-gray-100">
|
||||||
|
<h4 className="text-sm font-bold text-gray-800 mb-2 flex items-center gap-2">
|
||||||
|
<i className="fa-solid fa-circle-check text-green-600" /> Kesimpulan
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-700 bg-gray-50 p-3 rounded-lg border border-gray-100">
|
||||||
|
{result.summary}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<h4 className="text-sm font-bold text-gray-800 mb-2 flex items-center gap-2">
|
||||||
|
<i className="fa-solid fa-brain text-red-600" /> AI Insight
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-700 bg-gray-50 p-3 rounded-lg border border-gray-100 whitespace-pre-wrap">
|
||||||
|
{result.insight}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CitationsBlock citations={result.citations} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PageAiInsight;
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import type {
|
import type {
|
||||||
AIInsight,
|
AIInsight,
|
||||||
|
InsightApiResponse,
|
||||||
|
InsightCachedParams,
|
||||||
|
InsightGenerateJob,
|
||||||
|
InsightGenerateParams,
|
||||||
|
InsightJobStatus,
|
||||||
ChickenCounting,
|
ChickenCounting,
|
||||||
ChickenWeight,
|
ChickenWeight,
|
||||||
ActiveSite,
|
ActiveSite,
|
||||||
@@ -264,5 +269,25 @@ export const api = {
|
|||||||
insights: {
|
insights: {
|
||||||
list: async (query?: CycleFilter) =>
|
list: async (query?: CycleFilter) =>
|
||||||
unwrapList(await get<Paginated<AIInsight> | AIInsight[]>('/ai-insights/', query)),
|
unwrapList(await get<Paginated<AIInsight> | AIInsight[]>('/ai-insights/', query)),
|
||||||
|
generate: (body: InsightGenerateParams) => {
|
||||||
|
if (body.kandang_id == null) {
|
||||||
|
return Promise.reject(
|
||||||
|
new Error('kandang_id wajib - insight tidak boleh untuk semua kandang')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return post<InsightApiResponse>('/ai-insights/generate/', body);
|
||||||
|
},
|
||||||
|
generateAsync: (body: InsightGenerateParams) => {
|
||||||
|
if (body.kandang_id == null) {
|
||||||
|
return Promise.reject(
|
||||||
|
new Error('kandang_id wajib - insight tidak boleh untuk semua kandang')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return post<InsightGenerateJob>('/ai-insights/generate/', { ...body, async: true });
|
||||||
|
},
|
||||||
|
jobStatus: (jobId: string) =>
|
||||||
|
get<InsightJobStatus>(`/ai-insights/jobs/${encodeURIComponent(jobId)}/`),
|
||||||
|
cached: (params: InsightCachedParams) =>
|
||||||
|
get<InsightApiResponse>('/ai-insights/cached/', params),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
+143
-4
@@ -272,17 +272,156 @@ export type PanelIoT = {
|
|||||||
export type AIInsight = {
|
export type AIInsight = {
|
||||||
id: number;
|
id: number;
|
||||||
cycle: number;
|
cycle: number;
|
||||||
kandang: number;
|
kandang: number | null;
|
||||||
flock_id: number;
|
flock_id?: number;
|
||||||
date: string;
|
date: string;
|
||||||
insight_text: string;
|
insight_text: string;
|
||||||
|
/** Condition from graded facts: healthy | warning | critical | unknown */
|
||||||
alert: string;
|
alert: string;
|
||||||
section: string;
|
topic: string;
|
||||||
session: string;
|
report_type: string;
|
||||||
|
report_period: string;
|
||||||
|
summary?: string;
|
||||||
|
citations?: InsightCitation[];
|
||||||
|
section?: string;
|
||||||
|
session?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type InsightReportType = 'page' | 'daily' | 'weekly' | 'end_cycle';
|
||||||
|
|
||||||
|
export type InsightTopic =
|
||||||
|
| 'hitung_ayam'
|
||||||
|
| 'berat_ayam'
|
||||||
|
| 'fcr'
|
||||||
|
| 'eef'
|
||||||
|
| 'iot_panel'
|
||||||
|
| 'hitung_karung'
|
||||||
|
| 'dashboard';
|
||||||
|
|
||||||
|
export type InsightCitation = {
|
||||||
|
id?: string;
|
||||||
|
title?: string;
|
||||||
|
chapter?: string;
|
||||||
|
excerpt?: string;
|
||||||
|
source?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Curated page context sent to the insight service.
|
||||||
|
* `kandangId` is required — never generate for "all kandang".
|
||||||
|
*/
|
||||||
|
export type InsightContext = {
|
||||||
|
kandangId: number;
|
||||||
|
kandangName?: string;
|
||||||
|
cycleId?: number;
|
||||||
|
hari_ke?: number | null;
|
||||||
|
totalDays?: number | null;
|
||||||
|
periode?: string | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InsightGenerateParams = {
|
||||||
|
cycle_id: number;
|
||||||
|
kandang_id: number;
|
||||||
|
topic: InsightTopic | string;
|
||||||
|
report_type?: InsightReportType;
|
||||||
|
report_period?: string | null;
|
||||||
|
context: InsightContext;
|
||||||
|
force_refresh?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InsightCachedParams = {
|
||||||
|
cycle_id: number;
|
||||||
|
kandang_id: number;
|
||||||
|
topic: InsightTopic | string;
|
||||||
|
report_type?: InsightReportType;
|
||||||
|
report_period?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InsightJobStage =
|
||||||
|
| 'queued'
|
||||||
|
| 'starting'
|
||||||
|
| 'grading'
|
||||||
|
| 'retrieving'
|
||||||
|
| 'synthesizing'
|
||||||
|
| 'saving'
|
||||||
|
| 'done'
|
||||||
|
| 'error';
|
||||||
|
|
||||||
|
export type InsightGenerateJob = {
|
||||||
|
job_id: string;
|
||||||
|
status: 'queued' | 'running' | 'done' | 'error';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InsightJobStatus = InsightGenerateJob & {
|
||||||
|
stage: InsightJobStage;
|
||||||
|
result: InsightApiResponse | null;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InsightStatusLabel = 'ok' | 'warning' | 'critical' | 'unknown';
|
||||||
|
|
||||||
|
export type InsightStructuredSection = {
|
||||||
|
status: InsightStatusLabel;
|
||||||
|
bullets: string[];
|
||||||
|
evidence: string[];
|
||||||
|
actions: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InsightStructuredReport = {
|
||||||
|
summary: {
|
||||||
|
headline: string;
|
||||||
|
bullets: string[];
|
||||||
|
risks: string[];
|
||||||
|
actions: string[];
|
||||||
|
};
|
||||||
|
detailed?: Partial<
|
||||||
|
Record<
|
||||||
|
'hitung_ayam' | 'berat_ayam' | 'fcr' | 'eef' | 'panel_iot' | 'data_quality',
|
||||||
|
InsightStructuredSection
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Structured end-cycle insight (Masalah / Akar penyebab / Perbaikan). */
|
||||||
|
export type EndCycleRootCause = {
|
||||||
|
id?: string;
|
||||||
|
hipotesis: string;
|
||||||
|
bukti: string[];
|
||||||
|
confidence?: 'high' | 'med' | 'low' | string;
|
||||||
|
fase?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EndCycleAction = {
|
||||||
|
id?: string;
|
||||||
|
fase: string;
|
||||||
|
aksi: string;
|
||||||
|
metrik_pantau?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EndCycleStructuredInsight = {
|
||||||
|
kesimpulan: string;
|
||||||
|
masalah: string[];
|
||||||
|
akar_penyebab: EndCycleRootCause[];
|
||||||
|
perbaikan_siklus_berikutnya: EndCycleAction[];
|
||||||
|
insight?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Response from generate / cached insight endpoints. */
|
||||||
|
export type InsightApiResponse = {
|
||||||
|
success?: boolean;
|
||||||
|
insight_text: string;
|
||||||
|
summary?: string;
|
||||||
|
insight?: string;
|
||||||
|
structured?: InsightStructuredReport | null;
|
||||||
|
structured_end_cycle?: EndCycleStructuredInsight | null;
|
||||||
|
citations?: InsightCitation[];
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type KarungRequestResult = {
|
export type KarungRequestResult = {
|
||||||
karung: Karung;
|
karung: Karung;
|
||||||
upstream: Record<string, unknown>;
|
upstream: Record<string, unknown>;
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Environment standards from the CP 707 book, for client-side charting only.
|
||||||
|
*
|
||||||
|
* Mirrors temperature / air-quality tables in
|
||||||
|
* `backend/apps/operations/services/cp707_knowledge.py` — that Python module
|
||||||
|
* is authoritative for LLM grading. This TS copy exists so IoT charts can
|
||||||
|
* draw targets without a round trip. Keep both in sync when the book edition
|
||||||
|
* changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Target suhu pemeliharaan per umur (buku CP 707). */
|
||||||
|
const TEMPERATURE_STANDARD = [
|
||||||
|
{ from: 1, to: 2, temp_C: 32 },
|
||||||
|
{ from: 3, to: 4, temp_C: 31 },
|
||||||
|
{ from: 5, to: 7, temp_C: 30 },
|
||||||
|
{ from: 8, to: 14, temp_C: 29 },
|
||||||
|
{ from: 15, to: 21, temp_C: 28 },
|
||||||
|
{ from: 22, to: 28, temp_C: 26 },
|
||||||
|
{ from: 29, to: 35, temp_C: 23 },
|
||||||
|
{ from: 36, to: 99, temp_C: 22 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Kelembapan ideal sama untuk semua umur menurut tabel buku: 50–70 %. */
|
||||||
|
export const HUMIDITY_RANGE_PCT = { min: 50, max: 70 };
|
||||||
|
|
||||||
|
/** Standar kualitas udara (Lampiran 3). */
|
||||||
|
export const AIR_QUALITY = {
|
||||||
|
/** Amonia: ideal <10 ppm, batas kritis >25 ppm. */
|
||||||
|
ammoniaSafe_ppm: 10,
|
||||||
|
ammoniaCritical_ppm: 25,
|
||||||
|
/** CO2: ideal <3000 ppm, kritis >3500 ppm. */
|
||||||
|
co2Ideal_ppm: 3000,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standar konsumsi air per 1000 ekor per hari pada suhu 21°C (buku CP 707).
|
||||||
|
* Di atas 21°C kebutuhan naik rata-rata 6.5% per derajat.
|
||||||
|
*/
|
||||||
|
const WATER_CONSUMPTION_STANDARD: { week: number; minLiter: number; maxLiter: number }[] = [
|
||||||
|
{ week: 1, minLiter: 58, maxLiter: 65 },
|
||||||
|
{ week: 2, minLiter: 102, maxLiter: 115 },
|
||||||
|
{ week: 3, minLiter: 149, maxLiter: 167 },
|
||||||
|
{ week: 4, minLiter: 192, maxLiter: 216 },
|
||||||
|
{ week: 5, minLiter: 232, maxLiter: 261 },
|
||||||
|
{ week: 6, minLiter: 274, maxLiter: 308 },
|
||||||
|
{ week: 7, minLiter: 309, maxLiter: 347 },
|
||||||
|
{ week: 8, minLiter: 342, maxLiter: 385 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Standar konsumsi air (rata-rata min–max) untuk umur hari tertentu, atau null. */
|
||||||
|
export const getWaterStandardByDay = (
|
||||||
|
dayAge: number | null | undefined
|
||||||
|
): { min: number; max: number } | null => {
|
||||||
|
if (typeof dayAge !== 'number' || !Number.isFinite(dayAge) || dayAge <= 0) return null;
|
||||||
|
const week = Math.min(Math.ceil(dayAge / 7), WATER_CONSUMPTION_STANDARD.length);
|
||||||
|
const row = WATER_CONSUMPTION_STANDARD.find((r) => r.week === week);
|
||||||
|
return row ? { min: row.minLiter, max: row.maxLiter } : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suhu target untuk umur tertentu, atau null bila umurnya tidak masuk akal.
|
||||||
|
* Umur di luar tabel jatuh ke baris terakhir (36+ hari), sesuai isi buku.
|
||||||
|
*/
|
||||||
|
export const getTempStandardByDay = (dayAge: number | null | undefined): number | null => {
|
||||||
|
if (typeof dayAge !== 'number' || !Number.isFinite(dayAge) || dayAge <= 0) return null;
|
||||||
|
const row = TEMPERATURE_STANDARD.find((r) => dayAge >= r.from && dayAge <= r.to);
|
||||||
|
return row ? row.temp_C : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number out of a display-formatted reading.
|
||||||
|
*
|
||||||
|
* `computeDashboard` hands back strings like "32,1 °C" and "62,8 %" — already
|
||||||
|
* scaled from the raw sensor encoding, which is why they are the safe source —
|
||||||
|
* so the chart has to strip the unit and accept the Indonesian decimal comma.
|
||||||
|
* A plain number passes through unchanged.
|
||||||
|
*/
|
||||||
|
export const parseReading = (value: unknown): number | null => {
|
||||||
|
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
||||||
|
if (typeof value !== 'string' || value.trim() === '') return null;
|
||||||
|
const cleaned = value
|
||||||
|
.replace(/[^\d,.-]/g, '')
|
||||||
|
.replace(/\.(?=\d{3}\b)/g, '')
|
||||||
|
.replace(',', '.');
|
||||||
|
const n = Number(cleaned);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,989 @@
|
|||||||
|
import type { InsightContext } from '../types/api.ts';
|
||||||
|
import { getTargetWeightForAge } from './weight.ts';
|
||||||
|
|
||||||
|
const num = (value: unknown): number | null =>
|
||||||
|
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||||
|
|
||||||
|
/** Round to at most 2 decimal places (avoids float noise like 89.50399999999999). */
|
||||||
|
const round2 = (value: number | null): number | null =>
|
||||||
|
value === null ? null : Math.round(value * 100) / 100;
|
||||||
|
|
||||||
|
/** Positive finite number only — 0 / negative treated as missing sensor data. */
|
||||||
|
const positiveNum = (value: unknown): number | null => {
|
||||||
|
const n = num(value);
|
||||||
|
return n != null && n > 0 ? n : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const requireKandangId = (kandangId: number | null | undefined, topic: string): number => {
|
||||||
|
if (kandangId == null || !Number.isFinite(kandangId) || kandangId <= 0) {
|
||||||
|
throw new Error(`kandangId wajib — insight ${topic} tidak boleh untuk semua kandang`);
|
||||||
|
}
|
||||||
|
return kandangId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dayOf = (row: { day?: unknown; hari?: unknown; age?: unknown }): number | null =>
|
||||||
|
num(row.day) ?? num(row.hari) ?? num(row.age);
|
||||||
|
|
||||||
|
// ─── FCR ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type FcrInsightRow = {
|
||||||
|
day?: number;
|
||||||
|
hari?: number;
|
||||||
|
feedTotal?: number | null;
|
||||||
|
pakan_karung?: number | null;
|
||||||
|
iotWeight?: number | null;
|
||||||
|
/** IoT average bird weight in grams (same as KPI `iot_weight`). */
|
||||||
|
bobot_iot_gram?: number | null;
|
||||||
|
/** @deprecated use bobot_iot_gram — legacy name wrongly implied kg */
|
||||||
|
bobot_iot_kg?: number | null;
|
||||||
|
chickenLife?: number | null;
|
||||||
|
ayam_hidup_ekor?: number | null;
|
||||||
|
harvestWeightKg?: number | null;
|
||||||
|
beratPanen_kg?: number | null;
|
||||||
|
fcr?: number | null;
|
||||||
|
fcr_aktual?: number | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BuildFcrInsightContextArgs = {
|
||||||
|
kandangId: number;
|
||||||
|
kandangName?: string;
|
||||||
|
cycleId?: number;
|
||||||
|
hari_ke?: number | null;
|
||||||
|
totalDays?: number | null;
|
||||||
|
fcrRows?: FcrInsightRow[];
|
||||||
|
/** Optional precomputed latest scalars (units in field names). */
|
||||||
|
fcr_terakhir?: number | null;
|
||||||
|
pakan_karung_terakhir?: number | null;
|
||||||
|
bobot_iot_gram_terakhir?: number | null;
|
||||||
|
/** @deprecated use bobot_iot_gram_terakhir */
|
||||||
|
bobot_iot_kg_terakhir?: number | null;
|
||||||
|
ayam_hidup_ekor?: number | null;
|
||||||
|
beratPanen_kg?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build curated FCR InsightContext for one kandang.
|
||||||
|
* Numeric fields carry units in their names so the model cannot invent units.
|
||||||
|
* IoT weight is grams (matches FCR page StatCards / KPI `iot_weight`).
|
||||||
|
*/
|
||||||
|
export function buildFcrInsightContext(args: BuildFcrInsightContextArgs): InsightContext {
|
||||||
|
const kandangId = requireKandangId(args.kandangId, 'FCR');
|
||||||
|
|
||||||
|
const rows = Array.isArray(args.fcrRows) ? args.fcrRows : [];
|
||||||
|
const tren = rows
|
||||||
|
.map((row) => {
|
||||||
|
const hari = dayOf(row);
|
||||||
|
if (hari === null) return null;
|
||||||
|
// Prefer explicit grams; legacy `bobot_iot_kg` on rows was often mislabeled grams.
|
||||||
|
const bobotGram = num(row.iotWeight) ?? num(row.bobot_iot_gram) ?? num(row.bobot_iot_kg);
|
||||||
|
return {
|
||||||
|
hari,
|
||||||
|
fcr_aktual: num(row.fcr) ?? num(row.fcr_aktual),
|
||||||
|
pakan_karung: num(row.feedTotal) ?? num(row.pakan_karung),
|
||||||
|
bobot_iot_gram: bobotGram,
|
||||||
|
ayam_hidup_ekor: num(row.chickenLife) ?? num(row.ayam_hidup_ekor),
|
||||||
|
beratPanen_kg: num(row.harvestWeightKg) ?? num(row.beratPanen_kg),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((row): row is NonNullable<typeof row> => row !== null)
|
||||||
|
.sort((a, b) => a.hari - b.hari);
|
||||||
|
|
||||||
|
const latest = [...tren].reverse().find((row) => row.fcr_aktual != null) ?? tren[tren.length - 1];
|
||||||
|
|
||||||
|
const fcr_terakhir = args.fcr_terakhir ?? latest?.fcr_aktual ?? null;
|
||||||
|
const pakan_karung_terakhir = args.pakan_karung_terakhir ?? latest?.pakan_karung ?? null;
|
||||||
|
const bobot_iot_gram_terakhir =
|
||||||
|
args.bobot_iot_gram_terakhir ?? args.bobot_iot_kg_terakhir ?? latest?.bobot_iot_gram ?? null;
|
||||||
|
const ayam_hidup_ekor = args.ayam_hidup_ekor ?? latest?.ayam_hidup_ekor ?? null;
|
||||||
|
const beratPanen_kg = args.beratPanen_kg ?? latest?.beratPanen_kg ?? null;
|
||||||
|
const hari_ke = args.hari_ke ?? latest?.hari ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
kandangId,
|
||||||
|
kandangName: args.kandangName,
|
||||||
|
cycleId: args.cycleId,
|
||||||
|
hari_ke,
|
||||||
|
totalDays: args.totalDays ?? null,
|
||||||
|
fcr_terakhir,
|
||||||
|
pakan_karung_terakhir,
|
||||||
|
bobot_iot_gram_terakhir,
|
||||||
|
ayam_hidup_ekor,
|
||||||
|
beratPanen_kg,
|
||||||
|
tren_fcr_harian: tren,
|
||||||
|
tren_7_hari_terakhir: tren.slice(-7).map((r) => ({
|
||||||
|
hari: r.hari,
|
||||||
|
fcr: r.fcr_aktual,
|
||||||
|
pakan: r.pakan_karung,
|
||||||
|
bobot_gram: r.bobot_iot_gram,
|
||||||
|
ayam: r.ayam_hidup_ekor,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── EEF ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type EefInsightRow = {
|
||||||
|
day?: number;
|
||||||
|
hari?: number;
|
||||||
|
eef?: number | null;
|
||||||
|
fcr?: number | null;
|
||||||
|
chickenLifePercentage?: number | null;
|
||||||
|
persen_hidup?: number | null;
|
||||||
|
averageHarvestBw?: number | null;
|
||||||
|
bw_panen_gram?: number | null;
|
||||||
|
averageHarvestDay?: number | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BuildEefInsightContextArgs = {
|
||||||
|
kandangId: number;
|
||||||
|
kandangName?: string;
|
||||||
|
cycleId?: number;
|
||||||
|
hari_ke?: number | null;
|
||||||
|
totalDays?: number | null;
|
||||||
|
eefRows?: EefInsightRow[];
|
||||||
|
eef_terakhir?: number | null;
|
||||||
|
fcr_terakhir?: number | null;
|
||||||
|
persen_hidup?: number | null;
|
||||||
|
bw_panen_gram?: number | null;
|
||||||
|
umur_panen_rata_hari?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildEefInsightContext(args: BuildEefInsightContextArgs): InsightContext {
|
||||||
|
const kandangId = requireKandangId(args.kandangId, 'EEF');
|
||||||
|
|
||||||
|
const rows = Array.isArray(args.eefRows) ? args.eefRows : [];
|
||||||
|
const tren = rows
|
||||||
|
.map((row) => {
|
||||||
|
const hari = dayOf(row);
|
||||||
|
if (hari === null) return null;
|
||||||
|
return {
|
||||||
|
hari,
|
||||||
|
eef: num(row.eef),
|
||||||
|
fcr: num(row.fcr),
|
||||||
|
persen_hidup: round2(num(row.chickenLifePercentage) ?? num(row.persen_hidup)),
|
||||||
|
bw_panen: num(row.averageHarvestBw) ?? num(row.bw_panen_gram),
|
||||||
|
umur_panen_hari: num(row.averageHarvestDay),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((row): row is NonNullable<typeof row> => row !== null)
|
||||||
|
.sort((a, b) => a.hari - b.hari);
|
||||||
|
|
||||||
|
const latest = [...tren].reverse().find((row) => row.eef != null) ?? tren[tren.length - 1];
|
||||||
|
|
||||||
|
return {
|
||||||
|
kandangId,
|
||||||
|
kandangName: args.kandangName,
|
||||||
|
cycleId: args.cycleId,
|
||||||
|
hari_ke: args.hari_ke ?? latest?.hari ?? null,
|
||||||
|
totalDays: args.totalDays ?? null,
|
||||||
|
eef_terakhir: args.eef_terakhir ?? latest?.eef ?? null,
|
||||||
|
fcr_terakhir: args.fcr_terakhir ?? latest?.fcr ?? null,
|
||||||
|
persen_hidup: round2(args.persen_hidup ?? latest?.persen_hidup ?? null),
|
||||||
|
bw_panen_gram: args.bw_panen_gram ?? latest?.bw_panen ?? null,
|
||||||
|
umur_panen_rata_hari: args.umur_panen_rata_hari ?? latest?.umur_panen_hari ?? null,
|
||||||
|
tren_eef_harian: tren,
|
||||||
|
tren_7_hari_terakhir: tren.slice(-7),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Weight ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type WeightInsightRow = {
|
||||||
|
day?: number;
|
||||||
|
hari?: number;
|
||||||
|
age?: number;
|
||||||
|
average_weight?: number | null;
|
||||||
|
bobot_gram?: number | null;
|
||||||
|
target_gram?: number | null;
|
||||||
|
uniformity?: number | null;
|
||||||
|
average_daily_gain?: number | null;
|
||||||
|
adg_gram_per_hari?: number | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BuildWeightInsightContextArgs = {
|
||||||
|
kandangId: number;
|
||||||
|
kandangName?: string;
|
||||||
|
cycleId?: number;
|
||||||
|
hari_ke?: number | null;
|
||||||
|
totalDays?: number | null;
|
||||||
|
docWeight?: number | null;
|
||||||
|
weightRows?: WeightInsightRow[];
|
||||||
|
averageWeight?: number | null;
|
||||||
|
targetWeight?: number | null;
|
||||||
|
uniformity?: number | null;
|
||||||
|
adg?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildWeightInsightContext(args: BuildWeightInsightContextArgs): InsightContext {
|
||||||
|
const kandangId = requireKandangId(args.kandangId, 'berat ayam');
|
||||||
|
const totalDays = args.totalDays ?? null;
|
||||||
|
|
||||||
|
const rows = Array.isArray(args.weightRows) ? args.weightRows : [];
|
||||||
|
const tren = rows
|
||||||
|
.map((row) => {
|
||||||
|
const hari = dayOf(row);
|
||||||
|
if (hari === null) return null;
|
||||||
|
const bobot = num(row.average_weight) ?? num(row.bobot_gram);
|
||||||
|
const doc = num(row.doc_weight) ?? num(args.docWeight) ?? 40;
|
||||||
|
const target = num(row.target_gram) ?? (hari > 0 ? getTargetWeightForAge(hari, doc) : null);
|
||||||
|
return {
|
||||||
|
hari,
|
||||||
|
bobot_gram: bobot != null && bobot > 0 ? bobot : null,
|
||||||
|
target_gram: target,
|
||||||
|
adg_gram_per_hari: num(row.average_daily_gain) ?? num(row.adg_gram_per_hari),
|
||||||
|
uniformity_persen: num(row.uniformity),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((row): row is NonNullable<typeof row> => row !== null)
|
||||||
|
.sort((a, b) => a.hari - b.hari);
|
||||||
|
|
||||||
|
const latest = [...tren].reverse().find((row) => row.bobot_gram != null) ?? tren[tren.length - 1];
|
||||||
|
|
||||||
|
return {
|
||||||
|
kandangId,
|
||||||
|
kandangName: args.kandangName,
|
||||||
|
cycleId: args.cycleId,
|
||||||
|
hari_ke: args.hari_ke ?? latest?.hari ?? null,
|
||||||
|
totalDays,
|
||||||
|
averageWeight: args.averageWeight ?? latest?.bobot_gram ?? null,
|
||||||
|
targetWeight: args.targetWeight ?? latest?.target_gram ?? null,
|
||||||
|
uniformity: args.uniformity ?? latest?.uniformity_persen ?? null,
|
||||||
|
adg: args.adg ?? latest?.adg_gram_per_hari ?? null,
|
||||||
|
tren_harian: tren,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── IoT panel ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type BuildIotInsightContextArgs = {
|
||||||
|
kandangId: number;
|
||||||
|
kandangName?: string;
|
||||||
|
cycleId?: number;
|
||||||
|
hari_ke?: number | null;
|
||||||
|
totalDays?: number | null;
|
||||||
|
suhu_rata_rata_C?: number | null;
|
||||||
|
experience_suhu_C?: number | null;
|
||||||
|
kelembapan_persen?: number | null;
|
||||||
|
kecepatan_angin_mPerDetik?: number | null;
|
||||||
|
konsumsi_air_L?: number | null;
|
||||||
|
setpoint_C?: number | null;
|
||||||
|
suhu_min_C?: number | null;
|
||||||
|
suhu_max_C?: number | null;
|
||||||
|
experience_suhu_min_C?: number | null;
|
||||||
|
experience_suhu_max_C?: number | null;
|
||||||
|
kelembapan_min_persen?: number | null;
|
||||||
|
kelembapan_max_persen?: number | null;
|
||||||
|
kecepatan_angin_min_mPerDetik?: number | null;
|
||||||
|
kecepatan_angin_max_mPerDetik?: number | null;
|
||||||
|
waktu_data?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildIotInsightContext(args: BuildIotInsightContextArgs): InsightContext {
|
||||||
|
const kandangId = requireKandangId(args.kandangId, 'panel IoT');
|
||||||
|
|
||||||
|
return {
|
||||||
|
kandangId,
|
||||||
|
kandangName: args.kandangName,
|
||||||
|
cycleId: args.cycleId,
|
||||||
|
hari_ke: args.hari_ke ?? null,
|
||||||
|
totalDays: args.totalDays ?? null,
|
||||||
|
suhu_rata_rata_C: args.suhu_rata_rata_C ?? null,
|
||||||
|
experience_suhu_C: args.experience_suhu_C ?? null,
|
||||||
|
kelembapan_persen: args.kelembapan_persen ?? null,
|
||||||
|
kecepatan_angin_mPerDetik: args.kecepatan_angin_mPerDetik ?? null,
|
||||||
|
konsumsi_air_L: args.konsumsi_air_L ?? null,
|
||||||
|
setpoint_C: args.setpoint_C ?? null,
|
||||||
|
suhu_min_C: args.suhu_min_C ?? null,
|
||||||
|
suhu_max_C: args.suhu_max_C ?? null,
|
||||||
|
experience_suhu_min_C: args.experience_suhu_min_C ?? null,
|
||||||
|
experience_suhu_max_C: args.experience_suhu_max_C ?? null,
|
||||||
|
kelembapan_min_persen: args.kelembapan_min_persen ?? null,
|
||||||
|
kelembapan_max_persen: args.kelembapan_max_persen ?? null,
|
||||||
|
kecepatan_angin_min_mPerDetik: args.kecepatan_angin_min_mPerDetik ?? null,
|
||||||
|
kecepatan_angin_max_mPerDetik: args.kecepatan_angin_max_mPerDetik ?? null,
|
||||||
|
waktu_data: args.waktu_data ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Feed sack (hitung karung) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type FeedSackInsightRow = {
|
||||||
|
day?: number;
|
||||||
|
hari?: number;
|
||||||
|
masuk?: number | null;
|
||||||
|
dituang?: number | null;
|
||||||
|
keluar?: number | null;
|
||||||
|
masukManual?: number | null;
|
||||||
|
dituangManual?: number | null;
|
||||||
|
in_today?: number | null;
|
||||||
|
feed_use_today?: number | null;
|
||||||
|
out_today?: number | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BuildFeedSackInsightContextArgs = {
|
||||||
|
kandangId: number;
|
||||||
|
kandangName?: string;
|
||||||
|
cycleId?: number;
|
||||||
|
hari_ke?: number | null;
|
||||||
|
totalDays?: number | null;
|
||||||
|
dailyRows?: FeedSackInsightRow[];
|
||||||
|
saldo_awal_karung?: number | null;
|
||||||
|
total_karung_masuk_iot?: number | null;
|
||||||
|
total_karung_dituang_iot?: number | null;
|
||||||
|
total_karung_keluar_iot?: number | null;
|
||||||
|
total_karung_masuk_manual?: number | null;
|
||||||
|
total_karung_dituang_manual?: number | null;
|
||||||
|
rata_karung_per_hari?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildFeedSackInsightContext(args: BuildFeedSackInsightContextArgs): InsightContext {
|
||||||
|
const kandangId = requireKandangId(args.kandangId, 'hitung karung');
|
||||||
|
|
||||||
|
const rows = Array.isArray(args.dailyRows) ? args.dailyRows : [];
|
||||||
|
const tren = rows
|
||||||
|
.map((row) => {
|
||||||
|
const hari = dayOf(row);
|
||||||
|
if (hari === null) return null;
|
||||||
|
return {
|
||||||
|
hari,
|
||||||
|
masuk: num(row.masuk) ?? num(row.in_today),
|
||||||
|
dituang: num(row.dituang) ?? num(row.feed_use_today),
|
||||||
|
keluar: num(row.keluar) ?? num(row.out_today),
|
||||||
|
masukManual: num(row.masukManual),
|
||||||
|
dituangManual: num(row.dituangManual),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((row): row is NonNullable<typeof row> => row !== null)
|
||||||
|
.sort((a, b) => a.hari - b.hari);
|
||||||
|
|
||||||
|
const sumField = (field: 'masuk' | 'dituang' | 'keluar' | 'masukManual' | 'dituangManual') => {
|
||||||
|
if (tren.length === 0) return null;
|
||||||
|
let total = 0;
|
||||||
|
let any = false;
|
||||||
|
for (const row of tren) {
|
||||||
|
const v = row[field];
|
||||||
|
if (v != null) {
|
||||||
|
total += v;
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return any ? total : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const total_dituang_iot = args.total_karung_dituang_iot ?? sumField('dituang');
|
||||||
|
const hari_ke = args.hari_ke ?? (tren.length > 0 ? tren[tren.length - 1]!.hari : null);
|
||||||
|
const rata =
|
||||||
|
args.rata_karung_per_hari ??
|
||||||
|
(total_dituang_iot != null && hari_ke != null && hari_ke > 0
|
||||||
|
? Math.round((total_dituang_iot / hari_ke) * 10) / 10
|
||||||
|
: null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
kandangId,
|
||||||
|
kandangName: args.kandangName,
|
||||||
|
cycleId: args.cycleId,
|
||||||
|
hari_ke,
|
||||||
|
totalDays: args.totalDays ?? null,
|
||||||
|
saldo_awal_karung: args.saldo_awal_karung ?? null,
|
||||||
|
total_karung_masuk_iot: args.total_karung_masuk_iot ?? sumField('masuk'),
|
||||||
|
total_karung_dituang_iot: total_dituang_iot,
|
||||||
|
total_karung_keluar_iot: args.total_karung_keluar_iot ?? sumField('keluar'),
|
||||||
|
total_karung_masuk_manual: args.total_karung_masuk_manual ?? sumField('masukManual'),
|
||||||
|
total_karung_dituang_manual: args.total_karung_dituang_manual ?? sumField('dituangManual'),
|
||||||
|
rata_karung_per_hari: rata,
|
||||||
|
tren_harian: tren,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Counting (hitung ayam) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type CountingInsightRow = {
|
||||||
|
day?: number;
|
||||||
|
hari?: number;
|
||||||
|
stockAkhir?: number | null;
|
||||||
|
populasi?: number | null;
|
||||||
|
mati?: number | null;
|
||||||
|
panen?: number | null;
|
||||||
|
beratPanen?: number | null;
|
||||||
|
/** Running totals when the page already computed them (preferred). */
|
||||||
|
mortalityTotal?: number | null;
|
||||||
|
harvestTotal?: number | null;
|
||||||
|
harvestWeightTotal?: number | null;
|
||||||
|
chickenCount?: number | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BuildCountingInsightContextArgs = {
|
||||||
|
kandangId: number;
|
||||||
|
kandangName?: string;
|
||||||
|
cycleId?: number;
|
||||||
|
hari_ke?: number | null;
|
||||||
|
totalDays?: number | null;
|
||||||
|
dailyRows?: CountingInsightRow[];
|
||||||
|
populasi_awal_ekor?: number | null;
|
||||||
|
populasi_kini_ekor?: number | null;
|
||||||
|
mortalitas_kumulatif_ekor?: number | null;
|
||||||
|
/** Null when DOC-in unknown — never invent 0%. */
|
||||||
|
mortalitas_persen?: number | null;
|
||||||
|
panen_kumulatif_ekor?: number | null;
|
||||||
|
berat_panen_kumulatif_kg?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildCountingInsightContext(args: BuildCountingInsightContextArgs): InsightContext {
|
||||||
|
const kandangId = requireKandangId(args.kandangId, 'hitung ayam');
|
||||||
|
|
||||||
|
const rows = Array.isArray(args.dailyRows) ? args.dailyRows : [];
|
||||||
|
const populasi_awal = args.populasi_awal_ekor ?? null;
|
||||||
|
|
||||||
|
// Sort first, then accumulate — unsorted input would corrupt running totals.
|
||||||
|
const ordered = [...rows]
|
||||||
|
.map((row) => {
|
||||||
|
const hari = dayOf(row);
|
||||||
|
if (hari === null) return null;
|
||||||
|
return { row, hari };
|
||||||
|
})
|
||||||
|
.filter((item): item is { row: CountingInsightRow; hari: number } => item !== null)
|
||||||
|
.sort((a, b) => a.hari - b.hari);
|
||||||
|
|
||||||
|
let runMort = 0;
|
||||||
|
let runPanen = 0;
|
||||||
|
let runBerat = 0;
|
||||||
|
const tren = ordered.map(({ row, hari }) => {
|
||||||
|
const mati = num(row.mati) ?? 0;
|
||||||
|
const panen = num(row.panen) ?? 0;
|
||||||
|
const berat = num(row.beratPanen) ?? 0;
|
||||||
|
runMort += mati;
|
||||||
|
runPanen += panen;
|
||||||
|
runBerat += berat;
|
||||||
|
const mortCum = num(row.mortalityTotal) ?? runMort;
|
||||||
|
const panenCum = num(row.harvestTotal) ?? runPanen;
|
||||||
|
const beratCum = num(row.harvestWeightTotal) ?? runBerat;
|
||||||
|
const mortPct =
|
||||||
|
populasi_awal != null && populasi_awal > 0
|
||||||
|
? Math.round((mortCum / populasi_awal) * 10000) / 100
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
hari,
|
||||||
|
populasi: num(row.stockAkhir) ?? num(row.populasi),
|
||||||
|
mati_ekor: num(row.mati),
|
||||||
|
panen_ekor: num(row.panen),
|
||||||
|
chicken_count_iot_ekor: num(row.chickenCount),
|
||||||
|
mortalitas_kumulatif_ekor: mortCum,
|
||||||
|
panen_kumulatif_ekor: panenCum,
|
||||||
|
berat_panen_kumulatif_kg: Math.round(beratCum * 100) / 100,
|
||||||
|
mortalitas_persen: mortPct,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const latest = tren[tren.length - 1];
|
||||||
|
const mortalitas_ekor =
|
||||||
|
args.mortalitas_kumulatif_ekor ?? latest?.mortalitas_kumulatif_ekor ?? null;
|
||||||
|
const mortalitas_persen =
|
||||||
|
args.mortalitas_persen !== undefined
|
||||||
|
? args.mortalitas_persen
|
||||||
|
: (latest?.mortalitas_persen ??
|
||||||
|
(populasi_awal != null && populasi_awal > 0 && mortalitas_ekor != null
|
||||||
|
? Math.round((mortalitas_ekor / populasi_awal) * 10000) / 100
|
||||||
|
: null));
|
||||||
|
|
||||||
|
return {
|
||||||
|
kandangId,
|
||||||
|
kandangName: args.kandangName,
|
||||||
|
cycleId: args.cycleId,
|
||||||
|
hari_ke: args.hari_ke ?? latest?.hari ?? null,
|
||||||
|
totalDays: args.totalDays ?? null,
|
||||||
|
populasi_awal_ekor: populasi_awal,
|
||||||
|
populasi_kini_ekor: args.populasi_kini_ekor ?? latest?.populasi ?? null,
|
||||||
|
mortalitas_kumulatif_ekor: mortalitas_ekor,
|
||||||
|
mortalitas_persen,
|
||||||
|
panen_kumulatif_ekor: args.panen_kumulatif_ekor ?? latest?.panen_kumulatif_ekor ?? null,
|
||||||
|
berat_panen_kumulatif_kg:
|
||||||
|
args.berat_panen_kumulatif_kg ?? latest?.berat_panen_kumulatif_kg ?? null,
|
||||||
|
tren_populasi_harian: tren,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dashboard ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type BuildDashboardInsightContextArgs = {
|
||||||
|
kandangId: number;
|
||||||
|
kandangName?: string;
|
||||||
|
cycleId?: number;
|
||||||
|
hari_ke?: number | null;
|
||||||
|
totalDays?: number | null;
|
||||||
|
fcr_terakhir?: number | null;
|
||||||
|
eef_terakhir?: number | null;
|
||||||
|
bobot_avg_gram?: number | null;
|
||||||
|
mortalitas_hari_ini_ekor?: number | null;
|
||||||
|
/** Cumulative mortality % — preferred by the backend grader over deriving from % hidup. */
|
||||||
|
mortalitas_kumulatif_persen?: number | null;
|
||||||
|
karung_dituang_hari_ini?: number | null;
|
||||||
|
pakan_kumulatif_karung?: number | null;
|
||||||
|
ayam_hidup_ekor?: number | null;
|
||||||
|
persen_hidup?: number | null;
|
||||||
|
/** Muted-row / detail scalars for LLM analysis. */
|
||||||
|
doc_in_ekor?: number | null;
|
||||||
|
kematian_kumulatif_ekor?: number | null;
|
||||||
|
panen_kumulatif_ekor?: number | null;
|
||||||
|
tonase_panen_kg?: number | null;
|
||||||
|
uniformity_persen?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildDashboardInsightContext(
|
||||||
|
args: BuildDashboardInsightContextArgs
|
||||||
|
): InsightContext {
|
||||||
|
const kandangId = requireKandangId(args.kandangId, 'dashboard');
|
||||||
|
|
||||||
|
return {
|
||||||
|
kandangId,
|
||||||
|
kandangName: args.kandangName,
|
||||||
|
cycleId: args.cycleId,
|
||||||
|
hari_ke: args.hari_ke ?? null,
|
||||||
|
totalDays: args.totalDays ?? null,
|
||||||
|
fcr_terakhir: args.fcr_terakhir != null && args.fcr_terakhir > 0 ? args.fcr_terakhir : null,
|
||||||
|
eef_terakhir: args.eef_terakhir != null && args.eef_terakhir > 0 ? args.eef_terakhir : null,
|
||||||
|
bobot_avg_gram:
|
||||||
|
args.bobot_avg_gram != null && args.bobot_avg_gram > 0 ? args.bobot_avg_gram : null,
|
||||||
|
mortalitas_hari_ini_ekor: args.mortalitas_hari_ini_ekor ?? null,
|
||||||
|
mortalitas_kumulatif_persen: args.mortalitas_kumulatif_persen ?? null,
|
||||||
|
karung_dituang_hari_ini: args.karung_dituang_hari_ini ?? null,
|
||||||
|
pakan_kumulatif_karung: args.pakan_kumulatif_karung ?? null,
|
||||||
|
ayam_hidup_ekor: args.ayam_hidup_ekor ?? null,
|
||||||
|
persen_hidup: args.persen_hidup ?? null,
|
||||||
|
doc_in_ekor: args.doc_in_ekor ?? null,
|
||||||
|
kematian_kumulatif_ekor: args.kematian_kumulatif_ekor ?? null,
|
||||||
|
panen_kumulatif_ekor: args.panen_kumulatif_ekor ?? null,
|
||||||
|
tonase_panen_kg: args.tonase_panen_kg ?? null,
|
||||||
|
uniformity_persen: args.uniformity_persen ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Day-indexed weight point for dashboard insight chart (display only). */
|
||||||
|
export type DashboardInsightWeightPoint = {
|
||||||
|
hari: number;
|
||||||
|
actual: number | null;
|
||||||
|
target: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Day-indexed KPI snapshot so the card can scope tiles to a selected day. */
|
||||||
|
export type DashboardInsightKpiPoint = {
|
||||||
|
hari: number;
|
||||||
|
fcr: number | null;
|
||||||
|
eef: number | null;
|
||||||
|
ayam_hidup_ekor: number | null;
|
||||||
|
mortalitas_kumulatif_persen: number | null;
|
||||||
|
bobot_avg_gram: number | null;
|
||||||
|
kematian_kumulatif_ekor: number | null;
|
||||||
|
panen_kumulatif_ekor: number | null;
|
||||||
|
tonase_panen_kg: number | null;
|
||||||
|
uniformity_persen: number | null;
|
||||||
|
pakan_kumulatif_karung: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display-only metrics for main-dashboard AI Insight visuals.
|
||||||
|
* Kept separate from LLM `InsightContext` so chart series are not sent to the model.
|
||||||
|
*/
|
||||||
|
export type DashboardInsightMetrics = {
|
||||||
|
hari_ke: number | null;
|
||||||
|
fcr: number | null;
|
||||||
|
eef: number | null;
|
||||||
|
ayam_hidup_ekor: number | null;
|
||||||
|
mortalitas_kumulatif_persen: number | null;
|
||||||
|
bobot_avg_gram: number | null;
|
||||||
|
/** Cycle DOC intake — constant across days. */
|
||||||
|
doc_in_ekor: number | null;
|
||||||
|
kematian_kumulatif_ekor: number | null;
|
||||||
|
panen_kumulatif_ekor: number | null;
|
||||||
|
tonase_panen_kg: number | null;
|
||||||
|
uniformity_persen: number | null;
|
||||||
|
pakan_kumulatif_karung: number | null;
|
||||||
|
weightSeries: DashboardInsightWeightPoint[];
|
||||||
|
kpiSeries: DashboardInsightKpiPoint[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BuildDashboardInsightMetricsArgs = {
|
||||||
|
/** KPI rows already filtered by visibility / anchor (newest-first or any order). */
|
||||||
|
kpis: Array<{
|
||||||
|
age?: number | null;
|
||||||
|
date?: string | null;
|
||||||
|
fcr?: number | null;
|
||||||
|
eef?: number | null;
|
||||||
|
chicken_life?: number | null;
|
||||||
|
chicken_life_percentage?: number | null;
|
||||||
|
iot_weight?: number | null;
|
||||||
|
mortality_total?: number | null;
|
||||||
|
harvest_total?: number | null;
|
||||||
|
harvest_weight_total?: number | null;
|
||||||
|
feed_total?: number | null;
|
||||||
|
}>;
|
||||||
|
/** Weight rows already filtered by visibility / anchor. */
|
||||||
|
weights: Array<{
|
||||||
|
age?: number | null;
|
||||||
|
date?: string | null;
|
||||||
|
average_weight?: number | null;
|
||||||
|
doc_weight?: number | null;
|
||||||
|
uniformity?: number | null;
|
||||||
|
}>;
|
||||||
|
/** Fallback day label when no KPI age is present. */
|
||||||
|
currentDay?: number | null;
|
||||||
|
/** DOC chick-in count for the cycle (muted row). */
|
||||||
|
docInCount?: number | null;
|
||||||
|
/** Optional override when KPI feed_total is missing (e.g. karung.feed_use_total). */
|
||||||
|
pakanKumulatifFallback?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mortalitasFromPersenHidup = (persenHidup: number | null): number | null => {
|
||||||
|
if (persenHidup === null || !Number.isFinite(persenHidup)) return null;
|
||||||
|
return Math.round((100 - persenHidup) * 100) / 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build display metrics for dashboard AI Insight heroes / tiles / weight chart.
|
||||||
|
* Callers must pre-filter rows with `throughVisibleDate` (and anchor) like Dashboard does.
|
||||||
|
*/
|
||||||
|
export function buildDashboardInsightMetrics(
|
||||||
|
args: BuildDashboardInsightMetricsArgs
|
||||||
|
): DashboardInsightMetrics {
|
||||||
|
const weightByAge = new Map<
|
||||||
|
number,
|
||||||
|
{ actual: number | null; target: number | null; uniformity: number | null }
|
||||||
|
>();
|
||||||
|
for (const row of args.weights) {
|
||||||
|
const hari = num(row.age);
|
||||||
|
if (hari === null || hari < 0) continue;
|
||||||
|
const actual = positiveNum(row.average_weight);
|
||||||
|
const doc = num(row.doc_weight) ?? 40;
|
||||||
|
const target = getTargetWeightForAge(hari, doc);
|
||||||
|
weightByAge.set(hari, {
|
||||||
|
actual,
|
||||||
|
target,
|
||||||
|
uniformity: num(row.uniformity),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const weightSeries: DashboardInsightWeightPoint[] = [...weightByAge.entries()]
|
||||||
|
.map(([hari, point]) => ({ hari, actual: point.actual, target: point.target }))
|
||||||
|
.sort((a, b) => a.hari - b.hari);
|
||||||
|
|
||||||
|
const kpiByAge = new Map<number, DashboardInsightKpiPoint>();
|
||||||
|
for (const row of args.kpis) {
|
||||||
|
const hari = num(row.age);
|
||||||
|
if (hari === null || hari < 0) continue;
|
||||||
|
const iotWeight = positiveNum(row.iot_weight);
|
||||||
|
const weightFromSeries = weightByAge.get(hari)?.actual ?? null;
|
||||||
|
const bobot = iotWeight !== null ? iotWeight : weightFromSeries;
|
||||||
|
const persenHidup = num(row.chicken_life_percentage);
|
||||||
|
const uniformity = weightByAge.get(hari)?.uniformity ?? null;
|
||||||
|
kpiByAge.set(hari, {
|
||||||
|
hari,
|
||||||
|
fcr: positiveNum(row.fcr),
|
||||||
|
eef: positiveNum(row.eef),
|
||||||
|
// chicken_life is DOC − mortality; in-house remaining subtracts harvest.
|
||||||
|
ayam_hidup_ekor: (() => {
|
||||||
|
const life = num(row.chicken_life);
|
||||||
|
if (life == null) return null;
|
||||||
|
return Math.max(0, life - (num(row.harvest_total) ?? 0));
|
||||||
|
})(),
|
||||||
|
mortalitas_kumulatif_persen: mortalitasFromPersenHidup(persenHidup),
|
||||||
|
bobot_avg_gram: bobot,
|
||||||
|
kematian_kumulatif_ekor: num(row.mortality_total),
|
||||||
|
panen_kumulatif_ekor: num(row.harvest_total),
|
||||||
|
// KPI harvest_weight_total is grams; insight tile labels kg.
|
||||||
|
tonase_panen_kg: (() => {
|
||||||
|
const g = num(row.harvest_weight_total);
|
||||||
|
return g == null ? null : g / 1000;
|
||||||
|
})(),
|
||||||
|
uniformity_persen: uniformity,
|
||||||
|
pakan_kumulatif_karung: num(row.feed_total),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const kpiSeries = [...kpiByAge.values()].sort((a, b) => a.hari - b.hari);
|
||||||
|
const latestKpi = kpiSeries.length > 0 ? kpiSeries[kpiSeries.length - 1]! : null;
|
||||||
|
const latestWeight = weightSeries.length > 0 ? weightSeries[weightSeries.length - 1]! : null;
|
||||||
|
const latestWeightMeta = latestWeight != null ? weightByAge.get(latestWeight.hari) : undefined;
|
||||||
|
|
||||||
|
const bobotLatest =
|
||||||
|
latestKpi?.bobot_avg_gram ??
|
||||||
|
(latestWeight?.actual != null && latestWeight.actual > 0 ? latestWeight.actual : null);
|
||||||
|
|
||||||
|
const hari_ke =
|
||||||
|
latestKpi?.hari ??
|
||||||
|
latestWeight?.hari ??
|
||||||
|
(num(args.currentDay) != null && (args.currentDay as number) >= 0
|
||||||
|
? num(args.currentDay)
|
||||||
|
: null);
|
||||||
|
|
||||||
|
const docIn =
|
||||||
|
num(args.docInCount) != null && (args.docInCount as number) > 0 ? num(args.docInCount) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
hari_ke,
|
||||||
|
fcr: latestKpi?.fcr ?? null,
|
||||||
|
eef: latestKpi?.eef ?? null,
|
||||||
|
ayam_hidup_ekor: latestKpi?.ayam_hidup_ekor ?? null,
|
||||||
|
mortalitas_kumulatif_persen: latestKpi?.mortalitas_kumulatif_persen ?? null,
|
||||||
|
bobot_avg_gram: bobotLatest,
|
||||||
|
doc_in_ekor: docIn,
|
||||||
|
kematian_kumulatif_ekor: latestKpi?.kematian_kumulatif_ekor ?? null,
|
||||||
|
panen_kumulatif_ekor: latestKpi?.panen_kumulatif_ekor ?? null,
|
||||||
|
tonase_panen_kg: latestKpi?.tonase_panen_kg ?? null,
|
||||||
|
uniformity_persen: latestKpi?.uniformity_persen ?? latestWeightMeta?.uniformity ?? null,
|
||||||
|
pakan_kumulatif_karung:
|
||||||
|
latestKpi?.pakan_kumulatif_karung ?? num(args.pakanKumulatifFallback) ?? null,
|
||||||
|
weightSeries,
|
||||||
|
kpiSeries,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One IoT day aggregate for end-cycle environment rollups. */
|
||||||
|
export type EndCycleIotDayAggregate = {
|
||||||
|
date: string;
|
||||||
|
hari: number | null;
|
||||||
|
avg_temp_C: number | null;
|
||||||
|
avg_humidity_pct: number | null;
|
||||||
|
sample_count: number;
|
||||||
|
hours_temp_below: number;
|
||||||
|
hours_humidity_above: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EndCycleWeeklySummary = {
|
||||||
|
minggu_ke: number;
|
||||||
|
jumlah_hari_data: number;
|
||||||
|
fcr_rata_rasio: number | null;
|
||||||
|
fcr_akhir_rasio: number | null;
|
||||||
|
bobot_akhir_gram: number | null;
|
||||||
|
pakan_akhir_karung: number | null;
|
||||||
|
mortalitas_kumulatif_persen: number | null;
|
||||||
|
mortalitas_delta_persen: number | null;
|
||||||
|
jam_suhu_di_bawah_standar: number;
|
||||||
|
jam_kelembapan_di_atas_standar: number;
|
||||||
|
jam_amonia_tinggi: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EndCyclePhaseSummary = {
|
||||||
|
jumlah_minggu: number;
|
||||||
|
mortalitas_delta_persen: number | null;
|
||||||
|
fcr_akhir_rasio: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EndCycleRollups = {
|
||||||
|
weekly_summaries: EndCycleWeeklySummary[];
|
||||||
|
phase_summaries: Partial<Record<'brooding' | 'growth' | 'finisher', EndCyclePhaseSummary>>;
|
||||||
|
mati_ekor: number | null;
|
||||||
|
panen_ekor: number | null;
|
||||||
|
saldo_ekor: number | null;
|
||||||
|
doc_in_ekor: number | null;
|
||||||
|
tren_kpi_harian: DashboardInsightKpiPoint[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const avg = (values: number[]): number | null =>
|
||||||
|
values.length
|
||||||
|
? Math.round((values.reduce((a, b) => a + b, 0) / values.length) * 1000) / 1000
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build weekly/phase rollups for end-cycle insight from dashboard KPI series.
|
||||||
|
* Optional IoT day aggregates enrich temperature/humidity out-of-range hours.
|
||||||
|
*/
|
||||||
|
export function buildEndCycleRollups(
|
||||||
|
metrics: DashboardInsightMetrics,
|
||||||
|
iotDays: EndCycleIotDayAggregate[] = []
|
||||||
|
): EndCycleRollups {
|
||||||
|
const byWeek = new Map<number, DashboardInsightKpiPoint[]>();
|
||||||
|
for (const row of metrics.kpiSeries) {
|
||||||
|
if (!row.hari || row.hari <= 0) continue;
|
||||||
|
const week = Math.max(1, Math.ceil(row.hari / 7));
|
||||||
|
const list = byWeek.get(week) ?? [];
|
||||||
|
list.push(row);
|
||||||
|
byWeek.set(week, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const iotByWeek = new Map<number, EndCycleIotDayAggregate[]>();
|
||||||
|
for (const day of iotDays) {
|
||||||
|
const hari = day.hari;
|
||||||
|
if (hari == null || hari <= 0) continue;
|
||||||
|
const week = Math.max(1, Math.ceil(hari / 7));
|
||||||
|
const list = iotByWeek.get(week) ?? [];
|
||||||
|
list.push(day);
|
||||||
|
iotByWeek.set(week, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekly_summaries: EndCycleWeeklySummary[] = [];
|
||||||
|
let prevMort: number | null = null;
|
||||||
|
for (const week of [...byWeek.keys()].sort((a, b) => a - b)) {
|
||||||
|
const rows = [...(byWeek.get(week) ?? [])].sort((a, b) => a.hari - b.hari);
|
||||||
|
const fcrs = rows.map((r) => r.fcr).filter((v): v is number => v != null);
|
||||||
|
const bws = rows.map((r) => r.bobot_avg_gram).filter((v): v is number => v != null);
|
||||||
|
const feeds = rows.map((r) => r.pakan_kumulatif_karung).filter((v): v is number => v != null);
|
||||||
|
const morts = rows
|
||||||
|
.map((r) => r.mortalitas_kumulatif_persen)
|
||||||
|
.filter((v): v is number => v != null);
|
||||||
|
const mortEnd = morts.length ? morts[morts.length - 1]! : null;
|
||||||
|
const mortDelta =
|
||||||
|
mortEnd == null ? null : Math.round((mortEnd - (prevMort ?? 0)) * 1000) / 1000;
|
||||||
|
if (mortEnd != null) prevMort = mortEnd;
|
||||||
|
|
||||||
|
const iotRows = iotByWeek.get(week) ?? [];
|
||||||
|
weekly_summaries.push({
|
||||||
|
minggu_ke: week,
|
||||||
|
jumlah_hari_data: rows.length,
|
||||||
|
fcr_rata_rasio: avg(fcrs),
|
||||||
|
fcr_akhir_rasio: fcrs.length ? fcrs[fcrs.length - 1]! : null,
|
||||||
|
bobot_akhir_gram: bws.length ? bws[bws.length - 1]! : null,
|
||||||
|
pakan_akhir_karung: feeds.length ? feeds[feeds.length - 1]! : null,
|
||||||
|
mortalitas_kumulatif_persen: mortEnd,
|
||||||
|
mortalitas_delta_persen: mortDelta,
|
||||||
|
jam_suhu_di_bawah_standar: iotRows.reduce((s, d) => s + (d.hours_temp_below || 0), 0),
|
||||||
|
jam_kelembapan_di_atas_standar: iotRows.reduce(
|
||||||
|
(s, d) => s + (d.hours_humidity_above || 0),
|
||||||
|
0
|
||||||
|
),
|
||||||
|
jam_amonia_tinggi: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const phase_summaries: EndCycleRollups['phase_summaries'] = {};
|
||||||
|
const bucket = (phase: 'brooding' | 'growth' | 'finisher', weeks: number[]) => {
|
||||||
|
const rows = weekly_summaries.filter((w) => weeks.includes(w.minggu_ke));
|
||||||
|
if (!rows.length) return;
|
||||||
|
const deltas = rows.map((r) => r.mortalitas_delta_persen).filter((v): v is number => v != null);
|
||||||
|
phase_summaries[phase] = {
|
||||||
|
jumlah_minggu: rows.length,
|
||||||
|
mortalitas_delta_persen: deltas.length
|
||||||
|
? Math.round(deltas.reduce((a, b) => a + b, 0) * 1000) / 1000
|
||||||
|
: null,
|
||||||
|
fcr_akhir_rasio: rows[rows.length - 1]?.fcr_akhir_rasio ?? null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
bucket('brooding', [1, 2]);
|
||||||
|
bucket('growth', [3, 4]);
|
||||||
|
bucket(
|
||||||
|
'finisher',
|
||||||
|
weekly_summaries.map((w) => w.minggu_ke).filter((w) => w >= 5)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
weekly_summaries,
|
||||||
|
phase_summaries,
|
||||||
|
mati_ekor: metrics.kematian_kumulatif_ekor,
|
||||||
|
panen_ekor: metrics.panen_kumulatif_ekor,
|
||||||
|
saldo_ekor: metrics.ayam_hidup_ekor,
|
||||||
|
doc_in_ekor: metrics.doc_in_ekor,
|
||||||
|
tren_kpi_harian: metrics.kpiSeries,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate raw panel-iot readings into per-day env summaries with OOR hours.
|
||||||
|
* `hari` is derived from cycle start when possible.
|
||||||
|
*/
|
||||||
|
export function buildIotDayAggregates(args: {
|
||||||
|
rows: Array<{
|
||||||
|
date: string;
|
||||||
|
timestamp?: string | null;
|
||||||
|
average_temperature?: number | null;
|
||||||
|
humidity?: number | null;
|
||||||
|
}>;
|
||||||
|
cycleStartDate?: string | null;
|
||||||
|
getTempTarget: (hari: number) => number | null;
|
||||||
|
humidityMax?: number;
|
||||||
|
}): EndCycleIotDayAggregate[] {
|
||||||
|
const humidityMax = args.humidityMax ?? 70;
|
||||||
|
const byDate = new Map<
|
||||||
|
string,
|
||||||
|
{ temps: number[]; hums: number[]; below: number; above: number }
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const row of args.rows) {
|
||||||
|
const date = row.date;
|
||||||
|
if (!date) continue;
|
||||||
|
const bucket = byDate.get(date) ?? { temps: [], hums: [], below: 0, above: 0 };
|
||||||
|
const temp = num(row.average_temperature);
|
||||||
|
const hum = num(row.humidity);
|
||||||
|
let hari: number | null = null;
|
||||||
|
if (args.cycleStartDate) {
|
||||||
|
const start = new Date(`${args.cycleStartDate}T00:00:00`);
|
||||||
|
const cur = new Date(`${date}T00:00:00`);
|
||||||
|
if (!Number.isNaN(start.getTime()) && !Number.isNaN(cur.getTime())) {
|
||||||
|
hari = Math.floor((cur.getTime() - start.getTime()) / 86400000) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (temp != null) {
|
||||||
|
bucket.temps.push(temp);
|
||||||
|
const target = hari != null ? args.getTempTarget(hari) : null;
|
||||||
|
if (target != null && temp < target - 3) {
|
||||||
|
// Treat each sample as ~1 hour contribution when dense; else count as 1.
|
||||||
|
bucket.below += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hum != null) {
|
||||||
|
bucket.hums.push(hum);
|
||||||
|
if (hum > humidityMax) bucket.above += 1;
|
||||||
|
}
|
||||||
|
byDate.set(date, bucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: EndCycleIotDayAggregate[] = [];
|
||||||
|
for (const [date, bucket] of [...byDate.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
||||||
|
let hari: number | null = null;
|
||||||
|
if (args.cycleStartDate) {
|
||||||
|
const start = new Date(`${args.cycleStartDate}T00:00:00`);
|
||||||
|
const cur = new Date(`${date}T00:00:00`);
|
||||||
|
if (!Number.isNaN(start.getTime()) && !Number.isNaN(cur.getTime())) {
|
||||||
|
hari = Math.floor((cur.getTime() - start.getTime()) / 86400000) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push({
|
||||||
|
date,
|
||||||
|
hari,
|
||||||
|
avg_temp_C: avg(bucket.temps),
|
||||||
|
avg_humidity_pct: avg(bucket.hums),
|
||||||
|
sample_count: bucket.temps.length || bucket.hums.length,
|
||||||
|
hours_temp_below: bucket.below,
|
||||||
|
hours_humidity_above: bucket.above,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope display metrics to a selected cycle day.
|
||||||
|
* Weight chart keeps rows with `hari <= upToDay`.
|
||||||
|
* Tile scalars use the KPI snapshot at exactly that day — never a newer day's values.
|
||||||
|
*/
|
||||||
|
export function scopeDashboardInsightMetrics(
|
||||||
|
metrics: DashboardInsightMetrics,
|
||||||
|
upToDay: number | null
|
||||||
|
): DashboardInsightMetrics {
|
||||||
|
if (upToDay === null || !Number.isFinite(upToDay) || upToDay < 0) {
|
||||||
|
return metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
const weightSeries = metrics.weightSeries.filter((row) => row.hari <= upToDay);
|
||||||
|
const kpiSeries = metrics.kpiSeries.filter((row) => row.hari <= upToDay);
|
||||||
|
const exact = metrics.kpiSeries.find((row) => row.hari === upToDay) ?? null;
|
||||||
|
const weightAtDay = weightSeries.find((row) => row.hari === upToDay) ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
hari_ke: upToDay,
|
||||||
|
fcr: exact?.fcr ?? null,
|
||||||
|
eef: exact?.eef ?? null,
|
||||||
|
ayam_hidup_ekor: exact?.ayam_hidup_ekor ?? null,
|
||||||
|
mortalitas_kumulatif_persen: exact?.mortalitas_kumulatif_persen ?? null,
|
||||||
|
bobot_avg_gram: exact?.bobot_avg_gram ?? weightAtDay?.actual ?? null,
|
||||||
|
doc_in_ekor: metrics.doc_in_ekor,
|
||||||
|
kematian_kumulatif_ekor: exact?.kematian_kumulatif_ekor ?? null,
|
||||||
|
panen_kumulatif_ekor: exact?.panen_kumulatif_ekor ?? null,
|
||||||
|
tonase_panen_kg: exact?.tonase_panen_kg ?? null,
|
||||||
|
uniformity_persen: exact?.uniformity_persen ?? null,
|
||||||
|
pakan_kumulatif_karung: exact?.pakan_kumulatif_karung ?? null,
|
||||||
|
weightSeries,
|
||||||
|
kpiSeries,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { cobbStandardPerformance } from '../mockData/cobbStandard.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze trend of numerical data over a window
|
||||||
|
* @param data Array of numerical values
|
||||||
|
* @param windowSize Number of recent data points to analyze
|
||||||
|
* @returns Trend description in Indonesian
|
||||||
|
*/
|
||||||
|
export function analyzeTrend(data: number[], windowSize = 7): string {
|
||||||
|
if (!data || data.length < 2) {
|
||||||
|
return 'tidak cukup data';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out invalid values (null, undefined, NaN, 0)
|
||||||
|
const validData = data.filter((val) => val != null && !isNaN(val) && val !== 0);
|
||||||
|
|
||||||
|
if (validData.length < 2) {
|
||||||
|
return 'tidak cukup data';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take last N values
|
||||||
|
const recentData = validData.slice(-Math.min(windowSize, validData.length));
|
||||||
|
|
||||||
|
if (recentData.length < 2) {
|
||||||
|
return 'tidak cukup data';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate simple linear regression slope
|
||||||
|
const n = recentData.length;
|
||||||
|
const xMean = (n - 1) / 2; // Mean of indices 0, 1, 2, ..., n-1
|
||||||
|
const yMean = recentData.reduce((sum, val) => sum + val, 0) / n;
|
||||||
|
|
||||||
|
let numerator = 0;
|
||||||
|
let denominator = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
numerator += (i - xMean) * ((recentData[i] ?? 0) - yMean);
|
||||||
|
denominator += Math.pow(i - xMean, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const slope = denominator !== 0 ? numerator / denominator : 0;
|
||||||
|
|
||||||
|
// Calculate relative slope (as percentage of mean)
|
||||||
|
const relativeSlope = yMean !== 0 ? (slope / yMean) * 100 : 0;
|
||||||
|
|
||||||
|
// Determine trend based on relative slope
|
||||||
|
// Threshold: >2% = increasing, <-2% = decreasing, else stable
|
||||||
|
if (relativeSlope > 2) {
|
||||||
|
return 'meningkat';
|
||||||
|
} else if (relativeSlope < -2) {
|
||||||
|
return 'menurun';
|
||||||
|
} else {
|
||||||
|
return 'stabil';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cycle phase based on day of cycle
|
||||||
|
* @param day Current day of cycle (1-based)
|
||||||
|
* @returns Phase name
|
||||||
|
*/
|
||||||
|
export function getCyclePhase(day: number): string {
|
||||||
|
if (day <= 14) {
|
||||||
|
return 'Brooding';
|
||||||
|
} else if (day <= 28) {
|
||||||
|
return 'Growth';
|
||||||
|
} else {
|
||||||
|
return 'Finisher';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Cobb standard performance metrics for a given day
|
||||||
|
* @param day Day of cycle (1-based)
|
||||||
|
* @returns Object with FCR and weight, or null if not available
|
||||||
|
*/
|
||||||
|
export function getCobbStandardForDay(day: number): { fcr: number; weight: number } | null {
|
||||||
|
const standard = cobbStandardPerformance.find((entry) => entry.day === day);
|
||||||
|
if (standard) {
|
||||||
|
return {
|
||||||
|
fcr: standard.fcr,
|
||||||
|
// Weight targets live in page builders / CP707 knowledge; FCR row has no weight column here.
|
||||||
|
weight: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate deviation between actual and target values
|
||||||
|
* @param actual Actual value
|
||||||
|
* @param target Target value
|
||||||
|
* @returns Object with absolute and percentage deviation
|
||||||
|
*/
|
||||||
|
export function calculateDeviation(
|
||||||
|
actual: number,
|
||||||
|
target: number
|
||||||
|
): { absolute: number; percent: number } {
|
||||||
|
const absolute = actual - target;
|
||||||
|
const percent = target !== 0 ? (absolute / target) * 100 : 0;
|
||||||
|
return { absolute, percent };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine environmental status based on readings and cycle phase
|
||||||
|
* @param temp Temperature in Celsius
|
||||||
|
* @param humidity Humidity percentage
|
||||||
|
* @param ammonia Ammonia in ppm
|
||||||
|
* @param day Current day of cycle
|
||||||
|
* @returns Status description in Indonesian
|
||||||
|
*/
|
||||||
|
export function getEnvironmentalStatus(
|
||||||
|
temp: number,
|
||||||
|
humidity: number,
|
||||||
|
ammonia: number,
|
||||||
|
day: number
|
||||||
|
): string {
|
||||||
|
const issues: string[] = [];
|
||||||
|
|
||||||
|
// Age-appropriate temperature ranges
|
||||||
|
// Younger chicks need warmer temperatures
|
||||||
|
let optimalTempMin: number;
|
||||||
|
let optimalTempMax: number;
|
||||||
|
|
||||||
|
if (day <= 7) {
|
||||||
|
optimalTempMin = 32;
|
||||||
|
optimalTempMax = 35;
|
||||||
|
} else if (day <= 14) {
|
||||||
|
optimalTempMin = 30;
|
||||||
|
optimalTempMax = 32;
|
||||||
|
} else if (day <= 21) {
|
||||||
|
optimalTempMin = 28;
|
||||||
|
optimalTempMax = 30;
|
||||||
|
} else {
|
||||||
|
optimalTempMin = 26;
|
||||||
|
optimalTempMax = 28;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check temperature
|
||||||
|
if (temp < optimalTempMin) {
|
||||||
|
issues.push('suhu terlalu rendah');
|
||||||
|
} else if (temp > optimalTempMax) {
|
||||||
|
issues.push('suhu terlalu tinggi');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check humidity (optimal: 50-70%)
|
||||||
|
if (humidity < 50) {
|
||||||
|
issues.push('kelembapan rendah');
|
||||||
|
} else if (humidity > 70) {
|
||||||
|
issues.push('kelembapan tinggi');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ammonia (safe: <25ppm, warning: 25-50ppm, critical: >50ppm)
|
||||||
|
if (ammonia > 50) {
|
||||||
|
issues.push('amonia kritis');
|
||||||
|
} else if (ammonia > 25) {
|
||||||
|
issues.push('amonia tinggi');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (issues.length === 0) {
|
||||||
|
return 'optimal';
|
||||||
|
} else if (issues.length === 1) {
|
||||||
|
return issues[0] ?? 'optimal';
|
||||||
|
} else {
|
||||||
|
return issues.join(', ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze feed consumption trend
|
||||||
|
* @param feedHistory Array of daily feed consumption (in sacks)
|
||||||
|
* @param _currentDay Current day of cycle (reserved for future use)
|
||||||
|
* @returns Trend description in Indonesian
|
||||||
|
*/
|
||||||
|
export function analyzeFeedTrend(feedHistory: number[], _currentDay: number): string {
|
||||||
|
if (!feedHistory || feedHistory.length < 3) {
|
||||||
|
return 'tidak cukup data';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get last 7 days of feed data
|
||||||
|
const recentFeed = feedHistory.slice(-7);
|
||||||
|
|
||||||
|
// Rough expectation: feed consumption should increase gradually as chickens grow
|
||||||
|
// Early days: 0.5-1 sack/1000 birds/day
|
||||||
|
// Mid cycle: 1-2 sacks/1000 birds/day
|
||||||
|
// Late cycle: 2-3 sacks/1000 birds/day
|
||||||
|
|
||||||
|
// This is a simplified heuristic - in real deployment, this would be calibrated
|
||||||
|
// based on actual farm data and population size
|
||||||
|
|
||||||
|
// For now, just analyze if consumption is increasing (expected as birds grow)
|
||||||
|
const trend = analyzeTrend(recentFeed, 7);
|
||||||
|
|
||||||
|
if (trend === 'meningkat') {
|
||||||
|
return 'sesuai pertumbuhan';
|
||||||
|
} else if (trend === 'menurun') {
|
||||||
|
return 'menurun (perlu perhatian)';
|
||||||
|
} else {
|
||||||
|
return 'stabil';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* Turns whatever JSON the local model returns into {summary, insight}.
|
||||||
|
*
|
||||||
|
* The models routinely invent their own field names — `tren_mortalitas_harian`,
|
||||||
|
* `dampak_populasi`, `rekomendasi_tindakan` instead of `kesimpulan`/`insight`.
|
||||||
|
* The old per-page parser answered that by substituting a cheerful default
|
||||||
|
* ("Analisis operasional kandang terpantau dengan baik."), which could mask a
|
||||||
|
* report of total flock loss, and the counting page answered it by discarding
|
||||||
|
* the response outright.
|
||||||
|
*
|
||||||
|
* Rule here: never invent reassuring text, and never silently drop content. Any
|
||||||
|
* field the model produced is folded into the output; only a response with no
|
||||||
|
* usable text at all returns null, so the UI can show a real error instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface AiResult {
|
||||||
|
summary: string;
|
||||||
|
insight: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Field names seen standing in for the conclusion. */
|
||||||
|
const KESIMPULAN_KEYS = [
|
||||||
|
'kesimpulan',
|
||||||
|
'kesimpulan_analisis',
|
||||||
|
'ringkasan',
|
||||||
|
'headline',
|
||||||
|
'summary',
|
||||||
|
'conclusion',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Field names seen standing in for the recommendation. */
|
||||||
|
const INSIGHT_KEYS = [
|
||||||
|
'insight',
|
||||||
|
'insights',
|
||||||
|
'rekomendasi',
|
||||||
|
'rekomendasi_tindakan',
|
||||||
|
'tindakan',
|
||||||
|
'saran',
|
||||||
|
'actions',
|
||||||
|
'recommendations',
|
||||||
|
];
|
||||||
|
|
||||||
|
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
|
/** Turns a leftover field name into a readable label: `tren_mortalitas_harian` -> `Tren mortalitas harian`. */
|
||||||
|
const humanizeKey = (key: string) => {
|
||||||
|
const spaced = key.replace(/[_-]+/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2');
|
||||||
|
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Flattens any value into readable plain text. Arrays become bullet lines. */
|
||||||
|
const renderValue = (value: unknown, depth = 0): string => {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
if (typeof value === 'string') return value.trim();
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.map((item) => renderValue(item, depth + 1))
|
||||||
|
.filter((item) => item.length > 0)
|
||||||
|
.map((item) => (depth === 0 ? `• ${item}` : item))
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlainObject(value)) {
|
||||||
|
return Object.entries(value)
|
||||||
|
.map(([key, nested]) => {
|
||||||
|
const rendered = renderValue(nested, depth + 1);
|
||||||
|
return rendered ? `${humanizeKey(key)}: ${rendered}` : '';
|
||||||
|
})
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const takeFirst = (source: Record<string, unknown>, keys: string[]) => {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (key in source) {
|
||||||
|
const rendered = renderValue(source[key]);
|
||||||
|
if (rendered) return { key, text: rendered };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Strips think-tags, code fences, comments and trailing commas, then isolates the JSON object. */
|
||||||
|
const extractJsonObject = (text: string): string | null => {
|
||||||
|
const cleaned = text
|
||||||
|
.replace(/<think>[\s\S]*?<\/think>/gi, '')
|
||||||
|
.replace(/^```(?:json)?\s*/i, '')
|
||||||
|
.replace(/\s*```\s*$/, '')
|
||||||
|
.replace(/\/\/.*/g, '')
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||||
|
.replace(/,\s*([}\]])/g, '$1')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const first = cleaned.indexOf('{');
|
||||||
|
const last = cleaned.lastIndexOf('}');
|
||||||
|
if (first === -1 || last === -1 || last <= first) return null;
|
||||||
|
return cleaned.slice(first, last + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseAiResult = (text: string): AiResult | null => {
|
||||||
|
if (!text || typeof text !== 'string') return null;
|
||||||
|
|
||||||
|
const candidate = extractJsonObject(text);
|
||||||
|
if (!candidate) return null;
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(candidate);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isPlainObject(parsed)) return null;
|
||||||
|
|
||||||
|
const used = new Set<string>();
|
||||||
|
|
||||||
|
const kesimpulanHit = takeFirst(parsed, KESIMPULAN_KEYS);
|
||||||
|
if (kesimpulanHit) used.add(kesimpulanHit.key);
|
||||||
|
|
||||||
|
const insightHit = takeFirst(parsed, INSIGHT_KEYS);
|
||||||
|
if (insightHit) used.add(insightHit.key);
|
||||||
|
|
||||||
|
// Anything the model invented a name for still carries its analysis, so it is
|
||||||
|
// appended under a readable label rather than thrown away.
|
||||||
|
const leftovers = Object.entries(parsed)
|
||||||
|
.filter(([key]) => !used.has(key))
|
||||||
|
.map(([key, value]) => {
|
||||||
|
const rendered = renderValue(value);
|
||||||
|
return rendered ? `${humanizeKey(key)}: ${rendered}` : '';
|
||||||
|
})
|
||||||
|
.filter((line) => line.length > 0);
|
||||||
|
|
||||||
|
let kesimpulan = kesimpulanHit?.text ?? '';
|
||||||
|
let insight = insightHit?.text ?? '';
|
||||||
|
|
||||||
|
if (!kesimpulan && leftovers.length > 0) {
|
||||||
|
// Promote the first salvaged field to the conclusion so the card is never
|
||||||
|
// headed by a generic sentence the model never wrote.
|
||||||
|
kesimpulan = leftovers.shift() as string;
|
||||||
|
}
|
||||||
|
if (leftovers.length > 0) {
|
||||||
|
insight = [insight, ...leftovers].filter(Boolean).join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!kesimpulan && !insight) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary: kesimpulan || 'Model tidak mengembalikan ringkasan eksplisit.',
|
||||||
|
insight: insight || 'Model tidak mengembalikan rekomendasi eksplisit.',
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
/**
|
||||||
|
* Narrows a per-page insight's context data down to the period the user picked.
|
||||||
|
*
|
||||||
|
* The per-page AI Insight used to send the whole cycle to the model no matter
|
||||||
|
* which timeframe was selected, and only changed a label in the prompt — so
|
||||||
|
* "Harian" and "Mingguan" produced near-identical answers. This trims the data
|
||||||
|
* itself, which is the only thing the model actually reads.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type InsightTimeframe = 'harian' | 'mingguan';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rows of a day-indexed history carry a day number and/or a date.
|
||||||
|
*
|
||||||
|
* Pages are inconsistent about the field name: the dashboard builds `day`/`date`
|
||||||
|
* while the FCR and EEF pages build `hari`/`tanggal`. Recognising only the
|
||||||
|
* English spelling meant those two pages' histories were never sliced at all —
|
||||||
|
* their timeframe selector changed nothing.
|
||||||
|
*/
|
||||||
|
type DayRow = Record<string, unknown> & {
|
||||||
|
day?: unknown;
|
||||||
|
hari?: unknown;
|
||||||
|
date?: unknown;
|
||||||
|
tanggal?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DAY_KEYS = ['day', 'hari'] as const;
|
||||||
|
const DATE_KEYS = ['date', 'tanggal'] as const;
|
||||||
|
|
||||||
|
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
|
const dayOf = (row: DayRow): number | null => {
|
||||||
|
for (const key of DAY_KEYS) {
|
||||||
|
const value = row[key];
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasDateField = (row: DayRow): boolean =>
|
||||||
|
DATE_KEYS.some((key) => typeof row[key] === 'string');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A history indexed by time, as opposed to a snapshot list (cameras, devices,
|
||||||
|
* coops) that has no time dimension. Only the former may be sliced by period —
|
||||||
|
* trimming a device list to "today" would silently hide devices.
|
||||||
|
*/
|
||||||
|
const isDayIndexedArray = (value: unknown[]): value is DayRow[] =>
|
||||||
|
value.length > 0 &&
|
||||||
|
value.every(
|
||||||
|
(item) =>
|
||||||
|
isPlainObject(item) && (dayOf(item as DayRow) !== null || hasDateField(item as DayRow))
|
||||||
|
);
|
||||||
|
|
||||||
|
const sliceHistory = (
|
||||||
|
rows: DayRow[],
|
||||||
|
timeframe: InsightTimeframe,
|
||||||
|
selectedDay: number | null = null
|
||||||
|
): DayRow[] => {
|
||||||
|
const days = rows.map(dayOf).filter((day): day is number => day !== null);
|
||||||
|
|
||||||
|
if (timeframe === 'harian') {
|
||||||
|
if (days.length > 0) {
|
||||||
|
// Picking a day means "the cycle so far, up to that day" — day 0 through
|
||||||
|
// day N — not that day in isolation. A single row shows no trend and
|
||||||
|
// cannot support a cumulative figure like mortality-to-date.
|
||||||
|
if (selectedDay !== null)
|
||||||
|
return rows.filter((row) => (dayOf(row) ?? Infinity) <= selectedDay);
|
||||||
|
return rows.filter((row) => dayOf(row) === Math.max(...days));
|
||||||
|
}
|
||||||
|
// Date-only history: the array position is the only ordering we have.
|
||||||
|
if (selectedDay !== null) return rows.slice(0, selectedDay + 1);
|
||||||
|
return rows.slice(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (days.length > 0) {
|
||||||
|
const latestDay = Math.max(...days);
|
||||||
|
// The 7-day window ending today, matched on each row's own `day` so gaps in
|
||||||
|
// the data do not shift the window.
|
||||||
|
return rows.filter((row) => {
|
||||||
|
const day = dayOf(row);
|
||||||
|
return day !== null && day > latestDay - 7 && day <= latestDay;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows.slice(-7);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a copy of `contextData` where every day-indexed history is trimmed to
|
||||||
|
* `timeframe`. Snapshot lists, scalars, and nested objects are preserved.
|
||||||
|
*/
|
||||||
|
export const scopeContextToTimeframe = <T>(
|
||||||
|
contextData: T,
|
||||||
|
timeframe: InsightTimeframe,
|
||||||
|
selectedDay: number | null = null
|
||||||
|
): T => {
|
||||||
|
if (Array.isArray(contextData)) {
|
||||||
|
const scoped = isDayIndexedArray(contextData)
|
||||||
|
? sliceHistory(contextData as DayRow[], timeframe, selectedDay)
|
||||||
|
: contextData;
|
||||||
|
return scoped.map((item) =>
|
||||||
|
scopeContextToTimeframe(item, timeframe, selectedDay)
|
||||||
|
) as unknown as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlainObject(contextData)) {
|
||||||
|
const scoped: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(contextData)) {
|
||||||
|
scoped[key] = scopeContextToTimeframe(value, timeframe, selectedDay);
|
||||||
|
}
|
||||||
|
return scoped as unknown as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return contextData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UNAVAILABLE = 'tidak tersedia';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How one page's summary scalars relate to its day-indexed history.
|
||||||
|
*
|
||||||
|
* `derive` maps an OUTPUT key — which must carry its unit, e.g.
|
||||||
|
* `bobotRataRata_gram` — to the history field it is computed from and how to
|
||||||
|
* aggregate it across the window. `drop` lists the original unitless keys the
|
||||||
|
* derived ones replace; leaving them in would hand the model both the stale
|
||||||
|
* figure and the period one and let it pick. `unavailable` names scalars with
|
||||||
|
* no day-indexed source at all: they are marked rather than left showing a
|
||||||
|
* value from a different period.
|
||||||
|
*/
|
||||||
|
export type ScalarScopeSpec = {
|
||||||
|
historyKey: string;
|
||||||
|
/**
|
||||||
|
* `replaces` names the original key this derived value stands in for. The
|
||||||
|
* prompt drops the original, but the card's summary grid still renders it —
|
||||||
|
* without the link the grid kept showing cycle-to-date figures beside a
|
||||||
|
* narrative scoped to one period, which reads as the toggle doing nothing.
|
||||||
|
*/
|
||||||
|
derive: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
field: string;
|
||||||
|
/**
|
||||||
|
* `last` takes the final row verbatim. `lastValid` walks backwards past
|
||||||
|
* zeros — use it for readings where 0 means "the sensor sent nothing",
|
||||||
|
* not "the measurement was zero". The IoT scale stopped reporting at day
|
||||||
|
* 38 and has written 0 every day since, so `last` handed the card
|
||||||
|
* "Rata-rata Bobot 0 kg" while the page beside it showed the real figure.
|
||||||
|
*/
|
||||||
|
agg: 'mean' | 'sum' | 'last' | 'lastValid';
|
||||||
|
replaces?: string;
|
||||||
|
/**
|
||||||
|
* What to write when the window holds no usable reading. The default is
|
||||||
|
* `UNAVAILABLE`, which is right for figures a missing day says nothing
|
||||||
|
* about. Set `0` for the ones the supervisor wants reported as a plain
|
||||||
|
* zero instead — a day with no weighing (day 48) has to render "0 g",
|
||||||
|
* not a blank, because a blank read as a broken card.
|
||||||
|
*/
|
||||||
|
emptyAs?: 0;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
drop?: string[];
|
||||||
|
unavailable?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const round2 = (value: number) => Math.round(value * 100) / 100;
|
||||||
|
|
||||||
|
const aggregate = (rows: DayRow[], field: string, agg: 'mean' | 'sum' | 'last' | 'lastValid') => {
|
||||||
|
const values = rows
|
||||||
|
.map((row) => row[field])
|
||||||
|
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value));
|
||||||
|
if (values.length === 0) return null;
|
||||||
|
if (agg === 'lastValid') {
|
||||||
|
// Mirrors `getLatestValid` on the FCR/EEF pages, so the insight card and
|
||||||
|
// the page it sits on cannot report different numbers. All-zero means the
|
||||||
|
// reading is missing, which has to surface as "tidak tersedia" rather than
|
||||||
|
// a confident 0.
|
||||||
|
for (let i = values.length - 1; i >= 0; i--) {
|
||||||
|
const value = values[i];
|
||||||
|
if (value !== undefined && value !== 0) return value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (agg === 'last') return values[values.length - 1] ?? null;
|
||||||
|
const total = values.reduce((sum, value) => sum + value, 0);
|
||||||
|
return round2(agg === 'sum' ? total : total / values.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bring a per-page context's scalars into the same period as its history.
|
||||||
|
*
|
||||||
|
* Slicing the history alone is not enough: a "Mingguan" payload whose chart
|
||||||
|
* covers days 42-48 while `averageWeight` still holds the day-48 figure lets
|
||||||
|
* the model quote either, which is the exact contradiction already fixed on the
|
||||||
|
* dashboard. Run this AFTER `scopeContextToTimeframe`, on the sliced context.
|
||||||
|
*/
|
||||||
|
export const scopeScalarsToTimeframe = (
|
||||||
|
scopedContext: Record<string, unknown>,
|
||||||
|
timeframe: InsightTimeframe,
|
||||||
|
spec: ScalarScopeSpec,
|
||||||
|
selectedDay: number | null = null
|
||||||
|
): Record<string, unknown> => {
|
||||||
|
const history = scopedContext[spec.historyKey];
|
||||||
|
if (!Array.isArray(history)) return scopedContext;
|
||||||
|
const rows = history as DayRow[];
|
||||||
|
|
||||||
|
const out: Record<string, unknown> = { ...scopedContext };
|
||||||
|
for (const key of spec.drop ?? []) delete out[key];
|
||||||
|
|
||||||
|
const days = rows.map(dayOf).filter((day): day is number => day !== null);
|
||||||
|
// Harian = cumulative from cycle start (hari ke-0), matching dashboard reports.
|
||||||
|
// Do not label from the first day that happens to have a row (e.g. "2 s/d 8").
|
||||||
|
let label: string;
|
||||||
|
if (days.length === 0) {
|
||||||
|
label = UNAVAILABLE;
|
||||||
|
} else if (timeframe === 'harian') {
|
||||||
|
const endDay = selectedDay ?? Math.max(...days);
|
||||||
|
label =
|
||||||
|
selectedDay === null && Math.min(...days) === Math.max(...days)
|
||||||
|
? `hari ke-${endDay}`
|
||||||
|
: `hari ke-0 s/d ke-${endDay}`;
|
||||||
|
} else if (Math.min(...days) === Math.max(...days)) {
|
||||||
|
label = `hari ke-${Math.max(...days)}`;
|
||||||
|
} else {
|
||||||
|
label = `hari ke-${Math.min(...days)} s/d ke-${Math.max(...days)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [outKey, rule] of Object.entries(spec.derive)) {
|
||||||
|
const value = aggregate(rows, rule.field, rule.agg);
|
||||||
|
const finalValue = value === null ? (rule.emptyAs ?? UNAVAILABLE) : value;
|
||||||
|
out[outKey] = finalValue;
|
||||||
|
if (rule.replaces) out[rule.replaces] = finalValue;
|
||||||
|
}
|
||||||
|
for (const key of spec.unavailable ?? []) out[key] = UNAVAILABLE;
|
||||||
|
|
||||||
|
out.periode = label;
|
||||||
|
// Only the multi-day window needs the "not today" warning; saying it in daily
|
||||||
|
// mode contradicts the label directly above it.
|
||||||
|
// Only a window that is not today needs the "not today" warning; saying it in
|
||||||
|
// a single-day-today report contradicts the label directly above it.
|
||||||
|
const satuHariTerakhir =
|
||||||
|
timeframe === 'harian' && days.length > 0 && Math.min(...days) === Math.max(...days);
|
||||||
|
const bukanHariIni = satuHariTerakhir ? '' : 'BUKAN kondisi hari ini. ';
|
||||||
|
const dataSpan =
|
||||||
|
days.length > 0 && Math.min(...days) > 0
|
||||||
|
? ` Baris data tersedia mulai hari ke-${Math.min(...days)}.`
|
||||||
|
: '';
|
||||||
|
out.catatan_periode =
|
||||||
|
`Angka ringkas di objek ini dihitung dari periode ${label} (${rows.length} hari data).${dataSpan} ` +
|
||||||
|
`${bukanHariIni}Satuan setiap angka tertulis di akhir nama field ` +
|
||||||
|
`(_gram, _persen, _rasio, _karung, _ekor, _kg) — pakai satuan itu persis. ` +
|
||||||
|
`Field berisi "${UNAVAILABLE}" memang tidak ada datanya: tulis "data tidak tersedia" ` +
|
||||||
|
`dan JANGAN mengarang angkanya.`;
|
||||||
|
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Human-readable description of the window, for the prompt and the UI. */
|
||||||
|
export const describeTimeframe = (
|
||||||
|
timeframe: InsightTimeframe,
|
||||||
|
currentDay?: number | null,
|
||||||
|
selectedDay: number | null = null
|
||||||
|
) => {
|
||||||
|
if (timeframe === 'harian') {
|
||||||
|
// Daily now means "the cycle so far", so the label must state the range. A
|
||||||
|
// past end-day must not be described as "hari ini" either — the model
|
||||||
|
// repeats that phrasing back and claims the figures are today's.
|
||||||
|
if (selectedDay !== null) {
|
||||||
|
const ekor = `hari ke-0 s/d ke-${selectedDay} (kumulatif sejak awal siklus)`;
|
||||||
|
return selectedDay === currentDay ? `${ekor}, berakhir hari ini` : `${ekor}, BUKAN hari ini`;
|
||||||
|
}
|
||||||
|
return currentDay ? `Hari ini saja (hari ke-${currentDay})` : 'Hari ini saja (hari terakhir)';
|
||||||
|
}
|
||||||
|
return currentDay
|
||||||
|
? `7 hari terakhir (hari ke-${Math.max(1, currentDay - 6)} sampai hari ke-${currentDay})`
|
||||||
|
: '7 hari terakhir';
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* KPI.chicken_life = DOC − cumulative mortality (excludes harvest).
|
||||||
|
* Stock akhir (in-house remaining) = chicken_life − harvest_total.
|
||||||
|
*/
|
||||||
|
export function stockAkhirFromKpi(
|
||||||
|
kpi: { chicken_life?: number | null; harvest_total?: number | null } | null | undefined
|
||||||
|
): number | null {
|
||||||
|
if (kpi?.chicken_life == null) return null;
|
||||||
|
return Math.max(0, kpi.chicken_life - (kpi.harvest_total ?? 0));
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { KpiStatus } from '../components/KpiCard.tsx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daily mortality % of DOC thresholds (manager operational rules):
|
||||||
|
* - <= 0.05%: Normal / OK (green)
|
||||||
|
* - > 0.05% and < 0.10%: Warning (yellow)
|
||||||
|
* - >= 0.10%: Critical (red)
|
||||||
|
*/
|
||||||
|
export const DAILY_MORTALITY_DOC_THRESHOLDS = {
|
||||||
|
warning_pct: 0.05,
|
||||||
|
critical_pct: 0.1,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates daily mortality percentage relative to initial DOC count.
|
||||||
|
*/
|
||||||
|
export function getDailyMortalityPct(
|
||||||
|
mortalityToday: number | null | undefined,
|
||||||
|
docInCount: number | null | undefined
|
||||||
|
): number | null {
|
||||||
|
if (mortalityToday == null || docInCount == null || docInCount <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (mortalityToday / docInCount) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates the KPI status for daily mortality based on DOC thresholds.
|
||||||
|
* - 'na' when data is unavailable
|
||||||
|
* - 'critical' when daily mortality >= 0.10%
|
||||||
|
* - 'warning' when daily mortality > 0.05%
|
||||||
|
* - 'ok' when daily mortality <= 0.05%
|
||||||
|
*/
|
||||||
|
export function getDailyMortalityStatus(
|
||||||
|
mortalityToday: number | null | undefined,
|
||||||
|
docInCount: number | null | undefined
|
||||||
|
): KpiStatus {
|
||||||
|
if (mortalityToday == null) return 'na';
|
||||||
|
if (docInCount == null || docInCount <= 0) return 'ok';
|
||||||
|
|
||||||
|
const dailyPct = (mortalityToday / docInCount) * 100;
|
||||||
|
if (dailyPct >= DAILY_MORTALITY_DOC_THRESHOLDS.critical_pct) {
|
||||||
|
return 'critical';
|
||||||
|
}
|
||||||
|
if (dailyPct > DAILY_MORTALITY_DOC_THRESHOLDS.warning_pct) {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
Reference in new issue
Block a user