fix(FE): refactor UI Dashboard pixel perfect figma

This commit is contained in:
randy-ar
2026-01-28 17:54:55 +07:00
parent b19340536a
commit 34f93f8dcc
13 changed files with 716 additions and 249 deletions
@@ -0,0 +1,343 @@
import Card from '@/components/Card';
import {
Dashboard,
DashboardOverviewCharts,
DashboardComparisonCharts,
DashboardChartsSeries,
DashboardChartsDataset,
} from '@/types/api/dashboard/dashboard';
import { Icon } from '@iconify/react';
import { forwardRef, useImperativeHandle, useRef } from 'react';
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
type DashboardExportChartsProps = {
data: Dashboard;
analysisMode: string;
};
export type DashboardExportChartsRef = {
getChartRefs: () => {
key: string;
ref: HTMLDivElement | null;
label: string;
}[];
};
// Type guard to check if charts is DashboardOverviewCharts
function isOverviewCharts(
charts: DashboardOverviewCharts | DashboardComparisonCharts | undefined
): charts is DashboardOverviewCharts {
if (!charts) return false;
return (
'deplesi' in charts ||
'body_weight' in charts ||
'fcr' in charts ||
'performance' in charts ||
'quality_control' in charts
);
}
// Type guard to check if charts is DashboardComparisonCharts
function isComparisonCharts(
charts: DashboardOverviewCharts | DashboardComparisonCharts | undefined
): charts is DashboardComparisonCharts {
if (!charts) return false;
return 'farm' in charts || 'flock' in charts || 'kandang' in charts;
}
const lineColors: Record<string, string> = {
body_weight: '#10B981',
std_body_weight: '#10B981',
act_laying: '#1062B9',
std_laying: '#1062B9',
act_egg_weight: '#10B981',
std_egg_weight: '#10B981',
act_feed_intake: '#F52419',
std_feed_intake: '#F52419',
act_uniformity: '#F59E0B',
std_uniformity: '#F59E0B',
act_fcr: '#10B981',
std_fcr: '#10B981',
act_fcr_cum: '#F52419',
std_fcr_cum: '#10B981',
normal: '#10B981',
abnormal: '#F52419',
act_deplesi: '#10B981',
std_deplesi: '#10B981',
};
const defaultLineColors: string[] = [
'#10B981',
'#1062B9',
'#F52419',
'#F59E0B',
'#7F56D9',
];
// Helper function to get line color
const getLineColor = (seriesId: string | number, index: number): string => {
const predefinedColor = lineColors[seriesId];
if (predefinedColor) {
return predefinedColor;
}
return defaultLineColors[index % defaultLineColors.length];
};
// Mapping for chart type labels
const chartTypeLabels: Record<keyof DashboardOverviewCharts, string> = {
body_weight: 'Body Weight',
performance: 'Performance',
fcr: 'FCR',
quality_control: 'Quality Control',
deplesi: 'Deplesi',
};
const DashboardExportCharts = forwardRef<
DashboardExportChartsRef,
DashboardExportChartsProps
>(({ data, analysisMode }, ref) => {
// Create refs for charts - use string keys for flexibility
const chartRefs = useRef<{
[key: string]: HTMLDivElement | null;
}>({});
// Determine chart keys and labels based on analysis mode
const getChartConfig = () => {
if (analysisMode === 'OVERVIEW' && isOverviewCharts(data.charts)) {
const overviewKeys: (keyof DashboardOverviewCharts)[] = [
'body_weight',
'performance',
'fcr',
'quality_control',
'deplesi',
];
return overviewKeys.map((key) => ({
key,
label: chartTypeLabels[key],
chartData: (data.charts as DashboardOverviewCharts)[key],
}));
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
// For comparison mode, find which comparison type has data
const comparisonKey = data.charts.farm
? 'farm'
: data.charts.flock
? 'flock'
: 'kandang';
const comparisonLabels: Record<string, string> = {
farm: 'Farm Comparison',
flock: 'Flock Comparison',
kandang: 'Kandang Comparison',
};
return [
{
key: comparisonKey,
label: comparisonLabels[comparisonKey],
chartData: data.charts[comparisonKey],
},
];
}
return [];
};
const chartConfig = getChartConfig();
// Expose method to get all chart refs
useImperativeHandle(ref, () => ({
getChartRefs: () => {
return chartConfig
.map(({ key, label }) => ({
key,
ref: chartRefs.current[key] || null,
label,
}))
.filter((item) => item.ref !== null);
},
}));
return (
<div className='space-y-6'>
{chartConfig.map(({ key, label, chartData }) => {
if (
!chartData ||
!chartData.dataset ||
chartData.dataset.length === 0
) {
return null;
}
const seriesData: DashboardChartsSeries[] = chartData.series || [];
const dataset: DashboardChartsDataset[] = chartData.dataset || [];
return (
<div
key={key}
ref={(el: HTMLDivElement | null) => {
chartRefs.current[key] = el;
}}
>
<Card
className={{
wrapper: 'w-full rounded-lg',
}}
variant='bordered'
>
<div className='flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6'>
<div className='text-lg font-semibold'>
{label}{' '}
<Icon
icon='heroicons:information-circle'
width={20}
height={20}
className='inline text-neutral-500'
/>
</div>
</div>
{/* Legend */}
<div className='flex flex-wrap gap-3 mb-6'>
{seriesData.map((series, index) => {
const isStandard = series.id
.toString()
.toLowerCase()
.includes('std');
return (
<div
key={series.id}
className='flex items-center gap-2 px-3 py-2 rounded-lg border border-neutral-400 bg-neutral-50'
>
<div
className={`w-6 h-0.5 ${
isStandard ? 'border-t-2 border-dashed' : ''
}`}
style={{
backgroundColor: isStandard
? 'transparent'
: getLineColor(series.id, index),
borderColor: isStandard
? getLineColor(series.id, index)
: 'transparent',
}}
></div>
<span className='text-sm text-neutral-900 font-medium'>
{series.label}
</span>
<Icon
icon='heroicons:information-circle'
width={16}
height={16}
className='text-neutral-400'
/>
</div>
);
})}
</div>
{/* Chart */}
<ResponsiveContainer width='100%' height={350}>
<LineChart
data={dataset}
margin={{
top: 5,
right: 10,
left: 0,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray='3 3' stroke='#e5e7eb' />
<XAxis
dataKey='week'
tick={{ fontSize: 11, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
label={{
value: 'Weeks',
position: 'insideBottom',
offset: -5,
style: { fontSize: 12, fill: '#9ca3af' },
}}
/>
<YAxis
tick={{ fontSize: 11, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
domain={(() => {
const allValues: number[] = [];
dataset.forEach((item: DashboardChartsDataset) => {
seriesData.forEach((series) => {
const value = item[series.id];
if (typeof value === 'number') {
allValues.push(value);
}
});
});
if (allValues.length === 0) return [0, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(
Math.max(0, minValue - padding)
);
const domainMax = Math.ceil(maxValue + padding);
return [domainMin, domainMax];
})()}
/>
{seriesData.map((series, index) => {
const isStandard = series.id
.toString()
.toLowerCase()
.includes('std');
const dataKey = series.id.toString();
return (
<Line
key={series.id}
type='monotone'
dataKey={dataKey}
name={series.label}
stroke={getLineColor(series.id, index)}
opacity={isStandard ? 0.5 : 1}
strokeWidth={2}
strokeDasharray={isStandard ? '5 5' : undefined}
dot={
isStandard
? false
: {
r: 3,
fill: '#fff',
stroke: getLineColor(series.id, index),
strokeWidth: 2,
}
}
activeDot={isStandard ? undefined : { r: 5 }}
/>
);
})}
</LineChart>
</ResponsiveContainer>
</Card>
</div>
);
})}
</div>
);
});
DashboardExportCharts.displayName = 'DashboardExportCharts';
export default DashboardExportCharts;
@@ -0,0 +1,201 @@
import Alert from '@/components/Alert';
import Card from '@/components/Card';
import { formatNumber } from '@/lib/helper';
import {
DashboardOverviewCharts,
DashboardStatisticsData,
} from '@/types/api/dashboard/dashboard';
import { Icon } from '@iconify/react';
import { forwardRef, useImperativeHandle, useRef } from 'react';
interface DashboardStatsProps {
data: DashboardStatisticsData[];
}
export type DashboardExportStatsRef = {
getStatsRefs: () => {
key: string;
ref: HTMLDivElement | null;
label: string;
}[];
getContainerRef: () => HTMLDivElement | null;
};
// Konfigurasi untuk setiap kartu
const CARD_CONFIG = [
{
key: 'HPP Global',
icon: 'heroicons:banknotes',
alertColor: 'warning' as const,
suffix: ' /Kg',
prefix: 'RP ',
},
{
key: 'Avg. Selling Price',
icon: 'heroicons:document-currency-dollar',
alertColor: 'success' as const,
suffix: ' /Kg',
prefix: '',
},
{
key: 'FCR',
icon: 'heroicons:clipboard-document-list',
alertColor: 'info' as const,
suffix: '',
prefix: '',
},
{
key: 'Mortality',
icon: 'heroicons:exclamation-triangle',
alertColor: 'error' as const,
suffix: ' %',
prefix: '',
},
];
const DashboardExportStats = forwardRef<
DashboardExportStatsRef,
DashboardStatsProps
>(({ data }, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
// Helper to get trend icon and color
const getTrendDisplay = (percent: number) => {
const isPositive = percent >= 0;
return {
icon: isPositive
? 'heroicons:arrow-trending-up'
: 'heroicons:arrow-trending-down',
color: isPositive ? 'text-[#008000]' : 'text-[#FF3A3A]',
value: Math.abs(percent),
};
};
// Helper to format value
const formatValue = (value: number, prefix: string, suffix: string) => {
return (
<>
{prefix}
{formatNumber(value)}
{suffix && (
<span className='text-[14px] font-normal text-[#18181B]/50'>
{suffix}
</span>
)}
</>
);
};
// Expose container ref through imperative handle
useImperativeHandle(ref, () => ({
getStatsRefs: () => [],
getContainerRef: () => containerRef.current,
}));
return (
<div ref={containerRef} className='grid grid-cols-4 gap-[12px]'>
{CARD_CONFIG.map((config) => {
// Find matching data from API
const cardData = data.find((item) => item.label === config.key);
if (!cardData) {
// Show placeholder card for missing data (FCR & Mortality)
return (
<Card
key={config.key}
className={{
wrapper: 'w-full rounded-lg',
body: 'p-0',
wrapperContent:
'h-full flex flex-col items-between justify-between',
footer: '!mt-0',
}}
variant='bordered'
footer={
<div className='flex flex-row justify-between px-[16px] pb-[16px]'>
<div className='text-[#18181B]/50 font-semibold text-[12px]'>
From last month
</div>
<div className='text-[#18181B]/50 font-semibold text-[12px]'>
Filter Required
</div>
</div>
}
>
<div className='flex flex-row items-center gap-4 px-4 pt-4'>
<Alert
variant='soft'
className={`rounded-[8px] p-0 w-[50px] h-[50px] bg-[${config.alertColor}]/12 flex items-center justify-center`}
>
<Icon
icon={config.icon}
width={24}
height={24}
className='text-[#18181B]/50'
/>
</Alert>
<div>
<h3 className='text-[#18181B]/50 font-semibold text-[14px]'>
{config.key}
</h3>
<p className='text-[20px] font-semibold text-[#18181B]/50'>
********
</p>
</div>
</div>
</Card>
);
}
const trend = getTrendDisplay(cardData.percent_last_month);
return (
<Card
key={config.key}
className={{
wrapper:
'w-full rounded-[12px] min-h-[132px] border-[1px] border-[#18181B]/10',
body: 'p-0',
wrapperContent:
'h-full flex flex-col items-between justify-between',
footer: '!mt-0',
}}
variant='bordered'
footer={
<div className='flex flex-row justify-between px-[16px] pb-[16px]'>
<div className='text-[#18181B]/50 font-semibold text-[12px]'>
From last month
</div>
<div
className={`${trend.color} font-semibold flex flex-row items-center gap-[8px] text-[12px]`}
>
<Icon icon={trend.icon} width={16} height={16} />
{trend.value}%
</div>
</div>
}
>
<div className='flex flex-row items-center gap-4 px-4 pt-4'>
<Alert
variant='soft'
color={config.alertColor}
className={`rounded-[8px] p-0 w-[50px] h-[50px] bg-[${config.alertColor}]/12 flex items-center justify-center`}
>
<Icon icon={config.icon} width={24} height={24} />
</Alert>
<div>
<h3 className='text-[#18181B]/50 font-semibold text-[14px]'>
{cardData.label}
</h3>
<p className='text-[20px] font-semibold'>
{formatValue(cardData.value, config.prefix, config.suffix)}
</p>
</div>
</div>
</Card>
);
})}
</div>
);
});
DashboardExportStats.displayName = 'DashboardExportStats';
export default DashboardExportStats;
@@ -3,18 +3,19 @@ import { toPng } from 'html-to-image';
import toast from 'react-hot-toast';
import { formatDate } from '@/lib/helper';
import { DashboardFilterType } from '@/components/pages/dashboard/filter/DashboardProductionFilter.schema';
import { DashboardAllChartsRef } from '@/components/pages/dashboard/chart/DashboardAllCharts';
import { DashboardExportChartsRef } from '@/components/pages/dashboard/export/DashboardExportCharts';
import { DashboardExportStatsRef } from '@/components/pages/dashboard/export/DashboardExportStats';
interface DashboardPDFExportParams {
filterValues: DashboardFilterType;
statsRef: React.RefObject<HTMLDivElement | null>;
allChartsRef: React.RefObject<DashboardAllChartsRef | null>;
allStatsRef: React.RefObject<DashboardExportStatsRef | null>;
allChartsRef: React.RefObject<DashboardExportChartsRef | null>;
setExporting: (value: boolean) => void;
}
export const generateDashboardPDF = async ({
filterValues,
statsRef,
allStatsRef,
allChartsRef,
setExporting,
}: DashboardPDFExportParams): Promise<void> => {
@@ -168,31 +169,34 @@ export const generateDashboardPDF = async ({
yPosition += 10;
// Capture and add stats if available
if (statsRef.current) {
const statsImage = await toPng(statsRef.current, {
quality: 1,
pixelRatio: 2,
});
const statsImgProps = pdf.getImageProperties(statsImage);
const statsWidth = pageWidth - 2 * margin;
const statsHeight =
(statsImgProps.height * statsWidth) / statsImgProps.width;
if (allStatsRef.current) {
const statsContainer = allStatsRef.current.getContainerRef();
if (statsContainer) {
const statsImage = await toPng(statsContainer, {
quality: 1,
pixelRatio: 2,
});
const statsImgProps = pdf.getImageProperties(statsImage);
const statsWidth = pageWidth - 2 * margin;
const statsHeight =
(statsImgProps.height * statsWidth) / statsImgProps.width;
// Check if we need a new page
if (yPosition + statsHeight > pageHeight - margin) {
pdf.addPage();
yPosition = margin;
// Check if we need a new page
if (yPosition + statsHeight > pageHeight - margin) {
pdf.addPage();
yPosition = margin;
}
pdf.addImage(
statsImage,
'PNG',
margin,
yPosition,
statsWidth,
statsHeight
);
yPosition += statsHeight + 10;
}
pdf.addImage(
statsImage,
'PNG',
margin,
yPosition,
statsWidth,
statsHeight
);
yPosition += statsHeight + 10;
}
if (allChartsRef.current) {