Add dashboard report snapshots feature with CRUD operations. Implemented new database migrations for dashboard_report_snapshots, including report type handling. Updated backend routes and models to support snapshot retrieval and upsert functionality. Enhanced frontend components to integrate report snapshot management, including UI for daily and end-cycle reports. Added tests for new functionality to ensure reliability.
CI / lint-and-test (push) Canceled after 0s

This commit is contained in:
Alberto-Audrix committed 2026-08-13 15:46:16 +07:00
1 parent 059087be1d
commit 34336b9bd1
41 files changed
+4824 -178

No files matched your search

@@ -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';
@@ -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';
+73
View File
@@ -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;
@@ -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');
});
});
@@ -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 }]);
});
});
});
+110
View File
@@ -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;
+2
View File
@@ -71,6 +71,7 @@ const manualFeedSackRouter = require('./routes/manualFeedSack');
const cycleFeedInitialBalanceRouter = require('./routes/cycleFeedInitialBalance'); const cycleFeedInitialBalanceRouter = require('./routes/cycleFeedInitialBalance');
const auditLogsRouter = require('./routes/auditLogs'); const auditLogsRouter = require('./routes/auditLogs');
const aiInsightsRouter = require('./routes/aiInsights'); const aiInsightsRouter = require('./routes/aiInsights');
const dashboardReportSnapshotsRouter = require('./routes/dashboardReportSnapshots');
app.use('/api/cycles', cyclesRouter); app.use('/api/cycles', cyclesRouter);
app.use('/api/mortality', mortalityRouter); 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/cycle-feed-initial-balance', cycleFeedInitialBalanceRouter);
app.use('/api/audit-logs', auditLogsRouter); app.use('/api/audit-logs', auditLogsRouter);
app.use('/api/ai-insights', aiInsightsRouter); app.use('/api/ai-insights', aiInsightsRouter);
app.use('/api/dashboard-report-snapshots', dashboardReportSnapshotsRouter);
// Health check endpoint // Health check endpoint
app.get('/health', (req, res) => { app.get('/health', (req, res) => {
+4 -3
View File
@@ -873,14 +873,15 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
let toDay: number | null = null; let toDay: number | null = null;
if (reportType === 'end_cycle') { if (reportType === 'end_cycle') {
fromDay = 1; fromDay = 0;
toDay = currentDay ?? null; toDay = currentDay ?? null;
} else if (reportType === 'weekly') { } else if (reportType === 'weekly') {
// `reportPeriod` is "Minggu 6" — a week number, not a day number. // `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); const { fromDay: weekNo } = parseDayRangeLabel(reportPeriod);
if (weekNo) { if (weekNo) {
fromDay = (weekNo - 1) * 7 + 1; fromDay = (weekNo - 1) * 7;
toDay = weekNo * 7; toDay = weekNo * 7 - 1;
} }
} else if (reportType === 'daily') { } else if (reportType === 'daily') {
// Here the number IS a day, so it is used as-is rather than scaled by 7. // Here the number IS a day, so it is used as-is rather than scaled by 7.
+77 -1
View File
@@ -5,6 +5,10 @@ import DailyWeightGainChart from './weight/DailyWeightGainChart.tsx';
import { AiInsightCard } from './AiInsightCard.tsx'; import { AiInsightCard } from './AiInsightCard.tsx';
import FeedSaldoCard from './feedSack/FeedSaldoCard.tsx'; import FeedSaldoCard from './feedSack/FeedSaldoCard.tsx';
import KarungDituangTodayCard from './feedSack/KarungDituangTodayCard.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 { Kpi } from '../types.ts';
import { View } from '../types/common.ts'; import { View } from '../types/common.ts';
import { import {
@@ -34,7 +38,7 @@ interface DashboardProps {
const Dashboard: React.FC<DashboardProps> = ({ const Dashboard: React.FC<DashboardProps> = ({
kpis, kpis,
setView, setView,
selectedArea: _selectedArea, selectedArea,
onAreaChange: _onAreaChange, onAreaChange: _onAreaChange,
selectedKandangId, selectedKandangId,
onKandangChange, onKandangChange,
@@ -51,14 +55,22 @@ const Dashboard: React.FC<DashboardProps> = ({
cycleData, cycleData,
isRefreshingWeightKpi, isRefreshingWeightKpi,
feedSackHistory, feedSackHistory,
manualFeedSackEntries,
effectiveDate, effectiveDate,
isShowingPreviousDayData, isShowingPreviousDayData,
hasPermission,
} = useAppContext(); } = useAppContext();
// Column config state for FeedSaldoCard (loaded from DB) // Column config state for FeedSaldoCard (loaded from DB)
const [columnConfig, setColumnConfig] = const [columnConfig, setColumnConfig] =
useState<FeedSackColumnConfig[]>(DEFAULT_FEED_SACK_COLUMNS); useState<FeedSackColumnConfig[]>(DEFAULT_FEED_SACK_COLUMNS);
const [initialBalance, setInitialBalance] = useState<CycleFeedInitialBalance | null>(null); const [initialBalance, setInitialBalance] = useState<CycleFeedInitialBalance | null>(null);
const [reportOpen, setReportOpen] = useState(false);
const [reportType, setReportType] = useState<DashboardReportType>('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 feedSaldoEffectiveDate = useMemo(() => {
const cappedEffectiveDate = new Date(effectiveDate); const cappedEffectiveDate = new Date(effectiveDate);
@@ -192,6 +204,32 @@ const Dashboard: React.FC<DashboardProps> = ({
} }
}, [cycleData.id]); }, [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(() => { useLayoutEffect(() => {
const duration = performance.now() - mountStart.current; const duration = performance.now() - mountStart.current;
performanceService.recordComponentTiming('Dashboard', duration); performanceService.recordComponentTiming('Dashboard', duration);
@@ -218,6 +256,44 @@ const Dashboard: React.FC<DashboardProps> = ({
})} })}
</div> </div>
{canViewDashboard && (
<DashboardReportCtas
cycleClosed={cycleClosed}
hasDailySnapshot={hasDailySnapshot}
hasEndCycleSnapshot={hasEndCycleSnapshot}
onOpenDaily={() => {
setReportType('daily');
setReportOpen(true);
}}
onOpenEndCycle={() => {
if (!cycleClosed) return;
setReportType('end_cycle');
setReportOpen(true);
}}
/>
)}
<ReportPreviewOverlay
open={reportOpen}
onClose={() => 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 */} {/* AI Insight Card */}
<AiInsightCard kandangId={selectedKandangId ? selectedKandangId.toString() : null} /> <AiInsightCard kandangId={selectedKandangId ? selectedKandangId.toString() : null} />
+4 -4
View File
@@ -441,20 +441,17 @@ const EefDashboardPage: React.FC = () => {
<thead className="bg-gray-700 text-white sticky top-0 z-10"> <thead className="bg-gray-700 text-white sticky top-0 z-10">
<tr> <tr>
<th className="p-3 text-left font-semibold">Umur</th> <th className="p-3 text-left font-semibold">Umur</th>
<th className="p-3 text-right font-semibold">EEF</th>
<th className="p-3 text-right font-semibold">Persentase Ayam Hidup</th> <th className="p-3 text-right font-semibold">Persentase Ayam Hidup</th>
<th className="p-3 text-right font-semibold">BW Rata-rata Panen</th> <th className="p-3 text-right font-semibold">BW Rata-rata Panen</th>
<th className="p-3 text-right font-semibold">Umur Rata-rata Panen</th> <th className="p-3 text-right font-semibold">Umur Rata-rata Panen</th>
<th className="p-3 text-right font-semibold">FCR</th> <th className="p-3 text-right font-semibold">FCR</th>
<th className="p-3 text-right font-semibold">EEF</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{supportRows.map((row) => ( {supportRows.map((row) => (
<tr key={row.day} className="even:bg-gray-50 hover:bg-red-50"> <tr key={row.day} className="even:bg-gray-50 hover:bg-red-50">
<td className="p-3 font-semibold text-gray-800">Day {row.day}</td> <td className="p-3 font-semibold text-gray-800">Day {row.day}</td>
<td className="p-3 text-right font-mono text-gray-700">
{formatEef(row.iotEef)}
</td>
<td className="p-3 text-right text-gray-700"> <td className="p-3 text-right text-gray-700">
{formatNullableNumber(row.chickenLifePercentage, 3)} {formatNullableNumber(row.chickenLifePercentage, 3)}
{row.chickenLifePercentage !== null ? '%' : ''} {row.chickenLifePercentage !== null ? '%' : ''}
@@ -469,6 +466,9 @@ const EefDashboardPage: React.FC = () => {
<td className="p-3 text-right font-mono text-gray-700"> <td className="p-3 text-right font-mono text-gray-700">
{formatFcr(row.iotFcr)} {formatFcr(row.iotFcr)}
</td> </td>
<td className="p-3 text-right font-mono text-gray-700">
{formatEef(row.iotEef)}
</td>
</tr> </tr>
))} ))}
{!isLoading && supportRows.length === 0 && ( {!isLoading && supportRows.length === 0 && (
+4 -4
View File
@@ -429,20 +429,17 @@ const FcrDashboardPage: React.FC = () => {
<thead className="bg-gray-700 text-white sticky top-0 z-10"> <thead className="bg-gray-700 text-white sticky top-0 z-10">
<tr> <tr>
<th className="p-3 text-left font-semibold">Umur</th> <th className="p-3 text-left font-semibold">Umur</th>
<th className="p-3 text-right font-semibold">FCR</th>
<th className="p-3 text-right font-semibold">Total Pakan</th> <th className="p-3 text-right font-semibold">Total Pakan</th>
<th className="p-3 text-right font-semibold">Bobot IoT</th> <th className="p-3 text-right font-semibold">Bobot IoT</th>
<th className="p-3 text-right font-semibold">Ayam Hidup</th> <th className="p-3 text-right font-semibold">Ayam Hidup</th>
<th className="p-3 text-right font-semibold">Total Bobot Panen</th> <th className="p-3 text-right font-semibold">Total Bobot Panen</th>
<th className="p-3 text-right font-semibold">FCR</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{supportRows.map((row) => ( {supportRows.map((row) => (
<tr key={row.day} className="even:bg-gray-50 hover:bg-red-50"> <tr key={row.day} className="even:bg-gray-50 hover:bg-red-50">
<td className="p-3 font-semibold text-gray-800">Day {row.day}</td> <td className="p-3 font-semibold text-gray-800">Day {row.day}</td>
<td className="p-3 text-right font-mono text-gray-700">
{formatFcr(row.iotFcr)}
</td>
<td className="p-3 text-right text-gray-700"> <td className="p-3 text-right text-gray-700">
{formatNumber(row.feedTotal)} karung {formatNumber(row.feedTotal)} karung
</td> </td>
@@ -456,6 +453,9 @@ const FcrDashboardPage: React.FC = () => {
<td className="p-3 text-right text-gray-700"> <td className="p-3 text-right text-gray-700">
{formatHarvestWeightKg(row.harvestWeightTotal)} kg {formatHarvestWeightKg(row.harvestWeightTotal)} kg
</td> </td>
<td className="p-3 text-right font-mono text-gray-700">
{formatFcr(row.iotFcr)}
</td>
</tr> </tr>
))} ))}
{!isLoading && supportRows.length === 0 && ( {!isLoading && supportRows.length === 0 && (
@@ -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<DashboardReportCtasProps> = ({
cycleClosed,
hasDailySnapshot,
hasEndCycleSnapshot,
onOpenDaily,
onOpenEndCycle,
}) => (
<div className="flex flex-wrap items-end justify-end gap-3">
<button type="button" onClick={onOpenDaily} className={buttonClass}>
{reportCtaLabel(hasDailySnapshot)} Harian
</button>
<div className="flex flex-col items-end">
<button
type="button"
onClick={onOpenEndCycle}
disabled={!cycleClosed}
className={buttonClass}
>
{reportCtaLabel(hasEndCycleSnapshot)} Akhir Siklus
</button>
{!cycleClosed && (
<p className="mt-1 text-xs text-slate-500">Tersedia setelah siklus ditutup</p>
)}
</div>
</div>
);
export default DashboardReportCtas;
@@ -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<ReportPreviewOverlayProps> = ({
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<number | null>(kandangId);
const [draftDay, setDraftDay] = useState(isEndCycle ? closedUpToDay : latestDay);
const [snapshot, setSnapshot] = useState<DashboardReportSnapshotRecord | null>(null);
const [loading, setLoading] = useState(false);
const [generating, setGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [kpiWarning, setKpiWarning] = useState<string | null>(null);
const printRef = useRef<HTMLDivElement>(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<number, number | null>();
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<string, any>)) {
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 (
<div
className="fixed inset-0 z-50 bg-slate-900/60"
role="dialog"
aria-modal="true"
aria-labelledby="report-preview-title"
data-testid="report-preview-overlay"
>
<div className="flex h-full w-full flex-col bg-slate-50">
<header
className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-white px-4 py-3 sm:px-6"
data-html2canvas-ignore="true"
>
<div>
<h2 id="report-preview-title" className="text-lg font-semibold text-slate-900">
{overlayTitle}
</h2>
<p className="text-xs text-slate-500">
{selectedKandang?.name || 'Pilih kandang'} · {cycleId} · Hari ke-0 s/d ke-
{effectiveUpToDay}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<label className="sr-only" htmlFor="report-kandang">
Kandang
</label>
<select
id="report-kandang"
value={draftKandangId ?? ''}
onChange={(e) => setDraftKandangId(e.target.value ? Number(e.target.value) : null)}
className="rounded-lg border border-slate-200 bg-white px-2 py-1.5 text-xs font-semibold text-slate-700 shadow-sm"
>
<option value="">Pilih kandang</option>
{kandangs.map((kandang) => (
<option key={kandang.id} value={kandang.id}>
{kandang.name}
</option>
))}
</select>
{!isEndCycle && (
<>
<label className="sr-only" htmlFor="report-day">
Hari siklus
</label>
<select
id="report-day"
aria-label="Hari siklus"
value={draftDay}
onChange={(e) => setDraftDay(Number(e.target.value))}
className="rounded-lg border border-slate-200 bg-white px-2 py-1.5 text-xs font-semibold text-slate-700 shadow-sm"
>
{dayOptions.map((day) => (
<option key={day} value={day}>
Hari {day}
</option>
))}
</select>
</>
)}
<button
type="button"
onClick={handleGenerate}
disabled={!canGenerate || generating}
className="rounded-lg bg-slate-900 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{generating ? 'Memproses…' : generateButtonLabel(!!snapshot)}
</button>
<button
type="button"
onClick={downloadsEnabled ? handleDownloadPdf : undefined}
disabled={!downloadsEnabled}
className="inline-flex items-center gap-1 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<Download className="h-3.5 w-3.5" />
PDF
</button>
<button
type="button"
onClick={downloadsEnabled ? handleDownloadExcel : undefined}
disabled={!downloadsEnabled}
className="inline-flex items-center gap-1 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<FileSpreadsheet className="h-3.5 w-3.5" />
Excel
</button>
<button
type="button"
onClick={onClose}
className="rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 hover:bg-slate-50"
>
<X className="h-4 w-4" />
<span className="sr-only">Tutup</span>
</button>
</div>
</header>
<div className="flex-1 overflow-auto px-4 py-4 sm:px-6">
{draftKandangId == null && (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
Pilih satu kandang sebelum Generate. Laporan tidak digabung untuk semua kandang.
</div>
)}
{error && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
)}
{kpiWarning && (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
{kpiWarning}
</div>
)}
{loading && <div className="py-16 text-center text-slate-500">Memuat laporan…</div>}
<div
id="report-snapshot-print"
ref={printRef}
className="rounded-xl bg-white p-4 shadow-sm"
>
{snapshot && (
<PdfReportHeader
title={overlayTitle}
area={area}
kandangLabel={snapshot.payload.kandangName}
periodLabel={`Hari ke-0 s/d ke-${snapshot.upToDay}`}
cycleId={cycleId}
dataDateLabel={describeDataDates(cycleStartDate, 0, snapshot.upToDay)}
>
{isEndCycle ? (
<>
FCR akhir: {ratioCell(latestValidRatio(tables?.fcr, 'iotFcr'))}
{' · '}
EEF akhir: {ratioCell(latestValidRatio(tables?.eef, 'iotEef'))}
</>
) : null}
</PdfReportHeader>
)}
<ReportTable title="Hitung Ayam harian">
<thead>
<tr>
<Th>Tanggal</Th>
<Th>Umur (hari)</Th>
<Th>Stok awal</Th>
<Th>Mortalitas</Th>
<Th>Panen</Th>
<Th>Berat panen</Th>
<Th>Stok akhir</Th>
<Th>IoT</Th>
<Th>Akurasi %</Th>
</tr>
</thead>
<tbody>
{(tables?.counting || []).map((row) => (
<tr key={`c-${row.day}`} className="border-t border-slate-100" data-pdf-block>
<Td>{row.date}</Td>
<Td>{row.day}</Td>
<Td>{cell(row.countBefore)}</Td>
<Td>{cell(row.mortality)}</Td>
<Td>{cell(row.harvest)}</Td>
<Td>{cell(row.harvestWeight)}</Td>
<Td>{cell(row.countAfter)}</Td>
<Td>{cell(row.iotCount)}</Td>
<Td>{accuracyCell(row.accuracy)}</Td>
</tr>
))}
</tbody>
{countingTotal && (
<tfoot>
<TotalRow>
<Td>{countingTotal.label}</Td>
<Td>—</Td>
<Td>{cell(countingTotal.countBefore)}</Td>
<Td>{cell(countingTotal.mortality)}</Td>
<Td>{cell(countingTotal.harvest)}</Td>
<Td>{cell(countingTotal.harvestWeight)}</Td>
<Td>{cell(countingTotal.countAfter)}</Td>
<Td>{cell(countingTotal.iotCount)}</Td>
<Td>{accuracyCell(countingTotal.accuracy)}</Td>
</TotalRow>
</tfoot>
)}
</ReportTable>
<ReportTable title="Berat Ayam harian" divided>
<thead>
<tr>
<Th>Tanggal</Th>
<Th>Umur (hari)</Th>
<Th>Rata-rata manual</Th>
<Th>Rata-rata IoT</Th>
<Th>Keseragaman</Th>
<Th>Selisih</Th>
<Th>Akurasi %</Th>
</tr>
</thead>
<tbody>
{(tables?.weight || []).map((row) => (
<tr key={`w-${row.day}`} className="border-t border-slate-100" data-pdf-block>
<Td>{row.date}</Td>
<Td>{row.day}</Td>
<Td>{cell(row.avgManual)}</Td>
<Td>{cell(row.avgIot)}</Td>
<Td>{accuracyCell(row.uniformity)}</Td>
<Td>{cell(row.difference)}</Td>
<Td>{accuracyCell(row.accuracy)}</Td>
</tr>
))}
</tbody>
</ReportTable>
<ReportTable title="Karung masuk" divided>
<thead>
<tr>
<Th>Tanggal</Th>
<Th>Manual masuk</Th>
<Th>IoT masuk</Th>
<Th>Akurasi %</Th>
</tr>
</thead>
<tbody>
{(tables?.feedIn || []).map((row) => (
<tr key={`in-${row.date}`} className="border-t border-slate-100" data-pdf-block>
<Td>{row.date}</Td>
<Td>{cell(row.manualIn)}</Td>
<Td>{cell(row.iotIn)}</Td>
<Td>{accuracyCell(row.accuracy)}</Td>
</tr>
))}
</tbody>
{feedInTotal && (
<tfoot>
<TotalRow>
<Td>{feedInTotal.label}</Td>
<Td>{cell(feedInTotal.manualIn)}</Td>
<Td>{cell(feedInTotal.iotIn)}</Td>
<Td>{accuracyCell(feedInTotal.accuracy)}</Td>
</TotalRow>
</tfoot>
)}
</ReportTable>
<ReportTable title="Karung dituang" divided>
<thead>
<tr>
<Th>Tanggal</Th>
<Th>Umur (hari)</Th>
<Th>Manual dituang</Th>
<Th>IoT dituang</Th>
<Th>Akurasi %</Th>
</tr>
</thead>
<tbody>
{(tables?.feedUse || []).map((row) => (
<tr
key={`use-${row.day}-${row.date}`}
className="border-t border-slate-100"
data-pdf-block
>
<Td>{row.date}</Td>
<Td>{row.day}</Td>
<Td>{cell(row.manualUse)}</Td>
<Td>{cell(row.iotUse)}</Td>
<Td>{accuracyCell(row.accuracy)}</Td>
</tr>
))}
</tbody>
{feedUseTotal && (
<tfoot>
<TotalRow>
<Td>{feedUseTotal.label}</Td>
<Td>—</Td>
<Td>{cell(feedUseTotal.manualUse)}</Td>
<Td>{cell(feedUseTotal.iotUse)}</Td>
<Td>{accuracyCell(feedUseTotal.accuracy)}</Td>
</TotalRow>
</tfoot>
)}
</ReportTable>
{isEndCycle && (
<>
<ReportTable title="Data Pendukung FCR" divided>
<thead>
<tr>
<Th>Umur</Th>
<Th>Total Pakan</Th>
<Th>Bobot IoT</Th>
<Th>Ayam Hidup</Th>
<Th>Total Bobot Panen</Th>
<Th>FCR</Th>
</tr>
</thead>
<tbody>
{(tables?.fcr || []).map((row) => (
<tr key={`fcr-${row.day}`} className="border-t border-slate-100" data-pdf-block>
<Td>Day {row.day}</Td>
<Td>{qtyCell(row.feedTotal, 0, ' karung')}</Td>
<Td>{qtyCell(row.iotWeight, 2, row.iotWeight != null ? ' g' : '')}</Td>
<Td>{qtyCell(row.chickenLife, 0, ' ekor')}</Td>
<Td>{harvestKgCell(row.harvestWeightTotal)}</Td>
<Td>{ratioCell(row.iotFcr)}</Td>
</tr>
))}
</tbody>
</ReportTable>
<ReportTable title="Data Pendukung EEF" divided>
<thead>
<tr>
<Th>Umur</Th>
<Th>Persentase Ayam Hidup</Th>
<Th>BW Rata-rata Panen</Th>
<Th>Umur Rata-rata Panen</Th>
<Th>FCR</Th>
<Th>EEF</Th>
</tr>
</thead>
<tbody>
{(tables?.eef || []).map((row) => (
<tr key={`eef-${row.day}`} className="border-t border-slate-100" data-pdf-block>
<Td>Day {row.day}</Td>
<Td>{qtyCell(row.chickenLifePercentage, 3, '%')}</Td>
<Td>
{qtyCell(
row.iotAverageHarvestBw,
2,
row.iotAverageHarvestBw != null ? ' g' : ''
)}
</Td>
<Td>{qtyCell(row.iotAverageDayWeight, 2)}</Td>
<Td>{ratioCell(row.iotFcr)}</Td>
<Td>{ratioCell(row.iotEef)}</Td>
</tr>
))}
</tbody>
</ReportTable>
</>
)}
</div>
</div>
</div>
</div>
);
};
function parseLocalMidnight(value: string): Date {
return parseLocalDateString(value);
}
const ReportTable: React.FC<{
title: string;
children: React.ReactNode;
divided?: boolean;
}> = ({ title, children, divided = false }) => (
<section
data-pdf-block="true"
className={divided ? 'mt-10 border-t-2 border-slate-400 pt-10' : undefined}
>
<h3 className="mb-2 text-sm font-semibold text-slate-800">{title}</h3>
<div className="overflow-x-auto rounded-lg border border-slate-200">
<table className="min-w-full text-left text-xs text-slate-700">{children}</table>
</div>
</section>
);
const Th: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<th className="bg-slate-50 px-3 py-2 font-semibold text-slate-600">{children}</th>
);
const Td: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<td className="px-3 py-2 tabular-nums">{children}</td>
);
const TotalRow: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<tr className="border-t-2 border-slate-200 bg-slate-50 font-semibold" data-pdf-block>
{children}
</tr>
);
export default ReportPreviewOverlay;
@@ -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(
<DashboardReportCtas
cycleClosed={false}
hasDailySnapshot={false}
hasEndCycleSnapshot={false}
onOpenDaily={onOpenDaily}
onOpenEndCycle={onOpenEndCycle}
/>
);
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(
<DashboardReportCtas
cycleClosed
hasDailySnapshot
hasEndCycleSnapshot={false}
onOpenDaily={vi.fn()}
onOpenEndCycle={vi.fn()}
/>
);
expect(screen.getByRole('button', { name: /lihat laporan harian/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /^laporan akhir siklus$/i })).toBeEnabled();
});
});
@@ -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(<ReportPreviewOverlay {...baseProps} />);
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(<ReportPreviewOverlay {...baseProps} kandangId={null} />);
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(<ReportPreviewOverlay {...baseProps} />);
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(<ReportPreviewOverlay {...baseProps} />);
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(<ReportPreviewOverlay {...baseProps} />);
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(<ReportPreviewOverlay {...baseProps} />);
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(<ReportPreviewOverlay {...baseProps} />);
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(<ReportPreviewOverlay {...baseProps} />);
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(<ReportPreviewOverlay {...baseProps} />);
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(<ReportPreviewOverlay {...baseProps} />);
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(<ReportPreviewOverlay {...baseProps} reportType="daily" />);
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(
<ReportPreviewOverlay
{...baseProps}
reportType="end_cycle"
cycleEndDate="2026-05-30"
/>
);
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(
<ReportPreviewOverlay
{...baseProps}
reportType="end_cycle"
cycleEndDate="2026-05-30"
/>
);
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(
<ReportPreviewOverlay
{...baseProps}
reportType="end_cycle"
cycleEndDate="2026-05-30"
/>
);
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();
});
});
+2 -2
View File
@@ -734,7 +734,7 @@ Pastikan respons Anda valid JSON tanpa teks markdown tambahan.`;
const fromDay = const fromDay =
timeframe === 'harian' timeframe === 'harian'
? (activeDay ?? currentDayVal) ? (activeDay ?? currentDayVal)
: Math.max(1, (currentDayVal || 1) - 6); : Math.max(0, (currentDayVal || 0) - 6);
const filename = buildPdfFilename({ const filename = buildPdfFilename({
reportName: topic, reportName: topic,
area: appCtx.selectedArea || 'Sukawarna', area: appCtx.selectedArea || 'Sukawarna',
@@ -777,7 +777,7 @@ Pastikan respons Anda valid JSON tanpa teks markdown tambahan.`;
cycleData?.startDate, cycleData?.startDate,
timeframe === 'harian' timeframe === 'harian'
? (activeDay ?? cycleData?.currentDay) ? (activeDay ?? cycleData?.currentDay)
: Math.max(1, (cycleData?.currentDay || 1) - 6), : Math.max(0, (cycleData?.currentDay || 0) - 6),
timeframe === 'harian' ? null : cycleData?.currentDay timeframe === 'harian' ? null : cycleData?.currentDay
)} )}
/> />
+19 -8
View File
@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { reportDayToDate } from '../../utils/reportDay.ts';
/** /**
* Identity block printed at the top of every exported AI Insight PDF. * 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 * 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. * 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 = ( export const cycleDayToDate = (
startDate: Date | string | null | undefined, startDate: Date | string | null | undefined,
day: number | null | undefined day: number | null | undefined
): Date | null => { ): 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); const start = startDate instanceof Date ? startDate : new Date(startDate);
if (Number.isNaN(start.getTime())) return null; if (Number.isNaN(start.getTime())) return null;
const result = new Date(start); return reportDayToDate(start, day);
result.setDate(result.getDate() + (day - 1));
return result;
}; };
const shortDate = (value: Date | null): string => const shortDate = (value: Date | null): string =>
@@ -89,7 +88,8 @@ export const describeDataDates = (
): string | null => { ): string | null => {
const from = cycleDayToDate(startDate, fromDay); const from = cycleDayToDate(startDate, fromDay);
if (!from) return null; 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); return to ? `${shortDate(from)} – ${shortDate(to)}` : shortDate(from);
}; };
@@ -123,7 +123,8 @@ export const isoDataDates = (
: null; : null;
const from = iso(cycleDayToDate(startDate, fromDay)); const from = iso(cycleDayToDate(startDate, fromDay));
if (!from) return null; 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; return to ? `${from}_sd_${to}` : from;
}; };
@@ -151,9 +152,11 @@ export const buildPdfFilename = (parts: {
periodSlug: string; periodSlug: string;
/** From `isoDataDates`. */ /** From `isoDataDates`. */
isoDates?: string | null; isoDates?: string | null;
/** Default AI Insight prefix. Daily report uses "Laporan". */
prefix?: string;
}): string => { }): string => {
const segments = [ const segments = [
'AI-Insight', parts.prefix || 'AI-Insight',
slug(parts.reportName), slug(parts.reportName),
slug(parts.area), slug(parts.area),
slug(parts.kandangLabel), slug(parts.kandangLabel),
@@ -194,6 +197,8 @@ interface PdfReportHeaderProps {
cycleId?: string | null; cycleId?: string | null;
/** Calendar range the data covers, when the card knows it. */ /** Calendar range the data covers, when the card knows it. */
dataDateLabel?: string | null; 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<PdfReportHeaderProps> = ({ export const PdfReportHeader: React.FC<PdfReportHeaderProps> = ({
@@ -203,6 +208,7 @@ export const PdfReportHeader: React.FC<PdfReportHeaderProps> = ({
periodLabel, periodLabel,
cycleId, cycleId,
dataDateLabel, dataDateLabel,
children,
}) => ( }) => (
<div <div
className={PDF_HEADER_CLASS} className={PDF_HEADER_CLASS}
@@ -237,6 +243,11 @@ export const PdfReportHeader: React.FC<PdfReportHeaderProps> = ({
<div style={{ fontSize: '12px', color: '#6b7280', marginTop: '2px' }}> <div style={{ fontSize: '12px', color: '#6b7280', marginTop: '2px' }}>
Dicetak: {formatDate(new Date())} Dicetak: {formatDate(new Date())}
</div> </div>
{children ? (
<div style={{ fontSize: '22px', color: '#111827', marginTop: '12px', fontWeight: 700 }}>
{children}
</div>
) : null}
</div> </div>
</div> </div>
); );
@@ -13,14 +13,18 @@ import {
const START = '2026-05-21T17:00:00.000Z'; // cycle CYCLE-JBW-2026-05-22 const START = '2026-05-21T17:00:00.000Z'; // cycle CYCLE-JBW-2026-05-22
describe('PdfReportHeader helpers', () => { describe('PdfReportHeader helpers', () => {
it('maps cycle day 1 to the start date', () => { it('maps cycle day 0 to the start date', () => {
expect(describeDataDates(START, 1)).toBe('22 Mei 2026'); 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', () => { 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', () => { 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', () => { it('omits the date when the cycle start is unknown', () => {
expect(describeDataDates(null, 12)).toBeNull(); expect(describeDataDates(null, 12)).toBeNull();
@@ -60,7 +64,7 @@ describe('buildPdfFilename', () => {
isoDates: isoDataDates(START, 42, 48), isoDates: isoDataDates(START, 42, 48),
}) })
).toBe( ).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', periodSlug: 'Harian',
isoDates: isoDataDates(START, 48), 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', () => { 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'); ).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', () => { it('two coops on the same day no longer collide', () => {
const base = { const base = {
reportName: 'Hitung-Ayam', reportName: 'Hitung-Ayam',
+945 -97
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -27,6 +27,7 @@
] ]
}, },
"dependencies": { "dependencies": {
"exceljs": "^4.4.0",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
+42
View File
@@ -14,6 +14,10 @@ import {
SensorWeightSummaryResponse, SensorWeightSummaryResponse,
ScalePredictionRangeResponse, ScalePredictionRangeResponse,
} from '../types.ts'; } from '../types.ts';
import type {
DashboardReportPayload,
DashboardReportSnapshotResponse,
} from '../types/dashboardReport.ts';
// --- Configuration --- // --- Configuration ---
const API_BASE_URL = 'https://dashboard.cpsp.id'; const API_BASE_URL = 'https://dashboard.cpsp.id';
@@ -1480,6 +1484,44 @@ export const apiClient = {
return await response.json(); return await response.json();
}, },
// --- Dashboard daily report snapshots ---
getDashboardReportSnapshot: async (
cycleId: string,
kandangId: number,
reportType: DashboardReportPayload['reportType'] = 'daily'
): Promise<DashboardReportSnapshotResponse> => {
const response = await fetch(
`${DB_API_BASE_URL}/dashboard-report-snapshots/${cycleId}?kandangId=${kandangId}&reportType=${reportType || 'daily'}`
);
if (response.status === 404) {
return { success: false, data: null, message: 'No snapshot found' };
}
if (!response.ok) {
await throwApiError(response);
}
return await response.json();
},
upsertDashboardReportSnapshot: async (input: {
cycleId: string;
kandangId: number;
upToDay: number;
payload: DashboardReportPayload;
generatedAt?: string;
reportType?: DashboardReportPayload['reportType'];
}): Promise<DashboardReportSnapshotResponse> => {
const response = await fetch(`${DB_API_BASE_URL}/dashboard-report-snapshots`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!response.ok) {
await throwApiError(response);
}
return await response.json();
},
// --- AI Insights API Functions --- // --- AI Insights API Functions ---
/** /**
+94
View File
@@ -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;
}
+230
View File
@@ -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<number, number>,
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);
});
});
+90
View File
@@ -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);
});
});
+49
View File
@@ -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);
});
});
+26
View File
@@ -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);
});
});
+36
View File
@@ -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);
});
});
+162
View File
@@ -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',
]);
});
});
+125
View File
@@ -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();
});
});
@@ -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');
});
});
+94
View File
@@ -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,
});
});
});
+317
View File
@@ -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<number, number>;
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<number, number>,
records?: ReportChickenCountRecord[]
): Record<number, number> {
const map: Record<number, number> = { ...(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<number, ReportWeightDay>();
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 || [] }
: {}),
};
}
+52 -28
View File
@@ -42,29 +42,39 @@ export function normalizeFeedSackColumnConfigs(
return columns.map(normalizeFeedSackColumnConfig); return columns.map(normalizeFeedSackColumnConfig);
} }
export function getUsageCameraNamesForKandang( type FeedSackColumnType = 'inbound' | 'usage' | 'outbound';
function columnsOfType(
columns: FeedSackColumnConfig[], 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, kandangId?: number | null,
kandangName?: string | null kandangName?: string | null
): string[] { ): string[] {
const usageColumns = columns.filter((column) => column.type === 'usage'); const typed = columnsOfType(columns, type);
if (kandangId !== null && kandangId !== undefined) { if (kandangId !== null && kandangId !== undefined) {
const mappedCameras = usageColumns const mappedCameras = typed
.filter((column) => column.kandangId === kandangId) .filter((column) => column.kandangId === kandangId)
.map((column) => column.cameraName); .map((column) => column.cameraName);
if (mappedCameras.length > 0) return mappedCameras;
if (mappedCameras.length > 0) { const inferredCameras = typed
return mappedCameras;
}
const inferredCameras = usageColumns
.filter((column) => inferKandangIdFromCameraName(column.cameraName) === kandangId) .filter((column) => inferKandangIdFromCameraName(column.cameraName) === kandangId)
.map((column) => column.cameraName); .map((column) => column.cameraName);
if (inferredCameras.length > 0) return inferredCameras;
if (inferredCameras.length > 0) {
return inferredCameras;
}
} }
if (kandangName) { if (kandangName) {
@@ -72,41 +82,55 @@ export function getUsageCameraNamesForKandang(
const kandangPrefix = kandangNameLower.replace(/\s+/g, '_'); const kandangPrefix = kandangNameLower.replace(/\s+/g, '_');
if (kandangNameLower.includes('atas')) { if (kandangNameLower.includes('atas')) {
const atasCameras = usageColumns const atasCameras = typed
.filter( .filter(
(column) => (column) =>
column.cameraName.toLowerCase().includes('atas') || column.cameraName.toLowerCase().includes('atas') ||
column.cameraName.toLowerCase().includes('kiri') column.cameraName.toLowerCase().includes('kiri')
) )
.map((column) => column.cameraName); .map((column) => column.cameraName);
if (atasCameras.length > 0) return atasCameras;
if (atasCameras.length > 0) {
return atasCameras;
}
} }
if (kandangNameLower.includes('bawah')) { if (kandangNameLower.includes('bawah')) {
const bawahCameras = usageColumns const bawahCameras = typed
.filter( .filter(
(column) => (column) =>
column.cameraName.toLowerCase().includes('bawah') || column.cameraName.toLowerCase().includes('bawah') ||
column.cameraName.toLowerCase().includes('kanan') column.cameraName.toLowerCase().includes('kanan')
) )
.map((column) => column.cameraName); .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)) .filter((column) => column.cameraName.toLowerCase().startsWith(kandangPrefix))
.map((column) => column.cameraName); .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);
} }
+60 -25
View File
@@ -2,6 +2,52 @@ import { jsPDF } from 'jspdf';
import html2canvas from 'html2canvas'; import html2canvas from 'html2canvas';
import { revealPdfHeaders } from '../components/shared/PdfReportHeader.tsx'; 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. * 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 } options?: { widthPx?: number }
): Promise<void> => { ): Promise<void> => {
const widthPx = options?.widthPx ?? 800; 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. */ /** 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, { const canvas = await html2canvas(element, {
scale, scale,
@@ -45,20 +92,20 @@ export const exportCardToPdf = async (
const top = clonedElement.getBoundingClientRect().top; const top = clonedElement.getBoundingClientRect().top;
clonedElement.querySelectorAll<HTMLElement>('[data-pdf-block]').forEach((node) => { clonedElement.querySelectorAll<HTMLElement>('[data-pdf-block]').forEach((node) => {
const rect = node.getBoundingClientRect(); 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 pageWidthMm = doc.internal.pageSize.getWidth();
const pageHeightMm = doc.internal.pageSize.getHeight(); const pageHeightMm = doc.internal.pageSize.getHeight();
const marginMm = 10; const contentWidthMm = pageWidthMm - MARGIN_MM * 2;
const contentWidthMm = pageWidthMm - marginMm * 2; const usableHeightMm = pageHeightMm - MARGIN_MM * 2;
const usableHeightMm = pageHeightMm - marginMm * 2;
const mmPerPx = contentWidthMm / canvas.width; const mmPerPx = contentWidthMm / canvas.width;
const usableHeightPx = usableHeightMm / mmPerPx; const usableHeightPx = usableHeightMm / mmPerPx;
const safeBreaks = pdfBlockBreakOffsets(blockRects, usableHeightCssPx(widthPx));
// Clone-CSS px -> canvas px, de-duplicated and ordered. // Clone-CSS px -> canvas px, de-duplicated and ordered.
const breakPoints = [...new Set(safeBreaks.map((value) => Math.round(value * scale)))] const breakPoints = [...new Set(safeBreaks.map((value) => Math.round(value * scale)))]
@@ -69,20 +116,8 @@ export const exportCardToPdf = async (
let firstPage = true; let firstPage = true;
while (cursor < canvas.height) { while (cursor < canvas.height) {
const remaining = canvas.height - cursor; const sliceHeight = choosePdfSliceHeight(cursor, canvas.height, usableHeightPx, breakPoints);
let sliceHeight: number; if (sliceHeight <= 0) break;
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 slice = document.createElement('canvas'); const slice = document.createElement('canvas');
slice.width = canvas.width; slice.width = canvas.width;
@@ -95,10 +130,10 @@ export const exportCardToPdf = async (
if (!firstPage) doc.addPage(); if (!firstPage) doc.addPage();
doc.addImage( doc.addImage(
slice.toDataURL('image/png'), slice.toDataURL('image/jpeg', jpegQuality),
'PNG', 'JPEG',
marginMm, MARGIN_MM,
marginMm, MARGIN_MM,
contentWidthMm, contentWidthMm,
slice.height * mmPerPx slice.height * mmPerPx
); );
+59
View File
@@ -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);
}
+14
View File
@@ -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));
}
+52
View File
@@ -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;
}
+209
View File
@@ -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<ExcelJS.Workbook> {
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<void> {
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);
}
+69
View File
@@ -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<string, ScalesKpiRow> =>
!!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<T>(
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;
}
+10
View File
@@ -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';
+86
View File
@@ -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 | undefined>): 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),
};
}