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
CI / lint-and-test (push) Canceled after 0s
This commit is contained in:
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';
|
||||||
@@ -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 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Generated
+945
-97
File diff suppressed because it is too large.
Load diff
@@ -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",
|
||||||
|
|||||||
@@ -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 ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user