Merge branch 'release(DSAAS)'

This commit is contained in:
Keven 2025-12-08 10:03:33 -03:00
commit ff3ff6c2e0
4 changed files with 928 additions and 75 deletions

180
package-lock.json generated
View file

@ -4273,7 +4273,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
@ -4397,6 +4396,127 @@
"node": ">=12"
}
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@ -4492,6 +4612,12 @@
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@ -4564,6 +4690,16 @@
"node": ">=0.10.0"
}
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -5479,6 +5615,15 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/fast-equals": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.3.2.tgz",
"integrity": "sha512-6rxyATwPCkaFIL3JLqw8qXqMpIZ942pTX/tbQFkRsDGblS8tNGtlUauA/+mt6RUfqn/4MoEr+WDkYoIQbibWuQ==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@ -6042,6 +6187,15 @@
"node": ">=12"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@ -6928,6 +7082,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@ -7849,6 +8009,21 @@
}
}
},
"node_modules/react-smooth": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
"license": "MIT",
"dependencies": {
"fast-equals": "^5.0.1",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@ -8825,6 +9000,9 @@
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",

View file

@ -0,0 +1,278 @@
'use client'
import React, { useMemo, useState } from "react";
import {
Card,
CardTitle,
CardContent,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
PieChart,
Pie,
Cell,
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
Legend,
} from "recharts";
// Função de agregação
function sampleAggregations(data) {
const bySexo: Record<string, number> = {};
const byUF: Record<string, number> = {};
const byProf: Record<string, number> = {};
const byEstCivil: Record<string, number> = {};
const byInstr: Record<string, number> = {};
const docsExpiry: any[] = [];
const now = new Date();
for (const p of data) {
const sexo = p.SEXO || "Não informado";
bySexo[sexo] = (bySexo[sexo] || 0) + 1;
const uf = p.UF_RESIDENCIA || p.UF || "Não informado";
byUF[uf] = (byUF[uf] || 0) + 1;
const prof = p.TB_PROFISSAO_ID || "Outros";
byProf[prof] = (byProf[prof] || 0) + 1;
const ec = p.TB_ESTADOCIVIL_ID || "Não informado";
byEstCivil[ec] = (byEstCivil[ec] || 0) + 1;
const gi = p.GRAU_INSTRUCAO || "Não informado";
byInstr[gi] = (byInstr[gi] || 0) + 1;
if (p.DOCUMENTO_VALIDADE) {
const dv = new Date(p.DOCUMENTO_VALIDADE);
const days = Math.ceil((dv.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
docsExpiry.push({
id: p.PESSOA_ID || p.DOCUMENTO_NUMERO || Math.random(),
days,
date: p.DOCUMENTO_VALIDADE
});
}
}
return {
sexo: Object.entries(bySexo).map(([key, value]) => ({ name: key, value })),
uf: Object.entries(byUF).map(([key, value]) => ({ name: key, value })),
prof: Object.entries(byProf)
.map(([key, value]) => ({ name: String(key), value }))
.sort((a, b) => b.value - a.value)
.slice(0, 10),
estCivil: Object.entries(byEstCivil).map(([key, value]) => ({ name: key, value })),
instr: Object.entries(byInstr).map(([key, value]) => ({ name: key, value })),
docsExpiry,
};
}
// Paleta
const COLORS = [
"#4F46E5",
"#06B6D4",
"#F59E0B",
"#EF4444",
"#10B981",
"#8B5CF6",
"#F97316",
"#6366F1",
"#EC4899",
"#334155",
];
export default function PessoasDashboard({ dataset = null }) {
const mock = useMemo(() => {
if (dataset) return dataset;
// Mock realista
const nomes = ["Ana Clara", "Bruno Silva", "Carlos Souza", "Daniela Oliveira", "Eduardo Lima", "Fernanda Rocha", "Gustavo Alves", "Helena Martins"];
const sexos = ["Masculino", "Feminino", "Outro"];
const ufs = ["São Paulo", "Rio de Janeiro", "Minas Gerais", "Goiás", "Distrito Federal"];
const profs = ["Advogado", "Professor", "Agricultor", "Engenheiro Civil", "Estudante Universitário", "Médico", "Enfermeiro", "Empresário"];
const estc = ["Solteiro(a)", "Casado(a)", "Divorciado(a)", "Viúvo(a)", "União Estável"];
const instrucao = ["Ensino Fundamental", "Ensino Médio", "Ensino Superior", "Pós-Graduação", "Mestrado", "Doutorado"];
const arr: any[] = [];
for (let i = 0; i < 200; i++) {
const birth = new Date(1960 + Math.floor(Math.random() * 50), Math.floor(Math.random() * 12), Math.floor(Math.random() * 28) + 1);
const docVal = new Date();
docVal.setFullYear(docVal.getFullYear() + Math.floor(Math.random() * 5));
arr.push({
PESSOA_ID: i + 1,
NOME: nomes[Math.floor(Math.random() * nomes.length)],
SEXO: sexos[Math.floor(Math.random() * sexos.length)],
UF_RESIDENCIA: ufs[Math.floor(Math.random() * ufs.length)],
TB_PROFISSAO_ID: profs[Math.floor(Math.random() * profs.length)],
TB_ESTADOCIVIL_ID: estc[Math.floor(Math.random() * estc.length)],
GRAU_INSTRUCAO: instrucao[Math.floor(Math.random() * instrucao.length)],
DOCUMENTO_VALIDADE: docVal.toISOString().slice(0, 10),
DATA_NASCIMENTO: birth.toISOString().slice(0, 10),
});
}
return arr;
}, [dataset]);
const aggregations = useMemo(() => sampleAggregations(mock), [mock]);
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">Dashboard de Pessoas</h1>
<div className="flex gap-2">
<Button onClick={() => {}}>Exportar CSV</Button>
<Button variant="ghost" onClick={() => window.location.reload()}>Atualizar</Button>
</div>
</div>
{/* Linha 1 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<Card>
<CardTitle>Distribuição por Sexo</CardTitle>
<CardContent className="h-64 p-4">
<ResponsiveContainer>
<PieChart>
<Pie data={aggregations.sexo} dataKey="value" nameKey="name" outerRadius={80} label>
{aggregations.sexo.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardTitle>Idade (faixas etárias)</CardTitle>
<CardContent className="h-64 p-4">
<ResponsiveContainer>
<BarChart data={(() => {
const now = new Date();
const ages = mock.map((p) => {
const d = p.DATA_NASCIMENTO ? new Date(p.DATA_NASCIMENTO) : null;
if (!d) return null;
return Math.floor((now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24 * 365.25));
}).filter(Boolean) as number[];
const buckets = ["0-17","18-25","26-35","36-45","46-60","61+"];
const counts: Record<string, number> = {"0-17":0,"18-25":0,"26-35":0,"36-45":0,"46-60":0,"61+":0};
for (const a of ages) {
if (a<=17) counts['0-17']++;
else if (a<=25) counts['18-25']++;
else if (a<=35) counts['26-35']++;
else if (a<=45) counts['36-45']++;
else if (a<=60) counts['46-60']++;
else counts['61+']++;
}
return buckets.map(b => ({ bucket: b, value: counts[b]}));
})()}>
<XAxis dataKey="bucket" />
<YAxis />
<Tooltip />
<Bar dataKey="value" fill={COLORS[0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardTitle>Top Profissões</CardTitle>
<CardContent className="h-64 p-4">
<ResponsiveContainer>
<BarChart data={aggregations.prof} layout="vertical">
<XAxis type="number" />
<YAxis dataKey="name" type="category" />
<Tooltip />
<Bar dataKey="value" fill={COLORS[3]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
{/* Linha 2 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card>
<CardTitle>Distribuição por Unidade Federativa</CardTitle>
<CardContent className="h-72 p-4">
<ResponsiveContainer>
<BarChart data={aggregations.uf}>
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Bar dataKey="value" fill={COLORS[1]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardTitle>Documentos próximos ao vencimento</CardTitle>
<CardContent className="h-72 p-4 overflow-auto">
<ul className="text-sm space-y-2">
{aggregations.docsExpiry
.sort((a,b) => a.days - b.days)
.slice(0, 15)
.map((d) => (
<li key={d.id} className="flex justify-between">
<div>Documento #{d.id}</div>
<div>{d.date} ({d.days} dias)</div>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
{/* Linha 3 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<Card>
<CardTitle>Estado Civil</CardTitle>
<CardContent className="h-56 p-4">
<ResponsiveContainer>
<PieChart>
<Pie data={aggregations.estCivil} dataKey="value" nameKey="name" outerRadius={70} label />
<Tooltip />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardTitle>Grau de Instrução</CardTitle>
<CardContent className="h-56 p-4">
<ResponsiveContainer>
<BarChart data={aggregations.instr}>
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Bar dataKey="value" fill={COLORS[2]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardTitle>Documentos por Tipo (placeholder)</CardTitle>
<CardContent className="h-56 p-4">
<ResponsiveContainer>
<BarChart data={aggregations.prof /* trocar por tipos de documento no dataset real */}>
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Bar dataKey="value" fill={COLORS[4]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
<div className="mt-6">
<small className="text-muted text-sm">
Dica: ao conectar seu dataset real, ajuste o mapeamento em <code>sampleAggregations</code> (por exemplo, <b>TB_PROFISSAO_ID nome da profissão</b>, <b>TB_ESTADOCIVIL_ID descrição</b>).
</small>
</div>
</div>
);
}

View file

@ -1,92 +1,132 @@
import * as React from "react"
import * as React from "react"
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
className
)}
{...props}
/>
)
)
}
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
)
}
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
)
}
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
)
}
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
)
}
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
)
}
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

357
src/components/ui/chart.tsx Normal file
View file

@ -0,0 +1,357 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}