first commit
CI / lint-and-test (push) Canceled after 0s

This commit is contained in:
Alberto-Audrix committed 2026-07-28 08:55:05 +07:00
commit 32a36cceff
444 files changed
+67186

No files matched your search

+240
View File
@@ -0,0 +1,240 @@
import React, { useState, useEffect } from 'react';
import { FeedSackColumnConfig, FEED_SACK_COLUMN_COLORS } from '../../types/counting.ts';
interface ColumnConfigModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (columns: FeedSackColumnConfig[]) => void;
columns: FeedSackColumnConfig[];
availableCameras: string[];
}
const ColumnConfigModal: React.FC<ColumnConfigModalProps> = ({
isOpen,
onClose,
onSave,
columns,
availableCameras,
}) => {
const [editColumns, setEditColumns] = useState<FeedSackColumnConfig[]>(columns);
useEffect(() => {
if (isOpen) {
setEditColumns(columns);
}
}, [columns, isOpen]);
if (!isOpen) return null;
const handleAddColumn = () => {
const usageCount = editColumns.filter((column) => column.type === 'usage').length;
const nextColor = usageCount % 2 === 0 ? '#eab308' : '#a855f7';
const usedCameras = new Set(editColumns.map((c) => c.cameraName));
const availableCamera =
availableCameras.find((c) => !usedCameras.has(c)) || availableCameras[0] || '';
const newColumn: FeedSackColumnConfig = {
id: `col-${Date.now()}`,
label: '',
cameraName: availableCamera,
color: nextColor,
type: 'usage',
kandangId: null,
};
setEditColumns([...editColumns, newColumn]);
};
const handleRemoveColumn = (id: string) => {
setEditColumns(editColumns.filter((c) => c.id !== id));
};
const handleUpdateColumn = (id: string, field: keyof FeedSackColumnConfig, value: string) => {
setEditColumns(editColumns.map((c) => (c.id === id ? { ...c, [field]: value } : c)));
};
const handleSave = () => {
const validColumns = editColumns.filter((c) => c.cameraName && c.label.trim() && c.type);
const hasInbound = validColumns.some((c) => c.type === 'inbound');
if (!hasInbound) {
alert('Minimal harus ada satu kolom "Karung Masuk"');
return;
}
onSave(validColumns);
onClose();
};
return (
<div
className="fixed inset-0 bg-black bg-opacity-60 flex justify-center items-center z-50 p-4"
onClick={onClose}
>
<div
className="bg-white rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="p-6 overflow-y-auto flex-1">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-800">Konfigurasi Kolom</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl">
&times;
</button>
</div>
<div className="space-y-4">
{editColumns.map((col, index) => (
<div key={col.id} className="border border-gray-200 rounded-lg p-4 bg-gray-50">
<div className="flex justify-between items-center mb-3">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-600">Kolom {index + 1}</span>
<span
className={`text-xs px-2 py-0.5 rounded-full font-medium ${
col.type === 'inbound'
? 'bg-green-100 text-green-700'
: col.type === 'usage'
? 'bg-yellow-100 text-yellow-700'
: 'bg-red-100 text-red-700'
}`}
>
{col.type === 'inbound'
? 'Masuk'
: col.type === 'usage'
? 'Dituang'
: 'Keluar'}
</span>
</div>
<button
onClick={() => handleRemoveColumn(col.id)}
className="text-red-500 hover:text-red-700 text-sm font-medium"
>
<i className="fa-solid fa-trash mr-1"></i>Hapus
</button>
</div>
<div className="mb-3">
<label className="block text-xs font-medium text-gray-700 mb-1">Label</label>
<input
type="text"
value={col.label}
onChange={(e) => handleUpdateColumn(col.id, 'label', e.target.value)}
placeholder="e.g., Karung Masuk"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="mb-3">
<label className="block text-xs font-medium text-gray-700 mb-1">Kamera</label>
<select
value={col.cameraName}
onChange={(e) => handleUpdateColumn(col.id, 'cameraName', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
>
<option value="">-- Pilih Kamera --</option>
{availableCameras.map((cam) => (
<option key={cam} value={cam}>
{cam}
</option>
))}
</select>
</div>
<div className="mb-3">
<label className="block text-xs font-medium text-gray-700 mb-1">Warna</label>
<div className="flex gap-2">
{FEED_SACK_COLUMN_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => handleUpdateColumn(col.id, 'color', color)}
className={`w-7 h-7 rounded-full border-2 transition-all ${
col.color === color
? 'border-gray-800 scale-110'
: 'border-transparent hover:border-gray-300'
}`}
style={{ backgroundColor: color }}
/>
))}
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Tipe Kolom</label>
<div className="flex gap-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name={`type-${col.id}`}
value="inbound"
checked={col.type === 'inbound'}
onChange={() => handleUpdateColumn(col.id, 'type', 'inbound')}
className="w-4 h-4 text-green-600 focus:ring-green-500"
/>
<span className="text-sm text-gray-700">
<i className="fa-solid fa-arrow-down text-green-600 mr-1"></i>
Karung Masuk
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name={`type-${col.id}`}
value="usage"
checked={col.type === 'usage'}
onChange={() => handleUpdateColumn(col.id, 'type', 'usage')}
className="w-4 h-4 text-yellow-600 focus:ring-yellow-500"
/>
<span className="text-sm text-gray-700">
<i className="fa-solid fa-arrow-up text-yellow-600 mr-1"></i>
Karung Dituang
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name={`type-${col.id}`}
value="outbound"
checked={col.type === 'outbound'}
onChange={() => handleUpdateColumn(col.id, 'type', 'outbound')}
className="w-4 h-4 text-red-600 focus:ring-red-500"
/>
<span className="text-sm text-gray-700">
<i className="fa-solid fa-arrow-right text-red-600 mr-1"></i>
Karung Keluar
</span>
</label>
</div>
</div>
</div>
))}
</div>
<button
onClick={handleAddColumn}
className="mt-4 w-full py-2 border-2 border-dashed border-gray-300 rounded-lg text-gray-500 hover:text-gray-700 hover:border-gray-400 transition-colors text-sm font-medium"
>
<i className="fa-solid fa-plus mr-2"></i>Tambah Kolom
</button>
</div>
<div className="bg-gray-50 px-6 py-4 rounded-b-xl flex justify-end space-x-3 border-t border-gray-200">
<button
onClick={onClose}
className="bg-gray-200 text-gray-700 px-4 py-2 rounded-lg hover:bg-gray-300 transition duration-300 font-medium"
>
Batal
</button>
<button
onClick={handleSave}
className="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition duration-300 font-medium shadow-sm"
>
Simpan
</button>
</div>
</div>
</div>
);
};
export default ColumnConfigModal;
+67
View File
@@ -0,0 +1,67 @@
import React from 'react';
import { FeedSackLogEntry } from '../../types.ts';
import { SackIcon } from '../icons/Icons.tsx';
interface FeedActivityLogProps {
data: FeedSackLogEntry[];
}
const FeedActivityLog: React.FC<FeedActivityLogProps> = ({ data }) => {
const formatDate = (date: Date) => {
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (date.toDateString() === today.toDateString()) {
return 'Hari Ini';
}
if (date.toDateString() === yesterday.toDateString()) {
return 'Kemarin';
}
return date.toLocaleDateString('id-ID', { day: 'numeric', month: 'short' });
};
return (
<div className="bg-white p-6 rounded-xl shadow-sm">
<h2 className="text-xl font-bold mb-4">Aktivitas Terbaru</h2>
{data.length > 0 ? (
<div className="space-y-3 max-h-96 overflow-y-auto pr-2">
{data.slice(0, 15).map((log) => (
<div
key={log.id}
className="flex items-center space-x-3 p-3 rounded-lg hover:bg-gray-50 transition-colors"
>
<div
className={`flex items-center justify-center h-10 w-10 rounded-lg ${log.type === 'inbound' ? 'bg-green-100 text-green-600' : 'bg-orange-100 text-orange-600'}`}
>
<i
className={`fa-solid ${log.type === 'inbound' ? 'fa-arrow-down' : 'fa-arrow-up'} text-lg`}
></i>
</div>
<div className="flex-grow min-w-0">
<p className="font-semibold text-gray-800 text-sm">
{log.type === 'inbound' ? 'Karung Masuk Terdeteksi' : 'Karung Dituang Terdeteksi'}
</p>
<p className="text-xs text-gray-500">
{log.count} karung di {log.location}
</p>
</div>
<div className="flex-shrink-0">
<span className="inline-block px-2.5 py-1 text-xs font-medium bg-gray-100 text-gray-700 rounded-full">
{formatDate(log.timestamp)}
</span>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-10">
<SackIcon className="text-5xl text-gray-300 mx-auto" />
<p className="mt-3 text-gray-500">Tidak ada aktivitas terdeteksi.</p>
</div>
)}
</div>
);
};
export default FeedActivityLog;
@@ -0,0 +1,642 @@
import React, { useMemo, useState } from 'react';
import {
FeedSackLogEntry,
ManualFeedSackEntry,
FeedSackColumnConfig,
} from '../../types/counting.ts';
interface DailySummary {
day: number;
date: Date;
dateFormatted: string;
cameraCounts: {
[cameraName: string]: {
manual: number;
iot: number;
};
};
}
type SortOrder = 'asc' | 'desc';
interface FeedDailySummaryTableProps {
iotData: FeedSackLogEntry[];
manualEntries: ManualFeedSackEntry[];
cycleStartDate: Date;
currentDay: number;
columnConfig: FeedSackColumnConfig[];
onSaveManualEntry: (
cameraName: string,
count: number,
date: Date,
keterangan?: string
) => Promise<void>;
}
const FeedDailySummaryTable: React.FC<FeedDailySummaryTableProps> = ({
iotData,
manualEntries,
cycleStartDate,
currentDay,
columnConfig,
onSaveManualEntry,
}) => {
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
// Row-level edit state (Manajemen Data pattern)
const [editingDay, setEditingDay] = useState<number | null>(null);
const [editValues, setEditValues] = useState<{
manualCounts: Record<string, number>;
keteranganValues: Record<string, string>;
}>({
manualCounts: {},
keteranganValues: {},
});
const [validationErrors, setValidationErrors] = useState<{
manualCounts?: Record<string, string>;
keteranganValues?: Record<string, string>;
_general?: string;
}>({});
const [isSaving, setIsSaving] = useState(false);
const cameraNames = useMemo(() => columnConfig.map((c) => c.cameraName), [columnConfig]);
// Refs for scrolling to sections
const inboundRef = React.useRef<HTMLDivElement>(null);
const usageRef = React.useRef<HTMLDivElement>(null);
const outboundRef = React.useRef<HTMLDivElement>(null);
const scrollToSection = (ref: React.RefObject<HTMLDivElement>) => {
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
// Group columns by type for 3-box layout
const groupedColumns = useMemo(() => {
return {
inbound: columnConfig.filter((col) => col.type === 'inbound'),
usage: columnConfig.filter((col) => col.type === 'usage'),
outbound: columnConfig.filter((col) => col.type === 'outbound'),
};
}, [columnConfig]);
// Aggregate IOT + Manual data
const aggregatedData = useMemo(() => {
const cycleStart = new Date(cycleStartDate);
cycleStart.setHours(0, 0, 0, 0);
const dailyMap = new Map<
string,
{
day: number;
date: Date;
cameraCounts: { [cameraName: string]: { manual: number; iot: number } };
}
>();
// Process IOT data
iotData.forEach((log) => {
const logDate = new Date(log.timestamp);
logDate.setHours(0, 0, 0, 0);
const dayNumber = Math.floor(
(logDate.getTime() - cycleStart.getTime()) / (1000 * 60 * 60 * 24)
);
if (dayNumber < 0 || dayNumber > currentDay) return;
const dateKey = logDate.toISOString().split('T')[0];
if (!dailyMap.has(dateKey)) {
const counts: { [key: string]: { manual: number; iot: number } } = {};
cameraNames.forEach((name) => {
counts[name] = { manual: 0, iot: 0 };
});
dailyMap.set(dateKey, { day: dayNumber, date: logDate, cameraCounts: counts });
}
const entry = dailyMap.get(dateKey)!;
if (log.cameraName && cameraNames.includes(log.cameraName)) {
entry.cameraCounts[log.cameraName].iot += log.count;
}
});
// Process manual data
manualEntries.forEach((manual) => {
const manualDate = new Date(manual.date);
if (isNaN(manualDate.getTime())) {
return;
}
manualDate.setHours(0, 0, 0, 0);
const dayNumber = Math.floor(
(manualDate.getTime() - cycleStart.getTime()) / (1000 * 60 * 60 * 24)
);
if (dayNumber < 0 || dayNumber > currentDay) {
return;
}
const dateKey = manualDate.toISOString().split('T')[0];
if (!dailyMap.has(dateKey)) {
const counts: { [key: string]: { manual: number; iot: number } } = {};
cameraNames.forEach((name) => {
counts[name] = { manual: 0, iot: 0 };
});
dailyMap.set(dateKey, { day: dayNumber, date: manualDate, cameraCounts: counts });
}
const entry = dailyMap.get(dateKey)!;
if (manual.cameraName && cameraNames.includes(manual.cameraName)) {
entry.cameraCounts[manual.cameraName].manual = manual.count;
}
});
// Fill missing days
for (let d = 0; d <= currentDay; d++) {
const targetDate = new Date(cycleStart);
targetDate.setDate(targetDate.getDate() + d);
const dateKey = targetDate.toISOString().split('T')[0];
if (!dailyMap.has(dateKey)) {
const counts: { [key: string]: { manual: number; iot: number } } = {};
cameraNames.forEach((name) => {
counts[name] = { manual: 0, iot: 0 };
});
dailyMap.set(dateKey, { day: d, date: targetDate, cameraCounts: counts });
}
}
// Convert to array with formatted dates
const result = Array.from(dailyMap.values()).map((entry) => ({
...entry,
dateFormatted: entry.date.toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
}),
}));
return result;
}, [iotData, manualEntries, cycleStartDate, currentDay, cameraNames]);
// Sort data
const sortedData = useMemo(() => {
const sorted = [...aggregatedData].sort((a, b) => a.day - b.day);
return sortOrder === 'desc' ? sorted.reverse() : sorted;
}, [aggregatedData, sortOrder]);
const toggleSortOrder = () => {
setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc'));
};
// Row edit handlers (Manajemen Data pattern)
const handleEdit = (day: number, summary: DailySummary, cameras: FeedSackColumnConfig[]) => {
const manualCounts: Record<string, number> = {};
const keteranganValues: Record<string, string> = {};
cameras.forEach((col) => {
const counts = summary.cameraCounts[col.cameraName] || { manual: 0, iot: 0 };
manualCounts[col.cameraName] = counts.manual;
// Load existing keterangan from manualEntries
const entry = manualEntries.find(
(e) =>
e.date === summary.date.toISOString().split('T')[0] && e.cameraName === col.cameraName
);
keteranganValues[col.cameraName] = entry?.keterangan || '';
});
setEditValues({ manualCounts, keteranganValues });
setEditingDay(day);
setValidationErrors({});
};
const handleCancel = () => {
setEditingDay(null);
setEditValues({ manualCounts: {}, keteranganValues: {} });
setValidationErrors({});
};
const validateEditValues = (cameras: FeedSackColumnConfig[]): boolean => {
const errors: {
manualCounts?: Record<string, string>;
keteranganValues?: Record<string, string>;
} = {};
// Validate manual counts
cameras.forEach((col) => {
const count = editValues.manualCounts[col.cameraName];
if (count < 0) {
if (!errors.manualCounts) errors.manualCounts = {};
errors.manualCounts[col.cameraName] = 'Tidak boleh negatif';
}
if (!Number.isInteger(count)) {
if (!errors.manualCounts) errors.manualCounts = {};
errors.manualCounts[col.cameraName] = 'Harus bilangan bulat';
}
});
// Validate keterangan length
cameras.forEach((col) => {
const keterangan = editValues.keteranganValues[col.cameraName];
if (keterangan && keterangan.length > 200) {
if (!errors.keteranganValues) errors.keteranganValues = {};
errors.keteranganValues[col.cameraName] = 'Maksimal 200 karakter';
}
});
setValidationErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSave = async (date: Date, cameras: FeedSackColumnConfig[]) => {
if (!validateEditValues(cameras)) return;
setIsSaving(true);
try {
// Save all cameras in parallel
const savePromises = cameras.map((col) =>
onSaveManualEntry(
col.cameraName,
editValues.manualCounts[col.cameraName],
date,
editValues.keteranganValues[col.cameraName]
)
);
await Promise.all(savePromises);
// Clear edit state
setEditingDay(null);
setEditValues({ manualCounts: {}, keteranganValues: {} });
setValidationErrors({});
} catch (error) {
console.error('Failed to save:', error);
setValidationErrors({ _general: 'Gagal menyimpan. Coba lagi.' });
} finally {
setIsSaving(false);
}
};
// Render camera columns helper
const renderCameraColumns = (
col: FeedSackColumnConfig,
summary: DailySummary,
isEditing: boolean
) => {
const counts = summary.cameraCounts[col.cameraName] || { manual: 0, iot: 0 };
// Calculate percentage
let percentage = 0;
let percentageDisplay = '-';
if (counts.manual > 0 && counts.iot > 0) {
const smaller = Math.min(counts.manual, counts.iot);
const larger = Math.max(counts.manual, counts.iot);
percentage = (smaller / larger) * 100;
percentageDisplay = `${percentage.toFixed(1)}%`;
} else if (counts.manual === 0 && counts.iot === 0) {
percentage = 100;
percentageDisplay = '100%';
}
// Get keterangan
const entry = manualEntries.find(
(e) => e.date === summary.date.toISOString().split('T')[0] && e.cameraName === col.cameraName
);
const keterangan = entry?.keterangan || '';
return (
<React.Fragment key={`${col.id}-${summary.day}`}>
{/* Manual */}
<td className="px-2 py-3 text-center border-l bg-blue-50">
{isEditing ? (
<div className="flex flex-col items-center gap-1">
<input
type="number"
value={editValues.manualCounts[col.cameraName] || 0}
onChange={(e) =>
setEditValues({
...editValues,
manualCounts: {
...editValues.manualCounts,
[col.cameraName]: parseInt(e.target.value) || 0,
},
})
}
className="w-16 px-2 py-1 border rounded text-center text-sm"
/>
{validationErrors.manualCounts?.[col.cameraName] && (
<div className="text-xs text-red-600">
{validationErrors.manualCounts[col.cameraName]}
</div>
)}
</div>
) : (
<span className="font-semibold">{counts.manual}</span>
)}
</td>
{/* IOT */}
<td className="px-2 py-3 text-center bg-gray-100">
<span className="text-gray-700">{counts.iot}</span>
</td>
{/* Percentage */}
<td className="px-2 py-3 text-center bg-purple-50">
<span
className={`text-xs font-medium ${
percentage >= 95 && percentage <= 105
? 'text-green-600'
: percentage >= 80 && percentage <= 120
? 'text-yellow-600'
: 'text-red-600'
}`}
>
{percentageDisplay}
</span>
</td>
{/* Keterangan */}
<td className="px-2 py-3 bg-yellow-50">
{isEditing ? (
<div className="flex flex-col gap-1">
<textarea
value={editValues.keteranganValues[col.cameraName] || ''}
onChange={(e) =>
setEditValues({
...editValues,
keteranganValues: {
...editValues.keteranganValues,
[col.cameraName]: e.target.value,
},
})
}
rows={4}
maxLength={200}
placeholder="Catatan..."
className="w-full min-w-[200px] px-2 py-1 border rounded text-sm resize-none"
/>
<div className="text-xs text-gray-500">
{(editValues.keteranganValues[col.cameraName] || '').length}/200
</div>
{validationErrors.keteranganValues?.[col.cameraName] && (
<div className="text-xs text-red-600">
{validationErrors.keteranganValues[col.cameraName]}
</div>
)}
</div>
) : (
<div className="text-sm text-gray-700 max-w-xs truncate" title={keterangan}>
{keterangan || '-'}
</div>
)}
</td>
</React.Fragment>
);
};
// Render action buttons helper
const renderActions = (
summary: DailySummary,
cameras: FeedSackColumnConfig[],
isEditing: boolean
) => {
if (isEditing) {
return (
<div className="flex gap-1 justify-center">
<button
onClick={() => handleSave(summary.date, cameras)}
disabled={isSaving}
className="px-2 py-1 bg-green-500 text-white rounded hover:bg-green-600 disabled:opacity-50 transition-colors"
title="Simpan"
>
{isSaving ? (
<i className="fa-solid fa-spinner fa-spin"></i>
) : (
<i className="fa-solid fa-check"></i>
)}
</button>
<button
onClick={handleCancel}
disabled={isSaving}
className="px-2 py-1 bg-gray-500 text-white rounded hover:bg-gray-600 disabled:opacity-50 transition-colors"
title="Batal"
>
<i className="fa-solid fa-times"></i>
</button>
</div>
);
}
return (
<button
onClick={() => handleEdit(summary.day, summary, cameras)}
className="px-2 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
title="Edit"
>
<i className="fa-solid fa-edit"></i>
</button>
);
};
// Render box helper - creates one of the 3 vertical boxes
const renderBox = (
title: string,
emoji: string,
headerColor: string,
cameras: FeedSackColumnConfig[],
ref: React.RefObject<HTMLDivElement>
) => {
if (cameras.length === 0) return null;
return (
<div className="mb-6" ref={ref} style={{ scrollMarginTop: '100px' }}>
{/* Header */}
<div
className="px-6 py-3 rounded-t-xl font-bold text-black text-lg flex items-center gap-2"
style={{ backgroundColor: headerColor }}
>
<span>{emoji}</span>
<span>{title}</span>
<span className="text-sm font-normal opacity-90">({cameras.length} kamera)</span>
</div>
{/* Table container with scroll */}
<div className="bg-white rounded-b-xl shadow-sm overflow-hidden">
<div className="overflow-x-auto" style={{ maxHeight: '700px', overflowY: 'auto' }}>
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200 sticky top-0 z-10">
<tr>
<th rowSpan={2} className="px-4 py-3 text-left font-semibold text-gray-700">
Hari
</th>
<th rowSpan={2} className="px-4 py-3 text-left font-semibold text-gray-700">
Tanggal
</th>
{/* Camera columns */}
{cameras.map((col) => (
<th
key={col.id}
colSpan={4}
className="px-4 py-2 text-center font-semibold border-l"
style={{ color: col.color }}
>
{col.label}
<div className="text-xs font-normal text-gray-500">{col.cameraName}</div>
</th>
))}
<th rowSpan={2} className="px-4 py-3 text-center font-semibold text-gray-700">
Aksi
</th>
</tr>
<tr>
{cameras.map((col) => (
<React.Fragment key={`${col.id}-sub`}>
<th className="px-2 py-2 text-center text-xs font-semibold bg-blue-50 text-gray-700">
Manual
</th>
<th className="px-2 py-2 text-center text-xs font-semibold bg-gray-100 text-gray-700">
IOT
</th>
<th className="px-2 py-2 text-center text-xs font-semibold bg-purple-50 text-gray-700">
%
</th>
<th className="px-2 py-2 text-center text-xs font-semibold bg-yellow-50 text-gray-700">
Keterangan
</th>
</React.Fragment>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sortedData.map((summary) => {
const isEditing = editingDay === summary.day;
return (
<tr
key={summary.day}
className={isEditing ? 'bg-blue-50' : 'hover:bg-gray-50 transition-colors'}
>
{/* Day */}
<td className="px-4 py-3">
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 text-blue-800 font-bold text-xs">
{summary.day}
</span>
</td>
{/* Date */}
<td className="px-4 py-3 text-gray-700">{summary.dateFormatted}</td>
{/* Camera data columns */}
{cameras.map((col) => renderCameraColumns(col, summary, isEditing))}
{/* Actions */}
<td className="px-4 py-3 text-center">
{renderActions(summary, cameras, isEditing)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
);
};
if (aggregatedData.length === 0) {
return (
<div className="bg-white p-6 rounded-xl shadow-sm">
<h2 className="text-xl font-bold mb-4">Rincian Harian Karung Pakan</h2>
<div className="h-60 flex items-center justify-center">
<div className="text-center text-gray-500">
<i className="fa-solid fa-table text-5xl text-gray-300 mb-3"></i>
<p className="font-medium text-gray-600">Tidak ada data</p>
<p className="text-sm text-gray-500 mt-1">
Belum ada data karung pakan untuk siklus ini
</p>
</div>
</div>
</div>
);
}
return (
<div className="bg-gray-50 p-6 rounded-xl">
{/* Header */}
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-800">Rincian Harian Karung Pakan</h2>
<button
onClick={toggleSortOrder}
className="flex items-center gap-2 px-4 py-2 bg-white hover:bg-gray-100 rounded-lg text-sm font-medium shadow-sm transition-colors"
>
<i
className={`fa-solid ${
sortOrder === 'asc' ? 'fa-arrow-up-short-wide' : 'fa-arrow-down-short-wide'
}`}
></i>
<span>{sortOrder === 'asc' ? 'Terlama' : 'Terbaru'}</span>
</button>
</div>
{/* Sticky Quick Navigation Bar (Excel-style freeze) */}
<div className="sticky top-0 z-20 -mx-6 mb-6 bg-white border-y border-gray-200 shadow-md">
<div className="px-6 py-4">
<div className="flex gap-2 justify-center">
{groupedColumns.inbound.length > 0 && (
<button
onClick={() => scrollToSection(inboundRef)}
className="flex-1 max-w-xs flex items-center justify-center gap-2 px-6 py-3 bg-green-50 hover:bg-green-100 border-2 border-green-300 text-green-900 rounded-lg font-semibold shadow-sm transition-all hover:shadow-md active:scale-95"
>
<span className="text-xl">🟢</span>
<span>Karung Masuk</span>
<i className="fa-solid fa-chevron-down text-xs ml-1"></i>
</button>
)}
{groupedColumns.usage.length > 0 && (
<button
onClick={() => scrollToSection(usageRef)}
className="flex-1 max-w-xs flex items-center justify-center gap-2 px-6 py-3 bg-orange-50 hover:bg-orange-100 border-2 border-orange-300 text-orange-900 rounded-lg font-semibold shadow-sm transition-all hover:shadow-md active:scale-95"
>
<span className="text-xl">🟠</span>
<span>Karung Dituang</span>
<i className="fa-solid fa-chevron-down text-xs ml-1"></i>
</button>
)}
{groupedColumns.outbound.length > 0 && (
<button
onClick={() => scrollToSection(outboundRef)}
className="flex-1 max-w-xs flex items-center justify-center gap-2 px-6 py-3 bg-red-50 hover:bg-red-100 border-2 border-red-300 text-red-900 rounded-lg font-semibold shadow-sm transition-all hover:shadow-md active:scale-95"
>
<span className="text-xl">🔴</span>
<span>Karung Keluar</span>
<i className="fa-solid fa-chevron-down text-xs ml-1"></i>
</button>
)}
</div>
</div>
</div>
{/* General error message */}
{validationErrors._general && (
<div className="mb-4 p-3 bg-red-50 border-l-4 border-red-500 text-red-700 text-sm">
<i className="fa-solid fa-exclamation-circle mr-2"></i>
{validationErrors._general}
</div>
)}
{/* Three vertical boxes */}
{renderBox('KARUNG MASUK', '🟢', '#86efac', groupedColumns.inbound, inboundRef)}
{renderBox('KARUNG DITUANG', '🟠', '#fdba74', groupedColumns.usage, usageRef)}
{renderBox('KARUNG KELUAR', '🔴', '#fca5a5', groupedColumns.outbound, outboundRef)}
</div>
);
};
export default FeedDailySummaryTable;
@@ -0,0 +1,218 @@
import React, { useMemo } from 'react';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import {
FeedSackLogEntry,
ManualFeedSackEntry,
FeedSackColumnConfig,
} from '../../types/counting.ts';
interface FeedSackComparisonChartsProps {
iotData: FeedSackLogEntry[];
manualEntries: ManualFeedSackEntry[];
cycleStartDate: Date;
currentDay: number;
columnConfig: FeedSackColumnConfig[];
}
const FeedSackComparisonCharts: React.FC<FeedSackComparisonChartsProps> = ({
iotData,
manualEntries,
cycleStartDate,
currentDay,
columnConfig,
}) => {
// Aggregate data per camera
const chartDataPerCamera = useMemo(() => {
const cycleStart = new Date(cycleStartDate);
cycleStart.setHours(0, 0, 0, 0);
return columnConfig.map((col) => {
const dailyMap = new Map<string, { date: string; manual: number; iot: number }>();
// Process IOT data for this camera
iotData.forEach((log) => {
if (log.cameraName !== col.cameraName) return;
const logDate = new Date(log.timestamp);
logDate.setHours(0, 0, 0, 0);
const dayNumber = Math.floor(
(logDate.getTime() - cycleStart.getTime()) / (1000 * 60 * 60 * 24)
);
if (dayNumber < 0 || dayNumber > currentDay) return;
const dateKey = logDate.toISOString().split('T')[0];
if (!dailyMap.has(dateKey)) {
dailyMap.set(dateKey, {
date: logDate.toLocaleDateString('id-ID', {
day: '2-digit',
month: 'short',
}),
manual: 0,
iot: 0,
});
}
const entry = dailyMap.get(dateKey)!;
entry.iot += log.count;
});
// Process manual data for this camera
manualEntries.forEach((manual) => {
if (manual.cameraName !== col.cameraName) return;
const manualDate = new Date(manual.date);
if (isNaN(manualDate.getTime())) return;
manualDate.setHours(0, 0, 0, 0);
const dateKey = manualDate.toISOString().split('T')[0];
if (!dailyMap.has(dateKey)) {
dailyMap.set(dateKey, {
date: manualDate.toLocaleDateString('id-ID', {
day: '2-digit',
month: 'short',
}),
manual: 0,
iot: 0,
});
}
const entry = dailyMap.get(dateKey)!;
entry.manual = manual.count;
});
// Fill missing days
const filled = [];
for (let d = 0; d <= currentDay; d++) {
const date = new Date(cycleStart);
date.setDate(cycleStart.getDate() + d);
const dateKey = date.toISOString().split('T')[0];
if (dailyMap.has(dateKey)) {
filled.push(dailyMap.get(dateKey)!);
} else {
filled.push({
date: date.toLocaleDateString('id-ID', {
day: '2-digit',
month: 'short',
}),
manual: 0,
iot: 0,
});
}
}
return {
camera: col,
data: filled,
};
});
}, [iotData, manualEntries, cycleStartDate, currentDay, columnConfig]);
if (columnConfig.length === 0) {
return null;
}
return (
<div className="space-y-6">
<h2 className="text-xl font-bold text-gray-800">Perbandingan Manual vs IOT per Kamera</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{chartDataPerCamera.map(({ camera, data }) => {
const hasData = data.some((d) => d.manual > 0 || d.iot > 0);
return (
<div key={camera.id} className="bg-white p-6 rounded-xl shadow-sm">
<div className="mb-4">
<h3 className="text-lg font-bold" style={{ color: camera.color }}>
{camera.label}
</h3>
<p className="text-sm text-gray-500">{camera.cameraName}</p>
</div>
{!hasData ? (
<div className="h-64 flex items-center justify-center">
<div className="text-center text-gray-400">
<i className="fa-solid fa-chart-line text-4xl mb-2"></i>
<p className="text-sm">Belum ada data untuk kamera ini</p>
</div>
</div>
) : (
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 5, right: 30, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
<XAxis dataKey="date" fontSize={12} tick={{ fill: '#6b7280' }} />
<YAxis
fontSize={12}
tick={{ fill: '#6b7280' }}
domain={[
(dataMin: number) => {
if (dataMin === 0) return 0;
return Math.floor(dataMin * 0.9);
},
(dataMax: number) => Math.ceil(dataMax * 1.1),
]}
tickFormatter={(value) => `${value}`}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
border: '1px solid #ccc',
borderRadius: '8px',
padding: '10px',
}}
formatter={(value: number, name: string) => [
`${value} karung`,
name === 'manual' ? 'Manual' : 'IOT',
]}
/>
<Legend
wrapperStyle={{ paddingTop: '10px' }}
formatter={(value) => (value === 'manual' ? 'Manual' : 'IOT')}
/>
<Line
type="monotone"
dataKey="manual"
stroke="#3b82f6"
strokeWidth={2.5}
name="manual"
dot={{ r: 4, fill: '#3b82f6' }}
activeDot={{ r: 6 }}
connectNulls={false}
/>
<Line
type="monotone"
dataKey="iot"
stroke={camera.color}
strokeWidth={2.5}
name="iot"
dot={{ r: 4, fill: camera.color }}
activeDot={{ r: 6 }}
connectNulls={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
</div>
);
})}
</div>
</div>
);
};
export default FeedSackComparisonCharts;
+180
View File
@@ -0,0 +1,180 @@
import React, { useMemo } from 'react';
import { FeedSackLogEntry, FeedSackColumnConfig } from '../../types/counting.ts';
import { formatNumber } from '../../utils/formatters.ts';
import { getEffectiveDate } from '../../utils/dateFilter.ts';
interface FeedSaldoCardProps {
iotData: FeedSackLogEntry[];
columnConfig: FeedSackColumnConfig[];
startDate: Date;
saldoAwal?: number;
effectiveDate?: Date;
}
const FeedSaldoCard: React.FC<FeedSaldoCardProps> = ({
iotData,
columnConfig,
startDate,
saldoAwal = 0,
effectiveDate,
}) => {
const displayDate = effectiveDate ?? getEffectiveDate();
const saldoData = useMemo(() => {
const inboundCameras = columnConfig
.filter((c) => c.type === 'inbound')
.map((c) => c.cameraName);
const usageCameras = columnConfig.filter((c) => c.type === 'usage').map((c) => c.cameraName);
const outboundCameras = columnConfig
.filter((c) => c.type === 'outbound')
.map((c) => c.cameraName);
// Date filtering: from startDate to today
const filterStartDate = new Date(startDate);
filterStartDate.setHours(0, 0, 0, 0);
const today = new Date(displayDate);
today.setHours(23, 59, 59, 999);
// Calculate IOT saldo (cumulative)
const inbound = iotData
.filter(
(log) =>
inboundCameras.includes(log.cameraName) &&
log.timestamp >= filterStartDate &&
log.timestamp <= today
)
.reduce((sum, log) => sum + log.count, 0);
const usage = iotData
.filter(
(log) =>
usageCameras.includes(log.cameraName) &&
log.timestamp >= filterStartDate &&
log.timestamp <= today
)
.reduce((sum, log) => sum + log.count, 0);
const outbound = iotData
.filter(
(log) =>
outboundCameras.includes(log.cameraName) &&
log.timestamp >= filterStartDate &&
log.timestamp <= today
)
.reduce((sum, log) => sum + log.count, 0);
const saldo = saldoAwal + inbound - usage - outbound;
return { inbound, usage, outbound, saldo, saldoAwal };
}, [iotData, columnConfig, startDate, saldoAwal, displayDate]);
const formatTodayLabel = () =>
new Intl.DateTimeFormat('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: 'Asia/Jakarta',
}).format(displayDate);
const formatDateRange = () => {
const start = startDate.toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
return `${start} - ${formatTodayLabel()}`;
};
const getSaldoStatusColor = (saldo: number) => {
if (saldo > 0) return 'text-green-600';
if (saldo < 0) return 'text-red-600';
return 'text-gray-600';
};
return (
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 p-6 rounded-xl shadow-sm border-2 border-blue-200">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="bg-blue-500 text-white p-3 rounded-lg">
<i className="fa-solid fa-scale-balanced text-2xl"></i>
</div>
<div>
<h2 className="text-xl font-bold text-gray-800">Saldo Karung Pakan</h2>
<p className="text-sm text-gray-600">{formatDateRange()}</p>
</div>
</div>
</div>
{/* Saldo Display */}
<div className="bg-white rounded-lg p-6 border border-blue-200">
<div className="grid grid-cols-5 gap-6">
{/* Saldo Awal */}
<div className="text-center">
<div className="flex items-center justify-center gap-2 mb-2">
<i className="fa-solid fa-box-archive text-blue-600"></i>
<p className="text-sm font-semibold text-gray-700">Saldo Awal</p>
</div>
<p className="text-3xl font-bold text-blue-600">{formatNumber(saldoData.saldoAwal)}</p>
<p className="text-xs text-gray-400 mt-1">karung</p>
</div>
{/* Karung Masuk */}
<div className="text-center">
<div className="flex items-center justify-center gap-2 mb-2">
<i className="fa-solid fa-arrow-down text-green-600"></i>
<p className="text-sm font-semibold text-gray-700">Karung Masuk</p>
</div>
<p className="text-3xl font-bold text-green-600">{formatNumber(saldoData.inbound)}</p>
<p className="text-xs text-gray-400 mt-1">karung</p>
</div>
{/* Karung Dituang */}
<div className="text-center">
<div className="flex items-center justify-center gap-2 mb-2">
<i className="fa-solid fa-arrow-up text-orange-600"></i>
<p className="text-sm font-semibold text-gray-700">Karung Dituang</p>
</div>
<p className="text-3xl font-bold text-orange-600">{formatNumber(saldoData.usage)}</p>
<p className="text-xs text-gray-400 mt-1">karung</p>
</div>
{/* Karung Keluar */}
<div className="text-center">
<div className="flex items-center justify-center gap-2 mb-2">
<i className="fa-solid fa-arrow-right text-red-600"></i>
<p className="text-sm font-semibold text-gray-700">Karung Keluar</p>
</div>
<p className="text-3xl font-bold text-red-600">{formatNumber(saldoData.outbound)}</p>
<p className="text-xs text-gray-400 mt-1">karung</p>
</div>
{/* Saldo */}
<div className="text-center border-l-2 border-blue-200 pl-6">
<div className="flex items-center justify-center gap-2 mb-2">
<i className="fa-solid fa-scale-balanced text-blue-600"></i>
<p className="text-sm font-semibold text-gray-700">Saldo</p>
</div>
<p className={`text-4xl font-bold ${getSaldoStatusColor(saldoData.saldo)}`}>
{formatNumber(saldoData.saldo)}
</p>
<p className="text-xs text-gray-400 mt-1">karung</p>
</div>
</div>
</div>
{/* Warning for negative saldo */}
{saldoData.saldo < 0 && (
<div className="mt-4 bg-yellow-50 border border-yellow-200 rounded-lg p-3 flex items-start gap-2">
<i className="fa-solid fa-triangle-exclamation text-yellow-600 mt-0.5"></i>
<p className="text-xs text-yellow-800">
<strong>Peringatan:</strong> Saldo negatif menunjukkan karung dituang dan keluar lebih
banyak dari karung masuk. Periksa kembali data atau konfigurasi kolom.
</p>
</div>
)}
</div>
);
};
export default FeedSaldoCard;
+195
View File
@@ -0,0 +1,195 @@
import React, { useMemo } from 'react';
import {
FeedSackLogEntry,
ManualFeedSackEntry,
FeedSackColumnConfig,
} from '../../types/counting.ts';
import { useAppContext } from '../../context/AppContext.tsx';
import { formatNumber } from '../../utils/formatters.ts';
import { getEffectiveDate } from '../../utils/dateFilter.ts';
const StatItem: React.FC<{
icon: React.ReactNode;
label: string;
manualValue: string;
iotValue: string;
colorOverride?: string;
}> = ({ icon, label, manualValue, iotValue, colorOverride }) => (
<div className="border border-gray-200 rounded-lg p-3 sm:p-4 hover:border-gray-300 transition-colors">
<div className="flex items-start gap-2 mb-3 min-w-0">
<div className="text-gray-500">{icon}</div>
<p className="min-w-0 truncate text-xs sm:text-sm font-medium text-gray-700" title={label}>
{label}
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Manual */}
<div className="rounded-md bg-gray-50/70 p-3">
<div className="flex items-center gap-1.5 mb-1">
<i className="fa-solid fa-pen-to-square text-xs text-blue-500"></i>
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">Manual</p>
</div>
<p className="text-xl sm:text-2xl font-bold text-blue-600 leading-none">{manualValue}</p>
<p className="text-xs text-gray-400 mt-0.5">karung</p>
</div>
{/* IOT */}
<div className="rounded-md bg-gray-50/70 p-3">
<div className="flex items-center gap-1.5 mb-1">
<i className="fa-solid fa-camera text-xs text-gray-400"></i>
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">IOT</p>
</div>
<p
className="text-xl sm:text-2xl font-bold leading-none"
style={colorOverride ? { color: colorOverride } : { color: '#374151' }}
>
{iotValue}
</p>
<p className="text-xs text-gray-400 mt-0.5">karung</p>
</div>
</div>
</div>
);
interface FeedStatsProps {
data: FeedSackLogEntry[];
manualEntries: ManualFeedSackEntry[];
columnConfig: FeedSackColumnConfig[];
effectiveDate?: Date;
}
const FeedStats: React.FC<FeedStatsProps> = ({
data,
manualEntries,
columnConfig,
effectiveDate,
}) => {
const { cycleData } = useAppContext();
const displayDate = effectiveDate ?? getEffectiveDate();
const todayLabel = useMemo(() => {
return new Intl.DateTimeFormat('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: 'Asia/Jakarta',
}).format(displayDate);
}, [displayDate]);
const totalCycleLabel = useMemo(() => {
const startDate = cycleData.startDate.toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
});
const endDate = displayDate.toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
});
return `Total (${startDate} - ${endDate})`;
}, [cycleData.startDate, displayDate]);
const columnStats = useMemo(() => {
return columnConfig.map((col) => {
const cameraIotData = data.filter((log) => log.cameraName === col.cameraName);
const cameraManualData = manualEntries.filter((entry) => entry.cameraName === col.cameraName);
const calculateIotCount = (dataSet: FeedSackLogEntry[], days: number | 'cycle') => {
let startDate: Date;
if (days === 'cycle') {
startDate = new Date(cycleData.startDate);
} else {
startDate = new Date(displayDate);
startDate.setDate(displayDate.getDate() - (days - 1));
}
startDate.setHours(0, 0, 0, 0);
return dataSet
.filter((log) => log.timestamp >= startDate)
.reduce((sum, log) => sum + log.count, 0);
};
const calculateManualCount = (dataSet: ManualFeedSackEntry[], days: number | 'cycle') => {
let startDate: Date;
if (days === 'cycle') {
startDate = new Date(cycleData.startDate);
} else {
startDate = new Date(displayDate);
startDate.setDate(displayDate.getDate() - (days - 1));
}
startDate.setHours(0, 0, 0, 0);
return dataSet
.filter((entry) => {
const entryDate = new Date(entry.date);
return !isNaN(entryDate.getTime()) && entryDate >= startDate;
})
.reduce((sum, entry) => sum + entry.count, 0);
};
return {
...col,
todayManual: calculateManualCount(cameraManualData, 1),
todayIot: calculateIotCount(cameraIotData, 1),
weekManual: calculateManualCount(cameraManualData, 'cycle'),
weekIot: calculateIotCount(cameraIotData, 'cycle'),
};
});
}, [data, manualEntries, columnConfig, cycleData, displayDate]);
const gridCols =
columnStats.length <= 2
? 'md:grid-cols-2'
: columnStats.length <= 3
? 'md:grid-cols-3'
: 'md:grid-cols-2 lg:grid-cols-4';
return (
<div className="bg-white p-4 sm:p-6 rounded-xl shadow-sm">
<div className={`grid grid-cols-1 ${gridCols} gap-4 sm:gap-6`}>
{columnStats.map((stat) => (
<div key={stat.id}>
<h3
className="font-semibold text-sm sm:text-base leading-snug mb-2 min-h-[2.75rem] sm:min-h-[3.25rem] min-w-0"
style={{
color: stat.color,
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
overflow: 'hidden',
}}
title={stat.label}
>
{stat.label}
</h3>
<p
className="text-[11px] sm:text-xs text-gray-500 mb-3 truncate"
title={stat.cameraName}
>
{stat.cameraName}
</p>
<div className="space-y-3">
<StatItem
icon={<i className="fa-solid fa-calendar-day"></i>}
label={todayLabel}
manualValue={formatNumber(stat.todayManual)}
iotValue={formatNumber(stat.todayIot)}
colorOverride={stat.color}
/>
<StatItem
icon={<i className="fa-solid fa-calendar-week"></i>}
label={totalCycleLabel}
manualValue={formatNumber(stat.weekManual)}
iotValue={formatNumber(stat.weekIot)}
colorOverride={stat.color}
/>
</div>
</div>
))}
</div>
</div>
);
};
export default FeedStats;
+109
View File
@@ -0,0 +1,109 @@
import React, { useMemo } from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import { FeedSackLogEntry, FeedSackColumnConfig } from '../../types/counting.ts';
interface FeedTrendChartProps {
data: FeedSackLogEntry[];
cycleStartDate: Date;
currentDay: number;
columnConfig: FeedSackColumnConfig[];
}
const FeedTrendChart: React.FC<FeedTrendChartProps> = ({
data,
cycleStartDate,
currentDay,
columnConfig,
}) => {
const cameraNames = useMemo(() => columnConfig.map((c) => c.cameraName), [columnConfig]);
const chartData = useMemo(() => {
const cycleStart = new Date(cycleStartDate);
cycleStart.setHours(0, 0, 0, 0);
const now = new Date();
now.setHours(0, 0, 0, 0);
// Initialize daily buckets
const dailyData: { [key: number]: Record<string, any> } = {};
for (let d = 0; d <= currentDay; d++) {
const date = new Date(cycleStart);
date.setDate(cycleStart.getDate() + d);
const formattedDate = date.toLocaleDateString('id-ID', { day: '2-digit', month: 'short' });
const entry: Record<string, any> = { day: `H${d}`, date: formattedDate };
cameraNames.forEach((name) => {
entry[name] = 0;
});
dailyData[d] = entry;
}
// Aggregate data by day
data.forEach((log) => {
if (log.timestamp >= cycleStart && log.timestamp <= now) {
const diffTime = log.timestamp.getTime() - cycleStart.getTime();
const dayNumber = Math.floor(diffTime / (1000 * 60 * 60 * 24));
if (dayNumber >= 0 && dayNumber <= currentDay && dailyData[dayNumber]) {
if (log.cameraName && cameraNames.includes(log.cameraName)) {
dailyData[dayNumber][log.cameraName] += log.count;
}
}
}
});
return Object.values(dailyData);
}, [data, cycleStartDate, currentDay, cameraNames]);
return (
<div className="bg-white p-6 rounded-xl shadow-sm">
<h2 className="text-xl font-bold text-gray-800 mb-4">Tren Harian Karung Pakan</h2>
<div className="h-60">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 5, right: 20, left: -15, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
<XAxis
dataKey="date"
fontSize={11}
tick={{ fill: '#6b7280' }}
angle={-45}
textAnchor="end"
height={70}
/>
<YAxis fontSize={12} tick={{ fill: '#6b7280' }} />
<Tooltip
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.9)',
border: '1px solid #ccc',
borderRadius: '8px',
}}
formatter={(value: number) => [`${value} karung`]}
labelFormatter={(label) => `Tanggal: ${label}`}
/>
<Legend />
{columnConfig.map((col) => (
<Bar
key={col.id}
dataKey={col.cameraName}
name={col.label}
fill={col.color}
barSize={8}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
};
export default FeedTrendChart;
+188
View File
@@ -0,0 +1,188 @@
import React, { useState } from 'react';
import { CycleFeedInitialBalance } from '../../types/counting.ts';
import { formatNumber } from '../../utils/formatters.ts';
interface InitialBalanceCardProps {
cycleId: string;
initialBalance: CycleFeedInitialBalance | null;
onSave: (saldoAwal: number) => Promise<void>;
}
const InitialBalanceCard: React.FC<InitialBalanceCardProps> = ({
cycleId: _cycleId,
initialBalance,
onSave,
}) => {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState<number>(initialBalance?.saldoAwal || 0);
const [validationError, setValidationError] = useState<string>('');
const [isSaving, setIsSaving] = useState(false);
const handleEdit = () => {
setEditValue(initialBalance?.saldoAwal || 0);
setValidationError('');
setIsEditing(true);
};
const handleCancel = () => {
setIsEditing(false);
setEditValue(initialBalance?.saldoAwal || 0);
setValidationError('');
};
const handleSave = async () => {
// Validation
if (editValue < 0) {
setValidationError('Saldo awal tidak boleh negatif');
return;
}
if (!Number.isInteger(editValue)) {
setValidationError('Saldo awal harus bilangan bulat');
return;
}
setIsSaving(true);
try {
await onSave(editValue);
setIsEditing(false);
setValidationError('');
} catch (error) {
console.error('Failed to save initial balance:', error);
setValidationError('Gagal menyimpan. Coba lagi.');
} finally {
setIsSaving(false);
}
};
const formatLastUpdate = () => {
if (!initialBalance) return null;
const date = new Date(initialBalance.updatedAt);
// Check if date is valid
if (isNaN(date.getTime())) {
return null; // Return null if date is invalid, won't show the update info
}
const formattedDate = date.toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
const formattedTime = date.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
});
return `${formattedDate} ${formattedTime}${initialBalance.createdBy ? ` oleh ${initialBalance.createdBy}` : ''}`;
};
return (
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 p-6 rounded-xl shadow-sm border-2 border-blue-200">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="bg-blue-500 text-white p-3 rounded-lg">
<i className="fa-solid fa-box-archive text-2xl"></i>
</div>
<div>
<h2 className="text-xl font-bold text-gray-800">Saldo Karung Awal</h2>
<p className="text-sm text-gray-600">Stok awal karung pakan untuk siklus ini</p>
</div>
</div>
{!isEditing && (
<button
onClick={handleEdit}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors flex items-center gap-2"
>
<i className="fa-solid fa-edit"></i>
<span>Edit</span>
</button>
)}
</div>
<div className="bg-white rounded-lg p-6 border border-blue-200">
{isEditing ? (
<div className="space-y-4">
<div>
<div className="flex items-center gap-3">
<input
type="number"
value={editValue}
onChange={(e) => setEditValue(parseInt(e.target.value) || 0)}
className="flex-1 px-4 py-3 border-2 border-blue-300 rounded-lg text-2xl font-bold text-gray-800 focus:outline-none focus:border-blue-500"
placeholder="0"
min="0"
step="1"
/>
<span className="text-gray-600 font-semibold">karung</span>
</div>
{validationError && (
<p className="text-sm text-red-600 mt-2">
<i className="fa-solid fa-exclamation-circle mr-1"></i>
{validationError}
</p>
)}
</div>
<div className="flex gap-2">
<button
onClick={handleSave}
disabled={isSaving}
className="flex-1 px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 disabled:opacity-50 transition-colors"
>
{isSaving ? (
<>
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
Menyimpan...
</>
) : (
<>
<i className="fa-solid fa-check mr-2"></i>
Simpan
</>
)}
</button>
<button
onClick={handleCancel}
disabled={isSaving}
className="flex-1 px-4 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 disabled:opacity-50 transition-colors"
>
<i className="fa-solid fa-times mr-2"></i>
Batal
</button>
</div>
</div>
) : (
<div className="text-center">
{initialBalance && initialBalance.saldoAwal > 0 ? (
<>
<p className="text-5xl font-bold text-blue-600 mb-2">
{formatNumber(initialBalance.saldoAwal)}
</p>
<p className="text-lg text-gray-600 mb-3">karung</p>
{formatLastUpdate() && (
<p className="text-xs text-gray-500">
<i className="fa-solid fa-clock mr-1"></i>
Terakhir diperbarui: {formatLastUpdate()}
</p>
)}
</>
) : (
<div className="py-4">
<i className="fa-solid fa-inbox text-5xl text-gray-300 mb-3"></i>
<p className="text-lg font-semibold text-gray-600 mb-1">
Saldo karung awal belum diatur
</p>
<p className="text-sm text-gray-500">Klik Edit untuk menambahkan</p>
</div>
)}
</div>
)}
</div>
</div>
);
};
export default InitialBalanceCard;
@@ -0,0 +1,71 @@
import React, { useMemo } from 'react';
import { Kpi } from '../../types.ts';
import { formatNumber } from '../../utils/formatters.ts';
interface KarungDituangTodayCardProps {
karungKpi?: Kpi;
effectiveDate?: Date;
}
const KarungDituangTodayCard: React.FC<KarungDituangTodayCardProps> = ({
karungKpi,
effectiveDate,
}) => {
const todayData = useMemo(() => {
const totalKarung = karungKpi?.value ?? 0;
const totalKg = totalKarung * 50;
return { totalKarung, totalKg };
}, [karungKpi]);
const formatDisplayDate = (date: Date) =>
date.toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
const displayDate = effectiveDate ?? new Date();
return (
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 p-6 rounded-xl shadow-sm border-2 border-blue-200">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="bg-orange-500 text-white p-3 rounded-lg">
<i className="fa-solid fa-arrow-up text-2xl"></i>
</div>
<div>
<h2 className="text-xl font-bold text-gray-800">Karung Dituang</h2>
<p className="text-sm text-gray-600">{formatDisplayDate(displayDate)}</p>
</div>
</div>
</div>
{/* Today's Usage Display */}
<div className="bg-white rounded-lg p-6 border border-blue-200">
<div className="flex items-center justify-center gap-8">
{/* Karung Count */}
<div className="text-center">
<p className="text-sm font-semibold text-gray-600 mb-2">Total Karung</p>
<p className="text-4xl font-bold text-orange-600">
{formatNumber(todayData.totalKarung)}
</p>
<p className="text-xs text-gray-400 mt-1">karung</p>
</div>
{/* Divider */}
<div className="h-16 w-px bg-gray-300"></div>
{/* Kg Conversion */}
<div className="text-center">
<p className="text-sm font-semibold text-gray-600 mb-2">Total Berat</p>
<p className="text-4xl font-bold text-gray-700">{formatNumber(todayData.totalKg)}</p>
<p className="text-xs text-gray-400 mt-1">kg</p>
</div>
</div>
</div>
</div>
);
};
export default KarungDituangTodayCard;
+91
View File
@@ -0,0 +1,91 @@
import React from 'react';
import { FeedSackLogEntry } from '../../types.ts';
const BoundingBox: React.FC<{
top: string;
left: string;
width: string;
height: string;
delay: string;
color: string;
}> = ({ top, left, width, height, delay, color }) => (
<div
className={`absolute border-2 bg-opacity-20 rounded-sm animate-pulse`}
style={{
top,
left,
width,
height,
animationDelay: delay,
borderColor: color,
backgroundColor: color,
}}
></div>
);
interface LiveFeedViewProps {
mode: 'inbound' | 'usage';
latestLog?: FeedSackLogEntry;
}
const LiveFeedView: React.FC<LiveFeedViewProps> = ({ mode, latestLog }) => {
const config = {
inbound: {
title: 'Kamera Pintu Masuk Gudang',
image: 'https://i.imgur.com/rLd7gP9.jpg', // Warehouse entrance
color: '#22c55e', // green-500
textColor: 'text-green-400',
boxes: [
{ top: '40%', left: '55%', width: '12%', height: '25%', delay: '0s' },
{ top: '42%', left: '40%', width: '12%', height: '26%', delay: '0.2s' },
{ top: '45%', left: '25%', width: '12%', height: '24%', delay: '0.4s' },
],
},
usage: {
title: 'Kamera Area Feeder',
image: 'https://i.imgur.com/8p8Yq3g.jpg', // Coop interior
color: '#f97316', // orange-500
textColor: 'text-orange-400',
boxes: [{ top: '60%', left: '20%', width: '15%', height: '28%', delay: '0.1s' }],
},
};
const currentConfig = config[mode];
const count = latestLog ? latestLog.count : 0;
return (
<div className="bg-white p-6 rounded-xl shadow-sm">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">{currentConfig.title}</h2>
<div className="flex items-center space-x-2">
<span className="relative flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
</span>
<span className="text-red-600 font-semibold">LIVE</span>
</div>
</div>
<div className="relative w-full aspect-video bg-gray-800 rounded-lg overflow-hidden shadow-inner">
<img
src={currentConfig.image}
alt="Live camera feed"
className="w-full h-full object-cover opacity-80"
/>
{currentConfig.boxes.map((box, i) => (
<BoundingBox key={i} {...box} color={currentConfig.color} />
))}
<div className="absolute bottom-2 right-2 bg-black/60 backdrop-blur-sm text-white p-2 rounded-lg text-sm font-mono">
<p>
Terdeteksi:{' '}
<span className={`font-bold ${currentConfig.textColor}`}>{count} karung</span>
</p>
<p>Lokasi: {latestLog?.location || 'N/A'}</p>
</div>
</div>
</div>
);
};
export default LiveFeedView;