Compare commits
4 Commits
c8e0a2bd7f
...
e62a6a5458
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e62a6a5458 | ||
|
|
8f073e1458 | ||
|
|
8ac6aa6b72 | ||
|
|
485a581089 |
@@ -3,6 +3,4 @@ POSTGRES_USER=orgapp
|
|||||||
POSTGRES_PASSWORD=change-me
|
POSTGRES_PASSWORD=change-me
|
||||||
DATABASE_URL=postgresql://orgapp:change-me@db:5432/orgdb
|
DATABASE_URL=postgresql://orgapp:change-me@db:5432/orgdb
|
||||||
UPLOAD_DIR=/data/uploads
|
UPLOAD_DIR=/data/uploads
|
||||||
SNAPSHOT_DIR=/data/snapshots
|
|
||||||
MOCK_LOGIN_ENABLED=true
|
MOCK_LOGIN_ENABLED=true
|
||||||
|
|
||||||
|
|||||||
BIN
6f.dxf:Zone.Identifier
Normal file
BIN
6f.dxf:Zone.Identifier
Normal file
Binary file not shown.
BIN
7f.dxf:Zone.Identifier
Normal file
BIN
7f.dxf:Zone.Identifier
Normal file
Binary file not shown.
@@ -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" />
|
<link rel="stylesheet" href="/legacy/static/common.css?v=20260325-9" />
|
||||||
<link rel="stylesheet" href="/legacy/static/organization.css" />
|
<link rel="stylesheet" href="/legacy/static/organization.css?v=20260325-9" />
|
||||||
</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" />
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
|
|
||||||
<div id="stats-area" class="stats-section" style="padding: 10px 15px;">
|
<div id="stats-area" class="stats-section" style="padding: 10px 15px;">
|
||||||
<div class="flex justify-between items-center mb-0 cursor-pointer p-0" id="stats-header">
|
<div class="flex justify-between items-center mb-0 cursor-pointer p-0" id="stats-header">
|
||||||
<h2 class="text-xs font-black text-slate-800 flex items-center gap-2">인원 현황 통계 <span id="total-count-badge" class="bg-indigo-100 text-indigo-600 text-[10px] px-2 py-0.5 rounded-full">0명</span></h2>
|
<h2 class="stats-title text-xs font-black text-slate-800 flex items-center gap-2">인원 현황 통계 <span id="total-count-badge" class="bg-indigo-100 text-indigo-600 text-[10px] px-2 py-0.5 rounded-full">0명</span></h2>
|
||||||
<span id="stats-toggle-icon" class="text-slate-400 text-xs transition-transform duration-200" style="transform: rotate(-90deg);">▼</span>
|
<span id="stats-toggle-icon" class="text-slate-400 text-xs transition-transform duration-200" style="transform: rotate(-90deg);">▼</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="stats-table-container" class="mt-3 overflow-hidden transition-all duration-300" style="display: none;"></div>
|
<div id="stats-table-container" class="mt-3 overflow-hidden transition-all duration-300" style="display: none;"></div>
|
||||||
@@ -60,6 +60,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/legacy/static/organization.js"></script>
|
<script src="/legacy/static/organization.js?v=20260325-9"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ RUN pip install --no-cache-dir -r /app/requirements.txt
|
|||||||
COPY backend/app /app/backend/app
|
COPY backend/app /app/backend/app
|
||||||
COPY DashBoard-organization.html /app/legacy/DashBoard-organization.html
|
COPY DashBoard-organization.html /app/legacy/DashBoard-organization.html
|
||||||
COPY DashBoard-organization-backup.html /app/legacy/DashBoard-organization-backup.html
|
COPY DashBoard-organization-backup.html /app/legacy/DashBoard-organization-backup.html
|
||||||
COPY organization.xlsx /app/legacy/organization.xlsx
|
COPY organization1.xlsx /app/legacy/organization1.xlsx
|
||||||
COPY legacy/static /app/legacy/static
|
COPY legacy/static /app/legacy/static
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ import os
|
|||||||
BASE_DIR = Path("/app")
|
BASE_DIR = Path("/app")
|
||||||
LEGACY_DIR = BASE_DIR / "legacy"
|
LEGACY_DIR = BASE_DIR / "legacy"
|
||||||
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "/data/uploads"))
|
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "/data/uploads"))
|
||||||
SNAPSHOT_DIR = Path(os.getenv("SNAPSHOT_DIR", "/data/snapshots"))
|
|
||||||
DATABASE_URL = os.getenv(
|
DATABASE_URL = os.getenv(
|
||||||
"DATABASE_URL",
|
"DATABASE_URL",
|
||||||
"postgresql://orgapp:change-me@db:5432/orgdb",
|
"postgresql://orgapp:change-me@db:5432/orgdb",
|
||||||
)
|
)
|
||||||
MOCK_LOGIN_ENABLED = os.getenv("MOCK_LOGIN_ENABLED", "true").lower() == "true"
|
MOCK_LOGIN_ENABLED = os.getenv("MOCK_LOGIN_ENABLED", "true").lower() == "true"
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ SCHEMA_SQL = """
|
|||||||
CREATE TABLE IF NOT EXISTS members (
|
CREATE TABLE IF NOT EXISTS members (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
|
employee_id TEXT,
|
||||||
company TEXT,
|
company TEXT,
|
||||||
rank TEXT,
|
rank TEXT,
|
||||||
role TEXT,
|
role TEXT,
|
||||||
@@ -31,24 +32,131 @@ CREATE TABLE IF NOT EXISTS members (
|
|||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS seat_positions (
|
CREATE TABLE IF NOT EXISTS seat_maps (
|
||||||
member_id INTEGER PRIMARY KEY REFERENCES members(id) ON DELETE CASCADE,
|
id SERIAL PRIMARY KEY,
|
||||||
x INTEGER NOT NULL DEFAULT 0,
|
name TEXT NOT NULL,
|
||||||
y INTEGER NOT NULL DEFAULT 0,
|
image_url TEXT NOT NULL,
|
||||||
floor_label TEXT,
|
source_type TEXT NOT NULL DEFAULT 'image',
|
||||||
|
source_url TEXT,
|
||||||
|
preview_svg TEXT,
|
||||||
|
view_box_min_x DOUBLE PRECISION,
|
||||||
|
view_box_min_y DOUBLE PRECISION,
|
||||||
|
view_box_width DOUBLE PRECISION,
|
||||||
|
view_box_height DOUBLE PRECISION,
|
||||||
|
image_width INTEGER,
|
||||||
|
image_height INTEGER,
|
||||||
|
grid_rows INTEGER NOT NULL,
|
||||||
|
grid_cols INTEGER NOT NULL,
|
||||||
|
cell_gap INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS snapshots (
|
CREATE TABLE IF NOT EXISTS seat_positions (
|
||||||
|
member_id INTEGER PRIMARY KEY REFERENCES members(id) ON DELETE CASCADE,
|
||||||
|
seat_map_id INTEGER REFERENCES seat_maps(id) ON DELETE CASCADE,
|
||||||
|
seat_slot_id INTEGER,
|
||||||
|
row_index INTEGER NOT NULL DEFAULT 0,
|
||||||
|
col_index INTEGER NOT NULL DEFAULT 0,
|
||||||
|
seat_label TEXT,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS seat_slots (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
snapshot_month TEXT NOT NULL,
|
seat_map_id INTEGER NOT NULL REFERENCES seat_maps(id) ON DELETE CASCADE,
|
||||||
file_path TEXT NOT NULL,
|
slot_key TEXT NOT NULL,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
label TEXT NOT NULL,
|
||||||
|
x DOUBLE PRECISION NOT NULL,
|
||||||
|
y DOUBLE PRECISION NOT NULL,
|
||||||
|
rotation DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
layer_name TEXT NOT NULL DEFAULT 'chair',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (seat_map_id, slot_key)
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
MIGRATION_SQL = """
|
MIGRATION_SQL = """
|
||||||
|
ALTER TABLE members ADD COLUMN IF NOT EXISTS employee_id TEXT;
|
||||||
ALTER TABLE members ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0;
|
ALTER TABLE members ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE seat_positions ADD COLUMN IF NOT EXISTS seat_map_id INTEGER REFERENCES seat_maps(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE seat_positions ADD COLUMN IF NOT EXISTS seat_slot_id INTEGER;
|
||||||
|
ALTER TABLE seat_positions ADD COLUMN IF NOT EXISTS row_index INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE seat_positions ADD COLUMN IF NOT EXISTS col_index INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE seat_positions ADD COLUMN IF NOT EXISTS seat_label TEXT;
|
||||||
|
ALTER TABLE seat_positions ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
||||||
|
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS source_type TEXT NOT NULL DEFAULT 'image';
|
||||||
|
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS source_url TEXT;
|
||||||
|
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS preview_svg TEXT;
|
||||||
|
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS view_box_min_x DOUBLE PRECISION;
|
||||||
|
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS view_box_min_y DOUBLE PRECISION;
|
||||||
|
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS view_box_width DOUBLE PRECISION;
|
||||||
|
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS view_box_height DOUBLE PRECISION;
|
||||||
|
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS image_width INTEGER;
|
||||||
|
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS image_height INTEGER;
|
||||||
|
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS cell_gap INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
ALTER TABLE seat_maps ALTER COLUMN image_url DROP NOT NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS seat_slots (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
seat_map_id INTEGER NOT NULL REFERENCES seat_maps(id) ON DELETE CASCADE,
|
||||||
|
slot_key TEXT NOT NULL,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
x DOUBLE PRECISION NOT NULL,
|
||||||
|
y DOUBLE PRECISION NOT NULL,
|
||||||
|
rotation DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
layer_name TEXT NOT NULL DEFAULT 'chair',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (seat_map_id, slot_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'seat_positions' AND column_name = 'x'
|
||||||
|
) THEN
|
||||||
|
EXECUTE 'UPDATE seat_positions SET row_index = COALESCE(y, row_index, 0), col_index = COALESCE(x, col_index, 0) WHERE seat_map_id IS NULL';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'seat_positions' AND column_name = 'floor_label'
|
||||||
|
) THEN
|
||||||
|
EXECUTE 'UPDATE seat_positions SET seat_label = COALESCE(seat_label, floor_label) WHERE seat_label IS NULL';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS seat_positions_map_cell_idx
|
||||||
|
ON seat_positions (seat_map_id, row_index, col_index)
|
||||||
|
WHERE seat_map_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS seat_positions_slot_idx
|
||||||
|
ON seat_positions (seat_slot_id)
|
||||||
|
WHERE seat_slot_id IS NOT NULL;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.table_constraints
|
||||||
|
WHERE constraint_name = 'seat_positions_seat_slot_id_fkey'
|
||||||
|
AND table_name = 'seat_positions'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE seat_positions
|
||||||
|
ADD CONSTRAINT seat_positions_seat_slot_id_fkey
|
||||||
|
FOREIGN KEY (seat_slot_id) REFERENCES seat_slots(id) ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,3 +3,4 @@ uvicorn[standard]==0.35.0
|
|||||||
psycopg[binary]==3.2.9
|
psycopg[binary]==3.2.9
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
openpyxl==3.1.5
|
openpyxl==3.1.5
|
||||||
|
ezdxf==1.3.5
|
||||||
|
|||||||
521474
center2.dxf
Normal file
521474
center2.dxf
Normal file
File diff suppressed because it is too large
Load Diff
564192
center3.dxf
Normal file
564192
center3.dxf
Normal file
File diff suppressed because it is too large
Load Diff
592
center_chair_people_map.html
Normal file
592
center_chair_people_map.html
Normal file
File diff suppressed because one or more lines are too long
@@ -22,6 +22,8 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: frontend/Dockerfile
|
dockerfile: frontend/Dockerfile
|
||||||
|
volumes:
|
||||||
|
- ./frontend/public:/usr/share/nginx/html:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1/ || exit 1"]
|
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1/ || exit 1"]
|
||||||
@@ -34,14 +36,18 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: backend/Dockerfile
|
dockerfile: backend/Dockerfile
|
||||||
|
command: uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
|
- ./backend/app:/app/backend/app:ro
|
||||||
|
- ./DashBoard-organization.html:/app/legacy/DashBoard-organization.html:ro
|
||||||
|
- ./DashBoard-organization-backup.html:/app/legacy/DashBoard-organization-backup.html:ro
|
||||||
|
- ./legacy/static:/app/legacy/static:ro
|
||||||
- uploads_data:/data/uploads
|
- uploads_data:/data/uploads
|
||||||
- snapshots_data:/data/snapshots
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health')\" || exit 1"]
|
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health')\" || exit 1"]
|
||||||
@@ -69,4 +75,3 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
uploads_data:
|
uploads_data:
|
||||||
snapshots_data:
|
|
||||||
|
|||||||
@@ -8,13 +8,12 @@
|
|||||||
## 2. 이 프로젝트의 권장 구성
|
## 2. 이 프로젝트의 권장 구성
|
||||||
- `proxy`: 사내 접속용 단일 진입점 역할을 하는 Nginx 리버스 프록시
|
- `proxy`: 사내 접속용 단일 진입점 역할을 하는 Nginx 리버스 프록시
|
||||||
- `frontend`: 화면상 로그인과 허브 화면을 제공하는 정적 프론트엔드
|
- `frontend`: 화면상 로그인과 허브 화면을 제공하는 정적 프론트엔드
|
||||||
- `backend`: 구성원 데이터, 이미지 업로드, 스냅샷 생성을 처리하는 FastAPI 서버
|
- `backend`: 구성원 데이터와 이미지 업로드를 처리하는 FastAPI 서버
|
||||||
- `db`: 영구 저장을 담당하는 PostgreSQL 데이터베이스
|
- `db`: 영구 저장을 담당하는 PostgreSQL 데이터베이스
|
||||||
|
|
||||||
## 3. 왜 이 구조가 지금 프로젝트에 맞는가
|
## 3. 왜 이 구조가 지금 프로젝트에 맞는가
|
||||||
- 기존 조직도 HTML 화면을 그대로 레거시 모듈로 유지할 수 있습니다.
|
- 기존 조직도 HTML 화면을 그대로 레거시 모듈로 유지할 수 있습니다.
|
||||||
- 프로필 사진 업로드를 서버에 저장할 수 있습니다.
|
- 프로필 사진 업로드를 서버에 저장할 수 있습니다.
|
||||||
- 월말 조직 데이터 스냅샷을 서버에서 생성하고 보관할 수 있습니다.
|
|
||||||
- 요청하신 대로 로그인은 우선 화면상으로만 구현해 둘 수 있습니다.
|
- 요청하신 대로 로그인은 우선 화면상으로만 구현해 둘 수 있습니다.
|
||||||
|
|
||||||
## 4. Ubuntu 서버 준비
|
## 4. Ubuntu 서버 준비
|
||||||
@@ -56,17 +55,16 @@
|
|||||||
## 7. 현재 단계의 데이터 및 백업 정책
|
## 7. 현재 단계의 데이터 및 백업 정책
|
||||||
- 데이터베이스: PostgreSQL 볼륨 `postgres_data`
|
- 데이터베이스: PostgreSQL 볼륨 `postgres_data`
|
||||||
- 업로드 파일: Docker 볼륨 `uploads_data`
|
- 업로드 파일: Docker 볼륨 `uploads_data`
|
||||||
- 월말 스냅샷 파일: Docker 볼륨 `snapshots_data`
|
- 백업 주기: DB 볼륨 백업
|
||||||
- 백업 주기: 월말 스냅샷 생성 + DB 볼륨 백업
|
|
||||||
- 복구 기준: 아직 정해지지 않았으므로, 우선은 수동 복구 절차를 먼저 문서화하고 이후에 기준을 구체화합니다.
|
- 복구 기준: 아직 정해지지 않았으므로, 우선은 수동 복구 절차를 먼저 문서화하고 이후에 기준을 구체화합니다.
|
||||||
|
|
||||||
## 8. 현재 구조의 한계
|
## 8. 현재 구조의 한계
|
||||||
- 로그인은 화면상 동작만 구현되어 있고, 아직 백엔드 보호 기능은 없습니다.
|
- 로그인은 화면상 동작만 구현되어 있고, 아직 백엔드 보호 기능은 없습니다.
|
||||||
- 레거시 조직도 화면은 현재 DB 기반 API를 사용하도록 전환했지만, 운영 환경에서 전체 업로드/재기동/스냅샷 흐름 검증이 추가로 필요합니다.
|
- 레거시 조직도 화면은 현재 DB 기반 API를 사용하도록 전환했지만, 운영 환경에서 전체 업로드/재기동 흐름 검증이 추가로 필요합니다.
|
||||||
- 레거시 화면은 CDN 자산을 사용합니다. 사내망이 외부 인터넷 접속을 막는 환경이라면 추후 로컬 자산으로 바꿔야 합니다.
|
- 레거시 화면은 CDN 자산을 사용합니다. 사내망이 외부 인터넷 접속을 막는 환경이라면 추후 로컬 자산으로 바꿔야 합니다.
|
||||||
|
|
||||||
## 9. 다음 구현 권장 순서
|
## 9. 다음 구현 권장 순서
|
||||||
1. Docker Compose 기준 운영 검증과 스냅샷 검증을 완료합니다.
|
1. Docker Compose 기준 운영 검증을 완료합니다.
|
||||||
2. 4개 기능 통합 대시보드 프레임과 공통 헤더를 준비합니다.
|
2. 4개 기능 통합 대시보드 프레임과 공통 헤더를 준비합니다.
|
||||||
3. 프로필 사진 업로드 UI를 `/api/uploads/profile-photo` 와 연결합니다.
|
3. 프로필 사진 업로드 UI를 `/api/uploads/profile-photo` 와 연결합니다.
|
||||||
4. 사무실 자리배치 좌표 저장 기능을 추가합니다.
|
4. 사무실 자리배치 좌표 저장 기능을 추가합니다.
|
||||||
@@ -81,6 +79,4 @@
|
|||||||
## 11. 운영 검증 체크포인트
|
## 11. 운영 검증 체크포인트
|
||||||
- 엑셀 또는 CSV 업로드 후 `GET /api/members` 에서 데이터가 조회되는지 확인합니다.
|
- 엑셀 또는 CSV 업로드 후 `GET /api/members` 에서 데이터가 조회되는지 확인합니다.
|
||||||
- `docker compose restart backend proxy` 이후에도 데이터가 유지되는지 확인합니다.
|
- `docker compose restart backend proxy` 이후에도 데이터가 유지되는지 확인합니다.
|
||||||
- `POST /api/snapshots/monthly` 호출 시 `YYYY-MM` 형식만 허용되는지 확인합니다.
|
- `docker compose down` 후 다시 `up -d` 했을 때 DB/업로드 데이터가 유지되는지 확인합니다.
|
||||||
- 같은 월에 대해 중복 스냅샷 생성 시 409 에러가 반환되는지 확인합니다.
|
|
||||||
- `docker compose down` 후 다시 `up -d` 했을 때 DB/업로드/스냅샷 데이터가 유지되는지 확인합니다.
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
- `status` 가 `ok`
|
- `status` 가 `ok`
|
||||||
- `checks.database` 가 `true`
|
- `checks.database` 가 `true`
|
||||||
- `checks.upload_dir` 가 `true`
|
- `checks.upload_dir` 가 `true`
|
||||||
- `checks.snapshot_dir` 가 `true`
|
|
||||||
|
|
||||||
## 3. 초기 데이터 업로드
|
## 3. 초기 데이터 업로드
|
||||||
- 조직도 화면에서 `.xlsx` 또는 `.csv` 업로드
|
- 조직도 화면에서 `.xlsx` 또는 `.csv` 업로드
|
||||||
@@ -27,21 +26,7 @@
|
|||||||
- 확인 기준:
|
- 확인 기준:
|
||||||
- 업로드했던 데이터가 그대로 유지됨
|
- 업로드했던 데이터가 그대로 유지됨
|
||||||
|
|
||||||
## 5. 스냅샷 검증
|
## 5. 종료 후 재기동 확인
|
||||||
- `curl -X POST -F snapshot_month=2026-03 http://localhost:8080/api/snapshots/monthly`
|
|
||||||
- 확인 기준:
|
|
||||||
- CSV 파일 경로가 반환됨
|
|
||||||
- `/snapshots/...` 다운로드 가능
|
|
||||||
|
|
||||||
## 6. 중복/형식 오류 검증
|
|
||||||
- 같은 월로 다시 스냅샷 생성
|
|
||||||
- 확인 기준:
|
|
||||||
- 409 에러 반환
|
|
||||||
- 잘못된 형식으로 스냅샷 생성 예: `202603`
|
|
||||||
- 확인 기준:
|
|
||||||
- 400 에러 반환
|
|
||||||
|
|
||||||
## 7. 종료 후 재기동 확인
|
|
||||||
- `docker compose down`
|
- `docker compose down`
|
||||||
- `docker compose up -d`
|
- `docker compose up -d`
|
||||||
- 확인 기준:
|
- 확인 기준:
|
||||||
|
|||||||
726
frontend/public/app.js
Executable file → Normal file
726
frontend/public/app.js
Executable file → Normal file
@@ -11,13 +11,55 @@ const currentViewTitle = document.getElementById("current-view-title");
|
|||||||
const navButtons = Array.from(document.querySelectorAll(".header-center [data-view]"));
|
const navButtons = Array.from(document.querySelectorAll(".header-center [data-view]"));
|
||||||
const organizationFrame = document.getElementById("organization-frame");
|
const organizationFrame = document.getElementById("organization-frame");
|
||||||
const organizationStage = document.getElementById("organization-stage");
|
const organizationStage = document.getElementById("organization-stage");
|
||||||
|
const seatMapStage = document.getElementById("seatmap-stage");
|
||||||
const emptyStage = document.getElementById("empty-stage");
|
const emptyStage = document.getElementById("empty-stage");
|
||||||
|
|
||||||
|
const seatMapName = document.getElementById("seatmap-name");
|
||||||
|
const seatMapStatus = document.getElementById("seatmap-status");
|
||||||
|
const seatMapSaveBtn = document.getElementById("seatmap-save-btn");
|
||||||
|
const seatMapCancelBtn = document.getElementById("seatmap-cancel-btn");
|
||||||
|
const seatMapBoardWrap = document.getElementById("seatmap-board-wrap");
|
||||||
|
const seatMapBoard = document.getElementById("seatmap-board");
|
||||||
|
const seatMapEmpty = document.getElementById("seatmap-empty");
|
||||||
|
const seatMapSettingsPanel = document.getElementById("seatmap-settings-panel");
|
||||||
|
const seatMapSettingsForm = document.getElementById("seatmap-settings-form");
|
||||||
|
const seatMapFormName = document.getElementById("seatmap-form-name");
|
||||||
|
const seatMapFileName = document.getElementById("seatmap-file-name");
|
||||||
|
const seatMapFormRows = document.getElementById("seatmap-form-rows");
|
||||||
|
const seatMapFormCols = document.getElementById("seatmap-form-cols");
|
||||||
|
const seatMapFormGap = document.getElementById("seatmap-form-gap");
|
||||||
|
const seatMapFormImage = document.getElementById("seatmap-form-image");
|
||||||
|
const seatMapSearch = document.getElementById("seatmap-search");
|
||||||
|
const seatMapUnassigned = document.getElementById("seatmap-unassigned");
|
||||||
|
|
||||||
const viewLabels = {
|
const viewLabels = {
|
||||||
ledger: "사업관리대장",
|
ledger: "사업관리대장",
|
||||||
project: "프로젝트별 분석",
|
project: "프로젝트별 분석",
|
||||||
team: "팀/개인별 분석",
|
team: "팀/개인별 분석",
|
||||||
organization: "조직 현황",
|
organization: "조직 현황",
|
||||||
|
seatmap: "조직 현황",
|
||||||
|
};
|
||||||
|
|
||||||
|
const seatMapState = {
|
||||||
|
loaded: false,
|
||||||
|
loading: false,
|
||||||
|
seatMap: null,
|
||||||
|
members: [],
|
||||||
|
slots: [],
|
||||||
|
placements: [],
|
||||||
|
draftPlacements: [],
|
||||||
|
editMode: false,
|
||||||
|
dirty: false,
|
||||||
|
search: "",
|
||||||
|
status: "",
|
||||||
|
statusTone: "info",
|
||||||
|
draggingMemberId: null,
|
||||||
|
zoom: 1,
|
||||||
|
panning: false,
|
||||||
|
panStartX: 0,
|
||||||
|
panStartY: 0,
|
||||||
|
panScrollLeft: 0,
|
||||||
|
panScrollTop: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
let currentView = "organization";
|
let currentView = "organization";
|
||||||
@@ -46,31 +88,588 @@ function toggleUserPopover() {
|
|||||||
userPopover?.classList.toggle("hidden");
|
userPopover?.classList.toggle("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAdmin() {
|
||||||
|
return getSession()?.user?.role === "admin";
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? "")
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clonePlacements(items) {
|
||||||
|
return items.map((item) => ({
|
||||||
|
member_id: Number(item.member_id),
|
||||||
|
seat_slot_id: item.seat_slot_id == null ? null : Number(item.seat_slot_id),
|
||||||
|
row_index: Number(item.row_index),
|
||||||
|
col_index: Number(item.col_index),
|
||||||
|
seat_label: item.seat_label || "",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeSeatLabel(rowIndex, colIndex) {
|
||||||
|
let quotient = rowIndex;
|
||||||
|
let rowLabel = "";
|
||||||
|
while (true) {
|
||||||
|
const remainder = quotient % 26;
|
||||||
|
rowLabel = String.fromCharCode(65 + remainder) + rowLabel;
|
||||||
|
quotient = Math.floor(quotient / 26);
|
||||||
|
if (quotient === 0) break;
|
||||||
|
quotient -= 1;
|
||||||
|
}
|
||||||
|
return `${rowLabel}-${String(colIndex + 1).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitials(name) {
|
||||||
|
const trimmed = String(name || "").trim();
|
||||||
|
if (!trimmed) return "?";
|
||||||
|
return trimmed.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlacementSource() {
|
||||||
|
return seatMapState.editMode ? seatMapState.draftPlacements : seatMapState.placements;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSeatMapStatus(message, tone = "info") {
|
||||||
|
seatMapState.status = message || "";
|
||||||
|
seatMapState.statusTone = tone;
|
||||||
|
if (seatMapStatus) {
|
||||||
|
seatMapStatus.textContent = seatMapState.status;
|
||||||
|
seatMapStatus.dataset.tone = seatMapState.statusTone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSeatMapDraft() {
|
||||||
|
seatMapState.draftPlacements = clonePlacements(seatMapState.placements);
|
||||||
|
seatMapState.dirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampSeatMapZoom(nextZoom) {
|
||||||
|
return Math.min(3, Math.max(0.5, Number(nextZoom.toFixed(2))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSeatMapZoom(nextZoom) {
|
||||||
|
seatMapState.zoom = clampSeatMapZoom(nextZoom);
|
||||||
|
renderSeatMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSeatSlotMap() {
|
||||||
|
return new Map((seatMapState.slots || []).map((slot) => [Number(slot.id), slot]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMemberMap() {
|
||||||
|
return new Map(seatMapState.members.map((member) => [Number(member.id), member]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUnassignedMembers() {
|
||||||
|
const placedIds = new Set(getPlacementSource().map((item) => Number(item.member_id)));
|
||||||
|
const keyword = seatMapState.search.trim().toLowerCase();
|
||||||
|
return seatMapState.members.filter((member) => {
|
||||||
|
if (placedIds.has(Number(member.id))) return false;
|
||||||
|
if (!keyword) return true;
|
||||||
|
const haystack = `${member.name || ""} ${member.department || ""} ${member.team || ""}`.toLowerCase();
|
||||||
|
return haystack.includes(keyword);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCellPlacementMap() {
|
||||||
|
const cellMap = new Map();
|
||||||
|
getPlacementSource().forEach((item) => {
|
||||||
|
cellMap.set(`${item.row_index}:${item.col_index}`, item);
|
||||||
|
});
|
||||||
|
return cellMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSlotPlacementMap() {
|
||||||
|
const slotMap = new Map();
|
||||||
|
getPlacementSource().forEach((item) => {
|
||||||
|
if (item.seat_slot_id != null) {
|
||||||
|
slotMap.set(Number(item.seat_slot_id), item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return slotMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertDraftPlacement(memberId, rowIndex, colIndex) {
|
||||||
|
const cellMap = getCellPlacementMap();
|
||||||
|
const existing = cellMap.get(`${rowIndex}:${colIndex}`);
|
||||||
|
if (existing && Number(existing.member_id) !== Number(memberId)) {
|
||||||
|
setSeatMapStatus("이미 사용 중인 칸입니다. 빈 칸으로 이동해주세요.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPlacements = seatMapState.draftPlacements.filter((item) => Number(item.member_id) !== Number(memberId));
|
||||||
|
nextPlacements.push({
|
||||||
|
member_id: Number(memberId),
|
||||||
|
row_index: Number(rowIndex),
|
||||||
|
col_index: Number(colIndex),
|
||||||
|
seat_label: computeSeatLabel(rowIndex, colIndex),
|
||||||
|
});
|
||||||
|
seatMapState.draftPlacements = nextPlacements.sort((left, right) => {
|
||||||
|
if (left.row_index !== right.row_index) return left.row_index - right.row_index;
|
||||||
|
return left.col_index - right.col_index;
|
||||||
|
});
|
||||||
|
seatMapState.dirty = true;
|
||||||
|
setSeatMapStatus("배치를 수정했습니다. 저장 버튼으로 반영하세요.", "info");
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertDraftPlacementForSlot(memberId, seatSlotId) {
|
||||||
|
const placementMap = getSlotPlacementMap();
|
||||||
|
const existing = placementMap.get(Number(seatSlotId));
|
||||||
|
if (existing && Number(existing.member_id) !== Number(memberId)) {
|
||||||
|
setSeatMapStatus("이미 사용 중인 좌석입니다. 빈 좌석으로 이동해주세요.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seatSlot = getSeatSlotMap().get(Number(seatSlotId));
|
||||||
|
const nextPlacements = seatMapState.draftPlacements.filter((item) => Number(item.member_id) !== Number(memberId));
|
||||||
|
nextPlacements.push({
|
||||||
|
member_id: Number(memberId),
|
||||||
|
seat_slot_id: Number(seatSlotId),
|
||||||
|
row_index: 0,
|
||||||
|
col_index: 0,
|
||||||
|
seat_label: seatSlot?.label || `SLOT-${seatSlotId}`,
|
||||||
|
});
|
||||||
|
seatMapState.draftPlacements = nextPlacements;
|
||||||
|
seatMapState.dirty = true;
|
||||||
|
setSeatMapStatus("배치를 수정했습니다. 저장 버튼으로 반영하세요.", "info");
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDraftPlacement(memberId) {
|
||||||
|
const before = seatMapState.draftPlacements.length;
|
||||||
|
seatMapState.draftPlacements = seatMapState.draftPlacements.filter((item) => Number(item.member_id) !== Number(memberId));
|
||||||
|
if (seatMapState.draftPlacements.length !== before) {
|
||||||
|
seatMapState.dirty = true;
|
||||||
|
setSeatMapStatus("구성원을 미배치 목록으로 이동했습니다. 저장 버튼으로 반영하세요.", "info");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMemberCard(member, draggable) {
|
||||||
|
const photoUrl = member.photo_url ? escapeHtml(member.photo_url) : "";
|
||||||
|
const avatar = photoUrl
|
||||||
|
? `<span class="seatmap-member-avatar"><img src="${photoUrl}" alt="${escapeHtml(member.name)}"></span>`
|
||||||
|
: `<span class="seatmap-member-avatar seatmap-member-avatar-fallback">${escapeHtml(getInitials(member.name))}</span>`;
|
||||||
|
return `
|
||||||
|
<div class="seatmap-member-card${draggable ? " draggable" : ""}" draggable="${draggable}" data-member-id="${Number(member.id)}">
|
||||||
|
${avatar}
|
||||||
|
<span class="seatmap-member-text">
|
||||||
|
<strong>${escapeHtml(member.name || "-")}</strong>
|
||||||
|
<em>${escapeHtml(member.department || member.team || member.rank || "-")}</em>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUnassignedMemberCard(member, draggable) {
|
||||||
|
return `
|
||||||
|
<div class="seatmap-member-card seatmap-member-card-compact${draggable ? " draggable" : ""}" draggable="${draggable}" data-member-id="${Number(member.id)}">
|
||||||
|
<span class="seatmap-member-text seatmap-member-text-inline">
|
||||||
|
<strong>${escapeHtml(member.name || "-")}</strong>
|
||||||
|
<em>${escapeHtml(member.rank || "-")}</em>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSeatMapBoard() {
|
||||||
|
if (!seatMapBoard || !seatMapState.seatMap) return;
|
||||||
|
|
||||||
|
if (seatMapState.seatMap.source_type === "dxf") {
|
||||||
|
renderDxfSeatMapBoard();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberMap = getMemberMap();
|
||||||
|
const placementMap = getCellPlacementMap();
|
||||||
|
const rows = Number(seatMapState.seatMap.grid_rows || 0);
|
||||||
|
const cols = Number(seatMapState.seatMap.grid_cols || 0);
|
||||||
|
const gap = Number(seatMapState.seatMap.cell_gap || 0);
|
||||||
|
const editable = seatMapState.editMode && isAdmin();
|
||||||
|
const cells = [];
|
||||||
|
|
||||||
|
for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) {
|
||||||
|
for (let colIndex = 0; colIndex < cols; colIndex += 1) {
|
||||||
|
const key = `${rowIndex}:${colIndex}`;
|
||||||
|
const placement = placementMap.get(key);
|
||||||
|
const member = placement ? memberMap.get(Number(placement.member_id)) : null;
|
||||||
|
cells.push(`
|
||||||
|
<div class="seatmap-cell${placement ? " occupied" : ""}${editable ? " editable" : ""}" data-row="${rowIndex}" data-col="${colIndex}">
|
||||||
|
<span class="seatmap-cell-label">${escapeHtml(computeSeatLabel(rowIndex, colIndex))}</span>
|
||||||
|
${member ? renderMemberCard(member, editable) : ""}
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seatMapBoard.innerHTML = `
|
||||||
|
<div class="seatmap-canvas" style="--seatmap-rows:${rows}; --seatmap-cols:${cols}; --seatmap-gap:${gap}px;">
|
||||||
|
<img class="seatmap-image" src="${escapeHtml(seatMapState.seatMap.image_url)}" alt="${escapeHtml(seatMapState.seatMap.name)}">
|
||||||
|
<div class="seatmap-grid">${cells.join("")}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDxfSeatMapBoard() {
|
||||||
|
if (!seatMapBoard || !seatMapState.seatMap) return;
|
||||||
|
|
||||||
|
const memberMap = getMemberMap();
|
||||||
|
const placementMap = getSlotPlacementMap();
|
||||||
|
const slots = Array.isArray(seatMapState.slots) ? seatMapState.slots : [];
|
||||||
|
const editable = seatMapState.editMode && isAdmin();
|
||||||
|
const minX = Number(seatMapState.seatMap.view_box_min_x || 0);
|
||||||
|
const minY = Number(seatMapState.seatMap.view_box_min_y || 0);
|
||||||
|
const width = Number(seatMapState.seatMap.view_box_width || 1);
|
||||||
|
const height = Number(seatMapState.seatMap.view_box_height || 1);
|
||||||
|
const previewSvg = seatMapState.seatMap.preview_svg || "";
|
||||||
|
|
||||||
|
const slotHtml = slots
|
||||||
|
.map((slot) => {
|
||||||
|
const slotId = Number(slot.id);
|
||||||
|
const placement = placementMap.get(slotId);
|
||||||
|
const member = placement ? memberMap.get(Number(placement.member_id)) : null;
|
||||||
|
if (!member && !editable) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const left = ((Number(slot.x) - minX) / width) * 100;
|
||||||
|
const top = (1 - (Number(slot.y) - minY) / height) * 100;
|
||||||
|
return `
|
||||||
|
<div class="seatmap-slot${placement ? " occupied" : ""}${editable ? " editable" : ""}${!member ? " empty" : ""}" data-slot-id="${slotId}" style="left:${left}%; top:${top}%;">
|
||||||
|
${member ? renderMemberCard(member, editable) : ""}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
seatMapBoard.innerHTML = `
|
||||||
|
<div class="seatmap-dxf-canvas">
|
||||||
|
<div class="seatmap-dxf-stage" style="transform: scale(${seatMapState.zoom}); --seatmap-zoom:${seatMapState.zoom};">
|
||||||
|
<div class="seatmap-dxf-preview">${previewSvg}</div>
|
||||||
|
<div class="seatmap-dxf-slots">${slotHtml}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUnassignedMembers() {
|
||||||
|
if (!seatMapUnassigned) return;
|
||||||
|
const editable = seatMapState.editMode && isAdmin();
|
||||||
|
const members = getUnassignedMembers();
|
||||||
|
|
||||||
|
if (!members.length) {
|
||||||
|
seatMapUnassigned.innerHTML = `
|
||||||
|
<div class="seatmap-list-empty">
|
||||||
|
${seatMapState.search ? "검색 결과가 없습니다." : "미배치 인원이 없습니다."}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
seatMapUnassigned.innerHTML = members.map((member) => renderUnassignedMemberCard(member, editable)).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSeatMapEmpty() {
|
||||||
|
if (!seatMapEmpty) return;
|
||||||
|
if (seatMapState.seatMap) {
|
||||||
|
seatMapEmpty.classList.add("hidden");
|
||||||
|
seatMapEmpty.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
seatMapEmpty.classList.remove("hidden");
|
||||||
|
seatMapEmpty.innerHTML = `
|
||||||
|
<div class="seatmap-empty-card">
|
||||||
|
<strong>등록된 자리배치도가 없습니다.</strong>
|
||||||
|
<p>${isAdmin() ? "오른쪽 설정 패널에서 이미지와 그리드를 등록하세요." : "관리자에게 자리배치도 등록을 요청하세요."}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSeatMapSettingsForm() {
|
||||||
|
if (!seatMapSettingsForm) return;
|
||||||
|
if (seatMapFormName) {
|
||||||
|
seatMapFormName.value = seatMapState.seatMap?.name || "";
|
||||||
|
}
|
||||||
|
if (seatMapFormRows) {
|
||||||
|
seatMapFormRows.value = seatMapState.seatMap?.grid_rows || 12;
|
||||||
|
}
|
||||||
|
if (seatMapFormCols) {
|
||||||
|
seatMapFormCols.value = seatMapState.seatMap?.grid_cols || 24;
|
||||||
|
}
|
||||||
|
if (seatMapFormGap) {
|
||||||
|
seatMapFormGap.value = seatMapState.seatMap?.cell_gap ?? 2;
|
||||||
|
}
|
||||||
|
if (seatMapFormImage) {
|
||||||
|
seatMapFormImage.value = "";
|
||||||
|
}
|
||||||
|
if (seatMapFileName) {
|
||||||
|
seatMapFileName.textContent = "선택된 파일 없음";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSeatMap() {
|
||||||
|
const hasSeatMap = Boolean(seatMapState.seatMap);
|
||||||
|
const admin = isAdmin();
|
||||||
|
|
||||||
|
if (seatMapName) {
|
||||||
|
seatMapName.textContent = hasSeatMap ? seatMapState.seatMap.name : "자리배치도";
|
||||||
|
}
|
||||||
|
if (seatMapStatus) {
|
||||||
|
seatMapStatus.textContent = seatMapState.status;
|
||||||
|
seatMapStatus.dataset.tone = seatMapState.statusTone;
|
||||||
|
}
|
||||||
|
if (seatMapSettingsPanel) {
|
||||||
|
seatMapSettingsPanel.classList.toggle("hidden", !admin);
|
||||||
|
}
|
||||||
|
if (seatMapSaveBtn) {
|
||||||
|
seatMapSaveBtn.hidden = !admin || !hasSeatMap;
|
||||||
|
seatMapSaveBtn.disabled = !seatMapState.dirty;
|
||||||
|
}
|
||||||
|
if (seatMapCancelBtn) {
|
||||||
|
seatMapCancelBtn.hidden = !hasSeatMap;
|
||||||
|
}
|
||||||
|
if (seatMapSettingsForm) {
|
||||||
|
seatMapSettingsForm.querySelector("button[type='submit']").textContent = hasSeatMap ? "배치도 저장" : "배치도 생성";
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSeatMapEmpty();
|
||||||
|
if (seatMapBoardWrap) {
|
||||||
|
seatMapBoardWrap.classList.toggle("hidden", !hasSeatMap);
|
||||||
|
}
|
||||||
|
if (hasSeatMap) {
|
||||||
|
renderSeatMapBoard();
|
||||||
|
} else if (seatMapBoard) {
|
||||||
|
seatMapBoard.innerHTML = "";
|
||||||
|
}
|
||||||
|
renderUnassignedMembers();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEmbeddedNavigationMessage(event) {
|
||||||
|
const data = event.data;
|
||||||
|
if (!data || typeof data !== "object") return;
|
||||||
|
if (data.type === "open-seatmap" && isAdmin()) {
|
||||||
|
hideUserPopover();
|
||||||
|
setActiveView("seatmap");
|
||||||
|
}
|
||||||
|
if (data.type === "open-organization") {
|
||||||
|
hideUserPopover();
|
||||||
|
setActiveView("organization");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(url, options) {
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
let payload = null;
|
||||||
|
try {
|
||||||
|
payload = await response.json();
|
||||||
|
} catch {
|
||||||
|
payload = null;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = payload?.detail || "요청 처리에 실패했습니다.";
|
||||||
|
const error = new Error(message);
|
||||||
|
error.status = response.status;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSeatMapData(force = false) {
|
||||||
|
if (seatMapState.loading || (seatMapState.loaded && !force)) return;
|
||||||
|
seatMapState.loading = true;
|
||||||
|
setSeatMapStatus("자리배치도를 불러오는 중입니다.", "info");
|
||||||
|
renderSeatMap();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const activePayload = await fetchJson("/api/seat-maps/active");
|
||||||
|
const activeSeatMap = activePayload.item;
|
||||||
|
const layoutPayload = await fetchJson(`/api/seat-maps/${activeSeatMap.id}/layout`);
|
||||||
|
seatMapState.seatMap = layoutPayload.seat_map;
|
||||||
|
seatMapState.members = Array.isArray(layoutPayload.members) ? layoutPayload.members : [];
|
||||||
|
seatMapState.slots = Array.isArray(layoutPayload.slots) ? layoutPayload.slots : [];
|
||||||
|
seatMapState.placements = clonePlacements(layoutPayload.placements || []);
|
||||||
|
seatMapState.zoom = 1;
|
||||||
|
seatMapState.editMode = isAdmin();
|
||||||
|
resetSeatMapDraft();
|
||||||
|
seatMapState.loaded = true;
|
||||||
|
setSeatMapStatus(isAdmin() ? "구성원을 바로 드래그해서 배치한 뒤 저장하세요." : "자리배치도를 불러왔습니다.", "success");
|
||||||
|
syncSeatMapSettingsForm();
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status === 404) {
|
||||||
|
seatMapState.seatMap = null;
|
||||||
|
seatMapState.members = [];
|
||||||
|
seatMapState.slots = [];
|
||||||
|
seatMapState.placements = [];
|
||||||
|
seatMapState.zoom = 1;
|
||||||
|
seatMapState.editMode = isAdmin();
|
||||||
|
resetSeatMapDraft();
|
||||||
|
seatMapState.loaded = true;
|
||||||
|
setSeatMapStatus("활성화된 자리배치도가 없습니다.", "info");
|
||||||
|
syncSeatMapSettingsForm();
|
||||||
|
} else {
|
||||||
|
setSeatMapStatus(error.message || "자리배치도 조회에 실패했습니다.", "error");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
seatMapState.loading = false;
|
||||||
|
renderSeatMap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getImageDimensions(file) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const image = new Image();
|
||||||
|
const objectUrl = URL.createObjectURL(file);
|
||||||
|
image.onload = () => {
|
||||||
|
resolve({ width: image.naturalWidth || null, height: image.naturalHeight || null });
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
};
|
||||||
|
image.onerror = () => {
|
||||||
|
resolve({ width: null, height: null });
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
};
|
||||||
|
image.src = objectUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadSeatMapImage(file, name) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
formData.append("name", name);
|
||||||
|
return fetchJson("/api/seat-maps/dxf", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitSeatMapSettings(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!isAdmin()) return;
|
||||||
|
|
||||||
|
const name = seatMapFormName?.value?.trim() || "";
|
||||||
|
const imageFile = seatMapFormImage?.files?.[0] || null;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
setSeatMapStatus("배치도 이름을 입력하세요.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!imageFile) {
|
||||||
|
setSeatMapStatus("DXF 파일을 선택하세요.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!imageFile.name.toLowerCase().endsWith(".dxf")) {
|
||||||
|
setSeatMapStatus("DXF 파일만 업로드할 수 있습니다.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSeatMapStatus("DXF 자리배치도를 업로드하고 분석하는 중입니다.", "info");
|
||||||
|
await uploadSeatMapImage(imageFile, name);
|
||||||
|
if (seatMapFormImage) seatMapFormImage.value = "";
|
||||||
|
if (seatMapFileName) seatMapFileName.textContent = "선택된 파일 없음";
|
||||||
|
await loadSeatMapData(true);
|
||||||
|
setSeatMapStatus("DXF 자리배치도를 저장했습니다.", "success");
|
||||||
|
} catch (error) {
|
||||||
|
setSeatMapStatus(error.message || "DXF 자리배치도 저장에 실패했습니다.", "error");
|
||||||
|
} finally {
|
||||||
|
renderSeatMap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSeatLayout() {
|
||||||
|
if (!seatMapState.seatMap || !seatMapState.editMode || !seatMapState.dirty) return;
|
||||||
|
try {
|
||||||
|
setSeatMapStatus("자리배치를 저장하는 중입니다.", "info");
|
||||||
|
await fetchJson(`/api/seat-maps/${seatMapState.seatMap.id}/layout`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ placements: seatMapState.draftPlacements }),
|
||||||
|
});
|
||||||
|
await loadSeatMapData(true);
|
||||||
|
setSeatMapStatus("자리배치를 저장했습니다.", "success");
|
||||||
|
} catch (error) {
|
||||||
|
setSeatMapStatus(error.message || "자리배치도 저장에 실패했습니다.", "error");
|
||||||
|
renderSeatMap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelSeatMapEdit() {
|
||||||
|
resetSeatMapDraft();
|
||||||
|
setSeatMapStatus("", "info");
|
||||||
|
setActiveView("organization");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDraggedMemberId(event) {
|
||||||
|
const raw = event.dataTransfer?.getData("text/plain") || seatMapState.draggingMemberId;
|
||||||
|
const memberId = Number(raw);
|
||||||
|
if (!Number.isInteger(memberId) || memberId <= 0) return null;
|
||||||
|
return memberId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSeatMapCellDrop(event) {
|
||||||
|
if (!seatMapState.editMode) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const memberId = getDraggedMemberId(event);
|
||||||
|
if (!memberId) return;
|
||||||
|
if (seatMapState.seatMap?.source_type === "dxf") {
|
||||||
|
const slot = event.target.closest(".seatmap-slot");
|
||||||
|
if (!slot) return;
|
||||||
|
upsertDraftPlacementForSlot(memberId, Number(slot.dataset.slotId));
|
||||||
|
} else {
|
||||||
|
const cell = event.target.closest(".seatmap-cell");
|
||||||
|
if (!cell) return;
|
||||||
|
upsertDraftPlacement(memberId, Number(cell.dataset.row), Number(cell.dataset.col));
|
||||||
|
}
|
||||||
|
renderSeatMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSeatMapListDrop(event) {
|
||||||
|
if (!seatMapState.editMode) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const memberId = getDraggedMemberId(event);
|
||||||
|
if (!memberId) return;
|
||||||
|
removeDraftPlacement(memberId);
|
||||||
|
renderSeatMap();
|
||||||
|
}
|
||||||
|
|
||||||
function setActiveView(view) {
|
function setActiveView(view) {
|
||||||
const previousView = currentView;
|
const previousView = currentView;
|
||||||
currentView = view in viewLabels ? view : "organization";
|
currentView = view in viewLabels ? view : "organization";
|
||||||
if (currentViewTitle) {
|
if (currentViewTitle) {
|
||||||
currentViewTitle.textContent = viewLabels[currentView];
|
currentViewTitle.textContent = viewLabels[currentView];
|
||||||
}
|
}
|
||||||
|
|
||||||
navButtons.forEach((button) => {
|
navButtons.forEach((button) => {
|
||||||
const active = button.dataset.view === currentView;
|
const active = button.dataset.view === currentView;
|
||||||
button.classList.toggle("active", active);
|
button.classList.toggle("active", active);
|
||||||
button.classList.toggle("muted", !active);
|
button.classList.toggle("muted", !active);
|
||||||
});
|
});
|
||||||
|
|
||||||
const isOrganization = currentView === "organization";
|
const isOrganization = currentView === "organization";
|
||||||
|
const isSeatMap = currentView === "seatmap";
|
||||||
if (organizationStage) {
|
if (organizationStage) {
|
||||||
organizationStage.hidden = !isOrganization;
|
organizationStage.hidden = !isOrganization;
|
||||||
organizationStage.style.display = isOrganization ? "flex" : "none";
|
organizationStage.style.display = isOrganization ? "flex" : "none";
|
||||||
}
|
}
|
||||||
|
if (seatMapStage) {
|
||||||
|
seatMapStage.hidden = !isSeatMap;
|
||||||
|
seatMapStage.style.display = isSeatMap ? "flex" : "none";
|
||||||
|
}
|
||||||
if (emptyStage) {
|
if (emptyStage) {
|
||||||
emptyStage.hidden = isOrganization;
|
const showEmpty = !isOrganization && !isSeatMap;
|
||||||
emptyStage.style.display = isOrganization ? "none" : "flex";
|
emptyStage.hidden = !showEmpty;
|
||||||
|
emptyStage.style.display = showEmpty ? "flex" : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOrganization && previousView !== "organization" && organizationFrame) {
|
if (isOrganization && previousView !== "organization" && organizationFrame) {
|
||||||
const frameSrc = organizationFrame.dataset.src || organizationFrame.src;
|
const frameSrc = organizationFrame.dataset.src || organizationFrame.src;
|
||||||
organizationFrame.src = frameSrc;
|
organizationFrame.src = frameSrc;
|
||||||
}
|
}
|
||||||
|
if (isSeatMap) {
|
||||||
|
loadSeatMapData();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAuth() {
|
function renderAuth() {
|
||||||
@@ -81,29 +680,31 @@ function renderAuth() {
|
|||||||
if (authenticated) {
|
if (authenticated) {
|
||||||
const displayName = session.user.display_name || "접속자";
|
const displayName = session.user.display_name || "접속자";
|
||||||
const rank = "-";
|
const rank = "-";
|
||||||
userBadge.innerHTML = `<span class="user-chip-icon">◎</span><span class="user-chip-text"><strong>${displayName}</strong><em>${rank}</em></span><span class="user-chip-caret" aria-hidden="true">▾</span>`;
|
const employeeId = session.user.username || "-";
|
||||||
|
userBadge.innerHTML = `<span class="user-chip-icon">◎</span><span class="user-chip-text"><strong>${escapeHtml(displayName)}</strong><em>${escapeHtml(rank)}</em></span><span class="user-chip-caret" aria-hidden="true">▾</span>`;
|
||||||
userBadge.title = `${displayName} / -`;
|
userBadge.title = `${displayName} / -`;
|
||||||
if (userPopover) {
|
if (userPopover) {
|
||||||
userPopover.innerHTML = `
|
userPopover.innerHTML = `
|
||||||
<div class="user-popover-row">
|
<div class="user-popover-row">
|
||||||
<span class="user-popover-label">이름</span>
|
<span class="user-popover-label">이름</span>
|
||||||
<strong>${displayName}</strong>
|
<strong>${escapeHtml(displayName)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-popover-row">
|
<div class="user-popover-row">
|
||||||
<span class="user-popover-label">직급</span>
|
<span class="user-popover-label">직급</span>
|
||||||
<span>${rank}</span>
|
<span>${escapeHtml(rank)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-popover-row">
|
<div class="user-popover-row">
|
||||||
<span class="user-popover-label">권한</span>
|
<span class="user-popover-label">권한</span>
|
||||||
<span>${session.user.role || "-"}</span>
|
<span>${escapeHtml(session.user.role || "-")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-popover-row">
|
<div class="user-popover-row">
|
||||||
<span class="user-popover-label">아이디</span>
|
<span class="user-popover-label">사번</span>
|
||||||
<span>${session.user.username || "-"}</span>
|
<span>${escapeHtml(employeeId)}</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
renderSeatMap();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loginForm) {
|
if (loginForm) {
|
||||||
@@ -112,17 +713,19 @@ if (loginForm) {
|
|||||||
loginMessage.textContent = "로그인 처리 중입니다.";
|
loginMessage.textContent = "로그인 처리 중입니다.";
|
||||||
const formData = new FormData(loginForm);
|
const formData = new FormData(loginForm);
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/mock-login", {
|
const payload = await fetchJson("/api/mock-login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
const payload = await response.json();
|
|
||||||
if (!response.ok) throw new Error(payload.detail || "login failed");
|
|
||||||
setSession(payload);
|
setSession(payload);
|
||||||
loginForm.reset();
|
loginForm.reset();
|
||||||
|
loginMessage.textContent = "";
|
||||||
renderAuth();
|
renderAuth();
|
||||||
|
if (currentView === "seatmap") {
|
||||||
|
await loadSeatMapData(true);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
loginMessage.textContent = "로그인에 실패했습니다. backend 연결 상태를 확인해주세요.";
|
loginMessage.textContent = error.message || "로그인에 실패했습니다.";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -150,9 +753,108 @@ navButtons.forEach((button) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (seatMapSettingsForm) {
|
||||||
|
seatMapSettingsForm.addEventListener("submit", submitSeatMapSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seatMapSaveBtn) {
|
||||||
|
seatMapSaveBtn.addEventListener("click", saveSeatLayout);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seatMapCancelBtn) {
|
||||||
|
seatMapCancelBtn.addEventListener("click", cancelSeatMapEdit);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seatMapSearch) {
|
||||||
|
seatMapSearch.addEventListener("input", () => {
|
||||||
|
seatMapState.search = seatMapSearch.value || "";
|
||||||
|
renderSeatMap();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seatMapFormImage) {
|
||||||
|
seatMapFormImage.addEventListener("change", () => {
|
||||||
|
if (seatMapFileName) {
|
||||||
|
seatMapFileName.textContent = seatMapFormImage.files?.[0]?.name || "선택된 파일 없음";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seatMapBoard) {
|
||||||
|
seatMapBoard.addEventListener("wheel", (event) => {
|
||||||
|
if (seatMapState.seatMap?.source_type !== "dxf") return;
|
||||||
|
event.preventDefault();
|
||||||
|
const delta = event.deltaY < 0 ? 0.1 : -0.1;
|
||||||
|
setSeatMapZoom(seatMapState.zoom + delta);
|
||||||
|
}, { passive: false });
|
||||||
|
seatMapBoard.addEventListener("dragover", (event) => {
|
||||||
|
if (!seatMapState.editMode) return;
|
||||||
|
const target = seatMapState.seatMap?.source_type === "dxf"
|
||||||
|
? event.target.closest(".seatmap-slot")
|
||||||
|
: event.target.closest(".seatmap-cell");
|
||||||
|
if (!target) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = "move";
|
||||||
|
});
|
||||||
|
seatMapBoard.addEventListener("drop", handleSeatMapCellDrop);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seatMapBoardWrap) {
|
||||||
|
seatMapBoardWrap.addEventListener("mousedown", (event) => {
|
||||||
|
if (seatMapState.seatMap?.source_type !== "dxf") return;
|
||||||
|
if (event.button !== 1) return;
|
||||||
|
event.preventDefault();
|
||||||
|
seatMapState.panning = true;
|
||||||
|
seatMapState.panStartX = event.clientX;
|
||||||
|
seatMapState.panStartY = event.clientY;
|
||||||
|
seatMapState.panScrollLeft = seatMapBoardWrap.scrollLeft;
|
||||||
|
seatMapState.panScrollTop = seatMapBoardWrap.scrollTop;
|
||||||
|
seatMapBoardWrap.classList.add("is-panning");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", (event) => {
|
||||||
|
if (!seatMapState.panning || !seatMapBoardWrap) return;
|
||||||
|
const deltaX = event.clientX - seatMapState.panStartX;
|
||||||
|
const deltaY = event.clientY - seatMapState.panStartY;
|
||||||
|
seatMapBoardWrap.scrollLeft = seatMapState.panScrollLeft - deltaX;
|
||||||
|
seatMapBoardWrap.scrollTop = seatMapState.panScrollTop - deltaY;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("mouseup", () => {
|
||||||
|
if (!seatMapState.panning || !seatMapBoardWrap) return;
|
||||||
|
seatMapState.panning = false;
|
||||||
|
seatMapBoardWrap.classList.remove("is-panning");
|
||||||
|
});
|
||||||
|
|
||||||
|
if (seatMapUnassigned) {
|
||||||
|
seatMapUnassigned.addEventListener("dragover", (event) => {
|
||||||
|
if (!seatMapState.editMode) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = "move";
|
||||||
|
});
|
||||||
|
seatMapUnassigned.addEventListener("drop", handleSeatMapListDrop);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("dragstart", (event) => {
|
||||||
|
const card = event.target.closest(".seatmap-member-card");
|
||||||
|
if (!seatMapState.editMode || !card) return;
|
||||||
|
const memberId = Number(card.dataset.memberId);
|
||||||
|
if (!memberId) return;
|
||||||
|
seatMapState.draggingMemberId = memberId;
|
||||||
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
event.dataTransfer.setData("text/plain", String(memberId));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("dragend", () => {
|
||||||
|
seatMapState.draggingMemberId = null;
|
||||||
|
});
|
||||||
|
|
||||||
document.addEventListener("click", () => {
|
document.addEventListener("click", () => {
|
||||||
hideUserPopover();
|
hideUserPopover();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.addEventListener("message", handleEmbeddedNavigationMessage);
|
||||||
|
|
||||||
setActiveView(currentView);
|
setActiveView(currentView);
|
||||||
renderAuth();
|
renderAuth();
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<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">
|
||||||
<link rel="stylesheet" href="/legacy/static/common.css">
|
<link rel="stylesheet" href="/legacy/static/common.css">
|
||||||
<link rel="stylesheet" href="/styles.css">
|
<link rel="stylesheet" href="/styles.css?v=20260325-11">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<section id="login-panel" class="login-screen">
|
<section id="login-panel" class="login-screen">
|
||||||
@@ -53,12 +53,10 @@
|
|||||||
<button id="user-badge" class="ghost-button ghost-button-soft user-chip" type="button"></button>
|
<button id="user-badge" class="ghost-button ghost-button-soft user-chip" type="button"></button>
|
||||||
<div id="user-popover" class="user-popover hidden"></div>
|
<div id="user-popover" class="user-popover hidden"></div>
|
||||||
<button id="logout-btn" class="ghost-button icon-button" type="button" title="로그아웃" aria-label="로그아웃">
|
<button id="logout-btn" class="ghost-button icon-button" type="button" title="로그아웃" aria-label="로그아웃">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M15 3h-4a2 2 0 0 0-2 2v3" />
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
||||||
<path d="M10 17v2a2 2 0 0 0 2 2h3" />
|
<polyline points="16 17 21 12 16 7"></polyline>
|
||||||
<path d="M21 12H9" />
|
<line x1="21" y1="12" x2="9" y2="12"></line>
|
||||||
<path d="m16 7 5 5-5 5" />
|
|
||||||
<path d="M3 5h8v14H3z" />
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,7 +65,68 @@
|
|||||||
<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=20260325-2" data-src="/legacy/organization?v=20260325-2" title="조직도 메인 화면"></iframe>
|
<iframe id="organization-frame" src="/legacy/organization?v=20260325-11" data-src="/legacy/organization?v=20260325-11" title="조직도 메인 화면"></iframe>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section id="seatmap-stage" class="main-stage" hidden>
|
||||||
|
<div class="seatmap-layout">
|
||||||
|
<div class="seatmap-topbar">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Seat Layout</p>
|
||||||
|
<h3 id="seatmap-name">자리배치도</h3>
|
||||||
|
</div>
|
||||||
|
<div class="seatmap-actions">
|
||||||
|
<button id="seatmap-save-btn" class="ghost-button" type="button" hidden disabled>저장</button>
|
||||||
|
<button id="seatmap-cancel-btn" class="ghost-button ghost-button-soft" type="button" hidden>취소</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="seatmap-status" class="seatmap-status" role="status"></p>
|
||||||
|
|
||||||
|
<div class="seatmap-content">
|
||||||
|
<div class="seatmap-board-panel">
|
||||||
|
<div id="seatmap-empty" class="seatmap-empty hidden"></div>
|
||||||
|
<div id="seatmap-board-wrap" class="seatmap-board-wrap hidden">
|
||||||
|
<div id="seatmap-board" class="seatmap-board"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="seatmap-sidebar">
|
||||||
|
<section id="seatmap-settings-panel" class="seatmap-panel hidden">
|
||||||
|
<div class="seatmap-panel-head">
|
||||||
|
<h4>배치도 설정</h4>
|
||||||
|
<p>DXF 파일의 chair 레이어를 좌석 위치로 사용합니다.</p>
|
||||||
|
</div>
|
||||||
|
<form id="seatmap-settings-form" class="seatmap-form">
|
||||||
|
<label>
|
||||||
|
<span>배치도 이름</span>
|
||||||
|
<input id="seatmap-form-name" name="name" type="text" placeholder="예: 본사 3층" required>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<span>DXF 파일</span>
|
||||||
|
<label class="seatmap-file-input" for="seatmap-form-image">
|
||||||
|
<input id="seatmap-form-image" name="image" type="file" accept=".dxf" required>
|
||||||
|
<span class="seatmap-file-button">DXF 선택</span>
|
||||||
|
<strong id="seatmap-file-name" class="seatmap-file-name">선택된 파일 없음</strong>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button id="seatmap-settings-submit" type="submit">DXF 업로드</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="seatmap-panel">
|
||||||
|
<div class="seatmap-panel-head">
|
||||||
|
<h4>미배치 인원</h4>
|
||||||
|
<p>이름을 검색하고 자리배치도에 바로 드래그하세요.</p>
|
||||||
|
</div>
|
||||||
|
<label class="seatmap-search">
|
||||||
|
<span class="hidden">구성원 검색</span>
|
||||||
|
<input id="seatmap-search" type="search" placeholder="이름 또는 부서 검색">
|
||||||
|
</label>
|
||||||
|
<div id="seatmap-unassigned" class="seatmap-member-list"></div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section id="empty-stage" class="main-stage" hidden>
|
<section id="empty-stage" class="main-stage" hidden>
|
||||||
@@ -76,6 +135,6 @@
|
|||||||
</main>
|
</main>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js?v=20260325-11"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -182,12 +182,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-center {
|
.header-center {
|
||||||
position: absolute;
|
margin-left: auto;
|
||||||
left: 50%;
|
margin-right: 48px;
|
||||||
transform: translateX(-50%);
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 24px;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
@@ -197,33 +196,32 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 34px;
|
min-height: 48px;
|
||||||
padding: 0 14px;
|
padding: 0 4px;
|
||||||
border-radius: 999px;
|
border-radius: 0;
|
||||||
border: 1px solid #dbe2ea;
|
border: none;
|
||||||
background: #f8fafc;
|
border-bottom: 3px solid transparent;
|
||||||
color: var(--color-text-muted);
|
background: transparent;
|
||||||
font-size: 12px;
|
color: #64748b;
|
||||||
font-weight: 800;
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-pill.active {
|
.nav-pill.active {
|
||||||
background: var(--color-accent);
|
background: transparent;
|
||||||
border-color: transparent;
|
border-bottom-color: var(--color-accent);
|
||||||
color: #fff;
|
color: var(--color-text);
|
||||||
box-shadow: 0 10px 20px rgba(79, 70, 229, 0.2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-pill.muted {
|
.nav-pill.muted {
|
||||||
color: #64748b;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-pill:hover {
|
.nav-pill:hover {
|
||||||
transform: translateY(-1px);
|
transform: none;
|
||||||
border-color: #c7d2fe;
|
color: var(--color-accent);
|
||||||
box-shadow: 0 8px 18px rgba(148, 163, 184, 0.16);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
@@ -242,6 +240,34 @@
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button:hover {
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
color: var(--color-accent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
fill: none;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ghost-button-soft {
|
.ghost-button-soft {
|
||||||
@@ -291,26 +317,16 @@
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-button {
|
.user-chip-caret {
|
||||||
width: 34px;
|
color: var(--color-text-muted);
|
||||||
padding: 0;
|
font-size: 10px;
|
||||||
justify-content: center;
|
line-height: 1;
|
||||||
}
|
|
||||||
|
|
||||||
.icon-button svg {
|
|
||||||
width: 15px;
|
|
||||||
height: 15px;
|
|
||||||
stroke: currentColor;
|
|
||||||
stroke-width: 1.9;
|
|
||||||
fill: none;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-popover {
|
.user-popover {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 10px);
|
top: calc(100% + 10px);
|
||||||
right: 42px;
|
right: 0;
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
border: 1px solid #dbe2ea;
|
border: 1px solid #dbe2ea;
|
||||||
@@ -340,6 +356,19 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-popover-action {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 10px;
|
||||||
|
min-height: 38px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-main {
|
.dashboard-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: calc(100vh - 68px);
|
min-height: calc(100vh - 68px);
|
||||||
@@ -371,6 +400,514 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.seatmap-layout {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 18px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(248, 250, 252, 0.94), rgba(241, 245, 249, 0.92)),
|
||||||
|
radial-gradient(circle at top left, rgba(14, 165, 233, 0.1), transparent 32%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-topbar h3,
|
||||||
|
.seatmap-panel-head h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-topbar .eyebrow {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-status {
|
||||||
|
min-height: 20px;
|
||||||
|
margin: 0;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-status[data-tone="error"] {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-status[data-tone="success"] {
|
||||||
|
color: #047857;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 320px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-board-panel,
|
||||||
|
.seatmap-panel {
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
border-radius: 24px;
|
||||||
|
background: rgba(255, 255, 255, 0.88);
|
||||||
|
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-board-panel {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-board-wrap {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 0;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-board-wrap.is-panning {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-board {
|
||||||
|
min-width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-dxf-canvas {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: none;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-dxf-stage {
|
||||||
|
position: relative;
|
||||||
|
transform-origin: center center;
|
||||||
|
transition: transform 0.12s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-dxf-preview {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
line-height: 0;
|
||||||
|
filter: contrast(1.9) saturate(1.1) brightness(0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-preview-svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-preview-svg .seatmap-dxf-entity {
|
||||||
|
stroke: #000 !important;
|
||||||
|
stroke-opacity: 1 !important;
|
||||||
|
stroke-width: 12 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-preview-svg .seatmap-dxf-chair-entity {
|
||||||
|
stroke: #2563eb !important;
|
||||||
|
stroke-opacity: 1 !important;
|
||||||
|
stroke-width: 6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-preview-svg rect {
|
||||||
|
fill: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-dxf-slots {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-slot {
|
||||||
|
position: absolute;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 30px;
|
||||||
|
min-height: 30px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
pointer-events: auto;
|
||||||
|
transition: box-shadow 0.18s ease, background 0.18s ease, transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-slot.editable:hover {
|
||||||
|
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.4);
|
||||||
|
background: rgba(37, 99, 235, 0.12);
|
||||||
|
transform: translate(-50%, -50%) scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-slot.occupied {
|
||||||
|
width: 34px;
|
||||||
|
min-height: 34px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-slot.empty {
|
||||||
|
opacity: 0.14;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-canvas {
|
||||||
|
position: relative;
|
||||||
|
width: min(100%, 1240px);
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 18px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-image {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-grid {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--seatmap-cols), 1fr);
|
||||||
|
grid-template-rows: repeat(var(--seatmap-rows), 1fr);
|
||||||
|
gap: var(--seatmap-gap);
|
||||||
|
padding: var(--seatmap-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-cell {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
border: 1px dashed rgba(15, 23, 42, 0.14);
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
transition: border-color 0.18s ease, background 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-cell.editable:hover {
|
||||||
|
border-color: rgba(14, 165, 233, 0.62);
|
||||||
|
background: rgba(14, 165, 233, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-cell.occupied {
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-cell-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
left: 4px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(15, 23, 42, 0.72);
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-member-card {
|
||||||
|
position: absolute;
|
||||||
|
inset: 22px 6px 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(15, 23, 42, 0.8);
|
||||||
|
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.18);
|
||||||
|
color: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-member-card.draggable {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-member-avatar {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-member-avatar img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-member-avatar-fallback {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-member-text {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-member-text strong,
|
||||||
|
.seatmap-member-text em {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-member-text strong {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-member-text em {
|
||||||
|
color: rgba(226, 232, 240, 0.84);
|
||||||
|
font-size: 10px;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-sidebar {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-panel {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-sidebar .seatmap-panel:last-child {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-panel-head p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-form,
|
||||||
|
.seatmap-form-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-form label,
|
||||||
|
.seatmap-form > div,
|
||||||
|
.seatmap-search {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-form span {
|
||||||
|
color: #475569;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-form input,
|
||||||
|
.seatmap-search input,
|
||||||
|
.seatmap-form button {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-height: 38px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-form input,
|
||||||
|
.seatmap-search input {
|
||||||
|
border: 1px solid #d7dee8;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: #fff;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-file-input {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #d7dee8;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(180deg, #fff, #f8fafc);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-file-input input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-file-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-file-name {
|
||||||
|
min-width: 0;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-form button {
|
||||||
|
border: 0;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-member-list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-member-list .seatmap-member-card {
|
||||||
|
position: relative;
|
||||||
|
inset: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-member-card-compact {
|
||||||
|
position: relative;
|
||||||
|
inset: auto;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #3f4658;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-member-text-inline {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-member-text-inline strong {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-member-text-inline em {
|
||||||
|
display: inline;
|
||||||
|
color: rgba(226, 232, 240, 0.8);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-slot .seatmap-member-card {
|
||||||
|
inset: auto;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
min-width: 48px;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 3px 4px;
|
||||||
|
gap: 4px;
|
||||||
|
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-slot .seatmap-member-avatar {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-slot .seatmap-member-text strong {
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-slot .seatmap-member-text em {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-dxf-stage {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-list-empty,
|
||||||
|
.seatmap-empty-card {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 120px;
|
||||||
|
border: 1px dashed rgba(148, 163, 184, 0.4);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(248, 250, 252, 0.8);
|
||||||
|
color: #64748b;
|
||||||
|
text-align: center;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-empty-card strong {
|
||||||
|
display: block;
|
||||||
|
color: #0f172a;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
.dashboard-header {
|
.dashboard-header {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -390,6 +927,14 @@
|
|||||||
.header-actions {
|
.header-actions {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.seatmap-content {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-sidebar {
|
||||||
|
order: -1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
@@ -414,6 +959,15 @@
|
|||||||
.login-form-wrap {
|
.login-form-wrap {
|
||||||
padding: 28px;
|
padding: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.seatmap-layout {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-topbar {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
@@ -424,4 +978,28 @@
|
|||||||
.main-stage {
|
.main-stage {
|
||||||
height: calc(100vh - 68px);
|
height: calc(100vh - 68px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.seatmap-board {
|
||||||
|
min-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-member-card {
|
||||||
|
inset: 20px 4px 4px;
|
||||||
|
padding: 6px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-member-avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-member-text strong {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-member-text em {
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -571,17 +571,17 @@ body {
|
|||||||
|
|
||||||
.search-section {
|
.search-section {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 18px;
|
top: 14px;
|
||||||
left: 25px;
|
left: 18px;
|
||||||
background: var(--color-surface-soft);
|
background: var(--color-surface-soft);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
padding: 10px 18px;
|
padding: 8px 12px;
|
||||||
box-shadow: var(--shadow-soft);
|
box-shadow: var(--shadow-soft);
|
||||||
border: 1px solid var(--color-border-soft);
|
border: 1px solid var(--color-border-soft);
|
||||||
z-index: 1010;
|
z-index: 1010;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
}
|
}
|
||||||
@@ -590,26 +590,27 @@ body {
|
|||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
width: 180px;
|
width: 150px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-icon {
|
.search-icon {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
transform: scale(0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-section {
|
.stats-section {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 18px;
|
top: 14px;
|
||||||
right: 25px;
|
right: 18px;
|
||||||
width: 400px;
|
width: 332px;
|
||||||
background: var(--color-surface-soft);
|
background: var(--color-surface-soft);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
padding: 15px;
|
padding: 10px 12px;
|
||||||
box-shadow: var(--shadow-soft);
|
box-shadow: var(--shadow-soft);
|
||||||
border: 1px solid var(--color-border-soft);
|
border: 1px solid var(--color-border-soft);
|
||||||
z-index: 1010;
|
z-index: 1010;
|
||||||
@@ -617,10 +618,15 @@ body {
|
|||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stats-title {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
.stats-table {
|
.stats-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-style: hidden;
|
border-style: hidden;
|
||||||
@@ -631,13 +637,13 @@ body {
|
|||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
padding: 8px 4px;
|
padding: 6px 4px;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-table td {
|
.stats-table td {
|
||||||
padding: 8px 4px;
|
padding: 6px 4px;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -648,7 +654,38 @@ body {
|
|||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
color: var(--color-text-soft);
|
color: var(--color-text-soft);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
width: 80px;
|
width: 92px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-company-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-company-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-block;
|
||||||
|
background: #94a3b8;
|
||||||
|
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-company-dot.co-삼안 {
|
||||||
|
background: #ffb366;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-company-dot.co-한맥 {
|
||||||
|
background: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-company-dot.co-피티씨 {
|
||||||
|
background: #a855f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-company-dot.co-바론 {
|
||||||
|
background: #3b82f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-table .total-cell {
|
.stats-table .total-cell {
|
||||||
@@ -735,8 +772,8 @@ body {
|
|||||||
.dept-tabs-container {
|
.dept-tabs-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 15px;
|
margin-top: 10px;
|
||||||
padding: 5px 0;
|
padding: 2px 0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
@@ -746,11 +783,11 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dept-tab {
|
.dept-tab {
|
||||||
padding: 6px 14px;
|
padding: 5px 11px;
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ function toLegacyMember(item) {
|
|||||||
_id: String(item.id),
|
_id: String(item.id),
|
||||||
id: item.id,
|
id: item.id,
|
||||||
이름: item.name || '',
|
이름: item.name || '',
|
||||||
|
사번: item.employee_id || '',
|
||||||
소속회사: item.company || '',
|
소속회사: item.company || '',
|
||||||
직급: item.rank || '',
|
직급: item.rank || '',
|
||||||
직책: item.role || '',
|
직책: item.role || '',
|
||||||
@@ -57,6 +58,7 @@ function toLegacyMember(item) {
|
|||||||
function toApiMember(member, sortOrder) {
|
function toApiMember(member, sortOrder) {
|
||||||
return {
|
return {
|
||||||
name: member['이름'] || '',
|
name: member['이름'] || '',
|
||||||
|
employee_id: member['사번'] || '',
|
||||||
company: member['소속회사'] || '',
|
company: member['소속회사'] || '',
|
||||||
rank: member['직급'] || '',
|
rank: member['직급'] || '',
|
||||||
role: member['직책'] || '',
|
role: member['직책'] || '',
|
||||||
@@ -381,6 +383,12 @@ function updateStatsTable() {
|
|||||||
|
|
||||||
const columns = Object.keys(rankGroups);
|
const columns = Object.keys(rankGroups);
|
||||||
const stats = {};
|
const stats = {};
|
||||||
|
const companyLabelHtml = (company) => `
|
||||||
|
<span class="stats-company-label">
|
||||||
|
<span class="stats-company-dot co-${company}"></span>
|
||||||
|
<span>${company}</span>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
|
||||||
companies.forEach((company) => {
|
companies.forEach((company) => {
|
||||||
stats[company] = {};
|
stats[company] = {};
|
||||||
@@ -418,7 +426,7 @@ function updateStatsTable() {
|
|||||||
let grandTotal = 0;
|
let grandTotal = 0;
|
||||||
|
|
||||||
companies.forEach((company) => {
|
companies.forEach((company) => {
|
||||||
html += `<tr><td class="row-label">${company}</td>${columns.map((column) => {
|
html += `<tr><td class="row-label">${companyLabelHtml(company)}</td>${columns.map((column) => {
|
||||||
colSums[column] += stats[company][column];
|
colSums[column] += stats[company][column];
|
||||||
return `<td>${stats[company][column] || '-'}</td>`;
|
return `<td>${stats[company][column] || '-'}</td>`;
|
||||||
}).join('')}<td class="total-cell">${stats[company]._total}</td></tr>`;
|
}).join('')}<td class="total-cell">${stats[company]._total}</td></tr>`;
|
||||||
@@ -515,6 +523,7 @@ function updateFabMenu() {
|
|||||||
let html = '<button class="fab-sub shadow-xl" data-label="리스트" onclick="openListViewModal(event)">📋</button>';
|
let html = '<button class="fab-sub shadow-xl" data-label="리스트" onclick="openListViewModal(event)">📋</button>';
|
||||||
html += '<button class="fab-sub shadow-xl" data-label="조직도 인쇄(A3)" onclick="printA3()">🖨️</button>';
|
html += '<button class="fab-sub shadow-xl" data-label="조직도 인쇄(A3)" onclick="printA3()">🖨️</button>';
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
|
html += '<button class="fab-sub shadow-xl" data-label="자리배치도" onclick="openSeatMapView(event)">🪑</button>';
|
||||||
html += '<button class="fab-sub shadow-xl" data-label="조직현황 업로드" onclick="triggerUpload(event)">⬆️</button>';
|
html += '<button class="fab-sub shadow-xl" data-label="조직현황 업로드" onclick="triggerUpload(event)">⬆️</button>';
|
||||||
html += '<button class="fab-sub shadow-xl" data-label="신규 구성원" onclick="openAddModal(event)">👤</button>';
|
html += '<button class="fab-sub shadow-xl" data-label="신규 구성원" onclick="openAddModal(event)">👤</button>';
|
||||||
html += '<button class="fab-sub shadow-xl" data-label="신규 팀/그룹/셀" onclick="openUnitAddModal(event)">🏢</button>';
|
html += '<button class="fab-sub shadow-xl" data-label="신규 팀/그룹/셀" onclick="openUnitAddModal(event)">🏢</button>';
|
||||||
@@ -522,6 +531,14 @@ function updateFabMenu() {
|
|||||||
menu.innerHTML = html;
|
menu.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openSeatMapView(event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
document.getElementById('fab-container').classList.remove('active');
|
||||||
|
if (window.parent && window.parent !== window) {
|
||||||
|
window.parent.postMessage({ type: 'open-seatmap' }, '*');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function triggerUpload(event) {
|
function triggerUpload(event) {
|
||||||
if (event) {
|
if (event) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -872,6 +889,7 @@ function openModal(id) {
|
|||||||
<div id="modal-sec-basic" class="grid grid-cols-2 gap-4">
|
<div id="modal-sec-basic" class="grid grid-cols-2 gap-4">
|
||||||
<input type="hidden" id="m-id" value="${id || ''}">
|
<input type="hidden" id="m-id" value="${id || ''}">
|
||||||
<div class="col-span-2"><label class="text-[11px] font-black text-slate-600 block">이름 (필수)</label><input id="m-name" value="${member['이름'] || ''}" class="w-full bg-slate-50 p-3 rounded-xl border font-bold text-sm outline-none"></div>
|
<div class="col-span-2"><label class="text-[11px] font-black text-slate-600 block">이름 (필수)</label><input id="m-name" value="${member['이름'] || ''}" class="w-full bg-slate-50 p-3 rounded-xl border font-bold text-sm outline-none"></div>
|
||||||
|
<div class="col-span-2"><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="col-span-1"><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="col-span-1"><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="col-span-1"><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 class="col-span-1"><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 class="col-span-2"><label class="text-[11px] font-black text-slate-600 block">자리 위치</label><input id="m-seat" value="${member['자리위치'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none"></div>
|
<div class="col-span-2"><label class="text-[11px] font-black text-slate-600 block">자리 위치</label><input id="m-seat" value="${member['자리위치'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none"></div>
|
||||||
@@ -912,6 +930,7 @@ async function saveMember() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
member['이름'] = name;
|
member['이름'] = name;
|
||||||
|
member['사번'] = document.getElementById('m-employee-id').value.trim();
|
||||||
dropdownFields.forEach((field) => {
|
dropdownFields.forEach((field) => {
|
||||||
const selectValue = document.getElementById(`sel-${field}`).value;
|
const selectValue = document.getElementById(`sel-${field}`).value;
|
||||||
if (selectValue === '__NEW__') {
|
if (selectValue === '__NEW__') {
|
||||||
|
|||||||
Binary file not shown.
BIN
organization1.xlsx
Normal file
BIN
organization1.xlsx
Normal file
Binary file not shown.
@@ -18,13 +18,6 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /snapshots/ {
|
|
||||||
proxy_pass http://backend:8000;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /legacy/ {
|
location /legacy/ {
|
||||||
proxy_pass http://backend:8000;
|
proxy_pass http://backend:8000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -39,4 +32,3 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user