Compare commits
5 Commits
8121c9cf41
...
637b390024
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
637b390024 | ||
|
|
4b4ffafbd2 | ||
|
|
1cd0f21a36 | ||
|
|
f77be3f482 | ||
|
|
2e8c79bb43 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,3 +16,4 @@ incoming-files/~$*
|
|||||||
incoming-files/6f.html
|
incoming-files/6f.html
|
||||||
incoming-files/7f.html
|
incoming-files/7f.html
|
||||||
incoming-files/center.html
|
incoming-files/center.html
|
||||||
|
.dev-worktree-8081/
|
||||||
|
|||||||
@@ -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=20260327-03" />
|
<link rel="stylesheet" href="/legacy/static/common.css?v=20260331-01" />
|
||||||
<link rel="stylesheet" href="/legacy/static/organization.css?v=20260327-03" />
|
<link rel="stylesheet" href="/legacy/static/organization.css?v=20260331-01" />
|
||||||
</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=20260327-03"></script>
|
<script src="/legacy/static/organization.js?v=20260331-01"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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}")
|
raise HTTPException(status_code=500, detail=f"Fixed office viewer data not found: {office_key}")
|
||||||
|
|
||||||
html = re.sub(
|
html = re.sub(
|
||||||
r'<script\s+src="\./[^"]+payload[^"]*\.js"></script>',
|
r'<script\s+src="\./[^"]+payload[^"]*\.js(?:\?[^"]*)?"></script>',
|
||||||
f"<script>{payload_js}</script>",
|
f"<script>{payload_js}</script>",
|
||||||
html,
|
html,
|
||||||
count=1,
|
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),
|
(as_of, as_of, seat_map_id, as_of, as_of),
|
||||||
)
|
)
|
||||||
placements = cur.fetchall()
|
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
|
viewer_data: dict[str, object] | None = None
|
||||||
office_key = str(seat_map.get("source_url") or FIXED_OFFICE_SOURCE_KEY)
|
office_key = str(seat_map.get("source_url") or FIXED_OFFICE_SOURCE_KEY)
|
||||||
fixed_office = FIXED_OFFICE_CONFIGS.get(office_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>
|
<script>
|
||||||
const seatAssignments = new Map();
|
const seatAssignments = new Map();
|
||||||
let selectedChairKey = null;
|
let selectedChairKey = null;
|
||||||
|
let focusedChairKey = null;
|
||||||
|
let focusedChairPulseUntil = 0;
|
||||||
let viewerMode = "default";
|
let viewerMode = "default";
|
||||||
const popup = document.createElement("div");
|
const popup = document.createElement("div");
|
||||||
popup.className = "seat-popup";
|
popup.className = "seat-popup";
|
||||||
@@ -1854,6 +1860,9 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
|
|||||||
function focusChair(chairKey, padding = 2200) {
|
function focusChair(chairKey, padding = 2200) {
|
||||||
const chair = chairGeometry.find((item) => String(item.key) === String(chairKey));
|
const chair = chairGeometry.find((item) => String(item.key) === String(chairKey));
|
||||||
if (!chair) return;
|
if (!chair) return;
|
||||||
|
selectedChairKey = String(chairKey);
|
||||||
|
focusedChairKey = String(chairKey);
|
||||||
|
focusedChairPulseUntil = Date.now() + 2600;
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const pad = 24;
|
const pad = 24;
|
||||||
const minX = chair.minX - padding;
|
const minX = chair.minX - padding;
|
||||||
@@ -1919,8 +1928,31 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
|
|||||||
const originalDraw = draw;
|
const originalDraw = draw;
|
||||||
draw = function drawWithAssignments() {
|
draw = function drawWithAssignments() {
|
||||||
originalDraw();
|
originalDraw();
|
||||||
if (!seatAssignments.size) return;
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
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.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||||
ctx.textBaseline = "middle";
|
ctx.textBaseline = "middle";
|
||||||
for (const chair of chairGeometry) {
|
for (const chair of chairGeometry) {
|
||||||
|
|||||||
@@ -11,20 +11,21 @@
|
|||||||
### 코드 경로
|
### 코드 경로
|
||||||
|
|
||||||
- 공개용 `8080`: `/home/hyunho/projects/mh-dashboard-organization`
|
- 공개용 `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 기준
|
### 작업용 Compose 기준
|
||||||
|
|
||||||
- 공개용 `8080` stack: `docker-compose.yml`
|
- 공개용 `8080` stack: `docker-compose.yml`
|
||||||
- 작업용 `8081` stack: `docker-compose.8081.yml`
|
- 작업용 `8081` stack: `docker-compose.8081.yml`
|
||||||
- 작업용 project name 기본값: `mh-dashboard-organization-dev`
|
- 작업용 project name 기본값: `mh-dashboard-organization-dev`
|
||||||
|
- 작업용 `8081`는 반드시 `/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081`에서 띄운다
|
||||||
|
|
||||||
### DB 볼륨
|
### DB 볼륨
|
||||||
|
|
||||||
- 공개용 `8080`: `mh-dashboard-organization_postgres_data`
|
- 공개용 `8080`: `mh-dashboard-organization_postgres_data`
|
||||||
- 작업용 `8081`: `mh-dashboard-organization-dev_postgres_data`
|
- 작업용 `8081`: `mh-dashboard-organization-dev_postgres_data`
|
||||||
|
|
||||||
즉 현재는 코드 workspace는 같아도 compose project 와 DB volume 이 분리된 상태다.
|
즉 현재는 `8080` 과 `8081` 이 코드 workspace 와 DB volume 모두 분리된 상태로 운영한다.
|
||||||
|
|
||||||
## 정본 기준
|
## 정본 기준
|
||||||
|
|
||||||
@@ -172,11 +173,30 @@
|
|||||||
사용 방법:
|
사용 방법:
|
||||||
|
|
||||||
```bash
|
```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 minimal
|
||||||
./scripts/sync_prod_db_to_dev.sh full
|
./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`
|
- `minimal`
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
- latest checked commit: `24852d4`
|
- latest checked commit: `24852d4`
|
||||||
- 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)
|
||||||
- work rulebook: [WORK_RULEBOOK.md](/home/hyunho/projects/mh-dashboard-organization/docs/WORK_RULEBOOK.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)
|
- 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)
|
- 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)
|
- 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
|
## What Was Finished
|
||||||
|
|
||||||
@@ -96,8 +98,10 @@
|
|||||||
- 코드 선행은 `8081`, 공개 반영은 `8080`
|
- 코드 선행은 `8081`, 공개 반영은 `8080`
|
||||||
- 데이터 정본은 `8080` DB
|
- 데이터 정본은 `8080` DB
|
||||||
- `8081` DB는 독립 정본이 아니라 `8080` 기준 복제본처럼 관리해야 함
|
- `8081` DB는 독립 정본이 아니라 `8080` 기준 복제본처럼 관리해야 함
|
||||||
|
- `8081` 코드는 `.dev-worktree-8081` 기준으로 유지
|
||||||
- 조직도, 멤버, 자리배치 검증 전에는 `DEV_PROD_DB_PROTOCOL.md`를 먼저 확인
|
- 조직도, 멤버, 자리배치 검증 전에는 `DEV_PROD_DB_PROTOCOL.md`를 먼저 확인
|
||||||
- 기능 수정 후 완료 판단은 `REGRESSION_CHECKLIST.md`를 기준으로 해야 함
|
- 기능 수정 후 완료 판단은 `REGRESSION_CHECKLIST.md`를 기준으로 해야 함
|
||||||
|
- 빠른 재시작은 `./scripts/start_local_dashboards.sh`
|
||||||
|
|
||||||
### Seat Map Save
|
### Seat Map Save
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,8 @@
|
|||||||
- `8081` 작업용 접속 확인
|
- `8081` 작업용 접속 확인
|
||||||
- `8080` 공개용 접속 확인
|
- `8080` 공개용 접속 확인
|
||||||
- `docker compose ps`에서 `backend`, `frontend`, `proxy`, `db`가 정상인지 확인
|
- `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. 데이터 동기화 범위 결정
|
### 2. 데이터 동기화 범위 결정
|
||||||
|
|
||||||
|
|||||||
259
docs/WORK_EXECUTION_FLOW.md
Normal file
259
docs/WORK_EXECUTION_FLOW.md
Normal 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 유지`, `조직현황 탭`, `프로젝트/팀 탭`
|
||||||
@@ -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
|
## 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
|
## Daily Start Checklist
|
||||||
|
|
||||||
매일 첫 작업 시작 전 체크:
|
매일 첫 작업 시작 전 체크:
|
||||||
@@ -191,6 +244,7 @@ mock, fallback, hotfix, 임시 우회 로직은 허용할 수 있다.
|
|||||||
- `WORK_RULEBOOK.md` 확인
|
- `WORK_RULEBOOK.md` 확인
|
||||||
- 최신 체크포인트 확인
|
- 최신 체크포인트 확인
|
||||||
- 미추적 / 수정 파일 확인
|
- 미추적 / 수정 파일 확인
|
||||||
|
- 현재 작업은 커밋 없이 진행하고, 커밋/푸시는 지시받을 때만 한다는 규칙 확인
|
||||||
- 오늘 작업이 코드 문제인지 DB 문제인지 먼저 구분
|
- 오늘 작업이 코드 문제인지 DB 문제인지 먼저 구분
|
||||||
- 공개용 기준 데이터 검증이 필요한지 판단
|
- 공개용 기준 데이터 검증이 필요한지 판단
|
||||||
|
|
||||||
|
|||||||
@@ -325,9 +325,7 @@ function buildAuthHeaders(headers) {
|
|||||||
function shouldShowGlobalDateControls() {
|
function shouldShowGlobalDateControls() {
|
||||||
return currentView === "ledger"
|
return currentView === "ledger"
|
||||||
|| currentView === "project"
|
|| currentView === "project"
|
||||||
|| currentView === "team"
|
|| currentView === "team";
|
||||||
|| currentView === "seatmap-admin"
|
|
||||||
|| currentView === "seatmap-readonly";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncGlobalDateControlVisibility() {
|
function syncGlobalDateControlVisibility() {
|
||||||
@@ -361,6 +359,10 @@ function buildAsOfQuery() {
|
|||||||
return `?as_of=${encodeURIComponent(asOf)}`;
|
return `?as_of=${encodeURIComponent(asOf)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildSeatMapAsOfQuery() {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
function notifyEmbeddedTabActivated() {
|
function notifyEmbeddedTabActivated() {
|
||||||
if (currentView === "project" && projectFrame?.contentWindow) {
|
if (currentView === "project" && projectFrame?.contentWindow) {
|
||||||
projectFrame.contentWindow.postMessage({ source: "total-control", type: "tab-activated", tab: "project" }, window.location.origin);
|
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;
|
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) {
|
function memberMatchesSeatMapSearch(member) {
|
||||||
const keyword = seatMapState.search.trim().toLowerCase();
|
const keyword = seatMapState.search.trim().toLowerCase();
|
||||||
if (!keyword) return true;
|
if (!keyword) return true;
|
||||||
@@ -839,7 +857,9 @@ function renderSeatMapOfficeTabs() {
|
|||||||
function getUnassignedMembers() {
|
function getUnassignedMembers() {
|
||||||
const placedIds = new Set(getPlacementSource().map((item) => Number(item.member_id)));
|
const placedIds = new Set(getPlacementSource().map((item) => Number(item.member_id)));
|
||||||
return seatMapState.members.filter((member) => {
|
return seatMapState.members.filter((member) => {
|
||||||
|
if (shouldHideMemberFromSeatMap(member)) return false;
|
||||||
if (placedIds.has(Number(member.id))) return false;
|
if (placedIds.has(Number(member.id))) return false;
|
||||||
|
if (isMemberAssignedAnywhere(member)) return false;
|
||||||
return memberMatchesSeatMapSearch(member);
|
return memberMatchesSeatMapSearch(member);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -847,6 +867,7 @@ function getUnassignedMembers() {
|
|||||||
function getPlacedMembers() {
|
function getPlacedMembers() {
|
||||||
const placedIds = new Set(getPlacementSource().map((item) => Number(item.member_id)));
|
const placedIds = new Set(getPlacementSource().map((item) => Number(item.member_id)));
|
||||||
return seatMapState.members.filter((member) => {
|
return seatMapState.members.filter((member) => {
|
||||||
|
if (shouldHideMemberFromSeatMap(member)) return false;
|
||||||
if (!placedIds.has(Number(member.id))) return false;
|
if (!placedIds.has(Number(member.id))) return false;
|
||||||
return memberMatchesSeatMapSearch(member);
|
return memberMatchesSeatMapSearch(member);
|
||||||
});
|
});
|
||||||
@@ -1011,7 +1032,7 @@ function renderDxfSeatMapBoard() {
|
|||||||
seatMapBoard.innerHTML = `<div class="seatmap-empty-card"><strong>DXF 뷰어 데이터를 준비하지 못했습니다.</strong></div>`;
|
seatMapBoard.innerHTML = `<div class="seatmap-empty-card"><strong>DXF 뷰어 데이터를 준비하지 못했습니다.</strong></div>`;
|
||||||
return;
|
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 = `
|
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>
|
<div class="seatmap-dxf-drop-overlay" data-seatmap-drop-overlay></div>
|
||||||
@@ -1354,7 +1375,7 @@ async function loadSeatMapData(force = false) {
|
|||||||
const office = getCurrentSeatMapOffice();
|
const office = getCurrentSeatMapOffice();
|
||||||
const activePayload = await fetchJson(`/api/seat-maps/active?office_key=${encodeURIComponent(office.key)}`);
|
const activePayload = await fetchJson(`/api/seat-maps/active?office_key=${encodeURIComponent(office.key)}`);
|
||||||
const activeSeatMap = activePayload.item;
|
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 = {
|
seatMapState.seatMap = {
|
||||||
...(layoutPayload.seat_map || {}),
|
...(layoutPayload.seat_map || {}),
|
||||||
viewer_data: layoutPayload.viewer_data || null,
|
viewer_data: layoutPayload.viewer_data || null,
|
||||||
|
|||||||
@@ -93,7 +93,7 @@
|
|||||||
<main class="dashboard-main">
|
<main class="dashboard-main">
|
||||||
<section id="organization-stage" class="main-stage">
|
<section id="organization-stage" class="main-stage">
|
||||||
<div class="stage-frame">
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section id="project-stage" class="main-stage" hidden>
|
<section id="project-stage" class="main-stage" hidden>
|
||||||
|
|||||||
BIN
incoming-files/1.png
Normal file
BIN
incoming-files/1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 748 KiB |
2598
incoming-files/260320.html
Normal file
2598
incoming-files/260320.html
Normal file
File diff suppressed because one or more lines are too long
1377
incoming-files/sample style.css
Normal file
1377
incoming-files/sample style.css
Normal file
File diff suppressed because it is too large
Load Diff
931
incoming-files/seat/center_chair_people_map(2).html
Normal file
931
incoming-files/seat/center_chair_people_map(2).html
Normal 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
1377
incoming-files/사업관리대장/MH 통합 대시보드_260320.css
Normal file
1377
incoming-files/사업관리대장/MH 통합 대시보드_260320.css
Normal file
File diff suppressed because it is too large
Load Diff
2598
incoming-files/사업관리대장/MH 통합 대시보드_260320.html
Normal file
2598
incoming-files/사업관리대장/MH 통합 대시보드_260320.html
Normal file
File diff suppressed because one or more lines are too long
BIN
incoming-files/사업관리대장/사업관리대장-1.xlsx
Normal file
BIN
incoming-files/사업관리대장/사업관리대장-1.xlsx
Normal file
Binary file not shown.
@@ -316,7 +316,28 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-content.wide {
|
.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 {
|
.member-photo-field {
|
||||||
@@ -333,37 +354,59 @@ body {
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-edit-layout {
|
.member-basic-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-basic-split {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(320px, 380px) minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
gap: 16px;
|
gap: 14px;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-edit-left-pane,
|
.member-basic-left,
|
||||||
.member-edit-right-pane {
|
.member-basic-right,
|
||||||
|
.member-photo-panel,
|
||||||
|
.member-basic-fields {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-edit-left-pane {
|
.member-basic-left,
|
||||||
display: flex;
|
.member-basic-right,
|
||||||
flex-direction: column;
|
.member-photo-panel {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-basic-left {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
gap: 12px;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-seat-field {
|
.member-seat-field {
|
||||||
flex: 1 1 auto;
|
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-seat-field-emphasis .seat-preview-card {
|
.member-seat-field-compact .seat-preview-card {
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-detail-top-row {
|
.member-detail-top-row {
|
||||||
@@ -455,8 +498,6 @@ body {
|
|||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
height: 100%;
|
|
||||||
min-height: 100%;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,13 +508,21 @@ body {
|
|||||||
justify-content: space-between;
|
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 {
|
.member-photo-preview-wrap {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-photo-preview {
|
.member-photo-preview {
|
||||||
width: 84px;
|
width: 96px;
|
||||||
height: 84px;
|
height: 96px;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border: 3px solid #e0e7ff;
|
border: 3px solid #e0e7ff;
|
||||||
@@ -520,6 +569,11 @@ body {
|
|||||||
min-height: 100%;
|
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 {
|
.seat-preview-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -549,10 +603,11 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
background: #dbeafe;
|
background: linear-gradient(135deg, #4f46e5 0%, #2563eb 100%);
|
||||||
color: #1d4ed8;
|
color: #ffffff;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
|
box-shadow: 0 8px 20px rgba(79, 70, 229, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.seat-preview-badge-muted {
|
.seat-preview-badge-muted {
|
||||||
@@ -583,6 +638,11 @@ body {
|
|||||||
overflow: hidden;
|
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 {
|
.seat-preview-frame {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -591,6 +651,10 @@ body {
|
|||||||
background: #fff;
|
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 {
|
.seat-preview-placeholder {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -601,30 +665,78 @@ body {
|
|||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-edit-right-pane .seat-preview-head {
|
.member-seat-field-compact .seat-preview-head {
|
||||||
padding: 18px 20px 12px;
|
padding: 14px 16px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-edit-right-pane .seat-preview-head strong {
|
.member-seat-field-compact .seat-preview-head strong {
|
||||||
font-size: 18px;
|
font-size: 17px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-edit-right-pane .seat-preview-head p {
|
.member-seat-field-compact .seat-preview-head p {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-edit-right-pane .seat-preview-badge {
|
.member-seat-field-compact .seat-preview-badge {
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
padding: 8px 12px;
|
padding: 7px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-edit-right-pane .seat-preview-canvas {
|
.member-seat-field-compact .seat-preview-canvas {
|
||||||
min-height: 360px;
|
min-height: 208px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-edit-right-pane .seat-preview-frame,
|
.member-seat-field-compact .seat-preview-frame,
|
||||||
.member-edit-right-pane .seat-preview-placeholder {
|
.member-seat-field-compact .seat-preview-placeholder {
|
||||||
min-height: 320px;
|
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;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-edit-layout {
|
.member-basic-split {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-basic-fields {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,15 @@ function cloneMembers(items) {
|
|||||||
return JSON.parse(JSON.stringify(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 = '') {
|
function getPhotoPlaceholder(name = '') {
|
||||||
return `https://via.placeholder.com/160?text=${encodeURIComponent(name || 'Profile')}`;
|
return `https://via.placeholder.com/160?text=${encodeURIComponent(name || 'Profile')}`;
|
||||||
}
|
}
|
||||||
@@ -155,7 +164,7 @@ async function uploadProfilePhoto(file, memberName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setMembers(items) {
|
function setMembers(items) {
|
||||||
members = items.map(toLegacyMember);
|
members = getVisibleLegacyMembers(items.map(toLegacyMember));
|
||||||
if (selectedDept !== '전체' && !members.some((member) => member['부서'] === selectedDept)) {
|
if (selectedDept !== '전체' && !members.some((member) => member['부서'] === selectedDept)) {
|
||||||
selectedDept = '전체';
|
selectedDept = '전체';
|
||||||
}
|
}
|
||||||
@@ -1014,11 +1023,11 @@ function handlePhotoFileChange(event) {
|
|||||||
|
|
||||||
function renderSeatPreviewCard(seatInfo) {
|
function renderSeatPreviewCard(seatInfo) {
|
||||||
const assigned = Boolean(seatInfo?.assigned);
|
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 safeSeatMapName = escapeHtml(seatInfo?.seatMapName || '자리배치도');
|
||||||
const safeSlotKey = escapeHtml(seatInfo?.slotKey || '');
|
const safeOfficeLabel = escapeHtml(seatMapLabel);
|
||||||
const badge = assigned
|
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>';
|
: '<span class="seat-preview-badge seat-preview-badge-muted">미배치</span>';
|
||||||
const body = assigned
|
const body = assigned
|
||||||
? `
|
? `
|
||||||
@@ -1039,11 +1048,11 @@ function renderSeatPreviewCard(seatInfo) {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="seat-preview-card">
|
<div class="seat-preview-card${assigned ? ' is-assigned' : ''}">
|
||||||
<div class="seat-preview-head">
|
<div class="seat-preview-head">
|
||||||
<div>
|
<div>
|
||||||
<strong>재석위치</strong>
|
<strong>재석위치</strong>
|
||||||
<p>${assigned ? '현재 자리배치도 기준으로 배치된 좌석 정보를 표시합니다.' : '현재 자리배치도에서 배치된 좌석이 없습니다.'}</p>
|
<p>${assigned ? '현재 배치된 사무실과 좌석 위치를 강조해서 표시합니다.' : '현재 자리배치도에서 배치된 좌석이 없습니다.'}</p>
|
||||||
</div>
|
</div>
|
||||||
${badge}
|
${badge}
|
||||||
</div>
|
</div>
|
||||||
@@ -1177,8 +1186,9 @@ function openModal(id) {
|
|||||||
<div class="col-span-1">
|
<div class="col-span-1">
|
||||||
<label class="text-[11px] font-black text-slate-600 block">근무 상태</label>
|
<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">
|
<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>
|
||||||
|
<option value="퇴직" ${member['근무상태'] === '퇴직' ? 'selected' : ''}>퇴직</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-1">
|
<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-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>
|
<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>
|
||||||
<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-id" value="${id || ''}">
|
||||||
<input type="hidden" id="m-photo-hidden" value="${member['사진'] || ''}">
|
<input type="hidden" id="m-photo-hidden" value="${member['사진'] || ''}">
|
||||||
<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="member-basic-split">
|
||||||
<div class="member-edit-left-pane">
|
<div class="member-basic-left">
|
||||||
<div class="member-edit-profile-card">
|
<div class="member-photo-panel">
|
||||||
<label class="text-[11px] font-black text-slate-600 block">프로필 사진</label>
|
<div class="member-photo-upload-card member-photo-upload-card-inline">
|
||||||
<div class="member-photo-upload-card member-photo-upload-card-compact">
|
<div class="member-photo-card-title">프로필 사진</div>
|
||||||
<div class="member-photo-preview-wrap">
|
<div class="member-photo-preview-wrap">
|
||||||
<img id="m-photo-preview" src="${member['사진'] || getPhotoPlaceholder(member['이름'] || '')}" alt="프로필 미리보기" class="member-photo-preview">
|
<img id="m-photo-preview" src="${member['사진'] || getPhotoPlaceholder(member['이름'] || '')}" alt="프로필 미리보기" class="member-photo-preview">
|
||||||
</div>
|
</div>
|
||||||
@@ -1219,28 +1229,28 @@ 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 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>
|
<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>
|
</div>
|
||||||
<div class="member-inline-info-grid member-inline-info-grid-stacked">
|
<div class="member-basic-field">
|
||||||
<div class="member-inline-info-card member-inline-info-card-full">
|
<label class="text-[11px] font-black text-slate-600 block">사번</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 member-inline-info-card-full">
|
<div class="member-basic-field">
|
||||||
<label>전화번호</label>
|
<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">
|
<input id="m-phone" 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 member-inline-info-card-full">
|
<div class="member-basic-field">
|
||||||
<label>이메일</label>
|
<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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="member-basic-right">
|
||||||
<div class="member-edit-right-pane">
|
<div class="member-seat-field member-seat-field-compact">
|
||||||
<div class="member-seat-field member-seat-field-emphasis">
|
|
||||||
<div id="member-seat-preview">${renderSeatPreviewCard({ assigned: false, seatLabel: member['자리위치'] || '', seatMapName: '자리배치도', slotKey: '' })}</div>
|
<div id="member-seat-preview">${renderSeatPreviewCard({ assigned: false, seatLabel: member['자리위치'] || '', seatMapName: '자리배치도', slotKey: '' })}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1583,7 +1593,7 @@ async function loadSnapshotListView() {
|
|||||||
}
|
}
|
||||||
const payload = await apiFetch(`/api/members?as_of=${encodeURIComponent(snapshotDate)}`);
|
const payload = await apiFetch(`/api/members?as_of=${encodeURIComponent(snapshotDate)}`);
|
||||||
listViewState.snapshotDate = snapshotDate;
|
listViewState.snapshotDate = snapshotDate;
|
||||||
listViewState.snapshotMembers = (payload.items || []).map(toLegacyMember);
|
listViewState.snapshotMembers = getVisibleLegacyMembers((payload.items || []).map(toLegacyMember));
|
||||||
listViewState.mode = 'snapshot';
|
listViewState.mode = 'snapshot';
|
||||||
renderListViewModalContent();
|
renderListViewModalContent();
|
||||||
}
|
}
|
||||||
|
|||||||
56
scripts/prepare_dev_worktree.sh
Executable file
56
scripts/prepare_dev_worktree.sh
Executable 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
15
scripts/start_8081.sh
Executable 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}}'"
|
||||||
12
scripts/start_local_dashboards.sh
Executable file
12
scripts/start_local_dashboards.sh
Executable 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"
|
||||||
@@ -4,9 +4,9 @@ set -euo pipefail
|
|||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
PROD_DIR="${ROOT_DIR}"
|
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_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}"
|
SCOPE="${1:-minimal}"
|
||||||
|
|
||||||
if [[ ! -f "${PROD_DIR}/docker-compose.yml" ]]; then
|
if [[ ! -f "${PROD_DIR}/docker-compose.yml" ]]; then
|
||||||
|
|||||||
Reference in New Issue
Block a user