Fix seatmap slot matching and update member modal layout

This commit is contained in:
hyunho
2026-03-27 18:12:20 +09:00
parent d66614123e
commit 24852d4401
11 changed files with 517 additions and 89 deletions

View File

@@ -8,8 +8,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap" rel="stylesheet" />
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/legacy/static/common.css?v=20260326-02" /> <link rel="stylesheet" href="/legacy/static/common.css?v=20260327-03" />
<link rel="stylesheet" href="/legacy/static/organization.css?v=20260326-02" /> <link rel="stylesheet" href="/legacy/static/organization.css?v=20260327-03" />
</head> </head>
<body> <body>
<input type="file" id="upload-excel" class="hidden" accept=".xlsx, .csv" /> <input type="file" id="upload-excel" class="hidden" accept=".xlsx, .csv" />
@@ -60,6 +60,6 @@
</div> </div>
</div> </div>
<script src="/legacy/static/organization.js?v=20260326-02"></script> <script src="/legacy/static/organization.js?v=20260327-03"></script>
</body> </body>
</html> </html>

View File

@@ -1308,11 +1308,59 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
ctx.lineWidth = (selected ? 2.6 : active ? 2.0 : 1.6) / camera.scale;""", ctx.lineWidth = (selected ? 2.6 : active ? 2.0 : 1.6) / camera.scale;""",
1, 1,
) )
html = html.replace(
""" sorted.forEach((chair, index) => {
chair.key = String(index + 1);
chair.seatNo = index + 1;
});""",
""" sorted.forEach((chair, index) => {
chair.seatNo = index + 1;
});""",
1,
)
html = html.replace( html = html.replace(
"function persistPlaced() {\n localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));\n }", "function persistPlaced() {\n localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));\n }",
"function persistPlaced() {\n return;\n }", "function persistPlaced() {\n return;\n }",
1, 1,
) )
html = html.replace(
""" window.addEventListener("pointerup", (event) => {
if (dragging && dragStart) {
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
if (move < 4) {
const rect = canvas.getBoundingClientRect();
const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top);
if (picked) {
if (placed.has(picked.key)) placed.delete(picked.key);
else placed.add(picked.key);
persistPlaced();
if (activePersonId) {
const currentChair = getChairByPerson(activePersonId);
if (chairAssignments[picked.key] === activePersonId) {
delete chairAssignments[picked.key];
} else {
if (currentChair && currentChair !== picked.key) delete chairAssignments[currentChair];
chairAssignments[picked.key] = activePersonId;
}
persistAssignments();
renderPeopleList();
}
}
}
}
dragging = false;
dragStart = null;
canvas.classList.remove("dragging");
requestDraw();
});""",
""" window.addEventListener("pointerup", () => {
dragging = false;
dragStart = null;
canvas.classList.remove("dragging");
requestDraw();
});""",
1,
)
html = html.replace( html = html.replace(
""" window.addEventListener("pointerup", (event) => { """ window.addEventListener("pointerup", (event) => {
if (dragging && dragStart) { if (dragging && dragStart) {

View File

@@ -169,9 +169,59 @@
- Windows LAN IP 또는 WSL IP가 바뀌면 `portproxy``connectaddress`는 다시 맞춰야 한다 - Windows LAN IP 또는 WSL IP가 바뀌면 `portproxy``connectaddress`는 다시 맞춰야 한다
- 운영 안정성을 위해 향후 자동화 스크립트화가 필요함 - 운영 안정성을 위해 향후 자동화 스크립트화가 필요함
## 10. 인증 기본 구조 추가
### 작업 내용
- 프런트 로그인 화면을 실제 `/api/auth/login` API와 연결
- 로그인 세션 확인용 `/api/auth/me` 추가
- 로그아웃용 `/api/auth/logout` 추가
- 로그인 감사로그와 세션 저장 테이블 추가
### 해결 방식
- 업무 데이터는 기존 `members` 중심으로 유지
- 인증 데이터는 `auth.users`, `auth.sessions`, `auth.login_audit_logs` 로 분리
- 구성원 import 시 사번 기준으로 계정을 동기화하고 기본 관리자 계정을 seed
### 현재 한계
- 권한 모델은 아직 `role` 단일 컬럼 수준이다
- API별 세부 권한 검증은 아직 미완성이다
- `/api/mock-login` 은 아직 남아 있어 운영 기준으로는 정리가 필요하다
## 11. 이력형 DB 전환 방향 확정
### 배경
- 월간 스냅샷 파일보다, 사용자가 원하는 날짜 기준으로 조직도와 자리배치도를 바로 조회하는 요구가 더 중요해졌다
- 조직도 기본 정보나 자리배치 정보처럼 원래 날짜가 없는 데이터도 과거/현재 버전 차이를 추적해야 한다
### 결정
- 월간 스냅샷 기능은 범위에서 제외
- 대신 DB 자체를 `valid_from`, `valid_to` 기반 버전 구조로 전환
- 사용자 조회는 파일 스냅샷이 아니라 `as_of` 기준 조회 방식으로 설계
### 우선 적용 대상
- `members` -> `member_versions`
- `seat_positions` -> `seat_assignment_versions`
### 기대 효과
- 특정 날짜의 조직 상태 재구성 가능
- 특정 날짜의 자리배치도 재구성 가능
- 기간 비교나 변경 추적 UI로 확장 가능
### 설계 문서
- [HISTORY_ASOF_DB_PLAN.md](/home/hyunho/projects/mh-dashboard-organization/docs/HISTORY_ASOF_DB_PLAN.md)
## Next Focus ## Next Focus
- `#2` 영속성 운영 검증과 문서 기준 정리
- 권한 제어와 mock login 정리
- `#9` as-of date 기반 history 구조 설계 및 점진적 도입
- 자리배치도 조직 트리, 나머지 사무실 도면 등 실사용 기능 고도화
- 프로젝트별 분석의 남은 소수점/분류 오차 정리 - 프로젝트별 분석의 남은 소수점/분류 오차 정리
- 자리배치도 색상/조직 트리 등 추가 UX 기능 고도화
- 실제 인증 체계 전환
- 나머지 사무실 도면 추가

View File

@@ -95,6 +95,7 @@
1. `8080``8081` 모두 기동 상태 확인 1. `8080``8081` 모두 기동 상태 확인
2. 이번 작업이 `코드 변경`인지 `데이터 변경`인지 먼저 구분 2. 이번 작업이 `코드 변경`인지 `데이터 변경`인지 먼저 구분
3. 공개용 기준 데이터가 필요한 화면이면 `8081` DB를 먼저 `8080` 기준으로 맞춤 3. 공개용 기준 데이터가 필요한 화면이면 `8081` DB를 먼저 `8080` 기준으로 맞춤
4. 작업 전후 검증은 [REGRESSION_CHECKLIST.md](/home/hyunho/projects/mh-dashboard-organization/docs/REGRESSION_CHECKLIST.md) 기준으로 수행
### 2. 기능 개발 중 ### 2. 기능 개발 중
@@ -146,7 +147,8 @@
2. 공개용 기준 데이터가 필요한지 판단 2. 공개용 기준 데이터가 필요한지 판단
3. 필요하면 `8081` DB를 `8080` 기준으로 먼저 동기화 3. 필요하면 `8081` DB를 `8080` 기준으로 먼저 동기화
4. 그 뒤 기능 개발과 검증 수행 4. 그 뒤 기능 개발과 검증 수행
5. 검증 완료 후 공개용에 코드 승격 5. 검증은 [REGRESSION_CHECKLIST.md](/home/hyunho/projects/mh-dashboard-organization/docs/REGRESSION_CHECKLIST.md) 기준으로 수행
6. 검증 완료 후 공개용에 코드 승격
## 다음 액션 ## 다음 액션

View File

@@ -6,6 +6,7 @@
- latest checked commit: `1d15cf9` - latest checked commit: `1d15cf9`
- main history doc: [DEVELOPMENT_HISTORY.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEVELOPMENT_HISTORY.md) - main history doc: [DEVELOPMENT_HISTORY.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEVELOPMENT_HISTORY.md)
- dev/prod protocol: [DEV_PROD_DB_PROTOCOL.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEV_PROD_DB_PROTOCOL.md) - dev/prod protocol: [DEV_PROD_DB_PROTOCOL.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEV_PROD_DB_PROTOCOL.md)
- regression checklist: [REGRESSION_CHECKLIST.md](/home/hyunho/projects/mh-dashboard-organization/docs/REGRESSION_CHECKLIST.md)
## What Was Finished ## What Was Finished
@@ -80,6 +81,7 @@
- 데이터 정본은 `8080` DB - 데이터 정본은 `8080` DB
- `8081` DB는 독립 정본이 아니라 `8080` 기준 복제본처럼 관리해야 함 - `8081` DB는 독립 정본이 아니라 `8080` 기준 복제본처럼 관리해야 함
- 조직도, 멤버, 자리배치 검증 전에는 `DEV_PROD_DB_PROTOCOL.md`를 먼저 확인 - 조직도, 멤버, 자리배치 검증 전에는 `DEV_PROD_DB_PROTOCOL.md`를 먼저 확인
- 기능 수정 후 완료 판단은 `REGRESSION_CHECKLIST.md`를 기준으로 해야 함
### Seat Map Save ### Seat Map Save
@@ -165,6 +167,7 @@
- `docs/DEVELOPMENT_HISTORY.md` - `docs/DEVELOPMENT_HISTORY.md`
- `docs/NEXT_SESSION_CHECKPOINT.md` - `docs/NEXT_SESSION_CHECKPOINT.md`
- `docs/DEV_PROD_DB_PROTOCOL.md` - `docs/DEV_PROD_DB_PROTOCOL.md`
- `docs/REGRESSION_CHECKLIST.md`
- `docs/HISTORY_ASOF_DB_PLAN.md` - `docs/HISTORY_ASOF_DB_PLAN.md`
- Gitea 이슈 `#2`, `#5`, `#9` - Gitea 이슈 `#2`, `#5`, `#9`

View File

@@ -0,0 +1,160 @@
# 회귀 검증 체크리스트
## 목적
- 새 기능을 추가하거나 기존 기능을 수정할 때, 이전에 되던 핵심 기능이 깨졌는지 빠르게 확인한다.
- `8081` 작업용에서 검증한 결과를 신뢰할 수 있도록 `환경`, `데이터`, `핵심 시나리오`를 고정한다.
- 완료 판단을 감이 아니라 반복 가능한 체크 절차로 바꾼다.
## 적용 원칙
- 코드 수정은 먼저 `8081`에서 수행한다.
- 데이터 기준은 항상 `8080` 공개용 DB를 따른다.
- 검증 전에는 작업 범위에 맞는 DB 동기화를 먼저 수행한다.
- 기능 수정 후에는 관련 화면만 보지 말고, 이 문서의 핵심 시나리오를 함께 확인한다.
관련 문서:
- [DEV_PROD_DB_PROTOCOL.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEV_PROD_DB_PROTOCOL.md)
- [INFRA_VALIDATION_CHECKLIST.md](/home/hyunho/projects/mh-dashboard-organization/docs/INFRA_VALIDATION_CHECKLIST.md)
## 작업 시작 전
### 1. 서버 상태 확인
- `8081` 작업용 접속 확인
- `8080` 공개용 접속 확인
- `docker compose ps`에서 `backend`, `frontend`, `proxy`, `db`가 정상인지 확인
### 2. 데이터 동기화 범위 결정
- 조직도, 관리자모드, 자리배치도 작업 전:
- `./scripts/sync_prod_db_to_dev.sh minimal`
- 프로젝트별 분석, 팀/개인별 분석 작업 전:
- `./scripts/sync_prod_db_to_dev.sh analysis`
- 공개용 기준 전체 데이터 재검증이 필요한 경우만:
- `./scripts/sync_prod_db_to_dev.sh full`
### 3. 기준 고정
- 어느 서버에서 재현했는지 기록
- 어떤 데이터 동기화 범위로 검증했는지 기록
- 브라우저 캐시 영향을 피하려면 강력 새로고침 후 확인
## 공통 회귀 시나리오
기능 수정 후 아래 항목을 최소한 확인한다.
### A. 허브 및 공통 진입
- 메인 허브가 정상 렌더링된다.
- 상단 탭 이동이 정상 동작한다.
- 로그인 상태가 비정상적으로 풀리지 않는다.
### B. 조직현황
- 조직도 트리가 정상 표시된다.
- 관리자모드 진입이 가능하다.
- 대상인원 클릭 시 기본정보 모달이 열린다.
- `+` 신규 구성원 추가 모달이 열린다.
- 기본정보 저장이 정상 동작한다.
### C. 자리배치도
- `기술개발센터`, `한맥빌딩 6층`, `한맥빌딩 7층` 도면이 모두 열린다.
- 미배치 인원 목록이 정상 표시된다.
- 미배치 인원을 chair에 드래그앤드롭할 수 있다.
- 드롭 후:
- 미배치 목록에서 사라진다.
- chair에 배치 상태가 표시된다.
- 이름/직급 표기가 보인다.
- 배치된 좌석 클릭 후 해제 또는 수정 흐름이 정상 동작한다.
### D. 조직도와 자리배치 연동
- 조직도에서 인원 클릭 시 상세 정보가 열린다.
- 재석위치 미리보기가 표시된다.
- 좌석이 배정된 인원은 해당 자리로 줌인된다.
### E. 프로젝트별 분석
- 월 선택이 정상 동작한다.
- 프로젝트 목록과 합계가 비어 있지 않다.
- `1월`, `2월` 데이터가 현재 기준값과 일치한다.
현재 기준 검증값:
- `2026-01`
- 시간 `37,342.39`
- 인건비 `1,391,966,625`
- `2026-02`
- 시간 `29,060.59`
- 인건비 `1,078,337,651`
### F. 팀/개인별 분석
- `전체`, `GPD`, `TDC` 버튼이 순서대로 보인다.
- `전체`에서 모든 팀이 노출된다.
- `GPD`, `TDC` 선택 시 각 소속 범위만 버튼 기준으로 보인다.
- 검색은 버튼 상태와 무관하게 전체 데이터를 검색한다.
## 작업 유형별 필수 추가 확인
### 조직도 / 관리자모드 수정 시
- 대상인원 수정 모달 레이아웃이 깨지지 않는지 확인
- 신규 구성원 추가 모달도 같은 레이아웃으로 보이는지 확인
- 저장 후 목록 반영이 정상인지 확인
### 자리배치도 수정 시
- viewer iframe 로드 여부 확인
- 드래그앤드롭 이후 배치 상태가 즉시 반영되는지 확인
- 조직도 상세 재석위치 preview까지 같이 확인
### 분석 로직 수정 시
- 작업 전에 반드시 `analysis` 또는 `full` 동기화 수행
- 월별 합계 검증값 재확인
- 원본 기준과 차이가 있으면 반올림, 제외 인원, 가공시간 규칙부터 점검
## 완료 처리 기준
수정 사항을 완료로 판단하려면 아래를 모두 만족해야 한다.
- 수정한 기능이 의도대로 동작한다.
- 관련 공통 회귀 시나리오가 깨지지 않는다.
- 필요한 경우 `8081`에서 검증 결과를 숫자 또는 화면 기준으로 기록한다.
- 이후에만 `8080` 공개용 반영 여부를 판단한다.
## 장애 원인 분류 기준
문제가 생기면 먼저 아래 셋 중 어디인지 분리한다.
- 코드 차이
- `8080`, `8081`의 정적 파일 또는 백엔드 로직이 다름
- DB 차이
- `members`, `seat_maps`, `integration_*` 등 기준 데이터가 다름
- 캐시 또는 런타임 상태
- 정적 파일 캐시, 컨테이너 재시작 미반영, 브라우저 세션 상태 문제
이 분류를 먼저 해야 원인을 잘못 짚지 않는다.
## 권장 기록 방식
작업 종료 시 아래 형식으로 남긴다.
```text
작업 범위:
- 예: 조직현황 관리자모드 기본정보 모달 레이아웃 변경
검증 환경:
- 서버: 8081
- DB 동기화: minimal / analysis / full 중 무엇을 사용했는지
검증 결과:
- 조직도: 정상
- 관리자모드 모달: 정상
- 자리배치도 연동: 정상 또는 미검증
- 프로젝트별 분석: 정상 또는 미검증
```

View File

@@ -861,6 +861,7 @@ function renderDxfSeatMapBoard() {
const viewerUrl = resolveAppUrl(`/api/seat-maps/${seatMapState.seatMap.id}/viewer`); const viewerUrl = resolveAppUrl(`/api/seat-maps/${seatMapState.seatMap.id}/viewer`);
seatMapBoard.innerHTML = ` seatMapBoard.innerHTML = `
<div class="seatmap-dxf-frame-shell"> <div class="seatmap-dxf-frame-shell">
<div class="seatmap-dxf-drop-overlay" data-seatmap-drop-overlay></div>
<iframe <iframe
id="seatmap-dxf-frame" id="seatmap-dxf-frame"
class="seatmap-dxf-frame" class="seatmap-dxf-frame"
@@ -874,6 +875,12 @@ function renderDxfSeatMapBoard() {
setupSeatMapViewerFrame(); setupSeatMapViewerFrame();
} }
function setSeatMapDropOverlayActive(active) {
const overlay = seatMapBoard?.querySelector("[data-seatmap-drop-overlay]");
if (!overlay) return;
overlay.classList.toggle("is-active", Boolean(active && seatMapState.editMode));
}
function getDraftPlacedSlotKeys() { function getDraftPlacedSlotKeys() {
const slotMap = getSeatSlotMap(); const slotMap = getSeatSlotMap();
return (seatMapState.draftPlacements || []) return (seatMapState.draftPlacements || [])
@@ -917,6 +924,13 @@ function syncSeatMapViewerFrame() {
); );
} }
function scheduleSeatMapViewerSync() {
syncSeatMapViewerFrame();
window.setTimeout(() => {
syncSeatMapViewerFrame();
}, 80);
}
function renderSeatMapActions() { function renderSeatMapActions() {
const hasSeatMap = Boolean(seatMapState.seatMap); const hasSeatMap = Boolean(seatMapState.seatMap);
const adminMode = isSeatMapAdminMode(); const adminMode = isSeatMapAdminMode();
@@ -940,6 +954,7 @@ function updateSeatMapDraftUi() {
function setupSeatMapViewerFrame() { function setupSeatMapViewerFrame() {
const frame = seatMapBoard?.querySelector("#seatmap-dxf-frame"); const frame = seatMapBoard?.querySelector("#seatmap-dxf-frame");
const overlay = seatMapBoard?.querySelector("[data-seatmap-drop-overlay]");
if (!frame) return; if (!frame) return;
frame.addEventListener("load", () => { frame.addEventListener("load", () => {
@@ -950,26 +965,42 @@ function setupSeatMapViewerFrame() {
const canvas = frameDocument?.getElementById("canvas"); const canvas = frameDocument?.getElementById("canvas");
if (!frameWindow || !frameDocument || !canvas || !frameWindow.__mhSeatmap) return; if (!frameWindow || !frameDocument || !canvas || !frameWindow.__mhSeatmap) return;
canvas.addEventListener("dragover", (event) => { const handleDrop = (event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
});
canvas.addEventListener("drop", (event) => {
event.preventDefault(); event.preventDefault();
const memberId = getDraggedMemberId(event); const memberId = getDraggedMemberId(event);
if (!memberId) return; if (!memberId) {
const rect = canvas.getBoundingClientRect(); setSeatMapStatus("드롭 감지됨: memberId를 읽지 못했습니다.", "error");
return;
}
const frameRect = frame.getBoundingClientRect();
const canvasRect = canvas.getBoundingClientRect();
const picked = frameWindow.__mhSeatmap.pickChairAt( const picked = frameWindow.__mhSeatmap.pickChairAt(
event.clientX - rect.left, event.clientX - frameRect.left - canvasRect.left,
event.clientY - rect.top, event.clientY - frameRect.top - canvasRect.top,
); );
if (!picked?.key) return; if (!picked?.key) {
setSeatMapStatus(`드롭 감지됨: 좌석 인식 실패 (memberId=${memberId})`, "error");
return;
}
const matchedSlot = (seatMapState.slots || []).find((item) => String(item.slot_key) === String(picked.key)); const matchedSlot = (seatMapState.slots || []).find((item) => String(item.slot_key) === String(picked.key));
if (!matchedSlot) return; if (!matchedSlot) {
setSeatMapStatus(`드롭 감지됨: slot 매칭 실패 (${picked.key})`, "error");
return;
}
upsertDraftPlacementForSlot(memberId, Number(matchedSlot.id)); upsertDraftPlacementForSlot(memberId, Number(matchedSlot.id));
setSeatMapStatus(`드롭 성공: memberId=${memberId}, slot=${picked.key}, slotId=${matchedSlot.id}`, "info");
updateSeatMapDraftUi(); updateSeatMapDraftUi();
}); };
const handleDragOver = (event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
};
canvas.addEventListener("dragover", handleDragOver);
canvas.addEventListener("drop", handleDrop);
overlay?.addEventListener("dragover", handleDragOver);
overlay?.addEventListener("drop", handleDrop);
}, { once: true }); }, { once: true });
} }
@@ -1666,12 +1697,14 @@ document.addEventListener("dragstart", (event) => {
const memberId = Number(card.dataset.memberId); const memberId = Number(card.dataset.memberId);
if (!memberId) return; if (!memberId) return;
seatMapState.draggingMemberId = memberId; seatMapState.draggingMemberId = memberId;
setSeatMapDropOverlayActive(true);
event.dataTransfer.effectAllowed = "move"; event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", String(memberId)); event.dataTransfer.setData("text/plain", String(memberId));
}); });
document.addEventListener("dragend", () => { document.addEventListener("dragend", () => {
seatMapState.draggingMemberId = null; seatMapState.draggingMemberId = null;
setSeatMapDropOverlayActive(false);
}); });
document.addEventListener("click", () => { document.addEventListener("click", () => {

View File

@@ -623,6 +623,7 @@ body {
} }
.seatmap-dxf-frame-shell { .seatmap-dxf-frame-shell {
position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
@@ -630,6 +631,18 @@ body {
overflow: hidden; overflow: hidden;
} }
.seatmap-dxf-drop-overlay {
position: absolute;
inset: 0;
z-index: 3;
pointer-events: none;
background: transparent;
}
.seatmap-dxf-drop-overlay.is-active {
pointer-events: auto;
}
.seatmap-dxf-frame { .seatmap-dxf-frame {
display: block; display: block;
width: 100%; width: 100%;

View File

@@ -351,11 +351,21 @@ body {
gap: 12px; gap: 12px;
} }
.member-edit-profile-card {
display: flex;
flex-direction: column;
gap: 12px;
}
.member-seat-field { .member-seat-field {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
} }
.member-seat-field-emphasis .seat-preview-card {
min-height: 100%;
}
.member-detail-top-row { .member-detail-top-row {
width: 100%; width: 100%;
display: grid; display: grid;
@@ -386,6 +396,17 @@ body {
margin-top: 10px; margin-top: 10px;
} }
.member-inline-info-grid-stacked {
grid-template-columns: minmax(0, 1fr);
margin-top: 0;
}
.member-name-field-compact {
display: flex;
flex-direction: column;
gap: 6px;
}
.member-inline-info-card { .member-inline-info-card {
min-width: 0; min-width: 0;
display: flex; display: flex;
@@ -496,6 +517,7 @@ body {
border-radius: 18px; border-radius: 18px;
background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%); background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
overflow: hidden; overflow: hidden;
min-height: 100%;
} }
.seat-preview-head { .seat-preview-head {
@@ -579,6 +601,32 @@ body {
font-weight: 900; font-weight: 900;
} }
.member-edit-right-pane .seat-preview-head {
padding: 18px 20px 12px;
}
.member-edit-right-pane .seat-preview-head strong {
font-size: 18px;
}
.member-edit-right-pane .seat-preview-head p {
font-size: 12px;
}
.member-edit-right-pane .seat-preview-badge {
font-size: 12px;
padding: 8px 12px;
}
.member-edit-right-pane .seat-preview-canvas {
min-height: 360px;
}
.member-edit-right-pane .seat-preview-frame,
.member-edit-right-pane .seat-preview-placeholder {
min-height: 320px;
}
.seat-preview-placeholder-icon { .seat-preview-placeholder-icon {
width: 52px; width: 52px;

View File

@@ -7,6 +7,7 @@ let isListMode = false;
let emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.'; let emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.';
let photoPreviewObjectUrl = null; let photoPreviewObjectUrl = null;
let seatMapLayoutCache = null; let seatMapLayoutCache = null;
const seatMapOfficeKeys = ['technical-development-center', 'hanmac-building-6f', 'hanmac-building-7f'];
const levelOrder = ['부서', '그룹', '디비전', '팀', '셀']; const levelOrder = ['부서', '그룹', '디비전', '팀', '셀'];
const dropdownFields = ['소속회사', '직급', '직책', ...levelOrder]; const dropdownFields = ['소속회사', '직급', '직책', ...levelOrder];
@@ -147,23 +148,28 @@ async function loadMembers(message) {
render(); render();
} }
async function loadActiveSeatMapLayout(force = false) { async function loadSeatMapLayouts(force = false) {
if (seatMapLayoutCache && !force) { if (seatMapLayoutCache && !force) {
return seatMapLayoutCache; return seatMapLayoutCache;
} }
try { try {
const activePayload = await apiFetch('/api/seat-maps/active'); const layouts = (await Promise.all(seatMapOfficeKeys.map(async (officeKey) => {
try {
const activePayload = await apiFetch(`/api/seat-maps/active?office_key=${encodeURIComponent(officeKey)}`);
const seatMap = activePayload?.item; const seatMap = activePayload?.item;
if (!seatMap?.id) { if (!seatMap?.id) {
seatMapLayoutCache = null;
return null; return null;
} }
const layoutPayload = await apiFetch(`/api/seat-maps/${seatMap.id}/layout`); return await apiFetch(`/api/seat-maps/${seatMap.id}/layout`);
seatMapLayoutCache = layoutPayload; } catch {
return layoutPayload; return null;
}
}))).filter(Boolean);
seatMapLayoutCache = layouts;
return layouts;
} catch { } catch {
seatMapLayoutCache = null; seatMapLayoutCache = null;
return null; return [];
} }
} }
@@ -172,22 +178,62 @@ function handleSeatMapLayoutUpdated() {
loadMembers().catch(() => { }); loadMembers().catch(() => { });
} }
function getMemberSeatInfo(layout, memberId) { function getMemberSeatInfo(layouts, memberId) {
if (!layout || !memberId) { if (!Array.isArray(layouts) || !memberId) {
return null; return null;
} }
for (const layout of layouts) {
const placement = (layout.placements || []).find((item) => Number(item.member_id) === Number(memberId)); const placement = (layout.placements || []).find((item) => Number(item.member_id) === Number(memberId));
if (!placement) { if (!placement) {
return null; continue;
} }
const slot = (layout.slots || []).find((item) => Number(item.id) === Number(placement.seat_slot_id)); const slot = (layout.slots || []).find((item) => Number(item.id) === Number(placement.seat_slot_id));
return { return {
layout,
seatMapId: layout.seat_map?.id || null, seatMapId: layout.seat_map?.id || null,
seatMapName: layout.seat_map?.name || '자리배치도', seatMapName: layout.seat_map?.name || '자리배치도',
seatLabel: placement.seat_label || slot?.label || '', seatLabel: placement.seat_label || slot?.label || '',
slotKey: slot?.slot_key || '', slotKey: slot?.slot_key || '',
assigned: true, assigned: true,
}; };
}
return null;
}
function buildSeatAssignments(layout) {
if (!layout || !Array.isArray(layout.placements) || !Array.isArray(layout.members) || !Array.isArray(layout.slots)) {
return [];
}
return layout.placements.map((placement) => {
const slot = layout.slots.find((item) => Number(item.id) === Number(placement.seat_slot_id));
const memberItem = layout.members.find((item) => Number(item.id) === Number(placement.member_id));
if (!slot || !memberItem) return null;
return {
key: String(slot.slot_key || ''),
member_id: Number(memberItem.id),
name: memberItem.name || '-',
rank: memberItem.rank || '-',
};
}).filter(Boolean);
}
function applySeatPreviewFrameState(frame, seatInfo, layout) {
if (!frame?.contentWindow || !seatInfo?.slotKey) {
return;
}
const postState = () => {
if (!frame.contentWindow) {
return;
}
frame.contentWindow.postMessage({
type: 'seatmap-set-assignments',
items: buildSeatAssignments(layout),
}, window.location.origin);
frame.contentWindow.postMessage({ type: 'seatmap-set-mode', mode: 'compact' }, window.location.origin);
frame.contentWindow.postMessage({ type: 'seatmap-focus-chair', key: seatInfo.slotKey, padding: 2600 }, window.location.origin);
};
postState();
setTimeout(postState, 120);
} }
async function syncMembers(nextMembers) { async function syncMembers(nextMembers) {
@@ -953,15 +999,16 @@ async function hydrateMemberSeatPreview(member) {
seatLabel: member['자리위치'] || '', seatLabel: member['자리위치'] || '',
slotKey: '', slotKey: '',
}); });
const layout = await loadActiveSeatMapLayout(true); const layouts = await loadSeatMapLayouts(true);
if (!document.getElementById('member-seat-preview')) { if (!document.getElementById('member-seat-preview')) {
return; return;
} }
const seatInfo = getMemberSeatInfo(layout, member.id) || { const seatInfo = getMemberSeatInfo(layouts, member.id) || {
seatMapName: layout?.seat_map?.name || '자리배치도', layout: null,
seatMapName: '자리배치도',
seatLabel: member['자리위치'] || '', seatLabel: member['자리위치'] || '',
slotKey: '', slotKey: '',
assigned: Boolean(member['자리위치']), assigned: false,
}; };
target.innerHTML = renderSeatPreviewCard(seatInfo); target.innerHTML = renderSeatPreviewCard(seatInfo);
if (!seatInfo.assigned || !seatInfo.seatMapId || !seatInfo.slotKey) { if (!seatInfo.assigned || !seatInfo.seatMapId || !seatInfo.slotKey) {
@@ -972,27 +1019,7 @@ async function hydrateMemberSeatPreview(member) {
return; return;
} }
frame.addEventListener('load', () => { frame.addEventListener('load', () => {
if (!frame.contentWindow) { applySeatPreviewFrameState(frame, seatInfo, seatInfo.layout);
return;
}
frame.contentWindow.postMessage({
type: 'seatmap-set-assignments',
items: Array.isArray(layout?.placements) && Array.isArray(layout?.members) && Array.isArray(layout?.slots)
? layout.placements.map((placement) => {
const slot = layout.slots.find((item) => Number(item.id) === Number(placement.seat_slot_id));
const memberItem = layout.members.find((item) => Number(item.id) === Number(placement.member_id));
if (!slot || !memberItem) return null;
return {
key: String(slot.slot_key || ''),
member_id: Number(memberItem.id),
name: memberItem.name || '-',
rank: memberItem.rank || '-',
};
}).filter(Boolean)
: [],
}, window.location.origin);
frame.contentWindow.postMessage({ type: 'seatmap-set-mode', mode: 'compact' }, window.location.origin);
frame.contentWindow.postMessage({ type: 'seatmap-focus-chair', key: seatInfo.slotKey, padding: 2600 }, window.location.origin);
}, { once: true }); }, { once: true });
} }
@@ -1112,7 +1139,7 @@ function openModal(id) {
<input type="hidden" id="m-seat-hidden" value="${member['자리위치'] || ''}"> <input type="hidden" id="m-seat-hidden" value="${member['자리위치'] || ''}">
<div class="col-span-2 member-edit-layout"> <div class="col-span-2 member-edit-layout">
<div class="member-edit-left-pane"> <div class="member-edit-left-pane">
<div class="member-photo-field"> <div class="member-edit-profile-card">
<label class="text-[11px] font-black text-slate-600 block">프로필 사진</label> <label class="text-[11px] font-black text-slate-600 block">프로필 사진</label>
<div class="member-photo-upload-card member-photo-upload-card-compact"> <div class="member-photo-upload-card member-photo-upload-card-compact">
<div class="member-photo-preview-wrap"> <div class="member-photo-preview-wrap">
@@ -1126,21 +1153,16 @@ function openModal(id) {
<strong id="m-photo-file-name" class="member-photo-file-name">선택된 파일 없음</strong> <strong id="m-photo-file-name" class="member-photo-file-name">선택된 파일 없음</strong>
</div> </div>
</div> </div>
</div> <div class="member-name-field member-name-field-compact">
<div class="member-seat-field">
<div id="member-seat-preview">${renderSeatPreviewCard({ assigned: false, seatLabel: member['자리위치'] || '', seatMapName: '자리배치도', slotKey: '' })}</div>
</div>
</div>
<div class="member-edit-right-pane">
<div class="member-name-field">
<label class="text-[11px] font-black text-slate-600 block">이름 (필수)</label> <label class="text-[11px] font-black text-slate-600 block">이름 (필수)</label>
<input id="m-name" value="${member['이름'] || ''}" oninput="syncPhotoPreviewFromUrl()" class="w-full bg-slate-50 p-3 rounded-xl border font-bold text-sm outline-none"> <input id="m-name" value="${member['이름'] || ''}" oninput="syncPhotoPreviewFromUrl()" class="w-full bg-slate-50 p-3 rounded-xl border font-bold text-sm outline-none">
<div class="member-inline-info-grid member-inline-info-grid-edit"> </div>
<div class="member-inline-info-card"> <div class="member-inline-info-grid member-inline-info-grid-stacked">
<div class="member-inline-info-card member-inline-info-card-full">
<label>사번</label> <label>사번</label>
<input id="m-employee-id" value="${member['사번'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none"> <input id="m-employee-id" value="${member['사번'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none">
</div> </div>
<div class="member-inline-info-card"> <div class="member-inline-info-card member-inline-info-card-full">
<label>전화번호</label> <label>전화번호</label>
<input id="m-phone" value="${member['전화번호'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none"> <input id="m-phone" value="${member['전화번호'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none">
</div> </div>
@@ -1151,6 +1173,11 @@ function openModal(id) {
</div> </div>
</div> </div>
</div> </div>
<div class="member-edit-right-pane">
<div class="member-seat-field member-seat-field-emphasis">
<div id="member-seat-preview">${renderSeatPreviewCard({ assigned: false, seatLabel: member['자리위치'] || '', seatMapName: '자리배치도', slotKey: '' })}</div>
</div>
</div>
</div> </div>
</div> </div>
${orgFields} ${orgFields}

View File

@@ -29,6 +29,22 @@ case "${SCOPE}" in
seat_slots seat_slots
) )
;; ;;
analysis)
TABLES=(
integration_import_batches
integration_raw_organization_rows
integration_raw_mh_rows
integration_raw_mh_pm_rows
integration_raw_payment_rows
integration_project_aliases
integration_project_category_mappings
integration_project_pm_assignments
integration_projects
integration_work_logs
integration_work_log_segments
integration_vouchers
)
;;
full) full)
TABLES=( TABLES=(
integration_import_batches integration_import_batches
@@ -52,7 +68,7 @@ case "${SCOPE}" in
) )
;; ;;
*) *)
echo "Usage: $0 [minimal|full]" >&2 echo "Usage: $0 [minimal|analysis|full]" >&2
exit 1 exit 1
;; ;;
esac esac
@@ -83,6 +99,8 @@ trap cleanup EXIT
DUMP_FILE="${WORK_DIR}/prod_to_dev_${SCOPE}.sql" DUMP_FILE="${WORK_DIR}/prod_to_dev_${SCOPE}.sql"
TRUNCATE_FILE="${WORK_DIR}/truncate_${SCOPE}.sql" TRUNCATE_FILE="${WORK_DIR}/truncate_${SCOPE}.sql"
SEAT_POSITIONS_FILE="${WORK_DIR}/seat_positions.csv" SEAT_POSITIONS_FILE="${WORK_DIR}/seat_positions.csv"
SEQUENCE_FIX_FILE="${WORK_DIR}/sequence_fix.sql"
AUTH_SYNC_FILE="${WORK_DIR}/auth_sync.py"
echo "[3/6] Building truncate script for ${SCOPE} scope" echo "[3/6] Building truncate script for ${SCOPE} scope"
{ {
@@ -114,20 +132,20 @@ echo "[4.5/6] Exporting seat_positions in portable format"
echo "[5/6] Truncating target tables in 8081 dev DB" echo "[5/6] Truncating target tables in 8081 dev DB"
(cd "${DEV_DIR}" && "${DEV_COMPOSE[@]}" exec -T db \ (cd "${DEV_DIR}" && "${DEV_COMPOSE[@]}" exec -T db \
psql -q -v ON_ERROR_STOP=1 -U "${POSTGRES_USER:-orgapp}" -d "${POSTGRES_DB:-orgdb}") < "${TRUNCATE_FILE}" psql -q -v ON_ERROR_STOP=1 -U "${POSTGRES_USER:-orgapp}" -d "${POSTGRES_DB:-orgdb}" >/dev/null) < "${TRUNCATE_FILE}"
echo "[6/6] Restoring dumped data into 8081 dev DB" echo "[6/6] Restoring dumped data into 8081 dev DB"
(cd "${DEV_DIR}" && "${DEV_COMPOSE[@]}" exec -T db \ (cd "${DEV_DIR}" && "${DEV_COMPOSE[@]}" exec -T db \
psql -q -v ON_ERROR_STOP=1 -U "${POSTGRES_USER:-orgapp}" -d "${POSTGRES_DB:-orgdb}") < "${DUMP_FILE}" psql -q -v ON_ERROR_STOP=1 -U "${POSTGRES_USER:-orgapp}" -d "${POSTGRES_DB:-orgdb}" >/dev/null) < "${DUMP_FILE}"
echo "[6.5/6] Restoring portable seat_positions and rebuilding auth users" echo "[6.5/6] Restoring portable seat_positions and rebuilding auth users"
(cd "${DEV_DIR}" && "${DEV_COMPOSE[@]}" exec -T db \ (cd "${DEV_DIR}" && "${DEV_COMPOSE[@]}" exec -T db \
psql -q -v ON_ERROR_STOP=1 -U "${POSTGRES_USER:-orgapp}" -d "${POSTGRES_DB:-orgdb}" \ psql -q -v ON_ERROR_STOP=1 -U "${POSTGRES_USER:-orgapp}" -d "${POSTGRES_DB:-orgdb}" \
-c "DELETE FROM public.seat_positions") -c "DELETE FROM public.seat_positions" >/dev/null)
(cd "${DEV_DIR}" && "${DEV_COMPOSE[@]}" exec -T db \ (cd "${DEV_DIR}" && "${DEV_COMPOSE[@]}" exec -T db \
psql -q -v ON_ERROR_STOP=1 -U "${POSTGRES_USER:-orgapp}" -d "${POSTGRES_DB:-orgdb}" \ psql -q -v ON_ERROR_STOP=1 -U "${POSTGRES_USER:-orgapp}" -d "${POSTGRES_DB:-orgdb}" \
-c "COPY public.seat_positions (member_id, seat_map_id, seat_slot_id, row_index, col_index, seat_label, updated_at) FROM STDIN WITH CSV") < "${SEAT_POSITIONS_FILE}" -c "COPY public.seat_positions (member_id, seat_map_id, seat_slot_id, row_index, col_index, seat_label, updated_at) FROM STDIN WITH CSV" >/dev/null) < "${SEAT_POSITIONS_FILE}"
(cd "${DEV_DIR}" && "${DEV_COMPOSE[@]}" exec -T backend python - <<'PY' cat > "${AUTH_SYNC_FILE}" <<'PY'
from backend.app.main import get_conn, sync_auth_users_from_members from backend.app.main import get_conn, sync_auth_users_from_members
with get_conn() as conn: with get_conn() as conn:
@@ -145,7 +163,33 @@ with get_conn() as conn:
conn.commit() conn.commit()
print("members, seat labels, and auth users synced") print("members, seat labels, and auth users synced")
PY PY
) (cd "${DEV_DIR}" && "${DEV_COMPOSE[@]}" exec -T backend python -) < "${AUTH_SYNC_FILE}"
echo "[6.8/6] Resetting serial sequences"
{
echo "SELECT setval(pg_get_serial_sequence('public.members', 'id'), COALESCE((SELECT MAX(id) FROM public.members), 1), true);"
echo "SELECT setval(pg_get_serial_sequence('public.member_aliases', 'id'), COALESCE((SELECT MAX(id) FROM public.member_aliases), 1), true);"
echo "SELECT setval(pg_get_serial_sequence('public.member_overrides', 'id'), COALESCE((SELECT MAX(id) FROM public.member_overrides), 1), true);"
echo "SELECT setval(pg_get_serial_sequence('public.member_retirements', 'id'), COALESCE((SELECT MAX(id) FROM public.member_retirements), 1), true);"
echo "SELECT setval(pg_get_serial_sequence('public.seat_maps', 'id'), COALESCE((SELECT MAX(id) FROM public.seat_maps), 1), true);"
echo "SELECT setval(pg_get_serial_sequence('public.seat_slots', 'id'), COALESCE((SELECT MAX(id) FROM public.seat_slots), 1), true);"
if [[ "${SCOPE}" == "analysis" || "${SCOPE}" == "full" ]]; then
echo "SELECT setval(pg_get_serial_sequence('public.integration_import_batches', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_import_batches), 1), true);"
echo "SELECT setval(pg_get_serial_sequence('public.integration_raw_organization_rows', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_raw_organization_rows), 1), true);"
echo "SELECT setval(pg_get_serial_sequence('public.integration_raw_mh_rows', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_raw_mh_rows), 1), true);"
echo "SELECT setval(pg_get_serial_sequence('public.integration_raw_mh_pm_rows', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_raw_mh_pm_rows), 1), true);"
echo "SELECT setval(pg_get_serial_sequence('public.integration_raw_payment_rows', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_raw_payment_rows), 1), true);"
echo "SELECT setval(pg_get_serial_sequence('public.integration_project_aliases', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_project_aliases), 1), true);"
echo "SELECT setval(pg_get_serial_sequence('public.integration_project_category_mappings', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_project_category_mappings), 1), true);"
echo "SELECT setval(pg_get_serial_sequence('public.integration_project_pm_assignments', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_project_pm_assignments), 1), true);"
echo "SELECT setval(pg_get_serial_sequence('public.integration_projects', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_projects), 1), true);"
echo "SELECT setval(pg_get_serial_sequence('public.integration_work_logs', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_work_logs), 1), true);"
echo "SELECT setval(pg_get_serial_sequence('public.integration_work_log_segments', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_work_log_segments), 1), true);"
echo "SELECT setval(pg_get_serial_sequence('public.integration_vouchers', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_vouchers), 1), true);"
fi
} > "${SEQUENCE_FIX_FILE}"
(cd "${DEV_DIR}" && "${DEV_COMPOSE[@]}" exec -T db \
psql -q -v ON_ERROR_STOP=1 -U "${POSTGRES_USER:-orgapp}" -d "${POSTGRES_DB:-orgdb}" >/dev/null) < "${SEQUENCE_FIX_FILE}"
echo echo
echo "Sync complete." echo "Sync complete."