commit
32a36cceff
444 files changed
+67186
No files matched your search
@@ -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">
|
||||
×
|
||||
</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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user