diff --git a/frontend/components/Dashboard/DashboardCharts.tsx b/frontend/components/Dashboard/DashboardCharts.tsx new file mode 100644 index 000000000..5cc394c0c --- /dev/null +++ b/frontend/components/Dashboard/DashboardCharts.tsx @@ -0,0 +1,308 @@ +'use client'; + +import { useState } from 'react'; +import { + PieChart, + Pie, + Cell, + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + Legend, +} from 'recharts'; +import { ReportsSummary } from '@/lib/api/reports'; +import { AssetStatus } from '@/lib/query/types/asset'; + +// ─── Color palettes ─────────────────────────────────────────────────────────── +const STATUS_COLORS: Record = { + [AssetStatus.ACTIVE]: '#22c55e', + [AssetStatus.ASSIGNED]: '#3b82f6', + [AssetStatus.MAINTENANCE]: '#f59e0b', + [AssetStatus.RETIRED]: '#9ca3af', +}; + +const CATEGORY_COLORS = [ + '#6366f1', '#ec4899', '#14b8a6', '#f97316', + '#8b5cf6', '#06b6d4', '#84cc16', '#ef4444', +]; + +// ─── Accessible toggle ──────────────────────────────────────────────────────── +function ChartToggle({ + showTable, + onToggle, + chartId, +}: { + showTable: boolean; + onToggle: () => void; + chartId: string; +}) { + return ( + + ); +} + +// ─── Status Distribution Donut ──────────────────────────────────────────────── +function StatusDonut({ byStatus }: { byStatus: Record }) { + const [showTable, setShowTable] = useState(false); + + const data = Object.entries(byStatus).map(([name, value]) => ({ name, value })); + + return ( +
+
+

Status Distribution

+ setShowTable((v) => !v)} + chartId="status-donut-table" + /> +
+ + {showTable ? ( + + + + + + + + + {data.map(({ name, value }) => ( + + + + + ))} + +
StatusCount
{name.toLowerCase()}{value}
+ ) : ( + + + + {data.map(({ name }) => ( + + ))} + + ( + + {value.toLowerCase()} + + )} + /> + [value ?? 0, (name ?? '').toLowerCase()]} + /> + + + )} +
+ ); +} + +// ─── Category Bar Chart ─────────────────────────────────────────────────────── +function CategoryBar({ + byCategory, +}: { + byCategory: { name: string; count: number }[]; +}) { + const [showTable, setShowTable] = useState(false); + + const data = [...byCategory] + .sort((a, b) => b.count - a.count) + .slice(0, 8) + .map((item, i) => ({ ...item, fill: CATEGORY_COLORS[i % CATEGORY_COLORS.length] })); + + return ( +
+
+

Assets by Category

+ setShowTable((v) => !v)} + chartId="category-bar-table" + /> +
+ + {showTable ? ( + + + + + + + + + {data.map(({ name, count }) => ( + + + + + ))} + +
CategoryCount
{name}{count}
+ ) : data.length === 0 ? ( +

No category data

+ ) : ( + + + + + [value ?? 0, 'Assets']} + /> + + {data.map(({ name, fill }) => ( + + ))} + + + + )} +
+ ); +} + +// ─── Department Bar Chart ───────────────────────────────────────────────────── +function DepartmentBar({ + byDepartment, +}: { + byDepartment: { name: string; count: number }[]; +}) { + const [showTable, setShowTable] = useState(false); + + const data = [...byDepartment] + .sort((a, b) => b.count - a.count) + .slice(0, 8); + + return ( +
+
+

Assets by Department

+ setShowTable((v) => !v)} + chartId="dept-bar-table" + /> +
+ + {showTable ? ( + + + + + + + + + {data.map(({ name, count }) => ( + + + + + ))} + +
DepartmentCount
{name}{count}
+ ) : data.length === 0 ? ( +

No department data

+ ) : ( + + + + + [value ?? 0, 'Assets']} + /> + + + + )} +
+ ); +} + +// ─── Main Export ────────────────────────────────────────────────────────────── +interface Props { + data: ReportsSummary; +} + +export default function DashboardCharts({ data }: Props) { + const { byStatus, byCategory, byDepartment } = data; + return ( +
+
+ + + +
+
+ ); +} diff --git a/frontend/components/Dashboard/DateRangeSelector.tsx b/frontend/components/Dashboard/DateRangeSelector.tsx new file mode 100644 index 000000000..3f1d4e921 --- /dev/null +++ b/frontend/components/Dashboard/DateRangeSelector.tsx @@ -0,0 +1,128 @@ +'use client'; + +import { useCallback } from 'react'; +import { useRouter, usePathname, useSearchParams } from 'next/navigation'; +import { Calendar } from 'lucide-react'; + +export type DateRangePreset = '7d' | '30d' | '90d' | '1y' | 'custom'; + +const PRESETS: { label: string; value: DateRangePreset }[] = [ + { label: '7 days', value: '7d' }, + { label: '30 days', value: '30d' }, + { label: '90 days', value: '90d' }, + { label: '1 year', value: '1y' }, +]; + +interface DateRangeSelectorProps { + /** Called when the range changes. `from` and `to` are ISO date strings. */ + onChange?: (range: { from: string; to: string }) => void; +} + +/** + * Date range selector that reflects its state in URL search params + * (?from=YYYY-MM-DD&to=YYYY-MM-DD&preset=30d). + * Views are shareable and survive page reload. + */ +export function DateRangeSelector({ onChange }: DateRangeSelectorProps) { + const router = useRouter(); + const pathname = usePathname(); + const params = useSearchParams(); + + const currentPreset = (params.get('preset') as DateRangePreset) ?? '30d'; + const fromParam = params.get('from') ?? ''; + const toParam = params.get('to') ?? ''; + + /** Compute ISO dates from a preset string */ + const presetToDates = useCallback((preset: DateRangePreset): { from: string; to: string } => { + const to = new Date(); + const from = new Date(); + const map: Record, number> = { + '7d': 7, + '30d': 30, + '90d': 90, + '1y': 365, + }; + if (preset !== 'custom') { + from.setDate(from.getDate() - map[preset]); + } + const fmt = (d: Date) => d.toISOString().split('T')[0]; + return { from: fmt(from), to: fmt(to) }; + }, []); + + const pushParams = useCallback( + (preset: DateRangePreset, from: string, to: string) => { + const sp = new URLSearchParams(Array.from(params.entries())); + sp.set('preset', preset); + sp.set('from', from); + sp.set('to', to); + router.push(`${pathname}?${sp.toString()}`); + onChange?.({ from, to }); + }, + [params, pathname, router, onChange], + ); + + const handlePreset = (preset: DateRangePreset) => { + const { from, to } = presetToDates(preset); + pushParams(preset, from, to); + }; + + const handleCustomFrom = (e: React.ChangeEvent) => { + pushParams('custom', e.target.value, toParam || new Date().toISOString().split('T')[0]); + }; + + const handleCustomTo = (e: React.ChangeEvent) => { + pushParams('custom', fromParam, e.target.value); + }; + + const computedValue = currentPreset === 'custom' + ? { from: fromParam, to: toParam } + : presetToDates(currentPreset); + + return ( +
+
+ ); +} diff --git a/frontend/store/Dashboard/DashboardCharts.tsx b/frontend/store/Dashboard/DashboardCharts.tsx new file mode 100644 index 000000000..5cc394c0c --- /dev/null +++ b/frontend/store/Dashboard/DashboardCharts.tsx @@ -0,0 +1,308 @@ +'use client'; + +import { useState } from 'react'; +import { + PieChart, + Pie, + Cell, + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + Legend, +} from 'recharts'; +import { ReportsSummary } from '@/lib/api/reports'; +import { AssetStatus } from '@/lib/query/types/asset'; + +// ─── Color palettes ─────────────────────────────────────────────────────────── +const STATUS_COLORS: Record = { + [AssetStatus.ACTIVE]: '#22c55e', + [AssetStatus.ASSIGNED]: '#3b82f6', + [AssetStatus.MAINTENANCE]: '#f59e0b', + [AssetStatus.RETIRED]: '#9ca3af', +}; + +const CATEGORY_COLORS = [ + '#6366f1', '#ec4899', '#14b8a6', '#f97316', + '#8b5cf6', '#06b6d4', '#84cc16', '#ef4444', +]; + +// ─── Accessible toggle ──────────────────────────────────────────────────────── +function ChartToggle({ + showTable, + onToggle, + chartId, +}: { + showTable: boolean; + onToggle: () => void; + chartId: string; +}) { + return ( + + ); +} + +// ─── Status Distribution Donut ──────────────────────────────────────────────── +function StatusDonut({ byStatus }: { byStatus: Record }) { + const [showTable, setShowTable] = useState(false); + + const data = Object.entries(byStatus).map(([name, value]) => ({ name, value })); + + return ( +
+
+

Status Distribution

+ setShowTable((v) => !v)} + chartId="status-donut-table" + /> +
+ + {showTable ? ( + + + + + + + + + {data.map(({ name, value }) => ( + + + + + ))} + +
StatusCount
{name.toLowerCase()}{value}
+ ) : ( + + + + {data.map(({ name }) => ( + + ))} + + ( + + {value.toLowerCase()} + + )} + /> + [value ?? 0, (name ?? '').toLowerCase()]} + /> + + + )} +
+ ); +} + +// ─── Category Bar Chart ─────────────────────────────────────────────────────── +function CategoryBar({ + byCategory, +}: { + byCategory: { name: string; count: number }[]; +}) { + const [showTable, setShowTable] = useState(false); + + const data = [...byCategory] + .sort((a, b) => b.count - a.count) + .slice(0, 8) + .map((item, i) => ({ ...item, fill: CATEGORY_COLORS[i % CATEGORY_COLORS.length] })); + + return ( +
+
+

Assets by Category

+ setShowTable((v) => !v)} + chartId="category-bar-table" + /> +
+ + {showTable ? ( + + + + + + + + + {data.map(({ name, count }) => ( + + + + + ))} + +
CategoryCount
{name}{count}
+ ) : data.length === 0 ? ( +

No category data

+ ) : ( + + + + + [value ?? 0, 'Assets']} + /> + + {data.map(({ name, fill }) => ( + + ))} + + + + )} +
+ ); +} + +// ─── Department Bar Chart ───────────────────────────────────────────────────── +function DepartmentBar({ + byDepartment, +}: { + byDepartment: { name: string; count: number }[]; +}) { + const [showTable, setShowTable] = useState(false); + + const data = [...byDepartment] + .sort((a, b) => b.count - a.count) + .slice(0, 8); + + return ( +
+
+

Assets by Department

+ setShowTable((v) => !v)} + chartId="dept-bar-table" + /> +
+ + {showTable ? ( + + + + + + + + + {data.map(({ name, count }) => ( + + + + + ))} + +
DepartmentCount
{name}{count}
+ ) : data.length === 0 ? ( +

No department data

+ ) : ( + + + + + [value ?? 0, 'Assets']} + /> + + + + )} +
+ ); +} + +// ─── Main Export ────────────────────────────────────────────────────────────── +interface Props { + data: ReportsSummary; +} + +export default function DashboardCharts({ data }: Props) { + const { byStatus, byCategory, byDepartment } = data; + return ( +
+
+ + + +
+
+ ); +} diff --git a/frontend/store/Dashboard/DateRangeSelector.tsx b/frontend/store/Dashboard/DateRangeSelector.tsx new file mode 100644 index 000000000..3f1d4e921 --- /dev/null +++ b/frontend/store/Dashboard/DateRangeSelector.tsx @@ -0,0 +1,128 @@ +'use client'; + +import { useCallback } from 'react'; +import { useRouter, usePathname, useSearchParams } from 'next/navigation'; +import { Calendar } from 'lucide-react'; + +export type DateRangePreset = '7d' | '30d' | '90d' | '1y' | 'custom'; + +const PRESETS: { label: string; value: DateRangePreset }[] = [ + { label: '7 days', value: '7d' }, + { label: '30 days', value: '30d' }, + { label: '90 days', value: '90d' }, + { label: '1 year', value: '1y' }, +]; + +interface DateRangeSelectorProps { + /** Called when the range changes. `from` and `to` are ISO date strings. */ + onChange?: (range: { from: string; to: string }) => void; +} + +/** + * Date range selector that reflects its state in URL search params + * (?from=YYYY-MM-DD&to=YYYY-MM-DD&preset=30d). + * Views are shareable and survive page reload. + */ +export function DateRangeSelector({ onChange }: DateRangeSelectorProps) { + const router = useRouter(); + const pathname = usePathname(); + const params = useSearchParams(); + + const currentPreset = (params.get('preset') as DateRangePreset) ?? '30d'; + const fromParam = params.get('from') ?? ''; + const toParam = params.get('to') ?? ''; + + /** Compute ISO dates from a preset string */ + const presetToDates = useCallback((preset: DateRangePreset): { from: string; to: string } => { + const to = new Date(); + const from = new Date(); + const map: Record, number> = { + '7d': 7, + '30d': 30, + '90d': 90, + '1y': 365, + }; + if (preset !== 'custom') { + from.setDate(from.getDate() - map[preset]); + } + const fmt = (d: Date) => d.toISOString().split('T')[0]; + return { from: fmt(from), to: fmt(to) }; + }, []); + + const pushParams = useCallback( + (preset: DateRangePreset, from: string, to: string) => { + const sp = new URLSearchParams(Array.from(params.entries())); + sp.set('preset', preset); + sp.set('from', from); + sp.set('to', to); + router.push(`${pathname}?${sp.toString()}`); + onChange?.({ from, to }); + }, + [params, pathname, router, onChange], + ); + + const handlePreset = (preset: DateRangePreset) => { + const { from, to } = presetToDates(preset); + pushParams(preset, from, to); + }; + + const handleCustomFrom = (e: React.ChangeEvent) => { + pushParams('custom', e.target.value, toParam || new Date().toISOString().split('T')[0]); + }; + + const handleCustomTo = (e: React.ChangeEvent) => { + pushParams('custom', fromParam, e.target.value); + }; + + const computedValue = currentPreset === 'custom' + ? { from: fromParam, to: toParam } + : presetToDates(currentPreset); + + return ( +
+
+ ); +}