76 Commits
Author SHA1 Message Date
eomsangdonandClaude Opus 5.5 022eff69f5 refactor(M01): 표 · 모달 · 띠 알림 · 배지 · 알약 · 보조 글 · 빈 상태를 부품으로 (PLAN 553-5)
- 표 둘 = 표 부품(m01-master__grid = 일반표 · m01-logic__grid = 데이터표) · 페이지 CSS 는 폭 · 머리 고정 · 칸 상태만
- 손 모달 다섯(조달 찾기 · 요소 고르기 · 표 바꾸기 · 복사 시트 · 표 고르기) = 모달 부품 openModal
- 알림 띠 · 막힘 까닭 = 띠 알림 · 태그 = 배지 · 알약 · × · 삭제 · 로직 키 = 단추 부품 · muted 세 벌 = 보조 글 · 빈 줄 = 빈 상태
- M02 가 빌린 M01 클래스(표 · muted · 고르기 창)도 같은 부품으로 · 죽은 CSS 걷음 · 지킴 시험 test_553_5_m01_m02

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:57:33 +09:00
eomsangdon 6d8b38b518 Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_3' and 'origin/sub_laptop_5' into main_laptop_1 2026-10-26 21:58:45 +09:00
eomsangdonandClaude Opus 5.5 6008ba654e merge(sub_laptop_6): 552-3 묶음 부품 15 — 부품 모음 목록 import 충돌 양쪽 살림(552-2 기본 import + 552-3 부품 파일 import)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:58:15 +09:00
eomsangdon f54eed25bc refactor(D03·D04): 진행 막대 · 고르는 카드 · 입력 · 고르기 · 체크 · 띠 알림을 부품으로 (PLAN 553-4) 2026-10-26 21:57:01 +09:00
eomsangdonandClaude Sonnet 5.5 25adc2f0e7 refactor(공개화면): A01~A09 · B10 · B11 을 공개 화면 틀 · 카드 · 알림 · 배지 · 체크 · 접이 부품으로 (PLAN 553-7)
- A01~A05 hero 다섯 벌 · 구역 제목 복제 CSS 걷고 공개 화면 틀 한 벌(wide 옵션 · 구역 제목 가운데 · 머리 아랫줄은 자리 안 먹는 그림자)
- 카드 = 카드 부품(로그인 · 가입 · 지원 · 약관 · 기능 카드 · 소식 줄 · B11 단계 카드) · 지원 성공 · B10 안내 = 띠 알림 · 태그 = 배지
- A07 약관 날 체크 · 날 details = 체크 + 접이 부품 · A08 날 textarea = 입력 여러 줄 · 빈 소식 = 빈 상태
- 죽은 A07_Register_UI_Page · 쓰이지 않는 CSS 걷음 · 띠 알림 hidden 받음
- 지킴 시험 test_553_7_public_pages

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 21:56:42 +09:00
eomsangdonandClaude Opus 5.5 47b396b53d feat(ui-kit): 묶음 부품 다섯 — 표 · ⋯ 메뉴 · 모달 · 확인창 · 설정 줄 · 파일 놓기 칸 (PLAN 552-3)
- 새 부품 파일: ui_template_table · setting_row · drop · createRowMenu(context_menu) · 모달 폭 단 · askConfirm · askText(window.prompt 대신) · confirmPanel
- 단추 = 552-2 단추 부품(⋯ 아이콘 단추 28 · 띠 알림 닫기 20 · 놓기 칸 [파일 고르기] 28)
- 대시보드 PageKit 의 dataTable · tableShell · tableRow · rowMenu · settingRow · settingCard · valueText = 부품을 다시 내보냄(한 벌 · 부름 꼴 그대로) · B01 옛 CSS 걷음
- showConfirmDialog 는 모달 부품으로 옮기고 본체가 다시 내보냄 · 모달 가림막 옛 보라 → --color-scrim
- 시험: test_552_3_* 다섯 더함 · 옮긴 함수 자리를 읽던 test_290_1 · test_505_2 고침 · 부품 CSS import 를 못 읽던 시험 도우미 넷(130 · 153 · 280_5 · 311)이 .css 를 건너뜀
- 전후 촬영: B01 여덟 화면 자리 같음 · ⋯ 아이콘만 1 px 왼쪽(단추 부품 가운데 맞춤)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:55:11 +09:00
eomsangdonandClaude Sonnet 5.5 a826804277 refactor(B02·B03): 페이지 자체 변종을 부품 상자 부품으로 — 파일 카드 · 띠 알림 · 진행 막대 · 켜기 · 패널 머리 · 단추 (PLAN 553-1)
- B03 파일 카드 = 카드 부품 · [선택] · [×] = 단추 · 아이콘 단추 · 진행 막대 · 오류 띠 = 부품
- B03 현황 · 이어올리기 · 예정노선 바뀜 = 띠 알림 · LAS 없이 설계 = 켜기 · 노선 · 지형 제목 = 패널 머리
- B03 · B02 죽은 CSS 걷음 · 날 색 → 공통 값 · 가이드 촬영 선택자 · 지킴 시험 test_553_1_pages

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 21:53:32 +09:00
eomsangdon 065ca8b983 refactor(D01·D02): 날 입력 · 안내 글을 부품으로 — 측점 표기 · 연도 지점 칸 · 안내 줄 (PLAN 553-4) 2026-10-26 21:51:18 +09:00
eomsangdonandClaude Opus 5.5 9729892a3d merge(sub_laptop_2): 552-2 기본 부품 13 — 부품 모음 목록 충돌 양쪽 살림(552-2 견본 + 552-3 · 552-4 묶음)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:41:46 +09:00
eomsangdonandClaude Opus 5.5 2c51bb53a6 feat(ui-kit): 기본 부품 입력 · 고르기 · 슬라이더 · 체크 · 켜기 · 분할 · 탭 · 배지 · 툴팁 · 보조 글 (PLAN 552-2)
- ui_template_input.ts · .css 새로 — createInputField · createSelectField 를 옮김(ui_template_elements 가 다시 내보냄 · 부르는 길 그대로) · size(data-size 단 높이) · unit(칸 안 단위) · multiline · createSlider(이름 | 막대 | 값 글 3열)
- 옛 compact 는 단을 안 붙임 — 옛 작은 칸 30 그대로(32 로 맞추면 페이지 칸이 바뀜 · 553 에서 단으로 옮김)
- 입력 · 고르기 상태 = 마우스 선 짙게 · 막힘 흐림 · 오류 빨강(aria-invalid 같이) · 고르기 화살표 = 보라 → 보조 글 색(밝음 · 어두움 따로)
- 체크 = 라디오 · 칩 꼴 더함(createCheckbox 부름 그대로) · 켜기(스위치 · 색 점 칩) · 분할 고르기(대시보드 .b01-sys-seg 꼴) · 탭(밑줄 · 보조 알약 = 옛 .ui-subtabs · 수 배지)
- 배지(알약 · 수 · 램프 ✓ ✗ · 점 · 옛 .ui-tag 같이) · 툴팁 제자리 상자(tooltipBox) · 보조 글(흐린 · 안내 · 오류 ✗ · 경고 ! · 성공 ✓)
- 부품 모음 기본 묶음 13 줄 모두 새 부품 견본 · 상태 다섯 칸 · 여덟 단 한 줄
- 시험 test_552_2_parts 새로(머리 · 옛 이름 · 크기 단 · 상태 · 날 색 없음 · 부품 모음 13)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:39:26 +09:00
eomsangdon 66dcbdad17 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-26 21:34:56 +09:00
eomsangdonandClaude Opus 5.5 a3365946fd feat(ui-kit): 틀 부품 셋 — 도구줄 · 떠 있는 판 · 오른쪽 서랍 겉 (PLAN 552-4)
- 도구줄 createToolbar: data-size 24 · 28 · 32 · 나눔선 · 밀기 · 유리 꼴 · 단 없는 단추에 같은 단을 붙임(552-2 setUiSize)
- 떠 있는 판 createFloatPanel: 머리 32 · 닫기 = 아이콘 단추 24(x) · 끌기 머리 · 팝오버
- 오른쪽 서랍: 흰 판 + 1 px 선 · 자리 · 폭 · 열고 닫기 그대로 · 머리 규칙 8
- 부품 모음 틀 아홉 견본 다 참 · 지킴 시험 아홉 부품

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:33:32 +09:00
eomsangdon 8cf6a7b626 Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-10-26 21:30:25 +09:00
eomsangdonandClaude Opus 5.5 ed6554a977 merge(dev): 552-2 단추 · 552-4 틀 받음 — 부품 모음 목록 = 양쪽 살림(묶음 열다섯 · 틀 아홉) · emptyState = 빈 상태 부품 · 아이콘 = ui_template_icons
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:29:58 +09:00
eomsangdon c54afddfe8 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-26 21:29:09 +09:00
eomsangdon 894ea5059e Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-26 21:28:43 +09:00
eomsangdon 6271164dd5 Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-10-26 21:28:30 +09:00
eomsangdonandClaude Opus 5.5 7e584c1e7b feat(ui-kit): 묶음 부품 열 — 카드 · 접이 카드 · 빈 상태 · 알림 · 띠 알림 · 숫자 카드 · 값 목록 · 진행 막대 · 작은 그래프 · 범례 (PLAN 552-3)
- 새 부품 파일: ui_template_card · empty · banner · stat · kv · toast · flow_legend(.ts) · 진행 막대 · 막대 · 원 그래프 덧붙임
- createCard · showToast · 로딩 가림막은 부품 파일로 옮기고 본체(ui_template_elements)가 다시 내보냄 — 부름 꼴 그대로
- 대시보드 emptyState = 빈 상태 부품을 씀(한 벌) · 선 카드 그림자 0 · 가림막 옛 보라 → --color-scrim
- 부품 모음 화면 견본(밝음 · 어두움 · 상태) · 지킴 시험 test_552_3_* 열
- 전후 촬영: B01 여덟 화면 자리 · 화소 같음(실시간 CPU 값만 다름) · A01 · B08 카드 그림자만 다름

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:28:29 +09:00
eomsangdonandClaude Opus 5.5 f941ddc3f5 feat(ui-kit): 틀 부품 여섯 — 작업 화면 틀 겉 · 일반 화면 틀 · 공개 화면 틀 · 단계 줄 · 패널 머리 · 목록 줄 (PLAN 552-4)
- 작업 화면 틀(overlay): 왼쪽 패널 = 흰 판 + 1 px 선 · 그림자 없음 · 덮개 = --color-scrim · 손잡이 강조색 그대로 · 자리 · 폭 그대로
- 일반 화면 틀 createPageFrame(대시보드 세부 꼴) · 공개 화면 틀 createPublicFrame(A01 hero · section 꼴)
- 단계 줄 data-size 28 · 40 · 상태 = 공통 값 · 패널 머리 createPanelHead · 목록 줄 createListRow(옛 .ui-sidebar-row 겉 함께)
- 부품 모음: 떠 있는 판 · 서랍 · 도구줄 · 단계 줄 · 패널 머리 · 목록 줄 여섯 줄을 frame 묶음으로 옮김 · 견본 = ui_template_ui_kit_frames.ts
- 지킴 시험 test_552_4_frames.py · B02~B09 · D · E · M02 전후 자리 수치 같음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:28:17 +09:00
eomsangdonandClaude Opus 5.5 3ccb96fbdb feat(ui-kit): 부품 단추 · 아이콘 단추 · 아이콘 · 크기 여덟 단 (PLAN 552-2)
- ui_template_button.ts · .css 새로 — createButton 을 옮김(ui_template_elements 가 다시 내보냄 · 부르는 길 그대로) · createIconButton 새로(고스트 · 선 · 채움 · aria-pressed · 글자 기호 · title + aria-label)
- 크기 = data-size 18 · 20 · 22 · 24 · 28 · 32 · 36 · 42 · 단마다 높이 · 글자 · 여백 · 아이콘 · 둥글기 값(--ui-size-*) · 입력 · 고르기 · 분할 · 탭이 같은 값을 읽음
- 단추 높이 = 글 한 줄 + 여백 + 테두리(높이를 박지 않음) — 크기를 안 준 옛 단추 · 페이지 여백 덮어쓰기 그대로 · 옛 size "sm" = 18 단(같은 꼴) · 글 링크 꼴 더함
- 상태 = 마우스 · 눌림을 data-state 로도 받음(부품 모음이 굳혀 보임) · 마우스 색 = --color-primary-hover · --color-accent-soft(옛 보라 별칭 대신) · 눌림 = 한 칸 내려앉음
- 아이콘 = Tabler 32 더함(지도 · 길 · 자 · 합 · 크레인 · 핀 · 나침반 · 설정 · 닫기 · 메뉴 · 되돌리기 …) + PageKit 여섯을 옮김 · .ui-icon 규칙 전역(ui_template_icons.css)
- 부품 모음 = 단추 · 아이콘 단추 · 아이콘 견본 · 부품마다 여덟 단 한 줄
- 시험 test_552_2_button 새로(단 값 · 높이 식 · 옛 이름 · 상태 · 날 색 없음 · 아이콘)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:26:01 +09:00
eomsangdon e673714748 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-26 21:08:44 +09:00
eomsangdonandClaude Opus 5.5 aeb6bc498f feat(테마): 공통 값 한 벌 · Noto Sans KR 글꼴 파일 실음 (551-1)
- ui_template_theme.css = 대시보드 값(초록 강조 #1f7a4d · 바탕 #f6f7f9 · 선 #e6e8ec · 글 #1f2328 · 둥글기 10) 밝음 · 어두움 두 블록 · 시스템 어두움 = 같은 값
- 옛 보라 이름(deep-iris · plum-velvet · royal-amethyst · mist-violet · lavender-wash) = 새 값의 별칭 · 안 쓰는 그라디언트 3 · twilight-beam · shadow-feature 지움
- 새 토큰 accent-soft · hover · surface-sunken · border-strong · primary-hover · danger-fill(#e5484d 채운 바탕) · scrim · 위험 글자 = #c0392b 그대로
- 글꼴 = --font-ui 한 토큰(--font-body · --font-display 별칭) · Inter · Britti 등 앞 이름 뺌 · ui_template/fonts/ 에 Noto Sans KR 가변 굵기 woff2 39 묶음(unicode-range) + OFL.txt · 첫 화면 = 196 KB 한 파일
- 대시보드 --b01-* = 공통 값의 별칭 · 셸 덮어쓰기 10 줄 · 셸 어두움 블록 지움(판 바탕 흰색 한 줄만 남김)
- test_546: 셸 hex 단정 → theme.css 값 · 셸 별칭 단정으로(값 자리가 theme.css 로 옮겨 감) · test_551_1 새 지킴 시험

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:08:01 +09:00
eomsangdonandClaude Opus 5.5 edd06e4b51 chore(요구): 552-1 부품 목록 37 · 페이지별 변종 수 · 옛 변종 → 새 부품 짝 표 · 높이 → 여덟 단 짝 표
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:01:49 +09:00
eomsangdon 4348a47100 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-10-26 21:01:11 +09:00
eomsangdonandClaude Opus 5.5 c03228778b feat(ui-kit): 부품 목록 37 확정 — 조사로 찾은 일곱 더함 · 크기 단 짝 규칙 · 테마 칸 안 스크롤 막대 없앰 (PLAN 552-1)
- 더함: 슬라이더 · 보조 글 · 떠 있는 판 · 목록 줄 · 띠 알림 · 값 목록 · 범례 (기본 13 · 묶음 21 · 틀 3)
- 규칙 머리: 옛 size sm = 18 · compact = 32 · 지금 높이는 가까운 단(같은 거리면 표 · 도구 = 작은 단)
- iframe content-box — 테두리 2 px 때문에 생기던 안 스크롤 막대 없앰

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:00:24 +09:00
eomsangdon f6643fb946 Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-10-26 20:59:49 +09:00
eomsangdonandClaude Opus 5.5 7e07da4b31 feat(테마): 그림 전용 값 --draw-* — 종단 · 횡단 · 이정표 SVG · 지도 계획선 · CAD 바탕 · 3D 바탕 색을 화면 테마와 떼어 지금 색 고정 (PLAN 551-2)
- ui_template_theme.css 끝 블록 = --draw-* 17 개 · 밝음 / 어두움 / 시스템 어두움 글자 hex
- B05 · B06 · B08 그림 CSS stroke · fill 125 줄 · --b06-tag = --draw-*
- 캔버스 · 3D 읽기 = B05 노선 편집 지도 · 노선 추천 지도 · 3D 바탕 · B04 3D 바탕 · 지도 MC 글 · D03 고른 측점 · CAD 바탕 · 격자
- 지킴 시험 test_551_2_draw_tokens(화면 변수를 모두 바꿔도 그림 풀린 색 같음)
- 기존 시험 7 개 = 그림 줄이 옛 --color-* 이름을 잡던 단정을 --draw-* 로 고침(값 같음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:59:03 +09:00
eomsangdonandClaude Opus 5.5 aaac17db4c feat(ui-kit): 부품 모음 화면 틀 — 시스템 관리자만 · 밝음 / 어두움 나란히 · 상태 다섯 칸 · 크기 여덟 단 (PLAN 552-1)
- 대시보드 세부 페이지 `#/b01-account?view=ui-kit` 등록부 한 줄(시스템 관리 묶음 · SYSTEM_ADMIN 만)
- ui_template_ui_kit.ts — 테마 칸마다 iframe(data-theme)에 지금 CSS 를 옮겨 실제 값으로 그림
- ui_template_ui_kit_parts.ts — 표준 부품 30 줄(552-2 기본 · 552-3 묶음 · 552-4 틀) · 머리 = 부품 규칙(자리 · 이름 · data-size · data-state · 색 · 아이콘)
- 지금 PageKit · 공용 부품 견본(단추 · 입력 · 고르기 · 탭 · 배지 · 표 · ⋯ · 빈 상태 · 설정 줄 · 아이콘) · 나머지 빈 자리
- test_546 시스템 메뉴 기대값에 ui-kit 더함 · 새 지킴 시험 test_552_1_ui_kit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:49:09 +09:00
eomsangdonandClaude Opus 5.5 85ae6319e4 chore(요구): 요구 378 — 단추 크기 여덟 단(18~42) · 글꼴 = 지금 Noto Sans KR 파일 실음 · Pretendard 안 씀 · 진행
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:39:11 +09:00
eomsangdonandClaude Opus 5.5 8721bd7b18 chore(요구): 요구 377 — 단추 크기 꼴 다섯 이상 · 프로젝트 안 = ☰ 서랍 · 글꼴 물음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:28:23 +09:00
eomsangdonandClaude Opus 5.5 41973e9874 chore(요구): 요구서 375 고침(요구 376) — 범위 전체 영역 · 템플릿 부품 상자 먼저 · 부품 모음 화면
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:23:27 +09:00
eomsangdonandClaude Opus 5.5 c914702a84 chore(요구): 요구 376 — 테마 대상 = 전체 영역 · 템플릿 요소를 먼저 만들고 재활용(물음)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:22:38 +09:00
eomsangdonandClaude Opus 5.5 efb9b9ca67 chore(요구): 요구서 375 전역 테마 — 조사 둘(토큰 · 공용 부품 · 페이지별 위험) · 공통 값 한 벌 · 그림 색 고정 · 단계 셋
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:53:00 +09:00
eomsangdonandClaude Opus 5.5 66f5858e54 chore(요구): 요구 375 — 대시보드 테마 · 색 · 글꼴 · 요소를 프로젝트 안 화면까지 전역 반영(조사 · 요구서 · 계획서)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:34:51 +09:00
eomsangdonandClaude Opus 5.5 43d0ccb63f merge(B01): main_laptop_1(549-2 시스템 관리 · 내 정보) 합침 — 옛 구역 정리 · 시험 옮김
- B01_Dashboard_UI_Legacy.ts: 세부 페이지가 다 갈아 끼워 옛 구역 · mountLegacy 지움 · 고른 회사 자리 · 상태 타입만 남김
- B01_Dashboard_UI_Views.ts: 쓰는 곳 없는 legacy 도우미 지움
- B01_Dashboard_UI_Style_Pages.css: 배지 색(549-1) · 시스템 관리 꼴(549-2) 둘 다 살림
- test_b01_settings_cards · test_309_2: 지운 옛 카드 부품 대신 settingLinks · 설계 기본값 페이지 · 사람 페이지를 읽음(낡은 시험)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:33:32 +09:00
eomsangdon bbedceeae1 Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1
# Conflicts:
#	B01_Dashboard/B01_Dashboard_UI_Views.ts
#	resources/tester/test_546_b01_shell.py
2026-10-26 18:28:47 +09:00
eomsangdonandClaude Sonnet 5.5 adc64ddcf3 fix(B01): 시스템 관리 페이지 다듬기 — 로그 동작 · 대상 한글 추가 · 회사 상세 되돌아가기 링크 · 단추 눌림 색을 셸 강조 색으로 (PLAN 549-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 18:27:35 +09:00
eomsangdon a924f466bf Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_4
# Conflicts:
#	B01_Dashboard/B01_Dashboard_UI_Views.ts
#	resources/tester/test_546_b01_shell.py
2026-10-26 18:27:08 +09:00
eomsangdonandClaude Sonnet 5.5 7e34152de8 feat(B01): 받은 예정노선 · 임시 보관함 · 사람 · 회사 정보 · 회사 설계 기본값 세부 페이지 (549-1)
- 새 페이지 다섯(View_Assigned · Temp · People · Company · CompanyDefaults) · 등록부 다섯 줄 교체
- 표 줄 40 · 세로선 없음 · 줄 끝 ⋯ · 한글 배지 · 빈 상태 · 사람 탭 가입 요청 수 배지 · 보관함 남은 날 막대(7 일 안 빨강)
- 옛 구역 정리(Legacy · Company · AssignedPlans · TempUpload 다시 씀) · 시험 test_549 · test_546 stub 일반화

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 18:26:27 +09:00
eomsangdon bed7957a65 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1
# Conflicts:
#	B01_Dashboard/B01_Dashboard_UI_Views.ts
#	resources/tester/test_546_b01_shell.py
2026-10-26 18:25:26 +09:00
eomsangdonandClaude Opus 5.5 c5518a8783 fix(B01): 홈 머리 글 — 꼴 고정 때 「진행 중 N 개 · 작게/크게 고정」 (PLAN 547-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:22:58 +09:00
eomsangdonandClaude Opus 5.5 729f4c75b8 feat(B01): 대시보드 홈 — 진행 중 수에 따라 꼴이 저절로(1 큰 카드 · 2~3 중간 · 4~8 한 줄 · 9 넘음 요약) (PLAN 547-2)
- 등록부 home 줄 = mountHome · 썸네일 선 · 단계 점 · 「다음: 지금 단계」 · [이어서 하기] = 그 단계 화면 · [자세히] = 상세
- 4~8 = 팀원별 · 단계 분포 · 14 일 넘게 그대로 표시 · 9 넘음 = 요약 숫자 · 회사별 막대 · 종류 원(인라인 SVG)
- 오른쪽 = 처리할 일 · 임시 보관함 · 회사(회사 관리자) · 예정노선(산림청) · 처음 = 세 걸음 · 꼴 고정(자동 · 작게 · 크게) 기억
- 홈 역할 차례 = 셸 roleOf 와 같게(회사 관리자가 산림청 앞)
- test_546 등록부 시험 — import 한 mount 이름을 빈 함수로 저절로(페이지 줄 갈아 끼워도 그대로)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:20:21 +09:00
eomsangdonandClaude Sonnet 5.5 70786cef9e feat(B01): 시스템 관리 · 내 정보 세부 페이지 여섯 — 개요 · 회사 · 사용자 · 설계 기본값 · 로그 · 내 정보 (PLAN 549-2)
- 개요 = 숫자 카드 넷 + CPU · 메모리 · 디스크 게이지(70 % 주황 · 90 % 빨강) + 7 일 SVG 그래프
- 회사 = 목록(찾기 · ⋯) → 상세(정보 · 사람 · 프로젝트 탭) · 사용자 = 회사 · 역할 · 찾기 거르기 · ⋯
- 설계 기본값 = 설정 카드를 묶음 제목 + 줄로 · 시스템 로그 = 동작 · 기간 · 찾기 거르기 + 이전 기록 더 보기
- 내 정보 = Linear 꼴 설정 줄(기본정보 · 비밀번호 · 테마 · 내 설계 기본값)
- 옛 구역(리소스 패널 · 옛 내 정보 폼) 지움 · 지킴 시험 test_549 추가 · test_546 stub 맞춤

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 18:19:47 +09:00
eomsangdon 9826538c74 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-26 18:19:35 +09:00
eomsangdonandClaude Opus 5.5 13b417120c test(B01): 셸 등록부 시험 — 가져온 mount 함수를 저절로 흉내 (PLAN 548)
등록부에 세부 페이지 mount 가 늘면 고정 흉내 셋(mountLegacy · mountGuide · mountChat) 밖 이름이
ReferenceError — import 줄에서 이름을 읽어 빈 함수로 둠.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:18:48 +09:00
eomsangdonandClaude Opus 5.5 95a2bed75b feat(B01): 등록부 projects · project 줄 = 새 목록 · 상세 (PLAN 548)
- 셸 등록부 projects · project 줄을 mountProjectList · mountProjectDetail 로 갈아 끼움
- ⋯ 메뉴 오른쪽 끝 = 단추 오른쪽(표 · 상세 같은 함수)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:15:34 +09:00
eomsangdon bd1a080afc Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-26 18:13:22 +09:00
eomsangdonandClaude Opus 5.5 0ab85ec3b3 feat(B01): 대시보드 셸 마무리 — 메뉴 배지 · 알림 수 · 사용자 메뉴 테마 줄 (546-2)
- 메뉴 배지 = 가입 요청(빨강) · 받은 예정노선 미연결 · 업데이트(빨강) · 임시 보관함 묶음 수 — 페이지 바뀔 때마다 다시 셈
- 알림 종 = 처리할 일 수(가입 요청 + 새 예정노선 + 보관함 만료 7 일 안) · 누르면 홈
- 사용자 메뉴 테마 줄 = 바꾼 뒤 반대 화면 이름으로 다시 씀
- 셸 안 글 색 변수(--color-text 등)도 셸 색으로 — 어두운 화면 제목 보라 빠짐
- 시험 test_546_b01_shell 배지 · 레일 · ☰ 걷음 단정 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:12:33 +09:00
eomsangdon 2002affddf Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-26 18:12:15 +09:00
eomsangdonandClaude Sonnet 5.5 6b0d509077 feat(B01): 세부 페이지 공용 조각 — 머리 · 표(줄 40) · ⋯ · 배지 · 탭 · 빈 상태 (549-1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 18:11:48 +09:00
eomsangdon 5aaf7f4e65 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_3 2026-10-26 18:09:03 +09:00
eomsangdon 93e99872e4 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-26 18:08:47 +09:00
eomsangdonandClaude Opus 5.5 070791854c feat(B01): 프로젝트 상세 화면 · 카드 요약 연결 · 지킴 시험 (PLAN 548-2)
- 상세 화면(view=project) — 머리 · [⋯] · [작업 열기 — 지금 단계 ›] · 단계 진행(누름 = 그 단계) ·
  노선 지도(배경 타일 + 선 · 시점 · 종점) · 핵심 숫자 · 기본 정보 · 연결된 것 · 산출물 · 최근 변경(있는 칸만)
- 목록 · 상세 숫자 · 썸네일 = 547-1 카드 요약(project_summaries) 한 벌
- 색 = 셸 변수(--b01-*) 이름에 맞춤
- 시험 test_548_project_overview — 칸반 나눔 · 산출물 · 내 것 · 단계 수 짝

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:08:18 +09:00
eomsangdon 3274d4b107 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_2 2026-10-26 18:07:55 +09:00
eomsangdonandClaude Opus 5.5 bc1c8b44e8 feat(B01): 홈 API — 역할별 진행 중 카드 · 처리할 일 · 회사 · 산림청 · 시스템 요약 (PLAN 547-2)
- GET /api/dashboard/home — role(USER · ADMIN · FOREST · SYSTEM_ADMIN) · 진행 중 카드(최근 수정 순 · 썸네일 앞 8 개)
- 처리할 일 = 새로 받은 · 업데이트 있는 예정노선 · 가입 요청 수 · 7 일 안 만료 보관 파일 · 보관 묶음 수
- 회사 관리자 = 회사 이름 · 팀원 수 · 산림청 = 예정노선 연도 상태별 수 · 업체 지정 대기 · 시스템 = 회사 · 사용자 수 · 서버 지금 값

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:07:54 +09:00
eomsangdon 7190bf2cfe Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_3' into sub_laptop_4 2026-10-26 18:06:53 +09:00
eomsangdon 3c69fb96ca Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-26 18:06:18 +09:00
eomsangdonandClaude Opus 5.5 e505f4014d feat(B01): 대시보드 새 틀 — 셸 · 왼쪽 메뉴 · 세부 페이지 등록부 (546-1)
- 셸 = 위 머리 48(AISLO · 회사 이름 — 시스템 관리자 회사 전환 · 찾기 자리 · [?] · 알림 자리 · 사용자 메뉴) + 왼쪽 메뉴 210 + 챗봇 단추
- 등록부 B01_Dashboard_UI_Views.ts — { key, title, group, roles, icon, mount(host, ctx) } · 주소 ?view= · 같은 주소 안 이동 = 페이지만 갈아 끼움(MountedPage)
- 역할 넷(일반 · 회사 관리자 · 산림청 · 시스템 관리자) · 권한 없는 묶음 · 항목 숨김 · 권한 없는 열쇠 = 홈
- 열쇠마다 옛 구역 부품 그대로 얹음(B01_Dashboard_UI_Legacy.ts) — 기능 빠짐 0
- 옛 ☰ 서랍 · 구역 접기 · 회사 알약 걷음 — 테마 · 언어 · 터치 · 설정 · 로그아웃은 사용자 메뉴로
- 아이콘 = Tabler(MIT) 선 아이콘 몇 개(ui_template_icons.ts · 라이센스 글 함께)
- 시험 — test_b01_settings_cards 옛 한 페이지 단정(buildPage · 접힘 · 알약) 지움 · 셸 회사 고르기 단정으로 바꿈 · test_546_b01_shell 새로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:05:56 +09:00
eomsangdon ffa5d250e8 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_3
# Conflicts:
#	main.py
2026-10-26 18:04:31 +09:00
eomsangdonandClaude Opus 5.5 c6d93ee880 feat(B01): 프로젝트 목록 · 상세 API 와 목록 화면 초안 (PLAN 548)
- 새 API /api/dashboard/overview/projects(내 것 · 회사 전체 · 칸반 칸 board · 오래 멈춤)
  · /projects/{id}(노선 위경도 · 핵심 숫자 · 연결 · 산출물 · 최근 변경 — 있는 것만)
- 칸반 칸 = 단계 상태로 저절로(시작 전 · 설계 중 · 검토(STALE · FAILED) · 확정 대기 · 완료)
- 목록 화면(카드 · 표 줄 끝 ⋯ · 칸반 10 개부터) · 공용 조각 · 화면 CSS — 등록부 연결은 셸 뒤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:04:11 +09:00
eomsangdonandClaude Opus 5.5 8b1b286e04 feat(B01): 프로젝트 카드 요약 함수 · API — 단계 · 썸네일 선 · 담당 · 멈춘 날 · 연장 · 측점 · 구조물 (PLAN 547-1)
- B01_Dashboard_Summary.project_summaries(ids) — 쿼리 다섯 번(개수 무관) · 넣은 차례 · 없는 값은 칸 뺌
- 썸네일 = 최신 노선 GeoJSON 중심선 100 점 안쪽 · 0~1 칸(가로세로 비 유지 · y 아래로)
- GET /api/dashboard/projects/summary?ids= — 보이는 프로젝트만(시스템 관리자 전부 · 회사 · 내 것)
- 실측 21 개 0.14 s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:01:25 +09:00
eomsangdonandClaude Opus 5.5 392cb92987 chore(요구): 요구 374 — 대시보드 새 구조 반영(구현) 지시
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 17:51:01 +09:00
eomsangdonandClaude Opus 5.5 5e6e0b974e chore(요구): 제안 373 홈 — 진행 중 프로젝트 수에 따라 저절로(1 · 2~3 · 4~8 · 9 넘음)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 17:49:31 +09:00
eomsangdonandClaude Opus 5.5 49ef681bf6 chore(요구): 요구 373 — 사용자 2~3 · 회사 4~5 · 시스템 관리자 많음(물음)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 17:47:58 +09:00
eomsangdonandClaude Opus 5.5 6c56fddfdd chore(요구): 제안 372 대시보드 가벼운 판 — 홈 = 지금 하는 프로젝트 큰 카드 · 칸반 · 집계는 산림청 · 시스템만
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 17:45:19 +09:00
eomsangdonandClaude Opus 5.5 b14c170b8e chore(요구): 요구 372 — 사용자 1 · 회사 관리자 1~2 프로젝트면 시안이 거창하지 않은지(물음)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 17:44:04 +09:00
eomsangdonandClaude Opus 5.5 3deacf48ff chore(요구): 제안 371 대시보드 시안 — 요약 보드(숫자 · 그림 · 그래프) · 표/칸반 전환 · 프로젝트 상세 · 역할 넷
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 17:39:52 +09:00
eomsangdonandClaude Opus 5.5 57e6573070 chore(요구): 요구 371 — 최상위 칸반에 숫자 · 그림 · 그래프 요약
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 17:37:01 +09:00
eomsangdonandClaude Opus 5.5 97dcc33fe5 chore(요구): 요구 370 — 대시보드 칸반 · 표/칸반 전환 · 항목 상세 페이지(물음)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 17:34:58 +09:00
eomsangdonandClaude Opus 5.5 6080faf0cf chore(요구): 제안 369 대시보드 새 구조 — 조사 셋(기업 · 공공 포털 · 현대 SaaS · 역할 관리 화면) · 그림 58 · 사이트맵 · 홈 시안 HTML
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:34:48 +09:00
eomsangdonandClaude Opus 5.5 69783b58f5 chore(요구): 요구 369 — 대시보드 다시 조사(세부 페이지 · 예쁘게 · 접근 · 분류)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 15:54:33 +09:00
eomsangdonandClaude Opus 5.5 cb119ef8ea chore(요구): 요구 368 — 인터페이스 제안 아티팩트 공유
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 15:33:31 +09:00
eomsangdonandClaude Opus 5.5 2243836515 chore(요구): 제안 367 본보기 프로그램 — 조사 셋(Onshape · Felt · InfraWorks/ConceptStation) · 그림 58 · 권함 = Felt 꼴 + 도로 칸 ConceptStation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 15:33:02 +09:00
eomsangdonandClaude Opus 5.5 9c8da7fcdf chore(요구): 요구 367 — 화면 개편 = 고침이 아니라 새 인터페이스 · 본보기 프로그램을 찾아 비슷하게
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 15:11:30 +09:00
eomsangdonandClaude Opus 5.5 6708481275 chore(요구): 제안 366 화면 개편 — 조사 넷(업계 · SaaS 대시보드 · 작업 셸 · 지금 화면 코드) · 레퍼런스 그림 53
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:59:09 +09:00
eomsangdonandClaude Opus 5.5 539df9443c chore(요구): 요구 366 — 대시보드 · 상세 화면 개편 제안(외부 조사 셋 · 레퍼런스 페이지)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:40:58 +09:00
490 changed files with 18682 additions and 3892 deletions
+30 -70
View File
@@ -5,12 +5,15 @@
* 제약 준수:
* - 모든 문구는 ui_template_locale.ts 참조 (하드코딩 금지).
* - 색상/간격은 A01_Home_UI_Style.css + theme.css 변수만 사용.
* - 공통 컴포넌트(createButton/createCard/createTag) 우선 사용.
* - 공통 부품(createPublicFrame/createCard/createBadge/createButton) 우선 사용.
* - 이벤트 핸들러는 on[페이지]_[기능]_[액션] 명명.
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, createCard, createTag } from "@ui/ui_template_elements";
import { createBadge } from "@ui/ui_template_badge";
import { createButton, createCard } from "@ui/ui_template_elements";
import { createPublicFrame } from "@ui/ui_template_public_layout";
import { createText } from "@ui/ui_template_text";
import { navigateTo } from "../A00_Common/router";
import { ROUTES } from "@config/config_frontend";
import "./A01_Home_UI_Style.css";
@@ -49,57 +52,12 @@ function onA01_Home_Explore_Click(): void {
/* -----------------------------------------------------------------------------
* 섹션 빌더
* -------------------------------------------------------------------------- */
function buildHero(): HTMLElement {
const hero = document.createElement("section");
hero.className = "a01-hero";
const inner = document.createElement("div");
inner.className = "a01-hero__inner";
const title = document.createElement("h1");
title.className = "a01-hero__title";
title.textContent = L("A01_Home_Title");
const subtitle = document.createElement("p");
subtitle.className = "a01-hero__subtitle";
subtitle.textContent = L("A01_Home_Hero_Subtitle");
const ctas = document.createElement("div");
ctas.className = "a01-hero__ctas";
ctas.append(
createButton({
label: L("A01_Home_Hero_CtaPrimary"),
variant: "filled",
onClick: onA01_Home_GetStarted_Click,
}),
createButton({
label: L("A01_Home_Hero_CtaSecondary"),
variant: "ghost",
onClick: onA01_Home_Explore_Click,
}),
);
inner.append(title, subtitle, ctas);
hero.append(inner);
return hero;
}
function buildNews(): HTMLElement {
const section = document.createElement("section");
section.className = "a01-section";
const heading = document.createElement("h2");
heading.className = "a01-section__title";
heading.textContent = L("A01_Home_News_SectionTitle");
const list = document.createElement("div");
list.className = "a01-news-list";
for (const item of MOCK_NEWS) {
const row = document.createElement("div");
row.className = "a01-news-row";
const tag = createTag(L("A01_Home_News_Tag"), "accent");
const tag = createBadge(L("A01_Home_News_Tag"), "accent");
const titleEl = document.createElement("span");
titleEl.className = "a01-news-row__title";
titleEl.textContent = item.title;
@@ -107,22 +65,14 @@ function buildNews(): HTMLElement {
dateEl.className = "a01-news-row__date";
dateEl.textContent = item.date;
row.append(tag, titleEl, dateEl);
const row = createCard({ body: [tag, titleEl, dateEl] });
row.classList.add("a01-news-row");
list.append(row);
}
section.append(heading, list);
return section;
return list;
}
function buildFeatures(): HTMLElement {
const section = document.createElement("section");
section.className = "a01-section";
const heading = document.createElement("h2");
heading.className = "a01-section__title";
heading.textContent = L("A01_Home_Features_SectionTitle");
const grid = document.createElement("div");
grid.className = "a01-feature-grid";
@@ -133,24 +83,34 @@ function buildFeatures(): HTMLElement {
];
for (const [titleKey, descKey] of features) {
const desc = document.createElement("p");
desc.className = "a01-feature__desc";
desc.textContent = L(descKey);
const card = createCard({ title: L(titleKey), body: [desc] });
const card = createCard({ title: L(titleKey), body: [createText(L(descKey), "muted", 14)] });
card.classList.add("a01-feature-card");
grid.append(card);
}
section.append(heading, grid);
return section;
return grid;
}
/* -----------------------------------------------------------------------------
* 페이지 진입점
* -------------------------------------------------------------------------- */
export function renderA01Home(root: HTMLElement): void {
const page = document.createElement("div");
page.className = "a01-home";
page.append(buildHero(), buildNews(), buildFeatures());
root.append(page);
const frame = createPublicFrame({
title: L("A01_Home_Title"),
lead: L("A01_Home_Hero_Subtitle"),
actions: [
createButton({
label: L("A01_Home_Hero_CtaPrimary"),
variant: "filled",
onClick: onA01_Home_GetStarted_Click,
}),
createButton({
label: L("A01_Home_Hero_CtaSecondary"),
variant: "ghost",
onClick: onA01_Home_Explore_Click,
}),
],
});
frame.addSection(L("A01_Home_News_SectionTitle")).append(buildNews());
frame.addSection(L("A01_Home_Features_SectionTitle")).append(buildFeatures());
root.append(frame.root);
}
+7 -75
View File
@@ -1,66 +1,8 @@
/* =============================================================================
* A01_Home_UI_Style.css
* 홈 페이지 고유 스타일 — theme.css 변수만 참조 (하드코딩 금지).
* 홈 페이지 고유 배치 — 머리 · 구역 · 카드 겉은 공용 부품(공개 화면 틀 · 카드)이 냄.
* ========================================================================== */
.a01-home {
display: flex;
flex-direction: column;
gap: var(--section-gap);
padding-bottom: var(--spacing-64);
}
/* --- Hero --- */
.a01-hero {
/* 라벤더 그라디언트는 흰 표면 아래에만: 히어로 배경으로 사용 */
background: linear-gradient(to bottom, var(--color-mist-violet), var(--color-canvas));
padding: var(--spacing-96) var(--spacing-32);
}
.a01-hero__inner {
max-width: var(--page-max-width);
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: var(--spacing-24);
}
.a01-hero__title {
font-size: var(--text-heading-lg);
line-height: var(--leading-heading-lg);
color: var(--color-deep-iris);
max-width: 14ch;
}
.a01-hero__subtitle {
font-size: var(--text-body);
line-height: var(--leading-body);
color: var(--color-slate);
max-width: 52ch;
margin: 0;
}
.a01-hero__ctas {
display: flex;
gap: var(--spacing-16);
margin-top: var(--spacing-8);
}
/* --- 공통 섹션 --- */
.a01-section {
max-width: var(--page-max-width);
margin: 0 auto;
width: 100%;
padding: 0 var(--spacing-32);
display: flex;
flex-direction: column;
gap: var(--spacing-24);
}
.a01-section__title {
font-size: var(--text-heading-sm);
line-height: var(--leading-heading-sm);
color: var(--color-deep-iris);
text-align: center;
}
/* --- News --- */
.a01-news-list {
display: flex;
@@ -68,21 +10,20 @@
gap: var(--spacing-8);
}
.a01-news-row {
display: flex;
padding: var(--spacing-16) var(--spacing-24);
}
.a01-news-row > .ui-card__body {
flex-direction: row;
align-items: center;
gap: var(--spacing-16);
padding: var(--spacing-16) var(--spacing-24);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
}
.a01-news-row__title {
flex: 1;
color: var(--color-charcoal);
color: var(--color-text);
font-size: var(--text-body-sm);
}
.a01-news-row__date {
color: var(--color-ash);
color: var(--color-text-muted);
font-size: var(--text-caption);
}
@@ -95,18 +36,9 @@
.a01-feature-card {
min-height: 140px;
}
.a01-feature__desc {
margin: 0;
color: var(--color-slate);
font-size: var(--text-body-sm);
line-height: var(--leading-body-sm);
}
@media (max-width: 900px) {
.a01-feature-grid {
grid-template-columns: 1fr;
}
.a01-hero__title {
font-size: var(--text-heading);
}
}
+25 -64
View File
@@ -5,12 +5,14 @@
* 제약 준수:
* - 모든 문구는 ui_template_locale.ts 참조 (하드코딩 금지).
* - 색상/간격은 A02_ProgDetail_UI_Style.css + theme.css 변수만 사용.
* - 공통 컴포넌트(createButton/createCard) 우선 사용.
* - 공통 부품(createPublicFrame/createCard/createButton) 우선 사용.
* - 이벤트 핸들러는 on[페이지]_[기능]_[액션] 명명.
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, createCard } from "@ui/ui_template_elements";
import { createPublicFrame } from "@ui/ui_template_public_layout";
import { createText } from "@ui/ui_template_text";
import { navigateTo } from "../A00_Common/router";
import { ROUTES } from "@config/config_frontend";
import "./A02_ProgDetail_UI_Style.css";
@@ -30,40 +32,7 @@ function onA02_ProgDetail_Start_Click(): void {
/* -----------------------------------------------------------------------------
* 섹션 빌더
* -------------------------------------------------------------------------- */
function buildHero(): HTMLElement {
const hero = document.createElement("section");
hero.className = "a02-hero";
const inner = document.createElement("div");
inner.className = "a02-hero__inner";
const title = document.createElement("h1");
title.className = "a02-hero__title";
title.textContent = L("A02_ProgDetail_Title");
const subtitle = document.createElement("p");
subtitle.className = "a02-hero__subtitle";
subtitle.textContent = L("A02_ProgDetail_Hero_Subtitle");
const cta = createButton({
label: L("A02_ProgDetail_Hero_Cta"),
variant: "filled",
onClick: onA02_ProgDetail_Start_Click,
});
inner.append(title, subtitle, cta);
hero.append(inner);
return hero;
}
function buildWorkflow(): HTMLElement {
const section = document.createElement("section");
section.className = "a02-section";
const heading = document.createElement("h2");
heading.className = "a02-section__title";
heading.textContent = L("A02_ProgDetail_Workflow_SectionTitle");
const list = document.createElement("div");
list.className = "a02-workflow-list";
@@ -77,9 +46,6 @@ function buildWorkflow(): HTMLElement {
];
steps.forEach(([titleKey, descKey], index) => {
const row = document.createElement("div");
row.className = "a02-workflow-row";
const badge = document.createElement("span");
badge.className = "a02-workflow-row__badge";
badge.textContent = String(index + 1);
@@ -91,27 +57,15 @@ function buildWorkflow(): HTMLElement {
stepTitle.className = "a02-workflow-row__title";
stepTitle.textContent = L(titleKey);
const stepDesc = document.createElement("p");
stepDesc.className = "a02-workflow-row__desc";
stepDesc.textContent = L(descKey);
text.append(stepTitle, stepDesc);
row.append(badge, text);
text.append(stepTitle, createText(L(descKey), "muted", 14));
const row = createCard({ body: [badge, text] });
row.classList.add("a02-workflow-row");
list.append(row);
});
section.append(heading, list);
return section;
return list;
}
function buildTech(): HTMLElement {
const section = document.createElement("section");
section.className = "a02-section";
const heading = document.createElement("h2");
heading.className = "a02-section__title";
heading.textContent = L("A02_ProgDetail_Tech_SectionTitle");
const grid = document.createElement("div");
grid.className = "a02-tech-grid";
@@ -122,24 +76,31 @@ function buildTech(): HTMLElement {
];
for (const [titleKey, descKey] of techs) {
const desc = document.createElement("p");
desc.className = "a02-tech__desc";
desc.textContent = L(descKey);
const card = createCard({ title: L(titleKey), body: [desc] });
const card = createCard({ title: L(titleKey), body: [createText(L(descKey), "muted", 14)] });
card.classList.add("a02-tech-card");
grid.append(card);
}
section.append(heading, grid);
return section;
return grid;
}
/* -----------------------------------------------------------------------------
* 페이지 진입점
* -------------------------------------------------------------------------- */
export function renderA02ProgDetail(root: HTMLElement): void {
const page = document.createElement("div");
page.className = "a02-prog-detail";
page.append(buildHero(), buildWorkflow(), buildTech());
root.append(page);
const frame = createPublicFrame({
title: L("A02_ProgDetail_Title"),
lead: L("A02_ProgDetail_Hero_Subtitle"),
actions: [
createButton({
label: L("A02_ProgDetail_Hero_Cta"),
variant: "filled",
onClick: onA02_ProgDetail_Start_Click,
}),
],
wide: true,
});
frame.root.classList.add("a02-prog-detail");
frame.addSection(L("A02_ProgDetail_Workflow_SectionTitle")).append(buildWorkflow());
frame.addSection(L("A02_ProgDetail_Tech_SectionTitle")).append(buildTech());
root.append(frame.root);
}
+7 -73
View File
@@ -1,58 +1,11 @@
/* =============================================================================
* A02_ProgDetail_UI_Style.css
* 프로그램 상세 페이지 고유 스타일 — theme.css 변수만 참조 (하드코딩 금지).
* 프로그램 상세 페이지 고유 배치 — 머리 · 구역 · 카드 겉은 공용 부품(공개 화면 틀 · 카드)이 냄.
* ========================================================================== */
.a02-prog-detail {
display: flex;
flex-direction: column;
gap: var(--section-gap);
padding-bottom: var(--spacing-64);
}
/* --- Hero --- */
.a02-hero {
background: linear-gradient(to bottom, var(--color-mist-violet), var(--color-canvas));
padding: var(--spacing-96) var(--spacing-32);
}
.a02-hero__inner {
max-width: var(--page-max-width);
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: var(--spacing-24);
}
.a02-hero__title {
font-size: var(--text-heading-lg);
line-height: var(--leading-heading-lg);
color: var(--color-deep-iris);
max-width: 18ch;
}
.a02-hero__subtitle {
font-size: var(--text-body);
line-height: var(--leading-body);
color: var(--color-slate);
max-width: 56ch;
margin: 0;
}
/* --- 공통 섹션 --- */
.a02-section {
max-width: var(--page-max-width);
margin: 0 auto;
width: 100%;
padding: 0 var(--spacing-32);
display: flex;
flex-direction: column;
gap: var(--spacing-24);
}
.a02-section__title {
font-size: var(--text-heading-sm);
line-height: var(--leading-heading-sm);
color: var(--color-deep-iris);
text-align: center;
/* 단추 하나뿐인 머리 — 단추 줄 위 여백 없음 */
.a02-prog-detail .ui-public-frame__actions {
margin-top: 0;
}
/* --- Workflow (번호 배지 + 텍스트) --- */
@@ -61,14 +14,10 @@
flex-direction: column;
gap: var(--spacing-16);
}
.a02-workflow-row {
display: flex;
.a02-workflow-row > .ui-card__body {
flex-direction: row;
align-items: flex-start;
gap: var(--spacing-24);
padding: var(--spacing-24);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
}
.a02-workflow-row__badge {
flex-shrink: 0;
@@ -90,15 +39,9 @@
}
.a02-workflow-row__title {
font-size: var(--text-body);
color: var(--color-charcoal);
color: var(--color-text);
margin: 0;
}
.a02-workflow-row__desc {
margin: 0;
color: var(--color-slate);
font-size: var(--text-body-sm);
line-height: var(--leading-body-sm);
}
/* --- Tech --- */
.a02-tech-grid {
@@ -109,18 +52,9 @@
.a02-tech-card {
min-height: 140px;
}
.a02-tech__desc {
margin: 0;
color: var(--color-slate);
font-size: var(--text-body-sm);
line-height: var(--leading-body-sm);
}
@media (max-width: 900px) {
.a02-tech-grid {
grid-template-columns: 1fr;
}
.a02-hero__title {
font-size: var(--text-heading);
}
}
+16 -59
View File
@@ -5,11 +5,13 @@
* 제약 준수:
* - 모든 문구는 ui_template_locale.ts 참조 (하드코딩 금지).
* - 색상/간격은 A03_CompDetail_UI_Style.css + theme.css 변수만 사용.
* - 공통 컴포넌트(createCard) 우선 사용.
* - 공통 부품(createPublicFrame/createCard) 우선 사용.
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createCard } from "@ui/ui_template_elements";
import { createPublicFrame } from "@ui/ui_template_public_layout";
import { createText } from "@ui/ui_template_text";
import "./A03_CompDetail_UI_Style.css";
/** locale 헬퍼 */
@@ -20,50 +22,14 @@ function L(key: keyof typeof ui_locales): string {
/* -----------------------------------------------------------------------------
* 섹션 빌더
* -------------------------------------------------------------------------- */
function buildHero(): HTMLElement {
const hero = document.createElement("section");
hero.className = "a03-hero";
const inner = document.createElement("div");
inner.className = "a03-hero__inner";
const title = document.createElement("h1");
title.className = "a03-hero__title";
title.textContent = L("A03_CompDetail_Title");
const subtitle = document.createElement("p");
subtitle.className = "a03-hero__subtitle";
subtitle.textContent = L("A03_CompDetail_Hero_Subtitle");
inner.append(title, subtitle);
hero.append(inner);
return hero;
}
function buildMission(): HTMLElement {
const section = document.createElement("section");
section.className = "a03-section";
const heading = document.createElement("h2");
heading.className = "a03-section__title";
heading.textContent = L("A03_CompDetail_Mission_SectionTitle");
const body = document.createElement("p");
body.className = "a03-mission__body";
body.textContent = L("A03_CompDetail_Mission_Body");
section.append(heading, body);
return section;
return body;
}
function buildValues(): HTMLElement {
const section = document.createElement("section");
section.className = "a03-section";
const heading = document.createElement("h2");
heading.className = "a03-section__title";
heading.textContent = L("A03_CompDetail_Value_SectionTitle");
const grid = document.createElement("div");
grid.className = "a03-value-grid";
@@ -74,40 +40,31 @@ function buildValues(): HTMLElement {
];
for (const [titleKey, descKey] of values) {
const desc = document.createElement("p");
desc.className = "a03-value__desc";
desc.textContent = L(descKey);
const card = createCard({ title: L(titleKey), body: [desc] });
const card = createCard({ title: L(titleKey), body: [createText(L(descKey), "muted", 14)] });
card.classList.add("a03-value-card");
grid.append(card);
}
section.append(heading, grid);
return section;
return grid;
}
function buildContact(): HTMLElement {
const section = document.createElement("section");
section.className = "a03-section";
const heading = document.createElement("h2");
heading.className = "a03-section__title";
heading.textContent = L("A03_CompDetail_Contact_SectionTitle");
const email = document.createElement("p");
email.className = "a03-contact__email";
email.textContent = L("A03_CompDetail_Contact_Email");
section.append(heading, email);
return section;
return email;
}
/* -----------------------------------------------------------------------------
* 페이지 진입점
* -------------------------------------------------------------------------- */
export function renderA03CompDetail(root: HTMLElement): void {
const page = document.createElement("div");
page.className = "a03-comp-detail";
page.append(buildHero(), buildMission(), buildValues(), buildContact());
root.append(page);
const frame = createPublicFrame({
title: L("A03_CompDetail_Title"),
lead: L("A03_CompDetail_Hero_Subtitle"),
wide: true,
});
frame.addSection(L("A03_CompDetail_Mission_SectionTitle")).append(buildMission());
frame.addSection(L("A03_CompDetail_Value_SectionTitle")).append(buildValues());
frame.addSection(L("A03_CompDetail_Contact_SectionTitle")).append(buildContact());
root.append(frame.root);
}
+3 -64
View File
@@ -1,66 +1,14 @@
/* =============================================================================
* A03_CompDetail_UI_Style.css
* 회사 상세 페이지 고유 스타일 — theme.css 변수만 참조 (하드코딩 금지).
* 회사 상세 페이지 고유 배치 — 머리 · 구역 · 카드 겉은 공용 부품(공개 화면 틀 · 카드)이 냄.
* ========================================================================== */
.a03-comp-detail {
display: flex;
flex-direction: column;
gap: var(--section-gap);
padding-bottom: var(--spacing-64);
}
/* --- Hero --- */
.a03-hero {
background: linear-gradient(to bottom, var(--color-mist-violet), var(--color-canvas));
padding: var(--spacing-96) var(--spacing-32);
}
.a03-hero__inner {
max-width: var(--page-max-width);
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: var(--spacing-24);
}
.a03-hero__title {
font-size: var(--text-heading-lg);
line-height: var(--leading-heading-lg);
color: var(--color-deep-iris);
max-width: 18ch;
}
.a03-hero__subtitle {
font-size: var(--text-body);
line-height: var(--leading-body);
color: var(--color-slate);
max-width: 56ch;
margin: 0;
}
/* --- 공통 섹션 --- */
.a03-section {
max-width: var(--page-max-width);
margin: 0 auto;
width: 100%;
padding: 0 var(--spacing-32);
display: flex;
flex-direction: column;
gap: var(--spacing-24);
}
.a03-section__title {
font-size: var(--text-heading-sm);
line-height: var(--leading-heading-sm);
color: var(--color-deep-iris);
text-align: center;
}
/* --- Mission --- */
.a03-mission__body {
margin: 0 auto;
max-width: 60ch;
text-align: center;
color: var(--color-slate);
color: var(--color-text-secondary);
font-size: var(--text-body);
line-height: var(--leading-body);
}
@@ -74,18 +22,12 @@
.a03-value-card {
min-height: 140px;
}
.a03-value__desc {
margin: 0;
color: var(--color-slate);
font-size: var(--text-body-sm);
line-height: var(--leading-body-sm);
}
/* --- Contact --- */
.a03-contact__email {
margin: 0;
text-align: center;
color: var(--color-charcoal);
color: var(--color-text);
font-size: var(--text-body);
}
@@ -93,7 +35,4 @@
.a03-value-grid {
grid-template-columns: 1fr;
}
.a03-hero__title {
font-size: var(--text-heading);
}
}
+18 -46
View File
@@ -5,12 +5,15 @@
* 제약 준수:
* - 모든 문구는 ui_template_locale.ts 참조 (하드코딩 금지).
* - 색상/간격은 A04_NewsHistory_UI_Style.css + theme.css 변수만 사용.
* - 공통 컴포넌트(createTag) 우선 사용.
* - 공통 부품(createPublicFrame/createCard/createBadge) 우선 사용.
* - Mock 데이터는 나중에 GET /api/a04/history로 교체.
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createTag, type TagVariant } from "@ui/ui_template_elements";
import { createBadge, type BadgeTone } from "@ui/ui_template_badge";
import { createCard } from "@ui/ui_template_elements";
import { createEmpty } from "@ui/ui_template_empty";
import { createPublicFrame } from "@ui/ui_template_public_layout";
import "./A04_NewsHistory_UI_Style.css";
/** locale 헬퍼 */
@@ -38,7 +41,7 @@ const MOCK_HISTORY: HistoryItem[] = [
];
/** 이력 종류별 태그 변형/라벨 매핑 */
const KIND_META: Record<HistoryKind, { variant: TagVariant; labelKey: keyof typeof ui_locales }> = {
const KIND_META: Record<HistoryKind, { variant: BadgeTone; labelKey: keyof typeof ui_locales }> = {
feature: { variant: "accent", labelKey: "A04_NewsHistory_Tag_Feature" },
fix: { variant: "success", labelKey: "A04_NewsHistory_Tag_Fix" },
notice: { variant: "neutral", labelKey: "A04_NewsHistory_Tag_Notice" },
@@ -47,47 +50,14 @@ const KIND_META: Record<HistoryKind, { variant: TagVariant; labelKey: keyof type
/* -----------------------------------------------------------------------------
* 섹션 빌더
* -------------------------------------------------------------------------- */
function buildHero(): HTMLElement {
const hero = document.createElement("section");
hero.className = "a04-hero";
const inner = document.createElement("div");
inner.className = "a04-hero__inner";
const title = document.createElement("h1");
title.className = "a04-hero__title";
title.textContent = L("A04_NewsHistory_Title");
const subtitle = document.createElement("p");
subtitle.className = "a04-hero__subtitle";
subtitle.textContent = L("A04_NewsHistory_Hero_Subtitle");
inner.append(title, subtitle);
hero.append(inner);
return hero;
}
function buildTimeline(): HTMLElement {
const section = document.createElement("section");
section.className = "a04-section";
if (MOCK_HISTORY.length === 0) return createEmpty({ text: L("A04_NewsHistory_Empty") });
const list = document.createElement("div");
list.className = "a04-timeline";
if (MOCK_HISTORY.length === 0) {
const empty = document.createElement("p");
empty.className = "a04-timeline__empty";
empty.textContent = L("A04_NewsHistory_Empty");
section.append(empty);
return section;
}
for (const item of MOCK_HISTORY) {
const meta = KIND_META[item.kind];
const row = document.createElement("div");
row.className = "a04-timeline-row";
const tag = createTag(L(meta.labelKey), meta.variant);
const tag = createBadge(L(meta.labelKey), meta.variant);
const titleEl = document.createElement("span");
titleEl.className = "a04-timeline-row__title";
@@ -97,20 +67,22 @@ function buildTimeline(): HTMLElement {
dateEl.className = "a04-timeline-row__date";
dateEl.textContent = item.date;
row.append(tag, titleEl, dateEl);
const row = createCard({ body: [tag, titleEl, dateEl] });
row.classList.add("a04-timeline-row");
list.append(row);
}
section.append(list);
return section;
return list;
}
/* -----------------------------------------------------------------------------
* 페이지 진입점
* -------------------------------------------------------------------------- */
export function renderA04NewsHistory(root: HTMLElement): void {
const page = document.createElement("div");
page.className = "a04-news-history";
page.append(buildHero(), buildTimeline());
root.append(page);
const frame = createPublicFrame({
title: L("A04_NewsHistory_Title"),
lead: L("A04_NewsHistory_Hero_Subtitle"),
wide: true,
});
frame.addSection().append(buildTimeline());
root.append(frame.root);
}
+7 -65
View File
@@ -1,54 +1,8 @@
/* =============================================================================
* A04_NewsHistory_UI_Style.css
* 최신소식/개선이력 페이지 고유 스타일 — theme.css 변수만 참조 (하드코딩 금지).
* 최신소식 · 개선 이력 고유 배치 — 머리 · 구역 · 카드 겉은 공용 부품(공개 화면 틀 · 카드)이 냄.
* ========================================================================== */
.a04-news-history {
display: flex;
flex-direction: column;
gap: var(--section-gap);
padding-bottom: var(--spacing-64);
}
/* --- Hero --- */
.a04-hero {
background: linear-gradient(to bottom, var(--color-mist-violet), var(--color-canvas));
padding: var(--spacing-96) var(--spacing-32);
}
.a04-hero__inner {
max-width: var(--page-max-width);
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: var(--spacing-24);
}
.a04-hero__title {
font-size: var(--text-heading-lg);
line-height: var(--leading-heading-lg);
color: var(--color-deep-iris);
max-width: 18ch;
}
.a04-hero__subtitle {
font-size: var(--text-body);
line-height: var(--leading-body);
color: var(--color-slate);
max-width: 56ch;
margin: 0;
}
/* --- 공통 섹션 --- */
.a04-section {
max-width: var(--page-max-width);
margin: 0 auto;
width: 100%;
padding: 0 var(--spacing-32);
display: flex;
flex-direction: column;
gap: var(--spacing-24);
}
/* --- Timeline --- */
.a04-timeline {
display: flex;
@@ -56,31 +10,19 @@
gap: var(--spacing-8);
}
.a04-timeline-row {
display: flex;
padding: var(--spacing-16) var(--spacing-24);
}
.a04-timeline-row > .ui-card__body {
flex-direction: row;
align-items: center;
gap: var(--spacing-16);
padding: var(--spacing-16) var(--spacing-24);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
}
.a04-timeline-row__title {
flex: 1;
color: var(--color-charcoal);
color: var(--color-text);
font-size: var(--text-body-sm);
}
.a04-timeline-row__date {
color: var(--color-ash);
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.a04-timeline__empty {
text-align: center;
color: var(--color-slate);
font-size: var(--text-body);
}
@media (max-width: 900px) {
.a04-hero__title {
font-size: var(--text-heading);
}
}
+21 -53
View File
@@ -5,12 +5,14 @@
* 제약 준수:
* - 모든 문구는 ui_template_locale.ts 참조 (하드코딩 금지).
* - 색상/간격은 A05_EduDetail_UI_Style.css + theme.css 변수만 사용.
* - 공통 컴포넌트(createButton/createCard) 우선 사용.
* - 공통 부품(createPublicFrame/createCard/createButton) 우선 사용.
* - 이벤트 핸들러는 on[페이지]_[기능]_[액션] 명명.
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, createCard } from "@ui/ui_template_elements";
import { createPublicFrame } from "@ui/ui_template_public_layout";
import { createText } from "@ui/ui_template_text";
import { navigateTo } from "../A00_Common/router";
import { ROUTES } from "@config/config_frontend";
import "./A05_EduDetail_UI_Style.css";
@@ -30,34 +32,7 @@ function onA05_EduDetail_Inquiry_Click(): void {
/* -----------------------------------------------------------------------------
* 섹션 빌더
* -------------------------------------------------------------------------- */
function buildHero(): HTMLElement {
const hero = document.createElement("section");
hero.className = "a05-hero";
const inner = document.createElement("div");
inner.className = "a05-hero__inner";
const title = document.createElement("h1");
title.className = "a05-hero__title";
title.textContent = L("A05_EduDetail_Title");
const subtitle = document.createElement("p");
subtitle.className = "a05-hero__subtitle";
subtitle.textContent = L("A05_EduDetail_Hero_Subtitle");
inner.append(title, subtitle);
hero.append(inner);
return hero;
}
function buildCourses(): HTMLElement {
const section = document.createElement("section");
section.className = "a05-section";
const heading = document.createElement("h2");
heading.className = "a05-section__title";
heading.textContent = L("A05_EduDetail_Course_SectionTitle");
const grid = document.createElement("div");
grid.className = "a05-course-grid";
@@ -68,38 +43,31 @@ function buildCourses(): HTMLElement {
];
for (const [titleKey, descKey] of courses) {
const desc = document.createElement("p");
desc.className = "a05-course__desc";
desc.textContent = L(descKey);
const card = createCard({ title: L(titleKey), body: [desc] });
const card = createCard({ title: L(titleKey), body: [createText(L(descKey), "muted", 14)] });
card.classList.add("a05-course-card");
grid.append(card);
}
section.append(heading, grid);
return section;
}
function buildCta(): HTMLElement {
const section = document.createElement("section");
section.className = "a05-section a05-cta";
section.append(
createButton({
label: L("A05_EduDetail_Cta"),
variant: "filled",
onClick: onA05_EduDetail_Inquiry_Click,
}),
);
return section;
return grid;
}
/* -----------------------------------------------------------------------------
* 페이지 진입점
* -------------------------------------------------------------------------- */
export function renderA05EduDetail(root: HTMLElement): void {
const page = document.createElement("div");
page.className = "a05-edu-detail";
page.append(buildHero(), buildCourses(), buildCta());
root.append(page);
const frame = createPublicFrame({
title: L("A05_EduDetail_Title"),
lead: L("A05_EduDetail_Hero_Subtitle"),
wide: true,
});
frame.addSection(L("A05_EduDetail_Course_SectionTitle")).append(buildCourses());
const cta = frame.addSection();
cta.classList.add("a05-cta");
cta.append(
createButton({
label: L("A05_EduDetail_Cta"),
variant: "filled",
onClick: onA05_EduDetail_Inquiry_Click,
}),
);
root.append(frame.root);
}
+1 -62
View File
@@ -1,60 +1,8 @@
/* =============================================================================
* A05_EduDetail_UI_Style.css
* 교육 상세 페이지 고유 스타일 — theme.css 변수만 참조 (하드코딩 금지).
* 교육 상세 페이지 고유 배치 — 머리 · 구역 · 카드 겉은 공용 부품(공개 화면 틀 · 카드)이 냄.
* ========================================================================== */
.a05-edu-detail {
display: flex;
flex-direction: column;
gap: var(--section-gap);
padding-bottom: var(--spacing-64);
}
/* --- Hero --- */
.a05-hero {
background: linear-gradient(to bottom, var(--color-mist-violet), var(--color-canvas));
padding: var(--spacing-96) var(--spacing-32);
}
.a05-hero__inner {
max-width: var(--page-max-width);
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: var(--spacing-24);
}
.a05-hero__title {
font-size: var(--text-heading-lg);
line-height: var(--leading-heading-lg);
color: var(--color-deep-iris);
max-width: 18ch;
}
.a05-hero__subtitle {
font-size: var(--text-body);
line-height: var(--leading-body);
color: var(--color-slate);
max-width: 56ch;
margin: 0;
}
/* --- 공통 섹션 --- */
.a05-section {
max-width: var(--page-max-width);
margin: 0 auto;
width: 100%;
padding: 0 var(--spacing-32);
display: flex;
flex-direction: column;
gap: var(--spacing-24);
}
.a05-section__title {
font-size: var(--text-heading-sm);
line-height: var(--leading-heading-sm);
color: var(--color-deep-iris);
text-align: center;
}
/* --- Courses --- */
.a05-course-grid {
display: grid;
@@ -64,12 +12,6 @@
.a05-course-card {
min-height: 140px;
}
.a05-course__desc {
margin: 0;
color: var(--color-slate);
font-size: var(--text-body-sm);
line-height: var(--leading-body-sm);
}
/* --- CTA --- */
.a05-cta {
@@ -80,7 +22,4 @@
.a05-course-grid {
grid-template-columns: 1fr;
}
.a05-hero__title {
font-size: var(--text-heading);
}
}
+3 -3
View File
@@ -38,11 +38,11 @@
},
{
"n": 6,
"sel": ".b03-file__columns > :nth-child(1) .b03-file__group-title"
"sel": ".b03-file__columns > :nth-child(1) .ui-panel-head__title"
},
{
"n": 7,
"sel": ".b03-file__columns > :nth-child(2) .b03-file__group-title"
"sel": ".b03-file__columns > :nth-child(2) .ui-panel-head__title"
}
]
},
@@ -215,7 +215,7 @@
"wait": ".b03-file__lasfree"
},
{
"click": ".b03-file__lasfree input"
"click": ".b03-file__lasfree"
},
{
"wait_ms": 800
+3 -3
View File
@@ -2,6 +2,7 @@ import { ROUTES } from "@config/config_frontend";
import { isBlank, isValidEmail } from "@util/common_util_validate";
import {
createButton,
createCard,
createInputField,
hideLoadingOverlay,
showLoadingOverlay,
@@ -67,8 +68,6 @@ function clearOtpPending(): void {
export function renderA06Login(root: HTMLElement): void {
const page = document.createElement("div");
page.className = "a06-login";
const card = document.createElement("div");
card.className = "a06-login__card";
const title = document.createElement("h1");
title.className = "a06-login__title";
title.textContent = L("A06_Login_Title");
@@ -215,7 +214,8 @@ export function renderA06Login(root: HTMLElement): void {
variant: "ghost",
onClick: () => navigateTo(ROUTES.A07_REGISTER),
});
card.append(title, form, register);
const card = createCard({ body: [title, form, register] });
card.classList.add("a06-login__card");
page.append(card);
root.append(page);
+2 -22
View File
@@ -1,6 +1,6 @@
/* =============================================================================
* A06_Login_UI_Style.css
* 로그인 페이지 고유 스타일 — theme.css 변수만 참조 (하드코딩 금지).
* 로그인 페이지 고유 배치 — 카드 겉은 공용 부품(카드)이 냄.
* ========================================================================== */
.a06-login {
@@ -13,29 +13,16 @@
.a06-login__card {
width: 100%;
max-width: 400px;
display: flex;
flex-direction: column;
gap: var(--spacing-16);
padding: var(--spacing-40);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
box-shadow: var(--shadow-sm);
}
.a06-login__title {
font-size: var(--text-heading-sm);
line-height: var(--leading-heading-sm);
color: var(--color-deep-iris);
color: var(--color-text);
text-align: center;
margin: 0;
}
.a06-login__subtitle {
font-size: var(--text-body-sm);
color: var(--color-slate);
text-align: center;
margin: 0 0 var(--spacing-8);
}
.a06-login__form {
display: flex;
@@ -50,10 +37,3 @@
.a06-login__otp-actions > .ui-btn {
flex: 1;
}
.a06-login__submit {
width: 100%;
margin-top: var(--spacing-8);
}
.a06-login__link {
width: 100%;
}
+17 -24
View File
@@ -2,11 +2,14 @@ import { ROUTES } from "@config/config_frontend";
import { hasMinLength, isBlank, isValidEmail } from "@util/common_util_validate";
import {
createButton,
createCard,
createInputField,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { createCheckbox } from "@ui/ui_template_checkbox";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { navigateTo } from "../A00_Common/router";
import { requestRegistration, verifyRegistration } from "./A07_Register_Api_Fetch";
@@ -22,38 +25,29 @@ interface AgreementField {
function checkboxWithAccordion(label: string, contentText: string): AgreementField {
const container = document.createElement("div");
container.className = "a07-register__agreement";
container.className = "ui-collapsible is-collapsed a07-register__agreement";
const summary = document.createElement("div");
summary.className = "a07-register__agreement-summary";
summary.className = "ui-collapsible__title a07-register__agreement-summary";
const input = document.createElement("input");
input.type = "checkbox";
const box = createCheckbox("");
// 체크 상자를 누를 땐 접힘이 바뀌지 않게 — 줄의 나머지를 누르면 약관이 펴짐
box.root.addEventListener("click", (e) => e.stopPropagation());
const labelSpan = document.createElement("span");
labelSpan.className = "a07-register__agreement-label";
labelSpan.textContent = label;
summary.append(input, labelSpan);
summary.append(box.root, labelSpan);
const details = document.createElement("details");
details.className = "a07-register__agreement-details";
const content = document.createElement("div");
content.className = "a07-register__agreement-content";
content.textContent = contentText;
const detailsContent = document.createElement("div");
detailsContent.className = "a07-register__agreement-content";
detailsContent.textContent = contentText;
container.append(summary, content);
attachCollapsible(container);
details.appendChild(detailsContent);
summary.addEventListener("click", (e) => {
if (e.target !== input) {
details.open = !details.open;
}
});
container.append(summary, details);
return { root: container, input };
return { root: container, input: box.input };
}
async function loadAgreementText(filename: string): Promise<string> {
@@ -69,8 +63,6 @@ async function loadAgreementText(filename: string): Promise<string> {
export function renderA07Register(root: HTMLElement): void {
const page = document.createElement("div");
page.className = "a07-register";
const card = document.createElement("div");
card.className = "a07-register__card";
const title = document.createElement("h1");
title.className = "a07-register__title";
title.textContent = L("A07_Register_Title");
@@ -210,7 +202,8 @@ export function renderA07Register(root: HTMLElement): void {
variant: "ghost",
onClick: () => navigateTo(ROUTES.A06_LOGIN),
});
card.append(title, form, login);
const card = createCard({ body: [title, form, login] });
card.classList.add("a07-register__card");
page.append(card);
root.append(page);
});
-141
View File
@@ -1,141 +0,0 @@
/* =============================================================================
* A07_Register_UI_Page.ts
* 로그인 전 07: 회원가입 (회사명 + 이름 + 이메일 + 비밀번호 폼)
*
* 제약 준수:
* - 모든 문구는 ui_template_locale.ts 참조 (하드코딩 금지).
* - 색상/간격은 A07_Register_UI_Style.css + theme.css 변수만 사용.
* - 공통 컴포넌트(createButton/createInputField) 우선 사용.
* - 이벤트 핸들러는 on[페이지]_[기능]_[액션] 명명.
* - 백엔드 전송 전 1차 유효성 검사 (frontend.md §4).
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, createInputField } from "@ui/ui_template_elements";
import { isBlank, isValidEmail, hasMinLength } from "@util/common_util_validate";
import { navigateTo } from "../A00_Common/router";
import { ROUTES } from "@config/config_frontend";
import "./A07_Register_UI_Style.css";
/** locale 헬퍼 */
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** 비밀번호 최소 길이 */
const PASSWORD_MIN_LENGTH = 8;
/* -----------------------------------------------------------------------------
* 페이지 진입점
* -------------------------------------------------------------------------- */
export function renderA07Register(root: HTMLElement): void {
const page = document.createElement("div");
page.className = "a07-register";
const card = document.createElement("div");
card.className = "a07-register__card";
const title = document.createElement("h1");
title.className = "a07-register__title";
title.textContent = L("A07_Register_Title");
const subtitle = document.createElement("p");
subtitle.className = "a07-register__subtitle";
subtitle.textContent = L("A07_Register_Subtitle");
// 입력 필드
const companyField = createInputField({
label: L("A07_Register_Field_Company"),
placeholder: L("A07_Register_Field_Company_Placeholder"),
type: "text",
required: true,
});
const nameField = createInputField({
label: L("A07_Register_Field_Name"),
placeholder: L("A07_Register_Field_Name_Placeholder"),
type: "text",
required: true,
});
const emailField = createInputField({
label: L("A07_Register_Field_Email"),
placeholder: L("A07_Register_Field_Email_Placeholder"),
type: "email",
required: true,
});
const passwordField = createInputField({
label: L("A07_Register_Field_Password"),
placeholder: L("A07_Register_Field_Password_Placeholder"),
type: "password",
required: true,
});
const form = document.createElement("form");
form.className = "a07-register__form";
form.noValidate = true;
const submitBtn = createButton({
label: L("A07_Register_Submit"),
variant: "filled",
type: "submit",
});
submitBtn.classList.add("a07-register__submit");
/* 이벤트 핸들러: 제출 시 1차 유효성 검사 */
function onA07_Register_Submit_Click(event: Event): void {
event.preventDefault();
[companyField, nameField, emailField, passwordField].forEach((f) => f.setError());
const company = companyField.input.value;
const name = nameField.input.value;
const email = emailField.input.value;
const password = passwordField.input.value;
// 필수값 검사
let hasError = false;
if (isBlank(company)) {
companyField.setError(L("A07_Register_Error_Required"));
hasError = true;
}
if (isBlank(name)) {
nameField.setError(L("A07_Register_Error_Required"));
hasError = true;
}
if (isBlank(email)) {
emailField.setError(L("A07_Register_Error_Required"));
hasError = true;
}
if (isBlank(password)) {
passwordField.setError(L("A07_Register_Error_Required"));
hasError = true;
}
if (hasError) return;
// 형식 검사
if (!isValidEmail(email)) {
emailField.setError(L("A07_Register_Error_Email"));
return;
}
if (!hasMinLength(password, PASSWORD_MIN_LENGTH)) {
passwordField.setError(L("A07_Register_Error_Password"));
return;
}
// TODO: POST /api/a07/register 연동. 성공 시 로그인 페이지로.
navigateTo(ROUTES.A06_LOGIN);
}
form.addEventListener("submit", onA07_Register_Submit_Click);
form.append(companyField.root, nameField.root, emailField.root, passwordField.root, submitBtn);
// 로그인 링크
const toLogin = createButton({
label: L("A07_Register_ToLogin"),
variant: "ghost",
onClick: () => navigateTo(ROUTES.A06_LOGIN),
});
toLogin.classList.add("a07-register__link");
card.append(title, subtitle, form, toLogin);
page.append(card);
root.append(page);
}
+5 -46
View File
@@ -1,6 +1,6 @@
/* =============================================================================
* A07_Register_UI_Style.css
* 회원가입 페이지 고유 스타일 — theme.css 변수만 참조 (하드코딩 금지).
* 회원가입 페이지 고유 배치 — 카드 · 체크 · 접이 겉은 공용 부품이 냄.
* ========================================================================== */
.a07-register {
@@ -13,49 +13,22 @@
.a07-register__card {
width: 100%;
max-width: 420px;
display: flex;
flex-direction: column;
gap: var(--spacing-16);
padding: var(--spacing-40);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
box-shadow: var(--shadow-sm);
}
.a07-register__title {
font-size: var(--text-heading-sm);
line-height: var(--leading-heading-sm);
color: var(--color-deep-iris);
color: var(--color-text);
text-align: center;
margin: 0;
}
.a07-register__subtitle {
font-size: var(--text-body-sm);
color: var(--color-slate);
text-align: center;
margin: 0 0 var(--spacing-8);
}
.a07-register__form {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
}
.a07-register__submit {
width: 100%;
margin-top: var(--spacing-8);
}
.a07-register__link {
width: 100%;
}
.a07-register__check {
display: flex;
align-items: center;
gap: var(--spacing-8);
color: var(--color-text);
font-size: var(--text-body-sm);
}
.a07-register__agreement {
border: 1px solid var(--color-border);
@@ -64,17 +37,9 @@
}
.a07-register__agreement-summary {
display: flex;
align-items: center;
gap: var(--spacing-8);
padding: var(--spacing-12) var(--spacing-16);
background-color: var(--color-canvas);
cursor: pointer;
user-select: none;
}
.a07-register__agreement-summary input[type="checkbox"] {
flex-shrink: 0;
}
.a07-register__agreement-label {
@@ -83,20 +48,14 @@
color: var(--color-text);
}
.a07-register__agreement-details {
border-top: 1px solid var(--color-border);
}
.a07-register__agreement-details[open] {
padding: var(--spacing-12) var(--spacing-16);
}
.a07-register__agreement-content {
max-height: 200px;
overflow-y: auto;
padding: var(--spacing-12) var(--spacing-16);
border-top: 1px solid var(--color-border);
white-space: pre-wrap;
word-break: break-word;
font-size: var(--text-body-sm);
color: var(--color-slate);
color: var(--color-text-secondary);
line-height: var(--leading-relaxed);
}
+13 -57
View File
@@ -5,13 +5,14 @@
* 제약 준수:
* - 모든 문구는 ui_template_locale.ts 참조 (하드코딩 금지).
* - 색상/간격은 A08_Support_UI_Style.css + theme.css 변수만 사용.
* - 공통 컴포넌트(createButton/createInputField) 우선 사용.
* - 공통 부품(createCard/createBanner/createInputField/createButton) 우선 사용.
* - 이벤트 핸들러는 on[페이지]_[기능]_[액션] 명명.
* - 백엔드 전송 전 1차 유효성 검사 (frontend.md §4).
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, createInputField } from "@ui/ui_template_elements";
import { createBanner } from "@ui/ui_template_banner";
import { createButton, createCard, createInputField } from "@ui/ui_template_elements";
import { isBlank, isValidEmail } from "@util/common_util_validate";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import "./A08_Support_UI_Style.css";
@@ -21,48 +22,6 @@ function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** 문의내용 textarea 핸들 (createInputField와 유사한 형태로 에러 슬롯 포함) */
interface TextAreaHandle {
root: HTMLDivElement;
textarea: HTMLTextAreaElement;
setError: (msg?: string) => void;
}
/** 여러 줄 입력용 textarea 필드 생성 (공통 .ui-field/.ui-input 스타일 상속). */
function createTextAreaField(label: string, placeholder: string): TextAreaHandle {
const root = document.createElement("div");
root.className = "ui-field";
const labelEl = document.createElement("label");
labelEl.className = "ui-field__label";
labelEl.textContent = label;
const textarea = document.createElement("textarea");
textarea.className = "ui-input a08-support__textarea";
textarea.placeholder = placeholder;
textarea.rows = 6;
const errorSlot = document.createElement("span");
errorSlot.className = "ui-field__error";
errorSlot.setAttribute("role", "alert");
root.append(labelEl, textarea, errorSlot);
const setError = (msg?: string): void => {
if (msg) {
textarea.classList.add("ui-input--error");
errorSlot.textContent = msg;
errorSlot.classList.add("is-visible");
} else {
textarea.classList.remove("ui-input--error");
errorSlot.textContent = "";
errorSlot.classList.remove("is-visible");
}
};
return { root, textarea, setError };
}
/* -----------------------------------------------------------------------------
* 페이지 진입점
* -------------------------------------------------------------------------- */
@@ -70,9 +29,6 @@ export function renderA08Support(root: HTMLElement): void {
const page = document.createElement("div");
page.className = "a08-support";
const card = document.createElement("div");
card.className = "a08-support__card";
const title = document.createElement("h1");
title.className = "a08-support__title";
title.textContent = L("A08_Support_Title");
@@ -82,9 +38,7 @@ export function renderA08Support(root: HTMLElement): void {
subtitle.textContent = L("A08_Support_Subtitle");
// 성공 메시지 슬롯 (제출 성공 시 표시)
const successMsg = document.createElement("p");
successMsg.className = "a08-support__success";
successMsg.textContent = L("A08_Support_Success");
const successMsg = createBanner({ tone: "success", text: L("A08_Support_Success") });
successMsg.hidden = true;
// 입력 필드
@@ -113,10 +67,11 @@ export function renderA08Support(root: HTMLElement): void {
type: "text",
required: true,
});
const messageField = createTextAreaField(
L("A08_Support_Field_Message"),
L("A08_Support_Field_Message_Placeholder"),
);
const messageField = createInputField({
label: L("A08_Support_Field_Message"),
placeholder: L("A08_Support_Field_Message_Placeholder"),
multiline: 6,
});
const form = document.createElement("form");
form.className = "a08-support__form";
@@ -142,7 +97,7 @@ export function renderA08Support(root: HTMLElement): void {
const email = emailField.input.value;
const phone = phoneField.input.value;
const subject = subjectField.input.value;
const message = messageField.textarea.value;
const message = messageField.input.value;
// 필수값 검사
let hasError = false;
@@ -184,7 +139,7 @@ export function renderA08Support(root: HTMLElement): void {
throw new Error(error.detail ?? "요청 실패");
}
form.reset();
messageField.textarea.value = "";
messageField.input.value = "";
successMsg.hidden = false;
} catch (err) {
const msg = err instanceof Error ? err.message : "오류 발생";
@@ -203,7 +158,8 @@ export function renderA08Support(root: HTMLElement): void {
submitBtn,
);
card.append(title, subtitle, successMsg, form);
const card = createCard({ body: [title, subtitle, successMsg, form] });
card.classList.add("a08-support__card");
page.append(card);
root.append(page);
+3 -25
View File
@@ -1,6 +1,6 @@
/* =============================================================================
* A08_Support_UI_Style.css
* 기술지원 요청 페이지 고유 스타일 — theme.css 변수만 참조 (하드코딩 금지).
* 기술지원 요청 페이지 고유 배치 — 카드 · 알림 · 칸 겉은 공용 부품이 냄.
* ========================================================================== */
.a08-support {
@@ -13,50 +13,28 @@
.a08-support__card {
width: 100%;
max-width: 520px;
display: flex;
flex-direction: column;
gap: var(--spacing-16);
padding: var(--spacing-40);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
box-shadow: var(--shadow-sm);
}
.a08-support__title {
font-size: var(--text-heading-sm);
line-height: var(--leading-heading-sm);
color: var(--color-deep-iris);
color: var(--color-text);
text-align: center;
margin: 0;
}
.a08-support__subtitle {
font-size: var(--text-body-sm);
color: var(--color-slate);
color: var(--color-text-secondary);
text-align: center;
margin: 0 0 var(--spacing-8);
}
.a08-support__success {
margin: 0;
padding: var(--spacing-16);
text-align: center;
color: var(--color-success);
background-color: var(--color-mist-violet);
border-radius: var(--radius-cards);
font-size: var(--text-body-sm);
}
.a08-support__form {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
}
.a08-support__textarea {
resize: vertical;
min-height: 120px;
font-family: inherit;
}
.a08-support__submit {
width: 100%;
margin-top: var(--spacing-8);
+5 -4
View File
@@ -1,3 +1,4 @@
import { createCard } from "@ui/ui_template_elements";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { SECURITY_TERMS_VERSION, securityTerms } from "./A09_Security_Terms";
import "./A09_Security_UI_Style.css";
@@ -5,13 +6,11 @@ import "./A09_Security_UI_Style.css";
const L = (key: keyof typeof ui_locales): string => ui_locales[key][currentLanguageIndex];
export function renderA09Security(root: HTMLElement): void {
const page = document.createElement("article");
page.className = "a09-security";
const title = document.createElement("h1");
title.textContent = L("A09_Security_Title");
const version = document.createElement("p");
version.textContent = `${L("A09_Security_Version")} ${SECURITY_TERMS_VERSION}`;
page.append(title, version);
const blocks: HTMLElement[] = [title, version];
const sections = [
["A09_Security_Terms", securityTerms.terms],
["A09_Security_Privacy", securityTerms.privacy],
@@ -24,7 +23,9 @@ export function renderA09Security(root: HTMLElement): void {
const paragraph = document.createElement("p");
paragraph.textContent = body[currentLanguageIndex];
section.append(heading, paragraph);
page.append(section);
blocks.push(section);
}
const page = createCard({ body: blocks });
page.classList.add("a09-security");
root.append(page);
}
+5 -4
View File
@@ -1,12 +1,13 @@
/* 약관 문서 — 카드 겉은 공용 부품(카드) · 여기는 폭 · 여백 · 글 흐름만 */
.a09-security {
max-width: 800px;
margin: var(--spacing-64) auto;
padding: var(--spacing-40);
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
box-shadow: var(--shadow-sm);
}
.a09-security > .ui-card__body {
display: block;
}
.a09-security section {
+8
View File
@@ -495,6 +495,14 @@ export async function fetchAuditLogs(): Promise<AuditLog[]> {
return data.items;
}
/** 시스템 로그 한 쪽 — 최신순 · total = 전체 줄 수 */
export async function fetchAuditLogPage(
page: number,
limit = 100,
): Promise<{ items: AuditLog[]; total: number }> {
return request(`/dashboard/admin/audit-logs?page=${page}&limit=${limit}`);
}
export async function fetchSystemResources(days = 30): Promise<ResourceData> {
return request<ResourceData>(`/dashboard/admin/resources?days=${encodeURIComponent(days)}`);
}
+74
View File
@@ -0,0 +1,74 @@
/* =============================================================================
* B01_Dashboard_Api_Home.ts
* 대시보드 홈 API(PLAN 547) — GET /dashboard/home · /dashboard/projects/summary
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
/** 프로젝트 카드 요약(547-1 · `B01_Dashboard_Summary.py`) — 값이 없으면 칸이 빠짐 */
export interface ProjectSummary {
id: string;
name: string;
kind: string;
region?: string | null;
company_id?: number | null;
stage_index: number;
stage_total: number;
stage_name: string;
done: boolean;
updated_at?: string | null;
stale_days?: number | null;
owner_user_id?: number | null;
owner_names: string[];
/** 노선 중심선 0~1(y 아래로) · 없으면 null */
thumb: [number, number][] | null;
metrics: { length_m?: number; stations?: number; structures?: number };
}
export interface PlanTodo {
project_id: string;
project_name: string;
year: number;
linked_project_id: string | null;
}
export interface HomeData {
role: "SYSTEM_ADMIN" | "FOREST" | "ADMIN" | "USER";
/** 진행 중(완료 아님) · 최근 수정 순 · 썸네일은 앞 8 개만 */
projects: ProjectSummary[];
/** 홈 범위 프로젝트 전체(완료 포함) — 0 = 처음 */
project_total: number;
todos: {
new_plans: PlanTodo[];
updated_plans: PlanTodo[];
join_requests?: number;
expiring_temp: { batch_id: string; name: string; expires_at: string; days_left: number }[];
temp_count: number;
};
company?: { id: number; name: string | null; member_count: number | null };
forest?: {
plan_status: { draft: number; analyzed: number; confirmed: number };
assign_waiting: number;
};
system?: {
companies: number;
users: number;
company_names: Record<string, string>;
server: { cpu: number | null; memory: number | null; disk: number | null };
};
}
async function getJson<T>(path: string): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" });
if (!response.ok) throw new Error(`홈 자료를 불러오지 못했습니다 (${response.status})`);
return (await response.json()) as T;
}
export const fetchHome = (): Promise<HomeData> => getJson<HomeData>("/dashboard/home");
/** 카드 요약 — ids 없으면 보이는 프로젝트 전부(최근 수정 순) */
export async function fetchProjectSummaries(ids?: string[]): Promise<ProjectSummary[]> {
const query = ids?.length ? `?ids=${encodeURIComponent(ids.join(","))}` : "";
return (await getJson<{ projects: ProjectSummary[] }>(`/dashboard/projects/summary${query}`))
.projects;
}
@@ -0,0 +1,84 @@
/* =============================================================================
* B01_Dashboard_Api_Projects.ts
* 프로젝트 목록 · 상세 API(요구 369~374 · PLAN 548) — 서버 `B01_Dashboard_Router_Overview.py`.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import type { ProjectItem } from "./B01_Dashboard_Api_Fetch";
/** 칸반 칸 — 서버 `board_column` 과 같은 열쇠 */
export type BoardColumn = "start" | "design" | "review" | "confirm" | "done";
export interface OverviewProject extends ProjectItem {
user_id?: number | null;
project_year_end?: number | null;
design_speed_kph?: number | null;
created_at?: string | null;
manager_name?: string | null;
source_project_id?: string | null;
stage_total: number;
board: BoardColumn;
idle_days: number | null;
stalled: boolean;
/** 썸네일 선(0~1 칸 · y 아래로) — 547-1 카드 요약 */
thumb?: number[][] | null;
metrics?: { length_m?: number; stations?: number; structures?: number };
}
/** 지도 선 — 위경도 [lat, lon] */
export interface RouteLine {
points: [number, number][];
}
export interface ProjectLink {
kind: "source" | "derived" | "plan";
project_id: string;
name: string;
project_kind?: string | null;
note?: string | null;
}
export interface ProjectOutput {
key: string;
label: string;
url: string;
}
export interface ProjectChange {
at: string;
who?: string | null;
action: string;
stage_no?: number;
}
export interface ProjectDetail {
project: OverviewProject;
people: { pm?: string | null; field_lead?: string | null; designer?: string | null };
route: RouteLine | null;
numbers: { key: string; value: number; unit?: string }[];
links: ProjectLink[];
outputs: ProjectOutput[];
changes: ProjectChange[];
}
async function getJson<T>(path: string): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" });
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.detail || `요청 실패: ${response.status}`);
return data as T;
}
export async function fetchOverviewProjects(
scope: "mine" | "company",
companyId?: number | null,
): Promise<OverviewProject[]> {
const company = companyId ? `&company_id=${companyId}` : "";
const data = await getJson<{ projects: OverviewProject[] }>(
`/dashboard/overview/projects?scope=${scope}${company}`,
);
return data.projects ?? [];
}
export function fetchProjectDetail(projectId: string): Promise<ProjectDetail> {
return getJson<ProjectDetail>(`/dashboard/overview/projects/${encodeURIComponent(projectId)}`);
}
+205
View File
@@ -0,0 +1,205 @@
"""B01 대시보드 홈 API (PLAN 547) — 프로젝트 카드 요약 · 역할별 홈."""
from datetime import datetime, timedelta
from typing import Any
import aiomysql
from fastapi import APIRouter, Depends, Query
from common_util.common_util_auth import is_forest_email, verify_session
from config.config_db import get_db_pool
from .B01_Dashboard_Repository import get_system_resources
from .B01_Dashboard_Summary import project_summaries
router = APIRouter(prefix="/api/dashboard", tags=["B01_Dashboard"])
SUMMARY_LIMIT = 200
# 홈 카드 썸네일은 앞 8 개만(9 넘음 꼴은 그림 대신 숫자 · 그래프)
HOME_THUMBS = 8
EXPIRE_SOON_DAYS = 7
async def visible_project_ids(session: dict[str, Any]) -> list[str]:
"""보이는 프로젝트(최근 수정 순) — 시스템 관리자 = 전부 · 회사 = 회사 전체 · 없으면 내 것."""
where, args = "p.deleted_at IS NULL", ()
if session["role"] != "SYSTEM_ADMIN":
if session.get("company_id"):
where, args = where + " AND p.company_id = %s", (int(session["company_id"]),)
else:
where, args = where + " AND p.user_id = %s", (int(session["user_id"]),)
return await _ids(where, args)
async def _ids(where: str, args: tuple) -> list[str]:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
f"SELECT p.id FROM projects p WHERE {where} "
"ORDER BY p.updated_at DESC, p.created_at DESC",
args,
)
return [str(r["id"]) for r in await cursor.fetchall()]
@router.get("/projects/summary")
async def projects_summary(
ids: str | None = Query(None, max_length=8000, description="쉼표로 이은 프로젝트 id"),
session: dict[str, Any] = Depends(verify_session),
):
"""카드 요약 — ids 없으면 보이는 프로젝트 전부(최근 수정 순) · 안 보이는 id 는 빠짐."""
visible = await visible_project_ids(session)
if ids:
allowed = set(visible)
wanted = dict.fromkeys(pid.strip() for pid in ids.split(","))
visible = [pid for pid in wanted if pid in allowed]
return {"status": "success", "projects": await project_summaries(visible[:SUMMARY_LIMIT])}
def home_role(session: dict[str, Any]) -> str:
"""홈 역할 — 셸 등록부 `roleOf` 와 같은 차례(시스템 · 회사 관리자 · 산림청 *.go.kr · 그 밖)."""
if session["role"] in ("SYSTEM_ADMIN", "ADMIN"):
return session["role"]
return "FOREST" if is_forest_email(session.get("email")) else "USER"
async def home_project_ids(session: dict[str, Any], role: str) -> list[str]:
"""홈 프로젝트 — 시스템 관리자 = 전부 · 회사 관리자 = 회사 · 그 밖 = 내가 만든 · 참여한 것."""
if role in ("SYSTEM_ADMIN", "ADMIN") or not session.get("company_id"):
return await visible_project_ids(session)
me = int(session["user_id"])
return await _ids(
"p.deleted_at IS NULL AND p.company_id = %s AND (p.user_id = %s OR EXISTS "
"(SELECT 1 FROM project_members m WHERE m.project_id = p.id AND m.user_id = %s))",
(int(session["company_id"]), me, me),
)
async def _one(cursor: Any, sql: str, args: tuple = ()) -> dict[str, Any]:
await cursor.execute(sql, args)
return await cursor.fetchone() or {}
async def _todos(cursor: Any, session: dict[str, Any], role: str) -> dict[str, Any]:
"""처리할 일 · 보관함 — 받은 예정노선(새로 · 업데이트) · 가입 요청 · 곧 만료 보관 파일."""
from D01_PlanRegister.D01_PlanRegister_Router import assigned_plan_routes
company_id = session.get("company_id")
plans = (await assigned_plan_routes(session))["routes"] if company_id else []
pick = ("project_id", "project_name", "year", "linked_project_id")
todos: dict[str, Any] = {
"new_plans": [
{k: p[k] for k in pick}
for p in plans
if not p["linked_project_id"] and p["confirmed_at"]
],
"updated_plans": [{k: p[k] for k in pick} for p in plans if p["update_available"]],
}
if role == "SYSTEM_ADMIN" or (role == "ADMIN" and company_id):
where = "" if role == "SYSTEM_ADMIN" else " AND company_id = %s"
row = await _one(
cursor,
f"SELECT COUNT(*) AS n FROM join_requests WHERE status = 'PENDING'{where}",
() if role == "SYSTEM_ADMIN" else (int(company_id),),
)
todos["join_requests"] = int(row.get("n") or 0)
await cursor.execute(
"""SELECT id, name, status, expires_at FROM temp_upload_batches
WHERE user_id = %s AND status <> 'linked'
AND (expires_at IS NULL OR expires_at > NOW())
ORDER BY expires_at IS NULL, expires_at""",
(int(session["user_id"]),),
)
batches = list(await cursor.fetchall())
soon = datetime.now() + timedelta(days=EXPIRE_SOON_DAYS)
todos["expiring_temp"] = [
{
"batch_id": str(b["id"]),
"name": b["name"],
"expires_at": b["expires_at"].isoformat(),
"days_left": max(0, (b["expires_at"] - datetime.now()).days),
}
for b in batches
if b["expires_at"] and b["expires_at"] <= soon
]
todos["temp_count"] = len(batches)
return todos
async def _company(cursor: Any, company_id: int) -> dict[str, Any]:
row = await _one(
cursor,
"""SELECT c.name, (SELECT COUNT(*) FROM users u
WHERE u.company_id = c.id AND u.deleted_at IS NULL) AS member_count
FROM companies c WHERE c.id = %s""",
(company_id,),
)
return {"id": company_id, "name": row.get("name"), "member_count": row.get("member_count")}
async def _forest(cursor: Any, company_id: int) -> dict[str, Any]:
"""예정노선 연도 상태별 수(분석 전 · 분석됨 · 확정) · 업체 지정 대기(확정 · 업체 없음)."""
await cursor.execute(
"""SELECT y.route_json IS NOT NULL AS analyzed, y.confirmed_at IS NOT NULL AS confirmed,
EXISTS (SELECT 1 FROM plan_route_assignees a
WHERE a.project_id = y.project_id AND a.year = y.year) AS assigned
FROM plan_route_years y
JOIN projects p ON p.id = y.project_id AND p.deleted_at IS NULL
WHERE p.company_id = %s""",
(company_id,),
)
status = {"draft": 0, "analyzed": 0, "confirmed": 0}
waiting = 0
for r in await cursor.fetchall():
key = "confirmed" if r["confirmed"] else "analyzed" if r["analyzed"] else "draft"
status[key] += 1
waiting += bool(r["confirmed"] and not r["assigned"])
return {"plan_status": status, "assign_waiting": waiting}
async def _system(cursor: Any) -> dict[str, Any]:
row = await _one(
cursor,
"""SELECT (SELECT COUNT(*) FROM companies WHERE deleted_at IS NULL) AS companies,
(SELECT COUNT(*) FROM users WHERE deleted_at IS NULL) AS users""",
)
await cursor.execute("SELECT id, name FROM companies WHERE deleted_at IS NULL")
names = {int(r["id"]): r["name"] for r in await cursor.fetchall()}
current = (await get_system_resources(days=1))["current"]
server = {k: current.get(f"{k}_usage_percent") for k in ("cpu", "memory", "disk")}
return {
"companies": int(row.get("companies") or 0),
"users": int(row.get("users") or 0),
"company_names": names,
"server": server,
}
@router.get("/home")
async def dashboard_home(session: dict[str, Any] = Depends(verify_session)):
"""역할별 홈 — 진행 중 프로젝트 카드(최근 수정 순) · 처리할 일 · 회사 · 산림청 · 시스템 요약.
단계 분포 · 팀원별 · 종류별 · 회사별 수는 화면이 `projects` 로 셈(같은 목록 한 벌).
"""
role = home_role(session)
ids = await home_project_ids(session, role)
cards = [s for s in await project_summaries(ids[:SUMMARY_LIMIT]) if not s["done"]]
for card in cards[HOME_THUMBS:]:
card["thumb"] = None
company_id = session.get("company_id")
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
body: dict[str, Any] = {
"status": "success",
"role": role,
"projects": cards,
"project_total": len(ids),
"todos": await _todos(cursor, session, role),
}
if role == "ADMIN" and company_id:
body["company"] = await _company(cursor, int(company_id))
if role == "FOREST" and company_id:
body["forest"] = await _forest(cursor, int(company_id))
if role == "SYSTEM_ADMIN":
body["system"] = await _system(cursor)
return body
@@ -0,0 +1,327 @@
"""B01 대시보드 프로젝트 목록 · 상세 API (요구 369~374 · PLAN 548).
목록 = 카드 · 표 · 칸반 한 벌(칸반 칸은 단계 상태로 저절로 · `board_column`).
상세 = 머리 · 단계 · 지도 선 · 핵심 숫자 · 기본 정보 · 연결 · 산출물 · 최근 변경(있는 것만).
"""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import Any
import aiomysql
from fastapi import APIRouter, Depends, HTTPException, Query
from common_util.common_util_auth import verify_session
from config.config_db import get_db_pool
from .B01_Dashboard_Repository import _project_rows
from .B01_Dashboard_Summary import project_summaries
router = APIRouter(prefix="/api/dashboard/overview", tags=["B01_Dashboard"])
#: 종류별 단계 수 — 화면 단계 막대(`group_workflow.ts` · `WORKFLOW_STEP_ROUTES`)와 같음
STAGE_TOTAL = {"ROAD": 7, "PLAN": 3, "STABILITY": 6}
#: 손대지 않은 날이 이만큼 넘으면 「오래 멈춤」
STALE_DAYS = 14
_COLUMNS = """p.id, p.user_id, p.company_id, p.name, p.region, p.road_type, p.project_kind,
p.project_year, p.project_year_end, p.design_speed_kph,
p.estimated_length_m, p.route_start_m, p.route_end_m,
p.memo, p.status, p.updated_at, p.created_at, p.crs_epsg,
p.client_org, p.project_number, p.work_amount, p.design_date,
p.pm_user_id, p.field_lead_user_id, p.designer_user_id,
p.logo_asset_id, p.signature_asset_id, p.source_project_id,
u.name AS owner_name, COALESCE(pm.name, u.name) AS manager_name"""
_FROM = """FROM projects p LEFT JOIN users u ON u.id = p.user_id
LEFT JOIN users pm ON pm.id = p.pm_user_id"""
def board_column(kind: str | None, stages: list[dict[str, Any]]) -> str:
"""칸반 칸 — start 시작 전 · design 설계 중 · review 검토 · confirm 확정 대기 · done 완료.
review = 앞 단계가 바뀌어 다시 볼 단계(STALE) · 실패(FAILED)가 있음.
confirm = 앞 단계를 다 마치고 마지막 단계만 남음.
"""
total = STAGE_TOTAL.get(kind or "ROAD", 0)
states = {int(s["stage_no"]): s["state"] for s in stages}
if total and states.get(total - 1) == "COMPLETE":
return "done"
if all(state == "NOT_STARTED" for state in states.values()):
return "start"
if any(state in ("STALE", "FAILED") for state in states.values()):
return "review"
done_head = 0
while states.get(done_head) == "COMPLETE":
done_head += 1
if total and done_head >= total - 1:
return "confirm"
return "design"
def idle_days(updated_at: datetime | None, now: datetime | None = None) -> int | None:
"""마지막 수정 뒤 지난 날 수."""
if updated_at is None:
return None
return ((now or datetime.now()) - updated_at).days
def is_mine(row: dict[str, Any], user_id: int) -> bool:
"""내 것 = 만든 사람 · 참여자 · 책임 · 현장 · 설계 담당."""
people = (row.get("user_id"), row.get("pm_user_id"), row.get("field_lead_user_id"))
return user_id in (*people, row.get("designer_user_id")) or user_id in row.get(
"member_user_ids", []
)
def _decorate(row: dict[str, Any]) -> dict[str, Any]:
stages = row.get("workflow_state", {}).get("stages", [])
kind = row.get("project_kind") or "ROAD"
days = idle_days(row.get("updated_at"))
return {
**row,
"project_kind": kind,
"stage_total": STAGE_TOTAL.get(kind, 0),
"board": board_column(kind, stages),
"idle_days": days,
"stalled": days is not None and days > STALE_DAYS,
}
def _scope_where(session: dict[str, Any], company_id: int | None) -> tuple[str, tuple[Any, ...]]:
"""볼 범위 — 시스템 관리자 = 고른 회사(없음 = 전체) · 회사 사람 = 자기 회사 · 그 밖 = 내 것."""
if session["role"] == "SYSTEM_ADMIN":
return ("p.company_id = %s", (company_id,)) if company_id else ("1 = 1", ())
own = session.get("company_id")
if company_id is not None and int(company_id) != int(own or 0):
raise HTTPException(status_code=403, detail="다른 회사의 자료는 볼 수 없습니다.")
if own:
return "p.company_id = %s", (int(own),)
return "p.user_id = %s", (int(session["user_id"]),)
@router.get("/projects")
async def overview_projects(
scope: str = Query("company", pattern="^(mine|company)$"),
company_id: int | None = Query(None, gt=0),
session: dict[str, Any] = Depends(verify_session),
):
where, args = _scope_where(session, company_id)
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
f"""SELECT {_COLUMNS} {_FROM}
WHERE {where} AND p.deleted_at IS NULL
ORDER BY p.updated_at DESC, p.created_at DESC""",
args,
)
rows = await _project_rows(cursor, await cursor.fetchall())
if scope == "mine":
rows = [r for r in rows if is_mine(r, int(session["user_id"]))]
summaries = {s["id"]: s for s in await project_summaries([str(r["id"]) for r in rows])}
return {
"status": "success",
"projects": [_with_summary(_decorate(r), summaries.get(str(r["id"]))) for r in rows],
}
def _with_summary(row: dict[str, Any], summary: dict[str, Any] | None) -> dict[str, Any]:
"""카드 요약(547-1) 의 썸네일 선 · 핵심 숫자를 붙임."""
summary = summary or {}
return {**row, "thumb": summary.get("thumb"), "metrics": summary.get("metrics", {})}
async def _visible_project(cursor: Any, session: dict[str, Any], project_id: str) -> dict:
await cursor.execute(
f"""SELECT {_COLUMNS}, p.storage_path,
fl.name AS field_lead_name, ds.name AS designer_name {_FROM}
LEFT JOIN users fl ON fl.id = p.field_lead_user_id
LEFT JOIN users ds ON ds.id = p.designer_user_id
WHERE p.id = %s AND p.deleted_at IS NULL""",
(project_id,),
)
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
if session["role"] != "SYSTEM_ADMIN":
own = session.get("company_id")
allowed = (
int(row["company_id"] or 0) == int(own)
if own
else int(row["user_id"]) == int(session["user_id"])
)
if not allowed:
raise HTTPException(status_code=403, detail="이 프로젝트를 볼 권한이 없습니다.")
return row
def _route_lonlat(root: Path, limit: int = 300) -> tuple[list[list[float]], float | None]:
"""지도 선 = 설계 노선(작업본 → 초기값 → 예상노선) [lat, lon] · 연장 — 못 읽으면 ([], None)."""
from pyproj import Transformer
from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj
from common_util.common_util_route_geometry import (
expected_route_csv_path,
planned_route_initial_path,
planned_route_working_path,
read_planned_route_csv,
)
for path in (
planned_route_working_path(root),
planned_route_initial_path(root),
expected_route_csv_path(root),
):
route = read_planned_route_csv(path) if path.is_file() else None
if route is None or len(route.vertices) < 2:
continue
picked = route.vertices[:: max(1, len(route.vertices) // limit)]
if picked[-1] is not route.vertices[-1]:
picked.append(route.vertices[-1])
try:
crs = route.crs_input or project_epsg_from_prj(root)
to_wgs = Transformer.from_crs(crs, "EPSG:4326", always_xy=True)
lonlat = [to_wgs.transform(v.x, v.y) for v in picked]
except Exception: # 좌표계 못 읽음 = 지도 없음 · 연장은 그대로
return [], route.vertices[-1].chainage_m
points = [[round(lat, 7), round(lon, 7)] for lon, lat in lonlat]
return points, route.vertices[-1].chainage_m
return [], None
def _numbers(metrics: dict[str, Any]) -> list[dict[str, Any]]:
"""핵심 숫자 — 카드 요약 값 가운데 있는 것만(연장 · 측점 · 구조물)."""
keys = (
("length_m", "length", "m"),
("stations", "stations", None),
("structures", "structures", None),
)
return [
{"key": key, "value": metrics[name], **({"unit": unit} if unit else {})}
for name, key, unit in keys
if metrics.get(name)
]
def project_outputs(kind: str, stages: list[dict[str, Any]]) -> list[dict[str, str]]:
"""산출물 — 인쇄 · 내려받기 화면(b09-print)에서 지금 받을 수 있는 것만(임도설계).
도면 = 횡단(3) 마침 · 수량 = 수량(5) 마침 · 내역 = 원가(6) 들어감.
"""
if kind != "ROAD":
return []
state = {int(s["stage_no"]): s["state"] for s in stages}
out = []
if state.get(3) == "COMPLETE":
out.append({"key": "drawings", "label": "도면 PDF"})
if state.get(5) == "COMPLETE":
out.append({"key": "quantity", "label": "수량 xlsx"})
if state.get(6, "NOT_STARTED") != "NOT_STARTED":
out.append({"key": "cost", "label": "내역 xlsx"})
return out
def _link(kind: str, row: dict[str, Any], note: str | None = None) -> dict[str, Any]:
return {
"kind": kind,
"project_id": row["id"],
"name": row["name"],
"project_kind": row.get("project_kind") or "PLAN",
"note": note,
}
async def _links(cursor: Any, row: dict[str, Any]) -> list[dict[str, Any]]:
"""연결된 것 — 원본 · 이 프로젝트에서 나온 것 · 연결한 예정노선(원본과 같으면 한 번만)."""
links: list[dict[str, Any]] = []
source = row.get("source_project_id")
if source:
await cursor.execute(
"SELECT id, name, project_kind FROM projects WHERE id = %s AND deleted_at IS NULL",
(source,),
)
links += [_link("source", r) for r in await cursor.fetchall()]
await cursor.execute(
"""SELECT id, name, project_kind FROM projects
WHERE source_project_id = %s AND deleted_at IS NULL ORDER BY created_at""",
(row["id"],),
)
links += [_link("derived", r) for r in await cursor.fetchall()]
await cursor.execute(
"""SELECT p.id, p.name, a.year FROM plan_route_assignees a
JOIN projects p ON p.id = a.project_id AND p.deleted_at IS NULL
WHERE a.linked_project_id = %s ORDER BY a.year""",
(row["id"],),
)
for r in await cursor.fetchall():
if r["id"] == source:
links[0]["note"] = f"{r['year']} 년"
else:
links.append(_link("plan", r, f"{r['year']} 년"))
return links
#: 최근 변경 — 감사 기록 중 사람 글로 보일 것
_AUDIT_ACTIONS = ("PROJECT_CREATE", "PROJECT_UPDATE")
async def _changes(cursor: Any, project_id: str, stages: list[dict[str, Any]]) -> list[dict]:
"""최근 변경 — 감사 기록(만듦 · 정보 고침) + 단계 마친 때 · 새것부터 8 개."""
await cursor.execute(
"""SELECT l.action, DATE_FORMAT(l.timestamp, '%%Y-%%m-%%d %%H:%%i:%%s') AS at,
u.name AS who
FROM system_audit_logs l LEFT JOIN users u ON u.id = l.user_id
WHERE l.resource_type = 'project' AND l.resource_ref = %s
AND l.action IN (%s, %s)
ORDER BY l.timestamp DESC LIMIT 8""",
(project_id, *_AUDIT_ACTIONS),
)
items: list[dict[str, Any]] = [dict(r) for r in await cursor.fetchall()]
items += [
{
"at": s["completed_at"].replace("T", " "),
"who": None,
"action": "STAGE_COMPLETE",
"stage_no": s["stage_no"],
}
for s in stages
if s.get("state") == "COMPLETE" and s.get("completed_at")
]
return sorted(items, key=lambda item: item["at"], reverse=True)[:8]
@router.get("/projects/{project_id}")
async def overview_project_detail(
project_id: str, session: dict[str, Any] = Depends(verify_session)
):
from common_util.common_util_storage import resolve_stored_project_path
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
raw = await _visible_project(cursor, session, project_id)
row = _decorate((await _project_rows(cursor, [raw]))[0])
links = await _links(cursor, row)
changes = await _changes(cursor, project_id, row["workflow_state"]["stages"])
try:
root = Path(resolve_stored_project_path(raw["storage_path"]))
except (ValueError, TypeError, AttributeError):
root = None
line = _route_lonlat(root)[0] if root else []
summary = (await project_summaries([project_id]) or [{}])[0]
people = {
"pm": row["manager_name"] if row.get("pm_user_id") else None,
"field_lead": row.pop("field_lead_name", None),
"designer": row.pop("designer_name", None),
}
row.pop("storage_path", None)
return {
"status": "success",
"project": row,
"people": people,
"route": {"points": line} if line else None,
"numbers": _numbers(summary.get("metrics", {})),
"links": links,
"outputs": project_outputs(row["project_kind"], row["workflow_state"]["stages"]),
"changes": changes,
}
+172
View File
@@ -0,0 +1,172 @@
"""프로젝트 카드 요약 (PLAN 547-1) — 홈 · 목록 · 상세 카드가 같이 씀.
단계 = 대시보드 표 단계 막대와 같은 값(`workflow_state.current_stage` · 종류별 단계 목록).
썸네일 = 최신 노선 경로 GeoJSON(B05 정본) 중심선을 100 점 안쪽으로 줄여 0~1 칸에 맞춤
(가로세로 비 유지 · 가운데 · y 는 화면 방향 = 아래로 커짐). 값이 없으면 칸을 뺌.
쿼리 = 프로젝트 · 단계 · 참여자 · 노선 · 측점 다섯 번(개수와 무관) + 노선 · 구조물 파일 읽기.
"""
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from typing import Any
import aiomysql
from config.config_db import get_db_pool
from M02_MasterTemplete.M02_Template_Migrate import project_root
from .B01_Dashboard_Repository import get_workflow_states_for_projects
THUMB_POINTS = 100
# 단계 이름 = 화면 단계 막대 글(ui_template_locale_common · _de) 한국어 · 모르는 종류 = 임도
STAGE_NAMES: dict[str, tuple[str, ...]] = {
"ROAD": ("파일입력", "전처리", "노선설계", "횡단설계", "상세설계", "수량산출", "원가계산"),
"PLAN": ("기준 지정", "노선 분석", "확정 · 내보내기"),
"STABILITY": ("지반 조건", "2D 해석", "3D 해석", "보고서", "암반 해석", "광역 위험"),
}
def stage_names(kind: str | None) -> tuple[str, ...]:
return STAGE_NAMES.get(kind or "ROAD", STAGE_NAMES["ROAD"])
def thumb_line(points: list[list[float]], limit: int = THUMB_POINTS) -> list[list[float]] | None:
"""중심선 → limit 점 안쪽 · 0~1 칸(가로세로 비 유지 · y 아래로). 두 점 못 되면 None."""
if len(points) < 2:
return None
if len(points) > limit:
last = len(points) - 1
points = [points[round(i * last / (limit - 1))] for i in range(limit)]
xs = [p[0] for p in points]
ys = [p[1] for p in points]
dx, dy = max(xs) - min(xs), max(ys) - min(ys)
span = max(dx, dy) or 1.0
ox, oy = (1 - dx / span) / 2, (1 - dy / span) / 2
return [
[round(ox + (x - min(xs)) / span, 4), round(1 - oy - (y - min(ys)) / span, 4)]
for x, y in zip(xs, ys, strict=True)
]
def _route_points(root: Path | None, route_data_path: str | None) -> list[list[float]]:
from B05_Profile.B05_Profile_Engine_Sections import _load_route_polyline
if root is None or not route_data_path:
return []
try:
return [[x, y] for x, y, _ in _load_route_polyline(root, route_data_path)]
except (OSError, ValueError):
return []
def _structure_count(root: Path | None) -> int | None:
from B05_Profile.B05_Profile_Structures_Repository import structures_file_path
if root is None:
return None
try:
with open(structures_file_path(str(root)), encoding="utf-8") as handle:
items = json.load(handle).get("structures")
except (OSError, ValueError, AttributeError):
return None
return len(items) if isinstance(items, list) else None
def _marks(values: list[Any]) -> str:
return ", ".join(["%s"] * len(values))
async def project_summaries(project_ids: list[str]) -> list[dict[str, Any]]:
"""프로젝트 여럿 → 카드 요약(넣은 차례 · 지운 · 없는 프로젝트는 빠짐)."""
ids = list(dict.fromkeys(str(pid) for pid in project_ids))
if not ids:
return []
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
f"""SELECT p.id, p.name, p.project_kind, p.region, p.updated_at, p.storage_path,
p.company_id, p.user_id, u.name AS owner_name
FROM projects p LEFT JOIN users u ON u.id = p.user_id
WHERE p.id IN ({_marks(ids)}) AND p.deleted_at IS NULL""",
tuple(ids),
)
rows = {str(r["id"]): r for r in await cursor.fetchall()}
states = await get_workflow_states_for_projects(cursor, list(rows))
await cursor.execute(
f"""SELECT m.project_id, u.name FROM project_members m
JOIN users u ON u.id = m.user_id AND u.deleted_at IS NULL
WHERE m.project_id IN ({_marks(ids)}) ORDER BY u.name""",
tuple(ids),
)
members: dict[str, list[str]] = {}
for r in await cursor.fetchall():
members.setdefault(str(r["project_id"]), []).append(r["name"])
# 최신 노선 = B05 get_latest_route 와 같은 차례(computed_at · id 내림)
await cursor.execute(
f"""SELECT id, project_id, total_length_m, route_data_path FROM routes
WHERE project_id IN ({_marks(ids)})
ORDER BY computed_at DESC, id DESC""",
tuple(ids),
)
routes: dict[str, dict[str, Any]] = {}
for r in await cursor.fetchall():
routes.setdefault(str(r["project_id"]), r)
stations: dict[int, int] = {}
route_ids = [int(r["id"]) for r in routes.values()]
if route_ids:
await cursor.execute(
f"""SELECT route_id, COUNT(*) AS n FROM cross_sections
WHERE route_id IN ({_marks(route_ids)}) GROUP BY route_id""",
tuple(route_ids),
)
stations = {int(r["route_id"]): int(r["n"]) for r in await cursor.fetchall()}
now = datetime.now()
result = []
for pid in ids:
row = rows.get(pid)
if row is None:
continue
state = states.get(pid, {"current_stage": 0, "stages": []})
names = stage_names(row.get("project_kind"))
index = min(int(state["current_stage"]), len(names) - 1)
stages = state["stages"]
root = project_root(row.get("storage_path"))
route = routes.get(pid)
metrics: dict[str, Any] = {}
if route and route.get("total_length_m") is not None:
metrics["length_m"] = round(float(route["total_length_m"]), 1)
if route and stations.get(int(route["id"])):
metrics["stations"] = stations[int(route["id"])]
structures = _structure_count(root)
if structures is not None:
metrics["structures"] = structures
owners = [row["owner_name"]] if row.get("owner_name") else []
owners += [name for name in members.get(pid, []) if name and name not in owners]
updated = row.get("updated_at")
result.append(
{
"id": pid,
"name": row["name"],
"kind": row.get("project_kind") or "ROAD",
"region": row.get("region"),
"company_id": row.get("company_id"),
"stage_index": index,
"stage_total": len(names),
"stage_name": names[index],
"done": bool(stages) and all(s["state"] == "COMPLETE" for s in stages),
"updated_at": updated.isoformat() if updated else None,
"stale_days": (now - updated).days if updated else None,
"owner_user_id": row.get("user_id"),
"owner_names": owners,
"thumb": thumb_line(
_route_points(root, route.get("route_data_path")) if route else []
),
"metrics": metrics,
}
)
return result
@@ -1,17 +1,10 @@
/* =============================================================================
* B01_Dashboard_UI_AssignedPlans.ts
* 대시보드 「받은 예정노선」 구역(요구서 345 2-2 · PLAN 528-2) — 우리 회사에 지정된 예정노선 연도.
* 열 = 예정노선 · 연도 · 확정(KST) · 연결 상태(연결됨 · 미연결 · 업데이트 있음 배지) · [보기] [연결]
* [보기] = 그 예정노선 첫 단계(D02 · 공유 회사 = GET 만) · [연결] = 모달(`B01_Dashboard_UI_PlanLink`)
* 「받은 예정노선」 자료 읽기(요구서 345 2-2 · PLAN 528-2) — 우리 회사에 지정된 예정노선 연도.
* 화면 = `B01_Dashboard_UI_View_Assigned.ts` · [연결] 모달 = `B01_Dashboard_UI_PlanLink`
* ========================================================================== */
import { API_BASE_URL, ROUTES } from "@config/config_frontend";
import { createButton, createTag } from "@ui/ui_template_elements";
import { table, text } from "@ui/ui_template_general_blocks";
import { goToWorkflowStage } from "../A00_Common/b_workflow_nav";
import { kstText } from "../common_util/common_util_time_kst";
import { DASHBOARD_VISIBLE_ROWS, L } from "./B01_Dashboard_UI_Common";
import { openPlanLinkModal } from "./B01_Dashboard_UI_PlanLink";
import { API_BASE_URL } from "@config/config_frontend";
export interface AssignedPlan {
assignee_id: number;
@@ -32,54 +25,3 @@ export async function fetchAssignedPlans(): Promise<AssignedPlan[]> {
if (!response.ok) return [];
return ((await response.json()) as { routes?: AssignedPlan[] }).routes ?? [];
}
function linkState(plan: AssignedPlan): HTMLElement {
const cell = document.createElement("div");
cell.className = "b01-dashboard__actions";
cell.append(
createTag(
L(plan.linked_project_id ? "DE_Assigned_Linked" : "DE_Assigned_Unlinked"),
plan.linked_project_id ? "success" : "neutral",
),
);
if (plan.update_available) cell.append(createTag(L("DE_Assigned_Update"), "warning"));
return cell;
}
export function assignedPlanTable(plans: AssignedPlan[]): HTMLElement {
return table(
[
L("DE_New_Plan"),
L("DE_Assigned_Year"),
L("D04_Col_Confirmed"),
L("DE_Assigned_State"),
L("B01_Dashboard_Table_Action"),
],
plans.map((plan) => {
const actions = document.createElement("div");
actions.className = "b01-dashboard__actions";
const link = createButton({
label: L("DE_Assigned_Link"),
variant: "ghost",
onClick: () =>
void openPlanLinkModal(plan.assignee_id, `${plan.project_name} ${plan.year}`),
});
actions.append(
createButton({
label: L("DE_Assigned_View"),
variant: "ghost",
onClick: () => void goToWorkflowStage(plan.project_id, ROUTES.D02_PLAN_BASE),
}),
link,
);
return [
text(plan.project_name),
text(plan.year),
text(kstText(plan.confirmed_at)),
linkState(plan),
actions,
];
}),
DASHBOARD_VISIBLE_ROWS,
);
}
+5 -154
View File
@@ -1,163 +1,14 @@
import { createButton, createTag } from "@ui/ui_template_elements";
import { createButton } from "@ui/ui_template_elements";
import {
fetchCompanyAssets,
processJoinRequest,
setCompanyLogo,
type CompanyInfo,
type DashboardUser,
type JoinRequest,
type Member,
} from "./B01_Dashboard_Api_Fetch";
import { createAssetField, openAssetPicker } from "./B01_Dashboard_UI_AssetPicker";
import { canChangeRole, canDeleteUser } from "./B01_Dashboard_UI_Helper";
import {
openChangeRoleModal,
openEditCompanyModal,
openCreateCompanyModal,
openDeleteUserModal,
openEditUserModal,
openFindCompanyModal,
} from "./B01_Dashboard_UI_Modals";
import type { DashboardState } from "./B01_Dashboard_UI_Page";
import { actionPair, table, text } from "@ui/ui_template_general_blocks";
import { DASHBOARD_VISIBLE_ROWS, formatDate, L, runRequest } from "./B01_Dashboard_UI_Common";
export function buildCompanyPanel(state: DashboardState): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b01-dashboard__actions";
if (!state.company) {
wrap.append(
createTag(L("B01_Dashboard_NoCompany"), "warning"),
createButton({
label: L("B01_Dashboard_CreateCompany"),
onClick: () => openCreateCompanyModal(),
}),
createButton({
label: L("B01_Dashboard_FindCompany"),
variant: "ghost",
onClick: () => openFindCompanyModal(),
}),
);
return wrap;
}
wrap.append(
createTag(`${state.company.name} (${state.user.status})`, "success"),
text(`${L("B01_Dashboard_Metric_ActiveUsers")}: ${state.company.user_count ?? 0}`),
text(`${L("B01_Dashboard_Projects")}: ${state.company.project_count ?? 0}`),
// 저장한 주소가 화면에 안 보여 「저장이 안 된다」로 읽혔다 (2026-09-13 사용자 지시).
text(`${L("B01_Dashboard_Field_Address")}: ${state.company.business_address ?? "-"}`),
);
// 회사 정보 수정 (2026-09-06 사용자 지시) — 관리자만 보인다.
if (state.user.role !== "USER" && state.company) {
const company = state.company;
wrap.append(
createButton({
label: "회사 정보 수정",
variant: "ghost",
onClick: () => openEditCompanyModal(company),
}),
);
}
// 회사 대표 로고 — 등록 단계에서 받은 것을 여기서 바꾼다 (2026-09-02 사용자 확정).
// 프로젝트가 따로 고르지 않으면 도면이 이 로고를 쓴다.
if (state.user.role !== "USER") {
const company = state.company;
const slot = document.createElement("div");
void fetchCompanyAssets(company.id).then((assets) => {
slot.append(
createAssetField(
"회사 로고 (도면 표제란)",
"LOGO",
assets,
company.logo_asset_id ?? null,
company.id,
state.user,
{ onChange: (assetId) => setCompanyLogo(assetId, company.id).then(() => undefined) },
).root,
);
});
wrap.append(slot);
}
return wrap;
}
export function memberTable(members: Member[], currentUser: DashboardUser): HTMLElement {
return table(
[
L("B01_Dashboard_Table_Email"),
L("B01_Dashboard_Table_Name"),
L("B01_Dashboard_Table_Position"),
L("B01_Dashboard_Table_Department"),
L("B01_Dashboard_Table_Role"),
L("B01_Dashboard_Table_Action"),
],
members.map((member) => {
const actionsEl = document.createElement("div");
actionsEl.className = "b01-dashboard__actions";
actionsEl.append(
createButton({
label: L("Common_Btn_Edit"),
variant: "ghost",
onClick: () => openEditUserModal(currentUser, member),
}),
);
if (canChangeRole(currentUser, member)) {
actionsEl.append(
createButton({
label: L("B01_Dashboard_ChangeRole"),
variant: "ghost",
onClick: () => openChangeRoleModal(member),
}),
);
}
if (canDeleteUser(currentUser, member)) {
actionsEl.append(
createButton({
label: L("B01_Dashboard_RemoveMember"),
variant: "danger",
onClick: () => openDeleteUserModal(member),
}),
);
}
return [
text(member.email),
text(member.name),
text(member.position),
text(member.department),
text(member.role),
actionsEl,
];
}),
DASHBOARD_VISIBLE_ROWS,
);
}
export function joinRequestTable(requests: JoinRequest[], systemMode: boolean): HTMLElement {
return table(
[
L("B01_Dashboard_Table_Email"),
...(systemMode ? [L("B01_Dashboard_Table_Company")] : []),
L("B01_Dashboard_Table_Requested"),
L("B01_Dashboard_Table_Status"),
L("B01_Dashboard_Table_Action"),
],
requests.map((request) => [
text(request.user_email),
...(systemMode ? [text(request.company_name)] : []),
text(formatDate(request.requested_at)),
text(request.status),
actionPair(
() => onB01_JoinRequest_Process_Click(request.id, "APPROVE", systemMode),
() => onB01_JoinRequest_Process_Click(request.id, "REJECT", systemMode),
),
]),
DASHBOARD_VISIBLE_ROWS,
);
}
import { openAssetPicker } from "./B01_Dashboard_UI_AssetPicker";
import { openEditCompanyModal } from "./B01_Dashboard_UI_Modals";
import { table, text } from "@ui/ui_template_general_blocks";
import { DASHBOARD_VISIBLE_ROWS, L, runRequest } from "./B01_Dashboard_UI_Common";
export function companyTable(companies: CompanyInfo[], user?: DashboardUser): HTMLElement {
// 로고는 회사 등록 단계에서 받고 여기서 바꾼다 (2026-09-02 사용자 확정).
+258
View File
@@ -0,0 +1,258 @@
/* =============================================================================
* B01_Dashboard_UI_Home.ts
* 대시보드 홈(PLAN 547-2 · 요구 369~374) — 진행 중 프로젝트 수에 따라 꼴이 저절로 바뀜
* 1 = 큰 카드 · 2~3 = 중간 카드 · 4~8 = 한 줄 목록 + 팀원별 · 단계 분포 · 9 넘음 = 요약 · 그래프
* 오른쪽 = 처리할 일 · 보관함 · 회사(회사 관리자) · 예정노선(산림청) · 처음이면 세 걸음
* 꼴 고정(자동 · 작게 · 크게) = localStorage 기억
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { fetchHome, type HomeData, type ProjectSummary } from "./B01_Dashboard_Api_Home";
import { openNewProjectPicker } from "./B01_Dashboard_UI_NewProject";
import { bigCard, midCard, rowCard, type Go } from "./B01_Dashboard_UI_Home_Cards";
import { manyView, memberPane, pane, stagePane } from "./B01_Dashboard_UI_Home_Many";
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
import { homeLayout, type HomeFixed, type HomeLayout } from "./logic/B01_Dashboard_Home_Layout";
import "./B01_Dashboard_UI_Style_Home.css";
const FIXED_KEY = "aislo.b01.homeLayout";
const LAYOUT_TEXT: Record<HomeLayout, string> = {
empty: "처음 시작",
one: "진행 중 1 개 → 큰 카드",
few: "진행 중 2~3 개 → 중간 카드",
list: "진행 중 4~8 개 → 한 줄 목록",
many: "진행 중 9 개 넘음 → 요약 · 그래프",
};
function readFixed(): HomeFixed {
const saved = localStorage.getItem(FIXED_KEY);
return saved === "small" || saved === "large" ? saved : "auto";
}
function fixedSwitch(current: HomeFixed, onPick: (next: HomeFixed) => void): HTMLElement {
const seg = el("div", {
className: "b01-home__seg",
attrs: { role: "group", "aria-label": "꼴" },
});
for (const [value, label] of [
["auto", "자동"],
["small", "작게"],
["large", "크게"],
] as const) {
const b = el("button", {
className: value === current ? "on" : "",
text: label,
attrs: { type: "button", "aria-pressed": String(value === current) },
});
b.addEventListener("click", () => onPick(value));
seg.append(b);
}
return seg;
}
function todoRow(dot: string, text: string, action: string, onClick: () => void): HTMLElement {
return el("div", {
className: "b01-home__todo",
children: [
el("span", { className: `b01-home__dot b01-home__dot--${dot}` }),
el("span", { text }),
createButton({ label: action, variant: "ghost", size: "sm", onClick }),
],
});
}
/** 처리할 일 — 없으면 「처리할 일 없음」 */
function todoPane(data: HomeData, go: Go): HTMLElement {
const t = data.todos;
const rows: HTMLElement[] = [];
for (const p of t.new_plans)
rows.push(
todoRow("plan", `새 예정노선 — ${p.project_name} ${p.year}`, "보기", () => go("assigned")),
);
for (const p of t.updated_plans)
rows.push(
todoRow("warn", `업데이트 — ${p.project_name} ${p.year}`, "보기", () => go("assigned")),
);
if (t.join_requests)
rows.push(todoRow("alert", `가입 요청 ${t.join_requests} 건`, "처리", () => go("people")));
for (const b of t.expiring_temp)
rows.push(
todoRow("warn", `보관 파일 ${b.name} · ${b.days_left} 일 남음`, "열기", () => go("temp")),
);
if (!rows.length) rows.push(el("div", { className: "b01-home__muted", text: "처리할 일 없음" }));
return pane("처리할 일", ...rows);
}
function sidePanes(data: HomeData, go: Go): HTMLElement[] {
const panes = [todoPane(data, go)];
panes.push(
pane(
"임시 보관함",
el("div", { className: "b01-home__muted", text: `보관 묶음 ${data.todos.temp_count} 개` }),
createButton({ label: "보관함 ›", variant: "ghost", size: "sm", onClick: () => go("temp") }),
),
);
if (data.company) {
const c = data.company;
panes.push(
pane(
c.name ?? "회사",
el("div", {
className: "b01-home__muted",
text: `팀원 ${c.member_count ?? 0} 명 · 진행 중 ${data.projects.length} 개`,
}),
createButton({
label: "사람 ›",
variant: "ghost",
size: "sm",
onClick: () => go("people"),
}),
),
);
}
if (data.forest) {
const f = data.forest;
panes.push(
pane(
"예정노선",
el("div", {
className: "b01-home__muted",
text: `분석 전 ${f.plan_status.draft} · 분석됨 ${f.plan_status.analyzed} · 확정 ${f.plan_status.confirmed}`,
}),
el("div", {
className: f.assign_waiting ? "b01-home__stale" : "b01-home__muted",
text: `업체 지정 대기 ${f.assign_waiting}`,
}),
),
);
}
return panes;
}
/** 처음 — 세 걸음(내 정보 · 파일 · 첫 프로젝트) */
function firstSteps(ctx: ViewCtx, data: HomeData): HTMLElement {
const me = ctx.me;
const steps: [boolean, string, string, () => void][] = [
[
Boolean(me.position || me.department || me.phone),
"내 정보 채우기",
"내 정보 ›",
() => ctx.go("me"),
],
[data.todos.temp_count > 0, "측량 파일 올려 두기", "보관함 ›", () => ctx.go("temp")],
[false, "첫 프로젝트 만들기", "+ 새 프로젝트", () => void openNewProjectPicker()],
];
return pane(
"처음 오셨네요 — 세 걸음",
...steps.map(([done, text, action, onClick], i) =>
el("div", {
className: `b01-home__step${done ? " b01-home__step--done" : ""}`,
children: [
el("span", { className: "b01-home__step-no", text: done ? "✓" : String(i + 1) }),
el("span", { text }),
createButton({ label: action, variant: done ? "ghost" : "filled", size: "sm", onClick }),
],
}),
),
);
}
function mainArea(layout: HomeLayout, ctx: ViewCtx, data: HomeData): HTMLElement {
const cards: ProjectSummary[] = data.projects;
const go = ctx.go;
if (layout === "empty")
return data.project_total
? pane(
"진행 중인 프로젝트 없음",
el("div", { className: "b01-home__muted", text: "모두 끝났습니다." }),
)
: firstSteps(ctx, data);
if (layout === "one") return bigCard(cards[0], go);
if (layout === "few") {
const mids = el("div", {
className: "b01-home__mids",
children: cards.slice(0, 8).map((c, i) => midCard(c, go, i === 0)),
});
if (cards.length <= 8) return mids;
const all = createButton({ label: "모든 프로젝트 ›", onClick: () => go("projects") });
return el("div", { className: "b01-home__many", children: [mids, all] });
}
if (layout === "list")
return el("div", {
className: "b01-home__split b01-home__split--list",
children: [
el("div", {
children: [
el("div", {
className: "b01-home__muted",
text: `진행 중 ${cards.length} · 최근 작업 순`,
}),
el("div", { className: "b01-home__rows", children: cards.map((c) => rowCard(c, go)) }),
],
}),
el("div", { children: [memberPane(cards), stagePane(cards)] }),
],
});
return manyView(data, go);
}
export function mountHome(host: HTMLElement, ctx: ViewCtx): () => void {
let alive = true;
let data: HomeData | null = null;
const root = el("div", { className: "b01-home" });
host.append(root);
const draw = (): void => {
if (!data) return;
const fixed = readFixed();
const layout = homeLayout(data.projects.length, fixed);
root.dataset.layout = layout;
const head = el("div", {
className: "b01-home__head",
children: [
el("h2", { text: "홈" }),
el("span", {
className: "b01-home__muted",
text:
fixed === "auto" || layout === "empty"
? LAYOUT_TEXT[layout]
: `진행 중 ${data.projects.length} 개 · ${fixed === "small" ? "작게" : "크게"} 고정`,
}),
el("span", { className: "b01-home__push" }),
fixedSwitch(fixed, (next) => {
if (next === "auto") localStorage.removeItem(FIXED_KEY);
else localStorage.setItem(FIXED_KEY, next);
draw();
}),
createButton({ label: "+ 새 프로젝트", onClick: () => void openNewProjectPicker() }),
],
});
const body = el("div", {
className: "b01-home__body",
children: [
el("div", { className: "b01-home__main", children: [mainArea(layout, ctx, data)] }),
el("aside", { className: "b01-home__side", children: sidePanes(data, ctx.go) }),
],
});
root.replaceChildren(head, body);
};
root.append(el("div", { className: "b01-home__muted", text: "불러오는 중…" }));
fetchHome()
.then((loaded) => {
if (!alive) return;
data = loaded;
draw();
})
.catch((error: unknown) => {
if (!alive) return;
root.replaceChildren(
el("div", { className: "b01-home__muted", text: "홈을 불러오지 못했습니다." }),
);
showToast(error instanceof Error ? error.message : String(error), "error");
});
return () => {
alive = false;
};
}
@@ -0,0 +1,219 @@
/* =============================================================================
* B01_Dashboard_UI_Home_Cards.ts
* 홈 카드 부품(PLAN 547-2) — 썸네일 선 · 단계 점 · 큰 카드(1) · 중간 카드(2~3) · 한 줄(4~8)
* [이어서 하기] = 그 단계 화면 · [자세히] · 줄 누름 = go("project", { id })
* ========================================================================== */
import type { RoutePath } from "@config/config_frontend";
import { createButton, el } from "@ui/ui_template_elements";
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
import { groupFlowOf } from "../A00_Common/group_workflow";
import type { ProjectSummary } from "./B01_Dashboard_Api_Home";
import { L } from "./B01_Dashboard_UI_Common";
import { isStale } from "./logic/B01_Dashboard_Home_Layout";
export type Go = (view: string, params?: Record<string, string>) => void;
const SVG = "http://www.w3.org/2000/svg";
const KIND_LABEL = {
ROAD: "DE_New_Road",
PLAN: "DE_New_Plan",
STABILITY: "DE_New_Stability",
EROSION: "DE_New_Erosion",
} as const;
export const kindLabel = (kind: string): string =>
L(KIND_LABEL[kind as keyof typeof KIND_LABEL] ?? "DE_New_Road");
export const kindClass = (kind: string): string =>
`b01-home__kind--${(kind in KIND_LABEL ? kind : "ROAD").toLowerCase()}`;
/** 노선 중심선 썸네일 — 선이 없으면 빈 바탕 */
export function thumbSvg(card: ProjectSummary, className: string): SVGSVGElement {
const svg = document.createElementNS(SVG, "svg");
svg.setAttribute("class", `b01-home__thumb ${kindClass(card.kind)} ${className}`);
svg.setAttribute("viewBox", "-0.08 -0.08 1.16 1.16");
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
svg.setAttribute("aria-hidden", "true");
if (card.thumb) {
const line = document.createElementNS(SVG, "polyline");
line.setAttribute("points", card.thumb.map(([x, y]) => `${x},${y}`).join(" "));
line.setAttribute("vector-effect", "non-scaling-stroke");
svg.append(line);
}
return svg;
}
/** 단계 점 — 지난 단계 · 지금 단계 · 남은 단계 */
export function stagePips(card: ProjectSummary): HTMLElement {
const pips = el("div", { className: `b01-home__pips ${kindClass(card.kind)}` });
for (let i = 0; i < card.stage_total; i += 1) {
const state = i < card.stage_index ? "ok" : i === card.stage_index ? "cur" : "";
pips.append(el("i", { className: state }));
}
return pips;
}
export const stageText = (card: ProjectSummary): string =>
`${card.stage_index + 1} / ${card.stage_total} · ${card.stage_name}`;
/** 「n 일째 그대로」 · 오늘 · n 일 전 */
export function whenText(card: ProjectSummary): string {
const days = card.stale_days ?? null;
if (days === null) return "";
if (isStale(card)) return `${days} 일째 그대로`;
return days === 0 ? "오늘" : `${days} 일 전`;
}
export function continueStage(card: ProjectSummary): void {
const routes: readonly RoutePath[] = groupFlowOf(card.kind)?.routes ?? WORKFLOW_STEP_ROUTES;
void goToWorkflowStage(card.id, routes[Math.min(card.stage_index, routes.length - 1)]);
}
function kindBadge(card: ProjectSummary): HTMLElement {
return el("span", {
className: `b01-home__badge ${kindClass(card.kind)}`,
text: kindLabel(card.kind),
});
}
function title(card: ProjectSummary): HTMLElement {
return el("div", {
className: "b01-home__title",
children: [el("b", { text: card.name }), kindBadge(card)],
});
}
function nextBox(card: ProjectSummary): HTMLElement {
return el("div", { className: "b01-home__next", text: `다음: ${card.stage_name}` });
}
function whenLine(card: ProjectSummary): HTMLElement {
const line = el("div", { className: "b01-home__muted" });
const owner = card.owner_names[0];
if (owner) line.append(`${owner} · `);
line.append(
el("span", { className: isStale(card) ? "b01-home__stale" : "", text: whenText(card) }),
);
return line;
}
const METRIC_ROWS: [keyof ProjectSummary["metrics"], string, (v: number) => string][] = [
["length_m", "연장", (v) => `${v.toLocaleString()} m`],
["stations", "측점", (v) => `${v}`],
["structures", "구조물", (v) => `${v}`],
];
/** 핵심 숫자 — 있는 값만 */
function metricStrip(card: ProjectSummary): HTMLElement | null {
const cells = METRIC_ROWS.filter(([key]) => card.metrics[key] !== undefined).map(
([key, label, fmt]) =>
el("div", {
children: [
el("b", { text: fmt(card.metrics[key] as number) }),
el("span", { text: label }),
],
}),
);
return cells.length ? el("div", { className: "b01-home__metrics", children: cells }) : null;
}
const continueButton = (card: ProjectSummary, label = "이어서 하기 ›"): HTMLButtonElement =>
createButton({ label, size: "sm", onClick: () => continueStage(card) });
const detailButton = (card: ProjectSummary, go: Go): HTMLButtonElement =>
createButton({
label: "자세히",
variant: "ghost",
size: "sm",
onClick: () => go("project", { id: card.id }),
});
/** 진행 중 1 — 큰 카드(썸네일 · 단계 점 · 다음 할 일 · 핵심 숫자) */
export function bigCard(card: ProjectSummary, go: Go): HTMLElement {
const info = el("div", {
className: "b01-home__big-info",
children: [
el("div", { className: "b01-home__muted", text: "지금 하는 프로젝트" }),
title(card),
el("b", { className: "b01-home__stage", text: stageText(card) }),
stagePips(card),
nextBox(card),
whenLine(card),
el("div", {
className: "b01-home__actions",
children: [continueButton(card), detailButton(card, go)],
}),
],
});
const top = el("div", {
className: "b01-home__big-top",
children: [thumbSvg(card, "b01-home__thumb--big") as unknown as HTMLElement, info],
});
const metrics = metricStrip(card);
return el("div", {
className: "b01-home__pane b01-home__big",
children: metrics ? [top, metrics] : [top],
});
}
/** 진행 중 2~3 — 중간 카드(맨 앞 = 최근 작업) */
export function midCard(card: ProjectSummary, go: Go, first: boolean): HTMLElement {
const node = el("div", {
className: `b01-home__mid${first ? " b01-home__mid--first" : ""}`,
children: [
thumbSvg(card, "b01-home__thumb--mid") as unknown as HTMLElement,
el("div", {
className: "b01-home__mid-body",
children: [
whenLine(card),
title(card),
el("div", { className: "b01-home__muted", text: stageText(card) }),
stagePips(card),
nextBox(card),
el("div", {
className: "b01-home__actions",
children: [
continueButton(card, first ? "이어서 하기 ›" : "이어서 ›"),
detailButton(card, go),
],
}),
],
}),
],
});
return node;
}
/** 진행 중 4~8 — 한 줄(누름 = 상세 · 14 일 넘게 그대로면 노랑) */
export function rowCard(card: ProjectSummary, go: Go): HTMLElement {
const open = createButton({ label: "열기 ›", variant: "ghost", size: "sm" });
open.addEventListener("click", (event) => {
event.stopPropagation();
continueStage(card);
});
const row = el("div", {
className: `b01-home__row${isStale(card) ? " b01-home__row--stale" : ""}`,
attrs: { role: "button", tabindex: "0" },
children: [
thumbSvg(card, "b01-home__thumb--row") as unknown as HTMLElement,
el("div", { className: "b01-home__row-name", children: [title(card), whenLine(card)] }),
el("div", {
className: "b01-home__row-stage",
children: [
stagePips(card),
el("div", {
className: "b01-home__muted",
text: `${card.stage_name} ${card.stage_index + 1}/${card.stage_total}`,
}),
],
}),
open,
],
});
row.addEventListener("click", () => go("project", { id: card.id }));
row.addEventListener("keydown", (event) => {
if (event.key === "Enter") go("project", { id: card.id });
});
return row;
}
+207
View File
@@ -0,0 +1,207 @@
/* =============================================================================
* B01_Dashboard_UI_Home_Many.ts
* 홈 4 넘음 부품(PLAN 547-2) — 팀원별 · 단계 분포(4~8) · 요약 숫자 · 막대 · 원 그래프(9 넘음)
* 그래프 = 인라인 SVG(새 라이브러리 없음) · 색 = 셸 · 테마 변수(CSS 클래스)
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import type { HomeData, ProjectSummary } from "./B01_Dashboard_Api_Home";
import { kindClass, kindLabel, type Go } from "./B01_Dashboard_UI_Home_Cards";
import {
countBy,
donutArcs,
isStale,
stageBuckets,
topWithRest,
} from "./logic/B01_Dashboard_Home_Layout";
const SVG = "http://www.w3.org/2000/svg";
function svgNode(tag: string, attrs: Record<string, string | number>): SVGElement {
const node = document.createElementNS(SVG, tag);
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, String(v));
return node;
}
export function pane(titleText: string, ...children: (HTMLElement | SVGElement)[]): HTMLElement {
const node = el("div", {
className: "b01-home__pane",
children: [el("h3", { className: "b01-home__pane-title", text: titleText })],
});
node.append(...children);
return node;
}
const ownerOf = (card: ProjectSummary): string => card.owner_names[0] ?? "담당 없음";
/** 팀원별 진행 중 개수 */
export function memberPane(cards: ProjectSummary[]): HTMLElement {
const rows = countBy(cards, ownerOf).map(({ label, count }) =>
el("div", {
className: "b01-home__member",
children: [
el("span", { className: "b01-home__avatar", text: label.slice(0, 1) }),
el("span", { text: label }),
el("span", { className: "b01-home__muted b01-home__push", text: `${count} 개` }),
],
}),
);
return pane("팀원별", ...rows);
}
/** 단계 분포 — 시작 전 · 설계 중 · 확정 대기 · 쌓은 막대 */
export function stagePane(cards: ProjectSummary[]): HTMLElement {
const b = stageBuckets(cards);
const total = Math.max(1, cards.length);
const parts: [number, string, string][] = [
[b.start, "start", "시작 전"],
[b.working, "working", "설계 중"],
[b.last, "last", "확정 대기"],
];
const bar = el("div", { className: "b01-home__stack" });
for (const [n, cls, label] of parts) {
if (!n) continue;
const seg = el("i", {
className: `b01-home__stack--${cls}`,
attrs: { title: `${label} ${n}` },
});
seg.style.width = `${(n / total) * 100}%`;
bar.append(seg);
}
const text = parts.map(([n, , label]) => `${label} ${n}`).join(" · ");
const stale = cards.filter(isStale).length;
const legend = el("div", { className: "b01-home__muted", text });
const children: HTMLElement[] = [bar, legend];
if (stale)
children.push(
el("div", { className: "b01-home__stale", text: `14 일 넘게 그대로 ${stale} 개` }),
);
return pane("단계 분포", ...children);
}
function kpi(label: string, value: string, note?: string, warn = false): HTMLElement {
return el("div", {
className: "b01-home__kpi",
children: [
el("div", { className: "b01-home__muted", text: label }),
el("b", { className: warn ? "b01-home__alert" : "", text: value }),
...(note ? [el("span", { className: "b01-home__muted", text: note })] : []),
],
});
}
/** 가로 막대 — 회사별(시스템 관리자) · 담당별(그 밖) · 앞 4 + 그 밖 */
function barChart(rows: { label: string; count: number; rest?: number }[]): SVGElement {
const shown = topWithRest(rows, 5);
const max = Math.max(1, ...shown.map((r) => r.count));
const rowH = 22;
const svg = svgNode("svg", {
class: "b01-home__bars",
viewBox: `0 0 320 ${shown.length * rowH}`,
width: "100%",
preserveAspectRatio: "xMinYMin meet",
role: "img",
});
shown.forEach((r, i) => {
const y = i * rowH;
const label = r.rest ? `그 밖 ${r.rest} 곳` : r.label;
const width = (r.count / max) * 180;
const text = svgNode("text", { x: 0, y: y + 14 });
text.textContent = label.length > 9 ? `${label.slice(0, 9)}…` : label;
const count = svgNode("text", { x: 96 + width, y: y + 14 });
count.textContent = String(r.count);
svg.append(
text,
svgNode("rect", { x: 90, y: y + 4, width, height: 12, rx: 3, class: r.rest ? "rest" : "" }),
count,
);
});
return svg;
}
/** 원 그래프 — 종류별 · 가운데 = 합 */
function kindDonut(cards: ProjectSummary[]): HTMLElement {
const kinds = countBy(cards, (c) => c.kind);
const arcs = donutArcs(kinds.map((k) => k.count));
const svg = svgNode("svg", {
class: "b01-home__donut",
viewBox: "0 0 42 42",
width: 96,
height: 96,
});
kinds.forEach((k, i) => {
svg.append(
svgNode("circle", {
cx: 21,
cy: 21,
r: 15.9155,
class: kindClass(k.label),
"stroke-dasharray": arcs[i].dash,
"stroke-dashoffset": arcs[i].offset,
}),
);
});
const sum = svgNode("text", { x: 21, y: 24, "text-anchor": "middle" });
sum.textContent = String(cards.length);
svg.append(sum);
const legend = el("div", {
className: "b01-home__legend",
children: kinds.map((k) =>
el("span", {
children: [
el("i", { className: `b01-home__swatch ${kindClass(k.label)}` }),
`${kindLabel(k.label)} ${k.count}`,
],
}),
),
});
return el("div", {
className: "b01-home__donut-row",
children: [svg as unknown as HTMLElement, legend],
});
}
/** 9 넘음 — 요약 숫자 · 회사별(담당별) 막대 · 종류 원 · 단계 분포 · [모든 프로젝트 ›] */
export function manyView(data: HomeData, go: Go): HTMLElement {
const cards = data.projects;
const sys = data.system;
const pending = data.todos.join_requests ?? 0;
const kpis = el("div", { className: "b01-home__kpis" });
if (sys) kpis.append(kpi("회사", String(sys.companies)), kpi("사용자", String(sys.users)));
kpis.append(kpi("진행 중 프로젝트", String(cards.length), `전체 ${data.project_total}`));
if (data.todos.join_requests !== undefined)
kpis.append(
kpi("처리 대기", String(pending), pending ? `가입 ${pending}` : undefined, pending > 0),
);
if (sys) {
const s = sys.server;
const disk = s.disk ?? 0;
kpis.append(
kpi(
"서버",
`디스크 ${Math.round(disk)}%`,
`CPU ${Math.round(s.cpu ?? 0)} · 메모리 ${Math.round(s.memory ?? 0)}`,
disk >= 85,
),
);
}
const rows = sys
? countBy(cards, (c) => sys.company_names[String(c.company_id)] ?? "회사 없음")
: countBy(cards, (c) => c.owner_names[0] ?? "담당 없음");
const all = createButton({ label: "모든 프로젝트 ›", onClick: () => go("projects") });
return el("div", {
className: "b01-home__many",
children: [
kpis,
el("div", {
className: "b01-home__split",
children: [
pane(sys ? "회사별 진행 중" : "담당별 진행 중", barChart(rows)),
pane("종류", kindDonut(cards)),
],
}),
stagePane(cards),
el("div", { className: "b01-home__actions", children: [all] }),
],
});
}
+38
View File
@@ -0,0 +1,38 @@
/* =============================================================================
* B01_Dashboard_UI_Legacy.ts
* 옛 한 페이지 대시보드에서 남은 조각 — 옛 구역은 세부 페이지(549-1 · 549-2)가 다 갈아 끼움.
* 남은 것 = 고른 회사 자리(Shell · M02) · 옛 상태 타입(Page 가 내보냄).
* ========================================================================== */
import type {
AuditLog,
CompanyInfo,
DashboardUser,
JoinRequest,
ProjectItem,
ResourceData,
} from "./B01_Dashboard_Api_Fetch";
export interface DashboardState {
user: DashboardUser;
userProjects: ProjectItem[];
companyProjects: ProjectItem[];
allProjects: ProjectItem[];
allCompanies: CompanyInfo[];
allUsers: DashboardUser[];
allJoinRequests: JoinRequest[];
auditLogs: AuditLog[];
resources: ResourceData | null;
/** 시스템 관리자가 고른 회사(29-1) — 프로젝트 · 사용자 · 가입 요청 · 회사 관리가 이 회사 것 */
adminCompanyId: number | null;
}
/** 고른 회사 id 자리 — M02 도 같은 키를 읽음(이름 바꾸지 않음) */
export const ADMIN_COMPANY_KEY = "aislo.adminCompany";
/** 고른 회사 — 저장값이 없거나 없는 회사면 자기 회사 */
export function pickAdminCompany(user: DashboardUser, companies: CompanyInfo[]): number | null {
const saved = Number(sessionStorage.getItem(ADMIN_COMPANY_KEY));
if (companies.some((c) => c.id === saved)) return saved;
return user.company_id ?? companies[0]?.id ?? null;
}
+107
View File
@@ -0,0 +1,107 @@
/* =============================================================================
* B01_Dashboard_UI_Nav.ts
* 대시보드 왼쪽 메뉴(546) — 묶음(작업 · 회사 관리 · 시스템 관리 · 도움) · 권한 없는 항목 · 빈 묶음 숨김
* 항목 = 주소 링크(뒤로 가기 · 새 탭) · 지금 위치 강조 · 오른쪽 수 배지 · 작은 화면 = 아이콘 레일(CSS)
* 도움 묶음 페이지(가이드 · 챗봇)도 여기 — 창을 여는 일만 함
* ========================================================================== */
import { ROUTES } from "@config/config_frontend";
import { createButton } from "@ui/ui_template_elements";
import { icon } from "@ui/ui_template_icons";
import { createHelpButton } from "@ui/ui_template_help";
import type { View, ViewGroup } from "./B01_Dashboard_UI_Views";
const GROUPS: [ViewGroup, string][] = [
["work", "작업"],
["company", "회사 관리"],
["system", "시스템 관리"],
["help", "도움"],
];
export const viewHref = (key: string, params: Record<string, string> = {}): string =>
`#/${ROUTES.B01_ACCOUNT}?${new URLSearchParams({ view: key, ...params })}`;
export interface DashboardNav {
root: HTMLElement;
setActive: (key: string) => void;
/** 수 배지 — 0 이하면 숨김 · alert = 빨강(처리할 일) */
setBadge: (key: string, count: number, alert?: boolean) => void;
}
/** views = 이 역할에 보이는 것만 받음 */
export function buildNav(views: View[]): DashboardNav {
const root = document.createElement("nav");
root.className = "b01-shell__nav";
root.setAttribute("aria-label", "대시보드 메뉴");
const items = new Map<string, HTMLAnchorElement>();
for (const [group, label] of GROUPS) {
const inGroup = views.filter((v) => v.group === group);
if (!inGroup.length) continue;
const head = document.createElement("div");
head.className = "b01-shell__nav-group";
head.textContent = label;
root.append(head);
for (const view of inGroup) {
const a = document.createElement("a");
a.className = "b01-shell__nav-item";
a.href = viewHref(view.key);
a.title = view.title;
a.dataset.view = view.key;
const text = document.createElement("span");
text.className = "b01-shell__nav-label";
text.textContent = view.title;
const badge = document.createElement("span");
badge.className = "b01-shell__badge";
badge.hidden = true;
a.append(icon(view.icon ?? "home"), text, badge);
root.append(a);
items.set(view.key, a);
}
}
return {
root,
setActive: (key) => {
for (const [k, a] of items) {
a.classList.toggle("is-active", k === key);
if (k === key) a.setAttribute("aria-current", "page");
else a.removeAttribute("aria-current");
}
},
setBadge: (key, count, alert = false) => {
const badge = items.get(key)?.querySelector<HTMLElement>(".b01-shell__badge");
if (!badge) return;
badge.hidden = count <= 0;
badge.textContent = String(count);
badge.classList.toggle("is-alert", alert);
},
};
}
function helpCard(text: string, action: HTMLElement): HTMLElement {
const card = document.createElement("div");
card.className = "b01-shell__card b01-shell__help";
const p = document.createElement("p");
p.textContent = text;
card.append(p, action);
return card;
}
/** 사용자 가이드 — 위 머리 [?] 와 같은 창을 엶 */
export function mountGuide(host: HTMLElement): void {
const button = createHelpButton();
host.append(helpCard("대시보드 사용법을 그림과 함께 봅니다.", button));
button.click();
}
/** 지식 챗봇 — 오른쪽 아래 둥근 단추 창을 엶 */
export function mountChat(host: HTMLElement): void {
const toggle = (): HTMLButtonElement | null =>
document.querySelector<HTMLButtonElement>(".b01-knowledge-chat__toggle");
const open = (): void => {
const t = toggle();
if (t?.getAttribute("aria-expanded") !== "true") t?.click();
};
const button = createButton({ label: "챗봇 열기", variant: "filled", onClick: open });
host.append(helpCard("임도 · 사방 법령과 기술 자료를 물어봅니다.", button));
open();
}
+19 -355
View File
@@ -1,368 +1,32 @@
import { ROUTES } from "@config/config_frontend";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import {
createButton,
createTag,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { section } from "@ui/ui_template_general_blocks";
import { createHelpButton } from "@ui/ui_template_help";
import { createGeneralLayout } from "@ui/ui_template_general_layout";
import { createStepBar } from "@ui/ui_template_workflow_layout";
import { setDrawerSteps } from "../A00_Common/app_shell";
import { settingLinks } from "../A00_Common/m_settings_nav";
import { currentRoute, navigateTo } from "../A00_Common/router";
import {
fetchAllCompanies,
fetchAllUsers,
fetchAuditLogs,
fetchCompanyJoinRequests,
fetchCompanyMembers,
fetchCompanyProjects,
fetchDashboardMe,
fetchSystemResources,
fetchUserCompany,
fetchUserProjects,
type AuditLog,
type CompanyInfo,
type DashboardUser,
type JoinRequest,
type Member,
type ProjectItem,
type ResourceData,
} from "./B01_Dashboard_Api_Fetch";
import { auditLogTable, userTable } from "./B01_Dashboard_UI_Admin";
import { openCalcSitesDialog } from "./B01_Dashboard_UI_CalcSites";
import { L, roleLabel } from "./B01_Dashboard_UI_Common";
import {
buildCompanyPanel,
companyTable,
joinRequestTable,
memberTable,
} from "./B01_Dashboard_UI_Company";
import { openAddMemberModal, openCreateCompanyModal } from "./B01_Dashboard_UI_Modals";
import { buildProfileForm, buildSecurityForm } from "./B01_Dashboard_UI_Profile";
import { openCompanyPicker } from "./B01_Dashboard_UI_CompanyPick";
import { openNewProjectPicker } from "./B01_Dashboard_UI_NewProject";
import {
assignedPlanTable,
fetchAssignedPlans,
type AssignedPlan,
} from "./B01_Dashboard_UI_AssignedPlans";
import { projectTable } from "./B01_Dashboard_UI_Projects";
import { buildResourcePanel } from "./B01_Dashboard_UI_Resources";
import { buildTempUploadSection } from "./B01_Dashboard_UI_TempUpload";
import { buildKnowledgeChat } from "./B01_Dashboard_UI_Knowledge_Chat";
/* =============================================================================
* B01_Dashboard_UI_Page.ts
* 대시보드 입구 — 나(me) · 산림청 판정을 읽고 셸(`B01_Dashboard_UI_Shell.ts`)을 세움(546).
* 세부 페이지는 등록부(`B01_Dashboard_UI_Views.ts`) · 옛 구역 부품은 `B01_Dashboard_UI_Legacy.ts`.
* ========================================================================== */
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
import type { MountedPage } from "../A00_Common/router";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import { fetchDashboardMe } from "./B01_Dashboard_Api_Fetch";
import { L } from "./B01_Dashboard_UI_Common";
import { mountDashboardShell } from "./B01_Dashboard_UI_Shell";
import "./B01_Dashboard_UI_Style.css";
import "./B01_Dashboard_UI_Style_Company.css";
export interface DashboardState {
user: DashboardUser;
userProjects: ProjectItem[];
companyProjects: ProjectItem[];
allProjects: ProjectItem[];
company: CompanyInfo | null;
members: Member[];
joinRequests: JoinRequest[];
allCompanies: CompanyInfo[];
allUsers: DashboardUser[];
allJoinRequests: JoinRequest[];
auditLogs: AuditLog[];
resources: ResourceData | null;
/** 우리 회사에 지정된 예정노선(528-2) — 회사관리자 · 사용자만 */
assignedPlans: AssignedPlan[];
/** 시스템 관리자가 고른 회사(29-1) — 프로젝트 · 사용자 · 가입 요청 · 회사 관리가 이 회사 것 */
adminCompanyId: number | null;
}
export type { DashboardState } from "./B01_Dashboard_UI_Legacy";
/** 고른 회사 id 자리 — M02 도 같은 키를 읽음(이름 바꾸지 않음) */
const ADMIN_COMPANY_KEY = "aislo.adminCompany";
/** 고른 회사 — 저장값이 없거나 없는 회사면 자기 회사 */
function pickAdminCompany(user: DashboardUser, companies: CompanyInfo[]): number | null {
const saved = Number(sessionStorage.getItem(ADMIN_COMPANY_KEY));
if (companies.some((c) => c.id === saved)) return saved;
return user.company_id ?? companies[0]?.id ?? null;
}
export async function renderB01Dashboard(root: HTMLElement): Promise<void> {
export async function renderB01Dashboard(root: HTMLElement): Promise<MountedPage | void> {
showLoadingOverlay();
try {
const user = await fetchDashboardMe();
const state: DashboardState = {
user,
userProjects: [],
companyProjects: [],
allProjects: [],
company: null,
members: [],
joinRequests: [],
allCompanies: [],
allUsers: [],
allJoinRequests: [],
auditLogs: [],
resources: null,
assignedPlans: [],
adminCompanyId: null,
};
await loadRoleData(state);
const [user, session] = await Promise.all([
fetchDashboardMe(),
fetchSessionUser().catch(() => null),
]);
root.innerHTML = "";
root.append(buildPage(state));
return await mountDashboardShell(root, user, Boolean(session?.is_forest_service));
} catch (error) {
showToast(error instanceof Error ? error.message : L("B01_Dashboard_LoadFailed"), "error");
} finally {
hideLoadingOverlay();
}
}
async function loadRoleData(state: DashboardState): Promise<void> {
if (state.user.role === "SYSTEM_ADMIN") {
// 회사 목록을 먼저 — 고른 회사를 가린 뒤 그 회사 것만 읽음(29-1)
state.allCompanies = await fetchAllCompanies().catch(() => []);
const companyId = pickAdminCompany(state.user, state.allCompanies);
state.adminCompanyId = companyId;
const results = await Promise.allSettled([
fetchAllUsers(companyId),
fetchCompanyJoinRequests(companyId),
fetchAuditLogs(),
fetchSystemResources(7),
fetchCompanyProjects(companyId),
]);
state.allUsers = results[0].status === "fulfilled" ? results[0].value : [];
state.allJoinRequests = results[1].status === "fulfilled" ? results[1].value : [];
state.auditLogs = results[2].status === "fulfilled" ? results[2].value : [];
state.resources = results[3].status === "fulfilled" ? results[3].value : null;
state.allProjects = results[4].status === "fulfilled" ? results[4].value : [];
results.forEach((res, idx) => {
if (res.status === "rejected") {
console.error(`System Admin API failed at index ${idx}:`, res.reason);
}
});
return;
}
const [projects, company, assigned] = await Promise.all([
fetchUserProjects().catch(() => []),
fetchUserCompany().catch(() => null),
fetchAssignedPlans().catch(() => []),
]);
state.userProjects = projects;
state.company = company;
state.assignedPlans = assigned;
if (state.user.role === "ADMIN") {
const [members, requests, companyProjects] = await Promise.all([
fetchCompanyMembers().catch(() => []),
fetchCompanyJoinRequests().catch(() => []),
fetchCompanyProjects().catch(() => []),
]);
state.members = members;
state.joinRequests = requests;
state.companyProjects = companyProjects;
}
}
/** 설정 카드 공용 양식 — 제목 + 한 줄 설명 · 누르면 이동 */
function settingCard(title: string, desc: string, go: () => void): HTMLElement {
const card = document.createElement("button");
card.type = "button";
card.className = "b01-dashboard__setting-card";
const head = document.createElement("strong");
head.className = "b01-dashboard__setting-title";
head.textContent = title;
const sub = document.createElement("span");
sub.className = "b01-dashboard__setting-desc";
sub.textContent = desc;
card.append(head, sub);
card.addEventListener("click", go);
return card;
}
/** 시스템 설정 — 카드 규칙은 `settingLinks` 한 벌(☰ 바로가기와 같이 씀 · 334) · 계산 자리 = 창(504-2) */
function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b01-dashboard__setting-grid";
wrap.append(
...settingLinks(user, companyId).map((link) =>
settingCard(link.title, link.desc, () =>
link.key === "calcSites" ? void openCalcSitesDialog() : navigateTo(link.route, link.query),
),
),
);
return wrap;
}
/** 회사 알약(시스템 관리자만) — 컨테이너 제목 바로 옆 · 누르면 조회 후 고르기 · 고르면 저장 뒤 다시 읽음 */
function withCompanyPill(card: HTMLElement, state: DashboardState): HTMLElement {
const name = state.allCompanies.find((c) => c.id === state.adminCompanyId)?.name ?? "-";
const pill = document.createElement("button");
pill.type = "button";
pill.className = "ui-tag ui-tag--accent b01-dashboard__company-pill";
pill.title = L("B01_Dashboard_PickCompany");
pill.textContent = `${name} ▾`;
pill.addEventListener("click", (event) => {
// 제목 줄 누름 = 접힘 — 알약 누름은 올려 보내지 않음
event.stopPropagation();
openCompanyPicker(state.allCompanies, state.adminCompanyId, (company) => {
sessionStorage.setItem(ADMIN_COMPANY_KEY, String(company.id));
window.dispatchEvent(new HashChangeEvent("hashchange"));
});
});
card.querySelector(".b01-dashboard__section-header > .ui-card__title")?.after(pill);
return card;
}
/** 시스템 설정 컨테이너 — 모든 역할 · 한 줄 통째 · 제목 눌러 통째로 접힘 */
function buildSystemSettingsSection(state: DashboardState): HTMLElement {
const { user } = state;
const panel = buildSystemSettingsPanel(user, state.adminCompanyId);
const card = section(L("B01_Dashboard_SystemSettings"), panel, true);
if (user.role === "SYSTEM_ADMIN") withCompanyPill(card, state);
card.querySelector(".ui-card__body")?.classList.add("ui-collapsible");
card.querySelector(".b01-dashboard__section-header")?.classList.add("ui-collapsible__title");
attachCollapsible(card);
return card;
}
/** ☰ 구역 바로가기 아이콘(334) — 구역 제목 키로 짝(EN 글에서도 같음) */
const SECTION_ICONS: [Parameters<typeof L>[0], string][] = [
["B01_Dashboard_Resources", "📊"],
["B01_Dashboard_SystemSettings", "⚙️"],
["B01_Dashboard_Projects", "🗂️"],
["DE_Assigned_Title", "📬"],
["B01_Temp_Section", "📦"],
["B01_Dashboard_Users", "👥"],
["B01_Dashboard_Members", "👥"],
["B01_Dashboard_JoinRequests", "📨"],
["B01_Dashboard_Companies", "🏢"],
["B01_Dashboard_Company", "🏢"],
["B01_Dashboard_Profile", "👤"],
["B01_Account_Section_Security", "🔒"],
["B01_Dashboard_AuditLogs", "📜"],
];
/** ☰ 구역 바로가기(334) — 그린 구역 그대로 · 화면 차례 · 누름 = 서랍 닫힘 + 그 구역으로 굴림 */
function mountSectionShortcuts(grid: HTMLElement): void {
// 자료 기다리는 사이 다른 쪽으로 떠났으면 맡기지 않음
if (currentRoute() !== ROUTES.B01_ACCOUNT) return;
const sections = [...grid.children] as HTMLElement[];
const titles = sections.map((s) => s.querySelector(".ui-card__title")?.textContent ?? "");
const icons = titles.map((title) => SECTION_ICONS.find(([key]) => L(key) === title)?.[1] ?? "");
const bar = createStepBar(titles, -1, {
orientation: "vertical",
icons,
routes: titles,
onStepClick: (index) => sections[index].scrollIntoView({ behavior: "smooth", block: "start" }),
});
setDrawerSteps(bar);
}
/** 받은 예정노선 구역 — 받은 것이 있을 때만 */
function assignedPlanSection(state: DashboardState): HTMLElement[] {
if (!state.assignedPlans.length) return [];
return [section(L("DE_Assigned_Title"), assignedPlanTable(state.assignedPlans), true)];
}
function buildPage(state: DashboardState): HTMLElement {
// 제목·여백은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — B02 등 다른 화면과 같은 모양.
const page = document.createElement("div");
const grid = document.createElement("div");
grid.className = "b01-dashboard__grid";
if (state.user.role === "SYSTEM_ADMIN") {
// 시스템 설정은 리소스 현황 아래(29-1) · 회사 알약이 붙은 컨테이너는 고른 회사 것
const picked = state.allCompanies.filter((c) => c.id === state.adminCompanyId);
grid.append(
section(L("B01_Dashboard_Resources"), buildResourcePanel(state.resources), true),
buildSystemSettingsSection(state),
withCompanyPill(
section(L("B01_Dashboard_Projects"), projectTable(state.allProjects, state.user), true, [
createButton({ label: "+", onClick: () => void openNewProjectPicker() }),
]),
state,
),
buildTempUploadSection(),
withCompanyPill(
section(L("B01_Dashboard_Users"), userTable(state.allUsers, state.user), true, [
createButton({
label: "+",
onClick: () => openAddMemberModal(undefined, state.adminCompanyId),
}),
]),
state,
),
withCompanyPill(
section(
L("B01_Dashboard_JoinRequests"),
joinRequestTable(state.allJoinRequests, true),
true,
),
state,
),
withCompanyPill(
section(L("B01_Dashboard_Companies"), companyTable(picked, state.user), true, [
createButton({ label: "+", onClick: () => openCreateCompanyModal() }),
]),
state,
),
);
} else if (state.user.role === "ADMIN") {
// 시스템 설정은 맨 위 한 줄(27-3a)
grid.append(
buildSystemSettingsSection(state),
section(L("B01_Dashboard_Projects"), projectTable(state.companyProjects, state.user), true, [
createButton({ label: "+", onClick: () => void openNewProjectPicker() }),
]),
...assignedPlanSection(state),
buildTempUploadSection(),
section(L("B01_Dashboard_Members"), memberTable(state.members, state.user), true, [
createButton({ label: "+", onClick: () => openAddMemberModal() }),
]),
section(L("B01_Dashboard_JoinRequests"), joinRequestTable(state.joinRequests, false), true),
section(L("B01_Dashboard_Company"), buildCompanyPanel(state), true),
);
} else {
grid.append(
buildSystemSettingsSection(state),
section(L("B01_Dashboard_Projects"), projectTable(state.userProjects, state.user), true, [
createButton({ label: "+", onClick: () => void openNewProjectPicker() }),
]),
...assignedPlanSection(state),
// 임시 보관함 — 프로젝트를 만들기 전에 자료를 올려 두는 곳이라 프로젝트 목록
// 바로 아래에 둔다(2026-08-08 사용자 지시).
buildTempUploadSection(),
section(L("B01_Dashboard_Company"), buildCompanyPanel(state), true),
);
}
grid.append(
section(L("B01_Dashboard_Profile"), buildProfileForm(state.user)),
section(L("B01_Account_Section_Security"), buildSecurityForm()),
);
// 시스템 로그는 맨 아래 (2026-09-06 사용자 지시).
if (state.user.role === "SYSTEM_ADMIN") {
grid.append(section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true));
}
page.append(grid);
mountSectionShortcuts(grid);
page.append(buildKnowledgeChat());
const layout = createGeneralLayout({
pageClass: "b01-dashboard",
title: L("B01_Dashboard_Title"),
subtitle: L("B01_Dashboard_Subtitle"),
content: page,
});
// 역할 배지는 제목 줄 오른쪽에 둔다(자리는 CSS 격자가 잡는다).
const tag = createTag(
roleLabel(state.user.role),
state.user.role === "SYSTEM_ADMIN" ? "accent" : "neutral",
);
// [?] 사용자 가이드(538-2) = 배지 왼쪽 — 같은 자리 묶음
const head = document.createElement("span");
head.className = "b01-dashboard__role ui-help-head";
head.append(createHelpButton(), tag);
layout.root.querySelector(".ui-general-layout__header")?.append(head);
return layout.root;
}
+153
View File
@@ -0,0 +1,153 @@
/* =============================================================================
* B01_Dashboard_UI_PageKit.ts
* 대시보드 세부 페이지 공용 조각(549) — 페이지 머리 · 표 · 줄 끝 ⋯ · 배지 · 탭 · 빈 상태.
* 색 · 간격 = 셸 변수(`--b01-*`) 만 · 모양은 `B01_Dashboard_UI_Style_Pages.css`.
* 아이콘 = `ui_template_icons`(552-2 에 여섯을 옮김).
* ========================================================================== */
import { createButton, createTag } from "@ui/ui_template_elements";
import { createEmpty } from "@ui/ui_template_empty";
import { icon, type IconName } from "@ui/ui_template_icons";
import type { BadgeTone } from "./logic/B01_Dashboard_Logic_Pages";
import "./B01_Dashboard_UI_Style_Pages.css";
/** 아이콘 여섯 — 552-2 에 `ui_template_icons` 로 옮김(같은 Tabler 선) · 이름 · 기본 크기 16 그대로 */
export type KitIconName = Extract<
IconName,
"dots" | "plus" | "pencil" | "upload" | "mail" | "inbox"
>;
export function kitIcon(name: KitIconName, size = 16): HTMLSpanElement {
return icon(name, size);
}
function node<K extends keyof HTMLElementTagNameMap>(
tag: K,
className: string,
text?: string,
): HTMLElementTagNameMap[K] {
const n = document.createElement(tag);
n.className = className;
if (text !== undefined) n.textContent = text;
return n;
}
/** 배지 — 한글 글 + 색 */
export function badge(text: string, tone: BadgeTone = "neutral"): HTMLElement {
const tag = createTag(text, tone);
tag.classList.add("b01-pg-badge");
return tag;
}
/** 주 단추 — 오른쪽 위 하나 · 앞 아이콘 */
export function primaryButton(label: string, onClick: () => void, name: KitIconName = "plus") {
return createButton({ label, onClick, iconStart: kitIcon(name) });
}
export interface PageFrame {
root: HTMLElement;
/** 머리 아래 몸통 */
body: HTMLElement;
/** 머리 오른쪽 주 단추 자리 */
setAction: (action: HTMLElement | null) => void;
}
/** 페이지 제목 + 한 줄 설명 + 오른쪽 위 주 단추 */
export function pageFrame(host: HTMLElement, title: string, desc: string): PageFrame {
const root = node("div", "b01-pg");
const head = node("header", "b01-pg__head");
const text = node("div", "b01-pg__title-wrap");
text.append(node("h2", "b01-pg__title", title), node("p", "b01-pg__desc", desc));
const slot = node("div", "b01-pg__action");
head.append(text, slot);
const body = node("div", "b01-pg__body");
root.append(head, body);
host.append(root);
return {
root,
body,
setAction: (action) => slot.replaceChildren(...(action ? [action] : [])),
};
}
// 부품(552-3) — 표 · 줄 끝 ⋯ · 설정 줄 = ui_template 부품 · 부름 꼴 그대로
export { createRowMenu as rowMenu, type RowMenuItem } from "@ui/ui_template_context_menu";
export {
createTable as dataTable,
tableRow,
tableShell,
type Cell,
type Column,
} from "@ui/ui_template_table";
export { settingCard, settingRow, valueText } from "@ui/ui_template_setting_row";
export interface EmptyOptions {
icon: IconName | KitIconName;
text: string;
hint?: string;
action?: HTMLElement;
/** 가이드 링크 — 누르면 */
onGuide?: () => void;
}
/** 빈 상태 — 부품 「빈 상태」(ui_template_empty.ts) */
export function emptyState(opts: EmptyOptions): HTMLElement {
return createEmpty(opts);
}
export interface TabsHandle {
root: HTMLElement;
setBadge: (key: string, count: number) => void;
}
/** 탭 줄 — 눌러 바꿈 · 탭 오른쪽 수 배지(0 이면 숨김) */
export function tabBar(
tabs: { key: string; label: string }[],
initial: string,
onChange: (key: string) => void,
): TabsHandle {
const root = node("div", "b01-pg-tabs");
root.setAttribute("role", "tablist");
const badges = new Map<string, HTMLElement>();
const buttons = new Map<string, HTMLButtonElement>();
const select = (key: string): void => {
for (const [k, b] of buttons) {
b.classList.toggle("is-active", k === key);
b.setAttribute("aria-selected", String(k === key));
}
};
for (const tab of tabs) {
const b = node("button", "b01-pg-tabs__tab");
b.type = "button";
b.setAttribute("role", "tab");
b.dataset.tab = tab.key;
const count = node("span", "b01-pg-tabs__count");
count.hidden = true;
b.append(node("span", "", tab.label), count);
b.addEventListener("click", () => {
select(tab.key);
onChange(tab.key);
});
buttons.set(tab.key, b);
badges.set(tab.key, count);
root.append(b);
}
select(initial);
return {
root,
setBadge: (key, n) => {
const c = badges.get(key);
if (!c) return;
c.textContent = String(n);
c.hidden = n <= 0;
},
};
}
/** 줄 첫 칸 — 이름 + (작은 글) 보조 */
export function nameCell(name: string, sub?: string | null): HTMLElement {
const cell = node("div", "b01-pg-name");
cell.append(node("span", "b01-pg-name__main", name));
if (sub) cell.append(node("span", "b01-pg-name__sub", sub));
return cell;
}
+90 -67
View File
@@ -1,5 +1,11 @@
/* =============================================================================
* B01_Dashboard_UI_Profile.ts
* 내 정보 카드(549-2) — 기본정보(이름 · 직급 · 부서 · 전화 · 이메일 · 서명) · 비밀번호 변경.
* 모양 = 설정 줄(제목 · 설명 · 오른쪽 조작) · 저장 길은 옛 폼과 같음(`updateUserProfile` · `changePassword`).
* ========================================================================== */
import { isBlank } from "@util/common_util_validate";
import { createButton, createInputField } from "@ui/ui_template_elements";
import { createButton } from "@ui/ui_template_elements";
import {
changePassword,
fetchCompanyAssets,
@@ -8,26 +14,44 @@ import {
type DashboardUser,
} from "./B01_Dashboard_Api_Fetch";
import { createAssetField } from "./B01_Dashboard_UI_AssetPicker";
import { buildUserFields, L, runRequest } from "./B01_Dashboard_UI_Common";
import { L, runRequest } from "./B01_Dashboard_UI_Common";
import { settingCard, settingRow, valueText } from "./B01_Dashboard_UI_PageKit";
import { el } from "./B01_Dashboard_UI_View_Parts";
const PASSWORD_MIN_LENGTH = 8;
export function buildProfileForm(user: DashboardUser): HTMLElement {
// 로그인한 본인의 정보다 — 이메일 라벨도 「이메일」로 나간다 (2026-09-06 사용자 지시).
const fields = buildUserFields(user, { self: true });
const grid = fields.grid;
// 본인 서명 — 사용자 수정 모달과 같은 칸이다. 도면 표제란이 이 사람 자리를 채울 때
// 그대로 실리므로 본인이 여기서 바로 걸 수 있게 둔다 (2026-09-06 사용자 지시).
const signatureSlot = document.createElement("div");
function input(value: string, type = "text", label = ""): HTMLInputElement {
const box = el("input", "b01-sys-input");
box.type = type;
box.value = value;
if (label) box.ariaLabel = label;
return box;
}
/** 입력 줄 아래에 오류 글 — 비우면 지움 */
function fieldError(row: HTMLElement, message?: string): void {
row.querySelector(".b01-sys-error")?.remove();
if (message)
row.querySelector(".ui-setting-row__text")?.append(el("span", "b01-sys-error", message));
}
export function buildProfileCard(user: DashboardUser): HTMLElement {
const name = input(user.name, "text", "이름");
const position = input(user.position ?? "", "text", "직급");
const department = input(user.department ?? "", "text", "부서");
const phone = input(user.phone ?? "", "tel", "전화");
const nameRow = settingRow("이름", "도면 표제란 · 보고서에 들어갑니다.", name);
// 본인 서명 — 도면 표제란이 이 사람 자리를 채울 때 그대로 실림(회사 소속일 때만)
const signature = el("div", "b01-sys-assetslot");
if (user.company_id) {
void fetchCompanyAssets(user.company_id).then((assets) => {
const owned = assets.find((asset) => asset.kind === "SIGNATURE" && asset.user_id === user.id);
signatureSlot.append(
const owned = assets.find((a) => a.kind === "SIGNATURE" && a.user_id === user.id);
signature.append(
createAssetField("서명", "SIGNATURE", assets, owned?.id ?? null, user.company_id!, user, {
owner: { id: user.id, name: user.name },
onChange: async (assetId) => {
if (assetId === null) return;
const picked = assets.find((asset) => asset.id === assetId);
const picked = assets.find((a) => a.id === assetId);
if (picked)
await updateCompanyAsset(assetId, { label: picked.label, user_id: user.id });
},
@@ -38,73 +62,72 @@ export function buildProfileForm(user: DashboardUser): HTMLElement {
const save = createButton({
label: L("B01_Dashboard_SaveProfile"),
onClick: async function onB01_Profile_Save_Click() {
if (!fields.validate()) return;
await runRequest(() => updateUserProfile(fields.values()));
fieldError(nameRow);
if (!name.value.trim()) return fieldError(nameRow, L("Common_Msg_RequiredField"));
await runRequest(() =>
updateUserProfile({
name: name.value.trim(),
position: position.value.trim() || null,
department: department.value.trim() || null,
phone: phone.value.trim() || null,
}),
);
},
});
const wrap = document.createElement("div");
wrap.append(grid, signatureSlot, save);
return wrap;
const rows = [
nameRow,
settingRow("직급", "", position),
settingRow("부서", "", department),
settingRow("전화", "", phone),
settingRow("이메일", "로그인에 쓰는 계정이라 바꿀 수 없습니다.", valueText(user.email)),
];
if (user.company_id)
rows.push(settingRow("서명", "보고서 서명란 · 도면 결재란에 들어갑니다.", signature));
rows.push(settingRow("", "", save));
return settingCard(rows);
}
export function buildSecurityForm(): HTMLElement {
const currentPassword = createInputField({
label: L("B01_Account_Field_CurrentPw"),
type: "password",
required: true,
});
const newPassword = createInputField({
label: L("B01_Account_Field_NewPw"),
type: "password",
required: true,
});
const confirmPassword = createInputField({
label: L("B01_Account_Field_ConfirmPw"),
type: "password",
required: true,
});
const grid = document.createElement("div");
grid.className = "b01-dashboard__form-grid";
grid.append(currentPassword.root, newPassword.root, confirmPassword.root);
/** 비밀번호 — [변경] 을 누르면 입력 줄이 펼쳐짐 */
export function buildPasswordCard(): HTMLElement {
const current = input("", "password", "현재 비밀번호");
const next = input("", "password", "새 비밀번호");
const confirm = input("", "password", "새 비밀번호 확인");
const rows = [
settingRow("현재 비밀번호", "", current),
settingRow("새 비밀번호", `${PASSWORD_MIN_LENGTH} 자 이상`, next),
settingRow("새 비밀번호 확인", "", confirm),
];
const form = el("div");
form.hidden = true;
const save = createButton({
label: L("B01_Account_Save_Password"),
variant: "ghost",
onClick: async function onB01_Password_Save_Click() {
currentPassword.setError();
newPassword.setError();
confirmPassword.setError();
const currentValue = currentPassword.input.value;
const nextValue = newPassword.input.value;
const confirmValue = confirmPassword.input.value;
if (isBlank(currentValue) || isBlank(nextValue) || isBlank(confirmValue)) {
currentPassword.setError(isBlank(currentValue) ? L("Common_Msg_RequiredField") : undefined);
newPassword.setError(isBlank(nextValue) ? L("Common_Msg_RequiredField") : undefined);
confirmPassword.setError(isBlank(confirmValue) ? L("Common_Msg_RequiredField") : undefined);
return;
}
if (nextValue.length < PASSWORD_MIN_LENGTH) {
newPassword.setError(L("B01_Account_Error_PwLength"));
return;
}
if (nextValue !== confirmValue) {
confirmPassword.setError(L("B01_Account_Error_PwMismatch"));
return;
}
for (const r of rows) fieldError(r);
const required = L("Common_Msg_RequiredField");
if (isBlank(current.value)) return fieldError(rows[0], required);
if (isBlank(next.value)) return fieldError(rows[1], required);
if (isBlank(confirm.value)) return fieldError(rows[2], required);
if (next.value.length < PASSWORD_MIN_LENGTH)
return fieldError(rows[1], L("B01_Account_Error_PwLength"));
if (next.value !== confirm.value)
return fieldError(rows[2], L("B01_Account_Error_PwMismatch"));
await runRequest(() =>
changePassword({
current_password: currentValue,
new_password: nextValue,
new_password_confirm: confirmValue,
current_password: current.value,
new_password: next.value,
new_password_confirm: confirm.value,
logout_all: false,
}),
);
currentPassword.input.value = "";
newPassword.input.value = "";
confirmPassword.input.value = "";
current.value = next.value = confirm.value = "";
form.hidden = true;
},
});
const wrap = document.createElement("div");
wrap.append(grid, save);
return wrap;
form.append(...rows, settingRow("", "", save));
const toggle = createButton({
label: "변경",
variant: "ghost",
onClick: () => (form.hidden = !form.hidden),
});
return settingCard([settingRow("비밀번호", "주기적으로 바꾸면 안전합니다.", toggle), form]);
}
@@ -0,0 +1,297 @@
/* =============================================================================
* B01_Dashboard_UI_ProjectDetail.ts
* 프로젝트 상세(요구 369~374 · PLAN 548-2) — 주소 `view=project&id=`.
* 머리(이름 · 종류 · 지역 · 설계속도 · [⋯] · [작업 열기 — 지금 단계 ›]) · 단계 진행(누름 = 그 단계 화면) ·
* 지도(노선 · 시점 · 종점) + 핵심 숫자 · 기본 정보 · 연결된 것 · 산출물 · 최근 변경 — 값이 있는 칸만.
* ========================================================================== */
import "./B01_Dashboard_UI_Style_Projects.css";
import type { RoutePath } from "@config/config_frontend";
import { createMapContextMenu } from "@ui/ui_template_context_menu";
import { createButton } from "@ui/ui_template_elements";
import { el } from "@ui/ui_template_elements_base";
import { goToWorkflowStage } from "../A00_Common/b_workflow_nav";
import { currentQuery } from "../A00_Common/router";
import {
fetchProjectDetail,
type ProjectChange,
type ProjectDetail,
type ProjectLink,
} from "./B01_Dashboard_Api_Projects";
import { L } from "./B01_Dashboard_UI_Common";
import { routeMap } from "./B01_Dashboard_UI_ProjectDetail_Map";
import type { ProjectViewCtx } from "./B01_Dashboard_UI_ProjectList";
import {
kindBadge,
openStage,
stageFlow,
stageProgress,
} from "./B01_Dashboard_UI_ProjectList_Parts";
import { openMenuAt, rowMenuItems } from "./B01_Dashboard_UI_ProjectList_Views";
/** 인쇄 · 내려받기 화면(B09 초안 길) — 도면 PDF · 수량 · 내역 xlsx 를 받는 곳 */
const PRINT_ROUTE = "b09-print" as RoutePath;
const ROAD_TYPE: Record<string, Parameters<typeof L>[0]> = {
main: "B02_Proj_RoadType_Main",
fire: "B02_Proj_RoadType_Fire",
work: "B02_Proj_RoadType_Work",
};
const roadType = (value?: string | null): string | null =>
value ? (ROAD_TYPE[value] ? L(ROAD_TYPE[value]) : value) : null;
const NUMBER_LABEL: Record<string, string> = {
length: "연장",
stations: "측점",
structures: "구조물",
};
const LINK_LABEL: Record<ProjectLink["kind"], string> = {
source: "원본",
derived: "이어 만든 것",
plan: "받은 예정노선",
};
function pane(title: string, ...children: (HTMLElement | SVGElement)[]): HTMLElement {
const box = el("section", { className: "b01-pd-pane", children: [el("h3", { text: title })] });
box.append(...children);
return box;
}
function steps(detail: ProjectDetail): HTMLElement {
const project = detail.project;
const { steps: names } = stageFlow(project.project_kind);
const { current } = stageProgress(project);
const states = new Map((project.workflow_state?.stages ?? []).map((s) => [s.stage_no, s.state]));
const row = el("div", { className: "b01-pd-steps" });
row.style.setProperty("--n", String(names.length));
names.forEach((name, index) => {
const state = states.get(index);
const done = state === "COMPLETE";
const cls = done
? "is-done"
: state === "STALE" || state === "FAILED"
? "is-stale"
: index === current && project.board !== "start"
? "is-cur"
: "";
const step = el("button", {
className: `b01-pd-step ${cls}`,
attrs: { type: "button", title: `${name} 화면 열기` },
children: [el("span", { text: done ? "✓" : String(index + 1) }), el("b", { text: name })],
});
step.addEventListener("click", () => openStage(project, index));
row.append(step);
});
return row;
}
function numbers(detail: ProjectDetail): HTMLElement | null {
if (!detail.numbers.length) return null;
return el("div", {
className: "b01-pd-kpis",
children: detail.numbers.map((n) =>
el("div", {
className: "b01-pd-kpi",
children: [
el("b", { text: `${n.value.toLocaleString("ko-KR")}${n.unit ? ` ${n.unit}` : ""}` }),
el("span", { text: NUMBER_LABEL[n.key] ?? n.key }),
],
}),
),
});
}
function infoPane(detail: ProjectDetail): HTMLElement {
const p = detail.project;
const year = p.project_year
? `${p.project_year}${p.project_year_end && p.project_year_end !== p.project_year ? ` ~ ${p.project_year_end}` : ""}`
: null;
const people = [
...new Set([p.manager_name, detail.people.field_lead, detail.people.designer].filter(Boolean)),
].join(" · ");
const rows: [string, string | null | undefined][] = [
["사업 연도", year],
["지역", p.region],
["임도 종류", roadType(p.road_type)],
["설계속도", p.design_speed_kph ? `${p.design_speed_kph} km/h` : null],
["시행청", p.client_org],
["사업 번호", p.project_number],
["담당", people],
["만든 날", (p.created_at ?? "").slice(0, 10)],
];
const kv = el("div", { className: "b01-pd-kv" });
for (const [label, value] of rows) {
if (value) kv.append(el("span", { text: label }), el("b", { text: value }));
}
return pane("기본 정보", kv);
}
function linkPane(detail: ProjectDetail, ctx: ProjectViewCtx): HTMLElement | null {
if (!detail.links.length) return null;
return pane(
"연결된 것",
...detail.links.map((link) =>
el("div", {
className: "b01-pd-link",
children: [
kindBadge(link.project_kind),
el("span", { text: `${LINK_LABEL[link.kind]} · ${link.name}` }),
...(link.note ? [el("span", { className: "b01-proj-mini", text: link.note })] : []),
el("span", { className: "b01-proj-sp" }),
createButton({
label: "보기",
variant: "ghost",
onClick: () => ctx.go("project", { id: link.project_id }),
}),
],
}),
),
);
}
function outputPane(detail: ProjectDetail): HTMLElement | null {
if (!detail.outputs.length) return null;
return pane(
"산출물 내려받기",
...detail.outputs.map((out) =>
el("div", {
className: "b01-pd-link",
attrs: { "data-output": out.key },
children: [
el("span", { text: out.label }),
el("span", { className: "b01-proj-sp" }),
createButton({
label: "받기 화면",
variant: "ghost",
onClick: () => void goToWorkflowStage(detail.project.id, PRINT_ROUTE),
}),
],
}),
),
);
}
function changeText(change: ProjectChange, detail: ProjectDetail): string {
const what =
change.action === "STAGE_COMPLETE"
? `${stageFlow(detail.project.project_kind).steps[change.stage_no ?? 0] ?? ""} 마침`
: change.action === "PROJECT_CREATE"
? "만듦"
: "정보 고침";
return [change.at.slice(0, 16), change.who, what].filter(Boolean).join(" · ");
}
function changePane(detail: ProjectDetail): HTMLElement | null {
if (!detail.changes.length) return null;
return pane(
"최근 변경",
el("div", {
className: "b01-pd-timeline",
children: detail.changes.map((c) => el("div", { text: changeText(c, detail) })),
}),
);
}
function head(detail: ProjectDetail, ctx: ProjectViewCtx): HTMLElement {
const p = detail.project;
const { steps: names } = stageFlow(p.project_kind);
const { current } = stageProgress(p);
const sub = [
p.region,
roadType(p.road_type),
p.design_speed_kph && `설계속도 ${p.design_speed_kph} km/h`,
]
.filter(Boolean)
.join(" · ");
const bar = el("div", { className: "b01-pd-head" });
const menu = createMapContextMenu("b01-proj-menu");
const items = rowMenuItems(p, ctx.me);
const more = el("button", {
className: "b01-proj-more",
text: "⋯",
attrs: { type: "button", "aria-label": "더 보기" },
});
more.hidden = !items.length;
more.addEventListener("click", (event) => {
event.stopPropagation();
openMenuAt(menu, bar, more, items);
});
const work = createButton({
label: names.length ? `작업 열기 — ${names[current] ?? names[0]} ›` : "작업 열기 ›",
onClick: () => openStage(p, current),
});
work.dataset.openWork = "";
bar.append(
el("h2", { text: p.name }),
kindBadge(p.project_kind),
el("span", { className: "b01-proj-mini", text: sub }),
el("span", { className: "b01-proj-sp" }),
more,
work,
menu.element,
);
return bar;
}
function render(detail: ProjectDetail, ctx: ProjectViewCtx): HTMLElement {
const left: HTMLElement[] = [];
const kpis = numbers(detail);
if (detail.route) {
left.push(pane("노선 미리보기", routeMap(detail.route.points), ...(kpis ? [kpis] : [])));
} else if (kpis) {
left.push(pane("핵심 숫자", kpis));
}
const right = [
infoPane(detail),
linkPane(detail, ctx),
outputPane(detail),
changePane(detail),
].filter((x): x is HTMLElement => x !== null);
const crumb = el("button", {
className: "b01-pd-crumb",
text: "프로젝트 ›",
attrs: { type: "button" },
});
crumb.addEventListener("click", () => ctx.go("projects"));
return el("div", {
className: "b01-pd",
children: [
crumb,
head(detail, ctx),
pane("단계 진행 · 누르면 그 단계로", steps(detail)),
el("div", {
className: `b01-pd-grid${left.length ? "" : " is-single"}`,
children: [
el("div", { className: "b01-pd-col", children: left }),
el("div", { className: "b01-pd-col", children: right }),
],
}),
],
});
}
/** 「프로젝트 상세」 — 등록부 mount · id = 주소 `id` */
export function mountProjectDetail(host: HTMLElement, ctx: ProjectViewCtx): () => void {
let alive = true;
const id = currentQuery().get("id") ?? "";
host.replaceChildren(el("p", { className: "b01-proj-empty", text: "불러오는 중…" }));
void fetchProjectDetail(id).then(
(detail) => alive && host.replaceChildren(render(detail, ctx)),
(error) => {
if (!alive) return;
const back = createButton({ label: "프로젝트 목록", onClick: () => ctx.go("projects") });
host.replaceChildren(
el("div", {
className: "b01-proj-empty",
children: [
el("p", { text: error instanceof Error ? error.message : String(error) }),
back,
],
}),
);
},
);
return () => {
alive = false;
};
}
@@ -0,0 +1,95 @@
/* =============================================================================
* B01_Dashboard_UI_ProjectDetail_Map.ts
* 상세 지도 미리보기(PLAN 548-2) — 주소 지도(`UI_MapPreview`)와 같은 배경 타일 길(서버가 VWorld 를 받아 넘김)
* 위에 노선 선 · 시점 · 종점. 라이브러리 없이 SVG 한 장(타일 = image · 선 = path) — 칸 너비에 맞춰 줄어듦.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
const W = 640;
const H = 260;
const TILE = 256;
const PAD = 28;
const MAX_ZOOM = 17;
const MIN_ZOOM = 6;
const NS = "http://www.w3.org/2000/svg";
/** 위경도 → 그 배율의 전역 픽셀(웹 메르카토르) */
function toPixel(lat: number, lon: number, zoom: number): [number, number] {
const size = TILE * 2 ** zoom;
const rad = (lat * Math.PI) / 180;
return [
((lon + 180) / 360) * size,
((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * size,
];
}
/** 선이 판(여백 뺌)에 다 드는 가장 큰 배율 */
export function fitZoom(points: [number, number][]): number {
for (let zoom = MAX_ZOOM; zoom > MIN_ZOOM; zoom -= 1) {
const px = points.map(([lat, lon]) => toPixel(lat, lon, zoom));
const xs = px.map((p) => p[0]);
const ys = px.map((p) => p[1]);
if (
Math.max(...xs) - Math.min(...xs) <= W - 2 * PAD &&
Math.max(...ys) - Math.min(...ys) <= H - 2 * PAD
) {
return zoom;
}
}
return MIN_ZOOM;
}
function node<K extends keyof SVGElementTagNameMap>(
tag: K,
attrs: Record<string, string | number>,
): SVGElementTagNameMap[K] {
const element = document.createElementNS(NS, tag);
for (const [key, value] of Object.entries(attrs)) element.setAttribute(key, String(value));
return element;
}
/** 노선 지도 — points = [lat, lon] 두 점 넘게 */
export function routeMap(points: [number, number][]): SVGSVGElement {
const zoom = fitZoom(points);
const px = points.map(([lat, lon]) => toPixel(lat, lon, zoom));
const xs = px.map((p) => p[0]);
const ys = px.map((p) => p[1]);
// 판 왼쪽 위 = 선 가운데 - 판 반
const left = (Math.min(...xs) + Math.max(...xs)) / 2 - W / 2;
const top = (Math.min(...ys) + Math.max(...ys)) / 2 - H / 2;
const svg = node("svg", { viewBox: `0 0 ${W} ${H}`, class: "b01-pd-map" });
const limit = 2 ** zoom;
for (let tx = Math.floor(left / TILE); tx <= Math.floor((left + W) / TILE); tx += 1) {
for (let ty = Math.floor(top / TILE); ty <= Math.floor((top + H) / TILE); ty += 1) {
if (tx < 0 || ty < 0 || tx >= limit || ty >= limit) continue;
svg.append(
node("image", {
href: `${API_BASE_URL}/dashboard/map/tile/${zoom}/${tx}/${ty}`,
x: tx * TILE - left,
y: ty * TILE - top,
width: TILE,
height: TILE,
}),
);
}
}
const local = px.map(([x, y]) => [x - left, y - top] as const);
svg.append(
node("path", {
class: "b01-pd-map__line",
d: local.map(([x, y], i) => `${i ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)}`).join(" "),
}),
);
const ends: [readonly [number, number], string][] = [
[local[0], "시점"],
[local[local.length - 1], "종점"],
];
for (const [[x, y], label] of ends) {
svg.append(node("circle", { class: "b01-pd-map__end", cx: x, cy: y, r: 5 }));
const text = node("text", { class: "b01-pd-map__label", x: x + 8, y: y - 6 });
text.textContent = label;
svg.append(text);
}
return svg;
}
@@ -0,0 +1,231 @@
/* =============================================================================
* B01_Dashboard_UI_ProjectList.ts
* 대시보드 「프로젝트」 세부 페이지(요구 369~374 · PLAN 548-1) — 툴바(찾기 · 종류 칩 · 담당 · 보기 ·
* 카드/표/칸반 · [+ 새 프로젝트]) + 고른 보기. 고른 보기는 계정 취향(`aislo:pref:`)으로 기억.
* 칸반은 9 개 넘을 때만 · 칸은 서버가 단계 상태로 나눔(끌기 없음).
* ========================================================================== */
import "./B01_Dashboard_UI_Style_Projects.css";
import { createButton, showToast } from "@ui/ui_template_elements";
import { el } from "@ui/ui_template_elements_base";
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
import type { DashboardUser } from "./B01_Dashboard_Api_Fetch";
import { fetchOverviewProjects, type OverviewProject } from "./B01_Dashboard_Api_Projects";
import { openNewProjectPicker } from "./B01_Dashboard_UI_NewProject";
import { KINDS } from "./B01_Dashboard_UI_ProjectList_Parts";
import { cardGrid, kanbanBoard, projectRows } from "./B01_Dashboard_UI_ProjectList_Views";
/** 등록부 ctx(546) 가운데 이 페이지가 쓰는 것 */
export interface ProjectViewCtx {
me: DashboardUser;
companyId: number | null;
go: (view: string, params?: Record<string, string>) => void;
}
type ViewMode = "card" | "table" | "kanban";
type Scope = "mine" | "company";
/** 칸반이 열리는 수 — 넘으면 [칸반] 단추 */
export const KANBAN_MIN = 10;
const PREF_VIEW = "aislo:pref:b01.projects.view";
const PREF_SCOPE = "aislo:pref:b01.projects.scope";
interface ListState {
scope: Scope;
view: ViewMode;
kind: string;
manager: string;
query: string;
projects: OverviewProject[];
}
/** 거르기 — 종류 · 담당 · 찾기(이름 · 지역) */
export function filterProjects(
projects: OverviewProject[],
kind: string,
manager: string,
query: string,
): OverviewProject[] {
const q = query.trim().toLowerCase();
return projects.filter(
(p) =>
(!kind || (p.project_kind ?? "ROAD") === kind) &&
(!manager || p.manager_name === manager) &&
(!q || `${p.name} ${p.region ?? ""}`.toLowerCase().includes(q)),
);
}
function segment<T extends string>(
items: { key: T; label: string }[],
active: T,
onPick: (key: T) => void,
): HTMLElement {
const seg = el("span", { className: "b01-proj-seg", attrs: { role: "group" } });
for (const item of items) {
const button = el("button", {
className: item.key === active ? "is-on" : "",
text: item.label,
attrs: { type: "button", "data-key": item.key },
});
button.addEventListener("click", () => onPick(item.key));
seg.append(button);
}
return seg;
}
function chip(label: string, on: boolean, onClick?: () => void, soon = false): HTMLElement {
const button = el("button", {
className: `b01-proj-chip${on ? " is-on" : ""}${soon ? " is-soon" : ""}`,
text: label,
attrs: { type: "button" },
});
button.addEventListener("click", () => (soon ? showToast(label) : onClick?.()));
return button;
}
/** 「프로젝트」 페이지 — 등록부 mount */
export function mountProjectList(host: HTMLElement, ctx: ProjectViewCtx): () => void {
let alive = true;
const state: ListState = {
scope: readByKey(PREF_SCOPE) === "mine" ? "mine" : "company",
view: (readByKey(PREF_VIEW) as ViewMode | null) ?? "card",
kind: "",
manager: "",
query: "",
projects: [],
};
const root = el("div", { className: "b01-proj" });
const head = el("div", { className: "b01-proj-head" });
const tools = el("div", { className: "b01-proj-tools" });
const body = el("div", { className: "b01-proj-body" });
root.append(head, tools, body);
host.replaceChildren(root);
const search = el("input", {
className: "b01-proj-search",
attrs: { type: "search", placeholder: "이름 · 지역 찾기" },
});
search.addEventListener("input", () => {
state.query = search.value;
paintBody();
});
const open = (project: OverviewProject): void => ctx.go("project", { id: project.id });
function paintHead(): void {
const kanbanOk = state.projects.length >= KANBAN_MIN;
const views: { key: ViewMode; label: string }[] = [
{ key: "card", label: "카드" },
{ key: "table", label: "표" },
...(kanbanOk ? [{ key: "kanban" as ViewMode, label: "칸반" }] : []),
];
head.replaceChildren(
el("h2", { text: "프로젝트" }),
el("span", { className: "b01-proj-count", text: String(state.projects.length) }),
el("span", { className: "b01-proj-sp" }),
segment(views, shownView(), (key) => {
state.view = key;
writeByKey(PREF_VIEW, key);
paintHead();
paintBody();
}),
createButton({ label: "+ 새 프로젝트", onClick: () => void openNewProjectPicker() }),
);
}
/** 기억한 보기가 칸반인데 9 개 이하 = 카드 */
const shownView = (): ViewMode =>
state.view === "kanban" && state.projects.length < KANBAN_MIN ? "card" : state.view;
function paintTools(): void {
const count = (kind: string): number =>
state.projects.filter((p) => (p.project_kind ?? "ROAD") === kind).length;
const chips = [
chip(`전체 ${state.projects.length}`, !state.kind, () => pickKind("")),
...KINDS.map((k) =>
"soon" in k
? chip(`${k.label()} · 준비 중`, false, undefined, true)
: chip(`${k.label()} ${count(k.key)}`, state.kind === k.key, () => pickKind(k.key)),
),
];
const managers = [...new Set(state.projects.map((p) => p.manager_name).filter(Boolean))];
const select = el("select", { className: "b01-proj-select", attrs: { "aria-label": "담당" } });
select.append(new Option("담당 : 전체", ""), ...managers.map((m) => new Option(m!, m!)));
select.value = state.manager;
select.addEventListener("change", () => {
state.manager = select.value;
paintBody();
});
const scope = segment(
[
{ key: "mine" as Scope, label: "내 것" },
{ key: "company" as Scope, label: "회사 전체" },
],
state.scope,
(key) => {
state.scope = key;
writeByKey(PREF_SCOPE, key);
void load();
},
);
tools.replaceChildren(
search,
...chips,
el("span", { className: "b01-proj-sp" }),
select,
scope,
);
}
function pickKind(kind: string): void {
state.kind = kind;
paintTools();
paintBody();
}
function paintBody(): void {
const shown = filterProjects(state.projects, state.kind, state.manager, state.query);
if (!shown.length) {
body.replaceChildren(
el("p", {
className: "b01-proj-empty",
text: state.projects.length
? "조건에 맞는 프로젝트가 없습니다."
: "아직 프로젝트가 없습니다. [+ 새 프로젝트]로 시작합니다.",
}),
);
return;
}
const view = shownView();
body.replaceChildren(
view === "table"
? projectRows(shown, ctx.me, open)
: view === "kanban"
? kanbanBoard(shown, open)
: cardGrid(shown, open),
);
}
async function load(): Promise<void> {
body.replaceChildren(el("p", { className: "b01-proj-empty", text: "불러오는 중…" }));
try {
const projects = await fetchOverviewProjects(state.scope, ctx.companyId);
if (!alive) return;
state.projects = projects;
} catch (error) {
if (!alive) return;
state.projects = [];
showToast(error instanceof Error ? error.message : String(error), "error");
}
paintHead();
paintTools();
paintBody();
}
paintHead();
paintTools();
void load();
return () => {
alive = false;
};
}
@@ -0,0 +1,130 @@
/* =============================================================================
* B01_Dashboard_UI_ProjectList_Parts.ts
* 프로젝트 목록 · 상세 공용 조각(PLAN 548) — 종류 배지 · 단계 글 · 진행 막대 · 썸네일 선 · 칸반 칸 이름.
* ========================================================================== */
import { el } from "@ui/ui_template_elements_base";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
import { groupFlowOf } from "../A00_Common/group_workflow";
import type { RoutePath } from "@config/config_frontend";
import type { BoardColumn, OverviewProject } from "./B01_Dashboard_Api_Projects";
import { L } from "./B01_Dashboard_UI_Common";
/** 종류 — 칩 · 배지 차례 · 사방설계 = 준비 중 */
export const KINDS = [
{ key: "ROAD", label: () => L("DE_New_Road") },
{ key: "PLAN", label: () => L("DE_New_Plan") },
{ key: "STABILITY", label: () => L("DE_New_Stability") },
{ key: "EROSION", label: () => L("DE_New_Erosion"), soon: true },
] as const;
export const kindLabel = (kind?: string | null): string =>
(KINDS.find((k) => k.key === (kind ?? "ROAD")) ?? KINDS[0]).label();
export function kindBadge(kind?: string | null): HTMLElement {
const key = (kind ?? "ROAD").toLowerCase();
return el("span", { className: `b01-proj-badge b01-proj-badge--${key}`, text: kindLabel(kind) });
}
export const BOARD_COLUMNS: { key: BoardColumn; label: string }[] = [
{ key: "start", label: "시작 전" },
{ key: "design", label: "설계 중" },
{ key: "review", label: "검토" },
{ key: "confirm", label: "확정 대기" },
{ key: "done", label: "완료" },
];
export const boardLabel = (key: BoardColumn): string =>
BOARD_COLUMNS.find((c) => c.key === key)?.label ?? key;
/** 종류별 단계 이름 · 주소 */
export function stageFlow(kind?: string | null): {
steps: string[];
routes: readonly RoutePath[];
} {
const group = groupFlowOf(kind);
return group
? { steps: group.steps, routes: group.routes }
: { steps: workflowSteps(), routes: WORKFLOW_STEP_ROUTES };
}
/** 지금 단계 번호(0 부터) · 마친 단계 수 */
export function stageProgress(project: OverviewProject): { current: number; done: number } {
const stages = project.workflow_state?.stages ?? [];
return {
current: project.workflow_state?.current_stage ?? 0,
done: stages.filter((s) => s.state === "COMPLETE").length,
};
}
/** 「횡단설계 4/7」 — 시작 전 = 0 · 완료 = 전체 */
export function stageText(project: OverviewProject): string {
const { steps } = stageFlow(project.project_kind);
const total = project.stage_total || steps.length;
if (!total) return L("DE_New_Soon");
const { current } = stageProgress(project);
if (project.board === "done") return `완료 ${total}/${total}`;
const n = project.board === "start" ? 0 : current + 1;
return `${steps[current] ?? ""} ${n}/${total}`;
}
export function progressBar(project: OverviewProject): HTMLElement {
const total = project.stage_total || 1;
const bar = el("span", { className: "b01-proj-bar" });
const fill = el("i");
fill.style.width = `${Math.round((stageProgress(project).done / total) * 100)}%`;
bar.append(fill);
return bar;
}
/** 단계 점 — 마침 · 지금 · 남음 */
export function stageDots(project: OverviewProject): HTMLElement {
const total = project.stage_total;
const { current } = stageProgress(project);
const states = new Map((project.workflow_state?.stages ?? []).map((s) => [s.stage_no, s.state]));
const row = el("span", { className: "b01-proj-dots" });
for (let i = 0; i < total; i += 1) {
const state = states.get(i);
const cls =
state === "COMPLETE"
? "is-done"
: state === "STALE" || state === "FAILED"
? "is-stale"
: i === current && project.board !== "start"
? "is-cur"
: "";
row.append(el("i", { className: cls }));
}
return row;
}
/** 단계 화면으로 — 종류별 주소 */
export function openStage(project: OverviewProject, index: number): void {
const { routes } = stageFlow(project.project_kind);
const route = routes[Math.max(0, Math.min(index, routes.length - 1))];
if (route) void goToWorkflowStage(project.id, route);
}
/** 썸네일 선 — 카드 요약(547-1) 0~1 칸 좌표(y 아래로) · 비율 그대로 가운데 · 없으면 빈 판 */
export function thumbSvg(points: number[][] | null | undefined): SVGElement {
const ns = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(ns, "svg");
svg.setAttribute("viewBox", "-0.1 -0.1 1.2 1.2");
svg.classList.add("b01-proj-thumb");
if (!points || points.length < 2) return svg;
const path = document.createElementNS(ns, "path");
path.setAttribute("d", points.map(([x, y], i) => `${i ? "L" : "M"}${x} ${y}`).join(" "));
svg.append(path);
return svg;
}
/** 「오늘」 · 「어제」 · 「n 일 전」 · 그 밖 날짜 */
export function ago(project: OverviewProject): string {
const days = project.idle_days;
if (days === null || days === undefined) return "-";
if (days <= 0) return "오늘";
if (days === 1) return "어제";
if (days < 7) return `${days} 일 전`;
return (project.updated_at ?? "").slice(0, 10);
}
@@ -0,0 +1,192 @@
/* =============================================================================
* B01_Dashboard_UI_ProjectList_Views.ts
* 프로젝트 목록 보기 셋(PLAN 548-1) — 카드 · 표(줄 끝 ⋯) · 칸반(칸 = 서버 `board` · 완료 칸 접힘).
* ========================================================================== */
import {
createMapContextMenu,
type MapContextMenu,
type MapContextMenuItem,
} from "@ui/ui_template_context_menu";
import { el } from "@ui/ui_template_elements_base";
import type { DashboardUser } from "./B01_Dashboard_Api_Fetch";
import type { OverviewProject } from "./B01_Dashboard_Api_Projects";
import { canDeleteProject, canEditProject } from "./B01_Dashboard_UI_Helper";
import { openDeleteProjectModal, openEditProjectModal } from "./B01_Dashboard_UI_Modals";
import {
ago,
BOARD_COLUMNS,
boardLabel,
kindBadge,
progressBar,
stageDots,
stageText,
thumbSvg,
} from "./B01_Dashboard_UI_ProjectList_Parts";
type Open = (project: OverviewProject) => void;
/** 누름 = 상세 · Enter 도 같음 */
function clickable<T extends HTMLElement>(node: T, onOpen: () => void): T {
node.tabIndex = 0;
node.addEventListener("click", onOpen);
node.addEventListener("keydown", (event) => {
if (event.key === "Enter") onOpen();
});
return node;
}
function stalledMark(project: OverviewProject): HTMLElement[] {
return project.stalled
? [
el("span", {
className: "b01-proj-stalled",
text: `${project.idle_days} 일 그대로`,
attrs: { title: "14 일 넘게 고친 것이 없음" },
}),
]
: [];
}
function card(project: OverviewProject, onOpen: Open, compact = false): HTMLElement {
const box = el("div", {
className: `b01-proj-card b01-proj-card--${(project.project_kind ?? "ROAD").toLowerCase()}`,
attrs: { "data-project": project.id },
});
const info = el("div", {
className: "b01-proj-card__body",
children: [
el("b", { className: "b01-proj-card__name", text: project.name }),
el("div", {
className: "b01-proj-card__line",
children: [
kindBadge(project.project_kind),
el("span", { className: "b01-proj-mini", text: stageText(project) }),
],
}),
...(compact ? [progressBar(project)] : [stageDots(project)]),
el("div", {
className: "b01-proj-card__meta",
children: [
el("span", { text: project.manager_name ?? "-" }),
...stalledMark(project),
el("span", { text: ago(project) }),
],
}),
],
});
box.append(thumbSvg(project.thumb), info);
return clickable(box, () => onOpen(project));
}
export function cardGrid(projects: OverviewProject[], onOpen: Open): HTMLElement {
return el("div", {
className: "b01-proj-cards",
children: projects.map((p) => card(p, onOpen)),
});
}
/** ⋯ 메뉴 — 오른쪽 끝을 단추 오른쪽에 맞춤(host = position 기준 · 메뉴 element 를 품은 것) */
export function openMenuAt(
menu: MapContextMenu,
host: HTMLElement,
button: HTMLElement,
items: MapContextMenuItem[],
): void {
const box = host.getBoundingClientRect();
const at = button.getBoundingClientRect();
menu.open(at.right - box.left, at.bottom - box.top, items);
menu.element.style.left = `${at.right - box.left - menu.element.offsetWidth}px`;
}
/** 줄 끝 ⋯ — 지금 있는 동작만(수정 · 삭제) */
export function rowMenuItems(project: OverviewProject, me: DashboardUser): MapContextMenuItem[] {
const items: MapContextMenuItem[] = [];
if (canEditProject(me, project)) {
items.push(["수정", () => void openEditProjectModal(me, project)]);
}
if (canDeleteProject(me)) {
items.push("-", ["삭제", () => openDeleteProjectModal(me, project)]);
}
return items;
}
export function projectRows(
projects: OverviewProject[],
me: DashboardUser,
onOpen: Open,
): HTMLElement {
const wrap = el("div", { className: "b01-proj-table" });
const menu = createMapContextMenu("b01-proj-menu");
wrap.append(menu.element);
const head = ["이름", "종류", "지역", "진행", "상태", "담당", "수정일", ""];
const table = el("table");
const thead = el("tr", { children: head.map((h) => el("th", { text: h })) });
table.append(thead);
for (const project of projects) {
const more = el("button", {
className: "b01-proj-more",
text: "⋯",
attrs: { type: "button", "aria-label": "더 보기" },
});
const items = rowMenuItems(project, me);
if (!items.length) more.hidden = true;
more.addEventListener("click", (event) => {
event.stopPropagation();
openMenuAt(menu, wrap, more, items);
});
const row = el("tr", {
className: "b01-proj-row",
attrs: { "data-project": project.id },
children: [
el("td", { children: [el("b", { text: project.name }), ...stalledMark(project)] }),
el("td", { children: [kindBadge(project.project_kind)] }),
el("td", { text: project.region ?? "-" }),
el("td", {
className: "b01-proj-prog",
children: [progressBar(project), el("span", { text: stageText(project) })],
}),
el("td", { text: boardLabel(project.board) }),
el("td", { text: project.manager_name ?? "-" }),
el("td", { text: (project.updated_at ?? "").slice(0, 10) || "-" }),
el("td", { children: [more] }),
],
});
table.append(clickable(row, () => onOpen(project)));
}
wrap.append(table);
return wrap;
}
/** 칸반 — 칸 차례 고정 · 완료 칸은 접혀 시작(머리 누름 = 펼침) */
export function kanbanBoard(projects: OverviewProject[], onOpen: Open): HTMLElement {
const board = el("div", { className: "b01-proj-kanban" });
for (const column of BOARD_COLUMNS) {
const items = projects.filter((p) => p.board === column.key);
const col = el("section", {
className: "b01-proj-col",
attrs: { "data-board": column.key },
});
const title = el("h4", {
children: [
el("span", { text: column.label }),
el("span", { className: "b01-proj-mini", text: String(items.length) }),
],
});
const list = el("div", {
className: "b01-proj-col__list",
children: items.map((p) => card(p, onOpen, true)),
});
if (column.key === "done") {
col.classList.add("is-folded");
title.append(el("span", { className: "b01-proj-fold", text: "펼치기" }));
title.addEventListener("click", () => {
const folded = col.classList.toggle("is-folded");
title.lastElementChild!.textContent = folded ? "펼치기" : "접기";
});
}
col.append(title, list);
board.append(col);
}
return board;
}
-151
View File
@@ -1,151 +0,0 @@
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createLineChart } from "@ui/ui_template_elements";
import type { ResourceData } from "./B01_Dashboard_Api_Fetch";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
function percent(value?: number | null): string {
return value == null ? "-" : `${Math.round(value)}%`;
}
export function buildResourcePanel(resources: ResourceData | null): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b01-dashboard__resource-panel";
wrap.append(buildResourceMetrics(resources), buildResourceChart(resources));
return wrap;
}
function buildResourceMetrics(resources: ResourceData | null): HTMLElement {
const values = resources?.current;
const grid = document.createElement("div");
grid.className = "b01-dashboard__metric-grid";
const items = [
[L("B01_Dashboard_Metric_Cpu"), percent(values?.cpu_usage_percent)],
[L("B01_Dashboard_Metric_Memory"), percent(values?.memory_usage_percent)],
[L("B01_Dashboard_Metric_Disk"), percent(values?.disk_usage_percent)],
[L("B01_Dashboard_Metric_ActiveUsers"), values?.active_user_count ?? 0],
[L("B01_Dashboard_Metric_ActiveProjects"), values?.active_project_count ?? 0],
];
for (const [label, value] of items) {
const box = document.createElement("div");
box.className = "b01-dashboard__metric";
const labelEl = document.createElement("span");
labelEl.className = "b01-dashboard__metric-label";
labelEl.textContent = String(label);
const valueEl = document.createElement("span");
valueEl.className = "b01-dashboard__metric-value";
valueEl.textContent = String(value);
box.append(labelEl, valueEl);
grid.append(box);
}
return grid;
}
function buildResourceChart(resources: ResourceData | null): HTMLElement {
const box = document.createElement("div");
box.className = "b01-dashboard__chart";
const caption = document.createElement("p");
caption.className = "b01-dashboard__chart-caption";
caption.textContent = L("B01_Dashboard_Resources_ChartCaption");
box.append(caption);
const chartWrap = document.createElement("div");
chartWrap.className = "b01-dashboard__chart-content";
box.append(chartWrap);
const history = resources?.history ?? [];
const points: { timestamp: Date; cpu: number | null; mem: number | null; disk: number | null }[] =
[];
const now = new Date();
const baseDate = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
now.getHours() >= 12 ? 24 : 12,
0,
0,
0,
);
// 7일을 1시간 버킷으로 분할. 버킷이 넓으면 짧은 가동 구간이 한 칸에 뭉쳐
// 점 하나(선 없음)가 되므로, 가동 시간대가 연속 버킷으로 이어지도록 1시간 단위 사용.
const HOURS = 7 * 24;
for (let i = 0; i <= HOURS; i++) {
const time = new Date(baseDate.getTime() - (HOURS - i) * 60 * 60 * 1000);
points.push({ timestamp: time, cpu: null, mem: null, disk: null });
}
history.forEach((h) => {
if (!h.timestamp) return;
const tsStr = h.timestamp.endsWith("Z") ? h.timestamp : h.timestamp + "Z";
const hTime = new Date(tsStr);
let closestIdx = -1;
let minDiff = Infinity;
points.forEach((p, idx) => {
const diff = Math.abs(p.timestamp.getTime() - hTime.getTime());
if (diff < minDiff && diff <= 30 * 60 * 1000) {
minDiff = diff;
closestIdx = idx;
}
});
if (closestIdx !== -1) {
const p = points[closestIdx];
p.cpu = h.cpu_usage_percent;
p.mem = h.memory_usage_percent;
p.disk = h.disk_usage_percent;
}
});
const xLabels = points.map((p) => {
const hours = p.timestamp.getHours();
return hours === 0 ? `${p.timestamp.getMonth() + 1}/${p.timestamp.getDate()}` : "";
});
let resizeTimeout: number | null = null;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const width = Math.floor(entry.contentRect.width);
if (width <= 0) continue;
if (resizeTimeout) {
window.cancelAnimationFrame(resizeTimeout);
}
resizeTimeout = window.requestAnimationFrame(() => {
chartWrap.innerHTML = "";
const chart = createLineChart({
ariaLabel: L("B01_Dashboard_Resources_ChartAria"),
xLabels,
width: width,
height: 140,
series: [
{
name: L("B01_Dashboard_Metric_Cpu"),
colorIndex: 0,
values: points.map((p) => p.cpu),
},
{
name: L("B01_Dashboard_Metric_Memory"),
colorIndex: 1,
values: points.map((p) => p.mem),
},
{
name: L("B01_Dashboard_Metric_Disk"),
colorIndex: 2,
values: points.map((p) => p.disk),
},
],
});
chartWrap.append(chart);
});
}
});
observer.observe(box);
return box;
}
+326
View File
@@ -0,0 +1,326 @@
/* =============================================================================
* B01_Dashboard_UI_Shell.ts
* 대시보드 셸(546) — 위 머리 48 + 왼쪽 메뉴 210 + 세부 페이지 자리 + 오른쪽 아래 챗봇 단추.
* 머리 = AISLO · 회사 이름(시스템 관리자 = 눌러 회사 전환) · 찾기(자리만) · [?] · 알림 · 사용자 메뉴
* 페이지 = 등록부(`B01_Dashboard_UI_Views.ts`) · 주소 `?view=` 가 바뀌면 셸은 두고 페이지만 갈아 끼움
* (라우터 MountedPage — 같은 주소 안 이동 = show) · 옛 ☰ 서랍 · 구역 접기 없음
* ========================================================================== */
import { LANG_STORAGE_KEY, ROUTES, THEME_STORAGE_KEY } from "@config/config_frontend";
import { createHelpButton } from "@ui/ui_template_help";
import { icon, type IconName } from "@ui/ui_template_icons";
import { currentLanguageIndex, setLanguage } from "@ui/ui_template_locale";
import { readTouchMode, setTouchMode, type TouchMode } from "../A00_Common/device_context";
import { currentQuery, navigateTo, type MountedPage } from "../A00_Common/router";
import {
fetchAllCompanies,
fetchCompanyJoinRequests,
type CompanyInfo,
type DashboardUser,
} from "./B01_Dashboard_Api_Fetch";
import { fetchTempBatches } from "./B01_Dashboard_Api_Temp";
import { fetchAssignedPlans } from "./B01_Dashboard_UI_AssignedPlans";
import { roleLabel } from "./B01_Dashboard_UI_Common";
import { openCompanyPicker } from "./B01_Dashboard_UI_CompanyPick";
import { buildKnowledgeChat } from "./B01_Dashboard_UI_Knowledge_Chat";
import { ADMIN_COMPANY_KEY, pickAdminCompany } from "./B01_Dashboard_UI_Legacy";
import { buildNav, viewHref, type DashboardNav } from "./B01_Dashboard_UI_Nav";
import { roleOf, viewFor, VIEWS, type ViewCtx } from "./B01_Dashboard_UI_Views";
import "./B01_Dashboard_UI_Style_Shell.css";
function iconButton(name: IconName, label: string, onClick?: () => void): HTMLButtonElement {
const b = document.createElement("button");
b.type = "button";
b.className = "b01-shell__icon-btn";
b.title = b.ariaLabel = label;
b.append(icon(name));
if (onClick) b.addEventListener("click", onClick);
else b.disabled = true;
return b;
}
function menuItem(name: IconName, label: string, onClick: () => void): HTMLButtonElement {
const b = document.createElement("button");
b.type = "button";
b.className = "b01-shell__menu-item";
const text = document.createElement("span");
text.textContent = label;
b.append(icon(name, 16), text);
b.addEventListener("click", onClick);
return b;
}
function toggleTheme(): void {
const next = document.documentElement.getAttribute("data-theme") === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
localStorage.setItem(THEME_STORAGE_KEY, next);
}
/** 로그아웃 — 옛 ☰ 와 같음(서버 로그아웃 → 이 브라우저 자료 지움 → 홈으로 새로고침) */
async function logoutNow(): Promise<void> {
const { logout } = await import("../A06_Login/A06_Login_Api_Fetch");
await logout().catch(() => undefined); // 세션이 이미 만료된 경우 등은 무시
const { wipeAccountCache } = await import("../A00_Common/b_project_switch");
await wipeAccountCache();
history.replaceState(null, "", `#/${ROUTES.A01_HOME}`);
window.location.reload();
}
/** 사용자 메뉴 — 이름 · 역할 · 내 정보 · 화면(밝음 · 어두움 · 언어 · 터치) · 설정 · 홈페이지 · 로그아웃 */
function buildUserMenu(ctx: ViewCtx, roleText: string): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b01-shell__user";
const trigger = document.createElement("button");
trigger.type = "button";
trigger.className = "b01-shell__user-btn";
trigger.setAttribute("aria-haspopup", "menu");
trigger.setAttribute("aria-expanded", "false");
const avatar = document.createElement("span");
avatar.className = "b01-shell__avatar";
avatar.textContent = (ctx.me.name?.trim() || ctx.me.email).slice(0, 1);
const name = document.createElement("span");
name.className = "b01-shell__user-name";
name.textContent = ctx.me.name?.trim() || ctx.me.email;
trigger.append(avatar, name, icon("chevronDown", 14));
const menu = document.createElement("div");
menu.className = "b01-shell__menu";
menu.setAttribute("role", "menu");
menu.hidden = true;
const who = document.createElement("div");
who.className = "b01-shell__menu-who";
const strong = document.createElement("strong");
strong.textContent = name.textContent;
const sub = document.createElement("span");
sub.textContent = `${roleText} · ${ctx.me.email}`;
who.append(strong, sub);
const touch = document.createElement("div");
touch.className = "b01-shell__menu-touch";
const touchLabel = document.createElement("span");
touchLabel.textContent = "터치 모드";
touch.append(icon("hand", 16), touchLabel);
const modes: [TouchMode, string][] = [
["auto", "자동"],
["on", "켬"],
["off", "끔"],
];
for (const [mode, label] of modes) {
const b = document.createElement("button");
b.type = "button";
b.textContent = label;
b.dataset.touchMode = mode;
b.setAttribute("aria-pressed", String(readTouchMode() === mode));
b.addEventListener("click", () => {
setTouchMode(mode);
for (const x of touch.querySelectorAll("button"))
x.setAttribute("aria-pressed", String(x.dataset.touchMode === mode));
});
touch.append(b);
}
const setOpen = (open: boolean): void => {
menu.hidden = !open;
trigger.setAttribute("aria-expanded", String(open));
};
// 테마 줄 = 지금과 반대 화면 이름 · 바꾼 뒤 다시 씀
const themeItem = (): HTMLButtonElement => {
const dark = document.documentElement.getAttribute("data-theme") === "dark";
return menuItem(dark ? "sun" : "moon", dark ? "밝은 화면" : "어두운 화면", () => {
toggleTheme();
theme.replaceWith((theme = themeItem()));
});
};
let theme = themeItem();
menu.append(
who,
menuItem("userCircle", "내 정보", () => ctx.go("me")),
theme,
menuItem("world", currentLanguageIndex === 0 ? "English" : "한국어", () => {
const next = currentLanguageIndex === 0 ? "en" : "ko";
setLanguage(next);
localStorage.setItem(LANG_STORAGE_KEY, next);
window.location.reload();
}),
touch,
menuItem("adjustments", "설정(마우스 · 단축키)", () => {
void import("../A00_Common/app_settings").then((m) => m.openAppSettings());
}),
menuItem("home", "홈페이지", () => navigateTo(ROUTES.A01_HOME)),
menuItem("logout", "로그아웃", () => void logoutNow()),
);
// 고른 뒤 닫힘(터치 모드 단추는 그대로)
menu.addEventListener("click", (e) => {
if ((e.target as Element).closest(".b01-shell__menu-item")) setOpen(false);
});
trigger.addEventListener("click", () => setOpen(menu.hidden !== false));
const outside = (e: MouseEvent): void => {
if (!wrap.isConnected) document.removeEventListener("click", outside);
else if (!wrap.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("click", outside);
wrap.addEventListener("keydown", (e) => e.key === "Escape" && setOpen(false));
wrap.append(trigger, menu);
return wrap;
}
/** 회사 이름 — 시스템 관리자는 눌러 회사 고르기(옛 회사 알약) · 고르면 지금 페이지를 그 회사 것으로 다시 그림 */
function buildCompany(
ctx: ViewCtx,
companies: CompanyInfo[],
onPick: (id: number) => void,
): HTMLElement {
const nameOf = (): string =>
companies.find((c) => c.id === ctx.companyId)?.name ?? ctx.me.company_name ?? "-";
if (ctx.role !== "SYSTEM_ADMIN") {
const span = document.createElement("span");
span.className = "b01-shell__org";
span.textContent = ctx.me.company_name ?? "회사 없음";
return span;
}
const b = document.createElement("button");
b.type = "button";
b.className = "b01-shell__org is-switch";
b.title = "회사 전환";
const label = document.createElement("span");
label.textContent = nameOf();
b.append(label, icon("chevronDown", 14));
b.addEventListener("click", () =>
openCompanyPicker(companies, ctx.companyId, (company) => {
sessionStorage.setItem(ADMIN_COMPANY_KEY, String(company.id));
onPick(company.id);
label.textContent = nameOf();
}),
);
return b;
}
/** 보관함 만료 임박 = 7 일 안 */
const EXPIRE_SOON_MS = 7 * 24 * 3600 * 1000;
/** 메뉴 배지 · 알림 수 — 가입 요청(회사 관리자 · 시스템 관리자) · 받은 예정노선 미연결 · 업데이트 ·
* 보관함 묶음 수(만료 임박은 처리할 일) · 처리할 일 = 빨강 배지 합 · 실패한 것은 0 */
async function refreshBadges(ctx: ViewCtx, nav: DashboardNav, bell: HTMLElement): Promise<void> {
const admin = ctx.role === "ADMIN" || ctx.role === "SYSTEM_ADMIN";
const [joins, plans, temp] = await Promise.all([
admin
? fetchCompanyJoinRequests(ctx.role === "SYSTEM_ADMIN" ? ctx.companyId : undefined).catch(
() => [],
)
: [],
ctx.role === "FOREST" ? [] : fetchAssignedPlans().catch(() => []),
fetchTempBatches()
.then((r) => r.batches)
.catch(() => []),
]);
const newPlans = plans.filter((p) => !p.linked_project_id || p.update_available).length;
const now = Date.now();
const expiring = temp.filter(
(b) => b.expires_at && Date.parse(b.expires_at) - now < EXPIRE_SOON_MS,
).length;
nav.setBadge("people", joins.length, true);
nav.setBadge("assigned", newPlans, true);
nav.setBadge("temp", temp.length);
const todo = joins.length + newPlans + expiring;
const badge = bell.querySelector<HTMLElement>(".b01-shell__badge")!;
badge.hidden = todo <= 0;
badge.textContent = String(todo);
bell.title = bell.ariaLabel = `처리할 일 ${todo} 건`;
}
export async function mountDashboardShell(
root: HTMLElement,
me: DashboardUser,
forest: boolean,
): Promise<MountedPage> {
const role = roleOf(me, forest);
const companies = role === "SYSTEM_ADMIN" ? await fetchAllCompanies().catch(() => []) : [];
const ctx: ViewCtx = {
me,
role,
companyId: role === "SYSTEM_ADMIN" ? pickAdminCompany(me, companies) : (me.company_id ?? null),
go: (view, params = {}) => {
window.location.hash = viewHref(view, params);
},
};
const roleText = role === "FOREST" ? "산림청" : roleLabel(me.role);
const shell = document.createElement("div");
shell.className = "b01-shell b01-dashboard";
const header = document.createElement("header");
header.className = "b01-shell__head";
const brand = document.createElement("a");
brand.className = "b01-shell__brand";
brand.href = viewHref("home");
brand.textContent = "AISLO";
const search = document.createElement("input");
search.className = "b01-shell__search";
search.type = "search";
search.placeholder = "찾기 (준비 중)";
search.disabled = true;
const spacer = document.createElement("span");
spacer.className = "b01-shell__spacer";
const help = createHelpButton();
help.classList.add("b01-shell__help-btn");
// 알림 = 처리할 일 수 · 누르면 홈(처리할 일 목록 자리)
const bell = iconButton("bell", "처리할 일", () => ctx.go("home"));
const bellBadge = document.createElement("span");
bellBadge.className = "b01-shell__badge is-alert";
bellBadge.hidden = true;
bell.append(bellBadge);
header.append(
brand,
buildCompany(ctx, companies, (id) => {
ctx.companyId = id;
show();
}),
search,
spacer,
help,
bell,
buildUserMenu(ctx, roleText),
);
const visible = VIEWS.filter((v) => v.roles.includes(role));
const nav = buildNav(visible);
const main = document.createElement("main");
main.className = "b01-shell__main";
const body = document.createElement("div");
body.className = "b01-shell__body";
body.append(nav.root, main);
shell.append(header, body, buildKnowledgeChat());
root.append(shell);
let cleanup: (() => void) | null = null;
let seq = 0;
/** 주소 `?view=` 페이지를 새 자리에 그림 — 옛 페이지 정리 · 늦게 끝난 옛 mount 의 정리도 부름 */
const show = (): void => {
cleanup?.();
cleanup = null;
const mine = ++seq;
const view = viewFor(currentQuery().get("view"), role);
nav.setActive(view.group === "hidden" && view.key === "project" ? "projects" : view.key);
void refreshBadges(ctx, nav, bell);
const host = document.createElement("div");
host.className = "b01-shell__page";
host.dataset.view = view.key;
main.replaceChildren(host);
void Promise.resolve(view.mount(host, ctx)).then((done) => {
if (typeof done !== "function") return;
if (mine === seq) cleanup = done;
else done();
});
};
show();
return {
covers: (route) => route === ROUTES.B01_ACCOUNT,
show,
dispose: () => {
seq++;
cleanup?.();
cleanup = null;
},
};
}
@@ -0,0 +1,500 @@
/* 대시보드 홈(PLAN 547-2) — 색 · 간격 = 셸 변수(--b01-*) · 종류 색 = 테마 차트 변수 */
.b01-home {
--b01-home-road: var(--b01-accent);
--b01-home-plan: var(--color-chart-0);
--b01-home-stab: var(--color-chart-2);
--b01-home-warn: var(--color-warning);
display: flex;
flex-direction: column;
gap: var(--spacing-16);
color: var(--b01-text);
}
.b01-home__head {
display: flex;
align-items: center;
gap: var(--spacing-12);
flex-wrap: wrap;
}
.b01-home__head h2 {
margin: 0;
font-size: 19px;
}
.b01-home__push {
margin-left: auto;
}
.b01-home__muted {
color: var(--b01-muted);
font-size: 12px;
}
.b01-home__stale {
color: var(--b01-home-warn);
font-size: 12px;
font-weight: 600;
}
.b01-home__alert {
color: var(--b01-alert);
}
.b01-home__seg {
display: inline-flex;
border: 1px solid var(--b01-line);
border-radius: 8px;
overflow: hidden;
}
.b01-home__seg button {
border: 0;
background: var(--b01-card);
color: var(--b01-text);
padding: var(--spacing-4) var(--spacing-12);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.b01-home__seg button.on {
background: var(--b01-accent);
color: var(--b01-accent-text);
}
.b01-home__body {
display: grid;
grid-template-columns: minmax(0, 1fr) 260px;
gap: var(--spacing-16);
align-items: start;
}
.b01-home__main,
.b01-home__side {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
min-width: 0;
}
.b01-home__pane {
background: var(--b01-card);
border: 1px solid var(--b01-line);
border-radius: var(--b01-radius);
padding: var(--spacing-12) var(--spacing-16);
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.b01-home__pane > .ui-btn {
align-self: flex-start;
}
.b01-home__pane-title {
margin: 0;
font-size: 13px;
}
.b01-home__actions {
display: flex;
gap: var(--spacing-8);
flex-wrap: wrap;
}
/* 종류 배지 · 썸네일 · 단계 점 */
.b01-home__kind--road {
--kind: var(--b01-home-road);
}
.b01-home__kind--plan {
--kind: var(--b01-home-plan);
}
.b01-home__kind--stability {
--kind: var(--b01-home-stab);
}
.b01-home__kind--erosion {
--kind: var(--b01-muted);
}
.b01-home__badge {
display: inline-block;
font-size: 11px;
font-weight: 600;
border-radius: 5px;
padding: 0 6px;
white-space: nowrap;
color: var(--kind);
border: 1px solid var(--kind);
}
.b01-home__title {
display: flex;
align-items: center;
gap: var(--spacing-8);
min-width: 0;
}
.b01-home__title b {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.b01-home__thumb {
display: block;
width: 100%;
background: var(--b01-accent-soft);
}
.b01-home__thumb polyline {
fill: none;
stroke: var(--kind);
stroke-width: 3;
stroke-linejoin: round;
stroke-linecap: round;
}
.b01-home__thumb--big {
height: 210px;
}
.b01-home__thumb--mid {
height: 100px;
}
.b01-home__thumb--row {
width: 80px;
height: 44px;
border-radius: 6px;
}
.b01-home__thumb--row polyline {
stroke-width: 2;
}
.b01-home__pips {
display: flex;
gap: 4px;
}
.b01-home__pips i {
flex: 1;
height: 7px;
border-radius: 4px;
background: var(--b01-line);
box-sizing: border-box;
}
.b01-home__pips i.ok {
background: var(--kind);
}
.b01-home__pips i.cur {
background: var(--b01-card);
border: 2px solid var(--kind);
}
.b01-home__next {
border: 1px solid var(--b01-home-warn);
border-radius: 8px;
padding: var(--spacing-4) var(--spacing-8);
font-size: 12.5px;
}
/* 1 — 큰 카드 */
.b01-home__big {
padding: 0;
overflow: hidden;
gap: 0;
}
.b01-home__big-top {
display: grid;
grid-template-columns: 1.1fr 1fr;
}
.b01-home__big-info {
padding: var(--spacing-12) var(--spacing-16);
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.b01-home__big-info .b01-home__title b {
font-size: 17px;
}
.b01-home__metrics {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
border-top: 1px solid var(--b01-line);
}
.b01-home__metrics div {
padding: var(--spacing-8) var(--spacing-12);
border-left: 1px solid var(--b01-line);
}
.b01-home__metrics div:first-child {
border-left: 0;
}
.b01-home__metrics b {
display: block;
font-size: 15px;
font-variant-numeric: tabular-nums;
}
.b01-home__metrics span {
color: var(--b01-muted);
font-size: 11.5px;
}
/* 2~3 — 중간 카드 */
.b01-home__mids {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--spacing-12);
}
.b01-home__mid {
background: var(--b01-card);
border: 1px solid var(--b01-line);
border-radius: var(--b01-radius);
overflow: hidden;
}
.b01-home__mid--first {
border-color: var(--b01-accent);
}
.b01-home__mid-body {
padding: var(--spacing-8) var(--spacing-12) var(--spacing-12);
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
/* 4~8 — 한 줄 목록 */
.b01-home__split {
display: grid;
grid-template-columns: 1.3fr 1fr;
gap: var(--spacing-12);
align-items: start;
}
.b01-home__split--list {
grid-template-columns: minmax(0, 1fr) 220px;
}
.b01-home__split--list > div {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
}
.b01-home__rows {
background: var(--b01-card);
border: 1px solid var(--b01-line);
border-radius: var(--b01-radius);
overflow: hidden;
}
.b01-home__row {
display: grid;
grid-template-columns: 80px minmax(0, 1.2fr) minmax(0, 1fr) auto;
gap: var(--spacing-12);
align-items: center;
padding: var(--spacing-8) var(--spacing-12);
border-top: 1px solid var(--b01-line);
cursor: pointer;
}
.b01-home__row:first-child {
border-top: 0;
}
.b01-home__row:hover,
.b01-home__row:focus-visible {
background: var(--b01-hover);
outline: none;
}
.b01-home__row--stale {
box-shadow: inset 3px 0 0 var(--b01-home-warn);
}
.b01-home__member {
display: flex;
align-items: center;
gap: var(--spacing-8);
font-size: 12.5px;
}
.b01-home__avatar {
width: 22px;
height: 22px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 10px;
font-weight: 700;
background: var(--b01-accent-soft);
color: var(--b01-accent);
}
.b01-home__stack {
display: flex;
height: 10px;
border-radius: 6px;
overflow: hidden;
background: var(--b01-line);
}
.b01-home__stack i {
display: block;
height: 100%;
}
.b01-home__stack--start {
background: var(--b01-accent-soft);
}
.b01-home__stack--working {
background: var(--b01-accent);
}
.b01-home__stack--last {
background: var(--b01-home-warn);
}
/* 9 넘음 — 요약 숫자 · 그래프 */
.b01-home__many {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
}
.b01-home__kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: var(--spacing-12);
}
.b01-home__kpi {
background: var(--b01-card);
border: 1px solid var(--b01-line);
border-radius: var(--b01-radius);
padding: var(--spacing-8) var(--spacing-12);
}
.b01-home__kpi b {
display: block;
font-size: 22px;
font-variant-numeric: tabular-nums;
}
.b01-home__bars text {
font-size: 10px;
fill: var(--b01-text);
}
.b01-home__bars rect {
fill: var(--b01-accent);
}
.b01-home__bars rect.rest {
fill: var(--b01-accent-soft);
}
.b01-home__donut-row {
display: flex;
align-items: center;
gap: var(--spacing-16);
}
.b01-home__donut circle {
fill: none;
stroke: var(--kind);
stroke-width: 6;
}
.b01-home__donut text {
font-size: 8px;
font-weight: 700;
fill: var(--b01-text);
}
.b01-home__legend {
display: grid;
gap: var(--spacing-4);
font-size: 12px;
}
.b01-home__legend span {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.b01-home__swatch {
width: 9px;
height: 9px;
border-radius: 3px;
background: var(--kind);
}
/* 오른쪽 — 처리할 일 · 처음 세 걸음 */
.b01-home__todo,
.b01-home__step {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: var(--spacing-8);
align-items: center;
font-size: 12.5px;
}
.b01-home__dot {
width: 7px;
height: 7px;
border-radius: 50%;
}
.b01-home__dot--plan {
background: var(--b01-home-plan);
}
.b01-home__dot--warn {
background: var(--b01-home-warn);
}
.b01-home__dot--alert {
background: var(--b01-alert);
}
.b01-home__step-no {
width: 22px;
height: 22px;
border-radius: 50%;
display: grid;
place-items: center;
font-weight: 700;
border: 2px solid var(--b01-accent);
color: var(--b01-accent);
}
.b01-home__step--done .b01-home__step-no {
background: var(--b01-accent);
color: var(--b01-accent-text);
}
@media (max-width: 900px) {
.b01-home__body,
.b01-home__split,
.b01-home__split--list,
.b01-home__big-top {
grid-template-columns: 1fr;
}
.b01-home__row {
grid-template-columns: 60px minmax(0, 1fr) auto;
}
.b01-home__row-stage {
display: none;
}
}
@@ -0,0 +1,482 @@
/* =============================================================================
* B01_Dashboard_UI_Style_Pages.css
* 대시보드 세부 페이지 공용 꼴(549) — 머리 · 표(줄 40 · 세로선 없음) · ⋯ · 탭 · 설정 줄 · 빈 상태.
* 색 · 간격은 `.b01-shell` 변수(--b01-*)만. 페이지 전용 조각은 아래 쪽에 이어 붙임.
* ========================================================================== */
.b01-pg {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 1120px;
color: var(--b01-text);
}
.b01-pg__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.b01-pg__title {
margin: 0;
font-size: 20px;
font-weight: 700;
color: var(--b01-text);
}
.b01-pg__desc {
margin: 4px 0 0;
color: var(--b01-muted);
font-size: 13px;
}
.b01-pg__action {
display: flex;
gap: 8px;
flex: none;
}
.b01-pg__body {
display: flex;
flex-direction: column;
gap: 12px;
}
/* 카드 · 표 */
.b01-pg-card {
background: var(--b01-card);
border: 1px solid var(--b01-line);
border-radius: var(--b01-radius);
}
/* 이름 칸 */
.b01-pg-name {
display: flex;
flex-direction: column;
line-height: 1.25;
min-width: 0;
}
.b01-pg-name__main {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.b01-pg-name__sub {
font-size: 11.5px;
color: var(--b01-muted);
}
.b01-pg-muted {
color: var(--b01-muted);
}
.b01-pg-inline {
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
/* 배지 */
.b01-pg-badge {
border-radius: 999px;
font-size: 11.5px;
padding: 1px 8px;
white-space: nowrap;
}
/* 탭 */
.b01-pg-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--b01-line);
}
.b01-pg-tabs__tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
margin-bottom: -1px;
border: 0;
border-bottom: 2px solid transparent;
background: none;
color: var(--b01-muted);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.b01-pg-tabs__tab:hover {
color: var(--b01-text);
}
.b01-pg-tabs__tab.is-active {
color: var(--b01-accent);
border-bottom-color: var(--b01-accent);
}
.b01-pg-tabs__count {
min-width: 18px;
padding: 0 5px;
border-radius: 9px;
background: var(--b01-alert);
color: #fff;
font-size: 11px;
line-height: 18px;
text-align: center;
}
.b01-pg-tabs__count[hidden] {
display: none;
}
.b01-pg-link {
border: 0;
background: none;
color: var(--b01-accent);
font: inherit;
font-size: 12.5px;
cursor: pointer;
text-decoration: underline;
}
/* 단추 맞춤 — 셸 안 단추 높이 */
.b01-pg .ui-btn {
display: inline-flex;
align-items: center;
gap: 6px;
}
/* ── 임시 보관함 ──────────────────────────────────────────────────────────── */
.b01-pg-left {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 140px;
}
.b01-pg-left__bar {
flex: none;
width: 64px;
height: 6px;
border-radius: 3px;
background: var(--b01-line);
overflow: hidden;
}
.b01-pg-left__bar > i {
display: block;
height: 100%;
background: var(--b01-accent);
}
.b01-pg-left.is-soon {
color: var(--b01-alert);
font-weight: 600;
}
.b01-pg-left.is-soon .b01-pg-left__bar > i {
background: var(--b01-alert);
}
.b01-pg-caret {
display: inline-grid;
place-items: center;
width: 20px;
height: 20px;
margin-right: 4px;
border: 0;
background: none;
color: var(--b01-muted);
cursor: pointer;
transform: rotate(-90deg);
transition: transform 0.12s;
}
.b01-pg-caret.is-open {
transform: none;
}
.b01-pg-files {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
}
.b01-pg-files td {
height: 34px;
padding: 0 12px;
border-bottom: 1px solid var(--b01-line);
}
.b01-pg-files tr:last-child td {
border-bottom: 0;
}
.b01-pg-files td:first-child {
padding-left: 44px;
}
.b01-pg-progress {
display: inline-flex;
align-items: center;
gap: 8px;
}
.b01-pg-progress__bar {
width: 96px;
height: 6px;
border-radius: 3px;
background: var(--b01-line);
overflow: hidden;
}
.b01-pg-progress__bar > i {
display: block;
height: 100%;
background: var(--b01-accent);
}
.b01-pg-meta {
color: var(--b01-muted);
font-size: 12px;
}
/* ── 사람 · 회사 ──────────────────────────────────────────────────────────── */
.b01-pg-person {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.b01-pg-avatar {
display: grid;
place-items: center;
flex: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--b01-accent-soft);
color: var(--b01-accent);
font-size: 12px;
font-weight: 700;
}
.b01-pg-invite {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
max-width: 520px;
}
.b01-pg-invite__form {
display: flex;
gap: 8px;
align-items: flex-end;
}
.b01-pg-invite__form > :first-child {
flex: 1;
}
.b01-pg-note {
margin: 0;
font-size: 12.5px;
color: var(--b01-muted);
}
/* 배지 색 — 셸 변수에서 (밝음 · 어두움 같이) */
.b01-pg .ui-tag {
border: 1px solid transparent;
}
.b01-pg .ui-tag--success {
background: var(--b01-accent-soft);
color: var(--b01-accent);
}
.b01-pg .ui-tag--accent {
background: transparent;
border-color: var(--b01-accent);
color: var(--b01-accent);
}
.b01-pg .ui-tag--neutral {
background: var(--b01-hover);
color: var(--b01-muted);
}
.b01-pg .ui-tag--warning {
background: color-mix(in srgb, #d97706 16%, transparent);
color: #b45309;
}
.b01-pg .ui-tag--danger {
background: color-mix(in srgb, var(--b01-alert) 14%, transparent);
color: var(--b01-alert);
}
:root[data-theme="dark"] .b01-pg .ui-tag--warning {
color: #f5a524;
}
/* ── 시스템 관리 · 내 정보 (549-2) ────────────────────────────────────────── */
.b01-shell {
--b01-warn: #d9822b;
--b01-s1: var(--b01-accent);
--b01-s2: #3b82c4;
--b01-s3: var(--b01-warn);
}
.b01-sys-loading {
margin: 0;
color: var(--b01-muted);
}
.b01-sys-tool {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.b01-sys-input {
min-height: 32px;
padding: 0 8px;
border: 1px solid var(--b01-line);
border-radius: 8px;
background: var(--b01-card);
color: var(--b01-text);
font: inherit;
font-size: 13px;
}
.b01-sys-input:focus-visible {
outline: 2px solid var(--b01-accent);
outline-offset: 0;
}
.ui-setting-row__side .b01-sys-input {
width: 240px;
}
.b01-sys-count {
margin-left: auto;
color: var(--b01-muted);
font-size: 12px;
}
.b01-sys-group__title {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
color: var(--b01-text);
}
.b01-pg__body > .b01-sys-group + .b01-sys-group {
margin-top: 8px;
}
a.b01-pg-name__main {
color: var(--b01-text);
text-decoration: none;
}
a.b01-pg-name__main:hover {
color: var(--b01-accent);
text-decoration: underline;
}
.b01-sys-error {
color: var(--b01-alert);
font-size: 12px;
}
.b01-sys-assetslot .ui-field__label {
display: none;
}
.b01-sys-seg {
display: inline-flex;
border: 1px solid var(--b01-line);
border-radius: 8px;
overflow: hidden;
}
.b01-sys-seg button {
min-height: 32px;
padding: 0 12px;
border: 0;
background: var(--b01-card);
color: var(--b01-muted);
font: inherit;
cursor: pointer;
}
.b01-sys-seg button[aria-pressed="true"] {
background: var(--b01-accent-soft);
color: var(--b01-accent);
font-weight: 600;
}
/* 개요 — 숫자 카드 · 게이지 · 7 일 그래프 */
.b01-sys-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.b01-sys-stat {
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px;
}
.b01-sys-stat__label {
color: var(--b01-muted);
font-size: 12px;
}
.b01-sys-stat__value {
font-size: 28px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.b01-sys-gauge .b01-sys-stat__value {
font-size: 22px;
}
.b01-sys-gauge {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
}
.b01-sys-gauge__top {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.b01-sys-gauge__bar {
height: 8px;
border-radius: 4px;
background: var(--b01-hover);
overflow: hidden;
}
.b01-sys-gauge__fill {
height: 100%;
border-radius: 4px;
background: var(--b01-accent);
}
.b01-sys-gauge.is-warn .b01-sys-gauge__fill {
background: var(--b01-warn);
}
.b01-sys-gauge.is-bad .b01-sys-gauge__fill {
background: var(--b01-alert);
}
.b01-sys-gauge.is-warn .b01-sys-stat__value {
color: var(--b01-warn);
}
.b01-sys-gauge.is-bad .b01-sys-stat__value {
color: var(--b01-alert);
}
.b01-sys-chart {
padding: 16px;
}
.b01-sys-chart__legend {
display: flex;
gap: 16px;
margin-bottom: 8px;
color: var(--b01-muted);
font-size: 12px;
}
.b01-sys-chart__legend i {
display: inline-block;
width: 10px;
height: 10px;
margin-right: 4px;
border-radius: 2px;
}
.b01-sys-chart svg {
display: block;
width: 100%;
height: auto;
}
.b01-sys-chart text {
fill: var(--b01-muted);
font-size: 10px;
}
.b01-sys-chart line {
stroke: var(--b01-line);
}
/* 공용 단추 눌림 색 — 기본이 보라(royal-amethyst · mist-violet)라 셸 강조 색으로 */
.b01-pg .ui-btn--filled:hover:not(:disabled) {
background-color: var(--b01-accent);
filter: brightness(0.92);
}
.b01-pg .ui-btn--ghost:hover:not(:disabled) {
background-color: var(--b01-accent-soft);
}
.b01-sys-back {
align-self: flex-start;
padding: 0;
border: 0;
background: none;
color: var(--b01-muted);
font: inherit;
cursor: pointer;
}
.b01-sys-back:hover {
color: var(--b01-text);
}
@@ -0,0 +1,528 @@
/* =============================================================================
* B01_Dashboard_UI_Style_Projects.css
* 프로젝트 목록 · 상세(PLAN 548) — 색 · 간격은 대시보드 셸 변수(--b01-*) · 없으면 공용 테마 값.
* ========================================================================== */
.b01-proj,
.b01-pd {
--pj-accent: var(--b01-accent, var(--color-primary, #1f7a4d));
--pj-accent-soft: var(--b01-accent-soft, color-mix(in srgb, var(--pj-accent) 12%, transparent));
--pj-surface: var(--b01-card, var(--color-surface, #fff));
--pj-sunken: var(--b01-bg, var(--color-surface-sunken, #eef0f3));
--pj-border: var(--b01-line, var(--color-border, #e6e8ec));
--pj-text: var(--b01-text, var(--color-text, #1f2328));
--pj-muted: var(--b01-muted, var(--color-text-muted, #6b7280));
--pj-radius: var(--b01-radius, var(--radius-cards, 12px));
--pj-gap: var(--spacing-16, 14px);
--pj-road: var(--pj-accent);
--pj-plan: #2f5fbf;
--pj-stab: #c26a0f;
--pj-warn: #d97706;
--pj-danger: var(--b01-alert, var(--color-danger, #e5484d));
color: var(--pj-text);
font-size: var(--text-body-sm, 13.5px);
}
/* ── 머리 · 툴바 ─────────────────────────────────────────────────────────── */
.b01-proj-head,
.b01-pd-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.b01-proj-head h2,
.b01-pd-head h2 {
margin: 0;
font-size: 19px;
}
.b01-proj-count {
color: var(--pj-muted);
}
.b01-proj-sp {
flex: 1;
}
.b01-proj-tools {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 12px;
}
.b01-proj-search {
width: 220px;
padding: 5px 10px;
border: 1px solid var(--pj-border);
border-radius: 7px;
background: var(--pj-surface);
color: var(--pj-text);
font: inherit;
}
.b01-proj-select {
padding: 3px 8px;
border: 1px solid var(--pj-border);
border-radius: 14px;
background: var(--pj-surface);
color: var(--pj-text);
font: inherit;
}
.b01-proj-chip {
border: 1px solid var(--pj-border);
border-radius: 14px;
padding: 2px 10px;
background: var(--pj-surface);
color: var(--pj-text);
font: inherit;
cursor: pointer;
}
.b01-proj-chip.is-on {
border-color: var(--pj-accent);
color: var(--pj-accent);
background: var(--pj-accent-soft);
}
.b01-proj-chip.is-soon {
opacity: 0.5;
border-style: dashed;
}
.b01-proj-seg {
display: inline-flex;
border: 1px solid var(--pj-border);
border-radius: 8px;
overflow: hidden;
}
.b01-proj-seg button {
border: 0;
padding: 5px 11px;
background: var(--pj-surface);
color: var(--pj-text);
font: inherit;
cursor: pointer;
}
.b01-proj-seg button.is-on {
background: var(--pj-accent);
color: #fff;
}
.b01-proj-empty {
color: var(--pj-muted);
padding: 24px 0;
text-align: center;
}
.b01-proj-mini {
color: var(--pj-muted);
font-size: 12px;
}
/* ── 배지 · 막대 · 점 ────────────────────────────────────────────────────── */
.b01-proj-badge {
display: inline-block;
font-size: 11px;
font-weight: 600;
border-radius: 5px;
padding: 0 6px;
margin-right: 6px;
color: var(--pj-road);
background: color-mix(in srgb, var(--pj-road) 13%, transparent);
}
.b01-proj-badge--plan {
color: var(--pj-plan);
background: color-mix(in srgb, var(--pj-plan) 13%, transparent);
}
.b01-proj-badge--stability {
color: var(--pj-stab);
background: color-mix(in srgb, var(--pj-stab) 13%, transparent);
}
.b01-proj-badge--erosion {
color: var(--pj-muted);
background: var(--pj-sunken);
}
.b01-proj-bar {
display: inline-block;
width: 90px;
height: 6px;
border-radius: 4px;
background: var(--pj-sunken);
overflow: hidden;
vertical-align: middle;
}
.b01-proj-bar i {
display: block;
height: 100%;
background: var(--pj-accent);
}
.b01-proj-dots {
display: flex;
gap: 4px;
margin: 6px 0 2px;
}
.b01-proj-dots i {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--pj-sunken);
border: 1px solid var(--pj-border);
}
.b01-proj-dots i.is-done {
background: var(--pj-accent);
border-color: var(--pj-accent);
}
.b01-proj-dots i.is-cur {
border: 2px solid var(--pj-accent);
background: var(--pj-surface);
}
.b01-proj-dots i.is-stale {
background: var(--pj-warn);
border-color: var(--pj-warn);
}
.b01-proj-stalled {
color: var(--pj-warn);
font-size: 11.5px;
margin-left: 6px;
}
/* ── 카드 ────────────────────────────────────────────────────────────────── */
.b01-proj-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: var(--pj-gap);
}
.b01-proj-card {
background: var(--pj-surface);
border: 1px solid var(--pj-border);
border-radius: var(--pj-radius);
overflow: hidden;
cursor: pointer;
transition:
box-shadow 0.15s,
border-color 0.15s;
}
.b01-proj-card:hover,
.b01-proj-card:focus-visible {
border-color: var(--pj-accent);
box-shadow: 0 2px 10px rgb(16 24 40 / 8%);
outline: none;
}
.b01-proj-thumb {
display: block;
width: 100%;
height: 64px;
background: color-mix(in srgb, var(--pj-road) 9%, var(--pj-surface));
}
.b01-proj-col .b01-proj-thumb {
height: 44px;
}
.b01-proj-card--plan .b01-proj-thumb {
background: color-mix(in srgb, var(--pj-plan) 9%, var(--pj-surface));
}
.b01-proj-card--stability .b01-proj-thumb {
background: color-mix(in srgb, var(--pj-stab) 9%, var(--pj-surface));
}
.b01-proj-thumb path {
fill: none;
stroke: #c0392b;
stroke-width: 2.5;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
}
.b01-proj-card--plan .b01-proj-thumb path {
stroke: var(--pj-plan);
}
.b01-proj-card--stability .b01-proj-thumb path {
stroke: var(--pj-stab);
}
.b01-proj-card__body {
padding: 8px 10px 9px;
}
.b01-proj-card__name {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.b01-proj-card__line {
margin-top: 2px;
}
.b01-proj-card .b01-proj-bar {
width: 100%;
margin-top: 5px;
}
.b01-proj-card__meta {
display: flex;
justify-content: space-between;
gap: 6px;
color: var(--pj-muted);
font-size: 11.5px;
margin-top: 5px;
}
/* ── 표 ──────────────────────────────────────────────────────────────────── */
.b01-proj-table {
position: relative;
background: var(--pj-surface);
border: 1px solid var(--pj-border);
border-radius: var(--pj-radius);
overflow: visible;
}
.b01-proj-table table {
width: 100%;
border-collapse: collapse;
}
.b01-proj-table th,
.b01-proj-table td {
padding: 9px 12px;
text-align: left;
border-bottom: 1px solid var(--pj-border);
}
.b01-proj-table th {
color: var(--pj-muted);
font-weight: 600;
font-size: 12px;
background: var(--pj-sunken);
}
.b01-proj-row {
cursor: pointer;
}
.b01-proj-row:hover td,
.b01-proj-row:focus-visible td {
background: var(--pj-accent-soft);
}
.b01-proj-row:focus-visible {
outline: none;
}
.b01-proj-prog span:last-child {
margin-left: 8px;
}
.b01-proj-more {
border: 0;
background: none;
color: var(--pj-muted);
font-size: 16px;
cursor: pointer;
padding: 0 6px;
}
/* 삭제 = 끝 줄(수정 뒤 구분선 다음) */
.b01-proj-menu__context-menu .ui-ctx-menu__item:last-child:not(:first-child) {
color: var(--pj-danger);
}
/* ── 칸반 ────────────────────────────────────────────────────────────────── */
.b01-proj-kanban {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)) minmax(0, 0.8fr);
gap: 12px;
align-items: start;
}
.b01-proj-col {
background: var(--pj-sunken);
border-radius: var(--pj-radius);
padding: 10px;
}
.b01-proj-col h4 {
display: flex;
gap: 6px;
align-items: center;
margin: 0 0 8px;
font-size: 13px;
}
.b01-proj-col__list {
display: grid;
gap: 8px;
}
.b01-proj-col[data-board="done"] h4 {
cursor: pointer;
}
.b01-proj-col[data-board="done"] .b01-proj-card {
opacity: 0.75;
}
.b01-proj-col.is-folded .b01-proj-col__list {
display: none;
}
.b01-proj-fold {
margin-left: auto;
color: var(--pj-muted);
font-size: 11.5px;
font-weight: 400;
}
@media (max-width: 900px) {
.b01-proj-kanban {
grid-template-columns: 1fr 1fr;
}
}
/* ── 상세 ────────────────────────────────────────────────────────────────── */
.b01-pd-crumb {
border: 0;
background: none;
padding: 0;
color: var(--pj-muted);
font: inherit;
font-size: 12px;
cursor: pointer;
margin-bottom: 4px;
}
.b01-pd-head {
position: relative;
}
.b01-pd-pane {
background: var(--pj-surface);
border: 1px solid var(--pj-border);
border-radius: var(--pj-radius);
padding: 14px 16px;
margin-bottom: var(--pj-gap);
}
.b01-pd-pane h3 {
margin: 0 0 10px;
font-size: 14px;
}
.b01-pd-steps {
display: grid;
grid-template-columns: repeat(var(--n, 7), minmax(0, 1fr));
gap: 6px;
}
.b01-pd-step {
border: 1px solid var(--pj-border);
border-radius: 9px;
padding: 7px 4px;
background: var(--pj-surface);
color: var(--pj-text);
font: inherit;
font-size: 11.5px;
text-align: center;
cursor: pointer;
}
.b01-pd-step:hover {
border-color: var(--pj-accent);
}
.b01-pd-step span {
display: grid;
place-items: center;
width: 20px;
height: 20px;
margin: 0 auto 4px;
border-radius: 50%;
background: var(--pj-sunken);
color: var(--pj-muted);
font-size: 11px;
font-weight: 700;
}
.b01-pd-step b {
font-weight: 500;
}
.b01-pd-step.is-done span {
background: var(--pj-accent);
color: #fff;
}
.b01-pd-step.is-cur {
border-color: var(--pj-accent);
background: var(--pj-accent-soft);
}
.b01-pd-step.is-cur span {
background: var(--pj-surface);
color: var(--pj-accent);
border: 2px solid var(--pj-accent);
}
.b01-pd-step.is-stale span {
background: #d97706;
color: #fff;
}
.b01-pd-grid {
display: grid;
grid-template-columns: 1.55fr 1fr;
gap: var(--pj-gap);
align-items: start;
}
.b01-pd-grid.is-single {
grid-template-columns: 1fr;
}
.b01-pd-grid.is-single > .b01-pd-col:first-child {
display: none;
}
.b01-pd-map {
display: block;
width: 100%;
height: auto;
border-radius: 8px;
background: var(--pj-sunken);
}
.b01-pd-map__line {
fill: none;
stroke: #c0392b;
stroke-width: 3.5;
stroke-linejoin: round;
}
.b01-pd-map__end {
fill: #fff;
stroke: #c0392b;
stroke-width: 2;
}
.b01-pd-map__label {
font-size: 12px;
fill: #1f2328;
paint-order: stroke;
stroke: #fff;
stroke-width: 3px;
}
.b01-pd-kpis {
display: flex;
flex-wrap: wrap;
gap: 22px;
margin-top: 10px;
}
.b01-pd-kpi b {
display: block;
font-size: 22px;
font-variant-numeric: tabular-nums;
line-height: 1.2;
}
.b01-pd-kpi span {
color: var(--pj-muted);
font-size: 12px;
}
.b01-pd-kv {
display: grid;
grid-template-columns: 96px 1fr;
gap: 6px 10px;
}
.b01-pd-kv span {
color: var(--pj-muted);
}
.b01-pd-link {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
border-top: 1px solid var(--pj-border);
}
.b01-pd-link:first-of-type {
border-top: 0;
}
.b01-pd-timeline {
border-left: 2px solid var(--pj-border);
margin-left: 4px;
padding-left: 12px;
}
.b01-pd-timeline div {
position: relative;
padding: 4px 0;
font-size: 12.5px;
}
.b01-pd-timeline div::before {
content: "";
position: absolute;
left: -17px;
top: 10px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--pj-accent);
}
@media (max-width: 900px) {
.b01-pd-grid {
grid-template-columns: 1fr;
}
.b01-pd-steps {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@@ -0,0 +1,335 @@
/* 대시보드 셸(546) — 색 · 둥글기 = 공통 값(ui_template_theme.css)의 별칭 · 간격 = 4 의 배수 */
.b01-shell {
--b01-accent: var(--color-accent);
--b01-accent-soft: var(--color-accent-soft);
--b01-accent-text: var(--color-primary-text);
--b01-bg: var(--color-bg);
--b01-card: var(--color-surface-raised);
--b01-line: var(--color-border);
--b01-text: var(--color-text);
--b01-muted: var(--color-text-muted);
--b01-hover: var(--color-hover);
--b01-alert: var(--color-danger-fill);
--b01-radius: var(--radius-cards);
--b01-head-h: 48px;
--b01-nav-w: 210px;
--b01-rail-w: 56px;
/* 셸 안 판 바탕 = 카드 흰색 */
--color-surface: var(--color-surface-raised);
min-height: 100dvh;
display: grid;
grid-template-rows: var(--b01-head-h) 1fr;
background: var(--b01-bg);
color: var(--b01-text);
}
/* 옛 ☰ 서랍 · 떠 있는 단추 — 대시보드에선 걷음(왼쪽 메뉴 · 사용자 메뉴가 맡음) */
html:has(.b01-shell) .app-header {
display: none;
}
/* 위 머리 */
.b01-shell__head {
position: sticky;
top: 0;
z-index: var(--z-header);
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px;
background: var(--b01-card);
border-bottom: 1px solid var(--b01-line);
}
.b01-shell__brand {
font-weight: 800;
letter-spacing: 0.6px;
color: var(--b01-text);
text-decoration: none;
}
.b01-shell__org {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 220px;
padding: 4px 8px;
border: 1px solid var(--b01-line);
border-radius: 8px;
background: none;
color: var(--b01-muted);
font: inherit;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.b01-shell__org.is-switch {
cursor: pointer;
}
.b01-shell__org.is-switch:hover {
background: var(--b01-hover);
color: var(--b01-text);
}
.b01-shell__search {
width: 240px;
padding: 4px 12px;
border: 1px solid var(--b01-line);
border-radius: 8px;
background: var(--b01-bg);
color: var(--b01-text);
font: inherit;
}
.b01-shell__spacer {
flex: 1;
}
.b01-shell__icon-btn {
position: relative;
display: grid;
place-items: center;
width: 32px;
height: 32px;
border: 1px solid var(--b01-line);
border-radius: 8px;
background: var(--b01-card);
color: var(--b01-muted);
cursor: pointer;
}
.b01-shell__icon-btn:disabled {
cursor: default;
}
.b01-shell__icon-btn:not(:disabled):hover {
background: var(--b01-hover);
color: var(--b01-text);
}
.b01-shell__icon-btn .b01-shell__badge {
position: absolute;
top: -6px;
right: -6px;
margin: 0;
}
.ui-icon {
display: inline-grid;
place-items: center;
flex: none;
}
/* 사용자 메뉴 */
.b01-shell__user {
position: relative;
}
.b01-shell__user-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 8px 4px 4px;
border: 0;
border-radius: 8px;
background: none;
color: var(--b01-text);
font: inherit;
cursor: pointer;
}
.b01-shell__user-btn:hover {
background: var(--b01-hover);
}
.b01-shell__avatar {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--b01-accent-soft);
color: var(--b01-accent);
font-weight: 700;
font-size: 12px;
}
.b01-shell__user-name {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.b01-shell__menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
z-index: var(--z-dropdown);
display: flex;
flex-direction: column;
min-width: 240px;
padding: 4px;
background: var(--b01-card);
border: 1px solid var(--b01-line);
border-radius: var(--b01-radius);
box-shadow: 0 8px 24px rgba(16, 24, 40, 0.12);
}
.b01-shell__menu[hidden] {
display: none;
}
.b01-shell__menu-who {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 12px 12px;
border-bottom: 1px solid var(--b01-line);
margin-bottom: 4px;
}
.b01-shell__menu-who span {
color: var(--b01-muted);
font-size: 12px;
}
.b01-shell__menu-item,
.b01-shell__menu-touch {
display: flex;
align-items: center;
gap: 8px;
min-height: 36px;
padding: 0 12px;
border: 0;
border-radius: 8px;
background: none;
color: var(--b01-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.b01-shell__menu-item:hover {
background: var(--b01-hover);
}
.b01-shell__menu-touch {
cursor: default;
}
.b01-shell__menu-touch > span:nth-child(2) {
flex: 1;
}
.b01-shell__menu-touch button {
padding: 2px 8px;
border: 1px solid var(--b01-line);
border-radius: 12px;
background: var(--b01-card);
color: var(--b01-muted);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.b01-shell__menu-touch button[aria-pressed="true"] {
border-color: var(--b01-accent);
background: var(--b01-accent-soft);
color: var(--b01-accent);
}
/* 몸통 — 왼쪽 메뉴 + 페이지 */
.b01-shell__body {
display: grid;
grid-template-columns: var(--b01-nav-w) minmax(0, 1fr);
}
.b01-shell__nav {
position: sticky;
top: var(--b01-head-h);
height: calc(100dvh - var(--b01-head-h));
overflow-y: auto;
padding: 8px;
background: var(--b01-card);
border-right: 1px solid var(--b01-line);
}
.b01-shell__nav-group {
margin: 12px 8px 4px;
color: var(--b01-muted);
font-size: 12px;
}
.b01-shell__nav-item {
display: flex;
align-items: center;
gap: 8px;
min-height: 36px;
padding: 0 8px;
border-radius: 8px;
color: var(--b01-text);
text-decoration: none;
}
.b01-shell__nav-item:hover {
background: var(--b01-hover);
}
.b01-shell__nav-item.is-active {
background: var(--b01-accent-soft);
color: var(--b01-accent);
font-weight: 600;
}
.b01-shell__nav-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.b01-shell__badge {
min-width: 20px;
padding: 0 4px;
border-radius: 8px;
background: var(--b01-hover);
color: var(--b01-muted);
font-size: 11px;
font-weight: 600;
line-height: 16px;
text-align: center;
}
.b01-shell__badge.is-alert {
background: var(--b01-alert);
color: #fff;
}
.b01-shell__badge[hidden] {
display: none;
}
.b01-shell__main {
min-width: 0;
padding: 20px 24px 32px;
}
/* 도움 페이지 카드 */
.b01-shell__card {
padding: 16px;
background: var(--b01-card);
border: 1px solid var(--b01-line);
border-radius: var(--b01-radius);
}
.b01-shell__help {
display: flex;
align-items: center;
gap: 16px;
max-width: 640px;
}
.b01-shell__help p {
flex: 1;
margin: 0;
}
/* 작은 화면 — 메뉴는 아이콘 레일 · 머리 찾기 칸 · 이름 숨김 */
@media (max-width: 1024px) {
.b01-shell__body {
grid-template-columns: var(--b01-rail-w) minmax(0, 1fr);
}
.b01-shell__nav-group {
height: 1px;
margin: 8px 4px;
overflow: hidden;
background: var(--b01-line);
color: transparent;
}
.b01-shell__nav-item {
position: relative;
justify-content: center;
padding: 0;
}
.b01-shell__nav-label {
display: none;
}
.b01-shell__nav-item .b01-shell__badge {
position: absolute;
top: 0;
right: 0;
}
.b01-shell__search,
.b01-shell__user-name {
display: none;
}
}
+1 -126
View File
@@ -1,133 +1,8 @@
/* =============================================================================
* B01_Dashboard_UI_Style_Temp.css
* 임시 보관함 섹션 — 그룹(임시 프로젝트명) + 하위 파일 표, 등록 모달
* 표·모달 껍데기는 대시보드 공통 스타일을 그대로 쓰고, 여기서는 그룹 카드와
* 진행률 막대만 정의한다. 색·간격은 theme.css 변수만 사용한다.
* 임시 보관함 — 등록 · 파일 추가 모달 조각(목록 표는 `B01_Dashboard_UI_Style_Pages.css`).
* ========================================================================== */
/* 제목 + 보관 기간 태그를 한 묶음으로 — 공용 섹션 헤더의 왼쪽 칸에 들어간다. */
.b01-temp__section-title {
display: flex;
align-items: center;
gap: var(--spacing-8);
}
.b01-temp__list {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
}
/* 그룹 = 임시 프로젝트명 1건. 아래에 파일 표가 붙는다. */
.b01-temp__group {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-16);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-paper);
}
/* 프로젝트로 옮긴 묶음은 이력이라 흐리게 — 지금 쓸 수 있는 자료와 구분한다. */
.b01-temp__group.is-linked {
opacity: 0.6;
}
/* 제목 행 전체가 접기/펼치기 트리거다. */
.b01-temp__group-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-8);
flex-wrap: wrap;
cursor: pointer;
}
.b01-temp__group-title {
display: flex;
align-items: center;
gap: var(--spacing-8);
flex-wrap: wrap;
}
.b01-temp__caret {
width: 12px;
font-family: var(--font-body);
font-size: var(--text-caption);
line-height: 1;
color: var(--color-text-muted);
}
.b01-temp__group-meta {
font-family: var(--font-body);
font-size: var(--text-caption);
color: var(--color-text-muted);
}
/* 기본은 접힘 — 펼친 그룹만 파일 표를 보여 준다. */
.b01-temp__group-body {
display: none;
margin-top: var(--spacing-8);
}
.b01-temp__group.is-open .b01-temp__group-body {
display: block;
}
/* 파일 표는 행이 많아 촘촘하게 — 글자 크기는 그대로 두고 상하 여백만 줄인다. */
.b01-temp__group-body .ui-general-block__table th,
.b01-temp__group-body .ui-general-block__table td {
padding-top: 2px;
padding-bottom: 2px;
}
.b01-temp__group-body .ui-general-block__table .ui-btn {
padding-top: 2px;
padding-bottom: 2px;
min-height: 0;
}
.b01-temp__badge {
padding: 2px var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
font-size: 11px;
white-space: nowrap;
}
.b01-temp__group.is-completed .b01-temp__badge {
border-color: var(--color-success);
color: var(--color-success);
}
.b01-temp__group.is-uploading .b01-temp__badge {
border-color: var(--color-warning);
color: var(--color-warning);
}
/* 업로드 진행률은 모달이 아니라 해당 파일 행 안에서 보여 준다(2026-08-08 사용자 지시). */
.b01-temp__progress {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 140px;
font-size: 11px;
}
.b01-temp__progress-bar {
height: 6px;
border-radius: 3px;
background: var(--color-canvas);
overflow: hidden;
}
.b01-temp__progress-bar > div {
height: 100%;
background: var(--color-primary);
transition: width 0.2s ease;
}
/* ── 등록/추가 모달 ───────────────────────────────────────────────────── */
.b01-temp__hidden-input {
+2 -7
View File
@@ -12,6 +12,7 @@
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
import { table, text } from "@ui/ui_template_general_blocks";
import { bytesText } from "./logic/B01_Dashboard_Logic_Pages";
import { attachModalDismiss, L, type ModalDismissHandle } from "./B01_Dashboard_UI_Common";
/** 파일 확장자 = 보관함 슬롯 종류(csv·shp 세트·las·prj·tfw·tif). */
@@ -20,13 +21,7 @@ export function tempFileType(fileName: string): string {
return index >= 0 ? fileName.slice(index + 1).toLowerCase() : "";
}
export function formatTempBytes(bytes: number): string {
const gb = bytes / 1024 / 1024 / 1024;
if (gb >= 1) return `${gb.toFixed(2)} GB`;
const mb = bytes / 1024 / 1024;
if (mb >= 1) return `${mb.toFixed(1)} MB`;
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
}
export const formatTempBytes = bytesText;
export interface TempModalOptions {
title: string;
+205 -186
View File
@@ -1,18 +1,22 @@
/* =============================================================================
* B01_Dashboard_UI_TempUpload.ts
* 대시보드 임시 보관함 — 프로젝트를 만들기 전에 자료를 먼저 올려 두는 곳.
*
* 화면 구성은 프로젝트/사용자 관리 컨테이너와 같다(2026-08-08 사용자 지시):
* - 섹션 우측 상단 [+] 버튼으로 등록 모달 소환
* - 그룹(임시 프로젝트명) 아래에 파일 리스트(표)
* - 업로드 진행률은 그 파일 행 안에서 표시(모달 없음)
* - 그룹별 [파일 추가], 파일별 [삭제] — 삭제 시 임시 저장소 실제 파일도 지운다
* 대용량 라이다는 청크로 나눠 올리고 새로고침 후에도 이어올릴 수 있다.
* 임시 보관함 본체(549-1) — 프로젝트를 만들기 전에 자료를 먼저 올려 두는 곳.
* 목록 = 묶음 표(이름 · 상태 · 남은 날 막대 · 올린 날 · 줄 끝 ⋯) · 줄을 펼치면 그 안 파일 · 진행률
* ⋯ = 파일 추가 · 삭제(확인) · 파일 줄 ⋯ = 삭제(확인) · 삭제하면 임시 저장소 실제 파일도 지움
* 대용량 라이다는 청크로 나눠 올리고 새로고침 후에도 이어올릴 수 있음
* 화면 틀(머리 · 주 단추)은 `B01_Dashboard_UI_View_Temp.ts`
* ========================================================================== */
import { UPLOAD_CHUNK_SIZE_MB } from "@config/config_frontend";
import { createButton, createTag, showToast } from "@ui/ui_template_elements";
import { limitVisibleRows, section, table, text } from "@ui/ui_template_general_blocks";
import { showToast } from "@ui/ui_template_elements";
import { icon } from "@ui/ui_template_icons";
import {
bytesText,
daysLeft,
EXPIRY_WARN_DAYS,
isExpiringSoon,
leftPercent,
} from "./logic/B01_Dashboard_Logic_Pages";
import {
createTempBatch,
createTempUploadSession,
@@ -25,8 +29,18 @@ import {
uploadTempChunk,
type TempBatchItem,
} from "./B01_Dashboard_Api_Temp";
import { DASHBOARD_VISIBLE_ROWS, L } from "./B01_Dashboard_UI_Common";
import { formatTempBytes, openTempFileModal, tempFileType } from "./B01_Dashboard_UI_TempModal";
import { L } from "./B01_Dashboard_UI_Common";
import {
badge,
emptyState,
nameCell,
primaryButton,
rowMenu,
tableRow,
tableShell,
type Column,
} from "./B01_Dashboard_UI_PageKit";
import { openTempFileModal, tempFileType } from "./B01_Dashboard_UI_TempModal";
import "./B01_Dashboard_UI_Style_Temp.css";
/** 청크 이어올리기 표식 — 새로고침 후에도 같은 세션을 잇는다. */
@@ -65,40 +79,48 @@ function formatDate(value: string | null): string {
return Number.isNaN(date.getTime()) ? "-" : date.toLocaleDateString();
}
/**
* 보관함 섹션(제목 + [+] 버튼 + 그룹 목록)을 통째로 만든다.
* 목록은 내부에서 다시 그린다(전체 페이지 재렌더 없음).
*/
export function buildTempUploadSection(): HTMLElement {
const panel = document.createElement("div");
panel.className = "b01-temp";
const COLUMNS: Column[] = [
{ label: "보관 이름" },
{ label: "상태", width: "120px" },
{ label: "남은 기간", width: "210px" },
{ label: "올린 날", width: "110px" },
{ label: "", width: "44px" },
];
// 보관 기간은 제목 옆 태그로 알린다(서버가 준 값으로 목록 조회 때 갱신).
const retentionTag = createTag(`${30}${L("B01_Temp_Hint_Days")}`, "neutral");
export interface TempBox {
root: HTMLElement;
/** 새 자료 등록 모달 — 머리 주 단추가 부름 */
openCreate: () => void;
}
/** 보관함 본체 — 표 · 빈 상태 · 업로드를 한 덩이로 · 목록은 안에서 다시 그림(전체 페이지 재렌더 없음) */
export function buildTempBox(onGuide: () => void): TempBox {
const root = document.createElement("div");
root.className = "b01-pg__body";
const note = document.createElement("p");
note.className = "b01-pg-note";
const listHost = document.createElement("div");
listHost.className = "b01-temp__list";
panel.append(listHost);
root.append(note, listHost);
/** 업로드 중인 파일 — batch_id별 작업표. 서버 목록과 합쳐 행을 그린다. */
const tasks = new Map<string, UploadTask[]>();
/** 진행률 막대 DOM — 청크 하나 올릴 때마다 목록 전체를 다시 그리지 않기 위해 잡아 둔다. */
const progressNodes = new Map<string, { fill: HTMLElement; caption: HTMLElement }>();
/** 펼쳐 둔 그룹 — 기본은 접힘, 사용자가 연 그룹만 목록을 다시 그려도 열린 상태를 유지한다. */
/** 펼쳐 둔 묶음 — 목록을 다시 그려도 열린 상태를 유지한다. */
const openGroups = new Set<string>();
/** 4개 높이 제한을 다시 재는 함수 — 그룹을 펼치면 높이가 달라져 다시 재야 한다. */
let remeasureGroups: (() => void) | null = null;
let retentionDays = 30;
let lastBatches: TempBatchItem[] = [];
/* ── 행 구성 ─────────────────────────────────────────────────────────── */
function progressCell(key: string, percent: number, label: string): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b01-temp__progress";
const wrap = document.createElement("span");
wrap.className = "b01-pg-progress";
const caption = document.createElement("span");
caption.textContent = `${label} ${Math.round(percent)}%`;
const bar = document.createElement("div");
bar.className = "b01-temp__progress-bar";
const fill = document.createElement("div");
const bar = document.createElement("span");
bar.className = "b01-pg-progress__bar";
const fill = document.createElement("i");
fill.style.width = `${percent}%`;
bar.append(fill);
wrap.append(caption, bar);
@@ -113,181 +135,195 @@ export function buildTempUploadSection(): HTMLElement {
node.caption.textContent = `${L("B01_Temp_File_Status_Uploading")} ${Math.round(task.percent)}%`;
}
function fileRows(batch: TempBatchItem): HTMLElement[][] {
const rows: HTMLElement[][] = [];
for (const file of batch.files) {
// 목록에는 아직 프로젝트에 안 쓴 묶음만 온다 — 항상 지울 수 있다.
const action = createButton({
label: L("Common_Btn_Delete"),
variant: "danger",
onClick: () => void removeFile(batch, file.file_type, file.original_filename),
});
rows.push([
text(file.file_type.toUpperCase()),
text(file.original_filename),
text(formatTempBytes(file.file_size_bytes)),
text(L("B01_Temp_File_Status_Stored")),
action,
]);
function fileLine(cells: (string | Node)[]): HTMLElement {
const tr = document.createElement("tr");
for (const cell of cells) {
const td = document.createElement("td");
td.append(cell);
tr.append(td);
}
return tr;
}
/** 펼친 묶음 안 파일 표 — 올린 파일 · 올리는 중 · 이어올리기 대기 */
function filesTable(batch: TempBatchItem): HTMLElement {
const table = document.createElement("table");
table.className = "b01-pg-files";
const body = document.createElement("tbody");
for (const file of batch.files) {
body.append(
fileLine([
file.file_type.toUpperCase(),
file.original_filename,
bytesText(file.file_size_bytes),
badge(L("B01_Temp_File_Status_Stored"), "success"),
rowMenu([
{
label: L("Common_Btn_Delete"),
danger: true,
confirm: `${file.original_filename}\n${L("B01_Temp_File_Delete_Confirm")}`,
onClick: () => void removeFile(batch, file.file_type),
},
]),
]),
);
}
const running = tasks.get(batch.batch_id) ?? [];
for (const task of running) {
const status =
task.status === "failed"
? text(L("B01_Temp_File_Status_Failed"))
? badge(L("B01_Temp_File_Status_Failed"), "danger")
: task.status === "waiting"
? text(L("B01_Temp_File_Status_Waiting"))
? badge(L("B01_Temp_File_Status_Waiting"), "neutral")
: progressCell(
`${batch.batch_id}::${task.key}`,
task.percent,
L("B01_Temp_File_Status_Uploading"),
);
rows.push([
text(task.fileType.toUpperCase()),
text(task.fileName),
text(formatTempBytes(task.sizeBytes)),
status,
text("-"),
]);
body.append(
fileLine([
task.fileType.toUpperCase(),
task.fileName,
bytesText(task.sizeBytes),
status,
"",
]),
);
}
// 다른 창·이전 세션에서 올리다 만 파일 — 이 창의 작업표에는 없으므로 서버 값으로 보여 준다.
for (const pending of batch.pending_sessions) {
const known = running.some((task) => task.fileName === pending.original_filename);
if (known) continue;
rows.push([
text(tempFileType(pending.original_filename).toUpperCase()),
text(pending.original_filename),
text(formatTempBytes(pending.file_size_bytes)),
progressCell(
`${batch.batch_id}::pending::${pending.upload_session_id}`,
pending.progress_percent,
L("B01_Temp_File_Status_Paused"),
),
text("-"),
]);
if (running.some((task) => task.fileName === pending.original_filename)) continue;
body.append(
fileLine([
tempFileType(pending.original_filename).toUpperCase(),
pending.original_filename,
bytesText(pending.file_size_bytes),
progressCell(
`${batch.batch_id}::pending::${pending.upload_session_id}`,
pending.progress_percent,
L("B01_Temp_File_Status_Paused"),
),
"",
]),
);
}
return rows;
if (!body.children.length)
body.append(fileLine(["", "파일 없음 · 묶음 ⋯ 에서 파일 추가", "", "", ""]));
table.append(body);
return table;
}
function renderGroup(batch: TempBatchItem): HTMLElement {
const group = document.createElement("div");
group.className = `b01-temp__group is-${batch.status}`;
const opened = openGroups.has(batch.batch_id);
group.classList.toggle("is-open", opened);
const head = document.createElement("div");
head.className = "b01-temp__group-head";
// 제목 행 = 접기/펼치기 트리거. 이름·상태·요약이 한 줄에 들어간다.
const titleRow = document.createElement("div");
titleRow.className = "b01-temp__group-title";
const caret = document.createElement("span");
caret.className = "b01-temp__caret";
caret.textContent = opened ? "▾" : "▸";
const title = document.createElement("strong");
title.textContent = batch.name;
const badge = document.createElement("span");
badge.className = "b01-temp__badge";
badge.textContent = batch.required_complete
? L("B01_Temp_Status_Ready")
: L("B01_Temp_Status_Uploading");
const meta = document.createElement("span");
meta.className = "b01-temp__group-meta";
const parts = [
`${batch.files.length}${L("B01_Temp_Meta_FileCount")}`,
formatTempBytes(batch.total_size_bytes),
];
if (batch.expires_at) {
parts.push(`${L("B01_Temp_Meta_Expires")} ${formatDate(batch.expires_at)}`);
/** 남은 날 칸 — 막대 + 글 · 7 일 안 = 빨강 */
function leftCell(batch: TempBatchItem): HTMLElement {
const left = daysLeft(batch.expires_at);
const cell = document.createElement("span");
// 다 올리기 전에는 만료가 정해지지 않음 — 막대 없이 「-」
if (left === null) {
cell.className = "b01-pg-meta";
cell.textContent = "-";
return cell;
}
meta.textContent = parts.join(" · ");
titleRow.append(caret, title, badge, meta);
cell.className = `b01-pg-left${isExpiringSoon(left) ? " is-soon" : ""}`;
const bar = document.createElement("span");
bar.className = "b01-pg-left__bar";
const fill = document.createElement("i");
fill.style.width = `${leftPercent(left, retentionDays)}%`;
bar.append(fill);
const label = document.createElement("span");
label.textContent = left === 0 ? "오늘 만료" : `${left}일 남음`;
cell.append(bar, label);
return cell;
}
const actions = document.createElement("div");
actions.className = "b01-dashboard__actions";
// 버튼 클릭이 접기/펼치기까지 건드리지 않게 막는다.
actions.addEventListener("click", (event) => event.stopPropagation());
actions.append(
createButton({
label: L("B01_Temp_Btn_Add"),
variant: "ghost",
onClick: () => openAddModal(batch),
}),
createButton({
label: L("Common_Btn_Delete"),
variant: "danger",
onClick: () => void removeBatch(batch),
}),
);
head.append(titleRow, actions);
head.setAttribute("aria-expanded", String(opened));
head.addEventListener("click", () => {
const nowOpen = !group.classList.contains("is-open");
group.classList.toggle("is-open", nowOpen);
head.setAttribute("aria-expanded", String(nowOpen));
caret.textContent = nowOpen ? "▾" : "▸";
// 목록을 다시 그려도 열어 둔 그룹은 계속 열려 있어야 한다(업로드 중 진행률 확인).
if (nowOpen) openGroups.add(batch.batch_id);
else openGroups.delete(batch.batch_id);
// 펼치면 그 묶음이 커진다 — 4개까지 보이는 높이를 다시 잡는다.
remeasureGroups?.();
function nameWithCaret(batch: TempBatchItem, opened: boolean): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b01-pg-person";
const caret = document.createElement("button");
caret.type = "button";
caret.className = `b01-pg-caret${opened ? " is-open" : ""}`;
caret.title = caret.ariaLabel = "파일 펼치기";
caret.setAttribute("aria-expanded", String(opened));
caret.append(icon("chevronDown", 14));
caret.addEventListener("click", () => {
if (!openGroups.delete(batch.batch_id)) openGroups.add(batch.batch_id);
draw(lastBatches);
});
const sub = `${batch.files.length}${L("B01_Temp_Meta_FileCount")} · ${bytesText(batch.total_size_bytes)}`;
wrap.append(caret, nameCell(batch.name, sub));
return wrap;
}
const body = document.createElement("div");
body.className = "b01-temp__group-body";
body.append(
table(
[
L("B01_Temp_Table_Type"),
L("B01_Temp_Table_Name"),
L("B01_Temp_Table_Size"),
L("B01_Temp_Table_Status"),
L("B01_Temp_Table_Action"),
],
fileRows(batch),
),
function renderBatch(batch: TempBatchItem, tbody: HTMLElement): void {
const opened = openGroups.has(batch.batch_id);
const status = batch.required_complete
? badge(L("B01_Temp_Status_Ready"), "success")
: badge(L("B01_Temp_Status_Uploading"), "warning");
tbody.append(
tableRow(COLUMNS, [
nameWithCaret(batch, opened),
status,
leftCell(batch),
formatDate(batch.created_at),
rowMenu([
{ label: L("B01_Temp_Btn_Add"), onClick: () => openAddModal(batch) },
{
label: L("Common_Btn_Delete"),
danger: true,
confirm: `${batch.name}\n${L("B01_Temp_Delete_Confirm")}`,
onClick: () => void removeBatch(batch),
},
]),
]),
);
if (!opened) return;
const sub = document.createElement("tr");
sub.className = "is-sub";
const cell = document.createElement("td");
cell.colSpan = COLUMNS.length;
cell.append(filesTable(batch));
sub.append(cell);
tbody.append(sub);
}
group.append(head, body);
return group;
function draw(batches: TempBatchItem[]): void {
lastBatches = batches;
progressNodes.clear();
note.textContent = `보관 기간 ${retentionDays}일 · 만료 ${EXPIRY_WARN_DAYS}일 전부터 빨강`;
note.hidden = batches.length === 0;
if (batches.length === 0) {
listHost.replaceChildren(
emptyState({
icon: "archive",
text: "임시 보관함이 비어 있음",
hint: "프로젝트를 만들기 전에 측량 파일을 먼저 올려 둘 수 있음",
action: primaryButton("자료 올리기", openCreateModal, "upload"),
onGuide,
}),
);
return;
}
const shell = tableShell(COLUMNS);
for (const batch of batches) renderBatch(batch, shell.tbody);
listHost.replaceChildren(shell.root);
}
async function refresh(): Promise<void> {
try {
const response = await fetchTempBatches();
retentionTag.textContent = `${response.retention_days}${L("B01_Temp_Hint_Days")}`;
progressNodes.clear();
listHost.replaceChildren();
if (response.batches.length === 0) {
// 빈 상태 문구·서식은 프로젝트 컨테이너와 같게 공용 표 블록을 그대로 쓴다.
listHost.append(table([], []));
return;
}
for (const batch of response.batches) listHost.append(renderGroup(batch));
// 묶음이 쌓여도 컨테이너는 4개 높이까지만 — 나머지는 안쪽 스크롤.
// 표가 아니라 묶음이 한 행이다. 묶음 안 파일 표는 접혀 있어 대상이 아니다.
remeasureGroups = limitVisibleRows(
listHost,
() => [...listHost.querySelectorAll<HTMLElement>(":scope > .b01-temp__group")],
DASHBOARD_VISIBLE_ROWS,
);
retentionDays = response.retention_days;
draw(response.batches);
} catch (error) {
showToast(error instanceof Error ? error.message : L("B01_Temp_Load_Failed"), "error");
}
}
/* ── 삭제 ────────────────────────────────────────────────────────────── */
/* ── 삭제 — 확인은 ⋯ 메뉴가 먼저 받음 ─────────────────────────────────── */
async function removeBatch(batch: TempBatchItem): Promise<void> {
if (!window.confirm(`${batch.name}\n${L("B01_Temp_Delete_Confirm")}`)) return;
try {
await deleteTempBatch(batch.batch_id);
tasks.delete(batch.batch_id);
openGroups.delete(batch.batch_id);
showToast(L("B01_Temp_Delete_Success"), "success");
await refresh();
} catch (error) {
@@ -295,12 +331,7 @@ export function buildTempUploadSection(): HTMLElement {
}
}
async function removeFile(
batch: TempBatchItem,
fileType: string,
fileName: string,
): Promise<void> {
if (!window.confirm(`${fileName}\n${L("B01_Temp_File_Delete_Confirm")}`)) return;
async function removeFile(batch: TempBatchItem, fileType: string): Promise<void> {
try {
await deleteTempBatchFile(batch.batch_id, fileType);
showToast(L("B01_Temp_File_Delete_Success"), "success");
@@ -453,17 +484,5 @@ export function buildTempUploadSection(): HTMLElement {
}
void refresh();
const root = section(L("B01_Temp_Section"), panel, true, [
createButton({ label: "+", onClick: openCreateModal }),
]);
// 공용 섹션 헤더는 [제목 | 액션] 2단이라, 보관 기간 태그를 제목과 한 묶음으로 감싼다.
const heading = root.querySelector("h3");
if (heading) {
const titleWrap = document.createElement("div");
titleWrap.className = "b01-temp__section-title";
heading.replaceWith(titleWrap);
titleWrap.append(heading, retentionTag);
}
return root;
return { root, openCreate: openCreateModal };
}
@@ -0,0 +1,81 @@
/* =============================================================================
* B01_Dashboard_UI_View_Assigned.ts
* 세부 페이지 「받은 예정노선」(549-1) — 산림청이 우리 회사에 지정한 예정노선 연도 표.
* 0 건이어도 메뉴 · 페이지 보임 · 줄 끝 ⋯ = 보기(D02) · 연결(모달)
* ========================================================================== */
import { ROUTES } from "@config/config_frontend";
import { goToWorkflowStage } from "../A00_Common/b_workflow_nav";
import { kstText } from "../common_util/common_util_time_kst";
import { fetchAssignedPlans, type AssignedPlan } from "./B01_Dashboard_UI_AssignedPlans";
import { L } from "./B01_Dashboard_UI_Common";
import {
badge,
dataTable,
emptyState,
nameCell,
pageFrame,
rowMenu,
type Cell,
type Column,
} from "./B01_Dashboard_UI_PageKit";
import { openPlanLinkModal } from "./B01_Dashboard_UI_PlanLink";
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
const COLUMNS: Column[] = [
{ label: L("DE_New_Plan") },
{ label: L("DE_Assigned_Year"), width: "90px" },
{ label: L("D04_Col_Confirmed"), width: "150px" },
{ label: L("DE_Assigned_State"), width: "220px" },
{ label: "", width: "44px" },
];
function stateCell(plan: AssignedPlan): HTMLElement {
const cell = document.createElement("span");
cell.className = "b01-pg-inline";
cell.append(
plan.linked_project_id
? badge(L("DE_Assigned_Linked"), "success")
: badge(L("DE_Assigned_Unlinked"), "neutral"),
);
if (plan.update_available) cell.append(badge(L("DE_Assigned_Update"), "warning"));
return cell;
}
function planRow(plan: AssignedPlan): Cell[] {
const view = (): void => void goToWorkflowStage(plan.project_id, ROUTES.D02_PLAN_BASE);
return [
nameCell(plan.project_name),
String(plan.year),
kstText(plan.confirmed_at),
stateCell(plan),
rowMenu([
{ label: L("DE_Assigned_View"), onClick: view },
{
label: L("DE_Assigned_Link"),
onClick: () =>
void openPlanLinkModal(plan.assignee_id, `${plan.project_name} ${plan.year}`),
},
]),
];
}
export async function mountAssigned(host: HTMLElement, ctx: ViewCtx): Promise<void> {
const page = pageFrame(
host,
L("DE_Assigned_Title"),
"산림청이 지정한 예정노선이 여기 모임 · 연결하면 임도 설계가 그 노선으로 시작됨",
);
const plans = await fetchAssignedPlans().catch(() => []);
if (!host.isConnected) return;
page.body.append(
plans.length
? dataTable(COLUMNS, plans.map(planRow))
: emptyState({
icon: "route",
text: "아직 받은 예정노선이 없음",
hint: "산림청이 우리 회사에 예정노선을 지정하면 여기에 나타남",
onGuide: () => ctx.go("guide"),
}),
);
}
@@ -0,0 +1,107 @@
/* =============================================================================
* B01_Dashboard_UI_View_Company.ts
* 세부 페이지 「회사 정보」(549-1) — 설정 줄 카드(이름 · 사업자번호 · 대표자 · 주소 · 상태 · 수 · 로고).
* 회사 관리자 · 시스템 관리자 = [회사 정보 수정] · 로고 고르기 / 일반 · 산림청 = 보기만
* 시스템 관리자 = 머리에서 고른 회사 · 회사 없음 = [회사 생성] [회사 검색]
* ========================================================================== */
import { createButton } from "@ui/ui_template_elements";
import {
fetchAllCompanies,
fetchCompanyAssets,
fetchUserCompany,
setCompanyLogo,
type CompanyInfo,
} from "./B01_Dashboard_Api_Fetch";
import { createAssetField } from "./B01_Dashboard_UI_AssetPicker";
import { L } from "./B01_Dashboard_UI_Common";
import {
openCreateCompanyModal,
openEditCompanyModal,
openFindCompanyModal,
} from "./B01_Dashboard_UI_Modals";
import {
badge,
emptyState,
pageFrame,
primaryButton,
settingCard,
settingRow,
valueText,
} from "./B01_Dashboard_UI_PageKit";
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
import { statusBadgeOf } from "./logic/B01_Dashboard_Logic_Pages";
async function loadCompany(ctx: ViewCtx): Promise<CompanyInfo | null> {
if (ctx.role === "SYSTEM_ADMIN") {
const all = await fetchAllCompanies().catch(() => []);
return all.find((c) => c.id === ctx.companyId) ?? null;
}
return fetchUserCompany().catch(() => null);
}
/** 로고 줄 — 관리자는 고르기 · 그 밖은 등록 여부만 */
function logoRow(company: CompanyInfo, ctx: ViewCtx, canEdit: boolean): HTMLElement {
const desc = "도면 표제란에 들어감 · 프로젝트가 따로 고르지 않으면 이 로고를 씀";
if (!canEdit) {
return settingRow("회사 로고", desc, valueText(company.logo_asset_id ? "등록됨" : null));
}
const slot = document.createElement("div");
void fetchCompanyAssets(company.id).then((assets) => {
slot.append(
createAssetField("", "LOGO", assets, company.logo_asset_id ?? null, company.id, ctx.me, {
onChange: (assetId) => setCompanyLogo(assetId, company.id).then(() => undefined),
}).root,
);
});
return settingRow("회사 로고", desc, slot);
}
export async function mountCompany(host: HTMLElement, ctx: ViewCtx): Promise<void> {
const page = pageFrame(host, "회사 정보", "회사 이름 · 사업자 정보 · 도면에 들어가는 로고");
const company = await loadCompany(ctx);
if (!host.isConnected) return;
if (!company) {
const actions = document.createElement("div");
actions.className = "b01-pg-inline";
actions.append(
primaryButton(L("B01_Dashboard_CreateCompany"), () => openCreateCompanyModal()),
createButton({
label: L("B01_Dashboard_FindCompany"),
variant: "ghost",
onClick: () => openFindCompanyModal(),
}),
);
page.body.append(
emptyState({
icon: "building",
text: "소속 회사가 없음",
hint: "회사를 만들거나 이미 있는 회사를 찾아 가입 요청을 보낼 수 있음",
action: actions,
onGuide: () => ctx.go("guide"),
}),
);
return;
}
// 회사 정보 수정 · 로고 = 관리자만(범위는 백엔드가 다시 봄)
const canEdit = ctx.role === "ADMIN" || ctx.role === "SYSTEM_ADMIN";
if (canEdit)
page.setAction(primaryButton("회사 정보 수정", () => openEditCompanyModal(company), "pencil"));
const status = company.business_status ? statusBadgeOf(company.business_status) : null;
page.body.append(
settingCard([
settingRow("회사 이름", "", valueText(company.name)),
settingRow(
L("B01_Dashboard_Field_BusinessNumber"),
"",
valueText(company.business_registration_number),
),
settingRow(L("B01_Dashboard_Field_Owner"), "", valueText(company.business_owner)),
settingRow(L("B01_Dashboard_Field_Address"), "", valueText(company.business_address)),
settingRow("사업자 상태", "", status ? badge(status.text, status.tone) : valueText(null)),
settingRow(L("B01_Dashboard_Metric_ActiveUsers"), "", valueText(company.user_count ?? 0)),
settingRow(L("B01_Dashboard_Projects"), "", valueText(company.project_count ?? 0)),
logoRow(company, ctx, canEdit),
]),
);
}
@@ -0,0 +1,37 @@
/* =============================================================================
* B01_Dashboard_UI_View_CompanyDefaults.ts
* 세부 페이지 「회사 설계 기본값」(549-1) — 구조물 조합 회사 층 등 M02 로 가는 설정 줄.
* 줄 규칙 = `settingLinks` 한 벌(역할로 카드 숨김 · 회사 관리자만 수정 · 그 밖은 읽기)
* ========================================================================== */
import { navigateTo } from "../A00_Common/router";
import { settingLinks } from "../A00_Common/m_settings_nav";
import { createButton } from "@ui/ui_template_elements";
import { emptyState, pageFrame, settingCard, settingRow } from "./B01_Dashboard_UI_PageKit";
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
export function mountCompanyDefaults(host: HTMLElement, ctx: ViewCtx): void {
const page = pageFrame(
host,
"회사 설계 기본값",
"새 프로젝트가 처음 받는 구조물 조합 · 회사 층은 회사 관리자가 고침",
);
const links = settingLinks(ctx.me, ctx.companyId);
page.body.append(
links.length
? settingCard(
links.map((link) =>
settingRow(
link.title,
link.desc,
createButton({
label: ctx.role === "ADMIN" ? "열기 · 수정" : "열기",
variant: "ghost",
onClick: () => navigateTo(link.route, link.query),
}),
),
),
)
: emptyState({ icon: "adjustments", text: "표시할 설정이 없음" }),
);
}
+72
View File
@@ -0,0 +1,72 @@
/* =============================================================================
* B01_Dashboard_UI_View_Me.ts
* 내 정보(549-2) — Linear 설정 꼴: 묶음 제목 + 둥근 상자 안 「제목 · 설명 · 오른쪽 조작」 줄.
* 기본정보 · 보안(비밀번호) · 화면(밝음 · 어두움) · 내 설계 기본값(구조물 조합 개인 층).
* ========================================================================== */
import { THEME_STORAGE_KEY } from "@config/config_frontend";
import { createButton } from "@ui/ui_template_elements";
import { settingLinks } from "../A00_Common/m_settings_nav";
import { navigateTo } from "../A00_Common/router";
import { pageFrame, settingCard, settingRow } from "./B01_Dashboard_UI_PageKit";
import { buildPasswordCard, buildProfileCard } from "./B01_Dashboard_UI_Profile";
import { el, group } from "./B01_Dashboard_UI_View_Parts";
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
type Theme = "light" | "dark";
const currentTheme = (): Theme =>
document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
function setTheme(theme: Theme): void {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem(THEME_STORAGE_KEY, theme);
}
/** 밝음 · 어두움 두 칸 — 고르면 바로 바뀌고 이 브라우저에 기억 */
function themeSwitch(): HTMLElement {
const seg = el("div", "b01-sys-seg");
const options: [Theme, string][] = [
["light", "밝음"],
["dark", "어두움"],
];
const sync = (): void => {
for (const b of seg.querySelectorAll("button"))
b.setAttribute("aria-pressed", String(b.dataset.theme === currentTheme()));
};
for (const [theme, label] of options) {
const b = el("button", "", label);
b.type = "button";
b.dataset.theme = theme;
b.addEventListener("click", () => {
setTheme(theme);
sync();
});
seg.append(b);
}
sync();
return seg;
}
export function mountMe(host: HTMLElement, ctx: ViewCtx): void {
const page = pageFrame(host, "내 정보", "내 계정과 화면 설정을 관리합니다.");
const defaults = settingLinks(ctx.me, ctx.companyId)
.filter((link) => link.key === "구조물조합")
.map((link) =>
settingRow(
`${link.title} (내 것)`,
link.desc,
createButton({
label: "열기",
variant: "ghost",
onClick: () => navigateTo(link.route, link.query),
}),
),
);
page.body.append(
group("기본정보", buildProfileCard(ctx.me)),
group("보안", buildPasswordCard()),
group("화면", settingCard([settingRow("테마", "밝은 화면 · 어두운 화면", themeSwitch())])),
...(defaults.length ? [group("내 설계 기본값", settingCard(defaults))] : []),
);
}
@@ -0,0 +1,82 @@
/* =============================================================================
* B01_Dashboard_UI_View_Parts.ts
* 시스템 관리 · 내 정보 페이지(549-2) 곁부품 — 불러오기 틀 · 거르기 줄 · 묶음 제목.
* 공용 조각(`B01_Dashboard_UI_PageKit.ts` · sub4)에 없는 것만 · 모양은 `Style_Pages.css` 아래 「시스템 관리」 쪽.
* ========================================================================== */
import { createButton } from "@ui/ui_template_elements";
import { emptyState } from "./B01_Dashboard_UI_PageKit";
export const el = <K extends keyof HTMLElementTagNameMap>(
tag: K,
className = "",
text?: string,
): HTMLElementTagNameMap[K] => {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
};
/**
* 자료를 읽는 동안 안내 → 다 읽으면 build 가 stage 에 그린 것으로 바꿈.
* 실패하면 까닭 + [다시 시도] · 그 사이 떠났으면(host 떨어짐) 안 그림.
*/
export function loadPage(host: HTMLElement, build: (stage: HTMLElement) => Promise<void>): void {
const run = (): void => {
host.replaceChildren(el("p", "b01-sys-loading", "불러오는 중…"));
const stage = el("div", "b01-sys-stage");
build(stage).then(
() => host.isConnected && host.replaceChildren(...stage.childNodes),
(error: unknown) => {
if (!host.isConnected) return;
const retry = createButton({ label: "다시 시도", variant: "ghost", onClick: run });
const page = el("div", "b01-pg");
page.append(
emptyState({
icon: "bell",
text: error instanceof Error ? error.message : "불러오지 못했습니다.",
action: retry,
}),
);
host.replaceChildren(page);
},
);
};
run();
}
/** 거르기 줄 */
export function toolbar(...items: HTMLElement[]): HTMLElement {
const bar = el("div", "b01-sys-tool");
bar.append(...items);
return bar;
}
export function searchBox(placeholder: string, onInput: (value: string) => void): HTMLInputElement {
const input = el("input", "b01-sys-input");
input.type = "search";
input.placeholder = placeholder;
input.ariaLabel = placeholder;
input.addEventListener("input", () => onInput(input.value.trim().toLowerCase()));
return input;
}
export function selectBox(
label: string,
options: [value: string, text: string][],
onChange: (value: string) => void,
): HTMLSelectElement {
const select = el("select", "b01-sys-input");
select.ariaLabel = label;
for (const [value, text] of options) select.append(new Option(text, value));
select.addEventListener("change", () => onChange(select.value));
return select;
}
/** 묶음 — 제목 + 아래 내용(설정 카드 · 표) */
export function group(title: string, content: Node): HTMLElement {
const box = el("section", "b01-sys-group");
box.append(el("h3", "b01-sys-group__title", title), content);
return box;
}
@@ -0,0 +1,247 @@
/* =============================================================================
* B01_Dashboard_UI_View_People.ts
* 세부 페이지 「사람」(549-1) — 탭 셋: 팀원 · 가입 요청(수 배지) · 초대.
* 회사 관리자 = 자기 회사 · 시스템 관리자 = 머리에서 고른 회사
* 팀원 줄 ⋯ = 수정 · 역할 변경 · 회사에서 빼기(빨강) — 확인은 그 모달이 맡음
* 가입 요청 줄 = [승인] [거절] · 초대 = 이메일로 안내 메일 · 소속 없는 가입자는 [팀원 추가]
* ========================================================================== */
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
import {
fetchAllUsers,
fetchCompanyJoinRequests,
fetchCompanyMembers,
inviteMember,
type DashboardUser,
type JoinRequest,
type Member,
} from "./B01_Dashboard_Api_Fetch";
import { formatDate, L } from "./B01_Dashboard_UI_Common";
import { onB01_JoinRequest_Process_Click } from "./B01_Dashboard_UI_Company";
import { canChangeRole, canDeleteUser } from "./B01_Dashboard_UI_Helper";
import {
openAddMemberModal,
openChangeRoleModal,
openDeleteUserModal,
openEditUserModal,
} from "./B01_Dashboard_UI_Modals";
import {
badge,
dataTable,
emptyState,
kitIcon,
pageFrame,
primaryButton,
rowMenu,
tabBar,
type Cell,
type Column,
} from "./B01_Dashboard_UI_PageKit";
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
import { pendingCount, roleText, statusBadgeOf } from "./logic/B01_Dashboard_Logic_Pages";
type TabKey = "members" | "requests" | "invite";
/** 승인 · 거절 뒤 페이지가 다시 그려져도 보던 탭을 유지 */
let lastTab: TabKey = "members";
const MEMBER_COLUMNS: Column[] = [
{ label: "이름" },
{ label: "이메일" },
{ label: "직급 · 부서" },
{ label: "역할", width: "120px" },
{ label: "상태", width: "90px" },
{ label: "", width: "44px" },
];
const REQUEST_COLUMNS: Column[] = [
{ label: "요청자" },
{ label: "이메일" },
{ label: "요청일", width: "110px" },
{ label: "상태", width: "90px" },
{ label: "", width: "150px", align: "end" },
];
function person(name: string): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b01-pg-person";
const avatar = document.createElement("span");
avatar.className = "b01-pg-avatar";
avatar.textContent = (name.trim() || "?").slice(0, 1);
const text = document.createElement("span");
text.className = "b01-pg-name__main";
text.textContent = name || "-";
wrap.append(avatar, text);
return wrap;
}
function memberRow(m: Member, ctx: ViewCtx): Cell[] {
const me = ctx.me;
const status = statusBadgeOf(m.status);
const items = [{ label: L("Common_Btn_Edit"), onClick: () => openEditUserModal(me, m) }];
const rows = [...items];
if (canChangeRole(me, m)) {
rows.push({ label: L("B01_Dashboard_ChangeRole"), onClick: () => openChangeRoleModal(m) });
}
const system = ctx.role === "SYSTEM_ADMIN";
const removable = canDeleteUser(me, m) && (!system || m.id !== me.id);
return [
person(m.name),
m.email,
[m.position, m.department].filter(Boolean).join(" · ") || "-",
badge(roleText(m.role), m.role === "ADMIN" || m.role === "SYSTEM_ADMIN" ? "accent" : "neutral"),
badge(status.text, status.tone),
rowMenu([
...rows,
...(removable
? [
{
label: L(system ? "B01_Dashboard_DeleteUser" : "B01_Dashboard_RemoveMember"),
danger: true,
ownConfirm: true,
onClick: () => openDeleteUserModal(m),
},
]
: []),
]),
];
}
function requestRow(r: JoinRequest, system: boolean): Cell[] {
const status = statusBadgeOf(r.status);
const waiting = !r.status || r.status.toUpperCase() === "PENDING";
const actions = document.createElement("span");
actions.className = "b01-pg-inline";
if (waiting) {
actions.append(
createButton({
label: L("B01_Dashboard_Approve"),
size: "sm",
onClick: () => void onB01_JoinRequest_Process_Click(r.id, "APPROVE", system),
}),
createButton({
label: L("B01_Dashboard_Reject"),
size: "sm",
variant: "danger",
onClick: () => void onB01_JoinRequest_Process_Click(r.id, "REJECT", system),
}),
);
}
return [
person(r.user_name ?? r.user_email),
r.user_email,
formatDate(r.requested_at),
badge(status.text, status.tone),
actions,
];
}
/** 초대 탭 — 아직 가입 전인 사람에게 안내 메일 · 이미 가입한 사람은 [팀원 추가] */
function invitePanel(companyId: number | null | undefined, onAdd: () => void): HTMLElement {
const box = document.createElement("div");
box.className = "b01-pg-card b01-pg-invite";
const email = createInputField({
label: "초대할 이메일",
type: "email",
placeholder: "name@company.com",
});
const send = createButton({
label: "초대 메일 보내기",
iconStart: kitIcon("mail"),
onClick: async () => {
const value = email.input.value.trim();
if (!value.includes("@")) {
email.setError("메일을 보낼 이메일 주소를 입력");
return;
}
email.setError();
try {
await inviteMember(value, null, companyId);
email.input.value = "";
showToast("안내 메일을 보냈음", "success");
} catch (error) {
showToast(
error instanceof Error ? error.message : L("B01_Dashboard_RequestFailed"),
"error",
);
}
},
});
const form = document.createElement("div");
form.className = "b01-pg-invite__form";
form.append(email.root, send);
const note = document.createElement("p");
note.className = "b01-pg-note";
note.textContent = "아직 가입하지 않은 사람에게 가입 안내 메일만 보냄 · 계정은 본인이 만듦";
const already = document.createElement("p");
already.className = "b01-pg-note";
already.append("이미 가입했고 소속이 없는 사람은 ");
const link = document.createElement("button");
link.type = "button";
link.className = "b01-pg-link";
link.textContent = "팀원 추가";
link.addEventListener("click", onAdd);
already.append(link, " 에서 바로 등록");
box.append(form, note, already);
return box;
}
async function loadPeople(ctx: ViewCtx): Promise<{ members: Member[]; requests: JoinRequest[] }> {
const system = ctx.role === "SYSTEM_ADMIN";
const [members, requests] = await Promise.all([
(system ? fetchAllUsers(ctx.companyId) : fetchCompanyMembers()).catch(() => [] as Member[]),
fetchCompanyJoinRequests(system ? ctx.companyId : undefined).catch(() => []),
]);
return { members: members as DashboardUser[], requests };
}
export async function mountPeople(host: HTMLElement, ctx: ViewCtx): Promise<void> {
const system = ctx.role === "SYSTEM_ADMIN";
const companyId = system ? ctx.companyId : undefined;
const page = pageFrame(host, "사람", "회사 팀원 · 가입 요청 · 초대를 한곳에서 관리");
const addMember = (): void => openAddMemberModal(undefined, companyId);
page.setAction(primaryButton("팀원 추가", addMember));
const { members, requests } = await loadPeople(ctx);
if (!host.isConnected) return;
const content = document.createElement("div");
const draw = (key: TabKey): void => {
if (key === "members") {
content.replaceChildren(
members.length
? dataTable(
MEMBER_COLUMNS,
members.map((m) => memberRow(m, ctx)),
)
: emptyState({
icon: "users",
text: "등록된 팀원이 없음",
action: primaryButton("팀원 추가", addMember),
onGuide: () => ctx.go("guide"),
}),
);
} else if (key === "requests") {
content.replaceChildren(
requests.length
? dataTable(
REQUEST_COLUMNS,
requests.map((r) => requestRow(r, system)),
)
: emptyState({ icon: "users", text: "대기 중인 가입 요청이 없음" }),
);
} else {
content.replaceChildren(invitePanel(companyId, addMember));
}
};
const tabs = tabBar(
[
{ key: "members", label: "팀원" },
{ key: "requests", label: "가입 요청" },
{ key: "invite", label: "초대" },
],
lastTab,
(key) => draw((lastTab = key as TabKey)),
);
tabs.setBadge("requests", pendingCount(requests));
page.body.append(tabs.root, content);
draw(lastTab);
}
@@ -0,0 +1,228 @@
/* =============================================================================
* B01_Dashboard_UI_View_SysCompanies.ts
* 시스템 관리 › 회사(549-2) — 회사 목록(찾기 · 줄 끝 ⋯) → 회사 상세(정보 · 사람 · 프로젝트 탭) · [+ 회사].
* 상세 = 주소 `&company=<id>`(뒤로 가기 됨) · 수정 · 로고 · 새 회사 = 지금 창(Modals) 그대로.
* ========================================================================== */
import { createButton } from "@ui/ui_template_elements";
import { currentQuery } from "../A00_Common/router";
import {
fetchAllCompanies,
fetchAllUsers,
fetchCompanyProjects,
setCompanyLogo,
type CompanyInfo,
type ProjectItem,
} from "./B01_Dashboard_Api_Fetch";
import { openAssetPicker } from "./B01_Dashboard_UI_AssetPicker";
import { formatDate } from "./B01_Dashboard_UI_Common";
import { filterCompanies } from "./B01_Dashboard_UI_CompanyPick";
import { openCreateCompanyModal, openEditCompanyModal } from "./B01_Dashboard_UI_Modals";
import { viewHref } from "./B01_Dashboard_UI_Nav";
import {
badge,
dataTable,
emptyState,
nameCell,
pageFrame,
primaryButton,
rowMenu,
settingCard,
settingRow,
tabBar,
valueText,
} from "./B01_Dashboard_UI_PageKit";
import { el, loadPage, searchBox, toolbar } from "./B01_Dashboard_UI_View_Parts";
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
import { roleText, statusBadgeOf } from "./logic/B01_Dashboard_Logic_Pages";
export const KIND_KO: Record<string, string> = {
ROAD: "임도설계",
PLAN: "예정노선",
STABILITY: "안정성평가",
EROSION: "사방설계",
};
const statusTag = (s?: string | null): HTMLElement =>
badge(s || "-", s === "활동중" ? "success" : "neutral");
const logoLabel = (c: CompanyInfo): string => (c.logo_asset_id ? "로고 변경…" : "로고 지정…");
function pickLogo(ctx: ViewCtx, company: CompanyInfo, after?: () => void): void {
void openAssetPicker("LOGO", company.id, ctx.me, company.logo_asset_id ?? null, async (id) => {
await setCompanyLogo(id, company.id);
company.logo_asset_id = id;
after?.();
});
}
/** 이름 칸 — 이름은 상세로 가는 링크(뒤로 가기 · 새 탭 됨) + 아래 작은 주소 */
function linkedName(c: CompanyInfo): HTMLElement {
const link = el("a", "b01-pg-name__main", c.name);
link.href = viewHref("sys-companies", { company: String(c.id) });
const cell = nameCell(c.name, c.business_address);
cell.firstElementChild!.replaceWith(link);
return cell;
}
const LIST_COLUMNS = [
{ label: "회사" },
{ label: "사업자등록번호" },
{ label: "대표자" },
{ label: "상태" },
{ label: "사용자", align: "end" as const },
{ label: "프로젝트", align: "end" as const },
{ label: "" },
];
function companyList(stage: HTMLElement, ctx: ViewCtx, companies: CompanyInfo[]): void {
const page = pageFrame(
stage,
"회사",
"등록된 회사를 보고 정보 · 로고를 고칩니다. 이름을 누르면 상세입니다.",
);
page.setAction(primaryButton("회사", () => openCreateCompanyModal()));
const body = el("div");
const draw = (query: string): void => {
const shown = filterCompanies(companies, query);
if (!shown.length) {
body.replaceChildren(
companies.length
? emptyState({ icon: "search", text: "찾는 회사가 없습니다." })
: emptyState({
icon: "building",
text: "등록된 회사가 없습니다.",
action: primaryButton("회사", () => openCreateCompanyModal()),
}),
);
return;
}
body.replaceChildren(
dataTable(
LIST_COLUMNS,
shown.map((c) => [
linkedName(c),
c.business_registration_number ?? "-",
c.business_owner ?? "-",
statusTag(c.business_status),
String(c.user_count ?? 0),
String(c.project_count ?? 0),
rowMenu([
{
label: "상세 보기",
onClick: () => ctx.go("sys-companies", { company: String(c.id) }),
},
{ label: "정보 수정", onClick: () => openEditCompanyModal(c) },
{ label: logoLabel(c), onClick: () => pickLogo(ctx, c) },
]),
]),
),
);
};
draw("");
page.body.append(toolbar(searchBox("회사 이름 · 사업자번호 찾기", draw)), body);
}
const projectTable = (projects: ProjectItem[]): HTMLElement =>
projects.length
? dataTable(
[
{ label: "프로젝트" },
{ label: "종류" },
{ label: "지역" },
{ label: "단계" },
{ label: "담당" },
{ label: "수정일" },
],
projects.map((p) => [
p.name,
KIND_KO[p.project_kind ?? "ROAD"] ?? "-",
p.region ?? "-",
`${p.workflow_stage} 단계`,
p.owner_name ?? "-",
formatDate(p.updated_at),
]),
)
: emptyState({ icon: "folder", text: "이 회사의 프로젝트가 없습니다." });
async function companyDetail(
stage: HTMLElement,
ctx: ViewCtx,
company: CompanyInfo,
): Promise<void> {
const [users, projects] = await Promise.all([
fetchAllUsers(company.id).catch(() => []),
fetchCompanyProjects(company.id).catch(() => []),
]);
const page = pageFrame(stage, company.name, "회사 상세");
page.setAction(
createButton({ label: "정보 수정", onClick: () => openEditCompanyModal(company) }),
);
const back = el("button", "b01-sys-back", "‹ 회사 목록");
back.type = "button";
back.addEventListener("click", () => ctx.go("sys-companies"));
const logoBtn = createButton({
label: logoLabel(company),
variant: "ghost",
onClick: () => pickLogo(ctx, company, () => (logoBtn.textContent = logoLabel(company))),
});
const info = settingCard([
settingRow("회사 이름", "", valueText(company.name)),
settingRow("사업자등록번호", "", valueText(company.business_registration_number)),
settingRow("대표자", "", valueText(company.business_owner)),
settingRow("주소", "", valueText(company.business_address)),
settingRow("상태", "", statusTag(company.business_status)),
settingRow("로고", "도면 표제란에 들어갑니다.", logoBtn),
]);
const people = users.length
? dataTable(
[
{ label: "이름" },
{ label: "직급" },
{ label: "부서" },
{ label: "역할" },
{ label: "상태" },
{ label: "" },
],
users.map((u) => {
const st = statusBadgeOf(u.status);
return [
nameCell(u.name, u.email),
u.position ?? "-",
u.department ?? "-",
roleText(u.role),
badge(st.text, st.tone),
rowMenu([
{
label: "사용자 페이지에서 보기",
onClick: () => ctx.go("sys-users", { company: String(company.id), q: u.email }),
},
]),
];
}),
)
: emptyState({ icon: "users", text: "이 회사에 소속된 사용자가 없습니다." });
const panes: Record<string, HTMLElement> = { info, people, projects: projectTable(projects) };
const pane = el("div");
pane.append(info);
const tabs = tabBar(
[
{ key: "info", label: "정보" },
{ key: "people", label: `사람 ${users.length}` },
{ key: "projects", label: `프로젝트 ${projects.length}` },
],
"info",
(key) => pane.replaceChildren(panes[key]),
);
page.body.append(back, tabs.root, pane);
}
export function mountSysCompanies(host: HTMLElement, ctx: ViewCtx): void {
const id = Number(currentQuery().get("company"));
loadPage(host, async (stage) => {
const companies = await fetchAllCompanies();
const company = id ? companies.find((c) => c.id === id) : undefined;
if (company) await companyDetail(stage, ctx, company);
else companyList(stage, ctx, companies);
});
}
@@ -0,0 +1,36 @@
/* =============================================================================
* B01_Dashboard_UI_View_SysDefaults.ts
* 시스템 관리 › 설계 기본값(549-2) — 마스터 데이터 · 구조물 조합 · 구조물 옵션 · 도면 양식 · 계산 자리.
* 카드 규칙은 `settingLinks` 한 벌(334) — 여기는 모양만(묶음 제목 + 둥근 상자 안 줄) · 계산 자리 = 창(504-2).
* ========================================================================== */
import { createButton } from "@ui/ui_template_elements";
import { settingLinks, type SettingLink } from "../A00_Common/m_settings_nav";
import { navigateTo } from "../A00_Common/router";
import { openCalcSitesDialog } from "./B01_Dashboard_UI_CalcSites";
import { pageFrame, settingCard, settingRow } from "./B01_Dashboard_UI_PageKit";
import { group } from "./B01_Dashboard_UI_View_Parts";
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
/** 카드 열쇠 → 묶음 제목 — 모르는 열쇠는 「그 밖」 */
const GROUP_OF: Record<string, string> = {
m01: "마스터 데이터",
구조물조합: "구조물 · 도면",
옵션: "구조물 · 도면",
도면: "구조물 · 도면",
calcSites: "계산",
};
const open = (link: SettingLink): void =>
link.key === "calcSites" ? void openCalcSitesDialog() : navigateTo(link.route, link.query);
export function mountSysDefaults(host: HTMLElement, ctx: ViewCtx): void {
const groups = new Map<string, HTMLElement[]>();
for (const link of settingLinks(ctx.me, ctx.companyId)) {
const title = GROUP_OF[link.key] ?? "그 밖";
const openBtn = createButton({ label: "열기", variant: "ghost", onClick: () => open(link) });
groups.set(title, [...(groups.get(title) ?? []), settingRow(link.title, link.desc, openBtn)]);
}
const page = pageFrame(host, "설계 기본값", "새 프로젝트가 처음 받는 마스터 값을 관리합니다.");
page.body.append(...[...groups].map(([title, rows]) => group(title, settingCard(rows))));
}
@@ -0,0 +1,166 @@
/* =============================================================================
* B01_Dashboard_UI_View_SysLogs.ts
* 시스템 관리 › 시스템 로그(549-2) — 동작 · 대상 · 접속 주소 · 일시 표 + 거르기(동작 · 기간 · 찾기).
* 서버는 최신순 쪽(100 줄)만 줌 → 받은 줄 안에서 거르고 [이전 기록 더 보기] 로 다음 쪽을 이어 붙임.
* ========================================================================== */
import { fetchAuditLogPage, type AuditLog } from "./B01_Dashboard_Api_Fetch";
import { formatDate, formatTime } from "./B01_Dashboard_UI_Common";
import { createButton } from "@ui/ui_template_elements";
import { dataTable, emptyState, pageFrame } from "./B01_Dashboard_UI_PageKit";
import { el as h, loadPage, searchBox, selectBox, toolbar } from "./B01_Dashboard_UI_View_Parts";
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
const PAGE = 100;
export const ACTION_KO: Record<string, string> = {
PROJECT_CREATE: "프로젝트 생성",
PROJECT_UPDATE: "프로젝트 수정",
PROJECT_DELETE: "프로젝트 삭제",
PROJECT_HARD_DELETE: "프로젝트 영구 삭제",
COMPANY_CREATE: "회사 생성",
CALC_SITE_CHANGE: "계산 자리 변경",
RATE_ANOMALY: "호출 이상",
M02_OTHER_LAYER_WRITE: "마스터 다른 층 수정",
};
const TARGET_KO: Record<string, string> = {
PROJECT: "프로젝트",
COMPANY: "회사",
USER: "사용자",
ASSET: "자산",
M02_PERSONAL: "개인 마스터",
CALC_SITE: "계산 자리",
};
export const actionLabel = (action: string): string => ACTION_KO[action] ?? action;
/** 무엇에 한 일인지 — 종류 + 번호(UUID 는 앞 8 자) */
export function targetText(log: AuditLog): string {
const kind = TARGET_KO[(log.resource_type ?? "").toUpperCase()] ?? log.resource_type ?? "";
const ref = log.resource_ref ?? (log.resource_id ? String(log.resource_id) : "");
const short = ref.length > 12 ? `${ref.slice(0, 8)}…` : ref;
return kind ? `${kind} ${short}`.trim() : short || "-";
}
export interface LogFilter {
action: string;
from: string;
to: string;
q: string;
}
/** 동작 · 기간(날짜 YYYY-MM-DD 양끝 포함) · 찾기(이메일 · 대상 · 접속 주소) */
export function filterLogs(logs: AuditLog[], f: LogFilter): AuditLog[] {
return logs.filter((l) => {
const day = (l.timestamp ?? "").slice(0, 10);
if (f.action && l.action !== f.action) return false;
if (f.from && day < f.from) return false;
if (f.to && day > f.to) return false;
if (!f.q) return true;
return [l.email, targetText(l), l.ip_address, actionLabel(l.action)]
.filter(Boolean)
.join(" ")
.toLowerCase()
.includes(f.q);
});
}
function dateInput(label: string, onChange: (v: string) => void): HTMLInputElement {
const input = h("input", "b01-sys-input");
input.type = "date";
input.ariaLabel = label;
input.title = label;
input.addEventListener("change", () => onChange(input.value));
return input;
}
const COLUMNS = [
{ label: "사용자" },
{ label: "동작" },
{ label: "대상" },
{ label: "접속 주소" },
{ label: "일시" },
];
export function mountSysLogs(host: HTMLElement, _ctx: ViewCtx): void {
loadPage(host, async (stage) => {
const first = await fetchAuditLogPage(1, PAGE);
let logs = first.items;
let page = 1;
const total = first.total;
const f: LogFilter = { action: "", from: "", to: "", q: "" };
const body = h("div");
const count = h("span", "b01-sys-count");
const more = createButton({
label: "이전 기록 더 보기",
variant: "ghost",
onClick: () => void loadMore(),
});
const actionBox = h("span");
const drawActions = (): void => {
const kinds = [...new Set(logs.map((l) => l.action))].sort();
const select = selectBox(
"동작",
[["", "모든 동작"], ...kinds.map((a): [string, string] => [a, actionLabel(a)])],
(v) => {
f.action = v;
draw();
},
);
select.value = f.action;
actionBox.replaceChildren(select);
};
const draw = (): void => {
const shown = filterLogs(logs, f);
count.textContent = `${shown.length} / ${logs.length} 줄 (전체 ${total} 줄)`;
more.hidden = logs.length >= total;
body.replaceChildren(
shown.length
? dataTable(
COLUMNS,
shown.map((l) => {
const action = h("span", "", actionLabel(l.action));
action.title = l.action;
return [
l.email ?? "-",
action,
targetText(l),
l.ip_address ?? "-",
`${formatDate(l.timestamp)} ${formatTime(l.timestamp)}`.trim(),
];
}),
)
: emptyState({
icon: "fileText",
text: logs.length ? "조건에 맞는 기록이 없습니다." : "기록이 없습니다.",
}),
);
};
async function loadMore(): Promise<void> {
page += 1;
logs = logs.concat((await fetchAuditLogPage(page, PAGE)).items);
drawActions();
draw();
}
drawActions();
draw();
const search = searchBox("사용자 · 대상 · 접속 주소 찾기", (v) => {
f.q = v;
draw();
});
const frame = pageFrame(
stage,
"시스템 로그",
"누가 무엇을 했는지 최신순으로 봅니다. 기록은 1 년 보관됩니다.",
);
const from = dateInput("시작일", (v) => {
f.from = v;
draw();
});
const to = dateInput("종료일", (v) => {
f.to = v;
draw();
});
frame.body.append(toolbar(search, actionBox, from, h("span", "", "~"), to, count), body, more);
});
}
@@ -0,0 +1,155 @@
/* =============================================================================
* B01_Dashboard_UI_View_SysOverview.ts
* 시스템 관리 › 개요(549-2) — 숫자 카드(회사 · 사용자 · 활성 프로젝트 · 활성 사용자) +
* CPU · 메모리 · 디스크 게이지(넘으면 주황 · 빨강) + 7 일 그래프(인라인 SVG).
* 자료 = 리소스 API(7 일) · 회사 목록 · 전체 사용자 — 시스템 관리자만(서버도 막음).
* ========================================================================== */
import {
fetchAllCompanies,
fetchAllUsers,
fetchSystemResources,
type ResourceData,
} from "./B01_Dashboard_Api_Fetch";
import { pageFrame } from "./B01_Dashboard_UI_PageKit";
import { el as h, loadPage } from "./B01_Dashboard_UI_View_Parts";
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
const SVG_NS = "http://www.w3.org/2000/svg";
const DAY_MS = 86_400_000;
/** 게이지 색 바뀌는 선 — 이 % 이상 주황 · 빨강 */
export const GAUGE_WARN = 70;
export const GAUGE_BAD = 90;
export const gaugeTone = (percent: number | null | undefined): "ok" | "warn" | "bad" =>
percent == null || percent < GAUGE_WARN ? "ok" : percent < GAUGE_BAD ? "warn" : "bad";
function statCard(label: string, value: number | string): HTMLElement {
const card = h("div", "b01-pg-card b01-sys-stat");
card.append(
h("span", "b01-sys-stat__label", label),
h("span", "b01-sys-stat__value", String(value)),
);
return card;
}
function gauge(label: string, percent: number | null | undefined): HTMLElement {
const card = h("div", `b01-pg-card b01-sys-gauge is-${gaugeTone(percent)}`);
const top = h("div", "b01-sys-gauge__top");
top.append(
h("span", "b01-sys-stat__label", label),
h("span", "b01-sys-stat__value", percent == null ? "-" : `${Math.round(percent)}%`),
);
const bar = h("div", "b01-sys-gauge__bar");
bar.setAttribute("role", "progressbar");
bar.setAttribute("aria-valuenow", String(Math.round(percent ?? 0)));
const fill = h("div", "b01-sys-gauge__fill");
fill.style.width = `${Math.min(100, Math.max(0, percent ?? 0))}%`;
bar.append(fill);
card.append(top, bar);
return card;
}
const svg = (tag: string, attrs: Record<string, string | number>): SVGElement => {
const e = document.createElementNS(SVG_NS, tag);
for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, String(v));
return e;
};
const SERIES = [
{ name: "CPU", key: "cpu_usage_percent", color: "var(--b01-s1)" },
{ name: "메모리", key: "memory_usage_percent", color: "var(--b01-s2)" },
{ name: "디스크", key: "disk_usage_percent", color: "var(--b01-s3)" },
] as const;
/** 7 일 그래프 — 가로 = 시각 · 세로 0~100 % · 세 시간 넘게 비면 선을 끊음 */
export function buildWeekChart(data: ResourceData | null, now = Date.now()): HTMLElement {
const W = 720;
const H = 180;
const pad = { l: 28, r: 8, t: 8, b: 20 };
const start = now - 7 * DAY_MS;
const x = (t: number): number => pad.l + ((t - start) / (now - start)) * (W - pad.l - pad.r);
const y = (v: number): number => pad.t + (1 - v / 100) * (H - pad.t - pad.b);
const box = h("div", "b01-pg-card b01-sys-chart");
const legend = h("div", "b01-sys-chart__legend");
for (const s of SERIES) {
const item = h("span", "", s.name);
const dot = h("i");
dot.style.background = s.color;
item.prepend(dot);
legend.append(item);
}
const chart = svg("svg", { viewBox: `0 0 ${W} ${H}`, role: "img" });
chart.setAttribute("aria-label", "최근 7 일 CPU · 메모리 · 디스크 사용률");
for (const v of [0, 50, 100]) {
chart.append(svg("line", { x1: pad.l, x2: W - pad.r, y1: y(v), y2: y(v) }));
const label = svg("text", { x: pad.l - 4, y: y(v) + 3, "text-anchor": "end" });
label.textContent = `${v}`;
chart.append(label);
}
// 날짜 눈금 — 자정마다
const midnight = new Date(start);
midnight.setHours(24, 0, 0, 0);
for (let t = midnight.getTime(); t <= now; t += DAY_MS) {
const d = new Date(t);
const label = svg("text", { x: x(t), y: H - 6, "text-anchor": "middle" });
label.textContent = `${d.getMonth() + 1}/${d.getDate()}`;
chart.append(label);
}
const points = (data?.history ?? [])
.filter((p) => p.timestamp)
.map((p) => ({
t: new Date(p.timestamp.endsWith("Z") ? p.timestamp : `${p.timestamp}Z`).getTime(),
p,
}))
.filter(({ t }) => t >= start && t <= now)
.sort((a, b) => a.t - b.t);
for (const s of SERIES) {
let d = "";
let prev = -Infinity;
for (const { t, p } of points) {
const v = p[s.key];
if (v == null) continue;
d += `${t - prev > 3 * 3_600_000 ? "M" : "L"}${x(t).toFixed(1)} ${y(v).toFixed(1)} `;
prev = t;
chart.append(
svg("circle", { cx: x(t).toFixed(1), cy: y(v).toFixed(1), r: 1.5, fill: s.color }),
);
}
chart.append(svg("path", { d, fill: "none", stroke: s.color, "stroke-width": 1.5 }));
}
box.append(legend, chart);
if (!points.length) box.append(h("p", "b01-pg-note", "7 일 안에 쌓인 기록이 없습니다."));
return box;
}
export function mountSysOverview(host: HTMLElement, _ctx: ViewCtx): void {
loadPage(host, async (stage) => {
const [res, companies, users] = await Promise.all([
fetchSystemResources(7),
fetchAllCompanies().catch(() => []),
fetchAllUsers(null).catch(() => []),
]);
const now = res.current;
const stats = h("div", "b01-sys-stats");
stats.append(
statCard("회사", companies.length),
statCard("사용자", users.length),
statCard("활성 프로젝트", now?.active_project_count ?? 0),
statCard("활성 사용자", now?.active_user_count ?? 0),
);
const gauges = h("div", "b01-sys-stats");
gauges.append(
gauge("CPU", now?.cpu_usage_percent),
gauge("메모리", now?.memory_usage_percent),
gauge("디스크", now?.disk_usage_percent),
);
const page = pageFrame(
stage,
"개요",
"서버 상태와 회사 · 사용자 · 프로젝트 수를 한눈에 봅니다.",
);
page.body.append(stats, gauges, buildWeekChart(res));
});
}
@@ -0,0 +1,162 @@
/* =============================================================================
* B01_Dashboard_UI_View_SysUsers.ts
* 시스템 관리 › 사용자(549-2) — 전체 사용자 표 · 거르기(회사 · 역할 · 찾기) · [+ 사용자] · 줄 끝 ⋯(수정 · 역할 변경 · 삭제).
* 회사 거르기 = 서버가 그 회사 사람만 줌(전체는 최근 200 명) · 역할 · 찾기 = 받은 줄 안에서.
* 주소 `&company=<id>&q=<글>` 로 미리 거른 채 열 수 있음(회사 상세 › 사람).
* ========================================================================== */
import { currentQuery } from "../A00_Common/router";
import {
fetchAllCompanies,
fetchAllUsers,
type CompanyInfo,
type DashboardUser,
} from "./B01_Dashboard_Api_Fetch";
import { canChangeRole, canDeleteUser } from "./B01_Dashboard_UI_Helper";
import {
openAddMemberModal,
openChangeRoleModal,
openDeleteUserModal,
openEditUserModal,
} from "./B01_Dashboard_UI_Modals";
import {
badge,
dataTable,
emptyState,
nameCell,
pageFrame,
primaryButton,
rowMenu,
type RowMenuItem,
} from "./B01_Dashboard_UI_PageKit";
import { el, loadPage, searchBox, selectBox, toolbar } from "./B01_Dashboard_UI_View_Parts";
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
import { roleText, statusBadgeOf } from "./logic/B01_Dashboard_Logic_Pages";
const SERVER_LIMIT = 200;
/** 역할 · 찾기(이름 · 이메일 · 회사 · 부서 · 직급) 거르기 — 순서는 받은 그대로 */
export function filterUsers(users: DashboardUser[], role: string, query: string): DashboardUser[] {
const q = query.trim().toLowerCase();
return users.filter(
(u) =>
(!role || u.role === role) &&
(!q ||
[u.name, u.email, u.company_name, u.department, u.position]
.filter(Boolean)
.join(" ")
.toLowerCase()
.includes(q)),
);
}
const COLUMNS = [
{ label: "이름" },
{ label: "회사" },
{ label: "직급" },
{ label: "부서" },
{ label: "전화" },
{ label: "역할" },
{ label: "상태" },
{ label: "" },
];
function menuOf(ctx: ViewCtx, u: DashboardUser): RowMenuItem[] {
const items: RowMenuItem[] = [
{ label: "정보 수정", onClick: () => openEditUserModal(ctx.me, u) },
];
if (canChangeRole(ctx.me, u))
items.push({ label: "역할 변경", onClick: () => openChangeRoleModal(u) });
// 회사에서 빼기 · 계정 삭제 — 처리 방식 고르는 창이 확인을 겸함 · 자기 자신은 안 보임
if (canDeleteUser(ctx.me, u) && u.id !== ctx.me.id)
items.push({ label: "삭제…", onClick: () => openDeleteUserModal(u) });
return items;
}
export function mountSysUsers(host: HTMLElement, ctx: ViewCtx): void {
const start = currentQuery();
const state = {
company: start.get("company") ?? "",
role: "",
q: (start.get("q") ?? "").toLowerCase(),
};
loadPage(host, async (stage) => {
const companies: CompanyInfo[] = await fetchAllCompanies().catch(() => []);
let users: DashboardUser[] = [];
const body = el("div");
const count = el("span", "b01-sys-count");
const draw = (): void => {
const shown = filterUsers(users, state.role, state.q);
const capped = users.length >= SERVER_LIMIT && !state.company;
count.textContent = `${shown.length} / ${users.length} 명${capped ? " · 최근 200 명까지 — 회사로 거르면 더 보임" : ""}`;
body.replaceChildren(
shown.length
? dataTable(
COLUMNS,
shown.map((u) => {
const st = statusBadgeOf(u.status);
return [
nameCell(u.name, u.email),
u.company_name ?? "-",
u.position ?? "-",
u.department ?? "-",
u.phone ?? "-",
badge(roleText(u.role), u.role === "USER" ? "neutral" : "accent"),
badge(statusLabel(u.status, st.text), st.tone),
rowMenu(menuOf(ctx, u)),
];
}),
)
: emptyState({
icon: "user",
text: users.length ? "조건에 맞는 사용자가 없습니다." : "사용자가 없습니다.",
}),
);
};
const load = async (): Promise<void> => {
users = await fetchAllUsers(state.company ? Number(state.company) : null);
draw();
};
await load();
const company = selectBox(
"회사",
[["", "모든 회사"], ...companies.map((c): [string, string] => [String(c.id), c.name])],
(v) => {
state.company = v;
void load();
},
);
company.value = state.company;
const role = selectBox(
"역할",
[
["", "모든 역할"],
["SYSTEM_ADMIN", "시스템 관리자"],
["ADMIN", "회사 관리자"],
["USER", "일반 사용자"],
],
(v) => {
state.role = v;
draw();
},
);
const search = searchBox("이름 · 이메일 · 부서 찾기", (v) => {
state.q = v;
draw();
});
search.value = state.q;
const page = pageFrame(stage, "사용자", "모든 회사의 사용자를 보고 정보 · 역할을 고칩니다.");
page.setAction(
primaryButton("사용자", () =>
openAddMemberModal(undefined, state.company ? Number(state.company) : ctx.companyId),
),
);
page.body.append(toolbar(search, company, role, count), body);
});
}
/** 계정 상태 글 — 공용 배지 글에 없는 값(회사 없음)만 덧붙임 */
function statusLabel(status: string, fallback: string): string {
return status === "NO_COMPANY" ? "회사 없음" : fallback;
}
@@ -0,0 +1,20 @@
/* =============================================================================
* B01_Dashboard_UI_View_Temp.ts
* 세부 페이지 「임시 보관함」(549-1) — 머리 + 오른쪽 위 [자료 올리기] + 보관함 본체(`UI_TempUpload`).
* ========================================================================== */
import { L } from "./B01_Dashboard_UI_Common";
import { pageFrame, primaryButton } from "./B01_Dashboard_UI_PageKit";
import { buildTempBox } from "./B01_Dashboard_UI_TempUpload";
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
export function mountTemp(host: HTMLElement, ctx: ViewCtx): void {
const page = pageFrame(
host,
L("B01_Temp_Section"),
"프로젝트를 만들기 전에 측량 파일을 먼저 올려 두는 곳 · 새 프로젝트를 만들 때 불러 씀",
);
const box = buildTempBox(() => ctx.go("guide"));
page.setAction(primaryButton("자료 올리기", box.openCreate, "upload"));
page.body.append(box.root);
}
+92
View File
@@ -0,0 +1,92 @@
/* =============================================================================
* B01_Dashboard_UI_Views.ts
* 대시보드 세부 페이지 등록부(546) — 한 줄 = 한 페이지 · 주소 `#/b01-account?view=<열쇠>&…`.
* key = 주소 열쇠 · title = 메뉴 · 머리 글 · group = 메뉴 묶음(hidden = 메뉴에 안 보임 · 주소로만)
* roles = 보이는 역할(없는 역할은 메뉴에서 숨고 주소로 와도 홈) · icon = ui_template_icons 이름
* mount(host, ctx) = host 에 그림 · 정리 함수를 돌려주면 떠날 때 부름(비동기 가능)
* ctx = { me, role, companyId, go(view, params?) } — companyId = 시스템 관리자는 머리에서 고른 회사
* 새 페이지는 자기 줄만 갈아 끼움.
* ========================================================================== */
import type { IconName } from "@ui/ui_template_icons";
import type { DashboardUser } from "./B01_Dashboard_Api_Fetch";
import { mountHome } from "./B01_Dashboard_UI_Home";
import { mountChat, mountGuide } from "./B01_Dashboard_UI_Nav";
import { mountAssigned } from "./B01_Dashboard_UI_View_Assigned";
import { mountCompany } from "./B01_Dashboard_UI_View_Company";
import { mountCompanyDefaults } from "./B01_Dashboard_UI_View_CompanyDefaults";
import { mountPeople } from "./B01_Dashboard_UI_View_People";
import { mountTemp } from "./B01_Dashboard_UI_View_Temp";
import { mountProjectDetail } from "./B01_Dashboard_UI_ProjectDetail";
import { mountProjectList } from "./B01_Dashboard_UI_ProjectList";
import { mountMe } from "./B01_Dashboard_UI_View_Me";
import { mountSysCompanies } from "./B01_Dashboard_UI_View_SysCompanies";
import { mountSysDefaults } from "./B01_Dashboard_UI_View_SysDefaults";
import { mountSysLogs } from "./B01_Dashboard_UI_View_SysLogs";
import { mountSysOverview } from "./B01_Dashboard_UI_View_SysOverview";
import { mountSysUsers } from "./B01_Dashboard_UI_View_SysUsers";
import { mountUiKit } from "@ui/ui_template_ui_kit";
/** 역할 넷 — 산림청 = 세션 is_forest_service 인 일반 사용자(회사 관리자 · 시스템 관리자가 앞섬) */
export type ViewRole = "USER" | "ADMIN" | "FOREST" | "SYSTEM_ADMIN";
export type ViewGroup = "work" | "company" | "system" | "help" | "hidden";
export interface ViewCtx {
me: DashboardUser;
role: ViewRole;
companyId: number | null;
go: (view: string, params?: Record<string, string>) => void;
}
type Cleanup = (() => void) | void;
export interface View {
key: string;
title: string;
group: ViewGroup;
roles: readonly ViewRole[];
icon?: IconName;
mount: (host: HTMLElement, ctx: ViewCtx) => Cleanup | Promise<Cleanup>;
}
export const DEFAULT_VIEW = "home";
const ALL: readonly ViewRole[] = ["USER", "ADMIN", "FOREST", "SYSTEM_ADMIN"];
// prettier-ignore
export const VIEWS: View[] = [
// 작업
{ key: "home", title: "홈", group: "work", roles: ALL, icon: "home", mount: mountHome },
{ key: "projects", title: "프로젝트", group: "work", roles: ALL, icon: "folder", mount: mountProjectList },
{ key: "project", title: "프로젝트 상세", group: "hidden", roles: ALL, mount: mountProjectDetail },
{ key: "assigned", title: "받은 예정노선", group: "work", roles: ["USER", "ADMIN", "SYSTEM_ADMIN"], icon: "route", mount: mountAssigned },
{ key: "temp", title: "임시 보관함", group: "work", roles: ALL, icon: "archive", mount: mountTemp },
// 회사 관리
{ key: "people", title: "사람", group: "company", roles: ["ADMIN", "SYSTEM_ADMIN"], icon: "users", mount: mountPeople },
{ key: "company", title: "회사 정보", group: "company", roles: ALL, icon: "building", mount: mountCompany },
{ key: "company-defaults", title: "회사 설계 기본값", group: "company", roles: ["USER", "ADMIN", "FOREST"], icon: "adjustments", mount: mountCompanyDefaults },
// 시스템 관리
{ key: "sys-overview", title: "개요", group: "system", roles: ["SYSTEM_ADMIN"], icon: "gauge", mount: mountSysOverview },
{ key: "sys-companies", title: "회사", group: "system", roles: ["SYSTEM_ADMIN"], icon: "building", mount: mountSysCompanies },
{ key: "sys-users", title: "사용자", group: "system", roles: ["SYSTEM_ADMIN"], icon: "user", mount: mountSysUsers },
{ key: "sys-defaults", title: "설계 기본값", group: "system", roles: ["SYSTEM_ADMIN"], icon: "database", mount: mountSysDefaults },
{ key: "sys-logs", title: "시스템 로그", group: "system", roles: ["SYSTEM_ADMIN"], icon: "fileText", mount: mountSysLogs },
{ key: "ui-kit", title: "부품 모음", group: "system", roles: ["SYSTEM_ADMIN"], icon: "adjustments", mount: mountUiKit },
// 도움
{ key: "guide", title: "사용자 가이드", group: "help", roles: ALL, icon: "book", mount: mountGuide },
{ key: "chat", title: "지식 챗봇", group: "help", roles: ALL, icon: "chatbot", mount: mountChat },
// 사용자 메뉴
{ key: "me", title: "내 정보", group: "hidden", roles: ALL, icon: "userCircle", mount: mountMe },
];
/** 역할 가르기 — 옛 B01 갈래(시스템 관리자 · 회사 관리자 · 그 밖) + 산림청 */
export function roleOf(me: DashboardUser, forest: boolean): ViewRole {
if (me.role === "SYSTEM_ADMIN" || me.role === "ADMIN") return me.role;
return forest ? "FOREST" : "USER";
}
/** 주소 열쇠 → 페이지 · 없거나 권한 없으면 홈 */
export function viewFor(key: string | null, role: ViewRole): View {
const found = VIEWS.find((v) => v.key === key && v.roles.includes(role));
return found ?? VIEWS.find((v) => v.key === DEFAULT_VIEW)!;
}
@@ -0,0 +1,80 @@
/* =============================================================================
* logic/B01_Dashboard_Home_Layout.ts
* 홈 꼴 고르기 · 묶어 세기(PLAN 547-2) — 순수 함수 · 시험 `test_547_2_home_layout.py`
* 진행 중 1 = one · 2~3 = few · 4~8 = list · 9 넘음 = many · 0 = empty
* 고정 small = list(9 넘음 = many) · large = one(1) / few(2 넘음)
* ========================================================================== */
export type HomeLayout = "empty" | "one" | "few" | "list" | "many";
export type HomeFixed = "auto" | "small" | "large";
export const STALE_DAYS = 14;
export const MANY_FROM = 9;
export function homeLayout(count: number, fixed: HomeFixed = "auto"): HomeLayout {
if (count <= 0) return "empty";
if (fixed === "large") return count === 1 ? "one" : "few";
if (fixed === "small") return count >= MANY_FROM ? "many" : "list";
if (count === 1) return "one";
if (count <= 3) return "few";
return count < MANY_FROM ? "list" : "many";
}
interface StageLike {
stage_index: number;
stage_total: number;
stale_days?: number | null;
}
export const isStale = (card: StageLike): boolean => (card.stale_days ?? 0) >= STALE_DAYS;
/** 단계 분포 — 시작 전(첫 단계) · 설계 중 · 확정 대기(마지막 단계) */
export function stageBuckets(cards: StageLike[]): { start: number; working: number; last: number } {
const out = { start: 0, working: 0, last: 0 };
for (const c of cards) {
if (c.stage_index <= 0) out.start += 1;
else if (c.stage_index >= c.stage_total - 1) out.last += 1;
else out.working += 1;
}
return out;
}
/** 묶어 세기 — 많은 차례 · 같으면 이름 차례 */
export function countBy<T>(
items: T[],
key: (item: T) => string,
): { label: string; count: number }[] {
const counts = new Map<string, number>();
for (const item of items) counts.set(key(item), (counts.get(key(item)) ?? 0) + 1);
return [...counts]
.map(([label, count]) => ({ label, count }))
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
}
/** 앞 limit 개 + 나머지 하나로 묶음(「그 밖 N 곳」) */
export function topWithRest(
rows: { label: string; count: number }[],
limit: number,
): { label: string; count: number; rest?: number }[] {
if (rows.length <= limit) return rows;
const rest = rows.slice(limit - 1);
return [
...rows.slice(0, limit - 1),
{ label: "", count: rest.reduce((sum, r) => sum + r.count, 0), rest: rest.length },
];
}
/** 원 그래프 조각 — 둘레 100 기준 stroke-dasharray · stroke-dashoffset(12 시 시작 · 시계 방향) */
export function donutArcs(values: number[]): { dash: string; offset: number }[] {
const total = values.reduce((sum, v) => sum + v, 0);
if (total <= 0) return [];
let start = 0;
return values.map((v) => {
const len = (v / total) * 100;
const arc = { dash: `${round2(len)} ${round2(100 - len)}`, offset: round2(25 - start) };
start += len;
return arc;
});
}
const round2 = (v: number): number => Math.round(v * 100) / 100;
@@ -0,0 +1,74 @@
/* =============================================================================
* B01_Dashboard_Logic_Pages.ts
* 대시보드 세부 페이지 계산(549-1) — 화면 · fetch 없음 · Node 로 바로 시험.
* 배지 글(역할 · 상태) · 보관함 남은 날 · 가입 요청 수.
* ========================================================================== */
export type BadgeTone = "accent" | "neutral" | "success" | "warning" | "danger";
const DAY_MS = 86_400_000;
/** 역할 → 한글 */
export function roleText(role: string | null | undefined): string {
if (role === "SYSTEM_ADMIN") return "시스템 관리자";
if (role === "ADMIN") return "회사 관리자";
if (role === "USER") return "일반 사용자";
return role ?? "-";
}
/** 상태 → 한글 · 배지 색 (모르는 값은 그대로 · 회색) */
export function statusBadgeOf(status: string | null | undefined): {
text: string;
tone: BadgeTone;
} {
switch ((status ?? "").toUpperCase()) {
case "ACTIVE":
return { text: "활성", tone: "success" };
case "INACTIVE":
return { text: "비활성", tone: "neutral" };
case "PENDING":
return { text: "대기", tone: "warning" };
case "APPROVED":
return { text: "승인", tone: "success" };
case "REJECTED":
return { text: "거절", tone: "danger" };
default:
return { text: status || "-", tone: "neutral" };
}
}
/** 남은 날 — 만료 시각까지 올림 일수 · 지난 것 0 · 만료 없음 null */
export function daysLeft(
expiresAt: string | null | undefined,
now: number = Date.now(),
): number | null {
if (!expiresAt) return null;
const at = new Date(expiresAt).getTime();
if (Number.isNaN(at)) return null;
return Math.max(0, Math.ceil((at - now) / DAY_MS));
}
/** 막대 채움 0~100 — 남은 날 ÷ 보관 기간 */
export function leftPercent(left: number | null, retentionDays: number): number {
if (left === null || retentionDays <= 0) return 0;
return Math.max(0, Math.min(100, Math.round((left / retentionDays) * 100)));
}
/** 7 일 안 만료 = 강조 */
export const EXPIRY_WARN_DAYS = 7;
export const isExpiringSoon = (left: number | null): boolean =>
left !== null && left <= EXPIRY_WARN_DAYS;
/** 가입 요청 중 처리할 것(대기) 수 — 상태 값이 없으면 대기로 봄 */
export function pendingCount(requests: { status?: string | null }[]): number {
return requests.filter((r) => !r.status || r.status.toUpperCase() === "PENDING").length;
}
/** 바이트 → 글 */
export function bytesText(bytes: number): string {
const gb = bytes / 1024 / 1024 / 1024;
if (gb >= 1) return `${gb.toFixed(2)} GB`;
const mb = bytes / 1024 / 1024;
if (mb >= 1) return `${mb.toFixed(1)} MB`;
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
}
@@ -19,23 +19,6 @@
gap: var(--spacing-16);
}
/* select는 공통 .ui-input 상속 + 드롭다운 화살표 여백 확보 */
.b02-proj__select {
appearance: none;
cursor: pointer;
background-image:
linear-gradient(45deg, transparent 50%, var(--color-slate) 50%),
linear-gradient(135deg, var(--color-slate) 50%, transparent 50%);
background-position:
calc(100% - 18px) 50%,
calc(100% - 13px) 50%;
background-size:
5px 5px,
5px 5px;
background-repeat: no-repeat;
padding-right: var(--spacing-32);
}
@media (max-width: 640px) {
.b02-proj__grid {
grid-template-columns: 1fr;
+5 -3
View File
@@ -1,3 +1,5 @@
import { createBanner } from "@ui/ui_template_banner";
export type B03AnalysisStatus = "pending" | "in_progress" | "completed" | "failed";
export interface B03StoredFileState {
@@ -78,9 +80,9 @@ export function restoreB03ProjectState(options: {
const state = loadB03ProjectState(options.projectId);
if (!state) return;
options.container.replaceChildren();
const message = document.createElement("span");
message.textContent = `${options.label}: ${state.analysisProgress}%`;
options.container.append(message);
options.container.append(
createBanner({ tone: "info", text: `${options.label}: ${state.analysisProgress}%` }),
);
options.container.classList.add("is-visible");
if (state.analysisStatus === "in_progress" || state.analysisStatus === "pending") {
void options.poll(options.projectId).then((completed) => {
+68 -57
View File
@@ -5,7 +5,12 @@ import {
UPLOAD_MAX_FILES,
} from "@config/config_frontend";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { createButton, createTag } from "@ui/ui_template_elements";
import { createBanner } from "@ui/ui_template_banner";
import { createButton, createIconButton, createTag } from "@ui/ui_template_elements";
import { createPanelHead } from "@ui/ui_template_panel_head";
import { createProgressBar, type ProgressBarHandle } from "@ui/ui_template_progress";
import { createText } from "@ui/ui_template_text";
import { createToggle } from "@ui/ui_template_toggle";
import { createGeneralLayout } from "@ui/ui_template_general_layout";
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
import { createStepBar, WORKFLOW_STEP_ICONS } from "@ui/ui_template_workflow_layout";
@@ -63,6 +68,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
// 대시보드 임시 보관함에서 가져올 자료 선택기 — 선택되면 [업로드]가 이동을 수행한다.
const tempPicker = createTempPicker(() => updateUploadButton());
const cardMap = new Map<FileSlot, HTMLElement>();
const progressMap = new Map<FileSlot, ProgressBarHandle>();
let uploadButton: HTMLButtonElement;
let resumeBanner: HTMLDivElement;
let activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
@@ -75,9 +81,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
? localStorage.getItem(`b03_las_free_${activeProjectId}`) === "1"
: false;
const subtitle = document.createElement("p");
subtitle.className = "b03-file__subtitle";
subtitle.textContent = L("B03_File_Subtitle");
const subtitle = createText(L("B03_File_Subtitle"), "muted", 14);
subtitle.classList.add("b03-file__subtitle");
const fileInput = document.createElement("input");
fileInput.type = "file";
@@ -94,7 +99,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
dropzone.append(dropzoneLabel, fileInput);
const pageError = document.createElement("p");
pageError.className = "b03-file__error";
pageError.className = "ui-text ui-text--error b03-file__error";
pageError.setAttribute("role", "alert");
// 업로드 버튼 안에서 도는 원 — 공용 스피너를 버튼 크기로 줄여 쓴다.
@@ -200,11 +205,11 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
const fileName = card.querySelector<HTMLSpanElement>(".b03-file__file-name");
const fileSize = card.querySelector<HTMLSpanElement>(".b03-file__file-size");
const progress = card.querySelector<HTMLDivElement>(".b03-file__progress-bar");
const progress = progressMap.get(slot);
const progressBytes = card.querySelector<HTMLSpanElement>(".b03-file__progress-bytes");
const progressSpeed = card.querySelector<HTMLSpanElement>(".b03-file__progress-speed");
const progressEta = card.querySelector<HTMLSpanElement>(".b03-file__progress-eta");
const error = card.querySelector<HTMLDivElement>(".b03-file__error-message");
const error = card.querySelector<HTMLElement>(".b03-file__error-message .ui-banner__text");
const remove = card.querySelector<HTMLButtonElement>(".b03-file__card-remove");
const percent = state.file ? Math.min(100, (state.progressBytes / state.file.size) * 100) : 0;
@@ -217,7 +222,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
? `${state.serverUploaded.sizeMb.toFixed(2)} MB`
: "";
}
if (progress) progress.style.width = `${percent}%`;
progress?.set(percent / 100);
if (progressBytes) {
progressBytes.textContent = `${L("B03_File_Progress_Bytes")}: ${formatBytes(
state.progressBytes,
@@ -413,26 +418,27 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
const lock = overview.analysis_lock;
if (lock?.running) {
const who = lock.name ?? lock.email ?? null;
const busy = document.createElement("p");
busy.className = "b03-file__overview-lock";
busy.textContent = who
? `${who} 님이 올린 자료를 분석하는 중입니다 — 끝난 뒤에 새 자료를 올릴 수 있습니다.`
: "다른 자리에서 올린 자료를 분석하는 중입니다 — 끝난 뒤에 새 자료를 올릴 수 있습니다.";
notes.push(busy);
notes.push(
createBanner({
tone: "warning",
text: who
? `${who} 님이 올린 자료를 분석하는 중입니다 — 끝난 뒤에 새 자료를 올릴 수 있습니다.`
: "다른 자리에서 올린 자료를 분석하는 중입니다 — 끝난 뒤에 새 자료를 올릴 수 있습니다.",
}),
);
}
if (overview.required_complete && overview.analysis_complete) {
const complete = document.createElement("p");
complete.className = "b03-file__overview-complete";
complete.textContent = L("B03_File_Overview_Complete");
notes.push(complete);
notes.push(createBanner({ tone: "success", text: L("B03_File_Overview_Complete") }));
}
for (const session of overview.pending_sessions) {
const pending = document.createElement("p");
pending.className = "b03-file__overview-pending";
pending.textContent =
`${session.original_filename} — ${session.progress_percent}% ` +
L("B03_File_Overview_Pending");
notes.push(pending);
notes.push(
createBanner({
tone: "info",
text:
`${session.original_filename} — ${session.progress_percent}% ` +
L("B03_File_Overview_Pending"),
}),
);
}
if (notes.length) {
overviewBanner.append(...notes);
@@ -453,18 +459,30 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
renderExtensionLabel(card, state);
const input = card.querySelector<HTMLInputElement>(".b03-file__slot-input")!;
input.accept = state.extensions.join(",");
const select = card.querySelector<HTMLButtonElement>(".b03-file__card-select")!;
select.textContent = L("B03_File_Card_Select");
const select = createButton({ label: L("B03_File_Card_Select"), variant: "ghost", size: 24 });
select.classList.add("b03-file__card-select");
card.querySelector('[data-slot="select"]')!.replaceWith(select);
select.addEventListener("click", () => input.click());
input.addEventListener("change", () => {
onFileSelected(input.files ? Array.from(input.files) : [], state.slot);
input.value = "";
});
const remove = card.querySelector<HTMLButtonElement>(".b03-file__card-remove")!;
remove.textContent = "×";
remove.title = L("B03_File_Card_Remove");
remove.setAttribute("aria-label", L("B03_File_Card_Remove"));
remove.addEventListener("click", () => removeFile(state.slot));
const remove = createIconButton({
glyph: "×",
title: L("B03_File_Card_Remove"),
variant: "outline",
size: 24,
onClick: () => removeFile(state.slot),
});
remove.classList.add("b03-file__card-remove");
card.querySelector('[data-slot="remove"]')!.replaceWith(remove);
const progress = createProgressBar({ thick: 6 });
progress.root.classList.add("b03-file__progress");
card.querySelector('[data-slot="progress"]')!.replaceWith(progress.root);
progressMap.set(state.slot, progress);
const errorBanner = createBanner({ tone: "danger", text: "" });
errorBanner.classList.add("b03-file__error-message");
card.querySelector('[data-slot="error"]')!.replaceWith(errorBanner);
cardMap.set(state.slot, card);
return card;
}
@@ -478,18 +496,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
const group = document.createElement("section");
group.className = "b03-file__group";
if (modifier) group.classList.add(modifier);
if (title) {
const groupTitle = document.createElement("h3");
groupTitle.className = "b03-file__group-title";
groupTitle.textContent = title;
group.append(groupTitle);
}
if (hint) {
const groupHint = document.createElement("p");
groupHint.className = "b03-file__group-hint";
groupHint.textContent = hint;
group.append(groupHint);
}
if (title) group.append(createPanelHead({ title, sub: hint }).root);
const content = document.createElement("div");
content.className = "b03-file__group-content";
for (const slot of groupSlots) {
@@ -560,7 +567,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
pickRow.append(dropzone, tempPicker.root, uploadButton);
const uploadControlPanel = document.createElement("div");
uploadControlPanel.className = "b03-file__control-panel";
uploadControlPanel.dataset.pad = "16";
uploadControlPanel.className = "ui-card b03-file__control-panel";
uploadControlPanel.append(subtitle, pickRow, resumeBanner, pageError);
// 자료의 출처가 둘로 갈린다 — 원청이 준 계획노선, 측량이 준 지형(LAS·래스터).
@@ -581,16 +589,17 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
);
// LAS 없는 설계 토글 — 켜면 포인트클라우드 카드를 비활성화하고 필수에서 뺀다.
const lasFreeRow = document.createElement("label");
lasFreeRow.className = "b03-file__lasfree";
const lasFreeToggle = createToggle({
label: L("B03_File_LasFree_Toggle"),
checked: lasFreeDesign,
size: 36,
onChange: onLasFreeChange,
});
const lasFreeRow = lasFreeToggle.root;
lasFreeRow.classList.add("b03-file__lasfree");
lasFreeRow.title = L("B03_File_LasFree_Hint");
const lasFreeCheck = document.createElement("input");
lasFreeCheck.type = "checkbox";
const lasFreeText = document.createElement("span");
lasFreeText.textContent = L("B03_File_LasFree_Toggle");
lasFreeRow.append(lasFreeCheck, lasFreeText);
function applyLasFreeState(): void {
lasFreeCheck.checked = lasFreeDesign;
lasFreeToggle.set(lasFreeDesign);
terrainGroup.classList.toggle("b03-file__group--disabled", lasFreeDesign);
for (const slot of TERRAIN_SLOTS) {
const card = cardMap.get(slot);
@@ -608,8 +617,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
if (lasFreeDesign && slots.get(slot)?.file) removeFile(slot);
}
}
lasFreeCheck.addEventListener("change", () => {
lasFreeDesign = lasFreeCheck.checked;
function onLasFreeChange(checked: boolean): void {
lasFreeDesign = checked;
if (activeProjectId) {
localStorage.setItem(`b03_las_free_${activeProjectId}`, lasFreeDesign ? "1" : "0");
}
@@ -619,19 +628,21 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
// 골라 놓고도 [파일 업로드]가 잠긴 채 남는다(2026-09-03 실측: 파일을 먼저 고르고
// 토글을 나중에 켠 순서에서 재현).
updateUploadButton();
});
}
// LAS 토글은 **파일 입력 컨테이너**의 것이다(2026-09-03 사용자 지시) — 무엇을 받을지
// 정하는 스위치라 고르는 자리 옆에 선다. 켜면 지형 컨테이너가 통째로 잠긴다.
uploadControlPanel.insertBefore(lasFreeRow, resumeBanner);
const routePanel = document.createElement("div");
routePanel.className = "b03-file__control-panel b03-file__cards-container-panel";
routePanel.dataset.pad = "16";
routePanel.className = "ui-card b03-file__control-panel b03-file__cards-container-panel";
routePanel.append(routeGroup);
mountPlanImport(routePanel, uploadControlPanel, () => void applyUploadOverview());
const terrainPanel = document.createElement("div");
terrainPanel.className = "b03-file__control-panel b03-file__cards-container-panel";
terrainPanel.dataset.pad = "16";
terrainPanel.className = "ui-card b03-file__control-panel b03-file__cards-container-panel";
terrainPanel.append(terrainGroup);
const cardsContainer = document.createElement("div");
+10 -3
View File
@@ -8,6 +8,7 @@
* ========================================================================== */
import { CURRENT_PROJECT_ID_KEY, type RoutePath } from "@config/config_frontend";
import { createBanner } from "@ui/ui_template_banner";
import { createButton, showToast } from "@ui/ui_template_elements";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { navigateTo } from "../A00_Common/router";
@@ -81,16 +82,16 @@ export function createUploadFlow(ctx: UploadFlowContext): UploadFlowHandle {
state.uploadSessionId = session.uploadSessionId;
state.progressBytes = session.completedChunks * session.chunkSizeBytes;
ctx.renderSlot(state.slot);
const text = document.createElement("span");
text.textContent = `${L("B03_File_Status_Detected")}: ${session.fileName}`;
const resume = createButton({
label: L("B03_File_Resume_Button"),
variant: "ghost",
size: 24,
onClick: () => void startChunkedUpload([state]),
});
const fresh = createButton({
label: L("B03_File_New_Button"),
variant: "ghost",
size: 24,
onClick: () => {
localStorage.removeItem(session.key);
state.uploadSessionId = undefined;
@@ -100,7 +101,13 @@ export function createUploadFlow(ctx: UploadFlowContext): UploadFlowHandle {
ctx.resumeBanner.classList.remove("is-visible");
},
});
ctx.resumeBanner.append(text, resume, fresh);
ctx.resumeBanner.append(
createBanner({
tone: "info",
text: `${L("B03_File_Status_Detected")}: ${session.fileName}`,
actions: [resume, fresh],
}),
);
ctx.resumeBanner.classList.add("is-visible");
break;
}
+25 -21
View File
@@ -8,6 +8,7 @@
* ========================================================================== */
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { createBanner } from "@ui/ui_template_banner";
import {
createButton,
el,
@@ -17,6 +18,7 @@ import {
showToast,
} from "@ui/ui_template_elements";
import { t } from "@ui/ui_template_locale";
import { createListRow } from "@ui/ui_template_list_row";
import { openModal } from "@ui/ui_template_modal";
interface AssignedRoute {
@@ -99,7 +101,7 @@ export function mountPlanImport(
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
if (!projectId) return;
const lasFree = (): boolean => localStorage.getItem(`b03_las_free_${projectId}`) === "1";
const banner = el("div", { className: "b03-file__overview-pending b03-file__plan-update" });
const banner = el("div", { className: "b03-file__plan-update" });
banner.hidden = true;
bannerHost.prepend(banner);
@@ -117,12 +119,17 @@ export function mountPlanImport(
if (!source?.update_available || source.assignee_id === undefined) return;
const assigneeId = source.assignee_id;
banner.replaceChildren(
el("span", { text: `${t("D04_Update_Banner")} (${source.plan_name} ${source.year}) — ` }),
createButton({
label: t("D04_Update_Reimport"),
variant: "ghost",
size: "sm",
onClick: () => void done(assigneeId),
createBanner({
tone: "info",
text: `${t("D04_Update_Banner")} (${source.plan_name} ${source.year})`,
actions: [
createButton({
label: t("D04_Update_Reimport"),
variant: "ghost",
size: 24,
onClick: () => void done(assigneeId),
}),
],
}),
);
}
@@ -149,20 +156,17 @@ export function mountPlanImport(
? ` · ${t("D04_Linked")}: ${route.linked_project_name}`
: "";
body.append(
el("div", {
className: "b03-file__plan-pick",
children: [
el("span", { text: `${route.project_name} · ${route.year}${linked}` }),
createButton({
label: t("D04_Import_Btn"),
variant: "filled",
size: "sm",
onClick: () => {
close();
void done(route.assignee_id);
},
}),
],
createListRow({
label: `${route.project_name} · ${route.year}${linked}`,
trailing: createButton({
label: t("D04_Import_Btn"),
variant: "filled",
size: 24,
onClick: () => {
close();
void done(route.assignee_id);
},
}),
}),
);
}
+26 -140
View File
@@ -1,10 +1,6 @@
.b03-file {
min-height: calc(100dvh - var(--app-header-height, 0px));
background: linear-gradient(
180deg,
var(--color-canvas, #ffffff) 0%,
var(--color-mist-violet, #edecff) 100%
);
background: linear-gradient(180deg, var(--color-canvas) 0%, var(--color-accent-soft) 100%);
padding: 0 var(--spacing-24) var(--spacing-48) var(--spacing-24); /* 상단 여백은 main-layout 마진으로 조정하므로 padding-top은 0 */
}
@@ -27,31 +23,17 @@
display: flex;
flex-direction: column;
gap: var(--spacing-16);
background: var(--color-canvas, #ffffff);
/* 여백 32 → 16 (2026-09-03) — 고르는 자리만 남기고 안내를 좌측 패널로 뺐다.
`--spacing-20`은 테마에 없는 토큰이라 선언째 무효가 된다(패딩이 통째로 사라짐). */
padding: var(--spacing-16);
border-radius: var(--radius-large, 24px); /* Wiza 24px 대형 둥글기 */
border: 1px solid var(--color-mist, #e6e2e3);
box-shadow: var(--shadow-lg);
}
.b03-file__cards-container-panel {
margin-top: 0; /* 단일 layout gap에 의해 제어되도록 설정 */
}
.b03-file__subtitle {
font-size: var(--text-body-sm, 14px);
color: var(--color-slate, #615e6e);
margin: 0 0 var(--spacing-8) 0;
}
.b03-file__native-input,
.b03-file__slot-input {
display: none;
}
/* Wiza 스타일 가이드의 Lavender Glow 그라데이션 및 soft purple border hover */
/* 고르는 자리 한 줄 — [입력 파일 선택] · [임시 보관함] · [파일 업로드](2026-09-03).
좁아지면 아래로 접히되, 선택 영역이 남는 폭을 먹는다. */
/* 셋을 같은 폭·같은 높이로 나눈다 — 열 1:1:1(2026-09-03 사용자 지시).
@@ -80,9 +62,9 @@
패널로 갔으므로 여기는 「끌어 놓거나 눌러서 고르는 자리」 표시만 한다. */
.b03-file__dropzone {
padding: var(--spacing-12) var(--spacing-16);
border: 1px dashed var(--color-mist, #e6e2e3);
border-radius: var(--radius-cards, 8px);
background: var(--color-paper, #f6f7fa);
border: 1px dashed var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface);
display: flex;
flex-direction: row;
flex-wrap: wrap;
@@ -92,100 +74,54 @@
cursor: pointer;
text-align: center;
transition:
background var(--transition-base, 0.2s),
border-color var(--transition-base, 0.2s),
box-shadow var(--transition-base, 0.2s);
background var(--transition-base),
border-color var(--transition-base),
box-shadow var(--transition-base);
}
.b03-file__dropzone:hover {
background: var(--color-mist-violet, #edecff);
border-color: var(--color-royal-amethyst, #3e0079);
background: var(--color-accent-soft);
border-color: var(--color-accent);
}
.b03-file__dropzone:focus-visible,
.b03-file__dropzone.is-dragging {
outline: none;
background: var(--color-mist-violet, #edecff);
border-color: var(--color-royal-amethyst, #3e0079);
box-shadow: 0 0 0 4px rgba(62, 0, 121, 0.15);
background: var(--color-accent-soft);
border-color: var(--color-accent);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-accent) 15%, transparent);
}
.b03-file__dropzone strong {
color: var(--color-deep-iris, #26114a);
font-size: var(--text-body-sm, 14px);
font-weight: var(--font-weight-medium, 500);
color: var(--color-text);
font-size: var(--text-body-sm);
font-weight: var(--font-weight-medium);
}
.b03-file__dropzone span {
font-size: var(--text-body-sm, 14px);
color: var(--color-slate, #615e6e);
}
/* 빈 오류 줄이 자리를 잡고 있으면 토글 아래가 통째로 비어 보인다 — 글자가 있을 때만
자리를 차지한다(2026-09-03 사용자 지시). */
.b03-file__error {
color: var(--color-danger, #dc2626);
font-size: var(--text-body-sm, 14px);
margin: 0;
font-size: var(--text-body-sm);
color: var(--color-text-secondary);
}
/* 빈 오류 줄이 자리를 잡고 있으면 토글 아래가 통째로 비어 보인다 — 글자가 있을 때만 자리를 차지한다. */
.b03-file__error:empty {
display: none;
}
.b03-file__resume {
display: none;
align-items: center;
gap: var(--spacing-8);
flex-wrap: wrap;
padding: var(--spacing-12) var(--spacing-16);
border: 1px solid var(--color-mist, #e6e2e3);
border-radius: var(--radius-cards, 8px);
background: var(--color-paper, #f6f7fa);
color: var(--color-deep-iris, #26114a);
font-size: var(--text-body-sm, 14px);
margin-top: var(--spacing-8);
}
.b03-file__resume.is-visible {
display: flex;
}
/* 재접속 현황(서버 정본) 배너 — 완료 안내와 중단 세션 이어올리기 안내를 담는다. */
/* 띠 알림(부품)을 담는 자리 — 글이 있을 때(is-visible)만 선다. */
.b03-file__resume,
.b03-file__overview {
display: none;
flex-direction: column;
gap: var(--spacing-4);
margin-top: var(--spacing-8);
padding: var(--spacing-12) var(--spacing-16);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards, 8px);
background: var(--color-surface);
font-size: var(--text-body-sm, 14px);
}
.b03-file__resume.is-visible,
.b03-file__overview.is-visible {
display: flex;
}
.b03-file__overview-complete {
margin: 0;
color: var(--color-success, #3fbb6f);
font-weight: 600;
}
.b03-file__overview-pending {
margin: 0;
color: var(--color-text-secondary);
}
/* 잠금 표시 — 지금 누가 올리는 중인지. 막힌 까닭이라 눈에 띄게 둔다. */
.b03-file__overview-lock {
margin: 0;
color: var(--color-warning, #d98324);
font-weight: 600;
}
/* 완료 슬롯 재업로드 확인 모달 — 페이지 위를 덮는 단순 확인창. */
.b03-file__modal-backdrop {
position: fixed;
@@ -194,7 +130,7 @@
display: flex;
align-items: center;
justify-content: center;
background: rgb(0 0 0 / 45%);
background: var(--color-scrim);
}
.b03-file__modal {
@@ -204,9 +140,9 @@
max-width: 420px;
padding: var(--spacing-24);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards, 8px);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
box-shadow: 0 12px 32px rgb(0 0 0 / 30%);
box-shadow: var(--shadow-lg);
}
.b03-file__modal p {
@@ -221,12 +157,6 @@
justify-content: flex-end;
}
.b03-file__cards-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--spacing-32);
}
/* 계획노선 | 지형(LAS) — 컨테이너를 둘로 나눈다(2026-08-31 사용자 지시).
노선은 카드 5장(shapefile 한 벌), 지형은 4장이라 폭을 5:4 비슷하게 준다.
@@ -241,39 +171,9 @@
align-items: start;
}
/* LAS 없이 설계 토글 — 파일 입력 컨테이너의 스위치(2026-09-03 사용자 지시).
B04 좌측 패널의 체크 줄(`b04-surface__check`)과 같은 서식이다. */
/* LAS 없이 설계 — 켜기 부품(스위치) · 자리만 정함 */
.b03-file__lasfree {
display: flex;
align-items: center;
gap: var(--spacing-8);
align-self: flex-start;
padding: var(--spacing-8) var(--spacing-12);
border: 1px solid var(--color-mist, #e6e2e3);
border-radius: var(--radius-buttons, 8px);
font-size: var(--text-body-sm, 14px);
color: var(--color-deep-iris, #26114a);
cursor: pointer;
transition: border-color var(--transition-base, 0.2s);
}
.b03-file__lasfree:hover {
border-color: var(--color-royal-amethyst, #3e0079);
}
.b03-file__lasfree input {
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--color-royal-amethyst, #3e0079);
cursor: pointer;
}
/* 켜져 있으면 스위치 자신도 켜진 티를 낸다 — 지형 컨테이너가 왜 잠겼는지의 근거. */
.b03-file__lasfree:has(input:checked) {
border-color: var(--color-royal-amethyst, #3e0079);
background: var(--color-mist-violet, #edecff);
font-weight: var(--font-weight-medium, 500);
}
/* LAS 없이 설계를 켜면 지형 컨테이너는 받지 않는다 — 잠긴 것이 보이게 흐린다. */
@@ -282,7 +182,7 @@
}
.b03-file__card--disabled {
background: var(--color-paper, #f6f7fa);
background: var(--color-surface);
border-style: dashed;
}
@@ -324,20 +224,6 @@
gap: var(--spacing-12);
}
.b03-file__group-hint {
font-size: var(--text-body-sm, 14px);
color: var(--color-slate, #615e6e);
margin: calc(var(--spacing-8) * -1) 0 0 0;
}
/* 제목 24px → 본문 크기(2026-09-03) — 컨테이너가 둘뿐이라 큰 제목이 자리만 먹었다. */
.b03-file__group-title {
font-size: var(--text-body, 16px);
color: var(--color-deep-iris, #26114a);
font-family: var(--font-britti-sans, inherit);
margin: 0;
}
/* 카드가 좁아지면 한 줄에 제목·상태·[선택]이 못 들어가 제목이 글자 단위로 접힌다
(2026-09-03 실측: 안내 패널을 연 상태에서 카드 폭 210px). 폭이 모자라면 열을 줄인다. */
/* ⚠ `minmax(260px, …)` 는 **칸이 260px 보다 좁아질 수 없다**는 뜻이라, 컨테이너가
+42 -116
View File
@@ -14,50 +14,34 @@
width: 14px;
height: 14px;
border-width: 2px;
border-color: rgb(255 255 255 / 40%);
border-color: color-mix(in srgb, var(--color-primary-text) 40%, transparent);
border-top-color: var(--color-primary-text);
}
/* Wiza 8px radius 카드 */
/* 카드 한 장 = 파일 한 칸. 빈 칸은 **한 줄**로 끝난다(2026-09-03 사용자 지시) —
파일이 붙거나 올라가는 중에만 아래 내용(파일명·진행바)이 펼쳐진다. */
/* 카드 한 장 = 파일 한 칸(카드 부품) · 빈 칸은 **한 줄**로 끝난다(2026-09-03 사용자 지시) —
파일이 붙거나 올라가는 중에만 아래 내용(파일명·진행바)이 펼쳐진다. 상태는 선 색으로 가른다. */
.b03-file__card {
padding: var(--spacing-12) var(--spacing-16);
border: 1px solid var(--color-mist, #e6e2e3);
border-radius: var(--radius-cards, 8px);
background: var(--color-canvas, #ffffff);
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
gap: var(--spacing-8);
transition:
transform var(--transition-base, 0.2s),
border-color var(--transition-base, 0.2s),
box-shadow var(--transition-base, 0.2s);
}
.b03-file__card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
border-color: var(--color-royal-amethyst, #3e0079);
}
.b03-file__card--selected {
border-color: var(--color-royal-amethyst, #3e0079);
background: var(--color-paper, #f6f7fa);
border-color: var(--color-accent);
background: var(--color-surface);
}
.b03-file__card--uploading {
border-color: var(--color-royal-amethyst, #3e0079);
box-shadow: var(--shadow-lg-2);
border-color: var(--color-accent);
box-shadow: var(--shadow-lg);
}
.b03-file__card--completed {
border-color: #10b981; /* Success Green */
border-color: var(--color-success);
}
.b03-file__card--error {
border-color: var(--color-danger, #dc2626);
border-color: var(--color-danger);
}
/* 카드가 좁아졌다(컨테이너 2열 × 카드 2열, 2026-08-31). 격자로 고정하면 제목이
@@ -83,13 +67,13 @@
flex: 0 0 auto;
width: 28px;
height: 28px;
border-radius: var(--radius-icons, 8px);
border-radius: var(--radius-icons);
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--color-royal-amethyst, #3e0079);
background: var(--color-mist-violet, #edecff);
font-size: var(--text-body-sm, 14px);
color: var(--color-accent);
background: var(--color-accent-soft);
font-size: var(--text-body-sm);
margin-right: var(--spacing-8); /* 아이콘 우측 마진 추가 (아이콘 좌측 여유 확대 효과) */
}
@@ -102,9 +86,9 @@
}
.b03-file__card-label {
color: var(--color-deep-iris, #26114a);
font-size: var(--text-body-sm, 14px);
font-weight: var(--font-weight-medium, 500);
color: var(--color-text);
font-size: var(--text-body-sm);
font-weight: var(--font-weight-medium);
/* 제목은 한 줄로 두고 모자라면 말줄임 — 접히면 카드가 세로로 길어진다(2026-09-03). */
white-space: nowrap;
overflow: hidden;
@@ -114,8 +98,8 @@
.b03-file__card-ext,
.b03-file__file-size,
.b03-file__progress-info {
color: var(--color-slate, #615e6e);
font-size: var(--text-caption, 12px);
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.b03-file__card-badge-container {
@@ -125,28 +109,9 @@
margin-right: var(--spacing-4);
}
.b03-file__card-remove {
width: 24px;
height: 24px;
border: 1px solid var(--color-mist, #e6e2e3);
border-radius: var(--radius-buttons, 8px);
color: var(--color-slate, #615e6e);
background: var(--color-canvas, #ffffff);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-body-sm, 14px);
line-height: 1;
padding: 0;
margin-left: var(--spacing-8); /* 취소 버튼 좌측 여유 추가 (취소 버튼 우측 여유 확보) */
transition: all var(--transition-base, 0.2s);
}
.b03-file__card-remove:hover {
color: var(--color-danger, #dc2626);
border-color: var(--color-danger, #dc2626);
background: #fef2f2;
/* 파일 없는 칸에서는 지움 단추를 접는다(아이콘 단추 부품은 hidden 보다 display 가 앞섬) */
.b03-file__card-remove[hidden] {
display: none;
}
.b03-file__card-content {
@@ -160,24 +125,6 @@
display: none;
}
/* [선택]은 제목 줄 오른쪽 끝의 작은 버튼 — 종전 전폭 40px 버튼이 카드 높이를 키웠다. */
.b03-file__card-select {
flex: 0 0 auto;
min-height: 26px;
padding: 0 var(--spacing-8);
border: 1px solid var(--color-mist, #e6e2e3);
border-radius: var(--radius-buttons, 8px);
color: var(--color-deep-iris, #26114a);
background: var(--color-canvas, #ffffff);
font-weight: var(--font-weight-medium, 500);
font-size: var(--text-caption, 12px);
white-space: nowrap;
cursor: pointer;
transition:
background var(--transition-base, 0.2s),
border-color var(--transition-base, 0.2s);
}
.b03-file__file-info {
display: flex;
flex-direction: column;
@@ -186,9 +133,9 @@
}
.b03-file__file-name {
color: var(--color-deep-iris, #26114a);
font-size: var(--text-body-sm, 14px);
font-weight: var(--font-weight-medium, 500);
color: var(--color-text);
font-size: var(--text-body-sm);
font-weight: var(--font-weight-medium);
overflow-wrap: anywhere;
}
@@ -204,36 +151,15 @@
display: flex;
}
.b03-file__progress-bar-container {
width: 100%;
height: 6px;
overflow: hidden;
border-radius: var(--radius-pills, 1440px);
background: var(--color-mist, #e6e2e3);
}
.b03-file__progress-bar {
width: 0;
height: 100%;
border-radius: var(--radius-pills, 1440px);
background: var(--color-royal-amethyst, #3e0079);
transition: width var(--transition-base, 0.2s);
}
.b03-file__progress-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.b03-file__error-message {
/* 오류 띠(띠 알림 부품)는 오류 난 카드에서만 보인다 */
.b03-file__card .b03-file__error-message {
display: none;
padding: var(--spacing-8) var(--spacing-12);
border-radius: var(--radius-cards, 8px);
color: var(--color-danger, #dc2626);
background: #fef2f2;
font-size: var(--text-caption, 12px);
border: 1px solid rgba(220, 38, 38, 0.15);
}
.b03-file__card--empty .b03-file__file-info,
@@ -260,9 +186,9 @@
width: 100%;
height: 120px;
overflow: hidden;
border: 1px solid var(--color-border, #dcdce6);
border-radius: var(--radius-cards, 8px);
background: var(--color-surface, #fff);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface);
}
.b03-file__preview svg {
@@ -274,7 +200,7 @@
/* 라이다 탑뷰 점 그림 — 점이 5천 개라 선 하나로 묶어 그린다. */
.b03-file__preview-cloud {
fill: none;
stroke: var(--color-text-muted, #5a5a72);
stroke: var(--color-text-muted);
stroke-linecap: round;
stroke-opacity: 0.55;
stroke-width: 0.7;
@@ -286,7 +212,7 @@
.b03-file__preview-thumb {
max-width: 100%;
max-height: 100%;
border: 1px dashed var(--color-border, #9aa);
border: 1px dashed var(--color-border);
image-rendering: pixelated;
object-fit: contain;
}
@@ -302,24 +228,24 @@
}
.b03-file__preview-fact {
color: var(--color-text, #1c1c28);
color: var(--color-text);
font-size: var(--text-body, 15px);
font-weight: 600;
}
.b03-file__preview-fact-sub {
color: var(--color-text-muted, #5a5a72);
font-size: var(--text-caption, 12px);
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.b03-file__preview-empty {
color: var(--color-text-muted, #8a8aa0);
font-size: var(--text-caption, 12px);
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.b03-file__preview-extent {
fill: color-mix(in srgb, var(--color-border, #9aa) 12%, transparent);
stroke: var(--color-border, #9aa);
fill: color-mix(in srgb, var(--color-border) 12%, transparent);
stroke: var(--color-border);
stroke-dasharray: 4 3;
stroke-width: 1;
}
@@ -328,7 +254,7 @@
fill: none;
/* 계획노선 선 색 — 녹색(2026-09-04 사용자 지시). 지형 범위를 벗어나면 아래에서
붉은색으로 덮어써 경고로 쓴다. */
stroke: var(--color-success, #16a34a);
stroke: var(--color-success);
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.5;
@@ -336,11 +262,11 @@
/* 계획노선이 지형 자료 범위를 벗어난 경우 — 눈으로 바로 잡히게 경고색. */
.b03-file__preview.is-warning .b03-file__preview-route {
stroke: var(--color-danger, #d33);
stroke: var(--color-danger);
}
.b03-file__preview.is-warning .b03-file__preview-extent {
stroke: var(--color-danger, #d33);
stroke: var(--color-danger);
stroke-dasharray: 3 2;
}
@@ -355,7 +281,7 @@
}
.b03-file__card--error .b03-file__error-message {
display: block;
display: flex;
}
.b03-file__card--error .b03-file__progress-section {
@@ -68,7 +68,7 @@
.b03-file__temp-option.is-selected {
border-color: var(--color-primary);
background: var(--color-mist-violet);
background: var(--color-accent-soft);
}
.b03-file__temp-option-files {
+5 -7
View File
@@ -227,7 +227,7 @@ export function createFileCardTemplate(): HTMLTemplateElement {
const template = document.createElement("template");
template.id = "file-card-template";
template.innerHTML = `
<article class="b03-file__card b03-file__card--empty">
<article class="ui-card b03-file__card b03-file__card--empty" data-pad="12">
<div class="b03-file__card-header">
<span class="b03-file__card-icon" aria-hidden="true"></span>
<div class="b03-file__card-heading">
@@ -235,8 +235,8 @@ export function createFileCardTemplate(): HTMLTemplateElement {
<span class="b03-file__card-ext"></span>
</div>
<div class="b03-file__card-badge-container"></div>
<button class="b03-file__card-select" type="button"></button>
<button class="b03-file__card-remove" type="button"></button>
<span data-slot="select"></span>
<span data-slot="remove"></span>
</div>
<div class="b03-file__card-content">
<input class="b03-file__slot-input" type="file" />
@@ -246,16 +246,14 @@ export function createFileCardTemplate(): HTMLTemplateElement {
</div>
<div class="b03-file__preview"></div>
<div class="b03-file__progress-section">
<div class="b03-file__progress-bar-container">
<div class="b03-file__progress-bar"></div>
</div>
<span data-slot="progress"></span>
<div class="b03-file__progress-info">
<span class="b03-file__progress-bytes"></span>
<span class="b03-file__progress-speed"></span>
<span class="b03-file__progress-eta"></span>
</div>
</div>
<div class="b03-file__error-message" role="alert"></div>
<span data-slot="error"></span>
</div>
</article>
`;
+1 -1
View File
@@ -468,7 +468,7 @@ export function bindSurfaceViewerTheme(
return;
}
const surfaceRaised = getComputedStyle(document.documentElement)
.getPropertyValue("--color-surface-raised")
.getPropertyValue("--draw-surface-raised")
.trim();
applyBackground(
surfaceRaised && CSS.supports("color", surfaceRaised)
@@ -330,9 +330,9 @@ export function drawStationTicks(
context.translate(-lx, -ly);
}
// 배경을 깔아 등고선 위에서도 읽히게 한다.
context.fillStyle = mc ? themeColor("--color-canvas", "#ffffff") : "rgba(255, 255, 255, 0.78)";
context.fillStyle = mc ? themeColor("--draw-canvas", "#ffffff") : "rgba(255, 255, 255, 0.78)";
context.fillRect(lx - half, ly - 8, width, 16);
context.fillStyle = mc ? themeColor("--color-warning", "#b8860b") : "#222222";
context.fillStyle = mc ? themeColor("--draw-warning", "#b8860b") : "#222222";
context.fillText(label, lx, ly);
context.restore();
}
@@ -118,7 +118,7 @@ export function createRecommendMap(box: HTMLElement): RecommendMap {
context.lineJoin = "round";
const styles: Record<MapLayer["kind"], () => void> = {
expected: () => {
context.strokeStyle = themeColor("--color-text-secondary", "#9ca3af");
context.strokeStyle = themeColor("--draw-text-secondary", "#9ca3af");
context.lineWidth = 1.5;
context.setLineDash([5, 4]);
},
@@ -129,7 +129,7 @@ export function createRecommendMap(box: HTMLElement): RecommendMap {
context.setLineDash([]);
},
preview: () => {
context.strokeStyle = themeColor("--color-primary", "#6d28d9");
context.strokeStyle = themeColor("--draw-primary", "#6d28d9");
context.lineWidth = 3;
context.setLineDash([]);
},
@@ -169,7 +169,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
const style = getComputedStyle(document.documentElement);
const line = scene.plannedLine.length ? scene.plannedLine : scene.planned;
context.clearRect(0, 0, view.width, view.height);
context.fillStyle = style.getPropertyValue("--color-surface") || "#111";
context.fillStyle = style.getPropertyValue("--draw-surface") || "#111";
context.fillRect(0, 0, view.width, view.height);
// 여기서부터 **그림 전체**가 돈다 — 글자도 함께 돈다(CAD 도면과 같은 방식, 사용자 지시 ⑯).
@@ -246,7 +246,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
toScreen,
scene.expected,
[6, 5],
style.getPropertyValue("--color-text-secondary") || "#9ca3af",
style.getPropertyValue("--draw-text-secondary") || "#9ca3af",
1.6,
);
// 곡선 시작·끝점 — 잡아서 직선 각도와 R 을 함께 바꾸는 손잡이(2026-09-07 사용자 지시).
@@ -307,7 +307,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
const bad = (scene.nodeInfo[index]?.violations?.length ?? 0) > 0;
// 고정한 점은 고정 색 — 기준 미달(붉음)이 먼저다.
context.fillStyle = bad
? style.getPropertyValue("--color-danger") || "#dc2626"
? style.getPropertyValue("--draw-danger") || "#dc2626"
: scene.locked[index]
? lockedColor
: style.getPropertyValue("--map-route-node") || "#15803d";
@@ -367,7 +367,7 @@ function drawContourLabels(
style.getPropertyValue("--map-sheet-contour") || "#a5b4fc",
scene.contourStepM,
scene.uprightRad,
style.getPropertyValue("--color-surface") || "#111", // 바탕칠과 같은 색 — 어두움에서 흰 상자 안 생김
style.getPropertyValue("--draw-surface") || "#111", // 바탕칠과 같은 색 — 어두움에서 흰 상자 안 생김
);
if (scene.pickedContour >= 0) drawPickedContourLabel(context, scene, scene.view);
context.restore();
+1 -1
View File
@@ -153,7 +153,7 @@
/* 비정규 측점(구조물)은 그래프에서도 파선 세로선으로 구분해 규칙 측점과 헷갈리지 않게 한다. */
.b05-route-profile .b06-chart__station-line--irregular {
stroke: var(--color-royal-amethyst, rgb(139 92 246));
stroke: var(--draw-accent);
stroke-width: 1.4;
stroke-dasharray: 5 3;
}
@@ -344,7 +344,7 @@
.b05-routeedit__cross-ground {
fill: none;
stroke: var(--color-text-secondary, #94a3b8);
stroke: var(--draw-text-secondary);
stroke-width: 1.6;
vector-effect: non-scaling-stroke;
}
@@ -357,7 +357,7 @@
}
.b05-routeedit__cross-key {
fill: var(--color-text-secondary, #94a3b8);
fill: var(--draw-text-secondary);
font-size: 11px;
}
@@ -503,13 +503,13 @@
.b05-routerec__profile .is-ground {
fill: none;
stroke: var(--color-text-secondary);
stroke: var(--draw-text-secondary);
stroke-width: 1.5;
}
.b05-routerec__profile .is-plan {
fill: none;
stroke: var(--color-primary);
stroke: var(--draw-primary);
stroke-width: 2;
}
+13 -13
View File
@@ -331,7 +331,7 @@
.b05-profile-runmark__line {
fill: none;
stroke: var(--color-danger, #d64545);
stroke: var(--draw-danger);
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
@@ -340,7 +340,7 @@
/* 구간 경계(라운드 시·종점) 세로 표식 — 어디까지 함께 움직이는지 알린다. */
.b05-profile-runmark__edge {
stroke: var(--color-danger, #d64545);
stroke: var(--draw-danger);
stroke-width: 1;
stroke-dasharray: 3 3;
opacity: 0.5;
@@ -356,26 +356,26 @@
}
.b05-profile-freemark__band {
fill: color-mix(in srgb, var(--color-primary) 10%, transparent);
fill: color-mix(in srgb, var(--draw-primary) 10%, transparent);
}
.b05-profile-freemark__edge {
stroke: var(--color-primary);
stroke: var(--draw-primary);
stroke-width: 1.5;
}
/* 범위 「시작–끝 · 길이」 글 — [직선화] · [자유지정] 실시간 띠 위(271-2 · 3). */
.b05-profile-freemark__span {
fill: var(--color-primary);
fill: var(--draw-primary);
font-size: var(--text-caption);
paint-order: stroke;
stroke: var(--color-surface);
stroke: var(--draw-surface);
stroke-width: 3px;
}
/* 지나온 선 · 미리 보기 · 점 = 녹색(367-4) — 범위 띠 · 경계는 보라 그대로. */
.b05-profile-freemark__line {
stroke: var(--color-success);
stroke: var(--draw-success);
stroke-width: 2;
stroke-dasharray: 6 4;
}
@@ -385,25 +385,25 @@
}
.b05-profile-freemark__line.is-over {
stroke: var(--color-danger, #d64545);
stroke: var(--draw-danger);
}
.b05-profile-freemark__dot {
fill: var(--color-surface);
stroke: var(--color-success);
fill: var(--draw-surface);
stroke: var(--draw-success);
stroke-width: 1.5;
}
.b05-profile-freemark__grade {
fill: var(--color-text);
fill: var(--draw-text);
font-size: var(--text-caption);
paint-order: stroke;
stroke: var(--color-surface);
stroke: var(--draw-surface);
stroke-width: 3px;
}
.b05-profile-freemark__grade.is-over {
fill: var(--color-danger, #d64545);
fill: var(--draw-danger);
}
.b05-profile-edit {
+1 -1
View File
@@ -121,7 +121,7 @@ export function createRouteViewer(): RouteViewer {
return;
}
const surfaceRaised = getComputedStyle(document.documentElement)
.getPropertyValue("--color-surface-raised")
.getPropertyValue("--draw-surface-raised")
.trim();
scene.background = new THREE.Color(
surfaceRaised && CSS.supports("color", surfaceRaised)
+6 -6
View File
@@ -205,25 +205,25 @@
.b06-diag__ground {
fill: none;
stroke: var(--color-text-muted);
stroke: var(--draw-text-muted);
stroke-width: 1;
stroke-dasharray: 3 2;
}
.b06-diag__road {
fill: none;
stroke: var(--color-text-body);
stroke: var(--draw-text-body);
stroke-width: 2;
}
.b06-diag__center {
stroke: var(--color-royal-amethyst, rgb(109 40 217));
stroke: var(--draw-accent);
stroke-width: 1;
stroke-dasharray: 4 3;
}
.b06-diag__tick {
stroke: var(--color-text-secondary);
stroke: var(--draw-text-secondary);
stroke-width: 1;
}
@@ -244,12 +244,12 @@
}
.b06-diag__label {
fill: var(--color-text-body);
fill: var(--draw-text-body);
font-size: 14px;
}
.b06-diag__label-sm {
fill: var(--color-text-secondary);
fill: var(--draw-text-secondary);
font-size: 12px;
}
@@ -14,11 +14,11 @@
/* Y축 중심고 상자 색(379-3) — 원지반 = 원지반선 · 계획 = 설계선. */
.b06-chart__center-tag--ground {
--b06-tag: var(--color-chart-1);
--b06-tag: var(--draw-chart-1);
}
.b06-chart__center-tag--design {
--b06-tag: var(--color-royal-amethyst);
--b06-tag: var(--draw-accent);
}
/* 면적 강조 밴드 — 평소엔 클릭 대상으로만 남고, 켜질 때만 채워진다. */
@@ -30,34 +30,34 @@
/* 밴드 색은 위 면적표 글자색과 짝을 맞춘다 — 어느 숫자를 켰는지 색으로 바로 읽히게. */
.b06-chart__area--cut_soil.is-active > polygon {
fill: color-mix(in srgb, var(--color-chart-2) 28%, transparent);
stroke: var(--color-chart-2);
fill: color-mix(in srgb, var(--draw-chart-2) 28%, transparent);
stroke: var(--draw-chart-2);
stroke-width: 1.2;
}
/* 암반 밴드는 하나지만 색은 암종을 따라간다 — 표의 RR/BR 글자색과 같아야 짝이 읽힌다. */
.b06-chart__area--cut_rr.is-active > polygon {
fill: color-mix(in srgb, var(--color-success) 28%, transparent);
stroke: var(--color-success);
fill: color-mix(in srgb, var(--draw-success) 28%, transparent);
stroke: var(--draw-success);
stroke-width: 1.2;
}
.b06-chart__area--cut_br.is-active > polygon {
fill: color-mix(in srgb, var(--color-chart-3) 28%, transparent);
stroke: var(--color-chart-3);
fill: color-mix(in srgb, var(--draw-chart-3) 28%, transparent);
stroke: var(--draw-chart-3);
stroke-width: 1.2;
}
.b06-chart__area--fill.is-active > polygon {
fill: color-mix(in srgb, var(--color-chart-0) 28%, transparent);
stroke: var(--color-chart-0);
fill: color-mix(in srgb, var(--draw-chart-0) 28%, transparent);
stroke: var(--draw-chart-0);
stroke-width: 1.2;
}
/* 횡단 표준단면 설계선 오버레이. 기본은 실선. */
.b06-chart__design-cross {
fill: none;
stroke: var(--color-royal-amethyst);
stroke: var(--draw-accent);
stroke-width: 1.8;
stroke-linejoin: round;
}
@@ -80,7 +80,7 @@
/* 마우스 올림 = 고른 모양(379-1) · 고름은 누를 때만 */
.b06-chart__design-hit:hover,
.b06-chart__design-hit.is-active {
stroke: color-mix(in srgb, var(--color-royal-amethyst) 45%, transparent);
stroke: color-mix(in srgb, var(--draw-accent) 45%, transparent);
stroke-width: 7;
}
@@ -92,7 +92,7 @@
/* 원지반 십자선(413) = 원지반선 색 · 계획 십자선 아래(먼저 그림) */
.b06-chart__ground-center-marker {
stroke: var(--color-chart-1);
stroke: var(--draw-chart-1);
stroke-width: 2.5;
}
@@ -104,8 +104,8 @@
}
.b06-chart__center-hit.is-active {
fill: color-mix(in srgb, var(--color-royal-amethyst) 18%, transparent);
stroke: var(--color-royal-amethyst);
fill: color-mix(in srgb, var(--draw-accent) 18%, transparent);
stroke: var(--draw-accent);
cursor: ew-resize;
}
@@ -117,7 +117,7 @@
.b06-chart__toe-hit:hover,
.b06-chart__toe-hit.is-active {
fill: var(--color-royal-amethyst);
fill: var(--draw-accent);
stroke: transparent;
cursor: pointer;
}
@@ -125,7 +125,7 @@
/* 암 경계선 고름(370-3) — 점선 위 투명 판정 선 · 고르면 경고색 띠 · 세로 끌기. */
.b06-chart__design-hit.b06-chart__rock-hit:hover,
.b06-chart__design-hit.b06-chart__rock-hit.is-active {
stroke: color-mix(in srgb, var(--color-warning) 40%, transparent);
stroke: color-mix(in srgb, var(--draw-warning) 40%, transparent);
}
.b06-chart__design-hit.b06-chart__rock-hit.is-active {
@@ -134,21 +134,21 @@
/* 차도·노견 경계 짧은 수직 틱(N-4-2) — 설계선과 같은 계열, 얇게. */
.b06-chart__carriageway-tick {
stroke: var(--color-royal-amethyst);
stroke: var(--draw-accent);
stroke-width: 1.2;
}
/* 노견 바깥 끝 틱(2026-09-12) — 차도 틱보다 짧고 옅다. 차도 틱과 이 틱 사이가 노견이라,
노면이 넓어졌을 때 차도가 늘었는지 노견이 늘었는지 눈으로 바로 갈린다. */
.b06-chart__shoulder-tick {
stroke: var(--color-royal-amethyst);
stroke: var(--draw-accent);
stroke-width: 1;
opacity: 0.55;
}
/* 노폭 라벨(2026-09-06) — 차도 위 가운데. 확폭이 걸린 측점을 눈으로 가려내는 표기다. */
.b06-chart__carriageway-label {
fill: var(--color-royal-amethyst);
fill: var(--draw-accent);
font-size: 11px;
text-anchor: middle;
pointer-events: none;
@@ -157,7 +157,7 @@
/* 암 경계선(설계선 복사 + 오프셋): 리핑암·발파암 구간 점선 */
.b06-chart__rock-boundary {
fill: none;
stroke: var(--color-warning);
stroke: var(--draw-warning);
stroke-width: 1.6;
stroke-dasharray: 6 4;
stroke-linejoin: round;
@@ -166,31 +166,31 @@
/* 포장층 박스: 노면 양 끝점 기준 두께만큼 하향 채움 */
.b06-chart__pavement {
fill: color-mix(in srgb, var(--color-text-secondary) 30%, transparent);
stroke: var(--color-text-secondary);
fill: color-mix(in srgb, var(--draw-text-secondary) 30%, transparent);
stroke: var(--draw-text-secondary);
stroke-width: 1;
}
/* 고른 노면공의 포장층(152-5) — 범위 카드마다 · 무늬 채움은 inline 이라 무늬 없는 층만 채움이 바뀌고 테두리로 강조 */
.b06-chart__pavement.is-active {
fill: color-mix(in srgb, var(--color-danger) 30%, transparent);
stroke: var(--color-danger);
fill: color-mix(in srgb, var(--draw-danger) 30%, transparent);
stroke: var(--draw-danger);
stroke-width: 2.2;
}
/* 노면공 포장사양 무늬(88): 포장 박스 채움 = 바탕 + 무늬 — 콘크리트 빗금 · 아스콘 점 · 쇄석 돌 */
.b06-chart__pave-bg {
fill: color-mix(in srgb, var(--color-text-secondary) 30%, transparent);
fill: color-mix(in srgb, var(--draw-text-secondary) 30%, transparent);
}
.b06-chart__pave-mark {
fill: none;
stroke: color-mix(in srgb, var(--color-text-body) 70%, transparent);
stroke: color-mix(in srgb, var(--draw-text-body) 70%, transparent);
stroke-width: 1;
}
.b06-chart__pave-mark--dot {
fill: color-mix(in srgb, var(--color-text-body) 70%, transparent);
fill: color-mix(in srgb, var(--draw-text-body) 70%, transparent);
stroke: none;
}
@@ -214,13 +214,13 @@
/* 물넘이포장(287-2 · 287-4): 노면공 포장층(노견까지) + 월류 높이 점선 */
.b06-chart__ford-deck-plan {
fill: none;
stroke: var(--color-text-secondary);
stroke: var(--draw-text-secondary);
stroke-width: 1;
stroke-dasharray: 5 4;
}
.b06-chart__ford-pavement {
stroke: var(--color-text-body);
stroke: var(--draw-text-body);
stroke-width: 1;
}
@@ -303,14 +303,14 @@
}
.b06-chart__culvert-revet.is-active {
fill: color-mix(in srgb, var(--color-danger) 45%, transparent);
stroke: var(--color-danger);
fill: color-mix(in srgb, var(--draw-danger) 45%, transparent);
stroke: var(--draw-danger);
stroke-width: 2.2;
}
/* 흙막이 · 옹벽 덮개(66-5) — 벽 안으로 지나가는 다른 선을 바탕색으로 가림. */
.b06-chart__wall-cover {
fill: var(--color-surface-raised);
fill: var(--draw-surface-raised);
stroke: none;
pointer-events: none;
}
@@ -333,15 +333,15 @@
(2026-08-30 사용자: 선택해도 하이라이트가 없었다). 색은 기슭막이·집수정 강조와
같은 계열이되, 면이 넓어 채도를 낮춘다. */
.b06-chart__box-hit.is-active {
fill: color-mix(in srgb, var(--color-danger) 16%, transparent);
stroke: var(--color-danger);
fill: color-mix(in srgb, var(--draw-danger) 16%, transparent);
stroke: var(--draw-danger);
stroke-width: 1.6;
stroke-dasharray: 4 3;
}
.b06-chart__culvert-apron {
fill: color-mix(in srgb, var(--color-text-secondary) 22%, transparent);
stroke: var(--color-text-secondary);
fill: color-mix(in srgb, var(--draw-text-secondary) 22%, transparent);
stroke: var(--draw-text-secondary);
stroke-width: 1;
stroke-dasharray: 3 2; /* 지반 아래 매설부 — 점선으로 구분 */
}
@@ -349,7 +349,7 @@
.b06-chart__culvert-label {
font-size: 9px;
font-weight: var(--font-weight-medium);
fill: var(--color-text-secondary);
fill: var(--draw-text-secondary);
}
/* 관 정보 글(관매설 · 세월교 · 267-1) — 노폭 라벨(`.b06-chart__carriageway-label`)과 같은 글 크기 */
@@ -359,7 +359,7 @@
/* 유입 보호공 공간 확보 절토선 — 말단에서 표준단면 절토경사로 지반까지 */
.b06-chart__culvert-cut {
stroke: var(--color-warning);
stroke: var(--draw-warning);
stroke-width: 1.4;
stroke-dasharray: 5 3;
}
@@ -374,7 +374,7 @@
/* 옹벽 물빼기 파이프 단면(73-2) — 두 줄 평행선 · 밝음 · 어두움 토큰을 따름. */
.b06-chart__wall-drain line {
stroke: var(--color-text-secondary);
stroke: var(--draw-text-secondary);
stroke-width: 1.2;
}
@@ -388,8 +388,8 @@
/* 집수정 선택 강조(2026-08-22 사용자) — 기슭막이와 같은 규칙. */
.b06-chart__culvert-basin.is-active {
fill: color-mix(in srgb, var(--color-danger) 45%, transparent);
stroke: var(--color-danger);
fill: color-mix(in srgb, var(--draw-danger) 45%, transparent);
stroke: var(--draw-danger);
stroke-width: 2.2;
}
@@ -404,8 +404,8 @@
/* 도수로 · 돌붙임 띠 고름(152-4) — 관 고름과 같은 강조 · 판정면은 굵은 투명 테두리로 여유 */
.b06-chart__culvert-pitching.is-active {
fill: color-mix(in srgb, var(--color-danger) 30%, transparent);
stroke: var(--color-danger);
fill: color-mix(in srgb, var(--draw-danger) 30%, transparent);
stroke: var(--draw-danger);
stroke-width: 2.2;
stroke-dasharray: none;
}
@@ -452,8 +452,8 @@
/* 세월교 구체(2026-08-25) — 바닥판은 콘크리트. 측벽은 ㄴ형 집수정 형상을 그대로
쓰므로 `.b06-chart__culvert-basin` 색을 공유한다. */
.b06-chart__ford-slab {
fill: color-mix(in srgb, var(--color-text-secondary) 30%, transparent);
stroke: var(--color-text-secondary);
fill: color-mix(in srgb, var(--draw-text-secondary) 30%, transparent);
stroke: var(--draw-text-secondary);
stroke-width: 1.2;
stroke-linejoin: round;
}
@@ -461,15 +461,15 @@
/* 세월교 본체(바닥판 · 관) 선택 강조(66-1) — 측벽 강조와 같은 색. */
.b06-chart__ford-slab.is-active,
.b06-chart__culvert-pipe.is-active {
fill: color-mix(in srgb, var(--color-danger) 30%, transparent);
stroke: var(--color-danger);
fill: color-mix(in srgb, var(--draw-danger) 30%, transparent);
stroke: var(--draw-danger);
stroke-width: 2.2;
}
/* BOX암거 내공(유로) — 면은 비우고 윤곽만. 물이 지나는 빈 공간이라 채우지 않는다. */
.b06-chart__box-barrel {
fill: none;
stroke: var(--color-text-secondary);
stroke: var(--draw-text-secondary);
stroke-width: 1;
stroke-dasharray: 4 3;
stroke-linejoin: round;
@@ -1,6 +1,6 @@
/* SVG 차트 색상은 테마 토큰만 사용한다. */
.b06-chart__bg {
fill: var(--color-surface-raised);
fill: var(--draw-surface-raised);
}
/* 세로 창을 변환으로 옮기는 겹(2026-09-04) — 세로로 눌리거나 늘어나도 선 굵기·점선 간격은
@@ -10,19 +10,19 @@
}
.b06-chart__grid {
stroke: var(--color-border);
stroke: var(--draw-grid);
stroke-width: 1;
}
.b06-chart__axis {
stroke: var(--color-text-secondary);
stroke: var(--draw-text-secondary);
stroke-width: 1;
}
.b06-chart__tick,
.b06-chart__station-label,
.b06-chart__axis-label {
fill: var(--color-text-secondary);
fill: var(--draw-text-secondary);
font-family: var(--font-body);
}
@@ -35,7 +35,7 @@
.b06-chart__station-label--mc {
font-size: 12px;
font-weight: var(--font-weight-bold);
fill: var(--color-warning);
fill: var(--draw-warning);
}
.b06-station--mc {
@@ -47,7 +47,7 @@
/* 중심고 Y축 라벨(318-2) — 눈금과 같은 10px · 둥근 상자 · 축선 앞에서 멈춤 ·
바탕 = 불투명 섞음(겹친 눈금 글을 위에서 가림 · 319-1) · 색 = 원지반선 · 설계선(379-3 · `--b06-tag` = _Cross_Areas.css) */
.b06-chart__center-box {
fill: color-mix(in srgb, var(--b06-tag) 14%, var(--color-surface-raised));
fill: color-mix(in srgb, var(--b06-tag) 14%, var(--draw-surface-raised));
stroke: var(--b06-tag);
stroke-width: 1;
}
@@ -60,7 +60,7 @@
/* 겹쳐 위 단으로 올린 측점 이름(407-4) — 선 · 지반선 위라 바탕색 테두리로 읽히게. */
.b06-chart__station-label--lifted {
paint-order: stroke;
stroke: var(--color-surface-raised);
stroke: var(--draw-surface-raised);
stroke-width: 3px;
stroke-linejoin: round;
pointer-events: none;
@@ -74,14 +74,14 @@
}
.b06-chart__axis-label {
fill: var(--color-text-body);
fill: var(--draw-text-body);
font-size: var(--text-caption);
font-weight: var(--font-weight-medium);
}
.b06-chart__profile {
fill: none;
stroke: var(--color-chart-0);
stroke: var(--draw-chart-0);
stroke-width: 2.5;
}
@@ -93,14 +93,14 @@
.b06-chart__cross-profile {
fill: none;
stroke: var(--color-chart-1);
stroke: var(--draw-chart-1);
stroke-width: 2.4;
}
/* 종단 계획선(시공계획고): 지반선과 구분되도록 파선 + 강조색 */
.b06-chart__design-profile {
fill: none;
stroke: var(--color-royal-amethyst);
stroke: var(--draw-accent);
stroke-width: 2.2;
stroke-dasharray: 8 4;
}
@@ -119,7 +119,7 @@
}
.b06-chart__balance-boundary {
stroke: var(--color-royal-amethyst);
stroke: var(--draw-accent);
stroke-width: 1.2;
stroke-dasharray: 3 3;
opacity: 0.55;
@@ -148,42 +148,42 @@
.b06-chart__station-line--bp,
.b06-chart__station-line--regular,
.b06-chart__station-line--curve {
stroke: var(--color-warning);
stroke: var(--draw-warning);
}
.b06-chart__station-line--ep {
stroke: var(--color-accent);
stroke: var(--draw-accent);
stroke-dasharray: 4 3;
}
/* 비정규 측점(구조물)은 종단면도에서도 자수정색 파선으로 구분한다(B05 종단 규칙과 정합).
미선택 시 stroke 미정의로 세로선이 안 그려지던 문제 해소. */
.b06-chart__station-line--irregular {
stroke: var(--color-royal-amethyst, rgb(139 92 246));
stroke: var(--draw-accent);
stroke-width: 1.4;
stroke-dasharray: 5 3;
}
/* 추가 측점(368-3) = 일반측점 색 점선 */
.b06-chart__station-line--plus {
stroke: var(--color-warning);
stroke: var(--draw-warning);
stroke-dasharray: 5 3;
}
.b06-chart__station-line--selected,
.b06-chart__center-marker {
stroke: var(--color-danger);
stroke: var(--draw-danger);
stroke-width: 2.5;
}
/* 십자선이 계획 노선(계획고) 위치를 가리킬 때: 종단 계획선과 동일 색으로 대응시킨다 */
.b06-chart__center-marker--design {
stroke: var(--color-royal-amethyst);
stroke: var(--draw-accent);
}
.b06-chart__station--selected .b06-chart__station-label,
.b06-chart__station-label--selected {
fill: var(--color-danger);
fill: var(--draw-danger);
font-weight: var(--font-weight-bold);
}
@@ -122,7 +122,7 @@
.b06-detail__pick-outline path {
fill: none;
stroke: var(--color-danger);
stroke: var(--draw-danger);
stroke-width: 2;
stroke-linecap: square;
}
@@ -144,19 +144,19 @@
/* 그림 강조 한 벌 = 붉은 계통(164-2 · 카드 색은 그대로) — 띠 · 판정 선 · 중심 십자 · 사토장 */
.b06-detail__cad .b06-chart__area.is-active > polygon {
fill: color-mix(in srgb, var(--color-danger) 35%, transparent);
stroke: var(--color-danger);
fill: color-mix(in srgb, var(--draw-danger) 35%, transparent);
stroke: var(--draw-danger);
stroke-width: 2;
}
.b06-detail__cad .b06-chart__design-hit.is-active {
stroke: color-mix(in srgb, var(--color-danger) 70%, transparent);
stroke: color-mix(in srgb, var(--draw-danger) 70%, transparent);
stroke-width: 9;
}
.b06-detail__cad .b06-chart__ground-center.is-active line,
.b06-detail__cad .b06-chart__center.is-active line {
stroke: var(--color-danger);
stroke: var(--draw-danger);
stroke-width: 3;
}
@@ -206,11 +206,11 @@
/* 구조물 선 묶음(295-3) — 나뉜 조각 위 한 줄 · 걸치면 선 하나로 비침 */
.b06-detail__piece:hover,
.b06-detail__piece-group:hover {
stroke: color-mix(in srgb, var(--color-accent) 25%, transparent);
stroke: color-mix(in srgb, var(--draw-accent) 25%, transparent);
}
.b06-detail__piece.is-selected {
stroke: color-mix(in srgb, var(--color-danger) 55%, transparent);
stroke: color-mix(in srgb, var(--draw-danger) 55%, transparent);
stroke-width: 6;
}
@@ -223,11 +223,11 @@
}
.b06-detail__face:hover {
fill: color-mix(in srgb, var(--color-accent) 8%, transparent);
fill: color-mix(in srgb, var(--draw-accent) 8%, transparent);
}
.b06-detail__face.is-selected {
fill: color-mix(in srgb, var(--color-danger) 22%, transparent);
fill: color-mix(in srgb, var(--draw-danger) 22%, transparent);
}
/* 고른 것 값(카드 179-1 꼴) · [칸에 넣기] — [+ − ⤢] 왼쪽 */
+1 -1
View File
@@ -28,7 +28,7 @@
--cad-text-muted: var(--color-text-muted);
--cad-accent: var(--color-accent);
--cad-hover: var(--color-mist-violet);
--cad-canvas: var(--color-canvas);
--cad-canvas: var(--draw-canvas);
}
* {
@@ -339,7 +339,7 @@ export class ScreenCanvasDrawController implements DrawController {
this.context.fillStyle = themeColor('--cad-canvas', CANVAS_BACKGROUND_FALLBACK);
this.context.fillRect(0, 0, this.canvasSize?.x, this.canvasSize?.y);
if (getGridEnabled()) {
this.context.strokeStyle = themeColor('--color-mist', '#242b35');
this.context.strokeStyle = themeColor('--draw-grid', '#242b35');
this.context.lineWidth = 1;
this.context.setLineDash([]);
this.context.beginPath();
+4 -4
View File
@@ -179,22 +179,22 @@
.b08-hydro__pipe text {
font-size: var(--text-caption);
fill: var(--color-text-secondary);
fill: var(--draw-text-secondary);
}
.b08-hydro__water {
fill: color-mix(in srgb, var(--color-chart-0) 30%, transparent);
fill: color-mix(in srgb, var(--draw-chart-0) 30%, transparent);
}
.b08-hydro__wall {
fill: none;
stroke: var(--color-text-body);
stroke: var(--draw-text-body);
stroke-width: 2;
}
.b08-hydro__line {
fill: none;
stroke: var(--color-text-secondary);
stroke: var(--draw-text-secondary);
stroke-width: 1;
}
+20 -20
View File
@@ -81,41 +81,41 @@
}
.b08-milestone__line {
stroke: var(--color-text-secondary);
stroke: var(--draw-text-secondary);
stroke-width: 1.5;
}
.b08-milestone__box rect {
fill: var(--color-surface);
stroke: var(--color-text-secondary);
fill: var(--draw-surface);
stroke: var(--draw-text-secondary);
stroke-width: 1.2;
}
.b08-milestone__box text {
fill: var(--color-text);
fill: var(--draw-text);
}
/* 153 상자 색 — 공사지(시점 · 중점) = 강조색 · 기준점 = 경고색 · 거래처 = 성공색 · 옅은 채움 + 테두리(테마 변수 · 밝음 · 어두움 같이) */
.b08-milestone__box.is-site rect {
fill: color-mix(in srgb, var(--color-accent) 7%, var(--color-surface));
stroke: var(--color-accent);
fill: color-mix(in srgb, var(--draw-accent) 7%, var(--draw-surface));
stroke: var(--draw-accent);
stroke-dasharray: 4 3;
}
.b08-milestone__box.is-start rect,
.b08-milestone__box.is-mid rect {
fill: color-mix(in srgb, var(--color-accent) 16%, var(--color-surface));
stroke: var(--color-accent);
fill: color-mix(in srgb, var(--draw-accent) 16%, var(--draw-surface));
stroke: var(--draw-accent);
}
.b08-milestone__box.is-base rect {
fill: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface));
stroke: var(--color-warning);
fill: color-mix(in srgb, var(--draw-warning) 18%, var(--draw-surface));
stroke: var(--draw-warning);
}
.b08-milestone__box.is-route rect {
fill: color-mix(in srgb, var(--color-success) 14%, var(--color-surface));
stroke: var(--color-success);
fill: color-mix(in srgb, var(--draw-success) 14%, var(--draw-surface));
stroke: var(--draw-success);
}
.b08-milestone__box.is-route,
@@ -125,15 +125,15 @@
.b08-milestone__box.is-route:hover rect,
.b08-milestone__box.is-route:focus-visible rect {
stroke: var(--color-accent);
stroke: var(--draw-accent);
stroke-width: 2;
}
.b08-milestone__km {
fill: var(--color-text);
fill: var(--draw-text);
font-size: 12px;
paint-order: stroke;
stroke: var(--color-surface);
stroke: var(--draw-surface);
stroke-width: 4px;
stroke-linejoin: round;
}
@@ -173,13 +173,13 @@
/* 거래처 상자 윗줄 = 항목(244-6) · 글 크기 = 거래처 이름(254) */
.b08-milestone__items {
fill: var(--color-text-secondary);
fill: var(--draw-text-secondary);
font-size: 13px;
}
/* 상자 작은 글씨 = 주소 · 좌표(244-12) */
.b08-milestone__note {
fill: var(--color-text-secondary);
fill: var(--draw-text-secondary);
font-size: 10px;
}
@@ -228,8 +228,8 @@
/* 154 연결 점 ● — 거래처 · 기준점 상자 왼쪽 위 안쪽(254) · 끌어 기준점 · 공사지에 놓으면 연결 */
.b08-milestone__connect {
fill: var(--color-success);
stroke: var(--color-surface);
fill: var(--draw-success);
stroke: var(--draw-surface);
stroke-width: 2;
cursor: crosshair;
opacity: 0.85;
@@ -240,7 +240,7 @@
}
.b08-milestone__wire {
stroke: var(--color-success);
stroke: var(--draw-success);
stroke-width: 2;
stroke-dasharray: 6 4;
pointer-events: none;
+5 -5
View File
@@ -1,4 +1,6 @@
import { createButton, createTag } from "@ui/ui_template_elements";
import { createBadge } from "@ui/ui_template_badge";
import { createBanner } from "@ui/ui_template_banner";
import { createButton } from "@ui/ui_template_elements";
import { section, table, text } from "@ui/ui_template_general_blocks";
import { createGeneralLayout } from "@ui/ui_template_general_layout";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
@@ -13,15 +15,13 @@ export function renderB10Payment(root: HTMLElement): void {
invoiceBody.className = "b10-payment__summary";
const invoiceDescription = document.createElement("p");
invoiceDescription.textContent = L("B10_Payment_Invoice_Description");
invoiceBody.append(invoiceDescription, createTag(L("B10_Payment_Invoice_Status"), "neutral"));
invoiceBody.append(invoiceDescription, createBadge(L("B10_Payment_Invoice_Status"), "neutral"));
const invoiceSection = section(L("B10_Payment_Invoice_Title"), invoiceBody, true, [
createButton({ label: L("B10_Payment_Invoice_Request"), disabled: true }),
]);
const depositNote = document.createElement("p");
depositNote.className = "b10-payment__note";
depositNote.textContent = L("B10_Payment_Deposit_Note");
const depositNote = createBanner({ tone: "info", text: L("B10_Payment_Deposit_Note") });
const depositBody = document.createElement("div");
depositBody.className = "b10-payment__summary";
depositBody.append(
-8
View File
@@ -4,11 +4,3 @@
gap: var(--spacing-16);
color: var(--color-text-body);
}
.b10-payment__note {
padding: var(--spacing-16);
border-radius: var(--radius-cards);
background: var(--color-surface);
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
+10 -5
View File
@@ -1,4 +1,5 @@
import { createButton, createTag } from "@ui/ui_template_elements";
import { createBadge } from "@ui/ui_template_badge";
import { createButton, createCard } from "@ui/ui_template_elements";
import { section } from "@ui/ui_template_general_blocks";
import { createGeneralLayout } from "@ui/ui_template_general_layout";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
@@ -9,8 +10,9 @@ function L(key: keyof typeof ui_locales): string {
}
function buildStatusFlow(): HTMLElement {
const flow = document.createElement("ol");
const flow = document.createElement("div");
flow.className = "b11-status__flow";
flow.setAttribute("role", "list");
const steps = [
L("B11_Status_Step_Request"),
L("B11_Status_Step_Issue"),
@@ -18,11 +20,14 @@ function buildStatusFlow(): HTMLElement {
L("B11_Status_Step_Complete"),
];
for (const label of steps) {
const item = document.createElement("li");
item.className = "b11-status__step";
const title = document.createElement("strong");
title.textContent = label;
item.append(title, createTag(L("B11_Status_NotStarted"), "neutral"));
const item = createCard({
body: [title, createBadge(L("B11_Status_NotStarted"), "neutral")],
pad: 16,
});
item.classList.add("b11-status__step");
item.setAttribute("role", "listitem");
flow.append(item);
}
return flow;
+1 -11
View File
@@ -2,20 +2,10 @@
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--spacing-16);
margin: 0;
padding: 0;
list-style: none;
}
.b11-status__step {
display: flex;
flex-direction: column;
.b11-status__step > .ui-card__body {
gap: var(--spacing-12, var(--spacing-8));
padding: var(--spacing-16);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface);
color: var(--color-text-body);
}
.b11-status__download {
+2 -2
View File
@@ -17,6 +17,7 @@ import {
} from "@ui/ui_template_elements";
import { createGeneralLayout } from "@ui/ui_template_general_layout";
import { createHelpButton } from "@ui/ui_template_help";
import { createText } from "@ui/ui_template_text";
import { t } from "@ui/ui_template_locale";
import { stationLabel } from "../common_util/common_util_station";
import { createGroupProject } from "../A00_Common/group_workflow";
@@ -70,8 +71,7 @@ export function renderD01PlanRegister(root: HTMLElement): void {
const endM = createInputField({ label: t("DE_Reg_EndM"), type: "number", min: 0 });
// 누가거리 칸 옆 즉시 측점 표기(저장값 · 서버 검사는 m 그대로)
for (const field of [startM, endM]) {
const shown = document.createElement("span");
shown.style.cssText = "font-size:12px;color:var(--color-text-muted)";
const shown = createText("", "muted");
shown.dataset.station = "";
field.root.insertBefore(shown, field.root.lastElementChild);
field.input.addEventListener("input", () => {
+16 -12
View File
@@ -11,12 +11,14 @@ import "./D02_PlanBase_UI_Style.css";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import {
createButton,
createInputField,
el,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { createPanelCard } from "@ui/ui_template_collapsible";
import { createText } from "@ui/ui_template_text";
import { editMap, type MapPin } from "@ui/ui_template_kakao_map";
import { t } from "@ui/ui_template_locale";
import { mountGroupStep } from "../A00_Common/group_workflow";
@@ -66,9 +68,11 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
const mapHost = el("div", { className: "d02-map" });
const table = el("div", { className: "d02-points" });
const info = el("p", { className: "d02-info" });
const info = createText("", "muted");
info.classList.add("d02-info");
const card = createPanelCard(t("D02_Points_Title"), false);
const pointsHint = el("p", { className: "d02-info", text: t("D02_Points_Hint") });
const pointsHint = createText(t("D02_Points_Hint"), "muted");
pointsHint.classList.add("d02-info");
card.append(pointsHint, table, info);
const save = createButton({
label: t("D02_Save"),
@@ -139,12 +143,11 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
function drawTable(): void {
const rows = points.map((p, i) => {
const lon = el("input", { className: "d02-points__input" }) as HTMLInputElement;
const lat = el("input", { className: "d02-points__input" }) as HTMLInputElement;
lon.type = lat.type = "number";
const lon = createInputField({ type: "number", placeholder: t("D02_Lon"), size: 24 }).input;
const lat = createInputField({ type: "number", placeholder: t("D02_Lat"), size: 24 }).input;
lon.classList.add("d02-points__input");
lat.classList.add("d02-points__input");
lon.step = lat.step = "any";
lon.placeholder = t("D02_Lon");
lat.placeholder = t("D02_Lat");
lon.value = p ? String(p.lon) : "";
lat.value = p ? String(p.lat) : "";
lon.disabled = lat.disabled = !editable;
@@ -154,8 +157,11 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
const typed = (): void => {
const at = { lon: Number.parseFloat(lon.value), lat: Number.parseFloat(lat.value) };
const ok = at.lon >= 120 && at.lon <= 135 && at.lat >= 30 && at.lat <= 45;
lon.classList.toggle("is-invalid", !ok);
lat.classList.toggle("is-invalid", !ok);
for (const box of [lon, lat]) {
box.classList.toggle("is-error", !ok);
if (ok) box.removeAttribute("aria-invalid");
else box.setAttribute("aria-invalid", "true");
}
if (ok) place(i, at);
};
lon.addEventListener("change", typed);
@@ -254,8 +260,6 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
active = firstEmpty();
paint();
} catch (error) {
table.replaceChildren(
el("p", { className: "d02-info", text: error instanceof Error ? error.message : "" }),
);
table.replaceChildren(createText(error instanceof Error ? error.message : "", "muted"));
}
}
-12
View File
@@ -35,17 +35,5 @@
}
.d02-points__input {
width: 100%;
min-width: 0;
font-size: var(--text-caption);
}
.d02-points__input.is-invalid {
outline: 1px solid var(--color-danger, #d92d20);
}
.d02-info {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
+13 -9
View File
@@ -7,6 +7,8 @@
* ========================================================================== */
import "../B05_Profile/B05_Profile_UI_Style.css";
import { createCheckbox } from "@ui/ui_template_checkbox";
import { createSelectField } from "@ui/ui_template_input";
import { openModal } from "@ui/ui_template_modal";
import { createProgressCircle } from "@ui/ui_template_progress";
import { showToast } from "@ui/ui_template_elements";
@@ -51,17 +53,19 @@ function gridBounds(grid: DtmGrid): {
export function openPlan3d(projectId: string, years: Plan3dYear[], firstYear: number): void {
if (!years.length) return void showToast("3D 로 볼 연도 노선이 없습니다.", "warning");
let viewer: RouteViewer | null = createRouteViewer();
const pick = document.createElement("select");
pick.className = "d03-plan__3d-year";
for (const item of years)
pick.append(new Option(`${item.year}년`, String(item.year), false, item.year === firstYear));
const corridorBox = document.createElement("label");
corridorBox.className = "d03-plan__3d-corridor";
const corridorOn = Object.assign(document.createElement("input"), {
type: "checkbox",
const pick = createSelectField({
options: years.map((item) => ({ value: String(item.year), text: `${item.year}년` })),
value: String(firstYear),
size: 28,
ariaLabel: "연도",
}).select;
pick.classList.add("d03-plan__3d-year");
const corridor = createCheckbox(" 예상 코리도(표준 횡단 기본값 · 계획고 = 지반고)", {
checked: true,
});
corridorBox.append(corridorOn, " 예상 코리도(표준 횡단 기본값 · 계획고 = 지반고)");
const corridorBox = corridor.root;
corridorBox.classList.add("d03-plan__3d-corridor");
const corridorOn = corridor.input;
corridorOn.addEventListener("change", () => viewer?.setCorridorVisible(corridorOn.checked));
const top = document.createElement("div");
top.className = "d03-plan__3d-top";
@@ -8,6 +8,7 @@
import "../B05_Profile/B05_Profile_UI_Style_RouteEdit.css";
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createText } from "@ui/ui_template_text";
import { stationLabel } from "../common_util/common_util_station";
import type { DtmGrid } from "../common_util/common_util_surface_sampler";
import { buildCrossSvg } from "../B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw";
@@ -63,7 +64,7 @@ export function createPlanCross(): PlanCross {
const clear = (text: string): void => {
head.textContent = "";
body.replaceChildren(Object.assign(document.createElement("p"), { textContent: text }));
body.replaceChildren(createText(text, "muted", 14));
delete root.dataset.chainage;
};
clear("평면에서 측점을 누르면 그 자리 지반선 · 예상 횡단이 보입니다.");

Some files were not shown because too many files have changed in this diff Show More