Compare commits
76
Commits
75bb82d03f
...
022eff69f5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
022eff69f5 | ||
|
|
6d8b38b518 | ||
|
|
6008ba654e | ||
|
|
f54eed25bc | ||
|
|
25adc2f0e7 | ||
|
|
47b396b53d | ||
|
|
a826804277 | ||
|
|
065ca8b983 | ||
|
|
9729892a3d | ||
|
|
2c51bb53a6 | ||
|
|
66dcbdad17 | ||
|
|
a3365946fd | ||
|
|
8cf6a7b626 | ||
|
|
ed6554a977 | ||
|
|
c54afddfe8 | ||
|
|
894ea5059e | ||
|
|
6271164dd5 | ||
|
|
7e584c1e7b | ||
|
|
f941ddc3f5 | ||
|
|
3ccb96fbdb | ||
|
|
e673714748 | ||
|
|
aeb6bc498f | ||
|
|
edd06e4b51 | ||
|
|
4348a47100 | ||
|
|
c03228778b | ||
|
|
f6643fb946 | ||
|
|
7e07da4b31 | ||
|
|
aaac17db4c | ||
|
|
85ae6319e4 | ||
|
|
8721bd7b18 | ||
|
|
41973e9874 | ||
|
|
c914702a84 | ||
|
|
efb9b9ca67 | ||
|
|
66f5858e54 | ||
|
|
43d0ccb63f | ||
|
|
bbedceeae1 | ||
|
|
adc64ddcf3 | ||
|
|
a924f466bf | ||
|
|
7e34152de8 | ||
|
|
bed7957a65 | ||
|
|
c5518a8783 | ||
|
|
729f4c75b8 | ||
|
|
70786cef9e | ||
|
|
9826538c74 | ||
|
|
13b417120c | ||
|
|
95a2bed75b | ||
|
|
bd1a080afc | ||
|
|
0ab85ec3b3 | ||
|
|
2002affddf | ||
|
|
6b0d509077 | ||
|
|
5aaf7f4e65 | ||
|
|
93e99872e4 | ||
|
|
070791854c | ||
|
|
3274d4b107 | ||
|
|
bc1c8b44e8 | ||
|
|
7190bf2cfe | ||
|
|
3c69fb96ca | ||
|
|
e505f4014d | ||
|
|
ffa5d250e8 | ||
|
|
c6d93ee880 | ||
|
|
8b1b286e04 | ||
|
|
392cb92987 | ||
|
|
5e6e0b974e | ||
|
|
49ef681bf6 | ||
|
|
6c56fddfdd | ||
|
|
b14c170b8e | ||
|
|
3deacf48ff | ||
|
|
57e6573070 | ||
|
|
97dcc33fe5 | ||
|
|
6080faf0cf | ||
|
|
69783b58f5 | ||
|
|
cb119ef8ea | ||
|
|
2243836515 | ||
|
|
9c8da7fcdf | ||
|
|
6708481275 | ||
|
|
539df9443c |
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 사용자 확정).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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] }),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: "표시할 설정이 없음" }),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 보다 좁아질 수 없다**는 뜻이라, 컨테이너가
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 꼴) · [칸에 넣기] — [+ − ⤢] 왼쪽 */
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user