style: 리팩토링 및 CSS 통합 작업 완료 (하드코딩 스타일 제거)

This commit is contained in:
2026-06-12 13:29:59 +09:00
parent f5a84a77ef
commit fd9e88d7c6
38 changed files with 4785 additions and 592 deletions

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

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

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

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

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

@@ -19,18 +19,14 @@ class HwAssetModal extends BaseModal {
}
protected renderFrameHTML(): string {
const sharedStyle = 'height: 38px !important; box-sizing: border-box !important; font-size: 13px; margin: 0;';
const inputStyle = sharedStyle;
const btnStyle = `padding: 0 16px; display: inline-flex; align-items: center; justify-content: center; font-weight: 600; white-space: nowrap; cursor: pointer; ${sharedStyle}`;
return `
<div id="hw-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="hw-modal-title" style="margin: 0; font-size: 18px; font-weight: 800; color: white;">${this.title}</h2>
<button id="btn-close-hw-modal" class="btn-icon" aria-label="닫기" style="font-size: 28px; color: white; background: none; border: none; cursor: pointer; line-height: 1;">&times;</button>
<h2 id="hw-modal-title" class="modal-title">${this.title}</h2>
<button id="btn-close-hw-modal" class="btn-icon" aria-label="닫기">&times;</button>
</div>
<div class="modal-body" style="padding: 24px; overflow-y: auto;">
<div class="modal-body">
<div class="modal-body-split">
<div class="modal-form-area">
<form id="hw-asset-form" class="grid-form">
@@ -38,195 +34,195 @@ class HwAssetModal extends BaseModal {
<input type="hidden" id="hw-remotes-data" name="remotes" />
<!-- [SECTION 1] 기본 관리 정보 -->
<div class="form-section-title" style="padding-top: 0; margin-bottom: 12px;">기본 관리 정보</div>
<div class="form-section-title">기본 관리 정보</div>
<div class="form-group">
<label>${ASSET_SCHEMA.ASSET_CODE.ui}</label>
<div class="input-with-btn" style="display: flex; gap: 8px; align-items: stretch;">
<input type="text" id="hw-asset_code" name="asset_code" placeholder="자동 생성" readonly style="flex: 1; ${inputStyle}" />
<button type="button" id="btn-gen-hw-code" class="btn btn-outline" style="${btnStyle}">생성</button>
<div class="input-with-btn">
<input type="text" id="hw-asset_code" name="asset_code" placeholder="자동 생성" readonly />
<button type="button" id="btn-gen-hw-code" class="btn btn-outline">생성</button>
</div>
</div>
<div class="form-group">
<label>${ASSET_SCHEMA.PURCHASE_CORP.ui}</label>
<select id="hw-purchase_corp" name="purchase_corp" style="${inputStyle}">${generateOptionsHTML(CORP_LIST)}</select>
<select id="hw-purchase_corp" name="purchase_corp">${generateOptionsHTML(CORP_LIST)}</select>
</div>
<div class="form-group">
<label>${ASSET_SCHEMA.CATEGORY.ui}</label>
<select id="hw-category" name="category" style="${inputStyle}">
<select id="hw-category" name="category">
<option value="">선택</option>
${generateOptionsHTML(Object.keys(CATEGORY_TYPE_MAP), '', false)}
</select>
</div>
<div class="form-group">
<label>${ASSET_SCHEMA.ASSET_TYPE.ui}</label>
<select id="hw-asset_type" name="asset_type" style="${inputStyle}">
<select id="hw-asset_type" name="asset_type">
<option value="">구분을 먼저 선택하세요</option>
</select>
</div>
<div class="form-group">
<label>${ASSET_SCHEMA.HW_STATUS.ui}</label>
<select id="hw-hw_status" name="hw_status" style="${inputStyle}">${generateOptionsHTML(HW_STATUS_LIST)}</select>
<select id="hw-hw_status" name="hw_status">${generateOptionsHTML(HW_STATUS_LIST)}</select>
</div>
<div class="form-group infra-only monitoring-field">
<label>${ASSET_SCHEMA.MONITORING.ui}</label>
<select id="hw-monitoring" name="monitoring" style="${inputStyle}">
<select id="hw-monitoring" name="monitoring">
<option value="비대상">비대상</option>
<option value="대상">대상</option>
</select>
</div>
<!-- [SECTION 2] 조직 및 사용자 정보 -->
<div class="form-section-title org-user-section" style="margin-top: 24px; margin-bottom: 12px;">사용자 및 조직 정보</div>
<div class="form-section-title org-user-section">사용자 및 조직 정보</div>
<div class="form-group org-user-field">
<label>${ASSET_SCHEMA.CURRENT_DEPT.ui}</label>
<select id="hw-current_dept" name="current_dept" style="${inputStyle}">${generateOptionsHTML(ORG_LIST)}</select>
<select id="hw-current_dept" name="current_dept">${generateOptionsHTML(ORG_LIST)}</select>
</div>
<div class="form-group org-user-field">
<label>${ASSET_SCHEMA.MANAGER_MAIN.ui}</label>
<input type="text" id="hw-manager_primary" name="manager_primary" style="${inputStyle}" />
<input type="text" id="hw-manager_primary" name="manager_primary" />
</div>
<div class="form-group personal-only">
<label>${ASSET_SCHEMA.CURRENT_USER.ui}</label>
<input type="text" id="hw-user_current" name="user_current" style="${inputStyle}" />
<input type="text" id="hw-user_current" name="user_current" />
</div>
<div class="form-group personal-only">
<label>${ASSET_SCHEMA.USER_POSITION.ui}</label>
<input type="text" id="hw-user_position" name="user_position" style="${inputStyle}" />
<input type="text" id="hw-user_position" name="user_position" />
</div>
<div class="form-group">
<label>${ASSET_SCHEMA.MANAGER_SUB.ui}</label>
<input type="text" id="hw-manager_secondary" name="manager_secondary" style="${inputStyle}" />
<input type="text" id="hw-manager_secondary" name="manager_secondary" />
</div>
<div class="form-group personal-only">
<label>${ASSET_SCHEMA.PREV_USER.ui}</label>
<input type="text" id="hw-previous_user" name="previous_user" style="${inputStyle}" />
<input type="text" id="hw-previous_user" name="previous_user" />
</div>
<!-- [SECTION 3] 하드웨어 사양 -->
<div class="form-section-title hardware-section" style="margin-top: 24px; margin-bottom: 12px;">시스템 사양 정보</div>
<div class="form-section-title hardware-section">시스템 사양 정보</div>
<div class="form-group">
<label>${ASSET_SCHEMA.MODEL_NAME.ui}</label>
<input type="text" id="hw-model_name" name="model_name" style="${inputStyle}" />
<input type="text" id="hw-model_name" name="model_name" />
</div>
<div class="form-group">
<label>${ASSET_SCHEMA.ASSET_MFR.ui}</label>
<input type="text" id="hw-asset_mfr" name="asset_mfr" style="${inputStyle}" />
<input type="text" id="hw-asset_mfr" name="asset_mfr" />
</div>
<div class="form-group sn-only">
<label>${ASSET_SCHEMA.SERIAL_NUM.ui}</label>
<input type="text" id="hw-serial_num" name="serial_num" style="${inputStyle}" />
<input type="text" id="hw-serial_num" name="serial_num" />
</div>
<div class="form-group spec-only">
<label>${ASSET_SCHEMA.OS.ui}</label>
<input type="text" id="hw-os" name="os" style="${inputStyle}" />
<input type="text" id="hw-os" name="os" />
</div>
<div class="form-group spec-only">
<label>${ASSET_SCHEMA.CPU.ui}</label>
<input type="text" id="hw-cpu" name="cpu" style="${inputStyle}" />
<input type="text" id="hw-cpu" name="cpu" />
</div>
<div class="form-group spec-only">
<label>${ASSET_SCHEMA.RAM.ui}</label>
<input type="text" id="hw-ram" name="ram" style="${inputStyle}" />
<input type="text" id="hw-ram" name="ram" />
</div>
<div class="form-group spec-only">
<label>${ASSET_SCHEMA.GPU.ui}</label>
<input type="text" id="hw-gpu" name="gpu" style="${inputStyle}" />
<input type="text" id="hw-gpu" name="gpu" />
</div>
<div class="form-group spec-only">
<label>${ASSET_SCHEMA.MAINBOARD.ui}</label>
<input type="text" id="hw-mainboard" name="mainboard" style="${inputStyle}" />
<input type="text" id="hw-mainboard" name="mainboard" />
</div>
<div class="form-group monitor-only">
<label>${ASSET_SCHEMA.MONITOR_INCH.ui}</label>
<input type="text" id="hw-monitor_inch" name="monitor_inch" style="${inputStyle}" />
<input type="text" id="hw-monitor_inch" name="monitor_inch" />
</div>
<!-- 동적 디스크 할당 영역 -->
<div class="form-section-title spec-only" style="margin-top: 24px; margin-bottom: 12px;">디스크(용량) 정보</div>
<div class="form-group spec-only full-width" style="grid-column: span 2;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<label style="margin: 0; font-size: 11px; font-weight: 700; color: var(--text-muted);">연결된 드라이브 리스트</label>
<button type="button" id="btn-add-volume" class="btn btn-outline" style="height: 26px !important; padding: 0 10px; font-size: 11px; display: none;">+ 볼륨 추가</button>
<div class="form-section-title spec-only">디스크(용량) 정보</div>
<div class="form-group spec-only full-width">
<div class="history-header">
<label>연결된 드라이브 리스트</label>
<button type="button" id="btn-add-volume" class="btn btn-outline btn-sm hidden">+ 볼륨 추가</button>
</div>
<div id="hw-volume-container" style="display: flex; flex-direction: column; gap: 8px;"></div>
<div id="hw-volume-container" class="volume-container"></div>
<input type="hidden" id="hw-volumes-data" name="volumes" />
</div>
<!-- 통합 원격 접속 정보 영역 -->
<div class="form-section-title net-only" style="margin-top: 24px; margin-bottom: 12px;">네트워크 및 원격 접속 정보</div>
<div class="form-group net-only full-width" style="grid-column: span 2;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<label style="margin: 0; font-size: 11px; font-weight: 700; color: var(--text-muted);">IP/MAC 및 접속 계정 정보</label>
<button type="button" id="btn-add-remote-info" class="btn btn-outline" style="height: 26px !important; padding: 0 10px; font-size: 11px; display: none;">+ 접속 정보 추가</button>
<div class="form-section-title net-only">네트워크 및 원격 접속 정보</div>
<div class="form-group net-only full-width">
<div class="history-header">
<label>IP/MAC 및 접속 계정 정보</label>
<button type="button" id="btn-add-remote-info" class="btn btn-outline btn-sm hidden">+ 접속 정보 추가</button>
</div>
<div id="hw-remote-info-container" style="display: flex; flex-direction: column; gap: 12px;"></div>
<div id="hw-remote-info-container" class="remote-info-container"></div>
</div>
<!-- [SECTION 5] 설치 위치 -->
<div class="form-section-title location-section" style="margin-top: 24px; margin-bottom: 12px;">설치 위치</div>
<div class="form-section-title location-section">설치 위치</div>
<div class="form-group location-field">
<label>건물/위치</label>
<select id="hw-bldg-select" name="location" style="${inputStyle}">${generateOptionsHTML(Object.keys(LOCATION_DATA))}</select>
<select id="hw-bldg-select" name="location">${generateOptionsHTML(Object.keys(LOCATION_DATA))}</select>
</div>
<div class="form-group location-field">
<label>${ASSET_SCHEMA.LOC_DETAIL.ui}</label>
<div class="input-with-btn" style="display: flex; gap: 8px; align-items: stretch;">
<select id="hw-location_detail" name="location_detail" style="flex: 1; ${inputStyle}"><option value="">선택</option></select>
<button type="button" id="btn-reg-loc-map" class="btn btn-primary" style="${btnStyle} display: none;">위치등록</button>
<button type="button" id="btn-view-loc-map" class="btn btn-primary btn-loc-action btn-loc-view" style="${btnStyle} display: none; pointer-events: auto !important; cursor: pointer !important;">위치보기</button>
<div class="input-with-btn">
<select id="hw-location_detail" name="location_detail"><option value="">선택</option></select>
<button type="button" id="btn-reg-loc-map" class="btn btn-primary hidden">위치등록</button>
<button type="button" id="btn-view-loc-map" class="btn btn-primary btn-loc-action btn-loc-view hidden">위치보기</button>
</div>
<input type="hidden" id="hw-loc_x" name="loc_x" /><input type="hidden" id="hw-loc_y" name="loc_y" /><input type="hidden" id="hw-location_photo" name="location_photo" />
</div>
<!-- [SECTION 6] 구매 정보 -->
<div class="form-section-title" style="margin-top: 24px; margin-bottom: 12px;">구매 및 증빙 정보</div>
<div class="form-section-title">구매 및 증빙 정보</div>
<div class="form-group">
<label>${ASSET_SCHEMA.PURCHASE_DATE.ui}</label>
<input type="text" id="hw-purchase_date" name="purchase_date" placeholder="YYYY-MM-DD" style="${inputStyle}" />
<input type="text" id="hw-purchase_date" name="purchase_date" placeholder="YYYY-MM-DD" />
</div>
<div class="form-group">
<label>${ASSET_SCHEMA.PURCHASE_VENDOR.ui}</label>
<input type="text" id="hw-purchase_vendor" name="purchase_vendor" style="${inputStyle}" />
<input type="text" id="hw-purchase_vendor" name="purchase_vendor" />
</div>
<div class="form-group">
<label>${ASSET_SCHEMA.PURCHASE_AMOUNT.ui}</label>
<input type="text" id="hw-purchase_amount" name="purchase_amount" placeholder="0" style="${inputStyle}" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" />
<input type="text" id="hw-purchase_amount" name="purchase_amount" placeholder="0" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\\\B(?=(\\\\d{3})+(?!\\\\d))/g, ',')" />
</div>
<div class="form-group">
<label>${ASSET_SCHEMA.APPROVAL_DOC.ui} (첨부파일)</label>
<div class="file-upload-wrapper">
<input type="file" id="hw-approval_document_file" style="display:none;" />
<div class="input-with-btn" style="display: flex; gap: 8px; align-items: stretch;">
<button type="button" id="btn-file-select" onclick="document.getElementById('hw-approval_document_file').click()" class="btn btn-outline btn-loc-action" style="${btnStyle} flex: 1; justify-content: flex-start; pointer-events: auto !important; cursor: pointer !important;">
<div class="input-with-btn">
<button type="button" id="btn-file-select" onclick="document.getElementById('hw-approval_document_file').click()" class="btn btn-outline btn-loc-action">
<span id="hw-file-name-display">파일 선택...</span>
</button>
</div>
<input type="hidden" id="hw-approval_document" name="approval_document" />
<div id="hw-file-link-container" style="margin-top: 4px;"></div>
<div id="hw-file-link-container"></div>
</div>
</div>
<div class="form-group full-width">
<label>${ASSET_SCHEMA.MEMO.ui}</label>
<textarea id="hw-memo" name="memo" rows="3" style="width: 100%; padding: 10px; border: 1px solid var(--border-color); border-radius: 4px; font-family: inherit; font-size: 13px; resize: none !important; box-sizing: border-box;"></textarea>
<textarea id="hw-memo" name="memo" rows="3"></textarea>
</div>
</form>
</div>
<div class="modal-history-area">
<div class="history-header" style="border-bottom: 1px solid var(--border-color); padding-bottom: 12px; margin-bottom: 16px;">
<h3 style="margin: 0; font-size: 14px; font-weight: 800;">자산 변동 이력</h3>
<button type="button" id="btn-add-hw-log" class="btn btn-outline btn-sm" style="height: 30px; font-size: 11px;">이력 추가</button>
<div class="history-header">
<h3>자산 변동 이력</h3>
<button type="button" id="btn-add-hw-log" class="btn btn-outline btn-sm">이력 추가</button>
</div>
<div id="hw-history-list" class="history-timeline"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button id="btn-delete-hw-asset" class="btn btn-outline btn-danger" style="height: 42px;">삭제</button>
<button id="btn-delete-hw-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-hw-edit" class="btn btn-outline hidden" style="height: 42px;">수정 취소</button>
<button id="btn-cancel-hw-modal" class="btn btn-outline" style="height: 42px;">닫기</button>
<button id="btn-save-hw-asset" class="btn btn-primary" style="height: 42px;">저장</button>
<button id="btn-revert-hw-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-hw-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-hw-asset" class="btn btn-primary">저장</button>
</div>
</div>
</div>
@@ -395,25 +391,20 @@ class HwAssetModal extends BaseModal {
if (!container) return;
const row = document.createElement('div');
row.style.display = 'flex';
row.style.gap = '8px';
row.style.alignItems = 'center';
row.className = 'volume-row';
const inputStyle = 'height: 38px !important; box-sizing: border-box !important; font-size: 13px; margin: 0; padding: 0 8px;';
row.innerHTML = `
<select class="vol-type" style="${inputStyle} width: 80px;" ${!this.isEditMode ? 'disabled' : ''}>
<select class="vol-type" ${!this.isEditMode ? 'disabled' : ''}>
<option value="SSD" ${vol.type === 'SSD' ? 'selected' : ''}>SSD</option>
<option value="HDD" ${vol.type === 'HDD' ? 'selected' : ''}>HDD</option>
<option value="NVMe" ${vol.type === 'NVMe' ? 'selected' : ''}>NVMe</option>
</select>
<input type="number" class="vol-cap" value="${vol.capacity || ''}" placeholder="용량" style="${inputStyle} flex: 1;" ${!this.isEditMode ? 'readonly' : ''} />
<select class="vol-unit" style="${inputStyle} width: 70px;" ${!this.isEditMode ? 'disabled' : ''}>
<input type="number" class="vol-cap" value="${vol.capacity || ''}" placeholder="용량" ${!this.isEditMode ? 'readonly' : ''} />
<select class="vol-unit" ${!this.isEditMode ? 'disabled' : ''}>
<option value="GB" ${vol.unit === 'GB' ? 'selected' : ''}>GB</option>
<option value="TB" ${vol.unit === 'TB' ? 'selected' : ''}>TB</option>
</select>
<button type="button" class="btn btn-outline btn-remove-vol edit-only-btn" style="height: 38px !important; padding: 0 12px; color: #E11D48; border-color: #E11D48; display: ${this.isEditMode ? 'inline-flex' : 'none'};">&times;</button>
<button type="button" class="btn btn-outline btn-remove-vol edit-only-btn" style="display: ${this.isEditMode ? 'inline-flex' : 'none'};">&times;</button>
`;
row.querySelector('.btn-remove-vol')?.addEventListener('click', () => row.remove());
@@ -456,7 +447,8 @@ class HwAssetModal extends BaseModal {
// Second Line: Tool & Credentials (Only for IP)
const line2 = document.createElement('div');
line2.className = 'ri-line ri-cred-line';
line2.style.display = info.type === 'IP' ? 'flex' : 'none';
if (info.type !== 'IP') line2.classList.add('hidden');
line2.innerHTML = `
<div class="ri-connector"></div>
<select class="ri-tool" ${!this.isEditMode ? 'disabled' : ''}>
@@ -466,7 +458,7 @@ class HwAssetModal extends BaseModal {
</select>
<input type="text" class="ri-id" value="${parsedId}" placeholder="원격 ID" ${!this.isEditMode ? 'readonly' : ''} />
<input type="text" class="ri-pw" value="${parsedPw}" placeholder="원격 PW" ${!this.isEditMode ? 'readonly' : ''} />
<div class="ri-spacer"></div> <!-- Spacer for the remove button width -->
<div class="ri-spacer"></div>
`;
row.appendChild(line1);
@@ -476,7 +468,7 @@ class HwAssetModal extends BaseModal {
const typeSelect = row.querySelector('.ri-type') as HTMLSelectElement;
typeSelect.addEventListener('change', (e) => {
const isIP = (e.target as HTMLSelectElement).value === 'IP';
line2.style.display = isIP ? 'flex' : 'none';
line2.classList.toggle('hidden', !isIP);
if (!isIP) {
(row.querySelector('.ri-id') as HTMLInputElement).value = '';
(row.querySelector('.ri-pw') as HTMLInputElement).value = '';
@@ -741,9 +733,9 @@ class HwAssetModal extends BaseModal {
private renderHistory(assetId: string) {
const container = document.getElementById('hw-history-list');
if (!container) return;
const logs = (state.masterData.logs || []).filter(l => l.asset_id === assetId);
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.log_date}</div><div class=\"history-user\">${l.log_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('');
}
private getCategoryKey(asset: any): string {

View File

@@ -100,12 +100,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 +126,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 +140,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="refresh-ccw"></i>
</button>
</div>
<div id="sw-history-list" class="history-timeline"></div>
@@ -173,7 +173,7 @@ class SwAssetModal extends BaseModal {
<div id="sw-update-modal" class="modal-overlay hidden" style="z-index: 1100;">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-header">
<h2>계약 업데이트 반영</h2>
<h2 class="modal-title">계약 업데이트 반영</h2>
<button id="btn-close-sw-update" class="btn-icon"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
@@ -184,10 +184,10 @@ class SwAssetModal extends BaseModal {
</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">

View File

@@ -78,12 +78,8 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
adminGroup.className = 'nav-group';
const adminTrigger = document.createElement('div');
adminTrigger.className = 'gnb-trigger';
adminTrigger.className = 'gnb-trigger admin-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');

View File

@@ -189,7 +189,9 @@ input:checked + .slider:before { transform: translateX(16px); }
.badge-light { background: var(--bg-color); color: var(--text-muted); border: 1px solid var(--border-color); }
.hidden { display: none !important; }
@media (max-width: 768px) {
.brand h1 .sub-title { display: none; }
.header-actions .btn span { display: none; }
.gnb-trigger.admin-trigger {
color: var(--text-muted);
border-left: 1px solid var(--border-color);
margin-left: 1rem;
padding-left: 1.5rem;
}

View File

@@ -52,12 +52,101 @@
min-height: 360px;
}
.dashboard-card canvas {
flex: 1;
width: 100% !important;
max-height: 280px;
.dashboard-stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
margin-bottom: 2rem;
}
.dashboard-card-mini {
min-height: auto;
}
.dashboard-card h4 {
font-size: 1rem;
font-weight: 700;
color: var(--text-main);
margin-bottom: 0.25rem;
}
.dashboard-card .description {
font-size: 0.8125rem;
color: var(--text-muted);
margin-bottom: 1rem;
}
.dashboard-card .big-value {
font-size: 2rem;
font-weight: 700;
color: var(--primary-color);
}
.dashboard-card .big-value.danger {
color: var(--danger);
}
.progress-container {
width: 100%;
height: 4px;
background-color: var(--border-color);
border-radius: 2px;
overflow: hidden;
margin-top: 0.5rem;
}
.progress-fill {
height: 100%;
background-color: var(--primary-color);
}
.progress-fill.danger {
background-color: var(--danger);
}
.chart-wrapper {
height: 250px;
}
.dashboard-table-container {
background: white;
border-radius: 8px;
border: 1px solid var(--border-color);
overflow: hidden;
}
.dashboard-table-container table {
width: 100%;
border-collapse: collapse;
}
.dashboard-table-container th {
padding: 0.75rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--text-muted);
border-bottom: 2px solid var(--border-color);
text-align: center;
}
.dashboard-table-container td {
padding: 0.75rem;
font-size: 0.8125rem;
border-bottom: 1px solid var(--border-color);
text-align: center;
}
.badge-type {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
background: var(--bg-color);
border: 1px solid var(--border-color);
font-size: 0.75rem;
font-weight: 600;
}
/* --- Location View Styles --- */
.location-layout {
display: grid;

View File

@@ -40,10 +40,13 @@
flex-shrink: 0;
}
.modal-header h2 {
.modal-header h2, .modal-title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
font-weight: 800;
letter-spacing: -0.02em;
color: var(--white);
line-height: 1.2;
}
.modal-header .btn-icon {
@@ -51,6 +54,8 @@
cursor: pointer;
background: none !important;
border: none !important;
font-size: 1.75rem;
line-height: 1;
}
.btn-icon {
@@ -97,75 +102,15 @@
font-size: 0.875rem;
font-weight: 700;
color: var(--primary-color);
padding: 1.5rem 0 0.5rem 0; /* 패딩 조정 */
padding: 1.5rem 0 0.5rem 0;
border-bottom: 1px solid var(--border-color);
margin-bottom: 0.5rem;
margin-bottom: 0.75rem;
display: flex;
align-items: center;
}
/* Modal Readonly/Edit Mode Interaction */
.grid-form.is-view-mode input,
.grid-form.is-view-mode select,
.grid-form.is-view-mode textarea {
border: none !important;
background-color: transparent !important;
padding-left: 0 !important;
padding-right: 0 !important;
pointer-events: none !important;
color: var(--text-main) !important;
font-weight: 500 !important;
appearance: none !important;
-webkit-appearance: none !important;
-moz-appearance: none !important;
box-shadow: none !important;
}
.grid-form.is-view-mode input[type="file"] {
display: none !important;
}
.grid-form.is-view-mode .btn-helper {
display: none !important;
}
.grid-form.is-view-mode button:not(.btn-loc-action) {
pointer-events: none !important;
background: none !important;
border: none !important;
opacity: 0.8;
}
.grid-form.is-view-mode select::-ms-expand {
display: none !important;
}
.grid-form.is-edit-mode input,
.grid-form.is-edit-mode select,
.grid-form.is-edit-mode textarea {
color: var(--edit-mode-color); /* 수정 시 글자색 변경 */
border: 1px solid var(--border-color);
}
/* 수동 수정 불가 필드 (자산번호 등) 전용 스타일 */
.grid-form input[readonly] {
border-color: transparent !important;
background-color: transparent !important;
pointer-events: none !important;
color: var(--text-main) !important;
font-weight: 500 !important;
cursor: default;
}
.grid-form.is-edit-mode input:focus,
.grid-form.is-edit-mode select:focus,
.grid-form.is-edit-mode textarea:focus {
border-color: var(--edit-mode-color);
box-shadow: 0 0 0 2px var(--edit-mode-focus);
}
.form-section-title:first-child {
padding-top: 0.5rem;
padding-top: 0;
}
.form-group label {
@@ -177,17 +122,21 @@
.form-group input,
.form-group select,
.form-group textarea {
padding: 0.625rem;
height: 38px;
padding: 0 0.625rem;
border: 1px solid var(--border-color);
border-radius: 4px;
font-family: inherit;
font-size: 0.875rem;
font-size: 0.8125rem;
outline: none;
transition: all 0.2s;
background-color: var(--white);
box-sizing: border-box;
}
.form-group textarea {
height: auto;
padding: 0.625rem;
resize: none;
}
@@ -208,6 +157,29 @@
flex-shrink: 0;
}
.modal-footer .btn {
height: 42px;
padding: 0 1.25rem;
font-size: 0.875rem;
}
.edit-only-btn {
/* Dynamic visibility handled in JS, but base style here */
}
/* File Upload Display */
.file-upload-wrapper {
display: flex;
flex-direction: column;
gap: 4px;
}
#hw-file-name-display {
font-size: 0.8125rem;
color: var(--text-muted);
}
.footer-actions {
display: flex;
gap: 0.5rem;

View File

@@ -230,3 +230,91 @@ th.sortable.desc::after {
opacity: 1;
color: var(--primary-color);
}
/* --- Mini Table for System Status --- */
.mini-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.mini-table thead {
position: sticky;
top: 0;
background: var(--white);
z-index: 10;
}
.mini-table th {
padding: 10px 0;
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
border-bottom: 2px solid var(--border-color);
background: var(--white);
text-align: center;
}
.mini-table th:nth-child(2) {
text-align: left;
}
.mini-row {
border-bottom: 1px solid var(--border-color);
cursor: pointer;
font-size: 12px;
transition: background-color 0.2s;
}
.mini-row:hover {
background-color: #F8FAFA;
}
.mini-row.active {
background-color: #EBF2F1 !important;
}
.mini-row td {
padding: 10px 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
}
.mini-row td:nth-child(2) {
text-align: left;
font-weight: 600;
}
.mini-row.warning {
background-color: #FFF1F2 !important;
border-left: 3px solid #E11D48;
}
.mini-row.warning td {
color: #991B1B !important;
}
.warning-badge {
background: #FFF1F2;
color: #E11D48;
font-size: 10px;
font-weight: 800;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid #FDA4AF;
white-space: nowrap;
}
.warning-badge-orange {
background: #FFF7ED;
color: #C2410C;
font-size: 10px;
font-weight: 800;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid #FFEDD5;
white-space: nowrap;
}

View File

@@ -64,56 +64,58 @@ export function renderHwDashboard(container: HTMLElement) {
// 2. UI 렌더링
container.innerHTML = `
<div class="view-container">
<div class="dashboard-header-stats" style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; margin-bottom: 2rem;">
<div class="dashboard-card" style="min-height:auto;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">전체 평균 사용 연수</span>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">전체 자산 기준 (권장 4.5년)</div>
<div style="font-size: 2rem; font-weight:700; color:var(--dash-primary);">${avgAge}년</div>
<div style="width: 100%; height: 4px; background-color: var(--border-color); border-radius: 2px; overflow: hidden; margin-top: 0.5rem;">
<div style="width: ${Math.min(100, (Number(avgAge)/4.5)*100)}%; height: 100%; background-color: var(--dash-primary);"></div>
<div class="dashboard-stats-grid">
<div class="dashboard-card dashboard-card-mini">
<h4>전체 평균 사용 연수</h4>
<div class="description">전체 자산 기준 (권장 4.5년)</div>
<div class="big-value">${avgAge}년</div>
<div class="progress-container">
<div class="progress-fill" style="width: ${Math.min(100, (Number(avgAge)/4.5)*100)}%;"></div>
</div>
</div>
<div class="dashboard-card" style="min-height:auto;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">5년 이상 노후 자산 비율</span>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">총 ${over5YearsCount}대 해당</div>
<div style="font-size: 2rem; font-weight:700; color:${over5Rate >= 20 ? 'var(--dash-danger)' : 'var(--dash-primary)'};">${over5Rate}%</div>
<div style="width: 100%; height: 4px; background-color: var(--border-color); border-radius: 2px; overflow: hidden; margin-top: 0.5rem;">
<div style="width: ${over5Rate}%; height: 100%; background-color: ${over5Rate >= 20 ? 'var(--dash-danger)' : 'var(--dash-primary)'};"></div>
<div class="dashboard-card dashboard-card-mini">
<h4>5년 이상 노후 자산 비율</h4>
<div class="description">총 ${over5YearsCount}대 해당</div>
<div class="big-value ${over5Rate >= 20 ? 'danger' : ''}">${over5Rate}%</div>
<div class="progress-container">
<div class="progress-fill ${over5Rate >= 20 ? 'danger' : ''}" style="width: ${over5Rate}%;"></div>
</div>
</div>
<div class="dashboard-card" style="min-height:auto;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">최신 도입 모델 (${latestYear}년)</span>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">자산번호: ${latestAsset ? latestAsset[ASSET_SCHEMA.ASSET_CODE.key] : '-'}</div>
<div style="font-size: 1.25rem; font-weight:700; color:var(--primary-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; height: 3rem; display: flex; align-items: center;" title="${latestAsset ? latestAsset[ASSET_SCHEMA.MODEL_NAME.key] : '정보 없음'}">
<div class="dashboard-card dashboard-card-mini">
<h4>최신 도입 모델 (${latestYear}년)</h4>
<div class="description">자산번호: ${latestAsset ? latestAsset[ASSET_SCHEMA.ASSET_CODE.key] : '-'}</div>
<div class="big-value" style="font-size: 1.25rem; height: 3rem; display: flex; align-items: center;" title="${latestAsset ? latestAsset[ASSET_SCHEMA.MODEL_NAME.key] : '정보 없음'}">
${latestAsset ? (latestAsset[ASSET_SCHEMA.MODEL_NAME.key] || latestAsset[ASSET_SCHEMA.ASSET_NAME.key] || '정보 없음') : '정보 없음'}
</div>
<div style="width: 100%; height: 4px; background-color: var(--primary-color); border-radius: 2px; margin-top: 0.5rem;"></div>
<div class="progress-container">
<div class="progress-fill" style="width: 100%;"></div>
</div>
</div>
</div>
<div class="dashboard-layout-2col" style="margin-bottom: 2rem;">
<div class="dashboard-card">
<h4 class="card-title">자산 노후도 분포</h4>
<div style="height: 250px;"><canvas id="chart-aging-dist"></canvas></div>
<div class="chart-wrapper"><canvas id="chart-aging-dist"></canvas></div>
</div>
<div class="dashboard-card">
<h4 class="card-title">연도별 자산 도입 추이</h4>
<div style="height: 250px;"><canvas id="chart-purchase-trend"></canvas></div>
<div class="chart-wrapper"><canvas id="chart-purchase-trend"></canvas></div>
</div>
</div>
<h3 class="dashboard-section-title">⚠️ 교체 검토 대상 (가장 오래된 자산 TOP 10)</h3>
<div class="table-container" style="background: white; border-radius: 8px; border: 1px solid var(--border-color);">
<div class="dashboard-table-container">
<table>
<thead>
<tr>
<th style="text-align:center;">순위</th>
<th style="text-align:center;">자산번호</th>
<th style="text-align:center;">유형</th>
<th style="text-align:center;">모델명</th>
<th style="text-align:center;">담당자</th>
<th style="text-align:center;">구매일자</th>
<th style="text-align:center;">연령</th>
<th>순위</th>
<th>자산번호</th>
<th>유형</th>
<th>모델명</th>
<th>담당자</th>
<th>구매일자</th>
<th>연령</th>
</tr>
</thead>
<tbody>
@@ -121,14 +123,14 @@ export function renderHwDashboard(container: HTMLElement) {
const pDate = a[ASSET_SCHEMA.PURCHASE_DATE.key];
const age = calculateAssetAge(pDate);
return `
<tr class="clickable-row" data-id="${a.id}" style="cursor:pointer;">
<td style="text-align:center; font-weight:600; color:var(--text-muted)">${i + 1}</td>
<td style="text-align:center;">${a[ASSET_SCHEMA.ASSET_CODE.key] || '-'}</td>
<td style="text-align:center;"><span class="badge-type">${a[ASSET_SCHEMA.ASSET_TYPE.key] || a.category || ''}</span></td>
<td>${a[ASSET_SCHEMA.MODEL_NAME.key] || a[ASSET_SCHEMA.ASSET_NAME.key] || '-'}</td>
<td style="text-align:center;">${a[ASSET_SCHEMA.MANAGER_MAIN.key] || '-'}</td>
<td style="text-align:center;">${pDate || '-'}</td>
<td style="text-align:center;"><strong style="color:${age >= 5 ? 'var(--danger)' : 'inherit'}">${age}년</strong></td>
<tr class="clickable-row" data-id="${a.id}">
<td style="font-weight:600; color:var(--text-muted)">${i + 1}</td>
<td>${a[ASSET_SCHEMA.ASSET_CODE.key] || '-'}</td>
<td><span class="badge-type">${a[ASSET_SCHEMA.ASSET_TYPE.key] || a.category || ''}</span></td>
<td style="text-align: left; padding-left: 1rem;">${a[ASSET_SCHEMA.MODEL_NAME.key] || a[ASSET_SCHEMA.ASSET_NAME.key] || '-'}</td>
<td>${a[ASSET_SCHEMA.MANAGER_MAIN.key] || '-'}</td>
<td>${pDate || '-'}</td>
<td><strong style="color:${age >= 5 ? 'var(--danger)' : 'inherit'}">${age}년</strong></td>
</tr>
`;
}).join('')}

View File

@@ -37,34 +37,34 @@ export function renderSwDashboard(container: HTMLElement) {
<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:1rem; font-weight:700; color:var(--text-main);">외부 소프트웨어 사용율</span>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">${extQty}카피 중 ${extUsed}개 할당</div>
<div style="font-size: 2rem; 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-card dashboard-card-mini clickable-card" data-action="ext-usage">
<h4>외부 소프트웨어 사용율</h4>
<div class="description">${extQty}카피 중 ${extUsed}개 할당</div>
<div class="big-value">${extPer}%</div>
<div class="progress-container">
<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:1rem; font-weight:700; color:var(--text-main);">내부 소프트웨어 현황</span>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">등록된 내부 솔루션: ${intTotal}개</div>
<div style="font-size: 2rem; 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 dashboard-card-mini clickable-card" data-action="int-usage">
<h4>내부 소프트웨어 현황</h4>
<div class="description">등록된 내부 솔루션: ${intTotal}개</div>
<div class="big-value">${intPer}%</div>
<div class="progress-container">
<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:1rem; font-weight:700; color:var(--text-main);">외부 SW 누적 비용 (2026)</span>
<div style="font-size: 2rem; font-weight:700; color:var(--dash-primary);">₩ ${extCost2026.toLocaleString()}</div>
<div class="dashboard-layout-2col">
<div class="dashboard-card dashboard-card-mini">
<h4>외부 SW 누적 비용 (2026)</h4>
<div class="big-value">₩ ${extCost2026.toLocaleString()}</div>
</div>
<div class="dashboard-card" style="min-height:auto;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">내부 SW 누적 비용 (2026)</span>
<div style="font-size: 2rem; font-weight:700; color:#3b82f6;">₩ ${intCost2026.toLocaleString()}</div>
<div class="dashboard-card dashboard-card-mini">
<h4>내부 SW 누적 비용 (2026)</h4>
<div class="big-value" style="color:#3b82f6;">₩ ${intCost2026.toLocaleString()}</div>
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
import { dynamicSort, renderPageHeader, calculateAssetAge, formatInline } from '../../core/utils';
import { dynamicSort, renderPageHeader, calculateAssetAge } from '../../core/utils';
import { setupTableSorting, SortState } from '../../core/tableHandler';
import { renderFilterBar, applyCommonFilters } from '../../core/filterHandler';
import { state } from '../../core/state';
@@ -41,7 +41,6 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
let sortState: SortState = config.persistentSortState || { key: '', direction: 'asc' };
let currentFilters: any = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '' };
// 서버 탭이 아닐 경우 '자산 현황(대시보드)' 뷰 진입 방지 및 강제 'asset' 모드
const isServer = config.title === '서버';
if (!isServer) {
(state as any).currentViewMode = 'asset';
@@ -49,17 +48,17 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
(state as any).currentViewMode = 'system';
}
// 2. 뷰 전환 토글 버튼 생성 (명칭 변경)
// 2. 뷰 전환 토글 버튼 생성
const toggleWrapper = document.createElement('div');
toggleWrapper.className = 'view-toggle-container';
toggleWrapper.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
<div class="view-toggle" style="display: ${isServer ? 'flex' : 'none'}; gap: 0;">
<div class="view-toggle-flex-row">
<div class="view-toggle" style="display: ${isServer ? 'flex' : 'none'};">
<button class="toggle-btn ${(state as any).currentViewMode === 'system' ? 'active' : ''}" data-mode="system">자산 현황</button>
<button class="toggle-btn ${(state as any).currentViewMode === 'asset' ? 'active' : ''}" data-mode="asset">자산 목록</button>
</div>
<button id="btn-add-asset" style="padding: 6px 14px; font-size: 12px; font-weight: 700; background: #1E5149; color: white; border: none; border-radius: 4px; cursor: pointer; display: flex; align-items: center; gap: 4px;">
<span style="font-size: 16px; line-height: 1;">+</span> 자산 추가
<button id="btn-add-asset" class="btn btn-primary btn-add-new">
<span class="plus-icon">+</span> 자산 추가
</button>
</div>
`;
@@ -80,7 +79,6 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
let selectedDetailLocation: string | null = null;
let dynamicMapConfig: Record<string, any[]> = {};
// 맵 설정 미리 로드
const fetchMapConfig = async () => {
try {
const res = await fetch(`http://${location.hostname}:3000/api/maps`);
@@ -93,60 +91,26 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
const renderSystemStatus = () => {
const isPcView = config.title === 'PC';
const locationCounts: Record<string, number> = {};
const pcTypeCounts = { public: 0, server: 0, personal: 0 };
// 동적 통계 수집 객체 (Hardcoding 제거)
const extStats = {
total: 0,
locCounts: {} as Record<string, number>,
typeCounts: {} as Record<string, number>,
typeLocMap: {} as Record<string, Record<string, number>>, // 유형별 위치 분포
locWarning: 0,
typeWarning: 0
};
const intStats = {
total: 0,
locCounts: {} as Record<string, number>,
typeCounts: {} as Record<string, number>,
typeLocMap: {} as Record<string, Record<string, number>>
};
const extStats = { total: 0, locCounts: {} as Record<string, number>, typeCounts: {} as Record<string, number>, typeLocMap: {} as Record<string, Record<string, number>>, locWarning: 0, typeWarning: 0 };
const intStats = { total: 0, locCounts: {} as Record<string, number>, typeCounts: {} as Record<string, number>, typeLocMap: {} as Record<string, Record<string, number>> };
// 중앙화된 경고 감지 로직
const checkAnomaly = (serviceType: string, loc: string, type: string) => {
if (serviceType !== '외부') return { isWarning: false, isLocWarning: false, isTypeWarning: false, reason: '' };
if (serviceType !== '외부') return { isWarning: false, isLocWarning: false, isTypeWarning: false };
const isLocWarning = loc !== 'IDC' && loc !== '미지정' && loc !== '';
const isTypeWarning = type.toLowerCase().replace(/\s/g, '').includes('서버pc');
const isWarning = isLocWarning || isTypeWarning;
let reason = '';
if (isLocWarning && isTypeWarning) reason = '위치/형식 부적절';
else if (isLocWarning) reason = '위치 부적절';
else if (isTypeWarning) reason = '형식 부적절';
return { isWarning, isLocWarning, isTypeWarning, reason };
return { isWarning: isLocWarning || isTypeWarning, isLocWarning, isTypeWarning };
};
fullList.forEach(asset => {
const loc = asset[ASSET_SCHEMA.LOCATION.key] || '미지정';
const serviceTypeKey = (ASSET_SCHEMA as any).SERVICE_TYPE?.key || 'service_type';
const serviceType = asset[serviceTypeKey] || '외부';
const serviceType = asset.service_type || '외부';
const type = asset[ASSET_SCHEMA.ASSET_TYPE.key] || '';
locationCounts[loc] = (locationCounts[loc] || 0) + 1;
if (isPcView) {
if (type.includes('공용')) pcTypeCounts.public++;
else if (type.includes('서버')) pcTypeCounts.server++;
else pcTypeCounts.personal++;
}
const targetStat = serviceType === '내부' ? intStats : extStats;
targetStat.total++;
if (loc) targetStat.locCounts[loc] = (targetStat.locCounts[loc] || 0) + 1;
if (type) {
targetStat.typeCounts[type] = (targetStat.typeCounts[type] || 0) + 1;
// 유형별 위치 분포 수집
if (!targetStat.typeLocMap[type]) targetStat.typeLocMap[type] = {};
targetStat.typeLocMap[type][loc] = (targetStat.typeLocMap[type][loc] || 0) + 1;
}
@@ -158,136 +122,94 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
}
});
// 템플릿 제너레이터 함수 (HTML 중복 제거)
const generateDetailStatHTML = (title: string, stats: any) => `
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; gap: 0.5rem;">
<span style="font-size: 14px; font-weight: 800; color: var(--text-main); white-space: nowrap;">${title}</span>
<div style="display: flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end;">
${stats.locWarning ? `<span style="background: #FFF7ED; color: #C2410C; font-size: 10px; font-weight: 800; padding: 2px 6px; border-radius: 4px; border: 1px solid #FFEDD5; white-space: nowrap;">위치부적절: ${stats.locWarning}</span>` : ''}
${stats.typeWarning ? `<span style="background: #FFF1F2; color: #E11D48; font-size: 10px; font-weight: 800; padding: 2px 6px; border-radius: 4px; border: 1px solid #FDA4AF; white-space: nowrap;">형식부적절: ${stats.typeWarning}</span>` : ''}
<div class="detail-stat-header">
<span class="stat-title">${title}</span>
<div class="stat-badges">
${stats.locWarning ? `<span class="warning-badge-orange">위치부적절: ${stats.locWarning}</span>` : ''}
${stats.typeWarning ? `<span class="warning-badge">형식부적절: ${stats.typeWarning}</span>` : ''}
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 0.3rem; font-size: 13px; color: var(--text-muted);">
<div style="display: flex; gap: 0.75rem; flex-wrap: wrap;">
${Object.entries(stats.locCounts as Record<string, number>).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([l, c]) => `<span>${l}: <strong style="color:var(--text-main); font-size: 14px;">${c}</strong></span>`).join('')}
<div class="detail-stat-body">
<div class="loc-summary">
${Object.entries(stats.locCounts as Record<string, number>).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([l, c]) => `<span>${l}: <strong>${c}</strong></span>`).join('')}
</div>
<div style="display: flex; gap: 0.6rem; flex-wrap: wrap; opacity: 0.9; border-top: 1px dashed var(--border-color); padding-top: 4px; margin-top: 2px;">
<div class="type-summary">
${Object.entries(stats.typeCounts as Record<string, number>).sort((a, b) => b[1] - a[1]).slice(0, 6).map(([t, c]) => {
const isTypeWarning = title.includes('외부') && t.toLowerCase().replace(/\s/g, '').includes('서버pc');
// 위치별 상세 정보 생성 (툴팁용)
const locDist = stats.typeLocMap[t] || {};
const locHint = Object.entries(locDist)
.sort((a: any, b: any) => b[1] - a[1])
.map(([l, count]) => `${l}: ${count}`)
.join('\n');
return `<span title="${locHint}" style="${isTypeWarning ? 'color:#E11D48; font-weight:700;' : ''}; font-size: 13px; cursor: help;">${t}: <strong style="color:var(--text-main); font-size: 14px;">${c}</strong></span>`;
const locHint = Object.entries(locDist).sort((a: any, b: any) => b[1] - a[1]).map(([l, count]) => `${l}: ${count}`).join('\n');
return `<span title="${locHint}">${t}: <strong>${c}</strong></span>`;
}).join('')}
</div>
</div>
`;
contentWrapper.innerHTML = `
<div class="system-dashboard" style="height: calc(100vh - 240px); overflow: hidden; padding: 0.5rem 0; font-family: 'Pretendard', sans-serif; letter-spacing: -0.02em; display: flex; flex-direction: column;">
<!-- [자산 통계 그룹] -->
<div style="border-bottom: 1px solid var(--border-color); padding-bottom: 1.25rem; margin-bottom: 1rem; flex-shrink: 0; display: grid; grid-template-columns: 1fr 1.5fr 1.5fr; gap: 2rem;">
<div class="stat-group-item" style="min-width: 0;">
<div style="font-size: 11px; font-weight: 600; color: var(--text-muted); margin-bottom: 0.25rem;">총 보유 자산</div>
<div style="font-size: 28px; font-weight: 800; color: var(--text-main); line-height: 1.1;">${fullList.length}<span style="font-size: 13px; font-weight: 600; margin-left: 4px; color: var(--text-muted);">대</span></div>
<div style="display: flex; gap: 0.75rem; font-size: 14px; color: var(--text-muted); margin-top: 0.5rem;">
<span>외부: <strong style="color:#35635C; font-size: 18px;">${extStats.total}</strong></span>
<span>내부: <strong style="color:#94A3B8; font-size: 18px;">${intStats.total}</strong></span>
<div class="system-dashboard">
<div class="dashboard-stats-row">
<div class="stat-group-item">
<div class="stat-label">총 보유 자산</div>
<div class="stat-value">${fullList.length}<span>대</span></div>
<div class="stat-sub">
<span>외부: <strong class="text-primary">${extStats.total}</strong></span>
<span>내부: <strong class="text-muted">${intStats.total}</strong></span>
</div>
</div>
<div class="stat-group-item" style="border-left: 1px solid var(--border-color); padding-left: 1.5rem; min-width: 0;">
${isPcView ? `
<div style="font-size: 11px; font-weight: 600; color: var(--text-muted); margin-bottom: 0.25rem;">PC 유형별 현황</div>
<div style="display: flex; gap: 1rem; font-size: 14px; color: var(--text-muted); margin-top: 0.5rem;">
<span>공용: <strong style="color:var(--text-main); font-size: 18px;">${pcTypeCounts.public}</strong></span>
<span>서버: <strong style="color:var(--text-main); font-size: 18px;">${pcTypeCounts.server}</strong></span>
<span>개인: <strong style="color:var(--text-main); font-size: 18px;">${pcTypeCounts.personal}</strong></span>
</div>
` : generateDetailStatHTML('외부 (운영) 상세', extStats)}
</div>
<div class="stat-group-item" style="border-left: 1px solid var(--border-color); padding-left: 1.5rem; min-width: 0;">
${isPcView ? '' : generateDetailStatHTML('내부 (테스트) 상세', intStats as any)}
</div>
<div class="stat-group-item bordered">${generateDetailStatHTML('외부 (운영) 상세', extStats)}</div>
<div class="stat-group-item bordered">${generateDetailStatHTML('내부 (테스트) 상세', intStats)}</div>
</div>
<div style="display: flex; flex: 1; min-height: 0; border-top: 1px solid var(--border-color);">
<!-- 좌측: 자산 현황 목록 (Border-based Separation) -->
<div class="list-section" style="flex: 1.1; display: flex; flex-direction: column; min-height: 0; padding: 1rem 1.5rem 0 0; border-right: 1px solid var(--border-color);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-shrink: 0;">
<h4 id="list-section-title" style="font-size: 14px; font-weight: 700; color: var(--text-main); margin:0;">자산 현황 목록</h4>
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 11px; font-weight: 600; color: var(--text-muted);">위치:</span>
<select id="select-loc" style="padding: 2px 8px; font-size: 11px; border-radius: 4px; border: 1px solid var(--border-color); outline: none; background: white; cursor:pointer; font-family: 'Pretendard';">
<div class="dashboard-main-flex">
<div class="list-section">
<div class="section-header-flex">
<h4 id="list-section-title">자산 현황 목록</h4>
<div class="filter-controls">
<span class="filter-label">위치:</span>
<select id="select-loc" class="filter-select">
<option value="">전체</option>
${Array.from(new Set(fullList.map(a => a[ASSET_SCHEMA.LOCATION.key] || '미지정'))).sort().map(l => `<option value="${l}" ${l === selectedLocation ? 'selected' : ''}>${l}</option>`).join('')}
</select>
<span style="font-size: 11px; font-weight: 600; color: var(--text-muted);">상세:</span>
<select id="select-detail-loc" style="padding: 2px 8px; font-size: 11px; border-radius: 4px; border: 1px solid var(--border-color); outline: none; background: white; cursor:pointer; font-family: 'Pretendard'; max-width: 120px;"></select>
<select id="select-detail-loc" class="filter-select select-detail-loc"></select>
</div>
</div>
<div style="flex: 1; overflow-y: auto;">
<table style="width: 100%; border-collapse: collapse; table-layout: fixed;">
<thead style="position: sticky; top: 0; background: #fff; z-index: 10;">
<tr style="text-align: left; font-size: 11px; color: var(--text-muted);">
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 80px; text-align:center; background: #fff;">분류</th>
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 130px; background: #fff;">용도/자산명</th>
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); text-align:center; width: 90px; background: #fff;">관리자(정)</th>
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); text-align:center; width: 90px; background: #fff;">관리자(부)</th>
<th style="padding: 10px 0; text-align: center; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 100px; background: #fff;">상세위치</th>
<div class="mini-table-container">
<table class="mini-table">
<thead>
<tr>
<th style="width: 80px;">분류</th>
<th>용도/자산명</th>
<th style="width: 90px;">관리자(정)</th>
<th style="width: 90px;">관리자(부)</th>
<th style="width: 100px;">상세위치</th>
</tr>
</thead>
<tbody id="system-status-tbody" style="font-size: 12px;"></tbody>
<tbody id="system-status-tbody"></tbody>
</table>
</div>
</div>
<!-- 우측: 상세 정보 패널 (Box-less, Line-based) -->
<div id="system-detail-panel" style="flex: 0.9; display: flex; flex-direction: column; min-height: 0; padding: 1rem 0 0 1.5rem; overflow: hidden;">
<div id="detail-empty-state" style="height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: center; color: var(--text-muted); text-align: center;">
<p style="font-size: 1.125rem; font-weight: 500; color: #94A3B8;">목록에서 자산을 선택하면<br>상세 정보와 배치도가 표시됩니다.</p>
<div id="system-detail-panel" class="detail-panel">
<div id="detail-empty-state" class="detail-empty-state">
<p>목록에서 자산을 선택하면 상세 정보가 표시됩니다.</p>
</div>
<div id="detail-content" style="display: none; height: 100%; flex-direction: column;">
<!-- 상단 요약 정보 (Wrapping 방지 최적화) -->
<div style="display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border-color); flex-shrink: 0; gap: 2rem;">
<div style="display: flex; gap: 2.5rem; align-items: flex-end; min-width: 0; flex: 1;">
<div style="flex-shrink: 0;">
<label style="display: block; font-size: 10px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; margin-bottom: 4px;">자산번호</label>
<div id="detail-asset-code" style="font-size: 14px; font-weight: 800; color: var(--primary-color); white-space: nowrap;"></div>
</div>
<div style="flex-shrink: 0;">
<label style="display: block; font-size: 10px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; margin-bottom: 4px;">유형</label>
<div id="detail-asset-type" style="font-size: 14px; font-weight: 600; color: var(--text-main); white-space: nowrap;"></div>
</div>
<div style="flex: 1; min-width: 0;">
<label style="display: block; font-size: 10px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; margin-bottom: 4px;">메모 요약</label>
<div id="detail-memo" style="font-size: 14px; color: var(--text-main); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"></div>
</div>
<div id="detail-content" class="detail-content hidden">
<div class="detail-summary-header">
<div class="summary-items">
<div class="summary-item"><label>자산번호</label><div id="detail-asset-code" class="code-value"></div></div>
<div class="summary-item"><label>유형</label><div id="detail-asset-type" class="type-value"></div></div>
<div class="summary-item flex-1"><label>메모 요약</label><div id="detail-memo" class="memo-value"></div></div>
</div>
<button id="btn-view-full-detail" style="flex-shrink: 0; padding: 6px 16px; font-size: 12px; font-weight: 700; background: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; transition: opacity 0.2s;">상세 보기</button>
<button id="btn-view-full-detail" class="btn btn-primary">상세 보기</button>
</div>
<!-- 메인 배치도 영역 -->
<div style="flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden;">
<div style="margin-bottom: 0.75rem; flex-shrink: 0; display: flex; justify-content: space-between; align-items: center;">
<label style="font-size: 11px; font-weight: 700; color: var(--text-main); text-transform: uppercase;">설치 위치 배치도</label>
</div>
<div id="detail-photo-wrapper" style="width: 100%; flex: 1; overflow: hidden; display: flex; align-items: center; justify-content: center; position: relative; border: 1px solid var(--border-color); background: #f0f0f0;">
<div class="layout-map-container readonly" style="position: relative; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%;">
<img id="detail-photo" src="" style="display: block; max-width: 100%; max-height: 100%; width: auto; height: auto; object-fit: contain; pointer-events: none;" />
<div id="detail-marker" class="layout-marker pulse-marker" style="display: none; position: absolute; z-index: 20;"></div>
<div id="detail-overlay-layer" class="digital-overlay-layer" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; display: flex; align-items: center; justify-content: center;"></div>
</div>
<div id="detail-no-photo" style="display: none; height: 100%; flex-direction: column; align-items: center; justify-content: center; gap: 1rem;">
<span style="color: #94A3B8; font-size: 13px; font-weight: 500;">등록된 배치도가 없습니다.</span>
<div class="detail-map-section">
<div class="map-label-header"><label>설치 위치 배치도</label></div>
<div id="detail-photo-wrapper" class="map-photo-wrapper">
<div class="layout-map-container readonly">
<img id="detail-photo" src="" class="map-photo" />
<div id="detail-marker" class="layout-marker pulse-marker hidden"></div>
<div id="detail-overlay-layer" class="digital-overlay-layer"></div>
</div>
<div id="detail-no-photo" class="no-photo-state hidden"><span>등록된 배치도가 없습니다.</span></div>
</div>
</div>
</div>
@@ -296,16 +218,15 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
</div>
`;
// 상세 정보 패널 업데이트 함수
const updateDetailPanel = (asset: any) => {
const emptyState = document.getElementById('detail-empty-state');
const content = document.getElementById('detail-content');
if (!emptyState || !content) return;
emptyState.style.display = 'none';
emptyState.classList.add('hidden');
content.classList.remove('hidden');
content.style.display = 'flex';
// 텍스트 정보 업데이트
const codeEl = document.getElementById('detail-asset-code');
const typeEl = document.getElementById('detail-asset-type');
const memoEl = document.getElementById('detail-memo');
@@ -314,75 +235,44 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
if (codeEl) codeEl.textContent = asset.asset_code || '미지정';
if (typeEl) typeEl.textContent = asset.asset_type || '-';
if (memoEl) memoEl.textContent = asset.memo || '-';
if (viewBtn) {
viewBtn.onclick = () => config.onRowClick && config.onRowClick(asset);
}
if (viewBtn) viewBtn.onclick = () => config.onRowClick && config.onRowClick(asset);
// 위치 및 사진 정보 업데이트
const photo = document.getElementById('detail-photo') as HTMLImageElement;
const marker = document.getElementById('detail-marker');
const overlayLayer = document.getElementById('detail-overlay-layer');
const noPhoto = document.getElementById('detail-no-photo');
const photoWrapper = document.getElementById('detail-photo-wrapper');
const bldg = asset.location || '';
const detail = asset.location_detail || '';
// 숫자 0도 유효한 좌표이므로 정확한 체크 필요
const x = asset.loc_x;
const y = asset.loc_y;
const hasCoords = (x !== null && x !== undefined && x !== '' && x !== 'null') &&
(y !== null && y !== undefined && y !== '' && y !== 'null');
const x = asset.loc_x; const y = asset.loc_y;
const hasCoords = (x !== null && x !== undefined && x !== '' && x !== 'null');
const savedImg = asset.location_photo || asset.loc_img;
const locImgs = IMAGE_LOCATIONS[bldg.trim()]?.[detail.trim()] || null;
const imgPath = (savedImg && locImgs?.includes(savedImg)) ? savedImg : (locImgs ? locImgs[0] : null);
// 좌표가 없으면 사진이 있어도 '정보 없음' 상태로 유도 (사용자 요청)
if (imgPath && hasCoords) {
photo.src = imgPath;
photo.style.display = 'block';
if (noPhoto) noPhoto.style.display = 'none';
photo.classList.remove('hidden');
if (noPhoto) noPhoto.classList.add('hidden');
photo.onload = () => {
const updateMarkerPos = () => {
const imgW = photo.clientWidth;
const imgH = photo.clientHeight;
const imgW = photo.clientWidth; const imgH = photo.clientHeight;
if (marker) {
marker.style.left = `calc(50% - ${imgW/2}px + ${ (parseFloat(x as string) * imgW) / 100 }px)`;
marker.style.top = `calc(50% - ${imgH/2}px + ${ (parseFloat(y as string) * imgH) / 100 }px)`;
marker.style.display = 'block';
marker.classList.remove('hidden');
}
if (overlayLayer) {
overlayLayer.style.width = `${imgW}px`;
overlayLayer.style.height = `${imgH}px`;
overlayLayer.style.left = `calc(50% - ${imgW/2}px)`;
overlayLayer.style.top = `calc(50% - ${imgH/2}px)`;
overlayLayer.style.width = `${imgW}px`; overlayLayer.style.height = `${imgH}px`;
overlayLayer.style.left = `calc(50% - ${imgW/2}px)`; overlayLayer.style.top = `calc(50% - ${imgH/2}px)`;
const boxes = dynamicMapConfig[imgPath] || [];
if (boxes.length > 0) {
overlayLayer.innerHTML = `
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style="width:100%; height:100%;">
<g class="seat-group">
${boxes.map((b, i) => {
const isSelected = b.x === x && b.y === y;
const fill = isSelected ? 'rgba(255, 61, 0, 0.4)' : 'rgba(30, 81, 73, 0.02)';
const stroke = isSelected ? '#FF3D00' : 'rgba(30, 81, 73, 0.15)';
const strokeWidth = isSelected ? '0.8' : '0.2';
if (isSelected && marker) {
marker.style.left = `calc(50% - ${imgW/2}px + ${ (parseFloat(b.x) + parseFloat(b.w)/2) * imgW / 100 }px)`;
marker.style.top = `calc(50% - ${imgH/2}px + ${ (parseFloat(b.y) + parseFloat(b.h)/2) * imgH / 100 }px)`;
}
return `<rect class="map-seat-obj" x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="0.5" style="fill:${fill}; stroke:${stroke}; stroke-width:${strokeWidth};" />`;
}).join('')}
</g>
</svg>
`;
} else {
overlayLayer.innerHTML = '';
overlayLayer.innerHTML = `<svg viewBox="0 0 100 100" preserveAspectRatio="none" style="width:100%; height:100%;"><g class="seat-group">
${boxes.map(b => {
const isSelected = b.x === x && b.y === y;
return `<rect class="map-seat-obj" x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="0.5" style="fill:${isSelected ? 'rgba(255, 61, 0, 0.4)' : 'rgba(30, 81, 73, 0.02)'}; stroke:${isSelected ? '#FF3D00' : 'rgba(30, 81, 73, 0.15)'};" />`;
}).join('')}</g></svg>`;
}
}
};
@@ -390,220 +280,93 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
window.addEventListener('resize', updateMarkerPos);
};
} else {
photo.style.display = 'none';
if (marker) marker.style.display = 'none';
photo.classList.add('hidden');
if (marker) marker.classList.add('hidden');
if (overlayLayer) overlayLayer.innerHTML = '';
if (noPhoto) {
noPhoto.style.display = 'flex';
const msg = noPhoto.querySelector('span');
if (msg) msg.textContent = !hasCoords ? '등록된 위치 좌표 정보가 없습니다.' : '등록된 배치도가 없습니다.';
}
if (noPhoto) { noPhoto.classList.remove('hidden'); noPhoto.style.display = 'flex'; }
}
};
const updateTableOnly = () => {
let filtered = selectedLocation
? fullList.filter(a => (a[ASSET_SCHEMA.LOCATION.key] || '미지정') === selectedLocation)
: fullList;
const currentDetailLocs = Array.from(new Set(filtered.map(a => a[ASSET_SCHEMA.LOC_DETAIL.key] || '미지정'))).sort();
let filtered = selectedLocation ? fullList.filter(a => (a[ASSET_SCHEMA.LOCATION.key] || '미지정') === selectedLocation) : fullList;
if (selectedDetailLocation) filtered = filtered.filter(a => (a[ASSET_SCHEMA.LOC_DETAIL.key] || '미지정') === selectedDetailLocation);
const finalDisplayList = (!selectedLocation && !selectedDetailLocation) ? filtered.slice(0, 10) : filtered;
const titleEl = document.getElementById('list-section-title');
if (titleEl) titleEl.textContent = selectedLocation ? `${selectedLocation} 자산 현황 (${finalDisplayList.length}대)` : '위치별 자산등록현황 (최근 등록)';
const selectEl = document.getElementById('select-detail-loc') as HTMLSelectElement;
if (selectEl && !selectedDetailLocation) {
selectEl.innerHTML = `<option value="">전체보기</option>` + currentDetailLocs.map(dl => `<option value="${dl}">${dl}</option>`).join('');
}
const displayList = (!selectedLocation && !selectedDetailLocation) ? filtered.slice(0, 10) : filtered;
const tbody = document.getElementById('system-status-tbody');
if (tbody) {
tbody.innerHTML = finalDisplayList.length === 0
? `<tr><td colspan="4" style="padding: 3rem; text-align: center; color: var(--text-muted);">조회된 자산이 없습니다.</td></tr>`
: finalDisplayList.map(asset => {
const purpose = asset[ASSET_SCHEMA.ASSET_PURPOSE.key] || '';
const serviceTypeKey = (ASSET_SCHEMA as any).SERVICE_TYPE?.key || 'service_type';
const serviceType = asset[serviceTypeKey] || '외부';
const type = asset[ASSET_SCHEMA.ASSET_TYPE.key] || '';
tbody.innerHTML = displayList.length === 0
? `<tr><td colspan="5" class="empty-cell">조회된 자산이 없습니다.</td></tr>`
: displayList.map(asset => {
const serviceType = asset.service_type || '외부';
const loc = asset[ASSET_SCHEMA.LOCATION.key] || '';
const labelColor = serviceType === '내부' ? '#94A3B8' : '#35635C';
const managerMain = asset[ASSET_SCHEMA.MANAGER_MAIN.key] || '-';
const managerSub = asset[ASSET_SCHEMA.MANAGER_SUB.key] || '-';
// [경고 로직] 외부 운영인데 서버PC이거나 IDC가 아닌 경우
const isLocWarning = serviceType === '외부SW' && loc !== 'IDC';
const isTypeWarning = serviceType === '외부SW' && type.toLowerCase().replace(/\s/g, '').includes('서버pc');
const isWarning = isLocWarning || isTypeWarning;
const warningStyle = isWarning ? 'background-color: #FFF1F2; border-left: 3px solid #E11D48;' : '';
let warningReason = '';
if (isLocWarning && isTypeWarning) warningReason = '위치/형식 부적절';
else if (isLocWarning) warningReason = '위치 부적절';
else if (isTypeWarning) warningReason = '형식 부적절';
const isWarning = serviceType === '외부' && loc !== 'IDC' && loc !== '미지정';
return `
<tr style="border-bottom: 1px solid var(--border-color); cursor: pointer; ${warningStyle}" class="mini-row" data-id="${asset.id}">
<td style="padding: 10px 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align:center;">
<div style="display:flex; flex-direction:column; align-items:center; gap:2px;">
<span style="color: ${isWarning ? '#E11D48' : labelColor}; font-weight: 800; font-size: 12px;">${serviceType}</span>
${isWarning ? `<span style="color: #E11D48; font-size: 9px; font-weight: 700; white-space: nowrap;">${warningReason}</span>` : ''}
</div>
</td>
<td style="padding: 10px 0; font-weight: 600; color: ${isWarning ? '#991B1B' : 'var(--text-main)'}; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${purpose}">${purpose || '-'}</td>
<td style="padding: 10px 0; text-align: center; color: var(--text-main); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${managerMain}</td>
<td style="padding: 10px 0; text-align: center; color: var(--text-main); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${managerSub}</td>
<td style="padding: 10px 0; text-align: center; color: var(--text-main); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${asset[ASSET_SCHEMA.LOC_DETAIL.key] || '-'}</td>
<tr class="mini-row ${isWarning ? 'warning' : ''}" data-id="${asset.id}">
<td><div class="cell-service-type"><span class="fw-800">${serviceType}</span></div></td>
<td title="${asset.asset_purpose || ''}">${asset.asset_purpose || '-'}</td>
<td>${asset.manager_primary || '-'}</td>
<td>${asset.manager_secondary || '-'}</td>
<td>${asset.location_detail || '-'}</td>
</tr>`;
}).join('');
tbody.querySelectorAll('.mini-row').forEach(row => {
row.addEventListener('click', () => {
tbody.querySelectorAll('.mini-row').forEach(r => {
const rIsWarning = (r as HTMLElement).style.borderLeftColor === 'rgb(225, 29, 72)'; // E11D48
(r as HTMLElement).style.backgroundColor = rIsWarning ? '#FFF1F2' : 'transparent';
});
(row as HTMLElement).style.backgroundColor = '#EBF2F1'; // 선택 하이라이트
const id = (row as HTMLElement).getAttribute('data-id');
const asset = fullList.find(a => a.id === id);
tbody.querySelectorAll('.mini-row').forEach(r => r.classList.remove('active'));
row.classList.add('active');
const asset = fullList.find(a => a.id === (row as HTMLElement).getAttribute('data-id'));
if (asset) updateDetailPanel(asset);
});
row.addEventListener('mouseenter', () => {
if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') {
(row as HTMLElement).style.backgroundColor = '#F8FAFA';
}
});
row.addEventListener('mouseleave', () => {
const isWarning = (row as HTMLElement).style.borderLeftColor === 'rgb(225, 29, 72)';
if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') {
(row as HTMLElement).style.backgroundColor = isWarning ? '#FFF1F2' : 'transparent';
}
});
});
}
};
(window as any).dispatchLocFilter = (loc: string) => {
if (isPcView) return;
selectedLocation = loc;
selectedDetailLocation = null;
renderSystemStatus();
};
setTimeout(() => {
const selectLoc = document.getElementById('select-loc') as HTMLSelectElement;
const selectDetailLoc = document.getElementById('select-detail-loc') as HTMLSelectElement;
selectLoc?.addEventListener('change', (e) => {
selectedLocation = (e.target as HTMLSelectElement).value || null;
selectedDetailLocation = null;
updateTableOnly();
});
selectDetailLoc?.addEventListener('change', (e) => {
selectedDetailLocation = (e.target as HTMLSelectElement).value || null;
updateTableOnly();
});
document.getElementById('select-loc')?.addEventListener('change', (e) => { selectedLocation = (e.target as HTMLSelectElement).value || null; updateTableOnly(); });
document.getElementById('select-detail-loc')?.addEventListener('change', (e) => { selectedDetailLocation = (e.target as HTMLSelectElement).value || null; updateTableOnly(); });
updateTableOnly();
}, 50);
};
// [자산 목록] 테이블 렌더러
const tableWrapper = document.createElement('div');
tableWrapper.className = 'table-container';
const table = document.createElement('table');
const thead = document.createElement('thead');
const tbody = document.createElement('tbody');
tbody.id = 'dynamic-tbody';
table.appendChild(thead);
table.appendChild(tbody);
table.appendChild(thead); table.appendChild(tbody);
tableWrapper.appendChild(table);
const updateTable = () => {
if ((state as any).currentViewMode !== 'asset') return;
let filtered = applyCommonFilters(fullList, currentFilters, config.searchKeys as any[]);
if (sortState.key) filtered = dynamicSort(filtered, sortState.key, sortState.direction);
thead.innerHTML = `<tr>${config.columns.map(col => `
<th ${col.sortKey ? `data-sort="${col.sortKey}"` : ''}
style="${col.width ? `width:${col.width};` : ''}${col.align ? `text-align:${col.align};` : ''}"
class="${col.className || ''}">${col.header}</th>`).join('')}</tr>`;
thead.innerHTML = `<tr>${config.columns.map(col => `<th ${col.sortKey ? `data-sort="${col.sortKey}"` : ''} style="${col.width ? `width:${col.width};` : ''}" class="${col.align ? `text-${col.align}` : ''}">${col.header}</th>`).join('')}</tr>`;
tbody.innerHTML = filtered.length === 0 ? `<tr><td colspan="${config.columns.length}" class="text-center empty-cell">${UI_TEXT.MESSAGES.NO_DATA}</td></tr>`
: filtered.map(asset => `<tr class="asset-row clickable" data-id="${asset.id}">${config.columns.map(col => `<td class="${col.align ? `text-${col.align}` : ''}">${col.render(asset)}</td>`).join('')}</tr>`).join('');
tbody.innerHTML = filtered.length === 0
? `<tr><td colspan="${config.columns.length}" class="text-center" style="padding: 3rem; color: var(--text-muted);">${config.emptyMessage || UI_TEXT.MESSAGES.NO_DATA}</td></tr>`
: filtered.map(asset => `
<tr style="cursor:pointer;" class="asset-row" data-id="${asset.id}">
${config.columns.map(col => `<td style="${col.align ? `text-align:${col.align};` : ''}" class="${col.className || ''}">${col.render(asset)}</td>`).join('')}
</tr>`).join('');
tbody.querySelectorAll('.asset-row').forEach((tr, idx) => {
tr.addEventListener('click', () => config.onRowClick && config.onRowClick(filtered[idx]));
});
setupTableSorting(table, sortState, (key, dir) => {
sortState = { key, direction: dir };
if (config.persistentSortState) {
config.persistentSortState.key = key;
config.persistentSortState.direction = dir;
}
updateTable();
});
// createIcons call removed as icons are no longer needed in dashboard
tbody.querySelectorAll('.asset-row').forEach((tr, idx) => { tr.addEventListener('click', () => config.onRowClick && config.onRowClick(filtered[idx])); });
setupTableSorting(table, sortState, (key, dir) => { sortState = { key, direction: dir }; updateTable(); });
};
// --- 뷰 전환 로직 ---
const switchView = () => {
contentWrapper.innerHTML = '';
if ((state as any).currentViewMode === 'asset') {
filterBar.style.display = 'flex';
contentWrapper.style.overflowY = 'auto';
contentWrapper.appendChild(tableWrapper);
updateTable();
filterBar.style.display = 'flex'; contentWrapper.style.overflowY = 'auto';
contentWrapper.appendChild(tableWrapper); updateTable();
} else {
filterBar.style.display = 'none';
contentWrapper.style.overflowY = 'hidden';
filterBar.style.display = 'none'; contentWrapper.style.overflowY = 'hidden';
renderSystemStatus();
}
};
// 토글 버튼 이벤트
toggleWrapper.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest('.toggle-btn') as HTMLButtonElement;
if (!btn) return;
toggleWrapper.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
(state as any).currentViewMode = btn.getAttribute('data-mode') as 'asset' | 'system';
switchView();
});
// 필터 바 초기화
renderFilterBar(filterBar, {
...config.filterOptions,
onFilterChange: (filters) => {
currentFilters = { ...currentFilters, ...filters };
updateTable();
if (btn) {
toggleWrapper.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active'); (state as any).currentViewMode = btn.getAttribute('data-mode');
switchView();
}
});
// 셀렉트 박스 채우기
const populateSelect = (selector: string, dataKey: string) => {
const select = container.querySelector(selector) as HTMLSelectElement;
if (select) {
const getVal = (a: any) => dataKey === ASSET_SCHEMA.CURRENT_DEPT.key ? (a[dataKey] || a['현사용부서'] || a['현사용조직']) : a[dataKey];
const uniqueValues = Array.from(new Set(fullList.map(getVal))).filter(Boolean).sort();
uniqueValues.forEach(val => {
const opt = document.createElement('option');
opt.value = String(val);
opt.textContent = String(val);
select.appendChild(opt);
});
}
};
if (config.filterOptions.showLoc) populateSelect('#filter-loc', ASSET_SCHEMA.LOCATION.key);
if (config.filterOptions.showDept) populateSelect('#filter-dept', ASSET_SCHEMA.CURRENT_DEPT.key);
if (config.filterOptions.showCorp) populateSelect('#filter-corp', ASSET_SCHEMA.PURCHASE_CORP.key);
if (config.filterOptions.showType) populateSelect('#filter-type', ASSET_SCHEMA.ASSET_TYPE.key);
// 초기 실행
renderFilterBar(filterBar, { ...config.filterOptions, onFilterChange: (filters) => { currentFilters = { ...currentFilters, ...filters }; updateTable(); } });
switchView();
}