feat: update seat map dxf workflow and organization ui

This commit is contained in:
hyunho
2026-03-25 18:00:09 +09:00
parent 8f073e1458
commit e62a6a5458
27 changed files with 2517660 additions and 125 deletions
-2
View File
@@ -3,6 +3,4 @@ POSTGRES_USER=orgapp
POSTGRES_PASSWORD=change-me
DATABASE_URL=postgresql://orgapp:change-me@db:5432/orgdb
UPLOAD_DIR=/data/uploads
SNAPSHOT_DIR=/data/snapshots
MOCK_LOGIN_ENABLED=true
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

+722346
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
+707538
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
+4 -4
View File
@@ -8,8 +8,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap" rel="stylesheet" />
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/legacy/static/common.css" />
<link rel="stylesheet" href="/legacy/static/organization.css" />
<link rel="stylesheet" href="/legacy/static/common.css?v=20260325-9" />
<link rel="stylesheet" href="/legacy/static/organization.css?v=20260325-9" />
</head>
<body>
<input type="file" id="upload-excel" class="hidden" accept=".xlsx, .csv" />
@@ -28,7 +28,7 @@
<div id="stats-area" class="stats-section" style="padding: 10px 15px;">
<div class="flex justify-between items-center mb-0 cursor-pointer p-0" id="stats-header">
<h2 class="text-xs font-black text-slate-800 flex items-center gap-2">인원 현황 통계 <span id="total-count-badge" class="bg-indigo-100 text-indigo-600 text-[10px] px-2 py-0.5 rounded-full">0명</span></h2>
<h2 class="stats-title text-xs font-black text-slate-800 flex items-center gap-2">인원 현황 통계 <span id="total-count-badge" class="bg-indigo-100 text-indigo-600 text-[10px] px-2 py-0.5 rounded-full">0명</span></h2>
<span id="stats-toggle-icon" class="text-slate-400 text-xs transition-transform duration-200" style="transform: rotate(-90deg);"></span>
</div>
<div id="stats-table-container" class="mt-3 overflow-hidden transition-all duration-300" style="display: none;"></div>
@@ -60,6 +60,6 @@
</div>
</div>
<script src="/legacy/static/organization.js"></script>
<script src="/legacy/static/organization.js?v=20260325-9"></script>
</body>
</html>
-2
View File
@@ -5,10 +5,8 @@ import os
BASE_DIR = Path("/app")
LEGACY_DIR = BASE_DIR / "legacy"
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "/data/uploads"))
SNAPSHOT_DIR = Path(os.getenv("SNAPSHOT_DIR", "/data/snapshots"))
DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql://orgapp:change-me@db:5432/orgdb",
)
MOCK_LOGIN_ENABLED = os.getenv("MOCK_LOGIN_ENABLED", "true").lower() == "true"
+49 -3
View File
@@ -121,8 +121,10 @@ LEGACY_HEADER_MAP = {
"근무시간": "work_time",
"work_time": "work_time",
"전화번호": "phone",
"ph": "phone",
"phone": "phone",
"이메일": "email",
"mail": "email",
"email": "email",
"자리위치": "seat_label",
"seat_label": "seat_label",
@@ -131,6 +133,20 @@ LEGACY_HEADER_MAP = {
}
def normalize_phone(value: object) -> str:
raw = str(value or "").strip()
digits = "".join(ch for ch in raw if ch.isdigit())
if not digits:
return ""
if len(digits) == 10 and not digits.startswith("0"):
digits = f"0{digits}"
if len(digits) == 11 and digits.startswith("0"):
return f"{digits[:3]}-{digits[3:7]}-{digits[7:]}"
if len(digits) == 10 and digits.startswith("0"):
return f"{digits[:3]}-{digits[3:6]}-{digits[6:]}"
return raw
def serialize_member_payload(item: MemberPayload, sort_order: int) -> tuple[object, ...]:
return (
item.name.strip(),
@@ -145,7 +161,7 @@ def serialize_member_payload(item: MemberPayload, sort_order: int) -> tuple[obje
item.cell.strip(),
item.work_status.strip(),
item.work_time.strip(),
item.phone.strip(),
normalize_phone(item.phone),
item.email.strip(),
item.seat_label.strip(),
item.photo_url.strip(),
@@ -385,6 +401,33 @@ def compute_focus_bounds(slot_points: list[tuple[float, float]]) -> tuple[float,
return (min_x - pad_x, min_y - pad_y, max_x + pad_x, max_y + pad_y)
def get_entity_max_span(entity: ezdxf.entities.DXFGraphic) -> float:
bounds = get_entity_bounds(entity)
if bounds is None:
return 0.0
min_x, min_y, max_x, max_y = bounds
return max(max_x - min_x, max_y - min_y)
def compute_outline_bounds(entities: list[ezdxf.entities.DXFGraphic]) -> tuple[float, float, float, float] | None:
outline_layers = {"0", "0-COL", "WID", "XH", "CO-DOOR", "CO-DO-FR", "", "회의실"}
outline_points: list[tuple[float, float]] = []
for entity in entities:
if is_chair_layer(entity.dxf.layer):
continue
if entity.dxf.layer not in outline_layers:
continue
if get_entity_max_span(entity) < 3000:
continue
outline_points.extend(get_entity_points(entity))
if not outline_points:
return None
min_x, min_y, width, height = compute_bounds_from_points(outline_points)
pad_x = max(width * 0.025, 300.0)
pad_y = max(height * 0.025, 300.0)
return (min_x - pad_x, min_y - pad_y, min_x + width + pad_x, min_y + height + pad_y)
def bounds_intersect(bounds: tuple[float, float, float, float], focus_bounds: tuple[float, float, float, float]) -> bool:
min_x, min_y, max_x, max_y = bounds
focus_min_x, focus_min_y, focus_max_x, focus_max_y = focus_bounds
@@ -537,7 +580,7 @@ def parse_dxf_layout(file_path: Path) -> tuple[dict[str, object], list[dict[str,
raise HTTPException(status_code=400, detail="chair 레이어에서 좌석 위치를 추출하지 못했습니다.")
slot_points = [(float(slot["x"]), float(slot["y"])) for slot in slots]
focus_bounds = compute_focus_bounds(slot_points)
focus_bounds = compute_outline_bounds(all_entities) or compute_focus_bounds(slot_points)
visible_entities: list[ezdxf.entities.DXFGraphic] = []
visible_points: list[tuple[float, float]] = []
for entity in all_entities:
@@ -765,7 +808,10 @@ def rows_to_member_payloads(rows: list[list[object]]) -> list[MemberPayload]:
mapped = LEGACY_HEADER_MAP.get(header)
if not mapped:
continue
record[mapped] = str(row[col_idx] if col_idx < len(row) and row[col_idx] is not None else "").strip()
value = str(row[col_idx] if col_idx < len(row) and row[col_idx] is not None else "").strip()
if mapped == "phone":
value = normalize_phone(value)
record[mapped] = value
if not str(record.get("name", "")).strip():
continue
payloads.append(MemberPayload(**record))
+1
View File
@@ -3,3 +3,4 @@ uvicorn[standard]==0.35.0
psycopg[binary]==3.2.9
python-multipart==0.0.20
openpyxl==3.1.5
ezdxf==1.3.5
+521474
View File
File diff suppressed because it is too large Load Diff
+564192
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+7 -2
View File
@@ -22,6 +22,8 @@ services:
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"]
@@ -34,14 +36,18 @@ services:
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
- uploads_data:/data/uploads
- snapshots_data:/data/snapshots
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"]
@@ -69,4 +75,3 @@ services:
volumes:
postgres_data:
uploads_data:
snapshots_data:
+5 -9
View File
@@ -8,13 +8,12 @@
## 2. 이 프로젝트의 권장 구성
- `proxy`: 사내 접속용 단일 진입점 역할을 하는 Nginx 리버스 프록시
- `frontend`: 화면상 로그인과 허브 화면을 제공하는 정적 프론트엔드
- `backend`: 구성원 데이터, 이미지 업로드, 스냅샷 생성을 처리하는 FastAPI 서버
- `backend`: 구성원 데이터 이미지 업로드 처리하는 FastAPI 서버
- `db`: 영구 저장을 담당하는 PostgreSQL 데이터베이스
## 3. 왜 이 구조가 지금 프로젝트에 맞는가
- 기존 조직도 HTML 화면을 그대로 레거시 모듈로 유지할 수 있습니다.
- 프로필 사진 업로드를 서버에 저장할 수 있습니다.
- 월말 조직 데이터 스냅샷을 서버에서 생성하고 보관할 수 있습니다.
- 요청하신 대로 로그인은 우선 화면상으로만 구현해 둘 수 있습니다.
## 4. Ubuntu 서버 준비
@@ -56,17 +55,16 @@
## 7. 현재 단계의 데이터 및 백업 정책
- 데이터베이스: PostgreSQL 볼륨 `postgres_data`
- 업로드 파일: Docker 볼륨 `uploads_data`
- 월말 스냅샷 파일: Docker 볼륨 `snapshots_data`
- 백업 주기: 월말 스냅샷 생성 + DB 볼륨 백업
- 백업 주기: DB 볼륨 백업
- 복구 기준: 아직 정해지지 않았으므로, 우선은 수동 복구 절차를 먼저 문서화하고 이후에 기준을 구체화합니다.
## 8. 현재 구조의 한계
- 로그인은 화면상 동작만 구현되어 있고, 아직 백엔드 보호 기능은 없습니다.
- 레거시 조직도 화면은 현재 DB 기반 API를 사용하도록 전환했지만, 운영 환경에서 전체 업로드/재기동/스냅샷 흐름 검증이 추가로 필요합니다.
- 레거시 조직도 화면은 현재 DB 기반 API를 사용하도록 전환했지만, 운영 환경에서 전체 업로드/재기동 흐름 검증이 추가로 필요합니다.
- 레거시 화면은 CDN 자산을 사용합니다. 사내망이 외부 인터넷 접속을 막는 환경이라면 추후 로컬 자산으로 바꿔야 합니다.
## 9. 다음 구현 권장 순서
1. Docker Compose 기준 운영 검증과 스냅샷 검증을 완료합니다.
1. Docker Compose 기준 운영 검증을 완료합니다.
2. 4개 기능 통합 대시보드 프레임과 공통 헤더를 준비합니다.
3. 프로필 사진 업로드 UI를 `/api/uploads/profile-photo` 와 연결합니다.
4. 사무실 자리배치 좌표 저장 기능을 추가합니다.
@@ -81,6 +79,4 @@
## 11. 운영 검증 체크포인트
- 엑셀 또는 CSV 업로드 후 `GET /api/members` 에서 데이터가 조회되는지 확인합니다.
- `docker compose restart backend proxy` 이후에도 데이터가 유지되는지 확인합니다.
- `POST /api/snapshots/monthly` 호출 시 `YYYY-MM` 형식만 허용되는지 확인합니다.
- 같은 월에 대해 중복 스냅샷 생성 시 409 에러가 반환되는지 확인합니다.
- `docker compose down` 후 다시 `up -d` 했을 때 DB/업로드/스냅샷 데이터가 유지되는지 확인합니다.
- `docker compose down` 후 다시 `up -d` 했을 때 DB/업로드 데이터가 유지되는지 확인합니다.
+1 -16
View File
@@ -12,7 +12,6 @@
- `status``ok`
- `checks.database``true`
- `checks.upload_dir``true`
- `checks.snapshot_dir``true`
## 3. 초기 데이터 업로드
- 조직도 화면에서 `.xlsx` 또는 `.csv` 업로드
@@ -27,21 +26,7 @@
- 확인 기준:
- 업로드했던 데이터가 그대로 유지됨
## 5. 스냅샷 검증
- `curl -X POST -F snapshot_month=2026-03 http://localhost:8080/api/snapshots/monthly`
- 확인 기준:
- CSV 파일 경로가 반환됨
- `/snapshots/...` 다운로드 가능
## 6. 중복/형식 오류 검증
- 같은 월로 다시 스냅샷 생성
- 확인 기준:
- 409 에러 반환
- 잘못된 형식으로 스냅샷 생성 예: `202603`
- 확인 기준:
- 400 에러 반환
## 7. 종료 후 재기동 확인
## 5. 종료 후 재기동 확인
- `docker compose down`
- `docker compose up -d`
- 확인 기준:
Executable → Regular
+714 -12
View File
@@ -11,13 +11,55 @@ const currentViewTitle = document.getElementById("current-view-title");
const navButtons = Array.from(document.querySelectorAll(".header-center [data-view]"));
const organizationFrame = document.getElementById("organization-frame");
const organizationStage = document.getElementById("organization-stage");
const seatMapStage = document.getElementById("seatmap-stage");
const emptyStage = document.getElementById("empty-stage");
const seatMapName = document.getElementById("seatmap-name");
const seatMapStatus = document.getElementById("seatmap-status");
const seatMapSaveBtn = document.getElementById("seatmap-save-btn");
const seatMapCancelBtn = document.getElementById("seatmap-cancel-btn");
const seatMapBoardWrap = document.getElementById("seatmap-board-wrap");
const seatMapBoard = document.getElementById("seatmap-board");
const seatMapEmpty = document.getElementById("seatmap-empty");
const seatMapSettingsPanel = document.getElementById("seatmap-settings-panel");
const seatMapSettingsForm = document.getElementById("seatmap-settings-form");
const seatMapFormName = document.getElementById("seatmap-form-name");
const seatMapFileName = document.getElementById("seatmap-file-name");
const seatMapFormRows = document.getElementById("seatmap-form-rows");
const seatMapFormCols = document.getElementById("seatmap-form-cols");
const seatMapFormGap = document.getElementById("seatmap-form-gap");
const seatMapFormImage = document.getElementById("seatmap-form-image");
const seatMapSearch = document.getElementById("seatmap-search");
const seatMapUnassigned = document.getElementById("seatmap-unassigned");
const viewLabels = {
ledger: "사업관리대장",
project: "프로젝트별 분석",
team: "팀/개인별 분석",
organization: "조직 현황",
seatmap: "조직 현황",
};
const seatMapState = {
loaded: false,
loading: false,
seatMap: null,
members: [],
slots: [],
placements: [],
draftPlacements: [],
editMode: false,
dirty: false,
search: "",
status: "",
statusTone: "info",
draggingMemberId: null,
zoom: 1,
panning: false,
panStartX: 0,
panStartY: 0,
panScrollLeft: 0,
panScrollTop: 0,
};
let currentView = "organization";
@@ -46,31 +88,588 @@ function toggleUserPopover() {
userPopover?.classList.toggle("hidden");
}
function isAdmin() {
return getSession()?.user?.role === "admin";
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function clonePlacements(items) {
return items.map((item) => ({
member_id: Number(item.member_id),
seat_slot_id: item.seat_slot_id == null ? null : Number(item.seat_slot_id),
row_index: Number(item.row_index),
col_index: Number(item.col_index),
seat_label: item.seat_label || "",
}));
}
function computeSeatLabel(rowIndex, colIndex) {
let quotient = rowIndex;
let rowLabel = "";
while (true) {
const remainder = quotient % 26;
rowLabel = String.fromCharCode(65 + remainder) + rowLabel;
quotient = Math.floor(quotient / 26);
if (quotient === 0) break;
quotient -= 1;
}
return `${rowLabel}-${String(colIndex + 1).padStart(2, "0")}`;
}
function getInitials(name) {
const trimmed = String(name || "").trim();
if (!trimmed) return "?";
return trimmed.slice(0, 2).toUpperCase();
}
function getPlacementSource() {
return seatMapState.editMode ? seatMapState.draftPlacements : seatMapState.placements;
}
function setSeatMapStatus(message, tone = "info") {
seatMapState.status = message || "";
seatMapState.statusTone = tone;
if (seatMapStatus) {
seatMapStatus.textContent = seatMapState.status;
seatMapStatus.dataset.tone = seatMapState.statusTone;
}
}
function resetSeatMapDraft() {
seatMapState.draftPlacements = clonePlacements(seatMapState.placements);
seatMapState.dirty = false;
}
function clampSeatMapZoom(nextZoom) {
return Math.min(3, Math.max(0.5, Number(nextZoom.toFixed(2))));
}
function setSeatMapZoom(nextZoom) {
seatMapState.zoom = clampSeatMapZoom(nextZoom);
renderSeatMap();
}
function getSeatSlotMap() {
return new Map((seatMapState.slots || []).map((slot) => [Number(slot.id), slot]));
}
function getMemberMap() {
return new Map(seatMapState.members.map((member) => [Number(member.id), member]));
}
function getUnassignedMembers() {
const placedIds = new Set(getPlacementSource().map((item) => Number(item.member_id)));
const keyword = seatMapState.search.trim().toLowerCase();
return seatMapState.members.filter((member) => {
if (placedIds.has(Number(member.id))) return false;
if (!keyword) return true;
const haystack = `${member.name || ""} ${member.department || ""} ${member.team || ""}`.toLowerCase();
return haystack.includes(keyword);
});
}
function getCellPlacementMap() {
const cellMap = new Map();
getPlacementSource().forEach((item) => {
cellMap.set(`${item.row_index}:${item.col_index}`, item);
});
return cellMap;
}
function getSlotPlacementMap() {
const slotMap = new Map();
getPlacementSource().forEach((item) => {
if (item.seat_slot_id != null) {
slotMap.set(Number(item.seat_slot_id), item);
}
});
return slotMap;
}
function upsertDraftPlacement(memberId, rowIndex, colIndex) {
const cellMap = getCellPlacementMap();
const existing = cellMap.get(`${rowIndex}:${colIndex}`);
if (existing && Number(existing.member_id) !== Number(memberId)) {
setSeatMapStatus("이미 사용 중인 칸입니다. 빈 칸으로 이동해주세요.", "error");
return;
}
const nextPlacements = seatMapState.draftPlacements.filter((item) => Number(item.member_id) !== Number(memberId));
nextPlacements.push({
member_id: Number(memberId),
row_index: Number(rowIndex),
col_index: Number(colIndex),
seat_label: computeSeatLabel(rowIndex, colIndex),
});
seatMapState.draftPlacements = nextPlacements.sort((left, right) => {
if (left.row_index !== right.row_index) return left.row_index - right.row_index;
return left.col_index - right.col_index;
});
seatMapState.dirty = true;
setSeatMapStatus("배치를 수정했습니다. 저장 버튼으로 반영하세요.", "info");
}
function upsertDraftPlacementForSlot(memberId, seatSlotId) {
const placementMap = getSlotPlacementMap();
const existing = placementMap.get(Number(seatSlotId));
if (existing && Number(existing.member_id) !== Number(memberId)) {
setSeatMapStatus("이미 사용 중인 좌석입니다. 빈 좌석으로 이동해주세요.", "error");
return;
}
const seatSlot = getSeatSlotMap().get(Number(seatSlotId));
const nextPlacements = seatMapState.draftPlacements.filter((item) => Number(item.member_id) !== Number(memberId));
nextPlacements.push({
member_id: Number(memberId),
seat_slot_id: Number(seatSlotId),
row_index: 0,
col_index: 0,
seat_label: seatSlot?.label || `SLOT-${seatSlotId}`,
});
seatMapState.draftPlacements = nextPlacements;
seatMapState.dirty = true;
setSeatMapStatus("배치를 수정했습니다. 저장 버튼으로 반영하세요.", "info");
}
function removeDraftPlacement(memberId) {
const before = seatMapState.draftPlacements.length;
seatMapState.draftPlacements = seatMapState.draftPlacements.filter((item) => Number(item.member_id) !== Number(memberId));
if (seatMapState.draftPlacements.length !== before) {
seatMapState.dirty = true;
setSeatMapStatus("구성원을 미배치 목록으로 이동했습니다. 저장 버튼으로 반영하세요.", "info");
}
}
function renderMemberCard(member, draggable) {
const photoUrl = member.photo_url ? escapeHtml(member.photo_url) : "";
const avatar = photoUrl
? `<span class="seatmap-member-avatar"><img src="${photoUrl}" alt="${escapeHtml(member.name)}"></span>`
: `<span class="seatmap-member-avatar seatmap-member-avatar-fallback">${escapeHtml(getInitials(member.name))}</span>`;
return `
<div class="seatmap-member-card${draggable ? " draggable" : ""}" draggable="${draggable}" data-member-id="${Number(member.id)}">
${avatar}
<span class="seatmap-member-text">
<strong>${escapeHtml(member.name || "-")}</strong>
<em>${escapeHtml(member.department || member.team || member.rank || "-")}</em>
</span>
</div>
`;
}
function renderUnassignedMemberCard(member, draggable) {
return `
<div class="seatmap-member-card seatmap-member-card-compact${draggable ? " draggable" : ""}" draggable="${draggable}" data-member-id="${Number(member.id)}">
<span class="seatmap-member-text seatmap-member-text-inline">
<strong>${escapeHtml(member.name || "-")}</strong>
<em>${escapeHtml(member.rank || "-")}</em>
</span>
</div>
`;
}
function renderSeatMapBoard() {
if (!seatMapBoard || !seatMapState.seatMap) return;
if (seatMapState.seatMap.source_type === "dxf") {
renderDxfSeatMapBoard();
return;
}
const memberMap = getMemberMap();
const placementMap = getCellPlacementMap();
const rows = Number(seatMapState.seatMap.grid_rows || 0);
const cols = Number(seatMapState.seatMap.grid_cols || 0);
const gap = Number(seatMapState.seatMap.cell_gap || 0);
const editable = seatMapState.editMode && isAdmin();
const cells = [];
for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) {
for (let colIndex = 0; colIndex < cols; colIndex += 1) {
const key = `${rowIndex}:${colIndex}`;
const placement = placementMap.get(key);
const member = placement ? memberMap.get(Number(placement.member_id)) : null;
cells.push(`
<div class="seatmap-cell${placement ? " occupied" : ""}${editable ? " editable" : ""}" data-row="${rowIndex}" data-col="${colIndex}">
<span class="seatmap-cell-label">${escapeHtml(computeSeatLabel(rowIndex, colIndex))}</span>
${member ? renderMemberCard(member, editable) : ""}
</div>
`);
}
}
seatMapBoard.innerHTML = `
<div class="seatmap-canvas" style="--seatmap-rows:${rows}; --seatmap-cols:${cols}; --seatmap-gap:${gap}px;">
<img class="seatmap-image" src="${escapeHtml(seatMapState.seatMap.image_url)}" alt="${escapeHtml(seatMapState.seatMap.name)}">
<div class="seatmap-grid">${cells.join("")}</div>
</div>
`;
}
function renderDxfSeatMapBoard() {
if (!seatMapBoard || !seatMapState.seatMap) return;
const memberMap = getMemberMap();
const placementMap = getSlotPlacementMap();
const slots = Array.isArray(seatMapState.slots) ? seatMapState.slots : [];
const editable = seatMapState.editMode && isAdmin();
const minX = Number(seatMapState.seatMap.view_box_min_x || 0);
const minY = Number(seatMapState.seatMap.view_box_min_y || 0);
const width = Number(seatMapState.seatMap.view_box_width || 1);
const height = Number(seatMapState.seatMap.view_box_height || 1);
const previewSvg = seatMapState.seatMap.preview_svg || "";
const slotHtml = slots
.map((slot) => {
const slotId = Number(slot.id);
const placement = placementMap.get(slotId);
const member = placement ? memberMap.get(Number(placement.member_id)) : null;
if (!member && !editable) {
return "";
}
const left = ((Number(slot.x) - minX) / width) * 100;
const top = (1 - (Number(slot.y) - minY) / height) * 100;
return `
<div class="seatmap-slot${placement ? " occupied" : ""}${editable ? " editable" : ""}${!member ? " empty" : ""}" data-slot-id="${slotId}" style="left:${left}%; top:${top}%;">
${member ? renderMemberCard(member, editable) : ""}
</div>
`;
})
.join("");
seatMapBoard.innerHTML = `
<div class="seatmap-dxf-canvas">
<div class="seatmap-dxf-stage" style="transform: scale(${seatMapState.zoom}); --seatmap-zoom:${seatMapState.zoom};">
<div class="seatmap-dxf-preview">${previewSvg}</div>
<div class="seatmap-dxf-slots">${slotHtml}</div>
</div>
</div>
`;
}
function renderUnassignedMembers() {
if (!seatMapUnassigned) return;
const editable = seatMapState.editMode && isAdmin();
const members = getUnassignedMembers();
if (!members.length) {
seatMapUnassigned.innerHTML = `
<div class="seatmap-list-empty">
${seatMapState.search ? "검색 결과가 없습니다." : "미배치 인원이 없습니다."}
</div>
`;
return;
}
seatMapUnassigned.innerHTML = members.map((member) => renderUnassignedMemberCard(member, editable)).join("");
}
function renderSeatMapEmpty() {
if (!seatMapEmpty) return;
if (seatMapState.seatMap) {
seatMapEmpty.classList.add("hidden");
seatMapEmpty.innerHTML = "";
return;
}
seatMapEmpty.classList.remove("hidden");
seatMapEmpty.innerHTML = `
<div class="seatmap-empty-card">
<strong>등록된 자리배치도가 없습니다.</strong>
<p>${isAdmin() ? "오른쪽 설정 패널에서 이미지와 그리드를 등록하세요." : "관리자에게 자리배치도 등록을 요청하세요."}</p>
</div>
`;
}
function syncSeatMapSettingsForm() {
if (!seatMapSettingsForm) return;
if (seatMapFormName) {
seatMapFormName.value = seatMapState.seatMap?.name || "";
}
if (seatMapFormRows) {
seatMapFormRows.value = seatMapState.seatMap?.grid_rows || 12;
}
if (seatMapFormCols) {
seatMapFormCols.value = seatMapState.seatMap?.grid_cols || 24;
}
if (seatMapFormGap) {
seatMapFormGap.value = seatMapState.seatMap?.cell_gap ?? 2;
}
if (seatMapFormImage) {
seatMapFormImage.value = "";
}
if (seatMapFileName) {
seatMapFileName.textContent = "선택된 파일 없음";
}
}
function renderSeatMap() {
const hasSeatMap = Boolean(seatMapState.seatMap);
const admin = isAdmin();
if (seatMapName) {
seatMapName.textContent = hasSeatMap ? seatMapState.seatMap.name : "자리배치도";
}
if (seatMapStatus) {
seatMapStatus.textContent = seatMapState.status;
seatMapStatus.dataset.tone = seatMapState.statusTone;
}
if (seatMapSettingsPanel) {
seatMapSettingsPanel.classList.toggle("hidden", !admin);
}
if (seatMapSaveBtn) {
seatMapSaveBtn.hidden = !admin || !hasSeatMap;
seatMapSaveBtn.disabled = !seatMapState.dirty;
}
if (seatMapCancelBtn) {
seatMapCancelBtn.hidden = !hasSeatMap;
}
if (seatMapSettingsForm) {
seatMapSettingsForm.querySelector("button[type='submit']").textContent = hasSeatMap ? "배치도 저장" : "배치도 생성";
}
renderSeatMapEmpty();
if (seatMapBoardWrap) {
seatMapBoardWrap.classList.toggle("hidden", !hasSeatMap);
}
if (hasSeatMap) {
renderSeatMapBoard();
} else if (seatMapBoard) {
seatMapBoard.innerHTML = "";
}
renderUnassignedMembers();
}
function handleEmbeddedNavigationMessage(event) {
const data = event.data;
if (!data || typeof data !== "object") return;
if (data.type === "open-seatmap" && isAdmin()) {
hideUserPopover();
setActiveView("seatmap");
}
if (data.type === "open-organization") {
hideUserPopover();
setActiveView("organization");
}
}
async function fetchJson(url, options) {
const response = await fetch(url, options);
let payload = null;
try {
payload = await response.json();
} catch {
payload = null;
}
if (!response.ok) {
const message = payload?.detail || "요청 처리에 실패했습니다.";
const error = new Error(message);
error.status = response.status;
throw error;
}
return payload;
}
async function loadSeatMapData(force = false) {
if (seatMapState.loading || (seatMapState.loaded && !force)) return;
seatMapState.loading = true;
setSeatMapStatus("자리배치도를 불러오는 중입니다.", "info");
renderSeatMap();
try {
const activePayload = await fetchJson("/api/seat-maps/active");
const activeSeatMap = activePayload.item;
const layoutPayload = await fetchJson(`/api/seat-maps/${activeSeatMap.id}/layout`);
seatMapState.seatMap = layoutPayload.seat_map;
seatMapState.members = Array.isArray(layoutPayload.members) ? layoutPayload.members : [];
seatMapState.slots = Array.isArray(layoutPayload.slots) ? layoutPayload.slots : [];
seatMapState.placements = clonePlacements(layoutPayload.placements || []);
seatMapState.zoom = 1;
seatMapState.editMode = isAdmin();
resetSeatMapDraft();
seatMapState.loaded = true;
setSeatMapStatus(isAdmin() ? "구성원을 바로 드래그해서 배치한 뒤 저장하세요." : "자리배치도를 불러왔습니다.", "success");
syncSeatMapSettingsForm();
} catch (error) {
if (error.status === 404) {
seatMapState.seatMap = null;
seatMapState.members = [];
seatMapState.slots = [];
seatMapState.placements = [];
seatMapState.zoom = 1;
seatMapState.editMode = isAdmin();
resetSeatMapDraft();
seatMapState.loaded = true;
setSeatMapStatus("활성화된 자리배치도가 없습니다.", "info");
syncSeatMapSettingsForm();
} else {
setSeatMapStatus(error.message || "자리배치도 조회에 실패했습니다.", "error");
}
} finally {
seatMapState.loading = false;
renderSeatMap();
}
}
async function getImageDimensions(file) {
return new Promise((resolve) => {
const image = new Image();
const objectUrl = URL.createObjectURL(file);
image.onload = () => {
resolve({ width: image.naturalWidth || null, height: image.naturalHeight || null });
URL.revokeObjectURL(objectUrl);
};
image.onerror = () => {
resolve({ width: null, height: null });
URL.revokeObjectURL(objectUrl);
};
image.src = objectUrl;
});
}
async function uploadSeatMapImage(file, name) {
const formData = new FormData();
formData.append("file", file);
formData.append("name", name);
return fetchJson("/api/seat-maps/dxf", {
method: "POST",
body: formData,
});
}
async function submitSeatMapSettings(event) {
event.preventDefault();
if (!isAdmin()) return;
const name = seatMapFormName?.value?.trim() || "";
const imageFile = seatMapFormImage?.files?.[0] || null;
if (!name) {
setSeatMapStatus("배치도 이름을 입력하세요.", "error");
return;
}
if (!imageFile) {
setSeatMapStatus("DXF 파일을 선택하세요.", "error");
return;
}
if (!imageFile.name.toLowerCase().endsWith(".dxf")) {
setSeatMapStatus("DXF 파일만 업로드할 수 있습니다.", "error");
return;
}
try {
setSeatMapStatus("DXF 자리배치도를 업로드하고 분석하는 중입니다.", "info");
await uploadSeatMapImage(imageFile, name);
if (seatMapFormImage) seatMapFormImage.value = "";
if (seatMapFileName) seatMapFileName.textContent = "선택된 파일 없음";
await loadSeatMapData(true);
setSeatMapStatus("DXF 자리배치도를 저장했습니다.", "success");
} catch (error) {
setSeatMapStatus(error.message || "DXF 자리배치도 저장에 실패했습니다.", "error");
} finally {
renderSeatMap();
}
}
async function saveSeatLayout() {
if (!seatMapState.seatMap || !seatMapState.editMode || !seatMapState.dirty) return;
try {
setSeatMapStatus("자리배치를 저장하는 중입니다.", "info");
await fetchJson(`/api/seat-maps/${seatMapState.seatMap.id}/layout`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ placements: seatMapState.draftPlacements }),
});
await loadSeatMapData(true);
setSeatMapStatus("자리배치를 저장했습니다.", "success");
} catch (error) {
setSeatMapStatus(error.message || "자리배치도 저장에 실패했습니다.", "error");
renderSeatMap();
}
}
function cancelSeatMapEdit() {
resetSeatMapDraft();
setSeatMapStatus("", "info");
setActiveView("organization");
}
function getDraggedMemberId(event) {
const raw = event.dataTransfer?.getData("text/plain") || seatMapState.draggingMemberId;
const memberId = Number(raw);
if (!Number.isInteger(memberId) || memberId <= 0) return null;
return memberId;
}
function handleSeatMapCellDrop(event) {
if (!seatMapState.editMode) return;
event.preventDefault();
const memberId = getDraggedMemberId(event);
if (!memberId) return;
if (seatMapState.seatMap?.source_type === "dxf") {
const slot = event.target.closest(".seatmap-slot");
if (!slot) return;
upsertDraftPlacementForSlot(memberId, Number(slot.dataset.slotId));
} else {
const cell = event.target.closest(".seatmap-cell");
if (!cell) return;
upsertDraftPlacement(memberId, Number(cell.dataset.row), Number(cell.dataset.col));
}
renderSeatMap();
}
function handleSeatMapListDrop(event) {
if (!seatMapState.editMode) return;
event.preventDefault();
const memberId = getDraggedMemberId(event);
if (!memberId) return;
removeDraftPlacement(memberId);
renderSeatMap();
}
function setActiveView(view) {
const previousView = currentView;
currentView = view in viewLabels ? view : "organization";
if (currentViewTitle) {
currentViewTitle.textContent = viewLabels[currentView];
}
navButtons.forEach((button) => {
const active = button.dataset.view === currentView;
button.classList.toggle("active", active);
button.classList.toggle("muted", !active);
});
const isOrganization = currentView === "organization";
const isSeatMap = currentView === "seatmap";
if (organizationStage) {
organizationStage.hidden = !isOrganization;
organizationStage.style.display = isOrganization ? "flex" : "none";
}
if (seatMapStage) {
seatMapStage.hidden = !isSeatMap;
seatMapStage.style.display = isSeatMap ? "flex" : "none";
}
if (emptyStage) {
emptyStage.hidden = isOrganization;
emptyStage.style.display = isOrganization ? "none" : "flex";
const showEmpty = !isOrganization && !isSeatMap;
emptyStage.hidden = !showEmpty;
emptyStage.style.display = showEmpty ? "flex" : "none";
}
if (isOrganization && previousView !== "organization" && organizationFrame) {
const frameSrc = organizationFrame.dataset.src || organizationFrame.src;
organizationFrame.src = frameSrc;
}
if (isSeatMap) {
loadSeatMapData();
}
}
function renderAuth() {
@@ -81,29 +680,31 @@ function renderAuth() {
if (authenticated) {
const displayName = session.user.display_name || "접속자";
const rank = "-";
userBadge.innerHTML = `<span class="user-chip-icon">◎</span><span class="user-chip-text"><strong>${displayName}</strong><em>${rank}</em></span><span class="user-chip-caret" aria-hidden="true">▾</span>`;
const employeeId = session.user.username || "-";
userBadge.innerHTML = `<span class="user-chip-icon">◎</span><span class="user-chip-text"><strong>${escapeHtml(displayName)}</strong><em>${escapeHtml(rank)}</em></span><span class="user-chip-caret" aria-hidden="true">▾</span>`;
userBadge.title = `${displayName} / -`;
if (userPopover) {
userPopover.innerHTML = `
<div class="user-popover-row">
<span class="user-popover-label">이름</span>
<strong>${displayName}</strong>
<strong>${escapeHtml(displayName)}</strong>
</div>
<div class="user-popover-row">
<span class="user-popover-label">직급</span>
<span>${rank}</span>
<span>${escapeHtml(rank)}</span>
</div>
<div class="user-popover-row">
<span class="user-popover-label">권한</span>
<span>${session.user.role || "-"}</span>
<span>${escapeHtml(session.user.role || "-")}</span>
</div>
<div class="user-popover-row">
<span class="user-popover-label">아이디</span>
<span>${session.user.username || "-"}</span>
<span class="user-popover-label">사번</span>
<span>${escapeHtml(employeeId)}</span>
</div>
`;
}
}
renderSeatMap();
}
if (loginForm) {
@@ -112,17 +713,19 @@ if (loginForm) {
loginMessage.textContent = "로그인 처리 중입니다.";
const formData = new FormData(loginForm);
try {
const response = await fetch("/api/mock-login", {
const payload = await fetchJson("/api/mock-login", {
method: "POST",
body: formData,
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.detail || "login failed");
setSession(payload);
loginForm.reset();
loginMessage.textContent = "";
renderAuth();
if (currentView === "seatmap") {
await loadSeatMapData(true);
}
} catch (error) {
loginMessage.textContent = "로그인에 실패했습니다. backend 연결 상태를 확인해주세요.";
loginMessage.textContent = error.message || "로그인에 실패했습니다.";
}
});
}
@@ -150,9 +753,108 @@ navButtons.forEach((button) => {
});
});
if (seatMapSettingsForm) {
seatMapSettingsForm.addEventListener("submit", submitSeatMapSettings);
}
if (seatMapSaveBtn) {
seatMapSaveBtn.addEventListener("click", saveSeatLayout);
}
if (seatMapCancelBtn) {
seatMapCancelBtn.addEventListener("click", cancelSeatMapEdit);
}
if (seatMapSearch) {
seatMapSearch.addEventListener("input", () => {
seatMapState.search = seatMapSearch.value || "";
renderSeatMap();
});
}
if (seatMapFormImage) {
seatMapFormImage.addEventListener("change", () => {
if (seatMapFileName) {
seatMapFileName.textContent = seatMapFormImage.files?.[0]?.name || "선택된 파일 없음";
}
});
}
if (seatMapBoard) {
seatMapBoard.addEventListener("wheel", (event) => {
if (seatMapState.seatMap?.source_type !== "dxf") return;
event.preventDefault();
const delta = event.deltaY < 0 ? 0.1 : -0.1;
setSeatMapZoom(seatMapState.zoom + delta);
}, { passive: false });
seatMapBoard.addEventListener("dragover", (event) => {
if (!seatMapState.editMode) return;
const target = seatMapState.seatMap?.source_type === "dxf"
? event.target.closest(".seatmap-slot")
: event.target.closest(".seatmap-cell");
if (!target) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
});
seatMapBoard.addEventListener("drop", handleSeatMapCellDrop);
}
if (seatMapBoardWrap) {
seatMapBoardWrap.addEventListener("mousedown", (event) => {
if (seatMapState.seatMap?.source_type !== "dxf") return;
if (event.button !== 1) return;
event.preventDefault();
seatMapState.panning = true;
seatMapState.panStartX = event.clientX;
seatMapState.panStartY = event.clientY;
seatMapState.panScrollLeft = seatMapBoardWrap.scrollLeft;
seatMapState.panScrollTop = seatMapBoardWrap.scrollTop;
seatMapBoardWrap.classList.add("is-panning");
});
}
document.addEventListener("mousemove", (event) => {
if (!seatMapState.panning || !seatMapBoardWrap) return;
const deltaX = event.clientX - seatMapState.panStartX;
const deltaY = event.clientY - seatMapState.panStartY;
seatMapBoardWrap.scrollLeft = seatMapState.panScrollLeft - deltaX;
seatMapBoardWrap.scrollTop = seatMapState.panScrollTop - deltaY;
});
document.addEventListener("mouseup", () => {
if (!seatMapState.panning || !seatMapBoardWrap) return;
seatMapState.panning = false;
seatMapBoardWrap.classList.remove("is-panning");
});
if (seatMapUnassigned) {
seatMapUnassigned.addEventListener("dragover", (event) => {
if (!seatMapState.editMode) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
});
seatMapUnassigned.addEventListener("drop", handleSeatMapListDrop);
}
document.addEventListener("dragstart", (event) => {
const card = event.target.closest(".seatmap-member-card");
if (!seatMapState.editMode || !card) return;
const memberId = Number(card.dataset.memberId);
if (!memberId) return;
seatMapState.draggingMemberId = memberId;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", String(memberId));
});
document.addEventListener("dragend", () => {
seatMapState.draggingMemberId = null;
});
document.addEventListener("click", () => {
hideUserPopover();
});
window.addEventListener("message", handleEmbeddedNavigationMessage);
setActiveView(currentView);
renderAuth();
+68 -9
View File
@@ -8,7 +8,7 @@
<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="/legacy/static/common.css">
<link rel="stylesheet" href="/styles.css">
<link rel="stylesheet" href="/styles.css?v=20260325-11">
</head>
<body>
<section id="login-panel" class="login-screen">
@@ -53,12 +53,10 @@
<button id="user-badge" class="ghost-button ghost-button-soft user-chip" type="button"></button>
<div id="user-popover" class="user-popover hidden"></div>
<button id="logout-btn" class="ghost-button icon-button" type="button" title="로그아웃" aria-label="로그아웃">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M15 3h-4a2 2 0 0 0-2 2v3" />
<path d="M10 17v2a2 2 0 0 0 2 2h3" />
<path d="M21 12H9" />
<path d="m16 7 5 5-5 5" />
<path d="M3 5h8v14H3z" />
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
</button>
</div>
@@ -67,7 +65,68 @@
<main class="dashboard-main">
<section id="organization-stage" class="main-stage">
<div class="stage-frame">
<iframe id="organization-frame" src="/legacy/organization?v=20260325-2" data-src="/legacy/organization?v=20260325-2" title="조직도 메인 화면"></iframe>
<iframe id="organization-frame" src="/legacy/organization?v=20260325-11" data-src="/legacy/organization?v=20260325-11" title="조직도 메인 화면"></iframe>
</div>
</section>
<section id="seatmap-stage" class="main-stage" hidden>
<div class="seatmap-layout">
<div class="seatmap-topbar">
<div>
<p class="eyebrow">Seat Layout</p>
<h3 id="seatmap-name">자리배치도</h3>
</div>
<div class="seatmap-actions">
<button id="seatmap-save-btn" class="ghost-button" type="button" hidden disabled>저장</button>
<button id="seatmap-cancel-btn" class="ghost-button ghost-button-soft" type="button" hidden>취소</button>
</div>
</div>
<p id="seatmap-status" class="seatmap-status" role="status"></p>
<div class="seatmap-content">
<div class="seatmap-board-panel">
<div id="seatmap-empty" class="seatmap-empty hidden"></div>
<div id="seatmap-board-wrap" class="seatmap-board-wrap hidden">
<div id="seatmap-board" class="seatmap-board"></div>
</div>
</div>
<aside class="seatmap-sidebar">
<section id="seatmap-settings-panel" class="seatmap-panel hidden">
<div class="seatmap-panel-head">
<h4>배치도 설정</h4>
<p>DXF 파일의 chair 레이어를 좌석 위치로 사용합니다.</p>
</div>
<form id="seatmap-settings-form" class="seatmap-form">
<label>
<span>배치도 이름</span>
<input id="seatmap-form-name" name="name" type="text" placeholder="예: 본사 3층" required>
</label>
<div>
<span>DXF 파일</span>
<label class="seatmap-file-input" for="seatmap-form-image">
<input id="seatmap-form-image" name="image" type="file" accept=".dxf" required>
<span class="seatmap-file-button">DXF 선택</span>
<strong id="seatmap-file-name" class="seatmap-file-name">선택된 파일 없음</strong>
</label>
</div>
<button id="seatmap-settings-submit" type="submit">DXF 업로드</button>
</form>
</section>
<section class="seatmap-panel">
<div class="seatmap-panel-head">
<h4>미배치 인원</h4>
<p>이름을 검색하고 자리배치도에 바로 드래그하세요.</p>
</div>
<label class="seatmap-search">
<span class="hidden">구성원 검색</span>
<input id="seatmap-search" type="search" placeholder="이름 또는 부서 검색">
</label>
<div id="seatmap-unassigned" class="seatmap-member-list"></div>
</section>
</aside>
</div>
</div>
</section>
<section id="empty-stage" class="main-stage" hidden>
@@ -76,6 +135,6 @@
</main>
</section>
<script src="/app.js"></script>
<script src="/app.js?v=20260325-11"></script>
</body>
</html>
+614 -36
View File
@@ -182,12 +182,11 @@
}
.header-center {
position: absolute;
left: 50%;
transform: translateX(-50%);
margin-left: auto;
margin-right: 48px;
display: inline-flex;
justify-content: center;
gap: 8px;
gap: 24px;
flex-wrap: nowrap;
white-space: nowrap;
z-index: 1;
@@ -197,33 +196,32 @@
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 34px;
padding: 0 14px;
border-radius: 999px;
border: 1px solid #dbe2ea;
background: #f8fafc;
color: var(--color-text-muted);
font-size: 12px;
font-weight: 800;
min-height: 48px;
padding: 0 4px;
border-radius: 0;
border: none;
border-bottom: 3px solid transparent;
background: transparent;
color: #64748b;
font-size: 15px;
font-weight: 700;
cursor: pointer;
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
transition: all 0.2s ease;
}
.nav-pill.active {
background: var(--color-accent);
border-color: transparent;
color: #fff;
box-shadow: 0 10px 20px rgba(79, 70, 229, 0.2);
background: transparent;
border-bottom-color: var(--color-accent);
color: var(--color-text);
}
.nav-pill.muted {
color: #64748b;
color: #94a3b8;
}
.nav-pill:hover {
transform: translateY(-1px);
border-color: #c7d2fe;
box-shadow: 0 8px 18px rgba(148, 163, 184, 0.16);
transform: none;
color: var(--color-accent);
}
.header-actions {
@@ -242,6 +240,34 @@
border-radius: 999px;
font-size: 11px;
font-weight: 800;
transition: all 0.2s ease;
}
.icon-button {
width: 38px;
height: 38px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
background: #f8fafc;
}
.icon-button:hover {
background: #f1f5f9;
border-color: #cbd5e1;
color: var(--color-accent);
transform: translateY(-1px);
}
.icon-button svg {
width: 18px;
height: 18px;
stroke: currentColor;
stroke-width: 2.5;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.ghost-button-soft {
@@ -291,26 +317,16 @@
color: var(--color-text-muted);
}
.icon-button {
width: 34px;
padding: 0;
justify-content: center;
}
.icon-button svg {
width: 15px;
height: 15px;
stroke: currentColor;
stroke-width: 1.9;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
.user-chip-caret {
color: var(--color-text-muted);
font-size: 10px;
line-height: 1;
}
.user-popover {
position: absolute;
top: calc(100% + 10px);
right: 42px;
right: 0;
min-width: 220px;
padding: 14px;
border: 1px solid #dbe2ea;
@@ -340,6 +356,19 @@
font-weight: 700;
}
.user-popover-action {
width: 100%;
margin-top: 10px;
min-height: 38px;
border: none;
border-radius: 12px;
background: #0f172a;
color: #fff;
font-size: 11px;
font-weight: 800;
cursor: pointer;
}
.dashboard-main {
flex: 1;
min-height: calc(100vh - 68px);
@@ -371,6 +400,514 @@
background: transparent;
}
.seatmap-layout {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 12px;
padding: 18px;
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%);
}
.seatmap-topbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
}
.seatmap-topbar h3,
.seatmap-panel-head h4 {
margin: 0;
}
.seatmap-topbar .eyebrow {
margin-bottom: 4px;
}
.seatmap-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.seatmap-status {
min-height: 20px;
margin: 0;
color: #475569;
font-size: 12px;
font-weight: 700;
}
.seatmap-status[data-tone="error"] {
color: #b91c1c;
}
.seatmap-status[data-tone="success"] {
color: #047857;
}
.seatmap-content {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 16px;
}
.seatmap-board-panel,
.seatmap-panel {
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 24px;
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.08);
}
.seatmap-board-panel {
min-height: 0;
overflow: hidden;
padding: 0;
}
.seatmap-board-wrap {
width: 100%;
height: 100%;
overflow: auto;
border-radius: 24px;
background: #fff;
padding: 0;
overscroll-behavior: contain;
}
.seatmap-board-wrap.is-panning {
cursor: grabbing;
}
.seatmap-board {
min-width: 100%;
height: 100%;
}
.seatmap-dxf-canvas {
position: relative;
width: 100%;
min-height: 100%;
margin: 0 auto;
border-radius: 24px;
overflow: hidden;
box-shadow: none;
background: #fff;
}
.seatmap-dxf-stage {
position: relative;
transform-origin: center center;
transition: transform 0.12s ease-out;
}
.seatmap-dxf-preview {
position: relative;
z-index: 1;
line-height: 0;
filter: contrast(1.9) saturate(1.1) brightness(0.88);
}
.seatmap-preview-svg {
display: block;
width: 100%;
height: auto;
background: #fff;
}
.seatmap-preview-svg .seatmap-dxf-entity {
stroke: #000 !important;
stroke-opacity: 1 !important;
stroke-width: 12 !important;
}
.seatmap-preview-svg .seatmap-dxf-chair-entity {
stroke: #2563eb !important;
stroke-opacity: 1 !important;
stroke-width: 6 !important;
}
.seatmap-preview-svg rect {
fill: #fff !important;
}
.seatmap-dxf-slots {
position: absolute;
inset: 0;
z-index: 2;
}
.seatmap-slot {
position: absolute;
transform: translate(-50%, -50%);
width: 30px;
min-height: 30px;
border: 0;
border-radius: 999px;
background: transparent;
pointer-events: auto;
transition: box-shadow 0.18s ease, background 0.18s ease, transform 0.18s ease;
}
.seatmap-slot.editable:hover {
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.4);
background: rgba(37, 99, 235, 0.12);
transform: translate(-50%, -50%) scale(1.02);
}
.seatmap-slot.occupied {
width: 34px;
min-height: 34px;
background: transparent;
}
.seatmap-slot.empty {
opacity: 0.14;
}
.seatmap-canvas {
position: relative;
width: min(100%, 1240px);
margin: 0 auto;
border-radius: 18px;
overflow: hidden;
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.16);
}
.seatmap-image {
display: block;
width: 100%;
height: auto;
user-select: none;
-webkit-user-drag: none;
}
.seatmap-grid {
position: absolute;
inset: 0;
display: grid;
grid-template-columns: repeat(var(--seatmap-cols), 1fr);
grid-template-rows: repeat(var(--seatmap-rows), 1fr);
gap: var(--seatmap-gap);
padding: var(--seatmap-gap);
}
.seatmap-cell {
position: relative;
min-width: 0;
min-height: 0;
border: 1px dashed rgba(15, 23, 42, 0.14);
background: rgba(255, 255, 255, 0.12);
transition: border-color 0.18s ease, background 0.18s ease;
}
.seatmap-cell.editable:hover {
border-color: rgba(14, 165, 233, 0.62);
background: rgba(14, 165, 233, 0.12);
}
.seatmap-cell.occupied {
background: rgba(255, 255, 255, 0.18);
}
.seatmap-cell-label {
position: absolute;
top: 4px;
left: 4px;
padding: 2px 6px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.72);
color: rgba(255, 255, 255, 0.92);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.04em;
}
.seatmap-member-card {
position: absolute;
inset: 22px 6px 6px;
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 14px;
background: rgba(15, 23, 42, 0.8);
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.18);
color: #fff;
overflow: hidden;
}
.seatmap-member-card.draggable {
cursor: grab;
}
.seatmap-member-avatar {
width: 34px;
height: 34px;
border-radius: 12px;
overflow: hidden;
flex: 0 0 auto;
background: rgba(255, 255, 255, 0.2);
}
.seatmap-member-avatar img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.seatmap-member-avatar-fallback {
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 12px;
font-weight: 900;
letter-spacing: 0.04em;
}
.seatmap-member-text {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.seatmap-member-text strong,
.seatmap-member-text em {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.seatmap-member-text strong {
font-size: 12px;
}
.seatmap-member-text em {
color: rgba(226, 232, 240, 0.84);
font-size: 10px;
font-style: normal;
}
.seatmap-sidebar {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.seatmap-panel {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
}
.seatmap-sidebar .seatmap-panel:last-child {
flex: 1;
}
.seatmap-panel-head p {
margin: 6px 0 0;
color: #64748b;
font-size: 12px;
}
.seatmap-form,
.seatmap-form-grid {
display: grid;
gap: 10px;
}
.seatmap-form label,
.seatmap-form > div,
.seatmap-search {
display: grid;
gap: 6px;
}
.seatmap-form span {
color: #475569;
font-size: 11px;
font-weight: 800;
}
.seatmap-form input,
.seatmap-search input,
.seatmap-form button {
width: 100%;
max-width: 100%;
box-sizing: border-box;
min-height: 38px;
border-radius: 12px;
font: inherit;
}
.seatmap-form input,
.seatmap-search input {
border: 1px solid #d7dee8;
padding: 0 12px;
background: #fff;
color: #0f172a;
}
.seatmap-file-input {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 10px;
width: 100%;
max-width: 100%;
min-height: 48px;
padding: 8px 10px;
box-sizing: border-box;
border: 1px solid #d7dee8;
border-radius: 14px;
background: linear-gradient(180deg, #fff, #f8fafc);
cursor: pointer;
}
.seatmap-file-input input {
display: none;
}
.seatmap-file-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 32px;
padding: 0 12px;
border-radius: 10px;
background: #0f172a;
color: #fff;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.01em;
}
.seatmap-file-name {
min-width: 0;
color: #475569;
font-size: 12px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.seatmap-form button {
border: 0;
background: #0f172a;
color: #fff;
font-size: 12px;
font-weight: 800;
cursor: pointer;
}
.seatmap-member-list {
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
gap: 8px;
padding-right: 4px;
}
.seatmap-member-list .seatmap-member-card {
position: relative;
inset: auto;
}
.seatmap-member-card-compact {
position: relative;
inset: auto;
min-height: 42px;
padding: 10px 12px;
border-radius: 12px;
background: #3f4658;
border: 1px solid rgba(148, 163, 184, 0.14);
box-shadow: none;
}
.seatmap-member-text-inline {
flex-direction: row;
align-items: baseline;
gap: 6px;
}
.seatmap-member-text-inline strong {
font-size: 13px;
}
.seatmap-member-text-inline em {
display: inline;
color: rgba(226, 232, 240, 0.8);
font-size: 11px;
}
.seatmap-slot .seatmap-member-card {
inset: auto;
left: 50%;
top: 50%;
min-width: 48px;
transform: translate(-50%, -50%);
border-radius: 8px;
padding: 3px 4px;
gap: 4px;
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.12);
}
.seatmap-slot .seatmap-member-avatar {
width: 14px;
height: 14px;
border-radius: 4px;
}
.seatmap-slot .seatmap-member-text strong {
font-size: 8px;
}
.seatmap-slot .seatmap-member-text em {
display: none;
}
.seatmap-dxf-stage {
cursor: grab;
}
.seatmap-list-empty,
.seatmap-empty-card {
display: grid;
place-items: center;
min-height: 120px;
border: 1px dashed rgba(148, 163, 184, 0.4);
border-radius: 18px;
background: rgba(248, 250, 252, 0.8);
color: #64748b;
text-align: center;
padding: 18px;
}
.seatmap-empty-card strong {
display: block;
color: #0f172a;
margin-bottom: 8px;
}
@media (max-width: 1180px) {
.dashboard-header {
flex-wrap: wrap;
@@ -390,6 +927,14 @@
.header-actions {
flex-wrap: wrap;
}
.seatmap-content {
grid-template-columns: 1fr;
}
.seatmap-sidebar {
order: -1;
}
}
@media (max-width: 980px) {
@@ -414,6 +959,15 @@
.login-form-wrap {
padding: 28px;
}
.seatmap-layout {
padding: 12px;
}
.seatmap-topbar {
align-items: flex-start;
flex-direction: column;
}
}
@media (max-width: 720px) {
@@ -424,4 +978,28 @@
.main-stage {
height: calc(100vh - 68px);
}
.seatmap-board {
min-width: 640px;
}
.seatmap-member-card {
inset: 20px 4px 4px;
padding: 6px;
gap: 6px;
}
.seatmap-member-avatar {
width: 28px;
height: 28px;
border-radius: 10px;
}
.seatmap-member-text strong {
font-size: 11px;
}
.seatmap-member-text em {
font-size: 9px;
}
}
+55 -18
View File
@@ -571,17 +571,17 @@ body {
.search-section {
position: fixed;
top: 18px;
left: 25px;
top: 14px;
left: 18px;
background: var(--color-surface-soft);
border-radius: var(--radius-md);
padding: 10px 18px;
padding: 8px 12px;
box-shadow: var(--shadow-soft);
border: 1px solid var(--color-border-soft);
z-index: 1010;
display: flex;
align-items: center;
gap: 12px;
gap: 10px;
backdrop-filter: blur(8px);
transition: all 0.3s;
}
@@ -590,26 +590,27 @@ body {
border: none;
outline: none;
background: transparent;
font-size: 13px;
font-size: 12px;
font-weight: 700;
color: var(--color-text);
width: 180px;
width: 150px;
}
.search-icon {
color: var(--color-text-muted);
display: flex;
align-items: center;
transform: scale(0.9);
}
.stats-section {
position: fixed;
top: 18px;
right: 25px;
width: 400px;
top: 14px;
right: 18px;
width: 332px;
background: var(--color-surface-soft);
border-radius: var(--radius-md);
padding: 15px;
padding: 10px 12px;
box-shadow: var(--shadow-soft);
border: 1px solid var(--color-border-soft);
z-index: 1010;
@@ -617,10 +618,15 @@ body {
transition: all 0.3s;
}
.stats-title {
font-size: 11px;
line-height: 1.1;
}
.stats-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
font-size: 10px;
border-radius: 8px;
overflow: hidden;
border-style: hidden;
@@ -631,13 +637,13 @@ body {
background: #f8fafc;
color: var(--color-text-muted);
font-weight: 800;
padding: 8px 4px;
padding: 6px 4px;
border: 1px solid #e2e8f0;
text-align: center;
}
.stats-table td {
padding: 8px 4px;
padding: 6px 4px;
border: 1px solid #e2e8f0;
text-align: center;
font-weight: 700;
@@ -648,7 +654,38 @@ body {
background: #f8fafc;
color: var(--color-text-soft);
font-weight: 800;
width: 80px;
width: 92px;
}
.stats-company-label {
display: inline-flex;
align-items: center;
gap: 6px;
}
.stats-company-dot {
width: 8px;
height: 8px;
border-radius: 999px;
display: inline-block;
background: #94a3b8;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.7);
}
.stats-company-dot.co-삼안 {
background: #ffb366;
}
.stats-company-dot.co-한맥 {
background: #ef4444;
}
.stats-company-dot.co-피티씨 {
background: #a855f7;
}
.stats-company-dot.co-바론 {
background: #3b82f6;
}
.stats-table .total-cell {
@@ -735,8 +772,8 @@ body {
.dept-tabs-container {
display: flex;
gap: 8px;
margin-top: 15px;
padding: 5px 0;
margin-top: 10px;
padding: 2px 0;
overflow-x: auto;
scrollbar-width: none;
}
@@ -746,11 +783,11 @@ body {
}
.dept-tab {
padding: 6px 14px;
padding: 5px 11px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 20px;
font-size: 11px;
font-size: 10px;
font-weight: 800;
color: #64748b;
cursor: pointer;
-4
View File
@@ -813,10 +813,6 @@ function openModal(id) {
<p class="text-slate-400 text-xs mt-1 font-medium">${(member._path || []).map((path) => path.name).join(' > ')}</p>
</div>
<div class="w-full grid grid-cols-2 gap-3 mt-4">
<div class="bg-slate-50 p-4 rounded-2xl border border-slate-100 col-span-2">
<label class="text-[10px] text-slate-400 font-bold block mb-1">사번</label>
<span class="text-sm font-black text-slate-700">${member['사번'] || '정보 없음'}</span>
</div>
<div class="bg-indigo-50 p-4 rounded-2xl border border-indigo-100 col-span-2 flex items-center gap-4">
<div class="flex-1">
<label class="text-[10px] text-indigo-400 font-bold block mb-1">연락처</label>
BIN
View File
Binary file not shown.
Binary file not shown.
-8
View File
@@ -18,13 +18,6 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
location /snapshots/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /legacy/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
@@ -39,4 +32,3 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
}