Files
2026-08-27 14:26:19 +07:00

1776 lines
56 KiB
TypeScript

import { rawApiKpiData } from '../mockData/rawApiData.ts';
import { initialEnvironmentData } from '../mockData/dashboardData.ts';
import { initialFeedSackHistory } from '../mockData/feedSackData.ts';
import { initialLocations, initialCoops, initialFloors } from '../mockData/locationData.ts';
import { initialWeightDistribution } from '../mockData/weightData.ts';
import {
EnvironmentData,
FeedSackLogEntry,
Location,
Coop,
Floor,
WeightDistributionApiResponse,
WeightDistributionApiData,
SensorWeightSummaryResponse,
ScalePredictionRangeResponse,
} from '../types.ts';
import type {
DashboardReportPayload,
DashboardReportSnapshotResponse,
} from '../types/dashboardReport.ts';
// --- Configuration ---
const API_BASE_URL = 'https://dashboard.cpsp.id';
const IOT_API_BASE = `${API_BASE_URL}/api/iot`;
const FEED_SACK_API_BASE = `${API_BASE_URL}/api/counting/sacks`;
const CAGE_SITES_API = `${API_BASE_URL}/api/cage_sites/get_all/`;
const FCR_API_BASE = `${API_BASE_URL}/api/data-support/fcrtable`;
// Use environment variable for Docker deployment, fallback to localhost for local dev
const DB_API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5001/api';
const readErrorMessage = async (response: Response, fallback: string) => {
try {
const bodyText = await response.text();
if (!bodyText) {
return fallback;
}
try {
const parsed = JSON.parse(bodyText) as Record<string, unknown>;
const nestedError = parsed.error;
if (nestedError && typeof nestedError === 'object' && !Array.isArray(nestedError)) {
const nested = nestedError as Record<string, unknown>;
const nestedMessage =
nested.message ?? nested.error ?? parsed.message ?? parsed.error ?? bodyText;
if (typeof nestedMessage === 'string' && nestedMessage.trim()) {
return nestedMessage.trim();
}
}
const topLevelMessage = parsed.message ?? parsed.error ?? bodyText;
if (typeof topLevelMessage === 'string' && topLevelMessage.trim()) {
return topLevelMessage.trim();
}
return JSON.stringify(parsed);
} catch {
const trimmed = bodyText.trim();
return trimmed.length > 0 ? trimmed : fallback;
}
} catch {
return fallback;
}
};
const throwApiError = async (response: Response, fallbackPrefix = 'HTTP error!') => {
const fallback = `${fallbackPrefix} status: ${response.status}`;
throw new Error(await readErrorMessage(response, fallback));
};
// Build cage API endpoint dynamically based on cage UUID
const buildCageApiEndpoint = (cageUuid: string): string => {
return `${API_BASE_URL}/api/scales/cage/${cageUuid}`;
};
// Hardcoded values per user preference
// const DEFAULT_SITE_ID = 1; // Reserved for future use
const FCR_CACHE_KEY_PREFIX = 'fcr-cache-';
const FCR_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
const WEIGHT_DIST_CACHE_KEY_PREFIX = 'weight-dist-1hour-';
const WEIGHT_DIST_CACHE_TTL = 60 * 60 * 1000; // 1 hour
// --- API Functions ---
/**
* Fetches multi-coop weight data from the live API.
* @param cageUuid - The cage UUID to fetch data for. If null/empty, returns null.
*/
async function fetchLiveMultiCoopData(cageUuid?: string | null) {
// If no cage UUID provided, return null (will trigger empty state)
if (!cageUuid) {
console.warn('No cage UUID provided for weight data fetch');
return null;
}
try {
const apiEndpoint = buildCageApiEndpoint(cageUuid);
const response = await fetch(apiEndpoint);
if (!response.ok) {
console.error(`Failed to fetch from ${apiEndpoint}:`, response.status);
return null;
}
const jsonData = await response.json();
// Check if kpi object exists AND has at least one property
if (
jsonData &&
jsonData.success &&
jsonData.data &&
jsonData.data.kpi &&
Object.keys(jsonData.data.kpi).length > 0
) {
return jsonData.data.kpi;
} else {
throw new Error('Invalid data structure from API or empty KPI object');
}
} catch (error) {
console.error('Failed to fetch live API data:', error);
return null;
}
}
/**
* Returns a promise that resolves with the mock data.
*/
async function fetchMockMultiCoopData() {
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 300));
return Promise.resolve(rawApiKpiData);
}
// --- IoT Sensor API Functions ---
/**
* Transforms IoT API response to EnvironmentData format
*/
function transformIotSensorData(apiData: any): EnvironmentData {
// API returns an array, get the first item
const sensorData = Array.isArray(apiData) ? apiData[0] : apiData;
return {
time: new Date(sensorData.update_at).toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
}),
Suhu: sensorData.suhu,
Kelembapan: sensorData.kelembaban,
Amonia: sensorData.amonia,
};
}
/**
* Fetches latest IoT sensor data from the live API
*/
async function fetchLiveIotSensorData() {
const response = await fetch(`${IOT_API_BASE}/flocks/sensors/latest/`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const json = await response.json();
if (json.success && json.data) {
return transformIotSensorData(json.data);
}
throw new Error('Invalid data structure from API');
}
/**
* Returns mock IoT sensor data
*/
async function fetchMockIotData() {
await new Promise((resolve) => setTimeout(resolve, 300));
// Return the last item from mock data
return Promise.resolve(initialEnvironmentData[initialEnvironmentData.length - 1]);
}
/**
* Fetches IoT sensor data for a date range from the live API
* @param startDate - Start date in YYYY-MM-DD format
* @param endDate - End date in YYYY-MM-DD format
*/
async function fetchLiveIotSensorRange(
startDate: string,
endDate: string
): Promise<EnvironmentData[]> {
const response = await fetch(
`${IOT_API_BASE}/flocks/sensors/range/?start_date=${startDate}&end_date=${endDate}`
);
// Handle 404 - no data found for date range
if (response.status === 404) {
return []; // Return empty array instead of throwing error
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const json = await response.json();
// Handle API returning success: false
if (json.success === false) {
return []; // Return empty array
}
if (json.success && Array.isArray(json.data)) {
// Filter to last 12 hours
const now = new Date();
const twelveHoursAgo = new Date(now.getTime() - 12 * 60 * 60 * 1000);
// Transform API data to EnvironmentData format, filtering by time and invalid readings
const transformedData = json.data
.filter((item: any) => {
const timestamp = new Date(item.update_at);
// Filter out data older than 12 hours and sensor failures (zero values)
return timestamp >= twelveHoursAgo && item.suhu > 0 && item.kelembaban > 0;
})
.map((item: any) => {
// API returns UTC time (e.g., "2025-12-01T00:00:04.147000Z")
// Convert to WIB (Jakarta, UTC+7) for display
const timestampUTC = new Date(item.update_at);
// Use en-US locale to ensure consistent HH:MM format
const wibTimeString = timestampUTC.toLocaleString('en-US', {
timeZone: 'Asia/Jakarta',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
// Extract time part (format is "HH:MM" or "MM/DD/YYYY, HH:MM")
const timePart =
(wibTimeString.includes(',') ? wibTimeString.split(', ')[1] : wibTimeString) ?? '';
// Round to 5-minute intervals for aggregation
const [hourStr = '0', minuteStr = '0'] = timePart.split(':');
const hour = parseInt(hourStr, 10);
const minute = Math.floor(parseInt(minuteStr, 10) / 5) * 5;
return {
time: `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`,
Suhu: item.suhu,
Kelembapan: item.kelembaban,
Amonia: item.amonia,
};
});
// Aggregate data by 5-minute time buckets (average values for same time slot)
const aggregated: {
[key: string]: { Suhu: number[]; Kelembapan: number[]; Amonia: number[] };
} = {};
transformedData.forEach((item: (typeof transformedData)[number]) => {
const bucket =
aggregated[item.time] ?? (aggregated[item.time] = { Suhu: [], Kelembapan: [], Amonia: [] });
bucket.Suhu.push(item.Suhu);
bucket.Kelembapan.push(item.Kelembapan);
bucket.Amonia.push(item.Amonia);
});
// Calculate averages for each time slot
const result: EnvironmentData[] = Object.entries(aggregated).map(([time, values]) => ({
time,
Suhu: values.Suhu.reduce((a, b) => a + b, 0) / values.Suhu.length,
Kelembapan: values.Kelembapan.reduce((a, b) => a + b, 0) / values.Kelembapan.length,
Amonia: values.Amonia.reduce((a, b) => a + b, 0) / values.Amonia.length,
}));
// Sort by time
result.sort((a, b) => {
const [aHour = 0, aMin = 0] = a.time.split(':').map(Number);
const [bHour = 0, bMin = 0] = b.time.split(':').map(Number);
return aHour * 60 + aMin - (bHour * 60 + bMin);
});
return result;
}
throw new Error('Invalid data structure from API');
}
/** One cycle day of environment readings, averaged from every sensor sample. */
export interface DailyEnvironmentPoint {
hari: number;
tanggal: string;
suhu_C: number;
suhuMin_C: number;
suhuMax_C: number;
kelembapan_persen: number;
co2_ppm: number | null;
jumlahSampel: number;
}
/**
* Environment history aggregated per CYCLE DAY, for root-cause analysis.
*
* `fetchLiveIotSensorRange` cannot serve this: it throws away everything older
* than 12 hours and reduces the timestamp to "HH:MM", which is right for the
* live chart and useless for asking why the birds died on day 13. This one
* keeps the API's own `day` field — verified to be the cycle day, and already
* cut at the WIB midnight boundary rather than UTC's.
*/
async function fetchIotDailyEnvironment(
startDate: string,
endDate: string
): Promise<DailyEnvironmentPoint[]> {
const response = await fetch(
`${IOT_API_BASE}/flocks/sensors/range/?start_date=${startDate}&end_date=${endDate}`
);
if (response.status === 404) return [];
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
const json = await response.json();
if (!json?.success || !Array.isArray(json.data)) return [];
const buckets = new Map<
number,
{ tanggal: string; suhu: number[]; rh: number[]; co2: number[] }
>();
for (const item of json.data) {
const hari = Number(item?.day);
const suhu = Number(item?.suhu);
const rh = Number(item?.kelembaban);
// A dead sensor reports 0 for both; averaging those in drags the day's mean
// below the standard and manufactures a "kandang kedinginan" finding.
if (!Number.isFinite(hari) || hari <= 0 || !(suhu > 0) || !(rh > 0)) continue;
const bucket =
buckets.get(hari) ??
(buckets.set(hari, {
tanggal: String(item?.update_at ?? '').slice(0, 10),
suhu: [],
rh: [],
co2: [],
}),
buckets.get(hari)!);
bucket.suhu.push(suhu);
bucket.rh.push(rh);
const co2 = Number(item?.co2);
if (Number.isFinite(co2) && co2 > 0) bucket.co2.push(co2);
}
const mean = (values: number[]) =>
values.length === 0 ? 0 : values.reduce((sum, v) => sum + v, 0) / values.length;
const round1 = (value: number) => Math.round(value * 10) / 10;
return [...buckets.entries()]
.map(([hari, bucket]) => ({
hari,
tanggal: bucket.tanggal,
suhu_C: round1(mean(bucket.suhu)),
suhuMin_C: round1(Math.min(...bucket.suhu)),
suhuMax_C: round1(Math.max(...bucket.suhu)),
kelembapan_persen: round1(mean(bucket.rh)),
co2_ppm: bucket.co2.length > 0 ? round1(mean(bucket.co2)) : null,
jumlahSampel: bucket.suhu.length,
}))
.sort((a, b) => a.hari - b.hari);
}
/**
* Returns mock IoT sensor range data
*/
async function fetchMockIotRangeData(): Promise<EnvironmentData[]> {
await new Promise((resolve) => setTimeout(resolve, 300));
return Promise.resolve(initialEnvironmentData);
}
/**
* Fetches IoT activity logs from the live API
*/
async function fetchLiveIotActivityLogs(limit: number = 10) {
const response = await fetch(`${IOT_API_BASE}/log-act/last/?limit=${limit}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const json = await response.json();
if (json.success && json.data) {
return json.data; // Return raw data for now, transformation will be done in context
}
throw new Error('Invalid data structure from API');
}
// --- Feed Sack Counting API Functions ---
/**
* Transforms feed sack API response to FeedSackLogEntry format
*/
function transformFeedSackData(apiData: any[]): {
entries: FeedSackLogEntry[];
availableCameras: string[];
} {
const entries: FeedSackLogEntry[] = [];
const availableCameras: string[] = [];
apiData.forEach((cameraData) => {
availableCameras.push(cameraData.camera_name);
// Infer type for backward compatibility
const type: 'inbound' | 'usage' | 'outbound' = cameraData.camera_name.includes('karung_masuk')
? 'inbound'
: cameraData.camera_name.includes('karung_keluar')
? 'outbound'
: 'usage';
// Extract location from camera name (e.g., "kandang_1_karung_masuk" → "Kandang 1")
const location = cameraData.camera_name
.split('_')
.slice(0, 2)
.join(' ')
.replace(/^\w/, (c: string) => c.toUpperCase());
cameraData.records.forEach((record: any) => {
entries.push({
id: `${cameraData.camera_name}-${record.date}`,
timestamp: new Date(record.date),
location: location,
cameraName: cameraData.camera_name,
count: record.value,
type: type,
});
});
});
return {
entries: entries.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()),
availableCameras,
};
}
/**
* Fetches feed sack counting data from the live API
*/
async function fetchLiveFeedSackData(
siteId: number,
cameraName?: string
): Promise<{ entries: FeedSackLogEntry[]; availableCameras: string[] }> {
const url = cameraName
? `${FEED_SACK_API_BASE}/${siteId}/${cameraName}/`
: `${FEED_SACK_API_BASE}/${siteId}/`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const json = await response.json();
if (json.success && json.data) {
return transformFeedSackData(json.data);
}
throw new Error('Invalid data structure from API');
}
/**
* Returns mock feed sack data
*/
async function fetchMockFeedSackData(): Promise<{
entries: FeedSackLogEntry[];
availableCameras: string[];
}> {
await new Promise((resolve) => setTimeout(resolve, 300));
return {
entries: initialFeedSackHistory,
availableCameras: [
'kandang_1_karung_masuk-check_pintu',
'kandang_1_karung_pakan',
'kandang_2_karung_pakan',
'kandang_1_karung_masuk',
'kandang_atas_feeder_kiri',
'kandang_bawah_feeder_kanan',
],
};
}
// --- Location/Cage Sites API Functions ---
/**
* Transforms cage site API response to Location, Coop, Floor format
*/
function transformCageSiteData(apiData: any[]) {
const locations: Location[] = [];
const coops: Coop[] = [];
const floors: Floor[] = [];
apiData.forEach((site, idx) => {
const locationId = `loc-${idx}`;
locations.push({
id: locationId,
name: site.name,
});
site.iot_scales?.forEach((scale: any) => {
coops.push({
id: scale.id,
name: scale.name,
locationId: locationId,
});
scale.devices?.forEach((device: any) => {
floors.push({
id: device.id,
name: device.name,
coopId: scale.id,
});
});
});
});
return { locations, coops, floors };
}
/**
* Fetches cage sites data from the live API
*/
async function fetchLiveCageSites() {
const response = await fetch(CAGE_SITES_API);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const json = await response.json();
if (json.success && json.data) {
return transformCageSiteData(json.data);
}
throw new Error('Invalid data structure from API');
}
/**
* Returns mock location data
*/
async function fetchMockLocationData() {
await new Promise((resolve) => setTimeout(resolve, 300));
return Promise.resolve({
locations: initialLocations,
coops: initialCoops,
floors: initialFloors,
});
}
// --- FCR Calculation API Functions ---
/**
* Gets cached FCR data from localStorage
*/
function getCachedFcrData(dow: number, population: number, day: number) {
const key = `${FCR_CACHE_KEY_PREFIX}${dow}-${population}-${day}`;
const cached = localStorage.getItem(key);
if (!cached) return null;
try {
const { data, timestamp } = JSON.parse(cached);
if (Date.now() - timestamp < FCR_CACHE_TTL) {
return data;
}
} catch {
localStorage.removeItem(key);
}
return null;
}
/**
* Caches FCR data to localStorage
*/
function cacheFcrData(dow: number, population: number, day: number, data: any) {
const key = `${FCR_CACHE_KEY_PREFIX}${dow}-${population}-${day}`;
localStorage.setItem(key, JSON.stringify({ data, timestamp: Date.now() }));
}
// --- Weight Distribution 1-hour Caching Functions ---
/**
* Calculate which 14-day group a date belongs to
* Groups are aligned to cycle start date (used for batch fetching)
* @param date - Date to check
* @param cycleStartDate - Cycle start date for alignment
* @returns Group number (0-indexed)
*/
function get14DayGroup(date: Date, cycleStartDate: Date): number {
const targetDate = new Date(date);
const startDate = new Date(cycleStartDate);
targetDate.setHours(0, 0, 0, 0);
startDate.setHours(0, 0, 0, 0);
const daysSinceStart = Math.floor(
(targetDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)
);
return Math.floor(daysSinceStart / 14);
}
/**
* Gets cached weight distribution data for a 14-day group from localStorage
* Cache TTL: 1 hour
* @param groupNumber - 14-day group number
* @returns Cached data or null if not found/expired
*/
function getCachedWeightDistribution(groupNumber: number): WeightDistributionApiData[] | null {
const key = `${WEIGHT_DIST_CACHE_KEY_PREFIX}${groupNumber}`;
const cached = localStorage.getItem(key);
if (!cached) return null;
try {
const { data, timestamp } = JSON.parse(cached);
if (Date.now() - timestamp < WEIGHT_DIST_CACHE_TTL) {
return data;
}
// Expired, remove from cache
localStorage.removeItem(key);
} catch {
localStorage.removeItem(key);
}
return null;
}
/**
* Caches weight distribution data for a 14-day group to localStorage
* @param groupNumber - 14-day group number
* @param data - Weight distribution data array for the entire 14-day period
*/
function cacheWeightDistribution(groupNumber: number, data: WeightDistributionApiData[]) {
const key = `${WEIGHT_DIST_CACHE_KEY_PREFIX}${groupNumber}`;
localStorage.setItem(key, JSON.stringify({ data, timestamp: Date.now() }));
}
/**
* Clears all weight distribution cache from localStorage
*/
function clearWeightDistributionCache() {
const keys = Object.keys(localStorage);
keys.forEach((key) => {
// Clear both old (14-day) and new (1-hour) cache keys
if (key.startsWith(WEIGHT_DIST_CACHE_KEY_PREFIX) || key.startsWith('weight-dist-14day-')) {
localStorage.removeItem(key);
}
});
}
/**
* Fetches FCR table data for a specific day from the live API
*/
async function fetchLiveFcrData(dow: number, population: number, day: number) {
// Check cache first
const cached = getCachedFcrData(dow, population, day);
if (cached) return cached;
const response = await fetch(`${FCR_API_BASE}/${dow}/${population}/${day}/`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const json = await response.json();
if (json.success && json.data) {
// Cache successful result
cacheFcrData(dow, population, day, json.data);
return json.data;
}
throw new Error('Invalid data structure from API');
}
/**
* Batch fetches FCR data for multiple days
*/
async function fetchLiveFcrDataBatch(dow: number, population: number, days: number[]) {
const promises = days.map((day) => fetchLiveFcrData(dow, population, day));
const results = await Promise.allSettled(promises);
return results
.filter((r) => r.status === 'fulfilled')
.map((r) => (r as PromiseFulfilledResult<any>).value)
.filter((data) => data !== null);
}
/**
* Returns mock FCR data for a specific day
*/
async function fetchMockFcrData(day: number) {
await new Promise((resolve) => setTimeout(resolve, 300));
// Generate mock FCR data based on day
return Promise.resolve({
day: day,
weight: 35 + day * 15,
adg: 15,
'daily-gain': 0.5,
'daily-feed': 20,
'feeds-use': 100,
fcr: 1.2,
'total-feed': day * 100,
'bags-feed': day * 2,
});
}
/**
* Returns mock FCR data batch
*/
async function fetchMockFcrDataBatch(maxDay: number) {
const days = Array.from({ length: maxDay }, (_, i) => i + 1);
const promises = days.map((day) => fetchMockFcrData(day));
return Promise.all(promises);
}
// --- API Client ---
export const apiClient = {
/**
* Gets the multi-coop weight comparison data.
* Switches between the live API and mock data based on the mode flag.
*/
getMultiCoopWeightData: async (mode: 'LIVE' | 'DEMO', cageUuid?: string | null) => {
if (mode === 'LIVE') {
return fetchLiveMultiCoopData(cageUuid);
} else {
return fetchMockMultiCoopData();
}
},
/**
* Gets latest IoT sensor data.
* In LIVE mode, throws errors (no fallback to mock).
*/
getIotSensorLatest: async (mode: 'LIVE' | 'DEMO') => {
if (mode === 'LIVE') {
return fetchLiveIotSensorData();
} else {
return fetchMockIotData();
}
},
/**
* Gets IoT activity logs.
* In LIVE mode, throws errors (no fallback to mock).
*/
getIotActivityLogs: async (limit: number = 10, mode: 'LIVE' | 'DEMO') => {
if (mode === 'LIVE') {
return fetchLiveIotActivityLogs(limit);
} else {
return Promise.resolve([]); // Return empty array for mock mode
}
},
/**
* Gets IoT sensor data for a date range.
* In LIVE mode, fetches from API. In DEMO mode, returns mock data.
*/
getIotSensorRange: async (startDate: string, endDate: string, mode: 'LIVE' | 'DEMO') => {
if (mode === 'LIVE') {
return fetchLiveIotSensorRange(startDate, endDate);
} else {
return fetchMockIotRangeData();
}
},
/**
* Environment readings averaged per cycle day, for the AI Insight
* root-cause analysis. DEMO mode has no dated history to aggregate, so it
* returns nothing rather than mock days that would invent correlations.
*/
getIotDailyEnvironment: async (
startDate: string,
endDate: string,
mode: 'LIVE' | 'DEMO'
): Promise<DailyEnvironmentPoint[]> => {
if (mode !== 'LIVE') return [];
return fetchIotDailyEnvironment(startDate, endDate);
},
/**
* Gets feed sack counting data.
* In LIVE mode, throws errors (no fallback to mock).
*/
getFeedSackCounting: async (
siteId: number,
cameraName: string | undefined,
mode: 'LIVE' | 'DEMO'
): Promise<{ entries: FeedSackLogEntry[]; availableCameras: string[] }> => {
if (mode === 'LIVE') {
return fetchLiveFeedSackData(siteId, cameraName);
} else {
return fetchMockFeedSackData();
}
},
getFeedSackColumnConfig: async () => {
const response = await fetch(`${DB_API_BASE_URL}/feed-sack-column-config`);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const json = await response.json();
if (json.success) return json.data;
throw new Error('Invalid data structure from API');
},
saveFeedSackColumnConfig: async (config: any[]) => {
const response = await fetch(`${DB_API_BASE_URL}/feed-sack-column-config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config }),
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const json = await response.json();
if (json.success) return json.data;
throw new Error('Invalid data structure from API');
},
/**
* Gets initial feed balance for a specific cycle
*/
getCycleFeedInitialBalance: async (cycleId: string) => {
const response = await fetch(`${DB_API_BASE_URL}/cycle-feed-initial-balance/${cycleId}`);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const json = await response.json();
if (json.success && json.data) {
// Convert date strings to Date objects
return {
...json.data,
createdAt: new Date(json.data.createdAt),
updatedAt: new Date(json.data.updatedAt),
};
}
return json.data; // null case
},
/**
* Saves or updates initial feed balance for a cycle
*/
saveCycleFeedInitialBalance: async (cycleId: string, saldoAwal: number, createdBy: string) => {
const response = await fetch(`${DB_API_BASE_URL}/cycle-feed-initial-balance`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cycleId, saldoAwal, createdBy }),
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const json = await response.json();
if (json.success && json.data) {
// Convert date strings to Date objects
return {
...json.data,
createdAt: new Date(json.data.createdAt),
updatedAt: new Date(json.data.updatedAt),
};
}
throw new Error('Invalid data structure from API');
},
/**
* Deletes initial feed balance for a cycle
*/
deleteCycleFeedInitialBalance: async (cycleId: string) => {
const response = await fetch(`${DB_API_BASE_URL}/cycle-feed-initial-balance/${cycleId}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const json = await response.json();
if (json.success) return json.data;
throw new Error('Invalid data structure from API');
},
/**
* Gets cage sites/location data.
* In LIVE mode, throws errors (no fallback to mock).
*/
getCageSites: async (mode: 'LIVE' | 'DEMO') => {
if (mode === 'LIVE') {
return fetchLiveCageSites();
} else {
return fetchMockLocationData();
}
},
/**
* Gets FCR table data for a specific day.
* In LIVE mode, throws errors (no fallback to mock). Uses localStorage caching.
*/
getFcrTableForDay: async (
dow: number,
population: number,
day: number,
mode: 'LIVE' | 'DEMO'
) => {
if (mode === 'LIVE') {
return fetchLiveFcrData(dow, population, day);
} else {
return fetchMockFcrData(day);
}
},
/**
* Gets FCR table data for multiple days (batch fetch).
* In LIVE mode, throws errors (no fallback to mock). Uses localStorage caching.
*/
getFcrTableBatch: async (
dow: number,
population: number,
maxDay: number,
mode: 'LIVE' | 'DEMO'
) => {
const days = Array.from({ length: maxDay }, (_, i) => i + 1);
if (mode === 'LIVE') {
return fetchLiveFcrDataBatch(dow, population, days);
} else {
return fetchMockFcrDataBatch(maxDay);
}
},
/**
* Gets weight distribution data for yesterday.
* In LIVE mode, fetches from API. In DEMO mode, returns mock data.
*/
getWeightDistributionYesterday: async (
mode: 'LIVE' | 'DEMO'
): Promise<WeightDistributionApiResponse> => {
if (mode === 'LIVE') {
const response = await fetch(
`${API_BASE_URL}/api/iot/flocks/distribusi_bobot/?mode=yesterday`
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} else {
// Return mock data for DEMO mode based on initialWeightDistribution
return {
success: true,
mode: 'yesterday',
range_size: 50.0,
data: {
tanggal: new Date().toISOString().split('T')[0] ?? '',
total_predictions: 150,
total_chicken_count: initialWeightDistribution.reduce((sum, item) => sum + item.count, 0),
distribusi: initialWeightDistribution.map((item) => ({
range_bobot: item.range,
range_start: 0,
range_end: 0,
frekuensi: Math.floor(item.count / 10),
total_ayam: item.count,
rata_rata: 2200,
})),
statistik: {
min: 1900,
max: 2600,
avg: 2280,
Standard_deviasi: 150,
uniformity: 85,
},
},
};
}
},
/**
* Gets weight distribution data for a date range with 1-hour caching.
* In LIVE mode, fetches from API or uses cached data. In DEMO mode, returns mock data.
* Cache is grouped by 14-day periods and expires after 1 hour.
* @param startDate - Start date in YYYY-MM-DD format
* @param endDate - End date in YYYY-MM-DD format
* @param mode - 'LIVE' or 'DEMO'
* @param cycleStartDate - Optional cycle start date for cache alignment (required for LIVE mode)
*/
getWeightDistributionRange: async (
startDate: string,
endDate: string,
mode: 'LIVE' | 'DEMO',
cycleStartDate?: string
): Promise<WeightDistributionApiResponse> => {
if (mode === 'LIVE') {
// For single-date requests (startDate === endDate), check if we have cached 14-day group data
if (startDate === endDate && cycleStartDate) {
const requestDate = new Date(startDate);
const cycleStart = new Date(cycleStartDate);
const groupNumber = get14DayGroup(requestDate, cycleStart);
// Check if we have cached data for this 14-day group
const cachedGroup = getCachedWeightDistribution(groupNumber);
if (cachedGroup) {
// Find the specific date in cached data
const cachedDayData = cachedGroup.find((item) => item.tanggal === startDate);
if (cachedDayData) {
// Return cached single-day data
return {
success: true,
mode: 'range',
range_size: 50.0,
data: [cachedDayData],
};
}
}
// No cache hit, fetch the entire 14-day group from API
const groupStartDate = new Date(cycleStart);
groupStartDate.setDate(cycleStart.getDate() + groupNumber * 14);
const groupEndDate = new Date(groupStartDate);
groupEndDate.setDate(groupStartDate.getDate() + 13); // 14 days total (0-13)
// Don't fetch beyond today
const today = new Date();
today.setHours(0, 0, 0, 0);
const fetchEndDate = groupEndDate > today ? today : groupEndDate;
const groupStartStr = groupStartDate.toISOString().split('T')[0];
const groupEndStr = fetchEndDate.toISOString().split('T')[0];
// Fetch entire 14-day group
const response = await fetch(
`${API_BASE_URL}/api/iot/flocks/distribusi_bobot/?mode=range&start_date=${groupStartStr}&end_date=${groupEndStr}`
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const apiResponse: WeightDistributionApiResponse = await response.json();
// Cache the entire group
if (apiResponse.success && Array.isArray(apiResponse.data)) {
cacheWeightDistribution(groupNumber, apiResponse.data);
// Return only the requested date
const requestedDayData = apiResponse.data.find((item) => item.tanggal === startDate);
if (requestedDayData) {
return {
success: true,
mode: 'range',
range_size: apiResponse.range_size,
data: [requestedDayData],
};
}
}
return apiResponse;
}
// For date ranges or if no cycleStartDate provided, fetch directly without caching
const response = await fetch(
`${API_BASE_URL}/api/iot/flocks/distribusi_bobot/?mode=range&start_date=${startDate}&end_date=${endDate}`
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} else {
// Return mock data for DEMO mode with date range
const start = new Date(startDate);
const end = new Date(endDate);
const days = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1;
const mockDataArray: WeightDistributionApiData[] = [];
for (let i = 0; i < days; i++) {
const currentDate = new Date(start);
currentDate.setDate(start.getDate() + i);
const totalChickenCount = initialWeightDistribution.reduce(
(sum, item) => sum + item.count,
0
);
mockDataArray.push({
tanggal: currentDate.toISOString().split('T')[0] ?? '',
total_predictions: 100 + Math.floor(Math.random() * 100),
total_chicken_count: totalChickenCount + Math.floor(Math.random() * 200 - 100),
distribusi: initialWeightDistribution.map((item) => ({
range_bobot: item.range,
range_start: 0,
range_end: 0,
frekuensi: Math.floor(item.count / 10) + Math.floor(Math.random() * 5),
total_ayam: item.count + Math.floor(Math.random() * 100 - 50),
rata_rata: 2200 + Math.random() * 200 - 100,
})),
statistik: {
min: 1900 + Math.random() * 100,
max: 2500 + Math.random() * 100,
avg: 2200 + Math.random() * 100,
Standard_deviasi: 140 + Math.random() * 20,
uniformity: 80 + Math.random() * 10,
},
});
}
return {
success: true,
mode: 'range',
range_size: 50.0,
data: mockDataArray,
};
}
},
/**
* Gets sensor weight summary for a specific date.
* In LIVE mode, fetches from API. In DEMO mode, returns mock data.
* @param date - Date in YYYY-MM-DD format
* @param mode - 'LIVE' or 'DEMO'
*/
getSensorWeightSummary: async (
date: string,
mode: 'LIVE' | 'DEMO'
): Promise<SensorWeightSummaryResponse> => {
if (mode === 'LIVE') {
const response = await fetch(
`${API_BASE_URL}/api/scales/predictions/summary?start_date=${date}&end_date=${date}`
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} else {
// Return mock data for DEMO mode
return {
success: true,
data: [
{
device_id: 'iot-5',
device_name: 'Timbangan A-2-W1',
total_weight_prediction: 0.0,
total_weight_actual: 450000.0,
avg_weight_prediction: 0.0,
avg_weight_actual: 750.0,
chicken_count: 600,
},
{
device_id: 'iot-13',
device_name: 'Timbangan C-1-W1',
total_weight_prediction: 0.0,
total_weight_actual: 482500.0,
avg_weight_prediction: 0.0,
avg_weight_actual: 805.0,
chicken_count: 600,
},
{
device_id: 'iot-14',
device_name: 'Timbangan K-01-W1',
total_weight_prediction: 0.0,
total_weight_actual: 320000.0,
avg_weight_prediction: 0.0,
avg_weight_actual: 800.0,
chicken_count: 400,
},
],
};
}
},
/**
* Gets scale prediction data for a date range.
* Returns detailed prediction items grouped by date.
*/
getScalePredictionsRange: async (
startDate: string,
endDate: string,
mode: 'LIVE' | 'DEMO'
): Promise<ScalePredictionRangeResponse> => {
if (mode === 'LIVE') {
const response = await fetch(
`${API_BASE_URL}/api/scales/predictions/range?start_date=${startDate}&end_date=${endDate}`
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} else {
// Return mock data for DEMO mode
return {
success: true,
data: [],
};
}
},
// --- Database API Functions ---
/**
* Fetches all cycles from database
*/
getCycles: async () => {
const response = await fetch(`${DB_API_BASE_URL}/cycles`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
if (json.success && json.data) {
return json.data;
}
throw new Error('Invalid data structure from API');
},
/**
* Fetches active cycle from database
*/
getActiveCycle: async () => {
const response = await fetch(`${DB_API_BASE_URL}/cycles/active`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
if (json.success && json.data) {
return json.data;
}
throw new Error('Invalid data structure from API');
},
/**
* Fetches a specific cycle by ID from database
*/
getCycleById: async (id: string) => {
const response = await fetch(`${DB_API_BASE_URL}/cycles/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
if (json.success && json.data) {
return json.data;
}
throw new Error('Invalid data structure from API');
},
/**
* Updates a cycle in database
*/
updateCycle: async (id: string, cycleData: any) => {
const response = await fetch(`${DB_API_BASE_URL}/cycles/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(cycleData),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
if (json.success && json.data) {
return json.data;
}
throw new Error('Invalid data structure from API');
},
/**
* Creates a new cycle in database
*/
createCycle: async (cycleData: {
id: string;
totalDays: number;
currentDay: number;
startDate: string;
endDate?: string;
chickInWeight?: number | null;
docInCount?: number | null;
status: string;
}) => {
const response = await fetch(`${DB_API_BASE_URL}/cycles`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(cycleData),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
if (json.success && json.data) {
return json.data;
}
throw new Error('Invalid data structure from API');
},
/**
* Fetches all mortality records for a cycle from database (optionally filtered by kandang)
*/
getMortalityRecords: async (cycleId: string, kandangId?: number) => {
const url = kandangId
? `${DB_API_BASE_URL}/mortality/${cycleId}?kandangId=${kandangId}`
: `${DB_API_BASE_URL}/mortality/${cycleId}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
if (json.success && json.data) {
return json.data;
}
throw new Error('Invalid data structure from API');
},
/**
* Fetches aggregated mortality totals across all kandangs for a cycle
*/
getMortalityTotals: async (cycleId: string) => {
const response = await fetch(`${DB_API_BASE_URL}/mortality/${cycleId}/totals`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
if (json.success && json.data) {
return json.data;
}
throw new Error('Invalid data structure from API');
},
/**
* Fetches a specific mortality record for a cycle and day from database
*/
getMortalityRecord: async (cycleId: string, day: number) => {
const response = await fetch(`${DB_API_BASE_URL}/mortality/${cycleId}/${day}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
if (json.success && json.data) {
return json.data;
}
throw new Error('Invalid data structure from API');
},
/**
* Updates a mortality record in database (with optional kandangId, panen, keterangan, and afkir)
*/
updateMortalityRecord: async (
cycleId: string,
day: number,
mortalityCount: number,
chickenCount?: number,
kandangId?: number,
panen?: number,
keterangan?: string,
afkir?: number,
beratPanen?: number
) => {
const response = await fetch(`${DB_API_BASE_URL}/mortality/${cycleId}/${day}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
mortalityCount,
chickenCount,
kandangId,
panen: panen || 0,
keterangan: keterangan || null,
afkir: afkir || 0,
beratPanen: Number.isFinite(beratPanen) ? beratPanen : 0,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
if (json.success && json.data) {
return json.data;
}
throw new Error('Invalid data structure from API');
},
/**
* Deletes a mortality record from database (resets to default)
*/
deleteMortalityRecord: async (cycleId: string, day: number) => {
const response = await fetch(`${DB_API_BASE_URL}/mortality/${cycleId}/${day}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
if (json.success) {
return json;
}
throw new Error('Invalid data structure from API');
},
// --- Chicken Counting API Functions ---
getChickenCounting: async (startDate: string, endDate: string, kandangId?: number) => {
let url = `${DB_API_BASE_URL}/chicken-counting?startDate=${startDate}&endDate=${endDate}`;
if (kandangId) {
url += `&kandangId=${kandangId}`;
}
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const json = await response.json();
if (json.success && json.data) return json.data;
throw new Error('Invalid data structure from API');
},
submitChickenCounting: async (
date: string,
kandang: string,
totalCount: number,
isManual: boolean = false
) => {
const apiKey = import.meta.env.VITE_CHICKEN_COUNTING_API_KEY || '';
const url = isManual
? `${DB_API_BASE_URL}/chicken-counting?source=manual`
: `${DB_API_BASE_URL}/chicken-counting`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': apiKey,
},
body: JSON.stringify({ date, kandang, total_count: totalCount }),
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const json = await response.json();
if (json.success && json.data) return json.data;
throw new Error('Invalid data structure from API');
},
// --- Kandang API Functions ---
getKandangs: async () => {
const response = await fetch(`${DB_API_BASE_URL}/kandangs`);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const json = await response.json();
if (json.success && json.data) return json.data;
throw new Error('Invalid data structure from API');
},
getKandangsByCycle: async (cycleId: string) => {
const response = await fetch(`${DB_API_BASE_URL}/kandangs/by-cycle/${cycleId}`, {
headers: { 'Cache-Control': 'no-cache' },
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const json = await response.json();
if (json.success && json.data) return json.data;
throw new Error('Invalid data structure from API');
},
createKandang: async (name: string) => {
const response = await fetch(`${DB_API_BASE_URL}/kandangs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const json = await response.json();
if (json.success && json.data) return json.data;
throw new Error('Invalid data structure from API');
},
updateKandang: async (id: number, name: string) => {
const response = await fetch(`${DB_API_BASE_URL}/kandangs/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const json = await response.json();
if (json.success && json.data) return json.data;
throw new Error('Invalid data structure from API');
},
deleteKandang: async (id: number) => {
const response = await fetch(`${DB_API_BASE_URL}/kandangs/${id}`, { method: 'DELETE' });
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const json = await response.json();
if (json.success) return json;
throw new Error('Invalid data structure from API');
},
setKandangDocInCount: async (kandangId: number, cycleId: string, docInCount: number) => {
const response = await fetch(`${DB_API_BASE_URL}/kandangs/${kandangId}/cycle/${cycleId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ docInCount }),
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const json = await response.json();
if (json.success) return json;
throw new Error('Invalid data structure from API');
},
updateKandangCageUuid: async (kandangId: number, cageUuid: string | null) => {
const response = await fetch(`${DB_API_BASE_URL}/kandangs/${kandangId}/cage-uuid`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cageUuid }),
});
if (!response.ok) {
const json = await response.json();
throw new Error(json.error || `HTTP error! status: ${response.status}`);
}
const json = await response.json();
if (json.success && json.data) return json.data;
throw new Error('Invalid data structure from API');
},
// --- Manual Feed Sack API Functions ---
getManualFeedSackEntries: async (startDate: string, endDate: string) => {
const response = await fetch(
`${DB_API_BASE_URL}/manual-feed-sack?startDate=${startDate}&endDate=${endDate}`
);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const json = await response.json();
if (json.success && json.data) return json.data;
throw new Error('Invalid data structure from API');
},
saveManualFeedSackEntry: async (
date: string,
cameraName: string,
count: number,
keterangan?: string,
createdBy?: string
) => {
const response = await fetch(`${DB_API_BASE_URL}/manual-feed-sack`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
date,
cameraName,
count,
keterangan: keterangan || null,
createdBy,
}),
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const json = await response.json();
if (json.success && json.data) return json.data;
throw new Error('Failed to save manual entry');
},
deleteManualFeedSackEntry: async (date: string, cameraName: string) => {
const response = await fetch(
`${DB_API_BASE_URL}/manual-feed-sack/${date}/${encodeURIComponent(cameraName)}`,
{ method: 'DELETE' }
);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const json = await response.json();
if (!json.success) throw new Error('Failed to delete manual entry');
},
/**
* Clears weight distribution cache from localStorage
*/
clearWeightDistributionCache: () => {
clearWeightDistributionCache();
},
// --- Audit Log API Functions ---
/**
* Get audit logs with optional filtering
* @param params - Query parameters for filtering
* @returns Promise<{success: boolean, data: any[], pagination: any}>
*/
getAuditLogs: async (params?: {
table?: string;
recordId?: string;
startDate?: string;
endDate?: string;
limit?: number;
offset?: number;
}) => {
const queryParams = new URLSearchParams();
if (params?.table) queryParams.append('table', params.table);
if (params?.recordId) queryParams.append('recordId', params.recordId);
if (params?.startDate) queryParams.append('startDate', params.startDate);
if (params?.endDate) queryParams.append('endDate', params.endDate);
if (params?.limit) queryParams.append('limit', params.limit.toString());
if (params?.offset) queryParams.append('offset', params.offset.toString());
const response = await fetch(`${DB_API_BASE_URL}/audit-logs?${queryParams.toString()}`);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return await response.json();
},
/**
* Get count of audit logs for a table
* @param table - Table name
* @returns Promise<{success: boolean, count: number}>
*/
getAuditLogsCount: async (table: string) => {
const response = await fetch(`${DB_API_BASE_URL}/audit-logs/count?table=${table}`);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return await response.json();
},
// --- Dashboard daily report snapshots ---
getDashboardReportSnapshot: async (
cycleId: string,
kandangId: number,
reportType: DashboardReportPayload['reportType'] = 'daily'
): Promise<DashboardReportSnapshotResponse> => {
const response = await fetch(
`${DB_API_BASE_URL}/dashboard-report-snapshots/${cycleId}?kandangId=${kandangId}&reportType=${reportType || 'daily'}`
);
if (response.status === 404) {
return { success: false, data: null, message: 'No snapshot found' };
}
if (!response.ok) {
await throwApiError(response);
}
return await response.json();
},
upsertDashboardReportSnapshot: async (input: {
cycleId: string;
kandangId: number;
upToDay: number;
payload: DashboardReportPayload;
generatedAt?: string;
reportType?: DashboardReportPayload['reportType'];
}): Promise<DashboardReportSnapshotResponse> => {
const response = await fetch(`${DB_API_BASE_URL}/dashboard-report-snapshots`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!response.ok) {
await throwApiError(response);
}
return await response.json();
},
// --- AI Insights API Functions ---
/**
* Get AI insight from database cache
* @param cycleId - Cycle ID
* @param kandangId - Kandang ID (optional, null means all kandangs)
* @returns Promise<{success: boolean, data?: any, message?: string}>
*/
/**
* Reads a STORED insight. Never generates one.
*
* `reportType`/`reportPeriod` scope the lookup the same way the generate
* endpoint stores it — without them a card asking for "Hari 12" gets whatever
* report happens to be newest for the cycle.
*/
getAiInsight: async (
cycleId: string,
kandangId: string | null = null,
reportType: 'daily' | 'weekly' | 'end_cycle' | null = null,
reportPeriod: string | null = null
) => {
const params = new URLSearchParams();
if (kandangId) params.set('kandangId', kandangId);
if (reportType) params.set('reportType', reportType);
if (reportPeriod) params.set('reportPeriod', reportPeriod);
const queryParams = params.toString() ? `?${params.toString()}` : '';
const response = await fetch(`${DB_API_BASE_URL}/ai-insights/${cycleId}${queryParams}`);
if (response.status === 404) {
// Not found is expected when no cached insight exists
return { success: false, message: 'No cached insight found' };
}
if (!response.ok) {
await throwApiError(response);
}
return await response.json();
},
/**
* Save AI insight to database cache
* @param cycleId - Cycle ID
* @param kandangId - Kandang ID (optional, null means all kandangs)
* @param insightText - Generated insight text
* @param version - Version string (for cache invalidation)
* @returns Promise<{success: boolean, data: any}>
*/
saveAiInsight: async (
cycleId: string,
kandangId: string | null,
insightText: string,
version: string = 'v1'
) => {
const response = await fetch(`${DB_API_BASE_URL}/ai-insights`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cycleId,
kandangId,
insightText,
version,
}),
});
if (!response.ok) {
await throwApiError(response);
}
return await response.json();
},
/**
* Get or generate dashboard AI insight via backend cache-first endpoint
* @param cycleId - Cycle ID
* @param kandangId - Kandang ID (optional, null means all kandangs)
* @param contextPack - Sanitized dashboard context for AI model
* @param version - Version string (for cache invalidation)
* @param forceRefresh - When true, bypasses cache and regenerates
* @returns Promise<{success: boolean, source: string, data: any}>
*/
getOrGenerateDashboardInsight: async (
cycleId: string,
kandangId: string | null,
contextPack: unknown,
version: string = 'v1',
forceRefresh = false,
reportType: 'daily' | 'weekly' | 'end_cycle' = 'daily',
reportPeriod: string | null = null
) => {
const response = await fetch(`${DB_API_BASE_URL}/ai-insights/dashboard`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cycleId,
kandangId,
contextPack,
version,
forceRefresh,
reportType,
reportPeriod,
}),
});
if (!response.ok) {
await throwApiError(response);
}
return await response.json();
},
/**
* Proxies a chat prompt to the local LM Studio server via the backend proxy.
* The backend automatically enriches the system_prompt with relevant CP707 book
* chunks retrieved from the RAG service (ChromaDB).
* @param input - The user prompt (contextData as JSON text)
* @param system_prompt - The system prompt
* @param temperature - Generation temperature (optional, defaults to 0.2)
* @param topic - Topic slug for RAG hint: 'berat_ayam' | 'fcr' | 'iot_panel' | 'eef' | 'hitung_ayam' | 'hitung_karung'
* @param context_query - Short query text for RAG semantic search (optional, falls back to first 300 chars of input)
* @returns Promise<any>
*/
proxyLmStudio: async (
input: string,
system_prompt: string,
temperature = 0.2,
topic = '',
context_query = '',
contextData: Record<string, any> | null = null,
/**
* Teks insight yang sedang tampil, dikirim HANYA ketika operator menekan
* generate lagi untuk periode data yang sama. Jalur proxy tidak punya
* cache, jadi backend tidak bisa tahu sendiri ini permintaan ulang —
* kehadiran field inilah penandanya. Backend memakainya untuk meminta
* sudut pandang dan tindakan yang berbeda, dengan angka yang tetap sama.
*/
previousInsight: string | null = null
) => {
const response = await fetch(`${DB_API_BASE_URL}/ai-insights/proxy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
input,
system_prompt,
temperature,
topic,
context_query,
contextData,
previous_insight: previousInsight,
}),
});
if (!response.ok) {
await throwApiError(response);
}
return await response.json();
},
};