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

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
1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

BIN
2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

BIN
3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

BIN
5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

722346
6f.dxf Normal file

File diff suppressed because it is too large Load Diff

BIN
6f.dxf:Zone.Identifier Normal file

Binary file not shown.

707538
7f.dxf Normal file

File diff suppressed because it is too large Load Diff

BIN
7f.dxf:Zone.Identifier Normal file

Binary file not shown.

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>

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"

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

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
center2.dxf Normal file

File diff suppressed because it is too large Load Diff

564192
center3.dxf Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

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:

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/업로드 데이터가 유지되는지 확인합니다.

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`
- 확인 기준:

726
frontend/public/app.js Executable file → Normal file
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();

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>

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

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;

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>

Binary file not shown.

BIN
organization1.xlsx Normal file

Binary file not shown.

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