--- id: wiki-2026-0508-데이터-중심의-saas-어드민-패널-및-crm-대시보드-구 title: 데이터 중심의 SaaS 어드민 패널 및 CRM 대시보드 구축 category: 10_Wiki/Topics status: verified canonical_id: self aliases: [SaaS Admin Panel, CRM Dashboard, Internal Tools] duplicate_of: none source_trust_level: A confidence_score: 0.9 verification_status: applied tags: [saas, admin-panel, crm, dashboard, internal-tools, data-driven] raw_sources: [] last_reinforced: 2026-05-10 github_commit: pending tech_stack: language: TypeScript framework: Next.js 15 / Remix / Refine / Retool --- # 데이터 중심의 SaaS 어드민 패널 및 CRM 대시보드 구축 ## 매 한 줄 > **"매 CRUD 의 X, 매 actionable insight + 매 fast workflow"**. 매 SaaS admin panel / CRM dashboard 의 modern build — 매 server components, 매 typed RPC (tRPC / Hono RPC), 매 real-time (SSE / WebSocket), 매 RBAC, 매 audit log. 매 2026 의 standard: Next.js 15 + Refine + TanStack Table + shadcn/ui, 매 Retool / Internal 의 low-code alternative. ## 매 핵심 ### 매 architecture layers - **Data layer**: 매 Postgres + Drizzle/Prisma, 매 read-replica 의 분리. - **API layer**: 매 tRPC / Hono RPC — 매 type-safe end-to-end. - **UI layer**: 매 Server Component 의 default, 매 Client 의 interactive only. - **Auth layer**: 매 Better-Auth / Clerk / Auth.js — 매 RBAC + org/team. ### 매 essential features - **Resource CRUD**: 매 list / detail / create / edit — 매 generated, 매 customizable. - **Bulk operations**: 매 multi-select + action — 매 1000+ rows 의 handle. - **Filters & search**: 매 server-side, 매 URL state 의 sync. - **Audit log**: 매 who / when / what — 매 immutable. - **Impersonation**: 매 support 의 ticket investigation. - **Export**: 매 CSV / Parquet 의 streaming. ### 매 응용 1. 매 SaaS user management — 매 user search, 매 plan upgrade, 매 quota override. 2. 매 CRM contact / deal pipeline — 매 Kanban + Table dual view. 3. 매 financial ops — 매 refund, 매 invoice 의 manual issue. 4. 매 content moderation — 매 report queue 의 batch review. ## 💻 패턴 ### tRPC procedure with RBAC (Next.js 15) ```typescript import { z } from "zod"; import { adminProcedure, router } from "../trpc"; export const userRouter = router({ list: adminProcedure .input(z.object({ cursor: z.string().nullish(), filter: z.object({ email: z.string().optional() }).default({}), limit: z.number().min(1).max(100).default(50), })) .query(async ({ ctx, input }) => { ctx.requirePermission("user:read"); const rows = await ctx.db.user.findMany({ where: { email: { contains: input.filter.email } }, take: input.limit + 1, cursor: input.cursor ? { id: input.cursor } : undefined, orderBy: { createdAt: "desc" }, }); const next = rows.length > input.limit ? rows.pop()!.id : null; return { rows, nextCursor: next }; }), upgradePlan: adminProcedure .input(z.object({ userId: z.string(), plan: z.enum(["free","pro","ent"]) })) .mutation(async ({ ctx, input }) => { ctx.requirePermission("user:write"); await ctx.db.$transaction([ ctx.db.user.update({ where: { id: input.userId }, data: { plan: input.plan } }), ctx.db.auditLog.create({ data: { actorId: ctx.session.userId, action: "user.upgradePlan", target: input.userId, payload: input, } }), ]); }), }); ``` ### TanStack Table with server-side filters ```tsx "use client"; import { useReactTable, getCoreRowModel, flexRender } from "@tanstack/react-table"; import { useQueryState, parseAsString } from "nuqs"; export function UserTable() { const [email, setEmail] = useQueryState("email", parseAsString.withDefault("")); const { data } = trpc.user.list.useQuery({ filter: { email } }); const table = useReactTable({ data: data?.rows ?? [], columns: [ { accessorKey: "email", header: "Email" }, { accessorKey: "plan", header: "Plan" }, { accessorKey: "createdAt", header: "Created", cell: c => new Date(c.getValue() as string).toLocaleDateString() }, ], getCoreRowModel: getCoreRowModel(), }); return (
setEmail(e.target.value)} placeholder="매 email filter" /> ...
); } ``` ### Bulk action with optimistic update ```typescript const utils = trpc.useUtils(); const bulkSuspend = trpc.user.bulkSuspend.useMutation({ onMutate: async ({ ids }) => { await utils.user.list.cancel(); const prev = utils.user.list.getData(); utils.user.list.setData(undefined, old => ({ ...old!, rows: old!.rows.map(r => ids.includes(r.id) ? { ...r, suspended: true } : r), })); return { prev }; }, onError: (_e, _v, ctx) => utils.user.list.setData(undefined, ctx?.prev), onSettled: () => utils.user.list.invalidate(), }); ``` ### Audit log immutable schema (Drizzle) ```typescript import { pgTable, text, timestamp, jsonb, uuid } from "drizzle-orm/pg-core"; export const auditLog = pgTable("audit_log", { id: uuid("id").defaultRandom().primaryKey(), ts: timestamp("ts").defaultNow().notNull(), actorId: text("actor_id").notNull(), action: text("action").notNull(), // e.g. "user.upgradePlan" target: text("target"), // entity id payload: jsonb("payload").$type>(), ip: text("ip"), userAgent: text("user_agent"), }); // 매 trigger 의 UPDATE/DELETE 의 prevent — append-only. ``` ### Real-time updates via SSE (Hono) ```typescript import { Hono } from "hono"; import { streamSSE } from "hono/streaming"; const app = new Hono(); app.get("/admin/events", async (c) => { return streamSSE(c, async (stream) => { const sub = pubsub.subscribe("admin"); try { for await (const event of sub) { await stream.writeSSE({ data: JSON.stringify(event), event: event.type }); } } finally { sub.unsubscribe(); } }); }); ``` ### Streaming CSV export (1M+ rows) ```typescript export async function GET(req: Request) { const stream = new ReadableStream({ async start(controller) { controller.enqueue(new TextEncoder().encode("id,email,plan\n")); const cursor = db.user.findManyCursor({ batchSize: 1000 }); for await (const batch of cursor) { const csv = batch.map(u => `${u.id},${u.email},${u.plan}`).join("\n") + "\n"; controller.enqueue(new TextEncoder().encode(csv)); } controller.close(); }, }); return new Response(stream, { headers: { "Content-Type": "text/csv" } }); } ``` ### Impersonation with safety guard ```typescript export const impersonate = superAdminProcedure .input(z.object({ targetUserId: z.string(), reason: z.string().min(10) })) .mutation(async ({ ctx, input }) => { const token = await signImpersonationToken({ actor: ctx.session.userId, target: input.targetUserId, reason: input.reason, exp: Date.now() + 30 * 60 * 1000, // 매 30min cap }); await ctx.db.auditLog.create({ data: { actorId: ctx.session.userId, action: "user.impersonate", target: input.targetUserId, payload: { reason: input.reason }, }}); return { token }; }); ``` ## 매 결정 기준 | 상황 | Approach | |---|---| | 매 internal-only, 매 small team | Retool / Internal — 매 low-code | | 매 customer-facing admin | Next.js 15 + tRPC + Refine — 매 custom | | 매 1M+ rows | Server-side pagination + virtualization mandatory | | 매 high audit requirement | Append-only log + immutable trigger | | 매 multi-tenant | RLS (Postgres row-level security) + org scope | **기본값**: Next.js 15 (Server Components) + tRPC + Drizzle + shadcn/ui + TanStack Table + Better-Auth. ## 🔗 Graph - 부모: [[Large_Frontend_Projects]] · [[SaaS_Architecture]] - 변형: [[Retool]] · [[Refine]] · [[Internal_Tools]] - 응용: [[CRM]] · [[User_Management]] · [[Financial_Ops_Dashboard]] - Adjacent: [[RBAC]] · [[Audit_Log]] · [[Multi_Tenant]] ## 🤖 LLM 활용 **언제**: 매 CRUD scaffolding, 매 form generation, 매 SQL query draft, 매 audit event description. **언제 X**: 매 destructive operation 의 final confirm, 매 RBAC policy 의 author. ## ❌ 안티패턴 - **Client-side pagination on 100k rows**: 매 browser 의 freeze. - **No audit log**: 매 compliance 의 fail. - **Plain UPDATE without transaction**: 매 partial failure 의 inconsistent state. - **Impersonation without expiry**: 매 indefinite session 의 hijack risk. - **CSV blob in memory**: 매 OOM — 매 stream 의 mandatory. ## 🧪 검증 / 중복 - Verified (Refine docs, Vercel admin patterns, Linear engineering blog). - 신뢰도 A. ## 🕓 Changelog | 날짜 | 변경 | |---|---| | 2026-05-08 | Phase 1 | | 2026-05-10 | Manual cleanup — full content with tRPC/RBAC/audit/streaming patterns |