Compare commits
9 Commits
Dockerizin
...
6ed2faee2d
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ed2faee2d | |||
| 89d3ac2e89 | |||
| b37981506e | |||
| 73ef13f3a5 | |||
| 155570e8de | |||
| 119c799d1d | |||
| b169176d57 | |||
| 56abdddbc7 | |||
| fd9e88d7c6 |
736
DESIGN-vercel.md
Normal file
@@ -0,0 +1,736 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
25
README.md
@@ -28,29 +28,8 @@
|
|||||||
|
|
||||||
### 🎨 ITAM 시스템 디자인 가이드 (Design Guide)
|
### 🎨 ITAM 시스템 디자인 가이드 (Design Guide)
|
||||||
|
|
||||||
1. **디자인 철학 (Design Philosophy)**
|
디자인 일관성 및 시각적 원칙에 관한 상세 내용은 아래 문서를 참조하십시오.
|
||||||
* **Minimalist & Border-based**: 불필요한 박스(Card) 사용을 최소화하고, 정보의 구분은 간결한 라인(Border/Divider)을 활용하여 시각적 피로도를 낮춥니다.
|
|
||||||
* **Professional Achromatic**: 무채색(Black, White, Grey)을 기본으로 하여 정돈된 업무 환경을 제공합니다.
|
|
||||||
* **Green Accent**: 블루 대신 짙은 그린(`#1E5149`)을 포인트 컬러로 사용하여 차분한 전문성을 강조합니다.
|
|
||||||
|
|
||||||
2. **타이포그래피 (Typography)**
|
👉 **[디자인 가이드 바로가기 (design_rule.md)](./design_rule.md)**
|
||||||
* **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열 그리드 시스템을 권장하며, 하단 우측에 액션 버튼(닫기, 저장 등)을 배치합니다.
|
|
||||||
|
|
||||||
|
|||||||
29
analyze_remote_mess.cjs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
24
check_remote_data.cjs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
24
check_remote_specific.cjs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
27
check_remote_tangled.cjs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
55
debug_save.cjs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
47
design_rule.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# 🎨 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를 제공합니다.
|
||||||
63
fix_all_dates.cjs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
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 |
BIN
img/location_photo/기술개발센터/센터내부/센터내부.png
Normal file
|
After Width: | Height: | Size: 388 KiB |
354
img/location_photo/기술개발센터/팀자리/center_chair_map_view_only.html
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
<!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>
|
||||||
931
img/location_photo/기술개발센터/팀자리/center_chair_people_map.html
Normal file
@@ -0,0 +1,931 @@
|
|||||||
|
<!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>
|
||||||
932
img/location_photo/기술개발센터/팀자리/center_chair_people_map_6f.html
Normal file
@@ -0,0 +1,932 @@
|
|||||||
|
<!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>
|
||||||
|
|
||||||
932
img/location_photo/기술개발센터/팀자리/center_chair_people_map_7f.html
Normal file
@@ -0,0 +1,932 @@
|
|||||||
|
<!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>
|
||||||
|
|
||||||
BIN
img/location_photo/한맥빌딩/1층.png
Normal file
|
After Width: | Height: | Size: 196 KiB |
BIN
img/location_photo/한맥빌딩/2층.png
Normal file
|
After Width: | Height: | Size: 276 KiB |
BIN
img/location_photo/한맥빌딩/3층.png
Normal file
|
After Width: | Height: | Size: 225 KiB |
BIN
img/location_photo/한맥빌딩/4층.png
Normal file
|
After Width: | Height: | Size: 228 KiB |
BIN
img/location_photo/한맥빌딩/5층.png
Normal file
|
After Width: | Height: | Size: 242 KiB |
BIN
img/location_photo/한맥빌딩/6층.png
Normal file
|
After Width: | Height: | Size: 259 KiB |
BIN
img/location_photo/한맥빌딩/7층.png
Normal file
|
After 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>ITAM 자산관리 ERP</title>
|
<title>한맥가족 자산관리시스템</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>자산관리시스템<span class="sub-title">(Digital Asset Control Hub System)</span></h1>
|
<h1>한맥자산관리시스템</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Navigation (GNB + LNB in same row) -->
|
<!-- Navigation (GNB + LNB in same row) -->
|
||||||
@@ -57,8 +57,7 @@
|
|||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<footer class="main-footer">
|
<footer class="main-footer">
|
||||||
<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>© 2026 BARON Consultant Co,Ltd. All rights reserved.</p>
|
||||||
<p>Powered by BARON Consultant Co,Ltd</p>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
72
rebuild_asset_codes.cjs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
118
scratch/fix_dates_by_spec.js
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
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();
|
||||||
128
scratch/fix_dates_strict.js
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
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();
|
||||||
88
scratch/fix_purchase_dates.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
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();
|
||||||
@@ -111,9 +111,16 @@ export function closeModals() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function initBaseModal() {
|
export function initBaseModal() {
|
||||||
// ESC 키로 모든 모달 닫기
|
// ESC 키로 모든 모달 닫기 (위치보기 팝업이 있으면 그것부터 닫음)
|
||||||
window.addEventListener('keydown', (e) => {
|
window.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape') closeModals();
|
if (e.key === 'Escape') {
|
||||||
|
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" style="max-width: 1000px;">
|
<div class="modal-content wide">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 id="dashboard-detail-modal-title">상세 목록</h2>
|
<h2 id="dashboard-detail-modal-title" class="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 style="width:100%;">
|
<table>
|
||||||
<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, Database, CalendarClock, Edit2, History, Plus } from 'lucide';
|
import { createIcons, X, Save, 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,8 +16,11 @@ 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">
|
||||||
<h2 id="domain-modal-title">${this.title}</h2>
|
<div class="header-left">
|
||||||
<button id="btn-close-domain-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
|
<h2 id="domain-modal-title" class="modal-title">${this.title}</h2>
|
||||||
|
<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">
|
||||||
@@ -58,7 +61,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>
|
||||||
@@ -78,9 +81,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" style="width:16px; height:16px;"></i> 변경 이력</h3>
|
<h3><i data-lucide="history"></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" style="width:14px; height:14px;"></i>
|
이력 추가 <i data-lucide="plus"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="domain-history-list" class="history-timeline"></div>
|
<div id="domain-history-list" class="history-timeline"></div>
|
||||||
@@ -141,7 +144,7 @@ class DomainAssetModal extends BaseModal {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
createIcons({ icons: { History, Plus, Save, CalendarClock, Database } });
|
createIcons({ icons: { History, Plus, Save, X } });
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fillFormData(asset: any): void {
|
protected fillFormData(asset: any): void {
|
||||||
@@ -158,6 +161,7 @@ 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 {
|
||||||
@@ -166,6 +170,28 @@ class DomainAssetModal extends BaseModal {
|
|||||||
|
|
||||||
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) {
|
||||||
@@ -173,16 +199,10 @@ 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 initDomainModal(onSave: () => void, closeModals: () => void) {
|
export function openDomainModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { domainModal.open(asset, mode); }
|
||||||
domainModal.init(onSave, closeModals);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function openDomainModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
|
|
||||||
domainModal.open(asset, mode);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -117,13 +117,22 @@ 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 {
|
||||||
// 조회 모드 (잠금)
|
// 조회 모드 (잠금)
|
||||||
@@ -132,7 +141,13 @@ 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';
|
||||||
}
|
}
|
||||||
@@ -169,9 +184,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" style="width:16px; height:16px;"></i> ${options.historyTitle}</h3>
|
<h3><i data-lucide="history" class="icon-sm"></i> ${options.historyTitle}</h3>
|
||||||
<button type="button" id="${options.addLogBtnId}" class="btn btn-outline btn-sm">
|
<button type="button" id="btn-add-${idPrefix}-log" class="btn btn-outline btn-sm">
|
||||||
내역 추가 <i data-lucide="plus" style="width:14px; height:14px;"></i>
|
내역 추가 <i data-lucide="plus" class="icon-sm"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="${idPrefix}-history-list" class="history-timeline"></div>
|
<div id="${idPrefix}-history-list" class="history-timeline"></div>
|
||||||
|
|||||||
@@ -61,7 +61,12 @@ export class PCFlowModal {
|
|||||||
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) radioCheckout.checked = true;
|
if (radioCheckout) {
|
||||||
|
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;
|
||||||
@@ -309,21 +314,17 @@ 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 style="padding: 10px; color: var(--text-muted); font-size: 13px;">일치하는 사원이 없습니다.</div>';
|
container.innerHTML = '<div class="autocomplete-item-empty">일치하는 사원이 없습니다.</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.style.padding = '8px 12px';
|
item.className = 'autocomplete-item';
|
||||||
item.style.cursor = 'pointer';
|
|
||||||
item.style.fontSize = '13px';
|
|
||||||
item.style.borderBottom = '1px solid #F3F4F6';
|
|
||||||
item.className = 'suggestion-item';
|
|
||||||
item.innerHTML = `
|
item.innerHTML = `
|
||||||
<div style="font-weight: 700; color: var(--text-main);">${u.user_name}</div>
|
<div class="suggestion-name">${u.user_name}</div>
|
||||||
<div style="font-size: 11px; color: var(--text-muted); display: flex; gap: 8px;">
|
<div class="suggestion-meta">
|
||||||
<span>부서: ${u.dept_name}</span>
|
<span>부서: ${u.dept_name}</span>
|
||||||
<span>|</span>
|
<span>|</span>
|
||||||
<span>사번: ${u.emp_no || '-'}</span>
|
<span>사번: ${u.emp_no || '-'}</span>
|
||||||
@@ -338,21 +339,17 @@ 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 style="padding: 10px; color: var(--text-muted); font-size: 13px;">불출 가능한 대기 PC 재고가 없습니다.</div>';
|
container.innerHTML = '<div class="autocomplete-item-empty">불출 가능한 대기 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.style.padding = '8px 12px';
|
item.className = 'autocomplete-item';
|
||||||
item.style.cursor = 'pointer';
|
|
||||||
item.style.fontSize = '13px';
|
|
||||||
item.style.borderBottom = '1px solid #F3F4F6';
|
|
||||||
item.className = 'suggestion-item';
|
|
||||||
item.innerHTML = `
|
item.innerHTML = `
|
||||||
<div style="font-weight: 700; color: var(--primary-color);">${p.asset_code} (${p.model_name || '모델명 없음'})</div>
|
<div class="suggestion-name">${p.asset_code} (${p.model_name || '모델명 없음'})</div>
|
||||||
<div style="font-size: 11px; color: var(--text-muted);">
|
<div class="suggestion-meta">
|
||||||
사양: CPU ${p.cpu || '-'} / RAM ${p.ram || '-'} / 위치: ${p.location || '-'}
|
사양: CPU ${p.cpu || '-'} / RAM ${p.ram || '-'} / 위치: ${p.location || '-'}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -433,14 +430,14 @@ export class PCFlowModal {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (userPcs.length === 0) {
|
if (userPcs.length === 0) {
|
||||||
userPcsList.innerHTML = '<div style="font-size: 12px; color: var(--text-muted); padding: 8px 0;">이 사용자가 소유한 PC 자산이 없습니다.</div>';
|
userPcsList.innerHTML = '<div class="empty-list-message">이 사용자가 소유한 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}" 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="user-pc-item ${isSelected ? 'selected' : ''}" data-id="${p.id}">
|
||||||
<div style="font-weight: 700; font-size: 13px; color: ${isSelected ? 'var(--primary-color)' : 'var(--text-main)'};">${p.asset_code}</div>
|
<div class="pc-item-code">${p.asset_code}</div>
|
||||||
<div style="font-size: 11px; color: var(--text-muted); margin-top: 2px;">
|
<div class="pc-item-meta">
|
||||||
${p.model_name || '모델명 없음'} | CPU: ${p.cpu || '-'} | RAM: ${p.ram || '-'}
|
${p.model_name || '모델명 없음'} | CPU: ${p.cpu || '-'} | RAM: ${p.ram || '-'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -465,159 +462,132 @@ 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" style="${overlayStyle}">
|
<div id="pc-flow-modal" class="modal-overlay hidden">
|
||||||
<div class="modal-content" style="${contentStyle}">
|
<div class="modal-content wide">
|
||||||
|
<div class="modal-header">
|
||||||
<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 class="modal-title">
|
||||||
<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="닫기" style="font-size: 28px; color: white; background: none; border: none; cursor: pointer; line-height: 1;">×</button>
|
<button id="btn-close-pc-flow-modal" class="btn-icon" aria-label="닫기">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-body" style="padding: 24px; overflow-y: auto; display: flex; gap: 24px;">
|
<div class="modal-body">
|
||||||
<!-- 왼쪽 영역: 입력 폼 -->
|
<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. 처리 유형 -->
|
<!-- 1. 처리 유형 -->
|
||||||
<div>
|
<div class="form-group">
|
||||||
<label style="${labelStyle}">1. 처리 유형 선택</label>
|
<label>1. 처리 유형 선택</label>
|
||||||
<div style="display: flex; gap: 12px;">
|
<div class="view-toggle w-full flex-row">
|
||||||
<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;">
|
<label class="flow-type-label toggle-btn active flex-1 text-center">
|
||||||
<input type="radio" name="flow-type" value="checkout" checked style="display:none;" />
|
<input type="radio" name="flow-type" value="checkout" checked class="hidden" />
|
||||||
불출 (지급)
|
불출 (지급)
|
||||||
</label>
|
</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 class="flow-type-label toggle-btn flex-1 text-center">
|
||||||
<input type="radio" name="flow-type" value="return" style="display:none;" />
|
<input type="radio" name="flow-type" value="return" class="hidden" />
|
||||||
입고 (반납)
|
입고 (반납)
|
||||||
</label>
|
</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 class="flow-type-label toggle-btn flex-1 text-center">
|
||||||
<input type="radio" name="flow-type" value="move" style="display:none;" />
|
<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 style="position: relative;">
|
<div class="modal-history-area">
|
||||||
<label id="user-search-label" style="${labelStyle}">2. 대상 사원 검색</label>
|
<div class="history-header">
|
||||||
<div style="position: relative; display: flex; align-items: center;">
|
<h3>선택 내역 요약</h3>
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- 3. 새 인수자 검색 (이동 시 노출) -->
|
<div class="dynamic-row-container">
|
||||||
<div id="target-user-search-container" class="hidden" style="position: relative;">
|
<!-- 사원 요약 카드 -->
|
||||||
<label style="${labelStyle}">새 인수 사원 검색</label>
|
<div id="summary-user-card" class="summary-info-card">
|
||||||
<div style="position: relative; display: flex; align-items: center;">
|
<div class="detail-label-sm">대상 사원</div>
|
||||||
<input type="text" id="pc-flow-target-user-search" placeholder="사원명, 부서, 사번 검색..." style="${inputWithIconStyle}" />
|
<div id="summary-user-name" class="detail-value-lg">선택된 사원 없음</div>
|
||||||
<i data-lucide="search" style="position: absolute; left: 10px; width: 16px; height: 16px; color: var(--text-muted);"></i>
|
<div id="summary-user-dept" class="detail-label-sm">-</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 인수 사원 요약 카드 (이동 전용) -->
|
||||||
|
<div id="summary-target-user-card" class="summary-info-card hidden bg-primary-light">
|
||||||
|
<div class="detail-label-sm">새 인수 사원</div>
|
||||||
|
<div id="summary-target-user-name" class="detail-value-lg">선택된 사원 없음</div>
|
||||||
|
<div id="summary-target-user-dept" class="detail-label-sm">-</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 대상 PC 자산 요약 카드 -->
|
||||||
|
<div id="summary-pc-card" class="summary-info-card">
|
||||||
|
<div class="detail-label-sm">대상 PC 자산</div>
|
||||||
|
<div id="summary-pc-code" class="detail-value-lg text-success">선택된 PC 없음</div>
|
||||||
|
<div id="summary-pc-model" class="detail-label-sm">-</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 사용자 보유 PC 목록 선택 (반납/이동 시) -->
|
||||||
|
<div id="user-pcs-container" class="form-group hidden">
|
||||||
|
<label>사원 보유 PC 선택 (클릭하여 매핑)</label>
|
||||||
|
<div id="user-pcs-list" class="user-pc-selection-list"></div>
|
||||||
|
</div>
|
||||||
</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="stock-pc-search-container" style="position: relative;">
|
|
||||||
<label style="${labelStyle}">3. 불출할 재고 PC 선택</label>
|
|
||||||
<div style="position: relative; display: flex; align-items: center;">
|
|
||||||
<input type="text" id="pc-flow-stock-search" placeholder="자산코드 또는 모델명 검색..." style="${inputWithIconStyle}" />
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- 5. 상세 공통 입력 -->
|
|
||||||
<div style="display: flex; gap: 16px;">
|
|
||||||
<div style="flex: 1;">
|
|
||||||
<label style="${labelStyle.replace('margin-bottom: 8px;', 'margin-bottom: 6px;')}">처리 일자</label>
|
|
||||||
<input type="date" id="pc-flow-date" style="${inputStyle}" />
|
|
||||||
</div>
|
|
||||||
<div style="flex: 2;">
|
|
||||||
<label style="${labelStyle.replace('margin-bottom: 8px;', 'margin-bottom: 6px;')}">상세 사유</label>
|
|
||||||
<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>
|
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<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 class="modal-footer">
|
||||||
<button id="btn-cancel-pc-flow-modal" class="btn btn-outline" style="height: 42px;">취소</button>
|
<div></div>
|
||||||
<button id="btn-submit-pc-flow" class="btn btn-primary" style="height: 42px;">이동/반납 처리 완료</button>
|
<div class="footer-actions">
|
||||||
|
<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, Database, Edit2, Plus } from 'lucide';
|
import { createIcons, X, Save, Plus } from 'lucide';
|
||||||
import { UI_TEXT } from '../../core/schema';
|
import { UI_TEXT } from '../../core/schema';
|
||||||
|
|
||||||
class PartsMasterModal extends BaseModal {
|
class PartsMasterModal extends BaseModal {
|
||||||
@@ -10,52 +10,51 @@ 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" style="max-width: 500px; width: 100%;">
|
<div class="modal-content narrow">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 id="parts-master-modal-title" style="margin: 0; font-size: 18px; font-weight: 800; color: white;">${this.title}</h2>
|
<div class="header-left">
|
||||||
<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>
|
<h2 id="parts-master-modal-title" class="modal-title">${this.title}</h2>
|
||||||
|
<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" style="padding: 24px; overflow-y: auto;">
|
<div class="modal-body">
|
||||||
<form id="parts-master-asset-form" class="grid-form" style="display: flex; flex-direction: column; gap: 16px;">
|
<form id="parts-master-asset-form" class="grid-form vertical-form">
|
||||||
<input type="hidden" id="parts-master-id" name="id" />
|
<input type="hidden" id="parts-master-id" name="id" />
|
||||||
|
|
||||||
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
<div class="form-group">
|
||||||
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">부품 분류</label>
|
<label>부품 분류</label>
|
||||||
<select id="parts-master-category" name="category" style="${selectStyle}">
|
<select id="parts-master-category" name="category">
|
||||||
<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" style="display: flex; flex-direction: column; gap: 6px;">
|
<div class="form-group">
|
||||||
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">부품 표준 명칭</label>
|
<label>부품 표준 명칭</label>
|
||||||
<input type="text" id="parts-master-component-name" name="component_name" placeholder="예: Intel Core i7-14700K" required style="${inputStyle} width: 100%;" />
|
<input type="text" id="parts-master-component-name" name="component_name" placeholder="예: Intel Core i7-14700K" required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
<div class="form-group">
|
||||||
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">성능 등급</label>
|
<label>성능 등급</label>
|
||||||
<input type="text" id="parts-master-score-tier" name="score_tier" placeholder="예: i7 / S / 최적" required style="${inputStyle} width: 100%;" />
|
<input type="text" id="parts-master-score-tier" name="score_tier" placeholder="예: i7 / S / 최적" required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
<div class="form-group">
|
||||||
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">감점 점수 (양수로 입력)</label>
|
<label>감점 점수 (양수로 입력)</label>
|
||||||
<input type="number" id="parts-master-deduction" name="deduction" placeholder="예: 5" required style="${inputStyle} width: 100%;" />
|
<input type="number" id="parts-master-deduction" name="deduction" placeholder="예: 5" required />
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<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);">
|
<div class="modal-footer">
|
||||||
<button id="btn-delete-parts-master-asset" class="btn btn-outline btn-danger" style="height: 42px;">삭제</button>
|
<button id="btn-delete-parts-master-asset" class="btn btn-outline btn-danger">삭제</button>
|
||||||
<div class="footer-actions" style="display: flex; gap: 8px;">
|
<div class="footer-actions">
|
||||||
<button id="btn-revert-parts-master-edit" class="btn btn-outline hidden" style="height: 42px;">수정 취소</button>
|
<button id="btn-revert-parts-master-edit" class="btn btn-outline hidden">수정 취소</button>
|
||||||
<button id="btn-cancel-parts-master-modal" class="btn btn-outline" style="height: 42px;">닫기</button>
|
<button id="btn-cancel-parts-master-modal" class="btn btn-outline">닫기</button>
|
||||||
<button id="btn-save-parts-master-asset" class="btn btn-primary" style="height: 42px;">수정</button>
|
<button id="btn-save-parts-master-asset" class="btn btn-primary">수정</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,11 +108,13 @@ 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(this.currentAsset.id)) {
|
if (await deletePartsMaster(Number(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 {
|
||||||
@@ -122,23 +123,18 @@ 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) {
|
||||||
if (mode === 'add') {
|
titleEl.textContent = (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') {
|
||||||
@@ -152,15 +148,28 @@ 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 initPartsMasterModal(onSave: () => void, closeModals: () => void) {
|
export function openPartsMasterModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { partsMasterModal.open(asset, mode); }
|
||||||
partsMasterModal.init(onSave, closeModals);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function openPartsMasterModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
|
|
||||||
partsMasterModal.open(asset, mode);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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, Edit2, RotateCcw, Calendar, Users } from 'lucide';
|
import { createIcons, History, Plus, X, Save, 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';
|
||||||
@@ -22,8 +22,11 @@ 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">
|
||||||
<h2 id="sw-modal-title">${this.title}</h2>
|
<div class="header-left">
|
||||||
<button id="btn-close-sw-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
|
<h2 id="sw-modal-title" class="modal-title">${this.title}</h2>
|
||||||
|
<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">
|
||||||
@@ -81,7 +84,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">
|
||||||
@@ -100,12 +103,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 style="display:flex; gap:0.25rem; align-items:center; position:relative;">
|
<div class="input-with-btn">
|
||||||
<input type="text" id="sw-구매일" name="purchase_date" style="flex:1;" />
|
<input type="text" id="sw-구매일" name="purchase_date" />
|
||||||
<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;">
|
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-구매일-picker'); p.value = document.getElementById('sw-구매일').value; p.showPicker();">
|
||||||
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
|
<i data-lucide="calendar"></i>
|
||||||
</button>
|
</button>
|
||||||
<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" />
|
<input type="date" id="sw-구매일-picker" class="hidden-picker" 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">
|
||||||
@@ -126,12 +129,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 style="display:flex; gap:0.25rem; align-items:center; position:relative;">
|
<div class="input-with-btn">
|
||||||
<input type="text" id="sw-만료일" name="expiry_date" style="flex:1;" />
|
<input type="text" id="sw-만료일" name="expiry_date" />
|
||||||
<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;">
|
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-만료일-picker'); p.value = document.getElementById('sw-만료일').value; p.showPicker();">
|
||||||
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
|
<i data-lucide="calendar"></i>
|
||||||
</button>
|
</button>
|
||||||
<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" />
|
<input type="date" id="sw-만료일-picker" class="hidden-picker" 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">
|
||||||
@@ -140,18 +143,18 @@ class SwAssetModal extends BaseModal {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div id="sw-user-section" class="user-management-section" style="margin-top: 2rem; border-top: 1px solid var(--border-color); padding-top: 1.5rem;">
|
<div id="sw-user-section" class="user-management-section">
|
||||||
<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" style="width:16px; height:16px; margin-right:4px;"></i> 사용자 관리
|
<i data-lucide="users"></i> 사용자 관리
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-history-area">
|
<div class="modal-history-area">
|
||||||
<div class="history-header" style="display:flex; justify-content:space-between; align-items:center;">
|
<div class="history-header">
|
||||||
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> 업데이트 내역</h3>
|
<h3><i data-lucide="history"></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="refresh-ccw" style="width:14px; height:14px;"></i>
|
계약 업데이트 <i data-lucide="rotate-ccw"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="sw-history-list" class="history-timeline"></div>
|
<div id="sw-history-list" class="history-timeline"></div>
|
||||||
@@ -170,24 +173,24 @@ class SwAssetModal extends BaseModal {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 계약 업데이트 서브 모달 -->
|
<!-- 계약 업데이트 서브 모달 -->
|
||||||
<div id="sw-update-modal" class="modal-overlay hidden" style="z-index: 1100;">
|
<div id="sw-update-modal" class="modal-overlay hidden sub-modal">
|
||||||
<div class="modal-content" style="max-width: 500px;">
|
<div class="modal-content narrow">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2>계약 업데이트 반영</h2>
|
<h2 class="modal-title">계약 업데이트 반영</h2>
|
||||||
<button id="btn-close-sw-update" class="btn-icon"><i data-lucide="x"></i></button>
|
<button id="btn-close-sw-update" class="btn-icon">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="grid-form" style="grid-template-columns: 1fr;">
|
<div class="grid-form vertical-form">
|
||||||
<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 style="display: flex; align-items: center; gap: 0.5rem;">
|
<div class="input-with-btn">
|
||||||
<input type="text" id="sw-update-start" placeholder="YYYY-MM-DD" style="flex: 1;" />
|
<input type="text" id="sw-update-start" placeholder="YYYY-MM-DD" />
|
||||||
<span>~</span>
|
<span>~</span>
|
||||||
<input type="text" id="sw-update-end" placeholder="YYYY-MM-DD" style="flex: 1;" />
|
<input type="text" id="sw-update-end" placeholder="YYYY-MM-DD" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -209,6 +212,15 @@ 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>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +243,6 @@ 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);
|
||||||
@@ -258,7 +269,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: { 'Content-Type': 'application/json' },
|
headers: 'application/json',
|
||||||
body: JSON.stringify([...state.masterData.logs, log])
|
body: JSON.stringify([...state.masterData.logs, log])
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -322,10 +333,32 @@ 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) {
|
||||||
@@ -356,16 +389,10 @@ 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 initSwModal(onSave: () => void, closeModals: () => void) {
|
export function openSwModal(asset: any, mode: 'view' | 'add' | 'edit' = 'view') { swModal.open(asset, mode); }
|
||||||
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">${this.title}</h2>
|
<h2 id="sw-user-title" class="modal-title">${this.title}</h2>
|
||||||
<button id="btn-close-sw-user-modal" class="btn-icon"><i data-lucide="x"></i></button>
|
<button id="btn-close-sw-user-modal" class="btn-icon" aria-label="닫기">×</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="user-list-toolbar" style="display:flex; justify-content:space-between; margin-bottom:1rem; align-items:center;">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h3 style="font-size:1rem; font-weight:600;">할당된 사용자 목록</h3>
|
<h3 class="detail-section-title mb-0">할당된 사용자 목록</h3>
|
||||||
<button type="button" id="btn-open-add-user" class="btn btn-primary btn-sm"><i data-lucide="plus"></i> 사용자 추가</button>
|
<button type="button" id="btn-open-add-user" class="btn btn-primary btn-sm"><i data-lucide="plus" class="icon-sm"></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>사용기간</th>
|
<th class="text-center">사용기간</th>
|
||||||
<th>신청서</th>
|
<th class="text-center">신청서</th>
|
||||||
<th>관리</th>
|
<th class="text-center">관리</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" style="z-index: 1100;">
|
<div id="sw-user-edit-modal" class="modal-overlay hidden sub-modal">
|
||||||
<div class="modal-content" style="width: 400px;">
|
<div class="modal-content narrow">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3 id="sw-user-edit-title">사용자 정보</h3>
|
<h3 id="sw-user-edit-title" class="modal-title">사용자 정보</h3>
|
||||||
<button id="btn-close-user-edit" class="btn-icon"><i data-lucide="x"></i></button>
|
<button id="btn-close-user-edit" class="btn-icon">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form id="sw-user-edit-form" class="grid-form" style="grid-template-columns: 1fr;">
|
<form id="sw-user-edit-form" class="grid-form vertical-form">
|
||||||
<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 style="display:flex; gap:0.25rem; align-items:center; position:relative;">
|
<div class="input-with-btn">
|
||||||
<input type="text" id="new-user-시작일" style="flex:1;" />
|
<input type="text" id="new-user-시작일" />
|
||||||
<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;">
|
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-시작일-picker'); p.value = document.getElementById('new-user-시작일').value; p.showPicker();">
|
||||||
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
|
<i data-lucide="calendar" class="icon-sm"></i>
|
||||||
</button>
|
</button>
|
||||||
<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" />
|
<input type="date" id="new-user-시작일-picker" class="hidden-picker" 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 style="display:flex; gap:0.25rem; align-items:center; position:relative;">
|
<div class="input-with-btn">
|
||||||
<input type="text" id="new-user-종료일" style="flex:1;" />
|
<input type="text" id="new-user-종료일" />
|
||||||
<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;">
|
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-종료일-picker'); p.value = document.getElementById('new-user-종료일').value; p.showPicker();">
|
||||||
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
|
<i data-lucide="calendar" class="icon-sm"></i>
|
||||||
</button>
|
</button>
|
||||||
<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" />
|
<input type="date" id="new-user-종료일-picker" class="hidden-picker" onchange="document.getElementById('new-user-종료일').value = this.value" tabindex="-1" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -111,6 +111,15 @@ 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>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +149,6 @@ 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());
|
||||||
|
|
||||||
@@ -155,9 +163,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 style="background:var(--bg-light); padding:1rem; border-radius:6px; margin-bottom:1.5rem;">
|
<div class="sw-info-header border-b border-hairline pb-4 mb-6">
|
||||||
<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:0.25rem;">${asset.purchase_corp || asset.법인 || ''}</div>
|
<div class="detail-label-sm">${asset.purchase_corp || asset.법인 || ''}</div>
|
||||||
<div style="font-size:1.1rem; font-weight:700; color:var(--primary-color);">${asset.product_name || asset.제품명 || ''}</div>
|
<div class="asset-code-title">${asset.product_name || asset.제품명 || ''}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -173,9 +181,10 @@ 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" style="text-align:center; padding:2rem; color:var(--text-muted);">할당된 사용자가 없습니다.</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="7" class="empty-cell text-center p-8">할당된 사용자가 없습니다.</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,12 +195,12 @@ class SwUserModal extends BaseModal {
|
|||||||
<td>${user.부서 || ''}</td>
|
<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 style="text-align:center;">${user.신청서명 ? '<i data-lucide="paperclip" class="text-primary"></i>' : '-'}</td>
|
<td class="text-center">${user.신청서명 ? '<i data-lucide="paperclip" class="text-primary icon-sm"></i>' : '-'}</td>
|
||||||
<td>
|
<td class="text-center">
|
||||||
<div style="display:flex; gap:0.5rem;">
|
<div class="flex gap-2 justify-center items-center">
|
||||||
<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 btn-outline btn-sm btn-danger btn-del-user" data-idx="${idx}">삭제</button>
|
<button class="btn-circle-remove btn-del-user" data-idx="${idx}">×</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
`;
|
`;
|
||||||
@@ -257,11 +266,5 @@ 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 initSwUserModal(onSave: () => void, closeModals: () => void) {
|
export function openSwUserModal(asset: any) { swUserModal.open(asset); }
|
||||||
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" style="max-width: 500px; width: 100%;">
|
<div class="modal-content narrow">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 id="user-modal-title" style="margin: 0; font-size: 18px; font-weight: 800; color: white;">\${this.title}</h2>
|
<div class="header-left">
|
||||||
<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>
|
<h2 id="user-modal-title" class="modal-title">${this.title}</h2>
|
||||||
|
<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" style="padding: 24px; overflow-y: auto;">
|
<div class="modal-body">
|
||||||
<form id="user-asset-form" class="grid-form" style="display: flex; flex-direction: column; gap: 16px;">
|
<form id="user-asset-form" class="grid-form vertical-form">
|
||||||
<input type="hidden" id="user-id" name="id" />
|
<input type="hidden" id="user-id" name="id" />
|
||||||
|
|
||||||
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
<div class="form-group">
|
||||||
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">사번</label>
|
<label>사번</label>
|
||||||
<input type="text" id="user-emp-no" name="emp_no" placeholder="예: HM202601" required style="\${inputStyle} width: 100%;" />
|
<input type="text" id="user-emp-no" name="emp_no" placeholder="예: HM202601" required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
<div class="form-group">
|
||||||
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">사용자명</label>
|
<label>사용자명</label>
|
||||||
<input type="text" id="user-name-input" name="user_name" placeholder="예: 홍길동" required style="\${inputStyle} width: 100%;" />
|
<input type="text" id="user-name-input" name="user_name" placeholder="예: 홍길동" required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
<div class="form-group">
|
||||||
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">사용조직 (부서)</label>
|
<label>사용조직 (부서)</label>
|
||||||
<input type="text" id="user-dept" name="dept_name" placeholder="예: 기술개발센터" required style="\${inputStyle} width: 100%;" />
|
<input type="text" id="user-dept" name="dept_name" placeholder="예: 기술개발센터" required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
<div class="form-group">
|
||||||
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">직무 (직급)</label>
|
<label>직무 (직급)</label>
|
||||||
<input type="text" id="user-position-input" name="position" placeholder="예: BIM모델러" required style="\${inputStyle} width: 100%;" />
|
<input type="text" id="user-position-input" name="position" placeholder="예: BIM모델러" required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
<div class="form-group">
|
||||||
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">상태</label>
|
<label>상태</label>
|
||||||
<select id="user-status" name="status" style="\${sharedStyle}">
|
<select id="user-status" name="status">
|
||||||
<option value="재직">재직</option>
|
<option value="재직">재직</option>
|
||||||
<option value="퇴직">퇴직</option>
|
<option value="퇴직">퇴직</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<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);">
|
<div class="modal-footer">
|
||||||
<button id="btn-delete-user-asset" class="btn btn-outline btn-danger" style="height: 42px;">삭제</button>
|
<button id="btn-delete-user-asset" class="btn btn-outline btn-danger">삭제</button>
|
||||||
<div class="footer-actions" style="display: flex; gap: 8px;">
|
<div class="footer-actions">
|
||||||
<button id="btn-revert-user-edit" class="btn btn-outline hidden" style="height: 42px;">수정 취소</button>
|
<button id="btn-revert-user-edit" class="btn btn-outline hidden">수정 취소</button>
|
||||||
<button id="btn-cancel-user-modal" class="btn btn-outline" style="height: 42px;">닫기</button>
|
<button id="btn-cancel-user-modal" class="btn btn-outline">닫기</button>
|
||||||
<button id="btn-save-user-asset" class="btn btn-primary" style="height: 42px;">수정</button>
|
<button id="btn-save-user-asset" class="btn btn-primary">수정</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,6 +119,8 @@ 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 {
|
||||||
@@ -128,17 +130,13 @@ 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) {
|
||||||
if (mode === 'add') {
|
titleEl.textContent = (mode === 'add') ? '신규 임직원 등록' : '임직원 정보 수정';
|
||||||
titleEl.textContent = '신규 임직원 등록';
|
|
||||||
} else {
|
|
||||||
titleEl.textContent = '임직원 정보 수정';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteBtn = document.getElementById('btn-delete-user-asset')!;
|
const deleteBtn = document.getElementById('btn-delete-user-asset')!;
|
||||||
@@ -157,15 +155,30 @@ 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 initUserModal(onSave: () => void, closeModals: () => void) {
|
export function openUserModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { userModal.open(asset, mode); }
|
||||||
userModal.init(onSave, closeModals);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function openUserModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
|
|
||||||
userModal.open(asset, mode);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -24,59 +24,55 @@ const MENU_CONFIG: any = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function renderNavigation(onTabChange: (tab: string) => void) {
|
export function renderNavigation(onTabChange: (tab: string) => void) {
|
||||||
const navContainer = document.getElementById('main-nav')!;
|
const header = document.querySelector('.main-header') as HTMLElement;
|
||||||
|
const headerContainer = document.querySelector('.header-container')!;
|
||||||
|
if (!headerContainer) return;
|
||||||
|
|
||||||
const render = () => {
|
const render = () => {
|
||||||
navContainer.innerHTML = '';
|
// 1. 헤더 구조 (Vercel Style: Clean Single Row)
|
||||||
|
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>
|
||||||
(Object.keys(MENU_CONFIG) as Array<keyof typeof MENU_CONFIG>).forEach(catKey => {
|
|
||||||
|
<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 => {
|
||||||
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') {
|
if (state.currentUserRole === 'admin') return tab === '대시보드';
|
||||||
// 관리자(admin)일 경우 대시보드 탭만 노출
|
return tab !== '대시보드';
|
||||||
return tab === '대시보드';
|
|
||||||
} else {
|
|
||||||
// 실무자(user)일 경우 대시보드 제외한 모든 탭 노출
|
|
||||||
return tab !== '대시보드';
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 노출할 서브탭이 없으면 해당 대분류 GNB 메뉴도 렌더링하지 않음
|
if (visibleTabs.length === 0) return;
|
||||||
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');
|
||||||
item.className = `lnb-item ${isActive && state.activeSubTab === tab ? 'active' : ''}`;
|
const isActive = state.activeSubTab === tab;
|
||||||
|
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();
|
||||||
@@ -85,32 +81,39 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
|
|||||||
render();
|
render();
|
||||||
onTabChange(tab);
|
onTabChange(tab);
|
||||||
});
|
});
|
||||||
shelf.appendChild(item);
|
navList.appendChild(item);
|
||||||
});
|
});
|
||||||
group.appendChild(shelf);
|
|
||||||
navContainer.appendChild(group);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── '관리자' 메뉴 별도 추가 (GNB 스타일 - 관리자 역할일 때만 노출) ───
|
// 3. 관리자 전용 '관리도구'
|
||||||
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';
|
adminTrigger.className = 'gnb-trigger admin-trigger';
|
||||||
adminTrigger.innerHTML = '관리자';
|
adminTrigger.innerHTML = '관리도구';
|
||||||
adminTrigger.style.color = 'var(--text-muted)';
|
adminTrigger.addEventListener('click', () => window.open('/map_editor.html', '_blank'));
|
||||||
adminTrigger.style.borderLeft = '1px solid var(--border-color)';
|
navList.appendChild(adminTrigger);
|
||||||
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,63 +1,12 @@
|
|||||||
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,5 +1,4 @@
|
|||||||
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';
|
||||||
|
|
||||||
@@ -21,6 +20,13 @@ 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 = '통합 검색',
|
||||||
@@ -35,6 +41,8 @@ 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>
|
||||||
@@ -82,7 +90,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"></i> ${UI_TEXT.ACTION.RESET_FILTER}
|
<i data-lucide="refresh-ccw" class="icon-sm"></i> ${UI_TEXT.ACTION.RESET_FILTER}
|
||||||
</button>
|
</button>
|
||||||
${getActionButtonsHTML()}
|
${getActionButtonsHTML()}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1,41 +1,8 @@
|
|||||||
import { HardwareAsset, SoftwareAsset, SWUser, HardwareLog } from './excelHandler';
|
import { HardwareAsset, SoftwareAsset, SWUser, HardwareLog, MasterAssetData, SystemUser } from './types';
|
||||||
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;
|
||||||
@@ -60,10 +27,11 @@ 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: []
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -266,4 +234,3 @@ export async function deleteJobSpec(id: number) {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
153
src/core/types.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* 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,41 +19,28 @@ import { createIcons, Plus, X, LayoutDashboard, Monitor, Server, Database, Lapto
|
|||||||
|
|
||||||
|
|
||||||
// 화면 갱신 통합 핸들러
|
// 화면 갱신 통합 핸들러
|
||||||
function refreshView() {
|
function refreshView(tab?: string) {
|
||||||
const mainContent = document.getElementById('main-content')!;
|
const mainContent = document.getElementById('main-content')!;
|
||||||
if (!mainContent) return;
|
if (!mainContent) return;
|
||||||
|
|
||||||
if (state.activeSubTab === '대시보드') {
|
const activeTab = tab || state.activeSubTab;
|
||||||
|
|
||||||
|
if (activeTab === '대시보드') {
|
||||||
renderDashboard(mainContent);
|
renderDashboard(mainContent);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 서버 탭이 아닐 경우 '자산현황(위치)' 뷰 진입 방지 및 강제 리스트 모드 전환
|
// 서버 탭이 아닐 경우 '자산현황(위치)' 뷰 진입 방지 및 강제 리스트 모드 전환
|
||||||
if (state.activeSubTab !== '서버' && state.viewMode === 'location') {
|
if (activeTab !== '서버' && state.viewMode === 'location') {
|
||||||
state.viewMode = 'list';
|
state.viewMode = 'list';
|
||||||
}
|
}
|
||||||
|
|
||||||
const isServerTab = state.activeSubTab === '서버';
|
const isServerTab = activeTab === '서버';
|
||||||
|
|
||||||
mainContent.innerHTML = `
|
mainContent.innerHTML = `
|
||||||
<div class="view-header">
|
<div id="view-body" class="view-container"></div>
|
||||||
<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);
|
||||||
@@ -213,35 +200,19 @@ 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,51 +1,48 @@
|
|||||||
:root {
|
:root {
|
||||||
/* --- System Colors --- */
|
/* --- Vercel Stark Palette --- */
|
||||||
--color-red: #F21D0D;
|
--primary: #171717;
|
||||||
--color-pink: #E8175E;
|
--on-primary: #ffffff;
|
||||||
--color-magenta: #B92ED1;
|
--body: #4d4d4d;
|
||||||
--color-purple: #6D3DC2;
|
--mute: #888888;
|
||||||
--color-navy: #4255bd;
|
--hairline: #ebebeb;
|
||||||
--color-blue: #0D8DF2;
|
--hairline-strong: #a1a1a1;
|
||||||
--color-cyan: #03AEFC;
|
--canvas: #ffffff;
|
||||||
--color-green: #4DB251;
|
--canvas-soft: #fafafa;
|
||||||
--color-yellow: #FFBF00;
|
--canvas-soft-2: #f5f5f5;
|
||||||
--color-orange: #FF9800;
|
|
||||||
--color-dahong: #FF3D00;
|
|
||||||
--color-brown: #A0705F;
|
|
||||||
--color-iron: #7F7F7F;
|
|
||||||
--color-steel: #688897;
|
|
||||||
|
|
||||||
/* --- Primary Brand Levels --- */
|
/* --- Brand Accents --- */
|
||||||
--primary-lv-0: #E9EEED;
|
--color-blue: #0070f3;
|
||||||
--primary-lv-1: #D2DCDB;
|
--color-cyan: #50e3c2;
|
||||||
--primary-lv-2: #A5B9B6;
|
--color-pink: #ff0080;
|
||||||
--primary-lv-3: #789792;
|
--color-violet: #7928ca;
|
||||||
--primary-lv-4: #4B746D;
|
--color-orange: #f5a623;
|
||||||
--primary-lv-5: #35635C;
|
|
||||||
--primary-lv-6: #1E5149;
|
|
||||||
--primary-lv-7: #1B443D;
|
|
||||||
--primary-lv-8: #193833;
|
|
||||||
--primary-lv-9: #162A27;
|
|
||||||
|
|
||||||
/* --- Semantic Colors --- */
|
/* --- Semantic Alignment --- */
|
||||||
--primary-color: var(--primary-lv-6);
|
--primary-color: var(--primary);
|
||||||
--primary-hover: var(--primary-lv-5);
|
--primary-hover: #000000;
|
||||||
--primary-light: var(--primary-lv-0);
|
--primary-light: var(--canvas-soft-2);
|
||||||
|
--text-main: var(--primary);
|
||||||
--edit-mode-color: var(--color-dahong);
|
--text-muted: var(--body);
|
||||||
--edit-mode-light: rgba(255, 61, 0, 0.1);
|
--border-color: var(--hairline);
|
||||||
--edit-mode-focus: rgba(255, 61, 0, 0.3);
|
--bg-color: var(--canvas-soft);
|
||||||
--edit-mode-dark: #cc3100;
|
--bg-light: var(--canvas-soft-2);
|
||||||
|
|
||||||
--text-main: #111827;
|
|
||||||
--text-muted: #6B7280;
|
|
||||||
--border-color: #E5E7EB;
|
|
||||||
--bg-color: #F9FAFB;
|
|
||||||
--bg-light: #FAFAFA;
|
|
||||||
--white: #FFFFFF;
|
--white: #FFFFFF;
|
||||||
--danger: var(--color-red);
|
--danger: #ee0000;
|
||||||
--success: var(--color-green);
|
--success: #0070f3;
|
||||||
--header-height: 52px;
|
--header-height: 64px;
|
||||||
|
|
||||||
|
/* --- 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -55,13 +52,31 @@
|
|||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, .stat-value {
|
||||||
|
letter-spacing: -0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Pretendard Variable', Pretendard, sans-serif;
|
font-family: 'Inter', 'Geist', 'Pretendard Variable', -apple-system, 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: 14px;
|
font-size: var(--fs-base);
|
||||||
|
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 {
|
||||||
@@ -69,67 +84,52 @@ body {
|
|||||||
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(--white);
|
background-color: var(--canvas);
|
||||||
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;
|
||||||
}
|
|
||||||
|
|
||||||
.header-container {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 1.5rem;
|
padding: 0 1.5rem;
|
||||||
gap: 1.5rem;
|
}
|
||||||
|
|
||||||
|
.header-container {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand { display: flex; align-items: center; gap: 0.75rem; }
|
.brand { display: flex; align-items: center; gap: 0.75rem; }
|
||||||
.main-logo { height: 34px; width: auto; }
|
.main-logo { height: clamp(28px, 4vmin, 40px); width: auto; }
|
||||||
.brand h1 { font-size: 1.1rem; font-weight: 800; color: var(--text-main); white-space: nowrap; }
|
.brand h1 { font-size: clamp(0.85rem, 1.4vmin, 1.05rem); font-weight: 600; color: var(--text-main); }
|
||||||
.brand h1 .sub-title { font-size: 0.85rem; color: var(--primary-color); font-weight: 600; margin-left: 0.25rem; }
|
|
||||||
|
|
||||||
.integrated-nav { flex: 1; height: 100%; display: flex; align-items: center; gap: 0.25rem; overflow: hidden; }
|
.integrated-nav { flex: 1; display: flex; align-items: center; margin-left: 2rem; gap: 0.5rem; }
|
||||||
.nav-group { display: flex; align-items: center; height: 100%; position: relative; flex-shrink: 0; }
|
.gnb-trigger {
|
||||||
.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-size: var(--fs-xs);
|
||||||
.nav-group.active .gnb-trigger, .nav-group:hover .gnb-trigger { color: var(--text-main); }
|
font-weight: 500;
|
||||||
.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; }
|
color: var(--text-muted);
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
/* 기본적으로 활성 탭의 서브메뉴 표시 */
|
cursor: pointer;
|
||||||
.nav-group.active.is-showing-shelf .lnb-shelf { display: flex; }
|
border-radius: 9999px;
|
||||||
|
transition: all 0.2s;
|
||||||
/* GNB 전체 영역에 마우스가 올라가면 활성 탭의 서브메뉴를 일단 숨김 (다른 메뉴 탐색 우선) */
|
}
|
||||||
.integrated-nav:hover .nav-group.active.is-showing-shelf .lnb-shelf { display: none; }
|
.gnb-trigger:hover { color: var(--text-main); background: var(--canvas-soft-2); }
|
||||||
|
.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: 1.25rem 2rem 0;
|
padding: 0;
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-container {
|
.view-container {
|
||||||
@@ -140,165 +140,492 @@ input:checked + .slider:before { transform: translateX(16px); }
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-content-wrapper {
|
/* --- View Toggle (Vercel Tab Style) --- */
|
||||||
flex: 1;
|
.view-toggle {
|
||||||
overflow-y: auto;
|
display: inline-flex;
|
||||||
padding-bottom: 2rem;
|
background: var(--canvas-soft-2);
|
||||||
|
padding: 0.2rem;
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
gap: 0.1rem;
|
||||||
|
border-radius: var(--radius-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- View Toggle --- */
|
.toggle-btn {
|
||||||
.view-toggle-container { margin-bottom: 1rem; display: flex; justify-content: flex-start; }
|
padding: 0.35rem 1rem;
|
||||||
.view-toggle { display: inline-flex; background-color: var(--primary-lv-0); padding: 4px; border-radius: 8px; border: 1px solid var(--border-color); }
|
border: none;
|
||||||
.toggle-btn { padding: 6px 16px; font-size: 13px; font-weight: 600; color: var(--text-muted); background: none; border: none; border-radius: 6px; cursor: pointer; }
|
background: transparent;
|
||||||
.toggle-btn.active { background-color: var(--white); color: var(--primary-color); box-shadow: 0 2px 4px rgba(0,0,0,0.05); }
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/* --- System Status List (Docker Style) --- */
|
.toggle-btn:hover { color: var(--text-main); }
|
||||||
.system-status-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
.toggle-btn.active {
|
||||||
.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; }
|
background: var(--canvas);
|
||||||
.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; }
|
color: var(--text-main);
|
||||||
.system-row:hover { border-color: var(--primary-lv-3); box-shadow: 0 4px 12px rgba(0,0,0,0.03); }
|
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||||
.col-status { width: 100px; display: flex; align-items: center; gap: 0.5rem; }
|
font-weight: 600;
|
||||||
.col-info { flex: 1.5; }
|
}
|
||||||
.col-network { flex: 1; }
|
|
||||||
.col-remote { flex: 1; display: flex; align-items: center; gap: 0.5rem; }
|
|
||||||
.col-traffic { flex: 1.2; }
|
|
||||||
.col-actions { width: 120px; display: flex; justify-content: flex-end; }
|
|
||||||
.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); }
|
|
||||||
.asset-primary { font-weight: 700; font-size: 14px; }
|
|
||||||
.asset-secondary { font-size: 12px; color: var(--text-muted); }
|
|
||||||
.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; }
|
|
||||||
.progress-bg { height: 4px; background: var(--primary-lv-0); border-radius: 2px; overflow: hidden; }
|
|
||||||
.progress-fill { height: 100%; background: var(--primary-color); }
|
|
||||||
.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); }
|
|
||||||
|
|
||||||
/* --- Footer --- */
|
/* --- Role Toggle Switch --- */
|
||||||
.main-footer {
|
.role-toggle-wrapper {
|
||||||
height: 28px;
|
|
||||||
background-color: var(--white);
|
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
gap: 0.75rem;
|
||||||
padding: 0 1.5rem;
|
background: var(--canvas-soft-2);
|
||||||
flex-shrink: 0;
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-footer p {
|
.role-label {
|
||||||
font-family: 'Pretendard Variable', Pretendard, sans-serif;
|
font-size: var(--fs-xs);
|
||||||
font-size: 0.75rem;
|
font-weight: 500;
|
||||||
font-weight: 300;
|
color: var(--mute);
|
||||||
line-height: 1.25rem;
|
transition: all 0.2s;
|
||||||
letter-spacing: -0.0175rem;
|
}
|
||||||
color: #777777;
|
|
||||||
user-select: none;
|
.role-label.active {
|
||||||
pointer-events: all;
|
color: var(--primary);
|
||||||
-webkit-user-drag: none;
|
font-weight: 700;
|
||||||
margin: 0;
|
}
|
||||||
padding: 0;
|
|
||||||
|
.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;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden {
|
.search-item {
|
||||||
display: none !important;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-nowrap {
|
.search-item.flex-1 {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-item label {
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mute);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-item input,
|
||||||
|
.search-item select {
|
||||||
|
height: clamp(34px, 4.5vmin, 44px);
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
outline: none;
|
||||||
|
background-color: var(--canvas);
|
||||||
|
color: var(--primary);
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-item select {
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-item input:focus,
|
||||||
|
.search-item select:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
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 {
|
/* --- Modal & View Header Layouts --- */
|
||||||
padding: 2px 6px;
|
.header-left {
|
||||||
border-radius: 4px;
|
display: flex;
|
||||||
font-size: 16px;
|
align-items: center;
|
||||||
font-weight: 700;
|
gap: 1rem;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-primary {
|
/* --- Asset Identity & Header Styling (Global) --- */
|
||||||
background-color: var(--primary-color);
|
.header-identity {
|
||||||
color: white;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-muted {
|
.asset-code-title {
|
||||||
background-color: #9CA3AF;
|
font-size: var(--fs-md);
|
||||||
color: white;
|
font-weight: 600;
|
||||||
|
color: var(--primary);
|
||||||
|
letter-spacing: -0.05em;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-light {
|
.service-type-badge {
|
||||||
background: var(--bg-color);
|
font-size: var(--fs-xs);
|
||||||
color: var(--text-muted);
|
font-weight: 600;
|
||||||
border: 1px solid var(--border-color);
|
color: var(--on-primary);
|
||||||
|
background: var(--primary);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* PC 성능 등급 뱃지 컬러 스타일 */
|
.asset-type-label {
|
||||||
.badge.b-purple {
|
font-size: var(--fs-sm);
|
||||||
background-color: #EDE9FE;
|
font-weight: 500;
|
||||||
color: #7C3AED;
|
color: var(--mute);
|
||||||
border: 1px solid #DDD6FE;
|
line-height: 1;
|
||||||
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 {
|
.main-footer {
|
||||||
color: var(--text-muted);
|
border-top: 1px solid var(--border-color);
|
||||||
font-size: 16px;
|
background-color: var(--canvas);
|
||||||
padding: 1px 5px;
|
color: var(--mute);
|
||||||
border: 1px solid var(--border-color);
|
padding: 1rem 2rem;
|
||||||
border-radius: 3px;
|
text-align: right;
|
||||||
background-color: var(--bg-light);
|
font-size: var(--fs-xs);
|
||||||
|
flex-shrink: 0;
|
||||||
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.font-bold {
|
.main-footer p {
|
||||||
font-weight: 700;
|
margin: 0;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- 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,526 +1,486 @@
|
|||||||
/* --- Premium Executive Dashboard View Specific Styles --- */
|
/* --- Vercel Inspired Premium Dashboard --- */
|
||||||
.dashboard-section-title {
|
.dashboard-section-title {
|
||||||
padding: 0 0 0 8px;
|
padding: 0;
|
||||||
font-size: 1.55rem;
|
font-size: var(--fs-lg);
|
||||||
font-weight: 800;
|
font-weight: 600;
|
||||||
color: var(--text-main);
|
color: var(--primary);
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.05em;
|
||||||
border-left: 4px solid var(--primary-color);
|
margin-bottom: clamp(0.5rem, 1.5vmin, 1.5rem);
|
||||||
margin-bottom: 1rem;
|
line-height: 1;
|
||||||
line-height: 1.2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-grid {
|
/* Background Mesh Gradient for Stats Row */
|
||||||
display: grid;
|
.dashboard-stats-row {
|
||||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
display: flex;
|
||||||
gap: 1.5rem;
|
flex-wrap: wrap;
|
||||||
margin-bottom: 2rem;
|
border-bottom: 1px solid var(--hairline);
|
||||||
|
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%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Premium Executive Divider-based Style (Line-based Division) */
|
.stat-group-item {
|
||||||
.dashboard-card, .stat-card {
|
flex: 1;
|
||||||
background: transparent;
|
min-width: 250px;
|
||||||
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;
|
||||||
transition: opacity 0.2s ease;
|
padding: var(--spacing-base);
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-card:hover, .stat-card:hover {
|
.stat-group-item.bordered {
|
||||||
transform: none;
|
border-left: 1px solid var(--hairline);
|
||||||
box-shadow: none;
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-layout-2col {
|
.stat-group-item .stat-label {
|
||||||
display: grid;
|
font-size: var(--fs-xs);
|
||||||
grid-template-columns: repeat(2, 1fr);
|
font-weight: 500;
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-layout-3col {
|
/* --- Technical Data Alignment --- */
|
||||||
display: grid;
|
.text-primary {
|
||||||
grid-template-columns: repeat(3, 1fr);
|
color: var(--color-blue) !important;
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-card {
|
.detail-stat-header {
|
||||||
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-value-danger {
|
.stat-title {
|
||||||
background: linear-gradient(135deg, #E11D48 0%, #F59E0B 100%);
|
font-size: var(--fs-base);
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
font-weight: 600;
|
||||||
|
color: var(--primary);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-btns button {
|
.detail-stat-body {
|
||||||
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;
|
||||||
box-sizing: border-box;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Location View Styles --- */
|
.loc-summary {
|
||||||
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-toggle-container {
|
.loc-summary span {
|
||||||
display: flex;
|
font-size: var(--fs-sm);
|
||||||
background: #f1f5f9;
|
color: var(--mute);
|
||||||
padding: 0.25rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-toggle-btn {
|
.loc-summary span strong {
|
||||||
padding: 0.5rem 1rem;
|
color: var(--primary);
|
||||||
border: none;
|
font-size: var(--fs-base);
|
||||||
background: transparent;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-toggle-btn:hover {
|
.type-summary {
|
||||||
color: var(--text-main);
|
display: flex;
|
||||||
|
gap: 0.8rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
opacity: 0.9;
|
||||||
|
border-top: 1px dashed var(--hairline);
|
||||||
|
padding-top: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-toggle-btn.active {
|
.type-summary span {
|
||||||
background: var(--white);
|
cursor: help;
|
||||||
color: var(--primary-color);
|
font-size: var(--fs-xs);
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
color: var(--mute);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Enhanced Location View --- */
|
.type-summary span strong {
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Enhanced Location View Layout --- */
|
||||||
.location-view-wrapper {
|
.location-view-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: calc(100vh - 120px);
|
height: 100%;
|
||||||
}
|
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: 1.4fr 1fr;
|
grid-template-columns: 2fr 1fr;
|
||||||
gap: 1.5rem;
|
background: var(--canvas);
|
||||||
padding: 1.5rem;
|
gap: 0;
|
||||||
|
padding: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-container-section {
|
.map-container-section {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
overflow: auto;
|
justify-content: center;
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.box-label-text {
|
/* --- Asset Detail Sidebar --- */
|
||||||
font-size: 0.65rem;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--primary-color);
|
|
||||||
pointer-events: none;
|
|
||||||
text-shadow: 0 0 2px white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.asset-list-section {
|
.asset-list-section {
|
||||||
background: var(--white);
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background: var(--canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
.asset-list-section .section-header {
|
.section-header {
|
||||||
padding: 1rem 1.25rem;
|
padding: 1.5rem;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--hairline);
|
||||||
background: #f8fafc;
|
background: var(--canvas);
|
||||||
}
|
flex-shrink: 0;
|
||||||
|
|
||||||
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compact-table {
|
.sidebar-title {
|
||||||
width: 100%;
|
margin: 0;
|
||||||
border-collapse: collapse;
|
font-size: var(--fs-base);
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
||||||
display: flex;
|
color: var(--primary);
|
||||||
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;
|
||||||
gap: 8px;
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-header-title {
|
.header-identity {
|
||||||
|
display: flex;
|
||||||
|
align-items: center; /* Changed from baseline to center for perfect vertical alignment */
|
||||||
|
gap: 8px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 0.95rem;
|
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;
|
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%;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
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: 18px;
|
font-size: 24px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
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.3rem;
|
font-size: 1.73rem;
|
||||||
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: 18px;
|
font-size: 24px;
|
||||||
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: 17px;
|
font-size: 23px;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
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: 700;
|
font-weight: 800;
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
font-size: 18px;
|
font-size: 24px;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flow-step .step-desc {
|
.flow-step .step-desc {
|
||||||
font-size: 17px;
|
font-size: 23px;
|
||||||
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: 18px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.guide-info-table th {
|
.guide-info-table th {
|
||||||
background: #f8faf9;
|
background: #f8faf9;
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
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: 18px;
|
font-size: 24px;
|
||||||
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: 1.75rem;
|
font-size: 2.33rem;
|
||||||
font-weight: 800;
|
font-weight: 900;
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-header p {
|
.login-header p {
|
||||||
font-size: 0.9375rem;
|
font-size: 1.25rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,14 +94,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.role-card h3 {
|
.role-card h3 {
|
||||||
font-size: 1.125rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-card p {
|
.role-card p {
|
||||||
font-size: 0.8125rem;
|
font-size: 1.08rem;
|
||||||
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: 0.75rem;
|
font-size: 1rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,127 +1,50 @@
|
|||||||
/* --- Page Header for Description --- */
|
/* --- Page Header for Description --- */
|
||||||
.page-header {
|
.page-header {
|
||||||
padding: 1rem 0 0.2rem 0;
|
padding: 1.5rem 2rem 0.5rem; /* Padding added for better whitespace */
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title-group {
|
.page-title-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.3rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
font-size: 16px;
|
font-size: var(--fs-lg);
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
color: var(--primary-color);
|
color: var(--primary);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border-left: 4px solid var(--primary-color);
|
line-height: 1.1;
|
||||||
padding-left: 8px;
|
letter-spacing: -0.05em;
|
||||||
line-height: 1.2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-description {
|
.page-description {
|
||||||
font-size: 12px;
|
font-size: var(--fs-base);
|
||||||
color: var(--text-muted);
|
color: var(--mute);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.4;
|
line-height: 1.5;
|
||||||
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(--white);
|
background-color: var(--canvas);
|
||||||
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 1.2rem;
|
padding: 0.8rem 1rem;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--hairline);
|
||||||
text-align: left; /* 기본은 좌측 정렬 */
|
text-align: left;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,93 +55,93 @@ thead {
|
|||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
background-color: var(--bg-light) !important;
|
background-color: var(--canvas-soft) !important;
|
||||||
font-size: 13px;
|
font-size: var(--fs-xs);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-muted);
|
color: var(--mute);
|
||||||
position: sticky;
|
text-transform: uppercase;
|
||||||
top: 0;
|
letter-spacing: 0.05em;
|
||||||
z-index: 50;
|
box-shadow: inset 0 -1px 0 var(--hairline);
|
||||||
box-shadow: inset 0 1px 0 var(--border-color), inset 0 -1px 0 var(--border-color); /* 상하 테두리 보정 */
|
text-align: center; /* Set default header alignment to center */
|
||||||
text-transform: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
td {
|
td {
|
||||||
font-size: 13px;
|
font-size: var(--fs-base);
|
||||||
color: var(--text-main);
|
color: var(--primary);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
text-align: left; /* Set default data alignment to left */
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody tr:hover {
|
tbody tr:hover {
|
||||||
background-color: var(--bg-color);
|
background-color: var(--canvas-soft-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 정렬 클래스 강제 적용 */
|
/* 정렬 클래스 */
|
||||||
.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: 20%;
|
width: 25%;
|
||||||
min-width: 250px;
|
min-width: 300px;
|
||||||
white-space: normal !important;
|
white-space: normal !important;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
line-height: 1.4;
|
line-height: 1.5;
|
||||||
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: #F3F4F6;
|
background-color: var(--canvas-soft-2) !important;
|
||||||
color: var(--primary-color);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
th.sortable::after {
|
th.sortable::after {
|
||||||
content: '↕';
|
content: '↕';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0.6rem;
|
right: 0.75rem;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
font-size: 11px;
|
font-size: var(--fs-xs);
|
||||||
opacity: 0.3;
|
opacity: 0.4;
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
th.sortable.asc::after {
|
th.sortable.asc::after { content: '▲'; opacity: 1; color: var(--primary); }
|
||||||
content: '▲';
|
th.sortable.desc::after { content: '▼'; opacity: 1; color: var(--primary); }
|
||||||
opacity: 1;
|
|
||||||
color: var(--primary-color);
|
/* --- Compact Table (Used in Dashboards/Modals) --- */
|
||||||
|
.compact-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
}
|
}
|
||||||
|
|
||||||
th.sortable.desc::after {
|
.compact-table th {
|
||||||
content: '▼';
|
padding: 0.75rem 0.5rem;
|
||||||
opacity: 1;
|
font-size: var(--fs-xs);
|
||||||
color: var(--primary-color);
|
font-weight: 600;
|
||||||
|
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,6 +18,14 @@ 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;">
|
||||||
|
|
||||||
<!-- 대시보드 타이틀 및 사용조직 필터 -->
|
<!-- 대시보드 타이틀 및 사용조직 필터 -->
|
||||||
@@ -26,19 +34,20 @@ 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 style="display: flex; align-items: center; gap: 0.75rem;">
|
<div class="flex items-center gap-3">
|
||||||
<span style="font-size: 0.9rem; font-weight: 700; color: #475569; white-space: nowrap;">조직 필터:</span>
|
<span class="detail-label-sm font-bold">조직 필터:</span>
|
||||||
<div id="dashboard-dept-buttons" style="display: flex; gap: 0.3rem; background: #EEF2F6; padding: 4px; border-radius: 8px; border: 1px solid #E2E8F0;">
|
<div id="dashboard-dept-buttons" class="flex gap-1 p-1 bg-canvas-soft border border-hairline rounded-lg">
|
||||||
<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 active" 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>
|
<button class="dept-filter-btn" data-dept="총괄기획실">총괄기획실</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,6 +56,17 @@ 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;">
|
||||||
|
|
||||||
<!-- 핵심 지표 카드 -->
|
<!-- 핵심 지표 카드 -->
|
||||||
@@ -56,50 +76,91 @@ 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 style="display: flex; align-items: flex-end; justify-content: space-between;">
|
<div class="flex items-end justify-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 style="display: flex; align-items: flex-end; justify-content: space-between;">
|
<div class="flex items-end justify-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 style="display: flex; align-items: flex-end; justify-content: space-between;">
|
<div class="flex items-end justify-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 style="display: flex; align-items: flex-end; justify-content: space-between;">
|
<div class="flex items-end justify-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>
|
||||||
@@ -107,6 +168,64 @@ 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;">
|
||||||
|
|
||||||
@@ -189,10 +308,40 @@ 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;">
|
||||||
@@ -211,14 +360,58 @@ 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">
|
<div class="view-container bg-soft">
|
||||||
<h3 class="dashboard-section-title">소프트웨어 라이선스 현황</h3>
|
<h3 class="dashboard-section-title">소프트웨어 라이선스 현황</h3>
|
||||||
|
|
||||||
<div class="dashboard-layout-2col" style="margin-bottom: 1.5rem;">
|
<div class="dashboard-layout-2col mb-6">
|
||||||
<div class="dashboard-card" data-action="ext-usage" style="cursor:pointer; min-height:auto;">
|
<div class="dashboard-card clickable" data-action="ext-usage">
|
||||||
<span style="font-size:1.21rem; font-weight:700; color:var(--text-main);">외부 소프트웨어 사용율</span>
|
<div class="stat-label">외부 소프트웨어 사용율</div>
|
||||||
<div style="font-size: 1.02rem; color:var(--text-muted); margin-bottom: 1rem;">${extQty}카피 중 ${extUsed}개 할당</div>
|
<div class="stat-sub">${extQty}카피 중 ${extUsed}개 할당</div>
|
||||||
<div style="font-size: 2.21rem; font-weight:700; color:var(--dash-primary);">${extPer}%</div>
|
<div class="stat-value text-primary">${extPer}%</div>
|
||||||
<div style="width: 100%; height: 4px; background-color: var(--border-color); border-radius: 2px; overflow: hidden; margin-top: 0.5rem;">
|
<div class="stat-progress-bar">
|
||||||
<div style="width: ${extPer}%; height: 100%; background-color: var(--dash-primary);"></div>
|
<div class="progress-fill" style="width: ${extPer}%;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-card" data-action="int-usage" style="cursor:pointer; min-height:auto;">
|
<div class="dashboard-card clickable" data-action="int-usage">
|
||||||
<span style="font-size:1.21rem; font-weight:700; color:var(--text-main);">내부 소프트웨어 현황</span>
|
<div class="stat-label">내부 소프트웨어 현황</div>
|
||||||
<div style="font-size: 1.02rem; color:var(--text-muted); margin-bottom: 1rem;">등록된 내부 솔루션: ${intTotal}개</div>
|
<div class="stat-sub">등록된 내부 솔루션: ${intTotal}개</div>
|
||||||
<div style="font-size: 2.21rem; font-weight:700; color:var(--dash-primary);">${intPer}%</div>
|
<div class="stat-value text-primary">${intPer}%</div>
|
||||||
<div style="width: 100%; height: 4px; background-color: var(--border-color); border-radius: 2px; overflow: hidden; margin-top: 0.5rem;">
|
<div class="stat-progress-bar">
|
||||||
<div style="width: ${intPer}%; height: 100%; background-color: var(--dash-primary);"></div>
|
<div class="progress-fill" style="width: ${intPer}%;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="dashboard-section-title">2026년 누적 도입 비용 분석</h3>
|
<h3 class="dashboard-section-title">2026년 누적 도입 비용 분석</h3>
|
||||||
|
|
||||||
<div style="display:grid; grid-template-columns: repeat(2, 1fr); gap:1.5rem; margin-bottom:1.5rem;">
|
<div class="dashboard-layout-2col">
|
||||||
<div class="dashboard-card" style="min-height:auto;">
|
<div class="dashboard-card">
|
||||||
<span style="font-size:1.21rem; font-weight:700; color:var(--text-main);">외부 SW 누적 비용 (2026)</span>
|
<div class="stat-label">외부 SW 누적 비용 (2026)</div>
|
||||||
<div style="font-size: 2.21rem; font-weight:700; color:var(--dash-primary);">₩ ${extCost2026.toLocaleString()}</div>
|
<div class="stat-value text-primary">₩ ${extCost2026.toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-card" style="min-height:auto;">
|
<div class="dashboard-card">
|
||||||
<span style="font-size:1.21rem; font-weight:700; color:var(--text-main);">내부 SW 누적 비용 (2026)</span>
|
<div class="stat-label">내부 SW 누적 비용 (2026)</div>
|
||||||
<div style="font-size: 2.21rem; font-weight:700; color:#3b82f6;">₩ ${intCost2026.toLocaleString()}</div>
|
<div class="stat-value text-blue">₩ ${intCost2026.toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -168,4 +168,3 @@ function renderSubTabs(container: HTMLElement) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,10 +22,11 @@ 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, width: '15%', render: a => formatInline(a[ASSET_SCHEMA.ASSET_PURPOSE.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_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, 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, align: 'center', 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,12 +4,11 @@ import { ASSET_SCHEMA } from '../core/schema';
|
|||||||
import { LOCATION_DATA, IMAGE_LOCATIONS } from '../components/Modal/SharedData';
|
import { LOCATION_DATA, IMAGE_LOCATIONS } from '../components/Modal/SharedData';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 위치 중심 자산 현황 뷰 (Refined)
|
* 위치 중심 자산 현황 뷰 (Vercel Integrated)
|
||||||
*/
|
*/
|
||||||
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;
|
||||||
@@ -26,7 +25,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 =>
|
||||||
@@ -39,42 +38,50 @@ export async function renderLocationView(container: HTMLElement) {
|
|||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="location-view-wrapper">
|
<div class="location-view-wrapper">
|
||||||
<!-- 2단계 필터 바 -->
|
<!-- 상단 통합 바 (Vercel Style) -->
|
||||||
<div class="location-filter-bar">
|
<div class="location-filter-bar">
|
||||||
<div class="filter-group">
|
<div class="filter-actions-group">
|
||||||
<label>건물/위치</label>
|
<div class="filter-group">
|
||||||
<select id="sel-loc-main">
|
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; text-transform: none; font-weight: 500; color: var(--primary);">
|
||||||
${Object.keys(LOCATION_DATA).map(loc => `<option value="${loc}" ${loc === currentLoc ? 'selected' : ''}>${loc}</option>`).join('')}
|
<input type="checkbox" id="chk-list-view-loc" style="width: 16px; height: 16px; cursor: pointer;" />
|
||||||
</select>
|
목록보기
|
||||||
</div>
|
</label>
|
||||||
<div class="filter-group">
|
</div>
|
||||||
<label>상세 위치</label>
|
<div class="filter-group">
|
||||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
<label>건물/위치</label>
|
||||||
<select id="sel-loc-detail">
|
<select id="sel-loc-main" class="form-select-sm">
|
||||||
${(LOCATION_DATA[currentLoc] || []).map(det => `<option value="${det}" ${det === currentDetail ? 'selected' : ''}>${det}</option>`).join('')}
|
${Object.keys(LOCATION_DATA).map(loc => `<option value="${loc}" ${loc === currentLoc ? 'selected' : ''}>${loc}</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" 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="map-pagination-group">
|
||||||
<div class="page-btns">
|
<div class="page-btns">
|
||||||
<button id="btn-prev-page" class="btn btn-outline btn-sm" style="height: 28px; padding: 0 8px;" ${currentPage === 0 ? 'disabled' : ''}>이전</button>
|
<button id="btn-prev-page" class="btn btn-outline btn-sm" ${currentPage === 0 ? '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>
|
<button id="btn-next-page" class="btn btn-outline btn-sm" ${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" style="height: calc(100vh - 180px); align-items: stretch; gap: 1rem; padding: 1rem; overflow: hidden; display: grid; grid-template-columns: 1.4fr 1fr;">
|
<div class="location-main-content">
|
||||||
<!-- 지도 섹션: 상단 고정 정렬로 밀림 방지 -->
|
<!-- 지도 섹션 -->
|
||||||
<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-container-section">
|
||||||
<div class="map-frame-wrapper" style="position: relative; width: 100%; height: 100%; display: flex; align-items: flex-start; justify-content: center;">
|
<div class="map-frame-wrapper">
|
||||||
${mapPath ? `
|
${mapPath ? `
|
||||||
<img src="${mapPath}" id="main-map-img" style="max-width: 100%; max-height: 100%; object-fit: contain; display: block;">
|
<img src="${mapPath}" id="main-map-img" class="map-image">
|
||||||
<div id="box-overlay" style="position: absolute; pointer-events: none; transition: none;">
|
<div id="box-overlay" class="map-overlay">
|
||||||
${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 `
|
||||||
@@ -82,35 +89,32 @@ 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="position: absolute; left:${box.x}%; top:${box.y}%; width:${box.w}%; height:${box.h}%;
|
style="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 style="padding: 5rem; text-align:center; color: #999;">해당 위치의 도면이 등록되지 않았습니다.</div>'}
|
` : '<div class="no-map-message">해당 위치의 도면이 등록되지 않았습니다.</div>'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 상세 정보 섹션: 내부 스크롤만 허용 -->
|
<!-- 상세 정보 섹션 -->
|
||||||
<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="asset-list-section">
|
||||||
<div class="section-header" style="flex-shrink: 0; background: #f8fafc; border-bottom: 1px solid var(--border-color); padding: 1rem;">
|
<div class="section-header">
|
||||||
<h4 id="loc-list-title" style="margin:0; font-size: 0.95rem; font-weight: 700;">📍 구역을 선택하세요</h4>
|
<h4 id="loc-list-title" class="sidebar-title">구역을 선택하세요</h4>
|
||||||
</div>
|
</div>
|
||||||
<div id="loc-asset-table-container" class="mini-table-wrapper" style="flex: 1; overflow-y: auto; padding: 0;">
|
<div id="loc-asset-table-container" class="mini-table-wrapper">
|
||||||
<div class="empty-state" style="padding: 3rem 1rem;">지도에서 자산 위치를 클릭하세요.</div>
|
<div class="empty-state">지도에서 자산 위치를 클릭하세요.</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';
|
||||||
@@ -123,7 +127,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;
|
||||||
}
|
}
|
||||||
@@ -132,7 +136,6 @@ 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;
|
||||||
@@ -151,6 +154,24 @@ 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');
|
||||||
@@ -163,10 +184,7 @@ export async function renderLocationView(container: HTMLElement) {
|
|||||||
String(a.loc_y) === String(y)
|
String(a.loc_y) === String(y)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (targetAsset) {
|
if (targetAsset) renderAssetDetail(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)';
|
||||||
});
|
});
|
||||||
@@ -179,62 +197,52 @@ export async function renderLocationView(container: HTMLElement) {
|
|||||||
|
|
||||||
title.innerHTML = `
|
title.innerHTML = `
|
||||||
<div class="detail-header-actions">
|
<div class="detail-header-actions">
|
||||||
<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>
|
<div class="header-identity">
|
||||||
<span class="detail-header-title">자산 상세 정보</span>
|
<span class="asset-code-title">${asset.asset_code || '미부여'}</span>
|
||||||
<button id="btn-edit-from-loc" class="btn btn-primary btn-sm" style="font-size: 11px; height: 28px;">수정</button>
|
<span class="service-type-badge">${asset.service_type || '운영'}</span>
|
||||||
|
<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 renderSection = (title: string, fields: { label: string; value: any }[]) => `
|
const fields = [
|
||||||
<div class="detail-section">
|
{ label: ASSET_SCHEMA.CURRENT_DEPT.ui, value: asset.current_dept },
|
||||||
<div class="detail-section-title">${title}</div>
|
{ label: ASSET_SCHEMA.HW_STATUS.ui, value: asset.hw_status },
|
||||||
<div class="detail-grid">
|
{ label: ASSET_SCHEMA.MANAGER_MAIN.ui, value: asset.manager_primary },
|
||||||
|
{ 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-label">${f.label}</div>
|
<div class="detail-item ${f.fullWidth ? 'full-width' : ''}">
|
||||||
<div class="detail-value">${f.value || '-'}</div>
|
<div class="detail-label-sm">${f.label}</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');
|
||||||
});
|
});
|
||||||
|
|||||||