Compare commits
27 Commits
gbpark-node
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f8ef029e10 | |||
| 5cd8e72bf0 | |||
| 387a1ae611 | |||
| eeb130e3a8 | |||
| 3ffa5c8ff5 | |||
| acbab68a12 | |||
| db63ba6901 | |||
| ff95c1950e | |||
| 8a9285f13e | |||
| 88b0549a6d | |||
| 33f0647c61 | |||
| 8606f0aaa3 | |||
| 24106929fa | |||
| f530b3cf31 | |||
| 99487049fb | |||
| 6233877029 | |||
| 4031fe8b60 | |||
| a5288647c9 | |||
| 7dbeccc182 | |||
| c857e4f715 | |||
| cf5f7ef9af | |||
| 7e71730015 | |||
| 2d39d17428 | |||
| 30ebb14023 | |||
| 895cb48ee0 | |||
| 067193efa9 | |||
| 8a10edd8e1 |
@@ -1,97 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,338 +0,0 @@
|
||||
<!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 3–6 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>
|
||||
@@ -1,81 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,132 +0,0 @@
|
||||
{
|
||||
"$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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,247 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,3 +1,82 @@
|
||||
<!-- User customizations -->
|
||||
# 절대 규칙: 검증 없는 주장 금지
|
||||
|
||||
내가 출력하는 모든 발언은 근거가 있어야 한다. 근거가 없으면 그 말을 하지 않는다. 위로·추정·일반론·"보통 그렇다"로 채우지 않는다.
|
||||
|
||||
## 위반 사례 (절대 하지 말 것)
|
||||
- "100명 중 5명도 안 된다" 같은 통계를 출처 없이 만들어내기
|
||||
- "통과 확률 70~80%" 같은 수치를 추정으로 제시하기
|
||||
- "보통", "일반적으로", "대부분" 으로 시작하는 일반론
|
||||
- 본인이 검증 안 한 SDK/API 동작을 단정적으로 설명하기
|
||||
- 위로·격려를 위해 사실이 아닌 것을 끼워넣기
|
||||
|
||||
## 발화 전 자기 검증
|
||||
한 문장이라도 출력하기 전에 다음을 확인:
|
||||
1. **출처가 있는가?** — 코드(파일:라인), 명령 결과, 공식 문서, 사용자가 준 정보, 도구 호출 결과 중 하나
|
||||
2. **출처가 없다면 추정인가?** — 추정이면 명시적으로 "추정이지만…" 또는 "확인 안 됐지만…" 으로 시작
|
||||
3. **추정도 근거가 없으면?** — 말하지 않는다. "모릅니다" 또는 "확인이 필요합니다" 라고 한다
|
||||
|
||||
## 모를 때의 정답
|
||||
- 검색·문서 조회·코드 읽기로 확인 가능하면 확인부터 한다
|
||||
- 확인이 불가능하면 "모릅니다" 가 정답. 그럴듯한 답을 만들지 않는다
|
||||
- 사용자 의사결정에 영향을 주는 사실일수록 더 엄격하게 적용
|
||||
|
||||
## 어겼을 때
|
||||
사용자가 "그 근거 뭐야" 라고 묻거나 잘못된 사실을 지적하면:
|
||||
- 즉시 인정. "맞습니다. 그 수치 제가 지어냈습니다." 같이 명시적으로 시인
|
||||
- 변명·재포장 금지
|
||||
- 무엇이 검증된 사실이고 무엇이 추정/날조였는지 다시 분리해서 제시
|
||||
|
||||
|
||||
# 💬 사용자에게 설명할 때 — 그림으로 (★ 중요)
|
||||
|
||||
UI 변경 제안, 디자인 토론, 코드 구조 설명 등을 할 때는 **반드시 변경 전/후를 ASCII 표나 도식으로 그려서** 보여준다. 글로만 설명하면 사용자가 이해 못 한다.
|
||||
|
||||
## 원칙
|
||||
|
||||
1. **변경 제안은 무조건 Before / After 두 그림**
|
||||
2. **코드 인용 (file:line, 변수명, CSS class) 최소화** — 결론과 시각적 영향 위주
|
||||
3. **평어, 한국어, 짧은 문장**
|
||||
4. **영문/SQL/전문용어 풀어쓰기** — "grid template" 대신 "표 컬럼 배치", "stopPropagation" 대신 "클릭이 위로 새는 거 막기"
|
||||
5. **3줄 패턴 권장** — 무슨 일 / 사용자한테 보이는 영향 / 어떻게 고치는지
|
||||
|
||||
## 나쁜 예시 ❌
|
||||
|
||||
> "ColumnGrid.tsx:93-103 의 `grid-cols-[4px_140px_1fr_100px_160px_40px]` 를 5컬럼으로 축소하고, 라벨 셀에 sub-line 을 추가하면 entity/code/numbering 의 메타가 inline 으로..."
|
||||
|
||||
(사용자: "뭐라는지 모르겠어")
|
||||
|
||||
## 좋은 예시 ⭕
|
||||
|
||||
> **지금 모양:**
|
||||
> ```
|
||||
> 라벨·컬럼명 │ 참조/설정 │ 타입
|
||||
> 거래처명 │ — │ 텍스트 ← 빈 칸
|
||||
> 거래처ID │ customer_mng → ... │ 테이블참조
|
||||
> ```
|
||||
>
|
||||
> **바꿔서:**
|
||||
> ```
|
||||
> 라벨·컬럼명 │ 타입
|
||||
> 거래처명 │ 텍스트
|
||||
> 거래처ID │ 테이블참조
|
||||
> → customer_mng.id ← 정보 있을 때만 작게 밑에
|
||||
> ```
|
||||
|
||||
## 옵션 제시할 땐 표로
|
||||
|
||||
```
|
||||
| 옵션 | 핵심 | 단점 |
|
||||
| A안 | 이름만 바꾸기 | 가장 가벼움 |
|
||||
| B안 | 그룹을 잘게 쪼개기 | 그룹 수 늘어남 |
|
||||
```
|
||||
|
||||
## 우선 순위
|
||||
- 첫 시도에 글만 쓰지 말 것. 그림부터 그리고 글은 짧게 보충.
|
||||
- 사용자가 "무슨 말인지 모르겠어" 하면 → 더 분해해서 다시 그림 그리기. 글 길어지면 더 헷갈림.
|
||||
|
||||
---
|
||||
|
||||
# INVYONE — Claude 작업 컨벤션
|
||||
|
||||
이 파일은 git 에 올라가는 **프로젝트 공용** Claude 가이드입니다. 모든 머신/팀원의 Claude Code 인스턴스가 이 컨벤션을 따라야 합니다.
|
||||
|
||||
@@ -136,6 +136,15 @@ public class BatchManagementController {
|
||||
return ResponseEntity.ok(ApiResponse.success(batchManagementService.getBatchSparkline(params)));
|
||||
}
|
||||
|
||||
/** GET /api/batch-management/sparkline — 회사 전체 배치의 최근 24시간 1시간 단위 실행 집계 (24개 슬롯 고정) */
|
||||
@GetMapping("/sparkline")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getGlobalSparkline(
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(batchManagementService.getGlobalSparkline(params)));
|
||||
}
|
||||
|
||||
/** GET /api/batch-management/batch-configs/:id/recent-logs — 최근 실행 로그 (최대 20건) */
|
||||
@GetMapping("/batch-configs/{id}/recent-logs")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getBatchRecentLogs(
|
||||
|
||||
@@ -29,11 +29,12 @@ public class DdlController {
|
||||
@PostMapping("/tables")
|
||||
public ResponseEntity<ApiResponse<?>> createTable(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (!isSuperAdmin(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
String tableName = (String) body.get("table_name");
|
||||
@@ -65,11 +66,12 @@ public class DdlController {
|
||||
public ResponseEntity<ApiResponse<?>> addColumn(
|
||||
@PathVariable String tableName,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (!isSuperAdmin(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@@ -99,10 +101,11 @@ public class DdlController {
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role,
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
|
||||
if (!isSuperAdmin(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
Map<String, Object> result = ddlService.dropColumn(tableName, columnName, companyCode, userId);
|
||||
@@ -124,10 +127,11 @@ public class DdlController {
|
||||
public ResponseEntity<ApiResponse<?>> dropTable(
|
||||
@PathVariable String tableName,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role,
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
|
||||
if (!isSuperAdmin(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
Map<String, Object> result = ddlService.dropTable(tableName, companyCode, userId);
|
||||
@@ -147,10 +151,11 @@ public class DdlController {
|
||||
@PostMapping("/validate/table")
|
||||
public ResponseEntity<ApiResponse<?>> validateTableCreation(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (!isSuperAdmin(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
String tableName = (String) body.get("table_name");
|
||||
@@ -176,12 +181,13 @@ public class DdlController {
|
||||
@GetMapping("/logs")
|
||||
public ResponseEntity<ApiResponse<?>> getDdlLogs(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role,
|
||||
@RequestParam(required = false, defaultValue = "50") int limit,
|
||||
@RequestParam(required = false) String userId,
|
||||
@RequestParam(required = false) String ddlType) {
|
||||
|
||||
if (!isSuperAdmin(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
List<Map<String, Object>> logs = ddlService.getDdlLogs(limit, userId, ddlType);
|
||||
@@ -195,11 +201,12 @@ public class DdlController {
|
||||
@GetMapping("/statistics")
|
||||
public ResponseEntity<ApiResponse<?>> getDdlStatistics(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role,
|
||||
@RequestParam(required = false) String fromDate,
|
||||
@RequestParam(required = false) String toDate) {
|
||||
|
||||
if (!isSuperAdmin(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
Map<String, Object> statistics = ddlService.getDdlStatistics(fromDate, toDate);
|
||||
@@ -212,10 +219,11 @@ public class DdlController {
|
||||
@GetMapping("/tables/{tableName}/history")
|
||||
public ResponseEntity<ApiResponse<?>> getTableDdlHistory(
|
||||
@PathVariable String tableName,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role) {
|
||||
|
||||
if (!isSuperAdmin(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
List<Map<String, Object>> history = ddlService.getTableDdlHistory(tableName);
|
||||
@@ -230,10 +238,11 @@ public class DdlController {
|
||||
@GetMapping("/tables/{tableName}/info")
|
||||
public ResponseEntity<ApiResponse<?>> getTableInfo(
|
||||
@PathVariable String tableName,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role) {
|
||||
|
||||
if (!isSuperAdmin(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
Map<String, Object> tableInfo = ddlService.getTableInfo(tableName);
|
||||
@@ -255,10 +264,11 @@ public class DdlController {
|
||||
@DeleteMapping("/logs/cleanup")
|
||||
public ResponseEntity<ApiResponse<?>> cleanupOldLogs(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role,
|
||||
@RequestParam(required = false, defaultValue = "90") int retentionDays) {
|
||||
|
||||
if (!isSuperAdmin(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
int deletedCount = ddlService.cleanupOldLogs(retentionDays);
|
||||
@@ -272,10 +282,11 @@ public class DdlController {
|
||||
*/
|
||||
@GetMapping("/info")
|
||||
public ResponseEntity<ApiResponse<?>> getInfo(
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role) {
|
||||
|
||||
if (!isSuperAdmin(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(Map.of(
|
||||
@@ -318,7 +329,9 @@ public class DdlController {
|
||||
// 내부 유틸
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private boolean isSuperAdmin(String companyCode) {
|
||||
return "*".equals(companyCode);
|
||||
private boolean isSuperAdmin(String companyCode, String role) {
|
||||
// company_code 가 '*' 이고 role 이 SUPER_ADMIN 둘 다 충족해야 통과 (이중 체크).
|
||||
// 토큰 변조 또는 회사코드만으로 super 권한이 발급되는 사고 방지.
|
||||
return "*".equals(companyCode) && "SUPER_ADMIN".equals(role);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,7 +187,8 @@ public class TableManagementController {
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName,
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role) {
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
@@ -197,7 +198,8 @@ public class TableManagementController {
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> detailSettings = (Map<String, Object>) body.get("detail_settings");
|
||||
tableManagementService.updateColumnWebType(tableName, columnName, webType, detailSettings);
|
||||
// 멀티테넌트 격리: SUPER_ADMIN(company_code='*') 가 아니면 자기 회사 코드로 저장
|
||||
tableManagementService.updateColumnWebType(tableName, columnName, webType, detailSettings, companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "컬럼 웹타입이 설정되었습니다."));
|
||||
}
|
||||
|
||||
@@ -272,7 +274,7 @@ public class TableManagementController {
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role) {
|
||||
if (!isSuperAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> columns = (List<String>) body.get("columns");
|
||||
@@ -291,7 +293,7 @@ public class TableManagementController {
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role) {
|
||||
if (!isSuperAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
String columnName = (String) body.get("column_name");
|
||||
String indexType = (String) body.get("index_type");
|
||||
@@ -320,7 +322,7 @@ public class TableManagementController {
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isSuperAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
Object nullableObj = body.get("nullable");
|
||||
if (tableName == null || columnName == null || !(nullableObj instanceof Boolean)) {
|
||||
@@ -342,7 +344,7 @@ public class TableManagementController {
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isSuperAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
Object uniqueObj = body.get("unique");
|
||||
if (tableName == null || columnName == null || !(uniqueObj instanceof Boolean)) {
|
||||
@@ -567,7 +569,7 @@ public class TableManagementController {
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role) {
|
||||
if (!isSuperAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> logColumns = (List<String>) body.get("log_columns");
|
||||
|
||||
@@ -296,6 +296,11 @@ public class BatchManagementService extends BaseService {
|
||||
return sqlSession.selectList(NS + "getBatchManagementSparklineData", params);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> getGlobalSparkline(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
return sqlSession.selectList(NS + "getBatchManagementGlobalSparklineData", params);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> getBatchRecentLogs(Map<String, Object> params) {
|
||||
return sqlSession.selectList(NS + "getBatchManagementRecentLogList", params);
|
||||
}
|
||||
|
||||
@@ -176,6 +176,21 @@ public class TableManagementService extends BaseService {
|
||||
params.put("display_column", "entity".equals(inputType) ? settings.get("display_column") : null);
|
||||
params.put("display_order", settings.getOrDefault("display_order", 0));
|
||||
params.put("is_visible", settings.getOrDefault("is_visible", true));
|
||||
// is_nullable: 'Y'/'N' 또는 null. null 이면 mapper 의 COALESCE 로 기존 값 유지.
|
||||
Object rawIsNullable = settings.get("is_nullable");
|
||||
if (rawIsNullable != null) {
|
||||
String s = rawIsNullable.toString();
|
||||
// 프론트가 'YES'/'NO' 또는 'Y'/'N' 어느 쪽이든 보낼 수 있어 정규화
|
||||
if ("NO".equalsIgnoreCase(s) || "N".equalsIgnoreCase(s) || "FALSE".equalsIgnoreCase(s)) {
|
||||
params.put("is_nullable", "N");
|
||||
} else if ("YES".equalsIgnoreCase(s) || "Y".equalsIgnoreCase(s) || "TRUE".equalsIgnoreCase(s)) {
|
||||
params.put("is_nullable", "Y");
|
||||
} else {
|
||||
params.put("is_nullable", null);
|
||||
}
|
||||
} else {
|
||||
params.put("is_nullable", null);
|
||||
}
|
||||
params.put("company_code", companyCode);
|
||||
params.put("category_ref", "category".equals(inputType) ? settings.get("category_ref") : null);
|
||||
sqlSession.update(NS + "upsertColumnSettings", params);
|
||||
@@ -200,19 +215,21 @@ public class TableManagementService extends BaseService {
|
||||
|
||||
@Transactional
|
||||
public void updateColumnWebType(String tableName, String columnName,
|
||||
String webType, Map<String, Object> detailSettings) {
|
||||
String webType, Map<String, Object> detailSettings,
|
||||
String companyCode) {
|
||||
String finalType = normalizeInputType(webType);
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("table_name", tableName);
|
||||
params.put("column_name", columnName);
|
||||
params.put("input_type", finalType);
|
||||
params.put("detail_settings", detailSettings != null ? toJsonString(detailSettings) : "{}");
|
||||
params.put("company_code", "*");
|
||||
// 멀티테넌트 격리: SUPER_ADMIN("*") 은 공통 설정, 그 외는 회사별 설정
|
||||
params.put("company_code", companyCode != null ? companyCode : "*");
|
||||
params.put("clear_entity", false);
|
||||
params.put("clear_code", false);
|
||||
params.put("clear_category", false);
|
||||
sqlSession.update(NS + "upsertColumnInputType", params);
|
||||
log.info("컬럼 웹타입 설정: {}.{} = {}", tableName, columnName, finalType);
|
||||
log.info("컬럼 웹타입 설정: {}.{} = {} (company={})", tableName, columnName, finalType, companyCode);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -383,12 +400,14 @@ public class TableManagementService extends BaseService {
|
||||
String safeTable = sanitize(tableName);
|
||||
List<String> violations = new ArrayList<>();
|
||||
|
||||
// N+N → N+1 최적화: hasColumn 은 information_schema 조회라 비싸. 루프 밖에서 한 번만 수행.
|
||||
boolean hasCompanyCode = hasColumn(safeTable, "company_code");
|
||||
|
||||
for (Map<String, Object> col : uniqueCols) {
|
||||
String colName = (String) col.get("column_name");
|
||||
Object val = data.get(colName);
|
||||
if (val == null) continue;
|
||||
|
||||
boolean hasCompanyCode = hasColumn(safeTable, "company_code");
|
||||
String sql;
|
||||
List<Object> sqlParams = new ArrayList<>();
|
||||
|
||||
@@ -1252,9 +1271,40 @@ public class TableManagementService extends BaseService {
|
||||
}
|
||||
|
||||
/** SQL injection 방지용 식별자 정리 */
|
||||
/**
|
||||
* SQL 식별자(테이블/컬럼명) 살균.
|
||||
* - 영숫자/언더스코어만 허용 (PostgreSQL identifier 규칙)
|
||||
* - 빈 문자열, 숫자로 시작, 63자 초과, SQL 예약어 거부 → IllegalArgumentException
|
||||
*
|
||||
* 이렇게 가드해두지 않으면 동적 SQL 에 빈 식별자가 들어가거나 예약어가 통과해
|
||||
* 의도치 않은 컬럼에 접근하거나 SQL 문법 깨짐(500) 이 생김.
|
||||
*/
|
||||
private static final java.util.Set<String> SQL_RESERVED_WORDS = java.util.Set.of(
|
||||
"user", "order", "group", "table", "column", "index", "select", "insert",
|
||||
"update", "delete", "from", "where", "join", "on", "as", "and", "or", "not",
|
||||
"null", "true", "false", "create", "alter", "drop", "primary", "key",
|
||||
"foreign", "references", "constraint", "default", "unique", "check",
|
||||
"view", "procedure", "function"
|
||||
);
|
||||
|
||||
private String sanitize(String name) {
|
||||
if (name == null) return "";
|
||||
return name.replaceAll("[^a-zA-Z0-9_]", "");
|
||||
if (name == null) {
|
||||
throw new IllegalArgumentException("식별자가 null 입니다.");
|
||||
}
|
||||
String cleaned = name.replaceAll("[^a-zA-Z0-9_]", "");
|
||||
if (cleaned.isEmpty()) {
|
||||
throw new IllegalArgumentException("식별자가 비어있거나 유효하지 않습니다: " + name);
|
||||
}
|
||||
if (cleaned.length() > 63) {
|
||||
throw new IllegalArgumentException("식별자가 63자를 초과합니다: " + cleaned);
|
||||
}
|
||||
if (Character.isDigit(cleaned.charAt(0))) {
|
||||
throw new IllegalArgumentException("식별자는 숫자로 시작할 수 없습니다: " + cleaned);
|
||||
}
|
||||
if (SQL_RESERVED_WORDS.contains(cleaned.toLowerCase())) {
|
||||
throw new IllegalArgumentException("'" + cleaned + "' 은 SQL 예약어라 식별자로 사용할 수 없습니다.");
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/** "direct" / "auto" → "text" 변환 (legacy 호출처 보호 — system-normalize 동작) */
|
||||
|
||||
@@ -87,6 +87,32 @@
|
||||
ORDER BY hour_slot
|
||||
</select>
|
||||
|
||||
<!-- 글로벌 스파크라인: 회사 전체 배치의 최근 24시간 1시간 단위 실행 집계 (빈 슬롯 포함 24개 고정) -->
|
||||
<select id="getBatchManagementGlobalSparklineData" parameterType="map" resultType="map">
|
||||
WITH hours AS (
|
||||
SELECT generate_series(
|
||||
DATE_TRUNC('hour', NOW() - INTERVAL '23 hours'),
|
||||
DATE_TRUNC('hour', NOW()),
|
||||
INTERVAL '1 hour'
|
||||
) AS hour_slot
|
||||
),
|
||||
filtered_logs AS (
|
||||
SELECT DATE_TRUNC('hour', start_time) AS hour_slot,
|
||||
execution_status
|
||||
FROM batch_execution_logs
|
||||
WHERE start_time >= NOW() - INTERVAL '24 hours'
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
)
|
||||
SELECT h.hour_slot,
|
||||
COUNT(l.execution_status) AS total_count,
|
||||
COALESCE(SUM(CASE WHEN l.execution_status = 'SUCCESS' THEN 1 ELSE 0 END), 0) AS success_count,
|
||||
COALESCE(SUM(CASE WHEN l.execution_status = 'FAILED' THEN 1 ELSE 0 END), 0) AS failed_count
|
||||
FROM hours h
|
||||
LEFT JOIN filtered_logs l ON l.hour_slot = h.hour_slot
|
||||
GROUP BY h.hour_slot
|
||||
ORDER BY h.hour_slot
|
||||
</select>
|
||||
|
||||
<!-- 최근 실행 로그 목록 (최대 20건) -->
|
||||
<select id="getBatchManagementRecentLogList" parameterType="map" resultType="map">
|
||||
SELECT id,
|
||||
|
||||
@@ -300,7 +300,7 @@
|
||||
, #{display_column}
|
||||
, #{display_order}
|
||||
, #{is_visible}
|
||||
, 'Y'
|
||||
, COALESCE(#{is_nullable}, 'Y')
|
||||
, #{company_code}
|
||||
, #{category_ref}
|
||||
, NOW()
|
||||
@@ -318,6 +318,7 @@
|
||||
, DISPLAY_COLUMN = EXCLUDED.DISPLAY_COLUMN
|
||||
, DISPLAY_ORDER = COALESCE(EXCLUDED.DISPLAY_ORDER, TABLE_TYPE_COLUMNS.DISPLAY_ORDER)
|
||||
, IS_VISIBLE = COALESCE(EXCLUDED.IS_VISIBLE, TABLE_TYPE_COLUMNS.IS_VISIBLE)
|
||||
, IS_NULLABLE = COALESCE(EXCLUDED.IS_NULLABLE, TABLE_TYPE_COLUMNS.IS_NULLABLE)
|
||||
, CATEGORY_REF = EXCLUDED.CATEGORY_REF
|
||||
, UPDATED_DATE = NOW()
|
||||
</insert>
|
||||
|
||||
@@ -128,9 +128,11 @@ function Sparkline({ data }: { data: SparklineData[] }) {
|
||||
return (
|
||||
<div className="flex h-8 items-end gap-[2px]">
|
||||
{data.map((slot, i) => {
|
||||
const hasFail = slot.failed > 0;
|
||||
const hasSuccess = slot.success > 0;
|
||||
const height = hasFail ? "40%" : hasSuccess ? `${Math.max(30, Math.min(95, 50 + slot.success * 10))}%` : "8%";
|
||||
const failed = Number(slot.failed_count) || 0;
|
||||
const success = Number(slot.success_count) || 0;
|
||||
const hasFail = failed > 0;
|
||||
const hasSuccess = success > 0;
|
||||
const height = hasFail ? "40%" : hasSuccess ? `${Math.max(30, Math.min(95, 50 + success * 10))}%` : "8%";
|
||||
const colorClass = hasFail
|
||||
? "bg-destructive/70 hover:bg-destructive"
|
||||
: hasSuccess
|
||||
@@ -141,7 +143,7 @@ function Sparkline({ data }: { data: SparklineData[] }) {
|
||||
key={i}
|
||||
className={`min-w-[4px] flex-1 rounded-t-sm transition-colors ${colorClass}`}
|
||||
style={{ height }}
|
||||
title={`${slot.hour?.slice(11, 16) || i}시 | 성공: ${slot.success} 실패: ${slot.failed}`}
|
||||
title={`${slot.hour_slot?.slice(11, 16) || i}시 | 성공: ${success} 실패: ${failed}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -278,8 +280,10 @@ function BatchDetailPanel({ batch, sparkline, recentLogs }: { batch: BatchConfig
|
||||
);
|
||||
}
|
||||
|
||||
function GlobalSparkline({ stats }: { stats: BatchStats | null }) {
|
||||
if (!stats) return null;
|
||||
function GlobalSparkline({ data }: { data: SparklineData[] }) {
|
||||
if (!data || data.length === 0) return null;
|
||||
// 24개 슬롯 중 가장 큰 success_count 를 100% 로 맞춰 비율 스케일링
|
||||
const maxSuccess = data.reduce((m, s) => Math.max(m, Number(s.success_count) || 0), 0);
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
@@ -294,22 +298,31 @@ function GlobalSparkline({ stats }: { stats: BatchStats | null }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-10 items-end gap-[3px]">
|
||||
{Array.from({ length: 24 }).map((_, i) => {
|
||||
const hasExec = Math.random() > 0.3;
|
||||
const hasFail = hasExec && Math.random() < 0.08;
|
||||
const h = hasFail ? 35 : hasExec ? 25 + Math.random() * 70 : 6;
|
||||
{data.map((slot, i) => {
|
||||
const success = Number(slot.success_count) || 0;
|
||||
const failed = Number(slot.failed_count) || 0;
|
||||
const hasFail = failed > 0;
|
||||
const hasExec = success > 0 || hasFail;
|
||||
// 실패가 하나라도 있으면 실패 색으로 강조, 아니면 success 비율
|
||||
const h = hasFail
|
||||
? Math.max(35, Math.min(95, 35 + (failed / Math.max(maxSuccess, 1)) * 60))
|
||||
: hasExec
|
||||
? Math.max(20, Math.min(95, (success / Math.max(maxSuccess, 1)) * 90))
|
||||
: 6;
|
||||
const hour = slot.hour_slot?.slice(11, 16) || "";
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex-1 rounded-t-sm transition-colors ${hasFail ? "bg-destructive/60 hover:bg-destructive" : hasExec ? "bg-emerald-500/40 hover:bg-emerald-500/70" : "bg-muted-foreground/8"}`}
|
||||
className={`flex-1 rounded-t-sm transition-colors ${hasFail ? "bg-destructive/60 hover:bg-destructive" : hasExec ? "bg-emerald-500/40 hover:bg-emerald-500/70" : "bg-muted-foreground/10"}`}
|
||||
style={{ height: `${h}%` }}
|
||||
title={`${hour} | 성공 ${success} 실패 ${failed}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between text-[10px] text-muted-foreground">
|
||||
<span>24시간 전</span>
|
||||
<span>12시간 전</span>
|
||||
<span>6시간 전</span>
|
||||
<span>지금</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -327,6 +340,7 @@ export default function BatchManagementPage() {
|
||||
const [executingBatch, setExecutingBatch] = useState<number | null>(null);
|
||||
const [expandedBatch, setExpandedBatch] = useState<number | null>(null);
|
||||
const [stats, setStats] = useState<BatchStats | null>(null);
|
||||
const [globalSparkline, setGlobalSparkline] = useState<SparklineData[]>([]);
|
||||
const [sparklineCache, setSparklineCache] = useState<Record<number, SparklineData[]>>({});
|
||||
const [recentLogsCache, setRecentLogsCache] = useState<Record<number, RecentLog[]>>({});
|
||||
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
|
||||
@@ -339,10 +353,12 @@ export default function BatchManagementPage() {
|
||||
const loadBatchConfigs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [configsResponse, statsData] = await Promise.all([
|
||||
const [configsResponse, statsData, globalSpark] = await Promise.all([
|
||||
BatchAPI.getBatchConfigs({ page: 1, limit: 200 }),
|
||||
BatchAPI.getBatchStats(),
|
||||
BatchAPI.getGlobalSparkline(),
|
||||
]);
|
||||
setGlobalSparkline(globalSpark);
|
||||
// cross-tenant 메타 (단일 모드면 undefined → null)
|
||||
setCrossTenantMeta((configsResponse as any)?.cross_tenant_meta ?? null);
|
||||
if (configsResponse.success && configsResponse.data) {
|
||||
@@ -461,8 +477,12 @@ export default function BatchManagementPage() {
|
||||
|
||||
const activeBatches = batchConfigs.filter(b => b.is_active === "Y").length;
|
||||
const inactiveBatches = batchConfigs.length - activeBatches;
|
||||
const execDiff = stats ? stats.todayExecutions - stats.prevDayExecutions : 0;
|
||||
const failDiff = stats ? stats.todayFailures - stats.prevDayFailures : 0;
|
||||
const todayExec = Number(stats?.today_count) || 0;
|
||||
const todayFail = Number(stats?.today_failed_count) || 0;
|
||||
const yestExec = Number(stats?.yesterday_count) || 0;
|
||||
const yestFail = Number(stats?.yesterday_failed_count) || 0;
|
||||
const execDiff = todayExec - yestExec;
|
||||
const failDiff = todayFail - yestFail;
|
||||
|
||||
return (
|
||||
<div className="bg-background flex h-full min-h-0 w-full flex-col overflow-hidden">
|
||||
@@ -502,7 +522,7 @@ export default function BatchManagementPage() {
|
||||
<div className="h-8 w-px bg-border" />
|
||||
<div className="flex flex-1 flex-col px-4 py-3">
|
||||
<span className="text-[11px] text-muted-foreground">오늘 실행</span>
|
||||
<span className="text-lg font-bold text-emerald-600">{stats.todayExecutions}</span>
|
||||
<span className="text-lg font-bold text-emerald-600">{todayExec}</span>
|
||||
{execDiff !== 0 && (
|
||||
<span className={`text-[10px] ${execDiff > 0 ? "text-emerald-500" : "text-muted-foreground"}`}>
|
||||
어제보다 {execDiff > 0 ? "+" : ""}{execDiff}
|
||||
@@ -512,8 +532,8 @@ export default function BatchManagementPage() {
|
||||
<div className="h-8 w-px bg-border" />
|
||||
<div className="flex flex-1 flex-col px-4 py-3">
|
||||
<span className="text-[11px] text-muted-foreground">실패</span>
|
||||
<span className={`text-lg font-bold ${stats.todayFailures > 0 ? "text-destructive" : "text-muted-foreground"}`}>
|
||||
{stats.todayFailures}
|
||||
<span className={`text-lg font-bold ${todayFail > 0 ? "text-destructive" : "text-muted-foreground"}`}>
|
||||
{todayFail}
|
||||
</span>
|
||||
{failDiff !== 0 && (
|
||||
<span className={`text-[10px] ${failDiff > 0 ? "text-destructive" : "text-emerald-500"}`}>
|
||||
@@ -525,7 +545,7 @@ export default function BatchManagementPage() {
|
||||
)}
|
||||
|
||||
{/* 24시간 차트 */}
|
||||
<GlobalSparkline stats={stats} />
|
||||
<GlobalSparkline data={globalSparkline} />
|
||||
|
||||
{/* 검색 + 필터 */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
|
||||
@@ -331,14 +331,14 @@ export default function BatchManagementNewPage() {
|
||||
// 내부 데이터베이스 선택
|
||||
connection = connections.find((conn) => conn.type === "internal") || null;
|
||||
} else {
|
||||
// 외부 데이터베이스 선택
|
||||
const connectionId = parseInt(connectionValue);
|
||||
connection = connections.find((conn) => conn.id === connectionId) || null;
|
||||
// 외부 데이터베이스 선택 — id 가 number/string 어느 쪽이든 안전하게 비교
|
||||
connection = connections.find((conn) => conn.id?.toString() === connectionValue) || null;
|
||||
}
|
||||
|
||||
setToConnection(connection);
|
||||
setToTable("");
|
||||
setToColumns([]);
|
||||
setToTables([]);
|
||||
|
||||
if (connection) {
|
||||
try {
|
||||
@@ -383,12 +383,12 @@ export default function BatchManagementNewPage() {
|
||||
if (connectionValue === "internal") {
|
||||
connection = connections.find((conn) => conn.type === "internal") || null;
|
||||
} else {
|
||||
const connectionId = parseInt(connectionValue);
|
||||
connection = connections.find((conn) => conn.id === connectionId) || null;
|
||||
connection = connections.find((conn) => conn.id?.toString() === connectionValue) || null;
|
||||
}
|
||||
setFromConnection(connection);
|
||||
setFromTable("");
|
||||
setFromColumns([]);
|
||||
setFromTables([]);
|
||||
|
||||
if (connection) {
|
||||
try {
|
||||
|
||||
@@ -4,13 +4,14 @@ import { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Search, Hash, Plus, RefreshCw, Trash2, Save, RotateCcw, Loader2,
|
||||
Table2, AlertTriangle, Edit3,
|
||||
Calendar, Type, Hash as HashIcon, Link2, Layers,
|
||||
CheckCircle, X, Sparkles,
|
||||
Table2, Copy, Download, Share2, AlertTriangle, ArrowRight, Edit3,
|
||||
Calendar, Type, Hash as HashIcon, Link2, Code, Minus, Layers,
|
||||
CheckCircle, Eye, Upload, LayoutGrid, X, Sparkles,
|
||||
} from "lucide-react";
|
||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||
import {
|
||||
getNumberingRulesFromTest,
|
||||
getNumberingRuleById,
|
||||
deleteNumberingRuleFromTest,
|
||||
resetSequence,
|
||||
updateRuleSequence,
|
||||
@@ -33,25 +34,23 @@ import {
|
||||
type ResetPeriod = "none" | "daily" | "monthly" | "yearly";
|
||||
type SideFilter = "all" | "active" | "warn" | "unused";
|
||||
|
||||
const PART_KO_LABEL: Record<CodePartType, string> = {
|
||||
text: "고정",
|
||||
date: "날짜",
|
||||
sequence: "순번",
|
||||
number: "숫자",
|
||||
category: "카테고리",
|
||||
reference: "참조",
|
||||
const PART_TONE_BY_TYPE: Record<CodePartType, string> = {
|
||||
text: "text", // prefix
|
||||
date: "date",
|
||||
sequence: "sequence",
|
||||
number: "number",
|
||||
category: "category",
|
||||
reference: "reference",
|
||||
};
|
||||
|
||||
function buildPartEnTag(p: NumberingRulePart): string {
|
||||
switch (p.part_type) {
|
||||
case "text": return "text";
|
||||
case "date": return p.auto_config?.date_format ?? "date";
|
||||
case "sequence": return `${p.auto_config?.sequence_length ?? 4}자리`;
|
||||
case "number": return `${p.auto_config?.number_length ?? 3}자리`;
|
||||
case "category": return "cat";
|
||||
case "reference": return "ref";
|
||||
}
|
||||
}
|
||||
const PART_LABEL_BY_TYPE: Record<CodePartType, string> = {
|
||||
text: "TEXT",
|
||||
date: "DATE",
|
||||
sequence: "SEQ",
|
||||
number: "NUM",
|
||||
category: "CAT",
|
||||
reference: "REF",
|
||||
};
|
||||
|
||||
const RESET_LABEL: Record<ResetPeriod, string> = {
|
||||
none: "없음",
|
||||
@@ -75,17 +74,6 @@ export default function NumberingRuleManagementPage() {
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [draftSequence, setDraftSequence] = useState<string>("");
|
||||
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 () => {
|
||||
@@ -120,13 +108,6 @@ export default function NumberingRuleManagementPage() {
|
||||
}
|
||||
const fromList = rules.find((r) => String(r.rule_id) === selectedRuleId);
|
||||
if (fromList) {
|
||||
if (
|
||||
draftRuleIdsRef.current.has(selectedRuleId) &&
|
||||
dirtyRef.current &&
|
||||
editingRuleIdRef.current === selectedRuleId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const cloned = structuredClone(fromList);
|
||||
setEditingRule(cloned);
|
||||
setDraftSequence(String(cloned.current_sequence ?? 0));
|
||||
@@ -164,6 +145,7 @@ export default function NumberingRuleManagementPage() {
|
||||
|
||||
// 사이드바 섹션 분류
|
||||
const groupedRules = useMemo(() => {
|
||||
const live: NumberingRuleConfig[] = [];
|
||||
const warn: NumberingRuleConfig[] = [];
|
||||
const others: NumberingRuleConfig[] = [];
|
||||
const unused: NumberingRuleConfig[] = [];
|
||||
@@ -209,12 +191,6 @@ export default function NumberingRuleManagementPage() {
|
||||
const resp = await saveNumberingRuleToTest(editingRule);
|
||||
if (resp.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);
|
||||
setRefreshKey((k) => k + 1);
|
||||
} else {
|
||||
@@ -240,18 +216,8 @@ export default function NumberingRuleManagementPage() {
|
||||
const handleDelete = async () => {
|
||||
if (!editingRule) 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 {
|
||||
const resp = await deleteNumberingRuleFromTest(ruleId);
|
||||
const resp = await deleteNumberingRuleFromTest(String(editingRule.rule_id));
|
||||
if (resp.success) {
|
||||
toast.success("규칙이 삭제되었습니다");
|
||||
setSelectedRuleId(null);
|
||||
@@ -267,19 +233,9 @@ export default function NumberingRuleManagementPage() {
|
||||
const handleResetSequence = async () => {
|
||||
if (!editingRule?.rule_id) 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);
|
||||
try {
|
||||
const resp = await resetSequence(ruleId);
|
||||
const resp = await resetSequence(String(editingRule.rule_id));
|
||||
if (resp.success) {
|
||||
toast.success("시퀀스가 초기화되었습니다");
|
||||
setDraftSequence("0");
|
||||
@@ -305,18 +261,9 @@ export default function NumberingRuleManagementPage() {
|
||||
toast.info("현재 시퀀스와 동일합니다");
|
||||
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);
|
||||
try {
|
||||
const resp = await updateRuleSequence(ruleId, newSeq);
|
||||
const resp = await updateRuleSequence(String(editingRule.rule_id), newSeq);
|
||||
if (resp.success) {
|
||||
toast.success(`시퀀스 ${newSeq} 적용됨`);
|
||||
setRefreshKey((k) => k + 1);
|
||||
@@ -330,6 +277,15 @@ 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"]>>) => {
|
||||
if (!editingRule) return;
|
||||
setEditingRule({
|
||||
@@ -341,16 +297,12 @@ export default function NumberingRuleManagementPage() {
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
const addPart = (type: CodePartType, insertAfterOrder?: number) => {
|
||||
const addPart = (type: CodePartType) => {
|
||||
if (!editingRule) return;
|
||||
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 order = (editingRule.parts?.length ?? 0) + 1;
|
||||
const newPart: NumberingRulePart = {
|
||||
id: `part-${Date.now()}-${type}`,
|
||||
order: insertIndex + 1,
|
||||
id: `part-${Date.now()}`,
|
||||
order,
|
||||
part_type: type,
|
||||
generation_method: "auto",
|
||||
auto_config:
|
||||
@@ -364,13 +316,8 @@ export default function NumberingRuleManagementPage() {
|
||||
? { number_length: 3, number_value: 1 }
|
||||
: {},
|
||||
};
|
||||
const nextParts = [
|
||||
...sortedParts.slice(0, insertIndex),
|
||||
newPart,
|
||||
...sortedParts.slice(insertIndex),
|
||||
].map((p, i) => ({ ...p, order: i + 1 }));
|
||||
setEditingRule({ ...editingRule, parts: nextParts });
|
||||
setSelectedPartOrder(insertIndex + 1);
|
||||
setEditingRule({ ...editingRule, parts: [...editingRule.parts, newPart] });
|
||||
setSelectedPartOrder(order);
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
@@ -395,7 +342,6 @@ export default function NumberingRuleManagementPage() {
|
||||
const seq = (editingRule.current_sequence ?? 0) + 1;
|
||||
const today = new Date();
|
||||
return editingRule.parts
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((p) => renderPartValue(p, today, seq))
|
||||
.join(sep);
|
||||
@@ -407,82 +353,55 @@ export default function NumberingRuleManagementPage() {
|
||||
const seq = editingRule.current_sequence ?? 0;
|
||||
const today = new Date();
|
||||
return editingRule.parts
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((p) => renderPartValue(p, today, seq))
|
||||
.join(sep);
|
||||
}, [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 (
|
||||
<div className="v5-nrm">
|
||||
<div className="v5-nrm-topbar">
|
||||
<div className="v5-nrm-topcopy">
|
||||
<h1>
|
||||
<span className="v5-nrm-titlemark">#</span>
|
||||
<div
|
||||
style={{
|
||||
padding: "1.1rem 1.4rem .9rem",
|
||||
borderBottom: "1px solid var(--v5-border)",
|
||||
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>
|
||||
<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 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">
|
||||
<div style={{ display: "flex", gap: ".35rem", alignItems: "center" }}>
|
||||
<span className="v5-nrm-kbd">⌘ K</span>
|
||||
<button
|
||||
className="v5-nrm-btn icon-only ghost"
|
||||
@@ -491,6 +410,9 @@ export default function NumberingRuleManagementPage() {
|
||||
>
|
||||
{loading ? <Loader2 size={13} className="animate-spin" /> : <RefreshCw size={13} />}
|
||||
</button>
|
||||
<button className="v5-nrm-btn ghost">
|
||||
<Download size={13} /> 내보내기
|
||||
</button>
|
||||
<button className="v5-nrm-btn primary" onClick={() => setCmdkOpen(true)}>
|
||||
<Plus size={13} /> 새 채번
|
||||
</button>
|
||||
@@ -540,7 +462,7 @@ export default function NumberingRuleManagementPage() {
|
||||
<div className="v5-nrm-empty">
|
||||
<Hash size={28} />
|
||||
<div>채번 규칙이 없습니다</div>
|
||||
<div className="hint">상단 "새 채번" 또는 ⌘K 로 시작</div>
|
||||
<div className="hint">상단 "새 채번" 또는 ⌘K 로 시작</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -601,10 +523,17 @@ export default function NumberingRuleManagementPage() {
|
||||
<div className="v5-nrm-side-foot">
|
||||
<button
|
||||
className="v5-nrm-btn sm"
|
||||
style={{ flex: 1 }}
|
||||
onClick={() => setCmdkOpen(true)}
|
||||
>
|
||||
<Plus size={11} /> 새 채번
|
||||
</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>
|
||||
</aside>
|
||||
|
||||
@@ -620,114 +549,85 @@ export default function NumberingRuleManagementPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* DETAIL HEAD */}
|
||||
<div className="v5-nrm-detail-head">
|
||||
<div className="l">
|
||||
<h2>
|
||||
{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>
|
||||
{/* HERO */}
|
||||
<div className="v5-nrm-hero">
|
||||
<div className="v5-nrm-hero-top">
|
||||
<div className="v5-nrm-tone cyan" style={{ width: 40, height: 40, borderRadius: 10 }}>
|
||||
<Hash size={18} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="r">
|
||||
<button
|
||||
className="v5-nrm-btn sm ghost danger"
|
||||
title="삭제"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<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 className="v5-nrm-hero-info">
|
||||
<div className="v5-nrm-hero-row1">
|
||||
<h2>{editingRule.rule_name || "(이름 없음)"}</h2>
|
||||
{editingRule.parts?.length > 0 && (
|
||||
<span className="v5-nrm-chip active live">LIVE</span>
|
||||
)}
|
||||
<span className="v5-nrm-chip">{editingRule.rule_id}</span>
|
||||
</div>
|
||||
<div className="v5-nrm-hero-intent">
|
||||
<b>{previewCode || "—"}</b> 로 다음 값이 생성됩니다.
|
||||
</div>
|
||||
<div className="v5-nrm-hero-foot">
|
||||
<span className="it"><span className="k">현재</span><span className="v">{currentCode || "—"}</span></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>
|
||||
<span className="it">
|
||||
<span className="k">리셋</span>
|
||||
<span className="v">
|
||||
{RESET_PERIOD_OPTIONS.find((o) => o.value === editingRule.reset_period)?.label ?? "초기화 안함"}
|
||||
</span>
|
||||
<div className="v5-nrm-hero-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.table_name && editingRule.column_name ? 1 : 0}
|
||||
</span>
|
||||
</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>
|
||||
|
||||
{/* PIPELINE EDITOR */}
|
||||
<div className="v5-nrm-row">
|
||||
<div className="v5-nrm-row-hd">
|
||||
<h3>코드를 이루는 조각</h3>
|
||||
<span className="num">{editingRule.parts.length}개</span>
|
||||
<span className="desc">조각 클릭 = 인스펙터 · 사이 hover = 추가</span>
|
||||
<h3>코드 파이프라인</h3>
|
||||
<span className="num">{editingRule.parts.length} / 8</span>
|
||||
<span className="desc">part 클릭 = 인스펙터</span>
|
||||
<div className="actions">
|
||||
<button className="v5-nrm-btn sm ghost" onClick={handleRevert}>
|
||||
<RotateCcw size={11} /> 초기화
|
||||
@@ -737,54 +637,31 @@ export default function NumberingRuleManagementPage() {
|
||||
|
||||
<div className="v5-nrm-pipe-canvas">
|
||||
{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>
|
||||
) : (
|
||||
editingRule.parts
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((p, idx, arr) => (
|
||||
<span key={`pipe-${p.order}-${p.id}`} style={{ display: "contents" }}>
|
||||
<PipelineBlock
|
||||
part={p}
|
||||
selected={selectedPartOrder === p.order}
|
||||
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>
|
||||
))
|
||||
editingRule.parts.sort((a, b) => a.order - b.order).map((p, idx, arr) => (
|
||||
<PipelineBlock
|
||||
key={`${p.order}-${p.id}`}
|
||||
part={p}
|
||||
selected={selectedPartOrder === p.order}
|
||||
onClick={() => setSelectedPartOrder(p.order)}
|
||||
onRemove={() => removePart(p.order)}
|
||||
isLast={idx === arr.length - 1}
|
||||
separator={editingRule.separator ?? "-"}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
<button className="v5-nrm-drop" onClick={() => addPart("sequence")}>
|
||||
<Plus size={11} /> 끝에 추가
|
||||
<Plus size={12} /> 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedPart && (
|
||||
<PartInspector
|
||||
key={selectedPart.id}
|
||||
part={selectedPart}
|
||||
onUpdate={(patch) => updatePart(selectedPart.order, patch)}
|
||||
onUpdateConfig={(patch) => updatePartAutoConfig(selectedPart.order, patch)}
|
||||
onRemove={() => removePart(selectedPart.order)}
|
||||
rule={editingRule}
|
||||
@@ -796,24 +673,24 @@ export default function NumberingRuleManagementPage() {
|
||||
)}
|
||||
|
||||
<div className="v5-nrm-palette">
|
||||
<span className="lbl">조각 종류:</span>
|
||||
<span className="lbl">+ add part</span>
|
||||
<button className="v5-nrm-palette-item" onClick={() => addPart("text")}>
|
||||
<Type size={11} /> 고정 글자 <span className="en">text</span>
|
||||
<Type size={11} /> text
|
||||
</button>
|
||||
<button className="v5-nrm-palette-item" onClick={() => addPart("date")}>
|
||||
<Calendar size={11} /> 날짜 <span className="en">date</span>
|
||||
<Calendar size={11} /> date
|
||||
</button>
|
||||
<button className="v5-nrm-palette-item" onClick={() => addPart("sequence")}>
|
||||
<HashIcon size={11} /> 순번 <span className="en">seq</span>
|
||||
<HashIcon size={11} /> sequence
|
||||
</button>
|
||||
<button className="v5-nrm-palette-item" onClick={() => addPart("number")}>
|
||||
<Edit3 size={11} /> 고정 숫자 <span className="en">num</span>
|
||||
<Edit3 size={11} /> number
|
||||
</button>
|
||||
<button className="v5-nrm-palette-item" onClick={() => addPart("category")}>
|
||||
<Layers size={11} /> 카테고리 <span className="en">cat</span>
|
||||
<Layers size={11} /> category
|
||||
</button>
|
||||
<button className="v5-nrm-palette-item" onClick={() => addPart("reference")}>
|
||||
<Link2 size={11} /> 참조 <span className="en">ref</span>
|
||||
<Link2 size={11} /> reference
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -934,7 +811,6 @@ export default function NumberingRuleManagementPage() {
|
||||
open={cmdkOpen}
|
||||
onClose={() => setCmdkOpen(false)}
|
||||
rules={rules}
|
||||
onCreateRule={handleCreateDraftRule}
|
||||
onSelectRule={(id) => {
|
||||
setSelectedRuleId(id);
|
||||
setCmdkOpen(false);
|
||||
@@ -994,49 +870,57 @@ function PipelineBlock({
|
||||
selected,
|
||||
onClick,
|
||||
onRemove,
|
||||
isLast,
|
||||
separator,
|
||||
}: {
|
||||
part: NumberingRulePart;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
onRemove: () => void;
|
||||
isLast: boolean;
|
||||
separator: string;
|
||||
}) {
|
||||
const today = new Date();
|
||||
const previewSeq = 1;
|
||||
const val = renderPartValue(part, today, previewSeq);
|
||||
return (
|
||||
<div
|
||||
className={`v5-nrm-block ${part.part_type}${selected ? " sel" : ""}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="x"
|
||||
title="이 조각 삭제"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
<>
|
||||
<div
|
||||
className={`v5-nrm-block ${part.part_type}${selected ? " sel" : ""}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="top-row">
|
||||
<span className="ord">{part.order}번</span>
|
||||
<span className="lbl">{PART_KO_LABEL[part.part_type]}</span>
|
||||
<span className="typ-en">{buildPartEnTag(part)}</span>
|
||||
<span className="pin">{part.order}</span>
|
||||
<span
|
||||
className="x"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
>×</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>
|
||||
<span className="val">{val || "—"}</span>
|
||||
</div>
|
||||
{!isLast && <span className="v5-nrm-jn">{separator}</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PartInspector({
|
||||
part,
|
||||
onUpdate,
|
||||
onUpdateConfig,
|
||||
onRemove,
|
||||
rule,
|
||||
onUpdateRule,
|
||||
}: {
|
||||
part: NumberingRulePart;
|
||||
onUpdate: (patch: Partial<NumberingRulePart>) => void;
|
||||
onUpdateConfig: (patch: Partial<NonNullable<NumberingRulePart["auto_config"]>>) => void;
|
||||
onRemove: () => void;
|
||||
rule: NumberingRuleConfig;
|
||||
@@ -1046,13 +930,13 @@ function PartInspector({
|
||||
<div className="v5-nrm-insp">
|
||||
<div className="v5-nrm-insp-hd">
|
||||
<div className="l">
|
||||
<span className="pin">{part.order}번 조각</span>
|
||||
<span className="pin">#{part.order}</span>
|
||||
<span>
|
||||
<b>{PART_KO_LABEL[part.part_type]}</b> 설정
|
||||
<b>{PART_LABEL_BY_TYPE[part.part_type]}</b> 설정
|
||||
</span>
|
||||
</div>
|
||||
<button className="v5-nrm-btn sm ghost danger" onClick={onRemove}>
|
||||
<Trash2 size={11} /> 이 조각 삭제
|
||||
<Trash2 size={11} /> 삭제
|
||||
</button>
|
||||
</div>
|
||||
<div className="v5-nrm-insp-grid">
|
||||
@@ -1091,7 +975,7 @@ function PartInspector({
|
||||
/>
|
||||
</div>
|
||||
<div className="v5-nrm-insp-field grow">
|
||||
<label>이 채번의 초기화 주기</label>
|
||||
<label>리셋 주기 (규칙 전체)</label>
|
||||
<div className="v5-nrm-seg">
|
||||
{(["none", "daily", "monthly", "yearly"] as ResetPeriod[]).map((p) => (
|
||||
<button
|
||||
@@ -1103,13 +987,6 @@ function PartInspector({
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
@@ -1148,7 +1025,6 @@ function PartInspector({
|
||||
onClick={() => onUpdateConfig({ date_format: f.value })}
|
||||
>
|
||||
{f.value}
|
||||
<span className="sub">{f.example}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -1196,9 +1072,9 @@ function UsageList({ rule }: { rule: NumberingRuleConfig }) {
|
||||
아직 어떤 컬럼에도 연결되지 않음
|
||||
</div>
|
||||
)}
|
||||
<div className="v5-nrm-usage-note">
|
||||
<Table2 size={11} /> 테이블 타입 관리에서 채번 컬럼에 이 규칙을 연결하세요.
|
||||
</div>
|
||||
<button className="v5-nrm-usage-add">
|
||||
<Plus size={11} /> 컬럼에 이 채번 연결 — 테이블 타입 관리로 이동
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1207,13 +1083,11 @@ function CommandPalette({
|
||||
open,
|
||||
onClose,
|
||||
rules,
|
||||
onCreateRule,
|
||||
onSelectRule,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
rules: NumberingRuleConfig[];
|
||||
onCreateRule: (name?: string, preset?: "blank" | "simple" | "monthly" | "daily") => void;
|
||||
onSelectRule: (id: string) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -1257,16 +1131,13 @@ function CommandPalette({
|
||||
<div className="v5-nrm-cmdk-group">
|
||||
<span>빠른 액션</span>
|
||||
</div>
|
||||
<div
|
||||
className="v5-nrm-cmdk-item action focus"
|
||||
onClick={() => onCreateRule(query || undefined, "blank")}
|
||||
>
|
||||
<div className="v5-nrm-cmdk-item action focus">
|
||||
<span className="ic">
|
||||
<Plus size={12} />
|
||||
</span>
|
||||
<div className="v5-nrm-cmdk-row">
|
||||
<div className="v5-nrm-cmdk-title">
|
||||
새 채번{query && <> — <b>"{query}"</b> 이름으로</>} · 빈 캔버스
|
||||
새 채번{query && <> — <b>"{query}"</b> 이름으로</>} · 빈 캔버스
|
||||
</div>
|
||||
<div className="v5-nrm-cmdk-meta">파이프라인 디자이너 열기</div>
|
||||
</div>
|
||||
@@ -1280,7 +1151,7 @@ function CommandPalette({
|
||||
<span>프리셋</span>
|
||||
<span className="num">3</span>
|
||||
</div>
|
||||
<div className="v5-nrm-cmdk-item preset" onClick={() => onCreateRule(query || undefined, "simple")}>
|
||||
<div className="v5-nrm-cmdk-item preset">
|
||||
<span className="ic"><HashIcon size={12} /></span>
|
||||
<div className="v5-nrm-cmdk-row">
|
||||
<div className="v5-nrm-cmdk-title">간단형</div>
|
||||
@@ -1289,7 +1160,7 @@ function CommandPalette({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="v5-nrm-cmdk-item preset" onClick={() => onCreateRule(query || undefined, "monthly")}>
|
||||
<div className="v5-nrm-cmdk-item preset">
|
||||
<span className="ic"><Calendar size={12} /></span>
|
||||
<div className="v5-nrm-cmdk-row">
|
||||
<div className="v5-nrm-cmdk-title">월별 리셋 (권장)</div>
|
||||
@@ -1298,7 +1169,7 @@ function CommandPalette({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="v5-nrm-cmdk-item preset" onClick={() => onCreateRule(query || undefined, "daily")}>
|
||||
<div className="v5-nrm-cmdk-item preset">
|
||||
<span className="ic"><Sparkles size={12} /></span>
|
||||
<div className="v5-nrm-cmdk-row">
|
||||
<div className="v5-nrm-cmdk-title">일별 리셋</div>
|
||||
@@ -1433,9 +1304,9 @@ function buildPatternPreview(rule: NumberingRuleConfig): string {
|
||||
return `<span>${"0".repeat(len)}</span>`;
|
||||
}
|
||||
case "category":
|
||||
return "<span>CAT</span>";
|
||||
return `<span>CAT</span>`;
|
||||
case "reference":
|
||||
return "<span>REF</span>";
|
||||
return `<span>REF</span>`;
|
||||
default:
|
||||
return "?";
|
||||
}
|
||||
|
||||
@@ -21,7 +21,10 @@ import {
|
||||
ChevronsUpDown,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Columns3,
|
||||
Link2,
|
||||
} from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||
import { toast } from "sonner";
|
||||
@@ -56,6 +59,7 @@ import type { TableInfo, ColumnTypeInfo, SecondLevelMenu } from "@/components/ad
|
||||
import { TypeOverviewStrip } from "@/components/admin/table-type/TypeOverviewStrip";
|
||||
import { ColumnGrid } from "@/components/admin/table-type/ColumnGrid";
|
||||
import { ColumnDetailPanel } from "@/components/admin/table-type/ColumnDetailPanel";
|
||||
import { ReferenceListView } from "@/components/admin/table-type/ReferenceListView";
|
||||
|
||||
export default function TableManagementPage() {
|
||||
const { userLang, getText } = useMultiLang({ companyCode: "*" });
|
||||
@@ -131,6 +135,8 @@ export default function TableManagementPage() {
|
||||
indexes: Array<{ name: string; columns: string[]; is_unique: boolean }>;
|
||||
}>({ primaryKey: { name: "", columns: [] }, indexes: [] });
|
||||
const [pkDialogOpen, setPkDialogOpen] = useState(false);
|
||||
// 이번 세션 동안 PK 변경 확인 다이얼로그 건너뛰기 (composite PK 만들 때 매번 다이얼로그 뜨는 답답함 해소)
|
||||
const [pkSkipConfirmSession, setPkSkipConfirmSession] = useState(false);
|
||||
const [pendingPkColumns, setPendingPkColumns] = useState<string[]>([]);
|
||||
|
||||
// 선택된 테이블 목록 (체크박스)
|
||||
@@ -273,11 +279,9 @@ export default function TableManagementPage() {
|
||||
if (response.success && response.data) {
|
||||
setSecondLevelMenus(response.data);
|
||||
} else {
|
||||
console.warn("⚠️ 2레벨 메뉴 로드 실패:", response);
|
||||
setSecondLevelMenus([]); // 빈 배열로 설정하여 로딩 상태 해제
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 2레벨 메뉴 로드 에러:", error);
|
||||
setSecondLevelMenus([]); // 에러 발생 시에도 빈 배열로 설정
|
||||
}
|
||||
};
|
||||
@@ -320,12 +324,6 @@ export default function TableManagementPage() {
|
||||
if (response.data.success) {
|
||||
const data = response.data.data;
|
||||
|
||||
console.log("📥 원본 API 응답:", {
|
||||
hasColumns: !!(data.columns || data),
|
||||
firstColumn: (data.columns || data)[0],
|
||||
statusColumn: (data.columns || data).find((col: any) => col.column_name === "status"),
|
||||
});
|
||||
|
||||
// 컬럼 데이터에 기본값 설정
|
||||
const processedColumns = (data.columns || data).map((col: any) => {
|
||||
let hierarchyRole: "large" | "medium" | "small" | undefined = undefined;
|
||||
@@ -395,9 +393,38 @@ export default function TableManagementPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ESC 키로 우측 상세 패널 닫기 (좁은 화면에서 stuck 방지)
|
||||
useEffect(() => {
|
||||
if (!selectedColumn) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setSelectedColumn(null);
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [selectedColumn]);
|
||||
|
||||
// 저장 안 한 변경 사항이 있는지 — columns 와 originalColumns 의 reference 비교 (immutable update 패턴 의존)
|
||||
const hasUnsavedChanges = useMemo(() => {
|
||||
if (columns.length === 0 || originalColumns.length === 0) return false;
|
||||
if (columns.length !== originalColumns.length) return true;
|
||||
// 직렬화 비교 (얕은 ref 만으론 부족 — handleColumnChange 가 새 객체를 만들지만 다른 필드는 같은 ref 일 수 있어서)
|
||||
try {
|
||||
return JSON.stringify(columns) !== JSON.stringify(originalColumns);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [columns, originalColumns]);
|
||||
|
||||
// 테이블 선택
|
||||
const handleTableSelect = useCallback(
|
||||
(tableName: string) => {
|
||||
if (tableName === selectedTable) return;
|
||||
if (hasUnsavedChanges) {
|
||||
const ok = typeof window !== "undefined"
|
||||
? window.confirm("저장하지 않은 컬럼 변경 사항이 있습니다. 이동하면 변경 내용이 사라집니다. 계속할까요?")
|
||||
: true;
|
||||
if (!ok) return;
|
||||
}
|
||||
setSelectedTable(tableName);
|
||||
setCurrentPage(1);
|
||||
setColumns([]);
|
||||
@@ -412,12 +439,17 @@ export default function TableManagementPage() {
|
||||
loadColumnTypes(tableName, 1, pageSize);
|
||||
loadConstraints(tableName);
|
||||
},
|
||||
[loadColumnTypes, loadConstraints, pageSize, tables],
|
||||
[hasUnsavedChanges, loadColumnTypes, loadConstraints, pageSize, selectedTable, tables],
|
||||
);
|
||||
|
||||
// 입력 타입 변경 - 이전 타입의 설정값 초기화 포함
|
||||
const handleInputTypeChange = useCallback(
|
||||
(columnName: string, newInputType: string) => {
|
||||
// typeFilter 가 활성화된 상태에서 변경된 input_type 이 필터와 불일치하면 자동으로 필터 해제
|
||||
// (그렇지 않으면 사용자가 방금 편집한 행이 그리드에서 갑자기 사라져 혼란)
|
||||
if (typeFilter && typeFilter !== newInputType) {
|
||||
setTypeFilter(null);
|
||||
}
|
||||
setColumns((prev) =>
|
||||
prev.map((col) => {
|
||||
if (col.column_name === columnName) {
|
||||
@@ -608,7 +640,6 @@ export default function TableManagementPage() {
|
||||
};
|
||||
|
||||
finalDetailSettings = JSON.stringify(entitySettings);
|
||||
console.log("🔧 Entity 설정 JSON 생성:", entitySettings);
|
||||
}
|
||||
|
||||
// 🎯 Code 타입인 경우 hierarchyRole을 detailSettings에 포함
|
||||
@@ -628,7 +659,6 @@ export default function TableManagementPage() {
|
||||
};
|
||||
|
||||
finalDetailSettings = JSON.stringify(codeSettings);
|
||||
console.log("🔧 Code 계층 역할 설정 JSON 생성:", codeSettings);
|
||||
}
|
||||
|
||||
const columnSetting = {
|
||||
@@ -646,74 +676,39 @@ export default function TableManagementPage() {
|
||||
|
||||
// console.log("저장할 컬럼 설정:", columnSetting);
|
||||
|
||||
console.log("💾 저장할 컬럼 정보:", {
|
||||
columnName: column.column_name,
|
||||
inputType: column.input_type,
|
||||
categoryMenus: column.category_menus,
|
||||
hasCategoryMenus: !!column.category_menus,
|
||||
categoryMenusLength: column.category_menus?.length || 0,
|
||||
});
|
||||
|
||||
const response = await apiClient.post(`/table-management/tables/${selectedTable}/columns/settings`, [
|
||||
columnSetting,
|
||||
]);
|
||||
|
||||
if (response.data.success) {
|
||||
console.log("✅ 컬럼 설정 저장 성공");
|
||||
|
||||
// 🆕 Category 타입인 경우 컬럼 매핑 처리
|
||||
console.log("🔍 카테고리 조건 체크:", {
|
||||
isCategory: column.input_type === "category",
|
||||
hasCategoryMenus: !!column.category_menus,
|
||||
length: column.category_menus?.length || 0,
|
||||
});
|
||||
|
||||
if (column.input_type === "category" && !column.category_ref) {
|
||||
// 참조가 아닌 자체 카테고리만 메뉴 매핑 처리
|
||||
console.log("기존 카테고리 메뉴 매핑 삭제 시작:", {
|
||||
tableName: selectedTable,
|
||||
columnName: column.column_name,
|
||||
});
|
||||
|
||||
try {
|
||||
const deleteResponse = await deleteColumnMappingsByColumn(selectedTable, column.column_name);
|
||||
console.log("🗑️ 기존 매핑 삭제 결과:", deleteResponse);
|
||||
await deleteColumnMappingsByColumn(selectedTable, column.column_name);
|
||||
} catch (error) {
|
||||
console.error("❌ 기존 매핑 삭제 실패:", error);
|
||||
}
|
||||
|
||||
// 2. 새로운 매핑 추가 (선택된 메뉴가 있는 경우만)
|
||||
if (column.category_menus && column.category_menus.length > 0) {
|
||||
console.log("📥 카테고리 메뉴 매핑 시작:", {
|
||||
columnName: column.column_name,
|
||||
categoryMenus: column.category_menus,
|
||||
count: column.category_menus.length,
|
||||
});
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const menuObjid of column.category_menus) {
|
||||
try {
|
||||
const mappingResponse = await createColumnMapping({
|
||||
// 직렬 await 대신 Promise.allSettled 로 병렬 호출 (메뉴가 많으면 직렬은 수십 초 멈춤)
|
||||
const mappingResults = await Promise.allSettled(
|
||||
column.category_menus.map((menuObjid) =>
|
||||
createColumnMapping({
|
||||
tableName: selectedTable,
|
||||
logicalColumnName: column.column_name,
|
||||
physicalColumnName: column.column_name,
|
||||
menuObjid,
|
||||
description: `${column.display_name} (메뉴별 카테고리)`,
|
||||
});
|
||||
|
||||
if (mappingResponse.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
console.error("❌ 매핑 생성 실패:", mappingResponse);
|
||||
failCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 메뉴 ${menuObjid}에 대한 매핑 생성 실패:`, error);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
const successCount = mappingResults.filter(
|
||||
(r) => r.status === "fulfilled" && r.value.success,
|
||||
).length;
|
||||
const failCount = mappingResults.length - successCount;
|
||||
|
||||
if (successCount > 0 && failCount === 0) {
|
||||
toast.success(`컬럼 설정 및 ${successCount}개 메뉴 매핑이 저장되었습니다.`);
|
||||
@@ -732,10 +727,8 @@ export default function TableManagementPage() {
|
||||
// 원본 데이터 업데이트
|
||||
setOriginalColumns((prev) => prev.map((col) => (col.column_name === column.column_name ? column : col)));
|
||||
|
||||
// 저장 후 데이터 확인을 위해 다시 로드
|
||||
setTimeout(() => {
|
||||
loadColumnTypes(selectedTable);
|
||||
}, 1000);
|
||||
// 저장 후 데이터 확인을 위해 다시 로드 (await 로 즉시 reload — race + 깜빡임 회피)
|
||||
await loadColumnTypes(selectedTable);
|
||||
} else {
|
||||
showErrorToast("컬럼 설정 저장에 실패했습니다", response.data.message, {
|
||||
guidance: "입력 데이터를 확인하고 다시 시도해 주세요.",
|
||||
@@ -860,69 +853,39 @@ export default function TableManagementPage() {
|
||||
// 자체 카테고리 컬럼만 메뉴 매핑 처리 (참조 컬럼 제외)
|
||||
const categoryColumns = columns.filter((col) => col.input_type === "category" && !col.category_ref);
|
||||
|
||||
console.log("📥 전체 저장: 카테고리 컬럼 확인", {
|
||||
totalColumns: columns.length,
|
||||
categoryColumns: categoryColumns.length,
|
||||
categoryColumnsData: categoryColumns.map((col) => ({
|
||||
columnName: col.column_name,
|
||||
categoryMenus: col.category_menus,
|
||||
})),
|
||||
});
|
||||
|
||||
if (categoryColumns.length > 0) {
|
||||
let totalSuccessCount = 0;
|
||||
let totalFailCount = 0;
|
||||
|
||||
for (const column of categoryColumns) {
|
||||
// 1. 먼저 기존 매핑 모두 삭제
|
||||
console.log("🗑️ 기존 카테고리 메뉴 매핑 삭제:", {
|
||||
tableName: selectedTable,
|
||||
columnName: column.column_name,
|
||||
});
|
||||
|
||||
try {
|
||||
const deleteResponse = await deleteColumnMappingsByColumn(selectedTable, column.column_name);
|
||||
console.log("🗑️ 기존 매핑 삭제 결과:", deleteResponse);
|
||||
await deleteColumnMappingsByColumn(selectedTable, column.column_name);
|
||||
} catch (error) {
|
||||
console.error("❌ 기존 매핑 삭제 실패:", error);
|
||||
}
|
||||
|
||||
// 2. 새로운 매핑 추가 (선택된 메뉴가 있는 경우만)
|
||||
// 2. 새로운 매핑 추가 (선택된 메뉴가 있는 경우만) — 직렬 await 대신 Promise.allSettled 병렬 호출
|
||||
if (column.category_menus && column.category_menus.length > 0) {
|
||||
for (const menuObjid of column.category_menus) {
|
||||
try {
|
||||
console.log("🔄 매핑 API 호출:", {
|
||||
tableName: selectedTable,
|
||||
columnName: column.column_name,
|
||||
menuObjid,
|
||||
});
|
||||
|
||||
const mappingResponse = await createColumnMapping({
|
||||
const mappingResults = await Promise.allSettled(
|
||||
column.category_menus.map((menuObjid) =>
|
||||
createColumnMapping({
|
||||
tableName: selectedTable,
|
||||
logicalColumnName: column.column_name,
|
||||
physicalColumnName: column.column_name,
|
||||
menuObjid,
|
||||
description: `${column.display_name} (메뉴별 카테고리)`,
|
||||
});
|
||||
|
||||
console.log("✅ 매핑 API 응답:", mappingResponse);
|
||||
|
||||
if (mappingResponse.success) {
|
||||
totalSuccessCount++;
|
||||
} else {
|
||||
console.error("❌ 매핑 생성 실패:", mappingResponse);
|
||||
totalFailCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 메뉴 ${menuObjid}에 대한 매핑 생성 실패:`, error);
|
||||
totalFailCount++;
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
const colSuccess = mappingResults.filter(
|
||||
(r) => r.status === "fulfilled" && r.value.success,
|
||||
).length;
|
||||
totalSuccessCount += colSuccess;
|
||||
totalFailCount += mappingResults.length - colSuccess;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("📊 전체 매핑 결과:", { totalSuccessCount, totalFailCount });
|
||||
|
||||
if (totalSuccessCount > 0) {
|
||||
toast.success(`테이블 설정 및 ${totalSuccessCount}개 카테고리 메뉴 매핑이 저장되었습니다.`);
|
||||
} else if (totalFailCount > 0) {
|
||||
@@ -940,10 +903,8 @@ export default function TableManagementPage() {
|
||||
// 테이블 목록 새로고침 (라벨 변경 반영)
|
||||
loadTables();
|
||||
|
||||
// 저장 후 데이터 다시 로드
|
||||
setTimeout(() => {
|
||||
loadColumnTypes(selectedTable, 1, pageSize);
|
||||
}, 1000);
|
||||
// 저장 후 데이터 다시 로드 (await 로 즉시 reload — race + 깜빡임 회피)
|
||||
await loadColumnTypes(selectedTable, 1, pageSize);
|
||||
} else {
|
||||
showErrorToast("설정 저장에 실패했습니다", response.data.message, {
|
||||
guidance: "잠시 후 다시 시도해 주세요.",
|
||||
@@ -1054,24 +1015,28 @@ export default function TableManagementPage() {
|
||||
} else {
|
||||
newPkCols = currentPkCols.filter((c) => c !== columnName);
|
||||
}
|
||||
// 이번 세션 동안 묻지 않기로 한 경우 즉시 적용
|
||||
if (pkSkipConfirmSession) {
|
||||
applyPkChange(newPkCols);
|
||||
return;
|
||||
}
|
||||
// PK 변경은 확인 다이얼로그 표시
|
||||
setPendingPkColumns(newPkCols);
|
||||
setPkDialogOpen(true);
|
||||
},
|
||||
[constraints.primaryKey?.columns],
|
||||
[constraints.primaryKey?.columns, pkSkipConfirmSession],
|
||||
);
|
||||
|
||||
// PK 변경 확인
|
||||
const handlePkConfirm = async () => {
|
||||
// PK 변경 실제 적용 (다이얼로그 거치지 않거나 거친 후 호출)
|
||||
const applyPkChange = async (newPkCols: string[]) => {
|
||||
if (!selectedTable) return;
|
||||
try {
|
||||
if (pendingPkColumns.length === 0) {
|
||||
if (newPkCols.length === 0) {
|
||||
toast.error("PK 컬럼을 최소 1개 이상 선택해야 합니다.");
|
||||
setPkDialogOpen(false);
|
||||
return;
|
||||
}
|
||||
const response = await apiClient.put(`/table-management/tables/${selectedTable}/primary-key`, {
|
||||
columns: pendingPkColumns,
|
||||
columns: newPkCols,
|
||||
});
|
||||
if (response.data.success) {
|
||||
toast.success(response.data.message);
|
||||
@@ -1083,11 +1048,15 @@ export default function TableManagementPage() {
|
||||
showErrorToast("PK 설정에 실패했습니다", error, {
|
||||
guidance: "컬럼 정보를 확인하고 다시 시도해 주세요.",
|
||||
});
|
||||
} finally {
|
||||
setPkDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// PK 변경 확인 (다이얼로그에서 호출)
|
||||
const handlePkConfirm = async () => {
|
||||
setPkDialogOpen(false);
|
||||
await applyPkChange(pendingPkColumns);
|
||||
};
|
||||
|
||||
// 인덱스 토글 핸들러 (일반 인덱스만 DB 레벨 - 유니크는 앱 레벨 소프트 제약조건으로 분리됨)
|
||||
const handleIndexToggle = useCallback(
|
||||
async (columnName: string, indexType: "index", checked: boolean) => {
|
||||
@@ -1690,56 +1659,117 @@ export default function TableManagementPage() {
|
||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<TypeOverviewStrip
|
||||
columns={columns}
|
||||
activeFilter={typeFilter}
|
||||
onFilterChange={setTypeFilter}
|
||||
/>
|
||||
<ColumnGrid
|
||||
columns={columns}
|
||||
selectedColumn={selectedColumn}
|
||||
onSelectColumn={(c) => setSelectedColumn((prev) => (prev === c ? null : c))}
|
||||
onColumnChange={(columnName, field, value) => {
|
||||
if (field === "is_unique") {
|
||||
const currentColumn = columns.find((c) => c.column_name === columnName);
|
||||
if (currentColumn) {
|
||||
handleUniqueToggle(columnName, currentColumn.is_unique || "NO");
|
||||
<Tabs defaultValue="columns" className="flex min-h-0 flex-1 flex-col">
|
||||
<TabsList
|
||||
className={cn(
|
||||
"h-9 w-full shrink-0 justify-start gap-1 rounded-none bg-transparent p-0 px-2 pt-1",
|
||||
"border-b border-border",
|
||||
)}
|
||||
>
|
||||
<TabsTrigger
|
||||
value="columns"
|
||||
className={cn(
|
||||
"flex flex-none items-center gap-2 rounded-t-md rounded-b-none border border-border bg-transparent -mb-px",
|
||||
"px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors",
|
||||
"hover:bg-muted/40 hover:text-foreground",
|
||||
"data-[state=active]:bg-card data-[state=active]:text-primary data-[state=active]:font-semibold",
|
||||
"data-[state=active]:border-b-card",
|
||||
"data-[state=active]:shadow-[inset_0_2px_0_hsl(var(--primary))]",
|
||||
)}
|
||||
>
|
||||
<Columns3 className="h-4 w-4" />
|
||||
컬럼
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="references"
|
||||
className={cn(
|
||||
"flex flex-none items-center gap-2 rounded-t-md rounded-b-none border border-border bg-transparent -mb-px",
|
||||
"px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors",
|
||||
"hover:bg-muted/40 hover:text-foreground",
|
||||
"data-[state=active]:bg-card data-[state=active]:text-primary data-[state=active]:font-semibold",
|
||||
"data-[state=active]:border-b-card",
|
||||
"data-[state=active]:shadow-[inset_0_2px_0_hsl(var(--primary))]",
|
||||
)}
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
참조
|
||||
{(() => {
|
||||
const refCount = columns.filter((c) =>
|
||||
["entity", "code", "category", "numbering"].includes(c.input_type),
|
||||
).length;
|
||||
return refCount > 0 ? (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 px-1.5 text-[11px]">
|
||||
{refCount}
|
||||
</Badge>
|
||||
) : null;
|
||||
})()}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="columns" className="mt-0 flex min-h-0 flex-1 flex-col">
|
||||
<TypeOverviewStrip
|
||||
columns={columns}
|
||||
activeFilter={typeFilter}
|
||||
onFilterChange={setTypeFilter}
|
||||
/>
|
||||
<ColumnGrid
|
||||
columns={columns}
|
||||
selectedColumn={selectedColumn}
|
||||
onSelectColumn={(c) => setSelectedColumn((prev) => (prev === c ? null : c))}
|
||||
onColumnChange={(columnName, field, value) => {
|
||||
if (field === "is_unique") {
|
||||
const currentColumn = columns.find((c) => c.column_name === columnName);
|
||||
if (currentColumn) {
|
||||
handleUniqueToggle(columnName, currentColumn.is_unique || "NO");
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (field === "is_nullable") {
|
||||
const currentColumn = columns.find((c) => c.column_name === columnName);
|
||||
if (currentColumn) {
|
||||
handleNullableToggle(columnName, currentColumn.is_nullable || "YES");
|
||||
if (field === "is_nullable") {
|
||||
const currentColumn = columns.find((c) => c.column_name === columnName);
|
||||
if (currentColumn) {
|
||||
handleNullableToggle(columnName, currentColumn.is_nullable || "YES");
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
const idx = columns.findIndex((c) => c.column_name === columnName);
|
||||
if (idx >= 0) handleColumnChange(idx, field, value);
|
||||
}}
|
||||
constraints={constraints}
|
||||
typeFilter={typeFilter}
|
||||
getColumnIndexState={getColumnIndexState}
|
||||
onPkToggle={handlePkToggle}
|
||||
onIndexToggle={(columnName, checked) =>
|
||||
handleIndexToggle(columnName, "index", checked)
|
||||
}
|
||||
const idx = columns.findIndex((c) => c.column_name === columnName);
|
||||
if (idx >= 0) handleColumnChange(idx, field, value);
|
||||
}}
|
||||
constraints={constraints}
|
||||
typeFilter={typeFilter}
|
||||
getColumnIndexState={getColumnIndexState}
|
||||
onPkToggle={handlePkToggle}
|
||||
onIndexToggle={(columnName, checked) =>
|
||||
handleIndexToggle(columnName, "index", checked)
|
||||
}
|
||||
onDeleteColumn={handleDeleteColumnClick}
|
||||
tables={tables}
|
||||
referenceTableColumns={referenceTableColumns}
|
||||
/>
|
||||
</>
|
||||
onDeleteColumn={handleDeleteColumnClick}
|
||||
tables={tables}
|
||||
referenceTableColumns={referenceTableColumns}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="references" className="mt-0 flex min-h-0 flex-1 flex-col">
|
||||
<ReferenceListView
|
||||
columns={columns}
|
||||
tables={tables}
|
||||
referenceTableColumns={referenceTableColumns}
|
||||
selectedColumn={selectedColumn}
|
||||
onSelectColumn={(c) => setSelectedColumn((prev) => (prev === c ? null : c))}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 우측: 상세 패널 (overlay slide-in/out — 가운데 본문 위에 부드럽게 등장) */}
|
||||
{/* 우측: 상세 패널
|
||||
- 와이드 모니터 (xl 이상): 항상 보이는 고정 3-pane
|
||||
- 좁은 화면: 기존처럼 슬라이드 in 오버레이 */}
|
||||
<div
|
||||
className={cn(
|
||||
"bg-card absolute top-0 right-0 bottom-0 z-20 flex w-[380px] flex-col overflow-hidden border-l shadow-2xl transition-transform duration-300 ease-out",
|
||||
selectedColumn ? "translate-x-0" : "pointer-events-none translate-x-full",
|
||||
"xl:relative xl:z-0 xl:flex-shrink-0 xl:translate-x-0 xl:pointer-events-auto xl:shadow-none xl:transition-none",
|
||||
)}
|
||||
>
|
||||
<ColumnDetailPanel
|
||||
@@ -1754,6 +1784,21 @@ export default function TableManagementPage() {
|
||||
handleInputTypeChange(selectedColumn, value as string);
|
||||
return;
|
||||
}
|
||||
// 그리드 칩과 동일하게 is_nullable/is_unique 는 즉시 저장
|
||||
if (field === "is_nullable") {
|
||||
const currentColumn = columns.find((c) => c.column_name === selectedColumn);
|
||||
if (currentColumn) {
|
||||
handleNullableToggle(selectedColumn, currentColumn.is_nullable || "YES");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (field === "is_unique") {
|
||||
const currentColumn = columns.find((c) => c.column_name === selectedColumn);
|
||||
if (currentColumn) {
|
||||
handleUniqueToggle(selectedColumn, currentColumn.is_unique || "NO");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (field === "reference_table" && value) {
|
||||
loadReferenceTableColumns(value as string);
|
||||
}
|
||||
@@ -1803,13 +1848,13 @@ export default function TableManagementPage() {
|
||||
setDuplicateSourceTable(null);
|
||||
}}
|
||||
mode={duplicateModalMode}
|
||||
sourceTableName={duplicateSourceTable || undefined}
|
||||
source_table_name={duplicateSourceTable || undefined}
|
||||
/>
|
||||
|
||||
<AddColumnModal
|
||||
isOpen={addColumnModalOpen}
|
||||
onClose={() => setAddColumnModalOpen(false)}
|
||||
tableName={selectedTable || ""}
|
||||
table_name={selectedTable || ""}
|
||||
onSuccess={async (result) => {
|
||||
toast.success("컬럼이 성공적으로 추가되었습니다!");
|
||||
// 테이블 목록 새로고침 (컬럼 수 업데이트)
|
||||
@@ -2001,6 +2046,14 @@ export default function TableManagementPage() {
|
||||
<p className="text-destructive mt-2 text-sm">PK가 모두 제거됩니다</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
|
||||
<Checkbox
|
||||
checked={pkSkipConfirmSession}
|
||||
onCheckedChange={(v) => setPkSkipConfirmSession(v === true)}
|
||||
/>
|
||||
이번 세션 동안 PK 변경 확인 다이얼로그 건너뛰기 (composite PK 만들 때 편함)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
|
||||
@@ -429,7 +429,7 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
|
||||
}
|
||||
|
||||
// 테이블 위젯이 있으면 자동 로드 건너뜀 (테이블 행 선택으로 데이터 로드)
|
||||
// canonical table / widgetType=table 등 table-like 컴포넌트 모두 동일하게 skip
|
||||
// canonical table / legacy table-list / hidden v2-table-list / widgetType=table 모두 동일하게 skip
|
||||
const hasTableWidget = layout.components.some((comp: any) => isTableLikeComponent(comp));
|
||||
|
||||
if (hasTableWidget) {
|
||||
|
||||
@@ -19,8 +19,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Loader2, Info, AlertCircle, CheckCircle2, Plus, Activity } from "lucide-react";
|
||||
import { Loader2, Info, AlertCircle, CheckCircle2, Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { ColumnDefinitionTable } from "./ColumnDefinitionTable";
|
||||
import { ddlApi } from "../../lib/api/ddl";
|
||||
@@ -57,8 +56,6 @@ export function CreateTableModal({
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [tableNameError, setTableNameError] = useState("");
|
||||
const [validationResult, setValidationResult] = useState<any>(null);
|
||||
const [useLogTable, setUseLogTable] = useState(false);
|
||||
|
||||
/**
|
||||
* 모달 리셋
|
||||
*/
|
||||
@@ -76,7 +73,6 @@ export function CreateTableModal({
|
||||
]);
|
||||
setTableNameError("");
|
||||
setValidationResult(null);
|
||||
setUseLogTable(false);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -107,15 +103,11 @@ export function CreateTableModal({
|
||||
try {
|
||||
// 1. 테이블 컬럼 정보 조회
|
||||
const columnsResponse = await tableManagementApi.getColumnList(tableName);
|
||||
|
||||
console.log("🔍 컬럼 조회 응답:", columnsResponse);
|
||||
|
||||
|
||||
if (columnsResponse.success && columnsResponse.data) {
|
||||
// API는 { columns, total, page, size } 형태로 반환
|
||||
const columnsList = columnsResponse.data.columns;
|
||||
|
||||
console.log("🔍 컬럼 리스트:", columnsList);
|
||||
|
||||
|
||||
if (columnsList && columnsList.length > 0) {
|
||||
// 첫 번째 컬럼에서 테이블 설명 가져오기 (모든 컬럼이 같은 테이블 설명을 가짐)
|
||||
const firstColumn = columnsList[0];
|
||||
@@ -285,23 +277,6 @@ export function CreateTableModal({
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
|
||||
// 로그 테이블 생성 옵션이 선택되었다면 로그 테이블 생성
|
||||
if (useLogTable) {
|
||||
try {
|
||||
const pkColumn = { columnName: "id", dataType: "integer" };
|
||||
const logResult = await tableManagementApi.createLogTable(tableName, pkColumn);
|
||||
|
||||
if (logResult.success) {
|
||||
toast.success(`${tableName}_log 테이블이 생성되었습니다.`);
|
||||
} else {
|
||||
toast.warning(`테이블은 생성되었으나 로그 테이블 생성 실패: ${logResult.message}`);
|
||||
}
|
||||
} catch (logError) {
|
||||
toast.warning("테이블은 생성되었으나 로그 테이블 생성 중 오류가 발생했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
onSuccess(result);
|
||||
onClose();
|
||||
} else {
|
||||
@@ -380,29 +355,6 @@ export function CreateTableModal({
|
||||
<ColumnDefinitionTable columns={columns} onChange={setColumns} disabled={loading} />
|
||||
</div>
|
||||
|
||||
{/* 로그 테이블 생성 옵션 - 통합 변경 이력 시스템으로 대체됨 (숨김 처리) */}
|
||||
{/* <div className="flex items-start space-x-3 rounded-lg border p-4">
|
||||
<Checkbox
|
||||
id="useLogTable"
|
||||
checked={useLogTable}
|
||||
onCheckedChange={(checked) => setUseLogTable(checked as boolean)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor="useLogTable"
|
||||
className="flex cursor-pointer items-center gap-2 text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
<Activity className="h-4 w-4" />
|
||||
변경 이력 로그 테이블 생성
|
||||
</label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
선택 시 <code className="bg-muted rounded px-1 py-0.5">{tableName || "table"}_log</code> 테이블이
|
||||
자동으로 생성되어 INSERT/UPDATE/DELETE 변경 이력을 기록합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{/* 자동 추가 컬럼 안내 */}
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
|
||||
@@ -113,8 +113,29 @@ export function ColumnDetailPanel({
|
||||
}, [referenceTableOptions, tables]);
|
||||
|
||||
// early return 은 반드시 모든 hook 호출 뒤에 (Rules of Hooks).
|
||||
// overlay 패턴으로 항상 마운트되므로 column null 케이스가 정상적으로 들어옴.
|
||||
if (!column) return null;
|
||||
// 컬럼 선택 안 한 상태에서도 패널이 항상 보이는 와이드 레이아웃 대응 — 빈 상태 안내 UI 표시.
|
||||
if (!column) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col border-l bg-card">
|
||||
{/* 좁은 화면에서 패널이 슬라이드 in 된 상태로 column=null 이 되면 닫을 수단이 없어
|
||||
stuck 되는 문제 방지 — 빈 상태에도 X 버튼 유지 */}
|
||||
<div className="flex flex-shrink-0 items-center justify-end px-4 py-3">
|
||||
<Button variant="ghost" size="icon" onClick={onClose} aria-label="닫기" className="h-7 w-7">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
|
||||
<div className="rounded-full bg-muted/60 p-4">
|
||||
<Settings2 className="h-8 w-8 text-muted-foreground/60" />
|
||||
</div>
|
||||
<p className="mt-4 text-sm font-medium text-foreground">컬럼을 선택해주세요</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
좌측 그리드에서 컬럼을 선택하면 여기에 상세 설정이 표시됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col border-l bg-card">
|
||||
@@ -183,12 +204,12 @@ export function ColumnDetailPanel({
|
||||
isLegacy && "cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"text-base font-bold leading-none",
|
||||
isSelected ? "text-primary" : conf.color,
|
||||
)}>
|
||||
{conf.iconChar}
|
||||
</span>
|
||||
<conf.Icon
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
isSelected ? "text-primary" : conf.color,
|
||||
)}
|
||||
/>
|
||||
<span className={cn(
|
||||
"text-[16px] font-semibold leading-tight",
|
||||
isSelected ? "text-primary" : "text-foreground",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { MoreHorizontal, Database, Layers, FileStack, Trash2 } from "lucide-react";
|
||||
import { MoreHorizontal, Database, FileStack, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
@@ -47,11 +47,10 @@ function getIndexState(
|
||||
return { isPk, hasIndex };
|
||||
}
|
||||
|
||||
/** 그룹 헤더 라벨 */
|
||||
const GROUP_LABELS: Record<string, { icon: React.FC<{ className?: string }>; label: string }> = {
|
||||
basic: { icon: FileStack, label: "기본 정보" },
|
||||
reference: { icon: Layers, label: "참조 정보" },
|
||||
meta: { icon: Database, label: "메타 정보" },
|
||||
/** 그룹 헤더 라벨 — 참조 컬럼은 별도 "참조" 탭에서 보여주므로 컬럼 탭에서는 사용자/시스템 2그룹으로만 분류 */
|
||||
const GROUP_LABELS: Record<"user" | "system", { icon: React.FC<{ className?: string }>; label: string }> = {
|
||||
user: { icon: FileStack, label: "사용자 컬럼" },
|
||||
system: { icon: Database, label: "시스템 컬럼" },
|
||||
};
|
||||
|
||||
export function ColumnGrid({
|
||||
@@ -73,30 +72,28 @@ export function ColumnGrid({
|
||||
[constraints, externalGetIndexState],
|
||||
);
|
||||
|
||||
/** typeFilter 적용 후 그룹별로 정렬 */
|
||||
/** typeFilter 적용 후 사용자/시스템 그룹으로 분류 (참조 컬럼은 참조 탭으로 분리됐으므로 사용자 컬럼에 합침) */
|
||||
const filteredAndGrouped = useMemo(() => {
|
||||
const filtered =
|
||||
typeFilter != null ? columns.filter((c) => (c.input_type || "text") === typeFilter) : columns;
|
||||
const groups = { basic: [] as ColumnTypeInfo[], reference: [] as ColumnTypeInfo[], meta: [] as ColumnTypeInfo[] };
|
||||
const groups = { user: [] as ColumnTypeInfo[], system: [] as ColumnTypeInfo[] };
|
||||
for (const col of filtered) {
|
||||
const group = getColumnGroup(col);
|
||||
groups[group].push(col);
|
||||
const g = getColumnGroup(col) === "meta" ? "system" : "user";
|
||||
groups[g].push(col);
|
||||
}
|
||||
return groups;
|
||||
}, [columns, typeFilter]);
|
||||
|
||||
const totalFiltered =
|
||||
filteredAndGrouped.basic.length + filteredAndGrouped.reference.length + filteredAndGrouped.meta.length;
|
||||
const totalFiltered = filteredAndGrouped.user.length + filteredAndGrouped.system.length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div
|
||||
className="grid flex-shrink-0 items-center border-b bg-muted/50 px-4 py-2 text-xs font-semibold text-foreground"
|
||||
style={{ gridTemplateColumns: "4px 140px 1fr 100px 160px 40px" }}
|
||||
style={{ gridTemplateColumns: "4px 1fr 100px 160px 40px" }}
|
||||
>
|
||||
<span />
|
||||
<span>라벨 · 컬럼명</span>
|
||||
<span>참조/설정</span>
|
||||
<span>타입</span>
|
||||
<span className="text-center">PK / NN / IDX / UQ</span>
|
||||
<span />
|
||||
@@ -108,7 +105,7 @@ export function ColumnGrid({
|
||||
{typeFilter ? "해당 타입의 컬럼이 없습니다." : "컬럼이 없습니다."}
|
||||
</div>
|
||||
) : (
|
||||
(["basic", "reference", "meta"] as const).map((groupKey) => {
|
||||
(["user", "system"] as const).map((groupKey) => {
|
||||
const list = filteredAndGrouped[groupKey];
|
||||
if (list.length === 0) return null;
|
||||
const { icon: Icon, label } = GROUP_LABELS[groupKey];
|
||||
@@ -142,7 +139,7 @@ export function ColumnGrid({
|
||||
}}
|
||||
className={cn(
|
||||
"grid min-h-12 cursor-pointer items-center gap-2 rounded-md border px-4 py-2 transition-colors",
|
||||
"grid-cols-[4px_140px_1fr_100px_160px_40px]",
|
||||
"grid-cols-[4px_1fr_100px_160px_40px]",
|
||||
"bg-card border-transparent hover:border-border hover:shadow-sm",
|
||||
isSelected && "border-primary/30 bg-primary/5 shadow-sm",
|
||||
)}
|
||||
@@ -159,66 +156,6 @@ export function ColumnGrid({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 참조/설정 칩 */}
|
||||
<div className="flex min-w-0 flex-wrap gap-1">
|
||||
{column.input_type === "entity" && column.reference_table && column.reference_table !== "none" && (
|
||||
<>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs font-normal"
|
||||
title={
|
||||
tables
|
||||
? (() => {
|
||||
const t = tables.find((tb) => tb.table_name === column.reference_table);
|
||||
return t?.display_name && t.display_name !== t.table_name
|
||||
? `${t.display_name} (${column.reference_table})`
|
||||
: column.reference_table;
|
||||
})()
|
||||
: column.reference_table
|
||||
}
|
||||
>
|
||||
{column.reference_table}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">→</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs font-normal"
|
||||
title={
|
||||
referenceTableColumns?.[column.reference_table]
|
||||
? (() => {
|
||||
const refCols = referenceTableColumns[column.reference_table];
|
||||
const c = refCols.find((rc) => rc.column_name === (column.reference_column ?? ""));
|
||||
return c?.display_name && c.display_name !== c.column_name
|
||||
? `${c.display_name} (${column.reference_column})`
|
||||
: column.reference_column ?? "—";
|
||||
})()
|
||||
: column.reference_column ?? "—"
|
||||
}
|
||||
>
|
||||
{column.reference_column || "—"}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
{column.input_type === "code" && (
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{column.code_info ?? "—"} · {column.default_value ?? ""}
|
||||
</span>
|
||||
)}
|
||||
{column.input_type === "numbering" && column.numbering_rule_id && (
|
||||
<Badge variant="outline" className="text-xs font-normal">
|
||||
{column.numbering_rule_id}
|
||||
</Badge>
|
||||
)}
|
||||
{column.input_type !== "entity" &&
|
||||
column.input_type !== "code" &&
|
||||
column.input_type !== "numbering" &&
|
||||
(column.default_value ? (
|
||||
<span className="text-muted-foreground truncate text-xs">{column.default_value}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground/60 text-xs">—</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 타입 뱃지 */}
|
||||
<div className={cn("rounded-md border px-2 py-0.5 text-xs", typeConf.bgColor, typeConf.color)}>
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-current opacity-70" />
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Database, FolderTree, Hash, Link2, FileCode2 } from "lucide-react";
|
||||
import type { ColumnTypeInfo, TableInfo } from "./types";
|
||||
import { INPUT_TYPE_COLORS } from "./types";
|
||||
import type { ReferenceTableColumn } from "@/lib/api/entityJoin";
|
||||
|
||||
export interface ReferenceListViewProps {
|
||||
columns: ColumnTypeInfo[];
|
||||
tables?: TableInfo[];
|
||||
referenceTableColumns?: Record<string, ReferenceTableColumn[]>;
|
||||
onSelectColumn?: (columnName: string) => void;
|
||||
selectedColumn?: string | null;
|
||||
}
|
||||
|
||||
type RefKind = "entity" | "code" | "category" | "numbering";
|
||||
|
||||
const KIND_META: Record<
|
||||
RefKind,
|
||||
{ icon: React.FC<{ className?: string }>; label: string; color: string; bgColor: string }
|
||||
> = {
|
||||
entity: { icon: Link2, label: "테이블 참조", color: "text-violet-600", bgColor: "bg-violet-50" },
|
||||
code: { icon: FileCode2, label: "공통코드", color: "text-emerald-600", bgColor: "bg-emerald-50" },
|
||||
category: { icon: FolderTree, label: "카테고리", color: "text-teal-600", bgColor: "bg-teal-50" },
|
||||
numbering: { icon: Hash, label: "채번", color: "text-orange-600", bgColor: "bg-orange-50" },
|
||||
};
|
||||
|
||||
function getRefKind(col: ColumnTypeInfo): RefKind | null {
|
||||
const t = col.input_type;
|
||||
if (t === "entity" || t === "code" || t === "category" || t === "numbering") return t;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ReferenceListView({
|
||||
columns,
|
||||
tables,
|
||||
referenceTableColumns,
|
||||
onSelectColumn,
|
||||
selectedColumn = null,
|
||||
}: ReferenceListViewProps) {
|
||||
const grouped = useMemo(() => {
|
||||
const groups: Record<RefKind, ColumnTypeInfo[]> = {
|
||||
entity: [],
|
||||
code: [],
|
||||
category: [],
|
||||
numbering: [],
|
||||
};
|
||||
for (const col of columns) {
|
||||
const kind = getRefKind(col);
|
||||
if (kind) groups[kind].push(col);
|
||||
}
|
||||
return groups;
|
||||
}, [columns]);
|
||||
|
||||
const totalRefs =
|
||||
grouped.entity.length + grouped.code.length + grouped.category.length + grouped.numbering.length;
|
||||
|
||||
if (totalRefs === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Database className="h-8 w-8 text-muted-foreground/50" />
|
||||
<span>이 테이블에는 참조 컬럼이 없어요.</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div
|
||||
className="grid flex-shrink-0 items-center border-b bg-muted/50 px-4 py-2 text-xs font-semibold text-foreground"
|
||||
style={{ gridTemplateColumns: "4px 220px 110px 1fr" }}
|
||||
>
|
||||
<span />
|
||||
<span>소스 컬럼</span>
|
||||
<span>참조 종류</span>
|
||||
<span>참조 대상</span>
|
||||
</div>
|
||||
|
||||
{/* 그룹별 행 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{(["entity", "code", "category", "numbering"] as const).map((kind) => {
|
||||
const list = grouped[kind];
|
||||
if (list.length === 0) return null;
|
||||
const meta = KIND_META[kind];
|
||||
const KindIcon = meta.icon;
|
||||
return (
|
||||
<div key={kind} className="space-y-1 py-2">
|
||||
<div className="flex items-center gap-2 border-b border-border/60 px-4 pb-1.5">
|
||||
<KindIcon className={cn("h-4 w-4", meta.color)} />
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{meta.label}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{list.length}
|
||||
</Badge>
|
||||
</div>
|
||||
{list.map((column) => {
|
||||
const typeConf = INPUT_TYPE_COLORS[column.input_type || "text"] || INPUT_TYPE_COLORS.text;
|
||||
const isSelected = selectedColumn === column.column_name;
|
||||
return (
|
||||
<div
|
||||
key={column.column_name}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelectColumn?.(column.column_name)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelectColumn?.(column.column_name);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"grid min-h-10 cursor-pointer items-center gap-2 rounded-md border px-4 py-2 transition-colors",
|
||||
"bg-card border-transparent hover:border-border hover:shadow-sm",
|
||||
isSelected && "border-primary/30 bg-primary/5 shadow-sm",
|
||||
)}
|
||||
style={{ gridTemplateColumns: "4px 220px 110px 1fr" }}
|
||||
>
|
||||
{/* 색상바 */}
|
||||
<div className={cn("h-full min-h-8 w-1 rounded-full", typeConf.barColor)} />
|
||||
|
||||
{/* 소스 컬럼명 */}
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs font-medium">
|
||||
{column.display_name && column.display_name !== column.column_name
|
||||
? `${column.display_name} (${column.column_name})`
|
||||
: column.column_name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 참조 종류 칩 */}
|
||||
<div className={cn("inline-flex w-fit items-center gap-1 rounded-md border px-2 py-0.5 text-xs", meta.bgColor, meta.color)}>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-current opacity-70" />
|
||||
{meta.label}
|
||||
</div>
|
||||
|
||||
{/* 참조 대상 */}
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1">
|
||||
{kind === "entity" && column.reference_table && column.reference_table !== "none" ? (
|
||||
<>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs font-normal"
|
||||
title={
|
||||
tables
|
||||
? (() => {
|
||||
const t = tables.find((tb) => tb.table_name === column.reference_table);
|
||||
return t?.display_name && t.display_name !== t.table_name
|
||||
? `${t.display_name} (${column.reference_table})`
|
||||
: column.reference_table;
|
||||
})()
|
||||
: column.reference_table
|
||||
}
|
||||
>
|
||||
{column.reference_table}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">→</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs font-normal"
|
||||
title={
|
||||
referenceTableColumns?.[column.reference_table]
|
||||
? (() => {
|
||||
const refCols = referenceTableColumns[column.reference_table];
|
||||
const c = refCols.find((rc) => rc.column_name === (column.reference_column ?? ""));
|
||||
return c?.display_name && c.display_name !== c.column_name
|
||||
? `${c.display_name} (${column.reference_column})`
|
||||
: column.reference_column ?? "—";
|
||||
})()
|
||||
: column.reference_column ?? "—"
|
||||
}
|
||||
>
|
||||
{column.reference_column || "—"}
|
||||
</Badge>
|
||||
</>
|
||||
) : kind === "code" ? (
|
||||
column.code_info ? (
|
||||
<Badge variant="outline" className="text-xs font-normal">
|
||||
코드: {column.code_info}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground/60 text-xs">— (코드 그룹 미지정)</span>
|
||||
)
|
||||
) : kind === "category" ? (
|
||||
column.category_ref ? (
|
||||
<Badge variant="outline" className="text-xs font-normal">
|
||||
카테고리: {column.category_ref}
|
||||
</Badge>
|
||||
) : column.category_menus && column.category_menus.length > 0 ? (
|
||||
<Badge variant="outline" className="text-xs font-normal">
|
||||
카테고리 메뉴 {column.category_menus.length}개
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground/60 text-xs">— (카테고리 미지정)</span>
|
||||
)
|
||||
) : kind === "numbering" ? (
|
||||
column.numbering_rule_id ? (
|
||||
<Badge variant="outline" className="text-xs font-normal">
|
||||
채번: {column.numbering_rule_id}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground/60 text-xs">— (채번 규칙 미지정)</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-muted-foreground/60 text-xs">—</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ColumnTypeInfo } from "./types";
|
||||
import { INPUT_TYPE_COLORS } from "./types";
|
||||
import { INPUT_TYPE_COLORS, FALLBACK_TYPE_CONFIG } from "./types";
|
||||
import { USER_SELECTABLE_INPUT_TYPE_ORDER } from "@/types/input-types";
|
||||
|
||||
export interface TypeOverviewStripProps {
|
||||
@@ -57,20 +57,13 @@ export function TypeOverviewStrip({
|
||||
/** stroke-dasharray: 비율만큼 둘레에 할당 (둘레 100 기준) */
|
||||
const circumference = 100;
|
||||
let offset = 0;
|
||||
const LEGACY_CONF = {
|
||||
color: "text-amber-600",
|
||||
bgColor: "bg-amber-50",
|
||||
barColor: "bg-amber-400",
|
||||
label: "Legacy",
|
||||
desc: "구버전 타입",
|
||||
iconChar: "?",
|
||||
};
|
||||
const LEGACY_CONF = { ...FALLBACK_TYPE_CONFIG, color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-400" };
|
||||
const segmentPaths = segments.map(({ type, ratio, isLegacy }) => {
|
||||
const length = ratio * circumference;
|
||||
const dashArray = `${length} ${circumference - length}`;
|
||||
const dashOffset = -offset;
|
||||
offset += length;
|
||||
const conf = isLegacy ? LEGACY_CONF : (INPUT_TYPE_COLORS[type] || { color: "text-muted-foreground", bgColor: "bg-muted" });
|
||||
const conf = isLegacy ? LEGACY_CONF : (INPUT_TYPE_COLORS[type] || FALLBACK_TYPE_CONFIG);
|
||||
return {
|
||||
type,
|
||||
dashArray,
|
||||
@@ -112,7 +105,7 @@ export function TypeOverviewStrip({
|
||||
.filter((type) => (counts[type] || 0) > 0)
|
||||
.sort((a, b) => (counts[b] ?? 0) - (counts[a] ?? 0))
|
||||
.map((type) => {
|
||||
const conf = INPUT_TYPE_COLORS[type] || { color: "text-muted-foreground", bgColor: "bg-muted", label: type };
|
||||
const conf = INPUT_TYPE_COLORS[type] || { ...FALLBACK_TYPE_CONFIG, label: type };
|
||||
const isActive = activeFilter === null || activeFilter === type;
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -3,6 +3,23 @@
|
||||
* page.tsx에서 추출한 인터페이스 및 타입별 색상/그룹 유틸
|
||||
*/
|
||||
|
||||
import {
|
||||
AlignLeft,
|
||||
Braces,
|
||||
Calendar,
|
||||
CheckSquare,
|
||||
ChevronDown,
|
||||
CircleDot,
|
||||
FolderTree,
|
||||
Hash,
|
||||
HelpCircle,
|
||||
Image as ImageIcon,
|
||||
Link2,
|
||||
ListOrdered,
|
||||
Paperclip,
|
||||
Type,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { USER_SELECTABLE_INPUT_TYPE_ORDER } from "@/types/input-types";
|
||||
|
||||
export interface TableInfo {
|
||||
@@ -52,24 +69,34 @@ export interface TypeColorConfig {
|
||||
barColor: string;
|
||||
label: string;
|
||||
desc: string;
|
||||
iconChar: string;
|
||||
Icon: LucideIcon;
|
||||
}
|
||||
|
||||
/** 입력 타입별 색상 맵 - iconChar는 카드 선택용 시각 아이콘 */
|
||||
/** Legacy/알 수 없는 타입용 fallback config */
|
||||
export const FALLBACK_TYPE_CONFIG: TypeColorConfig = {
|
||||
color: "text-muted-foreground",
|
||||
bgColor: "bg-muted",
|
||||
barColor: "bg-muted",
|
||||
label: "Legacy",
|
||||
desc: "구버전 타입",
|
||||
Icon: HelpCircle,
|
||||
};
|
||||
|
||||
/** 입력 타입별 색상 맵 - Icon 은 lucide 컴포넌트로 통일 (letter/symbol/emoji 혼재 방지) */
|
||||
export const INPUT_TYPE_COLORS: Record<string, TypeColorConfig> = {
|
||||
text: { color: "text-slate-600", bgColor: "bg-slate-50", barColor: "bg-slate-400", label: "텍스트", desc: "일반 텍스트 입력", iconChar: "T" },
|
||||
number: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-500", label: "숫자", desc: "숫자만 입력", iconChar: "#" },
|
||||
date: { color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-500", label: "날짜", desc: "날짜 선택", iconChar: "D" },
|
||||
code: { color: "text-emerald-600", bgColor: "bg-emerald-50", barColor: "bg-emerald-500", label: "코드", desc: "공통코드 선택", iconChar: "{}" },
|
||||
entity: { color: "text-violet-600", bgColor: "bg-violet-50", barColor: "bg-violet-500", label: "테이블 참조", desc: "다른 테이블 연결", iconChar: "⊞" },
|
||||
select: { color: "text-cyan-600", bgColor: "bg-cyan-50", barColor: "bg-cyan-500", label: "셀렉트", desc: "직접 옵션 선택", iconChar: "☰" },
|
||||
checkbox: { color: "text-pink-600", bgColor: "bg-pink-50", barColor: "bg-pink-500", label: "체크박스", desc: "예/아니오 선택", iconChar: "☑" },
|
||||
numbering: { color: "text-orange-600", bgColor: "bg-orange-50", barColor: "bg-orange-500", label: "채번", desc: "자동 번호 생성", iconChar: "≡" },
|
||||
category: { color: "text-teal-600", bgColor: "bg-teal-50", barColor: "bg-teal-500", label: "카테고리", desc: "등록된 선택지", iconChar: "⊟" },
|
||||
textarea: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-400", label: "여러 줄", desc: "긴 텍스트 입력", iconChar: "≡" },
|
||||
radio: { color: "text-rose-600", bgColor: "bg-rose-50", barColor: "bg-rose-500", label: "라디오", desc: "하나만 선택", iconChar: "◉" },
|
||||
file: { color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-500", label: "파일", desc: "파일 업로드", iconChar: "📎" },
|
||||
image: { color: "text-sky-600", bgColor: "bg-sky-50", barColor: "bg-sky-500", label: "이미지", desc: "이미지 표시", iconChar: "🖼" },
|
||||
text: { color: "text-slate-600", bgColor: "bg-slate-50", barColor: "bg-slate-400", label: "텍스트", desc: "일반 텍스트 입력", Icon: Type },
|
||||
number: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-500", label: "숫자", desc: "숫자만 입력", Icon: Hash },
|
||||
date: { color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-500", label: "날짜", desc: "날짜 선택", Icon: Calendar },
|
||||
code: { color: "text-emerald-600", bgColor: "bg-emerald-50", barColor: "bg-emerald-500", label: "코드", desc: "공통코드 선택", Icon: Braces },
|
||||
entity: { color: "text-violet-600", bgColor: "bg-violet-50", barColor: "bg-violet-500", label: "테이블 참조", desc: "다른 테이블 연결", Icon: Link2 },
|
||||
select: { color: "text-cyan-600", bgColor: "bg-cyan-50", barColor: "bg-cyan-500", label: "셀렉트", desc: "직접 옵션 선택", Icon: ChevronDown },
|
||||
checkbox: { color: "text-pink-600", bgColor: "bg-pink-50", barColor: "bg-pink-500", label: "체크박스", desc: "예/아니오 선택", Icon: CheckSquare },
|
||||
numbering: { color: "text-orange-600", bgColor: "bg-orange-50", barColor: "bg-orange-500", label: "채번", desc: "자동 번호 생성", Icon: ListOrdered },
|
||||
category: { color: "text-teal-600", bgColor: "bg-teal-50", barColor: "bg-teal-500", label: "카테고리", desc: "등록된 선택지", Icon: FolderTree },
|
||||
textarea: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-400", label: "여러 줄", desc: "긴 텍스트 입력", Icon: AlignLeft },
|
||||
radio: { color: "text-rose-600", bgColor: "bg-rose-50", barColor: "bg-rose-500", label: "라디오", desc: "하나만 선택", Icon: CircleDot },
|
||||
file: { color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-500", label: "파일", desc: "파일 업로드", Icon: Paperclip },
|
||||
image: { color: "text-sky-600", bgColor: "bg-sky-50", barColor: "bg-sky-500", label: "이미지", desc: "이미지 표시", Icon: ImageIcon },
|
||||
};
|
||||
|
||||
/** v3.2 — 사용자 선택 가능한 8개 입력 타입 색상 맵 (T2 드롭다운/카드 그리드용) */
|
||||
@@ -81,9 +108,23 @@ export const USER_SELECTABLE_INPUT_TYPE_COLORS = USER_SELECTABLE_INPUT_TYPE_ORDE
|
||||
{} as Record<string, TypeColorConfig>,
|
||||
);
|
||||
|
||||
/** 컬럼 그룹 판별 */
|
||||
/** 컬럼 그룹 판별 — 시스템 자동 생성 컬럼은 meta 로 분류 (사용자가 거의 수정하지 않으므로 시각 분리) */
|
||||
export function getColumnGroup(col: ColumnTypeInfo): ColumnGroup {
|
||||
const metaCols = ["id", "created_date", "updated_date", "writer", "company_code"];
|
||||
// 시스템 컬럼: invyone 자동 생성 (id/날짜/작성자/회사) 외에 VEX 계승 (objid), 멀티테넌트 (tenant_id),
|
||||
// 수정자/생성자 변형 (creator/modifier/created_at/updated_at) 까지 모두 포함
|
||||
const metaCols = [
|
||||
"id",
|
||||
"objid",
|
||||
"tenant_id",
|
||||
"created_date",
|
||||
"updated_date",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"writer",
|
||||
"creator",
|
||||
"modifier",
|
||||
"company_code",
|
||||
];
|
||||
if (metaCols.includes(col.column_name)) return "meta";
|
||||
if (["entity", "code", "category"].includes(col.input_type)) return "reference";
|
||||
return "basic";
|
||||
|
||||
@@ -69,7 +69,6 @@ export function TabBar({ collapsed = false, onToggleCollapse, modeTransition = "
|
||||
const dragLeaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const dropGhostRef = useRef<HTMLDivElement>(null);
|
||||
const prevTabCountRef = useRef(tabs.length);
|
||||
const activeTabElRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// --- State ---
|
||||
const [visibleCount, setVisibleCount] = useState(tabs.length);
|
||||
@@ -81,12 +80,6 @@ export function TabBar({ collapsed = false, onToggleCollapse, modeTransition = "
|
||||
const [dragState, setDragState] = useState<DragState | null>(null);
|
||||
const [externalDragIdx, setExternalDragIdx] = useState<number | 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;
|
||||
|
||||
@@ -98,53 +91,6 @@ 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로 드롭 위치 → 목표 슬롯 이동 ---
|
||||
useEffect(() => {
|
||||
if (!dropGhost) return;
|
||||
@@ -232,20 +178,6 @@ export function TabBar({ collapsed = false, onToggleCollapse, modeTransition = "
|
||||
prevTabCountRef.current = tabs.length;
|
||||
}, [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 (
|
||||
menuName: string,
|
||||
menuObjid: string | number,
|
||||
@@ -552,7 +484,6 @@ export function TabBar({ collapsed = false, onToggleCollapse, modeTransition = "
|
||||
|
||||
const renderTab = (tab: Tab, displayIndex: number) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
const isClosing = closingIds.has(tab.id);
|
||||
const animStyle = getTabAnimStyle(tab.id, displayIndex);
|
||||
const hiddenByGhost =
|
||||
!!dropGhost && displayIndex === dropGhost.targetIdx && tabs.length > dropGhost.tabCountAtCreation;
|
||||
@@ -560,7 +491,6 @@ export function TabBar({ collapsed = false, onToggleCollapse, modeTransition = "
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
ref={isActive ? (el) => { activeTabElRef.current = el; } : undefined}
|
||||
onPointerDown={(e) => handlePointerDown(e, tab.id, displayIndex)}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
@@ -569,7 +499,6 @@ export function TabBar({ collapsed = false, onToggleCollapse, modeTransition = "
|
||||
className={cn(
|
||||
"v5-tab group relative flex shrink-0 cursor-pointer items-center gap-1 px-3 select-none",
|
||||
isActive && "on",
|
||||
isClosing && "closing",
|
||||
)}
|
||||
style={{
|
||||
width: TAB_WIDTH,
|
||||
@@ -597,7 +526,7 @@ export function TabBar({ collapsed = false, onToggleCollapse, modeTransition = "
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCloseTab(tab.id);
|
||||
closeTab(tab.id);
|
||||
}}
|
||||
className="v5-tab-x"
|
||||
>
|
||||
@@ -626,15 +555,6 @@ export function TabBar({ collapsed = false, onToggleCollapse, modeTransition = "
|
||||
)}
|
||||
{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 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -654,7 +574,7 @@ export function TabBar({ collapsed = false, onToggleCollapse, modeTransition = "
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCloseTab(tab.id);
|
||||
closeTab(tab.id);
|
||||
}}
|
||||
className="hover:bg-destructive/10 hover:text-destructive flex h-4 w-4 shrink-0 items-center justify-center rounded-sm"
|
||||
>
|
||||
@@ -726,21 +646,21 @@ export function TabBar({ collapsed = false, onToggleCollapse, modeTransition = "
|
||||
<ContextMenuItem
|
||||
label="왼쪽 탭 닫기"
|
||||
onClick={() => {
|
||||
handleCloseTabsToLeft(contextMenu.tabId);
|
||||
closeTabsToLeft(contextMenu.tabId);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
label="오른쪽 탭 닫기"
|
||||
onClick={() => {
|
||||
handleCloseTabsToRight(contextMenu.tabId);
|
||||
closeTabsToRight(contextMenu.tabId);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
label="다른 탭 모두 닫기"
|
||||
onClick={() => {
|
||||
handleCloseOtherTabs(contextMenu.tabId);
|
||||
closeOtherTabs(contextMenu.tabId);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
/>
|
||||
@@ -748,7 +668,7 @@ export function TabBar({ collapsed = false, onToggleCollapse, modeTransition = "
|
||||
<ContextMenuItem
|
||||
label="모든 탭 닫기"
|
||||
onClick={() => {
|
||||
handleCloseAllTabs();
|
||||
closeAllTabs();
|
||||
setContextMenu(null);
|
||||
}}
|
||||
destructive
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 좌측과 우측에 화면을 임베드합니다.
|
||||
*
|
||||
* 데이터 전달은 좌측 화면에 배치된 버튼의 transferData 액션으로 처리됩니다.
|
||||
* 예: 좌측 화면에 canonical TableComponent + Button(transferData 액션) 배치
|
||||
* 예: 좌측 화면에 TableListComponent + Button(transferData 액션) 배치
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
@@ -4102,7 +4102,9 @@ export default function InvyoneStudio({
|
||||
"divider-basic": 1, // 구분선 (100%)
|
||||
"divider-line": 1, // 구분선 (100%)
|
||||
"accordion-basic": 1, // 아코디언 (100%)
|
||||
"table": 1, // canonical 테이블 (100%) — 옛 ID 들은 isTableLikeComponentType 헬퍼가 흡수
|
||||
"table": 1, // canonical 테이블 (100%)
|
||||
"table-list": 1, // legacy 테이블 리스트 (100%)
|
||||
"v2-table-list": 1, // hidden legacy 테이블 리스트 (100%)
|
||||
"data-table": 1, // 데이터 테이블 (100%)
|
||||
"datatable": 1, // 데이터 테이블 (100%)
|
||||
"image-display": 4 / 12, // 이미지 표시 (33%)
|
||||
|
||||
@@ -343,7 +343,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
||||
// 컴포넌트 기본 스타일 - 레이아웃은 항상 맨 아래
|
||||
// 🔥 모든 컴포넌트를 픽셀 기준으로 통일 (스케일로만 조정)
|
||||
const getWidth = () => {
|
||||
// 모든 컴포넌트는 size.width 픽셀 사용 (canonical table 포함)
|
||||
// 모든 컴포넌트는 size.width 픽셀 사용 (table-list 포함)
|
||||
const width = `${size?.width || 100}px`;
|
||||
return width;
|
||||
};
|
||||
@@ -373,8 +373,8 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
||||
// 런타임 모드에서 컴포넌트 타입별 높이 처리
|
||||
if (!isDesignMode) {
|
||||
// 레이아웃 계열: 부모 래퍼를 꽉 채움 (ResponsiveGridRenderer가 % 높이 관리)
|
||||
// ★ table 계열 (canonical 'table' / 'data-table' / 'datatable') 은 helper 로 통일.
|
||||
// 그 외 layout/split/tabs 는 명시 목록.
|
||||
// ★ table 계열 (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list' /
|
||||
// 'data-table' / 'datatable') 은 helper 로 통일. 그 외 layout/split/tabs 는 명시 목록.
|
||||
const fillParentExtraTypes = [
|
||||
"container",
|
||||
"grouped-table", "card-list",
|
||||
@@ -396,7 +396,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
||||
}
|
||||
|
||||
// 1순위: size.height가 있으면 우선 사용
|
||||
// (canonical 'table' 등 table-like 컴포넌트 모두 최소 200px 보장)
|
||||
// (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list' 모두 최소 200px 보장)
|
||||
if (size?.height && size.height > 0) {
|
||||
if (isTableLikeComponentType(sizingType)) {
|
||||
return `${Math.max(size.height, 200)}px`;
|
||||
|
||||
@@ -225,10 +225,11 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
|
||||
};
|
||||
|
||||
// ========== 컴포넌트 종류별 미니어처 색상 ==========
|
||||
// componentKind 는 더 정확한 컴포넌트 타입 (canonical 'table' / 'button-primary' 등)
|
||||
// componentKind 는 더 정확한 컴포넌트 타입 (canonical 'table' / legacy 'table-list' /
|
||||
// hidden 'v2-table-list' / 'button-primary' 등)
|
||||
const TABLE_LIKE_EXTRA_KINDS = ["grouped-table", "card-list", "data-grid"];
|
||||
const getComponentColor = (componentKind: string) => {
|
||||
// 테이블/그리드 관련 (canonical table 등 table-like 컴포넌트)
|
||||
// 테이블/그리드 관련 (canonical table / legacy table-list / hidden v2-table-list 등)
|
||||
if (isTableLikeComponentType(componentKind) || TABLE_LIKE_EXTRA_KINDS.includes(componentKind)) {
|
||||
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 { isTableLikeComponentType, isTableLikeComponent, getTableNameFromTableLikeComponent } from "@/lib/utils/componentTypeUtils";
|
||||
|
||||
// canonical table / data-table / datatable 등 table-like 컴포넌트
|
||||
// canonical table / legacy table-list / hidden v2-table-list / data-table / datatable
|
||||
// 은 table-like helper 로 통일. 추가로 repeater-field-group / form-group 도 데이터 전송
|
||||
// 호환 대상으로 함께 인식.
|
||||
const DATA_TRANSFER_EXTRA_PATTERNS = ["repeater-field-group", "form-group"] as const;
|
||||
@@ -368,7 +368,10 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
|
||||
sourceTableName = getTableNameFromTableLikeComponent(comp) || null;
|
||||
if (sourceTableName) break;
|
||||
}
|
||||
// 옛 통합 목록 분기 폐기 (Phase F.8) — canonical table 사용.
|
||||
if (compType === "v2-list") {
|
||||
sourceTableName = compConfig.dataSource?.table || compConfig.table_name || null;
|
||||
if (sourceTableName) break;
|
||||
}
|
||||
}
|
||||
|
||||
setModalActionSourceTable(sourceTableName);
|
||||
@@ -526,7 +529,7 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// 테이블 계열 (canonical table 등 table-like 컴포넌트 모두)
|
||||
// 테이블 계열 (canonical table / legacy table-list / hidden v2-table-list 모두)
|
||||
if (isTableLikeComponent(comp)) {
|
||||
sourceTableName = getTableNameFromTableLikeComponent(comp) ?? compConfig?.table_name;
|
||||
if (sourceTableName) {
|
||||
|
||||
@@ -36,7 +36,7 @@ export interface DataTabProps {
|
||||
>;
|
||||
}
|
||||
|
||||
// canonical table / data-table / datatable 등 table-like 컴포넌트
|
||||
// canonical table / legacy table-list / hidden v2-table-list / data-table / datatable
|
||||
// 은 table-like helper 로 통일. 추가로 repeater-field-group / form-group 도 데이터 전송
|
||||
// 호환 대상으로 함께 인식.
|
||||
const DATA_TRANSFER_EXTRA_PATTERNS = ["repeater-field-group", "form-group"] as const;
|
||||
|
||||
@@ -55,7 +55,6 @@ import { Progress } from "@/components/ui/progress";
|
||||
import { ComponentData } from "@/types/screen";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
|
||||
|
||||
// 다국어 키 타입
|
||||
interface LangKey {
|
||||
@@ -147,16 +146,16 @@ interface MultilangSettingsModalProps {
|
||||
onSave: (updates: Array<{ componentId: string; path?: string; langKeyId: number; langKey: string }>) => void;
|
||||
}
|
||||
|
||||
// 타입별 아이콘 매핑.
|
||||
// Phase E.3 — canonical / legacy / hidden 등 table-like 는 모두 isTableLikeComponentType 헬퍼로
|
||||
// 흡수해 같은 Table2 아이콘. hard-coded 옛 ID literal 제거.
|
||||
// 타입별 아이콘 매핑
|
||||
// canonical table / legacy table-list / hidden v2-table-list 모두 같은 table 아이콘.
|
||||
const getTypeIcon = (type: string) => {
|
||||
if (isTableLikeComponentType(type)) {
|
||||
return <Table2 className="h-4 w-4" />;
|
||||
}
|
||||
switch (type) {
|
||||
case "button":
|
||||
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":
|
||||
return <LayoutPanelLeft className="h-4 w-4" />;
|
||||
case "filter":
|
||||
@@ -195,11 +194,12 @@ const getTypeLabel = (type: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 라벨 다국어 처리가 필요 없는 컴포넌트 타입 (테이블, 분할패널 등).
|
||||
// Phase E.3 — table-like 는 isTableLikeComponentType 헬퍼 (isInputComponent 안 분기) 가 흡수.
|
||||
// 여기는 canonical "table" 만 명시 — 옛 ID literal 은 헬퍼가 처리.
|
||||
// 라벨 다국어 처리가 필요 없는 컴포넌트 타입 (테이블, 분할패널 등)
|
||||
// canonical table 및 hidden legacy v2-table-list 도 모두 non-input 으로 분류.
|
||||
const NON_INPUT_COMPONENT_TYPES = new Set([
|
||||
"table",
|
||||
"table-list",
|
||||
"v2-table-list",
|
||||
"split-panel-layout",
|
||||
"tab-panel",
|
||||
"container",
|
||||
@@ -245,12 +245,6 @@ const isInputComponent = (comp: any): boolean => {
|
||||
const compType = comp.componentType || comp.type;
|
||||
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)) {
|
||||
return false;
|
||||
|
||||
@@ -39,12 +39,13 @@ export function ComponentsPanel({
|
||||
}: ComponentsPanelProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// 레지스트리에서 모든 컴포넌트 조회.
|
||||
// Phase E.3 — 새 생성 경로는 canonical 'table' (displayMode='table') 뿐.
|
||||
// 옛 layout JSON 호환은 BlockRenderer / DynamicComponentRenderer / templateMigrate 의
|
||||
// alias 라우팅 + TableComponent 의 early delegation 으로 처리되며 팔레트와는 무관.
|
||||
// 레지스트리에서 모든 컴포넌트 조회
|
||||
const allComponents = useMemo(() => {
|
||||
return ComponentRegistry.getAllComponents();
|
||||
const components = ComponentRegistry.getAllComponents();
|
||||
// ★ 새 생성 경로는 canonical 'table' (displayMode='table').
|
||||
// v2-table-list 는 옛 저장 화면 호환 hard blocker 로 자동 등록되지만
|
||||
// 팔레트에는 hidden 처리한다 (아래 hiddenComponents 참고).
|
||||
return components;
|
||||
}, []);
|
||||
|
||||
// ── 기본 컴포넌트 (v2 하드코딩) ──
|
||||
@@ -136,6 +137,7 @@ export function ComponentsPanel({
|
||||
"button-primary", // → v2-button-primary
|
||||
"split-panel-layout", // → v2-split-panel-layout
|
||||
// aggregation-widget: 폴더/Renderer 삭제 (2026-05-19). ComponentRegistry 에 없음 — hidden 처리 불필요
|
||||
"table-list", // legacy hidden — 새 생성 경로는 canonical 'table'
|
||||
"text-display", // → v2-text-display
|
||||
"divider-line", // → v2-divider-line
|
||||
// ★ 2026-04-11 통합 컴포넌트(Phase A-1): 구분선 3종 → `divider`
|
||||
@@ -169,10 +171,9 @@ export function ComponentsPanel({
|
||||
// form 컴포넌트는 롤백됨 (2026-04-11): 3뷰 탭 구조로 처리 예정.
|
||||
"field-example-1", // legacy form-layout 의 실제 id (숨김 유지)
|
||||
// ★ 2026-04-11 통합 컴포넌트(Phase C-1): 데이터 테이블 → `table`
|
||||
// Phase E.3 — 옛 hidden table ID 들은 ComponentRegistry 에 등록되지 않으므로
|
||||
// hidden 목록에 둘 필요 없음. canonical 'table' 만 새 생성 경로.
|
||||
"v2-table-list", // → table (displayMode='table')
|
||||
"v2-split-panel-layout", // → table (displayMode='split')
|
||||
// split-panel-layout, split-panel-layout2, modal-repeater-table,
|
||||
// table-list, split-panel-layout, split-panel-layout2, modal-repeater-table,
|
||||
// simple-repeater-table, tax-invoice-list, pivot-grid 는 기존 상단에서 이미 숨김
|
||||
// ★ 2026-04-11 통합 컴포넌트(Phase C-2): 컨테이너 → `container`
|
||||
// v2-tabs-widget / v2-section-card / v2-section-paper / section-card / section-paper / tabs / tabs-widget:
|
||||
@@ -229,6 +230,7 @@ export function ComponentsPanel({
|
||||
const s = 14;
|
||||
const icons: Record<string, React.ReactNode> = {
|
||||
table: <Table2 size={s} />,
|
||||
"v2-table-list": <Table2 size={s} />,
|
||||
stats: <BarChart3 size={s} />,
|
||||
title: <Type size={s} />,
|
||||
divider: <Minus size={s} />,
|
||||
|
||||
@@ -224,8 +224,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||
if (componentId?.startsWith("v2-")) {
|
||||
const v2ConfigPanels: Record<string, React.FC<{ config: any; onChange: (config: any) => void }>> = {
|
||||
// V2 입력/선택 폐기 (2026-05-12) — input canonical 로 흡수. 하드코딩 매핑 제거.
|
||||
// 옛 통합 목록 / repeater / 옛 표 형식 / v2-date 는 InvField · canonical table 등으로 흡수
|
||||
// (ComponentRegistry fallback 으로 라우팅)
|
||||
// v2-date / v2-list / v2-repeater / v2-table-list 는 InvField 등 통합 — ComponentRegistry fallback 사용
|
||||
"v2-layout": require("@/components/v2/config-panels/V2LayoutConfigPanel").V2LayoutConfigPanel,
|
||||
"v2-group": require("@/components/v2/config-panels/V2GroupConfigPanel").V2GroupConfigPanel,
|
||||
// v2-media — Phase D.5 폐기. canonical input (FilePicker) 으로 흡수.
|
||||
@@ -250,7 +249,9 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||
const extraProps: Record<string, any> = {};
|
||||
const resolvedTableName = (selectedComponent as any).tableName || currentTable?.tableName || currentTableName;
|
||||
|
||||
// 옛 통합 목록 extraProps 분기 폐기 (Phase F.8) — canonical table 로 흡수.
|
||||
if (componentId === "v2-list") {
|
||||
extraProps.currentTableName = currentTableName;
|
||||
}
|
||||
if (componentId === "v2-bom-item-editor" || componentId === "v2-bom-tree") {
|
||||
extraProps.currentTableName = currentTableName;
|
||||
extraProps.screenTableName = resolvedTableName;
|
||||
|
||||
@@ -97,9 +97,6 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
|
||||
setGroupSumEnabled(false);
|
||||
setGroupByColumn("");
|
||||
}
|
||||
} else {
|
||||
setGroupSumEnabled(false);
|
||||
setGroupByColumn("");
|
||||
}
|
||||
|
||||
if (savedFilters) {
|
||||
@@ -255,19 +252,9 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
|
||||
const groupSumConfig: GroupSumConfig = {
|
||||
enabled: groupSumEnabled,
|
||||
group_by_column: groupByColumn,
|
||||
group_by_column_label: table.columns.find((col) => col.column_name === groupByColumn)?.column_label,
|
||||
};
|
||||
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
|
||||
.filter((f) => f.enabled)
|
||||
@@ -278,8 +265,6 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
|
||||
filter_type: f.filterType,
|
||||
width: f.width && f.width >= 10 && f.width <= 100 ? f.width : 25,
|
||||
}));
|
||||
// Phase D.8 — canonical TableComponent.onFilterChange 도 호출 (이전엔 onFiltersApplied 만)
|
||||
table.onFilterChange(activeFilters);
|
||||
onFiltersApplied?.(activeFilters);
|
||||
|
||||
// 3. 그룹화 저장
|
||||
@@ -672,3 +657,4 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ export function TabsWidget({
|
||||
const inlineComponents = tab.components || [];
|
||||
if (inlineComponents.length > 0) {
|
||||
// 인라인 컴포넌트에서 table-like 컴포넌트의 selectedTable 추출
|
||||
// (canonical table 등 table-like 컴포넌트 인식,
|
||||
// (canonical table / legacy table-list / hidden v2-table-list 모두 인식,
|
||||
// camelCase / snake_case 양쪽 모두 처리)
|
||||
const tableComp = inlineComponents.find((c) => isTableLikeComponent(c));
|
||||
const selectedTable = getTableNameFromTableLikeComponent(tableComp);
|
||||
|
||||
@@ -11,13 +11,14 @@ import React, { forwardRef, useMemo } from "react";
|
||||
import {
|
||||
V2ComponentProps,
|
||||
isV2Text,
|
||||
isV2List,
|
||||
isV2Layout,
|
||||
isV2Group,
|
||||
isV2Biz,
|
||||
isV2Hierarchy,
|
||||
} from "@/types/v2-components";
|
||||
// 옛 입력/선택 import 는 Phase D.3 에서 제거. V2Media 는 Phase D.5 에서 제거 — canonical input 으로 흡수.
|
||||
// V2List 는 Phase F.8 (2026-05-21) 에서 제거 — canonical table 로 흡수.
|
||||
import { V2List } from "./V2List";
|
||||
import { V2Layout } from "./V2Layout";
|
||||
import { V2Group } from "./V2Group";
|
||||
import { V2Biz } from "./V2Biz";
|
||||
@@ -47,7 +48,10 @@ export const V2ComponentRenderer = forwardRef<HTMLDivElement, V2ComponentRendere
|
||||
}
|
||||
|
||||
// V2Media — Phase D.5 폐기. canonical input 의 file 분기로 흡수.
|
||||
// V2List — Phase F.8 폐기. canonical table 로 흡수.
|
||||
|
||||
if (isV2List(props)) {
|
||||
return <V2List {...props} />;
|
||||
}
|
||||
|
||||
if (isV2Layout(props)) {
|
||||
return <V2Layout {...props} />;
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
// V2 컴포넌트들 (옛 입력/선택은 Phase D.3 에서 폐기 — canonical `input` 으로 흡수됨)
|
||||
// V2List 는 Phase F.8 에서 폐기 — canonical table 로 흡수.
|
||||
import { V2List } from "./V2List";
|
||||
import { V2Layout } from "./V2Layout";
|
||||
import { V2Group } from "./V2Group";
|
||||
// V2Media — Phase D.5 폐기. canonical input 의 file 분기로 흡수.
|
||||
@@ -32,10 +32,17 @@ interface V2ComponentsDemoProps {
|
||||
}
|
||||
|
||||
export function V2ComponentsDemo({ onBack }: V2ComponentsDemoProps) {
|
||||
const [activeTab, setActiveTab] = useState("layout");
|
||||
|
||||
const [activeTab, setActiveTab] = useState("list");
|
||||
|
||||
// 데모용 상태 (옛 입력/선택 데모 state — Phase D.3 에서 제거됨)
|
||||
// sampleTableData / List 탭은 Phase F.8 에서 제거 — canonical table demo 별도 화면.
|
||||
|
||||
// 샘플 데이터
|
||||
const sampleTableData = [
|
||||
{ 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[] = [
|
||||
{
|
||||
@@ -85,6 +92,7 @@ export function V2ComponentsDemo({ onBack }: V2ComponentsDemoProps) {
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-2 lg:grid-cols-6">
|
||||
<TabsTrigger value="list">List</TabsTrigger>
|
||||
<TabsTrigger value="layout">Layout</TabsTrigger>
|
||||
<TabsTrigger value="group" className="hidden lg:flex">Group</TabsTrigger>
|
||||
<TabsTrigger value="biz" className="hidden lg:flex">Biz</TabsTrigger>
|
||||
@@ -93,7 +101,40 @@ export function V2ComponentsDemo({ onBack }: V2ComponentsDemoProps) {
|
||||
|
||||
{/* 조건부 동작 데모 탭 — Phase D.3 에서 폐기 (옛 입력/선택 의존) */}
|
||||
{/* 옛 입력/선택 탭 — 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 탭 */}
|
||||
<TabsContent value="layout" className="mt-6">
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"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,31 +1,35 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* InvDataConfigPanel — "데이터 입력" 카테고리 통합 ConfigPanel
|
||||
* InvDataConfigPanel — "데이터 조회/선택" 카테고리 통합 ConfigPanel
|
||||
*
|
||||
* 등록 대상 (v2-* runtime id 그대로 — DB 호환):
|
||||
* - v2-list → 조회 (read) / 통합 목록
|
||||
* - v2-table-list → 조회 (read) / 테이블 (legacy, hidden)
|
||||
* - v2-repeater → 입력 (write) / 자식 행 입력
|
||||
*
|
||||
* Phase F.8 (2026-05-21):
|
||||
* - 옛 read 카테고리(통합 목록) 는 canonical TableComponent + InvTableConfigPanel 로 통합되어 폐기.
|
||||
* - 본 패널은 write 전용. 옛 저장 데이터가 v2-list 등으로 들어오더라도 v2-repeater 로 정규화.
|
||||
* 마이그레이션 단계:
|
||||
* 현재 = wrapper (cp brumb + 옛 패널 위임)
|
||||
* 다음 = brumb 클릭 시 componentType 자동 전환 + 본체 점진 cp 이주 + resolver/writer
|
||||
*
|
||||
* Reference: notes/gbpark/2026-04-28-cp-panel-standard.md, 2026-04-28-invdata-inventory.md
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { MousePointerClick, Columns3 } from "lucide-react";
|
||||
import { Database, MousePointerClick, Table2, Rows3, Columns3 } from "lucide-react";
|
||||
import { CPCrumb, type CPCrumbType, type CPCrumbKind } from "./_shared/cp";
|
||||
import { V2ListConfigPanel } from "./V2ListConfigPanel";
|
||||
import { V2TableListConfigPanel } from "./V2TableListConfigPanel";
|
||||
import { InvRepeaterConfigPanel } from "./InvRepeaterConfigPanel";
|
||||
|
||||
type DataComponentType = "v2-repeater";
|
||||
type DataKind = "write";
|
||||
type DataComponentType = "v2-list" | "v2-table-list" | "v2-repeater";
|
||||
type DataKind = "read" | "write";
|
||||
|
||||
interface InvDataConfigPanelProps {
|
||||
config: any;
|
||||
onChange: (config: any) => void;
|
||||
/** 컴포넌트 타입 — runtime id (registerV2Components 에서 전달) */
|
||||
componentType: DataComponentType | string;
|
||||
componentType: DataComponentType;
|
||||
/** 화면 메인 테이블명 */
|
||||
screenTableName?: string;
|
||||
/** 현재 테이블명 */
|
||||
@@ -39,6 +43,11 @@ interface InvDataConfigPanelProps {
|
||||
}
|
||||
|
||||
const KINDS: CPCrumbKind[] = [
|
||||
{
|
||||
id: "read",
|
||||
name: "조회",
|
||||
icon: <Database size={14} />,
|
||||
},
|
||||
{
|
||||
id: "write",
|
||||
name: "입력",
|
||||
@@ -47,6 +56,22 @@ const KINDS: CPCrumbKind[] = [
|
||||
];
|
||||
|
||||
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: [
|
||||
{
|
||||
id: "v2-repeater",
|
||||
@@ -59,16 +84,11 @@ const TYPES_BY_KIND: Record<DataKind, CPCrumbType[]> = {
|
||||
};
|
||||
|
||||
const KIND_OF_TYPE: Record<DataComponentType, DataKind> = {
|
||||
"v2-list": "read",
|
||||
"v2-table-list": "read",
|
||||
"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> = ({
|
||||
config,
|
||||
onChange,
|
||||
@@ -79,11 +99,10 @@ export const InvDataConfigPanel: React.FC<InvDataConfigPanelProps> = ({
|
||||
menuObjid,
|
||||
onComponentTypeChange,
|
||||
}) => {
|
||||
const normalizedType = _normalizeComponentType(componentType as string);
|
||||
const currentKind: DataKind = KIND_OF_TYPE[normalizedType] || "write";
|
||||
const currentKind: DataKind = KIND_OF_TYPE[componentType] || "read";
|
||||
|
||||
const handleKindChange = (nextKind: string) => {
|
||||
// kind 가 바뀌면 해당 kind 의 첫 type 으로 전환 (현재 write 만 존재)
|
||||
// kind 가 바뀌면 해당 kind 의 첫 type 으로 전환
|
||||
const firstType = TYPES_BY_KIND[nextKind as DataKind]?.[0]?.id as DataComponentType | undefined;
|
||||
if (!firstType || firstType === componentType) return;
|
||||
if (onComponentTypeChange) {
|
||||
@@ -97,14 +116,13 @@ export const InvDataConfigPanel: React.FC<InvDataConfigPanelProps> = ({
|
||||
};
|
||||
|
||||
const handleTypeChange = (nextType: string) => {
|
||||
const normalizedNextType = _normalizeComponentType(nextType);
|
||||
if (normalizedNextType === componentType) return;
|
||||
if (nextType === componentType) return;
|
||||
if (onComponentTypeChange) {
|
||||
onComponentTypeChange(normalizedNextType);
|
||||
onComponentTypeChange(nextType as DataComponentType);
|
||||
} else {
|
||||
console.warn(
|
||||
"[InvDataConfigPanel] onComponentTypeChange 미구현 — type 변경 적용 안 됨:",
|
||||
normalizedNextType,
|
||||
nextType,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -116,11 +134,28 @@ export const InvDataConfigPanel: React.FC<InvDataConfigPanelProps> = ({
|
||||
currentKind={currentKind}
|
||||
onChangeKind={handleKindChange}
|
||||
types={TYPES_BY_KIND[currentKind] || []}
|
||||
value={normalizedType}
|
||||
value={componentType}
|
||||
onChange={handleTypeChange}
|
||||
/>
|
||||
|
||||
{normalizedType === "v2-repeater" && (
|
||||
{/* 본체 — 현재는 옛 패널 위임. 다음 단계에서 cp 이주 */}
|
||||
{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
|
||||
config={config}
|
||||
onChange={onChange}
|
||||
|
||||
@@ -113,7 +113,7 @@ const MODAL_SIZE_OPTIONS = [
|
||||
{ value: "full", label: "전체" },
|
||||
] as const;
|
||||
|
||||
// canonical table / data-table / datatable 등 table-like 컴포넌트
|
||||
// canonical table / legacy table-list / hidden v2-table-list / data-table / datatable
|
||||
// 은 table-like helper 로 통일. 추가로 repeater-field-group / form-group 도 데이터 전송
|
||||
// 호환 대상으로 함께 인식.
|
||||
const DATA_TRANSFER_EXTRA_PATTERNS = ["repeater-field-group", "form-group"] as const;
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
"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 사용.
|
||||
// V2List ConfigPanel 은 Phase F.8 에서 폐기됨 — canonical InvTableConfigPanel 사용.
|
||||
export { V2ListConfigPanel } from "./V2ListConfigPanel";
|
||||
export { V2LayoutConfigPanel } from "./V2LayoutConfigPanel";
|
||||
export { V2GroupConfigPanel } from "./V2GroupConfigPanel";
|
||||
// V2MediaConfigPanel — Phase D.5 폐기. canonical InvFieldConfigPanel 의 attach/file 분기로 흡수.
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
*/
|
||||
|
||||
// 옛 입력/선택 컴포넌트는 Phase D.3 (2026-05-12) 에서 폐기됨 — canonical `input` 으로 흡수.
|
||||
// V2List 는 Phase F.8 (2026-05-21) 에서 폐기됨 — canonical table 로 흡수.
|
||||
|
||||
// Phase 2 컴포넌트
|
||||
export { V2List } from "./V2List";
|
||||
export { V2Layout } from "./V2Layout";
|
||||
export { V2Group } from "./V2Group";
|
||||
|
||||
@@ -64,7 +64,12 @@ export type {
|
||||
MutualExclusionConfig,
|
||||
|
||||
// (옛 입력/선택 타입은 Phase D.3 에서 제거됨 — canonical InputConfig 와 OptionFilter 로 이전)
|
||||
// (V2List 타입은 Phase F.8 에서 제거됨 — canonical TableComponent + TableConfig 로 이전)
|
||||
|
||||
// V2List 타입
|
||||
V2ListViewMode,
|
||||
ListColumn,
|
||||
V2ListConfig,
|
||||
V2ListProps,
|
||||
|
||||
// V2Layout 타입
|
||||
V2LayoutType,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
||||
import { ComponentDefinition, ComponentCategory } from "@/types/component";
|
||||
import { WebType } from "@/types/screen";
|
||||
|
||||
import { V2List } from "./V2List";
|
||||
import { V2Layout } from "./V2Layout";
|
||||
import { V2Group } from "./V2Group";
|
||||
// V2Media — Phase D.5 폐기. canonical input (FilePicker) 으로 흡수.
|
||||
@@ -18,17 +19,35 @@ import { V2Biz } from "./V2Biz";
|
||||
import { V2Hierarchy } from "./V2Hierarchy";
|
||||
import { V2Repeater } from "./V2Repeater";
|
||||
|
||||
// V2List — Phase F.8 폐기. canonical TableComponent 직접 사용 (Phase E.1/E.2).
|
||||
import { V2ListConfigPanel } from "./config-panels/V2ListConfigPanel";
|
||||
import { V2LayoutConfigPanel } from "./config-panels/V2LayoutConfigPanel";
|
||||
import { V2GroupConfigPanel } from "./config-panels/V2GroupConfigPanel";
|
||||
// V2MediaConfigPanel — Phase D.5 폐기.
|
||||
import { V2BizConfigPanel } from "./config-panels/V2BizConfigPanel";
|
||||
import { V2HierarchyConfigPanel } from "./config-panels/V2HierarchyConfigPanel";
|
||||
import { InvRepeaterConfigPanel } from "./config-panels/InvRepeaterConfigPanel";
|
||||
import { InvDataConfigPanel } from "./config-panels/InvDataConfigPanel";
|
||||
|
||||
// V2 컴포넌트 정의
|
||||
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",
|
||||
name: "통합 레이아웃",
|
||||
|
||||
@@ -2,14 +2,6 @@ import React, { createContext, useContext, useState, useCallback, useMemo, React
|
||||
import { TableRegistration, TableOptionsContextValue } from "@/types/table-options";
|
||||
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);
|
||||
|
||||
export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
@@ -72,8 +64,8 @@ export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({ childr
|
||||
setRegisteredTables((prev) => {
|
||||
const table = prev.get(tableId);
|
||||
if (table) {
|
||||
// 기존 테이블 정보에 data_count만 업데이트 (TableRegistration contract)
|
||||
const updatedTable = { ...table, data_count: count };
|
||||
// 기존 테이블 정보에 dataCount만 업데이트
|
||||
const updatedTable = { ...table, dataCount: count };
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(tableId, updatedTable);
|
||||
return newMap;
|
||||
@@ -132,8 +124,7 @@ export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({ childr
|
||||
};
|
||||
|
||||
/**
|
||||
* Context Hook — Provider 없으면 throw. canonical TableComponent 등 Provider 가
|
||||
* 항상 wrap 된 화면에서만 호출.
|
||||
* Context Hook
|
||||
*/
|
||||
export const useTableOptions = () => {
|
||||
const context = useContext(TableOptionsContext);
|
||||
@@ -142,15 +133,3 @@ export const useTableOptions = () => {
|
||||
}
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -37,19 +37,22 @@ export interface NodeFlowInfo {
|
||||
node_count: number;
|
||||
}
|
||||
|
||||
// 백엔드 mapper (batchManagement.xml) 가 snake_case 로 응답하므로 그대로 사용한다.
|
||||
// 프로젝트 컨벤션: Map 키 = snake_case (CLAUDE.md 백엔드 규칙 참조)
|
||||
export interface BatchStats {
|
||||
totalBatches: number;
|
||||
activeBatches: number;
|
||||
todayExecutions: number;
|
||||
todayFailures: number;
|
||||
prevDayExecutions: number;
|
||||
prevDayFailures: number;
|
||||
total_count: number;
|
||||
active_count: number;
|
||||
today_count: number;
|
||||
today_failed_count: number;
|
||||
yesterday_count: number;
|
||||
yesterday_failed_count: number;
|
||||
}
|
||||
|
||||
export interface SparklineData {
|
||||
hour: string;
|
||||
success: number;
|
||||
failed: number;
|
||||
hour_slot: string;
|
||||
total_count: number;
|
||||
success_count: number;
|
||||
failed_count: number;
|
||||
}
|
||||
|
||||
export interface RecentLog {
|
||||
@@ -621,6 +624,24 @@ export class BatchAPI {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회사 전체 배치의 최근 24시간 스파크라인 데이터 (24개 슬롯 고정)
|
||||
*/
|
||||
static async getGlobalSparkline(): Promise<SparklineData[]> {
|
||||
try {
|
||||
const response = await apiClient.get<ApiResponse<SparklineData[]>>(
|
||||
`/batch-management/sparkline`
|
||||
);
|
||||
if (response.data.success) {
|
||||
return response.data.data || [];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error("글로벌 스파크라인 조회 오류:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치별 최근 24시간 스파크라인 데이터
|
||||
*/
|
||||
|
||||
@@ -366,7 +366,7 @@ export interface LayoutItem {
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
componentKind: string; // 정확한 컴포넌트 종류 (canonical 예: table / button — legacy 예: button-primary)
|
||||
componentKind: string; // 정확한 컴포넌트 종류 (table-list, button-primary 등)
|
||||
widgetType: string; // 일반적인 위젯 타입 (button, text 등)
|
||||
label?: string;
|
||||
bindField?: string; // 바인딩된 필드명 (컬럼명)
|
||||
|
||||
@@ -16,19 +16,11 @@
|
||||
import type { FieldConfig } from "@/types/invyone-component";
|
||||
|
||||
/**
|
||||
* FieldConfig[] → snake_case ColumnConfig[] 호환 배열.
|
||||
* FieldConfig[] → v2-table-list 의 ColumnConfig[] 호환 배열.
|
||||
*
|
||||
* (Phase F.2/F.8 이전 옛 table-list 본체가 소비하던 형태. 본체는 삭제됐고 현재는
|
||||
* 외부 코드가 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 객체에 머무른다.
|
||||
* 상세 매핑 규칙은 v2-table-list 내부 포맷 확정 후 보강한다. 현재는 공통 필드
|
||||
* (column_name / column_label / visible / display_order / width / align / sortable)
|
||||
* 만 매핑.
|
||||
*/
|
||||
export function fieldsToColumns(
|
||||
fields: FieldConfig[],
|
||||
@@ -36,78 +28,20 @@ export function fieldsToColumns(
|
||||
return [...fields]
|
||||
.filter((f) => f.visible !== false && !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 — FieldConfig 자체엔 autoGeneration 이 없지만 legacy layout 의
|
||||
// 원본 field 객체에 박혀들어올 수 있으므로 (any 캐스트로) 메타 보존.
|
||||
const autoGen =
|
||||
(f as any).autoGeneration ?? (f as any).auto_generation ?? undefined;
|
||||
return {
|
||||
column_name: f.column,
|
||||
column_label: f.label,
|
||||
label: f.label,
|
||||
visible: f.visible !== false,
|
||||
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 } : {}),
|
||||
};
|
||||
});
|
||||
.map((f) => ({
|
||||
column_name: f.column,
|
||||
column_label: f.label,
|
||||
label: f.label,
|
||||
visible: f.visible !== false,
|
||||
display_order: f.order ?? 0,
|
||||
width: f.width,
|
||||
align: f.align ?? (f.type === "number" ? "right" : "left"),
|
||||
sortable: f.sortable ?? true,
|
||||
data_type: f.type,
|
||||
format: f.format,
|
||||
pk: f.pk ?? false,
|
||||
editable: f.editable ?? true,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,7 +11,6 @@ import { useV2FormOptional } from "@/components/v2/V2FormContext";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
|
||||
import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
|
||||
|
||||
// 컬럼 메타데이터 캐시 (테이블명 → 컬럼 설정 맵)
|
||||
export const columnMetaCache: Record<string, Record<string, any>> = {};
|
||||
@@ -384,6 +383,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||
// stats
|
||||
"v2-aggregation-widget": "stats", "aggregation-widget": "stats",
|
||||
"v2-status-count": "stats",
|
||||
// table
|
||||
"v2-table-list": "table", "table-list": "table",
|
||||
// container
|
||||
"v2-tabs-widget": "container", "tabs-widget": "container", "tabs": "container", "v2-tabs": "container",
|
||||
"v2-section-card": "container",
|
||||
@@ -931,9 +932,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||
const rendererInstance = new RendererClass(rendererProps);
|
||||
renderedElement = rendererInstance.render();
|
||||
} else {
|
||||
// canonical 'table' / v2-repeater
|
||||
// 는 refreshKey 변동 시 강제 remount 가 필요 (data refetch 트리거).
|
||||
const needsKeyRefresh = isTableLikeComponentType(componentType) || componentType === "v2-repeater";
|
||||
const needsKeyRefresh =
|
||||
componentType === "v2-table-list" ||
|
||||
componentType === "table-list" ||
|
||||
componentType === "v2-repeater";
|
||||
renderedElement = <NewComponentRenderer key={needsKeyRefresh ? refreshKey : component.id} {...rendererProps} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -720,7 +720,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
|
||||
const allProviders = screenContext.getAllDataProviders();
|
||||
|
||||
// table-like (canonical table 등) 우선 탐색
|
||||
// table-like (canonical table / legacy table-list / hidden v2-table-list 등) 우선 탐색
|
||||
for (const [id, provider] of allProviders) {
|
||||
if (isTableLikeComponentType(provider.component_type)) {
|
||||
sourceProvider = provider;
|
||||
|
||||
@@ -5,25 +5,19 @@ import { ComponentConfig } from "@/types/component";
|
||||
/**
|
||||
* Container 컴포넌트 통합 설정 타입
|
||||
*
|
||||
* canonical 새 생성 경로. containerType 으로 tabs / section / accordion / repeater /
|
||||
* conditional 분기. SectionVariant 로 card / paper / plain 분기.
|
||||
* 11개의 레이아웃/컨테이너 계열 컴포넌트를 통합.
|
||||
* containerType 으로 탭/섹션/아코디언/반복/조건부 분기.
|
||||
*
|
||||
* 흡수 완료 (alias 라우팅 — DynamicComponentRenderer.LEGACY_TO_UNIFIED + getComponentConfigPanel.CONFIG_PANEL_ALIAS):
|
||||
* - tabs-widget / v2-tabs-widget / tabs / v2-tabs → container(tabs)
|
||||
* - section-card / v2-section-card → container(section, sectionVariant=card)
|
||||
* - section-paper / v2-section-paper → container(section, sectionVariant=paper)
|
||||
*
|
||||
* 보존 (canonical skeleton 부족 또는 도메인 특화) — concrete blocker, 본 cleanup 범위 외:
|
||||
* - accordion-basic → canonical container.containerType=accordion skeleton 부족
|
||||
* - conditional-container → canonical container.containerType=conditional skeleton 부족
|
||||
* - repeat-container → canonical container.containerType=repeater skeleton 부족
|
||||
* - 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 다수 사용처 때문에 별도 보존.
|
||||
* 흡수 대상 (11):
|
||||
* - v2-tabs-widget (탭)
|
||||
* - v2-section-card / v2-section-paper (섹션)
|
||||
* - v2-repeat-container / v2-repeater (반복)
|
||||
* - accordion-basic (아코디언)
|
||||
* - section-card / section-paper (legacy)
|
||||
* - tabs (legacy)
|
||||
* - conditional-container (조건부)
|
||||
* - repeat-container / repeat-screen-modal / repeater-field-group (legacy)
|
||||
* - screen-split-panel (legacy)
|
||||
*/
|
||||
|
||||
export type ContainerType =
|
||||
|
||||
@@ -59,7 +59,7 @@ import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데
|
||||
// ============================================================
|
||||
// ★ 2026-05-19 canonical 정리: alias 라우팅으로 충분한 옛 Renderer 자동등록 제거.
|
||||
// - aggregation-widget / v2-aggregation-widget / v2-status-count → canonical stats alias
|
||||
// - 옛 table 계열 → canonical table alias (Phase F.6 에서 alias 도 제거 완료)
|
||||
// - table-list / v2-table-list → canonical table alias
|
||||
// - 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)
|
||||
import "./button-primary/ButtonPrimaryRenderer";
|
||||
@@ -103,14 +103,8 @@ import "./grouped-table/GroupedTableRenderer"; // Phase G.3.1 — canonical 그
|
||||
// form 컴포넌트는 롤백됨 (2026-04-11): "폼" 은 별도 컴포넌트가 아닌
|
||||
// 화면 디자이너의 3뷰 탭(목록/등록 팝업/수정 팝업) 구조로 처리할 예정.
|
||||
// 관련: notes/gbpark/2026-04-11-component-unification-plan.md §3.2
|
||||
// canonical 'table' — 새 생성/유일한 런타임 경로. 옛 table 계열 ID 와 본체는 Phase F
|
||||
// (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 "./table/TableRenderer"; // v2-table-list + v2-table-grouped + v2-pivot-grid + v2-split-panel-layout + legacy 9종 흡수
|
||||
import "./container/ContainerRenderer"; // v2-tabs-widget + v2-section-card/paper + v2-repeat-container + accordion + conditional + legacy 11종 흡수
|
||||
import "./v2-repeat-container/RepeatContainerRenderer"; // canonical container.containerType=repeater skeleton 부족 → 보존
|
||||
// v2-section-card / v2-section-paper → canonical container alias (containerType=section + sectionVariant) 로 라우팅 (auto-register 제거)
|
||||
import "./domain/v2-rack-structure/RackStructureRenderer";
|
||||
|
||||
@@ -134,7 +134,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||
// 좌측 패널 대상: card-display만
|
||||
return tableId.includes("card-display") || tableId.includes("card");
|
||||
} else if (targetPanelPosition === "right") {
|
||||
// 우측 패널 대상: datatable, canonical table 등 (card-display 제외)
|
||||
// 우측 패널 대상: datatable, table-list 등 (card-display 제외)
|
||||
const isCardDisplay = tableId.includes("card-display") || tableId.includes("card");
|
||||
return !isCardDisplay;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,259 @@
|
||||
"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,11 +3,16 @@
|
||||
/**
|
||||
* SingleTableWithSticky — shared table renderer
|
||||
*
|
||||
* 2026-05-19 FlowWidget 의 sticky 테이블 구현을 흡수한 헬퍼.
|
||||
* 현재는 `frontend/components/screen/widgets/FlowWidget.tsx` 에서만 사용한다.
|
||||
* 2026-05-19 FlowWidget hard blocker 제거를 위해 legacy
|
||||
* `lib/registry/components/table-list/SingleTableWithSticky.tsx` 에서 이동.
|
||||
*
|
||||
* 본 파일은 자체 minimal `ColumnConfig` 를 export 하며 외부 타입을 의존하지 않는다.
|
||||
* canonical table runtime 은 `frontend/lib/registry/components/table/TableComponent.tsx` 사용.
|
||||
* 2026-05-20 v2-table-list 의 중복 sticky 구현을 흡수. `variant="v2"` prop 으로
|
||||
* v2 전용 헤더/행/셀 스타일, 인라인 편집(category/code select, date picker fallback,
|
||||
* 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";
|
||||
@@ -19,6 +24,10 @@ import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
|
||||
|
||||
/**
|
||||
* SingleTableWithSticky 가 실제 사용하는 컬럼 메타 minimal 정의.
|
||||
*
|
||||
* legacy table-list / v2-table-list 의 `ColumnConfig` 가
|
||||
* 이 type 의 superset 이므로 구조적으로 호환된다. 따라서 legacy 호출부에서는
|
||||
* 별도 변환 없이 그대로 전달 가능.
|
||||
*/
|
||||
export interface ColumnConfig {
|
||||
columnName: string;
|
||||
@@ -33,7 +42,11 @@ export interface ColumnConfig {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export type SingleTableVariant = "default" | "v2";
|
||||
|
||||
interface SingleTableWithStickyProps<TColumn extends ColumnConfig = ColumnConfig> {
|
||||
/** 시각/동작 분기 — 기본은 table-list / FlowWidget 기존 스타일, "v2" 는 v2-table-list 흡수 분기 */
|
||||
variant?: SingleTableVariant;
|
||||
visibleColumns?: TColumn[];
|
||||
columns?: TColumn[];
|
||||
data: Record<string, any>[];
|
||||
@@ -49,6 +62,7 @@ interface SingleTableWithStickyProps<TColumn extends ColumnConfig = ColumnConfig
|
||||
handleRowClick?: (row: any, index: number, e: React.MouseEvent) => void;
|
||||
renderCheckboxCell?: (row: any, index: number) => React.ReactNode;
|
||||
renderCheckboxHeader?: () => React.ReactNode;
|
||||
/** v2 에서는 ReactNode (이미지/JSX) 반환 가능. 기본 호출부는 string 반환해도 ReactNode subset 이라 호환 */
|
||||
formatCellValue: (
|
||||
value: any,
|
||||
format?: string,
|
||||
@@ -65,12 +79,19 @@ interface SingleTableWithStickyProps<TColumn extends ColumnConfig = ColumnConfig
|
||||
onEditingValueChange?: (value: string) => void;
|
||||
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
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>;
|
||||
currentSearchIndex?: number;
|
||||
searchTerm?: string;
|
||||
}
|
||||
|
||||
export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfig>({
|
||||
variant = "default",
|
||||
visibleColumns,
|
||||
columns,
|
||||
data,
|
||||
@@ -97,6 +118,9 @@ export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfi
|
||||
onEditingValueChange,
|
||||
onEditKeyDown,
|
||||
editInputRef,
|
||||
onEditSave,
|
||||
columnMeta,
|
||||
categoryMappings,
|
||||
searchHighlights,
|
||||
currentSearchIndex = 0,
|
||||
searchTerm = "",
|
||||
@@ -107,6 +131,22 @@ export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfi
|
||||
const sortHandler = onSort || handleSort || (() => {});
|
||||
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 (
|
||||
<div
|
||||
className="bg-background relative flex flex-1 flex-col overflow-hidden shadow-sm"
|
||||
@@ -116,19 +156,13 @@ export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfi
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
<div className="relative flex-1 overflow-auto">
|
||||
<Table
|
||||
noWrapper
|
||||
className="w-full"
|
||||
style={{ width: "100%", tableLayout: "auto", boxSizing: "border-box" }}
|
||||
>
|
||||
<div className="relative flex-1 overflow-auto" style={scrollContainerStyle}>
|
||||
<Table noWrapper className="w-full" style={tableStyle}>
|
||||
<TableHeader
|
||||
className={cn(
|
||||
"bg-background border-b",
|
||||
tableConfig?.stickyHeader && "sticky top-0 z-30 shadow-sm",
|
||||
)}
|
||||
className={cn(headerBaseClass, tableConfig?.stickyHeader && "sticky top-0 z-30 shadow-sm")}
|
||||
style={headerStyle}
|
||||
>
|
||||
<TableRow className="border-b">
|
||||
<TableRow className={headerRowClass}>
|
||||
{actualColumns.map((column, colIndex) => {
|
||||
const leftFixedWidth = actualColumns
|
||||
.slice(0, colIndex)
|
||||
@@ -144,10 +178,20 @@ export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfi
|
||||
|
||||
const isCheckboxCol = column.columnName === "__checkbox__";
|
||||
|
||||
const headCheckboxBaseClass =
|
||||
"bg-background h-9 border-0 px-3 py-1.5 text-center align-middle sm:px-4 sm:py-2";
|
||||
const headDataBaseClass =
|
||||
"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 headCheckboxBaseClass = isV2
|
||||
? "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 = isV2
|
||||
? "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 (
|
||||
<TableHead
|
||||
@@ -155,33 +199,45 @@ export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfi
|
||||
className={cn(
|
||||
isCheckboxCol ? headCheckboxBaseClass : headDataBaseClass,
|
||||
`text-${column.align}`,
|
||||
column.sortable && "hover:bg-primary/10",
|
||||
column.sortable && sortableHoverClass,
|
||||
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",
|
||||
isDesignMode && column.hidden && "bg-muted/50 opacity-40",
|
||||
)}
|
||||
style={{
|
||||
width: getColumnWidth(column),
|
||||
minWidth: "100px",
|
||||
maxWidth: "300px",
|
||||
width: headWidth,
|
||||
minWidth: headMinWidth,
|
||||
maxWidth: headMaxWidth,
|
||||
boxSizing: "border-box",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
backgroundColor: "hsl(var(--background))",
|
||||
backgroundColor: headBackground,
|
||||
...(column.fixed === "left" && { left: leftFixedWidth }),
|
||||
...(column.fixed === "right" && { right: rightFixedWidth }),
|
||||
}}
|
||||
onClick={() => column.sortable && sortHandler(column.columnName)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn("flex items-center", isV2 && isCheckboxCol ? "justify-center" : "gap-2")}>
|
||||
{isCheckboxCol ? (
|
||||
checkboxConfig.selectAll && (
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
onCheckedChange={handleSelectAll}
|
||||
aria-label="전체 선택"
|
||||
style={{ zIndex: 1 }}
|
||||
style={
|
||||
isV2
|
||||
? {
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderWidth: 1.5,
|
||||
borderColor: isAllSelected
|
||||
? "hsl(var(--primary))"
|
||||
: "hsl(var(--muted-foreground) / 0.5)",
|
||||
zIndex: 1,
|
||||
}
|
||||
: { zIndex: 1 }
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
@@ -241,10 +297,17 @@ export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfi
|
||||
</TableRow>
|
||||
) : (
|
||||
actualData.map((row, index) => {
|
||||
const rowClass = cn(
|
||||
"bg-background h-10 cursor-pointer border-b transition-colors",
|
||||
tableConfig?.tableStyle?.hoverEffect && "hover:bg-muted/50",
|
||||
);
|
||||
// ── 행 className 분기 (v2 alternate background + hoverEffect 기본 true) ──
|
||||
const rowClass = isV2
|
||||
? cn(
|
||||
"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 (
|
||||
<TableRow key={`row-${index}`} className={rowClass} onClick={(e) => handleRowClick?.(row, index, e)}>
|
||||
@@ -282,10 +345,31 @@ export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfi
|
||||
currentSearchIndex < highlightArray.length &&
|
||||
highlightArray[currentSearchIndex] === cellKey;
|
||||
|
||||
const rawCellValue: React.ReactNode =
|
||||
formatCellValue(row[column.columnName], column.format, column.columnName, row) || " ";
|
||||
// ── 셀 값 분기 ──
|
||||
// v2: null/undefined/"" → "-" 표시 (0 은 값 그대로), ReactElement 가능
|
||||
// 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 = () => {
|
||||
// ReactElement (v2 의 이미지/JSX) 는 그대로 렌더
|
||||
if (isReactElement) {
|
||||
return rawCellValue;
|
||||
}
|
||||
|
||||
if (!isHighlighted || !searchTerm || isCheckboxCol) {
|
||||
return rawCellValue;
|
||||
}
|
||||
@@ -319,15 +403,37 @@ export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfi
|
||||
);
|
||||
};
|
||||
|
||||
const cellClass = 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",
|
||||
);
|
||||
// ── 셀 className 분기 ──
|
||||
const cellClass = isV2
|
||||
? cn(
|
||||
"text-foreground h-10 align-middle text-[11px] transition-colors",
|
||||
isCheckboxCol ? "px-0 py-[7px] text-center" : "px-3 py-[7px]",
|
||||
!isReactElement && "whitespace-nowrap",
|
||||
!isCheckboxCol && `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",
|
||||
)
|
||||
: 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 (
|
||||
<TableCell
|
||||
@@ -335,13 +441,11 @@ export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfi
|
||||
id={isCurrentSearchResult ? "current-search-result" : undefined}
|
||||
className={cellClass}
|
||||
style={{
|
||||
width: getColumnWidth(column),
|
||||
minWidth: "100px",
|
||||
maxWidth: "300px",
|
||||
width: cellFixedWidth,
|
||||
minWidth: cellMinWidth,
|
||||
maxWidth: cellMaxWidth,
|
||||
boxSizing: "border-box",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
...cellOverflowStyle,
|
||||
...(column.fixed === "left" && { left: leftFixedWidth }),
|
||||
...(column.fixed === "right" && { right: rightFixedWidth }),
|
||||
}}
|
||||
@@ -355,24 +459,120 @@ export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfi
|
||||
{isCheckboxCol ? (
|
||||
renderCheckboxCell?.(row, index)
|
||||
) : isEditing ? (
|
||||
<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);
|
||||
isV2 ? (
|
||||
// ── v2 인라인 편집: inputType 에 따라 select(category/code), date/datetime, number, text ──
|
||||
(() => {
|
||||
const meta = columnMeta?.[column.columnName];
|
||||
const inputType = meta?.inputType ?? (column as { inputType?: string }).inputType;
|
||||
const isNumeric = inputType === "number" || inputType === "decimal";
|
||||
const isCategoryType = inputType === "category" || inputType === "code";
|
||||
const categoryOptions = categoryMappings?.[column.columnName];
|
||||
const hasCategoryOptions =
|
||||
isCategoryType && categoryOptions && Object.keys(categoryOptions).length > 0;
|
||||
|
||||
const commonInputClass =
|
||||
"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";
|
||||
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>
|
||||
);
|
||||
}
|
||||
}}
|
||||
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()}
|
||||
/>
|
||||
|
||||
if (inputType === "date" || inputType === "datetime") {
|
||||
try {
|
||||
// 외부 의존 모듈 — 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()
|
||||
)}
|
||||
|
||||
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
@@ -0,0 +1,92 @@
|
||||
"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";
|
||||
@@ -0,0 +1,352 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,530 +0,0 @@
|
||||
"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,18 +7,19 @@ import { InvTableConfigPanel } from "./InvTableConfigPanel";
|
||||
import type { TableConfig } from "./types";
|
||||
|
||||
/**
|
||||
* Table — canonical data table component
|
||||
* Table — 통합 데이터 테이블 컴포넌트 (2026-04-11, Phase C-1)
|
||||
*
|
||||
* 통합 데이터 테이블. 5가지 displayMode 지원:
|
||||
* - table (기본 그리드)
|
||||
* - split (좌우 분할 — master-detail)
|
||||
* - grouped (그룹화)
|
||||
* - pivot (피벗 그리드)
|
||||
* - card (카드 리스트, 좁은 컨테이너 자동 fallback)
|
||||
* 흡수 대상 (9):
|
||||
* - v2-table-list (base)
|
||||
* - v2-table-grouped (displayMode='grouped')
|
||||
* - v2-pivot-grid (displayMode='pivot')
|
||||
* - v2-split-panel-layout (displayMode='split')
|
||||
* - table-list, split-panel-layout, split-panel-layout2 (legacy)
|
||||
* - 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-05-20-table-canonical-cleanup-plan.md
|
||||
*/
|
||||
|
||||
const DEFAULT_CONFIG: Partial<TableConfig> = {
|
||||
|
||||
@@ -2,17 +2,21 @@
|
||||
|
||||
import { ComponentConfig } from "@/types/component";
|
||||
import type { FieldConfig } from "@/types/invyone-component";
|
||||
import type { DataFilterConfig } from "@/types/screen-management";
|
||||
import type { AutoGenerationConfig } from "@/types/screen";
|
||||
|
||||
/**
|
||||
* Table 컴포넌트 통합 설정 타입
|
||||
*
|
||||
* 기존 테이블 계열 컴포넌트들을 통합한 **범용 데이터 테이블**.
|
||||
* displayMode 로 변형 (기본/분할/그룹/피벗/카드).
|
||||
* 9개의 기존 테이블 계열 컴포넌트를 통합한 **범용 데이터 테이블**.
|
||||
* displayMode 로 변형 (기본/분할/그룹/피벗).
|
||||
*
|
||||
* 옛 분리 구현 (table-list / v2-table-list / split-panel-layout 등) 은 Phase F 단계에서
|
||||
* 본 canonical TableComponent 로 흡수 완료. 더 이상 별도 본체/스키마/엔트리가 존재하지 않음.
|
||||
* 흡수 대상 (9):
|
||||
* - v2-table-list (base, 기본 테이블)
|
||||
* - 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";
|
||||
@@ -32,271 +36,15 @@ export interface TableColumn {
|
||||
sortable?: boolean;
|
||||
/** 포맷 */
|
||||
format?: string;
|
||||
/** 표시 여부 (false 면 운영/디자인 모두 안 보임) */
|
||||
/** 표시 여부 */
|
||||
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 {
|
||||
enabled?: boolean;
|
||||
pageSize?: number;
|
||||
/** 페이지 크기 변경 드롭다운 노출 (D.6 footer 에서 wiring) */
|
||||
showSizeSelector?: boolean;
|
||||
/** "1-20 / 총 100건" 식 페이지 정보 텍스트 노출 (D.6) */
|
||||
showPageInfo?: boolean;
|
||||
/** 사용자가 선택할 수 있는 페이지 크기 옵션 (예: [10, 20, 50, 100]) */
|
||||
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 모드 보조 타입 ─────────────────────────────────────────────
|
||||
@@ -313,8 +61,6 @@ export interface TableCardStyleConfig {
|
||||
showViewButton?: boolean;
|
||||
showEditButton?: boolean;
|
||||
showDeleteButton?: boolean;
|
||||
/** Phase D.7 — 카드 고정 높이. `"auto"` 또는 미지정이면 content 에 맞춤 */
|
||||
cardHeight?: number | "auto";
|
||||
}
|
||||
|
||||
export interface TableCardColumnMapping {
|
||||
@@ -326,8 +72,6 @@ export interface TableCardColumnMapping {
|
||||
displayColumns?: string[];
|
||||
/** 액션 버튼 셀로 표시할 컬럼들 */
|
||||
actionColumns?: string[];
|
||||
/** Phase D.7 — 카드 헤더 우측 작은 ID 배지에 사용 (legacy cardConfig.idColumn 흡수) */
|
||||
idColumn?: string;
|
||||
}
|
||||
|
||||
// ─── pivot 모드 풍부한 필드 정의 ───────────────────────────────────
|
||||
@@ -593,33 +337,8 @@ export interface PivotGridProps {
|
||||
}
|
||||
|
||||
export interface TableConfig extends ComponentConfig {
|
||||
/** 연결된 테이블명 (DB). canonical 정규 키. */
|
||||
/** 연결된 테이블명 (DB) */
|
||||
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;
|
||||
/** 컬럼 설정 */
|
||||
@@ -686,13 +405,6 @@ export interface TableConfig extends ComponentConfig {
|
||||
cardStyle?: TableCardStyleConfig;
|
||||
/** card 모드: 데이터 컬럼 → 카드 영역 매핑 */
|
||||
cardColumnMapping?: TableCardColumnMapping;
|
||||
/**
|
||||
* Phase D.7 — 좁은 컨테이너에서 자동으로 카드 모드 fallback 임계값 (px).
|
||||
* 양수 number: 미만 시 displayMode=table → card 자동 전환 (split/grouped/pivot 영향 X)
|
||||
* `false` 또는 `0`: 자동 fallback 비활성
|
||||
* 미지정: 기본 600
|
||||
*/
|
||||
responsiveCardBreakpoint?: number | false;
|
||||
|
||||
// ─── 빈 상태 / 로딩 ───
|
||||
/** 빈 상태 메시지 */
|
||||
@@ -701,94 +413,8 @@ export interface TableConfig extends ComponentConfig {
|
||||
// ─── 툴바 ───
|
||||
/** 툴바 표시 */
|
||||
showToolbar?: boolean;
|
||||
/** 엑셀 내보내기 버튼 (legacy root key, `toolbar.showExcel` 와 alias) */
|
||||
/** 엑셀 내보내기 버튼 */
|
||||
showExcel?: boolean;
|
||||
/** 새로고침 버튼 (legacy root key, `toolbar.showRefresh` 와 alias) */
|
||||
/** 새로고침 버튼 */
|
||||
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,28 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import type { DataFilterConfig } from "@/types/screen-management";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
|
||||
/**
|
||||
* useTableData — 통합 table 컴포넌트 데이터 fetch 훅
|
||||
*
|
||||
* 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 {
|
||||
tableName?: string;
|
||||
page?: number;
|
||||
@@ -31,10 +17,6 @@ export interface UseTableDataParams {
|
||||
sortOrder?: "asc" | "desc";
|
||||
search?: Record<string, any>;
|
||||
enabled?: boolean; // false면 fetch 안 함 (디자인 모드)
|
||||
/** D.2 — enabled 일 때만 entityJoinApi 에 전달 */
|
||||
dataFilter?: DataFilterConfig;
|
||||
/** D.2 — enabled 일 때만 entityJoinApi 에 전달 */
|
||||
excludeFilter?: ExcludeFilterPayload;
|
||||
}
|
||||
|
||||
export interface UseTableDataResult {
|
||||
@@ -54,11 +36,6 @@ export interface UseTableDataResult {
|
||||
toggleSort: (col: string) => void;
|
||||
setSearch: (s: Record<string, any>) => 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 {
|
||||
@@ -70,21 +47,8 @@ export function useTableData(params: UseTableDataParams): UseTableDataResult {
|
||||
sortOrder: initialSortOrder = "desc",
|
||||
search: externalSearch,
|
||||
enabled = true,
|
||||
dataFilter,
|
||||
excludeFilter,
|
||||
} = 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 [total, setTotal] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
@@ -95,29 +59,14 @@ export function useTableData(params: UseTableDataParams): UseTableDataResult {
|
||||
const [search, setSearch] = useState<Record<string, any>>(externalSearch || {});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const initialStateRef = useRef({
|
||||
tableName,
|
||||
page: initialPage,
|
||||
pageSize: initialPageSize,
|
||||
sortBy: initialSortBy,
|
||||
sortOrder: initialSortOrder,
|
||||
});
|
||||
const refreshKey = useRef(0);
|
||||
|
||||
// 외부 검색 조건 동기화.
|
||||
// D.2: 필터를 모두 clear 해서 externalSearch 가 undefined 로 바뀐 경우에도
|
||||
// 내부 search state 를 비워야 stale 검색 조건이 남지 않는다.
|
||||
// 외부 검색 조건 동기화
|
||||
useEffect(() => {
|
||||
const nextSearch = externalSearch || {};
|
||||
setSearch((prev) => {
|
||||
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);
|
||||
if (externalSearch) {
|
||||
setSearch(externalSearch);
|
||||
setPage(1);
|
||||
}
|
||||
}, [externalSearch]);
|
||||
|
||||
// 데이터 fetch
|
||||
@@ -138,11 +87,6 @@ export function useTableData(params: UseTableDataParams): UseTableDataResult {
|
||||
sortOrder,
|
||||
search: Object.keys(search).length > 0 ? search : undefined,
|
||||
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 || []);
|
||||
@@ -157,51 +101,18 @@ export function useTableData(params: UseTableDataParams): UseTableDataResult {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// dataFilter / excludeFilter 객체 ref 가 아닌 *Json string 만 dep — fetch 폭주 방지
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
tableName,
|
||||
page,
|
||||
pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
search,
|
||||
enabled,
|
||||
dataFilterJson,
|
||||
excludeFilterJson,
|
||||
]);
|
||||
}, [tableName, page, pageSize, sortBy, sortOrder, search, enabled, refreshKey.current]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// 테이블 / config 초기값 변경 시 런타임 상태 동기화.
|
||||
// 초기 mount 에서는 useState(initial*) 값이 이미 권위이므로 reset 하지 않는다.
|
||||
// 테이블 변경 시 페이지 리셋
|
||||
useEffect(() => {
|
||||
const prev = initialStateRef.current;
|
||||
const changed =
|
||||
prev.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]);
|
||||
setPage(1);
|
||||
setSortBy("");
|
||||
setSearch({});
|
||||
}, [tableName]);
|
||||
|
||||
const toggleSort = useCallback((col: string) => {
|
||||
setSortBy((prev) => {
|
||||
@@ -215,28 +126,10 @@ export function useTableData(params: UseTableDataParams): UseTableDataResult {
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
refreshKey.current += 1;
|
||||
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 {
|
||||
data,
|
||||
total,
|
||||
@@ -248,12 +141,10 @@ export function useTableData(params: UseTableDataParams): UseTableDataResult {
|
||||
loading,
|
||||
error,
|
||||
setPage,
|
||||
setPageSize: setPageSizeAction,
|
||||
setPageSize: (s: number) => { setPageSize(s); setPage(1); },
|
||||
setSortBy,
|
||||
toggleSort,
|
||||
setSearch: setSearchAction,
|
||||
setSearch: (s: Record<string, any>) => { setSearch(s); setPage(1); },
|
||||
refresh,
|
||||
// Phase D.9 — 외부 receiveData() 가 local override. 다음 fetch 까지 유지.
|
||||
setLocalData,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import React from "react";
|
||||
import { Eye, Pencil, Trash2 } from "lucide-react";
|
||||
import type {
|
||||
TableColumn,
|
||||
TableConfig,
|
||||
TableCardStyleConfig,
|
||||
} from "../types";
|
||||
import { getFullImageUrl } from "@/lib/api/client";
|
||||
import { getFilePreviewUrl } from "@/lib/api/file";
|
||||
import { renderTableCellValue } from "../cell-renderers";
|
||||
import type { TableConfig, TableCardStyleConfig } from "../types";
|
||||
|
||||
export interface CardViewProps {
|
||||
config: TableConfig;
|
||||
@@ -19,10 +12,6 @@ export interface CardViewProps {
|
||||
onView?: (row: any) => void;
|
||||
onEdit?: (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> = {
|
||||
@@ -37,7 +26,6 @@ const DEFAULT_STYLE: Required<TableCardStyleConfig> = {
|
||||
showViewButton: false,
|
||||
showEditButton: false,
|
||||
showDeleteButton: false,
|
||||
cardHeight: "auto",
|
||||
};
|
||||
|
||||
const IMAGE_SIZE_PX: Record<NonNullable<TableCardStyleConfig["imageSize"]>, number> = {
|
||||
@@ -46,67 +34,11 @@ const IMAGE_SIZE_PX: Record<NonNullable<TableCardStyleConfig["imageSize"]>, numb
|
||||
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"
|
||||
*
|
||||
* 데이터 행을 카드 그리드로 렌더. `config.cardColumnMapping` 으로 데이터 컬럼을
|
||||
* 카드 영역 (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 면 카드 고정 높이
|
||||
* 데이터 행을 카드 그리드로 렌더. config.cardColumnMapping 으로 데이터 컬럼을
|
||||
* 카드 영역 (title/subtitle/description/image) 에 매핑.
|
||||
*/
|
||||
export function CardView({
|
||||
config,
|
||||
@@ -116,8 +48,6 @@ export function CardView({
|
||||
onView,
|
||||
onEdit,
|
||||
onDelete,
|
||||
columns,
|
||||
getColumnLabel,
|
||||
}: CardViewProps) {
|
||||
const cardsPerRow = config.cardsPerRow ?? 3;
|
||||
const cardSpacing = config.cardSpacing ?? 12;
|
||||
@@ -125,69 +55,7 @@ export function CardView({
|
||||
...DEFAULT_STYLE,
|
||||
...(config.cardStyle ?? {}),
|
||||
};
|
||||
|
||||
// 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]);
|
||||
const mapping = config.cardColumnMapping ?? {};
|
||||
|
||||
if (data.length === 0) {
|
||||
return <div style={emptyStyle}>{config.emptyMessage || "데이터 없음"}</div>;
|
||||
@@ -210,9 +78,6 @@ export function CardView({
|
||||
row={row}
|
||||
mapping={mapping}
|
||||
style={style}
|
||||
cardHeight={typeof cardHeight === "number" ? cardHeight : undefined}
|
||||
columnByKey={columnByKey}
|
||||
getColumnLabel={getColumnLabel}
|
||||
onClick={onCardClick ? () => onCardClick(row) : undefined}
|
||||
onView={style.showActions && style.showViewButton ? () => onView?.(row) : undefined}
|
||||
onEdit={style.showActions && style.showEditButton ? () => onEdit?.(row) : undefined}
|
||||
@@ -226,18 +91,8 @@ export function CardView({
|
||||
|
||||
interface CardItemProps {
|
||||
row: any;
|
||||
mapping: {
|
||||
titleColumn?: string;
|
||||
subtitleColumn?: string;
|
||||
descriptionColumn?: string;
|
||||
imageColumn?: string;
|
||||
idColumn?: string;
|
||||
displayColumns?: string[];
|
||||
};
|
||||
mapping: NonNullable<TableConfig["cardColumnMapping"]>;
|
||||
style: Required<TableCardStyleConfig>;
|
||||
cardHeight?: number;
|
||||
columnByKey: Map<string, TableColumn>;
|
||||
getColumnLabel?: (col: TableColumn) => string;
|
||||
isDesignMode: boolean;
|
||||
onClick?: () => void;
|
||||
onView?: () => void;
|
||||
@@ -245,34 +100,15 @@ interface CardItemProps {
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
function CardItem({
|
||||
row,
|
||||
mapping,
|
||||
style,
|
||||
cardHeight,
|
||||
columnByKey,
|
||||
getColumnLabel,
|
||||
isDesignMode,
|
||||
onClick,
|
||||
onView,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: CardItemProps) {
|
||||
const [imgError, setImgError] = React.useState(false);
|
||||
|
||||
function CardItem({ row, mapping, style, isDesignMode, onClick, onView, onEdit, onDelete }: CardItemProps) {
|
||||
const title = mapping.titleColumn ? row?.[mapping.titleColumn] : undefined;
|
||||
const subtitle = mapping.subtitleColumn ? row?.[mapping.subtitleColumn] : undefined;
|
||||
const descriptionRaw = mapping.descriptionColumn ? row?.[mapping.descriptionColumn] : 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 image = mapping.imageColumn ? row?.[mapping.imageColumn] : undefined;
|
||||
const description =
|
||||
typeof descriptionRaw === "string" && descriptionRaw.length > style.maxDescriptionLength
|
||||
? `${descriptionRaw.slice(0, style.maxDescriptionLength)}…`
|
||||
: descriptionRaw;
|
||||
const imageUrl = _normalizeImageUrl(imageRaw);
|
||||
|
||||
const imagePx = IMAGE_SIZE_PX[style.imageSize];
|
||||
const isHorizontal = style.imagePosition === "left" || style.imagePosition === "right";
|
||||
@@ -285,135 +121,45 @@ function CardItem({
|
||||
cursor: onClick ? "pointer" : "default",
|
||||
display: isHorizontal ? "flex" : "block",
|
||||
flexDirection: style.imagePosition === "right" ? "row-reverse" : "row",
|
||||
...(typeof cardHeight === "number" && cardHeight > 0
|
||||
? { height: `${cardHeight}px` }
|
||||
: {}),
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={cardStyle} onClick={onClick}>
|
||||
{style.showImage && imageUrl && !imgError && (
|
||||
{style.showImage && image && (
|
||||
<div
|
||||
style={{
|
||||
width: isHorizontal ? imagePx : "100%",
|
||||
height: imagePx,
|
||||
background: "hsl(var(--muted))",
|
||||
flexShrink: 0,
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
backgroundImage: `url(${image})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
onError={() => setImgError(true)}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
display: "block",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
<div style={{ padding: 12, flex: 1, minWidth: 0 }}>
|
||||
{style.showTitle && title !== undefined && title !== null && title !== "" && (
|
||||
<div style={{ fontSize: 13, fontWeight: 700 }}>{String(title)}</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, marginBottom: 4 }}>{String(title)}</div>
|
||||
)}
|
||||
{style.showSubtitle && subtitle !== undefined && subtitle !== null && subtitle !== "" && (
|
||||
<div style={{ fontSize: 11, color: "hsl(var(--muted-foreground))" }}>
|
||||
<div style={{ fontSize: 11, color: "hsl(var(--muted-foreground))", marginBottom: 6 }}>
|
||||
{String(subtitle)}
|
||||
</div>
|
||||
)}
|
||||
{style.showDescription && description !== undefined && description !== null && description !== "" && (
|
||||
<div style={{ fontSize: 12, lineHeight: 1.5 }}>{String(description)}</div>
|
||||
<div style={{ fontSize: 12, lineHeight: 1.5, marginBottom: style.showActions ? 8 : 0 }}>
|
||||
{String(description)}
|
||||
</div>
|
||||
)}
|
||||
{(mapping.displayColumns?.length ?? 0) > 0 && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 6,
|
||||
fontSize: 11,
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
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 style={{ marginTop: 6, fontSize: 11, color: "hsl(var(--muted-foreground))" }}>
|
||||
{mapping.displayColumns!.map((col) => (
|
||||
<div key={col} style={{ display: "flex", gap: 6 }}>
|
||||
<span style={{ fontWeight: 500 }}>{col}:</span>
|
||||
<span>{row?.[col] !== undefined && row?.[col] !== null ? String(row[col]) : "-"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{style.showActions && (onView || onEdit || onDelete) && (
|
||||
@@ -429,15 +175,11 @@ function CardItem({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isDesignMode &&
|
||||
!mapping.titleColumn &&
|
||||
!mapping.subtitleColumn &&
|
||||
!mapping.descriptionColumn &&
|
||||
!mapping.imageColumn && (
|
||||
<div style={{ fontSize: 10.5, color: "hsl(var(--muted-foreground))" }}>
|
||||
[디자인 모드] 컬럼 매핑이 비어있어 빈 카드만 표시됩니다.
|
||||
</div>
|
||||
)}
|
||||
{isDesignMode && Object.keys(mapping).length === 0 && (
|
||||
<div style={{ fontSize: 10.5, color: "hsl(var(--muted-foreground))" }}>
|
||||
[디자인 모드] 컬럼 매핑이 비어있어 빈 카드만 표시됩니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { ChevronRight, ChevronDown } from "lucide-react";
|
||||
import type { TableConfig, TableColumn } from "../types";
|
||||
import type { GroupSumConfig } from "@/types/table-options";
|
||||
import { renderTableCellValue } from "../cell-renderers";
|
||||
|
||||
export interface GroupedViewProps {
|
||||
config: TableConfig;
|
||||
@@ -13,25 +11,13 @@ export interface GroupedViewProps {
|
||||
rowHeightPx?: string;
|
||||
isDesignMode?: boolean;
|
||||
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"
|
||||
*
|
||||
* 그룹 컬럼 기준으로 데이터를 그룹화해 펼침/접힘 단위로 렌더.
|
||||
* 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({
|
||||
config,
|
||||
@@ -40,28 +26,8 @@ export function GroupedView({
|
||||
rowHeightPx = "36px",
|
||||
isDesignMode = false,
|
||||
onRowClick,
|
||||
groupByColumns,
|
||||
groupSumConfig,
|
||||
getColumnLabel,
|
||||
}: GroupedViewProps) {
|
||||
// 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 groupBy = config.groupBy;
|
||||
|
||||
const groups = useMemo<Array<{ key: string; rows: any[] }>>(() => {
|
||||
if (!groupBy) return [{ key: "(전체)", rows: data }];
|
||||
@@ -76,59 +42,6 @@ export function GroupedView({
|
||||
return Array.from(map.entries()).map(([key, rows]) => ({ key, rows }));
|
||||
}, [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 toggle = (key: string) => {
|
||||
setCollapsedKeys((prev) => {
|
||||
@@ -151,25 +64,6 @@ export function GroupedView({
|
||||
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 (
|
||||
<div style={{ overflow: "auto", flex: 1 }}>
|
||||
<table style={tableStyle}>
|
||||
@@ -185,7 +79,7 @@ export function GroupedView({
|
||||
textAlign: col.align || "left",
|
||||
}}
|
||||
>
|
||||
{getColumnLabel ? getColumnLabel(col) : col.label || col.key}
|
||||
{col.label || col.key}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
@@ -193,7 +87,6 @@ export function GroupedView({
|
||||
<tbody>
|
||||
{groups.map(({ key, rows }) => {
|
||||
const collapsed = collapsedKeys.has(key);
|
||||
const summary = summaryByGroup.get(key);
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
<tr style={groupHeaderRowStyle} onClick={() => toggle(key)}>
|
||||
@@ -212,19 +105,6 @@ export function GroupedView({
|
||||
<span style={{ color: "hsl(var(--muted-foreground))", fontSize: 11 }}>
|
||||
({rows.length}건)
|
||||
</span>
|
||||
{/* Phase D.8 — group summary inline */}
|
||||
{summary && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
color: "hsl(var(--primary))",
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{formatSumHint(summary)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{!collapsed &&
|
||||
@@ -240,114 +120,32 @@ export function GroupedView({
|
||||
key={col.key}
|
||||
style={{ ...tdStyle, textAlign: col.align || "left" }}
|
||||
>
|
||||
{renderTableCellValue({
|
||||
value: row?.[col.key],
|
||||
column: col,
|
||||
row,
|
||||
isDesignMode,
|
||||
})}
|
||||
{formatCell(row?.[col.key], col.format)}
|
||||
</td>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
{/* 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>
|
||||
</table>
|
||||
{isDesignMode && (
|
||||
<div style={{ padding: "6px 10px", fontSize: 10.5, color: "hsl(var(--muted-foreground))" }}>
|
||||
[디자인 모드] {groups.length}개 그룹
|
||||
{groupSumEnabled ? " (group-sum 활성)" : ""}
|
||||
</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 = {
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
@@ -360,7 +158,7 @@ const thStyle: React.CSSProperties = {
|
||||
fontWeight: 700,
|
||||
color: "hsl(var(--foreground))",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0,
|
||||
letterSpacing: "0.03em",
|
||||
borderBottom: "1px solid hsl(var(--border))",
|
||||
textAlign: "left",
|
||||
whiteSpace: "nowrap",
|
||||
@@ -381,15 +179,6 @@ const groupHeaderRowStyle: React.CSSProperties = {
|
||||
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 = {
|
||||
padding: 24,
|
||||
textAlign: "center",
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@ export function UniversalFormModalConfigPanel({
|
||||
const compType = comp.componentId || comp.componentConfig?.type || comp.componentConfig?.id || comp.type;
|
||||
const compConfig = comp.componentConfig || {};
|
||||
|
||||
// 1. Table-like (canonical 'table' 등)
|
||||
// 1. Table-like (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list')
|
||||
// + InteractiveDataTable - 테이블 컬럼 추출
|
||||
if (isTableLikeComponentType(compType) || compType === "interactive-data-table") {
|
||||
const tableName = compConfig.selectedTable || compConfig.tableName;
|
||||
|
||||
@@ -768,7 +768,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
|
||||
const allProviders = screenContext.getAllDataProviders();
|
||||
|
||||
// table-like (canonical 'table' 등) 우선 탐색
|
||||
// table-like (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list') 우선 탐색
|
||||
for (const [id, provider] of allProviders) {
|
||||
if (isTableLikeComponentType(provider.component_type)) {
|
||||
sourceProvider = provider;
|
||||
|
||||
@@ -114,7 +114,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||
// 좌측 패널 대상: card-display만
|
||||
return tableId.includes("card-display") || tableId.includes("card");
|
||||
} else if (targetPanelPosition === "right") {
|
||||
// 우측 패널 대상: datatable, canonical table 등 (card-display 제외)
|
||||
// 우측 패널 대상: datatable, table-list 등 (card-display 제외)
|
||||
const isCardDisplay = tableId.includes("card-display") || tableId.includes("card");
|
||||
return !isCardDisplay;
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ export function getComponentUrl(componentType: string): string {
|
||||
// 컴포넌트 타입 추출 함수 (URL에서)
|
||||
// ============================================
|
||||
export function getComponentTypeFromUrl(componentUrl: string): string {
|
||||
// "@/lib/registry/components/table" → "table"
|
||||
// "@/lib/registry/components/v2-table-list" → "v2-table-list"
|
||||
const parts = componentUrl.split("/");
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
@@ -223,6 +223,36 @@ export type LayoutV2 = z.infer<typeof layoutV2Schema>;
|
||||
// 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
|
||||
const v2ButtonPrimaryOverridesSchema = z
|
||||
.object({
|
||||
@@ -407,7 +437,17 @@ const v2V2RepeaterOverridesSchema = z
|
||||
|
||||
// V2 입력/선택 폐기 (Phase D.2, 2026-05-12) — input canonical 로 흡수.
|
||||
// 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
|
||||
const v2LayoutOverridesSchema = z
|
||||
@@ -511,7 +551,7 @@ const v2RepeaterOverridesSchema = z
|
||||
// ============================================
|
||||
const componentOverridesSchemaRegistry: Record<string, z.ZodType<Record<string, any>>> = {
|
||||
// V2 컴포넌트 (canonical alias 라우팅된 항목은 schema 제거됨 — 2026-05-19)
|
||||
// 옛 table-list 계열 schema 폐기 (Phase F.4) — canonical "table" 사용
|
||||
"v2-table-list": v2TableListOverridesSchema,
|
||||
"v2-button-primary": v2ButtonPrimaryOverridesSchema,
|
||||
"v2-text-display": v2TextDisplayOverridesSchema,
|
||||
"v2-split-panel-layout": v2SplitPanelLayoutOverridesSchema,
|
||||
@@ -533,7 +573,7 @@ const componentOverridesSchemaRegistry: Record<string, z.ZodType<Record<string,
|
||||
}).passthrough(),
|
||||
|
||||
// V2 입력/선택 폐기 (Phase D.2) — schema 미제공.
|
||||
// 옛 통합 목록 schema 폐기 (Phase F.8) — canonical table 사용.
|
||||
"v2-list": v2ListOverridesSchema,
|
||||
"v2-layout": v2LayoutOverridesSchema,
|
||||
"v2-group": v2GroupOverridesSchema,
|
||||
// v2-media 폐기 (Phase D.5) — schema 미제공.
|
||||
@@ -546,7 +586,18 @@ const componentOverridesSchemaRegistry: Record<string, z.ZodType<Record<string,
|
||||
// ============================================
|
||||
const componentDefaultsRegistry: Record<string, Record<string, any>> = {
|
||||
// V2 컴포넌트
|
||||
// 옛 table-list 계열 defaults 폐기 (Phase F.4) — canonical "table" 사용
|
||||
"v2-table-list": {
|
||||
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": {
|
||||
text: "저장",
|
||||
actionType: "button",
|
||||
@@ -618,7 +669,13 @@ const componentDefaultsRegistry: Record<string, Record<string, any>> = {
|
||||
},
|
||||
// v2-tabs-widget defaults 제거 (2026-05-19): canonical container alias (containerType=tabs)
|
||||
// V2 컴포넌트 (V2 입력/선택 폐기, Phase D.2)
|
||||
// 옛 통합 목록 defaults 폐기 (Phase F.8) — canonical table 사용.
|
||||
"v2-list": {
|
||||
viewMode: "table",
|
||||
source: "static",
|
||||
columns: [],
|
||||
pagination: true,
|
||||
sortable: true,
|
||||
},
|
||||
"v2-layout": {
|
||||
layoutType: "grid",
|
||||
columns: 2,
|
||||
|
||||
@@ -208,19 +208,12 @@ export class AutoGenerationUtils {
|
||||
return timeValue;
|
||||
|
||||
case "sequence":
|
||||
return this.generateSequence(
|
||||
columnName || "default",
|
||||
(options as any).startValue ?? options.start_value ?? 1,
|
||||
options.prefix,
|
||||
options.suffix,
|
||||
);
|
||||
return this.generateSequence(columnName || "default", options.startValue || 1, options.prefix, options.suffix);
|
||||
|
||||
case "numbering_rule":
|
||||
// 채번 규칙 ID가 있으면 API 호출
|
||||
const numberingRuleId =
|
||||
(options as any).numberingRuleId ?? options.numbering_rule_id;
|
||||
if (numberingRuleId) {
|
||||
return await this.generateNumberingRuleCode(numberingRuleId, formData);
|
||||
if (options.numberingRuleId) {
|
||||
return await this.generateNumberingRuleCode(options.numberingRuleId, formData);
|
||||
}
|
||||
console.warn("numbering_rule 타입인데 numberingRuleId가 없습니다");
|
||||
return null;
|
||||
|
||||
@@ -3263,9 +3263,9 @@ export class ButtonActionExecutor {
|
||||
if (autoDetectDataSource) {
|
||||
dataSourceId = config.dataSourceId;
|
||||
|
||||
// canonical table 또는 SplitPanelLayout에서 자동 감지
|
||||
// TableList, V2List 또는 SplitPanelLayout에서 자동 감지
|
||||
if (!dataSourceId && context.allComponents) {
|
||||
// 1. table-like 컴포넌트 찾기 (canonical table 인식)
|
||||
// 1. table-like 컴포넌트 찾기 (canonical table / legacy table-list / hidden v2-table-list 모두 인식)
|
||||
const tableLikeComponent = context.allComponents.find(
|
||||
(comp: any) => isTableLikeComponent(comp) && getTableNameFromTableLikeComponent(comp),
|
||||
);
|
||||
@@ -3273,8 +3273,22 @@ export class ButtonActionExecutor {
|
||||
if (tableLikeComponent) {
|
||||
dataSourceId = getTableNameFromTableLikeComponent(tableLikeComponent);
|
||||
} else {
|
||||
{
|
||||
// 2. split-panel-layout 컴포넌트 찾기
|
||||
// 2. v2-list 컴포넌트 찾기
|
||||
const v2ListComponent = context.allComponents.find(
|
||||
(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(
|
||||
(comp: any) =>
|
||||
comp.componentType === "split-panel-layout" && comp.componentConfig?.leftPanel?.tableName,
|
||||
@@ -3447,7 +3461,7 @@ export class ButtonActionExecutor {
|
||||
let dataSourceId = config.dataSourceId;
|
||||
|
||||
if (!dataSourceId && context.allComponents) {
|
||||
// table-like 우선 감지 (canonical table 등)
|
||||
// table-like 우선 감지 (canonical table / legacy table-list / hidden v2-table-list)
|
||||
const tableLikeComponent = context.allComponents.find(
|
||||
(comp: any) => isTableLikeComponent(comp) && getTableNameFromTableLikeComponent(comp),
|
||||
);
|
||||
@@ -5266,7 +5280,7 @@ export class ButtonActionExecutor {
|
||||
layoutData.components = JSON.parse(layoutData.components);
|
||||
}
|
||||
|
||||
// 테이블 컴포넌트 찾기 (canonical table 등 table-like 컴포넌트 인식)
|
||||
// 테이블 리스트 컴포넌트 찾기 (canonical table / legacy table-list / hidden v2-table-list 모두 인식)
|
||||
const findTableListComponent = (components: any[]): any => {
|
||||
if (!Array.isArray(components)) return null;
|
||||
|
||||
|
||||
@@ -34,10 +34,8 @@ export const isFileComponent = (component: ComponentData): boolean => {
|
||||
export const isButtonComponent = (component: ComponentData): boolean => {
|
||||
if (!component || !component.type) return false;
|
||||
|
||||
// ComponentData.type union 에는 "button" 이 명시되지 않지만 legacy 저장 layout 에서
|
||||
// type === "button" 으로 직접 박혀온 경우가 있어 보존 — cast 로만 union 우회.
|
||||
return (
|
||||
(component as any).type === "button" ||
|
||||
component.type === "button" ||
|
||||
(component.type === "widget" && (component as any).widgetType === "button") ||
|
||||
(component.type === "component" &&
|
||||
((component as any).webType === "button" || (component as any).componentType === "button"))
|
||||
@@ -197,11 +195,14 @@ export const getComponentLabel = (component: ComponentData): string => {
|
||||
* ────────────────────────────────────────────────────────────────────────
|
||||
* Canonical Table-like helpers
|
||||
*
|
||||
* INVYONE canonical data-view 정리(F phase) 이후, 화면 전반에서
|
||||
* 테이블 유형 인식은 canonical id 와 외부 코드가 쓰는 비표준 id 만 본다.
|
||||
* INVYONE canonical data-view cleanup 이후, 화면 전반에서 `table-list` 단독
|
||||
* 체크를 canonical-aware 형태로 옮긴다.
|
||||
*
|
||||
* - canonical: `table`
|
||||
* - 외부 비표준(보존): `data-table`, `datatable`
|
||||
* - 새 생성 경로: `table` (canonical)
|
||||
* - 레거시 호환: `table-list`
|
||||
* - 폐기 예정 hidden legacy: `v2-table-list` (registry/schema에 hard blocker로
|
||||
* 보존 중이므로 런타임에서 인식해야 함)
|
||||
* - 일부 외부 코드가 사용: `data-table`, `datatable`
|
||||
*
|
||||
* 단일 typeValue 비교용은 `isTableLikeComponentType`, 컴포넌트 객체용은
|
||||
* `isTableLikeComponent`, table name 추출은 `getTableNameFromTableLikeComponent`
|
||||
@@ -209,10 +210,13 @@ export const getComponentLabel = (component: ComponentData): string => {
|
||||
* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* canonical table 및 외부 비표준 별칭 집합.
|
||||
* canonical table 및 호환 alias 전체 집합.
|
||||
* v2-table-list 는 registry/schemas hard blocker 로 보존되므로 여기서도 인식한다.
|
||||
*/
|
||||
const TABLE_LIKE_COMPONENT_TYPES: ReadonlySet<string> = new Set([
|
||||
"table",
|
||||
"table-list",
|
||||
"v2-table-list",
|
||||
"data-table",
|
||||
"datatable",
|
||||
]);
|
||||
|
||||
@@ -24,7 +24,7 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
|
||||
|
||||
// ========== V2 컴포넌트 ==========
|
||||
// V2 입력/선택 폐기 (2026-05-12) — input canonical 로 흡수. alias / fallback / schema 미제공.
|
||||
// v2-list 폐기 (Phase F.8, 2026-05-21) — canonical table 로 흡수.
|
||||
"v2-list": () => import("@/components/v2/config-panels/InvDataConfigPanel"),
|
||||
// v2-media — Phase D.5 폐기. canonical input (FilePicker) 으로 흡수, ConfigPanel 미제공.
|
||||
"v2-biz": () => import("@/components/v2/config-panels/V2BizConfigPanel"),
|
||||
"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"),
|
||||
|
||||
// ========== 테이블/리스트 ==========
|
||||
// 옛 table-list / v2-table-list alias 는 Phase F.6 에서 제거됨 — canonical "table" 만 사용
|
||||
// ★ 2026-05-19 table-list / v2-table-list → CONFIG_PANEL_ALIAS["..."]="table" 로 라우팅
|
||||
"table-search-widget": () => import("@/lib/registry/components/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"),
|
||||
@@ -143,6 +143,7 @@ const CONFIG_PANEL_ALIAS: Record<string, string> = {
|
||||
"v2-table-search-widget": "search", "table-search-widget": "search",
|
||||
"v2-aggregation-widget": "stats", "aggregation-widget": "stats",
|
||||
"v2-status-count": "stats",
|
||||
"v2-table-list": "table", "table-list": "table",
|
||||
"v2-tabs-widget": "container", "tabs-widget": "container",
|
||||
"tabs": "container", "v2-tabs": "container",
|
||||
"v2-section-card": "container", "v2-section-paper": "container",
|
||||
@@ -168,7 +169,7 @@ export async function getComponentConfigPanel(componentId: string): Promise<Reac
|
||||
|
||||
// 모듈에서 ConfigPanel 컴포넌트 추출 (우선순위):
|
||||
// 1차: PascalCase 변환된 이름 (예: mail-recipient-selector -> MailRecipientSelectorConfigPanel)
|
||||
// 2차: v2- 접두사 제거 후 PascalCase (예: v2-text-display -> TextDisplayConfigPanel)
|
||||
// 2차: v2- 접두사 제거 후 PascalCase (예: v2-table-list -> TableListConfigPanel)
|
||||
// 3차: *ConfigPanel로 끝나는 첫 번째 named export
|
||||
// 4차: default export
|
||||
const pascalCaseName = `${toPascalCase(componentId)}ConfigPanel`;
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
import { ComponentData } from "@/types/screen-management";
|
||||
import { ResponsiveComponentConfig, BREAKPOINTS } from "@/types/responsive";
|
||||
import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
|
||||
|
||||
/**
|
||||
* 컴포넌트 크기에 따른 스마트 기본값 생성
|
||||
@@ -19,13 +18,13 @@ export function generateSmartDefaults(
|
||||
screenWidth: number = 1920,
|
||||
rowComponentCount: number = 1, // 같은 행에 있는 컴포넌트 개수
|
||||
): ResponsiveComponentConfig["responsive"] {
|
||||
// 특정 컴포넌트는 항상 전체 너비 (datatable, table 등).
|
||||
// Phase E.3 — 옛 table ID literal 은 제거하고 table-like helper 로 호환 유지.
|
||||
// 특정 컴포넌트는 항상 전체 너비 (datatable, table-list 등)
|
||||
// ★ 2026-05-18: canonical data-view 추가 (table / grouped-table / card-list).
|
||||
// chart 는 자연스럽게 작은 크기(예: 4컬럼)도 자주 쓰므로 제외하고 자동 추론.
|
||||
const fullWidthComponents = [
|
||||
"datatable",
|
||||
"data-table",
|
||||
"table-list",
|
||||
"repeater-field-group",
|
||||
// canonical (Phase G.3 ~)
|
||||
"table",
|
||||
@@ -35,12 +34,7 @@ export function generateSmartDefaults(
|
||||
const componentId = (component as any).componentId || (component as any).id;
|
||||
const componentType = (component as any).componentType || component.type;
|
||||
|
||||
if (
|
||||
isTableLikeComponentType(componentId) ||
|
||||
isTableLikeComponentType(componentType) ||
|
||||
fullWidthComponents.includes(componentId) ||
|
||||
fullWidthComponents.includes(componentType)
|
||||
) {
|
||||
if (fullWidthComponents.includes(componentId) || fullWidthComponents.includes(componentType)) {
|
||||
return {
|
||||
desktop: {
|
||||
grid_columns: 12, // 전체 너비
|
||||
|
||||
@@ -41,6 +41,8 @@ const LEGACY_TO_UNIFIED: Record<string, string> = {
|
||||
'v2-aggregation-widget': 'stats',
|
||||
'aggregation-widget': 'stats',
|
||||
'v2-status-count': 'stats',
|
||||
'v2-table-list': 'table',
|
||||
'table-list': 'table',
|
||||
'v2-tabs-widget': 'container',
|
||||
'tabs-widget': 'container',
|
||||
'tabs': 'container',
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
/**
|
||||
* 테이블 화면 표시 데이터 전역 저장소
|
||||
* 엑셀 다운로드 등에서 현재 화면에 표시된 데이터에 접근하기 위함
|
||||
*
|
||||
* 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 {
|
||||
@@ -33,16 +24,6 @@ class TableDisplayStore {
|
||||
private state: Map<string, TableDisplayState> = new Map();
|
||||
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 테이블명
|
||||
@@ -104,59 +85,6 @@ class TableDisplayStore {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 데이터 삭제
|
||||
*/
|
||||
|
||||
+78
-212
@@ -14,8 +14,6 @@
|
||||
--v5-red-rgb:255,71,87;
|
||||
--v5-green-rgb:0,184,148;
|
||||
--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-surface:rgba(255,255,255,0.55); --v5-surface-solid:#ffffff;
|
||||
@@ -25,7 +23,6 @@
|
||||
--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-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-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);
|
||||
@@ -112,7 +109,6 @@
|
||||
--v5-red-rgb:255,107,107;
|
||||
--v5-green-rgb:85,239,196;
|
||||
--v5-amber-rgb:255,234,167;
|
||||
--v5-accent-rgb:85,239,196;
|
||||
|
||||
--v5-bg:#0a0b0d; --v5-bg-subtle:#111215;
|
||||
--v5-surface:rgba(23,24,27,0.5); --v5-surface-solid:#17181b;
|
||||
@@ -121,7 +117,6 @@
|
||||
--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-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-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);
|
||||
@@ -144,51 +139,41 @@
|
||||
/* --- BLUE --- */
|
||||
html[data-color="blue"]{
|
||||
--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%;}
|
||||
html.dark[data-color="blue"]{
|
||||
--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%;}
|
||||
|
||||
/* --- GREEN --- */
|
||||
html[data-color="green"]{
|
||||
--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%;}
|
||||
html.dark[data-color="green"]{
|
||||
--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%;}
|
||||
|
||||
/* --- ORANGE --- */
|
||||
html[data-color="orange"]{
|
||||
--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%;}
|
||||
html.dark[data-color="orange"]{
|
||||
--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%;}
|
||||
|
||||
/* --- PINK --- */
|
||||
html[data-color="pink"]{
|
||||
--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%;}
|
||||
html.dark[data-color="pink"]{
|
||||
--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%;}
|
||||
|
||||
/* --- CYAN --- */
|
||||
html[data-color="cyan"]{
|
||||
--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%;}
|
||||
html.dark[data-color="cyan"]{
|
||||
--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%;}
|
||||
|
||||
/* --- PURPLE (기본) — 사이트 :root 토큰을 그대로 쓰지만 명시적으로 매핑해서
|
||||
@@ -250,7 +235,7 @@ html:not(.dark) .v5-hdr{
|
||||
box-shadow:0 4px 20px rgba(var(--v5-primary-rgb),0.06);}
|
||||
.v5-hdr-l{display:flex;align-items:center;gap:1rem;}
|
||||
.v5-hdr-logo{font-size:1.05rem;font-weight:900;letter-spacing:-.03em;
|
||||
background:linear-gradient(135deg,var(--v5-primary),var(--v5-accent));-webkit-background-clip:text;
|
||||
background:linear-gradient(135deg,var(--v5-primary),var(--v5-cyan));-webkit-background-clip:text;
|
||||
-webkit-text-fill-color:transparent;background-clip:text;cursor:default;}
|
||||
.v5-hdr-bc{font-size:.8125rem;color:var(--v5-text-muted);}
|
||||
.v5-hdr-bc b{color:var(--v5-text);font-weight:600;}
|
||||
@@ -402,7 +387,7 @@ html:not(.dark) .v5-hdr{
|
||||
/* Admin mode header accent */
|
||||
.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;
|
||||
background:linear-gradient(90deg,var(--v5-primary),var(--v5-accent));animation:v5-glowLine .6s ease-out both;}
|
||||
background:linear-gradient(90deg,var(--v5-primary),var(--v5-cyan));animation:v5-glowLine .6s ease-out both;}
|
||||
@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 애니메이션 가능 */
|
||||
@@ -428,25 +413,19 @@ 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)}}
|
||||
|
||||
/* ===== SOLID TABS ===== */
|
||||
.v5-tabs{height:36px;display:flex;align-items:stretch;padding:0 .5rem;gap:1px;overflow-x:auto;
|
||||
.v5-tabs{height:36px;display:flex;align-items:stretch;padding:4px .5rem 0;gap:2px;overflow-x:auto;
|
||||
background:var(--v5-surface-solid);
|
||||
border-bottom:1px solid var(--v5-border);position:relative;z-index:15;flex-shrink:0;
|
||||
scrollbar-width:none;-ms-overflow-style: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;
|
||||
color:var(--v5-text-muted);cursor:pointer;border-bottom:2px solid transparent;white-space:nowrap;transition:all .25s;
|
||||
transform-origin:bottom center;animation:v5-tabIn .24s cubic-bezier(.16,1,.3,1) both;}
|
||||
color:var(--v5-text-muted);cursor:pointer;white-space:nowrap;transition:color .15s,border-color .15s,background .15s;
|
||||
border:1px solid var(--v5-border);border-radius:8px 8px 0 0;margin-bottom:-1px;}
|
||||
.v5-tab:hover{color:var(--v5-text-sec);background:var(--v5-surface-hover);}
|
||||
.v5-tab.on{color:var(--v5-primary);font-weight:600;border-bottom-color:transparent;background:var(--v5-surface);}
|
||||
.v5-tab.closing{animation:v5-tabOut .18s cubic-bezier(.4,0,1,1) both;pointer-events:none;}
|
||||
@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.on{color:var(--v5-primary);font-weight:600;
|
||||
border-color:var(--v5-border);border-bottom-color:var(--v5-surface-hover);
|
||||
background:var(--v5-surface-hover);box-shadow:0 -1px 0 var(--v5-primary) inset;}
|
||||
.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;}
|
||||
.v5-tab:hover .v5-tab-x{opacity:1;}
|
||||
@@ -1545,22 +1524,9 @@ html.vt-color-changing .v5-admin-btn{
|
||||
=================================================================== */
|
||||
/* .v5-nrm 자체에는 background 안 깔아서 body 의 .dark radial-gradient (globals.css) 가 비치게 둠.
|
||||
sidebar 와 main 만 surface-solid 로 깔고, 헤더 영역은 투명 → 테마 컬러가 자연스럽게 헤더에 비침. */
|
||||
.v5-nrm{display:flex;flex-direction:column;height:100%;overflow:hidden;background:var(--v5-surface-solid);}
|
||||
.v5-nrm{display:flex;flex-direction:column;height:100%;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 ── */
|
||||
.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);}
|
||||
@@ -1596,7 +1562,6 @@ html.vt-color-changing .v5-admin-btn{
|
||||
.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);}}
|
||||
.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 */
|
||||
.v5-nrm-tone{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1;}
|
||||
@@ -1610,171 +1575,94 @@ html.vt-color-changing .v5-admin-btn{
|
||||
.v5-nrm-tone.muted{background:var(--v5-bg-subtle);color:var(--v5-text-muted);}
|
||||
|
||||
/* ── 우측 main (통짜) ── */
|
||||
.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;}
|
||||
.v5-nrm-main{overflow-y:auto;background:var(--v5-surface-solid);position:relative;}
|
||||
|
||||
/* keyframes — v4 EDIT 모드 마이크로 애니메이션 */
|
||||
@keyframes v5-nrm-pop{0%{opacity:0;transform:scale(.6);}60%{opacity:1;transform:scale(1.06);}100%{transform:scale(1);}}
|
||||
@keyframes v5-nrm-slide-up{from{opacity:0;transform:translateY(8px);}to{opacity:1;transform:translateY(0);}}
|
||||
@keyframes v5-nrm-slide-down{from{opacity:0;transform:translateY(-6px);}to{opacity:1;transform:translateY(0);}}
|
||||
@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);}}
|
||||
|
||||
/* DETAIL HEAD (제목 + chip + meta + 우측 액션) */
|
||||
.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-detail-head .l{display:flex;flex-direction:column;gap:4px;min-width:0;flex:1;}
|
||||
.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-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-detail-head .meta b{color:var(--v5-text-sec);font-weight:600;}
|
||||
.v5-nrm-detail-head .r{display:flex;gap:.25rem;}
|
||||
|
||||
/* PATTERN-VIS (현재/다음 발번 시각화) */
|
||||
.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-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-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-pattern-line{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
|
||||
.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-pattern-piece:nth-child(2){animation-delay:.15s;}
|
||||
.v5-nrm-pattern-piece:nth-child(4){animation-delay:.25s;}
|
||||
.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;}
|
||||
/* HERO (통짜, 카드 X) */
|
||||
.v5-nrm-hero{padding:1rem 1.5rem 1.1rem;border-bottom:1px solid var(--v5-border);position:relative;}
|
||||
.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)));}
|
||||
.v5-nrm-hero-top{display:flex;align-items:center;gap:.9rem;margin-bottom:.85rem;}
|
||||
.v5-nrm-hero-top .v5-nrm-tone{width:40px;height:40px;border-radius:10px;}
|
||||
.v5-nrm-hero-top .v5-nrm-tone svg{width:18px;height:18px;}
|
||||
.v5-nrm-hero-info{flex:1;min-width:0;}
|
||||
.v5-nrm-hero-row1{display:flex;align-items:center;gap:6px;margin-bottom:3px;flex-wrap:wrap;min-width:0;}
|
||||
.v5-nrm-hero-info{min-width:0;}
|
||||
.v5-nrm-hero-info h2{margin:0;font-size:1.2rem;font-weight:800;letter-spacing:-.02em;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-hero-meta b{color:var(--v5-text-sec);font-weight:600;}
|
||||
.v5-nrm-hero-actions{display:flex;gap:.3rem;align-items:center;}
|
||||
.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;}
|
||||
.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-hero-code-block.right{text-align:right;}
|
||||
.v5-nrm-hero-code-block .code .pfx{color:rgb(var(--v5-cyan-rgb));}
|
||||
.v5-nrm-hero-code-block .code .seq{color:rgb(var(--v5-pink-rgb));}
|
||||
.v5-nrm-hero-code-block .code .sep{color:var(--v5-text-muted);opacity:.55;margin:0 .08em;font-weight:400;}
|
||||
.v5-nrm-hero-code-block .sub{margin-top:6px;font-family:'JetBrains Mono',monospace;font-size:.58rem;color:var(--v5-text-muted);}
|
||||
.v5-nrm-hero-arrow{color:var(--v5-primary);align-self:center;margin-bottom:4px;}
|
||||
.v5-nrm-hero-arrow svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;}
|
||||
|
||||
/* row + 2-col split */
|
||||
.v5-nrm-row{padding:1rem 1.5rem 1.1rem;border-bottom:1px solid var(--v5-border);background:var(--v5-surface-solid);}
|
||||
.v5-nrm-row{padding:1rem 1.5rem 1.1rem;border-bottom:1px solid var(--v5-border);}
|
||||
.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 .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 .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 > div{padding:1rem 1.5rem 1.1rem;background:var(--v5-surface-solid);}
|
||||
.v5-nrm-row-split > div{padding:1rem 1.5rem 1.1rem;}
|
||||
.v5-nrm-row-split > div + div{border-left:1px solid var(--v5-border);}
|
||||
|
||||
/* pipeline editor — v4 시안: pipe-slot + pipe-block + add-here + drop(end) */
|
||||
.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;}
|
||||
|
||||
/* pipe-slot — 블록 사이 separator + hover 시 + 등장 */
|
||||
.v5-nrm-slot{display:inline-flex;align-items:center;width:18px;position:relative;align-self:stretch;}
|
||||
.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-slot:hover .sep{color:var(--v5-primary);transform:scale(.9);}
|
||||
.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-slot .add-here svg{width:9px;height:9px;stroke:currentColor;fill:none;stroke-width:2.5;}
|
||||
.v5-nrm-slot:hover .add-here{opacity:1;transform:translate(-50%,-50%) scale(1);}
|
||||
.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);}
|
||||
|
||||
/* 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 만) */
|
||||
/* pipeline editor */
|
||||
.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-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);}
|
||||
.v5-nrm-block.sel{border-color:var(--v5-primary);box-shadow:0 0 0 3px rgba(var(--v5-primary-rgb),.14);}
|
||||
.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-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-block:hover .x{display:flex;}
|
||||
.v5-nrm-block .x:hover{color:rgb(var(--v5-red-rgb));border-color:rgb(var(--v5-red-rgb));}
|
||||
.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-block .val{font-family:'JetBrains Mono',monospace;font-size:.9rem;font-weight:800;color:var(--v5-text);}
|
||||
.v5-nrm-block.text{border-color:rgba(var(--v5-cyan-rgb),.4);}
|
||||
.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.sequence{border-color:rgba(var(--v5-pink-rgb),.4);}
|
||||
.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.category{border-color:rgba(var(--v5-primary-rgb),.35);}
|
||||
.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));}
|
||||
|
||||
/* 옛 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-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;}
|
||||
|
||||
/* end-of-pipe 추가 버튼 (v4 의 pipe-add-end) */
|
||||
.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-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-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-drop:hover svg{transform:rotate(90deg);}
|
||||
/* inspector */
|
||||
.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-insp-hd{display:flex;align-items:center;justify-content:space-between;margin-bottom:.55rem;}
|
||||
.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-insp-hd .l .pin{background:var(--v5-primary);color:#fff;padding:1px 6px;border-radius:4px;font-size:.54rem;font-weight:800;}
|
||||
.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;}
|
||||
|
||||
/* inspector — v4 디자인 (위쪽 화살표 + fadeSlideDown + glow ring) */
|
||||
.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-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-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-insp-hd .l{display:flex;align-items:center;gap:8px;font-size:.72rem;color:var(--v5-text);}
|
||||
.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);}
|
||||
/* palette */
|
||||
.v5-nrm-palette{display:flex;gap:5px;align-items:center;flex-wrap:wrap;}
|
||||
.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-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-palette-item:hover{border-color:var(--v5-primary);color:var(--v5-primary);background:var(--v5-surface-hover);}
|
||||
.v5-nrm-palette-item svg{width:11px;height:11px;stroke:currentColor;fill:none;stroke-width:2;}
|
||||
|
||||
/* usage list */
|
||||
.v5-nrm-usage-list{display:flex;flex-direction:column;}
|
||||
@@ -1800,8 +1688,6 @@ 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: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-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 */
|
||||
.v5-nrm-seq-stats{display:grid;grid-template-columns:1fr 1fr;gap:.85rem 1.4rem;margin-bottom:.85rem;}
|
||||
@@ -1914,23 +1800,3 @@ 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 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);}
|
||||
|
||||
@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;}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 테이블 데이터 변경 이벤트
|
||||
* 발행: canonical table 등 데이터 테이블 컴포넌트
|
||||
* 테이블 리스트 데이터 변경 이벤트
|
||||
* 발행: v2-table-list
|
||||
* 구독: stats, v2-repeat-container
|
||||
*/
|
||||
export interface TableListDataChangeDetail {
|
||||
@@ -69,7 +69,7 @@ export interface RepeaterSaveDetail {
|
||||
/**
|
||||
* 테이블 새로고침 이벤트
|
||||
* 발행: v2-button-primary, buttonActions
|
||||
* 구독: canonical table, v2-split-panel-layout
|
||||
* 구독: v2-table-list, v2-split-panel-layout
|
||||
*/
|
||||
export interface RefreshTableDetail {
|
||||
table_name?: string;
|
||||
@@ -113,7 +113,7 @@ export interface SplitPanelDataTransferDetail {
|
||||
/**
|
||||
* 연관 데이터 버튼 선택 이벤트
|
||||
* 발행: related-data-buttons
|
||||
* 구독: canonical table
|
||||
* 구독: v2-table-list
|
||||
*/
|
||||
export interface RelatedButtonSelectDetail {
|
||||
target_table: string;
|
||||
|
||||
@@ -825,8 +825,7 @@ export interface TemplateComponent {
|
||||
|
||||
/**
|
||||
* 컴포넌트 종류 — ComponentRegistry 의 ID 참조.
|
||||
* canonical 예: 'table', 'container', 'stats', 'button', 'input', 'search'
|
||||
* legacy 예 (alias 라우팅으로 호환): 'v2-button-primary', 'v2-bom-tree'
|
||||
* 예: 'v2-table-list', 'v2-button-primary', 'v2-bom-tree'
|
||||
*/
|
||||
componentId: string;
|
||||
|
||||
|
||||
@@ -220,7 +220,7 @@ export interface ComponentComponent extends BaseComponent {
|
||||
*/
|
||||
export interface TabInlineComponent {
|
||||
id: string;
|
||||
component_type: string; // 컴포넌트 타입 (canonical 예: "table" / "container" / "stats" / "button" — 일부 legacy id 도 alias 라우팅으로 호환)
|
||||
component_type: string; // 컴포넌트 타입 (예: "v2-text-display", "v2-table-list")
|
||||
label?: string;
|
||||
position: Position; // 탭 내부에서의 위치
|
||||
size: Size; // 컴포넌트 크기
|
||||
|
||||
@@ -50,7 +50,7 @@ export interface TableColumn {
|
||||
* 테이블 등록 정보
|
||||
*/
|
||||
export interface TableRegistration {
|
||||
table_id: string; // 고유 ID (예: "table-123")
|
||||
table_id: string; // 고유 ID (예: "table-list-123")
|
||||
label: string; // 사용자에게 보이는 이름 (예: "품목 관리")
|
||||
table_name: string; // 실제 DB 테이블명 (예: "item_info")
|
||||
columns: TableColumn[];
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* V2 컴포넌트 타입 정의 (잔존)
|
||||
* V2 컴포넌트 타입 정의 (잔존 7종)
|
||||
*
|
||||
* - V2Text
|
||||
|
||||
|
||||
* - V2List
|
||||
* - V2Layout
|
||||
* - V2Group
|
||||
* - V2Biz
|
||||
@@ -10,7 +13,6 @@
|
||||
* 옛 입력/선택 두 종은 Phase D.3 (2026-05-12) 에서 폐기 — canonical `input`
|
||||
* (InputComponent + InvFieldConfigPanel) 로 흡수됨.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@@ -23,6 +25,7 @@ import { Position, Size, CommonStyle, ValidationRule } from "./v2-core";
|
||||
*/
|
||||
export type V2ComponentType =
|
||||
| "V2Text"
|
||||
| "V2List"
|
||||
| "V2Layout"
|
||||
| "V2Group"
|
||||
| "V2Biz"
|
||||
@@ -119,7 +122,59 @@ export interface V2TextProps extends V2BaseProps {
|
||||
}
|
||||
|
||||
// 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 =====
|
||||
|
||||
@@ -236,6 +291,7 @@ export interface V2HierarchyProps extends V2BaseProps {
|
||||
|
||||
export type V2ComponentProps =
|
||||
| V2TextProps
|
||||
| V2ListProps
|
||||
| V2LayoutProps
|
||||
| V2GroupProps
|
||||
| V2BizProps
|
||||
@@ -248,7 +304,10 @@ export function isV2Text(props: V2ComponentProps): props is V2TextProps {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return props.v2Type === "V2Layout";
|
||||
@@ -294,8 +353,12 @@ export const LEGACY_TO_V2_MAP: Record<string, V2ComponentType> = {
|
||||
|
||||
// Media 계열 — Phase D.5 에서 canonical input 으로 흡수, 매핑 제거.
|
||||
|
||||
// List 계열 — Phase F.8 (2026-05-21) 에서 매핑 제거. canonical table 사용.
|
||||
// (table-search-widget / modal-repeater-table / repeater-field-group / card-display 매핑 폐기)
|
||||
// List 계열
|
||||
// ★ 2026-05-19 table-list 는 canonical table alias 로 라우팅 — 매핑 제거
|
||||
"table-search-widget": "V2List",
|
||||
"modal-repeater-table": "V2List",
|
||||
"repeater-field-group": "V2List",
|
||||
"card-display": "V2List",
|
||||
|
||||
// Layout 계열
|
||||
// ★ split-panel-layout 보존 (master-detail UX 다름)
|
||||
|
||||
@@ -459,547 +459,3 @@ M notes/gbpark/2026-05-19-canonical-data-view-cleanup-followup.md (본 §10)
|
||||
컴포넌트군 정착 후 `TABLE_LIKE_COMPONENT_TYPES` 본체로 흡수 검토
|
||||
- `repeat-container` 의 `dataSourceType = "table-list"` enum naming — 도메인 분리 위해
|
||||
`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 추가)
|
||||
```
|
||||
|
||||
@@ -1,372 +0,0 @@
|
||||
# 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 프리미티브 표준
|
||||
@@ -1,73 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,641 +0,0 @@
|
||||
: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; }
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,230 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,182 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,120 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,636 +0,0 @@
|
||||
: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; }
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,260 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,232 +0,0 @@
|
||||
<!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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user