Compare commits
17 Commits
bc60f932c3
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb5b0f00c2 | ||
|
|
637b390024 | ||
|
|
4b4ffafbd2 | ||
|
|
1cd0f21a36 | ||
|
|
f77be3f482 | ||
|
|
2e8c79bb43 | ||
|
|
8121c9cf41 | ||
|
|
e67fd41cbf | ||
|
|
c9a93ea936 | ||
|
|
8d0cc78abc | ||
|
|
bbebe24763 | ||
|
|
2053791589 | ||
|
|
fc23156b2c | ||
|
|
33f157cb08 | ||
|
|
b735a4cdd1 | ||
|
|
6e55b99e9a | ||
|
|
cbae8769bf |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,3 +16,4 @@ incoming-files/~$*
|
||||
incoming-files/6f.html
|
||||
incoming-files/7f.html
|
||||
incoming-files/center.html
|
||||
.dev-worktree-8081/
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,),
|
||||
)
|
||||
|
||||
@@ -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,13 +1601,14 @@ 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:
|
||||
if as_of is None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT m.id, m.name, m.company, m.rank, m.role, m.department, m.grp, m.division,
|
||||
@@ -1182,6 +1619,8 @@ def fetch_seat_layout(seat_map_id: int) -> dict[str, object]:
|
||||
"""
|
||||
)
|
||||
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,6 +1631,7 @@ def fetch_seat_layout(seat_map_id: int) -> dict[str, object]:
|
||||
(seat_map_id,),
|
||||
)
|
||||
slots = cur.fetchall()
|
||||
if as_of is None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT sp.member_id, sp.row_index, sp.col_index, sp.seat_label,
|
||||
@@ -1207,6 +1647,32 @@ def fetch_seat_layout(seat_map_id: int) -> dict[str, object]:
|
||||
(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]]]:
|
||||
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
78
docker-compose.8081.yml
Normal 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:
|
||||
@@ -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)
|
||||
|
||||
@@ -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 상태를 유지해야 하면 별도 백업 후 실행한다
|
||||
|
||||
@@ -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`를 먼저 확인
|
||||
|
||||
@@ -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
269
docs/WORK_EXECUTION_FLOW.md
Normal 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 유지`, `조직현황 탭`, `프로젝트/팀 탭`
|
||||
@@ -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 문제인지 먼저 구분
|
||||
- 공개용 기준 데이터 검증이 필요한지 판단
|
||||
|
||||
|
||||
100
docs/architecture/8081_SERVING_MAP.md
Normal file
100
docs/architecture/8081_SERVING_MAP.md
Normal 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`의 실제 서빙 파일 위치가 문서와 코드에서 일치한다.
|
||||
- 기존 참고 자산을 지우지 않고도 실제 서빙 경로와 참고 경로를 구분할 수 있다.
|
||||
129
docs/architecture/DESIGN_SSOT.md
Normal file
129
docs/architecture/DESIGN_SSOT.md
Normal 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
|
||||
@@ -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]");
|
||||
|
||||
730
frontend/public/design-patterns.css
Normal file
730
frontend/public/design-patterns.css
Normal 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;
|
||||
}
|
||||
}
|
||||
60
frontend/public/design-tokens.css
Normal file
60
frontend/public/design-tokens.css
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
100
frontend/public/styles-8081-design.css
Normal file
100
frontend/public/styles-8081-design.css
Normal 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);
|
||||
}
|
||||
@@ -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
BIN
incoming-files/1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 748 KiB |
2598
incoming-files/260320.html
Normal file
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
34
incoming-files/README.md
Normal 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
@@ -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>
|
||||
);
|
||||
|
||||
13
incoming-files/reference/README.md
Normal file
13
incoming-files/reference/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Reference Assets
|
||||
|
||||
이 디렉터리는 앞으로 `8081`에서 직접 서빙하지 않는 참고 원본/복구 비교 자산을 모으기 위한 공간이다.
|
||||
|
||||
1차 정리에서는 위험한 대량 이동을 피하기 위해 기존 참고 파일을 즉시 옮기지 않는다.
|
||||
대신 실제 서빙 파일은 `incoming-files/served/`로 고정하고, 다음 차수에서 참고 자산을 단계적으로 재배치한다.
|
||||
|
||||
예상 대상:
|
||||
|
||||
- 원본 HTML/CSS 참고본
|
||||
- 원본 xlsx/csv
|
||||
- 복구 비교용 자산
|
||||
- 디자인 레퍼런스 파일
|
||||
1377
incoming-files/sample style.css
Normal file
1377
incoming-files/sample style.css
Normal file
File diff suppressed because it is too large
Load Diff
931
incoming-files/seat/center_chair_people_map(2).html
Normal file
931
incoming-files/seat/center_chair_people_map(2).html
Normal file
@@ -0,0 +1,931 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>center chair people map</title>
|
||||
<style>
|
||||
:root {
|
||||
--ink: #152330;
|
||||
--muted: #627286;
|
||||
--paper: rgba(255,255,255,0.86);
|
||||
--line: rgba(21,35,48,0.1);
|
||||
--accent: #0f766e;
|
||||
--bg: #edf2f6;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
|
||||
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
|
||||
}
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding: 0;
|
||||
}
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
.panel {
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
button {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 10px 14px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #0f766e, #115e59);
|
||||
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
|
||||
}
|
||||
button.alt {
|
||||
color: var(--ink);
|
||||
background: rgba(255,255,255,0.9);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: none;
|
||||
}
|
||||
.viewer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.viewer-head {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.chip {
|
||||
padding: 10px 12px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255,255,255,0.82);
|
||||
border: 1px solid rgba(255,255,255,0.94);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
|
||||
}
|
||||
.viewer-actions {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 64px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.mapper {
|
||||
position: absolute;
|
||||
top: 76px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: min(94vw, 1320px);
|
||||
max-height: min(56vh, 560px);
|
||||
overflow: hidden;
|
||||
z-index: 4;
|
||||
border-radius: 20px;
|
||||
background: rgba(234, 239, 247, 0.95);
|
||||
border: 1px solid rgba(101, 119, 146, 0.22);
|
||||
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.hidden-off {
|
||||
display: none !important;
|
||||
}
|
||||
.mapper-head {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid rgba(101,119,146,0.18);
|
||||
font-size: 12px;
|
||||
color: #51607a;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
background: rgba(255,255,255,0.6);
|
||||
}
|
||||
.mapper-head strong {
|
||||
display: block;
|
||||
color: #17243b;
|
||||
font-size: 20px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.mapper-head .alt {
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.org-chart {
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.org-top {
|
||||
margin: 0 auto;
|
||||
width: min(100%, 420px);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(67, 84, 118, 0.25);
|
||||
background: #fff;
|
||||
}
|
||||
.org-top-title {
|
||||
background: #1e2f4d;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-size: 34px;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
padding: 16px 12px;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.org-top-members {
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
background: rgba(255,255,255,0.95);
|
||||
}
|
||||
.org-teams {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
.org-team {
|
||||
border: 1px solid rgba(110, 126, 152, 0.25);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: rgba(255,255,255,0.95);
|
||||
min-width: 0;
|
||||
}
|
||||
.org-team h4 {
|
||||
margin: 0;
|
||||
padding: 9px 10px;
|
||||
font-size: 14px;
|
||||
color: #21324e;
|
||||
font-weight: 800;
|
||||
border-bottom: 1px solid rgba(110, 126, 152, 0.2);
|
||||
background: rgba(240, 245, 252, 0.96);
|
||||
}
|
||||
.org-members {
|
||||
padding: 7px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.org-person {
|
||||
border: 1px solid rgba(116, 133, 161, 0.25);
|
||||
background: rgba(255,255,255,0.95);
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, border-color 120ms ease;
|
||||
min-width: 0;
|
||||
}
|
||||
.org-person.active {
|
||||
border-color: rgba(15,118,110,0.6);
|
||||
background: rgba(15,118,110,0.11);
|
||||
}
|
||||
.org-person.assigned {
|
||||
border-color: rgba(37,99,235,0.5);
|
||||
background: rgba(37,99,235,0.1);
|
||||
}
|
||||
.org-person strong {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
color: #15233a;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.org-person small {
|
||||
display: block;
|
||||
color: #5a6a86;
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.mapper {
|
||||
top: 72px;
|
||||
width: min(96vw, 920px);
|
||||
max-height: 58vh;
|
||||
}
|
||||
.viewer-actions {
|
||||
top: 64px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.mapper-head strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
.org-top-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
.org-teams {
|
||||
grid-template-columns: repeat(3, minmax(150px, 1fr));
|
||||
}
|
||||
}
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
cursor: grab;
|
||||
}
|
||||
canvas.dragging { cursor: grabbing; }
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
min-width: 170px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(17,24,39,0.94);
|
||||
color: white;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translate(12px, 12px);
|
||||
transition: opacity 120ms ease;
|
||||
z-index: 3;
|
||||
}
|
||||
.tooltip.visible { opacity: 1; }
|
||||
.tooltip strong { display: block; margin-bottom: 6px; font-size: 14px; }
|
||||
.tooltip div { font-size: 12px; line-height: 1.45; color: rgba(255,255,255,0.82); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="shell">
|
||||
<main class="panel viewer">
|
||||
<div class="viewer-head">
|
||||
<div class="chip" id="scale-chip"></div>
|
||||
<div class="chip" id="hover-chip">chair hover: none</div>
|
||||
</div>
|
||||
<div class="viewer-actions">
|
||||
<button type="button" id="fit-btn">전체 맞춤</button>
|
||||
<button type="button" class="alt" id="clear-btn">선택 지우기</button>
|
||||
</div>
|
||||
<aside class="mapper hidden-off">
|
||||
<div class="mapper-head">
|
||||
<div id="mapper-status">
|
||||
<strong>조직 현황</strong>
|
||||
<span>선택 인원 없음</span>
|
||||
</div>
|
||||
<button type="button" class="alt" id="clear-assign-btn">매칭 초기화</button>
|
||||
</div>
|
||||
<div class="org-chart" id="org-chart"></div>
|
||||
</aside>
|
||||
<canvas id="canvas"></canvas>
|
||||
<div class="tooltip" id="tooltip"></div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="./center_chair_people_payload.js?v=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
14
incoming-files/served/README.md
Normal file
14
incoming-files/served/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Served Assets
|
||||
|
||||
이 디렉터리는 `8081`에서 실제 URL 응답으로 직접 서빙되는 integration HTML 파일만 둔다.
|
||||
|
||||
현재 사용 중:
|
||||
|
||||
- `payment.html`
|
||||
- `mh.html`
|
||||
|
||||
규칙:
|
||||
|
||||
- `/integrations/payment` 는 이 디렉터리의 `payment.html`을 읽는다.
|
||||
- `/integrations/mh` 는 이 디렉터리의 `mh.html`을 읽는다.
|
||||
- 원본 참고 파일이나 비교용 파일은 이 디렉터리에 두지 않는다.
|
||||
3472
incoming-files/served/mh.html
Normal file
3472
incoming-files/served/mh.html
Normal file
File diff suppressed because it is too large
Load Diff
1622
incoming-files/served/payment.html
Normal file
1622
incoming-files/served/payment.html
Normal file
File diff suppressed because it is too large
Load Diff
1377
incoming-files/사업관리대장/MH 통합 대시보드_260320.css
Normal file
1377
incoming-files/사업관리대장/MH 통합 대시보드_260320.css
Normal file
File diff suppressed because it is too large
Load Diff
2598
incoming-files/사업관리대장/MH 통합 대시보드_260320.html
Normal file
2598
incoming-files/사업관리대장/MH 통합 대시보드_260320.html
Normal file
File diff suppressed because one or more lines are too long
328
incoming-files/사업관리대장/ledger-override.css
Normal file
328
incoming-files/사업관리대장/ledger-override.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
498
incoming-files/사업관리대장/ledger-override.js
Normal file
498
incoming-files/사업관리대장/ledger-override.js
Normal 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);
|
||||
});
|
||||
})();
|
||||
BIN
incoming-files/사업관리대장/사업관리대장-1.xlsx
Normal file
BIN
incoming-files/사업관리대장/사업관리대장-1.xlsx
Normal file
Binary file not shown.
1377
incoming-files/사업관리대장/사업관리대장/MH 통합 대시보드_260320.css
Normal file
1377
incoming-files/사업관리대장/사업관리대장/MH 통합 대시보드_260320.css
Normal file
File diff suppressed because it is too large
Load Diff
2598
incoming-files/사업관리대장/사업관리대장/MH 통합 대시보드_260320.html
Normal file
2598
incoming-files/사업관리대장/사업관리대장/MH 통합 대시보드_260320.html
Normal file
File diff suppressed because one or more lines are too long
BIN
incoming-files/사업관리대장/사업관리대장/사업관리대장-1.xlsx
Normal file
BIN
incoming-files/사업관리대장/사업관리대장/사업관리대장-1.xlsx
Normal file
Binary file not shown.
@@ -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
@@ -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-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 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-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 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-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 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>
|
||||
<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
56
scripts/prepare_dev_worktree.sh
Executable 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
15
scripts/start_8081.sh
Executable 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}}'"
|
||||
12
scripts/start_local_dashboards.sh
Executable file
12
scripts/start_local_dashboards.sh
Executable 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"
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user