3 Commits

Author SHA1 Message Date
DDD1542 6b17c1fadf chore: commit remaining workspace updates
Build & Deploy to K8s / build-and-deploy (push) Failing after 8m6s
Include the outstanding numbering-rule admin page changes, TabBar interaction updates, V5 layout theme accent styling, and auto-generation option compatibility fix.

Add the local web-prototype skill assets, numbering-rule design variants, control IDE refactor note, and the table canonical cleanup plan/prompts used across phases B through F.

This commit captures the remaining workspace files after the canonical table cleanup commit so the branch can be pushed without leaving local dirty work behind.
2026-05-21 12:06:55 +09:00
DDD1542 7d204bfffd refactor: complete canonical table cleanup
Build & Deploy to K8s / build-and-deploy (push) Failing after 14m3s
2026-05-21 11:55:08 +09:00
DDD1542 bd4286f7ac refactor: finalize canonical data-view cleanup
Build & Deploy to K8s / build-and-deploy (push) Successful in 6m29s
2026-05-20 11:30:26 +09:00
115 changed files with 20471 additions and 19287 deletions
+97
View File
@@ -0,0 +1,97 @@
---
name: web-prototype
description: |
General-purpose desktop web prototype. Single self-contained HTML file built
by copying the seed `assets/template.html` and pasting section layouts from
`references/layouts.md`. Default for any landing / marketing / docs / SaaS
page when no more specific skill matches.
triggers:
- "prototype"
- "mockup"
- "landing"
- "single page"
- "marketing page"
- "homepage"
od:
mode: prototype
platform: desktop
scenario: design
preview:
type: html
entry: index.html
design_system:
requires: true
sections: [color, typography, layout, components]
---
# Web Prototype Skill
Produce a single, self-contained HTML prototype using the bundled seed and layout library — **not** by writing CSS from scratch. The seed already encodes good defaults (typography, spacing, accent budget). Your job is to compose it.
## Resource map
```
web-prototype/
├── SKILL.md ← you're reading this
├── assets/
│ └── template.html ← seed: tokens + class system + chrome (READ FIRST)
└── references/
├── layouts.md ← 8 paste-ready section skeletons
└── checklist.md ← P0/P1/P2 self-review
```
## Workflow
### Step 0 — Pre-flight (do this once before writing anything)
1. **Read `assets/template.html` end-to-end** — at minimum through the `<style>` block. The class inventory at the top of `references/layouts.md` lists every class that must be defined there; if one is missing, add it to `<style>` rather than re-defining it inline on every section.
2. **Read `references/layouts.md`** so you know which section skeletons exist. Don't write a section type that isn't covered — pick the closest layout and adapt.
3. **Read the active DESIGN.md** (already injected into your system prompt). Map its colors to the six `:root` variables in the seed; don't introduce new tokens.
### Step 1 — Copy the seed
Copy `assets/template.html` to the project root as `index.html`. Replace the six `:root` variables with the active design system's tokens. Replace the page `<title>` and the topnav brand.
### Step 2 — Plan the section list
**Pick layouts before writing copy.** Default rhythms (from `layouts.md`):
| Page kind | Default rhythm |
|---|---|
| Landing | 1 hero → 3 features → 4 stats *or* 5 quote → custom split → 6 cta |
| Marketing / editorial | 1 hero-center → 7 log list → 6 cta |
| Pricing | 1 hero-center → 8 comparison table → 6 cta |
| Docs index | 1 hero-center → 7 log list (sections of docs) → 6 cta |
State the chosen list in one sentence to the user *before* writing — they can redirect cheaply now and not after 200 lines of HTML.
### Step 3 — Paste and fill
For each chosen layout, copy the `<section>` block from `layouts.md` into `<main id="content">` of your `index.html`. Replace bracketed `[REPLACE]` strings with real, specific copy from the user's brief. **No filler** — if a slot is empty, the section is the wrong choice; pick a different layout.
### Step 4 — Self-check
Run through `references/checklist.md` top to bottom. Every P0 item must pass before you move on. P1 items should pass; P2 are bonus.
### Step 5 — Emit the artifact
Wrap `index.html` in `<artifact>` tags. One sentence before describing what's there. Stop after `</artifact>`.
## Hard rules (the seed protects most of these — don't fight it)
- **Single accent, used at most twice per screen.** Eyebrow + primary CTA is the default budget.
- **Display font is serif** (Iowan Old Style / Charter / Georgia in the seed). Sans for body. Mono for numerics, captions, eyebrows.
- **Image placeholders, not external URLs.** Use the `.ph-img` class — never link to a stock photo CDN.
- **Mobile reflow already works** via the seed's media query at 920px. Don't break it by adding fixed widths.
- **`data-od-id` on every `<section>`** so comment mode can target it.
## Output contract
```
<artifact identifier="kebab-case-slug" type="text/html" title="Human Title">
<!doctype html>
<html>...</html>
</artifact>
```
One sentence before the artifact. Nothing after.
@@ -0,0 +1,338 @@
<!doctype html>
<!--
OD web-prototype seed.
Copy this file to <project>/index.html, then fill `<main id="content">` by
pasting blocks from `references/layouts.md`. Every class referenced in
layouts.md is defined here — DO NOT remove unused ones unless you also
remove their callers, and DO NOT invent new global classes (use inline
style="…" for one-off tweaks).
Theme tokens at the top of `<style>` are the *only* place to set palette
and type. They map cleanly onto a DESIGN.md — when an active design system
is injected, swap these six variables and everything reflows.
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>[REPLACE] Page title · brand</title>
<style>
/* ─── tokens ─────────────────────────────────────────────────────────
Six variables. Bind them to the active DESIGN.md and stop.
Do not introduce raw hex anywhere else in this file. */
:root {
--bg: #fafaf7; /* page background — never #fff */
--surface: #ffffff; /* cards, modals, raised areas */
--fg: #1a1916; /* primary text — never #000 */
--muted: #6b6964; /* secondary text, captions */
--border: #e8e5df; /* hairlines, dividers */
--accent: #c96442; /* one accent — used at most 2× per screen */
/* derived — do not change */
--accent-soft: color-mix(in oklch, var(--accent) 14%, transparent);
--fg-soft: color-mix(in oklch, var(--fg) 6%, transparent);
/* type — display = serif (default), body = sans, mono for numerics */
--font-display: 'Iowan Old Style', 'Charter', Georgia, 'Times New Roman', serif;
--font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--font-mono: ui-monospace, 'JetBrains Mono', 'SF Mono', Menlo, monospace;
/* scale — clamp() so it works at 1280, 1440, 1920 without media queries */
--fs-h1: clamp(44px, 6vw, 76px);
--fs-h2: clamp(32px, 4vw, 48px);
--fs-h3: 22px;
--fs-lead: 19px;
--fs-body: 16px;
--fs-meta: 13px;
/* spacing — 8-point grid */
--gap-xs: 8px;
--gap-sm: 12px;
--gap-md: 20px;
--gap-lg: 32px;
--gap-xl: 56px;
--gap-2xl: 96px;
--container: 1120px;
--gutter: 32px;
--radius: 10px;
--radius-lg: 16px;
}
/* ─── reset & base ──────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--font-body);
font-size: var(--fs-body);
line-height: 1.55;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
img, svg { display: block; max-width: 100%; }
a { color: inherit; text-decoration: none; }
button { font: inherit; cursor: pointer; }
p { text-wrap: pretty; }
h1, h2, h3, h4 { text-wrap: balance; }
/* ─── layout primitives ─────────────────────────────────────────── */
.container {
max-width: var(--container);
margin-inline: auto;
padding-inline: var(--gutter);
}
.section {
padding-block: clamp(48px, 8vw, var(--gap-2xl));
}
.section + .section { border-top: 1px solid var(--border); }
.stack { display: flex; flex-direction: column; }
.stack > * + * { margin-top: var(--gap-md); }
.row { display: flex; align-items: center; gap: var(--gap-md); }
.row-between { display: flex; align-items: center; justify-content: space-between; gap: var(--gap-md); }
.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--gap-lg); }
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--gap-lg); }
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--gap-md); }
.grid-2-1 { display: grid; grid-template-columns: 2fr 1fr; gap: var(--gap-xl); align-items: start; }
.grid-1-2 { display: grid; grid-template-columns: 1fr 2fr; gap: var(--gap-xl); align-items: start; }
@media (max-width: 920px) {
.grid-2, .grid-3, .grid-4, .grid-2-1, .grid-1-2 { grid-template-columns: 1fr; }
}
/* ─── type ──────────────────────────────────────────────────────── */
.h1, h1 { font-family: var(--font-display); font-size: var(--fs-h1); line-height: 1.04; letter-spacing: -0.02em; margin: 0; }
.h2, h2 { font-family: var(--font-display); font-size: var(--fs-h2); line-height: 1.1; letter-spacing: -0.015em; margin: 0; }
.h3, h3 { font-size: var(--fs-h3); font-weight: 600; line-height: 1.3; letter-spacing: -0.005em; margin: 0; }
.lead { font-size: var(--fs-lead); line-height: 1.55; color: var(--muted); max-width: 60ch; margin: 0; }
.eyebrow {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
margin: 0 0 var(--gap-md);
}
.meta { font-family: var(--font-mono); font-size: var(--fs-meta); color: var(--muted); }
.num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
/* ─── chrome: nav + footer ──────────────────────────────────────── */
.topnav {
position: sticky; top: 0; z-index: 10;
background: color-mix(in oklch, var(--bg) 92%, transparent);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
}
.topnav-inner { display: flex; align-items: center; justify-content: space-between; padding-block: 14px; }
.topnav .logo { font-family: var(--font-display); font-size: 19px; font-weight: 600; letter-spacing: -0.01em; }
.topnav nav { display: flex; gap: var(--gap-lg); }
.topnav nav a { font-size: 14px; color: var(--muted); }
.topnav nav a:hover { color: var(--fg); }
.pagefoot { padding-block: var(--gap-xl); color: var(--muted); font-size: 13px; border-top: 1px solid var(--border); }
.pagefoot .row-between { flex-wrap: wrap; gap: var(--gap-md); }
/* ─── buttons ───────────────────────────────────────────────────── */
.btn {
display: inline-flex; align-items: center; gap: 8px;
padding: 11px 20px;
border-radius: var(--radius);
border: 1px solid transparent;
font-size: 15px;
font-weight: 500;
letter-spacing: -0.005em;
transition: transform 0.05s ease, background 0.15s ease, border-color 0.15s ease;
}
.btn:active { transform: translateY(1px); }
.btn-primary { background: var(--accent); color: var(--surface); border-color: var(--accent); }
.btn-primary:hover { background: color-mix(in oklch, var(--accent) 88%, black); }
.btn-secondary { background: transparent; color: var(--fg); border-color: var(--border); }
.btn-secondary:hover { border-color: var(--fg); }
.btn-ghost { background: transparent; color: var(--fg); border-color: transparent; padding-inline: 8px; }
.btn-ghost:hover { color: var(--accent); }
.btn-arrow::after { content: '→'; transition: transform 0.15s ease; }
.btn-arrow:hover::after { transform: translateX(2px); }
/* ─── card / surface ────────────────────────────────────────────── */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 28px;
}
.card-flat { background: transparent; border: 0; padding: 0; }
.card-rule { background: transparent; border: 0; border-top: 1px solid var(--fg); padding: 24px 0 0; border-radius: 0; }
/* ─── feature cell (icon + h3 + p) ──────────────────────────────── */
.feature .feature-mark {
width: 36px; height: 36px;
display: grid; place-items: center;
border: 1px solid var(--border);
border-radius: 10px;
color: var(--accent);
margin-bottom: var(--gap-md);
}
.feature .feature-mark svg { width: 18px; height: 18px; }
.feature h3 { margin-bottom: 6px; }
.feature p { margin: 0; color: var(--muted); font-size: 15px; }
/* ─── stat (big number + label) ─────────────────────────────────── */
.stat .stat-num {
font-family: var(--font-display);
font-size: clamp(56px, 8vw, 96px);
line-height: 0.95;
letter-spacing: -0.04em;
color: var(--accent);
font-weight: 600;
}
.stat .stat-label { color: var(--muted); font-size: 14px; margin-top: 8px; max-width: 24ch; }
.stat .stat-unit { font-size: 0.5em; opacity: 0.7; margin-left: 2px; }
/* ─── quote / testimonial ───────────────────────────────────────── */
.quote {
font-family: var(--font-display);
font-size: clamp(24px, 2.6vw, 32px);
line-height: 1.32;
letter-spacing: -0.01em;
max-width: 28ch;
}
.quote-author { color: var(--muted); font-size: 14px; margin-top: var(--gap-md); }
.quote-mark {
font-family: var(--font-display);
font-size: 140px; line-height: 0.7;
color: var(--accent); opacity: 0.18;
margin-bottom: -28px;
}
/* ─── pill / badge / tag ────────────────────────────────────────── */
.pill {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px;
background: var(--accent-soft);
color: var(--accent);
border-radius: 999px;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.tag {
display: inline-flex; align-items: center;
padding: 4px 10px;
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
border-radius: 999px;
font-size: 12px;
}
/* ─── form field ────────────────────────────────────────────────── */
.field { display: flex; flex-direction: column; gap: 6px; }
.field label { font-size: 13px; color: var(--muted); }
.input, .textarea {
width: 100%;
padding: 11px 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: var(--fg);
font: inherit;
font-size: 15px;
}
.input:focus, .textarea:focus {
outline: 2px solid var(--accent-soft);
border-color: var(--accent);
}
.textarea { min-height: 96px; resize: vertical; line-height: 1.55; }
/* ─── table (data-style, no chrome) ─────────────────────────────── */
.ds-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.ds-table th, .ds-table td { padding: 12px 14px; text-align: left; border-bottom: 1px solid var(--border); }
.ds-table th { color: var(--muted); font-weight: 500; font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.04em; text-transform: uppercase; }
.ds-table tbody tr:hover { background: var(--fg-soft); }
.ds-table .num-col { font-family: var(--font-mono); font-variant-numeric: tabular-nums; text-align: right; }
/* ─── image placeholder (DS-tinted block, never broken <img>) ──── */
.ph-img {
background:
linear-gradient(135deg, var(--accent-soft), var(--fg-soft)),
var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
aspect-ratio: 16 / 10;
display: grid; place-items: center;
color: var(--muted);
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.04em;
}
.ph-img.square { aspect-ratio: 1 / 1; }
.ph-img.portrait { aspect-ratio: 3 / 4; }
.ph-img.wide { aspect-ratio: 16 / 9; }
/* ─── divider ───────────────────────────────────────────────────── */
.rule { border: 0; border-top: 1px solid var(--border); margin: 0; }
.rule-strong { border: 0; border-top: 1px solid var(--fg); margin: 0; }
/* ─── hero variants used by layouts.md ──────────────────────────── */
.hero { padding-block: clamp(80px, 12vw, 160px); }
.hero-center { text-align: center; max-width: 32ch; margin-inline: auto; }
.hero h1 { margin-bottom: var(--gap-md); }
.hero .lead { margin-bottom: var(--gap-lg); }
.hero-cta { display: inline-flex; gap: var(--gap-sm); flex-wrap: wrap; }
.hero-center .hero-cta { justify-content: center; }
.hero-split { display: grid; grid-template-columns: 1fr 1fr; gap: var(--gap-2xl); align-items: center; }
@media (max-width: 920px) { .hero-split { grid-template-columns: 1fr; } }
/* ─── log row (newsletter, blog list, changelog) ────────────────── */
.log-row { display: grid; grid-template-columns: 120px 1fr 100px; gap: var(--gap-lg); padding: 22px 0; border-top: 1px solid var(--border); align-items: baseline; }
.log-row .meta { color: var(--muted); }
.log-row h3 { font-size: 19px; }
.log-row .pull { text-align: right; }
</style>
</head>
<body>
<header class="topnav" data-od-id="topnav">
<div class="container topnav-inner">
<span class="logo">[REPLACE] Brand</span>
<nav>
<a href="#">[REPLACE] Link 1</a>
<a href="#">[REPLACE] Link 2</a>
<a href="#">[REPLACE] Link 3</a>
</nav>
<button class="btn btn-primary">[REPLACE] CTA</button>
</div>
</header>
<main id="content">
<!--
┌─────────────────────────────────────────────────────────────────┐
│ PASTE LAYOUTS FROM references/layouts.md HERE. │
│ ► Each layout block is a self-contained `<section>` — │
│ drop in 36 of them per page. │
│ ► Always lead with one hero (Layout 1 or 2). │
│ ► End with a CTA strip + footer (Layout 6 / footer below). │
└─────────────────────────────────────────────────────────────────┘
-->
<section class="section hero" data-od-id="hero">
<div class="container hero-center">
<p class="eyebrow">[REPLACE] Eyebrow</p>
<h1>[REPLACE] One sharp sentence about what this is.</h1>
<p class="lead">[REPLACE] One subhead sentence — concrete value, not a tagline.</p>
<div class="hero-cta">
<button class="btn btn-primary">[REPLACE] Primary CTA</button>
<button class="btn btn-secondary">[REPLACE] Secondary</button>
</div>
</div>
</section>
</main>
<footer class="pagefoot" data-od-id="footer">
<div class="container row-between">
<span>© [REPLACE] Brand · [REPLACE] Year</span>
<span class="meta">[REPLACE] tagline · contact@example.com</span>
</div>
</footer>
</body>
</html>
+81
View File
@@ -0,0 +1,81 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Tomato — focused work timer</title>
<style>
:root {
--bg: #fafaf9; --fg: #1c1b1a; --muted: #6b6964; --border: #e6e4e0;
--accent: #c96442; --surface: #ffffff;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.55 -apple-system, system-ui, sans-serif; }
header, main, footer { max-width: 1080px; margin: 0 auto; padding: 0 32px; }
header { display: flex; justify-content: space-between; align-items: center; padding-top: 20px; }
.logo { font-weight: 600; font-size: 17px; letter-spacing: -0.01em; }
nav a { color: var(--fg); text-decoration: none; margin-left: 24px; font-size: 14px; }
nav a:hover { color: var(--accent); }
.hero { padding: 96px 0 80px; text-align: center; }
.hero h1 { font-size: clamp(44px, 6vw, 76px); line-height: 1.05; letter-spacing: -0.02em; margin: 0 0 20px; max-width: 18ch; margin-inline: auto; }
.hero p { color: var(--muted); font-size: 19px; max-width: 52ch; margin: 0 auto 32px; }
.cta { display: inline-flex; gap: 12px; }
button { font: inherit; cursor: pointer; padding: 12px 22px; border-radius: 8px; }
.btn-primary { background: var(--accent); color: white; border: 1px solid var(--accent); font-weight: 500; }
.btn-secondary { background: transparent; color: var(--fg); border: 1px solid var(--border); }
.features { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; padding: 56px 0 96px; }
@media (max-width: 720px) { .features { grid-template-columns: 1fr; } }
.feature { padding: 28px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; }
.feature .icon { width: 36px; height: 36px; border-radius: 8px; background: var(--accent); margin-bottom: 16px; opacity: 0.12; }
.feature h3 { margin: 0 0 8px; font-size: 17px; letter-spacing: -0.01em; }
.feature p { margin: 0; color: var(--muted); font-size: 14px; }
.closing { padding: 64px 0 96px; text-align: center; border-top: 1px solid var(--border); }
.closing h2 { font-size: 32px; margin: 0 0 12px; letter-spacing: -0.01em; }
.closing p { color: var(--muted); margin: 0 0 28px; }
footer { padding: 32px; color: var(--muted); font-size: 13px; text-align: center; }
</style>
</head>
<body>
<header data-od-id="topnav">
<span class="logo">🍅 Tomato</span>
<nav>
<a href="#features">Features</a>
<a href="#pricing">Pricing</a>
<a href="#login">Sign in</a>
</nav>
</header>
<main>
<section class="hero" data-od-id="hero">
<h1>Twenty-five minutes at a time.</h1>
<p>The pomodoro timer that actually keeps your hands off Slack. Block notifications, log every cycle, ship more before lunch.</p>
<div class="cta">
<button class="btn-primary">Start a session</button>
<button class="btn-secondary">See how it works</button>
</div>
</section>
<section class="features" id="features">
<div class="feature" data-od-id="feature-block">
<div class="icon"></div>
<h3>Block on, not off</h3>
<p>Slack and email go quiet for 25 minutes. They come back loud at the break, with a digest.</p>
</div>
<div class="feature" data-od-id="feature-stats">
<div class="icon"></div>
<h3>Stats that don't lie</h3>
<p>Weekly review tells you which days you actually shipped versus which you only seemed busy.</p>
</div>
<div class="feature" data-od-id="feature-team">
<div class="icon"></div>
<h3>Team-friendly silences</h3>
<p>Your status auto-updates so teammates know when to ask, when to wait, and when you're done.</p>
</div>
</section>
<section class="closing" data-od-id="closing">
<h2>Stop measuring meetings. Start measuring focus.</h2>
<p>Free for solo. $4/mo per teammate after that.</p>
<button class="btn-primary">Try Tomato free</button>
</section>
</main>
<footer>© Tomato Labs · Made for people who'd rather be making.</footer>
</body>
</html>
+132
View File
@@ -0,0 +1,132 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "example-web-prototype",
"title": "Web Prototype",
"version": "0.1.1",
"description": "General-purpose desktop web prototype. Single self-contained HTML file built\nby copying the seed `assets/template.html` and pasting section layouts from\n`references/layouts.md`. Default for any landing / marketing / docs / SaaS\npage when no more specific skill matches.",
"license": "MIT",
"author": {
"name": "Open Design",
"url": "https://github.com/nexu-io"
},
"homepage": "https://github.com/nexu-io/open-design/tree/main/plugins/_official/examples/web-prototype",
"tags": [
"example",
"first-party",
"prototype",
"design",
"web",
"desktop",
"mockup",
"landing",
"single-page",
"marketing-page",
"homepage"
],
"compat": {
"agentSkills": [
{
"path": "./SKILL.md"
}
]
},
"od": {
"kind": "scenario",
"taskKind": "new-generation",
"mode": "prototype",
"platform": "desktop",
"scenario": "design",
"surface": "web",
"preview": {
"type": "html",
"entry": "./example.html"
},
"useCase": {
"query": {
"en": "Build a {{fidelity}} {{artifactKind}} for {{audience}}. Use {{designSystem}} as the design-system direction and start from {{template}}. Single self-contained HTML file built by copying the seed `assets/template.html` and pasting section layouts from `references/layouts.md`.",
"zh-CN": "使用这个插件完成以下任务:为 {{audience}} 构建一个 {{fidelity}} 的 {{artifactKind}}。设计系统方向使用 {{designSystem}},从 {{template}} 开始。使用 `assets/template.html` 种子并从 `references/layouts.md` 粘贴版面,输出单文件 HTML。"
},
"exampleOutputs": [
{
"path": "./example.html",
"title": "Web Prototype"
}
]
},
"inputs": [
{
"name": "artifactKind",
"label": "Artifact kind",
"type": "string",
"required": true,
"placeholder": "SaaS landing page",
"default": "web prototype"
},
{
"name": "fidelity",
"label": "Fidelity",
"type": "select",
"required": true,
"options": [
"wireframe",
"high-fidelity"
],
"default": "high-fidelity"
},
{
"name": "audience",
"label": "Audience",
"type": "string",
"required": true,
"placeholder": "startup founders evaluating an AI CRM",
"default": "product evaluators"
},
{
"name": "designSystem",
"label": "Design system",
"type": "string",
"placeholder": "OpenAI, Linear, shadcn, or custom brand notes",
"default": "the active project design system"
},
{
"name": "template",
"label": "Template",
"type": "string",
"placeholder": "marketing homepage, dashboard, docs page",
"default": "the bundled web prototype seed"
}
],
"context": {
"skills": [
{
"path": "./SKILL.md"
}
],
"designSystem": {
"primary": true
},
"assets": [
"./example.html",
"./assets/template.html",
"./references/checklist.md",
"./references/layouts.md"
]
},
"pipeline": {
"stages": [
{
"id": "generate",
"atoms": [
"file-write",
"live-artifact"
]
}
]
},
"capabilities": [
"prompt:inject",
"fs:write"
]
}
}
@@ -0,0 +1,44 @@
# Web prototype checklist
Run this before emitting `<artifact>`. P0 = must pass; P1 = should pass; P2 = nice to have.
## P0 — must pass
- [ ] **No raw hex outside `:root` token block.** Every color is `var(--bg)` / `var(--fg)` / `var(--muted)` / `var(--border)` / `var(--accent)` / `var(--surface)` (or a `color-mix()` of those). Grep `#[0-9a-fA-F]{3,8}` outside `:root{}` should return nothing.
- [ ] **All headings use `var(--font-display)`.** No sans-serif `<h1>` / `<h2>`. Inter / Roboto / system-sans never serve as a display face.
- [ ] **Accent appears at most twice per screen.** Count: eyebrow color, primary CTA fill, anything else? If three or more, demote one to `var(--fg)` or `var(--muted)`.
- [ ] **No purple/violet gradient backgrounds.** No `linear-gradient(... #a855f7 / #8b5cf6 / purple ...)`. The seed template has no gradients on backgrounds — keep it that way.
- [ ] **No emoji used as feature icons.** Use the inline SVG monoline marks shipped in Layout 3, or a tasteful single-character glyph in `--font-mono`. ✨ 🚀 🎯 are out.
- [ ] **No invented metrics.** Every number on the page came from the user, the brief, or is clearly labelled as a placeholder (e.g. `[REPLACE] · 38×`). "10× faster", "99.9% uptime" without source = remove.
- [ ] **No filler copy.** Zero "Feature One / Feature Two", lorem ipsum, "Lorem ipsum dolor". If a section feels empty, delete it; do not pad.
- [ ] **`data-od-id` on every top-level `<section>`.** Used by comment mode to target sections.
- [ ] **Mobile reflow works.** All `grid-2`, `grid-3`, `grid-4`, `grid-2-1`, `grid-1-2` collapse to one column at ≤920px (the default media query in `template.html` does this). Verify by mentally narrowing — no horizontal scroll.
- [ ] **No `scrollIntoView()` calls.** Breaks the OD preview iframe. Use `scrollTo({...})` if you need scroll behaviour.
## P1 — should pass
- [ ] **One decisive flourish.** A pull quote, a striking stat, a real-feeling photograph, one micro-animation on the hero. *One.* Not three.
- [ ] **Section rhythm alternates.** No two stat rows in a row. No two feature triplets in a row. No two quote blocks in a row.
- [ ] **Headlines under 14 words.** If longer, the writing is doing the design's job.
- [ ] **Lead text under 56 ch / two sentences.** `max-width: 60ch` on `.lead` enforces this; don't override.
- [ ] **CTA buttons say what happens.** "Start free" beats "Get Started". "Read the story" beats "Learn More".
- [ ] **Hover states present** for all `<a>` and `.btn`. Seed template covers this.
- [ ] **Numerics use `.num` (mono, tabular).** Prices, stats, version numbers, dates.
- [ ] **One image style per page.** Don't mix square portrait headshots with widescreen product hero with vertical phone mock — pick a lane.
## P2 — nice to have
- [ ] **`text-wrap: pretty` / `balance`** on long paragraphs / headings (already on `<p>` and `h*` in seed).
- [ ] **`color-mix()` for derived tones.** No additional `--accent-50` / `--accent-300` Bootstrap-style tokens — derive on the spot.
- [ ] **Sticky topnav has frosted glass** (already in seed via `backdrop-filter: blur()`).
- [ ] **Loaded fonts are system-first.** Iowan Old Style / Charter for serif, system stack for sans. Only pull a Google Font if DESIGN.md specifies one.
## Anti-slop spot-check
Look at the page for two seconds. If your gut says any of:
- "looks like every Cursor / Linear / Vercel ripoff I've seen this month"
- "this could be any AI startup's homepage"
- "the feature row has an icon, a heading, and three lines of vague benefit copy"
…go back, replace one feature cell with something more specific to *this* product (a screenshot, a concrete example, a sample of the actual output), and remove one accent.
@@ -0,0 +1,247 @@
# Web prototype layouts
**8 paste-ready section skeletons.** Drop into `<main id="content">` of `assets/template.html`. Don't write sections from scratch — pick the closest layout, paste, swap copy.
## Pre-flight (do this once before pasting anything)
1. **Read `assets/template.html`** through the end of the `<style>` block. Every class used below must exist there. If one is missing, add it to `<style>` rather than inlining it on each section.
2. **Pick a section list before writing copy.** Default rhythms:
- **Landing**: 1 hero → 2 features → 3 stat-row OR quote → 4 split → 6 cta-strip → footer
- **Marketing / editorial**: 1 hero-center → 7 log-list → 4 split → 6 cta-strip
- **Pricing / docs**: 1 hero-center → table-driven → 6 cta-strip
3. **One accent per screen, used at most twice.** The hero eyebrow and the primary button already use it; budget any third usage carefully.
## Class inventory (must exist in `template.html`)
> `section` `container` `hero` `hero-center` `hero-split` `hero-cta` `eyebrow` `lead` `h1` `h2` `h3` `meta` `num` `btn` `btn-primary` `btn-secondary` `btn-ghost` `btn-arrow` `card` `card-flat` `card-rule` `feature` `feature-mark` `stat` `stat-num` `stat-label` `stat-unit` `quote` `quote-mark` `quote-author` `pill` `tag` `field` `input` `textarea` `ds-table` `num-col` `ph-img` `square` `portrait` `wide` `rule` `rule-strong` `grid-2` `grid-3` `grid-4` `grid-2-1` `grid-1-2` `row` `row-between` `stack` `log-row` `pull` `topnav` `pagefoot`
If you reach for a class not on this list, define it in `<style>` first or use `style="…"` inline. Never invent a global class on a `<section>` that isn't backed by CSS.
---
## Layout 1 — Hero, centered
Use when the page leads with a thesis sentence (most landings, most marketing pages). One eyebrow, one h1 (≤14 words), one lead sentence, two CTAs.
```html
<section class="section hero" data-od-id="hero">
<div class="container hero-center">
<p class="eyebrow">EYEBROW · CONTEXT</p>
<h1>One sharp sentence about what this is.</h1>
<p class="lead">One concrete-value subhead — what changes for the reader.</p>
<div class="hero-cta">
<button class="btn btn-primary">Primary action</button>
<button class="btn btn-secondary">Secondary</button>
</div>
</div>
</section>
```
## Layout 2 — Hero, split (text + visual)
Use when there is a real product visual (product UI, screenshot, photograph). Left half copy, right half a `ph-img` placeholder the user replaces.
```html
<section class="section" data-od-id="hero-split">
<div class="container hero-split">
<div>
<p class="eyebrow">EYEBROW · ROLE</p>
<h1>Headline that names the change.</h1>
<p class="lead" style="margin-top: 20px;">A short subhead — concrete, not corporate. Two sentences max.</p>
<div class="hero-cta" style="margin-top: 28px;">
<button class="btn btn-primary">Primary action</button>
<button class="btn btn-ghost btn-arrow">Read the story</button>
</div>
</div>
<div class="ph-img wide" aria-label="Hero visual placeholder">[ Hero visual · 16:9 ]</div>
</div>
</section>
```
## Layout 3 — Feature triplet
Three feature cells. Lead with a small `<h2>` framing the row. Don't put an icon on every heading — one tasteful mark per cell, monoline.
```html
<section class="section" data-od-id="features">
<div class="container stack" style="gap: 56px;">
<div style="max-width: 36ch;">
<p class="eyebrow">WHAT'S DIFFERENT</p>
<h2>Three things you'll notice in the first ten minutes.</h2>
</div>
<div class="grid-3">
<div class="feature card-flat">
<div class="feature-mark">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M12 3v18M3 12h18"/></svg>
</div>
<h3>Specific feature one</h3>
<p>Two-sentence description that names the user value, not the technology.</p>
</div>
<div class="feature card-flat">
<div class="feature-mark">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="8"/><path d="M12 8v4l3 2"/></svg>
</div>
<h3>Specific feature two</h3>
<p>Two-sentence description that names the user value, not the technology.</p>
</div>
<div class="feature card-flat">
<div class="feature-mark">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M4 7h16M4 12h10M4 17h16"/></svg>
</div>
<h3>Specific feature three</h3>
<p>Two-sentence description that names the user value, not the technology.</p>
</div>
</div>
</div>
</section>
```
## Layout 4 — Stat row (data billboard)
Use when there are real numbers. Three stats max — four feels like a brochure. **Don't invent metrics.** If you don't have a number, use a different layout.
```html
<section class="section" data-od-id="stats">
<div class="container">
<p class="eyebrow" style="margin-bottom: 40px;">BY THE NUMBERS · 2026</p>
<div class="grid-3">
<div class="stat">
<div class="stat-num num">38<span class="stat-unit">×</span></div>
<p class="stat-label">less data moved over the wire vs. naive sync, on real customer workloads.</p>
</div>
<div class="stat">
<div class="stat-num num">3,184</div>
<p class="stat-label">paying teams, including 14 of the YC W26 batch.</p>
</div>
<div class="stat">
<div class="stat-num num">$0.04<span class="stat-unit">/GB</span></div>
<p class="stat-label">average egress saved — typical $1,800/mo bill drops to $200.</p>
</div>
</div>
</div>
</section>
```
## Layout 5 — Pull quote (testimonial)
A single decisive quote with attribution. Use sparingly — one per page, never two in a row.
```html
<section class="section" data-od-id="quote">
<div class="container" style="max-width: 800px;">
<div class="quote-mark">"</div>
<blockquote class="quote">Filebase pays for itself in the first month. We were going to hire a dedicated DevOps person to babysit our sync — instead we just switched.</blockquote>
<p class="quote-author">— Mira Hassan, CTO at Northwind Studios</p>
</div>
</section>
```
## Layout 6 — CTA strip (closing)
End the page on one decisive ask. Centered, generous whitespace, one primary button. No secondary unless the page has zero other buttons.
```html
<section class="section" data-od-id="cta-strip" style="text-align: center;">
<div class="container" style="max-width: 600px;">
<h2>Stop measuring meetings. Start measuring focus.</h2>
<p class="lead" style="margin: 16px auto 32px;">Free for solo. $4/mo per teammate after that.</p>
<button class="btn btn-primary">Start free</button>
</div>
</section>
```
## Layout 7 — Log list (changelog / blog index / posts)
Editorial layout for a list of dated entries. Date in mono on the left, title + dek in the middle, optional pull stat on the right. Borders on top, never around — boxes feel like a brochure.
```html
<section class="section" data-od-id="log">
<div class="container">
<div class="row-between" style="margin-bottom: 32px;">
<h2>Recent changes</h2>
<a class="btn btn-ghost btn-arrow" href="#">View all</a>
</div>
<div>
<article class="log-row">
<span class="meta">Apr 27, 2026</span>
<div>
<h3>Sync engine v3 — half the wire bytes</h3>
<p style="margin: 4px 0 0; color: var(--muted); font-size: 14px;">A new content-defined chunker that produces 38× fewer post-edit changes on Final Cut projects.</p>
</div>
<span class="pull meta">Engineering</span>
</article>
<article class="log-row">
<span class="meta">Apr 19, 2026</span>
<div>
<h3>Per-folder bandwidth budgets</h3>
<p style="margin: 4px 0 0; color: var(--muted); font-size: 14px;">Cap how much a single project can pull each month — useful for archive folders.</p>
</div>
<span class="pull meta">Product</span>
</article>
<article class="log-row">
<span class="meta">Apr 04, 2026</span>
<div>
<h3>S3 + R2 dual-region replication</h3>
<p style="margin: 4px 0 0; color: var(--muted); font-size: 14px;">Two providers, automatic failover. Enterprise tier only for now.</p>
</div>
<span class="pull meta">Infra</span>
</article>
</div>
</div>
</section>
```
## Layout 8 — Comparison table (pricing, plan matrix, before/after)
Hairline borders, mono numerics, one column highlighted via an accent border. Don't put the whole row in surface-color — that screams "table".
```html
<section class="section" data-od-id="pricing">
<div class="container">
<div style="text-align: center; max-width: 36ch; margin: 0 auto 56px;">
<p class="eyebrow">PRICING</p>
<h2>One row of features. Three lines of pricing.</h2>
</div>
<table class="ds-table">
<thead>
<tr>
<th>Feature</th>
<th class="num-col">Solo</th>
<th class="num-col">Team</th>
<th class="num-col">Enterprise</th>
</tr>
</thead>
<tbody>
<tr><td>Sync engine v3</td><td class="num-col"></td><td class="num-col"></td><td class="num-col"></td></tr>
<tr><td>Per-folder budgets</td><td class="num-col"></td><td class="num-col"></td><td class="num-col"></td></tr>
<tr><td>SAML / SCIM</td><td class="num-col"></td><td class="num-col"></td><td class="num-col"></td></tr>
<tr><td>Dedicated infra</td><td class="num-col"></td><td class="num-col"></td><td class="num-col"></td></tr>
<tr style="border-top: 1px solid var(--fg);">
<td><strong>Monthly</strong></td>
<td class="num-col"><strong>$0</strong></td>
<td class="num-col"><strong>$4 / seat</strong></td>
<td class="num-col"><strong>Talk to us</strong></td>
</tr>
</tbody>
</table>
</div>
</section>
```
---
## Section rhythm — when in doubt
For a 5-section landing:
1. Hero (Layout 1 or 2)
2. Features (Layout 3)
3. Stats *or* quote (Layout 4 or 5)
4. Split detail (custom, using `grid-2-1` / `grid-1-2`)
5. CTA + footer (Layout 6)
For a 4-section docs/marketing index:
1. Hero center (Layout 1)
2. Log list (Layout 7)
3. CTA + footer (Layout 6)
Two stat rows in a row, two quote blocks in a row, two feature triplets in a row — all visual fatigue. Alternate.
@@ -4,14 +4,13 @@ import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
Search, Hash, Plus, RefreshCw, Trash2, Save, RotateCcw, Loader2, Search, Hash, Plus, RefreshCw, Trash2, Save, RotateCcw, Loader2,
Table2, Copy, Download, Share2, AlertTriangle, ArrowRight, Edit3, Table2, AlertTriangle, Edit3,
Calendar, Type, Hash as HashIcon, Link2, Code, Minus, Layers, Calendar, Type, Hash as HashIcon, Link2, Layers,
CheckCircle, Eye, Upload, LayoutGrid, X, Sparkles, CheckCircle, X, Sparkles,
} from "lucide-react"; } from "lucide-react";
import { showErrorToast } from "@/lib/utils/toastUtils"; import { showErrorToast } from "@/lib/utils/toastUtils";
import { import {
getNumberingRulesFromTest, getNumberingRulesFromTest,
getNumberingRuleById,
deleteNumberingRuleFromTest, deleteNumberingRuleFromTest,
resetSequence, resetSequence,
updateRuleSequence, updateRuleSequence,
@@ -34,23 +33,25 @@ import {
type ResetPeriod = "none" | "daily" | "monthly" | "yearly"; type ResetPeriod = "none" | "daily" | "monthly" | "yearly";
type SideFilter = "all" | "active" | "warn" | "unused"; type SideFilter = "all" | "active" | "warn" | "unused";
const PART_TONE_BY_TYPE: Record<CodePartType, string> = { const PART_KO_LABEL: Record<CodePartType, string> = {
text: "text", // prefix text: "고정",
date: "date", date: "날짜",
sequence: "sequence", sequence: "순번",
number: "number", number: "숫자",
category: "category", category: "카테고리",
reference: "reference", reference: "참조",
}; };
const PART_LABEL_BY_TYPE: Record<CodePartType, string> = { function buildPartEnTag(p: NumberingRulePart): string {
text: "TEXT", switch (p.part_type) {
date: "DATE", case "text": return "text";
sequence: "SEQ", case "date": return p.auto_config?.date_format ?? "date";
number: "NUM", case "sequence": return `${p.auto_config?.sequence_length ?? 4}자리`;
category: "CAT", case "number": return `${p.auto_config?.number_length ?? 3}자리`;
reference: "REF", case "category": return "cat";
}; case "reference": return "ref";
}
}
const RESET_LABEL: Record<ResetPeriod, string> = { const RESET_LABEL: Record<ResetPeriod, string> = {
none: "없음", none: "없음",
@@ -74,6 +75,17 @@ export default function NumberingRuleManagementPage() {
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const [draftSequence, setDraftSequence] = useState<string>(""); const [draftSequence, setDraftSequence] = useState<string>("");
const [mutating, setMutating] = useState(false); const [mutating, setMutating] = useState(false);
const draftRuleIdsRef = useRef<Set<string>>(new Set());
const dirtyRef = useRef(false);
const editingRuleIdRef = useRef<string | null>(null);
useEffect(() => {
dirtyRef.current = dirty;
}, [dirty]);
useEffect(() => {
editingRuleIdRef.current = editingRule?.rule_id ? String(editingRule.rule_id) : null;
}, [editingRule?.rule_id]);
// 회사별 채번 목록 로드 // 회사별 채번 목록 로드
const loadRules = useCallback(async () => { const loadRules = useCallback(async () => {
@@ -108,6 +120,13 @@ export default function NumberingRuleManagementPage() {
} }
const fromList = rules.find((r) => String(r.rule_id) === selectedRuleId); const fromList = rules.find((r) => String(r.rule_id) === selectedRuleId);
if (fromList) { if (fromList) {
if (
draftRuleIdsRef.current.has(selectedRuleId) &&
dirtyRef.current &&
editingRuleIdRef.current === selectedRuleId
) {
return;
}
const cloned = structuredClone(fromList); const cloned = structuredClone(fromList);
setEditingRule(cloned); setEditingRule(cloned);
setDraftSequence(String(cloned.current_sequence ?? 0)); setDraftSequence(String(cloned.current_sequence ?? 0));
@@ -145,7 +164,6 @@ export default function NumberingRuleManagementPage() {
// 사이드바 섹션 분류 // 사이드바 섹션 분류
const groupedRules = useMemo(() => { const groupedRules = useMemo(() => {
const live: NumberingRuleConfig[] = [];
const warn: NumberingRuleConfig[] = []; const warn: NumberingRuleConfig[] = [];
const others: NumberingRuleConfig[] = []; const others: NumberingRuleConfig[] = [];
const unused: NumberingRuleConfig[] = []; const unused: NumberingRuleConfig[] = [];
@@ -191,6 +209,12 @@ export default function NumberingRuleManagementPage() {
const resp = await saveNumberingRuleToTest(editingRule); const resp = await saveNumberingRuleToTest(editingRule);
if (resp.success) { if (resp.success) {
toast.success("채번 규칙이 저장되었습니다"); toast.success("채번 규칙이 저장되었습니다");
if (resp.data) {
draftRuleIdsRef.current.delete(String(editingRule.rule_id));
setRules((prev) => prev.map((r) => String(r.rule_id) === String(editingRule.rule_id) ? resp.data! : r));
setEditingRule(structuredClone(resp.data));
setSelectedRuleId(String(resp.data.rule_id));
}
setDirty(false); setDirty(false);
setRefreshKey((k) => k + 1); setRefreshKey((k) => k + 1);
} else { } else {
@@ -216,8 +240,18 @@ export default function NumberingRuleManagementPage() {
const handleDelete = async () => { const handleDelete = async () => {
if (!editingRule) return; if (!editingRule) return;
if (!confirm(`"${editingRule.rule_name}" 규칙을 삭제하시겠습니까?`)) return; if (!confirm(`"${editingRule.rule_name}" 규칙을 삭제하시겠습니까?`)) return;
const ruleId = String(editingRule.rule_id);
if (draftRuleIdsRef.current.has(ruleId)) {
draftRuleIdsRef.current.delete(ruleId);
setRules((prev) => prev.filter((r) => String(r.rule_id) !== ruleId));
setSelectedRuleId(null);
setEditingRule(null);
setDirty(false);
toast.success("작성 중인 규칙을 삭제했습니다");
return;
}
try { try {
const resp = await deleteNumberingRuleFromTest(String(editingRule.rule_id)); const resp = await deleteNumberingRuleFromTest(ruleId);
if (resp.success) { if (resp.success) {
toast.success("규칙이 삭제되었습니다"); toast.success("규칙이 삭제되었습니다");
setSelectedRuleId(null); setSelectedRuleId(null);
@@ -233,9 +267,19 @@ export default function NumberingRuleManagementPage() {
const handleResetSequence = async () => { const handleResetSequence = async () => {
if (!editingRule?.rule_id) return; if (!editingRule?.rule_id) return;
if (!confirm("시퀀스를 0 으로 초기화 하시겠습니까?")) return; if (!confirm("시퀀스를 0 으로 초기화 하시겠습니까?")) return;
const ruleId = String(editingRule.rule_id);
if (draftRuleIdsRef.current.has(ruleId)) {
const nextRule = { ...editingRule, current_sequence: 0 };
setEditingRule(nextRule);
setRules((prev) => prev.map((r) => (String(r.rule_id) === ruleId ? nextRule : r)));
setDraftSequence("0");
setDirty(true);
toast.success("작성 중인 규칙의 시퀀스를 0으로 맞췄습니다");
return;
}
setMutating(true); setMutating(true);
try { try {
const resp = await resetSequence(String(editingRule.rule_id)); const resp = await resetSequence(ruleId);
if (resp.success) { if (resp.success) {
toast.success("시퀀스가 초기화되었습니다"); toast.success("시퀀스가 초기화되었습니다");
setDraftSequence("0"); setDraftSequence("0");
@@ -261,9 +305,18 @@ export default function NumberingRuleManagementPage() {
toast.info("현재 시퀀스와 동일합니다"); toast.info("현재 시퀀스와 동일합니다");
return; return;
} }
const ruleId = String(editingRule.rule_id);
if (draftRuleIdsRef.current.has(ruleId)) {
const nextRule = { ...editingRule, current_sequence: newSeq };
setEditingRule(nextRule);
setRules((prev) => prev.map((r) => (String(r.rule_id) === ruleId ? nextRule : r)));
setDirty(true);
toast.success("작성 중인 규칙의 시퀀스를 반영했습니다");
return;
}
setMutating(true); setMutating(true);
try { try {
const resp = await updateRuleSequence(String(editingRule.rule_id), newSeq); const resp = await updateRuleSequence(ruleId, newSeq);
if (resp.success) { if (resp.success) {
toast.success(`시퀀스 ${newSeq} 적용됨`); toast.success(`시퀀스 ${newSeq} 적용됨`);
setRefreshKey((k) => k + 1); setRefreshKey((k) => k + 1);
@@ -277,15 +330,6 @@ export default function NumberingRuleManagementPage() {
} }
}; };
const updatePart = (order: number, patch: Partial<NumberingRulePart>) => {
if (!editingRule) return;
setEditingRule({
...editingRule,
parts: editingRule.parts.map((p) => (p.order === order ? { ...p, ...patch } : p)),
});
setDirty(true);
};
const updatePartAutoConfig = (order: number, patch: Partial<NonNullable<NumberingRulePart["auto_config"]>>) => { const updatePartAutoConfig = (order: number, patch: Partial<NonNullable<NumberingRulePart["auto_config"]>>) => {
if (!editingRule) return; if (!editingRule) return;
setEditingRule({ setEditingRule({
@@ -297,12 +341,16 @@ export default function NumberingRuleManagementPage() {
setDirty(true); setDirty(true);
}; };
const addPart = (type: CodePartType) => { const addPart = (type: CodePartType, insertAfterOrder?: number) => {
if (!editingRule) return; if (!editingRule) return;
const order = (editingRule.parts?.length ?? 0) + 1; const sortedParts = [...(editingRule.parts ?? [])].sort((a, b) => a.order - b.order);
const insertIndex =
insertAfterOrder == null
? sortedParts.length
: Math.max(0, sortedParts.findIndex((p) => p.order === insertAfterOrder) + 1);
const newPart: NumberingRulePart = { const newPart: NumberingRulePart = {
id: `part-${Date.now()}`, id: `part-${Date.now()}-${type}`,
order, order: insertIndex + 1,
part_type: type, part_type: type,
generation_method: "auto", generation_method: "auto",
auto_config: auto_config:
@@ -316,8 +364,13 @@ export default function NumberingRuleManagementPage() {
? { number_length: 3, number_value: 1 } ? { number_length: 3, number_value: 1 }
: {}, : {},
}; };
setEditingRule({ ...editingRule, parts: [...editingRule.parts, newPart] }); const nextParts = [
setSelectedPartOrder(order); ...sortedParts.slice(0, insertIndex),
newPart,
...sortedParts.slice(insertIndex),
].map((p, i) => ({ ...p, order: i + 1 }));
setEditingRule({ ...editingRule, parts: nextParts });
setSelectedPartOrder(insertIndex + 1);
setDirty(true); setDirty(true);
}; };
@@ -342,6 +395,7 @@ export default function NumberingRuleManagementPage() {
const seq = (editingRule.current_sequence ?? 0) + 1; const seq = (editingRule.current_sequence ?? 0) + 1;
const today = new Date(); const today = new Date();
return editingRule.parts return editingRule.parts
.slice()
.sort((a, b) => a.order - b.order) .sort((a, b) => a.order - b.order)
.map((p) => renderPartValue(p, today, seq)) .map((p) => renderPartValue(p, today, seq))
.join(sep); .join(sep);
@@ -353,55 +407,82 @@ export default function NumberingRuleManagementPage() {
const seq = editingRule.current_sequence ?? 0; const seq = editingRule.current_sequence ?? 0;
const today = new Date(); const today = new Date();
return editingRule.parts return editingRule.parts
.slice()
.sort((a, b) => a.order - b.order) .sort((a, b) => a.order - b.order)
.map((p) => renderPartValue(p, today, seq)) .map((p) => renderPartValue(p, today, seq))
.join(sep); .join(sep);
}, [editingRule]); }, [editingRule]);
const handleCreateDraftRule = (name?: string, preset: "blank" | "simple" | "monthly" | "daily" = "blank") => {
const id = `rule-${Date.now()}`;
const baseName = name?.trim() || (
preset === "monthly" ? "월별 리셋 채번" :
preset === "daily" ? "일별 리셋 채번" :
preset === "simple" ? "간단 채번" :
"새 채번 규칙"
);
const makePart = (order: number, part_type: CodePartType, auto_config: NumberingRulePart["auto_config"]): NumberingRulePart => ({
id: `part-${Date.now()}-${order}-${part_type}`,
order,
part_type,
generation_method: "auto",
auto_config,
});
const presetParts =
preset === "blank"
? []
: preset === "daily"
? [
makePart(1, "text", { text_value: "PFX" }),
makePart(2, "date", { date_format: "YYYYMMDD" }),
makePart(3, "sequence", { sequence_length: 3, start_from: 1 }),
]
: preset === "monthly"
? [
makePart(1, "text", { text_value: "PFX" }),
makePart(2, "date", { date_format: "YYYYMM" }),
makePart(3, "sequence", { sequence_length: 4, start_from: 1 }),
]
: [
makePart(1, "text", { text_value: "PFX" }),
makePart(2, "sequence", { sequence_length: 4, start_from: 1 }),
];
const draft: NumberingRuleConfig = {
rule_id: id,
rule_name: baseName,
parts: presetParts,
separator: "-",
reset_period: preset === "daily" ? "daily" : preset === "monthly" ? "monthly" : "none",
current_sequence: 0,
};
draftRuleIdsRef.current.add(id);
setRules((prev) => [draft, ...prev]);
setSelectedRuleId(id);
setEditingRule(structuredClone(draft));
setSelectedPartOrder(draft.parts[0]?.order ?? null);
setDraftSequence("0");
setDirty(true);
setCmdkOpen(false);
};
return ( return (
<div className="v5-nrm"> <div className="v5-nrm">
<div <div className="v5-nrm-topbar">
style={{ <div className="v5-nrm-topcopy">
padding: "1.1rem 1.4rem .9rem", <h1>
borderBottom: "1px solid var(--v5-border)", <span className="v5-nrm-titlemark">#</span>
display: "flex",
alignItems: "flex-end",
justifyContent: "space-between",
gap: "1rem",
flexShrink: 0,
}}
>
<div>
<h1
style={{
margin: 0,
fontSize: "1.1rem",
fontWeight: 700,
letterSpacing: "-.02em",
color: "var(--v5-text)",
}}
>
</h1> </h1>
<p <p> .</p>
style={{
margin: ".3rem 0 0",
fontSize: ".72rem",
color: "var(--v5-text-muted)",
}}
>
·{" "}
<b style={{ color: "var(--v5-text)" }}>{stats.total}</b> ·{" "}
<b style={{ color: "rgb(var(--v5-green-rgb))" }}>{stats.linked}</b>
{stats.unused > 0 && (
<> · <b style={{ color: "var(--v5-text)" }}>{stats.unused}</b> </>
)}
{stats.warn > 0 && (
<> · <b style={{ color: "rgb(var(--v5-amber-rgb))" }}>{stats.warn}</b> </>
)}
</p>
</div> </div>
<div style={{ display: "flex", gap: ".35rem", alignItems: "center" }}> <div className="v5-nrm-topstats" aria-label="채번 관리 요약">
<span className="v5-nrm-stat"><b>{stats.total}</b> </span>
<span className="v5-nrm-stat green"><b>{stats.linked}</b> </span>
<span className="v5-nrm-stat"><b>{stats.unused}</b> </span>
{stats.warn > 0 && <span className="v5-nrm-stat amber"><b>{stats.warn}</b> </span>}
</div>
<div className="v5-nrm-topactions">
<span className="v5-nrm-kbd"> K</span> <span className="v5-nrm-kbd"> K</span>
<button <button
className="v5-nrm-btn icon-only ghost" className="v5-nrm-btn icon-only ghost"
@@ -410,9 +491,6 @@ export default function NumberingRuleManagementPage() {
> >
{loading ? <Loader2 size={13} className="animate-spin" /> : <RefreshCw size={13} />} {loading ? <Loader2 size={13} className="animate-spin" /> : <RefreshCw size={13} />}
</button> </button>
<button className="v5-nrm-btn ghost">
<Download size={13} />
</button>
<button className="v5-nrm-btn primary" onClick={() => setCmdkOpen(true)}> <button className="v5-nrm-btn primary" onClick={() => setCmdkOpen(true)}>
<Plus size={13} /> <Plus size={13} />
</button> </button>
@@ -462,7 +540,7 @@ export default function NumberingRuleManagementPage() {
<div className="v5-nrm-empty"> <div className="v5-nrm-empty">
<Hash size={28} /> <Hash size={28} />
<div> </div> <div> </div>
<div className="hint"> "새 채번" K </div> <div className="hint"> &quot; &quot; K </div>
</div> </div>
) : ( ) : (
<> <>
@@ -523,17 +601,10 @@ export default function NumberingRuleManagementPage() {
<div className="v5-nrm-side-foot"> <div className="v5-nrm-side-foot">
<button <button
className="v5-nrm-btn sm" className="v5-nrm-btn sm"
style={{ flex: 1 }}
onClick={() => setCmdkOpen(true)} onClick={() => setCmdkOpen(true)}
> >
<Plus size={11} /> <Plus size={11} />
</button> </button>
<button className="v5-nrm-btn sm icon-only" title="가져오기">
<Upload size={11} />
</button>
<button className="v5-nrm-btn sm icon-only" title="템플릿">
<LayoutGrid size={11} />
</button>
</div> </div>
</aside> </aside>
@@ -549,85 +620,114 @@ export default function NumberingRuleManagementPage() {
</div> </div>
) : ( ) : (
<> <>
{/* HERO */} {/* DETAIL HEAD */}
<div className="v5-nrm-hero"> <div className="v5-nrm-detail-head">
<div className="v5-nrm-hero-top"> <div className="l">
<div className="v5-nrm-tone cyan" style={{ width: 40, height: 40, borderRadius: 10 }}> <h2>
<Hash size={18} /> {editingRule.rule_name || "(이름 없음)"}
{editingRule.parts?.length > 0 && (
<span className="v5-nrm-chip active"> </span>
)}
<span
className="v5-nrm-chip"
style={{ fontFamily: "var(--v5-font-mono)", fontSize: ".56rem" }}
>
{editingRule.rule_id}
</span>
</h2>
<div className="meta">
{editingRule.created_at && (
<span><b></b> {editingRule.created_at.slice(0, 10)}</span>
)}
{editingRule.updated_at && (
<span><b> </b> {editingRule.updated_at.slice(0, 16).replace("T", " ")}</span>
)}
{editingRule.created_by && (
<span><b>by</b> {editingRule.created_by}</span>
)}
<span>
<b></b> {(editingRule.current_sequence ?? 0).toLocaleString()}
</span>
</div> </div>
<div className="v5-nrm-hero-info"> </div>
<div className="v5-nrm-hero-row1"> <div className="r">
<h2>{editingRule.rule_name || "(이름 없음)"}</h2> <button
{editingRule.parts?.length > 0 && ( className="v5-nrm-btn sm ghost danger"
<span className="v5-nrm-chip active live">LIVE</span> title="삭제"
)} onClick={handleDelete}
<span className="v5-nrm-chip">{editingRule.rule_id}</span> >
<Trash2 size={11} />
</button>
</div>
</div>
{/* HERO PREVIEW (이 채번이 만드는 코드) */}
<div className="v5-nrm-hero-wrap">
{editingRule.parts.length === 0 ? (
<div className="v5-nrm-hero-empty">
<Hash size={24} />
<b> </b>
<span> preview가 .</span>
</div>
) : (
<div className="v5-nrm-hero-card">
<span className="v5-nrm-hero-tab">
<HashIcon size={11} /> NEXT CODE
</span>
<span className="v5-nrm-hero-corner">
{editingRule.table_name && editingRule.column_name
? `${editingRule.table_name}.${editingRule.column_name}`
: "미연결 규칙"}
</span>
<div className="v5-nrm-hero-code" aria-label={`다음 발번 ${previewCode}`}>
{editingRule.parts
.slice()
.sort((a, b) => a.order - b.order)
.map((p, idx, arr) => {
const today = new Date();
const seq = (editingRule.current_sequence ?? 0) + 1;
const val = renderPartValue(p, today, seq) || "—";
return (
<span key={`hero-${p.order}-${p.id}`} className="v5-nrm-hero-piece-wrap">
<button
type="button"
className={`v5-nrm-hero-part ${p.part_type}${selectedPartOrder === p.order ? " sel" : ""}`}
onClick={() => setSelectedPartOrder(p.order)}
>
<span className="v">{val}</span>
<span className="lbl">{PART_KO_LABEL[p.part_type]}</span>
</button>
{idx < arr.length - 1 && (
<span className="v5-nrm-hero-sep">{editingRule.separator || "-"}</span>
)}
</span>
);
})}
</div> </div>
<div className="v5-nrm-hero-meta"> <div className="v5-nrm-hero-intent">
{editingRule.created_at && ( <b>{previewCode || "—"}</b> .
<span><b></b> {editingRule.created_at.slice(0, 10)}</span> </div>
)} <div className="v5-nrm-hero-foot">
{editingRule.updated_at && ( <span className="it"><span className="k"></span><span className="v">{currentCode || "—"}</span></span>
<span><b></b> {editingRule.updated_at.slice(0, 16).replace("T", " ")}</span> <span className="it"><span className="k"></span><span className="v up">{previewCode || ""}</span></span>
)} <span className="it"><span className="k"></span><span className="v">{(editingRule.current_sequence ?? 0).toLocaleString()} {((editingRule.current_sequence ?? 0) + 1).toLocaleString()}</span></span>
{editingRule.created_by && ( <span className="it">
<span><b>by</b> {editingRule.created_by}</span> <span className="k"></span>
)} <span className="v">
<span> {RESET_PERIOD_OPTIONS.find((o) => o.value === editingRule.reset_period)?.label ?? "초기화 안함"}
<b> </b> {editingRule.table_name && editingRule.column_name ? 1 : 0} </span>
</span> </span>
</div> </div>
</div> </div>
<div className="v5-nrm-hero-actions"> )}
<button className="v5-nrm-btn sm icon-only ghost" title="복제">
<Copy size={11} />
</button>
<button className="v5-nrm-btn sm icon-only ghost" title="내보내기">
<Download size={11} />
</button>
<button className="v5-nrm-btn sm icon-only ghost" title="공유">
<Share2 size={11} />
</button>
<button
className="v5-nrm-btn sm icon-only ghost danger"
title="삭제"
onClick={handleDelete}
>
<Trash2 size={11} />
</button>
</div>
</div>
<div className="v5-nrm-hero-codes">
<div className="v5-nrm-hero-code-block">
<div className="lbl"> 퀀</div>
<div className="code" style={{ fontSize: "1.3rem" }}>
{currentCode || <span style={{ color: "var(--v5-text-muted)" }}>( )</span>}
</div>
</div>
<span className="v5-nrm-hero-arrow">
<ArrowRight size={18} />
</span>
<div className="v5-nrm-hero-code-block right">
<div className="lbl"> </div>
<div className="code">
{previewCode || "—"}
</div>
<div className="sub">
퀀 {editingRule.current_sequence ?? 0} {(editingRule.current_sequence ?? 0) + 1}
{editingRule.reset_period && editingRule.reset_period !== "none" && (
<> · {RESET_PERIOD_OPTIONS.find((o) => o.value === editingRule.reset_period)?.label}</>
)}
</div>
</div>
</div>
</div> </div>
{/* PIPELINE EDITOR */} {/* PIPELINE EDITOR */}
<div className="v5-nrm-row"> <div className="v5-nrm-row">
<div className="v5-nrm-row-hd"> <div className="v5-nrm-row-hd">
<h3> </h3> <h3> </h3>
<span className="num">{editingRule.parts.length} / 8</span> <span className="num">{editingRule.parts.length}</span>
<span className="desc">part = </span> <span className="desc"> = · hover = </span>
<div className="actions"> <div className="actions">
<button className="v5-nrm-btn sm ghost" onClick={handleRevert}> <button className="v5-nrm-btn sm ghost" onClick={handleRevert}>
<RotateCcw size={11} /> <RotateCcw size={11} />
@@ -637,31 +737,54 @@ export default function NumberingRuleManagementPage() {
<div className="v5-nrm-pipe-canvas"> <div className="v5-nrm-pipe-canvas">
{editingRule.parts.length === 0 ? ( {editingRule.parts.length === 0 ? (
<div style={{ flex: 1, textAlign: "center", color: "var(--v5-text-muted)", padding: ".75rem", fontSize: ".72rem" }}> <div
style={{
flex: 1,
textAlign: "center",
color: "var(--v5-text-muted)",
padding: ".75rem",
fontSize: ".72rem",
}}
>
</div> </div>
) : ( ) : (
editingRule.parts.sort((a, b) => a.order - b.order).map((p, idx, arr) => ( editingRule.parts
<PipelineBlock .slice()
key={`${p.order}-${p.id}`} .sort((a, b) => a.order - b.order)
part={p} .map((p, idx, arr) => (
selected={selectedPartOrder === p.order} <span key={`pipe-${p.order}-${p.id}`} style={{ display: "contents" }}>
onClick={() => setSelectedPartOrder(p.order)} <PipelineBlock
onRemove={() => removePart(p.order)} part={p}
isLast={idx === arr.length - 1} selected={selectedPartOrder === p.order}
separator={editingRule.separator ?? "-"} onClick={() => setSelectedPartOrder(p.order)}
/> onRemove={() => removePart(p.order)}
)) />
{idx < arr.length - 1 && (
<span className="v5-nrm-slot">
<span className="sep">{editingRule.separator || "-"}</span>
<button
type="button"
className="add-here"
title="여기에 조각 추가"
onClick={() => addPart("sequence", p.order)}
>
<Plus size={9} />
</button>
</span>
)}
</span>
))
)} )}
<button className="v5-nrm-drop" onClick={() => addPart("sequence")}> <button className="v5-nrm-drop" onClick={() => addPart("sequence")}>
<Plus size={12} /> <Plus size={11} />
</button> </button>
</div> </div>
{selectedPart && ( {selectedPart && (
<PartInspector <PartInspector
key={selectedPart.id}
part={selectedPart} part={selectedPart}
onUpdate={(patch) => updatePart(selectedPart.order, patch)}
onUpdateConfig={(patch) => updatePartAutoConfig(selectedPart.order, patch)} onUpdateConfig={(patch) => updatePartAutoConfig(selectedPart.order, patch)}
onRemove={() => removePart(selectedPart.order)} onRemove={() => removePart(selectedPart.order)}
rule={editingRule} rule={editingRule}
@@ -673,24 +796,24 @@ export default function NumberingRuleManagementPage() {
)} )}
<div className="v5-nrm-palette"> <div className="v5-nrm-palette">
<span className="lbl">+ add part</span> <span className="lbl"> :</span>
<button className="v5-nrm-palette-item" onClick={() => addPart("text")}> <button className="v5-nrm-palette-item" onClick={() => addPart("text")}>
<Type size={11} /> text <Type size={11} /> <span className="en">text</span>
</button> </button>
<button className="v5-nrm-palette-item" onClick={() => addPart("date")}> <button className="v5-nrm-palette-item" onClick={() => addPart("date")}>
<Calendar size={11} /> date <Calendar size={11} /> <span className="en">date</span>
</button> </button>
<button className="v5-nrm-palette-item" onClick={() => addPart("sequence")}> <button className="v5-nrm-palette-item" onClick={() => addPart("sequence")}>
<HashIcon size={11} /> sequence <HashIcon size={11} /> <span className="en">seq</span>
</button> </button>
<button className="v5-nrm-palette-item" onClick={() => addPart("number")}> <button className="v5-nrm-palette-item" onClick={() => addPart("number")}>
<Edit3 size={11} /> number <Edit3 size={11} /> <span className="en">num</span>
</button> </button>
<button className="v5-nrm-palette-item" onClick={() => addPart("category")}> <button className="v5-nrm-palette-item" onClick={() => addPart("category")}>
<Layers size={11} /> category <Layers size={11} /> <span className="en">cat</span>
</button> </button>
<button className="v5-nrm-palette-item" onClick={() => addPart("reference")}> <button className="v5-nrm-palette-item" onClick={() => addPart("reference")}>
<Link2 size={11} /> reference <Link2 size={11} /> <span className="en">ref</span>
</button> </button>
</div> </div>
</div> </div>
@@ -811,6 +934,7 @@ export default function NumberingRuleManagementPage() {
open={cmdkOpen} open={cmdkOpen}
onClose={() => setCmdkOpen(false)} onClose={() => setCmdkOpen(false)}
rules={rules} rules={rules}
onCreateRule={handleCreateDraftRule}
onSelectRule={(id) => { onSelectRule={(id) => {
setSelectedRuleId(id); setSelectedRuleId(id);
setCmdkOpen(false); setCmdkOpen(false);
@@ -870,57 +994,49 @@ function PipelineBlock({
selected, selected,
onClick, onClick,
onRemove, onRemove,
isLast,
separator,
}: { }: {
part: NumberingRulePart; part: NumberingRulePart;
selected: boolean; selected: boolean;
onClick: () => void; onClick: () => void;
onRemove: () => void; onRemove: () => void;
isLast: boolean;
separator: string;
}) { }) {
const today = new Date(); const today = new Date();
const previewSeq = 1; const previewSeq = 1;
const val = renderPartValue(part, today, previewSeq); const val = renderPartValue(part, today, previewSeq);
return ( return (
<> <div
<div className={`v5-nrm-block ${part.part_type}${selected ? " sel" : ""}`}
className={`v5-nrm-block ${part.part_type}${selected ? " sel" : ""}`} onClick={onClick}
onClick={onClick} >
<button
type="button"
className="x"
title="이 조각 삭제"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
> >
<span className="pin">{part.order}</span> ×
<span </button>
className="x" <div className="top-row">
onClick={(e) => { <span className="ord">{part.order}</span>
e.stopPropagation(); <span className="lbl">{PART_KO_LABEL[part.part_type]}</span>
onRemove(); <span className="typ-en">{buildPartEnTag(part)}</span>
}}
>×</span>
<span className="typ">
{PART_LABEL_BY_TYPE[part.part_type]}
{part.part_type === "date" && part.auto_config?.date_format ? ` · ${part.auto_config.date_format}` : ""}
{part.part_type === "sequence" && part.auto_config?.sequence_length
? ` · ${part.auto_config.sequence_length}d`
: ""}
</span>
<span className="val">{val || "—"}</span>
</div> </div>
{!isLast && <span className="v5-nrm-jn">{separator}</span>} <span className="val">{val || "—"}</span>
</> </div>
); );
} }
function PartInspector({ function PartInspector({
part, part,
onUpdate,
onUpdateConfig, onUpdateConfig,
onRemove, onRemove,
rule, rule,
onUpdateRule, onUpdateRule,
}: { }: {
part: NumberingRulePart; part: NumberingRulePart;
onUpdate: (patch: Partial<NumberingRulePart>) => void;
onUpdateConfig: (patch: Partial<NonNullable<NumberingRulePart["auto_config"]>>) => void; onUpdateConfig: (patch: Partial<NonNullable<NumberingRulePart["auto_config"]>>) => void;
onRemove: () => void; onRemove: () => void;
rule: NumberingRuleConfig; rule: NumberingRuleConfig;
@@ -930,13 +1046,13 @@ function PartInspector({
<div className="v5-nrm-insp"> <div className="v5-nrm-insp">
<div className="v5-nrm-insp-hd"> <div className="v5-nrm-insp-hd">
<div className="l"> <div className="l">
<span className="pin">#{part.order}</span> <span className="pin">{part.order} </span>
<span> <span>
<b>{PART_LABEL_BY_TYPE[part.part_type]}</b> <b>{PART_KO_LABEL[part.part_type]}</b>
</span> </span>
</div> </div>
<button className="v5-nrm-btn sm ghost danger" onClick={onRemove}> <button className="v5-nrm-btn sm ghost danger" onClick={onRemove}>
<Trash2 size={11} /> <Trash2 size={11} />
</button> </button>
</div> </div>
<div className="v5-nrm-insp-grid"> <div className="v5-nrm-insp-grid">
@@ -975,7 +1091,7 @@ function PartInspector({
/> />
</div> </div>
<div className="v5-nrm-insp-field grow"> <div className="v5-nrm-insp-field grow">
<label> ( )</label> <label> </label>
<div className="v5-nrm-seg"> <div className="v5-nrm-seg">
{(["none", "daily", "monthly", "yearly"] as ResetPeriod[]).map((p) => ( {(["none", "daily", "monthly", "yearly"] as ResetPeriod[]).map((p) => (
<button <button
@@ -987,6 +1103,13 @@ function PartInspector({
</button> </button>
))} ))}
</div> </div>
{rule.reset_period && rule.reset_period !== "none" && (
<span className="hint">
{rule.reset_period === "daily" && "매일 00:00 에 순번이 1 부터 다시 시작"}
{rule.reset_period === "monthly" && "매월 1일 00:00 에 순번이 1 부터 다시 시작"}
{rule.reset_period === "yearly" && "매년 1월 1일 00:00 에 순번이 1 부터 다시 시작"}
</span>
)}
</div> </div>
</> </>
)} )}
@@ -1025,6 +1148,7 @@ function PartInspector({
onClick={() => onUpdateConfig({ date_format: f.value })} onClick={() => onUpdateConfig({ date_format: f.value })}
> >
{f.value} {f.value}
<span className="sub">{f.example}</span>
</button> </button>
))} ))}
</div> </div>
@@ -1072,9 +1196,9 @@ function UsageList({ rule }: { rule: NumberingRuleConfig }) {
</div> </div>
)} )}
<button className="v5-nrm-usage-add"> <div className="v5-nrm-usage-note">
<Plus size={11} /> <Table2 size={11} /> .
</button> </div>
</div> </div>
); );
} }
@@ -1083,11 +1207,13 @@ function CommandPalette({
open, open,
onClose, onClose,
rules, rules,
onCreateRule,
onSelectRule, onSelectRule,
}: { }: {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
rules: NumberingRuleConfig[]; rules: NumberingRuleConfig[];
onCreateRule: (name?: string, preset?: "blank" | "simple" | "monthly" | "daily") => void;
onSelectRule: (id: string) => void; onSelectRule: (id: string) => void;
}) { }) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@@ -1131,13 +1257,16 @@ function CommandPalette({
<div className="v5-nrm-cmdk-group"> <div className="v5-nrm-cmdk-group">
<span> </span> <span> </span>
</div> </div>
<div className="v5-nrm-cmdk-item action focus"> <div
className="v5-nrm-cmdk-item action focus"
onClick={() => onCreateRule(query || undefined, "blank")}
>
<span className="ic"> <span className="ic">
<Plus size={12} /> <Plus size={12} />
</span> </span>
<div className="v5-nrm-cmdk-row"> <div className="v5-nrm-cmdk-row">
<div className="v5-nrm-cmdk-title"> <div className="v5-nrm-cmdk-title">
{query && <> <b>"{query}"</b> </>} · {query && <> <b>&quot;{query}&quot;</b> </>} ·
</div> </div>
<div className="v5-nrm-cmdk-meta"> </div> <div className="v5-nrm-cmdk-meta"> </div>
</div> </div>
@@ -1151,7 +1280,7 @@ function CommandPalette({
<span></span> <span></span>
<span className="num">3</span> <span className="num">3</span>
</div> </div>
<div className="v5-nrm-cmdk-item preset"> <div className="v5-nrm-cmdk-item preset" onClick={() => onCreateRule(query || undefined, "simple")}>
<span className="ic"><HashIcon size={12} /></span> <span className="ic"><HashIcon size={12} /></span>
<div className="v5-nrm-cmdk-row"> <div className="v5-nrm-cmdk-row">
<div className="v5-nrm-cmdk-title"></div> <div className="v5-nrm-cmdk-title"></div>
@@ -1160,7 +1289,7 @@ function CommandPalette({
</div> </div>
</div> </div>
</div> </div>
<div className="v5-nrm-cmdk-item preset"> <div className="v5-nrm-cmdk-item preset" onClick={() => onCreateRule(query || undefined, "monthly")}>
<span className="ic"><Calendar size={12} /></span> <span className="ic"><Calendar size={12} /></span>
<div className="v5-nrm-cmdk-row"> <div className="v5-nrm-cmdk-row">
<div className="v5-nrm-cmdk-title"> ()</div> <div className="v5-nrm-cmdk-title"> ()</div>
@@ -1169,7 +1298,7 @@ function CommandPalette({
</div> </div>
</div> </div>
</div> </div>
<div className="v5-nrm-cmdk-item preset"> <div className="v5-nrm-cmdk-item preset" onClick={() => onCreateRule(query || undefined, "daily")}>
<span className="ic"><Sparkles size={12} /></span> <span className="ic"><Sparkles size={12} /></span>
<div className="v5-nrm-cmdk-row"> <div className="v5-nrm-cmdk-row">
<div className="v5-nrm-cmdk-title"> </div> <div className="v5-nrm-cmdk-title"> </div>
@@ -1304,9 +1433,9 @@ function buildPatternPreview(rule: NumberingRuleConfig): string {
return `<span>${"0".repeat(len)}</span>`; return `<span>${"0".repeat(len)}</span>`;
} }
case "category": case "category":
return `<span>CAT</span>`; return "<span>CAT</span>";
case "reference": case "reference":
return `<span>REF</span>`; return "<span>REF</span>";
default: default:
return "?"; return "?";
} }
@@ -429,7 +429,7 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
} }
// 테이블 위젯이 있으면 자동 로드 건너뜀 (테이블 행 선택으로 데이터 로드) // 테이블 위젯이 있으면 자동 로드 건너뜀 (테이블 행 선택으로 데이터 로드)
// canonical table / legacy table-list / hidden v2-table-list / widgetType=table 모두 동일하게 skip // canonical table / widgetType=table 등 table-like 컴포넌트 모두 동일하게 skip
const hasTableWidget = layout.components.some((comp: any) => isTableLikeComponent(comp)); const hasTableWidget = layout.components.some((comp: any) => isTableLikeComponent(comp));
if (hasTableWidget) { if (hasTableWidget) {
+86 -6
View File
@@ -69,6 +69,7 @@ export function TabBar({ collapsed = false, onToggleCollapse, modeTransition = "
const dragLeaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const dragLeaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const dropGhostRef = useRef<HTMLDivElement>(null); const dropGhostRef = useRef<HTMLDivElement>(null);
const prevTabCountRef = useRef(tabs.length); const prevTabCountRef = useRef(tabs.length);
const activeTabElRef = useRef<HTMLDivElement | null>(null);
// --- State --- // --- State ---
const [visibleCount, setVisibleCount] = useState(tabs.length); const [visibleCount, setVisibleCount] = useState(tabs.length);
@@ -80,6 +81,12 @@ export function TabBar({ collapsed = false, onToggleCollapse, modeTransition = "
const [dragState, setDragState] = useState<DragState | null>(null); const [dragState, setDragState] = useState<DragState | null>(null);
const [externalDragIdx, setExternalDragIdx] = useState<number | null>(null); const [externalDragIdx, setExternalDragIdx] = useState<number | null>(null);
const [dropGhost, setDropGhost] = useState<DropGhost | null>(null); const [dropGhost, setDropGhost] = useState<DropGhost | null>(null);
const [closingIds, setClosingIds] = useState<Set<string>>(() => new Set());
const [indicatorStyle, setIndicatorStyle] = useState<{ left: number; width: number; opacity: number }>({
left: 0,
width: 0,
opacity: 0,
});
dragActiveRef.current = !!dragState; dragActiveRef.current = !!dragState;
@@ -91,6 +98,53 @@ export function TabBar({ collapsed = false, onToggleCollapse, modeTransition = "
}; };
}, []); }, []);
// --- 탭 leave 애니메이션: closing 표시 → 180ms 후 store splice ---
const CLOSE_ANIM_MS = 180;
const markClosing = useCallback((ids: string[]) => {
if (ids.length === 0) return;
setClosingIds((prev) => {
const next = new Set(prev);
ids.forEach((id) => next.add(id));
return next;
});
}, []);
const handleCloseTab = useCallback(
(tabId: string) => {
markClosing([tabId]);
setTimeout(() => closeTab(tabId), CLOSE_ANIM_MS);
},
[closeTab, markClosing],
);
const handleCloseOtherTabs = useCallback(
(tabId: string) => {
markClosing(tabs.filter((t) => t.id !== tabId).map((t) => t.id));
setTimeout(() => closeOtherTabs(tabId), CLOSE_ANIM_MS);
},
[tabs, closeOtherTabs, markClosing],
);
const handleCloseTabsToLeft = useCallback(
(tabId: string) => {
const idx = tabs.findIndex((t) => t.id === tabId);
if (idx <= 0) return;
markClosing(tabs.slice(0, idx).map((t) => t.id));
setTimeout(() => closeTabsToLeft(tabId), CLOSE_ANIM_MS);
},
[tabs, closeTabsToLeft, markClosing],
);
const handleCloseTabsToRight = useCallback(
(tabId: string) => {
const idx = tabs.findIndex((t) => t.id === tabId);
if (idx === -1 || idx >= tabs.length - 1) return;
markClosing(tabs.slice(idx + 1).map((t) => t.id));
setTimeout(() => closeTabsToRight(tabId), CLOSE_ANIM_MS);
},
[tabs, closeTabsToRight, markClosing],
);
const handleCloseAllTabs = useCallback(() => {
markClosing(tabs.map((t) => t.id));
setTimeout(() => closeAllTabs(), CLOSE_ANIM_MS);
}, [tabs, closeAllTabs, markClosing]);
// --- 드롭 고스트: Web Animations API로 드롭 위치 → 목표 슬롯 이동 --- // --- 드롭 고스트: Web Animations API로 드롭 위치 → 목표 슬롯 이동 ---
useEffect(() => { useEffect(() => {
if (!dropGhost) return; if (!dropGhost) return;
@@ -178,6 +232,20 @@ export function TabBar({ collapsed = false, onToggleCollapse, modeTransition = "
prevTabCountRef.current = tabs.length; prevTabCountRef.current = tabs.length;
}, [tabs.length, externalDragIdx]); }, [tabs.length, externalDragIdx]);
// --- Active 탭 underline indicator 위치 측정 (Chrome devtools 식 슬라이드) ---
useLayoutEffect(() => {
if (collapsed || !activeTabElRef.current || !containerRef.current) {
setIndicatorStyle((s) => (s.opacity === 0 ? s : { ...s, opacity: 0 }));
return;
}
const tabEl = activeTabElRef.current;
const left = tabEl.offsetLeft;
const width = tabEl.offsetWidth;
setIndicatorStyle((s) =>
s.left === left && s.width === width && s.opacity === 1 ? s : { left, width, opacity: 1 },
);
}, [activeTabId, displayVisible, collapsed, tabs.length]);
const resolveMenuAndOpenTab = async ( const resolveMenuAndOpenTab = async (
menuName: string, menuName: string,
menuObjid: string | number, menuObjid: string | number,
@@ -484,6 +552,7 @@ export function TabBar({ collapsed = false, onToggleCollapse, modeTransition = "
const renderTab = (tab: Tab, displayIndex: number) => { const renderTab = (tab: Tab, displayIndex: number) => {
const isActive = tab.id === activeTabId; const isActive = tab.id === activeTabId;
const isClosing = closingIds.has(tab.id);
const animStyle = getTabAnimStyle(tab.id, displayIndex); const animStyle = getTabAnimStyle(tab.id, displayIndex);
const hiddenByGhost = const hiddenByGhost =
!!dropGhost && displayIndex === dropGhost.targetIdx && tabs.length > dropGhost.tabCountAtCreation; !!dropGhost && displayIndex === dropGhost.targetIdx && tabs.length > dropGhost.tabCountAtCreation;
@@ -491,6 +560,7 @@ export function TabBar({ collapsed = false, onToggleCollapse, modeTransition = "
return ( return (
<div <div
key={tab.id} key={tab.id}
ref={isActive ? (el) => { activeTabElRef.current = el; } : undefined}
onPointerDown={(e) => handlePointerDown(e, tab.id, displayIndex)} onPointerDown={(e) => handlePointerDown(e, tab.id, displayIndex)}
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp} onPointerUp={handlePointerUp}
@@ -499,6 +569,7 @@ export function TabBar({ collapsed = false, onToggleCollapse, modeTransition = "
className={cn( className={cn(
"v5-tab group relative flex shrink-0 cursor-pointer items-center gap-1 px-3 select-none", "v5-tab group relative flex shrink-0 cursor-pointer items-center gap-1 px-3 select-none",
isActive && "on", isActive && "on",
isClosing && "closing",
)} )}
style={{ style={{
width: TAB_WIDTH, width: TAB_WIDTH,
@@ -526,7 +597,7 @@ export function TabBar({ collapsed = false, onToggleCollapse, modeTransition = "
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
closeTab(tab.id); handleCloseTab(tab.id);
}} }}
className="v5-tab-x" className="v5-tab-x"
> >
@@ -555,6 +626,15 @@ export function TabBar({ collapsed = false, onToggleCollapse, modeTransition = "
)} )}
{displayVisible.map((tab, i) => renderTab(tab, i))} {displayVisible.map((tab, i) => renderTab(tab, i))}
<div
className="v5-tab-indicator"
style={{
transform: `translateX(${indicatorStyle.left}px)`,
width: indicatorStyle.width,
opacity: indicatorStyle.opacity,
}}
/>
{hasOverflow && ( {hasOverflow && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -574,7 +654,7 @@ export function TabBar({ collapsed = false, onToggleCollapse, modeTransition = "
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
closeTab(tab.id); handleCloseTab(tab.id);
}} }}
className="hover:bg-destructive/10 hover:text-destructive flex h-4 w-4 shrink-0 items-center justify-center rounded-sm" className="hover:bg-destructive/10 hover:text-destructive flex h-4 w-4 shrink-0 items-center justify-center rounded-sm"
> >
@@ -646,21 +726,21 @@ export function TabBar({ collapsed = false, onToggleCollapse, modeTransition = "
<ContextMenuItem <ContextMenuItem
label="왼쪽 탭 닫기" label="왼쪽 탭 닫기"
onClick={() => { onClick={() => {
closeTabsToLeft(contextMenu.tabId); handleCloseTabsToLeft(contextMenu.tabId);
setContextMenu(null); setContextMenu(null);
}} }}
/> />
<ContextMenuItem <ContextMenuItem
label="오른쪽 탭 닫기" label="오른쪽 탭 닫기"
onClick={() => { onClick={() => {
closeTabsToRight(contextMenu.tabId); handleCloseTabsToRight(contextMenu.tabId);
setContextMenu(null); setContextMenu(null);
}} }}
/> />
<ContextMenuItem <ContextMenuItem
label="다른 탭 모두 닫기" label="다른 탭 모두 닫기"
onClick={() => { onClick={() => {
closeOtherTabs(contextMenu.tabId); handleCloseOtherTabs(contextMenu.tabId);
setContextMenu(null); setContextMenu(null);
}} }}
/> />
@@ -668,7 +748,7 @@ export function TabBar({ collapsed = false, onToggleCollapse, modeTransition = "
<ContextMenuItem <ContextMenuItem
label="모든 탭 닫기" label="모든 탭 닫기"
onClick={() => { onClick={() => {
closeAllTabs(); handleCloseAllTabs();
setContextMenu(null); setContextMenu(null);
}} }}
destructive destructive
@@ -3,7 +3,7 @@
* 좌측과 우측에 화면을 임베드합니다. * 좌측과 우측에 화면을 임베드합니다.
* *
* 데이터 전달은 좌측 화면에 배치된 버튼의 transferData 액션으로 처리됩니다. * 데이터 전달은 좌측 화면에 배치된 버튼의 transferData 액션으로 처리됩니다.
* 예: 좌측 화면에 TableListComponent + Button(transferData 액션) 배치 * 예: 좌측 화면에 canonical TableComponent + Button(transferData 액션) 배치
*/ */
"use client"; "use client";
+1 -3
View File
@@ -4102,9 +4102,7 @@ export default function InvyoneStudio({
"divider-basic": 1, // 구분선 (100%) "divider-basic": 1, // 구분선 (100%)
"divider-line": 1, // 구분선 (100%) "divider-line": 1, // 구분선 (100%)
"accordion-basic": 1, // 아코디언 (100%) "accordion-basic": 1, // 아코디언 (100%)
"table": 1, // canonical 테이블 (100%) "table": 1, // canonical 테이블 (100%) — 옛 ID 들은 isTableLikeComponentType 헬퍼가 흡수
"table-list": 1, // legacy 테이블 리스트 (100%)
"v2-table-list": 1, // hidden legacy 테이블 리스트 (100%)
"data-table": 1, // 데이터 테이블 (100%) "data-table": 1, // 데이터 테이블 (100%)
"datatable": 1, // 데이터 테이블 (100%) "datatable": 1, // 데이터 테이블 (100%)
"image-display": 4 / 12, // 이미지 표시 (33%) "image-display": 4 / 12, // 이미지 표시 (33%)
@@ -343,7 +343,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
// 컴포넌트 기본 스타일 - 레이아웃은 항상 맨 아래 // 컴포넌트 기본 스타일 - 레이아웃은 항상 맨 아래
// 🔥 모든 컴포넌트를 픽셀 기준으로 통일 (스케일로만 조정) // 🔥 모든 컴포넌트를 픽셀 기준으로 통일 (스케일로만 조정)
const getWidth = () => { const getWidth = () => {
// 모든 컴포넌트는 size.width 픽셀 사용 (table-list 포함) // 모든 컴포넌트는 size.width 픽셀 사용 (canonical table 포함)
const width = `${size?.width || 100}px`; const width = `${size?.width || 100}px`;
return width; return width;
}; };
@@ -373,8 +373,8 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
// 런타임 모드에서 컴포넌트 타입별 높이 처리 // 런타임 모드에서 컴포넌트 타입별 높이 처리
if (!isDesignMode) { if (!isDesignMode) {
// 레이아웃 계열: 부모 래퍼를 꽉 채움 (ResponsiveGridRenderer가 % 높이 관리) // 레이아웃 계열: 부모 래퍼를 꽉 채움 (ResponsiveGridRenderer가 % 높이 관리)
// ★ table 계열 (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list' / // ★ table 계열 (canonical 'table' / 'data-table' / 'datatable') 은 helper 로 통일.
// 'data-table' / 'datatable') 은 helper 로 통일. 그 외 layout/split/tabs 는 명시 목록. // 그 외 layout/split/tabs 는 명시 목록.
const fillParentExtraTypes = [ const fillParentExtraTypes = [
"container", "container",
"grouped-table", "card-list", "grouped-table", "card-list",
@@ -396,7 +396,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
} }
// 1순위: size.height가 있으면 우선 사용 // 1순위: size.height가 있으면 우선 사용
// (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list' 모두 최소 200px 보장) // (canonical 'table' 등 table-like 컴포넌트 모두 최소 200px 보장)
if (size?.height && size.height > 0) { if (size?.height && size.height > 0) {
if (isTableLikeComponentType(sizingType)) { if (isTableLikeComponentType(sizingType)) {
return `${Math.max(size.height, 200)}px`; return `${Math.max(size.height, 200)}px`;
+2 -3
View File
@@ -225,11 +225,10 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
}; };
// ========== 컴포넌트 종류별 미니어처 색상 ========== // ========== 컴포넌트 종류별 미니어처 색상 ==========
// componentKind 는 더 정확한 컴포넌트 타입 (canonical 'table' / legacy 'table-list' / // componentKind 는 더 정확한 컴포넌트 타입 (canonical 'table' / 'button-primary' 등)
// hidden 'v2-table-list' / 'button-primary' 등)
const TABLE_LIKE_EXTRA_KINDS = ["grouped-table", "card-list", "data-grid"]; const TABLE_LIKE_EXTRA_KINDS = ["grouped-table", "card-list", "data-grid"];
const getComponentColor = (componentKind: string) => { const getComponentColor = (componentKind: string) => {
// 테이블/그리드 관련 (canonical table / legacy table-list / hidden v2-table-list 등) // 테이블/그리드 관련 (canonical table table-like 컴포넌트)
if (isTableLikeComponentType(componentKind) || TABLE_LIKE_EXTRA_KINDS.includes(componentKind)) { if (isTableLikeComponentType(componentKind) || TABLE_LIKE_EXTRA_KINDS.includes(componentKind)) {
return "bg-primary/20 border-primary/40"; return "bg-primary/20 border-primary/40";
} }
@@ -30,7 +30,7 @@ import { getApprovalDefinitions, type ApprovalDefinition } from "@/lib/api/appro
import type { ButtonTabProps, TitleBlock, ScreenOption } from "./types"; import type { ButtonTabProps, TitleBlock, ScreenOption } from "./types";
import { isTableLikeComponentType, isTableLikeComponent, getTableNameFromTableLikeComponent } from "@/lib/utils/componentTypeUtils"; import { isTableLikeComponentType, isTableLikeComponent, getTableNameFromTableLikeComponent } from "@/lib/utils/componentTypeUtils";
// canonical table / legacy table-list / hidden v2-table-list / data-table / datatable // canonical table / data-table / datatable 등 table-like 컴포넌트
// 은 table-like helper 로 통일. 추가로 repeater-field-group / form-group 도 데이터 전송 // 은 table-like helper 로 통일. 추가로 repeater-field-group / form-group 도 데이터 전송
// 호환 대상으로 함께 인식. // 호환 대상으로 함께 인식.
const DATA_TRANSFER_EXTRA_PATTERNS = ["repeater-field-group", "form-group"] as const; const DATA_TRANSFER_EXTRA_PATTERNS = ["repeater-field-group", "form-group"] as const;
@@ -368,10 +368,7 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
sourceTableName = getTableNameFromTableLikeComponent(comp) || null; sourceTableName = getTableNameFromTableLikeComponent(comp) || null;
if (sourceTableName) break; if (sourceTableName) break;
} }
if (compType === "v2-list") { // 옛 통합 목록 분기 폐기 (Phase F.8) — canonical table 사용.
sourceTableName = compConfig.dataSource?.table || compConfig.table_name || null;
if (sourceTableName) break;
}
} }
setModalActionSourceTable(sourceTableName); setModalActionSourceTable(sourceTableName);
@@ -529,7 +526,7 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
} }
} }
// 테이블 계열 (canonical table / legacy table-list / hidden v2-table-list 모두) // 테이블 계열 (canonical table table-like 컴포넌트 모두)
if (isTableLikeComponent(comp)) { if (isTableLikeComponent(comp)) {
sourceTableName = getTableNameFromTableLikeComponent(comp) ?? compConfig?.table_name; sourceTableName = getTableNameFromTableLikeComponent(comp) ?? compConfig?.table_name;
if (sourceTableName) { if (sourceTableName) {
@@ -36,7 +36,7 @@ export interface DataTabProps {
>; >;
} }
// canonical table / legacy table-list / hidden v2-table-list / data-table / datatable // canonical table / data-table / datatable 등 table-like 컴포넌트
// 은 table-like helper 로 통일. 추가로 repeater-field-group / form-group 도 데이터 전송 // 은 table-like helper 로 통일. 추가로 repeater-field-group / form-group 도 데이터 전송
// 호환 대상으로 함께 인식. // 호환 대상으로 함께 인식.
const DATA_TRANSFER_EXTRA_PATTERNS = ["repeater-field-group", "form-group"] as const; const DATA_TRANSFER_EXTRA_PATTERNS = ["repeater-field-group", "form-group"] as const;
@@ -55,6 +55,7 @@ import { Progress } from "@/components/ui/progress";
import { ComponentData } from "@/types/screen"; import { ComponentData } from "@/types/screen";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
// 다국어 키 타입 // 다국어 키 타입
interface LangKey { interface LangKey {
@@ -146,16 +147,16 @@ interface MultilangSettingsModalProps {
onSave: (updates: Array<{ componentId: string; path?: string; langKeyId: number; langKey: string }>) => void; onSave: (updates: Array<{ componentId: string; path?: string; langKeyId: number; langKey: string }>) => void;
} }
// 타입별 아이콘 매핑 // 타입별 아이콘 매핑.
// canonical table / legacy table-list / hidden v2-table-list 모두 같은 table 아이콘. // Phase E.3 — canonical / legacy / hidden table-like 는 모두 isTableLikeComponentType 헬퍼로
// 흡수해 같은 Table2 아이콘. hard-coded 옛 ID literal 제거.
const getTypeIcon = (type: string) => { const getTypeIcon = (type: string) => {
if (isTableLikeComponentType(type)) {
return <Table2 className="h-4 w-4" />;
}
switch (type) { switch (type) {
case "button": case "button":
return <MousePointer className="h-4 w-4" />; return <MousePointer className="h-4 w-4" />;
case "table":
case "table-list":
case "v2-table-list":
return <Table2 className="h-4 w-4" />;
case "split-panel-layout": case "split-panel-layout":
return <LayoutPanelLeft className="h-4 w-4" />; return <LayoutPanelLeft className="h-4 w-4" />;
case "filter": case "filter":
@@ -194,12 +195,11 @@ const getTypeLabel = (type: string) => {
} }
}; };
// 라벨 다국어 처리가 필요 없는 컴포넌트 타입 (테이블, 분할패널 등) // 라벨 다국어 처리가 필요 없는 컴포넌트 타입 (테이블, 분할패널 등).
// canonical table 및 hidden legacy v2-table-list 도 모두 non-input 으로 분류. // Phase E.3 — table-like 는 isTableLikeComponentType 헬퍼 (isInputComponent 안 분기) 가 흡수.
// 여기는 canonical "table" 만 명시 — 옛 ID literal 은 헬퍼가 처리.
const NON_INPUT_COMPONENT_TYPES = new Set([ const NON_INPUT_COMPONENT_TYPES = new Set([
"table", "table",
"table-list",
"v2-table-list",
"split-panel-layout", "split-panel-layout",
"tab-panel", "tab-panel",
"container", "container",
@@ -245,6 +245,12 @@ const isInputComponent = (comp: any): boolean => {
const compType = comp.componentType || comp.type; const compType = comp.componentType || comp.type;
const webType = comp.webType || comp.componentConfig?.webType; const webType = comp.webType || comp.componentConfig?.webType;
// Phase E.3 — table-like (canonical "table" / legacy ID 등) 는 입력 아님.
// 헬퍼가 옛 ID 도 흡수하므로 NON_INPUT_COMPONENT_TYPES 에 직접 나열할 필요 없음.
if (isTableLikeComponentType(compType)) {
return false;
}
// 명시적으로 제외되는 컴포넌트 타입 // 명시적으로 제외되는 컴포넌트 타입
if (NON_INPUT_COMPONENT_TYPES.has(compType)) { if (NON_INPUT_COMPONENT_TYPES.has(compType)) {
return false; return false;
@@ -39,13 +39,12 @@ export function ComponentsPanel({
}: ComponentsPanelProps) { }: ComponentsPanelProps) {
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
// 레지스트리에서 모든 컴포넌트 조회 // 레지스트리에서 모든 컴포넌트 조회.
// Phase E.3 — 새 생성 경로는 canonical 'table' (displayMode='table') 뿐.
// 옛 layout JSON 호환은 BlockRenderer / DynamicComponentRenderer / templateMigrate 의
// alias 라우팅 + TableComponent 의 early delegation 으로 처리되며 팔레트와는 무관.
const allComponents = useMemo(() => { const allComponents = useMemo(() => {
const components = ComponentRegistry.getAllComponents(); return ComponentRegistry.getAllComponents();
// ★ 새 생성 경로는 canonical 'table' (displayMode='table').
// v2-table-list 는 옛 저장 화면 호환 hard blocker 로 자동 등록되지만
// 팔레트에는 hidden 처리한다 (아래 hiddenComponents 참고).
return components;
}, []); }, []);
// ── 기본 컴포넌트 (v2 하드코딩) ── // ── 기본 컴포넌트 (v2 하드코딩) ──
@@ -137,7 +136,6 @@ export function ComponentsPanel({
"button-primary", // → v2-button-primary "button-primary", // → v2-button-primary
"split-panel-layout", // → v2-split-panel-layout "split-panel-layout", // → v2-split-panel-layout
// aggregation-widget: 폴더/Renderer 삭제 (2026-05-19). ComponentRegistry 에 없음 — hidden 처리 불필요 // aggregation-widget: 폴더/Renderer 삭제 (2026-05-19). ComponentRegistry 에 없음 — hidden 처리 불필요
"table-list", // legacy hidden — 새 생성 경로는 canonical 'table'
"text-display", // → v2-text-display "text-display", // → v2-text-display
"divider-line", // → v2-divider-line "divider-line", // → v2-divider-line
// ★ 2026-04-11 통합 컴포넌트(Phase A-1): 구분선 3종 → `divider` // ★ 2026-04-11 통합 컴포넌트(Phase A-1): 구분선 3종 → `divider`
@@ -171,9 +169,10 @@ export function ComponentsPanel({
// form 컴포넌트는 롤백됨 (2026-04-11): 3뷰 탭 구조로 처리 예정. // form 컴포넌트는 롤백됨 (2026-04-11): 3뷰 탭 구조로 처리 예정.
"field-example-1", // legacy form-layout 의 실제 id (숨김 유지) "field-example-1", // legacy form-layout 의 실제 id (숨김 유지)
// ★ 2026-04-11 통합 컴포넌트(Phase C-1): 데이터 테이블 → `table` // ★ 2026-04-11 통합 컴포넌트(Phase C-1): 데이터 테이블 → `table`
"v2-table-list", // → table (displayMode='table') // Phase E.3 — 옛 hidden table ID 들은 ComponentRegistry 에 등록되지 않으므로
// hidden 목록에 둘 필요 없음. canonical 'table' 만 새 생성 경로.
"v2-split-panel-layout", // → table (displayMode='split') "v2-split-panel-layout", // → table (displayMode='split')
// table-list, split-panel-layout, split-panel-layout2, modal-repeater-table, // split-panel-layout, split-panel-layout2, modal-repeater-table,
// simple-repeater-table, tax-invoice-list, pivot-grid 는 기존 상단에서 이미 숨김 // simple-repeater-table, tax-invoice-list, pivot-grid 는 기존 상단에서 이미 숨김
// ★ 2026-04-11 통합 컴포넌트(Phase C-2): 컨테이너 → `container` // ★ 2026-04-11 통합 컴포넌트(Phase C-2): 컨테이너 → `container`
// v2-tabs-widget / v2-section-card / v2-section-paper / section-card / section-paper / tabs / tabs-widget: // v2-tabs-widget / v2-section-card / v2-section-paper / section-card / section-paper / tabs / tabs-widget:
@@ -230,7 +229,6 @@ export function ComponentsPanel({
const s = 14; const s = 14;
const icons: Record<string, React.ReactNode> = { const icons: Record<string, React.ReactNode> = {
table: <Table2 size={s} />, table: <Table2 size={s} />,
"v2-table-list": <Table2 size={s} />,
stats: <BarChart3 size={s} />, stats: <BarChart3 size={s} />,
title: <Type size={s} />, title: <Type size={s} />,
divider: <Minus size={s} />, divider: <Minus size={s} />,
@@ -224,7 +224,8 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
if (componentId?.startsWith("v2-")) { if (componentId?.startsWith("v2-")) {
const v2ConfigPanels: Record<string, React.FC<{ config: any; onChange: (config: any) => void }>> = { const v2ConfigPanels: Record<string, React.FC<{ config: any; onChange: (config: any) => void }>> = {
// V2 입력/선택 폐기 (2026-05-12) — input canonical 로 흡수. 하드코딩 매핑 제거. // V2 입력/선택 폐기 (2026-05-12) — input canonical 로 흡수. 하드코딩 매핑 제거.
// v2-date / v2-list / v2-repeater / v2-table-list 는 InvField 등 통합 — ComponentRegistry fallback 사용 // 옛 통합 목록 / repeater / 옛 표 형식 / v2-date 는 InvField · canonical table 등으로 흡수
// (ComponentRegistry fallback 으로 라우팅)
"v2-layout": require("@/components/v2/config-panels/V2LayoutConfigPanel").V2LayoutConfigPanel, "v2-layout": require("@/components/v2/config-panels/V2LayoutConfigPanel").V2LayoutConfigPanel,
"v2-group": require("@/components/v2/config-panels/V2GroupConfigPanel").V2GroupConfigPanel, "v2-group": require("@/components/v2/config-panels/V2GroupConfigPanel").V2GroupConfigPanel,
// v2-media — Phase D.5 폐기. canonical input (FilePicker) 으로 흡수. // v2-media — Phase D.5 폐기. canonical input (FilePicker) 으로 흡수.
@@ -249,9 +250,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
const extraProps: Record<string, any> = {}; const extraProps: Record<string, any> = {};
const resolvedTableName = (selectedComponent as any).tableName || currentTable?.tableName || currentTableName; const resolvedTableName = (selectedComponent as any).tableName || currentTable?.tableName || currentTableName;
if (componentId === "v2-list") { // 옛 통합 목록 extraProps 분기 폐기 (Phase F.8) — canonical table 로 흡수.
extraProps.currentTableName = currentTableName;
}
if (componentId === "v2-bom-item-editor" || componentId === "v2-bom-tree") { if (componentId === "v2-bom-item-editor" || componentId === "v2-bom-tree") {
extraProps.currentTableName = currentTableName; extraProps.currentTableName = currentTableName;
extraProps.screenTableName = resolvedTableName; extraProps.screenTableName = resolvedTableName;
@@ -97,6 +97,9 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
setGroupSumEnabled(false); setGroupSumEnabled(false);
setGroupByColumn(""); setGroupByColumn("");
} }
} else {
setGroupSumEnabled(false);
setGroupByColumn("");
} }
if (savedFilters) { if (savedFilters) {
@@ -252,9 +255,19 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
const groupSumConfig: GroupSumConfig = { const groupSumConfig: GroupSumConfig = {
enabled: groupSumEnabled, enabled: groupSumEnabled,
group_by_column: groupByColumn, group_by_column: groupByColumn,
group_by_column_label: table.columns.find((col) => col.column_name === groupByColumn)?.column_label,
}; };
localStorage.setItem(groupSumKey, JSON.stringify(groupSumConfig)); localStorage.setItem(groupSumKey, JSON.stringify(groupSumConfig));
// Phase D.8 — group-sum 콜백 호출 (canonical TableComponent 가 GroupedView 강제 + summary 렌더)
if (table.onGroupSumChange) {
if (groupSumEnabled && groupByColumn) {
table.onGroupSumChange(groupSumConfig);
} else {
table.onGroupSumChange(null);
}
}
// 활성화된 필터만 콜백 // 활성화된 필터만 콜백
const activeFilters: TableFilter[] = columnFilters const activeFilters: TableFilter[] = columnFilters
.filter((f) => f.enabled) .filter((f) => f.enabled)
@@ -265,6 +278,8 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
filter_type: f.filterType, filter_type: f.filterType,
width: f.width && f.width >= 10 && f.width <= 100 ? f.width : 25, width: f.width && f.width >= 10 && f.width <= 100 ? f.width : 25,
})); }));
// Phase D.8 — canonical TableComponent.onFilterChange 도 호출 (이전엔 onFiltersApplied 만)
table.onFilterChange(activeFilters);
onFiltersApplied?.(activeFilters); onFiltersApplied?.(activeFilters);
// 3. 그룹화 저장 // 3. 그룹화 저장
@@ -657,4 +672,3 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
</Dialog> </Dialog>
); );
}; };
@@ -144,7 +144,7 @@ export function TabsWidget({
const inlineComponents = tab.components || []; const inlineComponents = tab.components || [];
if (inlineComponents.length > 0) { if (inlineComponents.length > 0) {
// 인라인 컴포넌트에서 table-like 컴포넌트의 selectedTable 추출 // 인라인 컴포넌트에서 table-like 컴포넌트의 selectedTable 추출
// (canonical table / legacy table-list / hidden v2-table-list 모두 인식, // (canonical table table-like 컴포넌트 인식,
// camelCase / snake_case 양쪽 모두 처리) // camelCase / snake_case 양쪽 모두 처리)
const tableComp = inlineComponents.find((c) => isTableLikeComponent(c)); const tableComp = inlineComponents.find((c) => isTableLikeComponent(c));
const selectedTable = getTableNameFromTableLikeComponent(tableComp); const selectedTable = getTableNameFromTableLikeComponent(tableComp);
@@ -11,14 +11,13 @@ import React, { forwardRef, useMemo } from "react";
import { import {
V2ComponentProps, V2ComponentProps,
isV2Text, isV2Text,
isV2List,
isV2Layout, isV2Layout,
isV2Group, isV2Group,
isV2Biz, isV2Biz,
isV2Hierarchy, isV2Hierarchy,
} from "@/types/v2-components"; } from "@/types/v2-components";
// 옛 입력/선택 import 는 Phase D.3 에서 제거. V2Media 는 Phase D.5 에서 제거 — canonical input 으로 흡수. // 옛 입력/선택 import 는 Phase D.3 에서 제거. V2Media 는 Phase D.5 에서 제거 — canonical input 으로 흡수.
import { V2List } from "./V2List"; // V2List 는 Phase F.8 (2026-05-21) 에서 제거 — canonical table 로 흡수.
import { V2Layout } from "./V2Layout"; import { V2Layout } from "./V2Layout";
import { V2Group } from "./V2Group"; import { V2Group } from "./V2Group";
import { V2Biz } from "./V2Biz"; import { V2Biz } from "./V2Biz";
@@ -48,10 +47,7 @@ export const V2ComponentRenderer = forwardRef<HTMLDivElement, V2ComponentRendere
} }
// V2Media — Phase D.5 폐기. canonical input 의 file 분기로 흡수. // V2Media — Phase D.5 폐기. canonical input 의 file 분기로 흡수.
// V2List — Phase F.8 폐기. canonical table 로 흡수.
if (isV2List(props)) {
return <V2List {...props} />;
}
if (isV2Layout(props)) { if (isV2Layout(props)) {
return <V2Layout {...props} />; return <V2Layout {...props} />;
+5 -46
View File
@@ -15,7 +15,7 @@ import { Separator } from "@/components/ui/separator";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
// V2 컴포넌트들 (옛 입력/선택은 Phase D.3 에서 폐기 — canonical `input` 으로 흡수됨) // V2 컴포넌트들 (옛 입력/선택은 Phase D.3 에서 폐기 — canonical `input` 으로 흡수됨)
import { V2List } from "./V2List"; // V2List 는 Phase F.8 에서 폐기 — canonical table 로 흡수.
import { V2Layout } from "./V2Layout"; import { V2Layout } from "./V2Layout";
import { V2Group } from "./V2Group"; import { V2Group } from "./V2Group";
// V2Media — Phase D.5 폐기. canonical input 의 file 분기로 흡수. // V2Media — Phase D.5 폐기. canonical input 의 file 분기로 흡수.
@@ -32,17 +32,10 @@ interface V2ComponentsDemoProps {
} }
export function V2ComponentsDemo({ onBack }: V2ComponentsDemoProps) { export function V2ComponentsDemo({ onBack }: V2ComponentsDemoProps) {
const [activeTab, setActiveTab] = useState("list"); const [activeTab, setActiveTab] = useState("layout");
// 데모용 상태 (옛 입력/선택 데모 state — Phase D.3 에서 제거됨)
// 샘플 데이터 // 데모용 상태 (옛 입력/선택 데모 state — Phase D.3 에서 제거됨)
const sampleTableData = [ // sampleTableData / List 탭은 Phase F.8 에서 제거 — canonical table demo 별도 화면.
{ id: 1, name: "홍길동", email: "hong@test.com", status: "active", date: "2024-01-15" },
{ id: 2, name: "김철수", email: "kim@test.com", status: "inactive", date: "2024-02-20" },
{ id: 3, name: "이영희", email: "lee@test.com", status: "active", date: "2024-03-10" },
{ id: 4, name: "박민수", email: "park@test.com", status: "pending", date: "2024-04-05" },
];
const sampleHierarchyData: HierarchyNode[] = [ const sampleHierarchyData: HierarchyNode[] = [
{ {
@@ -92,7 +85,6 @@ export function V2ComponentsDemo({ onBack }: V2ComponentsDemoProps) {
<div className="flex-1 overflow-auto p-4"> <div className="flex-1 overflow-auto p-4">
<Tabs value={activeTab} onValueChange={setActiveTab}> <Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2 lg:grid-cols-6"> <TabsList className="grid w-full grid-cols-2 lg:grid-cols-6">
<TabsTrigger value="list">List</TabsTrigger>
<TabsTrigger value="layout">Layout</TabsTrigger> <TabsTrigger value="layout">Layout</TabsTrigger>
<TabsTrigger value="group" className="hidden lg:flex">Group</TabsTrigger> <TabsTrigger value="group" className="hidden lg:flex">Group</TabsTrigger>
<TabsTrigger value="biz" className="hidden lg:flex">Biz</TabsTrigger> <TabsTrigger value="biz" className="hidden lg:flex">Biz</TabsTrigger>
@@ -101,40 +93,7 @@ export function V2ComponentsDemo({ onBack }: V2ComponentsDemoProps) {
{/* 조건부 동작 데모 탭 — Phase D.3 에서 폐기 (옛 입력/선택 의존) */} {/* 조건부 동작 데모 탭 — Phase D.3 에서 폐기 (옛 입력/선택 의존) */}
{/* 옛 입력/선택 탭 — Phase D.3 에서 폐기. canonical `input` 데모는 별도 화면에서 확인 */} {/* 옛 입력/선택 탭 — Phase D.3 에서 폐기. canonical `input` 데모는 별도 화면에서 확인 */}
{/* List 탭 — Phase F.8 에서 폐기. canonical table demo 는 별도 화면에서 확인 */}
{/* V2List 탭 */}
<TabsContent value="list" className="mt-6">
<Card>
<CardHeader>
<CardTitle>V2List</CardTitle>
<CardDescription>
- table, card, list
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<V2List
id="demo-list"
label="사용자 목록"
v2Type="V2List"
config={{
view_mode: "table",
searchable: true,
pageable: true,
page_size: 5,
columns: [
{ field: "id", header: "ID", width: 60, sortable: true },
{ field: "name", header: "이름", sortable: true },
{ field: "email", header: "이메일" },
{ field: "status", header: "상태" },
{ field: "date", header: "등록일", format: "date", sortable: true },
],
}}
data={sampleTableData}
onRowClick={(row) => console.log("Row clicked:", row)}
/>
</CardContent>
</Card>
</TabsContent>
{/* V2Layout 탭 */} {/* V2Layout 탭 */}
<TabsContent value="layout" className="mt-6"> <TabsContent value="layout" className="mt-6">
-176
View File
@@ -1,176 +0,0 @@
"use client";
/**
* V2List
*
*
* TableListComponent를
*/
import React, { forwardRef, useMemo } from "react";
import { TableListComponent } from "@/lib/registry/components/table/_shared/TableListComponent";
import { V2ListProps } from "@/types/v2-components";
/**
* V2List
* TableListComponent의
*/
export const V2List = forwardRef<HTMLDivElement, V2ListProps>((props, ref) => {
const { id, style, size, config: configProp, onRowSelect } = props;
// config가 없으면 기본값 사용
const config = configProp || {
viewMode: "table" as const,
source: "static" as const,
columns: [],
};
// 테이블명 추출 (여러 가능한 경로에서 시도)
const tableName = config.dataSource?.table || (config as any).tableName || (props as any).tableName;
// columns 형식 변환 (V2ListConfigPanel 형식 -> TableListComponent 형식)
const tableColumns = useMemo(
() =>
(config.columns || []).map((col: any, index: number) => ({
columnName: col.key || col.field || "",
displayName: col.title || col.header || col.key || col.field || "",
width: col.width ? parseInt(col.width, 10) : undefined,
visible: true,
sortable: true,
searchable: true,
align: "left" as const,
order: index,
isEntityJoin: col.isJoinColumn || false,
thousandSeparator: col.thousandSeparator !== false, // 천단위 구분자 (기본: true)
})),
[config.columns],
);
// TableListComponent에 전달할 component 객체 생성
const componentObj = useMemo(
() => ({
id: id || "v2-list",
type: "table-list",
config: {
selectedTable: tableName,
tableName: tableName,
columns: tableColumns,
displayMode: config.viewMode === "card" ? "card" : "table",
cardConfig: {
idColumn: config.cardConfig?.titleColumn || tableColumns[0]?.columnName || "id",
titleColumn: config.cardConfig?.titleColumn || tableColumns[0]?.columnName || "",
subtitleColumn: config.cardConfig?.subtitleColumn || undefined,
descriptionColumn: config.cardConfig?.descriptionColumn || undefined,
imageColumn: config.cardConfig?.imageColumn || undefined,
cardsPerRow: config.cardConfig?.cardsPerRow || 3,
cardSpacing: 16,
showActions: false,
},
showHeader: config.viewMode !== "card", // 카드 모드에서는 테이블 헤더 숨김
showFooter: false,
checkbox: {
enabled: true, // 항상 체크박스 활성화 (modalDataStore에 자동 저장)
position: "left" as const,
showHeader: true,
},
height: "auto" as const, // auto로 변경하여 스크롤 가능하게
autoWidth: true,
stickyHeader: true,
autoLoad: true,
horizontalScroll: {
enabled: true,
minColumnWidth: 100,
maxColumnWidth: 300,
},
pagination: {
enabled: config.pagination !== false,
pageSize: config.pageSize || 10,
position: "bottom" as const,
showPageSize: true, // 사용자가 실제 화면에서 페이지 크기 변경 가능
pageSizeOptions: [5, 10, 20, 50, 100],
},
filter: {
enabled: false, // 필터 비활성화 (필요시 활성화)
position: "top" as const,
searchPlaceholder: "검색...",
},
actions: {
enabled: false,
items: [],
},
tableStyle: {
striped: false,
bordered: true,
hover: true,
compact: false,
},
toolbar: {
showRefresh: true,
showExport: false,
showColumnToggle: false,
},
},
style: {},
gridColumns: 1,
}),
[
id,
tableName,
tableColumns,
config.viewMode,
config.pagination,
config.pageSize,
config.cardConfig,
onRowSelect,
],
);
// 테이블이 없으면 안내 메시지
if (!tableName) {
return (
<div
ref={ref}
id={id}
className="bg-muted/20 flex items-center justify-center rounded-lg border p-8"
style={{
width: size?.width || style?.width || "100%",
height: size?.height || style?.height || "100%",
}}
>
<p className="text-muted-foreground text-sm"> .</p>
</div>
);
}
return (
<div
ref={ref}
id={id}
className="flex flex-col overflow-auto"
style={{
width: size?.width || style?.width || "100%",
height: size?.height || style?.height || "100%",
}}
>
<TableListComponent
component={componentObj}
tableName={tableName}
style={{
width: "100%",
minHeight: "100%",
display: "flex",
flexDirection: "column",
}}
onSelectedRowsChange={
onRowSelect
? (_, selectedData) => {
onRowSelect(selectedData);
}
: undefined
}
/>
</div>
);
});
V2List.displayName = "V2List";
@@ -1,35 +1,31 @@
"use client"; "use client";
/** /**
* InvDataConfigPanel "데이터 조회/선택" ConfigPanel * InvDataConfigPanel "데이터 입력" ConfigPanel
* *
* (v2-* runtime id DB ): * (v2-* runtime id DB ):
* - v2-list (read) /
* - v2-table-list (read) / (legacy, hidden)
* - v2-repeater (write) / * - v2-repeater (write) /
* *
* : * Phase F.8 (2026-05-21):
* = wrapper (cp brumb + ) * - read ( ) canonical TableComponent + InvTableConfigPanel .
* = brumb componentType + cp + resolver/writer * - write . v2-list v2-repeater .
* *
* Reference: notes/gbpark/2026-04-28-cp-panel-standard.md, 2026-04-28-invdata-inventory.md * Reference: notes/gbpark/2026-04-28-cp-panel-standard.md, 2026-04-28-invdata-inventory.md
*/ */
import React from "react"; import React from "react";
import { Database, MousePointerClick, Table2, Rows3, Columns3 } from "lucide-react"; import { MousePointerClick, Columns3 } from "lucide-react";
import { CPCrumb, type CPCrumbType, type CPCrumbKind } from "./_shared/cp"; import { CPCrumb, type CPCrumbType, type CPCrumbKind } from "./_shared/cp";
import { V2ListConfigPanel } from "./V2ListConfigPanel";
import { V2TableListConfigPanel } from "./V2TableListConfigPanel";
import { InvRepeaterConfigPanel } from "./InvRepeaterConfigPanel"; import { InvRepeaterConfigPanel } from "./InvRepeaterConfigPanel";
type DataComponentType = "v2-list" | "v2-table-list" | "v2-repeater"; type DataComponentType = "v2-repeater";
type DataKind = "read" | "write"; type DataKind = "write";
interface InvDataConfigPanelProps { interface InvDataConfigPanelProps {
config: any; config: any;
onChange: (config: any) => void; onChange: (config: any) => void;
/** 컴포넌트 타입 — runtime id (registerV2Components 에서 전달) */ /** 컴포넌트 타입 — runtime id (registerV2Components 에서 전달) */
componentType: DataComponentType; componentType: DataComponentType | string;
/** 화면 메인 테이블명 */ /** 화면 메인 테이블명 */
screenTableName?: string; screenTableName?: string;
/** 현재 테이블명 */ /** 현재 테이블명 */
@@ -43,11 +39,6 @@ interface InvDataConfigPanelProps {
} }
const KINDS: CPCrumbKind[] = [ const KINDS: CPCrumbKind[] = [
{
id: "read",
name: "조회",
icon: <Database size={14} />,
},
{ {
id: "write", id: "write",
name: "입력", name: "입력",
@@ -56,22 +47,6 @@ const KINDS: CPCrumbKind[] = [
]; ];
const TYPES_BY_KIND: Record<DataKind, CPCrumbType[]> = { const TYPES_BY_KIND: Record<DataKind, CPCrumbType[]> = {
read: [
{
id: "v2-list",
name: "통합 목록",
desc: "테이블/카드/칸반 등 다양한 표시",
icon: <Rows3 size={14} />,
col: "LIST",
},
{
id: "v2-table-list",
name: "테이블",
desc: "행/열 그리드 (legacy)",
icon: <Table2 size={14} />,
col: "TABLE",
},
],
write: [ write: [
{ {
id: "v2-repeater", id: "v2-repeater",
@@ -84,11 +59,16 @@ const TYPES_BY_KIND: Record<DataKind, CPCrumbType[]> = {
}; };
const KIND_OF_TYPE: Record<DataComponentType, DataKind> = { const KIND_OF_TYPE: Record<DataComponentType, DataKind> = {
"v2-list": "read",
"v2-table-list": "read",
"v2-repeater": "write", "v2-repeater": "write",
}; };
// Phase F.8 — 옛 read 카테고리는 canonical table 로 흡수됨. 옛 저장 데이터가 본 패널에
// 들어오면 무조건 write/v2-repeater 로 정규화한다.
function _normalizeComponentType(t: string): DataComponentType {
if (t === "v2-repeater") return t;
return "v2-repeater";
}
export const InvDataConfigPanel: React.FC<InvDataConfigPanelProps> = ({ export const InvDataConfigPanel: React.FC<InvDataConfigPanelProps> = ({
config, config,
onChange, onChange,
@@ -99,10 +79,11 @@ export const InvDataConfigPanel: React.FC<InvDataConfigPanelProps> = ({
menuObjid, menuObjid,
onComponentTypeChange, onComponentTypeChange,
}) => { }) => {
const currentKind: DataKind = KIND_OF_TYPE[componentType] || "read"; const normalizedType = _normalizeComponentType(componentType as string);
const currentKind: DataKind = KIND_OF_TYPE[normalizedType] || "write";
const handleKindChange = (nextKind: string) => { const handleKindChange = (nextKind: string) => {
// kind 가 바뀌면 해당 kind 의 첫 type 으로 전환 // kind 가 바뀌면 해당 kind 의 첫 type 으로 전환 (현재 write 만 존재)
const firstType = TYPES_BY_KIND[nextKind as DataKind]?.[0]?.id as DataComponentType | undefined; const firstType = TYPES_BY_KIND[nextKind as DataKind]?.[0]?.id as DataComponentType | undefined;
if (!firstType || firstType === componentType) return; if (!firstType || firstType === componentType) return;
if (onComponentTypeChange) { if (onComponentTypeChange) {
@@ -116,13 +97,14 @@ export const InvDataConfigPanel: React.FC<InvDataConfigPanelProps> = ({
}; };
const handleTypeChange = (nextType: string) => { const handleTypeChange = (nextType: string) => {
if (nextType === componentType) return; const normalizedNextType = _normalizeComponentType(nextType);
if (normalizedNextType === componentType) return;
if (onComponentTypeChange) { if (onComponentTypeChange) {
onComponentTypeChange(nextType as DataComponentType); onComponentTypeChange(normalizedNextType);
} else { } else {
console.warn( console.warn(
"[InvDataConfigPanel] onComponentTypeChange 미구현 — type 변경 적용 안 됨:", "[InvDataConfigPanel] onComponentTypeChange 미구현 — type 변경 적용 안 됨:",
nextType, normalizedNextType,
); );
} }
}; };
@@ -134,28 +116,11 @@ export const InvDataConfigPanel: React.FC<InvDataConfigPanelProps> = ({
currentKind={currentKind} currentKind={currentKind}
onChangeKind={handleKindChange} onChangeKind={handleKindChange}
types={TYPES_BY_KIND[currentKind] || []} types={TYPES_BY_KIND[currentKind] || []}
value={componentType} value={normalizedType}
onChange={handleTypeChange} onChange={handleTypeChange}
/> />
{/* 본체 — 현재는 옛 패널 위임. 다음 단계에서 cp 이주 */} {normalizedType === "v2-repeater" && (
{componentType === "v2-list" && (
<V2ListConfigPanel
config={config}
onChange={onChange}
currentTableName={currentTableName}
/>
)}
{componentType === "v2-table-list" && (
<V2TableListConfigPanel
config={config}
onChange={onChange}
screenTableName={screenTableName}
tableColumns={tableColumns}
menuObjid={menuObjid as number | undefined}
/>
)}
{componentType === "v2-repeater" && (
<InvRepeaterConfigPanel <InvRepeaterConfigPanel
config={config} config={config}
onChange={onChange} onChange={onChange}
@@ -113,7 +113,7 @@ const MODAL_SIZE_OPTIONS = [
{ value: "full", label: "전체" }, { value: "full", label: "전체" },
] as const; ] as const;
// canonical table / legacy table-list / hidden v2-table-list / data-table / datatable // canonical table / data-table / datatable 등 table-like 컴포넌트
// 은 table-like helper 로 통일. 추가로 repeater-field-group / form-group 도 데이터 전송 // 은 table-like helper 로 통일. 추가로 repeater-field-group / form-group 도 데이터 전송
// 호환 대상으로 함께 인식. // 호환 대상으로 함께 인식.
const DATA_TRANSFER_EXTRA_PATTERNS = ["repeater-field-group", "form-group"] as const; const DATA_TRANSFER_EXTRA_PATTERNS = ["repeater-field-group", "form-group"] as const;
@@ -1,333 +0,0 @@
"use client";
/**
* V2List
* UX: 테이블 -> (Switch) -> (Collapsible)
* / TableListConfigPanel에
*/
import React, { useState, useMemo } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Table2, Settings, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { TableListConfigPanel } from "@/lib/registry/components/table/_shared/TableListConfigPanel";
import type { TableListConfig } from "@/lib/registry/components/table/_shared/tableListConfigTypes";
interface V2ListConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
currentTableName?: string;
}
export const V2ListConfigPanel: React.FC<V2ListConfigPanelProps> = ({
config,
onChange,
currentTableName,
}) => {
const [detailOpen, setDetailOpen] = useState(false);
const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value });
};
const tableName = config.tableName || config.dataSource?.table || currentTableName || "";
const columnCount = (config.columns || []).length;
// ─── V2List config → TableListConfig 변환 (기존 로직 100% 유지) ───
const tableListConfig: TableListConfig = useMemo(() => {
const columns = (config.columns || []).map((col: any, index: number) => ({
columnName: col.key || col.columnName || col.field || "",
displayName: col.title || col.header || col.displayName || col.key || col.columnName || col.field || "",
width: col.width ? parseInt(col.width, 10) : undefined,
visible: col.visible !== false,
sortable: col.sortable !== false,
searchable: col.searchable !== false,
align: col.align || "left",
order: index,
isEntityJoin: col.isJoinColumn || col.isEntityJoin || false,
thousandSeparator: col.thousandSeparator,
editable: col.editable,
entityDisplayConfig: col.entityDisplayConfig,
}));
return {
selectedTable: config.tableName || config.dataSource?.table || currentTableName,
tableName: config.tableName || config.dataSource?.table || currentTableName,
columns,
useCustomTable: config.useCustomTable,
customTableName: config.customTableName,
isReadOnly: config.isReadOnly !== false,
displayMode: "table",
showHeader: true,
showFooter: false,
pagination: config.pagination !== false ? {
enabled: true,
pageSize: config.pageSize || 10,
showSizeSelector: true,
showPageInfo: true,
pageSizeOptions: [5, 10, 20, 50, 100],
} : {
enabled: false,
pageSize: 10,
showSizeSelector: false,
showPageInfo: false,
pageSizeOptions: [10],
},
filter: config.filter || { enabled: false, filters: [] },
dataFilter: config.dataFilter,
actions: config.actions || {
showActions: false,
actions: [],
bulkActions: false,
bulkActionList: [],
},
tableStyle: config.tableStyle || {
theme: "default",
headerStyle: "default",
rowHeight: "normal",
alternateRows: false,
hoverEffect: true,
borderStyle: "light",
},
checkbox: {
enabled: true,
multiple: true,
position: "left",
selectAll: true,
},
height: "auto",
autoWidth: true,
stickyHeader: true,
autoLoad: true,
horizontalScroll: {
enabled: true,
minColumnWidth: 100,
maxColumnWidth: 300,
},
toolbar: config.toolbar,
linkedFilters: config.linkedFilters,
excludeFilter: config.excludeFilter,
defaultSort: config.defaultSort,
};
}, [config, currentTableName]);
// ─── TableListConfig 변경 → V2List config 변환 (기존 로직 100% 유지) ───
const handleConfigChange = (partialConfig: Partial<TableListConfig>) => {
const newConfig: Record<string, any> = { ...config };
if (partialConfig.selectedTable !== undefined) {
newConfig.tableName = partialConfig.selectedTable;
if (!newConfig.dataSource) newConfig.dataSource = {};
newConfig.dataSource.table = partialConfig.selectedTable;
}
if (partialConfig.tableName !== undefined) {
newConfig.tableName = partialConfig.tableName;
if (!newConfig.dataSource) newConfig.dataSource = {};
newConfig.dataSource.table = partialConfig.tableName;
}
if (partialConfig.useCustomTable !== undefined) {
newConfig.useCustomTable = partialConfig.useCustomTable;
}
if (partialConfig.customTableName !== undefined) {
newConfig.customTableName = partialConfig.customTableName;
}
if (partialConfig.isReadOnly !== undefined) {
newConfig.isReadOnly = partialConfig.isReadOnly;
}
if (partialConfig.columns !== undefined) {
newConfig.columns = partialConfig.columns.map((col: any) => ({
key: col.columnName,
field: col.columnName,
title: col.displayName,
header: col.displayName,
width: col.width ? String(col.width) : undefined,
visible: col.visible,
sortable: col.sortable,
searchable: col.searchable,
align: col.align,
isJoinColumn: col.isEntityJoin,
isEntityJoin: col.isEntityJoin,
thousandSeparator: col.thousandSeparator,
editable: col.editable,
entityDisplayConfig: col.entityDisplayConfig,
}));
}
if (partialConfig.pagination !== undefined) {
newConfig.pagination = partialConfig.pagination?.enabled;
newConfig.pageSize = partialConfig.pagination?.pageSize || 10;
}
if (partialConfig.filter !== undefined) {
newConfig.filter = partialConfig.filter;
}
if (partialConfig.dataFilter !== undefined) {
newConfig.dataFilter = partialConfig.dataFilter;
}
if (partialConfig.actions !== undefined) {
newConfig.actions = partialConfig.actions;
}
if (partialConfig.tableStyle !== undefined) {
newConfig.tableStyle = partialConfig.tableStyle;
}
if (partialConfig.toolbar !== undefined) {
newConfig.toolbar = partialConfig.toolbar;
}
if (partialConfig.linkedFilters !== undefined) {
newConfig.linkedFilters = partialConfig.linkedFilters;
}
if (partialConfig.excludeFilter !== undefined) {
newConfig.excludeFilter = partialConfig.excludeFilter;
}
if (partialConfig.defaultSort !== undefined) {
newConfig.defaultSort = partialConfig.defaultSort;
}
onChange(newConfig);
};
return (
<div className="space-y-4">
{/* ─── 1단계: 테이블 정보 ─── */}
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<Table2 className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"> </span>
</div>
{tableName ? (
<div className="rounded-md border bg-background p-3">
<p className="text-xs text-muted-foreground"> </p>
<p className="mt-0.5 text-sm font-medium">{tableName}</p>
{columnCount > 0 && (
<p className="mt-1 text-[11px] text-muted-foreground">
{columnCount}
</p>
)}
</div>
) : (
<div className="rounded-md border-2 border-dashed p-4 text-center">
<Table2 className="mx-auto mb-2 h-8 w-8 opacity-30 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
</p>
<p className="mt-1 text-xs text-muted-foreground">
</p>
</div>
)}
</div>
{/* ─── 2단계: 기본 옵션 (Switch + 설명) ─── */}
<div className="space-y-2">
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.isReadOnly !== false}
onCheckedChange={(checked) => updateConfig("isReadOnly", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"></p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.pagination !== false}
onCheckedChange={(checked) => {
updateConfig("pagination", checked);
}}
/>
</div>
{config.pagination !== false && (
<div className="ml-4 border-l-2 border-primary/20 pl-3">
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Select
value={String(config.pageSize || 10)}
onValueChange={(v) => updateConfig("pageSize", Number(v))}
>
<SelectTrigger className="h-8 w-[180px] text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
{/* ─── 3단계: 상세 설정 (컬럼, 필터, 테이블 선택 등) ─── */}
<Collapsible open={detailOpen} onOpenChange={setDetailOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
detailOpen && "rotate-180",
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-2">
<p className="text-xs text-muted-foreground px-2 pb-2">
, ,
</p>
<TableListConfigPanel
config={tableListConfig}
onChange={handleConfigChange}
screenTableName={currentTableName}
/>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
V2ListConfigPanel.displayName = "V2ListConfigPanel";
export default V2ListConfigPanel;
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,7 @@
*/ */
// 옛 입력/선택 ConfigPanel 은 Phase D.3 에서 폐기됨 — InvFieldConfigPanel 사용. // 옛 입력/선택 ConfigPanel 은 Phase D.3 에서 폐기됨 — InvFieldConfigPanel 사용.
export { V2ListConfigPanel } from "./V2ListConfigPanel"; // V2List ConfigPanel 은 Phase F.8 에서 폐기됨 — canonical InvTableConfigPanel 사용.
export { V2LayoutConfigPanel } from "./V2LayoutConfigPanel"; export { V2LayoutConfigPanel } from "./V2LayoutConfigPanel";
export { V2GroupConfigPanel } from "./V2GroupConfigPanel"; export { V2GroupConfigPanel } from "./V2GroupConfigPanel";
// V2MediaConfigPanel — Phase D.5 폐기. canonical InvFieldConfigPanel 의 attach/file 분기로 흡수. // V2MediaConfigPanel — Phase D.5 폐기. canonical InvFieldConfigPanel 의 attach/file 분기로 흡수.
+2 -7
View File
@@ -5,9 +5,9 @@
*/ */
// 옛 입력/선택 컴포넌트는 Phase D.3 (2026-05-12) 에서 폐기됨 — canonical `input` 으로 흡수. // 옛 입력/선택 컴포넌트는 Phase D.3 (2026-05-12) 에서 폐기됨 — canonical `input` 으로 흡수.
// V2List 는 Phase F.8 (2026-05-21) 에서 폐기됨 — canonical table 로 흡수.
// Phase 2 컴포넌트 // Phase 2 컴포넌트
export { V2List } from "./V2List";
export { V2Layout } from "./V2Layout"; export { V2Layout } from "./V2Layout";
export { V2Group } from "./V2Group"; export { V2Group } from "./V2Group";
@@ -64,12 +64,7 @@ export type {
MutualExclusionConfig, MutualExclusionConfig,
// (옛 입력/선택 타입은 Phase D.3 에서 제거됨 — canonical InputConfig 와 OptionFilter 로 이전) // (옛 입력/선택 타입은 Phase D.3 에서 제거됨 — canonical InputConfig 와 OptionFilter 로 이전)
// (V2List 타입은 Phase F.8 에서 제거됨 — canonical TableComponent + TableConfig 로 이전)
// V2List 타입
V2ListViewMode,
ListColumn,
V2ListConfig,
V2ListProps,
// V2Layout 타입 // V2Layout 타입
V2LayoutType, V2LayoutType,
+2 -21
View File
@@ -11,7 +11,6 @@ import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
import { ComponentDefinition, ComponentCategory } from "@/types/component"; import { ComponentDefinition, ComponentCategory } from "@/types/component";
import { WebType } from "@/types/screen"; import { WebType } from "@/types/screen";
import { V2List } from "./V2List";
import { V2Layout } from "./V2Layout"; import { V2Layout } from "./V2Layout";
import { V2Group } from "./V2Group"; import { V2Group } from "./V2Group";
// V2Media — Phase D.5 폐기. canonical input (FilePicker) 으로 흡수. // V2Media — Phase D.5 폐기. canonical input (FilePicker) 으로 흡수.
@@ -19,35 +18,17 @@ import { V2Biz } from "./V2Biz";
import { V2Hierarchy } from "./V2Hierarchy"; import { V2Hierarchy } from "./V2Hierarchy";
import { V2Repeater } from "./V2Repeater"; import { V2Repeater } from "./V2Repeater";
import { V2ListConfigPanel } from "./config-panels/V2ListConfigPanel"; // V2List — Phase F.8 폐기. canonical TableComponent 직접 사용 (Phase E.1/E.2).
import { V2LayoutConfigPanel } from "./config-panels/V2LayoutConfigPanel"; import { V2LayoutConfigPanel } from "./config-panels/V2LayoutConfigPanel";
import { V2GroupConfigPanel } from "./config-panels/V2GroupConfigPanel"; import { V2GroupConfigPanel } from "./config-panels/V2GroupConfigPanel";
// V2MediaConfigPanel — Phase D.5 폐기. // V2MediaConfigPanel — Phase D.5 폐기.
import { V2BizConfigPanel } from "./config-panels/V2BizConfigPanel"; import { V2BizConfigPanel } from "./config-panels/V2BizConfigPanel";
import { V2HierarchyConfigPanel } from "./config-panels/V2HierarchyConfigPanel"; import { V2HierarchyConfigPanel } from "./config-panels/V2HierarchyConfigPanel";
import { InvRepeaterConfigPanel } from "./config-panels/InvRepeaterConfigPanel";
import { InvDataConfigPanel } from "./config-panels/InvDataConfigPanel"; import { InvDataConfigPanel } from "./config-panels/InvDataConfigPanel";
// V2 컴포넌트 정의 // V2 컴포넌트 정의
const v2ComponentDefinitions: ComponentDefinition[] = [ const v2ComponentDefinitions: ComponentDefinition[] = [
{ // v2-list — Phase F.8 폐기. canonical TableComponent 로 흡수됨 (Phase E.1/E.2).
id: "v2-list",
name: "통합 목록",
description: "테이블, 카드, 칸반, 리스트 등 다양한 데이터 표시 방식을 지원하는 통합 컴포넌트",
category: ComponentCategory.V2,
web_type: "list" as WebType,
component: V2List as any,
tags: ["list", "table", "card", "kanban", "data", "v2"],
default_size: { width: 600, height: 400 },
config_panel: InvDataConfigPanel as any,
default_config: {
viewMode: "table",
source: "static",
columns: [],
pagination: true,
sortable: true,
},
},
{ {
id: "v2-layout", id: "v2-layout",
name: "통합 레이아웃", name: "통합 레이아웃",
+24 -3
View File
@@ -2,6 +2,14 @@ import React, { createContext, useContext, useState, useCallback, useMemo, React
import { TableRegistration, TableOptionsContextValue } from "@/types/table-options"; import { TableRegistration, TableOptionsContextValue } from "@/types/table-options";
import { useActiveTab } from "./ActiveTabContext"; import { useActiveTab } from "./ActiveTabContext";
/**
* TableOptionsContext TableSettingsModal / TableOptionsToolbar / ColumnVisibilityPanel /
* GroupingPanel / FilterPanel UI .
*
* contract type . `table_id` unique key
* canonical `lib/registry/components/table/TableComponent` (`table-${id}` prefix)
* ID Provider .
*/
const TableOptionsContext = createContext<TableOptionsContextValue | undefined>(undefined); const TableOptionsContext = createContext<TableOptionsContextValue | undefined>(undefined);
export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({ children }) => { export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
@@ -64,8 +72,8 @@ export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({ childr
setRegisteredTables((prev) => { setRegisteredTables((prev) => {
const table = prev.get(tableId); const table = prev.get(tableId);
if (table) { if (table) {
// 기존 테이블 정보에 dataCount만 업데이트 // 기존 테이블 정보에 data_count만 업데이트 (TableRegistration contract)
const updatedTable = { ...table, dataCount: count }; const updatedTable = { ...table, data_count: count };
const newMap = new Map(prev); const newMap = new Map(prev);
newMap.set(tableId, updatedTable); newMap.set(tableId, updatedTable);
return newMap; return newMap;
@@ -124,7 +132,8 @@ export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({ childr
}; };
/** /**
* Context Hook * Context Hook Provider throw. canonical TableComponent Provider
* wrap .
*/ */
export const useTableOptions = () => { export const useTableOptions = () => {
const context = useContext(TableOptionsContext); const context = useContext(TableOptionsContext);
@@ -133,3 +142,15 @@ export const useTableOptions = () => {
} }
return context; return context;
}; };
/**
* Optional variant Provider ( , , embed iframe )
* mount canonical TableComponent . Provider context ,
* `undefined` . `if (!ctx) return;` .
*
* Phase B.1 (2026-05-20) canonical `table` contract
* register , dashboard Provider mount .
*/
export const useTableOptionsOptional = () => {
return useContext(TableOptionsContext);
};
+1 -1
View File
@@ -366,7 +366,7 @@ export interface LayoutItem {
y: number; y: number;
width: number; width: number;
height: number; height: number;
componentKind: string; // 정확한 컴포넌트 종류 (table-list, button-primary) componentKind: string; // 정확한 컴포넌트 종류 (canonical 예: table / button — legacy 예: button-primary)
widgetType: string; // 일반적인 위젯 타입 (button, text 등) widgetType: string; // 일반적인 위젯 타입 (button, text 등)
label?: string; label?: string;
bindField?: string; // 바인딩된 필드명 (컬럼명) bindField?: string; // 바인딩된 필드명 (컬럼명)
+84 -18
View File
@@ -16,11 +16,19 @@
import type { FieldConfig } from "@/types/invyone-component"; import type { FieldConfig } from "@/types/invyone-component";
/** /**
* FieldConfig[] v2-table-list ColumnConfig[] . * FieldConfig[] snake_case ColumnConfig[] .
* *
* v2-table-list . * (Phase F.2/F.8 table-list .
* (column_name / column_label / visible / display_order / width / align / sortable) * snake_case .)
* . *
* canonical `TableComponent` `fieldsToCanonicalColumns` (camelCase + )
* (Phase C.2 ).
*
* (column_name / column_label / visible / display_order / width / align /
* sortable / data_type / format / pk / editable) Phase C.2
* (`searchable` / `input_type` / `thousand_separator`) . FieldConfig
* legacy-only (`fixed` / `hidden` / entity )
* ConfigPanel set column .
*/ */
export function fieldsToColumns( export function fieldsToColumns(
fields: FieldConfig[], fields: FieldConfig[],
@@ -28,20 +36,78 @@ export function fieldsToColumns(
return [...fields] return [...fields]
.filter((f) => f.visible !== false && !f.system) .filter((f) => f.visible !== false && !f.system)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((f) => ({ .map((f) => {
column_name: f.column, const isNumberFormat =
column_label: f.label, f.type === "number" || f.format === "number" || f.format === "currency";
label: f.label, // Phase D.10 — FieldConfig 자체엔 autoGeneration 이 없지만 legacy layout 의
visible: f.visible !== false, // 원본 field 객체에 박혀들어올 수 있으므로 (any 캐스트로) 메타 보존.
display_order: f.order ?? 0, const autoGen =
width: f.width, (f as any).autoGeneration ?? (f as any).auto_generation ?? undefined;
align: f.align ?? (f.type === "number" ? "right" : "left"), return {
sortable: f.sortable ?? true, column_name: f.column,
data_type: f.type, column_label: f.label,
format: f.format, label: f.label,
pk: f.pk ?? false, visible: f.visible !== false,
editable: f.editable ?? true, display_order: f.order ?? 0,
})); width: f.width,
align: f.align ?? (f.type === "number" ? "right" : "left"),
sortable: f.sortable ?? true,
searchable: f.searchable === true,
data_type: f.type,
input_type: f.type,
format: f.format,
pk: f.pk ?? false,
editable: f.editable ?? true,
thousand_separator: isNumberFormat ? true : undefined,
...(autoGen ? { autoGeneration: autoGen, auto_generation: autoGen } : {}),
};
});
}
/**
* FieldConfig[] canonical `TableColumn[]` (camelCase, `frontend/lib/registry/components/table/types.ts`).
*
* (Phase C.2 2026-05-20). canonical `TableComponent` fields columns .
* FieldConfig (fixed / fixedOrder / hidden / entity )
* undefined ConfigPanel set .
*
* Phase D.1 canonical table source columns render columns
* `visible === false` source . / `TableComponent`
* renderColumns derive .
*/
export function fieldsToCanonicalColumns(
fields: FieldConfig[],
): Array<Record<string, any>> {
return [...fields]
.filter((f) => !f.system)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((f) => {
const isNumberFormat =
f.type === "number" || f.format === "number" || f.format === "currency";
// Phase D.10 — autoGeneration 메타 보존. FieldConfig 자체엔 없지만 legacy field 가
// 옵션 필드로 들고 들어올 수 있어 any 캐스트로 흡수. runtime 적용은 별도 phase.
const autoGen =
(f as any).autoGeneration ?? (f as any).auto_generation ?? undefined;
return {
key: f.column,
label: f.label,
visible: f.visible !== false,
order: f.order ?? 0,
width: f.width,
align: f.align ?? (f.type === "number" ? "right" : "left"),
sortable: f.sortable ?? true,
searchable: f.searchable === true,
editable: f.editable ?? true,
format: f.format,
// FieldConfig.type 을 inputType / dataType 양쪽으로 미러링 (D.3/D.5 wiring).
// 사용자가 ConfigPanel 에서 명시 inputType 을 set 했으면 그쪽이 우선 (ConfigPanel
// 출력 columns 가 fields 머지보다 후위로 들어가므로).
inputType: f.type,
dataType: f.type,
thousandSeparator: isNumberFormat ? true : undefined,
...(autoGen ? { autoGeneration: autoGen } : {}),
};
});
} }
/** /**
@@ -11,6 +11,7 @@ import { useV2FormOptional } from "@/components/v2/V2FormContext";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor"; import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
// 컬럼 메타데이터 캐시 (테이블명 → 컬럼 설정 맵) // 컬럼 메타데이터 캐시 (테이블명 → 컬럼 설정 맵)
export const columnMetaCache: Record<string, Record<string, any>> = {}; export const columnMetaCache: Record<string, Record<string, any>> = {};
@@ -383,8 +384,6 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// stats // stats
"v2-aggregation-widget": "stats", "aggregation-widget": "stats", "v2-aggregation-widget": "stats", "aggregation-widget": "stats",
"v2-status-count": "stats", "v2-status-count": "stats",
// table
"v2-table-list": "table", "table-list": "table",
// container // container
"v2-tabs-widget": "container", "tabs-widget": "container", "tabs": "container", "v2-tabs": "container", "v2-tabs-widget": "container", "tabs-widget": "container", "tabs": "container", "v2-tabs": "container",
"v2-section-card": "container", "v2-section-card": "container",
@@ -932,10 +931,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
const rendererInstance = new RendererClass(rendererProps); const rendererInstance = new RendererClass(rendererProps);
renderedElement = rendererInstance.render(); renderedElement = rendererInstance.render();
} else { } else {
const needsKeyRefresh = // canonical 'table' / v2-repeater
componentType === "v2-table-list" || // 는 refreshKey 변동 시 강제 remount 가 필요 (data refetch 트리거).
componentType === "table-list" || const needsKeyRefresh = isTableLikeComponentType(componentType) || componentType === "v2-repeater";
componentType === "v2-repeater";
renderedElement = <NewComponentRenderer key={needsKeyRefresh ? refreshKey : component.id} {...rendererProps} />; renderedElement = <NewComponentRenderer key={needsKeyRefresh ? refreshKey : component.id} {...rendererProps} />;
} }
@@ -720,7 +720,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
const allProviders = screenContext.getAllDataProviders(); const allProviders = screenContext.getAllDataProviders();
// table-like (canonical table / legacy table-list / hidden v2-table-list 등) 우선 탐색 // table-like (canonical table 등) 우선 탐색
for (const [id, provider] of allProviders) { for (const [id, provider] of allProviders) {
if (isTableLikeComponentType(provider.component_type)) { if (isTableLikeComponentType(provider.component_type)) {
sourceProvider = provider; sourceProvider = provider;
@@ -5,19 +5,25 @@ import { ComponentConfig } from "@/types/component";
/** /**
* Container * Container
* *
* 11 / . * canonical . containerType tabs / section / accordion / repeater /
* containerType //// . * conditional . SectionVariant card / paper / plain .
* *
* (11): * (alias DynamicComponentRenderer.LEGACY_TO_UNIFIED + getComponentConfigPanel.CONFIG_PANEL_ALIAS):
* - v2-tabs-widget () * - tabs-widget / v2-tabs-widget / tabs / v2-tabs container(tabs)
* - v2-section-card / v2-section-paper () * - section-card / v2-section-card container(section, sectionVariant=card)
* - v2-repeat-container / v2-repeater () * - section-paper / v2-section-paper container(section, sectionVariant=paper)
* - accordion-basic () *
* - section-card / section-paper (legacy) * (canonical skeleton ) concrete blocker, cleanup :
* - tabs (legacy) * - accordion-basic canonical container.containerType=accordion skeleton
* - conditional-container () * - conditional-container canonical container.containerType=conditional skeleton
* - repeat-container / repeat-screen-modal / repeater-field-group (legacy) * - repeat-container canonical container.containerType=repeater skeleton
* - screen-split-panel (legacy) * - v2-repeat-container . basicV2Components palette item
* - v2-repeater /
* - repeat-screen-modal / repeater-field-group
* - screen-split-panel + ( )
*
* split-panel-layout / v2-split-panel-layout / split-panel-layout2 table split
* displayMode SplitPanelContext provider .
*/ */
export type ContainerType = export type ContainerType =
+9 -3
View File
@@ -59,7 +59,7 @@ import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데
// ============================================================ // ============================================================
// ★ 2026-05-19 canonical 정리: alias 라우팅으로 충분한 옛 Renderer 자동등록 제거. // ★ 2026-05-19 canonical 정리: alias 라우팅으로 충분한 옛 Renderer 자동등록 제거.
// - aggregation-widget / v2-aggregation-widget / v2-status-count → canonical stats alias // - aggregation-widget / v2-aggregation-widget / v2-status-count → canonical stats alias
// - table-list / v2-table-list → canonical table alias // - table 계열 → canonical table alias (Phase F.6 에서 alias 도 제거 완료)
// - tabs / v2-tabs-widget → canonical container alias (containerType=tabs) // - tabs / v2-tabs-widget → canonical container alias (containerType=tabs)
// - section-card / v2-section-card / section-paper / v2-section-paper → canonical container alias (containerType=section + sectionVariant) // - section-card / v2-section-card / section-paper / v2-section-paper → canonical container alias (containerType=section + sectionVariant)
import "./button-primary/ButtonPrimaryRenderer"; import "./button-primary/ButtonPrimaryRenderer";
@@ -103,8 +103,14 @@ import "./grouped-table/GroupedTableRenderer"; // Phase G.3.1 — canonical 그
// form 컴포넌트는 롤백됨 (2026-04-11): "폼" 은 별도 컴포넌트가 아닌 // form 컴포넌트는 롤백됨 (2026-04-11): "폼" 은 별도 컴포넌트가 아닌
// 화면 디자이너의 3뷰 탭(목록/등록 팝업/수정 팝업) 구조로 처리할 예정. // 화면 디자이너의 3뷰 탭(목록/등록 팝업/수정 팝업) 구조로 처리할 예정.
// 관련: notes/gbpark/2026-04-11-component-unification-plan.md §3.2 // 관련: notes/gbpark/2026-04-11-component-unification-plan.md §3.2
import "./table/TableRenderer"; // v2-table-list + v2-table-grouped + v2-pivot-grid + v2-split-panel-layout + legacy 9종 흡수 // canonical 'table' — 새 생성/유일한 런타임 경로. 옛 table 계열 ID 와 본체는 Phase F
import "./container/ContainerRenderer"; // v2-tabs-widget + v2-section-card/paper + v2-repeat-container + accordion + conditional + legacy 11종 흡수 // (F.1~F.8) 에서 모두 흡수/삭제됐다.
import "./table/TableRenderer";
// canonical 'container' — 새 생성 경로. alias 흡수: tabs-widget / v2-tabs-widget /
// section-card / v2-section-card / section-paper / v2-section-paper / tabs / v2-tabs.
// 보존 (skeleton 부족 또는 도메인 특화): accordion-basic / conditional-container /
// repeat-container / v2-repeat-container / screen-split-panel / split-panel-layout 계열.
import "./container/ContainerRenderer";
import "./v2-repeat-container/RepeatContainerRenderer"; // canonical container.containerType=repeater skeleton 부족 → 보존 import "./v2-repeat-container/RepeatContainerRenderer"; // canonical container.containerType=repeater skeleton 부족 → 보존
// v2-section-card / v2-section-paper → canonical container alias (containerType=section + sectionVariant) 로 라우팅 (auto-register 제거) // v2-section-card / v2-section-paper → canonical container alias (containerType=section + sectionVariant) 로 라우팅 (auto-register 제거)
import "./domain/v2-rack-structure/RackStructureRenderer"; import "./domain/v2-rack-structure/RackStructureRenderer";
@@ -134,7 +134,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
// 좌측 패널 대상: card-display만 // 좌측 패널 대상: card-display만
return tableId.includes("card-display") || tableId.includes("card"); return tableId.includes("card-display") || tableId.includes("card");
} else if (targetPanelPosition === "right") { } else if (targetPanelPosition === "right") {
// 우측 패널 대상: datatable, table-list 등 (card-display 제외) // 우측 패널 대상: datatable, canonical table 등 (card-display 제외)
const isCardDisplay = tableId.includes("card-display") || tableId.includes("card"); const isCardDisplay = tableId.includes("card-display") || tableId.includes("card");
return !isCardDisplay; return !isCardDisplay;
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,259 +0,0 @@
"use client";
/**
* CardModeRenderer shared card grid renderer
*
* 2026-05-20 table-list / v2-table-list CardModeRenderer .
* `variant="v2"` prop v2 URL (objid getFilePreviewUrl,
* path getFullImageUrl) fallback DOM .
*
* legacy table-list / v2-table-list local type files import .
* shared `./tableListConfigTypes` .
*/
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Eye, Edit, Trash2, MoreHorizontal } from "lucide-react";
import type { CardDisplayConfig, ColumnConfig } from "./tableListConfigTypes";
import { getFullImageUrl } from "@/lib/api/client";
import { getFilePreviewUrl } from "@/lib/api/file";
export type CardModeVariant = "default" | "v2";
interface CardModeRendererProps {
/** 시각/동작 분기 — 기본은 table-list 기존 동작, "v2" 는 v2-table-list 흡수 분기 */
variant?: CardModeVariant;
data: Record<string, any>[];
cardConfig: CardDisplayConfig;
visibleColumns: ColumnConfig[];
onRowClick?: (row: Record<string, any>, index: number, e: React.MouseEvent) => void;
onRowSelect?: (row: Record<string, any>, selected: boolean) => void;
selectedRows?: string[];
}
/**
*
*
*/
export const CardModeRenderer: React.FC<CardModeRendererProps> = ({
variant = "default",
data,
cardConfig,
visibleColumns,
onRowClick,
selectedRows = [],
}) => {
const isV2 = variant === "v2";
// 기본값과 병합
const config = {
idColumn: cardConfig?.idColumn || "",
titleColumn: cardConfig?.titleColumn || "",
subtitleColumn: cardConfig?.subtitleColumn,
descriptionColumn: cardConfig?.descriptionColumn,
imageColumn: cardConfig?.imageColumn,
cardsPerRow: cardConfig?.cardsPerRow ?? 3,
cardSpacing: cardConfig?.cardSpacing ?? 16,
showActions: cardConfig?.showActions ?? true,
cardHeight: cardConfig?.cardHeight as number | "auto" | undefined,
};
// 디버깅: cardConfig 확인
console.log("🃏 CardModeRenderer config:", { cardConfig, mergedConfig: config });
// 카드 그리드 스타일 계산
const gridStyle: React.CSSProperties = {
display: "grid",
gridTemplateColumns: `repeat(${config.cardsPerRow}, 1fr)`,
gap: `${config.cardSpacing}px`,
padding: `${config.cardSpacing}px`,
overflow: "auto",
};
// 카드 높이 스타일
const cardStyle: React.CSSProperties = {
height: config.cardHeight === "auto" ? "auto" : `${config.cardHeight}px`,
cursor: onRowClick ? "pointer" : "default",
};
// 컬럼 값 가져오기 함수
const getColumnValue = (row: Record<string, any>, columnName?: string): string => {
if (!columnName || !row) return "";
return String(row[columnName] || "");
};
// 액션 버튼 렌더링
const renderActions = (_row: Record<string, any>) => {
if (!config.showActions) return null;
return (
<div className="mt-3 flex items-center justify-end space-x-1 border-t border-border pt-3">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
// 상세보기 액션
}}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
// 편집 액션
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
// 삭제 액션
}}
>
<Trash2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
// 더보기 액션
}}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</div>
);
};
// 데이터가 없는 경우
if (!data || data.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="bg-muted mb-4 flex h-16 w-16 items-center justify-center rounded-2xl">
<div className="bg-muted-foreground/20 h-8 w-8 rounded-lg"></div>
</div>
<div className="text-muted-foreground mb-1 text-sm font-medium"> </div>
<div className="text-muted-foreground/60 text-xs"> </div>
</div>
);
}
return (
<div style={gridStyle} className="w-full">
{data.map((row, index) => {
const idValue = getColumnValue(row, config.idColumn);
const titleValue = getColumnValue(row, config.titleColumn);
const subtitleValue = getColumnValue(row, config.subtitleColumn);
const descriptionValue = getColumnValue(row, config.descriptionColumn);
const imageValue = getColumnValue(row, config.imageColumn);
const isSelected = selectedRows.includes(idValue);
return (
<Card
key={`card-${index}-${idValue}`}
style={cardStyle}
className={`transition-all duration-200 hover:shadow-md ${
isSelected ? "bg-primary/10/30 ring-2 ring-ring" : ""
}`}
onClick={(e) => onRowClick?.(row, index, e)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<CardTitle className="truncate text-sm font-medium">{titleValue || "제목 없음"}</CardTitle>
{subtitleValue && <div className="mt-1 truncate text-xs text-muted-foreground">{subtitleValue}</div>}
</div>
{/* ID 뱃지 */}
{idValue && (
<Badge variant="secondary" className="ml-2 text-xs">
{idValue}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="pt-0">
{/* 이미지 표시 */}
{imageValue && (
<div className="mb-3">
<img
src={
isV2
? (() => {
// ★ v2 전용: 숫자 objid → getFilePreviewUrl, 그 외 path → getFullImageUrl 정규화
const strValue = String(imageValue);
const isObjid = /^\d+$/.test(strValue);
return isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue);
})()
: imageValue
}
alt={titleValue}
className="h-24 w-full rounded-md bg-muted object-cover"
onError={(e) => {
const target = e.target as HTMLImageElement;
// 이미지 로드 실패 시 폴백 표시
target.style.display = "none";
// ★ v2 전용: 폴백 DOM 삽입 (data-image-fallback). default 는 단순 hide 만.
if (!isV2) return;
const parent = target.parentElement;
if (parent && !parent.querySelector("[data-image-fallback]")) {
const fallback = document.createElement("div");
fallback.setAttribute("data-image-fallback", "true");
fallback.className = "flex items-center justify-center h-24 w-full rounded-md bg-muted text-muted-foreground";
fallback.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="2" x2="22" y2="22"/><path d="M10.41 10.41a2 2 0 1 1-2.83-2.83"/><line x1="13.5" y1="13.5" x2="6" y2="21"/><line x1="18" y1="12" x2="21" y2="15"/><path d="M3.59 3.59A1.99 1.99 0 0 0 3 5v14a2 2 0 0 0 2 2h14c.55 0 1.052-.22 1.41-.59"/><path d="M21 15V5a2 2 0 0 0-2-2H9"/></svg>`;
parent.appendChild(fallback);
}
}}
/>
</div>
)}
{/* 설명 표시 */}
{descriptionValue && <div className="mb-3 line-clamp-2 text-xs text-muted-foreground">{descriptionValue}</div>}
{/* 추가 필드들 표시 (선택적) */}
<div className="space-y-1">
{(visibleColumns || [])
.filter(
(col) =>
col.columnName !== config.idColumn &&
col.columnName !== config.titleColumn &&
col.columnName !== config.subtitleColumn &&
col.columnName !== config.descriptionColumn &&
col.columnName !== config.imageColumn &&
col.columnName !== "__checkbox__" &&
col.visible,
)
.slice(0, 3) // 최대 3개 추가 필드만 표시
.map((col) => {
const value = getColumnValue(row, col.columnName);
if (!value) return null;
return (
<div key={col.columnName} className="flex items-center justify-between text-xs">
<span className="truncate text-muted-foreground">{col.displayName}:</span>
<span className="ml-2 truncate font-medium">{value}</span>
</div>
);
})}
</div>
{/* 액션 버튼들 */}
{renderActions(row)}
</CardContent>
</Card>
);
})}
</div>
);
};
@@ -3,16 +3,11 @@
/** /**
* SingleTableWithSticky shared table renderer * SingleTableWithSticky shared table renderer
* *
* 2026-05-19 FlowWidget hard blocker legacy * 2026-05-19 FlowWidget sticky .
* `lib/registry/components/table-list/SingleTableWithSticky.tsx` . * `frontend/components/screen/widgets/FlowWidget.tsx` .
* *
* 2026-05-20 v2-table-list sticky . `variant="v2"` prop * minimal `ColumnConfig` export .
* v2 // , (category/code select, date picker fallback, * canonical table runtime `frontend/lib/registry/components/table/TableComponent.tsx` .
* number input), mobile scroll/minWidth, null/undefined/"" "-" (0 ) .
*
* legacy `table-list` / `v2-table-list` / FlowWidget .
* legacy table-list / v2-table-list local type files import . minimal
* `ColumnConfig` export .
*/ */
import React from "react"; import React from "react";
@@ -24,10 +19,6 @@ import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
/** /**
* SingleTableWithSticky minimal . * SingleTableWithSticky minimal .
*
* legacy table-list / v2-table-list `ColumnConfig`
* type superset . legacy
* .
*/ */
export interface ColumnConfig { export interface ColumnConfig {
columnName: string; columnName: string;
@@ -42,11 +33,7 @@ export interface ColumnConfig {
[key: string]: any; [key: string]: any;
} }
export type SingleTableVariant = "default" | "v2";
interface SingleTableWithStickyProps<TColumn extends ColumnConfig = ColumnConfig> { interface SingleTableWithStickyProps<TColumn extends ColumnConfig = ColumnConfig> {
/** 시각/동작 분기 — 기본은 table-list / FlowWidget 기존 스타일, "v2" 는 v2-table-list 흡수 분기 */
variant?: SingleTableVariant;
visibleColumns?: TColumn[]; visibleColumns?: TColumn[];
columns?: TColumn[]; columns?: TColumn[];
data: Record<string, any>[]; data: Record<string, any>[];
@@ -62,7 +49,6 @@ interface SingleTableWithStickyProps<TColumn extends ColumnConfig = ColumnConfig
handleRowClick?: (row: any, index: number, e: React.MouseEvent) => void; handleRowClick?: (row: any, index: number, e: React.MouseEvent) => void;
renderCheckboxCell?: (row: any, index: number) => React.ReactNode; renderCheckboxCell?: (row: any, index: number) => React.ReactNode;
renderCheckboxHeader?: () => React.ReactNode; renderCheckboxHeader?: () => React.ReactNode;
/** v2 에서는 ReactNode (이미지/JSX) 반환 가능. 기본 호출부는 string 반환해도 ReactNode subset 이라 호환 */
formatCellValue: ( formatCellValue: (
value: any, value: any,
format?: string, format?: string,
@@ -79,19 +65,12 @@ interface SingleTableWithStickyProps<TColumn extends ColumnConfig = ColumnConfig
onEditingValueChange?: (value: string) => void; onEditingValueChange?: (value: string) => void;
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void; onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
editInputRef?: React.RefObject<HTMLInputElement | null>; editInputRef?: React.RefObject<HTMLInputElement | null>;
/** v2 전용: Enter/blur 시 저장 콜백 (date picker fallback 포함 통합 저장 경로) */
onEditSave?: () => void;
/** v2 전용: 컬럼별 inputType (select/category/code, number, date, datetime) */
columnMeta?: Record<string, { inputType?: string }>;
/** v2 전용: category/code 컬럼의 옵션 매핑 */
categoryMappings?: Record<string, Record<string, { label: string }>>;
searchHighlights?: Set<string>; searchHighlights?: Set<string>;
currentSearchIndex?: number; currentSearchIndex?: number;
searchTerm?: string; searchTerm?: string;
} }
export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfig>({ export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfig>({
variant = "default",
visibleColumns, visibleColumns,
columns, columns,
data, data,
@@ -118,9 +97,6 @@ export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfi
onEditingValueChange, onEditingValueChange,
onEditKeyDown, onEditKeyDown,
editInputRef, editInputRef,
onEditSave,
columnMeta,
categoryMappings,
searchHighlights, searchHighlights,
currentSearchIndex = 0, currentSearchIndex = 0,
searchTerm = "", searchTerm = "",
@@ -131,22 +107,6 @@ export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfi
const sortHandler = onSort || handleSort || (() => {}); const sortHandler = onSort || handleSort || (() => {});
const actualData = data || []; const actualData = data || [];
const isV2 = variant === "v2";
// ── 컨테이너/스크롤 분기 (v2 만 mobile scroll + minWidth 적용) ──
const scrollContainerStyle: React.CSSProperties = isV2 ? { WebkitOverflowScrolling: "touch" } : {};
const tableStyle: React.CSSProperties = {
width: "100%",
tableLayout: "auto",
boxSizing: "border-box",
...(isV2 ? { minWidth: `${Math.max(actualColumns.length * 80, 400)}px` } : {}),
};
// ── 헤더 스타일 분기 ──
const headerBaseClass = isV2 ? "border-b border-border/60" : "bg-background border-b";
const headerStyle: React.CSSProperties = isV2 ? { backgroundColor: "hsl(var(--muted) / 0.4)" } : {};
const headerRowClass = isV2 ? "border-b border-border/60" : "border-b";
return ( return (
<div <div
className="bg-background relative flex flex-1 flex-col overflow-hidden shadow-sm" className="bg-background relative flex flex-1 flex-col overflow-hidden shadow-sm"
@@ -156,13 +116,19 @@ export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfi
boxSizing: "border-box", boxSizing: "border-box",
}} }}
> >
<div className="relative flex-1 overflow-auto" style={scrollContainerStyle}> <div className="relative flex-1 overflow-auto">
<Table noWrapper className="w-full" style={tableStyle}> <Table
noWrapper
className="w-full"
style={{ width: "100%", tableLayout: "auto", boxSizing: "border-box" }}
>
<TableHeader <TableHeader
className={cn(headerBaseClass, tableConfig?.stickyHeader && "sticky top-0 z-30 shadow-sm")} className={cn(
style={headerStyle} "bg-background border-b",
tableConfig?.stickyHeader && "sticky top-0 z-30 shadow-sm",
)}
> >
<TableRow className={headerRowClass}> <TableRow className="border-b">
{actualColumns.map((column, colIndex) => { {actualColumns.map((column, colIndex) => {
const leftFixedWidth = actualColumns const leftFixedWidth = actualColumns
.slice(0, colIndex) .slice(0, colIndex)
@@ -178,20 +144,10 @@ export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfi
const isCheckboxCol = column.columnName === "__checkbox__"; const isCheckboxCol = column.columnName === "__checkbox__";
const headCheckboxBaseClass = isV2 const headCheckboxBaseClass =
? "h-9 border-0 px-3 py-1.5 text-center align-middle sm:px-4 sm:py-2" "bg-background h-9 border-0 px-3 py-1.5 text-center align-middle sm:px-4 sm:py-2";
: "bg-background h-9 border-0 px-3 py-1.5 text-center align-middle sm:px-4 sm:py-2"; const headDataBaseClass =
const headDataBaseClass = isV2 "text-foreground hover:text-foreground bg-background h-9 cursor-pointer border-0 px-3 py-1.5 text-left align-middle text-xs font-semibold whitespace-nowrap transition-all duration-200 select-none sm:px-4 sm:py-2 sm:text-sm";
? "text-muted-foreground hover:text-foreground h-9 cursor-pointer border-0 px-3 py-1.5 text-left align-middle text-[10px] font-bold uppercase tracking-[0.04em] whitespace-nowrap transition-all duration-200 select-none sm:px-4 sm:py-2 sm:text-xs"
: "text-foreground hover:text-foreground bg-background h-9 cursor-pointer border-0 px-3 py-1.5 text-left align-middle text-xs font-semibold whitespace-nowrap transition-all duration-200 select-none sm:px-4 sm:py-2 sm:text-sm";
const sortableHoverClass = isV2 ? "hover:bg-muted/50" : "hover:bg-primary/10";
// ── 셀 너비 / 헤더 width 분기 (v2 의 checkbox 48px 강제) ──
const checkboxFixedWidth = 48;
const headWidth = isV2 && isCheckboxCol ? checkboxFixedWidth : getColumnWidth(column);
const headMinWidth = isV2 && isCheckboxCol ? "48px" : "100px";
const headMaxWidth = isV2 && isCheckboxCol ? "48px" : "300px";
const headBackground = isV2 ? "hsl(var(--muted) / 0.4)" : "hsl(var(--background))";
return ( return (
<TableHead <TableHead
@@ -199,45 +155,33 @@ export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfi
className={cn( className={cn(
isCheckboxCol ? headCheckboxBaseClass : headDataBaseClass, isCheckboxCol ? headCheckboxBaseClass : headDataBaseClass,
`text-${column.align}`, `text-${column.align}`,
column.sortable && sortableHoverClass, column.sortable && "hover:bg-primary/10",
column.fixed === "left" && "border-border bg-background sticky z-40 border-r shadow-sm", column.fixed === "left" && "border-border bg-background sticky z-40 border-r shadow-sm",
column.fixed === "right" && "border-border bg-background sticky z-40 border-l shadow-sm", column.fixed === "right" && "border-border bg-background sticky z-40 border-l shadow-sm",
isDesignMode && column.hidden && "bg-muted/50 opacity-40", isDesignMode && column.hidden && "bg-muted/50 opacity-40",
)} )}
style={{ style={{
width: headWidth, width: getColumnWidth(column),
minWidth: headMinWidth, minWidth: "100px",
maxWidth: headMaxWidth, maxWidth: "300px",
boxSizing: "border-box", boxSizing: "border-box",
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis", textOverflow: "ellipsis",
whiteSpace: "nowrap", whiteSpace: "nowrap",
backgroundColor: headBackground, backgroundColor: "hsl(var(--background))",
...(column.fixed === "left" && { left: leftFixedWidth }), ...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }), ...(column.fixed === "right" && { right: rightFixedWidth }),
}} }}
onClick={() => column.sortable && sortHandler(column.columnName)} onClick={() => column.sortable && sortHandler(column.columnName)}
> >
<div className={cn("flex items-center", isV2 && isCheckboxCol ? "justify-center" : "gap-2")}> <div className="flex items-center gap-2">
{isCheckboxCol ? ( {isCheckboxCol ? (
checkboxConfig.selectAll && ( checkboxConfig.selectAll && (
<Checkbox <Checkbox
checked={isAllSelected} checked={isAllSelected}
onCheckedChange={handleSelectAll} onCheckedChange={handleSelectAll}
aria-label="전체 선택" aria-label="전체 선택"
style={ style={{ zIndex: 1 }}
isV2
? {
width: 16,
height: 16,
borderWidth: 1.5,
borderColor: isAllSelected
? "hsl(var(--primary))"
: "hsl(var(--muted-foreground) / 0.5)",
zIndex: 1,
}
: { zIndex: 1 }
}
/> />
) )
) : ( ) : (
@@ -297,17 +241,10 @@ export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfi
</TableRow> </TableRow>
) : ( ) : (
actualData.map((row, index) => { actualData.map((row, index) => {
// ── 행 className 분기 (v2 alternate background + hoverEffect 기본 true) ── const rowClass = cn(
const rowClass = isV2 "bg-background h-10 cursor-pointer border-b transition-colors",
? cn( tableConfig?.tableStyle?.hoverEffect && "hover:bg-muted/50",
"cursor-pointer border-b border-border/50 transition-[background] duration-75", );
index % 2 === 0 ? "bg-background" : "bg-muted/20",
tableConfig?.tableStyle?.hoverEffect !== false && "hover:bg-accent",
)
: cn(
"bg-background h-10 cursor-pointer border-b transition-colors",
tableConfig?.tableStyle?.hoverEffect && "hover:bg-muted/50",
);
return ( return (
<TableRow key={`row-${index}`} className={rowClass} onClick={(e) => handleRowClick?.(row, index, e)}> <TableRow key={`row-${index}`} className={rowClass} onClick={(e) => handleRowClick?.(row, index, e)}>
@@ -345,31 +282,10 @@ export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfi
currentSearchIndex < highlightArray.length && currentSearchIndex < highlightArray.length &&
highlightArray[currentSearchIndex] === cellKey; highlightArray[currentSearchIndex] === cellKey;
// ── 셀 값 분기 ── const rawCellValue: React.ReactNode =
// v2: null/undefined/"" → "-" 표시 (0 은 값 그대로), ReactElement 가능 formatCellValue(row[column.columnName], column.format, column.columnName, row) || " ";
// default: falsy 면 nbsp fallback
let rawCellValue: React.ReactNode;
let isReactElement = false;
if (isV2) {
const formatted = formatCellValue(row[column.columnName], column.format, column.columnName, row);
if (formatted === null || formatted === undefined || formatted === "") {
rawCellValue = <span className="text-muted-foreground/50">-</span>;
isReactElement = true;
} else {
rawCellValue = formatted;
isReactElement = typeof formatted === "object" && React.isValidElement(formatted);
}
} else {
rawCellValue =
formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0";
}
const renderCellContent = () => { const renderCellContent = () => {
// ReactElement (v2 의 이미지/JSX) 는 그대로 렌더
if (isReactElement) {
return rawCellValue;
}
if (!isHighlighted || !searchTerm || isCheckboxCol) { if (!isHighlighted || !searchTerm || isCheckboxCol) {
return rawCellValue; return rawCellValue;
} }
@@ -403,37 +319,15 @@ export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfi
); );
}; };
// ── 셀 className 분기 ── const cellClass = cn(
const cellClass = isV2 "text-foreground h-10 px-3 py-1.5 align-middle text-xs whitespace-nowrap transition-colors sm:px-4 sm:py-2 sm:text-sm",
? cn( `text-${column.align}`,
"text-foreground h-10 align-middle text-[11px] transition-colors", column.fixed === "left" &&
isCheckboxCol ? "px-0 py-[7px] text-center" : "px-3 py-[7px]", "border-border bg-background/90 sticky z-10 border-r backdrop-blur-sm",
!isReactElement && "whitespace-nowrap", column.fixed === "right" &&
!isCheckboxCol && `text-${column.align}`, "border-border bg-background/90 sticky z-10 border-l backdrop-blur-sm",
column.fixed === "left" && onCellDoubleClick && !isCheckboxCol && "cursor-text",
"border-border bg-background/90 sticky z-10 border-r backdrop-blur-sm", );
column.fixed === "right" &&
"border-border bg-background/90 sticky z-10 border-l backdrop-blur-sm",
onCellDoubleClick && !isCheckboxCol && "cursor-text",
)
: cn(
"text-foreground h-10 px-3 py-1.5 align-middle text-xs whitespace-nowrap transition-colors sm:px-4 sm:py-2 sm:text-sm",
`text-${column.align}`,
column.fixed === "left" &&
"border-border bg-background/90 sticky z-10 border-r backdrop-blur-sm",
column.fixed === "right" &&
"border-border bg-background/90 sticky z-10 border-l backdrop-blur-sm",
onCellDoubleClick && !isCheckboxCol && "cursor-text",
);
// ── 셀 width/style 분기 (v2 의 checkbox 48px) ──
const cellFixedWidth = isV2 && isCheckboxCol ? 48 : getColumnWidth(column);
const cellMinWidth = isV2 && isCheckboxCol ? "48px" : "100px";
const cellMaxWidth = isV2 && isCheckboxCol ? "48px" : "300px";
const cellOverflowStyle: React.CSSProperties =
isV2 && isReactElement
? {}
: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" };
return ( return (
<TableCell <TableCell
@@ -441,11 +335,13 @@ export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfi
id={isCurrentSearchResult ? "current-search-result" : undefined} id={isCurrentSearchResult ? "current-search-result" : undefined}
className={cellClass} className={cellClass}
style={{ style={{
width: cellFixedWidth, width: getColumnWidth(column),
minWidth: cellMinWidth, minWidth: "100px",
maxWidth: cellMaxWidth, maxWidth: "300px",
boxSizing: "border-box", boxSizing: "border-box",
...cellOverflowStyle, overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
...(column.fixed === "left" && { left: leftFixedWidth }), ...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }), ...(column.fixed === "right" && { right: rightFixedWidth }),
}} }}
@@ -459,120 +355,24 @@ export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfi
{isCheckboxCol ? ( {isCheckboxCol ? (
renderCheckboxCell?.(row, index) renderCheckboxCell?.(row, index)
) : isEditing ? ( ) : isEditing ? (
isV2 ? ( <input
// ── v2 인라인 편집: inputType 에 따라 select(category/code), date/datetime, number, text ── ref={editInputRef}
(() => { type="text"
const meta = columnMeta?.[column.columnName]; value={editingValue ?? ""}
const inputType = meta?.inputType ?? (column as { inputType?: string }).inputType; onChange={(e) => onEditingValueChange?.(e.target.value)}
const isNumeric = inputType === "number" || inputType === "decimal"; onKeyDown={onEditKeyDown}
const isCategoryType = inputType === "category" || inputType === "code"; onBlur={() => {
const categoryOptions = categoryMappings?.[column.columnName]; if (onEditKeyDown) {
const hasCategoryOptions = const fakeEvent = {
isCategoryType && categoryOptions && Object.keys(categoryOptions).length > 0; key: "Enter",
preventDefault: () => {},
const commonInputClass = } as React.KeyboardEvent<HTMLInputElement>;
"border-primary bg-background focus:ring-primary h-8 w-full shrink-0 rounded border px-2 text-xs focus:ring-2 focus:outline-none sm:text-sm"; onEditKeyDown(fakeEvent);
const handleBlurSave = () => {
if (onEditKeyDown) {
const fakeEvent = {
key: "Enter",
preventDefault: () => {},
} as React.KeyboardEvent<HTMLInputElement>;
onEditKeyDown(fakeEvent);
}
onEditSave?.();
};
if (hasCategoryOptions) {
const selectOptions = Object.entries(categoryOptions).map(([value, info]) => ({
value,
label: info.label,
}));
return (
<select
ref={editInputRef as unknown as React.RefObject<HTMLSelectElement>}
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown as unknown as React.KeyboardEventHandler<HTMLSelectElement>}
onBlur={handleBlurSave}
className={cn(commonInputClass, "h-8")}
onClick={(e) => e.stopPropagation()}
>
<option value=""></option>
{selectOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
} }
}}
if (inputType === "date" || inputType === "datetime") { className="border-primary bg-background focus:ring-primary h-8 w-full rounded border px-2 text-xs focus:ring-2 focus:outline-none sm:text-sm"
try { onClick={(e) => e.stopPropagation()}
// 외부 의존 모듈 — runtime require 실패 시 일반 text input 으로 폴백 />
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { InlineCellDatePicker } = require("@/components/screen/filters/InlineCellDatePicker");
return (
<InlineCellDatePicker
value={editingValue ?? ""}
onChange={(v: string) => onEditingValueChange?.(v)}
onSave={handleBlurSave}
onKeyDown={onEditKeyDown}
inputRef={editInputRef}
/>
);
} catch {
return (
<input
ref={editInputRef}
type="text"
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={handleBlurSave}
className={commonInputClass}
onClick={(e) => e.stopPropagation()}
/>
);
}
}
return (
<input
ref={editInputRef}
type={isNumeric ? "number" : "text"}
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={handleBlurSave}
className={commonInputClass}
style={isNumeric ? { textAlign: "right" } : undefined}
onClick={(e) => e.stopPropagation()}
/>
);
})()
) : (
// ── 기본 인라인 편집: 단순 text input (table-list / FlowWidget 기존 동작) ──
<input
ref={editInputRef}
type="text"
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={() => {
if (onEditKeyDown) {
const fakeEvent = {
key: "Enter",
preventDefault: () => {},
} as React.KeyboardEvent<HTMLInputElement>;
onEditKeyDown(fakeEvent);
}
}}
className="border-primary bg-background focus:ring-primary h-8 w-full rounded border px-2 text-xs focus:ring-2 focus:outline-none sm:text-sm"
onClick={(e) => e.stopPropagation()}
/>
)
) : ( ) : (
renderCellContent() renderCellContent()
)} )}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,92 +0,0 @@
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { TableListWrapper } from "./V2TableListComponent";
import { fieldsToColumns } from "@/lib/fieldConfig/adapters";
import type { FieldConfig } from "@/types/invyone-component";
/**
* v2-table-list (2026-04-10, Phase 1 Step 6)
*
* ResizeObserver `displayMode` .
* TableListComponent , props.config
* displayMode . CardModeRenderer .
*
* - width >= NARROW_BREAKPOINT wide ( )
* - width < NARROW_BREAKPOINT narrow ( CardModeRenderer)
*
* container-type: inline-size @container .
*
* INVYONE FieldConfig (Phase 1+)
* props.fields: FieldConfig[]
* config.columns .
* (config.columns) . .
*/
const NARROW_BREAKPOINT = 600;
type AnyProps = Record<string, any> & { fields?: FieldConfig[] };
export const TableListContainerWrapper: React.FC<AnyProps> = (props) => {
const rootRef = useRef<HTMLDivElement | null>(null);
const [mode, setMode] = useState<"wide" | "narrow">("wide");
useEffect(() => {
const el = rootRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const apply = (width: number) => {
setMode((prev) => {
const next = width < NARROW_BREAKPOINT ? "narrow" : "wide";
return prev === next ? prev : next;
});
};
apply(el.getBoundingClientRect().width);
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
apply(entry.contentRect.width);
}
});
ro.observe(el);
return () => ro.disconnect();
}, []);
// INVYONE FieldConfig 를 기존 columns 포맷으로 메모이즈 변환.
// fields 가 없거나 빈 배열이면 null → 기존 config.columns 경로 유지.
const derivedColumns = useMemo(() => {
if (!Array.isArray(props.fields) || props.fields.length === 0) return null;
return fieldsToColumns(props.fields);
}, [props.fields]);
const { fields: _fields, ...restProps } = props ?? ({} as AnyProps);
const originalConfig = (restProps?.config ?? {}) as Record<string, any>;
const effectiveConfig: Record<string, any> = (() => {
const base =
mode === "narrow"
? { ...originalConfig, displayMode: "card" }
: originalConfig;
return derivedColumns ? { ...base, columns: derivedColumns } : base;
})();
return (
<div
ref={rootRef}
data-v2-table-list-mode={mode}
style={{
containerType: "inline-size",
containerName: "v2-table-list",
width: "100%",
height: "100%",
}}
>
<TableListWrapper
{...(restProps as any)}
config={effectiveConfig as any}
/>
</div>
);
};
TableListContainerWrapper.displayName = "TableListContainerWrapper";
@@ -1,352 +0,0 @@
import type { ComponentConfig } from "@/types/component";
/**
* Entity
*/
export interface EntityJoinInfo {
sourceTable: string;
sourceColumn: string;
joinAlias: string;
}
/**
*
*/
export type AutoGenerationType =
| "uuid" // UUID 생성
| "current_user" // 현재 사용자 ID
| "current_time" // 현재 시간
| "sequence" // 시퀀스 번호
| "numbering_rule" // 채번 규칙
| "random_string" // 랜덤 문자열
| "random_number" // 랜덤 숫자
| "company_code" // 회사 코드
| "department" // 부서 코드
| "none"; // 자동생성 없음
/**
*
*/
export interface AutoGenerationConfig {
type: AutoGenerationType;
enabled: boolean;
options?: {
length?: number; // 랜덤 문자열/숫자 길이
prefix?: string; // 접두사
suffix?: string; // 접미사
format?: string; // 시간 형식 (current_time용)
startValue?: number; // 시퀀스 시작값
numberingRuleId?: string; // 채번 규칙 ID (numbering_rule 타입용)
};
}
/**
*
*/
export interface ColumnConfig {
columnName: string;
displayName: string;
visible: boolean;
sortable: boolean;
searchable: boolean;
width?: number;
align: "left" | "center" | "right";
format?: "text" | "number" | "date" | "currency" | "boolean";
order: number;
dataType?: string; // 컬럼 데이터 타입 (검색 컬럼 선택에 사용)
isEntityJoin?: boolean; // Entity 조인된 컬럼인지 여부
entityJoinInfo?: EntityJoinInfo; // Entity 조인 상세 정보
// 숫자 포맷팅 설정
thousandSeparator?: boolean; // 천단위 구분자 사용 여부 (기본: true)
// 🎯 엔티티 컬럼 표시 설정 (화면별 동적 설정)
entityDisplayConfig?: {
displayColumns: string[]; // 표시할 컬럼들 (기본 테이블 + 조인 테이블)
separator?: string; // 구분자 (기본: " - ")
sourceTable?: string; // 기본 테이블명
joinTable?: string; // 조인 테이블명
};
// 컬럼 고정 관련 속성
fixed?: "left" | "right" | false; // 컬럼 고정 위치 (왼쪽, 오른쪽, 고정 안함)
fixedOrder?: number; // 고정된 컬럼들 내에서의 순서
// 새로운 기능들
hidden?: boolean; // 숨김 기능 (편집기에서는 연하게, 실제 화면에서는 숨김)
autoGeneration?: AutoGenerationConfig; // 자동생성 설정
editable?: boolean; // 🆕 편집 가능 여부 (기본값: true, false면 인라인 편집 불가)
// 🎯 추가 조인 컬럼 정보 (조인 탭에서 추가한 컬럼들)
additionalJoinInfo?: {
sourceTable: string; // 원본 테이블
sourceColumn: string; // 원본 컬럼 (예: dept_code)
referenceTable?: string; // 참조 테이블 (예: dept_info)
joinAlias: string; // 조인 별칭 (예: dept_code_company_name)
};
}
/**
*
*/
export interface CardDisplayConfig {
idColumn: string; // ID 컬럼 (사번 등)
titleColumn: string; // 제목 컬럼 (이름 등)
subtitleColumn?: string; // 부제목 컬럼 (부서 등)
descriptionColumn?: string; // 설명 컬럼
imageColumn?: string; // 이미지 컬럼
cardsPerRow: number; // 한 행당 카드 수 (기본: 3)
cardSpacing: number; // 카드 간격 (기본: 16px)
showActions: boolean; // 액션 버튼 표시 여부
cardHeight?: number; // 카드 높이 (기본: auto)
}
/**
*
*/
export interface FilterConfig {
enabled: boolean;
// 사용할 필터 목록 (DataTableFilter 타입 사용)
filters: Array<{
columnName: string;
widgetType: string;
label: string;
gridColumns: number;
numberFilterMode?: "exact" | "range"; // 숫자 필터 모드
codeInfo?: string;
referenceTable?: string;
referenceColumn?: string;
displayColumn?: string;
}>;
// 필터와 리스트 사이 간격 (px 단위, 기본: 40)
bottomSpacing?: number;
}
/**
*
*/
export interface ActionConfig {
showActions: boolean;
actions: Array<{
type: "view" | "edit" | "delete" | "custom";
label: string;
icon?: string;
color?: string;
confirmMessage?: string;
targetScreen?: string;
}>;
bulkActions: boolean;
bulkActionList: string[];
}
/**
*
*/
export interface TableStyleConfig {
theme: "default" | "striped" | "bordered" | "minimal";
headerStyle: "default" | "dark" | "light";
rowHeight: "compact" | "normal" | "comfortable";
alternateRows: boolean;
hoverEffect: boolean;
borderStyle: "none" | "light" | "heavy";
}
/**
*
*/
export interface PaginationConfig {
enabled: boolean;
pageSize: number;
showSizeSelector: boolean;
showPageInfo: boolean;
pageSizeOptions: number[];
}
/**
*
*/
export interface ToolbarConfig {
showEditMode?: boolean; // 즉시 저장/배치 모드 버튼
showExcel?: boolean; // Excel 내보내기 버튼
showPdf?: boolean; // PDF 내보내기 버튼
showCopy?: boolean; // 복사 버튼
showSearch?: boolean; // 검색 버튼
showFilter?: boolean; // 필터 버튼
showRefresh?: boolean; // 상단 툴바 새로고침 버튼
showPaginationRefresh?: boolean; // 하단 페이지네이션 새로고침 버튼
}
/**
*
*/
export interface CheckboxConfig {
enabled: boolean; // 체크박스 활성화 여부
multiple: boolean; // 다중 선택 가능 여부 (true: 체크박스, false: 라디오)
position: "left" | "right"; // 체크박스 위치
selectAll: boolean; // 전체 선택/해제 버튼 표시 여부
}
/**
*
* ( )
*/
export interface LinkedFilterConfig {
sourceComponentId: string; // 소스 컴포넌트 ID (셀렉트박스 등)
sourceField?: string; // 소스 컴포넌트에서 가져올 필드명 (기본: value)
targetColumn: string; // 필터링할 테이블 컬럼명
operator?: "equals" | "contains" | "in"; // 필터 연산자 (기본: equals)
enabled?: boolean; // 활성화 여부 (기본: true)
}
/**
*
*
* : 거래처에
*/
export interface ExcludeFilterConfig {
enabled: boolean; // 제외 필터 활성화 여부
referenceTable: string; // 참조 테이블 (예: customer_item_mapping)
referenceColumn: string; // 참조 테이블의 비교 컬럼 (예: item_id)
sourceColumn: string; // 현재 테이블의 비교 컬럼 (예: item_number)
filterColumn?: string; // 참조 테이블의 필터 컬럼 (예: customer_id)
filterValueSource?: "url" | "formData" | "parentData"; // 필터 값 소스 (기본: url)
filterValueField?: string; // 필터 값 필드명 (예: customer_code)
}
/**
* TableList
*/
import type { DataFilterConfig } from "@/types/screen-management";
export interface TableListConfig extends ComponentConfig {
// 표시 모드 설정
displayMode?: "table" | "card"; // 기본: "table"
// 카드 디스플레이 설정 (displayMode가 "card"일 때 사용)
cardConfig?: CardDisplayConfig;
// 테이블 기본 설정
selectedTable?: string;
tableName?: string;
title?: string;
showHeader: boolean;
showFooter: boolean;
// 🆕 커스텀 테이블 설정 (화면 메인 테이블과 다른 테이블 사용 시)
customTableName?: string; // 컴포넌트가 사용할 커스텀 테이블명
useCustomTable?: boolean; // true면 customTableName 사용, false면 화면 메인 테이블 사용
isReadOnly?: boolean; // 읽기전용 여부 (조회용 테이블인 경우 true)
// 체크박스 설정
checkbox: CheckboxConfig;
// 높이 설정
height: "auto" | "fixed" | "viewport";
fixedHeight?: number;
// 컬럼 설정
columns: ColumnConfig[];
autoWidth: boolean;
stickyHeader: boolean;
// 가로 스크롤 및 컬럼 고정 설정
horizontalScroll: {
enabled: boolean; // 가로 스크롤 활성화 여부
maxVisibleColumns?: number; // 스크롤 없이 표시할 최대 컬럼 수 (이 수를 넘으면 가로 스크롤)
minColumnWidth?: number; // 컬럼 최소 너비 (px)
maxColumnWidth?: number; // 컬럼 최대 너비 (px)
};
// 페이지네이션
pagination: PaginationConfig & {
currentPage?: number; // 현재 페이지 (추가)
};
// 필터 설정
filter: FilterConfig;
// 액션 설정
actions: ActionConfig;
// 스타일 설정
tableStyle: TableStyleConfig;
// 데이터 로딩
autoLoad: boolean;
refreshInterval?: number; // 초 단위
// 🆕 기본 정렬 설정
defaultSort?: {
columnName: string; // 정렬할 컬럼명
direction: "asc" | "desc"; // 정렬 방향
};
// 🆕 툴바 버튼 표시 설정
toolbar?: ToolbarConfig;
// 🆕 컬럼 값 기반 데이터 필터링
dataFilter?: DataFilterConfig;
// 🆕 연결된 필터 (다른 컴포넌트 값으로 필터링)
linkedFilters?: LinkedFilterConfig[];
// 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
excludeFilter?: ExcludeFilterConfig;
// 이벤트 핸들러
onRowClick?: (row: any) => void;
onRowDoubleClick?: (row: any) => void;
onSelectionChange?: (selectedRows: any[]) => void;
onPageChange?: (page: number, pageSize: number) => void;
onSortChange?: (column: string, direction: "asc" | "desc") => void;
onFilterChange?: (filters: any) => void;
// 선택된 행 정보 전달 핸들러
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
}
/**
*
*/
export interface TableDataResponse {
data: any[];
pagination: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
columns?: Array<{
name: string;
type: string;
nullable: boolean;
}>;
}
/**
* TableList Props
*/
export interface TableListProps {
id?: string;
config?: TableListConfig;
className?: string;
style?: React.CSSProperties;
// 데이터 관련
data?: any[];
loading?: boolean;
error?: string;
// 이벤트 핸들러
onRowClick?: (row: any) => void;
onRowDoubleClick?: (row: any) => void;
onSelectionChange?: (selectedRows: any[]) => void;
onPageChange?: (page: number, pageSize: number) => void;
onSortChange?: (column: string, direction: "asc" | "desc") => void;
onFilterChange?: (filters: any) => void;
onRefresh?: () => void;
// 선택된 행 정보 전달 핸들러
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
}
@@ -0,0 +1,530 @@
"use client";
/**
* Phase D.5 (2026-05-20) canonical TableComponent helper.
*
* plain `String(row[col.key])` .
*
* :
* - image (TableCellImage)
* - file (TableCellFile)
* - entity (entityDisplayConfig)
* - number / date / boolean / currency
*
* `TableCellImage` / `TableCellFile` / `formatCellValue`
* canonical .
*/
import React, { useEffect, useState } from "react";
import { FileText } from "lucide-react";
import { getFullImageUrl } from "@/lib/api/client";
import { getFilePreviewUrl } from "@/lib/api/file";
import {
formatDate as centralFormatDate,
formatNumber as centralFormatNumber,
formatCurrency as centralFormatCurrency,
} from "@/lib/formatting";
import type { TableColumn } from "./types";
// ───────────────────────────────────────────────────────
// TableCellImage — 이미지 썸네일 셀
// ───────────────────────────────────────────────────────
export interface TableCellImageProps {
value: string;
isDesignMode?: boolean;
}
export const TableCellImage: React.FC<TableCellImageProps> = React.memo(
({ value, isDesignMode }) => {
const [imgSrc, setImgSrc] = useState<string | null>(null);
const [displayObjid, setDisplayObjid] = useState<string>("");
const [error, setError] = useState(false);
useEffect(() => {
let mounted = true;
setError(false);
const rawValue = String(value || "").trim();
if (!rawValue) {
setImgSrc(null);
setError(true);
return () => {
mounted = false;
};
}
const parts = rawValue.split(",").map((s) => s.trim()).filter(Boolean);
const first = parts[0] || rawValue;
setDisplayObjid(first);
const isObjid = /^\d+$/.test(first);
if (isDesignMode) {
// 디자인 모드: remote lookup 안 함. path 만 직접 src 로.
if (!isObjid) {
setImgSrc(getFullImageUrl(first));
} else {
setImgSrc(null);
}
return () => {
mounted = false;
};
}
if (isObjid) {
setImgSrc(getFilePreviewUrl(first));
} else {
setImgSrc(getFullImageUrl(first));
}
return () => {
mounted = false;
};
}, [value, isDesignMode]);
if (error || !imgSrc) {
return (
<span style={{ color: "hsl(var(--muted-foreground))", fontSize: 11 }}>
-
</span>
);
}
return (
<img
src={imgSrc}
alt=""
style={{
maxWidth: 36,
maxHeight: 36,
borderRadius: 3,
objectFit: "cover",
cursor: isDesignMode ? "default" : "pointer",
verticalAlign: "middle",
}}
onClick={(e) => {
if (isDesignMode) return;
e.stopPropagation();
const isObjid = /^\d+$/.test(displayObjid);
const openUrl = isObjid
? getFilePreviewUrl(displayObjid)
: getFullImageUrl(displayObjid);
if (typeof window !== "undefined") {
window.open(openUrl, "_blank");
}
}}
onError={() => setError(true)}
/>
);
},
);
TableCellImage.displayName = "TableCellImage";
// ───────────────────────────────────────────────────────
// TableCellFile — 파일 이름 + 다운로드 셀
// ───────────────────────────────────────────────────────
interface FileInfo {
objid: string;
name: string;
ext?: string;
}
function _parseFileValue(raw: string): FileInfo[] {
const trimmed = String(raw || "").trim();
if (!trimmed || trimmed === "-") return [];
// JSON 배열 시도
if (trimmed.startsWith("[")) {
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return parsed.map((f: any) => ({
objid: String(f?.objid ?? f?.id ?? ""),
name: String(
f?.realFileName ?? f?.real_file_name ?? f?.name ?? "파일",
),
ext: String(f?.fileExt ?? f?.file_ext ?? "") || undefined,
}));
}
} catch {
/* fall through */
}
}
// 콤마 구분
const parts = trimmed.split(",").map((s) => s.trim()).filter(Boolean);
return parts.map((p) => ({
objid: /^\d+$/.test(p) ? p : "",
name: /^\d+$/.test(p) ? p : p.split("/").pop() || p,
}));
}
export interface TableCellFileProps {
value: string;
isDesignMode?: boolean;
}
export const TableCellFile: React.FC<TableCellFileProps> = React.memo(
({ value, isDesignMode }) => {
const [files, setFiles] = useState<FileInfo[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let mounted = true;
setLoading(true);
const parsed = _parseFileValue(value);
if (parsed.length === 0) {
setFiles([]);
setLoading(false);
return () => {
mounted = false;
};
}
// JSON 으로 이미 이름이 있는 경우는 즉시 표시.
const hasNames = parsed.every((f) => f.name && f.name !== f.objid);
if (hasNames || isDesignMode) {
setFiles(parsed);
setLoading(false);
return () => {
mounted = false;
};
}
// 디자인 모드 X + objid 만 있음 → 비동기로 파일명 lookup (대표 1개만 — 보수적)
const objids = parsed.map((f) => f.objid).filter(Boolean);
if (objids.length === 0) {
setFiles(parsed);
setLoading(false);
return () => {
mounted = false;
};
}
(async () => {
try {
const { getFileInfoByObjid } = await import("@/lib/api/file");
const lookups = await Promise.all(
objids.map(async (oid) => {
try {
const info = await getFileInfoByObjid(oid);
if (info?.success && info.data) {
// backend snake_case + 임의 camelCase fallback 흡수
const d = info.data as any;
return {
objid: oid,
name:
d.real_file_name ||
d.realFileName ||
d.name ||
oid,
ext: d.file_ext || d.fileExt,
};
}
} catch {
/* noop */
}
return { objid: oid, name: oid };
}),
);
if (mounted) {
setFiles(lookups);
setLoading(false);
}
} catch {
if (mounted) {
setFiles(parsed);
setLoading(false);
}
}
})();
return () => {
mounted = false;
};
}, [value, isDesignMode]);
if (loading) {
return (
<span style={{ color: "hsl(var(--muted-foreground))", fontSize: 11 }}>
...
</span>
);
}
if (files.length === 0) {
return (
<span style={{ color: "hsl(var(--muted-foreground))", fontSize: 11 }}>
-
</span>
);
}
// 단일 파일 inline link
if (files.length === 1) {
const f = files[0];
const url = f.objid && /^\d+$/.test(f.objid)
? getFilePreviewUrl(f.objid)
: null;
const content = (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
fontSize: 12,
maxWidth: 200,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={f.name}
>
<FileText size={12} style={{ flexShrink: 0 }} />
<span style={{ overflow: "hidden", textOverflow: "ellipsis" }}>
{f.name}
</span>
</span>
);
if (url && !isDesignMode) {
return (
<a
href={url}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
style={{
color: "hsl(var(--primary))",
textDecoration: "none",
}}
>
{content}
</a>
);
}
return content;
}
// 다중 — count 표시 + tooltip 으로 이름들
const allNames = files.map((f) => f.name).join(", ");
return (
<span
title={allNames}
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
fontSize: 12,
}}
>
<FileText size={12} />
<span>{files[0].name}</span>
<span
style={{
fontSize: 10,
color: "hsl(var(--muted-foreground))",
}}
>
{files.length - 1}
</span>
</span>
);
},
);
TableCellFile.displayName = "TableCellFile";
// ───────────────────────────────────────────────────────
// entityDisplayConfig 적용
// ───────────────────────────────────────────────────────
/**
* entity `entityDisplayConfig.displayColumns`
* row separator (`" - "` ) join.
*
* Key fallback ( displayColumn ):
* 1. `${column.key}_${displayColumn}`
* 2. `${entityJoinInfo?.sourceColumn}_${displayColumn}`
* 3. `${entityDisplayConfig?.joinTable}_${displayColumn}`
* 4. `${entityJoinInfo?.joinAlias}_${displayColumn}`
* 5. direct `displayColumn`
* 6. `displayColumn` `.` segment
*
* join null ( fallback ).
*/
function _applyEntityDisplayConfig(
column: TableColumn,
row: Record<string, any>,
): string | null {
const cfg = column.entityDisplayConfig;
if (!cfg) return null;
const displayColumns: string[] = Array.isArray(cfg.displayColumns)
? cfg.displayColumns
: Array.isArray((cfg as any).selectedColumns)
? ((cfg as any).selectedColumns as string[])
: [];
if (displayColumns.length === 0) return null;
const separator = cfg.separator || " - ";
const sourceColumn = column.entityJoinInfo?.sourceColumn;
const joinAlias = column.entityJoinInfo?.joinAlias;
const joinTable = cfg.joinTable;
const values: string[] = [];
for (const dc of displayColumns) {
const candidates: string[] = [
`${column.key}_${dc}`,
sourceColumn ? `${sourceColumn}_${dc}` : "",
joinTable ? `${joinTable}_${dc}` : "",
joinAlias ? `${joinAlias}_${dc}` : "",
dc,
].filter(Boolean);
if (dc.includes(".")) {
const last = dc.split(".").pop();
if (last) candidates.push(last);
}
let found: any = undefined;
for (const k of candidates) {
const v = row[k];
if (v !== undefined && v !== null && v !== "") {
found = v;
break;
}
}
if (found !== undefined && found !== null && found !== "") {
values.push(String(found));
}
}
const joined = values.join(separator).trim();
return joined.length > 0 ? joined : null;
}
// ───────────────────────────────────────────────────────
// renderTableCellValue — canonical 셀 디스플레이 진입점
// ───────────────────────────────────────────────────────
export interface RenderTableCellArgs {
value: any;
column: TableColumn;
row: Record<string, any>;
isDesignMode?: boolean;
}
export function renderTableCellValue(args: RenderTableCellArgs): React.ReactNode {
const { value, column, row, isDesignMode } = args;
// 1) entityDisplayConfig 우선 — value 가 비어도 다른 row 키에 표시값 있을 수 있음.
const entityDisplay = _applyEntityDisplayConfig(column, row);
if (entityDisplay) {
return entityDisplay;
}
// 2) image 셀
const isImage =
column.inputType === "image" || column.format === "image";
if (isImage) {
if (value === null || value === undefined || value === "") {
return (
<span style={{ color: "hsl(var(--muted-foreground))", fontSize: 11 }}>
-
</span>
);
}
return <TableCellImage value={String(value)} isDesignMode={isDesignMode} />;
}
// 3) file / attachment 셀
const keyLower = (column.key || "").toLowerCase();
const looksLikeFileKey =
keyLower.includes("attachment") ||
/(^|[_-])files?($|[_-])/.test(keyLower);
const isFile =
column.inputType === "file" ||
column.inputType === "attachment" ||
column.format === "file" ||
column.format === "attachment" ||
looksLikeFileKey;
if (isFile) {
if (value === null || value === undefined || value === "") {
return (
<span style={{ color: "hsl(var(--muted-foreground))", fontSize: 11 }}>
-
</span>
);
}
return <TableCellFile value={String(value)} isDesignMode={isDesignMode} />;
}
// 4) null/empty
if (value === null || value === undefined || value === "") {
return isDesignMode ? (
<span style={{ color: "hsl(var(--muted-foreground))" }}>...</span>
) : (
""
);
}
// 5) boolean
const isBoolean =
column.format === "boolean" || column.inputType === "checkbox";
if (isBoolean) {
const v = value;
const truthy =
v === true ||
v === 1 ||
v === "1" ||
v === "Y" ||
v === "y" ||
v === "true" ||
v === "TRUE";
return truthy ? "예" : "아니오";
}
// 6) date / datetime
const isDate =
column.inputType === "date" ||
column.inputType === "datetime" ||
column.format === "date" ||
column.format === "datetime";
if (isDate) {
try {
const formatted = centralFormatDate(value, "display");
if (formatted && formatted !== String(value)) return formatted;
} catch {
/* fall through to default */
}
// fallback: ISO date 의 앞 10 자리
const s = String(value);
if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10);
return s;
}
// 7) currency
if (column.format === "currency") {
try {
return centralFormatCurrency(value);
} catch {
const n = Number(value);
if (Number.isFinite(n)) return `${n.toLocaleString("ko-KR")}`;
}
return String(value);
}
// 8) number / decimal
const isNumber =
column.inputType === "number" ||
column.inputType === "decimal" ||
column.format === "number";
if (isNumber) {
const n = Number(value);
if (!Number.isFinite(n)) return String(value);
if (column.thousandSeparator === false) {
return String(n);
}
try {
return centralFormatNumber(value);
} catch {
return n.toLocaleString("ko-KR");
}
}
// 9) default — plain string
return String(value);
}
@@ -7,19 +7,18 @@ import { InvTableConfigPanel } from "./InvTableConfigPanel";
import type { TableConfig } from "./types"; import type { TableConfig } from "./types";
/** /**
* Table (2026-04-11, Phase C-1) * Table canonical data table component
* *
* (9): * . 5 displayMode :
* - v2-table-list (base) * - table ( )
* - v2-table-grouped (displayMode='grouped') * - split ( master-detail)
* - v2-pivot-grid (displayMode='pivot') * - grouped ()
* - v2-split-panel-layout (displayMode='split') * - pivot ( )
* - table-list, split-panel-layout, split-panel-layout2 (legacy) * - card ( , fallback)
* - modal-repeater-table, simple-repeater-table (legacy)
* - pivot-grid, tax-invoice-list (legacy)
* *
* : * :
* notes/gbpark/2026-04-11-component-unification-plan.md §3.1 * notes/gbpark/2026-04-11-component-unification-plan.md §3.1
* notes/gbpark/2026-05-20-table-canonical-cleanup-plan.md
*/ */
const DEFAULT_CONFIG: Partial<TableConfig> = { const DEFAULT_CONFIG: Partial<TableConfig> = {
+388 -14
View File
@@ -2,21 +2,17 @@
import { ComponentConfig } from "@/types/component"; import { ComponentConfig } from "@/types/component";
import type { FieldConfig } from "@/types/invyone-component"; import type { FieldConfig } from "@/types/invyone-component";
import type { DataFilterConfig } from "@/types/screen-management";
import type { AutoGenerationConfig } from "@/types/screen";
/** /**
* Table * Table
* *
* 9 ** **. * ** **.
* displayMode (///). * displayMode (////).
* *
* (9): * (table-list / v2-table-list / split-panel-layout ) Phase F
* - v2-table-list (base, ) * canonical TableComponent . // .
* - v2-table-grouped ( )
* - v2-pivot-grid ( )
* - v2-split-panel-layout ( 분할: 목록 | )
* - table-list, split-panel-layout, split-panel-layout2 (legacy)
* - modal-repeater-table, simple-repeater-table (legacy)
* - tax-invoice-list, pivot-grid (legacy)
*/ */
export type TableDisplayMode = "table" | "split" | "grouped" | "pivot" | "card"; export type TableDisplayMode = "table" | "split" | "grouped" | "pivot" | "card";
@@ -36,15 +32,271 @@ export interface TableColumn {
sortable?: boolean; sortable?: boolean;
/** 포맷 */ /** 포맷 */
format?: string; format?: string;
/** 표시 여부 */ /** 표시 여부 (false 면 운영/디자인 모두 안 보임) */
visible?: boolean; visible?: boolean;
// ─── Phase C.2 (2026-05-20) — legacy ColumnConfig 풀 옵션 흡수 ───
/**
* / sticky . `false` = .
* sticky CSS / scroll D.1 ( ) wiring. C.2 config .
*/
fixed?: "left" | "right" | false;
/**
* fixed (fixed = "left" "right" ).
* columns . D.1 wiring.
*/
fixedOrder?: number;
/**
* 릿, .
* `visible: false` (visible ). D.1 wiring.
*/
hidden?: boolean;
/**
* / .
* : `text` / `number` / `date` / `datetime` / `select` / `entity` / `checkbox` /
* `textarea` / `file` / `image` / `code`.
* `format` backend . D.3 ( ) / D.5 ( ) wiring.
*/
inputType?: string;
/**
* ( true). false .
* D.3 wiring. C.2 config .
*/
editable?: boolean;
/**
* ( true). `inputType === "number"`
* `format === "number" | "currency"` . D.5 ( ) wiring.
*/
thousandSeparator?: boolean;
/**
* / . C.3 () wiring.
*/
searchable?: boolean;
/**
* ( ). columns .
* D.1 wiring ( set).
*/
order?: number;
/**
* backend (string / number / date / boolean).
* , . C.3 / D.2 wiring.
*/
dataType?: string;
// ─── entity 조인 / 다중 표시 메타 (D.5 wiring, C.2 는 보존만) ───
/** entity 조인 컬럼 여부 */
isEntityJoin?: boolean;
/** entity 조인 상세 (sourceTable / sourceColumn / joinAlias) */
entityJoinInfo?: {
sourceTable: string;
sourceColumn: string;
joinAlias: string;
};
/**
* entity .
* D.5 .
*/
entityDisplayConfig?: {
displayColumns: string[];
separator?: string;
sourceTable?: string;
joinTable?: string;
};
/** 조인 탭에서 추가한 컬럼의 원본/참조 정보 */
additionalJoinInfo?: {
sourceTable: string;
sourceColumn: string;
referenceTable?: string;
joinAlias: string;
};
// ─── Phase D.5 (2026-05-20) — 다국어 라벨 ───
/** 헤더 라벨 다국어 키 — `useScreenMultiLang().getTranslatedText(langKey, label)` 로 번역 */
langKey?: string;
/** `lang_keys` 테이블 PK — 일부 화면 메타가 langKey 와 함께 보존 */
langKeyId?: number;
// ─── Phase D.10 (2026-05-20) — 자동생성 메타 (legacy ColumnConfig.autoGeneration 흡수) ───
/**
* (uuid / current_user / current_time / sequence / numbering_rule /
* random_string / random_number / company_code / department).
*
* Phase D.10 ** **. canonical `TableComponent` persisted
* new-row create path runtime . row-create
* `AutoGenerationUtils.generateValue()` . `numbering_rule` real
* code render / fetch / receive phase .
*/
autoGeneration?: AutoGenerationConfig;
} }
export interface TablePagination { export interface TablePagination {
enabled?: boolean; enabled?: boolean;
pageSize?: number; pageSize?: number;
/** 페이지 크기 변경 드롭다운 노출 (D.6 footer 에서 wiring) */
showSizeSelector?: boolean; showSizeSelector?: boolean;
/** "1-20 / 총 100건" 식 페이지 정보 텍스트 노출 (D.6) */
showPageInfo?: boolean;
/** 사용자가 선택할 수 있는 페이지 크기 옵션 (예: [10, 20, 50, 100]) */
pageSizeOptions?: number[]; pageSizeOptions?: number[];
/** 현재 페이지 (런타임 상태 미러링용 — useTableData 가 권위) */
currentPage?: number;
/** 페이지네이션 위치 — D.6 footer/header 분리 시 사용 */
position?: "top" | "bottom";
}
/**
* Phase C.5 (legacy `TableStyleConfig` )
*
* `alternateRows` / `hoverEffect` root `striped` / `hoverable` .
* ConfigPanel UI "스타일" CPGroup
* alias. runtime : `tableStyle.*` root `striped/hoverable` default.
*
* `theme` / `headerStyle` / `borderStyle` D.6 ( ) wiring.
*/
export interface TableStyleConfig {
theme?: "default" | "striped" | "bordered" | "minimal";
headerStyle?: "default" | "dark" | "light";
borderStyle?: "none" | "light" | "heavy";
/** root `striped` 별칭 (Phase C.5) — runtime 우선순위가 높음 */
alternateRows?: boolean;
/** root `hoverable` 별칭 (Phase C.5) — runtime 우선순위가 높음 */
hoverEffect?: boolean;
}
/**
* Phase C.3 (2026-05-20) (legacy `FilterConfig` ).
*
* / . `widgetType`
* UI (`text` / `number` / `date` / `select` / `entity` / `code` / `checkbox`) .
*
* C.3 config + ConfigPanel UI . + Phase D.2.
*/
export interface TableFilterConfig {
enabled: boolean;
filters: Array<{
columnName: string;
widgetType: string;
label: string;
gridColumns: number;
/** number 위젯일 때 단일 입력 / 범위 (min~max) 분기 */
numberFilterMode?: "exact" | "range";
/** code 위젯의 공통코드 그룹 키 */
codeInfo?: string;
/** entity / select 위젯의 참조 테이블 메타 */
referenceTable?: string;
referenceColumn?: string;
displayColumn?: string;
}>;
/** 필터 영역과 리스트 사이 간격 (px) */
bottomSpacing?: number;
}
/**
* Phase C.3 (2026-05-20) (legacy `LinkedFilterConfig` ).
*
* ( / / )
* . : 거래처 select customer_code .
*
* C.3 config + ConfigPanel . source / Phase D.2.
*/
export interface TableLinkedFilterConfig {
sourceComponentId: string;
sourceField?: string;
targetColumn: string;
operator?: "equals" | "contains" | "in";
enabled?: boolean;
}
/**
* Phase C.3 (2026-05-20) (legacy `ExcludeFilterConfig` ).
*
* row . : 거래처에
* .
*
* C.3 config + ConfigPanel . SQL `NOT EXISTS` sub-query Phase D.2.
*/
export interface TableExcludeFilterConfig {
enabled: boolean;
referenceTable: string;
referenceColumn: string;
sourceColumn: string;
filterColumn?: string;
filterValueSource?: "url" | "formData" | "parentData";
filterValueField?: string;
}
/**
* Phase C.4 (2026-05-20) (legacy `ActionConfig.actions[].type` ).
*
* `view` / `edit` (`targetScreen`) open, `delete` API,
* `custom` . wiring Phase D.4.
*/
export type TableActionType = "view" | "edit" | "delete" | "custom";
/**
* Phase C.4 (legacy `ActionConfig.actions[]` row ).
*
* . button + click (navigation /
* modal open / delete API / custom handler) Phase D.4 .
*/
export interface TableActionItemConfig {
type: TableActionType;
label: string;
/** lucide icon 이름 (예: "Eye" / "Pencil" / "Trash2") */
icon?: string;
/** primary / destructive / muted 등 의미 토큰 또는 hsl 값 */
color?: string;
/** delete 등 위험 액션의 확인 메시지 */
confirmMessage?: string;
/** view/edit 의 화면 이동 대상 (예: "/screen/123") */
targetScreen?: string;
}
/**
* Phase C.4 (legacy `ActionConfig` ).
*
* **Table-level row/bulk config.**
* (`cardStyle.showActions` / `cardStyle.show{View,Edit,Delete}Button` /
* `cardColumnMapping.actionColumns`) ** layer**.
* , `TableActionConfig` displayMode .
*
* C.4 config + ConfigPanel . + click + bulk
* wiring Phase D.4.
*/
export interface TableActionConfig {
/** 액션 컬럼 표시 여부 — D.4 에서 행 액션 영역 렌더 분기 */
showActions: boolean;
/** 각 행에 표시될 액션 버튼 목록 — 순서대로 렌더 (D.4) */
actions: TableActionItemConfig[];
/** 일괄 액션 영역 표시 여부 — 선택된 행들에 대한 일괄 작업 (D.4) */
bulkActions: boolean;
/** 일괄 액션 종류 목록 (예: ["delete", "export"]) — D.4 에서 매핑 */
bulkActionList: string[];
}
/**
* Phase C.5 (legacy `ToolbarConfig` )
*
* `showExcel` / `showRefresh` root (UI ). 6 D.6
* + wiring C.5 config .
*/
export interface TableToolbarConfig {
/** 즉시 저장 / 배치 모드 토글 (D.6) */
showEditMode?: boolean;
/** Excel 내보내기 (XLSX, D.6 dynamic import). root `showExcel` 의 별칭 */
showExcel?: boolean;
/** PDF 내보내기 (D.6) */
showPdf?: boolean;
/** 셀 / 행 복사 (clipboard, D.6) */
showCopy?: boolean;
/** 자체 검색바 (D.6) — 별도 Search 컴포넌트 사용 시 OFF */
showSearch?: boolean;
/** 자체 필터 패널 (D.6) */
showFilter?: boolean;
/** 상단 새로고침 버튼. root `showRefresh` 의 별칭 */
showRefresh?: boolean;
/** 페이지네이션 새로고침 버튼 (D.6 footer 영역) */
showPaginationRefresh?: boolean;
} }
// ─── card 모드 보조 타입 ───────────────────────────────────────────── // ─── card 모드 보조 타입 ─────────────────────────────────────────────
@@ -61,6 +313,8 @@ export interface TableCardStyleConfig {
showViewButton?: boolean; showViewButton?: boolean;
showEditButton?: boolean; showEditButton?: boolean;
showDeleteButton?: boolean; showDeleteButton?: boolean;
/** Phase D.7 — 카드 고정 높이. `"auto"` 또는 미지정이면 content 에 맞춤 */
cardHeight?: number | "auto";
} }
export interface TableCardColumnMapping { export interface TableCardColumnMapping {
@@ -72,6 +326,8 @@ export interface TableCardColumnMapping {
displayColumns?: string[]; displayColumns?: string[];
/** 액션 버튼 셀로 표시할 컬럼들 */ /** 액션 버튼 셀로 표시할 컬럼들 */
actionColumns?: string[]; actionColumns?: string[];
/** Phase D.7 — 카드 헤더 우측 작은 ID 배지에 사용 (legacy cardConfig.idColumn 흡수) */
idColumn?: string;
} }
// ─── pivot 모드 풍부한 필드 정의 ─────────────────────────────────── // ─── pivot 모드 풍부한 필드 정의 ───────────────────────────────────
@@ -337,8 +593,33 @@ export interface PivotGridProps {
} }
export interface TableConfig extends ComponentConfig { export interface TableConfig extends ComponentConfig {
/** 연결된 테이블명 (DB) */ /** 연결된 테이블명 (DB). canonical 정규 키. */
selectedTable?: string; selectedTable?: string;
/**
* layout JSON `tableName` fallback. canonical
* `selectedTable` , `tableName` fallback. ConfigPanel UI .
*/
tableName?: string;
/**
* (`screenTableName`) .
* true `customTableName` effective table . false
* `selectedTable` ( ) . (Phase C.1)
*/
useCustomTable?: boolean;
/** `useCustomTable === true` 일 때 effective table 로 쓰이는 테이블명. (Phase C.1) */
customTableName?: string;
/**
* ( ).
* / / UX . readonly
* Phase D.3 ( ) . config + UI . (Phase C.1)
*/
isReadOnly?: boolean;
/**
* ( true).
* false `useTableData` enabled=false fetch .
* search / `refreshTrigger` DataPort Phase D.6 wiring. (Phase C.1)
*/
autoLoad?: boolean;
/** 표시 모드 (기본/분할/그룹/피벗/카드) */ /** 표시 모드 (기본/분할/그룹/피벗/카드) */
displayMode?: TableDisplayMode; displayMode?: TableDisplayMode;
/** 컬럼 설정 */ /** 컬럼 설정 */
@@ -405,6 +686,13 @@ export interface TableConfig extends ComponentConfig {
cardStyle?: TableCardStyleConfig; cardStyle?: TableCardStyleConfig;
/** card 모드: 데이터 컬럼 → 카드 영역 매핑 */ /** card 모드: 데이터 컬럼 → 카드 영역 매핑 */
cardColumnMapping?: TableCardColumnMapping; cardColumnMapping?: TableCardColumnMapping;
/**
* Phase D.7 fallback (px).
* number: displayMode=table card (split/grouped/pivot X)
* `false` `0`: fallback
* 미지정: 기본 600
*/
responsiveCardBreakpoint?: number | false;
// ─── 빈 상태 / 로딩 ─── // ─── 빈 상태 / 로딩 ───
/** 빈 상태 메시지 */ /** 빈 상태 메시지 */
@@ -413,8 +701,94 @@ export interface TableConfig extends ComponentConfig {
// ─── 툴바 ─── // ─── 툴바 ───
/** 툴바 표시 */ /** 툴바 표시 */
showToolbar?: boolean; showToolbar?: boolean;
/** 엑셀 내보내기 버튼 */ /** 엑셀 내보내기 버튼 (legacy root key, `toolbar.showExcel` 와 alias) */
showExcel?: boolean; showExcel?: boolean;
/** 새로고침 버튼 */ /** 새로고침 버튼 (legacy root key, `toolbar.showRefresh` 와 alias) */
showRefresh?: boolean; showRefresh?: boolean;
// ─── Phase C.5 (2026-05-20) — 스타일 / 툴바 / 데이터 동작 확장 ───
/**
* . `alternateRows` / `hoverEffect` root `striped` / `hoverable`
* ConfigPanel "스타일" CPGroup . runtime :
* `tableStyle.alternateRows` `striped` true / `tableStyle.hoverEffect` `hoverable` true.
* theme / headerStyle / borderStyle D.6 wiring.
*/
tableStyle?: TableStyleConfig;
/**
* 8 . `showExcel` / `showRefresh` root . 6 (`showEditMode` /
* `showPdf` / `showCopy` / `showSearch` / `showFilter` / `showPaginationRefresh`)
* D.6 + wiring. C.5 config .
*/
toolbar?: TableToolbarConfig;
/**
* useTableData initialSortBy / initialSortOrder .
* (state). (Phase C.5)
*/
defaultSort?: {
columnName: string;
direction: "asc" | "desc";
};
/**
* ( , > 0 ). useEffect setInterval
* `tableData.refresh()` . skip. (Phase C.5)
*/
refreshInterval?: number;
// ─── Phase D.1 (2026-05-20) — 컬럼 시스템 runtime 옵션 (legacy `TableListConfig` 흡수) ───
/**
* / minColumnWidth fallback (content-fit)
* . false. sticky offset fallback . (Phase D.1)
*/
autoWidth?: boolean;
/**
* sticky ( true canonical ). false .
* (Phase D.1)
*/
stickyHeader?: boolean;
/**
* + . `enabled` true `<table>` `minWidth`
* effective `maxVisibleColumns * minColumnWidth`
* . `maxVisibleColumns` ** cap **
* ( ) . (Phase D.1)
*/
horizontalScroll?: {
enabled?: boolean;
maxVisibleColumns?: number;
minColumnWidth?: number;
maxColumnWidth?: number;
};
// ─── Phase C.3 (2026-05-20) — 필터 config parity (runtime 적용은 Phase D.2) ───
/**
* ( ) .
* C.3 ConfigPanel . AdvancedSearchFilters + `tableData.search` D.2.
*/
filter?: TableFilterConfig;
/**
* ( ) .
* C.3 config . source + search params D.2.
*/
linkedFilters?: TableLinkedFilterConfig[];
/**
* row (sub-query / NOT EXISTS).
* C.3 config . / API D.2.
*/
excludeFilter?: TableExcludeFilterConfig;
/**
* `DataFilterConfig` 14 operator (equals / in / between /
* date_range_contains ) `match_type` (all/any) row .
* `screen-management.ts` `DataFilterConfig` / `ColumnFilter` (snake_case ).
* C.3 config + ConfigPanel . D.2.
*/
dataFilter?: DataFilterConfig;
// ─── Phase C.4 (2026-05-20) — 액션 config parity (runtime 적용은 Phase D.4) ───
/**
* Table-level row/bulk view/edit/delete/custom row + .
* (`cardStyle.showActions` / `cardStyle.show{View,Edit,Delete}Button` /
* `cardColumnMapping.actionColumns`) ** layer** ( vs ).
* C.4 config + ConfigPanel . + click (navigation / modal /
* delete API / custom handler) + bulk wiring Phase D.4.
*/
actions?: TableActionConfig;
} }
@@ -1,14 +1,28 @@
"use client"; "use client";
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import type { DataFilterConfig } from "@/types/screen-management";
/** /**
* useTableData table fetch * useTableData table fetch
* *
* entityJoinApi.getTableDataWithJoins() . * entityJoinApi.getTableDataWithJoins() .
* , , . * , , .
*
* Phase D.2 (2026-05-20) `dataFilter` / `excludeFilter` entityJoinApi .
* ref memoize
* stable JSON string dep ref fetch .
*/ */
export interface ExcludeFilterPayload {
enabled: boolean;
referenceTable: string;
referenceColumn: string;
sourceColumn: string;
filterColumn?: string;
filterValue?: any;
}
export interface UseTableDataParams { export interface UseTableDataParams {
tableName?: string; tableName?: string;
page?: number; page?: number;
@@ -17,6 +31,10 @@ export interface UseTableDataParams {
sortOrder?: "asc" | "desc"; sortOrder?: "asc" | "desc";
search?: Record<string, any>; search?: Record<string, any>;
enabled?: boolean; // false면 fetch 안 함 (디자인 모드) enabled?: boolean; // false면 fetch 안 함 (디자인 모드)
/** D.2 — enabled 일 때만 entityJoinApi 에 전달 */
dataFilter?: DataFilterConfig;
/** D.2 — enabled 일 때만 entityJoinApi 에 전달 */
excludeFilter?: ExcludeFilterPayload;
} }
export interface UseTableDataResult { export interface UseTableDataResult {
@@ -36,6 +54,11 @@ export interface UseTableDataResult {
toggleSort: (col: string) => void; toggleSort: (col: string) => void;
setSearch: (s: Record<string, any>) => void; setSearch: (s: Record<string, any>) => void;
refresh: () => void; refresh: () => void;
/**
* Phase D.9 (2026-05-20) DataReceivable.receiveData() local data override.
* append/replace/merge . fetch refresh . totalOverride length .
*/
setLocalData: (next: Record<string, any>[], totalOverride?: number) => void;
} }
export function useTableData(params: UseTableDataParams): UseTableDataResult { export function useTableData(params: UseTableDataParams): UseTableDataResult {
@@ -47,8 +70,21 @@ export function useTableData(params: UseTableDataParams): UseTableDataResult {
sortOrder: initialSortOrder = "desc", sortOrder: initialSortOrder = "desc",
search: externalSearch, search: externalSearch,
enabled = true, enabled = true,
dataFilter,
excludeFilter,
} = params; } = params;
// D.2 — dataFilter / excludeFilter 객체 ref 가 매 렌더마다 신규여도 dep 으로 안 잡히도록
// stable JSON string 으로 변환해 fetchData dep 으로 사용. 호출자 책임 보강.
const dataFilterJson = useMemo(
() => (dataFilter && (dataFilter as any).enabled ? JSON.stringify(dataFilter) : null),
[dataFilter],
);
const excludeFilterJson = useMemo(
() => (excludeFilter && excludeFilter.enabled ? JSON.stringify(excludeFilter) : null),
[excludeFilter],
);
const [data, setData] = useState<Record<string, any>[]>([]); const [data, setData] = useState<Record<string, any>[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
@@ -59,14 +95,29 @@ export function useTableData(params: UseTableDataParams): UseTableDataResult {
const [search, setSearch] = useState<Record<string, any>>(externalSearch || {}); const [search, setSearch] = useState<Record<string, any>>(externalSearch || {});
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const refreshKey = useRef(0); const initialStateRef = useRef({
tableName,
page: initialPage,
pageSize: initialPageSize,
sortBy: initialSortBy,
sortOrder: initialSortOrder,
});
// 외부 검색 조건 동기화 // 외부 검색 조건 동기화.
// D.2: 필터를 모두 clear 해서 externalSearch 가 undefined 로 바뀐 경우에도
// 내부 search state 를 비워야 stale 검색 조건이 남지 않는다.
useEffect(() => { useEffect(() => {
if (externalSearch) { const nextSearch = externalSearch || {};
setSearch(externalSearch); setSearch((prev) => {
setPage(1); const prevKeys = Object.keys(prev);
} const nextKeys = Object.keys(nextSearch);
const changed =
prevKeys.length !== nextKeys.length ||
nextKeys.some((key) => prev[key] !== nextSearch[key]);
if (!changed) return prev;
return nextSearch;
});
setPage(1);
}, [externalSearch]); }, [externalSearch]);
// 데이터 fetch // 데이터 fetch
@@ -87,6 +138,11 @@ export function useTableData(params: UseTableDataParams): UseTableDataResult {
sortOrder, sortOrder,
search: Object.keys(search).length > 0 ? search : undefined, search: Object.keys(search).length > 0 ? search : undefined,
enableEntityJoin: true, enableEntityJoin: true,
// D.2 — JSON 으로 변환된 stable string 이 dep 이지만 실제 payload 는 원본 객체 사용.
dataFilter:
dataFilter && (dataFilter as any).enabled ? dataFilter : undefined,
excludeFilter:
excludeFilter && excludeFilter.enabled ? excludeFilter : undefined,
}); });
setData(response.data || []); setData(response.data || []);
@@ -101,18 +157,51 @@ export function useTableData(params: UseTableDataParams): UseTableDataResult {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [tableName, page, pageSize, sortBy, sortOrder, search, enabled, refreshKey.current]); // dataFilter / excludeFilter 객체 ref 가 아닌 *Json string 만 dep — fetch 폭주 방지
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
tableName,
page,
pageSize,
sortBy,
sortOrder,
search,
enabled,
dataFilterJson,
excludeFilterJson,
]);
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
}, [fetchData]); }, [fetchData]);
// 테이블 변경 시 페이지 리셋 // 테이블 / config 초기값 변경 시 런타임 상태 동기화.
// 초기 mount 에서는 useState(initial*) 값이 이미 권위이므로 reset 하지 않는다.
useEffect(() => { useEffect(() => {
setPage(1); const prev = initialStateRef.current;
setSortBy(""); const changed =
setSearch({}); prev.tableName !== tableName ||
}, [tableName]); prev.page !== initialPage ||
prev.pageSize !== initialPageSize ||
prev.sortBy !== initialSortBy ||
prev.sortOrder !== initialSortOrder;
if (!changed) return;
initialStateRef.current = {
tableName,
page: initialPage,
pageSize: initialPageSize,
sortBy: initialSortBy,
sortOrder: initialSortOrder,
};
setPage(initialPage);
setPageSize(initialPageSize);
setSortBy(initialSortBy);
setSortOrder(initialSortOrder);
setSearch(externalSearch || {});
}, [tableName, initialPage, initialPageSize, initialSortBy, initialSortOrder, externalSearch]);
const toggleSort = useCallback((col: string) => { const toggleSort = useCallback((col: string) => {
setSortBy((prev) => { setSortBy((prev) => {
@@ -126,10 +215,28 @@ export function useTableData(params: UseTableDataParams): UseTableDataResult {
}, []); }, []);
const refresh = useCallback(() => { const refresh = useCallback(() => {
refreshKey.current += 1;
fetchData(); fetchData();
}, [fetchData]); }, [fetchData]);
const setPageSizeAction = useCallback((s: number) => {
setPageSize(s);
setPage(1);
}, []);
const setSearchAction = useCallback((s: Record<string, any>) => {
setSearch(s);
setPage(1);
}, []);
const setLocalData = useCallback((next: Record<string, any>[], totalOverride?: number) => {
const arr = Array.isArray(next) ? next : [];
setData(arr);
const t = typeof totalOverride === "number" && totalOverride >= 0 ? totalOverride : arr.length;
setTotal(t);
const ps = pageSize > 0 ? pageSize : 20;
setTotalPages(Math.max(1, Math.ceil(t / ps)));
}, [pageSize]);
return { return {
data, data,
total, total,
@@ -141,10 +248,12 @@ export function useTableData(params: UseTableDataParams): UseTableDataResult {
loading, loading,
error, error,
setPage, setPage,
setPageSize: (s: number) => { setPageSize(s); setPage(1); }, setPageSize: setPageSizeAction,
setSortBy, setSortBy,
toggleSort, toggleSort,
setSearch: (s: Record<string, any>) => { setSearch(s); setPage(1); }, setSearch: setSearchAction,
refresh, refresh,
// Phase D.9 — 외부 receiveData() 가 local override. 다음 fetch 까지 유지.
setLocalData,
}; };
} }
@@ -1,8 +1,15 @@
"use client"; "use client";
import React from "react"; import React, { useMemo } from "react";
import { Eye, Pencil, Trash2 } from "lucide-react"; import { Eye, Pencil, Trash2 } from "lucide-react";
import type { TableConfig, TableCardStyleConfig } from "../types"; import type {
TableColumn,
TableConfig,
TableCardStyleConfig,
} from "../types";
import { getFullImageUrl } from "@/lib/api/client";
import { getFilePreviewUrl } from "@/lib/api/file";
import { renderTableCellValue } from "../cell-renderers";
export interface CardViewProps { export interface CardViewProps {
config: TableConfig; config: TableConfig;
@@ -12,6 +19,10 @@ export interface CardViewProps {
onView?: (row: any) => void; onView?: (row: any) => void;
onEdit?: (row: any) => void; onEdit?: (row: any) => void;
onDelete?: (row: any) => void; onDelete?: (row: any) => void;
/** Phase D.7 — canonical columns 메타. inference / D.5 cell formatting / display 라벨 위해. */
columns?: TableColumn[];
/** Phase D.7 — column 라벨 (langKey 번역 반영) */
getColumnLabel?: (col: TableColumn) => string;
} }
const DEFAULT_STYLE: Required<TableCardStyleConfig> = { const DEFAULT_STYLE: Required<TableCardStyleConfig> = {
@@ -26,6 +37,7 @@ const DEFAULT_STYLE: Required<TableCardStyleConfig> = {
showViewButton: false, showViewButton: false,
showEditButton: false, showEditButton: false,
showDeleteButton: false, showDeleteButton: false,
cardHeight: "auto",
}; };
const IMAGE_SIZE_PX: Record<NonNullable<TableCardStyleConfig["imageSize"]>, number> = { const IMAGE_SIZE_PX: Record<NonNullable<TableCardStyleConfig["imageSize"]>, number> = {
@@ -34,11 +46,67 @@ const IMAGE_SIZE_PX: Record<NonNullable<TableCardStyleConfig["imageSize"]>, numb
large: 200, large: 200,
}; };
/**
* Phase D.7 image inference: key image/photo/thumbnail
* inputType/format "image".
*/
function _isImageColumn(c: TableColumn): boolean {
const key = (c.key || "").toLowerCase();
if (c.inputType === "image" || c.format === "image") return true;
return /image|photo|thumbnail/.test(key);
}
/**
* Phase D.7 file/attachment (image ): key attachment/file
* inputType/format "file"/"attachment".
*/
function _isFileColumn(c: TableColumn): boolean {
const key = (c.key || "").toLowerCase();
if (
c.inputType === "file" ||
c.inputType === "attachment" ||
c.format === "file" ||
c.format === "attachment"
)
return true;
return (
key.includes("attachment") ||
/(^|[_-])files?($|[_-])/.test(key)
);
}
/**
* Phase D.7 URL .
* - http(s):// → 그대로 (getFullImageUrl 가 그대로 반환)
* - objid getFilePreviewUrl
* - path getFullImageUrl
*/
function _normalizeImageUrl(raw: any): string | null {
if (raw === null || raw === undefined) return null;
const s = String(raw).trim();
if (!s) return null;
// 콤마 구분 시 첫 값만 사용
const first = s.includes(",") ? s.split(",")[0].trim() : s;
if (!first) return null;
if (/^\d+$/.test(first)) {
return getFilePreviewUrl(first);
}
return getFullImageUrl(first);
}
/** /**
* CardView displayMode="card" * CardView displayMode="card"
* *
* . config.cardColumnMapping * . `config.cardColumnMapping`
* (title/subtitle/description/image) . * (title/subtitle/description/image/displayColumns) .
*
* Phase D.7 (2026-05-20) legacy :
* - `cardColumnMapping` columns inference (title/subtitle/description/image)
* - legacy `cardConfig.{titleColumn, idColumn, cardHeight}` fallback
* - URL normalize (objid getFilePreviewUrl, path getFullImageUrl, http(s) )
* - displayColumns D.5 `renderTableCellValue` ( / )
* - idColumn ID
* - cardHeight number
*/ */
export function CardView({ export function CardView({
config, config,
@@ -48,6 +116,8 @@ export function CardView({
onView, onView,
onEdit, onEdit,
onDelete, onDelete,
columns,
getColumnLabel,
}: CardViewProps) { }: CardViewProps) {
const cardsPerRow = config.cardsPerRow ?? 3; const cardsPerRow = config.cardsPerRow ?? 3;
const cardSpacing = config.cardSpacing ?? 12; const cardSpacing = config.cardSpacing ?? 12;
@@ -55,7 +125,69 @@ export function CardView({
...DEFAULT_STYLE, ...DEFAULT_STYLE,
...(config.cardStyle ?? {}), ...(config.cardStyle ?? {}),
}; };
const mapping = config.cardColumnMapping ?? {};
// Phase D.7 — mapping fallback (canonical → legacy cardConfig)
const legacyCardConfig =
(config as any).cardConfig && typeof (config as any).cardConfig === "object"
? ((config as any).cardConfig as Record<string, any>)
: {};
const explicitMapping = config.cardColumnMapping ?? {};
// Phase D.7 — inference (mapping 비어있을 때 columns 사용)
const inferred = useMemo(() => {
const cols = Array.isArray(columns) ? columns : [];
const nonMedia = cols.filter(
(c) => !_isImageColumn(c) && !_isFileColumn(c),
);
const imageCol = cols.find(_isImageColumn);
return {
titleColumn: nonMedia[0]?.key,
subtitleColumn: nonMedia[1]?.key,
descriptionColumn: nonMedia[2]?.key,
imageColumn: imageCol?.key,
};
}, [columns]);
const mapping = useMemo(
() => ({
titleColumn:
explicitMapping.titleColumn ||
(legacyCardConfig.titleColumn as string | undefined) ||
inferred.titleColumn,
subtitleColumn:
explicitMapping.subtitleColumn ||
(legacyCardConfig.subtitleColumn as string | undefined) ||
inferred.subtitleColumn,
descriptionColumn:
explicitMapping.descriptionColumn ||
(legacyCardConfig.descriptionColumn as string | undefined) ||
inferred.descriptionColumn,
imageColumn:
explicitMapping.imageColumn ||
(legacyCardConfig.imageColumn as string | undefined) ||
inferred.imageColumn,
idColumn:
explicitMapping.idColumn ||
(legacyCardConfig.idColumn as string | undefined),
displayColumns: explicitMapping.displayColumns,
}),
[explicitMapping, legacyCardConfig, inferred],
);
// Phase D.7 — legacy cardHeight (cardConfig.cardHeight) fallback
const cardHeight =
style.cardHeight !== "auto" && style.cardHeight !== undefined
? style.cardHeight
: (legacyCardConfig.cardHeight as number | undefined);
// Phase D.7 — column 메타 lookup (displayColumns 의 cell renderer 용)
const columnByKey = useMemo(() => {
const m = new Map<string, TableColumn>();
if (Array.isArray(columns)) {
for (const c of columns) m.set(c.key, c);
}
return m;
}, [columns]);
if (data.length === 0) { if (data.length === 0) {
return <div style={emptyStyle}>{config.emptyMessage || "데이터 없음"}</div>; return <div style={emptyStyle}>{config.emptyMessage || "데이터 없음"}</div>;
@@ -78,6 +210,9 @@ export function CardView({
row={row} row={row}
mapping={mapping} mapping={mapping}
style={style} style={style}
cardHeight={typeof cardHeight === "number" ? cardHeight : undefined}
columnByKey={columnByKey}
getColumnLabel={getColumnLabel}
onClick={onCardClick ? () => onCardClick(row) : undefined} onClick={onCardClick ? () => onCardClick(row) : undefined}
onView={style.showActions && style.showViewButton ? () => onView?.(row) : undefined} onView={style.showActions && style.showViewButton ? () => onView?.(row) : undefined}
onEdit={style.showActions && style.showEditButton ? () => onEdit?.(row) : undefined} onEdit={style.showActions && style.showEditButton ? () => onEdit?.(row) : undefined}
@@ -91,8 +226,18 @@ export function CardView({
interface CardItemProps { interface CardItemProps {
row: any; row: any;
mapping: NonNullable<TableConfig["cardColumnMapping"]>; mapping: {
titleColumn?: string;
subtitleColumn?: string;
descriptionColumn?: string;
imageColumn?: string;
idColumn?: string;
displayColumns?: string[];
};
style: Required<TableCardStyleConfig>; style: Required<TableCardStyleConfig>;
cardHeight?: number;
columnByKey: Map<string, TableColumn>;
getColumnLabel?: (col: TableColumn) => string;
isDesignMode: boolean; isDesignMode: boolean;
onClick?: () => void; onClick?: () => void;
onView?: () => void; onView?: () => void;
@@ -100,15 +245,34 @@ interface CardItemProps {
onDelete?: () => void; onDelete?: () => void;
} }
function CardItem({ row, mapping, style, isDesignMode, onClick, onView, onEdit, onDelete }: CardItemProps) { function CardItem({
row,
mapping,
style,
cardHeight,
columnByKey,
getColumnLabel,
isDesignMode,
onClick,
onView,
onEdit,
onDelete,
}: CardItemProps) {
const [imgError, setImgError] = React.useState(false);
const title = mapping.titleColumn ? row?.[mapping.titleColumn] : undefined; const title = mapping.titleColumn ? row?.[mapping.titleColumn] : undefined;
const subtitle = mapping.subtitleColumn ? row?.[mapping.subtitleColumn] : undefined; const subtitle = mapping.subtitleColumn ? row?.[mapping.subtitleColumn] : undefined;
const descriptionRaw = mapping.descriptionColumn ? row?.[mapping.descriptionColumn] : undefined; const descriptionRaw = mapping.descriptionColumn ? row?.[mapping.descriptionColumn] : undefined;
const image = mapping.imageColumn ? row?.[mapping.imageColumn] : undefined; const imageRaw = mapping.imageColumn ? row?.[mapping.imageColumn] : undefined;
const idValue = mapping.idColumn ? row?.[mapping.idColumn] : undefined;
React.useEffect(() => {
setImgError(false);
}, [mapping.imageColumn, imageRaw]);
const description = const description =
typeof descriptionRaw === "string" && descriptionRaw.length > style.maxDescriptionLength typeof descriptionRaw === "string" && descriptionRaw.length > style.maxDescriptionLength
? `${descriptionRaw.slice(0, style.maxDescriptionLength)}` ? `${descriptionRaw.slice(0, style.maxDescriptionLength)}`
: descriptionRaw; : descriptionRaw;
const imageUrl = _normalizeImageUrl(imageRaw);
const imagePx = IMAGE_SIZE_PX[style.imageSize]; const imagePx = IMAGE_SIZE_PX[style.imageSize];
const isHorizontal = style.imagePosition === "left" || style.imagePosition === "right"; const isHorizontal = style.imagePosition === "left" || style.imagePosition === "right";
@@ -121,45 +285,135 @@ function CardItem({ row, mapping, style, isDesignMode, onClick, onView, onEdit,
cursor: onClick ? "pointer" : "default", cursor: onClick ? "pointer" : "default",
display: isHorizontal ? "flex" : "block", display: isHorizontal ? "flex" : "block",
flexDirection: style.imagePosition === "right" ? "row-reverse" : "row", flexDirection: style.imagePosition === "right" ? "row-reverse" : "row",
...(typeof cardHeight === "number" && cardHeight > 0
? { height: `${cardHeight}px` }
: {}),
}; };
return ( return (
<div style={cardStyle} onClick={onClick}> <div style={cardStyle} onClick={onClick}>
{style.showImage && image && ( {style.showImage && imageUrl && !imgError && (
<div <div
style={{ style={{
width: isHorizontal ? imagePx : "100%", width: isHorizontal ? imagePx : "100%",
height: imagePx, height: imagePx,
background: "hsl(var(--muted))", background: "hsl(var(--muted))",
flexShrink: 0, flexShrink: 0,
backgroundImage: `url(${image})`, position: "relative",
backgroundSize: "cover", overflow: "hidden",
backgroundPosition: "center",
}} }}
/> >
<img
src={imageUrl}
alt=""
onError={() => setImgError(true)}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
</div>
)} )}
<div style={{ padding: 12, flex: 1, minWidth: 0 }}> {style.showImage && imageRaw && imgError && (
<div
style={{
width: isHorizontal ? imagePx : "100%",
height: imagePx,
background: "hsl(var(--muted))",
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "hsl(var(--muted-foreground))",
fontSize: 11,
}}
>
</div>
)}
<div
style={{
padding: 12,
flex: 1,
minWidth: 0,
display: "flex",
flexDirection: "column",
gap: 4,
overflow: "hidden",
}}
>
{(idValue !== undefined && idValue !== null && idValue !== "") && (
// Phase D.7 — idColumn 배지 (헤더 영역 우측)
<div
style={{
display: "flex",
justifyContent: "flex-end",
marginBottom: 2,
}}
>
<span
style={{
fontSize: 10,
color: "hsl(var(--muted-foreground))",
background: "hsl(var(--muted))",
borderRadius: 3,
padding: "1px 6px",
}}
title={mapping.idColumn}
>
{String(idValue)}
</span>
</div>
)}
{style.showTitle && title !== undefined && title !== null && title !== "" && ( {style.showTitle && title !== undefined && title !== null && title !== "" && (
<div style={{ fontSize: 13, fontWeight: 700, marginBottom: 4 }}>{String(title)}</div> <div style={{ fontSize: 13, fontWeight: 700 }}>{String(title)}</div>
)} )}
{style.showSubtitle && subtitle !== undefined && subtitle !== null && subtitle !== "" && ( {style.showSubtitle && subtitle !== undefined && subtitle !== null && subtitle !== "" && (
<div style={{ fontSize: 11, color: "hsl(var(--muted-foreground))", marginBottom: 6 }}> <div style={{ fontSize: 11, color: "hsl(var(--muted-foreground))" }}>
{String(subtitle)} {String(subtitle)}
</div> </div>
)} )}
{style.showDescription && description !== undefined && description !== null && description !== "" && ( {style.showDescription && description !== undefined && description !== null && description !== "" && (
<div style={{ fontSize: 12, lineHeight: 1.5, marginBottom: style.showActions ? 8 : 0 }}> <div style={{ fontSize: 12, lineHeight: 1.5 }}>{String(description)}</div>
{String(description)}
</div>
)} )}
{(mapping.displayColumns?.length ?? 0) > 0 && ( {(mapping.displayColumns?.length ?? 0) > 0 && (
<div style={{ marginTop: 6, fontSize: 11, color: "hsl(var(--muted-foreground))" }}> <div
{mapping.displayColumns!.map((col) => ( style={{
<div key={col} style={{ display: "flex", gap: 6 }}> marginTop: 6,
<span style={{ fontWeight: 500 }}>{col}:</span> fontSize: 11,
<span>{row?.[col] !== undefined && row?.[col] !== null ? String(row[col]) : "-"}</span> color: "hsl(var(--muted-foreground))",
</div> display: "flex",
))} flexDirection: "column",
gap: 2,
}}
>
{mapping.displayColumns!.map((colKey) => {
const col = columnByKey.get(colKey);
const label =
col && getColumnLabel ? getColumnLabel(col) : col?.label ?? colKey;
const cellValue = row?.[colKey];
// Phase D.7 — D.5 cell formatting 사용. col 메타 없으면 raw string.
const rendered = col
? renderTableCellValue({
value: cellValue,
column: col,
row,
isDesignMode,
})
: cellValue !== undefined && cellValue !== null
? String(cellValue)
: "-";
return (
<div key={colKey} style={{ display: "flex", gap: 6, minWidth: 0 }}>
<span style={{ fontWeight: 500, flexShrink: 0 }}>{label}:</span>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{rendered}
</span>
</div>
);
})}
</div> </div>
)} )}
{style.showActions && (onView || onEdit || onDelete) && ( {style.showActions && (onView || onEdit || onDelete) && (
@@ -175,11 +429,15 @@ function CardItem({ row, mapping, style, isDesignMode, onClick, onView, onEdit,
)} )}
</div> </div>
)} )}
{isDesignMode && Object.keys(mapping).length === 0 && ( {isDesignMode &&
<div style={{ fontSize: 10.5, color: "hsl(var(--muted-foreground))" }}> !mapping.titleColumn &&
[ ] . !mapping.subtitleColumn &&
</div> !mapping.descriptionColumn &&
)} !mapping.imageColumn && (
<div style={{ fontSize: 10.5, color: "hsl(var(--muted-foreground))" }}>
[ ] .
</div>
)}
</div> </div>
</div> </div>
); );
@@ -3,6 +3,8 @@
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { ChevronRight, ChevronDown } from "lucide-react"; import { ChevronRight, ChevronDown } from "lucide-react";
import type { TableConfig, TableColumn } from "../types"; import type { TableConfig, TableColumn } from "../types";
import type { GroupSumConfig } from "@/types/table-options";
import { renderTableCellValue } from "../cell-renderers";
export interface GroupedViewProps { export interface GroupedViewProps {
config: TableConfig; config: TableConfig;
@@ -11,13 +13,25 @@ export interface GroupedViewProps {
rowHeightPx?: string; rowHeightPx?: string;
isDesignMode?: boolean; isDesignMode?: boolean;
onRowClick?: (row: any) => void; onRowClick?: (row: any) => void;
/** Phase D.8 — TableOptions UI 가 set 한 grouping 컬럼 list. 첫 컬럼만 사용 (deep nesting 보류). */
groupByColumns?: string[];
/** Phase D.8 — group-sum 활성화 시 group 헤더에 sum/avg/count 표시 */
groupSumConfig?: GroupSumConfig | null;
/** Phase D.8 — column 라벨 (langKey 번역) */
getColumnLabel?: (col: TableColumn) => string;
} }
/** /**
* GroupedView displayMode="grouped" * GroupedView displayMode="grouped"
* *
* config.groupBy / . * / .
* + . . * + . .
*
* Phase D.8 (2026-05-20) groupByColumns / groupSumConfig :
* - groupByColumns (TableOptions UI set), config.groupBy
* - groupSumConfig.enabled numeric column sum/avg/count
* - footer row (groupSum enabled )
* - D.5 renderTableCellValue (image/file/entity/number/date/boolean)
*/ */
export function GroupedView({ export function GroupedView({
config, config,
@@ -26,8 +40,28 @@ export function GroupedView({
rowHeightPx = "36px", rowHeightPx = "36px",
isDesignMode = false, isDesignMode = false,
onRowClick, onRowClick,
groupByColumns,
groupSumConfig,
getColumnLabel,
}: GroupedViewProps) { }: GroupedViewProps) {
const groupBy = config.groupBy; // Phase D.8 — groupByColumns 첫 컬럼 우선, 없으면 config.groupBy.
const groupBy =
Array.isArray(groupByColumns) && groupByColumns.length > 0
? groupByColumns[0]
: config.groupBy;
// numeric column 식별
const numericColumnKeys = useMemo<string[]>(() => {
return columns
.filter((c) => {
if (c.key === groupBy) return false;
if (c.inputType === "number" || c.inputType === "decimal") return true;
if (c.dataType === "number" || c.dataType === "decimal") return true;
if (c.format === "number" || c.format === "currency") return true;
return false;
})
.map((c) => c.key);
}, [columns, groupBy]);
const groups = useMemo<Array<{ key: string; rows: any[] }>>(() => { const groups = useMemo<Array<{ key: string; rows: any[] }>>(() => {
if (!groupBy) return [{ key: "(전체)", rows: data }]; if (!groupBy) return [{ key: "(전체)", rows: data }];
@@ -42,6 +76,59 @@ export function GroupedView({
return Array.from(map.entries()).map(([key, rows]) => ({ key, rows })); return Array.from(map.entries()).map(([key, rows]) => ({ key, rows }));
}, [data, groupBy]); }, [data, groupBy]);
// Phase D.8 — 그룹별 numeric summary (sum/avg/count) 계산. group-sum enabled 시만.
const groupSumEnabled = !!groupSumConfig?.enabled;
const summaryByGroup = useMemo(() => {
if (!groupSumEnabled || numericColumnKeys.length === 0) return new Map<string, Record<string, { sum: number; avg: number; count: number }>>();
const out = new Map<string, Record<string, { sum: number; avg: number; count: number }>>();
for (const { key, rows } of groups) {
const summary: Record<string, { sum: number; avg: number; count: number }> = {};
for (const colKey of numericColumnKeys) {
let sum = 0;
let count = 0;
for (const row of rows) {
const v = row?.[colKey];
const n = typeof v === "number" ? v : Number(v);
if (Number.isFinite(n)) {
sum += n;
count += 1;
}
}
summary[colKey] = {
sum,
avg: count > 0 ? sum / count : 0,
count,
};
}
out.set(key, summary);
}
return out;
}, [groups, numericColumnKeys, groupSumEnabled]);
// 전체 합계 (grand total)
const grandTotal = useMemo(() => {
if (!groupSumEnabled || numericColumnKeys.length === 0) return null;
const summary: Record<string, { sum: number; avg: number; count: number }> = {};
for (const colKey of numericColumnKeys) {
let sum = 0;
let count = 0;
for (const row of data) {
const v = row?.[colKey];
const n = typeof v === "number" ? v : Number(v);
if (Number.isFinite(n)) {
sum += n;
count += 1;
}
}
summary[colKey] = {
sum,
avg: count > 0 ? sum / count : 0,
count,
};
}
return summary;
}, [data, numericColumnKeys, groupSumEnabled]);
const [collapsedKeys, setCollapsedKeys] = useState<Set<string>>(new Set()); const [collapsedKeys, setCollapsedKeys] = useState<Set<string>>(new Set());
const toggle = (key: string) => { const toggle = (key: string) => {
setCollapsedKeys((prev) => { setCollapsedKeys((prev) => {
@@ -64,6 +151,25 @@ export function GroupedView({
return <div style={emptyStyle}>{config.emptyMessage || "데이터 없음"}</div>; return <div style={emptyStyle}>{config.emptyMessage || "데이터 없음"}</div>;
} }
// 그룹 헤더의 sum hint 텍스트 — numeric 컬럼들의 sum 을 짧게.
const formatSumHint = (
summary: Record<string, { sum: number; avg: number; count: number }>,
): string => {
const parts: string[] = [];
for (const colKey of numericColumnKeys) {
const s = summary[colKey];
if (!s || s.count === 0) continue;
const col = columns.find((c) => c.key === colKey);
const label = col && getColumnLabel ? getColumnLabel(col) : col?.label ?? colKey;
const display =
col?.thousandSeparator === false
? String(s.sum)
: s.sum.toLocaleString("ko-KR");
parts.push(`${label}: ${display}`);
}
return parts.join(" · ");
};
return ( return (
<div style={{ overflow: "auto", flex: 1 }}> <div style={{ overflow: "auto", flex: 1 }}>
<table style={tableStyle}> <table style={tableStyle}>
@@ -79,7 +185,7 @@ export function GroupedView({
textAlign: col.align || "left", textAlign: col.align || "left",
}} }}
> >
{col.label || col.key} {getColumnLabel ? getColumnLabel(col) : col.label || col.key}
</th> </th>
))} ))}
</tr> </tr>
@@ -87,6 +193,7 @@ export function GroupedView({
<tbody> <tbody>
{groups.map(({ key, rows }) => { {groups.map(({ key, rows }) => {
const collapsed = collapsedKeys.has(key); const collapsed = collapsedKeys.has(key);
const summary = summaryByGroup.get(key);
return ( return (
<React.Fragment key={key}> <React.Fragment key={key}>
<tr style={groupHeaderRowStyle} onClick={() => toggle(key)}> <tr style={groupHeaderRowStyle} onClick={() => toggle(key)}>
@@ -105,6 +212,19 @@ export function GroupedView({
<span style={{ color: "hsl(var(--muted-foreground))", fontSize: 11 }}> <span style={{ color: "hsl(var(--muted-foreground))", fontSize: 11 }}>
({rows.length}) ({rows.length})
</span> </span>
{/* Phase D.8 — group summary inline */}
{summary && (
<span
style={{
marginLeft: 8,
color: "hsl(var(--primary))",
fontSize: 11,
fontWeight: 500,
}}
>
{formatSumHint(summary)}
</span>
)}
</td> </td>
</tr> </tr>
{!collapsed && {!collapsed &&
@@ -120,32 +240,114 @@ export function GroupedView({
key={col.key} key={col.key}
style={{ ...tdStyle, textAlign: col.align || "left" }} style={{ ...tdStyle, textAlign: col.align || "left" }}
> >
{formatCell(row?.[col.key], col.format)} {renderTableCellValue({
value: row?.[col.key],
column: col,
row,
isDesignMode,
})}
</td> </td>
))} ))}
</tr> </tr>
))} ))}
{/* Phase D.8 — group subtotal row (group-sum enabled + 펼친 상태 + numeric 컬럼 ≥1) */}
{!collapsed && summary && numericColumnKeys.length > 0 && (
<tr style={groupSubtotalRowStyle}>
<td style={tdStyle}></td>
{columns.map((col) => {
const s = summary[col.key];
if (!s || s.count === 0) {
return (
<td
key={col.key}
style={{ ...tdStyle, color: "hsl(var(--muted-foreground))" }}
/>
);
}
const display =
col.thousandSeparator === false
? String(s.sum)
: s.sum.toLocaleString("ko-KR");
return (
<td
key={col.key}
style={{
...tdStyle,
textAlign: col.align || "right",
fontWeight: 700,
color: "hsl(var(--primary))",
}}
title={`합계: ${display} / 평균: ${
col.thousandSeparator === false
? String(s.avg)
: s.avg.toLocaleString("ko-KR", { maximumFractionDigits: 2 })
} / 건수: ${s.count}`}
>
{display}
</td>
);
})}
</tr>
)}
</React.Fragment> </React.Fragment>
); );
})} })}
{/* Phase D.8 — grand total row */}
{grandTotal && numericColumnKeys.length > 0 && (
<tr style={grandTotalRowStyle}>
<td style={tdStyle}></td>
{columns.map((col) => {
const s = grandTotal[col.key];
if (!s || s.count === 0) {
if (col === columns[0]) {
return (
<td
key={col.key}
style={{
...tdStyle,
fontWeight: 700,
color: "hsl(var(--foreground))",
}}
>
</td>
);
}
return <td key={col.key} style={tdStyle} />;
}
const display =
col.thousandSeparator === false
? String(s.sum)
: s.sum.toLocaleString("ko-KR");
return (
<td
key={col.key}
style={{
...tdStyle,
textAlign: col.align || "right",
fontWeight: 700,
color: "hsl(var(--foreground))",
background: "hsl(var(--muted) / 0.4)",
}}
>
{display}
</td>
);
})}
</tr>
)}
</tbody> </tbody>
</table> </table>
{isDesignMode && ( {isDesignMode && (
<div style={{ padding: "6px 10px", fontSize: 10.5, color: "hsl(var(--muted-foreground))" }}> <div style={{ padding: "6px 10px", fontSize: 10.5, color: "hsl(var(--muted-foreground))" }}>
[ ] {groups.length} [ ] {groups.length}
{groupSumEnabled ? " (group-sum 활성)" : ""}
</div> </div>
)} )}
</div> </div>
); );
} }
function formatCell(value: any, _format?: string): React.ReactNode {
if (value === null || value === undefined) return "-";
if (typeof value === "boolean") return value ? "✓" : "✗";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
const tableStyle: React.CSSProperties = { const tableStyle: React.CSSProperties = {
width: "100%", width: "100%",
borderCollapse: "collapse", borderCollapse: "collapse",
@@ -158,7 +360,7 @@ const thStyle: React.CSSProperties = {
fontWeight: 700, fontWeight: 700,
color: "hsl(var(--foreground))", color: "hsl(var(--foreground))",
textTransform: "uppercase", textTransform: "uppercase",
letterSpacing: "0.03em", letterSpacing: 0,
borderBottom: "1px solid hsl(var(--border))", borderBottom: "1px solid hsl(var(--border))",
textAlign: "left", textAlign: "left",
whiteSpace: "nowrap", whiteSpace: "nowrap",
@@ -179,6 +381,15 @@ const groupHeaderRowStyle: React.CSSProperties = {
cursor: "pointer", cursor: "pointer",
}; };
const groupSubtotalRowStyle: React.CSSProperties = {
background: "hsl(var(--primary) / 0.05)",
};
const grandTotalRowStyle: React.CSSProperties = {
background: "hsl(var(--muted) / 0.7)",
borderTop: "2px solid hsl(var(--border))",
};
const emptyStyle: React.CSSProperties = { const emptyStyle: React.CSSProperties = {
padding: 24, padding: 24,
textAlign: "center", textAlign: "center",
@@ -107,7 +107,7 @@ export function UniversalFormModalConfigPanel({
const compType = comp.componentId || comp.componentConfig?.type || comp.componentConfig?.id || comp.type; const compType = comp.componentId || comp.componentConfig?.type || comp.componentConfig?.id || comp.type;
const compConfig = comp.componentConfig || {}; const compConfig = comp.componentConfig || {};
// 1. Table-like (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list') // 1. Table-like (canonical 'table' )
// + InteractiveDataTable - 테이블 컬럼 추출 // + InteractiveDataTable - 테이블 컬럼 추출
if (isTableLikeComponentType(compType) || compType === "interactive-data-table") { if (isTableLikeComponentType(compType) || compType === "interactive-data-table") {
const tableName = compConfig.selectedTable || compConfig.tableName; const tableName = compConfig.selectedTable || compConfig.tableName;
@@ -768,7 +768,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
const allProviders = screenContext.getAllDataProviders(); const allProviders = screenContext.getAllDataProviders();
// table-like (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list') 우선 탐색 // table-like (canonical 'table' ) 우선 탐색
for (const [id, provider] of allProviders) { for (const [id, provider] of allProviders) {
if (isTableLikeComponentType(provider.component_type)) { if (isTableLikeComponentType(provider.component_type)) {
sourceProvider = provider; sourceProvider = provider;
@@ -114,7 +114,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
// 좌측 패널 대상: card-display만 // 좌측 패널 대상: card-display만
return tableId.includes("card-display") || tableId.includes("card"); return tableId.includes("card-display") || tableId.includes("card");
} else if (targetPanelPosition === "right") { } else if (targetPanelPosition === "right") {
// 우측 패널 대상: datatable, table-list 등 (card-display 제외) // 우측 패널 대상: datatable, canonical table 등 (card-display 제외)
const isCardDisplay = tableId.includes("card-display") || tableId.includes("card"); const isCardDisplay = tableId.includes("card-display") || tableId.includes("card");
return !isCardDisplay; return !isCardDisplay;
} }
+6 -63
View File
@@ -125,7 +125,7 @@ export function getComponentUrl(componentType: string): string {
// 컴포넌트 타입 추출 함수 (URL에서) // 컴포넌트 타입 추출 함수 (URL에서)
// ============================================ // ============================================
export function getComponentTypeFromUrl(componentUrl: string): string { export function getComponentTypeFromUrl(componentUrl: string): string {
// "@/lib/registry/components/v2-table-list" → "v2-table-list" // "@/lib/registry/components/table" → "table"
const parts = componentUrl.split("/"); const parts = componentUrl.split("/");
return parts[parts.length - 1]; return parts[parts.length - 1];
} }
@@ -223,36 +223,6 @@ export type LayoutV2 = z.infer<typeof layoutV2Schema>;
// V2 컴포넌트 overrides 스키마 정의 // V2 컴포넌트 overrides 스키마 정의
// ============================================ // ============================================
// v2-table-list
const v2TableListOverridesSchema = z
.object({
displayMode: z.enum(["table", "card"]).default("table"),
showHeader: z.boolean().default(true),
showFooter: z.boolean().default(true),
height: z.string().default("auto"),
checkbox: z
.object({
enabled: z.boolean().default(true),
multiple: z.boolean().default(true),
position: z.string().default("left"),
selectAll: z.boolean().default(true),
})
.default({ enabled: true, multiple: true, position: "left", selectAll: true }),
columns: z.array(z.any()).default([]),
autoWidth: z.boolean().default(true),
stickyHeader: z.boolean().default(false),
pagination: z
.object({
enabled: z.boolean().default(true),
pageSize: z.number().default(20),
showSizeSelector: z.boolean().default(true),
showPageInfo: z.boolean().default(true),
})
.default({ enabled: true, pageSize: 20, showSizeSelector: true, showPageInfo: true }),
autoLoad: z.boolean().default(true),
})
.passthrough();
// v2-button-primary // v2-button-primary
const v2ButtonPrimaryOverridesSchema = z const v2ButtonPrimaryOverridesSchema = z
.object({ .object({
@@ -437,17 +407,7 @@ const v2V2RepeaterOverridesSchema = z
// V2 입력/선택 폐기 (Phase D.2, 2026-05-12) — input canonical 로 흡수. // V2 입력/선택 폐기 (Phase D.2, 2026-05-12) — input canonical 로 흡수.
// override schema / default config 모두 제거. fallback schema (default any.passthrough) 사용. // override schema / default config 모두 제거. fallback schema (default any.passthrough) 사용.
// v2-list schema 폐기 (Phase F.8, 2026-05-21) — canonical table 로 흡수.
// v2-list
const v2ListOverridesSchema = z
.object({
viewMode: z.string().default("table"),
source: z.string().default("static"),
columns: z.array(z.any()).default([]),
pagination: z.boolean().default(true),
sortable: z.boolean().default(true),
})
.passthrough();
// v2-layout // v2-layout
const v2LayoutOverridesSchema = z const v2LayoutOverridesSchema = z
@@ -551,7 +511,7 @@ const v2RepeaterOverridesSchema = z
// ============================================ // ============================================
const componentOverridesSchemaRegistry: Record<string, z.ZodType<Record<string, any>>> = { const componentOverridesSchemaRegistry: Record<string, z.ZodType<Record<string, any>>> = {
// V2 컴포넌트 (canonical alias 라우팅된 항목은 schema 제거됨 — 2026-05-19) // V2 컴포넌트 (canonical alias 라우팅된 항목은 schema 제거됨 — 2026-05-19)
"v2-table-list": v2TableListOverridesSchema, // 옛 table-list 계열 schema 폐기 (Phase F.4) — canonical "table" 사용
"v2-button-primary": v2ButtonPrimaryOverridesSchema, "v2-button-primary": v2ButtonPrimaryOverridesSchema,
"v2-text-display": v2TextDisplayOverridesSchema, "v2-text-display": v2TextDisplayOverridesSchema,
"v2-split-panel-layout": v2SplitPanelLayoutOverridesSchema, "v2-split-panel-layout": v2SplitPanelLayoutOverridesSchema,
@@ -573,7 +533,7 @@ const componentOverridesSchemaRegistry: Record<string, z.ZodType<Record<string,
}).passthrough(), }).passthrough(),
// V2 입력/선택 폐기 (Phase D.2) — schema 미제공. // V2 입력/선택 폐기 (Phase D.2) — schema 미제공.
"v2-list": v2ListOverridesSchema, // 옛 통합 목록 schema 폐기 (Phase F.8) — canonical table 사용.
"v2-layout": v2LayoutOverridesSchema, "v2-layout": v2LayoutOverridesSchema,
"v2-group": v2GroupOverridesSchema, "v2-group": v2GroupOverridesSchema,
// v2-media 폐기 (Phase D.5) — schema 미제공. // v2-media 폐기 (Phase D.5) — schema 미제공.
@@ -586,18 +546,7 @@ const componentOverridesSchemaRegistry: Record<string, z.ZodType<Record<string,
// ============================================ // ============================================
const componentDefaultsRegistry: Record<string, Record<string, any>> = { const componentDefaultsRegistry: Record<string, Record<string, any>> = {
// V2 컴포넌트 // V2 컴포넌트
"v2-table-list": { // 옛 table-list 계열 defaults 폐기 (Phase F.4) — canonical "table" 사용
displayMode: "table",
showHeader: true,
showFooter: true,
height: "auto",
checkbox: { enabled: true, multiple: true, position: "left", selectAll: true },
columns: [],
autoWidth: true,
stickyHeader: false,
pagination: { enabled: true, pageSize: 20, showSizeSelector: true, showPageInfo: true },
autoLoad: true,
},
"v2-button-primary": { "v2-button-primary": {
text: "저장", text: "저장",
actionType: "button", actionType: "button",
@@ -669,13 +618,7 @@ const componentDefaultsRegistry: Record<string, Record<string, any>> = {
}, },
// v2-tabs-widget defaults 제거 (2026-05-19): canonical container alias (containerType=tabs) // v2-tabs-widget defaults 제거 (2026-05-19): canonical container alias (containerType=tabs)
// V2 컴포넌트 (V2 입력/선택 폐기, Phase D.2) // V2 컴포넌트 (V2 입력/선택 폐기, Phase D.2)
"v2-list": { // 옛 통합 목록 defaults 폐기 (Phase F.8) — canonical table 사용.
viewMode: "table",
source: "static",
columns: [],
pagination: true,
sortable: true,
},
"v2-layout": { "v2-layout": {
layoutType: "grid", layoutType: "grid",
columns: 2, columns: 2,
+10 -3
View File
@@ -208,12 +208,19 @@ export class AutoGenerationUtils {
return timeValue; return timeValue;
case "sequence": case "sequence":
return this.generateSequence(columnName || "default", options.startValue || 1, options.prefix, options.suffix); return this.generateSequence(
columnName || "default",
(options as any).startValue ?? options.start_value ?? 1,
options.prefix,
options.suffix,
);
case "numbering_rule": case "numbering_rule":
// 채번 규칙 ID가 있으면 API 호출 // 채번 규칙 ID가 있으면 API 호출
if (options.numberingRuleId) { const numberingRuleId =
return await this.generateNumberingRuleCode(options.numberingRuleId, formData); (options as any).numberingRuleId ?? options.numbering_rule_id;
if (numberingRuleId) {
return await this.generateNumberingRuleCode(numberingRuleId, formData);
} }
console.warn("numbering_rule 타입인데 numberingRuleId가 없습니다"); console.warn("numbering_rule 타입인데 numberingRuleId가 없습니다");
return null; return null;
+6 -20
View File
@@ -3263,9 +3263,9 @@ export class ButtonActionExecutor {
if (autoDetectDataSource) { if (autoDetectDataSource) {
dataSourceId = config.dataSourceId; dataSourceId = config.dataSourceId;
// TableList, V2List 또는 SplitPanelLayout에서 자동 감지 // canonical table 또는 SplitPanelLayout에서 자동 감지
if (!dataSourceId && context.allComponents) { if (!dataSourceId && context.allComponents) {
// 1. table-like 컴포넌트 찾기 (canonical table / legacy table-list / hidden v2-table-list 모두 인식) // 1. table-like 컴포넌트 찾기 (canonical table 인식)
const tableLikeComponent = context.allComponents.find( const tableLikeComponent = context.allComponents.find(
(comp: any) => isTableLikeComponent(comp) && getTableNameFromTableLikeComponent(comp), (comp: any) => isTableLikeComponent(comp) && getTableNameFromTableLikeComponent(comp),
); );
@@ -3273,22 +3273,8 @@ export class ButtonActionExecutor {
if (tableLikeComponent) { if (tableLikeComponent) {
dataSourceId = getTableNameFromTableLikeComponent(tableLikeComponent); dataSourceId = getTableNameFromTableLikeComponent(tableLikeComponent);
} else { } else {
// 2. v2-list 컴포넌트 찾기 {
const v2ListComponent = context.allComponents.find( // 2. split-panel-layout 컴포넌트 찾기
(comp: any) =>
comp.componentType === "v2-list" &&
(comp.componentConfig?.dataSource?.table || comp.componentConfig?.tableName),
);
if (v2ListComponent) {
dataSourceId =
v2ListComponent.componentConfig.dataSource?.table || v2ListComponent.componentConfig.tableName;
console.log("✨ V2List 자동 감지:", {
componentId: v2ListComponent.id,
tableName: dataSourceId,
});
} else {
// 3. split-panel-layout 컴포넌트 찾기
const splitPanelComponent = context.allComponents.find( const splitPanelComponent = context.allComponents.find(
(comp: any) => (comp: any) =>
comp.componentType === "split-panel-layout" && comp.componentConfig?.leftPanel?.tableName, comp.componentType === "split-panel-layout" && comp.componentConfig?.leftPanel?.tableName,
@@ -3461,7 +3447,7 @@ export class ButtonActionExecutor {
let dataSourceId = config.dataSourceId; let dataSourceId = config.dataSourceId;
if (!dataSourceId && context.allComponents) { if (!dataSourceId && context.allComponents) {
// table-like 우선 감지 (canonical table / legacy table-list / hidden v2-table-list) // table-like 우선 감지 (canonical table )
const tableLikeComponent = context.allComponents.find( const tableLikeComponent = context.allComponents.find(
(comp: any) => isTableLikeComponent(comp) && getTableNameFromTableLikeComponent(comp), (comp: any) => isTableLikeComponent(comp) && getTableNameFromTableLikeComponent(comp),
); );
@@ -5280,7 +5266,7 @@ export class ButtonActionExecutor {
layoutData.components = JSON.parse(layoutData.components); layoutData.components = JSON.parse(layoutData.components);
} }
// 테이블 리스트 컴포넌트 찾기 (canonical table / legacy table-list / hidden v2-table-list 모두 인식) // 테이블 컴포넌트 찾기 (canonical table table-like 컴포넌트 인식)
const findTableListComponent = (components: any[]): any => { const findTableListComponent = (components: any[]): any => {
if (!Array.isArray(components)) return null; if (!Array.isArray(components)) return null;
+8 -12
View File
@@ -34,8 +34,10 @@ export const isFileComponent = (component: ComponentData): boolean => {
export const isButtonComponent = (component: ComponentData): boolean => { export const isButtonComponent = (component: ComponentData): boolean => {
if (!component || !component.type) return false; if (!component || !component.type) return false;
// ComponentData.type union 에는 "button" 이 명시되지 않지만 legacy 저장 layout 에서
// type === "button" 으로 직접 박혀온 경우가 있어 보존 — cast 로만 union 우회.
return ( return (
component.type === "button" || (component as any).type === "button" ||
(component.type === "widget" && (component as any).widgetType === "button") || (component.type === "widget" && (component as any).widgetType === "button") ||
(component.type === "component" && (component.type === "component" &&
((component as any).webType === "button" || (component as any).componentType === "button")) ((component as any).webType === "button" || (component as any).componentType === "button"))
@@ -195,14 +197,11 @@ export const getComponentLabel = (component: ComponentData): string => {
* *
* Canonical Table-like helpers * Canonical Table-like helpers
* *
* INVYONE canonical data-view cleanup , `table-list` * INVYONE canonical data-view (F phase) ,
* canonical-aware . * canonical id id .
* *
* - : `table` (canonical) * - canonical: `table`
* - : `table-list` * - (): `data-table`, `datatable`
* - hidden legacy: `v2-table-list` (registry/schema에 hard blocker로
* )
* - : `data-table`, `datatable`
* *
* typeValue `isTableLikeComponentType`, * typeValue `isTableLikeComponentType`,
* `isTableLikeComponent`, table name `getTableNameFromTableLikeComponent` * `isTableLikeComponent`, table name `getTableNameFromTableLikeComponent`
@@ -210,13 +209,10 @@ export const getComponentLabel = (component: ComponentData): string => {
* */ * */
/** /**
* canonical table alias . * canonical table .
* v2-table-list registry/schemas hard blocker .
*/ */
const TABLE_LIKE_COMPONENT_TYPES: ReadonlySet<string> = new Set([ const TABLE_LIKE_COMPONENT_TYPES: ReadonlySet<string> = new Set([
"table", "table",
"table-list",
"v2-table-list",
"data-table", "data-table",
"datatable", "datatable",
]); ]);
@@ -24,7 +24,7 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
// ========== V2 컴포넌트 ========== // ========== V2 컴포넌트 ==========
// V2 입력/선택 폐기 (2026-05-12) — input canonical 로 흡수. alias / fallback / schema 미제공. // V2 입력/선택 폐기 (2026-05-12) — input canonical 로 흡수. alias / fallback / schema 미제공.
"v2-list": () => import("@/components/v2/config-panels/InvDataConfigPanel"), // v2-list 폐기 (Phase F.8, 2026-05-21) — canonical table 로 흡수.
// v2-media — Phase D.5 폐기. canonical input (FilePicker) 으로 흡수, ConfigPanel 미제공. // v2-media — Phase D.5 폐기. canonical input (FilePicker) 으로 흡수, ConfigPanel 미제공.
"v2-biz": () => import("@/components/v2/config-panels/V2BizConfigPanel"), "v2-biz": () => import("@/components/v2/config-panels/V2BizConfigPanel"),
"v2-group": () => import("@/components/v2/config-panels/V2GroupConfigPanel"), "v2-group": () => import("@/components/v2/config-panels/V2GroupConfigPanel"),
@@ -65,7 +65,7 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
"v2-split-line": () => import("@/lib/registry/components/v2-split-line/SplitLineConfigPanel"), "v2-split-line": () => import("@/lib/registry/components/v2-split-line/SplitLineConfigPanel"),
// ========== 테이블/리스트 ========== // ========== 테이블/리스트 ==========
// ★ 2026-05-19 table-list / v2-table-list → CONFIG_PANEL_ALIAS["..."]="table" 로 라우팅 // table-list / v2-table-list alias 는 Phase F.6 에서 제거됨 — canonical "table" 만 사용
"table-search-widget": () => import("@/lib/registry/components/table-search-widget/TableSearchWidgetConfigPanel"), "table-search-widget": () => import("@/lib/registry/components/table-search-widget/TableSearchWidgetConfigPanel"),
"v2-table-search-widget": () => import("@/lib/registry/components/v2-table-search-widget/TableSearchWidgetConfigPanel"), "v2-table-search-widget": () => import("@/lib/registry/components/v2-table-search-widget/TableSearchWidgetConfigPanel"),
"tax-invoice-list": () => import("@/lib/registry/components/tax-invoice-list/TaxInvoiceListConfigPanel"), "tax-invoice-list": () => import("@/lib/registry/components/tax-invoice-list/TaxInvoiceListConfigPanel"),
@@ -143,7 +143,6 @@ const CONFIG_PANEL_ALIAS: Record<string, string> = {
"v2-table-search-widget": "search", "table-search-widget": "search", "v2-table-search-widget": "search", "table-search-widget": "search",
"v2-aggregation-widget": "stats", "aggregation-widget": "stats", "v2-aggregation-widget": "stats", "aggregation-widget": "stats",
"v2-status-count": "stats", "v2-status-count": "stats",
"v2-table-list": "table", "table-list": "table",
"v2-tabs-widget": "container", "tabs-widget": "container", "v2-tabs-widget": "container", "tabs-widget": "container",
"tabs": "container", "v2-tabs": "container", "tabs": "container", "v2-tabs": "container",
"v2-section-card": "container", "v2-section-paper": "container", "v2-section-card": "container", "v2-section-paper": "container",
@@ -169,7 +168,7 @@ export async function getComponentConfigPanel(componentId: string): Promise<Reac
// 모듈에서 ConfigPanel 컴포넌트 추출 (우선순위): // 모듈에서 ConfigPanel 컴포넌트 추출 (우선순위):
// 1차: PascalCase 변환된 이름 (예: mail-recipient-selector -> MailRecipientSelectorConfigPanel) // 1차: PascalCase 변환된 이름 (예: mail-recipient-selector -> MailRecipientSelectorConfigPanel)
// 2차: v2- 접두사 제거 후 PascalCase (예: v2-table-list -> TableListConfigPanel) // 2차: v2- 접두사 제거 후 PascalCase (예: v2-text-display -> TextDisplayConfigPanel)
// 3차: *ConfigPanel로 끝나는 첫 번째 named export // 3차: *ConfigPanel로 끝나는 첫 번째 named export
// 4차: default export // 4차: default export
const pascalCaseName = `${toPascalCase(componentId)}ConfigPanel`; const pascalCaseName = `${toPascalCase(componentId)}ConfigPanel`;
+9 -3
View File
@@ -4,6 +4,7 @@
import { ComponentData } from "@/types/screen-management"; import { ComponentData } from "@/types/screen-management";
import { ResponsiveComponentConfig, BREAKPOINTS } from "@/types/responsive"; import { ResponsiveComponentConfig, BREAKPOINTS } from "@/types/responsive";
import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
/** /**
* *
@@ -18,13 +19,13 @@ export function generateSmartDefaults(
screenWidth: number = 1920, screenWidth: number = 1920,
rowComponentCount: number = 1, // 같은 행에 있는 컴포넌트 개수 rowComponentCount: number = 1, // 같은 행에 있는 컴포넌트 개수
): ResponsiveComponentConfig["responsive"] { ): ResponsiveComponentConfig["responsive"] {
// 특정 컴포넌트는 항상 전체 너비 (datatable, table-list 등) // 특정 컴포넌트는 항상 전체 너비 (datatable, table 등).
// Phase E.3 — 옛 table ID literal 은 제거하고 table-like helper 로 호환 유지.
// ★ 2026-05-18: canonical data-view 추가 (table / grouped-table / card-list). // ★ 2026-05-18: canonical data-view 추가 (table / grouped-table / card-list).
// chart 는 자연스럽게 작은 크기(예: 4컬럼)도 자주 쓰므로 제외하고 자동 추론. // chart 는 자연스럽게 작은 크기(예: 4컬럼)도 자주 쓰므로 제외하고 자동 추론.
const fullWidthComponents = [ const fullWidthComponents = [
"datatable", "datatable",
"data-table", "data-table",
"table-list",
"repeater-field-group", "repeater-field-group",
// canonical (Phase G.3 ~) // canonical (Phase G.3 ~)
"table", "table",
@@ -34,7 +35,12 @@ export function generateSmartDefaults(
const componentId = (component as any).componentId || (component as any).id; const componentId = (component as any).componentId || (component as any).id;
const componentType = (component as any).componentType || component.type; const componentType = (component as any).componentType || component.type;
if (fullWidthComponents.includes(componentId) || fullWidthComponents.includes(componentType)) { if (
isTableLikeComponentType(componentId) ||
isTableLikeComponentType(componentType) ||
fullWidthComponents.includes(componentId) ||
fullWidthComponents.includes(componentType)
) {
return { return {
desktop: { desktop: {
grid_columns: 12, // 전체 너비 grid_columns: 12, // 전체 너비
-2
View File
@@ -41,8 +41,6 @@ const LEGACY_TO_UNIFIED: Record<string, string> = {
'v2-aggregation-widget': 'stats', 'v2-aggregation-widget': 'stats',
'aggregation-widget': 'stats', 'aggregation-widget': 'stats',
'v2-status-count': 'stats', 'v2-status-count': 'stats',
'v2-table-list': 'table',
'table-list': 'table',
'v2-tabs-widget': 'container', 'v2-tabs-widget': 'container',
'tabs-widget': 'container', 'tabs-widget': 'container',
'tabs': 'container', 'tabs': 'container',
+72
View File
@@ -1,6 +1,15 @@
/** /**
* *
* *
*
* Key (Phase B.4 2026-05-20, F.5 docstring ):
* - canonical `lib/registry/components/table/TableComponent` : `table-${component.id}` prefix
* (`setTableDataForComponent` / `getTableDataForComponent` helper )
* - prefix (`table-list-${component.id}`) fallback .
* ( Phase F.2/F.8 historical key store
* read fallback .)
* - consumer (Excel/Copy/Toolbar) component.id
* `getTableDataForComponent(componentId)` canonical + historical fallback .
*/ */
interface TableDisplayState { interface TableDisplayState {
@@ -24,6 +33,16 @@ class TableDisplayStore {
private state: Map<string, TableDisplayState> = new Map(); private state: Map<string, TableDisplayState> = new Map();
private listeners: Set<() => void> = new Set(); private listeners: Set<() => void> = new Set();
/** canonical TableComponent 가 쓰는 키 (`table-${componentId}`) */
private static canonicalKeyForComponent(componentId: string): string {
return `table-${componentId}`;
}
/** historical fallback 키 — 옛 본체가 직접 set 하던 prefix. 본체는 Phase F.2/F.8 에서 삭제됨. */
private static legacyKeyForComponent(componentId: string): string {
return `table-list-${componentId}`;
}
/** /**
* *
* @param tableName * @param tableName
@@ -85,6 +104,59 @@ class TableDisplayStore {
this.notifyListeners(); this.notifyListeners();
} }
/**
* canonical TableComponent set component.id key (Phase B.4)
*/
setTableDataForComponent(
componentId: string,
data: any[],
columnOrder: string[],
sortBy: string | null,
sortOrder: "asc" | "desc",
options?: {
filter_conditions?: Record<string, any>;
search_term?: string;
visible_columns?: string[];
column_labels?: Record<string, string>;
current_page?: number;
page_size?: number;
total_items?: number;
},
) {
this.setTableData(
TableDisplayStore.canonicalKeyForComponent(componentId),
data,
columnOrder,
sortBy,
sortOrder,
options,
);
}
/**
* component.id canonical + historical fallback (Phase B.4)
* consumer (Excel export, copy, toolbar) prefix component.id .
*/
getTableDataForComponent(componentId: string): TableDisplayState | undefined {
return (
this.state.get(TableDisplayStore.canonicalKeyForComponent(componentId)) ??
this.state.get(TableDisplayStore.legacyKeyForComponent(componentId))
);
}
/**
* component.id canonical + historical fallback (Phase B.4)
*/
clearTableDataForComponent(componentId: string) {
const canonical = TableDisplayStore.canonicalKeyForComponent(componentId);
const legacy = TableDisplayStore.legacyKeyForComponent(componentId);
const hadCanonical = this.state.delete(canonical);
const hadLegacy = this.state.delete(legacy);
if (hadCanonical || hadLegacy) {
this.notifyListeners();
}
}
/** /**
* *
*/ */
+212 -78
View File
@@ -14,6 +14,8 @@
--v5-red-rgb:255,71,87; --v5-red-rgb:255,71,87;
--v5-green-rgb:0,184,148; --v5-green-rgb:0,184,148;
--v5-amber-rgb:253,203,110; --v5-amber-rgb:253,203,110;
/* Gradient accent — far end of primary→accent header/logo gradient. Picked per-theme to stay visually distinct from primary even when cyan is too close (e.g. blue/cyan themes). */
--v5-accent-rgb:0,206,201;
--v5-bg:#fafaff; --v5-bg-subtle:#f3f2fa; --v5-bg:#fafaff; --v5-bg-subtle:#f3f2fa;
--v5-surface:rgba(255,255,255,0.55); --v5-surface-solid:#ffffff; --v5-surface:rgba(255,255,255,0.55); --v5-surface-solid:#ffffff;
@@ -23,6 +25,7 @@
--v5-cyan:rgb(var(--v5-cyan-rgb)); --v5-cyan-glow:rgba(var(--v5-cyan-rgb),0.2); --v5-cyan:rgb(var(--v5-cyan-rgb)); --v5-cyan-glow:rgba(var(--v5-cyan-rgb),0.2);
--v5-pink:rgb(var(--v5-pink-rgb)); --v5-pink-glow:rgba(var(--v5-pink-rgb),0.15); --v5-pink:rgb(var(--v5-pink-rgb)); --v5-pink-glow:rgba(var(--v5-pink-rgb),0.15);
--v5-red:rgb(var(--v5-red-rgb)); --v5-green:rgb(var(--v5-green-rgb)); --v5-amber:rgb(var(--v5-amber-rgb)); --v5-red:rgb(var(--v5-red-rgb)); --v5-green:rgb(var(--v5-green-rgb)); --v5-amber:rgb(var(--v5-amber-rgb));
--v5-accent:rgb(var(--v5-accent-rgb)); --v5-accent-glow:rgba(var(--v5-accent-rgb),0.2);
--v5-border:rgba(var(--v5-primary-rgb),0.12); --v5-border-subtle:rgba(0,0,0,0.05); --v5-border:rgba(var(--v5-primary-rgb),0.12); --v5-border-subtle:rgba(0,0,0,0.05);
--v5-glass:rgba(255,255,255,0.45); --v5-glass-strong:rgba(255,255,255,0.65); --v5-glass:rgba(255,255,255,0.45); --v5-glass-strong:rgba(255,255,255,0.65);
--v5-glass-border:rgba(var(--v5-primary-rgb),0.12); --v5-glass-border:rgba(var(--v5-primary-rgb),0.12);
@@ -109,6 +112,7 @@
--v5-red-rgb:255,107,107; --v5-red-rgb:255,107,107;
--v5-green-rgb:85,239,196; --v5-green-rgb:85,239,196;
--v5-amber-rgb:255,234,167; --v5-amber-rgb:255,234,167;
--v5-accent-rgb:85,239,196;
--v5-bg:#0a0b0d; --v5-bg-subtle:#111215; --v5-bg:#0a0b0d; --v5-bg-subtle:#111215;
--v5-surface:rgba(23,24,27,0.5); --v5-surface-solid:#17181b; --v5-surface:rgba(23,24,27,0.5); --v5-surface-solid:#17181b;
@@ -117,6 +121,7 @@
--v5-primary:rgb(var(--v5-primary-rgb)); --v5-primary-light:#c8c4ff; --v5-primary-glow:rgba(var(--v5-primary-rgb),0.25); --v5-primary:rgb(var(--v5-primary-rgb)); --v5-primary-light:#c8c4ff; --v5-primary-glow:rgba(var(--v5-primary-rgb),0.25);
--v5-cyan:rgb(var(--v5-cyan-rgb)); --v5-cyan-glow:rgba(var(--v5-cyan-rgb),0.15); --v5-cyan:rgb(var(--v5-cyan-rgb)); --v5-cyan-glow:rgba(var(--v5-cyan-rgb),0.15);
--v5-pink:rgb(var(--v5-pink-rgb)); --v5-red:rgb(var(--v5-red-rgb)); --v5-green:rgb(var(--v5-green-rgb)); --v5-amber:rgb(var(--v5-amber-rgb)); --v5-pink:rgb(var(--v5-pink-rgb)); --v5-red:rgb(var(--v5-red-rgb)); --v5-green:rgb(var(--v5-green-rgb)); --v5-amber:rgb(var(--v5-amber-rgb));
--v5-accent:rgb(var(--v5-accent-rgb)); --v5-accent-glow:rgba(var(--v5-accent-rgb),0.15);
--v5-border:rgba(255,255,255,0.08); --v5-border-subtle:rgba(255,255,255,0.04); --v5-border:rgba(255,255,255,0.08); --v5-border-subtle:rgba(255,255,255,0.04);
--v5-glass:rgba(23,24,27,0.45); --v5-glass-strong:rgba(23,24,27,0.65); --v5-glass:rgba(23,24,27,0.45); --v5-glass-strong:rgba(23,24,27,0.65);
--v5-glass-border:rgba(var(--v5-primary-rgb),0.12); --v5-glass-border:rgba(var(--v5-primary-rgb),0.12);
@@ -139,41 +144,51 @@
/* --- BLUE --- */ /* --- BLUE --- */
html[data-color="blue"]{ html[data-color="blue"]{
--v5-primary-rgb:59,130,246; --v5-cyan-rgb:14,165,233; --v5-pink-rgb:99,102,241; --v5-primary-rgb:59,130,246; --v5-cyan-rgb:14,165,233; --v5-pink-rgb:99,102,241;
--v5-accent-rgb:251,146,60;
--primary:217 91% 60%; --ring:217 91% 60%; --sidebar-primary:217 91% 60%; --sidebar-ring:217 91% 60%;} --primary:217 91% 60%; --ring:217 91% 60%; --sidebar-primary:217 91% 60%; --sidebar-ring:217 91% 60%;}
html.dark[data-color="blue"]{ html.dark[data-color="blue"]{
--v5-primary-rgb:147,197,253; --v5-cyan-rgb:125,211,252; --v5-pink-rgb:129,140,248; --v5-primary-rgb:147,197,253; --v5-cyan-rgb:125,211,252; --v5-pink-rgb:129,140,248;
--v5-accent-rgb:253,186,116;
--primary:213 93% 78%; --ring:213 93% 78%; --sidebar-primary:213 93% 78%; --sidebar-ring:213 93% 78%;} --primary:213 93% 78%; --ring:213 93% 78%; --sidebar-primary:213 93% 78%; --sidebar-ring:213 93% 78%;}
/* --- GREEN --- */ /* --- GREEN --- */
html[data-color="green"]{ html[data-color="green"]{
--v5-primary-rgb:16,185,129; --v5-cyan-rgb:20,184,166; --v5-pink-rgb:132,204,22; --v5-primary-rgb:16,185,129; --v5-cyan-rgb:20,184,166; --v5-pink-rgb:132,204,22;
--v5-accent-rgb:236,72,153;
--primary:160 84% 39%; --ring:160 84% 39%; --sidebar-primary:160 84% 39%; --sidebar-ring:160 84% 39%;} --primary:160 84% 39%; --ring:160 84% 39%; --sidebar-primary:160 84% 39%; --sidebar-ring:160 84% 39%;}
html.dark[data-color="green"]{ html.dark[data-color="green"]{
--v5-primary-rgb:110,231,183; --v5-cyan-rgb:94,234,212; --v5-pink-rgb:190,242,100; --v5-primary-rgb:110,231,183; --v5-cyan-rgb:94,234,212; --v5-pink-rgb:190,242,100;
--v5-accent-rgb:244,114,182;
--primary:156 73% 67%; --ring:156 73% 67%; --sidebar-primary:156 73% 67%; --sidebar-ring:156 73% 67%;} --primary:156 73% 67%; --ring:156 73% 67%; --sidebar-primary:156 73% 67%; --sidebar-ring:156 73% 67%;}
/* --- ORANGE --- */ /* --- ORANGE --- */
html[data-color="orange"]{ html[data-color="orange"]{
--v5-primary-rgb:249,115,22; --v5-cyan-rgb:6,182,212; --v5-pink-rgb:251,146,60; --v5-primary-rgb:249,115,22; --v5-cyan-rgb:6,182,212; --v5-pink-rgb:251,146,60;
--v5-accent-rgb:6,182,212;
--primary:25 95% 53%; --ring:25 95% 53%; --sidebar-primary:25 95% 53%; --sidebar-ring:25 95% 53%;} --primary:25 95% 53%; --ring:25 95% 53%; --sidebar-primary:25 95% 53%; --sidebar-ring:25 95% 53%;}
html.dark[data-color="orange"]{ html.dark[data-color="orange"]{
--v5-primary-rgb:253,186,116; --v5-cyan-rgb:103,232,249; --v5-pink-rgb:252,165,165; --v5-primary-rgb:253,186,116; --v5-cyan-rgb:103,232,249; --v5-pink-rgb:252,165,165;
--v5-accent-rgb:103,232,249;
--primary:32 97% 72%; --ring:32 97% 72%; --sidebar-primary:32 97% 72%; --sidebar-ring:32 97% 72%;} --primary:32 97% 72%; --ring:32 97% 72%; --sidebar-primary:32 97% 72%; --sidebar-ring:32 97% 72%;}
/* --- PINK --- */ /* --- PINK --- */
html[data-color="pink"]{ html[data-color="pink"]{
--v5-primary-rgb:236,72,153; --v5-cyan-rgb:168,85,247; --v5-pink-rgb:244,114,182; --v5-primary-rgb:236,72,153; --v5-cyan-rgb:168,85,247; --v5-pink-rgb:244,114,182;
--v5-accent-rgb:0,206,201;
--primary:330 81% 60%; --ring:330 81% 60%; --sidebar-primary:330 81% 60%; --sidebar-ring:330 81% 60%;} --primary:330 81% 60%; --ring:330 81% 60%; --sidebar-primary:330 81% 60%; --sidebar-ring:330 81% 60%;}
html.dark[data-color="pink"]{ html.dark[data-color="pink"]{
--v5-primary-rgb:244,114,182; --v5-cyan-rgb:192,132,252; --v5-pink-rgb:249,168,212; --v5-primary-rgb:244,114,182; --v5-cyan-rgb:192,132,252; --v5-pink-rgb:249,168,212;
--v5-accent-rgb:94,234,212;
--primary:330 86% 70%; --ring:330 86% 70%; --sidebar-primary:330 86% 70%; --sidebar-ring:330 86% 70%;} --primary:330 86% 70%; --ring:330 86% 70%; --sidebar-primary:330 86% 70%; --sidebar-ring:330 86% 70%;}
/* --- CYAN --- */ /* --- CYAN --- */
html[data-color="cyan"]{ html[data-color="cyan"]{
--v5-primary-rgb:8,145,178; --v5-cyan-rgb:14,165,233; --v5-pink-rgb:6,182,212; --v5-primary-rgb:8,145,178; --v5-cyan-rgb:14,165,233; --v5-pink-rgb:6,182,212;
--v5-accent-rgb:249,115,22;
--primary:191 91% 36%; --ring:191 91% 36%; --sidebar-primary:191 91% 36%; --sidebar-ring:191 91% 36%;} --primary:191 91% 36%; --ring:191 91% 36%; --sidebar-primary:191 91% 36%; --sidebar-ring:191 91% 36%;}
html.dark[data-color="cyan"]{ html.dark[data-color="cyan"]{
--v5-primary-rgb:125,211,252; --v5-cyan-rgb:103,232,249; --v5-pink-rgb:165,243,252; --v5-primary-rgb:125,211,252; --v5-cyan-rgb:103,232,249; --v5-pink-rgb:165,243,252;
--v5-accent-rgb:253,186,116;
--primary:200 94% 74%; --ring:200 94% 74%; --sidebar-primary:200 94% 74%; --sidebar-ring:200 94% 74%;} --primary:200 94% 74%; --ring:200 94% 74%; --sidebar-primary:200 94% 74%; --sidebar-ring:200 94% 74%;}
/* --- PURPLE (기본) 사이트 :root 토큰을 그대로 쓰지만 명시적으로 매핑해서 /* --- PURPLE (기본) 사이트 :root 토큰을 그대로 쓰지만 명시적으로 매핑해서
@@ -235,7 +250,7 @@ html:not(.dark) .v5-hdr{
box-shadow:0 4px 20px rgba(var(--v5-primary-rgb),0.06);} box-shadow:0 4px 20px rgba(var(--v5-primary-rgb),0.06);}
.v5-hdr-l{display:flex;align-items:center;gap:1rem;} .v5-hdr-l{display:flex;align-items:center;gap:1rem;}
.v5-hdr-logo{font-size:1.05rem;font-weight:900;letter-spacing:-.03em; .v5-hdr-logo{font-size:1.05rem;font-weight:900;letter-spacing:-.03em;
background:linear-gradient(135deg,var(--v5-primary),var(--v5-cyan));-webkit-background-clip:text; background:linear-gradient(135deg,var(--v5-primary),var(--v5-accent));-webkit-background-clip:text;
-webkit-text-fill-color:transparent;background-clip:text;cursor:default;} -webkit-text-fill-color:transparent;background-clip:text;cursor:default;}
.v5-hdr-bc{font-size:.8125rem;color:var(--v5-text-muted);} .v5-hdr-bc{font-size:.8125rem;color:var(--v5-text-muted);}
.v5-hdr-bc b{color:var(--v5-text);font-weight:600;} .v5-hdr-bc b{color:var(--v5-text);font-weight:600;}
@@ -387,7 +402,7 @@ html:not(.dark) .v5-hdr{
/* Admin mode header accent */ /* Admin mode header accent */
.v5-admin-mode .v5-hdr{border-bottom-color:var(--v5-primary);} .v5-admin-mode .v5-hdr{border-bottom-color:var(--v5-primary);}
.v5-admin-mode .v5-hdr::after{content:'';position:absolute;bottom:-1px;left:0;right:0;height:2px; .v5-admin-mode .v5-hdr::after{content:'';position:absolute;bottom:-1px;left:0;right:0;height:2px;
background:linear-gradient(90deg,var(--v5-primary),var(--v5-cyan));animation:v5-glowLine .6s ease-out both;} background:linear-gradient(90deg,var(--v5-primary),var(--v5-accent));animation:v5-glowLine .6s ease-out both;}
@keyframes v5-glowLine{from{transform:scaleX(0);opacity:0}to{transform:scaleX(1);opacity:1}} @keyframes v5-glowLine{from{transform:scaleX(0);opacity:0}to{transform:scaleX(1);opacity:1}}
/* Admin badge — display:none 대신 opacity/transform 으로 hidden 해서 zoom-in/out 애니메이션 가능 */ /* Admin badge — display:none 대신 opacity/transform 으로 hidden 해서 zoom-in/out 애니메이션 가능 */
@@ -413,19 +428,25 @@ html:not(.dark) .v5-hdr{
@keyframes v5-bdPulse{0%,100%{box-shadow:0 0 4px var(--v5-primary-glow)}50%{box-shadow:0 0 12px var(--v5-primary-glow)}} @keyframes v5-bdPulse{0%,100%{box-shadow:0 0 4px var(--v5-primary-glow)}50%{box-shadow:0 0 12px var(--v5-primary-glow)}}
/* ===== SOLID TABS ===== */ /* ===== SOLID TABS ===== */
.v5-tabs{height:36px;display:flex;align-items:stretch;padding:4px .5rem 0;gap:2px;overflow-x:auto; .v5-tabs{height:36px;display:flex;align-items:stretch;padding:0 .5rem;gap:1px;overflow-x:auto;
background:var(--v5-surface-solid); background:var(--v5-surface-solid);
border-bottom:1px solid var(--v5-border);position:relative;z-index:15;flex-shrink:0; border-bottom:1px solid var(--v5-border);position:relative;z-index:15;flex-shrink:0;
scrollbar-width:none;-ms-overflow-style:none;} scrollbar-width:none;-ms-overflow-style:none;}
.v5-tabs::-webkit-scrollbar{display:none;} .v5-tabs::-webkit-scrollbar{display:none;}
/* Chrome 식 outline 탭: 비활성도 카드처럼 각각 outline. 활성 탭은 본문과 seamless + primary 강조선 */
.v5-tab{display:flex;align-items:center;gap:.4rem;padding:0 .85rem;font-size:.7rem;font-weight:500; .v5-tab{display:flex;align-items:center;gap:.4rem;padding:0 .85rem;font-size:.7rem;font-weight:500;
color:var(--v5-text-muted);cursor:pointer;white-space:nowrap;transition:color .15s,border-color .15s,background .15s; color:var(--v5-text-muted);cursor:pointer;border-bottom:2px solid transparent;white-space:nowrap;transition:all .25s;
border:1px solid var(--v5-border);border-radius:8px 8px 0 0;margin-bottom:-1px;} transform-origin:bottom center;animation:v5-tabIn .24s cubic-bezier(.16,1,.3,1) both;}
.v5-tab:hover{color:var(--v5-text-sec);background:var(--v5-surface-hover);} .v5-tab:hover{color:var(--v5-text-sec);background:var(--v5-surface-hover);}
.v5-tab.on{color:var(--v5-primary);font-weight:600; .v5-tab.on{color:var(--v5-primary);font-weight:600;border-bottom-color:transparent;background:var(--v5-surface);}
border-color:var(--v5-border);border-bottom-color:var(--v5-surface-hover); .v5-tab.closing{animation:v5-tabOut .18s cubic-bezier(.4,0,1,1) both;pointer-events:none;}
background:var(--v5-surface-hover);box-shadow:0 -1px 0 var(--v5-primary) inset;} @keyframes v5-tabIn{from{opacity:0;transform:translateY(-3px) scale(.92);}to{opacity:1;transform:none;}}
@keyframes v5-tabOut{to{opacity:0;transform:translateY(-2px) scale(.85);}}
/* Active tab underline indicator — slides between tabs (Chrome devtools style) */
.v5-tab-indicator{position:absolute;left:0;bottom:-1px;height:2px;
background:var(--v5-primary);box-shadow:0 0 8px rgba(var(--v5-primary-rgb),.6);
border-radius:1px;pointer-events:none;z-index:2;
transition:transform .38s cubic-bezier(.83,0,.17,1),width .38s cubic-bezier(.83,0,.17,1),opacity .2s ease-out;
will-change:transform,width;}
.v5-tab-x{width:14px;height:14px;border-radius:3px;border:none;background:transparent;color:var(--v5-text-muted); .v5-tab-x{width:14px;height:14px;border-radius:3px;border:none;background:transparent;color:var(--v5-text-muted);
font-size:.6rem;cursor:pointer;display:flex;align-items:center;justify-content:center;opacity:0;transition:all .15s;} font-size:.6rem;cursor:pointer;display:flex;align-items:center;justify-content:center;opacity:0;transition:all .15s;}
.v5-tab:hover .v5-tab-x{opacity:1;} .v5-tab:hover .v5-tab-x{opacity:1;}
@@ -1524,9 +1545,22 @@ html.vt-color-changing .v5-admin-btn{
=================================================================== */ =================================================================== */
/* .v5-nrm 자체에는 background 깔아서 body .dark radial-gradient (globals.css) 비치게 . /* .v5-nrm 자체에는 background 깔아서 body .dark radial-gradient (globals.css) 비치게 .
sidebar main surface-solid 깔고, 헤더 영역은 투명 테마 컬러가 자연스럽게 헤더에 비침. */ sidebar main surface-solid 깔고, 헤더 영역은 투명 테마 컬러가 자연스럽게 헤더에 비침. */
.v5-nrm{display:flex;flex-direction:column;height:100%;overflow:hidden;} .v5-nrm{display:flex;flex-direction:column;height:100%;overflow:hidden;background:var(--v5-surface-solid);}
.v5-nrm-body{flex:1;min-height:0;display:grid;grid-template-columns:320px 1fr;overflow:hidden;} .v5-nrm-body{flex:1;min-height:0;display:grid;grid-template-columns:320px 1fr;overflow:hidden;}
/* top workbench header */
.v5-nrm-topbar{padding:.85rem 1.25rem;border-bottom:1px solid var(--v5-border);display:grid;grid-template-columns:minmax(260px,1fr) auto auto;align-items:center;gap:1rem;flex-shrink:0;background:var(--v5-surface-solid);}
.v5-nrm-topcopy{min-width:0;}
.v5-nrm-topcopy h1{margin:0;display:flex;align-items:center;gap:.5rem;font-size:.98rem;font-weight:800;letter-spacing:-.01em;color:var(--v5-text);}
.v5-nrm-titlemark{width:20px;height:20px;border-radius:5px;display:inline-flex;align-items:center;justify-content:center;background:var(--v5-primary);color:#fff;font-family:var(--v5-font-mono);font-size:.82rem;font-weight:900;box-shadow:0 8px 20px -12px rgba(var(--v5-primary-rgb),.8);}
.v5-nrm-topcopy p{margin:.18rem 0 0;color:var(--v5-text-muted);font-size:.66rem;}
.v5-nrm-topstats{display:flex;align-items:center;gap:.45rem;padding-left:1rem;border-left:1px solid var(--v5-border);font-family:var(--v5-font-mono);font-size:.58rem;color:var(--v5-text-muted);white-space:nowrap;}
.v5-nrm-stat{display:inline-flex;align-items:center;gap:4px;height:22px;padding:0 .45rem;border:1px solid var(--v5-border);border-radius:5px;background:var(--v5-bg-subtle);}
.v5-nrm-stat b{color:var(--v5-text);font-weight:800;}
.v5-nrm-stat.green b{color:rgb(var(--v5-green-rgb));}
.v5-nrm-stat.amber b{color:rgb(var(--v5-amber-rgb));}
.v5-nrm-topactions{display:flex;align-items:center;justify-content:flex-end;gap:.35rem;}
/* ── 좌측 sidebar ── */ /* ── 좌측 sidebar ── */
.v5-nrm-side{border-right:1px solid var(--v5-border);background:var(--v5-surface-solid);display:flex;flex-direction:column;overflow:hidden;} .v5-nrm-side{border-right:1px solid var(--v5-border);background:var(--v5-surface-solid);display:flex;flex-direction:column;overflow:hidden;}
.v5-nrm-side-srch{position:relative;padding:.55rem .65rem;border-bottom:1px solid var(--v5-border);} .v5-nrm-side-srch{position:relative;padding:.55rem .65rem;border-bottom:1px solid var(--v5-border);}
@@ -1562,6 +1596,7 @@ html.vt-color-changing .v5-admin-btn{
.v5-nrm-dot.off{background:var(--v5-text-muted);opacity:.4;} .v5-nrm-dot.off{background:var(--v5-text-muted);opacity:.4;}
@keyframes v5-nrm-pulse{0%,100%{opacity:1;}50%{opacity:.35;transform:scale(.88);}} @keyframes v5-nrm-pulse{0%,100%{opacity:1;}50%{opacity:.35;transform:scale(.88);}}
.v5-nrm-side-foot{padding:.5rem .65rem;border-top:1px solid var(--v5-border);display:flex;gap:.3rem;} .v5-nrm-side-foot{padding:.5rem .65rem;border-top:1px solid var(--v5-border);display:flex;gap:.3rem;}
.v5-nrm-side-foot .v5-nrm-btn{flex:1;justify-content:center;}
/* tone colors */ /* tone colors */
.v5-nrm-tone{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1;} .v5-nrm-tone{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1;}
@@ -1575,94 +1610,171 @@ html.vt-color-changing .v5-admin-btn{
.v5-nrm-tone.muted{background:var(--v5-bg-subtle);color:var(--v5-text-muted);} .v5-nrm-tone.muted{background:var(--v5-bg-subtle);color:var(--v5-text-muted);}
/* ── 우측 main (통짜) ── */ /* ── 우측 main (통짜) ── */
.v5-nrm-main{overflow-y:auto;background:var(--v5-surface-solid);position:relative;} .v5-nrm-main{overflow-y:auto;background:var(--v5-surface-solid);background-image:radial-gradient(circle,rgba(var(--v5-primary-rgb),.08) 1px,transparent 1px);background-size:14px 14px;background-position:0 0;position:relative;}
/* HERO (통짜, 카드 X) */ /* keyframes — v4 EDIT 모드 마이크로 애니메이션 */
.v5-nrm-hero{padding:1rem 1.5rem 1.1rem;border-bottom:1px solid var(--v5-border);position:relative;} @keyframes v5-nrm-pop{0%{opacity:0;transform:scale(.6);}60%{opacity:1;transform:scale(1.06);}100%{transform:scale(1);}}
.v5-nrm-hero::before{content:'';position:absolute;top:0;left:0;right:0;height:2px;background:linear-gradient(90deg,rgb(var(--v5-cyan-rgb)),var(--v5-primary),rgb(var(--v5-pink-rgb)));} @keyframes v5-nrm-slide-up{from{opacity:0;transform:translateY(8px);}to{opacity:1;transform:translateY(0);}}
.v5-nrm-hero-top{display:flex;align-items:center;gap:.9rem;margin-bottom:.85rem;} @keyframes v5-nrm-slide-down{from{opacity:0;transform:translateY(-6px);}to{opacity:1;transform:translateY(0);}}
.v5-nrm-hero-top .v5-nrm-tone{width:40px;height:40px;border-radius:10px;} @keyframes v5-nrm-pulse-cur{0%,100%{box-shadow:0 0 0 0 rgba(var(--v5-amber-rgb),.5);}50%{box-shadow:0 0 0 5px rgba(var(--v5-amber-rgb),0);}}
.v5-nrm-hero-top .v5-nrm-tone svg{width:18px;height:18px;}
.v5-nrm-hero-info{flex:1;min-width:0;} /* DETAIL HEAD (제목 + chip + meta + 우측 액션) */
.v5-nrm-hero-row1{display:flex;align-items:center;gap:6px;margin-bottom:3px;flex-wrap:wrap;min-width:0;} .v5-nrm-detail-head{padding:.95rem 1.4rem;border-bottom:1px solid var(--v5-border);display:flex;align-items:center;justify-content:space-between;gap:1rem;background:var(--v5-surface-solid);animation:v5-nrm-slide-down .45s cubic-bezier(.16,1,.3,1);}
.v5-nrm-hero-info{min-width:0;} .v5-nrm-detail-head .l{display:flex;flex-direction:column;gap:4px;min-width:0;flex:1;}
.v5-nrm-hero-info h2{margin:0;font-size:1.2rem;font-weight:800;letter-spacing:-.02em;color:var(--v5-text);} .v5-nrm-detail-head h2{margin:0;font-size:.98rem;font-weight:700;letter-spacing:-.01em;display:flex;align-items:center;gap:6px;flex-wrap:wrap;color:var(--v5-text);}
.v5-nrm-hero-meta{display:flex;gap:1rem;font-family:'JetBrains Mono',monospace;font-size:.62rem;color:var(--v5-text-muted);} .v5-nrm-detail-head .meta{display:flex;gap:.85rem;font-size:.6rem;color:var(--v5-text-muted);flex-wrap:wrap;font-family:'JetBrains Mono',monospace;}
.v5-nrm-hero-meta b{color:var(--v5-text-sec);font-weight:600;} .v5-nrm-detail-head .meta b{color:var(--v5-text-sec);font-weight:600;}
.v5-nrm-hero-actions{display:flex;gap:.3rem;align-items:center;} .v5-nrm-detail-head .r{display:flex;gap:.25rem;}
.v5-nrm-hero-codes{display:grid;grid-template-columns:1fr auto 1fr;align-items:end;gap:1.5rem;}
.v5-nrm-hero-code-block .lbl{font-family:'JetBrains Mono',monospace;font-size:.52rem;color:var(--v5-text-muted);font-weight:800;letter-spacing:.14em;text-transform:uppercase;margin-bottom:5px;} /* PATTERN-VIS (현재/다음 발번 시각화) */
.v5-nrm-hero-code-block .code{font-family:'JetBrains Mono',monospace;font-size:2rem;font-weight:800;letter-spacing:.04em;line-height:1;color:var(--v5-text);} .v5-nrm-pattern-vis{padding:1rem 1.4rem 1.1rem;border-bottom:1px solid var(--v5-border);background:var(--v5-bg-subtle);animation:v5-nrm-slide-up .5s cubic-bezier(.16,1,.3,1) .08s both;}
.v5-nrm-hero-code-block.right{text-align:right;} .v5-nrm-pv-bar{display:flex;align-items:center;gap:8px;margin-bottom:.55rem;font-family:'JetBrains Mono',monospace;font-size:.58rem;color:var(--v5-text-muted);font-weight:800;letter-spacing:.12em;text-transform:uppercase;}
.v5-nrm-hero-code-block .code .pfx{color:rgb(var(--v5-cyan-rgb));} .v5-nrm-pv-bar .e{font-family:'JetBrains Mono',monospace;font-size:.62rem;color:var(--v5-primary);background:rgba(var(--v5-primary-rgb),.08);padding:1px 7px;border-radius:4px;text-transform:none;letter-spacing:0;}
.v5-nrm-hero-code-block .code .seq{color:rgb(var(--v5-pink-rgb));} .v5-nrm-pattern-line{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
.v5-nrm-hero-code-block .code .sep{color:var(--v5-text-muted);opacity:.55;margin:0 .08em;font-weight:400;} .v5-nrm-pattern-piece{display:flex;flex-direction:column;align-items:center;gap:3px;animation:v5-nrm-pop .5s cubic-bezier(.34,1.56,.64,1) both;}
.v5-nrm-hero-code-block .sub{margin-top:6px;font-family:'JetBrains Mono',monospace;font-size:.58rem;color:var(--v5-text-muted);} .v5-nrm-pattern-piece:nth-child(2){animation-delay:.15s;}
.v5-nrm-hero-arrow{color:var(--v5-primary);align-self:center;margin-bottom:4px;} .v5-nrm-pattern-piece:nth-child(4){animation-delay:.25s;}
.v5-nrm-hero-arrow svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;} .v5-nrm-pattern-piece:nth-child(6){animation-delay:.35s;}
.v5-nrm-pattern-piece:nth-child(8){animation-delay:.45s;}
.v5-nrm-pattern-piece:nth-child(10){animation-delay:.55s;}
.v5-nrm-pattern-piece:nth-child(12){animation-delay:.65s;}
.v5-nrm-pattern-piece .top-lbl{font-size:.55rem;color:var(--v5-text-muted);font-weight:700;letter-spacing:.04em;}
.v5-nrm-pattern-piece .v{font-family:'JetBrains Mono',monospace;font-size:1.1rem;font-weight:700;color:var(--v5-text);padding:2px 8px;background:var(--v5-surface-solid);border:1px solid var(--v5-border);border-radius:5px;transition:transform .3s cubic-bezier(.34,1.56,.64,1),border-color .3s,color .3s,box-shadow .3s;cursor:pointer;}
.v5-nrm-pattern-piece .v:hover{transform:translateY(-2px) scale(1.05);border-color:var(--v5-primary);box-shadow:0 0 0 3px rgba(var(--v5-primary-rgb),.10);}
.v5-nrm-pattern-piece .v.cur{color:var(--v5-primary);border-color:var(--v5-primary);background:rgba(var(--v5-primary-rgb),.08);animation:v5-nrm-pulse-cur 2.2s ease-in-out infinite;}
.v5-nrm-pattern-sep{font-family:'JetBrains Mono',monospace;font-size:1rem;color:var(--v5-text-muted);font-weight:600;align-self:flex-end;padding-bottom:3px;}
.v5-nrm-pattern-next{margin-top:.7rem;font-size:.65rem;color:var(--v5-text-muted);display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
.v5-nrm-pattern-next b{font-family:'JetBrains Mono',monospace;color:var(--v5-primary);font-weight:700;padding:1px 5px;background:rgba(var(--v5-primary-rgb),.08);border-radius:3px;transition:transform .25s cubic-bezier(.34,1.56,.64,1);}
.v5-nrm-pattern-next b:hover{transform:scale(1.05);}
/* hero preview — receipt/ticket metaphor for generated identity */
.v5-nrm-hero-wrap{padding:1.35rem 1.4rem 1.1rem;border-bottom:1px solid var(--v5-border);display:flex;justify-content:center;background:transparent;animation:v5-nrm-slide-up .5s cubic-bezier(.16,1,.3,1) .08s both;}
.v5-nrm-hero-card{position:relative;width:100%;max-width:720px;padding:2rem 1.45rem 1.05rem;background:var(--v5-bg-paper,var(--v5-bg-subtle));border:1.5px dashed var(--v5-border);border-radius:10px;box-shadow:0 14px 40px -30px rgba(var(--v5-primary-rgb),.8);}
.v5-nrm-hero-card::before,.v5-nrm-hero-card::after{content:'';position:absolute;top:50%;width:16px;height:16px;background:var(--v5-surface-solid);border:1.5px dashed var(--v5-border);border-radius:50%;transform:translateY(-50%);}
.v5-nrm-hero-card::before{left:-9px;}
.v5-nrm-hero-card::after{right:-9px;}
.v5-nrm-hero-tab{position:absolute;top:-11px;left:1.35rem;padding:2px 10px;background:var(--v5-primary);color:#fff;border-radius:4px;font-family:var(--v5-font-mono);font-size:.58rem;font-weight:800;letter-spacing:.06em;display:inline-flex;align-items:center;gap:5px;}
.v5-nrm-hero-tab svg{width:11px;height:11px;stroke:currentColor;fill:none;stroke-width:2.2;}
.v5-nrm-hero-corner{position:absolute;top:.85rem;right:1rem;max-width:46%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--v5-font-mono);font-size:.54rem;color:var(--v5-text-muted);letter-spacing:.04em;text-transform:uppercase;font-weight:700;}
.v5-nrm-hero-code{display:flex;align-items:flex-start;justify-content:center;gap:0;flex-wrap:wrap;font-family:var(--v5-font-mono);font-weight:800;letter-spacing:.015em;margin-bottom:.55rem;min-height:54px;}
.v5-nrm-hero-piece-wrap{display:inline-flex;align-items:flex-start;}
.v5-nrm-hero-part{appearance:none;border:0;background:transparent;padding:0 .35rem;display:inline-flex;flex-direction:column;align-items:center;gap:3px;color:var(--v5-text);cursor:pointer;border-radius:7px;transition:background .15s,transform .2s cubic-bezier(.34,1.56,.64,1),color .15s;}
.v5-nrm-hero-part:hover{background:rgba(var(--v5-primary-rgb),.06);transform:translateY(-2px);}
.v5-nrm-hero-part .v{font-family:var(--v5-font-mono);font-size:clamp(1.3rem,2.6vw,1.95rem);line-height:1.08;font-weight:900;color:currentColor;}
.v5-nrm-hero-part .lbl{font-size:.54rem;color:var(--v5-text-muted);font-weight:800;letter-spacing:.04em;font-family:var(--v5-font-sans);}
.v5-nrm-hero-part.sequence,.v5-nrm-hero-part.sel{color:var(--v5-primary);}
.v5-nrm-hero-part.sequence .v{background:rgba(var(--v5-primary-rgb),.08);border:1.5px solid var(--v5-primary);border-radius:6px;padding:1px .5rem 2px;box-shadow:0 0 0 4px rgba(var(--v5-primary-rgb),.08);}
.v5-nrm-hero-part.text{color:rgb(var(--v5-cyan-rgb));}
.v5-nrm-hero-part.date{color:var(--v5-text);}
.v5-nrm-hero-part.number{color:rgb(var(--v5-amber-rgb));}
.v5-nrm-hero-part.category{color:var(--v5-primary);}
.v5-nrm-hero-part.reference{color:rgb(var(--v5-green-rgb));}
.v5-nrm-hero-sep{font-family:var(--v5-font-mono);font-size:clamp(1.3rem,2.6vw,1.95rem);line-height:1.1;color:var(--v5-text-muted);font-weight:900;padding:0 2px;align-self:flex-start;margin-top:-3px;}
.v5-nrm-hero-intent{text-align:center;font-size:.68rem;color:var(--v5-text-sec);margin:.25rem 0 .9rem;}
.v5-nrm-hero-intent b{font-family:var(--v5-font-mono);color:var(--v5-text);font-weight:900;}
.v5-nrm-hero-foot{border-top:1px dashed var(--v5-border);padding-top:.7rem;display:flex;justify-content:space-between;flex-wrap:wrap;gap:.55rem;font-family:var(--v5-font-mono);font-size:.58rem;color:var(--v5-text-muted);letter-spacing:.03em;}
.v5-nrm-hero-foot .it{display:inline-flex;align-items:center;gap:5px;}
.v5-nrm-hero-foot .k{color:var(--v5-text-muted);font-weight:800;text-transform:uppercase;letter-spacing:.08em;font-size:.52rem;}
.v5-nrm-hero-foot .v{color:var(--v5-text);font-weight:800;font-size:.66rem;}
.v5-nrm-hero-foot .v.up{color:rgb(var(--v5-green-rgb));}
.v5-nrm-hero-empty{width:100%;max-width:720px;min-height:132px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.35rem;background:var(--v5-bg-subtle);border:1.5px dashed var(--v5-border);border-radius:10px;color:var(--v5-text-muted);font-size:.7rem;}
.v5-nrm-hero-empty b{font-size:.82rem;color:var(--v5-text);}
.v5-nrm-hero-empty svg{opacity:.55;stroke:currentColor;fill:none;stroke-width:1.7;}
/* row + 2-col split */ /* row + 2-col split */
.v5-nrm-row{padding:1rem 1.5rem 1.1rem;border-bottom:1px solid var(--v5-border);} .v5-nrm-row{padding:1rem 1.5rem 1.1rem;border-bottom:1px solid var(--v5-border);background:var(--v5-surface-solid);}
.v5-nrm-row-hd{display:flex;align-items:center;gap:8px;margin-bottom:.75rem;} .v5-nrm-row-hd{display:flex;align-items:center;gap:8px;margin-bottom:.75rem;}
.v5-nrm-row-hd h3{margin:0;font-size:.82rem;font-weight:800;color:var(--v5-text);letter-spacing:-.005em;} .v5-nrm-row-hd h3{margin:0;font-size:.82rem;font-weight:800;color:var(--v5-text);letter-spacing:-.005em;}
.v5-nrm-row-hd .num{font-family:'JetBrains Mono',monospace;font-size:.58rem;color:rgb(var(--v5-cyan-rgb));font-weight:800;padding:1px 6px;background:rgba(var(--v5-cyan-rgb),.1);border-radius:4px;} .v5-nrm-row-hd .num{font-family:'JetBrains Mono',monospace;font-size:.58rem;color:rgb(var(--v5-cyan-rgb));font-weight:800;padding:1px 6px;background:rgba(var(--v5-cyan-rgb),.1);border-radius:4px;}
.v5-nrm-row-hd .desc{font-family:'JetBrains Mono',monospace;font-size:.58rem;color:var(--v5-text-muted);margin-left:6px;} .v5-nrm-row-hd .desc{font-family:'JetBrains Mono',monospace;font-size:.58rem;color:var(--v5-text-muted);margin-left:6px;}
.v5-nrm-row-hd .actions{margin-left:auto;display:flex;gap:.25rem;} .v5-nrm-row-hd .actions{margin-left:auto;display:flex;gap:.25rem;}
.v5-nrm-row-split{display:grid;grid-template-columns:1fr 1fr;border-bottom:1px solid var(--v5-border);} .v5-nrm-row-split{display:grid;grid-template-columns:1fr 1fr;border-bottom:1px solid var(--v5-border);}
.v5-nrm-row-split > div{padding:1rem 1.5rem 1.1rem;} .v5-nrm-row-split > div{padding:1rem 1.5rem 1.1rem;background:var(--v5-surface-solid);}
.v5-nrm-row-split > div + div{border-left:1px solid var(--v5-border);} .v5-nrm-row-split > div + div{border-left:1px solid var(--v5-border);}
/* pipeline editor */ /* pipeline editor — v4 시안: pipe-slot + pipe-block + add-here + drop(end) */
.v5-nrm-pipe-canvas{padding:.9rem 1rem;background:var(--v5-bg-subtle);border:1px dashed var(--v5-border);border-radius:8px;display:flex;align-items:stretch;gap:4px;flex-wrap:wrap;margin-bottom:.55rem;} .v5-nrm-pipe-canvas{padding:.65rem .75rem .5rem;background:var(--v5-bg-subtle);border:1px solid var(--v5-border);border-radius:8px;display:flex;align-items:stretch;flex-wrap:wrap;gap:0;margin-bottom:.55rem;}
.v5-nrm-block{position:relative;display:flex;flex-direction:column;align-items:center;gap:3px;padding:.5rem .75rem;background:var(--v5-surface-solid);border:2px solid var(--v5-border);border-radius:8px;cursor:pointer;transition:all .15s;min-width:78px;}
.v5-nrm-block:hover{transform:translateY(-1px);box-shadow:0 4px 12px -4px rgba(var(--v5-primary-rgb),.25);} /* pipe-slot — 블록 사이 separator + hover 시 + 등장 */
.v5-nrm-block.sel{border-color:var(--v5-primary);box-shadow:0 0 0 3px rgba(var(--v5-primary-rgb),.14);} .v5-nrm-slot{display:inline-flex;align-items:center;width:18px;position:relative;align-self:stretch;}
.v5-nrm-block .pin{position:absolute;top:-6px;left:-6px;width:16px;height:16px;background:var(--v5-primary);color:#fff;border-radius:50%;font-family:'JetBrains Mono',monospace;font-size:.55rem;font-weight:800;display:flex;align-items:center;justify-content:center;border:2px solid var(--v5-surface-solid);} .v5-nrm-slot .sep{font-family:'JetBrains Mono',monospace;font-size:.7rem;color:var(--v5-text-muted);font-weight:600;user-select:none;width:100%;text-align:center;transition:color .2s,transform .25s;align-self:center;}
.v5-nrm-block .x{position:absolute;top:-6px;right:-6px;width:15px;height:15px;background:var(--v5-surface-solid);border:1px solid var(--v5-border);color:var(--v5-text-muted);border-radius:50%;font-size:.68rem;line-height:1;display:none;align-items:center;justify-content:center;cursor:pointer;} .v5-nrm-slot:hover .sep{color:var(--v5-primary);transform:scale(.9);}
.v5-nrm-block:hover .x{display:flex;} .v5-nrm-slot .add-here{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) scale(.5);width:18px;height:18px;border-radius:50%;background:var(--v5-primary);color:#fff;border:2px solid var(--v5-bg-subtle);display:flex;align-items:center;justify-content:center;cursor:pointer;opacity:0;transition:opacity .2s cubic-bezier(.16,1,.3,1),transform .3s cubic-bezier(.34,1.56,.64,1),box-shadow .25s;z-index:2;padding:0;}
.v5-nrm-block .x:hover{color:rgb(var(--v5-red-rgb));border-color:rgb(var(--v5-red-rgb));} .v5-nrm-slot .add-here svg{width:9px;height:9px;stroke:currentColor;fill:none;stroke-width:2.5;}
.v5-nrm-block .typ{font-family:'JetBrains Mono',monospace;font-size:.52rem;color:var(--v5-text-muted);font-weight:800;letter-spacing:.08em;text-transform:uppercase;} .v5-nrm-slot:hover .add-here{opacity:1;transform:translate(-50%,-50%) scale(1);}
.v5-nrm-block .val{font-family:'JetBrains Mono',monospace;font-size:.9rem;font-weight:800;color:var(--v5-text);} .v5-nrm-slot .add-here:hover{transform:translate(-50%,-50%) scale(1.2) rotate(90deg);box-shadow:0 0 0 4px rgba(var(--v5-primary-rgb),.25);}
.v5-nrm-block.text{border-color:rgba(var(--v5-cyan-rgb),.4);}
/* pipe-block — v4 디자인 (top-row 에 ord+lbl+typ-en, 아래 val) */
.v5-nrm-block{display:inline-flex;flex-direction:column;gap:3px;padding:8px 11px;background:var(--v5-surface-solid);border:1px solid var(--v5-border);border-radius:6px;cursor:pointer;position:relative;transition:border-color .25s cubic-bezier(.16,1,.3,1),background .25s,transform .25s cubic-bezier(.34,1.56,.64,1),box-shadow .3s;min-width:90px;animation:v5-nrm-slide-up .4s cubic-bezier(.16,1,.3,1) both;}
.v5-nrm-block:hover{border-color:rgba(var(--v5-primary-rgb),.22);transform:translateY(-3px);box-shadow:0 6px 22px -8px rgba(var(--v5-primary-rgb),.35);}
.v5-nrm-block.sel{border-color:var(--v5-primary);box-shadow:0 0 0 4px rgba(var(--v5-primary-rgb),.16);transform:translateY(-2px);}
.v5-nrm-block .top-row{display:flex;align-items:center;gap:5px;}
.v5-nrm-block .ord{font-family:'JetBrains Mono',monospace;font-size:.54rem;font-weight:700;color:var(--v5-text-muted);}
.v5-nrm-block .lbl{font-size:.64rem;font-weight:600;color:var(--v5-text-sec);transition:color .25s;}
.v5-nrm-block:hover .lbl{color:var(--v5-text);}
.v5-nrm-block .typ-en{font-family:'JetBrains Mono',monospace;font-size:.52rem;color:var(--v5-text-muted);text-transform:uppercase;letter-spacing:.06em;}
.v5-nrm-block .val{font-family:'JetBrains Mono',monospace;font-size:.8rem;font-weight:700;color:var(--v5-text);margin-top:1px;transition:color .25s;}
.v5-nrm-block.sel .val{color:var(--v5-primary);}
.v5-nrm-block .x{position:absolute;top:-6px;right:-6px;width:16px;height:16px;background:var(--v5-surface-solid);border:1px solid var(--v5-border);border-radius:50%;color:var(--v5-text-muted);font-size:11px;line-height:1;display:flex;align-items:center;justify-content:center;cursor:pointer;opacity:0;transform:scale(0);transition:opacity .2s,transform .3s cubic-bezier(.34,1.56,.64,1),color .2s,border-color .2s;padding:0;}
.v5-nrm-block:hover .x{opacity:1;transform:scale(1);}
.v5-nrm-block .x:hover{color:rgb(var(--v5-red-rgb));border-color:rgb(var(--v5-red-rgb));transform:scale(1.15) rotate(90deg);}
/* 타입별 액센트 (val 색만, border 는 hover/sel 만) */
.v5-nrm-block.text .val{color:rgb(var(--v5-cyan-rgb));} .v5-nrm-block.text .val{color:rgb(var(--v5-cyan-rgb));}
.v5-nrm-block.date{border-color:rgba(var(--v5-primary-rgb),.35);}
.v5-nrm-block.date .val{color:var(--v5-primary);} .v5-nrm-block.date .val{color:var(--v5-primary);}
.v5-nrm-block.sequence{border-color:rgba(var(--v5-pink-rgb),.4);}
.v5-nrm-block.sequence .val{color:rgb(var(--v5-pink-rgb));} .v5-nrm-block.sequence .val{color:rgb(var(--v5-pink-rgb));}
.v5-nrm-block.number{border-color:rgba(var(--v5-amber-rgb),.4);}
.v5-nrm-block.number .val{color:rgb(var(--v5-amber-rgb));} .v5-nrm-block.number .val{color:rgb(var(--v5-amber-rgb));}
.v5-nrm-block.category{border-color:rgba(var(--v5-primary-rgb),.35);}
.v5-nrm-block.category .val{color:var(--v5-primary);} .v5-nrm-block.category .val{color:var(--v5-primary);}
.v5-nrm-block.reference{border-color:rgba(var(--v5-green-rgb),.4);}
.v5-nrm-block.reference .val{color:rgb(var(--v5-green-rgb));} .v5-nrm-block.reference .val{color:rgb(var(--v5-green-rgb));}
/* 옛 jn 클래스는 page.tsx 가 더 이상 사용하지 않지만 안전을 위해 보존 */
.v5-nrm-jn{align-self:center;color:var(--v5-text-muted);font-family:'JetBrains Mono',monospace;font-weight:700;padding:0 2px;font-size:.85rem;} .v5-nrm-jn{align-self:center;color:var(--v5-text-muted);font-family:'JetBrains Mono',monospace;font-weight:700;padding:0 2px;font-size:.85rem;}
.v5-nrm-drop{display:flex;align-items:center;justify-content:center;flex-direction:column;gap:2px;padding:.5rem .65rem;border:2px dashed rgba(var(--v5-primary-rgb),.22);border-radius:8px;color:var(--v5-text-muted);cursor:pointer;font-size:.56rem;font-weight:700;min-width:64px;background:transparent;transition:all .15s;font-family:inherit;}
.v5-nrm-drop:hover{border-color:var(--v5-primary);color:var(--v5-primary);background:rgba(var(--v5-primary-rgb),.03);}
.v5-nrm-drop svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
/* inspector */ /* end-of-pipe 추가 버튼 (v4 의 pipe-add-end) */
.v5-nrm-insp{background:var(--v5-bg-subtle);border:1px solid var(--v5-border);border-radius:8px;padding:.65rem .85rem;margin-bottom:.55rem;} .v5-nrm-drop{display:inline-flex;align-items:center;gap:5px;padding:8px 11px;background:transparent;border:1px dashed rgba(var(--v5-primary-rgb),.22);border-radius:6px;color:var(--v5-text-muted);font-size:.68rem;cursor:pointer;margin-left:8px;align-self:stretch;transition:color .25s,border-color .25s,background .25s,transform .25s cubic-bezier(.34,1.56,.64,1);font-family:inherit;font-weight:600;animation:v5-nrm-slide-up .4s cubic-bezier(.16,1,.3,1) .55s both;}
.v5-nrm-insp-hd{display:flex;align-items:center;justify-content:space-between;margin-bottom:.55rem;} .v5-nrm-drop:hover{color:var(--v5-primary);border-color:var(--v5-primary);background:rgba(var(--v5-primary-rgb),.06);transform:translateY(-2px) scale(1.02);}
.v5-nrm-insp-hd .l{display:flex;align-items:center;gap:6px;font-family:'JetBrains Mono',monospace;font-size:.62rem;color:var(--v5-text);font-weight:700;letter-spacing:.04em;text-transform:uppercase;} .v5-nrm-drop svg{width:11px;height:11px;stroke:currentColor;fill:none;stroke-width:2;transition:transform .3s cubic-bezier(.34,1.56,.64,1);}
.v5-nrm-insp-hd .l .pin{background:var(--v5-primary);color:#fff;padding:1px 6px;border-radius:4px;font-size:.54rem;font-weight:800;} .v5-nrm-drop:hover svg{transform:rotate(90deg);}
.v5-nrm-insp-hd .l b{color:rgb(var(--v5-pink-rgb));}
.v5-nrm-insp-grid{display:flex;flex-wrap:wrap;gap:.65rem;align-items:end;}
.v5-nrm-insp-field{display:flex;flex-direction:column;gap:3px;}
.v5-nrm-insp-field.w80{flex:0 0 80px;}
.v5-nrm-insp-field.grow{flex:1;min-width:200px;}
.v5-nrm-insp-field label{font-size:.56rem;color:var(--v5-text-sec);font-weight:600;}
.v5-nrm-insp-inp{height:26px;padding:0 .45rem;background:var(--v5-surface-solid);border:1px solid var(--v5-border);border-radius:5px;color:var(--v5-text);font-family:'JetBrains Mono',monospace;font-size:.68rem;outline:none;width:100%;}
.v5-nrm-insp-inp:focus{border-color:var(--v5-primary);box-shadow:0 0 0 2px rgba(var(--v5-primary-rgb),.14);}
.v5-nrm-seg{display:inline-flex;gap:1px;padding:2px;background:var(--v5-surface-solid);border:1px solid var(--v5-border);border-radius:5px;width:fit-content;}
.v5-nrm-seg button{padding:3px 12px;border:none;background:transparent;color:var(--v5-text-muted);font-size:.58rem;font-weight:700;border-radius:3px;cursor:pointer;min-width:36px;font-family:inherit;}
.v5-nrm-seg button.on{background:var(--v5-primary);color:#fff;min-width:44px;}
/* palette */ /* inspector — v4 디자인 (위쪽 화살표 + fadeSlideDown + glow ring) */
.v5-nrm-palette{display:flex;gap:5px;align-items:center;flex-wrap:wrap;} .v5-nrm-insp{margin-top:.75rem;padding:.7rem .85rem .8rem;background:var(--v5-surface-solid);border:1px solid var(--v5-primary);border-radius:8px;box-shadow:0 0 0 4px rgba(var(--v5-primary-rgb),.16);position:relative;animation:v5-nrm-slide-down .4s cubic-bezier(.16,1,.3,1);transform-origin:top center;margin-bottom:.55rem;}
.v5-nrm-palette .lbl{font-family:'JetBrains Mono',monospace;font-size:.54rem;color:var(--v5-text-muted);font-weight:800;letter-spacing:.08em;margin-right:4px;text-transform:uppercase;} .v5-nrm-insp::before{content:'';position:absolute;top:-7px;left:1.5rem;width:12px;height:12px;background:var(--v5-surface-solid);border-top:1px solid var(--v5-primary);border-left:1px solid var(--v5-primary);transform:rotate(45deg);}
.v5-nrm-palette-item{display:inline-flex;align-items:center;gap:4px;padding:3px 8px;background:transparent;border:1px solid var(--v5-border);border-radius:5px;color:var(--v5-text);font-size:.62rem;font-weight:700;cursor:pointer;transition:all .12s;font-family:inherit;} .v5-nrm-insp-hd{display:flex;align-items:center;justify-content:space-between;margin-bottom:.65rem;padding-bottom:.55rem;border-bottom:1px solid var(--v5-divider);}
.v5-nrm-palette-item:hover{border-color:var(--v5-primary);color:var(--v5-primary);background:var(--v5-surface-hover);} .v5-nrm-insp-hd .l{display:flex;align-items:center;gap:8px;font-size:.72rem;color:var(--v5-text);}
.v5-nrm-palette-item svg{width:11px;height:11px;stroke:currentColor;fill:none;stroke-width:2;} .v5-nrm-insp-hd .l .pin{font-family:'JetBrains Mono',monospace;font-size:.58rem;font-weight:700;color:var(--v5-primary);padding:2px 6px;background:rgba(var(--v5-primary-rgb),.08);border-radius:4px;}
.v5-nrm-insp-hd .l b{font-weight:700;color:var(--v5-text);}
.v5-nrm-insp-grid{display:flex;flex-wrap:wrap;gap:.8rem 1rem;align-items:flex-end;}
.v5-nrm-insp-field{display:flex;flex-direction:column;gap:4px;}
.v5-nrm-insp-field.w80{flex:0 0 90px;}
.v5-nrm-insp-field.grow{flex:1;min-width:180px;}
.v5-nrm-insp-field label{font-size:.62rem;color:var(--v5-text-sec);font-weight:600;}
.v5-nrm-insp-field .hint{font-size:.58rem;color:var(--v5-text-muted);}
.v5-nrm-insp-inp{height:30px;padding:0 .55rem;background:var(--v5-surface-solid);border:1px solid var(--v5-border);border-radius:5px;color:var(--v5-text);font-family:'JetBrains Mono',monospace;font-size:.72rem;outline:none;width:100%;font-weight:600;}
.v5-nrm-insp-inp:focus{border-color:var(--v5-primary);box-shadow:0 0 0 3px rgba(var(--v5-primary-rgb),.12);}
/* segment — v4 디자인 (sliding indicator + sub) */
.v5-nrm-seg{display:inline-flex;border:1px solid var(--v5-border);border-radius:5px;overflow:hidden;background:var(--v5-surface-solid);flex-wrap:wrap;position:relative;}
.v5-nrm-seg button{padding:0 .7rem;height:30px;background:transparent;border:none;border-right:1px solid var(--v5-border);color:var(--v5-text-sec);font-size:.66rem;font-family:'JetBrains Mono',monospace;cursor:pointer;transition:background .2s cubic-bezier(.16,1,.3,1),color .25s,transform .15s;position:relative;overflow:hidden;font-weight:600;}
.v5-nrm-seg button:last-child{border-right:none;}
.v5-nrm-seg button:hover{background:var(--v5-surface-hover);color:var(--v5-text);}
.v5-nrm-seg button:active{transform:scale(.94);}
.v5-nrm-seg button.on{background:var(--v5-primary);color:#fff;}
.v5-nrm-seg button .sub{font-size:.52rem;opacity:.7;display:block;margin-top:1px;font-family:inherit;}
/* palette — v4 디자인 (en 추가, hover 회전) */
.v5-nrm-palette{margin-top:.6rem;display:flex;flex-wrap:wrap;gap:4px;align-items:center;}
.v5-nrm-palette .lbl{font-size:.62rem;color:var(--v5-text-muted);font-weight:600;margin-right:4px;}
.v5-nrm-palette-item{display:inline-flex;align-items:center;gap:5px;height:26px;padding:0 9px;background:var(--v5-surface-solid);border:1px solid var(--v5-border);border-radius:5px;color:var(--v5-text-sec);font-size:.66rem;cursor:pointer;transition:border-color .2s,color .2s,background .2s,transform .2s cubic-bezier(.34,1.56,.64,1);font-family:inherit;font-weight:600;animation:v5-nrm-slide-up .35s cubic-bezier(.16,1,.3,1) both;}
.v5-nrm-palette-item:nth-child(2){animation-delay:.6s;}
.v5-nrm-palette-item:nth-child(3){animation-delay:.65s;}
.v5-nrm-palette-item:nth-child(4){animation-delay:.7s;}
.v5-nrm-palette-item:nth-child(5){animation-delay:.75s;}
.v5-nrm-palette-item:nth-child(6){animation-delay:.8s;}
.v5-nrm-palette-item:nth-child(7){animation-delay:.85s;}
.v5-nrm-palette-item:hover{border-color:var(--v5-primary);color:var(--v5-primary);background:rgba(var(--v5-primary-rgb),.06);transform:translateY(-2px);}
.v5-nrm-palette-item:active{transform:translateY(0) scale(.95);}
.v5-nrm-palette-item svg{width:11px;height:11px;stroke:currentColor;fill:none;stroke-width:1.75;transition:transform .3s cubic-bezier(.34,1.56,.64,1);}
.v5-nrm-palette-item:hover svg{transform:rotate(-8deg) scale(1.15);}
.v5-nrm-palette-item .en{font-family:'JetBrains Mono',monospace;font-size:.56rem;color:var(--v5-text-muted);}
/* usage list */ /* usage list */
.v5-nrm-usage-list{display:flex;flex-direction:column;} .v5-nrm-usage-list{display:flex;flex-direction:column;}
@@ -1688,6 +1800,8 @@ html.vt-color-changing .v5-admin-btn{
.v5-nrm-usage-add{margin-top:.55rem;display:flex;align-items:center;justify-content:center;gap:6px;padding:.4rem .5rem;background:transparent;border:1.5px dashed rgba(var(--v5-primary-rgb),.22);border-radius:6px;color:var(--v5-text-muted);cursor:pointer;font-size:.66rem;font-weight:600;transition:all .15s;width:100%;font-family:inherit;} .v5-nrm-usage-add{margin-top:.55rem;display:flex;align-items:center;justify-content:center;gap:6px;padding:.4rem .5rem;background:transparent;border:1.5px dashed rgba(var(--v5-primary-rgb),.22);border-radius:6px;color:var(--v5-text-muted);cursor:pointer;font-size:.66rem;font-weight:600;transition:all .15s;width:100%;font-family:inherit;}
.v5-nrm-usage-add:hover{border-color:var(--v5-primary);color:var(--v5-primary);background:rgba(var(--v5-primary-rgb),.03);} .v5-nrm-usage-add:hover{border-color:var(--v5-primary);color:var(--v5-primary);background:rgba(var(--v5-primary-rgb),.03);}
.v5-nrm-usage-add svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;} .v5-nrm-usage-add svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
.v5-nrm-usage-note{margin-top:.55rem;display:flex;align-items:center;justify-content:center;gap:6px;padding:.4rem .5rem;background:var(--v5-bg-subtle);border:1px dashed var(--v5-border);border-radius:6px;color:var(--v5-text-muted);font-size:.64rem;font-weight:600;}
.v5-nrm-usage-note svg{width:11px;height:11px;stroke:currentColor;fill:none;stroke-width:2;}
/* sequence stats */ /* sequence stats */
.v5-nrm-seq-stats{display:grid;grid-template-columns:1fr 1fr;gap:.85rem 1.4rem;margin-bottom:.85rem;} .v5-nrm-seq-stats{display:grid;grid-template-columns:1fr 1fr;gap:.85rem 1.4rem;margin-bottom:.85rem;}
@@ -1800,3 +1914,23 @@ html.vt-color-changing .v5-admin-btn{
.v5-nrm-empty{display:flex;align-items:center;justify-content:center;flex-direction:column;gap:.5rem;padding:4rem 2rem;color:var(--v5-text-muted);font-size:.78rem;text-align:center;} .v5-nrm-empty{display:flex;align-items:center;justify-content:center;flex-direction:column;gap:.5rem;padding:4rem 2rem;color:var(--v5-text-muted);font-size:.78rem;text-align:center;}
.v5-nrm-empty svg{width:32px;height:32px;opacity:.5;stroke:currentColor;fill:none;stroke-width:1.5;} .v5-nrm-empty svg{width:32px;height:32px;opacity:.5;stroke:currentColor;fill:none;stroke-width:1.5;}
.v5-nrm-empty .hint{font-size:.66rem;color:var(--v5-text-muted);} .v5-nrm-empty .hint{font-size:.66rem;color:var(--v5-text-muted);}
@media (max-width:1180px){
.v5-nrm-body{grid-template-columns:280px 1fr;}
.v5-nrm-topbar{grid-template-columns:1fr auto;}
.v5-nrm-topstats{display:none;}
.v5-nrm-row-split{grid-template-columns:1fr;}
.v5-nrm-row-split > div + div{border-left:0;border-top:1px solid var(--v5-border);}
}
@media (max-width:860px){
.v5-nrm-body{grid-template-columns:1fr;}
.v5-nrm-side{display:none;}
.v5-nrm-topbar{padding:.75rem .9rem;grid-template-columns:1fr;align-items:start;}
.v5-nrm-topactions{justify-content:flex-start;}
.v5-nrm-detail-head,.v5-nrm-row,.v5-nrm-row-split > div{padding-left:1rem;padding-right:1rem;}
.v5-nrm-hero-wrap{padding-left:1rem;padding-right:1rem;}
.v5-nrm-hero-card{padding:1.75rem 1rem 1rem;}
.v5-nrm-hero-corner{position:static;display:block;margin:-.55rem 0 .75rem;max-width:100%;text-align:center;}
.v5-nrm-savebar{padding:.55rem 1rem;align-items:stretch;flex-direction:column;}
}
+4 -4
View File
@@ -10,8 +10,8 @@
// ============================================================ // ============================================================
/** /**
* *
* 발행: v2-table-list * 발행: canonical table
* 구독: stats, v2-repeat-container * 구독: stats, v2-repeat-container
*/ */
export interface TableListDataChangeDetail { export interface TableListDataChangeDetail {
@@ -69,7 +69,7 @@ export interface RepeaterSaveDetail {
/** /**
* *
* 발행: v2-button-primary, buttonActions * 발행: v2-button-primary, buttonActions
* 구독: v2-table-list, v2-split-panel-layout * 구독: canonical table, v2-split-panel-layout
*/ */
export interface RefreshTableDetail { export interface RefreshTableDetail {
table_name?: string; table_name?: string;
@@ -113,7 +113,7 @@ export interface SplitPanelDataTransferDetail {
/** /**
* *
* 발행: related-data-buttons * 발행: related-data-buttons
* 구독: v2-table-list * 구독: canonical table
*/ */
export interface RelatedButtonSelectDetail { export interface RelatedButtonSelectDetail {
target_table: string; target_table: string;
+2 -1
View File
@@ -825,7 +825,8 @@ export interface TemplateComponent {
/** /**
* ComponentRegistry ID . * ComponentRegistry ID .
* : 'v2-table-list', 'v2-button-primary', 'v2-bom-tree' * canonical : 'table', 'container', 'stats', 'button', 'input', 'search'
* legacy (alias ): 'v2-button-primary', 'v2-bom-tree'
*/ */
componentId: string; componentId: string;
+1 -1
View File
@@ -220,7 +220,7 @@ export interface ComponentComponent extends BaseComponent {
*/ */
export interface TabInlineComponent { export interface TabInlineComponent {
id: string; id: string;
component_type: string; // 컴포넌트 타입 (예: "v2-text-display", "v2-table-list") component_type: string; // 컴포넌트 타입 (canonical 예: "table" / "container" / "stats" / "button" — 일부 legacy id 도 alias 라우팅으로 호환)
label?: string; label?: string;
position: Position; // 탭 내부에서의 위치 position: Position; // 탭 내부에서의 위치
size: Size; // 컴포넌트 크기 size: Size; // 컴포넌트 크기
+1 -1
View File
@@ -50,7 +50,7 @@ export interface TableColumn {
* *
*/ */
export interface TableRegistration { export interface TableRegistration {
table_id: string; // 고유 ID (예: "table-list-123") table_id: string; // 고유 ID (예: "table-123")
label: string; // 사용자에게 보이는 이름 (예: "품목 관리") label: string; // 사용자에게 보이는 이름 (예: "품목 관리")
table_name: string; // 실제 DB 테이블명 (예: "item_info") table_name: string; // 실제 DB 테이블명 (예: "item_info")
columns: TableColumn[]; columns: TableColumn[];
+6 -69
View File
@@ -1,10 +1,7 @@
/** /**
* V2 ( 7) * V2 ()
* *
* - V2Text * - V2Text
* - V2List
* - V2Layout * - V2Layout
* - V2Group * - V2Group
* - V2Biz * - V2Biz
@@ -13,6 +10,7 @@
* / Phase D.3 (2026-05-12) canonical `input` * / Phase D.3 (2026-05-12) canonical `input`
* (InputComponent + InvFieldConfigPanel) . * (InputComponent + InvFieldConfigPanel) .
* V2Date InvField triple type=date 4 format(date/datetime/time/range) . * V2Date InvField triple type=date 4 format(date/datetime/time/range) .
* V2List Phase F.8 (2026-05-21) canonical TableComponent .
* InputComponent + lib/registry/components/input/pickers.tsx. * InputComponent + lib/registry/components/input/pickers.tsx.
*/ */
@@ -25,7 +23,6 @@ import { Position, Size, CommonStyle, ValidationRule } from "./v2-core";
*/ */
export type V2ComponentType = export type V2ComponentType =
| "V2Text" | "V2Text"
| "V2List"
| "V2Layout" | "V2Layout"
| "V2Group" | "V2Group"
| "V2Biz" | "V2Biz"
@@ -122,59 +119,7 @@ export interface V2TextProps extends V2BaseProps {
} }
// V2Media 타입 정의는 Phase D.5 (2026-05-12) 에서 제거됨 — canonical input 의 file 분기로 흡수. // V2Media 타입 정의는 Phase D.5 (2026-05-12) 에서 제거됨 — canonical input 의 file 분기로 흡수.
// V2List 타입 정의는 Phase F.8 (2026-05-21) 에서 제거됨 — canonical TableComponent + TableConfig 로 흡수.
// ===== V2List =====
export type V2ListViewMode = "table" | "card" | "kanban" | "list";
export interface ListColumn {
field: string;
header: string;
width?: number;
sortable?: boolean;
filterable?: boolean;
editable?: boolean;
format?: string;
}
export interface V2ListCardConfig {
title_column?: string;
subtitle_column?: string;
description_column?: string;
image_column?: string;
cards_per_row?: number;
card_spacing?: number;
show_actions?: boolean;
}
export interface V2ListConfig {
view_mode: V2ListViewMode;
editable?: boolean;
searchable?: boolean;
pageable?: boolean;
page_size?: number;
sortable?: boolean;
pagination?: boolean;
source?: "static" | "db" | "api"; // 데이터 소스 타입
columns?: ListColumn[];
modal?: boolean;
card_config?: V2ListCardConfig;
// 데이터 소스
data_source?: {
table?: string;
api?: string;
filters?: Array<{ column: string; operator: string; value: unknown }>;
};
}
export interface V2ListProps extends V2BaseProps {
v2Type: "V2List";
config: V2ListConfig;
data?: Record<string, unknown>[];
selected_rows?: Record<string, unknown>[];
onRowSelect?: (rows: Record<string, unknown>[]) => void;
onRowClick?: (row: Record<string, unknown>) => void;
}
// ===== V2Layout ===== // ===== V2Layout =====
@@ -291,7 +236,6 @@ export interface V2HierarchyProps extends V2BaseProps {
export type V2ComponentProps = export type V2ComponentProps =
| V2TextProps | V2TextProps
| V2ListProps
| V2LayoutProps | V2LayoutProps
| V2GroupProps | V2GroupProps
| V2BizProps | V2BizProps
@@ -304,10 +248,7 @@ export function isV2Text(props: V2ComponentProps): props is V2TextProps {
} }
// isV2Media 는 Phase D.5 에서 제거됨 (canonical input 의 file 분기로 흡수) // isV2Media 는 Phase D.5 에서 제거됨 (canonical input 의 file 분기로 흡수)
// isV2List 는 Phase F.8 에서 제거됨 (canonical table 로 흡수)
export function isV2List(props: V2ComponentProps): props is V2ListProps {
return props.v2Type === "V2List";
}
export function isV2Layout(props: V2ComponentProps): props is V2LayoutProps { export function isV2Layout(props: V2ComponentProps): props is V2LayoutProps {
return props.v2Type === "V2Layout"; return props.v2Type === "V2Layout";
@@ -353,12 +294,8 @@ export const LEGACY_TO_V2_MAP: Record<string, V2ComponentType> = {
// Media 계열 — Phase D.5 에서 canonical input 으로 흡수, 매핑 제거. // Media 계열 — Phase D.5 에서 canonical input 으로 흡수, 매핑 제거.
// List 계열 // List 계열 — Phase F.8 (2026-05-21) 에서 매핑 제거. canonical table 사용.
// ★ 2026-05-19 table-list 는 canonical table alias 로 라우팅 — 매핑 제거 // (table-search-widget / modal-repeater-table / repeater-field-group / card-display 매핑 폐기)
"table-search-widget": "V2List",
"modal-repeater-table": "V2List",
"repeater-field-group": "V2List",
"card-display": "V2List",
// Layout 계열 // Layout 계열
// ★ split-panel-layout 보존 (master-detail UX 다름) // ★ split-panel-layout 보존 (master-detail UX 다름)
@@ -459,3 +459,547 @@ M notes/gbpark/2026-05-19-canonical-data-view-cleanup-followup.md (본 §10)
컴포넌트군 정착 후 `TABLE_LIKE_COMPONENT_TYPES` 본체로 흡수 검토 컴포넌트군 정착 후 `TABLE_LIKE_COMPONENT_TYPES` 본체로 흡수 검토
- `repeat-container``dataSourceType = "table-list"` enum naming — 도메인 분리 위해 - `repeat-container``dataSourceType = "table-list"` enum naming — 도메인 분리 위해
`tableList``legacyTableList` 등으로 rename 검토 (별도 트랙) `tableList``legacyTableList` 등으로 rename 검토 (별도 트랙)
---
## 12. 2026-05-20 — table-list / v2-table-list cleanup 최종 수렴
§10 / §11 에서 도입한 canonical-aware helper 및 §10.3 의 hard blocker 보존 정책을
유지한 채로, table-list / v2-table-list registration shell 자체를 삭제하고
런타임 본체를 `_shared/` 로 이동, `TableComponent` 의 early delegation 으로 옛 layout
호환을 완성했다. 이번 단계는 잔여 매칭 105건 (43 파일) 전수 분류 + stale 4건 정리 +
TableComponent resolver 견고화 + DynamicComponentRenderer active 분기 helper 화 까지
포함한다.
### 12.1 shell 삭제 완료 (이전 단계 누적)
```
D frontend/lib/registry/components/table-list/ (폴더 전체)
D frontend/lib/registry/components/v2-table-list/ (폴더 전체)
├─ index.ts (ComponentDefinition 등록 shell)
├─ TableListRenderer.tsx
├─ TableListComponent.tsx → _shared/TableListComponent.tsx (cp)
├─ TableListContainerWrapper.tsx → _shared/V2TableListContainerWrapper.tsx
└─ README.md
```
- ComponentRegistry 에 `table-list` / `v2-table-list` 직접 등록 0건
- `registry/components/index.ts` 의 side-effect import 0건
- BlockRenderer / DynamicComponentRenderer / templateMigrate / getComponentConfigPanel
의 alias 라우팅만 남음 (모두 canonical `"table"` 로 라우팅)
### 12.2 shared runtime 경로 (현재 상태)
```
frontend/lib/registry/components/table/_shared/
├── TableListComponent.tsx # legacy table-list 본체 (6838줄)
│ ├─ TableListComponent
│ ├─ TableListWrapper
│ └─ TableListComponentProps
├── V2TableListComponent.tsx # v2-table-list 본체 (7216줄)
│ ├─ TableListComponent
│ ├─ TableListWrapper
│ └─ TableListComponentProps
├── V2TableListContainerWrapper.tsx # v2-table-list 반응형 wrapper (FieldConfig adapter + ResizeObserver)
│ └─ TableListContainerWrapper
├── SingleTableWithSticky.tsx # 공유 sticky 렌더 (variant="v2" 분기 포함)
├── CardModeRenderer.tsx # 공유 카드 모드 렌더 (variant="v2" 분기 포함)
├── TableListConfigPanel.tsx
└── tableListConfigTypes.ts # ColumnConfig / TableListConfig / EntityJoinInfo / ...
```
### 12.3 early delegation 경로 (TableComponent.tsx)
```
[BlockRenderer]
isTableLikeComponentType(componentId) → canonical "table" alias
└─ ComponentRegistry.getComponent("table") → canonical TableComponent
[DynamicComponentRenderer]
LEGACY_TO_UNIFIED: { "v2-table-list": "table", "table-list": "table" }
+ needsKeyRefresh = isTableLikeComponentType(componentType) || componentType === "v2-repeater"
[TableComponent.tsx]
_resolveRawComponentType(component, props):
candidates = [componentType, component_type, componentConfig.type,
component_config.type, props.componentType, component.type,
props.type, url last segment]
- MEANINGFUL Set (table / table-list / v2-table-list / data-table / datatable) → 즉시 채택
- GENERIC Set (component / widget / container / group / row / column / area / flow / tabs) → skip
- 그 외 첫 non-empty string → fallback 후보
├─ rawType === "table-list" → <LegacyTableListWrapper>
├─ rawType === "v2-table-list" → <V2TableListContainerWrapper>
└─ default → 본체 4경로 머지 + Grouped/Card/Pivot/SplitView
```
### 12.4 잔여 105건 (43 파일) 분류
| 분류 | 위치 (대표) | 보존 결정 |
|---|---|---|
| **canonical alias / runtime compat** | DynamicComponentRenderer / templateMigrate / getComponentConfigPanel / TableComponent / componentTypeUtils | ✅ 보존 (concrete blocker) |
| **schema/default old layout compat** | componentConfig.ts v2-table-list overridesSchema / defaultConfig | ✅ 보존 (concrete blocker — 옛 layout JSON 검증) |
| **dataSourceType enum / domain value** | repeat-container/types.ts / v2-repeat-container/types.ts + ConfigPanel select | ✅ 보존 (컴포넌트 type 이 아닌 data source mode enum) |
| **InvDataConfigPanel old config** | InvDataConfigPanel.tsx (5건) | ✅ 보존 (사용자 hard blocker 명시) |
| **shared self-id** | _shared/{Legacy,V2}TableListComponent 의 `component_type: "table-list"` / `componentType="v2-table-list"` (7건) | ✅ 보존 (DataProvider/Receiver 컨트랙트) |
| **table_id naming** | _shared/*.tsx 의 `tableId = \`table-list-${id}\`` / table-options.ts 예시 | ✅ 보존 (DOM/store key) |
| **V2List wrapper** | V2List.tsx line 53 `type: "table-list"` | ✅ 보존 (shared props 시그니처 호환) |
| **component-events publisher/subscriber** | types/component-events.ts (3건) | ✅ 보존 (이벤트 토픽 메타) |
| **helper / 분류 매핑** | componentTypeUtils / MultilangSettingsModal / responsiveDefaults / ScreenNode / RealtimePreviewDynamic / TabsWidget / InvyoneStudio | ✅ 보존 (canonical-aware entry) |
| **stale 주석 (이번 정리)** | screen-management.ts / invyone-component.ts / screenGroup.ts / fieldConfig/adapters.ts | ✏ 갱신 (canonical wording 추가) |
| **docs / README 예시** | selected-items-detail-input/README.md / v2-timeline-scheduler/README.md / 각 컴포넌트 주석 | ✅ 보존 (코드 영향 없음, 옛 layout 흔적) |
| **active branch (이번 정리)** | DynamicComponentRenderer line 935-938 OR 체인 | ✏ `isTableLikeComponentType` 호출로 단일화 |
| **stale 자동등록 주석 (이전 정리, §11.x)** | ComponentsPanel.tsx allComponents 위 주석 | ✏ "shell 삭제 → alias 라우팅 → early delegation" 명시 |
### 12.5 이번 단계 (2026-05-20) 변경 파일
```
M frontend/lib/registry/components/table/TableComponent.tsx
└ _resolveRawComponentType 견고화 (MEANINGFUL 즉시 채택 / GENERIC skip / fallback)
- candidates 우선순위: componentType → component_type → componentConfig.type →
component_config.type → props.componentType →
component.type → props.type → url last segment
M frontend/lib/registry/DynamicComponentRenderer.tsx
└ needsKeyRefresh OR 체인 → isTableLikeComponentType(componentType) || === "v2-repeater"
└ isTableLikeComponentType import 추가
M frontend/components/screen/panels/ComponentsPanel.tsx
└ stale 주석 갱신: "v2-table-list 자동 등록" 진술 제거,
"shell 삭제 → alias 라우팅 → early delegation" 정확한 흐름 명시
M frontend/types/screen-management.ts
└ TabInlineComponent.component_type 예시 주석: legacy v2-text-display / v2-table-list 만
있던 것을 canonical (table / container / stats / button) + legacy alias 명시
M frontend/types/invyone-component.ts
└ componentId 예시 주석: canonical (table / container / stats / button / input / search) +
legacy alias 라우팅 호환 명시
M frontend/lib/api/screenGroup.ts
└ ScreenComponent.componentKind 주석: canonical / legacy 예시 모두 명시
M frontend/lib/fieldConfig/adapters.ts
└ fieldsToColumns docstring: v2-table-list 전용 → table-like (canonical 'table' /
legacy 'table-list' / hidden 'v2-table-list') 로 일반화.
호출자 V2TableListContainerWrapper 명시.
M notes/gbpark/2026-05-19-canonical-data-view-cleanup-followup.md
└ 본 §12 추가
```
### 12.6 다음 삭제 조건 (concrete blocker 별)
| Hard Blocker | 폐기 조건 |
|---|---|
| `componentConfig.ts` v2-table-list overridesSchema / defaultConfig | 모든 저장된 v2-table-list layout 이 canonical TableConfig 로 마이그레이션 완료 + DB 통계 v2-table-list 카운트 0 |
| `_shared/V2TableListComponent.tsx` 본체 (7216줄) | canonical TableComponent 가 다음 parity 100% 달성: v2 inline edit / category·code select / date picker fallback / image url·fallback / ResizeObserver wrapper / DataProvider·DataReceiver / FieldConfig adapter / sortable·searchable·filterable per-column / linked filter / exclude filter / GroupSum / context menu / export Excel |
| `_shared/TableListComponent.tsx` 본체 (6838줄) | canonical TableComponent 가 legacy parity 100% (FlowWidget SingleTableWithSticky / linked filter / exclude filter / 카드모드 / inline edit / DataProvider·DataReceiver) + FlowWidget 가 canonical table 으로 마이그레이션 |
| `_shared/V2TableListContainerWrapper.tsx` (FieldConfig adapter + ResizeObserver) | canonical TableComponent 가 자체적으로 ResizeObserver + FieldConfig adapter 내장 후 |
| `TableComponent.tsx` early delegation 분기 | 위 두 본체 폐기 시 함께 제거 |
| `DynamicComponentRenderer.LEGACY_TO_UNIFIED` v2-table-list/table-list alias | DB / 저장 layout 에서 옛 ID 사용 0 + 6개월 운영 안정 후 |
| `templateMigrate.LEGACY_TO_UNIFIED` v2-table-list/table-list alias | 위와 동일 |
| `getComponentConfigPanel.CONFIG_PANEL_ALIAS` v2-table-list/table-list | 위와 동일 |
| `componentTypeUtils.TABLE_LIKE_COMPONENT_TYPES` | 위 전체 폐기 후 |
| `InvDataConfigPanel.tsx` v2-table-list | old layout config 마이그레이션 완료 후 |
| `repeat-container` / `v2-repeat-container` `dataSourceType = "table-list"` enum | 별도 도메인 트랙 — `legacyTableList` 같은 명시적 enum 으로 rename |
| `V2List.tsx``type: "table-list"` 컴포넌트 객체 구성 | V2List 자체 폐기 후 |
### 12.7 기능 보존 검증 (delegation 경로 + adapter)
- **TableComponent early delegation 우선순위 검증**: `_rawType === "table-list"` / `"v2-table-list"` 일 때 early return → canonical 4경로 머지 / GroupedView / CardView / PivotView 코드에는 절대 진입하지 않음. 옛 layout 이 weak canonical TableComponent 로 떨어지는 경로 0건 확인.
- **FieldConfig adapter (fieldsToColumns)**: `V2TableListContainerWrapper` 가 import. 경로 끊김 없음.
- **DataProvider / DataReceiver**: `_shared/TableListComponent.tsx``dataProvider.component_type = "table-list"` / `_shared/V2TableListComponent.tsx``component_type = "table"` 으로 자체 등록. ScreenContext 호환.
- **selectedTable / tableName / dbTable 호환**: `getTableNameFromTableLikeComponent` helper 가 8 후보 (componentConfig.selectedTable / tableName / table_name / component_config.selectedTable / tableName / table_name / root tableName / table_name) 모두 검사. 끊김 없음.
- **sourceProvider / dataReceiver 호환**: ButtonPrimaryComponent (v2 / non-v2) 의 자동 탐색이 `isTableLikeComponentType(provider.component_type)` 로 통일 (§11). canonical / legacy / hidden 모두 자동 발견.
### 12.8 Acceptance 결과 (2026-05-20)
| 검증 항목 | 결과 |
|---|---|
| `git diff --check` | ✅ pass (출력 없음) |
| 옛 폴더 직접 import (`table-list/TableListRenderer` / `v2-table-list/TableListRenderer` / `components/table-list` / `components/v2-table-list` / `./table-list*` / `./v2-table-list*`) — `_shared/**` 제외 | ✅ `componentConfig.ts` historical 주석 **1건**만 |
| 입력 canonical 금지 토큰 (`v2-input` / `v2-select` / `V2InputRenderer` / `V2SelectRenderer`) | ✅ 0건 |
| EntityPicker / entity-picker | ✅ 0건 |
| `cd backend-spring && ./gradlew compileJava` | ✅ BUILD SUCCESSFUL |
| `npx tsc --noEmit --pretty false \| rg "lib/registry/components/table/TableComponent\|lib/registry/components/table/_shared\|lib/utils/componentTypeUtils\|components/dash/BlockRenderer\|lib/registry/DynamicComponentRenderer\|components/screen/panels/ComponentsPanel"` 신규 오류 | ✅ 0건 (이전 단계 좁은 cast 유효, resolver 견고화 / helper 화 / 주석 갱신 모두 클린) |
---
## 13. 2026-05-20 — Container 계열 cleanup 분류 + stale 정리
§12 의 table cleanup 과 동일 원칙 (canonical 새 생성 경로 보존 / shell 삭제 → alias /
shared runtime / FieldConfig·DataPort 호환 유지) 으로 container 계열 잔여를 전수 분류.
container 계열은 table 과 달리 **canonical container skeleton 이 일부 모드에서 부족**
하여 hard blocker 보존이 다수다. 본 단계는 분류 + stale 주석 2건 정리 + 보고서.
### 13.1 잔존 폴더 현황 (registry/components/)
| 폴더 | 상태 | 비고 |
|---|---|---|
| `container/` | ✅ canonical | 새 생성 경로 (containerType: tabs / section / accordion / repeater / conditional) |
| `accordion-basic/` | 🔒 보존 (hard blocker) | canonical container.containerType=accordion skeleton 부족 |
| `conditional-container/` | 🔒 보존 (hard blocker) | canonical container.containerType=conditional skeleton 부족 |
| `repeat-container/` | 🔒 보존 (hard blocker) | canonical container.containerType=repeater skeleton 부족. ComponentDefinition `hidden: true` |
| `v2-repeat-container/` | 🔒 보존 (hard blocker) | 동일. `basicV2Components` palette item |
| `v2-repeater/` | 🔒 보존 (hard blocker) | 별도 데이터 조회/선택 도메인. palette item |
| `repeat-screen-modal/` | 🔒 보존 (domain) | 도메인 특화 — 화면 모달 반복 |
| `repeater-field-group/` | 🔒 보존 (domain) | 도메인 특화 — 필드 그룹 반복 |
| `modal-repeater-table/` | 🔒 보존 (domain) | 도메인 특화 |
| `simple-repeater-table/` | 🔒 보존 (domain) | 도메인 특화 |
| `screen-split-panel/` | 🔒 보존 (domain) | 화면 임베딩 + 데이터 전달. SplitPanelContext provider. backend API (`/screen-split-panel/...`) 도 별도 |
| `split-panel-layout/` | 🔒 보존 (hard blocker) | SplitPanelContext provider 다수 사용처 (`(main)/screens/[screenId]/page.tsx` / `(pop)/pop/screens/[screenId]/page.tsx` / `InteractiveScreenViewer.tsx` / `RealtimePreview.tsx` / `RealtimePreviewDynamic.tsx` / `SplitPanelAwareWrapper.tsx` 등) |
| `split-panel-layout2/` | 🔒 보존 (hard blocker) | 새 분할 — table.displayMode='split' 와 짝 |
| `v2-split-panel-layout/` | 🔒 보존 (hard blocker) | 7000+줄 master-detail UX 구현체. alias 없음 (canonical split-mode parity 부족) |
### 13.2 canonical container 흡수 매핑 (이미 완료된 alias 라우팅)
```
DynamicComponentRenderer.LEGACY_TO_UNIFIED (line 387~394)
templateMigrate.LEGACY_TO_UNIFIED (line 46~56)
getComponentConfigPanel.CONFIG_PANEL_ALIAS (line 147~151)
┌── ✅ alias 흡수 (canonical 'container' 라우팅) ──┐
│ tabs-widget / v2-tabs-widget / tabs / v2-tabs → container (containerType=tabs)
│ section-card / v2-section-card → container (section, sectionVariant=card)
│ section-paper / v2-section-paper → container (section, sectionVariant=paper)
└──────────────────────────────────────────────────┘
┌── 🔒 보존 (concrete blocker — alias 없음 또는 별도 import 보존) ──┐
│ accordion-basic → AccordionBasicConfigPanel 직접 import
│ conditional-container → ConditionalContainerConfigPanel 직접 import
│ repeat-container → RepeatContainerConfigPanel 직접 import
│ v2-repeat-container → CONFIG_PANEL_ALIAS["v2-repeat-container"]="container" + 자체 import (skeleton 부족)
│ split-panel-layout → SplitPanelLayoutConfigPanel 직접 import
│ v2-split-panel-layout → 동일
│ split-panel-layout2 → 동일
│ screen-split-panel → 동일
└──────────────────────────────────────────────────┘
```
### 13.3 잔여 매칭 분류 요약
| 분류 | 위치 (대표) | 결정 |
|---|---|---|
| **canonical alias / runtime compat** | DynamicComponentRenderer / templateMigrate / getComponentConfigPanel alias map | ✅ 보존 (hard blocker) |
| **schema/default old layout compat** | componentConfig.ts v2-split-panel-layout / v2-repeat-container overridesSchema·defaultConfig | ✅ 보존 (hard blocker) |
| **canonical container 흡수 라우팅** | DynamicComponentRenderer line 390~394 (tabs/section alias) | ✅ 보존 (런타임 동작) |
| **canonical skeleton 부족 보존** | accordion-basic / conditional-container / repeat-container / v2-repeat-container | ✅ 보존 (hard blocker — skeleton 완성 후 폐기) |
| **SplitPanelContext + master-detail UX** | split-panel-layout / v2-split-panel-layout / split-panel-layout2 / screen-split-panel + 다수 사용처 | ✅ 보존 (hard blocker — canonical split UX 미완) |
| **domain-specific** | modal-repeater-table / simple-repeater-table / repeat-screen-modal / repeater-field-group / screen-split-panel API | ✅ 보존 (도메인 트랙 별도) |
| **InvyoneStudio drag/drop 분기** | `compType === "split-panel-layout" \|\| === "v2-split-panel-layout"` × 다수 / `=== "tabs-widget" \|\| === "v2-tabs-widget" \|\| isCanonicalTabs` × 다수 | ✅ 보존 (도메인 특화 drag/drop, 흡수 helper 시 시그니처 더 복잡 — canonical container.containerType=tabs 까지 인식하는 helper 는 component 객체 + componentConfig 둘 다 필요) |
| **ContainerComponent OR 체인** | `ContainerComponent.tsx` line 85-86 `v2-tabs-widget \|\| tabs-widget` | ✅ 보존 (canonical container 본체의 옛 ID 흡수 처리 — 본체 self-id) |
| **buttonActions split-panel/screen-split-panel 분기** | line 3294, 3478, 3845-3848 등 | ✅ 보존 (split-panel-layout 의 leftPanel.tableName 등 고유 path — table cleanup 의 hard blocker 와 동일) |
| **InteractiveScreenViewer / RealtimePreview / RealtimePreviewDynamic tabs/split 분기** | 다수 | ✅ 보존 (canonical container.containerType=tabs 와 옛 ID 의 동시 인식, 도메인 특화) |
| **screen-split-panel API client** | `lib/api/screenEmbedding.ts` line 195~249 | ✅ 보존 (backend `/screen-split-panel` endpoint — frontend 폐기 불가) |
| **stale 주석 — 이번 정리** | `registry/components/index.ts` line 106-107 / `container/types.ts` 헤더 docstring | ✏ 갱신 (2건) |
### 13.4 이번 단계 (2026-05-20) 변경 파일
```
M frontend/lib/registry/components/index.ts
└ line 106-107 의 "v2-table-list + ... 9종 흡수" / "container ... 11종 흡수"
주석을 정확한 현재 상태 (alias 라우팅 + early delegation + 보존 사유) 로 갱신.
auto-register import 자체는 그대로 (canonical TableRenderer / ContainerRenderer +
accordion-basic / split-panel-layout / split-panel-layout2 / conditional-container /
screen-split-panel / repeat-container / v2-repeat-container / v2-split-panel-layout
auto-register 보존).
M frontend/lib/registry/components/container/types.ts
└ 헤더 docstring 갱신: "흡수 대상 (11)" 단일 목록 → "흡수 완료 (alias 라우팅)" +
"보존 (skeleton 부족 또는 도메인 특화) — concrete blocker" 두 그룹으로 분리.
canonical 새 생성 경로 + alias 흡수 매핑 + hard blocker 사유 명시.
M notes/gbpark/2026-05-19-canonical-data-view-cleanup-followup.md
└ 본 §13 추가
```
### 13.5 Hard Blocker (다음 삭제 조건)
| Hard Blocker | 폐기 조건 |
|---|---|
| `accordion-basic/` 폴더 + ComponentDefinition | canonical `container.containerType=accordion` skeleton 완성 (헤더 / 패널 / 토글 / 다중 선택 / 키보드 네비게이션 / 애니메이션) + 옛 layout 마이그레이션 |
| `conditional-container/` 폴더 + ComponentDefinition | canonical `container.containerType=conditional` skeleton 완성 (조건식 평가 / sections / 동적 표시 / 자식 컴포넌트 마운트 처리) + 옛 layout 마이그레이션 |
| `repeat-container/` + `v2-repeat-container/` 폴더 | canonical `container.containerType=repeater` skeleton 완성 (데이터 lookup / 선택 / append / 인라인 add / 슬롯 컴포넌트 / `data-repeat-container` DOM 마커 / `_repeatContainerTables` save group 처리 / RepeatContainerConfig 마이그레이션) |
| `v2-repeater/` | canonical container repeater 완성 + canonical table multi-select 가 동일 UX 구현 |
| `split-panel-layout/` 폴더 + SplitPanelContext | canonical `table.displayMode=split` 의 master-detail UX 완성 (leftPanel/rightPanel / drag-drop / resize / selection sync / nested child / disableAutoDataTransfer) + 모든 사용처 마이그레이션 (`(main)/screens/[screenId]/page.tsx` / `(pop)/pop/screens/[screenId]/page.tsx` / `InteractiveScreenViewer.tsx` / `RealtimePreview.tsx` / `RealtimePreviewDynamic.tsx` / `SplitPanelAwareWrapper.tsx`) |
| `v2-split-panel-layout/` 폴더 (7000+줄) | 위와 동일 |
| `split-panel-layout2/` 폴더 | canonical split master-detail UX 완성 후 |
| `screen-split-panel/` 폴더 + backend `/screen-split-panel` API | 화면 임베딩 도메인 별도 트랙. canonical 흡수 시 backend endpoint 도 함께 정리 |
| `componentConfig.ts` v2-split-panel-layout / v2-repeat-container overridesSchema·defaultConfig | 옛 layout JSON 의 해당 type 사용 0 + 6개월 운영 안정 |
| `DynamicComponentRenderer.LEGACY_TO_UNIFIED` tabs/section alias | 옛 ID 사용 0 + 안정 후 |
| `templateMigrate.LEGACY_TO_UNIFIED` tabs/section alias | 위와 동일 |
| `getComponentConfigPanel.CONFIG_PANEL_ALIAS` tabs/section alias + accordion-basic / split-panel-layout / conditional-container / repeat-container 직접 import | 위 hard blocker 와 짝 |
| `modal-repeater-table / simple-repeater-table / repeat-screen-modal / repeater-field-group` | 별도 도메인 마이그레이션 트랙 |
### 13.6 기능 보존 검증
- **canonical container 흡수 라우팅 끊김 없음**: BlockRenderer / DynamicComponentRenderer / templateMigrate / getComponentConfigPanel 4 곳 모두에서 옛 ID → "container" 로 alias 처리됨. ContainerComponent 본체에서 옛 ID raw type 도 인식 (line 85-86).
- **canonical skeleton 부족 컴포넌트들 보존**: ComponentRegistry 에 자체 ComponentDefinition 등록 (auto-register import 유지). palette 에서는 `hiddenComponents` 로 숨김 처리되지만, 옛 저장 layout JSON 에서 직접 type 지목 시 렌더 가능.
- **SplitPanelContext 끊김 없음**: provider import 다수 사용처 (`(main)/screens/[screenId]/page.tsx` / `(pop)/pop/screens/[screenId]/page.tsx` / `InteractiveScreenViewer.tsx` / `RealtimePreview.tsx` / `RealtimePreviewDynamic.tsx` / `SplitPanelAwareWrapper.tsx`) 모두 그대로. master-detail data transfer 경로 보존.
- **schema/default 호환**: `componentConfig.ts` 의 v2-split-panel-layout / v2-repeat-container schema + defaultConfig 보존. 옛 layout JSON 검증 통과.
- **table cleanup 결과 보존**: `table-list/` / `v2-table-list/` 폴더 0건 + 직접 import 0건 + `_shared/{TableListComponent,V2TableListComponent,V2TableListContainerWrapper}` 본체 그대로.
- **FieldConfig / DataPort / sourceProvider / dataReceiver 호환 모두 유지**: `componentTypeUtils.isTableLikeComponent*` helpers + `fieldsToColumns` adapter + DataProvidable/DataReceivable 인터페이스 모두 변경 없음.
### 13.7 Acceptance 결과 (2026-05-20 container)
| 검증 항목 | 결과 |
|---|---|
| `git diff --check` | ✅ pass |
| 옛 table 폴더 직접 import (`table-list/TableListRenderer` 등) — `_shared/**` 제외 | ✅ `componentConfig.ts` historical 주석 1건만 |
| 입력 canonical 금지 토큰 | ✅ 0건 |
| EntityPicker / entity-picker | ✅ 0건 |
| `cd backend-spring && ./gradlew compileJava` | ✅ BUILD SUCCESSFUL |
| `npx tsc --noEmit \| rg "container\|tabs\|section\|accordion\|conditional\|repeat-container\|split-panel\|screen-split-panel\|DynamicComponentRenderer\|templateMigrate\|ComponentsPanel"` 신규 오류 | ✅ 0건 (주석 갱신 2건 모두 클린) |
---
## 14. 2026-05-20 — Final Audit / Close-out
input → stats → table → container → chart → card-list → grouped-table 까지 모든 canonical
data-view cleanup 단계가 완료되었다. 본 §14 는 전체 상태 검증 + 누적 잔여 분류 +
다음 삭제 조건의 최종 보정. 코드 수정은 **0건** (검증/문서 중심 단계).
### 14.1 Canonical 6종 — 새 생성 경로 상태
| Canonical ID | 폴더 | 본체 | 상태 |
|---|---|---|---|
| `stats` | `lib/registry/components/stats/` | StatsComponent | ✅ canonical 새 생성 경로 |
| `table` | `lib/registry/components/table/` | TableComponent + `_shared/{TableListComponent,V2TableListComponent,V2TableListContainerWrapper}` | ✅ canonical + early delegation |
| `container` | `lib/registry/components/container/` | ContainerComponent | ✅ canonical (tabs / section / accordion / repeater / conditional. 단 accordion/repeater/conditional skeleton 부족) |
| `chart` | `lib/registry/components/chart/` | ChartRenderer (recharts: bar / horizontalBar / line / donut) | ✅ canonical |
| `card-list` | `lib/registry/components/card-list/` | CardListRenderer | ✅ canonical |
| `grouped-table` | `lib/registry/components/grouped-table/` | GroupedTableRenderer | ✅ canonical |
### 14.2 삭제 완료 폴더 (11개)
```
D lib/registry/components/aggregation-widget/ (stats 흡수)
D lib/registry/components/v2-aggregation-widget/ (stats 흡수)
D lib/registry/components/v2-status-count/ (stats 흡수)
D lib/registry/components/tabs/ (container 흡수, alias 라우팅)
D lib/registry/components/v2-tabs-widget/ (container 흡수, alias 라우팅)
D lib/registry/components/section-card/ (container 흡수, alias 라우팅)
D lib/registry/components/v2-section-card/ (container 흡수, alias 라우팅)
D lib/registry/components/section-paper/ (container 흡수, alias 라우팅)
D lib/registry/components/v2-section-paper/ (container 흡수, alias 라우팅)
D lib/registry/components/table-list/ (table 흡수 + _shared/TableListComponent 로 이동)
D lib/registry/components/v2-table-list/ (table 흡수 + _shared/V2TableListContainerWrapper 로 이동)
```
부가 삭제:
```
D V2AggregationWidgetConfigPanel.tsx (1085줄)
D V2StatusCountConfigPanel.tsx (679줄)
```
### 14.3 Alias / Delegation 라우팅 (모두 작동 중)
```
┌─── BlockRenderer ───┐ ┌─── DynamicComponentRenderer ───┐
│ isTableLikeComponent│ │ LEGACY_TO_UNIFIED │
│ → canonical "table" │ v2-table-list → table │
│ ★ alias 안전장치 유지 │ table-list → table │
└─────────────────────┘ │ v2-aggregation-widget → stats │
│ aggregation-widget → stats │
┌─── templateMigrate.LEGACY_TO_UNIFIED ───┐│ v2-status-count → stats │
│ v2-table-list / table-list → table ││ v2-tabs-widget → container │
│ v2-aggregation-widget → stats ││ tabs-widget / tabs → container│
│ v2-tabs-widget / tabs-widget → container││ v2-section-card → container │
│ v2-section-card/paper → container ││ v2-section-paper → container │
│ section-card / section-paper → container││ section-card / paper → cont │
│ v2-repeat-container → container ││ v2-repeat-container → cont │
│ accordion-basic → container ││ accordion-basic → container │
└──────────────────────────────────────────┘└────────────────────────────────┘
┌─── getComponentConfigPanel ───────────────────────────────────────────┐
│ CONFIG_PANEL_ALIAS │
│ v2-table-list / table-list → table │
│ v2-tabs-widget / tabs-widget → container │
│ v2-section-card / v2-section-paper / section-card / paper → container│
│ v2-repeat-container → container │
│ v2-aggregation-widget / v2-status-count / aggregation-widget → stats│
│ │
│ CONFIG_PANEL_MAP (직접 import — alias 없음 hard blocker): │
│ accordion-basic / split-panel-layout / v2-split-panel-layout / │
│ split-panel-layout2 / screen-split-panel / conditional-container / │
│ repeat-container / v2-repeat-container │
└───────────────────────────────────────────────────────────────────────┘
┌─── TableComponent.tsx (canonical table 본체) ───────────────────────┐
│ _resolveRawComponentType(component, props): │
│ - MEANINGFUL (table / table-list / v2-table-list / data-table / │
│ datatable) → 즉시 채택 │
│ - GENERIC (component / widget / container / group / row / column / │
│ area / flow / tabs) → skip │
│ - 그 외 → fallback │
│ │
│ rawType === "table-list" → <LegacyTableListWrapper> │
│ rawType === "v2-table-list" → <V2TableListContainerWrapper> │
│ default → 본체 4경로 머지 + Grouped/Card/Pivot │
└─────────────────────────────────────────────────────────────────────┘
```
### 14.4 누적 잔여 분류 (전체 735건 매칭)
| 분류 | 결정 |
|---|---|
| canonical alias / runtime compat (DynamicComponentRenderer / templateMigrate / getComponentConfigPanel) | ✅ 보존 (hard blocker — 옛 ID 사용 0 + 안정 후 폐기) |
| schema/default old layout compat (componentConfig.ts: v2-table-list / v2-split-panel-layout / v2-repeat-container schema·default) | ✅ 보존 (hard blocker) |
| canonical container 흡수 alias (tabs / section) | ✅ 작동 중 |
| canonical skeleton 부족 (accordion / conditional / repeat 계열) | 🔒 hard blocker (skeleton 완성 + 마이그레이션 후 폐기) |
| SplitPanelContext + master-detail UX (split-panel / screen-split-panel) | 🔒 hard blocker (canonical split UX 미완) |
| domain-specific (modal-repeater / simple-repeater / repeat-screen-modal / repeater-field-group) | 🔒 hard blocker (도메인 트랙 별도) |
| InvyoneStudio drag/drop / buttonActions split-panel / InteractiveScreenViewer / RealtimePreview/Dynamic tabs/split 분기 | ✅ 보존 (도메인 특화 — canonical 흡수 시 함께 정리) |
| ContainerComponent self-id 인식 (v2-tabs-widget / tabs-widget) | ✅ 보존 (본체 옛 ID 흡수 처리) |
| `_shared/` 본체 self-id (`component_type: "table-list"` / `componentType="v2-table-list"`) | ✅ 보존 (DataProvider/Receiver 컨트랙트) |
| repeat-container `dataSourceType="table-list"` enum | ✅ 보존 (도메인 다름 — data source mode 값) |
| component-events publisher/subscriber 주석 (3건) | ✅ 보존 (메타) |
| helper / 분류 매핑 (componentTypeUtils / MultilangSettingsModal / responsiveDefaults / ScreenNode / RealtimePreviewDynamic / TabsWidget / InvyoneStudio summary count) | ✅ 보존 (canonical-aware entry) |
| docs / README 예시 | ✅ 보존 (코드 영향 0) |
| stale 주석 — 누적 정리 완료 | ✏ 갱신 완료 (§10 / §11 / §12 / §13 누적 8건) |
### 14.5 남은 Hard Blocker (concrete blockers)
#### 14.5.1 Schema / Default (componentConfig.ts)
- `v2-table-list` overridesSchema + defaultConfig
- `v2-split-panel-layout` overridesSchema + defaultConfig
- `v2-repeat-container` overridesSchema + defaultConfig
#### 14.5.2 Canonical Skeleton 부족
- `accordion-basic/` — canonical `container.containerType=accordion` skeleton 부족
- `conditional-container/` — canonical `container.containerType=conditional` skeleton 부족
- `repeat-container/` + `v2-repeat-container/` + `v2-repeater/` — canonical `container.containerType=repeater` 데이터 lookup / 슬롯 / save group 부족
#### 14.5.3 SplitPanel + Master-detail UX
- `split-panel-layout/` (SplitPanelContext provider 다수 사용처)
- `v2-split-panel-layout/` (7000+줄 master-detail UX 본체)
- `split-panel-layout2/` (새 분할)
- `screen-split-panel/` (화면 임베딩 + backend `/screen-split-panel` API)
#### 14.5.4 Table _shared 본체 (canonical TableComponent parity 부족)
- `_shared/TableListComponent.tsx` (6838줄) — legacy FlowWidget SingleTableWithSticky / linked filter / exclude filter
- `_shared/V2TableListComponent.tsx` (7216줄) — v2 inline edit / category·code select / image url·fallback / GroupSum / DataProvider/Receiver
- `_shared/V2TableListContainerWrapper.tsx` — ResizeObserver + FieldConfig adapter
- `table/TableComponent.tsx` early delegation 분기
#### 14.5.5 Alias 매핑
- `DynamicComponentRenderer.LEGACY_TO_UNIFIED` (table / stats / container 그룹)
- `templateMigrate.LEGACY_TO_UNIFIED`
- `getComponentConfigPanel.CONFIG_PANEL_ALIAS`
- `componentTypeUtils.TABLE_LIKE_COMPONENT_TYPES` Set
#### 14.5.6 Domain / Wrapper
- `InvDataConfigPanel.tsx` v2-table-list (old config hard blocker)
- `V2List.tsx` `type: "table-list"` (shared TableListComponent props 호환)
- `repeat-container` / `v2-repeat-container` `dataSourceType = "table-list"` enum (도메인 다름)
- `modal-repeater-table` / `simple-repeater-table` / `repeat-screen-modal` / `repeater-field-group` (도메인 특화)
- `lib/api/screenEmbedding.ts` `/screen-split-panel` API client (backend endpoint 짝)
### 14.6 다음 삭제 조건 (요약)
| Hard Blocker 그룹 | 폐기 조건 |
|---|---|
| `_shared/TableListComponent.tsx` (legacy) | canonical TableComponent legacy parity 100% (FlowWidget SingleTableWithSticky / linked filter / exclude filter / 카드모드 / inline edit / DataProvider·Receiver) + FlowWidget 마이그레이션 |
| `_shared/V2TableListComponent.tsx` (v2) | canonical TableComponent v2 parity 100% (v2 inline edit / category·code select / date picker fallback / image url·fallback / ResizeObserver / DataProvider·Receiver / FieldConfig adapter / sortable·searchable·filterable per-column / linked filter / exclude filter / GroupSum / context menu / export Excel) |
| `V2TableListContainerWrapper.tsx` | canonical TableComponent 가 자체적으로 ResizeObserver + FieldConfig adapter 내장 후 |
| `TableComponent.tsx` early delegation | 위 두 본체 폐기 시 함께 |
| `componentConfig.ts` v2-table-list / v2-split-panel-layout / v2-repeat-container schema | 옛 layout JSON 의 해당 type 사용 0 + 6개월 운영 안정 |
| `accordion-basic` | canonical container accordion skeleton 완성 + 마이그레이션 |
| `conditional-container` | canonical container conditional skeleton 완성 + 마이그레이션 |
| `repeat-container` / `v2-repeat-container` / `v2-repeater` | canonical container repeater 완성 (데이터 lookup / 슬롯 / append / 인라인 add / DOM marker / save group) |
| `split-panel-layout` 계열 + SplitPanelContext | canonical `table.displayMode=split` master-detail UX 완성 + 다수 사용처 마이그레이션 |
| `screen-split-panel` + backend API | 화면 임베딩 도메인 별도 트랙. canonical 흡수 시 endpoint 도 정리 |
| `LEGACY_TO_UNIFIED` / `CONFIG_PANEL_ALIAS` / `TABLE_LIKE_COMPONENT_TYPES` 매핑 | 위 hard blocker 전체 폐기 후 단순화 |
| `InvDataConfigPanel.tsx` v2-table-list | old layout config 마이그레이션 완료 후 |
| `V2List.tsx` | V2List 자체 폐기 후 (canonical 흡수 시점) |
| `repeat-container` `dataSourceType="table-list"` enum | 도메인 분리 트랙 — `legacyTableList` rename |
| domain-specific (modal-repeater 등) | 각 도메인 트랙 |
### 14.7 Final Acceptance 결과 (2026-05-20)
| 검증 항목 | 결과 |
|---|---|
| `git diff --check` | ✅ pass (출력 없음) |
| 입력 canonical 금지 토큰 (`v2-input` / `v2-select` / `V2InputRenderer` / `V2SelectRenderer`) | ✅ 0건 |
| EntityPicker / entity-picker | ✅ 0건 |
| table direct import (`table-list/TableListRenderer` / `v2-table-list/TableListRenderer` / `components/table-list` / `components/v2-table-list` / `./table-list*` / `./v2-table-list*`) — `_shared/**` 제외 | ✅ `componentConfig.ts` historical 주석 1건만 |
| stats deleted import (`AggregationWidgetRenderer` / `StatusCountRenderer` / `V2AggregationWidgetConfigPanel` / `V2StatusCountConfigPanel` / `components/aggregation-widget` / `components/v2-aggregation-widget` / `components/v2-status-count`) | ✅ 0건 |
| tabs/section deleted import (`TabsRenderer` / `tabs-component` / `SectionCardRenderer` / `SectionPaperRenderer` / `components/tabs` / `components/v2-tabs-widget` / `components/section-card` / `components/section-paper` / `components/v2-section-card` / `components/v2-section-paper`) | ✅ 0건 |
| 11개 삭제 폴더 존재 X (aggregation-widget / v2-aggregation-widget / v2-status-count / tabs / v2-tabs-widget / section-card / v2-section-card / section-paper / v2-section-paper / table-list / v2-table-list) | ✅ ALL DELETED |
| `cd backend-spring && ./gradlew compileJava` | ✅ BUILD SUCCESSFUL |
| `npx tsc --noEmit --pretty false \| rg <변경 파일 패턴>`**변경 파일 기준 신규 오류** | ✅ 0건 |
### 14.8 Known Residual Risks
1. **기존 전체 `tsc` 오류는 여전히 존재할 수 있음.**
본 cleanup 은 canonical data-view 라우팅 / shared runtime 이동 / helper 도입 / 좁은
타입 cast 만 수행. 본 cleanup 범위 외의 snake_case ↔ camelCase 타입 불일치,
union 타입 누락, ComponentData 시그니처 불일치 등 기존 오류는 정리하지 않음.
별도 typecheck cleanup 트랙에서 처리해야 함.
2. **넓은 grep 패턴은 기존 split-panel / InvyoneStudio 오류를 잡을 수 있음.**
예시: `rg "split-panel\|InvyoneStudio"` 또는 `rg "container"` 같은 광역 패턴은
본 cleanup 무관한 InvyoneStudio drag/drop 분기의 기존 union 오류, SplitPanel
master-detail 의 기존 snake_case 불일치 등을 함께 매칭. 본 §14.7 acceptance 의
`tsc` 검증 패턴은 **변경 파일 list 기준** 으로 좁혀 사용한다.
3. **acceptance 의 신규 오류 0건 기준 = 변경 파일 기준.**
본 cleanup 14단계 누적 변경 파일 list:
```
lib/registry/components/table/TableComponent.tsx
lib/registry/components/table/_shared/{TableListComponent,V2TableListComponent,V2TableListContainerWrapper,tableListConfigTypes}.tsx
lib/utils/componentTypeUtils.ts
lib/utils/buttonActions.ts
lib/utils/templateMigrate.ts
lib/utils/getComponentConfigPanel.tsx
lib/utils/responsiveDefaults.ts
lib/utils/layoutV2Converter.ts (touched in earlier phases)
lib/registry/DynamicComponentRenderer.tsx
lib/registry/components/index.ts
lib/registry/components/container/types.ts
lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx
lib/registry/components/button-primary/ButtonPrimaryComponent.tsx
lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx
lib/schemas/componentConfig.ts
lib/api/screenGroup.ts
lib/fieldConfig/adapters.ts
app/(main)/screens/[screenId]/page.tsx
app/(pop)/pop/screens/[screenId]/page.tsx (touched in earlier phases)
components/dash/BlockRenderer.tsx
components/screen/RealtimePreviewDynamic.tsx
components/screen/ScreenNode.tsx
components/screen/widgets/TabsWidget.tsx
components/screen/modals/MultilangSettingsModal.tsx
components/screen/panels/ComponentsPanel.tsx
components/screen/config-panels/button/DataTab.tsx
components/screen/config-panels/button-config/ActionTab.tsx
components/v2/config-panels/InvLegacyButtonConfigPanel.tsx
components/v2/V2List.tsx
types/screen-management.ts
types/invyone-component.ts
backend-spring/src/main/java/com/erp/service/ScreenGroupService.java
backend-spring/src/main/resources/mapper/screenGroup.xml
```
이 list 의 변경 line 들에서 신규 오류 0건. 같은 파일의 다른 line 에 있는 기존
오류 (예: V2TableListComponent 의 cp 이전부터 있던 union 불일치, ActionTab 의
snake_case 불일치) 는 본 cleanup 범위 외.
4. **Backend 컴파일 + 런타임 영향 0건.**
본 cleanup 의 backend 변경은 `ScreenGroupService.countTableLikeWidgets` helper
추가 + `screenGroup.xml``componentType IN ('table', 'table-list', 'v2-table-list')` 확장만.
`./gradlew compileJava BUILD SUCCESSFUL`. 기존 backend 컴파일 정책에 영향 없음.
5. **Hard Blocker 폐기는 운영 안정성 보장 후.**
§14.6 의 다음 삭제 조건은 모두 "옛 layout JSON 사용 0 + N개월 운영 안정"
또는 "canonical skeleton parity 100%" 같은 boolean 조건. 운영 환경 DB 통계 +
회귀 테스트가 선결.
### 14.9 본 §14 의 코드 수정
**0건.** 본 단계는 검증 + 분류 + 보고서 보정 only. 기능 파일 / 주석 / 문서 / 코드
모두 미변경.
```
M notes/gbpark/2026-05-19-canonical-data-view-cleanup-followup.md (본 §14 추가)
```
@@ -0,0 +1,372 @@
# Control IDE EditCanvas 구조 리팩토링
> 작성일: 2026-05-20
> 작업자: gbpark
> 관련 mockup: `frontend/control-mode.standalone.html` (V3 EditCanvas)
> 관련 시안 산출물: `notes/gbpark/2026-05-19-control-mockup/`
---
## 0. 한 줄 요약
INVYONE 제어 모드(Control IDE)의 EditCanvas — 비즈니스 룰 시각 에디터를 **mockup V3 시안 톤으로 정밀하게** 정렬하면서, 사용자가 지적한 **4가지 구조 결함**을 Phase 1~3로 분해해 정정. 작업 중간 GPT(Codex) 와 2차례 의견 교환·검증을 거쳐 critical 버그까지 fix.
---
## 1. 배경
### 1.1 mockup 출발점
- `control-mode.standalone.html``v3-canvas.jsx` 가 V3 EditCanvas 의 진실의 원천.
- 노드(V3RuleNode): cat-color stripe + cat-chip header + label + summary + ports.
- 연결선: orthogonal-with-rounded-corners SVG path, **화살표 마커 없음**.
- mockup 의 RULE_NODES 는 **액션 노드만** (status-change / validation / condition / auto-insert / ...) — **테이블 카드는 mockup standalone 에 없음**.
- 사용자가 별도로 그린 user_info 컬럼 리스트 카드는 invyone 자체 확장. 이 확장의 정확한 모델이 핵심 논의 대상.
### 1.2 작업 시작 시점의 결함
- 노드 비주얼이 V3RuleNode 시안과 톤 어긋남 (`ide/V3RuleNode.tsx`, `ide/Canvas.tsx` 자체 구현). 시안 inline style 을 그대로 복붙.
- 컴포넌트 이름이 `V3RuleNode`, `V3CtxMenu`, `EditCanvas`**시안 단계 명칭** 박힘 (CLAUDE.md 의 "시안 명칭 사용 금지" 위반).
- 컬럼별 port stripe(좌·우)가 컬럼마다 N개 → **50컬럼 테이블에서 시각 폭발**.
- 노드 설정창의 "필드/대상 테이블/값" 입력이 **영어 텍스트 자유 입력** → 사용자가 컬럼명을 타이핑해야 동작. 실사용 불가.
- 노드↔노드, 컬럼↔노드 연결 시 SVG path 가 **dashed + 마칭 ant 애니메이션** + 화살표 marker → mockup 의 깔끔한 solid line 과 어긋남.
- mouseup race condition 으로 **연결선 자체가 절대 안 생성됨** (cleanup 이 PortHandle.onMouseUp 보다 먼저 발생 → `dragRef.current = null` 처리).
---
## 2. 사용자 지적 (구조 결함 4가지)
1. **컬럼별 port stripe는 50컬럼 테이블에서 안 통함** — user_info 8개도 빡빡. port hit target N×2 = 시각 노이즈.
2. **노드 설정의 "필드" 영어 자유 입력 = 실사용 불가** — 연결된 테이블의 컬럼 한글 라벨 dropdown 이어야 함.
3. **조건분기 → 다른 테이블 값 변경 흐름이 빌더에서 명시 안 됨** — 예시: 수주.결재상태 = "결재완료" 면 발주.결재상태 = "수주결재완료" 로 자동 변경. 발주 테이블 카드는 연결만 됐을 뿐, "어느 컬럼 / 어떤 값" 설정 어디서 하는지 사용자 입장에서 불명.
4. **컬럼별 port 가 너무 많아 직각 라우팅이 카드 사이를 휘감음** — 라우팅 자체보다 port 모델이 잘못된 것.
---
## 3. Codex (GPT) 검증 사이클
### 3.1 사전 의견 교환 (Phase 1~3 시작 전)
| 항목 | 내가 제시한 1안 | Codex 추가 권장 |
|---|---|---|
| 테이블 port 모델 | 좌·우 단일 port | ✓ 동의 (n8n/Retool/Appsmith 컨벤션) |
| 테이블 카드 비주얼 | 그대로 + dot 변경 | **V3RuleNode 와 동일한 컴팩트 카드** (180px, 9px radius, 3px top stripe, Lucide `Database` 아이콘, mono 영문 sub, `N cols · K FK · M used` stats row) |
| Field dropdown | 한글 + 영문 sub | ✓ + multi-table namespace (`수주관리 · 결재상태` / `ORDER_MGMT.APPROVAL_STATUS` / `select · enum`) + 상단 filter tabs |
| 대상 테이블 자동 추론 | 항상 자동 | **연결된 테이블 1개일 때만 자동, 2개+ 면 required dropdown** |
Codex 가 지적한 추가 blind spot (작업 범위에 반영):
- [HIGH] Edge 타입 정의 (data context / execution flow / table mutation / lookup)
- [HIGH] Multi-table field collision → 내부 ID는 fully qualified `{table, column}` 저장, 한글 라벨은 presentation only
- [HIGH] 실행 순서·순환 검증 (Phase 5+ 로 deferral)
- [MEDIUM] Enum metadata fallback, undo/redo, copy-paste subgraph remap
### 3.2 사후 검증 (Phase 1~3 완료 후)
| Bug | Severity | 내용 | 조치 |
|---|---|---|---|
| 10 | HIGH | `usePortDrag.finishDrag``to_port``in` 인지 검증 안 함 → output → output 역방향 엣지 저장 가능 | port direction validation 추가 (action 노드 도착 시 to_port 가 `in` 이어야, action 노드 출발 시 from_port 가 `in` 이 아니어야) |
| 4 | MEDIUM | `TablePicker``queueMicrotask` 가 렌더 클로저 캡처 → Strict 모드 stale render 위험 | `useEffect` 로 이전 (committed lifecycle) |
| 1 | LOW | `TableNode` head bottom padding `.25rem` vs V3 `.5rem`, stats font `.58rem` vs V3 `.5rem` | `.35rem` + `.5rem` 으로 V3 매칭 |
| 6b | LOW | duplicate 검출이 render-captured stale `ruleConnections` 사용 | `useControlMode.getState().ruleConnections` 로 변경 |
---
## 4. Phase 별 작업
### Phase 1 — 테이블 카드 V3 컴팩트 + 단일 port
**변경 파일**:
- `frontend/components/control/TableNode.tsx` — 완전 재작성
- `frontend/components/control/RuleBuilder.tsx``portPos``col:*` 분기 폐기
- `frontend/components/control/ControlMode.tsx``slice(0, 8)` 제거 (모든 컬럼 로드)
- `frontend/styles/control-mode.css``.tbl-node-compact` 클래스 신규
**핵심 변화**:
```
[기존] 220px 폭, 컬럼 N개 row, 컬럼마다 좌·우 stripe port, 카드 좌측 cyan 그라데이션 head
[변경] 180px 폭, 3px top stripe, Lucide Database 아이콘, 한글 라벨 + mono 영문 sub,
stats row `N cols · K PK · M FK`, 좌·우 edge 단일 port 1개씩
```
**RuleBuilder.portPos 단순화**:
```ts
if (node.type === 'table') {
const cardW = 180, cardH = 70;
const yMid = node.y + cardH / 2;
if (port === 'in') return { x: node.x, y: yMid };
return { x: node.x + cardW, y: yMid };
}
```
`col:*` 분기 완전 폐기. 컬럼 선택은 노드 설정창 dropdown 에서 처리.
### Phase 2 — NodeConfigPopover schema-driven dropdown
**변경 파일**:
- `frontend/components/control/NodeConfigPopover.tsx` — 완전 재작성
- `frontend/styles/control-mode.css``.cfg-empty`, `.cfg-static`, optgroup 색감 추가
**새 helper**:
- `flattenColumns(tables)` — 연결된 테이블들의 모든 컬럼을 flat ColumnMeta[] 로
- `serializeField` / `deserializeField``{table, column}``"table|column"` 문자열 직렬화 (HTML select value 호환)
- `findColumn(cols, field)` — string(legacy) 와 object 양방향 매칭
- `displayField(field, cols)` — 한글 라벨 → 영문 컬럼명 fallback
**새 컴포넌트**:
| 컴포넌트 | 역할 | 데이터 출처 |
|---|---|---|
| `FieldPicker` | `<select>` + `<optgroup>` 으로 테이블별 컬럼 그룹화. value 는 `"table|column"` 직렬화. PK/enum 태그 부착 | connectedTables[].columns |
| `TablePicker` | 1개면 `useEffect` 로 자동 채움 + readonly 표시. 2개+ 면 `<select>` | connectedTables |
| `ValuePicker` | field type 이 `select` + `options` 있으면 enum dropdown, 아니면 typed input | findColumn(field).options |
**핵심 4종 schema-driven 화**: `condition` / `status-change` / `calculation` / `validation``field` / `table` / `value` 입력을 새 컴포넌트로 교체. summary 생성 시 `displayField` helper 로 한글 라벨 사용.
**연결된 테이블 추적**:
```ts
const connectedTables = useMemo(() => {
const tableNodeIds = new Set<string>();
ruleConnections.forEach((c) => {
if (c.from_node_id === configNodeId) tableNodeIds.add(c.to_node_id);
if (c.to_node_id === configNodeId) tableNodeIds.add(c.from_node_id);
});
return ruleNodes.filter((n) => n.type === 'table' && tableNodeIds.has(n.id));
}, [configNodeId, ruleNodes, ruleConnections]);
```
### Phase 3 — Edge 타입 4종 + fully qualified ID
**변경 파일**:
- `frontend/components/control/hooks/usePortDrag.ts``finishDrag``edge_type` 자동 추론 + port direction validation
- `frontend/components/control/RuleBuilder.tsx` — path className 에 `edge-${type}` + 선 중간 라벨 chip
- `frontend/styles/control-mode.css``.rule-conn-path.edge-*` 4종 stroke 정의
**Edge 타입 자동 추론** (from/to 노드 타입 기반):
| 패턴 | edge_type | stroke |
|---|---|---|
| table → table | `lookup` | dashed green |
| table → action | `data-context` | solid cyan |
| action → table | `table-mutation` | solid pink |
| action → action | `execution-flow` | solid purple |
YES/NO port 의 stroke specificity 가 더 높아 override 가능 (조건분기 yes 분기는 항상 초록 solid 등).
**선 중간 라벨 chip** (mockup v3 EditCanvas 스타일):
| from_port | 라벨 | 색 |
|---|---|---|
| `yes` | 예 | green |
| `no` | 아니오 | muted gray |
| `pass` | 통과 | green |
| `fail` | 실패 | red |
| `approved` | 승인 | green |
| `rejected` | 반려 | muted gray |
| `each` / `done` | 반복 / 완료 | (edge_type 색) |
라벨 폭은 글자 수 기반 동적 (`max(36, length*8+14)`). `c.label` 명시 시 자동 라벨 override 가능.
**fully qualified ID 저장**:
- node.config.field, node.config.table 등은 모두 `{table, column}` 객체 또는 `string` (legacy 호환).
- presentation 은 `displayField` 로 한글 라벨, 저장은 객체 그대로.
---
## 5. 작업 중 fix 한 critical 버그
### 5.1 mouseup race condition (연결선 절대 안 생성)
**증상**: 컬럼 dot 에서 끌어 노드 in port 위에서 떼도 연결선 안 그어짐.
**원인**: `usePortDrag.ts` 의 document mouseup 핸들러가 PortHandle 의 onMouseUp 보다 먼저 발생 → `cleanup()` 으로 `dragRef.current = null` → 그 직후 PortHandle 의 onMouseUp → `finishDrag` 첫 줄 `if (!d) return` 으로 즉시 탈출.
**수정**: document mouseup 에서 직접 `e.target.closest('.ctrl-io-port')` 로 port 찾고 `finishDrag` 호출. PortHandle.onMouseUp 의존 폐기. 추가로 6x6 dot hit-target 문제 해결을 위해 좌표 fallback (마우스 위치 24px 반경 내 가장 가까운 port 검색):
```ts
const onUp = (e: MouseEvent) => {
if (!dragRef.current) return;
let portEl = (e.target as HTMLElement | null)?.closest?.('.ctrl-io-port') as HTMLElement | null;
if (!portEl) {
// 좌표 fallback — 24px 반경 가장 가까운 port
let best: { el: HTMLElement; dist: number } | null = null;
document.querySelectorAll<HTMLElement>('.ctrl-io-port').forEach((el) => {
const r = el.getBoundingClientRect();
const dx = e.clientX - (r.left + r.width / 2);
const dy = e.clientY - (r.top + r.height / 2);
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 24 && (!best || dist < best.dist)) best = { el, dist };
});
if (best) portEl = best.el;
}
if (portEl?.dataset.node && portEl.dataset.port) {
finishDrag(portEl.dataset.node, portEl.dataset.port);
return;
}
cleanup();
};
```
### 5.2 NodeConfigPopover 가 안 뜸
**증상**: 노드 클릭해도 설정 팝오버 안 보임.
**원인**: 외부 클릭 닫기 핸들러가 `.ctrl-an-body` 만 무시. ControlNode 를 V3 비주얼로 갈아치우면서 클래스가 `.v3-rule-node-body` 로 바뀜 → 노드 클릭 = 토글 이지만 같은 click event 가 document handler 도착 → closest('.ctrl-an-body') = null → 즉시 setConfigNodeId(null).
**수정**: close handler 에 `.v3-rule-node`, `.tbl-node` 도 무시 추가.
### 5.3 SVG path 가 dashed + 마칭 ant
**증상**: 연결선이 점선 + 흘러가는 애니메이션. mockup 의 solid line 과 어긋남.
**원인**: `.rule-conn-path` CSS 에 `stroke-dasharray: 6 3; animation: ctrlPulse 1.5s linear infinite` 박힘. path 함수를 mockup orthogonal-with-rounded-corners 로 바꿔도 CSS 가 덮어씀.
**수정**: `.rule-conn-path` 의 dasharray + animation 제거. solid + `stroke-linecap/linejoin: round` + opacity 0.75. `.conn-no` 만 dashed 유지.
### 5.4 ControlNode summary 가 `[object Object]`
**증상**: 노드 body 에 `field: [object Object]` 표시.
**원인**: Phase 2 에서 field 가 `{table, column}` 객체 저장. ControlNode 의 summary fallback `Object.entries(node.config).slice(0, 1).map(([k, v]) => `${k}: ${v}`)[0]` 가 객체를 그대로 string concat → `[object Object]`.
**수정**: `formatVal(v)` helper — 객체면 `v.label ?? v.column`, primitive 면 `String(v)`. summary 우선순위 명확히:
1. `node.config.summary` (NodeConfigPopover 가 저장한 한글 라벨)
2. `node.summary[0]`
3. config entries fallback (formatVal 적용)
4. `'클릭하여 설정'`
### 5.5 화살표 마커 + 컬럼 stripe 큰 dot
mockup v3 EditCanvas 도 화살표 마커 없음 → `markerEnd` 제거.
컬럼 stripe 도 14x14 너무 큼 (사용자 분노) → 8x8 으로 롤백, 결국 컬럼별 stripe 모델 자체 폐기 (Phase 1).
---
## 6. 데이터 모델 변화
### 6.1 RuleNode (table 노드)
```ts
{
id: 'tbl-user_info',
type: 'table',
table_name: 'user_info',
label: '사용자정보',
columns: FieldConfig[], // 모든 컬럼 (이전 slice(0, 8) 폐기)
x, y,
}
```
### 6.2 RuleNode.config (action 노드 설정값)
```ts
// 이전 (legacy)
{ field: 'STATUS', table: 'user_info', value: 'completed' }
// Phase 2/3
{
field: { table: 'user_info', column: 'APPROVAL_STATUS' }, // fully qualified
table: 'user_info',
value: 'completed',
summary: '결재상태 = "결재완료"', // 한글 라벨 (NodeConfigPopover 가 자동 생성)
}
```
호환: `findColumn`, `displayField`, `serializeField` 가 string/object 둘 다 처리.
### 6.3 Connection
```ts
{
id: 'conn-3',
from_node_id: 'ctrl-condition-1',
from_port: 'yes',
to_node_id: 'ctrl-status-change-2',
to_port: 'in',
edge_type: 'execution-flow', // 신규 (자동 추론)
label?: '예 · 부족', // 선택, 자동 라벨 override
}
```
---
## 7. 변경 파일 일람
```
frontend/components/control/
TableNode.tsx ★ 완전 재작성 (180px 컴팩트, Lucide Database, stats row, 단일 port)
ControlNode.tsx ★ V3 비주얼 갈아치움 + summary 우선순위 수정 + formatVal
RuleBuilder.tsx portPos col:* 폐기, path 에 edge_type 클래스 + 선 중간 label chip
PortHandle.tsx type 분기 폐기 (mousedown + mouseup 둘 다 수신)
NodeConfigPopover.tsx ★ 완전 재작성 (FieldPicker / TablePicker / ValuePicker)
ConnectionLine.tsx bezierPath → orthogonal + rounded corners (mockup 일치)
hooks/usePortDrag.ts edge_type 자동 추론 + port direction validation + 좌표 fallback
hooks/useControlMode.ts (변경 없음 — connection 의 edge_type 은 Record<string,any> 라 자동 호환)
ControlMode.tsx slice(0, 8) 제거, primary_table 자동 등장 폐기
ide/Canvas.tsx EditCanvas → RuleBuilder 위임 (PanZoom+V3RuleNode 자체구현 폐기)
frontend/styles/control-mode.css
.tbl-node-compact ... ★ 신규 (V3 컴팩트 톤)
.cfg-empty / .cfg-static ★ 신규 (schema-driven dropdown 보조)
.rule-conn-path ★ dashed + animation 제거 → solid + linecap round
.rule-conn-path.edge-* ★ 4종 stroke 색
.ctrl-io-port.tbl-io 제거 (컬럼 port 폐기)
```
---
## 8. 남은 작업 (Phase 4+)
### [HIGH] (실사용 위험)
1. **순환 참조 검증** — DFS cycle check on connection 추가 전. 현재 self-loop 만 차단.
2. **Topological 정렬** — 실행 시 노드 순서. 현재 좌표만 저장.
3. **legacy string field × 다중 테이블 ambiguity** — 첫 번째 동명 컬럼에 매칭. `displayField` 에 "(알 수 없는 테이블)" 경고 추가.
### [MEDIUM] (UX 향상)
4. **Undo / Redo** — zustand store 에 command history 추가.
5. **Copy / Paste subgraph** — node ID remap, table binding 보존.
6. **used-field chips** — 노드/카드에 실제 사용 중인 컬럼 시각 표시.
### [LOW]
7. **컴포넌트 이름 rename**`V3RuleNode`, `V3CtxMenu`, `EditCanvas` 등 시안 명칭 → `RuleNode`, `RuleContextMenu`, `RuleCanvas`. `ide/` 폴더 통째 rename.
8. **inline rgb → v5 토큰** — 일부 inline `rgba(108,92,231, ...)` 가 남아있음. `--v5-primary-rgb` 등으로 일괄 교체.
---
## 9. 검증 결과 (Codex 사후)
| 항목 | 결과 |
|---|---|
| Phase 1 단일 port + col:* 폐기 | ✓ VERIFIED |
| Phase 2 dropdown 데이터 흐름 | ✓ VERIFIED |
| Phase 3 edge_type 추론 | ✓ VERIFIED |
| useControlMode.getState() 패턴 | ✓ VERIFIED |
| legacy connection (edge_type 없음) fallback | ✓ VERIFIED |
| string/object field 양방향 호환 | ✓ VERIFIED |
| multi-table optgroup + 직렬화 충돌 없음 | ✓ VERIFIED |
| TypeScript 컴파일 (control 폴더) | ✓ 에러 0 |
---
## 10. 교훈 / 메모
### 10.1 mockup html 은 "시각 참고"지 "복붙 대상" 아님
- 처음에 `V3RuleNode.tsx`, `V3CtxMenu`, `EditCanvas` 등 시안 명칭을 코드에 박고 inline style 도 mockup 의 rgb 값 그대로 들고 옴 → CLAUDE.md 의 "시안 명칭 사용 금지" + cp 시스템 컨벤션 위반.
- 옳은 흐름: mockup → 시각·UX 이해 → invyone v5 디자인 시스템(`--v5-*` 토큰, cp 프리미티브, v5-layout.css) 위에 다시 그림.
### 10.2 컬럼 단위 port 는 직관적이지만 확장 불가
- 컬럼별 port 가 데이터 lineage 를 가장 명확히 보여줌은 사실이지만, ERP 의 50+ 컬럼 테이블에서는 시각 무너짐.
- 테이블 단위 port + 노드 설정에서 컬럼 dropdown 이 n8n / Retool / Appsmith 의 공통 패턴.
### 10.3 단일 dot 양방향화의 race condition
- HTML5 native event + React synthetic event 의 mouseup 발화 순서 차이로 race 발생. document mouseup 에서 직접 처리 + 좌표 fallback 이 가장 안정적.
### 10.4 schema-driven dropdown 의 multi-table 처리
- `<optgroup>` 으로 테이블별 group 분리 + value 직렬화 `"table|column"` 가 가장 단순한 충돌 회피 패턴.
- 라벨은 한글 우선, 영문 sub 는 mono font 로 보조. 같은 영문 컬럼명이 두 테이블에 있어도 optgroup 으로 시각 + value 구분.
---
## 11. 관련 문서
- mockup 시각 시안: `frontend/control-mode.standalone.html`
- 추출본 (참고): `/tmp/ctrl-standalone/` (v3-canvas.jsx, shared.jsx, rich-ui.jsx 등 — 임시)
- 이전 작업 누적 기록: `notes/gbpark/2026-05-19-control-mockup/`
- CLAUDE.md 컨벤션: 디자인 시스템 + 시안 명칭 금지 + cp 프리미티브 표준
@@ -0,0 +1,73 @@
<!doctype html>
<html lang="ko" class="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>채번 Admin V2 시안 갤러리</title>
<link rel="stylesheet" href="./numbering-v2.css" />
</head>
<body>
<main class="gallery">
<div class="gallery-inner">
<section class="gallery-hero" data-od-id="gallery-hero">
<div>
<span class="chip primary">INVYONE NUMBERING ADMIN · V2</span>
<h1 style="margin-top:14px;">채번을 관리가 아니라 조작 가능한 시스템으로 보이게</h1>
<p>메뉴관리의 좌우 밀도, 회사 멀티테넌시의 3컬럼 캐스케이드, CP 디자인의 속성 편집 문법을 섞어서 다시 만든 HTML 시안입니다. 운영 파일은 건드리지 않았고, 아래 세 방향을 따로 비교할 수 있습니다.</p>
</div>
<div class="status-chip"><span class="dot"></span>HTML 시안 3개 · 독립 화면</div>
</section>
<section class="gallery-grid" data-od-id="gallery-grid">
<article class="gallery-card">
<div>
<span class="chip green">A · CASCADE</span>
<h2 style="margin-top:12px;">3컬럼 관리형</h2>
<p>회사·조직 관리처럼 범위, 규칙 묶음, 상세를 단계적으로 고르는 구조입니다. 어드민 사용자가 빠르게 위치를 파악하기 좋습니다.</p>
</div>
<div class="preview-mini">
<div class="mini-side"></div>
<div class="mini-main">
<div class="mini-line short"></div>
<div class="mini-line"></div>
<div class="mini-line" style="width:65%;"></div>
</div>
</div>
<a class="btn primary" href="./variant-a-cascade.html">A 시안 열기</a>
</article>
<article class="gallery-card">
<div>
<span class="chip primary">B · CP INSPECTOR</span>
<h2 style="margin-top:12px;">조립 작업대형</h2>
<p>중앙에서 채번 포맷을 직접 조립하고, 우측 CP 패널에서 선택 파트 속성을 편집합니다. “채번 디자이너” 느낌이 가장 강합니다.</p>
</div>
<div class="preview-mini" style="grid-template-columns:.35fr 1fr .45fr;">
<div class="mini-side"></div>
<div class="mini-main"><div class="mini-line short"></div><div class="mini-line"></div></div>
<div class="mini-side"></div>
</div>
<a class="btn primary" href="./variant-b-cp-workbench.html">B 시안 열기</a>
</article>
<article class="gallery-card">
<div>
<span class="chip amber">C · GUARD</span>
<h2 style="margin-top:12px;">검증 보드형</h2>
<p>중복, 리셋, 테이블 연결, 마지막 발번 이력을 전면화한 운영자 친화형입니다. 실수 방지와 승인 플로우에 가장 적합합니다.</p>
</div>
<div class="preview-mini" style="grid-template-columns:.45fr 1fr;">
<div class="mini-side"></div>
<div class="mini-main">
<div class="mini-line short" style="background:var(--amber);"></div>
<div class="mini-line"></div>
<div class="mini-line" style="width:72%;"></div>
</div>
</div>
<a class="btn primary" href="./variant-c-guard.html">C 시안 열기</a>
</article>
</section>
</div>
</main>
</body>
</html>
@@ -0,0 +1,641 @@
:root {
--bg: oklch(98% 0.004 255);
--bg-subtle: oklch(95% 0.008 255);
--surface: oklch(100% 0 0);
--surface-hover: oklch(96.5% 0.009 255);
--surface-strong: oklch(92% 0.012 255);
--text: oklch(18% 0.018 255);
--text-sec: oklch(44% 0.018 255);
--text-muted: oklch(62% 0.014 255);
--text-faint: oklch(78% 0.01 255);
--border: oklch(88% 0.008 255);
--border-strong: oklch(76% 0.018 255);
--primary: oklch(58% 0.17 258);
--primary-soft: color-mix(in oklch, var(--primary) 10%, transparent);
--cyan: oklch(67% 0.14 205);
--cyan-soft: color-mix(in oklch, var(--cyan) 13%, transparent);
--green: oklch(61% 0.16 155);
--green-soft: color-mix(in oklch, var(--green) 12%, transparent);
--amber: oklch(70% 0.15 78);
--amber-soft: color-mix(in oklch, var(--amber) 14%, transparent);
--red: oklch(58% 0.18 25);
--red-soft: color-mix(in oklch, var(--red) 11%, transparent);
--shadow-sm: 0 10px 26px color-mix(in oklch, var(--primary) 9%, transparent);
--shadow-md: 0 24px 72px color-mix(in oklch, var(--primary) 14%, transparent);
--font-sans: -apple-system, BlinkMacSystemFont, "Pretendard", "Apple SD Gothic Neo", "Malgun Gothic", "Segoe UI", system-ui, sans-serif;
--font-mono: "JetBrains Mono", "D2Coding", ui-monospace, SFMono-Regular, Menlo, monospace;
--radius-sm: 7px;
--radius-md: 10px;
--radius-lg: 14px;
--topbar-h: 58px;
}
html.dark {
--bg: oklch(11% 0.012 260);
--bg-subtle: oklch(15% 0.014 260);
--surface: oklch(18% 0.014 260);
--surface-hover: oklch(22% 0.015 260);
--surface-strong: oklch(25% 0.016 260);
--text: oklch(94% 0.004 260);
--text-sec: oklch(72% 0.01 260);
--text-muted: oklch(54% 0.011 260);
--text-faint: oklch(38% 0.012 260);
--border: oklch(29% 0.012 260);
--border-strong: oklch(42% 0.018 260);
--primary: oklch(73% 0.13 258);
--cyan: oklch(80% 0.13 185);
--green: oklch(76% 0.14 155);
--amber: oklch(82% 0.13 82);
--red: oklch(72% 0.16 25);
--shadow-sm: 0 0 28px color-mix(in oklch, var(--primary) 13%, transparent);
--shadow-md: 0 0 70px color-mix(in oklch, var(--primary) 18%, transparent);
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-sans);
font-size: 13px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
button, input, select, textarea { font: inherit; color: inherit; }
button { cursor: pointer; }
a { color: inherit; text-decoration: none; }
h1, h2, h3, h4, p { margin: 0; }
h1, h2, h3 { letter-spacing: -0.02em; text-wrap: balance; }
p { text-wrap: pretty; }
svg { width: 14px; height: 14px; stroke: currentColor; fill: none; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
.demo-shell {
min-height: 100vh;
display: grid;
grid-template-rows: var(--topbar-h) 1fr;
background:
radial-gradient(circle at 82% 10%, color-mix(in oklch, var(--primary) 10%, transparent), transparent 26rem),
radial-gradient(circle at 12% 88%, color-mix(in oklch, var(--cyan) 10%, transparent), transparent 28rem),
var(--bg);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 0 22px;
border-bottom: 1px solid var(--border);
background: color-mix(in oklch, var(--surface) 94%, transparent);
backdrop-filter: blur(14px);
position: sticky;
top: 0;
z-index: 20;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.brand-mark {
width: 30px;
height: 30px;
border-radius: 9px;
display: grid;
place-items: center;
color: white;
background: linear-gradient(135deg, var(--primary), var(--cyan));
font-family: var(--font-mono);
font-weight: 900;
box-shadow: var(--shadow-sm);
}
.brand h1 { font-size: 16px; font-weight: 800; }
.breadcrumb {
display: flex;
gap: 6px;
align-items: center;
margin-top: 1px;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 10px;
}
.breadcrumb b { color: var(--text-sec); }
.top-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.btn {
height: 30px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: var(--radius-sm);
padding: 0 11px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 12px;
font-weight: 750;
transition: background .14s, border-color .14s, color .14s, transform .05s;
white-space: nowrap;
}
.btn:hover { border-color: var(--border-strong); background: var(--surface-hover); }
.btn:active { transform: translateY(1px); }
.btn.primary { background: var(--primary); border-color: var(--primary); color: white; }
.btn.cyan { background: var(--cyan); border-color: var(--cyan); color: oklch(15% 0.012 240); }
.btn.ghost { background: transparent; border-color: transparent; color: var(--text-sec); }
.btn.ghost:hover { background: var(--surface-hover); color: var(--text); }
.btn.icon { width: 30px; padding: 0; }
.kbd, .chip, .status-chip {
display: inline-flex;
align-items: center;
gap: 5px;
min-height: 23px;
padding: 2px 7px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-subtle);
color: var(--text-sec);
font-family: var(--font-mono);
font-size: 10px;
font-weight: 750;
}
.chip.primary { color: var(--primary); background: var(--primary-soft); border-color: color-mix(in oklch, var(--primary) 30%, var(--border)); }
.chip.green { color: var(--green); background: var(--green-soft); border-color: color-mix(in oklch, var(--green) 28%, var(--border)); }
.chip.amber { color: var(--amber); background: var(--amber-soft); border-color: color-mix(in oklch, var(--amber) 30%, var(--border)); }
.chip.red { color: var(--red); background: var(--red-soft); border-color: color-mix(in oklch, var(--red) 30%, var(--border)); }
.dot { width: 6px; height: 6px; border-radius: 999px; background: currentColor; flex: none; }
.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.viewport { min-height: 0; display: grid; overflow: hidden; }
.cascade { grid-template-columns: clamp(248px, 18vw, 310px) clamp(260px, 19vw, 330px) minmax(0, 1fr); }
.workbench { grid-template-columns: clamp(260px, 19vw, 320px) minmax(0, 1fr) clamp(310px, 24vw, 380px); }
.guard { grid-template-columns: clamp(260px, 18vw, 320px) minmax(0, 1fr); }
.col {
min-width: 0;
min-height: 0;
border-right: 1px solid var(--border);
background: color-mix(in oklch, var(--surface) 92%, transparent);
display: flex;
flex-direction: column;
overflow: hidden;
}
.col:last-child { border-right: 0; }
.panel-head {
padding: 15px 15px 11px;
border-bottom: 1px solid var(--border);
flex: none;
}
.step-label {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 9px;
font-weight: 900;
letter-spacing: .12em;
text-transform: uppercase;
}
.step-label b {
color: var(--primary);
background: var(--primary-soft);
border: 1px solid color-mix(in oklch, var(--primary) 30%, var(--border));
padding: 1px 5px;
border-radius: 4px;
}
.panel-head h2 { margin-top: 7px; font-size: 17px; font-weight: 850; }
.panel-head p { margin-top: 3px; color: var(--text-muted); font-size: 12px; }
.search {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
position: relative;
flex: none;
}
.search input {
width: 100%;
height: 32px;
padding: 0 34px 0 12px;
border: 1px solid var(--border);
border-radius: 9px;
background: var(--bg-subtle);
outline: 0;
font-size: 12px;
}
.search input:focus {
border-color: var(--primary);
background: var(--surface);
box-shadow: 0 0 0 3px color-mix(in oklch, var(--primary) 14%, transparent);
}
.search .kbd { position: absolute; right: 18px; top: 15px; min-height: 22px; }
.scroll { min-height: 0; overflow: auto; padding: 10px; }
.scroll::-webkit-scrollbar, .main-scroll::-webkit-scrollbar { width: 8px; height: 8px; }
.scroll::-webkit-scrollbar-thumb, .main-scroll::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 999px; }
.scope-card,
.nav-card,
.rule-row {
width: 100%;
border: 1px solid transparent;
background: transparent;
border-radius: var(--radius-md);
text-align: left;
padding: 11px;
display: grid;
gap: 7px;
transition: background .14s, border-color .14s, transform .14s;
}
.scope-card:hover, .nav-card:hover, .rule-row:hover { background: var(--surface-hover); border-color: var(--border); }
.scope-card.on, .nav-card.on, .rule-row.on {
background: var(--primary-soft);
border-color: color-mix(in oklch, var(--primary) 28%, var(--border));
}
.scope-top, .nav-top, .rule-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.scope-name, .nav-name, .rule-name { font-weight: 850; font-size: 13px; letter-spacing: -0.01em; }
.scope-meta, .nav-meta, .rule-meta { color: var(--text-muted); font-size: 11px; }
.scope-code, .rule-pattern {
color: var(--text-sec);
font-family: var(--font-mono);
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mini-meter {
height: 5px;
border-radius: 999px;
background: var(--bg-subtle);
overflow: hidden;
}
.mini-meter span { display: block; height: 100%; background: linear-gradient(90deg, var(--primary), var(--cyan)); border-radius: inherit; }
.section-title {
padding: 12px 4px 6px;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 9px;
font-weight: 900;
letter-spacing: .14em;
text-transform: uppercase;
display: flex;
justify-content: space-between;
}
.main-scroll {
min-width: 0;
min-height: 0;
overflow: auto;
background: var(--bg);
}
.detail-head {
position: sticky;
top: 0;
z-index: 10;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
background: color-mix(in oklch, var(--bg) 88%, transparent);
backdrop-filter: blur(16px);
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.title-line { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.title-line h2 { font-size: 22px; font-weight: 900; }
.detail-meta { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 7px; color: var(--text-muted); font-size: 12px; }
.detail-meta b { color: var(--text-sec); font-weight: 800; }
.content-pad { padding: 18px 20px 86px; display: grid; gap: 14px; }
.two-col { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(260px, .75fr); gap: 14px; }
.three-col { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: 0 1px 0 color-mix(in oklch, var(--surface) 80%, transparent);
}
.card.pad { padding: 16px; }
.card-head {
min-height: 48px;
padding: 13px 15px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.card-head h3 { font-size: 14px; font-weight: 850; }
.card-head p { margin-top: 2px; color: var(--text-muted); font-size: 11px; }
.code-hero {
position: relative;
min-height: 220px;
padding: 24px;
background:
linear-gradient(90deg, color-mix(in oklch, var(--primary) 9%, transparent), transparent 42%),
radial-gradient(circle at 85% 20%, color-mix(in oklch, var(--cyan) 12%, transparent), transparent 16rem),
var(--surface);
border: 1px solid var(--border);
border-radius: 18px;
overflow: hidden;
box-shadow: var(--shadow-md);
}
.code-hero::after {
content: "";
position: absolute;
inset: auto 24px 22px;
border-bottom: 1px dashed var(--border-strong);
}
.hero-kicker {
display: flex;
justify-content: space-between;
gap: 10px;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 10px;
font-weight: 850;
letter-spacing: .08em;
text-transform: uppercase;
}
.code-line {
margin-top: 26px;
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 6px;
font-family: var(--font-mono);
font-size: clamp(34px, 5vw, 62px);
line-height: 1;
font-weight: 950;
letter-spacing: -0.055em;
}
.code-token {
padding: 4px 8px;
border-radius: 10px;
border: 1px solid transparent;
background: color-mix(in oklch, var(--surface) 74%, var(--bg-subtle));
}
.code-token.prefix { color: var(--text); }
.code-token.date { color: var(--cyan); }
.code-token.seq { color: var(--primary); box-shadow: inset 0 -3px 0 color-mix(in oklch, var(--primary) 24%, transparent); }
.code-token.scope { color: var(--green); }
.code-dash { color: var(--text-faint); }
.hero-foot {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin-top: 30px;
}
.hero-foot span { color: var(--text-muted); font-size: 11px; }
.hero-foot b { display: block; color: var(--text); margin-top: 2px; font-family: var(--font-mono); font-size: 12px; }
.composer { padding: 15px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.part {
min-width: 106px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--bg-subtle);
padding: 11px;
text-align: left;
}
.part:hover { border-color: var(--border-strong); background: var(--surface-hover); }
.part.on { border-color: var(--primary); background: var(--primary-soft); }
.part .type { color: var(--text-muted); font-family: var(--font-mono); font-size: 9px; font-weight: 900; letter-spacing: .1em; text-transform: uppercase; }
.part .value { display: block; margin-top: 5px; font-family: var(--font-mono); font-size: 16px; font-weight: 900; }
.joiner { color: var(--text-faint); font-family: var(--font-mono); font-weight: 900; }
.add-part {
border: 1px dashed color-mix(in oklch, var(--primary) 40%, var(--border));
background: transparent;
color: var(--primary);
border-radius: 12px;
min-height: 58px;
padding: 0 16px;
font-weight: 850;
}
.field-grid { padding: 15px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.field { display: grid; gap: 6px; color: var(--text-sec); font-size: 11px; font-weight: 750; }
.input, .field input, .field select, .field textarea {
width: 100%;
min-height: 32px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-subtle);
padding: 0 10px;
outline: 0;
color: var(--text);
}
.field textarea { min-height: 74px; padding: 8px 10px; resize: vertical; }
.input:focus, .field input:focus, .field select:focus, .field textarea:focus {
border-color: var(--primary);
background: var(--surface);
box-shadow: 0 0 0 3px color-mix(in oklch, var(--primary) 12%, transparent);
}
.metric-card { padding: 14px; border-right: 1px solid var(--border); }
.metric-card:last-child { border-right: 0; }
.metric-card span { color: var(--text-muted); font-family: var(--font-mono); font-size: 9px; font-weight: 900; letter-spacing: .1em; text-transform: uppercase; }
.metric-card b { display: block; margin-top: 4px; font-size: 24px; font-family: var(--font-mono); }
.metric-card small { display: block; margin-top: 3px; color: var(--text-muted); font-size: 11px; }
.cp-panel { background: var(--surface); }
.cp-bar {
min-height: 54px;
border-bottom: 1px solid var(--border);
display: grid;
grid-template-columns: 104px 1fr;
}
.cp-kind, .cp-type {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 11px;
}
.cp-kind { background: var(--bg-subtle); border-right: 1px solid var(--border); color: var(--text-sec); font-weight: 850; }
.cp-type strong { display: block; font-size: 12px; }
.cp-type span { display: block; color: var(--text-muted); font-size: 10px; }
.cp-icon {
width: 25px;
height: 25px;
border-radius: 7px;
display: grid;
place-items: center;
background: var(--primary-soft);
color: var(--primary);
border: 1px solid color-mix(in oklch, var(--primary) 24%, var(--border));
font-family: var(--font-mono);
font-weight: 900;
}
.cp-scroll { min-height: 0; overflow: auto; padding: 12px; display: grid; gap: 12px; }
.cp-section {
border-top: 1px solid var(--border);
padding-top: 12px;
}
.cp-section:first-child { border-top: 0; padding-top: 0; }
.cp-section-title {
margin-bottom: 8px;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 9px;
font-weight: 900;
letter-spacing: .14em;
text-transform: uppercase;
}
.cp-row {
display: grid;
grid-template-columns: 82px 1fr;
gap: 9px;
align-items: center;
min-height: 34px;
padding: 4px 0;
}
.cp-row label { color: var(--text-sec); font-size: 11px; font-weight: 750; }
.cp-help { color: var(--text-muted); font-size: 10px; margin-top: 4px; }
.segment {
display: inline-flex;
padding: 2px;
background: var(--bg-subtle);
border: 1px solid var(--border);
border-radius: 8px;
gap: 2px;
}
.segment button {
min-height: 25px;
border: 0;
background: transparent;
border-radius: 6px;
padding: 0 8px;
color: var(--text-sec);
font-size: 11px;
font-weight: 800;
}
.segment button.on { background: var(--surface); color: var(--primary); box-shadow: 0 1px 0 var(--border); }
.toggle {
width: 34px;
height: 19px;
padding: 2px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--bg-subtle);
position: relative;
}
.toggle::after {
content: "";
display: block;
width: 13px;
height: 13px;
border-radius: 50%;
background: var(--text-faint);
transition: transform .18s, background .18s;
}
.toggle.on { background: var(--primary-soft); border-color: color-mix(in oklch, var(--primary) 35%, var(--border)); }
.toggle.on::after { transform: translateX(14px); background: var(--primary); }
.validation-grid { display: grid; grid-template-columns: 1.15fr .85fr; gap: 14px; }
.risk-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.risk-card {
padding: 14px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--surface);
}
.risk-card strong { display: block; font-size: 18px; margin-top: 8px; font-family: var(--font-mono); }
.risk-card p { color: var(--text-muted); font-size: 11px; margin-top: 4px; }
.risk-card.good { border-color: color-mix(in oklch, var(--green) 35%, var(--border)); background: var(--green-soft); }
.risk-card.warn { border-color: color-mix(in oklch, var(--amber) 40%, var(--border)); background: var(--amber-soft); }
.risk-card.bad { border-color: color-mix(in oklch, var(--red) 35%, var(--border)); background: var(--red-soft); }
.audit-row, .test-row {
display: grid;
grid-template-columns: 130px 1fr auto;
gap: 12px;
align-items: center;
padding: 12px 15px;
border-bottom: 1px solid var(--border);
}
.audit-row:last-child, .test-row:last-child { border-bottom: 0; }
.audit-row .time, .test-row .case { color: var(--text-muted); font-family: var(--font-mono); font-size: 10px; }
.audit-row strong, .test-row strong { display: block; }
.audit-row p, .test-row p { color: var(--text-muted); font-size: 11px; margin-top: 2px; }
.savebar {
position: sticky;
bottom: 0;
z-index: 12;
min-height: 58px;
padding: 10px 20px;
border-top: 1px solid var(--border);
background: color-mix(in oklch, var(--surface) 94%, transparent);
backdrop-filter: blur(14px);
display: flex;
justify-content: space-between;
align-items: center;
gap: 14px;
}
.savebar .note { color: var(--text-sec); font-weight: 800; }
.savebar .note span { color: var(--amber); }
.gallery {
min-height: 100vh;
padding: 34px;
background:
radial-gradient(circle at 86% 8%, color-mix(in oklch, var(--primary) 12%, transparent), transparent 28rem),
var(--bg);
}
.gallery-inner { max-width: 1180px; margin: 0 auto; display: grid; gap: 22px; }
.gallery-hero {
display: grid;
grid-template-columns: 1fr auto;
gap: 18px;
align-items: end;
padding-bottom: 22px;
border-bottom: 1px solid var(--border);
}
.gallery-hero h1 { font-size: clamp(32px, 4vw, 52px); max-width: 14ch; }
.gallery-hero p { color: var(--text-sec); max-width: 58ch; margin-top: 10px; font-size: 15px; }
.gallery-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.gallery-card {
min-height: 330px;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 22px;
padding: 18px;
border: 1px solid var(--border);
border-radius: 18px;
background: var(--surface);
box-shadow: var(--shadow-sm);
}
.gallery-card h2 { font-size: 20px; }
.gallery-card p { color: var(--text-sec); margin-top: 8px; }
.preview-mini {
height: 150px;
border: 1px solid var(--border);
border-radius: 14px;
background:
linear-gradient(90deg, var(--primary-soft), transparent),
var(--bg-subtle);
padding: 12px;
display: grid;
grid-template-columns: .45fr 1fr;
gap: 10px;
}
.mini-side, .mini-main {
border-radius: 10px;
background: var(--surface);
border: 1px solid var(--border);
}
.mini-main { display: grid; align-content: center; justify-items: start; padding: 14px; gap: 8px; }
.mini-line { height: 7px; border-radius: 999px; background: var(--border); width: 80%; }
.mini-line.short { width: 48%; background: var(--primary); }
.gallery-card .btn { align-self: flex-start; }
@media (max-width: 1100px) {
.cascade, .workbench, .guard { grid-template-columns: 1fr; overflow: auto; }
.col { min-height: auto; border-right: 0; border-bottom: 1px solid var(--border); }
.col.scope, .col.nav { max-height: none; }
.two-col, .validation-grid, .gallery-grid { grid-template-columns: 1fr; }
.field-grid, .three-col, .hero-foot, .risk-grid { grid-template-columns: 1fr; }
.topbar, .detail-head, .gallery-hero { align-items: flex-start; flex-direction: column; height: auto; padding: 14px; }
.demo-shell { grid-template-rows: auto 1fr; }
}
@@ -0,0 +1,202 @@
<!doctype html>
<html lang="ko" class="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>채번 Admin V2 A · 3컬럼 관리형</title>
<link rel="stylesheet" href="./numbering-v2.css" />
</head>
<body>
<div class="demo-shell">
<header class="topbar">
<div class="brand">
<span class="brand-mark">#</span>
<div>
<h1>채번 관리</h1>
<div class="breadcrumb"><span>ADMIN</span><span>/</span><b>시스템관리</b><span>/</span><b>채번 규칙</b></div>
</div>
</div>
<div class="top-actions">
<span class="status-chip"><span class="dot" style="color:var(--green);"></span>14개 테이블 연결</span>
<span class="kbd">Ctrl K</span>
<button class="btn ghost" id="themeBtn">라이트</button>
<button class="btn">변경 이력</button>
<button class="btn primary">새 채번 규칙</button>
</div>
</header>
<div class="viewport cascade">
<aside class="col scope">
<div class="panel-head">
<span class="step-label"><b>01</b>범위 선택</span>
<h2>업무 도메인</h2>
<p>채번을 쓰는 업무 기준으로 먼저 좁힙니다.</p>
</div>
<div class="search"><input id="domainSearch" placeholder="영업, 구매, 생산 검색" /><span class="kbd">/</span></div>
<div class="scroll" id="domainList">
<button class="scope-card on" data-domain="sales">
<div class="scope-top"><span class="scope-name">영업 관리</span><span class="chip green">정상</span></div>
<span class="scope-code">sales_order · quote · delivery</span>
<div class="mini-meter"><span style="width:82%;"></span></div>
<span class="scope-meta">규칙 6개 · 테이블 연결 5개</span>
</button>
<button class="scope-card" data-domain="purchase">
<div class="scope-top"><span class="scope-name">구매 관리</span><span class="chip primary">운영</span></div>
<span class="scope-code">purchase_order · inbound</span>
<div class="mini-meter"><span style="width:64%;"></span></div>
<span class="scope-meta">규칙 4개 · 테이블 연결 4개</span>
</button>
<button class="scope-card" data-domain="production">
<div class="scope-top"><span class="scope-name">생산 관리</span><span class="chip amber">검토</span></div>
<span class="scope-code">work_order · lot · routing</span>
<div class="mini-meter"><span style="width:48%;"></span></div>
<span class="scope-meta">규칙 5개 · 미연결 2개</span>
</button>
<button class="scope-card" data-domain="master">
<div class="scope-top"><span class="scope-name">기준 정보</span><span class="chip">초안</span></div>
<span class="scope-code">item_master · customer · bom</span>
<div class="mini-meter"><span style="width:34%;"></span></div>
<span class="scope-meta">규칙 3개 · 포맷 정리 필요</span>
</button>
</div>
</aside>
<aside class="col nav">
<div class="panel-head">
<span class="step-label"><b>02</b>규칙 묶음</span>
<h2>영업 관리</h2>
<p>테이블과 컬럼 단위로 규칙을 훑습니다.</p>
</div>
<div class="search"><input id="ruleSearch" placeholder="수주, 견적, 출하 번호 검색" /><span class="kbd">K</span></div>
<div class="scroll" id="ruleList">
<div class="section-title"><span>LIVE RULES</span><span>6</span></div>
<button class="nav-card on" data-rule="so">
<div class="nav-top"><span class="nav-name">수주번호 자동채번</span><span class="chip green">LIVE</span></div>
<span class="rule-pattern">SO-{yyyyMM}-{0000}</span>
<span class="nav-meta">sales_order.order_no · 월별 리셋</span>
</button>
<button class="nav-card" data-rule="quote">
<div class="nav-top"><span class="nav-name">견적번호</span><span class="chip green">LIVE</span></div>
<span class="rule-pattern">QT-{yyyy}-{00000}</span>
<span class="nav-meta">quote.quote_no · 연도별 리셋</span>
</button>
<button class="nav-card" data-rule="claim">
<div class="nav-top"><span class="nav-name">클레임 접수번호</span><span class="chip amber">확인</span></div>
<span class="rule-pattern">CLM-{company}-{000}</span>
<span class="nav-meta">claim.claim_no · 회사별 범위</span>
</button>
<div class="section-title"><span>NEEDS LINK</span><span>2</span></div>
<button class="nav-card" data-rule="delivery">
<div class="nav-top"><span class="nav-name">출하 배차번호</span><span class="chip">미연결</span></div>
<span class="rule-pattern">DLV-{date}-{seq}</span>
<span class="nav-meta">배송 화면에서 사용 예정</span>
</button>
</div>
</aside>
<main class="main-scroll">
<section class="detail-head" data-od-id="detail-head">
<div>
<div class="title-line">
<h2 id="detailTitle">수주번호 자동채번</h2>
<span class="chip green">사용 중</span>
<span class="chip primary">sales_order.order_no</span>
</div>
<div class="detail-meta">
<span>생성 위치 <b>영업관리 / 수주등록</b></span>
<span>리셋 기준 <b>매월 1일</b></span>
<span>마지막 수정 <b>오늘 09:42</b></span>
</div>
</div>
<div class="top-actions">
<button class="btn">되돌리기</button>
<button class="btn primary">저장</button>
</div>
</section>
<section class="content-pad" data-od-id="cascade-content">
<div class="code-hero">
<div class="hero-kicker"><span>NEXT NUMBER PREVIEW</span><button class="btn" id="copyCode">복사</button></div>
<div class="code-line" id="codeLine">
<span class="code-token prefix">SO</span><span class="code-dash">-</span><span class="code-token date">202605</span><span class="code-dash">-</span><span class="code-token seq">0048</span>
</div>
<div class="hero-foot">
<span>현재 순번<b>47</b></span>
<span>다음 순번<b>48</b></span>
<span>리셋 주기<b>월별</b></span>
<span>충돌 검사<b>통과</b></span>
</div>
</div>
<div class="three-col card">
<div class="metric-card"><span>ACTIVE RULES</span><b>18</b><small>전체 채번 규칙</small></div>
<div class="metric-card"><span>TABLE LINKS</span><b>14</b><small>실제 컬럼 연결</small></div>
<div class="metric-card"><span>NEEDS REVIEW</span><b>3</b><small>미연결 또는 경고</small></div>
</div>
<div class="two-col">
<article class="card">
<div class="card-head">
<div><h3>포맷 구성</h3><p>읽는 즉시 어떤 코드가 만들어지는지 보이게 합니다.</p></div>
<button class="btn">파트 추가</button>
</div>
<div class="composer">
<button class="part on"><span class="type">prefix</span><span class="value">SO</span></button><span class="joiner">-</span>
<button class="part"><span class="type">date</span><span class="value">yyyyMM</span></button><span class="joiner">-</span>
<button class="part"><span class="type">sequence</span><span class="value">0000</span></button>
<button class="add-part">+ 검증 파트</button>
</div>
</article>
<article class="card">
<div class="card-head">
<div><h3>운영 상태</h3><p>발번 사고를 막기 위한 최소 정보입니다.</p></div>
<span class="chip green">정상</span>
</div>
<div class="field-grid" style="grid-template-columns:1fr 1fr;">
<label class="field">현재 순번<input value="47" /></label>
<label class="field">다음 순번<input value="48" /></label>
<label class="field">동시성 정책<select><option>DB 잠금 후 증가</option><option>트랜잭션 단위 예약</option></select></label>
<label class="field">중복 허용<select><option>허용 안 함</option><option>관리자 승인</option></select></label>
</div>
</article>
</div>
</section>
<div class="savebar">
<div class="note"><span>저장 전</span> 미리보기와 테이블 연결을 다시 확인하세요.</div>
<div class="top-actions"><button class="btn">변경 취소</button><button class="btn primary">규칙 저장</button></div>
</div>
</main>
</div>
</div>
<script>
const html = document.documentElement;
document.getElementById("themeBtn").addEventListener("click", (event) => {
html.classList.toggle("dark");
event.currentTarget.textContent = html.classList.contains("dark") ? "라이트" : "다크";
});
document.querySelectorAll(".scope-card, .nav-card").forEach((btn) => {
btn.addEventListener("click", () => {
btn.parentElement.querySelectorAll(".on").forEach((item) => item.classList.remove("on"));
btn.classList.add("on");
if (btn.dataset.rule === "quote") {
document.getElementById("detailTitle").textContent = "견적번호";
document.getElementById("codeLine").innerHTML = '<span class="code-token prefix">QT</span><span class="code-dash">-</span><span class="code-token date">2026</span><span class="code-dash">-</span><span class="code-token seq">00129</span>';
}
});
});
document.getElementById("ruleSearch").addEventListener("input", (event) => {
const term = event.target.value.trim().toLowerCase();
document.querySelectorAll("#ruleList .nav-card").forEach((row) => {
row.style.display = row.textContent.toLowerCase().includes(term) ? "grid" : "none";
});
});
document.getElementById("copyCode").addEventListener("click", (event) => {
event.currentTarget.textContent = "복사됨";
setTimeout(() => event.currentTarget.textContent = "복사", 900);
});
</script>
</body>
</html>
@@ -0,0 +1,230 @@
<!doctype html>
<html lang="ko" class="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>채번 Admin V2 B · CP 조립 작업대형</title>
<link rel="stylesheet" href="./numbering-v2.css" />
</head>
<body>
<div class="demo-shell">
<header class="topbar">
<div class="brand">
<span class="brand-mark">#</span>
<div>
<h1>채번 디자이너</h1>
<div class="breadcrumb"><span>BUILDER</span><span>/</span><b>포맷 조립</b><span>/</span><b>수주번호</b></div>
</div>
</div>
<div class="top-actions">
<span class="status-chip"><span class="dot" style="color:var(--amber);"></span>초안 변경 2건</span>
<button class="btn ghost" id="themeBtn">라이트</button>
<button class="btn">테스트 발번</button>
<button class="btn primary">변경사항 저장</button>
</div>
</header>
<div class="viewport workbench">
<aside class="col">
<div class="panel-head">
<span class="step-label"><b>01</b>규칙 목록</span>
<h2>채번 규칙</h2>
<p>실제 연결된 컬럼과 미연결 초안을 함께 봅니다.</p>
</div>
<div class="search"><input id="ruleSearch" placeholder="규칙명, 테이블, 컬럼 검색" /><span class="kbd">K</span></div>
<div class="scroll" id="rules">
<div class="section-title"><span>RECENT</span><span>오늘 수정</span></div>
<button class="rule-row on" data-name="수주번호 자동채번">
<div class="rule-top"><span class="rule-name">수주번호 자동채번</span><span class="chip green">LIVE</span></div>
<span class="rule-pattern">SO-{yyyyMM}-{0000}</span>
<span class="rule-meta">sales_order.order_no · 다음 0048</span>
</button>
<button class="rule-row" data-name="입고검사 LOT">
<div class="rule-top"><span class="rule-name">입고검사 LOT</span><span class="chip primary">LIVE</span></div>
<span class="rule-pattern">IQC-{yyyyMMdd}-{000}</span>
<span class="rule-meta">inspection.lot_no · 다음 019</span>
</button>
<button class="rule-row" data-name="작업지시번호">
<div class="rule-top"><span class="rule-name">작업지시번호</span><span class="chip amber">검토</span></div>
<span class="rule-pattern">WO-{plant}-{date}-{seq}</span>
<span class="rule-meta">work_order.work_no · 범위 확인</span>
</button>
<div class="section-title"><span>DRAFT</span><span>미연결</span></div>
<button class="rule-row" data-name="품목마스터 코드">
<div class="rule-top"><span class="rule-name">품목마스터 코드</span><span class="chip">DRAFT</span></div>
<span class="rule-pattern">ITEM-{category}-{0000}</span>
<span class="rule-meta">컬럼 연결 전 · 초안 저장됨</span>
</button>
</div>
</aside>
<main class="main-scroll">
<section class="detail-head" data-od-id="detail-head">
<div>
<div class="title-line"><h2>수주번호 자동채번</h2><span class="chip green">활성</span><span class="chip primary">월별 리셋</span></div>
<div class="detail-meta">
<span>포맷 <b>SO-{yyyyMM}-{0000}</b></span>
<span>충돌 정책 <b>DB 잠금</b></span>
<span>테이블 <b>sales_order.order_no</b></span>
</div>
</div>
<div class="top-actions"><button class="btn">미리보기 새로고침</button><button class="btn primary">저장</button></div>
</section>
<section class="content-pad" data-od-id="workbench-content">
<div class="code-hero">
<div class="hero-kicker"><span>LIVE PREVIEW · 선택 파트: sequence</span><span class="chip amber">저장 전</span></div>
<div class="code-line">
<span class="code-token prefix">SO</span><span class="code-dash">-</span><span class="code-token date">202605</span><span class="code-dash">-</span><span class="code-token seq" id="seqToken">0048</span>
</div>
<div class="hero-foot">
<span>Prefix<b>SO</b></span>
<span>날짜 포맷<b>yyyyMM</b></span>
<span>자리수<b id="digitLabel">4자리</b></span>
<span>다음 발번<b id="nextLabel">0048</b></span>
</div>
</div>
<article class="card">
<div class="card-head">
<div><h3>포맷 조립 캔버스</h3><p>파트를 선택하면 우측 CP 패널이 해당 속성으로 전환됩니다.</p></div>
<div class="top-actions"><button class="btn">정렬</button><button class="btn">검증</button></div>
</div>
<div class="composer">
<button class="part" data-part="prefix"><span class="type">TEXT</span><span class="value">SO</span></button>
<span class="joiner">-</span>
<button class="part" data-part="date"><span class="type">DATE</span><span class="value">yyyyMM</span></button>
<span class="joiner">-</span>
<button class="part on" data-part="sequence"><span class="type">SEQUENCE</span><span class="value">0000</span></button>
<button class="add-part">+ 파트 추가</button>
</div>
</article>
<div class="two-col">
<article class="card">
<div class="card-head">
<div><h3>테이블 연결</h3><p>어느 화면/컬럼에서 이 채번을 호출하는지 명확히 보여줍니다.</p></div>
<span class="chip green">1개 연결</span>
</div>
<div class="field-grid">
<label class="field">업무 화면<input value="수주등록" /></label>
<label class="field">테이블<input value="sales_order" /></label>
<label class="field">컬럼<input value="order_no" /></label>
<label class="field">생성 시점<select><option>신규 저장 직전</option><option>행 추가 즉시</option></select></label>
<label class="field">수동 입력<select><option>관리자만 허용</option><option>허용 안 함</option></select></label>
<label class="field">중복 검사<select><option>저장 전 필수</option><option>백그라운드 경고</option></select></label>
</div>
</article>
<article class="card">
<div class="card-head">
<div><h3>빠른 테스트</h3><p>저장 전 샘플을 바로 만들어 봅니다.</p></div>
<button class="btn cyan" id="generateBtn">3건 생성</button>
</div>
<div id="sampleList">
<div class="test-row"><span class="case">SAMPLE 01</span><div><strong>SO-202605-0048</strong><p>현재 설정 기준 첫 번째 발번</p></div><span class="chip green">OK</span></div>
<div class="test-row"><span class="case">SAMPLE 02</span><div><strong>SO-202605-0049</strong><p>연속 증가 확인</p></div><span class="chip green">OK</span></div>
<div class="test-row"><span class="case">SAMPLE 03</span><div><strong>SO-202605-0050</strong><p>자리수 유지 확인</p></div><span class="chip green">OK</span></div>
</div>
</article>
</div>
</section>
<div class="savebar">
<div class="note"><span>sequence 파트</span> 자리수를 바꾸면 기존 발번과 길이가 달라집니다.</div>
<div class="top-actions"><button class="btn">취소</button><button class="btn primary">저장 후 적용</button></div>
</div>
</main>
<aside class="col cp-panel">
<div class="cp-bar">
<div class="cp-kind">규칙</div>
<div class="cp-type">
<span class="cp-icon" id="cpIcon">SEQ</span>
<div><strong id="cpTitle">순번 파트</strong><span id="cpDesc">자리수와 리셋 정책을 조정</span></div>
</div>
</div>
<div class="cp-scroll">
<section class="cp-section" data-od-id="cp-data-scope">
<div class="cp-section-title">데이터 소속</div>
<div class="cp-row"><label>테이블</label><input class="input" value="sales_order" /></div>
<div class="cp-row"><label>컬럼</label><input class="input" value="order_no" /></div>
<div class="cp-row"><label>범위</label><select class="input"><option>회사 + 월별</option><option>전체 + 월별</option><option>회사 + 일별</option></select></div>
</section>
<section class="cp-section" data-od-id="cp-part-settings">
<div class="cp-section-title">선택 파트 설정</div>
<div class="cp-row"><label>표시명</label><input class="input" id="partName" value="순번" /></div>
<div class="cp-row">
<label>자리수</label>
<div class="segment" id="digitSeg">
<button>3</button><button class="on">4</button><button>5</button><button>6</button>
</div>
</div>
<div class="cp-row"><label>초기값</label><input class="input" value="1" /></div>
<div class="cp-row"><label>패딩</label><input class="input" value="0" /></div>
<div class="cp-help">자리수는 표시 포맷만 바꾸고, 현재 순번 값은 유지됩니다.</div>
</section>
<section class="cp-section" data-od-id="cp-operation-options">
<div class="cp-section-title">운영 옵션</div>
<div class="cp-row"><label>자동 발번</label><button class="toggle on" aria-label="자동 발번"></button></div>
<div class="cp-row"><label>중복 차단</label><button class="toggle on" aria-label="중복 차단"></button></div>
<div class="cp-row"><label>수동 수정</label><button class="toggle" aria-label="수동 수정"></button></div>
<div class="cp-row"><label>리셋 주기</label><select class="input"><option>월별</option><option>일별</option><option>연도별</option><option>없음</option></select></div>
</section>
<section class="cp-section" data-od-id="cp-advanced">
<div class="cp-section-title">고급</div>
<div class="cp-row"><label>동시성</label><select class="input"><option>DB row lock</option><option>optimistic retry</option></select></div>
<div class="cp-row"><label>감사 로그</label><button class="toggle on" aria-label="감사 로그"></button></div>
</section>
</div>
</aside>
</div>
</div>
<script>
document.getElementById("themeBtn").addEventListener("click", (event) => {
document.documentElement.classList.toggle("dark");
event.currentTarget.textContent = document.documentElement.classList.contains("dark") ? "라이트" : "다크";
});
document.querySelectorAll(".part").forEach((part) => {
part.addEventListener("click", () => {
document.querySelectorAll(".part").forEach((item) => item.classList.remove("on"));
part.classList.add("on");
const map = {
prefix: ["TXT", "고정 문자", "SO 같은 업무 접두어"],
date: ["DAY", "날짜 파트", "연월일 포맷과 기준일"],
sequence: ["SEQ", "순번 파트", "자리수와 리셋 정책을 조정"]
};
const data = map[part.dataset.part];
document.getElementById("cpIcon").textContent = data[0];
document.getElementById("cpTitle").textContent = data[1];
document.getElementById("cpDesc").textContent = data[2];
});
});
document.querySelectorAll("#digitSeg button").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelectorAll("#digitSeg button").forEach((item) => item.classList.remove("on"));
btn.classList.add("on");
const digits = Number(btn.textContent);
const value = String(48).padStart(digits, "0");
document.getElementById("seqToken").textContent = value;
document.getElementById("digitLabel").textContent = `${digits}자리`;
document.getElementById("nextLabel").textContent = value;
});
});
document.getElementById("ruleSearch").addEventListener("input", (event) => {
const term = event.target.value.trim().toLowerCase();
document.querySelectorAll("#rules .rule-row").forEach((row) => {
row.style.display = row.textContent.toLowerCase().includes(term) ? "grid" : "none";
});
});
document.getElementById("generateBtn").addEventListener("click", (event) => {
event.currentTarget.textContent = "생성 완료";
setTimeout(() => event.currentTarget.textContent = "3건 생성", 900);
});
</script>
</body>
</html>
@@ -0,0 +1,182 @@
<!doctype html>
<html lang="ko" class="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>채번 Admin V2 C · 검증 보드형</title>
<link rel="stylesheet" href="./numbering-v2.css" />
</head>
<body>
<div class="demo-shell">
<header class="topbar">
<div class="brand">
<span class="brand-mark">#</span>
<div>
<h1>채번 운영 검증</h1>
<div class="breadcrumb"><span>OPS</span><span>/</span><b>번호 충돌 방지</b><span>/</span><b>승인 대기</b></div>
</div>
</div>
<div class="top-actions">
<span class="status-chip"><span class="dot" style="color:var(--amber);"></span>검증 필요 2건</span>
<button class="btn ghost" id="themeBtn">라이트</button>
<button class="btn">전체 테스트</button>
<button class="btn primary">승인 후 저장</button>
</div>
</header>
<div class="viewport guard">
<aside class="col">
<div class="panel-head">
<span class="step-label"><b>01</b>검증 큐</span>
<h2>저장 전 점검</h2>
<p>위험도가 있는 규칙부터 먼저 봅니다.</p>
</div>
<div class="search"><input id="queueSearch" placeholder="규칙명 또는 원인 검색" /><span class="kbd">F</span></div>
<div class="scroll" id="queue">
<div class="section-title"><span>NEEDS APPROVAL</span><span>2</span></div>
<button class="rule-row on" data-kind="warn">
<div class="rule-top"><span class="rule-name">작업지시번호</span><span class="chip amber">범위 변경</span></div>
<span class="rule-pattern">WO-{plant}-{yyyyMMdd}-{000}</span>
<span class="rule-meta">plant 파트 추가로 기존 번호와 길이 변경</span>
</button>
<button class="rule-row" data-kind="bad">
<div class="rule-top"><span class="rule-name">품목마스터 코드</span><span class="chip red">중복 위험</span></div>
<span class="rule-pattern">ITEM-{category}-{000}</span>
<span class="rule-meta">category 코드가 빈 값일 때 기존 ITEM-000과 충돌</span>
</button>
<div class="section-title"><span>SAFE TO SAVE</span><span>4</span></div>
<button class="rule-row" data-kind="good">
<div class="rule-top"><span class="rule-name">수주번호 자동채번</span><span class="chip green">통과</span></div>
<span class="rule-pattern">SO-{yyyyMM}-{0000}</span>
<span class="rule-meta">샘플 20건 중복 없음</span>
</button>
<button class="rule-row" data-kind="good">
<div class="rule-top"><span class="rule-name">견적번호</span><span class="chip green">통과</span></div>
<span class="rule-pattern">QT-{yyyy}-{00000}</span>
<span class="rule-meta">연도별 리셋 정상</span>
</button>
</div>
</aside>
<main class="main-scroll">
<section class="detail-head" data-od-id="detail-head">
<div>
<div class="title-line"><h2 id="guardTitle">작업지시번호</h2><span class="chip amber" id="guardStatus">승인 필요</span><span class="chip primary">work_order.work_no</span></div>
<div class="detail-meta">
<span>변경 유형 <b>범위 파트 추가</b></span>
<span>영향 화면 <b>생산계획 / 작업지시</b></span>
<span>요청자 <b>admin01</b></span>
</div>
</div>
<div class="top-actions"><button class="btn">반려</button><button class="btn primary">승인</button></div>
</section>
<section class="content-pad" data-od-id="guard-content">
<div class="validation-grid">
<div class="code-hero">
<div class="hero-kicker"><span>PROPOSED NUMBER</span><span class="chip amber">포맷 변경</span></div>
<div class="code-line" id="guardCode">
<span class="code-token prefix">WO</span><span class="code-dash">-</span><span class="code-token scope">A01</span><span class="code-dash">-</span><span class="code-token date">20260520</span><span class="code-dash">-</span><span class="code-token seq">019</span>
</div>
<div class="hero-foot">
<span>기존 예시<b>WO-20260520-019</b></span>
<span>변경 예시<b>WO-A01-20260520-019</b></span>
<span>영향 레코드<b>신규 발번부터</b></span>
<span>승인 상태<b>대기</b></span>
</div>
</div>
<article class="card">
<div class="card-head">
<div><h3>검증 요약</h3><p>저장 전에 반드시 확인할 위험 신호입니다.</p></div>
<span class="chip amber">2개 확인</span>
</div>
<div class="risk-grid" style="grid-template-columns:1fr;">
<div class="risk-card warn"><span class="chip amber">WARNING</span><strong>길이 변경</strong><p>작업지시번호가 3자 더 길어져 외부 양식 컬럼 폭 확인이 필요합니다.</p></div>
<div class="risk-card good"><span class="chip green">PASS</span><strong>중복 없음</strong><p>최근 90일 발번 이력 기준 충돌 후보가 없습니다.</p></div>
<div class="risk-card good"><span class="chip green">PASS</span><strong>리셋 정상</strong><p>공장별 범위와 일별 리셋이 같은 트랜잭션 안에서 계산됩니다.</p></div>
</div>
</article>
</div>
<div class="risk-grid">
<div class="risk-card warn"><span class="chip amber">FORMAT</span><strong>+A01</strong><p>공장 코드가 새로 들어갑니다.</p></div>
<div class="risk-card good"><span class="chip green">HISTORY</span><strong>0</strong><p>샘플 충돌 후보 없음</p></div>
<div class="risk-card good"><span class="chip green">LOCK</span><strong>ON</strong><p>동시 저장 보호 활성</p></div>
</div>
<div class="two-col">
<article class="card">
<div class="card-head">
<div><h3>테스트 케이스</h3><p>업무 조건별로 실제 출력이 어떻게 달라지는지 보여줍니다.</p></div>
<button class="btn">케이스 추가</button>
</div>
<div>
<div class="test-row"><span class="case">CASE 01</span><div><strong>공장 A01 / 오늘</strong><p>WO-A01-20260520-019</p></div><span class="chip green">PASS</span></div>
<div class="test-row"><span class="case">CASE 02</span><div><strong>공장 B03 / 오늘</strong><p>WO-B03-20260520-001</p></div><span class="chip green">PASS</span></div>
<div class="test-row"><span class="case">CASE 03</span><div><strong>공장 코드 없음</strong><p>필수값 누락으로 발번 중단</p></div><span class="chip amber">확인</span></div>
</div>
</article>
<article class="card">
<div class="card-head">
<div><h3>승인 체크리스트</h3><p>관리자가 실수 없이 결정하게 만드는 항목입니다.</p></div>
<span class="chip primary">3단계</span>
</div>
<div class="field-grid" style="grid-template-columns:1fr;">
<label class="field">1. 기존 문서 양식 폭 확인<select><option>확인 완료</option><option>확인 필요</option></select></label>
<label class="field">2. 누락 공장 코드 처리<select><option>발번 중단 + 메시지 표시</option><option>기본값 A01 사용</option></select></label>
<label class="field">3. 적용 시점<select><option>승인 이후 신규 발번부터</option><option>내일 00시부터</option></select></label>
<label class="field">승인 메모<textarea>생산계획 화면의 작업지시번호 폭 확인 후 신규 발번부터 적용.</textarea></label>
</div>
</article>
</div>
<article class="card">
<div class="card-head">
<div><h3>감사 이력</h3><p>누가 어떤 이유로 채번 규칙을 바꿨는지 남깁니다.</p></div>
<button class="btn">전체 이력 보기</button>
</div>
<div>
<div class="audit-row"><span class="time">오늘 10:18</span><div><strong>plant 파트 추가 요청</strong><p>생산관리에서 공장별 작업지시 구분 필요</p></div><span class="chip amber">대기</span></div>
<div class="audit-row"><span class="time">5월 18일</span><div><strong>일별 리셋으로 변경</strong><p>월별 순번이 너무 커져 작업 현장 식별성이 낮아짐</p></div><span class="chip green">승인</span></div>
<div class="audit-row"><span class="time">5월 12일</span><div><strong>초기 규칙 생성</strong><p>WO-{yyyyMMdd}-{000} 형식 등록</p></div><span class="chip green">완료</span></div>
</div>
</article>
</section>
<div class="savebar">
<div class="note"><span>승인 필요</span> 케이스 1개가 남아 있습니다. 공장 코드 누락 처리만 확정하면 저장 가능합니다.</div>
<div class="top-actions"><button class="btn">임시 저장</button><button class="btn primary">체크 완료 후 승인</button></div>
</div>
</main>
</div>
</div>
<script>
document.getElementById("themeBtn").addEventListener("click", (event) => {
document.documentElement.classList.toggle("dark");
event.currentTarget.textContent = document.documentElement.classList.contains("dark") ? "라이트" : "다크";
});
const queueRows = document.querySelectorAll("#queue .rule-row");
queueRows.forEach((row) => {
row.addEventListener("click", () => {
queueRows.forEach((item) => item.classList.remove("on"));
row.classList.add("on");
if (row.dataset.kind === "bad") {
document.getElementById("guardTitle").textContent = "품목마스터 코드";
document.getElementById("guardStatus").textContent = "중복 위험";
document.getElementById("guardStatus").className = "chip red";
document.getElementById("guardCode").innerHTML = '<span class="code-token prefix">ITEM</span><span class="code-dash">-</span><span class="code-token scope">MTR</span><span class="code-dash">-</span><span class="code-token seq">000</span>';
}
});
});
document.getElementById("queueSearch").addEventListener("input", (event) => {
const term = event.target.value.trim().toLowerCase();
queueRows.forEach((row) => {
row.style.display = row.textContent.toLowerCase().includes(term) ? "grid" : "none";
});
});
</script>
</body>
</html>
@@ -0,0 +1,120 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>채번 Admin V3 시안 갤러리</title>
<link rel="stylesheet" href="./numbering-v3.css" />
</head>
<body>
<main class="gallery">
<div class="gallery-inner">
<section class="gallery-hero" data-od-id="gallery-hero">
<div>
<span class="chip primary">INVYONE NUMBERING ADMIN · V3</span>
<h1>과한 데모를 빼고, 실제 관리자 화면처럼 다시 잡은 채번 시안</h1>
<p>메뉴관리의 낮은 헤더와 촘촘한 리스트, 회사 멀티테넌시의 accordion 운영 감각, CP 디자인의 우측 속성 패널을 참고했습니다. 이번 세트는 장식보다 반복 작업, 선택 상태, 저장 전 검증을 중심으로 봐주세요.</p>
</div>
<div class="top-actions">
<a class="btn" href="../2026-05-20-numbering-admin-variants-v2/index.html">이전 V2 보기</a>
<span class="chip green"><span class="dot"></span>HTML 4개</span>
</div>
</section>
<section class="gallery-grid" data-od-id="gallery-grid">
<article class="gallery-card">
<div>
<span class="chip green">A · QUIET ADMIN</span>
<h2>조용한 관리자형</h2>
<p>가장 현실적인 형태입니다. 좌측 규칙 리스트, 우측 상세, 다음 발번 미리보기만 명확하게 둔 버전입니다.</p>
</div>
<div class="mini">
<div class="side">
<div class="mini-line primary"></div>
<div class="mini-line"></div>
<div class="mini-line"></div>
</div>
<div class="main">
<div class="mini-line" style="width:48%;"></div>
<div class="mini-line primary" style="width:72%;height:18px;"></div>
<div class="mini-line" style="width:88%;"></div>
<div class="mini-line" style="width:63%;"></div>
</div>
</div>
<a class="btn primary" href="./variant-a-quiet-admin.html">A 시안 열기</a>
</article>
<article class="gallery-card">
<div>
<span class="chip primary">B · CP EDITOR</span>
<h2>CP 편집기형</h2>
<p>중앙에서 포맷을 조립하고, 우측 CP 패널에서 선택 파트만 편집합니다. 이전보다 톤을 낮춘 채번 디자이너입니다.</p>
</div>
<div class="mini three">
<div class="side">
<div class="mini-line"></div>
<div class="mini-line primary"></div>
<div class="mini-line"></div>
</div>
<div class="main">
<div class="mini-line primary" style="width:76%;height:18px;"></div>
<div class="mini-line" style="width:84%;"></div>
<div class="mini-line" style="width:56%;"></div>
</div>
<div class="side">
<div class="mini-line" style="width:64%;"></div>
<div class="mini-line"></div>
<div class="mini-line"></div>
</div>
</div>
<a class="btn primary" href="./variant-b-cp-editor.html">B 시안 열기</a>
</article>
<article class="gallery-card">
<div>
<span class="chip amber">C · OPERATIONS</span>
<h2>운영 검토형</h2>
<p>운영자가 충돌, 미연결, 리셋 예정 항목을 먼저 훑고 펼쳐서 조치합니다. 회사관리 accordion 감각에 가깝습니다.</p>
</div>
<div class="mini">
<div class="side">
<div class="mini-line primary"></div>
<div class="mini-line"></div>
<div class="mini-line"></div>
</div>
<div class="main">
<div class="mini-line" style="width:42%;"></div>
<div class="mini-line primary" style="width:94%;"></div>
<div class="mini-line" style="width:94%;"></div>
<div class="mini-line" style="width:80%;"></div>
</div>
</div>
<a class="btn primary" href="./variant-c-operations-review.html">C 시안 열기</a>
</article>
<article class="gallery-card">
<div>
<span class="chip primary">D · TABLE FIRST</span>
<h2>표 중심형</h2>
<p>가장 실무적인 초밀도 버전입니다. 전체 규칙을 표로 훑고, 선택 행만 오른쪽에서 조치합니다.</p>
</div>
<div class="mini">
<div class="side">
<div class="mini-line"></div>
<div class="mini-line"></div>
<div class="mini-line primary"></div>
</div>
<div class="main">
<div class="mini-line primary" style="width:92%;"></div>
<div class="mini-line" style="width:92%;"></div>
<div class="mini-line" style="width:92%;"></div>
<div class="mini-line" style="width:70%;"></div>
</div>
</div>
<a class="btn primary" href="./variant-d-table-first.html">D 시안 열기</a>
</article>
</section>
</div>
</main>
</body>
</html>
@@ -0,0 +1,636 @@
:root {
--bg: oklch(99% 0.002 255);
--panel: oklch(100% 0 0);
--panel-2: oklch(97.4% 0.004 255);
--panel-3: oklch(95.4% 0.006 255);
--text: oklch(19% 0.015 255);
--text-2: oklch(43% 0.016 255);
--text-3: oklch(61% 0.012 255);
--text-4: oklch(76% 0.01 255);
--line: oklch(90% 0.007 255);
--line-2: oklch(84% 0.012 255);
--primary: oklch(55% 0.17 265);
--primary-soft: color-mix(in oklch, var(--primary) 9%, transparent);
--primary-line: color-mix(in oklch, var(--primary) 24%, var(--line));
--green: oklch(56% 0.14 155);
--green-soft: color-mix(in oklch, var(--green) 10%, transparent);
--amber: oklch(66% 0.13 78);
--amber-soft: color-mix(in oklch, var(--amber) 12%, transparent);
--red: oklch(57% 0.16 24);
--red-soft: color-mix(in oklch, var(--red) 10%, transparent);
--cyan: oklch(58% 0.11 210);
--cyan-soft: color-mix(in oklch, var(--cyan) 10%, transparent);
--shadow: 0 10px 28px color-mix(in oklch, var(--text) 7%, transparent);
--font: -apple-system, BlinkMacSystemFont, "Pretendard", "Apple SD Gothic Neo", "Malgun Gothic", "Segoe UI", system-ui, sans-serif;
--mono: "JetBrains Mono", "D2Coding", ui-monospace, SFMono-Regular, Menlo, monospace;
--r-sm: 6px;
--r-md: 8px;
--r-lg: 12px;
}
html.dark {
--bg: oklch(12% 0.009 260);
--panel: oklch(16% 0.01 260);
--panel-2: oklch(19% 0.012 260);
--panel-3: oklch(23% 0.014 260);
--text: oklch(94% 0.004 260);
--text-2: oklch(73% 0.009 260);
--text-3: oklch(55% 0.011 260);
--text-4: oklch(38% 0.012 260);
--line: oklch(27% 0.011 260);
--line-2: oklch(38% 0.016 260);
--primary: oklch(73% 0.12 265);
--green: oklch(75% 0.13 155);
--amber: oklch(80% 0.12 78);
--red: oklch(71% 0.15 24);
--cyan: oklch(76% 0.11 205);
--shadow: 0 0 0 transparent;
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 13px;
line-height: 1.45;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow: hidden;
}
button, input, select, textarea { font: inherit; color: inherit; }
button { cursor: pointer; }
a { color: inherit; text-decoration: none; }
h1, h2, h3, h4, p { margin: 0; }
svg { width: 14px; height: 14px; stroke: currentColor; fill: none; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
.muted { color: var(--text-3); }
.strong { color: var(--text); font-weight: 750; }
.app {
height: 100vh;
display: grid;
grid-template-rows: 48px minmax(0, 1fr);
background: var(--bg);
}
.top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 0 18px;
border-bottom: 1px solid var(--line);
background: var(--panel);
}
.top-left { display: flex; align-items: center; gap: 12px; min-width: 0; }
.mark {
width: 26px;
height: 26px;
border-radius: 7px;
display: grid;
place-items: center;
background: var(--primary);
color: var(--panel);
font-family: var(--mono);
font-weight: 900;
font-size: 14px;
}
.top h1 { font-size: 14px; font-weight: 780; letter-spacing: -0.01em; }
.path { display: flex; align-items: center; gap: 6px; color: var(--text-3); font-size: 11px; white-space: nowrap; }
.path b { color: var(--text-2); font-weight: 650; }
.top-actions { display: flex; align-items: center; justify-content: flex-end; gap: 7px; flex-wrap: wrap; }
.btn {
height: 29px;
padding: 0 10px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: var(--panel);
color: var(--text-2);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 12px;
font-weight: 680;
white-space: nowrap;
transition: background .14s, border-color .14s, color .14s, transform .05s;
}
.btn:hover { border-color: var(--line-2); background: var(--panel-2); color: var(--text); }
.btn:active { transform: translateY(1px); }
.btn.primary { background: var(--primary); border-color: var(--primary); color: var(--panel); }
.btn.flat { background: transparent; border-color: transparent; }
.btn.icon { width: 29px; padding: 0; }
.btn.warn { color: var(--amber); border-color: color-mix(in oklch, var(--amber) 28%, var(--line)); background: var(--amber-soft); }
.btn.danger:hover { color: var(--red); border-color: color-mix(in oklch, var(--red) 28%, var(--line)); background: var(--red-soft); }
.chip {
display: inline-flex;
align-items: center;
gap: 5px;
min-height: 22px;
padding: 2px 7px;
border-radius: 5px;
background: var(--panel-2);
border: 1px solid var(--line);
color: var(--text-2);
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.chip.primary { color: var(--primary); background: var(--primary-soft); border-color: var(--primary-line); }
.chip.green { color: var(--green); background: var(--green-soft); border-color: color-mix(in oklch, var(--green) 24%, var(--line)); }
.chip.amber { color: var(--amber); background: var(--amber-soft); border-color: color-mix(in oklch, var(--amber) 26%, var(--line)); }
.chip.red { color: var(--red); background: var(--red-soft); border-color: color-mix(in oklch, var(--red) 24%, var(--line)); }
.dot { width: 6px; height: 6px; border-radius: 999px; background: currentColor; flex: none; }
.kbd {
font-family: var(--mono);
font-size: 10px;
padding: 2px 5px;
border: 1px solid var(--line);
border-radius: 4px;
background: var(--panel-2);
color: var(--text-3);
}
.layout { min-height: 0; display: grid; overflow: hidden; }
.layout.two { grid-template-columns: 308px minmax(0, 1fr); }
.layout.three { grid-template-columns: 288px minmax(0, 1fr) 344px; }
.layout.review { grid-template-columns: 360px minmax(0, 1fr); }
.layout.single { grid-template-columns: minmax(0, 1fr); }
.pane {
min-height: 0;
min-width: 0;
background: var(--panel);
border-right: 1px solid var(--line);
display: flex;
flex-direction: column;
overflow: hidden;
}
.pane:last-child { border-right: 0; }
.pane.subtle { background: var(--panel-2); }
.pane-head {
padding: 14px 14px 10px;
border-bottom: 1px solid var(--line);
background: var(--panel);
flex: none;
}
.eyebrow {
font-family: var(--mono);
color: var(--text-3);
font-size: 10px;
font-weight: 850;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.pane-head h2 { margin-top: 6px; font-size: 16px; font-weight: 780; letter-spacing: -0.02em; }
.pane-head p { margin-top: 3px; color: var(--text-3); font-size: 12px; }
.search {
position: relative;
padding: 10px 12px;
border-bottom: 1px solid var(--line);
background: var(--panel);
flex: none;
}
.search svg { position: absolute; left: 20px; top: 19px; color: var(--text-3); width: 13px; height: 13px; }
.search input {
width: 100%;
height: 31px;
padding: 0 34px 0 30px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: var(--panel-2);
outline: 0;
font-size: 12px;
}
.search input:focus { border-color: var(--primary); background: var(--panel); box-shadow: 0 0 0 3px var(--primary-soft); }
.search .kbd { position: absolute; right: 18px; top: 14px; }
.scroll { min-height: 0; overflow: auto; }
.list { padding: 8px; display: grid; gap: 3px; }
.section-label {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 8px 4px;
color: var(--text-3);
font-family: var(--mono);
font-size: 10px;
font-weight: 850;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.rule-row,
.scope-row,
.queue-row {
width: 100%;
border: 1px solid transparent;
background: transparent;
border-radius: var(--r-md);
padding: 10px;
text-align: left;
display: grid;
gap: 6px;
}
.rule-row:hover,
.scope-row:hover,
.queue-row:hover { background: var(--panel-2); border-color: var(--line); }
.rule-row.on,
.scope-row.on,
.queue-row.on { background: var(--primary-soft); border-color: var(--primary-line); }
.row-top { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.row-title { font-size: 13px; font-weight: 760; letter-spacing: -0.01em; }
.rule-row.on .row-title,
.scope-row.on .row-title,
.queue-row.on .row-title { color: var(--primary); }
.pattern {
font-family: var(--mono);
font-size: 11px;
color: var(--text-2);
display: flex;
flex-wrap: wrap;
gap: 2px;
align-items: center;
}
.pattern .seq { color: var(--primary); font-weight: 800; }
.pattern .date { color: var(--cyan); font-weight: 760; }
.row-meta { color: var(--text-3); font-size: 11px; display: flex; gap: 7px; flex-wrap: wrap; }
.row-meta b { color: var(--text-2); font-weight: 680; }
.content {
min-width: 0;
min-height: 0;
overflow: auto;
background: var(--bg);
}
.content-head {
position: sticky;
top: 0;
z-index: 5;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 14px 18px;
border-bottom: 1px solid var(--line);
background: color-mix(in oklch, var(--panel) 96%, transparent);
backdrop-filter: blur(10px);
}
.title-line { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.title-line h2 { font-size: 17px; font-weight: 800; letter-spacing: -0.02em; }
.meta-line { margin-top: 4px; color: var(--text-3); font-size: 11px; display: flex; flex-wrap: wrap; gap: 11px; }
.meta-line b { color: var(--text-2); }
.content-pad { padding: 16px 18px 72px; display: grid; gap: 14px; }
.grid-2 { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(300px, .8fr); gap: 14px; align-items: start; }
.grid-3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
.card {
border: 1px solid var(--line);
border-radius: var(--r-lg);
background: var(--panel);
overflow: hidden;
}
.card.pad { padding: 14px; }
.card-head {
padding: 12px 14px;
border-bottom: 1px solid var(--line);
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.card-head h3 { font-size: 13px; font-weight: 780; letter-spacing: -0.01em; }
.card-head p { margin-top: 2px; color: var(--text-3); font-size: 11px; }
.card-body { padding: 14px; }
.metric {
border: 1px solid var(--line);
background: var(--panel);
border-radius: var(--r-md);
padding: 11px;
}
.metric span { display: block; color: var(--text-3); font-family: var(--mono); font-size: 10px; font-weight: 850; letter-spacing: .08em; text-transform: uppercase; }
.metric b { display: block; margin-top: 5px; font-family: var(--mono); font-size: 20px; letter-spacing: -0.03em; }
.metric small { display: block; margin-top: 3px; color: var(--text-3); font-size: 11px; }
.preview {
border: 1px solid var(--line);
border-radius: var(--r-lg);
background: var(--panel);
box-shadow: var(--shadow);
overflow: hidden;
}
.preview-top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 11px 13px;
border-bottom: 1px solid var(--line);
}
.preview-code {
padding: 20px 18px 16px;
font-family: var(--mono);
font-size: clamp(28px, 4.4vw, 54px);
line-height: 1;
letter-spacing: -0.06em;
white-space: nowrap;
}
.preview-code .dim { color: var(--text-4); }
.preview-code .pfx { color: var(--text); }
.preview-code .date { color: var(--cyan); }
.preview-code .seq { color: var(--primary); }
.preview-foot {
display: grid;
grid-template-columns: repeat(4, 1fr);
border-top: 1px solid var(--line);
}
.preview-foot div { padding: 10px 12px; border-right: 1px solid var(--line); }
.preview-foot div:last-child { border-right: 0; }
.preview-foot span { display: block; color: var(--text-3); font-size: 10px; font-weight: 750; }
.preview-foot b { display: block; margin-top: 2px; font-family: var(--mono); font-size: 12px; }
.form-grid { display: grid; grid-template-columns: 128px minmax(0, 1fr); gap: 9px 12px; align-items: center; }
.field-label { color: var(--text-2); font-size: 12px; font-weight: 650; }
.input, .select, .textarea {
width: 100%;
min-height: 31px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: var(--panel-2);
padding: 0 9px;
outline: 0;
font-size: 12px;
}
.textarea { min-height: 62px; padding: 8px 9px; resize: vertical; }
.input:focus, .select:focus, .textarea:focus { border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-soft); background: var(--panel); }
.seg {
display: inline-flex;
padding: 2px;
border-radius: var(--r-sm);
background: var(--panel-2);
border: 1px solid var(--line);
}
.seg button {
border: 0;
background: transparent;
color: var(--text-3);
border-radius: 4px;
padding: 5px 9px;
font-size: 11px;
font-weight: 720;
}
.seg button.on { background: var(--panel); color: var(--text); box-shadow: 0 1px 2px color-mix(in oklch, var(--text) 8%, transparent); }
.switch {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text-2);
font-weight: 650;
}
.switch i {
width: 32px;
height: 18px;
border-radius: 999px;
background: var(--line-2);
position: relative;
display: inline-block;
}
.switch i::after {
content: "";
position: absolute;
width: 14px;
height: 14px;
top: 2px;
left: 2px;
border-radius: 999px;
background: var(--panel);
box-shadow: 0 1px 2px color-mix(in oklch, var(--text) 20%, transparent);
}
.switch.on i { background: var(--primary); }
.switch.on i::after { left: 16px; }
.parts { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.part {
min-width: 86px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--panel);
padding: 9px 10px;
display: grid;
gap: 3px;
text-align: left;
}
.part:hover { border-color: var(--line-2); background: var(--panel-2); }
.part.on { border-color: var(--primary-line); background: var(--primary-soft); }
.part span { color: var(--text-3); font-family: var(--mono); font-size: 10px; font-weight: 800; text-transform: uppercase; }
.part b { font-family: var(--mono); font-size: 14px; }
.joiner { color: var(--text-4); font-family: var(--mono); font-weight: 800; }
.add-part {
border: 1px dashed var(--line-2);
background: transparent;
color: var(--text-2);
border-radius: var(--r-md);
padding: 9px 11px;
font-weight: 700;
}
.add-part:hover { border-color: var(--primary); color: var(--primary); background: var(--primary-soft); }
.inspector {
min-width: 0;
min-height: 0;
background: var(--panel);
display: flex;
flex-direction: column;
overflow: hidden;
}
.inspector-head {
padding: 12px 14px;
border-bottom: 1px solid var(--line);
}
.cp-bar {
min-height: 42px;
display: grid;
grid-template-columns: 108px 1fr;
border-bottom: 1px solid var(--line);
}
.cp-slot {
padding: 9px 10px;
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
}
.cp-slot + .cp-slot { border-left: 1px solid var(--line); }
.cp-icon {
width: 23px;
height: 23px;
border-radius: 5px;
display: grid;
place-items: center;
background: var(--panel-2);
border: 1px solid var(--line);
font-family: var(--mono);
font-size: 10px;
font-weight: 850;
color: var(--text-2);
flex: none;
}
.cp-slot strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.group { border-bottom: 1px solid var(--line); }
.group-title {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: var(--panel);
}
.group-title b { font-size: 12px; }
.group-body { padding: 12px; display: grid; gap: 10px; }
.small-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: 12px; color: var(--text-2); }
.table { width: 100%; border-collapse: collapse; }
.table th, .table td {
border-bottom: 1px solid var(--line);
padding: 9px 10px;
text-align: left;
vertical-align: middle;
}
.table th {
color: var(--text-3);
font-size: 10px;
font-family: var(--mono);
font-weight: 850;
letter-spacing: .08em;
text-transform: uppercase;
background: var(--panel-2);
}
.table td { font-size: 12px; }
.table tr.on td { background: var(--primary-soft); }
.table tr:hover td { background: var(--panel-2); }
.table-actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--line); }
.filter-strip { display: flex; flex-wrap: wrap; gap: 5px; align-items: center; }
.split-detail { display: grid; grid-template-columns: minmax(0, 1fr) 330px; gap: 14px; align-items: start; }
.timeline { display: grid; gap: 8px; }
.event {
display: grid;
grid-template-columns: 72px minmax(0,1fr) auto;
gap: 10px;
align-items: center;
padding: 9px 0;
border-bottom: 1px solid var(--line);
}
.event:last-child { border-bottom: 0; }
.event time { color: var(--text-3); font-family: var(--mono); font-size: 11px; }
.event b { font-size: 12px; }
.event p { color: var(--text-3); font-size: 11px; margin-top: 2px; }
.savebar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
min-height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 8px 18px;
border-top: 1px solid var(--line);
background: color-mix(in oklch, var(--panel) 97%, transparent);
backdrop-filter: blur(10px);
z-index: 10;
}
.savebar .note { color: var(--text-3); font-size: 12px; }
.savebar .note b { color: var(--text); }
.accordion { display: grid; gap: 0; border: 1px solid var(--line); border-radius: var(--r-lg); overflow: hidden; background: var(--panel); }
.acc-row { border-bottom: 1px solid var(--line); background: var(--panel); }
.acc-row:last-child { border-bottom: 0; }
.acc-head {
width: 100%;
border: 0;
background: transparent;
display: grid;
grid-template-columns: 18px minmax(220px, 1fr) 120px 120px 120px 94px;
gap: 12px;
align-items: center;
padding: 12px 14px;
text-align: left;
}
.acc-row.open { background: var(--panel-2); }
.acc-row.open .chev { transform: rotate(90deg); color: var(--primary); }
.chev { transition: transform .16s, color .16s; color: var(--text-3); }
.acc-title { font-weight: 780; letter-spacing: -0.01em; }
.acc-sub { margin-top: 2px; color: var(--text-3); font-family: var(--mono); font-size: 11px; }
.acc-cell span { display: block; color: var(--text-3); font-size: 10px; font-weight: 750; }
.acc-cell b { display: block; margin-top: 2px; font-family: var(--mono); font-size: 12px; }
.acc-body { display: none; padding: 0 14px 14px 44px; }
.acc-row.open .acc-body { display: block; }
.tabbar { display: flex; align-items: center; gap: 2px; padding: 2px; border: 1px solid var(--line); background: var(--panel); border-radius: var(--r-sm); width: fit-content; }
.tabbar button { border: 0; background: transparent; border-radius: 4px; color: var(--text-3); padding: 5px 9px; font-size: 11px; font-weight: 720; }
.tabbar button.on { background: var(--primary-soft); color: var(--primary); }
.gallery {
min-height: 100vh;
overflow: auto;
padding: 32px;
background: var(--bg);
}
.gallery-inner { max-width: 1180px; margin: 0 auto; display: grid; gap: 18px; }
.gallery-hero {
border: 1px solid var(--line);
border-radius: 16px;
background: var(--panel);
padding: 24px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 18px;
align-items: end;
}
.gallery-hero h1 { margin-top: 10px; font-size: clamp(28px, 4vw, 46px); line-height: 1.06; letter-spacing: -0.04em; max-width: 720px; }
.gallery-hero p { margin-top: 12px; color: var(--text-2); max-width: 760px; font-size: 15px; }
.gallery-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.gallery-card {
border: 1px solid var(--line);
border-radius: 14px;
background: var(--panel);
padding: 16px;
display: grid;
gap: 14px;
}
.gallery-card h2 { margin-top: 10px; font-size: 18px; }
.gallery-card p { margin-top: 6px; color: var(--text-3); font-size: 12px; }
.mini {
height: 130px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--panel-2);
padding: 8px;
display: grid;
grid-template-columns: .38fr 1fr;
gap: 8px;
}
.mini.three { grid-template-columns: .32fr 1fr .42fr; }
.mini .side,
.mini .main {
border: 1px solid var(--line);
border-radius: 7px;
background: var(--panel);
padding: 8px;
}
.mini-line { height: 8px; border-radius: 999px; background: var(--line-2); margin-bottom: 7px; }
.mini-line.primary { background: var(--primary); }
@media (max-width: 1100px) {
body { overflow: auto; }
.app { height: auto; min-height: 100vh; }
.layout.two, .layout.three, .layout.review, .layout.single { grid-template-columns: 1fr; }
.pane, .inspector { border-right: 0; border-bottom: 1px solid var(--line); max-height: none; }
.content { min-height: 720px; }
.grid-2, .grid-3, .gallery-grid { grid-template-columns: 1fr; }
.split-detail { grid-template-columns: 1fr; }
.savebar { position: sticky; }
.preview-foot { grid-template-columns: repeat(2, 1fr); }
.acc-head { grid-template-columns: 18px minmax(0, 1fr) 92px; }
.acc-head .hide-sm { display: none; }
.gallery { padding: 18px; }
.gallery-hero { grid-template-columns: 1fr; }
}
@@ -0,0 +1,263 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>채번 Admin V3 A · 조용한 관리자형</title>
<link rel="stylesheet" href="./numbering-v3.css" />
</head>
<body>
<div class="app">
<header class="top" data-od-id="topbar">
<div class="top-left">
<div class="mark">#</div>
<div>
<h1>채번 규칙 관리</h1>
<div class="path"><span>Admin</span><span>/</span><b>시스템 관리</b><span>/</span><b>채번 규칙</b></div>
</div>
</div>
<div class="top-actions">
<span class="chip green"><span class="dot"></span>운영 16</span>
<span class="chip amber"><span class="dot"></span>검토 3</span>
<button class="btn">변경 이력</button>
<button class="btn primary">새 규칙</button>
</div>
</header>
<div class="layout two">
<aside class="pane" data-od-id="rule-list">
<div class="pane-head">
<div class="eyebrow">Rules</div>
<h2>규칙 목록</h2>
<p>테이블 연결 상태와 마지막 발번 기준으로 정렬됩니다.</p>
</div>
<div class="search">
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
<input id="ruleSearch" placeholder="수주번호, sales_order, SO 검색" />
<span class="kbd">/</span>
</div>
<div class="scroll">
<div class="list" id="ruleList">
<div class="section-label"><span>영업 관리</span><span>6</span></div>
<button class="rule-row on" data-title="수주번호 자동채번" data-code="SO-202605-0048" data-table="sales_order.order_no" data-cycle="월별" data-seq="47">
<div class="row-top">
<span class="row-title">수주번호 자동채번</span>
<span class="chip green">운영</span>
</div>
<div class="pattern"><span>SO</span><span>-</span><span class="date">yyyyMM</span><span>-</span><span class="seq">0000</span></div>
<div class="row-meta"><span>sales_order.order_no</span><span>현재 <b>47</b></span></div>
</button>
<button class="rule-row" data-title="견적번호" data-code="QT-2026-00129" data-table="quote.quote_no" data-cycle="연도별" data-seq="128">
<div class="row-top">
<span class="row-title">견적번호</span>
<span class="chip green">운영</span>
</div>
<div class="pattern"><span>QT</span><span>-</span><span class="date">yyyy</span><span>-</span><span class="seq">00000</span></div>
<div class="row-meta"><span>quote.quote_no</span><span>현재 <b>128</b></span></div>
</button>
<button class="rule-row" data-title="클레임 접수번호" data-code="CLM-C07-018" data-table="claim.claim_no" data-cycle="회사별" data-seq="17">
<div class="row-top">
<span class="row-title">클레임 접수번호</span>
<span class="chip amber">검토</span>
</div>
<div class="pattern"><span>CLM</span><span>-</span><span class="date">company</span><span>-</span><span class="seq">000</span></div>
<div class="row-meta"><span>claim.claim_no</span><span>현재 <b>17</b></span></div>
</button>
<div class="section-label"><span>생산 관리</span><span>4</span></div>
<button class="rule-row" data-title="작업지시번호" data-code="WO-20260520-0094" data-table="work_order.work_no" data-cycle="일별" data-seq="93">
<div class="row-top">
<span class="row-title">작업지시번호</span>
<span class="chip green">운영</span>
</div>
<div class="pattern"><span>WO</span><span>-</span><span class="date">yyyyMMdd</span><span>-</span><span class="seq">0000</span></div>
<div class="row-meta"><span>work_order.work_no</span><span>현재 <b>93</b></span></div>
</button>
<button class="rule-row" data-title="LOT 번호" data-code="LOT-ITEM-2026-3412" data-table="lot_master.lot_no" data-cycle="품목별" data-seq="3411">
<div class="row-top">
<span class="row-title">LOT 번호</span>
<span class="chip red">충돌</span>
</div>
<div class="pattern"><span>LOT</span><span>-</span><span class="date">item</span><span>-</span><span>yyyy</span><span>-</span><span class="seq">0000</span></div>
<div class="row-meta"><span>lot_master.lot_no</span><span>중복 후보 <b>2</b></span></div>
</button>
</div>
</div>
</aside>
<main class="content" data-od-id="rule-detail">
<div class="content-head">
<div>
<div class="title-line">
<h2 id="detailTitle">수주번호 자동채번</h2>
<span class="chip green">운영 중</span>
<span class="chip primary mono" id="detailTable">sales_order.order_no</span>
</div>
<div class="meta-line">
<span>리셋 기준 <b id="detailCycle">월별</b></span>
<span>현재 순번 <b id="detailSeq">47</b></span>
<span>수정자 <b>admin01</b></span>
<span>마지막 저장 <b>오늘 09:42</b></span>
</div>
</div>
<div class="top-actions">
<button class="btn">미리 발번</button>
<button class="btn primary">저장</button>
</div>
</div>
<div class="content-pad">
<section class="preview" data-od-id="next-preview">
<div class="preview-top">
<div>
<div class="eyebrow">Next number</div>
<div class="muted">저장 후 다음 요청에서 발급될 번호입니다.</div>
</div>
<button class="btn" id="copyBtn">복사</button>
</div>
<div class="preview-code" id="previewCode">
<span class="pfx">SO</span><span class="dim">-</span><span class="date">202605</span><span class="dim">-</span><span class="seq">0048</span>
</div>
<div class="preview-foot">
<div><span>현재 순번</span><b id="footSeq">47</b></div>
<div><span>다음 순번</span><b id="footNext">48</b></div>
<div><span>리셋 정책</span><b id="footCycle">월별</b></div>
<div><span>검증</span><b class="strong">통과</b></div>
</div>
</section>
<section class="grid-2" data-od-id="rule-settings">
<article class="card">
<div class="card-head">
<div>
<h3>기본 설정</h3>
<p>관리자가 가장 자주 확인하는 값만 위로 올렸습니다.</p>
</div>
<span class="chip primary">필수</span>
</div>
<div class="card-body">
<div class="form-grid">
<label class="field-label">규칙명</label>
<input class="input" value="수주번호 자동채번" />
<label class="field-label">사용 테이블</label>
<input class="input mono" value="sales_order" />
<label class="field-label">대상 컬럼</label>
<input class="input mono" value="order_no" />
<label class="field-label">발번 시점</label>
<select class="select"><option>저장 직전 자동 발번</option><option>임시 저장 시 예약</option></select>
<label class="field-label">중복 정책</label>
<select class="select"><option>DB 잠금 후 증가</option><option>중복 발견 시 재시도</option></select>
</div>
</div>
</article>
<article class="card">
<div class="card-head">
<div>
<h3>운영 상태</h3>
<p>실수 가능성이 있는 항목만 별도 표시합니다.</p>
</div>
<span class="chip green">정상</span>
</div>
<div class="card-body">
<div class="grid-3" style="grid-template-columns:1fr 1fr;">
<div class="metric"><span>Issued today</span><b>31</b><small>오늘 발급 수</small></div>
<div class="metric"><span>Retries</span><b>0</b><small>재시도 없음</small></div>
</div>
<div style="height:12px"></div>
<div class="timeline">
<div class="event">
<time>09:42</time>
<div><b>패턴 저장</b><p>SO-yyyyMM-0000 유지, 리셋 정책만 확인</p></div>
<span class="chip green">완료</span>
</div>
<div class="event">
<time>08:17</time>
<div><b>자동 발번</b><p>sales_order.order_no 31건 발급</p></div>
<span class="chip">기록</span>
</div>
</div>
</div>
</article>
</section>
<section class="card" data-od-id="parts">
<div class="card-head">
<div>
<h3>포맷 구성</h3>
<p>파트를 선택하면 아래 설정이 바로 바뀌는 단순한 구조입니다.</p>
</div>
<button class="btn">파트 추가</button>
</div>
<div class="card-body">
<div class="parts">
<button class="part on"><span>Prefix</span><b>SO</b></button>
<span class="joiner">-</span>
<button class="part"><span>Date</span><b>yyyyMM</b></button>
<span class="joiner">-</span>
<button class="part"><span>Sequence</span><b>0000</b></button>
<button class="add-part">검증 파트 추가</button>
</div>
</div>
</section>
</div>
</main>
</div>
</div>
<div class="savebar">
<div class="note"><b>저장 전 확인</b> 포맷, 대상 컬럼, 현재 순번이 다음 발번에 바로 반영됩니다.</div>
<div class="top-actions">
<button class="btn">취소</button>
<button class="btn primary">규칙 저장</button>
</div>
</div>
<script>
const rows = document.querySelectorAll(".rule-row");
const title = document.getElementById("detailTitle");
const table = document.getElementById("detailTable");
const cycle = document.getElementById("detailCycle");
const seq = document.getElementById("detailSeq");
const footSeq = document.getElementById("footSeq");
const footNext = document.getElementById("footNext");
const footCycle = document.getElementById("footCycle");
const preview = document.getElementById("previewCode");
function renderCode(code) {
const parts = code.split("-");
preview.innerHTML = parts.map((part, index) => {
const cls = index === 0 ? "pfx" : index === parts.length - 1 ? "seq" : "date";
return `<span class="${cls}">${part}</span>${index < parts.length - 1 ? '<span class="dim">-</span>' : ""}`;
}).join("");
}
rows.forEach((row) => {
row.addEventListener("click", () => {
rows.forEach((item) => item.classList.remove("on"));
row.classList.add("on");
title.textContent = row.dataset.title;
table.textContent = row.dataset.table;
cycle.textContent = row.dataset.cycle;
seq.textContent = row.dataset.seq;
footSeq.textContent = row.dataset.seq;
footNext.textContent = String(Number(row.dataset.seq) + 1);
footCycle.textContent = row.dataset.cycle;
renderCode(row.dataset.code);
});
});
document.getElementById("ruleSearch").addEventListener("input", (event) => {
const term = event.target.value.trim().toLowerCase();
rows.forEach((row) => {
row.style.display = row.textContent.toLowerCase().includes(term) ? "grid" : "none";
});
});
document.getElementById("copyBtn").addEventListener("click", (event) => {
event.currentTarget.textContent = "복사됨";
setTimeout(() => event.currentTarget.textContent = "복사", 900);
});
</script>
</body>
</html>
@@ -0,0 +1,260 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>채번 Admin V3 B · CP 편집기형</title>
<link rel="stylesheet" href="./numbering-v3.css" />
</head>
<body>
<div class="app">
<header class="top" data-od-id="topbar">
<div class="top-left">
<div class="mark">#</div>
<div>
<h1>채번 디자이너</h1>
<div class="path"><span>Admin</span><span>/</span><b>채번 규칙</b><span>/</span><b>수주번호</b></div>
</div>
</div>
<div class="top-actions">
<button class="btn">미리 발번</button>
<button class="btn">검증 실행</button>
<button class="btn primary">저장</button>
</div>
</header>
<div class="layout three">
<aside class="pane" data-od-id="left-rules">
<div class="pane-head">
<div class="eyebrow">Navigation</div>
<h2>도메인과 규칙</h2>
<p>업무 화면에서 쓰이는 채번만 묶어서 봅니다.</p>
</div>
<div class="search">
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
<input id="filterInput" placeholder="테이블, 컬럼, 접두어 검색" />
<span class="kbd">K</span>
</div>
<div class="scroll">
<div class="list" id="filterList">
<div class="section-label"><span>자주 편집</span><span>4</span></div>
<button class="scope-row on">
<div class="row-top"><span class="row-title">수주번호 자동채번</span><span class="chip green">운영</span></div>
<div class="pattern"><span>SO</span><span>-</span><span class="date">yyyyMM</span><span>-</span><span class="seq">0000</span></div>
<div class="row-meta"><span>sales_order.order_no</span></div>
</button>
<button class="scope-row">
<div class="row-top"><span class="row-title">출하 배차번호</span><span class="chip">초안</span></div>
<div class="pattern"><span>DLV</span><span>-</span><span class="date">yyyyMMdd</span><span>-</span><span class="seq">000</span></div>
<div class="row-meta"><span>delivery.trip_no</span></div>
</button>
<button class="scope-row">
<div class="row-top"><span class="row-title">작업지시번호</span><span class="chip green">운영</span></div>
<div class="pattern"><span>WO</span><span>-</span><span class="date">yyyyMMdd</span><span>-</span><span class="seq">0000</span></div>
<div class="row-meta"><span>work_order.work_no</span></div>
</button>
<div class="section-label"><span>검토 필요</span><span>3</span></div>
<button class="scope-row">
<div class="row-top"><span class="row-title">LOT 번호</span><span class="chip red">충돌</span></div>
<div class="pattern"><span>LOT</span><span>-</span><span class="date">item</span><span>-</span><span class="seq">0000</span></div>
<div class="row-meta"><span>lot_master.lot_no</span></div>
</button>
<button class="scope-row">
<div class="row-top"><span class="row-title">클레임 접수번호</span><span class="chip amber">확인</span></div>
<div class="pattern"><span>CLM</span><span>-</span><span class="date">company</span><span>-</span><span class="seq">000</span></div>
<div class="row-meta"><span>claim.claim_no</span></div>
</button>
</div>
</div>
</aside>
<main class="content" data-od-id="editor">
<div class="content-head">
<div>
<div class="title-line">
<h2>수주번호 자동채번</h2>
<span class="chip primary">편집 중</span>
<span class="chip green">검증 통과</span>
</div>
<div class="meta-line">
<span>패턴 <b class="mono">SO-yyyyMM-0000</b></span>
<span>대상 <b class="mono">sales_order.order_no</b></span>
<span>리셋 <b>월별</b></span>
</div>
</div>
<div class="top-actions">
<span class="kbd">Ctrl S</span>
<button class="btn primary">저장</button>
</div>
</div>
<div class="content-pad">
<section class="preview" data-od-id="preview">
<div class="preview-top">
<div>
<div class="eyebrow">Live composition</div>
<div class="muted">선택한 파트의 속성은 오른쪽 CP 패널에서 바로 조정합니다.</div>
</div>
<span class="chip green"><span class="dot"></span>중복 없음</span>
</div>
<div class="preview-code">
<span class="pfx">SO</span><span class="dim">-</span><span class="date">202605</span><span class="dim">-</span><span class="seq">0048</span>
</div>
</section>
<section class="card" data-od-id="composer">
<div class="card-head">
<div>
<h3>포맷 파트</h3>
<p>왼쪽에서 오른쪽으로 발번 문자열이 생성됩니다. 과한 시각화 대신 실제 조립 단위를 명확히 둡니다.</p>
</div>
<button class="btn">파트 추가</button>
</div>
<div class="card-body">
<div class="parts" id="parts">
<button class="part" data-kind="prefix" data-name="고정 접두어" data-value="SO"><span>Prefix</span><b>SO</b></button>
<span class="joiner">-</span>
<button class="part on" data-kind="date" data-name="발번 일자" data-value="yyyyMM"><span>Date</span><b>yyyyMM</b></button>
<span class="joiner">-</span>
<button class="part" data-kind="sequence" data-name="순번" data-value="0000"><span>Sequence</span><b>0000</b></button>
<button class="add-part">조건 파트 추가</button>
</div>
</div>
</section>
<section class="grid-2" data-od-id="editor-body">
<article class="card">
<div class="card-head">
<div>
<h3>연결 대상</h3>
<p>실제 저장될 테이블과 컬럼을 분리해서 보여줍니다.</p>
</div>
</div>
<div class="card-body">
<table class="table">
<thead><tr><th>항목</th><th></th><th>상태</th></tr></thead>
<tbody>
<tr><td>테이블</td><td class="mono">sales_order</td><td><span class="chip green">확인</span></td></tr>
<tr><td>컬럼</td><td class="mono">order_no</td><td><span class="chip green">유니크</span></td></tr>
<tr><td>발번 함수</td><td class="mono">beforeSave</td><td><span class="chip">기본</span></td></tr>
<tr><td>잠금 정책</td><td class="mono">transaction_lock</td><td><span class="chip green">권장</span></td></tr>
</tbody>
</table>
</div>
</article>
<article class="card">
<div class="card-head">
<div>
<h3>발번 테스트</h3>
<p>관리자가 저장 전 확인할 수 있는 최소 샘플입니다.</p>
</div>
<button class="btn">다시 실행</button>
</div>
<div class="card-body">
<div class="timeline">
<div class="event">
<time>현재</time>
<div><b class="mono">SO-202605-0047</b><p>마지막 발급 번호</p></div>
<span class="chip">저장됨</span>
</div>
<div class="event">
<time>다음</time>
<div><b class="mono">SO-202605-0048</b><p>미리보기 결과</p></div>
<span class="chip green">통과</span>
</div>
<div class="event">
<time>리셋</time>
<div><b>2026년 6월 1일</b><p>월별 기준으로 0001부터 시작</p></div>
<span class="chip">예정</span>
</div>
</div>
</div>
</article>
</section>
</div>
</main>
<aside class="inspector" data-od-id="cp-inspector">
<div class="inspector-head">
<div class="eyebrow">CP Inspector</div>
<h2 id="cpTitle" style="font-size:16px;margin-top:6px;">발번 일자</h2>
<p class="muted" id="cpDesc" style="margin-top:3px;">선택한 파트만 편집합니다.</p>
</div>
<div class="cp-bar">
<div class="cp-slot">
<span class="cp-icon">N</span>
<strong>채번</strong>
</div>
<div class="cp-slot">
<span class="cp-icon" id="cpKind">DT</span>
<strong id="cpValue">yyyyMM</strong>
</div>
</div>
<div class="scroll">
<div class="group">
<div class="group-title"><b>파트 속성</b><span class="chip primary">선택됨</span></div>
<div class="group-body">
<div class="form-grid" style="grid-template-columns:92px 1fr;">
<label class="field-label">표시 이름</label>
<input class="input" id="partLabel" value="발번 일자" />
<label class="field-label"></label>
<input class="input mono" id="partValue" value="yyyyMM" />
<label class="field-label">구분자</label>
<select class="select"><option>앞뒤 하이픈</option><option>뒤에만 하이픈</option><option>없음</option></select>
</div>
</div>
</div>
<div class="group">
<div class="group-title"><b>날짜 정책</b><span class="muted">월별 리셋</span></div>
<div class="group-body">
<div class="small-row"><span>서버 시간 기준</span><span class="switch on"><i></i></span></div>
<div class="small-row"><span>월 변경 시 자동 초기화</span><span class="switch on"><i></i></span></div>
<div class="small-row"><span>수동 리셋 허용</span><span class="switch"><i></i></span></div>
</div>
</div>
<div class="group">
<div class="group-title"><b>검증</b><span class="chip green">통과</span></div>
<div class="group-body">
<div class="small-row"><span>패턴 길이</span><b class="mono">13</b></div>
<div class="small-row"><span>빈 값 가능성</span><b>없음</b></div>
<div class="small-row"><span>중복 가능성</span><b>낮음</b></div>
</div>
</div>
</div>
</aside>
</div>
</div>
<script>
const partButtons = document.querySelectorAll(".part");
const cpTitle = document.getElementById("cpTitle");
const cpDesc = document.getElementById("cpDesc");
const cpKind = document.getElementById("cpKind");
const cpValue = document.getElementById("cpValue");
const partLabel = document.getElementById("partLabel");
const partValue = document.getElementById("partValue");
const labels = { prefix: "PX", date: "DT", sequence: "SQ" };
partButtons.forEach((button) => {
button.addEventListener("click", () => {
partButtons.forEach((item) => item.classList.remove("on"));
button.classList.add("on");
cpTitle.textContent = button.dataset.name;
cpDesc.textContent = `${button.dataset.kind} 파트 속성만 편집합니다.`;
cpKind.textContent = labels[button.dataset.kind] || "PT";
cpValue.textContent = button.dataset.value;
partLabel.value = button.dataset.name;
partValue.value = button.dataset.value;
});
});
document.getElementById("filterInput").addEventListener("input", (event) => {
const term = event.target.value.trim().toLowerCase();
document.querySelectorAll("#filterList .scope-row").forEach((row) => {
row.style.display = row.textContent.toLowerCase().includes(term) ? "grid" : "none";
});
});
</script>
</body>
</html>
@@ -0,0 +1,232 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>채번 Admin V3 C · 운영 검토형</title>
<link rel="stylesheet" href="./numbering-v3.css" />
</head>
<body>
<div class="app">
<header class="top" data-od-id="topbar">
<div class="top-left">
<div class="mark">#</div>
<div>
<h1>채번 운영 검토</h1>
<div class="path"><span>Admin</span><span>/</span><b>운영 상태</b><span>/</span><b>채번 점검</b></div>
</div>
</div>
<div class="top-actions">
<span class="chip red"><span class="dot"></span>충돌 1</span>
<span class="chip amber"><span class="dot"></span>검토 3</span>
<button class="btn">전체 점검</button>
<button class="btn primary">승인 반영</button>
</div>
</header>
<div class="layout review">
<aside class="pane subtle" data-od-id="review-queue">
<div class="pane-head">
<div class="eyebrow">Review queue</div>
<h2>검토 대상</h2>
<p>회사관리 accordion처럼 운영자가 먼저 위험도를 훑고 펼쳐봅니다.</p>
</div>
<div class="search">
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
<input id="queueSearch" placeholder="충돌, 미연결, 수동 리셋 검색" />
<span class="kbd">/</span>
</div>
<div class="scroll">
<div class="list" id="queueList">
<button class="queue-row on" data-focus="collision">
<div class="row-top"><span class="row-title">LOT 번호 중복 후보</span><span class="chip red">P0</span></div>
<div class="row-meta"><span>lot_master.lot_no</span><span>후보 <b>2건</b></span></div>
</button>
<button class="queue-row" data-focus="draft">
<div class="row-top"><span class="row-title">출하 배차번호 미연결</span><span class="chip amber">P1</span></div>
<div class="row-meta"><span>delivery.trip_no</span><span>저장 전</span></div>
</button>
<button class="queue-row" data-focus="reset">
<div class="row-top"><span class="row-title">월별 리셋 전 점검</span><span class="chip">예정</span></div>
<div class="row-meta"><span>6월 1일 00:00</span><span>대상 <b>7개</b></span></div>
</button>
<button class="queue-row" data-focus="scope">
<div class="row-top"><span class="row-title">회사별 범위 누락</span><span class="chip amber">P1</span></div>
<div class="row-meta"><span>claim.claim_no</span><span>회사 2곳</span></div>
</button>
</div>
</div>
</aside>
<main class="content" data-od-id="operations">
<div class="content-head">
<div>
<div class="title-line">
<h2 id="focusTitle">LOT 번호 중복 후보</h2>
<span class="chip red" id="focusStatus">조치 필요</span>
<span class="chip primary mono">lot_master.lot_no</span>
</div>
<div class="meta-line">
<span>영향 화면 <b>생산 / LOT 등록</b></span>
<span>검출 시간 <b>오늘 10:18</b></span>
<span>권장 조치 <b id="focusAction">순번 시작값 조정</b></span>
</div>
</div>
<div class="top-actions">
<button class="btn">담당자 지정</button>
<button class="btn primary">조치 승인</button>
</div>
</div>
<div class="content-pad">
<section class="grid-3" data-od-id="risk-summary">
<div class="metric"><span>Open issues</span><b>4</b><small>현재 검토 대상</small></div>
<div class="metric"><span>Collision</span><b style="color:var(--red)">1</b><small>중복 후보</small></div>
<div class="metric"><span>Unlinked</span><b style="color:var(--amber)">2</b><small>화면 연결 전</small></div>
</section>
<section class="accordion" data-od-id="accordion-review">
<article class="acc-row open">
<button class="acc-head">
<svg class="chev" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></svg>
<div>
<div class="acc-title">LOT 번호 중복 후보</div>
<div class="acc-sub">LOT-item-yyyy-0000 · lot_master.lot_no</div>
</div>
<div class="acc-cell hide-sm"><span>위험도</span><b style="color:var(--red)">P0</b></div>
<div class="acc-cell hide-sm"><span>후보</span><b>2건</b></div>
<div class="acc-cell hide-sm"><span>현재 순번</span><b>3411</b></div>
<span class="chip red">확인</span>
</button>
<div class="acc-body">
<div class="grid-2">
<article class="card">
<div class="card-head">
<div>
<h3>발견된 후보</h3>
<p>같은 품목 범위에서 이미 존재하는 번호와 충돌합니다.</p>
</div>
</div>
<div class="card-body">
<table class="table">
<thead><tr><th>번호</th><th>품목</th><th>위치</th></tr></thead>
<tbody>
<tr><td class="mono">LOT-ITM-2026-3412</td><td>AX-240</td><td>기존 LOT</td></tr>
<tr><td class="mono">LOT-ITM-2026-3413</td><td>AX-240</td><td>예약 번호</td></tr>
</tbody>
</table>
</div>
</article>
<article class="card">
<div class="card-head">
<div>
<h3>권장 조치</h3>
<p>현재 발번 규칙은 유지하고 시작값만 올리는 쪽이 안전합니다.</p>
</div>
</div>
<div class="card-body">
<div class="form-grid">
<label class="field-label">현재 시작값</label>
<input class="input mono" value="3412" />
<label class="field-label">권장 시작값</label>
<input class="input mono" value="3414" />
<label class="field-label">적용 범위</label>
<select class="select"><option>품목 AX-240 범위만</option><option>전체 LOT 규칙</option></select>
</div>
</div>
</article>
</div>
</div>
</article>
<article class="acc-row">
<button class="acc-head">
<svg class="chev" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></svg>
<div>
<div class="acc-title">출하 배차번호 미연결</div>
<div class="acc-sub">DLV-yyyyMMdd-000 · delivery.trip_no</div>
</div>
<div class="acc-cell hide-sm"><span>위험도</span><b style="color:var(--amber)">P1</b></div>
<div class="acc-cell hide-sm"><span>후보</span><b>1건</b></div>
<div class="acc-cell hide-sm"><span>현재 순번</span><b>0</b></div>
<span class="chip amber">미연결</span>
</button>
<div class="acc-body">
<div class="card pad">
<div class="form-grid">
<label class="field-label">연결 화면</label>
<input class="input" value="영업관리 / 출하 배차 등록" />
<label class="field-label">발번 시점</label>
<select class="select"><option>배차 저장 시 자동 발번</option></select>
<label class="field-label">검토 메모</label>
<textarea class="textarea">출하 배차 화면 저장 API와 연결한 뒤 운영 전환합니다.</textarea>
</div>
</div>
</div>
</article>
<article class="acc-row">
<button class="acc-head">
<svg class="chev" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></svg>
<div>
<div class="acc-title">월별 리셋 전 점검</div>
<div class="acc-sub">SO, PO, WO 등 월별 규칙 7개</div>
</div>
<div class="acc-cell hide-sm"><span>위험도</span><b>예정</b></div>
<div class="acc-cell hide-sm"><span>대상</span><b>7개</b></div>
<div class="acc-cell hide-sm"><span>예정일</span><b>06.01</b></div>
<span class="chip green">정상</span>
</button>
<div class="acc-body">
<div class="timeline">
<div class="event"><time>05.31</time><div><b>사전 알림</b><p>월별 리셋 규칙 담당자에게 알림 전송</p></div><span class="chip">예정</span></div>
<div class="event"><time>06.01</time><div><b>자동 초기화</b><p>리셋 기준이 월별인 규칙만 0001부터 시작</p></div><span class="chip green">정상</span></div>
</div>
</div>
</article>
</section>
</div>
</main>
</div>
</div>
<script>
const accRows = document.querySelectorAll(".acc-row");
accRows.forEach((row) => {
row.querySelector(".acc-head").addEventListener("click", () => {
accRows.forEach((item) => item.classList.remove("open"));
row.classList.add("open");
});
});
const queueRows = document.querySelectorAll(".queue-row");
const title = document.getElementById("focusTitle");
const status = document.getElementById("focusStatus");
const action = document.getElementById("focusAction");
const map = {
collision: ["LOT 번호 중복 후보", "조치 필요", "순번 시작값 조정", "red"],
draft: ["출하 배차번호 미연결", "연결 필요", "화면 저장 API 연결", "amber"],
reset: ["월별 리셋 전 점검", "정상", "담당자 사전 알림", "green"],
scope: ["회사별 범위 누락", "검토 필요", "회사 범위 조건 추가", "amber"]
};
queueRows.forEach((row) => {
row.addEventListener("click", () => {
queueRows.forEach((item) => item.classList.remove("on"));
row.classList.add("on");
const next = map[row.dataset.focus];
title.textContent = next[0];
status.textContent = next[1];
status.className = `chip ${next[3]}`;
action.textContent = next[2];
});
});
document.getElementById("queueSearch").addEventListener("input", (event) => {
const term = event.target.value.trim().toLowerCase();
queueRows.forEach((row) => {
row.style.display = row.textContent.toLowerCase().includes(term) ? "grid" : "none";
});
});
</script>
</body>
</html>
@@ -0,0 +1,209 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>채번 Admin V3 D · 표 중심형</title>
<link rel="stylesheet" href="./numbering-v3.css" />
</head>
<body>
<div class="app">
<header class="top" data-od-id="topbar">
<div class="top-left">
<div class="mark">#</div>
<div>
<h1>채번 규칙 테이블</h1>
<div class="path"><span>Admin</span><span>/</span><b>시스템 관리</b><span>/</span><b>채번 전체</b></div>
</div>
</div>
<div class="top-actions">
<button class="btn">엑셀 내보내기</button>
<button class="btn">선택 검증</button>
<button class="btn primary">새 규칙</button>
</div>
</header>
<div class="layout single">
<main class="content" data-od-id="table-first">
<div class="content-head">
<div>
<div class="title-line">
<h2>채번 규칙 전체 현황</h2>
<span class="chip green">운영 16</span>
<span class="chip amber">검토 3</span>
<span class="chip red">충돌 1</span>
</div>
<div class="meta-line">
<span>관리 기준 <b>테이블 / 컬럼 / 패턴</b></span>
<span>마지막 전체 검증 <b>오늘 10:18</b></span>
<span>정렬 <b>위험도 우선</b></span>
</div>
</div>
<div class="top-actions">
<span class="kbd">Shift F</span>
<button class="btn primary">저장</button>
</div>
</div>
<div class="content-pad">
<section class="grid-3" data-od-id="table-metrics">
<div class="metric"><span>Total rules</span><b>20</b><small>등록된 규칙</small></div>
<div class="metric"><span>Linked columns</span><b>17</b><small>실제 컬럼 연결</small></div>
<div class="metric"><span>Today issued</span><b>142</b><small>오늘 발급 수</small></div>
</section>
<section class="split-detail" data-od-id="table-and-detail">
<article class="card">
<div class="table-actions">
<div class="filter-strip">
<button class="btn primary">전체</button>
<button class="btn">운영</button>
<button class="btn warn">검토</button>
<button class="btn danger">충돌</button>
</div>
<div class="search" style="padding:0;border-bottom:0;width:min(360px,42vw);background:transparent;">
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
<input id="tableSearch" placeholder="규칙명, 테이블, 패턴 검색" />
</div>
</div>
<div class="scroll" style="max-height:560px;">
<table class="table" id="rulesTable">
<thead>
<tr>
<th>상태</th>
<th>규칙명</th>
<th>패턴</th>
<th>대상 컬럼</th>
<th>현재</th>
<th>리셋</th>
<th>검증</th>
</tr>
</thead>
<tbody>
<tr class="on" data-title="LOT 번호" data-code="LOT-ITM-2026-3414" data-status="충돌 조치 필요">
<td><span class="chip red">충돌</span></td>
<td><b>LOT 번호</b></td>
<td class="mono">LOT-item-yyyy-0000</td>
<td class="mono">lot_master.lot_no</td>
<td class="mono">3411</td>
<td>품목별</td>
<td><span class="chip red">P0</span></td>
</tr>
<tr data-title="수주번호 자동채번" data-code="SO-202605-0048" data-status="정상 운영">
<td><span class="chip green">운영</span></td>
<td><b>수주번호 자동채번</b></td>
<td class="mono">SO-yyyyMM-0000</td>
<td class="mono">sales_order.order_no</td>
<td class="mono">47</td>
<td>월별</td>
<td><span class="chip green">통과</span></td>
</tr>
<tr data-title="견적번호" data-code="QT-2026-00129" data-status="정상 운영">
<td><span class="chip green">운영</span></td>
<td><b>견적번호</b></td>
<td class="mono">QT-yyyy-00000</td>
<td class="mono">quote.quote_no</td>
<td class="mono">128</td>
<td>연도별</td>
<td><span class="chip green">통과</span></td>
</tr>
<tr data-title="출하 배차번호" data-code="DLV-20260520-001" data-status="연결 필요">
<td><span class="chip amber">검토</span></td>
<td><b>출하 배차번호</b></td>
<td class="mono">DLV-yyyyMMdd-000</td>
<td class="mono">delivery.trip_no</td>
<td class="mono">0</td>
<td>일별</td>
<td><span class="chip amber">미연결</span></td>
</tr>
<tr data-title="클레임 접수번호" data-code="CLM-C07-018" data-status="회사 범위 검토">
<td><span class="chip amber">검토</span></td>
<td><b>클레임 접수번호</b></td>
<td class="mono">CLM-company-000</td>
<td class="mono">claim.claim_no</td>
<td class="mono">17</td>
<td>회사별</td>
<td><span class="chip amber">확인</span></td>
</tr>
<tr data-title="작업지시번호" data-code="WO-20260520-0094" data-status="정상 운영">
<td><span class="chip green">운영</span></td>
<td><b>작업지시번호</b></td>
<td class="mono">WO-yyyyMMdd-0000</td>
<td class="mono">work_order.work_no</td>
<td class="mono">93</td>
<td>일별</td>
<td><span class="chip green">통과</span></td>
</tr>
</tbody>
</table>
</div>
</article>
<aside class="card" data-od-id="row-detail">
<div class="card-head">
<div>
<h3 id="rowTitle">LOT 번호</h3>
<p id="rowStatus">충돌 조치 필요</p>
</div>
<span class="chip red">P0</span>
</div>
<div class="card-body">
<div class="preview" style="box-shadow:none;">
<div class="preview-top">
<div class="eyebrow">Next after fix</div>
<button class="btn">복사</button>
</div>
<div class="preview-code" id="rowCode" style="font-size:32px;">
<span class="pfx">LOT</span><span class="dim">-</span><span class="date">ITM</span><span class="dim">-</span><span class="date">2026</span><span class="dim">-</span><span class="seq">3414</span>
</div>
</div>
<div style="height:12px"></div>
<div class="form-grid" style="grid-template-columns:96px 1fr;">
<label class="field-label">조치 방식</label>
<select class="select"><option>시작값 조정</option><option>패턴 변경</option></select>
<label class="field-label">승인 메모</label>
<textarea class="textarea">기존 예약 번호 이후부터 발급되도록 시작값을 조정합니다.</textarea>
<label class="field-label">적용 범위</label>
<select class="select"><option>선택 규칙만</option><option>같은 도메인 전체</option></select>
</div>
</div>
</aside>
</section>
</div>
</main>
</div>
</div>
<script>
const tableRows = document.querySelectorAll("#rulesTable tbody tr");
const rowTitle = document.getElementById("rowTitle");
const rowStatus = document.getElementById("rowStatus");
const rowCode = document.getElementById("rowCode");
function renderCode(code) {
const parts = code.split("-");
rowCode.innerHTML = parts.map((part, index) => {
const cls = index === 0 ? "pfx" : index === parts.length - 1 ? "seq" : "date";
return `<span class="${cls}">${part}</span>${index < parts.length - 1 ? '<span class="dim">-</span>' : ""}`;
}).join("");
}
tableRows.forEach((row) => {
row.addEventListener("click", () => {
tableRows.forEach((item) => item.classList.remove("on"));
row.classList.add("on");
rowTitle.textContent = row.dataset.title;
rowStatus.textContent = row.dataset.status;
renderCode(row.dataset.code);
});
});
document.getElementById("tableSearch").addEventListener("input", (event) => {
const term = event.target.value.trim().toLowerCase();
tableRows.forEach((row) => {
row.style.display = row.textContent.toLowerCase().includes(term) ? "table-row" : "none";
});
});
</script>
</body>
</html>
@@ -0,0 +1,155 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>채번 Admin 시안 갤러리</title>
<style>
:root {
--bg: oklch(98% 0.004 250);
--surface: oklch(100% 0 0);
--surface-2: oklch(96% 0.006 250);
--fg: oklch(20% 0.018 250);
--muted: oklch(52% 0.014 250);
--border: oklch(90% 0.008 250);
--accent: oklch(56% 0.14 255);
--accent-2: oklch(58% 0.15 160);
--warn: oklch(68% 0.14 70);
--on-accent: oklch(100% 0 0);
--font-display: -apple-system, BlinkMacSystemFont, "Pretendard", "Segoe UI", system-ui, sans-serif;
--font-body: -apple-system, BlinkMacSystemFont, "Pretendard", "Segoe UI", system-ui, sans-serif;
--font-mono: "JetBrains Mono", "D2Coding", ui-monospace, Menlo, monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--fg);
font-family: var(--font-body);
line-height: 1.5;
}
a { color: inherit; text-decoration: none; }
.shell { max-width: 1160px; margin: 0 auto; padding: 56px 28px; }
.eyebrow {
font-family: var(--font-mono);
font-size: 12px;
color: var(--accent);
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 14px;
}
h1 {
margin: 0;
font-family: var(--font-display);
font-size: clamp(34px, 5vw, 58px);
line-height: 1.05;
letter-spacing: -0.02em;
max-width: 820px;
}
.lead { margin: 18px 0 0; color: var(--muted); font-size: 18px; max-width: 720px; }
.grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; margin-top: 44px; }
.card {
min-height: 320px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 22px;
display: flex;
flex-direction: column;
justify-content: space-between;
transition: transform .16s ease, border-color .16s ease;
}
.card:hover { transform: translateY(-2px); border-color: color-mix(in oklch, var(--accent) 44%, var(--border)); }
.mark {
width: 42px;
height: 42px;
border-radius: 10px;
display: grid;
place-items: center;
background: color-mix(in oklch, var(--accent) 12%, transparent);
color: var(--accent);
font-family: var(--font-mono);
font-weight: 800;
}
.card:nth-child(2) .mark { background: color-mix(in oklch, var(--accent-2) 12%, transparent); color: var(--accent-2); }
.card:nth-child(3) .mark { background: color-mix(in oklch, var(--warn) 16%, transparent); color: var(--warn); }
h2 { margin: 24px 0 8px; font-size: 23px; letter-spacing: -0.01em; }
p { margin: 0; color: var(--muted); }
.meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 20px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted);
}
.meta span { border: 1px solid var(--border); border-radius: 999px; padding: 4px 8px; background: var(--surface-2); }
.open {
margin-top: 24px;
display: inline-flex;
justify-content: center;
align-items: center;
height: 38px;
border-radius: 9px;
background: var(--fg);
color: var(--surface);
font-weight: 700;
font-size: 14px;
}
.note {
margin-top: 28px;
border-top: 1px solid var(--border);
padding-top: 18px;
color: var(--muted);
font-size: 14px;
}
@media (max-width: 880px) {
.grid { grid-template-columns: 1fr; }
.shell { padding: 36px 18px; }
}
</style>
</head>
<body>
<main class="shell">
<div class="eyebrow">Numbering Rule Admin - HTML drafts</div>
<h1>실제 운영 파일에 넣기 전, 채번 admin을 세 방향으로 비교합니다.</h1>
<p class="lead">각 시안은 독립 HTML입니다. A는 조립감, B는 관리 밀도, C는 충돌 검증을 우선순위로 둔 방향입니다.</p>
<section class="grid" aria-label="채번 admin 시안 목록">
<a class="card" href="variant-a-workbench.html">
<div>
<div class="mark">A</div>
<h2>조립 작업대형</h2>
<p>다음 발번 프리뷰와 파트 파이프라인을 화면의 중심에 둔 방향. 규칙을 직접 만드는 느낌이 가장 강합니다.</p>
<div class="meta"><span>preview first</span><span>pipeline</span><span>inspector</span></div>
</div>
<span class="open">시안 A 열기</span>
</a>
<a class="card" href="variant-b-console.html">
<div>
<div class="mark">B</div>
<h2>운영 콘솔형</h2>
<p>규칙 목록, 사용처, 현재 시퀀스, 리셋 정책을 한 화면에서 훑는 방향. 실무 admin 안정감이 큽니다.</p>
<div class="meta"><span>dense table</span><span>right inspector</span><span>governance</span></div>
</div>
<span class="open">시안 B 열기</span>
</a>
<a class="card" href="variant-c-validation.html">
<div>
<div class="mark">C</div>
<h2>검증 보드형</h2>
<p>채번 변경이 운영 데이터와 충돌하지 않는지 먼저 보는 방향. 승인/검수 플로우가 필요한 팀에 맞습니다.</p>
<div class="meta"><span>validation</span><span>impact</span><span>approval</span></div>
</div>
<span class="open">시안 C 열기</span>
</a>
</section>
<p class="note">운영 React/TSX 파일은 이 갤러리에서 건드리지 않습니다. 실제 적용은 선택한 방향을 기준으로 별도 반영하면 됩니다.</p>
</main>
</body>
</html>
@@ -0,0 +1,229 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>채번 Admin 시안 A - 조립 작업대형</title>
<style>
:root {
--bg: oklch(98% 0.004 250);
--surface: oklch(100% 0 0);
--surface-2: oklch(96% 0.007 250);
--surface-3: oklch(93% 0.012 250);
--fg: oklch(19% 0.018 250);
--muted: oklch(52% 0.014 250);
--border: oklch(89% 0.008 250);
--accent: oklch(58% 0.17 255);
--success: oklch(58% 0.15 155);
--warn: oklch(68% 0.14 72);
--danger: oklch(58% 0.18 25);
--cyan: oklch(66% 0.14 215);
--on-accent: oklch(100% 0 0);
--font-display: -apple-system, BlinkMacSystemFont, "Pretendard", "Segoe UI", system-ui, sans-serif;
--font-body: -apple-system, BlinkMacSystemFont, "Pretendard", "Segoe UI", system-ui, sans-serif;
--font-mono: "JetBrains Mono", "D2Coding", ui-monospace, Menlo, monospace;
}
* { box-sizing: border-box; }
body { margin: 0; height: 100vh; overflow: hidden; background: var(--bg); color: var(--fg); font-family: var(--font-body); font-size: 13px; }
button, input, select { font: inherit; color: inherit; }
button { cursor: pointer; }
.app { height: 100vh; display: grid; grid-template-rows: 58px 1fr; }
.top {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 18px;
align-items: center;
padding: 0 22px;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
h1, h2, h3 { font-family: var(--font-display); margin: 0; letter-spacing: -0.01em; }
h1 { display: flex; align-items: center; gap: 10px; font-size: 18px; }
.brand-mark { width: 28px; height: 28px; border-radius: 8px; display: grid; place-items: center; background: var(--accent); color: var(--on-accent); font-family: var(--font-mono); font-weight: 900; }
.sub { color: var(--muted); font-size: 12px; margin-left: 8px; font-weight: 500; }
.stats { display: flex; gap: 10px; font-family: var(--font-mono); color: var(--muted); }
.stats span { padding: 5px 8px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface-2); }
.stats b { color: var(--fg); }
.actions { display: flex; gap: 8px; align-items: center; }
.kbd, .chip { font-family: var(--font-mono); font-size: 11px; border: 1px solid var(--border); border-radius: 6px; padding: 3px 7px; color: var(--muted); background: var(--surface-2); }
.btn { height: 32px; border: 1px solid var(--border); background: var(--surface); border-radius: 8px; padding: 0 12px; font-weight: 700; font-size: 12px; }
.btn:hover { border-color: color-mix(in oklch, var(--accent) 45%, var(--border)); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.body { min-height: 0; display: grid; grid-template-columns: 304px 1fr; }
.side { min-height: 0; display: grid; grid-template-rows: auto auto 1fr auto; background: var(--surface); border-right: 1px solid var(--border); }
.search { padding: 12px; border-bottom: 1px solid var(--border); }
.search input { width: 100%; height: 34px; border: 1px solid var(--border); border-radius: 9px; background: var(--surface-2); padding: 0 12px; outline: none; }
.filters { display: flex; gap: 6px; padding: 10px 12px; border-bottom: 1px solid var(--border); }
.filter { border: 0; background: transparent; border-radius: 7px; padding: 6px 9px; color: var(--muted); font-weight: 700; font-size: 12px; }
.filter.on { background: color-mix(in oklch, var(--accent) 12%, transparent); color: var(--accent); }
.list { overflow: auto; }
.section-label { padding: 13px 14px 6px; color: var(--muted); font-family: var(--font-mono); font-size: 10px; font-weight: 900; letter-spacing: .08em; text-transform: uppercase; display: flex; justify-content: space-between; }
.rule { width: 100%; border: 0; border-bottom: 1px solid var(--border); background: transparent; text-align: left; padding: 11px 14px; display: grid; grid-template-columns: 1fr auto; gap: 10px; }
.rule:hover { background: var(--surface-2); }
.rule.on { background: color-mix(in oklch, var(--accent) 10%, transparent); box-shadow: inset 3px 0 0 var(--accent); }
.rule-name { font-weight: 800; margin-bottom: 3px; }
.pattern { font-family: var(--font-mono); font-size: 11px; color: var(--muted); }
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--success); margin-top: 5px; }
.dot.off { background: var(--muted); }
.side-foot { padding: 12px; border-top: 1px solid var(--border); display: grid; gap: 8px; }
.main { min-width: 0; overflow: auto; background: var(--bg); }
.detail-head { position: sticky; top: 0; z-index: 5; display: grid; grid-template-columns: 1fr auto; gap: 18px; padding: 18px 24px; background: color-mix(in oklch, var(--bg) 92%, transparent); backdrop-filter: blur(12px); border-bottom: 1px solid var(--border); }
.title-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.title-row h2 { font-size: 22px; }
.pill { display: inline-flex; align-items: center; gap: 5px; border: 1px solid var(--border); border-radius: 999px; padding: 4px 8px; font-family: var(--font-mono); font-size: 10px; font-weight: 800; color: var(--muted); background: var(--surface); }
.pill.good { color: var(--success); background: color-mix(in oklch, var(--success) 10%, var(--surface)); }
.meta { margin-top: 7px; color: var(--muted); display: flex; gap: 16px; flex-wrap: wrap; font-size: 12px; }
.meta b { color: var(--fg); }
.preview-wrap { padding: 26px 24px; background-image: radial-gradient(color-mix(in oklch, var(--accent) 16%, transparent) 1px, transparent 1px); background-size: 16px 16px; border-bottom: 1px solid var(--border); }
.ticket { position: relative; max-width: 1120px; min-height: 214px; margin: 0 auto; background: var(--surface); border: 1px solid var(--border); border-radius: 16px; padding: 30px; box-shadow: 0 24px 70px -48px var(--accent); overflow: hidden; }
.ticket::before, .ticket::after { content: ""; position: absolute; top: 86px; width: 30px; height: 30px; border-radius: 50%; background: var(--bg); border: 1px solid var(--border); }
.ticket::before { left: -15px; } .ticket::after { right: -15px; }
.ticket-top { display: flex; align-items: center; justify-content: space-between; gap: 18px; color: var(--muted); font-family: var(--font-mono); font-size: 11px; }
.code { margin: 28px 0 18px; display: flex; flex-wrap: wrap; align-items: center; gap: 7px; font-family: var(--font-mono); font-size: clamp(30px, 5vw, 58px); font-weight: 900; letter-spacing: -0.04em; }
.part { border: 1px solid transparent; border-radius: 10px; padding: 2px 7px; background: var(--surface-2); color: var(--fg); }
.part.date { color: var(--cyan); } .part.seq { color: var(--accent); } .part.sel { outline: 2px solid color-mix(in oklch, var(--accent) 34%, transparent); background: color-mix(in oklch, var(--accent) 9%, var(--surface)); }
.dash { color: var(--muted); }
.ticket-foot { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-top: 22px; border-top: 1px dashed var(--border); padding-top: 16px; }
.ticket-foot span { color: var(--muted); font-size: 11px; }
.ticket-foot b { display: block; color: var(--fg); font-family: var(--font-mono); font-size: 12px; margin-top: 2px; }
.workspace { padding: 20px 24px 82px; max-width: 1180px; margin: 0 auto; display: grid; gap: 16px; }
.panel { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; }
.panel-hd { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 15px 16px; border-bottom: 1px solid var(--border); }
.panel-hd h3 { font-size: 15px; }
.panel-hd .desc { color: var(--muted); font-size: 12px; margin-left: 8px; font-weight: 500; }
.pipe { padding: 18px; display: flex; flex-wrap: wrap; gap: 9px; align-items: center; }
.block { min-width: 110px; border: 1px solid var(--border); border-radius: 12px; padding: 12px; background: var(--surface-2); }
.block.sel { border-color: var(--accent); background: color-mix(in oklch, var(--accent) 9%, var(--surface)); }
.block .t { font-family: var(--font-mono); color: var(--muted); font-size: 10px; text-transform: uppercase; font-weight: 900; }
.block .v { margin-top: 6px; font-family: var(--font-mono); font-weight: 900; font-size: 16px; }
.join { color: var(--muted); font-family: var(--font-mono); font-weight: 900; }
.drop { border: 1px dashed color-mix(in oklch, var(--accent) 45%, var(--border)); background: transparent; color: var(--accent); border-radius: 12px; padding: 12px 16px; font-weight: 800; }
.grid { display: grid; grid-template-columns: 1.25fr .75fr; gap: 16px; }
.inspector { padding: 16px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
label { display: grid; gap: 6px; color: var(--muted); font-size: 11px; font-weight: 800; }
input, select { height: 34px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-2); padding: 0 10px; outline: none; }
input:focus, select:focus { border-color: var(--accent); background: var(--surface); }
.seq { padding: 16px; display: grid; gap: 13px; }
.seq-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.seq-card { border: 1px solid var(--border); border-radius: 12px; background: var(--surface-2); padding: 12px; }
.seq-card span { color: var(--muted); font-family: var(--font-mono); font-size: 10px; font-weight: 900; }
.seq-card b { display: block; margin-top: 6px; font-family: var(--font-mono); font-size: 24px; }
.savebar { position: sticky; bottom: 0; display: flex; justify-content: space-between; align-items: center; padding: 12px 24px; background: var(--surface); border-top: 1px solid var(--border); box-shadow: 0 -14px 30px -28px var(--fg); }
.unsaved { color: var(--warn); font-weight: 800; }
@media (max-width: 900px) { body { overflow: auto; height: auto; } .app { height: auto; } .body { grid-template-columns: 1fr; } .side { display: none; } .grid, .ticket-foot, .inspector { grid-template-columns: 1fr; } .top { grid-template-columns: 1fr; height: auto; padding: 14px; } }
</style>
</head>
<body>
<div class="app">
<header class="top">
<h1><span class="brand-mark">#</span>채번 관리 <span class="sub">규칙을 조립하고 다음 코드를 확인</span></h1>
<div class="stats"><span><b>18</b> 규칙</span><span><b>14</b> 연결</span><span><b>4</b> 미사용</span></div>
<div class="actions"><span class="kbd">Ctrl K</span><button class="btn">새로고침</button><button class="btn primary">새 채번</button></div>
</header>
<div class="body">
<aside class="side">
<div class="search"><input id="ruleSearch" placeholder="채번 이름, 코드, 컬럼명 검색" /></div>
<div class="filters"><button class="filter on">전체</button><button class="filter">연결</button><button class="filter">미사용</button></div>
<div class="list" id="ruleList">
<div class="section-label"><span>LIVE</span><span>최근 변경</span></div>
<button class="rule on" data-name="수주번호 자동채번"><span><span class="rule-name">수주번호 자동채번</span><span class="pattern">SO-{yyyyMM}-{0000}</span></span><span class="dot"></span></button>
<button class="rule" data-name="구매발주 번호"><span><span class="rule-name">구매발주 번호</span><span class="pattern">PO-{yyyy}-{00000}</span></span><span class="dot"></span></button>
<button class="rule" data-name="입고검사 LOT"><span><span class="rule-name">입고검사 LOT</span><span class="pattern">IQC-{date}-{seq}</span></span><span class="dot"></span></button>
<div class="section-label"><span>UNUSED</span><span>연결 필요</span></div>
<button class="rule" data-name="품목마스터 코드"><span><span class="rule-name">품목마스터 코드</span><span class="pattern">ITEM-{category}-{000}</span></span><span class="dot off"></span></button>
<button class="rule" data-name="작업지시 번호"><span><span class="rule-name">작업지시 번호</span><span class="pattern">WO-{yyyyMMdd}-{000}</span></span><span class="dot off"></span></button>
</div>
<div class="side-foot"><button class="btn primary">+ 새 채번 만들기</button></div>
</aside>
<main class="main">
<section class="detail-head">
<div>
<div class="title-row"><h2>수주번호 자동채번</h2><span class="pill good">사용 중</span><span class="pill">sales_order.order_no</span></div>
<div class="meta"><span>테이블 <b>sales_order</b></span><span>컬럼 <b>order_no</b></span><span>리셋 <b>월별</b></span><span>구분자 <b>-</b></span></div>
</div>
<div class="actions"><button class="btn">되돌리기</button><button class="btn primary">저장</button></div>
</section>
<section class="preview-wrap">
<article class="ticket">
<div class="ticket-top"><span>NEXT NUMBER PREVIEW</span><button class="btn" id="copyBtn">복사</button></div>
<div class="code" id="codePreview"><span class="part">SO</span><span class="dash">-</span><span class="part date">202605</span><span class="dash">-</span><span class="part seq sel">0048</span></div>
<div class="ticket-foot">
<span>현재 순번<b>47</b></span>
<span>다음 순번<b>48</b></span>
<span>초기화 기준<b>매월 1일</b></span>
<span>마지막 발번<b>2026-05-20 09:42</b></span>
</div>
</article>
</section>
<section class="workspace">
<article class="panel">
<div class="panel-hd"><h3>파트 조립 <span class="desc">클릭하면 아래 속성이 바뀝니다</span></h3><button class="btn">순서 정리</button></div>
<div class="pipe" id="pipe">
<button class="block"><span class="t">text</span><span class="v">SO</span></button><span class="join">-</span>
<button class="block"><span class="t">date</span><span class="v">yyyyMM</span></button><span class="join">-</span>
<button class="block sel"><span class="t">sequence</span><span class="v">0000</span></button>
<button class="drop">+ 파트 추가</button>
</div>
</article>
<div class="grid">
<article class="panel">
<div class="panel-hd"><h3>선택 파트 속성</h3><span class="chip">sequence</span></div>
<div class="inspector">
<label>표시명<input value="순번" /></label>
<label>자리수<select><option>4자리</option><option>5자리</option></select></label>
<label>초기값<input value="1" /></label>
<label>리셋 주기<select><option>월별</option><option>일별</option><option>없음</option></select></label>
<label>패딩 문자<input value="0" /></label>
<label>샘플 출력<input value="0048" /></label>
</div>
</article>
<article class="panel">
<div class="panel-hd"><h3>시퀀스 운영</h3><button class="btn">재시작</button></div>
<div class="seq">
<div class="seq-row"><div class="seq-card"><span>CURRENT</span><b>47</b></div><div class="seq-card"><span>NEXT</span><b>48</b></div></div>
<label>다음 발번 수동 조정<input value="48" /></label>
<button class="btn primary">시퀀스 적용</button>
</div>
</article>
</div>
</section>
<div class="savebar"><span class="unsaved">저장하지 않은 변경 있음</span><div class="actions"><button class="btn">변경 취소</button><button class="btn primary">규칙 저장</button></div></div>
</main>
</div>
</div>
<script>
const search = document.querySelector("#ruleSearch");
const rules = [...document.querySelectorAll(".rule")];
search.addEventListener("input", () => {
const term = search.value.trim().toLowerCase();
rules.forEach((rule) => {
rule.style.display = rule.dataset.name.toLowerCase().includes(term) ? "grid" : "none";
});
});
rules.forEach((rule) => {
rule.addEventListener("click", () => {
rules.forEach((item) => item.classList.remove("on"));
rule.classList.add("on");
});
});
document.querySelectorAll(".block").forEach((block) => {
block.addEventListener("click", () => {
document.querySelectorAll(".block, .part").forEach((item) => item.classList.remove("sel"));
block.classList.add("sel");
});
});
document.querySelector("#copyBtn").addEventListener("click", async () => {
const code = "SO-202605-0048";
try { await navigator.clipboard.writeText(code); } catch (error) {}
document.querySelector("#copyBtn").textContent = "복사됨";
setTimeout(() => { document.querySelector("#copyBtn").textContent = "복사"; }, 1200);
});
</script>
</body>
</html>
@@ -0,0 +1,169 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>채번 Admin 시안 B - 운영 콘솔형</title>
<style>
:root {
--bg: oklch(97% 0.004 235);
--surface: oklch(100% 0 0);
--surface-2: oklch(95% 0.006 235);
--fg: oklch(18% 0.014 235);
--muted: oklch(50% 0.012 235);
--border: oklch(88% 0.008 235);
--accent: oklch(55% 0.13 158);
--info: oklch(58% 0.14 225);
--warn: oklch(67% 0.14 76);
--danger: oklch(58% 0.18 28);
--on-accent: oklch(100% 0 0);
--font-display: -apple-system, BlinkMacSystemFont, "Pretendard", "Segoe UI", system-ui, sans-serif;
--font-body: -apple-system, BlinkMacSystemFont, "Pretendard", "Segoe UI", system-ui, sans-serif;
--font-mono: "JetBrains Mono", "D2Coding", ui-monospace, Menlo, monospace;
}
* { box-sizing: border-box; }
body { margin: 0; height: 100vh; overflow: hidden; background: var(--bg); color: var(--fg); font-family: var(--font-body); font-size: 13px; }
button, input, select { font: inherit; color: inherit; }
button { cursor: pointer; }
h1, h2, h3 { margin: 0; font-family: var(--font-display); letter-spacing: -0.01em; }
.app { height: 100vh; display: grid; grid-template-rows: 64px 84px 1fr; }
.top { display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 0 22px; background: var(--surface); border-bottom: 1px solid var(--border); }
.title { display: flex; align-items: center; gap: 12px; }
.title h1 { font-size: 19px; }
.title p { margin: 2px 0 0; color: var(--muted); font-size: 12px; }
.mark { width: 32px; height: 32px; border-radius: 10px; display: grid; place-items: center; background: var(--fg); color: var(--surface); font-family: var(--font-mono); font-weight: 900; }
.toolbar { display: flex; gap: 8px; align-items: center; }
.btn { height: 32px; border: 1px solid var(--border); background: var(--surface); border-radius: 8px; padding: 0 12px; font-size: 12px; font-weight: 800; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.btn:hover { border-color: color-mix(in oklch, var(--accent) 42%, var(--border)); }
.summary { display: grid; grid-template-columns: repeat(5, 1fr); gap: 1px; background: var(--border); border-bottom: 1px solid var(--border); }
.metric { background: var(--surface); padding: 14px 18px; display: grid; gap: 5px; }
.metric span { color: var(--muted); font-family: var(--font-mono); font-size: 10px; font-weight: 900; letter-spacing: .08em; text-transform: uppercase; }
.metric b { font-family: var(--font-mono); font-size: 22px; letter-spacing: -0.03em; }
.metric.ok b { color: var(--accent); } .metric.warn b { color: var(--warn); }
.body { min-height: 0; display: grid; grid-template-columns: 1fr 380px; }
.table-pane { min-width: 0; display: grid; grid-template-rows: auto 1fr; border-right: 1px solid var(--border); background: var(--surface); }
.table-tools { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 12px 16px; border-bottom: 1px solid var(--border); }
.search { display: flex; gap: 8px; align-items: center; min-width: 0; flex: 1; }
.search input { width: 340px; height: 34px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-2); padding: 0 11px; outline: none; }
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 9px; overflow: hidden; }
.seg button { border: 0; border-right: 1px solid var(--border); background: var(--surface); height: 32px; padding: 0 10px; color: var(--muted); font-weight: 800; font-size: 12px; }
.seg button:last-child { border-right: 0; }
.seg button.on { background: color-mix(in oklch, var(--accent) 12%, var(--surface)); color: var(--accent); }
.table-wrap { overflow: auto; }
table { width: 100%; border-collapse: collapse; min-width: 900px; }
th, td { border-bottom: 1px solid var(--border); padding: 11px 12px; text-align: left; }
th { position: sticky; top: 0; background: var(--surface); z-index: 2; color: var(--muted); font-family: var(--font-mono); font-size: 10px; letter-spacing: .08em; text-transform: uppercase; }
tr { cursor: pointer; }
tbody tr:hover, tbody tr.on { background: color-mix(in oklch, var(--accent) 9%, var(--surface)); }
.name { font-weight: 850; }
.code, .mono { font-family: var(--font-mono); font-size: 12px; }
.code { font-weight: 900; }
.muted { color: var(--muted); }
.status { display: inline-flex; align-items: center; gap: 5px; font-family: var(--font-mono); font-size: 10px; font-weight: 900; border-radius: 999px; padding: 4px 8px; border: 1px solid var(--border); color: var(--muted); }
.status.on { color: var(--accent); background: color-mix(in oklch, var(--accent) 10%, var(--surface)); border-color: color-mix(in oklch, var(--accent) 30%, var(--border)); }
.status.warn { color: var(--warn); background: color-mix(in oklch, var(--warn) 12%, var(--surface)); border-color: color-mix(in oklch, var(--warn) 36%, var(--border)); }
.inspector { overflow: auto; background: var(--surface); display: grid; grid-template-rows: auto auto auto 1fr; }
.ins-head { padding: 18px; border-bottom: 1px solid var(--border); }
.ins-head h2 { font-size: 21px; }
.ins-head p { margin: 7px 0 0; color: var(--muted); }
.next-card { margin: 16px 18px; border: 1px solid var(--border); border-radius: 14px; background: var(--fg); color: var(--surface); padding: 18px; }
.next-card span { color: color-mix(in oklch, var(--surface) 62%, transparent); font-family: var(--font-mono); font-size: 10px; font-weight: 900; letter-spacing: .08em; }
.next-card b { display: block; margin-top: 10px; font-family: var(--font-mono); font-size: 28px; letter-spacing: -0.04em; }
.tabs { display: flex; gap: 6px; padding: 0 18px 14px; border-bottom: 1px solid var(--border); }
.tabs button { border: 1px solid var(--border); border-radius: 999px; background: var(--surface); padding: 6px 10px; color: var(--muted); font-weight: 800; font-size: 12px; }
.tabs button.on { background: color-mix(in oklch, var(--accent) 12%, var(--surface)); color: var(--accent); }
.form { padding: 16px 18px; display: grid; gap: 12px; }
.field { display: grid; gap: 6px; color: var(--muted); font-size: 11px; font-weight: 850; }
.field input, .field select { height: 34px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-2); padding: 0 10px; outline: none; }
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.usage { border-top: 1px solid var(--border); padding: 16px 18px; display: grid; gap: 10px; }
.usage-row { display: grid; grid-template-columns: 1fr auto; gap: 10px; border: 1px solid var(--border); border-radius: 10px; padding: 10px; background: var(--surface-2); }
.usage-row b { font-family: var(--font-mono); }
.usage-row span { color: var(--muted); font-size: 11px; }
@media (max-width: 980px) { body { height: auto; overflow: auto; } .app { height: auto; grid-template-rows: auto; } .top, .table-tools { align-items: stretch; flex-direction: column; padding: 14px; } .summary { grid-template-columns: repeat(2, 1fr); } .body { grid-template-columns: 1fr; } .inspector { border-top: 1px solid var(--border); } }
</style>
</head>
<body>
<div class="app">
<header class="top">
<div class="title"><div class="mark">NR</div><div><h1>채번 운영 콘솔</h1><p>규칙, 연결 컬럼, 현재 시퀀스, 리셋 정책을 한 화면에서 관리</p></div></div>
<div class="toolbar"><button class="btn">CSV 내보내기</button><button class="btn">새로고침</button><button class="btn primary">새 규칙</button></div>
</header>
<section class="summary" aria-label="채번 요약">
<div class="metric"><span>Total rules</span><b>18</b></div>
<div class="metric ok"><span>Linked</span><b>14</b></div>
<div class="metric warn"><span>Need review</span><b>2</b></div>
<div class="metric"><span>Monthly reset</span><b>9</b></div>
<div class="metric"><span>Generated today</span><b>126</b></div>
</section>
<main class="body">
<section class="table-pane">
<div class="table-tools">
<div class="search"><input id="search" placeholder="규칙명, 테이블, 컬럼, 패턴 검색" /><button class="btn">필터</button></div>
<div class="seg"><button class="on" data-filter="all">전체</button><button data-filter="linked">연결</button><button data-filter="review">검토</button><button data-filter="unused">미사용</button></div>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Rule</th><th>Pattern</th><th>Target</th><th>Status</th><th>Reset</th><th>Current</th><th>Next</th><th>Updated</th></tr></thead>
<tbody id="rows">
<tr class="on" data-filter="linked" data-title="수주번호 자동채번" data-next="SO-202605-0048"><td><div class="name">수주번호 자동채번</div><div class="muted">영업 수주 등록</div></td><td class="code">SO-{yyyyMM}-{0000}</td><td class="mono">sales_order.order_no</td><td><span class="status on">ACTIVE</span></td><td>월별</td><td class="mono">47</td><td class="mono">48</td><td class="mono">05-20</td></tr>
<tr data-filter="linked" data-title="구매발주 번호" data-next="PO-2026-00132"><td><div class="name">구매발주 번호</div><div class="muted">구매 발주서</div></td><td class="code">PO-{yyyy}-{00000}</td><td class="mono">purchase_order.po_no</td><td><span class="status on">ACTIVE</span></td><td>연도별</td><td class="mono">131</td><td class="mono">132</td><td class="mono">05-18</td></tr>
<tr data-filter="review" data-title="품목마스터 코드" data-next="ITEM-RM-008"><td><div class="name">품목마스터 코드</div><div class="muted">분류 접두어 확인 필요</div></td><td class="code">ITEM-{category}-{000}</td><td class="mono">item.item_code</td><td><span class="status warn">REVIEW</span></td><td>없음</td><td class="mono">7</td><td class="mono">8</td><td class="mono">05-16</td></tr>
<tr data-filter="unused" data-title="작업지시 번호" data-next="WO-20260520-012"><td><div class="name">작업지시 번호</div><div class="muted">아직 연결된 컬럼 없음</div></td><td class="code">WO-{yyyyMMdd}-{000}</td><td class="mono muted">-</td><td><span class="status">UNUSED</span></td><td>일별</td><td class="mono">11</td><td class="mono">12</td><td class="mono">05-12</td></tr>
<tr data-filter="linked" data-title="입고검사 LOT" data-next="IQC-20260520-0006"><td><div class="name">입고검사 LOT</div><div class="muted">품질 입고 검사</div></td><td class="code">IQC-{date}-{seq}</td><td class="mono">incoming_inspection.lot_no</td><td><span class="status on">ACTIVE</span></td><td>일별</td><td class="mono">5</td><td class="mono">6</td><td class="mono">05-20</td></tr>
</tbody>
</table>
</div>
</section>
<aside class="inspector">
<div class="ins-head"><h2 id="insTitle">수주번호 자동채번</h2><p>선택한 채번 규칙의 운영 속성과 사용처입니다.</p></div>
<div class="next-card"><span>NEXT GENERATED CODE</span><b id="nextCode">SO-202605-0048</b></div>
<div class="tabs"><button class="on">기본</button><button>파트</button><button>이력</button></div>
<div class="form">
<label class="field">규칙명<input value="수주번호 자동채번" /></label>
<div class="split"><label class="field">구분자<input value="-" /></label><label class="field">리셋<select><option>월별</option><option>일별</option><option>없음</option></select></label></div>
<label class="field">패턴<input value="SO-{yyyyMM}-{0000}" /></label>
<div class="split"><label class="field">현재 순번<input value="47" /></label><label class="field">다음 순번<input value="48" /></label></div>
<button class="btn primary">변경 저장</button>
</div>
<div class="usage">
<div class="usage-row"><div><b>sales_order.order_no</b><br><span>수주 등록, 상세, 검색 필터</span></div><span class="mono">84/day</span></div>
<div class="usage-row"><div><b>sales_order_history.ref_no</b><br><span>변경 이력 참조번호</span></div><span class="mono">12/day</span></div>
</div>
</aside>
</main>
</div>
<script>
const rows = [...document.querySelectorAll("#rows tr")];
const search = document.querySelector("#search");
const filterButtons = [...document.querySelectorAll(".seg button")];
let activeFilter = "all";
function applyFilter() {
const term = search.value.trim().toLowerCase();
rows.forEach((row) => {
const matchesFilter = activeFilter === "all" || row.dataset.filter === activeFilter;
const matchesSearch = row.textContent.toLowerCase().includes(term);
row.style.display = matchesFilter && matchesSearch ? "table-row" : "none";
});
}
rows.forEach((row) => {
row.addEventListener("click", () => {
rows.forEach((item) => item.classList.remove("on"));
row.classList.add("on");
document.querySelector("#insTitle").textContent = row.dataset.title;
document.querySelector("#nextCode").textContent = row.dataset.next;
});
});
filterButtons.forEach((button) => {
button.addEventListener("click", () => {
filterButtons.forEach((item) => item.classList.remove("on"));
button.classList.add("on");
activeFilter = button.dataset.filter;
applyFilter();
});
});
search.addEventListener("input", applyFilter);
</script>
</body>
</html>
@@ -0,0 +1,211 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>채번 Admin 시안 C - 검증 보드형</title>
<style>
:root {
--bg: oklch(97% 0.006 248);
--surface: oklch(100% 0 0);
--surface-2: oklch(95% 0.008 248);
--surface-3: oklch(91% 0.012 248);
--fg: oklch(17% 0.018 248);
--muted: oklch(50% 0.014 248);
--border: oklch(88% 0.01 248);
--accent: oklch(58% 0.16 226);
--success: oklch(57% 0.15 155);
--warn: oklch(68% 0.15 72);
--danger: oklch(58% 0.18 28);
--on-accent: oklch(100% 0 0);
--font-display: -apple-system, BlinkMacSystemFont, "Pretendard", "Segoe UI", system-ui, sans-serif;
--font-body: -apple-system, BlinkMacSystemFont, "Pretendard", "Segoe UI", system-ui, sans-serif;
--font-mono: "JetBrains Mono", "D2Coding", ui-monospace, Menlo, monospace;
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: var(--bg); color: var(--fg); font-family: var(--font-body); font-size: 13px; }
button, input { font: inherit; color: inherit; }
button { cursor: pointer; }
h1, h2, h3 { margin: 0; font-family: var(--font-display); letter-spacing: -0.01em; }
.app { min-height: 100vh; display: grid; grid-template-rows: auto 1fr; }
.top { padding: 22px 26px 18px; background: var(--surface); border-bottom: 1px solid var(--border); display: grid; gap: 18px; }
.topline { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
.title { display: flex; align-items: flex-start; gap: 13px; }
.mark { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; background: var(--accent); color: var(--on-accent); font-family: var(--font-mono); font-weight: 900; }
h1 { font-size: 22px; }
.title p { margin: 5px 0 0; color: var(--muted); }
.actions { display: flex; gap: 8px; align-items: center; }
.btn { height: 34px; border: 1px solid var(--border); background: var(--surface); border-radius: 8px; padding: 0 12px; font-size: 12px; font-weight: 850; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.btn.good { background: var(--success); border-color: var(--success); color: var(--on-accent); }
.btn:hover { border-color: color-mix(in oklch, var(--accent) 45%, var(--border)); }
.steps { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
.step { border: 1px solid var(--border); border-radius: 12px; background: var(--surface-2); padding: 12px; display: grid; gap: 5px; }
.step span { font-family: var(--font-mono); font-size: 10px; color: var(--muted); font-weight: 900; letter-spacing: .08em; text-transform: uppercase; }
.step b { font-family: var(--font-mono); font-size: 22px; }
.step.warn b { color: var(--warn); } .step.danger b { color: var(--danger); } .step.good b { color: var(--success); }
.body { min-height: 0; display: grid; grid-template-columns: 1fr 408px; gap: 16px; padding: 16px; }
.board { min-width: 0; display: grid; grid-template-rows: auto 1fr; gap: 12px; }
.board-tools { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.search { width: 340px; height: 34px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); padding: 0 12px; outline: none; }
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 9px; overflow: hidden; background: var(--surface); }
.seg button { height: 32px; border: 0; border-right: 1px solid var(--border); background: transparent; padding: 0 11px; color: var(--muted); font-weight: 850; font-size: 12px; }
.seg button:last-child { border-right: 0; }
.seg button.on { background: color-mix(in oklch, var(--accent) 11%, var(--surface)); color: var(--accent); }
.columns { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; min-height: 0; }
.col { min-height: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 14px; overflow: hidden; display: grid; grid-template-rows: auto 1fr; }
.col-head { padding: 13px 14px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
.col-head h2 { font-size: 15px; }
.count { font-family: var(--font-mono); font-size: 11px; color: var(--muted); border: 1px solid var(--border); border-radius: 999px; padding: 3px 7px; }
.cards { overflow: auto; padding: 10px; display: grid; gap: 10px; align-content: start; }
.card { border: 1px solid var(--border); border-radius: 12px; background: var(--surface-2); padding: 12px; display: grid; gap: 10px; }
.card:hover, .card.on { border-color: var(--accent); background: color-mix(in oklch, var(--accent) 8%, var(--surface)); }
.card-top { display: flex; justify-content: space-between; gap: 12px; }
.card h3 { font-size: 14px; }
.tag { font-family: var(--font-mono); font-size: 10px; font-weight: 900; border-radius: 999px; padding: 3px 7px; background: var(--surface); border: 1px solid var(--border); color: var(--muted); }
.tag.danger { color: var(--danger); background: color-mix(in oklch, var(--danger) 11%, var(--surface)); }
.tag.warn { color: var(--warn); background: color-mix(in oklch, var(--warn) 13%, var(--surface)); }
.tag.good { color: var(--success); background: color-mix(in oklch, var(--success) 11%, var(--surface)); }
.pattern { font-family: var(--font-mono); font-size: 12px; font-weight: 900; padding: 8px; border-radius: 8px; background: var(--surface); border: 1px solid var(--border); }
.desc { color: var(--muted); font-size: 12px; }
.impact { display: flex; gap: 6px; flex-wrap: wrap; }
.impact span { font-family: var(--font-mono); font-size: 10px; color: var(--muted); border: 1px solid var(--border); border-radius: 999px; padding: 3px 6px; background: var(--surface); }
.detail { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; min-height: 0; overflow: auto; }
.detail-head { padding: 18px; border-bottom: 1px solid var(--border); display: grid; gap: 8px; }
.detail-head h2 { font-size: 21px; }
.detail-head p { margin: 0; color: var(--muted); }
.preview { margin: 16px 18px; padding: 18px; border-radius: 14px; background: var(--fg); color: var(--surface); }
.preview span { color: color-mix(in oklch, var(--surface) 62%, transparent); font-family: var(--font-mono); font-size: 10px; font-weight: 900; letter-spacing: .08em; }
.preview b { display: block; margin-top: 10px; font-family: var(--font-mono); font-size: 26px; letter-spacing: -0.04em; }
.section { padding: 16px 18px; border-top: 1px solid var(--border); display: grid; gap: 10px; }
.section:first-of-type { border-top: 0; }
.section h3 { font-size: 14px; }
.check { display: grid; gap: 8px; }
.check-row { display: grid; grid-template-columns: 18px 1fr auto; align-items: center; gap: 8px; border: 1px solid var(--border); border-radius: 10px; padding: 10px; background: var(--surface-2); }
.check-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--success); }
.check-dot.warn { background: var(--warn); }
.check-dot.danger { background: var(--danger); }
.mono { font-family: var(--font-mono); }
.timeline { display: grid; gap: 0; }
.event { display: grid; grid-template-columns: 72px 1fr; gap: 10px; padding: 9px 0; border-bottom: 1px solid var(--border); }
.event:last-child { border-bottom: 0; }
.event time { color: var(--muted); font-family: var(--font-mono); font-size: 11px; }
.event b { display: block; margin-bottom: 2px; }
.detail-actions { position: sticky; bottom: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 12px 18px; background: var(--surface); border-top: 1px solid var(--border); }
@media (max-width: 1040px) { .body { grid-template-columns: 1fr; } .columns { grid-template-columns: 1fr; } .steps { grid-template-columns: repeat(2, 1fr); } .topline, .board-tools { flex-direction: column; align-items: stretch; } .search { width: 100%; } }
</style>
</head>
<body>
<div class="app">
<header class="top">
<div class="topline">
<div class="title"><div class="mark">QA</div><div><h1>채번 변경 검증 보드</h1><p>규칙 저장 전에 중복 가능성, 연결 컬럼, 시퀀스 리셋 영향을 확인합니다.</p></div></div>
<div class="actions"><button class="btn">검증 다시 실행</button><button class="btn primary">변경 요청 생성</button></div>
</div>
<section class="steps" aria-label="검증 요약">
<div class="step danger"><span>Collision risk</span><b>2</b></div>
<div class="step warn"><span>Need mapping</span><b>3</b></div>
<div class="step"><span>Ready to save</span><b>9</b></div>
<div class="step good"><span>Approved</span><b>4</b></div>
</section>
</header>
<main class="body">
<section class="board">
<div class="board-tools">
<input id="search" class="search" placeholder="규칙명, 패턴, 테이블 검색" />
<div class="seg"><button class="on" data-filter="all">전체</button><button data-filter="collision">충돌</button><button data-filter="review">검토</button><button data-filter="approved">승인</button></div>
</div>
<div class="columns" id="columns">
<div class="col">
<div class="col-head"><h2>충돌 위험</h2><span class="count">2</span></div>
<div class="cards">
<article class="card on" data-status="collision" data-title="품목마스터 코드" data-code="ITEM-RM-008"><div class="card-top"><h3>품목마스터 코드</h3><span class="tag danger">collision</span></div><div class="pattern">ITEM-{category}-{000}</div><p class="desc">기존 수동 코드와 신규 자동 순번 범위가 겹칠 수 있습니다.</p><div class="impact"><span>item.item_code</span><span>현재 7</span><span>다음 8</span></div></article>
<article class="card" data-status="collision" data-title="거래처 코드" data-code="CUST-0241"><div class="card-top"><h3>거래처 코드</h3><span class="tag danger">collision</span></div><div class="pattern">CUST-{0000}</div><p class="desc">과거 마이그레이션 데이터의 접두어가 동일합니다.</p><div class="impact"><span>customer.customer_code</span><span>legacy</span></div></article>
</div>
</div>
<div class="col">
<div class="col-head"><h2>검토 필요</h2><span class="count">3</span></div>
<div class="cards">
<article class="card" data-status="review" data-title="작업지시 번호" data-code="WO-20260520-012"><div class="card-top"><h3>작업지시 번호</h3><span class="tag warn">mapping</span></div><div class="pattern">WO-{yyyyMMdd}-{000}</div><p class="desc">규칙은 정상이나 아직 연결된 컬럼이 없습니다.</p><div class="impact"><span>unlinked</span><span>daily reset</span></div></article>
<article class="card" data-status="review" data-title="입고검사 LOT" data-code="IQC-20260520-0006"><div class="card-top"><h3>입고검사 LOT</h3><span class="tag warn">reset</span></div><div class="pattern">IQC-{date}-{seq}</div><p class="desc">일별 리셋 변경 시 오늘 발번된 LOT와의 영향 확인이 필요합니다.</p><div class="impact"><span>incoming_inspection</span><span>5 today</span></div></article>
</div>
</div>
<div class="col">
<div class="col-head"><h2>저장 가능</h2><span class="count">9</span></div>
<div class="cards">
<article class="card" data-status="approved" data-title="수주번호 자동채번" data-code="SO-202605-0048"><div class="card-top"><h3>수주번호 자동채번</h3><span class="tag good">ready</span></div><div class="pattern">SO-{yyyyMM}-{0000}</div><p class="desc">연결 컬럼과 다음 순번이 정상입니다.</p><div class="impact"><span>sales_order</span><span>monthly</span></div></article>
<article class="card" data-status="approved" data-title="구매발주 번호" data-code="PO-2026-00132"><div class="card-top"><h3>구매발주 번호</h3><span class="tag good">ready</span></div><div class="pattern">PO-{yyyy}-{00000}</div><p class="desc">연도별 리셋 정책과 기존 발번 이력이 일치합니다.</p><div class="impact"><span>purchase_order</span><span>yearly</span></div></article>
</div>
</div>
</div>
</section>
<aside class="detail">
<div class="detail-head"><h2 id="detailTitle">품목마스터 코드</h2><p>선택된 변경의 충돌 원인과 적용 전 체크 항목입니다.</p></div>
<div class="preview"><span>NEXT CODE IF SAVED</span><b id="detailCode">ITEM-RM-008</b></div>
<section class="section">
<h3>검증 결과</h3>
<div class="check">
<div class="check-row"><span class="check-dot danger"></span><span>기존 코드 범위와 접두어가 겹침</span><span class="mono">fail</span></div>
<div class="check-row"><span class="check-dot warn"></span><span>카테고리 접두어가 수동 입력과 혼재</span><span class="mono">review</span></div>
<div class="check-row"><span class="check-dot"></span><span>시퀀스 저장소는 정상 연결</span><span class="mono">pass</span></div>
</div>
</section>
<section class="section">
<h3>영향 범위</h3>
<div class="check">
<div class="check-row"><span class="check-dot warn"></span><span>item.item_code</span><span class="mono">primary</span></div>
<div class="check-row"><span class="check-dot"></span><span>bom_item.child_item_code</span><span class="mono">ref</span></div>
<div class="check-row"><span class="check-dot"></span><span>purchase_order_item.item_code</span><span class="mono">ref</span></div>
</div>
</section>
<section class="section">
<h3>최근 이력</h3>
<div class="timeline">
<div class="event"><time>09:42</time><div><b>수주번호 자동채번 발번</b><span class="muted">SO-202605-0047 생성</span></div></div>
<div class="event"><time>09:10</time><div><b>품목마스터 코드 검증</b><span class="muted">중복 가능성 2건 발견</span></div></div>
<div class="event"><time>08:58</time><div><b>구매발주 번호 승인</b><span class="muted">PO-2026-00132 대기</span></div></div>
</div>
</section>
<div class="detail-actions"><button class="btn">수정으로 이동</button><button class="btn good" id="approve">검증 승인</button></div>
</aside>
</main>
</div>
<script>
const cards = [...document.querySelectorAll(".card")];
const filterButtons = [...document.querySelectorAll(".seg button")];
const search = document.querySelector("#search");
let activeFilter = "all";
function selectCard(card) {
cards.forEach((item) => item.classList.remove("on"));
card.classList.add("on");
document.querySelector("#detailTitle").textContent = card.dataset.title;
document.querySelector("#detailCode").textContent = card.dataset.code;
}
function applyFilter() {
const term = search.value.trim().toLowerCase();
cards.forEach((card) => {
const byFilter = activeFilter === "all" || card.dataset.status === activeFilter;
const bySearch = card.textContent.toLowerCase().includes(term);
card.style.display = byFilter && bySearch ? "grid" : "none";
});
}
cards.forEach((card) => card.addEventListener("click", () => selectCard(card)));
filterButtons.forEach((button) => button.addEventListener("click", () => {
filterButtons.forEach((item) => item.classList.remove("on"));
button.classList.add("on");
activeFilter = button.dataset.filter;
applyFilter();
}));
search.addEventListener("input", applyFilter);
document.querySelector("#approve").addEventListener("click", () => {
const active = document.querySelector(".card.on");
if (!active) return;
active.dataset.status = "approved";
active.querySelector(".tag").className = "tag good";
active.querySelector(".tag").textContent = "approved";
document.querySelector("#approve").textContent = "승인됨";
setTimeout(() => { document.querySelector("#approve").textContent = "검증 승인"; }, 1200);
});
</script>
</body>
</html>
@@ -0,0 +1,945 @@
<!DOCTYPE html>
<html lang="ko" data-theme="light">
<head>
<meta charset="UTF-8">
<title>채번 관리 — v5 (차분 정돈본)</title>
<style>
:root, [data-theme="light"] {
--v5-primary-rgb: 108, 92, 231;
--v5-green-rgb: 0, 184, 148;
--v5-amber-rgb: 217, 119, 6;
--v5-red-rgb: 235, 87, 87;
--v5-bg: #ffffff;
--v5-bg-subtle: #f7f7fa;
--v5-surface-solid: #ffffff;
--v5-surface-hover: #f4f4f8;
--v5-text: #1a1a26;
--v5-text-sec: #5a5a6c;
--v5-text-muted: #9c9caa;
--v5-primary: rgb(var(--v5-primary-rgb));
--v5-primary-soft: rgba(var(--v5-primary-rgb), .07);
--v5-border: rgba(var(--v5-primary-rgb), 0.10);
--v5-border-strong: rgba(var(--v5-primary-rgb), 0.22);
--v5-divider: rgba(20, 20, 30, 0.06);
--v5-font-mono: 'JetBrains Mono', 'D2Coding', ui-monospace, SFMono-Regular, Monaco, Consolas, monospace;
}
[data-theme="dark"] {
--v5-primary-rgb: 162, 155, 254;
--v5-bg: #0c0d12;
--v5-bg-subtle: #131420;
--v5-surface-solid: #11102a;
--v5-surface-hover: #1c1d3a;
--v5-text: #ebecee;
--v5-text-sec: #a3a4b3;
--v5-text-muted: #61627a;
--v5-border: rgba(255, 255, 255, 0.08);
--v5-border-strong: rgba(255, 255, 255, 0.18);
--v5-divider: rgba(255, 255, 255, 0.05);
--v5-primary-soft: rgba(var(--v5-primary-rgb), .10);
}
*, *::before, *::after { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
background: var(--v5-bg);
color: var(--v5-text);
font-family: -apple-system, BlinkMacSystemFont, "Pretendard", "Apple SD Gothic Neo", system-ui, sans-serif;
font-size: .76rem;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
overflow: hidden;
}
button, input, select, textarea { font-family: inherit; color: inherit; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-thumb { background: var(--v5-border-strong); border-radius: 3px; }
.ico { width: 13px; height: 13px; stroke: currentColor; fill: none; stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }
.ico-sm { width: 11px; height: 11px; stroke: currentColor; fill: none; stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }
.ico-xl { width: 28px; height: 28px; stroke: currentColor; fill: none; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
/* ─── 공통 컨트롤 ─── */
.btn {
display: inline-flex; align-items: center; gap: 5px;
height: 28px; padding: 0 .7rem;
border-radius: 6px;
font-size: .68rem; font-weight: 600;
border: 1px solid var(--v5-border);
background: var(--v5-surface-solid);
color: var(--v5-text);
cursor: pointer;
transition: border-color .15s, background .15s, color .15s;
white-space: nowrap;
}
.btn:hover { border-color: var(--v5-border-strong); background: var(--v5-surface-hover); }
.btn.primary {
background: var(--v5-primary);
border-color: var(--v5-primary);
color: #fff;
}
.btn.primary:hover { filter: brightness(.93); }
.btn.danger { color: var(--v5-text-sec); }
.btn.danger:hover { color: rgb(var(--v5-red-rgb)); border-color: rgba(var(--v5-red-rgb), .35); background: rgba(var(--v5-red-rgb), .04); }
.btn.ghost { border-color: transparent; background: transparent; }
.btn.ghost:hover { background: var(--v5-surface-hover); }
.btn.sm { height: 26px; padding: 0 .55rem; font-size: .64rem; gap: 4px; }
.btn.icon { padding: 0; width: 28px; justify-content: center; }
.btn.icon.sm { width: 26px; }
.chip {
display: inline-flex; align-items: center; gap: 4px;
padding: 1px 7px;
border-radius: 4px;
background: var(--v5-bg-subtle);
border: 1px solid var(--v5-border);
font-size: .58rem;
font-weight: 600;
color: var(--v5-text-sec);
}
.chip.live {
background: rgba(var(--v5-green-rgb), .08);
color: rgb(var(--v5-green-rgb));
border-color: rgba(var(--v5-green-rgb), .22);
}
.chip.live::before {
content: '';
width: 5px; height: 5px;
background: rgb(var(--v5-green-rgb));
border-radius: 50%;
}
.chip.mono {
font-family: var(--v5-font-mono);
font-size: .56rem;
letter-spacing: 0;
}
.kbd {
font-family: var(--v5-font-mono);
font-size: .56rem;
padding: 1px 5px;
background: var(--v5-bg-subtle);
border: 1px solid var(--v5-border);
border-radius: 4px;
color: var(--v5-text-sec);
}
/* ─── PAGE ─── */
.page { display: flex; flex-direction: column; height: 100%; }
.pagehead {
padding: .8rem 1.25rem;
border-bottom: 1px solid var(--v5-border);
background: var(--v5-surface-solid);
display: flex; align-items: center; justify-content: space-between;
gap: 1rem;
flex-shrink: 0;
}
.pagehead .l { display: flex; align-items: baseline; gap: .65rem; }
.pagehead h1 { margin: 0; font-size: .88rem; font-weight: 700; letter-spacing: -.01em; }
.pagehead .subtitle { font-size: .66rem; color: var(--v5-text-muted); }
.pagehead .subtitle b { color: var(--v5-text-sec); font-weight: 600; }
.pagehead .r { display: flex; gap: .35rem; align-items: center; }
.body { flex: 1; min-height: 0; display: grid; grid-template-columns: 300px 1fr; overflow: hidden; }
/* ─── SIDEBAR ─── */
.side {
border-right: 1px solid var(--v5-border);
background: var(--v5-surface-solid);
display: flex; flex-direction: column;
overflow: hidden;
}
.side-srch {
position: relative;
padding: .55rem .65rem;
border-bottom: 1px solid var(--v5-border);
}
.side-srch input {
width: 100%; height: 30px;
padding: 0 2rem 0 1.85rem;
background: var(--v5-bg-subtle);
border: 1px solid var(--v5-border);
border-radius: 6px;
font-size: .72rem;
outline: none;
}
.side-srch input:focus { border-color: var(--v5-primary); background: var(--v5-surface-solid); }
.side-srch svg { position: absolute; left: 1.15rem; top: 50%; transform: translateY(-50%); color: var(--v5-text-muted); width: 12px; height: 12px; }
.side-srch .kbd-hint { position: absolute; right: 1rem; top: 50%; transform: translateY(-50%); font-family: var(--v5-font-mono); font-size: .55rem; color: var(--v5-text-muted); padding: 1px 5px; background: var(--v5-surface-solid); border: 1px solid var(--v5-border); border-radius: 3px; }
.side-filters { display: flex; gap: 3px; padding: .45rem .65rem; border-bottom: 1px solid var(--v5-border); }
.filt {
padding: 3px 8px;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: var(--v5-text-sec);
font-size: .62rem; font-weight: 600;
cursor: pointer;
display: inline-flex; align-items: center; gap: 4px;
}
.filt:hover { background: var(--v5-surface-hover); color: var(--v5-text); }
.filt.on { background: var(--v5-primary-soft); color: var(--v5-primary); }
.filt b { font-weight: 700; }
.filt.on b { color: var(--v5-primary); }
.side-list { flex: 1; overflow-y: auto; }
.side-section {
font-size: .54rem;
color: var(--v5-text-muted);
font-weight: 700;
letter-spacing: .1em;
text-transform: uppercase;
padding: .65rem .85rem .25rem;
display: flex; justify-content: space-between;
}
.side-item {
display: grid;
grid-template-columns: 1fr auto;
gap: .5rem;
align-items: center;
padding: .55rem .85rem;
cursor: pointer;
transition: background .12s;
border-bottom: 1px solid var(--v5-divider);
position: relative;
}
.side-item:hover { background: var(--v5-surface-hover); }
.side-item.on { background: var(--v5-primary-soft); }
.side-item.on::before {
content: ''; position: absolute; left: 0; top: 0; bottom: 0;
width: 2px; background: var(--v5-primary);
}
.side-item.on .side-item-name { color: var(--v5-primary); font-weight: 700; }
.side-item-name { font-size: .76rem; font-weight: 600; margin-bottom: 1px; }
.side-item-pat { font-family: var(--v5-font-mono); font-size: .58rem; color: var(--v5-text-muted); }
.side-item-right { font-family: var(--v5-font-mono); font-size: .55rem; color: var(--v5-text-muted); display: flex; align-items: center; gap: 5px; }
.side-item-right .dot { width: 5px; height: 5px; border-radius: 50%; background: rgb(var(--v5-green-rgb)); }
.side-item.dim { opacity: .55; }
.side-item.dim .dot { background: var(--v5-text-muted); }
.side-foot { padding: .55rem .65rem; border-top: 1px solid var(--v5-border); }
/* ─── MAIN ─── */
.main {
overflow-y: auto;
background: var(--v5-surface-solid);
display: flex; flex-direction: column;
}
/* DETAIL HEAD */
.detail-head {
padding: .9rem 1.4rem .8rem;
border-bottom: 1px solid var(--v5-border);
display: flex; align-items: flex-start; justify-content: space-between;
gap: 1rem;
}
.detail-head .l { display: flex; flex-direction: column; gap: 5px; min-width: 0; flex: 1; }
.detail-head h2 {
margin: 0;
font-size: .98rem;
font-weight: 700;
letter-spacing: -.01em;
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
}
.detail-head .meta {
display: flex; gap: .85rem; flex-wrap: wrap;
font-size: .62rem;
color: var(--v5-text-muted);
}
.detail-head .meta b { color: var(--v5-text-sec); font-weight: 600; }
.detail-head .r { display: flex; gap: .25rem; }
/* NEXT BAR — pattern-vis 의 압축 버전. 한 줄로 핵심 정보만 */
.next-bar {
padding: .65rem 1.4rem;
background: var(--v5-bg-subtle);
border-bottom: 1px solid var(--v5-border);
display: flex; align-items: center; gap: 1rem;
font-size: .68rem;
flex-wrap: wrap;
}
.next-bar .group { display: inline-flex; align-items: center; gap: .45rem; }
.next-bar .lbl {
color: var(--v5-text-muted);
font-weight: 600;
font-size: .62rem;
}
.next-bar .code {
font-family: var(--v5-font-mono);
font-weight: 700;
color: var(--v5-text);
font-size: .82rem;
letter-spacing: .01em;
}
.next-bar .code.next {
color: var(--v5-primary);
background: var(--v5-primary-soft);
padding: 2px 7px;
border-radius: 4px;
}
.next-bar .arrow { color: var(--v5-text-muted); }
.next-bar .meta-inline { color: var(--v5-text-muted); font-size: .62rem; margin-left: auto; display: inline-flex; align-items: center; gap: .55rem; }
/* SECTION */
.section {
padding: 1rem 1.4rem 1.1rem;
border-bottom: 1px solid var(--v5-border);
}
.section-hd { display: flex; align-items: center; gap: 8px; margin-bottom: .65rem; }
.section-hd h3 { margin: 0; font-size: .8rem; font-weight: 700; letter-spacing: -.005em; }
.section-hd .count {
font-family: var(--v5-font-mono);
font-size: .58rem;
color: var(--v5-text-sec);
font-weight: 600;
padding: 1px 6px;
background: var(--v5-bg-subtle);
border: 1px solid var(--v5-border);
border-radius: 4px;
}
.section-hd .desc { font-size: .62rem; color: var(--v5-text-muted); }
.section-hd .actions { margin-left: auto; display: flex; gap: .25rem; }
/* PIPELINE — 컴팩트 블록 */
.pipe-wrap {
padding: .65rem .75rem .55rem;
background: var(--v5-bg-subtle);
border: 1px solid var(--v5-border);
border-radius: 8px;
}
.pipe {
display: flex;
flex-wrap: wrap;
align-items: stretch;
gap: 0;
}
.pipe-slot {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
color: var(--v5-text-muted);
font-family: var(--v5-font-mono);
font-size: .7rem;
font-weight: 500;
position: relative;
user-select: none;
transition: color .15s;
}
.pipe-slot .add-here {
position: absolute;
inset: 0;
display: flex; align-items: center; justify-content: center;
background: var(--v5-primary);
color: #fff;
border: 2px solid var(--v5-bg-subtle);
border-radius: 50%;
width: 16px; height: 16px;
left: 50%; top: 50%;
transform: translate(-50%, -50%) scale(0);
cursor: pointer;
opacity: 0;
transition: opacity .15s, transform .15s;
}
.pipe-slot .add-here svg { width: 9px; height: 9px; stroke: currentColor; fill: none; stroke-width: 2.5; }
.pipe-slot:hover .add-here { opacity: 1; transform: translate(-50%, -50%) scale(1); }
/* pipe-block — 단순하게: ord+lbl 한 줄 + val 큰 글자. typ-en 제거 (인스펙터에 표시) */
.pipe-block {
display: inline-flex;
flex-direction: column;
gap: 2px;
padding: 7px 11px 8px;
background: var(--v5-surface-solid);
border: 1px solid var(--v5-border);
border-radius: 6px;
cursor: pointer;
position: relative;
transition: border-color .15s, background .15s;
min-width: 78px;
}
.pipe-block:hover { border-color: var(--v5-border-strong); }
.pipe-block.sel {
border-color: var(--v5-primary);
background: var(--v5-primary-soft);
}
.pipe-block .top-row {
display: flex; align-items: center; gap: 5px;
font-size: .58rem;
color: var(--v5-text-muted);
font-weight: 600;
}
.pipe-block .top-row .ord { font-family: var(--v5-font-mono); }
.pipe-block .top-row .lbl { color: var(--v5-text-sec); }
.pipe-block.sel .top-row .lbl { color: var(--v5-primary); }
.pipe-block .val {
font-family: var(--v5-font-mono);
font-size: .92rem;
font-weight: 700;
color: var(--v5-text);
line-height: 1.1;
letter-spacing: .01em;
}
.pipe-block.sel .val { color: var(--v5-primary); }
.pipe-block .x {
position: absolute;
top: -6px; right: -6px;
width: 16px; height: 16px;
background: var(--v5-surface-solid);
border: 1px solid var(--v5-border);
border-radius: 50%;
color: var(--v5-text-muted);
font-size: 11px; line-height: 1;
display: flex; align-items: center; justify-content: center;
cursor: pointer;
opacity: 0;
transition: opacity .15s, color .15s, border-color .15s;
padding: 0;
}
.pipe-block:hover .x { opacity: 1; }
.pipe-block .x:hover { color: rgb(var(--v5-red-rgb)); border-color: rgba(var(--v5-red-rgb), .4); }
/* end-of-pipe 추가 */
.pipe-add-end {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 8px 11px;
background: transparent;
border: 1px dashed var(--v5-border-strong);
border-radius: 6px;
color: var(--v5-text-muted);
font-size: .66rem;
font-weight: 600;
cursor: pointer;
margin-left: 8px;
align-self: stretch;
transition: color .15s, border-color .15s, background .15s;
}
.pipe-add-end:hover {
color: var(--v5-primary);
border-color: var(--v5-primary);
background: var(--v5-primary-soft);
}
.pipe-add-end svg { width: 11px; height: 11px; stroke: currentColor; fill: none; stroke-width: 2; }
/* INSPECTOR — 차분하게. 위쪽 화살표 X, glow ring 약화 */
.insp {
margin-top: .65rem;
padding: .7rem .85rem .8rem;
background: var(--v5-surface-solid);
border: 1px solid var(--v5-border-strong);
border-left: 3px solid var(--v5-primary);
border-radius: 6px;
}
.insp-hd {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: .55rem;
padding-bottom: .5rem;
border-bottom: 1px solid var(--v5-divider);
}
.insp-hd .l { display: flex; align-items: center; gap: 8px; font-size: .72rem; }
.insp-hd .l .pin {
font-family: var(--v5-font-mono);
font-size: .58rem;
font-weight: 700;
color: var(--v5-primary);
padding: 2px 6px;
background: var(--v5-primary-soft);
border-radius: 4px;
}
.insp-hd .l b { color: var(--v5-text); font-weight: 700; }
.insp-grid { display: flex; flex-wrap: wrap; gap: .8rem 1rem; align-items: flex-end; }
.insp-field { display: flex; flex-direction: column; gap: 4px; }
.insp-field.grow { flex: 1; min-width: 180px; }
.insp-field.w90 { flex: 0 0 90px; }
.insp-field label { font-size: .62rem; color: var(--v5-text-sec); font-weight: 600; }
.insp-field .hint { font-size: .58rem; color: var(--v5-text-muted); }
.insp-inp {
height: 28px; padding: 0 .55rem;
background: var(--v5-surface-solid);
border: 1px solid var(--v5-border);
border-radius: 5px;
font-family: var(--v5-font-mono);
font-size: .72rem;
font-weight: 600;
outline: none;
width: 100%;
}
.insp-inp:focus { border-color: var(--v5-primary); }
/* SEG — 차분 */
.seg {
display: inline-flex;
border: 1px solid var(--v5-border);
border-radius: 5px;
overflow: hidden;
background: var(--v5-surface-solid);
flex-wrap: wrap;
}
.seg button {
padding: 0 .7rem;
height: 28px;
background: transparent;
border: none;
border-right: 1px solid var(--v5-border);
color: var(--v5-text-sec);
font-size: .64rem;
font-family: var(--v5-font-mono);
cursor: pointer;
font-weight: 600;
transition: background .12s, color .12s;
display: inline-flex; flex-direction: column; align-items: center; justify-content: center;
line-height: 1.1;
}
.seg button:last-child { border-right: none; }
.seg button:hover { background: var(--v5-surface-hover); color: var(--v5-text); }
.seg button.on { background: var(--v5-primary); color: #fff; }
.seg button .sub { font-size: .5rem; opacity: .65; margin-top: 1px; font-weight: 500; }
/* PALETTE — 한글만, 차분 */
.palette {
margin-top: .7rem;
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
}
.palette .lbl { font-size: .62rem; color: var(--v5-text-muted); font-weight: 600; margin-right: 4px; }
.palette-item {
display: inline-flex;
align-items: center;
gap: 5px;
height: 26px;
padding: 0 9px;
background: var(--v5-surface-solid);
border: 1px solid var(--v5-border);
border-radius: 5px;
color: var(--v5-text-sec);
font-size: .66rem;
font-weight: 600;
cursor: pointer;
transition: border-color .12s, color .12s, background .12s;
}
.palette-item:hover {
border-color: var(--v5-border-strong);
color: var(--v5-text);
background: var(--v5-surface-hover);
}
.palette-item svg { width: 11px; height: 11px; stroke: currentColor; fill: none; stroke-width: 1.75; }
/* 2-COL FOOTER (연결 컬럼 + 시퀀스) — 한 줄 요약 톤 */
.foot-split {
display: grid;
grid-template-columns: 1fr 1fr;
border-bottom: 1px solid var(--v5-border);
}
.foot-split > div { padding: .85rem 1.4rem; }
.foot-split > div + div { border-left: 1px solid var(--v5-border); }
.foot-row {
display: flex; align-items: center; justify-content: space-between;
gap: .65rem;
}
.foot-row .ttl {
display: flex; align-items: center; gap: 6px;
font-size: .68rem;
font-weight: 700;
}
.foot-row .ttl .n {
font-family: var(--v5-font-mono);
font-size: .55rem;
color: var(--v5-text-sec);
font-weight: 600;
padding: 1px 5px;
background: var(--v5-bg-subtle);
border: 1px solid var(--v5-border);
border-radius: 3px;
}
.foot-row .act {
font-size: .62rem;
color: var(--v5-primary);
cursor: pointer;
background: transparent; border: none;
font-weight: 600;
}
.foot-row .act:hover { text-decoration: underline; }
.foot-row .val {
margin-top: .35rem;
font-family: var(--v5-font-mono);
font-size: .72rem;
color: var(--v5-text);
font-weight: 600;
}
.foot-row .val .arr { color: var(--v5-text-muted); margin: 0 4px; }
.foot-row .val .nx { color: var(--v5-primary); }
.foot-row .sub {
margin-top: 2px;
font-size: .58rem;
color: var(--v5-text-muted);
}
/* SAVE BAR */
.savebar {
position: sticky;
bottom: 0;
padding: .65rem 1.4rem;
background: var(--v5-surface-solid);
border-top: 1px solid var(--v5-border);
display: flex;
align-items: center;
justify-content: space-between;
gap: .5rem;
z-index: 5;
margin-top: auto;
}
.savebar .l { font-size: .64rem; color: var(--v5-text-muted); display: flex; align-items: center; gap: 6px; }
.unsaved {
color: rgb(var(--v5-amber-rgb));
font-weight: 700;
display: inline-flex; align-items: center; gap: 5px;
}
.unsaved .d {
width: 5px; height: 5px;
background: rgb(var(--v5-amber-rgb));
border-radius: 50%;
}
/* 공허 상태 */
.empty {
flex: 1;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: .5rem;
color: var(--v5-text-muted);
}
.empty svg { width: 36px; height: 36px; opacity: .4; stroke: currentColor; fill: none; stroke-width: 1.5; }
</style>
</head>
<body>
<svg style="position:absolute;width:0;height:0" aria-hidden="true">
<defs>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></symbol>
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
<symbol id="i-hash" viewBox="0 0 24 24"><line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/></symbol>
<symbol id="i-arrow-right" viewBox="0 0 24 24"><path d="M5 12h14M13 5l7 7-7 7"/></symbol>
<symbol id="i-trash" viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-2 14a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></symbol>
<symbol id="i-copy" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></symbol>
<symbol id="i-refresh" viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></symbol>
<symbol id="i-rotate" viewBox="0 0 24 24"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></symbol>
<symbol id="i-save" viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></symbol>
<symbol id="i-x" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></symbol>
<symbol id="i-type" viewBox="0 0 24 24"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></symbol>
<symbol id="i-cal" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></symbol>
<symbol id="i-edit" viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4z"/></symbol>
<symbol id="i-layers" viewBox="0 0 24 24"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></symbol>
<symbol id="i-link" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></symbol>
<symbol id="i-share" viewBox="0 0 24 24"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></symbol>
<symbol id="i-download" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></symbol>
</defs>
</svg>
<div class="page">
<!-- PAGE HEAD -->
<div class="pagehead">
<div class="l">
<h1>채번 관리</h1>
<span class="subtitle">
자동 번호 규칙을 만들고 컬럼에 연결합니다 · <b>14</b> 규칙 · <b>9</b> 사용 중
</span>
</div>
<div class="r">
<span class="kbd">⌘ K</span>
<button class="btn icon ghost" title="새로고침"><svg class="ico"><use href="#i-refresh"/></svg></button>
<button class="btn primary"><svg class="ico"><use href="#i-plus"/></svg> 새 채번</button>
</div>
</div>
<div class="body">
<!-- SIDEBAR -->
<aside class="side">
<div class="side-srch">
<svg><use href="#i-search"/></svg>
<input placeholder="이름이나 컬럼으로 찾기"/>
<span class="kbd-hint">/</span>
</div>
<div class="side-filters">
<button class="filt on">전체 <b>14</b></button>
<button class="filt">사용 중 <b>9</b></button>
<button class="filt">미사용 <b>5</b></button>
</div>
<div class="side-list">
<div class="side-section"><span>사용 중</span><span>9</span></div>
<div class="side-item on">
<div>
<div class="side-item-name">수주번호</div>
<div class="side-item-pat">SO-YYYY-MM-####</div>
</div>
<div class="side-item-right"><span class="dot"></span>1곳</div>
</div>
<div class="side-item">
<div>
<div class="side-item-name">발주번호</div>
<div class="side-item-pat">PO-YYYYMMDD-###</div>
</div>
<div class="side-item-right"><span class="dot"></span>2곳</div>
</div>
<div class="side-item">
<div>
<div class="side-item-name">출고번호</div>
<div class="side-item-pat">OUT-YY-####</div>
</div>
<div class="side-item-right"><span class="dot"></span>1곳</div>
</div>
<div class="side-item">
<div>
<div class="side-item-name">제품코드</div>
<div class="side-item-pat">P-####</div>
</div>
<div class="side-item-right"><span class="dot"></span>1곳</div>
</div>
<div class="side-section"><span>미사용</span><span>5</span></div>
<div class="side-item dim">
<div>
<div class="side-item-name">샘플 번호</div>
<div class="side-item-pat">SMP-##</div>
</div>
<div class="side-item-right"><span class="dot"></span></div>
</div>
<div class="side-item dim">
<div>
<div class="side-item-name">테스트 코드</div>
<div class="side-item-pat">TEST-###</div>
</div>
<div class="side-item-right"><span class="dot"></span></div>
</div>
</div>
<div class="side-foot">
<button class="btn sm" style="width:100%;justify-content:center;"><svg class="ico-sm"><use href="#i-plus"/></svg> 새 채번 만들기</button>
</div>
</aside>
<!-- MAIN -->
<main class="main">
<!-- 1. DETAIL HEAD -->
<div class="detail-head">
<div class="l">
<h2>
수주번호
<span class="chip live">사용 중</span>
<span class="chip mono">RULE-1778487089084</span>
</h2>
<div class="meta">
<span><b>생성</b> 2026-03-12 by gbpark</span>
<span><b>마지막 수정</b> 2026-05-14 16:22</span>
<span><b>지금까지</b> 142건 발번</span>
</div>
</div>
<div class="r">
<button class="btn sm icon ghost" title="복제"><svg class="ico-sm"><use href="#i-copy"/></svg></button>
<button class="btn sm icon ghost" title="내보내기"><svg class="ico-sm"><use href="#i-download"/></svg></button>
<button class="btn sm icon ghost" title="공유"><svg class="ico-sm"><use href="#i-share"/></svg></button>
<button class="btn sm icon ghost danger" title="삭제"><svg class="ico-sm"><use href="#i-trash"/></svg></button>
</div>
</div>
<!-- 2. NEXT BAR — pattern-vis 를 한 줄로 압축 -->
<div class="next-bar">
<span class="group">
<span class="lbl">현재</span>
<span class="code">SO-2026-05-0142</span>
</span>
<svg class="ico" style="color:var(--v5-text-muted)"><use href="#i-arrow-right"/></svg>
<span class="group">
<span class="lbl">다음 발번</span>
<span class="code next">SO-2026-05-0143</span>
</span>
<span class="meta-inline">
<span>매월 1일 초기화</span>
<span>·</span>
<span>시퀀스 142 → 143</span>
</span>
</div>
<!-- 3. PIPELINE -->
<div class="section">
<div class="section-hd">
<h3>코드를 이루는 조각</h3>
<span class="count">4개</span>
<span class="desc">조각 클릭해서 편집 · 사이 hover 로 추가</span>
<div class="actions">
<button class="btn sm ghost"><svg class="ico-sm"><use href="#i-rotate"/></svg> 초기 상태로</button>
</div>
</div>
<div class="pipe-wrap">
<div class="pipe">
<div class="pipe-block">
<div class="top-row"><span class="ord">1번</span><span class="lbl">고정</span></div>
<span class="val">SO</span>
<button class="x">×</button>
</div>
<span class="pipe-slot">
-<button class="add-here"><svg><use href="#i-plus"/></svg></button>
</span>
<div class="pipe-block sel">
<div class="top-row"><span class="ord">2번</span><span class="lbl">년도</span></div>
<span class="val">2026</span>
<button class="x">×</button>
</div>
<span class="pipe-slot">
-<button class="add-here"><svg><use href="#i-plus"/></svg></button>
</span>
<div class="pipe-block">
<div class="top-row"><span class="ord">3번</span><span class="lbl"></span></div>
<span class="val">05</span>
<button class="x">×</button>
</div>
<span class="pipe-slot">
-<button class="add-here"><svg><use href="#i-plus"/></svg></button>
</span>
<div class="pipe-block">
<div class="top-row"><span class="ord">4번</span><span class="lbl">순번</span></div>
<span class="val">0143</span>
<button class="x">×</button>
</div>
<button class="pipe-add-end"><svg><use href="#i-plus"/></svg> 끝에 추가</button>
</div>
</div>
<!-- INSPECTOR (선택된 2번 조각) -->
<div class="insp">
<div class="insp-hd">
<div class="l">
<span class="pin">2번 조각</span>
<span><b>년도</b> 설정</span>
</div>
<button class="btn sm ghost danger"><svg class="ico-sm"><use href="#i-trash"/></svg> 이 조각 삭제</button>
</div>
<div class="insp-grid">
<div class="insp-field grow">
<label>날짜 형식</label>
<div class="seg">
<button>YYYY<span class="sub">2026</span></button>
<button class="on">YY<span class="sub">26</span></button>
<button>YYYYMM<span class="sub">202605</span></button>
<button>YYMM<span class="sub">2605</span></button>
<button>YYYYMMDD<span class="sub">20260515</span></button>
<button>YYMMDD<span class="sub">260515</span></button>
</div>
</div>
<div class="insp-field grow">
<label>초기화 주기 (규칙 전체)</label>
<div class="seg">
<button>안 함</button>
<button>매일</button>
<button class="on">매월</button>
<button>매년</button>
</div>
<span class="hint">매월 1일 00:00 에 순번이 1 부터 다시 시작</span>
</div>
</div>
</div>
<!-- PALETTE -->
<div class="palette">
<span class="lbl">조각 추가:</span>
<button class="palette-item"><svg><use href="#i-type"/></svg> 고정 글자</button>
<button class="palette-item"><svg><use href="#i-cal"/></svg> 날짜</button>
<button class="palette-item"><svg><use href="#i-hash"/></svg> 순번</button>
<button class="palette-item"><svg><use href="#i-edit"/></svg> 고정 숫자</button>
<button class="palette-item"><svg><use href="#i-layers"/></svg> 카테고리</button>
<button class="palette-item"><svg><use href="#i-link"/></svg> 참조</button>
</div>
</div>
<!-- 4. FOOTER STATS — 한 줄 요약 톤 -->
<div class="foot-split">
<div>
<div class="foot-row">
<span class="ttl">연결된 컬럼 <span class="n">1</span></span>
<button class="act">컬럼 연결 관리</button>
</div>
<div class="foot-row" style="display:block;">
<span class="val">user_info<span class="arr">·</span>order_no</span>
<div class="sub">단일 연결 · 향후 N:M 확장 예정</div>
</div>
</div>
<div>
<div class="foot-row">
<span class="ttl">시퀀스 현황</span>
<button class="act">수동 수정</button>
</div>
<div class="foot-row" style="display:block;">
<span class="val">142<span class="arr"></span><span class="nx">143</span></span>
<div class="sub">매월 1일 초기화 · 다음 리셋 2026-06-01 00:00</div>
</div>
</div>
</div>
<!-- 5. SAVE BAR -->
<div class="savebar">
<div class="l">
<span class="unsaved"><span class="d"></span>저장하지 않은 변경 1건</span>
</div>
<div style="display:flex; gap:.35rem;">
<button class="btn ghost"><svg class="ico"><use href="#i-rotate"/></svg> 되돌리기</button>
<button class="btn primary"><svg class="ico"><use href="#i-save"/></svg> 저장 <span class="kbd" style="background:rgba(255,255,255,.2);color:#fff;border-color:transparent;">⌘ S</span></button>
</div>
</div>
</main>
</div>
</div>
<script>
// 사이드바 선택
document.querySelectorAll('.side-item').forEach(el => {
el.addEventListener('click', () => {
document.querySelectorAll('.side-item.on').forEach(e => e.classList.remove('on'));
el.classList.add('on');
});
});
// pipe-block 선택
document.querySelectorAll('.pipe-block').forEach(el => {
el.addEventListener('click', () => {
document.querySelectorAll('.pipe-block.sel').forEach(e => e.classList.remove('sel'));
el.classList.add('sel');
});
});
// seg 클릭
document.querySelectorAll('.seg').forEach(seg => {
seg.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', () => {
seg.querySelectorAll('button.on').forEach(b => b.classList.remove('on'));
btn.classList.add('on');
});
});
});
// filt 클릭
document.querySelectorAll('.filt').forEach(el => {
el.addEventListener('click', () => {
document.querySelectorAll('.filt.on').forEach(e => e.classList.remove('on'));
el.classList.add('on');
});
});
// 다크 토글
document.addEventListener('keydown', e => {
if ((e.metaKey || e.ctrlKey) && e.key === 'j') {
e.preventDefault();
const t = document.documentElement.getAttribute('data-theme');
document.documentElement.setAttribute('data-theme', t === 'dark' ? 'light' : 'dark');
}
});
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,149 @@
# 2026-05-20 Claude Prompt — Table Canonical Input-Parity Cleanup
이 문서는 `table` 계열만 대상으로 하는 대형 작업용 Claude 프롬프트다.
운영 원칙:
- 큰 refactor 는 Claude 프롬프트로 진행한다.
- Codex 는 프롬프트 작성, 작은 follow-up, 검증/리뷰, 좁은 패치만 직접 처리한다.
---
## 바로 실행할 명령
```text
/goal Read notes/gbpark/2026-05-20-table-canonical-input-parity-claude-prompt.md, notes/gbpark/2026-05-08-input-canonical-migration.md, notes/gbpark/2026-05-12-codex-handoff-input-canonical.md, and notes/gbpark/2026-05-19-canonical-data-view-cleanup-followup.md. Then finish the table canonical cleanup to the same completion standard as canonical input: required v2-table-list/table-list behavior must be absorbed into canonical table, legacy/v2 table runtime aliases/fallback/schema/config-panel paths must be removed, FieldConfig/DataPort/sourceProvider/dataReceiver behavior must be preserved or reimplemented in canonical table, v2-input/v2-select must not be reintroduced, git diff --check must pass, backend compileJava must pass if backend is touched, and the final report must prove all remaining table-list/v2-table-list matches are either zero in code paths or explicitly non-runtime docs only.
```
---
## 보조 프롬프트
```text
You are working in /Users/gbpark/invyone.
This is a large refactor. The goal is not another "preserve and classify" pass. The goal is to bring table cleanup to the same completion standard as the canonical input cleanup.
Read these files first:
- notes/gbpark/2026-05-20-table-canonical-input-parity-claude-prompt.md
- notes/gbpark/2026-05-08-input-canonical-migration.md
- notes/gbpark/2026-05-12-codex-handoff-input-canonical.md
- notes/gbpark/2026-05-19-canonical-data-view-cleanup-followup.md
Key precedent from canonical input:
- v2-input / v2-select were not kept as runtime compatibility aliases.
- v2-input / v2-select renderer folders, component bodies, config panels, alias/fallback/schema/default paths were removed.
- Required behavior was absorbed into canonical input.
- DB layout JSON migration SQL was not written.
- New-solution code paths use canonical input.
Apply that same policy to table:
- canonical id is "table".
- v2-table-list / table-list are removal targets, not long-term runtime aliases.
- Do not keep weak compatibility shims just to keep old type names alive.
- If an old behavior is needed, implement or move it into canonical table or a neutral helper with no v2-table-list/table-list component identity.
- Do not write DB layout JSON migration SQL unless the user explicitly asks later.
Non-negotiable rules:
1. Do not reintroduce v2-input, v2-select, V2InputRenderer, V2SelectRenderer, V2Input.tsx, V2Select.tsx, EntityPicker, or EntitySearchModal.
2. Do not shrink FieldConfig, DataPort, sourceProvider, dataReceiver, selectedRow/selectedRows, searchParams, or refreshTrigger contracts.
3. Do not use broad git reset/checkout/clean. Preserve unrelated user changes.
4. Do not delete domain/special components such as modal-repeater-table, simple-repeater-table, repeat-screen-modal, tax-invoice-list, universal-form-modal table sections unless equivalent canonical behavior is implemented and active references are updated.
5. Do not leave v2-table-list/table-list as a runtime alias/fallback/schema/config-panel path in final code unless you stop and report a concrete blocker. The intended final state is removal.
6. Keep the work scoped to table canonical cleanup. Do not revive the broader stats/container cleanup.
Current known table state:
- New creation path is canonical table:
frontend/lib/registry/components/table/index.ts
- table-list/ and v2-table-list/ shell folders are already deleted.
- The old runtime still survives under:
frontend/lib/registry/components/table/_shared/TableListComponent.tsx
frontend/lib/registry/components/table/_shared/V2TableListComponent.tsx
frontend/lib/registry/components/table/_shared/V2TableListContainerWrapper.tsx
frontend/lib/registry/components/table/_shared/TableListConfigPanel.tsx
frontend/lib/registry/components/table/_shared/tableListConfigTypes.ts
- TableComponent currently early-delegates old raw types:
rawType === "table-list" -> LegacyTableListWrapper
rawType === "v2-table-list" -> V2TableListContainerWrapper
- Aliases/schema/default/config routing still exist in:
frontend/lib/registry/DynamicComponentRenderer.tsx
frontend/lib/utils/templateMigrate.ts
frontend/lib/utils/getComponentConfigPanel.tsx
frontend/lib/schemas/componentConfig.ts
frontend/lib/utils/componentTypeUtils.ts
- InvDataConfigPanel still has a v2-table-list branch and imports V2TableListConfigPanel.
- V2List.tsx builds a component object with type: "table-list".
- repeat-container / v2-repeat-container use dataSourceType = "table-list"; if this is not a component id, rename it to a clearer non-component enum such as "legacyTableSelection" or "tableSelectionSource" and update references.
Required implementation phases:
Phase 0 - Inventory and baseline
- Run:
git status --short
rg -n "v2-table-list|table-list|V2TableList|TableListComponent|TableListConfigPanel|V2TableListContainerWrapper" frontend/lib frontend/components frontend/app frontend/types
rg -n "v2-input|v2-select|V2InputRenderer|V2SelectRenderer" frontend/lib frontend/components frontend/app frontend/types frontend/styles
- Summarize each match into: runtime path, config path, schema/default path, type/helper path, docs/comment only, domain enum.
Phase 1 - Canonical Table parity inventory
- Compare canonical TableComponent / InvTableConfigPanel against _shared/TableListComponent and _shared/V2TableListComponent.
- Build a short checklist in the final report for these old behaviors:
selection, multi-select, checkbox, sorting, pagination, column visibility/order/width/align, searchable/filterable per column, linked filter, exclude filter, entity label display, category/code select, date picker fallback, image url/fallback, inline edit, row actions, context menu, export Excel, card mode, GroupSum, ResizeObserver responsive card fallback, DataProvider/DataReceiver, FieldConfig adapter, selectedTable/tableName/dbTable compatibility.
- Implement missing behavior in canonical table, or move reusable internals to neutral files under frontend/lib/registry/components/table/ with neutral names. Do not preserve old component identity.
Phase 2 - Canonical runtime absorption
- Remove early delegation from TableComponent after parity is implemented.
- Delete or empty old runtime files only when imports are gone:
_shared/TableListComponent.tsx
_shared/V2TableListComponent.tsx
_shared/V2TableListContainerWrapper.tsx
_shared/TableListConfigPanel.tsx
- Keep neutral helpers only if they do not encode old component identity:
SingleTableWithSticky.tsx may survive if it is a neutral internal table view, but rename/comments/types must not imply table-list component ownership.
CardModeRenderer.tsx may survive as a neutral card table helper if needed.
tableListConfigTypes.ts should be renamed or absorbed if it remains a runtime type dependency.
Phase 3 - Config and Studio paths
- InvTableConfigPanel must become the only table config panel.
- Remove InvDataConfigPanel v2-table-list branch and V2TableListConfigPanel import.
- Remove V2TableListConfigPanel.tsx if no active imports remain.
- Migrate V2List.tsx away from TableListComponent/table-list object identity or delete/deprecate it if no active canonical path needs it.
- Update ComponentsPanel, V2PropertiesPanel, DetailSettingsPanel, ActionTab, DataTab, InvLegacyButtonConfigPanel, ButtonPrimaryComponent, TabsWidget, RealtimePreviewDynamic, ScreenNode, screen pages, and buttonActions so they use canonical table helpers without old component ids.
Phase 4 - Alias/schema/type cleanup
- Remove table-list/v2-table-list aliases from:
DynamicComponentRenderer.LEGACY_TO_UNIFIED
templateMigrate.LEGACY_TO_UNIFIED
getComponentConfigPanel.CONFIG_PANEL_ALIAS
componentTypeUtils.TABLE_LIKE_COMPONENT_TYPES
- Remove v2-table-list schema/default from:
frontend/lib/schemas/componentConfig.ts
- Remove old table component type examples from:
frontend/types/invyone-component.ts
frontend/types/screen-management.ts
frontend/types/v2-components.ts
frontend/types/component-events.ts
- If a value is a domain enum rather than component id, rename it away from table-list so grep is not ambiguous.
Phase 5 - Verification
- Required commands:
git diff --check
rg -n "v2-input|v2-select|V2InputRenderer|V2SelectRenderer" frontend/lib frontend/components frontend/app frontend/types frontend/styles
rg -n "EntityPicker|entity-picker|EntitySearchModal" frontend/lib/registry/components/input frontend/components/v2/config-panels/InvFieldConfigPanel.tsx
rg -n "v2-table-list|table-list|V2TableList|TableListComponent|TableListConfigPanel|V2TableListContainerWrapper" frontend/lib frontend/components frontend/app frontend/types
rg -n "components/v2-table-list|components/table-list|./v2-table-list|./table-list" frontend
- Expected:
input forbidden tokens stay 0.
table old runtime/config/schema/import tokens should be 0 in code paths.
Any remaining table-list/v2-table-list matches must be docs-only or explicitly justified non-runtime comments. Prefer removing/rewriting stale comments.
- Run targeted typecheck if practical:
cd frontend && npx tsc --noEmit --pretty false
If global existing errors remain, report only whether modified files introduced new errors.
- Run backend compile only if backend touched:
cd backend-spring && ./gradlew compileJava
Final report requirements:
- List files changed and deleted.
- State whether final code still has any table-list/v2-table-list runtime/config/schema aliases.
- Include the rg results above.
- Include the old behavior parity checklist and mark implemented / not applicable / unresolved.
- If any blocker prevents full input-parity completion, stop with exact file/line blockers and do not pretend the cleanup is complete.
```
@@ -0,0 +1,235 @@
# Table Canonical Cleanup — Phase B.2 + B.3 + D.9 Claude Prompt
You are working in `/Users/gbpark/invyone`.
Reference:
- `notes/gbpark/2026-05-20-table-canonical-cleanup-plan.md`
- §8 Phase B.2
- §8 Phase B.3
- §8 Phase D.9
- §4 dependency table
Current status:
- B.1 / B.4 complete.
- C.1 ~ C.5 complete.
- D.1 ~ D.8 complete.
- D.9 is blocked by B.2 + B.3, so this session should finish the prerequisite wiring and then complete the canonical TableComponent D.9 wiring.
Important workspace rule:
- Do not revert or touch unrelated dirty files.
- Known unrelated dirty files include:
- `frontend/app/(main)/admin/systemMng/numberingRuleList/page.tsx`
- `frontend/components/layout/TabBar.tsx`
- `frontend/styles/v5-layout.css`
- `notes/gbpark/2026-05-20-control-ide-refactor.md`
- `notes/gbpark/2026-05-20-numbering-rule-*.html`
- `open-design/`
- Do not edit `_shared/TableListComponent.tsx` or `_shared/V2TableListComponent.tsx`; read them only as parity references.
- Do not reintroduce `v2-input`, `v2-select`, `EntityPicker`, or `EntitySearchModal`.
- Preserve the canonical TableComponent early delegation branch for legacy table-list / v2-table-list.
Goal:
Canonical `frontend/lib/registry/components/table/TableComponent.tsx` must expose the same data-transfer and split-panel runtime contracts that legacy `_shared/TableListComponent.tsx` and `_shared/V2TableListComponent.tsx` expose:
1. ScreenContext data provider registration.
2. ScreenContext data receiver registration.
3. SplitPanelContext receiver registration when mounted inside a split panel.
4. Left-panel row selection should set selected left data for right-panel consumers.
5. Right-panel linked filters should merge split-panel linked filter values into table search where compatible.
6. Provider absence must be safe. Canonical TableComponent must still mount outside ScreenContext / SplitPanelContext.
Primary file scope:
- `frontend/lib/registry/components/table/TableComponent.tsx`
Allowed only if needed:
- `frontend/lib/registry/components/table/useTableData.ts`
- only to expose a minimal local-data setter needed by `DataReceivable.receiveData`.
- `frontend/types/data-transfer.ts`
- additive type-only changes only. Do not shrink existing contracts.
- `frontend/contexts/SplitPanelContext.tsx`
- only if you prove canonical cannot be supported by consuming the existing context.
Prefer not to edit:
- `frontend/lib/registry/components/split-panel-layout/SplitPanelContext.tsx`
- `frontend/lib/registry/components/v2-split-panel-layout/SplitPanelContext.tsx`
Read first:
- `frontend/types/data-transfer.ts`
- `frontend/contexts/ScreenContext.tsx`
- `frontend/contexts/SplitPanelContext.tsx`
- `frontend/lib/registry/components/table/_shared/TableListComponent.tsx`
- around `useScreenContextOptional`
- around `useSplitPanelContext`
- around `DataProvidable`
- around `DataReceivable`
- around split-panel receiver registration
- around row click / `setSelectedLeftData`
- `frontend/lib/registry/components/table/_shared/V2TableListComponent.tsx`
- same areas as above
- current canonical:
- `frontend/lib/registry/components/table/TableComponent.tsx`
- `frontend/lib/registry/components/table/useTableData.ts`
Implementation requirements:
## 1. Import / context wiring
In canonical `TableComponent.tsx`, wire:
- `useSplitPanelContext` and `SplitPanelPosition` from `@/contexts/SplitPanelContext`.
- `DataProvidable`, `DataReceivable`, `DataReceiverConfig`, `DataReceivableComponentType`, and `EntityJoinColumnMeta` from `@/types/data-transfer` as needed.
There is already `screenContext = useScreenContextOptional()` from D.2. Reuse it.
Derive the current split position the same way legacy does:
- `screenContext?.split_panel_position`
- else `splitPanelContext?.getPositionByScreenId(Number(props.screenId))`
- else `null`
Do not require `props.screenId` to exist. Guard it.
## 2. DataProvidable parity
Create a stable provider object with `useMemo<DataProvidable>`.
Contract:
- `component_id`: canonical component id (`component.id` / `_componentId`).
- `component_type`: prefer `"table"` for canonical. If you choose `"table-list"` for legacy compatibility, explain why in report.
- `table_name`: effective canonical `tableName`.
- `getSelectedData()`:
- use canonical selected row state (`selectedRows` index set) and current runtime `rows` / `tableData.data`.
- preserve the same semantics as current selection callbacks.
- `getAllData()`:
- return current runtime rows.
- `clearSelection()`:
- clear `selectedRows` and `selectedRowIdx`.
- call existing selection emission logic if needed so external selected row callbacks stay consistent.
- `getEntityJoinColumns()`:
- return snake_case `EntityJoinColumnMeta[]`.
- use `sourceColumns` metadata:
- `additionalJoinInfo` maps to `{ source_table, source_column, join_alias, reference_table }`.
- also preserve useful `entityJoinInfo` if it has enough fields.
- no crashes on missing metadata.
## 3. DataReceivable parity
Create a stable receiver object with `useMemo<DataReceivable>`.
Contract:
- `component_id`: same as provider.
- `component_type`: `"table"` as `DataReceivableComponentType`.
- `getData()` returns current runtime rows.
- `receiveData(data, config)` supports at least:
- `append`
- `replace`
- `merge`
Legacy behavior mutates local table data. Canonical `useTableData` currently exposes data but not a setter. Choose the smallest safe implementation:
- Preferred: extend `useTableData.ts` with a minimal setter API (`setLocalData` or similar) and update `UseTableDataResult`.
- Alternative: keep a local override state inside `TableComponent` only if it does not break pagination/refresh semantics.
Rules:
- Do not refetch just to apply received data.
- `merge` should merge by `id` when present, then by `${tableName}_id` when present, then append with a generated stable-ish fallback key.
- If config has mapping rules, apply them conservatively:
- source field -> target field.
- default value if source missing.
- required missing values should skip that field, not crash the whole receive.
- Keep mode unknown cases safe.
## 4. ScreenContext registration
Register provider and receiver when all are true:
- not design mode
- `screenContext` exists
- component id exists
Cleanup:
- unregister both on unmount / dependency change.
Use stable `useMemo` / `useEffect` deps. Avoid infinite re-register loops caused by non-memoized objects.
## 5. SplitPanelContext receiver registration
If `splitPanelContext`, component id, and current split position exist:
- register receiver using `splitPanelContext.registerReceiver(position, componentId, receiver)`.
- receiver should forward to canonical `DataReceivable.receiveData(...)` with `target_component_type: "table"` and the incoming mode.
- unregister on cleanup.
Do not edit legacy split-panel provider unless the existing context cannot support canonical registration. If you do edit it, make the change additive and explain why.
## 6. Left-panel selected data
When a row is selected in canonical table and current split position is `"left"`:
- if `splitPanelContext.disable_auto_data_transfer` is false, call `splitPanelContext.setSelectedLeftData(row)`.
- if selection is cleared, call `setSelectedLeftData(null)`.
Integrate this into the existing `handleRowClick`, `handleCheckboxToggle`, or central `emitSelection` path so both single and multi-selection stay coherent.
Do not break existing `onRowSelect` / `onSelectedRowsChange` callbacks.
## 7. Right-panel linked filters
When current split position is `"right"` and SplitPanelContext has linked filters:
- merge `splitPanelContext.getLinkedFilterValues()` into the canonical active search object.
- preserve D.2 search merge priority:
- props.searchParams
- TableOptions filters
- linkedFilters from ScreenContext providers
- searchApplied
- Add split-panel linked filter values at the same layer as linkedFilters, but do not let empty values override meaningful searchApplied.
- Sanitize values using the existing `_sanitizeSearchValues` path if available.
Also support `splitPanelContext.selected_left_data` fallback for configured right-panel filters if current D.2 `excludeFilter` / linked filter logic can reuse it without large changes.
## 8. Left-panel added item filtering
Legacy table filters out items whose ids are in `splitPanelContext.added_item_ids` for left panel display.
Implement only if it is straightforward in canonical without corrupting pagination/total:
- safe option: filter only `rows` render output for current page when current split position is `"left"`.
- do not mutate `tableData.data`.
- do not change server total.
If not implemented, explicitly report it as remaining parity gap.
## 9. Forbidden work in this session
- Do not implement D.10 autoGeneration.
- Do not migrate V2List / E phases.
- Do not delete `v2-table-list`, `table-list`, aliases, schemas, or registry entries.
- Do not touch config panel parity unless a type compile error forces a tiny additive fix.
- Do not add broad new abstractions.
- Do not add network dependencies.
- Do not add toast dependencies.
Validation:
Run:
```bash
git diff --check
rg -n "DataProvidable|DataReceivable|useSplitPanelContext|registerDataProvider|registerDataReceiver|registerReceiver|setSelectedLeftData|getLinkedFilterValues" frontend/lib/registry/components/table frontend/contexts frontend/types
cd frontend && npx tsc --noEmit --pretty false 2>&1 | rg "^(lib/registry/components/table/(TableComponent|useTableData|types)|contexts/(ScreenContext|SplitPanelContext)|types/data-transfer)"
```
Expected:
- `git diff --check` passes.
- Targeted tsc grep has no output.
- If full `tsc` has unrelated baseline errors, list only errors that touch the scoped files above.
Report format:
1. Files changed with line-count scale.
2. Which B.2 option was used for SplitPanel support:
- existing context consumed only
- context additive change
- other
3. DataProvidable contract summary.
4. DataReceivable contract summary.
5. SplitPanel behavior summary:
- receiver registration
- left selected data
- right linked filters
- added item filtering implemented or deferred
6. Verification results.
7. Remaining parity gaps, if any.
@@ -0,0 +1,237 @@
# Phase C.3 Claude Prompt — Table Canonical Filter Config Parity
You are working in `/Users/gbpark/invyone`.
Reference:
- `notes/gbpark/2026-05-20-table-canonical-cleanup-plan.md` §8 Phase C.3
- Already completed in the current worktree: B.1, B.4, C.1, C.2, C.5
- Codex follow-up patches already applied:
- `useTableData.ts` keeps `defaultSort` initial state instead of resetting it on mount.
- `InvTableConfigPanel.tsx` keeps `striped/hoverable` aliases synchronized with `tableStyle.alternateRows/hoverEffect`.
Goal:
Complete **Phase C.3 only**: canonical table config parity for filters.
Absorb the old table filter config surface into canonical `TableConfig` and expose it in canonical `InvTableConfigPanel`, while leaving actual runtime filter execution to Phase D.2.
## Scope
Primary files:
- `frontend/lib/registry/components/table/types.ts`
- `frontend/lib/registry/components/table/InvTableConfigPanel.tsx`
Allowed narrow touch:
- `frontend/lib/registry/components/table/TableComponent.tsx`
- only for `fromProps` merge and DOM prop filtering of new config fields
- no runtime filter execution in this phase
Reference-only files:
- `frontend/lib/registry/components/table/_shared/tableListConfigTypes.ts`
- `frontend/types/screen-management.ts`
- `frontend/components/screen/config-panels/DataFilterConfigPanel.tsx`
Do not modify old runtime bodies:
- `frontend/lib/registry/components/table/_shared/TableListComponent.tsx`
- `frontend/lib/registry/components/table/_shared/V2TableListComponent.tsx`
- `frontend/lib/registry/components/table/_shared/TableListConfigPanel.tsx`
## Required Type Work
In `frontend/lib/registry/components/table/types.ts`:
1. Import `DataFilterConfig` from `@/types/screen-management` as a type-only import.
2. Add canonical filter interfaces matching the old config shape, but name them canonically to avoid collisions:
- `TableFilterConfig`
- `TableLinkedFilterConfig`
- `TableExcludeFilterConfig`
Use the old definitions in `_shared/tableListConfigTypes.ts` as the source of truth:
```ts
FilterConfig {
enabled: boolean;
filters: Array<{
columnName: string;
widgetType: string;
label: string;
gridColumns: number;
numberFilterMode?: "exact" | "range";
codeInfo?: string;
referenceTable?: string;
referenceColumn?: string;
displayColumn?: string;
}>;
bottomSpacing?: number;
}
LinkedFilterConfig {
sourceComponentId: string;
sourceField?: string;
targetColumn: string;
operator?: "equals" | "contains" | "in";
enabled?: boolean;
}
ExcludeFilterConfig {
enabled: boolean;
referenceTable: string;
referenceColumn: string;
sourceColumn: string;
filterColumn?: string;
filterValueSource?: "url" | "formData" | "parentData";
filterValueField?: string;
}
```
3. Add these fields to `TableConfig` with clear docstrings:
- `filter?: TableFilterConfig`
- `linkedFilters?: TableLinkedFilterConfig[]`
- `excludeFilter?: TableExcludeFilterConfig`
- `dataFilter?: DataFilterConfig`
Docstrings must clearly say:
- C.3 is config parity + ConfigPanel editing.
- Runtime application is Phase D.2.
- `dataFilter` reuses the shared `DataFilterConfig` contract from `screen-management.ts`.
## Required ConfigPanel Work
In `frontend/lib/registry/components/table/InvTableConfigPanel.tsx`:
Add a new collapsed CP group for filters. Since C.5 already added groups `⑥` to `⑨`, do not churn existing numbering. Add this group after `⑨ 툴바 버튼` and before existing `동작 옵션`:
```tsx
<CPGroup title="⑩ 필터" defaultOpen={false}>...</CPGroup>
```
Use the existing CP primitives and local compact row style:
- `CPGroup`, `CPRow`, `CPText`, `CPSelect`, `CPSegment`, `CPNumber`, `CPSwitch`, `Hint`
- Native `<input style={inputStyle()}>` is acceptable where `CPText` is too constrained.
- Do not import `DataFilterConfigPanel`; it is shadcn/Tailwind styled and does not match this CP panel.
### UI Sections Inside `⑩ 필터`
Keep the UI compact. Avoid nested `CPGroup` inside this `CPGroup`.
1. **검색 필터 위젯 (`filter`)**
- switch: `filter.enabled`
- number: `filter.bottomSpacing`
- list editor for `filter.filters`
- each row edits:
- `columnName` via `CPSelect` from available columns
- `widgetType` via `CPSelect` (`text`, `number`, `date`, `select`, `entity`, `code`, `checkbox`)
- `label` via `CPText` or input
- `gridColumns` via `CPNumber`
- optional `numberFilterMode` via `CPSegment` when `widgetType === "number"`
- optional `codeInfo`, `referenceTable`, `referenceColumn`, `displayColumn` as compact text inputs
- add/remove buttons can follow the existing column editor button style.
2. **연결 필터 (`linkedFilters`)**
- list editor for `linkedFilters`
- each row edits:
- `enabled`
- `sourceComponentId`
- `sourceField`
- `targetColumn` via available columns
- `operator` via `CPSegment` (`equals`, `contains`, `in`)
- add/remove controls.
3. **제외 필터 (`excludeFilter`)**
- switch: `excludeFilter.enabled`
- text/select controls:
- `referenceTable`
- `referenceColumn`
- `sourceColumn` via available columns
- `filterColumn`
- `filterValueSource` via `CPSegment` (`url`, `formData`, `parentData`)
- `filterValueField`
- Preserve optional empty strings as `undefined` where reasonable.
4. **데이터 필터 (`dataFilter`)**
- switch: `dataFilter.enabled`
- segment: `dataFilter.match_type` (`all`, `any`)
- compact list editor for `dataFilter.filters`
- each row edits:
- `column_name` via available columns
- `operator` via `CPSelect` using the `ColumnFilter.operator` values
- `value_type` via `CPSegment` (`static`, `category`, `code`, `dynamic`)
- `value` as text input. For `in` / `not_in`, comma text is acceptable and should become `string[]`; otherwise string.
- for `date_range_contains`, expose `range_config.start_column` and `range_config.end_column`.
- New filter default:
- `id: filter-${Date.now()}`
- first available column key or `""`
- `operator: "equals"`
- `value: ""`
- `value_type: "static"`
### Available Column Options
Build a small local helper or `useMemo` to normalize available columns from:
- canonical `columns` (`TableColumn[]`)
- `effectiveTableColumns` from connected DB metadata, if canonical columns are empty
Option shape:
- `value`: canonical key / DB column name
- `label`: display label / column name
Avoid unstable assumptions about backend metadata. Support common keys already used in this file:
- `key`
- `columnName`
- `column_name`
- `name`
- `label`
- `displayName`
## Optional TableComponent Narrow Work
In `frontend/lib/registry/components/table/TableComponent.tsx`, only if needed:
1. Add `fromProps` merge for:
- `filter`
- `linkedFilters`
- `excludeFilter`
- `dataFilter`
2. Add these fields to DOM prop filtering so they never leak onto DOM nodes.
3. Add comments that runtime application is Phase D.2.
Do **not** pass these fields to `useTableData` in C.3.
Do **not** implement linked filter polling, SplitPanelContext reading, excludeFilter API params, or AdvancedSearchFilters here.
## Forbidden
- Do not modify `_shared/TableListComponent.tsx` or `_shared/V2TableListComponent.tsx`.
- Do not delete old table-list/v2-table-list code.
- Do not remove early delegation in canonical `TableComponent.tsx`.
- Do not touch schema, alias routing, template migration, component registry, or input/select/entity components.
- Do not import or revive `v2-input`, `v2-select`, `EntityPicker`, `EntitySearchModal`.
- Do not do Phase D.2 runtime filtering in this phase.
- Do not do Phase C.4 actions.
- Do not change unrelated untracked notes, especially `notes/gbpark/2026-05-20-control-ide-refactor.md`.
## Verification
Run:
```bash
git diff --check
rg -n "TableFilterConfig|TableLinkedFilterConfig|TableExcludeFilterConfig|filter\\?:|linkedFilters|excludeFilter|dataFilter" frontend/lib/registry/components/table
cd frontend && npx tsc --noEmit --pretty false 2>&1 | rg "lib/registry/components/table/(InvTableConfigPanel|TableComponent|types)|types/screen-management|DataFilterConfig"
```
Expected:
- `git diff --check` has no output.
- Targeted `tsc` grep shows no new errors for changed table files.
- Existing unrelated errors elsewhere may still appear if running full `tsc`; report them separately.
## Report Format
After finishing, report:
- changed files and line-count summary
- exact fields added to `TableConfig`
- which filter editors were added in `InvTableConfigPanel`
- confirmation that runtime filtering was intentionally deferred to D.2
- validation command results
- any existing unrelated `tsc` errors, clearly marked unrelated
@@ -0,0 +1,204 @@
# Phase C.4 Claude Prompt — Table Canonical Action Config Parity
You are working in `/Users/gbpark/invyone`.
Reference:
- `notes/gbpark/2026-05-20-table-canonical-cleanup-plan.md` §8 Phase C.4
- Already completed in the current worktree: B.1, B.4, C.1, C.2, C.3, C.5
- C.3 review follow-up already applied by Codex:
- filter column options now remove empty/duplicate values
- dataFilter operator changes normalize `value` between string and string[]
- C.3 is still config-only; runtime filter application remains D.2
Goal:
Complete **Phase C.4 only**: canonical table config parity for row actions / bulk actions.
Absorb old `ActionConfig` into canonical `TableConfig` and expose it in canonical `InvTableConfigPanel`, while leaving actual runtime action execution to Phase D.4.
## Scope
Primary files:
- `frontend/lib/registry/components/table/types.ts`
- `frontend/lib/registry/components/table/InvTableConfigPanel.tsx`
Allowed narrow touch:
- `frontend/lib/registry/components/table/TableComponent.tsx`
- only for `fromProps` merge and DOM prop filtering of new action config fields
- no runtime action execution in this phase
Reference-only files:
- `frontend/lib/registry/components/table/_shared/tableListConfigTypes.ts`
- `frontend/lib/registry/components/table/_shared/TableListConfigPanel.tsx`
- `frontend/lib/registry/components/table/_shared/TableListComponent.tsx`
- `frontend/lib/registry/components/table/_shared/V2TableListComponent.tsx`
Do not modify old runtime bodies:
- `frontend/lib/registry/components/table/_shared/TableListComponent.tsx`
- `frontend/lib/registry/components/table/_shared/V2TableListComponent.tsx`
- `frontend/lib/registry/components/table/_shared/TableListConfigPanel.tsx`
Also do not touch unrelated current dirty files:
- `frontend/components/layout/TabBar.tsx`
- `frontend/styles/v5-layout.css`
- `notes/gbpark/2026-05-20-control-ide-refactor.md`
## Required Type Work
In `frontend/lib/registry/components/table/types.ts`:
1. Add canonical action interfaces matching the old config shape, but use canonical names:
- `TableActionType`
- `TableActionItemConfig`
- `TableActionConfig`
Source of truth from `_shared/tableListConfigTypes.ts`:
```ts
ActionConfig {
showActions: boolean;
actions: Array<{
type: "view" | "edit" | "delete" | "custom";
label: string;
icon?: string;
color?: string;
confirmMessage?: string;
targetScreen?: string;
}>;
bulkActions: boolean;
bulkActionList: string[];
}
```
2. Add to `TableConfig`:
```ts
actions?: TableActionConfig;
```
Docstring must say:
- C.4 is config parity + ConfigPanel editing.
- Runtime rendering/execution is Phase D.4.
- This is table-level row/bulk actions, separate from `cardStyle.showActions` and `cardColumnMapping.actionColumns`.
## Required ConfigPanel Work
In `frontend/lib/registry/components/table/InvTableConfigPanel.tsx`:
Add a new collapsed CP group after `⑩ 필터` and before existing `동작 옵션`:
```tsx
<CPGroup title="⑪ 액션" defaultOpen={false}>...</CPGroup>
```
Use existing CP primitives and local compact helpers:
- `CPGroup`, `CPRow`, `CPText`, `CPSelect`, `CPSegment`, `CPNumber`, `CPSwitch`, `Hint`
- Native `<input style={inputStyle()}>` is acceptable where compact text fields are easier.
- Reuse C.3 helpers if appropriate (`SubSectionHeading`, `AddRowButton`, `RemoveRowButton`), but keep write scope in this file.
### UI Sections Inside `⑪ 액션`
1. **Row actions**
- switch: `actions.showActions`
- list editor for `actions.actions`
- each row edits:
- `type` via `CPSegment` or `CPSelect`: `view`, `edit`, `delete`, `custom`
- `label`
- `icon`
- `color`
- `confirmMessage`
- `targetScreen`
- add/remove controls.
- New row default:
- `type: "view"`
- `label: "보기"`
2. **Bulk actions**
- switch: `actions.bulkActions`
- compact editor for `actions.bulkActionList`
- simple comma-separated text input is acceptable, but trim empty entries and store `string[]`.
- Help text should clarify runtime execution is D.4.
### Defaults / Patch Helper
Add a local `patchActions` helper near the C.3 helpers:
```ts
const patchActions = (next: Partial<NonNullable<TableConfig["actions"]>>) =>
patch({
actions: {
showActions: false,
actions: [],
bulkActions: false,
bulkActionList: [],
...current.actions,
...next,
} as any,
});
```
Do not overwrite existing `actions.actions` while toggling `showActions`.
Do not overwrite existing `bulkActionList` while toggling `bulkActions`.
## Optional TableComponent Narrow Work
In `frontend/lib/registry/components/table/TableComponent.tsx`, only if needed:
1. Add `fromProps` merge for:
- `actions`
2. Add `actions` to DOM prop filtering so it never leaks onto DOM nodes.
3. Add a short comment that runtime rendering/execution is Phase D.4.
Do **not** render action columns or buttons in C.4.
Do **not** wire navigation, modal opening, delete API, custom handlers, or bulk selection execution here.
## Relationship With Existing Card Actions
Current canonical types already contain:
- `cardStyle.showActions`
- `cardStyle.showViewButton`
- `cardStyle.showEditButton`
- `cardStyle.showDeleteButton`
- `cardColumnMapping.actionColumns`
Do not remove or rewrite those. They are card-mode presentation hints. The new `actions?: TableActionConfig` is table-level row/bulk action config parity with old `ActionConfig`.
If you add UI text, make this distinction clear in help text.
## Forbidden
- Do not modify `_shared/TableListComponent.tsx` or `_shared/V2TableListComponent.tsx`.
- Do not delete old table-list/v2-table-list code.
- Do not remove early delegation in canonical `TableComponent.tsx`.
- Do not touch schema, alias routing, template migration, component registry, or input/select/entity components.
- Do not import or revive `v2-input`, `v2-select`, `EntityPicker`, `EntitySearchModal`.
- Do not do Phase D.4 runtime action rendering/execution in this phase.
- Do not do Phase D.2 filtering or Phase D.1 column runtime.
- Do not change unrelated TabBar / v5 layout CSS dirty files.
## Verification
Run:
```bash
git diff --check
rg -n "TableActionConfig|TableActionItemConfig|TableActionType|actions\\?:|showActions|bulkActions|bulkActionList" frontend/lib/registry/components/table/{types.ts,InvTableConfigPanel.tsx,TableComponent.tsx}
cd frontend && npx tsc --noEmit --pretty false 2>&1 | rg "lib/registry/components/table/(InvTableConfigPanel|TableComponent|types)"
```
Expected:
- `git diff --check` has no output.
- Targeted `tsc` grep shows no new errors for changed table files.
- Existing unrelated errors elsewhere may still appear if running full `tsc`; report them separately.
## Report Format
After finishing, report:
- changed files and line-count summary
- exact action types/interfaces added
- `TableConfig` fields added
- which editors were added in `InvTableConfigPanel`
- confirmation that runtime action rendering/execution was intentionally deferred to D.4
- validation command results
- unrelated dirty files left untouched
@@ -0,0 +1,234 @@
# Phase D.1 Claude Prompt — Table Canonical Column Runtime
You are working in `/Users/gbpark/invyone`.
Reference:
- `notes/gbpark/2026-05-20-table-canonical-cleanup-plan.md` §8 Phase D.1 + §9.D.1
- Already completed in the current worktree: B.1, B.4, C.1, C.2, C.3, C.4, C.5
- C.3/C.4 are config-only. Do not start D.2 filters or D.4 actions.
Goal:
Complete **Phase D.1 only**: canonical `TableComponent.tsx` runtime support for the column system.
Implement runtime support for:
- column visibility from config + TableOptions UI callback
- `hidden` behavior: design mode shows dimmed column, runtime hides it
- `fixed: "left" | "right"` sticky columns
- `fixedOrder` ordering inside fixed groups
- `horizontalScroll`
- `autoWidth`
- `stickyHeader`
Keep the canonical table lightweight. Do not replace the whole renderer unless absolutely necessary.
## Scope
Primary files:
- `frontend/lib/registry/components/table/TableComponent.tsx`
- `frontend/lib/registry/components/table/types.ts`
Reference-only files:
- `frontend/lib/registry/components/table/_shared/SingleTableWithSticky.tsx`
- `frontend/lib/registry/components/table/_shared/tableListConfigTypes.ts`
- `frontend/types/table-options.ts`
- `frontend/components/screen/table-options/ColumnVisibilityPanel.tsx`
- `frontend/components/screen/table-options/TableSettingsModal.tsx`
Do not modify old runtime bodies:
- `frontend/lib/registry/components/table/_shared/TableListComponent.tsx`
- `frontend/lib/registry/components/table/_shared/V2TableListComponent.tsx`
- `frontend/lib/registry/components/table/_shared/SingleTableWithSticky.tsx`
Also do not touch unrelated current dirty files:
- `frontend/components/layout/TabBar.tsx`
- `frontend/styles/v5-layout.css`
- `notes/gbpark/2026-05-20-control-ide-refactor.md`
## Required Type Work
In `frontend/lib/registry/components/table/types.ts`, add these canonical config fields to `TableConfig`:
```ts
autoWidth?: boolean;
stickyHeader?: boolean;
horizontalScroll?: {
enabled?: boolean;
maxVisibleColumns?: number;
minColumnWidth?: number;
maxColumnWidth?: number;
};
```
Docstrings must say:
- These are legacy `TableListConfig` column/runtime options absorbed into canonical config.
- D.1 wires runtime behavior.
- `horizontalScroll.maxVisibleColumns` is a layout threshold, not a hard column visibility cap.
Do not add ConfigPanel UI in D.1 unless unavoidable. Old layout JSON compatibility and runtime support are the goal.
## Required TableComponent Work
### 1. Separate Source Columns From Runtime Columns
Currently `columns` is used as both config source and render list. For D.1, separate:
- source/config columns: all configured columns after field adapter overlay, without dropping `visible === false`
- runtime/render columns: derived from source columns + user option overrides
Behavior:
- `visible === false`: hide in both design and runtime.
- `hidden === true`: in design mode keep visible but dimmed; in runtime hide.
- TableOptions visibility callback can hide/show/reorder/resize/freeze columns without mutating props.
Avoid losing ConfigPanel-only metadata (`fixed`, `hidden`, `fixedOrder`, `inputType`, `editable`, `thousandSeparator`, entity join metadata).
### 2. TableOptions Column Visibility Callback
Use `ColumnVisibility` from `@/types/table-options`.
Add local state similar to:
```ts
const [columnOptionsState, setColumnOptionsState] = useState<ColumnVisibility[]>([]);
const [frozenColumnCount, setFrozenColumnCount] = useState(0);
```
Wire registration callbacks:
```ts
onColumnVisibilityChange: setColumnOptionsState
onFrozenColumnCountChange: (count, updatedColumns) => { ... }
```
If `updatedColumns` is passed, use it to update visible state too.
Important:
- Existing TableOptions UI does not persist column visibility in localStorage; do not claim DB persistence.
- This phase only needs mounted runtime state + `tableDisplayStore` metadata sync.
- Do not change `TableOptionsContext` unless a type mismatch forces a narrow fix.
Registration columns should include all configurable source columns, with `visible` reflecting current runtime state.
### 3. Runtime Column Derivation
Create helpers inside `TableComponent.tsx` or near top-level constants:
- normalize option overrides by `column_name`
- apply visible/order/width/fixed
- derive `renderColumns`
- derive `tableOptionsColumns`
Ordering policy:
1. TableOptions callback order, if present.
2. `column.order`, if present.
3. original source array order.
4. fixed groups:
- fixed left first
- non-fixed middle
- fixed right last
- within left/right, `fixedOrder` wins if present; otherwise current order.
`ColumnVisibility.fixed` is boolean only. Treat `true` as `fixed: "left"` because the existing options UI represents frozen left columns only.
`onFrozenColumnCountChange(count)` should mark the first `count` visible columns as `fixed: "left"` for runtime state. Do not convert right-fixed columns to left unless the callback explicitly affects them through visibility state.
### 4. Sticky Left/Right Runtime CSS
Do not import `SingleTableWithSticky` wholesale. Inspect it as a reference for offset math and port only the small sticky offset logic into canonical table if needed.
Implement sticky styles for `<th>` and `<td>`:
- `position: "sticky"`
- `left` offset for left fixed columns
- `right` offset for right fixed columns
- `zIndex` higher for header, lower for cells
- stable background color so sticky cells do not become transparent over scrolled content
- light border/shadow to distinguish frozen edge
Offset is the sum of widths of preceding left fixed columns or following right fixed columns. Use the same effective column width helper that rendering uses.
### 5. Width / Auto Width / Horizontal Scroll
Add helper:
```ts
const getColumnWidth = (col: TableColumn): number => ...
```
Rules:
- explicit `col.width` wins
- `horizontalScroll.minColumnWidth` default around 120
- `horizontalScroll.maxColumnWidth` clamps explicit/default width if present
- checkbox column stays stable at 32px
- if `autoWidth === true`, allow browser natural width by not forcing `width`, but still use numeric fallback for sticky offset math
For table/container:
- existing scroll container remains the wrapper.
- `horizontalScroll.enabled` should produce a `minWidth` for the `<table>` so horizontal scrolling actually appears.
- If `maxVisibleColumns` is set and render column count is greater than it, use at least `maxVisibleColumns * minColumnWidth` or sum of effective widths, whichever is appropriate to keep columns readable.
- `horizontalScroll.maxVisibleColumns` must not drop columns.
### 6. Sticky Header
Current canonical header is always sticky. Make it configurable:
- default preserve current behavior: `stickyHeader !== false`
- if `stickyHeader === false`, header should not be sticky.
### 7. Store Sync
Update `tableDisplayStore.setTableDataForComponent` metadata to use runtime visible columns:
- `column_labels` should include source columns or render columns consistently.
- `visible_columns` should be runtime render column keys.
- `page_size` remains existing logic.
### 8. Rendering Places To Update
Use runtime render columns consistently in:
- header rendering
- row cell rendering
- empty-state colspan
- split detail panel
- `GroupedView` columns prop
- TableOptions registration
- tableDisplayStore metadata
`CardView` and `PivotView` may continue to use their existing mapping/config fields unless the current basic table columns are directly passed.
## Forbidden
- Do not modify `_shared/TableListComponent.tsx`, `_shared/V2TableListComponent.tsx`, or `_shared/SingleTableWithSticky.tsx`.
- Do not delete old table-list/v2-table-list code.
- Do not remove early delegation in canonical `TableComponent.tsx`.
- Do not touch schema, alias routing, template migration, component registry, or input/select/entity components.
- Do not import or revive `v2-input`, `v2-select`, `EntityPicker`, `EntitySearchModal`.
- Do not implement Phase D.2 filters.
- Do not implement Phase D.3 inline edit.
- Do not implement Phase D.4 action rendering/execution.
- Do not implement Phase D.6 toolbar/export/pagination UI.
- Do not change unrelated `TabBar.tsx` or `v5-layout.css` dirty files.
## Verification
Run:
```bash
git diff --check
rg -n "autoWidth|stickyHeader|horizontalScroll|onColumnVisibilityChange|onFrozenColumnCountChange|fixedOrder|hidden" frontend/lib/registry/components/table/{types.ts,TableComponent.tsx}
cd frontend && npx tsc --noEmit --pretty false 2>&1 | rg "lib/registry/components/table/(TableComponent|types)|types/table-options|TableOptionsContext"
```
Expected:
- `git diff --check` has no output.
- Targeted `tsc` grep shows no new errors for changed table files.
- Existing unrelated errors elsewhere may still appear if running full `tsc`; report them separately.
## Report Format
After finishing, report:
- changed files and line-count summary
- fields added to `TableConfig`
- how source columns vs render columns are derived
- how hidden/visible/fixed/fixedOrder are applied
- how TableOptions callbacks are wired
- what was intentionally deferred to later phases
- validation command results
- unrelated dirty files left untouched
@@ -0,0 +1,135 @@
# Table Canonical Cleanup — Phase D.10 Claude Prompt
You are working in `/Users/gbpark/invyone`.
Reference:
- `notes/gbpark/2026-05-20-table-canonical-cleanup-plan.md` §8 Phase D.10
- Completed phases: B.1, B.2, B.3, B.4, C.1~C.5, D.1~D.9.
Important workspace rule:
- Do not revert or touch unrelated dirty files.
- Known unrelated dirty files include:
- `frontend/app/(main)/admin/systemMng/numberingRuleList/page.tsx`
- `frontend/components/layout/TabBar.tsx`
- `frontend/styles/v5-layout.css`
- `notes/gbpark/2026-05-20-control-ide-refactor.md`
- `notes/gbpark/2026-05-20-numbering-rule-*.html`
- `open-design/`
- Do not edit `_shared/TableListComponent.tsx` or `_shared/V2TableListComponent.tsx`; read them only as parity references.
- Preserve canonical TableComponent early delegation for legacy `table-list` / `v2-table-list`.
- Do not start E-phase migration in this prompt.
Goal:
Finish Phase D.10 conservatively: canonical table should preserve and safely use table column `autoGeneration` metadata where there is a real row-creation path. If no authoritative create/new-row path exists in canonical `TableComponent`, do **not** invent one. In that case, implement only safe type/adapter preservation and report the runtime gap clearly.
Why this caution matters:
- `AutoGenerationUtils.generateValue()` supports `numbering_rule`, which can allocate real codes.
- Canonical `TableComponent` currently has inline cell update and DataReceivable local data override, but no clear “create new persisted row” UI/API path.
- Do not consume numbering rules on render, search, receive-only preview, or unrelated local state updates.
Read first:
- `frontend/lib/registry/components/table/TableComponent.tsx`
- `frontend/lib/registry/components/table/types.ts`
- `frontend/lib/fieldConfig/adapters.ts`
- `frontend/lib/utils/autoGeneration.ts`
- `frontend/types/screen.ts` (`AutoGenerationConfig`)
- `frontend/lib/registry/components/input/InputComponent.tsx` autoGeneration behavior
- legacy references only:
- `frontend/lib/registry/components/table/_shared/tableListConfigTypes.ts`
- `_shared/TableListComponent.tsx`
- `_shared/V2TableListComponent.tsx`
Implementation rules:
## 1. Audit first
Before coding, find whether canonical `TableComponent` has any persisted new-row/create path:
- add row button
- insert API
- create API
- paste-to-backend write
- batch edit save
- DataReceivable receive path that is explicitly persisted
If there is no persisted create path, say so in the report and keep runtime work minimal.
## 2. Type preservation
Add canonical type support if missing:
- `TableColumn.autoGeneration?: AutoGenerationConfig`
Use the existing `AutoGenerationConfig` from `@/types/screen` unless that causes import cycles. If import risk exists, use a structurally compatible type-only alias and explain.
Do not remove or rename legacy config keys.
## 3. Adapter preservation
Update `fieldsToCanonicalColumns()` in `frontend/lib/fieldConfig/adapters.ts` to preserve auto-generation metadata if it exists on field objects:
- `(f as any).autoGeneration`
- `(f as any).auto_generation`
Do not change `FieldConfig` itself unless absolutely necessary. It currently does not expose `autoGeneration`; this phase should tolerate metadata from legacy layouts without forcing a global schema change.
Optionally preserve the same metadata in `fieldsToColumns()` for legacy snake output if the mapping is obviously missing and low-risk:
- `autoGeneration`
- `auto_generation`
But do not touch legacy table runtime files.
## 4. Runtime behavior
Only implement runtime auto-generation if there is a real and narrow row creation path.
Allowed safe runtime surfaces:
- If canonical has a real create/new-row handler, fill empty generated columns immediately before create API call.
- If receiveData is explicitly used as a new-row input and persists rows, apply generation only to newly appended rows, not to existing merge targets.
Forbidden runtime surfaces:
- Do not generate values during render.
- Do not generate values during normal fetch.
- Do not generate values during inline edit commit unless the user is explicitly editing an auto-generated column and the value is empty.
- Do not allocate `numbering_rule` values for local-only `receiveData` overrides.
- Do not add a new “Add row” UI in this phase.
- Do not make paste write to backend.
If no safe persisted create path exists, runtime change should be **zero** except helper code that is unused is also discouraged. Prefer no unused helper.
## 5. Numbering rule policy
For `numbering_rule`:
- Only call `AutoGenerationUtils.generateValue()` from an explicit persisted create/save path.
- Pass useful form/row context if available.
- If path is not persisted, skip and report deferred.
For non-allocating types (`uuid`, `current_user`, `current_time`, `sequence`, `random_string`, `random_number`, `company_code`, `department`):
- They may be generated in a create path when target value is empty.
- Still do not generate on render/fetch.
## 6. Validation
Run:
```bash
git diff --check
rg -n "autoGeneration|auto_generation|AutoGenerationConfig|AutoGenerationUtils" frontend/lib/registry/components/table frontend/lib/fieldConfig frontend/types/screen.ts frontend/lib/utils/autoGeneration.ts
cd frontend && npx tsc --noEmit --pretty false 2>&1 | rg "^(lib/registry/components/table/(TableComponent|types)|lib/fieldConfig/adapters|lib/utils/autoGeneration|types/screen)"
```
Expected:
- `git diff --check` passes.
- Targeted tsc grep has no output.
- If full tsc has unrelated baseline errors, list only scoped-file errors.
Report format:
1. Files changed.
2. Audit result:
- persisted create path exists: yes/no
- if yes, where
- if no, runtime generation deferred
3. Metadata preservation summary.
4. Runtime behavior summary:
- implemented, or intentionally zero runtime changes
5. Numbering rule policy result.
6. Verification results.
7. Remaining gap before E.1, if any.
@@ -0,0 +1,234 @@
# Table Canonical Cleanup — Phase D.2 Claude Prompt
You are working in `/Users/gbpark/invyone`.
Reference:
- `notes/gbpark/2026-05-20-table-canonical-cleanup-plan.md` §8 Phase D.2
- Prior completed phases: B.1/B.4, C.1/C.2/C.3/C.4/C.5, D.1
- D.1 includes Codex follow-up fixes already present in the worktree:
- `fieldsToCanonicalColumns()` preserves `visible === false` source columns.
- canonical `sourceColumns` overlays FieldConfig columns with `componentConfig.columns` and appends config-only extra columns.
- fixed group ordering treats missing `fixedOrder` as last and stable.
- TableOptions registration uses stable callback deps, runtime column state, and `frozen_column_count`.
- `TableOptionsContext.updateTableDataCount()` writes `data_count`.
## Goal
Implement Phase D.2 filter runtime parity for canonical
`frontend/lib/registry/components/table/TableComponent.tsx`.
Canonical `TableComponent` must now handle:
- `filter` / `filter.filters[]` via `AdvancedSearchFilters`
- `linkedFilters[]` from `ScreenContext` data providers and/or explicit props/search params
- `excludeFilter` by passing the legacy-compatible API payload to `entityJoinApi.getTableDataWithJoins`
- `dataFilter` by passing the configured `DataFilterConfig` to `entityJoinApi.getTableDataWithJoins`
- TableOptions `onFilterChange` callback, replacing the current D.2 placeholder
This phase is runtime wiring only. Do not expand the ConfigPanel and do not implement D.3/D.4/D.6/D.8 behavior.
## File Scope
Allowed to edit:
- `frontend/lib/registry/components/table/TableComponent.tsx`
- `frontend/lib/registry/components/table/useTableData.ts`
Read-only references:
- `frontend/components/screen/filters/AdvancedSearchFilters.tsx`
- `frontend/lib/api/entityJoin.ts`
- `frontend/lib/registry/components/table/_shared/TableListComponent.tsx`
- `frontend/lib/registry/components/table/_shared/V2TableListComponent.tsx`
- `frontend/contexts/ScreenContext.tsx`
- `frontend/types/table-options.ts`
Avoid changing `AdvancedSearchFilters.tsx` unless TypeScript makes it unavoidable. If you must touch it, keep the edit tiny and explain why.
## Forbidden
- Do not modify old bodies:
- `frontend/lib/registry/components/table/_shared/TableListComponent.tsx`
- `frontend/lib/registry/components/table/_shared/V2TableListComponent.tsx`
- `frontend/lib/registry/components/table/_shared/V2TableListContainerWrapper.tsx`
- Do not remove early legacy/v2 delegation in canonical `TableComponent`.
- Do not touch schema, registry aliases, template migration, component lists, `V2List`, or split-panel files in this phase.
- Do not reintroduce `v2-input`, `v2-select`, EntityPicker, EntitySearchModal, or any deleted input/select path.
- Do not touch unrelated dirty files:
- `frontend/components/layout/TabBar.tsx`
- `frontend/styles/v5-layout.css`
- `frontend/app/(main)/admin/systemMng/numberingRuleList/page.tsx`
- `notes/gbpark/2026-05-20-control-ide-refactor.md`
- `notes/gbpark/2026-05-20-numbering-rule-clean-v5.html`
## Current Relevant State
`TableComponent.tsx` already merges these C.3 fields into `componentConfig` and filters them from DOM props:
- `filter`
- `linkedFilters`
- `excludeFilter`
- `dataFilter`
`useTableData.ts` currently accepts:
- `tableName`
- `page/pageSize`
- `sortBy/sortOrder`
- `search`
- `enabled`
`entityJoinApi.getTableDataWithJoins()` already supports:
- `search`
- `dataFilter`
- `excludeFilter`
See `frontend/lib/api/entityJoin.ts` lines around `getTableDataWithJoins`.
## Implementation Requirements
### 1. Extend `useTableData`
Add params:
- `dataFilter?: DataFilterConfig`
- `excludeFilter?: { enabled: boolean; referenceTable: string; referenceColumn: string; sourceColumn: string; filterColumn?: string; filterValue?: any }`
Use type-only import for `DataFilterConfig`.
Pass both through to `entityJoinApi.getTableDataWithJoins()` exactly as supported by `frontend/lib/api/entityJoin.ts`.
Dependency correctness matters:
- `fetchData` must refetch when effective `search`, `dataFilter`, or `excludeFilter` changes.
- Avoid object identity refetch loops. In `TableComponent`, memoize effective payloads before passing to `useTableData`.
- Preserve C.5 behavior: `defaultSort` must still seed initial sorting and user header sort must still override.
- Preserve C.1 behavior: `autoLoad === false` still disables initial fetch.
### 2. Advanced Search Filter UI
Import and render `AdvancedSearchFilters` in canonical `TableComponent` when:
- `componentConfig.filter?.enabled === true`
- and there is at least one configured filter, or table columns can be supplied for auto-generation.
Place the filter area above the table body and below the toolbar. Respect:
- `componentConfig.filter.bottomSpacing` as margin-bottom, default small spacing.
Convert canonical `TableFilterConfig.filters[]` and `renderColumns/sourceColumns` to the shape expected by `AdvancedSearchFilters`:
- `columnName`
- `widgetType`
- `label`
- `gridColumns`
- `numberFilterMode`
- `codeInfo`
- `referenceTable`
- `referenceColumn`
- `displayColumn`
For `tableColumns`, pass normalized column metadata from `sourceColumns`, not only visible columns, so auto-generation can see searchable hidden columns when needed. Include web type fields that `AdvancedSearchFilters` checks (`webType` / `web_type`, `columnName` / `column_name`, labels, visibility).
Search value handling:
- Keep draft/applied search state so text filters do not rely on stale React state.
- `AdvancedSearchFilters` calls `onSearchValueChange()` then `onSearch()` in the same tick for several widget types. Use a ref or functional next-value helper so `onSearch()` applies the latest value, not the previous render's value.
- `onClearFilters()` clears both draft and applied values.
- Strip empty values and `"__ALL__"` before passing to `useTableData`.
### 3. Effective Search Merge
Create one memoized `effectiveSearch` object that merges, in this order:
1. external `props.searchParams` (existing behavior)
2. TableOptions modal filters from `onFilterChange`
3. linked filter values
4. applied AdvancedSearchFilters values
Later sources win if keys collide.
Sanitize empty values:
- empty string
- `null` / `undefined`
- `"__ALL__"`
- empty arrays
- range objects with all empty members
For number/date range objects, preserve the existing `AdvancedSearchFilters` shape; do not invent a new backend contract unless an existing one is clearly used elsewhere.
### 4. Linked Filters
Use `useScreenContextOptional()` from `frontend/contexts/ScreenContext.tsx`.
For each enabled `componentConfig.linkedFilters[]`:
- read source provider via `screenContext.getDataProvider(sourceComponentId)`
- use `getSelectedData()`
- use `sourceField || "value"`
- write the value to `targetColumn`
Match legacy behavior from `_shared/TableListComponent.tsx` / `_shared/V2TableListComponent.tsx`, but keep implementation smaller:
- initial check on mount/config change
- lightweight polling is acceptable if there is no event bus; use the same 500ms interval style as legacy
- cleanup interval
- do not throw if `ScreenContext` or provider is missing
If an operator other than equality is configured and no backend `search` contract exists for it, preserve legacy-compatible plain search value and leave a short comment. Do not create a speculative query DSL.
### 5. Exclude Filter
Build `excludeFilterParam` in `TableComponent` and pass it to `useTableData`.
Legacy-compatible payload:
```ts
{
enabled: true,
referenceTable,
referenceColumn,
sourceColumn,
filterColumn,
filterValue,
}
```
Resolve `filterValue` when `filterColumn` and `filterValueField` are present. Priority:
1. explicit props form data (`props.formData`)
2. `screenContext.form_data`
3. URL query param (`window.location.search`)
4. parent/split data if present on props (`splitPanelParentData`, `parentData`, or `selectedParentData`)
If `filterColumn` is absent, pass the exclude filter without a `filterValue`, same as legacy.
Memoize the resulting object to avoid unnecessary fetch loops.
### 6. Data Filter
Pass `componentConfig.dataFilter` to `useTableData` when enabled.
Do not implement client-side filtering in this phase unless the API contract is demonstrably unavailable. In this codebase it is available in `entityJoinApi`, so prefer server-side pass-through to keep total/page counts correct.
### 7. TableOptions Filter Callback
Replace the D.2 placeholder:
```ts
onFilterChange: () => undefined
```
with state wiring:
- keep `tableOptionsFilters` state
- convert `TableFilter[]` to an object merged into `effectiveSearch`
- support at least equality-style values
- keep unsupported operators as simple values unless an existing backend shape is confirmed
Do not modify TableOptions UI files.
## Verification
Run:
```bash
git diff --check
cd frontend && npx tsc --noEmit --pretty false 2>&1 | rg "lib/registry/components/table/(TableComponent|useTableData|types)|components/screen/filters/AdvancedSearchFilters|types/table-options|contexts/ScreenContext"
```
Expected:
- `git diff --check` has no output.
- targeted `tsc` grep has no new errors for changed files.
Also run:
```bash
rg -n "onFilterChange|AdvancedSearchFilters|linkedFilters|excludeFilter|dataFilter" frontend/lib/registry/components/table/{TableComponent.tsx,useTableData.ts}
```
Report:
- changed files and line ranges
- which filter features are runtime-wired now
- which items are intentionally deferred
- verification output
@@ -0,0 +1,196 @@
# Table Canonical Cleanup — Phase D.3 Claude Prompt
You are working in `/Users/gbpark/invyone`.
Reference:
- `notes/gbpark/2026-05-20-table-canonical-cleanup-plan.md` §8 Phase D.3
- Prior completed phases: B.1/B.4, C.1/C.2/C.3/C.4/C.5, D.1, D.2
- D.2 includes Codex follow-up fixes already present in the worktree:
- `useTableData` clears stale internal search when filters become empty.
- canonical linked filters handle `getSelectedData()` array results.
- `excludeFilter` is skipped when `filterColumn` exists but no filter value can be resolved.
- `AdvancedSearchFilters` table column metadata now includes `isVisible`.
## Goal
Implement Phase D.3 inline cell editing in canonical
`frontend/lib/registry/components/table/TableComponent.tsx`.
Canonical table cells should enter edit mode on double-click and support input behavior by column type:
- `number` / `decimal` → number input
- `date` / `datetime``InlineCellDatePicker` if practical, otherwise safe text/date fallback
- `category` / `code` / `select` → select when options are already available on the column, otherwise text fallback
- default → text input
This phase should implement immediate-save inline editing only. Keep batch editing, edit-mode toolbar UX, paste behavior, and richer validation for later phases unless a tiny helper is required.
## File Scope
Allowed to edit:
- `frontend/lib/registry/components/table/TableComponent.tsx`
Allowed only if a tiny type or helper is necessary:
- `frontend/lib/registry/components/table/types.ts`
Read-only references:
- `frontend/lib/registry/components/table/_shared/TableListComponent.tsx`
- `frontend/lib/registry/components/table/_shared/V2TableListComponent.tsx`
- `frontend/lib/registry/components/table/_shared/SingleTableWithSticky.tsx`
- `frontend/components/screen/filters/InlineCellDatePicker.tsx`
- `frontend/lib/api/screen.ts`
Do not edit old body files. They are references only.
## Forbidden
- Do not modify:
- `_shared/TableListComponent.tsx`
- `_shared/V2TableListComponent.tsx`
- `_shared/V2TableListContainerWrapper.tsx`
- `_shared/SingleTableWithSticky.tsx`
- Do not remove early legacy/v2 delegation in canonical `TableComponent`.
- Do not implement D.4 row/bulk actions.
- Do not implement D.5 special cell rendering except what is strictly needed for edit input choice.
- Do not implement D.6 toolbar/export/pagination/paste.
- Do not touch schema, registry aliases, template migration, V2List, split-panel files, or unrelated dirty files.
- Do not reintroduce `v2-input`, `v2-select`, EntityPicker, EntitySearchModal, or deleted input/select paths.
Unrelated dirty files to leave untouched:
- `frontend/components/layout/TabBar.tsx`
- `frontend/styles/v5-layout.css`
- `frontend/app/(main)/admin/systemMng/numberingRuleList/page.tsx`
- `notes/gbpark/2026-05-20-control-ide-refactor.md`
- `notes/gbpark/2026-05-20-numbering-rule-*.html`
## Current Relevant State
Canonical `TableComponent.tsx` already has:
- `sourceColumns` / `renderColumns`
- `TableColumn.editable`
- `TableColumn.inputType`
- `TableConfig.isReadOnly`
- `componentConfig.toolbar?.showEditMode` config field, but D.6 owns toolbar button behavior
- `tableData.refresh()`
- `rows` derived from `tableData.data`
- `renderRows()` with `<td>` rendering in table mode
Old reference behavior:
- `_shared/TableListComponent.tsx` around lines 625-648: edit state shape
- `_shared/TableListComponent.tsx` around lines 2213-2231: double-click start edit
- `_shared/TableListComponent.tsx` around lines 2366-2458: immediate save to `/dynamic-form/update-field`
- `_shared/SingleTableWithSticky.tsx` around lines 463-553: input type branching for select/date/number/text
## Implementation Requirements
### 1. Edit State
Add minimal state to canonical `TableComponent`:
- `editingCell: { rowIndex, columnKey, originalValue } | null`
- `editingValue: string`
- `editInputRef`
- optional `savingCellKey` / `editError`
Focus/select the input when edit starts.
### 2. Edit Entry Rules
Start editing on double-click of a data cell only when all are true:
- not design mode
- not `componentConfig.isReadOnly`
- column `editable !== false`
- column key is not checkbox/internal
- `tableName` is known
- row exists
Do not start edit for grouped/card/pivot/split detail panels unless table-mode cells naturally reuse the same render path. Keep the scope to cells rendered by canonical basic table.
### 3. Save Rules
Save on:
- Enter
- blur
Cancel on:
- Escape
If value did not change, close without API call.
Persist using the existing endpoint pattern from old body:
```ts
const { apiClient } = await import("@/lib/api/client");
await apiClient.put("/dynamic-form/update-field", {
tableName,
keyField,
keyValue,
updateField: columnKey,
updateValue,
});
```
Primary key:
- use `(componentConfig as any).primaryKey` if present
- else use `"id"`
- else try `${tableName}_id`
- if no key value exists, close edit and surface a non-crashing console warning
After successful save:
- call `tableData.refresh()`
- clear edit state
On failure:
- keep edit state open
- store a short error string or console.error
- do not corrupt local table data
### 4. Value Normalization
Normalize before save:
- empty string → `null`
- number/decimal → `Number(value)` if value is non-empty and numeric
- checkbox/boolean may be deferred unless trivial
- date/datetime should pass string value from picker/input
### 5. Input Rendering
When cell is editing, render an input inside the cell:
- text input for default
- number input for number/decimal
- date input or `InlineCellDatePicker` for date/datetime
- select for category/code/select only when options are already available in `column.options` or `column` has an obvious array option field; otherwise text fallback
Use inline styles consistent with existing canonical table (not Tailwind-only styling unless already in this file).
Stop propagation for input click/key events so row selection does not fire unexpectedly.
### 6. Avoid Scope Creep
Do not implement:
- batch edit mode
- pending changes map
- validation framework
- category/code option fetching or code cache
- cascading lookup loading
- toolbar edit mode button
- toast dependency
Leave comments for deferred parts only where they help future phases.
## Verification
Run:
```bash
git diff --check
cd frontend && npx tsc --noEmit --pretty false 2>&1 | rg "^lib/registry/components/table/(TableComponent|types)"
rg -n "editingCell|editingValue|update-field|InlineCellDatePicker|onDoubleClick" frontend/lib/registry/components/table/TableComponent.tsx
```
Expected:
- `git diff --check` has no output.
- targeted `tsc` grep has no new errors for changed files.
Report:
- changed files
- edit entry/save/cancel behavior
- input type branches implemented
- intentionally deferred items
- verification output
@@ -0,0 +1,236 @@
# Table Canonical Cleanup — Phase D.4 Claude Prompt
You are working in `/Users/gbpark/invyone`.
Reference:
- `notes/gbpark/2026-05-20-table-canonical-cleanup-plan.md` §8 Phase D.4
- Prior completed phases: B.1/B.4, C.1/C.2/C.3/C.4/C.5, D.1, D.2, D.3
- C.4 already added `TableActionType`, `TableActionItemConfig`, `TableActionConfig`, and `TableConfig.actions`.
- D.3 includes Codex follow-up fixes already present in the worktree:
- checkbox/boolean columns are not editable in canonical inline edit.
- inline edit uses a draft ref so date picker save sees the latest value.
- Enter/blur duplicate save is guarded by a commit ref.
## Goal
Implement Phase D.4 row and bulk action runtime wiring for canonical
`frontend/lib/registry/components/table/TableComponent.tsx`.
This phase should make `componentConfig.actions` usable at runtime:
- render a row action column for basic table mode
- execute `view` / `edit` / `delete` / `custom` row actions
- render a small bulk action bar when row selection + bulk actions are enabled
- execute configured bulk actions conservatively
Keep this as runtime wiring. Do not expand ConfigPanel.
## File Scope
Allowed to edit:
- `frontend/lib/registry/components/table/TableComponent.tsx`
Allowed only if a tiny type fix is necessary:
- `frontend/lib/registry/components/table/types.ts`
Read-only references:
- `frontend/lib/registry/components/table/types.ts`
- `frontend/lib/registry/components/table/views/CardView.tsx`
- `frontend/lib/registry/components/table/_shared/TableListComponent.tsx`
- `frontend/lib/registry/components/table/_shared/V2TableListComponent.tsx`
- `frontend/lib/api/screen.ts`
- `frontend/types/component.ts`
Do not edit old body files or `CardView.tsx` unless TypeScript makes it unavoidable. Prefer wiring existing `CardView` props from `TableComponent` if you support card mode.
## Forbidden
- Do not modify:
- `_shared/TableListComponent.tsx`
- `_shared/V2TableListComponent.tsx`
- `_shared/V2TableListContainerWrapper.tsx`
- `_shared/SingleTableWithSticky.tsx`
- Do not remove early legacy/v2 delegation in canonical `TableComponent`.
- Do not implement D.5 special cell rendering.
- Do not implement D.6 toolbar/export/pagination/paste.
- Do not implement schema/alias/V2List/split-panel cleanup.
- Do not reintroduce deleted v2 input/select/entity modal paths.
- Do not touch unrelated dirty files:
- `frontend/components/layout/TabBar.tsx`
- `frontend/styles/v5-layout.css`
- `frontend/app/(main)/admin/systemMng/numberingRuleList/page.tsx`
- `notes/gbpark/2026-05-20-control-ide-refactor.md`
- `notes/gbpark/2026-05-20-numbering-rule-*.html`
## Current Relevant State
`TableComponent.tsx` already has:
- `componentConfig.actions` merged from props/config
- `selectedRows: Set<number>` where selected values are row indexes
- `rows` for current visible data
- `tableName`
- `tableData.refresh()`
- D.3 helper `resolveKeyField(row)` for primary key resolution
- checkbox column / selection handlers
- `renderHeader()` and `renderRows()` for basic table mode
Action config shape:
```ts
interface TableActionConfig {
showActions: boolean;
actions: TableActionItemConfig[];
bulkActions: boolean;
bulkActionList: string[];
}
interface TableActionItemConfig {
type: "view" | "edit" | "delete" | "custom";
label: string;
icon?: string;
color?: string;
confirmMessage?: string;
targetScreen?: string;
}
```
## Implementation Requirements
### 1. Normalize Active Actions
Create memoized helpers:
- `rowActions`: enabled when `componentConfig.actions?.showActions === true` and actions array non-empty
- `bulkActionNames`: enabled when `componentConfig.actions?.bulkActions === true` and `bulkActionList` non-empty
Filter out malformed row actions with no `type`.
### 2. Row Action Column
In basic table mode only:
- add an "actions" `<th>` at the right side when row actions are enabled
- add matching `<td>` per row
- update empty-row `colSpan`
- make action cell compact and stable width
- stop event propagation so action clicks do not select rows or start inline edit
Use lucide icons for known action types if already importing is acceptable:
- `view``Eye`
- `edit``Pencil`
- `delete``Trash2`
- `custom``MoreHorizontal` or `Play`
If the configured `icon` string is unknown, ignore it or show the label; do not build a risky dynamic import.
### 3. Row Action Execution
Create a single handler:
```ts
handleRowAction(action, row, rowIndex)
```
Order:
1. stop propagation at button level
2. if `action.confirmMessage` exists, `window.confirm()` before proceeding
3. call optional external callbacks if present:
- `props.onRowAction?.(action, row, context)`
- `props.onTableAction?.(action, row, context)`
- `props.onAction?.(action.type, row, context)`
4. perform built-in fallback by `action.type`
Context should include:
- `tableName`
- `rowIndex`
- `keyField`
- `keyValue`
- `componentId`
Built-in fallback:
- `view` / `edit`:
- if `action.targetScreen` is a numeric string, dispatch:
```ts
window.dispatchEvent(new CustomEvent("openScreenModal", {
detail: {
screenId: Number(action.targetScreen),
urlParams: {
mode: action.type,
editId: keyValue,
tableName,
primaryKeyColumn: keyField,
},
},
}));
```
- if `targetScreen` starts with `/`, use `window.location.assign(targetScreen)`.
- otherwise do not crash; console.info a short message.
- `delete`:
- resolve row id using D.3 `resolveKeyField(row)` / `keyValue`
- confirm with `action.confirmMessage || "이 행을 삭제하시겠습니까?"`
- call `apiClient.delete(`/table-management/tables/${tableName}/delete`, { data: { ids: [String(keyValue)] } })`
- on success: clear selection for deleted index if needed and `tableData.refresh()`
- `custom`:
- dispatch `window.dispatchEvent(new CustomEvent("tableRowAction", { detail }))` after callbacks
- no built-in mutation
Do not add toast dependency.
### 4. Bulk Action Bar
When all are true:
- not design mode
- `bulkActionNames.length > 0`
- `selectedRows.size > 0`
Render a compact bar between filters and body, or just above the table body:
- selected count
- one button per bulk action name
Button click:
```ts
handleBulkAction(actionName)
```
Rows:
- selected row indexes are in `selectedRows`
- selected data is `Array.from(selectedRows).map(i => rows[i]).filter(Boolean)`
Built-in bulk behavior:
- `delete`: confirm, collect ids via `resolveKeyField(row)`, call the same delete endpoint with all ids, clear selection, refresh
- `export` / `copy` / unknown: call optional `props.onBulkAction?.(actionName, selectedRowsData, context)` and dispatch `tableBulkAction`; no built-in mutation
Do not implement Excel export here; D.6 owns export/copy toolbar behavior.
### 5. Card View Optional Wiring
If simple and safe, wire existing `CardView` callbacks:
- map first configured `view` action to `onView`
- first `edit` to `onEdit`
- first `delete` to `onDelete`
Do not edit `CardView.tsx`. If card mode conflicts with `cardStyle.showActions`, leave it untouched and report that row action column is table-mode only for this phase.
### 6. Styling
Use the existing inline style pattern in `TableComponent.tsx`.
- no nested cards
- no layout shifts
- compact icon buttons
- action header/cell fixed width
## Verification
Run:
```bash
git diff --check
cd frontend && npx tsc --noEmit --pretty false 2>&1 | rg "^lib/registry/components/table/(TableComponent|types)"
rg -n "handleRowAction|handleBulkAction|tableRowAction|tableBulkAction|actions\\?|bulkAction" frontend/lib/registry/components/table/TableComponent.tsx
```
Expected:
- `git diff --check` has no output.
- targeted `tsc` grep has no new errors for changed files.
Report:
- changed files
- row action rendering and handlers
- bulk action rendering and handlers
- built-in fallback behavior
- intentionally deferred items
- verification output
@@ -0,0 +1,281 @@
# Table Canonical Cleanup — Phase D.5 Claude Prompt
You are working in `/Users/gbpark/invyone`.
Reference:
- `notes/gbpark/2026-05-20-table-canonical-cleanup-plan.md` §8 Phase D.5
- Prior completed phases: B.1/B.4, C.1/C.2/C.3/C.4/C.5, D.1, D.2, D.3, D.4
- D.3 already implemented basic-table inline editing.
- D.4 already implemented row/bulk action runtime wiring.
## Goal
Implement Phase D.5 special cell rendering for canonical table:
- image cells
- file / attachment cells
- entity display cells using `entityDisplayConfig`
- number/date/boolean display formatting
- column `langKey` label translation support
This phase should replace the canonical table's current plain
`String(row[col.key])` display path with a safe canonical cell renderer. Keep the
scope to display rendering. Do not change ConfigPanel.
## File Scope
Allowed to edit:
- `frontend/lib/registry/components/table/TableComponent.tsx`
- `frontend/lib/registry/components/table/types.ts`
Preferred if the implementation grows beyond a small helper:
- create `frontend/lib/registry/components/table/cell-renderers.tsx`
Allowed only if absolutely required for a type-only compatibility fix:
- `frontend/lib/fieldConfig/adapters.ts`
Read-only references:
- `frontend/lib/registry/components/table/_shared/V2TableListComponent.tsx`
- `TableCellImage` lines near 21-124
- `TableCellFile` lines near 128-314
- `formatCellValue` lines near 4489-4700
- `frontend/lib/registry/components/table/_shared/TableListComponent.tsx`
- `formatCellValue` lines near 4171-4445
- `frontend/contexts/ScreenMultiLangContext.tsx`
- `frontend/lib/api/client.ts`
- `frontend/lib/api/file.ts`
- `frontend/lib/formatting.ts`
## Forbidden
- Do not modify:
- `_shared/TableListComponent.tsx`
- `_shared/V2TableListComponent.tsx`
- `_shared/V2TableListContainerWrapper.tsx`
- `_shared/SingleTableWithSticky.tsx`
- Do not remove early legacy/v2 delegation in canonical `TableComponent`.
- Do not implement D.6 toolbar/export/pagination/paste.
- Do not implement D.7 card-mode full parity.
- Do not implement schema/alias/V2List/split-panel cleanup.
- Do not reintroduce deleted v2 input/select/entity modal paths.
- Do not add toast dependencies.
- Do not use `require()` inside React render paths. Use static imports or dynamic
imports inside effects/handlers.
- Do not touch unrelated dirty files:
- `frontend/components/layout/TabBar.tsx`
- `frontend/styles/v5-layout.css`
- `frontend/app/(main)/admin/systemMng/numberingRuleList/page.tsx`
- `notes/gbpark/2026-05-20-control-ide-refactor.md`
- `notes/gbpark/2026-05-20-numbering-rule-*.html`
## Current Relevant State
`TableComponent.tsx` currently renders basic-table cells as plain strings:
```tsx
{isEditingThisCell ? (
renderEditInput(col)
) : isDesignMode ? (
<span style={{ color: "hsl(var(--muted-foreground))" }}>
{row[col.key] != null ? String(row[col.key]) : "..."}
</span>
) : (
<span>{row[col.key] != null ? String(row[col.key]) : ""}</span>
)}
```
`TableColumn` already has these Phase C.2 fields:
- `inputType`
- `dataType`
- `format`
- `thousandSeparator`
- `isEntityJoin`
- `entityJoinInfo`
- `entityDisplayConfig`
- `additionalJoinInfo`
It does not yet explicitly type `langKey` / `langKeyId`; add them if needed.
## Implementation Requirements
### 1. Add Canonical Cell Renderer
Create a helper that can be used from `TableComponent`:
```ts
renderCellValue(value, column, row, options)
```
or, if placed in a new file:
```ts
export function renderTableCellValue(args): React.ReactNode
```
Inputs should include:
- raw `value`
- `column: TableColumn`
- `row: Record<string, any>`
- `isDesignMode`
- optional `getTranslatedText`
Use this helper in the basic table body display path. Keep D.3 inline edit
unchanged: if a cell is editing, `renderEditInput(col)` still wins.
### 2. Entity Display Config
Apply `column.entityDisplayConfig` before null/empty fallback, because joined
display values can live elsewhere in the row even when `row[col.key]` is empty.
Support both forms:
- `displayColumns`
- legacy `selectedColumns`
For each display column, try these row keys in order:
- `${column.key}_${displayColumn}`
- `${column.entityJoinInfo?.sourceColumn}_${displayColumn}`
- `${column.entityDisplayConfig?.joinTable}_${displayColumn}`
- `${column.entityJoinInfo?.joinAlias}_${displayColumn}`
- direct `displayColumn`
- if `displayColumn` contains `.`, also try the last segment
Join non-empty values using `separator || " - "`. If the result is non-empty,
return it. If not, fall through to normal value formatting.
Do not add new entity fetches in this phase. Use data already returned in the row.
### 3. Image Cell
Render image cells when:
- `column.inputType === "image"`
- or `column.format === "image"`
Value formats to support:
- numeric objid string
- comma-separated objid/path string
- JSON array of objects/ids if trivial to support
- direct URL or `/uploads/...` path
Use existing APIs:
- `getFullImageUrl` from `@/lib/api/client`
- `getFilePreviewUrl` from `@/lib/api/file`
- optionally dynamic `getFileInfoByObjid` only to choose representative image for
comma-separated numeric objids. Keep this conservative.
Rendering:
- compact thumbnail, stable 32-40px size
- click opens preview/new tab and stops row click propagation
- safe loading/error fallback
- clean up blob/object URLs if you create any
- do not start remote image/file lookup in design mode; show a muted placeholder
or direct path preview only.
Prefer a small `TableCellImage` component in `cell-renderers.tsx` if the logic is
not trivial.
### 4. File / Attachment Cell
Render file cells when:
- `column.inputType === "file"` or `"attachment"`
- or `column.format === "file"` or `"attachment"`
- or column key contains `attachment` / `file` case-insensitively
Value formats to support:
- objid string
- comma-separated objids
- JSON array objects with `objid` / `id` / `realFileName` / `real_file_name` /
`name` / `fileExt` / `file_ext` / `fileSize` / `file_size`
Use existing APIs:
- dynamic `getFileInfoByObjid` from `@/lib/api/file`
- `getFilePreviewUrl` from `@/lib/api/file`
- dynamic `apiClient` only inside a download handler if you implement download
Rendering:
- compact icon + filename text
- if multiple files, show count or joined names without forcing row height growth
- click stops row selection and opens preview/list modal or preview URL
- no upload/edit/delete behavior; D.5 is display-only
- no shadcn modal dependency required. If you need a modal, use a minimal inline
overlay like old V2 did, but keep it small.
Prefer a small `TableCellFile` component in `cell-renderers.tsx` if the logic is
not trivial.
### 5. Number / Date / Boolean Formatting
Implement display formatting for basic cells:
- `inputType === "number" | "decimal"` or `format === "number"`:
- if `column.thousandSeparator !== false`, use central formatting from
`@/lib/formatting` if available, otherwise `toLocaleString("ko-KR")`.
- if disabled, show numeric string without separators.
- `format === "currency"`:
- use central currency formatting if available, otherwise `₩` + localized number.
- `inputType === "date" | "datetime"` or `format === "date"`:
- render `YYYY-MM-DD` for valid values.
- `format === "boolean"` or `inputType === "checkbox"`:
- render `예` / `아니오` for boolean-like values.
Do not change sort/filter semantics. This is display only.
### 6. Column Label Translation
Add optional `langKey?: string` and `langKeyId?: number` to `TableColumn` if not
present.
Use `useScreenMultiLang()` in `TableComponent` to translate header labels:
```ts
const { getTranslatedText } = useScreenMultiLang();
const label = getTranslatedText(col.langKey, col.label);
```
Apply the translated label in:
- basic table header
- `tableDisplayStore.setTableDataForComponent` `column_labels` if that data is
already being synced
Do not edit `ScreenMultiLangContext.tsx`; it already collects `componentConfig.columns[].langKey`.
### 7. Keep Existing Phase Behavior
Do not regress:
- D.1 visible/hidden/fixed/sticky column rendering
- D.2 filter runtime
- D.3 inline edit entry/save/cancel
- D.4 row action column and bulk action bar
Action cells and checkbox cells should not use the special cell renderer.
## Validation
Run:
```bash
git diff --check
cd frontend && npx tsc --noEmit --pretty false 2>&1 | rg "^lib/registry/components/table/(TableComponent|types|cell-renderers)|^lib/fieldConfig/adapters"
rg -n "TableCellImage|TableCellFile|renderTableCellValue|entityDisplayConfig|thousandSeparator|useScreenMultiLang|langKey" frontend/lib/registry/components/table
```
Expected:
- `git diff --check` prints nothing.
- targeted `tsc | rg ...` prints nothing.
- grep shows canonical table files only, plus old `_shared` references if your
grep includes `_shared`.
Known unrelated baseline errors may still appear if you run full `tsc` without a
targeted grep:
- `components/screen/filters/AdvancedSearchFilters.tsx` import of
`@/types/screen-legacy-backup`
- `lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx`
`DataReceivable` import from `ScreenContext`
## Final Report
Report:
- changed files
- whether a new `cell-renderers.tsx` was created
- exact cell types now handled
- entity display fallback key order
- whether `langKey` translation was wired
- validation command results
- any intentionally deferred items

Some files were not shown because too many files have changed in this diff Show More