Fix seatmap slot matching and update member modal layout
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 기능 고도화
|
|
||||||
- 실제 인증 체계 전환
|
|
||||||
- 나머지 사무실 도면 추가
|
|
||||||
|
|||||||
@@ -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. 검증 완료 후 공개용에 코드 승격
|
||||||
|
|
||||||
## 다음 액션
|
## 다음 액션
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|
||||||
|
|||||||
160
docs/REGRESSION_CHECKLIST.md
Normal file
160
docs/REGRESSION_CHECKLIST.md
Normal 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 중 무엇을 사용했는지
|
||||||
|
|
||||||
|
검증 결과:
|
||||||
|
- 조직도: 정상
|
||||||
|
- 관리자모드 모달: 정상
|
||||||
|
- 자리배치도 연동: 정상 또는 미검증
|
||||||
|
- 프로젝트별 분석: 정상 또는 미검증
|
||||||
|
```
|
||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
Reference in New Issue
Block a user