Compare commits

..

5 Commits

Author SHA1 Message Date
hyunho
637b390024 backup: snapshot local design source assets 2026-03-31 17:52:27 +09:00
hyunho
4b4ffafbd2 docs: persist isolated 8081 startup workflow 2026-03-31 17:47:39 +09:00
hyunho
1cd0f21a36 docs: codify isolated 8081 worktree workflow 2026-03-31 17:34:13 +09:00
hyunho
f77be3f482 feat: promote seatmap and organization updates 2026-03-30 16:40:07 +09:00
hyunho
2e8c79bb43 docs: require explicit instruction for commits 2026-03-30 10:28:49 +09:00
24 changed files with 9563 additions and 81 deletions

1
.gitignore vendored
View File

@@ -16,3 +16,4 @@ incoming-files/~$*
incoming-files/6f.html
incoming-files/7f.html
incoming-files/center.html
.dev-worktree-8081/

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?v=20260327-03" />
<link rel="stylesheet" href="/legacy/static/organization.css?v=20260327-03" />
<link rel="stylesheet" href="/legacy/static/common.css?v=20260331-01" />
<link rel="stylesheet" href="/legacy/static/organization.css?v=20260331-01" />
</head>
<body>
<input type="file" id="upload-excel" class="hidden" accept=".xlsx, .csv" />
@@ -60,6 +60,6 @@
</div>
</div>
<script src="/legacy/static/organization.js?v=20260327-03"></script>
<script src="/legacy/static/organization.js?v=20260331-01"></script>
</body>
</html>

View File

@@ -982,7 +982,7 @@ def parse_fixed_office_template(office_key: str = FIXED_OFFICE_SOURCE_KEY) -> di
raise HTTPException(status_code=500, detail=f"Fixed office viewer data not found: {office_key}")
html = re.sub(
r'<script\s+src="\./[^"]+payload[^"]*\.js"></script>',
r'<script\s+src="\./[^"]+payload[^"]*\.js(?:\?[^"]*)?"></script>',
f"<script>{payload_js}</script>",
html,
count=1,
@@ -1582,6 +1582,10 @@ def fetch_seat_layout(seat_map_id: int, as_of: datetime | None = None) -> dict[s
(as_of, as_of, seat_map_id, as_of, as_of),
)
placements = cur.fetchall()
cur.execute("SELECT name FROM member_retirements")
retired_names = {str(row["name"] or "").strip() for row in cur.fetchall() if str(row["name"] or "").strip()}
for member in members:
member["is_retired"] = str(member.get("name") or "").strip() in retired_names
viewer_data: dict[str, object] | None = None
office_key = str(seat_map.get("source_url") or FIXED_OFFICE_SOURCE_KEY)
fixed_office = FIXED_OFFICE_CONFIGS.get(office_key)
@@ -1807,6 +1811,8 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
<script>
const seatAssignments = new Map();
let selectedChairKey = null;
let focusedChairKey = null;
let focusedChairPulseUntil = 0;
let viewerMode = "default";
const popup = document.createElement("div");
popup.className = "seat-popup";
@@ -1854,6 +1860,9 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
function focusChair(chairKey, padding = 2200) {
const chair = chairGeometry.find((item) => String(item.key) === String(chairKey));
if (!chair) return;
selectedChairKey = String(chairKey);
focusedChairKey = String(chairKey);
focusedChairPulseUntil = Date.now() + 2600;
const rect = canvas.getBoundingClientRect();
const pad = 24;
const minX = chair.minX - padding;
@@ -1919,8 +1928,31 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
const originalDraw = draw;
draw = function drawWithAssignments() {
originalDraw();
if (!seatAssignments.size) return;
const rect = canvas.getBoundingClientRect();
const now = Date.now();
const focusedChair = focusedChairKey
? chairGeometry.find((item) => String(item.key) === String(focusedChairKey))
: null;
if (focusedChair && now <= focusedChairPulseUntil) {
const pulse = (Math.sin(now / 180) + 1) / 2;
const center = worldToScreen((focusedChair.minX + focusedChair.maxX) / 2, (focusedChair.minY + focusedChair.maxY) / 2);
const width = Math.max(34, (focusedChair.maxX - focusedChair.minX) * camera.scale + 20 + pulse * 18);
const height = Math.max(34, (focusedChair.maxY - focusedChair.minY) * camera.scale + 20 + pulse * 18);
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.save();
ctx.strokeStyle = `rgba(245, 158, 11, ${0.38 + pulse * 0.32})`;
ctx.lineWidth = 3 + pulse * 2;
ctx.shadowColor = "rgba(245, 158, 11, 0.35)";
ctx.shadowBlur = 16 + pulse * 10;
ctx.beginPath();
ctx.roundRect(center.x - width / 2, center.y - height / 2, width, height, 16);
ctx.stroke();
ctx.restore();
if (typeof requestDraw === "function") requestDraw();
} else if (focusedChair && now > focusedChairPulseUntil) {
focusedChairPulseUntil = 0;
}
if (!seatAssignments.size) return;
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.textBaseline = "middle";
for (const chair of chairGeometry) {

View File

@@ -11,20 +11,21 @@
### 코드 경로
- 공개용 `8080`: `/home/hyunho/projects/mh-dashboard-organization`
- 작업용 `8081`: `/home/hyunho/projects/mh-dashboard-organization`
- 작업용 `8081`: `/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081`
### 작업용 Compose 기준
- 공개용 `8080` stack: `docker-compose.yml`
- 작업용 `8081` stack: `docker-compose.8081.yml`
- 작업용 project name 기본값: `mh-dashboard-organization-dev`
- 작업용 `8081`는 반드시 `/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081`에서 띄운다
### DB 볼륨
- 공개용 `8080`: `mh-dashboard-organization_postgres_data`
- 작업용 `8081`: `mh-dashboard-organization-dev_postgres_data`
즉 현재는 코드 workspace는 같아도 compose project 와 DB volume 분리된 상태다.
즉 현재는 `8080``8081` 이 코드 workspace 와 DB volume 모두 분리된 상태로 운영한다.
## 정본 기준
@@ -172,11 +173,30 @@
사용 방법:
```bash
docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compose.8081.yml up -d
./scripts/prepare_dev_worktree.sh
cd /home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081
docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compose.8081.yml up -d --build
./scripts/sync_prod_db_to_dev.sh minimal
./scripts/sync_prod_db_to_dev.sh full
```
`prepare_dev_worktree.sh`가 같이 처리하는 것:
- 메인 workspace를 `.dev-worktree-8081`로 복제 또는 재사용
- `.env` 복사
- 로컬 전용 디자인 참고 자산 복사
- `incoming-files/sample style.css`
- `incoming-files/260320.html`
- `incoming-files/사업관리대장/`
- `incoming-files/1.png`
- `incoming-files/seat/center_chair_people_map(2).html`
중요:
- `8081`은 현재 메인 workspace를 직접 마운트하면 안 된다
- 컨테이너가 `/home/hyunho/projects/mh-dashboard-organization/...`를 물고 있으면 분리 상태가 깨진 것이다
- 정상 상태는 `docker inspect mh-dashboard-organization-dev-backend-1` 기준 마운트 소스가 `/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/...`로 나와야 한다
규칙:
- `minimal`

View File

@@ -6,6 +6,7 @@
- latest checked commit: `24852d4`
- main history doc: [DEVELOPMENT_HISTORY.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEVELOPMENT_HISTORY.md)
- work rulebook: [WORK_RULEBOOK.md](/home/hyunho/projects/mh-dashboard-organization/docs/WORK_RULEBOOK.md)
- execution flow: [WORK_EXECUTION_FLOW.md](/home/hyunho/projects/mh-dashboard-organization/docs/WORK_EXECUTION_FLOW.md)
- dev/prod protocol: [DEV_PROD_DB_PROTOCOL.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEV_PROD_DB_PROTOCOL.md)
- regression checklist: [REGRESSION_CHECKLIST.md](/home/hyunho/projects/mh-dashboard-organization/docs/REGRESSION_CHECKLIST.md)
- today prep note: [TODAY_WORK_PREP_2026-03-30.md](/home/hyunho/projects/mh-dashboard-organization/docs/TODAY_WORK_PREP_2026-03-30.md)
@@ -23,6 +24,7 @@
주의:
- 위 절차를 확인하기 전에는 새 코드 작성이나 기존 코드 수정부터 시작하지 않는다.
- 커밋과 푸시는 자동으로 하지 않고, 사용자 지시가 있을 때만 수행한다.
## What Was Finished
@@ -96,8 +98,10 @@
- 코드 선행은 `8081`, 공개 반영은 `8080`
- 데이터 정본은 `8080` DB
- `8081` DB는 독립 정본이 아니라 `8080` 기준 복제본처럼 관리해야 함
- `8081` 코드는 `.dev-worktree-8081` 기준으로 유지
- 조직도, 멤버, 자리배치 검증 전에는 `DEV_PROD_DB_PROTOCOL.md`를 먼저 확인
- 기능 수정 후 완료 판단은 `REGRESSION_CHECKLIST.md`를 기준으로 해야 함
- 빠른 재시작은 `./scripts/start_local_dashboards.sh`
### Seat Map Save

View File

@@ -25,7 +25,8 @@
- `8081` 작업용 접속 확인
- `8080` 공개용 접속 확인
- `docker compose ps`에서 `backend`, `frontend`, `proxy`, `db`가 정상인지 확인
- `8081`은 기본적으로 `docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compose.8081.yml up -d` 로 기동
- `8081`은 기본적으로 `./scripts/start_8081.sh` 또는 `./scripts/prepare_dev_worktree.sh``.dev-worktree-8081` 에서 `docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compose.8081.yml up -d --build` 로 기동
- `8081` 기동 후 `docker inspect mh-dashboard-organization-dev-backend-1`에서 마운트 경로가 `.dev-worktree-8081/...`인지 확인
### 2. 데이터 동기화 범위 결정

259
docs/WORK_EXECUTION_FLOW.md Normal file
View File

@@ -0,0 +1,259 @@
# Work Execution Flow
## 목적
이 문서는 앞으로 이 프로젝트에서 작업을 어떤 순서로 진행해야 하는지 아주 쉽게 고정하기 위한 문서다.
세미나에서 들은 흐름을 이 프로젝트 기준으로 다시 쓰면 아래 순서다.
1. `SSOT` 먼저 확인
2. 이슈 생성 또는 연결
3. 완료조건 먼저 적기
4. 실행 계획 적기
5. 필요한 동기화 먼저 하기
6. 코드 수정 / 화면 작업 수행
7. 가드레일 테스트
8. 기록 남기기
이 순서를 지키는 이유는 하나다.
- 작업 도중 기준이 바뀌지 않게 하기
- 임시 연결이 누적되지 않게 하기
- 나중에 봐도 왜 이렇게 했는지 알 수 있게 하기
- `8081` 작업이 `8080`을 망가뜨리지 않게 하기
## 1. SSOT 먼저 확인
`SSOT`는 Single Source Of Truth 의 줄임말이다.
쉬운 말로:
- "무엇을 기준 진실로 볼 것인가"
이걸 먼저 정하지 않으면 작업 중간에 기준이 계속 바뀌어서 코드가 꼬인다.
이 프로젝트에서 자주 쓰는 SSOT:
- 공개용 코드 기준: `/home/hyunho/projects/mh-dashboard-organization`
- 작업용 코드 기준: `/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081`
- 데이터 정본 기준: `8080` DB
- 기능 검증 기준: `8081`
- 사업관리대장 디자인 기준: `MH 통합 대시보드_260320.html`
- 허브 공통 시각 언어 기준: `sample style.css`
- 현재 작업 지시 기준: 연결된 Gitea 이슈
작업 시작 전에 먼저 정해야 하는 질문:
- 이번 작업의 코드 기준은 어디인가?
- 이번 작업의 데이터 기준은 어디인가?
- 이번 화면의 디자인 기준 파일은 무엇인가?
- 지금 바꾸려는 화면이 실제로 어떤 파일에서 렌더링되는가?
이걸 모르고 코드를 건드리면 높은 확률로 엉뚱한 파일을 수정하게 된다.
## 2. 이슈 생성 또는 연결
작업은 이슈 없이 하지 않는다.
이유:
- 왜 하는 작업인지 남기기 위해
- 중간에 범위가 커지는 걸 막기 위해
- 다음 세션에서 바로 이어가기 위해
좋은 이슈는 아래 4개가 있어야 한다.
1. 배경
2. 목표
3. 현재 상태
4. 남은 작업
이슈는 길게 쓸 필요는 없다.
하지만 최소한 아래는 있어야 한다.
- 왜 이 작업을 하는지
- 어디까지가 이번 범위인지
- 무엇을 완료로 볼지
## 3. 완료조건 먼저 적기
이 단계가 중요하다.
완료조건이 없으면 "대충 된 것 같음" 상태에서 끝나기 쉽다.
좋은 완료조건 예시:
- `8081``.dev-worktree-8081`를 실제로 마운트한다
- `사업관리대장` 탭이 원본 기준 레이아웃으로 열린다
- `8080`은 영향 없이 유지된다
- 관련 회귀 검증을 통과한다
나쁜 완료조건 예시:
- 화면이 좀 괜찮아 보인다
- 아마 될 것 같다
- 코드 정리함
완료조건은 반드시 확인 가능한 문장이어야 한다.
즉:
- "봤을 때 예쁨"이 아니라
- "어떤 URL에서 어떤 동작이 확인됨"이어야 한다
## 4. 실행 계획 적기
계획은 길 필요 없다.
이 프로젝트에서는 보통 아래 정도면 충분하다.
1. 기준 파일과 현재 연결 구조 확인
2. `8081` worktree 기준으로만 수정
3. 필요한 데이터 동기화
4. 화면/기능 수정
5. 회귀 검증
6. 이슈 코멘트와 체크포인트 기록
핵심은:
- 수정 전에 먼저 구조를 파악하고
- 범위를 정하고
- 검증까지 포함해서 끝내는 것
## 5. 실행 전 동기화
이 프로젝트는 코드만 맞아도 안 되고, 데이터도 맞아야 한다.
그래서 실행 전에 동기화가 필요할 수 있다.
무슨 뜻이냐면:
- `8081`에서 기능 확인을 하더라도
- 데이터가 `8080`과 다르면 검증 결과를 신뢰하면 안 된다
자주 쓰는 규칙:
- 조직도 / 멤버 / 자리배치 검증 전
- `./scripts/sync_prod_db_to_dev.sh minimal`
- 분석 화면까지 공개용 기준으로 맞춰야 할 때
- `./scripts/sync_prod_db_to_dev.sh full`
또 코드 동기화도 중요하다.
- `8081`은 메인 workspace에서 직접 띄우지 않는다
- 먼저 `./scripts/prepare_dev_worktree.sh`
- 그 다음 `.dev-worktree-8081`에서 실행
즉 이 프로젝트의 동기화는 두 종류다.
- DB 동기화
- 코드/worktree 동기화
## 6. 실제 실행
이 단계가 코드를 고치는 단계다.
하지만 여기서도 규칙이 있다.
- `8081`에서 먼저 작업
- 기준 파일이 아닌 곳은 건드리지 않기
- 임시 우회 연결을 만들었으면 반드시 기록 남기기
- 연결 구조가 난잡해지면 바로 이슈에 `코드 정리 필요`를 남기기
특히 이 프로젝트는 아래가 자주 꼬인다.
- `frontend/public`
- `legacy/static`
- `incoming-files`
- 정적 HTML
- iframe 연결
- 버전 쿼리스트링
그래서 실행 중 계속 확인해야 한다.
- 지금 내가 고친 파일이 실제 서빙 파일이 맞는가?
- 지금 수정이 `8081` 전용인가, `8080` 공통인가?
- 이 연결은 임시인가, 기준 구조인가?
## 7. 가드레일 테스트
가드레일 테스트는 쉬운 말로:
- "이 수정 때문에 같이 망가지면 안 되는 것들을 확인하는 테스트"
즉 핵심 기능만 보는 게 아니라, 같이 깨지기 쉬운 주변 기능까지 확인하는 것이다.
이 프로젝트에서 가드레일 테스트 예시:
- `8081` 디자인 수정 후
- `8080`은 그대로인지 확인
- 조직현황 수정 후
- 조직도 iframe, 모달, 리스트뷰, seat preview 확인
- 자리배치 수정 후
- 관리자 저장
- 비관리자 조회
- 조직도 상세 seat preview
- 분석 화면 수정 후
- 기간 필터
- 프로젝트/팀 전환
- 빈 데이터 상태
- 스타일 깨짐 여부
가드레일 테스트는 "다 테스트한다"가 아니다.
이번 수정 때문에 같이 깨질 가능성이 높은 것만 빠르게 확인하는 것이다.
## 8. 기록 남기기
작업은 기록까지 남겨야 끝난다.
남겨야 하는 것:
- 무엇을 바꿨는지
- 무엇을 기준으로 했는지
- 무엇을 검증했는지
- 무엇이 아직 안 끝났는지
- 다음에 어디서 이어야 하는지
남길 위치:
- Gitea 이슈 코멘트
- 체크포인트 문서
- 필요하면 룰북/프로토콜 문서
## 이 프로젝트용 한 줄 버전
앞으로는 아래 순서로 생각하면 된다.
1. 기준 진실부터 정한다
2. 이슈에 작업 목적과 완료조건을 적는다
3. 실행 전에 코드/DB 동기화를 맞춘다
4. `8081`에서만 수정한다
5. 같이 깨지면 안 되는 것까지 확인한다
6. 결과를 기록한다
## 시작할 때 바로 쓰는 짧은 템플릿
작업 시작 전에 아래 6줄만 적어도 된다.
- SSOT:
- 코드 기준:
- 데이터 기준:
- 디자인 기준:
- 이슈:
- 완료조건:
- 계획:
- 필요한 동기화:
- 가드레일 테스트:
예시:
- SSOT:
- 코드 기준: `/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081`
- 데이터 기준: `8080` DB를 sync한 `8081`
- 디자인 기준: `MH 통합 대시보드_260320.html`
- 이슈: `#16`
- 완료조건: `8081`에서 사업관리대장 메인이 원본 톤으로 열리고 `8080`은 안 바뀜
- 계획: 연결 확인 → worktree 수정 → 검증 → 이슈 기록
- 필요한 동기화: `minimal`
- 가드레일 테스트: `8080 유지`, `조직현황 탭`, `프로젝트/팀 탭`

View File

@@ -30,6 +30,11 @@
- "오늘 첫 작업"의 시작점은 코드 수정이 아니라 상태 확인이다.
- 이 절차를 건너뛰고 바로 수정 작업에 들어가는 것은 금지한다.
추가 기준:
- 실제 작업 순서는 [WORK_EXECUTION_FLOW.md](/home/hyunho/projects/mh-dashboard-organization/docs/WORK_EXECUTION_FLOW.md) 를 따른다.
- 특히 `SSOT → 이슈 → 완료조건 → 계획 → 동기화 → 실행 → 가드레일 테스트 → 기록` 순서를 기본 운영 흐름으로 본다.
## Rule 1. Completed Feature Protection
완료 판정된 작업물의 기능과 코드는 함부로 건드리지 않는다.
@@ -181,6 +186,54 @@ mock, fallback, hotfix, 임시 우회 로직은 허용할 수 있다.
둘 다 가능하면 둘 다 남긴다.
## Rule 11. Commit And Push Need Explicit User Instruction
커밋과 푸시는 자동으로 하지 않는다.
세부 규칙:
- 코드 수정, 문서 수정, 검증 작업은 커밋 없이 계속 진행할 수 있다.
- `git commit` 은 사용자가 명시적으로 지시한 경우에만 수행한다.
- `git push` 도 사용자가 명시적으로 지시한 경우에만 수행한다.
- 작업 중간 상태는 워크트리에 남겨둘 수 있으며, 임의로 잘라서 자주 커밋하지 않는다.
- 커밋이 필요하다고 판단되면 먼저 상태와 이유를 공유하고, 지시를 받은 뒤 진행한다.
## Rule 12. Promote 8081 To 8080 By Reviewed File Diff Only
`8081` 작업용에서 검증된 변경을 `8080` 공개용으로 가져갈 때는 전체 workspace 를 통째로 덮지 않는다.
세부 규칙:
- 먼저 `8081` 작업용의 변경 파일 목록과 diff 를 확인한다.
- 공개용에 필요한 파일만 선택해서 메인 workspace 로 반영한다.
- 반영 후에는 메인 workspace 기준으로 최소 회귀 검증을 다시 수행한다.
- `8081` DB 기준으로만 맞는 수정인지, `8080` 기준 데이터에서도 맞는지 다시 확인한다.
- 검증이 끝나기 전에는 공개용 완료로 판단하지 않는다.
금지:
- `8081` 작업 디렉터리를 통째로 복사해서 `8080`에 덮어쓰기
- diff 확인 없이 일괄 반영
- `8081`에서 됐으니 `8080`도 같을 것이라고 가정하기
## Rule 13. 8081 Must Start From The Isolated Worktree
`8081` 작업용은 포트만 다른 복제 서버가 아니라, 코드 소스까지 분리된 전용 worktree여야 한다.
세부 규칙:
- `8081`은 항상 `.dev-worktree-8081`에서 띄운다.
- 기동 전 `./scripts/prepare_dev_worktree.sh`를 먼저 실행한다.
- 재부팅 후 빠른 기동은 `./scripts/start_8081.sh` 또는 `./scripts/start_local_dashboards.sh`를 사용한다.
- `.env`와 로컬 전용 디자인 자산은 준비 스크립트가 복사한 것을 기준으로 사용한다.
- 기동 후 `docker inspect mh-dashboard-organization-dev-backend-1`로 마운트 소스를 확인한다.
금지:
- 현재 메인 workspace를 직접 마운트한 상태로 `8081`을 띄우기
- `8080``8081`이 같은 `frontend/public`, `legacy/static`, `incoming-files`를 동시에 보게 두기
- `8081`에서 보이던 디자인을 `8080` 공통 소스에 바로 덮어쓰기
## Daily Start Checklist
매일 첫 작업 시작 전 체크:
@@ -191,6 +244,7 @@ mock, fallback, hotfix, 임시 우회 로직은 허용할 수 있다.
- `WORK_RULEBOOK.md` 확인
- 최신 체크포인트 확인
- 미추적 / 수정 파일 확인
- 현재 작업은 커밋 없이 진행하고, 커밋/푸시는 지시받을 때만 한다는 규칙 확인
- 오늘 작업이 코드 문제인지 DB 문제인지 먼저 구분
- 공개용 기준 데이터 검증이 필요한지 판단

View File

@@ -325,9 +325,7 @@ function buildAuthHeaders(headers) {
function shouldShowGlobalDateControls() {
return currentView === "ledger"
|| currentView === "project"
|| currentView === "team"
|| currentView === "seatmap-admin"
|| currentView === "seatmap-readonly";
|| currentView === "team";
}
function syncGlobalDateControlVisibility() {
@@ -361,6 +359,10 @@ function buildAsOfQuery() {
return `?as_of=${encodeURIComponent(asOf)}`;
}
function buildSeatMapAsOfQuery() {
return "";
}
function notifyEmbeddedTabActivated() {
if (currentView === "project" && projectFrame?.contentWindow) {
projectFrame.contentWindow.postMessage({ source: "total-control", type: "tab-activated", tab: "project" }, window.location.origin);
@@ -787,6 +789,22 @@ function getPlacementForMember(memberId) {
return getPlacementSource().find((item) => Number(item.member_id) === Number(memberId)) || null;
}
function isMemberAssignedAnywhere(member) {
const seatLabel = String(
member?.member_seat_label
|| member?.seat_label
|| ""
).trim();
return Boolean(seatLabel);
}
function shouldHideMemberFromSeatMap(member) {
if (Boolean(member?.is_retired)) return true;
const workStatus = String(member?.work_status || "").trim();
if (/(퇴사|퇴직)/u.test(workStatus)) return true;
return false;
}
function memberMatchesSeatMapSearch(member) {
const keyword = seatMapState.search.trim().toLowerCase();
if (!keyword) return true;
@@ -839,7 +857,9 @@ function renderSeatMapOfficeTabs() {
function getUnassignedMembers() {
const placedIds = new Set(getPlacementSource().map((item) => Number(item.member_id)));
return seatMapState.members.filter((member) => {
if (shouldHideMemberFromSeatMap(member)) return false;
if (placedIds.has(Number(member.id))) return false;
if (isMemberAssignedAnywhere(member)) return false;
return memberMatchesSeatMapSearch(member);
});
}
@@ -847,6 +867,7 @@ function getUnassignedMembers() {
function getPlacedMembers() {
const placedIds = new Set(getPlacementSource().map((item) => Number(item.member_id)));
return seatMapState.members.filter((member) => {
if (shouldHideMemberFromSeatMap(member)) return false;
if (!placedIds.has(Number(member.id))) return false;
return memberMatchesSeatMapSearch(member);
});
@@ -1011,7 +1032,7 @@ function renderDxfSeatMapBoard() {
seatMapBoard.innerHTML = `<div class="seatmap-empty-card"><strong>DXF 뷰어 데이터를 준비하지 못했습니다.</strong></div>`;
return;
}
const viewerUrl = resolveAppUrl(`/api/seat-maps/${seatMapState.seatMap.id}/viewer${buildAsOfQuery()}`);
const viewerUrl = resolveAppUrl(`/api/seat-maps/${seatMapState.seatMap.id}/viewer${buildSeatMapAsOfQuery()}`);
seatMapBoard.innerHTML = `
<div class="seatmap-dxf-frame-shell">
<div class="seatmap-dxf-drop-overlay" data-seatmap-drop-overlay></div>
@@ -1354,7 +1375,7 @@ async function loadSeatMapData(force = false) {
const office = getCurrentSeatMapOffice();
const activePayload = await fetchJson(`/api/seat-maps/active?office_key=${encodeURIComponent(office.key)}`);
const activeSeatMap = activePayload.item;
const layoutPayload = await fetchJson(`/api/seat-maps/${activeSeatMap.id}/layout${buildAsOfQuery()}`);
const layoutPayload = await fetchJson(`/api/seat-maps/${activeSeatMap.id}/layout${buildSeatMapAsOfQuery()}`);
seatMapState.seatMap = {
...(layoutPayload.seat_map || {}),
viewer_data: layoutPayload.viewer_data || null,

View File

@@ -93,7 +93,7 @@
<main class="dashboard-main">
<section id="organization-stage" class="main-stage">
<div class="stage-frame">
<iframe id="organization-frame" src="/legacy/organization?v=20260330-01" data-src="/legacy/organization?v=20260330-01" title="조직도 메인 화면"></iframe>
<iframe id="organization-frame" src="/legacy/organization?v=20260331-01" data-src="/legacy/organization?v=20260331-01" title="조직도 메인 화면"></iframe>
</div>
</section>
<section id="project-stage" class="main-stage" hidden>

BIN
incoming-files/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 748 KiB

2598
incoming-files/260320.html Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,931 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>center chair people map</title>
<style>
:root {
--ink: #152330;
--muted: #627286;
--paper: rgba(255,255,255,0.86);
--line: rgba(21,35,48,0.1);
--accent: #0f766e;
--bg: #edf2f6;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
}
.page {
min-height: 100vh;
padding: 0;
}
.shell {
min-height: 100vh;
}
.panel {
border-radius: 0;
border: none;
background: transparent;
backdrop-filter: none;
box-shadow: none;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
button {
border: none;
border-radius: 999px;
padding: 10px 14px;
font: inherit;
font-weight: 700;
cursor: pointer;
color: white;
background: linear-gradient(135deg, #0f766e, #115e59);
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
}
button.alt {
color: var(--ink);
background: rgba(255,255,255,0.9);
border: 1px solid var(--line);
box-shadow: none;
}
.viewer {
position: relative;
overflow: hidden;
min-height: 100vh;
}
.viewer-head {
position: absolute;
top: 16px;
left: 16px;
right: 16px;
z-index: 2;
display: flex;
justify-content: space-between;
gap: 12px;
pointer-events: none;
}
.chip {
padding: 10px 12px;
border-radius: 16px;
background: rgba(255,255,255,0.82);
border: 1px solid rgba(255,255,255,0.94);
color: var(--muted);
font-size: 13px;
font-weight: 700;
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
}
.viewer-actions {
position: absolute;
left: 16px;
top: 64px;
z-index: 2;
display: flex;
gap: 8px;
}
.mapper {
position: absolute;
top: 76px;
left: 50%;
transform: translateX(-50%);
width: min(94vw, 1320px);
max-height: min(56vh, 560px);
overflow: hidden;
z-index: 4;
border-radius: 20px;
background: rgba(234, 239, 247, 0.95);
border: 1px solid rgba(101, 119, 146, 0.22);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
display: flex;
flex-direction: column;
backdrop-filter: blur(6px);
}
.hidden-off {
display: none !important;
}
.mapper-head {
padding: 10px 14px;
border-bottom: 1px solid rgba(101,119,146,0.18);
font-size: 12px;
color: #51607a;
font-weight: 700;
line-height: 1.35;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: rgba(255,255,255,0.6);
}
.mapper-head strong {
display: block;
color: #17243b;
font-size: 20px;
margin-bottom: 2px;
}
.mapper-head .alt {
padding: 8px 10px;
font-size: 12px;
white-space: nowrap;
}
.org-chart {
margin: 0;
padding: 14px;
overflow: auto;
display: grid;
gap: 12px;
}
.org-top {
margin: 0 auto;
width: min(100%, 420px);
border-radius: 14px;
overflow: hidden;
border: 1px solid rgba(67, 84, 118, 0.25);
background: #fff;
}
.org-top-title {
background: #1e2f4d;
color: #fff;
text-align: center;
font-size: 34px;
font-weight: 800;
line-height: 1.1;
padding: 16px 12px;
letter-spacing: -0.03em;
}
.org-top-members {
padding: 10px;
display: grid;
gap: 6px;
background: rgba(255,255,255,0.95);
}
.org-teams {
display: grid;
grid-template-columns: repeat(7, minmax(160px, 1fr));
gap: 10px;
align-items: start;
}
.org-team {
border: 1px solid rgba(110, 126, 152, 0.25);
border-radius: 10px;
overflow: hidden;
background: rgba(255,255,255,0.95);
min-width: 0;
}
.org-team h4 {
margin: 0;
padding: 9px 10px;
font-size: 14px;
color: #21324e;
font-weight: 800;
border-bottom: 1px solid rgba(110, 126, 152, 0.2);
background: rgba(240, 245, 252, 0.96);
}
.org-members {
padding: 7px;
display: grid;
gap: 6px;
}
.org-person {
border: 1px solid rgba(116, 133, 161, 0.25);
background: rgba(255,255,255,0.95);
border-radius: 8px;
padding: 6px 8px;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
min-width: 0;
}
.org-person.active {
border-color: rgba(15,118,110,0.6);
background: rgba(15,118,110,0.11);
}
.org-person.assigned {
border-color: rgba(37,99,235,0.5);
background: rgba(37,99,235,0.1);
}
.org-person strong {
display: block;
font-size: 13px;
line-height: 1.3;
color: #15233a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.org-person small {
display: block;
color: #5a6a86;
font-size: 11px;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 980px) {
.mapper {
top: 72px;
width: min(96vw, 920px);
max-height: 58vh;
}
.viewer-actions {
top: 64px;
left: 12px;
right: 12px;
flex-wrap: wrap;
}
.mapper-head strong {
font-size: 16px;
}
.org-top-title {
font-size: 24px;
}
.org-teams {
grid-template-columns: repeat(3, minmax(150px, 1fr));
}
}
canvas {
width: 100%;
height: 100%;
display: block;
cursor: grab;
}
canvas.dragging { cursor: grabbing; }
.tooltip {
position: absolute;
min-width: 170px;
padding: 12px 14px;
border-radius: 16px;
background: rgba(17,24,39,0.94);
color: white;
pointer-events: none;
opacity: 0;
transform: translate(12px, 12px);
transition: opacity 120ms ease;
z-index: 3;
}
.tooltip.visible { opacity: 1; }
.tooltip strong { display: block; margin-bottom: 6px; font-size: 14px; }
.tooltip div { font-size: 12px; line-height: 1.45; color: rgba(255,255,255,0.82); }
</style>
</head>
<body>
<div class="page">
<div class="shell">
<main class="panel viewer">
<div class="viewer-head">
<div class="chip" id="scale-chip"></div>
<div class="chip" id="hover-chip">chair hover: none</div>
</div>
<div class="viewer-actions">
<button type="button" id="fit-btn">전체 맞춤</button>
<button type="button" class="alt" id="clear-btn">선택 지우기</button>
</div>
<aside class="mapper hidden-off">
<div class="mapper-head">
<div id="mapper-status">
<strong>조직 현황</strong>
<span>선택 인원 없음</span>
</div>
<button type="button" class="alt" id="clear-assign-btn">매칭 초기화</button>
</div>
<div class="org-chart" id="org-chart"></div>
</aside>
<canvas id="canvas"></canvas>
<div class="tooltip" id="tooltip"></div>
</main>
</div>
</div>
<script src="./center_chair_people_payload.js?v=20260330a"></script>
<script>
const DATA = window.CHAIR_MAP_DATA;
function decodeSegments(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return new Int32Array(bytes.buffer);
}
const bgTileRanges = DATA.bgTileRanges;
const bgSegValues = decodeSegments(DATA.bgSegsB64);
const chairSegValues = decodeSegments(DATA.chairSegsB64);
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
key, name, kind, start, count
}));
const meta = DATA.meta;
const world = meta.headerBounds;
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const tooltip = document.getElementById("tooltip");
const scaleChip = document.getElementById("scale-chip");
const hoverChip = document.getElementById("hover-chip");
const STORAGE_KEY = "ptc-chair-selection";
const PEOPLE_STORAGE_KEY = "ptc-chair-people";
const ASSIGN_STORAGE_KEY = "ptc-chair-assignments";
const ACTIVE_PERSON_STORAGE_KEY = "ptc-chair-active-person";
const clearAssignBtn = document.getElementById("clear-assign-btn");
const orgChartEl = document.getElementById("org-chart");
const mapperStatus = document.getElementById("mapper-status");
// Prevent stale auto-highlights from previous sessions.
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
localStorage.removeItem(ASSIGN_STORAGE_KEY);
const placed = new Set();
let people = JSON.parse(localStorage.getItem(PEOPLE_STORAGE_KEY) || "[]");
let chairAssignments = {};
let activePersonId = null;
const ORG_TEMPLATE = {
top: {
name: "총괄기획실",
count: 53,
members: [
{ name: "장종찬", dept: "총괄기획실", title: "기획실장" },
{ name: "김원식", dept: "총괄기획실", title: "전무이사" },
],
},
teams: [
{ name: "경영기획팀", count: 6, members: ["김우진", "임민정", "국혜린", "최선아", "김윤재", "이미영"] },
{ name: "인재성장팀", count: 5, members: ["조태희", "최근혜", "류원준", "주안기", "정성호"] },
{ name: "ERP 기획팀", count: 5, members: ["류호성", "문형식", "최요제", "황대일", "이채봉"] },
{ name: "디자인기획팀", count: 17, members: ["신혜영", "정은혜", "김태식", "최예은", "채선영", "최영환", "윤봄이", "이예진", "허유나", "마희연", "김수현", "박지영", "권순호", "정두휘", "김정석", "정지윤", "양숙영"] },
{ name: "기술기획팀", count: 11, members: ["김원기", "홍아름", "이경민", "김혜인", "황동환", "최찬호", "이태훈", "김신지", "조찬영", "김용연", "한치영"] },
{ name: "협업증진팀", count: 3, members: ["성형일", "박주한", "한승민"] },
{ name: "솔루션통합팀", count: 4, members: ["권혁진", "염승호", "윤준수", "김지영"] },
],
};
const chairGeometry = chairs.map((chair) => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const path = new Path2D();
const hitSegments = new Float32Array(chair.count * 4);
let segCursor = 0;
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
const offset = i * 4;
const x1 = chairSegValues[offset] / 10;
const y1 = chairSegValues[offset + 1] / 10;
const x2 = chairSegValues[offset + 2] / 10;
const y2 = chairSegValues[offset + 3] / 10;
path.moveTo(x1, y1);
path.lineTo(x2, y2);
hitSegments[segCursor] = x1;
hitSegments[segCursor + 1] = y1;
hitSegments[segCursor + 2] = x2;
hitSegments[segCursor + 3] = y2;
segCursor += 4;
minX = Math.min(minX, x1, x2);
minY = Math.min(minY, y1, y2);
maxX = Math.max(maxX, x1, x2);
maxY = Math.max(maxY, y1, y2);
}
return {
...chair,
minX,
minY,
maxX,
maxY,
area: Math.max(1, (maxX - minX) * (maxY - minY)),
path,
hitSegments,
};
});
function renumberChairKeys(chairItems) {
if (!chairItems.length) return;
const heights = chairItems
.map((chair) => Math.max(1, chair.maxY - chair.minY))
.sort((a, b) => a - b);
const medianHeight = heights[Math.floor(heights.length / 2)] || 1;
const rowTolerance = Math.max(40, medianHeight * 0.9);
const sorted = [...chairItems].sort((a, b) => {
const ay = (a.minY + a.maxY) * 0.5;
const by = (b.minY + b.maxY) * 0.5;
if (Math.abs(by - ay) > rowTolerance) return by - ay; // top -> bottom
const ax = (a.minX + a.maxX) * 0.5;
const bx = (b.minX + b.maxX) * 0.5;
return ax - bx; // left -> right
});
sorted.forEach((chair, index) => {
chair.key = String(index + 1);
chair.seatNo = index + 1;
});
}
renumberChairKeys(chairGeometry);
const PICK_GRID_SIZE = 1800;
const chairPickGrid = new Map();
function pickGridKey(gx, gy) {
return `${gx},${gy}`;
}
chairGeometry.forEach((chair, index) => {
const minGX = Math.floor(chair.minX / PICK_GRID_SIZE);
const maxGX = Math.floor(chair.maxX / PICK_GRID_SIZE);
const minGY = Math.floor(chair.minY / PICK_GRID_SIZE);
const maxGY = Math.floor(chair.maxY / PICK_GRID_SIZE);
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const key = pickGridKey(gx, gy);
if (!chairPickGrid.has(key)) chairPickGrid.set(key, []);
chairPickGrid.get(key).push(index);
}
}
});
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
let pixelRatio = window.devicePixelRatio || 1;
let pointer = { x: 0, y: 0 };
let dragging = false;
let dragStart = null;
let hovered = null;
let rafPending = false;
function normalizePeople(raw) {
return raw
.map((person, index) => {
if (!person || !person.name) return null;
return {
id: person.id || `person-${index + 1}`,
name: String(person.name).trim(),
dept: String(person.dept || "").trim(),
title: String(person.title || "").trim(),
};
})
.filter(Boolean);
}
function createTemplatePeople() {
const generated = [];
let seq = 1;
ORG_TEMPLATE.top.members.forEach((member) => {
generated.push({
id: `org-${seq++}`,
name: member.name,
dept: member.dept,
title: member.title,
});
});
ORG_TEMPLATE.teams.forEach((team) => {
team.members.forEach((name) => {
generated.push({
id: `org-${seq++}`,
name,
dept: team.name,
title: "선임",
});
});
});
return generated;
}
people = normalizePeople(people);
const templateReady = people.some((person) => person.name === "장종찬" && person.dept === "총괄기획실");
if (!templateReady) {
people = createTemplatePeople();
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
const chairKeySet = new Set(chairGeometry.map((chair) => chair.key));
chairAssignments = Object.fromEntries(
Object.entries(chairAssignments).filter(([chairKey, personId]) => (
chairKeySet.has(chairKey) && people.some((person) => person.id === personId)
))
);
if (activePersonId && !people.some((person) => person.id === activePersonId)) activePersonId = null;
function persistPeople() {
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
function persistAssignments() {
localStorage.setItem(ASSIGN_STORAGE_KEY, JSON.stringify(chairAssignments));
}
function persistActivePerson() {
if (!activePersonId) localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
else localStorage.setItem(ACTIVE_PERSON_STORAGE_KEY, activePersonId);
}
function assignmentCount() {
return Object.keys(chairAssignments).length;
}
function getPersonById(id) {
return people.find((person) => person.id === id) || null;
}
function getChairByPerson(personId) {
for (const [chairKey, assignedPersonId] of Object.entries(chairAssignments)) {
if (assignedPersonId === personId) return chairKey;
}
return null;
}
function renderPeopleList() {
const activePerson = getPersonById(activePersonId);
const countText = `${assignmentCount()} / ${people.length} 매칭`;
mapperStatus.innerHTML = `<strong>조직 현황</strong><span>${activePerson ? `${activePerson.name} 선택됨` : "선택 인원 없음"} · ${countText}</span>`;
const findPerson = (dept, name) => people.find((person) => person.dept === dept && person.name === name) || null;
const personCard = (person, roleText) => {
if (!person) return "";
const chairKey = getChairByPerson(person.id);
const assignedClass = chairKey ? " assigned" : "";
const activeClass = person.id === activePersonId ? " active" : "";
return `
<article class="org-person${assignedClass}${activeClass}" data-person-id="${person.id}">
<strong>${person.name}</strong>
<small>${person.title || roleText || "-"}</small>
<small>${chairKey ? `좌석 ${chairKey}` : "좌석 미지정"}</small>
</article>
`;
};
const topHtml = ORG_TEMPLATE.top.members
.map((member) => personCard(findPerson(member.dept, member.name), member.title))
.join("");
const teamsHtml = ORG_TEMPLATE.teams.map((team) => {
const membersHtml = team.members
.map((name) => personCard(findPerson(team.name, name), "선임"))
.join("");
return `
<section class="org-team">
<h4>${team.name} (${team.count})</h4>
<div class="org-members">${membersHtml}</div>
</section>
`;
}).join("");
orgChartEl.innerHTML = `
<section class="org-top">
<div class="org-top-title">${ORG_TEMPLATE.top.name} (${ORG_TEMPLATE.top.count})</div>
<div class="org-top-members">${topHtml}</div>
</section>
<section class="org-teams">${teamsHtml}</section>
`;
}
function worldToScreen(x, y) {
return {
x: x * camera.scale + camera.offsetX,
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
};
}
function screenToWorld(x, y) {
return {
x: (x - camera.offsetX) / camera.scale,
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
};
}
function resize() {
pixelRatio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * pixelRatio);
canvas.height = Math.round(rect.height * pixelRatio);
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
fit();
}
function fit() {
const rect = canvas.getBoundingClientRect();
const width = world.maxX - world.minX;
const height = world.maxY - world.minY;
const pad = 36;
const scaleX = (rect.width - pad * 2) / width;
const scaleY = (rect.height - pad * 2) / height;
camera.scale = Math.min(scaleX, scaleY);
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
requestDraw();
}
function drawGrid(width, height) {
ctx.save();
ctx.strokeStyle = "rgba(21,35,48,0.05)";
ctx.lineWidth = 1;
for (let x = 120; x < width; x += 120) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 120; y < height; y += 120) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
ctx.restore();
}
function pickChair(screenX, screenY) {
const threshold = 12;
const pointerWorld = screenToWorld(screenX, screenY);
const thresholdWorld = threshold / camera.scale;
const thresholdWorldSq = thresholdWorld * thresholdWorld;
const minGX = Math.floor((pointerWorld.x - thresholdWorld) / PICK_GRID_SIZE);
const maxGX = Math.floor((pointerWorld.x + thresholdWorld) / PICK_GRID_SIZE);
const minGY = Math.floor((pointerWorld.y - thresholdWorld) / PICK_GRID_SIZE);
const maxGY = Math.floor((pointerWorld.y + thresholdWorld) / PICK_GRID_SIZE);
const candidateIndexes = [];
const seen = new Set();
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const candidates = chairPickGrid.get(pickGridKey(gx, gy));
if (!candidates) continue;
for (const index of candidates) {
if (seen.has(index)) continue;
seen.add(index);
candidateIndexes.push(index);
}
}
}
let best = null;
for (const index of candidateIndexes) {
const chair = chairGeometry[index];
if (
pointerWorld.x < chair.minX - thresholdWorld ||
pointerWorld.x > chair.maxX + thresholdWorld ||
pointerWorld.y < chair.minY - thresholdWorld ||
pointerWorld.y > chair.maxY + thresholdWorld
) continue;
let distSq = Infinity;
for (let i = 0; i < chair.hitSegments.length; i += 4) {
const x1 = chair.hitSegments[i];
const y1 = chair.hitSegments[i + 1];
const x2 = chair.hitSegments[i + 2];
const y2 = chair.hitSegments[i + 3];
const dx = x2 - x1;
const dy = y2 - y1;
const len2 = dx * dx + dy * dy;
let segDistSq;
if (len2 === 0) {
const px = pointerWorld.x - x1;
const py = pointerWorld.y - y1;
segDistSq = px * px + py * py;
} else {
let t = ((pointerWorld.x - x1) * dx + (pointerWorld.y - y1) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const lx = x1 + t * dx;
const ly = y1 + t * dy;
const px = pointerWorld.x - lx;
const py = pointerWorld.y - ly;
segDistSq = px * px + py * py;
}
if (segDistSq < distSq) distSq = segDistSq;
if (distSq <= thresholdWorldSq * 0.3) break;
}
if (distSq > thresholdWorldSq) continue;
const dist = Math.sqrt(distSq) * camera.scale;
if (!best) {
best = { chair, dist };
continue;
}
const distGap = dist - best.dist;
if (distGap < -0.75) {
best = { chair, dist };
continue;
}
if (Math.abs(distGap) <= 2) {
const areaGap = chair.area - best.chair.area;
if (areaGap < -1) {
best = { chair, dist };
continue;
}
if (
Math.abs(areaGap) <= 1 &&
chair.kind === "block" &&
best.chair.kind !== "block"
) {
best = { chair, dist };
}
}
}
return best ? best.chair : null;
}
function renderTooltip() {
if (!hovered) {
tooltip.classList.remove("visible");
hoverChip.textContent = "chair hover: none";
return;
}
hoverChip.textContent = `chair hover: ${hovered.name}`;
tooltip.innerHTML = `
<strong>${hovered.name}</strong>
<div>chair key: ${hovered.key}</div>
<div>${placed.has(hovered.key) ? "선택됨" : "클릭하면 선택"}</div>
<div>${chairAssignments[hovered.key] ? `배치: ${(getPersonById(chairAssignments[hovered.key]) || { name: "알수없음" }).name}` : "배치 인원 없음"}</div>
`;
tooltip.style.left = `${pointer.x + 14}px`;
tooltip.style.top = `${pointer.y + 14}px`;
tooltip.classList.add("visible");
}
function requestDraw() {
if (rafPending) return;
rafPending = true;
window.requestAnimationFrame(() => {
rafPending = false;
draw();
});
}
function applyWorldTransform() {
ctx.setTransform(
pixelRatio * camera.scale,
0,
0,
-pixelRatio * camera.scale,
pixelRatio * camera.offsetX,
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
);
}
function draw() {
const rect = canvas.getBoundingClientRect();
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.clearRect(0, 0, rect.width, rect.height);
drawGrid(rect.width, rect.height);
const viewA = screenToWorld(0, rect.height);
const viewB = screenToWorld(rect.width, 0);
const viewMinX = Math.min(viewA.x, viewB.x);
const viewMaxX = Math.max(viewA.x, viewB.x);
const viewMinY = Math.min(viewA.y, viewB.y);
const viewMaxY = Math.max(viewA.y, viewB.y);
ctx.save();
applyWorldTransform();
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
ctx.lineWidth = 1 / camera.scale;
const tileSize = meta.backgroundTileSize;
const tileMinX = Math.floor(viewMinX / tileSize);
const tileMaxX = Math.floor(viewMaxX / tileSize);
const tileMinY = Math.floor(viewMinY / tileSize);
const tileMaxY = Math.floor(viewMaxY / tileSize);
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
const range = bgTileRanges[`${tx},${ty}`];
if (!range) continue;
const start = range[0];
const count = range[1];
for (let i = start; i < start + count; i += 1) {
const offset = i * 4;
const x1 = bgSegValues[offset] / 10;
const y1 = bgSegValues[offset + 1] / 10;
const x2 = bgSegValues[offset + 2] / 10;
const y2 = bgSegValues[offset + 3] / 10;
if (
Math.max(x1, x2) < viewMinX ||
Math.min(x1, x2) > viewMaxX ||
Math.max(y1, y2) < viewMinY ||
Math.min(y1, y2) > viewMaxY
) continue;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
}
}
ctx.restore();
hovered = dragging ? null : pickChair(pointer.x, pointer.y);
ctx.save();
applyWorldTransform();
ctx.lineWidth = 1.45 / camera.scale;
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (const chair of chairGeometry) {
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
const active = hovered && hovered.key === chair.key;
const selected = placed.has(chair.key);
const assignedPersonId = chairAssignments[chair.key];
const activePersonChair = activePersonId && assignedPersonId === activePersonId;
const assigned = Boolean(assignedPersonId);
const baseWidth = chair.kind === "block" ? 1.45 : 1.35;
ctx.strokeStyle = activePersonChair
? "rgba(234, 179, 8, 1)"
: assigned
? "rgba(37, 99, 235, 0.98)"
: selected
? "rgba(220, 38, 38, 0.98)"
: active
? "rgba(15, 118, 110, 0.98)"
: chair.kind === "group"
? "rgba(16, 134, 149, 0.74)"
: "rgba(21, 149, 142, 0.8)";
ctx.lineWidth = (activePersonChair ? 2.8 : assigned ? 2.4 : selected ? 2.6 : active ? 2.1 : baseWidth) / camera.scale;
ctx.stroke(chair.path);
}
ctx.restore();
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
renderTooltip();
}
function persistPlaced() {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));
}
canvas.addEventListener("pointerdown", (event) => {
dragging = true;
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
canvas.classList.add("dragging");
});
window.addEventListener("pointerup", (event) => {
if (dragging && dragStart) {
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
if (move < 4) {
const rect = canvas.getBoundingClientRect();
const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top);
if (picked) {
if (placed.has(picked.key)) placed.delete(picked.key);
else placed.add(picked.key);
persistPlaced();
if (activePersonId) {
const currentChair = getChairByPerson(activePersonId);
if (chairAssignments[picked.key] === activePersonId) {
delete chairAssignments[picked.key];
} else {
if (currentChair && currentChair !== picked.key) delete chairAssignments[currentChair];
chairAssignments[picked.key] = activePersonId;
}
persistAssignments();
renderPeopleList();
}
}
}
}
dragging = false;
dragStart = null;
canvas.classList.remove("dragging");
requestDraw();
});
window.addEventListener("pointermove", (event) => {
const rect = canvas.getBoundingClientRect();
pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
if (dragging && dragStart) {
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
}
requestDraw();
});
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = event.clientX - rect.left;
const my = event.clientY - rect.top;
const before = screenToWorld(mx, my);
const factor = event.deltaY < 0 ? 1.08 : 0.92;
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
const after = worldToScreen(before.x, before.y);
camera.offsetX += mx - after.x;
camera.offsetY += my - after.y;
requestDraw();
}, { passive: false });
document.getElementById("fit-btn").addEventListener("click", fit);
document.getElementById("clear-btn").addEventListener("click", () => {
placed.clear();
persistPlaced();
requestDraw();
});
clearAssignBtn.addEventListener("click", () => {
chairAssignments = {};
persistAssignments();
renderPeopleList();
requestDraw();
});
orgChartEl.addEventListener("click", (event) => {
const item = event.target.closest(".org-person[data-person-id]");
if (!item) return;
const personId = item.getAttribute("data-person-id");
activePersonId = personId === activePersonId ? null : personId;
persistActivePerson();
renderPeopleList();
requestDraw();
});
window.addEventListener("resize", resize);
renderPeopleList();
resize();
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -316,7 +316,28 @@ body {
}
.modal-content.wide {
max-width: 1200px;
max-width: 1060px;
width: min(1040px, calc(100vw - 48px));
max-height: calc(100vh - 28px);
padding: 20px 20px 16px;
display: flex;
flex-direction: column;
gap: 14px;
overflow: hidden;
}
.modal-content.wide #modal-title {
margin-bottom: 0;
padding-bottom: 12px;
}
.modal-content.wide #modal-fields {
min-height: 0;
}
.modal-content.wide #modal-footer-area {
margin-top: 0 !important;
flex-shrink: 0;
}
.member-photo-field {
@@ -333,37 +354,59 @@ body {
align-items: stretch;
}
.member-edit-layout {
.member-basic-editor {
display: flex;
flex-direction: column;
gap: 14px;
}
.member-basic-split {
display: grid;
grid-template-columns: minmax(320px, 380px) minmax(0, 1fr);
gap: 16px;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 14px;
align-items: stretch;
}
.member-edit-left-pane,
.member-edit-right-pane {
.member-basic-left,
.member-basic-right,
.member-photo-panel,
.member-basic-fields {
min-width: 0;
}
.member-edit-left-pane {
display: flex;
flex-direction: column;
.member-basic-left,
.member-basic-right,
.member-photo-panel {
height: 100%;
}
.member-basic-left {
display: grid;
grid-template-rows: auto 1fr;
gap: 12px;
}
.member-edit-profile-card {
.member-basic-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 12px;
align-content: start;
}
.member-basic-field {
display: flex;
flex-direction: column;
gap: 12px;
gap: 5px;
min-width: 0;
}
.member-seat-field {
flex: 1 1 auto;
min-height: 0;
}
.member-seat-field-emphasis .seat-preview-card {
.member-seat-field-compact .seat-preview-card {
min-height: 100%;
height: 100%;
}
.member-detail-top-row {
@@ -455,8 +498,6 @@ body {
border: 1px solid #e2e8f0;
border-radius: 16px;
background: #f8fafc;
height: 100%;
min-height: 100%;
box-sizing: border-box;
}
@@ -467,13 +508,21 @@ body {
justify-content: space-between;
}
.member-photo-upload-card-inline {
min-height: 168px;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.member-photo-preview-wrap {
flex: 0 0 auto;
}
.member-photo-preview {
width: 84px;
height: 84px;
width: 96px;
height: 96px;
border-radius: 9999px;
object-fit: cover;
border: 3px solid #e0e7ff;
@@ -520,6 +569,11 @@ body {
min-height: 100%;
}
.seat-preview-card.is-assigned {
border-color: rgba(79, 70, 229, 0.28);
box-shadow: 0 14px 30px rgba(79, 70, 229, 0.12);
}
.seat-preview-head {
display: flex;
justify-content: space-between;
@@ -549,10 +603,11 @@ body {
align-items: center;
padding: 6px 10px;
border-radius: 9999px;
background: #dbeafe;
color: #1d4ed8;
background: linear-gradient(135deg, #4f46e5 0%, #2563eb 100%);
color: #ffffff;
font-size: 11px;
font-weight: 900;
box-shadow: 0 8px 20px rgba(79, 70, 229, 0.18);
}
.seat-preview-badge-muted {
@@ -583,6 +638,11 @@ body {
overflow: hidden;
}
.seat-preview-card.is-assigned .seat-preview-canvas {
border-color: rgba(79, 70, 229, 0.35);
box-shadow: inset 0 0 0 1px rgba(79, 70, 229, 0.08);
}
.seat-preview-frame {
display: block;
width: 100%;
@@ -591,6 +651,10 @@ body {
background: #fff;
}
.seat-preview-card.is-assigned .seat-preview-frame {
box-shadow: inset 0 0 0 2px rgba(79, 70, 229, 0.14);
}
.seat-preview-placeholder {
display: flex;
flex-direction: column;
@@ -601,30 +665,78 @@ body {
font-weight: 900;
}
.member-edit-right-pane .seat-preview-head {
padding: 18px 20px 12px;
.member-seat-field-compact .seat-preview-head {
padding: 14px 16px 10px;
}
.member-edit-right-pane .seat-preview-head strong {
font-size: 18px;
.member-seat-field-compact .seat-preview-head strong {
font-size: 17px;
}
.member-edit-right-pane .seat-preview-head p {
.member-seat-field-compact .seat-preview-head p {
font-size: 12px;
line-height: 1.35;
}
.member-edit-right-pane .seat-preview-badge {
font-size: 12px;
padding: 8px 12px;
.member-seat-field-compact .seat-preview-badge {
font-size: 11px;
padding: 7px 10px;
}
.member-edit-right-pane .seat-preview-canvas {
min-height: 360px;
.member-seat-field-compact .seat-preview-canvas {
min-height: 208px;
}
.member-edit-right-pane .seat-preview-frame,
.member-edit-right-pane .seat-preview-placeholder {
min-height: 320px;
.member-seat-field-compact .seat-preview-frame,
.member-seat-field-compact .seat-preview-placeholder {
min-height: 208px;
}
.member-photo-upload-card-inline {
min-height: 168px;
padding: 16px 14px;
gap: 10px;
align-items: stretch;
justify-content: space-between;
text-align: left;
}
.member-photo-card-title {
font-size: 11px;
font-weight: 900;
color: #475569;
line-height: 1.2;
}
.member-photo-upload-card-inline .member-photo-preview-wrap {
margin: 0 auto;
}
.member-photo-upload-card-inline .member-photo-preview {
width: 82px;
height: 82px;
}
.member-photo-upload-card-inline .member-photo-upload-controls {
align-items: center;
text-align: center;
}
.member-photo-upload-card-inline .member-photo-file-label {
padding: 9px 12px;
}
.member-photo-upload-card-inline .member-photo-file-name {
font-size: 11px;
}
.member-basic-field input {
min-width: 0;
padding: 12px 14px;
}
.member-basic-field label {
line-height: 1.2;
}
@@ -645,7 +757,11 @@ body {
grid-template-columns: 1fr;
}
.member-edit-layout {
.member-basic-split {
grid-template-columns: 1fr;
}
.member-basic-fields {
grid-template-columns: 1fr;
}

View File

@@ -44,6 +44,15 @@ function cloneMembers(items) {
return JSON.parse(JSON.stringify(items));
}
function isRetiredLegacyMember(member) {
const workStatus = String(member?.['근무상태'] || '').trim();
return workStatus === '퇴직';
}
function getVisibleLegacyMembers(items) {
return (items || []).filter((member) => !isRetiredLegacyMember(member));
}
function getPhotoPlaceholder(name = '') {
return `https://via.placeholder.com/160?text=${encodeURIComponent(name || 'Profile')}`;
}
@@ -155,7 +164,7 @@ async function uploadProfilePhoto(file, memberName) {
}
function setMembers(items) {
members = items.map(toLegacyMember);
members = getVisibleLegacyMembers(items.map(toLegacyMember));
if (selectedDept !== '전체' && !members.some((member) => member['부서'] === selectedDept)) {
selectedDept = '전체';
}
@@ -1014,11 +1023,11 @@ function handlePhotoFileChange(event) {
function renderSeatPreviewCard(seatInfo) {
const assigned = Boolean(seatInfo?.assigned);
const safeLabel = escapeHtml(seatInfo?.seatLabel || '');
const seatMapLabel = String(seatInfo?.seatMapName || '자리배치도').replace(/\s*자리배치도\s*$/u, '').trim() || '사무실';
const safeSeatMapName = escapeHtml(seatInfo?.seatMapName || '자리배치도');
const safeSlotKey = escapeHtml(seatInfo?.slotKey || '');
const safeOfficeLabel = escapeHtml(seatMapLabel);
const badge = assigned
? `<span class="seat-preview-badge">${safeLabel || '배치완료'}</span>`
? `<span class="seat-preview-badge">${safeOfficeLabel}</span>`
: '<span class="seat-preview-badge seat-preview-badge-muted">미배치</span>';
const body = assigned
? `
@@ -1039,11 +1048,11 @@ function renderSeatPreviewCard(seatInfo) {
`;
return `
<div class="seat-preview-card">
<div class="seat-preview-card${assigned ? ' is-assigned' : ''}">
<div class="seat-preview-head">
<div>
<strong>재석위치</strong>
<p>${assigned ? '현재 자리배치도 기준으로 배치된 좌석 정보를 표시합니다.' : '현재 자리배치도에서 배치된 좌석이 없습니다.'}</p>
<p>${assigned ? '현재 배치된 사무실과 좌석 위치를 강조해서 표시합니다.' : '현재 자리배치도에서 배치된 좌석이 없습니다.'}</p>
</div>
${badge}
</div>
@@ -1177,8 +1186,9 @@ function openModal(id) {
<div class="col-span-1">
<label class="text-[11px] font-black text-slate-600 block">근무 상태</label>
<select id="m-status" class="w-full bg-white p-3 rounded-xl border text-sm font-bold outline-none">
<option value="근무" ${member['근무상태'] !== '휴직' ? 'selected' : ''}>근무</option>
<option value="근무" ${member['근무상태'] !== '휴직' && member['근무상태'] !== '퇴직' ? 'selected' : ''}>근무</option>
<option value="휴직" ${member['근무상태'] === '휴직' ? 'selected' : ''}>휴직</option>
<option value="퇴직" ${member['근무상태'] === '퇴직' ? 'selected' : ''}>퇴직</option>
</select>
</div>
<div class="col-span-1">
@@ -1199,15 +1209,15 @@ function openModal(id) {
<button id="modal-tab-basic" onclick="switchModalTab('basic')" class="flex-1 py-3 font-bold border-b-2 border-indigo-600 text-indigo-600 text-sm transition-all">기본 정보</button>
<button id="modal-tab-org" onclick="switchModalTab('org')" class="flex-1 py-3 font-bold border-b-2 border-transparent text-slate-400 text-sm transition-all">조직 및 근무</button>
</div>
<div id="modal-sec-basic" class="grid grid-cols-2 gap-3 modal-form-grid">
<div id="modal-sec-basic" class="modal-form-grid member-basic-editor">
<input type="hidden" id="m-id" value="${id || ''}">
<input type="hidden" id="m-photo-hidden" value="${member['사진'] || ''}">
<input type="hidden" id="m-seat-hidden" value="${member['자리위치'] || ''}">
<div class="col-span-2 member-edit-layout">
<div class="member-edit-left-pane">
<div class="member-edit-profile-card">
<label class="text-[11px] font-black text-slate-600 block">프로필 사진</label>
<div class="member-photo-upload-card member-photo-upload-card-compact">
<div class="member-basic-split">
<div class="member-basic-left">
<div class="member-photo-panel">
<div class="member-photo-upload-card member-photo-upload-card-inline">
<div class="member-photo-card-title">프로필 사진</div>
<div class="member-photo-preview-wrap">
<img id="m-photo-preview" src="${member['사진'] || getPhotoPlaceholder(member['이름'] || '')}" alt="프로필 미리보기" class="member-photo-preview">
</div>
@@ -1219,28 +1229,28 @@ function openModal(id) {
<strong id="m-photo-file-name" class="member-photo-file-name">선택된 파일 없음</strong>
</div>
</div>
<div class="member-name-field member-name-field-compact">
</div>
<div class="member-basic-fields">
<div class="member-basic-field">
<label class="text-[11px] font-black text-slate-600 block">이름 (필수)</label>
<input id="m-name" value="${member['이름'] || ''}" oninput="syncPhotoPreviewFromUrl()" class="w-full bg-slate-50 p-3 rounded-xl border font-bold text-sm outline-none">
</div>
<div class="member-inline-info-grid member-inline-info-grid-stacked">
<div class="member-inline-info-card member-inline-info-card-full">
<label>사번</label>
<input id="m-employee-id" value="${member['사번'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none">
</div>
<div class="member-inline-info-card member-inline-info-card-full">
<label>전화번호</label>
<input id="m-phone" value="${member['전화번호'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none">
</div>
<div class="member-inline-info-card member-inline-info-card-full">
<label>이메일</label>
<input id="m-email" value="${member['이메일'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none">
</div>
<div class="member-basic-field">
<label class="text-[11px] font-black text-slate-600 block">사번</label>
<input id="m-employee-id" value="${member['사번'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none">
</div>
<div class="member-basic-field">
<label class="text-[11px] font-black text-slate-600 block">전화번호</label>
<input id="m-phone" value="${member['전화번호'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none">
</div>
<div class="member-basic-field">
<label class="text-[11px] font-black text-slate-600 block">이메일</label>
<input id="m-email" value="${member['이메일'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none">
</div>
</div>
</div>
<div class="member-edit-right-pane">
<div class="member-seat-field member-seat-field-emphasis">
<div class="member-basic-right">
<div class="member-seat-field member-seat-field-compact">
<div id="member-seat-preview">${renderSeatPreviewCard({ assigned: false, seatLabel: member['자리위치'] || '', seatMapName: '자리배치도', slotKey: '' })}</div>
</div>
</div>
@@ -1583,7 +1593,7 @@ async function loadSnapshotListView() {
}
const payload = await apiFetch(`/api/members?as_of=${encodeURIComponent(snapshotDate)}`);
listViewState.snapshotDate = snapshotDate;
listViewState.snapshotMembers = (payload.items || []).map(toLegacyMember);
listViewState.snapshotMembers = getVisibleLegacyMembers((payload.items || []).map(toLegacyMember));
listViewState.mode = 'snapshot';
renderListViewModalContent();
}

56
scripts/prepare_dev_worktree.sh Executable file
View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DEV_DIR="${DEV_DIR:-${ROOT_DIR}/.dev-worktree-8081}"
TARGET_REF="${1:-HEAD}"
FORCE_RECREATE="${FORCE_RECREATE:-0}"
copy_optional_path() {
local rel_path="$1"
local src="${ROOT_DIR}/${rel_path}"
local dst="${DEV_DIR}/${rel_path}"
if [[ ! -e "${src}" ]]; then
return 0
fi
mkdir -p "$(dirname "${dst}")"
cp -a "${src}" "${dst}"
}
if [[ "${DEV_DIR}" == "${ROOT_DIR}" ]]; then
echo "DEV_DIR must not be the same as the production workspace." >&2
exit 1
fi
if [[ -d "${DEV_DIR}/.git" && "${FORCE_RECREATE}" != "1" ]]; then
echo "[1/6] Reusing existing dev workspace at ${DEV_DIR}"
else
echo "[1/6] Removing previous dev workspace at ${DEV_DIR}"
rm -rf "${DEV_DIR}"
echo "[2/6] Cloning production workspace into isolated dev workspace"
git clone --no-hardlinks "${ROOT_DIR}" "${DEV_DIR}" >/dev/null
echo "[3/6] Checking out detached ref ${TARGET_REF}"
git -C "${DEV_DIR}" checkout --detach "${TARGET_REF}" >/dev/null
fi
echo "[4/6] Copying local runtime env when available"
copy_optional_path ".env"
echo "[5/6] Copying local-only incoming design assets when available"
copy_optional_path "incoming-files/1.png"
copy_optional_path "incoming-files/260320.html"
copy_optional_path "incoming-files/sample style.css"
copy_optional_path "incoming-files/seat/center_chair_people_map(2).html"
copy_optional_path "incoming-files/사업관리대장"
echo "[6/6] Dev worktree ready"
echo "Path: ${DEV_DIR}"
echo "Use this to start 8081 from the isolated workspace:"
echo " cd ${DEV_DIR} && docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compose.8081.yml up -d --build"
if [[ "${FORCE_RECREATE}" != "1" ]]; then
echo "To fully rebuild the dev workspace, run:"
echo " FORCE_RECREATE=1 ./scripts/prepare_dev_worktree.sh"
fi

15
scripts/start_8081.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DEV_DIR="${DEV_DIR:-${ROOT_DIR}/.dev-worktree-8081}"
"${ROOT_DIR}/scripts/prepare_dev_worktree.sh"
cd "${DEV_DIR}"
docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compose.8081.yml up -d --build
echo "8081 started from ${DEV_DIR}"
echo "Verify mounts with:"
echo " docker inspect mh-dashboard-organization-dev-backend-1 --format '{{range .Mounts}}{{println .Source \"->\" .Destination}}{{end}}'"

View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
docker compose up -d
"${ROOT_DIR}/scripts/start_8081.sh"
echo "8080: http://localhost:8080"
echo "8081: http://localhost:8081"

View File

@@ -4,9 +4,9 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PROD_DIR="${ROOT_DIR}"
DEV_DIR="${DEV_DIR:-${ROOT_DIR}}"
DEV_DIR="${DEV_DIR:-/tmp/mh-dashboard-organization-dev-worktree}"
DEV_PROJECT_NAME="${DEV_PROJECT_NAME:-mh-dashboard-organization-dev}"
DEV_COMPOSE_FILE="${DEV_COMPOSE_FILE:-${ROOT_DIR}/docker-compose.8081.yml}"
DEV_COMPOSE_FILE="${DEV_COMPOSE_FILE:-${DEV_DIR}/docker-compose.8081.yml}"
SCOPE="${1:-minimal}"
if [[ ! -f "${PROD_DIR}/docker-compose.yml" ]]; then