EENE Dashboard upload to Gitea

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
EENE Dashboard
2026-06-17 16:59:34 +09:00
parent cf72281c6d
commit b3f2da203b
138 changed files with 13013 additions and 1929 deletions

View File

@@ -13,6 +13,8 @@
"@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-query": "^5.56.0",
"axios": "^1.7.0",
"docx-preview": "^0.3.7",
"pptx-react-renderer": "^0.1.1",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-router-dom": "^6.26.0",
@@ -24,6 +26,7 @@
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-react": "^4.3.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.6.0",
@@ -365,6 +368,40 @@
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -857,6 +894,25 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
"integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@remix-run/router": {
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
@@ -1578,6 +1634,17 @@
"react": "^18 || ^19"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1658,6 +1725,19 @@
"@types/react": "^18.0.0"
}
},
"node_modules/@vitejs/plugin-basic-ssl": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz",
"integrity": "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"peerDependencies": {
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -1840,6 +1920,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
@@ -1895,6 +1981,15 @@
"node": ">=8"
}
},
"node_modules/docx-preview": {
"version": "0.3.7",
"resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.3.7.tgz",
"integrity": "sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==",
"license": "Apache-2.0",
"dependencies": {
"jszip": ">=3.0.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2254,6 +2349,24 @@
"node": ">= 6"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -2296,6 +2409,27 @@
"node": ">=6"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -2666,6 +2800,12 @@
"node": ">=18"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2715,6 +2855,24 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/pptx-react-renderer": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/pptx-react-renderer/-/pptx-react-renderer-0.1.1.tgz",
"integrity": "sha512-nUNMyMJu7xIWbgO6m7szuS+EDPTvHoNYFgCCOjtVJ37lYtbtNoI8zFk1ZnkthmY27U3StzHXcU5oy23FrBhvLg==",
"license": "MIT",
"dependencies": {
"jszip": "^3.10.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
@@ -2791,6 +2949,21 @@
"react-dom": ">=16.8"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/rollup": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
@@ -2836,6 +3009,12 @@
"fsevents": "~2.3.2"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -2855,6 +3034,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
@@ -2905,6 +3090,15 @@
"node": ">=0.8"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/tailwindcss": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
@@ -2994,6 +3188,12 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vite": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",

View File

@@ -14,6 +14,8 @@
"@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-query": "^5.56.0",
"axios": "^1.7.0",
"docx-preview": "^0.3.7",
"pptx-react-renderer": "^0.1.1",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-router-dom": "^6.26.0",
@@ -25,6 +27,7 @@
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-react": "^4.3.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.6.0",

View File

@@ -32,6 +32,17 @@
--conn-line-width: 4;
--text-primary: #1a2b3c;
--text-muted: #5a6b7d;
/* 헤더 상태 통계(stat-*)와 동일 — 프로젝트 제목 불릿 */
--status-in-progress: #10b981;
--status-hold: #ff9f0a;
--status-done: #b0b0b0;
--status-issue: #ff5252;
/* 부서 카드 헤드 — 샴페인 골드 */
--dept-head-bg-top: #faf6ef;
--dept-head-bg-bottom: #f0e6d4;
--dept-head-text: #4a3d2e;
--dept-head-accent: #9a7b4f;
--dept-head-line: rgba(180, 152, 108, 0.32);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
@@ -188,7 +199,7 @@
.poly-click-stat {
display: inline;
padding: 2px 6px;
border-radius: 4px;
border-radius: 0;
line-height: 21px;
color: #fffc;
font-size: 18px;
@@ -255,7 +266,8 @@
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 360px) minmax(0, 1fr);
/* 중앙 허브 확대(다이아몬드 1.1×) — 좌·우 부문 카드 열은 1fr로 자동 축소 */
grid-template-columns: minmax(0, 1fr) minmax(320px, 396px) minmax(0, 1fr);
grid-template-rows: 1fr 1fr;
gap: 12px 24px;
width: 100%;
@@ -360,9 +372,30 @@
.dept-card--ex { grid-column: 1; grid-row: 2; }
.dept-card--ga { grid-column: 3; grid-row: 2; }
/* 아이콘 숨김 (HTML은 BACKUP용으로 유지) */
.dept-icon {
display: none;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 36px;
height: 36px;
margin-right: 8px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.48);
border: 1px solid rgba(180, 152, 108, 0.24);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.62),
0 1px 2px rgba(92, 74, 50, 0.06);
color: var(--dept-head-accent);
align-self: center;
}
.dept-icon svg {
width: 32px;
height: 32px;
stroke: currentColor;
fill: none;
stroke-width: 1.75;
}
.dept-head {
@@ -373,15 +406,32 @@
justify-content: space-between;
gap: 12px;
margin-bottom: 0;
padding: 16px 16px 10px;
border-bottom: 1px solid #d8e8e0;
background: linear-gradient(180deg, #fcfefd 0%, #f7fbf9 100%);
padding: 9px 16px 9px;
border-bottom: none;
background: linear-gradient(180deg, var(--dept-head-bg-top) 0%, var(--dept-head-bg-bottom) 100%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72);
flex-shrink: 0;
}
.dept-head::after {
content: "";
position: absolute;
left: 14px;
right: 14px;
bottom: 0;
height: 1px;
background: linear-gradient(
90deg,
transparent 0%,
var(--dept-head-line) 18%,
var(--dept-head-line) 82%,
transparent 100%
);
}
.dept-head-main {
display: flex;
align-items: baseline;
align-items: center;
gap: 0;
min-width: 0;
flex: 1;
@@ -411,13 +461,13 @@
}
.dept-card .board-dept-title {
color: #0a2e24;
color: var(--dept-head-text);
}
.dept-card .dept-head-count .poly-stat-val,
.dept-card .dept-head-count .poly-stat-unit,
.dept-card .board-dept-title-en {
color: var(--dept-accent);
color: var(--dept-head-accent);
}
.board-dept-header-main {
@@ -452,14 +502,30 @@
/* ─── 프로젝트 목록 — 종이 메모 리스트 ─── */
.board-project-list {
position: relative;
min-height: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 0;
overflow-y: auto;
padding: 8px 14px 10px;
background: transparent;
padding: 10px 14px 10px;
background: linear-gradient(180deg, #f8fbfa 0%, rgba(248, 251, 250, 0) 20px);
}
.board-project-list::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(
180deg,
rgba(92, 74, 50, 0.04) 0%,
transparent 100%
);
pointer-events: none;
}
.project-sub-card {
@@ -482,16 +548,41 @@
.project-sub-body {
display: grid;
grid-template-columns: 1fr auto;
gap: 14px;
grid-template-columns: 1fr auto auto;
column-gap: 12px;
align-items: center;
}
.project-sub-divider {
display: block;
width: 1px;
align-self: stretch;
margin: 6px 0;
background: linear-gradient(
180deg,
transparent 0%,
#c8d9d0 14%,
#c8d9d0 86%,
transparent 100%
);
}
.progress-col {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 93px;
}
.project-fields {
display: flex;
flex-direction: column;
gap: 7px;
min-width: 0;
/* 불릿(10px) + gap(10px) — 하위 필드는 제목 글자 시작선과 정렬 */
--project-title-indent: 20px;
}
.project-field {
@@ -499,19 +590,43 @@
grid-template-columns: 96px 1fr;
gap: 8px;
align-items: baseline;
padding-left: var(--project-title-indent);
}
.project-sub-title {
display: flex;
align-items: flex-start;
gap: 10px;
font-size: 24px;
font-weight: 600;
color: #0a2e24;
line-height: 1.35;
}
.project-sub-title::before {
content: "";
flex-shrink: 0;
width: 10px;
height: 10px;
margin-top: 9px;
border-radius: 50%;
background: var(--status-bullet, var(--status-in-progress));
}
.project-sub-title-text {
flex: 1;
min-width: 0;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.project-sub-title--in-progress { --status-bullet: var(--status-in-progress); }
.project-sub-title--hold { --status-bullet: var(--status-hold); }
.project-sub-title--done { --status-bullet: var(--status-done); }
.project-sub-title--issue { --status-bullet: var(--status-issue); }
.project-field-label {
font-size: 20px;
font-weight: 600;
@@ -532,19 +647,6 @@
text-overflow: ellipsis;
}
.project-sub-divider {
display: none;
}
.progress-col {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 93px;
}
.dept-card .donut {
--color: var(--dept-accent);
background: conic-gradient(var(--color) calc(var(--pct) * 1%), #d4ddd8 0);
@@ -598,8 +700,9 @@
width: 100%;
z-index: 2;
--hub-row-h: calc((100% - 12px) / 2);
--hub-band-h: min(calc(var(--hub-row-h) * 0.52), 188px);
--hub-slogan-band-h: min(calc(var(--hub-row-h) * 0.624), 226px);
--hub-band-h: min(calc(var(--hub-row-h) * 0.473), 171px);
/* 슬로건·일정 밴드 — 다이아몬드 1.1×에 맞춰 높이 축소 (0.624→0.567, 226→205) */
--hub-slogan-band-h: min(calc(var(--hub-row-h) * 0.567), 205px);
/* 일정: 박스 top → 제목 (플래너·헤더 띠에서 역산) */
--hub-title-top: 20px;
/* 슬로건: 바깥(그리드→포스트잇) + 안(핀 아래→제목). 일정 플래너 14px과 같은 호흡 */
@@ -611,7 +714,7 @@
--hub-head-pad-bottom: 7px;
--hub-card-pad-x: 12px;
--hub-card-pad-bottom: 10px;
--hub-diamond-scale: 0.9;
--hub-diamond-scale: 0.99;
}
.hub-box--message {
@@ -677,7 +780,7 @@
border: 1px solid #d8e2ea;
}
/* 분기 슬로건 — 2장 겹친 sage 포스트잇 */
/* 분기 중점 과제 — 2장 겹친 sage 포스트잇 */
.hub-box--message {
background: transparent;
border: none;
@@ -985,14 +1088,20 @@
color: #0a2e24;
}
.hub-column .board-project-desc,
.hub-column .hub-routine-item {
.hub-column .board-project-desc {
font-size: 20px;
font-weight: 500;
line-height: 1.45;
font-family: inherit;
}
.hub-column .hub-routine-item {
font-size: 24px;
font-weight: 600;
line-height: 1.35;
font-family: inherit;
}
.hub-column .hub-schedule-month {
font-size: 20px;
font-weight: 600;
@@ -1180,30 +1289,25 @@
}
.hub-diamond-icon {
width: 36px;
height: 36px;
border: 2px solid var(--hrm);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--hrm);
font-size: 18px;
font-weight: 700;
flex-shrink: 0;
line-height: 0;
}
.hub-diamond-icon svg {
width: 22px;
height: 22px;
stroke: #6eecc8;
fill: none;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
}
.hub-diamond {
flex-shrink: 0;
transform: rotate(45deg);
background: var(--hub-diamond-bg);
border: 2.5px solid var(--hub-diamond-border);
border-radius: 10px;
box-shadow: 0 6px 22px rgba(7, 65, 46, 0.28);
background: #fff;
border: 2.5px solid var(--hrm);
box-shadow: 0 4px 18px rgba(7, 65, 46, 0.1);
display: flex;
align-items: center;
justify-content: center;
@@ -1211,77 +1315,52 @@
.hub-diamond-inner {
transform: rotate(-45deg);
width: 92%;
max-height: 92%;
text-align: center;
padding: 0 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
overflow: hidden;
}
.hub-diamond-head {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
}
.hub-diamond-head .board-project-title {
text-align: left;
color: #fff;
margin: 0;
font-size: 24px;
font-weight: 600;
line-height: 1.35;
}
.hub-diamond-divider {
width: 88%;
height: 1px;
background: rgba(255, 255, 255, 0.22);
margin: 2px 0 4px;
flex-shrink: 0;
text-align: center;
padding: 0 4px;
}
.hub-routine-grid {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 10px;
row-gap: 2px;
width: 100%;
max-width: 100%;
justify-items: center;
.hub-diamond-icon {
width: 38px;
height: 38px;
margin: 0 auto 6px;
border: 2px solid var(--hrm);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--hrm);
font-size: 20px;
font-weight: 700;
}
.hub-routine-item {
appearance: none;
background: none;
border: none;
padding: 2px 4px;
margin: 0;
color: #8fd4bc;
cursor: pointer;
border-radius: 4px;
transition: color 0.18s ease, opacity 0.18s ease, text-shadow 0.18s ease;
.hub-diamond-title {
font-size: 18px;
font-weight: 800;
color: #1e3a5f;
margin-bottom: 6px;
line-height: 1.3;
}
.hub-routine-item:hover {
color: #b4ecd6;
text-shadow: 0 0 10px rgba(143, 212, 188, 0.28);
.hub-diamond ul {
list-style: none;
text-align: center;
font-size: 14px;
line-height: 1.5;
color: #334155;
font-weight: 500;
overflow: visible;
padding: 0;
}
.hub-routine-item:active {
opacity: 0.82;
.hub-diamond li {
padding: 1px 0;
}
.hub-routine-item:focus-visible {
outline: 1px solid rgba(143, 212, 188, 0.45);
outline-offset: 2px;
.hub-diamond li::before {
content: "• ";
color: var(--hrm);
font-weight: 700;
}
@media (max-width: 1200px) {
@@ -1405,7 +1484,10 @@
<article class="project-sub-card project-sub-card--hrm">
<div class="project-sub-body">
<div class="project-fields">
<div class="project-sub-title">상반기 채용 운영</div>
<div class="project-field">
<span class="project-field-label">프로젝트명</span>
<span class="project-field-value">상반기 채용 운영</span>
</div>
<div class="project-field">
<span class="project-field-label">작업 기간</span>
<span class="project-field-value">2026.04 ~ 2026.06</span>
@@ -1424,9 +1506,12 @@
<article class="project-sub-card project-sub-card--hrm">
<div class="project-sub-body">
<div class="project-fields">
<div class="project-sub-title">평가제도 개선</div>
<div class="project-field">
<span class="project-field-label">작업 기간</span>
<span class="project-field-label">프로젝트명</span>
<span class="project-field-value">평가제도 개선</span>
</div>
<div class="project-field">
<span class="project-field-label">기간</span>
<span class="project-field-value">2026.03 ~ 2026.07</span>
</div>
<div class="project-field">
@@ -1436,7 +1521,8 @@
</div>
<div class="project-sub-divider" aria-hidden="true"></div>
<div class="progress-col">
<div class="donut" style="--pct:60"><span>60%</span></div>
<span class="progress-label">진행/달성률</span>
<div class="donut" style="--pct:60; --color:#ff9f0a"><span style="color:#ff9f0a">보류</span></div>
</div>
</div>
</article>
@@ -1465,9 +1551,12 @@
<article class="project-sub-card project-sub-card--hrd">
<div class="project-sub-body">
<div class="project-fields">
<div class="project-sub-title">신규입사자 온보딩 프로그램</div>
<div class="project-field">
<span class="project-field-label">작업 기간</span>
<span class="project-field-label">프로젝트명</span>
<span class="project-field-value">신규입사자 온보딩 프로그램</span>
</div>
<div class="project-field">
<span class="project-field-label">기간</span>
<span class="project-field-value">2026.04 ~ 2026.06</span>
</div>
<div class="project-field">
@@ -1477,6 +1566,7 @@
</div>
<div class="project-sub-divider" aria-hidden="true"></div>
<div class="progress-col">
<span class="progress-label">진행/달성률</span>
<div class="donut" style="--pct:85"><span>85%</span></div>
</div>
</div>
@@ -1484,9 +1574,12 @@
<article class="project-sub-card project-sub-card--hrd">
<div class="project-sub-body">
<div class="project-fields">
<div class="project-sub-title">팀장 리더십 교육</div>
<div class="project-field">
<span class="project-field-label">작업 기간</span>
<span class="project-field-label">프로젝트명</span>
<span class="project-field-value">팀장 리더십 교육</span>
</div>
<div class="project-field">
<span class="project-field-label">기간</span>
<span class="project-field-value">2026.05 ~ 2026.06</span>
</div>
<div class="project-field">
@@ -1496,7 +1589,8 @@
</div>
<div class="project-sub-divider" aria-hidden="true"></div>
<div class="progress-col">
<div class="donut" style="--pct:100"><span>100%</span></div>
<span class="progress-label">진행/달성률</span>
<div class="donut" style="--pct:100; --color:#b0b0b0"><span style="color:#b0b0b0">완료</span></div>
</div>
</div>
</article>
@@ -1505,7 +1599,7 @@
<!-- 중앙 허브 -->
<div class="hub-column" id="hub-column">
<!-- 분기 슬로건 -->
<!-- 분기 중점 과제 -->
<div class="hub-box hub-box--message">
<div class="hub-postit-stack">
<div class="hub-postit-sheet hub-postit-sheet--back" aria-hidden="true"></div>
@@ -1517,17 +1611,12 @@
<div class="hub-box-title-row">
<span class="hub-message-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 4v5"/>
<path d="M12 15v5"/>
<path d="M4 12h5"/>
<path d="M15 12h5"/>
<path d="M18 5v2"/>
<path d="M17 6h2"/>
<path d="M5 17v2"/>
<path d="M4 18h2"/>
<circle cx="12" cy="12" r="10"/>
<circle cx="12" cy="12" r="6"/>
<circle cx="12" cy="12" r="2"/>
</svg>
</span>
<div class="board-project-title">분기 슬로건</div>
<div class="board-project-title">분기 중점 과제</div>
</div>
</div>
<div class="hub-postit-message">
@@ -1554,12 +1643,11 @@
</div>
<div class="hub-diamond-divider" aria-hidden="true"></div>
<div class="hub-routine-grid">
<button type="button" class="hub-routine-item">채용</button>
<button type="button" class="hub-routine-item">교육</button>
<button type="button" class="hub-routine-item">소통</button>
<button type="button" class="hub-routine-item">시설</button>
<button type="button" class="hub-routine-item">자산</button>
<button type="button" class="hub-routine-item">행정</button>
<button type="button" class="hub-routine-item">채용 운영</button>
<button type="button" class="hub-routine-item">교육 운영</button>
<button type="button" class="hub-routine-item">직원 소통</button>
<button type="button" class="hub-routine-item">자산·시설</button>
<button type="button" class="hub-routine-item">문서·행정</button>
</div>
</div>
</div>
@@ -1623,9 +1711,12 @@
<article class="project-sub-card project-sub-card--ex">
<div class="project-sub-body">
<div class="project-fields">
<div class="project-sub-title">조직문화 진단</div>
<div class="project-field">
<span class="project-field-label">작업 기간</span>
<span class="project-field-label">프로젝트명</span>
<span class="project-field-value">조직문화 진단</span>
</div>
<div class="project-field">
<span class="project-field-label">기간</span>
<span class="project-field-value">2026.04 ~ 2026.05</span>
</div>
<div class="project-field">
@@ -1635,6 +1726,7 @@
</div>
<div class="project-sub-divider" aria-hidden="true"></div>
<div class="progress-col">
<span class="progress-label">진행/달성률</span>
<div class="donut" style="--pct:100"><span>100%</span></div>
</div>
</div>
@@ -1642,9 +1734,12 @@
<article class="project-sub-card project-sub-card--ex">
<div class="project-sub-body">
<div class="project-fields">
<div class="project-sub-title">복리후생제도 개선</div>
<div class="project-field">
<span class="project-field-label">작업 기간</span>
<span class="project-field-label">프로젝트명</span>
<span class="project-field-value">복리후생제도 개선</span>
</div>
<div class="project-field">
<span class="project-field-label">기간</span>
<span class="project-field-value">2026.05 ~ 2026.08</span>
</div>
<div class="project-field">
@@ -1654,7 +1749,8 @@
</div>
<div class="project-sub-divider" aria-hidden="true"></div>
<div class="progress-col">
<div class="donut" style="--pct:50"><span>50%</span></div>
<span class="progress-label">진행/달성률</span>
<div class="donut" style="--pct:50; --color:#ff9f0a"><span style="color:#ff9f0a">보류</span></div>
</div>
</div>
</article>
@@ -1683,9 +1779,12 @@
<article class="project-sub-card project-sub-card--ga">
<div class="project-sub-body">
<div class="project-fields">
<div class="project-sub-title">사무공간 재배치</div>
<div class="project-field">
<span class="project-field-label">작업 기간</span>
<span class="project-field-label">프로젝트명</span>
<span class="project-field-value">사무공간 재배치</span>
</div>
<div class="project-field">
<span class="project-field-label">기간</span>
<span class="project-field-value">2026.04 ~ 2026.07</span>
</div>
<div class="project-field">
@@ -1695,6 +1794,7 @@
</div>
<div class="project-sub-divider" aria-hidden="true"></div>
<div class="progress-col">
<span class="progress-label">진행/달성률</span>
<div class="donut" style="--pct:70"><span>70%</span></div>
</div>
</div>
@@ -1702,9 +1802,12 @@
<article class="project-sub-card project-sub-card--ga">
<div class="project-sub-body">
<div class="project-fields">
<div class="project-sub-title">안전·보안 점검 강화</div>
<div class="project-field">
<span class="project-field-label">작업 기간</span>
<span class="project-field-label">프로젝트명</span>
<span class="project-field-value">안전·보안 점검 강화</span>
</div>
<div class="project-field">
<span class="project-field-label">기간</span>
<span class="project-field-value">2026.04 ~ 2026.06</span>
</div>
<div class="project-field">
@@ -1714,6 +1817,7 @@
</div>
<div class="project-sub-divider" aria-hidden="true"></div>
<div class="progress-col">
<span class="progress-label">진행/달성률</span>
<div class="donut" style="--pct:85"><span>85%</span></div>
</div>
</div>

View File

@@ -0,0 +1,21 @@
type HubNavChevronProps = {
direction: 'prev' | 'next';
className?: string;
};
export function HubNavChevron({ direction, className = '' }: HubNavChevronProps) {
return (
<svg
className={`hub-nav-chevron${className ? ` ${className}` : ''}`}
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
{direction === 'prev' ? (
<path stroke="currentColor" d="M15.5 1 8.5 12l7 11" />
) : (
<path stroke="currentColor" d="M8.5 1 15.5 12 8.5 23" />
)}
</svg>
);
}

View File

@@ -1,7 +1,9 @@
import { useState } from 'react';
import { createPortal } from 'react-dom';
import type { Task, TeamMember } from '../../types';
import { normalizeTaskType, displayFlagsForTaskType, TASK_TYPE_OPTIONS } from '../../lib/taskType';
import type { Task, TeamMember, TaskIssueEntry } from '../../types';
import { normalizeTaskType } from '../../lib/taskType';
import { newIssueEntry, parseIssueEntries } from '../../lib/taskIssues';
import { getRoutineCategory, routineCategoryOptions } from '../../lib/routineCategories';
const STATUS_OPTIONS = [
{ value: 'TODO', label: '대기' },
@@ -13,28 +15,27 @@ const STATUS_OPTIONS = [
export interface TaskFormData {
title: string;
section: string;
category: string;
tag: string;
taskType: string;
status: string;
progress: number;
description: string;
issueNote: string;
issueEntries: TaskIssueEntry[];
quarter: string;
startDate: string;
dueDate: string;
showDate: boolean;
showDescription: boolean;
showStatus: boolean;
showIssue: boolean;
showProgress: boolean;
pmMemberId: string;
assigneeMemberIds: string[];
}
interface TaskModalProps {
mode: 'add' | 'edit';
/** project: 실행과제 전용 / routine: 기반업무(상시) 전용 */
variant?: 'project' | 'routine';
task?: Task;
defaultSection?: string;
defaultCategory?: string;
defaultQuarter?: string;
sectionOptions?: { value: string; label: string }[];
teamMembers?: TeamMember[];
@@ -44,37 +45,37 @@ interface TaskModalProps {
export function TaskModal({
mode,
variant = 'project',
task,
defaultSection = 'HR',
defaultCategory = '채용 운영',
defaultQuarter = '2026-Q2',
sectionOptions,
teamMembers = [],
onSave,
onClose,
}: TaskModalProps) {
const isRoutine = variant === 'routine';
const toDateInput = (iso: string | null | undefined) => {
if (!iso) return '';
return new Date(iso).toISOString().slice(0, 10);
};
// DB값(프로젝트/상시업무) → 새 값(실행과제/기반업무) 정규화
const [form, setForm] = useState<TaskFormData>({
title: task?.title ?? '',
section: task?.section ?? defaultSection,
category: (task ? getRoutineCategory(task) : null) ?? defaultCategory,
tag: task?.tag ?? '',
taskType: task?.taskType ? normalizeTaskType(task.taskType) : '실행과제',
taskType: isRoutine
? '기반업무'
: (task?.taskType ? normalizeTaskType(task.taskType) : '실행과제'),
status: task?.status ?? 'TODO',
progress: task?.progress ?? 0,
description: task?.description ?? '',
issueNote: task?.issueNote ?? '',
issueEntries: task ? parseIssueEntries(task) : [],
quarter: task?.quarter ?? defaultQuarter,
startDate: toDateInput(task?.startDate),
dueDate: toDateInput(task?.dueDate),
showDate: task?.showDate ?? true,
showDescription: task?.showDescription ?? true,
showStatus: task?.showStatus ?? true,
showIssue: task?.showIssue ?? true,
showProgress: task?.showProgress ?? true,
pmMemberId: task?.pmMember?.id ?? task?.pmMemberId ?? '',
assigneeMemberIds: task?.assigneeMembers?.map((m) => m.id) ?? [],
});
@@ -94,9 +95,36 @@ export function TaskModal({
const set = <K extends keyof TaskFormData>(field: K, value: TaskFormData[K]) =>
setForm(prev => ({ ...prev, [field]: value }));
const updateIssueEntry = (id: string, patch: Partial<TaskIssueEntry>) => {
setForm((prev) => ({
...prev,
issueEntries: prev.issueEntries.map((entry) =>
entry.id === id ? { ...entry, ...patch } : entry,
),
}));
};
const addIssueEntry = () => {
setForm((prev) => ({
...prev,
issueEntries: [...prev.issueEntries, newIssueEntry()],
}));
};
const removeIssueEntry = (id: string) => {
setForm((prev) => ({
...prev,
issueEntries: prev.issueEntries.filter((entry) => entry.id !== id),
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave(form);
const payload: TaskFormData = {
...form,
taskType: isRoutine ? '기반업무' : '실행과제',
};
onSave(payload);
};
return createPortal(
@@ -111,7 +139,9 @@ export function TaskModal({
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<h2 className="text-2xl font-black text-gray-800">
{mode === 'add' ? '✚ 업무 추가' : '✏ 업무 수정'}
{isRoutine
? (mode === 'add' ? '✚ 상시업무 추가' : '✏ 상시업무 수정')
: (mode === 'add' ? '✚ 프로젝트 추가' : '✏ 프로젝트 수정')}
</h2>
<button
type="button"
@@ -135,14 +165,26 @@ export function TaskModal({
/>
</div>
{/* 섹션 + 업무유형 */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
{/* 대분류 (상시업무) / 소속 부문 (프로젝트) */}
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5">
{isRoutine ? '대분류' : '소속 부문'}
</label>
{isRoutine ? (
<select
value={form.category}
onChange={(e) => set('category', e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:ring-2 transition bg-white focus:border-emerald-400 focus:ring-emerald-100"
>
{routineCategoryOptions().map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
) : (
<select
value={form.section}
onChange={(e) => set('section', e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition bg-white"
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:ring-2 transition bg-white focus:border-blue-400 focus:ring-blue-100"
>
{(sectionOptions ?? [
{ value: 'HR', label: 'HR' },
@@ -151,43 +193,14 @@ export function TaskModal({
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"> </label>
<select
value={form.taskType}
onChange={(e) => {
const newType = e.target.value;
setForm((prev) => ({
...prev,
taskType: newType,
...displayFlagsForTaskType(newType),
}));
}}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition bg-white"
>
{TASK_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
)}
</div>
{/* 상태 + 진행률 */}
{/* 상태 + 진행률 (프로젝트만) */}
{!isRoutine && (
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-sm font-bold text-gray-500"></label>
<label className="flex items-center gap-1.5 cursor-pointer select-none">
<input
type="checkbox"
checked={form.showStatus}
onChange={(e) => set('showStatus', e.target.checked)}
className="w-4 h-4 accent-blue-500 cursor-pointer"
/>
<span className="text-xs font-semibold text-gray-400"> </span>
</label>
</div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
<select
value={form.status}
onChange={(e) => set('status', e.target.value)}
@@ -199,22 +212,11 @@ export function TaskModal({
</select>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-sm font-bold text-gray-500">
<span className="ml-2 font-black text-gray-800">{form.progress}%</span>
</label>
<label className="flex items-center gap-1.5 cursor-pointer select-none">
<input
type="checkbox"
checked={form.showProgress}
onChange={(e) => set('showProgress', e.target.checked)}
className="w-4 h-4 accent-blue-500 cursor-pointer"
/>
<span className="text-xs font-semibold text-gray-400"> </span>
</label>
</div>
<div className="flex items-center gap-3">
<label className="block text-sm font-bold text-gray-500 mb-1.5">
<span className="ml-2 font-black text-gray-800">{form.progress}%</span>
</label>
<div className="flex items-center gap-3 pt-2">
<input
type="range"
min={0}
@@ -227,21 +229,27 @@ export function TaskModal({
</div>
</div>
</div>
)}
{/* 상태 (상시업무) */}
{isRoutine && (
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
<select
value={form.status}
onChange={(e) => set('status', e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100 transition bg-white"
>
{STATUS_OPTIONS.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
)}
{/* 내용 */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-sm font-bold text-gray-500"></label>
<label className="flex items-center gap-1.5 cursor-pointer select-none">
<input
type="checkbox"
checked={form.showDescription}
onChange={(e) => set('showDescription', e.target.checked)}
className="w-4 h-4 accent-blue-500 cursor-pointer"
/>
<span className="text-xs font-semibold text-gray-400"> </span>
</label>
</div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
<textarea
value={form.description}
onChange={(e) => set('description', e.target.value)}
@@ -299,19 +307,9 @@ export function TaskModal({
)}
{/* 프로젝트 기간 */}
{!isRoutine && (
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-sm font-bold text-gray-500"></label>
<label className="flex items-center gap-1.5 cursor-pointer select-none">
<input
type="checkbox"
checked={form.showDate}
onChange={(e) => set('showDate', e.target.checked)}
className="w-4 h-4 accent-blue-500 cursor-pointer"
/>
<span className="text-xs font-semibold text-gray-400"> </span>
</label>
</div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
<div className="grid grid-cols-2 gap-3">
<input
type="date"
@@ -327,27 +325,60 @@ export function TaskModal({
/>
</div>
</div>
)}
{/* 이슈 메모 */}
<div>
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-bold text-gray-500"> </label>
<label className="flex items-center gap-1.5 cursor-pointer select-none">
<input
type="checkbox"
checked={form.showIssue}
onChange={(e) => set('showIssue', e.target.checked)}
className="w-4 h-4 accent-blue-500 cursor-pointer"
/>
<span className="text-xs font-semibold text-gray-400"> </span>
</label>
<button
type="button"
onClick={addIssueEntry}
className="text-xs font-bold text-blue-600 hover:text-blue-700 px-2 py-1 rounded-lg hover:bg-blue-50 transition"
>
+
</button>
</div>
<input
value={form.issueNote}
onChange={(e) => set('issueNote', e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-red-400 focus:ring-2 focus:ring-red-100 transition text-red-600 placeholder:text-gray-300"
placeholder="[날짜] 이슈 내용 (비우면 표시 안 함)"
/>
{form.issueEntries.length === 0 ? (
<p className="text-sm text-gray-400 rounded-xl border border-dashed border-gray-200 px-4 py-3 text-center">
. .
</p>
) : (
<div className="space-y-2">
{form.issueEntries.map((entry, index) => (
<div
key={entry.id}
className="rounded-xl border border-gray-200 bg-gray-50/60 p-3 space-y-2"
>
<textarea
value={entry.text}
onChange={(e) => updateIssueEntry(entry.id, { text: e.target.value })}
rows={2}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 resize-none transition bg-white"
placeholder={`[날짜] 이슈 내용 (${index + 1})`}
/>
<div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-1.5 cursor-pointer select-none">
<input
type="checkbox"
checked={entry.showOnCard}
onChange={(e) => updateIssueEntry(entry.id, { showOnCard: e.target.checked })}
className="w-4 h-4 accent-blue-500 cursor-pointer"
/>
<span className="text-xs font-semibold text-gray-500"> </span>
</label>
<button
type="button"
onClick={() => removeIssueEntry(entry.id)}
className="text-xs font-bold text-red-500 hover:text-red-600 px-2 py-1 rounded-lg hover:bg-red-50 transition"
>
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* 버튼 */}
@@ -361,7 +392,9 @@ export function TaskModal({
</button>
<button
type="submit"
className="px-6 py-2.5 rounded-xl bg-blue-600 text-white font-bold hover:bg-blue-700 transition"
className={`px-6 py-2.5 rounded-xl text-white font-bold transition ${
isRoutine ? 'bg-emerald-700 hover:bg-emerald-800' : 'bg-blue-600 hover:bg-blue-700'
}`}
>
{mode === 'add' ? '추가하기' : '저장하기'}
</button>

View File

@@ -0,0 +1,172 @@
import { useEffect, useMemo, useState, type CSSProperties, type RefObject } from 'react';
import { createPortal } from 'react-dom';
import {
QUARTER_RANGE_LABELS,
buildMonthWeekRows,
dateToQuarter,
isSameDay,
isSameWeek,
quarterEndDate,
startOfDay,
startOfWeekMonday,
toIsoDate,
} from '../../lib/boardCalendar';
const WEEKDAY_LABELS = ['월', '화', '수', '목', '금', '토', '일'];
interface BoardCalendarPopoverProps {
referenceDate: Date;
onChange: (d: Date) => void;
onClose: () => void;
anchorRef: RefObject<HTMLElement | null>;
}
export function BoardCalendarPopover({
referenceDate,
onChange,
onClose,
anchorRef,
}: BoardCalendarPopoverProps) {
const [viewYear, setViewYear] = useState(referenceDate.getFullYear());
const [viewMonth, setViewMonth] = useState(referenceDate.getMonth());
const weekRows = useMemo(
() => buildMonthWeekRows(viewYear, viewMonth),
[viewYear, viewMonth],
);
const activeQuarterKey = dateToQuarter(referenceDate);
useEffect(() => {
setViewYear(referenceDate.getFullYear());
setViewMonth(referenceDate.getMonth());
}, [referenceDate]);
useEffect(() => {
const onPointerDown = (e: MouseEvent) => {
const anchor = anchorRef.current;
const target = e.target as Node;
if (anchor?.contains(target)) return;
const popover = document.getElementById('board-calendar-popover');
if (popover?.contains(target)) return;
onClose();
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('mousedown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('mousedown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [anchorRef, onClose]);
const rect = anchorRef.current?.getBoundingClientRect();
const style: CSSProperties = rect
? { top: rect.bottom + 8, right: Math.max(12, window.innerWidth - rect.right) }
: { top: 56, right: 24 };
const shiftMonth = (delta: number) => {
const d = new Date(viewYear, viewMonth + delta, 1);
setViewYear(d.getFullYear());
setViewMonth(d.getMonth());
};
const pickDate = (d: Date) => {
onChange(startOfDay(d));
};
const pickQuarter = (q: 1 | 2 | 3 | 4) => {
onChange(quarterEndDate(`${viewYear}-Q${q}`));
};
const today = startOfDay(new Date());
return createPortal(
<div id="board-calendar-popover" className="board-calendar-popover" style={style} role="dialog" aria-label="기준일 선택">
<div className="board-calendar-popover-head">
<button type="button" className="board-calendar-nav" onClick={() => shiftMonth(-1)} aria-label="이전 달"></button>
<span className="board-calendar-month">{viewYear} {viewMonth + 1}</span>
<button type="button" className="board-calendar-nav" onClick={() => shiftMonth(1)} aria-label="다음 달"></button>
</div>
<div className="board-calendar-quarter-row">
{([1, 2, 3, 4] as const).map((q) => {
const key = `${viewYear}-Q${q}`;
const selected = activeQuarterKey === key;
return (
<button
key={key}
type="button"
className={`board-calendar-quarter-chip${selected ? ' is-selected' : ''}`}
onClick={() => pickQuarter(q)}
>
<span className="board-calendar-quarter-chip-title">{q}</span>
<span className="board-calendar-quarter-chip-range">{QUARTER_RANGE_LABELS[q - 1]}</span>
</button>
);
})}
</div>
<div className="board-calendar-grid-wrap">
<table className="board-calendar-grid">
<thead>
<tr>
<th className="board-calendar-grid-week-col"></th>
{WEEKDAY_LABELS.map((label) => (
<th key={label}>{label}</th>
))}
</tr>
</thead>
<tbody>
{weekRows.map((row) => {
const weekSelected = isSameWeek(referenceDate, row.monday);
return (
<tr key={toIsoDate(row.monday)} className={weekSelected ? 'is-selected-week' : undefined}>
<td className="board-calendar-grid-week-col">
<button
type="button"
className="board-calendar-week-label-btn"
onClick={() => pickDate(startOfWeekMonday(row.monday))}
>
{row.label}
</button>
</td>
{row.days.map((day) => {
const inMonth = day.getMonth() === viewMonth;
const isRef = isSameDay(day, referenceDate);
const isToday = isSameDay(day, today);
return (
<td key={toIsoDate(day)}>
<button
type="button"
className={[
'board-calendar-day-btn',
!inMonth ? 'is-outside' : '',
isRef ? 'is-ref' : '',
isToday ? 'is-today' : '',
].filter(Boolean).join(' ')}
onClick={() => pickDate(day)}
>
{day.getDate()}
</button>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
<div className="board-calendar-foot">
<button type="button" onClick={() => pickDate(startOfWeekMonday(today))}> </button>
<button type="button" onClick={() => pickDate(today)}> </button>
</div>
</div>,
document.body,
);
}

View File

@@ -1,11 +1,24 @@
import { useBoardConnectors } from '../../hooks/useBoardConnectors';
import { useBoardConnectors, type ConnectorStyle } from '../../hooks/useBoardConnectors';
export function BoardConnectors({ enabled = true }: { enabled?: boolean }) {
const { svgRef, lineGroupRef } = useBoardConnectors(enabled);
export function BoardConnectors({
enabled = true,
style = 'default',
}: {
enabled?: boolean;
style?: ConnectorStyle;
}) {
const { svgRef, lineGroupRef, dotSvgRef, dotGroupRef } = useBoardConnectors(enabled, style);
return (
<svg ref={svgRef} className="connectors" aria-hidden="true">
<g ref={lineGroupRef} id="connector-lines" />
</svg>
<>
<svg ref={svgRef} className="connectors" aria-hidden="true">
<g ref={lineGroupRef} id="connector-lines" />
</svg>
{style === 'reference' && (
<svg ref={dotSvgRef} className="connectors connectors--dots" aria-hidden="true">
<g ref={dotGroupRef} id="connector-dots" />
</svg>
)}
</>
);
}

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useRef, useState } from 'react';
import { formatReferenceSummary } from '../../lib/boardCalendar';
import { isDetailWindowOpen } from '../../lib/dualMonitor';
import {
@@ -12,7 +13,8 @@ import {
} from '../../lib/statusFilters';
import { DualMonitorIcon, PlusIcon, UsersIcon } from './HeaderIcons';
import { BoardCalendarPopover } from './BoardCalendarPopover';
import { CalendarIcon, DualMonitorIcon, PlusIcon, UsersIcon } from './HeaderIcons';
@@ -36,6 +38,10 @@ interface DashboardHeaderProps {
quarter: string;
referenceDate: Date;
onReferenceDateChange: (d: Date) => void;
stats: Stats;
activeFilters: string[];
@@ -84,6 +90,10 @@ export function DashboardHeader({
quarter,
referenceDate,
onReferenceDateChange,
stats,
activeFilters,
@@ -107,6 +117,8 @@ export function DashboardHeader({
}: DashboardHeaderProps) {
const [detailViewActive, setDetailViewActive] = useState(isDetailWindowOpen);
const [calendarOpen, setCalendarOpen] = useState(false);
const calendarBtnRef = useRef<HTMLButtonElement>(null);
const quarterLabel = quarter.replace(/^(\d{4})-Q(\d)$/, '$1 $2분기 업무');
@@ -237,10 +249,21 @@ export function DashboardHeader({
<UsersIcon size={16} />
</button>
<button type="button" onClick={onOpenTaskManager} title="신규 프로젝트 추가" className="header-action-btn-new">
<PlusIcon size={16} />
</button>
<button
type="button"
onClick={handleOpenDetailWindow}
title="듀얼뷰"
className={`header-view-btn-new ${detailViewActive ? 'active' : ''}`}
>
<DualMonitorIcon size={16} />
</button>
</div>
<div className="header-stats-bar side-polygon-stats">
<div className="poly-stat-item" style={{ fontSize: '18px', gap: '8px' }}>
@@ -280,29 +303,30 @@ export function DashboardHeader({
<div className="side-right-actions shrink-0">
<button type="button" onClick={onOpenTaskManager} title="신규 프로젝트 추가" className="header-action-btn-new">
<PlusIcon size={16} />
</button>
<button
type="button"
onClick={handleOpenDetailWindow}
title="듀얼뷰"
className={`header-view-btn-new ${detailViewActive ? 'active' : ''}`}
>
<DualMonitorIcon size={16} />
</button>
<div className="header-calendar-slot">
<span className="board-calendar-ref-text">{formatReferenceSummary(referenceDate)}</span>
<button
ref={calendarBtnRef}
type="button"
className={`header-calendar-btn-new${calendarOpen ? ' active' : ''}`}
title="기준일 · 분기 선택"
aria-expanded={calendarOpen}
aria-label="캘린더 열기"
onClick={() => setCalendarOpen((open) => !open)}
>
<CalendarIcon size={16} />
</button>
{calendarOpen && (
<BoardCalendarPopover
referenceDate={referenceDate}
onChange={(d) => {
onReferenceDateChange(d);
}}
onClose={() => setCalendarOpen(false)}
anchorRef={calendarBtnRef}
/>
)}
</div>
</div>
</header>

View File

@@ -2,18 +2,21 @@ import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useDroppable } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { apiClient, getApiErrorMessage } from '../../lib/apiClient';
import { isProjectTask } from '../../lib/taskType';
import { type BoardSlotConfig, slotSectionLabel } from '../../lib/boardLayout';
import { type BoardSlotConfig, slotSectionLabel, columnDisplayTitle, columnDisplayTitleEn } from '../../lib/boardLayout';
import { SortableTaskCard } from './TaskCard';
import { DeptIcon } from './DeptIcon';
import { DeptProjectList } from './DeptProjectList';
import { ContextMenu } from '../common/ContextMenu';
import { TaskModal } from '../common/TaskModal';
import type { TaskFormData } from '../common/TaskModal';
import { taskFormToApiPayload } from '../../lib/taskFormPayload';
import { projectFormToApiPayload } from '../../lib/taskFormPayload';
import { invalidateTaskCaches } from '../../lib/taskQueryCache';
import type { Task, TeamMember } from '../../types';
const DUMMY_HEADER_KEY = 'eene-board-slot-headers-v1';
/** 참고 레이아웃: 부서당 표시 슬롯 수 (2행 + 스크롤) */
type DummyHeaders = Record<string, { title: string; titleEn: string; subtitle: string }>;
@@ -138,12 +141,8 @@ export function DepartmentColumn({
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['columns', slot.sectionKey] }),
});
const title = isDummySlot
? dummyHeader?.title ?? slot.defaultTitle
: colConfig?.title ?? slot.defaultTitle;
const titleEnState = isDummySlot
? dummyHeader?.titleEn ?? slot.defaultTitleEn
: colConfig?.titleEn ?? slot.defaultTitleEn;
const title = columnDisplayTitle(slot, colConfig, dummyHeader);
const titleEnState = columnDisplayTitleEn(slot, colConfig, dummyHeader);
const subtitle = isDummySlot ? dummyHeader?.subtitle ?? '' : colConfig?.subtitle ?? '';
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; type: 'header' | 'list' } | null>(null);
@@ -176,7 +175,7 @@ export function DepartmentColumn({
const patch = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
apiClient.patch(`/tasks/${id}`, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
onSuccess: (_data, { id }) => invalidateTaskCaches(queryClient, id),
});
const remove = useMutation({
@@ -210,7 +209,7 @@ export function DepartmentColumn({
const handleAdd = async (data: TaskFormData) => {
try {
await create.mutateAsync({
...taskFormToApiPayload(data),
...projectFormToApiPayload(data),
section,
priority: 'MEDIUM',
} as Partial<Task>);
@@ -222,7 +221,7 @@ export function DepartmentColumn({
const handleEdit = (data: TaskFormData) => {
if (!editingTask) return;
patch.mutate({ id: editingTask.id, data: taskFormToApiPayload(data) });
patch.mutate({ id: editingTask.id, data: projectFormToApiPayload(data) });
setShowEditModal(false);
setEditingTask(null);
};
@@ -257,41 +256,40 @@ export function DepartmentColumn({
}}
>
<div className="dept-head-main">
<DeptIcon slotId={slot.id} />
<div className="board-dept-header-main">
<div className="board-dept-title-wrap">
<h2 className="board-dept-title">{title.replace(/\s*부문$/, '')}</h2>
<span className="board-dept-title" role="heading" aria-level={2}>
{title.replace(/\s*부문$/, '')}
</span>
{titleEnState && <span className="board-dept-title-en">{titleEnState}</span>}
<div className="dept-head-count" aria-label={`${projectTasks.length}`}>
<span className="poly-stat-val">{projectTasks.length}</span>
<span className="poly-stat-unit"></span>
</div>
</div>
{subtitle && <p className="board-dept-subtitle">{subtitle}</p>}
</div>
</div>
<div className="dept-head-count" aria-label={`${projectTasks.length}`}>
<span className="poly-stat-val">{projectTasks.length}</span>
<span className="poly-stat-unit"> </span>
</div>
</div>
<div
ref={setProjectDropRef}
className={`board-project-list ${isProjectOver ? 'is-over' : ''}`}
<DeptProjectList
items={projectTasks}
getKey={(task) => task.id}
sortableItemIds={projectTasks.map((t) => t.id)}
dropRef={setProjectDropRef}
className={isProjectOver ? 'is-over' : ''}
onContextMenu={handleListContextMenu}
>
{projectTasks.length === 0 ? (
<div className="board-empty"> </div>
) : (
<SortableContext items={projectTasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
{projectTasks.map((task) => (
<SortableTaskCard
key={task.id}
task={task}
variant="project"
sectionOptions={sectionOptions}
onSelect={onSelectTask}
/>
))}
</SortableContext>
empty={<div className="board-empty"> </div>}
renderItem={(task) => (
<SortableTaskCard
task={task}
variant="project"
sectionOptions={sectionOptions}
onSelect={onSelectTask}
/>
)}
</div>
/>
</section>
{cardMenu && (
@@ -322,6 +320,7 @@ export function DepartmentColumn({
{showAddModal && (
<TaskModal
variant="project"
mode="add"
defaultSection={section}
defaultQuarter={quarter}
@@ -334,6 +333,7 @@ export function DepartmentColumn({
{showEditModal && editingTask && (
<TaskModal
variant="project"
mode="edit"
task={editingTask}
sectionOptions={sectionOptions}

View File

@@ -0,0 +1,44 @@
import type { BoardSlotId } from '../../lib/boardLayout';
function DeptIconSvg({ slotId }: { slotId: BoardSlotId }) {
switch (slotId) {
case 'hrm':
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
);
case 'hrd':
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
<path d="M6 12v5c0 1.7 3.3 3 7 3s7-1.3 7-3v-5" />
</svg>
);
case 'ex':
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.29 1.51 4.04 3 5.5l7 7Z" />
</svg>
);
case 'ga':
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="4" y="2" width="16" height="20" rx="2" ry="2" />
<path d="M9 22v-4h6v4" />
<path d="M8 6h.01M16 6h.01M12 6h.01M12 10h.01M12 14h.01M16 10h.01M16 14h.01M8 10h.01M8 14h.01" />
</svg>
);
}
}
export function DeptIcon({ slotId }: { slotId: BoardSlotId }) {
return (
<div className={`dept-icon dept-icon--${slotId}`}>
<DeptIconSvg slotId={slotId} />
</div>
);
}

View File

@@ -0,0 +1,150 @@
import {
useEffect,
useMemo,
useRef,
useState,
type MouseEventHandler,
type ReactNode,
} from 'react';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
const PAGE_SIZE = 2;
type DeptProjectListProps<T> = {
items: T[];
getKey: (item: T) => string;
renderItem: (item: T) => ReactNode;
sortableItemIds?: string[];
dropRef?: (node: HTMLDivElement | null) => void;
className?: string;
onContextMenu?: MouseEventHandler<HTMLDivElement>;
empty?: ReactNode;
};
function mergeRefs(
...refs: Array<((node: HTMLDivElement | null) => void) | undefined>
) {
return (node: HTMLDivElement | null) => {
refs.forEach((ref) => ref?.(node));
};
}
function chunkItems<T>(items: T[], size: number): T[][] {
const pages: T[][] = [];
for (let i = 0; i < items.length; i += size) {
pages.push(items.slice(i, i + size));
}
return pages;
}
export function DeptProjectList<T>({
items,
getKey,
renderItem,
sortableItemIds,
dropRef,
className = '',
onContextMenu,
empty,
}: DeptProjectListProps<T>) {
const rootRef = useRef<HTMLDivElement | null>(null);
const pages = useMemo(() => chunkItems(items, PAGE_SIZE), [items]);
const pageCount = pages.length;
const paged = pageCount > 1;
const [pageIndex, setPageIndex] = useState(0);
useEffect(() => {
setPageIndex((prev) => Math.min(prev, Math.max(0, pageCount - 1)));
}, [pageCount]);
useEffect(() => {
const root = rootRef.current;
if (!root || !paged) return;
const onWheel = (event: WheelEvent) => {
if (Math.abs(event.deltaY) < 1) return;
event.preventDefault();
const direction = event.deltaY > 0 ? 1 : -1;
setPageIndex((prev) => Math.min(pageCount - 1, Math.max(0, prev + direction)));
};
root.addEventListener('wheel', onWheel, { passive: false });
return () => root.removeEventListener('wheel', onWheel);
}, [paged, pageCount]);
const setRefs = mergeRefs((node) => {
rootRef.current = node;
}, dropRef);
const listBody = items.length === 0 ? (
empty
) : paged ? (
<div className="board-project-list-viewport">
<div className="board-project-list-page">
{pages[pageIndex].map((item) => (
<div key={getKey(item)} className="board-project-list-slot">
{renderItem(item)}
</div>
))}
{pages[pageIndex].length < PAGE_SIZE &&
Array.from({ length: PAGE_SIZE - pages[pageIndex].length }, (_, slot) => (
<div
key={`empty-${pageIndex}-${slot}`}
className="board-project-list-slot board-project-list-slot--empty"
aria-hidden="true"
/>
))}
</div>
</div>
) : (
<div className="board-project-list-viewport">
<div className="board-project-list-track">
<div className="board-project-list-page">
{pages[0].map((item) => (
<div key={getKey(item)} className="board-project-list-slot">
{renderItem(item)}
</div>
))}
{pages[0].length < PAGE_SIZE &&
Array.from({ length: PAGE_SIZE - pages[0].length }, (_, slot) => (
<div
key={`empty-0-${slot}`}
className="board-project-list-slot board-project-list-slot--empty"
aria-hidden="true"
/>
))}
</div>
</div>
</div>
);
const sortableIds = sortableItemIds ?? items.map(getKey);
const wrappedBody =
sortableItemIds && items.length > 0 ? (
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
{listBody}
</SortableContext>
) : (
listBody
);
return (
<div
ref={setRefs}
className={`board-project-list board-project-list--2slots${paged ? ' is-paged' : ''}${className ? ` ${className}` : ''}`}
onContextMenu={onContextMenu}
>
{wrappedBody}
{paged && (
<div className="board-project-list-steps" aria-hidden="true">
{Array.from({ length: pageCount }, (_, index) => (
<span
key={index}
className={`board-project-list-step${index === pageIndex ? ' is-active' : ''}`}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -36,6 +36,18 @@ export function UsersIcon({ size = 16, className }: IconProps) {
);
}
/** lucide `calendar` */
export function CalendarIcon({ size = 16, className }: IconProps) {
return (
<LucideSvg size={size} className={className}>
<path d="M8 2v4" />
<path d="M16 2v4" />
<rect width="18" height="18" x="3" y="4" rx="2" />
<path d="M3 10h18" />
</LucideSvg>
);
}
/** 참고 사이트 lucide `plus` */
export function PlusIcon({ size = 16, className }: IconProps) {
return (

View File

@@ -1,19 +1,23 @@
import { createPortal } from 'react-dom';
import { useState } from 'react';
import { useDroppable } from '@dnd-kit/core';
import { useMemo, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import type { Task } from '../../types';
import { apiClient, getApiErrorMessage } from '../../lib/apiClient';
import { useHubConfig, type HubConfig, type HubScheduleItem } from '../../lib/hubConfig';
import { useRoutineCategoryMilestones } from '../../hooks/useRoutineCategoryMilestones';
import { quarterDateBounds, sortScheduleItems, todayIso } from '../../lib/hubSchedule';
import { HubScheduleCarousel } from './HubScheduleCarousel';
import { HubRoutineFocusPanel } from './HubRoutineFocusPanel';
import { ContextMenu } from '../common/ContextMenu';
import { routineCategoryShellPayload } from '../../lib/taskFormPayload';
interface HubColumnProps {
routineTasks: Task[];
quarter?: string;
onSelectRoutine?: (task: Task) => void;
}
import {
ROUTINE_CATEGORIES,
pickRoutineCategoryTask,
type RoutineCategory,
} from '../../lib/routineCategories';
type HubEditSection = 'slogan' | 'routine' | 'schedule';
type HubEditSection = 'schedule';
function ModalShell({
title,
@@ -53,96 +57,6 @@ function ModalShell({
);
}
function SloganEditModal({
config,
onSave,
onClose,
}: {
config: HubConfig;
onSave: (patch: Pick<HubConfig, 'sloganTitle' | 'sloganLines'>) => void;
onClose: () => void;
}) {
const [title, setTitle] = useState(config.sloganTitle);
const [lines, setLines] = useState(config.sloganLines.join('\n'));
return (
<ModalShell
title="분기 슬로건 수정"
onClose={onClose}
onSubmit={(e) => {
e.preventDefault();
onSave({ sloganTitle: title.trim() || '분기 슬로건', sloganLines: lines.split('\n') });
onClose();
}}
>
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-emerald-400"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"> ( Enter)</label>
<textarea
rows={5}
value={lines}
onChange={(e) => setLines(e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-emerald-400 resize-y"
/>
</div>
</ModalShell>
);
}
function RoutineEditModal({
config,
hasRoutineTasks,
onSave,
onClose,
}: {
config: HubConfig;
hasRoutineTasks: boolean;
onSave: (patch: Pick<HubConfig, 'routineLabels'>) => void;
onClose: () => void;
}) {
const [labels, setLabels] = useState(config.routineLabels.join(', '));
return (
<ModalShell
title="상시업무 수정"
onClose={onClose}
onSubmit={(e) => {
e.preventDefault();
onSave({
routineLabels: labels
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.slice(0, 6),
});
onClose();
}}
>
{hasRoutineTasks && (
<p className="text-sm text-gray-500 leading-relaxed">
. · .
</p>
)}
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"> ( , 6)</label>
<input
value={labels}
onChange={(e) => setLabels(e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-emerald-400"
placeholder="채용, 교육, 소통, 시설, 자산, 행정"
/>
</div>
</ModalShell>
);
}
function ScheduleEditModal({
config,
quarter,
@@ -243,101 +157,84 @@ function openSectionContextMenu(
setCtxMenu({ x: e.clientX, y: e.clientY, section });
}
export function HubColumn({ routineTasks, quarter = '2026-Q2', onSelectRoutine }: HubColumnProps) {
interface HubColumnProps {
routineTasks: Task[];
quarter?: string;
referenceDate?: Date;
onSelectRoutine?: (task: Task) => void;
onSelectRoutineMilestone?: (taskId: string, milestoneId: string) => void;
}
export function HubColumn({
routineTasks,
quarter = '2026-Q2',
referenceDate,
onSelectRoutine,
onSelectRoutineMilestone,
}: HubColumnProps) {
const { config, setConfig } = useHubConfig();
const queryClient = useQueryClient();
const [editSection, setEditSection] = useState<HubEditSection | null>(null);
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; section: HubEditSection } | null>(null);
const { setNodeRef: setRoutineDropRef, isOver: isRoutineOver } = useDroppable({
id: 'drop::routine::hub',
});
const [routineOpening, setRoutineOpening] = useState(false);
const [activeCategoryIndex, setActiveCategoryIndex] = useState(0);
const routineLabels =
routineTasks.length > 0
? routineTasks.slice(0, 6).map((t) => ({ label: t.title, task: t }))
: config.routineLabels.map((label) => ({ label, task: null as Task | null }));
const categoryFocus = useRoutineCategoryMilestones(routineTasks);
const hubSlots = useMemo((): { task: Task | null; label: RoutineCategory }[] => {
return ROUTINE_CATEGORIES.map((label) => ({
label,
task: pickRoutineCategoryTask(routineTasks, label),
}));
}, [routineTasks]);
const openRoutineCategory = async (label: RoutineCategory, task: Task | null) => {
if (routineOpening) return;
if (task) {
onSelectRoutine?.(task);
return;
}
setRoutineOpening(true);
try {
const { data: created } = await apiClient.post<Task>(
'/tasks',
routineCategoryShellPayload(label, quarter),
);
await queryClient.invalidateQueries({ queryKey: ['tasks'] });
onSelectRoutine?.(created);
} catch (err: unknown) {
alert(getApiErrorMessage(err, `"${label}" 업무를 준비하지 못했습니다.`));
} finally {
setRoutineOpening(false);
}
};
const handleFocusMilestoneClick = async (milestoneId: string) => {
const entry = categoryFocus[activeCategoryIndex];
const label = ROUTINE_CATEGORIES[activeCategoryIndex];
if (entry?.task) {
onSelectRoutineMilestone?.(entry.task.id, milestoneId);
return;
}
if (routineOpening) return;
setRoutineOpening(true);
try {
const { data: created } = await apiClient.post<Task>(
'/tasks',
routineCategoryShellPayload(label, quarter),
);
await queryClient.invalidateQueries({ queryKey: ['tasks'] });
onSelectRoutineMilestone?.(created.id, milestoneId);
} catch (err: unknown) {
alert(getApiErrorMessage(err, `"${label}" 업무를 준비하지 못했습니다.`));
} finally {
setRoutineOpening(false);
}
};
return (
<>
<div className="hub-column" id="hub-column">
<div
className="hub-box hub-box--message"
onContextMenu={(e) => openSectionContextMenu(e, 'slogan', setCtxMenu)}
>
<div className="hub-postit-stack">
<div className="hub-postit-sheet hub-postit-sheet--back" aria-hidden="true" />
<div className="hub-postit-sheet hub-postit-sheet--front">
<span className="hub-postit-pin" aria-hidden="true" />
<span className="hub-postit-fold" aria-hidden="true" />
<div className="hub-postit-content">
<div className="hub-postit-header">
<div className="hub-box-title-row">
<span className="hub-message-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 4v5" />
<path d="M12 15v5" />
<path d="M4 12h5" />
<path d="M15 12h5" />
<path d="M18 5v2" />
<path d="M17 6h2" />
<path d="M5 17v2" />
<path d="M4 18h2" />
</svg>
</span>
<div className="board-project-title">{config.sloganTitle}</div>
</div>
</div>
<div className="hub-postit-message">
{config.sloganLines.filter(Boolean).map((line, i) => (
<p key={i} className="board-project-desc">
{line}
</p>
))}
</div>
</div>
</div>
</div>
</div>
<div
className="hub-diamond-wrap"
id="hub-diamond-wrap"
onContextMenu={(e) => openSectionContextMenu(e, 'routine', setCtxMenu)}
>
<div
ref={setRoutineDropRef}
className={`hub-diamond ${isRoutineOver ? 'is-over' : ''}`}
id="hub-diamond"
>
<div className="hub-diamond-inner">
<div className="hub-diamond-head">
<span className="hub-diamond-icon" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="m17 2 4 4-4 4" />
<path d="M3 11v-1a4 4 0 0 1 4-4h14" />
<path d="m7 22-4-4 4-4" />
<path d="M21 13v1a4 4 0 0 1-4 4H3" />
</svg>
</span>
<div className="board-project-title"></div>
</div>
<div className="hub-diamond-divider" aria-hidden="true" />
<div className="hub-routine-grid">
{routineLabels.map(({ label, task }, i) => (
<button
key={task?.id ?? `label-${i}`}
type="button"
className="hub-routine-item"
onClick={() => task && onSelectRoutine?.(task)}
onContextMenu={(e) => e.stopPropagation()}
>
{label}
</button>
))}
</div>
</div>
</div>
</div>
<div
className="hub-box hub-box--focus"
onContextMenu={(e) => openSectionContextMenu(e, 'schedule', setCtxMenu)}
@@ -354,9 +251,53 @@ export function HubColumn({ routineTasks, quarter = '2026-Q2', onSelectRoutine }
</span>
<div className="board-project-title">{config.scheduleTitle}</div>
</div>
<HubScheduleCarousel items={config.scheduleItems} />
<HubScheduleCarousel items={config.scheduleItems} focusDate={referenceDate} />
</div>
</div>
<div className="hub-diamond-wrap" id="hub-diamond-wrap">
<div className="hub-diamond" id="hub-diamond">
<div className="hub-diamond-inner">
<div className="hub-diamond-head">
<span className="hub-diamond-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M21 21 v-5 h-5" />
</svg>
</span>
<div className="board-project-title"></div>
</div>
<div className="hub-diamond-divider" aria-hidden="true" />
<div className="hub-routine-grid">
{hubSlots.map(({ label, task }, i) => (
<button
key={task?.id ?? `cat-${i}`}
type="button"
className={`hub-routine-item${activeCategoryIndex === i ? ' is-active' : ''}`}
disabled={routineOpening}
onMouseEnter={() => setActiveCategoryIndex(i)}
onFocus={() => setActiveCategoryIndex(i)}
onClick={() => openRoutineCategory(label, task)}
onContextMenu={(e) => e.stopPropagation()}
>
{label}
</button>
))}
</div>
</div>
</div>
</div>
<div className="hub-box hub-box--message hub-box--routine-focus">
<HubRoutineFocusPanel
activeIndex={activeCategoryIndex}
onActiveIndexChange={setActiveCategoryIndex}
categories={categoryFocus}
onSelectMilestone={handleFocusMilestoneClick}
/>
</div>
</div>
{ctxMenu && (
@@ -364,31 +305,14 @@ export function HubColumn({ routineTasks, quarter = '2026-Q2', onSelectRoutine }
x={ctxMenu.x}
y={ctxMenu.y}
onClose={() => setCtxMenu(null)}
items={[
{
icon: '',
label: '수정',
onClick: () => setEditSection(ctxMenu.section),
},
]}
items={[{
icon: '✏',
label: '수정',
onClick: () => setEditSection(ctxMenu.section),
}]}
/>
)}
{editSection === 'slogan' && (
<SloganEditModal
config={config}
onSave={(patch) => setConfig((prev) => ({ ...prev, ...patch }))}
onClose={() => setEditSection(null)}
/>
)}
{editSection === 'routine' && (
<RoutineEditModal
config={config}
hasRoutineTasks={routineTasks.length > 0}
onSave={(patch) => setConfig((prev) => ({ ...prev, ...patch }))}
onClose={() => setEditSection(null)}
/>
)}
{editSection === 'schedule' && (
<ScheduleEditModal
config={config}

View File

@@ -0,0 +1,117 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { RoutineFocusMilestone } from '../../hooks/useRoutineCategoryMilestones';
const PAGE_SIZE = 3;
function chunkItems<T>(items: T[], size: number): T[][] {
const pages: T[][] = [];
for (let i = 0; i < items.length; i += size) {
pages.push(items.slice(i, i + size));
}
return pages;
}
type HubFocusTaskListProps = {
milestones: RoutineFocusMilestone[];
isLoading: boolean;
categoryKey: string;
onSelectMilestone?: (milestoneId: string) => void;
};
export function HubFocusTaskList({
milestones,
isLoading,
categoryKey,
onSelectMilestone,
}: HubFocusTaskListProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const pages = useMemo(() => chunkItems(milestones, PAGE_SIZE), [milestones]);
const pageCount = pages.length;
const paged = pageCount > 1;
const [pageIndex, setPageIndex] = useState(0);
useEffect(() => {
setPageIndex(0);
}, [categoryKey]);
useEffect(() => {
setPageIndex((prev) => Math.min(prev, Math.max(0, pageCount - 1)));
}, [pageCount]);
useEffect(() => {
const root = rootRef.current;
if (!root || !paged) return;
const onWheel = (event: WheelEvent) => {
if (Math.abs(event.deltaY) < 1) return;
event.preventDefault();
const direction = event.deltaY > 0 ? 1 : -1;
setPageIndex((prev) => Math.min(pageCount - 1, Math.max(0, prev + direction)));
};
root.addEventListener('wheel', onWheel, { passive: false });
return () => root.removeEventListener('wheel', onWheel);
}, [paged, pageCount]);
if (isLoading) {
return (
<div className="hub-focus-task-list">
<div className="hub-focus-task-viewport">
<ul className="hub-focus-task-page">
<li className="hub-focus-task-slot hub-focus-task-slot--message">
<span className="hub-focus-task-empty board-project-desc"> </span>
</li>
</ul>
</div>
</div>
);
}
if (milestones.length === 0) {
return (
<div className="hub-focus-task-list">
<div className="hub-focus-task-viewport">
<ul className="hub-focus-task-page">
<li className="hub-focus-task-slot hub-focus-task-slot--message">
<span className="hub-focus-task-empty board-project-desc"> </span>
</li>
</ul>
</div>
</div>
);
}
const pageItems = pages[pageIndex] ?? [];
return (
<div
ref={rootRef}
className={`hub-focus-task-list${paged ? ' is-paged' : ''}`}
>
<div className="hub-focus-task-viewport">
<ul className="hub-focus-task-page" aria-live="polite">
{pageItems.map((milestone) => (
<li key={milestone.id} className="hub-focus-task-slot">
<button
type="button"
className="hub-focus-task-item board-project-desc"
onClick={() => onSelectMilestone?.(milestone.id)}
>
{milestone.title}
</button>
</li>
))}
{pageItems.length < PAGE_SIZE &&
Array.from({ length: PAGE_SIZE - pageItems.length }, (_, slot) => (
<li
key={`empty-${pageIndex}-${slot}`}
className="hub-focus-task-slot hub-focus-task-slot--empty"
aria-hidden="true"
/>
))}
</ul>
</div>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { HubNavChevron } from '../common/HubNavChevron';
import { HubFocusTaskList } from './HubFocusTaskList';
import { ROUTINE_CATEGORIES } from '../../lib/routineCategories';
import type { RoutineCategoryFocus } from '../../hooks/useRoutineCategoryMilestones';
interface HubRoutineFocusPanelProps {
activeIndex: number;
onActiveIndexChange: (index: number) => void;
categories: RoutineCategoryFocus[];
onSelectMilestone?: (milestoneId: string) => void;
}
export function HubRoutineFocusPanel({
activeIndex,
onActiveIndexChange,
categories,
onSelectMilestone,
}: HubRoutineFocusPanelProps) {
const safeIndex = Math.max(0, Math.min(activeIndex, ROUTINE_CATEGORIES.length - 1));
const active = categories[safeIndex];
const canPrev = safeIndex > 0;
const canNext = safeIndex < ROUTINE_CATEGORIES.length - 1;
const stepPrev = () => {
if (canPrev) onActiveIndexChange(safeIndex - 1);
};
const stepNext = () => {
if (canNext) onActiveIndexChange(safeIndex + 1);
};
return (
<div className="hub-routine-focus">
<div className="hub-focus-dots" role="tablist" aria-label="상시업무 대분류">
{ROUTINE_CATEGORIES.map((label, index) => (
<button
key={label}
type="button"
role="tab"
aria-selected={index === safeIndex}
aria-label={label}
title={label}
className={`hub-focus-dot${index === safeIndex ? ' is-active' : ''}`}
onClick={() => onActiveIndexChange(index)}
/>
))}
</div>
<div className="hub-routine-focus-body">
<button
type="button"
className="hub-routine-focus-nav hub-routine-focus-nav--prev"
disabled={!canPrev}
onClick={stepPrev}
aria-label="이전 대분류"
>
<HubNavChevron direction="prev" />
</button>
<HubFocusTaskList
milestones={active?.milestones ?? []}
isLoading={!!active?.isLoading}
categoryKey={ROUTINE_CATEGORIES[safeIndex]}
onSelectMilestone={onSelectMilestone}
/>
<button
type="button"
className="hub-routine-focus-nav hub-routine-focus-nav--next"
disabled={!canNext}
onClick={stepNext}
aria-label="다음 대분류"
>
<HubNavChevron direction="next" />
</button>
</div>
</div>
);
}

View File

@@ -1,8 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { HubScheduleItem } from '../../lib/hubConfig';
import { HubNavChevron } from '../common/HubNavChevron';
import {
findScheduleIndexForToday,
formatScheduleDateLabel,
formatScheduleDateParts,
isSchedulePast,
isScheduleToday,
scheduleWindowStart,
@@ -13,18 +14,22 @@ const VISIBLE_COUNT = 3;
interface HubScheduleCarouselProps {
items: HubScheduleItem[];
/** 기준일 — 없으면 오늘 */
focusDate?: Date;
}
export function HubScheduleCarousel({ items }: HubScheduleCarouselProps) {
export function HubScheduleCarousel({ items, focusDate }: HubScheduleCarouselProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const focus = focusDate ?? new Date();
const sorted = useMemo(
() => sortScheduleItems(items.filter((i) => i.date && i.text.trim())),
[items],
);
const todayIndex = useMemo(() => findScheduleIndexForToday(sorted), [sorted]);
const focusIndex = useMemo(() => findScheduleIndexForToday(sorted, focus), [sorted, focus]);
const initialStart = useMemo(
() => scheduleWindowStart(sorted.length, todayIndex, VISIBLE_COUNT),
[sorted.length, todayIndex],
() => scheduleWindowStart(sorted.length, focusIndex, VISIBLE_COUNT),
[sorted.length, focusIndex],
);
const [startIndex, setStartIndex] = useState(initialStart);
@@ -32,6 +37,32 @@ export function HubScheduleCarousel({ items }: HubScheduleCarouselProps) {
setStartIndex(initialStart);
}, [initialStart, sorted.length]);
const maxStart = Math.max(0, sorted.length - VISIBLE_COUNT);
const paged = sorted.length > VISIBLE_COUNT;
const canPrev = startIndex > 0;
const canNext = startIndex < maxStart;
const stepPrev = () => setStartIndex((i) => Math.max(0, i - VISIBLE_COUNT));
const stepNext = () => setStartIndex((i) => Math.min(maxStart, i + VISIBLE_COUNT));
useEffect(() => {
const root = rootRef.current;
if (!root || !paged) return;
const onWheel = (event: WheelEvent) => {
if (Math.abs(event.deltaY) < 1) return;
event.preventDefault();
if (event.deltaY > 0) {
setStartIndex((i) => Math.min(maxStart, i + VISIBLE_COUNT));
} else {
setStartIndex((i) => Math.max(0, i - VISIBLE_COUNT));
}
};
root.addEventListener('wheel', onWheel, { passive: false });
return () => root.removeEventListener('wheel', onWheel);
}, [paged, maxStart]);
if (sorted.length === 0) {
return (
<div className="hub-schedule-viewport hub-schedule-viewport--empty">
@@ -40,56 +71,64 @@ export function HubScheduleCarousel({ items }: HubScheduleCarouselProps) {
);
}
const maxStart = Math.max(0, sorted.length - VISIBLE_COUNT);
const canPrev = startIndex > 0;
const canNext = startIndex < maxStart;
const stepPrev = () => setStartIndex((i) => Math.max(0, i - VISIBLE_COUNT));
const stepNext = () => setStartIndex((i) => Math.min(maxStart, i + VISIBLE_COUNT));
const visible = sorted.slice(startIndex, startIndex + VISIBLE_COUNT);
return (
<div className="hub-schedule-viewport">
<button
type="button"
className="hub-schedule-nav hub-schedule-nav--prev"
disabled={!canPrev}
onClick={stepPrev}
onContextMenu={(e) => e.stopPropagation()}
aria-label="이전 일정"
>
</button>
<div
ref={rootRef}
className={`hub-schedule-carousel${paged ? ' is-paged' : ''}`}
>
<div className="hub-schedule-viewport">
<ul className="hub-schedule-list hub-list">
{visible.map((item) => {
const past = isSchedulePast(item.date, focus);
const today = isScheduleToday(item.date, focus);
const dateParts = formatScheduleDateParts(item.date);
return (
<li
key={item.id}
className={`hub-schedule-item${past ? ' hub-schedule-item--past' : ''}${today ? ' hub-schedule-item--today' : ''}`}
>
<span className="hub-schedule-date board-project-desc">
{dateParts ? (
<>
<span className="hub-schedule-date-month">{dateParts.month}</span>
<span className="hub-schedule-date-day">{dateParts.day}</span>
</>
) : (
item.date
)}
</span>
<span className="board-project-desc">{item.text}</span>
</li>
);
})}
</ul>
</div>
<ul className="hub-schedule-list hub-list">
{visible.map((item) => {
const past = isSchedulePast(item.date);
const today = isScheduleToday(item.date);
return (
<li
key={item.id}
className={`hub-schedule-item${past ? ' hub-schedule-item--past' : ''}${today ? ' hub-schedule-item--today' : ''}`}
>
<span className="hub-schedule-date board-project-desc">
{formatScheduleDateLabel(item.date)}
</span>
<span className="board-project-desc">{item.text}</span>
</li>
);
})}
</ul>
{canPrev && (
<button
type="button"
className="hub-schedule-nav hub-schedule-nav--prev"
onClick={stepPrev}
onContextMenu={(e) => e.stopPropagation()}
aria-label="이전 일정"
>
<HubNavChevron direction="prev" />
</button>
)}
<button
type="button"
className="hub-schedule-nav hub-schedule-nav--next"
disabled={!canNext}
onClick={stepNext}
onContextMenu={(e) => e.stopPropagation()}
aria-label="다음 일정"
>
</button>
{canNext && (
<button
type="button"
className="hub-schedule-nav hub-schedule-nav--next"
onClick={stepNext}
onContextMenu={(e) => e.stopPropagation()}
aria-label="다음 일정"
>
<HubNavChevron direction="next" />
</button>
)}
</div>
);
}

View File

@@ -16,7 +16,7 @@ export function MemberTaskTooltip({
activeProjectId,
onProjectClick,
}: MemberTaskTooltipProps) {
const memberTasks = getMemberTasks(memberId, tasks);
const memberTasks = getMemberTasks(memberId, tasks, 'project');
if (memberTasks.length === 0) return null;
return (

View File

@@ -0,0 +1,188 @@
type DeptKey = 'hrm' | 'hrd' | 'ex' | 'ga';
interface ProjectItem {
name: string;
period: string;
summary: string;
progress: number;
}
interface DeptBlock {
key: DeptKey;
icon: string;
name: string;
code: string;
projects: ProjectItem[];
}
const DUMMY_DEPARTMENTS: DeptBlock[] = [
{
key: 'hrm',
icon: '👥',
name: '인사관리',
code: 'HRM',
projects: [
{
name: '상반기 채용 운영',
period: '2026.04 ~ 2026.06',
summary: '채용공고, 서류검토, 면접, 사후협의 진행',
progress: 75,
},
{
name: '평가제도 개선',
period: '2026.03 ~ 2026.07',
summary: '평가항목 정비, 부서 의견수렴, 피드백 방식 개선',
progress: 60,
},
],
},
{
key: 'hrd',
icon: '📚',
name: '인재육성',
code: 'HRD',
projects: [
{
name: '신규입사자 온보딩 프로그램',
period: '2026.04 ~ 2026.06',
summary: '신규입사자 교육, 조직 적응 프로그램 운영',
progress: 85,
},
{
name: '팀장 리더십 교육',
period: '2026.05 ~ 2026.06',
summary: '팀장 대상 리더십·코칭·피드백 교육 실시',
progress: 100,
},
],
},
{
key: 'ex',
icon: '🤝',
name: '조직문화',
code: 'EX',
projects: [
{
name: '조직문화 진단',
period: '2026.04 ~ 2026.05',
summary: '만족도 조사, 직원 의견수렴, 개선과제 도출',
progress: 100,
},
{
name: '복리후생제도 개선',
period: '2026.05 ~ 2026.08',
summary: '복지제도 이용현황 분석, 개선안 검토',
progress: 50,
},
],
},
{
key: 'ga',
icon: '🏢',
name: '총무관리',
code: 'GA',
projects: [
{
name: '사무공간 재배치',
period: '2026.04 ~ 2026.07',
summary: '좌석 재배치, 회의실 운영 개선',
progress: 70,
},
{
name: '안전·보안 점검 강화',
period: '2026.04 ~ 2026.06',
summary: '출입통제, 보안카드, 시설안전, 소방점검 관리',
progress: 85,
},
],
},
];
const HUB_MESSAGE = '인사·육성·문화·총무 개선과제 정상 추진';
const ROUTINE_ITEMS = ['채용 운영', '교육 운영', '직원 소통', '자산·시설 관리', '문서·행정 지원'];
const FOCUS_ITEMS = ['핵심직무 채용 완료', '복지제도 개선안 확정', '안전보안 점검 강화'];
function deptByKey(key: DeptKey): DeptBlock {
return DUMMY_DEPARTMENTS.find((d) => d.key === key)!;
}
function DeptCard({ dept }: { dept: DeptBlock }) {
return (
<section className={`qboard-dept-card qboard-dept-card--${dept.key}`}>
<div className="qboard-dept-head">
<div className={`qboard-dept-icon qboard-dept-icon--${dept.key}`}>{dept.icon}</div>
<h2 className="qboard-dept-name">
{dept.name}
<span>{dept.code}</span>
</h2>
</div>
{dept.projects.map((project) => (
<article key={project.name} className="qboard-project-block">
<div className="qboard-project-fields">
<p className="qboard-field-row">
<span className="qboard-field-label"></span>
<span className="qboard-field-value">{project.name}</span>
</p>
<p className="qboard-field-row">
<span className="qboard-field-label"></span>
<span className="qboard-field-value">{project.period}</span>
</p>
<p className="qboard-field-row">
<span className="qboard-field-label"> </span>
<span className="qboard-field-value">{project.summary}</span>
</p>
</div>
<div className={`qboard-progress-ring qboard-progress-ring--${dept.key}`}>{project.progress}%</div>
</article>
))}
</section>
);
}
function CenterHub() {
return (
<aside className="qboard-hub">
<div className="qboard-hub-box qboard-hub-box--message">
<div className="qboard-hub-box-title">📢 </div>
<p className="qboard-hub-box-text">{HUB_MESSAGE}</p>
</div>
<div className="qboard-hub-diamond">
<div className="qboard-hub-diamond-title"></div>
<ul>
{ROUTINE_ITEMS.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
<div className="qboard-hub-box qboard-hub-box--focus">
<div className="qboard-hub-box-title">🎯 </div>
<ul>
{FOCUS_ITEMS.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
</aside>
);
}
export function QuarterStatusBoard() {
return (
<div className="qboard-wrap">
<h1 className="qboard-page-title">2026 2 Dashboard</h1>
<p className="qboard-page-subtitle">(HRM) · (HRD) · (EX) · (GA)</p>
<div className="qboard-layout">
<DeptCard dept={deptByKey('hrm')} />
<DeptCard dept={deptByKey('hrd')} />
<CenterHub />
<DeptCard dept={deptByKey('ex')} />
<DeptCard dept={deptByKey('ga')} />
</div>
</div>
);
}

View File

@@ -5,6 +5,8 @@ import type { DraggableAttributes } from '@dnd-kit/core';
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
import type { Task } from '../../types';
import { DonutGauge } from './DonutGauge';
import { getProjectTitleStatusClass } from '../../lib/taskStatusVisual';
import { getVisibleIssueEntries } from '../../lib/taskIssues';
function fmtDate(iso: string | null | undefined): string {
if (!iso) return '';
@@ -125,6 +127,7 @@ export function TaskCard({
const dateRange = fmtDateRange(task);
const descLine = task.showDescription ? firstDescriptionLine(task.description) : '';
const showProgress = task.showProgress !== false;
const visibleIssues = getVisibleIssueEntries(task);
return (
<article
@@ -138,10 +141,12 @@ export function TaskCard({
>
<div className="project-sub-body">
<div className="project-fields">
<div className="project-sub-title">{task.title}</div>
<div className={`project-sub-title ${getProjectTitleStatusClass(task)}`}>
<span className="project-sub-title-text">{task.title}</span>
</div>
{dateRange && (
<div className="project-field">
<span className="project-field-label"> </span>
<span className="project-field-label"> </span>
<span className="project-field-value">{dateRange}</span>
</div>
)}
@@ -151,19 +156,31 @@ export function TaskCard({
<span className="project-field-value">{descLine}</span>
</div>
)}
{task.showIssue && task.issueNote && (
<div className="project-field">
<span className="project-field-label"></span>
<span className="project-field-value" style={{ color: '#c0392b' }}>
{task.issueNote}
{visibleIssues.map((entry) => (
<div key={entry.id} className="project-field project-field--issue">
<span className="project-issue-icon" aria-label="이슈" title="이슈">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 7v6" />
<path d="M12 17h.01" />
</svg>
</span>
<span className="project-field-value project-field-value--issue">{entry.text}</span>
</div>
))}
{visibleIssues.length === 0 && (
<div className="project-field project-field--issue project-field--issue-reserved" aria-hidden="true">
<span className="project-issue-icon" />
<span className="project-field-value project-field-value--issue">&nbsp;</span>
</div>
)}
</div>
{showProgress && (
<div className="progress-col">
<DonutGauge task={task} />
</div>
<>
<div className="project-sub-divider" aria-hidden="true" />
<div className="progress-col">
<DonutGauge task={task} />
</div>
</>
)}
</div>
</article>

View File

@@ -7,6 +7,7 @@ import type { TaskFormData } from '../common/TaskModal';
import type { Task, TeamMember } from '../../types';
import { isProjectTask, isRoutineTask } from '../../lib/taskType';
import { taskFormToApiPayload } from '../../lib/taskFormPayload';
import { invalidateTaskCaches } from '../../lib/taskQueryCache';
const STATUS_LABEL: Record<string, string> = {
IN_PROGRESS: '진행', REVIEW: '보류', TODO: '대기', DONE: '완료', CANCELLED: '취소',
@@ -43,7 +44,7 @@ export function TaskManager({ tasks, sectionOptions, quarter, teamMembers = [],
const patch = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
apiClient.patch(`/tasks/${id}`, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
onSuccess: (_data, { id }) => invalidateTaskCaches(queryClient, id),
});
const remove = useMutation({
mutationFn: (id: string) => apiClient.delete(`/tasks/${id}`),

View File

@@ -0,0 +1,59 @@
import { useEffect, useRef } from 'react';
import { renderAsync } from 'docx-preview';
import { useFileArrayBuffer } from '../../hooks/useFileBuffer';
import { FilePreviewFallback } from './FilePreviewFallback';
interface DocxPreviewProps {
fileId: string;
fileName: string;
}
export function DocxPreview({ fileId, fileName }: DocxPreviewProps) {
const bodyRef = useRef<HTMLDivElement>(null);
const styleRef = useRef<HTMLDivElement>(null);
const { buffer, loading, error } = useFileArrayBuffer(fileId);
useEffect(() => {
const body = bodyRef.current;
const style = styleRef.current;
if (!buffer || !body || !style) return;
body.innerHTML = '';
style.innerHTML = '';
let cancelled = false;
renderAsync(buffer, body, style, {
className: 'docx-preview',
inWrapper: true,
ignoreWidth: false,
ignoreHeight: false,
breakPages: true,
}).catch(() => {
if (!cancelled) {
body.innerHTML = '';
}
});
return () => {
cancelled = true;
};
}, [buffer, fileId]);
if (error || (!loading && !buffer)) {
return <FilePreviewFallback fileId={fileId} fileName={fileName} error={error} />;
}
if (loading) {
return <FilePreviewFallback fileId={fileId} fileName={fileName} loading />;
}
return (
<div className="h-full w-full min-h-0 overflow-auto bg-white">
<div ref={styleRef} className="docx-preview-styles" />
<div
ref={bodyRef}
className="docx-preview-body p-4 [&_.docx-wrapper]:mx-auto [&_.docx-wrapper]:bg-white [&_.docx-wrapper]:shadow-sm"
/>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { fileDownloadUrl } from '../../lib/apiClient';
interface FilePreviewFallbackProps {
fileId: string;
fileName: string;
loading?: boolean;
error?: string | null;
message?: string;
}
export function FilePreviewFallback({
fileId,
fileName,
loading,
error,
message,
}: FilePreviewFallbackProps) {
if (loading) {
return <p className="text-lg text-white/50"> </p>;
}
return (
<div className="flex max-w-lg flex-col items-center gap-3 px-6 text-center">
<p className="text-xl font-bold text-white/70">{fileName}</p>
<p className="text-base leading-relaxed text-white/45">
{error ?? message ?? '미리보기를 표시할 수 없습니다.'}
</p>
{error && (
<p className="text-sm text-white/35">
. .
</p>
)}
<a
href={fileDownloadUrl(fileId)}
className="rounded-lg bg-white/10 px-4 py-2 text-sm font-bold text-white/80 hover:bg-white/20"
>
</a>
</div>
);
}

View File

@@ -0,0 +1,70 @@
import { useEffect, useState } from 'react';
import { fileHwpPreviewUrl } from '../../lib/apiClient';
import { FilePreviewFallback } from './FilePreviewFallback';
interface HwpPreviewProps {
fileId: string;
fileName: string;
}
export function HwpPreview({ fileId, fileName }: HwpPreviewProps) {
const [html, setHtml] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
setHtml(null);
fetch(fileHwpPreviewUrl(fileId))
.then(async (res) => {
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { message?: string } | null;
throw new Error(body?.message ?? '한글 미리보기를 불러올 수 없습니다.');
}
return res.json() as Promise<{ html: string }>;
})
.then((data) => {
if (!cancelled) {
setHtml(data.html);
setLoading(false);
}
})
.catch((e) => {
if (!cancelled) {
setError(e instanceof Error ? e.message : '한글 미리보기 실패');
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [fileId]);
if (error) {
return (
<FilePreviewFallback
fileId={fileId}
fileName={fileName}
error={error}
message="한글 미리보기 변환에 실패했습니다. 다운로드 후 한/글에서 확인해 주세요."
/>
);
}
if (loading || !html) {
return <FilePreviewFallback fileId={fileId} fileName={fileName} loading />;
}
return (
<div className="h-full w-full min-h-0 overflow-auto bg-white">
<div
className="hwp-preview mx-auto max-w-4xl p-6 text-[15px] leading-relaxed text-slate-800 [&_img]:max-w-full [&_table]:my-3 [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-slate-200 [&_td]:px-2 [&_td]:py-1 [&_th]:border [&_th]:border-slate-300 [&_th]:bg-slate-50 [&_th]:px-2 [&_th]:py-1"
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
);
}

View File

@@ -0,0 +1,145 @@
import { useEffect, useMemo, useRef, useState, type MouseEvent } from 'react';
import {
fmtPeriodPickerLabel,
parseMilestonePeriods,
parsePeriodNoteLines,
pickLatestPeriodId,
sortPeriodsByRecent,
} from '../../lib/milestonePeriods';
import type { Milestone } from '../../types';
interface MilestoneContentListProps {
milestone: Pick<Milestone, 'id' | 'periodEntries' | 'startDate' | 'dueDate' | 'description'> | null | undefined;
emptyMessage: string;
onContextMenu?: (event: MouseEvent) => void;
}
function PeriodPicker({
periods,
selectedId,
onSelect,
}: {
periods: ReturnType<typeof parseMilestonePeriods>;
selectedId: string;
onSelect: (id: string) => void;
}) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const selected = periods.find((p) => p.id === selectedId) ?? periods[0];
const selectedIndex = periods.findIndex((p) => p.id === selected?.id);
const label = selected ? fmtPeriodPickerLabel(selected, selectedIndex >= 0 ? selectedIndex : 0) : '';
useEffect(() => {
if (!open) return;
const onPointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener('pointerdown', onPointerDown);
return () => document.removeEventListener('pointerdown', onPointerDown);
}, [open]);
if (!selected || !label) return null;
const canPick = periods.length > 1;
return (
<div className="milestone-content-period" ref={rootRef}>
<button
type="button"
className={`milestone-content-period__btn${open ? ' is-open' : ''}`}
aria-haspopup="listbox"
aria-expanded={open}
disabled={!canPick}
onClick={() => canPick && setOpen((v) => !v)}
title={canPick ? '다른 기간 업무내용 보기' : undefined}
>
<span className="milestone-content-period__label">{label}</span>
{canPick && (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden className="milestone-content-period__chev">
<path d="M2 3.5 L5 6.5 L8 3.5" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
</svg>
)}
</button>
{open && canPick && (
<ul className="milestone-content-period__menu" role="listbox">
{periods.map((period, index) => {
const isActive = period.id === selectedId;
return (
<li key={period.id} role="option" aria-selected={isActive}>
<button
type="button"
className={`milestone-content-period__option${isActive ? ' is-active' : ''}`}
onClick={() => {
onSelect(period.id);
setOpen(false);
}}
>
{fmtPeriodPickerLabel(period, index)}
</button>
</li>
);
})}
</ul>
)}
</div>
);
}
export function MilestoneContentList({
milestone,
emptyMessage,
onContextMenu,
}: MilestoneContentListProps) {
const periods = useMemo(
() => sortPeriodsByRecent(parseMilestonePeriods(milestone)),
[milestone],
);
const latestId = useMemo(() => pickLatestPeriodId(periods), [periods]);
const [selectedPeriodId, setSelectedPeriodId] = useState<string | null>(null);
useEffect(() => {
setSelectedPeriodId(latestId);
}, [milestone?.id, latestId]);
const activePeriod =
periods.find((p) => p.id === selectedPeriodId) ?? periods[0] ?? null;
const lines = parsePeriodNoteLines(activePeriod?.note);
return (
<>
<div className="detail-section-head">
<h3 className="detail-section-label"></h3>
{milestone && periods.length > 0 && activePeriod && (
<PeriodPicker
periods={periods}
selectedId={activePeriod.id}
onSelect={setSelectedPeriodId}
/>
)}
</div>
<ul
className="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1"
onContextMenu={onContextMenu}
>
{!milestone ? (
<li className="detail-body-muted">{emptyMessage}</li>
) : periods.length === 0 ? (
<li className="detail-body-muted">{emptyMessage}</li>
) : lines.length === 0 ? (
<li className="detail-body-muted"> .</li>
) : (
lines.map((line, index) => (
<li key={`${activePeriod!.id}-${index}`} className="flex gap-2" onContextMenu={onContextMenu}>
<span className="detail-body-text shrink-0 text-[#4a90d9]"></span>
<p className="detail-body-content min-w-0 flex-1 whitespace-pre-wrap break-words">{line}</p>
</li>
))
)}
</ul>
</>
);
}

View File

@@ -0,0 +1,237 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
buildMilestoneTimeline,
type TimelineMilestoneInput,
type TimelineRangeFallback,
} from '../../lib/milestoneTimeline';
interface MilestoneTimelineProps {
milestones: TimelineMilestoneInput[];
fallback?: TimelineRangeFallback;
selectedId?: string | null;
onSelect?: (id: string) => void;
title?: string;
subtitle?: string;
emptyMessage?: string;
className?: string;
preserveRowOrder?: boolean;
/** 상시업무 — 시작·종료일 없는 단계는 게이지 미표시 */
hideUndatedBars?: boolean;
}
export function MilestoneTimeline({
milestones,
fallback = {},
selectedId,
onSelect,
title = '업무별 타임라인',
subtitle,
emptyMessage = '기간을 설정한 단계만 표시됩니다.',
preserveRowOrder = false,
hideUndatedBars = true,
className = '',
}: MilestoneTimelineProps) {
const model = useMemo(
() => buildMilestoneTimeline(milestones, fallback, { preserveOrder: preserveRowOrder, hideUndatedBars }),
[milestones, fallback, preserveRowOrder, hideUndatedBars],
);
const chartRef = useRef<HTMLDivElement>(null);
const rowRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const measureRef = useRef<HTMLSpanElement>(null);
const [expandedBar, setExpandedBar] = useState<{
id: string;
widthPx: number;
leftPx: number;
} | null>(null);
const measureTitleWidth = (title: string) => {
const el = measureRef.current;
if (!el) return 0;
el.textContent = title;
return el.offsetWidth + 16;
};
const handleBarEnter = (
row: { id: string; title: string; leftPct: number; widthPct: number },
event: React.MouseEvent<HTMLButtonElement>,
) => {
const chart = chartRef.current;
if (!chart) return;
const btn = event.currentTarget;
const chartWidth = chart.clientWidth;
const leftPx = (row.leftPct / 100) * chartWidth;
const origWidthPx = btn.offsetWidth;
const neededWidthPx = measureTitleWidth(row.title);
if (neededWidthPx <= origWidthPx + 1) return;
const widthPx = Math.min(chartWidth, neededWidthPx);
const extra = widthPx - origWidthPx;
let expandedLeftPx = leftPx - extra / 2;
if (expandedLeftPx < 0) expandedLeftPx = 0;
if (expandedLeftPx + widthPx > chartWidth) expandedLeftPx = chartWidth - widthPx;
setExpandedBar({
id: row.id,
widthPx,
leftPx: expandedLeftPx,
});
};
useEffect(() => {
if (!selectedId) return;
const row = rowRefs.current.get(selectedId);
const chart = chartRef.current;
if (!row || !chart) return;
const rowRect = row.getBoundingClientRect();
const chartRect = chart.getBoundingClientRect();
if (rowRect.top >= chartRect.top && rowRect.bottom <= chartRect.bottom) return;
const delta =
rowRect.top < chartRect.top
? rowRect.top - chartRect.top
: rowRect.bottom - chartRect.bottom;
chart.scrollBy({ top: delta, behavior: 'smooth' });
}, [selectedId, model?.rows.length]);
const rangeSubtitle =
subtitle ?? (model ? `${model.rangeStartLabel} ~ ${model.rangeEndLabel}` : undefined);
const rowGroups = useMemo(() => {
if (!model) return [];
const groups: Array<{
milestoneId: string;
title: string;
progress: number;
segments: (typeof model.rows)[number][];
}> = [];
const indexByMilestone = new Map<string, number>();
for (const row of model.rows) {
const idx = indexByMilestone.get(row.milestoneId);
if (idx === undefined) {
indexByMilestone.set(row.milestoneId, groups.length);
groups.push({
milestoneId: row.milestoneId,
title: row.title,
progress: row.progress,
segments: [row],
});
} else {
groups[idx].segments.push(row);
}
}
return groups;
}, [model]);
return (
<footer className={`milestone-timeline ${className}`.trim()}>
<div className="milestone-timeline__head">
<span className="milestone-timeline__title">{title}</span>
{rangeSubtitle && (
<span className="milestone-timeline__subtitle truncate">{rangeSubtitle}</span>
)}
</div>
{!model ? (
<p className="milestone-timeline__empty">{emptyMessage}</p>
) : (
<div className="milestone-timeline__body">
<div className="milestone-timeline__ticks" aria-hidden="true">
{model.ticks.map((tick) => (
<span
key={`${tick.label}-${tick.leftPct}`}
className={`milestone-timeline__tick${tick.isToday ? ' is-today' : ''}`}
style={{ left: `${tick.leftPct}%` }}
title={tick.isToday ? `오늘 (${tick.label})` : undefined}
>
<span className="milestone-timeline__tick-label">{tick.label}</span>
</span>
))}
</div>
<div className="milestone-timeline__chart" ref={chartRef}>
<span ref={measureRef} className="milestone-timeline__measure" aria-hidden="true" />
<div className="milestone-timeline__grid" aria-hidden="true">
{model.ticks.map((tick) => (
<span
key={`grid-${tick.label}-${tick.leftPct}`}
className="milestone-timeline__grid-line"
style={{ left: `${tick.leftPct}%` }}
/>
))}
</div>
<div className="milestone-timeline__rows">
{rowGroups.map((group) => {
const isSelected = group.milestoneId === selectedId;
return (
<div
key={group.milestoneId}
className="milestone-timeline__row"
ref={(el) => {
if (el) rowRefs.current.set(group.milestoneId, el);
else rowRefs.current.delete(group.milestoneId);
}}
>
{group.segments.map((row) => {
const isExpanded = expandedBar?.id === row.id;
return (
<button
key={row.id}
type="button"
className={`milestone-timeline__bar ${isSelected ? 'is-selected' : ''} ${isExpanded ? 'is-expanded' : ''}`}
style={
isExpanded
? {
left: `${expandedBar.leftPx}px`,
width: `${expandedBar.widthPx}px`,
}
: {
left: `${row.leftPct}%`,
width: `${row.widthPct}%`,
}
}
aria-label={group.title}
title={group.title}
onMouseEnter={(e) => handleBarEnter({ ...row, title: group.title }, e)}
onMouseLeave={() => setExpandedBar(null)}
onClick={() => onSelect?.(group.milestoneId)}
>
<span className="milestone-timeline__bar-track" />
<span
className="milestone-timeline__bar-fill"
style={{ width: `${row.progress}%` }}
/>
<span className="milestone-timeline__bar-label-wrap" aria-hidden="true">
<span
className="milestone-timeline__bar-label milestone-timeline__bar-label--fill"
style={{ clipPath: `inset(0 ${100 - row.progress}% 0 0)` }}
>
{group.title}
</span>
<span
className="milestone-timeline__bar-label milestone-timeline__bar-label--track"
style={{ clipPath: `inset(0 0 0 ${row.progress}%)` }}
>
{group.title}
</span>
</span>
</button>
);
})}
</div>
);
})}
</div>
</div>
</div>
)}
</footer>
);
}

View File

@@ -0,0 +1,27 @@
import { useFileBlobUrl } from '../../hooks/useFileBuffer';
import { FilePreviewFallback } from './FilePreviewFallback';
interface PdfPreviewProps {
fileId: string;
fileName: string;
}
export function PdfPreview({ fileId, fileName }: PdfPreviewProps) {
const { blobUrl, loading, error } = useFileBlobUrl(fileId, 'application/pdf');
if (error || (!loading && !blobUrl)) {
return <FilePreviewFallback fileId={fileId} fileName={fileName} error={error} />;
}
if (loading || !blobUrl) {
return <FilePreviewFallback fileId={fileId} fileName={fileName} loading />;
}
return (
<iframe
src={blobUrl}
title={fileName}
className="h-full w-full border-0 bg-white"
/>
);
}

View File

@@ -0,0 +1,50 @@
import { useEffect, useRef } from 'react';
import { renderPptx } from 'pptx-react-renderer';
import { useFileArrayBuffer } from '../../hooks/useFileBuffer';
import { FilePreviewFallback } from './FilePreviewFallback';
interface PptxPreviewProps {
fileId: string;
fileName: string;
}
export function PptxPreview({ fileId, fileName }: PptxPreviewProps) {
const containerRef = useRef<HTMLDivElement>(null);
const { buffer, loading, error } = useFileArrayBuffer(fileId);
useEffect(() => {
const container = containerRef.current;
if (!buffer || !container) return;
container.innerHTML = '';
let cancelled = false;
renderPptx(buffer, {
container,
scale: 0.55,
showSlideNumbers: true,
theme: 'light',
}).catch(() => {
if (!cancelled) container.innerHTML = '';
});
return () => {
cancelled = true;
};
}, [buffer, fileId]);
if (error || (!loading && !buffer)) {
return <FilePreviewFallback fileId={fileId} fileName={fileName} error={error} />;
}
if (loading) {
return <FilePreviewFallback fileId={fileId} fileName={fileName} loading />;
}
return (
<div
ref={containerRef}
className="h-full w-full min-h-0 overflow-auto bg-slate-100 p-4 [&_.pptx-slide]:mx-auto [&_.pptx-slide]:mb-6"
/>
);
}

View File

@@ -1,9 +1,18 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { ExcelPreview } from './ExcelPreview';
import { fileDownloadUrl, fileViewUrl } from '../../lib/apiClient';
import { DocxPreview } from './DocxPreview';
import { PptxPreview } from './PptxPreview';
import { PdfPreview } from './PdfPreview';
import { HwpPreview } from './HwpPreview';
import { FilePreviewFallback } from './FilePreviewFallback';
import { openLinkOnRightMonitor } from '../../lib/dualMonitor';
import { fileDisplayName, isExcelFile, isVideoFile } from '../../lib/fileDisplay';
import {
fileDisplayName,
getFilePreviewKind,
resolvePreviewMime,
} from '../../lib/fileDisplay';
import { useFileBlobUrl } from '../../hooks/useFileBuffer';
import type { FileRecord, MilestoneLink } from '../../types';
interface ResultPreviewProps {
@@ -12,32 +21,67 @@ interface ResultPreviewProps {
hasSelectedStage: boolean;
}
function FileMissingNotice({ label, fileId }: { label: string; fileId: string }) {
function ImagePreview({
fileId,
fileName,
mime,
zoom,
}: {
fileId: string;
fileName: string;
mime: string;
zoom: number;
}) {
const { blobUrl, loading, error } = useFileBlobUrl(fileId, mime);
if (error || (!loading && !blobUrl)) {
return <FilePreviewFallback fileId={fileId} fileName={fileName} error={error} />;
}
if (loading || !blobUrl) {
return <FilePreviewFallback fileId={fileId} fileName={fileName} loading />;
}
return (
<div className="flex max-w-lg flex-col items-center gap-3 px-6 text-center">
<p className="text-xl font-bold text-white/70">{label}</p>
<p className="text-base leading-relaxed text-white/45">
.
<br />
· .
<br />
.
</p>
<a
href={fileDownloadUrl(fileId)}
className="rounded-lg bg-white/10 px-4 py-2 text-sm font-bold text-white/80 hover:bg-white/20"
>
</a>
</div>
<img
src={blobUrl}
alt={fileName}
className="max-h-full max-w-full object-contain transition-transform duration-150"
style={{ transform: `scale(${zoom})` }}
draggable={false}
/>
);
}
function VideoPreview({ fileId, fileName, mime }: { fileId: string; fileName: string; mime: string }) {
const { blobUrl, loading, error } = useFileBlobUrl(fileId, mime);
if (error || (!loading && !blobUrl)) {
return <FilePreviewFallback fileId={fileId} fileName={fileName} error={error} />;
}
if (loading || !blobUrl) {
return <FilePreviewFallback fileId={fileId} fileName={fileName} loading />;
}
return <video src={blobUrl} controls className="max-h-full max-w-full" title={fileName} />;
}
function TextPreview({ fileId, fileName }: { fileId: string; fileName: string }) {
const { blobUrl, loading, error } = useFileBlobUrl(fileId, 'text/plain;charset=utf-8');
if (error || (!loading && !blobUrl)) {
return <FilePreviewFallback fileId={fileId} fileName={fileName} error={error} />;
}
if (loading || !blobUrl) {
return <FilePreviewFallback fileId={fileId} fileName={fileName} loading />;
}
return <iframe src={blobUrl} title={fileName} className="h-full w-full border-0 bg-white" />;
}
export function ResultPreview({ files, links, hasSelectedStage }: ResultPreviewProps) {
const [fileId, setFileId] = useState<string | null>(null);
const [zoom, setZoom] = useState(1);
const [fullscreen, setFullscreen] = useState(false);
const [fileMissing, setFileMissing] = useState(false);
useEffect(() => {
if (files.length > 0) {
@@ -49,29 +93,11 @@ export function ResultPreview({ files, links, hasSelectedStage }: ResultPreviewP
}, [files]);
const activeFile = fileId ? files.find((f) => f.id === fileId) ?? null : null;
const previewKind = activeFile ? getFilePreviewKind(activeFile) : null;
const previewMime = activeFile ? resolvePreviewMime(activeFile) : '';
useEffect(() => {
if (!activeFile || isExcelFile(activeFile)) {
setFileMissing(false);
return;
}
let cancelled = false;
setFileMissing(false);
fetch(fileViewUrl(activeFile.id), { method: 'HEAD' })
.then((res) => {
if (!cancelled) setFileMissing(!res.ok);
})
.catch(() => {
if (!cancelled) setFileMissing(true);
});
return () => {
cancelled = true;
};
}, [activeFile?.id, activeFile?.mimetype]);
const fileIndex = activeFile ? files.findIndex((f) => f.id === activeFile.id) : -1;
const isImage = activeFile?.mimetype.includes('image') ?? false;
const isVideo = activeFile ? isVideoFile(activeFile) : false;
const isExcel = activeFile ? isExcelFile(activeFile) : false;
const isImage = previewKind === 'image';
const goFile = useCallback(
(delta: number) => {
@@ -89,43 +115,42 @@ export function ResultPreview({ files, links, hasSelectedStage }: ResultPreviewP
const headerTitle = activeFile ? fileDisplayName(activeFile) : '결과물 프리뷰';
const renderContent = () => {
if (activeFile) {
const label = fileDisplayName(activeFile);
if (isExcel) {
const previewContent = useMemo(() => {
if (!activeFile || !previewKind) return null;
const label = fileDisplayName(activeFile);
switch (previewKind) {
case 'excel':
return <ExcelPreview fileId={activeFile.id} fileName={label} />;
}
if (fileMissing) {
return <FileMissingNotice label={label} fileId={activeFile.id} />;
}
const src = fileViewUrl(activeFile.id);
if (isImage) {
case 'image':
return (
<img
src={src}
alt={label}
className="max-h-full max-w-full object-contain transition-transform duration-150"
style={{ transform: `scale(${zoom})` }}
draggable={false}
onError={() => setFileMissing(true)}
<ImagePreview fileId={activeFile.id} fileName={label} mime={previewMime} zoom={zoom} />
);
case 'video':
return <VideoPreview fileId={activeFile.id} fileName={label} mime={previewMime} />;
case 'pdf':
return <PdfPreview fileId={activeFile.id} fileName={label} />;
case 'docx':
return <DocxPreview fileId={activeFile.id} fileName={label} />;
case 'pptx':
return <PptxPreview fileId={activeFile.id} fileName={label} />;
case 'text':
return <TextPreview fileId={activeFile.id} fileName={label} />;
case 'hwp':
return <HwpPreview fileId={activeFile.id} fileName={label} />;
default:
return (
<FilePreviewFallback
fileId={activeFile.id}
fileName={label}
message="이 형식은 미리보기를 지원하지 않습니다. 다운로드 후 확인해 주세요."
/>
);
}
if (isVideo) {
return (
<video
src={src}
controls
className="max-h-full max-w-full"
title={label}
onError={() => setFileMissing(true)}
/>
);
}
return (
<iframe src={src} title={label} className="h-full w-full border-0 bg-white" />
);
}
}, [activeFile, previewKind, previewMime, zoom]);
const renderContent = () => {
if (activeFile && previewContent) return previewContent;
if (links.length > 0) {
return (
<p className="px-6 text-center text-xl text-white/40">

View File

@@ -0,0 +1,740 @@
import { useEffect, useMemo, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient, getApiErrorMessage } from '../../lib/apiClient';
import { ContextMenu } from '../common/ContextMenu';
import {
StageModal,
parseMilestoneLinks,
type StageFileSavePayload,
type StageFormData,
} from './StageModal';
import {
ROUTINE_CATEGORIES,
getRoutineCategory,
pickRoutineCategoryTask,
type RoutineCategory,
} from '../../lib/routineCategories';
import { routineCategoryShellPayload } from '../../lib/taskFormPayload';
import { isRoutineTask } from '../../lib/taskType';
import { getMilestoneSubtitle } from '../../lib/milestoneSubtitle';
import { sortFilesByOrder } from '../../lib/fileDisplay';
import { useTeamMembers } from '../../hooks/useTeamMembers';
import { ResultPreview } from './ResultPreview';
import { MilestoneTimeline } from './MilestoneTimeline';
import { MilestoneContentList } from './MilestoneContentList';
import { taskTimelineFallback } from '../../lib/milestoneTimeline';
import { serializePeriodEntries } from '../../lib/milestonePeriods';
import type { Task, Milestone, FileRecord } from '../../types';
type TaskWithRelations = Task & {
files: FileRecord[];
milestones: Milestone[];
};
function LeftSection({ children }: { children: React.ReactNode }) {
return (
<section className="flex min-h-0 flex-col overflow-hidden border-b border-[#e8edf2] px-4 py-3 last:border-b-0">
{children}
</section>
);
}
export function RoutineDetailView({
task: initialTask,
initialStageId,
}: {
task: TaskWithRelations;
initialStageId?: string | null;
}) {
const qc = useQueryClient();
const { data: teamMembers = [] } = useTeamMembers();
const [activeTaskId, setActiveTaskId] = useState(initialTask.id);
const [selectedStageId, setSelectedStageId] = useState<string | null>(initialStageId ?? null);
const [stageModal, setStageModal] = useState<{ mode: 'add' | 'edit'; milestone?: Milestone } | null>(null);
const [stageSaving, setStageSaving] = useState(false);
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; stageId: string } | null>(null);
const [tabSwitching, setTabSwitching] = useState(false);
useEffect(() => {
setActiveTaskId(initialTask.id);
}, [initialTask.id]);
const { data: activeTask = initialTask } = useQuery({
queryKey: ['task', activeTaskId],
queryFn: async () => {
const { data } = await apiClient.get<TaskWithRelations>(`/tasks/${activeTaskId}`);
return data;
},
initialData: activeTaskId === initialTask.id ? initialTask : undefined,
staleTime: 10_000,
retry: 2,
});
const { data: quarterTasks = [] } = useQuery({
queryKey: ['tasks', { quarter: activeTask.quarter }],
queryFn: async () => {
const { data } = await apiClient.get<Task[]>('/tasks', {
params: { quarter: activeTask.quarter },
});
return data;
},
staleTime: 30_000,
retry: 2,
});
const tabTasks = useMemo(() => {
const routines = quarterTasks.filter((t) => isRoutineTask(t.taskType));
return ROUTINE_CATEGORIES.map((label) => ({
label,
task: pickRoutineCategoryTask(routines, label),
}));
}, [quarterTasks]);
const activeCategory = getRoutineCategory(activeTask) ?? '채용 운영';
const handleTabClick = async (label: RoutineCategory, tabTask: Task | null) => {
if (tabSwitching) return;
if (tabTask) {
setActiveTaskId(tabTask.id);
return;
}
setTabSwitching(true);
try {
const { data: created } = await apiClient.post<Task>(
'/tasks',
routineCategoryShellPayload(label, activeTask.quarter),
);
await qc.invalidateQueries({ queryKey: ['tasks'] });
setActiveTaskId(created.id);
} catch (err: unknown) {
alert(getApiErrorMessage(err, `"${label}" 업무를 준비하지 못했습니다.`));
} finally {
setTabSwitching(false);
}
};
const milestones = useMemo(
() => [...(activeTask.milestones ?? [])].sort((a, b) => a.order - b.order),
[activeTask.milestones],
);
useEffect(() => {
if (initialStageId && milestones.some((m) => m.id === initialStageId)) {
setSelectedStageId(initialStageId);
}
}, [initialTask.id, initialStageId, milestones]);
useEffect(() => {
if (!selectedStageId || !milestones.some((m) => m.id === selectedStageId)) {
setSelectedStageId(milestones[0]?.id ?? null);
}
}, [activeTaskId, milestones, selectedStageId]);
const selectedStage = milestones.find((m) => m.id === selectedStageId) ?? null;
const stageFiles = useMemo(
() =>
sortFilesByOrder(
selectedStageId ? (activeTask.files ?? []).filter((f) => f.milestoneId === selectedStageId) : [],
),
[activeTask.files, selectedStageId],
);
const stageLinks = useMemo(
() => (selectedStage ? parseMilestoneLinks(selectedStage.links) : []),
[selectedStage],
);
const deleteStage = useMutation({
mutationFn: (id: string) => apiClient.delete(`/milestones/item/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['task', activeTaskId] }),
});
const uploadFiles = async (milestoneId: string, filePayload: StageFileSavePayload['uploads']) => {
for (const item of filePayload) {
const form = new FormData();
form.append('file', item.file);
form.append('milestoneId', milestoneId);
form.append('sortOrder', String(item.sortOrder));
if (item.displayName.trim()) {
form.append('displayName', item.displayName.trim());
}
await apiClient.post(`/files/upload/${activeTaskId}`, form);
}
};
const handleStageSave = async (data: StageFormData, filePayload: StageFileSavePayload) => {
setStageSaving(true);
try {
const payload = {
title: data.title.trim(),
subtitle: data.subtitle.trim() || null,
periodEntries: serializePeriodEntries(data.periodEntries),
progress: data.progress,
links: data.links,
pmMemberId: data.pmMemberId || null,
assigneeMemberIds: data.assigneeMemberIds,
};
let milestoneId: string;
if (stageModal?.mode === 'add') {
const { data: created } = await apiClient.post<Milestone>(
`/milestones/${activeTaskId}`,
payload,
);
milestoneId = created.id;
setSelectedStageId(created.id);
} else if (stageModal?.milestone) {
const { data: updated } = await apiClient.patch<Milestone>(
`/milestones/item/${stageModal.milestone.id}`,
payload,
);
milestoneId = updated.id;
setSelectedStageId(updated.id);
} else {
return;
}
try {
for (const id of filePayload.deletedFileIds) {
await apiClient.delete(`/files/${id}`);
}
for (const rep of filePayload.replacements) {
const form = new FormData();
form.append('file', rep.file);
await apiClient.post(`/files/${rep.id}/replace`, form);
}
for (const edit of filePayload.existingEdits) {
const original = activeTask.files?.find((f) => f.id === edit.id);
if (!original) continue;
const prevName = (original.displayName ?? '').trim();
const nextName = edit.displayName.trim();
const prevOrder = original.sortOrder ?? 0;
if (nextName !== prevName || edit.sortOrder !== prevOrder) {
await apiClient.patch(`/files/${edit.id}`, {
displayName: nextName || null,
sortOrder: edit.sortOrder,
});
}
}
if (filePayload.uploads.length > 0) {
await uploadFiles(milestoneId, filePayload.uploads);
}
} catch (err: unknown) {
alert(`단계는 저장됐지만 ${getApiErrorMessage(err, '파일 처리에 실패했습니다.')}`);
}
await qc.invalidateQueries({ queryKey: ['task', activeTaskId] });
setStageModal(null);
} catch (err: unknown) {
alert(getApiErrorMessage(err, '단계 저장에 실패했습니다.'));
} finally {
setStageSaving(false);
}
};
return (
<div className="detail-page-shell flex h-full min-h-0 flex-col overflow-hidden">
<header className="detail-page-header detail-page-header--routine shrink-0">
<div className="detail-page-header__tabs">
{tabTasks.map(({ label, task: tabTask }) => {
const isActive = activeCategory === label;
return (
<button
key={label}
type="button"
disabled={tabSwitching}
className={`detail-page-header__tab ${isActive ? 'is-active' : ''}`}
onClick={() => handleTabClick(label, tabTask)}
>
{label}
</button>
);
})}
</div>
</header>
<div className="grid min-h-0 flex-1 grid-cols-[1fr_3fr] grid-rows-1">
<aside className="detail-aside grid h-full min-h-0 grid-rows-[2fr_1fr] overflow-hidden">
<LeftSection>
<div className="detail-section-head">
<h3 className="detail-section-label"></h3>
<button
type="button"
title="단계 추가"
onClick={() => setStageModal({ mode: 'add' })}
className="detail-add-btn"
>
+
</button>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto pr-1">
{milestones.length === 0 ? (
<p className="detail-body-muted">+ .</p>
) : (
milestones.map((stage) => {
const isSelected = stage.id === selectedStageId;
const subtitle = getMilestoneSubtitle(stage);
return (
<button
key={stage.id}
type="button"
className={`detail-stage-card shrink-0 rounded-lg border px-3 py-2 text-left transition-colors ${
isSelected
? 'is-selected ring-1 ring-[#7eb3e8]'
: 'hover:border-slate-300 hover:bg-white/80'
}`}
onClick={() => setSelectedStageId(stage.id)}
onContextMenu={(e) => {
e.preventDefault();
setSelectedStageId(stage.id);
setCtxMenu({ x: e.clientX, y: e.clientY, stageId: stage.id });
}}
>
<p className="detail-body-title truncate">{stage.title}</p>
{subtitle ? (
<p className="detail-body-caption mt-0.5 truncate">{subtitle}</p>
) : null}
</button>
);
})
)}
</div>
</LeftSection>
<LeftSection>
<MilestoneContentList
milestone={selectedStage}
emptyMessage={
selectedStage
? '수행 기간에 업무내용을 입력하세요.'
: '단계를 선택하세요.'
}
/>
</LeftSection>
</aside>
<div className="flex h-full min-h-0 min-w-0 flex-col">
<ResultPreview
files={stageFiles}
links={stageLinks}
hasSelectedStage={!!selectedStage}
/>
<MilestoneTimeline
milestones={milestones}
fallback={taskTimelineFallback(activeTask)}
selectedId={selectedStageId}
onSelect={setSelectedStageId}
preserveRowOrder
emptyMessage="기간을 설정한 업무명만 타임라인에 표시됩니다."
/>
</div>
</div>
{stageModal && (
<StageModal
variant="routine"
mode={stageModal.mode}
milestone={stageModal.milestone}
existingFiles={
stageModal.milestone
? sortFilesByOrder(
(activeTask.files ?? []).filter((f) => f.milestoneId === stageModal.milestone!.id),
)
: []
}
teamMembers={teamMembers}
saving={stageSaving}
onClose={() => setStageModal(null)}
onSave={handleStageSave}
/>
)}
{ctxMenu && (
<ContextMenu
x={ctxMenu.x}
y={ctxMenu.y}
onClose={() => setCtxMenu(null)}
items={[
{
icon: '✏',
label: '수정',
onClick: () => {
const m = milestones.find((s) => s.id === ctxMenu.stageId);
if (m) setStageModal({ mode: 'edit', milestone: m });
},
},
{
icon: '🗑',
label: '삭제',
danger: true,
onClick: () => {
if (window.confirm('이 단계를 정말 삭제하시겠습니까?')) {
deleteStage.mutate(ctxMenu.stageId);
}
},
},
]}
/>
)}
</div>
);
}
export function RoutineDetailShell({
task,
initialStageId,
}: {
task: TaskWithRelations;
initialStageId?: string | null;
}) {
return <RoutineDetailView task={task} initialStageId={initialStageId} />;
}

View File

@@ -1,15 +1,22 @@
import { useState, useRef, useMemo, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { sortFilesByOrder } from '../../lib/fileDisplay';
import type { FileRecord, Milestone, MilestoneLink } from '../../types';
import { decodeRoutineStageDescription } from '../../lib/routineMilestone';
import {
newPeriodEntry,
parseMilestonePeriods,
type MilestonePeriodEntry,
} from '../../lib/milestonePeriods';
import type { FileRecord, Milestone, MilestoneLink, TeamMember } from '../../types';
export interface StageFormData {
title: string;
startDate: string;
dueDate: string;
subtitle: string;
periodEntries: MilestonePeriodEntry[];
progress: number;
description: string;
links: MilestoneLink[];
pmMemberId: string;
assigneeMemberIds: string[];
}
export interface PendingFileUpload {
@@ -39,8 +46,10 @@ export interface StageFileSavePayload {
interface StageModalProps {
mode: 'add' | 'edit';
variant?: 'project' | 'routine';
milestone?: Milestone;
existingFiles?: FileRecord[];
teamMembers?: TeamMember[];
onSave: (data: StageFormData, files: StageFileSavePayload) => Promise<void>;
onClose: () => void;
saving?: boolean;
@@ -55,11 +64,6 @@ type EditTarget =
| { type: 'link'; index: number }
| null;
function toDateInput(iso: string | null | undefined) {
if (!iso) return '';
return new Date(iso).toISOString().slice(0, 10);
}
function parseLinks(raw: string | null | undefined): MilestoneLink[] {
if (!raw) return [];
try {
@@ -107,21 +111,30 @@ let pendingKeySeq = 0;
export function StageModal({
mode,
variant = 'project',
milestone,
existingFiles = [],
teamMembers = [],
onSave,
onClose,
saving,
}: StageModalProps) {
const isRoutine = variant === 'routine';
const sortedExisting = useMemo(() => sortFilesByOrder(existingFiles), [existingFiles]);
const [form, setForm] = useState<StageFormData>({
title: milestone?.title ?? '',
startDate: toDateInput(milestone?.startDate),
dueDate: toDateInput(milestone?.dueDate),
progress: milestone?.progress ?? 0,
description: milestone?.description ?? '',
links: parseLinks(milestone?.links),
const [form, setForm] = useState<StageFormData>(() => {
const legacyOverview = isRoutine
? decodeRoutineStageDescription(milestone?.description).overview
: '';
return {
title: milestone?.title ?? '',
subtitle: milestone?.subtitle?.trim() ?? legacyOverview,
periodEntries: parseMilestonePeriods(milestone),
progress: milestone?.progress ?? 0,
links: parseLinks(milestone?.links),
pmMemberId: milestone?.pmMemberId ?? milestone?.pmMember?.id ?? '',
assigneeMemberIds: milestone?.assigneeMembers?.map((m) => m.id) ?? [],
};
});
const [fileRows, setFileRows] = useState<FileRow[]>([]);
@@ -163,6 +176,49 @@ export function StageModal({
const set = <K extends keyof StageFormData>(key: K, value: StageFormData[K]) =>
setForm((prev) => ({ ...prev, [key]: value }));
const toggleAssignee = (memberId: string) => {
setForm((prev) => {
const has = prev.assigneeMemberIds.includes(memberId);
return {
...prev,
assigneeMemberIds: has
? prev.assigneeMemberIds.filter((id) => id !== memberId)
: [...prev.assigneeMemberIds, memberId],
};
});
};
const addPeriodEntry = () => {
setForm((prev) => ({
...prev,
periodEntries: [...prev.periodEntries, newPeriodEntry()],
}));
};
const updatePeriodEntry = (id: string, patch: Partial<MilestonePeriodEntry>) => {
setForm((prev) => ({
...prev,
periodEntries: prev.periodEntries.map((entry) =>
entry.id === id ? { ...entry, ...patch } : entry,
),
}));
};
const removePeriodEntry = (id: string) => {
setForm((prev) => ({
...prev,
periodEntries: prev.periodEntries.filter((entry) => entry.id !== id),
}));
};
const modalTitle = isRoutine
? mode === 'add'
? '업무 단계 추가'
: '업무 단계 수정'
: mode === 'add'
? '업무 일정 추가'
: '업무 일정 수정';
const clearEdit = () => {
setEditTarget(null);
setEditDisplayName('');
@@ -345,9 +401,7 @@ export function StageModal({
onSubmit={handleSubmit}
>
<div className="shrink-0 border-b border-slate-100 px-6 py-4">
<h2 className="text-xl font-black text-slate-800">
{mode === 'add' ? '업무 단계 추가' : '업무 단계 수정'}
</h2>
<h2 className="text-xl font-black text-slate-800">{modalTitle}</h2>
</div>
<div className="space-y-4 overflow-y-auto px-6 py-4">
@@ -362,6 +416,18 @@ export function StageModal({
/>
</label>
{isRoutine && (
<label className="block">
<span className="mb-1 block text-sm font-bold text-slate-500"></span>
<input
value={form.subtitle}
onChange={(e) => set('subtitle', e.target.value)}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-base focus:border-emerald-400 focus:outline-none"
placeholder="업무명 아래 표시 (선택)"
/>
</label>
)}
<label className="block">
<span className="mb-1 flex items-center justify-between text-sm font-bold text-slate-500">
<span></span>
@@ -378,37 +444,120 @@ export function StageModal({
/>
</label>
<div className="grid grid-cols-2 gap-3">
<label className="block">
<span className="mb-1 block text-sm font-bold text-slate-500"></span>
<input
type="date"
value={form.startDate}
onChange={(e) => set('startDate', e.target.value)}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-emerald-400 focus:outline-none"
/>
</label>
<label className="block">
<span className="mb-1 block text-sm font-bold text-slate-500"></span>
<input
type="date"
value={form.dueDate}
onChange={(e) => set('dueDate', e.target.value)}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-emerald-400 focus:outline-none"
/>
</label>
<div>
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-bold text-slate-500"> </span>
<button
type="button"
onClick={addPeriodEntry}
className="rounded-lg px-2 py-1 text-xs font-bold text-emerald-600 transition hover:bg-emerald-50 hover:text-emerald-700"
>
+
</button>
</div>
{form.periodEntries.length === 0 ? (
<p className="rounded-lg border border-dashed border-slate-200 px-4 py-3 text-center text-sm text-slate-400">
. · .
</p>
) : (
<div className="space-y-2">
{form.periodEntries.map((entry, index) => (
<div
key={entry.id}
className="space-y-2 rounded-xl border border-slate-200 bg-slate-50/60 p-3"
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-bold text-slate-500"> {index + 1}</span>
<button
type="button"
onClick={() => removePeriodEntry(entry.id)}
className="rounded-lg px-2 py-1 text-xs font-bold text-red-500 transition hover:bg-red-50 hover:text-red-600"
>
</button>
</div>
<div className="grid grid-cols-2 gap-2">
<label className="block">
<span className="mb-1 block text-xs font-semibold text-slate-500"></span>
<input
type="date"
value={entry.startDate}
onChange={(e) => updatePeriodEntry(entry.id, { startDate: e.target.value })}
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm focus:border-emerald-400 focus:outline-none"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-semibold text-slate-500"></span>
<input
type="date"
value={entry.dueDate}
onChange={(e) => updatePeriodEntry(entry.id, { dueDate: e.target.value })}
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm focus:border-emerald-400 focus:outline-none"
/>
</label>
</div>
<label className="block">
<span className="mb-1 block text-xs font-semibold text-slate-500"></span>
<textarea
value={entry.note}
onChange={(e) => updatePeriodEntry(entry.id, { note: e.target.value })}
rows={2}
className="w-full resize-none rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm focus:border-emerald-400 focus:outline-none"
placeholder="이 기간에 수행한 내용"
/>
</label>
</div>
))}
</div>
)}
</div>
<label className="block">
<span className="mb-1 block text-sm font-bold text-slate-500"></span>
<textarea
value={form.description}
onChange={(e) => set('description', e.target.value)}
rows={4}
className="w-full resize-none rounded-lg border border-slate-200 px-3 py-2 text-base focus:border-emerald-400 focus:outline-none"
placeholder="단계별 업무 내용 (줄바꿈 가능)"
/>
</label>
{isRoutine && teamMembers.length > 0 && (
<div className="space-y-3 rounded-xl border border-emerald-100 bg-emerald-50/40 p-4">
<div>
<label className="mb-1 block text-sm font-bold text-slate-500">PM</label>
<select
value={form.pmMemberId}
onChange={(e) => set('pmMemberId', e.target.value)}
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none focus:border-emerald-400"
>
<option value=""> </option>
{teamMembers.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
{m.rank ? ` · ${m.rank}` : ''}
</option>
))}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-bold text-slate-500"> ( )</label>
<div className="flex flex-wrap gap-2">
{teamMembers.map((m) => {
const checked = form.assigneeMemberIds.includes(m.id);
return (
<label
key={m.id}
className={`inline-flex cursor-pointer select-none items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-semibold transition ${
checked
? 'border-emerald-600 bg-emerald-600 text-white'
: 'border-slate-200 bg-white text-slate-600 hover:border-emerald-300'
}`}
>
<input
type="checkbox"
className="sr-only"
checked={checked}
onChange={() => toggleAssignee(m.id)}
/>
{m.name}
</label>
);
})}
</div>
</div>
</div>
)}
{/* 첨부 자료 */}
<div>

View File

@@ -0,0 +1,96 @@
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '../../lib/apiClient';
import {
columnDisplayTitle,
columnDisplayTitleEn,
type BoardSlotConfig,
} from '../../lib/boardLayout';
import { isProjectTask } from '../../lib/taskType';
import { DeptIcon } from '../dashboard/DeptIcon';
import { TaskCard } from '../dashboard/TaskCard';
import { DeptProjectList } from '../dashboard/DeptProjectList';
import type { Task } from '../../types';
const SLOT_COUNT = 3;
const DUMMY_HEADER_KEY = 'eene-board-slot-headers-v1';
type DummyHeaders = Record<string, { title: string; titleEn: string; subtitle: string }>;
function loadDummyHeaders(): DummyHeaders {
try {
const raw = localStorage.getItem(DUMMY_HEADER_KEY);
return raw ? JSON.parse(raw) : {};
} catch {
return {};
}
}
interface DummyDepartmentColumnProps {
slot: BoardSlotConfig;
tasks: Task[];
orderedIds: string[];
}
export function DummyDepartmentColumn({ slot, tasks, orderedIds }: DummyDepartmentColumnProps) {
const isDummySlot = !slot.sectionKey;
const { data: colConfig } = useQuery({
queryKey: ['columns', slot.sectionKey],
queryFn: () => apiClient.get(`/columns/${encodeURIComponent(slot.sectionKey!)}`).then((r) => r.data),
enabled: !!slot.sectionKey,
staleTime: 0,
});
const [dummyHeader, setDummyHeader] = useState(() => loadDummyHeaders()[slot.id]);
useEffect(() => {
setDummyHeader(loadDummyHeaders()[slot.id]);
}, [slot.id]);
const title = columnDisplayTitle(slot, colConfig, dummyHeader);
const titleEnState = columnDisplayTitleEn(slot, colConfig, dummyHeader);
const subtitle = isDummySlot ? dummyHeader?.subtitle ?? '' : colConfig?.subtitle ?? '';
const projectTasks = useMemo(() => {
const ordered = [...tasks].sort((a, b) => {
const ai = orderedIds.indexOf(a.id);
const bi = orderedIds.indexOf(b.id);
if (ai === -1 && bi === -1) return 0;
if (ai === -1) return 1;
if (bi === -1) return -1;
return ai - bi;
});
return ordered.filter((t) => isProjectTask(t.taskType)).slice(0, SLOT_COUNT);
}, [tasks, orderedIds]);
return (
<section className={`dept-card ${slot.cssClass}`}>
<div className="dept-head">
<div className="dept-head-main">
<DeptIcon slotId={slot.id} />
<div className="board-dept-header-main">
<div className="board-dept-title-wrap">
<span className="board-dept-title" role="heading" aria-level={2}>
{title.replace(/\s*부문$/, '')}
</span>
{titleEnState && <span className="board-dept-title-en">{titleEnState}</span>}
<div className="dept-head-count" aria-label={`${projectTasks.length}`}>
<span className="poly-stat-val">{projectTasks.length}</span>
<span className="poly-stat-unit"></span>
</div>
</div>
{subtitle && <p className="board-dept-subtitle">{subtitle}</p>}
</div>
</div>
</div>
<DeptProjectList
items={projectTasks}
getKey={(task) => task.id}
renderItem={(task) => <TaskCard key={task.id} task={task} variant="project" />}
/>
</section>
);
}

View File

@@ -34,7 +34,98 @@ function relBox(el: Element, parent: Element): Box {
}
function snap(n: number) {
return Math.round(n * 2) / 2;
return Math.round(n * 100) / 100;
}
/** 참고 레이아웃: 카드 변 부메랑(둔각 다이아) 끝점 */
function appendBoomerangMarker(
group: SVGGElement,
at: Point,
side: 'left' | 'right',
fill: string,
) {
const dir = side === 'right' ? 1 : -1;
const tipLen = 7;
const wing = 4.5;
const pts = [
[at.x + dir * tipLen, at.y],
[at.x + dir * 1.5, at.y - wing],
[at.x - dir * 2.5, at.y],
[at.x + dir * 1.5, at.y + wing],
]
.map(([x, y]) => `${snap(x)},${snap(y)}`)
.join(' ');
const poly = document.createElementNS(SVG_NS, 'polygon');
poly.setAttribute('points', pts);
poly.setAttribute('fill', fill);
group.appendChild(poly);
}
/** 참고 레이아웃: 허브 박스 연결부 V형 부메랑 (박스 안쪽 방향) */
function appendHubBoomerangMarker(
group: SVGGElement,
at: Point,
dir: 'up' | 'down',
fill: string,
) {
const sign = dir === 'down' ? 1 : -1;
const tipLen = 5.5;
const wing = 5;
const base = 3;
const pts = [
[at.x, at.y + sign * tipLen],
[at.x - wing, at.y - sign * base],
[at.x + wing, at.y - sign * base],
]
.map(([x, y]) => `${snap(x)},${snap(y)}`)
.join(' ');
const poly = document.createElementNS(SVG_NS, 'polygon');
poly.setAttribute('points', pts);
poly.setAttribute('fill', fill);
group.appendChild(poly);
}
function pathLength(a: Point, b: Point) {
return Math.hypot(b.x - a.x, b.y - a.y);
}
/** snap·배율 100%에서도 0px 세그먼트가 되지 않도록 보정 */
function normalizePathPoints(points: Point[], minSeg = 1.25): Point[] {
if (points.length < 2) return points;
const out: Point[] = [{ ...points[0] }];
for (let i = 1; i < points.length; i++) {
const prev = out[out.length - 1];
const cur = { ...points[i] };
const len = pathLength(prev, cur);
if (len < minSeg) {
const ref =
i + 1 < points.length
? points[i + 1]
: i > 0
? points[i - 1]
: cur;
let dx = cur.x - prev.x;
let dy = cur.y - prev.y;
if (len < 0.01) {
dx = ref.x - prev.x;
dy = ref.y - prev.y;
}
const d = Math.hypot(dx, dy) || 1;
out.push({ x: prev.x + (dx / d) * minSeg, y: prev.y + (dy / d) * minSeg });
} else {
out.push(cur);
}
}
return out;
}
/** reference 상·하: 허브 테두리 ↔ 다이아 꼭짓점 (wrap clamp·stub 보정 없음) */
function referenceVerticalLine(cx: number, yHub: number, yDiamond: number): Point[] {
if (Math.abs(yDiamond - yHub) < 0.5) return [];
return [
{ x: cx, y: yHub },
{ x: cx, y: yDiamond },
];
}
function pointsToPath(points: Point[]) {
@@ -59,12 +150,42 @@ function diamondEdgeMidpoint(cx: number, cy: number, size: number, edge: string)
return { x: cx + d, y: cy + d };
}
/** 다이아몬드 변 중점에서 바깥(테두리)쪽으로 살짝 밀어 연결 */
function diamondBorderEdgeMidpoint(
cx: number,
cy: number,
size: number,
edge: string,
outward = 2,
): Point {
const mid = diamondEdgeMidpoint(cx, cy, size, edge);
const dx = mid.x - cx;
const dy = mid.y - cy;
const len = Math.hypot(dx, dy) || 1;
return { x: mid.x + (dx / len) * outward, y: mid.y + (dy / len) * outward };
}
function cardEdgeAnchor(cardEl: Element, layout: Element, side: 'left' | 'right', y: number): Point {
const cardBox = relBox(cardEl, layout);
const clampedY = Math.max(cardBox.top + 10, Math.min(cardBox.bottom - 10, y));
return { x: side === 'right' ? cardBox.right : cardBox.left, y: clampedY };
}
/** 참고 레이아웃: 카드 안쪽 변 — 위 행은 중앙보다 살짝 아래, 아래 행은 살짝 위 */
function cardReferenceInnerEdgeAnchor(
cardEl: Element,
layout: Element,
side: 'left' | 'right',
vert: 'top' | 'bottom',
): Point {
const cardBox = relBox(cardEl, layout);
const offset = Math.min(36, Math.max(22, cardBox.height * 0.09));
const yShift = vert === 'top' ? offset : -offset;
const pad = 14;
const y = Math.max(cardBox.top + pad, Math.min(cardBox.bottom - pad, cardBox.cy + yShift));
return { x: side === 'right' ? cardBox.right : cardBox.left, y };
}
const FACE_LINKS = [
{ edge: 'top-left', card: '.dept-card--hrm', side: 'right' as const, vert: 'top' as const, knee: 'left' as const },
{ edge: 'top-right', card: '.dept-card--hrd', side: 'left' as const, vert: 'top' as const, knee: 'right' as const },
@@ -89,10 +210,60 @@ function buildBentPath(
return [cardAnchor, { x: x1, y: cardAnchor.y }, { x: approachX, y: edgeMid.y }, edgeMid];
}
/** 참고 레이아웃: 카드 → 짧은 수평 → ~120° 둔각 꺾임 → 대각 → 다이아몬드 */
function buildReferenceElbowPath(
cardAnchor: Point,
edgeMid: Point,
side: 'left' | 'right',
vert: 'top' | 'bottom',
): Point[] {
const towardCenter = side === 'right' ? 1 : -1;
const turnSign = vert === 'top' ? -1 : 1;
const turn = Math.PI / 3; // 60° 꺾임 → 내각 120°
const uOutX = towardCenter * Math.cos(turn);
const uOutY = turnSign * Math.sin(turn);
const minStub = 18;
const maxStub = 64;
let knee: Point;
if (Math.abs(uOutY) > 1e-4) {
const t = (cardAnchor.y - edgeMid.y) / uOutY;
knee = { x: edgeMid.x - t * uOutX, y: cardAnchor.y };
} else {
knee = { x: cardAnchor.x + towardCenter * minStub, y: cardAnchor.y };
}
const stub = (knee.x - cardAnchor.x) * towardCenter;
if (!Number.isFinite(stub) || stub < minStub || stub > maxStub) {
knee = { x: cardAnchor.x + towardCenter * Math.max(minStub, Math.min(maxStub, stub || minStub)), y: cardAnchor.y };
}
return [cardAnchor, knee, edgeMid];
}
function anchorYForSymmetricFace(faceY: number, runX: number, vert: 'top' | 'bottom') {
return vert === 'top' ? faceY + runX : faceY - runX;
}
const REF_HUB_LINE_COLOR = '#c5d2de';
const REF_HUB_DOT_COLOR = '#b8c6d4';
/** 둥근 꼭짓점 — 선 끝은 테두리 밖(다이아 레이어에 가려짐) */
const REF_DIAMOND_LINE_OVERLAP = 0;
/** reference 상·하: 실제 렌더 박스 + border-radius 보정 (기하 topV는 둥근 꼭짓점보다 바깥) */
function referenceDiamondVerticalY(
diamondEl: HTMLElement,
diamondBox: Box,
end: 'top' | 'bottom',
): number {
const r = parseFloat(getComputedStyle(diamondEl).borderRadius) || 16;
const tipOffset = Math.min(r * 0.7, diamondBox.height * 0.07);
return end === 'top'
? diamondBox.top + tipOffset + REF_DIAMOND_LINE_OVERLAP
: diamondBox.bottom - tipOffset - REF_DIAMOND_LINE_OVERLAP;
}
function fitDiamond(hubColumn: HTMLElement | null, diamond: HTMLElement | null) {
if (!hubColumn || !diamond || window.innerWidth <= 1200) return;
const wrap = diamond.parentElement;
@@ -109,9 +280,13 @@ function fitDiamond(hubColumn: HTMLElement | null, diamond: HTMLElement | null)
diamond.style.height = `${size}px`;
}
export function useBoardConnectors(enabled = true) {
export type ConnectorStyle = 'default' | 'reference';
export function useBoardConnectors(enabled = true, style: ConnectorStyle = 'default') {
const lineGroupRef = useRef<SVGGElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
const dotGroupRef = useRef<SVGGElement>(null);
const dotSvgRef = useRef<SVGSVGElement>(null);
useEffect(() => {
if (!enabled) return;
@@ -124,29 +299,73 @@ export function useBoardConnectors(enabled = true) {
const hubColumn = document.getElementById('hub-column');
const lineGroup = lineGroupRef.current;
const svg = svgRef.current;
const dotGroup = dotGroupRef.current;
const dotSvg = dotSvgRef.current;
if (!layout || !diamond || !lineGroup || !svg) return;
fitDiamond(hubColumn, diamond);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (!layout.isConnected || !diamond.isConnected) return;
drawConnectorsNow(
layout,
diamond,
hubColumn,
lineGroup,
svg,
dotGroup,
dotSvg,
);
});
});
};
const drawConnectorsNow = (
layout: Element,
diamond: HTMLElement,
hubColumn: HTMLElement | null,
lineGroup: SVGGElement,
svg: SVGSVGElement,
dotGroup: SVGGElement | null,
dotSvg: SVGSVGElement | null,
) => {
if (window.innerWidth <= 1200) {
lineGroup.innerHTML = '';
dotGroup && (dotGroup.innerHTML = '');
svg.removeAttribute('viewBox');
dotSvg?.removeAttribute('viewBox');
return;
}
const layoutBox = layout.getBoundingClientRect();
const diamondBox = relBox(diamond, layout);
const diamondSize = diamond.offsetWidth;
const { cx, cy } = diamondBox;
const diamondCx = diamondBox.cx;
const diamondCy = diamondBox.cy;
svg.setAttribute('viewBox', `0 0 ${layoutBox.width} ${layoutBox.height}`);
lineGroup.innerHTML = '';
if (dotGroup) dotGroup.innerHTML = '';
if (dotSvg) dotSvg.setAttribute('viewBox', `0 0 ${layoutBox.width} ${layoutBox.height}`);
const topV = diamondVertex(cx, cy, diamondSize, 'top');
const bottomV = diamondVertex(cx, cy, diamondSize, 'bottom');
const topV = diamondVertex(diamondCx, diamondCy, diamondSize, 'top');
const bottomV = diamondVertex(diamondCx, diamondCy, diamondSize, 'bottom');
const msgBox = document.querySelector('.hub-postit-sheet--front');
const focusBox = document.querySelector('.hub-schedule-planner');
const hubColumnEl = layout.querySelector('.hub-column, #hub-column');
const hubBoxEls = hubColumnEl
? Array.from(hubColumnEl.querySelectorAll(':scope > .hub-box'))
: [];
const topHubBox =
hubBoxEls[0] ??
(style === 'reference'
? layout.querySelector('.hub-box--message')
: layout.querySelector('.hub-postit-sheet--front'));
const bottomHubBox =
(hubBoxEls.length > 1 ? hubBoxEls[hubBoxEls.length - 1] : hubBoxEls[0]) ??
(style === 'reference'
? layout.querySelector('.hub-box--focus')
: layout.querySelector('.hub-schedule-planner'));
const hubBox = hubColumn ? relBox(hubColumn, layout) : null;
const hrmBox = document.querySelector('.dept-card--hrm');
@@ -166,39 +385,138 @@ export function useBoardConnectors(enabled = true) {
const d = diamondSize / (2 * Math.SQRT2);
const approachGap = 32;
const leftApproachX = cx - d - approachGap;
const rightApproachX = cx + d + approachGap;
const leftApproachX = diamondCx - d - approachGap;
const rightApproachX = diamondCx + d + approachGap;
const leftRunX = Math.max(12, leftApproachX - kneeXs.left);
const rightRunX = Math.max(12, kneeXs.right - rightApproachX);
const appendPath = (points: Point[]) => {
const path = document.createElementNS(SVG_NS, 'path');
path.setAttribute('d', pointsToPath(points));
path.setAttribute('stroke', '#b0bcc8');
path.setAttribute('stroke-width', '2.5');
path.setAttribute('opacity', '0.85');
path.setAttribute('fill', 'none');
lineGroup.appendChild(path);
const REF_CARD_LINE_COLORS: Record<string, string> = {
'.dept-card--hrm': '#4a90d9',
'.dept-card--hrd': '#37a184',
'.dept-card--ex': '#9168b8',
'.dept-card--ga': '#2563ab',
};
const appendPath = (
points: Point[],
opts?: {
cardSelector?: string;
dotAt?: Point;
vertical?: boolean;
markerSide?: 'left' | 'right';
markerDir?: 'up' | 'down';
},
) => {
if (points.length < 2) return;
const cardSelector = opts?.cardSelector;
const stroke =
opts?.vertical
? REF_HUB_LINE_COLOR
: style === 'reference' && cardSelector
? REF_CARD_LINE_COLORS[cardSelector] ?? REF_HUB_LINE_COLOR
: style === 'reference'
? REF_HUB_LINE_COLOR
: '#b0bcc8';
if (opts?.vertical && points.length === 2) {
const [a, b] = points;
const line = document.createElementNS(SVG_NS, 'line');
line.setAttribute('x1', String(snap(a.x)));
line.setAttribute('y1', String(snap(a.y)));
line.setAttribute('x2', String(snap(b.x)));
line.setAttribute('y2', String(snap(b.y)));
line.setAttribute('stroke', stroke);
line.setAttribute('stroke-width', '2');
line.setAttribute('fill', 'none');
line.setAttribute('stroke-linecap', 'butt');
lineGroup.appendChild(line);
} else {
const path = document.createElementNS(SVG_NS, 'path');
const normalized = normalizePathPoints(points);
if (normalized.length < 2) return;
path.setAttribute('d', pointsToPath(normalized));
path.setAttribute('stroke', stroke);
path.setAttribute('stroke-width', style === 'reference' ? '2' : '2.5');
path.setAttribute('opacity', style === 'reference' ? '1' : '0.85');
path.setAttribute('fill', 'none');
path.setAttribute('stroke-linecap', style === 'reference' ? 'butt' : 'round');
path.setAttribute('stroke-linejoin', style === 'reference' ? 'miter' : 'round');
lineGroup.appendChild(path);
}
if (style === 'reference' && opts?.dotAt && dotGroup) {
const fill =
cardSelector ? (REF_CARD_LINE_COLORS[cardSelector] ?? REF_HUB_DOT_COLOR) : REF_HUB_DOT_COLOR;
if (cardSelector && opts.markerSide) {
appendBoomerangMarker(dotGroup, opts.dotAt, opts.markerSide, fill);
} else if (opts.markerDir) {
appendHubBoomerangMarker(dotGroup, opts.dotAt, opts.markerDir, fill);
} else {
const dot = document.createElementNS(SVG_NS, 'circle');
dot.setAttribute('cx', String(snap(opts.dotAt.x)));
dot.setAttribute('cy', String(snap(opts.dotAt.y)));
dot.setAttribute('r', '4');
dot.setAttribute('fill', fill);
dotGroup.appendChild(dot);
}
}
};
FACE_LINKS.forEach((link) => {
const cardEl = document.querySelector(link.card);
if (!cardEl) return;
const edgeMid = diamondEdgeMidpoint(cx, cy, diamondSize, link.edge);
const edgeMid =
style === 'reference'
? diamondBorderEdgeMidpoint(diamondCx, diamondCy, diamondSize, link.edge)
: diamondEdgeMidpoint(diamondCx, diamondCy, diamondSize, link.edge);
const runX = link.knee === 'left' ? leftRunX : rightRunX;
const approachX = link.knee === 'left' ? leftApproachX : rightApproachX;
const anchorY = anchorYForSymmetricFace(edgeMid.y, runX, link.vert);
const cardAnchor = cardEdgeAnchor(cardEl, layout, link.side, anchorY);
appendPath(buildBentPath(cardAnchor, edgeMid, link.side, kneeXs[link.knee], approachX));
const cardAnchor =
style === 'reference'
? cardReferenceInnerEdgeAnchor(cardEl, layout, link.side, link.vert)
: cardEdgeAnchor(
cardEl,
layout,
link.side,
anchorYForSymmetricFace(edgeMid.y, runX, link.vert),
);
const pathPoints =
style === 'reference'
? buildReferenceElbowPath(cardAnchor, edgeMid, link.side, link.vert)
: buildBentPath(cardAnchor, edgeMid, link.side, kneeXs[link.knee], approachX);
if (style === 'reference') {
appendPath(pathPoints, {
cardSelector: link.card,
dotAt: cardAnchor,
markerSide: link.side,
});
} else {
appendPath(pathPoints);
}
});
if (msgBox) {
const msg = relBox(msgBox, layout);
appendPath([topV, { x: cx, y: msg.bottom }]);
if (style === 'reference' && topHubBox) {
const topHub = relBox(topHubBox, layout);
const hubAnchor = { x: diamondCx, y: topHub.bottom };
const diamondTopY = referenceDiamondVerticalY(diamond, diamondBox, 'top');
const topPoints = referenceVerticalLine(diamondCx, hubAnchor.y, diamondTopY);
appendPath(topPoints, { vertical: true, dotAt: hubAnchor, markerDir: 'down' });
} else if (topHubBox) {
const topHub = relBox(topHubBox, layout);
appendPath([topV, { x: diamondCx, y: topHub.bottom }]);
}
if (focusBox) {
const focus = relBox(focusBox, layout);
appendPath([bottomV, { x: cx, y: focus.top }]);
if (style === 'reference' && bottomHubBox) {
const bottomHub = relBox(bottomHubBox, layout);
const hubAnchor = { x: diamondCx, y: bottomHub.top };
const diamondBottomY = referenceDiamondVerticalY(diamond, diamondBox, 'bottom');
const bottomPoints = referenceVerticalLine(diamondCx, diamondBottomY, hubAnchor.y);
appendPath(bottomPoints, { vertical: true, dotAt: hubAnchor, markerDir: 'up' });
} else if (bottomHubBox) {
const bottomHub = relBox(bottomHubBox, layout);
appendPath([bottomV, { x: diamondCx, y: bottomHub.top }]);
}
};
@@ -217,10 +535,12 @@ export function useBoardConnectors(enabled = true) {
}
const layoutEl = document.querySelector('.board-layout');
const hubColEl = document.getElementById('hub-column');
let ro: ResizeObserver | undefined;
if (layoutEl && typeof ResizeObserver !== 'undefined') {
if (typeof ResizeObserver !== 'undefined') {
ro = new ResizeObserver(scheduleDraw);
ro.observe(layoutEl);
if (layoutEl) ro.observe(layoutEl);
if (hubColEl) ro.observe(hubColEl);
}
return () => {
@@ -228,7 +548,7 @@ export function useBoardConnectors(enabled = true) {
clearTimeout(resizeTimer);
ro?.disconnect();
};
}, [enabled]);
}, [enabled, style]);
return { svgRef, lineGroupRef };
return { svgRef, lineGroupRef, dotSvgRef, dotGroupRef };
}

View File

@@ -0,0 +1,42 @@
import { useCallback, useMemo, useState } from 'react';
import {
BOARD_REF_DATE_KEY,
dateToQuarter,
parseIsoDate,
startOfDay,
toIsoDate,
} from '../lib/boardCalendar';
export function useBoardReferenceDate() {
const [referenceDate, setReferenceDateState] = useState<Date>(() => {
try {
const stored = localStorage.getItem(BOARD_REF_DATE_KEY);
if (stored) {
const parsed = parseIsoDate(stored);
if (parsed) return startOfDay(parsed);
}
} catch {
/* ignore */
}
return startOfDay(new Date());
});
const setReferenceDate = useCallback((d: Date) => {
const normalized = startOfDay(d);
setReferenceDateState(normalized);
try {
localStorage.setItem(BOARD_REF_DATE_KEY, toIsoDate(normalized));
} catch {
/* ignore */
}
}, []);
const quarter = useMemo(() => dateToQuarter(referenceDate), [referenceDate]);
const resetToToday = useCallback(() => {
setReferenceDate(startOfDay(new Date()));
}, [setReferenceDate]);
return { referenceDate, setReferenceDate, quarter, resetToToday };
}

View File

@@ -0,0 +1,92 @@
import { useEffect, useState } from 'react';
import { fileViewUrl } from '../lib/apiClient';
export function useFileArrayBuffer(fileId: string | null) {
const [buffer, setBuffer] = useState<ArrayBuffer | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!fileId) {
setBuffer(null);
setError(null);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
setBuffer(null);
fetch(fileViewUrl(fileId))
.then((res) => {
if (!res.ok) throw new Error('파일을 불러올 수 없습니다.');
return res.arrayBuffer();
})
.then((data) => {
if (!cancelled) {
setBuffer(data);
setLoading(false);
}
})
.catch((e) => {
if (!cancelled) {
setError(e instanceof Error ? e.message : '미리보기 실패');
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [fileId]);
return { buffer, loading, error };
}
export function useFileBlobUrl(fileId: string | null, mime: string) {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!fileId) {
setBlobUrl(null);
setError(null);
setLoading(false);
return;
}
let cancelled = false;
let objectUrl: string | null = null;
setLoading(true);
setError(null);
setBlobUrl(null);
fetch(fileViewUrl(fileId))
.then((res) => {
if (!res.ok) throw new Error('파일을 불러올 수 없습니다.');
return res.arrayBuffer();
})
.then((data) => {
if (cancelled) return;
objectUrl = URL.createObjectURL(new Blob([data], { type: mime }));
setBlobUrl(objectUrl);
setLoading(false);
})
.catch((e) => {
if (!cancelled) {
setError(e instanceof Error ? e.message : '미리보기 실패');
setLoading(false);
}
});
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [fileId, mime]);
return { blobUrl, loading, error };
}

View File

@@ -0,0 +1,58 @@
import { useQueries } from '@tanstack/react-query';
import { apiClient } from '../lib/apiClient';
import {
ROUTINE_CATEGORIES,
pickRoutineCategoryTask,
type RoutineCategory,
} from '../lib/routineCategories';
import type { Milestone, Task } from '../types';
type TaskWithMilestones = Task & { milestones?: Milestone[] };
export interface RoutineFocusMilestone {
id: string;
title: string;
}
export interface RoutineCategoryFocus {
category: RoutineCategory;
task: Task | null;
milestones: RoutineFocusMilestone[];
isLoading: boolean;
}
export function useRoutineCategoryMilestones(routineTasks: Task[]): RoutineCategoryFocus[] {
const shells = ROUTINE_CATEGORIES.map((category) => ({
category,
task: pickRoutineCategoryTask(routineTasks, category),
}));
const queries = useQueries({
queries: shells.map(({ task }) => ({
queryKey: ['task', task?.id, 'hub-routine-focus'],
queryFn: async () => {
const { data } = await apiClient.get<TaskWithMilestones>(`/tasks/${task!.id}`);
return data;
},
enabled: !!task?.id,
staleTime: 30_000,
})),
});
return shells.map(({ category, task }, index) => {
const data = queries[index].data;
const milestones = (data?.milestones ?? [])
.slice()
.sort((a, b) => a.order - b.order)
.map((m) => ({ id: m.id, title: m.title.trim() }))
.filter((m) => m.title);
return {
category,
task,
milestones,
isLoading: queries[index].isLoading && !!task?.id,
};
});
}

View File

@@ -1,5 +1,18 @@
@import "tailwindcss";
:root {
--app-header-bg: linear-gradient(180deg, #37a184 0%, #29724f 20%, #07412e 100%);
--app-header-border: #135643;
--app-header-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
--board-cal-accent: #059669;
--board-cal-accent-dark: #047857;
--board-cal-accent-deep: #065f46;
--board-cal-border: #a7d7c5;
--board-cal-surface: #ecfdf5;
--board-cal-surface-hover: #d1fae5;
--board-cal-muted: #64748b;
}
html,
body,
#root {
@@ -97,12 +110,222 @@ body,
height: 48px;
min-height: 48px;
padding: 0 22px 0 20px;
background: linear-gradient(180deg, #37a184 0%, #29724f 20%, #07412e 100%);
border-bottom: 1px solid #135643;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
background: var(--app-header-bg);
border-bottom: 1px solid var(--app-header-border);
box-shadow: var(--app-header-shadow);
color: #fff;
}
.header-calendar-slot {
position: relative;
z-index: 101;
display: flex;
flex-shrink: 0;
align-items: center;
gap: 10px;
}
.board-calendar-ref-text {
padding: 6px 14px;
border: 1px solid rgba(186, 216, 202, 0.35);
border-radius: 999px;
background: rgba(7, 65, 46, 0.55);
color: #fff;
font-size: 13px;
font-weight: 700;
letter-spacing: -0.2px;
white-space: nowrap;
}
.board-calendar-popover {
position: fixed;
z-index: 10000;
width: min(400px, calc(100vw - 24px));
padding: 14px 14px 12px;
border: 1px solid var(--board-cal-border);
border-radius: 14px;
background: #fff;
box-shadow: 0 16px 40px rgba(7, 65, 46, 0.16);
color: #1f2937;
}
.board-calendar-popover-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.board-calendar-month {
font-size: 16px;
font-weight: 800;
color: var(--board-cal-accent-deep);
}
.board-calendar-nav {
width: 30px;
height: 30px;
border: 1px solid var(--board-cal-border);
border-radius: 8px;
background: var(--board-cal-surface);
color: var(--board-cal-accent);
font-size: 18px;
line-height: 1;
cursor: pointer;
}
.board-calendar-nav:hover {
background: var(--board-cal-surface-hover);
border-color: var(--board-cal-accent);
}
.board-calendar-quarter-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
margin-bottom: 12px;
}
.board-calendar-quarter-chip {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 8px 4px;
border: 1px solid var(--board-cal-border);
border-radius: 8px;
background: #fff;
color: var(--board-cal-accent-deep);
cursor: pointer;
}
.board-calendar-quarter-chip:hover {
background: var(--board-cal-surface);
border-color: var(--board-cal-accent);
}
.board-calendar-quarter-chip.is-selected {
border-color: var(--board-cal-accent-dark);
background: linear-gradient(180deg, #37a184 0%, #047857 100%);
color: #fff;
}
.board-calendar-quarter-chip-title {
font-size: 13px;
font-weight: 800;
}
.board-calendar-quarter-chip-range {
font-size: 10px;
font-weight: 600;
opacity: 0.85;
}
.board-calendar-grid-wrap {
overflow-x: auto;
}
.board-calendar-grid {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.board-calendar-grid th {
padding: 4px 2px;
font-size: 11px;
font-weight: 700;
color: var(--board-cal-muted);
text-align: center;
}
.board-calendar-grid-week-col {
width: 72px;
text-align: left;
}
.board-calendar-week-label-btn {
padding: 0;
border: none;
background: none;
color: var(--board-cal-accent);
font-size: 11px;
font-weight: 700;
text-align: left;
white-space: nowrap;
cursor: pointer;
}
.board-calendar-week-label-btn:hover {
color: var(--board-cal-accent-dark);
text-decoration: underline;
}
.board-calendar-day-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 28px;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
font-size: 12px;
font-weight: 600;
color: #1f2937;
cursor: pointer;
}
.board-calendar-day-btn.is-outside {
color: #cbd5e1;
font-weight: 500;
}
.board-calendar-day-btn.is-ref {
background: linear-gradient(180deg, #37a184 0%, #047857 100%);
color: #fff;
}
.board-calendar-day-btn.is-today:not(.is-ref) {
box-shadow: inset 0 0 0 1px var(--board-cal-accent);
color: var(--board-cal-accent);
}
.board-calendar-day-btn:hover:not(.is-ref) {
background: var(--board-cal-surface);
}
.board-calendar-grid tr.is-selected-week .board-calendar-week-label-btn {
color: var(--board-cal-accent-dark);
}
.board-calendar-foot {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid #e5e7eb;
}
.board-calendar-foot button {
padding: 6px 14px;
border: 1px solid var(--board-cal-border);
border-radius: 8px;
background: #fff;
font-size: 12px;
font-weight: 700;
color: var(--board-cal-accent-deep);
cursor: pointer;
}
.board-calendar-foot button:hover {
background: var(--board-cal-surface);
border-color: var(--board-cal-accent);
color: var(--board-cal-accent-dark);
}
.side-left-group {
display: flex;
align-items: center;
@@ -119,6 +342,7 @@ body,
/* F12 — 헤더 원형 아이콘 버튼 (team / + / 듀얼모니터) */
.header-action-btn-new,
.header-view-btn-new,
.header-calendar-btn-new,
.team-status-btn-new {
display: flex;
flex-shrink: 0;
@@ -137,6 +361,7 @@ body,
.header-action-btn-new svg,
.header-view-btn-new svg,
.header-calendar-btn-new svg,
.team-status-btn-new svg {
color: inherit;
filter: none;
@@ -144,6 +369,7 @@ body,
.header-action-btn-new:hover,
.header-view-btn-new:hover,
.header-calendar-btn-new:hover,
.team-status-btn-new:hover {
color: #fff;
border-color: #36816d;
@@ -158,6 +384,7 @@ body,
.header-action-btn-new:active,
.header-action-btn-new.active,
.header-view-btn-new.active,
.header-calendar-btn-new.active,
.team-status-btn-new.active {
color: #cef1eb;
border-color: #1a4d42;

View File

@@ -48,6 +48,10 @@ export function getSocketUrl(): string {
if (import.meta.env.VITE_SOCKET_URL) {
return import.meta.env.VITE_SOCKET_URL;
}
// dev: Vite proxy /socket.io → API (localhost·LAN IP 동일 origin)
if (import.meta.env.DEV && typeof window !== 'undefined') {
return window.location.origin;
}
return getBackendOrigin();
}

View File

@@ -21,6 +21,10 @@ export function fileDownloadUrl(fileId: string): string {
return `${baseURL}/files/${fileId}/download`;
}
export function fileHwpPreviewUrl(fileId: string): string {
return `${baseURL}/files/${fileId}/hwp-preview`;
}
apiClient.interceptors.request.use((config) => {
if (config.data instanceof FormData) {
delete config.headers['Content-Type'];
@@ -34,6 +38,14 @@ apiClient.interceptors.response.use(
);
export function getApiErrorMessage(err: unknown, fallback: string): string {
const ax = err as { response?: { data?: { message?: string }; status?: number }; message?: string };
return ax.response?.data?.message || ax.message || fallback;
const ax = err as {
response?: { data?: { message?: string }; status?: number };
message?: string;
code?: string;
};
if (ax.response?.data?.message) return ax.response.data.message;
if (!ax.response && (ax.code === 'ERR_NETWORK' || ax.message?.includes('Network Error'))) {
return '서버에 연결할 수 없습니다. 서버 PC에서 서버시작.bat 이 실행 중인지 확인해 주세요.';
}
return ax.message || fallback;
}

View File

@@ -0,0 +1,108 @@
export const BOARD_REF_DATE_KEY = 'eene-board-reference-date';
export function startOfDay(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
export function toIsoDate(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
export function parseIsoDate(iso: string): Date | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return null;
const [y, m, day] = iso.split('-').map(Number);
const dt = new Date(y, m - 1, day);
if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== day) return null;
return dt;
}
export function dateToQuarter(d: Date): string {
const q = Math.floor(d.getMonth() / 3) + 1;
return `${d.getFullYear()}-Q${q}`;
}
export function quarterToLabel(quarter: string): string {
return quarter.replace(/^(\d{4})-Q(\d)$/, '$1 $2분기 업무');
}
export function weekOfMonthLabel(d: Date): string {
return `${d.getMonth() + 1}${Math.ceil(d.getDate() / 7)}주차`;
}
export function quarterNumber(d: Date): number {
return Math.floor(d.getMonth() / 3) + 1;
}
export function formatReferenceSummary(d: Date): string {
return `기준일 ${toIsoDate(d)} · ${quarterNumber(d)}분기`;
}
/** @deprecated use formatReferenceSummary */
export function formatReferencePill(d: Date): string {
return formatReferenceSummary(d);
}
export const QUARTER_RANGE_LABELS = ['1.01~3.31', '4.01~6.30', '7.01~9.30', '10.01~12.31'] as const;
export function startOfWeekMonday(d: Date): Date {
const x = startOfDay(d);
const dow = x.getDay();
const offset = dow === 0 ? -6 : 1 - dow;
x.setDate(x.getDate() + offset);
return x;
}
export function quarterStartDate(quarter: string): Date {
const m = quarter.match(/^(\d{4})-Q([1-4])$/);
if (!m) return startOfDay(new Date());
return new Date(Number(m[1]), (Number(m[2]) - 1) * 3, 1);
}
export function quarterEndDate(quarter: string): Date {
const m = quarter.match(/^(\d{4})-Q([1-4])$/);
if (!m) return startOfDay(new Date());
const year = Number(m[1]);
const q = Number(m[2]);
const endMonth = q * 3;
return new Date(year, endMonth, 0);
}
export function isSameDay(a: Date, b: Date): boolean {
return startOfDay(a).getTime() === startOfDay(b).getTime();
}
export function isSameWeek(a: Date, b: Date): boolean {
return startOfWeekMonday(a).getTime() === startOfWeekMonday(b).getTime();
}
export interface CalendarWeekRow {
label: string;
monday: Date;
days: Date[];
}
export function buildMonthWeekRows(year: number, monthIndex: number): CalendarWeekRow[] {
const rows: CalendarWeekRow[] = [];
let monday = startOfWeekMonday(new Date(year, monthIndex, 1));
const monthEnd = new Date(year, monthIndex + 1, 0);
for (let w = 0; w < 6; w++) {
const days: Date[] = Array.from({ length: 7 }, (_, i) => {
const d = new Date(monday);
d.setDate(monday.getDate() + i);
return d;
});
if (days.some((d) => d.getMonth() === monthIndex)) {
const anchor = days.find((d) => d.getMonth() === monthIndex)!;
rows.push({
label: weekOfMonthLabel(anchor),
monday: startOfDay(new Date(monday)),
days,
});
}
monday = new Date(monday);
monday.setDate(monday.getDate() + 7);
if (monday > monthEnd && monday.getMonth() !== monthIndex) break;
}
return rows;
}

View File

@@ -56,6 +56,72 @@ export function slotSectionLabel(slot: BoardSlotConfig): string {
return slot.sectionKey ?? slot.dummySection ?? slot.defaultTitle;
}
/** 조직문화(EX) 슬롯 — HR 원본 category와 무관하게 배치할 프로젝트 */
const EX_CULTURE_TITLE = /회사생활|C\.E\.L|조직문화|복리후생|문화\s*진단|직원\s*소통/i;
export function isExCultureTask(task: {
section?: string | null;
title?: string | null;
tag?: string | null;
}): boolean {
if (task.section?.trim() === '조직문화') return true;
if (task.tag === 'Culture') return true;
const title = task.title?.trim() ?? '';
return title.length > 0 && EX_CULTURE_TITLE.test(title);
}
export function resolveTaskBoardSlot(task: {
section?: string | null;
title?: string | null;
tag?: string | null;
}): BoardSlotId | null {
if (isExCultureTask(task)) return 'ex';
for (const slot of BOARD_SLOTS) {
if (slot.id === 'ex') continue;
if (taskBelongsToSlot(task.section, slot)) return slot.id;
}
return null;
}
export function taskBelongsToBoardSlot(
task: { section?: string | null; title?: string | null; tag?: string | null },
slot: BoardSlotConfig,
): boolean {
return resolveTaskBoardSlot(task) === slot.id;
}
/** 컬럼 헤더 — API 기본값(운영관리 등)은 슬롯 표시명(총무관리 등)으로 */
export function columnDisplayTitle(
slot: BoardSlotConfig,
colConfig?: { title?: string | null } | null,
dummyHeader?: { title?: string } | null,
): string {
if (!slot.sectionKey) {
return dummyHeader?.title?.trim() || slot.defaultTitle;
}
const custom = colConfig?.title?.replace(/\s*부문$/, '').trim();
if (!custom) return slot.defaultTitle;
if (custom === slot.sectionKey || custom === '운영관리' || custom === 'HR' || custom === '학습성장') {
return slot.defaultTitle;
}
return custom;
}
export function columnDisplayTitleEn(
slot: BoardSlotConfig,
colConfig?: { titleEn?: string | null } | null,
dummyHeader?: { titleEn?: string } | null,
): string {
if (!slot.sectionKey) {
return dummyHeader?.titleEn?.trim() || slot.defaultTitleEn;
}
const custom = colConfig?.titleEn?.trim();
if (!custom || custom === 'Operations' || custom === 'Human Resources') {
return slot.defaultTitleEn;
}
return custom;
}
export function taskBelongsToSlot(
taskSection: string | null | undefined,
slot: BoardSlotConfig,

View File

@@ -1,11 +1,15 @@
/**
* 듀얼 모니터 연동 — dashboard-260504.vercel.app 와 동일한 창 배치 로직
* @see https://dashboard-260504.vercel.app/
*
* 핵심: 클릭 핸들러 안에서 await getScreenDetails() → 권한(모든 디스플레이) → window.open
* localhost / IP(172.x) 동일 코드. IP는 origin별로 권한을 한 번 더 허용해야 함.
*/
const CHANNEL_NAME = 'eee_dashboard';
const DETAIL_WINDOW_NAME = 'eene_detail';
const SELECTED_TASK_KEY = 'eee_selected_task';
const PLACEMENT_STORAGE_KEY = 'eee_detail_window_placement';
export type DualMonitorEvent =
| { type: 'TASK_SELECTED'; taskId: string }
@@ -18,6 +22,33 @@ let detailWindow: Window | null = null;
let dualModeActive = false;
let closePollTimer: ReturnType<typeof setInterval> | null = null;
let syncProvider: (() => string | null) | null = null;
let cachedPlacement: WindowPlacement | null = null;
let lastPlacementIssue: string | null = null;
export function getLastPlacementIssue(): string | null {
return lastPlacementIssue;
}
/** http://IP 는 secure context가 아니어서 getScreenDetails·권한 팝업 불가 → https 필요 */
export function getWindowPlacementHint(): string | null {
const win = window as WindowWithScreenDetails;
if (win.getScreenDetails) return null;
const { protocol, hostname } = window.location;
const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1';
if (protocol === 'http:' && !isLocalhost) {
return (
`http://${hostname} 으로는 「모든 디스플레이」 권한 창이 뜨지 않아 ` +
`상세 창이 왼쪽 모니터에 열립니다.\n\n` +
`→ https://${hostname}:3000 으로 접속해 주세요.\n` +
`(인증서 경고 → 고급 → 계속)\n\n` +
`localhost:3000 은 http 로도 동작합니다.`
);
}
return '이 브라우저/환경에서는 다중 모니터 창 배치 API를 사용할 수 없습니다.';
}
interface ScreenDetailed {
left: number;
@@ -50,47 +81,93 @@ function placementToFeatures({ left, top, width, height }: WindowPlacement): str
return `left=${left},top=${top},width=${width},height=${height},menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=yes`;
}
function readPersistedPlacement(): WindowPlacement | null {
if (cachedPlacement) return cachedPlacement;
try {
const raw = localStorage.getItem(PLACEMENT_STORAGE_KEY);
if (!raw) return null;
const p = JSON.parse(raw) as WindowPlacement;
if (
typeof p.left === 'number' &&
typeof p.top === 'number' &&
typeof p.width === 'number' &&
typeof p.height === 'number'
) {
cachedPlacement = p;
return p;
}
} catch {
/* ignore */
}
return null;
}
function persistPlacement(placement: WindowPlacement) {
cachedPlacement = placement;
try {
localStorage.setItem(PLACEMENT_STORAGE_KEY, JSON.stringify(placement));
} catch {
/* ignore */
}
}
function placementFromScreenDetails(details: ScreenDetails): WindowPlacement {
const current = details.currentScreen;
const sorted = [...details.screens].sort((a, b) => a.left - b.left || a.top - b.top);
const idx = sorted.findIndex((s) => s.left === current.left && s.top === current.top);
const target =
(idx >= 0 && idx < sorted.length - 1 ? sorted[idx + 1] : null) ??
sorted.find((s) => s.left !== current.left || s.top !== current.top) ??
sorted[sorted.length - 1];
return {
left: target.availLeft ?? target.left,
top: target.availTop ?? target.top,
width: target.availWidth ?? target.width,
height: target.availHeight ?? target.height,
};
}
function fallbackDetailPlacement(): WindowPlacement {
const stored = readPersistedPlacement();
if (stored) return stored;
const top = window.screen.availTop ?? 0;
const height = window.screen.availHeight;
const availLeft = window.screen.availLeft ?? 0;
const availWidth = window.screen.availWidth;
const monitorRight = availLeft + availWidth;
return {
left: monitorRight,
top,
width: Math.max(800, availWidth),
height,
};
}
/**
* 참고 사이트(fr)와 동일한 좌표 계산
* — getScreenDetails await 후 window.open (권한 요청 + 우측 모니터 배치)
* 참고 사이트와 동일 — getScreenDetails(await) 로 「모든 디스플레이」 권한 후 좌표 계산
*/
async function resolveDetailWindowPlacement(): Promise<WindowPlacement> {
let left = window.screenX + window.outerWidth;
let top = window.screenY;
let width = window.screen.availWidth;
let height = window.screen.availHeight;
lastPlacementIssue = getWindowPlacementHint();
try {
const win = window as WindowWithScreenDetails;
if (win.getScreenDetails) {
const details = await win.getScreenDetails();
const current = details.currentScreen;
let target = details.screens.find((s) => s.left >= current.left + current.width);
target ||= details.screens.find((s) => s !== current);
if (target) {
left = target.availLeft ?? target.left;
top = target.availTop ?? target.top;
width = target.availWidth ?? target.width;
height = target.availHeight ?? target.height;
} else {
left = window.screenX + window.outerWidth;
width = window.screen.availWidth - left;
if (width < 800) {
width = 1920;
left = window.screen.availWidth;
}
height = window.screen.availHeight;
}
lastPlacementIssue = null;
return placementFromScreenDetails(details);
}
} catch (err) {
console.warn('Window Management API failed or denied, using fallback', err);
console.warn('[dualMonitor] getScreenDetails failed, using fallback', err);
lastPlacementIssue =
'「모든 디스플레이」 권한이 거부되었거나 사용할 수 없습니다.\n' +
'주소창 자물쇠 → 사이트 권한 → 창 배치 → 허용';
}
return { left, top, width, height };
return fallbackDetailPlacement();
}
/** 우측 모니터 좌표·크기 (열린 뒤 moveTo 보정용) */
export async function getRightMonitorWindowFeatures(): Promise<string> {
return placementToFeatures(await resolveDetailWindowPlacement());
}
@@ -145,13 +222,23 @@ export function registerSyncProvider(fn: () => string | null): () => void {
};
}
function applyWindowPlacement(win: Window, left: number, top: number, width: number, height: number) {
try {
win.moveTo(left, top);
win.resizeTo(width, height);
} catch {
// 브라우저 정책으로 실패할 수 있음
/* ignore */
}
}
function schedulePlacementRetries(win: Window, placement: WindowPlacement) {
const apply = () => {
if (win.closed) return;
applyWindowPlacement(win, placement.left, placement.top, placement.width, placement.height);
};
apply();
for (const delay of [100, 400, 1000, 2000]) {
setTimeout(apply, delay);
}
}
@@ -178,7 +265,7 @@ function openDetailWindowWithPlacement(placement: WindowPlacement): Window | nul
try {
detailWindow.focus();
} catch {
// ignore
/* ignore */
}
applyWindowPlacement(detailWindow, placement.left, placement.top, placement.width, placement.height);
@@ -188,10 +275,13 @@ function openDetailWindowWithPlacement(placement: WindowPlacement): Window | nul
/** 참고 사이트: getScreenDetails(await) → window.open */
async function openDetailWindowPlaced(onPopupClosed?: () => void): Promise<Window | null> {
const placement = await resolveDetailWindowPlacement();
persistPlacement(placement);
const win = openDetailWindowWithPlacement(placement);
if (!win) return null;
startClosePoll(() => onPopupClosed?.());
schedulePlacementRetries(win, placement);
return win;
}
@@ -218,8 +308,11 @@ export async function openDetailWindow(onPopupClosed?: () => void): Promise<Wind
return win;
}
/** 업무 선택 — 참고 사이트와 같이 배치 계산(await) 후 팝업 열기 */
export async function sendTaskSelected(taskId: string, onPopupClosed?: () => void): Promise<void> {
/** 업무 선택 — await getScreenDetails(권한) 후 팝업 open */
export async function sendTaskSelected(
taskId: string,
onPopupClosed?: () => void,
): Promise<boolean> {
persistSelectedTask(taskId);
if (!isDetailWindowOpen()) {
@@ -227,10 +320,13 @@ export async function sendTaskSelected(taskId: string, onPopupClosed?: () => voi
const win = await openDetailWindowPlaced(onPopupClosed);
if (!win) {
dualModeActive = false;
return;
return false;
}
scheduleTaskSelected(taskId);
return;
if (lastPlacementIssue) {
window.alert(lastPlacementIssue);
}
return true;
}
scheduleTaskSelected(taskId);
@@ -238,14 +334,16 @@ export async function sendTaskSelected(taskId: string, onPopupClosed?: () => voi
try {
detailWindow!.focus();
} catch {
// ignore
/* ignore */
}
void resolveDetailWindowPlacement().then((placement) => {
if (detailWindow && !detailWindow.closed) {
applyWindowPlacement(detailWindow, placement.left, placement.top, placement.width, placement.height);
}
});
const placement = await resolveDetailWindowPlacement();
persistPlacement(placement);
if (detailWindow && !detailWindow.closed) {
schedulePlacementRetries(detailWindow, placement);
}
return true;
}
export function sendTaskDeselected(): void {
@@ -299,14 +397,12 @@ export function openLinkOnRightMonitor(url: string, windowName: string): Window
try {
win?.focus();
} catch {
// ignore
/* ignore */
}
if (win) {
void resolveDetailWindowPlacement().then((placement) => {
if (win && !win.closed) {
applyWindowPlacement(win, placement.left, placement.top, placement.width, placement.height);
}
schedulePlacementRetries(win, placement);
});
}

View File

@@ -35,3 +35,75 @@ export function isExcelFile(file: Pick<FileRecord, 'mimetype' | 'originalName'>)
name.endsWith('.csv')
);
}
export type FilePreviewKind =
| 'image'
| 'video'
| 'excel'
| 'pdf'
| 'docx'
| 'pptx'
| 'hwp'
| 'text'
| 'unsupported';
function extOf(name: string): string {
return name.split('.').pop()?.toLowerCase() ?? '';
}
/** 미리보기 라우팅 — iframe 대신 형식별 뷰어 사용 */
export function getFilePreviewKind(
file: Pick<FileRecord, 'mimetype' | 'originalName'>,
): FilePreviewKind {
const name = file.originalName.toLowerCase();
const ext = extOf(name);
if (isExcelFile(file)) return 'excel';
if (file.mimetype.startsWith('image/') || /^(png|jpe?g|gif|webp|bmp|svg)$/.test(ext)) return 'image';
if (isVideoFile(file)) return 'video';
if (file.mimetype === 'application/pdf' || ext === 'pdf') return 'pdf';
if (file.mimetype.includes('wordprocessingml') || ext === 'docx') return 'docx';
if (file.mimetype.includes('presentationml') || ext === 'pptx') return 'pptx';
if (ext === 'hwp' || ext === 'hwpx' || file.mimetype.includes('hwp')) return 'hwp';
if (file.mimetype.startsWith('text/') || ext === 'txt') return 'text';
return 'unsupported';
}
const EXT_MIME: Record<string, string> = {
pdf: 'application/pdf',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
bmp: 'image/bmp',
svg: 'image/svg+xml',
mp4: 'video/mp4',
mov: 'video/quicktime',
avi: 'video/x-msvideo',
webm: 'video/webm',
mkv: 'video/x-matroska',
m4v: 'video/x-m4v',
wmv: 'video/x-ms-wmv',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
xls: 'application/vnd.ms-excel',
csv: 'text/csv',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
doc: 'application/msword',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
ppt: 'application/vnd.ms-powerpoint',
hwp: 'application/x-hwp',
hwpx: 'application/hwp+zip',
txt: 'text/plain',
};
export function resolvePreviewMime(file: Pick<FileRecord, 'mimetype' | 'originalName'>): string {
const ext = extOf(file.originalName);
const fromExt = EXT_MIME[ext];
const stored = file.mimetype?.trim() ?? '';
if (!stored || stored === 'application/octet-stream') {
return fromExt ?? stored ?? 'application/octet-stream';
}
if (fromExt && stored.includes('octet')) return fromExt;
return stored;
}

View File

@@ -1,7 +1,11 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from './apiClient';
import { migrateScheduleItem, quarterDateBounds } from './hubSchedule';
import { ROUTINE_CATEGORIES } from './routineCategories';
const STORAGE_KEY = 'eene-quarter-hub-config-v1';
const QUERY_KEY = ['hub-config'] as const;
export interface HubScheduleItem {
id: string;
@@ -19,7 +23,7 @@ export interface HubConfig {
}
export const DEFAULT_HUB_CONFIG: HubConfig = {
sloganTitle: '분기 슬로건',
sloganTitle: '분기 중점 과제',
sloganLines: ['인사 · 육성 · 문화 · 총무', '개선과제', '정상 추진'],
scheduleTitle: '분기 주요 일정',
scheduleItems: [
@@ -27,9 +31,29 @@ export const DEFAULT_HUB_CONFIG: HubConfig = {
{ id: '2', date: '2026-05-15', text: '조직문화 진단·리더십 교육' },
{ id: '3', date: '2026-06-20', text: '분기 성과 점검·평가' },
],
routineLabels: ['채용', '교육', '소통', '시설', '자산', '행정'],
routineLabels: ['채용 운영', '학습 지원', '직원 소통', '자산·시설', '문서·행정'],
};
function migrateRoutineLabels(raw: unknown): string[] {
if (!Array.isArray(raw)) return [...ROUTINE_CATEGORIES];
const labels = raw.map(String);
if (labels.length === ROUTINE_CATEGORIES.length && labels.every((label, i) => label === ROUTINE_CATEGORIES[i])) {
return [...ROUTINE_CATEGORIES];
}
const legacyFull = ['채용 운영', '교육 운영', '직원 소통', '자산·시설', '문서·행정'];
if (labels.length === legacyFull.length && labels.every((label, i) => label === legacyFull[i])) {
return [...ROUTINE_CATEGORIES];
}
const legacyShort = ['채용', '교육', '소통', '시설', '자산', '행정'];
if (labels.length === legacyShort.length && labels.every((label, i) => label === legacyShort[i])) {
return [...ROUTINE_CATEGORIES];
}
if (labels.some((label) => label === '교육 운영')) {
return labels.map((label) => (label === '교육 운영' ? '학습 지원' : label));
}
return labels.length > 0 ? labels : [...ROUTINE_CATEGORIES];
}
function migrateConfig(raw: Record<string, unknown>): HubConfig {
const { year } = quarterDateBounds('2026-Q2');
const scheduleItems = Array.isArray(raw.scheduleItems)
@@ -39,52 +63,84 @@ function migrateConfig(raw: Record<string, unknown>): HubConfig {
: DEFAULT_HUB_CONFIG.scheduleItems;
return {
sloganTitle: (raw.sloganTitle as string) ?? DEFAULT_HUB_CONFIG.sloganTitle,
sloganTitle: (() => {
const t = (raw.sloganTitle as string) ?? DEFAULT_HUB_CONFIG.sloganTitle;
return t === '분기 슬로건' ? '분기 중점 과제' : t;
})(),
sloganLines: (raw.sloganLines as string[]) ?? DEFAULT_HUB_CONFIG.sloganLines,
scheduleTitle: (raw.scheduleTitle as string) ?? DEFAULT_HUB_CONFIG.scheduleTitle,
scheduleItems,
routineLabels: (raw.routineLabels as string[]) ?? DEFAULT_HUB_CONFIG.routineLabels,
routineLabels: migrateRoutineLabels(raw.routineLabels),
};
}
function loadConfig(): HubConfig {
if (typeof window === 'undefined') return DEFAULT_HUB_CONFIG;
async function fetchHubConfig(): Promise<HubConfig> {
const raw = await apiClient.get<Record<string, unknown>>('/hub-config').then((r) => r.data);
return migrateConfig({ ...DEFAULT_HUB_CONFIG, ...raw });
}
async function saveHubConfig(config: HubConfig): Promise<HubConfig> {
const raw = await apiClient.patch<Record<string, unknown>>('/hub-config', config).then((r) => r.data);
return migrateConfig({ ...DEFAULT_HUB_CONFIG, ...raw });
}
function readLegacyLocalConfig(): HubConfig | null {
if (typeof window === 'undefined') return null;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULT_HUB_CONFIG;
if (!raw) return null;
return migrateConfig({ ...DEFAULT_HUB_CONFIG, ...JSON.parse(raw) });
} catch {
return DEFAULT_HUB_CONFIG;
return null;
}
}
function saveConfig(config: HubConfig) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
}
export function useHubConfig() {
const [config, setConfigState] = useState<HubConfig>(loadConfig);
const queryClient = useQueryClient();
const legacyMigrated = useRef(false);
const setConfig = useCallback((patch: Partial<HubConfig> | ((prev: HubConfig) => HubConfig)) => {
setConfigState((prev) => {
const next = typeof patch === 'function' ? patch(prev) : { ...prev, ...patch };
saveConfig(next);
return next;
});
}, []);
const { data: config = DEFAULT_HUB_CONFIG, isLoading } = useQuery({
queryKey: QUERY_KEY,
queryFn: fetchHubConfig,
staleTime: 30_000,
});
const resetConfig = useCallback(() => {
localStorage.removeItem(STORAGE_KEY);
setConfigState(DEFAULT_HUB_CONFIG);
}, []);
const saveMutation = useMutation({
mutationFn: saveHubConfig,
onSuccess: (saved) => {
queryClient.setQueryData(QUERY_KEY, saved);
},
});
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEY) setConfigState(loadConfig());
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, []);
if (isLoading || legacyMigrated.current) return;
const legacy = readLegacyLocalConfig();
if (!legacy) return;
legacyMigrated.current = true;
localStorage.removeItem(STORAGE_KEY);
saveHubConfig(legacy)
.then((saved) => {
queryClient.setQueryData(QUERY_KEY, saved);
})
.catch(() => {
/* API 미준비 등 — 기본값 유지 */
});
}, [isLoading, queryClient]);
return { config, setConfig, resetConfig };
const setConfig = useCallback(
(patch: Partial<HubConfig> | ((prev: HubConfig) => HubConfig)) => {
const prev = queryClient.getQueryData<HubConfig>(QUERY_KEY) ?? DEFAULT_HUB_CONFIG;
const next = typeof patch === 'function' ? patch(prev) : { ...prev, ...patch };
queryClient.setQueryData(QUERY_KEY, next);
saveMutation.mutate(next);
},
[queryClient, saveMutation],
);
const resetConfig = useCallback(() => {
queryClient.setQueryData(QUERY_KEY, DEFAULT_HUB_CONFIG);
saveMutation.mutate(DEFAULT_HUB_CONFIG);
}, [queryClient, saveMutation]);
return { config, setConfig, resetConfig, isLoading };
}

View File

@@ -16,9 +16,19 @@ export function startOfDay(date: Date): Date {
/** "6월 8일" */
export function formatScheduleDateLabel(dateStr: string): string {
const parts = formatScheduleDateParts(dateStr);
if (!parts) return dateStr;
return `${parts.month} ${parts.day}`;
}
/** { month: "6월", day: "8일" } — 일정 목록 좌우 정렬용 */
export function formatScheduleDateParts(dateStr: string): { month: string; day: string } | null {
const dt = parseScheduleDate(dateStr);
if (!dt) return dateStr;
return `${dt.getMonth() + 1}${dt.getDate()}`;
if (!dt) return null;
return {
month: `${dt.getMonth() + 1}`,
day: `${dt.getDate()}`,
};
}
export function sortScheduleItems(items: HubScheduleItem[]): HubScheduleItem[] {

View File

@@ -0,0 +1,168 @@
import type { Milestone, MilestonePeriodEntry } from '../types';
import { routineStageBody } from './routineMilestone';
export type { MilestonePeriodEntry };
export function newPeriodEntry(): MilestonePeriodEntry {
return {
id: `period-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
startDate: '',
dueDate: '',
note: '',
};
}
function toDateInput(iso: string | null | undefined) {
if (!iso) return '';
return new Date(iso).toISOString().slice(0, 10);
}
export function normalizePeriodEntries(raw: unknown): MilestonePeriodEntry[] {
if (!Array.isArray(raw)) return [];
const entries: MilestonePeriodEntry[] = [];
for (const item of raw) {
if (!item || typeof item !== 'object') continue;
const row = item as Record<string, unknown>;
const startDate = typeof row.startDate === 'string' ? row.startDate.trim() : '';
const dueDate = typeof row.dueDate === 'string' ? row.dueDate.trim() : '';
const note = typeof row.note === 'string' ? row.note : '';
if (!startDate && !dueDate && !note.trim()) continue;
entries.push({
id: typeof row.id === 'string' && row.id ? row.id : newPeriodEntry().id,
startDate,
dueDate,
note,
});
}
return entries;
}
function legacyDescriptionNote(description: string | null | undefined): string {
return routineStageBody(description).trim();
}
export function parseMilestonePeriods(
milestone: Pick<Milestone, 'id' | 'periodEntries' | 'startDate' | 'dueDate' | 'description'> | null | undefined,
): MilestonePeriodEntry[] {
if (!milestone) return [];
const legacyNote = legacyDescriptionNote(milestone.description);
const fromJson = normalizePeriodEntries(milestone.periodEntries);
if (fromJson.length > 0) {
if (legacyNote && !fromJson.some((p) => p.note.trim())) {
return fromJson.map((p, i) => (i === 0 ? { ...p, note: legacyNote } : p));
}
return fromJson;
}
if (milestone.startDate || milestone.dueDate || legacyNote) {
return [
{
id: milestone.id ? `period-legacy-${milestone.id}` : newPeriodEntry().id,
startDate: toDateInput(milestone.startDate),
dueDate: toDateInput(milestone.dueDate),
note: legacyNote,
},
];
}
return [];
}
export function serializePeriodEntries(entries: MilestonePeriodEntry[]): MilestonePeriodEntry[] {
return entries
.map((entry) => ({
id: entry.id,
startDate: entry.startDate.trim(),
dueDate: entry.dueDate.trim(),
note: entry.note.trim(),
}))
.filter((entry) => entry.startDate || entry.dueDate || entry.note);
}
export function fmtPeriodRange(entry: Pick<MilestonePeriodEntry, 'startDate' | 'dueDate'>) {
const fmt = (iso: string) => {
const d = new Date(iso);
return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}`;
};
if (entry.startDate && entry.dueDate) return `${fmt(entry.startDate)} ~ ${fmt(entry.dueDate)}`;
if (entry.dueDate) return fmt(entry.dueDate);
if (entry.startDate) return `${fmt(entry.startDate)} ~`;
return '';
}
/** 업무내용 기간 선택 — MM.DD~MM.DD (연도 생략) */
export function fmtPeriodPickerLabel(
entry: Pick<MilestonePeriodEntry, 'startDate' | 'dueDate'>,
index: number,
): string {
const fmt = (iso: string) => {
const d = new Date(iso);
return `${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`;
};
if (entry.startDate && entry.dueDate) return `${fmt(entry.startDate)}~${fmt(entry.dueDate)}`;
if (entry.dueDate) return fmt(entry.dueDate);
if (entry.startDate) return `${fmt(entry.startDate)}~`;
return `기간 ${index + 1}`;
}
export function parsePeriodNoteLines(text: string | null | undefined): string[] {
if (!text) return [];
return text
.split('\n')
.map((line) => line.replace(/^[•·\-]\s*/, '').trim())
.filter(Boolean);
}
/** 최신 기간 우선 — 종료일·시작일 내림차순 */
export function sortPeriodsByRecent(periods: MilestonePeriodEntry[]): MilestonePeriodEntry[] {
return [...periods].sort((a, b) => {
const ta = a.dueDate || a.startDate || '';
const tb = b.dueDate || b.startDate || '';
if (ta && tb) return tb.localeCompare(ta);
if (tb) return 1;
if (ta) return -1;
return 0;
});
}
export function pickLatestPeriodId(periods: MilestonePeriodEntry[]): string | null {
return sortPeriodsByRecent(periods)[0]?.id ?? null;
}
/** 목록·카드용 — 최신(마지막) 기간 또는 요약 */
export function fmtMilestonePeriodSummary(
milestone: Pick<Milestone, 'periodEntries' | 'startDate' | 'dueDate'>,
): string {
const periods = parseMilestonePeriods(milestone);
if (periods.length === 0) return '';
if (periods.length === 1) return fmtPeriodRange(periods[0]) || '';
const last = periods[periods.length - 1];
const lastLabel = fmtPeriodRange(last);
return lastLabel ? `${lastLabel}${periods.length - 1}` : `기간 ${periods.length}`;
}
export interface MilestoneContentBlock {
key: string;
label: string;
lines: string[];
}
/** @deprecated MilestoneContentList에서 기간 선택 UI로 대체 */
export function buildMilestoneContentBlocks(
milestone: Pick<Milestone, 'id' | 'periodEntries' | 'startDate' | 'dueDate' | 'description'> | null | undefined,
): MilestoneContentBlock[] {
if (!milestone) return [];
const blocks: MilestoneContentBlock[] = [];
const periods = sortPeriodsByRecent(parseMilestonePeriods(milestone)).reverse();
periods.forEach((period, index) => {
const lines = parsePeriodNoteLines(period.note);
if (lines.length === 0) return;
blocks.push({
key: period.id,
label: fmtPeriodPickerLabel(period, index),
lines,
});
});
return blocks;
}

View File

@@ -0,0 +1,9 @@
import type { Milestone } from '../types';
import { decodeRoutineStageDescription } from './routineMilestone';
/** 업무명(단계) 카드 — 부제목 (기간 대신 표시) */
export function getMilestoneSubtitle(m: Milestone): string {
if (m.subtitle?.trim()) return m.subtitle.trim();
const { overview } = decodeRoutineStageDescription(m.description);
return overview;
}

View File

@@ -0,0 +1,267 @@
import { quarterDateBounds } from './hubSchedule';
export interface TimelineMilestoneInput {
id: string;
title: string;
startDate?: string | null;
dueDate?: string | null;
periodEntries?: Array<{
id: string;
startDate?: string | null;
dueDate?: string | null;
note?: string | null;
}> | null;
progress?: number;
completedAt?: string | null;
order: number;
createdAt?: string;
}
export interface TimelineRangeFallback {
startDate?: string | null;
dueDate?: string | null;
quarter?: string | null;
}
export interface TimelineTick {
label: string;
leftPct: number;
isToday?: boolean;
}
export interface TimelineRow {
id: string;
milestoneId: string;
title: string;
leftPct: number;
widthPct: number;
progress: number;
}
export interface MilestoneTimelineModel {
rangeStartLabel: string;
rangeEndLabel: string;
ticks: TimelineTick[];
rows: TimelineRow[];
}
const DAY_MS = 86_400_000;
const RANGE_PAD_DAYS = 2;
/** 하루·단일일정 막대가 타임라인에서 묻히지 않도록 */
const MIN_BAR_WIDTH_PCT = 2;
/** 오늘 눈금과 겹치는 기존 날짜 라벨 제거 (주간 눈금 등) */
const TODAY_TICK_MIN_GAP_PCT = 3.2;
export function taskTimelineFallback(task: {
startDate?: string | null;
dueDate?: string | null;
quarter?: string | null;
}): TimelineRangeFallback {
return {
startDate: task.startDate,
dueDate: task.dueDate,
quarter: task.quarter,
};
}
function startOfDay(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
function parseDay(iso: string): Date {
return startOfDay(new Date(iso));
}
export function fmtTimelineDayLabel(d: Date): string {
return `${d.getMonth() + 1}.${String(d.getDate()).padStart(2, '0')}`;
}
function milestoneProgress(m: TimelineMilestoneInput): number {
if (m.completedAt) return 100;
return Math.min(100, Math.max(0, m.progress ?? 0));
}
function collectMilestoneTimes(milestones: TimelineMilestoneInput[]): number[] {
const times: number[] = [];
for (const m of milestones) {
const periods = m.periodEntries?.length
? m.periodEntries
: m.startDate || m.dueDate
? [{ startDate: m.startDate, dueDate: m.dueDate }]
: [];
for (const p of periods) {
if (p.startDate) times.push(parseDay(p.startDate).getTime());
if (p.dueDate) times.push(parseDay(p.dueDate).getTime());
}
}
return times;
}
function computeRange(
milestones: TimelineMilestoneInput[],
fallback: TimelineRangeFallback,
): { start: Date; end: Date } | null {
const times = collectMilestoneTimes(milestones);
if (times.length >= 1) {
const min = Math.min(...times);
const max = Math.max(...times);
const pad = RANGE_PAD_DAYS * DAY_MS;
return {
start: new Date(min - pad),
end: new Date(max + pad),
};
}
if (fallback.startDate && fallback.dueDate) {
return {
start: parseDay(fallback.startDate),
end: parseDay(fallback.dueDate),
};
}
if (fallback.quarter) {
const { min, max } = quarterDateBounds(fallback.quarter);
return { start: parseDay(min), end: parseDay(max) };
}
return null;
}
function buildTicks(rangeStart: Date, rangeEnd: Date): TimelineTick[] {
const rangeMs = Math.max(rangeEnd.getTime() - rangeStart.getTime(), DAY_MS);
const totalDays = Math.ceil(rangeMs / DAY_MS) + 1;
const stepDays = totalDays > 45 ? 7 : totalDays > 28 ? 2 : 1;
const now = startOfDay(new Date());
const todayInRange = now >= rangeStart && now <= rangeEnd;
const nowMs = now.getTime();
const ticks: TimelineTick[] = [];
for (let t = rangeStart.getTime(); t <= rangeEnd.getTime(); t += stepDays * DAY_MS) {
const d = new Date(t);
ticks.push({
label: fmtTimelineDayLabel(d),
leftPct: ((d.getTime() - rangeStart.getTime()) / rangeMs) * 100,
isToday: todayInRange && d.getTime() === nowMs,
});
}
if (todayInRange && !ticks.some((tick) => tick.isToday)) {
const todayLeftPct = ((nowMs - rangeStart.getTime()) / rangeMs) * 100;
const todayTick: TimelineTick = {
label: fmtTimelineDayLabel(now),
leftPct: todayLeftPct,
isToday: true,
};
const spaced = ticks.filter(
(tick) => Math.abs(tick.leftPct - todayLeftPct) >= TODAY_TICK_MIN_GAP_PCT,
);
spaced.push(todayTick);
spaced.sort((a, b) => a.leftPct - b.leftPct);
return spaced;
}
return ticks;
}
function milestoneHasDates(m: TimelineMilestoneInput): boolean {
if (m.periodEntries?.some((p) => p.startDate || p.dueDate)) return true;
return !!(m.startDate || m.dueDate);
}
function milestonePeriodSpans(m: TimelineMilestoneInput): Array<{ startMs: number; endMs: number }> {
const raw = m.periodEntries?.length
? m.periodEntries
: m.startDate || m.dueDate
? [{ startDate: m.startDate, dueDate: m.dueDate }]
: [];
const spans: Array<{ startMs: number; endMs: number }> = [];
for (const p of raw) {
if (!p.startDate && !p.dueDate) continue;
if (p.startDate && p.dueDate) {
let startMs = parseDay(p.startDate).getTime();
let endMs = parseDay(p.dueDate).getTime();
if (endMs < startMs) [startMs, endMs] = [endMs, startMs];
spans.push({ startMs, endMs });
continue;
}
const ms = parseDay((p.dueDate ?? p.startDate)!).getTime();
spans.push({ startMs: ms, endMs: ms });
}
return spans;
}
function buildRows(
ordered: TimelineMilestoneInput[],
rangeStart: Date,
rangeEnd: Date,
hideUndatedBars?: boolean,
): TimelineRow[] {
const items =
hideUndatedBars === false
? ordered
: ordered.filter((m) => milestoneHasDates(m));
const rangeStartMs = rangeStart.getTime();
const rangeMs = Math.max(rangeEnd.getTime() - rangeStartMs, DAY_MS);
const rows: TimelineRow[] = [];
for (const m of items) {
const spans = milestonePeriodSpans(m);
if (spans.length === 0) continue;
spans.forEach((span, index) => {
const { startMs, endMs } = span;
const spanMs = endMs - startMs;
const isPoint = spanMs === 0;
let widthPct = isPoint ? MIN_BAR_WIDTH_PCT : (spanMs / rangeMs) * 100;
widthPct = Math.max(MIN_BAR_WIDTH_PCT, widthPct);
let leftPct = ((startMs - rangeStartMs) / rangeMs) * 100;
if (isPoint) leftPct -= widthPct / 2;
leftPct = Math.max(0, Math.min(100 - widthPct, leftPct));
rows.push({
id: spans.length > 1 ? `${m.id}:${index}` : m.id,
milestoneId: m.id,
title: m.title,
leftPct,
widthPct,
progress: milestoneProgress(m),
});
});
}
return rows;
}
export function buildMilestoneTimeline(
milestones: TimelineMilestoneInput[],
fallback: TimelineRangeFallback = {},
options?: { preserveOrder?: boolean; hideUndatedBars?: boolean },
): MilestoneTimelineModel | null {
if (milestones.length === 0) return null;
const range = computeRange(milestones, fallback);
if (!range) return null;
const ordered = options?.preserveOrder
? [...milestones]
: [...milestones].sort((a, b) => a.order - b.order);
const rows = buildRows(ordered, range.start, range.end, options?.hideUndatedBars);
if (rows.length === 0) return null;
const ticks = buildTicks(range.start, range.end);
return {
rangeStartLabel: fmtTimelineDayLabel(range.start),
rangeEndLabel: fmtTimelineDayLabel(range.end),
ticks,
rows,
};
}

View File

@@ -0,0 +1,79 @@
/** 허브 다이아몬드 · 상시업무 대분류 (고정 5종) */
export const ROUTINE_CATEGORIES = [
'채용 운영',
'학습 지원',
'직원 소통',
'자산·시설',
'문서·행정',
] as const;
export type RoutineCategory = (typeof ROUTINE_CATEGORIES)[number];
const LEGACY_CATEGORY_ALIASES: Record<string, RoutineCategory> = {
: '채용 운영',
'채용 운영': '채용 운영',
: '학습 지원',
'교육 운영': '학습 지원',
'학습 지원': '학습 지원',
: '학습 지원',
: '직원 소통',
'직원 소통': '직원 소통',
: '자산·시설',
: '자산·시설',
'자산·시설': '자산·시설',
'자산·시설 관리': '자산·시설',
: '문서·행정',
'문서·행정': '문서·행정',
'문서·행정 지원': '문서·행정',
: '채용 운영',
: '학습 지원',
: '자산·시설',
: '문서·행정',
};
export function normalizeRoutineCategory(value: string | null | undefined): RoutineCategory | null {
if (!value?.trim()) return null;
const trimmed = value.trim();
if (LEGACY_CATEGORY_ALIASES[trimmed]) return LEGACY_CATEGORY_ALIASES[trimmed];
const matched = ROUTINE_CATEGORIES.find((cat) => cat === trimmed);
return matched ?? null;
}
export function getRoutineCategory(task: {
category?: string | null;
section?: string | null;
title?: string;
}): RoutineCategory | null {
const fromField = normalizeRoutineCategory(task.category) ?? normalizeRoutineCategory(task.section);
if (fromField) return fromField;
if (task.title) {
const fromTitle = normalizeRoutineCategory(task.title);
if (fromTitle) return fromTitle;
}
return null;
}
export function routineCategoryOptions(): { value: RoutineCategory; label: RoutineCategory }[] {
return ROUTINE_CATEGORIES.map((cat) => ({ value: cat, label: cat }));
}
export function groupRoutineTasksByCategory<T extends { category?: string | null; section?: string | null; title?: string }>(
tasks: T[],
): Record<RoutineCategory, T[]> {
const groups = Object.fromEntries(ROUTINE_CATEGORIES.map((cat) => [cat, [] as T[]])) as Record<RoutineCategory, T[]>;
for (const task of tasks) {
const cat = getRoutineCategory(task) ?? '채용 운영';
groups[cat].push(task);
}
return groups;
}
/** 탭·허브 슬롯용 — 제목이 대분류명과 일치하는 대표 task 우선 */
export function pickRoutineCategoryTask<
T extends { id: string; category?: string | null; section?: string | null; title?: string },
>(tasks: T[], category: RoutineCategory): T | null {
const grouped = groupRoutineTasksByCategory(tasks);
const inCategory = grouped[category];
const shell = inCategory.find((task) => task.title?.trim() === category);
return shell ?? inCategory[0] ?? null;
}

View File

@@ -0,0 +1,43 @@
const OVERVIEW_PREFIX = '@overview:';
/** 상시업무 단계 — overview + 본문(description)을 한 필드에 저장 */
export function encodeRoutineStageDescription(overview: string, body: string): string | undefined {
const o = overview.trim();
const b = body.trim();
if (!o && !b) return undefined;
if (!o) return b || undefined;
return `${OVERVIEW_PREFIX}${o}\n${b}`.trim() || undefined;
}
export function decodeRoutineStageDescription(raw: string | null | undefined): {
overview: string;
body: string;
} {
if (!raw?.trim()) return { overview: '', body: '' };
if (raw.startsWith(OVERVIEW_PREFIX)) {
const rest = raw.slice(OVERVIEW_PREFIX.length);
const nl = rest.indexOf('\n');
if (nl === -1) return { overview: rest.trim(), body: '' };
return {
overview: rest.slice(0, nl).trim(),
body: rest.slice(nl + 1).trim(),
};
}
return { overview: '', body: raw.trim() };
}
export function parseRoutineContentLines(text: string | null | undefined): string[] {
if (!text) return [];
return text
.split('\n')
.map((l) => l.replace(/^[•·\-]\s*/, '').trim())
.filter(Boolean);
}
/** 레거시 @overview: 인코딩 → 편집용 본문 */
export function routineStageBody(raw: string | null | undefined): string {
if (!raw?.trim()) return '';
const { overview, body } = decodeRoutineStageDescription(raw);
if (body) return body;
return overview;
}

View File

@@ -62,7 +62,7 @@ export const COLUMN_META: Record<
: {
titleEn: 'GA',
accent: '#36816d',
displayTitle: '운영관리',
displayTitle: '총무관리',
routineBg: 'linear-gradient(180deg, #d4ece4 0%, #e4f3ed 100%)',
},
};

View File

@@ -1,24 +1,70 @@
import type { TaskFormData } from '../components/common/TaskModal';
import type { RoutineCategory } from './routineCategories';
import { displayFlagsForTaskType } from './taskType';
import { serializeIssueEntries } from './taskIssues';
export function taskFormToApiPayload(data: TaskFormData): Record<string, unknown> {
function basePayload(data: TaskFormData, taskType: string): Record<string, unknown> {
const flags = displayFlagsForTaskType(taskType);
const issueEntries = serializeIssueEntries(data.issueEntries);
return {
title: data.title,
section: data.section || null,
tag: data.tag || null,
taskType: data.taskType || null,
taskType,
status: data.status,
progress: data.progress,
description: data.description || null,
issueNote: data.issueNote || null,
issueEntries,
startDate: data.startDate || null,
dueDate: data.dueDate || null,
showDate: data.showDate,
showDescription: data.showDescription,
showStatus: data.showStatus,
showIssue: data.showIssue,
showProgress: data.showProgress,
showDate: flags.showDate,
showDescription: flags.showDescription,
showStatus: flags.showStatus,
showIssue: flags.showIssue,
showProgress: flags.showProgress,
quarter: data.quarter,
pmMemberId: data.pmMemberId || null,
assigneeMemberIds: data.assigneeMemberIds,
};
}
/** @deprecated variant별 helper 사용 권장 */
export function taskFormToApiPayload(data: TaskFormData): Record<string, unknown> {
return basePayload(data, data.taskType || '실행과제');
}
export function projectFormToApiPayload(data: TaskFormData): Record<string, unknown> {
return basePayload(data, '실행과제');
}
export function routineFormToApiPayload(data: TaskFormData): Record<string, unknown> {
return {
...basePayload(data, '기반업무'),
category: data.category || null,
section: null,
};
}
/** 상시업무 대분류 탭용 — 카테고리별 대표 task 자동 생성 */
export function routineCategoryShellPayload(
category: RoutineCategory,
quarter: string,
): Record<string, unknown> {
const flags = displayFlagsForTaskType('기반업무');
return {
title: category,
category,
section: null,
taskType: '기반업무',
quarter,
status: 'IN_PROGRESS',
progress: 0,
description: null,
issueEntries: [],
assigneeMemberIds: [],
pmMemberId: null,
startDate: null,
dueDate: null,
...flags,
};
}

View File

@@ -0,0 +1,63 @@
import type { Task } from '../types';
export interface TaskIssueEntry {
id: string;
text: string;
showOnCard: boolean;
}
export function newIssueEntry(text = ''): TaskIssueEntry {
return {
id: `issue-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
text,
showOnCard: true,
};
}
export function normalizeIssueEntries(raw: unknown): TaskIssueEntry[] {
if (!Array.isArray(raw)) return [];
const entries: TaskIssueEntry[] = [];
for (const item of raw) {
if (!item || typeof item !== 'object') continue;
const row = item as Record<string, unknown>;
const text = typeof row.text === 'string' ? row.text : '';
entries.push({
id: typeof row.id === 'string' && row.id ? row.id : newIssueEntry().id,
text,
showOnCard: row.showOnCard !== false,
});
}
return entries;
}
export function parseIssueEntries(task: Pick<Task, 'issueEntries' | 'issueNote' | 'showIssue'>): TaskIssueEntry[] {
const fromJson = normalizeIssueEntries(task.issueEntries);
if (fromJson.length > 0) return fromJson;
const legacy = task.issueNote?.trim();
if (!legacy) return [];
return [{ id: 'legacy', text: legacy, showOnCard: task.showIssue !== false }];
}
export function getVisibleIssueEntries(task: Pick<Task, 'issueEntries' | 'issueNote' | 'showIssue'>): TaskIssueEntry[] {
return parseIssueEntries(task).filter((entry) => entry.showOnCard && entry.text.trim());
}
export function hasVisibleIssue(task: Pick<Task, 'issueEntries' | 'issueNote' | 'showIssue'>): boolean {
return getVisibleIssueEntries(task).length > 0;
}
export function getPrimaryIssueText(task: Pick<Task, 'issueEntries' | 'issueNote' | 'showIssue'>): string | null {
const visible = getVisibleIssueEntries(task);
if (visible.length > 0) return visible[visible.length - 1].text;
return null;
}
export function serializeIssueEntries(entries: TaskIssueEntry[]): TaskIssueEntry[] {
return entries
.map((entry) => ({
id: entry.id,
text: entry.text.trim(),
showOnCard: entry.showOnCard,
}))
.filter((entry) => entry.text.length > 0);
}

View File

@@ -0,0 +1,9 @@
import type { QueryClient } from '@tanstack/react-query';
/** 목록 + (선택) 상세 페이지 캐시 갱신 */
export function invalidateTaskCaches(queryClient: QueryClient, taskId?: string | null) {
void queryClient.invalidateQueries({ queryKey: ['tasks'] });
if (taskId) {
void queryClient.invalidateQueries({ queryKey: ['task', taskId] });
}
}

View File

@@ -1,4 +1,5 @@
import type { Task, TaskStatus } from '../types';
import { hasVisibleIssue } from './taskIssues';
/** DashboardHeader STAT_ACCENT 와 동일 */
export const TASK_STAT_COLORS = {
@@ -17,6 +18,24 @@ export interface DonutDisplay {
ariaLabel: string;
}
export function getProjectTitleStatusClass(
task: Pick<Task, 'status' | 'showIssue' | 'issueNote' | 'issueEntries'>,
): string {
if (hasVisibleIssue(task)) return 'project-sub-title--issue';
switch (task.status) {
case 'IN_PROGRESS':
return 'project-sub-title--in-progress';
case 'REVIEW':
case 'CANCELLED':
case 'TODO':
return 'project-sub-title--hold';
case 'DONE':
return 'project-sub-title--done';
default:
return 'project-sub-title--in-progress';
}
}
export function getDonutDisplay(task: Pick<Task, 'status' | 'progress'>): DonutDisplay {
const p = Math.min(100, Math.max(0, task.progress));

View File

@@ -1,5 +1,5 @@
import type { Task, TeamMember } from '../types';
import { isRoutineTask } from './taskType';
import { isRoutineTask, isProjectTask } from './taskType';
/** 셀 컬럼 기본 순서 — 팀원 데이터에 다른 셀이 있으면 뒤에 추가 */
export const DEFAULT_CELL_ORDER = ['HR', '총무'] as const;
@@ -115,13 +115,33 @@ export function getMemberTaskRoleLabel(role: ReturnType<typeof getMemberTaskRole
return null;
}
export function getMemberTasks(memberId: string, tasks: Task[]): Task[] {
export function getMemberTasks(
memberId: string,
tasks: Task[],
scope: 'all' | 'project' | 'routine' = 'all',
): Task[] {
return tasks.filter((t) => {
if (t.status === 'DONE' || t.status === 'CANCELLED') return false;
if (scope === 'project' && !isProjectTask(t.taskType)) return false;
if (scope === 'routine' && !isRoutineTask(t.taskType)) return false;
return isTaskPm(memberId, t) || isTaskAssignee(memberId, t);
});
}
/** 상세 헤더 — PM / 담당 분리 (PM과 동일 인원은 담당에서 제외) */
export function getTaskPeopleHeaderParts(task: Task): {
pmName: string | null;
assigneeNames: string[];
} {
const pmId = task.pmMember?.id ?? task.pmMemberId ?? null;
const pmName = task.pmMember?.name?.trim() || null;
const assigneeNames = (task.assigneeMembers ?? [])
.filter((m) => m.name?.trim() && m.id !== pmId)
.map((m) => m.name!.trim());
return { pmName, assigneeNames };
}
export function getHighlightMemberIds(task: Task | null | undefined): string[] {
if (!task) return [];
const ids: string[] = [];

View File

@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
import './index.css';
import './styles/quarter-board.css';
import './styles/detail-theme.css';
const queryClient = new QueryClient({
defaultOptions: {

View File

@@ -13,18 +13,21 @@ import {
import { arrayMove } from '@dnd-kit/sortable';
import { apiClient } from '../lib/apiClient';
import { useTasks } from '../hooks/useTasks';
import { useBoardReferenceDate } from '../hooks/useBoardReferenceDate';
import { useTeamMembers } from '../hooks/useTeamMembers';
import { DashboardHeader } from '../components/dashboard/DashboardHeader';
import { TeamStatusPanel } from '../components/dashboard/TeamStatusPanel';
import { DepartmentColumn } from '../components/dashboard/DepartmentColumn';
import { HubColumn } from '../components/dashboard/HubColumn';
import { BoardConnectors } from '../components/dashboard/BoardConnectors';
import '../styles/dummy-board.css';
import { DonutGauge } from '../components/dashboard/DonutGauge';
import { TaskManager } from '../components/dashboard/TaskManager';
import { useSocket } from '../contexts/SocketContext';
import { sendTaskSelected, openDetailWindow, registerSyncProvider, isDetailWindowOpen } from '../lib/dualMonitor';
import { sendTaskSelected, openDetailWindow, registerSyncProvider, isDetailWindowOpen, getWindowPlacementHint } from '../lib/dualMonitor';
import { TaskDetailShell } from '../pages/DetailPage';
import { isRoutineTask, isProjectTask, displayFlagsForTaskType } from '../lib/taskType';
import { hasVisibleIssue } from '../lib/taskIssues';
import {
DEFAULT_STATUS_FILTERS,
taskMatchesStatusFilters,
@@ -43,10 +46,10 @@ import {
BOARD_SLOT_ORDER,
getBoardSlot,
slotSectionLabel,
taskBelongsToSlot,
columnDisplayTitle,
taskBelongsToBoardSlot,
} from '../lib/boardLayout';
const QUARTER = '2026-Q2';
import { invalidateTaskCaches } from '../lib/taskQueryCache';
function mergeCardOrders(primary: string | null | undefined, extras: (string | null | undefined)[]): string[] {
const merged: string[] = [];
@@ -83,7 +86,9 @@ export default function DashboardPage() {
const [activeTeamProjectId, setActiveTeamProjectId] = useState<string | null>(null);
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [detailStageId, setDetailStageId] = useState<string | null>(null);
const [detailPopupOpen, setDetailPopupOpen] = useState(false);
const [placementBanner, setPlacementBanner] = useState<string | null>(() => getWindowPlacementHint());
const [viewportWidth, setViewportWidth] = useState(() =>
typeof window !== 'undefined' ? window.innerWidth : 1920,
);
@@ -92,8 +97,9 @@ export default function DashboardPage() {
const queryClient = useQueryClient();
const socket = useSocket();
const { referenceDate, setReferenceDate, quarter } = useBoardReferenceDate();
const { data: tasks = [], isLoading } = useTasks({ quarter: QUARTER });
const { data: tasks = [], isLoading } = useTasks({ quarter });
const { data: teamMembers = [] } = useTeamMembers();
const { data: colConfigs } = useQuery({
@@ -157,7 +163,7 @@ export default function DashboardPage() {
useEffect(() => {
BOARD_SLOTS.forEach((slot) => {
const label = slotSectionLabel(slot);
const ids = tasks.filter((t) => taskBelongsToSlot(t.section, slot)).map((t) => t.id);
const ids = tasks.filter((t) => taskBelongsToBoardSlot(t, slot)).map((t) => t.id);
setColumnOrders((prev) => {
const existing = prev[label] ?? [];
const merged = [
@@ -178,15 +184,19 @@ export default function DashboardPage() {
const patchTask = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
apiClient.patch(`/tasks/${id}`, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
onSuccess: (_data, { id }) => invalidateTaskCaches(queryClient, id),
});
useEffect(() => {
if (!socket) return;
const refresh = () => queryClient.invalidateQueries({ queryKey: ['tasks'] });
socket.on('tasks:refresh', refresh);
socket.on('task:updated', refresh);
return () => { socket.off('tasks:refresh', refresh); socket.off('task:updated', refresh); };
const refreshList = () => queryClient.invalidateQueries({ queryKey: ['tasks'] });
const refreshTask = () => queryClient.invalidateQueries({ queryKey: ['task'] });
socket.on('tasks:refresh', refreshList);
socket.on('task:updated', refreshTask);
return () => {
socket.off('tasks:refresh', refreshList);
socket.off('task:updated', refreshTask);
};
}, [socket, queryClient]);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
@@ -206,7 +216,7 @@ export default function DashboardPage() {
const draggedTask = tasks.find((t) => t.id === activeId);
if (!draggedTask) return;
const srcSlot = BOARD_SLOTS.find((s) => taskBelongsToSlot(draggedTask.section, s));
const srcSlot = BOARD_SLOTS.find((s) => taskBelongsToBoardSlot(draggedTask, s));
const srcLabel = srcSlot ? slotSectionLabel(srcSlot) : (draggedTask.section ?? '인사관리');
if (overId.startsWith('drop::')) {
@@ -243,7 +253,7 @@ export default function DashboardPage() {
const overTask = tasks.find((t) => t.id === overId);
if (!overTask) return;
const dstSlot = BOARD_SLOTS.find((s) => taskBelongsToSlot(overTask.section, s));
const dstSlot = BOARD_SLOTS.find((s) => taskBelongsToBoardSlot(overTask, s));
const dstLabel = dstSlot ? slotSectionLabel(dstSlot) : (overTask.section ?? '인사관리');
const typeChanged = isRoutineTask(draggedTask.taskType) !== isRoutineTask(overTask.taskType);
@@ -277,19 +287,35 @@ export default function DashboardPage() {
}, 300);
};
const stats = {
total: tasks.length,
inProgress: tasks.filter((t) => t.status === 'IN_PROGRESS').length,
review: tasks.filter((t) => t.status === 'REVIEW' || t.status === 'CANCELLED' || t.status === 'TODO').length,
done: tasks.filter((t) => t.status === 'DONE').length,
issues: tasks.filter((t) => !!t.issueNote).length,
};
const filtered = tasks.filter((t) => {
if (issueFilterActive) return !!t.issueNote;
if (issueFilterActive) return hasVisibleIssue(t);
return taskMatchesStatusFilters(t, activeFilters);
});
/** 4분면 부서 카드에 표시되는 실행과제만 (상단 현황판과 건수 일치) */
const boardProjectTasks = useMemo(
() =>
filtered.filter(
(t) =>
isProjectTask(t.taskType) &&
BOARD_SLOTS.some((slot) => taskBelongsToBoardSlot(t, slot)),
),
[filtered],
);
const stats = useMemo(
() => ({
total: boardProjectTasks.length,
inProgress: boardProjectTasks.filter((t) => t.status === 'IN_PROGRESS').length,
review: boardProjectTasks.filter(
(t) => t.status === 'REVIEW' || t.status === 'CANCELLED' || t.status === 'TODO',
).length,
done: boardProjectTasks.filter((t) => t.status === 'DONE').length,
issues: boardProjectTasks.filter((t) => hasVisibleIssue(t)).length,
}),
[boardProjectTasks],
);
const routineTasks = useMemo(
() => filtered.filter((t) => isRoutineTask(t.taskType)),
[filtered],
@@ -317,27 +343,40 @@ export default function DashboardPage() {
const sectionOptions = BOARD_SLOTS.map((slot) => ({
value: slotSectionLabel(slot),
label: colConfigs?.find((c) => c.key === slot.sectionKey)?.title
?? slot.defaultTitle,
label: columnDisplayTitle(
slot,
colConfigs?.find((c) => c.key === slot.sectionKey),
),
}));
const activeTask = tasks.find((t) => t.id === activeTaskId) ?? null;
/** 초광역(단일 화면)에서만 인라인 우측 패널 — 일반은 팝업(듀얼 모니터) 전용 */
const ultraWideLayout = viewportWidth >= 3800;
const showDockedDetail = !!selectedTaskId && !detailPopupOpen;
const detailDocked = showDockedDetail && ultraWideLayout;
const showDockedDetail = ultraWideLayout && !!selectedTaskId && !detailPopupOpen;
const detailDocked = showDockedDetail;
const handleSelectTask = (taskId: string) => {
const handleSelectTask = (taskId: string, stageId?: string | null) => {
setSelectedTaskId(taskId);
setDetailStageId(stageId ?? null);
if (teamPanelOpen) setActiveTeamProjectId(taskId);
void sendTaskSelected(taskId, () => setDetailPopupOpen(false)).then(() => {
setDetailPopupOpen(isDetailWindowOpen());
void sendTaskSelected(taskId, () => setDetailPopupOpen(false)).then((popupOpened) => {
if (popupOpened) {
setDetailPopupOpen(true);
} else if (!ultraWideLayout) {
window.alert(
'상세 창을 열 수 없습니다.\n\n' +
'• 팝업 허용 (주소창 아이콘)\n' +
'• 「모든 디스플레이에 대한 정보 보기」 권한 허용\n' +
' (IP 주소 접속 시 localhost와 별도로 한 번 더 필요)',
);
}
});
};
const handleOpenDetailWindow = () => {
void openDetailWindow(() => setDetailPopupOpen(false)).then(() => {
setDetailPopupOpen(isDetailWindowOpen());
void openDetailWindow(() => setDetailPopupOpen(false)).then((win) => {
setDetailPopupOpen(!!win);
});
};
@@ -348,9 +387,9 @@ export default function DashboardPage() {
<DepartmentColumn
key={slot.id}
slot={slot}
tasks={filtered.filter((t) => taskBelongsToSlot(t.section, slot))}
tasks={filtered.filter((t) => taskBelongsToBoardSlot(t, slot))}
orderedIds={columnOrders[label] ?? []}
quarter={QUARTER}
quarter={quarter}
onSelectTask={(t) => handleSelectTask(t.id)}
sectionOptions={sectionOptions}
teamMembers={teamMembers}
@@ -360,7 +399,7 @@ export default function DashboardPage() {
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center bg-[#e9eef2]">
<div className="flex h-screen items-center justify-center bg-white">
<div className="text-lg text-gray-400"> ...</div>
</div>
);
@@ -368,12 +407,27 @@ export default function DashboardPage() {
return (
<div
className={`app-shell ${detailDocked ? 'detail-docked' : ''} ${detailPopupOpen ? 'dual-mode-parent' : ''}`}
className={`app-shell dummy-board-page dummy-board-page--2slots ${detailDocked ? 'detail-docked' : ''} ${detailPopupOpen ? 'dual-mode-parent' : ''}`}
>
<div className="app-main relative flex h-screen min-w-0 flex-col overflow-hidden bg-[#e9eef2]">
<div className="app-main relative flex h-screen min-w-0 flex-col overflow-hidden">
{placementBanner && (
<div className="flex shrink-0 items-start justify-between gap-3 border-b border-amber-200 bg-amber-50 px-4 py-2.5 text-sm text-amber-950">
<p className="whitespace-pre-line leading-relaxed">{placementBanner}</p>
<button
type="button"
className="shrink-0 rounded-lg px-2 py-1 text-amber-800 hover:bg-amber-100"
onClick={() => setPlacementBanner(null)}
aria-label="닫기"
>
</button>
</div>
)}
<DashboardHeader
quarter={QUARTER}
stats={stats}
quarter={quarter}
referenceDate={referenceDate}
onReferenceDateChange={setReferenceDate}
stats={stats}
activeFilters={activeFilters}
issueFilterActive={issueFilterActive}
onToggleAll={handleToggleAll}
@@ -411,7 +465,7 @@ export default function DashboardPage() {
/>
)}
<main className={`board-main ${teamPanelOpen ? 'hidden' : ''}`}>
<main className={`board-main dummy-board-main ${teamPanelOpen ? 'hidden' : ''}`}>
<div className="page-content">
<DndContext
sensors={sensors}
@@ -424,12 +478,14 @@ export default function DashboardPage() {
{renderDeptSlot('hrd')}
<HubColumn
routineTasks={routineTasks}
quarter={QUARTER}
quarter={quarter}
referenceDate={referenceDate}
onSelectRoutine={(t) => handleSelectTask(t.id)}
onSelectRoutineMilestone={(taskId, milestoneId) => handleSelectTask(taskId, milestoneId)}
/>
{renderDeptSlot('ex')}
{renderDeptSlot('ga')}
<BoardConnectors enabled={!teamPanelOpen} />
<BoardConnectors enabled={!teamPanelOpen} style="reference" />
</div>
<DragOverlay dropAnimation={{ duration: 180, easing: 'ease' }}>
@@ -460,16 +516,16 @@ export default function DashboardPage() {
<TaskManager
tasks={tasks}
sectionOptions={sectionOptions}
quarter={QUARTER}
quarter={quarter}
teamMembers={teamMembers}
onClose={() => setShowTaskManager(false)}
/>
)}
</div>
{!detailPopupOpen && (
{!detailPopupOpen && ultraWideLayout && (
<aside className={`app-right ${showDockedDetail ? 'detail-open' : ''}`}>
<TaskDetailShell taskId={selectedTaskId} />
<TaskDetailShell taskId={selectedTaskId} initialStageId={detailStageId} />
</aside>
)}
</div>

View File

@@ -15,7 +15,16 @@ import {
import { sortFilesByOrder } from '../lib/fileDisplay';
import { useAuth } from '../contexts/AuthContext';
import { formatSectionDisplay } from '../lib/sections';
import { getTaskPeopleHeaderParts } from '../lib/teamStatus';
import { isRoutineTask } from '../lib/taskType';
import { RoutineDetailShell } from '../components/detail/RoutineDetailView';
import { MilestoneTimeline } from '../components/detail/MilestoneTimeline';
import { MilestoneContentList } from '../components/detail/MilestoneContentList';
import { taskTimelineFallback } from '../lib/milestoneTimeline';
import { fmtMilestonePeriodSummary, serializePeriodEntries } from '../lib/milestonePeriods';
import type { Task, Milestone, FileRecord, TaskDetail } from '../types';
import { useSocket } from '../contexts/SocketContext';
import { invalidateTaskCaches } from '../lib/taskQueryCache';
const STATUS_CONFIG: Record<string, { label: string }> = {
IN_PROGRESS: { label: '진행중' },
@@ -44,17 +53,11 @@ function fmtShort(iso: string | null | undefined) {
}
function fmtStageRange(m: Milestone) {
if (m.startDate && m.dueDate) return `${fmtShort(m.startDate)} ~ ${fmtShort(m.dueDate)}`;
if (m.dueDate) return fmtShort(m.dueDate);
const summary = fmtMilestonePeriodSummary(m);
if (summary) return summary;
return fmtShort(m.createdAt);
}
function fmtTimelineLabel(iso: string | null | undefined) {
if (!iso) return '';
const d = new Date(iso);
return `${d.getMonth() + 1}.${String(d.getDate()).padStart(2, '0')}`;
}
function sortByIsoDesc<T>(items: T[], pick: (item: T) => string) {
return [...items].sort((a, b) => new Date(pick(b)).getTime() - new Date(pick(a)).getTime());
}
@@ -78,85 +81,47 @@ function milestoneProgress(m: Milestone) {
return Math.min(100, Math.max(0, p));
}
function parseContentLines(text: string | null | undefined) {
if (!text) return [];
return text.split('\n').map((l) => l.replace(/^[•·\-]\s*/, '').trim()).filter(Boolean);
}
interface TimelineBar {
id: string;
label: string;
left: number;
width: number;
progress: number;
title: string;
}
function buildTimeline(task: Task, milestones: Milestone[]): {
start: string;
end: string;
today: string;
todayLeft: number;
bars: TimelineBar[];
} | null {
if (!task.startDate || !task.dueDate) return null;
const startMs = new Date(task.startDate).getTime();
const endMs = new Date(task.dueDate).getTime();
const range = Math.max(endMs - startMs, 86400000);
const now = Date.now();
const todayLeft = Math.min(100, Math.max(0, ((now - startMs) / range) * 100));
const ordered = [...milestones].sort((a, b) => a.order - b.order);
const bars: TimelineBar[] = ordered.map((m, i) => {
const barStart = m.startDate
? new Date(m.startDate).getTime()
: i > 0 && ordered[i - 1].dueDate
? new Date(ordered[i - 1].dueDate!).getTime()
: startMs;
const barEnd = m.dueDate ? new Date(m.dueDate).getTime() : endMs;
const left = Math.max(0, ((barStart - startMs) / range) * 100);
const width = Math.max(4, ((barEnd - barStart) / range) * 100);
return {
id: m.id,
label: `${i + 1}`,
left,
width,
progress: milestoneProgress(m),
title: m.title,
};
});
const today = new Date();
return {
start: fmtTimelineLabel(task.startDate),
end: fmtTimelineLabel(task.dueDate),
today: `${today.getMonth() + 1}.${String(today.getDate()).padStart(2, '0')}`,
todayLeft,
bars,
};
}
function Badge({ children, className = '' }: { children: React.ReactNode; className?: string }) {
function OverviewMoreIcon({ expanded }: { expanded: boolean }) {
return (
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold ${className}`}>
{children}
</span>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
aria-hidden
className={`detail-overview__chevrons${expanded ? ' is-up' : ''}`}
>
<path
d="M2 4.5 L6 7.5 L10 4.5"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2 7 L6 10 L10 7"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function PanelLabel({ children, sub }: { children: React.ReactNode; sub?: string }) {
return (
<div className="mb-2 flex shrink-0 items-baseline justify-between gap-2 border-b border-slate-200 pb-2">
<h3 className="text-sm font-black uppercase tracking-widest text-slate-500">{children}</h3>
{sub && <span className="truncate text-sm font-bold text-emerald-600">{sub}</span>}
<div className="detail-section-head">
<h3 className="detail-section-label">{children}</h3>
{sub && <span className="detail-section-label-sub truncate">{sub}</span>}
</div>
);
}
function LeftSection({ children }: { children: React.ReactNode }) {
return (
<section className="flex min-h-0 flex-col overflow-hidden border-b border-slate-200 px-4 py-3 last:border-b-0">
<section className="flex min-h-0 flex-col overflow-hidden border-b border-[#e8edf2] px-4 py-3 last:border-b-0">
{children}
</section>
);
@@ -164,11 +129,11 @@ function LeftSection({ children }: { children: React.ReactNode }) {
function WaitingScreen() {
return (
<div className="flex h-full flex-col items-center justify-center gap-6 bg-[#eef2f5]">
<div className="flex h-20 w-20 animate-pulse items-center justify-center rounded-full bg-emerald-100 text-4xl"></div>
<div className="flex h-full flex-col items-center justify-center gap-6">
<div className="flex h-20 w-20 animate-pulse items-center justify-center rounded-full bg-[#eef5fc] text-4xl"></div>
<div className="text-center">
<p className="text-2xl font-black text-slate-700"> </p>
<p className="mt-2 text-lg font-medium text-slate-400">
<p className="detail-body-title"> </p>
<p className="detail-body-muted mt-2">
<br /> .
</p>
</div>
@@ -182,32 +147,42 @@ function DetailHeader({ task }: { task: Task }) {
task.startDate || task.dueDate
? `${fmtDate(task.startDate)} ~ ${fmtDate(task.dueDate)}`
: '—';
const { pmName, assigneeNames } = getTaskPeopleHeaderParts(task);
const hasPeople = !!pmName || assigneeNames.length > 0;
return (
<header className="relative flex h-12 shrink-0 items-center overflow-hidden bg-[linear-gradient(180deg,#37a184_0%,#29724f_20%,#07412e_100%)] px-5 text-white shadow-[0_2px_10px_rgba(0,0,0,0.20)]">
<div className="pointer-events-none absolute inset-x-0 top-0 h-[45%] bg-white/10" />
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-px bg-emerald-200/50" />
<h1 className="relative z-10 min-w-0 truncate text-[20px] font-bold leading-normal text-[#bad8ca]">
{task.title}
</h1>
<div className="relative z-10 ml-auto flex shrink-0 items-center gap-4 text-sm">
<span className="whitespace-nowrap">
<span className="font-semibold text-white/55"></span>{' '}
<span className="font-bold text-white/90">{task.assignee?.name ?? '—'}</span>
<header className="detail-page-header">
<h1 className="detail-page-header__title">{task.title}</h1>
<div className="detail-page-header__meta">
{pmName && (
<span>
<span className="detail-page-header__meta-label">PM </span>
<span className="detail-page-header__meta-value">{pmName}</span>
</span>
)}
{assigneeNames.length > 0 && (
<span>
<span className="detail-page-header__meta-label"> </span>
<span className="detail-page-header__meta-value">{assigneeNames.join(' · ')}</span>
</span>
)}
{!hasPeople && (
<span>
<span className="detail-page-header__meta-label"> </span>
<span className="detail-page-header__meta-value">{task.assignee?.name ?? '—'}</span>
</span>
)}
<span className="detail-page-header__divider" aria-hidden="true" />
<span>
<span className="detail-page-header__meta-label"> </span>
<span className="detail-page-header__meta-value">{period}</span>
</span>
<span className="h-4 w-px bg-white/25" />
<span className="whitespace-nowrap">
<span className="font-semibold text-white/55"></span>{' '}
<span className="font-bold text-white/90">{period}</span>
<span className="detail-page-header__divider" aria-hidden="true" />
<span>
<span className="detail-page-header__meta-label"> </span>
<span className="detail-page-header__meta-value">{formatSectionDisplay(task.section)}</span>
</span>
<span className="h-4 w-px bg-white/25" />
<span className="whitespace-nowrap">
<span className="font-semibold text-white/55"></span>{' '}
<span className="font-bold text-white/90">{formatSectionDisplay(task.section)}</span>
</span>
<Badge className="border border-white/20 bg-white/10 text-white/90">{status.label}</Badge>
<span className="detail-page-header__badge">{status.label}</span>
</div>
</header>
);
@@ -223,6 +198,7 @@ function DetailView({ task }: { task: TaskWithRelations }) {
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; stageId: string } | null>(null);
const [contentCtx, setContentCtx] = useState<{ x: number; y: number } | null>(null);
const [feedbackCtx, setFeedbackCtx] = useState<{ x: number; y: number; detailId?: string } | null>(null);
const [overviewExpanded, setOverviewExpanded] = useState(false);
const milestones = task.milestones ?? [];
const files = task.files ?? [];
@@ -241,13 +217,11 @@ function DetailView({ task }: { task: TaskWithRelations }) {
}
}, [task.id, sortedStages, selectedId]);
const selected = sortedStages.find((m) => m.id === selectedId) ?? sortedStages[0] ?? null;
useEffect(() => {
setOverviewExpanded(false);
}, [task.id]);
const stageContents = useMemo(() => {
const stage = sortedStages.find((m) => m.id === selectedId);
if (!stage?.description) return [];
return parseContentLines(stage.description);
}, [sortedStages, selectedId]);
const selected = sortedStages.find((m) => m.id === selectedId) ?? sortedStages[0] ?? null;
const stageDetails = useMemo(
() => (selectedId ? details.filter((d) => d.milestoneId === selectedId) : []),
@@ -272,8 +246,6 @@ function DetailView({ task }: { task: TaskWithRelations }) {
[selected],
);
const timeline = useMemo(() => buildTimeline(task, milestones), [task, milestones]);
const deleteMs = useMutation({
mutationFn: (id: string) => apiClient.delete(`/milestones/item/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['task', task.id] }),
@@ -297,9 +269,7 @@ function DetailView({ task }: { task: TaskWithRelations }) {
try {
const payload = {
title: data.title.trim(),
description: data.description.trim() || undefined,
startDate: data.startDate || undefined,
dueDate: data.dueDate || undefined,
periodEntries: serializePeriodEntries(data.periodEntries),
progress: data.progress,
links: data.links,
};
@@ -361,7 +331,7 @@ function DetailView({ task }: { task: TaskWithRelations }) {
const handleFeedbackSave = async (data: FeedbackFormData) => {
if (!selectedId && feedbackModal?.mode === 'add') {
alert('피드백을 추가할 업무 단계를 먼저 선택하세요.');
alert('피드백을 추가할 업무 일정을 먼저 선택하세요.');
return;
}
@@ -393,37 +363,53 @@ function DetailView({ task }: { task: TaskWithRelations }) {
onSuccess: () => qc.invalidateQueries({ queryKey: ['task', task.id] }),
});
const overview = task.description?.split('\n')[0]?.trim() || '등록된 개요가 없습니다.';
const overviewRaw = task.description?.trim() ?? '';
const overviewDisplay = overviewRaw || '등록된 개요가 없습니다.';
const canExpandOverview =
overviewRaw.length > 0 && (overviewRaw.includes('\n') || overviewRaw.length > 56);
return (
<div className="grid h-full min-h-0 grid-cols-[1fr_3fr] grid-rows-1">
{/* 좌 1/4 — 1:2:2:1 세로 비율 */}
<aside className="grid h-full min-h-0 grid-rows-[1fr_2fr_2fr_1fr] overflow-hidden border-r border-slate-300 bg-white">
<aside className="detail-aside detail-aside--project grid h-full min-h-0 overflow-hidden">
<LeftSection>
<PanelLabel></PanelLabel>
<p className="line-clamp-4 min-h-0 flex-1 overflow-hidden text-xl leading-snug text-slate-600">
{overview}
</p>
{task.issueNote && (
<p className="mt-1 truncate text-sm font-bold text-red-500"> {task.issueNote}</p>
)}
<div className={`detail-overview${overviewExpanded ? ' is-expanded' : ''}`}>
<p
className={`detail-body-text detail-overview__text${overviewExpanded ? '' : ' is-clamped'}`}
>
{overviewDisplay}
</p>
{canExpandOverview && (
<button
type="button"
className="detail-overview__more"
aria-expanded={overviewExpanded}
aria-label={overviewExpanded ? '개요 접기' : '개요 더 보기'}
title={overviewExpanded ? '접기' : '더 보기'}
onClick={() => setOverviewExpanded((v) => !v)}
>
<OverviewMoreIcon expanded={overviewExpanded} />
</button>
)}
</div>
</LeftSection>
<LeftSection>
<div className="mb-2 flex shrink-0 items-center justify-between gap-2 border-b border-slate-200 pb-2">
<h3 className="text-sm font-black uppercase tracking-widest text-slate-500"> </h3>
<div className="detail-section-head">
<h3 className="detail-section-label"> </h3>
<button
type="button"
title="업무 단계 추가"
title="업무 일정 추가"
onClick={() => setStageModal({ mode: 'add' })}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-emerald-500 text-lg font-bold leading-none text-white hover:bg-emerald-600"
className="detail-add-btn"
>
+
</button>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto pr-1">
<div className="detail-stage-list flex flex-col gap-2 pr-1">
{sortedStages.length === 0 && (
<p className="text-lg text-slate-400">+ .</p>
<p className="detail-body-muted">+ .</p>
)}
{sortedStages.map((stage) => {
const isSelected = stage.id === selectedId;
@@ -438,22 +424,18 @@ function DetailView({ task }: { task: TaskWithRelations }) {
setSelectedId(stage.id);
setCtxMenu({ x: e.clientX, y: e.clientY, stageId: stage.id });
}}
className={`shrink-0 rounded-lg border px-3 py-2 text-left transition-colors ${
isSelected
? 'border-emerald-400 bg-emerald-50 ring-1 ring-emerald-300'
: 'border-slate-200 bg-slate-50 hover:border-slate-300 hover:bg-white'
className={`detail-stage-card shrink-0 rounded-lg border px-3 py-2 text-left transition-colors ${
isSelected ? 'is-selected ring-1 ring-[#7eb3e8]' : 'hover:border-slate-300 hover:bg-white/80'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className={`truncate text-2xl font-black leading-snug ${isSelected ? 'text-emerald-800' : 'text-slate-800'}`}>
{stage.title}
</p>
<p className="mt-0.5 text-sm font-semibold text-slate-400">{fmtStageRange(stage)}</p>
<p className="detail-body-title truncate">{stage.title}</p>
<p className="detail-body-caption mt-0.5">{fmtStageRange(stage)}</p>
</div>
<span
className={`shrink-0 text-lg font-black ${
progress >= 100 ? 'text-emerald-600' : progress > 0 ? 'text-blue-500' : 'text-slate-300'
className={`shrink-0 text-[20px] font-bold tracking-[-0.2px] ${
progress >= 100 ? 'text-[#2563ab]' : progress > 0 ? 'text-[#4a90d9]' : 'text-slate-300'
}`}
>
{progress}%
@@ -466,70 +448,48 @@ function DetailView({ task }: { task: TaskWithRelations }) {
</LeftSection>
<LeftSection>
<PanelLabel sub={selected?.title}></PanelLabel>
<ul
className="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1"
<MilestoneContentList
milestone={selected}
emptyMessage={selected ? '수행 기간에 업무내용을 입력하세요.' : '단계를 선택하세요.'}
onContextMenu={(e) => {
if (!selected) return;
e.preventDefault();
setContentCtx({ x: e.clientX, y: e.clientY });
}}
>
{stageContents.length === 0 ? (
<li className="text-lg text-slate-400">
{selected ? '우클릭으로 업무내용을 수정하세요.' : '단계를 선택하세요.'}
</li>
) : (
stageContents.map((text, index) => (
<li
key={`${selectedId}-${index}`}
className="flex gap-2"
onContextMenu={(e) => {
if (!selected) return;
e.preventDefault();
e.stopPropagation();
setContentCtx({ x: e.clientX, y: e.clientY });
}}
>
<span className="shrink-0 text-lg text-blue-400"></span>
<p className="min-w-0 flex-1 whitespace-pre-wrap break-words text-2xl font-black leading-snug text-slate-800">{text}</p>
</li>
))
)}
</ul>
/>
</LeftSection>
<LeftSection>
<div className="mb-2 flex shrink-0 items-center justify-between gap-2 border-b border-slate-200 pb-2">
<h3 className="text-sm font-black uppercase tracking-widest text-slate-500"></h3>
<div className="detail-section-head">
<h3 className="detail-section-label"></h3>
<button
type="button"
title="피드백 추가"
disabled={!selectedId}
onClick={() => setFeedbackModal({ mode: 'add' })}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-emerald-500 text-lg font-bold leading-none text-white hover:bg-emerald-600 disabled:opacity-40"
className="detail-add-btn"
>
+
</button>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto pr-1">
{sortedFeedbacks.length === 0 ? (
<p className="text-lg text-slate-400">+ .</p>
<p className="detail-body-muted">+ .</p>
) : (
<div className="mb-2 min-h-0 flex-1 space-y-2">
{sortedFeedbacks.map((f) => (
<div
key={f.id}
className="rounded-lg bg-slate-50 px-3 py-2"
className="rounded-lg bg-white/50 px-3 py-2"
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
setFeedbackCtx({ x: e.clientX, y: e.clientY, detailId: f.id });
}}
>
<p className="truncate text-2xl font-black leading-snug text-slate-700">
<p className="detail-body-content truncate">
{f.content}
<span className="font-bold text-slate-400"> {feedbackAuthorName(f)}</span>
<span className="detail-body-caption font-semibold"> {feedbackAuthorName(f)}</span>
</p>
</div>
))}
@@ -547,58 +507,14 @@ function DetailView({ task }: { task: TaskWithRelations }) {
hasSelectedStage={!!selectedId}
/>
<footer className="shrink-0 border-t border-slate-300 bg-white px-5 py-4" style={{ height: '132px' }}>
<div className="mb-2 flex items-center justify-between">
<span className="text-lg font-black text-slate-700"> </span>
<span className="truncate text-base font-bold text-emerald-600">{selected?.title ?? task.title}</span>
</div>
{timeline ? (
<>
<div className="mb-1.5 flex justify-between text-sm font-semibold text-slate-400">
<span>{timeline.start}</span>
<span>{timeline.end}</span>
</div>
<div className="relative h-10 bg-slate-100">
<div
className="pointer-events-none absolute top-0 z-10 flex h-full flex-col items-center"
style={{ left: `${timeline.todayLeft}%` }}
>
<span className="mb-0.5 bg-emerald-500 px-1.5 py-0.5 text-[10px] font-black text-white">
TODAY ({timeline.today})
</span>
<div className="h-full w-0.5 bg-emerald-500" />
</div>
{timeline.bars.map((bar) => {
const isSelected = bar.id === selectedId;
return (
<button
key={bar.id}
type="button"
onClick={() => setSelectedId(bar.id)}
className={`absolute top-1/2 h-5 -translate-y-1/2 overflow-hidden transition-all ${
isSelected ? 'z-20 ring-2 ring-emerald-400' : 'z-0 opacity-85 hover:opacity-100'
}`}
style={{ left: `${bar.left}%`, width: `${bar.width}%` }}
title={bar.title}
>
<div className="absolute inset-0 bg-slate-300" />
<div
className="absolute inset-y-0 left-0 bg-emerald-500"
style={{ width: `${bar.progress}%` }}
/>
<span className="relative flex h-full items-center justify-center truncate px-1 text-[10px] font-bold text-white">
{bar.label}
</span>
</button>
);
})}
</div>
</>
) : (
<p className="text-sm text-slate-400"> .</p>
)}
</footer>
<MilestoneTimeline
milestones={sortedStages}
fallback={taskTimelineFallback(task)}
selectedId={selectedId}
onSelect={setSelectedId}
preserveRowOrder
emptyMessage="기간을 설정한 업무 일정만 타임라인에 표시됩니다."
/>
</div>
{stageModal && (
@@ -705,7 +621,16 @@ function DetailView({ task }: { task: TaskWithRelations }) {
);
}
export function TaskDetailShell({ taskId }: { taskId: string | null }) {
export function TaskDetailShell({
taskId,
initialStageId,
}: {
taskId: string | null;
initialStageId?: string | null;
}) {
const qc = useQueryClient();
const socket = useSocket();
const { data: task, isLoading, isError, error } = useQuery({
queryKey: ['task', taskId],
queryFn: async () => {
@@ -717,26 +642,38 @@ export function TaskDetailShell({ taskId }: { taskId: string | null }) {
retry: 2,
});
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-[#eef2f5] text-slate-800" style={{ fontSize: '18px' }}>
{task && <DetailHeader task={task} />}
useEffect(() => {
if (!socket || !taskId) return;
socket.emit('join:task', taskId);
const refresh = () => invalidateTaskCaches(qc, taskId);
socket.on('task:updated', refresh);
return () => {
socket.emit('leave:task', taskId);
socket.off('task:updated', refresh);
};
}, [socket, taskId, qc]);
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{isLoading ? (
<div className="flex h-full items-center justify-center text-xl text-slate-400"> ...</div>
) : isError ? (
<div className="flex h-full flex-col items-center justify-center gap-3 text-center">
<p className="text-xl font-bold text-red-500"> .</p>
<p className="text-base text-slate-500">{getApiErrorMessage(error, '서버 연결을 확인해 주세요.')}</p>
</div>
) : !task ? (
<WaitingScreen />
) : (
return (
<div className="h-full min-h-0 w-full overflow-hidden">
{isLoading ? (
<div className="detail-body-muted flex h-full items-center justify-center"> ...</div>
) : isError ? (
<div className="flex h-full flex-col items-center justify-center gap-3 text-center">
<p className="detail-body-title text-red-500"> .</p>
<p className="detail-body-muted">{getApiErrorMessage(error, '서버 연결을 확인해 주세요.')}</p>
</div>
) : !task ? (
<WaitingScreen />
) : isRoutineTask(task.taskType) ? (
<RoutineDetailShell task={task} initialStageId={initialStageId} />
) : (
<div className="detail-page-shell flex h-full min-h-0 flex-col overflow-hidden">
<DetailHeader task={task} />
<div className="h-full min-h-0 flex-1">
<DetailView task={task} />
</div>
)}
</div>
</div>
)}
</div>
);
}
@@ -768,7 +705,7 @@ export default function DetailPage() {
if (isPopupView) {
return (
<div className="detail-popup-view flex h-screen w-screen overflow-hidden">
<aside className="app-right detail-open">
<aside className="app-right detail-open flex h-full min-h-0 w-full flex-col overflow-hidden">
<TaskDetailShell taskId={taskId} />
</aside>
</div>

View File

@@ -0,0 +1,225 @@
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { apiClient } from '../lib/apiClient';
import { useTasks } from '../hooks/useTasks';
import { useBoardReferenceDate } from '../hooks/useBoardReferenceDate';
import { DashboardHeader } from '../components/dashboard/DashboardHeader';
import { DummyDepartmentColumn } from '../components/dummy/DummyDepartmentColumn';
import { HubColumn } from '../components/dashboard/HubColumn';
import { BoardConnectors } from '../components/dashboard/BoardConnectors';
import { isProjectTask, isRoutineTask } from '../lib/taskType';
import { hasVisibleIssue } from '../lib/taskIssues';
import {
DEFAULT_STATUS_FILTERS,
taskMatchesStatusFilters,
toggleAllFilter,
toggleCoreFilter,
type CoreStatusFilter,
} from '../lib/statusFilters';
import { LEGACY_COLUMN_KEYS, SECTIONS } from '../lib/sections';
import {
BOARD_SLOT_ORDER,
BOARD_SLOTS,
getBoardSlot,
slotSectionLabel,
taskBelongsToBoardSlot,
} from '../lib/boardLayout';
import '../styles/dummy-board.css';
function mergeCardOrders(primary: string | null | undefined, extras: (string | null | undefined)[]): string[] {
const merged: string[] = [];
const push = (raw: string | null | undefined) => {
if (!raw) return;
try {
const ids = JSON.parse(raw) as string[];
for (const id of ids) {
if (!merged.includes(id)) merged.push(id);
}
} catch {
/* ignore */
}
};
push(primary);
extras.forEach(push);
return merged;
}
export default function DummyDashboardPage() {
const [activeFilters, setActiveFilters] = useState<string[]>([...DEFAULT_STATUS_FILTERS]);
const [filtersBeforeIssue, setFiltersBeforeIssue] = useState<string[]>([...DEFAULT_STATUS_FILTERS]);
const [issueFilterActive, setIssueFilterActive] = useState(false);
const [teamPanelOpen, setTeamPanelOpen] = useState(false);
const [columnOrders, setColumnOrders] = useState<Record<string, string[]>>({});
const { referenceDate, setReferenceDate, quarter } = useBoardReferenceDate();
const { data: tasks = [], isLoading } = useTasks({ quarter });
const { data: colConfigs } = useQuery({
queryKey: ['columns', 'all', ...SECTIONS],
queryFn: async () => {
const results = await Promise.all(
SECTIONS.map(async (s) => {
const main = await apiClient.get(`/columns/${encodeURIComponent(s)}`).then((r) => r.data);
const legacy = await Promise.all(
LEGACY_COLUMN_KEYS[s].map((key) =>
apiClient.get(`/columns/${encodeURIComponent(key)}`).then((r) => r.data).catch(() => null),
),
);
const cardOrder = JSON.stringify(
mergeCardOrders(main.cardOrder, legacy.map((l) => l?.cardOrder)),
);
return { key: s, ...main, cardOrder };
}),
);
return results;
},
staleTime: 0,
});
useEffect(() => {
if (!colConfigs) return;
setColumnOrders((prev) => {
const next = { ...prev };
BOARD_SLOTS.forEach((slot) => {
const label = slotSectionLabel(slot);
if (next[label]) return;
if (!slot.sectionKey) return;
const cfg = colConfigs.find((c) => c.key === slot.sectionKey);
if (cfg?.cardOrder) {
try {
next[label] = JSON.parse(cfg.cardOrder);
} catch {
/* ignore */
}
}
});
return next;
});
}, [colConfigs]);
useEffect(() => {
BOARD_SLOTS.forEach((slot) => {
const label = slotSectionLabel(slot);
const ids = tasks.filter((t) => taskBelongsToBoardSlot(t, slot)).map((t) => t.id);
setColumnOrders((prev) => {
const existing = prev[label] ?? [];
const merged = [
...existing.filter((id) => ids.includes(id)),
...ids.filter((id) => !existing.includes(id)),
];
if (JSON.stringify(merged) === JSON.stringify(existing)) return prev;
return { ...prev, [label]: merged };
});
});
}, [tasks]);
const filtered = tasks.filter((t) => {
if (issueFilterActive) return hasVisibleIssue(t);
return taskMatchesStatusFilters(t, activeFilters);
});
const boardProjectTasks = useMemo(
() =>
filtered.filter(
(t) =>
isProjectTask(t.taskType) &&
BOARD_SLOTS.some((slot) => taskBelongsToBoardSlot(t, slot)),
),
[filtered],
);
const stats = useMemo(
() => ({
total: boardProjectTasks.length,
inProgress: boardProjectTasks.filter((t) => t.status === 'IN_PROGRESS').length,
review: boardProjectTasks.filter(
(t) => t.status === 'REVIEW' || t.status === 'CANCELLED' || t.status === 'TODO',
).length,
done: boardProjectTasks.filter((t) => t.status === 'DONE').length,
issues: boardProjectTasks.filter((t) => hasVisibleIssue(t)).length,
}),
[boardProjectTasks],
);
const routineTasks = useMemo(
() => filtered.filter((t) => isRoutineTask(t.taskType)),
[filtered],
);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 9999 } }),
);
const renderDeptSlot = (slotId: typeof BOARD_SLOT_ORDER[number]) => {
const slot = getBoardSlot(slotId);
const label = slotSectionLabel(slot);
return (
<DummyDepartmentColumn
key={slot.id}
slot={slot}
tasks={filtered.filter((t) => taskBelongsToBoardSlot(t, slot))}
orderedIds={columnOrders[label] ?? []}
/>
);
};
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center bg-[#e9eef2]">
<div className="text-lg text-gray-400"> ...</div>
</div>
);
}
return (
<div className="app-shell dummy-board-page dummy-board-page--2slots">
<div className="app-main relative flex h-screen min-w-0 flex-col overflow-hidden bg-[#e9eef2]">
<DashboardHeader
quarter={quarter}
referenceDate={referenceDate}
onReferenceDateChange={setReferenceDate}
stats={stats}
activeFilters={activeFilters}
issueFilterActive={issueFilterActive}
onToggleAll={() => {
setIssueFilterActive(false);
setActiveFilters((prev) => toggleAllFilter(prev));
}}
onToggleStatus={(key: CoreStatusFilter) => {
setIssueFilterActive(false);
setActiveFilters((prev) => toggleCoreFilter(prev, key));
}}
onToggleIssue={() => {
if (issueFilterActive) {
setIssueFilterActive(false);
setActiveFilters([...filtersBeforeIssue]);
return;
}
setFiltersBeforeIssue([...activeFilters]);
setIssueFilterActive(true);
}}
onOpenDetailWindow={() => {}}
onOpenTaskManager={() => {}}
teamPanelOpen={teamPanelOpen}
onToggleTeamPanel={() => setTeamPanelOpen((v) => !v)}
/>
<main className="board-main dummy-board-main dummy-board-main--preview">
<div className="page-content">
<DndContext sensors={sensors}>
<div className="board-layout">
{renderDeptSlot('hrm')}
{renderDeptSlot('hrd')}
<HubColumn routineTasks={routineTasks} quarter={quarter} referenceDate={referenceDate} />
{renderDeptSlot('ex')}
{renderDeptSlot('ga')}
<BoardConnectors style="reference" />
</div>
</DndContext>
</div>
</main>
</div>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { Routes, Route } from 'react-router-dom';
import DashboardPage from './pages/DashboardPage';
import DummyDashboardPage from './pages/DummyDashboardPage';
import DetailPage from './pages/DetailPage';
import AdminPage from './pages/AdminPage';
import NotFoundPage from './pages/NotFoundPage';
@@ -10,6 +11,9 @@ export function AppRouter() {
{/* 좌측 모니터: 업무 목록 대시보드 */}
<Route path="/" element={<DashboardPage />} />
{/* 참고 이미지 느낌 더미 메인 (상단바 동일 · 정적 데이터) */}
<Route path="/dummy" element={<DummyDashboardPage />} />
{/* 우측 모니터: 업무 상세 패널 */}
<Route path="/detail" element={<DetailPage />} />
<Route path="/detail/:taskId" element={<DetailPage />} />

View File

@@ -0,0 +1,765 @@
/* 상세 페이지 — 메인 ref-hub 테마 색상 (타이포·구조는 quarter-board/detail 클래스 유지) */
.detail-page-shell {
/* 메인 dummy-board / ref-hub 테마와 동일 팔레트 (타이포·구조는 유지) */
--detail-ref-hub: #4a90d9;
--detail-ref-hub-dark: #2563ab;
--detail-ref-hub-border: #3578c4;
--detail-ref-hub-soft: #f5faff;
--detail-ref-title: #1e3a5f;
--detail-header-bg: var(--app-header-bg);
--detail-header-border: var(--app-header-border);
--detail-page-bg: #fff;
--detail-card-bg: linear-gradient(168deg, #ffffff 0%, var(--detail-ref-hub-soft) 100%);
--detail-text-title: var(--detail-ref-title);
--detail-text-body: var(--detail-ref-title);
--detail-text-secondary: var(--detail-ref-hub-dark);
--detail-text-muted: #64748b;
--detail-accent: var(--detail-ref-hub);
--detail-border: #e8edf2;
font-family:
"Pretendard Variable",
Pretendard,
-apple-system,
BlinkMacSystemFont,
system-ui,
sans-serif;
background: var(--detail-page-bg);
color: var(--detail-text-title);
}
/* ─── 상단바 (메인 dashboard-header-bar 와 동일) ─── */
.detail-page-header {
position: relative;
z-index: 10;
display: flex;
align-items: center;
flex-shrink: 0;
overflow: visible;
height: 48px;
min-height: 48px;
padding: 0 22px 0 20px;
background: var(--detail-header-bg);
border-bottom: 1px solid var(--detail-header-border);
box-shadow: var(--app-header-shadow);
color: #fff;
}
.detail-page-header__title {
position: relative;
z-index: 1;
min-width: 0;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #bad8ca;
font-size: 20px;
font-weight: 700;
letter-spacing: -0.5px;
line-height: 1.2;
text-shadow:
-1px -1px 1px rgba(0, 0, 0, 0.25),
1px -1px 1px rgba(0, 0, 0, 0.25),
-1px 1px 1px rgba(0, 0, 0, 0.25),
1px 1px 1px rgba(0, 0, 0, 0.25);
}
.detail-page-header__meta {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 16px;
margin-left: auto;
flex-shrink: 0;
font-size: 18px;
font-weight: 400;
letter-spacing: -0.3px;
white-space: nowrap;
}
.detail-page-header__meta-label {
font-weight: 600;
color: rgba(255, 255, 255, 0.55);
}
.detail-page-header__meta-value {
font-weight: 700;
color: rgba(255, 255, 255, 0.9);
}
.detail-page-header__divider {
width: 1px;
height: 16px;
background: rgba(255, 255, 255, 0.25);
}
.detail-page-header__badge {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 9999px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.92);
font-size: 14px;
font-weight: 700;
letter-spacing: -0.2px;
}
/* 상시업무 상세 — 헤더 탭 (detail-page-header · dashboard 톤) */
.detail-page-header--routine {
gap: 16px;
}
.detail-page-header__tabs {
position: relative;
z-index: 1;
display: flex;
flex: 1;
min-width: 0;
align-items: center;
gap: 8px;
overflow-x: auto;
scrollbar-width: none;
}
.detail-page-header__tabs::-webkit-scrollbar {
display: none;
}
.detail-page-header__tab {
flex-shrink: 0;
padding: 4px 14px;
border: 1px solid transparent;
border-radius: 12px;
font-family: inherit;
font-size: 20px;
font-weight: 500;
letter-spacing: -0.3px;
line-height: 1.2;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.85);
}
.detail-page-header__tab:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.18);
color: #fff;
}
.detail-page-header__tab.is-active {
border-color: rgba(255, 255, 255, 0.35);
background: #fff;
color: var(--detail-ref-title);
font-weight: 600;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
.detail-page-header__tab:disabled {
opacity: 0.55;
cursor: wait;
}
/* ─── 본문 타이포 (quarter-board project-field · board-project-title) ─── */
.detail-section-label {
margin: 0;
color: var(--detail-text-title);
font-size: 20px;
font-weight: 700;
line-height: 1.45;
letter-spacing: -0.2px;
}
.detail-section-label-sub {
color: var(--detail-accent);
font-size: 20px;
font-weight: 600;
letter-spacing: -0.2px;
}
.detail-section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--detail-border);
flex-shrink: 0;
}
.detail-body-title {
margin: 0;
color: var(--detail-text-title);
font-size: 24px;
font-weight: 700;
line-height: 1.35;
letter-spacing: -0.2px;
}
.detail-body-text {
margin: 0;
color: var(--detail-text-body);
font-size: 20px;
font-weight: 600;
line-height: 1.45;
letter-spacing: -0.1px;
}
/** 업무내용·피드백 본문 */
.detail-body-content {
margin: 0;
color: var(--detail-text-body);
font-size: 20px;
font-weight: 600;
line-height: 1.45;
letter-spacing: -0.1px;
}
.detail-body-muted {
color: var(--detail-text-muted);
font-size: 20px;
font-weight: 500;
line-height: 1.45;
opacity: 0.9;
}
.milestone-content-block__label {
font-size: 14px;
font-weight: 700;
letter-spacing: -0.2px;
color: var(--detail-text-secondary);
}
.milestone-content-period {
position: relative;
flex-shrink: 0;
max-width: 52%;
}
.milestone-content-period__btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
margin: 0;
border: none;
border-radius: 6px;
background: transparent;
color: var(--detail-accent);
font-size: 18px;
font-weight: 600;
letter-spacing: -0.1px;
line-height: 1.35;
cursor: default;
white-space: nowrap;
}
.milestone-content-period__btn:not(:disabled) {
cursor: pointer;
}
.milestone-content-period__btn:not(:disabled):hover,
.milestone-content-period__btn.is-open {
background: rgba(74, 144, 217, 0.1);
}
.milestone-content-period__label {
overflow: hidden;
text-overflow: ellipsis;
}
.milestone-content-period__chev {
flex-shrink: 0;
opacity: 0.85;
}
.milestone-content-period__menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
z-index: 20;
min-width: 148px;
max-width: 240px;
margin: 0;
padding: 4px;
list-style: none;
border: 1px solid var(--detail-border);
border-radius: 8px;
background: #fff;
box-shadow: 0 8px 24px rgba(15, 35, 52, 0.12);
}
.milestone-content-period__option {
display: block;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--detail-text-body);
font-size: 16px;
font-weight: 600;
letter-spacing: -0.1px;
text-align: left;
white-space: nowrap;
cursor: pointer;
}
.milestone-content-period__option:hover {
background: rgba(74, 144, 217, 0.08);
}
.milestone-content-period__option.is-active {
color: var(--detail-accent);
background: rgba(74, 144, 217, 0.12);
}
.detail-body-caption {
color: var(--detail-text-secondary);
font-size: 18px;
font-weight: 600;
line-height: 1.45;
letter-spacing: -0.1px;
opacity: 1;
}
.detail-aside {
background: var(--detail-card-bg);
border-right: 1px solid var(--detail-border);
}
/* 프로젝트 상세 — 업무 일정 3단계 고정 노출, 업무내용 위로 */
.detail-aside--project {
--detail-stage-card-h: 80px;
--detail-stage-gap: 8px;
--detail-stage-list-extra: 6px; /* 3번째 카드 하단선·ring 여유 */
grid-template-rows: auto auto minmax(0, 1fr) minmax(0, 0.85fr);
}
.detail-aside--project > section:first-child,
.detail-aside--project > section:nth-child(3) {
padding-block: 8px;
}
.detail-aside--project > section:nth-child(2) {
padding-bottom: 10px;
}
.detail-aside--project > section:nth-child(3) {
margin-top: 4px;
}
.detail-aside--project > section:first-child .detail-body-text {
flex: 0 0 auto;
}
.detail-overview {
display: flex;
align-items: flex-start;
gap: 4px;
min-width: 0;
}
.detail-overview__text {
flex: 1;
min-width: 0;
margin: 0;
}
.detail-overview__text.is-clamped {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.detail-overview.is-expanded .detail-overview__text {
max-height: min(42vh, 280px);
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
.detail-overview__more {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
margin-top: 2px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--detail-text-secondary);
cursor: pointer;
opacity: 0.72;
transition:
opacity 0.15s ease,
color 0.15s ease,
background 0.15s ease;
}
.detail-overview__more:hover {
opacity: 1;
color: var(--detail-ref-hub-dark);
background: rgba(74, 144, 217, 0.1);
}
.detail-overview__chevrons {
display: block;
transition: transform 0.15s ease;
}
.detail-overview__chevrons.is-up {
transform: rotate(180deg);
}
.detail-aside--project .detail-stage-list {
height: calc(
var(--detail-stage-card-h) * 3 + var(--detail-stage-gap) * 2 + var(--detail-stage-list-extra)
);
padding-bottom: 4px;
box-sizing: border-box;
overflow-y: auto;
flex-shrink: 0;
}
.detail-aside--project .detail-stage-card {
min-height: var(--detail-stage-card-h);
box-sizing: border-box;
}
.detail-panel-footer-title {
color: var(--detail-text-title);
font-size: 24px;
font-weight: 600;
letter-spacing: -0.2px;
line-height: 1.35;
}
.detail-panel-footer-sub {
color: var(--detail-accent);
font-size: 20px;
font-weight: 600;
letter-spacing: -0.1px;
}
.detail-stage-card {
border-color: var(--detail-border);
background: rgba(255, 255, 255, 0.55);
}
.detail-stage-card.is-selected {
border-color: var(--detail-ref-hub);
background: var(--detail-ref-hub-soft);
}
.detail-stage-card.is-selected .detail-body-title {
color: var(--detail-ref-title);
}
.detail-add-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1.5px solid var(--detail-ref-hub-border);
border-radius: 50%;
background: linear-gradient(180deg, var(--detail-ref-hub) 0%, var(--detail-ref-hub-dark) 100%);
color: #e8f2fc;
font-size: 18px;
font-weight: 700;
line-height: 1;
cursor: pointer;
box-shadow: 0 1px 3px rgba(37, 99, 171, 0.28);
transition: all 0.2s ease;
}
.detail-add-btn:hover {
color: #fff;
border-color: #5a9ee8;
background: linear-gradient(180deg, #5a9ee8 0%, var(--detail-ref-hub-border) 100%);
}
.detail-add-btn:disabled {
opacity: 0.4;
cursor: default;
}
/* ─── 업무별 타임라인 (간트) — 게이지 4행 고정 ─── */
.milestone-timeline {
--mt-row-height: 28px;
--mt-row-gap: 4px;
--mt-visible-rows: 4;
--mt-chart-height: calc(
var(--mt-visible-rows) * var(--mt-row-height)
+ (var(--mt-visible-rows) - 1) * var(--mt-row-gap)
+ 12px
);
--mt-footer-height: calc(26px + 33px + 8px + 22px + 4px + var(--mt-chart-height));
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 8px;
height: var(--mt-footer-height);
min-height: var(--mt-footer-height);
max-height: var(--mt-footer-height);
padding: 12px 20px 14px;
border-top: 1px solid var(--detail-border);
background: linear-gradient(168deg, #ffffff 0%, var(--detail-ref-hub-soft) 100%);
overflow: hidden;
}
.milestone-timeline__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
}
.milestone-timeline__title {
color: var(--detail-text-title);
font-size: 24px;
font-weight: 600;
letter-spacing: -0.2px;
line-height: 1.35;
}
.milestone-timeline__subtitle {
color: var(--detail-accent);
font-size: 20px;
font-weight: 600;
letter-spacing: -0.1px;
max-width: 45%;
}
.milestone-timeline__empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
margin: 0;
padding: 12px;
border: 1px dashed var(--detail-border);
border-radius: 8px;
background: rgba(255, 255, 255, 0.45);
color: var(--detail-text-muted);
font-size: 20px;
font-weight: 500;
line-height: 1.45;
opacity: 0.72;
text-align: center;
}
.milestone-timeline__body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 4px;
overflow: hidden;
}
.milestone-timeline__ticks {
position: relative;
flex-shrink: 0;
height: 22px;
margin: 0 4px;
}
.milestone-timeline__tick {
position: absolute;
top: 0;
transform: translateX(-50%);
color: var(--detail-text-body);
font-size: 13px;
font-weight: 600;
letter-spacing: -0.2px;
line-height: 1;
white-space: nowrap;
opacity: 0.85;
}
.milestone-timeline__tick-label {
line-height: 1;
}
.milestone-timeline__tick.is-today {
opacity: 1;
}
.milestone-timeline__tick.is-today .milestone-timeline__tick-label {
color: var(--detail-ref-hub-dark);
font-weight: 800;
text-decoration: underline;
text-decoration-color: var(--detail-ref-hub);
text-underline-offset: 2px;
text-decoration-thickness: 2px;
}
.milestone-timeline__chart {
position: relative;
flex: none;
height: var(--mt-chart-height);
min-height: var(--mt-chart-height);
max-height: var(--mt-chart-height);
overflow-y: auto;
overflow-x: hidden;
margin: 0 4px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.55);
border: 1px solid var(--detail-border);
}
.milestone-timeline__grid {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
}
.milestone-timeline__grid-line {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
transform: translateX(-50%);
background: rgba(74, 144, 217, 0.08);
}
.milestone-timeline__rows {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: var(--mt-row-gap);
padding: 6px 0;
}
.milestone-timeline__row {
position: relative;
height: var(--mt-row-height);
flex-shrink: 0;
}
.milestone-timeline__bar {
position: absolute;
top: 50%;
height: 22px;
transform: translateY(-50%);
padding: 0;
border: none;
border-radius: 999px;
overflow: hidden;
cursor: pointer;
background: transparent;
transition:
width 0.2s ease,
left 0.2s ease,
box-shadow 0.15s ease,
opacity 0.15s ease;
opacity: 0.92;
}
.milestone-timeline__bar.is-expanded {
z-index: 5;
overflow: visible;
opacity: 1;
box-shadow: 0 2px 10px rgba(74, 144, 217, 0.28);
}
.milestone-timeline__bar.is-expanded .milestone-timeline__bar-track,
.milestone-timeline__bar.is-expanded .milestone-timeline__bar-fill {
border-radius: 999px;
}
.milestone-timeline__measure {
position: absolute;
visibility: hidden;
pointer-events: none;
white-space: nowrap;
font-size: 18px;
font-weight: 500;
letter-spacing: -0.1px;
}
.milestone-timeline__bar:hover {
opacity: 1;
z-index: 4;
}
.milestone-timeline__bar.is-selected {
z-index: 2;
opacity: 1;
box-shadow: 0 0 0 2px var(--detail-ref-hub), 0 2px 8px rgba(74, 144, 217, 0.35);
}
.milestone-timeline__bar-track {
position: absolute;
inset: 0;
border-radius: inherit;
background: rgba(74, 144, 217, 0.18);
}
.milestone-timeline__bar-fill {
position: absolute;
inset: 0 auto 0 0;
z-index: 1;
border-radius: inherit;
background: linear-gradient(90deg, var(--detail-ref-hub-dark) 0%, var(--detail-ref-hub) 100%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25);
transition: width 0.2s ease;
}
.milestone-timeline__bar-label-wrap {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
}
.milestone-timeline__bar-label {
position: absolute;
inset: 0;
padding: 0 8px;
overflow: hidden;
min-width: 0;
font-size: 18px;
font-weight: 500;
line-height: 22px;
letter-spacing: -0.1px;
white-space: nowrap;
text-overflow: ellipsis;
}
.milestone-timeline__bar-label--fill {
color: #fff;
}
.milestone-timeline__bar-label--track {
color: var(--detail-ref-title);
}
.milestone-timeline__bar.is-selected .milestone-timeline__bar-label {
font-weight: 600;
}
.milestone-timeline__bar.is-selected .milestone-timeline__bar-label--track {
color: var(--detail-ref-hub-dark);
}
.milestone-timeline__bar.is-expanded .milestone-timeline__bar-label {
overflow: visible;
text-overflow: clip;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,363 @@
/* 상시업무 상세 — detail-theme 와 동일 ref-hub 팔레트 */
.routine-detail {
--rd-header-bg: linear-gradient(180deg, #37a184 0%, #29724f 20%, #07412e 100%);
--rd-emerald-deep: #1e3a5f;
--rd-bg-page: #fff;
--rd-bg-card: #ffffff;
--rd-bg-card-hover: #f8fafc;
--rd-bg-stage-selected: #f5faff;
--rd-border-card: #e8edf2;
--rd-border-selected: #4a90d9;
--rd-text-main: #1e3a5f;
--rd-text-muted: #64748b;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
background: var(--rd-bg-page);
color: var(--rd-text-main);
font-family: "Pretendard Variable", Pretendard, -apple-system, sans-serif;
}
.routine-detail__header {
position: relative;
z-index: 10;
flex-shrink: 0;
height: 56px;
min-height: 56px;
display: flex;
align-items: center;
padding: 0 20px;
background: var(--rd-header-bg);
color: #fff;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
}
.routine-detail__header::before {
content: "";
position: absolute;
inset: 0 0 auto;
height: 45%;
background: rgba(255, 255, 255, 0.08);
pointer-events: none;
}
.routine-detail__tabs {
position: relative;
z-index: 1;
display: flex;
gap: 8px;
align-items: center;
min-width: 0;
overflow-x: auto;
}
.routine-detail__tab {
flex-shrink: 0;
padding: 6px 16px;
border: none;
border-radius: 12px;
font-family: inherit;
font-size: 18px;
font-weight: 400;
cursor: pointer;
transition: all 0.2s ease;
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.85);
}
.routine-detail__tab:hover {
background: rgba(255, 255, 255, 0.2);
}
.routine-detail__tab.is-active {
background: var(--rd-bg-card);
color: var(--rd-emerald-deep);
font-weight: 500;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
.routine-detail__tab.is-empty {
opacity: 0.45;
cursor: default;
}
.routine-detail__meta {
position: relative;
z-index: 1;
margin-left: auto;
display: flex;
align-items: center;
gap: 16px;
font-size: 14px;
flex-shrink: 0;
}
.routine-detail__meta-item {
color: rgba(255, 255, 255, 0.8);
white-space: nowrap;
}
.routine-detail__meta-item strong {
color: #fff;
font-weight: 700;
}
.routine-detail__meta-divider {
width: 1px;
height: 14px;
background: rgba(255, 255, 255, 0.25);
}
.routine-detail__status {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 9999px;
font-size: 12px;
font-weight: 700;
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
}
.routine-detail__main {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 1fr 3fr;
grid-template-rows: 1fr;
}
.routine-detail__aside {
display: grid;
grid-template-rows: 2fr 1fr;
background: var(--rd-bg-card);
border-right: 1px solid var(--rd-border-card);
min-height: 0;
overflow: hidden;
}
.routine-detail__section {
display: flex;
flex-direction: column;
padding: 16px;
min-height: 0;
overflow: hidden;
border-bottom: 1px solid var(--rd-border-card);
}
.routine-detail__section:last-child {
border-bottom: none;
}
.routine-detail__section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #e2e8f0;
flex-shrink: 0;
}
.routine-detail__section-label {
font-size: 18px;
font-weight: 900;
color: var(--rd-text-muted);
letter-spacing: 0.05em;
text-transform: uppercase;
}
.routine-detail__add-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
border: none;
background: #4a90d9;
color: #fff;
font-size: 18px;
font-weight: 700;
cursor: pointer;
transition: background 0.2s;
}
.routine-detail__add-btn:hover {
background: #2563ab;
}
.routine-detail__stage-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
padding-right: 4px;
}
.routine-detail__stage-card {
width: 100%;
text-align: left;
padding: 10px 14px;
border-radius: 8px;
border: 1px solid var(--rd-border-card);
background: var(--rd-bg-card-hover);
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.routine-detail__stage-card:hover {
border-color: #cbd5e1;
background: var(--rd-bg-card);
}
.routine-detail__stage-card.is-selected {
border-color: var(--rd-border-selected);
background: var(--rd-bg-stage-selected);
box-shadow: 0 0 0 1px var(--rd-border-selected);
}
.routine-detail__stage-title {
font-size: 24px;
font-weight: 400;
color: #1e293b;
line-height: 1.3;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.routine-detail__stage-card.is-selected .routine-detail__stage-title {
color: var(--rd-emerald-deep);
font-weight: 500;
}
.routine-detail__stage-overview {
font-size: 20px;
font-weight: 400;
color: var(--rd-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.routine-detail__content-list {
flex: 1;
overflow-y: auto;
list-style: none;
display: flex;
flex-direction: column;
gap: 10px;
padding-right: 4px;
margin: 0;
}
.routine-detail__content-item {
display: flex;
gap: 8px;
font-size: 20px;
font-weight: 400;
line-height: 1.4;
color: #334155;
}
.routine-detail__content-bullet {
color: #4a90d9;
font-weight: 700;
flex-shrink: 0;
}
.routine-detail__content-text {
flex: 1;
word-break: break-word;
}
.routine-detail__empty {
font-size: 16px;
color: var(--rd-text-muted);
font-style: italic;
}
.routine-detail__panel {
display: flex;
flex-direction: column;
min-width: 0;
height: 100%;
min-height: 0;
}
.routine-detail__preview {
flex: 1;
min-height: 0;
padding: 24px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 20px;
background: #f1f5f9;
}
.routine-detail__preview-title {
font-size: 16px;
font-weight: 800;
color: #475569;
margin: 0 0 4px;
}
.routine-detail__results-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
.routine-detail__result-card {
background: var(--rd-bg-card);
border: 1px solid var(--rd-border-card);
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
text-decoration: none;
color: var(--rd-text-main);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
transition: all 0.2s ease;
cursor: pointer;
}
.routine-detail__result-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.08);
border-color: #cbd5e1;
}
.routine-detail__result-icon {
font-size: 36px;
margin-bottom: 10px;
line-height: 1;
}
.routine-detail__result-name {
font-size: 15px;
font-weight: 600;
color: #1e293b;
word-break: break-all;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
line-height: 1.3;
}
.routine-detail__panel .milestone-timeline {
flex-shrink: 0;
}

View File

@@ -1,4 +1,7 @@
import type { TaskIssueEntry } from '../lib/taskIssues';
export type Role = 'ADMIN' | 'MANAGER' | 'MEMBER';
export type { TaskIssueEntry };
export type TaskStatus = 'TODO' | 'IN_PROGRESS' | 'REVIEW' | 'DONE' | 'CANCELLED';
export type Priority = 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
@@ -42,6 +45,7 @@ export interface Task {
taskType: string | null; // 상시업무 | 프로젝트
progress: number; // 0-100
issueNote: string | null;
issueEntries?: TaskIssueEntry[] | null;
startDate: string | null;
dueDate: string | null;
showDate: boolean;
@@ -102,17 +106,29 @@ export interface FileRecord {
createdAt: string;
}
export interface MilestonePeriodEntry {
id: string;
startDate: string;
dueDate: string;
note: string;
}
export interface Milestone {
id: string;
taskId: string;
title: string;
subtitle?: string | null;
description: string | null;
startDate: string | null;
dueDate: string | null;
periodEntries?: MilestonePeriodEntry[] | null;
progress: number;
links: string | null;
completedAt: string | null;
order: number;
pmMemberId?: string | null;
pmMember?: TeamMemberBrief | null;
assigneeMembers?: TeamMemberBrief[];
createdAt: string;
updatedAt: string;
}

View File

@@ -1,27 +1,69 @@
import fs from 'fs';
import path from 'path';
import type { Plugin } from 'vite';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import path from 'path';
import basicSsl from '@vitejs/plugin-basic-ssl';
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
/** http-local: http://localhost:3000 | https-lan: https://{LAN IP}:3000 */
function readLanIp(): string | null {
const file = path.join(__dirname, '.lan-ip');
if (!fs.existsSync(file)) return null;
const ip = fs.readFileSync(file, 'utf8').trim();
return ip || null;
}
function lanOnlyUrls(lanHost: string, port: number): Plugin {
return {
name: 'lan-only-urls',
configureServer(server) {
server.printUrls = () => {
server.config.logger.info(`\n ➜ LAN: https://${lanHost}:${port}/\n`, { clear: true });
};
},
},
server: {
port: 3000,
host: true, // 0.0.0.0 — 같은 네트워크 팀원 접속 허용
proxy: {
'/api': {
target: 'http://localhost:4000',
changeOrigin: true,
},
'/uploads': {
target: 'http://localhost:4000',
changeOrigin: true,
};
}
export default defineConfig(({ mode }) => {
const isHttpsLan = mode === 'https-lan';
const lanHost = readLanIp();
if (isHttpsLan && !lanHost) {
throw new Error('frontend/.lan-ip missing — restart via 서버시작.bat');
}
return {
plugins: [
react(),
tailwindcss(),
...(isHttpsLan ? [basicSsl(), lanOnlyUrls(lanHost!, 3000)] : []),
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
},
server: {
port: 3000,
strictPort: true,
host: isHttpsLan ? lanHost! : 'localhost',
https: isHttpsLan,
proxy: {
'/api': {
target: 'http://localhost:4000',
changeOrigin: true,
},
'/uploads': {
target: 'http://localhost:4000',
changeOrigin: true,
},
'/socket.io': {
target: 'http://localhost:4000',
changeOrigin: true,
ws: true,
},
},
},
};
});