Construiește Dashboard-ul de care ai Cu Adevărat Nevoie
- next.js
- prisma
- data
- architecture
Instrumentele de administrare prefabricate îți forțează afacerea în modelul lor de date. Un strat subțire și onest care citește propria ta bază de date și evidențiază puținele decizii care contează necesită zile, nu luni.
Fiecare produs SaaS vine cu un dashboard. Majoritatea sunt greșite. Afișează metricile pe care furnizorul le-a considerat importante, în layout-ul pe care furnizorul l-a găsit cel mai ușor de construit, cu frecvența pe care furnizorul a găsit-o cel mai ieftin de calculat. Afacerea ta este diferită. Deciziile tale sunt diferite. În momentul în care accepți un panou de administrare de la o terță parte ca vizualizare operațională, începi să iei decizii prin prisma altcuiva.
Vestea bună: un dashboard personalizat nu este un proiect de șase luni. Cu server components Next.js și Prisma, poți livra o vizualizare operațională în citire a propriei baze de date în două sau trei zile de lucru concentrat. Se va încărca mai rapid, va costa mai puțin și îți va spune exact ce trebuie să știi.

Capcana Instrumentelor Prefabricate
Instrumente precum Retool, Metabase și Forest Admin sunt cu adevărat utile pentru anumite echipe. Sunt excelente dacă modelul tău de date se potrivește întâmplător cu ipotezele lor și echipa ta este fericită să opereze în modelul lor de permisiuni. Dar majoritatea companiilor de produs ajung rapid la limită. Nu poți uni cu ușurință trei tabele interne cu logică de afaceri personalizată. Nu poți integra date websocket în timp real lângă o interogare de agregare lentă. Nu poți aplica aceleași reguli de autentificare pe care le folosești în produsul tău. Fiecare soluție de ocolire adaugă un alt nivel de indirectare între o decizie și datele care o conduc.
Problema mai profundă este proprietatea. Când vizualizarea ta operațională trăiește într-un instrument de la terți, echipa ta învață acel instrument în loc de sistemul tău. Inginerii noi petrec timp configurând widget-uri în loc să înțeleagă domeniul. Dashboard-ul devine o cutie neagră în care nimeni nu are deplină încredere.
Un dashboard este util doar dacă persoana care îl citește are încredere în fiecare număr de pe el.
Cum Arată un Strat Subțire și Onest
Alternativa nu este un framework complet de administrare. Este o mică aplicație Next.js orientată spre citire care se află în monorepo-ul tău, partajează clientul tău Prisma și redă doar câteva metrici și tabele pe care echipa ta operațională le folosește efectiv. Fără constructor de widget-uri drag-and-drop. Fără marketplace de plugin-uri. Doar server components care execută interogări și returnează date.
Arhitectura este deliberat plictisitoare. Un server component execută o interogare Prisma. Rezultatul este tipizat. Componentul îl redă. Nu există nicio rută API între ele pentru că nu ai nevoie de una. Server components sunt API-ul. Asta înseamnă că datele nu se serializează niciodată printr-o graniță client, cu excepția cazului în care alegi explicit să faci ceva interactiv.
- Fără preluare de date pe partea clientului pentru căile de citire. Server components elimină spinner-ul de încărcare pentru majoritatea vizualizărilor.
- Autentificarea ta, nu a lor. Dashboard-ul se află în spatele aceluiași middleware ca produsul tău.
- Interogări tipizate. Clientul generat de Prisma înseamnă că o schimbare a schemei rupe dashboard-ul la compilare, nu în producție.
- Incremental. Începe cu un panou de metrici. Adaugă secțiuni când cineva le solicită.
Un Server Component Real: Panou de Metrici și Tabel
Iată cum arată o pagină practică de prezentare generală a veniturilor. Componentul execută două interogări Prisma, calculează un delta simplu și redă inline. Tabelul de mai jos este paginat la nivelul bazei de date, nu în memorie.
// src/app/dashboard/page.tsx
import { prisma } from '@/lib/prisma';
import { formatCurrency, formatPercent } from '@/lib/format';
async function getRevenueStats() {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const startOfLastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const [thisMonth, lastMonth] = await Promise.all([
prisma.order.aggregate({
where: {
createdAt: { gte: startOfMonth },
status: 'completed',
},
_sum: { amountCents: true },
_count: true,
}),
prisma.order.aggregate({
where: {
createdAt: { gte: startOfLastMonth, lt: startOfMonth },
status: 'completed',
},
_sum: { amountCents: true },
_count: true,
}),
]);
const thisTotal = thisMonth._sum.amountCents ?? 0;
const lastTotal = lastMonth._sum.amountCents ?? 0;
const delta = lastTotal === 0 ? null : (thisTotal - lastTotal) / lastTotal;
return {
thisMonthRevenue: thisTotal,
thisMonthOrders: thisMonth._count,
revenueDelta: delta,
};
}
async function getRecentOrders(page = 0, pageSize = 20) {
const [orders, total] = await Promise.all([
prisma.order.findMany({
where: { status: 'completed' },
orderBy: { createdAt: 'desc' },
skip: page * pageSize,
take: pageSize,
select: {
id: true,
createdAt: true,
amountCents: true,
customer: { select: { email: true } },
},
}),
prisma.order.count({ where: { status: 'completed' } }),
]);
return { orders, total };
}
export default async function DashboardPage() {
const [stats, { orders, total }] = await Promise.all([
getRevenueStats(),
getRecentOrders(),
]);
return (
<main className="p-8 max-w-5xl mx-auto space-y-8">
<h1 className="text-2xl font-semibold">Operations</h1>
{/* Metric panel */}
<div className="grid grid-cols-3 gap-4">
<MetricCard
label="Revenue (this month)"
value={formatCurrency(stats.thisMonthRevenue)}
delta={stats.revenueDelta}
/>
<MetricCard
label="Orders (this month)"
value={String(stats.thisMonthOrders)}
/>
<MetricCard
label="Total completed orders"
value={String(total)}
/>
</div>
{/* Orders table */}
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b text-left text-neutral-500">
<th className="py-2 pr-4">Date</th>
<th className="py-2 pr-4">Customer</th>
<th className="py-2 text-right">Amount</th>
</tr>
</thead>
<tbody>
{orders.map((o) => (
<tr key={o.id} className="border-b last:border-0">
<td className="py-2 pr-4 text-neutral-400">
{o.createdAt.toLocaleDateString()}
</td>
<td className="py-2 pr-4">{o.customer.email}</td>
<td className="py-2 text-right font-mono">
{formatCurrency(o.amountCents)}
</td>
</tr>
))}
</tbody>
</table>
</main>
);
}
function MetricCard({
label,
value,
delta,
}: {
label: string;
value: string;
delta?: number | null;
}) {
return (
<div className="rounded-lg border p-4 space-y-1">
<p className="text-xs text-neutral-500">{label}</p>
<p className="text-2xl font-semibold">{value}</p>
{delta != null && (
<p className={delta >= 0 ? 'text-green-600 text-xs' : 'text-red-500 text-xs'}>
{delta >= 0 ? '+' : ''}{formatPercent(delta)} vs last month
</p>
)}
</div>
);
}Două interogări paralele, o formă de rezultat tipizată și un render. Asta este tot. Niciun saga, niciun store, niciun useEffect. Componentul este o funcție pură a datelor sale.
Performanță fără Infrastructură
Paginile de dashboard pot părea lente dacă fiecare navigare declanșează un round-trip complet la baza de date. Next.js îți oferă două instrumente care nu costă nimic în plus. În primul rând, funcția cache din React deduplică apelurile Prisma identice în cadrul aceluiași ciclu de render, astfel încât două componente care solicită același agregat nu declanșează două interogări. În al doilea rând, route-segment caching-ul din Next.js îți permite să setezi un export revalidate pe pagină. O valoare de 60 înseamnă că pagina se reconstruiește cel mult o dată pe minut la edge, nu la fiecare cerere.
// At the top of any dashboard page file:
export const revalidate = 60; // rebuild at most once per minute
// For a truly real-time view (operations room style), use:
export const dynamic = 'force-dynamic'; // always fresh, no caching
// Deduplicate expensive aggregations across components in the same render:
import { cache } from 'react';
export const getRevenueStats = cache(async () => {
// ... Prisma queries
});Alege revalidate = 60 pentru cele mai multe pagini operaționale. Numerele nu trebuie să fie live pentru a fi utile. Rezervă force-dynamic pentru singura vizualizare unde o întârziere de 60 de secunde contează cu adevărat, cum ar fi o coadă de suport sau un feed de alerte de fraudă.
Oferirea unei Memorii pe Termen Lung unui Agent AI pentru Dashboard-ul Tău
Odată ce dashboard-ul tău crește dincolo de câteva pagini, un agent AI de coding devine un colaborator util pentru adăugarea de noi panouri de metrici, ajustarea interogărilor sau remedierea cazurilor limită. Problema cu agenții AI este starea: fiecare sesiune începe de la zero, iar agentul nu îți amintește schema Prisma, convențiile de denumire sau motivul pentru care ai ales o anumită structură de interogare.
Soluția este un fișier de memorie markdown inclus în repository. Când agentul citește acest fișier la începutul unei sesiuni, are contextul operațional complet al dashboard-ului tău fără să fie nevoie să îl reexplici. Instrumente precum Claude Code citesc automat un fișier AGENTS.md. Altele pot fi îndrumate către un fișier din system prompt-ul lor sau din fereastra de context.

Iată un exemplu minimal dar complet despre cum arată acel fișier de memorie pentru un proiect de dashboard. Cheia este să fii concret și specific: numele câmpurilor din schemă, căile fișierelor, comenzile exacte care pornesc serverul de dezvoltare și problemele pe care le-ai întâlnit deja.
# AGENTS.md — Dashboard Project Memory
## What this repo is
Internal operations dashboard for Jumpino. Read-only views over the production
Postgres database via Prisma. Next.js 15 App Router, TypeScript strict mode,
Tailwind CSS for layout only (no component library).
## Database
- Prisma schema lives at `prisma/schema.prisma`.
- The `Order` model uses `amountCents: Int` (not a float). Always format with
`formatCurrency(cents)` from `src/lib/format.ts`.
- `customer` is a relation on `Order`, not a field. Use a `select` or `include`
in every query that needs the email.
- Never run migrations from this repo. Schema changes happen in the main API
repo and are pulled via `npx prisma db pull`.
## File structure
- Dashboard pages: `src/app/dashboard/**`
- Shared queries (used by 2+ pages): `src/lib/queries/`
- One-off page queries: keep inline in the page component
- Formatting utils: `src/lib/format.ts`
## Commands
- Dev server: `pnpm dev` (port 3001, main product runs on 3000)
- Type check: `pnpm tsc --noEmit`
- No tests yet; manual verification only
## Conventions
- All dashboard pages export `revalidate = 60` unless they need live data.
- Use `Promise.all` for parallel queries; never await in sequence inside a loop.
- MetricCard component lives in `src/components/MetricCard.tsx`; import it,
do not rewrite it.
## Known gotchas
- `createdAt` on Order is stored in UTC; display in the user's local timezone
via `toLocaleDateString()` — the server does not know the browser timezone.
- Prisma `aggregate` returns `null` for `_sum` when there are no rows. Always
use the nullish coalescing pattern: `result._sum.amountCents ?? 0`.Când actualizezi schema, adaugi o problemă cunoscută sau introduci o nouă convenție, actualizezi acest fișier. Agentul îl preia în sesiunea următoare. Nu este vorba de instrumente complicate: este un fișier text pe care îl menții în același mod în care menții un README. Diferența este că un agent poate acționa pe baza lui în mod fiabil, în timp ce un om ar putea sări peste citirea lui.
Când să Adaugi Mai Mult
Începe minimal. Un panou de metrici și un tabel paginat acoperă 80% din luarea deciziilor operaționale pentru cele mai multe produse în stadiu incipient. Întrebările de pus înainte de a adăuga o nouă secțiune sunt: cine va acționa pe baza acestui număr și ce acțiune va întreprinde? Dacă răspunsul este vag, metrica nu aparține încă dashboard-ului.
Adăugările naturale, aproximativ în ordinea valorii: un câmp de căutare peste tabelul de comenzi (o singură server action cu un parametru de interogare), un selector de interval de date pentru a delimita agregările și un buton de export care transmite un CSV direct din Prisma. Fiecare dintre acestea necesită câteva ore de lucru, nu câteva zile, pentru că fundația este deja corectă.
- Căutare: Transmite un parametru de interogare
qpaginii, adaugă o clauzăwhere: { customer: { email: { contains: q } } }la interogarea Prisma. - Interval de date: Două câmpuri
<input type="date">care conduc parametrii de interogare pe server component. - Export CSV: Un route handler la
GET /dashboard/orders.csvcare transmite rezultatele Prisma printr-un serializator simplu. - Grafice:
rechartscu un wrapper de client component este opțiunea cu cea mai mică fricțiune; păstrează preluarea datelor pe server și transmite-le ca props.
Scopul nu este să construiești un produs. Scopul este să construiești un instrument în care echipa ta operațională are încredere, pe care inginerii tăi îl pot modifica într-o după-amiază și care afișează exact informațiile necesare pentru a lua următoarea decizie bună. Este realizabil în zile. Necesită doar rezistența la atracția unui framework care promite totul și livrează zgomot.