diff --git a/backend/database/migrations/015_create_dashboard_report_snapshots.sql b/backend/database/migrations/015_create_dashboard_report_snapshots.sql new file mode 100644 index 0000000..381ee1f --- /dev/null +++ b/backend/database/migrations/015_create_dashboard_report_snapshots.sql @@ -0,0 +1,28 @@ +-- Frozen latest Dashboard IoT-vs-manual report snapshot per cycle + kandang. +-- Generate again overwrites the same row. No TTL / expires_at. + +CREATE TABLE IF NOT EXISTS dashboard_report_snapshots ( + id SERIAL PRIMARY KEY, + cycle_id VARCHAR(255) NOT NULL, + kandang_id INTEGER NOT NULL, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + up_to_day INTEGER NOT NULL, + generated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (cycle_id) REFERENCES cycles(id) ON DELETE CASCADE, + FOREIGN KEY (kandang_id) REFERENCES kandangs(id) ON DELETE CASCADE, + UNIQUE (cycle_id, kandang_id) +); + +CREATE INDEX IF NOT EXISTS idx_dashboard_report_snapshots_cycle_kandang + ON dashboard_report_snapshots (cycle_id, kandang_id); + +DROP TRIGGER IF EXISTS update_dashboard_report_snapshots_updated_at ON dashboard_report_snapshots; +CREATE TRIGGER update_dashboard_report_snapshots_updated_at + BEFORE UPDATE ON dashboard_report_snapshots + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +COMMENT ON TABLE dashboard_report_snapshots IS + 'Latest desktop Dashboard daily IoT-vs-manual report snapshot; one row per cycle+kandang, overwritten on Generate'; diff --git a/backend/database/migrations/016_add_report_type_to_dashboard_report_snapshots.sql b/backend/database/migrations/016_add_report_type_to_dashboard_report_snapshots.sql new file mode 100644 index 0000000..9d019d1 --- /dev/null +++ b/backend/database/migrations/016_add_report_type_to_dashboard_report_snapshots.sql @@ -0,0 +1,39 @@ +-- Independent daily vs end-cycle snapshots for the same cycle + kandang. + +ALTER TABLE dashboard_report_snapshots + ADD COLUMN IF NOT EXISTS report_type VARCHAR(32) NOT NULL DEFAULT 'daily'; + +DO $$ +DECLARE + constraint_name text; +BEGIN + SELECT conname INTO constraint_name + FROM pg_constraint + WHERE conrelid = 'dashboard_report_snapshots'::regclass + AND contype = 'u' + AND pg_get_constraintdef(oid) LIKE '%(cycle_id, kandang_id)%' + AND pg_get_constraintdef(oid) NOT LIKE '%report_type%'; + IF constraint_name IS NOT NULL THEN + EXECUTE format('ALTER TABLE dashboard_report_snapshots DROP CONSTRAINT IF EXISTS %I', constraint_name); + END IF; +END $$; + +ALTER TABLE dashboard_report_snapshots + DROP CONSTRAINT IF EXISTS dashboard_report_snapshots_cycle_id_kandang_id_key; + +ALTER TABLE dashboard_report_snapshots + DROP CONSTRAINT IF EXISTS dashboard_report_snapshots_cycle_kandang_type_key; + +ALTER TABLE dashboard_report_snapshots + ADD CONSTRAINT dashboard_report_snapshots_cycle_kandang_type_key + UNIQUE (cycle_id, kandang_id, report_type); + +ALTER TABLE dashboard_report_snapshots + DROP CONSTRAINT IF EXISTS dashboard_report_snapshots_report_type_check; + +ALTER TABLE dashboard_report_snapshots + ADD CONSTRAINT dashboard_report_snapshots_report_type_check + CHECK (report_type IN ('daily', 'end_cycle')); + +COMMENT ON COLUMN dashboard_report_snapshots.report_type IS + 'daily or end_cycle; unique with cycle_id + kandang_id so each type keeps its own latest snapshot'; diff --git a/backend/models/DashboardReportSnapshot.js b/backend/models/DashboardReportSnapshot.js new file mode 100644 index 0000000..a4338cb --- /dev/null +++ b/backend/models/DashboardReportSnapshot.js @@ -0,0 +1,73 @@ +const { query } = require('../database/db'); + +const ALLOWED_REPORT_TYPES = new Set(['daily', 'end_cycle']); + +class DashboardReportSnapshot { + static normalizeReportType(reportType) { + if (reportType == null || reportType === '') return 'daily'; + if (ALLOWED_REPORT_TYPES.has(reportType)) return reportType; + const error = new Error('Invalid reportType'); + error.code = 'INVALID_REPORT_TYPE'; + throw error; + } + + static formatRow(row) { + if (!row) return null; + return { + id: row.id, + cycleId: row.cycle_id, + kandangId: row.kandang_id, + upToDay: row.up_to_day, + generatedAt: row.generated_at, + reportType: row.report_type || 'daily', + payload: row.payload, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } + + static async getByCycleAndKandang(cycleId, kandangId, reportType = 'daily') { + if (kandangId === null || kandangId === undefined || kandangId === '') { + return null; + } + const type = this.normalizeReportType(reportType); + const result = await query( + `SELECT * FROM dashboard_report_snapshots + WHERE cycle_id = $1 AND kandang_id = $2 AND report_type = $3 + LIMIT 1`, + [cycleId, kandangId, type] + ); + return result.rows[0] ? this.formatRow(result.rows[0]) : null; + } + + static async upsert({ cycleId, kandangId, upToDay, payload, generatedAt, reportType }) { + if (kandangId === null || kandangId === undefined || kandangId === '') { + throw new Error('kandangId is required'); + } + if (!cycleId) { + throw new Error('cycleId is required'); + } + if (upToDay === null || upToDay === undefined || !Number.isFinite(Number(upToDay))) { + throw new Error('upToDay is required'); + } + + const type = this.normalizeReportType(reportType); + const generated = generatedAt ? new Date(generatedAt) : new Date(); + const result = await query( + `INSERT INTO dashboard_report_snapshots + (cycle_id, kandang_id, payload, up_to_day, generated_at, report_type) + VALUES ($1, $2, $3::jsonb, $4, $5, $6) + ON CONFLICT (cycle_id, kandang_id, report_type) + DO UPDATE SET + payload = EXCLUDED.payload, + up_to_day = EXCLUDED.up_to_day, + generated_at = EXCLUDED.generated_at, + updated_at = CURRENT_TIMESTAMP + RETURNING *`, + [cycleId, kandangId, JSON.stringify(payload ?? {}), Number(upToDay), generated, type] + ); + return this.formatRow(result.rows[0]); + } +} + +module.exports = DashboardReportSnapshot; diff --git a/backend/models/__tests__/DashboardReportSnapshot.test.js b/backend/models/__tests__/DashboardReportSnapshot.test.js new file mode 100644 index 0000000..b091d68 --- /dev/null +++ b/backend/models/__tests__/DashboardReportSnapshot.test.js @@ -0,0 +1,203 @@ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { query } from '../../database/db.js'; +import { runMigrations } from '../../database/runMigrations.js'; +import Cycle from '../Cycle.js'; +import Kandang from '../Kandang.js'; +import DashboardReportSnapshot from '../DashboardReportSnapshot.js'; + +const TEST_CYCLE_ID = 'TEST-REPORT-SNAP-CYCLE'; + +const emptyPayload = { + cycleId: TEST_CYCLE_ID, + kandangName: 'Kandang Test', + area: 'Sukawarna', + counting: [], + weight: [], + feedIn: [], + feedUse: [], +}; + +describe('DashboardReportSnapshot', () => { + let kandangA; + let kandangB; + + beforeAll(async () => { + await runMigrations(); + }); + + beforeEach(async () => { + await query('DELETE FROM dashboard_report_snapshots WHERE cycle_id = $1', [TEST_CYCLE_ID]); + await query('DELETE FROM kandang_cycles WHERE cycle_id = $1', [TEST_CYCLE_ID]); + await query('DELETE FROM cycles WHERE id = $1', [TEST_CYCLE_ID]); + await query("DELETE FROM kandangs WHERE name LIKE 'TEST-REPORT-SNAP-%'"); + + await Cycle.create({ + id: TEST_CYCLE_ID, + totalDays: 35, + currentDay: 8, + startDate: '2026-04-01', + status: 'Active', + docInCount: 10000, + }); + kandangA = await Kandang.create('TEST-REPORT-SNAP-A'); + kandangB = await Kandang.create('TEST-REPORT-SNAP-B'); + }); + + afterEach(async () => { + await query('DELETE FROM dashboard_report_snapshots WHERE cycle_id = $1', [TEST_CYCLE_ID]); + await query('DELETE FROM kandang_cycles WHERE cycle_id = $1', [TEST_CYCLE_ID]); + await query('DELETE FROM cycles WHERE id = $1', [TEST_CYCLE_ID]); + await query("DELETE FROM kandangs WHERE name LIKE 'TEST-REPORT-SNAP-%'"); + }); + + it('stores a row even when table payloads are empty (AE5)', async () => { + const saved = await DashboardReportSnapshot.upsert({ + cycleId: TEST_CYCLE_ID, + kandangId: kandangA.id, + upToDay: 0, + payload: { ...emptyPayload, kandangId: kandangA.id, upToDay: 0 }, + }); + + expect(saved).toBeDefined(); + expect(saved.cycleId).toBe(TEST_CYCLE_ID); + expect(saved.kandangId).toBe(kandangA.id); + expect(saved.upToDay).toBe(0); + expect(saved.payload.counting).toEqual([]); + expect(saved.payload.weight).toEqual([]); + expect(saved.payload.feedIn).toEqual([]); + expect(saved.payload.feedUse).toEqual([]); + expect(saved.generatedAt).toBeTruthy(); + expect(saved).not.toHaveProperty('expiresAt'); + }); + + it('returns the stored payload unchanged when nothing is POSTed again (AE1)', async () => { + const original = { + ...emptyPayload, + kandangId: kandangA.id, + upToDay: 8, + counting: [{ day: 5, mortality: 4 }], + }; + await DashboardReportSnapshot.upsert({ + cycleId: TEST_CYCLE_ID, + kandangId: kandangA.id, + upToDay: 8, + payload: original, + }); + + const fetched = await DashboardReportSnapshot.getByCycleAndKandang( + TEST_CYCLE_ID, + kandangA.id + ); + expect(fetched.payload.counting).toEqual([{ day: 5, mortality: 4 }]); + expect(fetched.upToDay).toBe(8); + }); + + it('replaces payload and up_to_day on second upsert; still one row', async () => { + await DashboardReportSnapshot.upsert({ + cycleId: TEST_CYCLE_ID, + kandangId: kandangA.id, + upToDay: 8, + payload: { ...emptyPayload, kandangId: kandangA.id, upToDay: 8, counting: [{ day: 1 }] }, + }); + await DashboardReportSnapshot.upsert({ + cycleId: TEST_CYCLE_ID, + kandangId: kandangA.id, + upToDay: 20, + payload: { ...emptyPayload, kandangId: kandangA.id, upToDay: 20, counting: [{ day: 20 }] }, + }); + + const fetched = await DashboardReportSnapshot.getByCycleAndKandang( + TEST_CYCLE_ID, + kandangA.id + ); + expect(fetched.upToDay).toBe(20); + expect(fetched.payload.counting).toEqual([{ day: 20 }]); + + const count = await query( + 'SELECT COUNT(*)::int AS n FROM dashboard_report_snapshots WHERE cycle_id = $1 AND kandang_id = $2', + [TEST_CYCLE_ID, kandangA.id] + ); + expect(count.rows[0].n).toBe(1); + }); + + it('does not return another kandang snapshot for a missing pair', async () => { + await DashboardReportSnapshot.upsert({ + cycleId: TEST_CYCLE_ID, + kandangId: kandangA.id, + upToDay: 8, + payload: { ...emptyPayload, kandangId: kandangA.id, upToDay: 8 }, + }); + + const missing = await DashboardReportSnapshot.getByCycleAndKandang( + TEST_CYCLE_ID, + kandangB.id + ); + expect(missing).toBeNull(); + }); + + it('rejects upsert without kandangId', async () => { + await expect( + DashboardReportSnapshot.upsert({ + cycleId: TEST_CYCLE_ID, + kandangId: null, + upToDay: 8, + payload: emptyPayload, + }) + ).rejects.toThrow(/kandang/i); + }); + + it('keeps daily and end-cycle snapshots independent for the same kandang', async () => { + await DashboardReportSnapshot.upsert({ + cycleId: TEST_CYCLE_ID, + kandangId: kandangA.id, + upToDay: 8, + reportType: 'daily', + payload: { ...emptyPayload, kandangId: kandangA.id, upToDay: 8, counting: [{ day: 1 }] }, + }); + await DashboardReportSnapshot.upsert({ + cycleId: TEST_CYCLE_ID, + kandangId: kandangA.id, + upToDay: 34, + reportType: 'end_cycle', + payload: { ...emptyPayload, kandangId: kandangA.id, upToDay: 34, fcr: [{ day: 0 }] }, + }); + + const daily = await DashboardReportSnapshot.getByCycleAndKandang( + TEST_CYCLE_ID, + kandangA.id, + 'daily' + ); + const endCycle = await DashboardReportSnapshot.getByCycleAndKandang( + TEST_CYCLE_ID, + kandangA.id, + 'end_cycle' + ); + + expect(daily.upToDay).toBe(8); + expect(daily.reportType).toBe('daily'); + expect(daily.payload.counting).toEqual([{ day: 1 }]); + expect(endCycle.upToDay).toBe(34); + expect(endCycle.reportType).toBe('end_cycle'); + expect(endCycle.payload.fcr).toEqual([{ day: 0 }]); + + const count = await query( + 'SELECT COUNT(*)::int AS n FROM dashboard_report_snapshots WHERE cycle_id = $1 AND kandang_id = $2', + [TEST_CYCLE_ID, kandangA.id] + ); + expect(count.rows[0].n).toBe(2); + }); + + it('defaults missing reportType to daily', async () => { + await DashboardReportSnapshot.upsert({ + cycleId: TEST_CYCLE_ID, + kandangId: kandangA.id, + upToDay: 8, + payload: { ...emptyPayload, kandangId: kandangA.id, upToDay: 8 }, + }); + const fetched = await DashboardReportSnapshot.getByCycleAndKandang( + TEST_CYCLE_ID, + kandangA.id + ); + expect(fetched.reportType).toBe('daily'); + }); +}); diff --git a/backend/routes/__tests__/dashboardReportSnapshots.test.js b/backend/routes/__tests__/dashboardReportSnapshots.test.js new file mode 100644 index 0000000..a77fe9f --- /dev/null +++ b/backend/routes/__tests__/dashboardReportSnapshots.test.js @@ -0,0 +1,253 @@ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import http from 'http'; +import express from 'express'; +import { query } from '../../database/db.js'; +import { runMigrations } from '../../database/runMigrations.js'; +import Cycle from '../../models/Cycle.js'; +import Kandang from '../../models/Kandang.js'; + +const TEST_CYCLE_ID = 'TEST-REPORT-SNAP-ROUTE'; + +const emptyPayload = { + cycleId: TEST_CYCLE_ID, + kandangName: 'Kandang Test', + area: 'Sukawarna', + counting: [], + weight: [], + feedIn: [], + feedUse: [], +}; + +async function withServer(handler) { + const app = express(); + app.use(express.json()); + const router = require('../dashboardReportSnapshots.js'); + app.use('/api/dashboard-report-snapshots', router); + + const server = http.createServer(app); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address(); + const base = `http://127.0.0.1:${port}/api/dashboard-report-snapshots`; + try { + return await handler(base); + } finally { + await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))); + } +} + +describe('dashboard report snapshot routes', () => { + let kandangA; + let kandangB; + + beforeAll(async () => { + await runMigrations(); + }); + + beforeEach(async () => { + await query('DELETE FROM dashboard_report_snapshots WHERE cycle_id = $1', [TEST_CYCLE_ID]); + await query('DELETE FROM kandang_cycles WHERE cycle_id = $1', [TEST_CYCLE_ID]); + await query('DELETE FROM cycles WHERE id = $1', [TEST_CYCLE_ID]); + await query("DELETE FROM kandangs WHERE name LIKE 'TEST-REPORT-ROUTE-%'"); + + await Cycle.create({ + id: TEST_CYCLE_ID, + totalDays: 35, + currentDay: 8, + startDate: '2026-04-01', + status: 'Active', + docInCount: 10000, + }); + kandangA = await Kandang.create('TEST-REPORT-ROUTE-A'); + kandangB = await Kandang.create('TEST-REPORT-ROUTE-B'); + }); + + afterEach(async () => { + await query('DELETE FROM dashboard_report_snapshots WHERE cycle_id = $1', [TEST_CYCLE_ID]); + await query('DELETE FROM kandang_cycles WHERE cycle_id = $1', [TEST_CYCLE_ID]); + await query('DELETE FROM cycles WHERE id = $1', [TEST_CYCLE_ID]); + await query("DELETE FROM kandangs WHERE name LIKE 'TEST-REPORT-ROUTE-%'"); + }); + + it('POST empty tables still stores a row (AE5)', async () => { + await withServer(async (base) => { + const res = await fetch(base, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + cycleId: TEST_CYCLE_ID, + kandangId: kandangA.id, + upToDay: 0, + payload: { ...emptyPayload, kandangId: kandangA.id, upToDay: 0 }, + }), + }); + const json = await res.json(); + expect(res.status).toBe(200); + expect(json.success).toBe(true); + expect(json.data.upToDay).toBe(0); + expect(json.data.payload.counting).toEqual([]); + }); + }); + + it('GET returns stored payload when nothing is POSTed again (AE1)', async () => { + await withServer(async (base) => { + await fetch(base, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + cycleId: TEST_CYCLE_ID, + kandangId: kandangA.id, + upToDay: 8, + payload: { + ...emptyPayload, + kandangId: kandangA.id, + upToDay: 8, + counting: [{ day: 5, mortality: 4 }], + }, + }), + }); + + const res = await fetch(`${base}/${TEST_CYCLE_ID}?kandangId=${kandangA.id}`); + const json = await res.json(); + expect(res.status).toBe(200); + expect(json.success).toBe(true); + expect(json.data.payload.counting).toEqual([{ day: 5, mortality: 4 }]); + }); + }); + + it('second POST replaces payload and upToDay; still one row', async () => { + await withServer(async (base) => { + await fetch(base, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + cycleId: TEST_CYCLE_ID, + kandangId: kandangA.id, + upToDay: 8, + payload: { ...emptyPayload, kandangId: kandangA.id, upToDay: 8 }, + }), + }); + await fetch(base, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + cycleId: TEST_CYCLE_ID, + kandangId: kandangA.id, + upToDay: 20, + payload: { + ...emptyPayload, + kandangId: kandangA.id, + upToDay: 20, + counting: [{ day: 20 }], + }, + }), + }); + + const res = await fetch(`${base}/${TEST_CYCLE_ID}?kandangId=${kandangA.id}`); + const json = await res.json(); + expect(json.data.upToDay).toBe(20); + expect(json.data.payload.counting).toEqual([{ day: 20 }]); + + const count = await query( + 'SELECT COUNT(*)::int AS n FROM dashboard_report_snapshots WHERE cycle_id = $1 AND kandang_id = $2', + [TEST_CYCLE_ID, kandangA.id] + ); + expect(count.rows[0].n).toBe(1); + }); + }); + + it('GET missing pair returns not found, not another kandang snapshot', async () => { + await withServer(async (base) => { + await fetch(base, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + cycleId: TEST_CYCLE_ID, + kandangId: kandangA.id, + upToDay: 8, + payload: { ...emptyPayload, kandangId: kandangA.id, upToDay: 8 }, + }), + }); + + const res = await fetch(`${base}/${TEST_CYCLE_ID}?kandangId=${kandangB.id}`); + const json = await res.json(); + expect(res.status).toBe(404); + expect(json.data == null).toBe(true); + }); + }); + + it('POST without kandangId is rejected', async () => { + await withServer(async (base) => { + const res = await fetch(base, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + cycleId: TEST_CYCLE_ID, + upToDay: 8, + payload: emptyPayload, + }), + }); + const json = await res.json(); + expect(res.status).toBe(400); + expect(json.success).toBe(false); + }); + }); + + it('POST invalid reportType is rejected', async () => { + await withServer(async (base) => { + const res = await fetch(base, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + cycleId: TEST_CYCLE_ID, + kandangId: kandangA.id, + upToDay: 8, + reportType: 'weekly', + payload: emptyPayload, + }), + }); + const json = await res.json(); + expect(res.status).toBe(400); + expect(json.success).toBe(false); + }); + }); + + it('GET/POST isolate daily vs end_cycle for the same kandang', async () => { + await withServer(async (base) => { + await fetch(base, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + cycleId: TEST_CYCLE_ID, + kandangId: kandangA.id, + upToDay: 8, + reportType: 'daily', + payload: { ...emptyPayload, kandangId: kandangA.id, upToDay: 8, counting: [{ day: 1 }] }, + }), + }); + await fetch(base, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + cycleId: TEST_CYCLE_ID, + kandangId: kandangA.id, + upToDay: 34, + reportType: 'end_cycle', + payload: { ...emptyPayload, kandangId: kandangA.id, upToDay: 34, fcr: [{ day: 0 }] }, + }), + }); + + const dailyRes = await fetch(`${base}/${TEST_CYCLE_ID}?kandangId=${kandangA.id}`); + const endRes = await fetch( + `${base}/${TEST_CYCLE_ID}?kandangId=${kandangA.id}&reportType=end_cycle` + ); + const daily = await dailyRes.json(); + const endCycle = await endRes.json(); + + expect(daily.data.reportType).toBe('daily'); + expect(daily.data.upToDay).toBe(8); + expect(endCycle.data.reportType).toBe('end_cycle'); + expect(endCycle.data.upToDay).toBe(34); + expect(endCycle.data.payload.fcr).toEqual([{ day: 0 }]); + }); + }); +}); diff --git a/backend/routes/dashboardReportSnapshots.js b/backend/routes/dashboardReportSnapshots.js new file mode 100644 index 0000000..04a4764 --- /dev/null +++ b/backend/routes/dashboardReportSnapshots.js @@ -0,0 +1,110 @@ +const express = require('express'); +const router = express.Router(); +const DashboardReportSnapshot = require('../models/DashboardReportSnapshot'); + +const ALLOWED_REPORT_TYPES = new Set(['daily', 'end_cycle']); + +const parseKandangId = (value) => { + if (value === null || value === undefined || value === '') return null; + const parsed = Number(value); + return Number.isInteger(parsed) ? parsed : null; +}; + +const parseReportType = (value) => { + if (value === null || value === undefined || value === '') return 'daily'; + if (ALLOWED_REPORT_TYPES.has(value)) return value; + return null; +}; + +/** + * GET /api/dashboard-report-snapshots/:cycleId?kandangId=&reportType= + * Latest snapshot for this cycle+kandang+type. 404 when none. + */ +router.get('/:cycleId', async (req, res) => { + try { + const { cycleId } = req.params; + const kandangId = parseKandangId(req.query.kandangId); + const reportType = parseReportType(req.query.reportType); + if (!cycleId || kandangId === null) { + return res.status(400).json({ + success: false, + error: 'cycleId and kandangId are required', + }); + } + if (reportType === null) { + return res.status(400).json({ + success: false, + error: 'reportType must be daily or end_cycle', + }); + } + + const snapshot = await DashboardReportSnapshot.getByCycleAndKandang( + cycleId, + kandangId, + reportType + ); + if (!snapshot) { + return res.status(404).json({ + success: false, + data: null, + message: 'No snapshot found', + }); + } + + res.json({ success: true, data: snapshot }); + } catch (error) { + console.error('Error fetching dashboard report snapshot:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * POST /api/dashboard-report-snapshots + * Upsert frozen snapshot for cycle+kandang+reportType. Overwrites the previous row of that type. + */ +router.post('/', async (req, res) => { + try { + const { cycleId, kandangId, upToDay, payload, generatedAt, reportType } = req.body || {}; + const parsedKandangId = parseKandangId(kandangId); + const parsedReportType = parseReportType(reportType); + + if (!cycleId || parsedKandangId === null) { + return res.status(400).json({ + success: false, + error: 'cycleId and kandangId are required', + }); + } + if (parsedReportType === null) { + return res.status(400).json({ + success: false, + error: 'reportType must be daily or end_cycle', + }); + } + + if (upToDay === null || upToDay === undefined || !Number.isFinite(Number(upToDay))) { + return res.status(400).json({ + success: false, + error: 'upToDay is required', + }); + } + + const snapshot = await DashboardReportSnapshot.upsert({ + cycleId, + kandangId: parsedKandangId, + upToDay: Number(upToDay), + payload: payload ?? {}, + generatedAt, + reportType: parsedReportType, + }); + + res.json({ success: true, data: snapshot }); + } catch (error) { + if (error.code === 'INVALID_REPORT_TYPE') { + return res.status(400).json({ success: false, error: error.message }); + } + console.error('Error saving dashboard report snapshot:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js index a74c763..10f0597 100644 --- a/backend/server.js +++ b/backend/server.js @@ -71,6 +71,7 @@ const manualFeedSackRouter = require('./routes/manualFeedSack'); const cycleFeedInitialBalanceRouter = require('./routes/cycleFeedInitialBalance'); const auditLogsRouter = require('./routes/auditLogs'); const aiInsightsRouter = require('./routes/aiInsights'); +const dashboardReportSnapshotsRouter = require('./routes/dashboardReportSnapshots'); app.use('/api/cycles', cyclesRouter); app.use('/api/mortality', mortalityRouter); @@ -82,6 +83,7 @@ app.use('/api/manual-feed-sack', manualFeedSackRouter); app.use('/api/cycle-feed-initial-balance', cycleFeedInitialBalanceRouter); app.use('/api/audit-logs', auditLogsRouter); app.use('/api/ai-insights', aiInsightsRouter); +app.use('/api/dashboard-report-snapshots', dashboardReportSnapshotsRouter); // Health check endpoint app.get('/health', (req, res) => { diff --git a/components/AiInsightCard.tsx b/components/AiInsightCard.tsx index 708ce0f..9e25118 100644 --- a/components/AiInsightCard.tsx +++ b/components/AiInsightCard.tsx @@ -873,14 +873,15 @@ export const AiInsightCard: React.FC = ({ kandangId, page = let toDay: number | null = null; if (reportType === 'end_cycle') { - fromDay = 1; + fromDay = 0; toDay = currentDay ?? null; } else if (reportType === 'weekly') { // `reportPeriod` is "Minggu 6" — a week number, not a day number. + // Hitung Ayam days are 0-based: week 1 = hari 0–6. const { fromDay: weekNo } = parseDayRangeLabel(reportPeriod); if (weekNo) { - fromDay = (weekNo - 1) * 7 + 1; - toDay = weekNo * 7; + fromDay = (weekNo - 1) * 7; + toDay = weekNo * 7 - 1; } } else if (reportType === 'daily') { // Here the number IS a day, so it is used as-is rather than scaled by 7. diff --git a/components/Dashboard.tsx b/components/Dashboard.tsx index aed3b50..3f47e70 100644 --- a/components/Dashboard.tsx +++ b/components/Dashboard.tsx @@ -5,6 +5,10 @@ import DailyWeightGainChart from './weight/DailyWeightGainChart.tsx'; import { AiInsightCard } from './AiInsightCard.tsx'; import FeedSaldoCard from './feedSack/FeedSaldoCard.tsx'; import KarungDituangTodayCard from './feedSack/KarungDituangTodayCard.tsx'; +import ReportPreviewOverlay from './dashboard/ReportPreviewOverlay.tsx'; +import DashboardReportCtas from './dashboard/DashboardReportCtas.tsx'; +import type { DashboardReportType } from '../types/dashboardReport.ts'; +import { isCycleClosed } from '../utils/reportCycle.ts'; import { Kpi } from '../types.ts'; import { View } from '../types/common.ts'; import { @@ -34,7 +38,7 @@ interface DashboardProps { const Dashboard: React.FC = ({ kpis, setView, - selectedArea: _selectedArea, + selectedArea, onAreaChange: _onAreaChange, selectedKandangId, onKandangChange, @@ -51,14 +55,22 @@ const Dashboard: React.FC = ({ cycleData, isRefreshingWeightKpi, feedSackHistory, + manualFeedSackEntries, effectiveDate, isShowingPreviousDayData, + hasPermission, } = useAppContext(); // Column config state for FeedSaldoCard (loaded from DB) const [columnConfig, setColumnConfig] = useState(DEFAULT_FEED_SACK_COLUMNS); const [initialBalance, setInitialBalance] = useState(null); + const [reportOpen, setReportOpen] = useState(false); + const [reportType, setReportType] = useState('daily'); + const [hasDailySnapshot, setHasDailySnapshot] = useState(false); + const [hasEndCycleSnapshot, setHasEndCycleSnapshot] = useState(false); + const canViewDashboard = hasPermission('p-dash-view'); + const cycleClosed = isCycleClosed(cycleData.endDate); const feedSaldoEffectiveDate = useMemo(() => { const cappedEffectiveDate = new Date(effectiveDate); @@ -192,6 +204,32 @@ const Dashboard: React.FC = ({ } }, [cycleData.id]); + useEffect(() => { + if (!canViewDashboard || !cycleData.id || selectedKandangId == null) { + setHasDailySnapshot(false); + setHasEndCycleSnapshot(false); + return; + } + let cancelled = false; + Promise.all([ + apiClient.getDashboardReportSnapshot(cycleData.id, selectedKandangId, 'daily'), + apiClient.getDashboardReportSnapshot(cycleData.id, selectedKandangId, 'end_cycle'), + ]) + .then(([daily, endCycle]) => { + if (cancelled) return; + setHasDailySnapshot(!!daily.success && !!daily.data); + setHasEndCycleSnapshot(!!endCycle.success && !!endCycle.data); + }) + .catch(() => { + if (cancelled) return; + setHasDailySnapshot(false); + setHasEndCycleSnapshot(false); + }); + return () => { + cancelled = true; + }; + }, [canViewDashboard, cycleData.id, selectedKandangId]); + useLayoutEffect(() => { const duration = performance.now() - mountStart.current; performanceService.recordComponentTiming('Dashboard', duration); @@ -218,6 +256,44 @@ const Dashboard: React.FC = ({ })} + {canViewDashboard && ( + { + setReportType('daily'); + setReportOpen(true); + }} + onOpenEndCycle={() => { + if (!cycleClosed) return; + setReportType('end_cycle'); + setReportOpen(true); + }} + /> + )} + + setReportOpen(false)} + reportType={reportType} + cycleId={cycleData.id} + cycleStartDate={cycleData.startDate} + cycleEndDate={cycleData.endDate} + cycleCurrentDay={cycleData.currentDay ?? 0} + cycleDocInCount={cycleData.docInCount} + kandangId={selectedKandangId} + kandangs={kandangs} + area={selectedArea} + feedSackHistory={feedSackHistory} + manualFeedEntries={manualFeedSackEntries} + feedColumnConfig={columnConfig} + onSnapshotSaved={() => { + if (reportType === 'end_cycle') setHasEndCycleSnapshot(true); + else setHasDailySnapshot(true); + }} + /> + {/* AI Insight Card */} diff --git a/components/EefDashboardPage.tsx b/components/EefDashboardPage.tsx index f6f4625..75fa4b0 100644 --- a/components/EefDashboardPage.tsx +++ b/components/EefDashboardPage.tsx @@ -441,20 +441,17 @@ const EefDashboardPage: React.FC = () => { Umur - EEF Persentase Ayam Hidup BW Rata-rata Panen Umur Rata-rata Panen FCR + EEF {supportRows.map((row) => ( Day {row.day} - - {formatEef(row.iotEef)} - {formatNullableNumber(row.chickenLifePercentage, 3)} {row.chickenLifePercentage !== null ? '%' : ''} @@ -469,6 +466,9 @@ const EefDashboardPage: React.FC = () => { {formatFcr(row.iotFcr)} + + {formatEef(row.iotEef)} + ))} {!isLoading && supportRows.length === 0 && ( diff --git a/components/FcrDashboardPage.tsx b/components/FcrDashboardPage.tsx index 7362f8b..61bb882 100644 --- a/components/FcrDashboardPage.tsx +++ b/components/FcrDashboardPage.tsx @@ -429,20 +429,17 @@ const FcrDashboardPage: React.FC = () => { Umur - FCR Total Pakan Bobot IoT Ayam Hidup Total Bobot Panen + FCR {supportRows.map((row) => ( Day {row.day} - - {formatFcr(row.iotFcr)} - {formatNumber(row.feedTotal)} karung @@ -456,6 +453,9 @@ const FcrDashboardPage: React.FC = () => { {formatHarvestWeightKg(row.harvestWeightTotal)} kg + + {formatFcr(row.iotFcr)} + ))} {!isLoading && supportRows.length === 0 && ( diff --git a/components/dashboard/DashboardReportCtas.tsx b/components/dashboard/DashboardReportCtas.tsx new file mode 100644 index 0000000..7171ede --- /dev/null +++ b/components/dashboard/DashboardReportCtas.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { reportCtaLabel } from '../../utils/reportOverlayLabels.ts'; + +export interface DashboardReportCtasProps { + cycleClosed: boolean; + hasDailySnapshot: boolean; + hasEndCycleSnapshot: boolean; + onOpenDaily: () => void; + onOpenEndCycle: () => void; +} + +const buttonClass = + 'rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50'; + +const DashboardReportCtas: React.FC = ({ + cycleClosed, + hasDailySnapshot, + hasEndCycleSnapshot, + onOpenDaily, + onOpenEndCycle, +}) => ( +
+ +
+ + {!cycleClosed && ( +

Tersedia setelah siklus ditutup

+ )} +
+
+); + +export default DashboardReportCtas; diff --git a/components/dashboard/ReportPreviewOverlay.tsx b/components/dashboard/ReportPreviewOverlay.tsx new file mode 100644 index 0000000..c4ac8ac --- /dev/null +++ b/components/dashboard/ReportPreviewOverlay.tsx @@ -0,0 +1,708 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Download, FileSpreadsheet, X } from 'lucide-react'; +import type { FeedSackColumnConfig, FeedSackLogEntry, Kandang, ManualFeedSackEntry } from '../../types/counting.ts'; +import type { + DashboardReportPayload, + DashboardReportSnapshotRecord, + DashboardReportType, +} from '../../types/dashboardReport.ts'; +import { apiClient } from '../../services/apiService.ts'; +import { buildReportSnapshot } from '../../utils/buildReportSnapshot.ts'; +import { closedCycleUpToDay } from '../../utils/reportCycle.ts'; +import { latestValidRatio, mapKpiToEefRows, mapKpiToFcrRows } from '../../utils/reportFcrEef.ts'; +import { generateButtonLabel, reportOverlayTitle } from '../../utils/reportOverlayLabels.ts'; +import { formatLocalDate, parseLocalDateString, reportDayToDate } from '../../utils/reportDay.ts'; +import { formatDecimal, formatNumber, formatTruncatedRatio } from '../../utils/formatters.ts'; +import PdfReportHeader, { + buildPdfFilename, + describeDataDates, + isoDataDates, +} from '../shared/PdfReportHeader.tsx'; +import { exportCardToPdf } from '../../utils/pdfExport.ts'; +import { downloadReportExcel } from '../../utils/reportExcel.ts'; +import { countingTotals, feedInTotals, feedUseTotals } from '../../utils/reportTotals.ts'; + +export interface ReportPreviewOverlayProps { + open: boolean; + onClose: () => void; + cycleId: string; + cycleStartDate: Date | string; + cycleCurrentDay: number; + cycleDocInCount?: number; + kandangId: number | null; + kandangs: Kandang[]; + area: string; + feedSackHistory: FeedSackLogEntry[]; + manualFeedEntries: ManualFeedSackEntry[]; + feedColumnConfig: FeedSackColumnConfig[]; + reportType?: DashboardReportType; + cycleEndDate?: Date | string | null; + onSnapshotSaved?: () => void; +} + +const cell = (value: number | string | null | undefined, suffix = ''): string => { + if (value === null || value === undefined || value === '') return 'N/A'; + if (typeof value === 'number' && !Number.isFinite(value)) return 'N/A'; + return `${value}${suffix}`; +}; + +const accuracyCell = (value: number | null | undefined): string => + value == null || !Number.isFinite(value) ? 'N/A' : `${value.toFixed(1)}%`; + +const ratioCell = (value: number | null | undefined): string => + value == null || !Number.isFinite(value) ? 'N/A' : formatTruncatedRatio(value); + +const qtyCell = (value: number | null | undefined, maxDecimals = 0, suffix = ''): string => { + if (value == null || !Number.isFinite(value)) return 'N/A'; + return `${formatNumber(value, { maxDecimals })}${suffix}`; +}; + +const harvestKgCell = (grams: number | null | undefined): string => { + if (grams == null || !Number.isFinite(grams)) return 'N/A'; + return `${formatDecimal(grams / 1000, { maxDecimals: 1 })} kg`; +}; + +const ReportPreviewOverlay: React.FC = ({ + open, + onClose, + cycleId, + cycleStartDate, + cycleCurrentDay, + cycleDocInCount = 0, + kandangId, + kandangs, + area, + feedSackHistory, + manualFeedEntries, + feedColumnConfig, + reportType = 'daily', + cycleEndDate = null, + onSnapshotSaved, +}) => { + const isEndCycle = reportType === 'end_cycle'; + const latestDay = Math.max(0, cycleCurrentDay || 0); + const closedUpToDay = closedCycleUpToDay(cycleStartDate, cycleEndDate); + const [draftKandangId, setDraftKandangId] = useState(kandangId); + const [draftDay, setDraftDay] = useState(isEndCycle ? closedUpToDay : latestDay); + const [snapshot, setSnapshot] = useState(null); + const [loading, setLoading] = useState(false); + const [generating, setGenerating] = useState(false); + const [error, setError] = useState(null); + const [kpiWarning, setKpiWarning] = useState(null); + const printRef = useRef(null); + const overlayTitle = reportOverlayTitle(reportType); + const effectiveUpToDay = isEndCycle ? closedUpToDay : draftDay; + + const dayOptions = useMemo( + () => Array.from({ length: latestDay + 1 }, (_, day) => day), + [latestDay] + ); + + const selectedKandang = useMemo( + () => kandangs.find((k) => k.id === draftKandangId) || null, + [kandangs, draftKandangId] + ); + + useEffect(() => { + if (!open) return; + setDraftKandangId(kandangId); + setDraftDay(isEndCycle ? closedUpToDay : latestDay); + setSnapshot(null); + setError(null); + setKpiWarning(null); + }, [open, kandangId, latestDay, isEndCycle, closedUpToDay]); + + useEffect(() => { + if (!open || !cycleId || draftKandangId == null) { + if (open && draftKandangId == null) setSnapshot(null); + return; + } + let cancelled = false; + setLoading(true); + apiClient + .getDashboardReportSnapshot(cycleId, draftKandangId, reportType) + .then((res) => { + if (cancelled) return; + if (res.success && res.data) { + setSnapshot(res.data); + if (!isEndCycle) setDraftDay(res.data.upToDay); + } else { + setSnapshot(null); + if (!isEndCycle) setDraftDay(latestDay); + } + }) + .catch(() => { + if (!cancelled) setSnapshot(null); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [open, cycleId, draftKandangId, latestDay, reportType, isEndCycle]); + + const handleGenerate = useCallback(async () => { + if (draftKandangId == null || !cycleId) return; + setGenerating(true); + setError(null); + setKpiWarning(null); + try { + const kandang = kandangs.find((k) => k.id === draftKandangId); + const upToDay = effectiveUpToDay; + const start = reportDayToDate(cycleStartDate, 0); + const end = reportDayToDate(cycleStartDate, upToDay); + const startStr = start ? formatLocalDate(start) : formatLocalDate(new Date(cycleStartDate)); + const endStr = end ? formatLocalDate(end) : startStr; + + const [mortalityRecords, chickenCounts, manuals] = await Promise.all([ + apiClient.getMortalityRecords(cycleId, draftKandangId).catch(() => []), + apiClient.getChickenCounting(startStr, endStr, draftKandangId).catch(() => []), + apiClient.getManualFeedSackEntries(startStr, endStr).catch(() => manualFeedEntries), + ]); + + const cageUuid = kandang?.cageUuid || null; + let weightByDay: Array<{ + day: number; + avgManual?: number | null; + avgIot?: number | null; + uniformity?: number | null; + }> = []; + let kpiData: unknown = null; + let kpiFailed = false; + + if (cageUuid) { + try { + kpiData = await apiClient.getMultiCoopWeightData('LIVE', cageUuid); + } catch { + kpiData = null; + } + if (!kpiData || typeof kpiData !== 'object') { + kpiFailed = true; + kpiData = null; + } + const kpi = kpiData; + const uniformityByReportDay = new Map(); + try { + const dist = await apiClient.getWeightDistributionRange( + startStr, + endStr, + 'LIVE', + startStr + ); + const rows = Array.isArray(dist?.data) ? dist.data : dist?.data ? [dist.data] : []; + for (const row of rows) { + if (!row?.tanggal) continue; + const reportDay = Math.round( + (parseLocalMidnight(row.tanggal).getTime() - parseLocalMidnight(startStr).getTime()) / + (1000 * 60 * 60 * 24) + ); + uniformityByReportDay.set(reportDay, row.statistik?.uniformity ?? null); + } + } catch { + /* R16: missing uniformity does not fail Generate */ + } + + if (kpi && typeof kpi === 'object') { + for (const [dayStr, dayData] of Object.entries(kpi as Record)) { + const dayIndex = Number(dayStr); + if (!Number.isFinite(dayIndex)) continue; + const reportDay = dayIndex; + weightByDay.push({ + day: dayIndex + 1, + avgManual: dayData?.manual_weight > 0 ? Number(dayData.manual_weight) : null, + avgIot: dayData?.iot_weight > 0 ? Number(dayData.iot_weight) : null, + uniformity: uniformityByReportDay.get(reportDay) ?? null, + }); + } + } + } + + const fcr = isEndCycle ? mapKpiToFcrRows(kpiData, upToDay) : undefined; + const eef = isEndCycle ? mapKpiToEefRows(kpiData, upToDay) : undefined; + if (isEndCycle && kpiFailed) { + setKpiWarning('Gagal memuat data FCR/EEF dari API. Tabel harian tetap disimpan.'); + } + + const payload = buildReportSnapshot({ + cycleId, + cycleStartDate, + kandangId: draftKandangId, + kandangName: kandang?.name || `Kandang ${draftKandangId}`, + area, + upToDay, + initialPopulation: kandang?.docInCount || cycleDocInCount || 0, + generatedAt: new Date().toISOString(), + cageUuid, + mortalityRecords: Array.isArray(mortalityRecords) ? mortalityRecords : [], + chickenCounts: Array.isArray(chickenCounts) ? chickenCounts : [], + feedSackHistory, + manualFeedEntries: Array.isArray(manuals) ? manuals : manualFeedEntries, + feedColumnConfig, + weightByDay, + reportType, + fcr, + eef, + }); + + const localSnapshot: DashboardReportSnapshotRecord = { + id: 0, + cycleId, + kandangId: draftKandangId, + upToDay, + generatedAt: payload.generatedAt, + reportType, + payload, + }; + + try { + const saved = await apiClient.upsertDashboardReportSnapshot({ + cycleId, + kandangId: draftKandangId, + upToDay, + payload, + generatedAt: payload.generatedAt, + reportType, + }); + if (saved.success && saved.data) { + setSnapshot(saved.data); + onSnapshotSaved?.(); + } else { + setSnapshot(localSnapshot); + setError(saved.error || saved.message || 'Gagal menyimpan laporan'); + } + } catch (err) { + setSnapshot(localSnapshot); + throw err; + } + } catch (err) { + setError( + err instanceof Error ? `Gagal menyimpan laporan: ${err.message}` : 'Gagal membuat laporan' + ); + } finally { + setGenerating(false); + } + }, [ + area, + cycleDocInCount, + cycleId, + cycleStartDate, + draftKandangId, + effectiveUpToDay, + feedColumnConfig, + feedSackHistory, + isEndCycle, + kandangs, + manualFeedEntries, + onSnapshotSaved, + reportType, + ]); + + const handleDownloadExcel = useCallback(async () => { + if (!snapshot) return; + await downloadReportExcel(snapshot.payload, cycleStartDate); + }, [cycleStartDate, snapshot]); + + const handleDownloadPdf = useCallback(async () => { + if (!snapshot || !printRef.current) return; + const filename = buildPdfFilename({ + prefix: 'Laporan', + reportName: isEndCycle ? 'Akhir-Siklus' : 'Harian', + area, + kandangLabel: snapshot.payload.kandangName, + periodSlug: isEndCycle ? 'Akhir-Siklus' : `Harian-Hari-${snapshot.upToDay}`, + isoDates: isoDataDates(cycleStartDate, 0, snapshot.upToDay), + }); + await exportCardToPdf(printRef.current, filename); + }, [area, cycleStartDate, isEndCycle, snapshot]); + + if (!open) return null; + + const tables: DashboardReportPayload | null = snapshot?.payload ?? null; + const countingTotal = countingTotals(tables?.counting || []); + const feedInTotal = feedInTotals(tables?.feedIn || []); + const feedUseTotal = feedUseTotals(tables?.feedUse || []); + const canGenerate = draftKandangId != null; + const downloadsEnabled = !!snapshot; + + return ( +
+
+
+
+

+ {overlayTitle} +

+

+ {selectedKandang?.name || 'Pilih kandang'} · {cycleId} · Hari ke-0 s/d ke- + {effectiveUpToDay} +

+
+
+ + + {!isEndCycle && ( + <> + + + + )} + + + + +
+
+ +
+ {draftKandangId == null && ( +
+ Pilih satu kandang sebelum Generate. Laporan tidak digabung untuk semua kandang. +
+ )} + {error && ( +
+ {error} +
+ )} + {kpiWarning && ( +
+ {kpiWarning} +
+ )} + {loading &&
Memuat laporan…
} + +
+ {snapshot && ( + + {isEndCycle ? ( + <> + FCR akhir: {ratioCell(latestValidRatio(tables?.fcr, 'iotFcr'))} + {' · '} + EEF akhir: {ratioCell(latestValidRatio(tables?.eef, 'iotEef'))} + + ) : null} + + )} + + + + + Tanggal + Umur (hari) + Stok awal + Mortalitas + Panen + Berat panen + Stok akhir + IoT + Akurasi % + + + + {(tables?.counting || []).map((row) => ( + + {row.date} + {row.day} + {cell(row.countBefore)} + {cell(row.mortality)} + {cell(row.harvest)} + {cell(row.harvestWeight)} + {cell(row.countAfter)} + {cell(row.iotCount)} + {accuracyCell(row.accuracy)} + + ))} + + {countingTotal && ( + + + {countingTotal.label} + — + {cell(countingTotal.countBefore)} + {cell(countingTotal.mortality)} + {cell(countingTotal.harvest)} + {cell(countingTotal.harvestWeight)} + {cell(countingTotal.countAfter)} + {cell(countingTotal.iotCount)} + {accuracyCell(countingTotal.accuracy)} + + + )} + + + + + + Tanggal + Umur (hari) + Rata-rata manual + Rata-rata IoT + Keseragaman + Selisih + Akurasi % + + + + {(tables?.weight || []).map((row) => ( + + {row.date} + {row.day} + {cell(row.avgManual)} + {cell(row.avgIot)} + {accuracyCell(row.uniformity)} + {cell(row.difference)} + {accuracyCell(row.accuracy)} + + ))} + + + + + + + Tanggal + Manual masuk + IoT masuk + Akurasi % + + + + {(tables?.feedIn || []).map((row) => ( + + {row.date} + {cell(row.manualIn)} + {cell(row.iotIn)} + {accuracyCell(row.accuracy)} + + ))} + + {feedInTotal && ( + + + {feedInTotal.label} + {cell(feedInTotal.manualIn)} + {cell(feedInTotal.iotIn)} + {accuracyCell(feedInTotal.accuracy)} + + + )} + + + + + + Tanggal + Umur (hari) + Manual dituang + IoT dituang + Akurasi % + + + + {(tables?.feedUse || []).map((row) => ( + + {row.date} + {row.day} + {cell(row.manualUse)} + {cell(row.iotUse)} + {accuracyCell(row.accuracy)} + + ))} + + {feedUseTotal && ( + + + {feedUseTotal.label} + — + {cell(feedUseTotal.manualUse)} + {cell(feedUseTotal.iotUse)} + {accuracyCell(feedUseTotal.accuracy)} + + + )} + + + {isEndCycle && ( + <> + + + + Umur + Total Pakan + Bobot IoT + Ayam Hidup + Total Bobot Panen + FCR + + + + {(tables?.fcr || []).map((row) => ( + + Day {row.day} + {qtyCell(row.feedTotal, 0, ' karung')} + {qtyCell(row.iotWeight, 2, row.iotWeight != null ? ' g' : '')} + {qtyCell(row.chickenLife, 0, ' ekor')} + {harvestKgCell(row.harvestWeightTotal)} + {ratioCell(row.iotFcr)} + + ))} + + + + + + + Umur + Persentase Ayam Hidup + BW Rata-rata Panen + Umur Rata-rata Panen + FCR + EEF + + + + {(tables?.eef || []).map((row) => ( + + Day {row.day} + {qtyCell(row.chickenLifePercentage, 3, '%')} + + {qtyCell( + row.iotAverageHarvestBw, + 2, + row.iotAverageHarvestBw != null ? ' g' : '' + )} + + {qtyCell(row.iotAverageDayWeight, 2)} + {ratioCell(row.iotFcr)} + {ratioCell(row.iotEef)} + + ))} + + + + )} +
+
+
+
+ ); +}; + +function parseLocalMidnight(value: string): Date { + return parseLocalDateString(value); +} + +const ReportTable: React.FC<{ + title: string; + children: React.ReactNode; + divided?: boolean; +}> = ({ title, children, divided = false }) => ( +
+

{title}

+
+ {children}
+
+
+); + +const Th: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} +); + +const Td: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} +); + +const TotalRow: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + + {children} + +); + +export default ReportPreviewOverlay; diff --git a/components/dashboard/__tests__/DashboardReportCtas.test.tsx b/components/dashboard/__tests__/DashboardReportCtas.test.tsx new file mode 100644 index 0000000..3349493 --- /dev/null +++ b/components/dashboard/__tests__/DashboardReportCtas.test.tsx @@ -0,0 +1,47 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import DashboardReportCtas from '../DashboardReportCtas.tsx'; + +describe('DashboardReportCtas', () => { + it('disables end-cycle with a hint while the cycle is open', async () => { + const onOpenDaily = vi.fn(); + const onOpenEndCycle = vi.fn(); + const user = userEvent.setup(); + render( + + ); + + const daily = screen.getByRole('button', { name: /^laporan harian$/i }); + const endCycle = screen.getByRole('button', { name: /laporan akhir siklus/i }); + expect(daily).toBeEnabled(); + expect(endCycle).toBeDisabled(); + expect(screen.getByText(/tersedia setelah siklus ditutup/i)).toBeInTheDocument(); + + await user.click(daily); + await user.click(endCycle); + expect(onOpenDaily).toHaveBeenCalledTimes(1); + expect(onOpenEndCycle).not.toHaveBeenCalled(); + }); + + it('enables both buttons when the cycle is closed and uses per-type labels', () => { + render( + + ); + + expect(screen.getByRole('button', { name: /lihat laporan harian/i })).toBeEnabled(); + expect(screen.getByRole('button', { name: /^laporan akhir siklus$/i })).toBeEnabled(); + }); +}); diff --git a/components/dashboard/__tests__/ReportPreviewOverlay.test.tsx b/components/dashboard/__tests__/ReportPreviewOverlay.test.tsx new file mode 100644 index 0000000..bb8b3c7 --- /dev/null +++ b/components/dashboard/__tests__/ReportPreviewOverlay.test.tsx @@ -0,0 +1,345 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { DashboardReportPayload, DashboardReportSnapshotRecord } from '../../../types/dashboardReport.ts'; + +const getDashboardReportSnapshot = vi.fn(); +const upsertDashboardReportSnapshot = vi.fn(); +const getMortalityRecords = vi.fn(); +const getChickenCounting = vi.fn(); +const getManualFeedSackEntries = vi.fn(); +const getMultiCoopWeightData = vi.fn(); +const getWeightDistributionRange = vi.fn(); +const buildReportSnapshot = vi.fn(); +const exportCardToPdf = vi.fn(); +const downloadReportExcel = vi.fn(); + +vi.mock('../../../services/apiService.ts', () => ({ + apiClient: { + getDashboardReportSnapshot: (...args: unknown[]) => getDashboardReportSnapshot(...args), + upsertDashboardReportSnapshot: (...args: unknown[]) => upsertDashboardReportSnapshot(...args), + getMortalityRecords: (...args: unknown[]) => getMortalityRecords(...args), + getChickenCounting: (...args: unknown[]) => getChickenCounting(...args), + getManualFeedSackEntries: (...args: unknown[]) => getManualFeedSackEntries(...args), + getMultiCoopWeightData: (...args: unknown[]) => getMultiCoopWeightData(...args), + getWeightDistributionRange: (...args: unknown[]) => getWeightDistributionRange(...args), + }, +})); + +vi.mock('../../../utils/buildReportSnapshot.ts', () => ({ + buildReportSnapshot: (...args: unknown[]) => buildReportSnapshot(...args), +})); + +vi.mock('../../../utils/pdfExport.ts', () => ({ + exportCardToPdf: (...args: unknown[]) => exportCardToPdf(...args), +})); + +vi.mock('../../../utils/reportExcel.ts', () => ({ + downloadReportExcel: (...args: unknown[]) => downloadReportExcel(...args), +})); + +import ReportPreviewOverlay from '../ReportPreviewOverlay.tsx'; + +const emptyPayload: DashboardReportPayload = { + cycleId: 'C1', + kandangId: 1, + kandangName: 'Kandang Atas', + area: 'Sukawarna', + upToDay: 8, + generatedAt: '2026-05-30T10:00:00.000Z', + counting: [ + { + date: '2026-05-22', + day: 0, + countBefore: 100, + mortality: 0, + harvest: 0, + harvestWeight: null, + countAfter: 100, + iotCount: 98, + accuracy: 98, + }, + ], + weight: [], + feedIn: [], + feedUse: [], +}; + +const savedSnapshot: DashboardReportSnapshotRecord = { + id: 1, + cycleId: 'C1', + kandangId: 1, + upToDay: 8, + generatedAt: '2026-05-30T10:00:00.000Z', + payload: emptyPayload, +}; + +const baseProps = { + open: true, + onClose: vi.fn(), + cycleId: 'C1', + cycleStartDate: '2026-05-21T17:00:00.000Z', + cycleCurrentDay: 20, + cycleDocInCount: 10000, + kandangId: 1 as number | null, + kandangs: [ + { id: 1, name: 'Kandang Atas', docInCount: 10000, cageUuid: 'cage-1' }, + { id: 2, name: 'Kandang Bawah', docInCount: 8000, cageUuid: 'cage-2' }, + ], + area: 'Sukawarna', + feedSackHistory: [], + manualFeedEntries: [], + feedColumnConfig: [], +}; + +describe('ReportPreviewOverlay', () => { + beforeEach(() => { + vi.clearAllMocks(); + getMortalityRecords.mockResolvedValue([]); + getChickenCounting.mockResolvedValue([]); + getManualFeedSackEntries.mockResolvedValue([]); + getMultiCoopWeightData.mockResolvedValue(null); + getWeightDistributionRange.mockResolvedValue({ success: false, data: [] }); + buildReportSnapshot.mockReturnValue(emptyPayload); + upsertDashboardReportSnapshot.mockResolvedValue({ success: true, data: savedSnapshot }); + }); + + it('disables PDF and Excel when no snapshot (AE4)', async () => { + getDashboardReportSnapshot.mockResolvedValue({ success: false, data: null }); + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /pdf/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /excel/i })).toBeDisabled(); + }); + }); + + it('blocks Generate when kandang is null (AE3)', async () => { + getDashboardReportSnapshot.mockResolvedValue({ success: false, data: null }); + render(); + + expect(await screen.findByText(/pilih satu kandang/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^generate$/i })).toBeDisabled(); + expect(buildReportSnapshot).not.toHaveBeenCalled(); + }); + + it('renders an existing snapshot without calling the builder (F2)', async () => { + getDashboardReportSnapshot.mockResolvedValue({ success: true, data: savedSnapshot }); + render(); + + expect((await screen.findAllByText('98.0%')).length).toBeGreaterThan(0); + expect(buildReportSnapshot).not.toHaveBeenCalled(); + }); + + it('shows a Total row on tables that have snapshot data', async () => { + getDashboardReportSnapshot.mockResolvedValue({ success: true, data: savedSnapshot }); + render(); + + expect((await screen.findAllByText('98.0%')).length).toBeGreaterThan(0); + expect(screen.getAllByText('Total').length).toBeGreaterThan(0); + }); + + it('does not upsert when the day changes without Generate (AE2)', async () => { + getDashboardReportSnapshot.mockResolvedValue({ success: true, data: savedSnapshot }); + const user = userEvent.setup(); + render(); + + await screen.findAllByText('98.0%'); + await user.selectOptions(screen.getByLabelText(/hari siklus/i), '20'); + expect(upsertDashboardReportSnapshot).not.toHaveBeenCalled(); + }); + + it('does not bind PDF export when snapshot is null', async () => { + getDashboardReportSnapshot.mockResolvedValue({ success: false, data: null }); + const user = userEvent.setup(); + render(); + await waitFor(() => expect(screen.getByRole('button', { name: /pdf/i })).toBeDisabled()); + await user.click(screen.getByRole('button', { name: /pdf/i })); + expect(exportCardToPdf).not.toHaveBeenCalled(); + }); + + it('does not bind Excel download when snapshot is null', async () => { + getDashboardReportSnapshot.mockResolvedValue({ success: false, data: null }); + const user = userEvent.setup(); + render(); + await waitFor(() => expect(screen.getByRole('button', { name: /excel/i })).toBeDisabled()); + await user.click(screen.getByRole('button', { name: /excel/i })); + expect(downloadReportExcel).not.toHaveBeenCalled(); + }); + + it('downloads Excel from the snapshot payload', async () => { + getDashboardReportSnapshot.mockResolvedValue({ success: true, data: savedSnapshot }); + downloadReportExcel.mockResolvedValue(undefined); + const user = userEvent.setup(); + render(); + + await screen.findAllByText('98.0%'); + await user.click(screen.getByRole('button', { name: /excel/i })); + await waitFor(() => { + expect(downloadReportExcel).toHaveBeenCalledWith(savedSnapshot.payload, baseProps.cycleStartDate); + }); + }); + + it('exports PDF from the snapshot with a Laporan filename', async () => { + getDashboardReportSnapshot.mockResolvedValue({ success: true, data: savedSnapshot }); + exportCardToPdf.mockResolvedValue(undefined); + const user = userEvent.setup(); + render(); + + await screen.findAllByText('98.0%'); + await user.click(screen.getByRole('button', { name: /pdf/i })); + await waitFor(() => { + expect(exportCardToPdf).toHaveBeenCalled(); + const filename = exportCardToPdf.mock.calls[0]?.[1] as string; + expect(filename.startsWith('Laporan_')).toBe(true); + expect(filename.includes('AI-Insight')).toBe(false); + }); + }); + + it('Generate ulang POSTs the new upToDay', async () => { + getDashboardReportSnapshot.mockResolvedValue({ success: true, data: savedSnapshot }); + const user = userEvent.setup(); + render(); + + await screen.findByRole('button', { name: /generate ulang/i }); + await user.selectOptions(screen.getByLabelText(/hari siklus/i), '20'); + await user.click(screen.getByRole('button', { name: /generate ulang/i })); + + await waitFor(() => { + expect(buildReportSnapshot).toHaveBeenCalled(); + expect(upsertDashboardReportSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ upToDay: 20, kandangId: 1, cycleId: 'C1' }) + ); + }); + }); + + it('daily overlay keeps the day picker and does not show FCR/EEF tables', async () => { + getDashboardReportSnapshot.mockResolvedValue({ success: true, data: savedSnapshot }); + render(); + + expect(await screen.findByLabelText(/hari siklus/i)).toBeInTheDocument(); + expect(screen.queryByText(/data pendukung fcr/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/data pendukung eef/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/fcr akhir/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/eef akhir/i)).not.toBeInTheDocument(); + }); + + it('end-cycle overlay has no day picker and renders FCR plus EEF from snapshot', async () => { + const endCycleSnapshot: DashboardReportSnapshotRecord = { + ...savedSnapshot, + reportType: 'end_cycle', + payload: { + ...emptyPayload, + reportType: 'end_cycle', + fcr: [ + { + day: 0, + iotFcr: 1.234, + feedTotal: 10, + iotWeight: 45.5, + chickenLife: 10000, + harvestWeightTotal: 12000, + }, + ], + eef: [ + { + day: 0, + iotEef: 310.456, + chickenLifePercentage: 99.5, + iotAverageHarvestBw: 45.5, + iotAverageDayWeight: 0.2, + iotFcr: 1.234, + }, + ], + }, + }; + getDashboardReportSnapshot.mockResolvedValue({ success: true, data: endCycleSnapshot }); + render( + + ); + + expect(await screen.findByRole('heading', { name: /laporan akhir siklus/i })).toBeInTheDocument(); + expect(screen.queryByLabelText(/hari siklus/i)).not.toBeInTheDocument(); + expect(screen.getByText(/data pendukung fcr/i)).toBeInTheDocument(); + expect(screen.getByText(/data pendukung eef/i)).toBeInTheDocument(); + expect(screen.getByText(/fcr akhir:\s*1,234/i)).toBeInTheDocument(); + expect(screen.getByText(/eef akhir:\s*310,456/i)).toBeInTheDocument(); + expect(getDashboardReportSnapshot).toHaveBeenCalledWith('C1', 1, 'end_cycle'); + }); + + it('still shows generated FCR/EEF when snapshot save fails', async () => { + getDashboardReportSnapshot.mockResolvedValue({ success: false, data: null }); + getMultiCoopWeightData.mockResolvedValue({ + '0': { + food_total: 10, + iot_weight: 45.5, + chicken_life: 10000, + harvest_weight_total: 12000, + iot_fcr: 1.234, + chicken_life_percentage: 99.5, + iot_average_harvest_bw: 45.5, + iot_average_day_weight: 0.2, + iot_eef: 310.456, + }, + }); + buildReportSnapshot.mockImplementation((input: { fcr?: unknown; eef?: unknown }) => ({ + ...emptyPayload, + reportType: 'end_cycle', + fcr: input.fcr || [], + eef: input.eef || [], + })); + upsertDashboardReportSnapshot.mockRejectedValue( + new Error('there is no unique or exclusion constraint matching the ON CONFLICT specification') + ); + const user = userEvent.setup(); + render( + + ); + + await user.click(await screen.findByRole('button', { name: /^generate$/i })); + + expect(await screen.findByText(/data pendukung fcr/i)).toBeInTheDocument(); + expect(screen.getAllByText(/day 0/i).length).toBeGreaterThanOrEqual(2); + expect(screen.getByText(/gagal menyimpan laporan/i)).toBeInTheDocument(); + }); + + it('end-cycle Generate still saves when KPI fetch fails', async () => { + getDashboardReportSnapshot.mockResolvedValue({ success: false, data: null }); + getMultiCoopWeightData.mockRejectedValue(new Error('kpi down')); + buildReportSnapshot.mockReturnValue({ + ...emptyPayload, + reportType: 'end_cycle', + fcr: [], + eef: [], + }); + const user = userEvent.setup(); + render( + + ); + + await user.click(await screen.findByRole('button', { name: /^generate$/i })); + + await waitFor(() => { + expect(upsertDashboardReportSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + reportType: 'end_cycle', + kandangId: 1, + cycleId: 'C1', + }) + ); + }); + expect(screen.getByText(/gagal memuat data fcr\/eef/i)).toBeInTheDocument(); + }); +}); diff --git a/components/shared/PageAiInsight.tsx b/components/shared/PageAiInsight.tsx index 118467c..258c8d3 100644 --- a/components/shared/PageAiInsight.tsx +++ b/components/shared/PageAiInsight.tsx @@ -734,7 +734,7 @@ Pastikan respons Anda valid JSON tanpa teks markdown tambahan.`; const fromDay = timeframe === 'harian' ? (activeDay ?? currentDayVal) - : Math.max(1, (currentDayVal || 1) - 6); + : Math.max(0, (currentDayVal || 0) - 6); const filename = buildPdfFilename({ reportName: topic, area: appCtx.selectedArea || 'Sukawarna', @@ -777,7 +777,7 @@ Pastikan respons Anda valid JSON tanpa teks markdown tambahan.`; cycleData?.startDate, timeframe === 'harian' ? (activeDay ?? cycleData?.currentDay) - : Math.max(1, (cycleData?.currentDay || 1) - 6), + : Math.max(0, (cycleData?.currentDay || 0) - 6), timeframe === 'harian' ? null : cycleData?.currentDay )} /> diff --git a/components/shared/PdfReportHeader.tsx b/components/shared/PdfReportHeader.tsx index 732f8b7..e5a7775 100644 --- a/components/shared/PdfReportHeader.tsx +++ b/components/shared/PdfReportHeader.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { reportDayToDate } from '../../utils/reportDay.ts'; /** * Identity block printed at the top of every exported AI Insight PDF. @@ -58,18 +59,16 @@ export const describeKandangScope = ( /** * Calendar date of a given cycle day, so the PDF carries a real date and not * only "hari ke-48" — which means nothing to whoever reads the file later. - * Day 1 is the cycle's start date. + * Day 0 is the cycle's start date (Hitung Ayam index). */ export const cycleDayToDate = ( startDate: Date | string | null | undefined, day: number | null | undefined ): Date | null => { - if (!startDate || !day || !Number.isFinite(day)) return null; + if (!startDate || day == null || !Number.isFinite(day) || day < 0) return null; const start = startDate instanceof Date ? startDate : new Date(startDate); if (Number.isNaN(start.getTime())) return null; - const result = new Date(start); - result.setDate(result.getDate() + (day - 1)); - return result; + return reportDayToDate(start, day); }; const shortDate = (value: Date | null): string => @@ -89,7 +88,8 @@ export const describeDataDates = ( ): string | null => { const from = cycleDayToDate(startDate, fromDay); if (!from) return null; - const to = toDay && toDay !== fromDay ? cycleDayToDate(startDate, toDay) : null; + const to = + toDay != null && toDay !== fromDay ? cycleDayToDate(startDate, toDay) : null; return to ? `${shortDate(from)} – ${shortDate(to)}` : shortDate(from); }; @@ -123,7 +123,8 @@ export const isoDataDates = ( : null; const from = iso(cycleDayToDate(startDate, fromDay)); if (!from) return null; - const to = toDay && toDay !== fromDay ? iso(cycleDayToDate(startDate, toDay)) : null; + const to = + toDay != null && toDay !== fromDay ? iso(cycleDayToDate(startDate, toDay)) : null; return to ? `${from}_sd_${to}` : from; }; @@ -151,9 +152,11 @@ export const buildPdfFilename = (parts: { periodSlug: string; /** From `isoDataDates`. */ isoDates?: string | null; + /** Default AI Insight prefix. Daily report uses "Laporan". */ + prefix?: string; }): string => { const segments = [ - 'AI-Insight', + parts.prefix || 'AI-Insight', slug(parts.reportName), slug(parts.area), slug(parts.kandangLabel), @@ -194,6 +197,8 @@ interface PdfReportHeaderProps { cycleId?: string | null; /** Calendar range the data covers, when the card knows it. */ dataDateLabel?: string | null; + /** Extra print-only lines just below the identity block (e.g. final FCR/EEF). */ + children?: React.ReactNode; } export const PdfReportHeader: React.FC = ({ @@ -203,6 +208,7 @@ export const PdfReportHeader: React.FC = ({ periodLabel, cycleId, dataDateLabel, + children, }) => (
= ({
Dicetak: {formatDate(new Date())}
+ {children ? ( +
+ {children} +
+ ) : null}
); diff --git a/components/shared/__tests__/PdfReportHeader.test.ts b/components/shared/__tests__/PdfReportHeader.test.ts index 44a4918..4e92920 100644 --- a/components/shared/__tests__/PdfReportHeader.test.ts +++ b/components/shared/__tests__/PdfReportHeader.test.ts @@ -13,14 +13,18 @@ import { const START = '2026-05-21T17:00:00.000Z'; // cycle CYCLE-JBW-2026-05-22 describe('PdfReportHeader helpers', () => { - it('maps cycle day 1 to the start date', () => { - expect(describeDataDates(START, 1)).toBe('22 Mei 2026'); + it('maps cycle day 0 to the start date', () => { + expect(describeDataDates(START, 0)).toBe('22 Mei 2026'); + expect(describeDataDates(START, 0, 0)).toBe('22 Mei 2026'); + }); + it('maps cycle day 1 to the day after start', () => { + expect(describeDataDates(START, 1)).toBe('23 Mei 2026'); }); it('maps day 48 to the right calendar date', () => { - expect(describeDataDates(START, 48)).toBe('8 Jul 2026'); + expect(describeDataDates(START, 48)).toBe('9 Jul 2026'); }); it('renders a range for a week window', () => { - expect(describeDataDates(START, 42, 48)).toBe('2 Jul 2026 – 8 Jul 2026'); + expect(describeDataDates(START, 42, 48)).toBe('3 Jul 2026 – 9 Jul 2026'); }); it('omits the date when the cycle start is unknown', () => { expect(describeDataDates(null, 12)).toBeNull(); @@ -60,7 +64,7 @@ describe('buildPdfFilename', () => { isoDates: isoDataDates(START, 42, 48), }) ).toBe( - 'AI-Insight_Dashboard_Sukawarna_Kandang-Atas_Mingguan-Minggu-6_2026-07-02_sd_2026-07-08.pdf' + 'AI-Insight_Dashboard_Sukawarna_Kandang-Atas_Mingguan-Minggu-6_2026-07-03_sd_2026-07-09.pdf' ); }); @@ -73,7 +77,7 @@ describe('buildPdfFilename', () => { periodSlug: 'Harian', isoDates: isoDataDates(START, 48), }) - ).toBe('AI-Insight_Hitung-Ayam_Sukawarna_Semua-Kandang-Atas-Bawah_Harian_2026-07-08.pdf'); + ).toBe('AI-Insight_Hitung-Ayam_Sukawarna_Semua-Kandang-Atas-Bawah_Harian_2026-07-09.pdf'); }); it('omits the date segment when the cycle start is unknown', () => { @@ -88,6 +92,19 @@ describe('buildPdfFilename', () => { ).toBe('AI-Insight_fcr_Sukawarna_Kandang-Bawah_Akhir-Siklus.pdf'); }); + it('uses a Laporan prefix instead of AI-Insight when asked', () => { + expect( + buildPdfFilename({ + prefix: 'Laporan', + reportName: 'Harian', + area: 'Sukawarna', + kandangLabel: 'Kandang Atas', + periodSlug: 'Harian-Hari-0', + isoDates: isoDataDates(START, 0), + }) + ).toBe('Laporan_Harian_Sukawarna_Kandang-Atas_Harian-Hari-0_2026-05-22.pdf'); + }); + it('two coops on the same day no longer collide', () => { const base = { reportName: 'Hitung-Ayam', diff --git a/package-lock.json b/package-lock.json index af41732..3d9dcfb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "v19-cp-dashboard:-solusi-ai-peternakan-ayam", "version": "0.0.0", "dependencies": { + "exceljs": "^4.4.0", "html2canvas": "^1.4.1", "jspdf": "^4.2.1", "lucide-react": "^0.468.0", @@ -1040,19 +1041,6 @@ "node": ">= 4" } }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/@eslint/js": { "version": "8.57.1", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", @@ -1081,6 +1069,47 @@ } } }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -1097,19 +1126,6 @@ "node": ">=10.10.0" } }, - "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -2459,6 +2475,81 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2624,6 +2715,12 @@ "node": ">=12" } }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -2652,7 +2749,6 @@ }, "node_modules/balanced-match": { "version": "1.0.2", - "dev": true, "license": "MIT" }, "node_modules/base64-arraybuffer": { @@ -2664,6 +2760,26 @@ "node": ">= 0.6.0" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.11.13", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz", @@ -2687,11 +2803,49 @@ "require-from-string": "^2.0.2" } }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.18", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -2732,6 +2886,56 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -2843,6 +3047,18 @@ "node": ">=18" } }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2966,11 +3182,25 @@ "node": ">=20" } }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "license": "MIT" }, "node_modules/convert-source-map": { @@ -2990,6 +3220,37 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "dev": true, @@ -3207,6 +3468,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "dev": true, @@ -3333,6 +3600,51 @@ "node": ">= 0.4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.403", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.403.tgz", @@ -3340,6 +3652,15 @@ "dev": true, "license": "ISC" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/entities": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", @@ -3778,19 +4099,6 @@ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, - "node_modules/eslint-plugin-react/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/eslint-scope": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", @@ -3854,19 +4162,6 @@ "node": ">= 4" } }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/eslint/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -3958,6 +4253,38 @@ "version": "5.0.1", "license": "MIT" }, + "node_modules/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "license": "MIT", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/exceljs/node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -3968,6 +4295,19 @@ "node": ">=12.0.0" } }, + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "license": "MIT", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4084,41 +4424,6 @@ "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/flat-cache/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/flat-cache/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/flat-cache/node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -4159,11 +4464,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, "license": "ISC" }, "node_modules/fsevents": { @@ -4181,6 +4491,22 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -4310,6 +4636,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -4369,6 +4716,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -4529,6 +4882,26 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -4539,6 +4912,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/immer": { "version": "10.2.0", "license": "MIT", @@ -4589,7 +4968,6 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -4600,7 +4978,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/internal-slot": { @@ -5217,6 +5594,60 @@ "node": ">=4.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5227,6 +5658,54 @@ "json-buffer": "3.0.1" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5241,6 +5720,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lint-staged": { "version": "16.4.0", "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz", @@ -5265,6 +5753,12 @@ "url": "https://opencollective.com/lint-staged" } }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, "node_modules/listr2": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", @@ -5342,6 +5836,73 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "license": "MIT" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "license": "MIT" + }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5349,6 +5910,18 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, "node_modules/log-update": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", @@ -5548,6 +6121,39 @@ "node": ">=4" } }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -5618,6 +6224,15 @@ "node": ">=18" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5741,7 +6356,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -5887,7 +6501,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6067,6 +6680,12 @@ "license": "MIT", "peer": true }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -6178,6 +6797,50 @@ "node": ">=0.10.0" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/recharts": { "version": "3.4.1", "license": "MIT", @@ -6375,6 +7038,19 @@ "node": ">= 0.8.15" } }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, "node_modules/rollup": { "version": "4.62.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", @@ -6465,6 +7141,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -6574,6 +7270,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "dev": true, @@ -6781,6 +7483,15 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -6988,6 +7699,22 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/text-segmentation": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", @@ -7070,6 +7797,15 @@ "dev": true, "license": "MIT" }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -7106,6 +7842,15 @@ "node": ">=20" } }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -7269,6 +8014,60 @@ "dev": true, "license": "MIT" }, + "node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, + "node_modules/unzipper/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/unzipper/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", @@ -7317,6 +8116,12 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utrie": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", @@ -7326,6 +8131,16 @@ "base64-arraybuffer": "^1.0.2" } }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/victory-vendor": { "version": "37.3.6", "license": "MIT AND ISC", @@ -7693,7 +8508,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/xml-name-validator": { @@ -7710,7 +8524,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, "license": "MIT" }, "node_modules/yallist": { @@ -7749,6 +8562,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/package.json b/package.json index 3f1cb59..3d03772 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ ] }, "dependencies": { + "exceljs": "^4.4.0", "html2canvas": "^1.4.1", "jspdf": "^4.2.1", "lucide-react": "^0.468.0", diff --git a/services/apiService.ts b/services/apiService.ts index 9245649..6989244 100644 --- a/services/apiService.ts +++ b/services/apiService.ts @@ -14,6 +14,10 @@ import { SensorWeightSummaryResponse, ScalePredictionRangeResponse, } from '../types.ts'; +import type { + DashboardReportPayload, + DashboardReportSnapshotResponse, +} from '../types/dashboardReport.ts'; // --- Configuration --- const API_BASE_URL = 'https://dashboard.cpsp.id'; @@ -1480,6 +1484,44 @@ export const apiClient = { return await response.json(); }, + // --- Dashboard daily report snapshots --- + + getDashboardReportSnapshot: async ( + cycleId: string, + kandangId: number, + reportType: DashboardReportPayload['reportType'] = 'daily' + ): Promise => { + 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 => { + 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 --- /** diff --git a/types/dashboardReport.ts b/types/dashboardReport.ts new file mode 100644 index 0000000..2be1664 --- /dev/null +++ b/types/dashboardReport.ts @@ -0,0 +1,94 @@ +/** Accuracy % or N/A (null) for IoT vs manual report cells. */ +export type ReportAccuracy = number | null; + +export interface DashboardReportCountingRow { + date: string; + day: number; + countBefore: number | null; + mortality: number | null; + harvest: number | null; + harvestWeight: number | null; + countAfter: number | null; + iotCount: number | null; + accuracy: ReportAccuracy; +} + +export interface DashboardReportWeightRow { + date: string; + day: number; + avgManual: number | null; + avgIot: number | null; + uniformity: number | null; + difference: number | null; + accuracy: ReportAccuracy; +} + +export interface DashboardReportFeedInRow { + date: string; + manualIn: number | null; + iotIn: number | null; + accuracy: ReportAccuracy; +} + +export interface DashboardReportFeedUseRow { + date: string; + day: number; + manualUse: number | null; + iotUse: number | null; + accuracy: ReportAccuracy; +} + +export type DashboardReportType = 'daily' | 'end_cycle'; + +export interface DashboardReportFcrRow { + day: number; + iotFcr: number | null; + feedTotal: number | null; + iotWeight: number | null; + chickenLife: number | null; + harvestWeightTotal: number | null; +} + +export interface DashboardReportEefRow { + day: number; + iotEef: number | null; + chickenLifePercentage: number | null; + iotAverageHarvestBw: number | null; + iotAverageDayWeight: number | null; + iotFcr: number | null; +} + +export interface DashboardReportPayload { + cycleId: string; + kandangId: number; + kandangName: string; + area: string; + upToDay: number; + generatedAt: string; + reportType?: DashboardReportType; + counting: DashboardReportCountingRow[]; + weight: DashboardReportWeightRow[]; + feedIn: DashboardReportFeedInRow[]; + feedUse: DashboardReportFeedUseRow[]; + fcr?: DashboardReportFcrRow[]; + eef?: DashboardReportEefRow[]; +} + +export interface DashboardReportSnapshotRecord { + id: number; + cycleId: string; + kandangId: number; + upToDay: number; + generatedAt: string; + reportType?: DashboardReportType; + payload: DashboardReportPayload; + createdAt?: string; + updatedAt?: string; +} + +export interface DashboardReportSnapshotResponse { + success: boolean; + data: DashboardReportSnapshotRecord | null; + message?: string; + error?: string; +} diff --git a/utils/__tests__/buildReportSnapshot.test.ts b/utils/__tests__/buildReportSnapshot.test.ts new file mode 100644 index 0000000..1313bb7 --- /dev/null +++ b/utils/__tests__/buildReportSnapshot.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect } from 'vitest'; +import type { FeedSackColumnConfig } from '../../types/counting.ts'; +import { buildReportSnapshot } from '../buildReportSnapshot.ts'; + +const START = '2026-05-21T17:00:00.000Z'; // 22 Mei 2026 00:00 WIB + +const COLUMNS: FeedSackColumnConfig[] = [ + { + id: 'in-1', + label: 'Karung Masuk K1', + cameraName: 'kandang_1_karung_masuk', + color: '#22c55e', + type: 'inbound', + kandangId: 1, + }, + { + id: 'use-1', + label: 'Dituang K1', + cameraName: 'kandang_1_karung_pakan', + color: '#eab308', + type: 'usage', + kandangId: 1, + }, + { + id: 'use-2', + label: 'Dituang K2', + cameraName: 'kandang_2_karung_pakan', + color: '#a855f7', + type: 'usage', + kandangId: 2, + }, + { + id: 'out-1', + label: 'Keluar', + cameraName: 'kandang_1_karung_keluar', + color: '#ef4444', + type: 'outbound', + kandangId: 1, + }, +]; + +const baseInput = { + cycleId: 'CYCLE-TEST', + cycleStartDate: START, + kandangId: 1, + kandangName: 'Kandang 1', + area: 'Sukawarna', + upToDay: 2, + initialPopulation: 100, + generatedAt: '2026-05-24T10:00:00.000Z', + mortalityRecords: [], + chickenCountByDay: {} as Record, + feedSackHistory: [] as Array<{ cameraName: string; count: number; timestamp: Date | string }>, + manualFeedEntries: [] as Array<{ cameraName: string; count: number; date: string }>, + feedColumnConfig: COLUMNS, + cageUuid: 'cage-1', + weightByDay: [] as Array<{ + day: number; + avgManual?: number | null; + avgIot?: number | null; + uniformity?: number | null; + }>, +}; + +describe('buildReportSnapshot', () => { + it('decreases counting after-count by mortality+harvest only for days 0–2', () => { + const snapshot = buildReportSnapshot({ + ...baseInput, + mortalityRecords: [ + { day: 1, mortalityCount: 3, afkir: 2, panen: 1, beratPanen: 4.5 }, + { day: 2, mortalityCount: 1, afkir: 0, panen: 0 }, + ], + }); + + expect(snapshot.counting).toHaveLength(3); + expect(snapshot.counting[0]).toMatchObject({ + day: 0, + countBefore: 100, + mortality: 0, + harvest: 0, + countAfter: 100, + }); + expect(snapshot.counting[1]).toMatchObject({ + day: 1, + countBefore: 100, + mortality: 5, + harvest: 1, + harvestWeight: 4.5, + countAfter: 94, + }); + expect(snapshot.counting[2]).toMatchObject({ + day: 2, + countBefore: 94, + mortality: 1, + harvest: 0, + countAfter: 93, + }); + }); + + it('combines left and right usage cameras for IoT and manual', () => { + const snapshot = buildReportSnapshot({ + ...baseInput, + kandangName: 'Kandang Atas', + upToDay: 0, + feedColumnConfig: [ + { + id: 'in-1', + label: 'Karung Masuk', + cameraName: 'kandang_1_karung_masuk', + color: '#22c55e', + type: 'inbound', + }, + { + id: 'use-atas', + label: 'Karung Dituang Kandang Atas', + cameraName: 'karung_tuang_feeder_kiri_atas', + color: '#f97316', + type: 'usage', + }, + { + id: 'use-bawah', + label: 'Karung Dituang Kandang Bawah', + cameraName: 'karung_tuang_feeder_kanan_bawah', + color: '#a855f7', + type: 'usage', + }, + ], + feedSackHistory: [ + { + cameraName: 'karung_tuang_feeder_kiri_atas', + count: 15, + timestamp: new Date(2026, 4, 22, 8, 0, 0), + }, + { + cameraName: 'karung_tuang_feeder_kanan_bawah', + count: 15, + timestamp: new Date(2026, 4, 22, 8, 0, 0), + }, + ], + manualFeedEntries: [ + { cameraName: 'karung_tuang_feeder_kiri_atas', count: 15, date: '2026-05-22' }, + { cameraName: 'karung_tuang_feeder_kanan_bawah', count: 15, date: '2026-05-22' }, + ], + }); + + expect(snapshot.feedUse).toHaveLength(1); + expect(snapshot.feedUse[0]?.iotUse).toBe(30); + expect(snapshot.feedUse[0]?.manualUse).toBe(30); + expect(snapshot.feedUse[0]?.accuracy).toBe(100); + }); + + it('keeps feed-in empty when inbound mapping fails, but still combines usage cameras', () => { + const snapshot = buildReportSnapshot({ + ...baseInput, + kandangId: 99, + upToDay: 0, + feedSackHistory: [ + { + cameraName: 'kandang_1_karung_pakan', + count: 10, + timestamp: new Date(2026, 4, 22, 8, 0, 0), + }, + { + cameraName: 'kandang_2_karung_pakan', + count: 50, + timestamp: new Date(2026, 4, 22, 8, 0, 0), + }, + ], + }); + + expect(snapshot.feedUse[0]?.iotUse).toBe(60); + expect(snapshot.feedUse[0]?.manualUse).toBe(0); + expect(snapshot.feedIn[0]?.iotIn).toBeNull(); + expect(snapshot.feedIn[0]?.manualIn).toBeNull(); + }); + + it('returns empty weight rows without cageUuid but still builds a snapshot', () => { + const snapshot = buildReportSnapshot({ + ...baseInput, + cageUuid: null, + weightByDay: [{ day: 1, avgManual: 1000, avgIot: 900, uniformity: 80 }], + }); + + expect(snapshot.cycleId).toBe('CYCLE-TEST'); + expect(snapshot.weight.every((row) => row.avgManual == null && row.avgIot == null)).toBe( + true + ); + }); + + it('drops rows after the selected day', () => { + const snapshot = buildReportSnapshot({ + ...baseInput, + upToDay: 8, + mortalityRecords: [ + { day: 8, mortalityCount: 1 }, + { day: 9, mortalityCount: 99 }, + ], + weightByDay: [ + { day: 9, avgManual: 2000, avgIot: 1900 }, // 1-based day 9 → report day 8 + { day: 10, avgManual: 3000, avgIot: 2900 }, // report day 9 — dropped + ], + }); + + expect(snapshot.counting.every((row) => row.day <= 8)).toBe(true); + expect(snapshot.counting.some((row) => row.day === 9)).toBe(false); + expect(snapshot.weight.some((row) => row.day === 8 && row.avgManual === 2000)).toBe(true); + expect(snapshot.weight.some((row) => row.avgManual === 3000)).toBe(false); + }); + + it('maps 1-based feed day 1 on cycle start to Hari ke-0', () => { + const snapshot = buildReportSnapshot({ + ...baseInput, + upToDay: 0, + feedSackHistory: [ + { + cameraName: 'kandang_1_karung_masuk', + count: 7, + timestamp: new Date(2026, 4, 22, 6, 0, 0), + }, + ], + manualFeedEntries: [{ cameraName: 'kandang_1_karung_masuk', count: 7, date: '2026-05-22' }], + }); + + expect(snapshot.feedIn).toHaveLength(1); + expect(snapshot.feedIn[0] && 'day' in snapshot.feedIn[0]).toBe(false); + expect(snapshot.feedIn[0]?.date).toBe('2026-05-22'); + expect(snapshot.feedIn[0]?.iotIn).toBe(7); + expect(snapshot.feedIn[0]?.manualIn).toBe(7); + }); +}); diff --git a/utils/__tests__/pdfExport.test.ts b/utils/__tests__/pdfExport.test.ts new file mode 100644 index 0000000..873fb1e --- /dev/null +++ b/utils/__tests__/pdfExport.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { choosePdfSliceHeight, pdfBlockBreakOffsets } from '../pdfExport.ts'; + +const mocks = vi.hoisted(() => ({ + addImage: vi.fn(), + save: vi.fn(), + jsPDF: vi.fn(), + html2canvas: vi.fn(), +})); + +vi.mock('jspdf', () => ({ + jsPDF: mocks.jsPDF, +})); + +vi.mock('html2canvas', () => ({ + default: mocks.html2canvas, +})); + +describe('exportCardToPdf compression', () => { + beforeEach(() => { + mocks.addImage.mockReset(); + mocks.save.mockReset(); + mocks.jsPDF.mockReset(); + mocks.html2canvas.mockReset(); + mocks.jsPDF.mockImplementation(function JsPDF() { + return { + internal: { pageSize: { getWidth: () => 210, getHeight: () => 297 } }, + addPage: vi.fn(), + addImage: mocks.addImage, + save: mocks.save, + }; + }); + mocks.html2canvas.mockImplementation(async () => { + const canvas = document.createElement('canvas'); + canvas.width = 800; + canvas.height = 400; + return canvas; + }); + }); + + it('embeds JPEG pages in a compressed jsPDF document', async () => { + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + fillStyle: '', + fillRect: vi.fn(), + drawImage: vi.fn(), + } as unknown as CanvasRenderingContext2D); + const toDataURL = vi + .spyOn(HTMLCanvasElement.prototype, 'toDataURL') + .mockReturnValue('data:image/jpeg;base64,xx'); + + const { exportCardToPdf } = await import('../pdfExport.ts'); + await exportCardToPdf(document.createElement('div'), 'Laporan.pdf'); + + expect(mocks.jsPDF).toHaveBeenCalledWith(expect.objectContaining({ compress: true })); + expect(toDataURL).toHaveBeenCalledWith('image/jpeg', expect.any(Number)); + const quality = toDataURL.mock.calls.find((call) => call[0] === 'image/jpeg')?.[1] as number; + expect(quality).toBeGreaterThan(0); + expect(quality).toBeLessThan(1); + expect(mocks.addImage).toHaveBeenCalledWith( + expect.any(String), + 'JPEG', + expect.any(Number), + expect.any(Number), + expect.any(Number), + expect.any(Number) + ); + expect(mocks.save).toHaveBeenCalledWith('Laporan.pdf'); + + toDataURL.mockRestore(); + vi.restoreAllMocks(); + }); +}); + +describe('pdf page breaks', () => { + it('does not defer an oversized table past the header', () => { + const usableHeight = 1200; + const breaks = pdfBlockBreakOffsets([{ top: 80, bottom: 5000 }], usableHeight); + expect(breaks).not.toContain(80); + expect(breaks).toContain(5000); + expect(choosePdfSliceHeight(0, 5000, usableHeight, breaks)).toBe(usableHeight); + }); + + it('keeps a small chart whole by breaking before it when it no longer fits', () => { + const usableHeight = 400; + const breaks = pdfBlockBreakOffsets([{ top: 350, bottom: 500 }], usableHeight); + expect(breaks).toEqual(expect.arrayContaining([350, 500])); + expect(choosePdfSliceHeight(0, 900, usableHeight, breaks)).toBe(350); + }); +}); + diff --git a/utils/__tests__/reportAccuracy.test.ts b/utils/__tests__/reportAccuracy.test.ts new file mode 100644 index 0000000..a540d55 --- /dev/null +++ b/utils/__tests__/reportAccuracy.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { + countingAccuracy, + sackAccuracy, + weightAccuracy, + combinedMortality, +} from '../reportAccuracy.ts'; + +describe('countingAccuracy', () => { + it('uses smaller/larger × 100 for IoT vs after-count', () => { + expect(countingAccuracy(95, 100)).toBe(95); + }); + + it('returns no accuracy when after-count is 0', () => { + expect(countingAccuracy(95, 0)).toBeNull(); + }); + + it('returns no accuracy when IoT count is missing', () => { + expect(countingAccuracy(null, 100)).toBeNull(); + expect(countingAccuracy(undefined, 100)).toBeNull(); + }); +}); + +describe('sackAccuracy', () => { + it('is 100% when both sides are 0', () => { + expect(sackAccuracy(0, 0)).toBe(100); + }); + + it('is N/A when only IoT is set', () => { + expect(sackAccuracy(null, 10)).toBeNull(); + expect(sackAccuracy(0, 10)).toBeNull(); + }); + + it('is 100% when both sides are 10', () => { + expect(sackAccuracy(10, 10)).toBe(100); + }); +}); + +describe('weightAccuracy', () => { + it('uses smaller/larger × 100 for average IoT vs average manual', () => { + expect(weightAccuracy(1000, 900)).toBe(90); + }); +}); + +describe('combinedMortality', () => { + it('sums mati + afkir and ignores panen', () => { + expect(combinedMortality(3, 2, 10)).toBe(5); + }); +}); diff --git a/utils/__tests__/reportCycle.test.ts b/utils/__tests__/reportCycle.test.ts new file mode 100644 index 0000000..c2e0200 --- /dev/null +++ b/utils/__tests__/reportCycle.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { closedCycleUpToDay, isCycleClosed } from '../reportCycle.ts'; + +describe('isCycleClosed', () => { + it('is false when endDate is missing', () => { + expect(isCycleClosed(undefined)).toBe(false); + expect(isCycleClosed(null)).toBe(false); + expect(isCycleClosed('')).toBe(false); + }); + + it('is true when endDate is set', () => { + expect(isCycleClosed('2026-06-25')).toBe(true); + expect(isCycleClosed(new Date('2026-06-25T00:00:00'))).toBe(true); + }); +}); + +describe('closedCycleUpToDay', () => { + it('returns the inclusive report day of endDate', () => { + expect(closedCycleUpToDay('2026-05-22', '2026-05-30')).toBe(8); + }); + + it('returns 0 when endDate is missing or before start', () => { + expect(closedCycleUpToDay('2026-05-22', null)).toBe(0); + expect(closedCycleUpToDay('2026-05-22', '2026-05-20')).toBe(0); + }); +}); diff --git a/utils/__tests__/reportDay.test.ts b/utils/__tests__/reportDay.test.ts new file mode 100644 index 0000000..32d956a --- /dev/null +++ b/utils/__tests__/reportDay.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { + reportDayToDate, + dateToReportDay, + feedOrWeightDayToReportDay, + formatLocalDate, +} from '../reportDay.ts'; + +/** Same instant PdfReportHeader tests use: 22 Mei 2026 00:00 WIB. */ +const START = '2026-05-21T17:00:00.000Z'; + +describe('reportDayToDate', () => { + it('maps day 0 to the cycle start date', () => { + const date = reportDayToDate(START, 0); + expect(formatLocalDate(date)).toBe('2026-05-22'); + }); + + it('maps day 1 to the day after cycle start', () => { + const date = reportDayToDate(START, 1); + expect(formatLocalDate(date)).toBe('2026-05-23'); + }); +}); + +describe('dateToReportDay', () => { + it('maps cycle start calendar date to report day 0', () => { + expect(dateToReportDay(START, reportDayToDate(START, 0))).toBe(0); + }); +}); + +describe('feedOrWeightDayToReportDay', () => { + it('maps 1-based feed day 1 on cycle start to report day 0', () => { + expect(feedOrWeightDayToReportDay(1)).toBe(0); + const start = reportDayToDate(START, 0); + expect(dateToReportDay(START, start)).toBe(0); + }); +}); diff --git a/utils/__tests__/reportExcel.test.ts b/utils/__tests__/reportExcel.test.ts new file mode 100644 index 0000000..9ae7879 --- /dev/null +++ b/utils/__tests__/reportExcel.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect } from 'vitest'; +import type { DashboardReportPayload } from '../../types/dashboardReport.ts'; +import { buildReportWorkbook } from '../reportExcel.ts'; + +const snapshot: DashboardReportPayload = { + cycleId: 'CYCLE-JBW', + kandangId: 1, + kandangName: 'Kandang Atas', + area: 'Sukawarna', + upToDay: 2, + generatedAt: '2026-05-24T10:00:00.000Z', + counting: [ + { + date: '2026-05-22', + day: 0, + countBefore: 100, + mortality: 0, + harvest: 0, + harvestWeight: null, + countAfter: 100, + iotCount: 95, + accuracy: 95, + }, + ], + weight: [ + { + date: '2026-05-23', + day: 1, + avgManual: 1000, + avgIot: 900, + uniformity: 80, + difference: 100, + accuracy: 90, + }, + ], + feedIn: [{ date: '2026-05-22', manualIn: 10, iotIn: 10, accuracy: 100 }], + feedUse: [{ date: '2026-05-22', day: 0, manualUse: 4, iotUse: 4, accuracy: 100 }], +}; + +const emptySnapshot: DashboardReportPayload = { + ...snapshot, + counting: [], + weight: [], + feedIn: [], + feedUse: [], +}; + +describe('buildReportWorkbook', () => { + it('creates four sheets with the specified names', async () => { + const workbook = await buildReportWorkbook(snapshot); + expect(workbook.worksheets.map((sheet) => sheet.name)).toEqual([ + 'Counting', + 'Weight', + 'Feed In', + 'Feed Use', + ]); + }); + + it('includes chicken age and mortality on Counting, and no chicken-age on Feed In', async () => { + const workbook = await buildReportWorkbook(snapshot); + const countingHeaders = workbook.getWorksheet('Counting')?.getRow(2).values ?? []; + const feedInHeaders = workbook.getWorksheet('Feed In')?.getRow(2).values ?? []; + + expect(countingHeaders).toEqual( + expect.arrayContaining(['Umur (hari)', 'Mortalitas']) + ); + expect(feedInHeaders).toEqual(expect.arrayContaining(['Tanggal', 'Manual masuk', 'IoT masuk'])); + expect(feedInHeaders).not.toEqual(expect.arrayContaining(['Umur (hari)'])); + }); + + it('writes header metadata matching upToDay and generatedAt', async () => { + const workbook = await buildReportWorkbook(snapshot); + const headerValues = workbook.getWorksheet('Counting')?.getRow(1).values ?? []; + const header = (Array.isArray(headerValues) ? headerValues : []) + .map((value) => (value == null ? '' : String(value))) + .filter(Boolean) + .join(' '); + + expect(header).toContain('Kandang Atas'); + expect(header).toContain('CYCLE-JBW'); + expect(header).toContain('Hari ke-0 s/d ke-2'); + expect(header).toContain('2026-05-24T10:00:00.000Z'); + }); + + it('still produces a workbook when snapshot tables are empty (AE5)', async () => { + const workbook = await buildReportWorkbook(emptySnapshot); + expect(workbook.worksheets).toHaveLength(4); + expect(workbook.getWorksheet('Counting')?.rowCount).toBeGreaterThanOrEqual(2); + }); + + it('appends a Total row on each sheet', async () => { + const workbook = await buildReportWorkbook(snapshot); + const lastValues = (sheetName: string) => { + const sheet = workbook.getWorksheet(sheetName); + const values = sheet?.getRow(sheet.rowCount).values; + return (Array.isArray(values) ? values : []).filter((value) => value != null); + }; + + expect(lastValues('Counting')[0]).toBe('Total'); + expect(lastValues('Weight')[0]).toBe('2026-05-23'); + expect(lastValues('Feed In')).toEqual(['Total', 10, 10, '100.0%']); + expect(lastValues('Feed Use')).toEqual(['Total', '—', 4, 4, '100.0%']); + }); + + it('adds FCR and EEF sheets for an end-cycle snapshot', async () => { + const workbook = await buildReportWorkbook({ + ...snapshot, + reportType: 'end_cycle', + fcr: [ + { + day: 0, + iotFcr: 1.234, + feedTotal: 10, + iotWeight: 45.5, + chickenLife: 10000, + harvestWeightTotal: 12000, + }, + ], + eef: [ + { + day: 0, + iotEef: 310.456, + chickenLifePercentage: 99.5, + iotAverageHarvestBw: 45.5, + iotAverageDayWeight: 0.2, + iotFcr: 1.234, + }, + ], + }); + + expect(workbook.worksheets.map((sheet) => sheet.name)).toEqual([ + 'Counting', + 'Weight', + 'Feed In', + 'Feed Use', + 'FCR', + 'EEF', + ]); + + const headerCells = (sheetName: string) => { + const values = workbook.getWorksheet(sheetName)?.getRow(2).values ?? []; + return (Array.isArray(values) ? values : []).filter((value) => value != null); + }; + + expect(headerCells('FCR')).toEqual([ + 'Umur', + 'Total Pakan', + 'Bobot IoT', + 'Ayam Hidup', + 'Total Bobot Panen', + 'FCR', + ]); + expect(headerCells('EEF')).toEqual([ + 'Umur', + 'Persentase Ayam Hidup', + 'BW Rata-rata Panen', + 'Umur Rata-rata Panen', + 'FCR', + 'EEF', + ]); + }); +}); diff --git a/utils/__tests__/reportFcrEef.test.ts b/utils/__tests__/reportFcrEef.test.ts new file mode 100644 index 0000000..e8b251f --- /dev/null +++ b/utils/__tests__/reportFcrEef.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from 'vitest'; +import { latestValidRatio, mapKpiToEefRows, mapKpiToFcrRows } from '../reportFcrEef.ts'; + +const kpi = { + '0': { + food_total: 10, + iot_weight: 45.5, + chicken_life: 10000, + harvest_weight_total: 12000, + iot_fcr: 1.234, + chicken_life_percentage: 99.5, + iot_average_harvest_bw: 45.5, + iot_average_day_weight: 0.2, + iot_eef: 310.456, + }, + '2': { + food_total: 20, + iot_weight: 80, + chicken_life: 9900, + harvest_weight_total: 50000, + iot_fcr: 0, + chicken_life_percentage: 98.1, + iot_average_harvest_bw: 80, + iot_average_day_weight: 2, + iot_eef: 0, + }, + '8': { + food_total: 40, + iot_weight: 400, + chicken_life: 9700, + harvest_weight_total: 200000, + iot_fcr: 1.7, + chicken_life_percentage: 96.2, + iot_average_harvest_bw: 400, + iot_average_day_weight: 8, + iot_eef: 280.9, + }, +}; + +describe('mapKpiToFcrRows', () => { + it('maps page FCR fields and keeps only days 0..upToDay', () => { + expect(mapKpiToFcrRows(kpi, 2)).toEqual([ + { + day: 0, + feedTotal: 10, + iotWeight: 45.5, + chickenLife: 10000, + harvestWeightTotal: 12000, + iotFcr: 1.234, + }, + { + day: 2, + feedTotal: 20, + iotWeight: 80, + chickenLife: 9900, + harvestWeightTotal: 50000, + iotFcr: null, + }, + ]); + }); + + it('returns an empty array for invalid KPI without throwing', () => { + expect(mapKpiToFcrRows(null, 8)).toEqual([]); + expect(mapKpiToFcrRows(undefined, 8)).toEqual([]); + expect(mapKpiToFcrRows('nope', 8)).toEqual([]); + }); +}); + +describe('mapKpiToEefRows', () => { + it('maps page EEF fields and keeps only days 0..upToDay', () => { + expect(mapKpiToEefRows(kpi, 2)).toEqual([ + { + day: 0, + chickenLifePercentage: 99.5, + iotAverageHarvestBw: 45.5, + iotAverageDayWeight: 0.2, + iotFcr: 1.234, + iotEef: 310.456, + }, + { + day: 2, + chickenLifePercentage: 98.1, + iotAverageHarvestBw: 80, + iotAverageDayWeight: 2, + iotFcr: null, + iotEef: null, + }, + ]); + }); + + it('returns an empty array for invalid KPI without throwing', () => { + expect(mapKpiToEefRows(null, 8)).toEqual([]); + expect(mapKpiToEefRows(undefined, 8)).toEqual([]); + }); +}); + +describe('latestValidRatio', () => { + it('returns the last non-null ratio, skipping empty later days', () => { + expect( + latestValidRatio( + [ + { day: 0, iotFcr: 1.234 }, + { day: 2, iotFcr: null }, + { day: 8, iotFcr: 1.7 }, + ], + 'iotFcr' + ) + ).toBe(1.7); + expect( + latestValidRatio( + [ + { day: 0, iotEef: 310.456 }, + { day: 2, iotEef: null }, + ], + 'iotEef' + ) + ).toBe(310.456); + }); + + it('returns null when every value is missing', () => { + expect(latestValidRatio([{ iotFcr: null }, { iotFcr: 0 }], 'iotFcr')).toBeNull(); + expect(latestValidRatio([], 'iotFcr')).toBeNull(); + expect(latestValidRatio(undefined, 'iotFcr')).toBeNull(); + }); +}); diff --git a/utils/__tests__/reportOverlayLabels.test.ts b/utils/__tests__/reportOverlayLabels.test.ts new file mode 100644 index 0000000..928eb52 --- /dev/null +++ b/utils/__tests__/reportOverlayLabels.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { generateButtonLabel, reportCtaLabel, reportOverlayTitle } from '../reportOverlayLabels.ts'; + +describe('report overlay labels', () => { + it('uses Laporan when no snapshot and Lihat Laporan when one exists', () => { + expect(reportCtaLabel(false)).toBe('Laporan'); + expect(reportCtaLabel(true)).toBe('Lihat Laporan'); + }); + + it('uses Generate then Generate ulang inside the preview', () => { + expect(generateButtonLabel(false)).toBe('Generate'); + expect(generateButtonLabel(true)).toBe('Generate ulang'); + }); + + it('titles daily vs end-cycle overlays', () => { + expect(reportOverlayTitle('daily')).toBe('Laporan Harian IoT vs Manual'); + expect(reportOverlayTitle('end_cycle')).toBe('Laporan Akhir Siklus'); + }); +}); diff --git a/utils/__tests__/reportTotals.test.ts b/utils/__tests__/reportTotals.test.ts new file mode 100644 index 0000000..c022a9e --- /dev/null +++ b/utils/__tests__/reportTotals.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import type { + DashboardReportCountingRow, + DashboardReportFeedInRow, + DashboardReportFeedUseRow, +} from '../../types/dashboardReport.ts'; +import { countingTotals, feedInTotals, feedUseTotals } from '../reportTotals.ts'; + +describe('countingTotals', () => { + it('sums mortality, harvest, and harvest weight; omits stok akhir and IoT', () => { + const rows: DashboardReportCountingRow[] = [ + { + date: '2026-05-22', + day: 0, + countBefore: 100, + mortality: 0, + harvest: 0, + harvestWeight: null, + countAfter: 100, + iotCount: 98, + accuracy: 98, + }, + { + date: '2026-05-23', + day: 1, + countBefore: 100, + mortality: 5, + harvest: 1, + harvestWeight: 4.5, + countAfter: 94, + iotCount: 90, + accuracy: 95.7, + }, + { + date: '2026-05-24', + day: 2, + countBefore: 94, + mortality: 2, + harvest: 0, + harvestWeight: null, + countAfter: 92, + iotCount: 91, + accuracy: 98.9, + }, + ]; + + expect(countingTotals(rows)).toEqual({ + label: 'Total', + countBefore: 100, + mortality: 7, + harvest: 1, + harvestWeight: 4.5, + countAfter: null, + iotCount: null, + accuracy: null, + }); + }); + + it('returns null when there are no counting rows', () => { + expect(countingTotals([])).toBeNull(); + }); +}); + +describe('feedInTotals', () => { + it('sums sacks and recomputes accuracy from the totals', () => { + const rows: DashboardReportFeedInRow[] = [ + { date: '2026-05-22', manualIn: 10, iotIn: 10, accuracy: 100 }, + { date: '2026-05-23', manualIn: 5, iotIn: 4, accuracy: 80 }, + ]; + + expect(feedInTotals(rows)).toEqual({ + label: 'Total', + manualIn: 15, + iotIn: 14, + accuracy: 93.3, + }); + }); +}); + +describe('feedUseTotals', () => { + it('sums left+right daily sacks across the period', () => { + const rows: DashboardReportFeedUseRow[] = [ + { date: '2026-05-22', day: 0, manualUse: 30, iotUse: 30, accuracy: 100 }, + { date: '2026-05-23', day: 1, manualUse: 10, iotUse: 8, accuracy: 80 }, + ]; + + expect(feedUseTotals(rows)).toEqual({ + label: 'Total', + manualUse: 40, + iotUse: 38, + accuracy: 95, + }); + }); +}); diff --git a/utils/buildReportSnapshot.ts b/utils/buildReportSnapshot.ts new file mode 100644 index 0000000..526c833 --- /dev/null +++ b/utils/buildReportSnapshot.ts @@ -0,0 +1,317 @@ +import type { FeedSackColumnConfig } from '../types/counting.ts'; +import type { + DashboardReportCountingRow, + DashboardReportEefRow, + DashboardReportFcrRow, + DashboardReportFeedInRow, + DashboardReportFeedUseRow, + DashboardReportPayload, + DashboardReportType, + DashboardReportWeightRow, +} from '../types/dashboardReport.ts'; +import { + countingAccuracy, + sackAccuracy, + weightAccuracy, + combinedMortality, +} from './reportAccuracy.ts'; +import { + dateToReportDay, + feedOrWeightDayToReportDay, + formatLocalDate, + parseLocalDateString, + reportDayToDate, +} from './reportDay.ts'; +import { getCameraNamesForKandang, getCombinedUsageCameraNames } from './feedSackMapping.ts'; + +export interface ReportMortalityRecord { + day: number; + mortalityCount?: number | null; + afkir?: number | null; + panen?: number | null; + beratPanen?: number | null; + chickenCount?: number | null; +} + +export interface ReportChickenCountRecord { + date: string; + totalCount: number; +} + +export interface ReportFeedLog { + cameraName: string; + count: number; + timestamp: Date | string; +} + +export interface ReportManualFeedEntry { + cameraName: string; + count: number; + date: string; +} + +export interface ReportWeightDay { + /** 1-based Berat Ayam / multi-coop day. */ + day: number; + avgManual?: number | null; + avgIot?: number | null; + uniformity?: number | null; +} + +export interface BuildReportSnapshotInput { + cycleId: string; + cycleStartDate: Date | string; + kandangId: number; + kandangName: string; + area: string; + upToDay: number; + initialPopulation: number; + generatedAt?: string; + cageUuid?: string | null; + mortalityRecords: ReportMortalityRecord[]; + chickenCountByDay?: Record; + chickenCounts?: ReportChickenCountRecord[]; + feedSackHistory: ReportFeedLog[]; + manualFeedEntries: ReportManualFeedEntry[]; + feedColumnConfig: FeedSackColumnConfig[]; + weightByDay?: ReportWeightDay[]; + reportType?: DashboardReportType; + fcr?: DashboardReportFcrRow[]; + eef?: DashboardReportEefRow[]; +} + +const localDateKey = (value: Date): string => formatLocalDate(value); + +const parseLocalDate = (value: Date | string): Date => { + if (value instanceof Date) { + const copy = new Date(value); + copy.setHours(0, 0, 0, 0); + return copy; + } + return parseLocalDateString(value); +}; + +function buildChickenCountMap( + cycleStart: Date | string, + upToDay: number, + byDay?: Record, + records?: ReportChickenCountRecord[] +): Record { + const map: Record = { ...(byDay || {}) }; + if (!records) return map; + for (const record of records) { + const day = dateToReportDay(cycleStart, parseLocalDate(record.date)); + if (day >= 0 && day <= upToDay) { + map[day] = (map[day] || 0) + (record.totalCount || 0); + } + } + return map; +} + +function buildCountingRows( + input: BuildReportSnapshotInput, + cycleStart: Date | string +): DashboardReportCountingRow[] { + const chickenCountMap = buildChickenCountMap( + cycleStart, + input.upToDay, + input.chickenCountByDay, + input.chickenCounts + ); + const byMortalityDay = new Map(input.mortalityRecords.map((row) => [row.day, row])); + const rows: DashboardReportCountingRow[] = []; + let previousAfter = 0; + + for (let day = 0; day <= input.upToDay; day++) { + const record = byMortalityDay.get(day); + const mati = record?.mortalityCount || 0; + const afkir = record?.afkir || 0; + const harvest = record?.panen || 0; + const harvestWeight = record?.beratPanen ?? null; + const countBefore = day === 0 ? input.initialPopulation || 0 : previousAfter; + const mortality = combinedMortality(mati, afkir, harvest); + const countAfter = countBefore - mortality - harvest; + const mappedCount = chickenCountMap[day]; + const recordCount = record?.chickenCount; + const iotCount: number | null = + typeof mappedCount === 'number' + ? mappedCount + : typeof recordCount === 'number' + ? recordCount + : null; + + rows.push({ + date: formatLocalDate(reportDayToDate(cycleStart, day)), + day, + countBefore, + mortality, + harvest, + harvestWeight: harvestWeight == null ? null : Number(harvestWeight), + countAfter, + iotCount, + accuracy: countingAccuracy(iotCount, countAfter), + }); + previousAfter = countAfter; + } + + return rows; +} + +function sumFeedForDate( + cameraNames: string[], + dateKey: string, + iotLogs: ReportFeedLog[], + manualEntries: ReportManualFeedEntry[] +): { manual: number; iot: number } { + const cameras = new Set(cameraNames); + let iot = 0; + let manual = 0; + for (const log of iotLogs) { + if (!cameras.has(log.cameraName)) continue; + if (localDateKey(parseLocalDate(log.timestamp)) !== dateKey) continue; + iot += log.count || 0; + } + for (const entry of manualEntries) { + if (!cameras.has(entry.cameraName)) continue; + const entryKey = /^\d{4}-\d{2}-\d{2}/.test(entry.date) + ? entry.date.slice(0, 10) + : localDateKey(parseLocalDate(entry.date)); + if (entryKey !== dateKey) continue; + manual += entry.count || 0; + } + return { manual, iot }; +} + +function buildFeedRows( + input: BuildReportSnapshotInput, + cycleStart: Date | string +): { feedIn: DashboardReportFeedInRow[]; feedUse: DashboardReportFeedUseRow[] } { + const inboundCameras = getCameraNamesForKandang( + input.feedColumnConfig, + 'inbound', + input.kandangId + ); + const usageCameras = getCombinedUsageCameraNames(input.feedColumnConfig); + const feedMapped = inboundCameras.length > 0 || usageCameras.length > 0; + + const feedIn: DashboardReportFeedInRow[] = []; + const feedUse: DashboardReportFeedUseRow[] = []; + + for (let day = 0; day <= input.upToDay; day++) { + const date = reportDayToDate(cycleStart, day); + const dateKey = formatLocalDate(date); + + if (!feedMapped) { + feedIn.push({ date: dateKey, manualIn: null, iotIn: null, accuracy: null }); + feedUse.push({ + date: dateKey, + day, + manualUse: null, + iotUse: null, + accuracy: null, + }); + continue; + } + + const inbound = sumFeedForDate( + inboundCameras, + dateKey, + input.feedSackHistory, + input.manualFeedEntries + ); + const usage = sumFeedForDate( + usageCameras, + dateKey, + input.feedSackHistory, + input.manualFeedEntries + ); + + feedIn.push({ + date: dateKey, + manualIn: inboundCameras.length ? inbound.manual : null, + iotIn: inboundCameras.length ? inbound.iot : null, + accuracy: inboundCameras.length ? sackAccuracy(inbound.manual, inbound.iot) : null, + }); + feedUse.push({ + date: dateKey, + day, + manualUse: usageCameras.length ? usage.manual : null, + iotUse: usageCameras.length ? usage.iot : null, + accuracy: usageCameras.length ? sackAccuracy(usage.manual, usage.iot) : null, + }); + } + + return { feedIn, feedUse }; +} + +function buildWeightRows( + input: BuildReportSnapshotInput, + cycleStart: Date | string +): DashboardReportWeightRow[] { + const rows: DashboardReportWeightRow[] = []; + const byReportDay = new Map(); + if (input.cageUuid) { + for (const point of input.weightByDay || []) { + const reportDay = feedOrWeightDayToReportDay(point.day); + if (reportDay >= 0 && reportDay <= input.upToDay) { + byReportDay.set(reportDay, point); + } + } + } + + for (let day = 0; day <= input.upToDay; day++) { + const date = formatLocalDate(reportDayToDate(cycleStart, day)); + if (!input.cageUuid) { + rows.push({ + date, + day, + avgManual: null, + avgIot: null, + uniformity: null, + difference: null, + accuracy: null, + }); + continue; + } + const point = byReportDay.get(day); + const avgManual = point?.avgManual ?? null; + const avgIot = point?.avgIot ?? null; + const difference = + avgManual != null && avgIot != null ? avgManual - avgIot : null; + rows.push({ + date, + day, + avgManual, + avgIot, + uniformity: point?.uniformity ?? null, + difference, + accuracy: weightAccuracy(avgManual, avgIot), + }); + } + + return rows; +} + +export function buildReportSnapshot(input: BuildReportSnapshotInput): DashboardReportPayload { + const upToDay = Math.max(0, input.upToDay); + const cycleStart = input.cycleStartDate; + const { feedIn, feedUse } = buildFeedRows({ ...input, upToDay }, cycleStart); + + const reportType = input.reportType || 'daily'; + return { + cycleId: input.cycleId, + kandangId: input.kandangId, + kandangName: input.kandangName, + area: input.area, + upToDay, + generatedAt: input.generatedAt || new Date().toISOString(), + reportType, + counting: buildCountingRows({ ...input, upToDay }, cycleStart), + weight: buildWeightRows({ ...input, upToDay }, cycleStart), + feedIn, + feedUse, + ...(reportType === 'end_cycle' + ? { fcr: input.fcr || [], eef: input.eef || [] } + : {}), + }; +} diff --git a/utils/feedSackMapping.ts b/utils/feedSackMapping.ts index 88100dc..df70483 100644 --- a/utils/feedSackMapping.ts +++ b/utils/feedSackMapping.ts @@ -42,29 +42,39 @@ export function normalizeFeedSackColumnConfigs( return columns.map(normalizeFeedSackColumnConfig); } -export function getUsageCameraNamesForKandang( +type FeedSackColumnType = 'inbound' | 'usage' | 'outbound'; + +function columnsOfType( columns: FeedSackColumnConfig[], + type: FeedSackColumnType +): FeedSackColumnConfig[] { + return columns.filter( + (column) => (column.type || getFeedSackColumnType(column.cameraName)) === type + ); +} + +/** + * Cameras of one type mapped to a kandang. Returns [] when mapping fails — + * never all cameras (report must not silently become all-kandang). + */ +export function getCameraNamesForKandang( + columns: FeedSackColumnConfig[], + type: FeedSackColumnType, kandangId?: number | null, kandangName?: string | null ): string[] { - const usageColumns = columns.filter((column) => column.type === 'usage'); + const typed = columnsOfType(columns, type); if (kandangId !== null && kandangId !== undefined) { - const mappedCameras = usageColumns + const mappedCameras = typed .filter((column) => column.kandangId === kandangId) .map((column) => column.cameraName); + if (mappedCameras.length > 0) return mappedCameras; - if (mappedCameras.length > 0) { - return mappedCameras; - } - - const inferredCameras = usageColumns + const inferredCameras = typed .filter((column) => inferKandangIdFromCameraName(column.cameraName) === kandangId) .map((column) => column.cameraName); - - if (inferredCameras.length > 0) { - return inferredCameras; - } + if (inferredCameras.length > 0) return inferredCameras; } if (kandangName) { @@ -72,41 +82,55 @@ export function getUsageCameraNamesForKandang( const kandangPrefix = kandangNameLower.replace(/\s+/g, '_'); if (kandangNameLower.includes('atas')) { - const atasCameras = usageColumns + const atasCameras = typed .filter( (column) => column.cameraName.toLowerCase().includes('atas') || column.cameraName.toLowerCase().includes('kiri') ) .map((column) => column.cameraName); - - if (atasCameras.length > 0) { - return atasCameras; - } + if (atasCameras.length > 0) return atasCameras; } if (kandangNameLower.includes('bawah')) { - const bawahCameras = usageColumns + const bawahCameras = typed .filter( (column) => column.cameraName.toLowerCase().includes('bawah') || column.cameraName.toLowerCase().includes('kanan') ) .map((column) => column.cameraName); - - if (bawahCameras.length > 0) { - return bawahCameras; - } + if (bawahCameras.length > 0) return bawahCameras; } - const prefixedCameras = usageColumns + const prefixedCameras = typed .filter((column) => column.cameraName.toLowerCase().startsWith(kandangPrefix)) .map((column) => column.cameraName); - - if (prefixedCameras.length > 0) { - return prefixedCameras; - } + if (prefixedCameras.length > 0) return prefixedCameras; } - return usageColumns.map((column) => column.cameraName); + return []; +} + +export function getInboundCameraNamesForKandang( + columns: FeedSackColumnConfig[], + kandangId?: number | null, + kandangName?: string | null +): string[] { + return getCameraNamesForKandang(columns, 'inbound', kandangId, kandangName); +} + +export function getUsageCameraNamesForKandang( + columns: FeedSackColumnConfig[], + kandangId?: number | null, + kandangName?: string | null +): string[] { + const mapped = getCameraNamesForKandang(columns, 'usage', kandangId, kandangName); + if (mapped.length > 0) return mapped; + return getCombinedUsageCameraNames(columns); +} + +/** All usage cameras (left + right / atas + bawah). Production feed use is combined. */ +export function getCombinedUsageCameraNames(columns: FeedSackColumnConfig[]): string[] { + return columnsOfType(columns, 'usage').map((column) => column.cameraName); } diff --git a/utils/pdfExport.ts b/utils/pdfExport.ts index 948b2c2..86663aa 100644 --- a/utils/pdfExport.ts +++ b/utils/pdfExport.ts @@ -2,6 +2,52 @@ import { jsPDF } from 'jspdf'; import html2canvas from 'html2canvas'; import { revealPdfHeaders } from '../components/shared/PdfReportHeader.tsx'; +const PAGE_WIDTH_MM = 210; +const PAGE_HEIGHT_MM = 297; +const MARGIN_MM = 10; +const CONTENT_WIDTH_MM = PAGE_WIDTH_MM - MARGIN_MM * 2; +const USABLE_HEIGHT_MM = PAGE_HEIGHT_MM - MARGIN_MM * 2; + +export const usableHeightCssPx = (widthPx: number): number => + (USABLE_HEIGHT_MM / CONTENT_WIDTH_MM) * widthPx; + +/** + * Safe page-break offsets for `data-pdf-block` nodes. + * + * A block's start is only a break point when the whole block fits on one page. + * Oversized tables (e.g. 49-day Hitung Ayam) must start under the header + * instead of being deferred to page 2. + */ +export function pdfBlockBreakOffsets( + blocks: Array<{ top: number; bottom: number }>, + usableHeightPx: number +): number[] { + const breaks: number[] = []; + for (const block of blocks) { + const height = block.bottom - block.top; + if (height <= usableHeightPx && block.top > 0) { + breaks.push(block.top); + } + if (block.bottom > 0) { + breaks.push(block.bottom); + } + } + return [...new Set(breaks.map((value) => Math.round(value)))].sort((a, b) => a - b); +} + +export function choosePdfSliceHeight( + cursor: number, + canvasHeight: number, + usableHeightPx: number, + breakPoints: number[] +): number { + const remaining = canvasHeight - cursor; + if (remaining <= usableHeightPx) return remaining; + const limit = cursor + usableHeightPx; + const candidate = [...breakPoints].reverse().find((point) => point > cursor && point <= limit); + return candidate ? candidate - cursor : usableHeightPx; +} + /** * Render a card to a multi-page A4 PDF without slicing through its contents. * @@ -21,9 +67,10 @@ export const exportCardToPdf = async ( options?: { widthPx?: number } ): Promise => { const widthPx = options?.widthPx ?? 800; - const scale = 2; + const scale = 1.5; + const jpegQuality = 0.82; /** Offsets, in cloned-CSS pixels, where a page may safely break. */ - const safeBreaks: number[] = []; + const blockRects: Array<{ top: number; bottom: number }> = []; const canvas = await html2canvas(element, { scale, @@ -45,20 +92,20 @@ export const exportCardToPdf = async ( const top = clonedElement.getBoundingClientRect().top; clonedElement.querySelectorAll('[data-pdf-block]').forEach((node) => { const rect = node.getBoundingClientRect(); - safeBreaks.push(rect.top - top, rect.bottom - top); + blockRects.push({ top: rect.top - top, bottom: rect.bottom - top }); }); }, }); - const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); + const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4', compress: true }); const pageWidthMm = doc.internal.pageSize.getWidth(); const pageHeightMm = doc.internal.pageSize.getHeight(); - const marginMm = 10; - const contentWidthMm = pageWidthMm - marginMm * 2; - const usableHeightMm = pageHeightMm - marginMm * 2; + const contentWidthMm = pageWidthMm - MARGIN_MM * 2; + const usableHeightMm = pageHeightMm - MARGIN_MM * 2; const mmPerPx = contentWidthMm / canvas.width; const usableHeightPx = usableHeightMm / mmPerPx; + const safeBreaks = pdfBlockBreakOffsets(blockRects, usableHeightCssPx(widthPx)); // Clone-CSS px -> canvas px, de-duplicated and ordered. const breakPoints = [...new Set(safeBreaks.map((value) => Math.round(value * scale)))] @@ -69,20 +116,8 @@ export const exportCardToPdf = async ( let firstPage = true; while (cursor < canvas.height) { - const remaining = canvas.height - cursor; - let sliceHeight: number; - - if (remaining <= usableHeightPx) { - sliceHeight = remaining; - } else { - const limit = cursor + usableHeightPx; - const candidate = [...breakPoints] - .reverse() - .find((point) => point > cursor && point <= limit); - // No declared boundary fits — the block is taller than a page, so fall - // back to a hard cut rather than looping forever. - sliceHeight = candidate ? candidate - cursor : usableHeightPx; - } + const sliceHeight = choosePdfSliceHeight(cursor, canvas.height, usableHeightPx, breakPoints); + if (sliceHeight <= 0) break; const slice = document.createElement('canvas'); slice.width = canvas.width; @@ -95,10 +130,10 @@ export const exportCardToPdf = async ( if (!firstPage) doc.addPage(); doc.addImage( - slice.toDataURL('image/png'), - 'PNG', - marginMm, - marginMm, + slice.toDataURL('image/jpeg', jpegQuality), + 'JPEG', + MARGIN_MM, + MARGIN_MM, contentWidthMm, slice.height * mmPerPx ); diff --git a/utils/reportAccuracy.ts b/utils/reportAccuracy.ts new file mode 100644 index 0000000..16c5aae --- /dev/null +++ b/utils/reportAccuracy.ts @@ -0,0 +1,59 @@ +/** + * IoT-vs-manual accuracy for the Dashboard daily report. + * + * Smaller/larger × 100%, matching Hitung Ayam (`DataManagementPage`) and + * Hitung Karung (`FeedDailySummaryTable`). Null means no accuracy / N/A. + */ + +const round1 = (value: number): number => Math.round(value * 10) / 10; + +const minMaxPercent = (a: number, b: number): number => + round1((Math.min(a, b) / Math.max(a, b)) * 100); + +const isPresentNumber = (value: number | null | undefined): value is number => + typeof value === 'number' && Number.isFinite(value); + +/** + * Counting: no IoT or after-count ≤ 0 → no accuracy. + * Otherwise min(IoT, after) / max(IoT, after) × 100, 1 decimal. + */ +export function countingAccuracy( + iotCount: number | null | undefined, + afterCount: number +): number | null { + if (!isPresentNumber(iotCount) || afterCount <= 0) return null; + return minMaxPercent(iotCount, afterCount); +} + +/** + * Sacks: both 0 (missing counts treated as 0) → 100%. + * Only one side > 0 → N/A. Both > 0 → min/max × 100. + */ +export function sackAccuracy( + manual: number | null | undefined, + iot: number | null | undefined +): number | null { + const manualVal = isPresentNumber(manual) ? manual : 0; + const iotVal = isPresentNumber(iot) ? iot : 0; + if (manualVal === 0 && iotVal === 0) return 100; + if (manualVal === 0 || iotVal === 0) return null; + return minMaxPercent(manualVal, iotVal); +} + +/** + * Weight: same smaller/larger rule on average IoT vs average manual. + * Missing or non-positive averages → no accuracy. + */ +export function weightAccuracy( + manualAvg: number | null | undefined, + iotAvg: number | null | undefined +): number | null { + if (!isPresentNumber(manualAvg) || !isPresentNumber(iotAvg)) return null; + if (manualAvg <= 0 || iotAvg <= 0) return null; + return minMaxPercent(manualAvg, iotAvg); +} + +/** Mortality column is mati + afkir. Panen is ignored. */ +export function combinedMortality(mati: number, afkir: number, _panen?: number): number { + return (mati || 0) + (afkir || 0); +} diff --git a/utils/reportCycle.ts b/utils/reportCycle.ts new file mode 100644 index 0000000..8374a48 --- /dev/null +++ b/utils/reportCycle.ts @@ -0,0 +1,14 @@ +import { dateToReportDay } from './reportDay.ts'; + +export function isCycleClosed(endDate?: Date | string | null): boolean { + if (endDate == null || endDate === '') return false; + return true; +} + +export function closedCycleUpToDay( + startDate: Date | string, + endDate?: Date | string | null +): number { + if (!isCycleClosed(endDate) || endDate == null || endDate === '') return 0; + return Math.max(0, dateToReportDay(startDate, endDate)); +} diff --git a/utils/reportDay.ts b/utils/reportDay.ts new file mode 100644 index 0000000..c8e28c9 --- /dev/null +++ b/utils/reportDay.ts @@ -0,0 +1,52 @@ +/** + * Hitung Ayam 0-based day index for the Dashboard daily report. + * Day 0 = cycle start (DOC in). Feed/weight pages are 1-based and must be + * converted before joining. Local calendar dates only — never toISOString(). + */ + +export function parseLocalDateString(value: string): Date { + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { + const year = Number(value.slice(0, 4)); + const month = Number(value.slice(5, 7)); + const day = Number(value.slice(8, 10)); + return new Date(year, month - 1, day); + } + const parsed = new Date(value); + parsed.setHours(0, 0, 0, 0); + return parsed; +} + +const toLocalMidnight = (value: Date | string): Date => { + if (typeof value === 'string') return parseLocalDateString(value); + const date = new Date(value); + date.setHours(0, 0, 0, 0); + return date; +}; + +/** YYYY-MM-DD from local parts. Do not use toISOString() (WIB off-by-one). */ +export function formatLocalDate(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +/** Calendar date of a Hitung Ayam day. Day 0 is the cycle start. */ +export function reportDayToDate(cycleStart: Date | string, day: number): Date { + const start = toLocalMidnight(cycleStart); + start.setDate(start.getDate() + day); + return start; +} + +/** Hitung Ayam day index for a calendar date. Cycle start → 0. */ +export function dateToReportDay(cycleStart: Date | string, date: Date | string): number { + const start = toLocalMidnight(cycleStart); + const target = toLocalMidnight(date); + const diffMs = target.getTime() - start.getTime(); + return Math.round(diffMs / (1000 * 60 * 60 * 24)); +} + +/** Feed/weight pages label cycle start as day 1; report labels it day 0. */ +export function feedOrWeightDayToReportDay(oneBasedDay: number): number { + return oneBasedDay - 1; +} diff --git a/utils/reportExcel.ts b/utils/reportExcel.ts new file mode 100644 index 0000000..5053622 --- /dev/null +++ b/utils/reportExcel.ts @@ -0,0 +1,209 @@ +import ExcelJS from 'exceljs'; +import type { DashboardReportPayload } from '../types/dashboardReport.ts'; +import { buildPdfFilename, isoDataDates } from '../components/shared/PdfReportHeader.tsx'; +import { countingTotals, feedInTotals, feedUseTotals } from './reportTotals.ts'; + +const dayRangeLabel = (upToDay: number): string => `Hari ke-0 s/d ke-${upToDay}`; + +const accuracyText = (value: number | null | undefined): string => + value == null || !Number.isFinite(value) ? 'N/A' : `${value.toFixed(1)}%`; + +const numOrNa = (value: number | null | undefined): number | string => + value == null || !Number.isFinite(value) ? 'N/A' : value; + +function writeMetaRow(sheet: ExcelJS.Worksheet, snapshot: DashboardReportPayload): void { + sheet.addRow([ + `Kandang: ${snapshot.kandangName}`, + `Siklus: ${snapshot.cycleId}`, + `Periode: ${dayRangeLabel(snapshot.upToDay)}`, + `Dibuat: ${snapshot.generatedAt}`, + ]); +} + +export async function buildReportWorkbook( + snapshot: DashboardReportPayload +): Promise { + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'Dashboard CPSP'; + workbook.created = snapshot.generatedAt ? new Date(snapshot.generatedAt) : new Date(); + + const counting = workbook.addWorksheet('Counting'); + writeMetaRow(counting, snapshot); + counting.addRow([ + 'Tanggal', + 'Umur (hari)', + 'Stok awal', + 'Mortalitas', + 'Panen', + 'Berat panen', + 'Stok akhir', + 'IoT', + 'Akurasi %', + ]); + for (const row of snapshot.counting || []) { + counting.addRow([ + row.date, + row.day, + numOrNa(row.countBefore), + numOrNa(row.mortality), + numOrNa(row.harvest), + numOrNa(row.harvestWeight), + numOrNa(row.countAfter), + numOrNa(row.iotCount), + accuracyText(row.accuracy), + ]); + } + const countingTotal = countingTotals(snapshot.counting || []); + if (countingTotal) { + counting.addRow([ + countingTotal.label, + '—', + numOrNa(countingTotal.countBefore), + numOrNa(countingTotal.mortality), + numOrNa(countingTotal.harvest), + numOrNa(countingTotal.harvestWeight), + numOrNa(countingTotal.countAfter), + numOrNa(countingTotal.iotCount), + accuracyText(countingTotal.accuracy), + ]); + } + + const weight = workbook.addWorksheet('Weight'); + writeMetaRow(weight, snapshot); + weight.addRow([ + 'Tanggal', + 'Umur (hari)', + 'Rata-rata manual', + 'Rata-rata IoT', + 'Keseragaman', + 'Selisih', + 'Akurasi %', + ]); + for (const row of snapshot.weight || []) { + weight.addRow([ + row.date, + row.day, + numOrNa(row.avgManual), + numOrNa(row.avgIot), + accuracyText(row.uniformity), + numOrNa(row.difference), + accuracyText(row.accuracy), + ]); + } + + const feedIn = workbook.addWorksheet('Feed In'); + writeMetaRow(feedIn, snapshot); + feedIn.addRow(['Tanggal', 'Manual masuk', 'IoT masuk', 'Akurasi %']); + for (const row of snapshot.feedIn || []) { + feedIn.addRow([row.date, numOrNa(row.manualIn), numOrNa(row.iotIn), accuracyText(row.accuracy)]); + } + const feedInTotal = feedInTotals(snapshot.feedIn || []); + if (feedInTotal) { + feedIn.addRow([ + feedInTotal.label, + numOrNa(feedInTotal.manualIn), + numOrNa(feedInTotal.iotIn), + accuracyText(feedInTotal.accuracy), + ]); + } + + const feedUse = workbook.addWorksheet('Feed Use'); + writeMetaRow(feedUse, snapshot); + feedUse.addRow(['Tanggal', 'Umur (hari)', 'Manual dituang', 'IoT dituang', 'Akurasi %']); + for (const row of snapshot.feedUse || []) { + feedUse.addRow([ + row.date, + row.day, + numOrNa(row.manualUse), + numOrNa(row.iotUse), + accuracyText(row.accuracy), + ]); + } + const feedUseTotal = feedUseTotals(snapshot.feedUse || []); + if (feedUseTotal) { + feedUse.addRow([ + feedUseTotal.label, + '—', + numOrNa(feedUseTotal.manualUse), + numOrNa(feedUseTotal.iotUse), + accuracyText(feedUseTotal.accuracy), + ]); + } + + if (snapshot.reportType === 'end_cycle') { + const harvestKg = (grams: number | null | undefined): number | string => + grams == null || !Number.isFinite(grams) ? 'N/A' : grams / 1000; + + const fcr = workbook.addWorksheet('FCR'); + writeMetaRow(fcr, snapshot); + fcr.addRow(['Umur', 'Total Pakan', 'Bobot IoT', 'Ayam Hidup', 'Total Bobot Panen', 'FCR']); + for (const row of snapshot.fcr || []) { + fcr.addRow([ + row.day, + numOrNa(row.feedTotal), + numOrNa(row.iotWeight), + numOrNa(row.chickenLife), + harvestKg(row.harvestWeightTotal), + numOrNa(row.iotFcr), + ]); + } + + const eef = workbook.addWorksheet('EEF'); + writeMetaRow(eef, snapshot); + eef.addRow([ + 'Umur', + 'Persentase Ayam Hidup', + 'BW Rata-rata Panen', + 'Umur Rata-rata Panen', + 'FCR', + 'EEF', + ]); + for (const row of snapshot.eef || []) { + eef.addRow([ + row.day, + numOrNa(row.chickenLifePercentage), + numOrNa(row.iotAverageHarvestBw), + numOrNa(row.iotAverageDayWeight), + numOrNa(row.iotFcr), + numOrNa(row.iotEef), + ]); + } + } + + return workbook; +} + +export function reportExcelFilename( + snapshot: DashboardReportPayload, + cycleStartDate?: Date | string | null +): string { + const isEndCycle = snapshot.reportType === 'end_cycle'; + const pdfName = buildPdfFilename({ + prefix: 'Laporan', + reportName: isEndCycle ? 'Akhir-Siklus' : 'Harian', + area: snapshot.area, + kandangLabel: snapshot.kandangName, + periodSlug: isEndCycle ? 'Akhir-Siklus' : `Harian-Hari-${snapshot.upToDay}`, + isoDates: isoDataDates(cycleStartDate, 0, snapshot.upToDay), + }); + return pdfName.replace(/\.pdf$/i, '.xlsx'); +} + +export async function downloadReportExcel( + snapshot: DashboardReportPayload, + cycleStartDate?: Date | string | null +): Promise { + const workbook = await buildReportWorkbook(snapshot); + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = reportExcelFilename(snapshot, cycleStartDate); + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +} diff --git a/utils/reportFcrEef.ts b/utils/reportFcrEef.ts new file mode 100644 index 0000000..eec7223 --- /dev/null +++ b/utils/reportFcrEef.ts @@ -0,0 +1,69 @@ +import type { DashboardReportEefRow, DashboardReportFcrRow } from '../types/dashboardReport.ts'; + +type ScalesKpiRow = { + food_total?: number; + iot_weight?: number; + chicken_life?: number; + harvest_weight_total?: number; + iot_fcr?: number; + chicken_life_percentage?: number; + iot_average_harvest_bw?: number; + iot_average_day_weight?: number; + iot_eef?: number; +}; + +const toValidRatio = (value: number | undefined): number | null => + value !== undefined && value > 0 ? value : null; + +const toNullableNumber = (value: number | undefined | null): number | null => + value !== undefined && value !== null ? value : null; + +const isKpiMap = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +const kpiDaysInRange = ( + kpi: unknown, + upToDay: number +): Array<[number, ScalesKpiRow]> => { + if (!isKpiMap(kpi)) return []; + return Object.entries(kpi) + .map(([dayKey, row]) => [Number(dayKey), row || {}] as [number, ScalesKpiRow]) + .filter(([day]) => Number.isFinite(day) && day >= 0 && day <= upToDay) + .sort((a, b) => a[0] - b[0]); +}; + +export function mapKpiToFcrRows(kpi: unknown, upToDay: number): DashboardReportFcrRow[] { + return kpiDaysInRange(kpi, upToDay).map(([day, row]) => ({ + day, + feedTotal: row.food_total ?? 0, + iotWeight: toNullableNumber(row.iot_weight), + chickenLife: row.chicken_life ?? 0, + harvestWeightTotal: row.harvest_weight_total ?? 0, + iotFcr: toValidRatio(row.iot_fcr), + })); +} + +export function mapKpiToEefRows(kpi: unknown, upToDay: number): DashboardReportEefRow[] { + return kpiDaysInRange(kpi, upToDay).map(([day, row]) => ({ + day, + chickenLifePercentage: toNullableNumber(row.chicken_life_percentage), + iotAverageHarvestBw: toNullableNumber(row.iot_average_harvest_bw), + iotAverageDayWeight: toNullableNumber(row.iot_average_day_weight), + iotFcr: toValidRatio(row.iot_fcr), + iotEef: toValidRatio(row.iot_eef), + })); +} + +export function latestValidRatio( + rows: T[] | null | undefined, + key: keyof T +): number | null { + if (!rows?.length) return null; + for (let i = rows.length - 1; i >= 0; i--) { + const value = rows[i]?.[key]; + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return value; + } + } + return null; +} diff --git a/utils/reportOverlayLabels.ts b/utils/reportOverlayLabels.ts new file mode 100644 index 0000000..03eee73 --- /dev/null +++ b/utils/reportOverlayLabels.ts @@ -0,0 +1,10 @@ +import type { DashboardReportType } from '../types/dashboardReport.ts'; + +export const reportCtaLabel = (hasSnapshot: boolean): string => + hasSnapshot ? 'Lihat Laporan' : 'Laporan'; + +export const reportOverlayTitle = (reportType: DashboardReportType): string => + reportType === 'end_cycle' ? 'Laporan Akhir Siklus' : 'Laporan Harian IoT vs Manual'; + +export const generateButtonLabel = (hasSnapshot: boolean): string => + hasSnapshot ? 'Generate ulang' : 'Generate'; diff --git a/utils/reportTotals.ts b/utils/reportTotals.ts new file mode 100644 index 0000000..05ff96e --- /dev/null +++ b/utils/reportTotals.ts @@ -0,0 +1,86 @@ +import type { + DashboardReportCountingRow, + DashboardReportFeedInRow, + DashboardReportFeedUseRow, +} from '../types/dashboardReport.ts'; +import { sackAccuracy } from './reportAccuracy.ts'; + +export const REPORT_TOTAL_LABEL = 'Total'; + +const isPresentNumber = (value: number | null | undefined): value is number => + typeof value === 'number' && Number.isFinite(value); + +function sumPresent(values: Array): number | null { + let total = 0; + let any = false; + for (const value of values) { + if (!isPresentNumber(value)) continue; + total += value; + any = true; + } + return any ? total : null; +} + +export interface CountingTotalsRow { + label: string; + countBefore: number | null; + mortality: number | null; + harvest: number | null; + harvestWeight: number | null; + countAfter: number | null; + iotCount: number | null; + accuracy: number | null; +} + +export interface FeedInTotalsRow { + label: string; + manualIn: number | null; + iotIn: number | null; + accuracy: number | null; +} + +export interface FeedUseTotalsRow { + label: string; + manualUse: number | null; + iotUse: number | null; + accuracy: number | null; +} + +export function countingTotals(rows: DashboardReportCountingRow[]): CountingTotalsRow | null { + if (!rows.length) return null; + const first = rows[0]; + return { + label: REPORT_TOTAL_LABEL, + countBefore: first?.countBefore ?? null, + mortality: sumPresent(rows.map((row) => row.mortality)), + harvest: sumPresent(rows.map((row) => row.harvest)), + harvestWeight: sumPresent(rows.map((row) => row.harvestWeight)), + countAfter: null, + iotCount: null, + accuracy: null, + }; +} + +export function feedInTotals(rows: DashboardReportFeedInRow[]): FeedInTotalsRow | null { + if (!rows.length) return null; + const manualIn = sumPresent(rows.map((row) => row.manualIn)); + const iotIn = sumPresent(rows.map((row) => row.iotIn)); + return { + label: REPORT_TOTAL_LABEL, + manualIn, + iotIn, + accuracy: sackAccuracy(manualIn, iotIn), + }; +} + +export function feedUseTotals(rows: DashboardReportFeedUseRow[]): FeedUseTotalsRow | null { + if (!rows.length) return null; + const manualUse = sumPresent(rows.map((row) => row.manualUse)); + const iotUse = sumPresent(rows.map((row) => row.iotUse)); + return { + label: REPORT_TOTAL_LABEL, + manualUse, + iotUse, + accuracy: sackAccuracy(manualUse, iotUse), + }; +}