kpi configuration (still prototype some data still use manual need to update later)
This commit is contained in:
1 parent
e4dbfd0252
commit
ed84d989b7
12 files changed
+547
-89
No files matched your search
+32
-31
@@ -6,7 +6,6 @@ import type {
|
||||
ChickenWeight,
|
||||
Karung,
|
||||
KPI,
|
||||
LatestAvg,
|
||||
ManualInput,
|
||||
} from '../types/api.ts';
|
||||
import type { View } from '../types/common.ts';
|
||||
@@ -20,7 +19,7 @@ import {
|
||||
formatNumber,
|
||||
resolveCycleStatus,
|
||||
} from '../utils/format.ts';
|
||||
import { isOnOrBeforeVisibleDate, throughVisibleDate } from '../utils/visibility.ts';
|
||||
import { throughVisibleDate } from '../utils/visibility.ts';
|
||||
import { getCountingAccuracyWarning } from '../utils/countingAccuracyWarning.ts';
|
||||
import DashboardReportCtas from './dashboard/DashboardReportCtas.tsx';
|
||||
import DashboardManualInputForm from './dashboard/DashboardManualInputForm.tsx';
|
||||
@@ -43,7 +42,6 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
const [manualInputs, setManualInputs] = useState<ManualInput[]>([]);
|
||||
const [weights, setWeights] = useState<ChickenWeight[]>([]);
|
||||
const [karung, setKarung] = useState<Karung | null>(null);
|
||||
const [latestAvg, setLatestAvg] = useState<LatestAvg | null>(null);
|
||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
||||
const [reportOpen, setReportOpen] = useState(false);
|
||||
const [reportType, setReportType] = useState<DashboardReportType>('daily');
|
||||
@@ -55,7 +53,6 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
setManualInputs([]);
|
||||
setWeights([]);
|
||||
setKarung(null);
|
||||
setLatestAvg(null);
|
||||
setInsights([]);
|
||||
return;
|
||||
}
|
||||
@@ -78,19 +75,12 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
} catch (err) {
|
||||
if (!(err instanceof ApiError && err.status === 404)) throw err;
|
||||
}
|
||||
let avg: LatestAvg | null = null;
|
||||
try {
|
||||
avg = await api.weights.latestAvg({ cycle_id: cycleId });
|
||||
} catch (err) {
|
||||
if (!(err instanceof ApiError && err.status === 404)) throw err;
|
||||
}
|
||||
if (cancelled) return;
|
||||
setKpis(kpiRows);
|
||||
setCountings(countingRows);
|
||||
setManualInputs(manualRows);
|
||||
setWeights(weightRows);
|
||||
setKarung(latestKarung);
|
||||
setLatestAvg(avg);
|
||||
setInsights(insightRows);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(errorMessage(err));
|
||||
@@ -104,40 +94,51 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
}, [selectedCycle]);
|
||||
|
||||
const cards: KpiCardModel[] = useMemo(() => {
|
||||
// Countings/weights APIs are real-time; KPI cards still honor publish cutoff.
|
||||
// All cards share the latest complete KPI day (not calendar current_day / live IoT).
|
||||
const visibleThrough = selectedCycle?.visible_through_date;
|
||||
const visibleKpis = throughVisibleDate(kpis, visibleThrough);
|
||||
const visibleCountings = throughVisibleDate(countings, visibleThrough);
|
||||
const visibleWeights = throughVisibleDate(weights, visibleThrough);
|
||||
const visibleLatestAvg =
|
||||
latestAvg && isOnOrBeforeVisibleDate(latestAvg.date, visibleThrough) ? latestAvg : null;
|
||||
const visibleKarung =
|
||||
karung && isOnOrBeforeVisibleDate(karung.date, visibleThrough) ? karung : null;
|
||||
const latest = visibleKpis[0] ?? null;
|
||||
const anchorDate = latest?.date ?? null;
|
||||
|
||||
const kpisThroughAnchor = anchorDate
|
||||
? visibleKpis.filter((row) => row.date <= anchorDate)
|
||||
: [];
|
||||
const weightsThroughAnchor = anchorDate
|
||||
? throughVisibleDate(weights, visibleThrough).filter((row) => row.date <= anchorDate)
|
||||
: [];
|
||||
const weightOnAnchor = anchorDate
|
||||
? weightsThroughAnchor.find((row) => row.date === anchorDate) ?? weightsThroughAnchor[0]
|
||||
: null;
|
||||
|
||||
const latest = visibleKpis[0];
|
||||
const startDate = selectedCycle?.start_date;
|
||||
const trendLabel = (isoDate: string): string => {
|
||||
const day = startDate ? cycleDayForDate(startDate, isoDate) : 0;
|
||||
return `Hari ${day} · ${formatDateId(isoDate)}`;
|
||||
};
|
||||
const fcrTrend: KpiTrendPoint[] = chronological(visibleKpis).map((row) => ({
|
||||
const fcrTrend: KpiTrendPoint[] = chronological(kpisThroughAnchor).map((row) => ({
|
||||
value: row.fcr,
|
||||
label: trendLabel(row.date),
|
||||
}));
|
||||
const eefTrend: KpiTrendPoint[] = chronological(visibleKpis).map((row) => ({
|
||||
const eefTrend: KpiTrendPoint[] = chronological(kpisThroughAnchor).map((row) => ({
|
||||
value: row.eef,
|
||||
label: trendLabel(row.date),
|
||||
}));
|
||||
const mortalityTrend: KpiTrendPoint[] = chronological(visibleCountings).map((row) => ({
|
||||
value: row.mortality_count,
|
||||
const mortalityTrend: KpiTrendPoint[] = chronological(kpisThroughAnchor).map((row) => ({
|
||||
value: row.mortality,
|
||||
label: trendLabel(row.date),
|
||||
}));
|
||||
const mortalityToday = latest?.mortality ?? visibleCountings[0]?.mortality_count ?? null;
|
||||
const weightTrend: KpiTrendPoint[] = chronological(visibleWeights).map((row) => ({
|
||||
const weightTrend: KpiTrendPoint[] = chronological(weightsThroughAnchor).map((row) => ({
|
||||
value: row.average_weight,
|
||||
label: trendLabel(row.date),
|
||||
}));
|
||||
const age = selectedCycle?.current_day ?? latest?.age ?? null;
|
||||
const age = latest?.age ?? null;
|
||||
const mortalityToday = latest?.mortality ?? null;
|
||||
const feedToday = latest != null ? latest.feed : null;
|
||||
const weightToday =
|
||||
latest && latest.iot_weight > 0
|
||||
? latest.iot_weight
|
||||
: (weightOnAnchor?.average_weight ?? null);
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'umur',
|
||||
@@ -160,20 +161,20 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
{
|
||||
key: 'karung',
|
||||
title: 'Karung Dituang',
|
||||
value: visibleKarung?.feed_use_today ?? visibleKarung?.out_today ?? null,
|
||||
value: feedToday,
|
||||
unit: 'karung',
|
||||
icon: '🛢️',
|
||||
status: visibleKarung ? 'ok' : 'na',
|
||||
status: feedToday == null ? 'na' : 'ok',
|
||||
targetView: 'feedSackCounting',
|
||||
},
|
||||
{
|
||||
key: 'weight',
|
||||
title: 'Berat AVG',
|
||||
value: visibleLatestAvg?.average_weight ?? visibleWeights[0]?.average_weight ?? null,
|
||||
value: weightToday,
|
||||
unit: 'gram',
|
||||
icon: '⚖️',
|
||||
trend: weightTrend,
|
||||
status: visibleLatestAvg || visibleWeights[0] ? 'ok' : 'na',
|
||||
status: weightToday == null ? 'na' : 'ok',
|
||||
targetView: 'weightMonitoring',
|
||||
},
|
||||
{
|
||||
@@ -197,7 +198,7 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
targetView: 'eefDashboard',
|
||||
},
|
||||
];
|
||||
}, [kpis, countings, weights, karung, latestAvg, selectedCycle]);
|
||||
}, [kpis, weights, selectedCycle]);
|
||||
|
||||
const countingAccuracyWarning = useMemo(() => {
|
||||
if (!selectedCycle) return null;
|
||||
|
||||
@@ -230,8 +230,8 @@ const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
|
||||
.map((row) => ({
|
||||
day: row.age,
|
||||
feedTotal: row.feed_total,
|
||||
iotWeight: weightByAge.get(row.age) ?? null,
|
||||
chickenLife: row.chicken_life,
|
||||
iotWeight: row.iot_weight > 0 ? row.iot_weight : weightByAge.get(row.age) ?? null,
|
||||
chickenLife: Math.max(row.chicken_life - row.harvest_total, 0),
|
||||
harvestWeightKg: row.harvest_weight_total / 1000,
|
||||
fcr: row.fcr > 0 ? row.fcr : null,
|
||||
})),
|
||||
|
||||
@@ -151,7 +151,7 @@ const DashboardManualInputForm: React.FC<Props> = ({ cycleId, currentDay, readOn
|
||||
<p className="text-sm text-gray-500">
|
||||
{readOnly
|
||||
? 'Siklus ini sudah ditutup. Data di bawah hanya untuk ditinjau.'
|
||||
: 'Isi data harian di sini. Total dihitung otomatis. FCR, EEF, dan rata-rata panen belum dihitung.'}
|
||||
: 'Isi data harian di sini. Total, FCR, dan EEF dihitung otomatis setelah data sumber lengkap.'}
|
||||
</p>
|
||||
{error && <Banner kind="error">{error}</Banner>}
|
||||
{notice && <Banner kind="success">{notice}</Banner>}
|
||||
|
||||
Reference in new issue
Block a user