1613 lines
50 KiB
TypeScript
1613 lines
50 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';
|
|
|
|
// --- 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, minuteStr] = 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) => {
|
|
if (!aggregated[item.time]) {
|
|
aggregated[item.time] = { Suhu: [], Kelembapan: [], Amonia: [] };
|
|
}
|
|
aggregated[item.time].Suhu.push(item.Suhu);
|
|
aggregated[item.time].Kelembapan.push(item.Kelembapan);
|
|
aggregated[item.time].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, aMin] = a.time.split(':').map(Number);
|
|
const [bHour, bMin] = b.time.split(':').map(Number);
|
|
return aHour * 60 + aMin - (bHour * 60 + bMin);
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
throw new Error('Invalid data structure from API');
|
|
}
|
|
|
|
/**
|
|
* 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) => 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();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 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();
|
|
},
|
|
|
|
// --- 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}>
|
|
*/
|
|
getAiInsight: async (cycleId: string, kandangId: string | null = null) => {
|
|
const queryParams = kandangId ? `?kandangId=${kandangId}` : '';
|
|
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
|
|
) => {
|
|
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,
|
|
}),
|
|
});
|
|
|
|
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
|
|
) => {
|
|
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,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
await throwApiError(response);
|
|
}
|
|
|
|
return await response.json();
|
|
},
|
|
};
|