Compare commits
5 Commits
1d15cf9b9b
...
bc60f932c3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc60f932c3 | ||
|
|
ca57a4a1e4 | ||
|
|
e50b24c25b | ||
|
|
24852d4401 | ||
|
|
d66614123e |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -11,3 +11,8 @@ node_modules/
|
|||||||
incoming-files/*.Zone.Identifier
|
incoming-files/*.Zone.Identifier
|
||||||
*:Zone.Identifier
|
*:Zone.Identifier
|
||||||
incoming-files/~$*
|
incoming-files/~$*
|
||||||
|
|
||||||
|
# Local-only inspection / conversion artifacts
|
||||||
|
incoming-files/6f.html
|
||||||
|
incoming-files/7f.html
|
||||||
|
incoming-files/center.html
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -8,8 +8,8 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap" rel="stylesheet" />
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<link rel="stylesheet" href="/legacy/static/common.css?v=20260326-02" />
|
<link rel="stylesheet" href="/legacy/static/common.css?v=20260327-03" />
|
||||||
<link rel="stylesheet" href="/legacy/static/organization.css?v=20260326-02" />
|
<link rel="stylesheet" href="/legacy/static/organization.css?v=20260327-03" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<input type="file" id="upload-excel" class="hidden" accept=".xlsx, .csv" />
|
<input type="file" id="upload-excel" class="hidden" accept=".xlsx, .csv" />
|
||||||
@@ -60,6 +60,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/legacy/static/organization.js?v=20260326-02"></script>
|
<script src="/legacy/static/organization.js?v=20260327-03"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ RUN pip install --no-cache-dir -r /app/requirements.txt
|
|||||||
COPY backend/app /app/backend/app
|
COPY backend/app /app/backend/app
|
||||||
COPY DashBoard-organization.html /app/legacy/DashBoard-organization.html
|
COPY DashBoard-organization.html /app/legacy/DashBoard-organization.html
|
||||||
COPY DashBoard-organization-backup.html /app/legacy/DashBoard-organization-backup.html
|
COPY DashBoard-organization-backup.html /app/legacy/DashBoard-organization-backup.html
|
||||||
COPY organization1.xlsx /app/legacy/organization1.xlsx
|
|
||||||
COPY legacy/static /app/legacy/static
|
COPY legacy/static /app/legacy/static
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import hmac
|
|||||||
from io import BytesIO, StringIO
|
from io import BytesIO, StringIO
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
@@ -42,9 +43,24 @@ app.add_middleware(
|
|||||||
LEGACY_STATIC_DIR = LEGACY_DIR / "static"
|
LEGACY_STATIC_DIR = LEGACY_DIR / "static"
|
||||||
INCOMING_FILES_DIR = BASE_DIR / "incoming-files"
|
INCOMING_FILES_DIR = BASE_DIR / "incoming-files"
|
||||||
FIXED_OFFICE_SOURCE_KEY = "technical-development-center"
|
FIXED_OFFICE_SOURCE_KEY = "technical-development-center"
|
||||||
FIXED_OFFICE_NAME = "기술개발센터"
|
FIXED_OFFICE_CONFIGS = {
|
||||||
FIXED_OFFICE_TEMPLATE_PATH = Path(__file__).with_name("center_chair_viewer_template.html")
|
"technical-development-center": {
|
||||||
_fixed_office_cache: dict[str, object] | None = None
|
"name": "기술개발센터",
|
||||||
|
"html_path": INCOMING_FILES_DIR / "seat" / "center_chair_people_map.html",
|
||||||
|
"payload_path": INCOMING_FILES_DIR / "seat" / "center_chair_people_payload.js",
|
||||||
|
},
|
||||||
|
"hanmac-building-6f": {
|
||||||
|
"name": "한맥빌딩 6층",
|
||||||
|
"html_path": INCOMING_FILES_DIR / "seat" / "center_chair_people_map_6f.html",
|
||||||
|
"payload_path": INCOMING_FILES_DIR / "seat" / "center_chair_people_payload_6f.js",
|
||||||
|
},
|
||||||
|
"hanmac-building-7f": {
|
||||||
|
"name": "한맥빌딩 7층",
|
||||||
|
"html_path": INCOMING_FILES_DIR / "seat" / "center_chair_people_map_7f.html",
|
||||||
|
"payload_path": INCOMING_FILES_DIR / "seat" / "center_chair_people_payload_7f.js",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_fixed_office_cache: dict[str, dict[str, object]] = {}
|
||||||
AUTH_DEFAULT_PASSWORD = "1111"
|
AUTH_DEFAULT_PASSWORD = "1111"
|
||||||
AUTH_PASSWORD_ITERATIONS = 390000
|
AUTH_PASSWORD_ITERATIONS = 390000
|
||||||
AUTH_SESSION_HOURS = 12
|
AUTH_SESSION_HOURS = 12
|
||||||
@@ -462,8 +478,11 @@ def fetch_active_seat_map() -> dict[str, object] | None:
|
|||||||
return cur.fetchone()
|
return cur.fetchone()
|
||||||
|
|
||||||
|
|
||||||
def ensure_fixed_office_seat_map(activate: bool = True) -> dict[str, object]:
|
def ensure_fixed_office_seat_map(office_key: str = FIXED_OFFICE_SOURCE_KEY, activate: bool = True) -> dict[str, object]:
|
||||||
template = parse_fixed_office_template()
|
config = FIXED_OFFICE_CONFIGS.get(office_key)
|
||||||
|
if not config:
|
||||||
|
raise HTTPException(status_code=404, detail="Fixed office configuration not found.")
|
||||||
|
template = parse_fixed_office_template(office_key)
|
||||||
slots = template["slots"]
|
slots = template["slots"]
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
@@ -475,7 +494,7 @@ def ensure_fixed_office_seat_map(activate: bool = True) -> dict[str, object]:
|
|||||||
AND source_url = %s
|
AND source_url = %s
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
(FIXED_OFFICE_SOURCE_KEY,),
|
(office_key,),
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
if activate:
|
if activate:
|
||||||
@@ -491,7 +510,7 @@ def ensure_fixed_office_seat_map(activate: bool = True) -> dict[str, object]:
|
|||||||
VALUES (%s, '', 'fixed_html', %s, '', NULL, NULL, NULL, NULL, NULL, NULL, 1, 1, 0, %s)
|
VALUES (%s, '', 'fixed_html', %s, '', NULL, NULL, NULL, NULL, NULL, NULL, 1, 1, 0, %s)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
(FIXED_OFFICE_NAME, FIXED_OFFICE_SOURCE_KEY, activate),
|
(str(config["name"]), office_key, activate),
|
||||||
)
|
)
|
||||||
seat_map_id = int(cur.fetchone()["id"])
|
seat_map_id = int(cur.fetchone()["id"])
|
||||||
else:
|
else:
|
||||||
@@ -511,7 +530,7 @@ def ensure_fixed_office_seat_map(activate: bool = True) -> dict[str, object]:
|
|||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
""",
|
""",
|
||||||
(FIXED_OFFICE_NAME, FIXED_OFFICE_SOURCE_KEY, activate, seat_map_id),
|
(str(config["name"]), office_key, activate, seat_map_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
cur.execute("SELECT id, slot_key FROM seat_slots WHERE seat_map_id = %s", (seat_map_id,))
|
cur.execute("SELECT id, slot_key FROM seat_slots WHERE seat_map_id = %s", (seat_map_id,))
|
||||||
@@ -591,18 +610,36 @@ def decode_segment_values(raw_base64: str) -> list[int]:
|
|||||||
return [item[0] for item in struct.iter_unpack("<i", decoded)]
|
return [item[0] for item in struct.iter_unpack("<i", decoded)]
|
||||||
|
|
||||||
|
|
||||||
def parse_fixed_office_template() -> dict[str, object]:
|
def parse_fixed_office_template(office_key: str = FIXED_OFFICE_SOURCE_KEY) -> dict[str, object]:
|
||||||
global _fixed_office_cache
|
cached = _fixed_office_cache.get(office_key)
|
||||||
if _fixed_office_cache is not None:
|
if cached is not None:
|
||||||
return _fixed_office_cache
|
return cached
|
||||||
if not FIXED_OFFICE_TEMPLATE_PATH.exists():
|
|
||||||
raise HTTPException(status_code=500, detail="Fixed office viewer template not found.")
|
|
||||||
|
|
||||||
html = FIXED_OFFICE_TEMPLATE_PATH.read_text(encoding="utf-8")
|
config = FIXED_OFFICE_CONFIGS.get(office_key)
|
||||||
match = re.search(r"const DATA = (\{.*?\});\n\s*function decodeSegments", html, flags=re.S)
|
if not config:
|
||||||
if not match:
|
raise HTTPException(status_code=404, detail="Fixed office configuration not found.")
|
||||||
raise HTTPException(status_code=500, detail="Fixed office viewer data not found.")
|
|
||||||
data = json.loads(match.group(1))
|
html_path = Path(str(config["html_path"]))
|
||||||
|
payload_path = Path(str(config["payload_path"]))
|
||||||
|
if not html_path.exists():
|
||||||
|
raise HTTPException(status_code=500, detail=f"Fixed office viewer template not found: {office_key}")
|
||||||
|
if not payload_path.exists():
|
||||||
|
raise HTTPException(status_code=500, detail=f"Fixed office payload not found: {office_key}")
|
||||||
|
|
||||||
|
html = html_path.read_text(encoding="utf-8")
|
||||||
|
payload_js = payload_path.read_text(encoding="utf-8")
|
||||||
|
payload_match = re.search(r"window\.CHAIR_MAP_DATA\s*=\s*(\{.*\});?\s*$", payload_js, flags=re.S)
|
||||||
|
if not payload_match:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Fixed office viewer data not found: {office_key}")
|
||||||
|
|
||||||
|
html = re.sub(
|
||||||
|
r'<script\s+src="\./[^"]+payload[^"]*\.js"></script>',
|
||||||
|
f"<script>{payload_js}</script>",
|
||||||
|
html,
|
||||||
|
count=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = json.loads(payload_match.group(1))
|
||||||
chair_values = decode_segment_values(str(data["chairSegsB64"]))
|
chair_values = decode_segment_values(str(data["chairSegsB64"]))
|
||||||
slots: list[dict[str, object]] = []
|
slots: list[dict[str, object]] = []
|
||||||
for index, chair in enumerate(data["chairs"]):
|
for index, chair in enumerate(data["chairs"]):
|
||||||
@@ -633,12 +670,13 @@ def parse_fixed_office_template() -> dict[str, object]:
|
|||||||
"layer_name": str(name),
|
"layer_name": str(name),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
_fixed_office_cache = {
|
parsed = {
|
||||||
"html": html,
|
"html": html,
|
||||||
"data": data,
|
"data": data,
|
||||||
"slots": slots,
|
"slots": slots,
|
||||||
}
|
}
|
||||||
return _fixed_office_cache
|
_fixed_office_cache[office_key] = parsed
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
def is_chair_layer(layer_name: str) -> bool:
|
def is_chair_layer(layer_name: str) -> bool:
|
||||||
@@ -1170,12 +1208,14 @@ def fetch_seat_layout(seat_map_id: int) -> dict[str, object]:
|
|||||||
)
|
)
|
||||||
placements = cur.fetchall()
|
placements = cur.fetchall()
|
||||||
viewer_data: dict[str, object] | None = None
|
viewer_data: dict[str, object] | None = None
|
||||||
if seat_map["source_type"] == "fixed_html" and seat_map.get("source_url") == FIXED_OFFICE_SOURCE_KEY:
|
office_key = str(seat_map.get("source_url") or FIXED_OFFICE_SOURCE_KEY)
|
||||||
template = parse_fixed_office_template()
|
fixed_office = FIXED_OFFICE_CONFIGS.get(office_key)
|
||||||
|
if seat_map["source_type"] == "fixed_html" and fixed_office:
|
||||||
|
template = parse_fixed_office_template(office_key)
|
||||||
viewer_data = {
|
viewer_data = {
|
||||||
"meta": {
|
"meta": {
|
||||||
"chair_count": len(template["slots"]),
|
"chair_count": len(template["slots"]),
|
||||||
"office": FIXED_OFFICE_NAME,
|
"office": str(fixed_office["name"]),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
elif seat_map["source_type"] == "dxf" and seat_map.get("source_url"):
|
elif seat_map["source_type"] == "dxf" and seat_map.get("source_url"):
|
||||||
@@ -1230,7 +1270,8 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
|
|||||||
placed_literal = json.dumps(sorted(set(placed_keys)), ensure_ascii=False, separators=(",", ":"))
|
placed_literal = json.dumps(sorted(set(placed_keys)), ensure_ascii=False, separators=(",", ":"))
|
||||||
assignments_literal = json.dumps(assignment_items, ensure_ascii=False, separators=(",", ":"))
|
assignments_literal = json.dumps(assignment_items, ensure_ascii=False, separators=(",", ":"))
|
||||||
if seat_map.get("source_type") == "fixed_html":
|
if seat_map.get("source_type") == "fixed_html":
|
||||||
html = parse_fixed_office_template()["html"]
|
office_key = str(seat_map.get("source_url") or FIXED_OFFICE_SOURCE_KEY)
|
||||||
|
html = parse_fixed_office_template(office_key)["html"]
|
||||||
else:
|
else:
|
||||||
viewer_data = layout.get("viewer_data")
|
viewer_data = layout.get("viewer_data")
|
||||||
if not isinstance(viewer_data, dict):
|
if not isinstance(viewer_data, dict):
|
||||||
@@ -1267,11 +1308,59 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
|
|||||||
ctx.lineWidth = (selected ? 2.6 : active ? 2.0 : 1.6) / camera.scale;""",
|
ctx.lineWidth = (selected ? 2.6 : active ? 2.0 : 1.6) / camera.scale;""",
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
|
html = html.replace(
|
||||||
|
""" sorted.forEach((chair, index) => {
|
||||||
|
chair.key = String(index + 1);
|
||||||
|
chair.seatNo = index + 1;
|
||||||
|
});""",
|
||||||
|
""" sorted.forEach((chair, index) => {
|
||||||
|
chair.seatNo = index + 1;
|
||||||
|
});""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
html = html.replace(
|
html = html.replace(
|
||||||
"function persistPlaced() {\n localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));\n }",
|
"function persistPlaced() {\n localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));\n }",
|
||||||
"function persistPlaced() {\n return;\n }",
|
"function persistPlaced() {\n return;\n }",
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
|
html = html.replace(
|
||||||
|
""" 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("pointerup", () => {
|
||||||
|
dragging = false;
|
||||||
|
dragStart = null;
|
||||||
|
canvas.classList.remove("dragging");
|
||||||
|
requestDraw();
|
||||||
|
});""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
html = html.replace(
|
html = html.replace(
|
||||||
""" window.addEventListener("pointerup", (event) => {
|
""" window.addEventListener("pointerup", (event) => {
|
||||||
if (dragging && dragStart) {
|
if (dragging && dragStart) {
|
||||||
@@ -2806,8 +2895,35 @@ def fetch_project_metrics(limit: int = 500, start_date: str | None = None, end_d
|
|||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
WITH work_by_project AS (
|
WITH project_base AS (
|
||||||
SELECT
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(project_code, '') <> '' THEN project_code
|
||||||
|
ELSE regexp_replace(
|
||||||
|
lower(COALESCE(NULLIF(project_name, ''), NULLIF(display_name, ''), NULLIF(intranet_name, ''), '')),
|
||||||
|
'[^0-9a-z가-힣]+',
|
||||||
|
'',
|
||||||
|
'g'
|
||||||
|
)
|
||||||
|
END AS project_key,
|
||||||
|
project_code,
|
||||||
|
project_name,
|
||||||
|
display_name,
|
||||||
|
business_area,
|
||||||
|
business_subarea
|
||||||
|
FROM integration_projects
|
||||||
|
),
|
||||||
|
work_by_project AS (
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(project_code, '') <> '' THEN project_code
|
||||||
|
ELSE regexp_replace(
|
||||||
|
lower(COALESCE(NULLIF(project_name, ''), '')),
|
||||||
|
'[^0-9a-z가-힣]+',
|
||||||
|
'',
|
||||||
|
'g'
|
||||||
|
)
|
||||||
|
END AS project_key,
|
||||||
COALESCE(project_code, '') AS project_code,
|
COALESCE(project_code, '') AS project_code,
|
||||||
COALESCE(NULLIF(project_name, ''), COALESCE(project_code, '')) AS project_name,
|
COALESCE(NULLIF(project_name, ''), COALESCE(project_code, '')) AS project_name,
|
||||||
SUM(hours) AS total_hours,
|
SUM(hours) AS total_hours,
|
||||||
@@ -2817,10 +2933,19 @@ def fetch_project_metrics(limit: int = 500, start_date: str | None = None, end_d
|
|||||||
JOIN integration_work_logs ON integration_work_logs.id = integration_work_log_segments.work_log_id
|
JOIN integration_work_logs ON integration_work_logs.id = integration_work_log_segments.work_log_id
|
||||||
WHERE (%s::date IS NULL OR integration_work_logs.work_date >= %s::date)
|
WHERE (%s::date IS NULL OR integration_work_logs.work_date >= %s::date)
|
||||||
AND (%s::date IS NULL OR integration_work_logs.work_date <= %s::date)
|
AND (%s::date IS NULL OR integration_work_logs.work_date <= %s::date)
|
||||||
GROUP BY 1, 2
|
GROUP BY 1, 2, 3
|
||||||
),
|
),
|
||||||
voucher_by_project AS (
|
voucher_by_project AS (
|
||||||
SELECT
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(project_code, '') <> '' THEN project_code
|
||||||
|
ELSE regexp_replace(
|
||||||
|
lower(COALESCE(NULLIF(project_name, ''), '')),
|
||||||
|
'[^0-9a-z가-힣]+',
|
||||||
|
'',
|
||||||
|
'g'
|
||||||
|
)
|
||||||
|
END AS project_key,
|
||||||
COALESCE(project_code, '') AS project_code,
|
COALESCE(project_code, '') AS project_code,
|
||||||
COALESCE(NULLIF(project_name, ''), COALESCE(project_code, '')) AS project_name,
|
COALESCE(NULLIF(project_name, ''), COALESCE(project_code, '')) AS project_name,
|
||||||
SUM(income_amount) AS total_income,
|
SUM(income_amount) AS total_income,
|
||||||
@@ -2829,7 +2954,8 @@ def fetch_project_metrics(limit: int = 500, start_date: str | None = None, end_d
|
|||||||
FROM integration_vouchers
|
FROM integration_vouchers
|
||||||
WHERE (%s::date IS NULL OR COALESCE(issue_date, claim_date) >= %s::date)
|
WHERE (%s::date IS NULL OR COALESCE(issue_date, claim_date) >= %s::date)
|
||||||
AND (%s::date IS NULL OR COALESCE(issue_date, claim_date) <= %s::date)
|
AND (%s::date IS NULL OR COALESCE(issue_date, claim_date) <= %s::date)
|
||||||
GROUP BY 1, 2
|
AND COALESCE(voucher_type, '') <> '제외'
|
||||||
|
GROUP BY 1, 2, 3
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(p.project_code, w.project_code, v.project_code) AS project_code,
|
COALESCE(p.project_code, w.project_code, v.project_code) AS project_code,
|
||||||
@@ -2844,9 +2970,9 @@ def fetch_project_metrics(limit: int = 500, start_date: str | None = None, end_d
|
|||||||
COALESCE(w.overtime_hours, 0) AS overtime_hours,
|
COALESCE(w.overtime_hours, 0) AS overtime_hours,
|
||||||
COALESCE(v.voucher_count, 0) AS voucher_count,
|
COALESCE(v.voucher_count, 0) AS voucher_count,
|
||||||
COALESCE(w.work_log_count, 0) AS work_log_count
|
COALESCE(w.work_log_count, 0) AS work_log_count
|
||||||
FROM integration_projects p
|
FROM project_base p
|
||||||
FULL OUTER JOIN work_by_project w ON w.project_code = p.project_code
|
FULL OUTER JOIN work_by_project w ON w.project_key = p.project_key
|
||||||
FULL OUTER JOIN voucher_by_project v ON v.project_code = COALESCE(p.project_code, w.project_code)
|
FULL OUTER JOIN voucher_by_project v ON v.project_key = COALESCE(p.project_key, w.project_key)
|
||||||
ORDER BY project_code ASC
|
ORDER BY project_code ASC
|
||||||
LIMIT %s
|
LIMIT %s
|
||||||
""",
|
""",
|
||||||
@@ -2986,6 +3112,19 @@ def payment_analysis_parse_number(value: object) -> float:
|
|||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def round_half_up_to_int(value: float | Decimal) -> int:
|
||||||
|
return int(Decimal(str(value)).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
|
||||||
|
|
||||||
|
|
||||||
|
def round_half_up_to_2(value: float | Decimal) -> float:
|
||||||
|
return float(Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_labor_cost(hours: float | Decimal, rate: int | float | Decimal, multiplier: float | Decimal) -> int:
|
||||||
|
amount = Decimal(str(hours)) * Decimal(str(rate)) * Decimal(str(multiplier))
|
||||||
|
return round_half_up_to_int(amount)
|
||||||
|
|
||||||
|
|
||||||
def build_payment_work_rows_from_raw_mh(
|
def build_payment_work_rows_from_raw_mh(
|
||||||
raw_rows: list[list[object]],
|
raw_rows: list[list[object]],
|
||||||
category_by_project_key: dict[str, dict[str, str]],
|
category_by_project_key: dict[str, dict[str, str]],
|
||||||
@@ -3057,6 +3196,7 @@ def build_payment_work_rows_from_raw_mh(
|
|||||||
position = clean_text(payment_analysis_get_value(row, ["직책", "직급"]))
|
position = clean_text(payment_analysis_get_value(row, ["직책", "직급"]))
|
||||||
user_state = clean_text(payment_analysis_get_value(row, ["user_state", "User State", "user state", "userstate", "User_State"]))
|
user_state = clean_text(payment_analysis_get_value(row, ["user_state", "User State", "user state", "userstate", "User_State"]))
|
||||||
weekend_flag = clean_text(payment_analysis_get_value(row, ["주말/지각"]))
|
weekend_flag = clean_text(payment_analysis_get_value(row, ["주말/지각"]))
|
||||||
|
is_weekend = "주말" in user_state or "주말" in weekend_flag
|
||||||
member_name = clean_text(payment_analysis_get_value(row, ["이름"]))
|
member_name = clean_text(payment_analysis_get_value(row, ["이름"]))
|
||||||
work_date = clean_text(payment_analysis_get_value(row, ["근무일자", "날짜", "일자"]))
|
work_date = clean_text(payment_analysis_get_value(row, ["근무일자", "날짜", "일자"]))
|
||||||
imported_labor = payment_analysis_parse_number(payment_analysis_get_value(row, ["산정금액", "인건비"]))
|
imported_labor = payment_analysis_parse_number(payment_analysis_get_value(row, ["산정금액", "인건비"]))
|
||||||
@@ -3073,7 +3213,7 @@ def build_payment_work_rows_from_raw_mh(
|
|||||||
weighted = []
|
weighted = []
|
||||||
total_weight = 0.0
|
total_weight = 0.0
|
||||||
for idx, segment in enumerate(segments):
|
for idx, segment in enumerate(segments):
|
||||||
multiplier = 1.5 if ("주말" in user_state or segment["overtime"]) else 1.0
|
multiplier = 1.5 if (is_weekend or segment["overtime"]) else 1.0
|
||||||
weight = segment["hours"] * multiplier
|
weight = segment["hours"] * multiplier
|
||||||
weighted.append((idx, weight))
|
weighted.append((idx, weight))
|
||||||
total_weight += weight
|
total_weight += weight
|
||||||
@@ -3108,8 +3248,8 @@ def build_payment_work_rows_from_raw_mh(
|
|||||||
if allocations:
|
if allocations:
|
||||||
labor = int(allocations[idx] or 0)
|
labor = int(allocations[idx] or 0)
|
||||||
else:
|
else:
|
||||||
multiplier = 1.5 if ("주말" in user_state or segment["overtime"]) else 1.0
|
multiplier = 1.5 if (is_weekend or segment["overtime"]) else 1.0
|
||||||
labor = round(hours * rate * multiplier)
|
labor = calculate_labor_cost(hours, rate, multiplier)
|
||||||
parsed_row = {
|
parsed_row = {
|
||||||
"__values": [
|
"__values": [
|
||||||
work_date,
|
work_date,
|
||||||
@@ -3245,8 +3385,8 @@ def fetch_payment_source_rows() -> dict[str, object]:
|
|||||||
position = clean_text(row["title"])
|
position = clean_text(row["title"])
|
||||||
raw_hours = float(row["hours"] or 0)
|
raw_hours = float(row["hours"] or 0)
|
||||||
adjusted_overtime_hours = float(row["overtime_hours_adjusted"] or 0)
|
adjusted_overtime_hours = float(row["overtime_hours_adjusted"] or 0)
|
||||||
hours = adjusted_overtime_hours if bool(row["is_overtime"]) and adjusted_overtime_hours > 0 else raw_hours
|
hours = adjusted_overtime_hours if bool(row["is_overtime"]) else raw_hours
|
||||||
hours = round(hours, 2)
|
hours = round_half_up_to_2(hours)
|
||||||
rate = 28900
|
rate = 28900
|
||||||
if "이사" in position or "수석" in position:
|
if "이사" in position or "수석" in position:
|
||||||
rate = 46600
|
rate = 46600
|
||||||
@@ -3254,7 +3394,11 @@ def fetch_payment_source_rows() -> dict[str, object]:
|
|||||||
rate = 40500
|
rate = 40500
|
||||||
elif "선임" in position:
|
elif "선임" in position:
|
||||||
rate = 35300
|
rate = 35300
|
||||||
labor = round(hours * rate * (1.5 if bool(row["is_overtime"]) or "주말" in clean_text(row["weekend_late_flag"]) else 1))
|
labor = calculate_labor_cost(
|
||||||
|
hours,
|
||||||
|
rate,
|
||||||
|
1.5 if bool(row["is_overtime"]) or "주말" in clean_text(row["weekend_late_flag"]) else 1,
|
||||||
|
)
|
||||||
parsed_row = {
|
parsed_row = {
|
||||||
"__values": [
|
"__values": [
|
||||||
clean_text(row["work_date"]),
|
clean_text(row["work_date"]),
|
||||||
@@ -3785,8 +3929,9 @@ async def create_dxf_seat_map(file: UploadFile = File(...), name: str = Form(...
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/seat-maps/active")
|
@app.get("/api/seat-maps/active")
|
||||||
def get_active_seat_map() -> dict[str, dict[str, object]]:
|
def get_active_seat_map(office_key: str | None = None) -> dict[str, dict[str, object]]:
|
||||||
seat_map = ensure_fixed_office_seat_map(activate=True)
|
requested_key = (office_key or "").strip() or FIXED_OFFICE_SOURCE_KEY
|
||||||
|
seat_map = ensure_fixed_office_seat_map(requested_key, activate=requested_key == FIXED_OFFICE_SOURCE_KEY)
|
||||||
if seat_map is None:
|
if seat_map is None:
|
||||||
raise HTTPException(status_code=404, detail="Active seat map not found.")
|
raise HTTPException(status_code=404, detail="Active seat map not found.")
|
||||||
return {"item": seat_map}
|
return {"item": seat_map}
|
||||||
|
|||||||
521474
center2.dxf
521474
center2.dxf
File diff suppressed because it is too large
Load Diff
564192
center3.dxf
564192
center3.dxf
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -169,9 +169,59 @@
|
|||||||
- Windows LAN IP 또는 WSL IP가 바뀌면 `portproxy`의 `connectaddress`는 다시 맞춰야 한다
|
- Windows LAN IP 또는 WSL IP가 바뀌면 `portproxy`의 `connectaddress`는 다시 맞춰야 한다
|
||||||
- 운영 안정성을 위해 향후 자동화 스크립트화가 필요함
|
- 운영 안정성을 위해 향후 자동화 스크립트화가 필요함
|
||||||
|
|
||||||
|
## 10. 인증 기본 구조 추가
|
||||||
|
|
||||||
|
### 작업 내용
|
||||||
|
|
||||||
|
- 프런트 로그인 화면을 실제 `/api/auth/login` API와 연결
|
||||||
|
- 로그인 세션 확인용 `/api/auth/me` 추가
|
||||||
|
- 로그아웃용 `/api/auth/logout` 추가
|
||||||
|
- 로그인 감사로그와 세션 저장 테이블 추가
|
||||||
|
|
||||||
|
### 해결 방식
|
||||||
|
|
||||||
|
- 업무 데이터는 기존 `members` 중심으로 유지
|
||||||
|
- 인증 데이터는 `auth.users`, `auth.sessions`, `auth.login_audit_logs` 로 분리
|
||||||
|
- 구성원 import 시 사번 기준으로 계정을 동기화하고 기본 관리자 계정을 seed
|
||||||
|
|
||||||
|
### 현재 한계
|
||||||
|
|
||||||
|
- 권한 모델은 아직 `role` 단일 컬럼 수준이다
|
||||||
|
- API별 세부 권한 검증은 아직 미완성이다
|
||||||
|
- `/api/mock-login` 은 아직 남아 있어 운영 기준으로는 정리가 필요하다
|
||||||
|
|
||||||
|
## 11. 이력형 DB 전환 방향 확정
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
|
||||||
|
- 월간 스냅샷 파일보다, 사용자가 원하는 날짜 기준으로 조직도와 자리배치도를 바로 조회하는 요구가 더 중요해졌다
|
||||||
|
- 조직도 기본 정보나 자리배치 정보처럼 원래 날짜가 없는 데이터도 과거/현재 버전 차이를 추적해야 한다
|
||||||
|
|
||||||
|
### 결정
|
||||||
|
|
||||||
|
- 월간 스냅샷 기능은 범위에서 제외
|
||||||
|
- 대신 DB 자체를 `valid_from`, `valid_to` 기반 버전 구조로 전환
|
||||||
|
- 사용자 조회는 파일 스냅샷이 아니라 `as_of` 기준 조회 방식으로 설계
|
||||||
|
|
||||||
|
### 우선 적용 대상
|
||||||
|
|
||||||
|
- `members` -> `member_versions`
|
||||||
|
- `seat_positions` -> `seat_assignment_versions`
|
||||||
|
|
||||||
|
### 기대 효과
|
||||||
|
|
||||||
|
- 특정 날짜의 조직 상태 재구성 가능
|
||||||
|
- 특정 날짜의 자리배치도 재구성 가능
|
||||||
|
- 기간 비교나 변경 추적 UI로 확장 가능
|
||||||
|
|
||||||
|
### 설계 문서
|
||||||
|
|
||||||
|
- [HISTORY_ASOF_DB_PLAN.md](/home/hyunho/projects/mh-dashboard-organization/docs/HISTORY_ASOF_DB_PLAN.md)
|
||||||
|
|
||||||
## Next Focus
|
## Next Focus
|
||||||
|
|
||||||
|
- `#2` 영속성 운영 검증과 문서 기준 정리
|
||||||
|
- 권한 제어와 mock login 정리
|
||||||
|
- `#9` as-of date 기반 history 구조 설계 및 점진적 도입
|
||||||
|
- 자리배치도 조직 트리, 나머지 사무실 도면 등 실사용 기능 고도화
|
||||||
- 프로젝트별 분석의 남은 소수점/분류 오차 정리
|
- 프로젝트별 분석의 남은 소수점/분류 오차 정리
|
||||||
- 자리배치도 색상/조직 트리 등 추가 UX 기능 고도화
|
|
||||||
- 실제 인증 체계 전환
|
|
||||||
- 나머지 사무실 도면 추가
|
|
||||||
|
|||||||
184
docs/DEV_PROD_DB_PROTOCOL.md
Normal file
184
docs/DEV_PROD_DB_PROTOCOL.md
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
# Dev / Prod DB Protocol
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
- `8081` 작업용은 기능 개발과 화면 검증을 먼저 수행하는 환경이다.
|
||||||
|
- `8080` 공개용은 실제 기준 데이터와 운영 화면을 제공하는 환경이다.
|
||||||
|
- 코드와 데이터의 기준을 분리해서 관리하되, 데이터 정본은 항상 `8080` 공개용 DB로 유지한다.
|
||||||
|
|
||||||
|
## 현재 구조
|
||||||
|
|
||||||
|
### 코드 경로
|
||||||
|
|
||||||
|
- 공개용 `8080`: `/home/hyunho/projects/mh-dashboard-organization`
|
||||||
|
- 작업용 `8081`: `/tmp/mh-dashboard-organization-dev`
|
||||||
|
|
||||||
|
### DB 볼륨
|
||||||
|
|
||||||
|
- 공개용 `8080`: `mh-dashboard-organization_postgres_data`
|
||||||
|
- 작업용 `8081`: `mh-dashboard-organization-dev_postgres_data`
|
||||||
|
|
||||||
|
즉 현재는 코드도 분리, DB도 분리 상태다.
|
||||||
|
|
||||||
|
## 정본 기준
|
||||||
|
|
||||||
|
- 코드 선행 환경: `8081`
|
||||||
|
- 데이터 정본: `8080`
|
||||||
|
- 공개 반영 기준: `8081`에서 검증 완료된 코드만 `8080`에 승격
|
||||||
|
|
||||||
|
중요:
|
||||||
|
- `8081` DB는 독립 정본이 아니다.
|
||||||
|
- `8081` DB는 `8080` DB를 기준으로 맞춘 검증용 복제본이어야 한다.
|
||||||
|
|
||||||
|
## 왜 이 규칙이 필요한가
|
||||||
|
|
||||||
|
- 조직현황, 조직도, 자리배치 인원, 퇴사자 제외, 멤버 수는 코드보다 DB 영향이 크다.
|
||||||
|
- 작업용 DB가 공개용과 달라지면 기능 검증 결과 자체가 왜곡된다.
|
||||||
|
- 원인 분석 시 `코드 차이`와 `DB 차이`를 분리할 수 있어야 한다.
|
||||||
|
|
||||||
|
## 현재 확인된 차이 예시
|
||||||
|
|
||||||
|
2026-03-27 확인 기준:
|
||||||
|
|
||||||
|
- `members`
|
||||||
|
- `8080`: `227`
|
||||||
|
- `8081`: `236`
|
||||||
|
- `member_retirements`
|
||||||
|
- `8080`: `9`
|
||||||
|
- `8081`: `0`
|
||||||
|
- `seat_maps`
|
||||||
|
- `8080`: `21`
|
||||||
|
- `8081`: `3`
|
||||||
|
- `seat_positions`
|
||||||
|
- `8080`: `5`
|
||||||
|
- `8081`: `0`
|
||||||
|
- `seat_slots`
|
||||||
|
- `8080`: `57308`
|
||||||
|
- `8081`: `370`
|
||||||
|
|
||||||
|
## 기준 테이블 분류
|
||||||
|
|
||||||
|
### A. 공개용 정본 기준으로 항상 맞춰야 하는 테이블
|
||||||
|
|
||||||
|
- `members`
|
||||||
|
- `member_aliases`
|
||||||
|
- `member_overrides`
|
||||||
|
- `member_retirements`
|
||||||
|
- `seat_maps`
|
||||||
|
- `seat_slots`
|
||||||
|
- `seat_positions`
|
||||||
|
|
||||||
|
### B. 원본 재적재로 다시 만들 수 있는 통합 테이블
|
||||||
|
|
||||||
|
- `integration_import_batches`
|
||||||
|
- `integration_raw_organization_rows`
|
||||||
|
- `integration_raw_mh_rows`
|
||||||
|
- `integration_raw_mh_pm_rows`
|
||||||
|
- `integration_raw_payment_rows`
|
||||||
|
- `integration_projects`
|
||||||
|
- `integration_project_aliases`
|
||||||
|
- `integration_project_category_mappings`
|
||||||
|
- `integration_project_pm_assignments`
|
||||||
|
- `integration_work_logs`
|
||||||
|
- `integration_work_log_segments`
|
||||||
|
- `integration_vouchers`
|
||||||
|
|
||||||
|
### C. 별도 정책이 필요한 영역
|
||||||
|
|
||||||
|
- `snapshots`
|
||||||
|
- 인증 관련 스키마와 테이블
|
||||||
|
|
||||||
|
## 작업 프로토콜
|
||||||
|
|
||||||
|
### 1. 작업 시작 전
|
||||||
|
|
||||||
|
1. `8080`과 `8081` 모두 기동 상태 확인
|
||||||
|
2. 이번 작업이 `코드 변경`인지 `데이터 변경`인지 먼저 구분
|
||||||
|
3. 공개용 기준 데이터가 필요한 화면이면 `8081` DB를 먼저 `8080` 기준으로 맞춤
|
||||||
|
4. 작업 전후 검증은 [REGRESSION_CHECKLIST.md](/home/hyunho/projects/mh-dashboard-organization/docs/REGRESSION_CHECKLIST.md) 기준으로 수행
|
||||||
|
|
||||||
|
### 2. 기능 개발 중
|
||||||
|
|
||||||
|
1. 코드 수정은 먼저 `8081`에서 수행
|
||||||
|
2. UI, 계산식, 자리배치도 동작은 `8081`에서 확인
|
||||||
|
3. 조직도/멤버/자리배치 검증은 공개용 기준 데이터가 반영된 `8081` DB에서만 수행
|
||||||
|
|
||||||
|
### 3. 검증 완료 후
|
||||||
|
|
||||||
|
1. 코드만 `8080`으로 승격
|
||||||
|
2. 데이터 반영이 필요한 기능은 별도 절차를 문서화한 뒤 적용
|
||||||
|
3. 공개용 DB를 개발 실험용으로 사용하지 않음
|
||||||
|
|
||||||
|
## 금지 사항
|
||||||
|
|
||||||
|
- `8081` DB를 장기간 독립 정본처럼 취급하지 않기
|
||||||
|
- 퇴사자, 멤버, 좌석 정보를 작업용에서 수작업으로만 유지하지 않기
|
||||||
|
- DB 차이를 무시하고 `8081` 검증 결과가 `8080`과 같다고 가정하지 않기
|
||||||
|
|
||||||
|
## 권장 동기화 범위
|
||||||
|
|
||||||
|
### 최소 범위
|
||||||
|
|
||||||
|
조직도/자리배치도 검증 전 반드시 동기화:
|
||||||
|
|
||||||
|
1. `members`
|
||||||
|
2. `member_aliases`
|
||||||
|
3. `member_overrides`
|
||||||
|
4. `member_retirements`
|
||||||
|
5. `seat_maps`
|
||||||
|
6. `seat_slots`
|
||||||
|
7. `seat_positions`
|
||||||
|
|
||||||
|
### 전체 범위
|
||||||
|
|
||||||
|
분석 화면까지 공개용 기준으로 검증해야 하면 아래도 포함:
|
||||||
|
|
||||||
|
1. `integration_import_batches`
|
||||||
|
2. `integration_raw_*`
|
||||||
|
3. `integration_projects`
|
||||||
|
4. `integration_project_*`
|
||||||
|
5. `integration_work_logs`
|
||||||
|
6. `integration_work_log_segments`
|
||||||
|
7. `integration_vouchers`
|
||||||
|
|
||||||
|
## 세션 시작 체크
|
||||||
|
|
||||||
|
1. 지금 작업이 `코드 변경`인지 `데이터 변경`인지 구분
|
||||||
|
2. 공개용 기준 데이터가 필요한지 판단
|
||||||
|
3. 필요하면 `8081` DB를 `8080` 기준으로 먼저 동기화
|
||||||
|
4. 그 뒤 기능 개발과 검증 수행
|
||||||
|
5. 검증은 [REGRESSION_CHECKLIST.md](/home/hyunho/projects/mh-dashboard-organization/docs/REGRESSION_CHECKLIST.md) 기준으로 수행
|
||||||
|
6. 검증 완료 후 공개용에 코드 승격
|
||||||
|
|
||||||
|
## 다음 액션
|
||||||
|
|
||||||
|
- `8081` DB를 `8080` 기준으로 맞추는 반복 가능한 동기화 절차를 만든다
|
||||||
|
- 최소한 `A 그룹` 테이블은 수동 기억에 의존하지 않고 다시 수행 가능해야 한다
|
||||||
|
- 이후 모든 작업은 이 문서를 기본 프로토콜로 따른다
|
||||||
|
|
||||||
|
## 실행 절차
|
||||||
|
|
||||||
|
반복 가능한 동기화 스크립트:
|
||||||
|
|
||||||
|
- [sync_prod_db_to_dev.sh](/home/hyunho/projects/mh-dashboard-organization/scripts/sync_prod_db_to_dev.sh)
|
||||||
|
|
||||||
|
사용 방법:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x scripts/sync_prod_db_to_dev.sh
|
||||||
|
./scripts/sync_prod_db_to_dev.sh minimal
|
||||||
|
./scripts/sync_prod_db_to_dev.sh full
|
||||||
|
```
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
|
||||||
|
- `minimal`
|
||||||
|
- 조직도, 멤버, 자리배치도 검증 전 사용
|
||||||
|
- `full`
|
||||||
|
- 분석 화면까지 공개용 기준 데이터로 맞춰야 할 때 사용
|
||||||
|
|
||||||
|
주의:
|
||||||
|
|
||||||
|
- 스크립트는 `8080` DB 데이터를 덤프해서 `8081` DB의 대상 테이블을 비우고 다시 적재한다
|
||||||
|
- `8081`에서만 존재하던 대상 테이블 데이터는 사라진다
|
||||||
|
- 따라서 실행 전 현재 작업용 DB 상태를 유지해야 하면 별도 백업 후 실행한다
|
||||||
294
docs/HISTORY_ASOF_DB_PLAN.md
Normal file
294
docs/HISTORY_ASOF_DB_PLAN.md
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
# History / As-Of DB Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
월간 스냅샷 파일을 따로 만드는 대신, DB 자체를 시간축이 있는 구조로 전환한다.
|
||||||
|
목표는 다음과 같다.
|
||||||
|
|
||||||
|
- 조직도와 자리배치도를 수정할 때마다 과거 값이 사라지지 않게 누적 저장
|
||||||
|
- 사용자가 특정 날짜 또는 기간을 선택하면 그 시점 기준 상태를 다시 조회
|
||||||
|
- 날짜가 원래 없는 데이터도 `유효 시작일`과 `유효 종료일`을 부여해 과거 버전 조회 가능하게 만들기
|
||||||
|
|
||||||
|
핵심 원칙은 아래 한 줄이다.
|
||||||
|
|
||||||
|
- 최신 값을 덮어쓰지 않고, `valid_from`, `valid_to` 기반 버전 행을 누적한다
|
||||||
|
|
||||||
|
## Why This Instead Of Snapshots
|
||||||
|
|
||||||
|
- 월간 스냅샷 파일은 생성 시점만 남고 중간 변경 추적이 약하다
|
||||||
|
- 원하는 날짜 기준으로 바로 조회하기 어렵다
|
||||||
|
- 조직도만 따로 파일로 남으면 자리배치도, 권한, 운영 이력을 함께 맞추기 어렵다
|
||||||
|
|
||||||
|
따라서 이 프로젝트에는 "파일 스냅샷"보다 "시점 조회 가능한 버전 DB"가 더 맞다.
|
||||||
|
|
||||||
|
## Query Model
|
||||||
|
|
||||||
|
조회 기준은 `as_of` 또는 `date_from`, `date_to` 이다.
|
||||||
|
|
||||||
|
- 특정 날짜 조회:
|
||||||
|
- `GET /api/members?as_of=2026-03-01`
|
||||||
|
- `GET /api/seat-maps/active/layout?as_of=2026-03-01`
|
||||||
|
- 기간 비교:
|
||||||
|
- `GET /api/history/organization/compare?date_from=2026-03-01&date_to=2026-03-31`
|
||||||
|
|
||||||
|
공통 조회 조건은 아래다.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
WHERE valid_from <= :as_of
|
||||||
|
AND (valid_to IS NULL OR valid_to > :as_of)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recommended Data Model
|
||||||
|
|
||||||
|
### 1. Stable Base Tables
|
||||||
|
|
||||||
|
식별자와 최소 메타만 유지하는 기준 테이블.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE members (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
employee_id TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE seat_assignment_targets (
|
||||||
|
member_id INTEGER PRIMARY KEY REFERENCES members(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
설명:
|
||||||
|
- `members` 는 "사람 자체" 식별자 역할
|
||||||
|
- 실제 이름, 조직, 직급, 연락처, 좌석 같은 표시 데이터는 버전 테이블로 이동
|
||||||
|
|
||||||
|
### 2. Member Version Table
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE member_versions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
company TEXT NOT NULL DEFAULT '',
|
||||||
|
rank TEXT NOT NULL DEFAULT '',
|
||||||
|
role TEXT NOT NULL DEFAULT '',
|
||||||
|
department TEXT NOT NULL DEFAULT '',
|
||||||
|
grp TEXT NOT NULL DEFAULT '',
|
||||||
|
division TEXT NOT NULL DEFAULT '',
|
||||||
|
team TEXT NOT NULL DEFAULT '',
|
||||||
|
cell TEXT NOT NULL DEFAULT '',
|
||||||
|
work_status TEXT NOT NULL DEFAULT '',
|
||||||
|
work_time TEXT NOT NULL DEFAULT '',
|
||||||
|
phone TEXT NOT NULL DEFAULT '',
|
||||||
|
email TEXT NOT NULL DEFAULT '',
|
||||||
|
photo_url TEXT NOT NULL DEFAULT '',
|
||||||
|
valid_from TIMESTAMPTZ NOT NULL,
|
||||||
|
valid_to TIMESTAMPTZ,
|
||||||
|
revision_no BIGINT NOT NULL,
|
||||||
|
changed_by_user_id BIGINT,
|
||||||
|
change_reason TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX member_versions_member_time_idx
|
||||||
|
ON member_versions (member_id, valid_from, valid_to);
|
||||||
|
```
|
||||||
|
|
||||||
|
설명:
|
||||||
|
- 날짜가 원래 없던 조직도 데이터도 이 테이블에서 과거 버전 관리
|
||||||
|
- 어떤 시점에 이름, 조직, 직책, 연락처가 어땠는지 재구성 가능
|
||||||
|
|
||||||
|
### 3. Seat Assignment Version Table
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE seat_assignment_versions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE,
|
||||||
|
seat_map_id INTEGER REFERENCES seat_maps(id) ON DELETE CASCADE,
|
||||||
|
seat_slot_id INTEGER REFERENCES seat_slots(id) ON DELETE CASCADE,
|
||||||
|
seat_label TEXT NOT NULL DEFAULT '',
|
||||||
|
valid_from TIMESTAMPTZ NOT NULL,
|
||||||
|
valid_to TIMESTAMPTZ,
|
||||||
|
revision_no BIGINT NOT NULL,
|
||||||
|
changed_by_user_id BIGINT,
|
||||||
|
change_reason TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX seat_assignment_versions_member_time_idx
|
||||||
|
ON seat_assignment_versions (member_id, valid_from, valid_to);
|
||||||
|
```
|
||||||
|
|
||||||
|
설명:
|
||||||
|
- 현재 `seat_positions` 가 맡는 "최신 좌석 상태"를 버전형으로 저장
|
||||||
|
- 특정 날짜의 자리배치도를 다시 그릴 수 있음
|
||||||
|
|
||||||
|
### 4. Optional Change Event Table
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE entity_change_events (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
entity_type TEXT NOT NULL,
|
||||||
|
entity_id BIGINT NOT NULL,
|
||||||
|
action_type TEXT NOT NULL,
|
||||||
|
revision_no BIGINT NOT NULL,
|
||||||
|
changed_by_user_id BIGINT,
|
||||||
|
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
change_reason TEXT NOT NULL DEFAULT '',
|
||||||
|
patch_json JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
설명:
|
||||||
|
- 버전 테이블은 "그 시점의 전체 값"
|
||||||
|
- 이벤트 테이블은 "무엇이 바뀌었는지"
|
||||||
|
- 초기에는 없어도 되지만, 추후 비교 UI와 감사로그에 유용
|
||||||
|
|
||||||
|
### 5. Revision Table
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE history_revisions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
scope TEXT NOT NULL DEFAULT 'organization',
|
||||||
|
revision_label TEXT NOT NULL,
|
||||||
|
created_by_user_id BIGINT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
note TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
설명:
|
||||||
|
- 버전 묶음을 사람 친화적으로 관리할 때 사용
|
||||||
|
- 예: `2026-03-27 1차 조직개편 반영`
|
||||||
|
|
||||||
|
## How Writes Change
|
||||||
|
|
||||||
|
현재 구조:
|
||||||
|
- `UPDATE members SET ...`
|
||||||
|
- `UPSERT seat_positions ...`
|
||||||
|
|
||||||
|
바꿀 구조:
|
||||||
|
1. 현재 유효한 버전 행을 조회
|
||||||
|
2. 값이 달라지면 기존 행의 `valid_to` 를 닫음
|
||||||
|
3. 새 값을 가진 행을 `valid_from = now()` 로 insert
|
||||||
|
4. 필요하면 최신 캐시 테이블도 함께 갱신
|
||||||
|
|
||||||
|
예시:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE member_versions
|
||||||
|
SET valid_to = NOW()
|
||||||
|
WHERE member_id = :member_id
|
||||||
|
AND valid_to IS NULL;
|
||||||
|
|
||||||
|
INSERT INTO member_versions (
|
||||||
|
member_id, name, company, rank, role, department, grp, division, team, cell,
|
||||||
|
work_status, work_time, phone, email, photo_url,
|
||||||
|
valid_from, valid_to, revision_no, changed_by_user_id, change_reason
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
:member_id, :name, :company, :rank, :role, :department, :grp, :division, :team, :cell,
|
||||||
|
:work_status, :work_time, :phone, :email, :photo_url,
|
||||||
|
NOW(), NULL, :revision_no, :changed_by_user_id, :change_reason
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## How Date-Bearing And Date-Less Data Coexist
|
||||||
|
|
||||||
|
### 날짜가 원래 있는 데이터
|
||||||
|
|
||||||
|
- `integration_work_logs.work_date`
|
||||||
|
- `integration_vouchers.issue_date`
|
||||||
|
|
||||||
|
이 데이터는 원래 날짜 컬럼이 있으므로 그대로 사용하면 된다.
|
||||||
|
|
||||||
|
### 날짜가 원래 없는 데이터
|
||||||
|
|
||||||
|
- 조직도 인원 기본 정보
|
||||||
|
- 조직 소속
|
||||||
|
- 자리배치 상태
|
||||||
|
- 사진 경로
|
||||||
|
|
||||||
|
이 데이터는 `valid_from`, `valid_to` 를 붙여 시점 조회가 가능하게 만든다.
|
||||||
|
|
||||||
|
즉, "날짜가 없는 데이터"가 아니라 "유효기간을 부여한 버전 데이터"로 바꾸는 것이다.
|
||||||
|
|
||||||
|
## API Direction
|
||||||
|
|
||||||
|
### Common UI Input
|
||||||
|
|
||||||
|
사용자가 실제 HTML에서 고르는 기준은 헤더의 날짜 제어를 공통 입력으로 쓰는 것이 맞다.
|
||||||
|
|
||||||
|
권장안:
|
||||||
|
- 프로젝트/팀 분석: 기존처럼 `시작일 ~ 종료일`
|
||||||
|
- 조직도/자리배치도: 우선 `기준일(as_of)` 1개를 사용
|
||||||
|
- 필요하면 조직도 비교 화면에서 `비교 시작일`, `비교 종료일` 확장
|
||||||
|
|
||||||
|
현재 상태:
|
||||||
|
- 헤더 날짜 제어는 `프로젝트별 분석`, `팀/개인별 분석` iframe에 이미 전달되고 있음
|
||||||
|
- 조직도/자리배치도는 아직 헤더 날짜를 실제 조회 조건으로 사용하지 않음
|
||||||
|
|
||||||
|
권장 API:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/members?as_of=2026-03-27
|
||||||
|
GET /api/members/{id}?as_of=2026-03-27
|
||||||
|
GET /api/seat-maps/active/layout?as_of=2026-03-27
|
||||||
|
GET /api/history/organization/compare?date_from=2026-03-01&date_to=2026-03-31
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Strategy
|
||||||
|
|
||||||
|
### Phase 1. History Tables Add
|
||||||
|
|
||||||
|
- `member_versions`
|
||||||
|
- `seat_assignment_versions`
|
||||||
|
- `history_revisions`
|
||||||
|
- 필요 시 `entity_change_events`
|
||||||
|
|
||||||
|
현재 `members`, `seat_positions` 는 그대로 유지
|
||||||
|
|
||||||
|
### Phase 2. Backfill
|
||||||
|
|
||||||
|
- 현재 `members` 최신값을 `member_versions(valid_from = NOW(), valid_to = NULL)` 로 적재
|
||||||
|
- 현재 `seat_positions` 최신값을 `seat_assignment_versions(valid_from = NOW(), valid_to = NULL)` 로 적재
|
||||||
|
- 이 단계에서는 과거 진짜 이력은 없고 "현재 상태를 버전 구조에 싣는 것"이 목표
|
||||||
|
|
||||||
|
### Phase 3. Dual Write
|
||||||
|
|
||||||
|
- 조직도 수정 시:
|
||||||
|
- 기존 `members` 갱신
|
||||||
|
- 동시에 `member_versions` 에 append
|
||||||
|
- 자리배치 저장 시:
|
||||||
|
- 기존 `seat_positions` 갱신
|
||||||
|
- 동시에 `seat_assignment_versions` 에 append
|
||||||
|
|
||||||
|
### Phase 4. As-Of Read APIs
|
||||||
|
|
||||||
|
- 조직도 API에 `as_of` 지원
|
||||||
|
- 자리배치도 API에 `as_of` 지원
|
||||||
|
- 헤더 날짜 제어와 연결
|
||||||
|
|
||||||
|
### Phase 5. Full History-First Read
|
||||||
|
|
||||||
|
- 최신 조회도 버전 테이블 기준으로 전환
|
||||||
|
- `members`, `seat_positions` 는 캐시 또는 편의 테이블로 축소 가능
|
||||||
|
|
||||||
|
## Recommended First Scope
|
||||||
|
|
||||||
|
처음부터 모든 테이블을 이력화하지 말고 아래부터 시작하는 것이 안전하다.
|
||||||
|
|
||||||
|
1. `members` -> `member_versions`
|
||||||
|
2. `seat_positions` -> `seat_assignment_versions`
|
||||||
|
3. 조직도/자리배치도 조회 API에 `as_of`
|
||||||
|
|
||||||
|
이 세 가지가 되면 사용자는 원하는 날짜의 조직 상태와 좌석 상태를 볼 수 있다.
|
||||||
|
|
||||||
|
## Explicitly Removed From Scope
|
||||||
|
|
||||||
|
- 월간 스냅샷 파일 생성
|
||||||
|
- 스냅샷 다운로드 기능
|
||||||
|
- 조직도만 따로 파일로 내보내는 방식
|
||||||
|
|
||||||
|
이 프로젝트의 방향은 "파일 스냅샷"이 아니라 "시점 조회 가능한 버전 DB"다.
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
# 인프라 검증 체크리스트
|
# 인프라 검증 체크리스트
|
||||||
|
|
||||||
|
## 현재 확인 상태
|
||||||
|
- 2026-03-27 기준 `docker compose ps` 에서 `proxy`, `frontend`, `backend`, `db` 모두 `healthy`
|
||||||
|
- 2026-03-27 기준 `curl http://localhost:8080/api/health` 정상
|
||||||
|
- 2026-03-27 기준 `curl http://localhost:8080/api/members` 에서 `items` 비어 있지 않음
|
||||||
|
- 다른 PC 접속도 현재 확인됨
|
||||||
|
- 개발/운영 DB 분리 운영 원칙은 [DEV_PROD_DB_PROTOCOL.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEV_PROD_DB_PROTOCOL.md) 기준으로 관리
|
||||||
|
|
||||||
## 1. 컨테이너 기동
|
## 1. 컨테이너 기동
|
||||||
- `docker compose build`
|
- `docker compose build`
|
||||||
- `docker compose up -d`
|
- `docker compose up -d`
|
||||||
@@ -32,4 +39,8 @@
|
|||||||
- 확인 기준:
|
- 확인 기준:
|
||||||
- DB 데이터 유지
|
- DB 데이터 유지
|
||||||
- 업로드 파일 유지
|
- 업로드 파일 유지
|
||||||
- 스냅샷 파일 유지
|
|
||||||
|
## 6. 제외 또는 후속 검증 항목
|
||||||
|
- 월간 스냅샷 파일 유지 검증은 현재 코드 기준 미구현 항목
|
||||||
|
- 스냅샷 기능을 다시 범위에 넣을 경우 별도 API/파일 경로/다운로드 검증 절차를 추가해야 함
|
||||||
|
- `8081`에서 조직도, 멤버, 자리배치도 검증 전에는 `8080` 정본 DB 기준 동기화가 필요함
|
||||||
|
|||||||
@@ -3,8 +3,26 @@
|
|||||||
## Current Base
|
## Current Base
|
||||||
|
|
||||||
- branch: `total`
|
- branch: `total`
|
||||||
- latest integration commit: `61b5638`
|
- latest checked commit: `24852d4`
|
||||||
- main history doc: [DEVELOPMENT_HISTORY.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEVELOPMENT_HISTORY.md)
|
- main history doc: [DEVELOPMENT_HISTORY.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEVELOPMENT_HISTORY.md)
|
||||||
|
- work rulebook: [WORK_RULEBOOK.md](/home/hyunho/projects/mh-dashboard-organization/docs/WORK_RULEBOOK.md)
|
||||||
|
- dev/prod protocol: [DEV_PROD_DB_PROTOCOL.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEV_PROD_DB_PROTOCOL.md)
|
||||||
|
- regression checklist: [REGRESSION_CHECKLIST.md](/home/hyunho/projects/mh-dashboard-organization/docs/REGRESSION_CHECKLIST.md)
|
||||||
|
- today prep note: [TODAY_WORK_PREP_2026-03-30.md](/home/hyunho/projects/mh-dashboard-organization/docs/TODAY_WORK_PREP_2026-03-30.md)
|
||||||
|
|
||||||
|
## Mandatory Start Rule
|
||||||
|
|
||||||
|
매일 아침 또는 그날의 첫 작업을 시작할 때는 코드를 수정하기 전에 반드시 아래 순서를 먼저 수행해야 한다.
|
||||||
|
|
||||||
|
1. Gitea 브랜치 상태 확인
|
||||||
|
2. 열린 이슈 확인
|
||||||
|
3. [WORK_RULEBOOK.md](/home/hyunho/projects/mh-dashboard-organization/docs/WORK_RULEBOOK.md) 확인
|
||||||
|
4. 이 문서 확인
|
||||||
|
5. 현재 워크트리의 미푸시 커밋, 변경 파일, 미추적 파일 확인
|
||||||
|
|
||||||
|
주의:
|
||||||
|
|
||||||
|
- 위 절차를 확인하기 전에는 새 코드 작성이나 기존 코드 수정부터 시작하지 않는다.
|
||||||
|
|
||||||
## What Was Finished
|
## What Was Finished
|
||||||
|
|
||||||
@@ -50,15 +68,37 @@
|
|||||||
- `member_retirements`
|
- `member_retirements`
|
||||||
- `member_overrides`
|
- `member_overrides`
|
||||||
|
|
||||||
|
### Auth Baseline
|
||||||
|
|
||||||
|
- 실제 로그인 API 연결 완료
|
||||||
|
- 프런트 로그인 화면이 `/api/auth/login` 사용
|
||||||
|
- 세션/로그아웃/세션 조회 API 구성 완료
|
||||||
|
- 사용 테이블:
|
||||||
|
- `auth.users`
|
||||||
|
- `auth.sessions`
|
||||||
|
- `auth.login_audit_logs`
|
||||||
|
- 현재 남은 범위:
|
||||||
|
- mock login 정리
|
||||||
|
- 역할별 권한 체크 적용
|
||||||
|
- 쓰기 API 보호 범위 정리
|
||||||
|
|
||||||
### External Access
|
### External Access
|
||||||
|
|
||||||
- WSL 내부 8080 리슨 확인
|
- WSL 내부 8080 리슨 확인
|
||||||
- Windows `portproxy`를 이용해 다른 PC에서 접속 가능하게 설정
|
- 현재 다른 PC에서 접속 확인
|
||||||
- 현재 기준 주소:
|
- 현재 기준 주소:
|
||||||
- `http://172.16.40.144:8080`
|
- `http://172.16.40.144:8080`
|
||||||
|
|
||||||
## Important Runtime Notes
|
## Important Runtime Notes
|
||||||
|
|
||||||
|
### Dev / Prod Protocol
|
||||||
|
|
||||||
|
- 코드 선행은 `8081`, 공개 반영은 `8080`
|
||||||
|
- 데이터 정본은 `8080` DB
|
||||||
|
- `8081` DB는 독립 정본이 아니라 `8080` 기준 복제본처럼 관리해야 함
|
||||||
|
- 조직도, 멤버, 자리배치 검증 전에는 `DEV_PROD_DB_PROTOCOL.md`를 먼저 확인
|
||||||
|
- 기능 수정 후 완료 판단은 `REGRESSION_CHECKLIST.md`를 기준으로 해야 함
|
||||||
|
|
||||||
### Seat Map Save
|
### Seat Map Save
|
||||||
|
|
||||||
- 저장이 안 되면 먼저 backend 로그에서 `PUT /api/seat-maps/{id}/layout` 상태코드 확인
|
- 저장이 안 되면 먼저 backend 로그에서 `PUT /api/seat-maps/{id}/layout` 상태코드 확인
|
||||||
@@ -79,14 +119,18 @@
|
|||||||
|
|
||||||
## Open Issues
|
## Open Issues
|
||||||
|
|
||||||
- `#2` 백엔드 영속 저장 구조 운영 마무리 및 스냅샷 검증
|
- `#2` 백엔드 영속 저장 구조 운영 마무리
|
||||||
- `#3` 사무실 좌석 배치도 조회 및 관리자 편집 기능 고도화
|
- `#3` 사무실 좌석 배치도 조회 및 관리자 편집 기능 고도화
|
||||||
- `#5` 실제 인증 체계 전환
|
- `#5` 실제 인증 체계 전환
|
||||||
- `#6` 4개 기능 통합 대시보드 프레임 및 공통 헤더 구축
|
|
||||||
- `#7` 자리배치도 팀별 색상 오버레이 표시
|
- `#7` 자리배치도 팀별 색상 오버레이 표시
|
||||||
- `#8` 자리배치도 좌석 클릭 시 개인 상위 조직 트리 표시
|
- `#8` 자리배치도 좌석 클릭 시 개인 상위 조직 트리 표시
|
||||||
- `#9` 조직도·자리배치도 변경 이력 버전 누적 저장
|
- `#9` 조직도·자리배치도 변경 이력 버전 누적 저장
|
||||||
|
|
||||||
|
현재 해석:
|
||||||
|
- `#6`은 코드 기준 사실상 완료 상태이며 Gitea 정리 대상
|
||||||
|
- `#5`는 "로그인 구현"보다 "권한 제어 마무리"가 핵심
|
||||||
|
- `#2`의 기존 "스냅샷 검증" 범위는 현재 코드와 불일치하므로 범위 재정의 필요
|
||||||
|
|
||||||
## Unfinished Ideas Discussed Today
|
## Unfinished Ideas Discussed Today
|
||||||
|
|
||||||
### Seat Map UX
|
### Seat Map UX
|
||||||
@@ -101,10 +145,15 @@
|
|||||||
### History / Versioning
|
### History / Versioning
|
||||||
|
|
||||||
- 조직도와 자리배치도 수정 이력을 버전 누적형으로 저장
|
- 조직도와 자리배치도 수정 이력을 버전 누적형으로 저장
|
||||||
- 원본 DB와 별도의 history/snapshot 구조 설계
|
- 원본 DB와 별도의 history/version 구조 설계
|
||||||
- 날짜/버전 형식 예:
|
- `valid_from`, `valid_to` 기반 시점 조회(as-of date) 구조 적용
|
||||||
- `00.00.00`
|
- 날짜 또는 revision label 기준으로 버전 묶음 관리
|
||||||
- 또는 날짜 기반 revision
|
- 상세 설계 문서:
|
||||||
|
- [HISTORY_ASOF_DB_PLAN.md](/home/hyunho/projects/mh-dashboard-organization/docs/HISTORY_ASOF_DB_PLAN.md)
|
||||||
|
|
||||||
|
주의:
|
||||||
|
- 현재 코드에는 조직도/자리배치도 버전 이력 기능이 아직 없음
|
||||||
|
- 월간 스냅샷 방향은 범위에서 제외
|
||||||
|
|
||||||
### Project Analysis Accuracy
|
### Project Analysis Accuracy
|
||||||
|
|
||||||
@@ -113,25 +162,29 @@
|
|||||||
|
|
||||||
### Auth / Permission
|
### Auth / Permission
|
||||||
|
|
||||||
- mock login을 실제 인증 체계로 전환
|
- mock login을 개발용 fallback 수준으로 제한하거나 제거
|
||||||
- 역할별 접근 제어 정리
|
- 역할별 접근 제어 정리
|
||||||
- 조직도/자리배치도/분석 화면 권한 경계 재정리
|
- 조직도/자리배치도/분석 화면 권한 경계 재정리
|
||||||
|
|
||||||
## Recommended Next Work Order
|
## Recommended Next Work Order
|
||||||
|
|
||||||
1. 자리배치도 저장/표시를 브라우저에서 한 번 더 실사용 검증
|
1. `#2` 범위를 현재 코드 기준으로 재정의하고 영속성 운영 검증 완료
|
||||||
2. `#7`, `#8`, `#9` 중 우선순위 확정
|
2. `#5`에서 권한 체크, mock login 정리, 쓰기 API 보호 적용
|
||||||
3. 프로젝트별 분석 남은 오차 정밀 보정
|
3. `8081` DB를 `8080` 정본 기준으로 동기화하는 반복 가능한 절차 마련
|
||||||
4. 실제 인증 체계 설계/구현
|
4. `#9`를 as-of date 기반 history 구조로 설계 후 `members`, `seat_positions` 부터 이력화
|
||||||
|
5. 그 다음 `#8`, 나머지 도면 추가, `#7`, 프로젝트 분석 오차 보정 순으로 진행
|
||||||
|
|
||||||
## Quick Resume Prompt
|
## Quick Resume Prompt
|
||||||
|
|
||||||
다음 세션 시작 시 아래 기준으로 이어가면 된다.
|
다음 세션 시작 시 아래 기준으로 이어가면 된다.
|
||||||
|
|
||||||
- 브랜치 `total`에서 시작
|
- 브랜치 `total`에서 시작
|
||||||
- 최근 커밋 `61b5638` 확인
|
- 최근 커밋 `1d15cf9` 확인
|
||||||
- `docs/DEVELOPMENT_HISTORY.md`
|
- `docs/DEVELOPMENT_HISTORY.md`
|
||||||
- `docs/NEXT_SESSION_CHECKPOINT.md`
|
- `docs/NEXT_SESSION_CHECKPOINT.md`
|
||||||
- Gitea 이슈 `#7`, `#8`, `#9`
|
- `docs/DEV_PROD_DB_PROTOCOL.md`
|
||||||
|
- `docs/REGRESSION_CHECKLIST.md`
|
||||||
|
- `docs/HISTORY_ASOF_DB_PLAN.md`
|
||||||
|
- Gitea 이슈 `#2`, `#5`, `#9`
|
||||||
|
|
||||||
그리고 먼저 현재 외부 접속과 자리배치 저장이 정상인지 확인한 뒤 다음 기능 개발로 넘어간다.
|
그리고 먼저 현재 외부 접속, 자리배치 저장, 실제 로그인 동작을 확인한 뒤 다음 기능 개발로 넘어간다.
|
||||||
|
|||||||
160
docs/REGRESSION_CHECKLIST.md
Normal file
160
docs/REGRESSION_CHECKLIST.md
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
# 회귀 검증 체크리스트
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
- 새 기능을 추가하거나 기존 기능을 수정할 때, 이전에 되던 핵심 기능이 깨졌는지 빠르게 확인한다.
|
||||||
|
- `8081` 작업용에서 검증한 결과를 신뢰할 수 있도록 `환경`, `데이터`, `핵심 시나리오`를 고정한다.
|
||||||
|
- 완료 판단을 감이 아니라 반복 가능한 체크 절차로 바꾼다.
|
||||||
|
|
||||||
|
## 적용 원칙
|
||||||
|
|
||||||
|
- 코드 수정은 먼저 `8081`에서 수행한다.
|
||||||
|
- 데이터 기준은 항상 `8080` 공개용 DB를 따른다.
|
||||||
|
- 검증 전에는 작업 범위에 맞는 DB 동기화를 먼저 수행한다.
|
||||||
|
- 기능 수정 후에는 관련 화면만 보지 말고, 이 문서의 핵심 시나리오를 함께 확인한다.
|
||||||
|
|
||||||
|
관련 문서:
|
||||||
|
|
||||||
|
- [DEV_PROD_DB_PROTOCOL.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEV_PROD_DB_PROTOCOL.md)
|
||||||
|
- [INFRA_VALIDATION_CHECKLIST.md](/home/hyunho/projects/mh-dashboard-organization/docs/INFRA_VALIDATION_CHECKLIST.md)
|
||||||
|
|
||||||
|
## 작업 시작 전
|
||||||
|
|
||||||
|
### 1. 서버 상태 확인
|
||||||
|
|
||||||
|
- `8081` 작업용 접속 확인
|
||||||
|
- `8080` 공개용 접속 확인
|
||||||
|
- `docker compose ps`에서 `backend`, `frontend`, `proxy`, `db`가 정상인지 확인
|
||||||
|
|
||||||
|
### 2. 데이터 동기화 범위 결정
|
||||||
|
|
||||||
|
- 조직도, 관리자모드, 자리배치도 작업 전:
|
||||||
|
- `./scripts/sync_prod_db_to_dev.sh minimal`
|
||||||
|
- 프로젝트별 분석, 팀/개인별 분석 작업 전:
|
||||||
|
- `./scripts/sync_prod_db_to_dev.sh analysis`
|
||||||
|
- 공개용 기준 전체 데이터 재검증이 필요한 경우만:
|
||||||
|
- `./scripts/sync_prod_db_to_dev.sh full`
|
||||||
|
|
||||||
|
### 3. 기준 고정
|
||||||
|
|
||||||
|
- 어느 서버에서 재현했는지 기록
|
||||||
|
- 어떤 데이터 동기화 범위로 검증했는지 기록
|
||||||
|
- 브라우저 캐시 영향을 피하려면 강력 새로고침 후 확인
|
||||||
|
|
||||||
|
## 공통 회귀 시나리오
|
||||||
|
|
||||||
|
기능 수정 후 아래 항목을 최소한 확인한다.
|
||||||
|
|
||||||
|
### A. 허브 및 공통 진입
|
||||||
|
|
||||||
|
- 메인 허브가 정상 렌더링된다.
|
||||||
|
- 상단 탭 이동이 정상 동작한다.
|
||||||
|
- 로그인 상태가 비정상적으로 풀리지 않는다.
|
||||||
|
|
||||||
|
### B. 조직현황
|
||||||
|
|
||||||
|
- 조직도 트리가 정상 표시된다.
|
||||||
|
- 관리자모드 진입이 가능하다.
|
||||||
|
- 대상인원 클릭 시 기본정보 모달이 열린다.
|
||||||
|
- `+` 신규 구성원 추가 모달이 열린다.
|
||||||
|
- 기본정보 저장이 정상 동작한다.
|
||||||
|
|
||||||
|
### C. 자리배치도
|
||||||
|
|
||||||
|
- `기술개발센터`, `한맥빌딩 6층`, `한맥빌딩 7층` 도면이 모두 열린다.
|
||||||
|
- 미배치 인원 목록이 정상 표시된다.
|
||||||
|
- 미배치 인원을 chair에 드래그앤드롭할 수 있다.
|
||||||
|
- 드롭 후:
|
||||||
|
- 미배치 목록에서 사라진다.
|
||||||
|
- chair에 배치 상태가 표시된다.
|
||||||
|
- 이름/직급 표기가 보인다.
|
||||||
|
- 배치된 좌석 클릭 후 해제 또는 수정 흐름이 정상 동작한다.
|
||||||
|
|
||||||
|
### D. 조직도와 자리배치 연동
|
||||||
|
|
||||||
|
- 조직도에서 인원 클릭 시 상세 정보가 열린다.
|
||||||
|
- 재석위치 미리보기가 표시된다.
|
||||||
|
- 좌석이 배정된 인원은 해당 자리로 줌인된다.
|
||||||
|
|
||||||
|
### E. 프로젝트별 분석
|
||||||
|
|
||||||
|
- 월 선택이 정상 동작한다.
|
||||||
|
- 프로젝트 목록과 합계가 비어 있지 않다.
|
||||||
|
- `1월`, `2월` 데이터가 현재 기준값과 일치한다.
|
||||||
|
|
||||||
|
현재 기준 검증값:
|
||||||
|
|
||||||
|
- `2026-01`
|
||||||
|
- 시간 `37,342.39`
|
||||||
|
- 인건비 `1,391,966,625`
|
||||||
|
- `2026-02`
|
||||||
|
- 시간 `29,060.59`
|
||||||
|
- 인건비 `1,078,337,651`
|
||||||
|
|
||||||
|
### F. 팀/개인별 분석
|
||||||
|
|
||||||
|
- `전체`, `GPD`, `TDC` 버튼이 순서대로 보인다.
|
||||||
|
- `전체`에서 모든 팀이 노출된다.
|
||||||
|
- `GPD`, `TDC` 선택 시 각 소속 범위만 버튼 기준으로 보인다.
|
||||||
|
- 검색은 버튼 상태와 무관하게 전체 데이터를 검색한다.
|
||||||
|
|
||||||
|
## 작업 유형별 필수 추가 확인
|
||||||
|
|
||||||
|
### 조직도 / 관리자모드 수정 시
|
||||||
|
|
||||||
|
- 대상인원 수정 모달 레이아웃이 깨지지 않는지 확인
|
||||||
|
- 신규 구성원 추가 모달도 같은 레이아웃으로 보이는지 확인
|
||||||
|
- 저장 후 목록 반영이 정상인지 확인
|
||||||
|
|
||||||
|
### 자리배치도 수정 시
|
||||||
|
|
||||||
|
- viewer iframe 로드 여부 확인
|
||||||
|
- 드래그앤드롭 이후 배치 상태가 즉시 반영되는지 확인
|
||||||
|
- 조직도 상세 재석위치 preview까지 같이 확인
|
||||||
|
|
||||||
|
### 분석 로직 수정 시
|
||||||
|
|
||||||
|
- 작업 전에 반드시 `analysis` 또는 `full` 동기화 수행
|
||||||
|
- 월별 합계 검증값 재확인
|
||||||
|
- 원본 기준과 차이가 있으면 반올림, 제외 인원, 가공시간 규칙부터 점검
|
||||||
|
|
||||||
|
## 완료 처리 기준
|
||||||
|
|
||||||
|
수정 사항을 완료로 판단하려면 아래를 모두 만족해야 한다.
|
||||||
|
|
||||||
|
- 수정한 기능이 의도대로 동작한다.
|
||||||
|
- 관련 공통 회귀 시나리오가 깨지지 않는다.
|
||||||
|
- 필요한 경우 `8081`에서 검증 결과를 숫자 또는 화면 기준으로 기록한다.
|
||||||
|
- 이후에만 `8080` 공개용 반영 여부를 판단한다.
|
||||||
|
|
||||||
|
## 장애 원인 분류 기준
|
||||||
|
|
||||||
|
문제가 생기면 먼저 아래 셋 중 어디인지 분리한다.
|
||||||
|
|
||||||
|
- 코드 차이
|
||||||
|
- `8080`, `8081`의 정적 파일 또는 백엔드 로직이 다름
|
||||||
|
- DB 차이
|
||||||
|
- `members`, `seat_maps`, `integration_*` 등 기준 데이터가 다름
|
||||||
|
- 캐시 또는 런타임 상태
|
||||||
|
- 정적 파일 캐시, 컨테이너 재시작 미반영, 브라우저 세션 상태 문제
|
||||||
|
|
||||||
|
이 분류를 먼저 해야 원인을 잘못 짚지 않는다.
|
||||||
|
|
||||||
|
## 권장 기록 방식
|
||||||
|
|
||||||
|
작업 종료 시 아래 형식으로 남긴다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
작업 범위:
|
||||||
|
- 예: 조직현황 관리자모드 기본정보 모달 레이아웃 변경
|
||||||
|
|
||||||
|
검증 환경:
|
||||||
|
- 서버: 8081
|
||||||
|
- DB 동기화: minimal / analysis / full 중 무엇을 사용했는지
|
||||||
|
|
||||||
|
검증 결과:
|
||||||
|
- 조직도: 정상
|
||||||
|
- 관리자모드 모달: 정상
|
||||||
|
- 자리배치도 연동: 정상 또는 미검증
|
||||||
|
- 프로젝트별 분석: 정상 또는 미검증
|
||||||
|
```
|
||||||
143
docs/TODAY_WORK_PREP_2026-03-30.md
Normal file
143
docs/TODAY_WORK_PREP_2026-03-30.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# Today Work Prep - 2026-03-30
|
||||||
|
|
||||||
|
## Current Local State
|
||||||
|
|
||||||
|
- working branch: `total`
|
||||||
|
- HEAD: `24852d4` (`Fix seatmap slot matching and update member modal layout`)
|
||||||
|
- remote tracking: `origin/total`
|
||||||
|
- status: local branch is `ahead 2`
|
||||||
|
- open PRs: none
|
||||||
|
|
||||||
|
untracked files:
|
||||||
|
|
||||||
|
- `docs/HISTORY_ASOF_DB_PLAN.md`
|
||||||
|
- `incoming-files/6f.html`
|
||||||
|
- `incoming-files/7f.html`
|
||||||
|
- `incoming-files/center.html`
|
||||||
|
|
||||||
|
주의:
|
||||||
|
|
||||||
|
- `docs/NEXT_SESSION_CHECKPOINT.md` 의 최신 checked commit 은 아직 `1d15cf9` 로 남아 있다.
|
||||||
|
- 실제 최신 작업 판단은 아래 최근 2개 로컬 커밋 기준으로 보는 것이 맞다.
|
||||||
|
|
||||||
|
## What Was Added After `origin/total`
|
||||||
|
|
||||||
|
### Commit `d666141`
|
||||||
|
|
||||||
|
- 3개 고정 오피스 자리배치도 반영
|
||||||
|
- `technical-development-center`
|
||||||
|
- `hanmac-building-6f`
|
||||||
|
- `hanmac-building-7f`
|
||||||
|
- 백엔드 `office_key` 기반 active viewer/layout 조회 지원
|
||||||
|
- 프런트 자리배치도 탭에서 3개 오피스 선택 지원
|
||||||
|
- `scripts/sync_prod_db_to_dev.sh` 추가
|
||||||
|
- `docs/DEV_PROD_DB_PROTOCOL.md` 추가
|
||||||
|
|
||||||
|
### Commit `24852d4`
|
||||||
|
|
||||||
|
- slot 기반 자리 저장 시 slot matching 보정
|
||||||
|
- 멤버 상세 모달 / 조직도 seat preview 레이아웃 조정
|
||||||
|
- 회귀 점검용 `docs/REGRESSION_CHECKLIST.md` 추가
|
||||||
|
- dev/prod sync script 후속 보정
|
||||||
|
|
||||||
|
## Remote Branch / Issue Snapshot
|
||||||
|
|
||||||
|
remote branches:
|
||||||
|
|
||||||
|
- `total` -> `1d15cf9`
|
||||||
|
- `hyunho` -> `8efb5da`
|
||||||
|
- `main` -> `7a0bd54`
|
||||||
|
|
||||||
|
open issues:
|
||||||
|
|
||||||
|
- `#11` `[P0] [버그] 자리배치도 회귀 오류`
|
||||||
|
- `#12` `[P1] [DB] 공개용/작업용 seat_positions 스키마 불일치 정리`
|
||||||
|
- `#13` `[P1] [인프라] 작업용 DB 동기화 절차 안정화 및 자동화`
|
||||||
|
- `#14` `[P2] [리팩터링] 누적된 임시 로직 정리 및 중복 코드 제거`
|
||||||
|
- `#10` `[P1] [분석] 1~2월 원본 정합성 보정 및 팀/개인별 검색 범위 개선 작업 정리`
|
||||||
|
- `#9` `[P1] [이력관리] as-of date / 버전 누적 저장`
|
||||||
|
- `#8` `[P2] [자리배치도] 좌석 클릭 시 개인 상위 조직 트리 표시`
|
||||||
|
- `#7` `[P2] [자리배치도] 팀별 색상 오버레이 표시`
|
||||||
|
- `#5` `[P2] [인증] 권한 제어 마무리 및 mock login 정리`
|
||||||
|
- `#3` `[P1] [기능] 사무실 좌석 배치도 조회 및 관리자 편집 기능 고도화`
|
||||||
|
- `#2` `[P0] [인프라] 백엔드 영속 저장 구조 운영 마무리`
|
||||||
|
|
||||||
|
현재 관계 해석:
|
||||||
|
|
||||||
|
- `#11` 은 최근 2개 커밋이 직접 겨냥한 회귀 묶음이다.
|
||||||
|
- `#12`, `#13` 은 `#11` 재발 방지용 운영 과제에 가깝다.
|
||||||
|
- `#3` 은 다중 오피스 도면 반영으로 많이 진척됐지만, 공개용 기준 회귀 검증 전에는 완료 처리하면 안 된다.
|
||||||
|
- `#2` 는 단순 구현보다 dev/prod 데이터 운영 기준 정리가 핵심으로 바뀌었다.
|
||||||
|
- `#5` 는 로그인 구현보다 권한 경계와 `/api/mock-login` 정리가 남은 상태다.
|
||||||
|
|
||||||
|
## Best Starting Point Today
|
||||||
|
|
||||||
|
오늘 첫 작업은 새 기능 추가보다, 최근 자리배치도/DB 동기화 작업을 검증 가능한 상태로 굳히는 쪽이 우선이다.
|
||||||
|
|
||||||
|
우선순위:
|
||||||
|
|
||||||
|
1. `#13` 프로토콜대로 작업용 DB를 `minimal` 범위로 동기화
|
||||||
|
2. `docs/REGRESSION_CHECKLIST.md` 기준으로 자리배치도 회귀 확인
|
||||||
|
3. 최근 2개 로컬 커밋을 `origin/total` 에 올릴지 결정
|
||||||
|
4. 회귀가 남아 있으면 `#11` 계속, 없으면 `#5` 또는 `#12/#13` 후속 정리로 이동
|
||||||
|
|
||||||
|
이 순서가 맞는 이유:
|
||||||
|
|
||||||
|
- 현재 가장 최근 변경이 seatmap + DB sync 쪽에 몰려 있다.
|
||||||
|
- 원격 `total` 은 아직 해당 수정들을 포함하지 않는다.
|
||||||
|
- 검증 없이 다른 기능으로 넘어가면 회귀 원인과 신규 작업이 다시 섞인다.
|
||||||
|
|
||||||
|
## Concrete Start Checklist
|
||||||
|
|
||||||
|
세션 시작 즉시:
|
||||||
|
|
||||||
|
1. `docs/DEV_PROD_DB_PROTOCOL.md` 다시 확인
|
||||||
|
2. 필요 시 `./scripts/sync_prod_db_to_dev.sh minimal`
|
||||||
|
3. 로그인 상태 확인
|
||||||
|
4. 아래 3개를 오피스별로 확인
|
||||||
|
- 관리자 DnD 배치 저장
|
||||||
|
- 조직도 상세 seat preview
|
||||||
|
- 비관리자 seatmap 진입 / 표시
|
||||||
|
|
||||||
|
필수 확인 오피스:
|
||||||
|
|
||||||
|
- `기술개발센터`
|
||||||
|
- `한맥빌딩 6층`
|
||||||
|
- `한맥빌딩 7층`
|
||||||
|
|
||||||
|
## Recommended Decision Tree
|
||||||
|
|
||||||
|
### Case A. 회귀가 남아 있음
|
||||||
|
|
||||||
|
- 바로 `#11` 우선
|
||||||
|
- 동시에 원인 범주를 분리
|
||||||
|
- DB sync 실패
|
||||||
|
- `seat_positions` 스키마 차이
|
||||||
|
- 프런트 fallback 오류
|
||||||
|
- 저장 API 로직 오류
|
||||||
|
|
||||||
|
### Case B. 회귀가 해소됨
|
||||||
|
|
||||||
|
- 최근 2개 커밋 푸시
|
||||||
|
- Gitea `#11`, `#3`, `#2` 코멘트 상태 업데이트
|
||||||
|
- 다음 메인 작업을 아래 중 하나로 선택
|
||||||
|
- `#5` 권한 제어 / mock login 제거
|
||||||
|
- `#12`, `#13` DB sync 안정화 마무리
|
||||||
|
- `#9` history / as-of 구조 착수
|
||||||
|
|
||||||
|
## Suggested Main Task After Verification
|
||||||
|
|
||||||
|
가장 자연스러운 다음 메인 작업은 `#5` 보다 `#12`, `#13` 마무리다.
|
||||||
|
|
||||||
|
이유:
|
||||||
|
|
||||||
|
- 지금 이 코드베이스에서 자리배치도/조직도 검증은 DB 상태에 크게 좌우된다.
|
||||||
|
- 권한 작업을 시작해도 검증 기반이 흔들리면 다시 혼선이 생긴다.
|
||||||
|
- 반대로 sync 절차와 스키마 호환을 먼저 고정하면 이후 `#5`, `#9`, `#8`, `#7` 진행이 쉬워진다.
|
||||||
|
|
||||||
|
## Short Summary
|
||||||
|
|
||||||
|
- 코드 최신 상태는 로컬 `total@24852d4`
|
||||||
|
- 원격 `total` 은 아직 최신 seatmap/sync 수정 전 상태
|
||||||
|
- 오늘 첫 목표는 `#11` 관련 회귀 검증과 `#12/#13` 기반 정리
|
||||||
|
- 검증 완료 전에는 새 기능보다 seatmap + DB 운영 안정화를 우선하는 것이 맞다
|
||||||
211
docs/WORK_RULEBOOK.md
Normal file
211
docs/WORK_RULEBOOK.md
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
# Work Rulebook
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
이 문서는 이 프로젝트에서 매일 작업을 시작하고 마무리할 때 반드시 따를 운영 규칙을 고정하기 위한 룰북이다.
|
||||||
|
|
||||||
|
목표는 아래 4가지다.
|
||||||
|
|
||||||
|
- 완료된 기능의 회귀 방지
|
||||||
|
- 코드 문제와 DB 문제의 혼선 방지
|
||||||
|
- 작업 기록 누락 방지
|
||||||
|
- 매일 같은 기준으로 안정적으로 이어서 작업
|
||||||
|
|
||||||
|
## Rule 0. Morning Start Mandatory Check
|
||||||
|
|
||||||
|
이 규칙은 강제 규칙이다.
|
||||||
|
|
||||||
|
매일 아침 또는 그날의 첫 작업을 시작할 때는, 코드를 수정하기 전에 반드시 아래 순서를 먼저 수행한다.
|
||||||
|
|
||||||
|
1. Gitea 브랜치 상태 확인
|
||||||
|
2. 열린 이슈 확인
|
||||||
|
3. 이 문서 `WORK_RULEBOOK.md` 확인
|
||||||
|
4. 최신 체크포인트 문서 확인
|
||||||
|
5. 현재 워크트리의 미푸시 커밋, 변경 파일, 미추적 파일 확인
|
||||||
|
|
||||||
|
위 5단계를 확인하기 전에는 새 코드 작성, 기존 코드 수정, 임의 테스트 진행을 시작하지 않는다.
|
||||||
|
|
||||||
|
즉:
|
||||||
|
|
||||||
|
- "오늘 첫 작업"의 시작점은 코드 수정이 아니라 상태 확인이다.
|
||||||
|
- 이 절차를 건너뛰고 바로 수정 작업에 들어가는 것은 금지한다.
|
||||||
|
|
||||||
|
## Rule 1. Completed Feature Protection
|
||||||
|
|
||||||
|
완료 판정된 작업물의 기능과 코드는 함부로 건드리지 않는다.
|
||||||
|
|
||||||
|
세부 규칙:
|
||||||
|
|
||||||
|
- 직접 관련된 이슈가 없으면 완료 기능을 수정하지 않는다.
|
||||||
|
- 완료 기능 수정이 필요하면 먼저 이유와 영향 범위를 이슈 또는 코멘트에 남긴다.
|
||||||
|
- 단순 편의상 구조를 바꾸거나 정리하는 리팩터링으로 완료 기능 동작을 바꾸지 않는다.
|
||||||
|
- 완료 기능을 수정한 경우에는 관련 회귀 검증까지 완료해야 한다.
|
||||||
|
|
||||||
|
핵심 원칙:
|
||||||
|
|
||||||
|
- "고치는 김에 같이 정리"를 금지한다.
|
||||||
|
- 수정 범위는 현재 작업 목적에 필요한 최소 범위로 제한한다.
|
||||||
|
|
||||||
|
## Rule 2. Work Must Be Tied To An Issue
|
||||||
|
|
||||||
|
원칙적으로 이슈 없는 작업은 하지 않는다.
|
||||||
|
|
||||||
|
세부 규칙:
|
||||||
|
|
||||||
|
- 모든 작업은 기존 이슈에 연결하거나 새 이슈/작업 메모를 만든 뒤 시작한다.
|
||||||
|
- 왜 하는 작업인지 한 줄로라도 남긴다.
|
||||||
|
- 임시 대응도 예외가 아니다.
|
||||||
|
|
||||||
|
## Rule 3. Branch And Workspace Awareness
|
||||||
|
|
||||||
|
작업 전에 현재 브랜치와 워크트리 상태를 먼저 확인한다.
|
||||||
|
|
||||||
|
반드시 확인할 항목:
|
||||||
|
|
||||||
|
- 현재 브랜치
|
||||||
|
- 원격 대비 ahead / behind 상태
|
||||||
|
- 미푸시 커밋
|
||||||
|
- 수정된 파일
|
||||||
|
- 미추적 파일
|
||||||
|
|
||||||
|
금지:
|
||||||
|
|
||||||
|
- 로컬에서만 있는 상태를 기준 진실처럼 가정하기
|
||||||
|
- 미정리 변경사항을 모른 채 새 작업을 덧붙이기
|
||||||
|
|
||||||
|
## Rule 4. DB Before Code Assumption
|
||||||
|
|
||||||
|
조직도, 멤버, 자리배치도, 권한 문제는 코드보다 DB 상태 영향을 먼저 의심한다.
|
||||||
|
|
||||||
|
세부 규칙:
|
||||||
|
|
||||||
|
- dev DB와 prod DB가 다른데 코드 버그로 단정하지 않는다.
|
||||||
|
- 공개용 기준 데이터가 필요한 검증은 먼저 동기화 상태를 확인한다.
|
||||||
|
- DB 차이를 무시한 검증 결과를 신뢰하지 않는다.
|
||||||
|
|
||||||
|
## Rule 5. Dev / Prod Protocol Is Mandatory
|
||||||
|
|
||||||
|
`docs/DEV_PROD_DB_PROTOCOL.md` 의 규칙은 권고가 아니라 작업 기준이다.
|
||||||
|
|
||||||
|
핵심 원칙:
|
||||||
|
|
||||||
|
- 코드 선행은 `8081`
|
||||||
|
- 데이터 정본은 `8080`
|
||||||
|
- `8081` DB는 독립 정본이 아니라 검증용 복제본처럼 다룬다
|
||||||
|
|
||||||
|
조직도/자리배치도/멤버 검증 전에는 필요 시 아래를 먼저 수행한다.
|
||||||
|
|
||||||
|
- `./scripts/sync_prod_db_to_dev.sh minimal`
|
||||||
|
|
||||||
|
분석 화면까지 공개용 기준으로 맞출 필요가 있으면 아래를 사용한다.
|
||||||
|
|
||||||
|
- `./scripts/sync_prod_db_to_dev.sh full`
|
||||||
|
|
||||||
|
## Rule 6. Validation Before Completion
|
||||||
|
|
||||||
|
완료 기준은 "코드를 썼다"가 아니라 "실제 동작을 검증했다"이다.
|
||||||
|
|
||||||
|
세부 규칙:
|
||||||
|
|
||||||
|
- 검증 없이 완료로 판단하지 않는다.
|
||||||
|
- 감으로 확인하지 않고 체크리스트 기준으로 확인한다.
|
||||||
|
- 회귀 가능성이 있는 수정은 관련 기능까지 같이 확인한다.
|
||||||
|
|
||||||
|
검증 기준 문서:
|
||||||
|
|
||||||
|
- `docs/REGRESSION_CHECKLIST.md`
|
||||||
|
|
||||||
|
## Rule 7. Seat Map Work Is High Risk
|
||||||
|
|
||||||
|
자리배치도 관련 작업은 항상 고위험 작업으로 취급한다.
|
||||||
|
|
||||||
|
작업 시 최소 확인 항목:
|
||||||
|
|
||||||
|
1. 관리자 DnD 배치 / 저장
|
||||||
|
2. 조직도 상세의 seat preview
|
||||||
|
3. 비관리자 seatmap 진입 / 표시
|
||||||
|
|
||||||
|
오피스가 여러 개면 아래 모두 확인한다.
|
||||||
|
|
||||||
|
- `기술개발센터`
|
||||||
|
- `한맥빌딩 6층`
|
||||||
|
- `한맥빌딩 7층`
|
||||||
|
|
||||||
|
기술개발센터만 보고 완료 처리하지 않는다.
|
||||||
|
|
||||||
|
## Rule 8. Auth / Schema / Sync Changes Are High Risk
|
||||||
|
|
||||||
|
아래 영역은 일반 기능 수정처럼 다루지 않는다.
|
||||||
|
|
||||||
|
- `auth.*`
|
||||||
|
- `members`
|
||||||
|
- `seat_maps`
|
||||||
|
- `seat_slots`
|
||||||
|
- `seat_positions`
|
||||||
|
- 동기화 스크립트
|
||||||
|
- 스키마 변경
|
||||||
|
|
||||||
|
이 작업은 반드시:
|
||||||
|
|
||||||
|
- 변경 이유 명시
|
||||||
|
- 영향 범위 확인
|
||||||
|
- 관련 검증 수행
|
||||||
|
- 결과 기록
|
||||||
|
|
||||||
|
까지 포함해야 한다.
|
||||||
|
|
||||||
|
## Rule 9. Temporary Logic Must Be Tracked
|
||||||
|
|
||||||
|
mock, fallback, hotfix, 임시 우회 로직은 허용할 수 있다.
|
||||||
|
하지만 반드시 추적 가능해야 한다.
|
||||||
|
|
||||||
|
세부 규칙:
|
||||||
|
|
||||||
|
- 왜 임시인지 기록한다.
|
||||||
|
- 제거 또는 정식화할 이슈를 연결한다.
|
||||||
|
- 운영 기준 로직처럼 장기 방치하지 않는다.
|
||||||
|
|
||||||
|
## Rule 10. End-Of-Day Closing Record
|
||||||
|
|
||||||
|
작업 종료 시 아래를 반드시 남긴다.
|
||||||
|
|
||||||
|
- 무엇을 했는지
|
||||||
|
- 무엇을 검증했는지
|
||||||
|
- 무엇이 아직 남았는지
|
||||||
|
- 다음에 어디서 이어야 하는지
|
||||||
|
|
||||||
|
남길 위치:
|
||||||
|
|
||||||
|
- Gitea 이슈 코멘트
|
||||||
|
- 또는 체크포인트 문서
|
||||||
|
|
||||||
|
둘 다 가능하면 둘 다 남긴다.
|
||||||
|
|
||||||
|
## Daily Start Checklist
|
||||||
|
|
||||||
|
매일 첫 작업 시작 전 체크:
|
||||||
|
|
||||||
|
- 현재 브랜치 확인
|
||||||
|
- 원격 대비 커밋 상태 확인
|
||||||
|
- 열린 이슈 확인
|
||||||
|
- `WORK_RULEBOOK.md` 확인
|
||||||
|
- 최신 체크포인트 확인
|
||||||
|
- 미추적 / 수정 파일 확인
|
||||||
|
- 오늘 작업이 코드 문제인지 DB 문제인지 먼저 구분
|
||||||
|
- 공개용 기준 데이터 검증이 필요한지 판단
|
||||||
|
|
||||||
|
## Daily End Checklist
|
||||||
|
|
||||||
|
매일 작업 종료 전 체크:
|
||||||
|
|
||||||
|
- 오늘 변경 파일 정리
|
||||||
|
- 검증 결과 정리
|
||||||
|
- 미완료 항목 정리
|
||||||
|
- 관련 이슈 코멘트 또는 문서 업데이트
|
||||||
|
- 다음 시작 지점 명시
|
||||||
|
|
||||||
|
## One-Line Operating Principle
|
||||||
|
|
||||||
|
이 프로젝트의 작업 기준은 아래 한 줄로 요약한다.
|
||||||
|
|
||||||
|
- 상태를 먼저 확인하고, 완료 기능은 보호하며, DB와 검증을 무시하지 않고, 기록을 남기면서 작업한다.
|
||||||
@@ -101,8 +101,8 @@ const APP_BASE_URL = String(window.__MH_BASE_URL || "").replace(/\/$/, "");
|
|||||||
|
|
||||||
const seatMapOffices = [
|
const seatMapOffices = [
|
||||||
{ key: "technical-development-center", label: "기술개발센터", ready: true },
|
{ key: "technical-development-center", label: "기술개발센터", ready: true },
|
||||||
{ key: "hanmac-building-7f", label: "한맥빌딩 7층", ready: false },
|
{ key: "hanmac-building-6f", label: "한맥빌딩 6층", ready: true },
|
||||||
{ key: "hanmac-building-6f", label: "한맥빌딩 6층", ready: false },
|
{ key: "hanmac-building-7f", label: "한맥빌딩 7층", ready: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
const viewLabels = {
|
const viewLabels = {
|
||||||
@@ -148,7 +148,7 @@ const seatMapState = {
|
|||||||
forceReadOnly: false,
|
forceReadOnly: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let currentView = "organization";
|
let currentView = "project";
|
||||||
const globalDateState = {
|
const globalDateState = {
|
||||||
loaded: true,
|
loaded: true,
|
||||||
startDate: "2026-01-01",
|
startDate: "2026-01-01",
|
||||||
@@ -861,6 +861,7 @@ function renderDxfSeatMapBoard() {
|
|||||||
const viewerUrl = resolveAppUrl(`/api/seat-maps/${seatMapState.seatMap.id}/viewer`);
|
const viewerUrl = resolveAppUrl(`/api/seat-maps/${seatMapState.seatMap.id}/viewer`);
|
||||||
seatMapBoard.innerHTML = `
|
seatMapBoard.innerHTML = `
|
||||||
<div class="seatmap-dxf-frame-shell">
|
<div class="seatmap-dxf-frame-shell">
|
||||||
|
<div class="seatmap-dxf-drop-overlay" data-seatmap-drop-overlay></div>
|
||||||
<iframe
|
<iframe
|
||||||
id="seatmap-dxf-frame"
|
id="seatmap-dxf-frame"
|
||||||
class="seatmap-dxf-frame"
|
class="seatmap-dxf-frame"
|
||||||
@@ -874,6 +875,12 @@ function renderDxfSeatMapBoard() {
|
|||||||
setupSeatMapViewerFrame();
|
setupSeatMapViewerFrame();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setSeatMapDropOverlayActive(active) {
|
||||||
|
const overlay = seatMapBoard?.querySelector("[data-seatmap-drop-overlay]");
|
||||||
|
if (!overlay) return;
|
||||||
|
overlay.classList.toggle("is-active", Boolean(active && seatMapState.editMode));
|
||||||
|
}
|
||||||
|
|
||||||
function getDraftPlacedSlotKeys() {
|
function getDraftPlacedSlotKeys() {
|
||||||
const slotMap = getSeatSlotMap();
|
const slotMap = getSeatSlotMap();
|
||||||
return (seatMapState.draftPlacements || [])
|
return (seatMapState.draftPlacements || [])
|
||||||
@@ -917,6 +924,13 @@ function syncSeatMapViewerFrame() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleSeatMapViewerSync() {
|
||||||
|
syncSeatMapViewerFrame();
|
||||||
|
window.setTimeout(() => {
|
||||||
|
syncSeatMapViewerFrame();
|
||||||
|
}, 80);
|
||||||
|
}
|
||||||
|
|
||||||
function renderSeatMapActions() {
|
function renderSeatMapActions() {
|
||||||
const hasSeatMap = Boolean(seatMapState.seatMap);
|
const hasSeatMap = Boolean(seatMapState.seatMap);
|
||||||
const adminMode = isSeatMapAdminMode();
|
const adminMode = isSeatMapAdminMode();
|
||||||
@@ -940,6 +954,7 @@ function updateSeatMapDraftUi() {
|
|||||||
|
|
||||||
function setupSeatMapViewerFrame() {
|
function setupSeatMapViewerFrame() {
|
||||||
const frame = seatMapBoard?.querySelector("#seatmap-dxf-frame");
|
const frame = seatMapBoard?.querySelector("#seatmap-dxf-frame");
|
||||||
|
const overlay = seatMapBoard?.querySelector("[data-seatmap-drop-overlay]");
|
||||||
if (!frame) return;
|
if (!frame) return;
|
||||||
|
|
||||||
frame.addEventListener("load", () => {
|
frame.addEventListener("load", () => {
|
||||||
@@ -950,26 +965,42 @@ function setupSeatMapViewerFrame() {
|
|||||||
const canvas = frameDocument?.getElementById("canvas");
|
const canvas = frameDocument?.getElementById("canvas");
|
||||||
if (!frameWindow || !frameDocument || !canvas || !frameWindow.__mhSeatmap) return;
|
if (!frameWindow || !frameDocument || !canvas || !frameWindow.__mhSeatmap) return;
|
||||||
|
|
||||||
canvas.addEventListener("dragover", (event) => {
|
const handleDrop = (event) => {
|
||||||
event.preventDefault();
|
|
||||||
event.dataTransfer.dropEffect = "move";
|
|
||||||
});
|
|
||||||
|
|
||||||
canvas.addEventListener("drop", (event) => {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const memberId = getDraggedMemberId(event);
|
const memberId = getDraggedMemberId(event);
|
||||||
if (!memberId) return;
|
if (!memberId) {
|
||||||
const rect = canvas.getBoundingClientRect();
|
setSeatMapStatus("드롭 감지됨: memberId를 읽지 못했습니다.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const frameRect = frame.getBoundingClientRect();
|
||||||
|
const canvasRect = canvas.getBoundingClientRect();
|
||||||
const picked = frameWindow.__mhSeatmap.pickChairAt(
|
const picked = frameWindow.__mhSeatmap.pickChairAt(
|
||||||
event.clientX - rect.left,
|
event.clientX - frameRect.left - canvasRect.left,
|
||||||
event.clientY - rect.top,
|
event.clientY - frameRect.top - canvasRect.top,
|
||||||
);
|
);
|
||||||
if (!picked?.key) return;
|
if (!picked?.key) {
|
||||||
|
setSeatMapStatus(`드롭 감지됨: 좌석 인식 실패 (memberId=${memberId})`, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const matchedSlot = (seatMapState.slots || []).find((item) => String(item.slot_key) === String(picked.key));
|
const matchedSlot = (seatMapState.slots || []).find((item) => String(item.slot_key) === String(picked.key));
|
||||||
if (!matchedSlot) return;
|
if (!matchedSlot) {
|
||||||
|
setSeatMapStatus(`드롭 감지됨: slot 매칭 실패 (${picked.key})`, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
upsertDraftPlacementForSlot(memberId, Number(matchedSlot.id));
|
upsertDraftPlacementForSlot(memberId, Number(matchedSlot.id));
|
||||||
|
setSeatMapStatus(`드롭 성공: memberId=${memberId}, slot=${picked.key}, slotId=${matchedSlot.id}`, "info");
|
||||||
updateSeatMapDraftUi();
|
updateSeatMapDraftUi();
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = "move";
|
||||||
|
};
|
||||||
|
|
||||||
|
canvas.addEventListener("dragover", handleDragOver);
|
||||||
|
canvas.addEventListener("drop", handleDrop);
|
||||||
|
overlay?.addEventListener("dragover", handleDragOver);
|
||||||
|
overlay?.addEventListener("drop", handleDrop);
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1168,22 +1199,7 @@ async function loadSeatMapData(force = false) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const office = getCurrentSeatMapOffice();
|
const office = getCurrentSeatMapOffice();
|
||||||
if (!office.ready) {
|
const activePayload = await fetchJson(`/api/seat-maps/active?office_key=${encodeURIComponent(office.key)}`);
|
||||||
const membersPayload = await fetchJson("/api/members");
|
|
||||||
seatMapState.seatMap = null;
|
|
||||||
seatMapState.members = Array.isArray(membersPayload.items) ? membersPayload.items : [];
|
|
||||||
seatMapState.slots = [];
|
|
||||||
seatMapState.placements = [];
|
|
||||||
seatMapState.zoom = 1;
|
|
||||||
seatMapState.hoveredSlotId = null;
|
|
||||||
seatMapState.editMode = canEditSeatMap();
|
|
||||||
resetSeatMapDraft();
|
|
||||||
seatMapState.loaded = true;
|
|
||||||
setSeatMapStatus(`${office.label} 도면은 아직 등록 전입니다.`, "info");
|
|
||||||
renderSeatMap();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const activePayload = await fetchJson("/api/seat-maps/active");
|
|
||||||
const activeSeatMap = activePayload.item;
|
const activeSeatMap = activePayload.item;
|
||||||
const layoutPayload = await fetchJson(`/api/seat-maps/${activeSeatMap.id}/layout`);
|
const layoutPayload = await fetchJson(`/api/seat-maps/${activeSeatMap.id}/layout`);
|
||||||
seatMapState.seatMap = {
|
seatMapState.seatMap = {
|
||||||
@@ -1479,12 +1495,10 @@ if (loginForm) {
|
|||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
setSession(payload);
|
setSession(payload);
|
||||||
|
setActiveView("project");
|
||||||
loginForm.reset();
|
loginForm.reset();
|
||||||
loginMessage.textContent = "";
|
loginMessage.textContent = "";
|
||||||
renderAuth();
|
renderAuth();
|
||||||
if (currentView === "seatmap-admin" || currentView === "seatmap-readonly") {
|
|
||||||
await loadSeatMapData(true);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
loginMessage.textContent = error.message || "로그인에 실패했습니다.";
|
loginMessage.textContent = error.message || "로그인에 실패했습니다.";
|
||||||
}
|
}
|
||||||
@@ -1683,12 +1697,14 @@ document.addEventListener("dragstart", (event) => {
|
|||||||
const memberId = Number(card.dataset.memberId);
|
const memberId = Number(card.dataset.memberId);
|
||||||
if (!memberId) return;
|
if (!memberId) return;
|
||||||
seatMapState.draggingMemberId = memberId;
|
seatMapState.draggingMemberId = memberId;
|
||||||
|
setSeatMapDropOverlayActive(true);
|
||||||
event.dataTransfer.effectAllowed = "move";
|
event.dataTransfer.effectAllowed = "move";
|
||||||
event.dataTransfer.setData("text/plain", String(memberId));
|
event.dataTransfer.setData("text/plain", String(memberId));
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener("dragend", () => {
|
document.addEventListener("dragend", () => {
|
||||||
seatMapState.draggingMemberId = null;
|
seatMapState.draggingMemberId = null;
|
||||||
|
setSeatMapDropOverlayActive(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener("click", () => {
|
document.addEventListener("click", () => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>MH 조직현황 대시보드</title>
|
<title>MH 대시보드-공개용</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap" rel="stylesheet">
|
||||||
|
|||||||
@@ -623,6 +623,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.seatmap-dxf-frame-shell {
|
.seatmap-dxf-frame-shell {
|
||||||
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -630,6 +631,18 @@ body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.seatmap-dxf-drop-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 3;
|
||||||
|
pointer-events: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-dxf-drop-overlay.is-active {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.seatmap-dxf-frame {
|
.seatmap-dxf-frame {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -1316,7 +1316,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="mh-topbar-actions" class="flex flex-wrap items-center justify-end gap-3">
|
<div id="mh-topbar-actions" class="flex flex-wrap items-center justify-end gap-3">
|
||||||
<div id="mh-scope-toggle" aria-label="조직 구분 선택">
|
<div id="mh-scope-toggle" aria-label="조직 구분 선택">
|
||||||
<button type="button" class="mh-scope-btn active" data-scope="gpd">GPD</button>
|
<button type="button" class="mh-scope-btn active" data-scope="all">전체</button>
|
||||||
|
<button type="button" class="mh-scope-btn" data-scope="gpd">GPD</button>
|
||||||
<button type="button" class="mh-scope-btn" data-scope="tdc">TDC</button>
|
<button type="button" class="mh-scope-btn" data-scope="tdc">TDC</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="mh-inline-filters">
|
<div id="mh-inline-filters">
|
||||||
@@ -1483,13 +1484,16 @@
|
|||||||
let teamData = [];
|
let teamData = [];
|
||||||
let allTeams = [];
|
let allTeams = [];
|
||||||
let allPeopleData = [];
|
let allPeopleData = [];
|
||||||
let currentScope = 'gpd';
|
let searchTeams = [];
|
||||||
|
let searchPeopleData = [];
|
||||||
|
let currentScope = 'all';
|
||||||
let matrixBizOpenState = {};
|
let matrixBizOpenState = {};
|
||||||
let matrixProjectFilter = 'all';
|
let matrixProjectFilter = 'all';
|
||||||
let currentMatrixData = {};
|
let currentMatrixData = {};
|
||||||
let personChartOpenState = {};
|
let personChartOpenState = {};
|
||||||
let personCalendarState = { name: '', team: '', year: 2026, month: 0 };
|
let personCalendarState = { name: '', team: '', year: 2026, month: 0 };
|
||||||
let lastUploadedBinary = '';
|
let lastUploadedBinary = '';
|
||||||
|
let lastPmSheetData = [];
|
||||||
let projectPmMap = new Map();
|
let projectPmMap = new Map();
|
||||||
let memberTeamMap = new Map();
|
let memberTeamMap = new Map();
|
||||||
const GPD_TEAM_DIVISIONS = new Set(['총괄', '영업']);
|
const GPD_TEAM_DIVISIONS = new Set(['총괄', '영업']);
|
||||||
@@ -1622,6 +1626,14 @@
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
const getScopedRows = (scope = currentScope) => teamData.slice(1).filter(row => isRowInScope(row, scope));
|
const getScopedRows = (scope = currentScope) => teamData.slice(1).filter(row => isRowInScope(row, scope));
|
||||||
|
const getTeamScope = (teamName) => {
|
||||||
|
const normalizedTeam = String(teamName || '').trim();
|
||||||
|
if (!normalizedTeam) return 'all';
|
||||||
|
const matchedRow = teamData.slice(1).find(row => String(row?.[columnMap.team] || '').trim() === normalizedTeam);
|
||||||
|
const division = getRowTeamDivision(matchedRow);
|
||||||
|
if (!division) return 'all';
|
||||||
|
return GPD_TEAM_DIVISIONS.has(division) ? 'gpd' : 'tdc';
|
||||||
|
};
|
||||||
const buildScopedPeopleData = (rows) => {
|
const buildScopedPeopleData = (rows) => {
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
return rows.map(r => ({
|
return rows.map(r => ({
|
||||||
@@ -2239,6 +2251,8 @@
|
|||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
if (payload.binary) {
|
if (payload.binary) {
|
||||||
loadWorkbookBinary(payload.binary);
|
loadWorkbookBinary(payload.binary);
|
||||||
|
} else if (Array.isArray(payload.teamData)) {
|
||||||
|
applyMhSourceRows(payload.teamData, Array.isArray(payload.pmSheet) ? payload.pmSheet : []);
|
||||||
}
|
}
|
||||||
if (payload.scope) {
|
if (payload.scope) {
|
||||||
currentScope = payload.scope === 'tdc' ? 'tdc' : 'gpd';
|
currentScope = payload.scope === 'tdc' ? 'tdc' : 'gpd';
|
||||||
@@ -2261,7 +2275,7 @@
|
|||||||
|
|
||||||
function openTeamAnalysisPopup(team) {
|
function openTeamAnalysisPopup(team) {
|
||||||
|
|
||||||
if (!teamData.length || !lastUploadedBinary) return;
|
if (!teamData.length) return;
|
||||||
|
|
||||||
closeProjectModal();
|
closeProjectModal();
|
||||||
|
|
||||||
@@ -2280,12 +2294,17 @@
|
|||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
source: 'team-popup-init',
|
source: 'team-popup-init',
|
||||||
binary: lastUploadedBinary,
|
|
||||||
team,
|
team,
|
||||||
scope: popupScope,
|
scope: popupScope,
|
||||||
startDate: document.getElementById('start-date').value || '',
|
startDate: document.getElementById('start-date').value || '',
|
||||||
endDate: document.getElementById('end-date').value || ''
|
endDate: document.getElementById('end-date').value || ''
|
||||||
};
|
};
|
||||||
|
if (lastUploadedBinary) {
|
||||||
|
payload.binary = lastUploadedBinary;
|
||||||
|
} else {
|
||||||
|
payload.teamData = teamData;
|
||||||
|
payload.pmSheet = lastPmSheetData;
|
||||||
|
}
|
||||||
const sendPayload = () => {
|
const sendPayload = () => {
|
||||||
if (!popupWindow || popupWindow.closed) return;
|
if (!popupWindow || popupWindow.closed) return;
|
||||||
try {
|
try {
|
||||||
@@ -2329,16 +2348,13 @@
|
|||||||
|
|
||||||
|
|
||||||
// --- Handlers ---
|
// --- Handlers ---
|
||||||
function loadWorkbookBinary(binaryStr) {
|
function applyMhSourceRows(nextTeamData, nextPmSheet = []) {
|
||||||
if (!binaryStr) return;
|
teamData = Array.isArray(nextTeamData) ? nextTeamData : [];
|
||||||
lastUploadedBinary = binaryStr;
|
lastPmSheetData = Array.isArray(nextPmSheet) ? nextPmSheet : [];
|
||||||
const workbook = XLSX.read(binaryStr, {type: 'binary', cellDates: true, dateNF: 'yyyy-mm-dd'});
|
|
||||||
teamData = XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]], {header: 1, defval: ""});
|
|
||||||
const pmSheet = workbook.SheetNames[1] ? XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[1]], {header: 1, defval: ""}) : [];
|
|
||||||
buildColumnMap();
|
buildColumnMap();
|
||||||
rebuildMemberTeamMap();
|
rebuildMemberTeamMap();
|
||||||
projectPmMap = new Map();
|
projectPmMap = new Map();
|
||||||
pmSheet.forEach(row => {
|
lastPmSheetData.forEach(row => {
|
||||||
const projectCode = String(row?.[0] || '').trim();
|
const projectCode = String(row?.[0] || '').trim();
|
||||||
const pmName = String(row?.[1] || '').trim();
|
const pmName = String(row?.[1] || '').trim();
|
||||||
if (projectCode && pmName) projectPmMap.set(projectCode, pmName);
|
if (projectCode && pmName) projectPmMap.set(projectCode, pmName);
|
||||||
@@ -2346,6 +2362,15 @@
|
|||||||
initTeamTabAfterUpload();
|
initTeamTabAfterUpload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadWorkbookBinary(binaryStr) {
|
||||||
|
if (!binaryStr) return;
|
||||||
|
lastUploadedBinary = binaryStr;
|
||||||
|
const workbook = XLSX.read(binaryStr, {type: 'binary', cellDates: true, dateNF: 'yyyy-mm-dd'});
|
||||||
|
const nextTeamData = XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]], {header: 1, defval: ""});
|
||||||
|
const nextPmSheet = workbook.SheetNames[1] ? XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[1]], {header: 1, defval: ""}) : [];
|
||||||
|
applyMhSourceRows(nextTeamData, nextPmSheet);
|
||||||
|
}
|
||||||
|
|
||||||
const fileInput = document.getElementById('file-input');
|
const fileInput = document.getElementById('file-input');
|
||||||
const uploadMhButton = document.getElementById('btn-upload-mh');
|
const uploadMhButton = document.getElementById('btn-upload-mh');
|
||||||
|
|
||||||
@@ -2408,16 +2433,10 @@
|
|||||||
const response = await fetch('/api/integration/mh-source', { credentials: 'same-origin' });
|
const response = await fetch('/api/integration/mh-source', { credentials: 'same-origin' });
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
teamData = Array.isArray(payload.teamData) ? payload.teamData : [];
|
applyMhSourceRows(
|
||||||
buildColumnMap();
|
Array.isArray(payload.teamData) ? payload.teamData : [],
|
||||||
rebuildMemberTeamMap();
|
Array.isArray(payload.pmSheet) ? payload.pmSheet : []
|
||||||
projectPmMap = new Map();
|
);
|
||||||
(Array.isArray(payload.pmSheet) ? payload.pmSheet : []).forEach(row => {
|
|
||||||
const projectCode = String(row?.[0] || '').trim();
|
|
||||||
const pmName = String(row?.[1] || '').trim();
|
|
||||||
if (projectCode && pmName) projectPmMap.set(projectCode, pmName);
|
|
||||||
});
|
|
||||||
initTeamTabAfterUpload();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
@@ -2439,9 +2458,12 @@
|
|||||||
const currentTeam = preserveSelection ? teamSelect.value : '';
|
const currentTeam = preserveSelection ? teamSelect.value : '';
|
||||||
const currentPerson = preserveSelection ? personSelect.value : '';
|
const currentPerson = preserveSelection ? personSelect.value : '';
|
||||||
const scopedRows = getScopedRows();
|
const scopedRows = getScopedRows();
|
||||||
|
const allRows = getScopedRows('all');
|
||||||
|
|
||||||
allTeams = [...new Set(scopedRows.map(r => String(r[columnMap.team] || '').trim()))].filter(Boolean).sort();
|
allTeams = [...new Set(scopedRows.map(r => String(r[columnMap.team] || '').trim()))].filter(Boolean).sort();
|
||||||
allPeopleData = buildScopedPeopleData(scopedRows);
|
allPeopleData = buildScopedPeopleData(scopedRows);
|
||||||
|
searchTeams = [...new Set(allRows.map(r => String(r[columnMap.team] || '').trim()))].filter(Boolean).sort();
|
||||||
|
searchPeopleData = buildScopedPeopleData(allRows);
|
||||||
|
|
||||||
teamSelect.innerHTML = '<option value="">팀 선택</option>' + allTeams.map(t => `<option value="${t}">${t}</option>`).join('');
|
teamSelect.innerHTML = '<option value="">팀 선택</option>' + allTeams.map(t => `<option value="${t}">${t}</option>`).join('');
|
||||||
|
|
||||||
@@ -2463,7 +2485,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setScope(scope, preserveSelection = true) {
|
function setScope(scope, preserveSelection = true) {
|
||||||
currentScope = scope === 'tdc' ? 'tdc' : 'gpd';
|
currentScope = scope === 'gpd' || scope === 'tdc' ? scope : 'all';
|
||||||
syncScopeButtons();
|
syncScopeButtons();
|
||||||
refreshScopedSelections(preserveSelection);
|
refreshScopedSelections(preserveSelection);
|
||||||
render();
|
render();
|
||||||
@@ -2520,9 +2542,11 @@
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const matchedTeams = allTeams.filter(t => t.toLowerCase().includes(val));
|
const matchedTeams = searchTeams.filter(t => t.toLowerCase().includes(val));
|
||||||
|
|
||||||
const matchedPeople = allPeopleData.filter(p => p.name.toLowerCase().includes(val));
|
const matchedPeople = searchPeopleData.filter(p =>
|
||||||
|
p.name.toLowerCase().includes(val) || p.team.toLowerCase().includes(val)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -2589,21 +2613,15 @@
|
|||||||
|
|
||||||
|
|
||||||
if (type === 'team') {
|
if (type === 'team') {
|
||||||
|
setScope(getTeamScope(item.dataset.value), false);
|
||||||
teamSelect.value = item.dataset.value;
|
teamSelect.value = item.dataset.value;
|
||||||
|
|
||||||
updateFilters();
|
updateFilters();
|
||||||
|
|
||||||
personSelect.value = "";
|
personSelect.value = "";
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
setScope(getTeamScope(item.dataset.team), false);
|
||||||
teamSelect.value = item.dataset.team;
|
teamSelect.value = item.dataset.team;
|
||||||
|
|
||||||
updateFilters();
|
updateFilters();
|
||||||
|
|
||||||
personSelect.value = item.dataset.name;
|
personSelect.value = item.dataset.name;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mainSearchInput.value = "";
|
mainSearchInput.value = "";
|
||||||
|
|||||||
@@ -421,6 +421,7 @@ const App = () => {
|
|||||||
if (parts.length < 3) return clean;
|
if (parts.length < 3) return clean;
|
||||||
return `${parts[0]}-${parts[1].padStart(2, '0')}-${parts[2].padStart(2, '0')}`;
|
return `${parts[0]}-${parts[1].padStart(2, '0')}-${parts[2].padStart(2, '0')}`;
|
||||||
};
|
};
|
||||||
|
const getExpenseDate = (row) => norm(getVal(row, ['발행일', '청구일', '발행 일자', '청구 일자'], 2));
|
||||||
|
|
||||||
const processedData = useMemo(() => {
|
const processedData = useMemo(() => {
|
||||||
if (!dataLoaded.expense || !dataLoaded.work) return null;
|
if (!dataLoaded.expense || !dataLoaded.work) return null;
|
||||||
@@ -484,7 +485,8 @@ const App = () => {
|
|||||||
const workerName = norm(getVal(row, ['이름']));
|
const workerName = norm(getVal(row, ['이름']));
|
||||||
const position = norm(getVal(row, ['직책', '직급']));
|
const position = norm(getVal(row, ['직책', '직급']));
|
||||||
const userState = norm(getVal(row, ['user_state', 'User State', 'user state', 'userstate', 'User_State']));
|
const userState = norm(getVal(row, ['user_state', 'User State', 'user state', 'userstate', 'User_State']));
|
||||||
const isWeekend = userState.includes('주말');
|
const weekendFlag = norm(getVal(row, ['주말/지각']));
|
||||||
|
const isWeekend = userState.includes('주말') || weekendFlag.includes('주말');
|
||||||
const isMhSchema = hasNamedHeader(row, '메인업무 프로젝트명') || hasNamedHeader(row, '연장근무 프로젝트명') || hasNamedHeader(row, '연장근무 시간(가공)');
|
const isMhSchema = hasNamedHeader(row, '메인업무 프로젝트명') || hasNamedHeader(row, '연장근무 프로젝트명') || hasNamedHeader(row, '연장근무 시간(가공)');
|
||||||
const importedLabor = pnum(getVal(row, ['산정금액', '인건비']));
|
const importedLabor = pnum(getVal(row, ['산정금액', '인건비']));
|
||||||
const overtimeHoursFromRow = isMhSchema
|
const overtimeHoursFromRow = isMhSchema
|
||||||
@@ -623,7 +625,7 @@ const App = () => {
|
|||||||
return raw.length >= 7 ? raw.slice(0, 7) : raw;
|
return raw.length >= 7 ? raw.slice(0, 7) : raw;
|
||||||
};
|
};
|
||||||
|
|
||||||
const excludedWorkers = new Set(['정태원', '양병홍', '장계석', '장종찬', '김원식', '김형준']);
|
const excludedWorkers = new Set(['정태원', '양병홍', '장종찬', '김형준']);
|
||||||
const selectedProjectKey = normalizeProjectKey(selectedProject === '전체' ? '' : selectedProject);
|
const selectedProjectKey = normalizeProjectKey(selectedProject === '전체' ? '' : selectedProject);
|
||||||
const projectSearchKey = normalizeProjectKey(projectSearch);
|
const projectSearchKey = normalizeProjectKey(projectSearch);
|
||||||
|
|
||||||
@@ -648,7 +650,7 @@ const App = () => {
|
|||||||
const d1FromRow = normalizeD1Category(getVal(item, ['D1', '매출/비매출', '매출비매출'], 14));
|
const d1FromRow = normalizeD1Category(getVal(item, ['D1', '매출/비매출', '매출비매출'], 14));
|
||||||
if (!d1FromRow) return false; // D1(K) empty rows are ignored.
|
if (!d1FromRow) return false; // D1(K) empty rows are ignored.
|
||||||
const info = projectToDepth[pKey] || unknownDepth;
|
const info = projectToDepth[pKey] || unknownDepth;
|
||||||
const issueDate = getVal(item, ['발행일'], 2);
|
const issueDate = getExpenseDate(item);
|
||||||
return (selectedRev === '전체' || info.d1 === selectedRev) &&
|
return (selectedRev === '전체' || info.d1 === selectedRev) &&
|
||||||
(selectedD1 === '전체' || info.d2 === selectedD1) &&
|
(selectedD1 === '전체' || info.d2 === selectedD1) &&
|
||||||
(selectedD2 === '전체' || info.d3 === selectedD2) &&
|
(selectedD2 === '전체' || info.d3 === selectedD2) &&
|
||||||
@@ -667,7 +669,7 @@ const App = () => {
|
|||||||
const baseAllExp = expenseRaw.filter(item => {
|
const baseAllExp = expenseRaw.filter(item => {
|
||||||
const d1FromRow = normalizeD1Category(getVal(item, ['D1', '매출/비매출', '매출비매출'], 14));
|
const d1FromRow = normalizeD1Category(getVal(item, ['D1', '매출/비매출', '매출비매출'], 14));
|
||||||
if (!d1FromRow) return false;
|
if (!d1FromRow) return false;
|
||||||
const issueDate = getVal(item, ['발행일'], 2);
|
const issueDate = getExpenseDate(item);
|
||||||
return isWithinRange(issueDate);
|
return isWithinRange(issueDate);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -996,7 +998,7 @@ const App = () => {
|
|||||||
else if (div === '외주비' || div === '외주') category = '외주비';
|
else if (div === '외주비' || div === '외주') category = '외주비';
|
||||||
else if (div === '제외') return;
|
else if (div === '제외') return;
|
||||||
|
|
||||||
const issueDate = norm(getVal(e, ['발행일', '발행 일자', '일자'], 2));
|
const issueDate = getExpenseDate(e);
|
||||||
expenseDetailByCategory[category].push({
|
expenseDetailByCategory[category].push({
|
||||||
issueMonth: toIssueMonth(issueDate),
|
issueMonth: toIssueMonth(issueDate),
|
||||||
issueDate,
|
issueDate,
|
||||||
|
|||||||
@@ -450,6 +450,7 @@ const App = () => {
|
|||||||
if (parts.length < 3) return clean;
|
if (parts.length < 3) return clean;
|
||||||
return `${parts[0]}-${parts[1].padStart(2, '0')}-${parts[2].padStart(2, '0')}`;
|
return `${parts[0]}-${parts[1].padStart(2, '0')}-${parts[2].padStart(2, '0')}`;
|
||||||
};
|
};
|
||||||
|
const getExpenseDate = (row) => norm(getVal(row, ['발행일', '청구일', '발행 일자', '청구 일자'], 2));
|
||||||
|
|
||||||
const processedData = useMemo(() => {
|
const processedData = useMemo(() => {
|
||||||
if (!dataLoaded.expense || !dataLoaded.work) return null;
|
if (!dataLoaded.expense || !dataLoaded.work) return null;
|
||||||
@@ -513,7 +514,8 @@ const App = () => {
|
|||||||
const workerName = norm(getVal(row, ['이름']));
|
const workerName = norm(getVal(row, ['이름']));
|
||||||
const position = norm(getVal(row, ['직책', '직급']));
|
const position = norm(getVal(row, ['직책', '직급']));
|
||||||
const userState = norm(getVal(row, ['user_state', 'User State', 'user state', 'userstate', 'User_State']));
|
const userState = norm(getVal(row, ['user_state', 'User State', 'user state', 'userstate', 'User_State']));
|
||||||
const isWeekend = userState.includes('주말');
|
const weekendFlag = norm(getVal(row, ['주말/지각']));
|
||||||
|
const isWeekend = userState.includes('주말') || weekendFlag.includes('주말');
|
||||||
const isMhSchema = hasNamedHeader(row, '메인업무 프로젝트명') || hasNamedHeader(row, '연장근무 프로젝트명') || hasNamedHeader(row, '연장근무 시간(가공)');
|
const isMhSchema = hasNamedHeader(row, '메인업무 프로젝트명') || hasNamedHeader(row, '연장근무 프로젝트명') || hasNamedHeader(row, '연장근무 시간(가공)');
|
||||||
const importedLabor = pnum(getVal(row, ['산정금액', '인건비']));
|
const importedLabor = pnum(getVal(row, ['산정금액', '인건비']));
|
||||||
const overtimeHoursFromRow = isMhSchema
|
const overtimeHoursFromRow = isMhSchema
|
||||||
@@ -652,7 +654,7 @@ const App = () => {
|
|||||||
return raw.length >= 7 ? raw.slice(0, 7) : raw;
|
return raw.length >= 7 ? raw.slice(0, 7) : raw;
|
||||||
};
|
};
|
||||||
|
|
||||||
const excludedWorkers = new Set(['정태원', '양병홍', '장계석', '장종찬', '김원식', '김형준']);
|
const excludedWorkers = new Set(['정태원', '양병홍', '장종찬', '김형준']);
|
||||||
const selectedProjectKey = normalizeProjectKey(selectedProject === '전체' ? '' : selectedProject);
|
const selectedProjectKey = normalizeProjectKey(selectedProject === '전체' ? '' : selectedProject);
|
||||||
const projectSearchKey = normalizeProjectKey(projectSearch);
|
const projectSearchKey = normalizeProjectKey(projectSearch);
|
||||||
|
|
||||||
@@ -677,7 +679,7 @@ const App = () => {
|
|||||||
const d1FromRow = normalizeD1Category(getVal(item, ['D1', '매출/비매출', '매출비매출'], 14));
|
const d1FromRow = normalizeD1Category(getVal(item, ['D1', '매출/비매출', '매출비매출'], 14));
|
||||||
if (!d1FromRow) return false; // D1(K) empty rows are ignored.
|
if (!d1FromRow) return false; // D1(K) empty rows are ignored.
|
||||||
const info = projectToDepth[pKey] || unknownDepth;
|
const info = projectToDepth[pKey] || unknownDepth;
|
||||||
const issueDate = getVal(item, ['발행일'], 2);
|
const issueDate = getExpenseDate(item);
|
||||||
return (selectedRev === '전체' || info.d1 === selectedRev) &&
|
return (selectedRev === '전체' || info.d1 === selectedRev) &&
|
||||||
(selectedD1 === '전체' || info.d2 === selectedD1) &&
|
(selectedD1 === '전체' || info.d2 === selectedD1) &&
|
||||||
(selectedD2 === '전체' || info.d3 === selectedD2) &&
|
(selectedD2 === '전체' || info.d3 === selectedD2) &&
|
||||||
@@ -696,7 +698,7 @@ const App = () => {
|
|||||||
const baseAllExp = expenseRaw.filter(item => {
|
const baseAllExp = expenseRaw.filter(item => {
|
||||||
const d1FromRow = normalizeD1Category(getVal(item, ['D1', '매출/비매출', '매출비매출'], 14));
|
const d1FromRow = normalizeD1Category(getVal(item, ['D1', '매출/비매출', '매출비매출'], 14));
|
||||||
if (!d1FromRow) return false;
|
if (!d1FromRow) return false;
|
||||||
const issueDate = getVal(item, ['발행일'], 2);
|
const issueDate = getExpenseDate(item);
|
||||||
return isWithinRange(issueDate);
|
return isWithinRange(issueDate);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1025,7 +1027,7 @@ const App = () => {
|
|||||||
else if (div === '외주비' || div === '외주') category = '외주비';
|
else if (div === '외주비' || div === '외주') category = '외주비';
|
||||||
else if (div === '제외') return;
|
else if (div === '제외') return;
|
||||||
|
|
||||||
const issueDate = norm(getVal(e, ['발행일', '발행 일자', '일자'], 2));
|
const issueDate = getExpenseDate(e);
|
||||||
expenseDetailByCategory[category].push({
|
expenseDetailByCategory[category].push({
|
||||||
issueMonth: toIssueMonth(issueDate),
|
issueMonth: toIssueMonth(issueDate),
|
||||||
issueDate,
|
issueDate,
|
||||||
|
|||||||
931
incoming-files/seat/center_chair_people_map.html
Normal file
931
incoming-files/seat/center_chair_people_map.html
Normal file
@@ -0,0 +1,931 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>center chair people map</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--ink: #152330;
|
||||||
|
--muted: #627286;
|
||||||
|
--paper: rgba(255,255,255,0.86);
|
||||||
|
--line: rgba(21,35,48,0.1);
|
||||||
|
--accent: #0f766e;
|
||||||
|
--bg: #edf2f6;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
|
||||||
|
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
|
||||||
|
}
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
border-radius: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
backdrop-filter: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
color: white;
|
||||||
|
background: linear-gradient(135deg, #0f766e, #115e59);
|
||||||
|
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
|
||||||
|
}
|
||||||
|
button.alt {
|
||||||
|
color: var(--ink);
|
||||||
|
background: rgba(255,255,255,0.9);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.viewer {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.viewer-head {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
left: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255,255,255,0.82);
|
||||||
|
border: 1px solid rgba(255,255,255,0.94);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
|
||||||
|
}
|
||||||
|
.viewer-actions {
|
||||||
|
position: absolute;
|
||||||
|
left: 16px;
|
||||||
|
top: 64px;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.mapper {
|
||||||
|
position: absolute;
|
||||||
|
top: 76px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: min(94vw, 1320px);
|
||||||
|
max-height: min(56vh, 560px);
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 4;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(234, 239, 247, 0.95);
|
||||||
|
border: 1px solid rgba(101, 119, 146, 0.22);
|
||||||
|
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
.hidden-off {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.mapper-head {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid rgba(101,119,146,0.18);
|
||||||
|
font-size: 12px;
|
||||||
|
color: #51607a;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
background: rgba(255,255,255,0.6);
|
||||||
|
}
|
||||||
|
.mapper-head strong {
|
||||||
|
display: block;
|
||||||
|
color: #17243b;
|
||||||
|
font-size: 20px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.mapper-head .alt {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.org-chart {
|
||||||
|
margin: 0;
|
||||||
|
padding: 14px;
|
||||||
|
overflow: auto;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.org-top {
|
||||||
|
margin: 0 auto;
|
||||||
|
width: min(100%, 420px);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(67, 84, 118, 0.25);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.org-top-title {
|
||||||
|
background: #1e2f4d;
|
||||||
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 34px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.1;
|
||||||
|
padding: 16px 12px;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
.org-top-members {
|
||||||
|
padding: 10px;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
background: rgba(255,255,255,0.95);
|
||||||
|
}
|
||||||
|
.org-teams {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, minmax(160px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.org-team {
|
||||||
|
border: 1px solid rgba(110, 126, 152, 0.25);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(255,255,255,0.95);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.org-team h4 {
|
||||||
|
margin: 0;
|
||||||
|
padding: 9px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #21324e;
|
||||||
|
font-weight: 800;
|
||||||
|
border-bottom: 1px solid rgba(110, 126, 152, 0.2);
|
||||||
|
background: rgba(240, 245, 252, 0.96);
|
||||||
|
}
|
||||||
|
.org-members {
|
||||||
|
padding: 7px;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.org-person {
|
||||||
|
border: 1px solid rgba(116, 133, 161, 0.25);
|
||||||
|
background: rgba(255,255,255,0.95);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 120ms ease, border-color 120ms ease;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.org-person.active {
|
||||||
|
border-color: rgba(15,118,110,0.6);
|
||||||
|
background: rgba(15,118,110,0.11);
|
||||||
|
}
|
||||||
|
.org-person.assigned {
|
||||||
|
border-color: rgba(37,99,235,0.5);
|
||||||
|
background: rgba(37,99,235,0.1);
|
||||||
|
}
|
||||||
|
.org-person strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: #15233a;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.org-person small {
|
||||||
|
display: block;
|
||||||
|
color: #5a6a86;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.25;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.mapper {
|
||||||
|
top: 72px;
|
||||||
|
width: min(96vw, 920px);
|
||||||
|
max-height: 58vh;
|
||||||
|
}
|
||||||
|
.viewer-actions {
|
||||||
|
top: 64px;
|
||||||
|
left: 12px;
|
||||||
|
right: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.mapper-head strong {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.org-top-title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.org-teams {
|
||||||
|
grid-template-columns: repeat(3, minmax(150px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
canvas.dragging { cursor: grabbing; }
|
||||||
|
.tooltip {
|
||||||
|
position: absolute;
|
||||||
|
min-width: 170px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(17,24,39,0.94);
|
||||||
|
color: white;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(12px, 12px);
|
||||||
|
transition: opacity 120ms ease;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.tooltip.visible { opacity: 1; }
|
||||||
|
.tooltip strong { display: block; margin-bottom: 6px; font-size: 14px; }
|
||||||
|
.tooltip div { font-size: 12px; line-height: 1.45; color: rgba(255,255,255,0.82); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<div class="shell">
|
||||||
|
<main class="panel viewer">
|
||||||
|
<div class="viewer-head">
|
||||||
|
<div class="chip" id="scale-chip"></div>
|
||||||
|
<div class="chip" id="hover-chip">chair hover: none</div>
|
||||||
|
</div>
|
||||||
|
<div class="viewer-actions">
|
||||||
|
<button type="button" id="fit-btn">전체 맞춤</button>
|
||||||
|
<button type="button" class="alt" id="clear-btn">선택 지우기</button>
|
||||||
|
</div>
|
||||||
|
<aside class="mapper hidden-off">
|
||||||
|
<div class="mapper-head">
|
||||||
|
<div id="mapper-status">
|
||||||
|
<strong>조직 현황</strong>
|
||||||
|
<span>선택 인원 없음</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="alt" id="clear-assign-btn">매칭 초기화</button>
|
||||||
|
</div>
|
||||||
|
<div class="org-chart" id="org-chart"></div>
|
||||||
|
</aside>
|
||||||
|
<canvas id="canvas"></canvas>
|
||||||
|
<div class="tooltip" id="tooltip"></div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="./center_chair_people_payload.js"></script>
|
||||||
|
<script>
|
||||||
|
const DATA = window.CHAIR_MAP_DATA;
|
||||||
|
function decodeSegments(base64) {
|
||||||
|
const binary = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||||
|
return new Int32Array(bytes.buffer);
|
||||||
|
}
|
||||||
|
const bgTileRanges = DATA.bgTileRanges;
|
||||||
|
const bgSegValues = decodeSegments(DATA.bgSegsB64);
|
||||||
|
const chairSegValues = decodeSegments(DATA.chairSegsB64);
|
||||||
|
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
|
||||||
|
key, name, kind, start, count
|
||||||
|
}));
|
||||||
|
const meta = DATA.meta;
|
||||||
|
const world = meta.headerBounds;
|
||||||
|
const canvas = document.getElementById("canvas");
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
const tooltip = document.getElementById("tooltip");
|
||||||
|
const scaleChip = document.getElementById("scale-chip");
|
||||||
|
const hoverChip = document.getElementById("hover-chip");
|
||||||
|
const STORAGE_KEY = "ptc-chair-selection";
|
||||||
|
const PEOPLE_STORAGE_KEY = "ptc-chair-people";
|
||||||
|
const ASSIGN_STORAGE_KEY = "ptc-chair-assignments";
|
||||||
|
const ACTIVE_PERSON_STORAGE_KEY = "ptc-chair-active-person";
|
||||||
|
const clearAssignBtn = document.getElementById("clear-assign-btn");
|
||||||
|
const orgChartEl = document.getElementById("org-chart");
|
||||||
|
const mapperStatus = document.getElementById("mapper-status");
|
||||||
|
// Prevent stale auto-highlights from previous sessions.
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
|
||||||
|
localStorage.removeItem(ASSIGN_STORAGE_KEY);
|
||||||
|
const placed = new Set();
|
||||||
|
let people = JSON.parse(localStorage.getItem(PEOPLE_STORAGE_KEY) || "[]");
|
||||||
|
let chairAssignments = {};
|
||||||
|
let activePersonId = null;
|
||||||
|
const ORG_TEMPLATE = {
|
||||||
|
top: {
|
||||||
|
name: "총괄기획실",
|
||||||
|
count: 53,
|
||||||
|
members: [
|
||||||
|
{ name: "장종찬", dept: "총괄기획실", title: "기획실장" },
|
||||||
|
{ name: "김원식", dept: "총괄기획실", title: "전무이사" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
teams: [
|
||||||
|
{ name: "경영기획팀", count: 6, members: ["김우진", "임민정", "국혜린", "최선아", "김윤재", "이미영"] },
|
||||||
|
{ name: "인재성장팀", count: 5, members: ["조태희", "최근혜", "류원준", "주안기", "정성호"] },
|
||||||
|
{ name: "ERP 기획팀", count: 5, members: ["류호성", "문형식", "최요제", "황대일", "이채봉"] },
|
||||||
|
{ name: "디자인기획팀", count: 17, members: ["신혜영", "정은혜", "김태식", "최예은", "채선영", "최영환", "윤봄이", "이예진", "허유나", "마희연", "김수현", "박지영", "권순호", "정두휘", "김정석", "정지윤", "양숙영"] },
|
||||||
|
{ name: "기술기획팀", count: 11, members: ["김원기", "홍아름", "이경민", "김혜인", "황동환", "최찬호", "이태훈", "김신지", "조찬영", "김용연", "한치영"] },
|
||||||
|
{ name: "협업증진팀", count: 3, members: ["성형일", "박주한", "한승민"] },
|
||||||
|
{ name: "솔루션통합팀", count: 4, members: ["권혁진", "염승호", "윤준수", "김지영"] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const chairGeometry = chairs.map((chair) => {
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
const path = new Path2D();
|
||||||
|
const hitSegments = new Float32Array(chair.count * 4);
|
||||||
|
let segCursor = 0;
|
||||||
|
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
|
||||||
|
const offset = i * 4;
|
||||||
|
const x1 = chairSegValues[offset] / 10;
|
||||||
|
const y1 = chairSegValues[offset + 1] / 10;
|
||||||
|
const x2 = chairSegValues[offset + 2] / 10;
|
||||||
|
const y2 = chairSegValues[offset + 3] / 10;
|
||||||
|
path.moveTo(x1, y1);
|
||||||
|
path.lineTo(x2, y2);
|
||||||
|
hitSegments[segCursor] = x1;
|
||||||
|
hitSegments[segCursor + 1] = y1;
|
||||||
|
hitSegments[segCursor + 2] = x2;
|
||||||
|
hitSegments[segCursor + 3] = y2;
|
||||||
|
segCursor += 4;
|
||||||
|
minX = Math.min(minX, x1, x2);
|
||||||
|
minY = Math.min(minY, y1, y2);
|
||||||
|
maxX = Math.max(maxX, x1, x2);
|
||||||
|
maxY = Math.max(maxY, y1, y2);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...chair,
|
||||||
|
minX,
|
||||||
|
minY,
|
||||||
|
maxX,
|
||||||
|
maxY,
|
||||||
|
area: Math.max(1, (maxX - minX) * (maxY - minY)),
|
||||||
|
path,
|
||||||
|
hitSegments,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
function renumberChairKeys(chairItems) {
|
||||||
|
if (!chairItems.length) return;
|
||||||
|
const heights = chairItems
|
||||||
|
.map((chair) => Math.max(1, chair.maxY - chair.minY))
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
const medianHeight = heights[Math.floor(heights.length / 2)] || 1;
|
||||||
|
const rowTolerance = Math.max(40, medianHeight * 0.9);
|
||||||
|
|
||||||
|
const sorted = [...chairItems].sort((a, b) => {
|
||||||
|
const ay = (a.minY + a.maxY) * 0.5;
|
||||||
|
const by = (b.minY + b.maxY) * 0.5;
|
||||||
|
if (Math.abs(by - ay) > rowTolerance) return by - ay; // top -> bottom
|
||||||
|
const ax = (a.minX + a.maxX) * 0.5;
|
||||||
|
const bx = (b.minX + b.maxX) * 0.5;
|
||||||
|
return ax - bx; // left -> right
|
||||||
|
});
|
||||||
|
|
||||||
|
sorted.forEach((chair, index) => {
|
||||||
|
chair.key = String(index + 1);
|
||||||
|
chair.seatNo = index + 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
renumberChairKeys(chairGeometry);
|
||||||
|
const PICK_GRID_SIZE = 1800;
|
||||||
|
const chairPickGrid = new Map();
|
||||||
|
function pickGridKey(gx, gy) {
|
||||||
|
return `${gx},${gy}`;
|
||||||
|
}
|
||||||
|
chairGeometry.forEach((chair, index) => {
|
||||||
|
const minGX = Math.floor(chair.minX / PICK_GRID_SIZE);
|
||||||
|
const maxGX = Math.floor(chair.maxX / PICK_GRID_SIZE);
|
||||||
|
const minGY = Math.floor(chair.minY / PICK_GRID_SIZE);
|
||||||
|
const maxGY = Math.floor(chair.maxY / PICK_GRID_SIZE);
|
||||||
|
for (let gx = minGX; gx <= maxGX; gx += 1) {
|
||||||
|
for (let gy = minGY; gy <= maxGY; gy += 1) {
|
||||||
|
const key = pickGridKey(gx, gy);
|
||||||
|
if (!chairPickGrid.has(key)) chairPickGrid.set(key, []);
|
||||||
|
chairPickGrid.get(key).push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
|
||||||
|
let pixelRatio = window.devicePixelRatio || 1;
|
||||||
|
let pointer = { x: 0, y: 0 };
|
||||||
|
let dragging = false;
|
||||||
|
let dragStart = null;
|
||||||
|
let hovered = null;
|
||||||
|
let rafPending = false;
|
||||||
|
|
||||||
|
function normalizePeople(raw) {
|
||||||
|
return raw
|
||||||
|
.map((person, index) => {
|
||||||
|
if (!person || !person.name) return null;
|
||||||
|
return {
|
||||||
|
id: person.id || `person-${index + 1}`,
|
||||||
|
name: String(person.name).trim(),
|
||||||
|
dept: String(person.dept || "").trim(),
|
||||||
|
title: String(person.title || "").trim(),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTemplatePeople() {
|
||||||
|
const generated = [];
|
||||||
|
let seq = 1;
|
||||||
|
ORG_TEMPLATE.top.members.forEach((member) => {
|
||||||
|
generated.push({
|
||||||
|
id: `org-${seq++}`,
|
||||||
|
name: member.name,
|
||||||
|
dept: member.dept,
|
||||||
|
title: member.title,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ORG_TEMPLATE.teams.forEach((team) => {
|
||||||
|
team.members.forEach((name) => {
|
||||||
|
generated.push({
|
||||||
|
id: `org-${seq++}`,
|
||||||
|
name,
|
||||||
|
dept: team.name,
|
||||||
|
title: "선임",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return generated;
|
||||||
|
}
|
||||||
|
|
||||||
|
people = normalizePeople(people);
|
||||||
|
const templateReady = people.some((person) => person.name === "장종찬" && person.dept === "총괄기획실");
|
||||||
|
if (!templateReady) {
|
||||||
|
people = createTemplatePeople();
|
||||||
|
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
|
||||||
|
}
|
||||||
|
const chairKeySet = new Set(chairGeometry.map((chair) => chair.key));
|
||||||
|
chairAssignments = Object.fromEntries(
|
||||||
|
Object.entries(chairAssignments).filter(([chairKey, personId]) => (
|
||||||
|
chairKeySet.has(chairKey) && people.some((person) => person.id === personId)
|
||||||
|
))
|
||||||
|
);
|
||||||
|
if (activePersonId && !people.some((person) => person.id === activePersonId)) activePersonId = null;
|
||||||
|
|
||||||
|
function persistPeople() {
|
||||||
|
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistAssignments() {
|
||||||
|
localStorage.setItem(ASSIGN_STORAGE_KEY, JSON.stringify(chairAssignments));
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistActivePerson() {
|
||||||
|
if (!activePersonId) localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
|
||||||
|
else localStorage.setItem(ACTIVE_PERSON_STORAGE_KEY, activePersonId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignmentCount() {
|
||||||
|
return Object.keys(chairAssignments).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPersonById(id) {
|
||||||
|
return people.find((person) => person.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChairByPerson(personId) {
|
||||||
|
for (const [chairKey, assignedPersonId] of Object.entries(chairAssignments)) {
|
||||||
|
if (assignedPersonId === personId) return chairKey;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPeopleList() {
|
||||||
|
const activePerson = getPersonById(activePersonId);
|
||||||
|
const countText = `${assignmentCount()} / ${people.length} 매칭`;
|
||||||
|
mapperStatus.innerHTML = `<strong>조직 현황</strong><span>${activePerson ? `${activePerson.name} 선택됨` : "선택 인원 없음"} · ${countText}</span>`;
|
||||||
|
|
||||||
|
const findPerson = (dept, name) => people.find((person) => person.dept === dept && person.name === name) || null;
|
||||||
|
const personCard = (person, roleText) => {
|
||||||
|
if (!person) return "";
|
||||||
|
const chairKey = getChairByPerson(person.id);
|
||||||
|
const assignedClass = chairKey ? " assigned" : "";
|
||||||
|
const activeClass = person.id === activePersonId ? " active" : "";
|
||||||
|
return `
|
||||||
|
<article class="org-person${assignedClass}${activeClass}" data-person-id="${person.id}">
|
||||||
|
<strong>${person.name}</strong>
|
||||||
|
<small>${person.title || roleText || "-"}</small>
|
||||||
|
<small>${chairKey ? `좌석 ${chairKey}` : "좌석 미지정"}</small>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const topHtml = ORG_TEMPLATE.top.members
|
||||||
|
.map((member) => personCard(findPerson(member.dept, member.name), member.title))
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const teamsHtml = ORG_TEMPLATE.teams.map((team) => {
|
||||||
|
const membersHtml = team.members
|
||||||
|
.map((name) => personCard(findPerson(team.name, name), "선임"))
|
||||||
|
.join("");
|
||||||
|
return `
|
||||||
|
<section class="org-team">
|
||||||
|
<h4>${team.name} (${team.count})</h4>
|
||||||
|
<div class="org-members">${membersHtml}</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
orgChartEl.innerHTML = `
|
||||||
|
<section class="org-top">
|
||||||
|
<div class="org-top-title">${ORG_TEMPLATE.top.name} (${ORG_TEMPLATE.top.count})</div>
|
||||||
|
<div class="org-top-members">${topHtml}</div>
|
||||||
|
</section>
|
||||||
|
<section class="org-teams">${teamsHtml}</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function worldToScreen(x, y) {
|
||||||
|
return {
|
||||||
|
x: x * camera.scale + camera.offsetX,
|
||||||
|
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function screenToWorld(x, y) {
|
||||||
|
return {
|
||||||
|
x: (x - camera.offsetX) / camera.scale,
|
||||||
|
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
pixelRatio = window.devicePixelRatio || 1;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
canvas.width = Math.round(rect.width * pixelRatio);
|
||||||
|
canvas.height = Math.round(rect.height * pixelRatio);
|
||||||
|
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||||
|
fit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fit() {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const width = world.maxX - world.minX;
|
||||||
|
const height = world.maxY - world.minY;
|
||||||
|
const pad = 36;
|
||||||
|
const scaleX = (rect.width - pad * 2) / width;
|
||||||
|
const scaleY = (rect.height - pad * 2) / height;
|
||||||
|
camera.scale = Math.min(scaleX, scaleY);
|
||||||
|
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
|
||||||
|
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
|
||||||
|
requestDraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawGrid(width, height) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.strokeStyle = "rgba(21,35,48,0.05)";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let x = 120; x < width; x += 120) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, 0);
|
||||||
|
ctx.lineTo(x, height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let y = 120; y < height; y += 120) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y);
|
||||||
|
ctx.lineTo(width, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickChair(screenX, screenY) {
|
||||||
|
const threshold = 12;
|
||||||
|
const pointerWorld = screenToWorld(screenX, screenY);
|
||||||
|
const thresholdWorld = threshold / camera.scale;
|
||||||
|
const thresholdWorldSq = thresholdWorld * thresholdWorld;
|
||||||
|
const minGX = Math.floor((pointerWorld.x - thresholdWorld) / PICK_GRID_SIZE);
|
||||||
|
const maxGX = Math.floor((pointerWorld.x + thresholdWorld) / PICK_GRID_SIZE);
|
||||||
|
const minGY = Math.floor((pointerWorld.y - thresholdWorld) / PICK_GRID_SIZE);
|
||||||
|
const maxGY = Math.floor((pointerWorld.y + thresholdWorld) / PICK_GRID_SIZE);
|
||||||
|
const candidateIndexes = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (let gx = minGX; gx <= maxGX; gx += 1) {
|
||||||
|
for (let gy = minGY; gy <= maxGY; gy += 1) {
|
||||||
|
const candidates = chairPickGrid.get(pickGridKey(gx, gy));
|
||||||
|
if (!candidates) continue;
|
||||||
|
for (const index of candidates) {
|
||||||
|
if (seen.has(index)) continue;
|
||||||
|
seen.add(index);
|
||||||
|
candidateIndexes.push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let best = null;
|
||||||
|
for (const index of candidateIndexes) {
|
||||||
|
const chair = chairGeometry[index];
|
||||||
|
if (
|
||||||
|
pointerWorld.x < chair.minX - thresholdWorld ||
|
||||||
|
pointerWorld.x > chair.maxX + thresholdWorld ||
|
||||||
|
pointerWorld.y < chair.minY - thresholdWorld ||
|
||||||
|
pointerWorld.y > chair.maxY + thresholdWorld
|
||||||
|
) continue;
|
||||||
|
let distSq = Infinity;
|
||||||
|
for (let i = 0; i < chair.hitSegments.length; i += 4) {
|
||||||
|
const x1 = chair.hitSegments[i];
|
||||||
|
const y1 = chair.hitSegments[i + 1];
|
||||||
|
const x2 = chair.hitSegments[i + 2];
|
||||||
|
const y2 = chair.hitSegments[i + 3];
|
||||||
|
const dx = x2 - x1;
|
||||||
|
const dy = y2 - y1;
|
||||||
|
const len2 = dx * dx + dy * dy;
|
||||||
|
let segDistSq;
|
||||||
|
if (len2 === 0) {
|
||||||
|
const px = pointerWorld.x - x1;
|
||||||
|
const py = pointerWorld.y - y1;
|
||||||
|
segDistSq = px * px + py * py;
|
||||||
|
} else {
|
||||||
|
let t = ((pointerWorld.x - x1) * dx + (pointerWorld.y - y1) * dy) / len2;
|
||||||
|
t = Math.max(0, Math.min(1, t));
|
||||||
|
const lx = x1 + t * dx;
|
||||||
|
const ly = y1 + t * dy;
|
||||||
|
const px = pointerWorld.x - lx;
|
||||||
|
const py = pointerWorld.y - ly;
|
||||||
|
segDistSq = px * px + py * py;
|
||||||
|
}
|
||||||
|
if (segDistSq < distSq) distSq = segDistSq;
|
||||||
|
if (distSq <= thresholdWorldSq * 0.3) break;
|
||||||
|
}
|
||||||
|
if (distSq > thresholdWorldSq) continue;
|
||||||
|
const dist = Math.sqrt(distSq) * camera.scale;
|
||||||
|
|
||||||
|
if (!best) {
|
||||||
|
best = { chair, dist };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const distGap = dist - best.dist;
|
||||||
|
if (distGap < -0.75) {
|
||||||
|
best = { chair, dist };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(distGap) <= 2) {
|
||||||
|
const areaGap = chair.area - best.chair.area;
|
||||||
|
if (areaGap < -1) {
|
||||||
|
best = { chair, dist };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
Math.abs(areaGap) <= 1 &&
|
||||||
|
chair.kind === "block" &&
|
||||||
|
best.chair.kind !== "block"
|
||||||
|
) {
|
||||||
|
best = { chair, dist };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best ? best.chair : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTooltip() {
|
||||||
|
if (!hovered) {
|
||||||
|
tooltip.classList.remove("visible");
|
||||||
|
hoverChip.textContent = "chair hover: none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hoverChip.textContent = `chair hover: ${hovered.name}`;
|
||||||
|
tooltip.innerHTML = `
|
||||||
|
<strong>${hovered.name}</strong>
|
||||||
|
<div>chair key: ${hovered.key}</div>
|
||||||
|
<div>${placed.has(hovered.key) ? "선택됨" : "클릭하면 선택"}</div>
|
||||||
|
<div>${chairAssignments[hovered.key] ? `배치: ${(getPersonById(chairAssignments[hovered.key]) || { name: "알수없음" }).name}` : "배치 인원 없음"}</div>
|
||||||
|
`;
|
||||||
|
tooltip.style.left = `${pointer.x + 14}px`;
|
||||||
|
tooltip.style.top = `${pointer.y + 14}px`;
|
||||||
|
tooltip.classList.add("visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDraw() {
|
||||||
|
if (rafPending) return;
|
||||||
|
rafPending = true;
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
rafPending = false;
|
||||||
|
draw();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyWorldTransform() {
|
||||||
|
ctx.setTransform(
|
||||||
|
pixelRatio * camera.scale,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
-pixelRatio * camera.scale,
|
||||||
|
pixelRatio * camera.offsetX,
|
||||||
|
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||||
|
ctx.clearRect(0, 0, rect.width, rect.height);
|
||||||
|
drawGrid(rect.width, rect.height);
|
||||||
|
const viewA = screenToWorld(0, rect.height);
|
||||||
|
const viewB = screenToWorld(rect.width, 0);
|
||||||
|
const viewMinX = Math.min(viewA.x, viewB.x);
|
||||||
|
const viewMaxX = Math.max(viewA.x, viewB.x);
|
||||||
|
const viewMinY = Math.min(viewA.y, viewB.y);
|
||||||
|
const viewMaxY = Math.max(viewA.y, viewB.y);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
applyWorldTransform();
|
||||||
|
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
|
||||||
|
ctx.lineWidth = 1 / camera.scale;
|
||||||
|
const tileSize = meta.backgroundTileSize;
|
||||||
|
const tileMinX = Math.floor(viewMinX / tileSize);
|
||||||
|
const tileMaxX = Math.floor(viewMaxX / tileSize);
|
||||||
|
const tileMinY = Math.floor(viewMinY / tileSize);
|
||||||
|
const tileMaxY = Math.floor(viewMaxY / tileSize);
|
||||||
|
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
|
||||||
|
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
|
||||||
|
const range = bgTileRanges[`${tx},${ty}`];
|
||||||
|
if (!range) continue;
|
||||||
|
const start = range[0];
|
||||||
|
const count = range[1];
|
||||||
|
for (let i = start; i < start + count; i += 1) {
|
||||||
|
const offset = i * 4;
|
||||||
|
const x1 = bgSegValues[offset] / 10;
|
||||||
|
const y1 = bgSegValues[offset + 1] / 10;
|
||||||
|
const x2 = bgSegValues[offset + 2] / 10;
|
||||||
|
const y2 = bgSegValues[offset + 3] / 10;
|
||||||
|
if (
|
||||||
|
Math.max(x1, x2) < viewMinX ||
|
||||||
|
Math.min(x1, x2) > viewMaxX ||
|
||||||
|
Math.max(y1, y2) < viewMinY ||
|
||||||
|
Math.min(y1, y2) > viewMaxY
|
||||||
|
) continue;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x1, y1);
|
||||||
|
ctx.lineTo(x2, y2);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
hovered = dragging ? null : pickChair(pointer.x, pointer.y);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
applyWorldTransform();
|
||||||
|
ctx.lineWidth = 1.45 / camera.scale;
|
||||||
|
ctx.lineCap = "round";
|
||||||
|
ctx.lineJoin = "round";
|
||||||
|
for (const chair of chairGeometry) {
|
||||||
|
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
|
||||||
|
const active = hovered && hovered.key === chair.key;
|
||||||
|
const selected = placed.has(chair.key);
|
||||||
|
const assignedPersonId = chairAssignments[chair.key];
|
||||||
|
const activePersonChair = activePersonId && assignedPersonId === activePersonId;
|
||||||
|
const assigned = Boolean(assignedPersonId);
|
||||||
|
const baseWidth = chair.kind === "block" ? 1.45 : 1.35;
|
||||||
|
ctx.strokeStyle = activePersonChair
|
||||||
|
? "rgba(234, 179, 8, 1)"
|
||||||
|
: assigned
|
||||||
|
? "rgba(37, 99, 235, 0.98)"
|
||||||
|
: selected
|
||||||
|
? "rgba(220, 38, 38, 0.98)"
|
||||||
|
: active
|
||||||
|
? "rgba(15, 118, 110, 0.98)"
|
||||||
|
: chair.kind === "group"
|
||||||
|
? "rgba(16, 134, 149, 0.74)"
|
||||||
|
: "rgba(21, 149, 142, 0.8)";
|
||||||
|
ctx.lineWidth = (activePersonChair ? 2.8 : assigned ? 2.4 : selected ? 2.6 : active ? 2.1 : baseWidth) / camera.scale;
|
||||||
|
ctx.stroke(chair.path);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
|
||||||
|
renderTooltip();
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistPlaced() {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.addEventListener("pointerdown", (event) => {
|
||||||
|
dragging = true;
|
||||||
|
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
|
||||||
|
canvas.classList.add("dragging");
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("pointerup", (event) => {
|
||||||
|
if (dragging && dragStart) {
|
||||||
|
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
|
||||||
|
if (move < 4) {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top);
|
||||||
|
if (picked) {
|
||||||
|
if (placed.has(picked.key)) placed.delete(picked.key);
|
||||||
|
else placed.add(picked.key);
|
||||||
|
persistPlaced();
|
||||||
|
if (activePersonId) {
|
||||||
|
const currentChair = getChairByPerson(activePersonId);
|
||||||
|
if (chairAssignments[picked.key] === activePersonId) {
|
||||||
|
delete chairAssignments[picked.key];
|
||||||
|
} else {
|
||||||
|
if (currentChair && currentChair !== picked.key) delete chairAssignments[currentChair];
|
||||||
|
chairAssignments[picked.key] = activePersonId;
|
||||||
|
}
|
||||||
|
persistAssignments();
|
||||||
|
renderPeopleList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dragging = false;
|
||||||
|
dragStart = null;
|
||||||
|
canvas.classList.remove("dragging");
|
||||||
|
requestDraw();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("pointermove", (event) => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
||||||
|
if (dragging && dragStart) {
|
||||||
|
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
|
||||||
|
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
|
||||||
|
}
|
||||||
|
requestDraw();
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.addEventListener("wheel", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const mx = event.clientX - rect.left;
|
||||||
|
const my = event.clientY - rect.top;
|
||||||
|
const before = screenToWorld(mx, my);
|
||||||
|
const factor = event.deltaY < 0 ? 1.08 : 0.92;
|
||||||
|
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
|
||||||
|
const after = worldToScreen(before.x, before.y);
|
||||||
|
camera.offsetX += mx - after.x;
|
||||||
|
camera.offsetY += my - after.y;
|
||||||
|
requestDraw();
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
document.getElementById("fit-btn").addEventListener("click", fit);
|
||||||
|
document.getElementById("clear-btn").addEventListener("click", () => {
|
||||||
|
placed.clear();
|
||||||
|
persistPlaced();
|
||||||
|
requestDraw();
|
||||||
|
});
|
||||||
|
clearAssignBtn.addEventListener("click", () => {
|
||||||
|
chairAssignments = {};
|
||||||
|
persistAssignments();
|
||||||
|
renderPeopleList();
|
||||||
|
requestDraw();
|
||||||
|
});
|
||||||
|
orgChartEl.addEventListener("click", (event) => {
|
||||||
|
const item = event.target.closest(".org-person[data-person-id]");
|
||||||
|
if (!item) return;
|
||||||
|
const personId = item.getAttribute("data-person-id");
|
||||||
|
activePersonId = personId === activePersonId ? null : personId;
|
||||||
|
persistActivePerson();
|
||||||
|
renderPeopleList();
|
||||||
|
requestDraw();
|
||||||
|
});
|
||||||
|
window.addEventListener("resize", resize);
|
||||||
|
renderPeopleList();
|
||||||
|
resize();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
932
incoming-files/seat/center_chair_people_map_6f.html
Normal file
932
incoming-files/seat/center_chair_people_map_6f.html
Normal file
@@ -0,0 +1,932 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>center chair people map 6f</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--ink: #152330;
|
||||||
|
--muted: #627286;
|
||||||
|
--paper: rgba(255,255,255,0.86);
|
||||||
|
--line: rgba(21,35,48,0.1);
|
||||||
|
--accent: #0f766e;
|
||||||
|
--bg: #edf2f6;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
|
||||||
|
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
|
||||||
|
}
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
border-radius: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
backdrop-filter: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
color: white;
|
||||||
|
background: linear-gradient(135deg, #0f766e, #115e59);
|
||||||
|
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
|
||||||
|
}
|
||||||
|
button.alt {
|
||||||
|
color: var(--ink);
|
||||||
|
background: rgba(255,255,255,0.9);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.viewer {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.viewer-head {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
left: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255,255,255,0.82);
|
||||||
|
border: 1px solid rgba(255,255,255,0.94);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
|
||||||
|
}
|
||||||
|
.viewer-actions {
|
||||||
|
position: absolute;
|
||||||
|
left: 16px;
|
||||||
|
top: 64px;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.mapper {
|
||||||
|
position: absolute;
|
||||||
|
top: 76px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: min(94vw, 1320px);
|
||||||
|
max-height: min(56vh, 560px);
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 4;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(234, 239, 247, 0.95);
|
||||||
|
border: 1px solid rgba(101, 119, 146, 0.22);
|
||||||
|
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
.hidden-off {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.mapper-head {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid rgba(101,119,146,0.18);
|
||||||
|
font-size: 12px;
|
||||||
|
color: #51607a;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
background: rgba(255,255,255,0.6);
|
||||||
|
}
|
||||||
|
.mapper-head strong {
|
||||||
|
display: block;
|
||||||
|
color: #17243b;
|
||||||
|
font-size: 20px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.mapper-head .alt {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.org-chart {
|
||||||
|
margin: 0;
|
||||||
|
padding: 14px;
|
||||||
|
overflow: auto;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.org-top {
|
||||||
|
margin: 0 auto;
|
||||||
|
width: min(100%, 420px);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(67, 84, 118, 0.25);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.org-top-title {
|
||||||
|
background: #1e2f4d;
|
||||||
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 34px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.1;
|
||||||
|
padding: 16px 12px;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
.org-top-members {
|
||||||
|
padding: 10px;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
background: rgba(255,255,255,0.95);
|
||||||
|
}
|
||||||
|
.org-teams {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, minmax(160px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.org-team {
|
||||||
|
border: 1px solid rgba(110, 126, 152, 0.25);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(255,255,255,0.95);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.org-team h4 {
|
||||||
|
margin: 0;
|
||||||
|
padding: 9px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #21324e;
|
||||||
|
font-weight: 800;
|
||||||
|
border-bottom: 1px solid rgba(110, 126, 152, 0.2);
|
||||||
|
background: rgba(240, 245, 252, 0.96);
|
||||||
|
}
|
||||||
|
.org-members {
|
||||||
|
padding: 7px;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.org-person {
|
||||||
|
border: 1px solid rgba(116, 133, 161, 0.25);
|
||||||
|
background: rgba(255,255,255,0.95);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 120ms ease, border-color 120ms ease;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.org-person.active {
|
||||||
|
border-color: rgba(15,118,110,0.6);
|
||||||
|
background: rgba(15,118,110,0.11);
|
||||||
|
}
|
||||||
|
.org-person.assigned {
|
||||||
|
border-color: rgba(37,99,235,0.5);
|
||||||
|
background: rgba(37,99,235,0.1);
|
||||||
|
}
|
||||||
|
.org-person strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: #15233a;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.org-person small {
|
||||||
|
display: block;
|
||||||
|
color: #5a6a86;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.25;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.mapper {
|
||||||
|
top: 72px;
|
||||||
|
width: min(96vw, 920px);
|
||||||
|
max-height: 58vh;
|
||||||
|
}
|
||||||
|
.viewer-actions {
|
||||||
|
top: 64px;
|
||||||
|
left: 12px;
|
||||||
|
right: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.mapper-head strong {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.org-top-title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.org-teams {
|
||||||
|
grid-template-columns: repeat(3, minmax(150px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
canvas.dragging { cursor: grabbing; }
|
||||||
|
.tooltip {
|
||||||
|
position: absolute;
|
||||||
|
min-width: 170px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(17,24,39,0.94);
|
||||||
|
color: white;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(12px, 12px);
|
||||||
|
transition: opacity 120ms ease;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.tooltip.visible { opacity: 1; }
|
||||||
|
.tooltip strong { display: block; margin-bottom: 6px; font-size: 14px; }
|
||||||
|
.tooltip div { font-size: 12px; line-height: 1.45; color: rgba(255,255,255,0.82); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<div class="shell">
|
||||||
|
<main class="panel viewer">
|
||||||
|
<div class="viewer-head">
|
||||||
|
<div class="chip" id="scale-chip"></div>
|
||||||
|
<div class="chip" id="hover-chip">chair hover: none</div>
|
||||||
|
</div>
|
||||||
|
<div class="viewer-actions">
|
||||||
|
<button type="button" id="fit-btn">전체 맞춤</button>
|
||||||
|
<button type="button" class="alt" id="clear-btn">선택 지우기</button>
|
||||||
|
</div>
|
||||||
|
<aside class="mapper hidden-off">
|
||||||
|
<div class="mapper-head">
|
||||||
|
<div id="mapper-status">
|
||||||
|
<strong>조직 현황</strong>
|
||||||
|
<span>선택 인원 없음</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="alt" id="clear-assign-btn">매칭 초기화</button>
|
||||||
|
</div>
|
||||||
|
<div class="org-chart" id="org-chart"></div>
|
||||||
|
</aside>
|
||||||
|
<canvas id="canvas"></canvas>
|
||||||
|
<div class="tooltip" id="tooltip"></div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="./center_chair_people_payload_6f.js"></script>
|
||||||
|
<script>
|
||||||
|
const DATA = window.CHAIR_MAP_DATA;
|
||||||
|
function decodeSegments(base64) {
|
||||||
|
const binary = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||||
|
return new Int32Array(bytes.buffer);
|
||||||
|
}
|
||||||
|
const bgTileRanges = DATA.bgTileRanges;
|
||||||
|
const bgSegValues = decodeSegments(DATA.bgSegsB64);
|
||||||
|
const chairSegValues = decodeSegments(DATA.chairSegsB64);
|
||||||
|
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
|
||||||
|
key, name, kind, start, count
|
||||||
|
}));
|
||||||
|
const meta = DATA.meta;
|
||||||
|
const world = meta.headerBounds;
|
||||||
|
const canvas = document.getElementById("canvas");
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
const tooltip = document.getElementById("tooltip");
|
||||||
|
const scaleChip = document.getElementById("scale-chip");
|
||||||
|
const hoverChip = document.getElementById("hover-chip");
|
||||||
|
const STORAGE_KEY = "ptc-chair-selection";
|
||||||
|
const PEOPLE_STORAGE_KEY = "ptc-chair-people";
|
||||||
|
const ASSIGN_STORAGE_KEY = "ptc-chair-assignments";
|
||||||
|
const ACTIVE_PERSON_STORAGE_KEY = "ptc-chair-active-person";
|
||||||
|
const clearAssignBtn = document.getElementById("clear-assign-btn");
|
||||||
|
const orgChartEl = document.getElementById("org-chart");
|
||||||
|
const mapperStatus = document.getElementById("mapper-status");
|
||||||
|
// Prevent stale auto-highlights from previous sessions.
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
|
||||||
|
localStorage.removeItem(ASSIGN_STORAGE_KEY);
|
||||||
|
const placed = new Set();
|
||||||
|
let people = JSON.parse(localStorage.getItem(PEOPLE_STORAGE_KEY) || "[]");
|
||||||
|
let chairAssignments = {};
|
||||||
|
let activePersonId = null;
|
||||||
|
const ORG_TEMPLATE = {
|
||||||
|
top: {
|
||||||
|
name: "총괄기획실",
|
||||||
|
count: 53,
|
||||||
|
members: [
|
||||||
|
{ name: "장종찬", dept: "총괄기획실", title: "기획실장" },
|
||||||
|
{ name: "김원식", dept: "총괄기획실", title: "전무이사" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
teams: [
|
||||||
|
{ name: "경영기획팀", count: 6, members: ["김우진", "임민정", "국혜린", "최선아", "김윤재", "이미영"] },
|
||||||
|
{ name: "인재성장팀", count: 5, members: ["조태희", "최근혜", "류원준", "주안기", "정성호"] },
|
||||||
|
{ name: "ERP 기획팀", count: 5, members: ["류호성", "문형식", "최요제", "황대일", "이채봉"] },
|
||||||
|
{ name: "디자인기획팀", count: 17, members: ["신혜영", "정은혜", "김태식", "최예은", "채선영", "최영환", "윤봄이", "이예진", "허유나", "마희연", "김수현", "박지영", "권순호", "정두휘", "김정석", "정지윤", "양숙영"] },
|
||||||
|
{ name: "기술기획팀", count: 11, members: ["김원기", "홍아름", "이경민", "김혜인", "황동환", "최찬호", "이태훈", "김신지", "조찬영", "김용연", "한치영"] },
|
||||||
|
{ name: "협업증진팀", count: 3, members: ["성형일", "박주한", "한승민"] },
|
||||||
|
{ name: "솔루션통합팀", count: 4, members: ["권혁진", "염승호", "윤준수", "김지영"] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const chairGeometry = chairs.map((chair) => {
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
const path = new Path2D();
|
||||||
|
const hitSegments = new Float32Array(chair.count * 4);
|
||||||
|
let segCursor = 0;
|
||||||
|
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
|
||||||
|
const offset = i * 4;
|
||||||
|
const x1 = chairSegValues[offset] / 10;
|
||||||
|
const y1 = chairSegValues[offset + 1] / 10;
|
||||||
|
const x2 = chairSegValues[offset + 2] / 10;
|
||||||
|
const y2 = chairSegValues[offset + 3] / 10;
|
||||||
|
path.moveTo(x1, y1);
|
||||||
|
path.lineTo(x2, y2);
|
||||||
|
hitSegments[segCursor] = x1;
|
||||||
|
hitSegments[segCursor + 1] = y1;
|
||||||
|
hitSegments[segCursor + 2] = x2;
|
||||||
|
hitSegments[segCursor + 3] = y2;
|
||||||
|
segCursor += 4;
|
||||||
|
minX = Math.min(minX, x1, x2);
|
||||||
|
minY = Math.min(minY, y1, y2);
|
||||||
|
maxX = Math.max(maxX, x1, x2);
|
||||||
|
maxY = Math.max(maxY, y1, y2);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...chair,
|
||||||
|
minX,
|
||||||
|
minY,
|
||||||
|
maxX,
|
||||||
|
maxY,
|
||||||
|
area: Math.max(1, (maxX - minX) * (maxY - minY)),
|
||||||
|
path,
|
||||||
|
hitSegments,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
function renumberChairKeys(chairItems) {
|
||||||
|
if (!chairItems.length) return;
|
||||||
|
const heights = chairItems
|
||||||
|
.map((chair) => Math.max(1, chair.maxY - chair.minY))
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
const medianHeight = heights[Math.floor(heights.length / 2)] || 1;
|
||||||
|
const rowTolerance = Math.max(40, medianHeight * 0.9);
|
||||||
|
|
||||||
|
const sorted = [...chairItems].sort((a, b) => {
|
||||||
|
const ay = (a.minY + a.maxY) * 0.5;
|
||||||
|
const by = (b.minY + b.maxY) * 0.5;
|
||||||
|
if (Math.abs(by - ay) > rowTolerance) return by - ay; // top -> bottom
|
||||||
|
const ax = (a.minX + a.maxX) * 0.5;
|
||||||
|
const bx = (b.minX + b.maxX) * 0.5;
|
||||||
|
return ax - bx; // left -> right
|
||||||
|
});
|
||||||
|
|
||||||
|
sorted.forEach((chair, index) => {
|
||||||
|
chair.key = String(index + 1);
|
||||||
|
chair.seatNo = index + 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
renumberChairKeys(chairGeometry);
|
||||||
|
const PICK_GRID_SIZE = 1800;
|
||||||
|
const chairPickGrid = new Map();
|
||||||
|
function pickGridKey(gx, gy) {
|
||||||
|
return `${gx},${gy}`;
|
||||||
|
}
|
||||||
|
chairGeometry.forEach((chair, index) => {
|
||||||
|
const minGX = Math.floor(chair.minX / PICK_GRID_SIZE);
|
||||||
|
const maxGX = Math.floor(chair.maxX / PICK_GRID_SIZE);
|
||||||
|
const minGY = Math.floor(chair.minY / PICK_GRID_SIZE);
|
||||||
|
const maxGY = Math.floor(chair.maxY / PICK_GRID_SIZE);
|
||||||
|
for (let gx = minGX; gx <= maxGX; gx += 1) {
|
||||||
|
for (let gy = minGY; gy <= maxGY; gy += 1) {
|
||||||
|
const key = pickGridKey(gx, gy);
|
||||||
|
if (!chairPickGrid.has(key)) chairPickGrid.set(key, []);
|
||||||
|
chairPickGrid.get(key).push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
|
||||||
|
let pixelRatio = window.devicePixelRatio || 1;
|
||||||
|
let pointer = { x: 0, y: 0 };
|
||||||
|
let dragging = false;
|
||||||
|
let dragStart = null;
|
||||||
|
let hovered = null;
|
||||||
|
let rafPending = false;
|
||||||
|
|
||||||
|
function normalizePeople(raw) {
|
||||||
|
return raw
|
||||||
|
.map((person, index) => {
|
||||||
|
if (!person || !person.name) return null;
|
||||||
|
return {
|
||||||
|
id: person.id || `person-${index + 1}`,
|
||||||
|
name: String(person.name).trim(),
|
||||||
|
dept: String(person.dept || "").trim(),
|
||||||
|
title: String(person.title || "").trim(),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTemplatePeople() {
|
||||||
|
const generated = [];
|
||||||
|
let seq = 1;
|
||||||
|
ORG_TEMPLATE.top.members.forEach((member) => {
|
||||||
|
generated.push({
|
||||||
|
id: `org-${seq++}`,
|
||||||
|
name: member.name,
|
||||||
|
dept: member.dept,
|
||||||
|
title: member.title,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ORG_TEMPLATE.teams.forEach((team) => {
|
||||||
|
team.members.forEach((name) => {
|
||||||
|
generated.push({
|
||||||
|
id: `org-${seq++}`,
|
||||||
|
name,
|
||||||
|
dept: team.name,
|
||||||
|
title: "선임",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return generated;
|
||||||
|
}
|
||||||
|
|
||||||
|
people = normalizePeople(people);
|
||||||
|
const templateReady = people.some((person) => person.name === "장종찬" && person.dept === "총괄기획실");
|
||||||
|
if (!templateReady) {
|
||||||
|
people = createTemplatePeople();
|
||||||
|
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
|
||||||
|
}
|
||||||
|
const chairKeySet = new Set(chairGeometry.map((chair) => chair.key));
|
||||||
|
chairAssignments = Object.fromEntries(
|
||||||
|
Object.entries(chairAssignments).filter(([chairKey, personId]) => (
|
||||||
|
chairKeySet.has(chairKey) && people.some((person) => person.id === personId)
|
||||||
|
))
|
||||||
|
);
|
||||||
|
if (activePersonId && !people.some((person) => person.id === activePersonId)) activePersonId = null;
|
||||||
|
|
||||||
|
function persistPeople() {
|
||||||
|
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistAssignments() {
|
||||||
|
localStorage.setItem(ASSIGN_STORAGE_KEY, JSON.stringify(chairAssignments));
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistActivePerson() {
|
||||||
|
if (!activePersonId) localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
|
||||||
|
else localStorage.setItem(ACTIVE_PERSON_STORAGE_KEY, activePersonId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignmentCount() {
|
||||||
|
return Object.keys(chairAssignments).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPersonById(id) {
|
||||||
|
return people.find((person) => person.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChairByPerson(personId) {
|
||||||
|
for (const [chairKey, assignedPersonId] of Object.entries(chairAssignments)) {
|
||||||
|
if (assignedPersonId === personId) return chairKey;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPeopleList() {
|
||||||
|
const activePerson = getPersonById(activePersonId);
|
||||||
|
const countText = `${assignmentCount()} / ${people.length} 매칭`;
|
||||||
|
mapperStatus.innerHTML = `<strong>조직 현황</strong><span>${activePerson ? `${activePerson.name} 선택됨` : "선택 인원 없음"} · ${countText}</span>`;
|
||||||
|
|
||||||
|
const findPerson = (dept, name) => people.find((person) => person.dept === dept && person.name === name) || null;
|
||||||
|
const personCard = (person, roleText) => {
|
||||||
|
if (!person) return "";
|
||||||
|
const chairKey = getChairByPerson(person.id);
|
||||||
|
const assignedClass = chairKey ? " assigned" : "";
|
||||||
|
const activeClass = person.id === activePersonId ? " active" : "";
|
||||||
|
return `
|
||||||
|
<article class="org-person${assignedClass}${activeClass}" data-person-id="${person.id}">
|
||||||
|
<strong>${person.name}</strong>
|
||||||
|
<small>${person.title || roleText || "-"}</small>
|
||||||
|
<small>${chairKey ? `좌석 ${chairKey}` : "좌석 미지정"}</small>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const topHtml = ORG_TEMPLATE.top.members
|
||||||
|
.map((member) => personCard(findPerson(member.dept, member.name), member.title))
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const teamsHtml = ORG_TEMPLATE.teams.map((team) => {
|
||||||
|
const membersHtml = team.members
|
||||||
|
.map((name) => personCard(findPerson(team.name, name), "선임"))
|
||||||
|
.join("");
|
||||||
|
return `
|
||||||
|
<section class="org-team">
|
||||||
|
<h4>${team.name} (${team.count})</h4>
|
||||||
|
<div class="org-members">${membersHtml}</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
orgChartEl.innerHTML = `
|
||||||
|
<section class="org-top">
|
||||||
|
<div class="org-top-title">${ORG_TEMPLATE.top.name} (${ORG_TEMPLATE.top.count})</div>
|
||||||
|
<div class="org-top-members">${topHtml}</div>
|
||||||
|
</section>
|
||||||
|
<section class="org-teams">${teamsHtml}</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function worldToScreen(x, y) {
|
||||||
|
return {
|
||||||
|
x: x * camera.scale + camera.offsetX,
|
||||||
|
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function screenToWorld(x, y) {
|
||||||
|
return {
|
||||||
|
x: (x - camera.offsetX) / camera.scale,
|
||||||
|
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
pixelRatio = window.devicePixelRatio || 1;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
canvas.width = Math.round(rect.width * pixelRatio);
|
||||||
|
canvas.height = Math.round(rect.height * pixelRatio);
|
||||||
|
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||||
|
fit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fit() {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const width = world.maxX - world.minX;
|
||||||
|
const height = world.maxY - world.minY;
|
||||||
|
const pad = 36;
|
||||||
|
const scaleX = (rect.width - pad * 2) / width;
|
||||||
|
const scaleY = (rect.height - pad * 2) / height;
|
||||||
|
camera.scale = Math.min(scaleX, scaleY);
|
||||||
|
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
|
||||||
|
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
|
||||||
|
requestDraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawGrid(width, height) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.strokeStyle = "rgba(21,35,48,0.05)";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let x = 120; x < width; x += 120) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, 0);
|
||||||
|
ctx.lineTo(x, height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let y = 120; y < height; y += 120) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y);
|
||||||
|
ctx.lineTo(width, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickChair(screenX, screenY) {
|
||||||
|
const threshold = 12;
|
||||||
|
const pointerWorld = screenToWorld(screenX, screenY);
|
||||||
|
const thresholdWorld = threshold / camera.scale;
|
||||||
|
const thresholdWorldSq = thresholdWorld * thresholdWorld;
|
||||||
|
const minGX = Math.floor((pointerWorld.x - thresholdWorld) / PICK_GRID_SIZE);
|
||||||
|
const maxGX = Math.floor((pointerWorld.x + thresholdWorld) / PICK_GRID_SIZE);
|
||||||
|
const minGY = Math.floor((pointerWorld.y - thresholdWorld) / PICK_GRID_SIZE);
|
||||||
|
const maxGY = Math.floor((pointerWorld.y + thresholdWorld) / PICK_GRID_SIZE);
|
||||||
|
const candidateIndexes = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (let gx = minGX; gx <= maxGX; gx += 1) {
|
||||||
|
for (let gy = minGY; gy <= maxGY; gy += 1) {
|
||||||
|
const candidates = chairPickGrid.get(pickGridKey(gx, gy));
|
||||||
|
if (!candidates) continue;
|
||||||
|
for (const index of candidates) {
|
||||||
|
if (seen.has(index)) continue;
|
||||||
|
seen.add(index);
|
||||||
|
candidateIndexes.push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let best = null;
|
||||||
|
for (const index of candidateIndexes) {
|
||||||
|
const chair = chairGeometry[index];
|
||||||
|
if (
|
||||||
|
pointerWorld.x < chair.minX - thresholdWorld ||
|
||||||
|
pointerWorld.x > chair.maxX + thresholdWorld ||
|
||||||
|
pointerWorld.y < chair.minY - thresholdWorld ||
|
||||||
|
pointerWorld.y > chair.maxY + thresholdWorld
|
||||||
|
) continue;
|
||||||
|
let distSq = Infinity;
|
||||||
|
for (let i = 0; i < chair.hitSegments.length; i += 4) {
|
||||||
|
const x1 = chair.hitSegments[i];
|
||||||
|
const y1 = chair.hitSegments[i + 1];
|
||||||
|
const x2 = chair.hitSegments[i + 2];
|
||||||
|
const y2 = chair.hitSegments[i + 3];
|
||||||
|
const dx = x2 - x1;
|
||||||
|
const dy = y2 - y1;
|
||||||
|
const len2 = dx * dx + dy * dy;
|
||||||
|
let segDistSq;
|
||||||
|
if (len2 === 0) {
|
||||||
|
const px = pointerWorld.x - x1;
|
||||||
|
const py = pointerWorld.y - y1;
|
||||||
|
segDistSq = px * px + py * py;
|
||||||
|
} else {
|
||||||
|
let t = ((pointerWorld.x - x1) * dx + (pointerWorld.y - y1) * dy) / len2;
|
||||||
|
t = Math.max(0, Math.min(1, t));
|
||||||
|
const lx = x1 + t * dx;
|
||||||
|
const ly = y1 + t * dy;
|
||||||
|
const px = pointerWorld.x - lx;
|
||||||
|
const py = pointerWorld.y - ly;
|
||||||
|
segDistSq = px * px + py * py;
|
||||||
|
}
|
||||||
|
if (segDistSq < distSq) distSq = segDistSq;
|
||||||
|
if (distSq <= thresholdWorldSq * 0.3) break;
|
||||||
|
}
|
||||||
|
if (distSq > thresholdWorldSq) continue;
|
||||||
|
const dist = Math.sqrt(distSq) * camera.scale;
|
||||||
|
|
||||||
|
if (!best) {
|
||||||
|
best = { chair, dist };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const distGap = dist - best.dist;
|
||||||
|
if (distGap < -0.75) {
|
||||||
|
best = { chair, dist };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(distGap) <= 2) {
|
||||||
|
const areaGap = chair.area - best.chair.area;
|
||||||
|
if (areaGap < -1) {
|
||||||
|
best = { chair, dist };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
Math.abs(areaGap) <= 1 &&
|
||||||
|
chair.kind === "block" &&
|
||||||
|
best.chair.kind !== "block"
|
||||||
|
) {
|
||||||
|
best = { chair, dist };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best ? best.chair : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTooltip() {
|
||||||
|
if (!hovered) {
|
||||||
|
tooltip.classList.remove("visible");
|
||||||
|
hoverChip.textContent = "chair hover: none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hoverChip.textContent = `chair hover: ${hovered.name}`;
|
||||||
|
tooltip.innerHTML = `
|
||||||
|
<strong>${hovered.name}</strong>
|
||||||
|
<div>chair key: ${hovered.key}</div>
|
||||||
|
<div>${placed.has(hovered.key) ? "선택됨" : "클릭하면 선택"}</div>
|
||||||
|
<div>${chairAssignments[hovered.key] ? `배치: ${(getPersonById(chairAssignments[hovered.key]) || { name: "알수없음" }).name}` : "배치 인원 없음"}</div>
|
||||||
|
`;
|
||||||
|
tooltip.style.left = `${pointer.x + 14}px`;
|
||||||
|
tooltip.style.top = `${pointer.y + 14}px`;
|
||||||
|
tooltip.classList.add("visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDraw() {
|
||||||
|
if (rafPending) return;
|
||||||
|
rafPending = true;
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
rafPending = false;
|
||||||
|
draw();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyWorldTransform() {
|
||||||
|
ctx.setTransform(
|
||||||
|
pixelRatio * camera.scale,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
-pixelRatio * camera.scale,
|
||||||
|
pixelRatio * camera.offsetX,
|
||||||
|
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||||
|
ctx.clearRect(0, 0, rect.width, rect.height);
|
||||||
|
drawGrid(rect.width, rect.height);
|
||||||
|
const viewA = screenToWorld(0, rect.height);
|
||||||
|
const viewB = screenToWorld(rect.width, 0);
|
||||||
|
const viewMinX = Math.min(viewA.x, viewB.x);
|
||||||
|
const viewMaxX = Math.max(viewA.x, viewB.x);
|
||||||
|
const viewMinY = Math.min(viewA.y, viewB.y);
|
||||||
|
const viewMaxY = Math.max(viewA.y, viewB.y);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
applyWorldTransform();
|
||||||
|
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
|
||||||
|
ctx.lineWidth = 1 / camera.scale;
|
||||||
|
const tileSize = meta.backgroundTileSize;
|
||||||
|
const tileMinX = Math.floor(viewMinX / tileSize);
|
||||||
|
const tileMaxX = Math.floor(viewMaxX / tileSize);
|
||||||
|
const tileMinY = Math.floor(viewMinY / tileSize);
|
||||||
|
const tileMaxY = Math.floor(viewMaxY / tileSize);
|
||||||
|
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
|
||||||
|
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
|
||||||
|
const range = bgTileRanges[`${tx},${ty}`];
|
||||||
|
if (!range) continue;
|
||||||
|
const start = range[0];
|
||||||
|
const count = range[1];
|
||||||
|
for (let i = start; i < start + count; i += 1) {
|
||||||
|
const offset = i * 4;
|
||||||
|
const x1 = bgSegValues[offset] / 10;
|
||||||
|
const y1 = bgSegValues[offset + 1] / 10;
|
||||||
|
const x2 = bgSegValues[offset + 2] / 10;
|
||||||
|
const y2 = bgSegValues[offset + 3] / 10;
|
||||||
|
if (
|
||||||
|
Math.max(x1, x2) < viewMinX ||
|
||||||
|
Math.min(x1, x2) > viewMaxX ||
|
||||||
|
Math.max(y1, y2) < viewMinY ||
|
||||||
|
Math.min(y1, y2) > viewMaxY
|
||||||
|
) continue;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x1, y1);
|
||||||
|
ctx.lineTo(x2, y2);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
hovered = dragging ? null : pickChair(pointer.x, pointer.y);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
applyWorldTransform();
|
||||||
|
ctx.lineWidth = 1.45 / camera.scale;
|
||||||
|
ctx.lineCap = "round";
|
||||||
|
ctx.lineJoin = "round";
|
||||||
|
for (const chair of chairGeometry) {
|
||||||
|
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
|
||||||
|
const active = hovered && hovered.key === chair.key;
|
||||||
|
const selected = placed.has(chair.key);
|
||||||
|
const assignedPersonId = chairAssignments[chair.key];
|
||||||
|
const activePersonChair = activePersonId && assignedPersonId === activePersonId;
|
||||||
|
const assigned = Boolean(assignedPersonId);
|
||||||
|
const baseWidth = chair.kind === "block" ? 1.45 : 1.35;
|
||||||
|
ctx.strokeStyle = activePersonChair
|
||||||
|
? "rgba(234, 179, 8, 1)"
|
||||||
|
: assigned
|
||||||
|
? "rgba(37, 99, 235, 0.98)"
|
||||||
|
: selected
|
||||||
|
? "rgba(220, 38, 38, 0.98)"
|
||||||
|
: active
|
||||||
|
? "rgba(15, 118, 110, 0.98)"
|
||||||
|
: chair.kind === "group"
|
||||||
|
? "rgba(16, 134, 149, 0.74)"
|
||||||
|
: "rgba(21, 149, 142, 0.8)";
|
||||||
|
ctx.lineWidth = (activePersonChair ? 2.8 : assigned ? 2.4 : selected ? 2.6 : active ? 2.1 : baseWidth) / camera.scale;
|
||||||
|
ctx.stroke(chair.path);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
|
||||||
|
renderTooltip();
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistPlaced() {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.addEventListener("pointerdown", (event) => {
|
||||||
|
dragging = true;
|
||||||
|
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
|
||||||
|
canvas.classList.add("dragging");
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("pointerup", (event) => {
|
||||||
|
if (dragging && dragStart) {
|
||||||
|
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
|
||||||
|
if (move < 4) {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top);
|
||||||
|
if (picked) {
|
||||||
|
if (placed.has(picked.key)) placed.delete(picked.key);
|
||||||
|
else placed.add(picked.key);
|
||||||
|
persistPlaced();
|
||||||
|
if (activePersonId) {
|
||||||
|
const currentChair = getChairByPerson(activePersonId);
|
||||||
|
if (chairAssignments[picked.key] === activePersonId) {
|
||||||
|
delete chairAssignments[picked.key];
|
||||||
|
} else {
|
||||||
|
if (currentChair && currentChair !== picked.key) delete chairAssignments[currentChair];
|
||||||
|
chairAssignments[picked.key] = activePersonId;
|
||||||
|
}
|
||||||
|
persistAssignments();
|
||||||
|
renderPeopleList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dragging = false;
|
||||||
|
dragStart = null;
|
||||||
|
canvas.classList.remove("dragging");
|
||||||
|
requestDraw();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("pointermove", (event) => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
||||||
|
if (dragging && dragStart) {
|
||||||
|
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
|
||||||
|
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
|
||||||
|
}
|
||||||
|
requestDraw();
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.addEventListener("wheel", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const mx = event.clientX - rect.left;
|
||||||
|
const my = event.clientY - rect.top;
|
||||||
|
const before = screenToWorld(mx, my);
|
||||||
|
const factor = event.deltaY < 0 ? 1.08 : 0.92;
|
||||||
|
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
|
||||||
|
const after = worldToScreen(before.x, before.y);
|
||||||
|
camera.offsetX += mx - after.x;
|
||||||
|
camera.offsetY += my - after.y;
|
||||||
|
requestDraw();
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
document.getElementById("fit-btn").addEventListener("click", fit);
|
||||||
|
document.getElementById("clear-btn").addEventListener("click", () => {
|
||||||
|
placed.clear();
|
||||||
|
persistPlaced();
|
||||||
|
requestDraw();
|
||||||
|
});
|
||||||
|
clearAssignBtn.addEventListener("click", () => {
|
||||||
|
chairAssignments = {};
|
||||||
|
persistAssignments();
|
||||||
|
renderPeopleList();
|
||||||
|
requestDraw();
|
||||||
|
});
|
||||||
|
orgChartEl.addEventListener("click", (event) => {
|
||||||
|
const item = event.target.closest(".org-person[data-person-id]");
|
||||||
|
if (!item) return;
|
||||||
|
const personId = item.getAttribute("data-person-id");
|
||||||
|
activePersonId = personId === activePersonId ? null : personId;
|
||||||
|
persistActivePerson();
|
||||||
|
renderPeopleList();
|
||||||
|
requestDraw();
|
||||||
|
});
|
||||||
|
window.addEventListener("resize", resize);
|
||||||
|
renderPeopleList();
|
||||||
|
resize();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
932
incoming-files/seat/center_chair_people_map_7f.html
Normal file
932
incoming-files/seat/center_chair_people_map_7f.html
Normal file
@@ -0,0 +1,932 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>center chair people map 7f</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--ink: #152330;
|
||||||
|
--muted: #627286;
|
||||||
|
--paper: rgba(255,255,255,0.86);
|
||||||
|
--line: rgba(21,35,48,0.1);
|
||||||
|
--accent: #0f766e;
|
||||||
|
--bg: #edf2f6;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
|
||||||
|
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
|
||||||
|
}
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
border-radius: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
backdrop-filter: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
color: white;
|
||||||
|
background: linear-gradient(135deg, #0f766e, #115e59);
|
||||||
|
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
|
||||||
|
}
|
||||||
|
button.alt {
|
||||||
|
color: var(--ink);
|
||||||
|
background: rgba(255,255,255,0.9);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.viewer {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.viewer-head {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
left: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255,255,255,0.82);
|
||||||
|
border: 1px solid rgba(255,255,255,0.94);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
|
||||||
|
}
|
||||||
|
.viewer-actions {
|
||||||
|
position: absolute;
|
||||||
|
left: 16px;
|
||||||
|
top: 64px;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.mapper {
|
||||||
|
position: absolute;
|
||||||
|
top: 76px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: min(94vw, 1320px);
|
||||||
|
max-height: min(56vh, 560px);
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 4;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(234, 239, 247, 0.95);
|
||||||
|
border: 1px solid rgba(101, 119, 146, 0.22);
|
||||||
|
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
.hidden-off {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.mapper-head {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid rgba(101,119,146,0.18);
|
||||||
|
font-size: 12px;
|
||||||
|
color: #51607a;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
background: rgba(255,255,255,0.6);
|
||||||
|
}
|
||||||
|
.mapper-head strong {
|
||||||
|
display: block;
|
||||||
|
color: #17243b;
|
||||||
|
font-size: 20px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.mapper-head .alt {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.org-chart {
|
||||||
|
margin: 0;
|
||||||
|
padding: 14px;
|
||||||
|
overflow: auto;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.org-top {
|
||||||
|
margin: 0 auto;
|
||||||
|
width: min(100%, 420px);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(67, 84, 118, 0.25);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.org-top-title {
|
||||||
|
background: #1e2f4d;
|
||||||
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 34px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.1;
|
||||||
|
padding: 16px 12px;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
.org-top-members {
|
||||||
|
padding: 10px;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
background: rgba(255,255,255,0.95);
|
||||||
|
}
|
||||||
|
.org-teams {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, minmax(160px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.org-team {
|
||||||
|
border: 1px solid rgba(110, 126, 152, 0.25);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(255,255,255,0.95);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.org-team h4 {
|
||||||
|
margin: 0;
|
||||||
|
padding: 9px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #21324e;
|
||||||
|
font-weight: 800;
|
||||||
|
border-bottom: 1px solid rgba(110, 126, 152, 0.2);
|
||||||
|
background: rgba(240, 245, 252, 0.96);
|
||||||
|
}
|
||||||
|
.org-members {
|
||||||
|
padding: 7px;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.org-person {
|
||||||
|
border: 1px solid rgba(116, 133, 161, 0.25);
|
||||||
|
background: rgba(255,255,255,0.95);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 120ms ease, border-color 120ms ease;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.org-person.active {
|
||||||
|
border-color: rgba(15,118,110,0.6);
|
||||||
|
background: rgba(15,118,110,0.11);
|
||||||
|
}
|
||||||
|
.org-person.assigned {
|
||||||
|
border-color: rgba(37,99,235,0.5);
|
||||||
|
background: rgba(37,99,235,0.1);
|
||||||
|
}
|
||||||
|
.org-person strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: #15233a;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.org-person small {
|
||||||
|
display: block;
|
||||||
|
color: #5a6a86;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.25;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.mapper {
|
||||||
|
top: 72px;
|
||||||
|
width: min(96vw, 920px);
|
||||||
|
max-height: 58vh;
|
||||||
|
}
|
||||||
|
.viewer-actions {
|
||||||
|
top: 64px;
|
||||||
|
left: 12px;
|
||||||
|
right: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.mapper-head strong {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.org-top-title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.org-teams {
|
||||||
|
grid-template-columns: repeat(3, minmax(150px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
canvas.dragging { cursor: grabbing; }
|
||||||
|
.tooltip {
|
||||||
|
position: absolute;
|
||||||
|
min-width: 170px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(17,24,39,0.94);
|
||||||
|
color: white;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(12px, 12px);
|
||||||
|
transition: opacity 120ms ease;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.tooltip.visible { opacity: 1; }
|
||||||
|
.tooltip strong { display: block; margin-bottom: 6px; font-size: 14px; }
|
||||||
|
.tooltip div { font-size: 12px; line-height: 1.45; color: rgba(255,255,255,0.82); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<div class="shell">
|
||||||
|
<main class="panel viewer">
|
||||||
|
<div class="viewer-head">
|
||||||
|
<div class="chip" id="scale-chip"></div>
|
||||||
|
<div class="chip" id="hover-chip">chair hover: none</div>
|
||||||
|
</div>
|
||||||
|
<div class="viewer-actions">
|
||||||
|
<button type="button" id="fit-btn">전체 맞춤</button>
|
||||||
|
<button type="button" class="alt" id="clear-btn">선택 지우기</button>
|
||||||
|
</div>
|
||||||
|
<aside class="mapper hidden-off">
|
||||||
|
<div class="mapper-head">
|
||||||
|
<div id="mapper-status">
|
||||||
|
<strong>조직 현황</strong>
|
||||||
|
<span>선택 인원 없음</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="alt" id="clear-assign-btn">매칭 초기화</button>
|
||||||
|
</div>
|
||||||
|
<div class="org-chart" id="org-chart"></div>
|
||||||
|
</aside>
|
||||||
|
<canvas id="canvas"></canvas>
|
||||||
|
<div class="tooltip" id="tooltip"></div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="./center_chair_people_payload_7f.js"></script>
|
||||||
|
<script>
|
||||||
|
const DATA = window.CHAIR_MAP_DATA;
|
||||||
|
function decodeSegments(base64) {
|
||||||
|
const binary = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||||
|
return new Int32Array(bytes.buffer);
|
||||||
|
}
|
||||||
|
const bgTileRanges = DATA.bgTileRanges;
|
||||||
|
const bgSegValues = decodeSegments(DATA.bgSegsB64);
|
||||||
|
const chairSegValues = decodeSegments(DATA.chairSegsB64);
|
||||||
|
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
|
||||||
|
key, name, kind, start, count
|
||||||
|
}));
|
||||||
|
const meta = DATA.meta;
|
||||||
|
const world = meta.headerBounds;
|
||||||
|
const canvas = document.getElementById("canvas");
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
const tooltip = document.getElementById("tooltip");
|
||||||
|
const scaleChip = document.getElementById("scale-chip");
|
||||||
|
const hoverChip = document.getElementById("hover-chip");
|
||||||
|
const STORAGE_KEY = "ptc-chair-selection";
|
||||||
|
const PEOPLE_STORAGE_KEY = "ptc-chair-people";
|
||||||
|
const ASSIGN_STORAGE_KEY = "ptc-chair-assignments";
|
||||||
|
const ACTIVE_PERSON_STORAGE_KEY = "ptc-chair-active-person";
|
||||||
|
const clearAssignBtn = document.getElementById("clear-assign-btn");
|
||||||
|
const orgChartEl = document.getElementById("org-chart");
|
||||||
|
const mapperStatus = document.getElementById("mapper-status");
|
||||||
|
// Prevent stale auto-highlights from previous sessions.
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
|
||||||
|
localStorage.removeItem(ASSIGN_STORAGE_KEY);
|
||||||
|
const placed = new Set();
|
||||||
|
let people = JSON.parse(localStorage.getItem(PEOPLE_STORAGE_KEY) || "[]");
|
||||||
|
let chairAssignments = {};
|
||||||
|
let activePersonId = null;
|
||||||
|
const ORG_TEMPLATE = {
|
||||||
|
top: {
|
||||||
|
name: "총괄기획실",
|
||||||
|
count: 53,
|
||||||
|
members: [
|
||||||
|
{ name: "장종찬", dept: "총괄기획실", title: "기획실장" },
|
||||||
|
{ name: "김원식", dept: "총괄기획실", title: "전무이사" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
teams: [
|
||||||
|
{ name: "경영기획팀", count: 6, members: ["김우진", "임민정", "국혜린", "최선아", "김윤재", "이미영"] },
|
||||||
|
{ name: "인재성장팀", count: 5, members: ["조태희", "최근혜", "류원준", "주안기", "정성호"] },
|
||||||
|
{ name: "ERP 기획팀", count: 5, members: ["류호성", "문형식", "최요제", "황대일", "이채봉"] },
|
||||||
|
{ name: "디자인기획팀", count: 17, members: ["신혜영", "정은혜", "김태식", "최예은", "채선영", "최영환", "윤봄이", "이예진", "허유나", "마희연", "김수현", "박지영", "권순호", "정두휘", "김정석", "정지윤", "양숙영"] },
|
||||||
|
{ name: "기술기획팀", count: 11, members: ["김원기", "홍아름", "이경민", "김혜인", "황동환", "최찬호", "이태훈", "김신지", "조찬영", "김용연", "한치영"] },
|
||||||
|
{ name: "협업증진팀", count: 3, members: ["성형일", "박주한", "한승민"] },
|
||||||
|
{ name: "솔루션통합팀", count: 4, members: ["권혁진", "염승호", "윤준수", "김지영"] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const chairGeometry = chairs.map((chair) => {
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
const path = new Path2D();
|
||||||
|
const hitSegments = new Float32Array(chair.count * 4);
|
||||||
|
let segCursor = 0;
|
||||||
|
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
|
||||||
|
const offset = i * 4;
|
||||||
|
const x1 = chairSegValues[offset] / 10;
|
||||||
|
const y1 = chairSegValues[offset + 1] / 10;
|
||||||
|
const x2 = chairSegValues[offset + 2] / 10;
|
||||||
|
const y2 = chairSegValues[offset + 3] / 10;
|
||||||
|
path.moveTo(x1, y1);
|
||||||
|
path.lineTo(x2, y2);
|
||||||
|
hitSegments[segCursor] = x1;
|
||||||
|
hitSegments[segCursor + 1] = y1;
|
||||||
|
hitSegments[segCursor + 2] = x2;
|
||||||
|
hitSegments[segCursor + 3] = y2;
|
||||||
|
segCursor += 4;
|
||||||
|
minX = Math.min(minX, x1, x2);
|
||||||
|
minY = Math.min(minY, y1, y2);
|
||||||
|
maxX = Math.max(maxX, x1, x2);
|
||||||
|
maxY = Math.max(maxY, y1, y2);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...chair,
|
||||||
|
minX,
|
||||||
|
minY,
|
||||||
|
maxX,
|
||||||
|
maxY,
|
||||||
|
area: Math.max(1, (maxX - minX) * (maxY - minY)),
|
||||||
|
path,
|
||||||
|
hitSegments,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
function renumberChairKeys(chairItems) {
|
||||||
|
if (!chairItems.length) return;
|
||||||
|
const heights = chairItems
|
||||||
|
.map((chair) => Math.max(1, chair.maxY - chair.minY))
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
const medianHeight = heights[Math.floor(heights.length / 2)] || 1;
|
||||||
|
const rowTolerance = Math.max(40, medianHeight * 0.9);
|
||||||
|
|
||||||
|
const sorted = [...chairItems].sort((a, b) => {
|
||||||
|
const ay = (a.minY + a.maxY) * 0.5;
|
||||||
|
const by = (b.minY + b.maxY) * 0.5;
|
||||||
|
if (Math.abs(by - ay) > rowTolerance) return by - ay; // top -> bottom
|
||||||
|
const ax = (a.minX + a.maxX) * 0.5;
|
||||||
|
const bx = (b.minX + b.maxX) * 0.5;
|
||||||
|
return ax - bx; // left -> right
|
||||||
|
});
|
||||||
|
|
||||||
|
sorted.forEach((chair, index) => {
|
||||||
|
chair.key = String(index + 1);
|
||||||
|
chair.seatNo = index + 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
renumberChairKeys(chairGeometry);
|
||||||
|
const PICK_GRID_SIZE = 1800;
|
||||||
|
const chairPickGrid = new Map();
|
||||||
|
function pickGridKey(gx, gy) {
|
||||||
|
return `${gx},${gy}`;
|
||||||
|
}
|
||||||
|
chairGeometry.forEach((chair, index) => {
|
||||||
|
const minGX = Math.floor(chair.minX / PICK_GRID_SIZE);
|
||||||
|
const maxGX = Math.floor(chair.maxX / PICK_GRID_SIZE);
|
||||||
|
const minGY = Math.floor(chair.minY / PICK_GRID_SIZE);
|
||||||
|
const maxGY = Math.floor(chair.maxY / PICK_GRID_SIZE);
|
||||||
|
for (let gx = minGX; gx <= maxGX; gx += 1) {
|
||||||
|
for (let gy = minGY; gy <= maxGY; gy += 1) {
|
||||||
|
const key = pickGridKey(gx, gy);
|
||||||
|
if (!chairPickGrid.has(key)) chairPickGrid.set(key, []);
|
||||||
|
chairPickGrid.get(key).push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
|
||||||
|
let pixelRatio = window.devicePixelRatio || 1;
|
||||||
|
let pointer = { x: 0, y: 0 };
|
||||||
|
let dragging = false;
|
||||||
|
let dragStart = null;
|
||||||
|
let hovered = null;
|
||||||
|
let rafPending = false;
|
||||||
|
|
||||||
|
function normalizePeople(raw) {
|
||||||
|
return raw
|
||||||
|
.map((person, index) => {
|
||||||
|
if (!person || !person.name) return null;
|
||||||
|
return {
|
||||||
|
id: person.id || `person-${index + 1}`,
|
||||||
|
name: String(person.name).trim(),
|
||||||
|
dept: String(person.dept || "").trim(),
|
||||||
|
title: String(person.title || "").trim(),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTemplatePeople() {
|
||||||
|
const generated = [];
|
||||||
|
let seq = 1;
|
||||||
|
ORG_TEMPLATE.top.members.forEach((member) => {
|
||||||
|
generated.push({
|
||||||
|
id: `org-${seq++}`,
|
||||||
|
name: member.name,
|
||||||
|
dept: member.dept,
|
||||||
|
title: member.title,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ORG_TEMPLATE.teams.forEach((team) => {
|
||||||
|
team.members.forEach((name) => {
|
||||||
|
generated.push({
|
||||||
|
id: `org-${seq++}`,
|
||||||
|
name,
|
||||||
|
dept: team.name,
|
||||||
|
title: "선임",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return generated;
|
||||||
|
}
|
||||||
|
|
||||||
|
people = normalizePeople(people);
|
||||||
|
const templateReady = people.some((person) => person.name === "장종찬" && person.dept === "총괄기획실");
|
||||||
|
if (!templateReady) {
|
||||||
|
people = createTemplatePeople();
|
||||||
|
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
|
||||||
|
}
|
||||||
|
const chairKeySet = new Set(chairGeometry.map((chair) => chair.key));
|
||||||
|
chairAssignments = Object.fromEntries(
|
||||||
|
Object.entries(chairAssignments).filter(([chairKey, personId]) => (
|
||||||
|
chairKeySet.has(chairKey) && people.some((person) => person.id === personId)
|
||||||
|
))
|
||||||
|
);
|
||||||
|
if (activePersonId && !people.some((person) => person.id === activePersonId)) activePersonId = null;
|
||||||
|
|
||||||
|
function persistPeople() {
|
||||||
|
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistAssignments() {
|
||||||
|
localStorage.setItem(ASSIGN_STORAGE_KEY, JSON.stringify(chairAssignments));
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistActivePerson() {
|
||||||
|
if (!activePersonId) localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
|
||||||
|
else localStorage.setItem(ACTIVE_PERSON_STORAGE_KEY, activePersonId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignmentCount() {
|
||||||
|
return Object.keys(chairAssignments).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPersonById(id) {
|
||||||
|
return people.find((person) => person.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChairByPerson(personId) {
|
||||||
|
for (const [chairKey, assignedPersonId] of Object.entries(chairAssignments)) {
|
||||||
|
if (assignedPersonId === personId) return chairKey;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPeopleList() {
|
||||||
|
const activePerson = getPersonById(activePersonId);
|
||||||
|
const countText = `${assignmentCount()} / ${people.length} 매칭`;
|
||||||
|
mapperStatus.innerHTML = `<strong>조직 현황</strong><span>${activePerson ? `${activePerson.name} 선택됨` : "선택 인원 없음"} · ${countText}</span>`;
|
||||||
|
|
||||||
|
const findPerson = (dept, name) => people.find((person) => person.dept === dept && person.name === name) || null;
|
||||||
|
const personCard = (person, roleText) => {
|
||||||
|
if (!person) return "";
|
||||||
|
const chairKey = getChairByPerson(person.id);
|
||||||
|
const assignedClass = chairKey ? " assigned" : "";
|
||||||
|
const activeClass = person.id === activePersonId ? " active" : "";
|
||||||
|
return `
|
||||||
|
<article class="org-person${assignedClass}${activeClass}" data-person-id="${person.id}">
|
||||||
|
<strong>${person.name}</strong>
|
||||||
|
<small>${person.title || roleText || "-"}</small>
|
||||||
|
<small>${chairKey ? `좌석 ${chairKey}` : "좌석 미지정"}</small>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const topHtml = ORG_TEMPLATE.top.members
|
||||||
|
.map((member) => personCard(findPerson(member.dept, member.name), member.title))
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const teamsHtml = ORG_TEMPLATE.teams.map((team) => {
|
||||||
|
const membersHtml = team.members
|
||||||
|
.map((name) => personCard(findPerson(team.name, name), "선임"))
|
||||||
|
.join("");
|
||||||
|
return `
|
||||||
|
<section class="org-team">
|
||||||
|
<h4>${team.name} (${team.count})</h4>
|
||||||
|
<div class="org-members">${membersHtml}</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
orgChartEl.innerHTML = `
|
||||||
|
<section class="org-top">
|
||||||
|
<div class="org-top-title">${ORG_TEMPLATE.top.name} (${ORG_TEMPLATE.top.count})</div>
|
||||||
|
<div class="org-top-members">${topHtml}</div>
|
||||||
|
</section>
|
||||||
|
<section class="org-teams">${teamsHtml}</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function worldToScreen(x, y) {
|
||||||
|
return {
|
||||||
|
x: x * camera.scale + camera.offsetX,
|
||||||
|
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function screenToWorld(x, y) {
|
||||||
|
return {
|
||||||
|
x: (x - camera.offsetX) / camera.scale,
|
||||||
|
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
pixelRatio = window.devicePixelRatio || 1;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
canvas.width = Math.round(rect.width * pixelRatio);
|
||||||
|
canvas.height = Math.round(rect.height * pixelRatio);
|
||||||
|
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||||
|
fit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fit() {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const width = world.maxX - world.minX;
|
||||||
|
const height = world.maxY - world.minY;
|
||||||
|
const pad = 36;
|
||||||
|
const scaleX = (rect.width - pad * 2) / width;
|
||||||
|
const scaleY = (rect.height - pad * 2) / height;
|
||||||
|
camera.scale = Math.min(scaleX, scaleY);
|
||||||
|
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
|
||||||
|
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
|
||||||
|
requestDraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawGrid(width, height) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.strokeStyle = "rgba(21,35,48,0.05)";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let x = 120; x < width; x += 120) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, 0);
|
||||||
|
ctx.lineTo(x, height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let y = 120; y < height; y += 120) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y);
|
||||||
|
ctx.lineTo(width, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickChair(screenX, screenY) {
|
||||||
|
const threshold = 12;
|
||||||
|
const pointerWorld = screenToWorld(screenX, screenY);
|
||||||
|
const thresholdWorld = threshold / camera.scale;
|
||||||
|
const thresholdWorldSq = thresholdWorld * thresholdWorld;
|
||||||
|
const minGX = Math.floor((pointerWorld.x - thresholdWorld) / PICK_GRID_SIZE);
|
||||||
|
const maxGX = Math.floor((pointerWorld.x + thresholdWorld) / PICK_GRID_SIZE);
|
||||||
|
const minGY = Math.floor((pointerWorld.y - thresholdWorld) / PICK_GRID_SIZE);
|
||||||
|
const maxGY = Math.floor((pointerWorld.y + thresholdWorld) / PICK_GRID_SIZE);
|
||||||
|
const candidateIndexes = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (let gx = minGX; gx <= maxGX; gx += 1) {
|
||||||
|
for (let gy = minGY; gy <= maxGY; gy += 1) {
|
||||||
|
const candidates = chairPickGrid.get(pickGridKey(gx, gy));
|
||||||
|
if (!candidates) continue;
|
||||||
|
for (const index of candidates) {
|
||||||
|
if (seen.has(index)) continue;
|
||||||
|
seen.add(index);
|
||||||
|
candidateIndexes.push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let best = null;
|
||||||
|
for (const index of candidateIndexes) {
|
||||||
|
const chair = chairGeometry[index];
|
||||||
|
if (
|
||||||
|
pointerWorld.x < chair.minX - thresholdWorld ||
|
||||||
|
pointerWorld.x > chair.maxX + thresholdWorld ||
|
||||||
|
pointerWorld.y < chair.minY - thresholdWorld ||
|
||||||
|
pointerWorld.y > chair.maxY + thresholdWorld
|
||||||
|
) continue;
|
||||||
|
let distSq = Infinity;
|
||||||
|
for (let i = 0; i < chair.hitSegments.length; i += 4) {
|
||||||
|
const x1 = chair.hitSegments[i];
|
||||||
|
const y1 = chair.hitSegments[i + 1];
|
||||||
|
const x2 = chair.hitSegments[i + 2];
|
||||||
|
const y2 = chair.hitSegments[i + 3];
|
||||||
|
const dx = x2 - x1;
|
||||||
|
const dy = y2 - y1;
|
||||||
|
const len2 = dx * dx + dy * dy;
|
||||||
|
let segDistSq;
|
||||||
|
if (len2 === 0) {
|
||||||
|
const px = pointerWorld.x - x1;
|
||||||
|
const py = pointerWorld.y - y1;
|
||||||
|
segDistSq = px * px + py * py;
|
||||||
|
} else {
|
||||||
|
let t = ((pointerWorld.x - x1) * dx + (pointerWorld.y - y1) * dy) / len2;
|
||||||
|
t = Math.max(0, Math.min(1, t));
|
||||||
|
const lx = x1 + t * dx;
|
||||||
|
const ly = y1 + t * dy;
|
||||||
|
const px = pointerWorld.x - lx;
|
||||||
|
const py = pointerWorld.y - ly;
|
||||||
|
segDistSq = px * px + py * py;
|
||||||
|
}
|
||||||
|
if (segDistSq < distSq) distSq = segDistSq;
|
||||||
|
if (distSq <= thresholdWorldSq * 0.3) break;
|
||||||
|
}
|
||||||
|
if (distSq > thresholdWorldSq) continue;
|
||||||
|
const dist = Math.sqrt(distSq) * camera.scale;
|
||||||
|
|
||||||
|
if (!best) {
|
||||||
|
best = { chair, dist };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const distGap = dist - best.dist;
|
||||||
|
if (distGap < -0.75) {
|
||||||
|
best = { chair, dist };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(distGap) <= 2) {
|
||||||
|
const areaGap = chair.area - best.chair.area;
|
||||||
|
if (areaGap < -1) {
|
||||||
|
best = { chair, dist };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
Math.abs(areaGap) <= 1 &&
|
||||||
|
chair.kind === "block" &&
|
||||||
|
best.chair.kind !== "block"
|
||||||
|
) {
|
||||||
|
best = { chair, dist };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best ? best.chair : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTooltip() {
|
||||||
|
if (!hovered) {
|
||||||
|
tooltip.classList.remove("visible");
|
||||||
|
hoverChip.textContent = "chair hover: none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hoverChip.textContent = `chair hover: ${hovered.name}`;
|
||||||
|
tooltip.innerHTML = `
|
||||||
|
<strong>${hovered.name}</strong>
|
||||||
|
<div>chair key: ${hovered.key}</div>
|
||||||
|
<div>${placed.has(hovered.key) ? "선택됨" : "클릭하면 선택"}</div>
|
||||||
|
<div>${chairAssignments[hovered.key] ? `배치: ${(getPersonById(chairAssignments[hovered.key]) || { name: "알수없음" }).name}` : "배치 인원 없음"}</div>
|
||||||
|
`;
|
||||||
|
tooltip.style.left = `${pointer.x + 14}px`;
|
||||||
|
tooltip.style.top = `${pointer.y + 14}px`;
|
||||||
|
tooltip.classList.add("visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDraw() {
|
||||||
|
if (rafPending) return;
|
||||||
|
rafPending = true;
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
rafPending = false;
|
||||||
|
draw();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyWorldTransform() {
|
||||||
|
ctx.setTransform(
|
||||||
|
pixelRatio * camera.scale,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
-pixelRatio * camera.scale,
|
||||||
|
pixelRatio * camera.offsetX,
|
||||||
|
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||||
|
ctx.clearRect(0, 0, rect.width, rect.height);
|
||||||
|
drawGrid(rect.width, rect.height);
|
||||||
|
const viewA = screenToWorld(0, rect.height);
|
||||||
|
const viewB = screenToWorld(rect.width, 0);
|
||||||
|
const viewMinX = Math.min(viewA.x, viewB.x);
|
||||||
|
const viewMaxX = Math.max(viewA.x, viewB.x);
|
||||||
|
const viewMinY = Math.min(viewA.y, viewB.y);
|
||||||
|
const viewMaxY = Math.max(viewA.y, viewB.y);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
applyWorldTransform();
|
||||||
|
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
|
||||||
|
ctx.lineWidth = 1 / camera.scale;
|
||||||
|
const tileSize = meta.backgroundTileSize;
|
||||||
|
const tileMinX = Math.floor(viewMinX / tileSize);
|
||||||
|
const tileMaxX = Math.floor(viewMaxX / tileSize);
|
||||||
|
const tileMinY = Math.floor(viewMinY / tileSize);
|
||||||
|
const tileMaxY = Math.floor(viewMaxY / tileSize);
|
||||||
|
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
|
||||||
|
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
|
||||||
|
const range = bgTileRanges[`${tx},${ty}`];
|
||||||
|
if (!range) continue;
|
||||||
|
const start = range[0];
|
||||||
|
const count = range[1];
|
||||||
|
for (let i = start; i < start + count; i += 1) {
|
||||||
|
const offset = i * 4;
|
||||||
|
const x1 = bgSegValues[offset] / 10;
|
||||||
|
const y1 = bgSegValues[offset + 1] / 10;
|
||||||
|
const x2 = bgSegValues[offset + 2] / 10;
|
||||||
|
const y2 = bgSegValues[offset + 3] / 10;
|
||||||
|
if (
|
||||||
|
Math.max(x1, x2) < viewMinX ||
|
||||||
|
Math.min(x1, x2) > viewMaxX ||
|
||||||
|
Math.max(y1, y2) < viewMinY ||
|
||||||
|
Math.min(y1, y2) > viewMaxY
|
||||||
|
) continue;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x1, y1);
|
||||||
|
ctx.lineTo(x2, y2);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
hovered = dragging ? null : pickChair(pointer.x, pointer.y);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
applyWorldTransform();
|
||||||
|
ctx.lineWidth = 1.45 / camera.scale;
|
||||||
|
ctx.lineCap = "round";
|
||||||
|
ctx.lineJoin = "round";
|
||||||
|
for (const chair of chairGeometry) {
|
||||||
|
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
|
||||||
|
const active = hovered && hovered.key === chair.key;
|
||||||
|
const selected = placed.has(chair.key);
|
||||||
|
const assignedPersonId = chairAssignments[chair.key];
|
||||||
|
const activePersonChair = activePersonId && assignedPersonId === activePersonId;
|
||||||
|
const assigned = Boolean(assignedPersonId);
|
||||||
|
const baseWidth = chair.kind === "block" ? 1.45 : 1.35;
|
||||||
|
ctx.strokeStyle = activePersonChair
|
||||||
|
? "rgba(234, 179, 8, 1)"
|
||||||
|
: assigned
|
||||||
|
? "rgba(37, 99, 235, 0.98)"
|
||||||
|
: selected
|
||||||
|
? "rgba(220, 38, 38, 0.98)"
|
||||||
|
: active
|
||||||
|
? "rgba(15, 118, 110, 0.98)"
|
||||||
|
: chair.kind === "group"
|
||||||
|
? "rgba(16, 134, 149, 0.74)"
|
||||||
|
: "rgba(21, 149, 142, 0.8)";
|
||||||
|
ctx.lineWidth = (activePersonChair ? 2.8 : assigned ? 2.4 : selected ? 2.6 : active ? 2.1 : baseWidth) / camera.scale;
|
||||||
|
ctx.stroke(chair.path);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
|
||||||
|
renderTooltip();
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistPlaced() {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.addEventListener("pointerdown", (event) => {
|
||||||
|
dragging = true;
|
||||||
|
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
|
||||||
|
canvas.classList.add("dragging");
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("pointerup", (event) => {
|
||||||
|
if (dragging && dragStart) {
|
||||||
|
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
|
||||||
|
if (move < 4) {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top);
|
||||||
|
if (picked) {
|
||||||
|
if (placed.has(picked.key)) placed.delete(picked.key);
|
||||||
|
else placed.add(picked.key);
|
||||||
|
persistPlaced();
|
||||||
|
if (activePersonId) {
|
||||||
|
const currentChair = getChairByPerson(activePersonId);
|
||||||
|
if (chairAssignments[picked.key] === activePersonId) {
|
||||||
|
delete chairAssignments[picked.key];
|
||||||
|
} else {
|
||||||
|
if (currentChair && currentChair !== picked.key) delete chairAssignments[currentChair];
|
||||||
|
chairAssignments[picked.key] = activePersonId;
|
||||||
|
}
|
||||||
|
persistAssignments();
|
||||||
|
renderPeopleList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dragging = false;
|
||||||
|
dragStart = null;
|
||||||
|
canvas.classList.remove("dragging");
|
||||||
|
requestDraw();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("pointermove", (event) => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
||||||
|
if (dragging && dragStart) {
|
||||||
|
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
|
||||||
|
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
|
||||||
|
}
|
||||||
|
requestDraw();
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.addEventListener("wheel", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const mx = event.clientX - rect.left;
|
||||||
|
const my = event.clientY - rect.top;
|
||||||
|
const before = screenToWorld(mx, my);
|
||||||
|
const factor = event.deltaY < 0 ? 1.08 : 0.92;
|
||||||
|
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
|
||||||
|
const after = worldToScreen(before.x, before.y);
|
||||||
|
camera.offsetX += mx - after.x;
|
||||||
|
camera.offsetY += my - after.y;
|
||||||
|
requestDraw();
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
document.getElementById("fit-btn").addEventListener("click", fit);
|
||||||
|
document.getElementById("clear-btn").addEventListener("click", () => {
|
||||||
|
placed.clear();
|
||||||
|
persistPlaced();
|
||||||
|
requestDraw();
|
||||||
|
});
|
||||||
|
clearAssignBtn.addEventListener("click", () => {
|
||||||
|
chairAssignments = {};
|
||||||
|
persistAssignments();
|
||||||
|
renderPeopleList();
|
||||||
|
requestDraw();
|
||||||
|
});
|
||||||
|
orgChartEl.addEventListener("click", (event) => {
|
||||||
|
const item = event.target.closest(".org-person[data-person-id]");
|
||||||
|
if (!item) return;
|
||||||
|
const personId = item.getAttribute("data-person-id");
|
||||||
|
activePersonId = personId === activePersonId ? null : personId;
|
||||||
|
persistActivePerson();
|
||||||
|
renderPeopleList();
|
||||||
|
requestDraw();
|
||||||
|
});
|
||||||
|
window.addEventListener("resize", resize);
|
||||||
|
renderPeopleList();
|
||||||
|
resize();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
1
incoming-files/seat/center_chair_people_payload.js
Normal file
1
incoming-files/seat/center_chair_people_payload.js
Normal file
File diff suppressed because one or more lines are too long
1
incoming-files/seat/center_chair_people_payload_6f.js
Normal file
1
incoming-files/seat/center_chair_people_payload_6f.js
Normal file
File diff suppressed because one or more lines are too long
1
incoming-files/seat/center_chair_people_payload_7f.js
Normal file
1
incoming-files/seat/center_chair_people_payload_7f.js
Normal file
File diff suppressed because one or more lines are too long
@@ -351,11 +351,21 @@ body {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.member-edit-profile-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.member-seat-field {
|
.member-seat-field {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.member-seat-field-emphasis .seat-preview-card {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.member-detail-top-row {
|
.member-detail-top-row {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -386,6 +396,17 @@ body {
|
|||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.member-inline-info-grid-stacked {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-name-field-compact {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.member-inline-info-card {
|
.member-inline-info-card {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -496,6 +517,7 @@ body {
|
|||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
|
background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.seat-preview-head {
|
.seat-preview-head {
|
||||||
@@ -579,6 +601,32 @@ body {
|
|||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.member-edit-right-pane .seat-preview-head {
|
||||||
|
padding: 18px 20px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-edit-right-pane .seat-preview-head strong {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-edit-right-pane .seat-preview-head p {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-edit-right-pane .seat-preview-badge {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-edit-right-pane .seat-preview-canvas {
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-edit-right-pane .seat-preview-frame,
|
||||||
|
.member-edit-right-pane .seat-preview-placeholder {
|
||||||
|
min-height: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.seat-preview-placeholder-icon {
|
.seat-preview-placeholder-icon {
|
||||||
width: 52px;
|
width: 52px;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ let isListMode = false;
|
|||||||
let emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.';
|
let emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.';
|
||||||
let photoPreviewObjectUrl = null;
|
let photoPreviewObjectUrl = null;
|
||||||
let seatMapLayoutCache = null;
|
let seatMapLayoutCache = null;
|
||||||
|
const seatMapOfficeKeys = ['technical-development-center', 'hanmac-building-6f', 'hanmac-building-7f'];
|
||||||
|
|
||||||
const levelOrder = ['부서', '그룹', '디비전', '팀', '셀'];
|
const levelOrder = ['부서', '그룹', '디비전', '팀', '셀'];
|
||||||
const dropdownFields = ['소속회사', '직급', '직책', ...levelOrder];
|
const dropdownFields = ['소속회사', '직급', '직책', ...levelOrder];
|
||||||
@@ -147,23 +148,28 @@ async function loadMembers(message) {
|
|||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadActiveSeatMapLayout(force = false) {
|
async function loadSeatMapLayouts(force = false) {
|
||||||
if (seatMapLayoutCache && !force) {
|
if (seatMapLayoutCache && !force) {
|
||||||
return seatMapLayoutCache;
|
return seatMapLayoutCache;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const activePayload = await apiFetch('/api/seat-maps/active');
|
const layouts = (await Promise.all(seatMapOfficeKeys.map(async (officeKey) => {
|
||||||
const seatMap = activePayload?.item;
|
try {
|
||||||
if (!seatMap?.id) {
|
const activePayload = await apiFetch(`/api/seat-maps/active?office_key=${encodeURIComponent(officeKey)}`);
|
||||||
seatMapLayoutCache = null;
|
const seatMap = activePayload?.item;
|
||||||
return null;
|
if (!seatMap?.id) {
|
||||||
}
|
return null;
|
||||||
const layoutPayload = await apiFetch(`/api/seat-maps/${seatMap.id}/layout`);
|
}
|
||||||
seatMapLayoutCache = layoutPayload;
|
return await apiFetch(`/api/seat-maps/${seatMap.id}/layout`);
|
||||||
return layoutPayload;
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}))).filter(Boolean);
|
||||||
|
seatMapLayoutCache = layouts;
|
||||||
|
return layouts;
|
||||||
} catch {
|
} catch {
|
||||||
seatMapLayoutCache = null;
|
seatMapLayoutCache = null;
|
||||||
return null;
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,22 +178,62 @@ function handleSeatMapLayoutUpdated() {
|
|||||||
loadMembers().catch(() => { });
|
loadMembers().catch(() => { });
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMemberSeatInfo(layout, memberId) {
|
function getMemberSeatInfo(layouts, memberId) {
|
||||||
if (!layout || !memberId) {
|
if (!Array.isArray(layouts) || !memberId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const placement = (layout.placements || []).find((item) => Number(item.member_id) === Number(memberId));
|
for (const layout of layouts) {
|
||||||
if (!placement) {
|
const placement = (layout.placements || []).find((item) => Number(item.member_id) === Number(memberId));
|
||||||
return null;
|
if (!placement) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const slot = (layout.slots || []).find((item) => Number(item.id) === Number(placement.seat_slot_id));
|
||||||
|
return {
|
||||||
|
layout,
|
||||||
|
seatMapId: layout.seat_map?.id || null,
|
||||||
|
seatMapName: layout.seat_map?.name || '자리배치도',
|
||||||
|
seatLabel: placement.seat_label || slot?.label || '',
|
||||||
|
slotKey: slot?.slot_key || '',
|
||||||
|
assigned: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const slot = (layout.slots || []).find((item) => Number(item.id) === Number(placement.seat_slot_id));
|
return null;
|
||||||
return {
|
}
|
||||||
seatMapId: layout.seat_map?.id || null,
|
|
||||||
seatMapName: layout.seat_map?.name || '자리배치도',
|
function buildSeatAssignments(layout) {
|
||||||
seatLabel: placement.seat_label || slot?.label || '',
|
if (!layout || !Array.isArray(layout.placements) || !Array.isArray(layout.members) || !Array.isArray(layout.slots)) {
|
||||||
slotKey: slot?.slot_key || '',
|
return [];
|
||||||
assigned: true,
|
}
|
||||||
|
return layout.placements.map((placement) => {
|
||||||
|
const slot = layout.slots.find((item) => Number(item.id) === Number(placement.seat_slot_id));
|
||||||
|
const memberItem = layout.members.find((item) => Number(item.id) === Number(placement.member_id));
|
||||||
|
if (!slot || !memberItem) return null;
|
||||||
|
return {
|
||||||
|
key: String(slot.slot_key || ''),
|
||||||
|
member_id: Number(memberItem.id),
|
||||||
|
name: memberItem.name || '-',
|
||||||
|
rank: memberItem.rank || '-',
|
||||||
|
};
|
||||||
|
}).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySeatPreviewFrameState(frame, seatInfo, layout) {
|
||||||
|
if (!frame?.contentWindow || !seatInfo?.slotKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const postState = () => {
|
||||||
|
if (!frame.contentWindow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
frame.contentWindow.postMessage({
|
||||||
|
type: 'seatmap-set-assignments',
|
||||||
|
items: buildSeatAssignments(layout),
|
||||||
|
}, window.location.origin);
|
||||||
|
frame.contentWindow.postMessage({ type: 'seatmap-set-mode', mode: 'compact' }, window.location.origin);
|
||||||
|
frame.contentWindow.postMessage({ type: 'seatmap-focus-chair', key: seatInfo.slotKey, padding: 2600 }, window.location.origin);
|
||||||
};
|
};
|
||||||
|
postState();
|
||||||
|
setTimeout(postState, 120);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncMembers(nextMembers) {
|
async function syncMembers(nextMembers) {
|
||||||
@@ -953,15 +999,16 @@ async function hydrateMemberSeatPreview(member) {
|
|||||||
seatLabel: member['자리위치'] || '',
|
seatLabel: member['자리위치'] || '',
|
||||||
slotKey: '',
|
slotKey: '',
|
||||||
});
|
});
|
||||||
const layout = await loadActiveSeatMapLayout(true);
|
const layouts = await loadSeatMapLayouts(true);
|
||||||
if (!document.getElementById('member-seat-preview')) {
|
if (!document.getElementById('member-seat-preview')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const seatInfo = getMemberSeatInfo(layout, member.id) || {
|
const seatInfo = getMemberSeatInfo(layouts, member.id) || {
|
||||||
seatMapName: layout?.seat_map?.name || '자리배치도',
|
layout: null,
|
||||||
|
seatMapName: '자리배치도',
|
||||||
seatLabel: member['자리위치'] || '',
|
seatLabel: member['자리위치'] || '',
|
||||||
slotKey: '',
|
slotKey: '',
|
||||||
assigned: Boolean(member['자리위치']),
|
assigned: false,
|
||||||
};
|
};
|
||||||
target.innerHTML = renderSeatPreviewCard(seatInfo);
|
target.innerHTML = renderSeatPreviewCard(seatInfo);
|
||||||
if (!seatInfo.assigned || !seatInfo.seatMapId || !seatInfo.slotKey) {
|
if (!seatInfo.assigned || !seatInfo.seatMapId || !seatInfo.slotKey) {
|
||||||
@@ -972,27 +1019,7 @@ async function hydrateMemberSeatPreview(member) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
frame.addEventListener('load', () => {
|
frame.addEventListener('load', () => {
|
||||||
if (!frame.contentWindow) {
|
applySeatPreviewFrameState(frame, seatInfo, seatInfo.layout);
|
||||||
return;
|
|
||||||
}
|
|
||||||
frame.contentWindow.postMessage({
|
|
||||||
type: 'seatmap-set-assignments',
|
|
||||||
items: Array.isArray(layout?.placements) && Array.isArray(layout?.members) && Array.isArray(layout?.slots)
|
|
||||||
? layout.placements.map((placement) => {
|
|
||||||
const slot = layout.slots.find((item) => Number(item.id) === Number(placement.seat_slot_id));
|
|
||||||
const memberItem = layout.members.find((item) => Number(item.id) === Number(placement.member_id));
|
|
||||||
if (!slot || !memberItem) return null;
|
|
||||||
return {
|
|
||||||
key: String(slot.slot_key || ''),
|
|
||||||
member_id: Number(memberItem.id),
|
|
||||||
name: memberItem.name || '-',
|
|
||||||
rank: memberItem.rank || '-',
|
|
||||||
};
|
|
||||||
}).filter(Boolean)
|
|
||||||
: [],
|
|
||||||
}, window.location.origin);
|
|
||||||
frame.contentWindow.postMessage({ type: 'seatmap-set-mode', mode: 'compact' }, window.location.origin);
|
|
||||||
frame.contentWindow.postMessage({ type: 'seatmap-focus-chair', key: seatInfo.slotKey, padding: 2600 }, window.location.origin);
|
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1112,7 +1139,7 @@ function openModal(id) {
|
|||||||
<input type="hidden" id="m-seat-hidden" value="${member['자리위치'] || ''}">
|
<input type="hidden" id="m-seat-hidden" value="${member['자리위치'] || ''}">
|
||||||
<div class="col-span-2 member-edit-layout">
|
<div class="col-span-2 member-edit-layout">
|
||||||
<div class="member-edit-left-pane">
|
<div class="member-edit-left-pane">
|
||||||
<div class="member-photo-field">
|
<div class="member-edit-profile-card">
|
||||||
<label class="text-[11px] font-black text-slate-600 block">프로필 사진</label>
|
<label class="text-[11px] font-black text-slate-600 block">프로필 사진</label>
|
||||||
<div class="member-photo-upload-card member-photo-upload-card-compact">
|
<div class="member-photo-upload-card member-photo-upload-card-compact">
|
||||||
<div class="member-photo-preview-wrap">
|
<div class="member-photo-preview-wrap">
|
||||||
@@ -1126,21 +1153,16 @@ function openModal(id) {
|
|||||||
<strong id="m-photo-file-name" class="member-photo-file-name">선택된 파일 없음</strong>
|
<strong id="m-photo-file-name" class="member-photo-file-name">선택된 파일 없음</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="member-name-field member-name-field-compact">
|
||||||
<div class="member-seat-field">
|
<label class="text-[11px] font-black text-slate-600 block">이름 (필수)</label>
|
||||||
<div id="member-seat-preview">${renderSeatPreviewCard({ assigned: false, seatLabel: member['자리위치'] || '', seatMapName: '자리배치도', slotKey: '' })}</div>
|
<input id="m-name" value="${member['이름'] || ''}" oninput="syncPhotoPreviewFromUrl()" class="w-full bg-slate-50 p-3 rounded-xl border font-bold text-sm outline-none">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="member-inline-info-grid member-inline-info-grid-stacked">
|
||||||
<div class="member-edit-right-pane">
|
<div class="member-inline-info-card member-inline-info-card-full">
|
||||||
<div class="member-name-field">
|
|
||||||
<label class="text-[11px] font-black text-slate-600 block">이름 (필수)</label>
|
|
||||||
<input id="m-name" value="${member['이름'] || ''}" oninput="syncPhotoPreviewFromUrl()" class="w-full bg-slate-50 p-3 rounded-xl border font-bold text-sm outline-none">
|
|
||||||
<div class="member-inline-info-grid member-inline-info-grid-edit">
|
|
||||||
<div class="member-inline-info-card">
|
|
||||||
<label>사번</label>
|
<label>사번</label>
|
||||||
<input id="m-employee-id" value="${member['사번'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none">
|
<input id="m-employee-id" value="${member['사번'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none">
|
||||||
</div>
|
</div>
|
||||||
<div class="member-inline-info-card">
|
<div class="member-inline-info-card member-inline-info-card-full">
|
||||||
<label>전화번호</label>
|
<label>전화번호</label>
|
||||||
<input id="m-phone" value="${member['전화번호'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none">
|
<input id="m-phone" value="${member['전화번호'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none">
|
||||||
</div>
|
</div>
|
||||||
@@ -1151,6 +1173,11 @@ function openModal(id) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="member-edit-right-pane">
|
||||||
|
<div class="member-seat-field member-seat-field-emphasis">
|
||||||
|
<div id="member-seat-preview">${renderSeatPreviewCard({ assigned: false, seatLabel: member['자리위치'] || '', seatMapName: '자리배치도', slotKey: '' })}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${orgFields}
|
${orgFields}
|
||||||
|
|||||||
Binary file not shown.
198
scripts/sync_prod_db_to_dev.sh
Executable file
198
scripts/sync_prod_db_to_dev.sh
Executable file
@@ -0,0 +1,198 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
PROD_DIR="${ROOT_DIR}"
|
||||||
|
DEV_DIR="${DEV_DIR:-/tmp/mh-dashboard-organization-dev}"
|
||||||
|
SCOPE="${1:-minimal}"
|
||||||
|
|
||||||
|
if [[ ! -f "${PROD_DIR}/docker-compose.yml" ]]; then
|
||||||
|
echo "Production workspace not found: ${PROD_DIR}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "${DEV_DIR}/docker-compose.yml" ]]; then
|
||||||
|
echo "Development workspace not found: ${DEV_DIR}" >&2
|
||||||
|
echo "Set DEV_DIR=/path/to/dev-copy if the dev workspace moved." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "${SCOPE}" in
|
||||||
|
minimal)
|
||||||
|
TABLES=(
|
||||||
|
member_aliases
|
||||||
|
member_overrides
|
||||||
|
member_retirements
|
||||||
|
members
|
||||||
|
seat_maps
|
||||||
|
seat_slots
|
||||||
|
)
|
||||||
|
;;
|
||||||
|
analysis)
|
||||||
|
TABLES=(
|
||||||
|
integration_import_batches
|
||||||
|
integration_raw_organization_rows
|
||||||
|
integration_raw_mh_rows
|
||||||
|
integration_raw_mh_pm_rows
|
||||||
|
integration_raw_payment_rows
|
||||||
|
integration_project_aliases
|
||||||
|
integration_project_category_mappings
|
||||||
|
integration_project_pm_assignments
|
||||||
|
integration_projects
|
||||||
|
integration_work_logs
|
||||||
|
integration_work_log_segments
|
||||||
|
integration_vouchers
|
||||||
|
)
|
||||||
|
;;
|
||||||
|
full)
|
||||||
|
TABLES=(
|
||||||
|
integration_import_batches
|
||||||
|
integration_raw_organization_rows
|
||||||
|
integration_raw_mh_rows
|
||||||
|
integration_raw_mh_pm_rows
|
||||||
|
integration_raw_payment_rows
|
||||||
|
integration_project_aliases
|
||||||
|
integration_project_category_mappings
|
||||||
|
integration_project_pm_assignments
|
||||||
|
integration_projects
|
||||||
|
integration_work_logs
|
||||||
|
integration_work_log_segments
|
||||||
|
integration_vouchers
|
||||||
|
member_aliases
|
||||||
|
member_overrides
|
||||||
|
member_retirements
|
||||||
|
members
|
||||||
|
seat_maps
|
||||||
|
seat_slots
|
||||||
|
)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 [minimal|analysis|full]" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
PROD_COMPOSE=(docker compose --project-directory "${PROD_DIR}")
|
||||||
|
DEV_COMPOSE=(docker compose --project-directory "${DEV_DIR}")
|
||||||
|
|
||||||
|
require_service() {
|
||||||
|
local dir="$1"
|
||||||
|
shift
|
||||||
|
(cd "${dir}" && "$@") >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "[1/6] Checking source and target stacks"
|
||||||
|
require_service "${PROD_DIR}" "${PROD_COMPOSE[@]}" ps
|
||||||
|
require_service "${DEV_DIR}" "${DEV_COMPOSE[@]}" ps
|
||||||
|
|
||||||
|
echo "[2/6] Ensuring db containers are reachable"
|
||||||
|
(cd "${PROD_DIR}" && "${PROD_COMPOSE[@]}" exec -T db pg_isready -U "${POSTGRES_USER:-orgapp}" -d "${POSTGRES_DB:-orgdb}") >/dev/null
|
||||||
|
(cd "${DEV_DIR}" && "${DEV_COMPOSE[@]}" exec -T db pg_isready -U "${POSTGRES_USER:-orgapp}" -d "${POSTGRES_DB:-orgdb}") >/dev/null
|
||||||
|
|
||||||
|
WORK_DIR="$(mktemp -d)"
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "${WORK_DIR}"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
DUMP_FILE="${WORK_DIR}/prod_to_dev_${SCOPE}.sql"
|
||||||
|
TRUNCATE_FILE="${WORK_DIR}/truncate_${SCOPE}.sql"
|
||||||
|
SEAT_POSITIONS_FILE="${WORK_DIR}/seat_positions.csv"
|
||||||
|
SEQUENCE_FIX_FILE="${WORK_DIR}/sequence_fix.sql"
|
||||||
|
AUTH_SYNC_FILE="${WORK_DIR}/auth_sync.py"
|
||||||
|
|
||||||
|
echo "[3/6] Building truncate script for ${SCOPE} scope"
|
||||||
|
{
|
||||||
|
echo "BEGIN;"
|
||||||
|
echo "SET session_replication_role = replica;"
|
||||||
|
printf 'TRUNCATE TABLE %s RESTART IDENTITY CASCADE;\n' "$(IFS=,; echo "${TABLES[*]}")"
|
||||||
|
echo "SET session_replication_role = DEFAULT;"
|
||||||
|
echo "COMMIT;"
|
||||||
|
} > "${TRUNCATE_FILE}"
|
||||||
|
|
||||||
|
echo "[4/6] Dumping ${SCOPE} data from 8080 source DB"
|
||||||
|
TABLE_ARGS=()
|
||||||
|
for table in "${TABLES[@]}"; do
|
||||||
|
TABLE_ARGS+=(-t "public.${table}")
|
||||||
|
done
|
||||||
|
(cd "${PROD_DIR}" && "${PROD_COMPOSE[@]}" exec -T db \
|
||||||
|
pg_dump -U "${POSTGRES_USER:-orgapp}" -d "${POSTGRES_DB:-orgdb}" \
|
||||||
|
--data-only --column-inserts --disable-triggers --no-owner --no-privileges \
|
||||||
|
"${TABLE_ARGS[@]}") > "${DUMP_FILE}"
|
||||||
|
|
||||||
|
echo "[4.5/6] Exporting seat_positions in portable format"
|
||||||
|
(cd "${PROD_DIR}" && "${PROD_COMPOSE[@]}" exec -T db \
|
||||||
|
psql -At -F ',' -U "${POSTGRES_USER:-orgapp}" -d "${POSTGRES_DB:-orgdb}" \
|
||||||
|
-c "COPY (
|
||||||
|
SELECT member_id, seat_map_id, seat_slot_id, row_index, col_index, seat_label, updated_at
|
||||||
|
FROM public.seat_positions
|
||||||
|
ORDER BY member_id
|
||||||
|
) TO STDOUT WITH CSV") > "${SEAT_POSITIONS_FILE}"
|
||||||
|
|
||||||
|
echo "[5/6] Truncating target tables in 8081 dev DB"
|
||||||
|
(cd "${DEV_DIR}" && "${DEV_COMPOSE[@]}" exec -T db \
|
||||||
|
psql -q -v ON_ERROR_STOP=1 -U "${POSTGRES_USER:-orgapp}" -d "${POSTGRES_DB:-orgdb}" >/dev/null) < "${TRUNCATE_FILE}"
|
||||||
|
|
||||||
|
echo "[6/6] Restoring dumped data into 8081 dev DB"
|
||||||
|
(cd "${DEV_DIR}" && "${DEV_COMPOSE[@]}" exec -T db \
|
||||||
|
psql -q -v ON_ERROR_STOP=1 -U "${POSTGRES_USER:-orgapp}" -d "${POSTGRES_DB:-orgdb}" >/dev/null) < "${DUMP_FILE}"
|
||||||
|
|
||||||
|
echo "[6.5/6] Restoring portable seat_positions and rebuilding auth users"
|
||||||
|
(cd "${DEV_DIR}" && "${DEV_COMPOSE[@]}" exec -T db \
|
||||||
|
psql -q -v ON_ERROR_STOP=1 -U "${POSTGRES_USER:-orgapp}" -d "${POSTGRES_DB:-orgdb}" \
|
||||||
|
-c "DELETE FROM public.seat_positions" >/dev/null)
|
||||||
|
(cd "${DEV_DIR}" && "${DEV_COMPOSE[@]}" exec -T db \
|
||||||
|
psql -q -v ON_ERROR_STOP=1 -U "${POSTGRES_USER:-orgapp}" -d "${POSTGRES_DB:-orgdb}" \
|
||||||
|
-c "COPY public.seat_positions (member_id, seat_map_id, seat_slot_id, row_index, col_index, seat_label, updated_at) FROM STDIN WITH CSV" >/dev/null) < "${SEAT_POSITIONS_FILE}"
|
||||||
|
cat > "${AUTH_SYNC_FILE}" <<'PY'
|
||||||
|
from backend.app.main import get_conn, sync_auth_users_from_members
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("UPDATE members SET seat_label = ''")
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE members AS m
|
||||||
|
SET seat_label = sp.seat_label
|
||||||
|
FROM seat_positions AS sp
|
||||||
|
WHERE sp.member_id = m.id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
sync_auth_users_from_members(cur)
|
||||||
|
conn.commit()
|
||||||
|
print("members, seat labels, and auth users synced")
|
||||||
|
PY
|
||||||
|
(cd "${DEV_DIR}" && "${DEV_COMPOSE[@]}" exec -T backend python -) < "${AUTH_SYNC_FILE}"
|
||||||
|
|
||||||
|
echo "[6.8/6] Resetting serial sequences"
|
||||||
|
{
|
||||||
|
echo "SELECT setval(pg_get_serial_sequence('public.members', 'id'), COALESCE((SELECT MAX(id) FROM public.members), 1), true);"
|
||||||
|
echo "SELECT setval(pg_get_serial_sequence('public.member_aliases', 'id'), COALESCE((SELECT MAX(id) FROM public.member_aliases), 1), true);"
|
||||||
|
echo "SELECT setval(pg_get_serial_sequence('public.member_overrides', 'id'), COALESCE((SELECT MAX(id) FROM public.member_overrides), 1), true);"
|
||||||
|
echo "SELECT setval(pg_get_serial_sequence('public.member_retirements', 'id'), COALESCE((SELECT MAX(id) FROM public.member_retirements), 1), true);"
|
||||||
|
echo "SELECT setval(pg_get_serial_sequence('public.seat_maps', 'id'), COALESCE((SELECT MAX(id) FROM public.seat_maps), 1), true);"
|
||||||
|
echo "SELECT setval(pg_get_serial_sequence('public.seat_slots', 'id'), COALESCE((SELECT MAX(id) FROM public.seat_slots), 1), true);"
|
||||||
|
if [[ "${SCOPE}" == "analysis" || "${SCOPE}" == "full" ]]; then
|
||||||
|
echo "SELECT setval(pg_get_serial_sequence('public.integration_import_batches', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_import_batches), 1), true);"
|
||||||
|
echo "SELECT setval(pg_get_serial_sequence('public.integration_raw_organization_rows', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_raw_organization_rows), 1), true);"
|
||||||
|
echo "SELECT setval(pg_get_serial_sequence('public.integration_raw_mh_rows', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_raw_mh_rows), 1), true);"
|
||||||
|
echo "SELECT setval(pg_get_serial_sequence('public.integration_raw_mh_pm_rows', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_raw_mh_pm_rows), 1), true);"
|
||||||
|
echo "SELECT setval(pg_get_serial_sequence('public.integration_raw_payment_rows', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_raw_payment_rows), 1), true);"
|
||||||
|
echo "SELECT setval(pg_get_serial_sequence('public.integration_project_aliases', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_project_aliases), 1), true);"
|
||||||
|
echo "SELECT setval(pg_get_serial_sequence('public.integration_project_category_mappings', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_project_category_mappings), 1), true);"
|
||||||
|
echo "SELECT setval(pg_get_serial_sequence('public.integration_project_pm_assignments', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_project_pm_assignments), 1), true);"
|
||||||
|
echo "SELECT setval(pg_get_serial_sequence('public.integration_projects', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_projects), 1), true);"
|
||||||
|
echo "SELECT setval(pg_get_serial_sequence('public.integration_work_logs', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_work_logs), 1), true);"
|
||||||
|
echo "SELECT setval(pg_get_serial_sequence('public.integration_work_log_segments', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_work_log_segments), 1), true);"
|
||||||
|
echo "SELECT setval(pg_get_serial_sequence('public.integration_vouchers', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_vouchers), 1), true);"
|
||||||
|
fi
|
||||||
|
} > "${SEQUENCE_FIX_FILE}"
|
||||||
|
(cd "${DEV_DIR}" && "${DEV_COMPOSE[@]}" exec -T db \
|
||||||
|
psql -q -v ON_ERROR_STOP=1 -U "${POSTGRES_USER:-orgapp}" -d "${POSTGRES_DB:-orgdb}" >/dev/null) < "${SEQUENCE_FIX_FILE}"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Sync complete."
|
||||||
|
echo "Source: ${PROD_DIR} (8080)"
|
||||||
|
echo "Target: ${DEV_DIR} (8081)"
|
||||||
|
echo "Scope : ${SCOPE}"
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ko">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>MH 조직현황 대시보드 Standalone</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="http://localhost:8080/legacy/static/common.css">
|
|
||||||
<link rel="stylesheet" href="http://localhost:8080/styles.css?v=20260326-standalone">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<section id="login-panel" class="login-screen">
|
|
||||||
<div class="login-backdrop">
|
|
||||||
<form id="login-form" class="login-card">
|
|
||||||
<div class="login-brand">
|
|
||||||
<p class="eyebrow">GPD/TDC</p>
|
|
||||||
<h1>MH Dash Board</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="login-form-wrap">
|
|
||||||
<label>
|
|
||||||
<span>사번</span>
|
|
||||||
<input name="username" type="text" placeholder="사번 입력" required>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>비번</span>
|
|
||||||
<input name="password" type="password" placeholder="비밀번호 입력" required>
|
|
||||||
</label>
|
|
||||||
<button type="submit">로그인</button>
|
|
||||||
<p id="login-message" class="helper-text"></p>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="dashboard-panel" class="dashboard-shell hidden">
|
|
||||||
<header class="dashboard-header">
|
|
||||||
<div class="brand-block">
|
|
||||||
<p class="eyebrow">MH Dashboard</p>
|
|
||||||
<h2 id="current-view-title">조직 현황</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="header-center">
|
|
||||||
<button class="nav-pill" type="button" data-view="ledger">사업관리대장</button>
|
|
||||||
<button class="nav-pill" type="button" data-view="project">프로젝트별 분석</button>
|
|
||||||
<button class="nav-pill" type="button" data-view="team">팀/개인별 분석</button>
|
|
||||||
<button class="nav-pill active" type="button" data-view="organization">조직 현황</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="header-actions">
|
|
||||||
<button id="user-badge" class="ghost-button ghost-button-soft user-chip" type="button"></button>
|
|
||||||
<div id="user-popover" class="user-popover hidden"></div>
|
|
||||||
<button id="logout-btn" class="ghost-button icon-button" type="button" title="로그아웃" aria-label="로그아웃">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
|
||||||
<polyline points="16 17 21 12 16 7"></polyline>
|
|
||||||
<line x1="21" y1="12" x2="9" y2="12"></line>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="dashboard-main">
|
|
||||||
<section id="organization-stage" class="main-stage">
|
|
||||||
<div class="stage-frame">
|
|
||||||
<iframe
|
|
||||||
id="organization-frame"
|
|
||||||
src="http://localhost:8080/legacy/organization?v=20260326-standalone"
|
|
||||||
data-src="http://localhost:8080/legacy/organization?v=20260326-standalone"
|
|
||||||
title="조직도 메인 화면"></iframe>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section id="seatmap-stage" class="main-stage" hidden>
|
|
||||||
<div class="seatmap-layout">
|
|
||||||
<div class="seatmap-topbar">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Seat Layout</p>
|
|
||||||
<h3 id="seatmap-name">자리배치도</h3>
|
|
||||||
</div>
|
|
||||||
<div class="seatmap-actions">
|
|
||||||
<button id="seatmap-save-btn" class="ghost-button" type="button" hidden disabled>저장</button>
|
|
||||||
<button id="seatmap-cancel-btn" class="ghost-button ghost-button-soft" type="button" hidden>취소</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p id="seatmap-status" class="seatmap-status" role="status"></p>
|
|
||||||
|
|
||||||
<div class="seatmap-content">
|
|
||||||
<div class="seatmap-board-panel">
|
|
||||||
<div id="seatmap-empty" class="seatmap-empty hidden"></div>
|
|
||||||
<div id="seatmap-board-wrap" class="seatmap-board-wrap hidden">
|
|
||||||
<div id="seatmap-board" class="seatmap-board"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside class="seatmap-sidebar">
|
|
||||||
<section id="seatmap-settings-panel" class="seatmap-panel hidden">
|
|
||||||
<div class="seatmap-panel-head">
|
|
||||||
<h4>배치도 설정</h4>
|
|
||||||
<p>DXF 파일의 chair 레이어를 좌석 위치로 사용합니다.</p>
|
|
||||||
</div>
|
|
||||||
<form id="seatmap-settings-form" class="seatmap-form">
|
|
||||||
<label>
|
|
||||||
<span>배치도 이름</span>
|
|
||||||
<input id="seatmap-form-name" name="name" type="text" placeholder="예: 본사 3층" required>
|
|
||||||
</label>
|
|
||||||
<div>
|
|
||||||
<span>DXF 파일</span>
|
|
||||||
<label class="seatmap-file-input" for="seatmap-form-image">
|
|
||||||
<input id="seatmap-form-image" name="image" type="file" accept=".dxf" required>
|
|
||||||
<span class="seatmap-file-button">DXF 선택</span>
|
|
||||||
<strong id="seatmap-file-name" class="seatmap-file-name">선택된 파일 없음</strong>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<button id="seatmap-settings-submit" type="submit">DXF 업로드</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="seatmap-panel">
|
|
||||||
<div class="seatmap-panel-head">
|
|
||||||
<h4>미배치 인원</h4>
|
|
||||||
<p>이름을 검색하고 자리배치도에 바로 드래그하세요.</p>
|
|
||||||
</div>
|
|
||||||
<label class="seatmap-search">
|
|
||||||
<span class="hidden">구성원 검색</span>
|
|
||||||
<input id="seatmap-search" type="search" placeholder="이름 또는 부서 검색">
|
|
||||||
</label>
|
|
||||||
<div id="seatmap-unassigned" class="seatmap-member-list"></div>
|
|
||||||
</section>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section id="empty-stage" class="main-stage" hidden>
|
|
||||||
<div class="stage-empty"></div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
window.__MH_BASE_URL = "http://localhost:8080";
|
|
||||||
</script>
|
|
||||||
<script src="http://localhost:8080/app.js?v=20260326-standalone"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Reference in New Issue
Block a user