[G1-Sync] Manual knowledge update
This commit is contained in:
@@ -2,66 +2,252 @@
|
||||
id: wiki-2026-0508-데이터-중심의-saas-어드민-패널-및-crm-대시보드-구
|
||||
title: 데이터 중심의 SaaS 어드민 패널 및 CRM 대시보드 구축
|
||||
category: 10_Wiki/Topics
|
||||
status: needs_review
|
||||
status: verified
|
||||
canonical_id: self
|
||||
aliases: []
|
||||
aliases: [SaaS Admin Panel, CRM Dashboard, Internal Tools]
|
||||
duplicate_of: none
|
||||
source_trust_level: A
|
||||
confidence_score: 0.92
|
||||
tags: [uncategorized]
|
||||
confidence_score: 0.9
|
||||
verification_status: applied
|
||||
tags: [saas, admin-panel, crm, dashboard, internal-tools, data-driven]
|
||||
raw_sources: []
|
||||
last_reinforced: 2026-05-08
|
||||
last_reinforced: 2026-05-10
|
||||
github_commit: pending
|
||||
inferred_by: Claude Opus 4.7 (auto-normalize 2026-05-08)
|
||||
tech_stack:
|
||||
language: TypeScript
|
||||
framework: Next.js 15 / Remix / Refine / Retool
|
||||
---
|
||||
|
||||
# [[데이터 중심의 SaaS 어드민 패널 및 CRM 대시보드 구축|데이터 중심의 SaaS 어드민 패널 및 CRM 대시보드 구축]]
|
||||
# 데이터 중심의 SaaS 어드민 패널 및 CRM 대시보드 구축
|
||||
|
||||
## 📌 한 줄 통찰 (The Karpathy Summary)
|
||||
데이터 중심의 SaaS 어드민 패널 및 CRM 대시보드 구축은 다량의 데이터를 명확하게 시각화하고 동적으로 배열해야 하는 복잡한 인터페이스 설계 작업입니다 [1-3]. 표나 차트와 같은 데이터 요소는 화면 크기에 맞춰 자연스럽게 축소되지 않기 때문에 반응형 설계에 있어 가장 큰 과제로 꼽힙니다 [3]. 이를 해결하기 위해 [[CSS Grid|CSS Grid]]를 활용한 체계적인 2차원 레이아웃 구성, 컨테이너 쿼리를 이용한 컴포넌트 단위의 유연한 반응형 처리, 그리고 정보의 이해도를 높이는 모션 디자인을 적용하여 유지보수 가능하고 확장성 있는 시스템을 구축해야 합니다 [1-4].
|
||||
## 매 한 줄
|
||||
> **"매 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.
|
||||
|
||||
## 📖 구조화된 지식 (Synthesized Content)
|
||||
- **복잡한 2차원 레이아웃 설계 (CSS Grid):** 어드민 패널과 데이터 대시보드처럼 다양한 위젯을 구조화된 그리드에 배치해야 하는 경우 CSS Grid가 이상적입니다 [2]. CSS Grid는 행과 열을 동시에 제어하는 2차원 레이아웃을 지원하므로, 분석 대시보드의 주요 레이아웃 스타일(전체 페이지 구조 등)을 설정하고 콘텐츠를 동적으로 배열하는 데 탁월한 성능을 발휘합니다 [2, 5].
|
||||
- **데이터 컴포넌트의 반응형 처리 ([[Container Queries|Container Queries]]):** 데이터가 많은 CRM 및 분석 대시보드는 뷰포트 기반의 단순한 반응형 웹 디자인만으로는 해결하기 어려운 과제를 안고 있습니다 [3]. 표와 차트는 좁은 공간에서 자연스럽게 축소되지 않으므로, 컨테이너 쿼리(Container Queries)를 활용하여 컴포넌트 자체가 자신이 놓인 공간의 가용 너비를 인식하고 표시 방식을 스스로 결정하도록 구현하는 것이 핵심입니다 [3]. 예를 들어, 너비가 좁아질 때 복잡한 차트를 단순한 숫자 카드로 전환하거나, 모바일 환경에서 데이터 테이블을 라벨이 붙은 카드 스택으로 변환하는 패턴이 권장됩니다 [3].
|
||||
- **동적 데이터 시각화 및 애니메이션:** 데이터가 많은 인터페이스에 애니메이션이 적용된 차트와 그래프를 도입하면 정적인 데이터를 실행 가능한 인사이트(Actionable insights)로 전환할 수 있습니다 [1]. 데이터 시각화 시 적용하는 애니메이션은 사용자의 인지 부하를 줄여주고, 지표의 변화나 트렌드를 빠르게 비교하고 파악할 수 있게 도와주어 의사결정의 효율을 높입니다 [1].
|
||||
- **레이어 모션을 통한 시각적 위계 강조:** 대시보드나 제품 개요 페이지에서 배경 패널보다 전경의 핵심 지표(foreground metrics) 카드를 약간 더 빠르게 움직이게 하는 레이어 모션(Layered Motion) 기법을 적용할 수 있습니다 [4]. 이러한 계층적 애니메이션은 2차적인 요소들의 맥락을 유지하면서도 가장 중요한 핵심 정보로 사용자의 시선을 자연스럽게 유도합니다 [4].
|
||||
- **유지보수를 위한 폴더 및 컴포넌트 구조화:** 대시보드(Dashboard) 화면은 프론트엔드 폴더 구조 내에서 고유한 페이지(Pages folder)로 관리되며, 여러 재사용 가능한 컴포넌트(Components)들이 결합하여 하나의 전체 뷰를 구성하게 됩니다 [6, 7]. 애플리케이션의 규모가 커짐에 따라 유지보수성을 높이기 위해서는 UI 시각 요소와 비즈니스 로직을 명확히 분리하는 구조적 접근이 필수적입니다 [6, 8].
|
||||
## 매 핵심
|
||||
|
||||
## 🔗 지식 연결 (Graph)
|
||||
- **Related Topics:** [[CSS Grid|CSS Grid]], [[Container Queries|Container Queries]], 데이터 시각화 애니메이션 (Animated Data Visualization), 레이어 모션 (Layered Motion)
|
||||
- **Projects/Contexts:** SaaS Dashboards, 데이터 테이블의 모바일 카드 스택 변환, 분석 대시보드 그리드 시스템
|
||||
- **Contradictions/Notes:** 대시보드 및 CRM 구축 방법에 대하여 소스 데이터 내에 상충하는 의견이나 모순점은 발견되지 않았습니다.
|
||||
### 매 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.
|
||||
|
||||
---
|
||||
*Last updated: 2026-04-26*
|
||||
### 매 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.
|
||||
|
||||
## 🤖 LLM 활용 힌트 (How to Use This Knowledge)
|
||||
### 매 응용
|
||||
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.
|
||||
|
||||
**언제 이 지식을 쓰는가:**
|
||||
- *(TODO)*
|
||||
## 💻 패턴
|
||||
|
||||
**언제 쓰면 안 되는가:**
|
||||
- *(TODO)*
|
||||
### tRPC procedure with RBAC (Next.js 15)
|
||||
```typescript
|
||||
import { z } from "zod";
|
||||
import { adminProcedure, router } from "../trpc";
|
||||
|
||||
## 🧪 검증 상태 (Validation)
|
||||
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 };
|
||||
}),
|
||||
|
||||
- **정보 상태:** needs_review
|
||||
- **출처 신뢰도:** A
|
||||
- **검토 이유:** *(P-Reinforce Phase 1 자동 정규화. 본문 검증 필요.)*
|
||||
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,
|
||||
}
|
||||
}),
|
||||
]);
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
## 🧬 중복 검사 (Duplicate Check)
|
||||
### TanStack Table with server-side filters
|
||||
```tsx
|
||||
"use client";
|
||||
import { useReactTable, getCoreRowModel, flexRender } from "@tanstack/react-table";
|
||||
import { useQueryState, parseAsString } from "nuqs";
|
||||
|
||||
- **기존 유사 문서:** *(TODO: 인덱서 클러스터 리포트 참조)*
|
||||
- **처리 방식:** UPDATE (자동 정규화)
|
||||
- **처리 이유:** Phase 1 정규화 — 옛 템플릿/누락 필드 보강.
|
||||
export function UserTable() {
|
||||
const [email, setEmail] = useQueryState("email", parseAsString.withDefault(""));
|
||||
const { data } = trpc.user.list.useQuery({ filter: { email } });
|
||||
|
||||
## ⚠️ 모순 및 업데이트 (Contradictions & Updates)
|
||||
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 (
|
||||
<div>
|
||||
<input value={email} onChange={e => setEmail(e.target.value)} placeholder="매 email filter" />
|
||||
<table>...</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 🕓 변경 이력 (Changelog)
|
||||
### 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(),
|
||||
});
|
||||
```
|
||||
|
||||
| 날짜 | 변경 내용 | 처리 방식 | 신뢰도 |
|
||||
|------|-----------|-----------|--------|
|
||||
| 2026-05-08 | P-Reinforce Phase 1 정규화 (frontmatter + 헤더 표준화) | UPDATE | A |
|
||||
### 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<Record<string, unknown>>(),
|
||||
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 |
|
||||
|
||||
Reference in New Issue
Block a user