Compare commits
1 Commits
6ed2faee2d
...
Dockerizin
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d19d8283e |
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
npm-debug.log
|
||||||
|
uploads
|
||||||
|
*.xlsx
|
||||||
|
*.log
|
||||||
6
.env
@@ -1,6 +0,0 @@
|
|||||||
DB_HOST=172.16.8.151
|
|
||||||
DB_PORT=3306
|
|
||||||
DB_USER=itam_admin
|
|
||||||
DB_PASS=itam1234
|
|
||||||
DB_NAME=itam
|
|
||||||
PORT=3000
|
|
||||||
736
DESIGN-vercel.md
@@ -1,736 +0,0 @@
|
|||||||
---
|
|
||||||
version: alpha
|
|
||||||
name: Vercel-design-analysis
|
|
||||||
description: An inspired interpretation of Vercel's design language — a developer-platform brand whose surface is a stark black-and-ink duet on near-white canvas, broken at hero scale by a multi-color mesh gradient (cyan / blue / magenta / amber) that acts as the entire decorative system, paired with a custom geometric sans for headlines and a monospaced caption face for technical labels.
|
|
||||||
|
|
||||||
colors:
|
|
||||||
primary: "#171717"
|
|
||||||
on-primary: "#ffffff"
|
|
||||||
ink: "#171717"
|
|
||||||
body: "#4d4d4d"
|
|
||||||
mute: "#888888"
|
|
||||||
hairline: "#ebebeb"
|
|
||||||
hairline-strong: "#a1a1a1"
|
|
||||||
canvas: "#ffffff"
|
|
||||||
canvas-soft: "#fafafa"
|
|
||||||
canvas-soft-2: "#f5f5f5"
|
|
||||||
link: "#0070f3"
|
|
||||||
link-deep: "#0761d1"
|
|
||||||
link-bg-soft: "#d3e5ff"
|
|
||||||
success: "#0070f3"
|
|
||||||
error: "#ee0000"
|
|
||||||
error-soft: "#f7d4d6"
|
|
||||||
error-deep: "#c50000"
|
|
||||||
warning: "#f5a623"
|
|
||||||
warning-soft: "#ffefcf"
|
|
||||||
warning-deep: "#ab570a"
|
|
||||||
violet: "#7928ca"
|
|
||||||
violet-soft: "#d8ccf1"
|
|
||||||
violet-deep: "#4c2889"
|
|
||||||
cyan: "#50e3c2"
|
|
||||||
cyan-soft: "#aaffec"
|
|
||||||
cyan-deep: "#29bc9b"
|
|
||||||
highlight-pink: "#ff0080"
|
|
||||||
highlight-magenta: "#eb367f"
|
|
||||||
gradient-develop-start: "#007cf0"
|
|
||||||
gradient-develop-end: "#00dfd8"
|
|
||||||
gradient-preview-start: "#7928ca"
|
|
||||||
gradient-preview-end: "#ff0080"
|
|
||||||
gradient-ship-start: "#ff4d4d"
|
|
||||||
gradient-ship-end: "#f9cb28"
|
|
||||||
selection-bg: "#171717"
|
|
||||||
selection-fg: "#f2f2f2"
|
|
||||||
|
|
||||||
typography:
|
|
||||||
display-xl:
|
|
||||||
fontFamily: Geist, Inter, system-ui, -apple-system, sans-serif
|
|
||||||
fontSize: 48px
|
|
||||||
fontWeight: 600
|
|
||||||
lineHeight: 48px
|
|
||||||
letterSpacing: -2.4px
|
|
||||||
display-lg:
|
|
||||||
fontFamily: Geist, Inter, system-ui, -apple-system, sans-serif
|
|
||||||
fontSize: 32px
|
|
||||||
fontWeight: 600
|
|
||||||
lineHeight: 40px
|
|
||||||
letterSpacing: -1.28px
|
|
||||||
display-md:
|
|
||||||
fontFamily: Geist, Inter, system-ui, -apple-system, sans-serif
|
|
||||||
fontSize: 24px
|
|
||||||
fontWeight: 600
|
|
||||||
lineHeight: 32px
|
|
||||||
letterSpacing: -0.96px
|
|
||||||
display-sm:
|
|
||||||
fontFamily: Geist, Inter, system-ui, -apple-system, sans-serif
|
|
||||||
fontSize: 20px
|
|
||||||
fontWeight: 600
|
|
||||||
lineHeight: 28px
|
|
||||||
letterSpacing: -0.6px
|
|
||||||
body-lg:
|
|
||||||
fontFamily: Geist, Inter, system-ui, -apple-system, sans-serif
|
|
||||||
fontSize: 18px
|
|
||||||
fontWeight: 400
|
|
||||||
lineHeight: 28px
|
|
||||||
letterSpacing: 0px
|
|
||||||
body-md:
|
|
||||||
fontFamily: Geist, Inter, system-ui, -apple-system, sans-serif
|
|
||||||
fontSize: 16px
|
|
||||||
fontWeight: 400
|
|
||||||
lineHeight: 24px
|
|
||||||
body-md-strong:
|
|
||||||
fontFamily: Geist, Inter, system-ui, -apple-system, sans-serif
|
|
||||||
fontSize: 16px
|
|
||||||
fontWeight: 500
|
|
||||||
lineHeight: 24px
|
|
||||||
body-sm:
|
|
||||||
fontFamily: Geist, Inter, system-ui, -apple-system, sans-serif
|
|
||||||
fontSize: 14px
|
|
||||||
fontWeight: 400
|
|
||||||
lineHeight: 20px
|
|
||||||
letterSpacing: -0.28px
|
|
||||||
body-sm-strong:
|
|
||||||
fontFamily: Geist, Inter, system-ui, -apple-system, sans-serif
|
|
||||||
fontSize: 14px
|
|
||||||
fontWeight: 500
|
|
||||||
lineHeight: 20px
|
|
||||||
letterSpacing: -0.28px
|
|
||||||
caption:
|
|
||||||
fontFamily: Geist, Inter, system-ui, -apple-system, sans-serif
|
|
||||||
fontSize: 12px
|
|
||||||
fontWeight: 400
|
|
||||||
lineHeight: 16px
|
|
||||||
caption-mono:
|
|
||||||
fontFamily: Geist Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, monospace
|
|
||||||
fontSize: 12px
|
|
||||||
fontWeight: 400
|
|
||||||
lineHeight: 16px
|
|
||||||
code:
|
|
||||||
fontFamily: Geist Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, monospace
|
|
||||||
fontSize: 13px
|
|
||||||
fontWeight: 400
|
|
||||||
lineHeight: 20px
|
|
||||||
button-md:
|
|
||||||
fontFamily: Geist, Inter, system-ui, -apple-system, sans-serif
|
|
||||||
fontSize: 14px
|
|
||||||
fontWeight: 500
|
|
||||||
lineHeight: 20px
|
|
||||||
button-lg:
|
|
||||||
fontFamily: Geist, Inter, system-ui, -apple-system, sans-serif
|
|
||||||
fontSize: 16px
|
|
||||||
fontWeight: 500
|
|
||||||
lineHeight: 24px
|
|
||||||
|
|
||||||
rounded:
|
|
||||||
none: 0px
|
|
||||||
xs: 4px
|
|
||||||
sm: 6px
|
|
||||||
md: 8px
|
|
||||||
lg: 12px
|
|
||||||
xl: 16px
|
|
||||||
pill-sm: 64px
|
|
||||||
pill: 100px
|
|
||||||
full: 9999px
|
|
||||||
|
|
||||||
spacing:
|
|
||||||
xxs: 4px
|
|
||||||
xs: 8px
|
|
||||||
sm: 12px
|
|
||||||
md: 16px
|
|
||||||
lg: 24px
|
|
||||||
xl: 32px
|
|
||||||
2xl: 40px
|
|
||||||
3xl: 48px
|
|
||||||
4xl: 64px
|
|
||||||
5xl: 96px
|
|
||||||
6xl: 128px
|
|
||||||
section: 192px
|
|
||||||
|
|
||||||
components:
|
|
||||||
nav-bar:
|
|
||||||
backgroundColor: "{colors.canvas}"
|
|
||||||
textColor: "{colors.ink}"
|
|
||||||
typography: "{typography.body-sm}"
|
|
||||||
height: 64px
|
|
||||||
padding: "{spacing.sm} {spacing.lg}"
|
|
||||||
nav-link:
|
|
||||||
textColor: "{colors.body}"
|
|
||||||
typography: "{typography.body-sm}"
|
|
||||||
rounded: "{rounded.full}"
|
|
||||||
padding: "{spacing.xs} {spacing.sm}"
|
|
||||||
nav-cta-signup:
|
|
||||||
backgroundColor: "{colors.primary}"
|
|
||||||
textColor: "{colors.on-primary}"
|
|
||||||
typography: "{typography.body-sm-strong}"
|
|
||||||
rounded: "{rounded.sm}"
|
|
||||||
padding: "0px {spacing.xs}"
|
|
||||||
height: 28px
|
|
||||||
nav-cta-login:
|
|
||||||
backgroundColor: "{colors.canvas}"
|
|
||||||
textColor: "{colors.ink}"
|
|
||||||
typography: "{typography.body-sm-strong}"
|
|
||||||
rounded: "{rounded.sm}"
|
|
||||||
padding: "0px {spacing.xs}"
|
|
||||||
height: 28px
|
|
||||||
nav-cta-ask-ai:
|
|
||||||
backgroundColor: "{colors.canvas}"
|
|
||||||
textColor: "{colors.ink}"
|
|
||||||
borderColor: "{colors.hairline}"
|
|
||||||
typography: "{typography.body-sm-strong}"
|
|
||||||
rounded: "{rounded.sm}"
|
|
||||||
padding: "0px {spacing.xs}"
|
|
||||||
height: 28px
|
|
||||||
button-primary:
|
|
||||||
backgroundColor: "{colors.primary}"
|
|
||||||
textColor: "{colors.on-primary}"
|
|
||||||
typography: "{typography.button-lg}"
|
|
||||||
rounded: "{rounded.pill}"
|
|
||||||
padding: "0px {spacing.sm}"
|
|
||||||
button-secondary:
|
|
||||||
backgroundColor: "{colors.canvas}"
|
|
||||||
textColor: "{colors.ink}"
|
|
||||||
typography: "{typography.button-lg}"
|
|
||||||
rounded: "{rounded.pill}"
|
|
||||||
padding: "0px {spacing.sm}"
|
|
||||||
button-primary-sm:
|
|
||||||
backgroundColor: "{colors.primary}"
|
|
||||||
textColor: "{colors.on-primary}"
|
|
||||||
typography: "{typography.button-md}"
|
|
||||||
rounded: "{rounded.pill}"
|
|
||||||
padding: "0px {spacing.xs}"
|
|
||||||
button-secondary-sm:
|
|
||||||
backgroundColor: "{colors.canvas}"
|
|
||||||
textColor: "{colors.ink}"
|
|
||||||
typography: "{typography.button-md}"
|
|
||||||
rounded: "{rounded.pill}"
|
|
||||||
padding: "0px {spacing.xs}"
|
|
||||||
tab-ghost:
|
|
||||||
backgroundColor: "{colors.canvas}"
|
|
||||||
textColor: "{colors.ink}"
|
|
||||||
typography: "{typography.body-sm}"
|
|
||||||
rounded: "{rounded.pill-sm}"
|
|
||||||
padding: "0px {spacing.md}"
|
|
||||||
icon-button-circular:
|
|
||||||
backgroundColor: "{colors.canvas}"
|
|
||||||
textColor: "{colors.ink}"
|
|
||||||
borderColor: "{colors.hairline}"
|
|
||||||
rounded: "{rounded.full}"
|
|
||||||
card-marketing:
|
|
||||||
backgroundColor: "{colors.canvas}"
|
|
||||||
textColor: "{colors.ink}"
|
|
||||||
typography: "{typography.body-md}"
|
|
||||||
rounded: "{rounded.md}"
|
|
||||||
padding: "{spacing.lg}"
|
|
||||||
card-marketing-large:
|
|
||||||
backgroundColor: "{colors.canvas}"
|
|
||||||
textColor: "{colors.ink}"
|
|
||||||
typography: "{typography.body-md}"
|
|
||||||
rounded: "{rounded.lg}"
|
|
||||||
padding: "{spacing.xl}"
|
|
||||||
card-soft:
|
|
||||||
backgroundColor: "{colors.canvas-soft}"
|
|
||||||
textColor: "{colors.ink}"
|
|
||||||
typography: "{typography.body-md}"
|
|
||||||
rounded: "{rounded.md}"
|
|
||||||
padding: "{spacing.lg}"
|
|
||||||
template-card:
|
|
||||||
backgroundColor: "{colors.canvas}"
|
|
||||||
textColor: "{colors.ink}"
|
|
||||||
typography: "{typography.body-md}"
|
|
||||||
rounded: "{rounded.md}"
|
|
||||||
padding: "{spacing.md}"
|
|
||||||
code-editor-mockup:
|
|
||||||
backgroundColor: "{colors.primary}"
|
|
||||||
textColor: "{colors.on-primary}"
|
|
||||||
typography: "{typography.code}"
|
|
||||||
rounded: "{rounded.md}"
|
|
||||||
padding: "{spacing.lg}"
|
|
||||||
form-input:
|
|
||||||
backgroundColor: "{colors.canvas}"
|
|
||||||
textColor: "{colors.ink}"
|
|
||||||
borderColor: "{colors.hairline}"
|
|
||||||
typography: "{typography.body-sm}"
|
|
||||||
rounded: "{rounded.sm}"
|
|
||||||
padding: "0px {spacing.sm}"
|
|
||||||
height: 40px
|
|
||||||
form-input-sm:
|
|
||||||
backgroundColor: "{colors.canvas}"
|
|
||||||
textColor: "{colors.ink}"
|
|
||||||
borderColor: "{colors.hairline}"
|
|
||||||
typography: "{typography.body-sm}"
|
|
||||||
rounded: "{rounded.sm}"
|
|
||||||
padding: "0px {spacing.sm}"
|
|
||||||
height: 32px
|
|
||||||
form-input-lg:
|
|
||||||
backgroundColor: "{colors.canvas}"
|
|
||||||
textColor: "{colors.ink}"
|
|
||||||
borderColor: "{colors.hairline}"
|
|
||||||
typography: "{typography.body-md}"
|
|
||||||
rounded: "{rounded.sm}"
|
|
||||||
padding: "0px {spacing.sm}"
|
|
||||||
height: 48px
|
|
||||||
badge-secondary:
|
|
||||||
backgroundColor: "{colors.canvas-soft}"
|
|
||||||
textColor: "{colors.body}"
|
|
||||||
typography: "{typography.caption}"
|
|
||||||
rounded: "{rounded.full}"
|
|
||||||
padding: "0px {spacing.xs}"
|
|
||||||
pricing-card:
|
|
||||||
backgroundColor: "{colors.canvas}"
|
|
||||||
textColor: "{colors.ink}"
|
|
||||||
typography: "{typography.body-md}"
|
|
||||||
rounded: "{rounded.lg}"
|
|
||||||
padding: "{spacing.xl}"
|
|
||||||
pricing-card-featured:
|
|
||||||
backgroundColor: "{colors.primary}"
|
|
||||||
textColor: "{colors.on-primary}"
|
|
||||||
typography: "{typography.body-md}"
|
|
||||||
rounded: "{rounded.lg}"
|
|
||||||
padding: "{spacing.xl}"
|
|
||||||
logo-strip:
|
|
||||||
backgroundColor: "{colors.canvas}"
|
|
||||||
textColor: "{colors.body}"
|
|
||||||
typography: "{typography.body-sm}"
|
|
||||||
padding: "{spacing.lg} {spacing.xl}"
|
|
||||||
hero-band:
|
|
||||||
backgroundColor: "{colors.canvas}"
|
|
||||||
textColor: "{colors.ink}"
|
|
||||||
typography: "{typography.display-xl}"
|
|
||||||
padding: "{spacing.4xl} {spacing.lg}"
|
|
||||||
feature-mesh-band:
|
|
||||||
backgroundColor: "{colors.canvas}"
|
|
||||||
textColor: "{colors.ink}"
|
|
||||||
typography: "{typography.display-lg}"
|
|
||||||
padding: "{spacing.5xl} {spacing.lg}"
|
|
||||||
showcase-band-light:
|
|
||||||
backgroundColor: "{colors.canvas-soft}"
|
|
||||||
textColor: "{colors.ink}"
|
|
||||||
typography: "{typography.display-lg}"
|
|
||||||
padding: "{spacing.5xl} {spacing.lg}"
|
|
||||||
showcase-band-dark:
|
|
||||||
backgroundColor: "{colors.primary}"
|
|
||||||
textColor: "{colors.on-primary}"
|
|
||||||
typography: "{typography.display-lg}"
|
|
||||||
padding: "{spacing.5xl} {spacing.lg}"
|
|
||||||
footer:
|
|
||||||
backgroundColor: "{colors.canvas}"
|
|
||||||
textColor: "{colors.body}"
|
|
||||||
typography: "{typography.body-sm}"
|
|
||||||
padding: "{spacing.4xl} {spacing.lg}"
|
|
||||||
link-inline:
|
|
||||||
textColor: "{colors.link}"
|
|
||||||
typography: "{typography.body-md}"
|
|
||||||
banner-marketing:
|
|
||||||
backgroundColor: "{colors.canvas-soft}"
|
|
||||||
textColor: "{colors.body}"
|
|
||||||
typography: "{typography.body-sm}"
|
|
||||||
rounded: "{rounded.full}"
|
|
||||||
padding: "{spacing.xs} {spacing.sm}"
|
|
||||||
|
|
||||||
# ─── Examples (illustrative) — auto-derived; resolve any TO_FILL markers below ───
|
|
||||||
ex-pricing-tier:
|
|
||||||
description: "Default tier card. Mirrors pricing-card chrome on canvas-soft surface with a hairline border."
|
|
||||||
backgroundColor: "{colors.canvas-soft}"
|
|
||||||
textColor: "{colors.ink}"
|
|
||||||
borderColor: "{colors.hairline}"
|
|
||||||
rounded: "{rounded.lg}"
|
|
||||||
padding: "{spacing.xl}"
|
|
||||||
ex-pricing-tier-featured:
|
|
||||||
description: "Featured tier — polarity-flipped to ink primary with white text and white CTA."
|
|
||||||
backgroundColor: "{colors.ink}"
|
|
||||||
textColor: "{colors.on-primary}"
|
|
||||||
rounded: "{rounded.lg}"
|
|
||||||
padding: "{spacing.xl}"
|
|
||||||
ex-product-selector:
|
|
||||||
description: "What's Included summary card — repurposed for the brand's GPU / inference / Pro feature tiers."
|
|
||||||
backgroundColor: "{colors.canvas-soft}"
|
|
||||||
rounded: "{rounded.md}"
|
|
||||||
padding: "{spacing.lg}"
|
|
||||||
ex-cart-drawer:
|
|
||||||
description: "Subscription summary — line items per add-on (NOT a literal e-commerce cart)."
|
|
||||||
backgroundColor: "{colors.canvas}"
|
|
||||||
rounded: "{rounded.md}"
|
|
||||||
padding: "{spacing.lg}"
|
|
||||||
item-divider: "{colors.hairline}"
|
|
||||||
ex-app-shell-row:
|
|
||||||
description: "Sidebar nav row. Active state uses brand primary as a left-edge indicator bar."
|
|
||||||
backgroundColor: "{colors.canvas}"
|
|
||||||
activeIndicator: "{colors.primary}"
|
|
||||||
rounded: "{rounded.sm}"
|
|
||||||
padding: "{spacing.xs} {spacing.sm}"
|
|
||||||
ex-data-table-cell:
|
|
||||||
description: "Mirrors the brand's table chrome. Header uses caption-mono uppercase mono; body uses body-sm."
|
|
||||||
headerBackground: "{colors.canvas-soft}"
|
|
||||||
headerTypography: "{typography.caption-mono}"
|
|
||||||
bodyTypography: "{typography.body-sm}"
|
|
||||||
cellPadding: "{spacing.xs} {spacing.sm}"
|
|
||||||
rowBorder: "{colors.hairline}"
|
|
||||||
ex-auth-form-card:
|
|
||||||
description: "Sign-in / sign-up card. Mirrors card-marketing-large chrome with form-input primitives inside."
|
|
||||||
backgroundColor: "{colors.canvas-soft}"
|
|
||||||
rounded: "{rounded.lg}"
|
|
||||||
padding: "{spacing.xl}"
|
|
||||||
ex-modal-card:
|
|
||||||
description: "Modal dialog surface — same chrome as card-marketing-large with Level 5 modal shadow."
|
|
||||||
backgroundColor: "{colors.canvas}"
|
|
||||||
rounded: "{rounded.lg}"
|
|
||||||
padding: "{spacing.xl}"
|
|
||||||
ex-empty-state-card:
|
|
||||||
description: "Empty-state illustration frame. Generous padding on canvas-soft."
|
|
||||||
backgroundColor: "{colors.canvas-soft}"
|
|
||||||
rounded: "{rounded.lg}"
|
|
||||||
padding: "{spacing.3xl}"
|
|
||||||
captionTypography: "{typography.body-md}"
|
|
||||||
ex-toast:
|
|
||||||
description: "Toast notification surface — flat-cornered card-marketing chrome with Level 4 shadow."
|
|
||||||
backgroundColor: "{colors.canvas}"
|
|
||||||
rounded: "{rounded.md}"
|
|
||||||
padding: "{spacing.sm} {spacing.md}"
|
|
||||||
typography: "{typography.body-sm}"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Vercel is a developer-platform brand — the page is a deployment dashboard's marketing surface, written for engineers who already know the syntax. It earns that posture with one of the cleanest stark systems on the web: near-white `{colors.canvas-soft}` body background, ink-near-black `{colors.ink}` text, a 200-step gray scale that gives every divider, border, and disabled state its own deliberate step. The only place the brand introduces colour at marketing scale is the multi-stop mesh gradient (`{colors.gradient-develop-start}` → `{colors.gradient-preview-end}` → `{colors.gradient-ship-start}` → cyan / magenta / amber) that floats in atmospheric backdrops, never miniaturised to a swatch. That gradient is the entire decoration system.
|
|
||||||
|
|
||||||
Type is the second decisive voice. The brand's own custom geometric sans (Geist) carries display, body, button — everything narrative — at weight 600 for display, 500 for buttons, 400 for body. A matching monospaced face (Geist Mono) carries technical labels: terminal mockups, code blocks, sometimes filename captions. Headlines are sentence-case with aggressive negative letter-spacing (`-2.4px` at 48 px hero) — the brand never letter-spaces positively, never goes uppercase outside of mono labels.
|
|
||||||
|
|
||||||
Surfaces use a four-step ladder: `{colors.canvas}` (pure white for cards), `{colors.canvas-soft}` 98% (the page body), `{colors.canvas-soft-2}` 95% (occasional inset region), `{colors.primary}` (the deep ink-near-black used as the polarity-flipped band when a section needs the dark mode treatment). Shadows are exceptionally subtle — every elevated card carries a stacked shadow built from `0px 1px 1px #00000005` + `0px 2px 2px #0000000a` + an inset border. Cards never float on heavy drop-shadow; they sit on the page held by hairline + soft glow.
|
|
||||||
|
|
||||||
**Key Characteristics:**
|
|
||||||
- A single black-ink primary CTA `{colors.primary}` carries every conversion target, paired with white-on-white `button-secondary` for the secondary action. The brand uses 100 px pill shape for marketing CTAs and a tight 6 px square shape for in-app nav buttons.
|
|
||||||
- A multi-stop mesh gradient (cyan-blue-magenta-amber) is the only decorative chrome — used at hero scale and inside feature-band atmospheric backdrops. It is the brand.
|
|
||||||
- Every section eyebrow and small label uses the monospace face `{typography.caption-mono}` or `{typography.code}`; everything else is in the geometric sans.
|
|
||||||
- Subtle stacked-shadow elevation — three offsets layered with 4-12 % black opacity — never a single heavy drop-shadow.
|
|
||||||
- A complete 100–1000 gray + blue + red + amber + green + teal + purple + pink colour scale exists as a system token set, but the marketing surface uses only the `100`, `1000`, and `700`-level tones; the rest stay in the design-system tokens for in-product surfaces.
|
|
||||||
- An "Active CPU" pricing rhythm: `pricing-card` lays out 3-up on the pricing page with `pricing-card-featured` (Pro tier) polarity-flipped to `{colors.primary}` against white-card siblings.
|
|
||||||
|
|
||||||
## Colors
|
|
||||||
|
|
||||||
### Brand & Accent
|
|
||||||
- **Ink** (`{colors.primary}` — `#171717`): The single primary CTA color. Black-near-pure ink that carries every Sign Up pill, every footer CTA, the dark-band polarity-flip. Used as text color throughout the page on light surfaces. (Resolved from `--ds-gray-1000`.)
|
|
||||||
- **Cyan** (`{colors.cyan}` — `#50e3c2`): A signature mint-cyan used in the brand gradient and inside Geist-system spotlight tokens. Visible inside the hero gradient stops.
|
|
||||||
- **Highlight Pink** (`{colors.highlight-pink}` — `#ff0080`): The brand's highlight magenta, used as the high-saturation stop in the preview-gradient pair.
|
|
||||||
- **Violet** (`{colors.violet}` — `#7928ca`): The deep purple used as the start of the preview-gradient and inside developer-console highlights.
|
|
||||||
- **Link Blue** (`{colors.link}` — `#0070f3`): The brand's primary link color and the legacy `--geist-success` semantic.
|
|
||||||
|
|
||||||
### Surface
|
|
||||||
- **Canvas** (`{colors.canvas}` — `#ffffff`): The pure-white card / dialog / modal surface.
|
|
||||||
- **Canvas Soft** (`{colors.canvas-soft}` — `#fafafa`): The default page background — 98 % white. Almost every section sits on this tone.
|
|
||||||
- **Canvas Soft 2** (`{colors.canvas-soft-2}` — `#f5f5f5`): A slightly deeper inset surface for "code editor inner background", template-card hover states, and dropdown menus.
|
|
||||||
- **Hairline** (`{colors.hairline}` — `#ebebeb`): 1 px dividers — table rows, card borders, input borders.
|
|
||||||
- **Hairline Strong** (`{colors.hairline-strong}` — `#a1a1a1`): The 500-level gray, used as the slightly-stronger divider on light bands and as the deemphasised text color.
|
|
||||||
|
|
||||||
### Text
|
|
||||||
- **Ink** (`{colors.ink}` — `#171717`): Every heading and body paragraph on light surfaces.
|
|
||||||
- **Body** (`{colors.body}` — `#4d4d4d`): Secondary text — sub-headings, body captions, nav-link inactive text, footer column body.
|
|
||||||
- **Mute** (`{colors.mute}` — `#888888`): Lowest-priority text — placeholder text, fine print, low-key labels.
|
|
||||||
- **On Primary** (`{colors.on-primary}` — `#ffffff`): All text on `{colors.primary}` surfaces.
|
|
||||||
|
|
||||||
### Semantic
|
|
||||||
- **Success / Link** (`{colors.success}` — `#0070f3`): The brand's legacy success indicator doubles as the primary link color. Visible underline-on-hover for inline body links.
|
|
||||||
- **Link Deep** (`{colors.link-deep}` — `#0761d1`): The pressed / visited tone for inline links.
|
|
||||||
- **Link Bg Soft** (`{colors.link-bg-soft}` — `#d3e5ff`): Soft pastel blue fill for "what's new" pill banners and informational badges.
|
|
||||||
- **Error** (`{colors.error}` — `#ee0000`): Validation red for destructive actions and form errors.
|
|
||||||
- **Error Soft** (`{colors.error-soft}` — `#f7d4d6`): Soft pastel red for destructive-state backgrounds.
|
|
||||||
- **Error Deep** (`{colors.error-deep}` — `#c50000`): Pressed / deep destructive state.
|
|
||||||
- **Warning** (`{colors.warning}` — `#f5a623`): Caution / pending status indicator.
|
|
||||||
- **Warning Soft** (`{colors.warning-soft}` — `#ffefcf`) / **Warning Deep** (`{colors.warning-deep}` — `#ab570a`): Background + pressed variants.
|
|
||||||
|
|
||||||
### Brand Gradient
|
|
||||||
The brand's signature decoration is a three-pair gradient stack:
|
|
||||||
- **Develop** (`{colors.gradient-develop-start}` `#007cf0` → `{colors.gradient-develop-end}` `#00dfd8`) — the blue-to-teal pair used to mark the "deploy" / "develop" rhythm.
|
|
||||||
- **Preview** (`{colors.gradient-preview-start}` `#7928ca` → `{colors.gradient-preview-end}` `#ff0080`) — the violet-to-pink pair used for "preview" surfaces.
|
|
||||||
- **Ship** (`{colors.gradient-ship-start}` `#ff4d4d` → `{colors.gradient-ship-end}` `#f9cb28`) — the coral-to-amber pair used for "ship" surfaces.
|
|
||||||
|
|
||||||
The three pairs collapse into a single multi-color mesh gradient when used as the hero atmospheric backdrop. Treat the gradient as one unified object — do not crop down to a single colour, do not reorder the stops, and do not miniaturise. Used at hero scale only.
|
|
||||||
|
|
||||||
## Typography
|
|
||||||
|
|
||||||
### Font Family
|
|
||||||
Two custom faces carry the entire system:
|
|
||||||
|
|
||||||
1. **A custom geometric sans** (extracted as `Geist`) for every display, body, button, link, and label. Weights 400 / 500 / 600 are the working set; the face never appears in 700 or heavier. Display sizes are tracked aggressively negative (`-2.4 px` at 48 px hero, `-1.28 px` at 32 px section); body stays at neutral or slightly-negative tracking.
|
|
||||||
2. **A custom monospaced face** (extracted as `Geist Mono`) for terminal mockups, code blocks, and small mono-caption labels — anything that wants to signal "technical." Weight 400 only at 12 – 13 px. Tracking neutral.
|
|
||||||
|
|
||||||
A condensed display sans (`Space Grotesk`) is loaded as a third face for occasional editorial moments but does not render as the primary face anywhere in the captured surfaces.
|
|
||||||
|
|
||||||
### Hierarchy
|
|
||||||
|
|
||||||
| Token | Size | Weight | Line Height | Letter Spacing | Use |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| `{typography.display-xl}` | 48px | 600 | 48px | -2.4px | Hero headline ("Build and deploy on the AI Cloud."). |
|
|
||||||
| `{typography.display-lg}` | 32px | 600 | 40px | -1.28px | Section headlines ("Your frontend, delivered.", "A compute model for all workloads."). |
|
|
||||||
| `{typography.display-md}` | 24px | 600 | 32px | -0.96px | Card-cluster headlines, pricing-tier names. |
|
|
||||||
| `{typography.display-sm}` | 20px | 600 | 28px | -0.6px | Inline display micro-headings. |
|
|
||||||
| `{typography.body-lg}` | 18px | 400 | 28px | 0 | Lead paragraphs under section headlines. |
|
|
||||||
| `{typography.body-md}` | 16px | 400 | 24px | 0 | Default body paragraph. |
|
|
||||||
| `{typography.body-md-strong}` | 16px | 500 | 24px | 0 | Bolded inline body. |
|
|
||||||
| `{typography.body-sm}` | 14px | 400 | 20px | -0.28px | Secondary body, nav-link text, button-md labels. |
|
|
||||||
| `{typography.body-sm-strong}` | 14px | 500 | 20px | -0.28px | Nav CTA labels, table-row emphasis. |
|
|
||||||
| `{typography.caption}` | 12px | 400 | 16px | 0 | Footer secondary lines, badge labels. |
|
|
||||||
| `{typography.caption-mono}` | 12px | 400 | 16px | 0 | Section eyebrows and label captions that want a technical voice. |
|
|
||||||
| `{typography.code}` | 13px | 400 | 20px | 0 | Inline code, terminal mockups, command snippets. |
|
|
||||||
| `{typography.button-md}` | 14px | 500 | 20px | 0 | Small / nav-scale button labels. |
|
|
||||||
| `{typography.button-lg}` | 16px | 500 | 24px | 0 | Marketing-scale pill button labels. |
|
|
||||||
|
|
||||||
### Principles
|
|
||||||
- **Negative tracking is part of the voice.** Display sizes use aggressive `-2.4` to `-0.6` px tracking. Reverting to default tracking breaks the brand.
|
|
||||||
- **Sentence-case headlines, period-terminated.** Headlines like "Build and deploy on the AI Cloud." end with a deliberate period — that punctuation is part of the brand's voice.
|
|
||||||
- **Mono for the technical layer only.** Section eyebrows, code blocks, terminal mockups. Body paragraphs never set in mono.
|
|
||||||
- **Weight 600 is the display ceiling.** The geometric sans never appears at 700 / 800. The brand reads as a calmer system because of this.
|
|
||||||
|
|
||||||
### Note on Font Substitutes
|
|
||||||
The two primary faces are proprietary (custom-cut for the brand). Open-source substitutes:
|
|
||||||
- **Geometric sans** — *Inter* (400 / 500 / 600) is the closest stylistic match; `font-feature-settings: "ss01", "ss02"` enables the geometric alternates. *Satoshi* is a passable second choice.
|
|
||||||
- **Monospace** — *JetBrains Mono* (400) at 12 – 13 px matches the technical voice. *IBM Plex Mono* is the second-best option.
|
|
||||||
|
|
||||||
## Layout
|
|
||||||
|
|
||||||
### Spacing System
|
|
||||||
- **Base unit**: 4 px. The brand's `--geist-space` token is exactly 4 px and every captured value is a multiple of 4.
|
|
||||||
- **Tokens**: `{spacing.xxs}` 4 px · `{spacing.xs}` 8 px · `{spacing.sm}` 12 px · `{spacing.md}` 16 px · `{spacing.lg}` 24 px · `{spacing.xl}` 32 px · `{spacing.2xl}` 40 px · `{spacing.3xl}` 48 px · `{spacing.4xl}` 64 px · `{spacing.5xl}` 96 px · `{spacing.6xl}` 128 px · `{spacing.section}` 192 px.
|
|
||||||
- **Section padding**: marketing bands use `{spacing.4xl}` to `{spacing.5xl}` top/bottom. Hero bands stretch to `{spacing.section}` to give the mesh gradient room to breathe.
|
|
||||||
- **Card interior padding**: marketing cards sit at `{spacing.lg}` to `{spacing.xl}`; template-grid cards stay tighter at `{spacing.md}` because they sit in a denser grid.
|
|
||||||
- **Inline gap**: button rows, nav rows, and chip rows use `{spacing.sm}` to `{spacing.md}` between siblings. The brand's `--geist-gap` is exactly 24 px.
|
|
||||||
|
|
||||||
### Grid & Container
|
|
||||||
- **Max width**: ~1400 px (`--ds-page-width`); the legacy `--geist-page-width` is 1200 px and still appears on some marketing surfaces. Content centres with horizontal gutters of `{spacing.lg}` 24 px on desktop, `{spacing.md}` 16 px on mobile.
|
|
||||||
- **Column patterns**:
|
|
||||||
- Three-feature row: 3-up at desktop, 1-up at mobile (rows like "Web Apps / Composable Commerce / Multi-tenant Platforms").
|
|
||||||
- Tab pill row: 5-up centred row of `tab-ghost` pills.
|
|
||||||
- Template-grid cluster: 5-up at desktop, scaling to 1-up at mobile.
|
|
||||||
- Pricing tier grid: 3-up at desktop with the middle tier polarity-flipped.
|
|
||||||
- Logo strip: ~5 logos wide, single row.
|
|
||||||
|
|
||||||
### Whitespace Philosophy
|
|
||||||
The mesh gradient does most of the heavy decorative lifting; whitespace separates the bands. Section spacing is generous — `{spacing.4xl}` to `{spacing.5xl}` between bands lets the gradient breathe. Inside a card, the headline/paragraph stack is tight (`{spacing.xs}` 8 px gap), then a wider gap before the CTA cluster. The page reads as engineered — large gaps + tight interior, never the other way around.
|
|
||||||
|
|
||||||
### Responsive Strategy
|
|
||||||
|
|
||||||
#### Breakpoints
|
|
||||||
|
|
||||||
| Name | Width | Key Changes |
|
|
||||||
|---|---|---|
|
|
||||||
| Mobile | < 600px | Hero stacks; nav collapses to hamburger; 3-up feature grids drop to 1-up; tab pill row enables horizontal scroll. |
|
|
||||||
| Tablet | 600–959px | 3-up grids drop to 2-up; nav still horizontal. |
|
|
||||||
| Desktop | 960–1199px | Full 3-up grids; pricing 3-up. |
|
|
||||||
| Wide | 1200–1399px | Container caps at 1400 px content width. |
|
|
||||||
| Ultra-wide | ≥ 1400px | Content stays centred at 1400 px; bands stretch edge-to-edge in colour but content holds the max-width. |
|
|
||||||
|
|
||||||
#### Touch Targets
|
|
||||||
The `button-primary` pill renders at ~32 px tall in nav and ~48 px tall in marketing contexts. Marketing CTAs comfortably meet WCAG AAA at all breakpoints; nav buttons inflate touch area through `{spacing.xs}` padding on mobile to meet the 44 × 44 px floor.
|
|
||||||
|
|
||||||
#### Collapsing Strategy
|
|
||||||
- **Nav**: full link row + Ask AI / Log In / Sign Up pills at desktop. Collapses to logo + hamburger at mobile with the menu opening as a full-overlay.
|
|
||||||
- **Hero**: mesh gradient stays centred; headline + body stack vertically at all breakpoints (the brand doesn't use a split-hero pattern).
|
|
||||||
- **Three-feature row**: 3-up → 2-up → 1-up at the breakpoints above; cards keep their `{rounded.md}` 8 px shape across all viewports.
|
|
||||||
- **Pricing card grid**: 3-up at desktop, vertical stack at mobile with `pricing-card-featured` always sitting in the middle.
|
|
||||||
- **Template grid**: 5-up → 3-up → 2-up → 1-up. Each `template-card` keeps its 16:9 aspect on the image.
|
|
||||||
|
|
||||||
#### Image Behavior
|
|
||||||
- **Mesh gradient**: rendered as inline SVG or canvas-painted gradient; scales fluidly with the hero container; never crops, never tiles.
|
|
||||||
- **Customer logos**: rendered as monochrome SVGs in the logo strip; consistent 24 px height.
|
|
||||||
- **Code editor mockup**: dark `{colors.primary}` rectangle with mono text rendered inside; treated as an image at the layout level.
|
|
||||||
- **Template thumbnails**: 16:9 landscape inside `{rounded.md}` card chrome; lazy-loaded; consistent grayscale palette in the placeholder state.
|
|
||||||
|
|
||||||
## Elevation & Depth
|
|
||||||
|
|
||||||
| Level | Treatment | Use |
|
|
||||||
|---|---|---|
|
|
||||||
| Level 0 — Flat | No shadow, no border. | Full-bleed hero bands and the polarity-flipped dark sections. |
|
|
||||||
| Level 1 — Inset Hairline | `0 0 0 1px #00000014` inset 1 px border. | Default card chrome — the brand's universal "you can see this card" cue. |
|
|
||||||
| Level 2 — Subtle Drop | `0px 1px 1px #00000005, 0px 2px 2px #0000000a` plus inset hairline. | Slightly elevated cards (template-grid, marketing-card). |
|
|
||||||
| Level 3 — Soft Stack | `0px 2px 2px #0000000a, 0px 8px 8px -8px #0000000a` plus inset hairline. | The "medium" elevation — feature-grid cards. |
|
|
||||||
| Level 4 — Float Stack | `0px 2px 2px #0000000a, 0px 8px 16px -4px #0000000a` plus inset hairline. | "Large" elevation — pricing cards, callout panels. |
|
|
||||||
| Level 5 — Modal | `0px 1px 1px #00000005, 0px 8px 16px -4px #0000000a, 0px 24px 32px -8px #0000000f` plus inset hairline. | Modal / dialog surfaces and dropdown menus. |
|
|
||||||
|
|
||||||
The brand uses STACKED shadows — multiple small offsets layered to fake natural light — never a single 8-px-blur generic drop. Inset hairline rings are always added so the card edge stays crisp.
|
|
||||||
|
|
||||||
### Decorative Depth
|
|
||||||
- **Mesh gradient as atmospheric depth**: the hero's multi-stop gradient is the brand's only "atmospheric" effect — applied as a flat 2-D backdrop rather than a 3-D illustration.
|
|
||||||
- **Polarity-flipped dark band as section-depth**: switching the surface from `{colors.canvas-soft}` to `{colors.primary}` (the deep ink) is the brand's chief depth cue between bands.
|
|
||||||
- **Inset-shadow + drop-shadow combo**: the cards' combination of an inset 1 px ring and a multi-stop drop produces a "card sits on the page" effect without ever feeling material-heavy.
|
|
||||||
|
|
||||||
## Shapes
|
|
||||||
|
|
||||||
### Border Radius Scale
|
|
||||||
|
|
||||||
| Token | Value | Use |
|
|
||||||
|---|---|---|
|
|
||||||
| `{rounded.none}` | 0px | Full-bleed hero / footer bands. |
|
|
||||||
| `{rounded.xs}` | 4px | Tightest inline pill — the `nav-cta-signup` 6-px-radius button (mapped to `xs/sm`). |
|
|
||||||
| `{rounded.sm}` | 6px | The brand's `--geist-radius` token — base UI radius for in-app buttons, form inputs, dropdown menus. |
|
|
||||||
| `{rounded.md}` | 8px | The brand's `--geist-marketing-radius` token — feature cards, template cards. |
|
|
||||||
| `{rounded.lg}` | 12px | Slightly larger card chrome (pricing-card variants). |
|
|
||||||
| `{rounded.xl}` | 16px | Largest card chrome — when a card hosts a hero image cap. |
|
|
||||||
| `{rounded.pill-sm}` | 64px | Tab-ghost pills inside the "AI Apps / Web Apps / Ecommerce / Marketing / Platforms" row. |
|
|
||||||
| `{rounded.pill}` | 100px | The marketing CTA pill — `button-primary`, `button-secondary`, "Start Deploying" pill. |
|
|
||||||
| `{rounded.full}` | 9999px | Icon-button circular containers, nav-link ghost pills. |
|
|
||||||
|
|
||||||
### Photography Geometry
|
|
||||||
- **Mesh gradient**: full-bleed 2-D atmospheric backdrop, never cropped to a frame; treated as the page's wallpaper.
|
|
||||||
- **Customer logos**: monochrome SVG, consistent 24 px height in a flex row.
|
|
||||||
- **Code editor mockup**: 16:10 dark rectangle, `{rounded.md}` corners.
|
|
||||||
- **Template thumbnails**: 16:9 landscape inside `{rounded.md}` chrome.
|
|
||||||
- **Showcase imagery**: 2:1 or 16:9 inside `{rounded.lg}` to `{rounded.xl}` chrome with a stacked shadow.
|
|
||||||
|
|
||||||
## Components
|
|
||||||
|
|
||||||
### Buttons
|
|
||||||
|
|
||||||
**`button-primary`** — the canonical 100-px-radius black pill, marketing scale.
|
|
||||||
- Background `{colors.primary}`, text `{colors.on-primary}`, label set in `{typography.button-lg}`, padding `0px {spacing.sm}` 12 px, shape `{rounded.pill}` 100 px. Renders ~48 px tall when paired with the marketing flex layout.
|
|
||||||
|
|
||||||
**`button-secondary`** — the white pill paired with the black primary inside marketing bands.
|
|
||||||
- Background `{colors.canvas}`, text `{colors.ink}`, same typography + padding as `button-primary`, shape `{rounded.pill}`.
|
|
||||||
|
|
||||||
**`button-primary-sm`** — the smaller-scale primary pill used inside nav and pricing-card CTAs.
|
|
||||||
- Background `{colors.primary}`, text `{colors.on-primary}`, label set in `{typography.button-md}` (14 px / 500), shape `{rounded.pill}`.
|
|
||||||
|
|
||||||
**`button-secondary-sm`** — the smaller-scale white pill paired with `button-primary-sm`.
|
|
||||||
- Background `{colors.canvas}`, text `{colors.ink}`, same typography + shape as `button-primary-sm`.
|
|
||||||
|
|
||||||
**`tab-ghost`** — the centred-row tab pill ("AI Apps / Web Apps / Ecommerce / Marketing / Platforms").
|
|
||||||
- Background `{colors.canvas}`, text `{colors.ink}`, label set in `{typography.body-sm}`, padding `0px {spacing.md}`, shape `{rounded.pill-sm}` 64 px.
|
|
||||||
|
|
||||||
**`icon-button-circular`** — the circular icon container (often a "?" or arrow inside).
|
|
||||||
- Background `{colors.canvas}`, dark icon, 1 px solid hairline border, shape `{rounded.full}`.
|
|
||||||
|
|
||||||
**Nav CTAs:**
|
|
||||||
|
|
||||||
**`nav-cta-signup`** — the small black "Sign Up" button in the nav row.
|
|
||||||
- Background `{colors.primary}`, text `{colors.on-primary}`, label `{typography.body-sm-strong}`, padding `0px {spacing.xs}`, height 28 px, shape `{rounded.sm}` 6 px (the brand's `--geist-radius`).
|
|
||||||
|
|
||||||
**`nav-cta-login`** — the white "Log In" button in the nav.
|
|
||||||
- Background `{colors.canvas}`, text `{colors.ink}`, same typography / height / shape as `nav-cta-signup`.
|
|
||||||
|
|
||||||
**`nav-cta-ask-ai`** — the small "Ask AI" button with a faint border.
|
|
||||||
- Background `{colors.canvas}`, text `{colors.ink}`, 1 px solid `{colors.hairline}` border (extracted as `0px solid rgb(235, 235, 235)`), same typography / height / shape.
|
|
||||||
|
|
||||||
### Cards & Containers
|
|
||||||
|
|
||||||
**`card-marketing`** — the canonical marketing feature card (3-up section cards).
|
|
||||||
- Background `{colors.canvas}`, text `{colors.ink}`, padding `{spacing.lg}` 24 px, shape `{rounded.md}` 8 px (the `--geist-marketing-radius`). Carries Level 3 soft-stack shadow.
|
|
||||||
|
|
||||||
**`card-marketing-large`** — the larger marketing card used for "compute model" / "AI Gateway" callouts.
|
|
||||||
- Background `{colors.canvas}`, text `{colors.ink}`, padding `{spacing.xl}`, shape `{rounded.lg}` 12 px. Carries Level 4 float-stack shadow.
|
|
||||||
|
|
||||||
**`card-soft`** — the soft-tinted card used inside cluster groups (lighter than canvas-soft).
|
|
||||||
- Background `{colors.canvas-soft}`, text `{colors.ink}`, padding `{spacing.lg}`, shape `{rounded.md}`.
|
|
||||||
|
|
||||||
**`template-card`** — the deploy-template card in the "Deploy your first app" grid.
|
|
||||||
- Background `{colors.canvas}`, text `{colors.ink}`, padding `{spacing.md}` 16 px, shape `{rounded.md}` 8 px. Hosts a 16:9 thumbnail at the top.
|
|
||||||
|
|
||||||
**`code-editor-mockup`** — the dark code-preview surface inside marketing bands.
|
|
||||||
- Background `{colors.primary}`, text `{colors.on-primary}`, body in `{typography.code}` (13 px / Geist Mono), padding `{spacing.lg}` 24 px, shape `{rounded.md}` 8 px.
|
|
||||||
|
|
||||||
**`pricing-card`** — the default pricing-tier card.
|
|
||||||
- Background `{colors.canvas}`, text `{colors.ink}`, padding `{spacing.xl}` 32 px, shape `{rounded.lg}` 12 px. Inside: tier name in `{typography.display-md}`, price in `{typography.display-xl}`, feature list in `{typography.body-md}` rows, CTA at the bottom.
|
|
||||||
|
|
||||||
**`pricing-card-featured`** — the polarity-flipped "Pro" tier card.
|
|
||||||
- Background `{colors.primary}`, text `{colors.on-primary}`, same shape + padding as `pricing-card`. CTA inverts to `button-secondary-sm` (white pill on black card).
|
|
||||||
|
|
||||||
### Inputs & Forms
|
|
||||||
|
|
||||||
**`form-input`** — the canonical text input.
|
|
||||||
- Background `{colors.canvas}`, text `{colors.ink}`, 1 px solid `{colors.hairline}` border, body in `{typography.body-sm}` (14 px), padding `0px {spacing.sm}`, height 40 px (the brand's `--geist-form-height`), shape `{rounded.sm}` 6 px.
|
|
||||||
|
|
||||||
**`form-input-sm`** — small-height variant (32 px tall) for tight forms.
|
|
||||||
- Same as `form-input` but height 32 px (the `--geist-form-small-height`).
|
|
||||||
|
|
||||||
**`form-input-lg`** — large-height variant (48 px tall) for hero CTAs.
|
|
||||||
- Same as `form-input` but height 48 px (the `--geist-form-large-height`); body in `{typography.body-md}` 16 px.
|
|
||||||
|
|
||||||
### Navigation
|
|
||||||
|
|
||||||
**`nav-bar`** — the sticky top nav.
|
|
||||||
- Background `{colors.canvas}`, text `{colors.ink}`, height 64 px (the brand's `--header-height`), padding `{spacing.sm} {spacing.lg}`. Layout: logo left, link row centre, "Ask AI / Log In / Sign Up" cluster right.
|
|
||||||
|
|
||||||
**`nav-link`** — the centred link row inside `nav-bar`.
|
|
||||||
- Text `{colors.body}`, set in `{typography.body-sm}`, padding `{spacing.xs} {spacing.sm}`, shape `{rounded.full}` (ghost pill — visible only on hover or active, but the radius is documented).
|
|
||||||
|
|
||||||
**`footer`** — the bottom 4-column nav.
|
|
||||||
- Background `{colors.canvas}`, text `{colors.body}`, padding `{spacing.4xl} {spacing.lg}`. Eyebrow column labels in `{typography.caption-mono}` (uppercase mono effect); link rows in `{typography.body-sm}`.
|
|
||||||
|
|
||||||
### Signature Components
|
|
||||||
|
|
||||||
**`hero-band`** — the white hero with the mesh gradient backdrop.
|
|
||||||
- Background `{colors.canvas}` (or `{colors.canvas-soft}` on some surfaces), text `{colors.ink}`, padding `{spacing.4xl} {spacing.lg}`. Inside: a small mono badge above the headline, the headline in `{typography.display-xl}` (sentence-case, period-terminated), a body lead in `{typography.body-lg}`, then a CTA row with `button-primary` + `button-secondary`. The mesh gradient sits behind, scaled to occupy roughly the top half of the band.
|
|
||||||
|
|
||||||
**`feature-mesh-band`** — the secondary section that hosts a mesh-gradient atmospheric backdrop with feature copy on top.
|
|
||||||
- Background `{colors.canvas}`, text `{colors.ink}`, padding `{spacing.5xl} {spacing.lg}`. Section headline in `{typography.display-lg}`; supporting body in `{typography.body-md}`.
|
|
||||||
|
|
||||||
**`showcase-band-light`** — a soft-canvas section ("Deploy your first app in seconds").
|
|
||||||
- Background `{colors.canvas-soft}`, text `{colors.ink}`, padding `{spacing.5xl} {spacing.lg}`.
|
|
||||||
|
|
||||||
**`showcase-band-dark`** — the polarity-flipped dark band ("A compute model for all workloads").
|
|
||||||
- Background `{colors.primary}`, text `{colors.on-primary}`, padding `{spacing.5xl} {spacing.lg}`. Section headline in `{typography.display-lg}` (white on black). Often contains a `code-editor-mockup` flush with the band.
|
|
||||||
|
|
||||||
**`logo-strip`** — the customer-logo wrapping row near the top of the page.
|
|
||||||
- Background `{colors.canvas}`, text `{colors.body}`, padding `{spacing.lg} {spacing.xl}`. Logos rendered as monochrome SVGs at consistent height.
|
|
||||||
|
|
||||||
**`badge-secondary`** — the small inline metadata pill ("New", "Beta", "Live").
|
|
||||||
- Background `{colors.canvas-soft}`, text `{colors.body}`, body in `{typography.caption}`, padding `0px {spacing.xs}`, shape `{rounded.full}`.
|
|
||||||
|
|
||||||
**`banner-marketing`** — the "Introducing X" announcement pill at the top of pages.
|
|
||||||
- Background `{colors.canvas-soft}`, text `{colors.body}`, body in `{typography.body-sm}`, padding `{spacing.xs} {spacing.sm}`, shape `{rounded.full}`.
|
|
||||||
|
|
||||||
**`link-inline`** — body-copy inline links.
|
|
||||||
- Text `{colors.link}` (`#0070f3`), body in `{typography.body-md}`, underlined.
|
|
||||||
|
|
||||||
### Examples (illustrative)
|
|
||||||
|
|
||||||
> Auto-derived kit-mirror demonstration surfaces (`scripts/derive-examples-block.mjs`). Each `ex-*` entry references brand-native primitives so downstream consumers (`/preview-design`, `/generate-kit`) re-skin the same 10 surfaces consistently. `TO_FILL` markers indicate missing primitives — resolve in the LLM judgment pass.
|
|
||||||
|
|
||||||
**`ex-pricing-tier`** — Default Pricing tier card. Re-uses feature-card chrome with brand canvas-soft surface.
|
|
||||||
- Properties: `backgroundColor`, `textColor`, `borderColor`, `rounded`, `padding`
|
|
||||||
|
|
||||||
**`ex-pricing-tier-featured`** — Featured/highlighted tier — polarity-flipped surface (dark fill + light text in light mode, light fill + dark text in dark mode).
|
|
||||||
- Properties: `backgroundColor`, `textColor`, `rounded`, `padding`
|
|
||||||
|
|
||||||
**`ex-product-selector`** — What's Included summary card — re-purposed for SaaS / B2B verticals (NOT a literal product gallery).
|
|
||||||
- Properties: `backgroundColor`, `rounded`, `padding`
|
|
||||||
|
|
||||||
**`ex-cart-drawer`** — Subscription summary — re-purposed for SaaS / B2B (line items per add-on, not literal cart).
|
|
||||||
- Properties: `backgroundColor`, `rounded`, `padding`, `item-divider`
|
|
||||||
|
|
||||||
**`ex-app-shell-row`** — Sidebar nav row inside the App Shell example. Active state uses brand primary as the indicator.
|
|
||||||
- Properties: `backgroundColor`, `activeIndicator`, `rounded`, `padding`
|
|
||||||
|
|
||||||
**`ex-data-table-cell`** — Default data-table th + td chrome. Header uses mono-caps eyebrow typography; body uses body-sm.
|
|
||||||
- Properties: `headerBackground`, `headerTypography`, `bodyTypography`, `cellPadding`, `rowBorder`
|
|
||||||
|
|
||||||
**`ex-auth-form-card`** — Sign-in / sign-up card. Re-uses feature-card chrome with text-input primitives inside.
|
|
||||||
- Properties: `backgroundColor`, `rounded`, `padding`
|
|
||||||
|
|
||||||
**`ex-modal-card`** — Modal dialog surface — same chrome as feature-card with elevated shadow.
|
|
||||||
- Properties: `backgroundColor`, `rounded`, `padding`
|
|
||||||
|
|
||||||
**`ex-empty-state-card`** — Empty-state illustration frame.
|
|
||||||
- Properties: `backgroundColor`, `rounded`, `padding`, `captionTypography`
|
|
||||||
|
|
||||||
**`ex-toast`** — Toast notification surface — feature-card shape + medium shadow.
|
|
||||||
- Properties: `backgroundColor`, `rounded`, `padding`, `typography`
|
|
||||||
|
|
||||||
|
|
||||||
## Do's and Don'ts
|
|
||||||
|
|
||||||
### Do
|
|
||||||
- Reserve `{colors.primary}` (`#171717`) for primary CTAs across the page. Black ink IS the conversion target.
|
|
||||||
- Use `{rounded.pill}` 100 px for every marketing-scale CTA and `{rounded.sm}` 6 px for nav-scale buttons. The two pill scales coexist deliberately.
|
|
||||||
- Set every headline in `{typography.display-*}` weight 600, sentence-case, often period-terminated. Aggressive negative tracking is part of the voice.
|
|
||||||
- Use the brand mesh gradient as atmospheric decoration at hero scale only — never miniaturise it to an icon, never reduce to a single colour.
|
|
||||||
- Layer stacked shadows (multiple small offsets with inset hairline) rather than single heavy drops. The brand's elevation is calmer than Material.
|
|
||||||
- Cycle page surfaces in `{colors.canvas-soft}` → `{colors.canvas}` → `{colors.primary}` polarity-flipped bands; the dark band IS the depth cue.
|
|
||||||
- Set every code block and technical eyebrow in `{typography.code}` / `{typography.caption-mono}`. Mono is the voice of the platform.
|
|
||||||
|
|
||||||
### Don't
|
|
||||||
- Don't introduce a sixth accent colour. The brand operates with ink + gray + the four-pair gradient palette; new accents flatten the voice.
|
|
||||||
- Don't render headlines in all-caps. Sentence-case + negative tracking is non-negotiable.
|
|
||||||
- Don't drop a single heavy drop-shadow on cards. The brand's elevation is built from stacked small offsets + inset hairline rings.
|
|
||||||
- Don't render the brand gradient at icon scale or in a single-colour reduced form. The gradient lives at hero scale only.
|
|
||||||
- Don't promote the geometric sans to weight 700. The brand's display ceiling is 600.
|
|
||||||
- Don't pair the marketing 100-px pill CTA shape with the 6-px nav radius on the same screen — pick a scale and stay there.
|
|
||||||
- Don't set body paragraphs in the mono face. The mono is for code + technical labels only.
|
|
||||||
12
Dockerfile.backend
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["npm", "run", "server"]
|
||||||
12
Dockerfile.frontend
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||||
25
README.md
@@ -28,8 +28,29 @@
|
|||||||
|
|
||||||
### 🎨 ITAM 시스템 디자인 가이드 (Design Guide)
|
### 🎨 ITAM 시스템 디자인 가이드 (Design Guide)
|
||||||
|
|
||||||
디자인 일관성 및 시각적 원칙에 관한 상세 내용은 아래 문서를 참조하십시오.
|
1. **디자인 철학 (Design Philosophy)**
|
||||||
|
* **Minimalist & Border-based**: 불필요한 박스(Card) 사용을 최소화하고, 정보의 구분은 간결한 라인(Border/Divider)을 활용하여 시각적 피로도를 낮춥니다.
|
||||||
|
* **Professional Achromatic**: 무채색(Black, White, Grey)을 기본으로 하여 정돈된 업무 환경을 제공합니다.
|
||||||
|
* **Green Accent**: 블루 대신 짙은 그린(`#1E5149`)을 포인트 컬러로 사용하여 차분한 전문성을 강조합니다.
|
||||||
|
|
||||||
👉 **[디자인 가이드 바로가기 (design_rule.md)](./design_rule.md)**
|
2. **타이포그래피 (Typography)**
|
||||||
|
* **Font Family**: `Pretendard` (전역 적용)
|
||||||
|
* **Letter Spacing**: `-0.02em` (약 -2%) 적용. 자간을 좁게 설정하여 밀도 있고 세련된 가독성을 확보합니다.
|
||||||
|
* **Weights**: 400(Regular), 500(Medium), 600(SemiBold), 700(Bold).
|
||||||
|
|
||||||
|
3. **컬러 팔레트 (Color Palette)**
|
||||||
|
* **Point Color**: `#1E5149` (Deep Green) - 강조, 활성화 상태, 주요 액션 버튼.
|
||||||
|
* **Text**: Main(`#111827` - Near Black), Muted(`#6B7280` - Grey).
|
||||||
|
* **Border/Divider**: `#E5E7EB` (Light Grey) - 정보 구분을 위한 얇은 실선.
|
||||||
|
* **Background**: `#FFFFFF` (White) / `#F9FAFB` (Off White).
|
||||||
|
|
||||||
|
4. **레이아웃 및 컴포넌트 규칙 (Layout Rules)**
|
||||||
|
* **Box-less Design**: 꼭 필요한 정보 묶음(데이터 그룹화 등)이 아니면 박스 형태의 테두리나 배경 사용을 지양합니다.
|
||||||
|
* **Line-based Division**: 섹션 간의 구분은 1px 두께의 얇은 실선(Border)을 통해 명확히 합니다.
|
||||||
|
* **Table**: 배경색이나 화려한 효과 없이 행(Row) 간의 얇은 구분선만 사용하여 데이터 본연에 집중하게 합니다.
|
||||||
|
* **Input/Button**: 입력 필드와 버튼은 최소한의 보더와 포인트 컬러만 사용하여 정갈하게 표현합니다.
|
||||||
|
* **Modal (모달 공통 규칙)**:
|
||||||
|
* **Header**: 짙은 그린(`#1E5149`) 배경에 화이트 텍스트를 사용하며, 우측 상단에 명확한 'X' 닫기 버튼을 배치합니다.
|
||||||
|
* **Interaction**: 사용자의 오입력(실수로 바깥을 클릭하여 입력 내용이 날아가는 현상)을 방지하기 위해 **모달 바깥 영역(Overlay) 클릭 시 모달이 닫히지 않도록** 설정합니다. 닫기는 오직 'ESC' 키 또는 명시적인 'X' 및 '닫기' 버튼을 통해서만 가능합니다.
|
||||||
|
* **Layout**: `detail.png` 기준의 2열 그리드 시스템을 권장하며, 하단 우측에 액션 버튼(닫기, 저장 등)을 배치합니다.
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
const mysql = require('mysql2/promise');
|
|
||||||
require('dotenv').config({ override: true });
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const pool = mysql.createPool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASS,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
port: parseInt(process.env.DB_PORT || '3306')
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. PW 필드에 9~10자리 숫자(팀뷰어 ID 의심)가 있는 경우
|
|
||||||
const [rows1] = await pool.query("SELECT id, asset_id, net_name, net_value1, net_value2 FROM asset_remote WHERE net_value2 REGEXP '^[0-9]{9,10}$'");
|
|
||||||
console.log('--- Suspicious PW as ID ---');
|
|
||||||
console.log(JSON.stringify(rows1, null, 2));
|
|
||||||
|
|
||||||
// 2. REMOTE 타입인데 value1이 IP인 경우 (아까 확인한 거 포함)
|
|
||||||
const [rows2] = await pool.query("SELECT id, asset_id, net_name, net_value1, net_value2 FROM asset_remote WHERE net_type = 'REMOTE' AND net_value1 REGEXP '^[0-9]+\\\\.[0-9]+\\\\.[0-9]+\\\\.[0-9]+$'");
|
|
||||||
console.log('\n--- REMOTE with IP in val1 ---');
|
|
||||||
console.log(JSON.stringify(rows2, null, 2));
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
const mysql = require('mysql2/promise');
|
|
||||||
require('dotenv').config({ override: true });
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const pool = mysql.createPool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASS,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
port: parseInt(process.env.DB_PORT || '3306')
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [rows] = await pool.query(
|
|
||||||
'SELECT net_name, net_value1, net_value2 FROM asset_remote WHERE net_type = ?',
|
|
||||||
['REMOTE']
|
|
||||||
);
|
|
||||||
console.log(JSON.stringify(rows, null, 2));
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
const mysql = require('mysql2/promise');
|
|
||||||
require('dotenv').config({ override: true });
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const pool = mysql.createPool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASS,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
port: parseInt(process.env.DB_PORT || '3306')
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [rows] = await pool.query(
|
|
||||||
'SELECT net_name, net_value1, net_value2 FROM asset_remote WHERE net_name LIKE ? OR net_name LIKE ? OR net_name LIKE ? OR net_name LIKE ?',
|
|
||||||
['%팀뷰어%', '%애니데스크%', '%TeamViewer%', '%AnyDesk%']
|
|
||||||
);
|
|
||||||
console.log(JSON.stringify(rows, null, 2));
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
const mysql = require('mysql2/promise');
|
|
||||||
require('dotenv').config({ override: true });
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const pool = mysql.createPool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASS,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
port: parseInt(process.env.DB_PORT || '3306')
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Find assets where REMOTE net_value1 looks like an IP and matches an existing IP row
|
|
||||||
const [rows] = await pool.query(`
|
|
||||||
SELECT r1.asset_id, r1.net_name as remote_name, r1.net_value1 as remote_val1, r2.net_value1 as ip_val1
|
|
||||||
FROM asset_remote r1
|
|
||||||
JOIN asset_remote r2 ON r1.asset_id = r2.asset_id AND r2.net_type = 'IP'
|
|
||||||
WHERE r1.net_type = 'REMOTE' AND r1.net_value1 REGEXP '^[0-9]+\\\\.[0-9]+\\\\.[0-9]+\\\\.[0-9]+$'
|
|
||||||
`);
|
|
||||||
console.log(JSON.stringify(rows, null, 2));
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
const mysql = require('mysql2/promise');
|
|
||||||
require('dotenv').config({ override: true });
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const pool = mysql.createPool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASS,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
port: parseInt(process.env.DB_PORT || '3306')
|
|
||||||
});
|
|
||||||
|
|
||||||
const connection = await pool.getConnection();
|
|
||||||
try {
|
|
||||||
await connection.beginTransaction();
|
|
||||||
|
|
||||||
const asset = {
|
|
||||||
id: 'debug_test_' + Date.now(),
|
|
||||||
asset_code: 'SVR-240612-DEBUG',
|
|
||||||
category: '서버',
|
|
||||||
asset_type: '서버PC'
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('--- Step 1: Insert into asset_core ---');
|
|
||||||
const coreFields = ['id', 'asset_code', 'category', 'asset_type'];
|
|
||||||
const coreData = {};
|
|
||||||
coreFields.forEach(f => { if (asset[f] !== undefined) coreData[f] = asset[f]; });
|
|
||||||
const coreKeys = Object.keys(coreData);
|
|
||||||
const coreSql = `INSERT INTO asset_core (${coreKeys.join(', ')}) VALUES (${coreKeys.map(() => '?').join(', ')})`;
|
|
||||||
|
|
||||||
const [coreRes] = await connection.query(coreSql, Object.values(coreData));
|
|
||||||
console.log('Core Insert Success:', coreRes);
|
|
||||||
|
|
||||||
console.log('\n--- Step 2: Insert into asset_spec ---');
|
|
||||||
const specData = {
|
|
||||||
asset_id: asset.id,
|
|
||||||
hw_status: '운영'
|
|
||||||
};
|
|
||||||
const specKeys = Object.keys(specData);
|
|
||||||
const specSql = `INSERT INTO asset_spec (${specKeys.join(', ')}) VALUES (${specKeys.map(() => '?').join(', ')})`;
|
|
||||||
|
|
||||||
const [specRes] = await connection.query(specSql, Object.values(specData));
|
|
||||||
console.log('Spec Insert Success:', specRes);
|
|
||||||
|
|
||||||
await connection.commit();
|
|
||||||
console.log('\n✅ Transaction Committed Successfully');
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
await connection.rollback();
|
|
||||||
console.error('\n❌ Error Caught:', err);
|
|
||||||
} finally {
|
|
||||||
connection.release();
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# 🎨 ITAM 시스템 디자인 가이드 (Design Guide)
|
|
||||||
|
|
||||||
본 문서는 ITAM(IT Asset Management System)의 시각적 일관성과 사용자 경험을 유지하기 위한 핵심 디자인 원칙을 정의합니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1. 디자인 철학 (Design Philosophy)
|
|
||||||
* **Minimalist & Stark**: Vercel 스타일의 극도로 간결하고 현대적인 디자인을 지향합니다.
|
|
||||||
* **Achromatic Base**: 블랙(#171717)과 화이트를 기본으로 하며, 정보의 구분은 얇은 헤어라인(#ebebeb)을 사용합니다.
|
|
||||||
* **Fluid & Responsive**: 고정된 픽셀 대신 화면 크기에 비례하여 UI 밀도가 변하는 유동적 스케일링 시스템을 적용합니다.
|
|
||||||
|
|
||||||
### 2. 반응형 스케일링 (Fluid Scaling System)
|
|
||||||
* **Core Principle**: 모든 UI 요소는 `vmin`과 `vw` 단위를 조합한 `clamp()` 함수를 통해 화면 크기에 맞춰 동적으로 변화합니다.
|
|
||||||
* **Typography Scale**:
|
|
||||||
* **XS**: `clamp(10px, 1.2vmin + 0.2vw, 15px)` - 보조 텍스트
|
|
||||||
* **SM**: `clamp(12px, 1.4vmin + 0.3vw, 18px)` - 필터, 일반 라벨
|
|
||||||
* **Base**: `clamp(14px, 1.6vmin + 0.4vw, 22px)` - 본문, 테이블 데이터
|
|
||||||
* **MD**: `clamp(18px, 2.5vmin + 0.5vw, 30px)` - 섹션 소제목
|
|
||||||
* **LG**: `clamp(24px, 4vmin + 0.6vw, 48px)` - 페이지 대제목
|
|
||||||
* **XL**: `clamp(32px, 6vmin + 0.8vw, 72px)` - 핵심 통계 지표
|
|
||||||
* **Layout Units**:
|
|
||||||
* **Header Height**: `clamp(50px, 8vmin, 90px)`
|
|
||||||
* **Base Spacing**: `clamp(0.75rem, 3vmin, 3rem)`
|
|
||||||
* **Radius**: `clamp(6px, 1.5vmin, 16px)`
|
|
||||||
|
|
||||||
### 3. 컬러 팔레트 (Vercel Stark Palette)
|
|
||||||
* **Primary**: `#171717` (Stark Black) - 텍스트, 주요 버튼, 강조 요소.
|
|
||||||
* **Secondary**: `#888888` (Mute) - 보조 텍스트, 비활성 아이콘.
|
|
||||||
* **Border**: `#ebebeb` (Hairline) - 정보 구분선.
|
|
||||||
* **Background**: `#ffffff` (Canvas), `#fafafa` (Soft), `#f5f5f5` (Soft 2).
|
|
||||||
* **Accents**: Blue(`#0070f3`), Orange(`#f5a623`), Danger(`#ee0000`).
|
|
||||||
|
|
||||||
### 4. 컴포넌트 및 레이아웃 규칙 (Component Rules)
|
|
||||||
* **Header & Navigation**:
|
|
||||||
* 상단 1열 통합 바 형태를 유지하며, GNB와 LNB를 동일 라인에 배치하여 공간을 효율적으로 사용합니다.
|
|
||||||
* **Unified Filter Bar**:
|
|
||||||
* 검색창과 필터는 상단 타이틀 바로 아래(기존 액션 버튼 라인)까지 올려서 배치합니다.
|
|
||||||
* **Action Group**: '자산 추가', '부품 마스터' 등의 주요 액션 버튼은 검색창과 같은 라인의 최우측에 정렬합니다.
|
|
||||||
* **Dashboard**:
|
|
||||||
* **Single-Screen View**: 1920*1080(또는 1920*919) 해상도에서 스크롤 없이 한 화면에 핵심 정보가 모두 보이도록 최적화합니다.
|
|
||||||
* **Fixed Charts**: 차트 내부 숫자나 요소에 애니메이션(`animation: false`) 및 플로팅 레이블을 배제하여 정적인 안정성을 확보합니다.
|
|
||||||
* **Footer**:
|
|
||||||
* 화면 최하단에 위치하며, 텍스트는 **우측 정렬(Right-aligned)**합니다.
|
|
||||||
* 상단에 1px 헤어라인 구분선을 가집니다.
|
|
||||||
* **Security & UX**:
|
|
||||||
* **Text Selection**: 사용자의 실수에 의한 UI 드래그 방지를 위해 입력창(`input`, `textarea`)을 제외한 전체 영역의 텍스트 선택을 차단합니다.
|
|
||||||
* **View Toggle**: '서버' 탭 등 특정 탭에서만 '목록보기' 체크박스를 통해 뷰를 전환하며, 그 외 화면은 리스트 중심의 UI를 제공합니다.
|
|
||||||
729
doc_readme.md
Normal file
@@ -0,0 +1,729 @@
|
|||||||
|
# ITAM 도커라이징 실전 가이드
|
||||||
|
|
||||||
|
## 1. 문서 목적
|
||||||
|
|
||||||
|
이 문서는 Gitea에 올라가 있는 현재 저장소를 기준으로, 개발 PC에 WSL2와 Ubuntu만 설치되어 있는 상태에서 지금의 Docker 실행 구조를 재현하는 방법을 처음부터 끝까지 설명하는 실전 가이드다.
|
||||||
|
|
||||||
|
이 문서는 아래 상황을 가정한다.
|
||||||
|
|
||||||
|
1. 소스 코드는 아직 로컬에 없거나, Gitea에서 막 받아올 예정이다.
|
||||||
|
2. Windows에는 WSL2와 Ubuntu는 설치되어 있다.
|
||||||
|
3. 그 외 Docker 관련 세팅은 아직 안 되어 있을 수 있다.
|
||||||
|
4. 최종 목표는 현재 저장소 기준 `frontend + backend + external DB` 구조를 Docker로 재현하는 것이다.
|
||||||
|
|
||||||
|
이 문서의 목적은 아래 네 가지다.
|
||||||
|
|
||||||
|
1. 현재 시스템 구조와 Docker 구조를 먼저 이해하게 한다.
|
||||||
|
2. 기존 파일 중 무엇이 새로 추가되었고 무엇이 수정되었는지 정리한다.
|
||||||
|
3. 각 단계별로 정확히 어디에서 명령을 실행해야 하는지 명시한다.
|
||||||
|
4. Gitea 소스만 받은 상태에서 지금과 같은 Docker 실행 상태까지 도달하게 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 현재 시스템 구조 개요
|
||||||
|
|
||||||
|
## 2.1 애플리케이션 원래 구조
|
||||||
|
|
||||||
|
현재 저장소의 본래 실행 구조는 다음과 같다.
|
||||||
|
|
||||||
|
1. 프런트엔드: Vite 기반 TypeScript 앱
|
||||||
|
2. 백엔드: Express 기반 Node.js API 서버
|
||||||
|
3. 데이터베이스: 외부 MySQL 서버
|
||||||
|
|
||||||
|
즉, 원래부터 MySQL이 Docker 안에 들어 있던 구조가 아니다.
|
||||||
|
|
||||||
|
프런트와 백엔드는 각각 별도 프로세스로 실행되며, 프런트는 `/api` 상대 경로로 백엔드 API를 호출한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2.2 현재 Docker 구조
|
||||||
|
|
||||||
|
현재 최종 Docker 구조는 아래와 같다.
|
||||||
|
|
||||||
|
1. `frontend` 컨테이너
|
||||||
|
2. `backend` 컨테이너
|
||||||
|
3. 외부 MySQL DB
|
||||||
|
|
||||||
|
즉, 지금은 내부 `db` 컨테이너가 없고, 내부 `db-bootstrap` 컨테이너도 없다.
|
||||||
|
|
||||||
|
현재 구조를 문장으로 풀면 다음과 같다.
|
||||||
|
|
||||||
|
1. 브라우저는 `http://localhost:8080`으로 `frontend` 컨테이너에 접속한다.
|
||||||
|
2. `frontend`는 `/api` 요청을 `backend:3000`으로 프록시한다.
|
||||||
|
3. `backend`는 `.env`에 적힌 외부 DB 정보로 외부 MySQL에 직접 접속한다.
|
||||||
|
4. 조회 결과 JSON을 프런트가 받아 화면에 렌더링한다.
|
||||||
|
|
||||||
|
간단한 흐름은 아래와 같다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Browser
|
||||||
|
-> frontend container :8080
|
||||||
|
-> Vite proxy (/api)
|
||||||
|
-> backend container :3000
|
||||||
|
-> external MySQL (.env)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2.3 왜 이 구조가 맞는가
|
||||||
|
|
||||||
|
현재 구조가 적절한 이유는 다음과 같다.
|
||||||
|
|
||||||
|
1. 원래 시스템도 외부 MySQL을 쓰는 구조였다.
|
||||||
|
2. 지금 목표는 운영형 단일 배포가 아니라 현재 개발형 구조를 Docker로 재현하는 것이다.
|
||||||
|
3. 프런트는 Vite dev server 기반이라 운영형 nginx 정적 배포 구조로 억지로 바꾸는 것보다, 현 구조를 유지하는 편이 안전하다.
|
||||||
|
4. 실무 표준 관점에서도 앱 컨테이너는 무상태로 유지하고, DB는 외부 인프라를 사용하는 구성이 더 일반적이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 이번 도커라이징에서 추가되거나 수정된 파일 정리
|
||||||
|
|
||||||
|
아래 파일들은 이번 Docker 재현 구조를 위해 새로 추가되었거나 수정된 핵심 파일이다.
|
||||||
|
|
||||||
|
## 3.1 새로 추가된 파일
|
||||||
|
|
||||||
|
1. `Dockerfile.frontend`
|
||||||
|
2. `Dockerfile.backend`
|
||||||
|
3. `.dockerignore`
|
||||||
|
4. `docker-compose.yaml`
|
||||||
|
5. `start_docker_wsl.ps1`
|
||||||
|
6. `stop_docker_wsl.ps1`
|
||||||
|
7. `start_docker_wsl.bat`
|
||||||
|
8. `stop_docker_wsl.bat`
|
||||||
|
9. `docker/mysql/init/README.md`
|
||||||
|
10. `docker_task_plan.md`
|
||||||
|
11. `doc_readme2.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3.2 기존 파일 중 수정된 핵심 파일
|
||||||
|
|
||||||
|
1. `server.js`
|
||||||
|
2. `vite.config.ts`
|
||||||
|
3. `doc_readme.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3.3 각 파일의 역할
|
||||||
|
|
||||||
|
### `Dockerfile.frontend`
|
||||||
|
|
||||||
|
역할:
|
||||||
|
|
||||||
|
1. 프런트 Vite 개발 서버 이미지를 만든다.
|
||||||
|
2. 컨테이너 내부에서 `npm run dev -- --host 0.0.0.0`를 실행한다.
|
||||||
|
|
||||||
|
### `Dockerfile.backend`
|
||||||
|
|
||||||
|
역할:
|
||||||
|
|
||||||
|
1. 백엔드 Express 서버 이미지를 만든다.
|
||||||
|
2. 컨테이너 내부에서 `npm run server`를 실행한다.
|
||||||
|
|
||||||
|
### `.dockerignore`
|
||||||
|
|
||||||
|
역할:
|
||||||
|
|
||||||
|
1. `node_modules`, `build`, `.git`, `.env`, `uploads` 같은 불필요한 파일을 Docker build context에서 제외한다.
|
||||||
|
|
||||||
|
### `docker-compose.yaml`
|
||||||
|
|
||||||
|
역할:
|
||||||
|
|
||||||
|
1. `frontend`, `backend` 두 컨테이너를 동시에 띄운다.
|
||||||
|
2. `backend`는 `.env`의 외부 DB를 사용한다.
|
||||||
|
3. `frontend`는 `backend:3000`으로 프록시한다.
|
||||||
|
|
||||||
|
### `start_docker_wsl.ps1`
|
||||||
|
|
||||||
|
역할:
|
||||||
|
|
||||||
|
1. Windows 경로를 WSL 경로로 안전하게 바꾼다.
|
||||||
|
2. WSL 내부 Docker를 사용해 `docker compose up --build -d`를 실행한다.
|
||||||
|
3. 한글 경로와 공백 경로에서도 안정적으로 실행되게 한다.
|
||||||
|
|
||||||
|
### `stop_docker_wsl.ps1`
|
||||||
|
|
||||||
|
역할:
|
||||||
|
|
||||||
|
1. 같은 방식으로 WSL 내부에서 `docker compose down`을 실행한다.
|
||||||
|
|
||||||
|
### `start_docker_wsl.bat`, `stop_docker_wsl.bat`
|
||||||
|
|
||||||
|
역할:
|
||||||
|
|
||||||
|
1. PowerShell 스크립트를 쉽게 실행하는 래퍼 역할을 한다.
|
||||||
|
|
||||||
|
### `server.js`
|
||||||
|
|
||||||
|
중요 수정 사항:
|
||||||
|
|
||||||
|
1. `dotenv.config({ override: true })`가 아니라 `dotenv.config()`를 사용한다.
|
||||||
|
|
||||||
|
이유:
|
||||||
|
|
||||||
|
1. Compose나 실행 환경이 주는 환경변수를 `.env`가 덮어써 버리면 안 된다.
|
||||||
|
2. 외부 DB 정보와 포트 설정 등 실행 환경 우선 구조를 유지해야 한다.
|
||||||
|
|
||||||
|
### `vite.config.ts`
|
||||||
|
|
||||||
|
중요 수정 사항:
|
||||||
|
|
||||||
|
1. 프록시 타깃을 고정 `localhost:3000`이 아니라 환경변수 기반으로 받도록 바꿨다.
|
||||||
|
|
||||||
|
현재 구조:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const proxyTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3000';
|
||||||
|
```
|
||||||
|
|
||||||
|
이유:
|
||||||
|
|
||||||
|
1. 로컬에서 직접 프런트를 띄울 때는 `localhost:3000`이 맞다.
|
||||||
|
2. Docker 안에서는 `frontend` 컨테이너에서 보는 `localhost`가 백엔드가 아니므로 `backend:3000`을 써야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 현재 `docker-compose.yaml` 기준 실제 동작 구조
|
||||||
|
|
||||||
|
현재 `docker-compose.yaml`은 아래 구조다.
|
||||||
|
|
||||||
|
### `backend`
|
||||||
|
|
||||||
|
1. `Dockerfile.backend`로 이미지를 빌드한다.
|
||||||
|
2. `.env`를 읽는다.
|
||||||
|
3. DB 관련 변수는 `${DB_HOST}`, `${DB_PORT}`, `${DB_USER}`, `${DB_PASS}`, `${DB_NAME}`를 그대로 사용한다.
|
||||||
|
4. 포트 `3000:3000`으로 노출한다.
|
||||||
|
5. `uploads`, `map_config.json`을 마운트한다.
|
||||||
|
|
||||||
|
### `frontend`
|
||||||
|
|
||||||
|
1. `Dockerfile.frontend`로 이미지를 빌드한다.
|
||||||
|
2. `VITE_DEV_PROXY_TARGET: http://backend:3000` 환경변수를 사용한다.
|
||||||
|
3. 포트 `8080:8080`으로 노출한다.
|
||||||
|
4. 브라우저의 `/api` 요청을 `backend`로 프록시한다.
|
||||||
|
|
||||||
|
즉, 현재 Compose는 DB를 띄우지 않고 앱 두 개만 띄운다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 사전 준비 사항
|
||||||
|
|
||||||
|
이 섹션은 Gitea에서 코드를 받기 전 또는 받은 직후에 확인해야 한다.
|
||||||
|
|
||||||
|
## 5.1 가정하는 기본 상태
|
||||||
|
|
||||||
|
이미 설치되어 있다고 가정하는 것:
|
||||||
|
|
||||||
|
1. Windows
|
||||||
|
2. WSL2
|
||||||
|
3. Ubuntu 배포판
|
||||||
|
|
||||||
|
아직 없을 수 있는 것:
|
||||||
|
|
||||||
|
1. Docker Desktop 또는 WSL 내부 Docker 사용 환경
|
||||||
|
2. Git 클라이언트
|
||||||
|
3. 프로젝트 `.env`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.2 권장 Docker 실행 방식
|
||||||
|
|
||||||
|
현재 저장소 구조상 가장 권장하는 방식은 다음이다.
|
||||||
|
|
||||||
|
1. Windows에 Docker Desktop 설치
|
||||||
|
2. Docker Desktop에서 WSL2 통합 활성화
|
||||||
|
3. Ubuntu WSL 내부에서 `docker` 명령을 사용할 수 있게 한다.
|
||||||
|
|
||||||
|
이유:
|
||||||
|
|
||||||
|
1. 현재 `start_docker_wsl.ps1`가 WSL 내부의 `docker`를 호출하는 구조다.
|
||||||
|
2. 실제 검증도 WSL 내부 Docker 기준으로 이루어졌다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.3 외부 DB 정보 준비
|
||||||
|
|
||||||
|
현재 구조는 외부 MySQL을 사용하므로 `.env` 파일이 반드시 필요하다.
|
||||||
|
|
||||||
|
최소한 아래 값이 필요하다.
|
||||||
|
|
||||||
|
```env
|
||||||
|
DB_HOST=<외부 MySQL 호스트>
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=<외부 MySQL 계정>
|
||||||
|
DB_PASS=<외부 MySQL 비밀번호>
|
||||||
|
DB_NAME=itam
|
||||||
|
```
|
||||||
|
|
||||||
|
필요 시 추가 환경변수는 현재 백엔드 코드 기준으로 함께 넣을 수 있다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Gitea에서 소스 받기
|
||||||
|
|
||||||
|
## 6.1 작업 실행 위치
|
||||||
|
|
||||||
|
이 단계는 **Windows PowerShell** 또는 **Windows 터미널의 PowerShell**에서 수행한다.
|
||||||
|
|
||||||
|
실행 위치 이유:
|
||||||
|
|
||||||
|
1. 이후 `start_docker_wsl.ps1`도 Windows PowerShell에서 실행하는 것이 가장 자연스럽다.
|
||||||
|
2. 로컬 작업 폴더를 Windows 경로 기준으로 준비할 수 있다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.2 소스 클론
|
||||||
|
|
||||||
|
예시:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git clone <Gitea 저장소 URL>
|
||||||
|
cd <클론된 저장소 경로>
|
||||||
|
```
|
||||||
|
|
||||||
|
현재 프로젝트처럼 한글 경로를 사용할 수도 있지만, 가능하면 너무 복잡한 경로는 피하는 것이 좋다.
|
||||||
|
|
||||||
|
현재 실제 프로젝트 경로 예시는 아래였다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
c:\Users\user\Desktop\안건 파일\itam
|
||||||
|
```
|
||||||
|
|
||||||
|
이 경로도 현재 스크립트로는 동작 가능하다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Docker 환경 준비
|
||||||
|
|
||||||
|
## 7.1 작업 실행 위치
|
||||||
|
|
||||||
|
이 단계는 **Windows PowerShell**과 **WSL Ubuntu 터미널**을 둘 다 사용한다.
|
||||||
|
|
||||||
|
1. 설치 확인은 Windows PowerShell에서 시작
|
||||||
|
2. 실제 Docker 동작 확인은 WSL Ubuntu에서 수행
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7.2 Docker Desktop 설치 여부 확인
|
||||||
|
|
||||||
|
**실행 위치: Windows PowerShell**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker version
|
||||||
|
```
|
||||||
|
|
||||||
|
만약 여기서 바로 안 잡혀도 현재 프로젝트는 WSL 내부 Docker를 쓰므로, 다음 단계로 넘어가 WSL 내부 확인을 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7.3 WSL 내부 Docker 확인
|
||||||
|
|
||||||
|
**실행 위치: Windows PowerShell**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wsl -l -v
|
||||||
|
wsl sh -lc "docker --version"
|
||||||
|
```
|
||||||
|
|
||||||
|
정상 기대 결과:
|
||||||
|
|
||||||
|
1. Ubuntu가 Running 상태
|
||||||
|
2. `docker --version`이 정상 출력
|
||||||
|
|
||||||
|
만약 `docker --version`이 실패하면, Docker Desktop 설치 및 WSL 통합을 먼저 완료해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. `.env` 파일 준비
|
||||||
|
|
||||||
|
## 8.1 작업 실행 위치
|
||||||
|
|
||||||
|
이 단계는 **Windows PowerShell**, **VS Code**, 또는 아무 텍스트 편집기**에서 수행한다.
|
||||||
|
|
||||||
|
즉, 프로젝트 루트에 `.env` 파일을 만드는 작업이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8.2 `.env` 작성
|
||||||
|
|
||||||
|
프로젝트 루트에 `.env`를 만든다.
|
||||||
|
|
||||||
|
예시:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DB_HOST=your-external-db-host
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=your-db-user
|
||||||
|
DB_PASS=your-db-password
|
||||||
|
DB_NAME=itam
|
||||||
|
```
|
||||||
|
|
||||||
|
주의:
|
||||||
|
|
||||||
|
1. 현재 Compose는 내부 DB를 만들지 않는다.
|
||||||
|
2. 따라서 이 값이 곧 실제 운영/개발 외부 DB 연결 정보다.
|
||||||
|
3. 이 정보가 틀리면 `backend`는 기동해도 API에서 DB 오류가 난다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 현재 Docker 파일이 어떻게 동작하는지 이해하기
|
||||||
|
|
||||||
|
## 9.1 `Dockerfile.frontend`
|
||||||
|
|
||||||
|
**확인 위치: 프로젝트 루트 / VS Code**
|
||||||
|
|
||||||
|
현재 내용 핵심:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||||
|
```
|
||||||
|
|
||||||
|
의미:
|
||||||
|
|
||||||
|
1. Node 20 Alpine 기반
|
||||||
|
2. 의존성 설치 후 전체 소스 복사
|
||||||
|
3. Vite 개발 서버 실행
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9.2 `Dockerfile.backend`
|
||||||
|
|
||||||
|
**확인 위치: 프로젝트 루트 / VS Code**
|
||||||
|
|
||||||
|
현재 내용 핵심:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["npm", "run", "server"]
|
||||||
|
```
|
||||||
|
|
||||||
|
의미:
|
||||||
|
|
||||||
|
1. Node 20 Alpine 기반
|
||||||
|
2. Express 서버 실행
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9.3 `vite.config.ts`
|
||||||
|
|
||||||
|
**확인 위치: 프로젝트 루트 / VS Code**
|
||||||
|
|
||||||
|
현재 핵심:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const proxyTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3000';
|
||||||
|
```
|
||||||
|
|
||||||
|
그리고 `/api`, `/uploads`가 모두 `proxyTarget`으로 프록시된다.
|
||||||
|
|
||||||
|
의미:
|
||||||
|
|
||||||
|
1. 로컬 실행 시 기본값은 `localhost:3000`
|
||||||
|
2. Docker 실행 시 Compose가 `http://backend:3000`을 주입
|
||||||
|
|
||||||
|
이 수정이 있어야 Docker 안에서도 화면에 데이터가 표시된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Docker Compose 기동
|
||||||
|
|
||||||
|
## 10.1 작업 실행 위치
|
||||||
|
|
||||||
|
이 단계는 반드시 **Windows PowerShell**에서 수행하는 것을 권장한다.
|
||||||
|
|
||||||
|
이유:
|
||||||
|
|
||||||
|
1. `start_docker_wsl.ps1`가 Windows 경로를 받아 WSL 경로로 바꾸는 구조다.
|
||||||
|
2. 한글/공백 경로에서 가장 안전하다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10.2 권장 기동 방법
|
||||||
|
|
||||||
|
**실행 위치: 프로젝트 루트의 Windows PowerShell**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\start_docker_wsl.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
또는
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\start_docker_wsl.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
이 스크립트는 내부적으로 아래를 수행한다.
|
||||||
|
|
||||||
|
1. PowerShell 출력 인코딩을 UTF-8로 설정
|
||||||
|
2. 현재 Windows 경로를 WSL 경로로 변환
|
||||||
|
3. WSL 동작 확인
|
||||||
|
4. WSL 내부 Docker 동작 확인
|
||||||
|
5. `docker compose up --build -d` 수행
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10.3 직접 기동이 필요할 때
|
||||||
|
|
||||||
|
**실행 위치: WSL Ubuntu 터미널**
|
||||||
|
|
||||||
|
직접 실행 예시는 아래와 같다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/c/Users/user/Desktop/안건\ 파일/itam
|
||||||
|
docker compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
하지만 현재 프로젝트는 한글 경로 이슈가 있었기 때문에, 특별한 이유가 없으면 `start_docker_wsl.ps1`를 우선 사용한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 컨테이너 기동 후 검증
|
||||||
|
|
||||||
|
## 11.1 컨테이너 상태 확인
|
||||||
|
|
||||||
|
**실행 위치: Windows PowerShell**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wsl sh -lc "docker ps -a --format 'table {{.Names}}\t{{.Status}}' | grep itam"
|
||||||
|
```
|
||||||
|
|
||||||
|
정상 기대 상태:
|
||||||
|
|
||||||
|
1. `itam-backend` -> `Up`
|
||||||
|
2. `itam-frontend` -> `Up`
|
||||||
|
|
||||||
|
현재는 `itam-db`, `itam-db-bootstrap`가 없어야 정상이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11.2 백엔드 API 확인
|
||||||
|
|
||||||
|
**실행 위치: Windows PowerShell**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest -Uri http://localhost:3000/api/assets/master -UseBasicParsing | Select-Object -ExpandProperty StatusCode
|
||||||
|
```
|
||||||
|
|
||||||
|
정상 기대값:
|
||||||
|
|
||||||
|
1. `200`
|
||||||
|
|
||||||
|
이 검사는 `backend`가 외부 DB에 정상 연결됐는지 보는 가장 직접적인 검사다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11.3 프런트 경유 API 확인
|
||||||
|
|
||||||
|
**실행 위치: Windows PowerShell**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest -Uri http://localhost:8080/api/assets/master -UseBasicParsing | Select-Object -ExpandProperty StatusCode
|
||||||
|
```
|
||||||
|
|
||||||
|
정상 기대값:
|
||||||
|
|
||||||
|
1. `200`
|
||||||
|
|
||||||
|
이 검사는 프런트 프록시가 정상인지 확인한다.
|
||||||
|
|
||||||
|
예전에 화면에 데이터가 안 보였던 것은 외부 DB 자체가 아니라, 이 프록시 경로가 잘못돼 있었기 때문이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11.4 브라우저 화면 확인
|
||||||
|
|
||||||
|
**실행 위치: 브라우저**
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
확인 포인트:
|
||||||
|
|
||||||
|
1. 화면이 열리는지
|
||||||
|
2. 목록/대시보드/테이블 데이터가 비어 있지 않은지
|
||||||
|
3. 모달 진입 시 데이터가 정상적으로 보이는지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 지금 데이터가 표시되는 원리
|
||||||
|
|
||||||
|
현재는 내부 DB로 데이터를 옮겨 담지 않는다.
|
||||||
|
|
||||||
|
현재 실제 동작 원리는 다음과 같다.
|
||||||
|
|
||||||
|
1. 브라우저가 `frontend`에 접속한다.
|
||||||
|
2. 프런트가 `/api/...`로 요청한다.
|
||||||
|
3. Vite 프록시가 `backend:3000`으로 요청을 넘긴다.
|
||||||
|
4. `backend`가 `.env`의 외부 MySQL에 직접 접속한다.
|
||||||
|
5. 조회 결과 JSON을 프런트가 받아 화면에 렌더링한다.
|
||||||
|
|
||||||
|
즉, 현재는 아래 구조다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Browser -> frontend -> backend -> external MySQL
|
||||||
|
```
|
||||||
|
|
||||||
|
예전 외부 DB 구조에서 화면에 데이터가 안 보였던 이유는 외부 DB 때문이 아니라, 프런트 컨테이너가 `localhost:3000`을 잘못 바라보고 있었기 때문이다.
|
||||||
|
|
||||||
|
지금은 `VITE_DEV_PROXY_TARGET: http://backend:3000`으로 수정되어 있기 때문에 정상 표시된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 자주 헷갈리는 포인트
|
||||||
|
|
||||||
|
## 13.1 현재는 내부 DB 컨테이너가 없다
|
||||||
|
|
||||||
|
현재 `docker-compose.yaml`에는 아래가 없다.
|
||||||
|
|
||||||
|
1. `db` 서비스
|
||||||
|
2. `db-bootstrap` 서비스
|
||||||
|
3. `itam_mysql_data` 볼륨
|
||||||
|
|
||||||
|
즉, DB는 Docker 스택 밖에 있다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13.2 현재는 `.env`가 곧 실제 DB 연결 정보다
|
||||||
|
|
||||||
|
현재 `backend`는 아래처럼 Compose에서 그대로 받는다.
|
||||||
|
|
||||||
|
1. `DB_HOST: ${DB_HOST}`
|
||||||
|
2. `DB_PORT: ${DB_PORT}`
|
||||||
|
3. `DB_USER: ${DB_USER}`
|
||||||
|
4. `DB_PASS: ${DB_PASS}`
|
||||||
|
5. `DB_NAME: ${DB_NAME}`
|
||||||
|
|
||||||
|
즉, `.env`를 틀리게 적으면 화면도 데이터가 안 뜬다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13.3 `server.js`는 여전히 중요하게 수정된 상태다
|
||||||
|
|
||||||
|
현재 `server.js`는 `dotenv.config()`를 사용한다.
|
||||||
|
|
||||||
|
이 구조는 이후 Compose나 실행 환경에서 변수를 주입할 때, 애플리케이션이 그 값을 받아들일 수 있게 하기 위해 유지해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 스택 중지 방법
|
||||||
|
|
||||||
|
## 14.1 작업 실행 위치
|
||||||
|
|
||||||
|
**Windows PowerShell / 프로젝트 루트**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14.2 권장 종료 명령
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\stop_docker_wsl.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
또는
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\stop_docker_wsl.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
이 스크립트는 내부적으로 WSL 경로 변환 후 `docker compose down`을 수행한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 장애 발생 시 점검 순서
|
||||||
|
|
||||||
|
## 15.1 `frontend` 화면은 뜨는데 데이터가 없을 때
|
||||||
|
|
||||||
|
**실행 위치: Windows PowerShell**
|
||||||
|
|
||||||
|
먼저 아래 두 API를 분리해서 본다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest -Uri http://localhost:3000/api/assets/master -UseBasicParsing | Select-Object -ExpandProperty StatusCode
|
||||||
|
Invoke-WebRequest -Uri http://localhost:8080/api/assets/master -UseBasicParsing | Select-Object -ExpandProperty StatusCode
|
||||||
|
```
|
||||||
|
|
||||||
|
판단 기준:
|
||||||
|
|
||||||
|
1. `3000`은 200이고 `8080`만 실패 -> 프런트 프록시 문제
|
||||||
|
2. 둘 다 실패 -> 백엔드 또는 외부 DB 연결 문제
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15.2 백엔드가 외부 DB에 연결되지 않을 때
|
||||||
|
|
||||||
|
**실행 위치: Windows PowerShell**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wsl sh -lc "docker logs --tail=200 itam-backend"
|
||||||
|
```
|
||||||
|
|
||||||
|
점검 항목:
|
||||||
|
|
||||||
|
1. `.env`의 DB 정보가 정확한지
|
||||||
|
2. 외부 DB 서버 접근이 가능한지
|
||||||
|
3. 계정/비밀번호가 맞는지
|
||||||
|
4. 방화벽 또는 네트워크 이슈가 없는지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15.3 프런트 프록시가 의심될 때
|
||||||
|
|
||||||
|
**확인 위치: `vite.config.ts`, `docker-compose.yaml`**
|
||||||
|
|
||||||
|
다음 두 설정이 유지되는지 확인한다.
|
||||||
|
|
||||||
|
`vite.config.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const proxyTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3000';
|
||||||
|
```
|
||||||
|
|
||||||
|
`docker-compose.yaml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
VITE_DEV_PROXY_TARGET: http://backend:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
이 둘 중 하나라도 바뀌면 Docker 안에서 화면 데이터가 다시 안 보일 수 있다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. 현재 기준 재현 절차 요약
|
||||||
|
|
||||||
|
가장 짧게 정리하면 아래 순서다.
|
||||||
|
|
||||||
|
1. Gitea에서 소스를 클론한다.
|
||||||
|
2. Windows PowerShell에서 프로젝트 루트로 이동한다.
|
||||||
|
3. `.env`에 외부 MySQL 정보를 작성한다.
|
||||||
|
4. Docker Desktop + WSL 통합 또는 WSL 내부 Docker 사용 가능 상태를 만든다.
|
||||||
|
5. `start_docker_wsl.ps1`를 실행한다.
|
||||||
|
6. `http://localhost:3000/api/assets/master`가 200인지 확인한다.
|
||||||
|
7. `http://localhost:8080/api/assets/master`가 200인지 확인한다.
|
||||||
|
8. 브라우저에서 `http://localhost:8080`을 열어 실제 데이터 표시를 확인한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. 현재 최종 결론
|
||||||
|
|
||||||
|
현재 저장소의 도커라이징 구조는 실무 표준에 맞는 `무상태 앱 컨테이너 + 외부 DB` 구조다.
|
||||||
|
|
||||||
|
현재 핵심은 아래 세 가지다.
|
||||||
|
|
||||||
|
1. `backend`는 외부 MySQL에 직접 연결한다.
|
||||||
|
2. `frontend`는 `backend:3000`으로 API 프록시한다.
|
||||||
|
3. WSL 경로 변환 스크립트를 통해 Windows 한글 경로에서도 안정적으로 실행한다.
|
||||||
|
|
||||||
|
즉, 이 문서대로 진행하면 Gitea 소스만 받은 상태에서 지금과 같은 Docker 실행 구조를 재현할 수 있다.
|
||||||
730
doc_readme2.md
Normal file
@@ -0,0 +1,730 @@
|
|||||||
|
# ITAM 도커라이징 최종 재현 가이드
|
||||||
|
|
||||||
|
## 1. 문서 목적
|
||||||
|
|
||||||
|
이 문서는 현재 Git 저장소에 올라간 파일만 가지고, 지금과 동일한 수준으로 ITAM 시스템을 도커라이징하고 실행하는 절차를 처음부터 끝까지 정리한 최종 가이드다.
|
||||||
|
|
||||||
|
이 문서만 읽어도 아래 목표를 달성할 수 있게 작성한다.
|
||||||
|
|
||||||
|
1. 현재 저장소 구조를 이해한다.
|
||||||
|
2. 왜 이렇게 도커라이징했는지 판단 근거를 안다.
|
||||||
|
3. WSL2 기반으로 실제 스택을 기동한다.
|
||||||
|
4. 외부 MySQL에서 내부 MySQL 컨테이너로 초기 데이터를 bootstrap 한다.
|
||||||
|
5. 프런트 8080과 백엔드 3000이 모두 정상 동작하는지 검증한다.
|
||||||
|
6. 재초기화, 재기동, 장애 확인까지 수행한다.
|
||||||
|
|
||||||
|
이 문서는 최종 성공 구조 기준이다. 실패 기록은 `doc_readme.md`를 본다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 최종 목표 구조
|
||||||
|
|
||||||
|
현재 최종 구조는 아래 4개 서비스/역할로 나뉜다.
|
||||||
|
|
||||||
|
1. `frontend`: Vite 개발 서버 컨테이너, 포트 8080
|
||||||
|
2. `backend`: Express API 서버 컨테이너, 포트 3000
|
||||||
|
3. `db`: MySQL 8 컨테이너, 포트 3306
|
||||||
|
4. `db-bootstrap`: 외부 MySQL -> 내부 MySQL로 1회성 복제 수행 후 종료되는 도우미 컨테이너
|
||||||
|
|
||||||
|
논리 흐름은 다음과 같다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
브라우저 -> frontend:8080 -> Vite proxy -> backend:3000 -> db:3306
|
||||||
|
\
|
||||||
|
-> /uploads -> backend 정적 경로
|
||||||
|
|
||||||
|
초기 1회 기동 시
|
||||||
|
외부 MySQL(.env) -> db-bootstrap -> 내부 MySQL(db)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 왜 이 구조를 선택했는가
|
||||||
|
|
||||||
|
이 저장소는 처음부터 운영형 정적 배포 앱이 아니었다. 실제 구조는 다음과 같았다.
|
||||||
|
|
||||||
|
1. 프런트는 Vite 개발 서버가 따로 돈다.
|
||||||
|
2. 백엔드는 Express API가 따로 돈다.
|
||||||
|
3. 프런트는 상대 경로 `/api`를 호출한다.
|
||||||
|
4. 백엔드는 프런트의 `dist`를 서빙하지 않는다.
|
||||||
|
|
||||||
|
따라서 내일 바로 시연 가능한 수준까지 빠르게 안정화하려면 아래 전략이 가장 맞다.
|
||||||
|
|
||||||
|
1. 프런트를 Vite dev server 그대로 컨테이너화한다.
|
||||||
|
2. 백엔드를 별도 컨테이너로 유지한다.
|
||||||
|
3. DB는 MySQL 8 컨테이너로 묶되, 초기 데이터는 외부 DB에서 복제한다.
|
||||||
|
4. 프런트 프록시는 컨테이너 네트워크 서비스명 `backend`로 붙게 한다.
|
||||||
|
|
||||||
|
즉, 현재 구조는 "개발형 구조를 Docker로 재현한 시연/개발용 Compose"다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 저장소 내 최종 관련 파일 목록
|
||||||
|
|
||||||
|
현재 도커라이징과 직접 관련된 핵심 파일은 아래와 같다.
|
||||||
|
|
||||||
|
1. `.dockerignore`
|
||||||
|
2. `Dockerfile.frontend`
|
||||||
|
3. `Dockerfile.backend`
|
||||||
|
4. `docker-compose.yaml`
|
||||||
|
5. `start_docker_wsl.ps1`
|
||||||
|
6. `stop_docker_wsl.ps1`
|
||||||
|
7. `start_docker_wsl.bat`
|
||||||
|
8. `stop_docker_wsl.bat`
|
||||||
|
9. `docker/mysql/init/README.md`
|
||||||
|
10. `server.js`
|
||||||
|
11. `vite.config.ts`
|
||||||
|
|
||||||
|
각 파일 역할은 다음과 같다.
|
||||||
|
|
||||||
|
### 4.1 `.dockerignore`
|
||||||
|
|
||||||
|
Docker build context에서 제외할 파일을 정의한다.
|
||||||
|
|
||||||
|
주요 제외 대상은 다음과 같다.
|
||||||
|
|
||||||
|
1. `node_modules`
|
||||||
|
2. `dist`
|
||||||
|
3. `build`
|
||||||
|
4. `.git`
|
||||||
|
5. `.env`
|
||||||
|
6. `uploads`
|
||||||
|
7. `*.xlsx`
|
||||||
|
8. `*.log`
|
||||||
|
|
||||||
|
### 4.2 `Dockerfile.frontend`
|
||||||
|
|
||||||
|
프런트 컨테이너 이미지 정의다.
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||||
|
```
|
||||||
|
|
||||||
|
이 이미지는 Vite dev server를 컨테이너에서 띄우기 위한 것이다.
|
||||||
|
|
||||||
|
### 4.3 `Dockerfile.backend`
|
||||||
|
|
||||||
|
백엔드 컨테이너 이미지 정의다.
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["npm", "run", "server"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 `docker-compose.yaml`
|
||||||
|
|
||||||
|
전체 스택의 핵심 파일이다.
|
||||||
|
|
||||||
|
현재 최종 구성은 다음 논리를 가진다.
|
||||||
|
|
||||||
|
1. `db`는 MySQL 8 내부 DB다.
|
||||||
|
2. `db-bootstrap`은 외부 DB 데이터를 내부 DB로 1회 복제한다.
|
||||||
|
3. `backend`는 내부 `db`에 붙는다.
|
||||||
|
4. `frontend`는 `backend` 서비스명으로 프록시한다.
|
||||||
|
|
||||||
|
### 4.5 `start_docker_wsl.ps1`
|
||||||
|
|
||||||
|
Windows에서 WSL 경유로 Docker Compose를 안전하게 기동하는 진입점이다.
|
||||||
|
|
||||||
|
핵심은 다음 두 가지다.
|
||||||
|
|
||||||
|
1. 프로젝트 Windows 경로를 `wslpath`로 WSL 경로로 바꾼다.
|
||||||
|
2. 그 경로로 이동한 뒤 `docker compose up --build -d`를 수행한다.
|
||||||
|
|
||||||
|
### 4.6 `stop_docker_wsl.ps1`
|
||||||
|
|
||||||
|
같은 방식으로 WSL 내부에서 `docker compose down`을 수행해 스택을 안전하게 내린다.
|
||||||
|
|
||||||
|
### 4.7 `start_docker_wsl.bat`, `stop_docker_wsl.bat`
|
||||||
|
|
||||||
|
더블클릭 또는 간단 실행용 래퍼다. 내부적으로 PowerShell 스크립트를 호출한다.
|
||||||
|
|
||||||
|
### 4.8 `server.js`
|
||||||
|
|
||||||
|
중요 포인트는 다음 두 가지다.
|
||||||
|
|
||||||
|
1. `dotenv.config();`를 사용한다.
|
||||||
|
2. `dotenv.config({ override: true })`를 사용하지 않는다.
|
||||||
|
|
||||||
|
이 차이로 Compose 환경변수 `DB_HOST=db`가 `.env`보다 우선하도록 보장한다.
|
||||||
|
|
||||||
|
### 4.9 `vite.config.ts`
|
||||||
|
|
||||||
|
현재 프록시는 환경변수 기반으로 동작한다.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const proxyTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3000';
|
||||||
|
```
|
||||||
|
|
||||||
|
로컬 PC에서 직접 Vite를 띄우면 기본값 `http://localhost:3000`을 쓴다.
|
||||||
|
컨테이너에서는 Compose가 `http://backend:3000`을 주입한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 현재 최종 `docker-compose.yaml` 구조 설명
|
||||||
|
|
||||||
|
아래는 실제 동작 관점에서 읽어야 할 핵심 내용이다.
|
||||||
|
|
||||||
|
### 5.1 `db` 서비스
|
||||||
|
|
||||||
|
역할:
|
||||||
|
|
||||||
|
1. 내부 MySQL 데이터 저장소
|
||||||
|
2. 앱이 최종적으로 붙는 DB
|
||||||
|
|
||||||
|
핵심 설정:
|
||||||
|
|
||||||
|
1. 이미지: `mysql:8.0`
|
||||||
|
2. DB 이름: `itam`
|
||||||
|
3. 앱 계정: `itam_admin`
|
||||||
|
4. 데이터 볼륨: `itam_mysql_data`
|
||||||
|
5. healthcheck 사용
|
||||||
|
|
||||||
|
healthcheck는 `mysqladmin ping`으로 동작하며, `backend`와 `db-bootstrap`은 이 상태를 기다린다.
|
||||||
|
|
||||||
|
### 5.2 `db-bootstrap` 서비스
|
||||||
|
|
||||||
|
역할:
|
||||||
|
|
||||||
|
1. 외부 원본 DB에서 내부 `db`로 초기 데이터 복제
|
||||||
|
2. 1회성 작업 후 종료
|
||||||
|
|
||||||
|
핵심 포인트:
|
||||||
|
|
||||||
|
1. `.env`를 읽어 외부 DB 접속 정보를 가져온다.
|
||||||
|
2. 내부 `db`에 `asset_core` 테이블이 이미 존재하면 아무 것도 하지 않고 종료한다.
|
||||||
|
3. 그렇지 않으면 `mysqldump | mysql` 파이프라인으로 복제한다.
|
||||||
|
4. `restart: "no"` 이므로 정상 종료 후 반복 실행하지 않는다.
|
||||||
|
|
||||||
|
또한 source DB와 target DB 변수는 분리돼 있다.
|
||||||
|
|
||||||
|
1. source: `SOURCE_DB_*`
|
||||||
|
2. target: `TARGET_DB_*`
|
||||||
|
|
||||||
|
이 구조로 외부 원본 DB 자격증명과 내부 컨테이너 DB 자격증명이 섞이지 않는다.
|
||||||
|
|
||||||
|
### 5.3 `backend` 서비스
|
||||||
|
|
||||||
|
역할:
|
||||||
|
|
||||||
|
1. Express API 제공
|
||||||
|
2. 내부 `db`에 연결
|
||||||
|
3. `/uploads` 정적 제공
|
||||||
|
|
||||||
|
핵심 포인트:
|
||||||
|
|
||||||
|
1. `env_file: .env`를 유지하지만,
|
||||||
|
2. Compose `environment`에서 `DB_HOST=db`, `DB_PORT=3306`, `DB_USER=itam_admin`, `DB_PASS=itam1234`, `DB_NAME=itam`를 다시 지정한다.
|
||||||
|
3. `depends_on`은 `db` healthy와 `db-bootstrap` 성공 종료를 모두 기다린다.
|
||||||
|
|
||||||
|
즉, 백엔드는 DB bootstrap이 끝난 뒤 시작한다.
|
||||||
|
|
||||||
|
### 5.4 `frontend` 서비스
|
||||||
|
|
||||||
|
역할:
|
||||||
|
|
||||||
|
1. Vite dev server 제공
|
||||||
|
2. 브라우저 요청 `/api`, `/uploads`를 `backend`로 프록시
|
||||||
|
|
||||||
|
핵심 포인트:
|
||||||
|
|
||||||
|
1. `VITE_DEV_PROXY_TARGET: http://backend:3000`
|
||||||
|
2. `CHOKIDAR_USEPOLLING: "true"`
|
||||||
|
3. `npm run dev -- --host 0.0.0.0`
|
||||||
|
|
||||||
|
중요한 이유는 컨테이너 안의 `localhost`가 호스트의 `localhost`가 아니기 때문이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 사전 준비 조건
|
||||||
|
|
||||||
|
이 저장소를 지금처럼 기동하려면 다음 전제가 필요하다.
|
||||||
|
|
||||||
|
### 6.1 운영체제와 런타임
|
||||||
|
|
||||||
|
1. Windows
|
||||||
|
2. WSL2 Ubuntu 설치 및 실행 중
|
||||||
|
3. Docker CLI가 WSL 내부에서 동작 가능
|
||||||
|
|
||||||
|
권장 확인 명령:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wsl -l -v
|
||||||
|
wsl sh -lc "docker --version"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 `.env` 파일
|
||||||
|
|
||||||
|
현재 최종 구조는 "첫 기동 시 외부 DB에서 내부 DB로 bootstrap" 하는 방식이므로 `.env`가 반드시 필요하다.
|
||||||
|
|
||||||
|
최소한 다음 값은 외부 원본 DB를 가리켜야 한다.
|
||||||
|
|
||||||
|
```env
|
||||||
|
DB_HOST=<external-mysql-host>
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=<external-db-user>
|
||||||
|
DB_PASS=<external-db-password>
|
||||||
|
DB_NAME=itam
|
||||||
|
```
|
||||||
|
|
||||||
|
주의:
|
||||||
|
|
||||||
|
1. `.env`는 `db-bootstrap`이 외부 원본 DB에 접속할 때 사용한다.
|
||||||
|
2. `backend`는 최종적으로 내부 `db` 컨테이너를 쓰므로, 런타임에서는 Compose `environment`가 우선한다.
|
||||||
|
|
||||||
|
### 6.3 한글 경로 주의
|
||||||
|
|
||||||
|
현재 프로젝트 경로는 한글과 공백을 포함한다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
c:\Users\user\Desktop\안건 파일\itam
|
||||||
|
```
|
||||||
|
|
||||||
|
이 때문에 Docker 관련 명령은 수동으로 경로를 조립하지 말고, `start_docker_wsl.ps1` / `stop_docker_wsl.ps1`을 우선 사용해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 첫 기동 절차
|
||||||
|
|
||||||
|
이 절차는 "Git에서 소스를 받은 뒤 처음 올리는 경우" 기준이다.
|
||||||
|
|
||||||
|
### 7.1 저장소 준비
|
||||||
|
|
||||||
|
1. 저장소를 받는다.
|
||||||
|
2. `.env`가 올바른 외부 원본 DB를 가리키는지 확인한다.
|
||||||
|
3. WSL이 켜져 있는지 확인한다.
|
||||||
|
|
||||||
|
### 7.2 권장 실행 방법
|
||||||
|
|
||||||
|
Windows PowerShell에서 프로젝트 루트로 이동한 뒤 아래 중 하나를 사용한다.
|
||||||
|
|
||||||
|
방법 A:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\start_docker_wsl.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
방법 B:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\start_docker_wsl.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 내부 실행 순서
|
||||||
|
|
||||||
|
스크립트는 내부적으로 다음 순서로 동작한다.
|
||||||
|
|
||||||
|
1. 현재 Windows 경로를 WSL 경로로 변환한다.
|
||||||
|
2. WSL 동작 여부를 확인한다.
|
||||||
|
3. WSL 내부 Docker 사용 가능 여부를 확인한다.
|
||||||
|
4. `docker compose up --build -d`를 수행한다.
|
||||||
|
|
||||||
|
### 7.4 기대되는 컨테이너 순서
|
||||||
|
|
||||||
|
정상이라면 다음 순서로 올라온다.
|
||||||
|
|
||||||
|
1. `itam-db`
|
||||||
|
2. `itam-db-bootstrap`
|
||||||
|
3. `itam-backend`
|
||||||
|
4. `itam-frontend`
|
||||||
|
|
||||||
|
`itam-db-bootstrap`은 정상이라면 최종 상태가 `Exited (0)`이어야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 첫 기동 후 검증 절차
|
||||||
|
|
||||||
|
기동 후에는 반드시 아래 검증을 수행한다.
|
||||||
|
|
||||||
|
### 8.1 컨테이너 상태 확인
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wsl sh -lc "docker ps -a --format 'table {{.Names}}\t{{.Status}}' | grep itam"
|
||||||
|
```
|
||||||
|
|
||||||
|
정상 기대 상태:
|
||||||
|
|
||||||
|
1. `itam-db` -> `Up ... (healthy)`
|
||||||
|
2. `itam-db-bootstrap` -> `Exited (0)`
|
||||||
|
3. `itam-backend` -> `Up`
|
||||||
|
4. `itam-frontend` -> `Up`
|
||||||
|
|
||||||
|
### 8.2 백엔드 API 직접 확인
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest -Uri http://localhost:3000/api/assets/master -UseBasicParsing | Select-Object -ExpandProperty StatusCode
|
||||||
|
```
|
||||||
|
|
||||||
|
정상 기대값:
|
||||||
|
|
||||||
|
1. `200`
|
||||||
|
|
||||||
|
### 8.3 프런트 경유 API 확인
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest -Uri http://localhost:8080/api/assets/master -UseBasicParsing | Select-Object -ExpandProperty StatusCode
|
||||||
|
```
|
||||||
|
|
||||||
|
정상 기대값:
|
||||||
|
|
||||||
|
1. `200`
|
||||||
|
|
||||||
|
### 8.4 데이터가 실제로 들어왔는지 확인
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wsl sh -lc "docker exec itam-db mysql -uitam_admin -pitam1234 -D itam -e 'SHOW TABLES' | head -n 20"
|
||||||
|
```
|
||||||
|
|
||||||
|
정상이라면 아래와 같은 테이블들이 보여야 한다.
|
||||||
|
|
||||||
|
1. `asset_core`
|
||||||
|
2. `asset_remote`
|
||||||
|
3. `asset_spec`
|
||||||
|
4. `asset_location`
|
||||||
|
5. `asset_history`
|
||||||
|
6. `asset_software_perpetual`
|
||||||
|
7. `asset_software_subscription`
|
||||||
|
8. `hardware_components_master`
|
||||||
|
9. `job_spec_standards`
|
||||||
|
|
||||||
|
### 8.5 브라우저 화면 확인
|
||||||
|
|
||||||
|
브라우저에서 아래 주소를 연다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
목록/대시보드 데이터가 보이면 화면까지 정상 연결된 것이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 재기동 절차
|
||||||
|
|
||||||
|
코드만 수정됐고 DB는 유지하고 싶다면 다음처럼 하면 된다.
|
||||||
|
|
||||||
|
### 9.1 스택 종료
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\stop_docker_wsl.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
또는
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\stop_docker_wsl.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 스택 재기동
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\start_docker_wsl.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
이 경우 `itam_mysql_data` 볼륨이 유지되므로, `db-bootstrap`은 내부 DB에 `asset_core`가 이미 있음을 감지하고 빠르게 종료한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. DB를 완전히 다시 초기화하는 절차
|
||||||
|
|
||||||
|
외부 원본 DB에서 다시 처음부터 내부 DB를 복제하고 싶다면, MySQL 볼륨을 제거해야 한다.
|
||||||
|
|
||||||
|
### 10.1 스택 중지
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\stop_docker_wsl.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 MySQL 데이터 볼륨 삭제
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wsl sh -lc "docker volume rm -f itam_itam_mysql_data"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 다시 시작
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\start_docker_wsl.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
이때 `db-bootstrap`이 외부 DB에서 내부 DB로 전체를 다시 복제한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 현재 구조에서 꼭 알아야 할 설계 포인트
|
||||||
|
|
||||||
|
### 11.1 `server.js`의 `dotenv.config()` 변경 이유
|
||||||
|
|
||||||
|
백엔드가 내부 DB로 붙게 하려면 Compose가 준 환경변수가 `.env`보다 우선해야 한다.
|
||||||
|
|
||||||
|
만약 아래처럼 `override: true`를 쓰면 안 된다.
|
||||||
|
|
||||||
|
```js
|
||||||
|
dotenv.config({ override: true });
|
||||||
|
```
|
||||||
|
|
||||||
|
이렇게 되면 내부 `db`가 아니라 `.env`의 외부 DB로 다시 붙을 수 있다.
|
||||||
|
|
||||||
|
현재는 아래가 맞다.
|
||||||
|
|
||||||
|
```js
|
||||||
|
dotenv.config();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 왜 `docker-entrypoint-initdb.d` 기반 dump 파일을 안 쓰는가
|
||||||
|
|
||||||
|
처음에는 이 방식을 시도했지만, 실제 데이터의 긴 문자열/깨진 텍스트 때문에 import가 line 97에서 중단됐다.
|
||||||
|
|
||||||
|
그래서 현재는 더 안정적인 아래 방식을 쓴다.
|
||||||
|
|
||||||
|
1. 외부 DB에서 `mysqldump`
|
||||||
|
2. 파이프로 내부 `db`에 즉시 `mysql` import
|
||||||
|
|
||||||
|
즉, 파일 중간 생성물을 신뢰하지 않는 구조다.
|
||||||
|
|
||||||
|
### 11.3 왜 프런트 프록시 타깃을 환경변수화했는가
|
||||||
|
|
||||||
|
로컬 직접 실행과 컨테이너 실행의 네트워크 기준이 다르기 때문이다.
|
||||||
|
|
||||||
|
1. 로컬 직접 실행: `localhost:3000`이 맞다.
|
||||||
|
2. 컨테이너 내부 실행: `backend:3000`이 맞다.
|
||||||
|
|
||||||
|
그래서 `vite.config.ts`는 둘 다 수용할 수 있게 작성됐다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 문제 발생 시 진단 순서
|
||||||
|
|
||||||
|
이 프로젝트에서는 문제를 아래 순서로 자르면 가장 빠르다.
|
||||||
|
|
||||||
|
### 12.1 브라우저 화면에 데이터가 없을 때
|
||||||
|
|
||||||
|
먼저 다음 둘을 분리해서 본다.
|
||||||
|
|
||||||
|
1. `http://localhost:3000/api/assets/master`
|
||||||
|
2. `http://localhost:8080/api/assets/master`
|
||||||
|
|
||||||
|
판단 기준:
|
||||||
|
|
||||||
|
1. `3000`은 200이고 `8080`만 실패면 프런트 프록시 문제다.
|
||||||
|
2. 둘 다 실패면 백엔드 또는 DB 문제다.
|
||||||
|
|
||||||
|
### 12.2 DB bootstrap이 성공했는지 확인할 때
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wsl sh -lc "docker ps -a --format 'table {{.Names}}\t{{.Status}}' | grep itam"
|
||||||
|
```
|
||||||
|
|
||||||
|
여기서 `itam-db-bootstrap`이 `Exited (0)`인지 본다.
|
||||||
|
|
||||||
|
### 12.3 내부 DB에 실제 데이터가 있는지 확인할 때
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wsl sh -lc "docker exec itam-db mysql -uitam_admin -pitam1234 -D itam -e 'SHOW TABLES'"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.4 백엔드 로그 확인
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wsl sh -lc "docker logs --tail=200 itam-backend"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.5 DB 로그 확인
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wsl sh -lc "docker logs --tail=200 itam-db"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.6 프런트 로그 확인
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wsl sh -lc "docker logs --tail=200 itam-frontend"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 자주 나올 수 있는 장애와 해석
|
||||||
|
|
||||||
|
### 13.1 `docker` 명령이 PowerShell에서 안 보임
|
||||||
|
|
||||||
|
의미:
|
||||||
|
|
||||||
|
1. Windows 셸이 아니라 WSL에서 Docker를 쓰는 환경이다.
|
||||||
|
|
||||||
|
대응:
|
||||||
|
|
||||||
|
1. `start_docker_wsl.ps1` 사용
|
||||||
|
|
||||||
|
### 13.2 `asset_core` 테이블 없음
|
||||||
|
|
||||||
|
의미:
|
||||||
|
|
||||||
|
1. 내부 DB 초기화가 안 됐거나 bootstrap이 안 끝났다.
|
||||||
|
|
||||||
|
대응:
|
||||||
|
|
||||||
|
1. `db-bootstrap` 상태 확인
|
||||||
|
2. `.env` 외부 DB 접속 정보 확인
|
||||||
|
3. 필요하면 볼륨 삭제 후 재초기화
|
||||||
|
|
||||||
|
### 13.3 `3000` API는 되는데 화면은 비어 있음
|
||||||
|
|
||||||
|
의미:
|
||||||
|
|
||||||
|
1. DB는 정상이고 프런트 프록시 또는 화면 렌더링 문제다.
|
||||||
|
|
||||||
|
대응:
|
||||||
|
|
||||||
|
1. `8080/api/assets/master` 상태 먼저 확인
|
||||||
|
|
||||||
|
### 13.4 `db-bootstrap`가 실패 종료함
|
||||||
|
|
||||||
|
의미 후보:
|
||||||
|
|
||||||
|
1. `.env` 외부 DB 접속 정보 오류
|
||||||
|
2. 외부 DB 네트워크 접근 불가
|
||||||
|
3. 외부 계정 권한 문제
|
||||||
|
|
||||||
|
대응:
|
||||||
|
|
||||||
|
1. `docker logs itam-db-bootstrap` 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 현재 최종 검증 완료 상태
|
||||||
|
|
||||||
|
이 저장소는 아래 상태까지 검증이 완료됐다.
|
||||||
|
|
||||||
|
1. WSL2 Ubuntu에서 Docker 실행 가능
|
||||||
|
2. `start_docker_wsl.ps1`로 전체 스택 기동 가능
|
||||||
|
3. `db` 컨테이너 healthcheck 통과
|
||||||
|
4. `db-bootstrap`가 외부 DB에서 내부 DB로 데이터 복제 후 `Exited (0)` 종료
|
||||||
|
5. `backend`가 내부 `db`를 사용해 API 응답 가능
|
||||||
|
6. `frontend`가 `backend`를 프록시해 8080 기준 화면/API 동작 가능
|
||||||
|
7. 내부 MySQL에 실데이터 적재 확인
|
||||||
|
|
||||||
|
즉, 현재 Git에 올라간 상태만으로도 WSL2와 외부 원본 DB 정보만 있으면 지금과 같은 수준의 Docker 실행 재현이 가능하다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 현재 구조의 한계와 다음 단계
|
||||||
|
|
||||||
|
현재 구조는 충분히 시연 가능하고 개발 재현도 가능하지만, 다음은 아직 별도 작업이 필요하다.
|
||||||
|
|
||||||
|
1. 운영형 정적 배포 구조 전환
|
||||||
|
2. 외부 DB 없이도 완전 독립 실행 가능한 정식 dump/backup 체계
|
||||||
|
3. `.env.example` 정리
|
||||||
|
4. DB bootstrap 전용 계정/권한 최소화
|
||||||
|
5. 장기적으로 `map_config.json` 파일 저장 정책 정리
|
||||||
|
|
||||||
|
하지만 "현재 저장소만으로 지금과 같은 Docker 실행 상태 재현"이라는 목표는 이미 충족한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. 빠른 실행 요약
|
||||||
|
|
||||||
|
가장 짧게 요약하면 다음 순서다.
|
||||||
|
|
||||||
|
1. `.env`에 외부 원본 MySQL 접속 정보를 넣는다.
|
||||||
|
2. WSL2 Ubuntu와 WSL 내부 Docker가 살아 있는지 확인한다.
|
||||||
|
3. `start_docker_wsl.ps1`를 실행한다.
|
||||||
|
4. `itam-db-bootstrap`가 `Exited (0)`인지 확인한다.
|
||||||
|
5. `http://localhost:3000/api/assets/master`와 `http://localhost:8080/api/assets/master`가 모두 200인지 확인한다.
|
||||||
|
6. 브라우저에서 `http://localhost:8080`을 열어 데이터가 보이는지 확인한다.
|
||||||
|
|
||||||
|
이 순서대로 진행하면 현재 저장소 기준 Dockerized ITAM 시연 환경을 재현할 수 있다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. 2026-06-16 최신 정정
|
||||||
|
|
||||||
|
이 문서의 상단 본문은 한동안 사용했던 `내부 db + db-bootstrap` 구조를 기준으로 작성됐다. 하지만 오늘 기준 현재 저장소의 실제 `docker-compose.yaml`은 다시 `무상태 앱 컨테이너 + 외부 DB` 구조로 되돌아가 있다.
|
||||||
|
|
||||||
|
따라서 현재 시점의 정답 아키텍처는 아래다.
|
||||||
|
|
||||||
|
1. `backend` 컨테이너
|
||||||
|
2. `frontend` 컨테이너
|
||||||
|
3. 외부 MySQL DB
|
||||||
|
|
||||||
|
현재는 더 이상 아래 항목이 없다.
|
||||||
|
|
||||||
|
1. `db` 서비스 없음
|
||||||
|
2. `db-bootstrap` 서비스 없음
|
||||||
|
3. `itam_mysql_data` 볼륨 없음
|
||||||
|
|
||||||
|
### 17.1 현재 실제 `docker-compose.yaml` 기준 backend 동작
|
||||||
|
|
||||||
|
현재 backend는 `.env`의 외부 DB 접속 정보를 그대로 사용한다.
|
||||||
|
|
||||||
|
즉, 아래 환경변수 매핑이 현재 기준이다.
|
||||||
|
|
||||||
|
1. `DB_HOST: ${DB_HOST}`
|
||||||
|
2. `DB_PORT: ${DB_PORT}`
|
||||||
|
3. `DB_USER: ${DB_USER}`
|
||||||
|
4. `DB_PASS: ${DB_PASS}`
|
||||||
|
5. `DB_NAME: ${DB_NAME}`
|
||||||
|
|
||||||
|
`PORT: 3000`만 Compose에서 고정한다.
|
||||||
|
|
||||||
|
### 17.2 현재 실제 기동 구조
|
||||||
|
|
||||||
|
현재 스택 기동 순서는 단순하다.
|
||||||
|
|
||||||
|
1. `backend` 기동
|
||||||
|
2. `frontend` 기동
|
||||||
|
3. backend는 외부 DB에 직접 접속
|
||||||
|
4. frontend는 `http://backend:3000`으로 프록시
|
||||||
|
|
||||||
|
즉, 현재는 DB 컨테이너 초기화 단계나 bootstrap 단계가 존재하지 않는다.
|
||||||
|
|
||||||
|
### 17.3 현재 기준 첫 실행 체크리스트
|
||||||
|
|
||||||
|
오늘 기준으로는 아래 순서가 맞다.
|
||||||
|
|
||||||
|
1. `.env`에 외부 DB 접속 정보 입력
|
||||||
|
2. `start_docker_wsl.ps1` 또는 `start_docker_wsl.bat` 실행
|
||||||
|
3. `http://localhost:3000/api/assets/master`가 200인지 확인
|
||||||
|
4. `http://localhost:8080/api/assets/master`가 200인지 확인
|
||||||
|
5. 브라우저에서 `http://localhost:8080` 접속 후 데이터 표시 확인
|
||||||
|
|
||||||
|
### 17.4 이 문서에서 현재 유효한 부분과 과거 이력 부분
|
||||||
|
|
||||||
|
현재도 그대로 유효한 내용은 아래다.
|
||||||
|
|
||||||
|
1. WSL2 기반 실행 방식
|
||||||
|
2. `start_docker_wsl.ps1` / `stop_docker_wsl.ps1` 사용 방식
|
||||||
|
3. `server.js`에서 Compose 환경변수가 `.env`보다 우선되도록 `dotenv.config()`를 유지해야 한다는 점
|
||||||
|
4. `vite.config.ts`에서 프록시 타깃을 환경변수화해야 한다는 점
|
||||||
|
|
||||||
|
현재는 과거 이력으로만 읽어야 하는 내용은 아래다.
|
||||||
|
|
||||||
|
1. 내부 `db` 서비스 설명
|
||||||
|
2. `db-bootstrap` 설명
|
||||||
|
3. `itam_mysql_data` 볼륨 설명
|
||||||
|
4. 내부 DB 재초기화 절차
|
||||||
|
5. 내부 테이블 확인 절차
|
||||||
|
|
||||||
|
### 17.5 현재 최종 한 줄 요약
|
||||||
|
|
||||||
|
오늘 날짜 기준 현재 저장소의 실사용 Compose 구조는 `frontend + backend + external DB`이며, 이전의 내부 DB/bootstrap 구조는 역사적으로 한 번 사용했던 임시 해결책으로만 남아 있다.
|
||||||
48
docker-compose.yaml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.backend
|
||||||
|
container_name: itam-backend
|
||||||
|
working_dir: /app
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
DB_HOST: ${DB_HOST}
|
||||||
|
DB_PORT: ${DB_PORT}
|
||||||
|
DB_USER: ${DB_USER}
|
||||||
|
DB_PASS: ${DB_PASS}
|
||||||
|
DB_NAME: ${DB_NAME}
|
||||||
|
PORT: 3000
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- ./:/app
|
||||||
|
- backend_node_modules:/app/node_modules
|
||||||
|
- ./uploads:/app/uploads
|
||||||
|
- ./map_config.json:/app/map_config.json
|
||||||
|
command: npm run server
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.frontend
|
||||||
|
container_name: itam-frontend
|
||||||
|
working_dir: /app
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
environment:
|
||||||
|
CHOKIDAR_USEPOLLING: "true"
|
||||||
|
VITE_DEV_PROXY_TARGET: http://backend:3000
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./:/app
|
||||||
|
- frontend_node_modules:/app/node_modules
|
||||||
|
command: npm run dev -- --host 0.0.0.0
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
backend_node_modules:
|
||||||
|
frontend_node_modules:
|
||||||
16
docker/mysql/init/README.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# MySQL init directory
|
||||||
|
|
||||||
|
This directory is kept as a legacy hook for file-based MySQL initialization.
|
||||||
|
|
||||||
|
Current production path in this repository is not file-based import.
|
||||||
|
The live Docker flow uses the `db-bootstrap` service in `docker-compose.yaml` to stream data from the external source DB into the internal `db` container.
|
||||||
|
|
||||||
|
Use this directory only if you intentionally switch back to `docker-entrypoint-initdb.d` style initialization.
|
||||||
|
|
||||||
|
If you do that, typical naming would be:
|
||||||
|
|
||||||
|
- `01_schema.sql`
|
||||||
|
- `02_seed.sql`
|
||||||
|
- or a single `01_itam_dump.sql`
|
||||||
|
|
||||||
|
Remember that files in this directory are executed automatically by the MySQL container only on the first initialization of the data volume.
|
||||||
330
docker_task_plan.md
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
# ITAM 도커라이징 작업 태스크 정리
|
||||||
|
|
||||||
|
## 1. 문서 목적
|
||||||
|
|
||||||
|
이 문서는 ITAM 자산관리 시스템의 도커라이징 작업을 실제 실행 단위로 쪼개서 정리한 태스크 문서다.
|
||||||
|
|
||||||
|
이 문서의 목표는 아래와 같다.
|
||||||
|
|
||||||
|
1. 내일까지 보여줄 시연 범위를 기준으로 우선순위를 정한다.
|
||||||
|
2. 시연용 작업과 운영형 전환 작업을 분리한다.
|
||||||
|
3. 개발 담당자가 바로 실행할 수 있는 체크리스트를 제공한다.
|
||||||
|
|
||||||
|
관련 배경과 구조 분석은 [doc_readme.md](c:/Users/user/Desktop/안건%20파일/itam/doc_readme.md) 문서를 기준으로 한다.
|
||||||
|
|
||||||
|
현재 구현/검증 상태:
|
||||||
|
|
||||||
|
- `Dockerfile.frontend` 생성 완료
|
||||||
|
- `Dockerfile.backend` 생성 완료
|
||||||
|
- `docker-compose.yaml` 생성 완료
|
||||||
|
- `.dockerignore` 생성 완료
|
||||||
|
- WSL2 Ubuntu에서 `docker compose up --build -d` 검증 완료
|
||||||
|
- frontend 8080 응답 확인 완료
|
||||||
|
- backend `/api/assets/master` 응답 확인 완료
|
||||||
|
- 현재 DB는 external MySQL 기준이며, DB 컨테이너 추가 작업은 다음 단계로 남아 있음
|
||||||
|
|
||||||
|
## 2. 이번 작업의 최우선 목표
|
||||||
|
|
||||||
|
이번 도커라이징의 1차 목표는 "운영 배포 완료"가 아니라 아래 상태를 재현하는 것이다.
|
||||||
|
|
||||||
|
1. frontend 컨테이너가 정상 기동한다.
|
||||||
|
2. backend 컨테이너가 정상 기동한다.
|
||||||
|
3. backend가 기존 외부 MySQL 또는 MySQL 컨테이너에 정상 연결된다.
|
||||||
|
4. 브라우저에서 화면이 열린다.
|
||||||
|
5. 핵심 API 호출이 정상 동작한다.
|
||||||
|
6. 업로드 저장 경로가 유지된다.
|
||||||
|
7. 필요 시 DB까지 함께 포함된 재현 가능한 스택을 제공한다.
|
||||||
|
|
||||||
|
## 3. 작업 범위 구분
|
||||||
|
|
||||||
|
### 3.1 이번 시연 범위에 포함
|
||||||
|
|
||||||
|
- Dockerfile.frontend 초안 작성
|
||||||
|
- Dockerfile.backend 초안 작성
|
||||||
|
- docker-compose.yaml 작성
|
||||||
|
- `.dockerignore` 작성
|
||||||
|
- MySQL 컨테이너 추가 설계
|
||||||
|
- 초기 SQL dump 또는 init SQL 적재 방식 정의
|
||||||
|
- `uploads` 볼륨 처리
|
||||||
|
- `map_config.json` 영속성 처리 방식 반영
|
||||||
|
- 컨테이너 기동 및 접속 확인
|
||||||
|
- 핵심 API 및 화면 확인
|
||||||
|
|
||||||
|
### 3.2 이번 시연 범위에서 제외
|
||||||
|
|
||||||
|
- DB 전체 마이그레이션 자동화
|
||||||
|
- nginx 기반 운영 배포 구조
|
||||||
|
- 단일 이미지 운영 구조 전환
|
||||||
|
- CI/CD 연계
|
||||||
|
|
||||||
|
## 4. 선행 확인 태스크
|
||||||
|
|
||||||
|
아래 태스크는 실제 Docker 파일 작성 전에 먼저 확인해야 한다.
|
||||||
|
|
||||||
|
### Task 1. 외부 MySQL 접근 가능 여부 확인
|
||||||
|
|
||||||
|
- 목적: 컨테이너에서 외부 DB 접속이 가능한지 확인
|
||||||
|
- 확인 항목:
|
||||||
|
- DB_HOST 접근 가능 여부
|
||||||
|
- DB_PORT 3306 접속 가능 여부
|
||||||
|
- 계정 권한 정상 여부
|
||||||
|
- 완료 기준:
|
||||||
|
- backend 컨테이너 기준 DB 연결 에러가 발생하지 않음
|
||||||
|
|
||||||
|
### Task 2. 기준 스키마 상태 확인
|
||||||
|
|
||||||
|
- 목적: 현재 앱이 요구하는 테이블 구조가 실제 DB와 맞는지 확인
|
||||||
|
- 확인 항목:
|
||||||
|
- `asset_core`
|
||||||
|
- `asset_spec`
|
||||||
|
- `asset_location`
|
||||||
|
- `asset_remote`
|
||||||
|
- `asset_history`
|
||||||
|
- `hardware_components_master`
|
||||||
|
- `job_spec_standards`
|
||||||
|
- 완료 기준:
|
||||||
|
- `/api/assets/master` 호출 시 쿼리 에러가 발생하지 않음
|
||||||
|
|
||||||
|
### Task 3. 파일 영속성 대상 확인
|
||||||
|
|
||||||
|
- 목적: 컨테이너 재시작 이후에도 유지되어야 할 파일/폴더 식별
|
||||||
|
- 대상:
|
||||||
|
- `uploads`
|
||||||
|
- `map_config.json`
|
||||||
|
- 완료 기준:
|
||||||
|
- 볼륨 설계 대상이 명확하게 문서화됨
|
||||||
|
|
||||||
|
### Task 4. DB 기준 데이터 소스 확정
|
||||||
|
|
||||||
|
- 목적: MySQL 컨테이너 최초 기동 시 어떤 데이터로 초기화할지 결정
|
||||||
|
- 선택지:
|
||||||
|
- 기존 사내 DB에서 추출한 SQL dump 사용
|
||||||
|
- 정리된 스키마 SQL + seed SQL 사용
|
||||||
|
- 수동 import 절차 사용
|
||||||
|
- 완료 기준:
|
||||||
|
- `docker/mysql/init` 기준 적재 전략 또는 수동 복원 절차가 확정됨
|
||||||
|
|
||||||
|
## 5. 시연용 도커라이징 태스크
|
||||||
|
|
||||||
|
### Task 5. 프런트 Dockerfile 작성
|
||||||
|
|
||||||
|
- 목적: Vite 개발 서버를 컨테이너에서 구동
|
||||||
|
- 작업 내용:
|
||||||
|
- Node 20 계열 이미지 사용
|
||||||
|
- `package*.json` 복사 후 `npm install`
|
||||||
|
- 8080 포트 노출
|
||||||
|
- `npm run dev -- --host 0.0.0.0` 실행
|
||||||
|
- 산출물:
|
||||||
|
- `Dockerfile.frontend`
|
||||||
|
- 완료 기준:
|
||||||
|
- 컨테이너에서 8080 포트가 정상 listen 상태가 됨
|
||||||
|
|
||||||
|
### Task 6. 백엔드 Dockerfile 작성
|
||||||
|
|
||||||
|
- 목적: Express API 서버를 컨테이너에서 구동
|
||||||
|
- 작업 내용:
|
||||||
|
- Node 20 계열 이미지 사용
|
||||||
|
- `package*.json` 복사 후 `npm install`
|
||||||
|
- 3000 포트 노출
|
||||||
|
- `npm run server` 실행
|
||||||
|
- 산출물:
|
||||||
|
- `Dockerfile.backend`
|
||||||
|
- 완료 기준:
|
||||||
|
- 컨테이너에서 3000 포트가 정상 listen 상태가 됨
|
||||||
|
|
||||||
|
### Task 7. MySQL Docker 구성 추가
|
||||||
|
|
||||||
|
- 목적: DB까지 포함한 재현 가능한 스택 구성
|
||||||
|
- 작업 내용:
|
||||||
|
- `mysql:8.0` 서비스 정의
|
||||||
|
- `MYSQL_DATABASE`, `MYSQL_USER`, `MYSQL_PASSWORD` 설정
|
||||||
|
- utf8mb4 문자셋 옵션 반영
|
||||||
|
- MySQL 데이터 volume 연결
|
||||||
|
- 초기 SQL 적재용 `docker/mysql/init` 디렉터리 설계
|
||||||
|
- 산출물:
|
||||||
|
- `docker-compose.yaml` 내 `db` 서비스 또는 별도 DB compose 확장안
|
||||||
|
- 완료 기준:
|
||||||
|
- MySQL 컨테이너가 정상 기동하고 3306 포트에서 응답 가능
|
||||||
|
|
||||||
|
### Task 8. backend DB 연결 전환
|
||||||
|
|
||||||
|
- 목적: backend가 external MySQL 대신 DB 컨테이너를 바라보도록 변경
|
||||||
|
- 작업 내용:
|
||||||
|
- `DB_HOST`를 `db`로 전환
|
||||||
|
- 필요 시 `.env.docker` 또는 compose 내부 환경변수 사용
|
||||||
|
- backend `depends_on`에 db 추가
|
||||||
|
- 산출물:
|
||||||
|
- DB 컨테이너용 backend 환경 정의
|
||||||
|
- 완료 기준:
|
||||||
|
- backend 로그에서 DB 연결 성공 확인
|
||||||
|
|
||||||
|
### Task 9. docker-compose.yaml 확장
|
||||||
|
|
||||||
|
- 목적: frontend/backend를 함께 기동
|
||||||
|
- 작업 내용:
|
||||||
|
- frontend 서비스 정의
|
||||||
|
- backend 서비스 정의
|
||||||
|
- db 서비스 정의
|
||||||
|
- 포트 매핑 추가
|
||||||
|
- `.env` 또는 docker 전용 환경변수 연결
|
||||||
|
- MySQL 데이터 볼륨 연결
|
||||||
|
- `uploads` 볼륨 연결
|
||||||
|
- `map_config.json` 처리 방식 반영
|
||||||
|
- 산출물:
|
||||||
|
- `docker-compose.yaml`
|
||||||
|
- 완료 기준:
|
||||||
|
- `docker compose up --build` 한 번으로 세 서비스가 모두 올라옴
|
||||||
|
|
||||||
|
### Task 10. `.dockerignore` 작성
|
||||||
|
|
||||||
|
- 목적: 불필요한 빌드 컨텍스트 제외
|
||||||
|
- 제외 권장 항목:
|
||||||
|
- `node_modules`
|
||||||
|
- `dist`
|
||||||
|
- `build`
|
||||||
|
- `.git`
|
||||||
|
- `uploads`
|
||||||
|
- `*.xlsx`
|
||||||
|
- 산출물:
|
||||||
|
- `.dockerignore`
|
||||||
|
- 완료 기준:
|
||||||
|
- 이미지 빌드 컨텍스트가 과도하게 커지지 않음
|
||||||
|
|
||||||
|
## 6. 시연 검증 태스크
|
||||||
|
|
||||||
|
### Task 11. WSL 컨테이너 기동 검증
|
||||||
|
|
||||||
|
- 실행 명령:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\start_docker_wsl.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
- 확인 항목:
|
||||||
|
- frontend 로그 에러 여부
|
||||||
|
- backend 로그 에러 여부
|
||||||
|
- db 로그 에러 여부
|
||||||
|
- backend와 db 연결 성공 여부
|
||||||
|
- 완료 기준:
|
||||||
|
- 세 컨테이너 모두 종료 없이 유지됨
|
||||||
|
|
||||||
|
### Task 12. 웹 접속 검증
|
||||||
|
|
||||||
|
- 확인 항목:
|
||||||
|
- `http://localhost:8080` 접속 가능 여부
|
||||||
|
- 첫 화면 로딩 여부
|
||||||
|
- 콘솔 에러 여부
|
||||||
|
- 완료 기준:
|
||||||
|
- 브라우저에서 초기 화면이 정상 표시됨
|
||||||
|
|
||||||
|
### Task 13. API 검증
|
||||||
|
|
||||||
|
- 확인 항목:
|
||||||
|
- `http://localhost:3000/api/assets/master`
|
||||||
|
- 프런트에서 `/api/assets/master` 호출 정상 여부
|
||||||
|
- 완료 기준:
|
||||||
|
- 200 응답 또는 정상 데이터 응답 확인
|
||||||
|
|
||||||
|
### Task 14. DB 초기 데이터 검증
|
||||||
|
|
||||||
|
- 확인 항목:
|
||||||
|
- MySQL 컨테이너 내부에 목표 DB가 생성되었는지
|
||||||
|
- 기준 테이블이 존재하는지
|
||||||
|
- 샘플 데이터 또는 실데이터가 적재되었는지
|
||||||
|
- 완료 기준:
|
||||||
|
- backend가 기대하는 최소 테이블과 데이터가 실제로 조회됨
|
||||||
|
|
||||||
|
### Task 15. 업로드/파일 저장 검증
|
||||||
|
|
||||||
|
- 확인 항목:
|
||||||
|
- `/api/upload` 호출 정상 여부
|
||||||
|
- 업로드 파일이 `uploads`에 실제 저장되는지
|
||||||
|
- `map_config.json` 수정 내용이 유지되는지
|
||||||
|
- 완료 기준:
|
||||||
|
- 컨테이너 재시작 후에도 저장 데이터가 유지됨
|
||||||
|
|
||||||
|
## 7. 시연 후 후속 태스크
|
||||||
|
|
||||||
|
### Task 16. 운영형 프런트 배포 구조 전환
|
||||||
|
|
||||||
|
- 목표: Vite dev server 대신 정적 빌드 기반 구조로 전환
|
||||||
|
- 후보:
|
||||||
|
- nginx 정적 서빙
|
||||||
|
- Express 정적 서빙
|
||||||
|
|
||||||
|
### Task 17. DB 초기화/마이그레이션 전략 통합
|
||||||
|
|
||||||
|
- 목표: 기준 스키마와 실행 순서를 단일 정책으로 통일
|
||||||
|
- 필요 작업:
|
||||||
|
- 기준 스키마 선정
|
||||||
|
- 초기화 스크립트 확정
|
||||||
|
- 마이그레이션 순서 정의
|
||||||
|
|
||||||
|
### Task 18. `.env.example` 및 배포 환경 분리
|
||||||
|
|
||||||
|
- 목표: 민감정보를 저장소에서 분리하고 배포별 설정 체계화
|
||||||
|
|
||||||
|
### Task 19. 운영 볼륨 및 백업 전략 정리
|
||||||
|
|
||||||
|
- 목표: 업로드 파일과 설정 파일, MySQL 데이터의 장기 보존 정책 정리
|
||||||
|
|
||||||
|
### Task 20. DB 백업/복원 절차 문서화
|
||||||
|
|
||||||
|
- 목표: 컨테이너 DB를 기준으로 dump/restore 절차를 문서화
|
||||||
|
|
||||||
|
## 8. 우선순위 정리
|
||||||
|
|
||||||
|
### P0: 내일까지 반드시 필요한 작업
|
||||||
|
|
||||||
|
1. Task 1. 외부 MySQL 접근 가능 여부 확인
|
||||||
|
2. Task 2. 기준 스키마 상태 확인
|
||||||
|
3. Task 4. DB 기준 데이터 소스 확정
|
||||||
|
4. Task 7. MySQL Docker 구성 추가
|
||||||
|
5. Task 8. backend DB 연결 전환
|
||||||
|
6. Task 9. docker-compose.yaml 확장
|
||||||
|
7. Task 11. WSL 컨테이너 기동 검증
|
||||||
|
8. Task 12. 웹 접속 검증
|
||||||
|
9. Task 13. API 검증
|
||||||
|
10. Task 14. DB 초기 데이터 검증
|
||||||
|
|
||||||
|
### P1: 시연 안정화를 위해 권장되는 작업
|
||||||
|
|
||||||
|
1. Task 3. 파일 영속성 대상 확인
|
||||||
|
2. Task 10. `.dockerignore` 작성
|
||||||
|
3. Task 15. 업로드/파일 저장 검증
|
||||||
|
|
||||||
|
### P2: 시연 이후 진행할 작업
|
||||||
|
|
||||||
|
1. Task 16. 운영형 프런트 배포 구조 전환
|
||||||
|
2. Task 17. DB 초기화/마이그레이션 전략 통합
|
||||||
|
3. Task 18. `.env.example` 및 배포 환경 분리
|
||||||
|
4. Task 19. 운영 볼륨 및 백업 전략 정리
|
||||||
|
5. Task 20. DB 백업/복원 절차 문서화
|
||||||
|
|
||||||
|
## 9. 개발자용 최종 작업 순서 제안
|
||||||
|
|
||||||
|
개발 담당자에게는 아래 순서로 진행하라고 전달하면 된다.
|
||||||
|
|
||||||
|
1. 외부 DB 연결 가능 여부부터 확인
|
||||||
|
2. 현재 DB 스키마가 앱 요구사항과 맞는지 확인
|
||||||
|
3. DB 기준 dump 또는 init SQL 확보
|
||||||
|
4. MySQL 컨테이너 구성 추가
|
||||||
|
5. backend의 DB 연결 대상을 `db`로 전환
|
||||||
|
6. WSL에서 `docker compose config` 확인
|
||||||
|
7. WSL에서 컨테이너 기동 테스트
|
||||||
|
8. 웹 접속 및 API 확인
|
||||||
|
9. 업로드 및 파일 영속성 확인
|
||||||
|
10. 시연 완료 후 운영형 구조로 분리 작업 진행
|
||||||
|
|
||||||
|
## 10. 완료 판단 기준
|
||||||
|
|
||||||
|
이번 도커라이징 1차 작업은 아래 조건을 만족하면 완료로 본다.
|
||||||
|
|
||||||
|
1. `docker compose up --build`로 프런트, 백엔드, DB가 모두 기동한다.
|
||||||
|
2. 브라우저에서 8080 화면이 열린다.
|
||||||
|
3. `/api/assets/master`가 정상 응답한다.
|
||||||
|
4. backend가 DB 컨테이너와 정상 연결된다.
|
||||||
|
5. DB 초기 테이블과 데이터가 기대 상태로 적재된다.
|
||||||
|
6. `uploads`, `map_config.json`, MySQL 데이터가 재시작 후에도 유지된다.
|
||||||
|
|
||||||
|
이 문서는 실제 구현 작업의 체크리스트로 사용한다.
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
const mysql = require('mysql2/promise');
|
|
||||||
require('dotenv').config({ override: true });
|
|
||||||
|
|
||||||
function formatToYYYYMMDD(val) {
|
|
||||||
if (!val) return null;
|
|
||||||
const s = String(val).trim().replace(/[^0-9]/g, '');
|
|
||||||
if (s.length === 8) {
|
|
||||||
// YYYYMMDD
|
|
||||||
return `${s.substring(0, 4)}-${s.substring(4, 6)}-${s.substring(6, 8)}`;
|
|
||||||
} else if (s.length === 6) {
|
|
||||||
// YYMMDD -> Assume 20XX
|
|
||||||
const year = parseInt(s.substring(0, 2)) > 50 ? '19' + s.substring(0, 2) : '20' + s.substring(0, 2);
|
|
||||||
return `${year}-${s.substring(2, 4)}-${s.substring(4, 6)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to split by dots or slashes if original had them
|
|
||||||
const parts = String(val).trim().split(/[\.\-\/]/);
|
|
||||||
if (parts.length === 3) {
|
|
||||||
let y = parts[0];
|
|
||||||
let m = parts[1].padStart(2, '0');
|
|
||||||
let d = parts[2].padStart(2, '0');
|
|
||||||
if (y.length === 2) y = '20' + y;
|
|
||||||
if (y.length === 4 && m.length <= 2 && d.length <= 2) {
|
|
||||||
return `${y}-${m}-${d}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return val; // Return as is if format is unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const pool = mysql.createPool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASS,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
port: parseInt(process.env.DB_PORT || '3306')
|
|
||||||
});
|
|
||||||
|
|
||||||
const connection = await pool.getConnection();
|
|
||||||
try {
|
|
||||||
const [rows] = await connection.query('SELECT id, purchase_date FROM asset_core WHERE purchase_date IS NOT NULL AND purchase_date != \'\'');
|
|
||||||
console.log(`Found ${rows.length} rows to check.`);
|
|
||||||
|
|
||||||
let updatedCount = 0;
|
|
||||||
for (const row of rows) {
|
|
||||||
const original = row.purchase_date;
|
|
||||||
const formatted = formatToYYYYMMDD(original);
|
|
||||||
|
|
||||||
if (formatted !== original && /^\d{4}-\d{2}-\d{2}$/.test(formatted)) {
|
|
||||||
await connection.query('UPDATE asset_core SET purchase_date = ? WHERE id = ?', [formatted, row.id]);
|
|
||||||
updatedCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`✅ Successfully updated ${updatedCount} rows to YYYY-MM-DD format.`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('❌ Error during date migration:', err);
|
|
||||||
} finally {
|
|
||||||
connection.release();
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 388 KiB |
@@ -1,354 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ko">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Center Chair Map (View Only)</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--ink: #152330;
|
|
||||||
--muted: #627286;
|
|
||||||
--paper: rgba(255,255,255,0.86);
|
|
||||||
--line: rgba(21,35,48,0.1);
|
|
||||||
--accent: #0f766e;
|
|
||||||
--bg: #edf2f6;
|
|
||||||
}
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
|
|
||||||
color: var(--ink);
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
|
|
||||||
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.page {
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.shell {
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
.panel {
|
|
||||||
border-radius: 0;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
backdrop-filter: none;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
.viewer {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
.viewer-head {
|
|
||||||
position: absolute;
|
|
||||||
top: 16px;
|
|
||||||
left: 16px;
|
|
||||||
z-index: 2;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.chip {
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 16px;
|
|
||||||
background: rgba(255,255,255,0.82);
|
|
||||||
border: 1px solid rgba(255,255,255,0.94);
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
.viewer-actions {
|
|
||||||
position: absolute;
|
|
||||||
left: 16px;
|
|
||||||
top: 64px;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
border: none;
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
font: inherit;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
color: white;
|
|
||||||
background: linear-gradient(135deg, #0f766e, #115e59);
|
|
||||||
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
|
|
||||||
}
|
|
||||||
canvas {
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
display: block;
|
|
||||||
cursor: grab;
|
|
||||||
}
|
|
||||||
canvas.dragging { cursor: grabbing; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="page">
|
|
||||||
<div class="shell">
|
|
||||||
<main class="panel viewer">
|
|
||||||
<div class="viewer-head">
|
|
||||||
<div class="chip" id="scale-chip"></div>
|
|
||||||
</div>
|
|
||||||
<div class="viewer-actions">
|
|
||||||
<button type="button" id="fit-btn">전체 맞춤</button>
|
|
||||||
</div>
|
|
||||||
<canvas id="canvas"></canvas>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script src="./center_chair_people_payload.js?v=20260403a"></script>
|
|
||||||
<script>
|
|
||||||
const DATA = window.CHAIR_MAP_DATA;
|
|
||||||
function decodeSegments(base64) {
|
|
||||||
const binary = atob(base64);
|
|
||||||
const bytes = new Uint8Array(binary.length);
|
|
||||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
||||||
return new Int32Array(bytes.buffer);
|
|
||||||
}
|
|
||||||
const bgTileRanges = DATA.bgTileRanges;
|
|
||||||
const bgSegValues = decodeSegments(DATA.bgSegsB64);
|
|
||||||
const chairSegValues = decodeSegments(DATA.chairSegsB64);
|
|
||||||
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
|
|
||||||
key, name, kind, start, count
|
|
||||||
}));
|
|
||||||
const meta = DATA.meta;
|
|
||||||
const world = meta.headerBounds;
|
|
||||||
const canvas = document.getElementById("canvas");
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
const scaleChip = document.getElementById("scale-chip");
|
|
||||||
|
|
||||||
// --- Added for Point Picking & Marker ---
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
let markerX = params.get('markerX') ? parseFloat(params.get('markerX')) : null;
|
|
||||||
let markerY = params.get('markerY') ? parseFloat(params.get('markerY')) : null;
|
|
||||||
|
|
||||||
const chairGeometry = chairs.map((chair) => {
|
|
||||||
let minX = Infinity;
|
|
||||||
let minY = Infinity;
|
|
||||||
let maxX = -Infinity;
|
|
||||||
let maxY = -Infinity;
|
|
||||||
const path = new Path2D();
|
|
||||||
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
|
|
||||||
const offset = i * 4;
|
|
||||||
const x1 = chairSegValues[offset] / 10;
|
|
||||||
const y1 = chairSegValues[offset + 1] / 10;
|
|
||||||
const x2 = chairSegValues[offset + 2] / 10;
|
|
||||||
const y2 = chairSegValues[offset + 3] / 10;
|
|
||||||
path.moveTo(x1, y1);
|
|
||||||
path.lineTo(x2, y2);
|
|
||||||
minX = Math.min(minX, x1, x2);
|
|
||||||
minY = Math.min(minY, y1, y2);
|
|
||||||
maxX = Math.max(maxX, x1, x2);
|
|
||||||
maxY = Math.max(maxY, y1, y2);
|
|
||||||
}
|
|
||||||
return { ...chair, minX, minY, maxX, maxY, path };
|
|
||||||
});
|
|
||||||
|
|
||||||
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
|
|
||||||
let pixelRatio = window.devicePixelRatio || 1;
|
|
||||||
let dragging = false;
|
|
||||||
let dragStart = null;
|
|
||||||
let rafPending = false;
|
|
||||||
|
|
||||||
function resize() {
|
|
||||||
pixelRatio = window.devicePixelRatio || 1;
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
canvas.width = Math.round(rect.width * pixelRatio);
|
|
||||||
canvas.height = Math.round(rect.height * pixelRatio);
|
|
||||||
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
|
||||||
fit();
|
|
||||||
}
|
|
||||||
|
|
||||||
function fit() {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const width = world.maxX - world.minX;
|
|
||||||
const height = world.maxY - world.minY;
|
|
||||||
const pad = 36;
|
|
||||||
const scaleX = (rect.width - pad * 2) / width;
|
|
||||||
const scaleY = (rect.height - pad * 2) / height;
|
|
||||||
camera.scale = Math.min(scaleX, scaleY);
|
|
||||||
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
|
|
||||||
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
|
|
||||||
requestDraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawGrid(width, height) {
|
|
||||||
ctx.save();
|
|
||||||
ctx.strokeStyle = "rgba(21,35,48,0.05)";
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
for (let x = 120; x < width; x += 120) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x, 0);
|
|
||||||
ctx.lineTo(x, height);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
for (let y = 120; y < height; y += 120) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(0, y);
|
|
||||||
ctx.lineTo(width, y);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
function worldToScreen(x, y) {
|
|
||||||
return {
|
|
||||||
x: x * camera.scale + camera.offsetX,
|
|
||||||
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function screenToWorld(x, y) {
|
|
||||||
return {
|
|
||||||
x: (x - camera.offsetX) / camera.scale,
|
|
||||||
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestDraw() {
|
|
||||||
if (rafPending) return;
|
|
||||||
rafPending = true;
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
rafPending = false;
|
|
||||||
draw();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyWorldTransform() {
|
|
||||||
ctx.setTransform(
|
|
||||||
pixelRatio * camera.scale,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
-pixelRatio * camera.scale,
|
|
||||||
pixelRatio * camera.offsetX,
|
|
||||||
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function draw() {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
|
||||||
ctx.clearRect(0, 0, rect.width, rect.height);
|
|
||||||
drawGrid(rect.width, rect.height);
|
|
||||||
|
|
||||||
const viewA = screenToWorld(0, rect.height);
|
|
||||||
const viewB = screenToWorld(rect.width, 0);
|
|
||||||
const viewMinX = Math.min(viewA.x, viewB.x);
|
|
||||||
const viewMaxX = Math.max(viewA.x, viewB.x);
|
|
||||||
const viewMinY = Math.min(viewA.y, viewB.y);
|
|
||||||
const viewMaxY = Math.max(viewA.y, viewB.y);
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
applyWorldTransform();
|
|
||||||
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
|
|
||||||
ctx.lineWidth = 1 / camera.scale;
|
|
||||||
const tileSize = meta.backgroundTileSize;
|
|
||||||
const tileMinX = Math.floor(viewMinX / tileSize);
|
|
||||||
const tileMaxX = Math.floor(viewMaxX / tileSize);
|
|
||||||
const tileMinY = Math.floor(viewMinY / tileSize);
|
|
||||||
const tileMaxY = Math.floor(viewMaxY / tileSize);
|
|
||||||
|
|
||||||
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
|
|
||||||
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
|
|
||||||
const range = bgTileRanges[`${tx},${ty}`];
|
|
||||||
if (!range) continue;
|
|
||||||
const start = range[0];
|
|
||||||
const count = range[1];
|
|
||||||
for (let i = start; i < start + count; i += 1) {
|
|
||||||
const offset = i * 4;
|
|
||||||
const x1 = bgSegValues[offset] / 10;
|
|
||||||
const y1 = bgSegValues[offset + 1] / 10;
|
|
||||||
const x2 = bgSegValues[offset + 2] / 10;
|
|
||||||
const y2 = bgSegValues[offset + 3] / 10;
|
|
||||||
if (Math.max(x1, x2) < viewMinX || Math.min(x1, x2) > viewMaxX ||
|
|
||||||
Math.max(y1, y2) < viewMinY || Math.min(y1, y2) > viewMaxY) continue;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x1, y1);
|
|
||||||
ctx.lineTo(x2, y2);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.lineWidth = 1.35 / camera.scale;
|
|
||||||
ctx.lineCap = "round";
|
|
||||||
ctx.lineJoin = "round";
|
|
||||||
ctx.strokeStyle = "rgba(21, 149, 142, 0.8)";
|
|
||||||
for (const chair of chairGeometry) {
|
|
||||||
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
|
|
||||||
ctx.stroke(chair.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Draw Marker ---
|
|
||||||
if (markerX !== null && markerY !== null) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(markerX, markerY, 50 / camera.scale, 0, Math.PI * 2);
|
|
||||||
ctx.fillStyle = "rgba(220, 38, 38, 0.8)";
|
|
||||||
ctx.fill();
|
|
||||||
ctx.strokeStyle = "#fff";
|
|
||||||
ctx.lineWidth = 10 / camera.scale;
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.addEventListener("pointerdown", (event) => {
|
|
||||||
dragging = true;
|
|
||||||
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
|
|
||||||
canvas.classList.add("dragging");
|
|
||||||
});
|
|
||||||
window.addEventListener("pointerup", (event) => {
|
|
||||||
if (dragging && dragStart) {
|
|
||||||
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
|
|
||||||
if (move < 4) {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const mx = event.clientX - rect.left;
|
|
||||||
const my = event.clientY - rect.top;
|
|
||||||
const worldPos = screenToWorld(mx, my);
|
|
||||||
markerX = worldPos.x;
|
|
||||||
markerY = worldPos.y;
|
|
||||||
requestDraw();
|
|
||||||
|
|
||||||
// Notify parent window
|
|
||||||
window.parent.postMessage({
|
|
||||||
type: 'PICK_LOCATION',
|
|
||||||
x: markerX.toFixed(2),
|
|
||||||
y: markerY.toFixed(2)
|
|
||||||
}, '*');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dragging = false;
|
|
||||||
dragStart = null;
|
|
||||||
canvas.classList.remove("dragging");
|
|
||||||
});
|
|
||||||
window.addEventListener("pointermove", (event) => {
|
|
||||||
if (dragging && dragStart) {
|
|
||||||
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
|
|
||||||
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
|
|
||||||
requestDraw();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
canvas.addEventListener("wheel", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const mx = event.clientX - rect.left;
|
|
||||||
const my = event.clientY - rect.top;
|
|
||||||
const before = screenToWorld(mx, my);
|
|
||||||
const factor = event.deltaY < 0 ? 1.08 : 0.92;
|
|
||||||
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
|
|
||||||
const after = worldToScreen(before.x, before.y);
|
|
||||||
camera.offsetX += mx - after.x;
|
|
||||||
camera.offsetY += my - after.y;
|
|
||||||
requestDraw();
|
|
||||||
}, { passive: false });
|
|
||||||
document.getElementById("fit-btn").addEventListener("click", fit);
|
|
||||||
window.addEventListener("resize", resize);
|
|
||||||
resize();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,931 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ko">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>center chair people map</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--ink: #152330;
|
|
||||||
--muted: #627286;
|
|
||||||
--paper: rgba(255,255,255,0.86);
|
|
||||||
--line: rgba(21,35,48,0.1);
|
|
||||||
--accent: #0f766e;
|
|
||||||
--bg: #edf2f6;
|
|
||||||
}
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
|
|
||||||
color: var(--ink);
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
|
|
||||||
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
|
|
||||||
}
|
|
||||||
.page {
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.shell {
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
.panel {
|
|
||||||
border-radius: 0;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
backdrop-filter: none;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
border: none;
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
font: inherit;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
color: white;
|
|
||||||
background: linear-gradient(135deg, #0f766e, #115e59);
|
|
||||||
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
|
|
||||||
}
|
|
||||||
button.alt {
|
|
||||||
color: var(--ink);
|
|
||||||
background: rgba(255,255,255,0.9);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
.viewer {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
.viewer-head {
|
|
||||||
position: absolute;
|
|
||||||
top: 16px;
|
|
||||||
left: 16px;
|
|
||||||
right: 16px;
|
|
||||||
z-index: 2;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.chip {
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 16px;
|
|
||||||
background: rgba(255,255,255,0.82);
|
|
||||||
border: 1px solid rgba(255,255,255,0.94);
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
|
|
||||||
}
|
|
||||||
.viewer-actions {
|
|
||||||
position: absolute;
|
|
||||||
left: 16px;
|
|
||||||
top: 64px;
|
|
||||||
z-index: 2;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.mapper {
|
|
||||||
position: absolute;
|
|
||||||
top: 76px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
width: min(94vw, 1320px);
|
|
||||||
max-height: min(56vh, 560px);
|
|
||||||
overflow: hidden;
|
|
||||||
z-index: 4;
|
|
||||||
border-radius: 20px;
|
|
||||||
background: rgba(234, 239, 247, 0.95);
|
|
||||||
border: 1px solid rgba(101, 119, 146, 0.22);
|
|
||||||
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
backdrop-filter: blur(6px);
|
|
||||||
}
|
|
||||||
.hidden-off {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
.mapper-head {
|
|
||||||
padding: 10px 14px;
|
|
||||||
border-bottom: 1px solid rgba(101,119,146,0.18);
|
|
||||||
font-size: 12px;
|
|
||||||
color: #51607a;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.35;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
background: rgba(255,255,255,0.6);
|
|
||||||
}
|
|
||||||
.mapper-head strong {
|
|
||||||
display: block;
|
|
||||||
color: #17243b;
|
|
||||||
font-size: 20px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
.mapper-head .alt {
|
|
||||||
padding: 8px 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.org-chart {
|
|
||||||
margin: 0;
|
|
||||||
padding: 14px;
|
|
||||||
overflow: auto;
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.org-top {
|
|
||||||
margin: 0 auto;
|
|
||||||
width: min(100%, 420px);
|
|
||||||
border-radius: 14px;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid rgba(67, 84, 118, 0.25);
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
.org-top-title {
|
|
||||||
background: #1e2f4d;
|
|
||||||
color: #fff;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 34px;
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 1.1;
|
|
||||||
padding: 16px 12px;
|
|
||||||
letter-spacing: -0.03em;
|
|
||||||
}
|
|
||||||
.org-top-members {
|
|
||||||
padding: 10px;
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
background: rgba(255,255,255,0.95);
|
|
||||||
}
|
|
||||||
.org-teams {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(7, minmax(160px, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
.org-team {
|
|
||||||
border: 1px solid rgba(110, 126, 152, 0.25);
|
|
||||||
border-radius: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: rgba(255,255,255,0.95);
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.org-team h4 {
|
|
||||||
margin: 0;
|
|
||||||
padding: 9px 10px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #21324e;
|
|
||||||
font-weight: 800;
|
|
||||||
border-bottom: 1px solid rgba(110, 126, 152, 0.2);
|
|
||||||
background: rgba(240, 245, 252, 0.96);
|
|
||||||
}
|
|
||||||
.org-members {
|
|
||||||
padding: 7px;
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.org-person {
|
|
||||||
border: 1px solid rgba(116, 133, 161, 0.25);
|
|
||||||
background: rgba(255,255,255,0.95);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 6px 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 120ms ease, border-color 120ms ease;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.org-person.active {
|
|
||||||
border-color: rgba(15,118,110,0.6);
|
|
||||||
background: rgba(15,118,110,0.11);
|
|
||||||
}
|
|
||||||
.org-person.assigned {
|
|
||||||
border-color: rgba(37,99,235,0.5);
|
|
||||||
background: rgba(37,99,235,0.1);
|
|
||||||
}
|
|
||||||
.org-person strong {
|
|
||||||
display: block;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.3;
|
|
||||||
color: #15233a;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.org-person small {
|
|
||||||
display: block;
|
|
||||||
color: #5a6a86;
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1.25;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
@media (max-width: 980px) {
|
|
||||||
.mapper {
|
|
||||||
top: 72px;
|
|
||||||
width: min(96vw, 920px);
|
|
||||||
max-height: 58vh;
|
|
||||||
}
|
|
||||||
.viewer-actions {
|
|
||||||
top: 64px;
|
|
||||||
left: 12px;
|
|
||||||
right: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.mapper-head strong {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
.org-top-title {
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
.org-teams {
|
|
||||||
grid-template-columns: repeat(3, minmax(150px, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
canvas {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: block;
|
|
||||||
cursor: grab;
|
|
||||||
}
|
|
||||||
canvas.dragging { cursor: grabbing; }
|
|
||||||
.tooltip {
|
|
||||||
position: absolute;
|
|
||||||
min-width: 170px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
border-radius: 16px;
|
|
||||||
background: rgba(17,24,39,0.94);
|
|
||||||
color: white;
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translate(12px, 12px);
|
|
||||||
transition: opacity 120ms ease;
|
|
||||||
z-index: 3;
|
|
||||||
}
|
|
||||||
.tooltip.visible { opacity: 1; }
|
|
||||||
.tooltip strong { display: block; margin-bottom: 6px; font-size: 14px; }
|
|
||||||
.tooltip div { font-size: 12px; line-height: 1.45; color: rgba(255,255,255,0.82); }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="page">
|
|
||||||
<div class="shell">
|
|
||||||
<main class="panel viewer">
|
|
||||||
<div class="viewer-head">
|
|
||||||
<div class="chip" id="scale-chip"></div>
|
|
||||||
<div class="chip" id="hover-chip">chair hover: none</div>
|
|
||||||
</div>
|
|
||||||
<div class="viewer-actions">
|
|
||||||
<button type="button" id="fit-btn">전체 맞춤</button>
|
|
||||||
<button type="button" class="alt" id="clear-btn">선택 지우기</button>
|
|
||||||
</div>
|
|
||||||
<aside class="mapper hidden-off">
|
|
||||||
<div class="mapper-head">
|
|
||||||
<div id="mapper-status">
|
|
||||||
<strong>조직 현황</strong>
|
|
||||||
<span>선택 인원 없음</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="alt" id="clear-assign-btn">매칭 초기화</button>
|
|
||||||
</div>
|
|
||||||
<div class="org-chart" id="org-chart"></div>
|
|
||||||
</aside>
|
|
||||||
<canvas id="canvas"></canvas>
|
|
||||||
<div class="tooltip" id="tooltip"></div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script src="./center_chair_people_payload.js?v=20260403a"></script>
|
|
||||||
<script>
|
|
||||||
const DATA = window.CHAIR_MAP_DATA;
|
|
||||||
function decodeSegments(base64) {
|
|
||||||
const binary = atob(base64);
|
|
||||||
const bytes = new Uint8Array(binary.length);
|
|
||||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
||||||
return new Int32Array(bytes.buffer);
|
|
||||||
}
|
|
||||||
const bgTileRanges = DATA.bgTileRanges;
|
|
||||||
const bgSegValues = decodeSegments(DATA.bgSegsB64);
|
|
||||||
const chairSegValues = decodeSegments(DATA.chairSegsB64);
|
|
||||||
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
|
|
||||||
key, name, kind, start, count
|
|
||||||
}));
|
|
||||||
const meta = DATA.meta;
|
|
||||||
const world = meta.headerBounds;
|
|
||||||
const canvas = document.getElementById("canvas");
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
const tooltip = document.getElementById("tooltip");
|
|
||||||
const scaleChip = document.getElementById("scale-chip");
|
|
||||||
const hoverChip = document.getElementById("hover-chip");
|
|
||||||
const STORAGE_KEY = "ptc-chair-selection";
|
|
||||||
const PEOPLE_STORAGE_KEY = "ptc-chair-people";
|
|
||||||
const ASSIGN_STORAGE_KEY = "ptc-chair-assignments";
|
|
||||||
const ACTIVE_PERSON_STORAGE_KEY = "ptc-chair-active-person";
|
|
||||||
const clearAssignBtn = document.getElementById("clear-assign-btn");
|
|
||||||
const orgChartEl = document.getElementById("org-chart");
|
|
||||||
const mapperStatus = document.getElementById("mapper-status");
|
|
||||||
// Prevent stale auto-highlights from previous sessions.
|
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
|
||||||
localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
|
|
||||||
localStorage.removeItem(ASSIGN_STORAGE_KEY);
|
|
||||||
const placed = new Set();
|
|
||||||
let people = JSON.parse(localStorage.getItem(PEOPLE_STORAGE_KEY) || "[]");
|
|
||||||
let chairAssignments = {};
|
|
||||||
let activePersonId = null;
|
|
||||||
const ORG_TEMPLATE = {
|
|
||||||
top: {
|
|
||||||
name: "총괄기획실",
|
|
||||||
count: 53,
|
|
||||||
members: [
|
|
||||||
{ name: "장종찬", dept: "총괄기획실", title: "기획실장" },
|
|
||||||
{ name: "김원식", dept: "총괄기획실", title: "전무이사" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
teams: [
|
|
||||||
{ name: "경영기획팀", count: 6, members: ["김우진", "임민정", "국혜린", "최선아", "김윤재", "이미영"] },
|
|
||||||
{ name: "인재성장팀", count: 5, members: ["조태희", "최근혜", "류원준", "주안기", "정성호"] },
|
|
||||||
{ name: "ERP 기획팀", count: 5, members: ["류호성", "문형식", "최요제", "황대일", "이채봉"] },
|
|
||||||
{ name: "디자인기획팀", count: 17, members: ["신혜영", "정은혜", "김태식", "최예은", "채선영", "최영환", "윤봄이", "이예진", "허유나", "마희연", "김수현", "박지영", "권순호", "정두휘", "김정석", "정지윤", "양숙영"] },
|
|
||||||
{ name: "기술기획팀", count: 11, members: ["김원기", "홍아름", "이경민", "김혜인", "황동환", "최찬호", "이태훈", "김신지", "조찬영", "김용연", "한치영"] },
|
|
||||||
{ name: "협업증진팀", count: 3, members: ["성형일", "박주한", "한승민"] },
|
|
||||||
{ name: "솔루션통합팀", count: 4, members: ["권혁진", "염승호", "윤준수", "김지영"] },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const chairGeometry = chairs.map((chair) => {
|
|
||||||
let minX = Infinity;
|
|
||||||
let minY = Infinity;
|
|
||||||
let maxX = -Infinity;
|
|
||||||
let maxY = -Infinity;
|
|
||||||
const path = new Path2D();
|
|
||||||
const hitSegments = new Float32Array(chair.count * 4);
|
|
||||||
let segCursor = 0;
|
|
||||||
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
|
|
||||||
const offset = i * 4;
|
|
||||||
const x1 = chairSegValues[offset] / 10;
|
|
||||||
const y1 = chairSegValues[offset + 1] / 10;
|
|
||||||
const x2 = chairSegValues[offset + 2] / 10;
|
|
||||||
const y2 = chairSegValues[offset + 3] / 10;
|
|
||||||
path.moveTo(x1, y1);
|
|
||||||
path.lineTo(x2, y2);
|
|
||||||
hitSegments[segCursor] = x1;
|
|
||||||
hitSegments[segCursor + 1] = y1;
|
|
||||||
hitSegments[segCursor + 2] = x2;
|
|
||||||
hitSegments[segCursor + 3] = y2;
|
|
||||||
segCursor += 4;
|
|
||||||
minX = Math.min(minX, x1, x2);
|
|
||||||
minY = Math.min(minY, y1, y2);
|
|
||||||
maxX = Math.max(maxX, x1, x2);
|
|
||||||
maxY = Math.max(maxY, y1, y2);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...chair,
|
|
||||||
minX,
|
|
||||||
minY,
|
|
||||||
maxX,
|
|
||||||
maxY,
|
|
||||||
area: Math.max(1, (maxX - minX) * (maxY - minY)),
|
|
||||||
path,
|
|
||||||
hitSegments,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
function renumberChairKeys(chairItems) {
|
|
||||||
if (!chairItems.length) return;
|
|
||||||
const heights = chairItems
|
|
||||||
.map((chair) => Math.max(1, chair.maxY - chair.minY))
|
|
||||||
.sort((a, b) => a - b);
|
|
||||||
const medianHeight = heights[Math.floor(heights.length / 2)] || 1;
|
|
||||||
const rowTolerance = Math.max(40, medianHeight * 0.9);
|
|
||||||
|
|
||||||
const sorted = [...chairItems].sort((a, b) => {
|
|
||||||
const ay = (a.minY + a.maxY) * 0.5;
|
|
||||||
const by = (b.minY + b.maxY) * 0.5;
|
|
||||||
if (Math.abs(by - ay) > rowTolerance) return by - ay; // top -> bottom
|
|
||||||
const ax = (a.minX + a.maxX) * 0.5;
|
|
||||||
const bx = (b.minX + b.maxX) * 0.5;
|
|
||||||
return ax - bx; // left -> right
|
|
||||||
});
|
|
||||||
|
|
||||||
sorted.forEach((chair, index) => {
|
|
||||||
chair.key = String(index + 1);
|
|
||||||
chair.seatNo = index + 1;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
renumberChairKeys(chairGeometry);
|
|
||||||
const PICK_GRID_SIZE = 1800;
|
|
||||||
const chairPickGrid = new Map();
|
|
||||||
function pickGridKey(gx, gy) {
|
|
||||||
return `${gx},${gy}`;
|
|
||||||
}
|
|
||||||
chairGeometry.forEach((chair, index) => {
|
|
||||||
const minGX = Math.floor(chair.minX / PICK_GRID_SIZE);
|
|
||||||
const maxGX = Math.floor(chair.maxX / PICK_GRID_SIZE);
|
|
||||||
const minGY = Math.floor(chair.minY / PICK_GRID_SIZE);
|
|
||||||
const maxGY = Math.floor(chair.maxY / PICK_GRID_SIZE);
|
|
||||||
for (let gx = minGX; gx <= maxGX; gx += 1) {
|
|
||||||
for (let gy = minGY; gy <= maxGY; gy += 1) {
|
|
||||||
const key = pickGridKey(gx, gy);
|
|
||||||
if (!chairPickGrid.has(key)) chairPickGrid.set(key, []);
|
|
||||||
chairPickGrid.get(key).push(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
|
|
||||||
let pixelRatio = window.devicePixelRatio || 1;
|
|
||||||
let pointer = { x: 0, y: 0 };
|
|
||||||
let dragging = false;
|
|
||||||
let dragStart = null;
|
|
||||||
let hovered = null;
|
|
||||||
let rafPending = false;
|
|
||||||
|
|
||||||
function normalizePeople(raw) {
|
|
||||||
return raw
|
|
||||||
.map((person, index) => {
|
|
||||||
if (!person || !person.name) return null;
|
|
||||||
return {
|
|
||||||
id: person.id || `person-${index + 1}`,
|
|
||||||
name: String(person.name).trim(),
|
|
||||||
dept: String(person.dept || "").trim(),
|
|
||||||
title: String(person.title || "").trim(),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTemplatePeople() {
|
|
||||||
const generated = [];
|
|
||||||
let seq = 1;
|
|
||||||
ORG_TEMPLATE.top.members.forEach((member) => {
|
|
||||||
generated.push({
|
|
||||||
id: `org-${seq++}`,
|
|
||||||
name: member.name,
|
|
||||||
dept: member.dept,
|
|
||||||
title: member.title,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
ORG_TEMPLATE.teams.forEach((team) => {
|
|
||||||
team.members.forEach((name) => {
|
|
||||||
generated.push({
|
|
||||||
id: `org-${seq++}`,
|
|
||||||
name,
|
|
||||||
dept: team.name,
|
|
||||||
title: "선임",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return generated;
|
|
||||||
}
|
|
||||||
|
|
||||||
people = normalizePeople(people);
|
|
||||||
const templateReady = people.some((person) => person.name === "장종찬" && person.dept === "총괄기획실");
|
|
||||||
if (!templateReady) {
|
|
||||||
people = createTemplatePeople();
|
|
||||||
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
|
|
||||||
}
|
|
||||||
const chairKeySet = new Set(chairGeometry.map((chair) => chair.key));
|
|
||||||
chairAssignments = Object.fromEntries(
|
|
||||||
Object.entries(chairAssignments).filter(([chairKey, personId]) => (
|
|
||||||
chairKeySet.has(chairKey) && people.some((person) => person.id === personId)
|
|
||||||
))
|
|
||||||
);
|
|
||||||
if (activePersonId && !people.some((person) => person.id === activePersonId)) activePersonId = null;
|
|
||||||
|
|
||||||
function persistPeople() {
|
|
||||||
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistAssignments() {
|
|
||||||
localStorage.setItem(ASSIGN_STORAGE_KEY, JSON.stringify(chairAssignments));
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistActivePerson() {
|
|
||||||
if (!activePersonId) localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
|
|
||||||
else localStorage.setItem(ACTIVE_PERSON_STORAGE_KEY, activePersonId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assignmentCount() {
|
|
||||||
return Object.keys(chairAssignments).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPersonById(id) {
|
|
||||||
return people.find((person) => person.id === id) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getChairByPerson(personId) {
|
|
||||||
for (const [chairKey, assignedPersonId] of Object.entries(chairAssignments)) {
|
|
||||||
if (assignedPersonId === personId) return chairKey;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPeopleList() {
|
|
||||||
const activePerson = getPersonById(activePersonId);
|
|
||||||
const countText = `${assignmentCount()} / ${people.length} 매칭`;
|
|
||||||
mapperStatus.innerHTML = `<strong>조직 현황</strong><span>${activePerson ? `${activePerson.name} 선택됨` : "선택 인원 없음"} · ${countText}</span>`;
|
|
||||||
|
|
||||||
const findPerson = (dept, name) => people.find((person) => person.dept === dept && person.name === name) || null;
|
|
||||||
const personCard = (person, roleText) => {
|
|
||||||
if (!person) return "";
|
|
||||||
const chairKey = getChairByPerson(person.id);
|
|
||||||
const assignedClass = chairKey ? " assigned" : "";
|
|
||||||
const activeClass = person.id === activePersonId ? " active" : "";
|
|
||||||
return `
|
|
||||||
<article class="org-person${assignedClass}${activeClass}" data-person-id="${person.id}">
|
|
||||||
<strong>${person.name}</strong>
|
|
||||||
<small>${person.title || roleText || "-"}</small>
|
|
||||||
<small>${chairKey ? `좌석 ${chairKey}` : "좌석 미지정"}</small>
|
|
||||||
</article>
|
|
||||||
`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const topHtml = ORG_TEMPLATE.top.members
|
|
||||||
.map((member) => personCard(findPerson(member.dept, member.name), member.title))
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
const teamsHtml = ORG_TEMPLATE.teams.map((team) => {
|
|
||||||
const membersHtml = team.members
|
|
||||||
.map((name) => personCard(findPerson(team.name, name), "선임"))
|
|
||||||
.join("");
|
|
||||||
return `
|
|
||||||
<section class="org-team">
|
|
||||||
<h4>${team.name} (${team.count})</h4>
|
|
||||||
<div class="org-members">${membersHtml}</div>
|
|
||||||
</section>
|
|
||||||
`;
|
|
||||||
}).join("");
|
|
||||||
|
|
||||||
orgChartEl.innerHTML = `
|
|
||||||
<section class="org-top">
|
|
||||||
<div class="org-top-title">${ORG_TEMPLATE.top.name} (${ORG_TEMPLATE.top.count})</div>
|
|
||||||
<div class="org-top-members">${topHtml}</div>
|
|
||||||
</section>
|
|
||||||
<section class="org-teams">${teamsHtml}</section>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function worldToScreen(x, y) {
|
|
||||||
return {
|
|
||||||
x: x * camera.scale + camera.offsetX,
|
|
||||||
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function screenToWorld(x, y) {
|
|
||||||
return {
|
|
||||||
x: (x - camera.offsetX) / camera.scale,
|
|
||||||
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resize() {
|
|
||||||
pixelRatio = window.devicePixelRatio || 1;
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
canvas.width = Math.round(rect.width * pixelRatio);
|
|
||||||
canvas.height = Math.round(rect.height * pixelRatio);
|
|
||||||
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
|
||||||
fit();
|
|
||||||
}
|
|
||||||
|
|
||||||
function fit() {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const width = world.maxX - world.minX;
|
|
||||||
const height = world.maxY - world.minY;
|
|
||||||
const pad = 36;
|
|
||||||
const scaleX = (rect.width - pad * 2) / width;
|
|
||||||
const scaleY = (rect.height - pad * 2) / height;
|
|
||||||
camera.scale = Math.min(scaleX, scaleY);
|
|
||||||
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
|
|
||||||
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
|
|
||||||
requestDraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawGrid(width, height) {
|
|
||||||
ctx.save();
|
|
||||||
ctx.strokeStyle = "rgba(21,35,48,0.05)";
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
for (let x = 120; x < width; x += 120) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x, 0);
|
|
||||||
ctx.lineTo(x, height);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
for (let y = 120; y < height; y += 120) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(0, y);
|
|
||||||
ctx.lineTo(width, y);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickChair(screenX, screenY) {
|
|
||||||
const threshold = 12;
|
|
||||||
const pointerWorld = screenToWorld(screenX, screenY);
|
|
||||||
const thresholdWorld = threshold / camera.scale;
|
|
||||||
const thresholdWorldSq = thresholdWorld * thresholdWorld;
|
|
||||||
const minGX = Math.floor((pointerWorld.x - thresholdWorld) / PICK_GRID_SIZE);
|
|
||||||
const maxGX = Math.floor((pointerWorld.x + thresholdWorld) / PICK_GRID_SIZE);
|
|
||||||
const minGY = Math.floor((pointerWorld.y - thresholdWorld) / PICK_GRID_SIZE);
|
|
||||||
const maxGY = Math.floor((pointerWorld.y + thresholdWorld) / PICK_GRID_SIZE);
|
|
||||||
const candidateIndexes = [];
|
|
||||||
const seen = new Set();
|
|
||||||
for (let gx = minGX; gx <= maxGX; gx += 1) {
|
|
||||||
for (let gy = minGY; gy <= maxGY; gy += 1) {
|
|
||||||
const candidates = chairPickGrid.get(pickGridKey(gx, gy));
|
|
||||||
if (!candidates) continue;
|
|
||||||
for (const index of candidates) {
|
|
||||||
if (seen.has(index)) continue;
|
|
||||||
seen.add(index);
|
|
||||||
candidateIndexes.push(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let best = null;
|
|
||||||
for (const index of candidateIndexes) {
|
|
||||||
const chair = chairGeometry[index];
|
|
||||||
if (
|
|
||||||
pointerWorld.x < chair.minX - thresholdWorld ||
|
|
||||||
pointerWorld.x > chair.maxX + thresholdWorld ||
|
|
||||||
pointerWorld.y < chair.minY - thresholdWorld ||
|
|
||||||
pointerWorld.y > chair.maxY + thresholdWorld
|
|
||||||
) continue;
|
|
||||||
let distSq = Infinity;
|
|
||||||
for (let i = 0; i < chair.hitSegments.length; i += 4) {
|
|
||||||
const x1 = chair.hitSegments[i];
|
|
||||||
const y1 = chair.hitSegments[i + 1];
|
|
||||||
const x2 = chair.hitSegments[i + 2];
|
|
||||||
const y2 = chair.hitSegments[i + 3];
|
|
||||||
const dx = x2 - x1;
|
|
||||||
const dy = y2 - y1;
|
|
||||||
const len2 = dx * dx + dy * dy;
|
|
||||||
let segDistSq;
|
|
||||||
if (len2 === 0) {
|
|
||||||
const px = pointerWorld.x - x1;
|
|
||||||
const py = pointerWorld.y - y1;
|
|
||||||
segDistSq = px * px + py * py;
|
|
||||||
} else {
|
|
||||||
let t = ((pointerWorld.x - x1) * dx + (pointerWorld.y - y1) * dy) / len2;
|
|
||||||
t = Math.max(0, Math.min(1, t));
|
|
||||||
const lx = x1 + t * dx;
|
|
||||||
const ly = y1 + t * dy;
|
|
||||||
const px = pointerWorld.x - lx;
|
|
||||||
const py = pointerWorld.y - ly;
|
|
||||||
segDistSq = px * px + py * py;
|
|
||||||
}
|
|
||||||
if (segDistSq < distSq) distSq = segDistSq;
|
|
||||||
if (distSq <= thresholdWorldSq * 0.3) break;
|
|
||||||
}
|
|
||||||
if (distSq > thresholdWorldSq) continue;
|
|
||||||
const dist = Math.sqrt(distSq) * camera.scale;
|
|
||||||
|
|
||||||
if (!best) {
|
|
||||||
best = { chair, dist };
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const distGap = dist - best.dist;
|
|
||||||
if (distGap < -0.75) {
|
|
||||||
best = { chair, dist };
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Math.abs(distGap) <= 2) {
|
|
||||||
const areaGap = chair.area - best.chair.area;
|
|
||||||
if (areaGap < -1) {
|
|
||||||
best = { chair, dist };
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
Math.abs(areaGap) <= 1 &&
|
|
||||||
chair.kind === "block" &&
|
|
||||||
best.chair.kind !== "block"
|
|
||||||
) {
|
|
||||||
best = { chair, dist };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return best ? best.chair : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTooltip() {
|
|
||||||
if (!hovered) {
|
|
||||||
tooltip.classList.remove("visible");
|
|
||||||
hoverChip.textContent = "chair hover: none";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
hoverChip.textContent = `chair hover: ${hovered.name}`;
|
|
||||||
tooltip.innerHTML = `
|
|
||||||
<strong>${hovered.name}</strong>
|
|
||||||
<div>chair key: ${hovered.key}</div>
|
|
||||||
<div>${placed.has(hovered.key) ? "선택됨" : "클릭하면 선택"}</div>
|
|
||||||
<div>${chairAssignments[hovered.key] ? `배치: ${(getPersonById(chairAssignments[hovered.key]) || { name: "알수없음" }).name}` : "배치 인원 없음"}</div>
|
|
||||||
`;
|
|
||||||
tooltip.style.left = `${pointer.x + 14}px`;
|
|
||||||
tooltip.style.top = `${pointer.y + 14}px`;
|
|
||||||
tooltip.classList.add("visible");
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestDraw() {
|
|
||||||
if (rafPending) return;
|
|
||||||
rafPending = true;
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
rafPending = false;
|
|
||||||
draw();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyWorldTransform() {
|
|
||||||
ctx.setTransform(
|
|
||||||
pixelRatio * camera.scale,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
-pixelRatio * camera.scale,
|
|
||||||
pixelRatio * camera.offsetX,
|
|
||||||
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function draw() {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
|
||||||
ctx.clearRect(0, 0, rect.width, rect.height);
|
|
||||||
drawGrid(rect.width, rect.height);
|
|
||||||
const viewA = screenToWorld(0, rect.height);
|
|
||||||
const viewB = screenToWorld(rect.width, 0);
|
|
||||||
const viewMinX = Math.min(viewA.x, viewB.x);
|
|
||||||
const viewMaxX = Math.max(viewA.x, viewB.x);
|
|
||||||
const viewMinY = Math.min(viewA.y, viewB.y);
|
|
||||||
const viewMaxY = Math.max(viewA.y, viewB.y);
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
applyWorldTransform();
|
|
||||||
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
|
|
||||||
ctx.lineWidth = 1 / camera.scale;
|
|
||||||
const tileSize = meta.backgroundTileSize;
|
|
||||||
const tileMinX = Math.floor(viewMinX / tileSize);
|
|
||||||
const tileMaxX = Math.floor(viewMaxX / tileSize);
|
|
||||||
const tileMinY = Math.floor(viewMinY / tileSize);
|
|
||||||
const tileMaxY = Math.floor(viewMaxY / tileSize);
|
|
||||||
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
|
|
||||||
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
|
|
||||||
const range = bgTileRanges[`${tx},${ty}`];
|
|
||||||
if (!range) continue;
|
|
||||||
const start = range[0];
|
|
||||||
const count = range[1];
|
|
||||||
for (let i = start; i < start + count; i += 1) {
|
|
||||||
const offset = i * 4;
|
|
||||||
const x1 = bgSegValues[offset] / 10;
|
|
||||||
const y1 = bgSegValues[offset + 1] / 10;
|
|
||||||
const x2 = bgSegValues[offset + 2] / 10;
|
|
||||||
const y2 = bgSegValues[offset + 3] / 10;
|
|
||||||
if (
|
|
||||||
Math.max(x1, x2) < viewMinX ||
|
|
||||||
Math.min(x1, x2) > viewMaxX ||
|
|
||||||
Math.max(y1, y2) < viewMinY ||
|
|
||||||
Math.min(y1, y2) > viewMaxY
|
|
||||||
) continue;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x1, y1);
|
|
||||||
ctx.lineTo(x2, y2);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
hovered = dragging ? null : pickChair(pointer.x, pointer.y);
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
applyWorldTransform();
|
|
||||||
ctx.lineWidth = 1.45 / camera.scale;
|
|
||||||
ctx.lineCap = "round";
|
|
||||||
ctx.lineJoin = "round";
|
|
||||||
for (const chair of chairGeometry) {
|
|
||||||
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
|
|
||||||
const active = hovered && hovered.key === chair.key;
|
|
||||||
const selected = placed.has(chair.key);
|
|
||||||
const assignedPersonId = chairAssignments[chair.key];
|
|
||||||
const activePersonChair = activePersonId && assignedPersonId === activePersonId;
|
|
||||||
const assigned = Boolean(assignedPersonId);
|
|
||||||
const baseWidth = chair.kind === "block" ? 1.45 : 1.35;
|
|
||||||
ctx.strokeStyle = activePersonChair
|
|
||||||
? "rgba(234, 179, 8, 1)"
|
|
||||||
: assigned
|
|
||||||
? "rgba(37, 99, 235, 0.98)"
|
|
||||||
: selected
|
|
||||||
? "rgba(220, 38, 38, 0.98)"
|
|
||||||
: active
|
|
||||||
? "rgba(15, 118, 110, 0.98)"
|
|
||||||
: chair.kind === "group"
|
|
||||||
? "rgba(16, 134, 149, 0.74)"
|
|
||||||
: "rgba(21, 149, 142, 0.8)";
|
|
||||||
ctx.lineWidth = (activePersonChair ? 2.8 : assigned ? 2.4 : selected ? 2.6 : active ? 2.1 : baseWidth) / camera.scale;
|
|
||||||
ctx.stroke(chair.path);
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
|
|
||||||
renderTooltip();
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistPlaced() {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.addEventListener("pointerdown", (event) => {
|
|
||||||
dragging = true;
|
|
||||||
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
|
|
||||||
canvas.classList.add("dragging");
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("pointerup", (event) => {
|
|
||||||
if (dragging && dragStart) {
|
|
||||||
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
|
|
||||||
if (move < 4) {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top);
|
|
||||||
if (picked) {
|
|
||||||
if (placed.has(picked.key)) placed.delete(picked.key);
|
|
||||||
else placed.add(picked.key);
|
|
||||||
persistPlaced();
|
|
||||||
if (activePersonId) {
|
|
||||||
const currentChair = getChairByPerson(activePersonId);
|
|
||||||
if (chairAssignments[picked.key] === activePersonId) {
|
|
||||||
delete chairAssignments[picked.key];
|
|
||||||
} else {
|
|
||||||
if (currentChair && currentChair !== picked.key) delete chairAssignments[currentChair];
|
|
||||||
chairAssignments[picked.key] = activePersonId;
|
|
||||||
}
|
|
||||||
persistAssignments();
|
|
||||||
renderPeopleList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dragging = false;
|
|
||||||
dragStart = null;
|
|
||||||
canvas.classList.remove("dragging");
|
|
||||||
requestDraw();
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("pointermove", (event) => {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
|
||||||
if (dragging && dragStart) {
|
|
||||||
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
|
|
||||||
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
|
|
||||||
}
|
|
||||||
requestDraw();
|
|
||||||
});
|
|
||||||
|
|
||||||
canvas.addEventListener("wheel", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const mx = event.clientX - rect.left;
|
|
||||||
const my = event.clientY - rect.top;
|
|
||||||
const before = screenToWorld(mx, my);
|
|
||||||
const factor = event.deltaY < 0 ? 1.08 : 0.92;
|
|
||||||
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
|
|
||||||
const after = worldToScreen(before.x, before.y);
|
|
||||||
camera.offsetX += mx - after.x;
|
|
||||||
camera.offsetY += my - after.y;
|
|
||||||
requestDraw();
|
|
||||||
}, { passive: false });
|
|
||||||
|
|
||||||
document.getElementById("fit-btn").addEventListener("click", fit);
|
|
||||||
document.getElementById("clear-btn").addEventListener("click", () => {
|
|
||||||
placed.clear();
|
|
||||||
persistPlaced();
|
|
||||||
requestDraw();
|
|
||||||
});
|
|
||||||
clearAssignBtn.addEventListener("click", () => {
|
|
||||||
chairAssignments = {};
|
|
||||||
persistAssignments();
|
|
||||||
renderPeopleList();
|
|
||||||
requestDraw();
|
|
||||||
});
|
|
||||||
orgChartEl.addEventListener("click", (event) => {
|
|
||||||
const item = event.target.closest(".org-person[data-person-id]");
|
|
||||||
if (!item) return;
|
|
||||||
const personId = item.getAttribute("data-person-id");
|
|
||||||
activePersonId = personId === activePersonId ? null : personId;
|
|
||||||
persistActivePerson();
|
|
||||||
renderPeopleList();
|
|
||||||
requestDraw();
|
|
||||||
});
|
|
||||||
window.addEventListener("resize", resize);
|
|
||||||
renderPeopleList();
|
|
||||||
resize();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,932 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ko">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>center chair people map 6f</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--ink: #152330;
|
|
||||||
--muted: #627286;
|
|
||||||
--paper: rgba(255,255,255,0.86);
|
|
||||||
--line: rgba(21,35,48,0.1);
|
|
||||||
--accent: #0f766e;
|
|
||||||
--bg: #edf2f6;
|
|
||||||
}
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
|
|
||||||
color: var(--ink);
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
|
|
||||||
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
|
|
||||||
}
|
|
||||||
.page {
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.shell {
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
.panel {
|
|
||||||
border-radius: 0;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
backdrop-filter: none;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
border: none;
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
font: inherit;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
color: white;
|
|
||||||
background: linear-gradient(135deg, #0f766e, #115e59);
|
|
||||||
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
|
|
||||||
}
|
|
||||||
button.alt {
|
|
||||||
color: var(--ink);
|
|
||||||
background: rgba(255,255,255,0.9);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
.viewer {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
.viewer-head {
|
|
||||||
position: absolute;
|
|
||||||
top: 16px;
|
|
||||||
left: 16px;
|
|
||||||
right: 16px;
|
|
||||||
z-index: 2;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.chip {
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 16px;
|
|
||||||
background: rgba(255,255,255,0.82);
|
|
||||||
border: 1px solid rgba(255,255,255,0.94);
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
|
|
||||||
}
|
|
||||||
.viewer-actions {
|
|
||||||
position: absolute;
|
|
||||||
left: 16px;
|
|
||||||
top: 64px;
|
|
||||||
z-index: 2;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.mapper {
|
|
||||||
position: absolute;
|
|
||||||
top: 76px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
width: min(94vw, 1320px);
|
|
||||||
max-height: min(56vh, 560px);
|
|
||||||
overflow: hidden;
|
|
||||||
z-index: 4;
|
|
||||||
border-radius: 20px;
|
|
||||||
background: rgba(234, 239, 247, 0.95);
|
|
||||||
border: 1px solid rgba(101, 119, 146, 0.22);
|
|
||||||
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
backdrop-filter: blur(6px);
|
|
||||||
}
|
|
||||||
.hidden-off {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
.mapper-head {
|
|
||||||
padding: 10px 14px;
|
|
||||||
border-bottom: 1px solid rgba(101,119,146,0.18);
|
|
||||||
font-size: 12px;
|
|
||||||
color: #51607a;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.35;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
background: rgba(255,255,255,0.6);
|
|
||||||
}
|
|
||||||
.mapper-head strong {
|
|
||||||
display: block;
|
|
||||||
color: #17243b;
|
|
||||||
font-size: 20px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
.mapper-head .alt {
|
|
||||||
padding: 8px 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.org-chart {
|
|
||||||
margin: 0;
|
|
||||||
padding: 14px;
|
|
||||||
overflow: auto;
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.org-top {
|
|
||||||
margin: 0 auto;
|
|
||||||
width: min(100%, 420px);
|
|
||||||
border-radius: 14px;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid rgba(67, 84, 118, 0.25);
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
.org-top-title {
|
|
||||||
background: #1e2f4d;
|
|
||||||
color: #fff;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 34px;
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 1.1;
|
|
||||||
padding: 16px 12px;
|
|
||||||
letter-spacing: -0.03em;
|
|
||||||
}
|
|
||||||
.org-top-members {
|
|
||||||
padding: 10px;
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
background: rgba(255,255,255,0.95);
|
|
||||||
}
|
|
||||||
.org-teams {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(7, minmax(160px, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
.org-team {
|
|
||||||
border: 1px solid rgba(110, 126, 152, 0.25);
|
|
||||||
border-radius: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: rgba(255,255,255,0.95);
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.org-team h4 {
|
|
||||||
margin: 0;
|
|
||||||
padding: 9px 10px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #21324e;
|
|
||||||
font-weight: 800;
|
|
||||||
border-bottom: 1px solid rgba(110, 126, 152, 0.2);
|
|
||||||
background: rgba(240, 245, 252, 0.96);
|
|
||||||
}
|
|
||||||
.org-members {
|
|
||||||
padding: 7px;
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.org-person {
|
|
||||||
border: 1px solid rgba(116, 133, 161, 0.25);
|
|
||||||
background: rgba(255,255,255,0.95);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 6px 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 120ms ease, border-color 120ms ease;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.org-person.active {
|
|
||||||
border-color: rgba(15,118,110,0.6);
|
|
||||||
background: rgba(15,118,110,0.11);
|
|
||||||
}
|
|
||||||
.org-person.assigned {
|
|
||||||
border-color: rgba(37,99,235,0.5);
|
|
||||||
background: rgba(37,99,235,0.1);
|
|
||||||
}
|
|
||||||
.org-person strong {
|
|
||||||
display: block;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.3;
|
|
||||||
color: #15233a;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.org-person small {
|
|
||||||
display: block;
|
|
||||||
color: #5a6a86;
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1.25;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
@media (max-width: 980px) {
|
|
||||||
.mapper {
|
|
||||||
top: 72px;
|
|
||||||
width: min(96vw, 920px);
|
|
||||||
max-height: 58vh;
|
|
||||||
}
|
|
||||||
.viewer-actions {
|
|
||||||
top: 64px;
|
|
||||||
left: 12px;
|
|
||||||
right: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.mapper-head strong {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
.org-top-title {
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
.org-teams {
|
|
||||||
grid-template-columns: repeat(3, minmax(150px, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
canvas {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: block;
|
|
||||||
cursor: grab;
|
|
||||||
}
|
|
||||||
canvas.dragging { cursor: grabbing; }
|
|
||||||
.tooltip {
|
|
||||||
position: absolute;
|
|
||||||
min-width: 170px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
border-radius: 16px;
|
|
||||||
background: rgba(17,24,39,0.94);
|
|
||||||
color: white;
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translate(12px, 12px);
|
|
||||||
transition: opacity 120ms ease;
|
|
||||||
z-index: 3;
|
|
||||||
}
|
|
||||||
.tooltip.visible { opacity: 1; }
|
|
||||||
.tooltip strong { display: block; margin-bottom: 6px; font-size: 14px; }
|
|
||||||
.tooltip div { font-size: 12px; line-height: 1.45; color: rgba(255,255,255,0.82); }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="page">
|
|
||||||
<div class="shell">
|
|
||||||
<main class="panel viewer">
|
|
||||||
<div class="viewer-head">
|
|
||||||
<div class="chip" id="scale-chip"></div>
|
|
||||||
<div class="chip" id="hover-chip">chair hover: none</div>
|
|
||||||
</div>
|
|
||||||
<div class="viewer-actions">
|
|
||||||
<button type="button" id="fit-btn">전체 맞춤</button>
|
|
||||||
<button type="button" class="alt" id="clear-btn">선택 지우기</button>
|
|
||||||
</div>
|
|
||||||
<aside class="mapper hidden-off">
|
|
||||||
<div class="mapper-head">
|
|
||||||
<div id="mapper-status">
|
|
||||||
<strong>조직 현황</strong>
|
|
||||||
<span>선택 인원 없음</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="alt" id="clear-assign-btn">매칭 초기화</button>
|
|
||||||
</div>
|
|
||||||
<div class="org-chart" id="org-chart"></div>
|
|
||||||
</aside>
|
|
||||||
<canvas id="canvas"></canvas>
|
|
||||||
<div class="tooltip" id="tooltip"></div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script src="./center_chair_people_payload_6f.js"></script>
|
|
||||||
<script>
|
|
||||||
const DATA = window.CHAIR_MAP_DATA;
|
|
||||||
function decodeSegments(base64) {
|
|
||||||
const binary = atob(base64);
|
|
||||||
const bytes = new Uint8Array(binary.length);
|
|
||||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
||||||
return new Int32Array(bytes.buffer);
|
|
||||||
}
|
|
||||||
const bgTileRanges = DATA.bgTileRanges;
|
|
||||||
const bgSegValues = decodeSegments(DATA.bgSegsB64);
|
|
||||||
const chairSegValues = decodeSegments(DATA.chairSegsB64);
|
|
||||||
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
|
|
||||||
key, name, kind, start, count
|
|
||||||
}));
|
|
||||||
const meta = DATA.meta;
|
|
||||||
const world = meta.headerBounds;
|
|
||||||
const canvas = document.getElementById("canvas");
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
const tooltip = document.getElementById("tooltip");
|
|
||||||
const scaleChip = document.getElementById("scale-chip");
|
|
||||||
const hoverChip = document.getElementById("hover-chip");
|
|
||||||
const STORAGE_KEY = "ptc-chair-selection";
|
|
||||||
const PEOPLE_STORAGE_KEY = "ptc-chair-people";
|
|
||||||
const ASSIGN_STORAGE_KEY = "ptc-chair-assignments";
|
|
||||||
const ACTIVE_PERSON_STORAGE_KEY = "ptc-chair-active-person";
|
|
||||||
const clearAssignBtn = document.getElementById("clear-assign-btn");
|
|
||||||
const orgChartEl = document.getElementById("org-chart");
|
|
||||||
const mapperStatus = document.getElementById("mapper-status");
|
|
||||||
// Prevent stale auto-highlights from previous sessions.
|
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
|
||||||
localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
|
|
||||||
localStorage.removeItem(ASSIGN_STORAGE_KEY);
|
|
||||||
const placed = new Set();
|
|
||||||
let people = JSON.parse(localStorage.getItem(PEOPLE_STORAGE_KEY) || "[]");
|
|
||||||
let chairAssignments = {};
|
|
||||||
let activePersonId = null;
|
|
||||||
const ORG_TEMPLATE = {
|
|
||||||
top: {
|
|
||||||
name: "총괄기획실",
|
|
||||||
count: 53,
|
|
||||||
members: [
|
|
||||||
{ name: "장종찬", dept: "총괄기획실", title: "기획실장" },
|
|
||||||
{ name: "김원식", dept: "총괄기획실", title: "전무이사" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
teams: [
|
|
||||||
{ name: "경영기획팀", count: 6, members: ["김우진", "임민정", "국혜린", "최선아", "김윤재", "이미영"] },
|
|
||||||
{ name: "인재성장팀", count: 5, members: ["조태희", "최근혜", "류원준", "주안기", "정성호"] },
|
|
||||||
{ name: "ERP 기획팀", count: 5, members: ["류호성", "문형식", "최요제", "황대일", "이채봉"] },
|
|
||||||
{ name: "디자인기획팀", count: 17, members: ["신혜영", "정은혜", "김태식", "최예은", "채선영", "최영환", "윤봄이", "이예진", "허유나", "마희연", "김수현", "박지영", "권순호", "정두휘", "김정석", "정지윤", "양숙영"] },
|
|
||||||
{ name: "기술기획팀", count: 11, members: ["김원기", "홍아름", "이경민", "김혜인", "황동환", "최찬호", "이태훈", "김신지", "조찬영", "김용연", "한치영"] },
|
|
||||||
{ name: "협업증진팀", count: 3, members: ["성형일", "박주한", "한승민"] },
|
|
||||||
{ name: "솔루션통합팀", count: 4, members: ["권혁진", "염승호", "윤준수", "김지영"] },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const chairGeometry = chairs.map((chair) => {
|
|
||||||
let minX = Infinity;
|
|
||||||
let minY = Infinity;
|
|
||||||
let maxX = -Infinity;
|
|
||||||
let maxY = -Infinity;
|
|
||||||
const path = new Path2D();
|
|
||||||
const hitSegments = new Float32Array(chair.count * 4);
|
|
||||||
let segCursor = 0;
|
|
||||||
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
|
|
||||||
const offset = i * 4;
|
|
||||||
const x1 = chairSegValues[offset] / 10;
|
|
||||||
const y1 = chairSegValues[offset + 1] / 10;
|
|
||||||
const x2 = chairSegValues[offset + 2] / 10;
|
|
||||||
const y2 = chairSegValues[offset + 3] / 10;
|
|
||||||
path.moveTo(x1, y1);
|
|
||||||
path.lineTo(x2, y2);
|
|
||||||
hitSegments[segCursor] = x1;
|
|
||||||
hitSegments[segCursor + 1] = y1;
|
|
||||||
hitSegments[segCursor + 2] = x2;
|
|
||||||
hitSegments[segCursor + 3] = y2;
|
|
||||||
segCursor += 4;
|
|
||||||
minX = Math.min(minX, x1, x2);
|
|
||||||
minY = Math.min(minY, y1, y2);
|
|
||||||
maxX = Math.max(maxX, x1, x2);
|
|
||||||
maxY = Math.max(maxY, y1, y2);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...chair,
|
|
||||||
minX,
|
|
||||||
minY,
|
|
||||||
maxX,
|
|
||||||
maxY,
|
|
||||||
area: Math.max(1, (maxX - minX) * (maxY - minY)),
|
|
||||||
path,
|
|
||||||
hitSegments,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
function renumberChairKeys(chairItems) {
|
|
||||||
if (!chairItems.length) return;
|
|
||||||
const heights = chairItems
|
|
||||||
.map((chair) => Math.max(1, chair.maxY - chair.minY))
|
|
||||||
.sort((a, b) => a - b);
|
|
||||||
const medianHeight = heights[Math.floor(heights.length / 2)] || 1;
|
|
||||||
const rowTolerance = Math.max(40, medianHeight * 0.9);
|
|
||||||
|
|
||||||
const sorted = [...chairItems].sort((a, b) => {
|
|
||||||
const ay = (a.minY + a.maxY) * 0.5;
|
|
||||||
const by = (b.minY + b.maxY) * 0.5;
|
|
||||||
if (Math.abs(by - ay) > rowTolerance) return by - ay; // top -> bottom
|
|
||||||
const ax = (a.minX + a.maxX) * 0.5;
|
|
||||||
const bx = (b.minX + b.maxX) * 0.5;
|
|
||||||
return ax - bx; // left -> right
|
|
||||||
});
|
|
||||||
|
|
||||||
sorted.forEach((chair, index) => {
|
|
||||||
chair.key = String(index + 1);
|
|
||||||
chair.seatNo = index + 1;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
renumberChairKeys(chairGeometry);
|
|
||||||
const PICK_GRID_SIZE = 1800;
|
|
||||||
const chairPickGrid = new Map();
|
|
||||||
function pickGridKey(gx, gy) {
|
|
||||||
return `${gx},${gy}`;
|
|
||||||
}
|
|
||||||
chairGeometry.forEach((chair, index) => {
|
|
||||||
const minGX = Math.floor(chair.minX / PICK_GRID_SIZE);
|
|
||||||
const maxGX = Math.floor(chair.maxX / PICK_GRID_SIZE);
|
|
||||||
const minGY = Math.floor(chair.minY / PICK_GRID_SIZE);
|
|
||||||
const maxGY = Math.floor(chair.maxY / PICK_GRID_SIZE);
|
|
||||||
for (let gx = minGX; gx <= maxGX; gx += 1) {
|
|
||||||
for (let gy = minGY; gy <= maxGY; gy += 1) {
|
|
||||||
const key = pickGridKey(gx, gy);
|
|
||||||
if (!chairPickGrid.has(key)) chairPickGrid.set(key, []);
|
|
||||||
chairPickGrid.get(key).push(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
|
|
||||||
let pixelRatio = window.devicePixelRatio || 1;
|
|
||||||
let pointer = { x: 0, y: 0 };
|
|
||||||
let dragging = false;
|
|
||||||
let dragStart = null;
|
|
||||||
let hovered = null;
|
|
||||||
let rafPending = false;
|
|
||||||
|
|
||||||
function normalizePeople(raw) {
|
|
||||||
return raw
|
|
||||||
.map((person, index) => {
|
|
||||||
if (!person || !person.name) return null;
|
|
||||||
return {
|
|
||||||
id: person.id || `person-${index + 1}`,
|
|
||||||
name: String(person.name).trim(),
|
|
||||||
dept: String(person.dept || "").trim(),
|
|
||||||
title: String(person.title || "").trim(),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTemplatePeople() {
|
|
||||||
const generated = [];
|
|
||||||
let seq = 1;
|
|
||||||
ORG_TEMPLATE.top.members.forEach((member) => {
|
|
||||||
generated.push({
|
|
||||||
id: `org-${seq++}`,
|
|
||||||
name: member.name,
|
|
||||||
dept: member.dept,
|
|
||||||
title: member.title,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
ORG_TEMPLATE.teams.forEach((team) => {
|
|
||||||
team.members.forEach((name) => {
|
|
||||||
generated.push({
|
|
||||||
id: `org-${seq++}`,
|
|
||||||
name,
|
|
||||||
dept: team.name,
|
|
||||||
title: "선임",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return generated;
|
|
||||||
}
|
|
||||||
|
|
||||||
people = normalizePeople(people);
|
|
||||||
const templateReady = people.some((person) => person.name === "장종찬" && person.dept === "총괄기획실");
|
|
||||||
if (!templateReady) {
|
|
||||||
people = createTemplatePeople();
|
|
||||||
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
|
|
||||||
}
|
|
||||||
const chairKeySet = new Set(chairGeometry.map((chair) => chair.key));
|
|
||||||
chairAssignments = Object.fromEntries(
|
|
||||||
Object.entries(chairAssignments).filter(([chairKey, personId]) => (
|
|
||||||
chairKeySet.has(chairKey) && people.some((person) => person.id === personId)
|
|
||||||
))
|
|
||||||
);
|
|
||||||
if (activePersonId && !people.some((person) => person.id === activePersonId)) activePersonId = null;
|
|
||||||
|
|
||||||
function persistPeople() {
|
|
||||||
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistAssignments() {
|
|
||||||
localStorage.setItem(ASSIGN_STORAGE_KEY, JSON.stringify(chairAssignments));
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistActivePerson() {
|
|
||||||
if (!activePersonId) localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
|
|
||||||
else localStorage.setItem(ACTIVE_PERSON_STORAGE_KEY, activePersonId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assignmentCount() {
|
|
||||||
return Object.keys(chairAssignments).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPersonById(id) {
|
|
||||||
return people.find((person) => person.id === id) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getChairByPerson(personId) {
|
|
||||||
for (const [chairKey, assignedPersonId] of Object.entries(chairAssignments)) {
|
|
||||||
if (assignedPersonId === personId) return chairKey;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPeopleList() {
|
|
||||||
const activePerson = getPersonById(activePersonId);
|
|
||||||
const countText = `${assignmentCount()} / ${people.length} 매칭`;
|
|
||||||
mapperStatus.innerHTML = `<strong>조직 현황</strong><span>${activePerson ? `${activePerson.name} 선택됨` : "선택 인원 없음"} · ${countText}</span>`;
|
|
||||||
|
|
||||||
const findPerson = (dept, name) => people.find((person) => person.dept === dept && person.name === name) || null;
|
|
||||||
const personCard = (person, roleText) => {
|
|
||||||
if (!person) return "";
|
|
||||||
const chairKey = getChairByPerson(person.id);
|
|
||||||
const assignedClass = chairKey ? " assigned" : "";
|
|
||||||
const activeClass = person.id === activePersonId ? " active" : "";
|
|
||||||
return `
|
|
||||||
<article class="org-person${assignedClass}${activeClass}" data-person-id="${person.id}">
|
|
||||||
<strong>${person.name}</strong>
|
|
||||||
<small>${person.title || roleText || "-"}</small>
|
|
||||||
<small>${chairKey ? `좌석 ${chairKey}` : "좌석 미지정"}</small>
|
|
||||||
</article>
|
|
||||||
`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const topHtml = ORG_TEMPLATE.top.members
|
|
||||||
.map((member) => personCard(findPerson(member.dept, member.name), member.title))
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
const teamsHtml = ORG_TEMPLATE.teams.map((team) => {
|
|
||||||
const membersHtml = team.members
|
|
||||||
.map((name) => personCard(findPerson(team.name, name), "선임"))
|
|
||||||
.join("");
|
|
||||||
return `
|
|
||||||
<section class="org-team">
|
|
||||||
<h4>${team.name} (${team.count})</h4>
|
|
||||||
<div class="org-members">${membersHtml}</div>
|
|
||||||
</section>
|
|
||||||
`;
|
|
||||||
}).join("");
|
|
||||||
|
|
||||||
orgChartEl.innerHTML = `
|
|
||||||
<section class="org-top">
|
|
||||||
<div class="org-top-title">${ORG_TEMPLATE.top.name} (${ORG_TEMPLATE.top.count})</div>
|
|
||||||
<div class="org-top-members">${topHtml}</div>
|
|
||||||
</section>
|
|
||||||
<section class="org-teams">${teamsHtml}</section>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function worldToScreen(x, y) {
|
|
||||||
return {
|
|
||||||
x: x * camera.scale + camera.offsetX,
|
|
||||||
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function screenToWorld(x, y) {
|
|
||||||
return {
|
|
||||||
x: (x - camera.offsetX) / camera.scale,
|
|
||||||
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resize() {
|
|
||||||
pixelRatio = window.devicePixelRatio || 1;
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
canvas.width = Math.round(rect.width * pixelRatio);
|
|
||||||
canvas.height = Math.round(rect.height * pixelRatio);
|
|
||||||
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
|
||||||
fit();
|
|
||||||
}
|
|
||||||
|
|
||||||
function fit() {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const width = world.maxX - world.minX;
|
|
||||||
const height = world.maxY - world.minY;
|
|
||||||
const pad = 36;
|
|
||||||
const scaleX = (rect.width - pad * 2) / width;
|
|
||||||
const scaleY = (rect.height - pad * 2) / height;
|
|
||||||
camera.scale = Math.min(scaleX, scaleY);
|
|
||||||
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
|
|
||||||
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
|
|
||||||
requestDraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawGrid(width, height) {
|
|
||||||
ctx.save();
|
|
||||||
ctx.strokeStyle = "rgba(21,35,48,0.05)";
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
for (let x = 120; x < width; x += 120) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x, 0);
|
|
||||||
ctx.lineTo(x, height);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
for (let y = 120; y < height; y += 120) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(0, y);
|
|
||||||
ctx.lineTo(width, y);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickChair(screenX, screenY) {
|
|
||||||
const threshold = 12;
|
|
||||||
const pointerWorld = screenToWorld(screenX, screenY);
|
|
||||||
const thresholdWorld = threshold / camera.scale;
|
|
||||||
const thresholdWorldSq = thresholdWorld * thresholdWorld;
|
|
||||||
const minGX = Math.floor((pointerWorld.x - thresholdWorld) / PICK_GRID_SIZE);
|
|
||||||
const maxGX = Math.floor((pointerWorld.x + thresholdWorld) / PICK_GRID_SIZE);
|
|
||||||
const minGY = Math.floor((pointerWorld.y - thresholdWorld) / PICK_GRID_SIZE);
|
|
||||||
const maxGY = Math.floor((pointerWorld.y + thresholdWorld) / PICK_GRID_SIZE);
|
|
||||||
const candidateIndexes = [];
|
|
||||||
const seen = new Set();
|
|
||||||
for (let gx = minGX; gx <= maxGX; gx += 1) {
|
|
||||||
for (let gy = minGY; gy <= maxGY; gy += 1) {
|
|
||||||
const candidates = chairPickGrid.get(pickGridKey(gx, gy));
|
|
||||||
if (!candidates) continue;
|
|
||||||
for (const index of candidates) {
|
|
||||||
if (seen.has(index)) continue;
|
|
||||||
seen.add(index);
|
|
||||||
candidateIndexes.push(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let best = null;
|
|
||||||
for (const index of candidateIndexes) {
|
|
||||||
const chair = chairGeometry[index];
|
|
||||||
if (
|
|
||||||
pointerWorld.x < chair.minX - thresholdWorld ||
|
|
||||||
pointerWorld.x > chair.maxX + thresholdWorld ||
|
|
||||||
pointerWorld.y < chair.minY - thresholdWorld ||
|
|
||||||
pointerWorld.y > chair.maxY + thresholdWorld
|
|
||||||
) continue;
|
|
||||||
let distSq = Infinity;
|
|
||||||
for (let i = 0; i < chair.hitSegments.length; i += 4) {
|
|
||||||
const x1 = chair.hitSegments[i];
|
|
||||||
const y1 = chair.hitSegments[i + 1];
|
|
||||||
const x2 = chair.hitSegments[i + 2];
|
|
||||||
const y2 = chair.hitSegments[i + 3];
|
|
||||||
const dx = x2 - x1;
|
|
||||||
const dy = y2 - y1;
|
|
||||||
const len2 = dx * dx + dy * dy;
|
|
||||||
let segDistSq;
|
|
||||||
if (len2 === 0) {
|
|
||||||
const px = pointerWorld.x - x1;
|
|
||||||
const py = pointerWorld.y - y1;
|
|
||||||
segDistSq = px * px + py * py;
|
|
||||||
} else {
|
|
||||||
let t = ((pointerWorld.x - x1) * dx + (pointerWorld.y - y1) * dy) / len2;
|
|
||||||
t = Math.max(0, Math.min(1, t));
|
|
||||||
const lx = x1 + t * dx;
|
|
||||||
const ly = y1 + t * dy;
|
|
||||||
const px = pointerWorld.x - lx;
|
|
||||||
const py = pointerWorld.y - ly;
|
|
||||||
segDistSq = px * px + py * py;
|
|
||||||
}
|
|
||||||
if (segDistSq < distSq) distSq = segDistSq;
|
|
||||||
if (distSq <= thresholdWorldSq * 0.3) break;
|
|
||||||
}
|
|
||||||
if (distSq > thresholdWorldSq) continue;
|
|
||||||
const dist = Math.sqrt(distSq) * camera.scale;
|
|
||||||
|
|
||||||
if (!best) {
|
|
||||||
best = { chair, dist };
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const distGap = dist - best.dist;
|
|
||||||
if (distGap < -0.75) {
|
|
||||||
best = { chair, dist };
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Math.abs(distGap) <= 2) {
|
|
||||||
const areaGap = chair.area - best.chair.area;
|
|
||||||
if (areaGap < -1) {
|
|
||||||
best = { chair, dist };
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
Math.abs(areaGap) <= 1 &&
|
|
||||||
chair.kind === "block" &&
|
|
||||||
best.chair.kind !== "block"
|
|
||||||
) {
|
|
||||||
best = { chair, dist };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return best ? best.chair : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTooltip() {
|
|
||||||
if (!hovered) {
|
|
||||||
tooltip.classList.remove("visible");
|
|
||||||
hoverChip.textContent = "chair hover: none";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
hoverChip.textContent = `chair hover: ${hovered.name}`;
|
|
||||||
tooltip.innerHTML = `
|
|
||||||
<strong>${hovered.name}</strong>
|
|
||||||
<div>chair key: ${hovered.key}</div>
|
|
||||||
<div>${placed.has(hovered.key) ? "선택됨" : "클릭하면 선택"}</div>
|
|
||||||
<div>${chairAssignments[hovered.key] ? `배치: ${(getPersonById(chairAssignments[hovered.key]) || { name: "알수없음" }).name}` : "배치 인원 없음"}</div>
|
|
||||||
`;
|
|
||||||
tooltip.style.left = `${pointer.x + 14}px`;
|
|
||||||
tooltip.style.top = `${pointer.y + 14}px`;
|
|
||||||
tooltip.classList.add("visible");
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestDraw() {
|
|
||||||
if (rafPending) return;
|
|
||||||
rafPending = true;
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
rafPending = false;
|
|
||||||
draw();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyWorldTransform() {
|
|
||||||
ctx.setTransform(
|
|
||||||
pixelRatio * camera.scale,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
-pixelRatio * camera.scale,
|
|
||||||
pixelRatio * camera.offsetX,
|
|
||||||
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function draw() {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
|
||||||
ctx.clearRect(0, 0, rect.width, rect.height);
|
|
||||||
drawGrid(rect.width, rect.height);
|
|
||||||
const viewA = screenToWorld(0, rect.height);
|
|
||||||
const viewB = screenToWorld(rect.width, 0);
|
|
||||||
const viewMinX = Math.min(viewA.x, viewB.x);
|
|
||||||
const viewMaxX = Math.max(viewA.x, viewB.x);
|
|
||||||
const viewMinY = Math.min(viewA.y, viewB.y);
|
|
||||||
const viewMaxY = Math.max(viewA.y, viewB.y);
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
applyWorldTransform();
|
|
||||||
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
|
|
||||||
ctx.lineWidth = 1 / camera.scale;
|
|
||||||
const tileSize = meta.backgroundTileSize;
|
|
||||||
const tileMinX = Math.floor(viewMinX / tileSize);
|
|
||||||
const tileMaxX = Math.floor(viewMaxX / tileSize);
|
|
||||||
const tileMinY = Math.floor(viewMinY / tileSize);
|
|
||||||
const tileMaxY = Math.floor(viewMaxY / tileSize);
|
|
||||||
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
|
|
||||||
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
|
|
||||||
const range = bgTileRanges[`${tx},${ty}`];
|
|
||||||
if (!range) continue;
|
|
||||||
const start = range[0];
|
|
||||||
const count = range[1];
|
|
||||||
for (let i = start; i < start + count; i += 1) {
|
|
||||||
const offset = i * 4;
|
|
||||||
const x1 = bgSegValues[offset] / 10;
|
|
||||||
const y1 = bgSegValues[offset + 1] / 10;
|
|
||||||
const x2 = bgSegValues[offset + 2] / 10;
|
|
||||||
const y2 = bgSegValues[offset + 3] / 10;
|
|
||||||
if (
|
|
||||||
Math.max(x1, x2) < viewMinX ||
|
|
||||||
Math.min(x1, x2) > viewMaxX ||
|
|
||||||
Math.max(y1, y2) < viewMinY ||
|
|
||||||
Math.min(y1, y2) > viewMaxY
|
|
||||||
) continue;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x1, y1);
|
|
||||||
ctx.lineTo(x2, y2);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
hovered = dragging ? null : pickChair(pointer.x, pointer.y);
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
applyWorldTransform();
|
|
||||||
ctx.lineWidth = 1.45 / camera.scale;
|
|
||||||
ctx.lineCap = "round";
|
|
||||||
ctx.lineJoin = "round";
|
|
||||||
for (const chair of chairGeometry) {
|
|
||||||
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
|
|
||||||
const active = hovered && hovered.key === chair.key;
|
|
||||||
const selected = placed.has(chair.key);
|
|
||||||
const assignedPersonId = chairAssignments[chair.key];
|
|
||||||
const activePersonChair = activePersonId && assignedPersonId === activePersonId;
|
|
||||||
const assigned = Boolean(assignedPersonId);
|
|
||||||
const baseWidth = chair.kind === "block" ? 1.45 : 1.35;
|
|
||||||
ctx.strokeStyle = activePersonChair
|
|
||||||
? "rgba(234, 179, 8, 1)"
|
|
||||||
: assigned
|
|
||||||
? "rgba(37, 99, 235, 0.98)"
|
|
||||||
: selected
|
|
||||||
? "rgba(220, 38, 38, 0.98)"
|
|
||||||
: active
|
|
||||||
? "rgba(15, 118, 110, 0.98)"
|
|
||||||
: chair.kind === "group"
|
|
||||||
? "rgba(16, 134, 149, 0.74)"
|
|
||||||
: "rgba(21, 149, 142, 0.8)";
|
|
||||||
ctx.lineWidth = (activePersonChair ? 2.8 : assigned ? 2.4 : selected ? 2.6 : active ? 2.1 : baseWidth) / camera.scale;
|
|
||||||
ctx.stroke(chair.path);
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
|
|
||||||
renderTooltip();
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistPlaced() {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.addEventListener("pointerdown", (event) => {
|
|
||||||
dragging = true;
|
|
||||||
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
|
|
||||||
canvas.classList.add("dragging");
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("pointerup", (event) => {
|
|
||||||
if (dragging && dragStart) {
|
|
||||||
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
|
|
||||||
if (move < 4) {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top);
|
|
||||||
if (picked) {
|
|
||||||
if (placed.has(picked.key)) placed.delete(picked.key);
|
|
||||||
else placed.add(picked.key);
|
|
||||||
persistPlaced();
|
|
||||||
if (activePersonId) {
|
|
||||||
const currentChair = getChairByPerson(activePersonId);
|
|
||||||
if (chairAssignments[picked.key] === activePersonId) {
|
|
||||||
delete chairAssignments[picked.key];
|
|
||||||
} else {
|
|
||||||
if (currentChair && currentChair !== picked.key) delete chairAssignments[currentChair];
|
|
||||||
chairAssignments[picked.key] = activePersonId;
|
|
||||||
}
|
|
||||||
persistAssignments();
|
|
||||||
renderPeopleList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dragging = false;
|
|
||||||
dragStart = null;
|
|
||||||
canvas.classList.remove("dragging");
|
|
||||||
requestDraw();
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("pointermove", (event) => {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
|
||||||
if (dragging && dragStart) {
|
|
||||||
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
|
|
||||||
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
|
|
||||||
}
|
|
||||||
requestDraw();
|
|
||||||
});
|
|
||||||
|
|
||||||
canvas.addEventListener("wheel", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const mx = event.clientX - rect.left;
|
|
||||||
const my = event.clientY - rect.top;
|
|
||||||
const before = screenToWorld(mx, my);
|
|
||||||
const factor = event.deltaY < 0 ? 1.08 : 0.92;
|
|
||||||
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
|
|
||||||
const after = worldToScreen(before.x, before.y);
|
|
||||||
camera.offsetX += mx - after.x;
|
|
||||||
camera.offsetY += my - after.y;
|
|
||||||
requestDraw();
|
|
||||||
}, { passive: false });
|
|
||||||
|
|
||||||
document.getElementById("fit-btn").addEventListener("click", fit);
|
|
||||||
document.getElementById("clear-btn").addEventListener("click", () => {
|
|
||||||
placed.clear();
|
|
||||||
persistPlaced();
|
|
||||||
requestDraw();
|
|
||||||
});
|
|
||||||
clearAssignBtn.addEventListener("click", () => {
|
|
||||||
chairAssignments = {};
|
|
||||||
persistAssignments();
|
|
||||||
renderPeopleList();
|
|
||||||
requestDraw();
|
|
||||||
});
|
|
||||||
orgChartEl.addEventListener("click", (event) => {
|
|
||||||
const item = event.target.closest(".org-person[data-person-id]");
|
|
||||||
if (!item) return;
|
|
||||||
const personId = item.getAttribute("data-person-id");
|
|
||||||
activePersonId = personId === activePersonId ? null : personId;
|
|
||||||
persistActivePerson();
|
|
||||||
renderPeopleList();
|
|
||||||
requestDraw();
|
|
||||||
});
|
|
||||||
window.addEventListener("resize", resize);
|
|
||||||
renderPeopleList();
|
|
||||||
resize();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
@@ -1,932 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ko">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>center chair people map 7f</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--ink: #152330;
|
|
||||||
--muted: #627286;
|
|
||||||
--paper: rgba(255,255,255,0.86);
|
|
||||||
--line: rgba(21,35,48,0.1);
|
|
||||||
--accent: #0f766e;
|
|
||||||
--bg: #edf2f6;
|
|
||||||
}
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
|
|
||||||
color: var(--ink);
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
|
|
||||||
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
|
|
||||||
}
|
|
||||||
.page {
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.shell {
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
.panel {
|
|
||||||
border-radius: 0;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
backdrop-filter: none;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
border: none;
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
font: inherit;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
color: white;
|
|
||||||
background: linear-gradient(135deg, #0f766e, #115e59);
|
|
||||||
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
|
|
||||||
}
|
|
||||||
button.alt {
|
|
||||||
color: var(--ink);
|
|
||||||
background: rgba(255,255,255,0.9);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
.viewer {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
.viewer-head {
|
|
||||||
position: absolute;
|
|
||||||
top: 16px;
|
|
||||||
left: 16px;
|
|
||||||
right: 16px;
|
|
||||||
z-index: 2;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.chip {
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 16px;
|
|
||||||
background: rgba(255,255,255,0.82);
|
|
||||||
border: 1px solid rgba(255,255,255,0.94);
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
|
|
||||||
}
|
|
||||||
.viewer-actions {
|
|
||||||
position: absolute;
|
|
||||||
left: 16px;
|
|
||||||
top: 64px;
|
|
||||||
z-index: 2;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.mapper {
|
|
||||||
position: absolute;
|
|
||||||
top: 76px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
width: min(94vw, 1320px);
|
|
||||||
max-height: min(56vh, 560px);
|
|
||||||
overflow: hidden;
|
|
||||||
z-index: 4;
|
|
||||||
border-radius: 20px;
|
|
||||||
background: rgba(234, 239, 247, 0.95);
|
|
||||||
border: 1px solid rgba(101, 119, 146, 0.22);
|
|
||||||
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
backdrop-filter: blur(6px);
|
|
||||||
}
|
|
||||||
.hidden-off {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
.mapper-head {
|
|
||||||
padding: 10px 14px;
|
|
||||||
border-bottom: 1px solid rgba(101,119,146,0.18);
|
|
||||||
font-size: 12px;
|
|
||||||
color: #51607a;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.35;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
background: rgba(255,255,255,0.6);
|
|
||||||
}
|
|
||||||
.mapper-head strong {
|
|
||||||
display: block;
|
|
||||||
color: #17243b;
|
|
||||||
font-size: 20px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
.mapper-head .alt {
|
|
||||||
padding: 8px 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.org-chart {
|
|
||||||
margin: 0;
|
|
||||||
padding: 14px;
|
|
||||||
overflow: auto;
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.org-top {
|
|
||||||
margin: 0 auto;
|
|
||||||
width: min(100%, 420px);
|
|
||||||
border-radius: 14px;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid rgba(67, 84, 118, 0.25);
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
.org-top-title {
|
|
||||||
background: #1e2f4d;
|
|
||||||
color: #fff;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 34px;
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 1.1;
|
|
||||||
padding: 16px 12px;
|
|
||||||
letter-spacing: -0.03em;
|
|
||||||
}
|
|
||||||
.org-top-members {
|
|
||||||
padding: 10px;
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
background: rgba(255,255,255,0.95);
|
|
||||||
}
|
|
||||||
.org-teams {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(7, minmax(160px, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
.org-team {
|
|
||||||
border: 1px solid rgba(110, 126, 152, 0.25);
|
|
||||||
border-radius: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: rgba(255,255,255,0.95);
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.org-team h4 {
|
|
||||||
margin: 0;
|
|
||||||
padding: 9px 10px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #21324e;
|
|
||||||
font-weight: 800;
|
|
||||||
border-bottom: 1px solid rgba(110, 126, 152, 0.2);
|
|
||||||
background: rgba(240, 245, 252, 0.96);
|
|
||||||
}
|
|
||||||
.org-members {
|
|
||||||
padding: 7px;
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.org-person {
|
|
||||||
border: 1px solid rgba(116, 133, 161, 0.25);
|
|
||||||
background: rgba(255,255,255,0.95);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 6px 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 120ms ease, border-color 120ms ease;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.org-person.active {
|
|
||||||
border-color: rgba(15,118,110,0.6);
|
|
||||||
background: rgba(15,118,110,0.11);
|
|
||||||
}
|
|
||||||
.org-person.assigned {
|
|
||||||
border-color: rgba(37,99,235,0.5);
|
|
||||||
background: rgba(37,99,235,0.1);
|
|
||||||
}
|
|
||||||
.org-person strong {
|
|
||||||
display: block;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.3;
|
|
||||||
color: #15233a;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.org-person small {
|
|
||||||
display: block;
|
|
||||||
color: #5a6a86;
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1.25;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
@media (max-width: 980px) {
|
|
||||||
.mapper {
|
|
||||||
top: 72px;
|
|
||||||
width: min(96vw, 920px);
|
|
||||||
max-height: 58vh;
|
|
||||||
}
|
|
||||||
.viewer-actions {
|
|
||||||
top: 64px;
|
|
||||||
left: 12px;
|
|
||||||
right: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.mapper-head strong {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
.org-top-title {
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
.org-teams {
|
|
||||||
grid-template-columns: repeat(3, minmax(150px, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
canvas {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: block;
|
|
||||||
cursor: grab;
|
|
||||||
}
|
|
||||||
canvas.dragging { cursor: grabbing; }
|
|
||||||
.tooltip {
|
|
||||||
position: absolute;
|
|
||||||
min-width: 170px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
border-radius: 16px;
|
|
||||||
background: rgba(17,24,39,0.94);
|
|
||||||
color: white;
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translate(12px, 12px);
|
|
||||||
transition: opacity 120ms ease;
|
|
||||||
z-index: 3;
|
|
||||||
}
|
|
||||||
.tooltip.visible { opacity: 1; }
|
|
||||||
.tooltip strong { display: block; margin-bottom: 6px; font-size: 14px; }
|
|
||||||
.tooltip div { font-size: 12px; line-height: 1.45; color: rgba(255,255,255,0.82); }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="page">
|
|
||||||
<div class="shell">
|
|
||||||
<main class="panel viewer">
|
|
||||||
<div class="viewer-head">
|
|
||||||
<div class="chip" id="scale-chip"></div>
|
|
||||||
<div class="chip" id="hover-chip">chair hover: none</div>
|
|
||||||
</div>
|
|
||||||
<div class="viewer-actions">
|
|
||||||
<button type="button" id="fit-btn">전체 맞춤</button>
|
|
||||||
<button type="button" class="alt" id="clear-btn">선택 지우기</button>
|
|
||||||
</div>
|
|
||||||
<aside class="mapper hidden-off">
|
|
||||||
<div class="mapper-head">
|
|
||||||
<div id="mapper-status">
|
|
||||||
<strong>조직 현황</strong>
|
|
||||||
<span>선택 인원 없음</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="alt" id="clear-assign-btn">매칭 초기화</button>
|
|
||||||
</div>
|
|
||||||
<div class="org-chart" id="org-chart"></div>
|
|
||||||
</aside>
|
|
||||||
<canvas id="canvas"></canvas>
|
|
||||||
<div class="tooltip" id="tooltip"></div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script src="./center_chair_people_payload_7f.js"></script>
|
|
||||||
<script>
|
|
||||||
const DATA = window.CHAIR_MAP_DATA;
|
|
||||||
function decodeSegments(base64) {
|
|
||||||
const binary = atob(base64);
|
|
||||||
const bytes = new Uint8Array(binary.length);
|
|
||||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
||||||
return new Int32Array(bytes.buffer);
|
|
||||||
}
|
|
||||||
const bgTileRanges = DATA.bgTileRanges;
|
|
||||||
const bgSegValues = decodeSegments(DATA.bgSegsB64);
|
|
||||||
const chairSegValues = decodeSegments(DATA.chairSegsB64);
|
|
||||||
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
|
|
||||||
key, name, kind, start, count
|
|
||||||
}));
|
|
||||||
const meta = DATA.meta;
|
|
||||||
const world = meta.headerBounds;
|
|
||||||
const canvas = document.getElementById("canvas");
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
const tooltip = document.getElementById("tooltip");
|
|
||||||
const scaleChip = document.getElementById("scale-chip");
|
|
||||||
const hoverChip = document.getElementById("hover-chip");
|
|
||||||
const STORAGE_KEY = "ptc-chair-selection";
|
|
||||||
const PEOPLE_STORAGE_KEY = "ptc-chair-people";
|
|
||||||
const ASSIGN_STORAGE_KEY = "ptc-chair-assignments";
|
|
||||||
const ACTIVE_PERSON_STORAGE_KEY = "ptc-chair-active-person";
|
|
||||||
const clearAssignBtn = document.getElementById("clear-assign-btn");
|
|
||||||
const orgChartEl = document.getElementById("org-chart");
|
|
||||||
const mapperStatus = document.getElementById("mapper-status");
|
|
||||||
// Prevent stale auto-highlights from previous sessions.
|
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
|
||||||
localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
|
|
||||||
localStorage.removeItem(ASSIGN_STORAGE_KEY);
|
|
||||||
const placed = new Set();
|
|
||||||
let people = JSON.parse(localStorage.getItem(PEOPLE_STORAGE_KEY) || "[]");
|
|
||||||
let chairAssignments = {};
|
|
||||||
let activePersonId = null;
|
|
||||||
const ORG_TEMPLATE = {
|
|
||||||
top: {
|
|
||||||
name: "총괄기획실",
|
|
||||||
count: 53,
|
|
||||||
members: [
|
|
||||||
{ name: "장종찬", dept: "총괄기획실", title: "기획실장" },
|
|
||||||
{ name: "김원식", dept: "총괄기획실", title: "전무이사" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
teams: [
|
|
||||||
{ name: "경영기획팀", count: 6, members: ["김우진", "임민정", "국혜린", "최선아", "김윤재", "이미영"] },
|
|
||||||
{ name: "인재성장팀", count: 5, members: ["조태희", "최근혜", "류원준", "주안기", "정성호"] },
|
|
||||||
{ name: "ERP 기획팀", count: 5, members: ["류호성", "문형식", "최요제", "황대일", "이채봉"] },
|
|
||||||
{ name: "디자인기획팀", count: 17, members: ["신혜영", "정은혜", "김태식", "최예은", "채선영", "최영환", "윤봄이", "이예진", "허유나", "마희연", "김수현", "박지영", "권순호", "정두휘", "김정석", "정지윤", "양숙영"] },
|
|
||||||
{ name: "기술기획팀", count: 11, members: ["김원기", "홍아름", "이경민", "김혜인", "황동환", "최찬호", "이태훈", "김신지", "조찬영", "김용연", "한치영"] },
|
|
||||||
{ name: "협업증진팀", count: 3, members: ["성형일", "박주한", "한승민"] },
|
|
||||||
{ name: "솔루션통합팀", count: 4, members: ["권혁진", "염승호", "윤준수", "김지영"] },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const chairGeometry = chairs.map((chair) => {
|
|
||||||
let minX = Infinity;
|
|
||||||
let minY = Infinity;
|
|
||||||
let maxX = -Infinity;
|
|
||||||
let maxY = -Infinity;
|
|
||||||
const path = new Path2D();
|
|
||||||
const hitSegments = new Float32Array(chair.count * 4);
|
|
||||||
let segCursor = 0;
|
|
||||||
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
|
|
||||||
const offset = i * 4;
|
|
||||||
const x1 = chairSegValues[offset] / 10;
|
|
||||||
const y1 = chairSegValues[offset + 1] / 10;
|
|
||||||
const x2 = chairSegValues[offset + 2] / 10;
|
|
||||||
const y2 = chairSegValues[offset + 3] / 10;
|
|
||||||
path.moveTo(x1, y1);
|
|
||||||
path.lineTo(x2, y2);
|
|
||||||
hitSegments[segCursor] = x1;
|
|
||||||
hitSegments[segCursor + 1] = y1;
|
|
||||||
hitSegments[segCursor + 2] = x2;
|
|
||||||
hitSegments[segCursor + 3] = y2;
|
|
||||||
segCursor += 4;
|
|
||||||
minX = Math.min(minX, x1, x2);
|
|
||||||
minY = Math.min(minY, y1, y2);
|
|
||||||
maxX = Math.max(maxX, x1, x2);
|
|
||||||
maxY = Math.max(maxY, y1, y2);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...chair,
|
|
||||||
minX,
|
|
||||||
minY,
|
|
||||||
maxX,
|
|
||||||
maxY,
|
|
||||||
area: Math.max(1, (maxX - minX) * (maxY - minY)),
|
|
||||||
path,
|
|
||||||
hitSegments,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
function renumberChairKeys(chairItems) {
|
|
||||||
if (!chairItems.length) return;
|
|
||||||
const heights = chairItems
|
|
||||||
.map((chair) => Math.max(1, chair.maxY - chair.minY))
|
|
||||||
.sort((a, b) => a - b);
|
|
||||||
const medianHeight = heights[Math.floor(heights.length / 2)] || 1;
|
|
||||||
const rowTolerance = Math.max(40, medianHeight * 0.9);
|
|
||||||
|
|
||||||
const sorted = [...chairItems].sort((a, b) => {
|
|
||||||
const ay = (a.minY + a.maxY) * 0.5;
|
|
||||||
const by = (b.minY + b.maxY) * 0.5;
|
|
||||||
if (Math.abs(by - ay) > rowTolerance) return by - ay; // top -> bottom
|
|
||||||
const ax = (a.minX + a.maxX) * 0.5;
|
|
||||||
const bx = (b.minX + b.maxX) * 0.5;
|
|
||||||
return ax - bx; // left -> right
|
|
||||||
});
|
|
||||||
|
|
||||||
sorted.forEach((chair, index) => {
|
|
||||||
chair.key = String(index + 1);
|
|
||||||
chair.seatNo = index + 1;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
renumberChairKeys(chairGeometry);
|
|
||||||
const PICK_GRID_SIZE = 1800;
|
|
||||||
const chairPickGrid = new Map();
|
|
||||||
function pickGridKey(gx, gy) {
|
|
||||||
return `${gx},${gy}`;
|
|
||||||
}
|
|
||||||
chairGeometry.forEach((chair, index) => {
|
|
||||||
const minGX = Math.floor(chair.minX / PICK_GRID_SIZE);
|
|
||||||
const maxGX = Math.floor(chair.maxX / PICK_GRID_SIZE);
|
|
||||||
const minGY = Math.floor(chair.minY / PICK_GRID_SIZE);
|
|
||||||
const maxGY = Math.floor(chair.maxY / PICK_GRID_SIZE);
|
|
||||||
for (let gx = minGX; gx <= maxGX; gx += 1) {
|
|
||||||
for (let gy = minGY; gy <= maxGY; gy += 1) {
|
|
||||||
const key = pickGridKey(gx, gy);
|
|
||||||
if (!chairPickGrid.has(key)) chairPickGrid.set(key, []);
|
|
||||||
chairPickGrid.get(key).push(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
|
|
||||||
let pixelRatio = window.devicePixelRatio || 1;
|
|
||||||
let pointer = { x: 0, y: 0 };
|
|
||||||
let dragging = false;
|
|
||||||
let dragStart = null;
|
|
||||||
let hovered = null;
|
|
||||||
let rafPending = false;
|
|
||||||
|
|
||||||
function normalizePeople(raw) {
|
|
||||||
return raw
|
|
||||||
.map((person, index) => {
|
|
||||||
if (!person || !person.name) return null;
|
|
||||||
return {
|
|
||||||
id: person.id || `person-${index + 1}`,
|
|
||||||
name: String(person.name).trim(),
|
|
||||||
dept: String(person.dept || "").trim(),
|
|
||||||
title: String(person.title || "").trim(),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTemplatePeople() {
|
|
||||||
const generated = [];
|
|
||||||
let seq = 1;
|
|
||||||
ORG_TEMPLATE.top.members.forEach((member) => {
|
|
||||||
generated.push({
|
|
||||||
id: `org-${seq++}`,
|
|
||||||
name: member.name,
|
|
||||||
dept: member.dept,
|
|
||||||
title: member.title,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
ORG_TEMPLATE.teams.forEach((team) => {
|
|
||||||
team.members.forEach((name) => {
|
|
||||||
generated.push({
|
|
||||||
id: `org-${seq++}`,
|
|
||||||
name,
|
|
||||||
dept: team.name,
|
|
||||||
title: "선임",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return generated;
|
|
||||||
}
|
|
||||||
|
|
||||||
people = normalizePeople(people);
|
|
||||||
const templateReady = people.some((person) => person.name === "장종찬" && person.dept === "총괄기획실");
|
|
||||||
if (!templateReady) {
|
|
||||||
people = createTemplatePeople();
|
|
||||||
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
|
|
||||||
}
|
|
||||||
const chairKeySet = new Set(chairGeometry.map((chair) => chair.key));
|
|
||||||
chairAssignments = Object.fromEntries(
|
|
||||||
Object.entries(chairAssignments).filter(([chairKey, personId]) => (
|
|
||||||
chairKeySet.has(chairKey) && people.some((person) => person.id === personId)
|
|
||||||
))
|
|
||||||
);
|
|
||||||
if (activePersonId && !people.some((person) => person.id === activePersonId)) activePersonId = null;
|
|
||||||
|
|
||||||
function persistPeople() {
|
|
||||||
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistAssignments() {
|
|
||||||
localStorage.setItem(ASSIGN_STORAGE_KEY, JSON.stringify(chairAssignments));
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistActivePerson() {
|
|
||||||
if (!activePersonId) localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
|
|
||||||
else localStorage.setItem(ACTIVE_PERSON_STORAGE_KEY, activePersonId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assignmentCount() {
|
|
||||||
return Object.keys(chairAssignments).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPersonById(id) {
|
|
||||||
return people.find((person) => person.id === id) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getChairByPerson(personId) {
|
|
||||||
for (const [chairKey, assignedPersonId] of Object.entries(chairAssignments)) {
|
|
||||||
if (assignedPersonId === personId) return chairKey;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPeopleList() {
|
|
||||||
const activePerson = getPersonById(activePersonId);
|
|
||||||
const countText = `${assignmentCount()} / ${people.length} 매칭`;
|
|
||||||
mapperStatus.innerHTML = `<strong>조직 현황</strong><span>${activePerson ? `${activePerson.name} 선택됨` : "선택 인원 없음"} · ${countText}</span>`;
|
|
||||||
|
|
||||||
const findPerson = (dept, name) => people.find((person) => person.dept === dept && person.name === name) || null;
|
|
||||||
const personCard = (person, roleText) => {
|
|
||||||
if (!person) return "";
|
|
||||||
const chairKey = getChairByPerson(person.id);
|
|
||||||
const assignedClass = chairKey ? " assigned" : "";
|
|
||||||
const activeClass = person.id === activePersonId ? " active" : "";
|
|
||||||
return `
|
|
||||||
<article class="org-person${assignedClass}${activeClass}" data-person-id="${person.id}">
|
|
||||||
<strong>${person.name}</strong>
|
|
||||||
<small>${person.title || roleText || "-"}</small>
|
|
||||||
<small>${chairKey ? `좌석 ${chairKey}` : "좌석 미지정"}</small>
|
|
||||||
</article>
|
|
||||||
`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const topHtml = ORG_TEMPLATE.top.members
|
|
||||||
.map((member) => personCard(findPerson(member.dept, member.name), member.title))
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
const teamsHtml = ORG_TEMPLATE.teams.map((team) => {
|
|
||||||
const membersHtml = team.members
|
|
||||||
.map((name) => personCard(findPerson(team.name, name), "선임"))
|
|
||||||
.join("");
|
|
||||||
return `
|
|
||||||
<section class="org-team">
|
|
||||||
<h4>${team.name} (${team.count})</h4>
|
|
||||||
<div class="org-members">${membersHtml}</div>
|
|
||||||
</section>
|
|
||||||
`;
|
|
||||||
}).join("");
|
|
||||||
|
|
||||||
orgChartEl.innerHTML = `
|
|
||||||
<section class="org-top">
|
|
||||||
<div class="org-top-title">${ORG_TEMPLATE.top.name} (${ORG_TEMPLATE.top.count})</div>
|
|
||||||
<div class="org-top-members">${topHtml}</div>
|
|
||||||
</section>
|
|
||||||
<section class="org-teams">${teamsHtml}</section>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function worldToScreen(x, y) {
|
|
||||||
return {
|
|
||||||
x: x * camera.scale + camera.offsetX,
|
|
||||||
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function screenToWorld(x, y) {
|
|
||||||
return {
|
|
||||||
x: (x - camera.offsetX) / camera.scale,
|
|
||||||
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resize() {
|
|
||||||
pixelRatio = window.devicePixelRatio || 1;
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
canvas.width = Math.round(rect.width * pixelRatio);
|
|
||||||
canvas.height = Math.round(rect.height * pixelRatio);
|
|
||||||
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
|
||||||
fit();
|
|
||||||
}
|
|
||||||
|
|
||||||
function fit() {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const width = world.maxX - world.minX;
|
|
||||||
const height = world.maxY - world.minY;
|
|
||||||
const pad = 36;
|
|
||||||
const scaleX = (rect.width - pad * 2) / width;
|
|
||||||
const scaleY = (rect.height - pad * 2) / height;
|
|
||||||
camera.scale = Math.min(scaleX, scaleY);
|
|
||||||
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
|
|
||||||
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
|
|
||||||
requestDraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawGrid(width, height) {
|
|
||||||
ctx.save();
|
|
||||||
ctx.strokeStyle = "rgba(21,35,48,0.05)";
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
for (let x = 120; x < width; x += 120) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x, 0);
|
|
||||||
ctx.lineTo(x, height);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
for (let y = 120; y < height; y += 120) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(0, y);
|
|
||||||
ctx.lineTo(width, y);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickChair(screenX, screenY) {
|
|
||||||
const threshold = 12;
|
|
||||||
const pointerWorld = screenToWorld(screenX, screenY);
|
|
||||||
const thresholdWorld = threshold / camera.scale;
|
|
||||||
const thresholdWorldSq = thresholdWorld * thresholdWorld;
|
|
||||||
const minGX = Math.floor((pointerWorld.x - thresholdWorld) / PICK_GRID_SIZE);
|
|
||||||
const maxGX = Math.floor((pointerWorld.x + thresholdWorld) / PICK_GRID_SIZE);
|
|
||||||
const minGY = Math.floor((pointerWorld.y - thresholdWorld) / PICK_GRID_SIZE);
|
|
||||||
const maxGY = Math.floor((pointerWorld.y + thresholdWorld) / PICK_GRID_SIZE);
|
|
||||||
const candidateIndexes = [];
|
|
||||||
const seen = new Set();
|
|
||||||
for (let gx = minGX; gx <= maxGX; gx += 1) {
|
|
||||||
for (let gy = minGY; gy <= maxGY; gy += 1) {
|
|
||||||
const candidates = chairPickGrid.get(pickGridKey(gx, gy));
|
|
||||||
if (!candidates) continue;
|
|
||||||
for (const index of candidates) {
|
|
||||||
if (seen.has(index)) continue;
|
|
||||||
seen.add(index);
|
|
||||||
candidateIndexes.push(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let best = null;
|
|
||||||
for (const index of candidateIndexes) {
|
|
||||||
const chair = chairGeometry[index];
|
|
||||||
if (
|
|
||||||
pointerWorld.x < chair.minX - thresholdWorld ||
|
|
||||||
pointerWorld.x > chair.maxX + thresholdWorld ||
|
|
||||||
pointerWorld.y < chair.minY - thresholdWorld ||
|
|
||||||
pointerWorld.y > chair.maxY + thresholdWorld
|
|
||||||
) continue;
|
|
||||||
let distSq = Infinity;
|
|
||||||
for (let i = 0; i < chair.hitSegments.length; i += 4) {
|
|
||||||
const x1 = chair.hitSegments[i];
|
|
||||||
const y1 = chair.hitSegments[i + 1];
|
|
||||||
const x2 = chair.hitSegments[i + 2];
|
|
||||||
const y2 = chair.hitSegments[i + 3];
|
|
||||||
const dx = x2 - x1;
|
|
||||||
const dy = y2 - y1;
|
|
||||||
const len2 = dx * dx + dy * dy;
|
|
||||||
let segDistSq;
|
|
||||||
if (len2 === 0) {
|
|
||||||
const px = pointerWorld.x - x1;
|
|
||||||
const py = pointerWorld.y - y1;
|
|
||||||
segDistSq = px * px + py * py;
|
|
||||||
} else {
|
|
||||||
let t = ((pointerWorld.x - x1) * dx + (pointerWorld.y - y1) * dy) / len2;
|
|
||||||
t = Math.max(0, Math.min(1, t));
|
|
||||||
const lx = x1 + t * dx;
|
|
||||||
const ly = y1 + t * dy;
|
|
||||||
const px = pointerWorld.x - lx;
|
|
||||||
const py = pointerWorld.y - ly;
|
|
||||||
segDistSq = px * px + py * py;
|
|
||||||
}
|
|
||||||
if (segDistSq < distSq) distSq = segDistSq;
|
|
||||||
if (distSq <= thresholdWorldSq * 0.3) break;
|
|
||||||
}
|
|
||||||
if (distSq > thresholdWorldSq) continue;
|
|
||||||
const dist = Math.sqrt(distSq) * camera.scale;
|
|
||||||
|
|
||||||
if (!best) {
|
|
||||||
best = { chair, dist };
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const distGap = dist - best.dist;
|
|
||||||
if (distGap < -0.75) {
|
|
||||||
best = { chair, dist };
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Math.abs(distGap) <= 2) {
|
|
||||||
const areaGap = chair.area - best.chair.area;
|
|
||||||
if (areaGap < -1) {
|
|
||||||
best = { chair, dist };
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
Math.abs(areaGap) <= 1 &&
|
|
||||||
chair.kind === "block" &&
|
|
||||||
best.chair.kind !== "block"
|
|
||||||
) {
|
|
||||||
best = { chair, dist };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return best ? best.chair : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTooltip() {
|
|
||||||
if (!hovered) {
|
|
||||||
tooltip.classList.remove("visible");
|
|
||||||
hoverChip.textContent = "chair hover: none";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
hoverChip.textContent = `chair hover: ${hovered.name}`;
|
|
||||||
tooltip.innerHTML = `
|
|
||||||
<strong>${hovered.name}</strong>
|
|
||||||
<div>chair key: ${hovered.key}</div>
|
|
||||||
<div>${placed.has(hovered.key) ? "선택됨" : "클릭하면 선택"}</div>
|
|
||||||
<div>${chairAssignments[hovered.key] ? `배치: ${(getPersonById(chairAssignments[hovered.key]) || { name: "알수없음" }).name}` : "배치 인원 없음"}</div>
|
|
||||||
`;
|
|
||||||
tooltip.style.left = `${pointer.x + 14}px`;
|
|
||||||
tooltip.style.top = `${pointer.y + 14}px`;
|
|
||||||
tooltip.classList.add("visible");
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestDraw() {
|
|
||||||
if (rafPending) return;
|
|
||||||
rafPending = true;
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
rafPending = false;
|
|
||||||
draw();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyWorldTransform() {
|
|
||||||
ctx.setTransform(
|
|
||||||
pixelRatio * camera.scale,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
-pixelRatio * camera.scale,
|
|
||||||
pixelRatio * camera.offsetX,
|
|
||||||
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function draw() {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
|
||||||
ctx.clearRect(0, 0, rect.width, rect.height);
|
|
||||||
drawGrid(rect.width, rect.height);
|
|
||||||
const viewA = screenToWorld(0, rect.height);
|
|
||||||
const viewB = screenToWorld(rect.width, 0);
|
|
||||||
const viewMinX = Math.min(viewA.x, viewB.x);
|
|
||||||
const viewMaxX = Math.max(viewA.x, viewB.x);
|
|
||||||
const viewMinY = Math.min(viewA.y, viewB.y);
|
|
||||||
const viewMaxY = Math.max(viewA.y, viewB.y);
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
applyWorldTransform();
|
|
||||||
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
|
|
||||||
ctx.lineWidth = 1 / camera.scale;
|
|
||||||
const tileSize = meta.backgroundTileSize;
|
|
||||||
const tileMinX = Math.floor(viewMinX / tileSize);
|
|
||||||
const tileMaxX = Math.floor(viewMaxX / tileSize);
|
|
||||||
const tileMinY = Math.floor(viewMinY / tileSize);
|
|
||||||
const tileMaxY = Math.floor(viewMaxY / tileSize);
|
|
||||||
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
|
|
||||||
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
|
|
||||||
const range = bgTileRanges[`${tx},${ty}`];
|
|
||||||
if (!range) continue;
|
|
||||||
const start = range[0];
|
|
||||||
const count = range[1];
|
|
||||||
for (let i = start; i < start + count; i += 1) {
|
|
||||||
const offset = i * 4;
|
|
||||||
const x1 = bgSegValues[offset] / 10;
|
|
||||||
const y1 = bgSegValues[offset + 1] / 10;
|
|
||||||
const x2 = bgSegValues[offset + 2] / 10;
|
|
||||||
const y2 = bgSegValues[offset + 3] / 10;
|
|
||||||
if (
|
|
||||||
Math.max(x1, x2) < viewMinX ||
|
|
||||||
Math.min(x1, x2) > viewMaxX ||
|
|
||||||
Math.max(y1, y2) < viewMinY ||
|
|
||||||
Math.min(y1, y2) > viewMaxY
|
|
||||||
) continue;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x1, y1);
|
|
||||||
ctx.lineTo(x2, y2);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
hovered = dragging ? null : pickChair(pointer.x, pointer.y);
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
applyWorldTransform();
|
|
||||||
ctx.lineWidth = 1.45 / camera.scale;
|
|
||||||
ctx.lineCap = "round";
|
|
||||||
ctx.lineJoin = "round";
|
|
||||||
for (const chair of chairGeometry) {
|
|
||||||
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
|
|
||||||
const active = hovered && hovered.key === chair.key;
|
|
||||||
const selected = placed.has(chair.key);
|
|
||||||
const assignedPersonId = chairAssignments[chair.key];
|
|
||||||
const activePersonChair = activePersonId && assignedPersonId === activePersonId;
|
|
||||||
const assigned = Boolean(assignedPersonId);
|
|
||||||
const baseWidth = chair.kind === "block" ? 1.45 : 1.35;
|
|
||||||
ctx.strokeStyle = activePersonChair
|
|
||||||
? "rgba(234, 179, 8, 1)"
|
|
||||||
: assigned
|
|
||||||
? "rgba(37, 99, 235, 0.98)"
|
|
||||||
: selected
|
|
||||||
? "rgba(220, 38, 38, 0.98)"
|
|
||||||
: active
|
|
||||||
? "rgba(15, 118, 110, 0.98)"
|
|
||||||
: chair.kind === "group"
|
|
||||||
? "rgba(16, 134, 149, 0.74)"
|
|
||||||
: "rgba(21, 149, 142, 0.8)";
|
|
||||||
ctx.lineWidth = (activePersonChair ? 2.8 : assigned ? 2.4 : selected ? 2.6 : active ? 2.1 : baseWidth) / camera.scale;
|
|
||||||
ctx.stroke(chair.path);
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
|
|
||||||
renderTooltip();
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistPlaced() {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.addEventListener("pointerdown", (event) => {
|
|
||||||
dragging = true;
|
|
||||||
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
|
|
||||||
canvas.classList.add("dragging");
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("pointerup", (event) => {
|
|
||||||
if (dragging && dragStart) {
|
|
||||||
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
|
|
||||||
if (move < 4) {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top);
|
|
||||||
if (picked) {
|
|
||||||
if (placed.has(picked.key)) placed.delete(picked.key);
|
|
||||||
else placed.add(picked.key);
|
|
||||||
persistPlaced();
|
|
||||||
if (activePersonId) {
|
|
||||||
const currentChair = getChairByPerson(activePersonId);
|
|
||||||
if (chairAssignments[picked.key] === activePersonId) {
|
|
||||||
delete chairAssignments[picked.key];
|
|
||||||
} else {
|
|
||||||
if (currentChair && currentChair !== picked.key) delete chairAssignments[currentChair];
|
|
||||||
chairAssignments[picked.key] = activePersonId;
|
|
||||||
}
|
|
||||||
persistAssignments();
|
|
||||||
renderPeopleList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dragging = false;
|
|
||||||
dragStart = null;
|
|
||||||
canvas.classList.remove("dragging");
|
|
||||||
requestDraw();
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("pointermove", (event) => {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
|
||||||
if (dragging && dragStart) {
|
|
||||||
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
|
|
||||||
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
|
|
||||||
}
|
|
||||||
requestDraw();
|
|
||||||
});
|
|
||||||
|
|
||||||
canvas.addEventListener("wheel", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const mx = event.clientX - rect.left;
|
|
||||||
const my = event.clientY - rect.top;
|
|
||||||
const before = screenToWorld(mx, my);
|
|
||||||
const factor = event.deltaY < 0 ? 1.08 : 0.92;
|
|
||||||
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
|
|
||||||
const after = worldToScreen(before.x, before.y);
|
|
||||||
camera.offsetX += mx - after.x;
|
|
||||||
camera.offsetY += my - after.y;
|
|
||||||
requestDraw();
|
|
||||||
}, { passive: false });
|
|
||||||
|
|
||||||
document.getElementById("fit-btn").addEventListener("click", fit);
|
|
||||||
document.getElementById("clear-btn").addEventListener("click", () => {
|
|
||||||
placed.clear();
|
|
||||||
persistPlaced();
|
|
||||||
requestDraw();
|
|
||||||
});
|
|
||||||
clearAssignBtn.addEventListener("click", () => {
|
|
||||||
chairAssignments = {};
|
|
||||||
persistAssignments();
|
|
||||||
renderPeopleList();
|
|
||||||
requestDraw();
|
|
||||||
});
|
|
||||||
orgChartEl.addEventListener("click", (event) => {
|
|
||||||
const item = event.target.closest(".org-person[data-person-id]");
|
|
||||||
if (!item) return;
|
|
||||||
const personId = item.getAttribute("data-person-id");
|
|
||||||
activePersonId = personId === activePersonId ? null : personId;
|
|
||||||
persistActivePerson();
|
|
||||||
renderPeopleList();
|
|
||||||
requestDraw();
|
|
||||||
});
|
|
||||||
window.addEventListener("resize", resize);
|
|
||||||
renderPeopleList();
|
|
||||||
resize();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 276 KiB |
|
Before Width: | Height: | Size: 225 KiB |
|
Before Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 242 KiB |
|
Before Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 213 KiB |
@@ -5,7 +5,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>한맥가족 자산관리시스템</title>
|
<title>ITAM 자산관리 ERP</title>
|
||||||
<link rel="stylesheet"
|
<link rel="stylesheet"
|
||||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" />
|
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" />
|
||||||
<link rel="stylesheet" href="/src/styles/common.css" />
|
<link rel="stylesheet" href="/src/styles/common.css" />
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
<div class="header-container" id="nav-container">
|
<div class="header-container" id="nav-container">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<img src="/image 92.png" alt="Logo" class="main-logo" />
|
<img src="/image 92.png" alt="Logo" class="main-logo" />
|
||||||
<h1>한맥자산관리시스템</h1>
|
<h1>자산관리시스템<span class="sub-title">(Digital Asset Control Hub System)</span></h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Navigation (GNB + LNB in same row) -->
|
<!-- Navigation (GNB + LNB in same row) -->
|
||||||
@@ -57,7 +57,8 @@
|
|||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<footer class="main-footer">
|
<footer class="main-footer">
|
||||||
<p>© 2026 BARON Consultant Co,Ltd. All rights reserved.</p>
|
<div id="secret-cloud-trigger" style="width: 20px; height: 20px; cursor: pointer; opacity: 0.1; background: #000; border-radius: 4px; position: absolute; left: 1rem;"></div>
|
||||||
|
<p>Powered by BARON Consultant Co,Ltd</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
const mysql = require('mysql2/promise');
|
|
||||||
require('dotenv').config({ override: true });
|
|
||||||
|
|
||||||
const TYPE_PREFIX_MAP = {
|
|
||||||
'서버': 'SVR', '워크스테이션': 'SVR', '개인PC': 'PC', '공용PC': 'PC', '서버PC': 'PC',
|
|
||||||
'저장시스템_렉(NAS)': 'DSS', '저장시스템_렉(DAS)': 'DSS', '저장시스템_미니(NAS)': 'DSS', '저장시스템_미니(DAS)':'DSS',
|
|
||||||
'저장매체': 'STM', 'HDD': 'HDD', 'SSD': 'SSD',
|
|
||||||
'노트북': 'NBK', '태블릿': 'TAB',
|
|
||||||
'드론': 'DRO', '측량장비': 'SUR', '보조기기': 'SUR', '허브': 'NET',
|
|
||||||
'구독SW': 'SW', '영구SW': 'SW', '내부' : 'SW_INT', '외부':'SW_EXT'
|
|
||||||
};
|
|
||||||
|
|
||||||
const CAT_PREFIX_MAP = {
|
|
||||||
'서버': 'SVR', 'PC': 'PC', '저장매체': 'STM', '네트워크': 'NET',
|
|
||||||
'공간정보장비': 'SUR', 'PC부품': 'PRT', '업무지원장비': 'EQP', '시설자산': 'FUR'
|
|
||||||
};
|
|
||||||
|
|
||||||
function getPrefix(cat, type) {
|
|
||||||
return TYPE_PREFIX_MAP[type] || TYPE_PREFIX_MAP[cat] || CAT_PREFIX_MAP[cat] || 'ETC';
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const pool = mysql.createPool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASS,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
port: parseInt(process.env.DB_PORT || '3306')
|
|
||||||
});
|
|
||||||
|
|
||||||
const connection = await pool.getConnection();
|
|
||||||
try {
|
|
||||||
const [rows] = await connection.query('SELECT id, category, asset_type, purchase_date, asset_code FROM asset_core ORDER BY purchase_date ASC, id ASC');
|
|
||||||
console.log(`Found ${rows.length} assets to process.`);
|
|
||||||
|
|
||||||
// Grouping by prefix and YYMM
|
|
||||||
const groups = {};
|
|
||||||
|
|
||||||
rows.forEach(row => {
|
|
||||||
const prefix = getPrefix(row.category, row.asset_type);
|
|
||||||
const datePart = (row.purchase_date && row.purchase_date.length >= 7)
|
|
||||||
? row.purchase_date.replace(/-/g, '').substring(0, 6) // YYYYMM
|
|
||||||
: '000000';
|
|
||||||
|
|
||||||
const groupKey = `${prefix}-${datePart}`;
|
|
||||||
if (!groups[groupKey]) groups[groupKey] = [];
|
|
||||||
groups[groupKey].push(row);
|
|
||||||
});
|
|
||||||
|
|
||||||
let updatedCount = 0;
|
|
||||||
for (const groupKey in groups) {
|
|
||||||
const groupAssets = groups[groupKey];
|
|
||||||
for (let i = 0; i < groupAssets.length; i++) {
|
|
||||||
const asset = groupAssets[i];
|
|
||||||
const nextNum = i + 1;
|
|
||||||
const newCode = `${groupKey}-${String(nextNum).padStart(4, '0')}`;
|
|
||||||
|
|
||||||
if (asset.asset_code !== newCode) {
|
|
||||||
await connection.query('UPDATE asset_core SET asset_code = ? WHERE id = ?', [newCode, asset.id]);
|
|
||||||
updatedCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`✅ Successfully rebuilt asset codes. Total updated: ${updatedCount}`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('❌ Error rebuilding asset codes:', err);
|
|
||||||
} finally {
|
|
||||||
connection.release();
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import mysql from 'mysql2/promise';
|
|
||||||
import dotenv from 'dotenv';
|
|
||||||
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const pool = mysql.createPool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASS,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
port: parseInt(process.env.DB_PORT || '3306'),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 하드웨어 출시 연도 데이터베이스 (CPU/GPU)
|
|
||||||
const RELEASE_DATES = {
|
|
||||||
// Intel CPU Generations (Mainstream desktop release month/year)
|
|
||||||
'i9-14': '2023-10', 'i7-14': '2023-10', 'i5-14': '2023-10',
|
|
||||||
'i9-13': '2022-10', 'i7-13': '2022-10', 'i5-13': '2022-10',
|
|
||||||
'i9-12': '2021-11', 'i7-12': '2021-11', 'i5-12': '2021-11',
|
|
||||||
'i9-11': '2021-03', 'i7-11': '2021-03', 'i5-11': '2021-03',
|
|
||||||
'i9-10': '2020-05', 'i7-10': '2020-05', 'i5-10': '2020-05',
|
|
||||||
'i9-9': '2018-10', 'i7-9': '2018-10', 'i5-9': '2018-10',
|
|
||||||
'i7-8': '2017-10', 'i5-8': '2017-10',
|
|
||||||
'i7-7': '2017-01', 'i5-7': '2017-01',
|
|
||||||
'i7-6': '2015-08', 'i5-6': '2015-08',
|
|
||||||
'i7-4': '2013-06', 'i5-4': '2013-06',
|
|
||||||
'i7-3': '2012-04', 'i5-3': '2012-04',
|
|
||||||
'i7-2': '2011-01', 'i5-2': '2011-01',
|
|
||||||
|
|
||||||
// NVIDIA GPU Series
|
|
||||||
'RTX 4090': '2022-10', 'RTX 4080': '2022-11', 'RTX 4070': '2023-04', 'RTX 4060': '2023-06',
|
|
||||||
'RTX 3090': '2020-09', 'RTX 3080': '2020-09', 'RTX 3070': '2020-10', 'RTX 3060': '2021-02',
|
|
||||||
'RTX 2080': '2018-09', 'RTX 2070': '2018-10', 'RTX 2060': '2019-01',
|
|
||||||
'GTX 1660': '2019-03', 'GTX 1650': '2019-04',
|
|
||||||
'GTX 1080': '2016-05', 'GTX 1070': '2016-06', 'GTX 1060': '2016-07', 'GTX 1050': '2016-10',
|
|
||||||
'GTX 980': '2014-09', 'GTX 970': '2014-09', 'GTX 960': '2015-01'
|
|
||||||
};
|
|
||||||
|
|
||||||
function inferDateFromSpecs(cpu, gpu) {
|
|
||||||
const cpuStr = (cpu || '').toUpperCase();
|
|
||||||
const gpuStr = (gpu || '').toUpperCase();
|
|
||||||
|
|
||||||
let inferred = null;
|
|
||||||
|
|
||||||
// 1. GPU 기준 (최신 그래픽카드가 꽂혀있으면 그 시기 이후 구매일 확률이 높음)
|
|
||||||
for (const [key, date] of Object.entries(RELEASE_DATES)) {
|
|
||||||
if (gpuStr.includes(key)) {
|
|
||||||
inferred = date;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. CPU 기준 (GPU에서 못 찾았거나, CPU가 더 최신일 경우)
|
|
||||||
if (!inferred) {
|
|
||||||
for (const [key, date] of Object.entries(RELEASE_DATES)) {
|
|
||||||
// i7-13700 등을 찾기 위해 정규식 또는 포함 여부 확인
|
|
||||||
if (cpuStr.includes(key)) {
|
|
||||||
inferred = date;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return inferred ? `${inferred}-01` : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
const connection = await pool.getConnection();
|
|
||||||
try {
|
|
||||||
const [rows] = await connection.query(`
|
|
||||||
SELECT c.id, c.asset_code, c.purchase_date, s.cpu, s.gpu
|
|
||||||
FROM asset_core c
|
|
||||||
LEFT JOIN asset_spec s ON c.id = s.asset_id
|
|
||||||
`);
|
|
||||||
|
|
||||||
const updates = [];
|
|
||||||
const unchanged = [];
|
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
const currentVal = (row.purchase_date || '').trim();
|
|
||||||
|
|
||||||
// 구매일자가 없거나 부정확한 경우만 처리
|
|
||||||
if (!currentVal || currentVal === '-' || currentVal === 'undefined' || currentVal.startsWith('2024-01-01')) {
|
|
||||||
const specDate = inferDateFromSpecs(row.cpu, row.gpu);
|
|
||||||
|
|
||||||
if (specDate) {
|
|
||||||
updates.push({ id: row.id, date: specDate, code: row.asset_code, cpu: row.cpu, gpu: row.gpu });
|
|
||||||
} else {
|
|
||||||
unchanged.push({ code: row.asset_code, cpu: row.cpu, gpu: row.gpu });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`🚀 스펙 분석 결과: ${updates.length}건의 자산 구매일자를 보정합니다.`);
|
|
||||||
|
|
||||||
for (const item of updates) {
|
|
||||||
await connection.query('UPDATE asset_core SET purchase_date = ? WHERE id = ?', [item.date, item.id]);
|
|
||||||
console.log(`[Update] ${item.code.padEnd(15)} | CPU: ${String(item.cpu).padEnd(20)} | GPU: ${String(item.gpu).padEnd(15)} -> ${item.date}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (unchanged.length > 0) {
|
|
||||||
console.log('\n⚠️ 스펙 정보를 찾을 수 없어 보정하지 못한 자산:');
|
|
||||||
unchanged.forEach(u => {
|
|
||||||
if (u.code) console.log(`[Skip] ${u.code.padEnd(15)} | CPU: ${u.cpu || '-'} | GPU: ${u.gpu || '-'}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n✅ 완료: ${updates.length}건 보정됨.`);
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error:', err);
|
|
||||||
} finally {
|
|
||||||
connection.release();
|
|
||||||
pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
run();
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import mysql from 'mysql2/promise';
|
|
||||||
import dotenv from 'dotenv';
|
|
||||||
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const pool = mysql.createPool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASS,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
port: parseInt(process.env.DB_PORT || '3306'),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 하드웨어 출시 연도/월 데이터베이스
|
|
||||||
const RELEASE_DATES = {
|
|
||||||
// Intel CPU
|
|
||||||
'i9-14': '2023-10', 'i7-14': '2023-10', 'i5-14': '2023-10',
|
|
||||||
'i9-13': '2022-10', 'i7-13': '2022-10', 'i5-13': '2022-10',
|
|
||||||
'i9-12': '2021-11', 'i7-12': '2021-11', 'i5-12': '2021-11',
|
|
||||||
'i9-11': '2021-03', 'i7-11': '2021-03', 'i5-11': '2021-03',
|
|
||||||
'i9-10': '2020-05', 'i7-10': '2020-05', 'i5-10': '2020-05',
|
|
||||||
'i9-9': '2018-10', 'i7-9': '2018-10', 'i5-9': '2018-10',
|
|
||||||
'i7-8': '2017-10', 'i5-8': '2017-10',
|
|
||||||
'i7-7': '2017-01', 'i5-7': '2017-01',
|
|
||||||
'i7-6': '2015-08', 'i5-6': '2015-08',
|
|
||||||
'i7-5': '2014-06', 'i5-5': '2015-06', // Broadwell
|
|
||||||
'i7-4': '2013-06', 'i5-4': '2013-06',
|
|
||||||
'i7-3': '2012-04', 'i5-3': '2012-04',
|
|
||||||
'i7-2': '2011-01', 'i5-2': '2011-01',
|
|
||||||
|
|
||||||
// NVIDIA GPU
|
|
||||||
'RTX 40': '2022-10',
|
|
||||||
'RTX 30': '2020-09',
|
|
||||||
'RTX 20': '2018-09',
|
|
||||||
'GTX 16': '2019-02',
|
|
||||||
'GTX 10': '2016-05',
|
|
||||||
'GTX 9': '2014-09',
|
|
||||||
'GTX 750': '2014-02',
|
|
||||||
'GTX 7': '2013-05',
|
|
||||||
'GTX 6': '2012-03'
|
|
||||||
};
|
|
||||||
|
|
||||||
// 출시 연도만 있는 경우 (지시에 따라 후속년도 12월 적용을 위함)
|
|
||||||
const YEAR_ONLY = {
|
|
||||||
'I5-4': 2013,
|
|
||||||
'I5-6': 2015,
|
|
||||||
'I7-7': 2017,
|
|
||||||
'GTX 750': 2014
|
|
||||||
};
|
|
||||||
|
|
||||||
function inferDateFromSpecs(cpu, gpu) {
|
|
||||||
const cpuStr = (cpu || '').toUpperCase();
|
|
||||||
const gpuStr = (gpu || '').toUpperCase();
|
|
||||||
|
|
||||||
let latestYear = 0;
|
|
||||||
let latestMonth = 0;
|
|
||||||
|
|
||||||
// 모든 매핑 데이터를 순회하며 가장 최신 날짜를 찾음
|
|
||||||
for (const [key, dateStr] of Object.entries(RELEASE_DATES)) {
|
|
||||||
if (cpuStr.includes(key) || gpuStr.includes(key)) {
|
|
||||||
const [y, m] = dateStr.split('-').map(Number);
|
|
||||||
if (y > latestYear || (y === latestYear && m > latestMonth)) {
|
|
||||||
latestYear = y;
|
|
||||||
latestMonth = m;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 매칭된 정보가 있는 경우
|
|
||||||
if (latestYear > 0) {
|
|
||||||
// 월 정보가 명확히 매핑된 경우 (RELEASE_DATES 사용)
|
|
||||||
// 하지만 지시사항에 따라 "월을 못찾으면 12월" & "후속년도" 규칙 적용 여부 판단
|
|
||||||
// RELEASE_DATES는 월이 이미 있으므로 그대로 사용하되,
|
|
||||||
// 만약 YEAR_ONLY에만 걸리는 경우를 위해 로직 보강
|
|
||||||
return `${latestYear}-${String(latestMonth).padStart(2, '0')}-01`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 연도만 매칭되는 경우 (지시사항: 후속년도 12월)
|
|
||||||
for (const [key, year] of Object.entries(YEAR_ONLY)) {
|
|
||||||
if (cpuStr.includes(key) || gpuStr.includes(key)) {
|
|
||||||
return `${year + 1}-12-01`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
const connection = await pool.getConnection();
|
|
||||||
try {
|
|
||||||
const [rows] = await connection.query(`
|
|
||||||
SELECT c.id, c.asset_code, c.purchase_date, s.cpu, s.gpu
|
|
||||||
FROM asset_core c
|
|
||||||
LEFT JOIN asset_spec s ON c.id = s.asset_id
|
|
||||||
`);
|
|
||||||
|
|
||||||
const updates = [];
|
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
const currentVal = (row.purchase_date || '').trim();
|
|
||||||
|
|
||||||
// 구매일자가 없거나 '-', 'undefined'인 경우 + 혹은 아직 보정이 필요한 자산
|
|
||||||
if (!currentVal || currentVal === '-' || currentVal === 'undefined' || currentVal.startsWith('0000') || currentVal === '2024-01-01') {
|
|
||||||
const specDate = inferDateFromSpecs(row.cpu, row.gpu);
|
|
||||||
if (specDate) {
|
|
||||||
updates.push({ id: row.id, date: specDate, code: row.asset_code, cpu: row.cpu, gpu: row.gpu });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`🚀 지시사항 반영: ${updates.length}건의 자산을 보정합니다. (후속년도/12월 규칙 적용)`);
|
|
||||||
|
|
||||||
for (const item of updates) {
|
|
||||||
await connection.query('UPDATE asset_core SET purchase_date = ? WHERE id = ?', [item.date, item.id]);
|
|
||||||
console.log(`[Update] ${item.code.padEnd(15)} | CPU: ${String(item.cpu).padEnd(20)} | GPU: ${String(item.gpu).padEnd(15)} -> ${item.date}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n✅ 완료: ${updates.length}건 보정됨.`);
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error:', err);
|
|
||||||
} finally {
|
|
||||||
connection.release();
|
|
||||||
pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
run();
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import mysql from 'mysql2/promise';
|
|
||||||
import dotenv from 'dotenv';
|
|
||||||
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const pool = mysql.createPool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASS,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
port: parseInt(process.env.DB_PORT || '3306'),
|
|
||||||
});
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
const connection = await pool.getConnection();
|
|
||||||
try {
|
|
||||||
// 먼저 잘못 들어간 0000-00-01 등 복구
|
|
||||||
console.log('잘못된 형식(0000-00-01 등)을 초기화합니다...');
|
|
||||||
await connection.query("UPDATE asset_core SET purchase_date = '-' WHERE purchase_date LIKE '0000%' OR purchase_date = '2020-01-01'");
|
|
||||||
|
|
||||||
const [rows] = await connection.query('SELECT id, asset_code, purchase_date, category FROM asset_core');
|
|
||||||
|
|
||||||
const updates = [];
|
|
||||||
const missing = [];
|
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
const code = (row.asset_code || '').trim();
|
|
||||||
const currentVal = (row.purchase_date || '').trim();
|
|
||||||
|
|
||||||
// 구매일자가 없거나 '-', 'undefined' 인 경우 대상
|
|
||||||
if (!currentVal || currentVal === '-' || currentVal === 'undefined') {
|
|
||||||
let inferredDate = null;
|
|
||||||
|
|
||||||
// 1. PREFIX-YYYYMM-NNNN 형식 (예: PC-202406-0001)
|
|
||||||
const match6 = code.match(/[A-Z]+-(\d{4})(0[1-9]|1[0-2])-\d+/);
|
|
||||||
if (match6) {
|
|
||||||
inferredDate = `${match6[1]}-${match6[2]}-01`;
|
|
||||||
} else {
|
|
||||||
// 2. PREFIX-YYYYNN 형식 (예: PC-202423) -> 연도만 있고 뒤에 순번 2자리
|
|
||||||
const matchYearSeq = code.match(/[A-Z]+-(20\d{2})(\d{2})$/);
|
|
||||||
if (matchYearSeq) {
|
|
||||||
inferredDate = `${matchYearSeq[1]}-01-01`; // 월을 모르므로 1월로 통일
|
|
||||||
} else {
|
|
||||||
// 3. PREFIX-YYNNN 형식 (예: PC-24001)
|
|
||||||
const matchShort = code.match(/[A-Z]+-(1\d|2\d)(\d{3})/);
|
|
||||||
if (matchShort) {
|
|
||||||
inferredDate = `20${matchShort[1]}-01-01`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 0000 등의 잘못된 매칭 방지
|
|
||||||
if (inferredDate && !inferredDate.startsWith('0000')) {
|
|
||||||
updates.push({ id: row.id, date: inferredDate, code: code });
|
|
||||||
} else {
|
|
||||||
missing.push({ id: row.id, code: code, category: row.category });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`총 ${updates.length}건의 자산을 업데이트합니다.`);
|
|
||||||
for (const item of updates) {
|
|
||||||
await connection.query('UPDATE asset_core SET purchase_date = ? WHERE id = ?', [item.date, item.id]);
|
|
||||||
console.log(`[Update] ${item.code} -> ${item.date}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n--- 구매일자를 추정할 수 없는 자산 목록 ---');
|
|
||||||
if (missing.length === 0) {
|
|
||||||
console.log('없음');
|
|
||||||
} else {
|
|
||||||
// 중복 제거 및 정렬하여 보고
|
|
||||||
const uniqueMissing = missing.filter(m => m.code !== '');
|
|
||||||
uniqueMissing.forEach(m => {
|
|
||||||
console.log(`[Missing] 코드: ${m.code.padEnd(20)} | 카테고리: ${m.category}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n완료: ${updates.length}건 업데이트됨, ${missing.length}건 미결정.`);
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error:', err);
|
|
||||||
} finally {
|
|
||||||
connection.release();
|
|
||||||
pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
run();
|
|
||||||
47
server.js
@@ -4,7 +4,22 @@ import cors from 'cors';
|
|||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
|
||||||
dotenv.config({ override: true });
|
dotenv.config();
|
||||||
|
|
||||||
|
const dbConfig = {
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASS,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
port: parseInt(process.env.DB_PORT || '3306')
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDbConnectionSummary = () => ({
|
||||||
|
host: dbConfig.host || '(missing)',
|
||||||
|
port: dbConfig.port,
|
||||||
|
user: dbConfig.user || '(missing)',
|
||||||
|
database: dbConfig.database || '(missing)'
|
||||||
|
});
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
@@ -18,11 +33,11 @@ if (!fs.existsSync('uploads')) {
|
|||||||
|
|
||||||
// MySQL Pool Configuration
|
// MySQL Pool Configuration
|
||||||
const pool = mysql.createPool({
|
const pool = mysql.createPool({
|
||||||
host: process.env.DB_HOST,
|
host: dbConfig.host,
|
||||||
user: process.env.DB_USER,
|
user: dbConfig.user,
|
||||||
password: process.env.DB_PASS,
|
password: dbConfig.password,
|
||||||
database: process.env.DB_NAME,
|
database: dbConfig.database,
|
||||||
port: parseInt(process.env.DB_PORT || '3306'),
|
port: dbConfig.port,
|
||||||
waitForConnections: true,
|
waitForConnections: true,
|
||||||
connectionLimit: 10,
|
connectionLimit: 10,
|
||||||
queueLimit: 0
|
queueLimit: 0
|
||||||
@@ -48,7 +63,15 @@ const pool = mysql.createPool({
|
|||||||
`);
|
`);
|
||||||
console.log('✅ job_spec_standards table verification completed.');
|
console.log('✅ job_spec_standards table verification completed.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('❌ Failed to verify/create job_spec_standards table:', err);
|
console.error('❌ Failed to verify/create job_spec_standards table:', {
|
||||||
|
db: getDbConnectionSummary(),
|
||||||
|
code: err.code,
|
||||||
|
errno: err.errno,
|
||||||
|
syscall: err.syscall,
|
||||||
|
address: err.address,
|
||||||
|
port: err.port,
|
||||||
|
message: err.message
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
if (connection) connection.release();
|
if (connection) connection.release();
|
||||||
}
|
}
|
||||||
@@ -56,7 +79,15 @@ const pool = mysql.createPool({
|
|||||||
|
|
||||||
// Error Handler
|
// Error Handler
|
||||||
const handleError = (res, err, label) => {
|
const handleError = (res, err, label) => {
|
||||||
console.error(`❌ [${label}] Error:`, err);
|
console.error(`❌ [${label}] Error:`, {
|
||||||
|
db: getDbConnectionSummary(),
|
||||||
|
code: err.code,
|
||||||
|
errno: err.errno,
|
||||||
|
syscall: err.syscall,
|
||||||
|
address: err.address,
|
||||||
|
port: err.port,
|
||||||
|
message: err.message
|
||||||
|
});
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -111,16 +111,9 @@ export function closeModals() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function initBaseModal() {
|
export function initBaseModal() {
|
||||||
// ESC 키로 모든 모달 닫기 (위치보기 팝업이 있으면 그것부터 닫음)
|
// ESC 키로 모든 모달 닫기
|
||||||
window.addEventListener('keydown', (e) => {
|
window.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') closeModals();
|
||||||
const picker = document.querySelector('.image-picker-overlay');
|
|
||||||
if (picker) {
|
|
||||||
picker.remove();
|
|
||||||
} else {
|
|
||||||
closeModals();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { closeAllModals: closeModals };
|
return { closeAllModals: closeModals };
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import { createIcons, X } from 'lucide';
|
|||||||
|
|
||||||
const DASHBOARD_DETAIL_MODAL_HTML = `
|
const DASHBOARD_DETAIL_MODAL_HTML = `
|
||||||
<div id="dashboard-detail-modal" class="modal-overlay hidden">
|
<div id="dashboard-detail-modal" class="modal-overlay hidden">
|
||||||
<div class="modal-content wide">
|
<div class="modal-content wide" style="max-width: 1000px;">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 id="dashboard-detail-modal-title" class="modal-title">상세 목록</h2>
|
<h2 id="dashboard-detail-modal-title">상세 목록</h2>
|
||||||
<button id="btn-close-dashboard-detail-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
|
<button id="btn-close-dashboard-detail-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table>
|
<table style="width:100%;">
|
||||||
<thead></thead>
|
<thead></thead>
|
||||||
<tbody id="dashboard-detail-tbody"></tbody>
|
<tbody id="dashboard-detail-tbody"></tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { state, saveAsset, deleteAsset } from '../../core/state';
|
|||||||
import { BaseModal } from './BaseModal';
|
import { BaseModal } from './BaseModal';
|
||||||
import { CORP_LIST } from './SharedData';
|
import { CORP_LIST } from './SharedData';
|
||||||
import { generateOptionsHTML, setFieldValue, getFieldValue } from './ModalUtils';
|
import { generateOptionsHTML, setFieldValue, getFieldValue } from './ModalUtils';
|
||||||
import { createIcons, X, Save, History, Plus } from 'lucide';
|
import { createIcons, X, Save, Database, CalendarClock, Edit2, History, Plus } from 'lucide';
|
||||||
import { formatExcelDate } from '../../core/excelHandler';
|
import { formatExcelDate } from '../../core/excelHandler';
|
||||||
import { UI_TEXT } from '../../core/schema';
|
import { UI_TEXT } from '../../core/schema';
|
||||||
|
|
||||||
@@ -16,18 +16,15 @@ class DomainAssetModal extends BaseModal {
|
|||||||
<div id="domain-asset-modal" class="modal-overlay hidden">
|
<div id="domain-asset-modal" class="modal-overlay hidden">
|
||||||
<div class="modal-content wide">
|
<div class="modal-content wide">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div class="header-left">
|
<h2 id="domain-modal-title">${this.title}</h2>
|
||||||
<h2 id="domain-modal-title" class="modal-title">${this.title}</h2>
|
<button id="btn-close-domain-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
|
||||||
<div id="domain-header-identity" class="header-identity"></div>
|
|
||||||
</div>
|
|
||||||
<button id="btn-close-domain-modal" class="btn-icon" aria-label="닫기">×</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="modal-body-split">
|
<div class="modal-body-split">
|
||||||
<div class="modal-form-area">
|
<div class="modal-form-area">
|
||||||
<form id="domain-asset-form" class="grid-form">
|
<form id="domain-asset-form" class="grid-form">
|
||||||
<input type="hidden" id="domain-id" name="id" />
|
<input type="hidden" id="domain-id" name="id" />
|
||||||
|
|
||||||
<div class="form-section-title">기본 정보</div>
|
<div class="form-section-title">기본 정보</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>구분</label>
|
<label>구분</label>
|
||||||
@@ -61,7 +58,7 @@ class DomainAssetModal extends BaseModal {
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>비용 (연간/월간)</label>
|
<label>비용 (연간/월간)</label>
|
||||||
<input type="text" id="domain-price" name="price" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" />
|
<input type="text" id="domain-price" name="price" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\\\B(?=(\\\\d{3})+(?!\\\\d))/g, ',')" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-section-title">담당자 및 비고</div>
|
<div class="form-section-title">담당자 및 비고</div>
|
||||||
@@ -81,9 +78,9 @@ class DomainAssetModal extends BaseModal {
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-history-area">
|
<div class="modal-history-area">
|
||||||
<div class="history-header">
|
<div class="history-header">
|
||||||
<h3><i data-lucide="history"></i> 변경 이력</h3>
|
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> 변경 이력</h3>
|
||||||
<button type="button" id="btn-add-domain-log" class="btn btn-outline btn-sm">
|
<button type="button" id="btn-add-domain-log" class="btn btn-outline btn-sm">
|
||||||
이력 추가 <i data-lucide="plus"></i>
|
이력 추가 <i data-lucide="plus" style="width:14px; height:14px;"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="domain-history-list" class="history-timeline"></div>
|
<div id="domain-history-list" class="history-timeline"></div>
|
||||||
@@ -144,7 +141,7 @@ class DomainAssetModal extends BaseModal {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
createIcons({ icons: { History, Plus, Save, X } });
|
createIcons({ icons: { History, Plus, Save, CalendarClock, Database } });
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fillFormData(asset: any): void {
|
protected fillFormData(asset: any): void {
|
||||||
@@ -161,37 +158,14 @@ class DomainAssetModal extends BaseModal {
|
|||||||
setFieldValue('domain-remarks', asset.remarks || '');
|
setFieldValue('domain-remarks', asset.remarks || '');
|
||||||
|
|
||||||
this.renderHistory(asset.id);
|
this.renderHistory(asset.id);
|
||||||
this.updateHeaderIdentity(asset);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onAfterOpen(asset: any, mode: string): void {
|
protected onAfterOpen(asset: any, mode: string): void {
|
||||||
const titleEl = document.getElementById('domain-modal-title');
|
const titleEl = document.getElementById('domain-modal-title');
|
||||||
if (titleEl) titleEl.textContent = (mode === 'add') ? '신규 도메인 등록' : '도메인 정보 상세';
|
if (titleEl) titleEl.textContent = (mode === 'add') ? '신규 도메인 등록' : '도메인 정보 상세';
|
||||||
|
|
||||||
const deleteBtn = document.getElementById('btn-delete-domain-asset');
|
const deleteBtn = document.getElementById('btn-delete-domain-asset');
|
||||||
if (deleteBtn) deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
|
if (deleteBtn) deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
|
||||||
|
|
||||||
this.updateHeaderIdentity(asset);
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateHeaderIdentity(asset: any) {
|
|
||||||
const container = document.getElementById('domain-header-identity');
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
if (this.currentMode === 'add') {
|
|
||||||
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const type = getFieldValue('domain-type') || asset.type || '';
|
|
||||||
const serviceName = getFieldValue('domain-service-name') || asset.service_name || '';
|
|
||||||
const domainName = getFieldValue('domain-name') || asset.domain_name || '';
|
|
||||||
|
|
||||||
container.innerHTML = `
|
|
||||||
<span class="asset-code-title">${serviceName}</span>
|
|
||||||
<span class="service-type-badge">${type}</span>
|
|
||||||
<span class="asset-type-label">${domainName}</span>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderHistory(assetId: string) {
|
private renderHistory(assetId: string) {
|
||||||
@@ -199,10 +173,16 @@ class DomainAssetModal extends BaseModal {
|
|||||||
if (!container) return;
|
if (!container) return;
|
||||||
const logs = (state.masterData.logs || []).filter(l => l.assetId === assetId);
|
const logs = (state.masterData.logs || []).filter(l => l.assetId === assetId);
|
||||||
if (logs.length === 0) { container.innerHTML = '<div class="empty-history">이력이 없습니다.</div>'; return; }
|
if (logs.length === 0) { container.innerHTML = '<div class="empty-history">이력이 없습니다.</div>'; return; }
|
||||||
container.innerHTML = logs.map(l => `<div class="history-item"><div class="history-date">${l.date}</div><div class="history-user">${l.user}</div><div class="history-details">${l.details}</div></div>`).join('');
|
container.innerHTML = logs.map(l => `<div class=\"history-item\"><div class=\"history-date\">${l.date}</div><div class=\"history-user\">${l.user}</div><div class=\"history-details\">${l.details}</div></div>`).join('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const domainModal = new DomainAssetModal();
|
export const domainModal = new DomainAssetModal();
|
||||||
export function initDomainModal(onSave: () => void, closeModals: () => void) { domainModal.init(onSave, closeModals); }
|
|
||||||
export function openDomainModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { domainModal.open(asset, mode); }
|
export function initDomainModal(onSave: () => void, closeModals: () => void) {
|
||||||
|
domainModal.init(onSave, closeModals);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openDomainModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
|
||||||
|
domainModal.open(asset, mode);
|
||||||
|
}
|
||||||
|
|||||||
@@ -117,22 +117,13 @@ export function setEditLock(
|
|||||||
form.classList.remove('is-view-mode');
|
form.classList.remove('is-view-mode');
|
||||||
form.classList.add('is-edit-mode');
|
form.classList.add('is-edit-mode');
|
||||||
saveBtn.textContent = '저장';
|
saveBtn.textContent = '저장';
|
||||||
revertBtn.classList.toggle('hidden', mode === 'add');
|
revertBtn.classList.toggle('hidden', mode === 'add'); // 신규 추가 시에는 취소 버튼 숨김
|
||||||
|
|
||||||
// 모든 필드 활성화
|
|
||||||
const inputs = form.querySelectorAll('input, select, textarea');
|
|
||||||
inputs.forEach(input => {
|
|
||||||
const el = input as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
|
|
||||||
if (el.name !== 'asset_code' && !el.id.includes('asset-id')) { // 자산번호 등 일부는 편집 모드에서도 잠금 유지
|
|
||||||
el.disabled = false;
|
|
||||||
if ('readOnly' in el) (el as HTMLInputElement).readOnly = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 번호 생성 버튼은 '추가(add)' 시에만 노출
|
// 번호 생성 버튼은 '추가(add)' 시에만 노출
|
||||||
if (generateBtn) {
|
if (generateBtn) {
|
||||||
generateBtn.style.display = mode === 'add' ? 'flex' : 'none';
|
generateBtn.style.display = mode === 'add' ? 'flex' : 'none';
|
||||||
}
|
}
|
||||||
|
// 내역 추가 버튼 노출
|
||||||
if (addLogBtn) addLogBtn.style.display = 'flex';
|
if (addLogBtn) addLogBtn.style.display = 'flex';
|
||||||
} else {
|
} else {
|
||||||
// 조회 모드 (잠금)
|
// 조회 모드 (잠금)
|
||||||
@@ -141,13 +132,7 @@ export function setEditLock(
|
|||||||
saveBtn.textContent = '수정';
|
saveBtn.textContent = '수정';
|
||||||
revertBtn.classList.add('hidden');
|
revertBtn.classList.add('hidden');
|
||||||
|
|
||||||
// 모든 필드 잠금
|
// 조회 모드에서는 버튼들 숨김
|
||||||
const inputs = form.querySelectorAll('input, select, textarea');
|
|
||||||
inputs.forEach(input => {
|
|
||||||
const el = input as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
|
|
||||||
el.disabled = true; // select의 경우 disabled 필요
|
|
||||||
});
|
|
||||||
|
|
||||||
if (generateBtn) generateBtn.style.display = 'none';
|
if (generateBtn) generateBtn.style.display = 'none';
|
||||||
if (addLogBtn) addLogBtn.style.display = 'none';
|
if (addLogBtn) addLogBtn.style.display = 'none';
|
||||||
}
|
}
|
||||||
@@ -184,9 +169,9 @@ export function createModalFrameHTML(
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-history-area">
|
<div class="modal-history-area">
|
||||||
<div class="history-header">
|
<div class="history-header">
|
||||||
<h3><i data-lucide="history" class="icon-sm"></i> ${options.historyTitle}</h3>
|
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> ${options.historyTitle}</h3>
|
||||||
<button type="button" id="btn-add-${idPrefix}-log" class="btn btn-outline btn-sm">
|
<button type="button" id="${options.addLogBtnId}" class="btn btn-outline btn-sm">
|
||||||
내역 추가 <i data-lucide="plus" class="icon-sm"></i>
|
내역 추가 <i data-lucide="plus" style="width:14px; height:14px;"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="${idPrefix}-history-list" class="history-timeline"></div>
|
<div id="${idPrefix}-history-list" class="history-timeline"></div>
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { API_BASE_URL } from '../../core/utils';
|
|||||||
|
|
||||||
export class PCFlowModal {
|
export class PCFlowModal {
|
||||||
private static instance: PCFlowModal | null = null;
|
private static instance: PCFlowModal | null = null;
|
||||||
|
|
||||||
private modalEl: HTMLElement | null = null;
|
private modalEl: HTMLElement | null = null;
|
||||||
private currentFlowType: 'checkout' | 'return' | 'move' = 'checkout';
|
private currentFlowType: 'checkout' | 'return' | 'move' = 'checkout';
|
||||||
|
|
||||||
// Selected state
|
// Selected state
|
||||||
private selectedUser: any = null;
|
private selectedUser: any = null;
|
||||||
private selectedTargetUser: any = null;
|
private selectedTargetUser: any = null;
|
||||||
@@ -30,7 +30,7 @@ export class PCFlowModal {
|
|||||||
|
|
||||||
this.modalEl = document.getElementById('pc-flow-modal');
|
this.modalEl = document.getElementById('pc-flow-modal');
|
||||||
this.setupEventListeners(onSave);
|
this.setupEventListeners(onSave);
|
||||||
|
|
||||||
// Set default date to today
|
// Set default date to today
|
||||||
const dateInput = document.getElementById('pc-flow-date') as HTMLInputElement;
|
const dateInput = document.getElementById('pc-flow-date') as HTMLInputElement;
|
||||||
if (dateInput) {
|
if (dateInput) {
|
||||||
@@ -59,19 +59,14 @@ export class PCFlowModal {
|
|||||||
this.selectedTargetUser = null;
|
this.selectedTargetUser = null;
|
||||||
this.selectedPC = null;
|
this.selectedPC = null;
|
||||||
this.currentFlowType = 'checkout';
|
this.currentFlowType = 'checkout';
|
||||||
|
|
||||||
const radioCheckout = document.querySelector('input[name="flow-type"][value="checkout"]') as HTMLInputElement;
|
const radioCheckout = document.querySelector('input[name="flow-type"][value="checkout"]') as HTMLInputElement;
|
||||||
if (radioCheckout) {
|
if (radioCheckout) radioCheckout.checked = true;
|
||||||
radioCheckout.checked = true;
|
|
||||||
document.querySelectorAll('.flow-type-label').forEach(l => {
|
|
||||||
l.classList.toggle('active', l.contains(radioCheckout));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset text fields
|
// Reset text fields
|
||||||
const userSearch = document.getElementById('pc-flow-user-search') as HTMLInputElement;
|
const userSearch = document.getElementById('pc-flow-user-search') as HTMLInputElement;
|
||||||
if (userSearch) userSearch.value = '';
|
if (userSearch) userSearch.value = '';
|
||||||
|
|
||||||
const targetUserSearch = document.getElementById('pc-flow-target-user-search') as HTMLInputElement;
|
const targetUserSearch = document.getElementById('pc-flow-target-user-search') as HTMLInputElement;
|
||||||
if (targetUserSearch) targetUserSearch.value = '';
|
if (targetUserSearch) targetUserSearch.value = '';
|
||||||
|
|
||||||
@@ -99,7 +94,7 @@ export class PCFlowModal {
|
|||||||
label.classList.add('active');
|
label.classList.add('active');
|
||||||
radio.checked = true;
|
radio.checked = true;
|
||||||
this.currentFlowType = radio.value as any;
|
this.currentFlowType = radio.value as any;
|
||||||
|
|
||||||
// Reset selected PC when switching flow types
|
// Reset selected PC when switching flow types
|
||||||
this.selectedPC = null;
|
this.selectedPC = null;
|
||||||
this.updateUI();
|
this.updateUI();
|
||||||
@@ -109,16 +104,16 @@ export class PCFlowModal {
|
|||||||
// 1. Source User Autocomplete Search
|
// 1. Source User Autocomplete Search
|
||||||
const userSearch = document.getElementById('pc-flow-user-search') as HTMLInputElement;
|
const userSearch = document.getElementById('pc-flow-user-search') as HTMLInputElement;
|
||||||
const userSuggestions = document.getElementById('pc-flow-user-suggestions')!;
|
const userSuggestions = document.getElementById('pc-flow-user-suggestions')!;
|
||||||
|
|
||||||
userSearch?.addEventListener('input', () => {
|
userSearch?.addEventListener('input', () => {
|
||||||
const query = userSearch.value.trim().toLowerCase();
|
const query = userSearch.value.trim().toLowerCase();
|
||||||
if (!query) {
|
if (!query) {
|
||||||
userSuggestions.classList.add('hidden');
|
userSuggestions.classList.add('hidden');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const users = state.masterData.users || [];
|
const users = state.masterData.users || [];
|
||||||
const filtered = users.filter((u: any) =>
|
const filtered = users.filter((u: any) =>
|
||||||
(u.user_name && u.user_name.toLowerCase().includes(query)) ||
|
(u.user_name && u.user_name.toLowerCase().includes(query)) ||
|
||||||
(u.dept_name && u.dept_name.toLowerCase().includes(query)) ||
|
(u.dept_name && u.dept_name.toLowerCase().includes(query)) ||
|
||||||
(u.emp_no && u.emp_no.toString().includes(query))
|
(u.emp_no && u.emp_no.toString().includes(query))
|
||||||
@@ -138,7 +133,7 @@ export class PCFlowModal {
|
|||||||
this.selectedUser = user;
|
this.selectedUser = user;
|
||||||
userSearch.value = `${user.user_name} (${user.dept_name} / 사번:${user.emp_no || '-'})`;
|
userSearch.value = `${user.user_name} (${user.dept_name} / 사번:${user.emp_no || '-'})`;
|
||||||
userSuggestions.classList.add('hidden');
|
userSuggestions.classList.add('hidden');
|
||||||
|
|
||||||
// Automatically populate details if return or move
|
// Automatically populate details if return or move
|
||||||
if (this.currentFlowType === 'return' || this.currentFlowType === 'move') {
|
if (this.currentFlowType === 'return' || this.currentFlowType === 'move') {
|
||||||
this.selectedPC = null; // Reset selection
|
this.selectedPC = null; // Reset selection
|
||||||
@@ -166,16 +161,16 @@ export class PCFlowModal {
|
|||||||
// 2. Target User Autocomplete Search (For Moves)
|
// 2. Target User Autocomplete Search (For Moves)
|
||||||
const targetUserSearch = document.getElementById('pc-flow-target-user-search') as HTMLInputElement;
|
const targetUserSearch = document.getElementById('pc-flow-target-user-search') as HTMLInputElement;
|
||||||
const targetSuggestions = document.getElementById('pc-flow-target-user-suggestions')!;
|
const targetSuggestions = document.getElementById('pc-flow-target-user-suggestions')!;
|
||||||
|
|
||||||
targetUserSearch?.addEventListener('input', () => {
|
targetUserSearch?.addEventListener('input', () => {
|
||||||
const query = targetUserSearch.value.trim().toLowerCase();
|
const query = targetUserSearch.value.trim().toLowerCase();
|
||||||
if (!query) {
|
if (!query) {
|
||||||
targetSuggestions.classList.add('hidden');
|
targetSuggestions.classList.add('hidden');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const users = state.masterData.users || [];
|
const users = state.masterData.users || [];
|
||||||
const filtered = users.filter((u: any) =>
|
const filtered = users.filter((u: any) =>
|
||||||
(u.user_name && u.user_name.toLowerCase().includes(query)) ||
|
(u.user_name && u.user_name.toLowerCase().includes(query)) ||
|
||||||
(u.dept_name && u.dept_name.toLowerCase().includes(query)) ||
|
(u.dept_name && u.dept_name.toLowerCase().includes(query)) ||
|
||||||
(u.emp_no && u.emp_no.toString().includes(query))
|
(u.emp_no && u.emp_no.toString().includes(query))
|
||||||
@@ -202,7 +197,7 @@ export class PCFlowModal {
|
|||||||
// 3. Stock PC Autocomplete Search (For Checkout)
|
// 3. Stock PC Autocomplete Search (For Checkout)
|
||||||
const stockSearch = document.getElementById('pc-flow-stock-search') as HTMLInputElement;
|
const stockSearch = document.getElementById('pc-flow-stock-search') as HTMLInputElement;
|
||||||
const stockSuggestions = document.getElementById('pc-flow-stock-suggestions')!;
|
const stockSuggestions = document.getElementById('pc-flow-stock-suggestions')!;
|
||||||
|
|
||||||
const showStockSuggestions = () => {
|
const showStockSuggestions = () => {
|
||||||
const query = stockSearch.value.trim().toLowerCase();
|
const query = stockSearch.value.trim().toLowerCase();
|
||||||
|
|
||||||
@@ -210,11 +205,11 @@ export class PCFlowModal {
|
|||||||
const pcs = state.masterData.pc || [];
|
const pcs = state.masterData.pc || [];
|
||||||
const filtered = pcs.filter((p: any) => {
|
const filtered = pcs.filter((p: any) => {
|
||||||
const status = (p.hw_status || '').trim();
|
const status = (p.hw_status || '').trim();
|
||||||
const matchesQuery = !query ||
|
const matchesQuery = !query ||
|
||||||
(p.asset_code && p.asset_code.toLowerCase().includes(query)) ||
|
(p.asset_code && p.asset_code.toLowerCase().includes(query)) ||
|
||||||
(p.model_name && p.model_name.toLowerCase().includes(query)) ||
|
(p.model_name && p.model_name.toLowerCase().includes(query)) ||
|
||||||
(p.cpu && p.cpu.toLowerCase().includes(query));
|
(p.cpu && p.cpu.toLowerCase().includes(query));
|
||||||
|
|
||||||
return (status === '대기' || status === '미할당' || status === '재고') && matchesQuery;
|
return (status === '대기' || status === '미할당' || status === '재고') && matchesQuery;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -314,17 +309,21 @@ export class PCFlowModal {
|
|||||||
private renderUserSuggestions(users: any[], container: HTMLElement, onSelect: (user: any) => void) {
|
private renderUserSuggestions(users: any[], container: HTMLElement, onSelect: (user: any) => void) {
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
if (users.length === 0) {
|
if (users.length === 0) {
|
||||||
container.innerHTML = '<div class="autocomplete-item-empty">일치하는 사원이 없습니다.</div>';
|
container.innerHTML = '<div style="padding: 10px; color: var(--text-muted); font-size: 13px;">일치하는 사원이 없습니다.</div>';
|
||||||
container.classList.remove('hidden');
|
container.classList.remove('hidden');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
users.forEach(u => {
|
users.forEach(u => {
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'autocomplete-item';
|
item.style.padding = '8px 12px';
|
||||||
|
item.style.cursor = 'pointer';
|
||||||
|
item.style.fontSize = '13px';
|
||||||
|
item.style.borderBottom = '1px solid #F3F4F6';
|
||||||
|
item.className = 'suggestion-item';
|
||||||
item.innerHTML = `
|
item.innerHTML = `
|
||||||
<div class="suggestion-name">${u.user_name}</div>
|
<div style="font-weight: 700; color: var(--text-main);">${u.user_name}</div>
|
||||||
<div class="suggestion-meta">
|
<div style="font-size: 11px; color: var(--text-muted); display: flex; gap: 8px;">
|
||||||
<span>부서: ${u.dept_name}</span>
|
<span>부서: ${u.dept_name}</span>
|
||||||
<span>|</span>
|
<span>|</span>
|
||||||
<span>사번: ${u.emp_no || '-'}</span>
|
<span>사번: ${u.emp_no || '-'}</span>
|
||||||
@@ -339,17 +338,21 @@ export class PCFlowModal {
|
|||||||
private renderPCSuggestions(pcs: any[], container: HTMLElement, onSelect: (pc: any) => void) {
|
private renderPCSuggestions(pcs: any[], container: HTMLElement, onSelect: (pc: any) => void) {
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
if (pcs.length === 0) {
|
if (pcs.length === 0) {
|
||||||
container.innerHTML = '<div class="autocomplete-item-empty">불출 가능한 대기 PC 재고가 없습니다.</div>';
|
container.innerHTML = '<div style="padding: 10px; color: var(--text-muted); font-size: 13px;">불출 가능한 대기 PC 재고가 없습니다.</div>';
|
||||||
container.classList.remove('hidden');
|
container.classList.remove('hidden');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
pcs.forEach(p => {
|
pcs.forEach(p => {
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'autocomplete-item';
|
item.style.padding = '8px 12px';
|
||||||
|
item.style.cursor = 'pointer';
|
||||||
|
item.style.fontSize = '13px';
|
||||||
|
item.style.borderBottom = '1px solid #F3F4F6';
|
||||||
|
item.className = 'suggestion-item';
|
||||||
item.innerHTML = `
|
item.innerHTML = `
|
||||||
<div class="suggestion-name">${p.asset_code} (${p.model_name || '모델명 없음'})</div>
|
<div style="font-weight: 700; color: var(--primary-color);">${p.asset_code} (${p.model_name || '모델명 없음'})</div>
|
||||||
<div class="suggestion-meta">
|
<div style="font-size: 11px; color: var(--text-muted);">
|
||||||
사양: CPU ${p.cpu || '-'} / RAM ${p.ram || '-'} / 위치: ${p.location || '-'}
|
사양: CPU ${p.cpu || '-'} / RAM ${p.ram || '-'} / 위치: ${p.location || '-'}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -424,20 +427,20 @@ export class PCFlowModal {
|
|||||||
const userPcsList = document.getElementById('user-pcs-list')!;
|
const userPcsList = document.getElementById('user-pcs-list')!;
|
||||||
if (this.selectedUser && (this.currentFlowType === 'return' || this.currentFlowType === 'move')) {
|
if (this.selectedUser && (this.currentFlowType === 'return' || this.currentFlowType === 'move')) {
|
||||||
const allPcs = state.masterData.pc || [];
|
const allPcs = state.masterData.pc || [];
|
||||||
const userPcs = allPcs.filter((p: any) =>
|
const userPcs = allPcs.filter((p: any) =>
|
||||||
(p.emp_no && p.emp_no.toString() === this.selectedUser.emp_no?.toString()) ||
|
(p.emp_no && p.emp_no.toString() === this.selectedUser.emp_no?.toString()) ||
|
||||||
(p.user_current && p.user_current === this.selectedUser.user_name)
|
(p.user_current && p.user_current === this.selectedUser.user_name)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (userPcs.length === 0) {
|
if (userPcs.length === 0) {
|
||||||
userPcsList.innerHTML = '<div class="empty-list-message">이 사용자가 소유한 PC 자산이 없습니다.</div>';
|
userPcsList.innerHTML = '<div style="font-size: 12px; color: var(--text-muted); padding: 8px 0;">이 사용자가 소유한 PC 자산이 없습니다.</div>';
|
||||||
} else {
|
} else {
|
||||||
userPcsList.innerHTML = userPcs.map(p => {
|
userPcsList.innerHTML = userPcs.map(p => {
|
||||||
const isSelected = this.selectedPC && this.selectedPC.id === p.id;
|
const isSelected = this.selectedPC && this.selectedPC.id === p.id;
|
||||||
return `
|
return `
|
||||||
<div class="user-pc-item ${isSelected ? 'selected' : ''}" data-id="${p.id}">
|
<div class="user-pc-item ${isSelected ? 'selected' : ''}" data-id="${p.id}" style="padding: 10px; border: 1px solid ${isSelected ? 'var(--primary-color)' : 'var(--border-color)'}; border-radius: 4px; cursor: pointer; background: ${isSelected ? 'var(--primary-light)' : 'white'}; transition: all 0.2s;">
|
||||||
<div class="pc-item-code">${p.asset_code}</div>
|
<div style="font-weight: 700; font-size: 13px; color: ${isSelected ? 'var(--primary-color)' : 'var(--text-main)'};">${p.asset_code}</div>
|
||||||
<div class="pc-item-meta">
|
<div style="font-size: 11px; color: var(--text-muted); margin-top: 2px;">
|
||||||
${p.model_name || '모델명 없음'} | CPU: ${p.cpu || '-'} | RAM: ${p.ram || '-'}
|
${p.model_name || '모델명 없음'} | CPU: ${p.cpu || '-'} | RAM: ${p.ram || '-'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -462,132 +465,159 @@ export class PCFlowModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private renderHTML(): string {
|
private renderHTML(): string {
|
||||||
|
const overlayStyle = `
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4); display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 1000; transition: opacity 0.3s;
|
||||||
|
`;
|
||||||
|
const contentStyle = `
|
||||||
|
background: white; border-radius: 12px; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
|
||||||
|
overflow: hidden; max-height: 90vh; width: 950px; display: flex; flex-direction: column;
|
||||||
|
`;
|
||||||
|
const labelStyle = 'display: block; font-size: 13px; font-weight: 700; color: var(--text-muted); margin-bottom: 8px;';
|
||||||
|
const inputStyle = 'width: 100%; height: 38px; padding: 0 12px; border: 1px solid var(--border-color); border-radius: 4px; font-size: 13px; outline: none; box-sizing: border-box;';
|
||||||
|
const inputWithIconStyle = 'width: 100%; height: 38px; padding: 0 12px 0 36px; border: 1px solid var(--border-color); border-radius: 4px; font-size: 13px; outline: none; box-sizing: border-box;';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div id="pc-flow-modal" class="modal-overlay hidden">
|
<div id="pc-flow-modal" class="modal-overlay hidden" style="${overlayStyle}">
|
||||||
<div class="modal-content wide">
|
<div class="modal-content" style="${contentStyle}">
|
||||||
<div class="modal-header">
|
|
||||||
<h2 class="modal-title">
|
<div class="modal-header" style="background: var(--primary-color); padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-color);">
|
||||||
|
<h2 style="margin: 0; font-size: 18px; font-weight: 800; color: white; display: flex; align-items: center; gap: 8px;">
|
||||||
<i data-lucide="refresh-cw"></i> PC 이동/반납 (불출/반납/이동)
|
<i data-lucide="refresh-cw"></i> PC 이동/반납 (불출/반납/이동)
|
||||||
</h2>
|
</h2>
|
||||||
<button id="btn-close-pc-flow-modal" class="btn-icon" aria-label="닫기">×</button>
|
<button id="btn-close-pc-flow-modal" class="btn-icon" aria-label="닫기" style="font-size: 28px; color: white; background: none; border: none; cursor: pointer; line-height: 1;">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-body">
|
<div class="modal-body" style="padding: 24px; overflow-y: auto; display: flex; gap: 24px;">
|
||||||
<div class="modal-body-split">
|
<!-- 왼쪽 영역: 입력 폼 -->
|
||||||
<!-- 왼쪽 영역: 입력 폼 -->
|
<div style="flex: 1.2; display: flex; flex-direction: column; gap: 20px;">
|
||||||
<div class="modal-form-area">
|
|
||||||
<div class="grid-form flex-col">
|
<!-- 1. 처리 유형 -->
|
||||||
|
<div>
|
||||||
<!-- 1. 처리 유형 -->
|
<label style="${labelStyle}">1. 처리 유형 선택</label>
|
||||||
<div class="form-group">
|
<div style="display: flex; gap: 12px;">
|
||||||
<label>1. 처리 유형 선택</label>
|
<label class="flow-type-label active" style="flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px; border: 1px solid var(--border-color); border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600;">
|
||||||
<div class="view-toggle w-full flex-row">
|
<input type="radio" name="flow-type" value="checkout" checked style="display:none;" />
|
||||||
<label class="flow-type-label toggle-btn active flex-1 text-center">
|
불출 (지급)
|
||||||
<input type="radio" name="flow-type" value="checkout" checked class="hidden" />
|
</label>
|
||||||
불출 (지급)
|
<label class="flow-type-label" style="flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px; border: 1px solid var(--border-color); border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600;">
|
||||||
</label>
|
<input type="radio" name="flow-type" value="return" style="display:none;" />
|
||||||
<label class="flow-type-label toggle-btn flex-1 text-center">
|
입고 (반납)
|
||||||
<input type="radio" name="flow-type" value="return" class="hidden" />
|
</label>
|
||||||
입고 (반납)
|
<label class="flow-type-label" style="flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px; border: 1px solid var(--border-color); border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600;">
|
||||||
</label>
|
<input type="radio" name="flow-type" value="move" style="display:none;" />
|
||||||
<label class="flow-type-label toggle-btn flex-1 text-center">
|
이동 (이관)
|
||||||
<input type="radio" name="flow-type" value="move" class="hidden" />
|
</label>
|
||||||
이동 (이관)
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 2. 대상 사용자 검색 -->
|
|
||||||
<div class="form-group relative">
|
|
||||||
<label id="user-search-label">2. 대상 사원 검색</label>
|
|
||||||
<div class="input-with-icon">
|
|
||||||
<input type="text" id="pc-flow-user-search" placeholder="사원명, 부서, 사번 검색..." />
|
|
||||||
<i data-lucide="search" class="icon-sm"></i>
|
|
||||||
</div>
|
|
||||||
<div id="pc-flow-user-suggestions" class="autocomplete-list hidden"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 3. 새 인수자 검색 (이동 시 노출) -->
|
|
||||||
<div id="target-user-search-container" class="form-group hidden relative">
|
|
||||||
<label>새 인수 사원 검색</label>
|
|
||||||
<div class="input-with-icon">
|
|
||||||
<input type="text" id="pc-flow-target-user-search" placeholder="사원명, 부서, 사번 검색..." />
|
|
||||||
<i data-lucide="search" class="icon-sm"></i>
|
|
||||||
</div>
|
|
||||||
<div id="pc-flow-target-user-suggestions" class="autocomplete-list hidden"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 4. 재고 PC 검색 (불출 시 노출) -->
|
|
||||||
<div id="stock-pc-search-container" class="form-group relative">
|
|
||||||
<label>3. 불출할 재고 PC 선택</label>
|
|
||||||
<div class="input-with-icon">
|
|
||||||
<input type="text" id="pc-flow-stock-search" placeholder="자산코드 또는 모델명 검색..." />
|
|
||||||
<i data-lucide="monitor" class="icon-sm"></i>
|
|
||||||
</div>
|
|
||||||
<div id="pc-flow-stock-suggestions" class="autocomplete-list hidden"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 5. 상세 공통 입력 -->
|
|
||||||
<div class="detail-grid-2col">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>처리 일자</label>
|
|
||||||
<input type="date" id="pc-flow-date" />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>상세 사유</label>
|
|
||||||
<textarea id="pc-flow-details" rows="2" placeholder="미입력 시 기본 문구로 자동 입력됩니다."></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 오른쪽 영역: 선택 요약 & 사원 소유 자산 목록 -->
|
<!-- 2. 대상 사용자 검색 -->
|
||||||
<div class="modal-history-area">
|
<div style="position: relative;">
|
||||||
<div class="history-header">
|
<label id="user-search-label" style="${labelStyle}">2. 대상 사원 검색</label>
|
||||||
<h3>선택 내역 요약</h3>
|
<div style="position: relative; display: flex; align-items: center;">
|
||||||
|
<input type="text" id="pc-flow-user-search" placeholder="사원명, 부서, 사번 검색..." style="${inputWithIconStyle}" />
|
||||||
|
<i data-lucide="search" style="position: absolute; left: 10px; width: 16px; height: 16px; color: var(--text-muted);"></i>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="pc-flow-user-suggestions" class="hidden" style="position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: white; border: 1px solid var(--border-color); border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); z-index: 1000; margin-top: 4px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="dynamic-row-container">
|
<!-- 3. 새 인수자 검색 (이동 시 노출) -->
|
||||||
<!-- 사원 요약 카드 -->
|
<div id="target-user-search-container" class="hidden" style="position: relative;">
|
||||||
<div id="summary-user-card" class="summary-info-card">
|
<label style="${labelStyle}">새 인수 사원 검색</label>
|
||||||
<div class="detail-label-sm">대상 사원</div>
|
<div style="position: relative; display: flex; align-items: center;">
|
||||||
<div id="summary-user-name" class="detail-value-lg">선택된 사원 없음</div>
|
<input type="text" id="pc-flow-target-user-search" placeholder="사원명, 부서, 사번 검색..." style="${inputWithIconStyle}" />
|
||||||
<div id="summary-user-dept" class="detail-label-sm">-</div>
|
<i data-lucide="search" style="position: absolute; left: 10px; width: 16px; height: 16px; color: var(--text-muted);"></i>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="pc-flow-target-user-suggestions" class="hidden" style="position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: white; border: 1px solid var(--border-color); border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); z-index: 1000; margin-top: 4px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 인수 사원 요약 카드 (이동 전용) -->
|
<!-- 4. 재고 PC 검색 (불출 시 노출) -->
|
||||||
<div id="summary-target-user-card" class="summary-info-card hidden bg-primary-light">
|
<div id="stock-pc-search-container" style="position: relative;">
|
||||||
<div class="detail-label-sm">새 인수 사원</div>
|
<label style="${labelStyle}">3. 불출할 재고 PC 선택</label>
|
||||||
<div id="summary-target-user-name" class="detail-value-lg">선택된 사원 없음</div>
|
<div style="position: relative; display: flex; align-items: center;">
|
||||||
<div id="summary-target-user-dept" class="detail-label-sm">-</div>
|
<input type="text" id="pc-flow-stock-search" placeholder="자산코드 또는 모델명 검색..." style="${inputWithIconStyle}" />
|
||||||
</div>
|
<i data-lucide="monitor" style="position: absolute; left: 10px; width: 16px; height: 16px; color: var(--text-muted);"></i>
|
||||||
|
</div>
|
||||||
|
<div id="pc-flow-stock-suggestions" class="hidden" style="position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: white; border: 1px solid var(--border-color); border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); z-index: 1000; margin-top: 4px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 대상 PC 자산 요약 카드 -->
|
<!-- 5. 상세 공통 입력 -->
|
||||||
<div id="summary-pc-card" class="summary-info-card">
|
<div style="display: flex; gap: 16px;">
|
||||||
<div class="detail-label-sm">대상 PC 자산</div>
|
<div style="flex: 1;">
|
||||||
<div id="summary-pc-code" class="detail-value-lg text-success">선택된 PC 없음</div>
|
<label style="${labelStyle.replace('margin-bottom: 8px;', 'margin-bottom: 6px;')}">처리 일자</label>
|
||||||
<div id="summary-pc-model" class="detail-label-sm">-</div>
|
<input type="date" id="pc-flow-date" style="${inputStyle}" />
|
||||||
</div>
|
</div>
|
||||||
|
<div style="flex: 2;">
|
||||||
<!-- 사용자 보유 PC 목록 선택 (반납/이동 시) -->
|
<label style="${labelStyle.replace('margin-bottom: 8px;', 'margin-bottom: 6px;')}">상세 사유</label>
|
||||||
<div id="user-pcs-container" class="form-group hidden">
|
<textarea id="pc-flow-details" rows="2" placeholder="미입력 시 기본 문구로 자동 입력됩니다." style="width: 100%; padding: 10px; border: 1px solid var(--border-color); border-radius: 4px; font-family: inherit; font-size: 13px; resize: none; box-sizing: border-box; outline: none;"></textarea>
|
||||||
<label>사원 보유 PC 선택 (클릭하여 매핑)</label>
|
|
||||||
<div id="user-pcs-list" class="user-pc-selection-list"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 오른쪽 영역: 선택 요약 & 사원 소유 자산 목록 -->
|
||||||
|
<div style="flex: 0.8; border-left: 1px solid var(--border-color); padding-left: 24px; display: flex; flex-direction: column; gap: 16px;">
|
||||||
|
<h3 style="margin: 0; font-size: 14px; font-weight: 800; border-bottom: 1px solid var(--border-color); padding-bottom: 8px;">선택 내역 요약</h3>
|
||||||
|
|
||||||
|
<!-- 사원 요약 카드 -->
|
||||||
|
<div id="summary-user-card" style="padding: 12px; background: var(--bg-light); border: 1px solid var(--border-color); border-radius: 6px; display: flex; flex-direction: column; gap: 4px;">
|
||||||
|
<div style="font-size: 11px; color: var(--text-muted);">대상 사원</div>
|
||||||
|
<div id="summary-user-name" style="font-weight: 700; font-size: 14px;">선택된 사원 없음</div>
|
||||||
|
<div id="summary-user-dept" style="font-size: 12px; color: var(--text-muted);">-</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 인수 사원 요약 카드 (이동 전용) -->
|
||||||
|
<div id="summary-target-user-card" class="summary-card hidden" style="padding: 12px; background: #EEF2F6; border: 1px solid var(--border-color); border-radius: 6px; display: flex; flex-direction: column; gap: 4px;">
|
||||||
|
<div style="font-size: 11px; color: var(--text-muted);">새 인수 사원</div>
|
||||||
|
<div id="summary-target-user-name" style="font-weight: 700; font-size: 14px;">선택된 사원 없음</div>
|
||||||
|
<div id="summary-target-user-dept" style="font-size: 12px; color: var(--text-muted);">-</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 대상 PC 자산 요약 카드 -->
|
||||||
|
<div id="summary-pc-card" style="padding: 12px; background: var(--bg-light); border: 1px solid var(--border-color); border-radius: 6px; display: flex; flex-direction: column; gap: 4px;">
|
||||||
|
<div style="font-size: 11px; color: var(--text-muted);">대상 PC 자산</div>
|
||||||
|
<div id="summary-pc-code" style="font-weight: 700; font-size: 14px; color: var(--primary-color);">선택된 PC 없음</div>
|
||||||
|
<div id="summary-pc-model" style="font-size: 12px; color: var(--text-muted);">-</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 사용자 보유 PC 목록 선택 (반납/이동 시) -->
|
||||||
|
<div id="user-pcs-container" class="hidden" style="display: flex; flex-direction: column; gap: 8px;">
|
||||||
|
<div style="font-size: 12px; font-weight: 700; color: var(--text-muted);">사원 보유 PC 선택 (클릭하여 매핑)</div>
|
||||||
|
<div id="user-pcs-list" style="display: flex; flex-direction: column; gap: 8px; max-height: 200px; overflow-y: auto;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-footer">
|
<div class="modal-footer" style="padding: 16px 24px; border-top: 1px solid var(--border-color); display: flex; justify-content: flex-end; gap: 12px; background: var(--bg-light);">
|
||||||
<div></div>
|
<button id="btn-cancel-pc-flow-modal" class="btn btn-outline" style="height: 42px;">취소</button>
|
||||||
<div class="footer-actions">
|
<button id="btn-submit-pc-flow" class="btn btn-primary" style="height: 42px;">이동/반납 처리 완료</button>
|
||||||
<button id="btn-cancel-pc-flow-modal" class="btn btn-outline">취소</button>
|
|
||||||
<button id="btn-submit-pc-flow" class="btn btn-primary">이동/반납 처리 완료</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.flow-type-label {
|
||||||
|
transition: all 0.2s;
|
||||||
|
border-color: var(--border-color);
|
||||||
|
background: white;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.flow-type-label:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
.flow-type-label.active {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background: var(--primary-light);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
.suggestion-item:hover {
|
||||||
|
background-color: var(--primary-light) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { state, savePartsMaster, deletePartsMaster } from '../../core/state';
|
import { state, savePartsMaster, deletePartsMaster } from '../../core/state';
|
||||||
import { BaseModal } from './BaseModal';
|
import { BaseModal } from './BaseModal';
|
||||||
import { generateOptionsHTML, setFieldValue, getFieldValue } from './ModalUtils';
|
import { generateOptionsHTML, setFieldValue, getFieldValue } from './ModalUtils';
|
||||||
import { createIcons, X, Save, Plus } from 'lucide';
|
import { createIcons, X, Save, Database, Edit2, Plus } from 'lucide';
|
||||||
import { UI_TEXT } from '../../core/schema';
|
import { UI_TEXT } from '../../core/schema';
|
||||||
|
|
||||||
class PartsMasterModal extends BaseModal {
|
class PartsMasterModal extends BaseModal {
|
||||||
@@ -10,51 +10,52 @@ class PartsMasterModal extends BaseModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected renderFrameHTML(): string {
|
protected renderFrameHTML(): string {
|
||||||
|
const sharedStyle = 'height: 38px !important; box-sizing: border-box !important; font-size: 13px; margin: 0;';
|
||||||
|
const inputStyle = sharedStyle;
|
||||||
|
const selectStyle = sharedStyle;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div id="parts-master-asset-modal" class="modal-overlay hidden">
|
<div id="parts-master-asset-modal" class="modal-overlay hidden">
|
||||||
<div class="modal-content narrow">
|
<div class="modal-content" style="max-width: 500px; width: 100%;">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div class="header-left">
|
<h2 id="parts-master-modal-title" style="margin: 0; font-size: 18px; font-weight: 800; color: white;">${this.title}</h2>
|
||||||
<h2 id="parts-master-modal-title" class="modal-title">${this.title}</h2>
|
<button id="btn-close-parts-master-modal" class="btn-icon" aria-label="닫기" style="font-size: 28px; color: white; background: none; border: none; cursor: pointer; line-height: 1;">×</button>
|
||||||
<div id="parts-master-header-identity" class="header-identity"></div>
|
|
||||||
</div>
|
|
||||||
<button id="btn-close-parts-master-modal" class="btn-icon" aria-label="닫기">×</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body" style="padding: 24px; overflow-y: auto;">
|
||||||
<form id="parts-master-asset-form" class="grid-form vertical-form">
|
<form id="parts-master-asset-form" class="grid-form" style="display: flex; flex-direction: column; gap: 16px;">
|
||||||
<input type="hidden" id="parts-master-id" name="id" />
|
<input type="hidden" id="parts-master-id" name="id" />
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
||||||
<label>부품 분류</label>
|
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">부품 분류</label>
|
||||||
<select id="parts-master-category" name="category">
|
<select id="parts-master-category" name="category" style="${selectStyle}">
|
||||||
<option value="CPU">CPU</option>
|
<option value="CPU">CPU</option>
|
||||||
<option value="GPU">GPU</option>
|
<option value="GPU">GPU</option>
|
||||||
<option value="RAM">RAM</option>
|
<option value="RAM">RAM</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
||||||
<label>부품 표준 명칭</label>
|
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">부품 표준 명칭</label>
|
||||||
<input type="text" id="parts-master-component-name" name="component_name" placeholder="예: Intel Core i7-14700K" required />
|
<input type="text" id="parts-master-component-name" name="component_name" placeholder="예: Intel Core i7-14700K" required style="${inputStyle} width: 100%;" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
||||||
<label>성능 등급</label>
|
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">성능 등급</label>
|
||||||
<input type="text" id="parts-master-score-tier" name="score_tier" placeholder="예: i7 / S / 최적" required />
|
<input type="text" id="parts-master-score-tier" name="score_tier" placeholder="예: i7 / S / 최적" required style="${inputStyle} width: 100%;" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
||||||
<label>감점 점수 (양수로 입력)</label>
|
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">감점 점수 (양수로 입력)</label>
|
||||||
<input type="number" id="parts-master-deduction" name="deduction" placeholder="예: 5" required />
|
<input type="number" id="parts-master-deduction" name="deduction" placeholder="예: 5" required style="${inputStyle} width: 100%;" />
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer" style="display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; background: #f8fafc; border-top: 1px solid var(--border-color);">
|
||||||
<button id="btn-delete-parts-master-asset" class="btn btn-outline btn-danger">삭제</button>
|
<button id="btn-delete-parts-master-asset" class="btn btn-outline btn-danger" style="height: 42px;">삭제</button>
|
||||||
<div class="footer-actions">
|
<div class="footer-actions" style="display: flex; gap: 8px;">
|
||||||
<button id="btn-revert-parts-master-edit" class="btn btn-outline hidden">수정 취소</button>
|
<button id="btn-revert-parts-master-edit" class="btn btn-outline hidden" style="height: 42px;">수정 취소</button>
|
||||||
<button id="btn-cancel-parts-master-modal" class="btn btn-outline">닫기</button>
|
<button id="btn-cancel-parts-master-modal" class="btn btn-outline" style="height: 42px;">닫기</button>
|
||||||
<button id="btn-save-parts-master-asset" class="btn btn-primary">수정</button>
|
<button id="btn-save-parts-master-asset" class="btn btn-primary" style="height: 42px;">수정</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,13 +109,11 @@ class PartsMasterModal extends BaseModal {
|
|||||||
if (!this.currentAsset || !this.currentAsset.id) return;
|
if (!this.currentAsset || !this.currentAsset.id) return;
|
||||||
if (!confirm('정말로 이 부품 마스터 정보를 삭제하시겠습니까?\n삭제 시 기존 등록 PC 중 이 부품명을 사용하는 PC의 자동완성 정합성 체크에 영향을 줄 수 있습니다.')) return;
|
if (!confirm('정말로 이 부품 마스터 정보를 삭제하시겠습니까?\n삭제 시 기존 등록 PC 중 이 부품명을 사용하는 PC의 자동완성 정합성 체크에 영향을 줄 수 있습니다.')) return;
|
||||||
|
|
||||||
if (await deletePartsMaster(Number(this.currentAsset.id))) {
|
if (await deletePartsMaster(this.currentAsset.id)) {
|
||||||
alert('성공적으로 삭제되었습니다.');
|
alert('성공적으로 삭제되었습니다.');
|
||||||
onSave(); this.close(); closeModals();
|
onSave(); this.close(); closeModals();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
createIcons({ icons: { Plus, X, Save } });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fillFormData(asset: any): void {
|
protected fillFormData(asset: any): void {
|
||||||
@@ -123,18 +122,23 @@ class PartsMasterModal extends BaseModal {
|
|||||||
setFieldValue('parts-master-component-name', asset.component_name || '');
|
setFieldValue('parts-master-component-name', asset.component_name || '');
|
||||||
setFieldValue('parts-master-score-tier', asset.score_tier || '');
|
setFieldValue('parts-master-score-tier', asset.score_tier || '');
|
||||||
setFieldValue('parts-master-deduction', asset.deduction !== undefined ? asset.deduction.toString() : '0');
|
setFieldValue('parts-master-deduction', asset.deduction !== undefined ? asset.deduction.toString() : '0');
|
||||||
this.updateHeaderIdentity(asset);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onAfterOpen(asset: any, mode: string): void {
|
protected onAfterOpen(asset: any, mode: string): void {
|
||||||
const titleEl = document.getElementById('parts-master-modal-title');
|
const titleEl = document.getElementById('parts-master-modal-title');
|
||||||
|
|
||||||
if (titleEl) {
|
if (titleEl) {
|
||||||
titleEl.textContent = (mode === 'add') ? '신규 부품 마스터 등록' : '부품 마스터 상세 편집';
|
if (mode === 'add') {
|
||||||
|
titleEl.textContent = '신규 부품 마스터 등록';
|
||||||
|
} else {
|
||||||
|
titleEl.textContent = '부품 마스터 상세 편집';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteBtn = document.getElementById('btn-delete-parts-master-asset')!;
|
const deleteBtn = document.getElementById('btn-delete-parts-master-asset')!;
|
||||||
const saveBtn = document.getElementById('btn-save-parts-master-asset')!;
|
const saveBtn = document.getElementById('btn-save-parts-master-asset')!;
|
||||||
|
|
||||||
|
// 추가 모드일 때는 삭제 버튼 숨김
|
||||||
deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
|
deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
|
||||||
|
|
||||||
if (mode === 'add') {
|
if (mode === 'add') {
|
||||||
@@ -148,28 +152,15 @@ class PartsMasterModal extends BaseModal {
|
|||||||
saveBtn.textContent = '수정';
|
saveBtn.textContent = '수정';
|
||||||
saveBtn.style.display = 'block';
|
saveBtn.style.display = 'block';
|
||||||
}
|
}
|
||||||
this.updateHeaderIdentity(asset);
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateHeaderIdentity(asset: any) {
|
|
||||||
const container = document.getElementById('parts-master-header-identity');
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
if (this.currentMode === 'add') {
|
|
||||||
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cat = asset.category || '';
|
|
||||||
const name = asset.component_name || '';
|
|
||||||
|
|
||||||
container.innerHTML = `
|
|
||||||
<span class="asset-code-title">${name}</span>
|
|
||||||
<span class="service-type-badge">${cat}</span>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const partsMasterModal = new PartsMasterModal();
|
export const partsMasterModal = new PartsMasterModal();
|
||||||
export function initPartsMasterModal(onSave: () => void, closeModals: () => void) { partsMasterModal.init(onSave, closeModals); }
|
|
||||||
export function openPartsMasterModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { partsMasterModal.open(asset, mode); }
|
export function initPartsMasterModal(onSave: () => void, closeModals: () => void) {
|
||||||
|
partsMasterModal.init(onSave, closeModals);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openPartsMasterModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
|
||||||
|
partsMasterModal.open(asset, mode);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { state, saveAsset, deleteAsset } from '../../core/state';
|
import { state, saveAsset, deleteAsset } from '../../core/state';
|
||||||
import { BaseModal } from './BaseModal';
|
import { BaseModal } from './BaseModal';
|
||||||
import { openSwUserModal } from './SWUserModal';
|
import { openSwUserModal } from './SWUserModal';
|
||||||
import { createIcons, History, Plus, X, Save, RotateCcw, Calendar, Users } from 'lucide';
|
import { createIcons, History, Plus, X, Save, Edit2, RotateCcw, Calendar, Users } from 'lucide';
|
||||||
import { CORP_LIST } from './SharedData';
|
import { CORP_LIST } from './SharedData';
|
||||||
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
|
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
|
||||||
import { API_BASE_URL } from '../../core/utils';
|
import { API_BASE_URL } from '../../core/utils';
|
||||||
import {
|
import {
|
||||||
generateOptionsHTML,
|
generateOptionsHTML,
|
||||||
setFieldValue,
|
setFieldValue,
|
||||||
getFieldValue,
|
getFieldValue,
|
||||||
applyDateMask
|
applyDateMask
|
||||||
} from './ModalUtils';
|
} from './ModalUtils';
|
||||||
|
|
||||||
@@ -22,18 +22,15 @@ class SwAssetModal extends BaseModal {
|
|||||||
<div id="sw-asset-modal" class="modal-overlay hidden">
|
<div id="sw-asset-modal" class="modal-overlay hidden">
|
||||||
<div class="modal-content wide">
|
<div class="modal-content wide">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div class="header-left">
|
<h2 id="sw-modal-title">${this.title}</h2>
|
||||||
<h2 id="sw-modal-title" class="modal-title">${this.title}</h2>
|
<button id="btn-close-sw-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
|
||||||
<div id="sw-header-identity" class="header-identity"></div>
|
|
||||||
</div>
|
|
||||||
<button id="btn-close-sw-modal" class="btn-icon" aria-label="닫기">×</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="modal-body-split">
|
<div class="modal-body-split">
|
||||||
<div class="modal-form-area">
|
<div class="modal-form-area">
|
||||||
<form id="sw-asset-form" class="grid-form">
|
<form id="sw-asset-form" class="grid-form">
|
||||||
<input type="hidden" id="sw-asset-id" name="id" />
|
<input type="hidden" id="sw-asset-id" name="id" />
|
||||||
|
|
||||||
<div class="form-section-title">기본 정보 (Identity)</div>
|
<div class="form-section-title">기본 정보 (Identity)</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>자산 유형</label>
|
<label>자산 유형</label>
|
||||||
@@ -84,7 +81,7 @@ class SwAssetModal extends BaseModal {
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group sw-standard-field">
|
<div class="form-group sw-standard-field">
|
||||||
<label>${ASSET_SCHEMA.PURCHASE_AMOUNT.ui}</label>
|
<label>${ASSET_SCHEMA.PURCHASE_AMOUNT.ui}</label>
|
||||||
<input type="text" id="sw-금액" name="purchase_amount" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" />
|
<input type="text" id="sw-금액" name="purchase_amount" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\\\B(?=(\\\\d{3})+(?!\\\\d))/g, ',')" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group cloud-only">
|
<div class="form-group cloud-only">
|
||||||
@@ -103,12 +100,12 @@ class SwAssetModal extends BaseModal {
|
|||||||
<div class="form-section-title">관리 및 비고</div>
|
<div class="form-section-title">관리 및 비고</div>
|
||||||
<div class="form-group sw-standard-field">
|
<div class="form-group sw-standard-field">
|
||||||
<label>${ASSET_SCHEMA.PURCHASE_DATE.ui}</label>
|
<label>${ASSET_SCHEMA.PURCHASE_DATE.ui}</label>
|
||||||
<div class="input-with-btn">
|
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
|
||||||
<input type="text" id="sw-구매일" name="purchase_date" />
|
<input type="text" id="sw-구매일" name="purchase_date" style="flex:1;" />
|
||||||
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-구매일-picker'); p.value = document.getElementById('sw-구매일').value; p.showPicker();">
|
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-구매일-picker'); p.value = document.getElementById('sw-구매일').value; p.showPicker();" style="padding:0.25rem;">
|
||||||
<i data-lucide="calendar"></i>
|
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
|
||||||
</button>
|
</button>
|
||||||
<input type="date" id="sw-구매일-picker" class="hidden-picker" onchange="document.getElementById('sw-구매일').value = this.value" tabindex="-1" />
|
<input type="date" id="sw-구매일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('sw-구매일').value = this.value" tabindex="-1" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group sw-standard-field">
|
<div class="form-group sw-standard-field">
|
||||||
@@ -129,12 +126,12 @@ class SwAssetModal extends BaseModal {
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group sw-standard-field" id="sw-expiry-group">
|
<div class="form-group sw-standard-field" id="sw-expiry-group">
|
||||||
<label>${ASSET_SCHEMA.EXPIRED_DATE.ui}</label>
|
<label>${ASSET_SCHEMA.EXPIRED_DATE.ui}</label>
|
||||||
<div class="input-with-btn">
|
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
|
||||||
<input type="text" id="sw-만료일" name="expiry_date" />
|
<input type="text" id="sw-만료일" name="expiry_date" style="flex:1;" />
|
||||||
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-만료일-picker'); p.value = document.getElementById('sw-만료일').value; p.showPicker();">
|
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-만료일-picker'); p.value = document.getElementById('sw-만료일').value; p.showPicker();" style="padding:0.25rem;">
|
||||||
<i data-lucide="calendar"></i>
|
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
|
||||||
</button>
|
</button>
|
||||||
<input type="date" id="sw-만료일-picker" class="hidden-picker" onchange="document.getElementById('sw-만료일').value = this.value" tabindex="-1" />
|
<input type="date" id="sw-만료일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('sw-만료일').value = this.value" tabindex="-1" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group full-width">
|
<div class="form-group full-width">
|
||||||
@@ -143,18 +140,18 @@ class SwAssetModal extends BaseModal {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div id="sw-user-section" class="user-management-section">
|
<div id="sw-user-section" class="user-management-section" style="margin-top: 2rem; border-top: 1px solid var(--border-color); padding-top: 1.5rem;">
|
||||||
<button type="button" id="btn-open-sw-user" class="btn btn-outline btn-sm" title="사용자 관리">
|
<button type="button" id="btn-open-sw-user" class="btn btn-outline btn-sm" title="사용자 관리">
|
||||||
<i data-lucide="users"></i> 사용자 관리
|
<i data-lucide="users" style="width:16px; height:16px; margin-right:4px;"></i> 사용자 관리
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-history-area">
|
<div class="modal-history-area">
|
||||||
<div class="history-header">
|
<div class="history-header" style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
<h3><i data-lucide="history"></i> 업데이트 내역</h3>
|
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> 업데이트 내역</h3>
|
||||||
<button type="button" id="btn-open-sw-update" class="btn btn-outline btn-sm">
|
<button type="button" id="btn-open-sw-update" class="btn btn-outline btn-sm">
|
||||||
계약 업데이트 <i data-lucide="rotate-ccw"></i>
|
계약 업데이트 <i data-lucide="refresh-ccw" style="width:14px; height:14px;"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="sw-history-list" class="history-timeline"></div>
|
<div id="sw-history-list" class="history-timeline"></div>
|
||||||
@@ -173,24 +170,24 @@ class SwAssetModal extends BaseModal {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 계약 업데이트 서브 모달 -->
|
<!-- 계약 업데이트 서브 모달 -->
|
||||||
<div id="sw-update-modal" class="modal-overlay hidden sub-modal">
|
<div id="sw-update-modal" class="modal-overlay hidden" style="z-index: 1100;">
|
||||||
<div class="modal-content narrow">
|
<div class="modal-content" style="max-width: 500px;">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 class="modal-title">계약 업데이트 반영</h2>
|
<h2>계약 업데이트 반영</h2>
|
||||||
<button id="btn-close-sw-update" class="btn-icon">×</button>
|
<button id="btn-close-sw-update" class="btn-icon"><i data-lucide="x"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="grid-form vertical-form">
|
<div class="grid-form" style="grid-template-columns: 1fr;">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>업데이트 일자</label>
|
<label>업데이트 일자</label>
|
||||||
<input type="date" id="sw-update-date" />
|
<input type="date" id="sw-update-date" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group sub-sw-update">
|
<div class="form-group sub-sw-update">
|
||||||
<label>새로운 계약 기간</label>
|
<label>새로운 계약 기간</label>
|
||||||
<div class="input-with-btn">
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
<input type="text" id="sw-update-start" placeholder="YYYY-MM-DD" />
|
<input type="text" id="sw-update-start" placeholder="YYYY-MM-DD" style="flex: 1;" />
|
||||||
<span>~</span>
|
<span>~</span>
|
||||||
<input type="text" id="sw-update-end" placeholder="YYYY-MM-DD" />
|
<input type="text" id="sw-update-end" placeholder="YYYY-MM-DD" style="flex: 1;" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -212,15 +209,6 @@ class SwAssetModal extends BaseModal {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<style>
|
|
||||||
.hidden-picker {
|
|
||||||
position: absolute;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,6 +231,7 @@ class SwAssetModal extends BaseModal {
|
|||||||
if (this.currentAsset) openSwUserModal(this.currentAsset);
|
if (this.currentAsset) openSwUserModal(this.currentAsset);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 업데이트 모달 로직
|
||||||
const subModal = document.getElementById('sw-update-modal')!;
|
const subModal = document.getElementById('sw-update-modal')!;
|
||||||
const closeUpdate = () => subModal.classList.add('hidden');
|
const closeUpdate = () => subModal.classList.add('hidden');
|
||||||
document.getElementById('btn-close-sw-update')?.addEventListener('click', closeUpdate);
|
document.getElementById('btn-close-sw-update')?.addEventListener('click', closeUpdate);
|
||||||
@@ -269,7 +258,7 @@ class SwAssetModal extends BaseModal {
|
|||||||
const log = { assetId: this.currentAsset.id, date, details: `[계약갱신] ${note} (${start} ~ ${end}, 비용: ${cost})`, user: '관리자' };
|
const log = { assetId: this.currentAsset.id, date, details: `[계약갱신] ${note} (${start} ~ ${end}, 비용: ${cost})`, user: '관리자' };
|
||||||
await fetch(`${API_BASE_URL}/api/asset/history/batch`, {
|
await fetch(`${API_BASE_URL}/api/asset/history/batch`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: 'application/json',
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify([...state.masterData.logs, log])
|
body: JSON.stringify([...state.masterData.logs, log])
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -289,7 +278,7 @@ class SwAssetModal extends BaseModal {
|
|||||||
const formData = new FormData(this.formEl!);
|
const formData = new FormData(this.formEl!);
|
||||||
const updated = { ...this.currentAsset };
|
const updated = { ...this.currentAsset };
|
||||||
formData.forEach((value, key) => { updated[key] = value; });
|
formData.forEach((value, key) => { updated[key] = value; });
|
||||||
|
|
||||||
let categoryKey = (type === '내부SW') ? 'swInternal' : (type === '클라우드' ? 'cloud' : 'swExternal');
|
let categoryKey = (type === '내부SW') ? 'swInternal' : (type === '클라우드' ? 'cloud' : 'swExternal');
|
||||||
if (await saveAsset(categoryKey, updated)) { onSave(); this.close(); closeModals(); }
|
if (await saveAsset(categoryKey, updated)) { onSave(); this.close(); closeModals(); }
|
||||||
});
|
});
|
||||||
@@ -333,32 +322,10 @@ class SwAssetModal extends BaseModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.renderHistory(asset.id);
|
this.renderHistory(asset.id);
|
||||||
this.updateHeaderIdentity(asset);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onAfterOpen(asset: any, mode: string): void {
|
protected onAfterOpen(asset: any, mode: string): void {
|
||||||
this.applySwTypeUI(asset.asset_type || asset.type);
|
this.applySwTypeUI(asset.asset_type || asset.type);
|
||||||
this.updateHeaderIdentity(asset);
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateHeaderIdentity(asset: any) {
|
|
||||||
const container = document.getElementById('sw-header-identity');
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
if (this.currentMode === 'add') {
|
|
||||||
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const type = getFieldValue('sw-asset-type') || asset.asset_type || asset.type || '';
|
|
||||||
const name = getFieldValue('sw-제품명') || asset.product_name || '';
|
|
||||||
const corp = getFieldValue('sw-법인') || asset.purchase_corp || '';
|
|
||||||
|
|
||||||
container.innerHTML = `
|
|
||||||
<span class="asset-code-title">${name}</span>
|
|
||||||
<span class="service-type-badge">${corp}</span>
|
|
||||||
<span class="asset-type-label">${type}</span>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private applySwTypeUI(type: string) {
|
private applySwTypeUI(type: string) {
|
||||||
@@ -389,10 +356,16 @@ class SwAssetModal extends BaseModal {
|
|||||||
if (!container) return;
|
if (!container) return;
|
||||||
const logs = (state.masterData.logs || []).filter(l => l.assetId === swId);
|
const logs = (state.masterData.logs || []).filter(l => l.assetId === swId);
|
||||||
if (logs.length === 0) { container.innerHTML = '<div class="empty-history">수정 이력이 없습니다.</div>'; return; }
|
if (logs.length === 0) { container.innerHTML = '<div class="empty-history">수정 이력이 없습니다.</div>'; return; }
|
||||||
container.innerHTML = logs.map(l => `<div class="history-item"><div class="history-date">${l.date}</div><div class="history-user">${l.user}</div><div class="history-details">${l.details}</div></div>`).join('');
|
container.innerHTML = logs.map(l => `<div class=\"history-item\"><div class=\"history-date\">${l.date}</div><div class=\"history-user\">${l.user}</div><div class=\"history-details\">${l.details}</div></div>`).join('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const swModal = new SwAssetModal();
|
export const swModal = new SwAssetModal();
|
||||||
export function initSwModal(onSave: () => void, closeModals: () => void) { swModal.init(onSave, closeModals); }
|
|
||||||
export function openSwModal(asset: any, mode: 'view' | 'add' | 'edit' = 'view') { swModal.open(asset, mode); }
|
export function initSwModal(onSave: () => void, closeModals: () => void) {
|
||||||
|
swModal.init(onSave, closeModals);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openSwModal(asset: any, mode: 'view' | 'add' | 'edit' = 'view') {
|
||||||
|
swModal.open(asset, mode);
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,15 +16,15 @@ class SwUserModal extends BaseModal {
|
|||||||
<div id="sw-user-asset-modal" class="modal-overlay hidden">
|
<div id="sw-user-asset-modal" class="modal-overlay hidden">
|
||||||
<div class="modal-content wide">
|
<div class="modal-content wide">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 id="sw-user-title" class="modal-title">${this.title}</h2>
|
<h2 id="sw-user-title">${this.title}</h2>
|
||||||
<button id="btn-close-sw-user-modal" class="btn-icon" aria-label="닫기">×</button>
|
<button id="btn-close-sw-user-modal" class="btn-icon"><i data-lucide="x"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="sw-info-summary" id="sw-user-sw-info"></div>
|
<div class="sw-info-summary" id="sw-user-sw-info"></div>
|
||||||
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="user-list-toolbar" style="display:flex; justify-content:space-between; margin-bottom:1rem; align-items:center;">
|
||||||
<h3 class="detail-section-title mb-0">할당된 사용자 목록</h3>
|
<h3 style="font-size:1rem; font-weight:600;">할당된 사용자 목록</h3>
|
||||||
<button type="button" id="btn-open-add-user" class="btn btn-primary btn-sm"><i data-lucide="plus" class="icon-sm"></i> 사용자 추가</button>
|
<button type="button" id="btn-open-add-user" class="btn btn-primary btn-sm"><i data-lucide="plus"></i> 사용자 추가</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
@@ -35,9 +35,9 @@ class SwUserModal extends BaseModal {
|
|||||||
<th>부서</th>
|
<th>부서</th>
|
||||||
<th>직위</th>
|
<th>직위</th>
|
||||||
<th>이름</th>
|
<th>이름</th>
|
||||||
<th class="text-center">사용기간</th>
|
<th>사용기간</th>
|
||||||
<th class="text-center">신청서</th>
|
<th>신청서</th>
|
||||||
<th class="text-center">관리</th>
|
<th>관리</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="sw-user-table-body"></tbody>
|
<tbody id="sw-user-table-body"></tbody>
|
||||||
@@ -54,14 +54,14 @@ class SwUserModal extends BaseModal {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 사용자 추가/수정 서브 모달 -->
|
<!-- 사용자 추가/수정 서브 모달 -->
|
||||||
<div id="sw-user-edit-modal" class="modal-overlay hidden sub-modal">
|
<div id="sw-user-edit-modal" class="modal-overlay hidden" style="z-index: 1100;">
|
||||||
<div class="modal-content narrow">
|
<div class="modal-content" style="width: 400px;">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3 id="sw-user-edit-title" class="modal-title">사용자 정보</h3>
|
<h3 id="sw-user-edit-title">사용자 정보</h3>
|
||||||
<button id="btn-close-user-edit" class="btn-icon">×</button>
|
<button id="btn-close-user-edit" class="btn-icon"><i data-lucide="x"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form id="sw-user-edit-form" class="grid-form vertical-form">
|
<form id="sw-user-edit-form" class="grid-form" style="grid-template-columns: 1fr;">
|
||||||
<input type="hidden" id="edit-user-index" value="-1" />
|
<input type="hidden" id="edit-user-index" value="-1" />
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>조직</label>
|
<label>조직</label>
|
||||||
@@ -81,22 +81,22 @@ class SwUserModal extends BaseModal {
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>사용 시작일</label>
|
<label>사용 시작일</label>
|
||||||
<div class="input-with-btn">
|
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
|
||||||
<input type="text" id="new-user-시작일" />
|
<input type="text" id="new-user-시작일" style="flex:1;" />
|
||||||
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-시작일-picker'); p.value = document.getElementById('new-user-시작일').value; p.showPicker();">
|
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-시작일-picker'); p.value = document.getElementById('new-user-시작일').value; p.showPicker();" style="padding:0.25rem;">
|
||||||
<i data-lucide="calendar" class="icon-sm"></i>
|
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
|
||||||
</button>
|
</button>
|
||||||
<input type="date" id="new-user-시작일-picker" class="hidden-picker" onchange="document.getElementById('new-user-시작일').value = this.value" tabindex="-1" />
|
<input type="date" id="new-user-시작일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('new-user-시작일').value = this.value" tabindex="-1" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>사용 종료일</label>
|
<label>사용 종료일</label>
|
||||||
<div class="input-with-btn">
|
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
|
||||||
<input type="text" id="new-user-종료일" />
|
<input type="text" id="new-user-종료일" style="flex:1;" />
|
||||||
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-종료일-picker'); p.value = document.getElementById('new-user-종료일').value; p.showPicker();">
|
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-종료일-picker'); p.value = document.getElementById('new-user-종료일').value; p.showPicker();" style="padding:0.25rem;">
|
||||||
<i data-lucide="calendar" class="icon-sm"></i>
|
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
|
||||||
</button>
|
</button>
|
||||||
<input type="date" id="new-user-종료일-picker" class="hidden-picker" onchange="document.getElementById('new-user-종료일').value = this.value" tabindex="-1" />
|
<input type="date" id="new-user-종료일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('new-user-종료일').value = this.value" tabindex="-1" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -111,15 +111,6 @@ class SwUserModal extends BaseModal {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<style>
|
|
||||||
.hidden-picker {
|
|
||||||
position: absolute;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,9 +140,10 @@ class SwUserModal extends BaseModal {
|
|||||||
onSave(); this.close(); closeModals();
|
onSave(); this.close(); closeModals();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 닫기 이벤트들 (BaseModal의 공통 버튼 외 추가분)
|
||||||
document.getElementById('btn-close-sw-user-modal')?.addEventListener('click', () => this.close());
|
document.getElementById('btn-close-sw-user-modal')?.addEventListener('click', () => this.close());
|
||||||
document.getElementById('btn-cancel-sw-user')?.addEventListener('click', () => this.close());
|
document.getElementById('btn-cancel-sw-user')?.addEventListener('click', () => this.close());
|
||||||
|
|
||||||
const subModal = document.getElementById('sw-user-edit-modal')!;
|
const subModal = document.getElementById('sw-user-edit-modal')!;
|
||||||
const closeSub = () => subModal.classList.add('hidden');
|
const closeSub = () => subModal.classList.add('hidden');
|
||||||
document.getElementById('btn-close-user-edit')?.addEventListener('click', closeSub);
|
document.getElementById('btn-close-user-edit')?.addEventListener('click', closeSub);
|
||||||
@@ -163,9 +155,9 @@ class SwUserModal extends BaseModal {
|
|||||||
protected fillFormData(asset: any): void {
|
protected fillFormData(asset: any): void {
|
||||||
const swInfo = document.getElementById('sw-user-sw-info')!;
|
const swInfo = document.getElementById('sw-user-sw-info')!;
|
||||||
swInfo.innerHTML = `
|
swInfo.innerHTML = `
|
||||||
<div class="sw-info-header border-b border-hairline pb-4 mb-6">
|
<div style="background:var(--bg-light); padding:1rem; border-radius:6px; margin-bottom:1.5rem;">
|
||||||
<div class="detail-label-sm">${asset.purchase_corp || asset.법인 || ''}</div>
|
<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:0.25rem;">${asset.purchase_corp || asset.법인 || ''}</div>
|
||||||
<div class="asset-code-title">${asset.product_name || asset.제품명 || ''}</div>
|
<div style="font-size:1.1rem; font-weight:700; color:var(--primary-color);">${asset.product_name || asset.제품명 || ''}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -173,7 +165,7 @@ class SwUserModal extends BaseModal {
|
|||||||
this.tempSwUsers = existingMapping ? (existingMapping.userData || []).map((u: any) => ({
|
this.tempSwUsers = existingMapping ? (existingMapping.userData || []).map((u: any) => ({
|
||||||
조직: u[0], 부서: u[1], 직위: u[2], 이름: u[3], 사용기간: u[4], 신청서명: u[5]
|
조직: u[0], 부서: u[1], 직위: u[2], 이름: u[3], 사용기간: u[4], 신청서명: u[5]
|
||||||
})) : [];
|
})) : [];
|
||||||
|
|
||||||
this.renderUserList();
|
this.renderUserList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,10 +173,9 @@ class SwUserModal extends BaseModal {
|
|||||||
|
|
||||||
private renderUserList() {
|
private renderUserList() {
|
||||||
const tbody = document.getElementById('sw-user-table-body')!;
|
const tbody = document.getElementById('sw-user-table-body')!;
|
||||||
if (!tbody) return;
|
|
||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
if (this.tempSwUsers.length === 0) {
|
if (this.tempSwUsers.length === 0) {
|
||||||
tbody.innerHTML = '<tr><td colspan="7" class="empty-cell text-center p-8">할당된 사용자가 없습니다.</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center; padding:2rem; color:var(--text-muted);">할당된 사용자가 없습니다.</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,12 +186,12 @@ class SwUserModal extends BaseModal {
|
|||||||
<td>${user.부서 || ''}</td>
|
<td>${user.부서 || ''}</td>
|
||||||
<td>${user.직위 || ''}</td>
|
<td>${user.직위 || ''}</td>
|
||||||
<td>${user.이름 || ''}</td>
|
<td>${user.이름 || ''}</td>
|
||||||
<td class="text-center">${user.사용기간 || ''}</td>
|
<td>${user.사용기간 || ''}</td>
|
||||||
<td class="text-center">${user.신청서명 ? '<i data-lucide="paperclip" class="text-primary icon-sm"></i>' : '-'}</td>
|
<td style="text-align:center;">${user.신청서명 ? '<i data-lucide="paperclip" class="text-primary"></i>' : '-'}</td>
|
||||||
<td class="text-center">
|
<td>
|
||||||
<div class="flex gap-2 justify-center items-center">
|
<div style="display:flex; gap:0.5rem;">
|
||||||
<button class="btn btn-outline btn-sm btn-edit-user" data-idx="${idx}">수정</button>
|
<button class="btn btn-outline btn-sm btn-edit-user" data-idx="${idx}">수정</button>
|
||||||
<button class="btn-circle-remove btn-del-user" data-idx="${idx}">×</button>
|
<button class="btn btn-outline btn-sm btn-danger btn-del-user" data-idx="${idx}">삭제</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
`;
|
`;
|
||||||
@@ -266,5 +257,11 @@ class SwUserModal extends BaseModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const swUserModal = new SwUserModal();
|
export const swUserModal = new SwUserModal();
|
||||||
export function initSwUserModal(onSave: () => void, closeModals: () => void) { swUserModal.init(onSave, closeModals); }
|
|
||||||
export function openSwUserModal(asset: any) { swUserModal.open(asset); }
|
export function initSwUserModal(onSave: () => void, closeModals: () => void) {
|
||||||
|
swUserModal.init(onSave, closeModals);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openSwUserModal(asset: any) {
|
||||||
|
swUserModal.open(asset);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,55 +10,55 @@ class UserModal extends BaseModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected renderFrameHTML(): string {
|
protected renderFrameHTML(): string {
|
||||||
|
const sharedStyle = 'height: 38px !important; box-sizing: border-box !important; font-size: 13px; margin: 0;';
|
||||||
|
const inputStyle = sharedStyle;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div id="user-asset-modal" class="modal-overlay hidden">
|
<div id="user-asset-modal" class="modal-overlay hidden">
|
||||||
<div class="modal-content narrow">
|
<div class="modal-content" style="max-width: 500px; width: 100%;">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div class="header-left">
|
<h2 id="user-modal-title" style="margin: 0; font-size: 18px; font-weight: 800; color: white;">\${this.title}</h2>
|
||||||
<h2 id="user-modal-title" class="modal-title">${this.title}</h2>
|
<button id="btn-close-user-modal" class="btn-icon" aria-label="닫기" style="font-size: 28px; color: white; background: none; border: none; cursor: pointer; line-height: 1;">×</button>
|
||||||
<div id="user-header-identity" class="header-identity"></div>
|
|
||||||
</div>
|
|
||||||
<button id="btn-close-user-modal" class="btn-icon" aria-label="닫기">×</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body" style="padding: 24px; overflow-y: auto;">
|
||||||
<form id="user-asset-form" class="grid-form vertical-form">
|
<form id="user-asset-form" class="grid-form" style="display: flex; flex-direction: column; gap: 16px;">
|
||||||
<input type="hidden" id="user-id" name="id" />
|
<input type="hidden" id="user-id" name="id" />
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
||||||
<label>사번</label>
|
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">사번</label>
|
||||||
<input type="text" id="user-emp-no" name="emp_no" placeholder="예: HM202601" required />
|
<input type="text" id="user-emp-no" name="emp_no" placeholder="예: HM202601" required style="\${inputStyle} width: 100%;" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
||||||
<label>사용자명</label>
|
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">사용자명</label>
|
||||||
<input type="text" id="user-name-input" name="user_name" placeholder="예: 홍길동" required />
|
<input type="text" id="user-name-input" name="user_name" placeholder="예: 홍길동" required style="\${inputStyle} width: 100%;" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
||||||
<label>사용조직 (부서)</label>
|
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">사용조직 (부서)</label>
|
||||||
<input type="text" id="user-dept" name="dept_name" placeholder="예: 기술개발센터" required />
|
<input type="text" id="user-dept" name="dept_name" placeholder="예: 기술개발센터" required style="\${inputStyle} width: 100%;" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
||||||
<label>직무 (직급)</label>
|
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">직무 (직급)</label>
|
||||||
<input type="text" id="user-position-input" name="position" placeholder="예: BIM모델러" required />
|
<input type="text" id="user-position-input" name="position" placeholder="예: BIM모델러" required style="\${inputStyle} width: 100%;" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
||||||
<label>상태</label>
|
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">상태</label>
|
||||||
<select id="user-status" name="status">
|
<select id="user-status" name="status" style="\${sharedStyle}">
|
||||||
<option value="재직">재직</option>
|
<option value="재직">재직</option>
|
||||||
<option value="퇴직">퇴직</option>
|
<option value="퇴직">퇴직</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer" style="display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; background: #f8fafc; border-top: 1px solid var(--border-color);">
|
||||||
<button id="btn-delete-user-asset" class="btn btn-outline btn-danger">삭제</button>
|
<button id="btn-delete-user-asset" class="btn btn-outline btn-danger" style="height: 42px;">삭제</button>
|
||||||
<div class="footer-actions">
|
<div class="footer-actions" style="display: flex; gap: 8px;">
|
||||||
<button id="btn-revert-user-edit" class="btn btn-outline hidden">수정 취소</button>
|
<button id="btn-revert-user-edit" class="btn btn-outline hidden" style="height: 42px;">수정 취소</button>
|
||||||
<button id="btn-cancel-user-modal" class="btn btn-outline">닫기</button>
|
<button id="btn-cancel-user-modal" class="btn btn-outline" style="height: 42px;">닫기</button>
|
||||||
<button id="btn-save-user-asset" class="btn btn-primary">수정</button>
|
<button id="btn-save-user-asset" class="btn btn-primary" style="height: 42px;">수정</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,8 +119,6 @@ class UserModal extends BaseModal {
|
|||||||
onSave(); this.close(); closeModals();
|
onSave(); this.close(); closeModals();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
createIcons({ icons: { Save, X } });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fillFormData(asset: any): void {
|
protected fillFormData(asset: any): void {
|
||||||
@@ -130,18 +128,22 @@ class UserModal extends BaseModal {
|
|||||||
setFieldValue('user-dept', asset.dept_name || '');
|
setFieldValue('user-dept', asset.dept_name || '');
|
||||||
setFieldValue('user-position-input', asset.position || '');
|
setFieldValue('user-position-input', asset.position || '');
|
||||||
setFieldValue('user-status', asset.status || '재직');
|
setFieldValue('user-status', asset.status || '재직');
|
||||||
this.updateHeaderIdentity(asset);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onAfterOpen(asset: any, mode: string): void {
|
protected onAfterOpen(asset: any, mode: string): void {
|
||||||
const titleEl = document.getElementById('user-modal-title');
|
const titleEl = document.getElementById('user-modal-title');
|
||||||
|
|
||||||
if (titleEl) {
|
if (titleEl) {
|
||||||
titleEl.textContent = (mode === 'add') ? '신규 임직원 등록' : '임직원 정보 수정';
|
if (mode === 'add') {
|
||||||
|
titleEl.textContent = '신규 임직원 등록';
|
||||||
|
} else {
|
||||||
|
titleEl.textContent = '임직원 정보 수정';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteBtn = document.getElementById('btn-delete-user-asset')!;
|
const deleteBtn = document.getElementById('btn-delete-user-asset')!;
|
||||||
const saveBtn = document.getElementById('btn-save-user-asset')!;
|
const saveBtn = document.getElementById('btn-save-user-asset')!;
|
||||||
|
|
||||||
deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
|
deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
|
||||||
|
|
||||||
if (mode === 'add') {
|
if (mode === 'add') {
|
||||||
@@ -155,30 +157,15 @@ class UserModal extends BaseModal {
|
|||||||
saveBtn.textContent = '수정';
|
saveBtn.textContent = '수정';
|
||||||
saveBtn.style.display = 'block';
|
saveBtn.style.display = 'block';
|
||||||
}
|
}
|
||||||
this.updateHeaderIdentity(asset);
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateHeaderIdentity(asset: any) {
|
|
||||||
const container = document.getElementById('user-header-identity');
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
if (this.currentMode === 'add') {
|
|
||||||
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const empNo = asset.emp_no || '';
|
|
||||||
const userName = asset.user_name || '';
|
|
||||||
const dept = asset.dept_name || '';
|
|
||||||
|
|
||||||
container.innerHTML = `
|
|
||||||
<span class="asset-code-title">${userName}</span>
|
|
||||||
<span class="service-type-badge">${empNo}</span>
|
|
||||||
<span class="asset-type-label">${dept}</span>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const userModal = new UserModal();
|
export const userModal = new UserModal();
|
||||||
export function initUserModal(onSave: () => void, closeModals: () => void) { userModal.init(onSave, closeModals); }
|
|
||||||
export function openUserModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { userModal.open(asset, mode); }
|
export function initUserModal(onSave: () => void, closeModals: () => void) {
|
||||||
|
userModal.init(onSave, closeModals);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openUserModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
|
||||||
|
userModal.open(asset, mode);
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,96 +24,93 @@ const MENU_CONFIG: any = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function renderNavigation(onTabChange: (tab: string) => void) {
|
export function renderNavigation(onTabChange: (tab: string) => void) {
|
||||||
const header = document.querySelector('.main-header') as HTMLElement;
|
const navContainer = document.getElementById('main-nav')!;
|
||||||
const headerContainer = document.querySelector('.header-container')!;
|
|
||||||
if (!headerContainer) return;
|
|
||||||
|
|
||||||
const render = () => {
|
const render = () => {
|
||||||
// 1. 헤더 구조 (Vercel Style: Clean Single Row)
|
navContainer.innerHTML = '';
|
||||||
headerContainer.innerHTML = `
|
|
||||||
<div class="brand" id="btn-home-logo" style="cursor: pointer;">
|
|
||||||
<img src="img/image_92.png" class="main-logo" alt="HM Logo" />
|
|
||||||
<h1>한맥자산관리시스템</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="integrated-nav" id="main-nav-list"></nav>
|
|
||||||
|
|
||||||
<div class="header-actions">
|
|
||||||
<div class="role-toggle-wrapper">
|
|
||||||
<span class="role-label user ${state.currentUserRole === 'user' ? 'active' : ''}">실무자</span>
|
|
||||||
<label class="role-toggle">
|
|
||||||
<input type="checkbox" id="role-toggle-checkbox" ${state.currentUserRole === 'admin' ? 'checked' : ''}>
|
|
||||||
<span class="role-slider"></span>
|
|
||||||
</label>
|
|
||||||
<span class="role-label admin ${state.currentUserRole === 'admin' ? 'active' : ''}">관리자</span>
|
|
||||||
</div>
|
|
||||||
<div class="notification-area">
|
|
||||||
<button class="icon-btn" title="알림"><i data-lucide="bell" style="width:18px; height:18px;"></i></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const navList = document.getElementById('main-nav-list')!;
|
|
||||||
|
|
||||||
// 2. GNB 메뉴 렌더링 (Ghost Tab Style)
|
// 기존 메뉴 렌더링
|
||||||
Object.keys(MENU_CONFIG).forEach(catKey => {
|
(Object.keys(MENU_CONFIG) as Array<keyof typeof MENU_CONFIG>).forEach(catKey => {
|
||||||
const config = MENU_CONFIG[catKey];
|
const config = MENU_CONFIG[catKey];
|
||||||
|
|
||||||
|
// 역할에 따라 노출할 서브탭 필터링
|
||||||
const visibleTabs = config.tabs.filter((tab: string) => {
|
const visibleTabs = config.tabs.filter((tab: string) => {
|
||||||
if (state.currentUserRole === 'admin') return tab === '대시보드';
|
if (state.currentUserRole === 'admin') {
|
||||||
return tab !== '대시보드';
|
// 관리자(admin)일 경우 대시보드 탭만 노출
|
||||||
|
return tab === '대시보드';
|
||||||
|
} else {
|
||||||
|
// 실무자(user)일 경우 대시보드 제외한 모든 탭 노출
|
||||||
|
return tab !== '대시보드';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (visibleTabs.length === 0) return;
|
// 노출할 서브탭이 없으면 해당 대분류 GNB 메뉴도 렌더링하지 않음
|
||||||
|
if (visibleTabs.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActive = state.activeCategory === catKey;
|
||||||
|
|
||||||
|
const group = document.createElement('div');
|
||||||
|
group.className = `nav-group ${isActive ? 'active is-showing-shelf' : ''}`;
|
||||||
|
|
||||||
|
const trigger = document.createElement('div');
|
||||||
|
trigger.className = 'gnb-trigger';
|
||||||
|
trigger.textContent = config.label;
|
||||||
|
|
||||||
|
trigger.addEventListener('click', () => {
|
||||||
|
if (state.activeCategory !== catKey) {
|
||||||
|
state.activeCategory = catKey as any;
|
||||||
|
const firstTab = visibleTabs[0] || config.tabs[0];
|
||||||
|
state.activeSubTab = firstTab;
|
||||||
|
render();
|
||||||
|
onTabChange(firstTab);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
group.appendChild(trigger);
|
||||||
|
|
||||||
|
const shelf = document.createElement('div');
|
||||||
|
shelf.className = 'lnb-shelf';
|
||||||
|
|
||||||
visibleTabs.forEach((tab: string) => {
|
visibleTabs.forEach((tab: string) => {
|
||||||
if (tab === '부품 마스터') return;
|
if (tab === '부품 마스터') return; // 메뉴바에서 표시 생략
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
const isActive = state.activeSubTab === tab;
|
item.className = `lnb-item ${isActive && state.activeSubTab === tab ? 'active' : ''}`;
|
||||||
item.className = `gnb-trigger ${isActive ? 'active' : ''}`;
|
|
||||||
item.textContent = tab;
|
item.textContent = tab;
|
||||||
item.style.fontSize = 'var(--fs-sm)'; // Ensure small but standard font
|
|
||||||
|
|
||||||
item.addEventListener('click', (e) => {
|
item.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
state.activeCategory = catKey as any;
|
state.activeCategory = catKey as any;
|
||||||
state.activeSubTab = tab;
|
state.activeSubTab = tab;
|
||||||
render();
|
render();
|
||||||
onTabChange(tab);
|
onTabChange(tab);
|
||||||
});
|
});
|
||||||
navList.appendChild(item);
|
shelf.appendChild(item);
|
||||||
});
|
});
|
||||||
|
group.appendChild(shelf);
|
||||||
|
navContainer.appendChild(group);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. 관리자 전용 '관리도구'
|
// ─── '관리자' 메뉴 별도 추가 (GNB 스타일 - 관리자 역할일 때만 노출) ───
|
||||||
if (state.currentUserRole === 'admin') {
|
if (state.currentUserRole === 'admin') {
|
||||||
|
const adminGroup = document.createElement('div');
|
||||||
|
adminGroup.className = 'nav-group';
|
||||||
|
|
||||||
const adminTrigger = document.createElement('div');
|
const adminTrigger = document.createElement('div');
|
||||||
adminTrigger.className = 'gnb-trigger admin-trigger';
|
adminTrigger.className = 'gnb-trigger';
|
||||||
adminTrigger.innerHTML = '관리도구';
|
adminTrigger.innerHTML = '관리자';
|
||||||
adminTrigger.addEventListener('click', () => window.open('/map_editor.html', '_blank'));
|
adminTrigger.style.color = 'var(--text-muted)';
|
||||||
navList.appendChild(adminTrigger);
|
adminTrigger.style.borderLeft = '1px solid var(--border-color)';
|
||||||
|
adminTrigger.style.marginLeft = '1rem';
|
||||||
|
adminTrigger.style.paddingLeft = '1.5rem';
|
||||||
|
|
||||||
|
adminTrigger.addEventListener('click', () => {
|
||||||
|
window.open('/map_editor.html', '_blank');
|
||||||
|
});
|
||||||
|
|
||||||
|
adminGroup.appendChild(adminTrigger);
|
||||||
|
navContainer.appendChild(adminGroup);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 이벤트 바인딩
|
|
||||||
document.getElementById('btn-home-logo')?.addEventListener('click', () => location.reload());
|
|
||||||
|
|
||||||
const roleToggle = document.getElementById('role-toggle-checkbox') as HTMLInputElement;
|
|
||||||
roleToggle?.addEventListener('change', () => {
|
|
||||||
state.currentUserRole = roleToggle.checked ? 'admin' : 'user';
|
|
||||||
if (state.currentUserRole === 'admin') {
|
|
||||||
state.activeCategory = 'hw';
|
|
||||||
state.activeSubTab = '대시보드';
|
|
||||||
} else {
|
|
||||||
state.activeCategory = 'hw';
|
|
||||||
state.activeSubTab = '서버';
|
|
||||||
}
|
|
||||||
render();
|
|
||||||
onTabChange(state.activeSubTab);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 아이콘 생성
|
|
||||||
// @ts-ignore
|
|
||||||
if (window.lucide) window.lucide.createIcons();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
render();
|
render();
|
||||||
|
|||||||
@@ -1,12 +1,63 @@
|
|||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
import { ASSET_SCHEMA } from './schema';
|
import { ASSET_SCHEMA } from './schema';
|
||||||
import { HardwareAsset, SoftwareAsset, SWUser, HardwareLog, MasterAssetData } from './types';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ITAM 엑셀 핸들러 (Database Synchronized Edition)
|
* ITAM 엑셀 핸들러 (Database Synchronized Edition)
|
||||||
* 데이터베이스 실제 스키마 컬럼과 엑셀 헤더를 1:1로 일치시킵니다.
|
* 데이터베이스 실제 스키마 컬럼과 엑셀 헤더를 1:1로 일치시킵니다.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export interface HardwareAsset {
|
||||||
|
[key: string]: any;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SoftwareAsset {
|
||||||
|
[key: string]: any;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SWUser {
|
||||||
|
id: string;
|
||||||
|
sw_id: string;
|
||||||
|
user_name: string;
|
||||||
|
dept: string;
|
||||||
|
corp: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HardwareLog {
|
||||||
|
id: string;
|
||||||
|
assetId?: string;
|
||||||
|
asset_id?: string;
|
||||||
|
date?: string;
|
||||||
|
log_date?: string;
|
||||||
|
created_at?: string;
|
||||||
|
details: string;
|
||||||
|
user?: string;
|
||||||
|
log_user?: string;
|
||||||
|
event_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MasterAssetData {
|
||||||
|
pc: HardwareAsset[];
|
||||||
|
server: HardwareAsset[];
|
||||||
|
storage: HardwareAsset[];
|
||||||
|
network: HardwareAsset[];
|
||||||
|
equipment: HardwareAsset[];
|
||||||
|
survey: HardwareAsset[];
|
||||||
|
pcParts: HardwareAsset[];
|
||||||
|
swInternal: SoftwareAsset[];
|
||||||
|
swExternal: SoftwareAsset[];
|
||||||
|
cloud: SoftwareAsset[];
|
||||||
|
domain: any[];
|
||||||
|
vip: HardwareAsset[];
|
||||||
|
officeSupplies: HardwareAsset[];
|
||||||
|
cost: any[];
|
||||||
|
swUsers: SWUser[];
|
||||||
|
logs: HardwareLog[];
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DB 컬럼 순서 및 구성 정의 (실제 DB 스키마 dump 기준)
|
* DB 컬럼 순서 및 구성 정의 (실제 DB 스키마 dump 기준)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ASSET_SCHEMA, UI_TEXT } from './schema';
|
import { ASSET_SCHEMA, UI_TEXT } from './schema';
|
||||||
|
import { getActionButtonsHTML } from './utils';
|
||||||
import { generateOptionsHTML } from '../components/Modal/ModalUtils';
|
import { generateOptionsHTML } from '../components/Modal/ModalUtils';
|
||||||
import { CORP_LIST } from '../components/Modal/SharedData';
|
import { CORP_LIST } from '../components/Modal/SharedData';
|
||||||
|
|
||||||
@@ -20,13 +21,6 @@ export interface FilterOptions {
|
|||||||
initialFilters?: any;
|
initialFilters?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 전역 액션 버튼 그룹 생성 (자산 추가 등)
|
|
||||||
*/
|
|
||||||
export function getActionButtonsHTML(): string {
|
|
||||||
return `<div id="filter-bar-actions" class="header-action-group"></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderFilterBar(container: HTMLElement, options: FilterOptions) {
|
export function renderFilterBar(container: HTMLElement, options: FilterOptions) {
|
||||||
const {
|
const {
|
||||||
keywordLabel = '통합 검색',
|
keywordLabel = '통합 검색',
|
||||||
@@ -41,8 +35,6 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions)
|
|||||||
initialFilters = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '', status: '' }
|
initialFilters = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '', status: '' }
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
container.classList.add('search-bar'); // Restored class
|
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="search-item flex-1">
|
<div class="search-item flex-1">
|
||||||
<label>${keywordLabel}</label>
|
<label>${keywordLabel}</label>
|
||||||
@@ -90,7 +82,7 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions)
|
|||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
${extraHTML}
|
${extraHTML}
|
||||||
<button id="btn-reset-filters" class="btn btn-outline btn-reset">
|
<button id="btn-reset-filters" class="btn btn-outline btn-reset">
|
||||||
<i data-lucide="refresh-ccw" class="icon-sm"></i> ${UI_TEXT.ACTION.RESET_FILTER}
|
<i data-lucide="refresh-ccw"></i> ${UI_TEXT.ACTION.RESET_FILTER}
|
||||||
</button>
|
</button>
|
||||||
${getActionButtonsHTML()}
|
${getActionButtonsHTML()}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1,8 +1,41 @@
|
|||||||
import { HardwareAsset, SoftwareAsset, SWUser, HardwareLog, MasterAssetData, SystemUser } from './types';
|
import { HardwareAsset, SoftwareAsset, SWUser, HardwareLog } from './excelHandler';
|
||||||
import { API_BASE_URL } from './utils';
|
import { API_BASE_URL } from './utils';
|
||||||
import { dummyPCs, dummyServers, dummyStorages, dummyEquips, dummySubSw, dummyPermSw, dummyCloud, dummyDomain, dummySwUsers, dummyLogs } from './dummyData';
|
import { dummyPCs, dummyServers, dummyStorages, dummyEquips, dummySubSw, dummyPermSw, dummyCloud, dummyDomain, dummySwUsers, dummyLogs } from './dummyData';
|
||||||
|
|
||||||
// --- State Definitions ---
|
// --- State Definitions ---
|
||||||
|
export interface MasterAssetData {
|
||||||
|
users: any[];
|
||||||
|
pc: any[];
|
||||||
|
server: any[];
|
||||||
|
storage: any[];
|
||||||
|
network: any[];
|
||||||
|
survey: any[];
|
||||||
|
pcParts: any[];
|
||||||
|
partsMaster: any[];
|
||||||
|
equipment: any[];
|
||||||
|
officeSupplies: any[];
|
||||||
|
swInternal: any[];
|
||||||
|
swExternal: any[];
|
||||||
|
cloud: any[];
|
||||||
|
domain: any[];
|
||||||
|
cost: any[];
|
||||||
|
vip: any[];
|
||||||
|
mobile?: any[]; // Legacy mobile support
|
||||||
|
equip?: any[]; // Backward compat
|
||||||
|
jobSpecs?: any[];
|
||||||
|
|
||||||
|
// Backward compatibility
|
||||||
|
subSw: any[];
|
||||||
|
permSw: any[];
|
||||||
|
|
||||||
|
swUsers: SWUser[];
|
||||||
|
logs: HardwareLog[];
|
||||||
|
|
||||||
|
// 통합 배열
|
||||||
|
hw: any[];
|
||||||
|
sw: any[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppState {
|
export interface AppState {
|
||||||
activeCategory: 'dashboard' | 'hw' | 'sw' | 'ops' | 'vip' | 'fac' | 'users' | 'etc';
|
activeCategory: 'dashboard' | 'hw' | 'sw' | 'ops' | 'vip' | 'fac' | 'users' | 'etc';
|
||||||
activeSubTab: string;
|
activeSubTab: string;
|
||||||
@@ -27,11 +60,10 @@ export const state: AppState = {
|
|||||||
survey: [], pcParts: [], partsMaster: [], equipment: [], officeSupplies: [],
|
survey: [], pcParts: [], partsMaster: [], equipment: [], officeSupplies: [],
|
||||||
swInternal: [], swExternal: [], cloud: [], domain: [],
|
swInternal: [], swExternal: [], cloud: [], domain: [],
|
||||||
cost: [], vip: [],
|
cost: [], vip: [],
|
||||||
|
subSw: [], permSw: [],
|
||||||
hw: [], sw: [],
|
hw: [], sw: [],
|
||||||
swUsers: [], logs: [],
|
swUsers: [], logs: [],
|
||||||
jobSpecs: [],
|
jobSpecs: []
|
||||||
subSw: [],
|
|
||||||
permSw: []
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -234,3 +266,4 @@ export async function deleteJobSpec(id: number) {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,153 +0,0 @@
|
|||||||
/**
|
|
||||||
* ITAM Global Type Definitions
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface BaseAsset {
|
|
||||||
id: string;
|
|
||||||
asset_code?: string;
|
|
||||||
category?: string;
|
|
||||||
asset_type?: string;
|
|
||||||
purchase_corp?: string;
|
|
||||||
purchase_date?: string;
|
|
||||||
purchase_amount?: number | string;
|
|
||||||
purchase_vendor?: string;
|
|
||||||
approval_document?: string;
|
|
||||||
service_type?: string;
|
|
||||||
manager_primary?: string;
|
|
||||||
manager_secondary?: string;
|
|
||||||
location?: string;
|
|
||||||
location_detail?: string;
|
|
||||||
location_photo?: string;
|
|
||||||
loc_x?: number;
|
|
||||||
loc_y?: number;
|
|
||||||
memo?: string;
|
|
||||||
updated_at?: string;
|
|
||||||
created_at?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HardwareAsset extends BaseAsset {
|
|
||||||
hw_status?: string;
|
|
||||||
model_name?: string;
|
|
||||||
asset_name?: string;
|
|
||||||
asset_mfr?: string;
|
|
||||||
current_dept?: string;
|
|
||||||
previous_dept?: string;
|
|
||||||
user_current?: string;
|
|
||||||
emp_no?: string;
|
|
||||||
user_position?: string;
|
|
||||||
previous_user?: string;
|
|
||||||
cpu?: string;
|
|
||||||
ram?: string;
|
|
||||||
gpu?: string;
|
|
||||||
ssd_1?: string;
|
|
||||||
ssd_2?: string;
|
|
||||||
hdd_1?: string;
|
|
||||||
hdd_2?: string;
|
|
||||||
hdd_3?: string;
|
|
||||||
hdd_4?: string;
|
|
||||||
mainboard?: string;
|
|
||||||
os?: string;
|
|
||||||
ip_address?: string;
|
|
||||||
ip_address_2?: string;
|
|
||||||
mac_address?: string;
|
|
||||||
remote_tool?: string;
|
|
||||||
remote_id?: string;
|
|
||||||
remote_pw?: string;
|
|
||||||
monitoring?: string;
|
|
||||||
volume?: string;
|
|
||||||
monitor_inch?: string;
|
|
||||||
asset_count?: number | string;
|
|
||||||
serial_num?: string;
|
|
||||||
// Normalized V3 fields
|
|
||||||
volumes?: any[];
|
|
||||||
remotes?: any[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SoftwareAsset extends BaseAsset {
|
|
||||||
sw_status?: string;
|
|
||||||
sw_field?: string;
|
|
||||||
sw_type?: string;
|
|
||||||
dev_objective?: string;
|
|
||||||
dev_manager?: string;
|
|
||||||
planning_manager?: string;
|
|
||||||
sales_manager?: string;
|
|
||||||
product_name?: string;
|
|
||||||
domain_address?: string;
|
|
||||||
email_account?: string;
|
|
||||||
email_pw?: string;
|
|
||||||
sw_id?: string;
|
|
||||||
sw_pw?: string;
|
|
||||||
purchase_method?: string;
|
|
||||||
asset_purpose?: string;
|
|
||||||
asset_status?: string;
|
|
||||||
start_date?: string;
|
|
||||||
expired_date?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SWUser {
|
|
||||||
id: string;
|
|
||||||
sw_id: string;
|
|
||||||
user_name: string;
|
|
||||||
dept: string;
|
|
||||||
corp: string;
|
|
||||||
emp_no?: string;
|
|
||||||
created_at?: string;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HardwareLog {
|
|
||||||
id: string;
|
|
||||||
asset_id: string;
|
|
||||||
log_date: string;
|
|
||||||
log_user: string;
|
|
||||||
event_type: string;
|
|
||||||
details: string;
|
|
||||||
old_dept?: string;
|
|
||||||
new_dept?: string;
|
|
||||||
old_user?: string;
|
|
||||||
new_user?: string;
|
|
||||||
created_at?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SystemUser {
|
|
||||||
id: string;
|
|
||||||
emp_no: string;
|
|
||||||
user_name: string;
|
|
||||||
dept_name: string;
|
|
||||||
position: string;
|
|
||||||
status: string;
|
|
||||||
created_at?: string;
|
|
||||||
updated_at?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PartsMaster {
|
|
||||||
id: number | string;
|
|
||||||
category: string;
|
|
||||||
component_name: string;
|
|
||||||
score_tier: string;
|
|
||||||
deduction: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MasterAssetData {
|
|
||||||
users: SystemUser[];
|
|
||||||
pc: HardwareAsset[];
|
|
||||||
server: HardwareAsset[];
|
|
||||||
storage: HardwareAsset[];
|
|
||||||
network: HardwareAsset[];
|
|
||||||
survey: HardwareAsset[];
|
|
||||||
pcParts: HardwareAsset[];
|
|
||||||
partsMaster: PartsMaster[];
|
|
||||||
equipment: HardwareAsset[];
|
|
||||||
officeSupplies: HardwareAsset[];
|
|
||||||
swInternal: SoftwareAsset[];
|
|
||||||
swExternal: SoftwareAsset[];
|
|
||||||
cloud: SoftwareAsset[];
|
|
||||||
domain: SoftwareAsset[];
|
|
||||||
cost: any[];
|
|
||||||
vip: HardwareAsset[];
|
|
||||||
swUsers: SWUser[];
|
|
||||||
logs: HardwareLog[];
|
|
||||||
// Integrated arrays
|
|
||||||
hw: HardwareAsset[];
|
|
||||||
sw: SoftwareAsset[];
|
|
||||||
}
|
|
||||||
47
src/main.ts
@@ -19,28 +19,41 @@ import { createIcons, Plus, X, LayoutDashboard, Monitor, Server, Database, Lapto
|
|||||||
|
|
||||||
|
|
||||||
// 화면 갱신 통합 핸들러
|
// 화면 갱신 통합 핸들러
|
||||||
function refreshView(tab?: string) {
|
function refreshView() {
|
||||||
const mainContent = document.getElementById('main-content')!;
|
const mainContent = document.getElementById('main-content')!;
|
||||||
if (!mainContent) return;
|
if (!mainContent) return;
|
||||||
|
|
||||||
const activeTab = tab || state.activeSubTab;
|
if (state.activeSubTab === '대시보드') {
|
||||||
|
|
||||||
if (activeTab === '대시보드') {
|
|
||||||
renderDashboard(mainContent);
|
renderDashboard(mainContent);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 서버 탭이 아닐 경우 '자산현황(위치)' 뷰 진입 방지 및 강제 리스트 모드 전환
|
// 서버 탭이 아닐 경우 '자산현황(위치)' 뷰 진입 방지 및 강제 리스트 모드 전환
|
||||||
if (activeTab !== '서버' && state.viewMode === 'location') {
|
if (state.activeSubTab !== '서버' && state.viewMode === 'location') {
|
||||||
state.viewMode = 'list';
|
state.viewMode = 'list';
|
||||||
}
|
}
|
||||||
|
|
||||||
const isServerTab = activeTab === '서버';
|
const isServerTab = state.activeSubTab === '서버';
|
||||||
|
|
||||||
mainContent.innerHTML = `
|
mainContent.innerHTML = `
|
||||||
<div id="view-body" class="view-container"></div>
|
<div class="view-header">
|
||||||
|
<div class="view-toggle-container" style="${isServerTab ? '' : 'display:none;'}">
|
||||||
|
<button class="mode-toggle-btn ${state.viewMode === 'location' ? 'active' : ''}" data-mode="location">자산현황(위치)</button>
|
||||||
|
<button class="mode-toggle-btn ${state.viewMode === 'list' ? 'active' : ''}" data-mode="list">자산목록</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="view-body" style="flex: 1; overflow: hidden; display: flex; flex-direction: column;"></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// 이벤트 바인딩
|
||||||
|
mainContent.querySelectorAll('.mode-toggle-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const mode = (btn as HTMLElement).getAttribute('data-mode') as any;
|
||||||
|
state.viewMode = mode;
|
||||||
|
refreshView();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const viewBody = document.getElementById('view-body')!;
|
const viewBody = document.getElementById('view-body')!;
|
||||||
if (state.viewMode === 'location') {
|
if (state.viewMode === 'location') {
|
||||||
renderLocationView(viewBody);
|
renderLocationView(viewBody);
|
||||||
@@ -200,19 +213,35 @@ function initRoleSwitcher() {
|
|||||||
function initializeAppDirectly() {
|
function initializeAppDirectly() {
|
||||||
const loginContainer = document.getElementById('login-container');
|
const loginContainer = document.getElementById('login-container');
|
||||||
const appLayout = document.getElementById('app-layout');
|
const appLayout = document.getElementById('app-layout');
|
||||||
|
const checkbox = document.getElementById('role-toggle-checkbox') as HTMLInputElement;
|
||||||
|
const userLabel = document.querySelector('.role-label.user');
|
||||||
|
const adminLabel = document.querySelector('.role-label.admin');
|
||||||
|
|
||||||
// 기본 권한 설정: 실무자 (User)
|
// 기본 권한 설정: 실무자 (User)
|
||||||
state.currentUserRole = 'user';
|
state.currentUserRole = 'user';
|
||||||
state.activeCategory = 'hw';
|
state.activeCategory = 'hw';
|
||||||
state.activeSubTab = '서버'; // 실무자 기본 탭
|
state.activeSubTab = '서버'; // 실무자 기본 탭
|
||||||
|
|
||||||
|
// UI 상태 동기화
|
||||||
|
if (checkbox) checkbox.checked = false;
|
||||||
|
if (userLabel) userLabel.classList.add('active');
|
||||||
|
if (adminLabel) adminLabel.classList.remove('active');
|
||||||
|
document.body.classList.remove('admin-mode');
|
||||||
|
|
||||||
// 화면 전환
|
// 화면 전환
|
||||||
if (loginContainer) loginContainer.style.display = 'none';
|
if (loginContainer) loginContainer.style.display = 'none';
|
||||||
if (appLayout) appLayout.style.display = 'flex';
|
if (appLayout) appLayout.style.display = 'flex';
|
||||||
|
|
||||||
// 앱 초기화 및 내비게이션(헤더 포함) 렌더링
|
// 앱 초기화
|
||||||
|
initRoleSwitcher();
|
||||||
initApp();
|
initApp();
|
||||||
renderNavigation((tab) => refreshView(tab));
|
|
||||||
|
// 로고 클릭 시 새로고침 (초기 화면 복귀 효과)
|
||||||
|
const brand = document.querySelector('.brand') as HTMLElement;
|
||||||
|
if (brand) {
|
||||||
|
brand.style.cursor = 'pointer';
|
||||||
|
brand.onclick = () => location.reload();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', initializeAppDirectly);
|
document.addEventListener('DOMContentLoaded', initializeAppDirectly);
|
||||||
|
|||||||
@@ -1,48 +1,51 @@
|
|||||||
:root {
|
:root {
|
||||||
/* --- Vercel Stark Palette --- */
|
/* --- System Colors --- */
|
||||||
--primary: #171717;
|
--color-red: #F21D0D;
|
||||||
--on-primary: #ffffff;
|
--color-pink: #E8175E;
|
||||||
--body: #4d4d4d;
|
--color-magenta: #B92ED1;
|
||||||
--mute: #888888;
|
--color-purple: #6D3DC2;
|
||||||
--hairline: #ebebeb;
|
--color-navy: #4255bd;
|
||||||
--hairline-strong: #a1a1a1;
|
--color-blue: #0D8DF2;
|
||||||
--canvas: #ffffff;
|
--color-cyan: #03AEFC;
|
||||||
--canvas-soft: #fafafa;
|
--color-green: #4DB251;
|
||||||
--canvas-soft-2: #f5f5f5;
|
--color-yellow: #FFBF00;
|
||||||
|
--color-orange: #FF9800;
|
||||||
/* --- Brand Accents --- */
|
--color-dahong: #FF3D00;
|
||||||
--color-blue: #0070f3;
|
--color-brown: #A0705F;
|
||||||
--color-cyan: #50e3c2;
|
--color-iron: #7F7F7F;
|
||||||
--color-pink: #ff0080;
|
--color-steel: #688897;
|
||||||
--color-violet: #7928ca;
|
|
||||||
--color-orange: #f5a623;
|
|
||||||
|
|
||||||
/* --- Semantic Alignment --- */
|
/* --- Primary Brand Levels --- */
|
||||||
--primary-color: var(--primary);
|
--primary-lv-0: #E9EEED;
|
||||||
--primary-hover: #000000;
|
--primary-lv-1: #D2DCDB;
|
||||||
--primary-light: var(--canvas-soft-2);
|
--primary-lv-2: #A5B9B6;
|
||||||
--text-main: var(--primary);
|
--primary-lv-3: #789792;
|
||||||
--text-muted: var(--body);
|
--primary-lv-4: #4B746D;
|
||||||
--border-color: var(--hairline);
|
--primary-lv-5: #35635C;
|
||||||
--bg-color: var(--canvas-soft);
|
--primary-lv-6: #1E5149;
|
||||||
--bg-light: var(--canvas-soft-2);
|
--primary-lv-7: #1B443D;
|
||||||
|
--primary-lv-8: #193833;
|
||||||
|
--primary-lv-9: #162A27;
|
||||||
|
|
||||||
|
/* --- Semantic Colors --- */
|
||||||
|
--primary-color: var(--primary-lv-6);
|
||||||
|
--primary-hover: var(--primary-lv-5);
|
||||||
|
--primary-light: var(--primary-lv-0);
|
||||||
|
|
||||||
|
--edit-mode-color: var(--color-dahong);
|
||||||
|
--edit-mode-light: rgba(255, 61, 0, 0.1);
|
||||||
|
--edit-mode-focus: rgba(255, 61, 0, 0.3);
|
||||||
|
--edit-mode-dark: #cc3100;
|
||||||
|
|
||||||
|
--text-main: #111827;
|
||||||
|
--text-muted: #6B7280;
|
||||||
|
--border-color: #E5E7EB;
|
||||||
|
--bg-color: #F9FAFB;
|
||||||
|
--bg-light: #FAFAFA;
|
||||||
--white: #FFFFFF;
|
--white: #FFFFFF;
|
||||||
--danger: #ee0000;
|
--danger: var(--color-red);
|
||||||
--success: #0070f3;
|
--success: var(--color-green);
|
||||||
--header-height: 64px;
|
--header-height: 52px;
|
||||||
|
|
||||||
/* --- Global Typography Scale (Tighter Clamps) --- */
|
|
||||||
--fs-xs: clamp(10px, 1vmin + 0.1vw, 13px);
|
|
||||||
--fs-sm: clamp(12px, 1.2vmin + 0.2vw, 15px);
|
|
||||||
--fs-base: clamp(13px, 1.4vmin + 0.2vw, 16px);
|
|
||||||
--fs-md: clamp(16px, 2vmin + 0.3vw, 24px);
|
|
||||||
--fs-lg: clamp(20px, 3vmin + 0.4vw, 32px);
|
|
||||||
--fs-xl: clamp(28px, 5vmin + 0.6vw, 48px);
|
|
||||||
|
|
||||||
/* --- Layout Units --- */
|
|
||||||
--header-height: 64px;
|
|
||||||
--spacing-base: 1.5rem;
|
|
||||||
--radius-base: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -52,31 +55,13 @@
|
|||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, .stat-value {
|
|
||||||
letter-spacing: -0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', 'Geist', 'Pretendard Variable', -apple-system, sans-serif;
|
font-family: 'Pretendard Variable', Pretendard, sans-serif;
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
background-color: var(--bg-color);
|
background-color: var(--bg-color);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-size: var(--fs-base);
|
font-size: 14px;
|
||||||
height: 100vh;
|
|
||||||
width: 100vw;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
-moz-user-select: none;
|
|
||||||
-ms-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
input, textarea {
|
|
||||||
-webkit-user-select: text;
|
|
||||||
-moz-user-select: text;
|
|
||||||
-ms-user-select: text;
|
|
||||||
user-select: text;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-layout {
|
.app-layout {
|
||||||
@@ -84,52 +69,67 @@ input, textarea {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Header --- */
|
/* --- Header --- */
|
||||||
.main-header {
|
.main-header {
|
||||||
background-color: var(--canvas);
|
background-color: var(--white);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
height: var(--header-height);
|
height: var(--header-height);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 1.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-container {
|
.header-container {
|
||||||
width: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
padding: 0 1.5rem;
|
||||||
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand { display: flex; align-items: center; gap: 0.75rem; }
|
.brand { display: flex; align-items: center; gap: 0.75rem; }
|
||||||
.main-logo { height: clamp(28px, 4vmin, 40px); width: auto; }
|
.main-logo { height: 34px; width: auto; }
|
||||||
.brand h1 { font-size: clamp(0.85rem, 1.4vmin, 1.05rem); font-weight: 600; color: var(--text-main); }
|
.brand h1 { font-size: 1.1rem; font-weight: 800; color: var(--text-main); white-space: nowrap; }
|
||||||
|
.brand h1 .sub-title { font-size: 0.85rem; color: var(--primary-color); font-weight: 600; margin-left: 0.25rem; }
|
||||||
|
|
||||||
.integrated-nav { flex: 1; display: flex; align-items: center; margin-left: 2rem; gap: 0.5rem; }
|
.integrated-nav { flex: 1; height: 100%; display: flex; align-items: center; gap: 0.25rem; overflow: hidden; }
|
||||||
.gnb-trigger {
|
.nav-group { display: flex; align-items: center; height: 100%; position: relative; flex-shrink: 0; }
|
||||||
font-size: var(--fs-xs);
|
.gnb-trigger { font-size: 14px; font-weight: 700; color: var(--text-muted); padding: 0 0.75rem; cursor: pointer; height: 100%; display: flex; align-items: center; white-space: nowrap; transition: color 0.2s; }
|
||||||
font-weight: 500;
|
.nav-group.active .gnb-trigger, .nav-group:hover .gnb-trigger { color: var(--text-main); }
|
||||||
color: var(--text-muted);
|
.lnb-shelf { display: none; align-items: center; gap: 0.2rem; padding: 0 0.5rem; height: 60%; border-left: 1px solid var(--border-color); margin-left: 0.2rem; }
|
||||||
padding: 0.4rem 0.75rem;
|
|
||||||
cursor: pointer;
|
/* 기본적으로 활성 탭의 서브메뉴 표시 */
|
||||||
border-radius: 9999px;
|
.nav-group.active.is-showing-shelf .lnb-shelf { display: flex; }
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
/* GNB 전체 영역에 마우스가 올라가면 활성 탭의 서브메뉴를 일단 숨김 (다른 메뉴 탐색 우선) */
|
||||||
.gnb-trigger:hover { color: var(--text-main); background: var(--canvas-soft-2); }
|
.integrated-nav:hover .nav-group.active.is-showing-shelf .lnb-shelf { display: none; }
|
||||||
.gnb-trigger.active { color: var(--text-main); font-weight: 600; background: var(--canvas-soft-2); }
|
|
||||||
|
/* 마우스가 올라간 메뉴의 서브메뉴만 표시 */
|
||||||
|
.nav-group:hover .lnb-shelf { display: flex !important; }
|
||||||
|
|
||||||
|
.lnb-item { font-size: 13px; font-weight: 500; color: var(--text-muted); cursor: pointer; padding: 0.2rem 0.6rem; border-radius: 4px; white-space: nowrap; transition: all 0.2s; }
|
||||||
|
.lnb-item:hover { color: var(--primary-color); background-color: var(--primary-light); }
|
||||||
|
.lnb-item.active { color: var(--primary-color); background-color: var(--primary-light); font-weight: 700; }
|
||||||
|
|
||||||
|
.header-actions { display: flex; align-items: center; gap: 1rem; }
|
||||||
|
.role-switcher { display: flex; align-items: center; gap: 0.75rem; padding: 0 0.75rem; border-right: 1px solid var(--border-color); height: 24px; }
|
||||||
|
.role-label { font-size: 11px; font-weight: 700; color: var(--text-muted); }
|
||||||
|
.role-label.active { color: var(--primary-color); }
|
||||||
|
.switch { position: relative; display: inline-block; width: 34px; height: 18px; }
|
||||||
|
.switch input { opacity: 0; width: 0; height: 0; }
|
||||||
|
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .4s; border-radius: 34px; }
|
||||||
|
.slider:before { position: absolute; content: ""; height: 12px; width: 12px; left: 3px; bottom: 3px; background-color: white; transition: .4s; border-radius: 50%; }
|
||||||
|
input:checked + .slider { background-color: var(--color-orange); }
|
||||||
|
input:checked + .slider:before { transform: translateX(16px); }
|
||||||
|
|
||||||
/* --- Layout Content --- */
|
/* --- Layout Content --- */
|
||||||
.content-area {
|
.content-area {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0;
|
padding: 1.25rem 2rem 0;
|
||||||
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-container {
|
.view-container {
|
||||||
@@ -140,492 +140,165 @@ input, textarea {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- View Toggle (Vercel Tab Style) --- */
|
.view-content-wrapper {
|
||||||
.view-toggle {
|
|
||||||
display: inline-flex;
|
|
||||||
background: var(--canvas-soft-2);
|
|
||||||
padding: 0.2rem;
|
|
||||||
border: 1px solid var(--hairline);
|
|
||||||
gap: 0.1rem;
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-btn {
|
|
||||||
padding: 0.35rem 1rem;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
font-size: var(--fs-xs);
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.1s;
|
|
||||||
border-radius: calc(var(--radius-base) - 2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-btn:hover { color: var(--text-main); }
|
|
||||||
.toggle-btn.active {
|
|
||||||
background: var(--canvas);
|
|
||||||
color: var(--text-main);
|
|
||||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Role Toggle Switch --- */
|
|
||||||
.role-toggle-wrapper {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
background: var(--canvas-soft-2);
|
|
||||||
padding: 0.35rem 0.75rem;
|
|
||||||
border-radius: 9999px;
|
|
||||||
border: 1px solid var(--hairline);
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-label {
|
|
||||||
font-size: var(--fs-xs);
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--mute);
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-label.active {
|
|
||||||
color: var(--primary);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-toggle {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
width: 40px;
|
|
||||||
height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-toggle input {
|
|
||||||
opacity: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-slider {
|
|
||||||
position: absolute;
|
|
||||||
cursor: pointer;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-color: var(--hairline-strong);
|
|
||||||
transition: .4s;
|
|
||||||
border-radius: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-slider:before {
|
|
||||||
position: absolute;
|
|
||||||
content: "";
|
|
||||||
height: 16px;
|
|
||||||
width: 16px;
|
|
||||||
left: 2px;
|
|
||||||
bottom: 2px;
|
|
||||||
background-color: white;
|
|
||||||
transition: .4s;
|
|
||||||
border-radius: 50%;
|
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
input:checked + .role-slider {
|
|
||||||
background-color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
input:checked + .role-slider:before {
|
|
||||||
transform: translateX(20px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Utility Styles (The Standard) --- */
|
|
||||||
.btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
padding: 0 1.25rem;
|
|
||||||
font-size: var(--fs-xs);
|
|
||||||
font-weight: 500;
|
|
||||||
border-radius: 9999px;
|
|
||||||
cursor: pointer;
|
|
||||||
height: clamp(32px, 4.5vmin, 44px);
|
|
||||||
transition: all 0.2s;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary { background-color: var(--primary); color: var(--on-primary); }
|
|
||||||
.btn-primary:hover { background-color: #000; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
|
|
||||||
|
|
||||||
.btn-outline { background-color: var(--canvas); color: var(--text-main); border: 1px solid var(--hairline); }
|
|
||||||
.btn-outline:hover { border-color: var(--hairline-strong); background: var(--canvas-soft); }
|
|
||||||
|
|
||||||
.btn-sm { height: clamp(28px, 3.5vmin, 36px); padding: 0 1rem; font-size: var(--fs-xs); }
|
|
||||||
.btn-danger { color: var(--danger) !important; border-color: var(--danger) !important; }
|
|
||||||
|
|
||||||
/* --- Form Elements --- */
|
|
||||||
.form-select-sm {
|
|
||||||
height: clamp(28px, 3.5vmin, 36px);
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
border: 1px solid var(--hairline);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: var(--fs-xs);
|
|
||||||
outline: none;
|
|
||||||
background-color: var(--canvas);
|
|
||||||
color: var(--primary);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-select-sm:focus {
|
|
||||||
border-color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 9999px;
|
|
||||||
font-size: var(--fs-xs);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.badge-primary { background-color: var(--primary); color: var(--on-primary); }
|
|
||||||
|
|
||||||
/* --- Form Elements Extra --- */
|
|
||||||
.input-with-icon {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-with-icon input {
|
|
||||||
padding-left: 2.5rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-with-icon i,
|
|
||||||
.input-with-icon .icon-sm {
|
|
||||||
position: absolute;
|
|
||||||
left: 12px;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
color: var(--mute);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.autocomplete-list {
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
max-height: 250px;
|
|
||||||
overflow-y: auto;
|
|
||||||
background: var(--canvas);
|
|
||||||
border: 1px solid var(--hairline);
|
|
||||||
border-radius: 6px;
|
|
||||||
box-shadow: 0 12px 30px rgba(0,0,0,0.12);
|
|
||||||
z-index: 1100;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.autocomplete-item {
|
|
||||||
padding: 10px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
border-bottom: 1px solid var(--hairline-soft, #f5f5f5);
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.autocomplete-item:hover {
|
|
||||||
background: var(--canvas-soft-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.autocomplete-item-empty {
|
|
||||||
padding: 1rem;
|
|
||||||
color: var(--mute);
|
|
||||||
font-size: var(--fs-xs);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.suggestion-name {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: var(--fs-xs);
|
|
||||||
color: var(--primary);
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.suggestion-meta {
|
|
||||||
font-size: var(--fs-xs);
|
|
||||||
color: var(--mute);
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Summary & Selection Cards --- */
|
|
||||||
.summary-info-card {
|
|
||||||
padding: 1.25rem;
|
|
||||||
background: var(--canvas-soft);
|
|
||||||
border: 1px solid var(--hairline);
|
|
||||||
border-radius: 8px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-pc-selection-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
max-height: 250px;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-right: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-pc-item {
|
|
||||||
padding: 12px;
|
|
||||||
border: 1px solid var(--hairline);
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
background: var(--canvas);
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-pc-item:hover {
|
|
||||||
border-color: var(--hairline-strong);
|
|
||||||
background: var(--canvas-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-pc-item.selected {
|
|
||||||
border-color: var(--primary);
|
|
||||||
background: var(--primary-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pc-item-code {
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: var(--fs-xs);
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pc-item-meta {
|
|
||||||
font-size: var(--fs-xs);
|
|
||||||
color: var(--mute);
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-list-message {
|
|
||||||
font-size: var(--fs-xs);
|
|
||||||
color: var(--mute);
|
|
||||||
padding: 1rem 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Global Utilities --- */
|
|
||||||
.hidden { display: none !important; }
|
|
||||||
.clickable { cursor: pointer; transition: opacity 0.2s; }
|
|
||||||
.clickable:hover { opacity: 0.8; }
|
|
||||||
|
|
||||||
/* Flexbox & Grid Utilities */
|
|
||||||
.flex { display: flex; }
|
|
||||||
.flex-col { display: flex; flex-direction: column; }
|
|
||||||
.flex-row { display: flex; flex-direction: row; }
|
|
||||||
.items-center { align-items: center; }
|
|
||||||
.justify-between { justify-content: space-between; }
|
|
||||||
.justify-center { justify-content: center; }
|
|
||||||
.gap-1 { gap: 0.25rem; }
|
|
||||||
.gap-2 { gap: 0.5rem; }
|
|
||||||
.gap-3 { gap: 0.75rem; }
|
|
||||||
.gap-4 { gap: 1rem; }
|
|
||||||
.gap-6 { gap: 1.5rem; }
|
|
||||||
.gap-y-3 { row-gap: 0.75rem; }
|
|
||||||
.gap-x-4 { column-gap: 1rem; }
|
|
||||||
.mb-0 { margin-bottom: 0 !important; }
|
|
||||||
.mb-4 { margin-bottom: 1rem !important; }
|
|
||||||
.mb-6 { margin-bottom: 1.5rem !important; }
|
|
||||||
.pb-4 { padding-bottom: 1rem !important; }
|
|
||||||
.p-4 { padding: 1rem !important; }
|
|
||||||
.p-2 { padding: 0.5rem !important; }
|
|
||||||
.p-8 { padding: 2rem !important; }
|
|
||||||
.ml-auto { margin-left: auto !important; }
|
|
||||||
.self-end { align-self: flex-end !important; }
|
|
||||||
.font-medium { font-weight: 500; }
|
|
||||||
.text-muted { color: var(--mute) !important; }
|
|
||||||
.mt-12 { margin-top: 3rem !important; }
|
|
||||||
.icon-sm { width: 16px; height: 16px; }
|
|
||||||
.h-90vh { height: 90vh !important; }
|
|
||||||
.pt-0 { padding-top: 0 !important; }
|
|
||||||
.font-semibold { font-weight: 600; }
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.w-full { width: 100%; }
|
|
||||||
.h-full { height: 100%; }
|
|
||||||
|
|
||||||
/* Text Utilities */
|
|
||||||
.text-center { text-align: center !important; }
|
|
||||||
.text-right { text-align: right !important; }
|
|
||||||
.text-left { text-align: left !important; }
|
|
||||||
.font-bold { font-weight: 700; }
|
|
||||||
.bg-primary-light { background-color: var(--primary-light) !important; }
|
|
||||||
.text-success { color: var(--success) !important; }
|
|
||||||
.text-danger { color: var(--danger) !important; }
|
|
||||||
.text-blue { color: var(--color-blue) !important; }
|
|
||||||
.text-orange { color: var(--color-orange) !important; }
|
|
||||||
/* --- Unified Search & Filter Bar --- */
|
|
||||||
.search-bar {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--spacing-base);
|
|
||||||
padding: 1.25rem var(--spacing-base);
|
|
||||||
border-bottom: 1px solid var(--hairline);
|
|
||||||
align-items: flex-end;
|
|
||||||
background: var(--canvas);
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-item.flex-1 {
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 300px;
|
overflow-y: auto;
|
||||||
|
padding-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-item label {
|
/* --- View Toggle --- */
|
||||||
font-size: var(--fs-xs);
|
.view-toggle-container { margin-bottom: 1rem; display: flex; justify-content: flex-start; }
|
||||||
font-weight: 600;
|
.view-toggle { display: inline-flex; background-color: var(--primary-lv-0); padding: 4px; border-radius: 8px; border: 1px solid var(--border-color); }
|
||||||
color: var(--mute);
|
.toggle-btn { padding: 6px 16px; font-size: 13px; font-weight: 600; color: var(--text-muted); background: none; border: none; border-radius: 6px; cursor: pointer; }
|
||||||
text-transform: uppercase;
|
.toggle-btn.active { background-color: var(--white); color: var(--primary-color); box-shadow: 0 2px 4px rgba(0,0,0,0.05); }
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-item input,
|
/* --- System Status List (Docker Style) --- */
|
||||||
.search-item select {
|
.system-status-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
height: clamp(34px, 4.5vmin, 44px);
|
.system-list-header { display: flex; align-items: center; padding: 0.75rem 1.25rem; background-color: var(--bg-light); border-bottom: 1px solid var(--border-color); font-size: 11px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; }
|
||||||
padding: 0 0.75rem;
|
.system-row { display: flex; align-items: center; padding: 1rem 1.25rem; background-color: var(--white); border: 1px solid var(--border-color); border-radius: 6px; transition: all 0.2s; }
|
||||||
border: 1px solid var(--hairline);
|
.system-row:hover { border-color: var(--primary-lv-3); box-shadow: 0 4px 12px rgba(0,0,0,0.03); }
|
||||||
border-radius: 6px;
|
.col-status { width: 100px; display: flex; align-items: center; gap: 0.5rem; }
|
||||||
font-size: var(--fs-sm);
|
.col-info { flex: 1.5; }
|
||||||
outline: none;
|
.col-network { flex: 1; }
|
||||||
background-color: var(--canvas);
|
.col-remote { flex: 1; display: flex; align-items: center; gap: 0.5rem; }
|
||||||
color: var(--primary);
|
.col-traffic { flex: 1.2; }
|
||||||
transition: border-color 0.2s;
|
.col-actions { width: 120px; display: flex; justify-content: flex-end; }
|
||||||
box-sizing: border-box;
|
.status-dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||||
}
|
.status-dot.online { background-color: var(--success); box-shadow: 0 0 6px var(--success); }
|
||||||
|
.status-text { font-size: 11px; font-weight: 600; color: var(--success); }
|
||||||
.search-item select {
|
.asset-primary { font-weight: 700; font-size: 14px; }
|
||||||
cursor: pointer;
|
.asset-secondary { font-size: 12px; color: var(--text-muted); }
|
||||||
min-width: 120px;
|
.ip-address { font-weight: 600; font-family: monospace; color: var(--primary-color); }
|
||||||
}
|
.traffic-mini-chart { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.traffic-info { display: flex; justify-content: space-between; font-size: 11px; }
|
||||||
.search-item input:focus,
|
.progress-bg { height: 4px; background: var(--primary-lv-0); border-radius: 2px; overflow: hidden; }
|
||||||
.search-item select:focus {
|
.progress-fill { height: 100%; background: var(--primary-color); }
|
||||||
border-color: var(--primary);
|
.icon-btn { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: 4px; border: 1px solid var(--border-color); background: var(--white); color: var(--text-muted); cursor: pointer; }
|
||||||
}
|
.icon-btn:hover { background-color: var(--primary-light); border-color: var(--primary-color); color: var(--primary-color); }
|
||||||
|
|
||||||
.header-action-group {
|
|
||||||
margin-left: auto;
|
|
||||||
align-self: flex-end;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-view-toggle-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--primary);
|
|
||||||
height: clamp(34px, 4.5vmin, 44px);
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
font-size: var(--fs-sm);
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-view-toggle-label input[type="checkbox"] {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-pagination-group {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
padding-left: 1rem;
|
|
||||||
border-left: 1px solid var(--hairline);
|
|
||||||
height: clamp(34px, 4.5vmin, 44px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-info {
|
|
||||||
font-size: var(--fs-xs);
|
|
||||||
color: var(--mute);
|
|
||||||
font-weight: 500;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* --- Modal & View Header Layouts --- */
|
|
||||||
.header-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Asset Identity & Header Styling (Global) --- */
|
|
||||||
.header-identity {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex: 1;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.asset-code-title {
|
|
||||||
font-size: var(--fs-md);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--primary);
|
|
||||||
letter-spacing: -0.05em;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.service-type-badge {
|
|
||||||
font-size: var(--fs-xs);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--on-primary);
|
|
||||||
background: var(--primary);
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 9999px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.asset-type-label {
|
|
||||||
font-size: var(--fs-sm);
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--mute);
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/* --- Footer --- */
|
||||||
.main-footer {
|
.main-footer {
|
||||||
|
height: 28px;
|
||||||
|
background-color: var(--white);
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
background-color: var(--canvas);
|
display: flex;
|
||||||
color: var(--mute);
|
align-items: center;
|
||||||
padding: 1rem 2rem;
|
justify-content: flex-end;
|
||||||
text-align: right;
|
padding: 0 1.5rem;
|
||||||
font-size: var(--fs-xs);
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-footer p {
|
.main-footer p {
|
||||||
|
font-family: 'Pretendard Variable', Pretendard, sans-serif;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
letter-spacing: -0.0175rem;
|
||||||
|
color: #777777;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: all;
|
||||||
|
-webkit-user-drag: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
letter-spacing: 0.02em;
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-nowrap {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Utility Styles --- */
|
||||||
|
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 0.35rem; padding: 0 0.8rem; font-size: 12px; font-weight: 600; border-radius: 4px; cursor: pointer; height: 28px; }
|
||||||
|
.btn-primary { background-color: var(--primary-color); color: var(--white); border: none; }
|
||||||
|
.btn-outline { background-color: transparent; color: var(--text-muted); border: 1px solid var(--border-color); }
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-primary {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-muted {
|
||||||
|
background-color: #9CA3AF;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-light {
|
||||||
|
background: var(--bg-color);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PC 성능 등급 뱃지 컬러 스타일 */
|
||||||
|
.badge.b-purple {
|
||||||
|
background-color: #EDE9FE;
|
||||||
|
color: #7C3AED;
|
||||||
|
border: 1px solid #DDD6FE;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
.badge.b-primary {
|
||||||
|
background-color: #DBEAFE;
|
||||||
|
color: #1D4ED8;
|
||||||
|
border: 1px solid #BFDBFE;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
.badge.b-green {
|
||||||
|
background-color: #D1FAE5;
|
||||||
|
color: #047857;
|
||||||
|
border: 1px solid #A7F3D0;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
.badge.b-yellow {
|
||||||
|
background-color: #FEF3C7;
|
||||||
|
color: #D97706;
|
||||||
|
border: 1px solid #FDE68A;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-tag {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: var(--bg-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-bold {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Responsive Design (Tablet & Mobile) --- */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.header-container { gap: 0.75rem; padding: 0 1rem; }
|
||||||
|
.brand h1 { font-size: 1rem; }
|
||||||
|
.brand h1 .sub-title { font-size: 0.75rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.main-header { height: auto; padding: 0.5rem 0; }
|
||||||
|
.header-container { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
|
||||||
|
.integrated-nav { width: 100%; justify-content: flex-start; border-top: 1px solid var(--border-color); padding-top: 0.5rem; }
|
||||||
|
.header-actions { width: 100%; justify-content: flex-end; padding-top: 0.5rem; }
|
||||||
|
.content-area { padding: 0 1rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.brand h1 .sub-title { display: none; }
|
||||||
|
.header-actions .btn span { display: none; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,486 +1,526 @@
|
|||||||
/* --- Vercel Inspired Premium Dashboard --- */
|
/* --- Premium Executive Dashboard View Specific Styles --- */
|
||||||
.dashboard-section-title {
|
.dashboard-section-title {
|
||||||
padding: 0;
|
padding: 0 0 0 8px;
|
||||||
font-size: var(--fs-lg);
|
font-size: 1.55rem;
|
||||||
font-weight: 600;
|
font-weight: 800;
|
||||||
color: var(--primary);
|
color: var(--text-main);
|
||||||
letter-spacing: -0.05em;
|
letter-spacing: -0.02em;
|
||||||
margin-bottom: clamp(0.5rem, 1.5vmin, 1.5rem);
|
border-left: 4px solid var(--primary-color);
|
||||||
line-height: 1;
|
margin-bottom: 1rem;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Background Mesh Gradient for Stats Row */
|
.dashboard-grid {
|
||||||
.dashboard-stats-row {
|
display: grid;
|
||||||
display: flex;
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
flex-wrap: wrap;
|
gap: 1.5rem;
|
||||||
border-bottom: 1px solid var(--hairline);
|
margin-bottom: 2rem;
|
||||||
padding: 0;
|
|
||||||
margin-bottom: clamp(1rem, 2vmin, 2rem);
|
|
||||||
background: radial-gradient(at 0% 0%, rgba(80, 227, 194, 0.05) 0px, transparent 50%),
|
|
||||||
radial-gradient(at 100% 0%, rgba(121, 40, 202, 0.05) 0px, transparent 50%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-group-item {
|
/* Premium Executive Divider-based Style (Line-based Division) */
|
||||||
flex: 1;
|
.dashboard-card, .stat-card {
|
||||||
min-width: 250px;
|
background: transparent;
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 1.5rem 0.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: var(--spacing-base);
|
transition: opacity 0.2s ease;
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-group-item.bordered {
|
.dashboard-card:hover, .stat-card:hover {
|
||||||
border-left: 1px solid var(--hairline);
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-group-item .stat-label {
|
.dashboard-layout-2col {
|
||||||
font-size: var(--fs-xs);
|
display: grid;
|
||||||
font-weight: 500;
|
grid-template-columns: repeat(2, 1fr);
|
||||||
color: var(--mute);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-group-item .stat-value {
|
|
||||||
font-size: var(--fs-xl);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--primary);
|
|
||||||
line-height: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-group-item .stat-value span {
|
|
||||||
font-size: var(--fs-base);
|
|
||||||
font-weight: 400;
|
|
||||||
margin-left: 6px;
|
|
||||||
color: var(--mute);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-group-item .stat-sub {
|
|
||||||
display: flex;
|
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
font-size: var(--fs-sm);
|
|
||||||
color: var(--body);
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Technical Data Alignment --- */
|
.dashboard-layout-3col {
|
||||||
.text-primary {
|
display: grid;
|
||||||
color: var(--color-blue) !important;
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-stat-header {
|
.dashboard-card {
|
||||||
|
min-height: 380px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card canvas {
|
||||||
|
flex: 1;
|
||||||
|
width: 100% !important;
|
||||||
|
max-height: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Premium KPI Value Styling */
|
||||||
|
.stat-value {
|
||||||
|
font-size: 2.41rem;
|
||||||
|
font-weight: 800;
|
||||||
|
background: linear-gradient(135deg, #1E5149 0%, #3B82F6 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
margin-top: 0.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-title {
|
.stat-value-danger {
|
||||||
font-size: var(--fs-base);
|
background: linear-gradient(135deg, #E11D48 0%, #F59E0B 100%);
|
||||||
font-weight: 600;
|
-webkit-background-clip: text;
|
||||||
color: var(--primary);
|
-webkit-text-fill-color: transparent;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-stat-body {
|
.stat-label {
|
||||||
|
font-size: 1.36rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-blue { background: rgba(59, 130, 246, 0.1); color: #3B82F6; }
|
||||||
|
.icon-green { background: rgba(30, 81, 73, 0.1); color: #1E5149; }
|
||||||
|
.icon-red { background: rgba(225, 29, 72, 0.1); color: #E11D48; }
|
||||||
|
.icon-yellow { background: rgba(245, 158, 11, 0.1); color: #F59E0B; }
|
||||||
|
|
||||||
|
.table-premium {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-premium table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-premium th {
|
||||||
|
background: #F8FAFC;
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.96rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-premium td {
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid #E2E8F0;
|
||||||
|
color: #1E293B;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-premium tr:hover td {
|
||||||
|
background: #F1F5F9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Slider/Carousel Specific Styles --- */
|
||||||
|
.dashboard-header-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-nav-btn {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-main);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-nav-btn:hover {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-nav-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-info {
|
||||||
|
font-size: 0.96rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btns button {
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.96rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btns button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-indicator {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1.41rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-slider-viewport {
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-slider-track {
|
||||||
|
display: flex;
|
||||||
|
transition: transform 0.5s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||||
|
width: 400%; /* For 4 pages */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-slide {
|
||||||
|
width: 25%; /* 100% / 4 pages */
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0 2px; /* Slight padding to avoid cutting off box-shadows */
|
||||||
|
height: calc(100vh - 150px);
|
||||||
|
min-height: 520px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loc-summary {
|
/* --- Location View Styles --- */
|
||||||
|
.location-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.2fr 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
height: calc(100vh - 180px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-section, .asset-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--text-main);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
background: #f8fafc;
|
||||||
|
box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-box {
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-box:hover {
|
||||||
|
background: rgba(30, 81, 73, 0.2) !important;
|
||||||
|
transform: scale(1.02);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-box:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-section .table-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.625rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #059669;
|
||||||
|
border: 1px solid #d1fae5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle-btn:hover {
|
||||||
|
border-color: var(--primary-color) !important;
|
||||||
|
color: var(--primary-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle-btn.active:hover {
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- View Toggle Header --- */
|
||||||
|
.view-header {
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
background: var(--white);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.loc-summary span {
|
.view-toggle-container {
|
||||||
font-size: var(--fs-sm);
|
|
||||||
color: var(--mute);
|
|
||||||
}
|
|
||||||
|
|
||||||
.loc-summary span strong {
|
|
||||||
color: var(--primary);
|
|
||||||
font-size: var(--fs-base);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-summary {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.8rem;
|
background: #f1f5f9;
|
||||||
flex-wrap: wrap;
|
padding: 0.25rem;
|
||||||
opacity: 0.9;
|
border-radius: 8px;
|
||||||
border-top: 1px dashed var(--hairline);
|
gap: 0.25rem;
|
||||||
padding-top: 8px;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.type-summary span {
|
.mode-toggle-btn {
|
||||||
cursor: help;
|
padding: 0.5rem 1rem;
|
||||||
font-size: var(--fs-xs);
|
border: none;
|
||||||
color: var(--mute);
|
background: transparent;
|
||||||
}
|
border-radius: 6px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
.type-summary span strong {
|
|
||||||
color: var(--primary);
|
|
||||||
font-size: var(--fs-sm);
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Enhanced Location View Layout --- */
|
.mode-toggle-btn:hover {
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-toggle-btn.active {
|
||||||
|
background: var(--white);
|
||||||
|
color: var(--primary-color);
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Enhanced Location View --- */
|
||||||
.location-view-wrapper {
|
.location-view-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: calc(100vh - 120px);
|
||||||
background: var(--canvas);
|
}
|
||||||
overflow: hidden;
|
|
||||||
|
.location-filter-bar {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background: var(--white);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group label {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group select {
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-main);
|
||||||
|
background: var(--white);
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-pagination {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.location-main-content {
|
.location-main-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 2fr 1fr;
|
grid-template-columns: 1.4fr 1fr;
|
||||||
background: var(--canvas);
|
gap: 1.5rem;
|
||||||
gap: 0;
|
padding: 1.5rem;
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
align-items: stretch;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-container-section {
|
.map-container-section {
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
justify-content: center;
|
overflow: auto;
|
||||||
background: var(--canvas);
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-frame-wrapper {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-image {
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
width: auto;
|
|
||||||
height: auto;
|
|
||||||
object-fit: contain;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-overlay {
|
|
||||||
position: absolute;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-map-message {
|
|
||||||
padding: 5rem;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--mute);
|
|
||||||
font-size: var(--fs-base);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.location-box-point {
|
.location-box-point {
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Asset Detail Sidebar --- */
|
.box-label-text {
|
||||||
.asset-list-section {
|
font-size: 0.65rem;
|
||||||
display: flex;
|
font-weight: 800;
|
||||||
flex-direction: column;
|
color: var(--primary-color);
|
||||||
height: 100%;
|
pointer-events: none;
|
||||||
overflow: hidden;
|
text-shadow: 0 0 2px white;
|
||||||
background: var(--canvas);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-header {
|
.asset-list-section {
|
||||||
padding: 1.5rem;
|
background: var(--white);
|
||||||
border-bottom: 1px solid var(--hairline);
|
border-radius: 12px;
|
||||||
background: var(--canvas);
|
border: 1px solid var(--border-color);
|
||||||
flex-shrink: 0;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-list-section .section-header {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-list-section h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini-table-wrapper {
|
.mini-table-wrapper {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-title {
|
.compact-table {
|
||||||
margin: 0;
|
width: 100%;
|
||||||
font-size: var(--fs-base);
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-table th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--white);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-table td {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-table tr.clickable-row:hover {
|
||||||
|
background: #f1f5f9;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Asset Detail Sidebar (LocationView) --- */
|
||||||
|
.asset-detail-sidebar {
|
||||||
|
padding-top: 1rem;
|
||||||
|
background: var(--white);
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 0 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding-bottom: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(80px, auto) 1fr);
|
||||||
|
gap: 8px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--primary);
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-main);
|
||||||
|
font-weight: 500;
|
||||||
|
word-break: break-all;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-header-actions {
|
.detail-header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
width: 100%;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-identity {
|
|
||||||
display: flex;
|
|
||||||
align-items: center; /* Changed from baseline to center for perfect vertical alignment */
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex: 1;
|
|
||||||
flex-wrap: wrap; /* Allow wrapping on very small screens */
|
|
||||||
}
|
|
||||||
|
|
||||||
.asset-code-title {
|
|
||||||
font-size: var(--fs-md);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--primary);
|
|
||||||
letter-spacing: -0.05em;
|
|
||||||
line-height: 1; /* Reset line-height to prevent baseline shifts */
|
|
||||||
}
|
|
||||||
|
|
||||||
.service-type-badge {
|
|
||||||
font-size: var(--fs-xs);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--on-primary);
|
|
||||||
background: var(--primary);
|
|
||||||
padding: 4px 8px; /* Adjusted padding for better vertical centering */
|
|
||||||
border-radius: 9999px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
line-height: 1; /* Match line-height */
|
|
||||||
}
|
|
||||||
|
|
||||||
.asset-type-label {
|
|
||||||
font-size: var(--fs-sm);
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--mute);
|
|
||||||
line-height: 1; /* Match line-height */
|
|
||||||
}
|
|
||||||
|
|
||||||
.asset-detail-sidebar {
|
|
||||||
padding: 1.5rem 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-section {
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
padding: 0 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-section-title {
|
|
||||||
font-size: var(--fs-xs);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--mute);
|
|
||||||
border-bottom: 1px solid var(--hairline);
|
|
||||||
padding-bottom: 8px;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-grid-2col {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 1rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-item.full-width {
|
|
||||||
grid-column: span 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-label-sm {
|
|
||||||
font-size: var(--fs-xs);
|
|
||||||
color: var(--mute);
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-layout-2col {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 2rem;
|
|
||||||
padding: 0 2rem 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-card {
|
|
||||||
background: var(--canvas);
|
|
||||||
border: 1px solid var(--hairline);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 2rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-card.clickable:hover {
|
|
||||||
border-color: var(--primary);
|
|
||||||
box-shadow: 0 12px 30px rgba(0,0,0,0.08);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-progress-bar {
|
|
||||||
height: 8px;
|
|
||||||
background: var(--canvas-soft-2);
|
|
||||||
border-radius: 9999px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: var(--primary);
|
|
||||||
border-radius: 9999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-card .stat-label {
|
|
||||||
font-size: var(--fs-xs);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--mute);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-card .stat-value {
|
|
||||||
font-size: var(--fs-xl);
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-card .stat-sub {
|
|
||||||
font-size: var(--fs-sm);
|
|
||||||
color: var(--body);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-soft {
|
|
||||||
background-color: var(--canvas-soft) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-placeholder {
|
|
||||||
width: 140px;
|
|
||||||
height: 140px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.circular-progress {
|
|
||||||
width: 100px;
|
|
||||||
height: 100px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: conic-gradient(var(--primary) calc(var(--val) * 1%), var(--hairline) 0);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.circular-progress::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
width: 70px;
|
|
||||||
height: 70px;
|
|
||||||
background: var(--canvas);
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.circular-progress::after {
|
|
||||||
content: attr(style); /* This is a hack to get the value, but we'll use innerHTML in TS if needed */
|
|
||||||
position: absolute;
|
|
||||||
font-size: var(--fs-sm);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.system-dashboard {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning-badge-orange { background-color: var(--color-orange); color: var(--white); padding: 2px 8px; border-radius: 9999px; font-size: var(--fs-xs); font-weight: 600; }
|
|
||||||
.warning-badge { background-color: var(--danger); color: var(--white); padding: 2px 8px; border-radius: 9999px; font-size: var(--fs-xs); font-weight: 600; }
|
|
||||||
|
|
||||||
.list-section {
|
|
||||||
flex: 1.3;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 1rem 1.5rem 0 0;
|
|
||||||
border-right: 1px solid var(--hairline);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-panel {
|
|
||||||
flex: 0.7;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 1rem 0 0 1.5rem;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-empty-state {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
color: var(--mute);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-photo-wrapper {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
font-size: 0.95rem;
|
||||||
display: flex;
|
font-weight: 700;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
position: relative;
|
|
||||||
border: 1px solid var(--hairline);
|
|
||||||
background: #f0f0f0;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-photo-state {
|
|
||||||
padding: 3rem;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--mute);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* Responsive Overrides */
|
|
||||||
@media (max-width: 1440px) {
|
|
||||||
.location-main-content {
|
|
||||||
grid-template-columns: 1.5fr 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
.location-main-content {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
grid-template-rows: auto 1fr;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.map-container-section {
|
|
||||||
height: 400px;
|
|
||||||
border-right: none;
|
|
||||||
border-bottom: 1px solid var(--hairline);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,8 @@
|
|||||||
|
|
||||||
.guide-tab {
|
.guide-tab {
|
||||||
padding: 0.75rem 1.25rem;
|
padding: 0.75rem 1.25rem;
|
||||||
font-size: 24px;
|
font-size: 18px;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-bottom: 2px solid transparent;
|
border-bottom: 2px solid transparent;
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.guide-section h3 {
|
.guide-section h3 {
|
||||||
font-size: 1.73rem;
|
font-size: 1.3rem;
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
border-bottom: 2px solid var(--primary-color);
|
border-bottom: 2px solid var(--primary-color);
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.guide-text {
|
.guide-text {
|
||||||
font-size: 24px;
|
font-size: 18px;
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -127,8 +127,8 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: var(--primary-color);
|
background-color: var(--primary-color);
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 23px;
|
font-size: 17px;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -136,14 +136,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.flow-step .step-label {
|
.flow-step .step-label {
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
font-size: 24px;
|
font-size: 18px;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flow-step .step-desc {
|
.flow-step .step-desc {
|
||||||
font-size: 23px;
|
font-size: 17px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
@@ -159,13 +159,13 @@
|
|||||||
.guide-info-table {
|
.guide-info-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 24px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.guide-info-table th {
|
.guide-info-table th {
|
||||||
background: #f8faf9;
|
background: #f8faf9;
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
@@ -182,7 +182,7 @@
|
|||||||
background: var(--primary-light);
|
background: var(--primary-light);
|
||||||
border-left: 4px solid var(--primary-color);
|
border-left: 4px solid var(--primary-color);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
font-size: 24px;
|
font-size: 18px;
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,14 +36,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.login-header h2 {
|
.login-header h2 {
|
||||||
font-size: 2.33rem;
|
font-size: 1.75rem;
|
||||||
font-weight: 900;
|
font-weight: 800;
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-header p {
|
.login-header p {
|
||||||
font-size: 1.25rem;
|
font-size: 0.9375rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,14 +94,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.role-card h3 {
|
.role-card h3 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.125rem;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-card p {
|
.role-card p {
|
||||||
font-size: 1.08rem;
|
font-size: 0.8125rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
.login-footer {
|
.login-footer {
|
||||||
margin-top: 3rem;
|
margin-top: 3rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 1rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +1,127 @@
|
|||||||
/* --- Page Header for Description --- */
|
/* --- Page Header for Description --- */
|
||||||
.page-header {
|
.page-header {
|
||||||
padding: 1.5rem 2rem 0.5rem; /* Padding added for better whitespace */
|
padding: 1rem 0 0.2rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title-group {
|
.page-title-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
font-size: var(--fs-lg);
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: var(--primary);
|
color: var(--primary-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.1;
|
border-left: 4px solid var(--primary-color);
|
||||||
letter-spacing: -0.05em;
|
padding-left: 8px;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-description {
|
.page-description {
|
||||||
font-size: var(--fs-base);
|
font-size: 12px;
|
||||||
color: var(--mute);
|
color: var(--text-muted);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.5;
|
line-height: 1.4;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Table View & Filter Styles --- */
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem; /* 간격 축소 및 통일 */
|
||||||
|
padding: 1.2rem 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-item.flex-1 {
|
||||||
|
flex: 1; /* 검색창이 남은 공간을 채우도록 설정 */
|
||||||
|
min-width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem; /* 버튼들 간의 간격 */
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-actions .btn {
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-item label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-item input,
|
||||||
|
.search-item select {
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 1rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
background-color: var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 셀렉트 박스 화살표 여백 절대 고정 (수정 금지) */
|
||||||
|
.search-item select {
|
||||||
|
padding-right: 2.5rem !important;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-item input:focus,
|
||||||
|
.search-item select:focus {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 필터 초기화 버튼 크기 조정 (입력창 높이 38px에 맞춤) */
|
||||||
|
.btn-reset {
|
||||||
|
height: 38px !important;
|
||||||
|
color: var(--text-muted) !important;
|
||||||
|
padding: 0 1.2rem !important;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-left: 0; /* 불필요한 마진 제거 */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Table View Styles --- */
|
|
||||||
.table-container {
|
.table-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background-color: var(--canvas);
|
background-color: var(--white);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: separate;
|
border-collapse: separate;
|
||||||
border-spacing: 0;
|
border-spacing: 0;
|
||||||
|
table-layout: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
th, td {
|
th, td {
|
||||||
padding: 0.8rem 1rem;
|
padding: 0.8rem 1.2rem;
|
||||||
border-bottom: 1px solid var(--hairline);
|
border-bottom: 1px solid var(--border-color);
|
||||||
text-align: left;
|
text-align: left; /* 기본은 좌측 정렬 */
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,93 +132,93 @@ thead {
|
|||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
background-color: var(--canvas-soft) !important;
|
background-color: var(--bg-light) !important;
|
||||||
font-size: var(--fs-xs);
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--mute);
|
color: var(--text-muted);
|
||||||
text-transform: uppercase;
|
position: sticky;
|
||||||
letter-spacing: 0.05em;
|
top: 0;
|
||||||
box-shadow: inset 0 -1px 0 var(--hairline);
|
z-index: 50;
|
||||||
text-align: center; /* Set default header alignment to center */
|
box-shadow: inset 0 1px 0 var(--border-color), inset 0 -1px 0 var(--border-color); /* 상하 테두리 보정 */
|
||||||
|
text-transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
td {
|
td {
|
||||||
font-size: var(--fs-base);
|
font-size: 13px;
|
||||||
color: var(--primary);
|
color: var(--text-main);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
text-align: left; /* Set default data alignment to left */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody tr:hover {
|
tbody tr:hover {
|
||||||
background-color: var(--canvas-soft-2);
|
background-color: var(--bg-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 정렬 클래스 */
|
/* 정렬 클래스 강제 적용 */
|
||||||
.text-center { text-align: center !important; }
|
.text-center { text-align: center !important; }
|
||||||
.text-right { text-align: right !important; }
|
.text-right { text-align: right !important; }
|
||||||
.text-left { text-align: left !important; }
|
.text-left { text-align: left !important; }
|
||||||
|
|
||||||
/* 메모 컬럼 전용 */
|
/* 메모 컬럼 전용: 가장 길게 표시되도록 너비 조정 및 줄바꿈 허용 */
|
||||||
.col-memo {
|
.col-memo {
|
||||||
width: 25%;
|
width: 20%;
|
||||||
min-width: 300px;
|
min-width: 250px;
|
||||||
white-space: normal !important;
|
white-space: normal !important;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
line-height: 1.5;
|
line-height: 1.4;
|
||||||
|
text-align: left !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
padding: 0.25rem;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Table Sorting --- */
|
/* --- Table Sorting --- */
|
||||||
th.sortable {
|
th.sortable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
transition: background-color 0.2s;
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-right: 1.8rem !important;
|
padding-right: 1.8rem !important; /* 아이콘 공간 확보 */
|
||||||
}
|
}
|
||||||
|
|
||||||
th.sortable:hover {
|
th.sortable:hover {
|
||||||
background-color: var(--canvas-soft-2) !important;
|
background-color: #F3F4F6;
|
||||||
color: var(--primary);
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
th.sortable::after {
|
th.sortable::after {
|
||||||
content: '↕';
|
content: '↕';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0.75rem;
|
right: 0.6rem;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
font-size: var(--fs-xs);
|
font-size: 11px;
|
||||||
opacity: 0.4;
|
opacity: 0.3;
|
||||||
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
th.sortable.asc::after { content: '▲'; opacity: 1; color: var(--primary); }
|
th.sortable.asc::after {
|
||||||
th.sortable.desc::after { content: '▼'; opacity: 1; color: var(--primary); }
|
content: '▲';
|
||||||
|
opacity: 1;
|
||||||
/* --- Compact Table (Used in Dashboards/Modals) --- */
|
color: var(--primary-color);
|
||||||
.compact-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.compact-table th {
|
th.sortable.desc::after {
|
||||||
padding: 0.75rem 0.5rem;
|
content: '▼';
|
||||||
font-size: var(--fs-xs);
|
opacity: 1;
|
||||||
font-weight: 600;
|
color: var(--primary-color);
|
||||||
color: var(--mute);
|
|
||||||
border-bottom: 1px solid var(--hairline);
|
|
||||||
background: var(--canvas-soft);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compact-table td {
|
|
||||||
padding: 0.75rem 0.5rem;
|
|
||||||
font-size: var(--fs-sm);
|
|
||||||
border-bottom: 1px solid var(--hairline-soft, #f5f5f5);
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.compact-table tr.clickable-row:hover {
|
|
||||||
background: var(--canvas-soft);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,14 +18,6 @@ export function renderHwDashboard(container: HTMLElement) {
|
|||||||
|
|
||||||
// 2. 1페이지 매거진 리포트(제목바 제거, '| 제목' 미니멀리즘 스타일) HTML 빌드
|
// 2. 1페이지 매거진 리포트(제목바 제거, '| 제목' 미니멀리즘 스타일) HTML 빌드
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<<<<<<< HEAD
|
|
||||||
<div class="view-container bg-soft" style="padding: 1.5rem 2rem; height: calc(100vh - var(--header-height) - 28px); box-sizing: border-box; display: flex; flex-direction: column; gap: 1.25rem;">
|
|
||||||
|
|
||||||
<!-- 대시보드 타이틀 및 사용조직 필터 -->
|
|
||||||
<div class="flex justify-between items-end flex-shrink-0 mb-4">
|
|
||||||
<div style="border-left: 4px solid var(--primary); padding-left: 8px;">
|
|
||||||
<h2 class="dashboard-section-title mb-0">개인 PC 자산 대시보드</h2>
|
|
||||||
=======
|
|
||||||
<div class="view-container" style="overflow: hidden; padding: 0.4rem 1.2rem; background-color: #F8FAFC; height: calc(100vh - var(--header-height) - 48px); box-sizing: border-box; display: flex; flex-direction: column; gap: 0.5rem; font-family: 'Pretendard', sans-serif; color: #1E293B;">
|
<div class="view-container" style="overflow: hidden; padding: 0.4rem 1.2rem; background-color: #F8FAFC; height: calc(100vh - var(--header-height) - 48px); box-sizing: border-box; display: flex; flex-direction: column; gap: 0.5rem; font-family: 'Pretendard', sans-serif; color: #1E293B;">
|
||||||
|
|
||||||
<!-- 대시보드 타이틀 및 사용조직 필터 -->
|
<!-- 대시보드 타이틀 및 사용조직 필터 -->
|
||||||
@@ -34,20 +26,19 @@ export function renderHwDashboard(container: HTMLElement) {
|
|||||||
<h2 style="font-size: 1.65rem; font-weight: 850; color: #1E5149; margin: 0; letter-spacing: -0.5px; display: flex; align-items: center; gap: 0.6rem;">
|
<h2 style="font-size: 1.65rem; font-weight: 850; color: #1E5149; margin: 0; letter-spacing: -0.5px; display: flex; align-items: center; gap: 0.6rem;">
|
||||||
개인 PC 자산 대시보드
|
개인 PC 자산 대시보드
|
||||||
</h2>
|
</h2>
|
||||||
>>>>>>> origin/main
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 사용조직 필터 (브랜드 그린 매칭 칩 디자인) -->
|
<!-- 사용조직 필터 (브랜드 그린 매칭 칩 디자인) -->
|
||||||
<div class="flex items-center gap-3">
|
<div style="display: flex; align-items: center; gap: 0.75rem;">
|
||||||
<span class="detail-label-sm font-bold">조직 필터:</span>
|
<span style="font-size: 0.9rem; font-weight: 700; color: #475569; white-space: nowrap;">조직 필터:</span>
|
||||||
<div id="dashboard-dept-buttons" class="flex gap-1 p-1 bg-canvas-soft border border-hairline rounded-lg">
|
<div id="dashboard-dept-buttons" style="display: flex; gap: 0.3rem; background: #EEF2F6; padding: 4px; border-radius: 8px; border: 1px solid #E2E8F0;">
|
||||||
<button class="dept-filter-btn active" data-dept="">전체</button>
|
<button class="dept-filter-btn active" data-dept="" style="padding: 6px 14px; font-size: 0.88rem; font-weight: 700; border-radius: 6px; border: none; background: #1E5149; color: white; cursor: pointer; transition: all 0.2s;">전체</button>
|
||||||
<button class="dept-filter-btn" data-dept="한맥">한맥</button>
|
<button class="dept-filter-btn" data-dept="한맥" style="padding: 6px 14px; font-size: 0.88rem; font-weight: 700; border-radius: 6px; border: none; background: transparent; color: #475569; cursor: pointer; transition: all 0.2s;">한맥</button>
|
||||||
<button class="dept-filter-btn" data-dept="삼안">삼안</button>
|
<button class="dept-filter-btn" data-dept="삼안" style="padding: 6px 14px; font-size: 0.88rem; font-weight: 700; border-radius: 6px; border: none; background: transparent; color: #475569; cursor: pointer; transition: all 0.2s;">삼안</button>
|
||||||
<button class="dept-filter-btn" data-dept="장헌">장헌</button>
|
<button class="dept-filter-btn" data-dept="장헌" style="padding: 6px 14px; font-size: 0.88rem; font-weight: 700; border-radius: 6px; border: none; background: transparent; color: #475569; cursor: pointer; transition: all 0.2s;">장헌</button>
|
||||||
<button class="dept-filter-btn" data-dept="한라">한라</button>
|
<button class="dept-filter-btn" data-dept="한라" style="padding: 6px 14px; font-size: 0.88rem; font-weight: 700; border-radius: 6px; border: none; background: transparent; color: #475569; cursor: pointer; transition: all 0.2s;">한라</button>
|
||||||
<button class="dept-filter-btn" data-dept="기술개발센터">기술개발센터</button>
|
<button class="dept-filter-btn" data-dept="기술개발센터" style="padding: 6px 14px; font-size: 0.88rem; font-weight: 700; border-radius: 6px; border: none; background: transparent; color: #475569; cursor: pointer; transition: all 0.2s;">기술개발센터</button>
|
||||||
<button class="dept-filter-btn" data-dept="총괄기획실">총괄기획실</button>
|
<button class="dept-filter-btn" data-dept="총괄기획실" style="padding: 6px 14px; font-size: 0.88rem; font-weight: 700; border-radius: 6px; border: none; background: transparent; color: #475569; cursor: pointer; transition: all 0.2s;">총괄기획실</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,17 +47,6 @@ export function renderHwDashboard(container: HTMLElement) {
|
|||||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; flex: 1; min-height: 0; margin-bottom: 0.1rem;">
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; flex: 1; min-height: 0; margin-bottom: 0.1rem;">
|
||||||
|
|
||||||
<!-- 좌측 컬럼 (Left Column) -->
|
<!-- 좌측 컬럼 (Left Column) -->
|
||||||
<<<<<<< HEAD
|
|
||||||
<div class="flex-col gap-4 min-h-0">
|
|
||||||
|
|
||||||
<!-- 상단 핵심 지표 그룹 카드 (1개 카드로 통합, 4개 지표 가로 배치) -->
|
|
||||||
<div class="stat-card border-b border-hairline pb-4 flex flex-row items-center justify-between flex-shrink-0 gap-0">
|
|
||||||
|
|
||||||
<!-- 1. 보유 자산 수량 -->
|
|
||||||
<div class="flex-1 border-r border-hairline pr-4">
|
|
||||||
<div style="border-left: 4px solid var(--primary); padding-left: 8px; margin-bottom: 0.5rem;" class="flex items-center">
|
|
||||||
<span class="detail-label-sm font-bold text-primary">보유 자산 수량</span>
|
|
||||||
=======
|
|
||||||
<div style="display: flex; flex-direction: column; gap: 0.5rem; min-height: 0;">
|
<div style="display: flex; flex-direction: column; gap: 0.5rem; min-height: 0;">
|
||||||
|
|
||||||
<!-- 핵심 지표 카드 -->
|
<!-- 핵심 지표 카드 -->
|
||||||
@@ -76,91 +56,50 @@ export function renderHwDashboard(container: HTMLElement) {
|
|||||||
<div style="border-right: 1px solid #EEF2F6; border-bottom: 1px solid #EEF2F6; padding-bottom: 0.65rem; padding-right: 1.0rem;">
|
<div style="border-right: 1px solid #EEF2F6; border-bottom: 1px solid #EEF2F6; padding-bottom: 0.65rem; padding-right: 1.0rem;">
|
||||||
<div style="border-left: 4px solid #1E5149; padding-left: 8px; margin-bottom: 0.5rem; display: flex; align-items: center; line-height: 1;">
|
<div style="border-left: 4px solid #1E5149; padding-left: 8px; margin-bottom: 0.5rem; display: flex; align-items: center; line-height: 1;">
|
||||||
<span style="font-size: 1.15rem; font-weight: 800; color: #1E293B; white-space: nowrap;">보유 자산 수량</span>
|
<span style="font-size: 1.15rem; font-weight: 800; color: #1E293B; white-space: nowrap;">보유 자산 수량</span>
|
||||||
>>>>>>> origin/main
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-end justify-between">
|
<div style="display: flex; align-items: flex-end; justify-content: space-between;">
|
||||||
<div>
|
<div>
|
||||||
<<<<<<< HEAD
|
|
||||||
<div id="metric-total-pcs" class="stat-value" style="line-height: 1; margin-bottom: 0.2rem;">0대</div>
|
|
||||||
<span class="detail-label-sm text-muted">전사 보유 개인용 PC</span>
|
|
||||||
=======
|
|
||||||
<div id="metric-total-pcs" style="font-size: 2.3rem; font-weight: 900; color: #1E5149; line-height: 1; margin-bottom: 0.35rem;">0대</div>
|
<div id="metric-total-pcs" style="font-size: 2.3rem; font-weight: 900; color: #1E5149; line-height: 1; margin-bottom: 0.35rem;">0대</div>
|
||||||
<span style="font-size: 1.05rem; color: #64748B; font-weight: 700; white-space: nowrap;">전사 보유 개인용 PC</span>
|
<span style="font-size: 1.05rem; color: #64748B; font-weight: 700; white-space: nowrap;">전사 보유 개인용 PC</span>
|
||||||
>>>>>>> origin/main
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
<!-- 2. 사양 부족 검토 -->
|
|
||||||
<div id="card-under-spec" class="flex-1 border-r border-hairline px-4 cursor-pointer hover:opacity-70 transition-opacity">
|
|
||||||
<div style="border-left: 4px solid var(--danger); padding-left: 8px; margin-bottom: 0.5rem;" class="flex items-center">
|
|
||||||
<span class="detail-label-sm font-bold text-primary">사양 부족 검토</span>
|
|
||||||
=======
|
|
||||||
<!-- 2. 사양 부족 -->
|
<!-- 2. 사양 부족 -->
|
||||||
<div id="card-under-spec" style="border-bottom: 1px solid #EEF2F6; padding-bottom: 0.65rem; padding-left: 1.0rem; cursor: pointer; transition: opacity 0.2s;" onmouseover="this.style.opacity='0.7'" onmouseout="this.style.opacity='1'">
|
<div id="card-under-spec" style="border-bottom: 1px solid #EEF2F6; padding-bottom: 0.65rem; padding-left: 1.0rem; cursor: pointer; transition: opacity 0.2s;" onmouseover="this.style.opacity='0.7'" onmouseout="this.style.opacity='1'">
|
||||||
<div style="border-left: 4px solid #EF4444; padding-left: 8px; margin-bottom: 0.5rem; display: flex; align-items: center; line-height: 1;">
|
<div style="border-left: 4px solid #EF4444; padding-left: 8px; margin-bottom: 0.5rem; display: flex; align-items: center; line-height: 1;">
|
||||||
<span style="font-size: 1.15rem; font-weight: 800; color: #1E293B; white-space: nowrap;">사양 부족</span>
|
<span style="font-size: 1.15rem; font-weight: 800; color: #1E293B; white-space: nowrap;">사양 부족</span>
|
||||||
>>>>>>> origin/main
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-end justify-between">
|
<div style="display: flex; align-items: flex-end; justify-content: space-between;">
|
||||||
<div>
|
<div>
|
||||||
<<<<<<< HEAD
|
|
||||||
<div id="metric-under-spec" class="stat-value text-danger" style="line-height: 1; margin-bottom: 0.2rem;">0명</div>
|
|
||||||
<span class="detail-label-sm text-muted">사양 교체 권고 자산</span>
|
|
||||||
=======
|
|
||||||
<div id="metric-under-spec" style="font-size: 2.3rem; font-weight: 900; color: #EF4444; line-height: 1; margin-bottom: 0.35rem;">0대</div>
|
<div id="metric-under-spec" style="font-size: 2.3rem; font-weight: 900; color: #EF4444; line-height: 1; margin-bottom: 0.35rem;">0대</div>
|
||||||
<span style="font-size: 1.05rem; color: #64748B; font-weight: 700; white-space: nowrap;">사양 교체 권고 자산</span>
|
<span style="font-size: 1.05rem; color: #64748B; font-weight: 700; white-space: nowrap;">사양 교체 권고 자산</span>
|
||||||
>>>>>>> origin/main
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
<!-- 3. 오버스펙 검토 -->
|
|
||||||
<div id="card-over-spec" class="flex-1 border-r border-hairline px-4 cursor-pointer hover:opacity-70 transition-opacity">
|
|
||||||
<div style="border-left: 4px solid var(--color-orange); padding-left: 8px; margin-bottom: 0.5rem;" class="flex items-center">
|
|
||||||
<span class="detail-label-sm font-bold text-primary">오버스펙 검토</span>
|
|
||||||
=======
|
|
||||||
<!-- 3. 오버 스펙 -->
|
<!-- 3. 오버 스펙 -->
|
||||||
<div id="card-over-spec" style="border-right: 1px solid #EEF2F6; padding-top: 0.65rem; padding-right: 1.0rem; cursor: pointer; transition: opacity 0.2s;" onmouseover="this.style.opacity='0.7'" onmouseout="this.style.opacity='1'">
|
<div id="card-over-spec" style="border-right: 1px solid #EEF2F6; padding-top: 0.65rem; padding-right: 1.0rem; cursor: pointer; transition: opacity 0.2s;" onmouseover="this.style.opacity='0.7'" onmouseout="this.style.opacity='1'">
|
||||||
<div style="border-left: 4px solid #F59E0B; padding-left: 8px; margin-bottom: 0.5rem; display: flex; align-items: center; line-height: 1;">
|
<div style="border-left: 4px solid #F59E0B; padding-left: 8px; margin-bottom: 0.5rem; display: flex; align-items: center; line-height: 1;">
|
||||||
<span style="font-size: 1.15rem; font-weight: 800; color: #1E293B; white-space: nowrap;">오버 스펙</span>
|
<span style="font-size: 1.15rem; font-weight: 800; color: #1E293B; white-space: nowrap;">오버 스펙</span>
|
||||||
>>>>>>> origin/main
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-end justify-between">
|
<div style="display: flex; align-items: flex-end; justify-content: space-between;">
|
||||||
<div>
|
<div>
|
||||||
<<<<<<< HEAD
|
|
||||||
<div id="metric-over-spec" class="stat-value text-orange" style="line-height: 1; margin-bottom: 0.2rem;">0명</div>
|
|
||||||
<span class="detail-label-sm text-muted">사양 회수 권고 자산</span>
|
|
||||||
=======
|
|
||||||
<div id="metric-over-spec" style="font-size: 2.3rem; font-weight: 900; color: #F59E0B; line-height: 1; margin-bottom: 0.35rem;">0대</div>
|
<div id="metric-over-spec" style="font-size: 2.3rem; font-weight: 900; color: #F59E0B; line-height: 1; margin-bottom: 0.35rem;">0대</div>
|
||||||
<span style="font-size: 1.05rem; color: #64748B; font-weight: 700; white-space: nowrap;">사양 회수 권고 자산</span>
|
<span style="font-size: 1.05rem; color: #64748B; font-weight: 700; white-space: nowrap;">사양 회수 권고 자산</span>
|
||||||
>>>>>>> origin/main
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 4. 윈도우 11 불가 PC -->
|
<!-- 4. 윈도우 11 불가 PC -->
|
||||||
<<<<<<< HEAD
|
|
||||||
<div id="card-win11-incompatible" class="flex-1 pl-4 cursor-pointer hover:opacity-70 transition-opacity">
|
|
||||||
<div style="border-left: 4px solid var(--color-blue); padding-left: 8px; margin-bottom: 0.5rem;" class="flex items-center">
|
|
||||||
<span class="detail-label-sm font-bold text-primary">윈도우 11 불가 PC</span>
|
|
||||||
=======
|
|
||||||
<div id="card-win11-incompatible" style="padding-top: 0.65rem; padding-left: 1.0rem; cursor: pointer; transition: opacity 0.2s;" onmouseover="this.style.opacity='0.7'" onmouseout="this.style.opacity='1'">
|
<div id="card-win11-incompatible" style="padding-top: 0.65rem; padding-left: 1.0rem; cursor: pointer; transition: opacity 0.2s;" onmouseover="this.style.opacity='0.7'" onmouseout="this.style.opacity='1'">
|
||||||
<div style="border-left: 4px solid #3B82F6; padding-left: 8px; margin-bottom: 0.5rem; display: flex; align-items: center; line-height: 1;">
|
<div style="border-left: 4px solid #3B82F6; padding-left: 8px; margin-bottom: 0.5rem; display: flex; align-items: center; line-height: 1;">
|
||||||
<span style="font-size: 1.15rem; font-weight: 800; color: #1E293B; white-space: nowrap;">윈도우 11 불가 PC</span>
|
<span style="font-size: 1.15rem; font-weight: 800; color: #1E293B; white-space: nowrap;">윈도우 11 불가 PC</span>
|
||||||
>>>>>>> origin/main
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-end justify-between">
|
<div style="display: flex; align-items: flex-end; justify-content: space-between;">
|
||||||
<div>
|
<div>
|
||||||
<<<<<<< HEAD
|
|
||||||
<div id="metric-win11-incompatible" class="stat-value text-blue" style="line-height: 1; margin-bottom: 0.2rem;">0대</div>
|
|
||||||
<span class="detail-label-sm text-muted">업데이트 미지원 하드웨어</span>
|
|
||||||
=======
|
|
||||||
<div id="metric-win11-incompatible" style="font-size: 2.3rem; font-weight: 900; color: #3B82F6; line-height: 1; margin-bottom: 0.35rem;">0대</div>
|
<div id="metric-win11-incompatible" style="font-size: 2.3rem; font-weight: 900; color: #3B82F6; line-height: 1; margin-bottom: 0.35rem;">0대</div>
|
||||||
<span style="font-size: 1.05rem; color: #64748B; font-weight: 700; white-space: nowrap;">업데이트 미지원 하드웨어</span>
|
<span style="font-size: 1.05rem; color: #64748B; font-weight: 700; white-space: nowrap;">업데이트 미지원 하드웨어</span>
|
||||||
>>>>>>> origin/main
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,64 +107,6 @@ export function renderHwDashboard(container: HTMLElement) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
<!-- PC 성능 등급별 분포 현황 (등급별 게이지 + 우측 사양 적정성 도넛차트) -->
|
|
||||||
<div class="border-b border-hairline flex flex-row items-center gap-6" style="padding: 1.25rem 0.25rem; border: none; border-bottom: 1px solid var(--hairline); flex: 1.1; min-height: 0;">
|
|
||||||
|
|
||||||
<!-- 1열: 등급별 보유 현황 리스트 영역 -->
|
|
||||||
<div class="flex-1 flex flex-col gap-4 justify-center pl-2">
|
|
||||||
<!-- 메인 제목 -->
|
|
||||||
<div style="border-left: 4px solid var(--primary); padding-left: 8px; margin-bottom: 0.35rem;" class="flex items-center">
|
|
||||||
<span class="detail-label-sm font-bold text-primary">PC 성능 등급별 분포 현황</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 등급 리스트 (바 그래프 제거 및 폰트 확대, 간격 조정) -->
|
|
||||||
<div class="flex flex-col gap-1 py-1">
|
|
||||||
<!-- 최상급 -->
|
|
||||||
<div id="grade-premium" class="cursor-pointer flex flex-col p-1 hover:bg-canvas-soft rounded transition-all">
|
|
||||||
<div class="flex items-center gap-3" style="font-size: 1.25rem; font-weight: 800;">
|
|
||||||
<span style="color: #11302B; white-space: nowrap; width: 220px; display: inline-block;">최상급 PC (85점 이상)</span>
|
|
||||||
<span class="grade-info flex items-center gap-1 text-primary"><span class="grade-count">0대</span><span class="grade-rate text-muted text-lg">(0%)</span></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- 상급 -->
|
|
||||||
<div id="grade-high" class="cursor-pointer flex flex-col p-1 hover:bg-canvas-soft rounded transition-all">
|
|
||||||
<div class="flex items-center gap-3" style="font-size: 1.25rem; font-weight: 800;">
|
|
||||||
<span style="color: #1E8E7C; white-space: nowrap; width: 220px; display: inline-block;">상급 PC (70점 ~ 85점)</span>
|
|
||||||
<span class="grade-info flex items-center gap-1 text-primary"><span class="grade-count">0대</span><span class="grade-rate text-muted text-lg">(0%)</span></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- 중급 -->
|
|
||||||
<div id="grade-normal" class="cursor-pointer flex flex-col p-1 hover:bg-canvas-soft rounded transition-all">
|
|
||||||
<div class="flex items-center gap-3" style="font-size: 1.25rem; font-weight: 800;">
|
|
||||||
<span style="color: #10B981; white-space: nowrap; width: 220px; display: inline-block;">중급 PC (40점 ~ 70점)</span>
|
|
||||||
<span class="grade-info flex items-center gap-1 text-primary"><span class="grade-count">0대</span><span class="grade-rate text-muted text-lg">(0%)</span></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- 보급 -->
|
|
||||||
<div id="grade-entry" class="cursor-pointer flex flex-col p-1 hover:bg-canvas-soft rounded transition-all">
|
|
||||||
<div class="flex items-center gap-3" style="font-size: 1.25rem; font-weight: 800;">
|
|
||||||
<span style="color: #64748B; white-space: nowrap; width: 220px; display: inline-block;">보급 PC (40점 미만)</span>
|
|
||||||
<span class="grade-info flex items-center gap-1 text-primary"><span class="grade-count">0대</span><span class="grade-rate text-muted text-lg">(0%)</span></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 2열: 등급별 보유 비율 도넛 영역 -->
|
|
||||||
<div class="flex flex-col items-center justify-center gap-3">
|
|
||||||
<div class="detail-label-sm font-bold text-muted uppercase">등급별 보유 비율</div>
|
|
||||||
<div class="flex flex-col items-center justify-center flex-shrink-0 w-full">
|
|
||||||
<div style="width: 160px; height: 140px; position: relative;">
|
|
||||||
<canvas id="chart-overall-donut"></canvas>
|
|
||||||
</div>
|
|
||||||
<!-- 커스텀 범례 -->
|
|
||||||
<div class="flex gap-2 justify-center items-center mt-1 font-bold text-xs text-muted">
|
|
||||||
<div class="flex items-center gap-1"><span class="w-2 h-2 rounded-full" style="background: #11302B;"></span>최상</div>
|
|
||||||
<div class="flex items-center gap-1"><span class="w-2 h-2 rounded-full" style="background: #1E8E7C;"></span>상</div>
|
|
||||||
<div class="flex items-center gap-1"><span class="w-2 h-2 rounded-full" style="background: #10B981;"></span>중</div>
|
|
||||||
<div class="flex items-center gap-1"><span class="w-2 h-2 rounded-full" style="background: #94A3B8;"></span>보급</div>
|
|
||||||
=======
|
|
||||||
<!-- 등급별 자산 종합 현황 (좌측 하단 단독 배치 및 크기 확대) -->
|
<!-- 등급별 자산 종합 현황 (좌측 하단 단독 배치 및 크기 확대) -->
|
||||||
<div style="background: transparent; border-radius: 0; padding: 0.75rem 0.25rem; border: none; border-bottom: 1px solid #E2E8F0; display: flex; flex-direction: column; flex: 1.0; min-height: 0;">
|
<div style="background: transparent; border-radius: 0; padding: 0.75rem 0.25rem; border: none; border-bottom: 1px solid #E2E8F0; display: flex; flex-direction: column; flex: 1.0; min-height: 0;">
|
||||||
|
|
||||||
@@ -308,40 +189,10 @@ export function renderHwDashboard(container: HTMLElement) {
|
|||||||
<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #EF4444;"></span>
|
<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #EF4444;"></span>
|
||||||
<span>교체 대상</span>
|
<span>교체 대상</span>
|
||||||
</div>
|
</div>
|
||||||
>>>>>>> origin/main
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
<!-- 유효 재고 현황 -->
|
|
||||||
<div class="flex flex-col gap-4 flex-1 min-h-0" style="padding: 1.25rem 0.25rem; border-bottom: 1px solid var(--hairline);">
|
|
||||||
<div style="border-left: 4px solid var(--primary); padding-left: 8px; margin-bottom: 0.35rem;" class="flex items-center">
|
|
||||||
<span class="detail-label-sm font-bold text-primary">유효 재고 현황</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-[1fr,1px,1fr] gap-2 flex-1 items-center">
|
|
||||||
<div class="flex flex-col gap-4 w-full">
|
|
||||||
<div id="stock-premium-card" class="text-center w-full cursor-pointer hover:opacity-70 transition-opacity">
|
|
||||||
<div class="summary-grade-stock-premium stat-value" style="color: #11302B;">0대</div>
|
|
||||||
<span class="detail-label-sm font-bold text-muted">최상급 재고</span>
|
|
||||||
</div>
|
|
||||||
<div id="stock-normal-card" class="text-center w-full cursor-pointer hover:opacity-70 transition-opacity">
|
|
||||||
<div class="summary-grade-stock-normal stat-value" style="color: #10B981;">0대</div>
|
|
||||||
<span class="detail-label-sm font-bold text-muted">중급 재고</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="w-px h-4/5 bg-hairline self-center"></div>
|
|
||||||
<div class="flex flex-col gap-4 w-full">
|
|
||||||
<div id="stock-high-card" class="text-center w-full cursor-pointer hover:opacity-70 transition-opacity">
|
|
||||||
<div class="summary-grade-stock-high stat-value" style="color: #1E8E7C;">0대</div>
|
|
||||||
<span class="detail-label-sm font-bold text-muted">상급 재고</span>
|
|
||||||
</div>
|
|
||||||
<div id="stock-entry-card" class="text-center w-full cursor-pointer hover:opacity-70 transition-opacity">
|
|
||||||
<div class="summary-grade-stock-entry stat-value" style="color: #94A3B8;">0대</div>
|
|
||||||
<span class="detail-label-sm font-bold text-muted">보급 재고</span>
|
|
||||||
</div>
|
|
||||||
=======
|
|
||||||
<!-- 2열: 연도별 PC 노후도 및 교체 주기 예측 카드 (너비 줄임) -->
|
<!-- 2열: 연도별 PC 노후도 및 교체 주기 예측 카드 (너비 줄임) -->
|
||||||
<div style="display: flex; flex-direction: column; min-height: 0;">
|
<div style="display: flex; flex-direction: column; min-height: 0;">
|
||||||
<div style="border-left: 4px solid #1E5149; padding-left: 8px; margin-bottom: 0.5rem; display: flex; align-items: center; line-height: 1; flex-shrink: 0; height: 1.5rem;">
|
<div style="border-left: 4px solid #1E5149; padding-left: 8px; margin-bottom: 0.5rem; display: flex; align-items: center; line-height: 1; flex-shrink: 0; height: 1.5rem;">
|
||||||
@@ -360,58 +211,14 @@ export function renderHwDashboard(container: HTMLElement) {
|
|||||||
<!-- Dynamic Aging Contents -->
|
<!-- Dynamic Aging Contents -->
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
>>>>>>> origin/main
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<<<<<<< HEAD
|
|
||||||
|
|
||||||
<!-- 우측 컬럼 (Right Column) -->
|
|
||||||
<div class="flex-col gap-4 min-h-0">
|
|
||||||
|
|
||||||
<!-- 직무별 사양 적정성 분석 차트 카드 -->
|
|
||||||
<div class="flex flex-col flex-1 min-h-0" style="padding: 1.5rem 0.25rem; border-bottom: 1px solid var(--hairline);">
|
|
||||||
<div style="border-left: 4px solid var(--primary); padding-left: 8px; margin-bottom: 0.9rem;" class="flex items-center flex-shrink-0">
|
|
||||||
<span class="detail-label-sm font-bold text-primary">직무별 사양 적정성 분석</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-h-0 w-full relative">
|
|
||||||
<canvas id="chart-job-scores" style="width: 100%; height: 100%;"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 연도별 PC 노후도 및 교체 주기 예측 카드 -->
|
|
||||||
<div class="flex flex-col flex-1 min-h-0" style="padding: 1.5rem 0.25rem; border-bottom: 1px solid var(--hairline);">
|
|
||||||
<div style="border-left: 4px solid var(--primary); padding-left: 8px; margin-bottom: 0.9rem;" class="flex items-center flex-shrink-0">
|
|
||||||
<span class="detail-label-sm font-bold text-primary">연도별 PC 노후도 및 교체 주기 예측</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 overflow-hidden min-h-0">
|
|
||||||
<table class="compact-table w-full text-left">
|
|
||||||
<thead class="sticky top-0 bg-canvas z-10">
|
|
||||||
<tr class="border-b-2 border-primary text-muted font-bold">
|
|
||||||
<th class="p-2 w-1/2">구분 (사용 연한)</th>
|
|
||||||
<th class="p-2 w-1/4 text-center">보유 대수</th>
|
|
||||||
<th class="p-2 w-1/4 text-center">권장 조치</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="pc-aging-tbody"></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
=======
|
|
||||||
>>>>>>> origin/main
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<style>
|
|
||||||
.dept-filter-btn { padding: 6px 14px; font-size: 0.85rem; font-weight: 700; border-radius: 6px; border: none; background: transparent; color: var(--mute); cursor: pointer; transition: all 0.2s; }
|
|
||||||
.dept-filter-btn.active { background: var(--primary); color: var(--on-primary); }
|
|
||||||
.aging-row:hover { background: var(--canvas-soft); }
|
|
||||||
.donut-text-overlay { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -46%); font-size: 1.25rem; font-weight: 900; color: var(--primary); pointer-events: none; white-space: nowrap; }
|
|
||||||
</style>
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// 3. Lucide 아이콘 초기화
|
// 3. Lucide 아이콘 초기화
|
||||||
|
|||||||
@@ -33,38 +33,38 @@ export function renderSwDashboard(container: HTMLElement) {
|
|||||||
const intPer = intQty > 0 ? Math.round((intUsed/intQty)*100) : 0;
|
const intPer = intQty > 0 ? Math.round((intUsed/intQty)*100) : 0;
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="view-container bg-soft">
|
<div class="view-container">
|
||||||
<h3 class="dashboard-section-title">소프트웨어 라이선스 현황</h3>
|
<h3 class="dashboard-section-title">소프트웨어 라이선스 현황</h3>
|
||||||
|
|
||||||
<div class="dashboard-layout-2col mb-6">
|
<div class="dashboard-layout-2col" style="margin-bottom: 1.5rem;">
|
||||||
<div class="dashboard-card clickable" data-action="ext-usage">
|
<div class="dashboard-card" data-action="ext-usage" style="cursor:pointer; min-height:auto;">
|
||||||
<div class="stat-label">외부 소프트웨어 사용율</div>
|
<span style="font-size:1.21rem; font-weight:700; color:var(--text-main);">외부 소프트웨어 사용율</span>
|
||||||
<div class="stat-sub">${extQty}카피 중 ${extUsed}개 할당</div>
|
<div style="font-size: 1.02rem; color:var(--text-muted); margin-bottom: 1rem;">${extQty}카피 중 ${extUsed}개 할당</div>
|
||||||
<div class="stat-value text-primary">${extPer}%</div>
|
<div style="font-size: 2.21rem; font-weight:700; color:var(--dash-primary);">${extPer}%</div>
|
||||||
<div class="stat-progress-bar">
|
<div style="width: 100%; height: 4px; background-color: var(--border-color); border-radius: 2px; overflow: hidden; margin-top: 0.5rem;">
|
||||||
<div class="progress-fill" style="width: ${extPer}%;"></div>
|
<div style="width: ${extPer}%; height: 100%; background-color: var(--dash-primary);"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-card clickable" data-action="int-usage">
|
<div class="dashboard-card" data-action="int-usage" style="cursor:pointer; min-height:auto;">
|
||||||
<div class="stat-label">내부 소프트웨어 현황</div>
|
<span style="font-size:1.21rem; font-weight:700; color:var(--text-main);">내부 소프트웨어 현황</span>
|
||||||
<div class="stat-sub">등록된 내부 솔루션: ${intTotal}개</div>
|
<div style="font-size: 1.02rem; color:var(--text-muted); margin-bottom: 1rem;">등록된 내부 솔루션: ${intTotal}개</div>
|
||||||
<div class="stat-value text-primary">${intPer}%</div>
|
<div style="font-size: 2.21rem; font-weight:700; color:var(--dash-primary);">${intPer}%</div>
|
||||||
<div class="stat-progress-bar">
|
<div style="width: 100%; height: 4px; background-color: var(--border-color); border-radius: 2px; overflow: hidden; margin-top: 0.5rem;">
|
||||||
<div class="progress-fill" style="width: ${intPer}%;"></div>
|
<div style="width: ${intPer}%; height: 100%; background-color: var(--dash-primary);"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="dashboard-section-title">2026년 누적 도입 비용 분석</h3>
|
<h3 class="dashboard-section-title">2026년 누적 도입 비용 분석</h3>
|
||||||
|
|
||||||
<div class="dashboard-layout-2col">
|
<div style="display:grid; grid-template-columns: repeat(2, 1fr); gap:1.5rem; margin-bottom:1.5rem;">
|
||||||
<div class="dashboard-card">
|
<div class="dashboard-card" style="min-height:auto;">
|
||||||
<div class="stat-label">외부 SW 누적 비용 (2026)</div>
|
<span style="font-size:1.21rem; font-weight:700; color:var(--text-main);">외부 SW 누적 비용 (2026)</span>
|
||||||
<div class="stat-value text-primary">₩ ${extCost2026.toLocaleString()}</div>
|
<div style="font-size: 2.21rem; font-weight:700; color:var(--dash-primary);">₩ ${extCost2026.toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-card">
|
<div class="dashboard-card" style="min-height:auto;">
|
||||||
<div class="stat-label">내부 SW 누적 비용 (2026)</div>
|
<span style="font-size:1.21rem; font-weight:700; color:var(--text-main);">내부 SW 누적 비용 (2026)</span>
|
||||||
<div class="stat-value text-blue">₩ ${intCost2026.toLocaleString()}</div>
|
<div style="font-size: 2.21rem; font-weight:700; color:#3b82f6;">₩ ${intCost2026.toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -168,3 +168,4 @@ function renderSubTabs(container: HTMLElement) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,11 +22,10 @@ export function renderServerList(container: HTMLElement) {
|
|||||||
onRowClick: (asset) => openHwModal(asset, 'view'),
|
onRowClick: (asset) => openHwModal(asset, 'view'),
|
||||||
columns: [
|
columns: [
|
||||||
{ header: ASSET_SCHEMA.CURRENT_DEPT.ui, sortKey: ASSET_SCHEMA.CURRENT_DEPT.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_DEPT.key] || '-' },
|
{ header: ASSET_SCHEMA.CURRENT_DEPT.ui, sortKey: ASSET_SCHEMA.CURRENT_DEPT.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_DEPT.key] || '-' },
|
||||||
{ header: ASSET_SCHEMA.ASSET_PURPOSE.ui, sortKey: ASSET_SCHEMA.ASSET_PURPOSE.key, align: 'center', width: '15%', render: a => formatInline(a[ASSET_SCHEMA.ASSET_PURPOSE.key] || '-') },
|
{ header: ASSET_SCHEMA.ASSET_PURPOSE.ui, sortKey: ASSET_SCHEMA.ASSET_PURPOSE.key, width: '15%', render: a => formatInline(a[ASSET_SCHEMA.ASSET_PURPOSE.key] || '-') },
|
||||||
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
|
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
|
||||||
{
|
{
|
||||||
header: '모델/메인보드',
|
header: '모델/메인보드',
|
||||||
align: 'center',
|
|
||||||
width: '15%',
|
width: '15%',
|
||||||
render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || a[ASSET_SCHEMA.ASSET_NAME.key] || a[ASSET_SCHEMA.MAINBOARD.key] || '-')
|
render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || a[ASSET_SCHEMA.ASSET_NAME.key] || a[ASSET_SCHEMA.MAINBOARD.key] || '-')
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function renderStorageList(container: HTMLElement) {
|
|||||||
{ header: ASSET_SCHEMA.CURRENT_USER.ui, sortKey: ASSET_SCHEMA.CURRENT_USER.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_USER.key] || '-' },
|
{ header: ASSET_SCHEMA.CURRENT_USER.ui, sortKey: ASSET_SCHEMA.CURRENT_USER.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_USER.key] || '-' },
|
||||||
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
|
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
|
||||||
{ header: ASSET_SCHEMA.VOLUME.ui, sortKey: ASSET_SCHEMA.VOLUME.key, align: 'center', render: a => a[ASSET_SCHEMA.VOLUME.key] || '-' },
|
{ header: ASSET_SCHEMA.VOLUME.ui, sortKey: ASSET_SCHEMA.VOLUME.key, align: 'center', render: a => a[ASSET_SCHEMA.VOLUME.key] || '-' },
|
||||||
{ header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, align: 'center', render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || a[ASSET_SCHEMA.ASSET_NAME.key] || '-') },
|
{ header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || a[ASSET_SCHEMA.ASSET_NAME.key] || '-') },
|
||||||
{ header: ASSET_SCHEMA.SERIAL_NUM.ui, sortKey: ASSET_SCHEMA.SERIAL_NUM.key, align: 'center', render: a => a[ASSET_SCHEMA.SERIAL_NUM.key] || '-' },
|
{ header: ASSET_SCHEMA.SERIAL_NUM.ui, sortKey: ASSET_SCHEMA.SERIAL_NUM.key, align: 'center', render: a => a[ASSET_SCHEMA.SERIAL_NUM.key] || '-' },
|
||||||
{
|
{
|
||||||
header: ASSET_SCHEMA.LOCATION.ui,
|
header: ASSET_SCHEMA.LOCATION.ui,
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import { ASSET_SCHEMA } from '../core/schema';
|
|||||||
import { LOCATION_DATA, IMAGE_LOCATIONS } from '../components/Modal/SharedData';
|
import { LOCATION_DATA, IMAGE_LOCATIONS } from '../components/Modal/SharedData';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 위치 중심 자산 현황 뷰 (Vercel Integrated)
|
* 위치 중심 자산 현황 뷰 (Refined)
|
||||||
*/
|
*/
|
||||||
export async function renderLocationView(container: HTMLElement) {
|
export async function renderLocationView(container: HTMLElement) {
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
|
// 로컬 상태 (UI 제어용)
|
||||||
let currentLoc = '기술개발센터';
|
let currentLoc = '기술개발센터';
|
||||||
let currentDetail = '서버실';
|
let currentDetail = '서버실';
|
||||||
let currentPage = 0;
|
let currentPage = 0;
|
||||||
@@ -25,7 +26,7 @@ export async function renderLocationView(container: HTMLElement) {
|
|||||||
: [];
|
: [];
|
||||||
const mapPath = locImages[currentPage] || '';
|
const mapPath = locImages[currentPage] || '';
|
||||||
|
|
||||||
// 자산이 등록된 구역만 필터링
|
// 자산이 등록된(좌표가 일치하는) 구역만 필터링하여 표시
|
||||||
const allBoxes = mapConfig[mapPath] || [];
|
const allBoxes = mapConfig[mapPath] || [];
|
||||||
const boxes = allBoxes.filter((box: any) =>
|
const boxes = allBoxes.filter((box: any) =>
|
||||||
state.masterData.hw.some(a =>
|
state.masterData.hw.some(a =>
|
||||||
@@ -38,50 +39,42 @@ export async function renderLocationView(container: HTMLElement) {
|
|||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="location-view-wrapper">
|
<div class="location-view-wrapper">
|
||||||
<!-- 상단 통합 바 (Vercel Style) -->
|
<!-- 2단계 필터 바 -->
|
||||||
<div class="location-filter-bar">
|
<div class="location-filter-bar">
|
||||||
<div class="filter-actions-group">
|
<div class="filter-group">
|
||||||
<div class="filter-group">
|
<label>건물/위치</label>
|
||||||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; text-transform: none; font-weight: 500; color: var(--primary);">
|
<select id="sel-loc-main">
|
||||||
<input type="checkbox" id="chk-list-view-loc" style="width: 16px; height: 16px; cursor: pointer;" />
|
${Object.keys(LOCATION_DATA).map(loc => `<option value="${loc}" ${loc === currentLoc ? 'selected' : ''}>${loc}</option>`).join('')}
|
||||||
목록보기
|
</select>
|
||||||
</label>
|
</div>
|
||||||
</div>
|
<div class="filter-group">
|
||||||
<div class="filter-group">
|
<label>상세 위치</label>
|
||||||
<label>건물/위치</label>
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
<select id="sel-loc-main" class="form-select-sm">
|
<select id="sel-loc-detail">
|
||||||
${Object.keys(LOCATION_DATA).map(loc => `<option value="${loc}" ${loc === currentLoc ? 'selected' : ''}>${loc}</option>`).join('')}
|
${(LOCATION_DATA[currentLoc] || []).map(det => `<option value="${det}" ${det === currentDetail ? 'selected' : ''}>${det}</option>`).join('')}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
|
||||||
<div class="filter-group">
|
|
||||||
<label>상세 위치</label>
|
|
||||||
<div class="filter-row">
|
|
||||||
<select id="sel-loc-detail" class="form-select-sm">
|
|
||||||
${(LOCATION_DATA[currentLoc] || []).map(det => `<option value="${det}" ${det === currentDetail ? 'selected' : ''}>${det}</option>`).join('')}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<!-- 페이지네이션 -->
|
<!-- 페이지네이션을 상세 위치 바로 옆으로 이동 -->
|
||||||
${locImages.length > 1 ? `
|
${locImages.length > 1 ? `
|
||||||
<div class="map-pagination-group">
|
<div class="map-pagination" style="margin-left: 0; padding-left: 0.5rem; border-left: 1px solid var(--border-color); display: flex; align-items: center; gap: 0.5rem;">
|
||||||
<div class="page-btns">
|
<div class="page-btns">
|
||||||
<button id="btn-prev-page" class="btn btn-outline btn-sm" ${currentPage === 0 ? 'disabled' : ''}>이전</button>
|
<button id="btn-prev-page" class="btn btn-outline btn-sm" style="height: 28px; padding: 0 8px;" ${currentPage === 0 ? 'disabled' : ''}>이전</button>
|
||||||
<button id="btn-next-page" class="btn btn-outline btn-sm" ${currentPage === locImages.length - 1 ? 'disabled' : ''}>다음</button>
|
<button id="btn-next-page" class="btn btn-outline btn-sm" style="height: 28px; padding: 0 8px;" ${currentPage === locImages.length - 1 ? 'disabled' : ''}>다음</button>
|
||||||
</div>
|
|
||||||
<span class="page-info">(${currentPage + 1} / ${locImages.length})</span>
|
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
<span class="page-info">(${currentPage + 1} / ${locImages.length})</span>
|
||||||
</div>
|
</div>
|
||||||
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="location-main-content">
|
<div class="location-main-content" style="height: calc(100vh - 180px); align-items: stretch; gap: 1rem; padding: 1rem; overflow: hidden; display: grid; grid-template-columns: 1.4fr 1fr;">
|
||||||
<!-- 지도 섹션 -->
|
<!-- 지도 섹션: 상단 고정 정렬로 밀림 방지 -->
|
||||||
<div class="map-container-section">
|
<div class="map-container-section" style="position: relative; overflow: hidden; border-radius: 8px; border: 1px solid var(--border-color); background: #f1f5f9; display: flex; align-items: flex-start; justify-content: center;">
|
||||||
<div class="map-frame-wrapper">
|
<div class="map-frame-wrapper" style="position: relative; width: 100%; height: 100%; display: flex; align-items: flex-start; justify-content: center;">
|
||||||
${mapPath ? `
|
${mapPath ? `
|
||||||
<img src="${mapPath}" id="main-map-img" class="map-image">
|
<img src="${mapPath}" id="main-map-img" style="max-width: 100%; max-height: 100%; object-fit: contain; display: block;">
|
||||||
<div id="box-overlay" class="map-overlay">
|
<div id="box-overlay" style="position: absolute; pointer-events: none; transition: none;">
|
||||||
${boxes.map((box: any, idx: number) => {
|
${boxes.map((box: any, idx: number) => {
|
||||||
const name = box.name || `#${idx+1}`;
|
const name = box.name || `#${idx+1}`;
|
||||||
return `
|
return `
|
||||||
@@ -89,32 +82,35 @@ export async function renderLocationView(container: HTMLElement) {
|
|||||||
data-name="${name}"
|
data-name="${name}"
|
||||||
data-x="${box.x}"
|
data-x="${box.x}"
|
||||||
data-y="${box.y}"
|
data-y="${box.y}"
|
||||||
style="left:${box.x}%; top:${box.y}%; width:${box.w}%; height:${box.h}%;
|
style="position: absolute; left:${box.x}%; top:${box.y}%; width:${box.w}%; height:${box.h}%;
|
||||||
border: 2px solid var(--primary-color); background: rgba(30, 81, 73, 0.1); cursor:pointer; pointer-events: auto;">
|
border: 2px solid var(--primary-color); background: rgba(30, 81, 73, 0.1); cursor:pointer; pointer-events: auto;">
|
||||||
</div>
|
</div>
|
||||||
`}).join('')}
|
`}).join('')}
|
||||||
</div>
|
</div>
|
||||||
` : '<div class="no-map-message">해당 위치의 도면이 등록되지 않았습니다.</div>'}
|
` : '<div style="padding: 5rem; text-align:center; color: #999;">해당 위치의 도면이 등록되지 않았습니다.</div>'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 상세 정보 섹션 -->
|
<!-- 상세 정보 섹션: 내부 스크롤만 허용 -->
|
||||||
<div class="asset-list-section">
|
<div class="asset-list-section" style="display: flex; flex-direction: column; height: 100%; overflow: hidden; background: #fff; border-radius: 8px; border: 1px solid var(--border-color);">
|
||||||
<div class="section-header">
|
<div class="section-header" style="flex-shrink: 0; background: #f8fafc; border-bottom: 1px solid var(--border-color); padding: 1rem;">
|
||||||
<h4 id="loc-list-title" class="sidebar-title">구역을 선택하세요</h4>
|
<h4 id="loc-list-title" style="margin:0; font-size: 0.95rem; font-weight: 700;">📍 구역을 선택하세요</h4>
|
||||||
</div>
|
</div>
|
||||||
<div id="loc-asset-table-container" class="mini-table-wrapper">
|
<div id="loc-asset-table-container" class="mini-table-wrapper" style="flex: 1; overflow-y: auto; padding: 0;">
|
||||||
<div class="empty-state">지도에서 자산 위치를 클릭하세요.</div>
|
<div class="empty-state" style="padding: 3rem 1rem;">지도에서 자산 위치를 클릭하세요.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="padding: 0 1.5rem 0.5rem; flex-shrink: 0;">
|
||||||
|
<p style="font-size:0.75rem; color:var(--text-muted); margin: 0;">* 지도 위의 구역을 클릭하면 자산 상세 정보가 표시됩니다.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// 이미지 로드 및 윈도우 리사이즈 시 오버레이 크기와 위치를 이미지에 정확히 맞춤
|
||||||
const syncOverlaySize = () => {
|
const syncOverlaySize = () => {
|
||||||
const img = container.querySelector('#main-map-img') as HTMLImageElement;
|
const img = container.querySelector('#main-map-img') as HTMLImageElement;
|
||||||
const overlay = container.querySelector('#box-overlay') as HTMLElement;
|
const overlay = container.querySelector('#box-overlay') as HTMLElement;
|
||||||
|
|
||||||
if (img && overlay && img.complete) {
|
if (img && overlay && img.complete) {
|
||||||
overlay.style.width = img.clientWidth + 'px';
|
overlay.style.width = img.clientWidth + 'px';
|
||||||
overlay.style.height = img.clientHeight + 'px';
|
overlay.style.height = img.clientHeight + 'px';
|
||||||
@@ -127,7 +123,7 @@ export async function renderLocationView(container: HTMLElement) {
|
|||||||
if (img) {
|
if (img) {
|
||||||
if (img.complete) {
|
if (img.complete) {
|
||||||
syncOverlaySize();
|
syncOverlaySize();
|
||||||
setTimeout(syncOverlaySize, 50);
|
setTimeout(syncOverlaySize, 50); // 레이아웃 안정화 대기
|
||||||
} else {
|
} else {
|
||||||
img.onload = syncOverlaySize;
|
img.onload = syncOverlaySize;
|
||||||
}
|
}
|
||||||
@@ -136,6 +132,7 @@ export async function renderLocationView(container: HTMLElement) {
|
|||||||
window.removeEventListener('resize', syncOverlaySize);
|
window.removeEventListener('resize', syncOverlaySize);
|
||||||
window.addEventListener('resize', syncOverlaySize);
|
window.addEventListener('resize', syncOverlaySize);
|
||||||
|
|
||||||
|
// 이벤트 바인딩
|
||||||
const selMain = container.querySelector('#sel-loc-main') as HTMLSelectElement;
|
const selMain = container.querySelector('#sel-loc-main') as HTMLSelectElement;
|
||||||
selMain?.addEventListener('change', () => {
|
selMain?.addEventListener('change', () => {
|
||||||
currentLoc = selMain.value;
|
currentLoc = selMain.value;
|
||||||
@@ -154,24 +151,6 @@ export async function renderLocationView(container: HTMLElement) {
|
|||||||
container.querySelector('#btn-prev-page')?.addEventListener('click', () => { currentPage--; render(); });
|
container.querySelector('#btn-prev-page')?.addEventListener('click', () => { currentPage--; render(); });
|
||||||
container.querySelector('#btn-next-page')?.addEventListener('click', () => { currentPage++; render(); });
|
container.querySelector('#btn-next-page')?.addEventListener('click', () => { currentPage++; render(); });
|
||||||
|
|
||||||
const chkBox = container.querySelector('#chk-list-view-loc') as HTMLInputElement;
|
|
||||||
|
|
||||||
if (chkBox) {
|
|
||||||
chkBox.checked = (state as any).currentViewMode === 'asset';
|
|
||||||
const handleToggle = () => {
|
|
||||||
const isListMode = chkBox.checked;
|
|
||||||
if (isListMode) {
|
|
||||||
state.viewMode = 'list';
|
|
||||||
(state as any).currentViewMode = 'asset';
|
|
||||||
} else {
|
|
||||||
state.viewMode = 'location';
|
|
||||||
(state as any).currentViewMode = 'location';
|
|
||||||
}
|
|
||||||
window.dispatchEvent(new Event('refresh-view'));
|
|
||||||
};
|
|
||||||
chkBox.addEventListener('change', handleToggle);
|
|
||||||
}
|
|
||||||
|
|
||||||
container.querySelectorAll('.location-box-point').forEach(box => {
|
container.querySelectorAll('.location-box-point').forEach(box => {
|
||||||
box.addEventListener('click', () => {
|
box.addEventListener('click', () => {
|
||||||
const x = box.getAttribute('data-x');
|
const x = box.getAttribute('data-x');
|
||||||
@@ -184,7 +163,10 @@ export async function renderLocationView(container: HTMLElement) {
|
|||||||
String(a.loc_y) === String(y)
|
String(a.loc_y) === String(y)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (targetAsset) renderAssetDetail(targetAsset);
|
if (targetAsset) {
|
||||||
|
renderAssetDetail(targetAsset);
|
||||||
|
}
|
||||||
|
|
||||||
container.querySelectorAll('.location-box-point').forEach(b => (b as HTMLElement).style.background = 'rgba(30, 81, 73, 0.1)');
|
container.querySelectorAll('.location-box-point').forEach(b => (b as HTMLElement).style.background = 'rgba(30, 81, 73, 0.1)');
|
||||||
(box as HTMLElement).style.background = 'rgba(30, 81, 73, 0.4)';
|
(box as HTMLElement).style.background = 'rgba(30, 81, 73, 0.4)';
|
||||||
});
|
});
|
||||||
@@ -197,52 +179,62 @@ export async function renderLocationView(container: HTMLElement) {
|
|||||||
|
|
||||||
title.innerHTML = `
|
title.innerHTML = `
|
||||||
<div class="detail-header-actions">
|
<div class="detail-header-actions">
|
||||||
<div class="header-identity">
|
<button id="btn-back-to-list" class="btn-icon" style="background: none; border: none; cursor: pointer; color: var(--primary-color); font-size: 1.2rem; padding: 0 4px;">←</button>
|
||||||
<span class="asset-code-title">${asset.asset_code || '미부여'}</span>
|
<span class="detail-header-title">자산 상세 정보</span>
|
||||||
<span class="service-type-badge">${asset.service_type || '운영'}</span>
|
<button id="btn-edit-from-loc" class="btn btn-primary btn-sm" style="font-size: 11px; height: 28px;">수정</button>
|
||||||
<span class="asset-type-label">${asset.asset_type || 'PC'}</span>
|
|
||||||
</div>
|
|
||||||
<button id="btn-edit-from-loc" class="btn btn-primary btn-sm">수정</button>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const fields = [
|
const renderSection = (title: string, fields: { label: string; value: any }[]) => `
|
||||||
{ label: ASSET_SCHEMA.CURRENT_DEPT.ui, value: asset.current_dept },
|
<div class="detail-section">
|
||||||
{ label: ASSET_SCHEMA.HW_STATUS.ui, value: asset.hw_status },
|
<div class="detail-section-title">${title}</div>
|
||||||
{ label: ASSET_SCHEMA.MANAGER_MAIN.ui, value: asset.manager_primary },
|
<div class="detail-grid">
|
||||||
{ label: ASSET_SCHEMA.MANAGER_SUB.ui, value: asset.manager_secondary },
|
|
||||||
{ label: ASSET_SCHEMA.ASSET_PURPOSE.ui, value: asset.asset_purpose, fullWidth: true },
|
|
||||||
{ label: ASSET_SCHEMA.MODEL_NAME.ui, value: asset.model_name },
|
|
||||||
{ label: ASSET_SCHEMA.OS.ui, value: asset.os },
|
|
||||||
{ label: ASSET_SCHEMA.CPU.ui, value: asset.cpu },
|
|
||||||
{ label: ASSET_SCHEMA.RAM.ui, value: asset.ram },
|
|
||||||
{ label: ASSET_SCHEMA.GPU.ui, value: asset.gpu, fullWidth: true },
|
|
||||||
{ label: ASSET_SCHEMA.IP_ADDR.ui, value: asset.ip_address },
|
|
||||||
{ label: ASSET_SCHEMA.MAC_ADDR.ui, value: asset.mac_address },
|
|
||||||
{ label: ASSET_SCHEMA.REMOTE_TOOL.ui, value: asset.remote_tool },
|
|
||||||
{ label: ASSET_SCHEMA.MONITORING.ui, value: asset.monitoring },
|
|
||||||
{ label: ASSET_SCHEMA.MEMO.ui, value: asset.memo, fullWidth: true }
|
|
||||||
];
|
|
||||||
|
|
||||||
const sectionsHTML = `
|
|
||||||
<div class="detail-section" style="margin-bottom: 0;">
|
|
||||||
<div class="detail-grid-2col" style="gap: 0.75rem 1rem;">
|
|
||||||
${fields.map(f => `
|
${fields.map(f => `
|
||||||
<div class="detail-item ${f.fullWidth ? 'full-width' : ''}">
|
<div class="detail-label">${f.label}</div>
|
||||||
<div class="detail-label-sm">${f.label}</div>
|
<div class="detail-value">${f.value || '-'}</div>
|
||||||
<div class="detail-value-lg">${f.value || '-'}</div>
|
|
||||||
</div>
|
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const sectionsHTML = [
|
||||||
|
renderSection('기본 관리 정보', [
|
||||||
|
{ label: ASSET_SCHEMA.ASSET_CODE.ui, value: asset.asset_code },
|
||||||
|
{ label: ASSET_SCHEMA.PURCHASE_CORP.ui, value: asset.purchase_corp },
|
||||||
|
{ label: ASSET_SCHEMA.CATEGORY.ui, value: asset.category },
|
||||||
|
{ label: ASSET_SCHEMA.ASSET_TYPE.ui, value: asset.asset_type },
|
||||||
|
{ label: ASSET_SCHEMA.HW_STATUS.ui, value: asset.hw_status }
|
||||||
|
]),
|
||||||
|
renderSection('시스템 사양', [
|
||||||
|
{ label: ASSET_SCHEMA.MODEL_NAME.ui, value: asset.model_name },
|
||||||
|
{ label: ASSET_SCHEMA.OS.ui, value: asset.os },
|
||||||
|
{ label: ASSET_SCHEMA.CPU.ui, value: asset.cpu },
|
||||||
|
{ label: ASSET_SCHEMA.RAM.ui, value: asset.ram },
|
||||||
|
{ label: ASSET_SCHEMA.GPU.ui, value: asset.gpu }
|
||||||
|
]),
|
||||||
|
renderSection('네트워크 정보', [
|
||||||
|
{ label: ASSET_SCHEMA.IP_ADDR.ui, value: asset.ip_address },
|
||||||
|
{ label: ASSET_SCHEMA.MAC_ADDR.ui, value: asset.mac_address },
|
||||||
|
{ label: ASSET_SCHEMA.REMOTE_TOOL.ui, value: asset.remote_tool }
|
||||||
|
]),
|
||||||
|
renderSection('구매 및 기타', [
|
||||||
|
{ label: ASSET_SCHEMA.PURCHASE_DATE.ui, value: asset.purchase_date },
|
||||||
|
{ label: ASSET_SCHEMA.PURCHASE_AMOUNT.ui, value: asset.purchase_amount ? `${Number(asset.purchase_amount).toLocaleString()}원` : '-' },
|
||||||
|
{ label: ASSET_SCHEMA.MEMO.ui, value: asset.memo }
|
||||||
|
])
|
||||||
|
].join('');
|
||||||
|
|
||||||
tableContainer.innerHTML = `
|
tableContainer.innerHTML = `
|
||||||
<div class="asset-detail-sidebar">
|
<div class="asset-detail-sidebar">
|
||||||
${sectionsHTML}
|
${sectionsHTML}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
container.querySelector('#btn-back-to-list')?.addEventListener('click', () => {
|
||||||
|
title.textContent = `📍 구역을 선택하세요`;
|
||||||
|
tableContainer.innerHTML = `<div class="empty-state" style="padding: 3rem 1rem;">지도에서 자산 위치를 클릭하세요.</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
container.querySelector('#btn-edit-from-loc')?.addEventListener('click', () => {
|
container.querySelector('#btn-edit-from-loc')?.addEventListener('click', () => {
|
||||||
openHwModal(asset, 'edit');
|
openHwModal(asset, 'edit');
|
||||||
});
|
});
|
||||||
|
|||||||
10
start_docker_wsl.bat
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul
|
||||||
|
cd /d "%~dp0"
|
||||||
|
powershell -ExecutionPolicy Bypass -File "%~dp0start_docker_wsl.ps1"
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo.
|
||||||
|
echo [ERROR] start_docker_wsl.ps1 failed.
|
||||||
|
pause
|
||||||
|
exit /b %errorlevel%
|
||||||
|
)
|
||||||
107
start_docker_wsl.ps1
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
|
$projectWindowsPath = $PSScriptRoot
|
||||||
|
$wslProjectPath = (wsl wslpath $projectWindowsPath).Trim()
|
||||||
|
$envFilePath = Join-Path $PSScriptRoot '.env'
|
||||||
|
|
||||||
|
function Get-EnvValue {
|
||||||
|
param(
|
||||||
|
[string]$FilePath,
|
||||||
|
[string]$Key
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not (Test-Path $FilePath)) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
$line = Get-Content $FilePath | Where-Object { $_ -match "^$Key=" } | Select-Object -First 1
|
||||||
|
if (-not $line) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($line -split '=', 2)[1].Trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-TcpPortFast {
|
||||||
|
param(
|
||||||
|
[string]$HostName,
|
||||||
|
[int]$Port,
|
||||||
|
[int]$TimeoutMs = 3000
|
||||||
|
)
|
||||||
|
|
||||||
|
$client = New-Object System.Net.Sockets.TcpClient
|
||||||
|
try {
|
||||||
|
$asyncResult = $client.BeginConnect($HostName, $Port, $null, $null)
|
||||||
|
if (-not $asyncResult.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) {
|
||||||
|
$client.Close()
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
$client.EndConnect($asyncResult)
|
||||||
|
$client.Close()
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$client.Close()
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "============================================" -ForegroundColor Cyan
|
||||||
|
Write-Host " HM ITAM WSL Docker Start" -ForegroundColor Cyan
|
||||||
|
Write-Host "============================================" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
Write-Host "[INFO] Checking WSL..."
|
||||||
|
wsl -l -v
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "[ERROR] WSL is not available." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "[INFO] Checking Docker in WSL..."
|
||||||
|
wsl sh -lc "docker --version"
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "[ERROR] Docker is not available inside WSL." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$dbHost = Get-EnvValue -FilePath $envFilePath -Key 'DB_HOST'
|
||||||
|
$dbPort = Get-EnvValue -FilePath $envFilePath -Key 'DB_PORT'
|
||||||
|
|
||||||
|
if (-not $dbPort) {
|
||||||
|
$dbPort = '3306'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $dbHost) {
|
||||||
|
Write-Host "[WARN] .env is missing DB_HOST. Containers will still start, but backend DB calls will fail until DB settings are fixed." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dbHost) {
|
||||||
|
Write-Host "[INFO] Checking external DB reachability..."
|
||||||
|
$dbReachable = Test-TcpPortFast -HostName $dbHost -Port ([int]$dbPort)
|
||||||
|
if (-not $dbReachable) {
|
||||||
|
Write-Host "[WARN] External DB is unreachable: $dbHost`:$dbPort" -ForegroundColor Yellow
|
||||||
|
Write-Host "[HINT] Containers will still start. Check VPN/private network connection, firewall rules, DB host/port in .env, or whether the DB server is running." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "[INFO] Starting ITAM containers in WSL..."
|
||||||
|
wsl sh -lc "cd '$wslProjectPath' && docker compose up --build -d --remove-orphans"
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "[WARN] Build-based startup failed. Retrying with cached images/containers..." -ForegroundColor Yellow
|
||||||
|
wsl sh -lc "cd '$wslProjectPath' && docker compose up -d --remove-orphans"
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "[ERROR] Failed to start containers." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "============================================" -ForegroundColor Green
|
||||||
|
Write-Host " [OK] WSL Docker stack started." -ForegroundColor Green
|
||||||
|
Write-Host " [INFO] Frontend: http://localhost:8080"
|
||||||
|
Write-Host " [INFO] Backend : http://localhost:3000/api/assets/master"
|
||||||
|
Write-Host "============================================" -ForegroundColor Green
|
||||||
|
|
||||||
|
Start-Process "http://localhost:8080"
|
||||||
@@ -1,6 +1,49 @@
|
|||||||
# HM ITAM Server Start Script
|
# HM ITAM Server Start Script
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
|
function Get-EnvValue {
|
||||||
|
param(
|
||||||
|
[string]$FilePath,
|
||||||
|
[string]$Key
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not (Test-Path $FilePath)) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
$line = Get-Content $FilePath | Where-Object { $_ -match "^$Key=" } | Select-Object -First 1
|
||||||
|
if (-not $line) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($line -split '=', 2)[1].Trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-TcpPortFast {
|
||||||
|
param(
|
||||||
|
[string]$HostName,
|
||||||
|
[int]$Port,
|
||||||
|
[int]$TimeoutMs = 3000
|
||||||
|
)
|
||||||
|
|
||||||
|
$client = New-Object System.Net.Sockets.TcpClient
|
||||||
|
try {
|
||||||
|
$asyncResult = $client.BeginConnect($HostName, $Port, $null, $null)
|
||||||
|
if (-not $asyncResult.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) {
|
||||||
|
$client.Close()
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
$client.EndConnect($asyncResult)
|
||||||
|
$client.Close()
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$client.Close()
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Write-Host "============================================" -ForegroundColor Cyan
|
Write-Host "============================================" -ForegroundColor Cyan
|
||||||
Write-Host " HM ITAM System Start" -ForegroundColor Cyan
|
Write-Host " HM ITAM System Start" -ForegroundColor Cyan
|
||||||
Write-Host "============================================" -ForegroundColor Cyan
|
Write-Host "============================================" -ForegroundColor Cyan
|
||||||
@@ -21,6 +64,13 @@ if (-not (Test-Path "node_modules")) {
|
|||||||
Write-Host "[INFO] Checking ports..."
|
Write-Host "[INFO] Checking ports..."
|
||||||
$backendPort = 3000
|
$backendPort = 3000
|
||||||
$frontendPort = 8080
|
$frontendPort = 8080
|
||||||
|
$envFilePath = Join-Path $PSScriptRoot '.env'
|
||||||
|
$dbHost = Get-EnvValue -FilePath $envFilePath -Key 'DB_HOST'
|
||||||
|
$dbPort = Get-EnvValue -FilePath $envFilePath -Key 'DB_PORT'
|
||||||
|
|
||||||
|
if (-not $dbPort) {
|
||||||
|
$dbPort = '3306'
|
||||||
|
}
|
||||||
|
|
||||||
if (Get-NetTCPConnection -LocalPort $backendPort -ErrorAction SilentlyContinue) {
|
if (Get-NetTCPConnection -LocalPort $backendPort -ErrorAction SilentlyContinue) {
|
||||||
Write-Host "[WARNING] Port $backendPort [Backend] is already in use." -ForegroundColor Yellow
|
Write-Host "[WARNING] Port $backendPort [Backend] is already in use." -ForegroundColor Yellow
|
||||||
@@ -30,6 +80,21 @@ if (Get-NetTCPConnection -LocalPort $frontendPort -ErrorAction SilentlyContinue)
|
|||||||
Write-Host "[WARNING] Port $frontendPort [Frontend] is already in use." -ForegroundColor Yellow
|
Write-Host "[WARNING] Port $frontendPort [Frontend] is already in use." -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (-not $dbHost) {
|
||||||
|
Write-Host "[WARNING] .env is missing DB_HOST. Backend and frontend will still start, but DB calls will fail until DB settings are fixed." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host "[INFO] Checking external DB reachability..."
|
||||||
|
$dbReachable = Test-TcpPortFast -HostName $dbHost -Port ([int]$dbPort)
|
||||||
|
if ($dbReachable) {
|
||||||
|
Write-Host "[INFO] External DB reachable: $dbHost`:$dbPort"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host "[WARNING] External DB is unreachable: $dbHost`:$dbPort" -ForegroundColor Yellow
|
||||||
|
Write-Host "[WARNING] Backend and frontend will still start, but DB-backed screens and APIs may fail." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "[INFO] Starting Backend [Port: 3000]..."
|
Write-Host "[INFO] Starting Backend [Port: 3000]..."
|
||||||
Start-Process cmd -ArgumentList "/k npm run server"
|
Start-Process cmd -ArgumentList "/k npm run server"
|
||||||
|
|||||||
4
stop_docker_wsl.bat
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul
|
||||||
|
cd /d "%~dp0"
|
||||||
|
powershell -ExecutionPolicy Bypass -File "%~dp0stop_docker_wsl.ps1"
|
||||||
13
stop_docker_wsl.ps1
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
|
$projectWindowsPath = $PSScriptRoot
|
||||||
|
$wslProjectPath = (wsl wslpath $projectWindowsPath).Trim()
|
||||||
|
|
||||||
|
Write-Host "[INFO] Stopping ITAM WSL Docker stack..."
|
||||||
|
wsl sh -lc "cd '$wslProjectPath' && docker compose down --remove-orphans"
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "[ERROR] Failed to stop containers." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "[OK] WSL Docker stack stopped." -ForegroundColor Green
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
const proxyTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3000';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
server: {
|
server: {
|
||||||
port: 8080,
|
port: 8080,
|
||||||
host: true, // Listen on all local IPs
|
host: true, // Listen on all local IPs
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3000',
|
target: proxyTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
'/uploads': {
|
'/uploads': {
|
||||||
target: 'http://localhost:3000',
|
target: proxyTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||