17 Commits

Author SHA1 Message Date
hyunho
fb5b0f00c2 feat: unify 8081 dashboard design system and views 2026-04-01 14:02:05 +09:00
hyunho
637b390024 backup: snapshot local design source assets 2026-03-31 17:52:27 +09:00
hyunho
4b4ffafbd2 docs: persist isolated 8081 startup workflow 2026-03-31 17:47:39 +09:00
hyunho
1cd0f21a36 docs: codify isolated 8081 worktree workflow 2026-03-31 17:34:13 +09:00
hyunho
f77be3f482 feat: promote seatmap and organization updates 2026-03-30 16:40:07 +09:00
hyunho
2e8c79bb43 docs: require explicit instruction for commits 2026-03-30 10:28:49 +09:00
hyunho
8121c9cf41 fix: align analysis defaults to latest completed month 2026-03-30 10:27:21 +09:00
hyunho
e67fd41cbf fix: restore default mh team selection 2026-03-30 10:24:28 +09:00
hyunho
c9a93ea936 feat: add all-team filter to mh analysis 2026-03-30 10:22:12 +09:00
hyunho
8d0cc78abc feat: show change timestamps in history compare 2026-03-30 10:10:44 +09:00
hyunho
bbebe24763 feat: add monthly history controls for organization view 2026-03-30 10:08:00 +09:00
hyunho
2053791589 fix: keep organization main view on current data 2026-03-30 09:55:53 +09:00
hyunho
fc23156b2c fix: label dev dashboard title by port 2026-03-30 09:51:07 +09:00
hyunho
33f157cb08 feat: add member history list snapshot and compare views 2026-03-30 09:46:48 +09:00
hyunho
b735a4cdd1 feat: wire as-of date into organization and seatmap views 2026-03-30 09:32:06 +09:00
hyunho
6e55b99e9a feat: add as-of history read and write paths 2026-03-30 09:26:04 +09:00
hyunho
cbae8769bf feat: stabilize dev sync and add history tables 2026-03-30 09:15:31 +09:00
46 changed files with 23720 additions and 1062 deletions

1
.gitignore vendored
View File

@@ -16,3 +16,4 @@ incoming-files/~$*
incoming-files/6f.html
incoming-files/7f.html
incoming-files/center.html
.dev-worktree-8081/

View File

@@ -8,8 +8,8 @@
<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" />
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/legacy/static/common.css?v=20260327-03" />
<link rel="stylesheet" href="/legacy/static/organization.css?v=20260327-03" />
<link rel="stylesheet" href="/legacy/static/common.css?v=20260331-01" />
<link rel="stylesheet" href="/legacy/static/organization.css?v=20260331-01" />
</head>
<body>
<input type="file" id="upload-excel" class="hidden" accept=".xlsx, .csv" />
@@ -60,6 +60,6 @@
</div>
</div>
<script src="/legacy/static/organization.js?v=20260327-03"></script>
<script src="/legacy/static/organization.js?v=20260331-01"></script>
</body>
</html>

View File

@@ -281,6 +281,66 @@ CREATE TABLE IF NOT EXISTS integration_vouchers (
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS 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 ''
);
CREATE TABLE IF NOT EXISTS 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 TABLE IF NOT EXISTS 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 TABLE IF NOT EXISTS 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
);
CREATE SCHEMA IF NOT EXISTS auth;
CREATE TABLE IF NOT EXISTS auth.users (
@@ -474,6 +534,18 @@ ON integration_vouchers (project_code, project_name);
CREATE UNIQUE INDEX IF NOT EXISTS integration_project_category_mappings_key_idx
ON integration_project_category_mappings (source_key, normalized_project_key);
CREATE INDEX IF NOT EXISTS member_versions_member_time_idx
ON member_versions (member_id, valid_from, valid_to);
CREATE INDEX IF NOT EXISTS seat_assignment_versions_member_time_idx
ON seat_assignment_versions (member_id, valid_from, valid_to);
CREATE INDEX IF NOT EXISTS history_revisions_scope_created_idx
ON history_revisions (scope, created_at DESC);
CREATE INDEX IF NOT EXISTS entity_change_events_entity_idx
ON entity_change_events (entity_type, entity_id, changed_at DESC);
DO $$
BEGIN
IF NOT EXISTS (
@@ -556,6 +628,7 @@ def init_db(max_retries: int = 20, retry_delay: float = 2.0) -> None:
with conn.cursor() as cur:
cur.execute(SCHEMA_SQL)
cur.execute(MIGRATION_SQL)
ensure_history_backfill(cur)
conn.commit()
return
except psycopg.OperationalError as exc:
@@ -563,3 +636,89 @@ def init_db(max_retries: int = 20, retry_delay: float = 2.0) -> None:
time.sleep(retry_delay)
if last_error is not None:
raise last_error
def ensure_history_backfill(cur) -> None:
cur.execute(
"""
SELECT id
FROM history_revisions
WHERE scope = 'organization'
AND revision_label = 'initial-backfill'
ORDER BY id ASC
LIMIT 1
"""
)
row = cur.fetchone()
if row is None:
cur.execute(
"""
INSERT INTO history_revisions (scope, revision_label, note)
VALUES ('organization', 'initial-backfill', 'Seeded from current members and seat_positions state')
RETURNING id
"""
)
revision_id = int(cur.fetchone()["id"])
else:
revision_id = int(row["id"])
cur.execute(
"""
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
)
SELECT
m.id, m.name, COALESCE(m.company, ''), COALESCE(m.rank, ''), COALESCE(m.role, ''),
COALESCE(m.department, ''), COALESCE(m.grp, ''), COALESCE(m.division, ''), COALESCE(m.team, ''), COALESCE(m.cell, ''),
COALESCE(m.work_status, ''), COALESCE(m.work_time, ''), COALESCE(m.phone, ''), COALESCE(m.email, ''), COALESCE(m.photo_url, ''),
TIMESTAMPTZ '1970-01-01 00:00:00+00', NULL, %s, NULL, 'initial-backfill'
FROM members AS m
WHERE NOT EXISTS (
SELECT 1
FROM member_versions mv
WHERE mv.member_id = m.id
)
""",
(revision_id,),
)
cur.execute(
"""
INSERT INTO seat_assignment_versions (
member_id, seat_map_id, seat_slot_id, seat_label,
valid_from, valid_to, revision_no, changed_by_user_id, change_reason
)
SELECT
sp.member_id, sp.seat_map_id, sp.seat_slot_id, COALESCE(sp.seat_label, ''),
TIMESTAMPTZ '1970-01-01 00:00:00+00', NULL, %s, NULL, 'initial-backfill'
FROM seat_positions AS sp
WHERE NOT EXISTS (
SELECT 1
FROM seat_assignment_versions sav
WHERE sav.member_id = sp.member_id
)
""",
(revision_id,),
)
cur.execute(
"""
UPDATE member_versions
SET valid_from = TIMESTAMPTZ '1970-01-01 00:00:00+00'
WHERE revision_no = %s
AND change_reason = 'initial-backfill'
""",
(revision_id,),
)
cur.execute(
"""
UPDATE seat_assignment_versions
SET valid_from = TIMESTAMPTZ '1970-01-01 00:00:00+00'
WHERE revision_no = %s
AND change_reason = 'initial-backfill'
""",
(revision_id,),
)

View File

@@ -21,7 +21,7 @@ import ezdxf
from ezdxf import recover
from fastapi import FastAPI, File, Form, Header, HTTPException, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.responses import FileResponse, HTMLResponse, Response
from fastapi.staticfiles import StaticFiles
from openpyxl import load_workbook
from pydantic import BaseModel, Field
@@ -42,6 +42,11 @@ app.add_middleware(
LEGACY_STATIC_DIR = LEGACY_DIR / "static"
INCOMING_FILES_DIR = BASE_DIR / "incoming-files"
INCOMING_SERVED_DIR = INCOMING_FILES_DIR / "served"
INCOMING_REFERENCE_DIR = INCOMING_FILES_DIR / "reference"
BUSINESS_DASHBOARD_DIR = INCOMING_FILES_DIR / "사업관리대장"
BUSINESS_DASHBOARD_WRAPPER_PATH = BUSINESS_DASHBOARD_DIR / "MH 통합 대시보드_260320.html"
BUSINESS_DASHBOARD_THEME_CSS = BUSINESS_DASHBOARD_DIR / "MH 통합 대시보드_260320.css"
FIXED_OFFICE_SOURCE_KEY = "technical-development-center"
FIXED_OFFICE_CONFIGS = {
"technical-development-center": {
@@ -61,9 +66,12 @@ FIXED_OFFICE_CONFIGS = {
},
}
_fixed_office_cache: dict[str, dict[str, object]] = {}
_business_ledger_html_cache: str | None = None
BUSINESS_LEDGER_DEFAULT_SOURCE_KEY = "business_ledger_default"
AUTH_DEFAULT_PASSWORD = "1111"
AUTH_PASSWORD_ITERATIONS = 390000
AUTH_SESSION_HOURS = 12
APP_TIMEZONE = timezone(timedelta(hours=9))
PAYMENT_HEADER_ORDER = [
"상신회사", "청구일", "발행일", "발행월", "계정코드", "관리계정코드", "각사 계정명", "프로젝트코드",
"사업명", "사업명(표출PJT)", "사업명(인트라넷기준)", "사업분야", "세부분야", "기획/개발/영업",
@@ -82,6 +90,86 @@ MH_HEADER_ORDER = [
]
def build_business_ledger_html() -> str:
global _business_ledger_html_cache
if _business_ledger_html_cache is not None:
return _business_ledger_html_cache
if not BUSINESS_DASHBOARD_WRAPPER_PATH.exists():
raise FileNotFoundError("Business dashboard wrapper file not found.")
source = BUSINESS_DASHBOARD_WRAPPER_PATH.read_text(encoding="utf-8-sig")
match = re.search(r"const BUSINESS_HTML_B64='([^']+)';", source)
if not match:
raise ValueError("Embedded business ledger source was not found.")
decoded = base64.b64decode(match.group(1)).decode("utf-8")
head_injection = (
'<base href="/integrations/ledger-assets/">'
'<link rel="stylesheet" href="/integrations/ledger-assets/MH%20통합%20대시보드_260320.css">'
'<link rel="stylesheet" href="/integrations/ledger-assets/ledger-override.css?v=20260401-03">'
)
html = decoded.replace("</head>", f"{head_injection}</head>", 1)
html = html.replace("<body>", '<body class="mh-business-theme">', 1)
html = html.replace("</body>", '<script src="/integrations/ledger-assets/ledger-override.js?v=20260401-03"></script></body>', 1)
_business_ledger_html_cache = html
return html
def sync_default_business_ledger_source(cur) -> None:
if not BUSINESS_DASHBOARD_DIR.exists():
return
candidates = [
BUSINESS_DASHBOARD_DIR / "사업관리대장-1.xlsx",
BUSINESS_DASHBOARD_DIR / "사업관리 대장-1.xlsx",
BUSINESS_DASHBOARD_DIR / "사업관리대장.xlsx",
BUSINESS_DASHBOARD_DIR / "사업관리 대장.xlsx",
]
source_path = next((candidate for candidate in candidates if candidate.exists()), None)
if source_path is None:
return
content = source_path.read_bytes()
content_sha256 = hashlib.sha256(content).hexdigest()
meta_json = {
"byte_size": len(content),
"source_path": str(source_path),
"synced_from": "startup",
}
cur.execute(
"""
INSERT INTO integration_binary_sources (
source_key, source_name, filename, mime_type, content, content_sha256, meta_json, imported_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, NOW())
ON CONFLICT (source_key) DO UPDATE
SET source_name = EXCLUDED.source_name,
filename = EXCLUDED.filename,
mime_type = EXCLUDED.mime_type,
content = EXCLUDED.content,
content_sha256 = EXCLUDED.content_sha256,
meta_json = EXCLUDED.meta_json,
imported_at = NOW()
WHERE integration_binary_sources.content_sha256 IS DISTINCT FROM EXCLUDED.content_sha256
OR integration_binary_sources.filename IS DISTINCT FROM EXCLUDED.filename
OR integration_binary_sources.mime_type IS DISTINCT FROM EXCLUDED.mime_type
OR integration_binary_sources.meta_json IS DISTINCT FROM EXCLUDED.meta_json
""",
(
BUSINESS_LEDGER_DEFAULT_SOURCE_KEY,
"사업관리대장 기본 원본",
source_path.name,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
content,
content_sha256,
json.dumps(meta_json, ensure_ascii=False),
),
)
app.mount(
"/integrations/ledger-assets",
StaticFiles(directory=str(BUSINESS_DASHBOARD_DIR), check_dir=False),
name="integration-ledger-assets",
)
class MemberPayload(BaseModel):
id: int | None = None
name: str = Field(min_length=1)
@@ -423,6 +511,354 @@ def fetch_members() -> list[dict[str, object]]:
return cur.fetchall()
def parse_as_of(as_of: str | None) -> datetime | None:
raw = str(as_of or "").strip()
if not raw:
return None
try:
if "T" in raw:
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
if parsed.tzinfo is None:
return parsed.replace(tzinfo=APP_TIMEZONE)
return parsed
parsed_date = date.fromisoformat(raw)
return datetime.combine(parsed_date, time.max, tzinfo=APP_TIMEZONE)
except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid as_of format. Use YYYY-MM-DD or ISO datetime.") from exc
def create_history_revision(cur, label_prefix: str, note: str) -> int:
cur.execute(
"""
INSERT INTO history_revisions (scope, revision_label, note)
VALUES ('organization', %s, %s)
RETURNING id
""",
(f"{label_prefix}-{datetime.now(APP_TIMEZONE).strftime('%Y%m%d-%H%M%S-%f')}", note),
)
return int(cur.fetchone()["id"])
def fetch_current_member_state(cur) -> dict[int, dict[str, object]]:
cur.execute(
"""
SELECT id, name, employee_id, company, rank, role, department, grp, division, team, cell,
work_status, work_time, phone, email, seat_label, photo_url, sort_order,
created_at, updated_at
FROM members
"""
)
return {int(row["id"]): row for row in cur.fetchall()}
def sync_member_versions(cur, member_ids: list[int], change_reason: str, revision_no: int) -> None:
if not member_ids:
return
unique_ids = sorted(set(int(member_id) for member_id in member_ids))
current_members = fetch_current_member_state(cur)
cur.execute(
"""
SELECT id, member_id, name, company, rank, role, department, grp, division, team, cell,
work_status, work_time, phone, email, photo_url, valid_from, valid_to
FROM member_versions
WHERE member_id = ANY(%s)
AND valid_to IS NULL
""",
(unique_ids,),
)
active_versions = {int(row["member_id"]): row for row in cur.fetchall()}
for member_id in unique_ids:
current = current_members.get(member_id)
active = active_versions.get(member_id)
if current is None:
if active is not None:
cur.execute(
"UPDATE member_versions SET valid_to = NOW() WHERE id = %s AND valid_to IS NULL",
(int(active["id"]),),
)
continue
current_tuple = (
str(current.get("name") or ""),
str(current.get("company") or ""),
str(current.get("rank") or ""),
str(current.get("role") or ""),
str(current.get("department") or ""),
str(current.get("grp") or ""),
str(current.get("division") or ""),
str(current.get("team") or ""),
str(current.get("cell") or ""),
str(current.get("work_status") or ""),
str(current.get("work_time") or ""),
str(current.get("phone") or ""),
str(current.get("email") or ""),
str(current.get("photo_url") or ""),
)
active_tuple = None
if active is not None:
active_tuple = (
str(active.get("name") or ""),
str(active.get("company") or ""),
str(active.get("rank") or ""),
str(active.get("role") or ""),
str(active.get("department") or ""),
str(active.get("grp") or ""),
str(active.get("division") or ""),
str(active.get("team") or ""),
str(active.get("cell") or ""),
str(active.get("work_status") or ""),
str(active.get("work_time") or ""),
str(active.get("phone") or ""),
str(active.get("email") or ""),
str(active.get("photo_url") or ""),
)
if active_tuple == current_tuple:
continue
if active is not None:
cur.execute(
"UPDATE member_versions SET valid_to = NOW() WHERE id = %s AND valid_to IS NULL",
(int(active["id"]),),
)
cur.execute(
"""
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 (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NULL, %s, NULL, %s)
""",
(member_id, *current_tuple, revision_no, change_reason),
)
def fetch_current_seat_assignments(cur) -> dict[int, dict[str, object]]:
cur.execute(
"""
SELECT member_id, seat_map_id, seat_slot_id, seat_label, updated_at
FROM seat_positions
"""
)
return {int(row["member_id"]): row for row in cur.fetchall()}
def sync_seat_assignment_versions(cur, member_ids: list[int], change_reason: str, revision_no: int) -> None:
if not member_ids:
return
unique_ids = sorted(set(int(member_id) for member_id in member_ids))
current_assignments = fetch_current_seat_assignments(cur)
cur.execute(
"""
SELECT id, member_id, seat_map_id, seat_slot_id, seat_label
FROM seat_assignment_versions
WHERE member_id = ANY(%s)
AND valid_to IS NULL
""",
(unique_ids,),
)
active_versions = {int(row["member_id"]): row for row in cur.fetchall()}
for member_id in unique_ids:
current = current_assignments.get(member_id)
active = active_versions.get(member_id)
current_tuple = None
if current is not None:
current_tuple = (
current.get("seat_map_id"),
current.get("seat_slot_id"),
str(current.get("seat_label") or ""),
)
active_tuple = None
if active is not None:
active_tuple = (
active.get("seat_map_id"),
active.get("seat_slot_id"),
str(active.get("seat_label") or ""),
)
if active_tuple == current_tuple:
continue
if active is not None:
cur.execute(
"UPDATE seat_assignment_versions SET valid_to = NOW() WHERE id = %s AND valid_to IS NULL",
(int(active["id"]),),
)
if current is None:
continue
cur.execute(
"""
INSERT INTO seat_assignment_versions (
member_id, seat_map_id, seat_slot_id, seat_label,
valid_from, valid_to, revision_no, changed_by_user_id, change_reason
)
VALUES (%s, %s, %s, %s, NOW(), NULL, %s, NULL, %s)
""",
(
member_id,
current.get("seat_map_id"),
current.get("seat_slot_id"),
str(current.get("seat_label") or ""),
revision_no,
change_reason,
),
)
def fetch_members_as_of(cur, as_of: datetime) -> list[dict[str, object]]:
cur.execute(
"""
SELECT mv.member_id AS id,
mv.name,
COALESCE(m.employee_id, '') AS employee_id,
mv.company,
mv.rank,
mv.role,
mv.department,
mv.grp,
mv.division,
mv.team,
mv.cell,
mv.work_status,
mv.work_time,
mv.phone,
mv.email,
COALESCE(sav.seat_label, '') AS seat_label,
mv.photo_url,
COALESCE(m.sort_order, 2147483647) AS sort_order,
mv.created_at,
mv.valid_from AS updated_at,
mv.valid_to AS history_valid_to
FROM member_versions mv
LEFT JOIN members m
ON m.id = mv.member_id
LEFT JOIN seat_assignment_versions sav
ON sav.member_id = mv.member_id
AND sav.valid_from <= %s
AND (sav.valid_to IS NULL OR sav.valid_to > %s)
WHERE mv.valid_from <= %s
AND (mv.valid_to IS NULL OR mv.valid_to > %s)
ORDER BY COALESCE(m.sort_order, 2147483647) ASC, mv.member_id ASC
""",
(as_of, as_of, as_of, as_of),
)
return cur.fetchall()
def build_member_compare_items(from_items: list[dict[str, object]], to_items: list[dict[str, object]]) -> list[dict[str, object]]:
tracked_fields = (
("company", "소속회사", "기본"),
("rank", "직급", "기본"),
("role", "직책", "기본"),
("department", "부서", "조직"),
("grp", "그룹", "조직"),
("division", "디비전", "조직"),
("team", "", "조직"),
("cell", "", "조직"),
("work_status", "근무상태", "기본"),
("work_time", "근무시간", "기본"),
("phone", "전화번호", "기본"),
("email", "이메일", "기본"),
)
def build_summary(item: dict[str, object] | None) -> list[str]:
if not item:
return []
summary_fields = (
("rank", "직급"),
("role", "직책"),
("department", "부서"),
("grp", "그룹"),
("division", "디비전"),
("team", ""),
("cell", ""),
)
lines: list[str] = []
for field, label in summary_fields:
value = str(item.get(field) or "").strip()
if value:
lines.append(f"{label}: {value}")
return lines
from_map = {int(item["id"]): item for item in from_items}
to_map = {int(item["id"]): item for item in to_items}
all_ids = sorted(set(from_map) | set(to_map))
items: list[dict[str, object]] = []
for member_id in all_ids:
before = from_map.get(member_id)
after = to_map.get(member_id)
if before is None and after is not None:
items.append(
{
"member_id": member_id,
"name": str(after.get("name") or "-"),
"status": "added",
"status_label": "신규",
"categories": ["신규"],
"changed_at": after.get("updated_at"),
"changes": [],
"before_lines": [],
"after_lines": build_summary(after),
}
)
continue
if before is not None and after is None:
items.append(
{
"member_id": member_id,
"name": str(before.get("name") or "-"),
"status": "removed",
"status_label": "삭제",
"categories": ["삭제"],
"changed_at": before.get("history_valid_to") or before.get("updated_at"),
"changes": [],
"before_lines": build_summary(before),
"after_lines": [],
}
)
continue
if before is None or after is None:
continue
changes: list[dict[str, str]] = []
categories: set[str] = set()
for field, label, category in tracked_fields:
before_value = str(before.get(field) or "").strip()
after_value = str(after.get(field) or "").strip()
if before_value == after_value:
continue
changes.append(
{
"field": field,
"label": label,
"before": before_value,
"after": after_value,
}
)
categories.add(category)
if not changes:
continue
items.append(
{
"member_id": member_id,
"name": str(after.get("name") or before.get("name") or "-"),
"status": "updated",
"status_label": "변경",
"categories": sorted(categories),
"changed_at": after.get("updated_at") or before.get("updated_at"),
"changes": changes,
"before_lines": [f"{change['label']}: {change['before'] or '-'}" for change in changes],
"after_lines": [f"{change['label']}: {change['after'] or '-'}" for change in changes],
}
)
order_map = {"added": 0, "updated": 1, "removed": 2}
items.sort(key=lambda item: (order_map.get(str(item.get("status") or ""), 9), str(item.get("name") or ""), int(item.get("member_id") or 0)))
return items
def serialize_seat_map_payload(payload: SeatMapPayload) -> tuple[object, ...]:
return (
payload.name.strip(),
@@ -633,7 +1069,7 @@ def parse_fixed_office_template(office_key: str = FIXED_OFFICE_SOURCE_KEY) -> di
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>',
r'<script\s+src="\./[^"]+payload[^"]*\.js(?:\?[^"]*)?"></script>',
f"<script>{payload_js}</script>",
html,
count=1,
@@ -1165,23 +1601,26 @@ def parse_dxf_layout(file_path: Path) -> tuple[dict[str, object], list[dict[str,
return metadata, slots
def fetch_seat_layout(seat_map_id: int) -> dict[str, object]:
def fetch_seat_layout(seat_map_id: int, as_of: datetime | None = None) -> dict[str, object]:
seat_map = fetch_seat_map(seat_map_id)
if seat_map is None:
raise HTTPException(status_code=404, detail="Seat map not found.")
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT m.id, m.name, m.company, m.rank, m.role, m.department, m.grp, m.division,
m.team, m.cell, m.work_status, m.work_time, m.phone, m.email,
m.seat_label AS member_seat_label, m.photo_url, m.sort_order
FROM members m
ORDER BY m.sort_order ASC, m.id ASC
"""
)
members = cur.fetchall()
if as_of is None:
cur.execute(
"""
SELECT m.id, m.name, m.company, m.rank, m.role, m.department, m.grp, m.division,
m.team, m.cell, m.work_status, m.work_time, m.phone, m.email,
m.seat_label AS member_seat_label, m.photo_url, m.sort_order
FROM members m
ORDER BY m.sort_order ASC, m.id ASC
"""
)
members = cur.fetchall()
else:
members = fetch_members_as_of(cur, as_of)
cur.execute(
"""
SELECT id, slot_key, label, x, y, rotation, layer_name
@@ -1192,21 +1631,48 @@ def fetch_seat_layout(seat_map_id: int) -> dict[str, object]:
(seat_map_id,),
)
slots = cur.fetchall()
cur.execute(
"""
SELECT sp.member_id, sp.row_index, sp.col_index, sp.seat_label,
sp.seat_slot_id,
m.name, m.company, m.rank, m.role, m.department, m.grp, m.division,
m.team, m.cell, m.work_status, m.work_time, m.phone, m.email,
m.photo_url, m.sort_order
FROM seat_positions sp
JOIN members m ON m.id = sp.member_id
WHERE sp.seat_map_id = %s
ORDER BY sp.row_index ASC, sp.col_index ASC, m.sort_order ASC, m.id ASC
""",
(seat_map_id,),
)
placements = cur.fetchall()
if as_of is None:
cur.execute(
"""
SELECT sp.member_id, sp.row_index, sp.col_index, sp.seat_label,
sp.seat_slot_id,
m.name, m.company, m.rank, m.role, m.department, m.grp, m.division,
m.team, m.cell, m.work_status, m.work_time, m.phone, m.email,
m.photo_url, m.sort_order
FROM seat_positions sp
JOIN members m ON m.id = sp.member_id
WHERE sp.seat_map_id = %s
ORDER BY sp.row_index ASC, sp.col_index ASC, m.sort_order ASC, m.id ASC
""",
(seat_map_id,),
)
placements = cur.fetchall()
else:
cur.execute(
"""
SELECT sav.member_id, 0 AS row_index, 0 AS col_index, sav.seat_label,
sav.seat_slot_id,
mv.name, mv.company, mv.rank, mv.role, mv.department, mv.grp, mv.division,
mv.team, mv.cell, mv.work_status, mv.work_time, mv.phone, mv.email,
mv.photo_url, m.sort_order
FROM seat_assignment_versions sav
JOIN members m ON m.id = sav.member_id
JOIN member_versions mv
ON mv.member_id = sav.member_id
AND mv.valid_from <= %s
AND (mv.valid_to IS NULL OR mv.valid_to > %s)
WHERE sav.seat_map_id = %s
AND sav.valid_from <= %s
AND (sav.valid_to IS NULL OR sav.valid_to > %s)
ORDER BY m.sort_order ASC, m.id ASC
""",
(as_of, as_of, seat_map_id, as_of, as_of),
)
placements = cur.fetchall()
cur.execute("SELECT name FROM member_retirements")
retired_names = {str(row["name"] or "").strip() for row in cur.fetchall() if str(row["name"] or "").strip()}
for member in members:
member["is_retired"] = str(member.get("name") or "").strip() in retired_names
viewer_data: dict[str, object] | None = None
office_key = str(seat_map.get("source_url") or FIXED_OFFICE_SOURCE_KEY)
fixed_office = FIXED_OFFICE_CONFIGS.get(office_key)
@@ -1432,6 +1898,8 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
<script>
const seatAssignments = new Map();
let selectedChairKey = null;
let focusedChairKey = null;
let focusedChairPulseUntil = 0;
let viewerMode = "default";
const popup = document.createElement("div");
popup.className = "seat-popup";
@@ -1479,6 +1947,9 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
function focusChair(chairKey, padding = 2200) {
const chair = chairGeometry.find((item) => String(item.key) === String(chairKey));
if (!chair) return;
selectedChairKey = String(chairKey);
focusedChairKey = String(chairKey);
focusedChairPulseUntil = Date.now() + 2600;
const rect = canvas.getBoundingClientRect();
const pad = 24;
const minX = chair.minX - padding;
@@ -1544,8 +2015,31 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
const originalDraw = draw;
draw = function drawWithAssignments() {
originalDraw();
if (!seatAssignments.size) return;
const rect = canvas.getBoundingClientRect();
const now = Date.now();
const focusedChair = focusedChairKey
? chairGeometry.find((item) => String(item.key) === String(focusedChairKey))
: null;
if (focusedChair && now <= focusedChairPulseUntil) {
const pulse = (Math.sin(now / 180) + 1) / 2;
const center = worldToScreen((focusedChair.minX + focusedChair.maxX) / 2, (focusedChair.minY + focusedChair.maxY) / 2);
const width = Math.max(34, (focusedChair.maxX - focusedChair.minX) * camera.scale + 20 + pulse * 18);
const height = Math.max(34, (focusedChair.maxY - focusedChair.minY) * camera.scale + 20 + pulse * 18);
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.save();
ctx.strokeStyle = `rgba(245, 158, 11, ${0.38 + pulse * 0.32})`;
ctx.lineWidth = 3 + pulse * 2;
ctx.shadowColor = "rgba(245, 158, 11, 0.35)";
ctx.shadowBlur = 16 + pulse * 10;
ctx.beginPath();
ctx.roundRect(center.x - width / 2, center.y - height / 2, width, height, 16);
ctx.stroke();
ctx.restore();
if (typeof requestDraw === "function") requestDraw();
} else if (focusedChair && now > focusedChairPulseUntil) {
focusedChairPulseUntil = 0;
}
if (!seatAssignments.size) return;
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.textBaseline = "middle";
for (const chair of chairGeometry) {
@@ -1680,6 +2174,8 @@ def save_seat_layout(seat_map_id: int, payload: SeatLayoutPayload) -> list[dict[
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute("SELECT DISTINCT member_id FROM seat_positions WHERE seat_map_id = %s", (seat_map_id,))
previous_member_ids = [int(row["member_id"]) for row in cur.fetchall()]
if member_ids:
cur.execute("SELECT id FROM members WHERE id = ANY(%s)", (member_ids,))
existing_ids = {int(row["id"]) for row in cur.fetchall()}
@@ -1758,6 +2254,11 @@ def save_seat_layout(seat_map_id: int, payload: SeatLayoutPayload) -> list[dict[
WHERE sp.member_id = m.id
"""
)
affected_member_ids = sorted(set(previous_member_ids + member_ids))
if affected_member_ids:
revision_no = create_history_revision(cur, "seat-layout", f"Seat layout saved for seat_map_id={seat_map_id}")
sync_seat_assignment_versions(cur, affected_member_ids, f"seat-layout:{seat_map_id}", revision_no)
sync_member_versions(cur, affected_member_ids, f"seat-layout:{seat_map_id}", revision_no)
conn.commit()
return fetch_seat_layout(seat_map_id)["placements"]
@@ -1879,6 +2380,13 @@ def replace_members(items: list[MemberPayload]) -> list[dict[str, object]]:
if stale_ids:
cur.execute("DELETE FROM members WHERE id = ANY(%s)", (stale_ids,))
sync_auth_users_from_members(cur)
cur.execute("SELECT id FROM members")
current_ids = [int(row["id"]) for row in cur.fetchall()]
affected_member_ids = sorted(set(current_ids + [int(member["id"]) for member in existing_members]))
if affected_member_ids:
revision_no = create_history_revision(cur, "members-bulk-sync", "Bulk member sync applied")
sync_member_versions(cur, affected_member_ids, "members-bulk-sync", revision_no)
sync_seat_assignment_versions(cur, affected_member_ids, "members-bulk-sync", revision_no)
conn.commit()
return fetch_members()
@@ -3489,6 +3997,7 @@ def startup() -> None:
init_db()
with get_conn() as conn:
with conn.cursor() as cur:
sync_default_business_ledger_source(cur)
sync_auth_users_from_members(cur)
conn.commit()
@@ -3518,6 +4027,37 @@ def health() -> dict[str, object]:
}
@app.get("/api/integration/business-ledger-default")
def integration_business_ledger_default() -> Response:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT filename, mime_type, content
FROM integration_binary_sources
WHERE source_key = %s
ORDER BY imported_at DESC
LIMIT 1
""",
(BUSINESS_LEDGER_DEFAULT_SOURCE_KEY,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Business ledger default source not found.")
filename = str(row["filename"] or "사업관리대장-1.xlsx")
headers = {
"Content-Disposition": 'inline; filename="business-ledger-default.xlsx"',
"X-Source-Filename": "business-ledger-default.xlsx",
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
"Pragma": "no-cache",
}
return Response(
content=bytes(row["content"]),
media_type=str(row["mime_type"] or "application/octet-stream"),
headers=headers,
)
@app.post("/api/auth/login")
def auth_login(
request: Request,
@@ -3677,8 +4217,26 @@ def mock_login(username: str = Form(...), password: str = Form(...)) -> dict[str
@app.get("/api/members")
def list_members() -> dict[str, list[dict[str, object]]]:
return {"items": fetch_members()}
def list_members(as_of: str | None = None) -> dict[str, list[dict[str, object]]]:
parsed_as_of = parse_as_of(as_of)
if parsed_as_of is None:
return {"items": fetch_members()}
with get_conn() as conn:
with conn.cursor() as cur:
return {"items": fetch_members_as_of(cur, parsed_as_of)}
@app.get("/api/history/members/compare")
def compare_members_history(from_date: str, to_date: str) -> dict[str, list[dict[str, object]]]:
parsed_from = parse_as_of(from_date)
parsed_to = parse_as_of(to_date)
if parsed_from is None or parsed_to is None:
raise HTTPException(status_code=400, detail="from_date and to_date are required.")
with get_conn() as conn:
with conn.cursor() as cur:
from_items = fetch_members_as_of(cur, parsed_from)
to_items = fetch_members_as_of(cur, parsed_to)
return {"items": build_member_compare_items(from_items, to_items)}
@app.post("/api/members")
@@ -3702,6 +4260,8 @@ def create_member(payload: MemberPayload) -> dict[str, object]:
)
member = cur.fetchone()
sync_auth_users_from_members(cur)
revision_no = create_history_revision(cur, "member-create", f"Member created id={int(member['id'])}")
sync_member_versions(cur, [int(member["id"])], "member-create", revision_no)
conn.commit()
return {"item": member}
@@ -3747,6 +4307,9 @@ def update_member(member_id: int, payload: MemberPayload) -> dict[str, object]:
if member is None:
raise HTTPException(status_code=404, detail="Member not found.")
sync_auth_users_from_members(cur)
revision_no = create_history_revision(cur, "member-update", f"Member updated id={member_id}")
sync_member_versions(cur, [member_id], "member-update", revision_no)
sync_seat_assignment_versions(cur, [member_id], "member-update", revision_no)
conn.commit()
return {"item": member}
@@ -3759,6 +4322,9 @@ def delete_member(member_id: int) -> dict[str, bool]:
deleted = cur.rowcount > 0
if deleted:
sync_auth_users_from_members(cur)
revision_no = create_history_revision(cur, "member-delete", f"Member deleted id={member_id}")
sync_member_versions(cur, [member_id], "member-delete", revision_no)
sync_seat_assignment_versions(cur, [member_id], "member-delete", revision_no)
conn.commit()
if not deleted:
raise HTTPException(status_code=404, detail="Member not found.")
@@ -4017,13 +4583,13 @@ def update_seat_map(seat_map_id: int, payload: SeatMapPayload) -> dict[str, dict
@app.get("/api/seat-maps/{seat_map_id}/layout")
def get_seat_layout(seat_map_id: int) -> dict[str, object]:
return fetch_seat_layout(seat_map_id)
def get_seat_layout(seat_map_id: int, as_of: str | None = None) -> dict[str, object]:
return fetch_seat_layout(seat_map_id, parse_as_of(as_of))
@app.get("/api/seat-maps/{seat_map_id}/viewer")
def get_seat_map_viewer(seat_map_id: int) -> HTMLResponse:
layout = fetch_seat_layout(seat_map_id)
def get_seat_map_viewer(seat_map_id: int, as_of: str | None = None) -> HTMLResponse:
layout = fetch_seat_layout(seat_map_id, parse_as_of(as_of))
seat_map = layout.get("seat_map") or {}
if seat_map.get("source_type") not in {"dxf", "fixed_html"}:
raise HTTPException(status_code=400, detail="Viewer is only available for supported seat maps.")
@@ -4053,15 +4619,34 @@ def legacy_organization_backup() -> FileResponse:
@app.get("/integrations/payment")
def integration_payment() -> FileResponse:
target = INCOMING_FILES_DIR / "payment.html"
# 8081 phase-1 cleanup: integration HTML is served only from incoming-files/served.
target = INCOMING_SERVED_DIR / "payment.html"
if not target.exists():
raise HTTPException(status_code=404, detail="Payment integration file not found.")
return FileResponse(target)
@app.get("/integrations/ledger")
def integration_ledger() -> HTMLResponse:
try:
html = build_business_ledger_html()
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Business ledger integration file not found.")
except ValueError:
raise HTTPException(status_code=500, detail="Business ledger integration source is invalid.")
return HTMLResponse(
html,
headers={
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
"Pragma": "no-cache",
},
)
@app.get("/integrations/mh")
def integration_mh() -> FileResponse:
target = INCOMING_FILES_DIR / "mh.html"
# Keep the served path explicit so comparison/reference copies are never picked up by accident.
target = INCOMING_SERVED_DIR / "mh.html"
if not target.exists():
raise HTTPException(status_code=404, detail="MH integration file not found.")
return FileResponse(target)

78
docker-compose.8081.yml Normal file
View File

@@ -0,0 +1,78 @@
services:
proxy:
image: nginx:1.27-alpine
depends_on:
frontend:
condition: service_healthy
backend:
condition: service_healthy
ports:
- "8081:80"
volumes:
- ./proxy/nginx.conf:/etc/nginx/conf.d/default.conf:ro
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1/ || exit 1"]
interval: 15s
timeout: 5s
retries: 5
start_period: 10s
frontend:
build:
context: .
dockerfile: frontend/Dockerfile
volumes:
- ./frontend/public:/usr/share/nginx/html:ro
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1/ || exit 1"]
interval: 15s
timeout: 5s
retries: 5
start_period: 10s
backend:
build:
context: .
dockerfile: backend/Dockerfile
command: uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 --reload
env_file:
- .env
depends_on:
db:
condition: service_healthy
volumes:
- ./backend/app:/app/backend/app:ro
- ./DashBoard-organization.html:/app/legacy/DashBoard-organization.html:ro
- ./DashBoard-organization-backup.html:/app/legacy/DashBoard-organization-backup.html:ro
- ./legacy/static:/app/legacy/static:ro
- ./incoming-files:/app/incoming-files:ro
- uploads_data:/data/uploads
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health')\" || exit 1"]
interval: 15s
timeout: 5s
retries: 8
start_period: 20s
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
volumes:
postgres_data:
uploads_data:

View File

@@ -214,6 +214,16 @@
- 특정 날짜의 자리배치도 재구성 가능
- 기간 비교나 변경 추적 UI로 확장 가능
### 현재 반영 상태
- `history_revisions`
- `member_versions`
- `seat_assignment_versions`
- `entity_change_events`
초기 단계로 테이블과 baseline backfill 경로를 먼저 추가했다.
아직 조직도/자리배치도 쓰기 API가 매 수정마다 version row 를 append 하도록 완전히 전환된 상태는 아니다.
### 설계 문서
- [HISTORY_ASOF_DB_PLAN.md](/home/hyunho/projects/mh-dashboard-organization/docs/HISTORY_ASOF_DB_PLAN.md)

View File

@@ -11,14 +11,21 @@
### 코드 경로
- 공개용 `8080`: `/home/hyunho/projects/mh-dashboard-organization`
- 작업용 `8081`: `/tmp/mh-dashboard-organization-dev`
- 작업용 `8081`: `/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081`
### 작업용 Compose 기준
- 공개용 `8080` stack: `docker-compose.yml`
- 작업용 `8081` stack: `docker-compose.8081.yml`
- 작업용 project name 기본값: `mh-dashboard-organization-dev`
- 작업용 `8081`는 반드시 `/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081`에서 띄운다
### DB 볼륨
- 공개용 `8080`: `mh-dashboard-organization_postgres_data`
- 작업용 `8081`: `mh-dashboard-organization-dev_postgres_data`
즉 현재는 코드도 분리, DB도 분리 상태다.
즉 현재는 `8080``8081` 이 코드 workspace 와 DB volume 모두 분리 상태로 운영한다.
## 정본 기준
@@ -161,15 +168,35 @@
반복 가능한 동기화 스크립트:
- [sync_prod_db_to_dev.sh](/home/hyunho/projects/mh-dashboard-organization/scripts/sync_prod_db_to_dev.sh)
- [docker-compose.8081.yml](/home/hyunho/projects/mh-dashboard-organization/docker-compose.8081.yml)
사용 방법:
```bash
chmod +x scripts/sync_prod_db_to_dev.sh
./scripts/prepare_dev_worktree.sh
cd /home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081
docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compose.8081.yml up -d --build
./scripts/sync_prod_db_to_dev.sh minimal
./scripts/sync_prod_db_to_dev.sh full
```
`prepare_dev_worktree.sh`가 같이 처리하는 것:
- 메인 workspace를 `.dev-worktree-8081`로 복제 또는 재사용
- `.env` 복사
- 로컬 전용 디자인 참고 자산 복사
- `incoming-files/sample style.css`
- `incoming-files/260320.html`
- `incoming-files/사업관리대장/`
- `incoming-files/1.png`
- `incoming-files/seat/center_chair_people_map(2).html`
중요:
- `8081`은 현재 메인 workspace를 직접 마운트하면 안 된다
- 컨테이너가 `/home/hyunho/projects/mh-dashboard-organization/...`를 물고 있으면 분리 상태가 깨진 것이다
- 정상 상태는 `docker inspect mh-dashboard-organization-dev-backend-1` 기준 마운트 소스가 `/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/...`로 나와야 한다
규칙:
- `minimal`
@@ -179,6 +206,11 @@ chmod +x scripts/sync_prod_db_to_dev.sh
주의:
- 스크립트는 동기화 전에 `8081``proxy`, `frontend`, `backend` 를 잠시 멈춘다
- 이유는 중간 상태를 읽는 API 요청과 DB truncate/restore 가 충돌하면 deadlock 또는 부분 검증이 발생할 수 있기 때문이다
- 스크립트는 `8080` DB 데이터를 덤프해서 `8081` DB의 대상 테이블을 비우고 다시 적재한다
- `8081`에서만 존재하던 대상 테이블 데이터는 사라진다
- `seat_positions` 는 portable CSV 경로로 별도 복원한다
- 복원 후 `members.seat_label`, `auth.users`, history backfill 을 다시 맞춘다
- 실행 후 주요 테이블 수량과 seat 정합성 수치를 출력한다
- 따라서 실행 전 현재 작업용 DB 상태를 유지해야 하면 별도 백업 후 실행한다

View File

@@ -2,189 +2,144 @@
## Current Base
- branch: `total`
- latest checked commit: `24852d4`
- 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)
- `8080` 공개 기준 브랜치: `total`
- `8081` 작업 기준 브랜치: `work-8081`
- `8080` 공개 기준 커밋: `637b390`
- `8081` worktree 경로: `/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081`
- `8081` 실제 서빙 책임 맵: [architecture/8081_SERVING_MAP.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/8081_SERVING_MAP.md)
- 메인 히스토리: [DEVELOPMENT_HISTORY.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/DEVELOPMENT_HISTORY.md)
- 작업 룰북: [WORK_RULEBOOK.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/WORK_RULEBOOK.md)
- 실행 플로우: [WORK_EXECUTION_FLOW.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/WORK_EXECUTION_FLOW.md)
- dev/prod DB 프로토콜: [DEV_PROD_DB_PROTOCOL.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/DEV_PROD_DB_PROTOCOL.md)
- 회귀 체크리스트: [REGRESSION_CHECKLIST.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/REGRESSION_CHECKLIST.md)
## Mandatory Start Rule
아침 또는 그날의 첫 작업을 시작할 때는 코드를 수정하기 전에 반드시 아래 순서를 먼저 수행해야 한다.
첫 작업 전에는 아래 순서를 먼저 확인한다.
1. Gitea 브랜치 상태 확인
1. 브랜치 기준 확인
2. 열린 이슈 확인
3. [WORK_RULEBOOK.md](/home/hyunho/projects/mh-dashboard-organization/docs/WORK_RULEBOOK.md) 확인
3. [WORK_RULEBOOK.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/WORK_RULEBOOK.md) 확인
4. 이 문서 확인
5. 현재 워크트리의 미푸시 커밋, 변경 파일, 미추적 파일 확인
5. `git status`, 변경 파일, 미추적 파일 확인
주의:
- 위 절차를 확인하기 전에는 새 코드 작성이나 기존 코드 수정부터 시작하지 않는다.
- `8080` 기준 코드는 직접 수정하지 않는다.
- 새 작업은 항상 `.dev-worktree-8081`에서 진행한다.
- 커밋과 푸시는 사용자 지시가 있을 때만 수행한다.
## What Was Finished
## Confirmed Runtime Rule
### Dashboard Integration
- `8080`은 루트 workspace의 `total` 기준으로 유지한다.
- `8081``.dev-worktree-8081` + `work-8081` 기준으로만 수정한다.
- `main`, `hyunho`는 보류 브랜치이며 현재 작업에 사용하지 않는다.
- `8081` 변경을 `8080`에 올릴 때는 reviewed file diff 기준으로만 반영한다.
- `8081` DB는 운영 정본이 아니라 `8080` 기준 검증용 복제본처럼 다룬다.
- `조직 현황`, `프로젝트별 분석`, `팀/개인별 분석`, `자리배치도`를 하나의 허브에 통합
- `payment.html`, `mh.html`을 현재 프로젝트에 편입
- 공통 헤더, 탭, 로그인 정보, 공통 기간 제어 구성
## What Was Stabilized
### Integrated DB
### Branch / Worktree Safety
- `organization.xlsx`, `MH.xlsx`, `payment.csv`, `ptj.csv` 기반 통합 DB 구성
- raw/staging/standard 성격의 구조를 PostgreSQL에 반영
- `members`, `seat_maps`, `seat_slots`, `seat_positions`
- `integration_raw_*`, `integration_work_logs`, `integration_work_log_segments`, `integration_vouchers`
- 프로젝트 카테고리 매핑 반영
- 기존 `8081` 작업본은 [`.dev-worktree-8081-backup-2026-04-01`](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081-backup-2026-04-01)로 보존
- 현재 [`.dev-worktree-8081`](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081)는 `work-8081` 기준으로 재생성
- `8080` 루트 workspace는 그대로 두고 분리 운영
### Team / Member Analysis
### 8081 Design / Serving Baseline
- `omh.html` 원본 기준으로 계산식/카테고리/디자인 복원
- DB raw MH 데이터를 원본 입력 구조처럼 다시 공급하는 방식으로 정리
- 디자인 SSOT 토큰:
- [frontend/public/design-tokens.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-tokens.css)
- 디자인 SSOT 패턴:
- [frontend/public/design-patterns.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-patterns.css)
- 디자인 기준 문서:
- [architecture/DESIGN_SSOT.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/DESIGN_SSOT.md)
- 로그인 기본 스타일은 [frontend/public/styles.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/styles.css) 기준으로 유지
- `8081` 허브 전용 디자인은 [frontend/public/styles-8081-design.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/styles-8081-design.css)에서만 덮어씀
- 조직현황은 [legacy/static/common.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/legacy/static/common.css), [legacy/static/organization.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/legacy/static/organization.css), [legacy/static/organization.js](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/legacy/static/organization.js)를 사용
- 프로젝트별 분석 디자인은 [incoming-files/served/payment.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/payment.html) 내부에서 `design-tokens.css` + `design-patterns.css`를 참조
- 사업관리대장 상세 팝업 디자인은 [incoming-files/사업관리대장/ledger-override.js](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/사업관리대장/ledger-override.js)에서 `design-tokens.css` + `design-patterns.css`를 직접 링크
### Project Analysis
디자인 수정 우선순위:
- `opayment.html` 원본 기준으로 화면 복원
- `payment.csv` 분류 우선, `ptj.csv` fallback 적용
- 연장근무는 `연장근무 시간(가공)` 기준으로 반영
### Organization / Seat Map
- 조직도 상세 프로필에 `재석위치` preview 연결
- 관리자/비관리자 자리배치도 화면 분리
- 저장 후 조직도와 비관리자 열람에 반영되도록 seat save 흐름 정리
- seat persistence bug 수정
- 원인: `seat_positions_map_cell_idx`가 slot 기반 도면에도 적용됨
- 조치: `seat_slot_id IS NULL`인 grid map에만 적용되도록 수정
### Member Data Governance
- 이름 alias, 퇴사 제외, 조직 override를 DB 테이블 기반으로 전환
- 사용 테이블:
- `member_aliases`
- `member_retirements`
- `member_overrides`
### Auth Baseline
- 실제 로그인 API 연결 완료
- 프런트 로그인 화면이 `/api/auth/login` 사용
- 세션/로그아웃/세션 조회 API 구성 완료
- 사용 테이블:
- `auth.users`
- `auth.sessions`
- `auth.login_audit_logs`
- 현재 남은 범위:
- mock login 정리
- 역할별 권한 체크 적용
- 쓰기 API 보호 범위 정리
### External Access
- WSL 내부 8080 리슨 확인
- 현재 다른 PC에서 접속 확인
- 현재 기준 주소:
- `http://172.16.40.144:8080`
## Important Runtime Notes
### Dev / Prod Protocol
- 코드 선행은 `8081`, 공개 반영은 `8080`
- 데이터 정본은 `8080` DB
- `8081` DB는 독립 정본이 아니라 `8080` 기준 복제본처럼 관리해야 함
- 조직도, 멤버, 자리배치 검증 전에는 `DEV_PROD_DB_PROTOCOL.md`를 먼저 확인
- 기능 수정 후 완료 판단은 `REGRESSION_CHECKLIST.md`를 기준으로 해야 함
### Seat Map Save
- 저장이 안 되면 먼저 backend 로그에서 `PUT /api/seat-maps/{id}/layout` 상태코드 확인
- 과거 핵심 장애는 DB 인덱스 충돌이었다
- 현재 저장 구조는:
- `seat_positions`
- `members.seat_label`
둘 다 같이 갱신
### External Access
- Windows LAN IP가 바뀌면 접속 주소가 바뀔 수 있음
- WSL IP가 바뀌면 `portproxy connectaddress`를 다시 맞춰야 함
- 다음 확인 명령:
- Windows: `ipconfig`
- WSL: `hostname -I`
- Windows: `netsh interface portproxy show all`
## Open Issues
- `#2` 백엔드 영속 저장 구조 운영 마무리
- `#3` 사무실 좌석 배치도 조회 및 관리자 편집 기능 고도화
- `#5` 실제 인증 체계 전환
- `#7` 자리배치도 팀별 색상 오버레이 표시
- `#8` 자리배치도 좌석 클릭 시 개인 상위 조직 트리 표시
- `#9` 조직도·자리배치도 변경 이력 버전 누적 저장
현재 해석:
- `#6`은 코드 기준 사실상 완료 상태이며 Gitea 정리 대상
- `#5`는 "로그인 구현"보다 "권한 제어 마무리"가 핵심
- `#2`의 기존 "스냅샷 검증" 범위는 현재 코드와 불일치하므로 범위 재정의 필요
## Unfinished Ideas Discussed Today
### Seat Map UX
- 자리배치도 내 인원 등록 시 팀별 색상 표시
- 좌석 클릭 시 본인까지의 상위 조직 트리 표시
- 나머지 사무실 2개 도면 추가
- `한맥빌딩 7층`
- `한맥빌딩 6층`
- 비관리자 열람 화면 품질 추가 점검
### History / Versioning
- 조직도와 자리배치도 수정 이력을 버전 누적형으로 저장
- 원본 DB와 별도의 history/version 구조 설계
- `valid_from`, `valid_to` 기반 시점 조회(as-of date) 구조 적용
- 날짜 또는 revision label 기준으로 버전 묶음 관리
- 상세 설계 문서:
- [HISTORY_ASOF_DB_PLAN.md](/home/hyunho/projects/mh-dashboard-organization/docs/HISTORY_ASOF_DB_PLAN.md)
1. [frontend/public/design-tokens.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-tokens.css)
2. [frontend/public/design-patterns.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-patterns.css)
3. 화면별 실제 서빙 파일
주의:
- 현재 코드에는 조직도/자리배치도 버전 이력 기능이 아직 없음
- 월간 스냅샷 방향은 범위에서 제외
### Project Analysis Accuracy
- `incoming-files/sample style.css`는 참고 기준이지만 직접 런타임 수정 파일이 아니다.
- `incoming-files` 원본/reference 파일을 먼저 고치지 않는다.
- 새 디자인 수정은 먼저 토큰/패턴 파일에서 해결 가능한지 확인한 뒤, 불가피할 때만 화면별 파일에 내린다.
- 총합은 거의 맞았지만 일부 프로젝트 단위 소수점/분류 오차는 추가 정밀 보정 필요
- `opayment` 기준으로 특정 프로젝트 차이를 계속 줄여야 함
### 1차 구조 정리 진행분
### Auth / Permission
- 이슈 기준:
- `#14` 전체 구조 정리 umbrella
- `#18` 1차: 파일 책임 맵 정리 및 프런트 서빙 경로 정돈
- `#19` 2차: 백엔드 라우터/서빙 책임 분리
- `#20` 3차: worktree/스크립트/문서 정리
- 책임 맵 문서 추가:
- [architecture/8081_SERVING_MAP.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/8081_SERVING_MAP.md)
- `/integrations/payment`, `/integrations/mh`의 실제 서빙 파일을 분리:
- [incoming-files/served/payment.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/payment.html)
- [incoming-files/served/mh.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/mh.html)
- 기존 [incoming-files/payment.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/payment.html), [incoming-files/mh.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/mh.html)은 비교/복구용 복사본으로 당분간 유지
- backend 서빙 경로는 [backend/app/main.py](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/backend/app/main.py)에서 `incoming-files/served/*`를 보도록 정리 시작
- mock login을 개발용 fallback 수준으로 제한하거나 제거
- 역할별 접근 제어 정리
- 조직도/자리배치도/분석 화면 권한 경계 재정리
## Current Actual Serving Map
- `/`:
- [frontend/public/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/index.html)
- `/styles.css`:
- [frontend/public/styles.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/styles.css)
- `/styles-8081-design.css`:
- [frontend/public/styles-8081-design.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/styles-8081-design.css)
- `/legacy/organization`:
- [legacy/static/DashBoard-organization.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/legacy/static/DashBoard-organization.html)
- `/integrations/payment`:
- [incoming-files/served/payment.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/payment.html)
- `/integrations/mh`:
- [incoming-files/served/mh.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/mh.html)
## Cross Checks Last Confirmed
- `8080`: `curl http://localhost:8080/api/health` 정상
- `8081` dev 컨테이너: proxy/backend/frontend/db `healthy`
- `8081` backend 내부 확인:
- `/api/health` 200
- `/legacy/organization` 200
- `/integrations/payment` 200
- `/integrations/mh` 200
- `incoming-files/served` 내 실제 서빙 파일 존재 확인
주의:
- Codex 터미널 세션에서는 `curl http://localhost:8081`가 간헐적으로 실패할 수 있다.
- 이 경우 브라우저 확인 또는 컨테이너 내부 라우트 확인을 기준으로 판단한다.
## Open Issues Relevant Now
- `#14` 누적된 임시 로직 정리 및 중복 코드 제거
- `#16` 사업관리대장 메인 연동 및 기본 원본 DB화
- `#17` 8081 분리 worktree 기동 절차와 로컬 디자인 자산 복제 고정
- `#18` 8081 파일 책임 맵 정리 및 프런트 서빙 경로 정돈
- `#19` 8081 백엔드 라우터/서빙 책임 분리
- `#20` 8081 worktree 준비 스크립트·문서·운영 규칙 정리
## Recommended Next Work Order
1. `#2` 범위를 현재 코드 기준으로 재정의하고 영속성 운영 검증 완료
2. `#5`에서 권한 체크, mock login 정리, 쓰기 API 보호 적용
3. `8081` DB를 `8080` 정본 기준으로 동기화하는 반복 가능한 절차 마련
4. `#9`를 as-of date 기반 history 구조로 설계 후 `members`, `seat_positions` 부터 이력화
5. 그 다음 `#8`, 나머지 도면 추가, `#7`, 프로젝트 분석 오차 보정 순으로 진행
1. `#18` 범위에서 실제 서빙 파일과 비교용 파일 경계를 더 명확히 정리
2. 사업관리대장 탭 기능 추가 전에 수정 대상 파일을 고정
3. 그 다음 `#19`로 backend 라우터/서빙 책임 분리
4. 마지막으로 `#20`에서 스크립트/문서/운영 규칙 정리
## Quick Resume Prompt
다음 세션 시작 시 아래 기준으로 이어가면 된다.
- 브랜치 `total`에서 시작
- 최근 커밋 `1d15cf9` 확인
- `docs/DEVELOPMENT_HISTORY.md`
- `docs/NEXT_SESSION_CHECKPOINT.md`
- `docs/DEV_PROD_DB_PROTOCOL.md`
- `docs/REGRESSION_CHECKLIST.md`
- `docs/HISTORY_ASOF_DB_PLAN.md`
- Gitea 이슈 `#2`, `#5`, `#9`
그리고 먼저 현재 외부 접속, 자리배치 저장, 실제 로그인 동작을 확인한 뒤 다음 기능 개발로 넘어간다.
- `8080` 기준은 `total`
- `8081` 작업은 `work-8081` + `.dev-worktree-8081`
- 먼저 [WORK_RULEBOOK.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/WORK_RULEBOOK.md), [NEXT_SESSION_CHECKPOINT.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/NEXT_SESSION_CHECKPOINT.md), [architecture/8081_SERVING_MAP.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/8081_SERVING_MAP.md) 확인
- 디자인 수정이면 [frontend/public/design-tokens.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-tokens.css), [frontend/public/design-patterns.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-patterns.css), [architecture/DESIGN_SSOT.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/DESIGN_SSOT.md) 먼저 확인
- 현재 1차 구조 정리 기준 이슈는 `#18`
- 작업 전 `git status`, dev 컨테이너 상태, `/api/health`, `/legacy/organization`, `/integrations/payment`, `/integrations/mh`를 먼저 확인

View File

@@ -25,6 +25,8 @@
- `8081` 작업용 접속 확인
- `8080` 공개용 접속 확인
- `docker compose ps`에서 `backend`, `frontend`, `proxy`, `db`가 정상인지 확인
- `8081`은 기본적으로 `./scripts/start_8081.sh` 또는 `./scripts/prepare_dev_worktree.sh``.dev-worktree-8081` 에서 `docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compose.8081.yml up -d --build` 로 기동
- `8081` 기동 후 `docker inspect mh-dashboard-organization-dev-backend-1`에서 마운트 경로가 `.dev-worktree-8081/...`인지 확인
### 2. 데이터 동기화 범위 결정

269
docs/WORK_EXECUTION_FLOW.md Normal file
View File

@@ -0,0 +1,269 @@
# Work Execution Flow
## 목적
이 문서는 앞으로 이 프로젝트에서 작업을 어떤 순서로 진행해야 하는지 아주 쉽게 고정하기 위한 문서다.
세미나에서 들은 흐름을 이 프로젝트 기준으로 다시 쓰면 아래 순서다.
1. `SSOT` 먼저 확인
2. 이슈 생성 또는 연결
3. 완료조건 먼저 적기
4. 실행 계획 적기
5. 필요한 동기화 먼저 하기
6. 코드 수정 / 화면 작업 수행
7. 가드레일 테스트
8. 기록 남기기
이 순서를 지키는 이유는 하나다.
- 작업 도중 기준이 바뀌지 않게 하기
- 임시 연결이 누적되지 않게 하기
- 나중에 봐도 왜 이렇게 했는지 알 수 있게 하기
- `8081` 작업이 `8080`을 망가뜨리지 않게 하기
## 1. SSOT 먼저 확인
`SSOT`는 Single Source Of Truth 의 줄임말이다.
쉬운 말로:
- "무엇을 기준 진실로 볼 것인가"
이걸 먼저 정하지 않으면 작업 중간에 기준이 계속 바뀌어서 코드가 꼬인다.
이 프로젝트에서 자주 쓰는 SSOT:
- 공개용 코드 기준: `/home/hyunho/projects/mh-dashboard-organization`
- 작업용 코드 기준: `/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081`
- 데이터 정본 기준: `8080` DB
- 기능 검증 기준: `8081`
- 사업관리대장 디자인 기준: `MH 통합 대시보드_260320.html`
- 허브 공통 시각 언어 기준: `sample style.css`
- 런타임 디자인 토큰 기준: `frontend/public/design-tokens.css`
- 런타임 디자인 패턴 기준: `frontend/public/design-patterns.css`
- 현재 작업 지시 기준: 연결된 Gitea 이슈
작업 시작 전에 먼저 정해야 하는 질문:
- 이번 작업의 코드 기준은 어디인가?
- 이번 작업의 데이터 기준은 어디인가?
- 이번 화면의 디자인 기준 파일은 무엇인가?
- 지금 바꾸려는 화면이 실제로 어떤 파일에서 렌더링되는가?
이걸 모르고 코드를 건드리면 높은 확률로 엉뚱한 파일을 수정하게 된다.
디자인 작업 추가 규칙:
- 디자인 수정은 항상 `design-tokens.css``design-patterns.css`를 먼저 확인한다.
- 색/패널/버튼/테이블/팝업이 공통 규칙으로 해결 가능한지 먼저 본다.
- 해결 가능하면 화면별 파일을 고치지 않고 토큰/패턴 파일에서 수정한다.
- 화면별 실제 서빙 파일은 마지막 단계에서만 조정한다.
- 원본/reference 파일은 비교용이지 직접 수정 우선 대상이 아니다.
## 2. 이슈 생성 또는 연결
작업은 이슈 없이 하지 않는다.
이유:
- 왜 하는 작업인지 남기기 위해
- 중간에 범위가 커지는 걸 막기 위해
- 다음 세션에서 바로 이어가기 위해
좋은 이슈는 아래 4개가 있어야 한다.
1. 배경
2. 목표
3. 현재 상태
4. 남은 작업
이슈는 길게 쓸 필요는 없다.
하지만 최소한 아래는 있어야 한다.
- 왜 이 작업을 하는지
- 어디까지가 이번 범위인지
- 무엇을 완료로 볼지
## 3. 완료조건 먼저 적기
이 단계가 중요하다.
완료조건이 없으면 "대충 된 것 같음" 상태에서 끝나기 쉽다.
좋은 완료조건 예시:
- `8081``.dev-worktree-8081`를 실제로 마운트한다
- `사업관리대장` 탭이 원본 기준 레이아웃으로 열린다
- `8080`은 영향 없이 유지된다
- 관련 회귀 검증을 통과한다
나쁜 완료조건 예시:
- 화면이 좀 괜찮아 보인다
- 아마 될 것 같다
- 코드 정리함
완료조건은 반드시 확인 가능한 문장이어야 한다.
즉:
- "봤을 때 예쁨"이 아니라
- "어떤 URL에서 어떤 동작이 확인됨"이어야 한다
## 4. 실행 계획 적기
계획은 길 필요 없다.
이 프로젝트에서는 보통 아래 정도면 충분하다.
1. 기준 파일과 현재 연결 구조 확인
2. `8081` worktree 기준으로만 수정
3. 필요한 데이터 동기화
4. 화면/기능 수정
5. 회귀 검증
6. 이슈 코멘트와 체크포인트 기록
핵심은:
- 수정 전에 먼저 구조를 파악하고
- 범위를 정하고
- 검증까지 포함해서 끝내는 것
## 5. 실행 전 동기화
이 프로젝트는 코드만 맞아도 안 되고, 데이터도 맞아야 한다.
그래서 실행 전에 동기화가 필요할 수 있다.
무슨 뜻이냐면:
- `8081`에서 기능 확인을 하더라도
- 데이터가 `8080`과 다르면 검증 결과를 신뢰하면 안 된다
자주 쓰는 규칙:
- 조직도 / 멤버 / 자리배치 검증 전
- `./scripts/sync_prod_db_to_dev.sh minimal`
- 분석 화면까지 공개용 기준으로 맞춰야 할 때
- `./scripts/sync_prod_db_to_dev.sh full`
또 코드 동기화도 중요하다.
- `8081`은 메인 workspace에서 직접 띄우지 않는다
- 먼저 `./scripts/prepare_dev_worktree.sh`
- 그 다음 `.dev-worktree-8081`에서 실행
즉 이 프로젝트의 동기화는 두 종류다.
- DB 동기화
- 코드/worktree 동기화
## 6. 실제 실행
이 단계가 코드를 고치는 단계다.
하지만 여기서도 규칙이 있다.
- `8081`에서 먼저 작업
- 기준 파일이 아닌 곳은 건드리지 않기
- 임시 우회 연결을 만들었으면 반드시 기록 남기기
- 연결 구조가 난잡해지면 바로 이슈에 `코드 정리 필요`를 남기기
특히 이 프로젝트는 아래가 자주 꼬인다.
- `frontend/public`
- `legacy/static`
- `incoming-files`
- 정적 HTML
- iframe 연결
- 버전 쿼리스트링
그래서 실행 중 계속 확인해야 한다.
- 지금 내가 고친 파일이 실제 서빙 파일이 맞는가?
- 지금 수정이 `8081` 전용인가, `8080` 공통인가?
- 이 연결은 임시인가, 기준 구조인가?
## 7. 가드레일 테스트
가드레일 테스트는 쉬운 말로:
- "이 수정 때문에 같이 망가지면 안 되는 것들을 확인하는 테스트"
즉 핵심 기능만 보는 게 아니라, 같이 깨지기 쉬운 주변 기능까지 확인하는 것이다.
이 프로젝트에서 가드레일 테스트 예시:
- `8081` 디자인 수정 후
- `8080`은 그대로인지 확인
- 조직현황 수정 후
- 조직도 iframe, 모달, 리스트뷰, seat preview 확인
- 자리배치 수정 후
- 관리자 저장
- 비관리자 조회
- 조직도 상세 seat preview
- 분석 화면 수정 후
- 기간 필터
- 프로젝트/팀 전환
- 빈 데이터 상태
- 스타일 깨짐 여부
가드레일 테스트는 "다 테스트한다"가 아니다.
이번 수정 때문에 같이 깨질 가능성이 높은 것만 빠르게 확인하는 것이다.
## 8. 기록 남기기
작업은 기록까지 남겨야 끝난다.
남겨야 하는 것:
- 무엇을 바꿨는지
- 무엇을 기준으로 했는지
- 무엇을 검증했는지
- 무엇이 아직 안 끝났는지
- 다음에 어디서 이어야 하는지
남길 위치:
- Gitea 이슈 코멘트
- 체크포인트 문서
- 필요하면 룰북/프로토콜 문서
## 이 프로젝트용 한 줄 버전
앞으로는 아래 순서로 생각하면 된다.
1. 기준 진실부터 정한다
2. 이슈에 작업 목적과 완료조건을 적는다
3. 실행 전에 코드/DB 동기화를 맞춘다
4. `8081`에서만 수정한다
5. 같이 깨지면 안 되는 것까지 확인한다
6. 결과를 기록한다
## 시작할 때 바로 쓰는 짧은 템플릿
작업 시작 전에 아래 6줄만 적어도 된다.
- SSOT:
- 코드 기준:
- 데이터 기준:
- 디자인 기준:
- 이슈:
- 완료조건:
- 계획:
- 필요한 동기화:
- 가드레일 테스트:
예시:
- SSOT:
- 코드 기준: `/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081`
- 데이터 기준: `8080` DB를 sync한 `8081`
- 디자인 기준: `MH 통합 대시보드_260320.html`
- 이슈: `#16`
- 완료조건: `8081`에서 사업관리대장 메인이 원본 톤으로 열리고 `8080`은 안 바뀜
- 계획: 연결 확인 → worktree 수정 → 검증 → 이슈 기록
- 필요한 동기화: `minimal`
- 가드레일 테스트: `8080 유지`, `조직현황 탭`, `프로젝트/팀 탭`

View File

@@ -30,6 +30,11 @@
- "오늘 첫 작업"의 시작점은 코드 수정이 아니라 상태 확인이다.
- 이 절차를 건너뛰고 바로 수정 작업에 들어가는 것은 금지한다.
추가 기준:
- 실제 작업 순서는 [WORK_EXECUTION_FLOW.md](/home/hyunho/projects/mh-dashboard-organization/docs/WORK_EXECUTION_FLOW.md) 를 따른다.
- 특히 `SSOT → 이슈 → 완료조건 → 계획 → 동기화 → 실행 → 가드레일 테스트 → 기록` 순서를 기본 운영 흐름으로 본다.
## Rule 1. Completed Feature Protection
완료 판정된 작업물의 기능과 코드는 함부로 건드리지 않는다.
@@ -181,6 +186,74 @@ mock, fallback, hotfix, 임시 우회 로직은 허용할 수 있다.
둘 다 가능하면 둘 다 남긴다.
## Rule 11. Commit And Push Need Explicit User Instruction
커밋과 푸시는 자동으로 하지 않는다.
세부 규칙:
- 코드 수정, 문서 수정, 검증 작업은 커밋 없이 계속 진행할 수 있다.
- `git commit` 은 사용자가 명시적으로 지시한 경우에만 수행한다.
- `git push` 도 사용자가 명시적으로 지시한 경우에만 수행한다.
- 작업 중간 상태는 워크트리에 남겨둘 수 있으며, 임의로 잘라서 자주 커밋하지 않는다.
- 커밋이 필요하다고 판단되면 먼저 상태와 이유를 공유하고, 지시를 받은 뒤 진행한다.
## Rule 12. Promote 8081 To 8080 By Reviewed File Diff Only
`8081` 작업용에서 검증된 변경을 `8080` 공개용으로 가져갈 때는 전체 workspace 를 통째로 덮지 않는다.
세부 규칙:
- 먼저 `8081` 작업용의 변경 파일 목록과 diff 를 확인한다.
- 공개용에 필요한 파일만 선택해서 메인 workspace 로 반영한다.
- 반영 후에는 메인 workspace 기준으로 최소 회귀 검증을 다시 수행한다.
- `8081` DB 기준으로만 맞는 수정인지, `8080` 기준 데이터에서도 맞는지 다시 확인한다.
- 검증이 끝나기 전에는 공개용 완료로 판단하지 않는다.
금지:
- `8081` 작업 디렉터리를 통째로 복사해서 `8080`에 덮어쓰기
- diff 확인 없이 일괄 반영
- `8081`에서 됐으니 `8080`도 같을 것이라고 가정하기
## Rule 13. 8081 Must Start From The Isolated Worktree
`8081` 작업은 항상 `.dev-worktree-8081` 기준으로 시작한다.
세부 규칙:
- 디자인 작업도 예외가 아니다.
- 허브/조직현황/프로젝트별 분석/사업관리대장 수정 전에 현재 실제 서빙 파일과 SSOT 파일을 먼저 확인한다.
디자인 작업 강제 우선순위:
1. `frontend/public/design-tokens.css`
2. `frontend/public/design-patterns.css`
3. `docs/architecture/DESIGN_SSOT.md`
4. 그 다음 화면별 실제 서빙 파일
금지:
- reference/original 파일을 먼저 수정하기
- 예전 파란톤/indigo/slate 계열을 새 기본값으로 다시 넣기
- 토큰/패턴으로 해결 가능한 문제를 화면별 임시 하드코딩으로 처리하기
`8081` 작업용은 포트만 다른 복제 서버가 아니라, 코드 소스까지 분리된 전용 worktree여야 한다.
세부 규칙:
- `8081`은 항상 `.dev-worktree-8081`에서 띄운다.
- 기동 전 `./scripts/prepare_dev_worktree.sh`를 먼저 실행한다.
- 재부팅 후 빠른 기동은 `./scripts/start_8081.sh` 또는 `./scripts/start_local_dashboards.sh`를 사용한다.
- `.env`와 로컬 전용 디자인 자산은 준비 스크립트가 복사한 것을 기준으로 사용한다.
- 기동 후 `docker inspect mh-dashboard-organization-dev-backend-1`로 마운트 소스를 확인한다.
금지:
- 현재 메인 workspace를 직접 마운트한 상태로 `8081`을 띄우기
- `8080``8081`이 같은 `frontend/public`, `legacy/static`, `incoming-files`를 동시에 보게 두기
- `8081`에서 보이던 디자인을 `8080` 공통 소스에 바로 덮어쓰기
## Daily Start Checklist
매일 첫 작업 시작 전 체크:
@@ -191,6 +264,7 @@ mock, fallback, hotfix, 임시 우회 로직은 허용할 수 있다.
- `WORK_RULEBOOK.md` 확인
- 최신 체크포인트 확인
- 미추적 / 수정 파일 확인
- 현재 작업은 커밋 없이 진행하고, 커밋/푸시는 지시받을 때만 한다는 규칙 확인
- 오늘 작업이 코드 문제인지 DB 문제인지 먼저 구분
- 공개용 기준 데이터 검증이 필요한지 판단

View File

@@ -0,0 +1,100 @@
# 8081 Serving Map
## Purpose
이 문서는 `8081` 작업용에서 어떤 URL이 어떤 파일을 실제로 읽는지 고정하기 위한 책임 맵이다.
이번 1차 정리의 목표는 기능 변경이 아니라 `실제 서빙 파일`, `공통 기본 스타일`, `8081 전용 오버라이드`, `참고 원본 자산`의 경계를 분명히 하는 것이다.
## Runtime Entry Points
- 허브 엔트리: `/`
- 파일: `frontend/public/index.html`
- 허브 공통 스크립트:
- 파일: `frontend/public/app.js`
- 허브 공통 기본 스타일:
- 파일: `frontend/public/styles.css`
- 허브 8081 전용 디자인 오버라이드:
- 파일: `frontend/public/styles-8081-design.css`
## Login Rules
- 로그인 화면 기본 구조와 스타일은 `8080` 공통 기준을 따른다.
- 로그인 기본 스타일은 `frontend/public/styles.css`에서만 정의한다.
- `frontend/public/styles-8081-design.css`에는 로그인 관련 셀렉터를 넣지 않는다.
## Legacy Organization
- URL: `/legacy/organization`
- HTML 파일:
- `DashBoard-organization.html`
- 정적 자산:
- `legacy/static/common.css`
- `legacy/static/organization.css`
- `legacy/static/organization.js`
## Integration Screens
- URL: `/integrations/payment`
- 현재 실제 서빙 파일: `incoming-files/served/payment.html`
- URL: `/integrations/mh`
- 현재 실제 서빙 파일: `incoming-files/served/mh.html`
정리 원칙:
- `incoming-files` 아래에서는 `served/`를 실제 서빙 자산용으로 사용한다.
- `reference/`는 원본 참고 파일, 복구 참고 파일, 비교용 자산만 둔다.
- 1차 정리에서는 기존 실제 서빙 파일을 `served/`에 복사하고, backend 서빙 경로를 먼저 `served/`로 갱신한다.
- 기존 루트 `incoming-files/payment.html`, `incoming-files/mh.html`는 안전한 비교/복구를 위해 당분간 남겨둔다.
## Seat Map
- 허브 화면 구성:
- `frontend/public/index.html`
- `frontend/public/app.js`
- `frontend/public/styles.css`
- `frontend/public/styles-8081-design.css`
- API / viewer:
- `backend/app/main.py`
- `backend/app/db.py`
- `backend/app/center_chair_viewer_template.html`
## Incoming Files Classification
### Served
- 실제 URL에서 직접 읽는 파일
- 예:
- `served/payment.html`
- `served/mh.html`
### Reference
- 원본 HTML/CSS/XLSX/CSV
- 복구 비교용 자산
- 직접 서빙하지 않는 참고 파일
- 필요 시 다음 차수에서 `reference/` 하위로 단계적 재배치한다.
예:
- `260320.html`
- `sample style.css`
- `opayment.html`
- `omh.html`
- `사업관리대장/*`
- 원본 xlsx/csv
## Out Of Scope For Phase 1
- DB 스키마 의미 변경
- 계산식 변경
- 권한 로직 변경
- 신규 기능 추가
- backend 라우터 대분해
## Phase 1 Success Criteria
- 수정 대상 파일을 화면별로 즉시 찾을 수 있다.
- 로그인은 `styles.css`만 본다.
- 허브 8081 디자인은 `styles-8081-design.css`만 본다.
- `/integrations/payment`, `/integrations/mh`의 실제 서빙 파일 위치가 문서와 코드에서 일치한다.
- 기존 참고 자산을 지우지 않고도 실제 서빙 경로와 참고 경로를 구분할 수 있다.

View File

@@ -0,0 +1,129 @@
# Design SSOT
## Source of truth
- Primary visual source: [incoming-files/sample style.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/sample%20style.css)
- Runtime token file: [design-tokens.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-tokens.css)
- Runtime pattern file: [design-patterns.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-patterns.css)
`sample style.css` defines the intended MH visual language. `design-tokens.css` is the token-level SSOT, and `design-patterns.css` is the component-level SSOT that packages those tokens into reusable runtime patterns.
## Rules
- New UI must use `design-tokens.css` variables first.
- New UI must use `design-patterns.css` patterns before adding page-local variants.
- Direct hex values are exceptions, not defaults.
- Page files may define layout and composition, but color, panel, border, radius, and shadow values must come from tokens.
- Shared aliases in `legacy/static/common.css` and `frontend/public/styles.css` exist only to bridge older code to the SSOT.
- Reference files under `incoming-files/*` are not visual authority. Runtime visuals must follow `design-tokens.css` and `design-patterns.css`.
## Fixed vs Flexible
SSOT is not a pixel-locked screenshot spec. It is a design rule system with two layers.
### Fixed rules
These should be treated as stable defaults across screens.
- Brand color family and accent family
- Surface, border, text, and shadow tokens
- Radius scale
- Button, tab, input, panel, and card visual language
- Typography tone and hierarchy
- Background atmosphere and overall contrast direction
### Flexible rules
These must be interpreted per screen based on content density and interaction needs.
- KPI card width and number of columns
- Sidebar/content split ratios
- Table column widths
- Search/filter placement
- Card stacking and wrap behavior
- Desktop/mobile breakpoint behavior
Example:
- Wrong SSOT: `KPI width is 100px`
- Correct SSOT: `KPI cards use the shared panel, radius, spacing, and text hierarchy tokens, and their width adapts to content without collapsing readability`
## When SSOT does not define a component
If a screen needs a pattern that SSOT does not explicitly define yet, do not fall back to arbitrary legacy styling.
Use this order:
1. Reuse existing tokens and the nearest shared pattern
2. Design the missing component in the same visual grammar
3. If the pattern is likely to repeat, document and promote it into SSOT
This applies to examples such as:
- A table pattern that does not exist in the current SSOT
- A KPI strip that needs a different density than the sample
- A new modal layout for a data-heavy screen
## Candidate and deprecated styles
Not every style already visible in the product is automatically part of SSOT.
- `SSOT`
- Approved and repeatable patterns
- Token-backed visual rules
- `candidate`
- Screen-local styles that look usable but do not yet have a documented basis
- Can be promoted later if they prove reusable
- `deprecated`
- Old blue/slate/indigo defaults
- Temporary hardcoded fixes
- Styles that conflict with the sample-based MH visual language
When a screen has a design with no clear basis, classify it as `candidate` first. Promote it only after it has been checked for reuse and consistency.
## Token groups
- Surface: `--ds-bg`, `--ds-panel`, `--ds-panel-soft`, `--ds-panel-strong`
- Text: `--ds-ink`, `--ds-text-soft`, `--ds-text-muted`
- Brand: `--ds-brand`, `--ds-brand-deep`, `--ds-brand-soft`, `--ds-accent`, `--ds-accent-soft`, `--ds-mint`
- Borders and shadows: `--ds-line`, `--ds-line-soft`, `--ds-shadow-*`
- Layout primitives: `--ds-radius-*`, `--ds-space-*`, `--ds-page-max-width`
## Promoted runtime patterns
These are now the official reusable patterns for current screens.
- Panels and heads: `.ds-panel`, `.ds-panel-head`
- KPI cards: `.ds-kpi-card`, `.ds-kpi-people`, `.ds-kpi-inverse`
- Filter surfaces and toggles: `.ds-filter-surface`, `.ds-filter-toggle`, `.ds-reset-button`
- Tables: `.ds-table-head`, `.ds-table-head-row`, `.ds-table-row`, `.ds-axis-cell`, `.ds-axis-cell-idle`, `.ds-axis-cell-active`
- Value emphasis: `.ds-project-cell`, `.ds-income`, `.ds-expense`, `.ds-subhead`, `.ds-empty`, `.ds-strong`, `.ds-muted`
- Breakdown/detail UI: `.ds-progress-track*`, `.ds-mode-chip`, `.ds-name-chip`, `.ds-mini-table-*`, `.ds-group-title`
- Position chips: `.ds-position-*` via `position-*` compatibility classes
- Business ledger popup/detail blocks: `.popup-*`, `.inline-card`, `.project-head-*`, `.summary-*`, `.ledger-*`, `.badge`, `.project-link`
- Organization modal forms/buttons: `.member-form-*`, `.modal-btn*`
- Seatmap action visibility: `.seatmap-actions .ghost-button`
These patterns may still have compatibility selectors for existing screen classes, but they should now be treated as the official design layer.
## Migration order
1. Token file and common aliases
2. Hub shell and shared controls
3. Team/Personal analysis and Organization
4. Project analysis
5. Business ledger detail cleanup
## Implementation guidance
- Prefer tokenized ranges over hardcoded single values when layout depends on data volume
- Prefer `design-patterns.css` component rules over one-off inline colors
- If a new pattern is introduced during implementation, update this document once the pattern is stable
- If a screen needs an exception, keep the exception local and explain why it cannot follow the shared pattern
## Anti-patterns
- Adding new `#4f46e5`, `#4338ca`, `bg-slate-*`, `text-indigo-*` style defaults
- Reintroducing separate page-level color systems
- Hardcoding “quick fix” brand colors in JS templates when a token/class can carry the same intent
- Letting reference/original files override runtime pattern files

View File

@@ -11,7 +11,12 @@ const currentViewTitle = document.getElementById("current-view-title");
const globalDateControls = document.getElementById("global-date-controls");
const globalStartDateInput = document.getElementById("global-start-date");
const globalEndDateInput = document.getElementById("global-end-date");
const organizationHistoryControls = document.getElementById("organization-history-controls");
const organizationMonthSelect = document.getElementById("organization-month-select");
const organizationCompareBtn = document.getElementById("organization-compare-btn");
const navButtons = Array.from(document.querySelectorAll(".header-center [data-view]"));
const ledgerFrame = document.getElementById("ledger-frame");
const ledgerStage = document.getElementById("ledger-stage");
const organizationFrame = document.getElementById("organization-frame");
const organizationStage = document.getElementById("organization-stage");
const projectFrame = document.getElementById("project-frame");
@@ -148,13 +153,152 @@ const seatMapState = {
forceReadOnly: false,
};
let currentView = "project";
let currentView = "ledger";
const globalDateState = {
loaded: true,
startDate: "2026-01-01",
endDate: "2026-01-31",
loaded: false,
startDate: "",
endDate: "",
};
const organizationHistoryState = {
selectedMonth: "",
currentMonth: "",
};
function padDatePart(value) {
return String(value).padStart(2, "0");
}
function getCurrentMonthValue() {
const now = new Date();
return `${now.getFullYear()}-${padDatePart(now.getMonth() + 1)}`;
}
function getMonthLabel(monthValue) {
const [, month] = String(monthValue || "").split("-");
if (!month) return "";
const monthNumber = Number(month);
if (!Number.isInteger(monthNumber) || monthNumber <= 0) return "";
return `${monthNumber}`;
}
function getMonthEndDate(monthValue) {
const [yearText, monthText] = String(monthValue || "").split("-");
const year = Number(yearText);
const month = Number(monthText);
if (!Number.isInteger(year) || !Number.isInteger(month) || month <= 0) return "";
const lastDay = new Date(year, month, 0);
return `${lastDay.getFullYear()}-${padDatePart(lastDay.getMonth() + 1)}-${padDatePart(lastDay.getDate())}`;
}
function getTodayDate() {
const now = new Date();
return `${now.getFullYear()}-${padDatePart(now.getMonth() + 1)}-${padDatePart(now.getDate())}`;
}
function parseDateOnly(value) {
const raw = String(value || "").trim();
if (!raw) return null;
const match = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return null;
const year = Number(match[1]);
const monthIndex = Number(match[2]) - 1;
const day = Number(match[3]);
const parsed = new Date(year, monthIndex, day);
if (
Number.isNaN(parsed.getTime())
|| parsed.getFullYear() !== year
|| parsed.getMonth() !== monthIndex
|| parsed.getDate() !== day
) {
return null;
}
return parsed;
}
function formatDateOnly(date) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) return "";
return `${date.getFullYear()}-${padDatePart(date.getMonth() + 1)}-${padDatePart(date.getDate())}`;
}
function getMonthRangeForDate(date) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
return { startDate: "", endDate: "" };
}
const start = new Date(date.getFullYear(), date.getMonth(), 1);
const end = new Date(date.getFullYear(), date.getMonth() + 1, 0);
return {
startDate: formatDateOnly(start),
endDate: formatDateOnly(end),
};
}
function getPreviousMonthRange(baseDate = new Date()) {
return getMonthRangeForDate(new Date(baseDate.getFullYear(), baseDate.getMonth() - 1, 1));
}
function resolveLatestCompletedMonthRange(maxDateText) {
const fallbackRange = getPreviousMonthRange();
const latestAvailableDate = parseDateOnly(String(maxDateText || "").slice(0, 10));
if (!latestAvailableDate) return fallbackRange;
const fallbackStart = parseDateOnly(fallbackRange.startDate);
if (fallbackStart && latestAvailableDate >= fallbackStart) {
return fallbackRange;
}
return getMonthRangeForDate(latestAvailableDate);
}
function syncOrganizationHistoryControls() {
if (!organizationHistoryControls) return;
const visible = currentView === "organization";
organizationHistoryControls.classList.toggle("hidden", !visible);
if (organizationCompareBtn) {
const isCurrentMonth = !organizationHistoryState.selectedMonth || organizationHistoryState.selectedMonth === organizationHistoryState.currentMonth;
organizationCompareBtn.classList.toggle("hidden", !visible || isCurrentMonth);
}
}
function initializeOrganizationMonthOptions() {
if (!organizationMonthSelect) return;
const now = new Date();
const year = now.getFullYear();
const monthCount = now.getMonth() + 1;
organizationMonthSelect.innerHTML = "";
for (let month = monthCount; month >= 1; month -= 1) {
const value = `${year}-${padDatePart(month)}`;
const option = document.createElement("option");
option.value = value;
option.textContent = month === monthCount ? `${month}월 (최신)` : `${month}`;
organizationMonthSelect.append(option);
}
organizationHistoryState.currentMonth = `${year}-${padDatePart(monthCount)}`;
organizationHistoryState.selectedMonth = organizationHistoryState.currentMonth;
organizationMonthSelect.value = organizationHistoryState.selectedMonth;
syncOrganizationHistoryControls();
}
function postOrganizationHistoryState() {
if (!organizationFrame?.contentWindow) return;
const selectedMonth = organizationHistoryState.selectedMonth || organizationHistoryState.currentMonth;
const currentMonth = organizationHistoryState.currentMonth || getCurrentMonthValue();
const isHistorical = Boolean(selectedMonth) && selectedMonth !== currentMonth;
organizationFrame.contentWindow.postMessage(
{
source: "total-control",
type: "organization-history-view",
month: selectedMonth,
asOfDate: isHistorical ? getMonthEndDate(selectedMonth) : "",
historical: isHistorical,
},
window.location.origin,
);
syncOrganizationHistoryControls();
}
function getGlobalAsOfDate() {
return globalDateState.endDate || "";
}
function getSession() {
try {
return JSON.parse(sessionStorage.getItem(sessionKey) || "null");
@@ -181,12 +325,15 @@ function buildAuthHeaders(headers) {
}
function shouldShowGlobalDateControls() {
return currentView === "ledger" || currentView === "project" || currentView === "team" || currentView === "organization";
return currentView === "ledger"
|| currentView === "project"
|| currentView === "team";
}
function syncGlobalDateControlVisibility() {
if (!globalDateControls) return;
globalDateControls.classList.toggle("hidden", !shouldShowGlobalDateControls());
syncOrganizationHistoryControls();
}
function syncGlobalDateControlInputs() {
@@ -208,7 +355,21 @@ function postGlobalDateRangeToFrame(frame) {
frame.contentWindow.postMessage(getGlobalDateRangePayload(), window.location.origin);
}
function buildAsOfQuery() {
const asOf = getGlobalAsOfDate();
if (!asOf) return "";
return `?as_of=${encodeURIComponent(asOf)}`;
}
function buildSeatMapAsOfQuery() {
return "";
}
function notifyEmbeddedTabActivated() {
if (currentView === "ledger" && ledgerFrame?.contentWindow) {
ledgerFrame.contentWindow.postMessage({ source: "total-control", type: "embedded-host" }, window.location.origin);
ledgerFrame.contentWindow.postMessage({ source: "total-control", type: "tab-activated", tab: "business" }, window.location.origin);
}
if (currentView === "project" && projectFrame?.contentWindow) {
projectFrame.contentWindow.postMessage({ source: "total-control", type: "tab-activated", tab: "project" }, window.location.origin);
}
@@ -217,16 +378,59 @@ function notifyEmbeddedTabActivated() {
}
}
let ledgerDefaultSourcePromise = null;
async function fetchDefaultLedgerSource() {
if (!ledgerDefaultSourcePromise) {
ledgerDefaultSourcePromise = fetch("/api/integration/business-ledger-default")
.then(async (response) => {
if (!response.ok) {
throw new Error("기본 사업관리대장 원본을 불러오지 못했습니다.");
}
const fileName = response.headers.get("x-source-filename") || "사업관리대장-1.xlsx";
const buffer = await response.arrayBuffer();
if (!buffer || !buffer.byteLength) {
throw new Error("기본 사업관리대장 원본 데이터가 비어 있습니다.");
}
return { fileName, buffer };
})
.catch((error) => {
ledgerDefaultSourcePromise = null;
throw error;
});
}
return ledgerDefaultSourcePromise;
}
async function pushDefaultLedgerSourceToFrame(force = false) {
if (!ledgerFrame?.contentWindow) return;
if (ledgerFrame.dataset.defaultLedgerLoaded === "true" && !force) return;
try {
const { fileName, buffer } = await fetchDefaultLedgerSource();
ledgerFrame.contentWindow.postMessage(
{ source: "total-control", type: "embedded-host" },
window.location.origin,
);
ledgerFrame.contentWindow.postMessage(
{ source: "total-upload", type: "business", fileName, buffer },
window.location.origin,
);
ledgerFrame.dataset.defaultLedgerLoaded = "true";
} catch (error) {
console.error("사업관리대장 기본 원본 전달에 실패했습니다.", error);
}
}
async function ensureGlobalDateRangeLoaded() {
if (globalDateState.loaded) return;
try {
const payload = await fetchJson("/api/integration/summary");
const work = payload?.date_ranges?.work || {};
const voucher = payload?.date_ranges?.voucher || {};
const starts = [work.min_work_date, voucher.min_voucher_date].filter(Boolean).sort();
const ends = [work.max_work_date, voucher.max_voucher_date].filter(Boolean).sort();
globalDateState.startDate = starts[0] ? String(starts[0]).slice(0, 10) : "";
globalDateState.endDate = ends.length ? String(ends[ends.length - 1]).slice(0, 10) : "";
const defaultRange = resolveLatestCompletedMonthRange(ends.length ? ends[ends.length - 1] : "");
globalDateState.startDate = defaultRange.startDate;
globalDateState.endDate = defaultRange.endDate;
globalDateState.loaded = true;
syncGlobalDateControlInputs();
postGlobalDateRangeToFrame(projectFrame);
@@ -634,6 +838,22 @@ function getPlacementForMember(memberId) {
return getPlacementSource().find((item) => Number(item.member_id) === Number(memberId)) || null;
}
function isMemberAssignedAnywhere(member) {
const seatLabel = String(
member?.member_seat_label
|| member?.seat_label
|| ""
).trim();
return Boolean(seatLabel);
}
function shouldHideMemberFromSeatMap(member) {
if (Boolean(member?.is_retired)) return true;
const workStatus = String(member?.work_status || "").trim();
if (/(퇴사|퇴직)/u.test(workStatus)) return true;
return false;
}
function memberMatchesSeatMapSearch(member) {
const keyword = seatMapState.search.trim().toLowerCase();
if (!keyword) return true;
@@ -686,7 +906,9 @@ function renderSeatMapOfficeTabs() {
function getUnassignedMembers() {
const placedIds = new Set(getPlacementSource().map((item) => Number(item.member_id)));
return seatMapState.members.filter((member) => {
if (shouldHideMemberFromSeatMap(member)) return false;
if (placedIds.has(Number(member.id))) return false;
if (isMemberAssignedAnywhere(member)) return false;
return memberMatchesSeatMapSearch(member);
});
}
@@ -694,6 +916,7 @@ function getUnassignedMembers() {
function getPlacedMembers() {
const placedIds = new Set(getPlacementSource().map((item) => Number(item.member_id)));
return seatMapState.members.filter((member) => {
if (shouldHideMemberFromSeatMap(member)) return false;
if (!placedIds.has(Number(member.id))) return false;
return memberMatchesSeatMapSearch(member);
});
@@ -858,7 +1081,7 @@ function renderDxfSeatMapBoard() {
seatMapBoard.innerHTML = `<div class="seatmap-empty-card"><strong>DXF 뷰어 데이터를 준비하지 못했습니다.</strong></div>`;
return;
}
const viewerUrl = resolveAppUrl(`/api/seat-maps/${seatMapState.seatMap.id}/viewer`);
const viewerUrl = resolveAppUrl(`/api/seat-maps/${seatMapState.seatMap.id}/viewer${buildSeatMapAsOfQuery()}`);
seatMapBoard.innerHTML = `
<div class="seatmap-dxf-frame-shell">
<div class="seatmap-dxf-drop-overlay" data-seatmap-drop-overlay></div>
@@ -1201,7 +1424,7 @@ async function loadSeatMapData(force = false) {
const office = getCurrentSeatMapOffice();
const activePayload = await fetchJson(`/api/seat-maps/active?office_key=${encodeURIComponent(office.key)}`);
const activeSeatMap = activePayload.item;
const layoutPayload = await fetchJson(`/api/seat-maps/${activeSeatMap.id}/layout`);
const layoutPayload = await fetchJson(`/api/seat-maps/${activeSeatMap.id}/layout${buildSeatMapAsOfQuery()}`);
seatMapState.seatMap = {
...(layoutPayload.seat_map || {}),
viewer_data: layoutPayload.viewer_data || null,
@@ -1397,10 +1620,15 @@ function setActiveView(view) {
});
const isOrganization = currentView === "organization";
const isLedger = currentView === "ledger";
const isProject = currentView === "project";
const isTeam = currentView === "team";
const isSeatMapAdmin = currentView === "seatmap-admin";
const isSeatMapReadonly = currentView === "seatmap-readonly";
if (ledgerStage) {
ledgerStage.hidden = !isLedger;
ledgerStage.style.display = isLedger ? "flex" : "none";
}
if (organizationStage) {
organizationStage.hidden = !isOrganization;
organizationStage.style.display = isOrganization ? "flex" : "none";
@@ -1422,14 +1650,20 @@ function setActiveView(view) {
seatMapReadonlyStage.style.display = isSeatMapReadonly ? "flex" : "none";
}
if (emptyStage) {
const showEmpty = !isOrganization && !isProject && !isTeam && !isSeatMapAdmin && !isSeatMapReadonly;
const showEmpty = !isLedger && !isOrganization && !isProject && !isTeam && !isSeatMapAdmin && !isSeatMapReadonly;
emptyStage.hidden = !showEmpty;
emptyStage.style.display = showEmpty ? "flex" : "none";
}
if (isLedger && previousView !== "ledger" && ledgerFrame) {
const frameSrc = ledgerFrame.dataset.src || ledgerFrame.src;
ledgerFrame.src = resolveAppUrl(frameSrc);
}
if (isOrganization && previousView !== "organization" && organizationFrame) {
const frameSrc = organizationFrame.dataset.src || organizationFrame.src;
organizationFrame.src = resolveAppUrl(frameSrc);
} else if (isOrganization) {
postOrganizationHistoryState();
}
if (isProject && previousView !== "project" && projectFrame) {
const frameSrc = projectFrame.dataset.src || projectFrame.src;
@@ -1495,7 +1729,7 @@ if (loginForm) {
body: formData,
});
setSession(payload);
setActiveView("project");
setActiveView("ledger");
loginForm.reset();
loginMessage.textContent = "";
renderAuth();
@@ -1541,9 +1775,24 @@ if (globalEndDateInput) {
globalDateState.endDate = globalEndDateInput.value || "";
postGlobalDateRangeToFrame(projectFrame);
postGlobalDateRangeToFrame(teamFrame);
if (currentView === "seatmap-admin" || currentView === "seatmap-readonly") {
seatMapState.loaded = false;
loadSeatMapData(true);
}
});
}
organizationFrame?.addEventListener("load", () => {
postOrganizationHistoryState();
});
ledgerFrame?.addEventListener("load", () => {
if (currentView === "ledger") {
notifyEmbeddedTabActivated();
}
void pushDefaultLedgerSourceToFrame(true);
});
projectFrame?.addEventListener("load", () => {
postGlobalDateRangeToFrame(projectFrame);
if (currentView === "project") {
@@ -1565,6 +1814,32 @@ navButtons.forEach((button) => {
});
});
if (organizationMonthSelect) {
initializeOrganizationMonthOptions();
organizationMonthSelect.addEventListener("change", () => {
organizationHistoryState.selectedMonth = organizationMonthSelect.value || organizationHistoryState.currentMonth;
postOrganizationHistoryState();
});
}
if (organizationCompareBtn) {
organizationCompareBtn.addEventListener("click", () => {
if (!organizationFrame?.contentWindow) return;
const fromDate = getMonthEndDate(organizationHistoryState.selectedMonth);
const toDate = getTodayDate();
if (!fromDate) return;
organizationFrame.contentWindow.postMessage(
{
source: "total-control",
type: "open-history-compare",
fromDate,
toDate,
},
window.location.origin,
);
});
}
Object.values(seatMapDom).forEach((dom) => {
dom.officeTabs?.addEventListener("click", (event) => {
const button = event.target.closest("[data-seatmap-office]");

View File

@@ -0,0 +1,730 @@
@import url("/design-tokens.css?v=20260401-01");
:root {
--ds-hero-text: #f7f0e4;
--ds-hero-border: rgba(242, 196, 132, 0.22);
--ds-hero-surface: rgba(255, 255, 255, 0.08);
--ds-hero-surface-strong: rgba(255, 255, 255, 0.1);
--ds-hero-text-muted: rgba(255, 244, 230, 0.72);
--ds-hero-text-soft: rgba(255, 244, 230, 0.56);
--ds-hero-line: rgba(242, 196, 132, 0.18);
--ds-danger-soft: rgba(169, 72, 50, 0.1);
--ds-danger-line: rgba(169, 72, 50, 0.22);
--ds-success-soft: rgba(47, 153, 115, 0.14);
--ds-success-line: rgba(47, 153, 115, 0.24);
--ds-brand-soft-surface: rgba(15, 58, 47, 0.1);
--ds-brand-soft-line: rgba(15, 58, 47, 0.18);
--ds-accent-soft-surface: rgba(242, 196, 132, 0.18);
--ds-accent-soft-line: rgba(214, 138, 58, 0.24);
}
.ds-panel,
.payment-panel {
background: rgba(255, 250, 243, 0.96);
border: 1px solid var(--ds-line-soft);
box-shadow: var(--ds-shadow-soft);
}
.ds-panel-head,
.payment-panel-head {
background: rgba(255, 250, 243, 0.92);
border-bottom: 1px solid var(--ds-line-soft);
}
.ds-kpi-card,
.payment-kpi-card {
border: 1px solid var(--ds-line-soft);
background: linear-gradient(180deg, rgba(255, 250, 243, 0.96) 0%, rgba(248, 242, 232, 0.96) 100%);
box-shadow: var(--ds-shadow-soft);
color: var(--ds-ink);
}
.ds-kpi-inverse,
.payment-kpi-inverse {
color: #fffaf3;
}
.ds-kpi-people,
.payment-kpi-people {
background: linear-gradient(135deg, var(--ds-brand) 0%, var(--ds-brand-soft) 100%);
border-color: rgba(15, 58, 47, 0.2);
}
.ds-subhead,
.payment-subhead {
color: var(--ds-text-muted);
}
.ds-empty,
.payment-empty {
color: #9b937f;
}
.ds-tooltip,
.payment-tooltip {
background: var(--ds-brand-deep);
color: #fffaf3;
}
.ds-filter-surface,
.payment-filter-bar {
background: rgba(246, 237, 221, 0.8);
border: 1px solid var(--ds-line);
}
.ds-filter-toggle,
.payment-filter-toggle {
background: var(--ds-brand);
border-color: rgba(15, 58, 47, 0.28);
color: #fffaf3;
}
.ds-reset-button,
.payment-reset-btn {
background: rgba(255, 250, 243, 0.96);
border: 1px solid var(--ds-line);
color: var(--ds-text-muted);
}
.ds-reset-button:hover,
.payment-reset-btn:hover {
color: var(--ds-brand-soft);
background: rgba(255, 255, 255, 0.98);
}
.ds-table-head,
.payment-table-head {
background: rgba(246, 237, 221, 0.82);
}
.ds-table-head-row,
.payment-table-head-row {
color: var(--ds-brand-deep);
border-bottom: 1px solid var(--ds-line);
}
.ds-table-row,
.payment-data-row {
border-color: #f0e5d2;
}
.ds-table-row:hover,
.payment-data-row:hover {
background: #f6eddd;
}
.ds-axis-cell,
.payment-axis-cell {
border-right: 1px solid var(--ds-line-soft);
}
.ds-axis-cell-idle,
.payment-axis-cell-idle {
background: rgba(255, 250, 243, 0.96);
color: var(--ds-ink);
}
.ds-axis-cell-idle:hover,
.payment-axis-cell-idle:hover {
background: rgba(234, 220, 196, 0.52);
color: var(--ds-brand-deep);
}
.ds-axis-cell-active,
.payment-axis-cell-active {
background: rgba(234, 220, 196, 0.78);
color: var(--ds-brand-deep);
}
.ds-project-cell,
.payment-project-cell {
color: var(--ds-brand-deep);
font-weight: 800;
}
.ds-project-cell:hover,
.payment-project-cell:hover {
background: #efe2ca;
color: #214634;
}
.ds-income,
.payment-income {
color: var(--ds-status-success);
}
.ds-expense,
.payment-expense {
color: var(--ds-status-danger);
}
.ds-progress-track,
.payment-progress-track {
background: rgba(217, 197, 168, 0.45);
}
.ds-progress-track-grand,
.payment-progress-track-grand {
background: rgba(75, 135, 179, 0.24);
}
.ds-progress-track-mid,
.payment-progress-track-mid {
background: rgba(214, 138, 58, 0.22);
}
.ds-mode-chip,
.payment-mode-chip {
color: var(--ds-brand-soft);
background: rgba(242, 196, 132, 0.22);
border: 1px solid rgba(214, 138, 58, 0.28);
}
.ds-name-chip,
.payment-name-chip {
background: rgba(246, 237, 221, 0.76);
border: 1px solid var(--ds-line-soft);
color: var(--ds-text-soft);
}
.ds-divider-top,
.payment-divider-top {
border-top: 1px solid var(--ds-line-soft);
}
.ds-divider-left,
.payment-divider-left {
border-left: 1px solid var(--ds-line-soft);
}
.ds-divider-mark,
.payment-divider-mark {
color: rgba(183, 170, 147, 0.92);
}
.ds-mini-table-shell,
.payment-mini-table-shell {
border: 1px solid var(--ds-line-soft);
}
.ds-mini-table-head,
.payment-mini-table-head {
background: rgba(246, 237, 221, 0.68);
color: var(--ds-text-muted);
}
.ds-mini-table-row,
.payment-mini-table-row {
border-top: 1px solid rgba(217, 197, 168, 0.36);
color: var(--ds-text-soft);
}
.ds-group-title,
.payment-group-title {
background: var(--ds-brand);
color: #fffaf3;
}
.ds-strong,
.payment-strong {
color: var(--ds-ink);
}
.ds-muted,
.payment-muted {
color: var(--ds-text-soft);
}
.ds-accent-text,
.payment-icon-accent {
color: var(--ds-brand-soft);
}
.ds-position-chip,
.position-chip {
background: rgba(246, 237, 221, 0.76);
}
.ds-position-text,
.position-text {
color: var(--ds-text-soft);
}
.ds-position-border,
.position-border {
border-color: rgba(217, 197, 168, 0.46);
}
.ds-position-dot,
.position-dot {
box-shadow: 0 0 0 2px rgba(255, 250, 243, 0.9);
}
.position-executive.position-chip { background: rgba(15, 58, 47, 0.1); }
.position-executive.position-text { color: var(--ds-brand); }
.position-executive.position-border { border-color: rgba(15, 58, 47, 0.22); }
.position-executive.position-dot { background: var(--ds-brand); }
.position-principal.position-chip { background: rgba(26, 86, 69, 0.1); }
.position-principal.position-text { color: var(--ds-brand-soft); }
.position-principal.position-border { border-color: rgba(26, 86, 69, 0.22); }
.position-principal.position-dot { background: var(--ds-brand-soft); }
.position-senior.position-chip { background: rgba(47, 153, 115, 0.12); }
.position-senior.position-text { color: var(--ds-mint); }
.position-senior.position-border { border-color: rgba(47, 153, 115, 0.26); }
.position-senior.position-dot { background: var(--ds-mint); }
.position-associate.position-chip { background: rgba(75, 135, 179, 0.12); }
.position-associate.position-text { color: var(--ds-info); }
.position-associate.position-border { border-color: rgba(75, 135, 179, 0.22); }
.position-associate.position-dot { background: var(--ds-info); }
.position-staff.position-chip { background: rgba(214, 138, 58, 0.12); }
.position-staff.position-text { color: var(--ds-status-warning); }
.position-staff.position-border { border-color: rgba(214, 138, 58, 0.24); }
.position-staff.position-dot { background: var(--ds-status-warning); }
.position-member.position-chip { background: rgba(102, 117, 109, 0.12); }
.position-member.position-text { color: var(--ds-text-soft); }
.position-member.position-border { border-color: rgba(102, 117, 109, 0.24); }
.position-member.position-dot { background: var(--ds-text-soft); }
.position-unset.position-chip { background: rgba(183, 170, 147, 0.18); }
.position-unset.position-text { color: #8b7e69; }
.position-unset.position-border { border-color: rgba(183, 170, 147, 0.3); }
.position-unset.position-dot { background: #b7aa93; }
.popup-wrap {
max-width: 1680px;
margin: 0 auto;
padding: 20px;
}
.popup-head {
margin-bottom: 14px;
padding: 18px 20px;
border: 1px solid rgba(217, 197, 168, 0.62);
border-radius: 24px;
background: linear-gradient(180deg, #fff8ee 0%, #f4e9d7 100%);
box-shadow: 0 18px 36px rgba(15, 58, 47, 0.08);
}
.popup-title {
font-size: 28px;
font-weight: 900;
line-height: 1.2;
color: var(--ds-ink);
}
.popup-sub {
margin-top: 6px;
font-size: 13px;
font-weight: 800;
color: var(--ds-text-muted);
}
.inline-panel {
padding: 0;
display: grid;
gap: 14px;
}
.project-head-grid {
display: grid;
grid-template-columns: minmax(0, 1.95fr) minmax(280px, 0.72fr);
gap: 10px;
align-items: start;
}
.project-head-main {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.project-contact-stack {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
width: 100%;
}
.inline-card,
.ledger-block,
.popup-wrap .ledger-block.collect {
background: rgba(255, 250, 243, 0.98) !important;
border: 1px solid rgba(217, 197, 168, 0.56) !important;
border-radius: 24px !important;
box-shadow: 0 16px 32px rgba(15, 58, 47, 0.08) !important;
}
.inline-card {
padding: 16px 18px;
}
.project-meta-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 12px;
}
.kv {
padding: 12px 14px;
border-radius: 18px;
background: linear-gradient(180deg, #fffdf8 0%, #f4e9d7 100%);
border: 1px solid rgba(217, 197, 168, 0.46);
}
.kvk,
.summary-label {
font-size: 11px;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #8a6b3d;
}
.kvv {
font-size: 15px;
font-weight: 900;
color: var(--ds-ink);
line-height: 1.35;
word-break: keep-all;
}
.summary-note {
margin-top: 6px;
font-size: 12px;
font-weight: 800;
color: var(--ds-text-muted);
line-height: 1.45;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.summary-card {
padding: 14px 16px;
border-radius: 18px;
background: linear-gradient(180deg, #fffdf8 0%, #f4e9d7 100%);
border: 1px solid rgba(217, 197, 168, 0.46);
box-shadow: none;
}
.summary-card.receivable {
background: linear-gradient(180deg, var(--ds-danger-soft) 0%, rgba(255, 248, 238, 0.98) 100%);
border-color: var(--ds-danger-line);
}
.summary-value {
margin-top: 8px;
font-size: 24px;
font-weight: 900;
color: var(--ds-ink);
line-height: 1.15;
}
.summary-card.receivable .summary-value {
color: var(--ds-status-danger);
}
.project-progress {
margin-top: 10px;
height: 12px;
border-radius: var(--ds-radius-pill);
overflow: hidden;
background: rgba(217, 197, 168, 0.48);
box-shadow: inset 0 1px 2px rgba(15, 58, 47, 0.08);
}
.progress .bar {
height: 100%;
border-radius: var(--ds-radius-pill);
background: linear-gradient(90deg, var(--ds-brand-soft) 0%, var(--ds-mint) 100%);
box-shadow: 0 8px 18px rgba(47, 153, 115, 0.18);
}
.ledger-stack {
display: grid;
gap: 12px;
}
.ledger-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 18px 18px 14px;
border-bottom: 1px solid rgba(217, 197, 168, 0.38) !important;
background: linear-gradient(180deg, #fffdf8 0%, #f4e9d7 100%) !important;
}
.ledger-head-left {
display: flex;
align-items: flex-start;
gap: 12px;
min-width: 0;
}
.ledger-icon {
width: 36px;
height: 36px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(180deg, #fff8ee 0%, #f2dec0 100%) !important;
color: var(--ds-accent-strong) !important;
font-weight: 900;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65);
}
.ledger-name {
font-size: 16px;
font-weight: 900;
color: var(--ds-ink) !important;
line-height: 1.2;
}
.ledger-sub {
margin-top: 4px;
font-size: 12px;
font-weight: 800;
color: var(--ds-text-muted) !important;
line-height: 1.45;
}
.ledger-pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 12px;
border-radius: var(--ds-radius-pill);
background: var(--ds-brand-soft-surface) !important;
border: 1px solid var(--ds-brand-soft-line) !important;
color: var(--ds-brand-soft) !important;
font-size: 12px;
font-weight: 900;
white-space: nowrap;
}
.ledger-table-wrap {
padding: 0 16px 16px;
background: transparent !important;
}
.ledger-table {
width: 100%;
border-collapse: collapse;
background: transparent !important;
}
.ledger-table thead th {
padding: 12px 10px;
background: var(--ds-brand) !important;
color: #fff5e6 !important;
font-size: 12px;
font-weight: 900;
text-align: left;
border-right: 1px solid rgba(242, 196, 132, 0.18) !important;
}
.ledger-table thead th:last-child {
border-right: 0;
}
.ledger-table tbody td {
padding: 12px 10px;
border-bottom: 1px solid rgba(217, 197, 168, 0.34) !important;
vertical-align: top;
font-size: 13px;
font-weight: 800;
color: var(--ds-ink) !important;
background: rgba(255, 250, 243, 0.72) !important;
}
.ledger-table tbody tr:last-child td {
border-bottom: 0;
}
.ledger-main {
display: block;
color: var(--ds-ink) !important;
font-weight: 900;
}
.ledger-muted,
.ledger-note {
display: block;
margin-top: 4px;
color: var(--ds-text-muted) !important;
font-size: 12px;
font-weight: 800;
line-height: 1.45;
}
.ledger-amount {
font-weight: 900;
text-align: right;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 30px;
padding: 0 12px;
border-radius: var(--ds-radius-pill);
border: 1px solid rgba(217, 197, 168, 0.5);
background: rgba(255, 250, 243, 0.96);
color: #17392f;
font-size: 12px;
font-weight: 900;
}
.badge.badge-baron {
background: var(--ds-brand-soft-surface);
border-color: var(--ds-brand-soft-line);
color: var(--ds-brand-soft);
}
.badge.badge-family {
background: var(--ds-accent-soft-surface);
border-color: var(--ds-accent-soft-line);
color: var(--ds-status-warning);
}
.badge.ok {
background: var(--ds-success-soft);
border-color: var(--ds-success-line);
color: var(--ds-brand-soft);
}
.project-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0;
border: 0;
background: none;
color: #17392f;
font: inherit;
font-weight: 900;
text-align: left;
cursor: pointer;
}
.project-link:hover {
color: #0f6a55;
}
.member-form-label {
color: var(--ds-text-soft);
font-size: 12px;
font-weight: 900;
letter-spacing: 0.04em;
}
.member-form-input,
.member-form-select,
.member-form-time {
border: 1px solid var(--ds-line-soft);
border-radius: 16px;
background: rgba(255, 250, 243, 0.92);
color: var(--ds-ink);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65);
}
.member-form-input:focus,
.member-form-select:focus,
.member-form-time:focus {
border-color: rgba(47, 153, 115, 0.45);
box-shadow: 0 0 0 4px rgba(47, 153, 115, 0.1);
}
.modal-btn {
min-height: 40px;
padding: 0 16px;
border-radius: var(--ds-radius-pill);
font-size: 12px;
font-weight: 900;
border: 1px solid transparent;
}
.modal-btn-cancel {
background: rgba(255, 250, 243, 0.96);
border-color: var(--ds-line);
color: var(--ds-text-soft);
}
.modal-btn-save {
background: var(--ds-brand-soft);
border-color: rgba(15, 58, 47, 0.22);
color: #fffaf3;
}
.modal-btn-delete {
background: rgba(169, 72, 50, 0.12);
border-color: rgba(169, 72, 50, 0.24);
color: var(--ds-status-danger);
}
.modal-btn-close {
background: rgba(242, 196, 132, 0.18);
border-color: rgba(214, 138, 58, 0.24);
color: var(--ds-status-warning);
}
.seatmap-actions .ghost-button {
min-height: 40px;
padding: 0 16px;
border-width: 1px;
border-style: solid;
border-radius: var(--ds-radius-pill);
font-size: 12px;
letter-spacing: -0.01em;
box-shadow: var(--ds-shadow-soft);
}
@media (max-width: 1180px) {
.project-head-grid {
grid-template-columns: 1fr;
}
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.project-meta-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.popup-wrap {
padding: 14px;
}
.summary-grid {
grid-template-columns: 1fr;
}
.ledger-head {
flex-direction: column;
align-items: flex-start;
}
.ledger-pill {
white-space: normal;
}
.ledger-table-wrap {
padding: 0 10px 12px;
overflow-x: auto;
}
}

View File

@@ -0,0 +1,60 @@
:root {
--ds-font-sans: "Pretendard", "Malgun Gothic", sans-serif;
--ds-bg: #f1eadf;
--ds-bg-soft: #f4e9d7;
--ds-bg-gradient:
radial-gradient(circle at top left, rgba(214, 138, 58, 0.18), transparent 24%),
radial-gradient(circle at top right, rgba(47, 153, 115, 0.12), transparent 22%),
linear-gradient(180deg, #f6efe6 0%, #f1eadf 100%);
--ds-panel: #fffaf3;
--ds-panel-soft: rgba(255, 250, 243, 0.9);
--ds-panel-strong: #eadcc4;
--ds-ink: #10251d;
--ds-text-soft: #425148;
--ds-text-muted: #66756d;
--ds-line: #d9c5a8;
--ds-line-soft: rgba(217, 197, 168, 0.45);
--ds-brand: #0f3a2f;
--ds-brand-deep: #0a2a22;
--ds-brand-soft: #1a5645;
--ds-accent: #d68a3a;
--ds-accent-soft: #f2c484;
--ds-accent-strong: #b66e22;
--ds-mint: #2f9973;
--ds-info: #4b87b3;
--ds-status-success: #2f6b52;
--ds-status-warning: #9a6422;
--ds-status-danger: #a94832;
--ds-surface-tint: rgba(255, 255, 255, 0.72);
--ds-surface-tint-strong: rgba(255, 255, 255, 0.88);
--ds-glass-dark: rgba(20, 45, 37, 0.34);
--ds-glass-dark-soft: rgba(16, 37, 29, 0.16);
--ds-glass-line: rgba(255, 255, 255, 0.14);
--ds-shadow-soft: 0 10px 24px rgba(15, 58, 47, 0.08);
--ds-shadow-card: 0 22px 54px rgba(15, 58, 47, 0.12);
--ds-shadow-float: 0 18px 36px rgba(15, 58, 47, 0.16);
--ds-shadow-hero: 0 28px 70px rgba(15, 58, 47, 0.22);
--ds-radius-sm: 8px;
--ds-radius-md: 12px;
--ds-radius-lg: 18px;
--ds-radius-xl: 24px;
--ds-radius-pill: 999px;
--ds-space-1: 4px;
--ds-space-2: 8px;
--ds-space-3: 12px;
--ds-space-4: 16px;
--ds-space-5: 20px;
--ds-space-6: 24px;
--ds-page-max-width: 2000px;
}

View File

@@ -4,11 +4,21 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MH 대시보드-공개용</title>
<script>
document.title = window.location.port === '8081'
? 'MH 대시보드-작업용'
: 'MH 대시보드-공개용';
</script>
<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="/design-tokens.css?v=20260401-01">
<link rel="stylesheet" href="/design-patterns.css?v=20260401-01">
<link rel="stylesheet" href="/legacy/static/common.css">
<link rel="stylesheet" href="/styles.css?v=20260326-01">
<!-- Keep login and common hub defaults aligned with 8080. -->
<link rel="stylesheet" href="/styles.css?v=20260330-01">
<!-- 8081-only hub overrides must not restyle the login screen. -->
<link rel="stylesheet" href="/styles-8081-design.css?v=20260401-01">
</head>
<body>
<section id="login-panel" class="login-screen">
@@ -53,6 +63,14 @@
<input id="global-end-date" type="date" aria-label="종료일">
</label>
</div>
<div id="organization-history-controls" class="header-date-controls hidden">
<span class="header-date-label">조직 기준월</span>
<label class="header-date-field">
<select id="organization-month-select" aria-label="조직 기준월"></select>
</label>
<button id="organization-compare-btn" class="ghost-button ghost-button-soft hidden" type="button">조직도 변경사항 확인</button>
</div>
</div>
<div class="header-right">
@@ -78,18 +96,26 @@
</header>
<main class="dashboard-main">
<section id="ledger-stage" class="main-stage" hidden>
<div class="stage-frame">
<iframe id="ledger-frame" src="/integrations/ledger?v=20260401-02" data-src="/integrations/ledger?v=20260401-02" title="사업관리대장 화면"></iframe>
</div>
</section>
<section id="organization-stage" class="main-stage">
<div class="stage-frame">
<iframe id="organization-frame" src="/legacy/organization?v=20260326-02" data-src="/legacy/organization?v=20260326-02" title="조직도 메인 화면"></iframe>
<!-- Legacy organization keeps its own CSS/JS responsibility under /legacy/static. -->
<iframe id="organization-frame" src="/legacy/organization?v=20260330-02" data-src="/legacy/organization?v=20260330-02" title="조직도 메인 화면"></iframe>
</div>
</section>
<section id="project-stage" class="main-stage" hidden>
<div class="stage-frame">
<!-- Integration HTML is served from incoming-files/served/payment.html. -->
<iframe id="project-frame" src="/integrations/payment" data-src="/integrations/payment" title="프로젝트별 분석 화면"></iframe>
</div>
</section>
<section id="team-stage" class="main-stage" hidden>
<div class="stage-frame">
<!-- Integration HTML is served from incoming-files/served/mh.html. -->
<iframe id="team-frame" src="/integrations/mh" data-src="/integrations/mh" title="팀/개인별 분석 화면"></iframe>
</div>
</section>
@@ -200,6 +226,6 @@
</main>
</section>
<script src="/app.js?v=20260326-02"></script>
<script src="/app.js?v=20260401-02"></script>
</body>
</html>

View File

@@ -0,0 +1,100 @@
.dashboard-header {
min-height: 68px;
background:
radial-gradient(circle at 12% 18%, rgba(242, 196, 132, 0.16), transparent 24%),
linear-gradient(145deg, rgba(10, 42, 34, 0.96) 0%, rgba(15, 58, 47, 0.96) 52%, rgba(26, 86, 69, 0.96) 100%);
color: #f7f0e4;
border-bottom: 1px solid rgba(242, 196, 132, 0.22);
backdrop-filter: blur(16px);
box-shadow: var(--ds-shadow-float);
}
.dashboard-header .eyebrow {
color: rgba(242, 196, 132, 0.94);
}
.dashboard-header h2 {
color: #fff7ea;
}
.nav-pill {
min-height: 42px;
padding: 0 14px;
border-radius: 999px;
border: 1px solid rgba(242, 196, 132, 0.28);
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 244, 230, 0.78);
font-size: 14px;
font-weight: 800;
}
.nav-pill.active {
background: linear-gradient(180deg, rgba(255, 253, 248, 0.98), rgba(245, 235, 221, 0.94));
border-color: rgba(242, 196, 132, 0.34);
color: var(--ds-ink);
box-shadow: var(--ds-shadow-float);
}
.nav-pill.muted {
color: rgba(255, 244, 230, 0.48);
}
.nav-pill:hover {
color: #fff7ea;
border-color: rgba(242, 196, 132, 0.48);
}
.header-actions {
border-left: 1px solid rgba(242, 196, 132, 0.2);
}
.header-date-label {
color: rgba(255, 244, 230, 0.72);
}
.header-date-field {
border: 1px solid rgba(242, 196, 132, 0.22);
background: rgba(255, 255, 255, 0.1);
}
.header-date-field input,
.header-date-field select {
color: #fff7ea;
}
.header-date-sep {
color: rgba(255, 244, 230, 0.56);
}
.ghost-button {
border: 1px solid rgba(242, 196, 132, 0.22);
background: rgba(255, 255, 255, 0.08);
color: #fff7ea;
}
.icon-button {
background: rgba(255, 255, 255, 0.1);
}
.icon-button:hover {
background: rgba(242, 196, 132, 0.14);
border-color: rgba(242, 196, 132, 0.32);
color: #fff7ea;
}
.ghost-button-soft {
background: rgba(239, 228, 208, 0.92);
}
.seatmap-status[data-tone="error"] {
color: var(--ds-status-danger);
}
.seatmap-status[data-tone="success"] {
color: var(--ds-status-success);
}
.seatmap-board-wrap,
.seatmap-dxf-canvas {
background: var(--ds-panel);
}

View File

@@ -1,3 +1,30 @@
:root {
--color-bg: var(--ds-bg);
--color-surface: var(--ds-panel);
--color-surface-soft: var(--ds-panel-soft);
--color-surface-strong: var(--ds-panel-strong);
--color-text: var(--ds-ink);
--color-text-soft: var(--ds-text-soft);
--color-text-muted: var(--ds-text-muted);
--color-border: var(--ds-line);
--color-border-soft: var(--ds-line-soft);
--color-brand: var(--ds-brand);
--color-brand-deep: var(--ds-brand-deep);
--color-brand-soft: var(--ds-brand-soft);
--color-accent: var(--ds-accent);
--color-accent-soft: var(--ds-accent-soft);
--color-success: var(--ds-status-success);
--color-danger: var(--ds-status-danger);
--radius-sm: var(--ds-radius-sm);
--radius-md: var(--ds-radius-md);
--radius-lg: var(--ds-radius-lg);
--radius-xl: var(--ds-radius-xl);
--radius-pill: var(--ds-radius-pill);
--shadow-soft: var(--ds-shadow-soft);
--shadow-card: var(--ds-shadow-card);
--shadow-float: var(--ds-shadow-float);
}
.dashboard-shell,
.dashboard-main,
.main-stage,
@@ -31,7 +58,7 @@ body {
min-height: 100vh;
padding: 24px;
background:
linear-gradient(135deg, rgba(15, 23, 42, 0.42), rgba(30, 41, 59, 0.18)),
linear-gradient(135deg, rgba(10, 42, 34, 0.42), rgba(26, 86, 69, 0.18)),
url("https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?auto=format&fit=crop&w=1800&q=80")
center center / cover no-repeat;
}
@@ -54,10 +81,10 @@ body {
display: grid;
grid-template-columns: 1.3fr 0.7fr;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.14);
border: 1px solid var(--ds-glass-line);
border-radius: var(--radius-lg);
background: rgba(71, 85, 105, 0.34);
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.24);
background: var(--ds-glass-dark);
box-shadow: var(--ds-shadow-hero);
backdrop-filter: blur(14px);
}
@@ -68,8 +95,8 @@ body {
padding: 30px 30px;
border-right: 1px solid rgba(255, 255, 255, 0.08);
background:
linear-gradient(90deg, rgba(15, 23, 42, 0.08), rgba(255, 255, 255, 0.02)),
linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(15, 23, 42, 0.08));
linear-gradient(90deg, rgba(10, 42, 34, 0.08), rgba(255, 255, 255, 0.02)),
linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(10, 42, 34, 0.08));
}
.login-brand .eyebrow {
@@ -83,7 +110,7 @@ body {
font-size: clamp(1.7rem, 3.2vw, 2.5rem);
line-height: 0.96;
letter-spacing: -0.04em;
color: #f8fafc;
color: #f7f0e4;
}
.login-form-wrap {
@@ -91,7 +118,7 @@ body {
display: grid;
align-content: center;
gap: 10px;
background: rgba(15, 23, 42, 0.12);
background: var(--ds-glass-dark-soft);
}
.login-card label {
@@ -140,8 +167,8 @@ body {
margin-top: 2px;
border: none;
color: #fff;
background: rgba(31, 41, 55, 0.82);
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.22);
background: rgba(10, 42, 34, 0.82);
box-shadow: var(--shadow-float);
min-height: 34px;
border-radius: 999px;
font-size: 11px;
@@ -167,9 +194,9 @@ body {
.dashboard-header {
min-height: 68px;
background: rgba(255, 255, 255, 0.94);
background: rgba(255, 250, 243, 0.94);
color: var(--color-text);
border-bottom: 1px solid #d7dee8;
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: space-between;
@@ -241,7 +268,7 @@ body {
border: none;
border-bottom: 3px solid transparent;
background: transparent;
color: #64748b;
color: var(--color-text-muted);
font-size: 15px;
font-weight: 700;
cursor: pointer;
@@ -255,7 +282,7 @@ body {
}
.nav-pill.muted {
color: #94a3b8;
color: rgba(102, 117, 109, 0.64);
}
.nav-pill:hover {
@@ -269,7 +296,7 @@ body {
gap: 6px;
position: relative;
padding-left: 18px;
border-left: 1px solid #dbe2ea;
border-left: 1px solid var(--color-border);
}
.header-date-controls {
@@ -284,7 +311,7 @@ body {
.header-date-label {
font-size: 12px;
font-weight: 800;
color: #64748b;
color: var(--color-text-muted);
}
.header-date-field {
@@ -292,9 +319,9 @@ body {
align-items: center;
min-height: 36px;
padding: 0 10px;
border: 1px solid #dbe2ea;
border: 1px solid var(--color-border);
border-radius: 999px;
background: #fff;
background: var(--color-surface);
}
.header-date-field input {
@@ -306,16 +333,27 @@ body {
outline: none;
}
.header-date-field select {
border: 0;
background: transparent;
color: var(--color-text);
font-size: 12px;
font-weight: 700;
outline: none;
appearance: none;
padding-right: 8px;
}
.header-date-sep {
color: #94a3b8;
color: var(--color-text-muted);
font-size: 12px;
font-weight: 800;
}
.ghost-button {
min-height: 34px;
border: 1px solid #dbe2ea;
background: #fff;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
padding: 0 12px;
border-radius: 999px;
@@ -331,12 +369,12 @@ body {
display: inline-flex;
align-items: center;
justify-content: center;
background: #f8fafc;
background: var(--color-surface-soft);
}
.icon-button:hover {
background: #f1f5f9;
border-color: #cbd5e1;
background: var(--ds-bg-soft);
border-color: var(--color-border);
color: var(--color-accent);
transform: translateY(-1px);
}
@@ -352,7 +390,7 @@ body {
}
.ghost-button-soft {
background: #f8fafc;
background: var(--color-surface-soft);
}
.user-chip {
@@ -370,8 +408,8 @@ body {
width: 18px;
height: 18px;
border-radius: 50%;
background: #e2e8f0;
color: #475569;
background: var(--color-surface-strong);
color: var(--color-text-soft);
font-size: 10px;
font-weight: 900;
flex: 0 0 auto;
@@ -410,10 +448,10 @@ body {
right: 0;
min-width: 220px;
padding: 14px;
border: 1px solid #dbe2ea;
border: 1px solid var(--color-border);
border-radius: 16px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.14);
background: rgba(255, 250, 243, 0.96);
box-shadow: var(--shadow-float);
backdrop-filter: blur(12px);
z-index: 30;
}
@@ -429,7 +467,7 @@ body {
}
.user-popover-row + .user-popover-row {
border-top: 1px solid #eef2f7;
border-top: 1px solid rgba(217, 197, 168, 0.4);
}
.user-popover-label {
@@ -443,7 +481,7 @@ body {
min-height: 38px;
border: none;
border-radius: 12px;
background: #0f172a;
background: var(--color-brand);
color: #fff;
font-size: 11px;
font-weight: 800;
@@ -474,7 +512,7 @@ body {
width: 100%;
height: 100%;
border: 0;
background: #fff;
background: var(--color-surface);
}
.stage-empty {
@@ -491,9 +529,7 @@ body {
gap: 12px;
padding: 18px;
overflow: hidden;
background:
linear-gradient(180deg, rgba(248, 250, 252, 0.94), rgba(241, 245, 249, 0.92)),
radial-gradient(circle at top left, rgba(14, 165, 233, 0.1), transparent 32%);
background: var(--ds-bg-gradient);
}
.seatmap-topbar {
@@ -550,6 +586,54 @@ body {
display: none !important;
}
.seatmap-actions .ghost-button {
min-height: 40px;
padding: 0 16px;
border-width: 1px;
border-style: solid;
border-radius: var(--radius-pill);
font-size: 12px;
letter-spacing: -0.01em;
box-shadow: var(--shadow-soft);
}
#seatmap-admin-save-btn {
border-color: var(--color-brand-soft);
background: var(--color-brand-soft);
color: #fffaf3;
}
#seatmap-admin-save-btn:hover:not(:disabled) {
background: var(--color-brand);
border-color: var(--color-brand);
transform: translateY(-1px);
box-shadow: var(--shadow-float);
}
#seatmap-admin-save-btn:disabled {
opacity: 1;
cursor: not-allowed;
border-color: rgba(26, 86, 69, 0.24);
background: rgba(26, 86, 69, 0.18);
color: rgba(16, 37, 29, 0.72);
box-shadow: none;
}
#seatmap-admin-exit-btn,
#seatmap-readonly-exit-btn {
border-color: rgba(214, 138, 58, 0.48);
background: rgba(242, 196, 132, 0.22);
color: var(--color-brand-deep);
}
#seatmap-admin-exit-btn:hover,
#seatmap-readonly-exit-btn:hover {
background: rgba(242, 196, 132, 0.34);
border-color: rgba(182, 110, 34, 0.56);
color: var(--color-brand);
transform: translateY(-1px);
}
.seatmap-status {
min-height: 20px;
margin: 0;

BIN
incoming-files/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 748 KiB

2598
incoming-files/260320.html Normal file

File diff suppressed because one or more lines are too long

34
incoming-files/README.md Normal file
View File

@@ -0,0 +1,34 @@
# incoming-files Layout
`8081` 1차 구조 정리 기준으로 `incoming-files`는 아래처럼 해석한다.
## Served
- 실제 URL에서 직접 서빙되는 HTML
- 현재 사용 파일:
- `served/payment.html`
- `served/mh.html`
주의:
- backend `/integrations/payment`, `/integrations/mh`는 위 `served/*`만 읽는다.
- 새 기능을 붙일 때도 실제 서비스 파일은 `served/` 기준으로 수정한다.
## Reference
- 원본 참고 자산
- 복구 비교용 자산
- 직접 서빙하지 않는 파일
예:
- 원본 `xlsx`, `csv`
- 샘플 스타일 파일
- 원본/백업 HTML
- 디자인 비교용 파일
## Temporary Comparison Copies
- 현재 루트의 `payment.html`, `mh.html`은 당장 삭제하지 않는다.
- 이 두 파일은 기존 recovery 작업본과 현재 `served/*`를 비교하거나 되돌릴 때만 본다.
- 다음 차수에서 안전성이 확보되면 `reference/` 하위로 재배치 여부를 검토한다.

File diff suppressed because it is too large Load Diff

View File

@@ -110,35 +110,35 @@ const App = () => {
};
const costCategories = [
{ name: '인건비', color: '#6366f1' },
{ name: '출장비', color: '#f43f5e' },
{ name: '복리후생비', color: '#fbbf24' },
{ name: '구매비', color: '#0ea5e9' },
{ name: '외주비', color: '#94a3b8' }
{ name: '인건비', color: '#0f3a2f' },
{ name: '출장비', color: '#a94832' },
{ name: '복리후생비', color: '#d68a3a' },
{ name: '구매비', color: '#4b87b3' },
{ name: '외주비', color: '#66756d' }
];
const positionStyles = {
'수석연구원': { bg: 'bg-purple-50', text: 'text-purple-600', border: 'border-purple-100', icon: 'bg-purple-600' },
'책임연구원': { bg: 'bg-blue-50', text: 'text-blue-600', border: 'border-blue-100', icon: 'bg-blue-600' },
'선임연구원': { bg: 'bg-indigo-50', text: 'text-indigo-600', border: 'border-indigo-100', icon: 'bg-indigo-600' },
'전임연구원': { bg: 'bg-emerald-50', text: 'text-emerald-600', border: 'border-emerald-100', icon: 'bg-emerald-600' },
'주임연구원': { bg: 'bg-slate-50', text: 'text-slate-600', border: 'border-slate-100', icon: 'bg-slate-600' },
'연구원': { bg: 'bg-slate-50', text: 'text-slate-500', border: 'border-slate-100', icon: 'bg-slate-400' },
'미지정': { bg: 'bg-gray-50', text: 'text-gray-400', border: 'border-gray-100', icon: 'bg-gray-300' }
'수석연구원': { bg: 'position-chip position-executive', text: 'position-text position-executive', border: 'position-border position-executive', icon: 'position-dot position-executive' },
'책임연구원': { bg: 'position-chip position-principal', text: 'position-text position-principal', border: 'position-border position-principal', icon: 'position-dot position-principal' },
'선임연구원': { bg: 'position-chip position-senior', text: 'position-text position-senior', border: 'position-border position-senior', icon: 'position-dot position-senior' },
'전임연구원': { bg: 'position-chip position-associate', text: 'position-text position-associate', border: 'position-border position-associate', icon: 'position-dot position-associate' },
'주임연구원': { bg: 'position-chip position-staff', text: 'position-text position-staff', border: 'position-border position-staff', icon: 'position-dot position-staff' },
'연구원': { bg: 'position-chip position-member', text: 'position-text position-member', border: 'position-border position-member', icon: 'position-dot position-member' },
'미지정': { bg: 'position-chip position-unset', text: 'position-text position-unset', border: 'position-border position-unset', icon: 'position-dot position-unset' }
};
const positionOrder = { '수석연구원': 1, '책임연구원': 2, '선임연구원': 3, '연구원': 4 };
const positionColorMap = {
'수석연구원': '#7c3aed',
'책임연구원': '#2563eb',
'선임연구원': '#4f46e5',
'전임연구원': '#059669',
'주임연구원': '#475569',
'연구원': '#64748b',
'미지정': '#9ca3af'
'수석연구원': '#0f3a2f',
'책임연구원': '#1a5645',
'선임연구원': '#2f9973',
'전임연구원': '#4b87b3',
'주임연구원': '#9a6422',
'연구원': '#66756d',
'미지정': '#b7aa93'
};
const getPositionStyle = (pos) => positionStyles[pos] || positionStyles['미지정'];
const getCostColor = (name) => costCategories.find(c => c.name === name)?.color || '#94a3b8';
const getCostColor = (name) => costCategories.find(c => c.name === name)?.color || '#66756d';
const getPositionColor = (name) => positionColorMap[name] || positionColorMap['미지정'];
const twoLineClampStyle = {
display: '-webkit-box',
@@ -164,7 +164,7 @@ const App = () => {
const buildDonutGradient = (items) => {
const total = items.reduce((sum, item) => sum + (item.value || 0), 0);
if (total <= 0) return 'conic-gradient(#e2e8f0 0deg 360deg)';
if (total <= 0) return 'conic-gradient(#eadcc4 0deg 360deg)';
let start = 0;
const slices = items.map((item) => {
const deg = ((item.value || 0) / total) * 360;
@@ -177,7 +177,7 @@ const App = () => {
};
const renderBreakdownTooltip = (breakdown, total) => (
<div className="pointer-events-none absolute left-1/2 top-0 z-20 -translate-x-1/2 -translate-y-[110%] whitespace-nowrap rounded-lg bg-slate-900 px-3 py-2 text-[12px] font-bold text-white opacity-0 shadow-lg transition-opacity group-hover:opacity-100">
<div className="pointer-events-none absolute left-1/2 top-0 z-20 -translate-x-1/2 -translate-y-[110%] whitespace-nowrap rounded-lg payment-tooltip px-3 py-2 text-[12px] font-bold opacity-0 shadow-lg transition-opacity group-hover:opacity-100">
{costCategories.map((cat) => {
const val = breakdown?.[cat.name] || 0;
const ratio = total > 0 ? ((val / total) * 100).toFixed(1) : '0.0';
@@ -195,7 +195,7 @@ const App = () => {
);
const renderPositionBreakdownTooltip = (breakdown, totalHrs) => (
<div className="pointer-events-none absolute left-1/2 top-0 z-20 -translate-x-1/2 -translate-y-[110%] whitespace-nowrap rounded-lg bg-slate-900 px-3 py-2 text-[12px] font-bold text-white opacity-0 shadow-lg transition-opacity group-hover:opacity-100">
<div className="pointer-events-none absolute left-1/2 top-0 z-20 -translate-x-1/2 -translate-y-[110%] whitespace-nowrap rounded-lg payment-tooltip px-3 py-2 text-[12px] font-bold opacity-0 shadow-lg transition-opacity group-hover:opacity-100">
{Object.entries(breakdown || {})
.sort(([a], [b]) => (positionOrder[a] || 99) - (positionOrder[b] || 99) || a.localeCompare(b))
.map(([pos, val]) => {
@@ -226,9 +226,9 @@ const App = () => {
return (
<div className="mt-2 grid grid-cols-[72px_1fr] items-center gap-2">
<div className="self-center text-center">
<div className="text-[16px] leading-none font-black text-slate-800">{Number(totalWorkers || 0)}</div>
<div className="text-[16px] leading-none font-black payment-strong">{Number(totalWorkers || 0)}</div>
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[10px] font-black text-slate-600 leading-tight">
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[10px] font-black payment-muted leading-tight">
{entries.map(([pos, val]) => {
const count = details?.[pos]?.names?.size || 0;
const hrsText = Number(val || 0).toFixed(1).replace(/\.0$/, '');
@@ -258,7 +258,7 @@ const App = () => {
{cells.map((cell) => {
const amount = Math.round(breakdown?.[cell.key] || 0);
return (
<div key={`${cell.key}-v`} className="px-2 py-1.5 text-right text-[11px] font-black text-slate-700 whitespace-nowrap">
<div key={`${cell.key}-v`} className="px-2 py-1.5 text-right text-[11px] font-black payment-muted whitespace-nowrap">
{amount === 0 ? '-' : `${amount.toLocaleString()}`}
</div>
);
@@ -1134,23 +1134,23 @@ const App = () => {
const isAllFiltersApplied = selectedRev !== '전체' && selectedD1 !== '전체' && selectedD2 !== '전체' && selectedProject !== '전체';
return (
<div className="min-h-screen bg-[#f8fafc] p-6 font-sans text-slate-900">
<div className="w-full mx-auto space-y-6">
<div className="payment-theme min-h-screen p-6 font-sans">
<div className="w-full mx-auto space-y-6" style={{ maxWidth: '2000px' }}>
{!isAllFiltersApplied && (
<>
{/* KPIs */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4 sticky top-0 z-30 bg-[#f8fafc] pb-3">
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4 sticky top-0 z-30 payment-kpi-grid pb-3">
{[
{ label: '총 수입(매출)', value: formatWonRounded(viewData.kpis.income), totalValue: formatWonRounded(viewData.kpisAll.income), icon: Wallet, color: 'text-indigo-600' },
{ label: '인건비 합계', value: formatWonRounded(viewData.kpis.labor), totalValue: formatWonRounded(viewData.kpisAll.labor), icon: Briefcase, color: 'text-slate-600' },
{ label: '출장비', value: formatWonRounded(viewData.kpis.travel), totalValue: formatWonRounded(viewData.kpisAll.travel), icon: MapPin, color: 'text-rose-600' },
{ label: '복리후생비', value: formatWonRounded(viewData.kpis.welfare), totalValue: formatWonRounded(viewData.kpisAll.welfare), icon: Coffee, color: 'text-amber-600' },
{ label: '구매/외주비', value: formatWonRounded(viewData.kpis.others), totalValue: formatWonRounded(viewData.kpisAll.others), icon: Package, color: 'text-slate-500' },
{ label: '투입시간', value: `${viewData.kpis.hours.toLocaleString()}h`, totalValue: `${viewData.kpisAll.hours.toLocaleString()}h`, icon: Clock, color: 'text-indigo-600' },
{ label: '참여인원', value: `${viewData.kpis.workers}`, totalValue: `${viewData.kpisAll.workers}`, icon: Users, color: 'text-white', bg: 'bg-slate-900' },
{ label: '총 수입(매출)', value: formatWonRounded(viewData.kpis.income), totalValue: formatWonRounded(viewData.kpisAll.income), icon: Wallet, color: 'payment-kpi-income' },
{ label: '인건비 합계', value: formatWonRounded(viewData.kpis.labor), totalValue: formatWonRounded(viewData.kpisAll.labor), icon: Briefcase, color: 'payment-kpi-labor' },
{ label: '출장비', value: formatWonRounded(viewData.kpis.travel), totalValue: formatWonRounded(viewData.kpisAll.travel), icon: MapPin, color: 'payment-kpi-travel' },
{ label: '복리후생비', value: formatWonRounded(viewData.kpis.welfare), totalValue: formatWonRounded(viewData.kpisAll.welfare), icon: Coffee, color: 'payment-kpi-welfare' },
{ label: '구매/외주비', value: formatWonRounded(viewData.kpis.others), totalValue: formatWonRounded(viewData.kpisAll.others), icon: Package, color: 'payment-kpi-others' },
{ label: '투입시간', value: `${viewData.kpis.hours.toLocaleString()}h`, totalValue: `${viewData.kpisAll.hours.toLocaleString()}h`, icon: Clock, color: 'payment-kpi-hours' },
{ label: '참여인원', value: `${viewData.kpis.workers}`, totalValue: `${viewData.kpisAll.workers}`, icon: Users, color: 'payment-kpi-inverse', bg: 'payment-kpi-people' },
].map((kpi, i) => (
<div key={i} className={`${kpi.bg || 'bg-white'} ${kpi.color} p-4 rounded-[22px] border border-slate-100 shadow-sm flex flex-col h-24`}>
<div key={i} className={`payment-kpi-card ${kpi.bg || ''} ${kpi.color} p-4 rounded-[22px] flex flex-col h-24`}>
<span className="text-[11px] font-black uppercase opacity-60 flex justify-between">{kpi.label} <kpi.icon size={10}/></span>
<div className="flex flex-col leading-tight mt-1 gap-1">
<span className="text-lg font-black truncate">{kpi.value}</span>
@@ -1163,16 +1163,16 @@ const App = () => {
)}
{/* 상세 분석 테이블 */}
<section className="bg-white rounded-[35px] shadow-sm border border-slate-100 overflow-visible">
<div className={`px-6 py-4 border-b border-slate-50 flex items-center justify-between gap-4 sticky ${!isAllFiltersApplied ? 'top-[108px]' : 'top-0'} z-40 bg-white/95 backdrop-blur-sm`}>
<h2 className="text-lg font-black flex items-center gap-3"><List size={20} className="text-indigo-600" /> 분야별 프로젝트 상세 분석</h2>
<section className="payment-panel payment-table-panel rounded-[35px] overflow-visible">
<div className={`payment-panel-head px-6 py-4 flex items-center justify-between gap-4 sticky ${!isAllFiltersApplied ? 'top-[108px]' : 'top-0'} z-40 backdrop-blur-sm`}>
<h2 className="text-lg font-black flex items-center gap-3"><List size={20} className="payment-icon-accent" /> 분야별 프로젝트 상세 분석</h2>
<div className="group relative shrink-0">
<button type="button" className="px-3 py-2 bg-slate-900 text-white rounded-xl text-[12px] font-black tracking-wide shadow-sm border border-slate-800">
<button type="button" className="payment-filter-toggle px-3 py-2 rounded-xl text-[12px] font-black tracking-wide shadow-sm border">
카테고리 필터
</button>
<div className="absolute right-0 top-full mt-2 z-30 w-[min(980px,92vw)] rounded-2xl border border-slate-200 bg-white p-3 shadow-2xl opacity-0 pointer-events-none translate-y-1 transition-all duration-200 group-hover:opacity-100 group-hover:pointer-events-auto group-hover:translate-y-0 group-focus-within:opacity-100 group-focus-within:pointer-events-auto group-focus-within:translate-y-0">
<div className="payment-filter-pop absolute right-0 top-full mt-2 z-30 w-[min(980px,92vw)] rounded-2xl p-3 shadow-2xl opacity-0 pointer-events-none translate-y-1 transition-all duration-200 group-hover:opacity-100 group-hover:pointer-events-auto group-hover:translate-y-0 group-focus-within:opacity-100 group-focus-within:pointer-events-auto group-focus-within:translate-y-0">
<div className="flex items-center gap-2">
<div className="flex gap-2 bg-slate-50/80 p-1.5 rounded-2xl border border-slate-100 flex-1 min-w-[420px]">
<div className="payment-filter-bar flex gap-2 p-1.5 rounded-2xl flex-1 min-w-[420px]">
<select value={selectedRev} onChange={e => {setSelectedRev(e.target.value); setSelectedD1('전체'); setSelectedD2('전체'); setSelectedProject('전체');}} className="filter-select flex-1">
<option value="전체">대분류 전체</option>
{Object.keys(viewData.hierarchy)
@@ -1209,7 +1209,7 @@ const App = () => {
className="filter-select flex-[1.1]"
/>
</div>
<button onClick={() => {setSelectedRev('전체'); setSelectedD1('전체'); setSelectedD2('전체'); setSelectedProject('전체'); setProjectSearch('');}} className="p-1.5 bg-white rounded-xl border border-slate-200 text-slate-400 hover:text-indigo-600 transition-all shadow-sm shrink-0"><RefreshCw size={14}/></button>
<button onClick={() => {setSelectedRev('전체'); setSelectedD1('전체'); setSelectedD2('전체'); setSelectedProject('전체'); setProjectSearch('');}} className="payment-reset-btn p-1.5 rounded-xl transition-all shadow-sm shrink-0"><RefreshCw size={14}/></button>
</div>
</div>
</div>
@@ -1226,17 +1226,17 @@ const App = () => {
<col style={{ width: '23%' }} />
<col style={{ width: '26%' }} />
</colgroup>
<thead className="bg-slate-50/80">
<tr className="text-[11px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-100">
<thead className="payment-table-head">
<tr className="text-[12px] font-extrabold uppercase tracking-widest payment-table-head-row">
<th className="px-4 py-3 whitespace-nowrap">대분류</th>
<th className="px-4 py-3 whitespace-nowrap">중분류</th>
<th className="px-4 py-3 whitespace-nowrap">소분류</th>
<th className="px-4 py-3 whitespace-nowrap">{viewData.isAllFiltersOff ? '' : '프로젝트명'}</th>
<th className="px-4 py-3 whitespace-nowrap">프로젝트명</th>
<th className="px-4 py-3 text-right whitespace-nowrap">수입(매출)</th>
<th className="px-4 py-3 text-right whitespace-nowrap">지출 합계</th>
<th className="px-4 py-3 whitespace-nowrap text-center">
<div className="text-[11px] font-black text-slate-500 mb-1 text-center">지출 구성비</div>
<div className="grid grid-cols-5 text-[10px] font-black text-slate-600 normal-case tracking-normal">
<div className="text-[11px] font-black payment-subhead mb-1 text-center">지출 구성비</div>
<div className="grid grid-cols-5 text-[10px] font-black payment-subhead normal-case tracking-normal">
<span className="py-1 text-center">인건비</span>
<span className="py-1 text-center">출장비</span>
<span className="py-1 text-center">복리후생비</span>
@@ -1250,7 +1250,7 @@ const App = () => {
<tbody className="text-[13px] font-bold">
{viewData.finalDisplayList.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-12 text-center text-slate-400 font-bold">표시할 데이터가 없습니다.</td>
<td colSpan={8} className="px-4 py-12 text-center payment-empty font-bold">표시할 데이터가 없습니다.</td>
</tr>
)}
{viewData.finalDisplayList.map((item, idx) => {
@@ -1259,16 +1259,16 @@ const App = () => {
return (
<tr
key={`subtotal-${idx}`}
className={`h-12 border-y ${isGrandTotal ? 'bg-indigo-100 border-indigo-300 shadow-[inset_0_1px_0_rgba(99,102,241,0.35)]' : 'bg-amber-50 border-amber-200'}`}
className={`h-12 border-y ${isGrandTotal ? 'payment-subtotal payment-subtotal-grand shadow-[inset_0_1px_0_rgba(33,70,52,0.18)]' : 'payment-subtotal payment-subtotal-mid'}`}
>
<td colSpan={item.labelColSpan || 4} className={`px-4 py-3 whitespace-nowrap ${isGrandTotal ? 'text-indigo-900 text-[14px] font-extrabold' : 'text-amber-900 font-black'}`}>
<td colSpan={item.labelColSpan || 4} className={`px-4 py-3 whitespace-nowrap ${isGrandTotal ? 'payment-subtotal-label-grand text-[14px] font-extrabold' : 'payment-subtotal-label-mid font-black'}`}>
{item.subtotalLabel}
</td>
<td className={`px-4 py-3 text-right font-black whitespace-nowrap ${isGrandTotal ? 'text-indigo-800 text-[14px]' : 'text-amber-800'}`}>{formatWonDash(item.income)}</td>
<td className={`px-4 py-3 text-right font-black whitespace-nowrap ${isGrandTotal ? 'text-indigo-900 text-[14px]' : 'text-amber-900'}`}>{formatWonRoundedDash(item.total)}</td>
<td className={`px-4 py-3 text-right font-black whitespace-nowrap ${isGrandTotal ? 'payment-subtotal-income-grand text-[14px]' : 'payment-subtotal-income-mid'}`}>{formatWonDash(item.income)}</td>
<td className={`px-4 py-3 text-right font-black whitespace-nowrap ${isGrandTotal ? 'payment-subtotal-total-grand text-[14px]' : 'payment-subtotal-total-mid'}`}>{formatWonRoundedDash(item.total)}</td>
<td className="px-4 py-3">{renderCostBreakdownTable(item.costBreakdown)}</td>
<td className="px-4 py-3">
<div className={`h-2.5 rounded-full overflow-hidden flex shadow-inner ${isGrandTotal ? 'bg-indigo-200/80' : 'bg-amber-100'}`}>
<div className={`h-2.5 rounded-full overflow-hidden flex shadow-inner ${isGrandTotal ? 'payment-progress-track-grand' : 'payment-progress-track-mid'}`}>
{Object.entries(item.positionBreakdown || {})
.sort(([a], [b]) => (positionOrder[a] || 99) - (positionOrder[b] || 99) || a.localeCompare(b))
.map(([pos, val]) => {
@@ -1284,12 +1284,12 @@ const App = () => {
}
return (
<tr key={`row-${idx}`} className="h-12 hover:bg-indigo-50/30 transition-all border-b border-slate-50 group">
<tr key={`row-${idx}`} className="payment-data-row h-12 transition-all border-b group">
{item.d1Span > 0 && (
<td
rowSpan={item.d1Span}
onClick={() => handleD1Click(item.d1)}
className={`px-3 py-3 border-r border-slate-100 align-middle font-black cursor-pointer transition-colors whitespace-normal ${selectedRev === item.d1 ? 'bg-slate-200 text-slate-900' : 'bg-white text-slate-900 hover:bg-slate-50 hover:text-slate-900'}`}
className={`px-3 py-3 payment-axis-cell align-middle font-black cursor-pointer transition-colors whitespace-normal ${selectedRev === item.d1 ? 'payment-axis-cell-active' : 'payment-axis-cell-idle'}`}
>
<span className="block whitespace-normal break-words leading-tight" style={twoLineClampStyle}>{item.d1}</span>
</td>
@@ -1298,7 +1298,7 @@ const App = () => {
<td
rowSpan={item.d2Span}
onClick={() => handleD2Click(item.d1, item.d2)}
className={`px-3 py-3 border-r border-slate-100 align-middle font-black cursor-pointer transition-colors whitespace-normal ${selectedRev === item.d1 && selectedD1 === item.d2 ? 'bg-slate-200 text-slate-900' : 'bg-white text-slate-900 hover:bg-slate-50 hover:text-slate-900'}`}
className={`px-3 py-3 payment-axis-cell align-middle font-black cursor-pointer transition-colors whitespace-normal ${selectedRev === item.d1 && selectedD1 === item.d2 ? 'payment-axis-cell-active' : 'payment-axis-cell-idle'}`}
>
<span className="block whitespace-normal break-words leading-tight" style={twoLineClampStyle}>{item.d2}</span>
</td>
@@ -1307,22 +1307,22 @@ const App = () => {
<td
rowSpan={item.d3Span}
onClick={() => handleD3Click(item.d1, item.d2, item.d3)}
className={`px-3 py-3 border-r border-slate-100 align-middle font-black cursor-pointer transition-colors whitespace-normal ${selectedRev === item.d1 && selectedD1 === item.d2 && selectedD2 === item.d3 ? 'bg-slate-200 text-slate-900' : 'bg-white text-slate-900 hover:bg-slate-50 hover:text-slate-900'}`}
className={`px-3 py-3 payment-axis-cell align-middle font-black cursor-pointer transition-colors whitespace-normal ${selectedRev === item.d1 && selectedD1 === item.d2 && selectedD2 === item.d3 ? 'payment-axis-cell-active' : 'payment-axis-cell-idle'}`}
>
<span className="block whitespace-normal break-words leading-tight" style={twoLineClampStyle}>{item.d3}</span>
</td>
)}
<td
onClick={() => { if (!viewData.isAllFiltersOff) handleD4Click(item.d1, item.d2, item.d3, item.name); }}
className={`px-4 py-3 text-slate-700 transition-colors ${viewData.isAllFiltersOff ? '' : 'truncate cursor-pointer hover:bg-indigo-50 hover:text-indigo-800'}`}
onClick={() => { handleD4Click(item.d1, item.d2, item.d3, item.name); }}
className="px-4 py-3 payment-project-cell font-extrabold truncate cursor-pointer transition-colors"
>
{viewData.isAllFiltersOff ? '\u00A0' : item.name}
{item.name}
</td>
<td className="px-4 py-3 text-right text-emerald-700 font-extrabold whitespace-nowrap">{formatWonDash(item.income)}</td>
<td className="px-4 py-3 text-right text-rose-700 font-extrabold whitespace-nowrap">{formatWonRoundedDash(item.total)}</td>
<td className="px-4 py-3 text-right payment-income font-extrabold whitespace-nowrap">{formatWonDash(item.income)}</td>
<td className="px-4 py-3 text-right payment-expense font-extrabold whitespace-nowrap">{formatWonRoundedDash(item.total)}</td>
<td className="px-4 py-3">{renderCostBreakdownTable(item.costBreakdown)}</td>
<td className="px-4 py-3">
<div className="h-2.5 bg-slate-100 rounded-full overflow-hidden flex shadow-inner">
<div className="h-2.5 payment-progress-track rounded-full overflow-hidden flex shadow-inner">
{Object.entries(item.positionBreakdown || {})
.sort(([a], [b]) => (positionOrder[a] || 99) - (positionOrder[b] || 99) || a.localeCompare(b))
.map(([pos, val]) => {
@@ -1343,8 +1343,8 @@ const App = () => {
{/* 하단 상세 차트 */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 pb-12">
<div className="lg:col-span-5 bg-white p-8 rounded-[40px] shadow-sm border border-slate-100 min-h-[480px] flex flex-col">
<h3 className="text-lg font-black mb-4 flex items-center gap-3"><Target className="text-indigo-600"/> 지출 구성 상세</h3>
<div className="lg:col-span-5 payment-panel p-8 rounded-[40px] min-h-[480px] flex flex-col">
<h3 className="text-lg font-black mb-4 flex items-center gap-3"><Target className="payment-icon-accent"/> 지출 구성 상세</h3>
<div className="flex-1">
{viewData.categoryData.length > 0 ? (
<div className="h-full flex flex-col gap-5">
@@ -1354,9 +1354,9 @@ const App = () => {
className="relative h-56 w-56 rounded-full"
style={{ background: buildDonutGradient(viewData.categoryData) }}
>
<div className="absolute inset-11 rounded-full bg-white border border-slate-100 flex flex-col items-center justify-center">
<span className="text-[12px] font-black text-slate-500"> 지출</span>
<span className="text-[15px] font-black text-slate-900">
<div className="absolute inset-11 payment-donut-center rounded-full flex flex-col items-center justify-center">
<span className="text-[12px] font-black payment-subhead"> 지출</span>
<span className="text-[15px] font-black payment-strong">
{formatWon(viewData.categoryData.reduce((sum, item) => sum + (item.value || 0), 0))}
</span>
</div>
@@ -1374,13 +1374,13 @@ const App = () => {
if (!isSelectable) return;
setSelectedExpenseDetailCategory((prev) => (prev === item.name ? '' : item.name));
}}
className={`flex items-center justify-between gap-2 text-[13px] font-bold text-left rounded-lg px-2 py-1.5 transition-colors ${isSelectable ? 'hover:bg-slate-50 cursor-pointer' : 'cursor-default'} ${isSelected ? 'bg-indigo-50' : ''}`}
className={`payment-cost-row flex items-center justify-between gap-2 text-[13px] font-bold text-left rounded-lg px-2 py-1.5 transition-colors ${isSelectable ? 'cursor-pointer' : 'cursor-default'} ${isSelected ? 'payment-cost-row-active' : ''}`}
>
<span className="flex items-center gap-2 text-slate-600 truncate">
<span className="flex items-center gap-2 payment-muted truncate">
<span className="inline-block w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: getCostColor(item.name) }}></span>
{item.name} ({item.ratio}%)
</span>
<span className="text-slate-900">{formatWon(item.value)}</span>
<span className="payment-strong">{formatWon(item.value)}</span>
</button>
);
})}
@@ -1388,20 +1388,20 @@ const App = () => {
</div>
{viewData.isAllFiltersOff && (
<div className="w-full mt-4 text-[12px] text-slate-400 font-bold text-center">
<div className="w-full mt-4 text-[12px] payment-empty font-bold text-center">
상세 내역은 필터 적용 표시됩니다.
</div>
)}
{!viewData.isAllFiltersOff && selectedExpenseDetailCategory && selectedExpenseDetailCategory !== '인건비' && (
<div className="w-full mt-5 pt-4 border-t border-slate-100">
<div className="text-[12px] font-black text-slate-600 mb-2">
<div className="w-full mt-5 pt-4 payment-divider-top">
<div className="text-[12px] font-black payment-subhead mb-2">
{selectedExpenseDetailCategory} 지출 구성 상세 내역
</div>
{(viewData.expenseDetailByCategory?.[selectedExpenseDetailCategory] || []).length > 0 ? (
<div className="max-h-56 overflow-y-auto rounded-lg border border-slate-100 custom-scrollbar">
<div className="max-h-56 overflow-y-auto rounded-lg payment-mini-table-shell custom-scrollbar">
<table className="w-full text-[12px] table-fixed border-collapse">
<thead className="bg-slate-50 text-slate-500 font-black">
<thead className="payment-mini-table-head font-black">
<tr>
<th className="px-2 py-2 text-left w-[74px]">발행월</th>
<th className="px-2 py-2 text-left w-[88px]">발행일</th>
@@ -1412,7 +1412,7 @@ const App = () => {
</thead>
<tbody>
{(viewData.expenseDetailByCategory[selectedExpenseDetailCategory] || []).map((row, idx) => (
<tr key={`${selectedExpenseDetailCategory}-${idx}`} className="border-t border-slate-50 text-slate-700">
<tr key={`${selectedExpenseDetailCategory}-${idx}`} className="payment-mini-table-row">
<td className="px-2 py-1.5 whitespace-nowrap">{row.issueMonth || '-'}</td>
<td className="px-2 py-1.5 whitespace-nowrap">{row.issueDate || '-'}</td>
<td className="px-2 py-1.5 truncate">{row.summary || '-'}</td>
@@ -1424,21 +1424,21 @@ const App = () => {
</table>
</div>
) : (
<div className="text-[12px] text-slate-400 font-bold">표시할 전표 데이터가 없습니다.</div>
<div className="text-[12px] payment-empty font-bold">표시할 전표 데이터가 없습니다.</div>
)}
</div>
)}
</div>
) : (
<div className="h-full flex items-center justify-center text-slate-300 text-sm font-bold">표시할 지출 데이터가 없습니다.</div>
<div className="h-full flex items-center justify-center payment-empty text-sm font-bold">표시할 지출 데이터가 없습니다.</div>
)}
</div>
</div>
<div className="lg:col-span-7 bg-white p-8 rounded-[40px] shadow-sm border border-slate-100 flex flex-col h-[560px] overflow-hidden">
<div className="lg:col-span-7 payment-panel p-8 rounded-[40px] flex flex-col h-[560px] overflow-hidden">
<h3 className="text-lg font-black mb-4 flex items-center gap-3 shrink-0">
<UserCheck className="text-indigo-600"/> 직급별 인원 투입 상세
<span className="ml-1 text-[11px] font-black text-indigo-600 bg-indigo-50 border border-indigo-100 px-2 py-1 rounded-lg">
<UserCheck className="payment-icon-accent"/> 직급별 인원 투입 상세
<span className="payment-mode-chip ml-1 text-[11px] font-black px-2 py-1 rounded-lg">
기준: {viewData.positionGroupMode}
</span>
</h3>
@@ -1453,33 +1453,33 @@ const App = () => {
})
.map(([pName, positions]) => (
<div key={pName} className="mb-8 last:mb-0">
<div className="bg-slate-900 px-4 py-1.5 rounded-xl text-[12px] font-black text-white mb-4 sticky top-0 z-10">{pName}</div>
<div className="payment-group-title px-4 py-1.5 rounded-xl text-[12px] font-black mb-4 sticky top-0 z-10">{pName}</div>
<div className="grid grid-cols-1 gap-3">
{Object.entries(positions)
.sort(([a], [b]) => (positionOrder[a] || 99) - (positionOrder[b] || 99) || a.localeCompare(b))
.map(([pos, data]) => {
const style = getPositionStyle(pos);
return (
<div key={pos} className={`bg-white border ${style.border} rounded-[28px] p-5 flex items-center gap-6 hover:shadow-md transition-all`}>
<div key={pos} className={`payment-position-card border ${style.border} rounded-[28px] p-5 flex items-center gap-6 transition-all`}>
<div className={`flex items-center gap-3 w-1/4 shrink-0 px-4 py-2 rounded-2xl ${style.bg} border ${style.border}`}>
<div className={`w-3 h-3 rounded-full ${style.icon} shadow-sm`}></div>
<div className={`text-[14px] font-black ${style.text}`}>{pos}</div>
</div>
<div className="flex-1 grid grid-cols-2 gap-8 border-l border-slate-100 pl-8">
<div className="flex-1 grid grid-cols-2 gap-8 payment-divider-left pl-8">
<div>
<div className="text-[11px] text-slate-400 font-black uppercase mb-1">Estimated Cost</div>
<div className="text-[16px] font-black text-indigo-600 font-mono">{Math.round(data.labor).toLocaleString()}</div>
<div className="text-[11px] payment-empty font-black uppercase mb-1">Estimated Cost</div>
<div className="text-[16px] font-black payment-icon-accent font-mono">{Math.round(data.labor).toLocaleString()}</div>
</div>
<div>
<div className="text-[11px] text-slate-400 font-black uppercase mb-1">Hours & Count</div>
<div className="text-[16px] font-black text-slate-900">{data.hrs.toFixed(2)}h <span className="text-slate-300 mx-1">|</span> {data.names.size}</div>
<div className="text-[11px] payment-empty font-black uppercase mb-1">Hours & Count</div>
<div className="text-[16px] font-black payment-strong">{data.hrs.toFixed(2)}h <span className="payment-divider-mark mx-1">|</span> {data.names.size}</div>
</div>
</div>
<div className="w-1/3 min-w-[260px] border-l border-slate-100 pl-4">
<div className="w-1/3 min-w-[260px] payment-divider-left pl-4">
<div className="overflow-x-auto overflow-y-hidden custom-scrollbar">
<div className="grid grid-rows-2 grid-flow-col auto-cols-max gap-x-1.5 gap-y-1.5 min-w-max pb-1">
{Array.from(data.names).map(name => (
<span key={name} className="px-2 py-0.5 bg-slate-50 text-slate-500 rounded-lg text-[11px] font-bold border border-slate-100 whitespace-nowrap">{name}</span>
<span key={name} className="payment-name-chip px-2 py-0.5 rounded-lg text-[11px] font-bold whitespace-nowrap">{name}</span>
))}
</div>
</div>
@@ -1491,7 +1491,7 @@ const App = () => {
</div>
))
) : (
<div className="h-full flex flex-col items-center justify-center text-slate-300 gap-3">
<div className="h-full flex flex-col items-center justify-center payment-empty gap-3">
<Info size={40} />
<span className="text-sm font-bold">표시할 데이터가 없습니다.</span>
</div>
@@ -1500,18 +1500,18 @@ const App = () => {
</div>
</div>
<section className="bg-white rounded-[35px] shadow-sm border border-slate-100 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-50 flex items-center justify-between gap-4">
<h3 className="text-lg font-black flex items-center gap-3"><List size={18} className="text-indigo-600" /> 프로젝트별 Activity 분석</h3>
<section className="payment-panel rounded-[35px] overflow-hidden">
<div className="payment-panel-head px-6 py-4 flex items-center justify-between gap-4">
<h3 className="text-lg font-black flex items-center gap-3"><List size={18} className="payment-icon-accent" /> 프로젝트별 Activity 분석</h3>
</div>
<div className="p-6">
{viewData.projectActivityList.length > 0 ? (
<div className="space-y-4">
{viewData.projectActivityList.map((project) => (
<div key={`activity-${project.projectName}`} className="border border-slate-200 rounded-2xl overflow-hidden">
<div className="px-4 py-3 bg-slate-50 border-b border-slate-200 flex items-center justify-between gap-3">
<div className="text-[14px] font-black text-slate-900 truncate">{project.projectName}</div>
<div className="text-[12px] font-black text-indigo-700 whitespace-nowrap"> {formatHours(project.totalHours)}h · {project.workerCount}</div>
<div key={`activity-${project.projectName}`} className="payment-activity-card border rounded-2xl overflow-hidden">
<div className="payment-activity-card-head px-4 py-3 flex items-center justify-between gap-3">
<div className="text-[14px] font-black payment-strong truncate">{project.projectName}</div>
<div className="text-[12px] font-black payment-icon-accent whitespace-nowrap"> {formatHours(project.totalHours)}h · {project.workerCount}</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse table-fixed">
@@ -1521,8 +1521,8 @@ const App = () => {
<col style={{ width: '90px' }} />
<col style={{ width: 'auto' }} />
</colgroup>
<thead className="bg-slate-50/70 border-b border-slate-100">
<tr className="text-[11px] font-black text-slate-500 uppercase tracking-wide">
<thead className="payment-mini-table-head border-b">
<tr className="text-[11px] font-black payment-subhead uppercase tracking-wide">
<th className="px-3 py-2 whitespace-nowrap">Activity</th>
<th className="px-3 py-2 text-right whitespace-nowrap">투입시간</th>
<th className="px-3 py-2 text-right whitespace-nowrap">투입인원</th>
@@ -1531,14 +1531,14 @@ const App = () => {
</thead>
<tbody>
{project.activities.map((activity) => (
<tr key={`${project.projectName}-${activity.activityName}`} className="border-b border-slate-50 last:border-b-0">
<td className="px-3 py-2 text-[12px] font-black text-slate-800 whitespace-nowrap truncate">{activity.activityName}</td>
<td className="px-3 py-2 text-[12px] font-black text-right text-indigo-700 whitespace-nowrap">{formatHours(activity.hours)}h</td>
<td className="px-3 py-2 text-[12px] font-black text-right text-slate-700 whitespace-nowrap">{activity.workerCount}</td>
<td className="px-3 py-2 text-[12px] text-slate-600">
<tr key={`${project.projectName}-${activity.activityName}`} className="payment-mini-table-row last:border-b-0">
<td className="px-3 py-2 text-[12px] font-black payment-strong whitespace-nowrap truncate">{activity.activityName}</td>
<td className="px-3 py-2 text-[12px] font-black text-right payment-icon-accent whitespace-nowrap">{formatHours(activity.hours)}h</td>
<td className="px-3 py-2 text-[12px] font-black text-right payment-muted whitespace-nowrap">{activity.workerCount}</td>
<td className="px-3 py-2 text-[12px] payment-muted">
<div className="flex flex-wrap gap-1.5">
{activity.members.map((m) => (
<span key={`${activity.activityName}-${m.name}`} className="px-2 py-0.5 rounded-lg bg-slate-50 border border-slate-100 text-[11px] font-bold text-slate-600 whitespace-nowrap">
<span key={`${activity.activityName}-${m.name}`} className="payment-name-chip px-2 py-0.5 rounded-lg text-[11px] font-bold whitespace-nowrap">
{m.name} ({formatHours(m.hours)}h)
</span>
))}
@@ -1553,24 +1553,46 @@ const App = () => {
))}
</div>
) : (
<div className="py-10 text-center text-slate-300 text-sm font-bold">표시할 Activity 데이터가 없습니다.</div>
<div className="py-10 text-center payment-empty text-sm font-bold">표시할 Activity 데이터가 없습니다.</div>
)}
</div>
</section>
</div>
<style>{`
@import url('/design-tokens.css');
@import url('/design-patterns.css');
@import url('https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap');
body { font-family: 'Pretendard', sans-serif; letter-spacing: -0.025em; -webkit-font-smoothing: antialiased; background-color: #f8fafc; }
body { font-family: 'Pretendard', sans-serif; letter-spacing: -0.025em; -webkit-font-smoothing: antialiased; background-color: var(--ds-bg); color: var(--ds-ink); }
.payment-theme { color: var(--ds-ink); }
.payment-kpi-income, .payment-kpi-hours { color: var(--ds-brand-soft); }
.payment-kpi-labor, .payment-kpi-others { color: var(--ds-text-soft); }
.payment-kpi-travel { color: var(--ds-status-danger); }
.payment-kpi-welfare { color: var(--ds-status-warning); }
.payment-filter-pop { border: 1px solid var(--ds-line); background: rgba(255,250,243,0.98); }
.payment-subtotal { border-color: var(--ds-line); }
.payment-subtotal-grand { background: #efe2ca; }
.payment-subtotal-mid { background: #f6e6c9; }
.payment-subtotal-label-grand, .payment-subtotal-total-grand { color: var(--ds-brand-deep); }
.payment-subtotal-income-grand { color: var(--ds-brand-soft); }
.payment-subtotal-label-mid, .payment-subtotal-total-mid { color: #9a6422; }
.payment-subtotal-income-mid { color: #7b5a20; }
.payment-donut-center { background: rgba(255,250,243,0.98); border: 1px solid var(--ds-line-soft); }
.payment-cost-row:hover { background: rgba(234,220,196,0.34); }
.payment-cost-row-active { background: rgba(242,196,132,0.18); }
.payment-position-card { background: rgba(255,250,243,0.96); box-shadow: var(--ds-shadow-soft); }
.payment-activity-card { border-color: var(--ds-line-soft); }
.payment-activity-card-head { background: rgba(246,237,221,0.68); border-bottom: 1px solid var(--ds-line-soft); }
.filter-select {
background-color: transparent; border: none; padding: 0.35rem 1.6rem 0.35rem 0.5rem; font-size: 10px; font-weight: 800;
outline: none; appearance: none; cursor: pointer; transition: all 0.2s;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2394a3b8'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
color: var(--ds-ink);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2366756d'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 0.4rem center; background-size: 0.6rem;
}
.filter-select:hover { color: #6366f1; background-color: white; border-radius: 8px; }
.filter-select:hover { color: var(--ds-brand-soft); background-color: rgba(255,255,255,0.98); border-radius: 8px; }
.custom-scrollbar::-webkit-scrollbar { width: 4px; height: 4px; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #e2e8f0; border-radius: 10px; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: var(--ds-line); border-radius: 10px; }
`}</style>
</div>
);

View File

@@ -0,0 +1,13 @@
# Reference Assets
이 디렉터리는 앞으로 `8081`에서 직접 서빙하지 않는 참고 원본/복구 비교 자산을 모으기 위한 공간이다.
1차 정리에서는 위험한 대량 이동을 피하기 위해 기존 참고 파일을 즉시 옮기지 않는다.
대신 실제 서빙 파일은 `incoming-files/served/`로 고정하고, 다음 차수에서 참고 자산을 단계적으로 재배치한다.
예상 대상:
- 원본 HTML/CSS 참고본
- 원본 xlsx/csv
- 복구 비교용 자산
- 디자인 레퍼런스 파일

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,931 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>center chair people map</title>
<style>
:root {
--ink: #152330;
--muted: #627286;
--paper: rgba(255,255,255,0.86);
--line: rgba(21,35,48,0.1);
--accent: #0f766e;
--bg: #edf2f6;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
}
.page {
min-height: 100vh;
padding: 0;
}
.shell {
min-height: 100vh;
}
.panel {
border-radius: 0;
border: none;
background: transparent;
backdrop-filter: none;
box-shadow: none;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
button {
border: none;
border-radius: 999px;
padding: 10px 14px;
font: inherit;
font-weight: 700;
cursor: pointer;
color: white;
background: linear-gradient(135deg, #0f766e, #115e59);
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
}
button.alt {
color: var(--ink);
background: rgba(255,255,255,0.9);
border: 1px solid var(--line);
box-shadow: none;
}
.viewer {
position: relative;
overflow: hidden;
min-height: 100vh;
}
.viewer-head {
position: absolute;
top: 16px;
left: 16px;
right: 16px;
z-index: 2;
display: flex;
justify-content: space-between;
gap: 12px;
pointer-events: none;
}
.chip {
padding: 10px 12px;
border-radius: 16px;
background: rgba(255,255,255,0.82);
border: 1px solid rgba(255,255,255,0.94);
color: var(--muted);
font-size: 13px;
font-weight: 700;
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
}
.viewer-actions {
position: absolute;
left: 16px;
top: 64px;
z-index: 2;
display: flex;
gap: 8px;
}
.mapper {
position: absolute;
top: 76px;
left: 50%;
transform: translateX(-50%);
width: min(94vw, 1320px);
max-height: min(56vh, 560px);
overflow: hidden;
z-index: 4;
border-radius: 20px;
background: rgba(234, 239, 247, 0.95);
border: 1px solid rgba(101, 119, 146, 0.22);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
display: flex;
flex-direction: column;
backdrop-filter: blur(6px);
}
.hidden-off {
display: none !important;
}
.mapper-head {
padding: 10px 14px;
border-bottom: 1px solid rgba(101,119,146,0.18);
font-size: 12px;
color: #51607a;
font-weight: 700;
line-height: 1.35;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: rgba(255,255,255,0.6);
}
.mapper-head strong {
display: block;
color: #17243b;
font-size: 20px;
margin-bottom: 2px;
}
.mapper-head .alt {
padding: 8px 10px;
font-size: 12px;
white-space: nowrap;
}
.org-chart {
margin: 0;
padding: 14px;
overflow: auto;
display: grid;
gap: 12px;
}
.org-top {
margin: 0 auto;
width: min(100%, 420px);
border-radius: 14px;
overflow: hidden;
border: 1px solid rgba(67, 84, 118, 0.25);
background: #fff;
}
.org-top-title {
background: #1e2f4d;
color: #fff;
text-align: center;
font-size: 34px;
font-weight: 800;
line-height: 1.1;
padding: 16px 12px;
letter-spacing: -0.03em;
}
.org-top-members {
padding: 10px;
display: grid;
gap: 6px;
background: rgba(255,255,255,0.95);
}
.org-teams {
display: grid;
grid-template-columns: repeat(7, minmax(160px, 1fr));
gap: 10px;
align-items: start;
}
.org-team {
border: 1px solid rgba(110, 126, 152, 0.25);
border-radius: 10px;
overflow: hidden;
background: rgba(255,255,255,0.95);
min-width: 0;
}
.org-team h4 {
margin: 0;
padding: 9px 10px;
font-size: 14px;
color: #21324e;
font-weight: 800;
border-bottom: 1px solid rgba(110, 126, 152, 0.2);
background: rgba(240, 245, 252, 0.96);
}
.org-members {
padding: 7px;
display: grid;
gap: 6px;
}
.org-person {
border: 1px solid rgba(116, 133, 161, 0.25);
background: rgba(255,255,255,0.95);
border-radius: 8px;
padding: 6px 8px;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
min-width: 0;
}
.org-person.active {
border-color: rgba(15,118,110,0.6);
background: rgba(15,118,110,0.11);
}
.org-person.assigned {
border-color: rgba(37,99,235,0.5);
background: rgba(37,99,235,0.1);
}
.org-person strong {
display: block;
font-size: 13px;
line-height: 1.3;
color: #15233a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.org-person small {
display: block;
color: #5a6a86;
font-size: 11px;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 980px) {
.mapper {
top: 72px;
width: min(96vw, 920px);
max-height: 58vh;
}
.viewer-actions {
top: 64px;
left: 12px;
right: 12px;
flex-wrap: wrap;
}
.mapper-head strong {
font-size: 16px;
}
.org-top-title {
font-size: 24px;
}
.org-teams {
grid-template-columns: repeat(3, minmax(150px, 1fr));
}
}
canvas {
width: 100%;
height: 100%;
display: block;
cursor: grab;
}
canvas.dragging { cursor: grabbing; }
.tooltip {
position: absolute;
min-width: 170px;
padding: 12px 14px;
border-radius: 16px;
background: rgba(17,24,39,0.94);
color: white;
pointer-events: none;
opacity: 0;
transform: translate(12px, 12px);
transition: opacity 120ms ease;
z-index: 3;
}
.tooltip.visible { opacity: 1; }
.tooltip strong { display: block; margin-bottom: 6px; font-size: 14px; }
.tooltip div { font-size: 12px; line-height: 1.45; color: rgba(255,255,255,0.82); }
</style>
</head>
<body>
<div class="page">
<div class="shell">
<main class="panel viewer">
<div class="viewer-head">
<div class="chip" id="scale-chip"></div>
<div class="chip" id="hover-chip">chair hover: none</div>
</div>
<div class="viewer-actions">
<button type="button" id="fit-btn">전체 맞춤</button>
<button type="button" class="alt" id="clear-btn">선택 지우기</button>
</div>
<aside class="mapper hidden-off">
<div class="mapper-head">
<div id="mapper-status">
<strong>조직 현황</strong>
<span>선택 인원 없음</span>
</div>
<button type="button" class="alt" id="clear-assign-btn">매칭 초기화</button>
</div>
<div class="org-chart" id="org-chart"></div>
</aside>
<canvas id="canvas"></canvas>
<div class="tooltip" id="tooltip"></div>
</main>
</div>
</div>
<script src="./center_chair_people_payload.js?v=20260330a"></script>
<script>
const DATA = window.CHAIR_MAP_DATA;
function decodeSegments(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return new Int32Array(bytes.buffer);
}
const bgTileRanges = DATA.bgTileRanges;
const bgSegValues = decodeSegments(DATA.bgSegsB64);
const chairSegValues = decodeSegments(DATA.chairSegsB64);
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
key, name, kind, start, count
}));
const meta = DATA.meta;
const world = meta.headerBounds;
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const tooltip = document.getElementById("tooltip");
const scaleChip = document.getElementById("scale-chip");
const hoverChip = document.getElementById("hover-chip");
const STORAGE_KEY = "ptc-chair-selection";
const PEOPLE_STORAGE_KEY = "ptc-chair-people";
const ASSIGN_STORAGE_KEY = "ptc-chair-assignments";
const ACTIVE_PERSON_STORAGE_KEY = "ptc-chair-active-person";
const clearAssignBtn = document.getElementById("clear-assign-btn");
const orgChartEl = document.getElementById("org-chart");
const mapperStatus = document.getElementById("mapper-status");
// Prevent stale auto-highlights from previous sessions.
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
localStorage.removeItem(ASSIGN_STORAGE_KEY);
const placed = new Set();
let people = JSON.parse(localStorage.getItem(PEOPLE_STORAGE_KEY) || "[]");
let chairAssignments = {};
let activePersonId = null;
const ORG_TEMPLATE = {
top: {
name: "총괄기획실",
count: 53,
members: [
{ name: "장종찬", dept: "총괄기획실", title: "기획실장" },
{ name: "김원식", dept: "총괄기획실", title: "전무이사" },
],
},
teams: [
{ name: "경영기획팀", count: 6, members: ["김우진", "임민정", "국혜린", "최선아", "김윤재", "이미영"] },
{ name: "인재성장팀", count: 5, members: ["조태희", "최근혜", "류원준", "주안기", "정성호"] },
{ name: "ERP 기획팀", count: 5, members: ["류호성", "문형식", "최요제", "황대일", "이채봉"] },
{ name: "디자인기획팀", count: 17, members: ["신혜영", "정은혜", "김태식", "최예은", "채선영", "최영환", "윤봄이", "이예진", "허유나", "마희연", "김수현", "박지영", "권순호", "정두휘", "김정석", "정지윤", "양숙영"] },
{ name: "기술기획팀", count: 11, members: ["김원기", "홍아름", "이경민", "김혜인", "황동환", "최찬호", "이태훈", "김신지", "조찬영", "김용연", "한치영"] },
{ name: "협업증진팀", count: 3, members: ["성형일", "박주한", "한승민"] },
{ name: "솔루션통합팀", count: 4, members: ["권혁진", "염승호", "윤준수", "김지영"] },
],
};
const chairGeometry = chairs.map((chair) => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const path = new Path2D();
const hitSegments = new Float32Array(chair.count * 4);
let segCursor = 0;
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
const offset = i * 4;
const x1 = chairSegValues[offset] / 10;
const y1 = chairSegValues[offset + 1] / 10;
const x2 = chairSegValues[offset + 2] / 10;
const y2 = chairSegValues[offset + 3] / 10;
path.moveTo(x1, y1);
path.lineTo(x2, y2);
hitSegments[segCursor] = x1;
hitSegments[segCursor + 1] = y1;
hitSegments[segCursor + 2] = x2;
hitSegments[segCursor + 3] = y2;
segCursor += 4;
minX = Math.min(minX, x1, x2);
minY = Math.min(minY, y1, y2);
maxX = Math.max(maxX, x1, x2);
maxY = Math.max(maxY, y1, y2);
}
return {
...chair,
minX,
minY,
maxX,
maxY,
area: Math.max(1, (maxX - minX) * (maxY - minY)),
path,
hitSegments,
};
});
function renumberChairKeys(chairItems) {
if (!chairItems.length) return;
const heights = chairItems
.map((chair) => Math.max(1, chair.maxY - chair.minY))
.sort((a, b) => a - b);
const medianHeight = heights[Math.floor(heights.length / 2)] || 1;
const rowTolerance = Math.max(40, medianHeight * 0.9);
const sorted = [...chairItems].sort((a, b) => {
const ay = (a.minY + a.maxY) * 0.5;
const by = (b.minY + b.maxY) * 0.5;
if (Math.abs(by - ay) > rowTolerance) return by - ay; // top -> bottom
const ax = (a.minX + a.maxX) * 0.5;
const bx = (b.minX + b.maxX) * 0.5;
return ax - bx; // left -> right
});
sorted.forEach((chair, index) => {
chair.key = String(index + 1);
chair.seatNo = index + 1;
});
}
renumberChairKeys(chairGeometry);
const PICK_GRID_SIZE = 1800;
const chairPickGrid = new Map();
function pickGridKey(gx, gy) {
return `${gx},${gy}`;
}
chairGeometry.forEach((chair, index) => {
const minGX = Math.floor(chair.minX / PICK_GRID_SIZE);
const maxGX = Math.floor(chair.maxX / PICK_GRID_SIZE);
const minGY = Math.floor(chair.minY / PICK_GRID_SIZE);
const maxGY = Math.floor(chair.maxY / PICK_GRID_SIZE);
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const key = pickGridKey(gx, gy);
if (!chairPickGrid.has(key)) chairPickGrid.set(key, []);
chairPickGrid.get(key).push(index);
}
}
});
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
let pixelRatio = window.devicePixelRatio || 1;
let pointer = { x: 0, y: 0 };
let dragging = false;
let dragStart = null;
let hovered = null;
let rafPending = false;
function normalizePeople(raw) {
return raw
.map((person, index) => {
if (!person || !person.name) return null;
return {
id: person.id || `person-${index + 1}`,
name: String(person.name).trim(),
dept: String(person.dept || "").trim(),
title: String(person.title || "").trim(),
};
})
.filter(Boolean);
}
function createTemplatePeople() {
const generated = [];
let seq = 1;
ORG_TEMPLATE.top.members.forEach((member) => {
generated.push({
id: `org-${seq++}`,
name: member.name,
dept: member.dept,
title: member.title,
});
});
ORG_TEMPLATE.teams.forEach((team) => {
team.members.forEach((name) => {
generated.push({
id: `org-${seq++}`,
name,
dept: team.name,
title: "선임",
});
});
});
return generated;
}
people = normalizePeople(people);
const templateReady = people.some((person) => person.name === "장종찬" && person.dept === "총괄기획실");
if (!templateReady) {
people = createTemplatePeople();
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
const chairKeySet = new Set(chairGeometry.map((chair) => chair.key));
chairAssignments = Object.fromEntries(
Object.entries(chairAssignments).filter(([chairKey, personId]) => (
chairKeySet.has(chairKey) && people.some((person) => person.id === personId)
))
);
if (activePersonId && !people.some((person) => person.id === activePersonId)) activePersonId = null;
function persistPeople() {
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
function persistAssignments() {
localStorage.setItem(ASSIGN_STORAGE_KEY, JSON.stringify(chairAssignments));
}
function persistActivePerson() {
if (!activePersonId) localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
else localStorage.setItem(ACTIVE_PERSON_STORAGE_KEY, activePersonId);
}
function assignmentCount() {
return Object.keys(chairAssignments).length;
}
function getPersonById(id) {
return people.find((person) => person.id === id) || null;
}
function getChairByPerson(personId) {
for (const [chairKey, assignedPersonId] of Object.entries(chairAssignments)) {
if (assignedPersonId === personId) return chairKey;
}
return null;
}
function renderPeopleList() {
const activePerson = getPersonById(activePersonId);
const countText = `${assignmentCount()} / ${people.length} 매칭`;
mapperStatus.innerHTML = `<strong>조직 현황</strong><span>${activePerson ? `${activePerson.name} 선택됨` : "선택 인원 없음"} · ${countText}</span>`;
const findPerson = (dept, name) => people.find((person) => person.dept === dept && person.name === name) || null;
const personCard = (person, roleText) => {
if (!person) return "";
const chairKey = getChairByPerson(person.id);
const assignedClass = chairKey ? " assigned" : "";
const activeClass = person.id === activePersonId ? " active" : "";
return `
<article class="org-person${assignedClass}${activeClass}" data-person-id="${person.id}">
<strong>${person.name}</strong>
<small>${person.title || roleText || "-"}</small>
<small>${chairKey ? `좌석 ${chairKey}` : "좌석 미지정"}</small>
</article>
`;
};
const topHtml = ORG_TEMPLATE.top.members
.map((member) => personCard(findPerson(member.dept, member.name), member.title))
.join("");
const teamsHtml = ORG_TEMPLATE.teams.map((team) => {
const membersHtml = team.members
.map((name) => personCard(findPerson(team.name, name), "선임"))
.join("");
return `
<section class="org-team">
<h4>${team.name} (${team.count})</h4>
<div class="org-members">${membersHtml}</div>
</section>
`;
}).join("");
orgChartEl.innerHTML = `
<section class="org-top">
<div class="org-top-title">${ORG_TEMPLATE.top.name} (${ORG_TEMPLATE.top.count})</div>
<div class="org-top-members">${topHtml}</div>
</section>
<section class="org-teams">${teamsHtml}</section>
`;
}
function worldToScreen(x, y) {
return {
x: x * camera.scale + camera.offsetX,
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
};
}
function screenToWorld(x, y) {
return {
x: (x - camera.offsetX) / camera.scale,
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
};
}
function resize() {
pixelRatio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * pixelRatio);
canvas.height = Math.round(rect.height * pixelRatio);
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
fit();
}
function fit() {
const rect = canvas.getBoundingClientRect();
const width = world.maxX - world.minX;
const height = world.maxY - world.minY;
const pad = 36;
const scaleX = (rect.width - pad * 2) / width;
const scaleY = (rect.height - pad * 2) / height;
camera.scale = Math.min(scaleX, scaleY);
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
requestDraw();
}
function drawGrid(width, height) {
ctx.save();
ctx.strokeStyle = "rgba(21,35,48,0.05)";
ctx.lineWidth = 1;
for (let x = 120; x < width; x += 120) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 120; y < height; y += 120) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
ctx.restore();
}
function pickChair(screenX, screenY) {
const threshold = 12;
const pointerWorld = screenToWorld(screenX, screenY);
const thresholdWorld = threshold / camera.scale;
const thresholdWorldSq = thresholdWorld * thresholdWorld;
const minGX = Math.floor((pointerWorld.x - thresholdWorld) / PICK_GRID_SIZE);
const maxGX = Math.floor((pointerWorld.x + thresholdWorld) / PICK_GRID_SIZE);
const minGY = Math.floor((pointerWorld.y - thresholdWorld) / PICK_GRID_SIZE);
const maxGY = Math.floor((pointerWorld.y + thresholdWorld) / PICK_GRID_SIZE);
const candidateIndexes = [];
const seen = new Set();
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const candidates = chairPickGrid.get(pickGridKey(gx, gy));
if (!candidates) continue;
for (const index of candidates) {
if (seen.has(index)) continue;
seen.add(index);
candidateIndexes.push(index);
}
}
}
let best = null;
for (const index of candidateIndexes) {
const chair = chairGeometry[index];
if (
pointerWorld.x < chair.minX - thresholdWorld ||
pointerWorld.x > chair.maxX + thresholdWorld ||
pointerWorld.y < chair.minY - thresholdWorld ||
pointerWorld.y > chair.maxY + thresholdWorld
) continue;
let distSq = Infinity;
for (let i = 0; i < chair.hitSegments.length; i += 4) {
const x1 = chair.hitSegments[i];
const y1 = chair.hitSegments[i + 1];
const x2 = chair.hitSegments[i + 2];
const y2 = chair.hitSegments[i + 3];
const dx = x2 - x1;
const dy = y2 - y1;
const len2 = dx * dx + dy * dy;
let segDistSq;
if (len2 === 0) {
const px = pointerWorld.x - x1;
const py = pointerWorld.y - y1;
segDistSq = px * px + py * py;
} else {
let t = ((pointerWorld.x - x1) * dx + (pointerWorld.y - y1) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const lx = x1 + t * dx;
const ly = y1 + t * dy;
const px = pointerWorld.x - lx;
const py = pointerWorld.y - ly;
segDistSq = px * px + py * py;
}
if (segDistSq < distSq) distSq = segDistSq;
if (distSq <= thresholdWorldSq * 0.3) break;
}
if (distSq > thresholdWorldSq) continue;
const dist = Math.sqrt(distSq) * camera.scale;
if (!best) {
best = { chair, dist };
continue;
}
const distGap = dist - best.dist;
if (distGap < -0.75) {
best = { chair, dist };
continue;
}
if (Math.abs(distGap) <= 2) {
const areaGap = chair.area - best.chair.area;
if (areaGap < -1) {
best = { chair, dist };
continue;
}
if (
Math.abs(areaGap) <= 1 &&
chair.kind === "block" &&
best.chair.kind !== "block"
) {
best = { chair, dist };
}
}
}
return best ? best.chair : null;
}
function renderTooltip() {
if (!hovered) {
tooltip.classList.remove("visible");
hoverChip.textContent = "chair hover: none";
return;
}
hoverChip.textContent = `chair hover: ${hovered.name}`;
tooltip.innerHTML = `
<strong>${hovered.name}</strong>
<div>chair key: ${hovered.key}</div>
<div>${placed.has(hovered.key) ? "선택됨" : "클릭하면 선택"}</div>
<div>${chairAssignments[hovered.key] ? `배치: ${(getPersonById(chairAssignments[hovered.key]) || { name: "알수없음" }).name}` : "배치 인원 없음"}</div>
`;
tooltip.style.left = `${pointer.x + 14}px`;
tooltip.style.top = `${pointer.y + 14}px`;
tooltip.classList.add("visible");
}
function requestDraw() {
if (rafPending) return;
rafPending = true;
window.requestAnimationFrame(() => {
rafPending = false;
draw();
});
}
function applyWorldTransform() {
ctx.setTransform(
pixelRatio * camera.scale,
0,
0,
-pixelRatio * camera.scale,
pixelRatio * camera.offsetX,
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
);
}
function draw() {
const rect = canvas.getBoundingClientRect();
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.clearRect(0, 0, rect.width, rect.height);
drawGrid(rect.width, rect.height);
const viewA = screenToWorld(0, rect.height);
const viewB = screenToWorld(rect.width, 0);
const viewMinX = Math.min(viewA.x, viewB.x);
const viewMaxX = Math.max(viewA.x, viewB.x);
const viewMinY = Math.min(viewA.y, viewB.y);
const viewMaxY = Math.max(viewA.y, viewB.y);
ctx.save();
applyWorldTransform();
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
ctx.lineWidth = 1 / camera.scale;
const tileSize = meta.backgroundTileSize;
const tileMinX = Math.floor(viewMinX / tileSize);
const tileMaxX = Math.floor(viewMaxX / tileSize);
const tileMinY = Math.floor(viewMinY / tileSize);
const tileMaxY = Math.floor(viewMaxY / tileSize);
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
const range = bgTileRanges[`${tx},${ty}`];
if (!range) continue;
const start = range[0];
const count = range[1];
for (let i = start; i < start + count; i += 1) {
const offset = i * 4;
const x1 = bgSegValues[offset] / 10;
const y1 = bgSegValues[offset + 1] / 10;
const x2 = bgSegValues[offset + 2] / 10;
const y2 = bgSegValues[offset + 3] / 10;
if (
Math.max(x1, x2) < viewMinX ||
Math.min(x1, x2) > viewMaxX ||
Math.max(y1, y2) < viewMinY ||
Math.min(y1, y2) > viewMaxY
) continue;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
}
}
ctx.restore();
hovered = dragging ? null : pickChair(pointer.x, pointer.y);
ctx.save();
applyWorldTransform();
ctx.lineWidth = 1.45 / camera.scale;
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (const chair of chairGeometry) {
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
const active = hovered && hovered.key === chair.key;
const selected = placed.has(chair.key);
const assignedPersonId = chairAssignments[chair.key];
const activePersonChair = activePersonId && assignedPersonId === activePersonId;
const assigned = Boolean(assignedPersonId);
const baseWidth = chair.kind === "block" ? 1.45 : 1.35;
ctx.strokeStyle = activePersonChair
? "rgba(234, 179, 8, 1)"
: assigned
? "rgba(37, 99, 235, 0.98)"
: selected
? "rgba(220, 38, 38, 0.98)"
: active
? "rgba(15, 118, 110, 0.98)"
: chair.kind === "group"
? "rgba(16, 134, 149, 0.74)"
: "rgba(21, 149, 142, 0.8)";
ctx.lineWidth = (activePersonChair ? 2.8 : assigned ? 2.4 : selected ? 2.6 : active ? 2.1 : baseWidth) / camera.scale;
ctx.stroke(chair.path);
}
ctx.restore();
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
renderTooltip();
}
function persistPlaced() {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));
}
canvas.addEventListener("pointerdown", (event) => {
dragging = true;
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
canvas.classList.add("dragging");
});
window.addEventListener("pointerup", (event) => {
if (dragging && dragStart) {
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
if (move < 4) {
const rect = canvas.getBoundingClientRect();
const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top);
if (picked) {
if (placed.has(picked.key)) placed.delete(picked.key);
else placed.add(picked.key);
persistPlaced();
if (activePersonId) {
const currentChair = getChairByPerson(activePersonId);
if (chairAssignments[picked.key] === activePersonId) {
delete chairAssignments[picked.key];
} else {
if (currentChair && currentChair !== picked.key) delete chairAssignments[currentChair];
chairAssignments[picked.key] = activePersonId;
}
persistAssignments();
renderPeopleList();
}
}
}
}
dragging = false;
dragStart = null;
canvas.classList.remove("dragging");
requestDraw();
});
window.addEventListener("pointermove", (event) => {
const rect = canvas.getBoundingClientRect();
pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
if (dragging && dragStart) {
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
}
requestDraw();
});
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = event.clientX - rect.left;
const my = event.clientY - rect.top;
const before = screenToWorld(mx, my);
const factor = event.deltaY < 0 ? 1.08 : 0.92;
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
const after = worldToScreen(before.x, before.y);
camera.offsetX += mx - after.x;
camera.offsetY += my - after.y;
requestDraw();
}, { passive: false });
document.getElementById("fit-btn").addEventListener("click", fit);
document.getElementById("clear-btn").addEventListener("click", () => {
placed.clear();
persistPlaced();
requestDraw();
});
clearAssignBtn.addEventListener("click", () => {
chairAssignments = {};
persistAssignments();
renderPeopleList();
requestDraw();
});
orgChartEl.addEventListener("click", (event) => {
const item = event.target.closest(".org-person[data-person-id]");
if (!item) return;
const personId = item.getAttribute("data-person-id");
activePersonId = personId === activePersonId ? null : personId;
persistActivePerson();
renderPeopleList();
requestDraw();
});
window.addEventListener("resize", resize);
renderPeopleList();
resize();
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,14 @@
# Served Assets
이 디렉터리는 `8081`에서 실제 URL 응답으로 직접 서빙되는 integration HTML 파일만 둔다.
현재 사용 중:
- `payment.html`
- `mh.html`
규칙:
- `/integrations/payment` 는 이 디렉터리의 `payment.html`을 읽는다.
- `/integrations/mh` 는 이 디렉터리의 `mh.html`을 읽는다.
- 원본 참고 파일이나 비교용 파일은 이 디렉터리에 두지 않는다.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,328 @@
html,
body {
margin: 0;
padding: 0;
}
body.mh-business-theme {
overflow-x: hidden;
background:
radial-gradient(circle at top left, rgba(214, 138, 58, 0.16), transparent 24%),
radial-gradient(circle at top right, rgba(47, 153, 115, 0.10), transparent 20%),
linear-gradient(180deg, #f6efe6 0%, #f1eadf 100%);
}
body.mh-business-theme .wrap {
width: min(100%, 2000px);
max-width: 2000px;
margin: 0 auto;
padding: 18px 18px 26px;
box-sizing: border-box;
}
body.mh-business-theme .top,
body.mh-business-theme .status {
display: none !important;
}
body.mh-business-theme .cards {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 14px;
margin: 0 0 16px;
}
body.mh-business-theme .business-shell {
width: 100%;
box-sizing: border-box;
margin-top: 2px;
padding: 18px;
border-radius: 32px;
background:
radial-gradient(circle at 16% 14%, rgba(255,255,255,0.05), transparent 18%),
radial-gradient(circle at 88% 8%, rgba(255,255,255,0.04), transparent 16%),
linear-gradient(145deg, #0b352b 0%, #174e41 52%, #245f50 100%);
box-shadow: 0 26px 54px rgba(15, 58, 47, 0.16);
border: 1px solid rgba(255,255,255,0.08);
}
body.mh-business-theme .cards-toolbar {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
gap: 14px;
padding: 10px 0 2px;
}
body.mh-business-theme .cards-toolbar-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
body.mh-business-theme .cards-toolbar-search {
margin-left: auto;
display: flex;
align-items: center;
min-width: min(360px, 100%);
flex: 1 1 320px;
max-width: 520px;
}
body.mh-business-theme .cards-toolbar-search .search {
width: 100%;
min-width: 0;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(255,255,255,0.10);
color: #f4efe6;
padding: 14px 18px;
font-size: 14px;
font-weight: 800;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04);
}
body.mh-business-theme .cards-toolbar-search .search::placeholder {
color: rgba(244, 239, 230, 0.74);
}
body.mh-business-theme #btnUpload {
display: none !important;
}
body.mh-business-theme .cards-toolbar-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
body.mh-business-theme .summary-year-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 60px;
padding: 10px 16px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.08);
color: #f4efe6;
font-size: 12px;
font-weight: 900;
cursor: pointer;
}
body.mh-business-theme .summary-year-chip.active {
background: linear-gradient(180deg, #fff8ee 0%, #f2dec0 100%);
color: #0a2a22;
border-color: rgba(242, 196, 132, 0.58);
box-shadow: 0 12px 28px rgba(10, 42, 34, 0.18);
}
body.mh-business-theme .summary-filter-chip {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
min-height: 98px;
padding: 18px 22px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.14);
background: linear-gradient(180deg, rgba(255,255,255,0.10) 0%, rgba(255,255,255,0.07) 100%);
color: #f4efe6;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), 0 16px 30px rgba(7, 28, 22, 0.14);
cursor: pointer;
text-align: center;
}
body.mh-business-theme .summary-filter-chip.active {
background: linear-gradient(180deg, #fff8ee 0%, #f2dec0 100%);
color: #0a2a22;
border-color: rgba(242, 196, 132, 0.58);
}
body.mh-business-theme .summary-filter-chip .label {
color: rgba(244, 239, 230, 0.78);
font-size: 13px;
font-weight: 900;
}
body.mh-business-theme .summary-filter-chip.active .label {
color: rgba(10, 42, 34, 0.78);
}
body.mh-business-theme .summary-filter-chip .count {
color: #fff7e6;
font-size: 32px;
line-height: 1;
font-weight: 900;
}
body.mh-business-theme .summary-filter-chip.active .count {
color: #b86b1f;
}
body.mh-business-theme .summary-filter-chip .meta {
color: #f2c484;
font-size: 11px;
font-weight: 800;
text-align: center;
}
body.mh-business-theme .summary-filter-chip.active .meta {
color: #7c5a20;
}
body.mh-business-theme .card {
grid-column: span 2;
min-height: 110px;
border-radius: 24px;
border: 1px solid rgba(217, 197, 168, 0.55);
background: linear-gradient(180deg, rgba(255,250,243,0.96) 0%, rgba(248,242,232,0.96) 100%);
padding: 18px 20px;
box-shadow: 0 18px 32px rgba(15, 58, 47, 0.08);
}
body.mh-business-theme .card.management {
grid-column: span 2;
}
body.mh-business-theme .card .k {
color: #5b6d63;
font-size: 12px;
font-weight: 900;
}
body.mh-business-theme .card .v {
margin-top: 8px;
color: #17392f;
font-size: 30px;
font-weight: 900;
}
body.mh-business-theme .card .n {
margin-top: 8px;
color: #7b6953;
font-size: 11px;
font-weight: 700;
}
body.mh-business-theme .panel {
border-radius: 28px;
border: 1px solid rgba(217, 197, 168, 0.55);
box-shadow: 0 18px 32px rgba(15, 58, 47, 0.08);
}
body.mh-business-theme .table-wrap {
width: 100%;
max-width: 100%;
border-radius: 28px;
overflow-x: hidden !important;
}
body.mh-business-theme .table-vat-note {
display: none !important;
}
body.mh-business-theme table {
width: 100% !important;
min-width: 0 !important;
table-layout: fixed;
background: rgba(255, 250, 243, 0.96);
}
body.mh-business-theme thead th {
background: #0f352b;
color: #fff5e6;
border-right: 1px solid rgba(242, 196, 132, 0.2);
}
body.mh-business-theme tbody td {
background: rgba(255, 250, 243, 0.96);
}
body.mh-business-theme .group-row td {
padding: 12px 14px 10px;
background: linear-gradient(180deg, rgba(255, 248, 238, 0.98) 0%, rgba(242, 222, 192, 0.78) 100%);
border-top: 1px solid rgba(214, 138, 58, 0.26);
border-bottom: 1px solid rgba(217, 197, 168, 0.54);
}
body.mh-business-theme .group-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(255, 250, 243, 0.98);
border: 1px solid rgba(214, 138, 58, 0.3);
color: #17392f;
font-size: 12px;
font-weight: 900;
box-shadow: 0 8px 18px rgba(15, 58, 47, 0.08);
cursor: pointer;
}
body.mh-business-theme .group-chip .group-toggle {
margin-left: 4px;
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: rgba(242, 196, 132, 0.18);
color: #b66e22;
font-size: 14px;
line-height: 1;
}
body.mh-business-theme .project-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0;
border: 0;
background: none;
color: #17392f;
font: inherit;
font-weight: 900;
text-align: left;
cursor: pointer;
}
body.mh-business-theme .project-link:hover {
color: #0f6a55;
}
@media (max-width: 1280px) {
body.mh-business-theme .cards-toolbar-metrics {
grid-template-columns: 1fr;
}
body.mh-business-theme .card {
grid-column: span 4;
}
}
@media (max-width: 880px) {
body.mh-business-theme .wrap {
padding: 12px 12px 20px;
}
body.mh-business-theme .cards {
grid-template-columns: 1fr;
}
body.mh-business-theme .card {
grid-column: auto;
}
body.mh-business-theme .cards-toolbar-search {
margin-left: 0;
max-width: none;
flex-basis: 100%;
}
}

View File

@@ -0,0 +1,498 @@
(function () {
window.__mhLedgerEnhancementLoaded = false;
if (typeof S === "undefined" || typeof E === "undefined" || typeof render !== "function") return;
window.__mhLedgerEnhancementLoaded = true;
if (!S.dashboard) S.dashboard = { year: "", section: "active" };
if (!S.collapsedGroups) S.collapsedGroups = {};
function bgToday() {
var now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
}
function bgParseDate(value) {
var text = String(value || "").trim();
if (!text) return null;
var match = text.match(/(20\d{2})\D?(\d{1,2})\D?(\d{1,2})/);
if (match) {
var parsed = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
return isNaN(parsed.getTime()) ? null : parsed;
}
var fallback = new Date(text);
if (isNaN(fallback.getTime())) return null;
return new Date(fallback.getFullYear(), fallback.getMonth(), fallback.getDate());
}
function bgYearFromText(value) {
var match = String(value || "").trim().match(/(20\d{2})/);
return match ? match[1] : "";
}
function bgStartYear(row) {
return bgYearFromText(row && row.sDate);
}
function bgEndYear(row) {
return bgYearFromText(row && row.eDate);
}
function bgDisplayYear(row) {
var start = bgStartYear(row);
if (start) return start;
var contractMatch = String((row && row.cDate) || "").trim().match(/(20\d{2})/);
if (contractMatch) return contractMatch[1];
var nameMatch = String((row && row.name) || "").trim().match(/^(20\d{2})/);
if (nameMatch) return nameMatch[1];
return bgEndYear(row) || "미지정";
}
function bgCompletionYear(row) {
return bgEndYear(row) || bgDisplayYear(row);
}
function bgDateOrYearStart(row) {
var yearText = bgDisplayYear(row);
return bgParseDate(row && row.sDate) || bgParseDate(row && row.cDate) || (/^20\d{2}$/.test(yearText) ? new Date(Number(yearText), 0, 1) : null);
}
function bgDateOrYearEnd(row) {
var completionYear = bgCompletionYear(row);
return bgParseDate(row && row.eDate) || (/^20\d{2}$/.test(completionYear) ? new Date(Number(completionYear), 11, 31) : null);
}
function bgYearCutoff(year) {
var targetYear = Number(year || 0);
if (!targetYear) return null;
var today = bgToday();
if (targetYear < today.getFullYear()) return new Date(targetYear, 11, 31);
if (targetYear === today.getFullYear()) return today;
return null;
}
function bgYearStartDate(year) {
var targetYear = Number(year || 0);
return targetYear ? new Date(targetYear, 0, 1) : null;
}
function bgActiveInYear(row, year) {
var cutoff = bgYearCutoff(year);
var yearStart = bgYearStartDate(year);
var startDate = bgDateOrYearStart(row);
var endDate = bgDateOrYearEnd(row);
if (!(cutoff && yearStart && startDate)) return false;
if (startDate > cutoff) return false;
if (endDate && endDate < yearStart) return false;
return !(endDate && endDate <= cutoff);
}
function bgStartedInYear(row, year) {
var cutoff = bgYearCutoff(year);
var startDate = bgDateOrYearStart(row);
if (!(cutoff && startDate)) return false;
return startDate.getFullYear() === Number(year || 0) && startDate <= cutoff;
}
function bgCompletedInYear(row, year) {
var cutoff = bgYearCutoff(year);
var endDate = bgDateOrYearEnd(row);
if (!(cutoff && endDate)) return false;
return endDate.getFullYear() === Number(year || 0) && endDate <= cutoff;
}
function bgYearRange(row) {
var years = [];
var startYear = Number(bgDisplayYear(row) || 0);
var endYear = Number(bgCompletionYear(row) || 0);
if (startYear && endYear && endYear >= startYear) {
for (var year = startYear; year <= endYear; year += 1) years.push(String(year));
} else if (startYear) {
years.push(String(startYear));
}
return years;
}
function bgYears(rows) {
var currentYear = new Date().getFullYear();
var years = Array.from(new Set((Array.isArray(rows) ? rows : []).flatMap(bgYearRange).filter(function (year) {
return /^20\d{2}$/.test(year);
}))).sort(function (a, b) {
return Number(b) - Number(a);
});
years = years.filter(function (year) {
var numericYear = Number(year);
return numericYear >= 2018 && numericYear <= currentYear;
});
return years.length ? years : [String(currentYear)];
}
function bgEnsureYear(rows) {
var years = bgYears(rows);
if (!years.includes(S.dashboard.year)) S.dashboard.year = years[0];
return years;
}
function bgTotals(targetRows) {
return (Array.isArray(targetRows) ? targetRows : []).reduce(function (acc, row) {
acc.c += Number((row && row.cSup) || 0);
acc.col += Number((row && row.col) || 0);
acc.recv += Number((row && row.recv) || 0);
return acc;
}, { c: 0, col: 0, recv: 0 });
}
function isSupportServiceRow(row) {
var category = String((row && row.cat) || "").trim();
return category.indexOf("경영지원") >= 0 || category.indexOf("서비스") >= 0;
}
function isBaronProjectRow(row) {
var category = String((row && row.cat) || "").trim();
if (category.indexOf("바론") < 0) return false;
if (isSupportServiceRow(row)) return false;
return true;
}
function bgSummarize(rows, selectedYear) {
var items = Array.isArray(rows) ? rows : [];
var targetYear = selectedYear || bgEnsureYear(items)[0];
var activeRows = items.filter(function (row) { return bgActiveInYear(row, targetYear); });
var newProjectRows = items.filter(function (row) { return bgStartedInYear(row, targetYear); });
var completedRows = items.filter(function (row) { return bgCompletedInYear(row, targetYear); });
var managementRows = newProjectRows.filter(isSupportServiceRow);
return {
targetYear: targetYear,
activeRows: activeRows,
newProjectRows: newProjectRows,
completedRows: completedRows,
managementRows: managementRows,
managementTotals: bgTotals(managementRows)
};
}
function bgMatches(row) {
var section = S.dashboard.section || "active";
var selectedYear = S.dashboard.year || bgEnsureYear(S.all)[0];
if (section === "new") return bgStartedInYear(row, selectedYear);
if (section === "completed") return bgCompletedInYear(row, selectedYear);
return bgActiveInYear(row, selectedYear);
}
function normalizeStatusLabel(status) {
var value = String(status || "").trim();
if (!value) return "-";
if (value.indexOf("진행") >= 0) return "과업 진행중";
return value;
}
function formatSplitPercent(split) {
var numeric = parseFloat(String(split || "").replace(/[^0-9.\-]/g, ""));
if (!Number.isFinite(numeric) || numeric === 0) return "분담율 -%";
return "분담율 " + numeric.toFixed(2) + "%";
}
function projectYear(row) {
var start = String((row && row.sDate) || "").trim();
var startMatch = start.match(/(20\d{2})/);
if (startMatch) return startMatch[1];
var name = String((row && row.name) || "").trim();
var nameMatch = name.match(/^(20\d{2})/);
if (nameMatch) return nameMatch[1];
var end = String((row && row.eDate) || "").trim();
var endMatch = end.match(/(20\d{2})/);
if (endMatch) return endMatch[1];
return "미지정";
}
function groupSortRank(row) {
var selectedYear = Number((S.dashboard && S.dashboard.year) || projectYear(row) || 0);
var startYear = Number(projectYear(row) || 0);
if (typeof bgCompletedInYear === "function" && bgCompletedInYear(row, String(selectedYear))) return 9999;
if (!startYear) return 9998;
return startYear;
}
function tableGroupLabel(row) {
var startYear = projectYear(row);
if (/^20\d{2}$/.test(startYear)) return startYear + "년";
return "미지정";
}
function renderLedgerTable() {
var table = document.querySelector(".panel table");
if (!table || !E.tbody) return;
var thead = table.querySelector("thead");
if (thead) {
thead.innerHTML = '<tr>'
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="cat" data-label="구분"><span class="th-title">구분</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterCatMenu" class="th-menu" data-filter="cat"></div></div></th>'
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="code" data-label="사업코드"><span class="th-title">사업코드</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterCodeMenu" class="th-menu" data-filter="code"></div></div></th>'
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="name" data-label="사업명(계약명)"><span class="th-title">사업명(계약명)</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterNameMenu" class="th-menu" data-filter="name"></div></div></th>'
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="client" data-label="발주처(계약처)"><span class="th-title">발주처(계약처)</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterClientMenu" class="th-menu" data-filter="client"></div></div></th>'
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="order" data-label="발주방법"><span class="th-title">발주방법</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterOrderMenu" class="th-menu" data-filter="order"></div></div></th>'
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="status" data-label="진행상태"><span class="th-title">진행상태</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterStatusMenu" class="th-menu" data-filter="status"></div></div></th>'
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="amount" data-label="계약금"><span class="th-title">계약금</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterAmountMenu" class="th-menu" data-filter="amount"></div></div></th>'
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="outsource" data-label="외주비"><span class="th-title">외주비</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterOutsourceMenu" class="th-menu" data-filter="outsource"></div></div></th>'
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="receivable" data-label="미수금"><span class="th-title">미수금</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterReceivableMenu" class="th-menu" data-filter="receivable"></div></div></th>'
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="collected" data-label="수금액"><span class="th-title">수금액</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterCollectedMenu" class="th-menu" data-filter="collected"></div></div></th>'
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="rate" data-label="수금률"><span class="th-title">수금률</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterRateMenu" class="th-menu" data-filter="rate"></div></div></th>'
+ "</tr>";
}
var rows = (Array.isArray(S.viewRows) ? S.viewRows : []).slice().sort(function (a, b) {
var ar = groupSortRank(a);
var br = groupSortRank(b);
if (ar !== br) return ar - br;
return Number(b.recv || 0) - Number(a.recv || 0);
});
S.viewRows = rows;
var lastGroupLabel = "";
E.tbody.innerHTML = rows.map(function (r) {
var groupLabel = tableGroupLabel(r);
var isCollapsed = !!S.collapsedGroups[groupLabel];
var groupRow = "";
if (groupLabel !== lastGroupLabel) {
groupRow = '<tr class="group-row"><td colspan="11"><button type="button" class="group-chip" data-group-label="' + escAttr(groupLabel) + '"><span>' + esc(groupLabel) + '</span><span class="group-toggle" aria-hidden="true">' + (isCollapsed ? "" : "") + "</span></button></td></tr>";
lastGroupLabel = groupLabel;
}
if (isCollapsed) return groupRow;
return groupRow + '<tr class="' + (isSettledRow(r) ? 'settled' : '') + '">'
+ '<td><div class="badge ' + esc(String(r.cat || "").indexOf("바론") >= 0 ? 'badge-baron' : 'badge-family') + '">' + esc(r.cat || "-") + '</div></td>'
+ '<td><div class="subline" style="margin-top:0;font-size:12px;color:#66756d">' + esc(r.code || "-") + '</div></td>'
+ '<td><button type="button" class="project-link" data-project-key="' + escAttr(String(r.code || "") + "|" + String(r.name || "")) + '">' + esc(r.name || "-") + '</button><div class="subline">' + esc(r.periodText || "-") + '</div></td>'
+ '<td><div class="client-main">' + esc((r.client || "").trim() || "-") + '</div><div class="subline">' + esc(formatSplitPercent(r.split)) + '</div></td>'
+ '<td><div>' + esc(r.order || "-") + '</div></td>'
+ '<td><div class="badge ' + (String(r.status || "").indexOf("완료") >= 0 ? 'ok' : '') + '">' + esc(normalizeStatusLabel(r.status)) + '</div></td>'
+ '<td class="num"><strong>' + esc(won(r.cSup || 0)) + '</strong></td>'
+ '<td class="num"><strong>' + esc(r.outsourceCost ? won(r.outsourceCost) : "-") + '</strong></td>'
+ '<td class="num"><strong>' + esc(won(r.recv || 0)) + '</strong></td>'
+ '<td class="num"><strong>' + esc(won(r.col || 0)) + '</strong></td>'
+ '<td class="num"><strong style="color:' + (isSettledRow(r) ? '#b7aa93' : '#1a5645') + '">' + esc((Number(r.rate || 0)).toFixed(2) + "%") + '</strong></td>'
+ '</tr>';
}).join("");
}
function renderCollectionBoard(r) {
var payments = Array.isArray(r.payments) && r.payments.length ? r.payments : [{
pay: r.pay || "-",
issueDate: r.issueDate || "",
collectDate: r.collectDateSummary || r.colDate || "",
collected: r.col || 0,
receivable: r.recv || Math.max(0, Number(r.sTot || 0) - Number(r.col || 0)),
note: r.note || "",
status: r.status || ""
}];
return '<div class="ledger-block collect"><div class="ledger-head"><div class="ledger-head-left"><div class="ledger-icon">C</div><div><div class="ledger-name">수금 및 기성 현황</div><div class="ledger-sub">기성 차수별 세금계산서 발행 및 수금 내역</div></div></div><div class="ledger-pill">총 수금 ' + esc(won(r.col || 0)) + '</div></div><div class="ledger-table-wrap"><table class="ledger-table"><thead><tr><th>기성 차수</th><th>세금계산서 발행일</th><th>수금일</th><th style="text-align:right">수금금액</th><th style="text-align:right">미수금액</th><th>비고</th></tr></thead><tbody>'
+ payments.map(function (payment, index) {
var noteParts = [];
if (payment.status) noteParts.push(payment.status);
if (payment.note) noteParts.push(payment.note);
return '<tr><td><span class="ledger-main">' + esc((index + 1) + "차") + '</span><span class="ledger-muted">' + esc(payment.pay || "-") + '</span></td><td><span class="ledger-main">' + esc(payment.issueDate ? d(payment.issueDate) : "-") + '</span></td><td><span class="ledger-main">' + esc(payment.collectDate ? d(payment.collectDate) : "-") + '</span></td><td class="ledger-amount">' + esc(won(payment.collected || 0)) + '</td><td class="ledger-amount" style="color:#a94832">' + esc(won(payment.receivable || 0)) + '</td><td><span class="ledger-note">' + esc(noteParts.join(" / ") || "-") + '</span></td></tr>';
}).join("")
+ "</tbody></table></div></div>";
}
function renderContactCard(label, name, company, department, phone, email) {
var hasValue = [name, company, department, phone, email].some(function (value) {
return String(value || "").trim() !== "";
});
if (!hasValue) {
return '<div class="inline-card"><div class="kvk">' + esc(label) + '</div><div class="summary-note">등록된 담당자 정보가 없습니다.</div></div>';
}
return '<div class="inline-card"><div class="kvk">' + esc(label) + '</div><div class="project-meta-grid">'
+ '<div class="kv"><div class="kvk">이름</div><div class="kvv">' + esc(name || "-") + '</div></div>'
+ '<div class="kv"><div class="kvk">소속</div><div class="kvv">' + esc(company || "-") + '</div><div class="summary-note">' + esc(department || "-") + '</div></div>'
+ '<div class="kv"><div class="kvk">연락처</div><div class="kvv">' + esc(phone || "-") + '</div></div>'
+ '<div class="kv"><div class="kvk">이메일</div><div class="kvv">' + esc(email || "-") + '</div></div>'
+ "</div></div>";
}
function renderProjectInline(r) {
var payments = Array.isArray(r.payments) ? r.payments : [];
var latestCollect = d(r.collectDateSummary || r.colDate);
var hasOutsource = (Array.isArray(r.outsourceItems) && r.outsourceItems.length > 0) || Number(r.outsourceCost || 0) > 0 || Number(r.outsourcePaid || 0) > 0 || Number(r.outsourceRemaining || 0) > 0;
var clientDisplay = typeof normalizeClientDisplay === "function" ? normalizeClientDisplay(r.client) : (String(r.client || "").trim() || "-");
var splitDisplay = typeof formatSplitDisplay === "function" ? formatSplitDisplay(r.split) : formatSplitPercent(r.split).replace("분담율 ", "");
var summaryCards = [
'<div class="summary-card"><div class="summary-label">계약금</div><div class="summary-value">' + esc(won(r.cSup || 0)) + '</div><div class="summary-note"></div></div>',
'<div class="summary-card"><div class="summary-label">수금액</div><div class="summary-value">' + esc(won(r.col || 0)) + '</div><div class="summary-note">' + esc(latestCollect === "-" ? "수금일 없음" : "최종 수금일 " + latestCollect) + '</div></div>',
'<div class="summary-card"><div class="summary-label">수금률</div><div class="summary-value">' + esc((Number(r.rate || 0)).toFixed(2) + "%") + '</div><div class="summary-note">' + esc(payments.length ? "기성 " + payments.length + "차까지 반영" : "차수 정보 없음") + '</div></div>',
'<div class="summary-card receivable"><div class="summary-label">미수금액</div><div class="summary-value">' + esc(won(r.recv || 0)) + '</div><div class="summary-note">잔여 수금 필요 금액</div></div>'
].join("");
var boards = [
hasOutsource && typeof renderOutsourceBoard === "function" ? renderOutsourceBoard(r) : "",
renderCollectionBoard(r)
].filter(Boolean).join("");
return '<div class="inline-panel"><div class="project-head project-head-grid"><div class="project-head-main"><div class="inline-card"><div class="project-meta-grid"><div class="kv"><div class="kvk">계약법인</div><div class="kvv">' + esc(r.corp || "-") + '</div></div><div class="kv"><div class="kvk">발주처</div><div class="kvv">' + esc(clientDisplay) + '</div><div class="summary-note">' + esc(splitDisplay ? "분담율 " + splitDisplay : "분담율 -") + '</div></div><div class="kv"><div class="kvk">발주방법</div><div class="kvv">' + esc(r.order || "-") + '</div></div><div class="kv"><div class="kvk">PM</div><div class="kvv">' + esc(r.pm || "-") + '</div></div></div></div><div class="inline-card"><div class="summary-grid">' + summaryCards + '</div><div class="project-progress progress"><div class="bar" style="width:' + esc(String(Math.max(0, Math.min(100, Number(r.rate || 0))))) + '%"></div></div></div></div><div class="project-contact-stack">' + renderContactCard("계약 / 청구 담당자", r.cmNm, r.cmCo, r.cmDp, r.cmPh, r.cmEm) + renderContactCard("부서 담당자", r.dmNm, r.dmCo, r.dmDp, r.dmPh, r.dmEm) + '</div></div><div class="ledger-stack">' + boards + '</div></div>';
}
function openProjectWindow(r) {
var popupKey = typeof rowKey === "function"
? rowKey(r).replace(/[^0-9a-zA-Z]/g, "_")
: String((r.code || "project") + "_" + (r.name || "")).replace(/[^0-9a-zA-Z_]/g, "_");
var popup = window.open("", "business_project_" + popupKey, "width=1600,height=980,resizable=yes,scrollbars=yes");
if (!popup) return;
var styleText = Array.from(document.querySelectorAll("style")).map(function (el) {
return el.textContent || "";
}).join("\n");
var detailHtml = renderProjectInline(r);
var pageHtml = '<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>'
+ esc(r.name || "사업 상세")
+ '</title><link rel="stylesheet" href="/design-tokens.css?v=20260401-01"><link rel="stylesheet" href="/design-patterns.css?v=20260401-01"><style>' + styleText
+ 'body{margin:0;background:#f1eadf;color:#10251d;font-family:"Pretendard","Noto Sans KR","Malgun Gothic",sans-serif;}'
+ '.popup-wrap{max-width:1680px;margin:0 auto;padding:20px;}'
+ '@media (max-width: 1180px){.project-head-grid{grid-template-columns:1fr;}.summary-grid{grid-template-columns:repeat(2,minmax(0,1fr));}.project-meta-grid{grid-template-columns:1fr;}}'
+ '@media (max-width: 760px){.popup-wrap{padding:14px;}.summary-grid{grid-template-columns:1fr;}.ledger-head{flex-direction:column;align-items:flex-start;}.ledger-pill{white-space:normal;}.ledger-table-wrap{padding:0 10px 12px;overflow-x:auto;}}'
+ '</style></head><body><div class="popup-wrap"><div class="popup-head"><div class="popup-title">' + esc(r.name || "-") + '</div><div class="popup-sub">사업코드 ' + esc(r.code || "-") + ' · 계약법인 ' + esc(r.corp || "-") + '</div></div>' + detailHtml + "</div></body></html>";
popup.document.open();
popup.document.write(pageHtml);
popup.document.close();
popup.focus();
}
async function tryLoadDbDefaultBusinessLedger() {
if (window.__mhBusinessDefaultLoaded) return;
window.__mhBusinessDefaultLoaded = true;
try {
var response = await fetch("/api/integration/business-ledger-default");
if (!response.ok) throw new Error("기본 사업관리대장 원본을 불러오지 못했습니다.");
var fileName = response.headers.get("x-source-filename") || "사업관리대장-1.xlsx";
var buffer = await response.arrayBuffer();
if (!buffer || !buffer.byteLength) throw new Error("기본 사업관리대장 원본 데이터가 비어 있습니다.");
await loadLedgerFile(buffer, fileName);
} catch (error) {
console.error(error);
}
}
function applyDashboardChrome() {
if (!E.cards) return;
document.body.setAttribute("data-mh-ledger-enhanced", "true");
var wrap = document.querySelector(".wrap");
var panel = document.querySelector(".panel");
if (wrap && panel) {
var shell = wrap.querySelector(".business-shell");
if (!shell) {
shell = document.createElement("div");
shell.className = "business-shell";
wrap.insertBefore(shell, E.cards);
}
if (E.cards.parentNode !== shell) shell.appendChild(E.cards);
if (panel.parentNode !== shell) shell.appendChild(panel);
}
var years = bgEnsureYear(S.all);
var summary = bgSummarize(S.all, S.dashboard.year);
var rows = Array.isArray(S.rows) ? S.rows : [];
var visibleBaronProjectRows = rows.filter(isBaronProjectRow);
var totals = bgTotals(visibleBaronProjectRows);
var totalRate = typeof rate === "function" ? rate("", totals.col, totals.col + totals.recv) : 0;
var toolbarHtml = '<div class="cards-toolbar">'
+ '<div class="cards-toolbar-row">'
+ years.map(function (year) {
return '<button type="button" class="summary-year-chip ' + (S.dashboard.year === year ? "active" : "") + '" data-dashboard-year="' + escAttr(year) + '">' + esc(year) + "</button>";
}).join("")
+ '<div class="cards-toolbar-search"></div>'
+ "</div>"
+ '<div class="cards-toolbar-metrics">'
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "active" ? "active" : "") + '" data-dashboard-section="active"><span class="label">' + esc(summary.targetYear) + '년 진행과업</span><span class="count">' + summary.activeRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">전년도 이월 사업 포함</span></button>'
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "new" ? "active" : "") + '" data-dashboard-section="new"><span class="label">' + esc(summary.targetYear) + '년 신규프로젝트</span><span class="count">' + summary.newProjectRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">계약기간 시작년도 기준</span></button>'
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "completed" ? "active" : "") + '" data-dashboard-section="completed"><span class="label">' + esc(summary.targetYear) + '년 완료과업</span><span class="count">' + summary.completedRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">해당년도 종료 사업 기준</span></button>'
+ "</div></div>";
var cards = [
{ label: summary.targetYear + "년 프로젝트", value: visibleBaronProjectRows.length.toLocaleString("ko-KR") + " 건", note: "" },
{ label: "계약금", value: won(totals.c), note: "" },
{ label: "수금액", value: won(totals.col), note: "" },
{ label: "미수금", value: won(totals.recv), note: "" },
{ label: "수금률(%)", value: totalRate.toFixed(2) + "%", note: "" },
{ label: "경영지원서비스 금액", value: won(summary.managementTotals.c), note: "", className: "management" }
];
E.cards.innerHTML = toolbarHtml + cards.map(function (card) {
return '<div class="card ' + esc(card.className || "") + '"><div class="k">' + esc(card.label) + '</div><div class="v">' + esc(card.value) + '</div><div class="n">' + esc(card.note || "") + "</div></div>";
}).join("");
var searchWrap = E.cards.querySelector(".cards-toolbar-search");
if (searchWrap && E.search) {
searchWrap.appendChild(E.search);
E.search.placeholder = "전체 검색";
}
}
var originalRender = render;
render = function () {
originalRender();
applyDashboardChrome();
renderLedgerTable();
};
filter = function () {
bgEnsureYear(S.all);
var q = String(E.search.value || "").trim().toLowerCase();
var searched = !q ? S.all.slice() : S.all.filter(function (r) {
return [r.code, r.name, r.client, r.pm, r.status, r.cat, r.corp, r.pay, (r.payments || []).map(function (p) { return p.pay; }).join(" "), r.periodText].join(" ").toLowerCase().includes(q);
});
S.rows = searched.filter(function (r) {
return bgMatches(r) && matchesColumnFilters(r);
});
render();
};
if (E.cards && !E.cards.dataset.dashboardBound) {
E.cards.dataset.dashboardBound = "true";
E.cards.addEventListener("click", function (event) {
var yearButton = event.target && event.target.closest ? event.target.closest("[data-dashboard-year]") : null;
if (yearButton) {
S.dashboard.year = yearButton.getAttribute("data-dashboard-year") || S.dashboard.year;
filter();
return;
}
var sectionButton = event.target && event.target.closest ? event.target.closest("[data-dashboard-section]") : null;
if (sectionButton) {
S.dashboard.section = sectionButton.getAttribute("data-dashboard-section") || "active";
filter();
}
});
}
if (E.tbody && !E.tbody.dataset.projectBound) {
E.tbody.dataset.projectBound = "true";
E.tbody.addEventListener("click", function (event) {
var groupButton = event.target && event.target.closest ? event.target.closest("[data-group-label]") : null;
if (groupButton) {
var label = groupButton.getAttribute("data-group-label") || "";
if (label) {
S.collapsedGroups[label] = !S.collapsedGroups[label];
render();
}
return;
}
var trigger = event.target && event.target.closest ? event.target.closest(".project-link") : null;
if (!trigger) return;
var key = trigger.getAttribute("data-project-key") || "";
var rows = Array.isArray(S.viewRows) ? S.viewRows : [];
var row = rows.find(function (item) {
return (String(item.code || "") + "|" + String(item.name || "")) === key;
});
if (row) openProjectWindow(row);
});
}
setTimeout(function () {
try {
filter();
if (typeof loadLedgerFile === "function") {
tryLoadDbDefaultBusinessLedger();
}
} catch (error) {
console.error(error);
}
}, 0);
window.addEventListener("message", function (event) {
var data = event.data || {};
if (data.source !== "total-upload" || data.type !== "business") return;
setTimeout(function () {
try {
applyDashboardChrome();
renderLedgerTable();
} catch (error) {
console.error(error);
}
}, 50);
});
})();

File diff suppressed because one or more lines are too long

View File

@@ -1,38 +1,41 @@
@import url("/design-tokens.css?v=20260401-01");
@import url("/design-patterns.css?v=20260401-01");
:root {
--font-sans: "Pretendard", sans-serif;
--font-sans: var(--ds-font-sans);
--color-bg: #f1f5f9;
--color-bg-soft: #eef2ff;
--color-surface: #ffffff;
--color-surface-soft: rgba(255, 255, 255, 0.88);
--color-surface-strong: #e2e8f0;
--color-text: #1e293b;
--color-text-soft: #475569;
--color-text-muted: #64748b;
--color-border: #cbd5e1;
--color-border-soft: rgba(148, 163, 184, 0.3);
--color-header: #1e293b;
--color-header-soft: #334155;
--color-accent: #4f46e5;
--color-accent-soft: #e0e7ff;
--color-accent-strong: #4338ca;
--color-bg: var(--ds-bg);
--color-bg-soft: var(--ds-bg-soft);
--color-surface: var(--ds-panel);
--color-surface-soft: var(--ds-panel-soft);
--color-surface-strong: var(--ds-panel-strong);
--color-text: var(--ds-ink);
--color-text-soft: var(--ds-text-soft);
--color-text-muted: var(--ds-text-muted);
--color-border: var(--ds-line);
--color-border-soft: var(--ds-line-soft);
--color-header: var(--ds-brand);
--color-header-soft: var(--ds-brand-soft);
--color-accent: var(--ds-accent);
--color-accent-soft: var(--ds-accent-soft);
--color-accent-strong: var(--ds-accent-strong);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 18px;
--radius-xl: 24px;
--radius-pill: 999px;
--radius-sm: var(--ds-radius-sm);
--radius-md: var(--ds-radius-md);
--radius-lg: var(--ds-radius-lg);
--radius-xl: var(--ds-radius-xl);
--radius-pill: var(--ds-radius-pill);
--shadow-soft: 0 4px 14px rgba(15, 23, 42, 0.08);
--shadow-card: 0 18px 44px rgba(15, 23, 42, 0.12);
--shadow-float: 0 18px 36px rgba(79, 70, 229, 0.16);
--shadow-soft: var(--ds-shadow-soft);
--shadow-card: var(--ds-shadow-card);
--shadow-float: var(--ds-shadow-float);
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-1: var(--ds-space-1);
--space-2: var(--ds-space-2);
--space-3: var(--ds-space-3);
--space-4: var(--ds-space-4);
--space-5: var(--ds-space-5);
--space-6: var(--ds-space-6);
}
* {
@@ -46,15 +49,13 @@ body {
min-height: 100%;
font-family: var(--font-sans);
color: var(--color-text);
background:
radial-gradient(circle at top left, rgba(79, 70, 229, 0.12), transparent 22%),
radial-gradient(circle at bottom right, rgba(148, 163, 184, 0.18), transparent 28%),
var(--color-bg);
background: var(--ds-bg-gradient);
}
body {
min-height: 100vh;
overflow: hidden;
background: var(--ds-bg-gradient);
}
button,
@@ -92,18 +93,18 @@ a {
.ui-button-secondary {
border: 1px solid var(--color-border-soft);
color: var(--color-text);
background: rgba(255, 255, 255, 0.72);
background: var(--ds-surface-tint);
}
.ui-input {
border: 1px solid var(--color-border-soft);
border-radius: var(--radius-pill);
background: rgba(255, 255, 255, 0.88);
background: var(--ds-surface-tint-strong);
color: var(--color-text);
outline: none;
}
.ui-input:focus {
border-color: rgba(79, 70, 229, 0.45);
box-shadow: 0 0 0 4px rgba(79, 70, 229, 0.08);
border-color: rgba(47, 153, 115, 0.45);
box-shadow: 0 0 0 4px rgba(47, 153, 115, 0.1);
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,16 @@ let isListMode = false;
let emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.';
let photoPreviewObjectUrl = null;
let seatMapLayoutCache = null;
let activeAsOfDate = '';
let isHistoricalSnapshot = false;
const listViewState = {
mode: 'current',
snapshotDate: '',
compareFromDate: '',
compareToDate: '',
snapshotMembers: [],
compareItems: [],
};
const seatMapOfficeKeys = ['technical-development-center', 'hanmac-building-6f', 'hanmac-building-7f'];
const levelOrder = ['부서', '그룹', '디비전', '팀', '셀'];
@@ -34,6 +44,15 @@ function cloneMembers(items) {
return JSON.parse(JSON.stringify(items));
}
function isRetiredLegacyMember(member) {
const workStatus = String(member?.['근무상태'] || '').trim();
return workStatus === '퇴직';
}
function getVisibleLegacyMembers(items) {
return (items || []).filter((member) => !isRetiredLegacyMember(member));
}
function getPhotoPlaceholder(name = '') {
return `https://via.placeholder.com/160?text=${encodeURIComponent(name || 'Profile')}`;
}
@@ -117,6 +136,22 @@ async function apiFetch(url, options = {}) {
return payload;
}
function withAsOf(url) {
if (!activeAsOfDate) {
return url;
}
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}as_of=${encodeURIComponent(activeAsOfDate)}`;
}
function getDefaultHistoryDate() {
if (activeAsOfDate) {
return activeAsOfDate;
}
const now = new Date();
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
}
async function uploadProfilePhoto(file, memberName) {
const formData = new FormData();
formData.append('file', file);
@@ -129,7 +164,7 @@ async function uploadProfilePhoto(file, memberName) {
}
function setMembers(items) {
members = items.map(toLegacyMember);
members = getVisibleLegacyMembers(items.map(toLegacyMember));
if (selectedDept !== '전체' && !members.some((member) => member['부서'] === selectedDept)) {
selectedDept = '전체';
}
@@ -140,7 +175,7 @@ async function loadMembers(message) {
if (message) {
emptyStateMessage = message;
}
const payload = await apiFetch('/api/members');
const payload = await apiFetch(withAsOf('/api/members'));
setMembers(payload.items || []);
if (!members.length) {
emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.';
@@ -160,7 +195,7 @@ async function loadSeatMapLayouts(force = false) {
if (!seatMap?.id) {
return null;
}
return await apiFetch(`/api/seat-maps/${seatMap.id}/layout`);
return await apiFetch(withAsOf(`/api/seat-maps/${seatMap.id}/layout`));
} catch {
return null;
}
@@ -618,6 +653,10 @@ function render() {
}
function toggleAdminMode(checked) {
if (checked && isHistoricalSnapshot) {
alert('월말 히스토리 조회 중에는 수정할 수 없습니다. 최신 월로 돌아간 뒤 수정해주세요.');
return;
}
isAdmin = checked;
const button = document.getElementById('admin-mode-btn');
if (isAdmin) {
@@ -645,7 +684,7 @@ function updateFabMenu() {
let html = '<button class="fab-sub shadow-xl" data-label="리스트" onclick="openListViewModal(event)">📋</button>';
html += '<button class="fab-sub shadow-xl" data-label="조직도 인쇄(A3)" onclick="printA3()">🖨️</button>';
html += '<button class="fab-sub shadow-xl" data-label="자리배치도" onclick="openSeatMapView(event)">🪑</button>';
if (isAdmin) {
if (isAdmin && !isHistoricalSnapshot) {
html += '<button class="fab-sub shadow-xl" data-label="조직현황 업로드" onclick="triggerUpload(event)">⬆️</button>';
html += '<button class="fab-sub shadow-xl" data-label="신규 구성원" onclick="openAddModal(event)">👤</button>';
html += '<button class="fab-sub shadow-xl" data-label="신규 팀/그룹/셀" onclick="openUnitAddModal(event)">🏢</button>';
@@ -653,6 +692,19 @@ function updateFabMenu() {
menu.innerHTML = html;
}
async function openHistoryCompareModal(fromDate, toDate) {
openListViewModal();
const fromInput = document.getElementById('list-compare-from');
const toInput = document.getElementById('list-compare-to');
if (fromInput) {
fromInput.value = fromDate || '';
}
if (toInput) {
toInput.value = toDate || '';
}
await loadCompareListView();
}
function openSeatMapView(event) {
event.stopPropagation();
document.getElementById('fab-container').classList.remove('active');
@@ -666,6 +718,29 @@ window.addEventListener('message', (event) => {
if (!data || typeof data !== 'object') {
return;
}
if (data.type === 'date-range') {
activeAsOfDate = String(data.endDate || '').slice(0, 10);
return;
}
if (data.type === 'organization-history-view') {
activeAsOfDate = String(data.asOfDate || '').slice(0, 10);
isHistoricalSnapshot = Boolean(data.historical);
if (isHistoricalSnapshot && isAdmin) {
toggleAdminMode(false);
} else {
updateFabMenu();
render();
}
seatMapLayoutCache = null;
loadMembers().catch(() => { });
return;
}
if (data.type === 'open-history-compare') {
openHistoryCompareModal(String(data.fromDate || ''), String(data.toDate || '')).catch((error) => {
alert(error.message || '변경 비교를 불러오지 못했습니다.');
});
return;
}
if (data.type === 'seatmap-layout-updated') {
handleSeatMapLayoutUpdated();
}
@@ -785,18 +860,20 @@ function openUnitAddModal(event) {
</select>
</div>
<div class="col-span-2">
<label class="text-[11px] font-black text-slate-600 block">상위 위치 선택</label>
<select id="new-unit-parent" class="w-full bg-white p-3 rounded-xl border text-sm font-bold outline-none"></select>
<label class="member-form-label block">상위 위치 선택</label>
<select id="new-unit-parent" class="member-form-select"></select>
</div>
<div class="col-span-2">
<label class="text-[11px] font-black text-slate-600 block">신규 명칭 입력</label>
<input id="new-unit-name" placeholder="예: 신규개발팀" class="w-full bg-slate-50 p-3 rounded-xl border font-bold text-sm outline-none">
<label class="member-form-label block">신규 명칭 입력</label>
<input id="new-unit-name" placeholder="예: 신규개발팀" class="member-form-input">
</div>
`;
updateParentList();
document.getElementById('modal-footer-area').innerHTML = `
<button onclick="closeModal()" class="flex-1 bg-slate-100 py-3.5 rounded-xl font-bold text-sm">취소</button>
<button onclick="saveNewUnit()" class="flex-1 bg-indigo-600 text-white py-3.5 rounded-xl font-bold text-sm">저장</button>
<div class="modal-footer-actions">
<button onclick="closeModal()" class="modal-btn modal-btn-cancel">취소</button>
<button onclick="saveNewUnit()" class="modal-btn modal-btn-save">저장</button>
</div>
`;
modal.style.display = 'flex';
}
@@ -859,14 +936,16 @@ function openOrgEditModal(level, oldName) {
fieldsArea.style.maxHeight = 'none';
fieldsArea.innerHTML = `
<div class="col-span-2">
<label class="text-[11px] font-black text-slate-400 block">새로운 ${level} 명칭</label>
<input id="new-org-name" value="${oldName}" class="w-full bg-slate-50 p-3 rounded-xl border font-bold text-sm outline-none">
<label class="member-form-label block">새로운 ${level} 명칭</label>
<input id="new-org-name" value="${oldName}" class="member-form-input">
</div>
`;
document.getElementById('modal-footer-area').innerHTML = `
<button onclick="deleteOrg('${jsString(level)}', '${jsString(oldName)}')" class="bg-red-50 text-red-600 py-3.5 px-6 rounded-xl font-bold text-sm border border-red-100 hover:bg-red-100 transition-colors">삭제</button>
<button onclick="closeModal()" class="flex-1 bg-slate-100 py-3.5 rounded-xl font-bold text-sm">취소</button>
<button onclick="saveOrgName('${jsString(level)}', '${jsString(oldName)}')" class="flex-1 bg-indigo-600 text-white py-3.5 rounded-xl font-bold text-sm">저장</button>
<button onclick="deleteOrg('${jsString(level)}', '${jsString(oldName)}')" class="modal-btn modal-btn-delete">삭제</button>
<div class="modal-footer-actions">
<button onclick="closeModal()" class="modal-btn modal-btn-cancel">취소</button>
<button onclick="saveOrgName('${jsString(level)}', '${jsString(oldName)}')" class="modal-btn modal-btn-save">저장</button>
</div>
`;
modal.style.display = 'flex';
}
@@ -948,11 +1027,11 @@ function handlePhotoFileChange(event) {
function renderSeatPreviewCard(seatInfo) {
const assigned = Boolean(seatInfo?.assigned);
const safeLabel = escapeHtml(seatInfo?.seatLabel || '');
const seatMapLabel = String(seatInfo?.seatMapName || '자리배치도').replace(/\s*자리배치도\s*$/u, '').trim() || '사무실';
const safeSeatMapName = escapeHtml(seatInfo?.seatMapName || '자리배치도');
const safeSlotKey = escapeHtml(seatInfo?.slotKey || '');
const safeOfficeLabel = escapeHtml(seatMapLabel);
const badge = assigned
? `<span class="seat-preview-badge">${safeLabel || '배치완료'}</span>`
? `<span class="seat-preview-badge">${safeOfficeLabel}</span>`
: '<span class="seat-preview-badge seat-preview-badge-muted">미배치</span>';
const body = assigned
? `
@@ -973,11 +1052,11 @@ function renderSeatPreviewCard(seatInfo) {
`;
return `
<div class="seat-preview-card">
<div class="seat-preview-card${assigned ? ' is-assigned' : ''}">
<div class="seat-preview-head">
<div>
<strong>재석위치</strong>
<p>${assigned ? '현재 자리배치도 기준으로 배치된 좌석 정보를 표시합니다.' : '현재 자리배치도에서 배치된 좌석이 없습니다.'}</p>
<p>${assigned ? '현재 배치된 사무실과 좌석 위치를 강조해서 표시합니다.' : '현재 자리배치도에서 배치된 좌석이 없습니다.'}</p>
</div>
${badge}
</div>
@@ -1027,12 +1106,8 @@ function switchModalTab(tab) {
const isBasic = tab === 'basic';
document.getElementById('modal-sec-basic').classList.toggle('hidden', !isBasic);
document.getElementById('modal-sec-org').classList.toggle('hidden', isBasic);
document.getElementById('modal-tab-basic').className = isBasic
? 'flex-1 py-3 font-bold border-b-2 border-indigo-600 text-indigo-600 text-sm transition-all'
: 'flex-1 py-3 font-bold border-b-2 border-transparent text-slate-400 text-sm transition-all';
document.getElementById('modal-tab-org').className = !isBasic
? 'flex-1 py-3 font-bold border-b-2 border-indigo-600 text-indigo-600 text-sm transition-all'
: 'flex-1 py-3 font-bold border-b-2 border-transparent text-slate-400 text-sm transition-all';
document.getElementById('modal-tab-basic').className = isBasic ? 'member-modal-tab is-active' : 'member-modal-tab';
document.getElementById('modal-tab-org').className = !isBasic ? 'member-modal-tab is-active' : 'member-modal-tab';
}
function openModal(id) {
@@ -1049,14 +1124,14 @@ function openModal(id) {
fieldsArea.style.maxHeight = 'none';
fieldsArea.innerHTML = `
<div class="member-detail-top-row">
<div class="relative w-32 h-32 rounded-full overflow-hidden border-4 border-indigo-100 shadow-lg">
<div class="relative w-32 h-32 rounded-full overflow-hidden border-4 shadow-lg" style="border-color: var(--color-surface-strong);">
<img src="${member['사진'] || 'https://via.placeholder.com/120?text=Profile'}" class="w-full h-full object-cover">
</div>
<div class="member-detail-summary">
<div>
<h2 class="text-2xl font-black text-slate-800">${member['이름'] || ''}</h2>
<p class="text-indigo-600 font-bold">${member['직급'] || '-'} / ${member['직책'] || '팀원'}</p>
<p class="text-slate-400 text-xs mt-1 font-medium">${(member._path || []).map((path) => path.name).join(' > ')}</p>
<p class="font-bold" style="color: var(--color-header);">${member['직급'] || '-'} / ${member['직책'] || '팀원'}</p>
<p class="text-xs mt-1 font-medium" style="color: var(--color-text-muted);">${(member._path || []).map((path) => path.name).join(' > ')}</p>
</div>
<div class="member-inline-info-grid">
<div class="member-inline-info-card">
@@ -1074,7 +1149,7 @@ function openModal(id) {
<div id="member-seat-preview">${renderSeatPreviewCard({ assigned: false, seatLabel: member['자리위치'] || '', seatMapName: '자리배치도', slotKey: '' })}</div>
</div>
`;
footer.innerHTML = '<button onclick="closeModal()" class="w-full bg-slate-800 text-white py-4 rounded-xl font-bold text-sm shadow-lg">닫기</button>';
footer.innerHTML = '<button onclick="closeModal()" class="modal-btn modal-btn-close">닫기</button>';
modal.style.display = 'flex';
hydrateMemberSeatPreview(member);
return;
@@ -1093,14 +1168,14 @@ function openModal(id) {
const currentValue = member[field] || '';
orgFields += `
<div class="col-span-1">
<label class="text-[11px] font-black text-slate-600 block">${field}</label>
<select id="sel-${field}" onchange="toggleManualInput('${field}')" class="w-full bg-white p-3 rounded-xl border text-sm font-bold text-slate-700 outline-none">
<option value="__NEW__" class="text-indigo-600 font-bold">+ 직접/신규 입력</option>
<label class="member-form-label block">${field}</label>
<select id="sel-${field}" onchange="toggleManualInput('${field}')" class="member-form-select">
<option value="__NEW__" class="member-form-new-option">+ 직접/신규 입력</option>
<option value="__NONE__" ${currentValue === '' ? 'selected' : ''}>-- 선택 안 함 --</option>
${uniqueValues.map((value) => `<option value="${value}" ${value === currentValue ? 'selected' : ''}>${value}</option>`).join('')}
</select>
<div id="manual-${field}" class="hidden mt-2">
<input id="input-${field}" placeholder="직접 입력" class="w-full bg-indigo-50 p-3 rounded-xl border-indigo-200 border text-sm font-bold">
<div id="manual-${field}" class="hidden member-form-manual">
<input id="input-${field}" placeholder="직접 입력" class="member-form-input">
</div>
</div>
`;
@@ -1109,39 +1184,41 @@ function openModal(id) {
const isFlexible = member['근무시간'] === '유연근무제';
orgFields += `
<div class="col-span-1">
<label class="text-[11px] font-black text-slate-600 block">근무 상태</label>
<select id="m-status" class="w-full bg-white p-3 rounded-xl border text-sm font-bold outline-none">
<option value="근무" ${member['근무상태'] !== '휴직' ? 'selected' : ''}>근무</option>
<label class="member-form-label block">근무 상태</label>
<select id="m-status" class="member-form-select">
<option value="근무" ${member['근무상태'] !== '휴직' && member['근무상태'] !== '퇴직' ? 'selected' : ''}>근무</option>
<option value="휴직" ${member['근무상태'] === '휴직' ? 'selected' : ''}>휴직</option>
<option value="퇴직" ${member['근무상태'] === '퇴직' ? 'selected' : ''}>퇴직</option>
</select>
</div>
<div class="col-span-1">
<label class="text-[11px] font-black text-slate-600 block">근무 시간</label>
<select id="m-worktime" onchange="toggleFlexibleTime(this.value)" class="w-full bg-white p-3 rounded-xl border text-sm font-bold outline-none">
<label class="member-form-label block">근무 시간</label>
<select id="m-worktime" onchange="toggleFlexibleTime(this.value)" class="member-form-select">
<option value="09~18" ${!isFlexible ? 'selected' : ''}>09~18</option>
<option value="유연근무제" ${isFlexible ? 'selected' : ''}>유연근무제</option>
</select>
<div id="flexible-time-area" class="${isFlexible ? '' : 'hidden'} mt-2 flex items-center gap-2">
<input type="time" id="m-work-start" value="${member['유연근무_시작'] || '09:00'}" class="bg-indigo-50 p-2 rounded-lg border border-indigo-100 text-xs font-bold w-full">
<input type="time" id="m-work-end" value="${member['유연근무_종료'] || '18:00'}" class="bg-indigo-50 p-2 rounded-lg border border-indigo-100 text-xs font-bold w-full">
<input type="time" id="m-work-start" value="${member['유연근무_시작'] || '09:00'}" class="member-form-time">
<input type="time" id="m-work-end" value="${member['유연근무_종료'] || '18:00'}" class="member-form-time">
</div>
</div>
</div>`;
fieldsArea.innerHTML = `
<div class="flex border-b mb-6 sticky top-0 bg-white z-10">
<button id="modal-tab-basic" onclick="switchModalTab('basic')" class="flex-1 py-3 font-bold border-b-2 border-indigo-600 text-indigo-600 text-sm transition-all">기본 정보</button>
<button id="modal-tab-org" onclick="switchModalTab('org')" class="flex-1 py-3 font-bold border-b-2 border-transparent text-slate-400 text-sm transition-all">조직 및 근무</button>
<div class="member-modal-tabs">
<button id="modal-tab-basic" onclick="switchModalTab('basic')" class="member-modal-tab is-active">기본 정보</button>
<button id="modal-tab-org" onclick="switchModalTab('org')" class="member-modal-tab">조직 및 근무</button>
</div>
<div id="modal-sec-basic" class="grid grid-cols-2 gap-3 modal-form-grid">
<div id="modal-sec-basic" class="modal-form-grid member-basic-editor">
<input type="hidden" id="m-id" value="${id || ''}">
<input type="hidden" id="m-photo-hidden" value="${member['사진'] || ''}">
<input type="hidden" id="m-seat-hidden" value="${member['자리위치'] || ''}">
<div class="col-span-2 member-edit-layout">
<div class="member-edit-left-pane">
<div class="member-edit-profile-card">
<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-basic-split">
<div class="member-basic-left">
<div class="member-photo-panel">
<p class="member-modal-panel-title">기본 정보</p>
<div class="member-photo-upload-card member-photo-upload-card-inline">
<div class="member-photo-card-title">프로필 사진</div>
<div class="member-photo-preview-wrap">
<img id="m-photo-preview" src="${member['사진'] || getPhotoPlaceholder(member['이름'] || '')}" alt="프로필 미리보기" class="member-photo-preview">
</div>
@@ -1153,43 +1230,47 @@ function openModal(id) {
<strong id="m-photo-file-name" class="member-photo-file-name">선택된 파일 없음</strong>
</div>
</div>
<div class="member-name-field member-name-field-compact">
<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>
<div class="member-basic-fields member-modal-panel">
<p class="member-modal-panel-title">기본 정보</p>
<div class="member-basic-field">
<label class="member-form-label block">이름 (필수)</label>
<input id="m-name" value="${member['이름'] || ''}" oninput="syncPhotoPreviewFromUrl()" class="member-form-input">
</div>
<div class="member-inline-info-grid member-inline-info-grid-stacked">
<div class="member-inline-info-card member-inline-info-card-full">
<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">
</div>
<div class="member-inline-info-card member-inline-info-card-full">
<label>전화번호</label>
<input id="m-phone" value="${member['전화번호'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none">
</div>
<div class="member-inline-info-card member-inline-info-card-full">
<label>이메일</label>
<input id="m-email" value="${member['이메일'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none">
</div>
<div class="member-basic-field">
<label class="member-form-label block">사번</label>
<input id="m-employee-id" value="${member['사번'] || ''}" class="member-form-input">
</div>
<div class="member-basic-field">
<label class="member-form-label block">전화번호</label>
<input id="m-phone" value="${member['전화번호'] || ''}" class="member-form-input">
</div>
<div class="member-basic-field">
<label class="member-form-label block">이메일</label>
<input id="m-email" value="${member['이메일'] || ''}" class="member-form-input">
</div>
</div>
</div>
<div class="member-edit-right-pane">
<div class="member-seat-field member-seat-field-emphasis">
<div class="member-basic-right">
<p class="member-modal-panel-title" style="padding:16px 16px 0;">조직 및 근무</p>
<div class="member-seat-field member-seat-field-compact">
<div id="member-seat-preview">${renderSeatPreviewCard({ assigned: false, seatLabel: member['자리위치'] || '', seatMapName: '자리배치도', slotKey: '' })}</div>
</div>
</div>
</div>
</div>
${orgFields}
<div class="member-modal-panel">${orgFields}</div>
`;
resetPhotoPreviewObjectUrl();
const deleteBtn = id ? `<button onclick="deleteMember('${id}')" class="bg-red-50 text-red-600 py-3.5 px-6 rounded-xl font-bold text-sm border border-red-100 hover:bg-red-100 transition-colors">삭제</button>` : '';
const deleteBtn = id ? `<button onclick="deleteMember('${id}')" class="modal-btn modal-btn-delete">삭제</button>` : '';
footer.innerHTML = `
${deleteBtn}
<button onclick="closeModal()" class="flex-1 bg-slate-100 py-3.5 rounded-xl font-bold text-sm">취소</button>
<button onclick="saveMember()" class="flex-1 bg-indigo-600 text-white py-3.5 rounded-xl font-bold text-sm">저장</button>
<div class="modal-footer-actions">
<button onclick="closeModal()" class="modal-btn modal-btn-cancel">취소</button>
<button onclick="saveMember()" class="modal-btn modal-btn-save">저장</button>
</div>
`;
modal.style.display = 'flex';
if (id) {
@@ -1308,6 +1389,14 @@ function openListViewModal(event) {
event.stopPropagation();
}
const defaultDate = getDefaultHistoryDate();
listViewState.mode = 'current';
listViewState.snapshotDate = defaultDate;
listViewState.compareFromDate = defaultDate;
listViewState.compareToDate = defaultDate;
listViewState.snapshotMembers = [];
listViewState.compareItems = [];
const modal = document.getElementById('modal');
modal.querySelector('.modal-content').classList.add('wide');
document.getElementById('modal-title').innerText = '인원 명단';
@@ -1317,36 +1406,41 @@ function openListViewModal(event) {
isListMode = true;
editingMembers = cloneMembers(members);
fieldsArea.innerHTML = `
<div class="mb-4 flex gap-2 p-1">
<input type="text" id="list-search-input" placeholder="이름 또는 부서/팀 검색 (Enter 시 이동)" class="flex-1 bg-slate-50 border-2 border-slate-100 p-3 rounded-xl text-sm outline-none font-bold focus:border-indigo-400 transition-all" onkeydown="if(event.key==='Enter') handleListSearch(this.value)">
<button onclick="handleListSearch(document.getElementById('list-search-input').value)" class="bg-indigo-600 text-white px-5 rounded-xl font-bold text-sm">검색</button>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-group">
<button type="button" onclick="showCurrentListView()" class="list-mode-btn">현재 명단</button>
</div>
<div class="list-toolbar-divider" aria-hidden="true"></div>
<div class="list-toolbar-group list-date-group">
<input type="date" id="list-snapshot-date" value="${escapeHtml(defaultDate)}" class="list-date-input">
<button type="button" onclick="loadSnapshotListView()" class="list-mode-btn">기준일 조회</button>
</div>
<div class="list-toolbar-divider" aria-hidden="true"></div>
<div class="list-toolbar-group list-date-group">
<input type="date" id="list-compare-from" value="${escapeHtml(defaultDate)}" class="list-date-input">
<span class="list-date-separator">~</span>
<input type="date" id="list-compare-to" value="${escapeHtml(defaultDate)}" class="list-date-input">
<button type="button" onclick="loadCompareListView()" class="list-mode-btn">변경 비교</button>
</div>
</div>
<div class="list-toolbar-row">
<input type="text" id="list-search-input" placeholder="이름 또는 부서/팀 검색 (Enter 시 이동)" class="flex-1 bg-[#f6eddd] border-2 border-[#e0d0b4] p-3 rounded-xl text-sm outline-none font-bold text-[#1f2f25] focus:border-[#d68a3a] transition-all" onkeydown="if(event.key==='Enter') handleListSearch(this.value)">
<button type="button" onclick="handleListSearch(document.getElementById('list-search-input').value)" class="bg-[#214634] text-white px-5 rounded-xl font-bold text-sm">검색</button>
</div>
<div id="list-view-status" class="list-view-status"></div>
</div>
<div id="list-table-container" class="overflow-y-auto flex-1 border rounded-xl"></div>
`;
renderListViewTable();
const footer = document.getElementById('modal-footer-area');
if (isAdmin) {
footer.innerHTML = `
<div class="flex gap-2 w-full justify-between items-center">
<div class="flex gap-2">
<button onclick="openAddModal(event)" class="bg-indigo-50 text-indigo-600 px-4 py-2 rounded-lg text-xs font-bold border border-indigo-100">+ 구성원 추가</button>
<button onclick="openUnitAddModal(event)" class="bg-indigo-50 text-indigo-600 px-4 py-2 rounded-lg text-xs font-bold border border-indigo-100">+ 조직 추가</button>
</div>
<div class="flex gap-2 items-center">
<p class="text-[10px] text-slate-400 font-bold mr-4">항목을 드래그하여 순서를 바꿀 수 있습니다.</p>
<button onclick="closeModal()" class="bg-slate-100 text-slate-600 px-6 py-2 rounded-lg text-xs font-bold">취소</button>
<button onclick="applyListViewChanges()" class="bg-indigo-600 text-white px-8 py-2 rounded-lg text-xs font-bold shadow-lg shadow-indigo-200">반영하기</button>
</div>
</div>
`;
} else {
footer.innerHTML = '<div class="flex gap-2 w-full justify-end items-center"><button onclick="closeModal()" class="bg-indigo-600 text-white px-10 py-2.5 rounded-lg text-xs font-bold shadow-lg shadow-indigo-200">닫기</button></div>';
}
renderListViewModalContent();
modal.style.display = 'flex';
}
async function applyListViewChanges() {
if (listViewState.mode !== 'current') {
closeModal();
return;
}
if (!confirm('리스트의 변경 사항을 메인 화면에 반영하시겠습니까?')) {
return;
}
@@ -1355,19 +1449,192 @@ async function applyListViewChanges() {
closeModal();
}
function renderListViewFooter() {
const footer = document.getElementById('modal-footer-area');
if (!footer) {
return;
}
if (listViewState.mode === 'current' && isAdmin) {
footer.innerHTML = `
<div class="flex gap-2 w-full justify-between items-center">
<div class="flex gap-2">
<button onclick="openAddModal(event)" class="bg-[#f6eddd] text-[#214634] px-4 py-2 rounded-lg text-xs font-bold border border-[#e0d0b4]">+ 구성원 추가</button>
<button onclick="openUnitAddModal(event)" class="bg-[#f6eddd] text-[#214634] px-4 py-2 rounded-lg text-xs font-bold border border-[#e0d0b4]">+ 조직 추가</button>
</div>
<div class="flex gap-2 items-center">
<p class="text-[10px] text-[#8b8a77] font-bold mr-4">항목을 드래그하여 순서를 바꿀 수 있습니다.</p>
<button onclick="closeModal()" class="bg-[#efe4d0] text-[#5b665a] px-6 py-2 rounded-lg text-xs font-bold">취소</button>
<button onclick="applyListViewChanges()" class="bg-[#214634] text-white px-8 py-2 rounded-lg text-xs font-bold shadow-lg shadow-[#d6c1a3]">반영하기</button>
</div>
</div>
`;
return;
}
footer.innerHTML = '<div class="flex gap-2 w-full justify-end items-center"><button onclick="closeModal()" class="bg-[#214634] text-white px-10 py-2.5 rounded-lg text-xs font-bold shadow-lg shadow-[#d6c1a3]">닫기</button></div>';
}
function getRenderableListMembers() {
if (listViewState.mode === 'snapshot') {
return listViewState.snapshotMembers;
}
return editingMembers;
}
function getListSearchEntries() {
if (listViewState.mode === 'compare') {
return (listViewState.compareItems || []).map((item) => ({
rowId: `list-compare-row-${item.member_id}`,
name: String(item.name || ''),
values: [String(item.name || ''), ...(item.before_lines || []), ...(item.after_lines || [])],
}));
}
return getRenderableListMembers().map((member) => ({
rowId: `list-row-${member._id}`,
name: String(member['이름'] || ''),
values: [
String(member['이름'] || ''),
...levelOrder.map((level) => String(member[level] || '')),
],
}));
}
function formatCompareChangedAt(value) {
const raw = String(value || '').trim();
if (!raw) {
return '-';
}
const date = new Date(raw);
if (Number.isNaN(date.getTime())) {
return raw;
}
const year = date.getFullYear();
const month = pad(date.getMonth() + 1);
const day = pad(date.getDate());
const hours = pad(date.getHours());
const minutes = pad(date.getMinutes());
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
function renderListViewCompareTable() {
const container = document.getElementById('list-table-container');
if (!container) {
return;
}
const rows = listViewState.compareItems || [];
let html = `
<table class="list-table list-compare-table">
<thead>
<tr>
<th class="col-name">이름</th>
<th class="col-compare-status">상태</th>
<th class="col-compare-date">변경일시</th>
<th class="col-compare-category">변경유형</th>
<th>이전</th>
<th>현재</th>
</tr>
</thead>
<tbody>
`;
if (!rows.length) {
html += '<tr><td colspan="6" class="list-empty-cell">선택한 기간 사이의 구성원 변경 내역이 없습니다.</td></tr>';
} else {
rows.forEach((item) => {
const categories = (item.categories || []).map((category) => `<span class="list-compare-chip">${escapeHtml(category)}</span>`).join('');
const beforeLines = (item.before_lines || []).map((line) => `<div class="list-compare-line">${escapeHtml(line)}</div>`).join('') || '<span class="text-slate-300">-</span>';
const afterLines = (item.after_lines || []).map((line) => `<div class="list-compare-line">${escapeHtml(line)}</div>`).join('') || '<span class="text-slate-300">-</span>';
html += `
<tr id="list-compare-row-${item.member_id}">
<td class="font-black text-slate-700">${escapeHtml(item.name || '-')}</td>
<td><span class="list-compare-status list-compare-status-${escapeHtml(item.status || 'updated')}">${escapeHtml(item.status_label || '-')}</span></td>
<td>${escapeHtml(formatCompareChangedAt(item.changed_at))}</td>
<td><div class="list-compare-chip-group">${categories || '<span class="text-slate-300">-</span>'}</div></td>
<td class="list-compare-cell">${beforeLines}</td>
<td class="list-compare-cell">${afterLines}</td>
</tr>
`;
});
}
html += '</tbody></table>';
container.innerHTML = html;
}
function renderListViewModalContent() {
const status = document.getElementById('list-view-status');
if (status) {
if (listViewState.mode === 'snapshot') {
status.textContent = listViewState.snapshotDate
? `${listViewState.snapshotDate} 기준 인원 명단입니다.`
: '기준일을 선택한 뒤 조회하세요.';
} else if (listViewState.mode === 'compare') {
status.textContent = (listViewState.compareFromDate && listViewState.compareToDate)
? `${listViewState.compareFromDate} ~ ${listViewState.compareToDate} 변경 내역입니다.`
: '비교 시작일과 종료일을 선택하세요.';
} else {
status.textContent = '현재 조직 인원 명단입니다.';
}
}
if (listViewState.mode === 'compare') {
renderListViewCompareTable();
} else {
renderListViewTable();
}
renderListViewFooter();
}
function showCurrentListView() {
listViewState.mode = 'current';
renderListViewModalContent();
}
async function loadSnapshotListView() {
const snapshotDate = document.getElementById('list-snapshot-date')?.value || '';
if (!snapshotDate) {
alert('기준일을 선택해주세요.');
return;
}
const payload = await apiFetch(`/api/members?as_of=${encodeURIComponent(snapshotDate)}`);
listViewState.snapshotDate = snapshotDate;
listViewState.snapshotMembers = getVisibleLegacyMembers((payload.items || []).map(toLegacyMember));
listViewState.mode = 'snapshot';
renderListViewModalContent();
}
async function loadCompareListView() {
const fromDate = document.getElementById('list-compare-from')?.value || '';
const toDate = document.getElementById('list-compare-to')?.value || '';
if (!fromDate || !toDate) {
alert('비교 시작일과 종료일을 선택해주세요.');
return;
}
const payload = await apiFetch(`/api/history/members/compare?from_date=${encodeURIComponent(fromDate)}&to_date=${encodeURIComponent(toDate)}`);
listViewState.compareFromDate = fromDate;
listViewState.compareToDate = toDate;
listViewState.compareItems = Array.isArray(payload.items) ? payload.items : [];
listViewState.mode = 'compare';
renderListViewModalContent();
}
function renderListViewTable() {
const container = document.getElementById('list-table-container');
if (!container) {
return;
}
let html = `<table class="list-table"><thead><tr>${isAdmin ? '<th width="40">순서</th>' : ''}<th class="col-name">이름</th><th class="col-rank">직급</th><th class="col-pos">직책</th><th class="col-unit-sm">셀</th><th class="col-unit-sm">팀</th><th class="col-unit-lg">디비전</th><th class="col-unit-lg">그룹</th><th class="col-unit-lg">부서</th><th class="col-corp">소속</th><th class="col-action">${isAdmin ? '관리' : '조회'}</th></tr></thead><tbody id="list-body">`;
const sourceMembers = getRenderableListMembers();
const editable = isAdmin && listViewState.mode === 'current';
const inspectable = !editable && listViewState.mode === 'current';
const groupColumnCount = editable ? 11 : 10;
let html = `<table class="list-table"><thead><tr>${editable ? '<th width="40">순서</th>' : ''}<th class="col-name">이름</th><th class="col-rank">직급</th><th class="col-pos">직책</th><th class="col-unit-sm">셀</th><th class="col-unit-sm">팀</th><th class="col-unit-lg">디비전</th><th class="col-unit-lg">그룹</th><th class="col-unit-lg">부서</th><th class="col-corp">소속</th><th class="col-action">${editable ? '관리' : '조회'}</th></tr></thead><tbody id="list-body">`;
const lastValues = {};
levelOrder.forEach((level) => {
lastValues[level] = '';
});
editingMembers.forEach((member, index) => {
sourceMembers.forEach((member, index) => {
let isAnyParentCollapsed = false;
levelOrder.forEach((level, depth) => {
const value = (member[level] || '').trim();
@@ -1381,8 +1648,8 @@ function renderListViewTable() {
}
if (value !== lastValues[level]) {
const isCollapsed = collapsedUnits.has(key);
const dragAttr = isAdmin ? `draggable="true" ondragstart="handleListGroupDragStart(event, '${jsString(level)}', '${jsString(value)}')" ondragover="event.preventDefault()" ondrop="handleListGroupDrop(event, '${jsString(level)}', '${jsString(value)}')"` : '';
html += `<tr ${dragAttr} class="list-header-row lvl-${depth} ${isCollapsed ? 'collapsed' : ''} ${isAnyParentCollapsed ? 'hidden-row' : ''}"><td colspan="${(isAdmin ? 10 : 9) + 1}" onclick="toggleUnitCollapse('${jsString(level)}', '${jsString(value)}')" style="padding-left: 15px !important;"><span class="collapse-icon">▼</span> ${value}</td></tr>`;
const dragAttr = editable ? `draggable="true" ondragstart="handleListGroupDragStart(event, '${jsString(level)}', '${jsString(value)}')" ondragover="event.preventDefault()" ondrop="handleListGroupDrop(event, '${jsString(level)}', '${jsString(value)}')"` : '';
html += `<tr ${dragAttr} class="list-header-row lvl-${depth} ${isCollapsed ? 'collapsed' : ''} ${isAnyParentCollapsed ? 'hidden-row' : ''}"><td colspan="${groupColumnCount}" onclick="toggleUnitCollapse('${jsString(level)}', '${jsString(value)}')" style="padding-left: 15px !important;"><span class="collapse-icon">▼</span> ${escapeHtml(value)}</td></tr>`;
lastValues[level] = value;
levelOrder.slice(depth + 1).forEach((childLevel) => {
lastValues[childLevel] = '';
@@ -1391,20 +1658,25 @@ function renderListViewTable() {
});
const hidden = levelOrder.some((level) => member[level] && collapsedUnits.has(`${level}_${member[level].trim()}`)) || isAnyParentCollapsed;
const rowDragAttr = isAdmin ? `draggable="true" ondragstart="handleListDragStart(event, ${index})" ondragover="event.preventDefault()" ondrop="handleListDrop(event, ${index})"` : '';
const rowDragAttr = editable ? `draggable="true" ondragstart="handleListDragStart(event, ${index})" ondragover="event.preventDefault()" ondrop="handleListDrop(event, ${index})"` : '';
const actionCell = editable
? `<div class="flex gap-1 justify-center"><span class="list-action-btn btn-edit" onclick="openModal('${member._id}')">수정</span><span class="list-action-btn btn-delete" onclick="deleteMember('${member._id}')">삭제</span></div>`
: inspectable
? `<span class="list-action-btn btn-edit bg-indigo-50 text-indigo-600 border border-indigo-100" onclick="openModal('${member._id}')">조회</span>`
: '<span class="text-slate-300">-</span>';
html += `
<tr id="list-row-${member._id}" ${rowDragAttr} class="${hidden ? 'hidden-row' : ''}">
${isAdmin ? '<td class="text-slate-300 cursor-move">☰</td>' : ''}
<td class="font-black text-slate-700">${member['이름']}</td>
<td>${member['직급'] || '-'}</td>
<td>${member['근무상태'] === '휴직' ? '<span class="text-red-500 font-black">휴직</span>' : (member['직책'] || '-')}</td>
<td>${member['셀'] || '-'}</td>
<td>${member['팀'] || '-'}</td>
<td>${member['디비전'] || '-'}</td>
<td>${member['그룹'] || '-'}</td>
<td>${member['부서'] || '-'}</td>
<td>${member['소속회사'] || '-'}</td>
<td>${isAdmin ? `<div class="flex gap-1 justify-center"><span class="list-action-btn btn-edit" onclick="openModal('${member._id}')">수정</span><span class="list-action-btn btn-delete" onclick="deleteMember('${member._id}')">삭제</span></div>` : `<span class="list-action-btn btn-edit bg-indigo-50 text-indigo-600 border border-indigo-100" onclick="openModal('${member._id}')">조회</span>`}</td>
${editable ? '<td class="text-slate-300 cursor-move">☰</td>' : ''}
<td class="font-black text-slate-700">${escapeHtml(member['이름'] || '-')}</td>
<td>${escapeHtml(member['직급'] || '-')}</td>
<td>${member['근무상태'] === '휴직' ? '<span class="text-red-500 font-black">휴직</span>' : escapeHtml(member['직책'] || '-')}</td>
<td>${escapeHtml(member['셀'] || '-')}</td>
<td>${escapeHtml(member['팀'] || '-')}</td>
<td>${escapeHtml(member['디비전'] || '-')}</td>
<td>${escapeHtml(member['그룹'] || '-')}</td>
<td>${escapeHtml(member['부서'] || '-')}</td>
<td>${escapeHtml(member['소속회사'] || '-')}</td>
<td>${actionCell}</td>
</tr>
`;
});
@@ -1454,15 +1726,14 @@ function handleListSearch(value) {
return;
}
document.querySelectorAll('.list-search-target').forEach((element) => element.classList.remove('list-search-target'));
const targetMember = editingMembers.find((member) => (
(member['이름'] || '').toLowerCase().includes(query)
|| levelOrder.some((level) => (member[level] || '').toLowerCase().includes(query))
const targetEntry = getListSearchEntries().find((entry) => (
entry.values.some((candidate) => String(candidate || '').toLowerCase().includes(query))
));
if (!targetMember) {
if (!targetEntry) {
alert('검색 결과가 없습니다.');
return;
}
const row = document.getElementById(`list-row-${targetMember._id}`);
const row = document.getElementById(targetEntry.rowId);
if (row) {
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
row.classList.add('list-search-target');

56
scripts/prepare_dev_worktree.sh Executable file
View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DEV_DIR="${DEV_DIR:-${ROOT_DIR}/.dev-worktree-8081}"
TARGET_REF="${1:-HEAD}"
FORCE_RECREATE="${FORCE_RECREATE:-0}"
copy_optional_path() {
local rel_path="$1"
local src="${ROOT_DIR}/${rel_path}"
local dst="${DEV_DIR}/${rel_path}"
if [[ ! -e "${src}" ]]; then
return 0
fi
mkdir -p "$(dirname "${dst}")"
cp -a "${src}" "${dst}"
}
if [[ "${DEV_DIR}" == "${ROOT_DIR}" ]]; then
echo "DEV_DIR must not be the same as the production workspace." >&2
exit 1
fi
if [[ -d "${DEV_DIR}/.git" && "${FORCE_RECREATE}" != "1" ]]; then
echo "[1/6] Reusing existing dev workspace at ${DEV_DIR}"
else
echo "[1/6] Removing previous dev workspace at ${DEV_DIR}"
rm -rf "${DEV_DIR}"
echo "[2/6] Cloning production workspace into isolated dev workspace"
git clone --no-hardlinks "${ROOT_DIR}" "${DEV_DIR}" >/dev/null
echo "[3/6] Checking out detached ref ${TARGET_REF}"
git -C "${DEV_DIR}" checkout --detach "${TARGET_REF}" >/dev/null
fi
echo "[4/6] Copying local runtime env when available"
copy_optional_path ".env"
echo "[5/6] Copying local-only incoming design assets when available"
copy_optional_path "incoming-files/1.png"
copy_optional_path "incoming-files/260320.html"
copy_optional_path "incoming-files/sample style.css"
copy_optional_path "incoming-files/seat/center_chair_people_map(2).html"
copy_optional_path "incoming-files/사업관리대장"
echo "[6/6] Dev worktree ready"
echo "Path: ${DEV_DIR}"
echo "Use this to start 8081 from the isolated workspace:"
echo " cd ${DEV_DIR} && docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compose.8081.yml up -d --build"
if [[ "${FORCE_RECREATE}" != "1" ]]; then
echo "To fully rebuild the dev workspace, run:"
echo " FORCE_RECREATE=1 ./scripts/prepare_dev_worktree.sh"
fi

15
scripts/start_8081.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DEV_DIR="${DEV_DIR:-${ROOT_DIR}/.dev-worktree-8081}"
"${ROOT_DIR}/scripts/prepare_dev_worktree.sh"
cd "${DEV_DIR}"
docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compose.8081.yml up -d --build
echo "8081 started from ${DEV_DIR}"
echo "Verify mounts with:"
echo " docker inspect mh-dashboard-organization-dev-backend-1 --format '{{range .Mounts}}{{println .Source \"->\" .Destination}}{{end}}'"

View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
docker compose up -d
"${ROOT_DIR}/scripts/start_8081.sh"
echo "8080: http://localhost:8080"
echo "8081: http://localhost:8081"

View File

@@ -4,7 +4,9 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PROD_DIR="${ROOT_DIR}"
DEV_DIR="${DEV_DIR:-/tmp/mh-dashboard-organization-dev}"
DEV_DIR="${DEV_DIR:-/tmp/mh-dashboard-organization-dev-worktree}"
DEV_PROJECT_NAME="${DEV_PROJECT_NAME:-mh-dashboard-organization-dev}"
DEV_COMPOSE_FILE="${DEV_COMPOSE_FILE:-${DEV_DIR}/docker-compose.8081.yml}"
SCOPE="${1:-minimal}"
if [[ ! -f "${PROD_DIR}/docker-compose.yml" ]]; then
@@ -14,7 +16,13 @@ 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
echo "Set DEV_DIR=/path/to/workspace if the dev workspace moved." >&2
exit 1
fi
if [[ ! -f "${DEV_COMPOSE_FILE}" ]]; then
echo "Development compose file not found: ${DEV_COMPOSE_FILE}" >&2
echo "Set DEV_COMPOSE_FILE=/path/to/dev-compose.yml if the dev compose file moved." >&2
exit 1
fi
@@ -74,21 +82,30 @@ case "${SCOPE}" in
esac
PROD_COMPOSE=(docker compose --project-directory "${PROD_DIR}")
DEV_COMPOSE=(docker compose --project-directory "${DEV_DIR}")
DEV_COMPOSE=(docker compose -p "${DEV_PROJECT_NAME}" --env-file "${DEV_DIR}/.env" -f "${DEV_COMPOSE_FILE}")
run_compose() {
local dir="$1"
shift
(cd "${dir}" && "$@")
}
require_service() {
local dir="$1"
shift
(cd "${dir}" && "$@") >/dev/null
run_compose "${dir}" "$@" >/dev/null
}
echo "[1/6] Checking source and target stacks"
echo "[1/8] 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
echo "[2/8] Ensuring db containers are reachable"
run_compose "${PROD_DIR}" "${PROD_COMPOSE[@]}" exec -T db pg_isready -U "${POSTGRES_USER:-orgapp}" -d "${POSTGRES_DB:-orgdb}" >/dev/null
run_compose "${DEV_DIR}" "${DEV_COMPOSE[@]}" exec -T db pg_isready -U "${POSTGRES_USER:-orgapp}" -d "${POSTGRES_DB:-orgdb}" >/dev/null
echo "[3/8] Pausing 8081 app services to avoid partial reads during sync"
run_compose "${DEV_DIR}" "${DEV_COMPOSE[@]}" stop proxy frontend backend >/dev/null
WORK_DIR="$(mktemp -d)"
cleanup() {
@@ -100,53 +117,54 @@ 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 "[4/8] 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[*]}")"
printf 'TRUNCATE TABLE %s RESTART IDENTITY CASCADE;\n' "$(printf 'public.%s,' "${TABLES[@]}" | sed 's/,$//')"
echo "SET session_replication_role = DEFAULT;"
echo "COMMIT;"
} > "${TRUNCATE_FILE}"
echo "[4/6] Dumping ${SCOPE} data from 8080 source DB"
echo "[5/8] 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 \
run_compose "${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}"
"${TABLE_ARGS[@]}" > "${DUMP_FILE}"
echo "[4.5/6] Exporting seat_positions in portable format"
(cd "${PROD_DIR}" && "${PROD_COMPOSE[@]}" exec -T db \
echo "[5.5/8] Exporting seat_positions in portable format"
run_compose "${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}"
) 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/8] Truncating target tables in 8081 dev DB"
run_compose "${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 "[7/8] Restoring dumped data into 8081 dev DB"
run_compose "${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 \
echo "[7.5/8] Restoring portable seat_positions and rebuilding auth users"
run_compose "${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 \
-c "DELETE FROM public.seat_positions" >/dev/null
run_compose "${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'
-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}"
run_compose "${DEV_DIR}" "${DEV_COMPOSE[@]}" up -d backend >/dev/null
AUTH_SYNC_PY="$(cat <<'PY'
from backend.app.main import get_conn, sync_auth_users_from_members
from backend.app.db import ensure_history_backfill
with get_conn() as conn:
with conn.cursor() as cur:
@@ -160,12 +178,14 @@ with get_conn() as conn:
"""
)
sync_auth_users_from_members(cur)
ensure_history_backfill(cur)
conn.commit()
print("members, seat labels, and auth users synced")
print("members, seat labels, auth users, and history backfill synced")
PY
(cd "${DEV_DIR}" && "${DEV_COMPOSE[@]}" exec -T backend python -) < "${AUTH_SYNC_FILE}"
)"
run_compose "${DEV_DIR}" "${DEV_COMPOSE[@]}" exec -T backend python -c "${AUTH_SYNC_PY}"
echo "[6.8/6] Resetting serial sequences"
echo "[7.8/8] 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);"
@@ -188,11 +208,53 @@ echo "[6.8/6] Resetting serial sequences"
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}"
run_compose "${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}"
VERIFY_SQL="${WORK_DIR}/verify_${SCOPE}.sql"
{
cat <<'SQL'
SELECT 'members' AS table_name, COUNT(*)::text AS value FROM public.members
UNION ALL
SELECT 'member_retirements', COUNT(*)::text FROM public.member_retirements
UNION ALL
SELECT 'seat_maps', COUNT(*)::text FROM public.seat_maps
UNION ALL
SELECT 'seat_slots', COUNT(*)::text FROM public.seat_slots
UNION ALL
SELECT 'seat_positions', COUNT(*)::text FROM public.seat_positions
UNION ALL
SELECT 'members_with_seat_label', COUNT(*)::text FROM public.members WHERE COALESCE(seat_label, '') <> ''
UNION ALL
SELECT 'seat_positions_without_slot', COUNT(*)::text FROM public.seat_positions WHERE seat_slot_id IS NULL
UNION ALL
SELECT 'seat_label_mismatch', COUNT(*)::text
FROM public.members m
JOIN public.seat_positions sp ON sp.member_id = m.id
WHERE COALESCE(m.seat_label, '') <> COALESCE(sp.seat_label, '')
UNION ALL
SELECT 'auth_users', COUNT(*)::text FROM auth.users
ORDER BY table_name;
SQL
if [[ "${SCOPE}" == "analysis" || "${SCOPE}" == "full" ]]; then
cat <<'SQL'
SELECT 'integration_work_logs', COUNT(*)::text FROM public.integration_work_logs
UNION ALL
SELECT 'integration_vouchers', COUNT(*)::text FROM public.integration_vouchers
ORDER BY 1;
SQL
fi
} > "${VERIFY_SQL}"
echo "[8/8] Restarting 8081 app services and printing verification snapshot"
run_compose "${DEV_DIR}" "${DEV_COMPOSE[@]}" up -d frontend proxy >/dev/null
run_compose "${DEV_DIR}" "${DEV_COMPOSE[@]}" exec -T db \
psql -q -v ON_ERROR_STOP=1 -U "${POSTGRES_USER:-orgapp}" -d "${POSTGRES_DB:-orgdb}" -f - < "${VERIFY_SQL}"
echo
echo "Sync complete."
echo "Source: ${PROD_DIR} (8080)"
echo "Target: ${DEV_DIR} (8081)"
echo "Dev compose: ${DEV_COMPOSE_FILE}"
echo "Dev project: ${DEV_PROJECT_NAME}"
echo "Scope : ${SCOPE}"