9 Commits

Author SHA1 Message Date
6ed2faee2d merge: remote main updates into ux_setting with style preservation
- Resolved conflicts in state.ts, HwDashboard.ts, ListFactory.ts, and PartsMasterListView.ts
- Prioritized latest functional logic from main branch (Job Spec mapping, Matrix calculations)
- Maintained Vercel-inspired UI styling and unified CSS classes from ux_setting branch
- Synchronized PC status toggle visibility rules with latest main branch changes
2026-06-17 13:08:59 +09:00
89d3ac2e89 style: unify UI styling & restore dashboard logic
- Restored HW/SW Dashboard full features (Chart.js, filters, tables) from main
- Unified Search Bar & Filter Bar across all views (List, Location)
- Integrated asset identity info into all Modal Headers
- Standardized 'Remove Row' buttons as high-visibility circular circles
- Centralized hardcoded inline styles into dedicated CSS files
- Fixed various ReferenceErrors and layout regressions in HWModal
2026-06-17 12:29:26 +09:00
b37981506e style: revert content/logic to main while preserving Vercel UI styles
- Reverted HWModal to unified form structure from main branch
- Restored original field positions and visibility logic in all modals
- Applied Vercel-inspired CSS classes and removed legacy inline styles
- Restored SwDashboard 2x2 layout from main
- Cleaned up unused modular form files
- Fixed TypeError related to ASSET_MFR schema key
2026-06-17 10:46:24 +09:00
73ef13f3a5 style: apply Vercel-inspired responsive UI & fluid scaling 2026-06-16 17:43:20 +09:00
155570e8de style: disable global text selection to prevent accidental UI dragging 2026-06-16 14:32:16 +09:00
119c799d1d style: 레이아웃 비율 복구 및 타이포그래피 전역 표준화 (16px Base)
- 주요 변경 사항:
  1. 레이아웃 안정화: 서버 위치도 뷰의 2:1 비율 복원 및 가변형(Adaptive) 레이아웃 적용
  2. 타이포그래피 표준화: 전역 폰트 스케일 도입 및 기본 폰트 사이즈 상향 (15px -> 16px)
  3. 3-Way 토글 통합: [자산 위치] [운영 현황] [자산 목록] 간의 전환 오류 수정 및 UI 통일
  4. 하드코딩 제거: 인라인 스타일을 CSS 클래스 및 변수 체계로 전면 리팩토링
  5. 가이드 업데이트: 변경된 디자인 정책을 design_rule.md에 반영
2026-06-15 14:21:54 +09:00
b169176d57 WIP(style): UI 컴포넌트 하드코딩 제거 및 CSS 통합 (진행 중)
- 작업 상태: 진행 중 (Work In Progress)
- 주요 변경 사항:
  1. CSS 파일 통합: HWModal, SWModal, ListFactory 등에서 인라인 스타일(style 속성) 전면 제거 및 클래스 기반으로 재작성
  2. 폰트/타이포그래피 스케일업: 최소 폰트 14px 기준으로 전체 텍스트 크기 상향 및 굵기(font-weight) 상향 조정
  3. GNB(상단바) 레이아웃 개편: 2단 구조(로고 라인 / 메뉴 라인)로 변경 및 카테고리 텍스트 라벨 생략을 통한 간결화
  4. 로고 이미지 교체: image 92.png로 업데이트 및 경로 정리
  5. 디자인 가이드 분리: README에서 design_rule.md로 디자인 정책 문서 독립

* 참고: 현재 디자인 검토를 위한 중간 반영 상태이며, 피드백에 따라 추가 수정 예정임.
2026-06-12 15:57:20 +09:00
56abdddbc7 Merge remote-tracking branch 'origin/main' into ux_setting 2026-06-12 13:34:13 +09:00
fd9e88d7c6 style: 리팩토링 및 CSS 통합 작업 완료 (하드코딩 스타일 제거) 2026-06-12 13:29:59 +09:00
61 changed files with 8102 additions and 3208 deletions

736
DESIGN-vercel.md Normal file
View 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 1001000 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 | 600959px | 3-up grids drop to 2-up; nav still horizontal. |
| Desktop | 9601199px | Full 3-up grids; pricing 3-up. |
| Wide | 12001399px | 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.

View File

@@ -28,29 +28,8 @@
### 🎨 ITAM 시스템 디자인 가이드 (Design Guide)
1. **디자인 철학 (Design Philosophy)**
* **Minimalist & Border-based**: 불필요한 박스(Card) 사용을 최소화하고, 정보의 구분은 간결한 라인(Border/Divider)을 활용하여 시각적 피로도를 낮춥니다.
* **Professional Achromatic**: 무채색(Black, White, Grey)을 기본으로 하여 정돈된 업무 환경을 제공합니다.
* **Green Accent**: 블루 대신 짙은 그린(`#1E5149`)을 포인트 컬러로 사용하여 차분한 전문성을 강조합니다.
디자인 일관성 및 시각적 원칙에 관한 상세 내용은 아래 문서를 참조하십시오.
2. **타이포그래피 (Typography)**
* **Font Family**: `Pretendard` (전역 적용)
* **Letter Spacing**: `-0.02em` (약 -2%) 적용. 자간을 좁게 설정하여 밀도 있고 세련된 가독성을 확보합니다.
* **Weights**: 400(Regular), 500(Medium), 600(SemiBold), 700(Bold).
👉 **[디자인 가이드 바로가기 (design_rule.md)](./design_rule.md)**
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
View 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
View 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
View 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
View 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
View 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
View 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
View 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();
}
})();

View File

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

View 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>

File diff suppressed because one or more lines are too long

View 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>

View 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>

View 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>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ITAM 자산관리 ERP</title>
<title>한맥가족 자산관리시스템</title>
<link rel="stylesheet"
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" />
@@ -25,7 +25,7 @@
<div class="header-container" id="nav-container">
<div class="brand">
<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>
<!-- Navigation (GNB + LNB in same row) -->
@@ -57,8 +57,7 @@
<!-- 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>Powered by BARON Consultant Co,Ltd</p>
<p>&copy; 2026 BARON Consultant Co,Ltd. All rights reserved.</p>
</footer>
</div>

72
rebuild_asset_codes.cjs Normal file
View 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();
}
})();

View 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
View 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();

View 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();

View File

@@ -111,9 +111,16 @@ export function closeModals() {
}
export function initBaseModal() {
// ESC 키로 모든 모달 닫기
// ESC 키로 모든 모달 닫기 (위치보기 팝업이 있으면 그것부터 닫음)
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 };

View File

@@ -4,14 +4,14 @@ import { createIcons, X } from 'lucide';
const DASHBOARD_DETAIL_MODAL_HTML = `
<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">
<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>
</div>
<div class="modal-body">
<div class="table-container">
<table style="width:100%;">
<table>
<thead></thead>
<tbody id="dashboard-detail-tbody"></tbody>
</table>

View File

@@ -2,7 +2,7 @@ import { state, saveAsset, deleteAsset } from '../../core/state';
import { BaseModal } from './BaseModal';
import { CORP_LIST } from './SharedData';
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 { UI_TEXT } from '../../core/schema';
@@ -16,8 +16,11 @@ class DomainAssetModal extends BaseModal {
<div id="domain-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="domain-modal-title">${this.title}</h2>
<button id="btn-close-domain-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
<div class="header-left">
<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="닫기">&times;</button>
</div>
<div class="modal-body">
<div class="modal-body-split">
@@ -58,7 +61,7 @@ class DomainAssetModal extends BaseModal {
</div>
<div class="form-group">
<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 class="form-section-title">담당자 및 비고</div>
@@ -78,9 +81,9 @@ class DomainAssetModal extends BaseModal {
</div>
<div class="modal-history-area">
<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">
이력 추가 <i data-lucide="plus" style="width:14px; height:14px;"></i>
이력 추가 <i data-lucide="plus"></i>
</button>
</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 {
@@ -158,6 +161,7 @@ class DomainAssetModal extends BaseModal {
setFieldValue('domain-remarks', asset.remarks || '');
this.renderHistory(asset.id);
this.updateHeaderIdentity(asset);
}
protected onAfterOpen(asset: any, mode: string): void {
@@ -166,6 +170,28 @@ class DomainAssetModal extends BaseModal {
const deleteBtn = document.getElementById('btn-delete-domain-asset');
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) {
@@ -173,16 +199,10 @@ class DomainAssetModal extends BaseModal {
if (!container) return;
const logs = (state.masterData.logs || []).filter(l => l.assetId === assetId);
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 function initDomainModal(onSave: () => void, closeModals: () => void) {
domainModal.init(onSave, closeModals);
}
export function openDomainModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
domainModal.open(asset, mode);
}
export function initDomainModal(onSave: () => void, closeModals: () => void) { domainModal.init(onSave, closeModals); }
export function openDomainModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { domainModal.open(asset, mode); }

File diff suppressed because it is too large Load Diff

View File

@@ -117,13 +117,22 @@ export function setEditLock(
form.classList.remove('is-view-mode');
form.classList.add('is-edit-mode');
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)' 시에만 노출
if (generateBtn) {
generateBtn.style.display = mode === 'add' ? 'flex' : 'none';
}
// 내역 추가 버튼 노출
if (addLogBtn) addLogBtn.style.display = 'flex';
} else {
// 조회 모드 (잠금)
@@ -132,7 +141,13 @@ export function setEditLock(
saveBtn.textContent = '수정';
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 (addLogBtn) addLogBtn.style.display = 'none';
}
@@ -169,9 +184,9 @@ export function createModalFrameHTML(
</div>
<div class="modal-history-area">
<div class="history-header">
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> ${options.historyTitle}</h3>
<button type="button" id="${options.addLogBtnId}" class="btn btn-outline btn-sm">
내역 추가 <i data-lucide="plus" style="width:14px; height:14px;"></i>
<h3><i data-lucide="history" class="icon-sm"></i> ${options.historyTitle}</h3>
<button type="button" id="btn-add-${idPrefix}-log" class="btn btn-outline btn-sm">
내역 추가 <i data-lucide="plus" class="icon-sm"></i>
</button>
</div>
<div id="${idPrefix}-history-list" class="history-timeline"></div>

View File

@@ -61,7 +61,12 @@ export class PCFlowModal {
this.currentFlowType = 'checkout';
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
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) {
container.innerHTML = '';
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');
return;
}
users.forEach(u => {
const item = document.createElement('div');
item.style.padding = '8px 12px';
item.style.cursor = 'pointer';
item.style.fontSize = '13px';
item.style.borderBottom = '1px solid #F3F4F6';
item.className = 'suggestion-item';
item.className = 'autocomplete-item';
item.innerHTML = `
<div style="font-weight: 700; color: var(--text-main);">${u.user_name}</div>
<div style="font-size: 11px; color: var(--text-muted); display: flex; gap: 8px;">
<div class="suggestion-name">${u.user_name}</div>
<div class="suggestion-meta">
<span>부서: ${u.dept_name}</span>
<span>|</span>
<span>사번: ${u.emp_no || '-'}</span>
@@ -338,21 +339,17 @@ export class PCFlowModal {
private renderPCSuggestions(pcs: any[], container: HTMLElement, onSelect: (pc: any) => void) {
container.innerHTML = '';
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');
return;
}
pcs.forEach(p => {
const item = document.createElement('div');
item.style.padding = '8px 12px';
item.style.cursor = 'pointer';
item.style.fontSize = '13px';
item.style.borderBottom = '1px solid #F3F4F6';
item.className = 'suggestion-item';
item.className = 'autocomplete-item';
item.innerHTML = `
<div style="font-weight: 700; color: var(--primary-color);">${p.asset_code} (${p.model_name || '모델명 없음'})</div>
<div style="font-size: 11px; color: var(--text-muted);">
<div class="suggestion-name">${p.asset_code} (${p.model_name || '모델명 없음'})</div>
<div class="suggestion-meta">
사양: CPU ${p.cpu || '-'} / RAM ${p.ram || '-'} / 위치: ${p.location || '-'}
</div>
`;
@@ -433,14 +430,14 @@ export class PCFlowModal {
);
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 {
userPcsList.innerHTML = userPcs.map(p => {
const isSelected = this.selectedPC && this.selectedPC.id === p.id;
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 style="font-weight: 700; font-size: 13px; color: ${isSelected ? 'var(--primary-color)' : 'var(--text-main)'};">${p.asset_code}</div>
<div style="font-size: 11px; color: var(--text-muted); margin-top: 2px;">
<div class="user-pc-item ${isSelected ? 'selected' : ''}" data-id="${p.id}">
<div class="pc-item-code">${p.asset_code}</div>
<div class="pc-item-meta">
${p.model_name || '모델명 없음'} | CPU: ${p.cpu || '-'} | RAM: ${p.ram || '-'}
</div>
</div>
@@ -465,159 +462,132 @@ export class PCFlowModal {
}
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 `
<div id="pc-flow-modal" class="modal-overlay hidden" style="${overlayStyle}">
<div class="modal-content" style="${contentStyle}">
<div class="modal-header" style="background: var(--primary-color); padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-color);">
<h2 style="margin: 0; font-size: 18px; font-weight: 800; color: white; display: flex; align-items: center; gap: 8px;">
<div id="pc-flow-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 class="modal-title">
<i data-lucide="refresh-cw"></i> PC 이동/반납 (불출/반납/이동)
</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;">&times;</button>
<button id="btn-close-pc-flow-modal" class="btn-icon" aria-label="닫기">&times;</button>
</div>
<div class="modal-body" style="padding: 24px; overflow-y: auto; display: flex; gap: 24px;">
<!-- 왼쪽 영역: 입력 폼 -->
<div style="flex: 1.2; display: flex; flex-direction: column; gap: 20px;">
<div class="modal-body">
<div class="modal-body-split">
<!-- 왼쪽 영역: 입력 폼 -->
<div class="modal-form-area">
<div class="grid-form flex-col">
<!-- 1. 처리 유형 -->
<div>
<label style="${labelStyle}">1. 처리 유형 선택</label>
<div style="display: flex; gap: 12px;">
<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;">
<input type="radio" name="flow-type" value="checkout" checked style="display:none;" />
불출 (지급)
</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;">
<input type="radio" name="flow-type" value="return" style="display:none;" />
입고 (반납)
</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;">
<input type="radio" name="flow-type" value="move" style="display:none;" />
이동 (이관)
</label>
<!-- 1. 처리 유형 -->
<div class="form-group">
<label>1. 처리 유형 선택</label>
<div class="view-toggle w-full flex-row">
<label class="flow-type-label toggle-btn active flex-1 text-center">
<input type="radio" name="flow-type" value="checkout" checked class="hidden" />
불출 (지급)
</label>
<label class="flow-type-label toggle-btn flex-1 text-center">
<input type="radio" name="flow-type" value="return" class="hidden" />
입고 (반납)
</label>
<label class="flow-type-label toggle-btn flex-1 text-center">
<input type="radio" name="flow-type" value="move" class="hidden" />
이동 (이관)
</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>
<!-- 2. 대상 사용자 검색 -->
<div style="position: relative;">
<label id="user-search-label" style="${labelStyle}">2. 대상 사원 검색</label>
<div style="position: relative; display: flex; align-items: center;">
<input type="text" id="pc-flow-user-search" placeholder="사원명, 부서, 사번 검색..." style="${inputWithIconStyle}" />
<i data-lucide="search" style="position: absolute; left: 10px; width: 16px; height: 16px; color: var(--text-muted);"></i>
<!-- 오른쪽 영역: 선택 요약 & 사원 소유 자산 목록 -->
<div class="modal-history-area">
<div class="history-header">
<h3>선택 내역 요약</h3>
</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 id="target-user-search-container" class="hidden" style="position: relative;">
<label style="${labelStyle}">새 인수 사원 검색</label>
<div style="position: relative; display: flex; align-items: center;">
<input type="text" id="pc-flow-target-user-search" placeholder="사원명, 부서, 사번 검색..." style="${inputWithIconStyle}" />
<i data-lucide="search" style="position: absolute; left: 10px; width: 16px; height: 16px; color: var(--text-muted);"></i>
<div class="dynamic-row-container">
<!-- 사원 요약 카드 -->
<div id="summary-user-card" class="summary-info-card">
<div class="detail-label-sm">대상 사원</div>
<div id="summary-user-name" class="detail-value-lg">선택된 사원 없음</div>
<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 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 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);">
<button id="btn-cancel-pc-flow-modal" class="btn btn-outline" style="height: 42px;">취소</button>
<button id="btn-submit-pc-flow" class="btn btn-primary" style="height: 42px;">이동/반납 처리 완료</button>
<div class="modal-footer">
<div></div>
<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>
<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>
`;
}
}

View File

@@ -1,7 +1,7 @@
import { state, savePartsMaster, deletePartsMaster } from '../../core/state';
import { BaseModal } from './BaseModal';
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';
class PartsMasterModal extends BaseModal {
@@ -10,52 +10,51 @@ class PartsMasterModal extends BaseModal {
}
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 `
<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">
<h2 id="parts-master-modal-title" style="margin: 0; font-size: 18px; font-weight: 800; color: white;">${this.title}</h2>
<button id="btn-close-parts-master-modal" class="btn-icon" aria-label="닫기" style="font-size: 28px; color: white; background: none; border: none; cursor: pointer; line-height: 1;">&times;</button>
<div class="header-left">
<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="닫기">&times;</button>
</div>
<div class="modal-body" style="padding: 24px; overflow-y: auto;">
<form id="parts-master-asset-form" class="grid-form" style="display: flex; flex-direction: column; gap: 16px;">
<div class="modal-body">
<form id="parts-master-asset-form" class="grid-form vertical-form">
<input type="hidden" id="parts-master-id" name="id" />
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">부품 분류</label>
<select id="parts-master-category" name="category" style="${selectStyle}">
<div class="form-group">
<label>부품 분류</label>
<select id="parts-master-category" name="category">
<option value="CPU">CPU</option>
<option value="GPU">GPU</option>
<option value="RAM">RAM</option>
</select>
</div>
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">부품 표준 명칭</label>
<input type="text" id="parts-master-component-name" name="component_name" placeholder="예: Intel Core i7-14700K" required style="${inputStyle} width: 100%;" />
<div class="form-group">
<label>부품 표준 명칭</label>
<input type="text" id="parts-master-component-name" name="component_name" placeholder="예: Intel Core i7-14700K" required />
</div>
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">성능 등급</label>
<input type="text" id="parts-master-score-tier" name="score_tier" placeholder="예: i7 / S / 최적" required style="${inputStyle} width: 100%;" />
<div class="form-group">
<label>성능 등급</label>
<input type="text" id="parts-master-score-tier" name="score_tier" placeholder="예: i7 / S / 최적" required />
</div>
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">감점 점수 (양수로 입력)</label>
<input type="number" id="parts-master-deduction" name="deduction" placeholder="예: 5" required style="${inputStyle} width: 100%;" />
<div class="form-group">
<label>감점 점수 (양수로 입력)</label>
<input type="number" id="parts-master-deduction" name="deduction" placeholder="예: 5" required />
</div>
</form>
</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);">
<button id="btn-delete-parts-master-asset" class="btn btn-outline btn-danger" style="height: 42px;">삭제</button>
<div class="footer-actions" style="display: flex; gap: 8px;">
<button id="btn-revert-parts-master-edit" class="btn btn-outline hidden" style="height: 42px;">수정 취소</button>
<button id="btn-cancel-parts-master-modal" class="btn btn-outline" style="height: 42px;">닫기</button>
<button id="btn-save-parts-master-asset" class="btn btn-primary" style="height: 42px;">수정</button>
<div class="modal-footer">
<button id="btn-delete-parts-master-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-parts-master-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-parts-master-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-parts-master-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
@@ -109,11 +108,13 @@ class PartsMasterModal extends BaseModal {
if (!this.currentAsset || !this.currentAsset.id) return;
if (!confirm('정말로 이 부품 마스터 정보를 삭제하시겠습니까?\n삭제 시 기존 등록 PC 중 이 부품명을 사용하는 PC의 자동완성 정합성 체크에 영향을 줄 수 있습니다.')) return;
if (await deletePartsMaster(this.currentAsset.id)) {
if (await deletePartsMaster(Number(this.currentAsset.id))) {
alert('성공적으로 삭제되었습니다.');
onSave(); this.close(); closeModals();
}
});
createIcons({ icons: { Plus, X, Save } });
}
protected fillFormData(asset: any): void {
@@ -122,23 +123,18 @@ class PartsMasterModal extends BaseModal {
setFieldValue('parts-master-component-name', asset.component_name || '');
setFieldValue('parts-master-score-tier', asset.score_tier || '');
setFieldValue('parts-master-deduction', asset.deduction !== undefined ? asset.deduction.toString() : '0');
this.updateHeaderIdentity(asset);
}
protected onAfterOpen(asset: any, mode: string): void {
const titleEl = document.getElementById('parts-master-modal-title');
if (titleEl) {
if (mode === 'add') {
titleEl.textContent = '신규 부품 마스터 등록';
} else {
titleEl.textContent = '부품 마스터 상세 편집';
}
titleEl.textContent = (mode === 'add') ? '신규 부품 마스터 등록' : '부품 마스터 상세 편집';
}
const deleteBtn = document.getElementById('btn-delete-parts-master-asset')!;
const saveBtn = document.getElementById('btn-save-parts-master-asset')!;
// 추가 모드일 때는 삭제 버튼 숨김
deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
if (mode === 'add') {
@@ -152,15 +148,28 @@ class PartsMasterModal extends BaseModal {
saveBtn.textContent = '수정';
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 function initPartsMasterModal(onSave: () => void, closeModals: () => void) {
partsMasterModal.init(onSave, closeModals);
}
export function openPartsMasterModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
partsMasterModal.open(asset, mode);
}
export function initPartsMasterModal(onSave: () => void, closeModals: () => void) { partsMasterModal.init(onSave, closeModals); }
export function openPartsMasterModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { partsMasterModal.open(asset, mode); }

View File

@@ -1,7 +1,7 @@
import { state, saveAsset, deleteAsset } from '../../core/state';
import { BaseModal } from './BaseModal';
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 { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
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 class="modal-content wide">
<div class="modal-header">
<h2 id="sw-modal-title">${this.title}</h2>
<button id="btn-close-sw-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
<div class="header-left">
<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="닫기">&times;</button>
</div>
<div class="modal-body">
<div class="modal-body-split">
@@ -81,7 +84,7 @@ class SwAssetModal extends BaseModal {
</div>
<div class="form-group sw-standard-field">
<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 class="form-group cloud-only">
@@ -100,12 +103,12 @@ class SwAssetModal extends BaseModal {
<div class="form-section-title">관리 및 비고</div>
<div class="form-group sw-standard-field">
<label>${ASSET_SCHEMA.PURCHASE_DATE.ui}</label>
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
<input type="text" id="sw-구매일" name="purchase_date" style="flex:1;" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-구매일-picker'); p.value = document.getElementById('sw-구매일').value; p.showPicker();" style="padding:0.25rem;">
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
<div class="input-with-btn">
<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();">
<i data-lucide="calendar"></i>
</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 class="form-group sw-standard-field">
@@ -126,12 +129,12 @@ class SwAssetModal extends BaseModal {
</div>
<div class="form-group sw-standard-field" id="sw-expiry-group">
<label>${ASSET_SCHEMA.EXPIRED_DATE.ui}</label>
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
<input type="text" id="sw-만료일" name="expiry_date" style="flex:1;" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-만료일-picker'); p.value = document.getElementById('sw-만료일').value; p.showPicker();" style="padding:0.25rem;">
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
<div class="input-with-btn">
<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();">
<i data-lucide="calendar"></i>
</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 class="form-group full-width">
@@ -140,18 +143,18 @@ class SwAssetModal extends BaseModal {
</div>
</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="사용자 관리">
<i data-lucide="users" style="width:16px; height:16px; margin-right:4px;"></i> 사용자 관리
<i data-lucide="users"></i> 사용자 관리
</button>
</div>
</div>
<div class="modal-history-area">
<div class="history-header" style="display:flex; justify-content:space-between; align-items:center;">
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> 업데이트 내역</h3>
<div class="history-header">
<h3><i data-lucide="history"></i> 업데이트 내역</h3>
<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>
</div>
<div id="sw-history-list" class="history-timeline"></div>
@@ -170,24 +173,24 @@ class SwAssetModal extends BaseModal {
</div>
<!-- 계약 업데이트 서브 모달 -->
<div id="sw-update-modal" class="modal-overlay hidden" style="z-index: 1100;">
<div class="modal-content" style="max-width: 500px;">
<div id="sw-update-modal" class="modal-overlay hidden sub-modal">
<div class="modal-content narrow">
<div class="modal-header">
<h2>계약 업데이트 반영</h2>
<button id="btn-close-sw-update" class="btn-icon"><i data-lucide="x"></i></button>
<h2 class="modal-title">계약 업데이트 반영</h2>
<button id="btn-close-sw-update" class="btn-icon">&times;</button>
</div>
<div class="modal-body">
<div class="grid-form" style="grid-template-columns: 1fr;">
<div class="grid-form vertical-form">
<div class="form-group">
<label>업데이트 일자</label>
<input type="date" id="sw-update-date" />
</div>
<div class="form-group sub-sw-update">
<label>새로운 계약 기간</label>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<input type="text" id="sw-update-start" placeholder="YYYY-MM-DD" style="flex: 1;" />
<div class="input-with-btn">
<input type="text" id="sw-update-start" placeholder="YYYY-MM-DD" />
<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 class="form-group">
@@ -209,6 +212,15 @@ class SwAssetModal extends BaseModal {
</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);
});
// 업데이트 모달 로직
const subModal = document.getElementById('sw-update-modal')!;
const closeUpdate = () => subModal.classList.add('hidden');
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: '관리자' };
await fetch(`${API_BASE_URL}/api/asset/history/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: 'application/json',
body: JSON.stringify([...state.masterData.logs, log])
});
@@ -322,10 +333,32 @@ class SwAssetModal extends BaseModal {
}
this.renderHistory(asset.id);
this.updateHeaderIdentity(asset);
}
protected onAfterOpen(asset: any, mode: string): void {
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) {
@@ -356,16 +389,10 @@ class SwAssetModal extends BaseModal {
if (!container) return;
const logs = (state.masterData.logs || []).filter(l => l.assetId === swId);
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 function initSwModal(onSave: () => void, closeModals: () => void) {
swModal.init(onSave, closeModals);
}
export function openSwModal(asset: any, mode: 'view' | 'add' | 'edit' = 'view') {
swModal.open(asset, mode);
}
export function initSwModal(onSave: () => void, closeModals: () => void) { swModal.init(onSave, closeModals); }
export function openSwModal(asset: any, mode: 'view' | 'add' | 'edit' = 'view') { swModal.open(asset, mode); }

View File

@@ -16,15 +16,15 @@ class SwUserModal extends BaseModal {
<div id="sw-user-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="sw-user-title">${this.title}</h2>
<button id="btn-close-sw-user-modal" class="btn-icon"><i data-lucide="x"></i></button>
<h2 id="sw-user-title" class="modal-title">${this.title}</h2>
<button id="btn-close-sw-user-modal" class="btn-icon" aria-label="닫기">&times;</button>
</div>
<div class="modal-body">
<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;">
<h3 style="font-size:1rem; font-weight:600;">할당된 사용자 목록</h3>
<button type="button" id="btn-open-add-user" class="btn btn-primary btn-sm"><i data-lucide="plus"></i> 사용자 추가</button>
<div class="flex justify-between items-center mb-4">
<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" class="icon-sm"></i> 사용자 추가</button>
</div>
<div class="table-container">
@@ -35,9 +35,9 @@ class SwUserModal extends BaseModal {
<th>부서</th>
<th>직위</th>
<th>이름</th>
<th>사용기간</th>
<th>신청서</th>
<th>관리</th>
<th class="text-center">사용기간</th>
<th class="text-center">신청서</th>
<th class="text-center">관리</th>
</tr>
</thead>
<tbody id="sw-user-table-body"></tbody>
@@ -54,14 +54,14 @@ class SwUserModal extends BaseModal {
</div>
<!-- 사용자 추가/수정 서브 모달 -->
<div id="sw-user-edit-modal" class="modal-overlay hidden" style="z-index: 1100;">
<div class="modal-content" style="width: 400px;">
<div id="sw-user-edit-modal" class="modal-overlay hidden sub-modal">
<div class="modal-content narrow">
<div class="modal-header">
<h3 id="sw-user-edit-title">사용자 정보</h3>
<button id="btn-close-user-edit" class="btn-icon"><i data-lucide="x"></i></button>
<h3 id="sw-user-edit-title" class="modal-title">사용자 정보</h3>
<button id="btn-close-user-edit" class="btn-icon">&times;</button>
</div>
<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" />
<div class="form-group">
<label>조직</label>
@@ -81,22 +81,22 @@ class SwUserModal extends BaseModal {
</div>
<div class="form-group">
<label>사용 시작일</label>
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
<input type="text" id="new-user-시작일" style="flex:1;" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-시작일-picker'); p.value = document.getElementById('new-user-시작일').value; p.showPicker();" style="padding:0.25rem;">
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
<div class="input-with-btn">
<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();">
<i data-lucide="calendar" class="icon-sm"></i>
</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 class="form-group">
<label>사용 종료일</label>
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
<input type="text" id="new-user-종료일" style="flex:1;" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-종료일-picker'); p.value = document.getElementById('new-user-종료일').value; p.showPicker();" style="padding:0.25rem;">
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
<div class="input-with-btn">
<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();">
<i data-lucide="calendar" class="icon-sm"></i>
</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 class="form-group">
@@ -111,6 +111,15 @@ class SwUserModal extends BaseModal {
</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();
});
// 닫기 이벤트들 (BaseModal의 공통 버튼 외 추가분)
document.getElementById('btn-close-sw-user-modal')?.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 {
const swInfo = document.getElementById('sw-user-sw-info')!;
swInfo.innerHTML = `
<div style="background:var(--bg-light); padding:1rem; border-radius:6px; margin-bottom:1.5rem;">
<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:0.25rem;">${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="sw-info-header border-b border-hairline pb-4 mb-6">
<div class="detail-label-sm">${asset.purchase_corp || asset. || ''}</div>
<div class="asset-code-title">${asset.product_name || asset. || ''}</div>
</div>
`;
@@ -173,9 +181,10 @@ class SwUserModal extends BaseModal {
private renderUserList() {
const tbody = document.getElementById('sw-user-table-body')!;
if (!tbody) return;
tbody.innerHTML = '';
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;
}
@@ -186,12 +195,12 @@ class SwUserModal extends BaseModal {
<td>${user. || ''}</td>
<td>${user. || ''}</td>
<td>${user. || ''}</td>
<td>${user. || ''}</td>
<td style="text-align:center;">${user. ? '<i data-lucide="paperclip" class="text-primary"></i>' : '-'}</td>
<td>
<div style="display:flex; gap:0.5rem;">
<td class="text-center">${user. || ''}</td>
<td class="text-center">${user. ? '<i data-lucide="paperclip" class="text-primary icon-sm"></i>' : '-'}</td>
<td class="text-center">
<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-danger btn-del-user" data-idx="${idx}">삭제</button>
<button class="btn-circle-remove btn-del-user" data-idx="${idx}">&times;</button>
</div>
</td>
`;
@@ -257,11 +266,5 @@ class SwUserModal extends BaseModal {
}
export const swUserModal = new SwUserModal();
export function initSwUserModal(onSave: () => void, closeModals: () => void) {
swUserModal.init(onSave, closeModals);
}
export function openSwUserModal(asset: any) {
swUserModal.open(asset);
}
export function initSwUserModal(onSave: () => void, closeModals: () => void) { swUserModal.init(onSave, closeModals); }
export function openSwUserModal(asset: any) { swUserModal.open(asset); }

View File

@@ -10,55 +10,55 @@ class UserModal extends BaseModal {
}
protected renderFrameHTML(): string {
const sharedStyle = 'height: 38px !important; box-sizing: border-box !important; font-size: 13px; margin: 0;';
const inputStyle = sharedStyle;
return `
<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">
<h2 id="user-modal-title" style="margin: 0; font-size: 18px; font-weight: 800; color: white;">\${this.title}</h2>
<button id="btn-close-user-modal" class="btn-icon" aria-label="닫기" style="font-size: 28px; color: white; background: none; border: none; cursor: pointer; line-height: 1;">&times;</button>
<div class="header-left">
<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="닫기">&times;</button>
</div>
<div class="modal-body" style="padding: 24px; overflow-y: auto;">
<form id="user-asset-form" class="grid-form" style="display: flex; flex-direction: column; gap: 16px;">
<div class="modal-body">
<form id="user-asset-form" class="grid-form vertical-form">
<input type="hidden" id="user-id" name="id" />
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">사번</label>
<input type="text" id="user-emp-no" name="emp_no" placeholder="예: HM202601" required style="\${inputStyle} width: 100%;" />
<div class="form-group">
<label>사번</label>
<input type="text" id="user-emp-no" name="emp_no" placeholder="예: HM202601" required />
</div>
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">사용자명</label>
<input type="text" id="user-name-input" name="user_name" placeholder="예: 홍길동" required style="\${inputStyle} width: 100%;" />
<div class="form-group">
<label>사용자명</label>
<input type="text" id="user-name-input" name="user_name" placeholder="예: 홍길동" required />
</div>
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">사용조직 (부서)</label>
<input type="text" id="user-dept" name="dept_name" placeholder="예: 기술개발센터" required style="\${inputStyle} width: 100%;" />
<div class="form-group">
<label>사용조직 (부서)</label>
<input type="text" id="user-dept" name="dept_name" placeholder="예: 기술개발센터" required />
</div>
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">직무 (직급)</label>
<input type="text" id="user-position-input" name="position" placeholder="예: BIM모델러" required style="\${inputStyle} width: 100%;" />
<div class="form-group">
<label>직무 (직급)</label>
<input type="text" id="user-position-input" name="position" placeholder="예: BIM모델러" required />
</div>
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">상태</label>
<select id="user-status" name="status" style="\${sharedStyle}">
<div class="form-group">
<label>상태</label>
<select id="user-status" name="status">
<option value="재직">재직</option>
<option value="퇴직">퇴직</option>
</select>
</div>
</form>
</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);">
<button id="btn-delete-user-asset" class="btn btn-outline btn-danger" style="height: 42px;">삭제</button>
<div class="footer-actions" style="display: flex; gap: 8px;">
<button id="btn-revert-user-edit" class="btn btn-outline hidden" style="height: 42px;">수정 취소</button>
<button id="btn-cancel-user-modal" class="btn btn-outline" style="height: 42px;">닫기</button>
<button id="btn-save-user-asset" class="btn btn-primary" style="height: 42px;">수정</button>
<div class="modal-footer">
<button id="btn-delete-user-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-user-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-user-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-user-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
@@ -119,6 +119,8 @@ class UserModal extends BaseModal {
onSave(); this.close(); closeModals();
}
});
createIcons({ icons: { Save, X } });
}
protected fillFormData(asset: any): void {
@@ -128,17 +130,13 @@ class UserModal extends BaseModal {
setFieldValue('user-dept', asset.dept_name || '');
setFieldValue('user-position-input', asset.position || '');
setFieldValue('user-status', asset.status || '재직');
this.updateHeaderIdentity(asset);
}
protected onAfterOpen(asset: any, mode: string): void {
const titleEl = document.getElementById('user-modal-title');
if (titleEl) {
if (mode === 'add') {
titleEl.textContent = '신규 임직원 등록';
} else {
titleEl.textContent = '임직원 정보 수정';
}
titleEl.textContent = (mode === 'add') ? '신규 임직원 등록' : '임직원 정보 수정';
}
const deleteBtn = document.getElementById('btn-delete-user-asset')!;
@@ -157,15 +155,30 @@ class UserModal extends BaseModal {
saveBtn.textContent = '수정';
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 function initUserModal(onSave: () => void, closeModals: () => void) {
userModal.init(onSave, closeModals);
}
export function openUserModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
userModal.open(asset, mode);
}
export function initUserModal(onSave: () => void, closeModals: () => void) { userModal.init(onSave, closeModals); }
export function openUserModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { userModal.open(asset, mode); }

View File

@@ -24,59 +24,55 @@ const MENU_CONFIG: any = {
};
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 = () => {
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>
// 기존 메뉴 렌더링
(Object.keys(MENU_CONFIG) as Array<keyof typeof MENU_CONFIG>).forEach(catKey => {
<nav class="integrated-nav" id="main-nav-list"></nav>
<div class="header-actions">
<div class="role-toggle-wrapper">
<span class="role-label user ${state.currentUserRole === 'user' ? 'active' : ''}">실무자</span>
<label class="role-toggle">
<input type="checkbox" id="role-toggle-checkbox" ${state.currentUserRole === 'admin' ? 'checked' : ''}>
<span class="role-slider"></span>
</label>
<span class="role-label admin ${state.currentUserRole === 'admin' ? 'active' : ''}">관리자</span>
</div>
<div class="notification-area">
<button class="icon-btn" title="알림"><i data-lucide="bell" style="width:18px; height:18px;"></i></button>
</div>
</div>
`;
const navList = document.getElementById('main-nav-list')!;
// 2. GNB 메뉴 렌더링 (Ghost Tab Style)
Object.keys(MENU_CONFIG).forEach(catKey => {
const config = MENU_CONFIG[catKey];
// 역할에 따라 노출할 서브탭 필터링
const visibleTabs = config.tabs.filter((tab: string) => {
if (state.currentUserRole === 'admin') {
// 관리자(admin)일 경우 대시보드 탭만 노출
return tab === '대시보드';
} else {
// 실무자(user)일 경우 대시보드 제외한 모든 탭 노출
return tab !== '대시보드';
}
if (state.currentUserRole === 'admin') return tab === '대시보드';
return tab !== '대시보드';
});
// 노출할 서브탭이 없으면 해당 대분류 GNB 메뉴도 렌더링하지 않음
if (visibleTabs.length === 0) {
return;
}
const isActive = state.activeCategory === catKey;
const group = document.createElement('div');
group.className = `nav-group ${isActive ? 'active is-showing-shelf' : ''}`;
const trigger = document.createElement('div');
trigger.className = 'gnb-trigger';
trigger.textContent = config.label;
trigger.addEventListener('click', () => {
if (state.activeCategory !== catKey) {
state.activeCategory = catKey as any;
const firstTab = visibleTabs[0] || config.tabs[0];
state.activeSubTab = firstTab;
render();
onTabChange(firstTab);
}
});
group.appendChild(trigger);
const shelf = document.createElement('div');
shelf.className = 'lnb-shelf';
if (visibleTabs.length === 0) return;
visibleTabs.forEach((tab: string) => {
if (tab === '부품 마스터') return; // 메뉴바에서 표시 생략
if (tab === '부품 마스터') return;
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.style.fontSize = 'var(--fs-sm)'; // Ensure small but standard font
item.addEventListener('click', (e) => {
e.stopPropagation();
@@ -85,32 +81,39 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
render();
onTabChange(tab);
});
shelf.appendChild(item);
navList.appendChild(item);
});
group.appendChild(shelf);
navContainer.appendChild(group);
});
// ─── '관리자' 메뉴 별도 추가 (GNB 스타일 - 관리자 역할일 때만 노출) ───
// 3. 관리자 전용 '관리도구'
if (state.currentUserRole === 'admin') {
const adminGroup = document.createElement('div');
adminGroup.className = 'nav-group';
const adminTrigger = document.createElement('div');
adminTrigger.className = 'gnb-trigger';
adminTrigger.innerHTML = '관리';
adminTrigger.style.color = 'var(--text-muted)';
adminTrigger.style.borderLeft = '1px solid var(--border-color)';
adminTrigger.style.marginLeft = '1rem';
adminTrigger.style.paddingLeft = '1.5rem';
adminTrigger.addEventListener('click', () => {
window.open('/map_editor.html', '_blank');
});
adminGroup.appendChild(adminTrigger);
navContainer.appendChild(adminGroup);
adminTrigger.className = 'gnb-trigger admin-trigger';
adminTrigger.innerHTML = '관리도구';
adminTrigger.addEventListener('click', () => window.open('/map_editor.html', '_blank'));
navList.appendChild(adminTrigger);
}
// 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();

View File

@@ -1,63 +1,12 @@
import * as XLSX from 'xlsx';
import { ASSET_SCHEMA } from './schema';
import { HardwareAsset, SoftwareAsset, SWUser, HardwareLog, MasterAssetData } from './types';
/**
* ITAM 엑셀 핸들러 (Database Synchronized Edition)
* 데이터베이스 실제 스키마 컬럼과 엑셀 헤더를 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 기준)
*/

View File

@@ -1,5 +1,4 @@
import { ASSET_SCHEMA, UI_TEXT } from './schema';
import { getActionButtonsHTML } from './utils';
import { generateOptionsHTML } from '../components/Modal/ModalUtils';
import { CORP_LIST } from '../components/Modal/SharedData';
@@ -21,6 +20,13 @@ export interface FilterOptions {
initialFilters?: any;
}
/**
* 전역 액션 버튼 그룹 생성 (자산 추가 등)
*/
export function getActionButtonsHTML(): string {
return `<div id="filter-bar-actions" class="header-action-group"></div>`;
}
export function renderFilterBar(container: HTMLElement, options: FilterOptions) {
const {
keywordLabel = '통합 검색',
@@ -35,6 +41,8 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions)
initialFilters = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '', status: '' }
} = options;
container.classList.add('search-bar'); // Restored class
container.innerHTML = `
<div class="search-item flex-1">
<label>${keywordLabel}</label>
@@ -82,7 +90,7 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions)
</div>` : ''}
${extraHTML}
<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>
${getActionButtonsHTML()}
`;

View File

@@ -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 { dummyPCs, dummyServers, dummyStorages, dummyEquips, dummySubSw, dummyPermSw, dummyCloud, dummyDomain, dummySwUsers, dummyLogs } from './dummyData';
// --- 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 {
activeCategory: 'dashboard' | 'hw' | 'sw' | 'ops' | 'vip' | 'fac' | 'users' | 'etc';
activeSubTab: string;
@@ -60,10 +27,11 @@ export const state: AppState = {
survey: [], pcParts: [], partsMaster: [], equipment: [], officeSupplies: [],
swInternal: [], swExternal: [], cloud: [], domain: [],
cost: [], vip: [],
subSw: [], permSw: [],
hw: [], sw: [],
swUsers: [], logs: [],
jobSpecs: []
jobSpecs: [],
subSw: [],
permSw: []
}
};
@@ -266,4 +234,3 @@ export async function deleteJobSpec(id: number) {
}
return false;
}

153
src/core/types.ts Normal file
View 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[];
}

View File

@@ -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')!;
if (!mainContent) return;
if (state.activeSubTab === '대시보드') {
const activeTab = tab || state.activeSubTab;
if (activeTab === '대시보드') {
renderDashboard(mainContent);
return;
}
// 서버 탭이 아닐 경우 '자산현황(위치)' 뷰 진입 방지 및 강제 리스트 모드 전환
if (state.activeSubTab !== '서버' && state.viewMode === 'location') {
if (activeTab !== '서버' && state.viewMode === 'location') {
state.viewMode = 'list';
}
const isServerTab = state.activeSubTab === '서버';
const isServerTab = activeTab === '서버';
mainContent.innerHTML = `
<div class="view-header">
<div class="view-toggle-container" style="${isServerTab ? '' : 'display:none;'}">
<button class="mode-toggle-btn ${state.viewMode === 'location' ? 'active' : ''}" data-mode="location">자산현황(위치)</button>
<button class="mode-toggle-btn ${state.viewMode === 'list' ? 'active' : ''}" data-mode="list">자산목록</button>
</div>
</div>
<div id="view-body" style="flex: 1; overflow: hidden; display: flex; flex-direction: column;"></div>
<div id="view-body" class="view-container"></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')!;
if (state.viewMode === 'location') {
renderLocationView(viewBody);
@@ -213,35 +200,19 @@ function initRoleSwitcher() {
function initializeAppDirectly() {
const loginContainer = document.getElementById('login-container');
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)
state.currentUserRole = 'user';
state.activeCategory = 'hw';
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 (appLayout) appLayout.style.display = 'flex';
// 앱 초기화
initRoleSwitcher();
// 앱 초기화 및 내비게이션(헤더 포함) 렌더링
initApp();
// 로고 클릭 시 새로고침 (초기 화면 복귀 효과)
const brand = document.querySelector('.brand') as HTMLElement;
if (brand) {
brand.style.cursor = 'pointer';
brand.onclick = () => location.reload();
}
renderNavigation((tab) => refreshView(tab));
}
document.addEventListener('DOMContentLoaded', initializeAppDirectly);

View File

@@ -1,51 +1,48 @@
:root {
/* --- System Colors --- */
--color-red: #F21D0D;
--color-pink: #E8175E;
--color-magenta: #B92ED1;
--color-purple: #6D3DC2;
--color-navy: #4255bd;
--color-blue: #0D8DF2;
--color-cyan: #03AEFC;
--color-green: #4DB251;
--color-yellow: #FFBF00;
--color-orange: #FF9800;
--color-dahong: #FF3D00;
--color-brown: #A0705F;
--color-iron: #7F7F7F;
--color-steel: #688897;
/* --- Vercel Stark Palette --- */
--primary: #171717;
--on-primary: #ffffff;
--body: #4d4d4d;
--mute: #888888;
--hairline: #ebebeb;
--hairline-strong: #a1a1a1;
--canvas: #ffffff;
--canvas-soft: #fafafa;
--canvas-soft-2: #f5f5f5;
/* --- Primary Brand Levels --- */
--primary-lv-0: #E9EEED;
--primary-lv-1: #D2DCDB;
--primary-lv-2: #A5B9B6;
--primary-lv-3: #789792;
--primary-lv-4: #4B746D;
--primary-lv-5: #35635C;
--primary-lv-6: #1E5149;
--primary-lv-7: #1B443D;
--primary-lv-8: #193833;
--primary-lv-9: #162A27;
/* --- Brand Accents --- */
--color-blue: #0070f3;
--color-cyan: #50e3c2;
--color-pink: #ff0080;
--color-violet: #7928ca;
--color-orange: #f5a623;
/* --- Semantic Colors --- */
--primary-color: var(--primary-lv-6);
--primary-hover: var(--primary-lv-5);
--primary-light: var(--primary-lv-0);
--edit-mode-color: var(--color-dahong);
--edit-mode-light: rgba(255, 61, 0, 0.1);
--edit-mode-focus: rgba(255, 61, 0, 0.3);
--edit-mode-dark: #cc3100;
--text-main: #111827;
--text-muted: #6B7280;
--border-color: #E5E7EB;
--bg-color: #F9FAFB;
--bg-light: #FAFAFA;
/* --- Semantic Alignment --- */
--primary-color: var(--primary);
--primary-hover: #000000;
--primary-light: var(--canvas-soft-2);
--text-main: var(--primary);
--text-muted: var(--body);
--border-color: var(--hairline);
--bg-color: var(--canvas-soft);
--bg-light: var(--canvas-soft-2);
--white: #FFFFFF;
--danger: var(--color-red);
--success: var(--color-green);
--header-height: 52px;
--danger: #ee0000;
--success: #0070f3;
--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;
}
h1, h2, h3, .stat-value {
letter-spacing: -0.05em;
}
body {
font-family: 'Pretendard Variable', Pretendard, sans-serif;
font-family: 'Inter', 'Geist', 'Pretendard Variable', -apple-system, sans-serif;
color: var(--text-main);
background-color: var(--bg-color);
line-height: 1.5;
font-size: 14px;
font-size: var(--fs-base);
height: 100vh;
width: 100vw;
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 {
@@ -69,67 +84,52 @@ body {
flex-direction: column;
height: 100vh;
width: 100%;
overflow: hidden;
}
/* --- Header --- */
.main-header {
background-color: var(--white);
background-color: var(--canvas);
border-bottom: 1px solid var(--border-color);
z-index: 100;
height: var(--header-height);
flex-shrink: 0;
}
.header-container {
height: 100%;
display: flex;
align-items: center;
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; }
.main-logo { height: 34px; width: auto; }
.brand h1 { font-size: 1.1rem; font-weight: 800; color: var(--text-main); white-space: nowrap; }
.brand h1 .sub-title { font-size: 0.85rem; color: var(--primary-color); font-weight: 600; margin-left: 0.25rem; }
.main-logo { height: clamp(28px, 4vmin, 40px); width: auto; }
.brand h1 { font-size: clamp(0.85rem, 1.4vmin, 1.05rem); font-weight: 600; color: var(--text-main); }
.integrated-nav { flex: 1; height: 100%; display: flex; align-items: center; gap: 0.25rem; overflow: hidden; }
.nav-group { display: flex; align-items: center; height: 100%; position: relative; flex-shrink: 0; }
.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; }
.nav-group.active .gnb-trigger, .nav-group:hover .gnb-trigger { color: var(--text-main); }
.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; }
/* 기본적으로 활성 탭의 서브메뉴 표시 */
.nav-group.active.is-showing-shelf .lnb-shelf { display: flex; }
/* GNB 전체 영역에 마우스가 올라가면 활성 탭의 서브메뉴를 일단 숨김 (다른 메뉴 탐색 우선) */
.integrated-nav:hover .nav-group.active.is-showing-shelf .lnb-shelf { display: none; }
/* 마우스가 올라간 메뉴의 서브메뉴만 표시 */
.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); }
.integrated-nav { flex: 1; display: flex; align-items: center; margin-left: 2rem; gap: 0.5rem; }
.gnb-trigger {
font-size: var(--fs-xs);
font-weight: 500;
color: var(--text-muted);
padding: 0.4rem 0.75rem;
cursor: pointer;
border-radius: 9999px;
transition: all 0.2s;
}
.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); }
/* --- Layout Content --- */
.content-area {
flex: 1;
padding: 1.25rem 2rem 0;
overflow: hidden;
padding: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.view-container {
@@ -140,165 +140,492 @@ input:checked + .slider:before { transform: translateX(16px); }
overflow: hidden;
}
.view-content-wrapper {
flex: 1;
overflow-y: auto;
padding-bottom: 2rem;
/* --- View Toggle (Vercel Tab Style) --- */
.view-toggle {
display: inline-flex;
background: var(--canvas-soft-2);
padding: 0.2rem;
border: 1px solid var(--hairline);
gap: 0.1rem;
border-radius: var(--radius-base);
}
/* --- View Toggle --- */
.view-toggle-container { margin-bottom: 1rem; display: flex; justify-content: flex-start; }
.view-toggle { display: inline-flex; background-color: var(--primary-lv-0); padding: 4px; border-radius: 8px; border: 1px solid var(--border-color); }
.toggle-btn { padding: 6px 16px; font-size: 13px; font-weight: 600; color: var(--text-muted); background: none; border: none; border-radius: 6px; cursor: pointer; }
.toggle-btn.active { background-color: var(--white); color: var(--primary-color); box-shadow: 0 2px 4px rgba(0,0,0,0.05); }
.toggle-btn {
padding: 0.35rem 1rem;
border: none;
background: transparent;
font-size: var(--fs-xs);
font-weight: 500;
color: var(--text-muted);
cursor: pointer;
transition: all 0.1s;
border-radius: calc(var(--radius-base) - 2px);
}
/* --- System Status List (Docker Style) --- */
.system-status-list { display: flex; flex-direction: column; gap: 0.5rem; }
.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; }
.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; }
.system-row:hover { border-color: var(--primary-lv-3); box-shadow: 0 4px 12px rgba(0,0,0,0.03); }
.col-status { width: 100px; display: flex; align-items: center; gap: 0.5rem; }
.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); }
.toggle-btn:hover { color: var(--text-main); }
.toggle-btn.active {
background: var(--canvas);
color: var(--text-main);
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
font-weight: 600;
}
/* --- Footer --- */
.main-footer {
height: 28px;
background-color: var(--white);
border-top: 1px solid var(--border-color);
/* --- Role Toggle Switch --- */
.role-toggle-wrapper {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 1.5rem;
flex-shrink: 0;
gap: 0.75rem;
background: var(--canvas-soft-2);
padding: 0.35rem 0.75rem;
border-radius: 9999px;
border: 1px solid var(--hairline);
}
.main-footer p {
font-family: 'Pretendard Variable', Pretendard, sans-serif;
font-size: 0.75rem;
font-weight: 300;
line-height: 1.25rem;
letter-spacing: -0.0175rem;
color: #777777;
user-select: none;
pointer-events: all;
-webkit-user-drag: none;
margin: 0;
padding: 0;
.role-label {
font-size: var(--fs-xs);
font-weight: 500;
color: var(--mute);
transition: all 0.2s;
}
.role-label.active {
color: var(--primary);
font-weight: 700;
}
.role-toggle {
position: relative;
display: inline-block;
width: 40px;
height: 20px;
}
.role-toggle input {
opacity: 0;
width: 0;
height: 0;
}
.role-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--hairline-strong);
transition: .4s;
border-radius: 20px;
}
.role-slider:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 2px;
bottom: 2px;
background-color: white;
transition: .4s;
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
input:checked + .role-slider {
background-color: var(--primary);
}
input:checked + .role-slider:before {
transform: translateX(20px);
}
/* --- Utility Styles (The Standard) --- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0 1.25rem;
font-size: var(--fs-xs);
font-weight: 500;
border-radius: 9999px;
cursor: pointer;
height: clamp(32px, 4.5vmin, 44px);
transition: all 0.2s;
border: 1px solid transparent;
white-space: nowrap;
}
.btn-primary { background-color: var(--primary); color: var(--on-primary); }
.btn-primary:hover { background-color: #000; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.btn-outline { background-color: var(--canvas); color: var(--text-main); border: 1px solid var(--hairline); }
.btn-outline:hover { border-color: var(--hairline-strong); background: var(--canvas-soft); }
.btn-sm { height: clamp(28px, 3.5vmin, 36px); padding: 0 1rem; font-size: var(--fs-xs); }
.btn-danger { color: var(--danger) !important; border-color: var(--danger) !important; }
/* --- Form Elements --- */
.form-select-sm {
height: clamp(28px, 3.5vmin, 36px);
padding: 0 0.5rem;
border: 1px solid var(--hairline);
border-radius: 6px;
font-size: var(--fs-xs);
outline: none;
background-color: var(--canvas);
color: var(--primary);
cursor: pointer;
transition: all 0.2s;
}
.form-select-sm:focus {
border-color: var(--primary);
}
.badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 9999px;
font-size: var(--fs-xs);
font-weight: 600;
}
.badge-primary { background-color: var(--primary); color: var(--on-primary); }
/* --- Form Elements Extra --- */
.input-with-icon {
position: relative;
display: flex;
align-items: center;
}
.input-with-icon input {
padding-left: 2.5rem !important;
}
.input-with-icon i,
.input-with-icon .icon-sm {
position: absolute;
left: 12px;
width: 16px;
height: 16px;
color: var(--mute);
pointer-events: none;
}
.autocomplete-list {
position: absolute;
top: 100%;
left: 0;
right: 0;
max-height: 250px;
overflow-y: auto;
background: var(--canvas);
border: 1px solid var(--hairline);
border-radius: 6px;
box-shadow: 0 12px 30px rgba(0,0,0,0.12);
z-index: 1100;
margin-top: 4px;
}
.autocomplete-item {
padding: 10px 12px;
cursor: pointer;
border-bottom: 1px solid var(--hairline-soft, #f5f5f5);
transition: background 0.1s;
}
.autocomplete-item:hover {
background: var(--canvas-soft-2);
}
.autocomplete-item-empty {
padding: 1rem;
color: var(--mute);
font-size: var(--fs-xs);
text-align: center;
}
.suggestion-name {
font-weight: 600;
font-size: var(--fs-xs);
color: var(--primary);
margin-bottom: 2px;
}
.suggestion-meta {
font-size: var(--fs-xs);
color: var(--mute);
display: flex;
gap: 8px;
}
/* --- Summary & Selection Cards --- */
.summary-info-card {
padding: 1.25rem;
background: var(--canvas-soft);
border: 1px solid var(--hairline);
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.user-pc-selection-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 250px;
overflow-y: auto;
padding-right: 4px;
}
.user-pc-item {
padding: 12px;
border: 1px solid var(--hairline);
border-radius: 6px;
cursor: pointer;
background: var(--canvas);
transition: all 0.2s;
}
.user-pc-item:hover {
border-color: var(--hairline-strong);
background: var(--canvas-soft);
}
.user-pc-item.selected {
border-color: var(--primary);
background: var(--primary-light);
}
.pc-item-code {
font-weight: 700;
font-size: var(--fs-xs);
color: var(--primary);
}
.pc-item-meta {
font-size: var(--fs-xs);
color: var(--mute);
margin-top: 2px;
}
.empty-list-message {
font-size: var(--fs-xs);
color: var(--mute);
padding: 1rem 0;
text-align: center;
}
/* --- Global Utilities --- */
.hidden { display: none !important; }
.clickable { cursor: pointer; transition: opacity 0.2s; }
.clickable:hover { opacity: 0.8; }
/* Flexbox & Grid Utilities */
.flex { display: flex; }
.flex-col { display: flex; flex-direction: column; }
.flex-row { display: flex; flex-direction: row; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.justify-center { justify-content: center; }
.gap-1 { gap: 0.25rem; }
.gap-2 { gap: 0.5rem; }
.gap-3 { gap: 0.75rem; }
.gap-4 { gap: 1rem; }
.gap-6 { gap: 1.5rem; }
.gap-y-3 { row-gap: 0.75rem; }
.gap-x-4 { column-gap: 1rem; }
.mb-0 { margin-bottom: 0 !important; }
.mb-4 { margin-bottom: 1rem !important; }
.mb-6 { margin-bottom: 1.5rem !important; }
.pb-4 { padding-bottom: 1rem !important; }
.p-4 { padding: 1rem !important; }
.p-2 { padding: 0.5rem !important; }
.p-8 { padding: 2rem !important; }
.ml-auto { margin-left: auto !important; }
.self-end { align-self: flex-end !important; }
.font-medium { font-weight: 500; }
.text-muted { color: var(--mute) !important; }
.mt-12 { margin-top: 3rem !important; }
.icon-sm { width: 16px; height: 16px; }
.h-90vh { height: 90vh !important; }
.pt-0 { padding-top: 0 !important; }
.font-semibold { font-weight: 600; }
.w-full { width: 100%; }
.h-full { height: 100%; }
/* Text Utilities */
.text-center { text-align: center !important; }
.text-right { text-align: right !important; }
.text-left { text-align: left !important; }
.font-bold { font-weight: 700; }
.bg-primary-light { background-color: var(--primary-light) !important; }
.text-success { color: var(--success) !important; }
.text-danger { color: var(--danger) !important; }
.text-blue { color: var(--color-blue) !important; }
.text-orange { color: var(--color-orange) !important; }
/* --- Unified Search & Filter Bar --- */
.search-bar {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-base);
padding: 1.25rem var(--spacing-base);
border-bottom: 1px solid var(--hairline);
align-items: flex-end;
background: var(--canvas);
box-sizing: border-box;
}
.hidden {
display: none !important;
.search-item {
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;
}
/* --- Utility Styles --- */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 0.35rem; padding: 0 0.8rem; font-size: 12px; font-weight: 600; border-radius: 4px; cursor: pointer; height: 28px; }
.btn-primary { background-color: var(--primary-color); color: var(--white); border: none; }
.btn-outline { background-color: transparent; color: var(--text-muted); border: 1px solid var(--border-color); }
.badge {
padding: 2px 6px;
border-radius: 4px;
font-size: 16px;
font-weight: 700;
white-space: nowrap;
/* --- Modal & View Header Layouts --- */
.header-left {
display: flex;
align-items: center;
gap: 1rem;
}
.badge-primary {
background-color: var(--primary-color);
color: white;
/* --- Asset Identity & Header Styling (Global) --- */
.header-identity {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
flex-wrap: wrap;
}
.badge-muted {
background-color: #9CA3AF;
color: white;
.asset-code-title {
font-size: var(--fs-md);
font-weight: 600;
color: var(--primary);
letter-spacing: -0.05em;
line-height: 1;
}
.badge-light {
background: var(--bg-color);
color: var(--text-muted);
border: 1px solid var(--border-color);
.service-type-badge {
font-size: var(--fs-xs);
font-weight: 600;
color: var(--on-primary);
background: var(--primary);
padding: 4px 8px;
border-radius: 9999px;
text-transform: uppercase;
line-height: 1;
}
/* PC 성능 등급 뱃지 컬러 스타일 */
.badge.b-purple {
background-color: #EDE9FE;
color: #7C3AED;
border: 1px solid #DDD6FE;
font-size: 11px;
padding: 2px 6px;
}
.badge.b-primary {
background-color: #DBEAFE;
color: #1D4ED8;
border: 1px solid #BFDBFE;
font-size: 11px;
padding: 2px 6px;
}
.badge.b-green {
background-color: #D1FAE5;
color: #047857;
border: 1px solid #A7F3D0;
font-size: 11px;
padding: 2px 6px;
}
.badge.b-yellow {
background-color: #FEF3C7;
color: #D97706;
border: 1px solid #FDE68A;
font-size: 11px;
padding: 2px 6px;
.asset-type-label {
font-size: var(--fs-sm);
font-weight: 500;
color: var(--mute);
line-height: 1;
}
.text-tag {
color: var(--text-muted);
font-size: 16px;
padding: 1px 5px;
border: 1px solid var(--border-color);
border-radius: 3px;
background-color: var(--bg-light);
.main-footer {
border-top: 1px solid var(--border-color);
background-color: var(--canvas);
color: var(--mute);
padding: 1rem 2rem;
text-align: right;
font-size: var(--fs-xs);
flex-shrink: 0;
z-index: 10;
}
.font-bold {
font-weight: 700;
.main-footer p {
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; }
}

View File

@@ -1,526 +1,486 @@
/* --- Premium Executive Dashboard View Specific Styles --- */
/* --- Vercel Inspired Premium Dashboard --- */
.dashboard-section-title {
padding: 0 0 0 8px;
font-size: 1.55rem;
font-weight: 800;
color: var(--text-main);
letter-spacing: -0.02em;
border-left: 4px solid var(--primary-color);
margin-bottom: 1rem;
line-height: 1.2;
padding: 0;
font-size: var(--fs-lg);
font-weight: 600;
color: var(--primary);
letter-spacing: -0.05em;
margin-bottom: clamp(0.5rem, 1.5vmin, 1.5rem);
line-height: 1;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
/* Background Mesh Gradient for Stats Row */
.dashboard-stats-row {
display: flex;
flex-wrap: wrap;
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) */
.dashboard-card, .stat-card {
background: transparent;
backdrop-filter: none;
-webkit-backdrop-filter: none;
border: none;
border-bottom: 1px solid var(--border-color);
box-shadow: none;
border-radius: 0;
padding: 1.5rem 0.5rem;
.stat-group-item {
flex: 1;
min-width: 250px;
display: flex;
flex-direction: column;
transition: opacity 0.2s ease;
padding: var(--spacing-base);
justify-content: center;
}
.dashboard-card:hover, .stat-card:hover {
transform: none;
box-shadow: none;
opacity: 0.85;
.stat-group-item.bordered {
border-left: 1px solid var(--hairline);
}
.dashboard-layout-2col {
display: grid;
grid-template-columns: repeat(2, 1fr);
.stat-group-item .stat-label {
font-size: var(--fs-xs);
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;
font-size: var(--fs-sm);
color: var(--body);
margin-top: 1rem;
}
.dashboard-layout-3col {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
/* --- Technical Data Alignment --- */
.text-primary {
color: var(--color-blue) !important;
}
.dashboard-card {
min-height: 380px;
}
.dashboard-card canvas {
flex: 1;
width: 100% !important;
max-height: 280px;
}
/* Premium KPI Value Styling */
.stat-value {
font-size: 2.41rem;
font-weight: 800;
background: linear-gradient(135deg, #1E5149 0%, #3B82F6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-top: 0.5rem;
.detail-stat-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
gap: 0.5rem;
}
.stat-value-danger {
background: linear-gradient(135deg, #E11D48 0%, #F59E0B 100%);
-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);
.stat-title {
font-size: var(--fs-base);
font-weight: 600;
color: var(--primary);
white-space: nowrap;
}
.page-btns button {
padding: 0.3rem 0.75rem;
border: 1px solid var(--border-color);
background: var(--white);
border-radius: 4px;
font-size: 0.96rem;
cursor: pointer;
}
.page-btns button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.slider-indicator {
font-weight: 700;
color: var(--text-muted);
font-size: 1.41rem;
}
.dashboard-slider-viewport {
width: 100%;
overflow: hidden;
padding: 0.5rem 0;
}
.dashboard-slider-track {
display: flex;
transition: transform 0.5s cubic-bezier(0.25, 0.8, 0.25, 1);
width: 400%; /* For 4 pages */
}
.dashboard-slide {
width: 25%; /* 100% / 4 pages */
flex-shrink: 0;
padding: 0 2px; /* Slight padding to avoid cutting off box-shadows */
height: calc(100vh - 150px);
min-height: 520px;
.detail-stat-body {
display: flex;
flex-direction: column;
box-sizing: border-box;
gap: 0.5rem;
}
/* --- Location View Styles --- */
.location-layout {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 2rem;
height: calc(100vh - 180px);
}
.map-section, .asset-section {
.loc-summary {
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;
flex-wrap: wrap;
}
.view-toggle-container {
display: flex;
background: #f1f5f9;
padding: 0.25rem;
border-radius: 8px;
gap: 0.25rem;
.loc-summary span {
font-size: var(--fs-sm);
color: var(--mute);
}
.mode-toggle-btn {
padding: 0.5rem 1rem;
border: none;
background: transparent;
border-radius: 6px;
font-size: 0.8125rem;
.loc-summary span strong {
color: var(--primary);
font-size: var(--fs-base);
font-weight: 600;
color: var(--text-muted);
cursor: pointer;
transition: all 0.2s ease;
}
.mode-toggle-btn:hover {
color: var(--text-main);
.type-summary {
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 {
background: var(--white);
color: var(--primary-color);
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
.type-summary span {
cursor: help;
font-size: var(--fs-xs);
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 {
display: flex;
flex-direction: column;
height: calc(100vh - 120px);
}
.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;
height: 100%;
background: var(--canvas);
overflow: hidden;
}
.location-main-content {
flex: 1;
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 1.5rem;
padding: 1.5rem;
grid-template-columns: 2fr 1fr;
background: var(--canvas);
gap: 0;
padding: 0;
overflow: hidden;
align-items: stretch;
}
.map-container-section {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
overflow: auto;
align-items: center;
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 {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.box-label-text {
font-size: 0.65rem;
font-weight: 800;
color: var(--primary-color);
pointer-events: none;
text-shadow: 0 0 2px white;
}
/* --- Asset Detail Sidebar --- */
.asset-list-section {
background: var(--white);
border-radius: 12px;
border: 1px solid var(--border-color);
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
background: var(--canvas);
}
.asset-list-section .section-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-color);
background: #f8fafc;
}
.asset-list-section h4 {
margin: 0;
font-size: 0.9375rem;
color: var(--text-main);
.section-header {
padding: 1.5rem;
border-bottom: 1px solid var(--hairline);
background: var(--canvas);
flex-shrink: 0;
}
.mini-table-wrapper {
flex: 1;
overflow-y: auto;
position: relative;
}
.compact-table {
width: 100%;
border-collapse: collapse;
}
.compact-table th {
position: sticky;
top: 0;
background: var(--white);
padding: 0.75rem 1rem;
text-align: left;
font-size: 0.75rem;
font-weight: 700;
color: var(--text-muted);
border-bottom: 1px solid var(--border-color);
}
.compact-table td {
padding: 0.75rem 1rem;
font-size: 0.8125rem;
border-bottom: 1px solid #f1f5f9;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 150px;
}
.compact-table tr.clickable-row:hover {
background: #f1f5f9;
cursor: pointer;
}
/* --- Asset Detail Sidebar (LocationView) --- */
.asset-detail-sidebar {
padding-top: 1rem;
background: var(--white);
height: 100%;
overflow-y: auto;
}
.detail-section {
margin-bottom: 20px;
padding: 0 1.25rem;
}
.detail-section-title {
font-size: 13px;
font-weight: 700;
color: var(--primary-color);
border-bottom: 1px solid var(--border-color);
padding-bottom: 6px;
margin-bottom: 12px;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(80px, auto) 1fr);
gap: 8px 16px;
}
.detail-label {
font-size: 12px;
color: var(--text-muted);
.sidebar-title {
margin: 0;
font-size: var(--fs-base);
font-weight: 600;
display: flex;
align-items: center;
}
.detail-value {
font-size: 14px;
color: var(--text-main);
font-weight: 500;
word-break: break-all;
display: flex;
align-items: center;
color: var(--primary);
}
.detail-header-actions {
display: flex;
align-items: center;
gap: 8px;
justify-content: space-between;
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;
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;
}
.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);
}
}

View File

@@ -19,8 +19,8 @@
.guide-tab {
padding: 0.75rem 1.25rem;
font-size: 18px;
font-weight: 600;
font-size: 24px;
font-weight: 700;
color: var(--text-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
@@ -72,7 +72,7 @@
}
.guide-section h3 {
font-size: 1.3rem;
font-size: 1.73rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--primary-color);
color: var(--primary-color);
@@ -83,7 +83,7 @@
}
.guide-text {
font-size: 18px;
font-size: 24px;
color: var(--text-main);
line-height: 1.7;
margin: 0;
@@ -127,8 +127,8 @@
border-radius: 50%;
background-color: var(--primary-color);
color: white;
font-size: 17px;
font-weight: 700;
font-size: 23px;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
@@ -136,14 +136,14 @@
}
.flow-step .step-label {
font-weight: 700;
font-weight: 800;
color: var(--text-main);
font-size: 18px;
font-size: 24px;
display: block;
}
.flow-step .step-desc {
font-size: 17px;
font-size: 23px;
color: var(--text-muted);
line-height: 1.5;
margin-top: 4px;
@@ -159,13 +159,13 @@
.guide-info-table {
width: 100%;
border-collapse: collapse;
font-size: 18px;
font-size: 24px;
}
.guide-info-table th {
background: #f8faf9;
color: var(--primary-color);
font-weight: 700;
font-weight: 800;
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
@@ -182,7 +182,7 @@
background: var(--primary-light);
border-left: 4px solid var(--primary-color);
padding: 1rem;
font-size: 18px;
font-size: 24px;
color: var(--primary-color);
line-height: 1.6;
}

View File

@@ -36,14 +36,14 @@
}
.login-header h2 {
font-size: 1.75rem;
font-weight: 800;
font-size: 2.33rem;
font-weight: 900;
color: var(--text-main);
margin-bottom: 0.5rem;
}
.login-header p {
font-size: 0.9375rem;
font-size: 1.25rem;
color: var(--text-muted);
}
@@ -94,14 +94,14 @@
}
.role-card h3 {
font-size: 1.125rem;
font-weight: 700;
font-size: 1.5rem;
font-weight: 800;
color: var(--text-main);
margin-bottom: 0.5rem;
}
.role-card p {
font-size: 0.8125rem;
font-size: 1.08rem;
color: var(--text-muted);
line-height: 1.4;
}
@@ -109,7 +109,7 @@
.login-footer {
margin-top: 3rem;
text-align: center;
font-size: 0.75rem;
font-size: 1rem;
color: var(--text-muted);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,127 +1,50 @@
/* --- Page Header for Description --- */
.page-header {
padding: 1rem 0 0.2rem 0;
padding: 1.5rem 2rem 0.5rem; /* Padding added for better whitespace */
}
.page-title-group {
display: flex;
flex-direction: column;
gap: 0.3rem;
gap: 0.5rem;
}
.page-title {
font-size: 16px;
font-weight: 700;
color: var(--primary-color);
font-size: var(--fs-lg);
font-weight: 600;
color: var(--primary);
display: flex;
align-items: center;
margin: 0;
border-left: 4px solid var(--primary-color);
padding-left: 8px;
line-height: 1.2;
line-height: 1.1;
letter-spacing: -0.05em;
}
.page-description {
font-size: 12px;
color: var(--text-muted);
font-size: var(--fs-base);
color: var(--mute);
margin: 0;
line-height: 1.4;
opacity: 0.8;
}
/* --- Table View & Filter Styles --- */
.search-bar {
display: flex;
flex-wrap: wrap;
gap: 0.75rem; /* 간격 축소 및 통일 */
padding: 1.2rem 0;
border-bottom: 1px solid var(--border-color);
align-items: flex-end;
margin-bottom: 0.5rem;
}
.search-item {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.search-item.flex-1 {
flex: 1; /* 검색창이 남은 공간을 채우도록 설정 */
min-width: 250px;
}
.search-actions {
display: flex;
gap: 0.5rem; /* 버튼들 간의 간격 */
align-items: center;
}
.search-actions .btn {
height: 38px;
padding: 0 1rem;
}
.search-item label {
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
}
.search-item input,
.search-item select {
height: 38px;
padding: 0 1rem;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 14px;
outline: none;
background-color: var(--white);
}
/* 셀렉트 박스 화살표 여백 절대 고정 (수정 금지) */
.search-item select {
padding-right: 2.5rem !important;
cursor: pointer;
}
.search-item input:focus,
.search-item select:focus {
border-color: var(--primary-color);
}
/* 필터 초기화 버튼 크기 조정 (입력창 높이 38px에 맞춤) */
.btn-reset {
height: 38px !important;
color: var(--text-muted) !important;
padding: 0 1.2rem !important;
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: 0; /* 불필요한 마진 제거 */
line-height: 1.5;
}
/* --- Table View Styles --- */
.table-container {
flex: 1;
background-color: var(--white);
border-top: 1px solid var(--border-color);
background-color: var(--canvas);
overflow: auto;
position: relative;
-webkit-overflow-scrolling: touch;
}
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
table-layout: auto;
}
th, td {
padding: 0.8rem 1.2rem;
border-bottom: 1px solid var(--border-color);
text-align: left; /* 기본은 좌측 정렬 */
padding: 0.8rem 1rem;
border-bottom: 1px solid var(--hairline);
text-align: left;
white-space: nowrap;
}
@@ -132,93 +55,93 @@ thead {
}
th {
background-color: var(--bg-light) !important;
font-size: 13px;
background-color: var(--canvas-soft) !important;
font-size: var(--fs-xs);
font-weight: 600;
color: var(--text-muted);
position: sticky;
top: 0;
z-index: 50;
box-shadow: inset 0 1px 0 var(--border-color), inset 0 -1px 0 var(--border-color); /* 상하 테두리 보정 */
text-transform: none;
color: var(--mute);
text-transform: uppercase;
letter-spacing: 0.05em;
box-shadow: inset 0 -1px 0 var(--hairline);
text-align: center; /* Set default header alignment to center */
}
td {
font-size: 13px;
color: var(--text-main);
font-size: var(--fs-base);
color: var(--primary);
font-weight: 400;
text-align: left; /* Set default data alignment to left */
}
tbody tr:hover {
background-color: var(--bg-color);
background-color: var(--canvas-soft-2);
}
/* 정렬 클래스 강제 적용 */
/* 정렬 클래스 */
.text-center { text-align: center !important; }
.text-right { text-align: right !important; }
.text-left { text-align: left !important; }
/* 메모 컬럼 전용: 가장 길게 표시되도록 너비 조정 및 줄바꿈 허용 */
/* 메모 컬럼 전용 */
.col-memo {
width: 20%;
min-width: 250px;
width: 25%;
min-width: 300px;
white-space: normal !important;
word-break: break-all;
line-height: 1.4;
text-align: left !important;
}
.btn-icon {
padding: 0.25rem;
border: none;
background: none;
cursor: pointer;
color: var(--text-muted);
transition: color 0.2s;
}
.btn-icon:hover {
color: var(--primary-color);
}
.btn-icon svg {
width: 16px;
height: 16px;
line-height: 1.5;
}
/* --- Table Sorting --- */
th.sortable {
cursor: pointer;
user-select: none;
transition: background-color 0.2s;
position: relative;
padding-right: 1.8rem !important; /* 아이콘 공간 확보 */
padding-right: 1.8rem !important;
}
th.sortable:hover {
background-color: #F3F4F6;
color: var(--primary-color);
background-color: var(--canvas-soft-2) !important;
color: var(--primary);
}
th.sortable::after {
content: '↕';
position: absolute;
right: 0.6rem;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
font-size: 11px;
opacity: 0.3;
transition: all 0.2s;
font-size: var(--fs-xs);
opacity: 0.4;
}
th.sortable.asc::after {
content: '';
opacity: 1;
color: var(--primary-color);
th.sortable.asc::after { content: '▲'; opacity: 1; color: var(--primary); }
th.sortable.desc::after { content: ''; opacity: 1; color: var(--primary); }
/* --- Compact Table (Used in Dashboards/Modals) --- */
.compact-table {
width: 100%;
border-collapse: collapse;
}
th.sortable.desc::after {
content: '▼';
opacity: 1;
color: var(--primary-color);
.compact-table th {
padding: 0.75rem 0.5rem;
font-size: var(--fs-xs);
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;
}

View File

@@ -18,6 +18,14 @@ export function renderHwDashboard(container: HTMLElement) {
// 2. 1페이지 매거진 리포트(제목바 제거, '| 제목' 미니멀리즘 스타일) HTML 빌드
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;">
<!-- 대시보드 타이틀 및 사용조직 필터 -->
@@ -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;">
개인 PC 자산 대시보드
</h2>
>>>>>>> origin/main
</div>
<!-- 사용조직 필터 (브랜드 그린 매칭 칩 디자인) -->
<div style="display: flex; align-items: center; gap: 0.75rem;">
<span style="font-size: 0.9rem; font-weight: 700; color: #475569; white-space: nowrap;">조직 필터:</span>
<div id="dashboard-dept-buttons" style="display: flex; gap: 0.3rem; background: #EEF2F6; padding: 4px; border-radius: 8px; border: 1px solid #E2E8F0;">
<button class="dept-filter-btn active" data-dept="" style="padding: 6px 14px; font-size: 0.88rem; font-weight: 700; border-radius: 6px; border: none; background: #1E5149; color: white; cursor: pointer; transition: all 0.2s;">전체</button>
<button class="dept-filter-btn" data-dept="한맥" 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="삼안" 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="장헌" 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="한라" 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="기술개발센터" 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="총괄기획실" style="padding: 6px 14px; font-size: 0.88rem; font-weight: 700; border-radius: 6px; border: none; background: transparent; color: #475569; cursor: pointer; transition: all 0.2s;">총괄기획실</button>
<div class="flex items-center gap-3">
<span class="detail-label-sm font-bold">조직 필터:</span>
<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="">전체</button>
<button class="dept-filter-btn" data-dept="한맥">한맥</button>
<button class="dept-filter-btn" data-dept="삼안">삼안</button>
<button class="dept-filter-btn" data-dept="장헌">장헌</button>
<button class="dept-filter-btn" data-dept="한라">한라</button>
<button class="dept-filter-btn" data-dept="기술개발센터">기술개발센터</button>
<button class="dept-filter-btn" data-dept="총괄기획실">총괄기획실</button>
</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;">
<!-- 좌측 컬럼 (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;">
<!-- 핵심 지표 카드 -->
@@ -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-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>
>>>>>>> origin/main
</div>
<div style="display: flex; align-items: flex-end; justify-content: space-between;">
<div class="flex items-end justify-between">
<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>
<span style="font-size: 1.05rem; color: #64748B; font-weight: 700; white-space: nowrap;">전사 보유 개인용 PC</span>
>>>>>>> origin/main
</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. 사양 부족 -->
<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;">
<span style="font-size: 1.15rem; font-weight: 800; color: #1E293B; white-space: nowrap;">사양 부족</span>
>>>>>>> origin/main
</div>
<div style="display: flex; align-items: flex-end; justify-content: space-between;">
<div class="flex items-end justify-between">
<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>
<span style="font-size: 1.05rem; color: #64748B; font-weight: 700; white-space: nowrap;">사양 교체 권고 자산</span>
>>>>>>> origin/main
</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. 오버 스펙 -->
<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;">
<span style="font-size: 1.15rem; font-weight: 800; color: #1E293B; white-space: nowrap;">오버 스펙</span>
>>>>>>> origin/main
</div>
<div style="display: flex; align-items: flex-end; justify-content: space-between;">
<div class="flex items-end justify-between">
<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>
<span style="font-size: 1.05rem; color: #64748B; font-weight: 700; white-space: nowrap;">사양 회수 권고 자산</span>
>>>>>>> origin/main
</div>
</div>
</div>
<!-- 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 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>
>>>>>>> origin/main
</div>
<div style="display: flex; align-items: flex-end; justify-content: space-between;">
<div class="flex items-end justify-between">
<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>
<span style="font-size: 1.05rem; color: #64748B; font-weight: 700; white-space: nowrap;">업데이트 미지원 하드웨어</span>
>>>>>>> origin/main
</div>
</div>
</div>
@@ -107,6 +168,64 @@ export function renderHwDashboard(container: HTMLElement) {
</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;">
@@ -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>교체 대상</span>
</div>
>>>>>>> origin/main
</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 노후도 및 교체 주기 예측 카드 (너비 줄임) -->
<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;">
@@ -211,14 +360,58 @@ export function renderHwDashboard(container: HTMLElement) {
<!-- Dynamic Aging Contents -->
</tbody>
</table>
>>>>>>> origin/main
</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>
<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 아이콘 초기화

View File

@@ -33,38 +33,38 @@ export function renderSwDashboard(container: HTMLElement) {
const intPer = intQty > 0 ? Math.round((intUsed/intQty)*100) : 0;
container.innerHTML = `
<div class="view-container">
<div class="view-container bg-soft">
<h3 class="dashboard-section-title">소프트웨어 라이선스 현황</h3>
<div class="dashboard-layout-2col" style="margin-bottom: 1.5rem;">
<div class="dashboard-card" data-action="ext-usage" style="cursor:pointer; min-height:auto;">
<span style="font-size:1.21rem; font-weight:700; color:var(--text-main);">외부 소프트웨어 사용율</span>
<div style="font-size: 1.02rem; color:var(--text-muted); margin-bottom: 1rem;">${extQty}카피 중 ${extUsed}개 할당</div>
<div style="font-size: 2.21rem; font-weight:700; color:var(--dash-primary);">${extPer}%</div>
<div style="width: 100%; height: 4px; background-color: var(--border-color); border-radius: 2px; overflow: hidden; margin-top: 0.5rem;">
<div style="width: ${extPer}%; height: 100%; background-color: var(--dash-primary);"></div>
<div class="dashboard-layout-2col mb-6">
<div class="dashboard-card clickable" data-action="ext-usage">
<div class="stat-label">외부 소프트웨어 사용율</div>
<div class="stat-sub">${extQty}카피 중 ${extUsed}개 할당</div>
<div class="stat-value text-primary">${extPer}%</div>
<div class="stat-progress-bar">
<div class="progress-fill" style="width: ${extPer}%;"></div>
</div>
</div>
<div class="dashboard-card" data-action="int-usage" style="cursor:pointer; min-height:auto;">
<span style="font-size:1.21rem; font-weight:700; color:var(--text-main);">내부 소프트웨어 현황</span>
<div style="font-size: 1.02rem; color:var(--text-muted); margin-bottom: 1rem;">등록된 내부 솔루션: ${intTotal}개</div>
<div style="font-size: 2.21rem; font-weight:700; color:var(--dash-primary);">${intPer}%</div>
<div style="width: 100%; height: 4px; background-color: var(--border-color); border-radius: 2px; overflow: hidden; margin-top: 0.5rem;">
<div style="width: ${intPer}%; height: 100%; background-color: var(--dash-primary);"></div>
<div class="dashboard-card clickable" data-action="int-usage">
<div class="stat-label">내부 소프트웨어 현황</div>
<div class="stat-sub">등록된 내부 솔루션: ${intTotal}개</div>
<div class="stat-value text-primary">${intPer}%</div>
<div class="stat-progress-bar">
<div class="progress-fill" style="width: ${intPer}%;"></div>
</div>
</div>
</div>
<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-card" style="min-height:auto;">
<span style="font-size:1.21rem; font-weight:700; color:var(--text-main);">외부 SW 누적 비용 (2026)</span>
<div style="font-size: 2.21rem; font-weight:700; color:var(--dash-primary);">₩ ${extCost2026.toLocaleString()}</div>
<div class="dashboard-layout-2col">
<div class="dashboard-card">
<div class="stat-label">외부 SW 누적 비용 (2026)</div>
<div class="stat-value text-primary">₩ ${extCost2026.toLocaleString()}</div>
</div>
<div class="dashboard-card" style="min-height:auto;">
<span style="font-size:1.21rem; font-weight:700; color:var(--text-main);">내부 SW 누적 비용 (2026)</span>
<div style="font-size: 2.21rem; font-weight:700; color:#3b82f6;">₩ ${intCost2026.toLocaleString()}</div>
<div class="dashboard-card">
<div class="stat-label">내부 SW 누적 비용 (2026)</div>
<div class="stat-value text-blue">₩ ${intCost2026.toLocaleString()}</div>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -168,4 +168,3 @@ function renderSubTabs(container: HTMLElement) {
}
});
}

View File

@@ -22,10 +22,11 @@ export function renderServerList(container: HTMLElement) {
onRowClick: (asset) => openHwModal(asset, 'view'),
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.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: '모델/메인보드',
align: 'center',
width: '15%',
render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || a[ASSET_SCHEMA.ASSET_NAME.key] || a[ASSET_SCHEMA.MAINBOARD.key] || '-')
},

View File

@@ -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.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.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.LOCATION.ui,

View File

@@ -4,12 +4,11 @@ import { ASSET_SCHEMA } from '../core/schema';
import { LOCATION_DATA, IMAGE_LOCATIONS } from '../components/Modal/SharedData';
/**
* 위치 중심 자산 현황 뷰 (Refined)
* 위치 중심 자산 현황 뷰 (Vercel Integrated)
*/
export async function renderLocationView(container: HTMLElement) {
if (!container) return;
// 로컬 상태 (UI 제어용)
let currentLoc = '기술개발센터';
let currentDetail = '서버실';
let currentPage = 0;
@@ -26,7 +25,7 @@ export async function renderLocationView(container: HTMLElement) {
: [];
const mapPath = locImages[currentPage] || '';
// 자산이 등록된(좌표가 일치하는) 구역만 필터링하여 표시
// 자산이 등록된 구역만 필터링
const allBoxes = mapConfig[mapPath] || [];
const boxes = allBoxes.filter((box: any) =>
state.masterData.hw.some(a =>
@@ -39,42 +38,50 @@ export async function renderLocationView(container: HTMLElement) {
container.innerHTML = `
<div class="location-view-wrapper">
<!-- 2단계 필터 바 -->
<!-- 상단 통합 바 (Vercel Style) -->
<div class="location-filter-bar">
<div class="filter-group">
<label>건물/위치</label>
<select id="sel-loc-main">
${Object.keys(LOCATION_DATA).map(loc => `<option value="${loc}" ${loc === currentLoc ? 'selected' : ''}>${loc}</option>`).join('')}
</select>
</div>
<div class="filter-group">
<label>상세 위치</label>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<select id="sel-loc-detail">
${(LOCATION_DATA[currentLoc] || []).map(det => `<option value="${det}" ${det === currentDetail ? 'selected' : ''}>${det}</option>`).join('')}
<div class="filter-actions-group">
<div class="filter-group">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; text-transform: none; font-weight: 500; color: var(--primary);">
<input type="checkbox" id="chk-list-view-loc" style="width: 16px; height: 16px; cursor: pointer;" />
목록보기
</label>
</div>
<div class="filter-group">
<label>건물/위치</label>
<select id="sel-loc-main" class="form-select-sm">
${Object.keys(LOCATION_DATA).map(loc => `<option value="${loc}" ${loc === currentLoc ? 'selected' : ''}>${loc}</option>`).join('')}
</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 ? `
<div class="map-pagination" style="margin-left: 0; padding-left: 0.5rem; border-left: 1px solid var(--border-color); display: flex; align-items: center; gap: 0.5rem;">
<div class="page-btns">
<button id="btn-prev-page" class="btn btn-outline btn-sm" style="height: 28px; padding: 0 8px;" ${currentPage === 0 ? 'disabled' : ''}>이전</button>
<button id="btn-next-page" class="btn btn-outline btn-sm" style="height: 28px; padding: 0 8px;" ${currentPage === locImages.length - 1 ? 'disabled' : ''}>다음</button>
<!-- 페이지네이션 -->
${locImages.length > 1 ? `
<div class="map-pagination-group">
<div class="page-btns">
<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" ${currentPage === locImages.length - 1 ? 'disabled' : ''}>다음</button>
</div>
<span class="page-info">(${currentPage + 1} / ${locImages.length})</span>
</div>
<span class="page-info">(${currentPage + 1} / ${locImages.length})</span>
` : ''}
</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="map-container-section" style="position: relative; overflow: hidden; border-radius: 8px; border: 1px solid var(--border-color); background: #f1f5f9; display: flex; align-items: flex-start; justify-content: center;">
<div class="map-frame-wrapper" style="position: relative; width: 100%; height: 100%; display: flex; align-items: flex-start; justify-content: center;">
<div class="location-main-content">
<!-- 지도 섹션 -->
<div class="map-container-section">
<div class="map-frame-wrapper">
${mapPath ? `
<img src="${mapPath}" id="main-map-img" style="max-width: 100%; max-height: 100%; object-fit: contain; display: block;">
<div id="box-overlay" style="position: absolute; pointer-events: none; transition: none;">
<img src="${mapPath}" id="main-map-img" class="map-image">
<div id="box-overlay" class="map-overlay">
${boxes.map((box: any, idx: number) => {
const name = box.name || `#${idx+1}`;
return `
@@ -82,35 +89,32 @@ export async function renderLocationView(container: HTMLElement) {
data-name="${name}"
data-x="${box.x}"
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;">
</div>
`}).join('')}
</div>
` : '<div style="padding: 5rem; text-align:center; color: #999;">해당 위치의 도면이 등록되지 않았습니다.</div>'}
` : '<div class="no-map-message">해당 위치의 도면이 등록되지 않았습니다.</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="section-header" style="flex-shrink: 0; background: #f8fafc; border-bottom: 1px solid var(--border-color); padding: 1rem;">
<h4 id="loc-list-title" style="margin:0; font-size: 0.95rem; font-weight: 700;">📍 구역을 선택하세요</h4>
<!-- 상세 정보 섹션 -->
<div class="asset-list-section">
<div class="section-header">
<h4 id="loc-list-title" class="sidebar-title">구역을 선택하세요</h4>
</div>
<div id="loc-asset-table-container" class="mini-table-wrapper" style="flex: 1; overflow-y: auto; padding: 0;">
<div class="empty-state" style="padding: 3rem 1rem;">지도에서 자산 위치를 클릭하세요.</div>
<div id="loc-asset-table-container" class="mini-table-wrapper">
<div class="empty-state">지도에서 자산 위치를 클릭하세요.</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>
`;
// 이미지 로드 및 윈도우 리사이즈 시 오버레이 크기와 위치를 이미지에 정확히 맞춤
const syncOverlaySize = () => {
const img = container.querySelector('#main-map-img') as HTMLImageElement;
const overlay = container.querySelector('#box-overlay') as HTMLElement;
if (img && overlay && img.complete) {
overlay.style.width = img.clientWidth + 'px';
overlay.style.height = img.clientHeight + 'px';
@@ -123,7 +127,7 @@ export async function renderLocationView(container: HTMLElement) {
if (img) {
if (img.complete) {
syncOverlaySize();
setTimeout(syncOverlaySize, 50); // 레이아웃 안정화 대기
setTimeout(syncOverlaySize, 50);
} else {
img.onload = syncOverlaySize;
}
@@ -132,7 +136,6 @@ export async function renderLocationView(container: HTMLElement) {
window.removeEventListener('resize', syncOverlaySize);
window.addEventListener('resize', syncOverlaySize);
// 이벤트 바인딩
const selMain = container.querySelector('#sel-loc-main') as HTMLSelectElement;
selMain?.addEventListener('change', () => {
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-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 => {
box.addEventListener('click', () => {
const x = box.getAttribute('data-x');
@@ -163,10 +184,7 @@ export async function renderLocationView(container: HTMLElement) {
String(a.loc_y) === String(y)
);
if (targetAsset) {
renderAssetDetail(targetAsset);
}
if (targetAsset) renderAssetDetail(targetAsset);
container.querySelectorAll('.location-box-point').forEach(b => (b as HTMLElement).style.background = 'rgba(30, 81, 73, 0.1)');
(box as HTMLElement).style.background = 'rgba(30, 81, 73, 0.4)';
});
@@ -179,62 +197,52 @@ export async function renderLocationView(container: HTMLElement) {
title.innerHTML = `
<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>
<span class="detail-header-title">자산 상세 정보</span>
<button id="btn-edit-from-loc" class="btn btn-primary btn-sm" style="font-size: 11px; height: 28px;">수정</button>
<div class="header-identity">
<span class="asset-code-title">${asset.asset_code || '미부여'}</span>
<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>
`;
const renderSection = (title: string, fields: { label: string; value: any }[]) => `
<div class="detail-section">
<div class="detail-section-title">${title}</div>
<div class="detail-grid">
const fields = [
{ label: ASSET_SCHEMA.CURRENT_DEPT.ui, value: asset.current_dept },
{ label: ASSET_SCHEMA.HW_STATUS.ui, value: asset.hw_status },
{ 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 => `
<div class="detail-label">${f.label}</div>
<div class="detail-value">${f.value || '-'}</div>
<div class="detail-item ${f.fullWidth ? 'full-width' : ''}">
<div class="detail-label-sm">${f.label}</div>
<div class="detail-value-lg">${f.value || '-'}</div>
</div>
`).join('')}
</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 = `
<div class="asset-detail-sidebar">
${sectionsHTML}
</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', () => {
openHwModal(asset, 'edit');
});