first commit
This commit is contained in:
commit
367533c02b
171 files changed
+101175
No files matched your search
@@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import type { ChickenWeight } from '../../types/api.ts';
|
||||
import { formatDateId, formatNumber } from '../../utils/format.ts';
|
||||
import { formatWeight, getTargetWeightForAge } from '../../utils/weight.ts';
|
||||
|
||||
interface DailyDetailPanelProps {
|
||||
row: ChickenWeight | null;
|
||||
totalDays: number;
|
||||
}
|
||||
|
||||
const DetailCard: React.FC<{
|
||||
label: string;
|
||||
value: string;
|
||||
icon: React.ReactNode;
|
||||
highlight?: boolean;
|
||||
}> = ({ label, value, icon, highlight = false }) => (
|
||||
<div
|
||||
className={`bg-white rounded-xl shadow-sm p-4 ${
|
||||
highlight ? 'border-2 border-red-500' : 'border border-gray-100'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-2xl text-red-500 flex-shrink-0">{icon}</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-gray-500">{label}</p>
|
||||
<p className="text-xl font-bold text-gray-800 truncate">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DailyDetailPanel: React.FC<DailyDetailPanelProps> = ({ row, totalDays }) => {
|
||||
if (!row) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<i className="fa-solid fa-calendar-xmark text-4xl text-gray-300 mb-3"></i>
|
||||
<p className="text-gray-500 font-medium">Belum ada data untuk tanggal ini</p>
|
||||
<p className="text-gray-400 text-sm mt-1">
|
||||
Pilih tanggal lain untuk melihat rincian harian
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const target = getTargetWeightForAge(row.age, totalDays);
|
||||
const deviation = target != null && row.average_weight > 0 ? row.average_weight - target : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<DetailCard
|
||||
label="Tanggal"
|
||||
value={formatDateId(row.date)}
|
||||
icon={<i className="fa-solid fa-calendar-day"></i>}
|
||||
/>
|
||||
<DetailCard
|
||||
label="Umur"
|
||||
value={`${row.age} hari`}
|
||||
icon={<i className="fa-solid fa-clock"></i>}
|
||||
/>
|
||||
<DetailCard
|
||||
label="DOC"
|
||||
value={formatWeight(row.doc_weight, { decimals: 0 })}
|
||||
icon={<i className="fa-solid fa-egg"></i>}
|
||||
/>
|
||||
<DetailCard
|
||||
label="AVG Berat"
|
||||
value={formatWeight(row.average_weight, { stripTrailingZeros: true })}
|
||||
icon={<i className="fa-solid fa-weight-scale"></i>}
|
||||
highlight
|
||||
/>
|
||||
<DetailCard
|
||||
label="Uniformity"
|
||||
value={`${formatNumber(row.uniformity, 2, 2, true)}%`}
|
||||
icon={<i className="fa-solid fa-percent"></i>}
|
||||
/>
|
||||
<DetailCard
|
||||
label="ADG"
|
||||
value={`${formatNumber(row.average_daily_gain, 2, 2, true)} g/hari`}
|
||||
icon={<i className="fa-solid fa-chart-line"></i>}
|
||||
/>
|
||||
<DetailCard
|
||||
label="Populasi"
|
||||
value={`${formatNumber(row.chicken_count, 0)} ekor`}
|
||||
icon={<i className="fa-solid fa-users"></i>}
|
||||
/>
|
||||
{deviation != null && (
|
||||
<DetailCard
|
||||
label="Deviasi vs Target"
|
||||
value={`${deviation >= 0 ? '+' : ''}${formatWeight(Math.abs(deviation), {
|
||||
stripTrailingZeros: true,
|
||||
})}`}
|
||||
icon={<i className="fa-solid fa-arrow-trend-up"></i>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DailyDetailPanel;
|
||||
@@ -0,0 +1,193 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
Bar,
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Legend,
|
||||
Line,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import type { ChickenWeight } from '../../types/api.ts';
|
||||
import { formatDateId } from '../../utils/format.ts';
|
||||
import { formatWeight, getTargetGainForAge } from '../../utils/weight.ts';
|
||||
|
||||
interface DailyWeightGainChartProps {
|
||||
rows: ChickenWeight[];
|
||||
totalDays: number;
|
||||
}
|
||||
|
||||
type GainStatus = 'above' | 'near' | 'below' | 'none';
|
||||
|
||||
type ChartPoint = {
|
||||
day: number;
|
||||
date: string;
|
||||
gain: number | null;
|
||||
targetGain: number | null;
|
||||
status: GainStatus;
|
||||
};
|
||||
|
||||
const DailyWeightGainChart: React.FC<DailyWeightGainChartProps> = ({ rows, totalDays }) => {
|
||||
const chartData = useMemo<ChartPoint[]>(
|
||||
() =>
|
||||
[...rows].reverse().map((row) => {
|
||||
const targetGain = getTargetGainForAge(row.age, totalDays);
|
||||
const gain = row.average_daily_gain;
|
||||
let status: GainStatus = 'none';
|
||||
if (gain != null && targetGain != null) {
|
||||
const threshold = Math.abs(targetGain) * 0.1;
|
||||
if (gain >= targetGain + threshold) status = 'above';
|
||||
else if (gain >= targetGain - threshold) status = 'near';
|
||||
else status = 'below';
|
||||
}
|
||||
return { day: row.age, date: row.date, gain, targetGain, status };
|
||||
}),
|
||||
[rows, totalDays]
|
||||
);
|
||||
|
||||
const hasNoData = chartData.length === 0;
|
||||
|
||||
const CustomLegend = () => (
|
||||
<div className="mt-2 flex flex-wrap items-center justify-center gap-6 text-sm text-gray-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-8 rounded-sm bg-red-600" />
|
||||
<span>Pertumbuhan Aktual</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-8 rounded-sm bg-blue-600" />
|
||||
<span>Target Pertumbuhan</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CustomTooltip = ({
|
||||
active,
|
||||
payload,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ payload: ChartPoint }>;
|
||||
}) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const point = payload[0]?.payload;
|
||||
if (!point || point.gain == null) return null;
|
||||
const hasTarget = point.targetGain != null;
|
||||
const gainDiff = hasTarget ? point.gain - point.targetGain! : null;
|
||||
|
||||
return (
|
||||
<div className="bg-white p-4 border border-gray-300 rounded shadow-lg">
|
||||
<p className="font-bold text-gray-800">Hari ke-{point.day}</p>
|
||||
<p className="text-sm text-gray-600">{formatDateId(point.date)}</p>
|
||||
<div className="mt-2 space-y-1 text-sm">
|
||||
<p>
|
||||
<span className="font-semibold text-red-600">Pertumbuhan Aktual:</span>{' '}
|
||||
{formatWeight(point.gain, { stripTrailingZeros: true })}
|
||||
</p>
|
||||
{hasTarget ? (
|
||||
<>
|
||||
<p>
|
||||
<span className="font-semibold text-blue-600">Target Pertumbuhan:</span>{' '}
|
||||
{formatWeight(point.targetGain!, { stripTrailingZeros: true })}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-semibold">Selisih:</span>{' '}
|
||||
<span className={gainDiff! >= 0 ? 'text-green-600' : 'text-orange-600'}>
|
||||
{gainDiff! >= 0 ? '+' : ''}
|
||||
{formatWeight(Math.abs(gainDiff!), { stripTrailingZeros: true })}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-semibold">Status:</span>{' '}
|
||||
<span
|
||||
className={
|
||||
point.status === 'above'
|
||||
? 'text-green-600'
|
||||
: point.status === 'near'
|
||||
? 'text-yellow-600'
|
||||
: 'text-red-600'
|
||||
}
|
||||
>
|
||||
{point.status === 'above'
|
||||
? 'Di Atas Target'
|
||||
: point.status === 'near'
|
||||
? 'Mendekati Target'
|
||||
: 'Di Bawah Target'}
|
||||
</span>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
<span className="font-semibold text-gray-500">Target:</span>{' '}
|
||||
<span className="text-gray-400">Belum ada data standar</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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">Grafik Kenaikan Berat Harian</h2>
|
||||
</div>
|
||||
<div className="h-80">
|
||||
{hasNoData ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-500 text-lg font-medium">Tidak ada data tersedia</p>
|
||||
<p className="text-gray-400 text-sm mt-2">Data pertumbuhan belum tersedia</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={chartData} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tick={{ fill: '#6b7280', fontSize: 12 }}
|
||||
label={{ value: 'Umur (hari)', position: 'insideBottom', offset: -5 }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: '#6b7280', fontSize: 12 }}
|
||||
tickFormatter={(value) => formatWeight(value, { stripTrailingZeros: true })}
|
||||
/>
|
||||
<Legend content={<CustomLegend />} />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<ReferenceLine y={0} stroke="#9ca3af" strokeWidth={1} />
|
||||
<Bar dataKey="gain" fill="#dc2626" radius={[8, 8, 0, 0]} name="Pertumbuhan Aktual" />
|
||||
<Bar
|
||||
dataKey="targetGain"
|
||||
fill="#2563eb"
|
||||
radius={[8, 8, 0, 0]}
|
||||
name="Target Pertumbuhan"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="gain"
|
||||
stroke="#dc2626"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 5 }}
|
||||
connectNulls
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="targetGain"
|
||||
stroke="#2563eb"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 5 }}
|
||||
connectNulls
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DailyWeightGainChart;
|
||||
@@ -0,0 +1,157 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import type { ChickenWeight } from '../../types/api.ts';
|
||||
import { formatDateId } from '../../utils/format.ts';
|
||||
import { formatWeight, getTargetWeightForAge } from '../../utils/weight.ts';
|
||||
|
||||
interface DailyWeightProgressionChartProps {
|
||||
rows: ChickenWeight[];
|
||||
totalDays: number;
|
||||
}
|
||||
|
||||
type ChartPoint = {
|
||||
day: number;
|
||||
date: string;
|
||||
actual: number | null;
|
||||
target: number | null;
|
||||
chickenCount: number;
|
||||
};
|
||||
|
||||
const DailyWeightProgressionChart: React.FC<DailyWeightProgressionChartProps> = ({
|
||||
rows,
|
||||
totalDays,
|
||||
}) => {
|
||||
const chartData = useMemo<ChartPoint[]>(
|
||||
() =>
|
||||
[...rows].reverse().map((row) => ({
|
||||
day: row.age,
|
||||
date: row.date,
|
||||
actual: row.average_weight,
|
||||
target: getTargetWeightForAge(row.age, totalDays),
|
||||
chickenCount: row.chicken_count,
|
||||
})),
|
||||
[rows, totalDays]
|
||||
);
|
||||
|
||||
const hasNoData = chartData.length === 0;
|
||||
|
||||
const CustomTooltip = ({
|
||||
active,
|
||||
payload,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ payload: ChartPoint }>;
|
||||
}) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const point = payload[0]?.payload;
|
||||
if (!point || point.actual == null) return null;
|
||||
const deviation = point.target != null ? point.actual - point.target : null;
|
||||
|
||||
return (
|
||||
<div className="bg-white p-4 border border-gray-300 rounded shadow-lg">
|
||||
<p className="font-bold text-gray-800">Hari ke-{point.day}</p>
|
||||
<p className="text-sm text-gray-600">{formatDateId(point.date)}</p>
|
||||
<div className="mt-2 space-y-1 text-sm">
|
||||
<p>
|
||||
<span className="font-semibold text-red-600">Aktual:</span>{' '}
|
||||
{formatWeight(point.actual, { stripTrailingZeros: true })}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-semibold text-blue-600">Target:</span>{' '}
|
||||
{formatWeight(point.target, { stripTrailingZeros: true })}
|
||||
</p>
|
||||
{point.chickenCount > 0 && (
|
||||
<p>
|
||||
<span className="font-semibold text-gray-600">Ayam:</span> {point.chickenCount} ekor
|
||||
</p>
|
||||
)}
|
||||
{deviation != null && (
|
||||
<p>
|
||||
<span className="font-semibold">Deviasi:</span>{' '}
|
||||
<span className={deviation >= 0 ? 'text-green-600' : 'text-orange-600'}>
|
||||
{deviation >= 0 ? '+' : ''}
|
||||
{formatWeight(Math.abs(deviation), { stripTrailingZeros: true })}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm h-full">
|
||||
<h2 className="text-xl font-bold mb-4">Grafik Berat Aktual vs Target</h2>
|
||||
<div className="h-80">
|
||||
{hasNoData ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-500 text-lg font-medium">Tidak ada data tersedia</p>
|
||||
<p className="text-gray-400 text-sm mt-2">
|
||||
Data berat belum tersedia untuk periode ini
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={chartData} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tick={{ fill: '#6b7280', fontSize: 12 }}
|
||||
label={{ value: 'Umur (hari)', position: 'insideBottom', offset: -5 }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: '#6b7280', fontSize: 12 }}
|
||||
tickFormatter={(value) => formatWeight(value, { stripTrailingZeros: true })}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="target"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 5"
|
||||
dot={false}
|
||||
connectNulls
|
||||
name="Target"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="actual"
|
||||
stroke="#dc2626"
|
||||
strokeWidth={3}
|
||||
dot={{ r: 4, fill: '#dc2626' }}
|
||||
activeDot={{ r: 6 }}
|
||||
connectNulls
|
||||
name="Berat Aktual"
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
{!hasNoData && (
|
||||
<div className="mt-4 flex items-center justify-center space-x-6 text-sm text-gray-600">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-8 h-0.5 bg-red-600"></div>
|
||||
<span>Berat Aktual</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-8 h-0.5 bg-blue-600 border-dashed border-t-2 border-blue-600"></div>
|
||||
<span>Target Standar</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DailyWeightProgressionChart;
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import type { LatestAvg } from '../../types/api.ts';
|
||||
import { formatDateId, formatNumber } from '../../utils/format.ts';
|
||||
import {
|
||||
adgStatus,
|
||||
formatWeight,
|
||||
getTargetWeightForAge,
|
||||
type WeightStatus,
|
||||
uniformityStatus,
|
||||
weightDeviationStatus,
|
||||
} from '../../utils/weight.ts';
|
||||
|
||||
const statusText: Record<WeightStatus, string> = {
|
||||
ok: 'text-green-600',
|
||||
warning: 'text-yellow-600',
|
||||
critical: 'text-red-600',
|
||||
};
|
||||
|
||||
const StatItem: React.FC<{
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
status: WeightStatus;
|
||||
}> = ({ icon, label, value, status }) => (
|
||||
<div className="flex items-center p-2 rounded-lg hover:bg-gray-50">
|
||||
<div className="text-2xl text-gray-400 w-12">{icon}</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 font-medium">{label}</p>
|
||||
<p className={`text-2xl font-bold ${statusText[status]}`}>
|
||||
<span className="text-base font-medium">{value}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface WeightStatsProps {
|
||||
latest: LatestAvg | null;
|
||||
totalDays: number;
|
||||
}
|
||||
|
||||
const WeightStats: React.FC<WeightStatsProps> = ({ latest, totalDays }) => {
|
||||
const target = latest ? getTargetWeightForAge(latest.age, totalDays) : null;
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm h-full">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-xl font-bold">Statistik Utama Pertumbuhan</h2>
|
||||
{latest?.date ? (
|
||||
<p className="text-sm text-gray-500 mt-1">Data per: {formatDateId(latest.date)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{!latest ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-center">
|
||||
<i className="fa-solid fa-exclamation-triangle text-5xl text-yellow-500 mb-4"></i>
|
||||
<h3 className="text-lg font-semibold text-gray-700 mb-2">Data Tidak Tersedia</h3>
|
||||
<p className="text-sm text-gray-500 max-w-xs">
|
||||
Belum ada data timbangan untuk siklus ini. Data akan muncul setelah ayam ditimbang.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<StatItem
|
||||
icon={<i className="fa-solid fa-weight-scale"></i>}
|
||||
label="Rata-rata Berat Ayam"
|
||||
value={formatWeight(latest.average_weight, { stripTrailingZeros: true })}
|
||||
status={weightDeviationStatus(latest.average_weight, target)}
|
||||
/>
|
||||
<StatItem
|
||||
icon={<i className="fa-solid fa-chart-line"></i>}
|
||||
label="Pertambahan Bobot Harian (ADG)"
|
||||
value={formatWeight(latest.average_daily_gain, {
|
||||
suffix: '/hari',
|
||||
decimals: 2,
|
||||
truncate: true,
|
||||
})}
|
||||
status={adgStatus(latest.average_daily_gain)}
|
||||
/>
|
||||
<StatItem
|
||||
icon={<i className="fa-solid fa-percent"></i>}
|
||||
label="Tingkat Keseragaman (Uniformity)"
|
||||
value={`${formatNumber(latest.uniformity, 2, 2, true)} %`}
|
||||
status={uniformityStatus(latest.uniformity)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WeightStats;
|
||||
Reference in new issue
Block a user