feat: port clinic dashboard components from clinic-pro-tauri
- Add NewAppointmentsTable for displaying today's appointments with status chips and formatted time. - Implement TauriCharts for bar and line charts representing patient counts and revenue. - Create TauriDashboardView to combine stat cards, charts, and new appointments list. - Introduce TauriStatCards for displaying key statistics with icons. - Add dashboardIcons for SVG icons used in stat cards. - Implement tests for DashboardPage to ensure correct rendering and API calls. - Create DashboardTodayAppointmentsTest to validate extended fields in today's appointments API response.
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Dashboard charts — bar (تعداد بیماران) + line/area (میزان درآمد).
|
||||
*
|
||||
* The source (clinic-pro-tauri) draws these with @mui/x-charts. MUI is not used
|
||||
* in clinicpro, so they are reproduced with plain DOM + inline SVG, matching the
|
||||
* source visuals: bars #5559CE, dashed horizontal grid, y-axis ticks #858D9D,
|
||||
* x-axis labels #7E7E7E, and a line #5559CE over a #3A6FF8 gradient area.
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
export interface ChartPoint {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/** ~5 rounded gridline ticks covering [0, max], top → bottom. */
|
||||
function niceTicks(max: number, count = 4): number[] {
|
||||
const safeMax = max > 0 ? max : 1;
|
||||
const rawStep = safeMax / count;
|
||||
const mag = Math.pow(10, Math.floor(Math.log10(rawStep)));
|
||||
const norm = rawStep / mag;
|
||||
const niceNorm = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10;
|
||||
const step = niceNorm * mag;
|
||||
const top = step * count;
|
||||
const ticks: number[] = [];
|
||||
for (let i = count; i >= 0; i--) ticks.push(Math.round(step * i));
|
||||
return ticks; // e.g. [400,300,200,100,0]
|
||||
}
|
||||
|
||||
const faNum = new Intl.NumberFormat('fa-IR');
|
||||
|
||||
/**
|
||||
* Shared plot frame: left y-axis tick column + dashed gridlines + bottom x-axis
|
||||
* labels. `render(top, bottom)` receives the plot-area vertical bounds (px kept
|
||||
* implicit via fl/percentages) and returns the plot content.
|
||||
*/
|
||||
function ChartFrame({
|
||||
ticks,
|
||||
labels,
|
||||
yWidth,
|
||||
children,
|
||||
}: {
|
||||
ticks: number[];
|
||||
labels: string[];
|
||||
yWidth: number;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="h-[300px] w-full flex flex-col px-[20px] pb-[12px]">
|
||||
<div className="flex-1 flex min-h-0">
|
||||
{/* y-axis ticks, aligned to gridlines */}
|
||||
<div
|
||||
className="flex flex-col justify-between text-[14px] font-normal text-[#858D9D] text-left pl-[4px] shrink-0"
|
||||
style={{ width: yWidth }}
|
||||
>
|
||||
{ticks.map((t, i) => (
|
||||
<span key={i} className="leading-none -translate-y-1/2 first:translate-y-0 last:translate-y-0">
|
||||
{faNum.format(t)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* plot area */}
|
||||
<div className="relative flex-1 min-w-0">
|
||||
{ticks.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute left-0 right-0 border-t border-dashed border-[#E7E7E7] dark:border-[#35343D]"
|
||||
style={{ top: `${(i / (ticks.length - 1)) * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
{/* x-axis labels */}
|
||||
<div className="flex pt-[8px]" style={{ paddingRight: yWidth }}>
|
||||
{labels.map((l, i) => (
|
||||
<span key={i} className="flex-1 text-center text-[10px] font-normal text-[#7E7E7E] whitespace-nowrap">
|
||||
{l}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Bar chart — thin #5559CE columns (source: BarPlot, categoryGapRatio 0.7). */
|
||||
export function TauriBarChart({ data }: { data: ChartPoint[] }) {
|
||||
if (!data.length) {
|
||||
return <EmptyChart />;
|
||||
}
|
||||
const max = Math.max(...data.map((d) => d.value), 1);
|
||||
const ticks = niceTicks(max);
|
||||
const top = ticks[0] || 1;
|
||||
return (
|
||||
<ChartFrame ticks={ticks} labels={data.map((d) => d.label)} yWidth={42}>
|
||||
<div className="absolute inset-0 flex items-stretch">
|
||||
{data.map((d, i) => (
|
||||
<div key={i} className="flex-1 flex items-end justify-center">
|
||||
<div
|
||||
title={faNum.format(d.value)}
|
||||
className="w-[24px] max-w-[30%] rounded-t-[3px] bg-[#5559CE]"
|
||||
style={{
|
||||
height: `${(d.value / top) * 100}%`,
|
||||
minHeight: d.value > 0 ? 4 : 0,
|
||||
animation: `tdgrowcol .9s ${i * 0.06}s cubic-bezier(.22,.61,.36,1) both`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<style>{`@keyframes tdgrowcol{from{height:0}}`}</style>
|
||||
</ChartFrame>
|
||||
);
|
||||
}
|
||||
|
||||
/** Line + area chart — #5559CE line over a #3A6FF8 gradient (source: LinePlot). */
|
||||
export function TauriLineChart({ data }: { data: ChartPoint[] }) {
|
||||
if (data.length < 2) {
|
||||
return <EmptyChart />;
|
||||
}
|
||||
const values = data.map((d) => d.value);
|
||||
const max = Math.max(...values, 1);
|
||||
const ticks = niceTicks(max);
|
||||
const top = ticks[0] || 1;
|
||||
|
||||
// SVG plot: 0..100 in both axes, non-uniform scaling (path has no text).
|
||||
const W = 100;
|
||||
const H = 100;
|
||||
const stepX = data.length > 1 ? W / (data.length - 1) : W;
|
||||
const pts = values.map((v, i) => [i * stepX, H - (v / top) * H] as [number, number]);
|
||||
const d = pts
|
||||
.map((p, i) => {
|
||||
if (i === 0) return `M${p[0]},${p[1]}`;
|
||||
const prev = pts[i - 1];
|
||||
const cx = (prev[0] + p[0]) / 2;
|
||||
return `C${cx},${prev[1]} ${cx},${p[1]} ${p[0]},${p[1]}`;
|
||||
})
|
||||
.join(' ');
|
||||
const area = `${d} L${pts[pts.length - 1][0]},${H} L${pts[0][0]},${H} Z`;
|
||||
|
||||
return (
|
||||
<ChartFrame ticks={ticks} labels={data.map((x) => x.label)} yWidth={64}>
|
||||
<svg
|
||||
className="absolute inset-0 h-full w-full overflow-visible"
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="tdIncomeGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#3A6FF8" stopOpacity={0.1} />
|
||||
<stop offset="100%" stopColor="#3A6FF8" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={area} fill="url(#tdIncomeGrad)" />
|
||||
<path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke="#5559CE"
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
</ChartFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyChart() {
|
||||
return (
|
||||
<div className="h-[300px] w-full grid place-items-center text-[13px] text-[#7E7E7E]">
|
||||
دادهای برای نمایش نیست
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user