Compare commits
265
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0888ea2a4 | ||
|
|
1ead18b53c | ||
|
|
07db5b1fbd | ||
|
|
6110abebb4 | ||
|
|
05e9082831 | ||
|
|
76a634cf9a | ||
|
|
793c673414 | ||
|
|
2a76c279eb | ||
|
|
5d1ba3fd87 | ||
|
|
99db9a0b77 | ||
|
|
4126bc2234 | ||
|
|
d4f1ec095e | ||
|
|
bbf3ce3948 | ||
|
|
fb5b8356c7 | ||
|
|
1333bd040a | ||
|
|
013c07ef59 | ||
|
|
29fd59a321 | ||
|
|
3bc152c341 | ||
|
|
ea274f8f7d | ||
|
|
7ac53e56d7 | ||
|
|
7b1c12e952 | ||
|
|
17dafe6530 | ||
|
|
ef331363f7 | ||
|
|
4f10864c2c | ||
|
|
4b5ceb0e0a | ||
|
|
4fa50161fd | ||
|
|
3d27e07ccb | ||
|
|
ca1ca71a62 | ||
|
|
bdd6c7b804 | ||
|
|
04b8082f04 | ||
|
|
3451b19b96 | ||
|
|
ae7c1ea1c4 | ||
|
|
9c76b955e2 | ||
|
|
6c4bfe2798 | ||
|
|
98189cd14a | ||
|
|
823ce262bb | ||
|
|
34358400e8 | ||
|
|
da9d069a9a | ||
|
|
58a03678e4 | ||
|
|
f5c281cf86 | ||
|
|
c72bcea7d9 | ||
|
|
49cd160e76 | ||
|
|
5ca93a25ca | ||
|
|
55554fc781 | ||
|
|
8e78c10304 | ||
|
|
0eb74b46ba | ||
|
|
f21dde8f7d | ||
|
|
1fc6707bb3 | ||
|
|
44c5d66afa | ||
|
|
04edb49c9f | ||
|
|
e3eb542e59 | ||
|
|
428b199dd3 | ||
|
|
3c4a6efeea | ||
|
|
dae7024043 | ||
|
|
64e5c26e8c | ||
|
|
84ae60edd6 | ||
|
|
d4c9989069 | ||
|
|
6cda413913 | ||
|
|
a11eaf031d | ||
|
|
3c701359fd | ||
|
|
85ba9fe0ab | ||
|
|
71077f4c8e | ||
|
|
4c7ff53d78 | ||
|
|
bdc3a94ef1 | ||
|
|
b31691dd9c | ||
|
|
0647df733a | ||
|
|
0131cae0d7 | ||
|
|
f37b19eab6 | ||
|
|
7c1db5e0e0 | ||
|
|
2c495ab8fd | ||
|
|
ced1b7084d | ||
|
|
b0c871eff7 | ||
|
|
4148a87f32 | ||
|
|
bd83fa3417 | ||
|
|
c0b63a655e | ||
|
|
cc21aaede1 | ||
|
|
0c71c80a25 | ||
|
|
49e42757bc | ||
|
|
0964f442be | ||
|
|
73ceeb1b38 | ||
|
|
06f68ec392 | ||
|
|
e5dc347276 | ||
|
|
7c6c43b334 | ||
|
|
abf53afafc | ||
|
|
fc70963fa9 | ||
|
|
814365d7de | ||
|
|
8c1a976b5b | ||
|
|
66db81071e | ||
|
|
6df6e612bc | ||
|
|
84013993c9 | ||
|
|
3fee2a781f | ||
|
|
acaf2dd9f4 | ||
|
|
55839f0913 | ||
|
|
f68131fc13 | ||
|
|
7b9081a125 | ||
|
|
0947b76a6e | ||
|
|
23c18da399 | ||
|
|
ec18f17c79 | ||
|
|
cdfa1e56e5 | ||
|
|
434c3cfc08 | ||
|
|
aee3449578 | ||
|
|
54d3a35b38 | ||
|
|
b640fffd0d | ||
|
|
981e4aab54 | ||
|
|
6b68894d23 | ||
|
|
97a8b2811e | ||
|
|
2c3839fc5f | ||
|
|
e9caa1b84d | ||
|
|
d2d5502f8d | ||
|
|
77f2546cc4 | ||
|
|
4df353518f | ||
|
|
ee114dec7e | ||
|
|
d58b2db786 | ||
|
|
1c2eaef5a4 | ||
|
|
bb7cce47ac | ||
|
|
ba910d6bfe | ||
|
|
2a8c2ae542 | ||
|
|
56f52194a6 | ||
|
|
a71b20ba6f | ||
|
|
b2282accb1 | ||
|
|
5def2ec5ee | ||
|
|
9a8350dae8 | ||
|
|
77fd719f96 | ||
|
|
aa831141f6 | ||
|
|
fb148a77c1 | ||
|
|
d8d98aee6d | ||
|
|
87f9b90dbf | ||
|
|
b704db99b1 | ||
|
|
f231564548 | ||
|
|
db04b13310 | ||
|
|
9958ea72b8 | ||
|
|
35f765d870 | ||
|
|
b6afaba02a | ||
|
|
9b9edec627 | ||
|
|
d4302550b4 | ||
|
|
6432b9f44a | ||
|
|
98f24d8a96 | ||
|
|
887549c755 | ||
|
|
d3ca402e4a | ||
|
|
9ca0e96525 | ||
|
|
4c28db79df | ||
|
|
6e2a957e99 | ||
|
|
b5eea1a476 | ||
|
|
5d31c70e16 | ||
|
|
a6f05b3850 | ||
|
|
49ec6423bd | ||
|
|
68ac1168e9 | ||
|
|
e875df1e55 | ||
|
|
69bd924cd2 | ||
|
|
164bffc5de | ||
|
|
613f2a36d9 | ||
|
|
0eee6bf338 | ||
|
|
c2fdc3aa71 | ||
|
|
e98fe69d68 | ||
|
|
426e3ffcdf | ||
|
|
2e25a0311c | ||
|
|
97df2c47de | ||
|
|
f7c1cf0718 | ||
|
|
4d0e39721f | ||
|
|
a87f887635 | ||
|
|
153f3b7fa7 | ||
|
|
668e750b03 | ||
|
|
7e152cacd2 | ||
|
|
ac00f978be | ||
|
|
c313c1d6cc | ||
|
|
215d742270 | ||
|
|
aa213ceead | ||
|
|
4eb031ae67 | ||
|
|
b0cd0c772f | ||
|
|
e75e38a0b8 | ||
|
|
86a95ca6e4 | ||
|
|
99fe41b41d | ||
|
|
a78059ec67 | ||
|
|
50bc502251 | ||
|
|
7cbde80466 | ||
|
|
f952f8c262 | ||
|
|
44edac8fbb | ||
|
|
0e8e646547 | ||
|
|
357dce2161 | ||
|
|
430148d843 | ||
|
|
21dab0ef1f | ||
|
|
da8f66fca1 | ||
|
|
31ee840072 | ||
|
|
e3d022fcd7 | ||
|
|
09d4750a67 | ||
|
|
8ab63870d4 | ||
|
|
d3434060ba | ||
|
|
641132dd56 | ||
|
|
28bd0cbbf0 | ||
|
|
df4208d4e1 | ||
|
|
ad13875ce8 | ||
|
|
9673ee0ed2 | ||
|
|
d864cf73b9 | ||
|
|
9dec42a584 | ||
|
|
9b4d2d410d | ||
|
|
9e7dea7e9f | ||
|
|
6938c1e96f | ||
|
|
4783bbacbd | ||
|
|
0ef375310f | ||
|
|
3eb028b075 | ||
|
|
c1a03fcabc | ||
|
|
24c446b880 | ||
|
|
48172aa3f0 | ||
|
|
adf5e288d3 | ||
|
|
d8d5755849 | ||
|
|
9f803fd4ad | ||
|
|
cc4b2ca084 | ||
|
|
ab3c4e77ee | ||
|
|
3c0a16ea67 | ||
|
|
a6da87837a | ||
|
|
21533c8c0e | ||
|
|
3f75d2751a | ||
|
|
c5a5b3bde3 | ||
|
|
75e619a997 | ||
|
|
02f5fae903 | ||
|
|
1e3a2fea8d | ||
|
|
a4e73284ed | ||
|
|
c94d9019d3 | ||
|
|
a9d78a7e0d | ||
|
|
d93157b10a | ||
|
|
ad18cf1c16 | ||
|
|
90834f3493 | ||
|
|
535a6f1ef2 | ||
|
|
86566c7692 | ||
|
|
51204e2ec1 | ||
|
|
25affc06b9 | ||
|
|
ad5fc8a2de | ||
|
|
e2cf4f3f34 | ||
|
|
8272093e07 | ||
|
|
ba42b74578 | ||
|
|
fba84202f0 | ||
|
|
f70a20c0c7 | ||
|
|
ac75188980 | ||
|
|
404624a583 | ||
|
|
696fdf84d2 | ||
|
|
91e505f37f | ||
|
|
294fea4f71 | ||
|
|
148275626c | ||
|
|
7a0a8f23b7 | ||
|
|
45bf7b10f3 | ||
|
|
246f6472a3 | ||
|
|
965b8f3feb | ||
|
|
c37696dee3 | ||
|
|
fb61080d8d | ||
|
|
d4f46ca344 | ||
|
|
50ed22b4bc | ||
|
|
b0dbd78410 | ||
|
|
b508568e86 | ||
|
|
8384743a30 | ||
|
|
ea9bd64088 | ||
|
|
758d5e7fac | ||
|
|
658a20f587 | ||
|
|
9661369708 | ||
|
|
4e8f492006 | ||
|
|
12e9c2a30c | ||
|
|
6539e5bb80 | ||
|
|
91116ccdf7 | ||
|
|
2bcc49e090 | ||
|
|
c013759869 | ||
|
|
bdbba42616 | ||
|
|
a84b233435 | ||
|
|
4340a57180 | ||
|
|
1cc6b4b565 | ||
|
|
4a6cfed9bb | ||
|
|
0e067bb5fc |
Vendored
+88
@@ -0,0 +1,88 @@
|
||||
{
|
||||
// IntelliSense를 사용하여 가능한 특성에 대해 알아보세요.
|
||||
// 기존 특성에 대한 설명을 보려면 가리킵니다.
|
||||
// 자세한 내용을 보려면 https://go.microsoft.com/fwlink/?linkid=830387을(를) 방문하세요.
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "dev:as 디버그",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"dev:as"
|
||||
],
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
"sourceMaps": true,
|
||||
"env": {
|
||||
"NODE_OPTIONS": "--inspect"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "dev:cs 디버그",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"dev:cs"
|
||||
],
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
"sourceMaps": true,
|
||||
"env": {
|
||||
"NODE_OPTIONS": "--inspect"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "attach",
|
||||
"name": "dev:cs 서버 연결 (attach)",
|
||||
"port": 9229,
|
||||
"restart": true,
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"sourceMaps": true,
|
||||
"resolveSourceMapLocations": [
|
||||
"${workspaceFolder}/apps/cs/**",
|
||||
"!**/node_modules/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"name": "dev:cs 클라이언트 디버그 (Chrome)",
|
||||
"url": "http://localhost:3000",
|
||||
"webRoot": "${workspaceFolder}/apps/cs",
|
||||
"sourceMaps": true
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "dev:st 디버그",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"dev:st"
|
||||
],
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
"sourceMaps": true,
|
||||
"env": {
|
||||
"NODE_OPTIONS": "--inspect"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
"pnpm": true
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
"axios": "^1.8.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"next": "15.3.8",
|
||||
"next": "15.5.18",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
@@ -33,4 +33,4 @@
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { AcntgCodeManagementPage as default } from "@/pages/acntg-code-management";
|
||||
@@ -0,0 +1 @@
|
||||
export { ApprovalLineManagementPage as default } from "@/pages/approval-line-management";
|
||||
@@ -0,0 +1 @@
|
||||
export { ChecklistManagementPage as default } from "@/pages/checklist-management";
|
||||
@@ -0,0 +1 @@
|
||||
export { CnstpCodeManagementPage as default } from "@/pages/cnstp-code-management";
|
||||
@@ -0,0 +1 @@
|
||||
export { EsntlAtchFileMngPage as default } from "@/pages/esntl-atch-file-mng";
|
||||
@@ -0,0 +1,10 @@
|
||||
import { CnfmModifyPage } from "@/pages/instl-sncn";
|
||||
|
||||
export default async function InstlSncnModifyPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
return <CnfmModifyPage id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { CnfmViewPage } from "@/pages/instl-sncn";
|
||||
|
||||
export default async function InstlSncnViewPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
return <CnfmViewPage id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { CnfmListPage } from "@/pages/instl-sncn";
|
||||
|
||||
export default CnfmListPage;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CnfmRegistPage } from "@/pages/instl-sncn";
|
||||
|
||||
export default function Page(props: any) {
|
||||
return <CnfmRegistPage {...props} />;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { RvwMnpwMngPage as default } from "@/pages/rvw-mnpw-mng";
|
||||
@@ -38,13 +38,28 @@ async function handler(
|
||||
body = await request.json();
|
||||
}
|
||||
|
||||
const result = await fetch(`${SERVER_API_URL}/${params.url.join("/")}`, {
|
||||
const targetUrl = `${SERVER_API_URL}/${params.url.join("/")}`;
|
||||
console.log(`[Proxy API] ${method} ${params.url.join("/")} -> ${targetUrl}`);
|
||||
console.log(`[Proxy API] Headers:`, headers);
|
||||
if (!isRefreshHandler) {
|
||||
console.log(`[Proxy API] Body:`, body);
|
||||
}
|
||||
|
||||
const result = await fetch(targetUrl, {
|
||||
method,
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
console.log(`[Proxy API] Response Status: ${result.status}`);
|
||||
console.log(`[Proxy API] Response Headers:`, {
|
||||
[ACCESS_TOKEN]: result.headers.get(ACCESS_TOKEN),
|
||||
[REFRESH_TOKEN]: result.headers.get(REFRESH_TOKEN) ? '***hidden***' : undefined,
|
||||
[AUTH_USER_ID]: result.headers.get(AUTH_USER_ID),
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
console.error(`[Proxy API] Request failed with status ${result.status}`);
|
||||
return NextResponse.json({ message: "Invalid Credentials" }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -53,6 +68,7 @@ async function handler(
|
||||
const userId = result.headers.get(AUTH_USER_ID);
|
||||
|
||||
if (accessToken && userId) {
|
||||
console.log(`[Proxy API] Login successful for user: ${userId}`);
|
||||
const response = NextResponse.json({
|
||||
[ACCESS_TOKEN]: accessToken,
|
||||
[AUTH_USER_ID]: userId,
|
||||
@@ -79,6 +95,7 @@ async function handler(
|
||||
|
||||
return NextResponse.json({ message: "Invalid Credentials" }, { status: 401 });
|
||||
} catch (error) {
|
||||
console.error(`[Proxy API] Error caught in handler:`, error);
|
||||
return NextResponse.json({ message: "Server Error", error }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ---------- | -------------------------------------------- |
|
||||
| 버전 | 0.1 (초안) |
|
||||
| 버전 | 0.3 |
|
||||
| 프로세스명 | UI설계 |
|
||||
| 화면명 | 결재선 관리 |
|
||||
| 화면 유형 | [v] 조회 [v] 입력 [v] 수정 [v] 삭제 [ ] 출력 |
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
## 2. 화면 목적
|
||||
|
||||
전자결재 처리에 사용할 **결재서식**(템플릿)을 등록·관리하고, 각 서식별로 **결재선**(접수 -> 검토 -> 승인 -> 최종승인 단계)을 구성한다. 상단의 결재서식 목록에서 하나를 선택하면 하단 영역에 해당 서식의 결재 단계(결재자/순서/구분)가 표시되며, 사용자는 결재자 추가·수정·삭제와 결재선 저장을 수행한다.
|
||||
전자결재 처리에 사용할 **결재서식**(템플릿)을 등록·관리하고, 각 서식별로 **결재선**(결재구분·결재자·순서)을 구성한다. 상단의 결재서식 목록에서 하나를 선택하면 하단 영역에 해당 서식의 결재선이 표시되며, 사용자는 **그리드 인라인 폼**으로 결재자를 등록·수정한다. **행 추가 또는 한 행 수정 중에는 다른 행을 편집할 수 없다**(단일 행 편집 잠금). 드래그로 순서를 변경한 뒤 **[결재선 저장]** 으로 서버에 반영한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -23,30 +23,27 @@
|
||||
### 3-1. 전체 레이아웃
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 페이지 타이틀: 결재선 관리 브레드크럼: 디지털행정지원 > … > │
|
||||
├──────────────────────────────────────────────────────────────────────────────┤
|
||||
│ [영역 A] 전자결재선 관리 목록 [서식 등록] │
|
||||
│ ┌──────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 전자결재서식아이디 | 결재서식명 | 문서구분 | 등록일시 | 관리 │ │
|
||||
│ ├──────────────────────────────────────────────────────────────────────┤ │
|
||||
│ │ TMPL-0001 (선택 행 강조) … [수정][삭제] │ │
|
||||
│ │ TMPL-0002 … [수정][삭제] │ │
|
||||
│ └──────────────────────────────────────────────────────────────────────┘ │
|
||||
├──────────────────────────────────────────────────────────────────────────────┤
|
||||
│ [영역 B] 결재선 관리 선택된 서식: TMPL-0001 │
|
||||
│ ┌──────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ [영역 B-1] 결재자 입력 폼 │ │
|
||||
│ │ 결재선아이디(자동발급) | 결재구분 ▾ | 결재자명 [검토인력 선택] │ │
|
||||
│ │ 결재순서번호 [결재자 추가] [결재선 저장] [초기화] │ │
|
||||
│ ├──────────────────────────────────────────────────────────────────────┤ │
|
||||
│ │ [영역 B-2] 결재선 그리드 │ │
|
||||
│ │ 결재선아이디 | 결재순서번호 | 결재구분 | 결재자명 | 등록일시 | 관리 │ │
|
||||
│ │ APLN-0001 | 1 | 접수 | 김민수 | … |[수정][삭제] │
|
||||
│ │ APLN-0002 | 2 | 검토 | 박지훈 | … |[수정][삭제] │
|
||||
│ │ APLN-0003 | 3 | 최종승인 | 이서연 | … |[수정][삭제] │
|
||||
│ └──────────────────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
┌───────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 페이지 타이틀: 결재선 관리 브레드크럼: 디지털행정지원 > … > │
|
||||
├───────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ [영역 A] 전자결재선 관리 목록 [서식 등록] │
|
||||
│ ┌──────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 전자결재서식아이디 | 결재서식명 | 문서구분 | 등록일시 | 관리 │ │
|
||||
│ ├──────────────────────────────────────────────────────────────────────┤ │
|
||||
│ │ TMPL-0001 (선택 행 강조) … [수정][삭제] │ │
|
||||
│ └──────────────────────────────────────────────────────────────────────┘ │
|
||||
├───────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ [영역 B] 결재선 관리 선택된 서식: TMPL-0001 [결재선 저장] │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ [영역 B-2] 결재선 그리드 (인라인 편집 + 행 드래그 — @dnd-kit) │ │
|
||||
│ │ ⋮⋮ | 결재선아이디 | 결재순서 | 결재구분 ▼ | 결재자 [선택] | 등록일시 | 관리 │ │
|
||||
│ │ ⋮⋮ | APLN-0001 | [ 1 ] | [검토자 ▼] | [김민수 ][선택] | … | [저장][삭제] │ │
|
||||
│ │ ⋮⋮ | (신규) | [ 2 ] | [승인자 ▼] | [ ][선택] | — | [저장][취소] │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────────────────────────┘ │
|
||||
│ [행 추가] │
|
||||
│ ※ 결재자 등록·수정은 그리드 셀에서 직접 입력. 검토인력만 [선택] 팝업/다이얼로그 │
|
||||
│ ※ 행 추가·수정 중: 해당 행만 편집 가능, 나머지 행·[행 추가]·드래그 비활성 │
|
||||
└───────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3-2. 화면 데이터 흐름
|
||||
@@ -55,102 +52,116 @@
|
||||
BEGIN
|
||||
|
||||
// 최초 진입
|
||||
CALL TBD.결재서식목록조회()
|
||||
CALL GET /as-elapr-service/api/v1/elapr-tmplt
|
||||
DISPLAY 결재서식 그리드 (영역 A)
|
||||
DISPLAY 빈 상태 안내 (영역 B) // 서식 미선택 시 폼·그리드 비활성
|
||||
DISPLAY 빈 상태 안내 (영역 B) // 서식 미선택 시 그리드·툴바 비활성
|
||||
|
||||
// 영역 A: 서식 행 선택
|
||||
ON 영역 A 행 CLICK DO
|
||||
SET 선택된 서식 = 행.전자결재서식아이디 // 영역 B 상단 라벨에 표시
|
||||
CALL TBD.결재선목록조회(서식아이디)
|
||||
DISPLAY 결재선 그리드 (영역 B-2)
|
||||
RESET 결재자 입력 폼 (영역 B-1)
|
||||
SET 선택된 서식 = 행.elaprTmpltId
|
||||
CALL GET /as-elapr-service/api/v1/elapr-apln?elaprTmpltId={id}
|
||||
DISPLAY 결재선 그리드 (영역 B-2) — 조회 모드
|
||||
END ON
|
||||
|
||||
// 영역 A: 신규 서식 등록
|
||||
ON [서식 등록] 버튼 CLICK DO
|
||||
OPEN 결재서식 등록 다이얼로그
|
||||
ON 확인 DO
|
||||
CALL TBD.결재서식등록(서식정보)
|
||||
CALL TBD.결재서식목록조회() // → 영역 A 갱신
|
||||
CALL POST /as-elapr-service/api/v1/elapr-tmplt
|
||||
CALL GET /as-elapr-service/api/v1/elapr-tmplt
|
||||
END ON
|
||||
END ON
|
||||
|
||||
// 영역 A: 서식 수정·삭제
|
||||
ON 영역 A [수정] 버튼 CLICK DO
|
||||
OPEN 결재서식 수정 다이얼로그(선택 행)
|
||||
ON 확인 DO
|
||||
CALL TBD.결재서식수정(서식아이디, 서식정보)
|
||||
CALL TBD.결재서식목록조회()
|
||||
END ON
|
||||
// 영역 A: 서식 수정·삭제 (다이얼로그 유지)
|
||||
ON 영역 A [수정] / [삭제] DO
|
||||
(기존과 동일 — PUT / DELETE elapr-tmplt)
|
||||
END ON
|
||||
|
||||
ON 영역 A [삭제] 버튼 CLICK DO
|
||||
CONFIRM "선택한 결재서식을 삭제하시겠습니까?"
|
||||
CALL TBD.결재서식삭제(서식아이디)
|
||||
CALL TBD.결재서식목록조회()
|
||||
IF 삭제된 서식이 현재 선택된 서식 THEN
|
||||
RESET 영역 B (그리드 비우기, 폼 비활성)
|
||||
END IF
|
||||
END ON
|
||||
|
||||
// 영역 B-1: 결재자 입력 폼
|
||||
ON [검토인력 선택] 버튼 CLICK DO
|
||||
VALIDATE 서식 선택됨 // 미선택 시 안내 후 종료
|
||||
OPEN 검토인력 선택 다이얼로그
|
||||
ON 선택 DO
|
||||
SET 결재자명 = 선택인력.성명
|
||||
SET 결재자ID = 선택인력.사용자ID // 내부 상태로 보관
|
||||
END ON
|
||||
END ON
|
||||
|
||||
ON 결재구분 SELECT CHANGE DO
|
||||
SET 결재구분 = 선택값 // 접수 / 검토 / 최종승인
|
||||
END ON
|
||||
|
||||
ON 결재순서번호 INPUT CHANGE DO
|
||||
SET 결재순서번호 = 값(숫자)
|
||||
END ON
|
||||
|
||||
// 영역 B-1: 결재자 추가 (그리드에 행 추가, 미저장 상태)
|
||||
ON [결재자 추가] 버튼 CLICK DO
|
||||
// 영역 B: 행 추가 (인라인 신규 행)
|
||||
ON [행 추가] 버튼 CLICK DO
|
||||
VALIDATE 서식 선택됨
|
||||
· 결재구분 입력
|
||||
· 결재자 선택됨
|
||||
· 결재순서번호 > 0 (중복 시 안내)
|
||||
ADD 입력 폼 값을 영역 B-2 그리드에 임시 행으로 추가
|
||||
RESET 결재자 입력 폼 (영역 B-1)
|
||||
// 결재선아이디는 저장 시점에 서버에서 자동 발급되므로
|
||||
// 임시 행은 클라이언트 임시 키로 식별 (예: APLN-0007 같은 미발급 표기 또는 임시 ID)
|
||||
VALIDATE 편집 중인 행 없음 (editingRowKey == null)
|
||||
ADD 영역 B-2 그리드 맨 아래 편집 가능 행
|
||||
SET editingRowKey = rowKey (draft-{timestamp})
|
||||
SET 편집상태 = 신규
|
||||
SET aplnId = 미설정 (POST 저장 시 서버 fn_gen_apln_id 발급)
|
||||
SET atrzSeqNo = 현재 행 수 + 1
|
||||
SET atrzSeComCd, aprvrUuid = 빈 값
|
||||
DISABLE 나머지 행 편집 · [행 추가] · 드래그 · [결재선 저장](정책에 따라)
|
||||
END ON
|
||||
|
||||
// 영역 B-1: 결재선 저장 (그리드 전체 일괄 저장)
|
||||
// 영역 B-2: 기존 행 수정 진입
|
||||
ON 행별 [수정] CLICK DO
|
||||
VALIDATE 편집 중인 행 없음
|
||||
SET editingRowId = 행.aplnId
|
||||
SET 해당 행만 편집 모드
|
||||
DISABLE 나머지 행 편집 · [행 추가] · 드래그
|
||||
END ON
|
||||
|
||||
// 영역 B-2: 인라인 편집
|
||||
ON 그리드 셀 변경 DO
|
||||
VALIDATE 변경 대상 행 == editingRowId
|
||||
SET 해당 행 dirty = true
|
||||
END ON
|
||||
|
||||
ON 셀 [선택] (검토인력) CLICK DO
|
||||
OPEN 검토인력 선택 다이얼로그 (단일 선택)
|
||||
ON 선택 DO
|
||||
SET 행.aprvrUuid = 선택인력.uuid
|
||||
SET 행.aprvrNm = 선택인력.성명 // UI 표시용 (응답 DTO에 없음 — 로컬 보조 필드)
|
||||
END ON
|
||||
END ON
|
||||
|
||||
// 영역 B-2: 행별 저장
|
||||
ON 행별 [저장] CLICK DO
|
||||
VALIDATE atrzSeComCd, aprvrUuid, atrzSeqNo
|
||||
IF 편집상태 = 신규 THEN
|
||||
CALL POST /as-elapr-service/api/v1/elapr-apln
|
||||
ELSE
|
||||
CALL PUT /as-elapr-service/api/v1/elapr-apln/{aplnId}
|
||||
END IF
|
||||
CALL GET /as-elapr-service/api/v1/elapr-apln?elaprTmpltId={id}
|
||||
CLEAR editingRowId
|
||||
ENABLE 전체 행 조회 모드 · [행 추가] · 드래그
|
||||
END ON
|
||||
|
||||
// 영역 B: 결재선 저장 (드래그 순서 일괄 반영)
|
||||
ON [결재선 저장] 버튼 CLICK DO
|
||||
VALIDATE 서식 선택됨 · 결재선 1건 이상 · 순서번호 중복 없음
|
||||
CALL TBD.결재선일괄저장(서식아이디, 결재선목록)
|
||||
CALL TBD.결재선목록조회(서식아이디) // → 영역 B-2 갱신
|
||||
VALIDATE 서식 선택됨
|
||||
VALIDATE 편집 중인 행 없음
|
||||
REORDER atrzSeqNo = 그리드 행 순서 (1..n)
|
||||
FOR EACH dirty 또는 순서 변경된 행 DO
|
||||
CALL PUT /as-elapr-service/api/v1/elapr-apln/{aplnId} // atrzSeqNo 등 반영
|
||||
END FOR
|
||||
FOR EACH 신규 미저장 행 DO
|
||||
CALL POST /as-elapr-service/api/v1/elapr-apln
|
||||
END FOR
|
||||
CALL GET /as-elapr-service/api/v1/elapr-apln?elaprTmpltId={id}
|
||||
TOAST "결재선이 저장되었습니다."
|
||||
END ON
|
||||
|
||||
// 영역 B-1: 초기화
|
||||
ON [초기화] 버튼 CLICK DO
|
||||
RESET 결재자 입력 폼 (영역 B-1) → 기본값
|
||||
(결재구분=접수, 결재자명="", 결재순서번호=1)
|
||||
// 영역 B-2: 행 드래그
|
||||
ON 영역 B-2 행 DRAG END DO
|
||||
VALIDATE 편집 중인 행 없음
|
||||
REORDER 그리드 행
|
||||
SET 각 행 atrzSeqNo = 행 인덱스 + 1 (UI 즉시 반영, 서버는 [결재선 저장] 또는 행별 [저장])
|
||||
END ON
|
||||
|
||||
// 영역 B-2: 그리드 행 수정·삭제
|
||||
ON 영역 B-2 [수정] 버튼 CLICK DO
|
||||
OPEN 결재자 수정 다이얼로그(선택 행)
|
||||
ON 확인 DO
|
||||
CALL TBD.결재선수정(결재선아이디, 결재선정보)
|
||||
CALL TBD.결재선목록조회(서식아이디)
|
||||
END ON
|
||||
// 영역 B-2: 행 삭제
|
||||
ON 행별 [삭제] CLICK DO
|
||||
CONFIRM "선택한 결재선을 삭제하시겠습니까?"
|
||||
IF 서버 저장된 행 THEN
|
||||
CALL DELETE /as-elapr-service/api/v1/elapr-apln/{aplnId}
|
||||
END IF
|
||||
REMOVE 그리드 행 (신규 미저장 행은 API 호출 없이 제거)
|
||||
CALL GET /as-elapr-service/api/v1/elapr-apln?elaprTmpltId={id}
|
||||
END ON
|
||||
|
||||
ON 영역 B-2 [삭제] 버튼 CLICK DO
|
||||
CONFIRM "선택한 결재자를 삭제하시겠습니까?"
|
||||
CALL TBD.결재선삭제(결재선아이디)
|
||||
CALL TBD.결재선목록조회(서식아이디)
|
||||
ON 행별 [취소] CLICK DO // 신규·수정 공통
|
||||
IF 편집상태 = 신규 THEN REMOVE 임시 행
|
||||
ELSE RESTORE 행 스냅샷 (수정 취소)
|
||||
CLEAR editingRowId
|
||||
ENABLE 전체 행 조회 모드 · [행 추가] · 드래그
|
||||
END ON
|
||||
|
||||
END
|
||||
@@ -171,135 +182,179 @@ END
|
||||
|
||||
### 4-2. 그리드 컬럼
|
||||
|
||||
| 컬럼명 | Field | 타입 | 비고 |
|
||||
| ------------------ | ----------------- | ------ | --------------------------------------------- |
|
||||
| 전자결재서식아이디 | `elctAtrzTmpltId` | string | 예) `TMPL-0001` (서버 자동 발급, TBD) |
|
||||
| 결재서식명 | `atrzTmpltNm` | string | 예) `설치인가 검토 결재` |
|
||||
| 문서구분 | `docSeNm` | string | 예) `설치인가`, `기본계획` (코드/명 매핑 TBD) |
|
||||
| 등록일시 | `regDt` | string | `YYYY-MM-DD HH:mm:ss` |
|
||||
| 관리 | — | — | 행별 **[수정] · [삭제]** 버튼 |
|
||||
| 컬럼명 | Field | 타입 | 비고 |
|
||||
| ------------------ | -------------- | ------ | --------------------------------------- |
|
||||
| 전자결재서식아이디 | `elaprTmpltId` | string | 등록 시 서버(DB 함수) 자동 발급 |
|
||||
| 결재서식명 | `elaprTmpltNm` | string | |
|
||||
| 문서구분 | `docSeComNm` | string | 코드 `docSeComCd`, 상위코드 `ASDOCSECD` |
|
||||
| 등록일시 | `regDt` | string | `yyyy-MM-dd HH:mm:ss` |
|
||||
| 관리 | — | — | 행별 **[수정] · [삭제]** 버튼 |
|
||||
|
||||
- 행 클릭 시: 해당 서식을 **선택 상태**로 만들고 영역 B 라벨(`선택된 서식: {전자결재서식아이디}`)과 영역 B-2 그리드를 갱신한다.
|
||||
- 페이지네이션: TBD (스크린샷에는 미표시).
|
||||
- 행 클릭 시: `elaprTmpltId` 선택 → 영역 B 라벨·B-2 그리드 갱신.
|
||||
- 페이지네이션: Spring `Pageable` (`page`, `size`, `sort`).
|
||||
|
||||
### 4-3. 버튼
|
||||
|
||||
| 버튼명 | 위치 | 동작 |
|
||||
| --------- | -------------- | ----------------------------------------------------------------- |
|
||||
| 서식 등록 | 섹션 우측 상단 | 결재서식 등록 다이얼로그 오픈 (입력 항목·필수값 TBD) |
|
||||
| 수정 | 행별 | 결재서식 수정 다이얼로그 오픈 (해당 행 값으로 초기화) |
|
||||
| 삭제 | 행별 | 확인 다이얼로그 후 삭제. 선택 중인 서식이면 영역 B 상태도 초기화. |
|
||||
| 버튼명 | 위치 | 동작 |
|
||||
| --------- | -------------- | ----------------------------------------------- |
|
||||
| 서식 등록 | 섹션 우측 상단 | 결재서식 등록 다이얼로그 오픈 |
|
||||
| 수정 | 행별 | 결재서식 수정 다이얼로그 (기존 값 초기화) |
|
||||
| 삭제 | 행별 | 확인 후 삭제. 선택 중인 서식이면 영역 B 초기화. |
|
||||
|
||||
### 4-4. 다이얼로그(개요)
|
||||
|
||||
| 다이얼로그명 | 트리거 | 입력 항목(예상) | 비고 |
|
||||
| ------------------ | ---------------- | ------------------------------ | ------------------------ |
|
||||
| 결재서식 등록 | [서식 등록] 클릭 | 결재서식명, 문서구분(드롭다운) | 등록 성공 시 영역 A 갱신 |
|
||||
| 결재서식 수정 | 행별 [수정] | 결재서식명, 문서구분 | 기존 값으로 초기화 |
|
||||
| 결재서식 삭제 확인 | 행별 [삭제] | (확인/취소) | |
|
||||
| 다이얼로그명 | 트리거 | 입력 항목 | 비고 |
|
||||
| ------------------ | ----------- | ----------------------------------------------- | ----------------------------------------- |
|
||||
| 결재서식 등록 | [서식 등록] | `elaprTmpltNm`, `docSeComCd` | `elaprTmpltExpln` 선택, ID는 저장 후 응답 |
|
||||
| 결재서식 수정 | 행별 [수정] | `elaprTmpltNm`, `docSeComCd`, `elaprTmpltExpln` | ID는 변경 불가 |
|
||||
| 결재서식 삭제 확인 | 행별 [삭제] | (확인/취소) | |
|
||||
|
||||
---
|
||||
|
||||
## 5. 영역 B: 결재선 관리
|
||||
|
||||
영역 A에서 선택된 결재서식을 기준으로 결재 단계(접수·검토·최종승인)와 결재자, 순서를 관리한다. 영역 B는 상단 **결재자 입력 폼(B-1)** 과 하단 **결재선 그리드(B-2)** 로 구성된다.
|
||||
영역 A에서 선택된 `elaprTmpltId` 기준으로 결재선을 관리한다. **등록·수정은 B-2 그리드 인라인 폼**으로 수행하며, 검토인력 지정만 **[선택]** 보조 다이얼로그를 사용한다.
|
||||
|
||||
### 5-1. 헤더 / 상태 표시
|
||||
### 5-1. 헤더 / 툴바
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| 섹션 타이틀 | 결재선 관리 |
|
||||
| 우측 라벨 | `선택된 서식: {전자결재서식아이디}` — 영역 A에서 선택한 서식 ID를 강조 색으로 표시. 미선택 시 영역 B 비활성. |
|
||||
| 항목 | 내용 |
|
||||
| ----------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 섹션 타이틀 | 결재선 관리 |
|
||||
| 우측 라벨 | `선택된 서식: {elaprTmpltId}` — 미선택 시 영역 B 비활성 |
|
||||
| 우측 버튼 | **[행 추가]** — 편집 중인 행이 없을 때만 활성 · **[결재선 저장]** — 편집 중인 행이 없을 때만 활성(순서·미저장 행 일괄 반영) |
|
||||
|
||||
### 5-2. 영역 B-1: 결재자 입력 폼
|
||||
|
||||
#### 필드 정의
|
||||
|
||||
| 표시명 | 입력 유형 | 필수 | 기본값 | 데이터 소스 | API 파라미터 매핑 | 비고 |
|
||||
| ---------------------- | ----------------- | ---- | ----------- | -------------------- | ----------------- | --------------------------------------------------------------------- |
|
||||
| 결재선아이디(자동발급) | 텍스트(읽기 전용) | — | `APLN-XXXX` | 서버 자동 발급 (TBD) | TBD | 입력 불가. 저장 시 서버 발급. 표시값은 미발급 자리표시(또는 임시 ID). |
|
||||
| 결재구분 | 드롭다운 | 예 | `접수` | 코드 (TBD) | TBD | 옵션: **접수 / 검토 / 최종승인** (그리드 행 기준) |
|
||||
| 결재자명 | 텍스트(읽기 전용) | 예 | `""` | [검토인력 선택] 결과 | TBD | 자유 입력 불가. 다이얼로그 선택 결과로 채움. |
|
||||
| 결재순서번호 | 숫자 입력 | 예 | `1` | — | TBD | 1 이상의 정수. 동일 서식 내 중복 검증 필요. |
|
||||
|
||||
플레이스홀더 / 표시 텍스트:
|
||||
|
||||
- 결재자명 입력란 플레이스홀더: `검토인력을 선택하세요`
|
||||
- 결재선아이디 표시 예시: `APLN-0007` (다음 발급 예정 번호 노출 또는 빈 칸 — 표시 정책 TBD)
|
||||
|
||||
#### 버튼
|
||||
|
||||
| 버튼명 | 동작 |
|
||||
| ------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| 검토인력 선택 | **검토인력 선택 다이얼로그** 오픈. 선택 시 `결재자명`과 내부 `결재자 ID`를 폼 상태에 반영. |
|
||||
| 결재자 추가 | 입력 폼 값을 영역 B-2 그리드에 **임시 행**으로 추가하고 폼을 초기화한다. (서버 호출 없음, 저장 시 일괄 반영.) |
|
||||
| 결재선 저장 | 영역 B-2의 결재선 목록을 일괄 저장 API로 전송한다. 성공 시 그리드 재조회 + 토스트 표시. |
|
||||
| 초기화 | 입력 폼을 기본값으로 리셋(결재구분=접수, 결재자명=빈값, 결재순서번호=1). |
|
||||
|
||||
> 정책 메모: **[결재자 추가]** 와 **[결재선 저장]** 의 분리에 대해서는 두 가지 운영이 가능하다. 본 SPEC 초안은 그리드에 임시 행을 누적해 두고 [결재선 저장] 시점에 서버에 일괄 반영하는 모델을 가정한다. 실제 구현 방식(추가 즉시 저장 vs 일괄 저장)은 백엔드 API 확정 후 재정의(TBD).
|
||||
|
||||
#### 다이얼로그
|
||||
|
||||
| 다이얼로그명 | 트리거 | 표시 내용 / 입력 항목 | 결과 |
|
||||
| ---------------- | --------------- | ---------------------------------------------------------- | ----------------------------------- |
|
||||
| 검토인력 선택 | [검토인력 선택] | 사용자 검색 필터(이름/소속 등 TBD), 사용자 목록(단일 선택) | 선택된 사용자 정보를 입력 폼에 반영 |
|
||||
| 결재자 수정 | B-2 행별 [수정] | 결재구분, 결재자명(선택), 결재순서번호 | 확인 시 해당 행 갱신 |
|
||||
| 결재자 삭제 확인 | B-2 행별 [삭제] | (확인/취소) | 확인 시 해당 행 삭제 |
|
||||
|
||||
### 5-3. 영역 B-2: 결재선 그리드
|
||||
### 5-2. 영역 B-2: 결재선 그리드 (인라인 편집)
|
||||
|
||||
#### 그리드 컬럼
|
||||
|
||||
| 컬럼명 | Field | 타입 | 비고 |
|
||||
| ------------ | ------------ | ------ | ------------------------------------- |
|
||||
| 결재선아이디 | `atrzLineId` | string | 예) `APLN-0001` (서버 자동 발급, TBD) |
|
||||
| 결재순서번호 | `atrzSeqNo` | number | 1부터 시작. 단계 순서. |
|
||||
| 결재구분 | `atrzSeNm` | string | `접수` / `검토` / `최종승인` |
|
||||
| 결재자명 | `atrzUserNm` | string | 검토인력 선택 시 채워짐 |
|
||||
| 등록일시 | `regDt` | string | `YYYY-MM-DD HH:mm:ss` |
|
||||
| 관리 | — | — | 행별 **[수정] · [삭제]** 버튼 |
|
||||
| 컬럼명 | Field | 입력 UI (편집 모드) | 비고 |
|
||||
| ------------- | ------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------- |
|
||||
| (드래그 핸들) | — | — | `useSortable` 연동 |
|
||||
| 결재선아이디 | `aplnId` | 읽기 전용 | 신규 행: `—`, 저장 후 서버 `aplnId` 표시 |
|
||||
| 결재순서번호 | `atrzSeqNo` | 읽기 전용 (그리드 행 순서 1부터 표시) | 저장 시 행 순서 기준 자동 채번 |
|
||||
| 결재구분 | `atrzSeComCd` | Select (`ATRZSECD` 공통코드) | 표시명 `atrzSeComNm` (조회 행) |
|
||||
| 결재자 | `aprvrUuid` | 읽기 전용 이름 + **[선택]** 버튼 | 응답에 성명 없음 — 선택 시 로컬 `aprvrNm` 유지 |
|
||||
| 등록일시 | `regDt` | 읽기 전용 | 신규 행은 `—` |
|
||||
| 관리 | — | 편집 중인 행: **[저장] [취소]** · 조회 행: **[수정] [삭제]** (다른 행 편집 중이면 비활성) |
|
||||
|
||||
- 표시 정렬: 기본 `결재순서번호` 오름차순.
|
||||
- 임시 행(서버 미저장)과 저장된 행의 시각적 구분 필요(예: ID 컬럼 표기 또는 행 스타일). 세부 UI는 TBD.
|
||||
- 페이지네이션: TBD.
|
||||
#### 인라인 편집 정책
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **단일 행 편집** | **행 추가 또는 한 행 수정 중에는 다른 행을 편집할 수 없다.** `editingRowId` 로 현재 편집 행 1건만 허용. |
|
||||
| 편집 잠금 시 비활성 | 다른 행: 셀·[수정]·[삭제] 비활성 · `[행 추가]` 비활성 · 행 드래그 비활성 · (선택) `[결재선 저장]` 비활성 |
|
||||
| 편집 해제 | 행별 **[저장]** 성공 또는 **[취소]** 시 `editingRowId` 초기화 후 전체 조회 모드 복귀 |
|
||||
| 조회 행 | 편집 중이 아닐 때 읽기 전용. **[수정]** 클릭 시 해당 행만 편집 모드 진입 |
|
||||
| 신규 행 | `[행 추가]` 시 맨 아래 편집 행 1건 생성 (`editingRowId` = 임시 키) |
|
||||
| 필수값 | `atrzSeComCd`, `aprvrUuid` |
|
||||
| 순서 중복 | 저장 시 행 순서로 자동 채번하므로 중복 없음 |
|
||||
| 결재구분 코드 | `ATRZSECD` — 예: `ATRZ01` 검토자, `ATRZ02` 승인자, `ATRZ03` 최종승인자 |
|
||||
| **순서 제약** | `ATRZ01`은 최상단 블록, `ATRZ03`은 최하단 블록. 드래그 종료·저장 시 `validateAtrzOrder`로 검증, 위반 시 적용/저장 불가 (결재구분 변경 시에는 미검증) |
|
||||
| 저장 단위 | 행별 **[저장]** (단건 POST/PUT) 또는 툴바 **[결재선 저장]** (일괄) |
|
||||
|
||||
#### 행 드래그 정렬 (`@dnd-kit` + `useSortable`)
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ---------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| 목적 | 결재 단계 순서를 그리드에서 직접 변경 |
|
||||
| 라이브러리 | `@dnd-kit/core`, `@dnd-kit/sortable` — 테이블 행 POC 후 확정 |
|
||||
| 저장 시점 | 드래그는 클라이언트 상태만 변경 → **[결재선 저장]** 또는 행별 **[저장]** 시 `atrzSeqNo` PUT |
|
||||
| 편집 중 | `editingRowKey != null` 이면 드래그 **불가** |
|
||||
| 순서 제약 | 드래그 종료 시 `validateAtrzOrder` — 실패하면 순서 변경 미적용 + 안내. 결재구분 미선택 행은 드래그 불가 |
|
||||
|
||||
### 5-3. 보조 다이얼로그 (검토인력만)
|
||||
|
||||
| 다이얼로그명 | 트리거 | 결과 |
|
||||
| ------------- | ----------------------- | ---------------------------------- |
|
||||
| 검토인력 선택 | 그리드 결재자 셀 [선택] | `aprvrUuid` 및 UI용 `aprvrNm` 반영 |
|
||||
|
||||
> 결재자 **등록·수정 다이얼로그는 사용하지 않음** (v0.2 변경).
|
||||
|
||||
---
|
||||
|
||||
## 6. API/DTO 명세
|
||||
|
||||
> 본 화면은 현재 UI 위주 초안으로, API/DTO는 백엔드 명세 확정 후 채운다.
|
||||
|
||||
### 6-1. 공통
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ----------- | ------------------------------------------- |
|
||||
| Base prefix | TBD (예: `/server` 또는 결재 서비스 prefix) |
|
||||
| 인증 | JWT 토큰 기반 (Authorization 헤더) |
|
||||
| 클라이언트 | TBD (예: `@/entities/approval-line`) |
|
||||
| 항목 | 내용 |
|
||||
| ----------- | ------------------------------------------------------------- |
|
||||
| 서비스 | `as-elapr-service` |
|
||||
| Base prefix | `/as-elapr-service/api/v1` (프록시 경유) |
|
||||
| 인증 | JWT (`Authorization` 헤더) |
|
||||
| 페이징 | Spring `Pageable`: `page`, `size`, `sort` → `PageResponse<T>` |
|
||||
| 엔티티 | `@/entities/elapr` (`elaprTmpltService`, `elaprAplnService`) |
|
||||
|
||||
### 6-2. 전자결재서식 (영역 A)
|
||||
### 6-2. 전자결재서식 (영역 A) — `elapr-tmplt`
|
||||
|
||||
| 기능 | Method | URL | Request DTO | Response DTO | API(클라이언트 함수명) |
|
||||
| ------------------ | ------ | --- | ----------- | ------------ | ---------------------- |
|
||||
| 결재서식 목록 조회 | TBD | TBD | TBD | TBD | TBD |
|
||||
| 결재서식 등록 | TBD | TBD | TBD | TBD | TBD |
|
||||
| 결재서식 수정 | TBD | TBD | TBD | TBD | TBD |
|
||||
| 결재서식 삭제 | TBD | TBD | TBD | TBD | TBD |
|
||||
| 기능 | Method | URL | Request / Query | Response | 클라이언트 |
|
||||
| --------- | ------ | ----------------------------- | ------------------------------------------ | ------------------ | ---------------------------- |
|
||||
| 목록 조회 | GET | `/elapr-tmplt` | Query: `ElaprTmpltSearchParams` + Pageable | `Page<ElaprTmplt>` | `elaprTmpltService.findPage` |
|
||||
| 단건 조회 | GET | `/elapr-tmplt/{elaprTmpltId}` | — | `ElaprTmplt` | `elaprTmpltService.findById` |
|
||||
| 등록 | POST | `/elapr-tmplt` | `ElaprTmpltSaveRequest` | `ElaprTmplt` (201) | `elaprTmpltService.create` |
|
||||
| 수정 | PUT | `/elapr-tmplt/{elaprTmpltId}` | `ElaprTmpltUpdateRequest` | `ElaprTmplt` | `elaprTmpltService.update` |
|
||||
| 삭제 | DELETE | `/elapr-tmplt/{elaprTmpltId}` | — | 204 | `elaprTmpltService.remove` |
|
||||
|
||||
### 6-3. 결재선 (영역 B)
|
||||
**ElaprTmplt (응답)**
|
||||
|
||||
| 기능 | Method | URL | Request DTO | Response DTO | API(클라이언트 함수명) |
|
||||
| ------------------------ | ------ | --- | ------------------------------------ | ------------ | ---------------------- |
|
||||
| 결재선 목록 조회(서식별) | TBD | TBD | `{ elctAtrzTmpltId }` (TBD) | TBD | TBD |
|
||||
| 결재선 추가/일괄 저장 | TBD | TBD | `{ elctAtrzTmpltId, lines[] }` (TBD) | TBD | TBD |
|
||||
| 결재선 수정 | TBD | TBD | TBD | TBD | TBD |
|
||||
| 결재선 삭제 | TBD | TBD | TBD | TBD | TBD |
|
||||
| Field | 타입 | 설명 |
|
||||
| ----------------- | ------- | ------------------ |
|
||||
| `elaprTmpltId` | string | 전자결재서식아이디 |
|
||||
| `elaprTmpltNm` | string | 결재서식명 |
|
||||
| `elaprTmpltExpln` | string? | 서식 설명 |
|
||||
| `docSeComCd` | string? | 문서구분 코드 |
|
||||
| `docSeComNm` | string? | 문서구분명 |
|
||||
| `regDt` | string? | 등록일시 |
|
||||
|
||||
### 6-4. 검토인력 (다이얼로그)
|
||||
**ElaprTmpltSaveRequest**: `elaprTmpltNm`\_, `elaprTmpltExpln?`, `docSeComCd?`
|
||||
|
||||
| 기능 | Method | URL | Request DTO | Response DTO | API(클라이언트 함수명) |
|
||||
| ------------------ | ------ | --- | --------------- | ------------ | ---------------------- |
|
||||
| 검토인력 목록 조회 | TBD | TBD | TBD (검색 필터) | TBD | TBD |
|
||||
**ElaprTmpltUpdateRequest**: `elaprTmpltNm`\*, `elaprTmpltExpln?`, `docSeComCd?`
|
||||
|
||||
**ElaprTmpltSearchParams**: `elaprTmpltNm?`, `docSeComCd?`, `regDtStart?`, `regDtEnd?` + `page`, `size`, `sort`
|
||||
|
||||
### 6-3. 결재선 (영역 B) — `elapr-apln`
|
||||
|
||||
| 기능 | Method | URL | Request / Query | Response | 클라이언트 |
|
||||
| --------- | ------ | ---------------------- | ------------------------------- | ----------------- | --------------------------- |
|
||||
| 목록 조회 | GET | `/elapr-apln` | Query: `elaprTmpltId`, Pageable | `Page<ElaprApln>` | `elaprAplnService.findPage` |
|
||||
| 단건 조회 | GET | `/elapr-apln/{aplnId}` | — | `ElaprApln` | `elaprAplnService.findById` |
|
||||
| 등록 | POST | `/elapr-apln` | `ElaprAplnSaveRequest` | `ElaprApln` (201) | `elaprAplnService.create` |
|
||||
| 수정 | PUT | `/elapr-apln/{aplnId}` | `ElaprAplnUpdateRequest` | `ElaprApln` | `elaprAplnService.update` |
|
||||
| 삭제 | DELETE | `/elapr-apln/{aplnId}` | — | 204 | `elaprAplnService.remove` |
|
||||
|
||||
> **일괄 저장 API 없음** — UI의 [결재선 저장]은 변경된 행에 대해 POST/PUT을 순차(또는 병렬) 호출하는 클라이언트 오케스트레이션으로 구현.
|
||||
|
||||
**ElaprApln (응답)**
|
||||
|
||||
| Field | 타입 | 설명 |
|
||||
| -------------- | ------- | ------------------ |
|
||||
| `aplnId` | string | 결재선아이디 |
|
||||
| `elaprTmpltId` | string | 전자결재서식아이디 |
|
||||
| `aprvrUuid` | string? | 결재자 UUID |
|
||||
| `atrzSeComCd` | string? | 결재구분 코드 |
|
||||
| `atrzSeComNm` | string? | 결재구분명 |
|
||||
| `atrzSeqNo` | number? | 결재순서번호 |
|
||||
| `regDt` | string? | 등록일시 |
|
||||
|
||||
**ElaprAplnSaveRequest**: `elaprTmpltId`\_, `aprvrUuid?`, `atrzSeComCd?`, `atrzSeqNo?`
|
||||
|
||||
**ElaprAplnUpdateRequest**: `elaprTmpltId`\*, `aprvrUuid?`, `atrzSeComCd?`, `atrzSeqNo?`
|
||||
|
||||
### 6-4. 검토인력 (보조)
|
||||
|
||||
| 기능 | Method | URL | 비고 |
|
||||
| ------------------ | ------ | --- | --------------------------- |
|
||||
| 검토인력 목록 조회 | TBD | TBD | 사용자 서비스 또는 별도 API |
|
||||
|
||||
### 6-5. Backend ↔ 화면 필드 매핑 (구 SPEC 대비)
|
||||
|
||||
| 구 SPEC 필드 | 백엔드 필드 |
|
||||
| ----------------- | ---------------------------------------------------- |
|
||||
| `elctAtrzTmpltId` | `elaprTmpltId` |
|
||||
| `atrzTmpltNm` | `elaprTmpltNm` |
|
||||
| `docSeNm` | `docSeComNm` |
|
||||
| `atrzLineId` | `aplnId` |
|
||||
| `atrzSeNm` | `atrzSeComNm` |
|
||||
| `atrzUserNm` | (없음) `aprvrUuid` + 사용자 조회/선택 시 로컬 표시명 |
|
||||
|
||||
---
|
||||
|
||||
@@ -307,52 +362,46 @@ END
|
||||
|
||||
### 7-1. 기술 스택·원칙
|
||||
|
||||
- **공통 컴포넌트**: `@common/components` (버튼, 셀렉트, 인풋, 카드 등 기존 패턴 재사용).
|
||||
- **테이블**: `@tanstack/react-table`, 프로젝트 표준 `useDataTable` 패턴이 있다면 동일하게 적용.
|
||||
- **데이터**: React Query 기반 조회/뮤테이션. API 확정 후 `queryKey` 와 무효화 정책 정리.
|
||||
- **상태**: 영역 A의 **선택된 서식 ID**, 영역 B-1의 **결재자 입력 폼 값**, 영역 B-2의 **임시 결재선 배열**은 페이지 로컬 상태로 관리. 다이얼로그 상태(검토인력 선택 등)는 컴포넌트 로컬 상태 또는 가벼운 상태 관리 훅으로 분리.
|
||||
- **모의 데이터**: 백엔드 명세 확정 전까지 `@faker-js/faker` 로 그리드 목데이터 구성(스크린샷 컬럼/값과 동일 형태).
|
||||
- **공통 컴포넌트**: `@common/components`
|
||||
- **테이블**: `@tanstack/react-table` + 인라인 편집 셀 (`Select`, `Input`, `Button`)
|
||||
- **드래그**: `@dnd-kit` — B-2 행 정렬 (POC 필수)
|
||||
- **데이터**: React Query — `@/entities/elapr` hooks
|
||||
- **상태**: 선택된 `elaprTmpltId`, `editingRowId` (단일 행 편집 잠금), B-2 행 배열(dirty·신규·`aprvrNm`·수정 전 스냅샷)
|
||||
- **모의 데이터**: API 연동 전 `@faker-js/faker`
|
||||
|
||||
### 7-2. 모듈·파일 배치(제안)
|
||||
|
||||
| 구분 | 경로 |
|
||||
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 라우트(App Router) | `apps/as/app/(with-base-layout)/approval-line-management/page.tsx` |
|
||||
| 페이지 컴포넌트 | `apps/as/src/pages/approval-line-management/index.ts`<br/>`.../ui/approval-line-management-page.tsx` |
|
||||
| 위젯(영역 단위) | `apps/as/src/widgets/approval-template-list/`<br/>`apps/as/src/widgets/approval-line-editor/` |
|
||||
| 피처(상호작용) | `apps/as/src/features/approver-picker/` (검토인력 선택 다이얼로그) |
|
||||
| 엔티티(도메인) | `apps/as/src/entities/approval-template/`<br/>`apps/as/src/entities/approval-line/` |
|
||||
| 그리드 컬럼 정의 | `entities/approval-template/model/approval-template-table-columns.tsx`<br/>`entities/approval-line/model/approval-line-table-columns.tsx` |
|
||||
| API 클라이언트 | `entities/approval-template/api/*`, `entities/approval-line/api/*` (TBD) |
|
||||
| 구분 | 경로 |
|
||||
| ------ | -------------------------------------------------------------------------------------- |
|
||||
| 라우트 | `apps/as/app/(with-base-layout)/approval-line-management/page.tsx` |
|
||||
| 페이지 | `apps/as/src/pages/approval-line-management/` (Public API: page만 export) |
|
||||
| UI | `.../ui/elapr-tmplt-list.tsx`, `elapr-tmplt-form-dialog.tsx`, `elapr-apln-grid.tsx` 등 |
|
||||
| model | `.../model/columns.tsx`, `apln-row-types.ts`, `mock-reviewers.ts` |
|
||||
| lib | `.../lib/use-apln-rows.ts`, `validators.ts`, `use-dnd-sensors.ts` |
|
||||
| 엔티티 | `apps/as/src/entities/elapr/` |
|
||||
|
||||
### 7-3. 영역별 UI 매핑
|
||||
|
||||
| 영역 | 주요 UI | 비고 |
|
||||
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
|
||||
| A | 섹션 헤더 + Button(`서식 등록`) + DataTable (행 선택, 행별 Action 버튼) | 선택 행 강조, 행 클릭으로 영역 B 갱신 |
|
||||
| B 헤더 | 상태 라벨 `선택된 서식: {ID}` | 미선택 시 영역 B-1·B-2 비활성 |
|
||||
| B-1 | 읽기 전용 Input(아이디), Select(결재구분), Input+Button(결재자/검토인력 선택), NumberInput(순서번호), Action 버튼 3종(`결재자 추가`/`결재선 저장`/`초기화`) | 폼 유효성 검증 후 그리드에 임시 행 추가 |
|
||||
| B-2 | DataTable (행별 [수정][삭제]), 임시 행 시각적 구분 | 기본 정렬: 결재순서번호 ASC |
|
||||
| 공통 | Dialog(검토인력 선택, 서식 등록/수정, 결재자 수정, 삭제 확인) | 디자인 시스템의 `Dialog` 컴포넌트 사용 |
|
||||
| 영역 | 주요 UI |
|
||||
| ------ | -------------------------------------------------------------------------------------------- |
|
||||
| A | DataTable + 행 선택 + 서식 등록/수정 다이얼로그 |
|
||||
| B 헤더 | 상태 라벨 + `행 추가`, `결재선 저장` |
|
||||
| B-2 | 인라인 편집 DataTable + `editingRowId` 단일 행 잠금 + 드래그 핸들 + 행별 수정/저장/삭제/취소 |
|
||||
| 공통 | 서식 Dialog, 검토인력 선택 Dialog만 |
|
||||
|
||||
### 7-4. 데이터 흐름(요약)
|
||||
|
||||
- **진입 시**: 결재서식 목록 조회(TBD) → 영역 A 그리드 렌더. 영역 B는 안내/비활성 상태.
|
||||
- **서식 선택 시**: 선택된 `elctAtrzTmpltId` 를 페이지 상태로 보관 → 결재선 목록 조회(TBD) → 영역 B-2 그리드 갱신. 영역 B-1 폼 초기화.
|
||||
- **결재자 추가**: 입력 폼 값을 영역 B-2의 **클라이언트 임시 배열**에 push. 결재선아이디는 미발급 상태로 표시(또는 임시 키). 폼 리셋.
|
||||
- **결재선 저장**: 임시 배열 + 기존 그리드 행을 합쳐 일괄 저장 API(TBD) 호출 → 성공 시 그리드 재조회 + 토스트.
|
||||
- **수정/삭제**: 각각의 단건 API(TBD) 호출 후 그리드 재조회.
|
||||
- **검토인력 선택 다이얼로그**: 사용자 목록 조회(TBD) → 단일 선택 → 폼의 결재자명/결재자 ID 갱신.
|
||||
- **서식 등록/수정/삭제**: 다이얼로그 또는 확인 후 단건 API(TBD) 호출 → 영역 A 그리드 재조회. 삭제 대상이 현재 선택된 서식이면 영역 B 초기화.
|
||||
- **진입**: `useElaprTmpltPage` → 영역 A. B 비활성.
|
||||
- **서식 선택**: `useElaprAplnPage({ elaprTmpltId })` → B-2.
|
||||
- **행 추가**: `editingRowId` 없을 때만 가능 → 신규 행 1건 편집 잠금 → 저장/취소 후 해제.
|
||||
- **인라인 수정**: [수정]으로 `editingRowId` 설정 → 해당 행만 편집 → `useUpdateElaprApln` 후 해제.
|
||||
- **드래그**: `editingRowId == null` 일 때만 가능 → 로컬 `atrzSeqNo` 재부여 → 일괄 PUT.
|
||||
- **삭제**: `useDeleteElaprApln` 또는 신규 행 로컬 제거.
|
||||
|
||||
### 7-5. 미확정 / 검증 필요 항목 (TBD)
|
||||
### 7-5. 미확정 / 검증 필요
|
||||
|
||||
- 모든 백엔드 API 경로·DTO·필드명.
|
||||
- `결재선아이디(자동발급)` 입력 박스의 표시 정책: (a) 다음 발급 예정 번호 미리 보여주기, (b) 저장 전에는 빈 칸 또는 자리표시자만, (c) 임시 ID.
|
||||
- `[결재자 추가]` 의 동작: 그리드에 임시 누적(현재 가정) vs 즉시 단건 저장.
|
||||
- 결재구분 코드 값(`접수`/`검토`/`최종승인`)의 코드 매핑.
|
||||
- 문서구분 코드 값(`설치인가`/`기본계획` 등)의 코드 매핑.
|
||||
- 영역 A·B 그리드의 페이지네이션·정렬·검색 필터 유무.
|
||||
- 검토인력 선택 다이얼로그의 검색 필드(부서/직급/이름 등) 및 다중 선택 허용 여부.
|
||||
- 결재선 저장 시 순서번호 중복·누락(예: 1, 2, 4) 허용 정책.
|
||||
- 권한(역할별 등록/수정/삭제 가능 여부).
|
||||
- 검토인력 API 경로·DTO.
|
||||
- `@dnd-kit` + react-table 행 인라인 Input 동시 적용 POC.
|
||||
- 결재자 성명 표시: `aprvrUuid` → 사용자명 조회 API.
|
||||
- 영역 A·B 페이지네이션·검색 UI 노출 여부.
|
||||
- 권한(역할별 CRUD).
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
# 화면설계서: 체크리스트관리
|
||||
|
||||
## 1. 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ---------- | -------------------------------------------- |
|
||||
| 버전 | 0.1 |
|
||||
| 프로세스명 | UI설계 |
|
||||
| 화면명 | 체크리스트관리 |
|
||||
| 화면 유형 | [v] 조회 [v] 입력 [v] 수정 [v] 삭제 [ ] 출력 |
|
||||
| 메뉴 경로 | 디지털행정지원 > 시스템관리 > 체크리스트관리 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 화면 목적
|
||||
|
||||
업무별·문서별 **점검표**(체크리스트)를 등록·관리하고, **점검항목** 마스터를 유지한다. 상단 좌측에서 점검표를 선택하면 하단 **체크리스트 항목** 영역에 해당 점검표의 검토 구성(계층형 검토내용)을 편집한다. 상단 우측 **점검항목 관리**는 점검표에 연결할 항목 풀을 등록·수정·삭제한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 화면 구성
|
||||
|
||||
### 3-1. 전체 레이아웃
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 페이지 타이틀: 체크리스트관리 브레드크럼: 디지털행정지원 > … > │
|
||||
├──────────────────────────────────────────────┬────────────────────────────────────────────────┤
|
||||
│ [영역 A] 점검표 관리 │ [영역 B] 점검항목 관리 │
|
||||
│ [점검표 추가] │ [점검항목 추가] │
|
||||
│ ┌────────────────────────────────────────┐ │ ┌──────────────────────────────────────────┐ │
|
||||
│ │ 점검표일련번호 | 점검표명 | 문서구분 │ │ │ 점검항목코드 | 점검항목명 | 사용여부 │ │
|
||||
│ │ 업무구분 | 사용여부 | 등록일시 | 관리 │ │ │ 등록일시 | 관리 │ │
|
||||
│ ├────────────────────────────────────────┤ │ ├──────────────────────────────────────────┤ │
|
||||
│ │ 1 (선택 행 강조) [관리] [삭제] │ │ │ ITEM_001 요건검토 … [관리][삭제] │ │
|
||||
│ │ │ │ │ ITEM_002 법령검토 … [관리][삭제] │ │
|
||||
│ └────────────────────────────────────────┘ │ └──────────────────────────────────────────┘ │
|
||||
├──────────────────────────────────────────────┴────────────────────────────────────────────────┤
|
||||
│ [영역 C] 체크리스트 항목 [미리보기] [추가] [저장] │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 점검항목 ▼ | 주요 검토내용 | 사용 여부 | 구분 | 관리 │ │
|
||||
│ │ L1 [요건검토 ▼] | (입력) | ○예 ○아니오 | 1단계 | [하위추가]│ │
|
||||
│ │ L2 [요건검토 ▼] | (입력) | ○예 ○아니오 | 2단계 | [삭제] │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────────────────────────┘ │
|
||||
└───────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3-2. 화면 데이터 흐름
|
||||
|
||||
```
|
||||
BEGIN
|
||||
|
||||
// 최초 진입
|
||||
CALL GET /as-checklist-service/api/v1/cklst
|
||||
DISPLAY 점검표 그리드 (영역 A)
|
||||
CALL GET /as-checklist-service/api/v1/cklst-artcl
|
||||
DISPLAY 점검항목 그리드 (영역 B)
|
||||
DISPLAY 빈 상태 안내 (영역 C) // 점검표 미선택 시 C 비활성
|
||||
|
||||
// 영역 A: 점검표 행 선택
|
||||
ON 영역 A 행 CLICK DO
|
||||
SET 선택된 점검표 = 행.cklstSn
|
||||
CALL GET /as-checklist-service/api/v1/cklst-rvw?cklstSn={cklstSn}
|
||||
// TBD: 점검표 단위 전체 검토 트리 조회 API — 현재는 항목별 by-artcl 조합 또는 클라이언트 병합
|
||||
DISPLAY 체크리스트 항목 그리드 (영역 C)
|
||||
END ON
|
||||
|
||||
// 영역 A: 점검표 추가
|
||||
ON [점검표 추가] 버튼 CLICK DO
|
||||
OPEN 점검표 추가 다이얼로그
|
||||
ON [저장] DO
|
||||
VALIDATE cklstNm, taskSeComCd
|
||||
CALL POST /as-checklist-service/api/v1/cklst
|
||||
CALL GET /as-checklist-service/api/v1/cklst
|
||||
CLOSE 다이얼로그
|
||||
END ON
|
||||
ON [취소] / [닫기] DO
|
||||
CLOSE 다이얼로그
|
||||
END ON
|
||||
END ON
|
||||
|
||||
// 영역 A: 점검표 수정·삭제
|
||||
ON 영역 A 행 [관리] CLICK DO
|
||||
OPEN 점검표 수정 다이얼로그 (기존 값 초기화)
|
||||
ON [저장] DO
|
||||
CALL PUT /as-checklist-service/api/v1/cklst/{cklstSn}
|
||||
CALL GET /as-checklist-service/api/v1/cklst
|
||||
END ON
|
||||
END ON
|
||||
|
||||
ON 영역 A 행 [삭제] CLICK DO
|
||||
CONFIRM "선택한 점검표를 삭제하시겠습니까?"
|
||||
CALL DELETE /as-checklist-service/api/v1/cklst/{cklstSn}
|
||||
IF 삭제 대상 == 선택된 점검표 THEN RESET 영역 C
|
||||
CALL GET /as-checklist-service/api/v1/cklst
|
||||
END ON
|
||||
|
||||
// 영역 B: 점검항목 추가
|
||||
ON [점검항목 추가] 버튼 CLICK DO
|
||||
OPEN 점검항목 추가 다이얼로그
|
||||
ON [저장] DO
|
||||
VALIDATE cklstArtclNm
|
||||
// TBD: cklstSn 필수 — UI에 소속 점검표 선택 없음. 백엔드 정책 확인
|
||||
CALL POST /as-checklist-service/api/v1/cklst-artcl
|
||||
CALL GET /as-checklist-service/api/v1/cklst-artcl
|
||||
CLOSE 다이얼로그
|
||||
END ON
|
||||
END ON
|
||||
|
||||
// 영역 B: 점검항목 수정·삭제
|
||||
ON 영역 B 행 [관리] CLICK DO
|
||||
OPEN 점검항목 수정 다이얼로그
|
||||
ON [저장] DO
|
||||
CALL PUT /as-checklist-service/api/v1/cklst-artcl/{cklstArtclSn}/{cklstSn}
|
||||
END ON
|
||||
END ON
|
||||
|
||||
ON 영역 B 행 [삭제] CLICK DO
|
||||
CONFIRM "선택한 점검항목을 삭제하시겠습니까?"
|
||||
CALL DELETE /as-checklist-service/api/v1/cklst-artcl/{cklstArtclSn}/{cklstSn}
|
||||
CALL GET /as-checklist-service/api/v1/cklst-artcl
|
||||
END ON
|
||||
|
||||
// 영역 C: 검토 행 추가
|
||||
ON [추가] 버튼 CLICK DO
|
||||
VALIDATE 선택된 점검표 존재
|
||||
ADD 영역 C 그리드 맨 아래 편집 가능 행 (1단계, rvwCnLvNo=1)
|
||||
END ON
|
||||
|
||||
// 영역 C: 하위 검토 추가
|
||||
ON 행 [하위추가] CLICK DO
|
||||
ADD 부모행 하위 편집 행 (2단계, upCklstRvwSn=부모.cklstRvwSn, rvwCnLvNo=2)
|
||||
END ON
|
||||
|
||||
// 영역 C: 일괄 저장
|
||||
ON [저장] 버튼 CLICK DO
|
||||
VALIDATE 선택된 점검표 존재
|
||||
FOR EACH 신규·변경된 검토 행 DO
|
||||
IF 신규 THEN
|
||||
CALL POST /as-checklist-service/api/v1/cklst-rvw
|
||||
ELSE
|
||||
CALL PUT /as-checklist-service/api/v1/cklst-rvw/{cklstRvwSn}/{cklstArtclSn}/{cklstSn}
|
||||
END IF
|
||||
END FOR
|
||||
CALL GET /as-checklist-service/api/v1/cklst-rvw?cklstSn={cklstSn}
|
||||
TOAST "체크리스트 항목이 저장되었습니다."
|
||||
END ON
|
||||
|
||||
// 영역 C: 행 삭제
|
||||
ON 행 [삭제] CLICK DO
|
||||
CONFIRM "선택한 항목을 삭제하시겠습니까?"
|
||||
IF 서버 저장된 행 THEN
|
||||
CALL DELETE /as-checklist-service/api/v1/cklst-rvw/{cklstRvwSn}/{cklstArtclSn}/{cklstSn}
|
||||
END IF
|
||||
REMOVE 그리드 행 (미저장 행은 API 호출 없이 제거)
|
||||
END ON
|
||||
|
||||
// 영역 C: 미리보기
|
||||
ON [미리보기] 버튼 CLICK DO
|
||||
OPEN 체크리스트 미리보기 다이얼로그 (읽기 전용 트리)
|
||||
// TBD: 미리보기 UI·API
|
||||
END ON
|
||||
|
||||
END
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 영역 A: 점검표 관리
|
||||
|
||||
선택된 점검표가 영역 C의 편집 대상이 된다. 행 클릭으로 선택 상태를 표시한다.
|
||||
|
||||
### 4-1. 헤더 / 툴바
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ----------- | ------------------------------------------ |
|
||||
| 섹션 타이틀 | 점검표 관리 |
|
||||
| 우측 버튼 | **[점검표 추가]** — 점검표 등록 다이얼로그 |
|
||||
|
||||
### 4-2. 그리드 컬럼
|
||||
|
||||
| 컬럼명 | Field | 타입 | 비고 |
|
||||
| ---------------- | ------------- | ------ | ---------------------------------------------------- |
|
||||
| 점검표일련번호 | `cklstSn` | number | PK |
|
||||
| 점검표명 | `cklstNm` | string | |
|
||||
| 문서구분공통코드 | `docSeComCd` | string | 코드명 표시 — `ASDOCSECD` 등 공통코드 (TBD 상위코드) |
|
||||
| 업무구분공통코드 | `taskSeComCd` | string | RE/SF/SP 등 — 공통코드 (TBD 상위코드) |
|
||||
| 사용여부 | `useYn` | string | `Y`→예, `N`→아니오 |
|
||||
| 등록일시 | `regDt` | string | `yyyy-MM-dd HH:mm:ss` |
|
||||
| 관리 | — | — | 행별 **[관리] · [삭제]** |
|
||||
|
||||
- 행 클릭 시: `cklstSn` 선택 → 영역 C 헤더·그리드 갱신.
|
||||
- 페이지네이션: Spring `Pageable` (`page`, `size`, `sort`).
|
||||
|
||||
### 4-3. 버튼
|
||||
|
||||
| 버튼명 | 위치 | 동작 |
|
||||
| ----------- | -------------- | ------------------------------------------------- |
|
||||
| 점검표 추가 | 섹션 우측 상단 | 점검표 추가 다이얼로그 오픈 |
|
||||
| 관리 | 행별 | 점검표 수정 다이얼로그 (기존 값 초기화) |
|
||||
| 삭제 | 행별 | 확인 후 삭제. 선택 중인 점검표이면 영역 C 초기화. |
|
||||
|
||||
### 4-4. 다이얼로그: 점검표 추가 / 수정
|
||||
|
||||
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
|
||||
| -------- | ---------------- | ---- | ------ | ----------------- | ------------------------------------------------ |
|
||||
| 점검표명 | Text | Y | — | `cklstNm` | placeholder: "예: 설치인가 체크리스트" |
|
||||
| 문서구분 | Select(공통코드) | N | — | `docSeComCd` | 예: FNRS, INSTL, PWRK, DESIGN |
|
||||
| 업무구분 | Select(공통코드) | Y | — | `taskSeComCd` | 예: RE(재이용수), SF(하수처리시설), SP(하수관로) |
|
||||
| 사용여부 | Select | N | Y | `useYn` | Y/N, UI 표시: 예/아니오 |
|
||||
|
||||
| 버튼명 | 동작 |
|
||||
| ------ | -------------------------------------- |
|
||||
| 저장 | POST(등록) 또는 PUT(수정) 후 목록 갱신 |
|
||||
| 취소 | 다이얼로그 닫기, 입력값 폐기 |
|
||||
| 닫기 | 다이얼로그 닫기 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 영역 B: 점검항목 관리
|
||||
|
||||
점검표 하단(영역 C)에서 선택할 **점검항목** 마스터를 관리한다.
|
||||
|
||||
### 5-1. 헤더 / 툴바
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ----------- | ---------------------------------------------- |
|
||||
| 섹션 타이틀 | 점검항목 관리 |
|
||||
| 우측 버튼 | **[점검항목 추가]** — 점검항목 등록 다이얼로그 |
|
||||
|
||||
### 5-2. 그리드 컬럼
|
||||
|
||||
| 컬럼명 | Field | 타입 | 비고 |
|
||||
| ------------ | -------------- | ------ | ------------------------------------- |
|
||||
| 점검항목코드 | `cklstArtclSn` | number | UI mock `ITEM_001` 형식 표시 여부 TBD |
|
||||
| 점검항목명 | `cklstArtclNm` | string | |
|
||||
| 사용여부 | `useYn` | string | `Y`→예, `N`→아니오 |
|
||||
| 등록일시 | `regDt` | string | `yyyy-MM-dd HH:mm:ss` |
|
||||
| 관리 | — | — | 행별 **[관리] · [삭제]** |
|
||||
|
||||
> **TBD**: 백엔드 `CklstArtcl`은 복합 PK(`cklstArtclSn`, `cklstSn`)이며 등록 시 `cklstSn` 필수. UI 다이얼로그에는 `cklstSn` 입력이 없음 — 소속 점검표 지정 방식·전역 마스터 여부 백엔드 정책 확인.
|
||||
|
||||
### 5-3. 다이얼로그: 점검항목 추가 / 수정
|
||||
|
||||
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
|
||||
| ---------- | --------- | ---- | ------ | ----------------- | --------------------------- |
|
||||
| 점검항목명 | Text | Y | — | `cklstArtclNm` | placeholder: "예: 법령검토" |
|
||||
| 사용여부 | Select | N | Y | `useYn` | Y/N |
|
||||
|
||||
| 버튼명 | 동작 |
|
||||
| ------ | -------------------------------------- |
|
||||
| 저장 | POST(등록) 또는 PUT(수정) 후 목록 갱신 |
|
||||
| 취소 | 다이얼로그 닫기 |
|
||||
| 닫기 | 다이얼로그 닫기 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 영역 C: 체크리스트 항목 (선택된 점검표)
|
||||
|
||||
영역 A에서 선택된 `cklstSn` 기준으로 **점검표검토**(계층형 검토내용)를 구성한다.
|
||||
|
||||
### 6-1. 헤더 / 툴바
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ----------- | -------------------------------------------------------------------- |
|
||||
| 섹션 타이틀 | 체크리스트 항목 (선택된 점검표: {cklstNm}) |
|
||||
| 우측 버튼 | **[미리보기]** · **[추가]** · **[저장]** — 점검표 미선택 시 C 비활성 |
|
||||
|
||||
### 6-2. 그리드 컬럼
|
||||
|
||||
| 컬럼명 | Field | 입력 UI | 비고 |
|
||||
| ------------- | -------------- | ------------------------------------- | ------------------------------------ |
|
||||
| 점검항목 | `cklstArtclSn` | Select (영역 B 항목 목록) | L1/L2 접두 표시, `cklstArtclNm` 라벨 |
|
||||
| 주요 검토내용 | `rvwCn` | Text / Textarea | 최대 4000자 |
|
||||
| 사용 여부 | `useYn` | Radio (예/아니오) | `Y` / `N` |
|
||||
| 구분 | `rvwCnLvNo` | 읽기 전용 | 1→1단계, 2→2단계, 3→3단계 |
|
||||
| 관리 | — | **[하위추가]** (1·2단계, 저장 후) · **[삭제]** | 3단계 행은 [하위추가] 미표시 |
|
||||
|
||||
#### 계층 정책
|
||||
|
||||
| 항목 | 내용 |
|
||||
| -------------- | -------------------------------------------------------------------- |
|
||||
| 최대 깊이 | 3단계 (`rvwCnLvNo`: 1, 2, 3) |
|
||||
| 상위-하위 연결 | `upCklstRvwSn` — 2단계 행은 부모 1단계 `cklstRvwSn` 참조 |
|
||||
| 정렬 | `sortSeq` — 동일 레벨 내 순서 (TBD: UI 드래그 정렬 여부) |
|
||||
| 체크 사용 | `bchckUseYn` — 미리보기·실사용 화면 체크박스 노출 여부 (TBD UI 반영) |
|
||||
|
||||
### 6-3. 버튼
|
||||
|
||||
| 버튼명 | 동작 |
|
||||
| -------- | ---------------------------------------------------- |
|
||||
| 추가 | 1단계 검토 행 1건 추가 (편집 가능) |
|
||||
| 저장 | 변경·신규 검토 행 일괄 POST/PUT (일괄 저장 API 없음) |
|
||||
| 미리보기 | 선택 점검표 체크리스트 읽기 전용 미리보기 (TBD) |
|
||||
| 하위추가 | 선택 1·2단계 행 아래 하위 단계(2·3단계) 자식 행 추가 |
|
||||
| 삭제 | 확인 후 DELETE 또는 미저장 행 로컬 제거 |
|
||||
|
||||
---
|
||||
|
||||
## 7. API/DTO 명세
|
||||
|
||||
### 7-1. 공통
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ------------- | ------------------------------------------------------------- |
|
||||
| 서비스 | `as-checklist-service` |
|
||||
| Base prefix | `/as-checklist-service/api/v1` (게이트웨이 프록시 경유) |
|
||||
| 인증 | JWT (`Authorization` 헤더), `X-Site-Id`, `token-id` |
|
||||
| 페이징 | Spring `Pageable`: `page`, `size`, `sort` → `PageResponse<T>` |
|
||||
| 엔티티 | `@/entities/cklst` (`cklstMngService`, `cklstArtclService`, `cklstRvwService`) |
|
||||
|
||||
### 7-2. 점검표 (영역 A) — `cklst`
|
||||
|
||||
| 기능 | Method | URL | Request / Query | Response | 클라이언트 |
|
||||
| --------- | ------ | ------------------ | --------------------------------- | ------------------ | -------------------------- |
|
||||
| 목록 조회 | GET | `/cklst` | `CklstMngSearchParams` + Pageable | `Page<CklstMng>` | `cklstMngService.findPage` |
|
||||
| 단건 조회 | GET | `/cklst/{cklstSn}` | — | `CklstMng` | `cklstMngService.findById` |
|
||||
| 등록 | POST | `/cklst` | `CklstMngSaveRequest` | `number` (cklstSn) | `cklstMngService.create` |
|
||||
| 수정 | PUT | `/cklst/{cklstSn}` | `CklstMngSaveRequest` | `number` (cklstSn) | `cklstMngService.update` |
|
||||
| 삭제 | DELETE | `/cklst/{cklstSn}` | — | `number` (cklstSn) | `cklstMngService.remove` |
|
||||
|
||||
**CklstMng (응답)**
|
||||
|
||||
| Field | 타입 | 설명 |
|
||||
| ------------- | ------- | ---------------- |
|
||||
| `cklstSn` | number | 점검표일련번호 |
|
||||
| `cklstNm` | string | 점검표명 |
|
||||
| `taskSeComCd` | string | 업무구분공통코드 |
|
||||
| `useYn` | string? | 사용여부 |
|
||||
| `docSeComCd` | string? | 문서구분공통코드 |
|
||||
| `rgtr` | string? | 등록자 UUID |
|
||||
| `rgtrNm` | string? | 등록자명 |
|
||||
| `mdfr` | string? | 수정자 UUID |
|
||||
| `regDt` | string? | 등록일시 |
|
||||
| `mdfcnDt` | string? | 수정일시 |
|
||||
|
||||
**CklstMngSaveRequest**: `cklstNm`\*, `taskSeComCd`\*, `useYn?`, `docSeComCd?`
|
||||
|
||||
**CklstMngSearchParams**: `cklstNm?`, `taskSeComCd?`, `useYn?`, `docSeComCd?` + `page`, `size`, `sort`
|
||||
|
||||
**React Query hooks**: `useCklstMngPage`, `useCklstMng`, `useCreateCklstMng`, `useUpdateCklstMng`, `useDeleteCklstMng`
|
||||
|
||||
### 7-3. 점검표항목 (영역 B) — `cklst-artcl`
|
||||
|
||||
| 기능 | Method | URL | Request / Query | Response | 클라이언트 |
|
||||
| ------------------ | ------ | --------------------------------------- | ----------------------------------- | ------------------ | --------------------------------- |
|
||||
| 목록 조회 (페이징) | GET | `/cklst-artcl` | `CklstArtclSearchParams` + Pageable | `Page<CklstArtcl>` | `cklstArtclService.findPage` |
|
||||
| 점검표별 전체 목록 | GET | `/cklst-artcl/by-cklst/{cklstSn}` | — | `CklstArtcl[]` | `cklstArtclService.findByCklstSn` |
|
||||
| 단건 조회 | GET | `/cklst-artcl/{cklstArtclSn}/{cklstSn}` | — | `CklstArtcl` | `cklstArtclService.findById` |
|
||||
| 등록 | POST | `/cklst-artcl` | `CklstArtclSaveRequest` | `CklstArtclId` | `cklstArtclService.create` |
|
||||
| 수정 | PUT | `/cklst-artcl/{cklstArtclSn}/{cklstSn}` | `CklstArtclUpdateRequest` | `CklstArtclId` | `cklstArtclService.update` |
|
||||
| 삭제 | DELETE | `/cklst-artcl/{cklstArtclSn}/{cklstSn}` | — | `CklstArtclId` | `cklstArtclService.remove` |
|
||||
|
||||
**CklstArtcl (응답)**
|
||||
|
||||
| Field | 타입 | 설명 |
|
||||
| ----------------- | ------- | ------------------ |
|
||||
| `cklstArtclSn` | number | 점검표항목일련번호 |
|
||||
| `cklstSn` | number | 점검표일련번호 |
|
||||
| `cklstArtclNm` | string? | 점검표항목명 |
|
||||
| `useYn` | string? | 사용여부 |
|
||||
| `rgtr` / `rgtrNm` | string? | 등록자 |
|
||||
| `regDt` | string? | 등록일시 |
|
||||
|
||||
**CklstArtclSaveRequest**: `cklstSn`\*, `cklstArtclNm?`, `useYn?`
|
||||
|
||||
**CklstArtclUpdateRequest**: `cklstArtclNm?`, `useYn?`
|
||||
|
||||
**CklstArtclId**: `{ cklstArtclSn: number; cklstSn: number }`
|
||||
|
||||
**React Query hooks**: `useCklstArtclPage`, `useCklstArtclList`, `useCklstArtcl`, `useCreateCklstArtcl`, `useUpdateCklstArtcl`, `useDeleteCklstArtcl`
|
||||
|
||||
### 7-4. 점검표검토 (영역 C) — `cklst-rvw`
|
||||
|
||||
| 기능 | Method | URL | Request / Query | Response | 클라이언트 |
|
||||
| --------------------- | ------ | -------------------------------------------------- | ------------------------------ | ---------------- | ------------------------------- |
|
||||
| 목록 조회 (페이징) | GET | `/cklst-rvw` | `CklstRvwSearchParams` + Pageable | `Page<CklstRvw>` | `cklstRvwService.findPage` |
|
||||
| 항목별 검토 전체 목록 | GET | `/cklst-rvw/by-artcl/{cklstSn}/{cklstArtclSn}` | — | `CklstRvw[]` | `cklstRvwService.findByArtcl` |
|
||||
| 단건 조회 | GET | `/cklst-rvw/{cklstRvwSn}/{cklstArtclSn}/{cklstSn}` | — | `CklstRvw` | `cklstRvwService.findById` |
|
||||
| 등록 | POST | `/cklst-rvw` | `CklstRvwSaveRequest` | `CklstRvwId` | `cklstRvwService.create` |
|
||||
| 수정 | PUT | `/cklst-rvw/{cklstRvwSn}/{cklstArtclSn}/{cklstSn}` | `CklstRvwUpdateRequest` | `CklstRvwId` | `cklstRvwService.update` |
|
||||
| 삭제 | DELETE | `/cklst-rvw/{cklstRvwSn}/{cklstArtclSn}/{cklstSn}` | — | `CklstRvwId` | `cklstRvwService.remove` |
|
||||
|
||||
**CklstRvw (응답)**
|
||||
|
||||
| Field | 타입 | 설명 |
|
||||
| -------------- | ------- | ---------------------- |
|
||||
| `cklstRvwSn` | number | 점검표검토일련번호 |
|
||||
| `cklstArtclSn` | number | 점검표항목일련번호 |
|
||||
| `cklstSn` | number | 점검표일련번호 |
|
||||
| `upCklstRvwSn` | number? | 상위점검표검토일련번호 |
|
||||
| `rvwCnLvNo` | number? | 검토내용레벨번호 |
|
||||
| `rvwCn` | string? | 검토내용 |
|
||||
| `bchckUseYn` | string? | 체크사용여부 |
|
||||
| `sortSeq` | number? | 정렬순서 |
|
||||
| `useYn` | string? | 사용여부 |
|
||||
| `regDt` | string? | 등록일시 |
|
||||
|
||||
**CklstRvwSaveRequest**: `cklstArtclSn`\*, `cklstSn`\*, `upCklstRvwSn?`, `rvwCnLvNo?`, `rvwCn?`, `bchckUseYn?`, `sortSeq?`, `useYn?`
|
||||
|
||||
**CklstRvwUpdateRequest**: `upCklstRvwSn?`, `rvwCnLvNo?`, `rvwCn?`, `bchckUseYn?`, `sortSeq?`, `useYn?`
|
||||
|
||||
**CklstRvwSearchParams**: `cklstSn?`, `cklstArtclSn?`, `useYn?` + `page`, `size`, `sort`
|
||||
|
||||
**CklstRvwId**: `{ cklstRvwSn: number; cklstArtclSn: number; cklstSn: number }`
|
||||
|
||||
**React Query hooks**: `useCklstRvwPage`, `useCklstRvwByArtcl`, `useCklstRvw`, `useCreateCklstRvw`, `useUpdateCklstRvw`, `useDeleteCklstRvw`
|
||||
|
||||
### 7-5. 공통코드 (보조)
|
||||
|
||||
| UI 필드 | 코드 그룹 (TBD) | 예시 값 |
|
||||
| -------- | --------------- | -------------------------------------------- |
|
||||
| 문서구분 | `ASDOCSECD` 등 | FNRS, INSTL, PWRK, DESIGN |
|
||||
| 업무구분 | TBD | RE(재이용수), SF(하수처리시설), SP(하수관로) |
|
||||
| 사용여부 | 고정 | Y, N |
|
||||
|
||||
공통코드 조회: `@/entities/code` — `codeService`, `CodeSelect`
|
||||
|
||||
---
|
||||
|
||||
## 8. UI 구현 가이드
|
||||
|
||||
### 8-1. 기술 스택·원칙
|
||||
|
||||
- **공통 컴포넌트**: `@common/components` (`Dialog`, `Button`, `Input`, `Select`, `RadioGroup` 등)
|
||||
- **테이블**: `@tanstack/react-table` + `useDataTable`
|
||||
- **데이터**: React Query — `@/entities/cklst` hooks (`useCklstRvwPage` 등 영역 C 포함)
|
||||
- **공통코드**: `@/entities/code` — `CodeSelect`, `findCodeLabel`
|
||||
- **상태**: 선택된 `cklstSn`, 영역 C 검토 행 배열(신규·dirty·계층 트리)
|
||||
- **모의 데이터**: API 연동 전 `@faker-js/faker`
|
||||
|
||||
### 8-2. 모듈·파일 배치(제안)
|
||||
|
||||
| 구분 | 경로 |
|
||||
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 라우트 | `apps/as/app/(with-base-layout)/checklist-management/page.tsx` |
|
||||
| 페이지 | `apps/as/src/pages/checklist-management/` (Public API: page만 export) |
|
||||
| UI | `.../ui/cklst-mng-list.tsx`, `cklst-mng-form-dialog.tsx`, `cklst-artcl-list.tsx`, `cklst-artcl-form-dialog.tsx`, `cklst-rvw-grid.tsx`, `cklst-preview-dialog.tsx` |
|
||||
| model | `.../model/cklst-mng-columns.tsx`, `cklst-artcl-columns.tsx`, `cklst-rvw-row-types.ts` |
|
||||
| lib | `.../lib/use-cklst-rvw-rows.ts`, `validators.ts`, `build-rvw-tree.ts` |
|
||||
| 엔티티 | `apps/as/src/entities/cklst/` (`cklstMng`, `cklstArtcl`, `cklstRvw` 서비스·hooks) |
|
||||
| 페이지 | `apps/as/src/pages/checklist-management/` — `/checklist-management` |
|
||||
| 문서 | `apps/as/docs/checklist-management/SPEC.md` |
|
||||
|
||||
### 8-3. 영역별 UI 매핑
|
||||
|
||||
| 영역 | 주요 UI |
|
||||
| ---- | ---------------------------------------------------------------------------- |
|
||||
| A | DataTable + 행 선택 + 점검표 추가/수정 Dialog |
|
||||
| B | DataTable + 점검항목 추가/수정 Dialog |
|
||||
| C | 계층형 편집 그리드(1·2단계) + [추가][저장][미리보기] + 행별 [하위추가][삭제] |
|
||||
| 공통 | `CodeSelect` (문서구분·업무구분), 사용여부 Y/N Select/Radio |
|
||||
|
||||
### 8-4. 다이얼로그·라우팅
|
||||
|
||||
| 다이얼로그명 | 트리거 | API 연동 |
|
||||
| ------------------- | --------------- | ------------------------- |
|
||||
| 점검표 추가 | [점검표 추가] | `useCreateCklstMng` |
|
||||
| 점검표 수정 | 행 [관리] | `useUpdateCklstMng` |
|
||||
| 점검표 삭제 확인 | 행 [삭제] | `useDeleteCklstMng` |
|
||||
| 점검항목 추가 | [점검항목 추가] | `useCreateCklstArtcl` |
|
||||
| 점검항목 수정 | 행 [관리] | `useUpdateCklstArtcl` |
|
||||
| 체크리스트 미리보기 | [미리보기] | TBD — 읽기 전용 트리 렌더 |
|
||||
|
||||
라우트: `/checklist-management` (메뉴 URL은 CMS 등록값과 일치시킴)
|
||||
|
||||
### 8-5. 데이터 흐름(요약)
|
||||
|
||||
- **진입**: `useCklstMngPage` → 영역 A, `useCklstArtclPage` → 영역 B. C 비활성.
|
||||
- **점검표 선택**: `cklstSn` 설정 → 영역 C 활성 → `useCklstRvwPage({ cklstSn })` → 트리 그리드 표시.
|
||||
- **점검표 CRUD**: Dialog → `useCreateCklstMng` / `useUpdateCklstMng` / `useDeleteCklstMng`.
|
||||
- **점검항목 CRUD**: Dialog → `useCreateCklstArtcl` / `useUpdateCklstArtcl` / `useDeleteCklstArtcl`.
|
||||
- **검토 저장**: [저장] → 신규 POST / 기존 PUT 순차 호출 (일괄 API 없음).
|
||||
- **영역 C 점검항목 Select**: `useCklstArtclList(cklstSn)` 또는 영역 B 전체 목록과 교차 — **TBD**.
|
||||
|
||||
### 8-6. 미확정 / 검증 필요
|
||||
|
||||
- [ ] 영역 B 점검항목 등록 시 `cklstSn` 필수 vs UI 무소속 마스터 — 백엔드·기획 확인
|
||||
- [ ] `점검항목코드`(ITEM_001) 표시 형식 vs `cklstArtclSn` 숫자 PK
|
||||
- [ ] 영역 C 전체 검토 트리 조회: `cklstSn` 단위 일괄 API 부재 — `by-artcl` 다건 호출 또는 백엔드 확장
|
||||
- [x] `@/entities/cklst` — `cklstRvwService`·hooks 구현
|
||||
- [x] `pages/checklist-management` — 영역 A·B·C UI 구현 (`/checklist-management`)
|
||||
- [ ] 문서구분·업무구분 공통코드 상위코드 ID (`ASDOCSECD` 등)
|
||||
- [ ] [미리보기] 다이얼로그 UI·데이터 소스
|
||||
- [ ] `bchckUseYn` UI 반영 여부
|
||||
- [ ] 영역 A·B 페이지네이션·검색 UI 노출 여부
|
||||
- [ ] 권한(역할별 CRUD)
|
||||
@@ -0,0 +1,385 @@
|
||||
# 화면설계서: 코드관리
|
||||
|
||||
## 1. 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ---------- | -------------------------------------------- |
|
||||
| 버전 | 0.1 |
|
||||
| 프로세스명 | UI설계 |
|
||||
| 화면명 | 코드관리 |
|
||||
| 화면 유형 | [v] 조회 [v] 입력 [v] 수정 [v] 삭제 [ ] 출력 |
|
||||
| 메뉴 경로 | 디지털행정지원 > 사이트관리 > 코드관리 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 화면 목적
|
||||
|
||||
설치인가 등 AS 업무에서 참조하는 **공종구분코드**(공종코드)와 **회계구분코드**(회계코드)를 한 화면에서 조회·등록·수정·삭제한다. 좌측 **공종코드 관리**와 우측 **회계코드 관리**는 독립된 목록·CRUD 영역이며, 각 섹션은 자체 API(`cnstp-se`, `acntg-se`)와 연동한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 화면 구성
|
||||
|
||||
### 3-1. 전체 레이아웃
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 페이지 타이틀: 코드관리 브레드크럼: 디지털행정지원 > 사이트관리 > 코드관리 │
|
||||
├──────────────────────────────────────────────────┬────────────────────────────────────────────────┤
|
||||
│ [영역 A] 공종코드 관리 │ [영역 B] 회계코드 관리 │
|
||||
│ [등록] │ [등록] │
|
||||
│ ┌────────────────────────────────────────────┐ │ ┌──────────────────────────────────────────┐ │
|
||||
│ │ 코드 | 코드명 | 설명 | 사용여부 | 관리 │ │ │ 코드 | 코드명 | 설명 | 사용여부 | 관리 │ │
|
||||
│ ├────────────────────────────────────────────┤ │ ├──────────────────────────────────────────┤ │
|
||||
│ │ WC001 공공하수처리시설 … 사용 [수정][삭제]│ │ │ AC101 시설비 … 사용 [수정][삭제] │ │
|
||||
│ │ WC002 하수관로정비 … 사용 [수정][삭제]│ │ │ AC102 용역비 … 사용 [수정][삭제] │ │
|
||||
│ │ WC003 재이용시설 … 사용 [수정][삭제]│ │ │ AC103 운영비 … 미사용 [수정][삭제] │ │
|
||||
│ └────────────────────────────────────────────┘ │ └──────────────────────────────────────────┘ │
|
||||
│ « 1 2 3 … » │ « 1 2 3 … » │
|
||||
└──────────────────────────────────────────────────┴────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3-2. 화면 데이터 흐름
|
||||
|
||||
```
|
||||
BEGIN
|
||||
|
||||
// 최초 진입 — 좌·우 독립 조회
|
||||
CALL cnstpSeMngService.findAll({ page, size })
|
||||
DISPLAY 공종코드 그리드 (영역 A)
|
||||
|
||||
CALL acntgSeMngService.findAll({ page, size })
|
||||
DISPLAY 회계코드 그리드 (영역 B)
|
||||
|
||||
// 영역 A: 공종코드 등록
|
||||
ON 영역 A [등록] 버튼 CLICK DO
|
||||
OPEN 공종코드 등록 다이얼로그
|
||||
ON [저장] DO
|
||||
VALIDATE cnstpSeCd
|
||||
CALL cnstpSeMngService.save(CnstpSeMngSaveRequestDto)
|
||||
CALL cnstpSeMngService.findAll
|
||||
CLOSE 다이얼로그
|
||||
END ON
|
||||
ON [취소] / [닫기] DO
|
||||
CLOSE 다이얼로그
|
||||
END ON
|
||||
END ON
|
||||
|
||||
// 영역 A: 공종코드 수정
|
||||
ON 영역 A 행 [수정] CLICK DO
|
||||
CALL cnstpSeMngService.findById(cnstpSeCd)
|
||||
OPEN 공종코드 수정 다이얼로그 (기존 값 초기화)
|
||||
ON [저장] DO
|
||||
CALL cnstpSeMngService.update(cnstpSeCd, CnstpSeMngSaveRequestDto)
|
||||
CALL cnstpSeMngService.findAll
|
||||
CLOSE 다이얼로그
|
||||
END ON
|
||||
END ON
|
||||
|
||||
// 영역 A: 공종코드 삭제
|
||||
ON 영역 A 행 [삭제] CLICK DO
|
||||
CONFIRM "선택한 공종코드를 삭제하시겠습니까?"
|
||||
CALL cnstpSeMngService.delete(cnstpSeCd)
|
||||
CALL cnstpSeMngService.findAll
|
||||
END ON
|
||||
|
||||
// 영역 B: 회계코드 등록
|
||||
ON 영역 B [등록] 버튼 CLICK DO
|
||||
OPEN 회계코드 등록 다이얼로그
|
||||
ON [저장] DO
|
||||
VALIDATE acntgSeCd, acntgSeCdNm
|
||||
CALL acntgSeMngService.save(AcntgSeMngSaveRequestDto)
|
||||
CALL acntgSeMngService.findAll
|
||||
CLOSE 다이얼로그
|
||||
END ON
|
||||
ON [취소] / [닫기] DO
|
||||
CLOSE 다이얼로그
|
||||
END ON
|
||||
END ON
|
||||
|
||||
// 영역 B: 회계코드 수정
|
||||
ON 영역 B 행 [수정] CLICK DO
|
||||
CALL acntgSeMngService.findById(acntgSeCd)
|
||||
OPEN 회계코드 수정 다이얼로그 (기존 값 초기화)
|
||||
ON [저장] DO
|
||||
CALL acntgSeMngService.update(acntgSeCd, AcntgSeMngSaveRequestDto)
|
||||
CALL acntgSeMngService.findAll
|
||||
CLOSE 다이얼로그
|
||||
END ON
|
||||
END ON
|
||||
|
||||
// 영역 B: 회계코드 삭제
|
||||
ON 영역 B 행 [삭제] CLICK DO
|
||||
CONFIRM "선택한 회계코드를 삭제하시겠습니까?"
|
||||
CALL acntgSeMngService.delete(acntgSeCd)
|
||||
CALL acntgSeMngService.findAll
|
||||
END ON
|
||||
|
||||
END
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 영역 A: 공종코드 관리
|
||||
|
||||
백엔드 **공종구분코드**(`CnstpSeMng`) CRUD. 계층 구조(`upCnstpCd`, `cdLvNo`)를 지원하나, 본 화면은 **평면 목록**으로 표시.
|
||||
|
||||
### 4-1. 헤더 / 툴바
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ----------- | ------------------------------------- |
|
||||
| 섹션 타이틀 | 공종코드 관리 |
|
||||
| 우측 버튼 | **[등록]** — 공종코드 등록 다이얼로그 |
|
||||
|
||||
### 4-2. 그리드 컬럼
|
||||
|
||||
| 컬럼명 | Field | 타입 | 비고 |
|
||||
| -------- | ------------- | ------ | ------------------------ |
|
||||
| 코드 | `cnstpSeCd` | string | PK, 최대 10자 |
|
||||
| 코드명 | `cnstpSeCdNm` | string | 최대 100자 |
|
||||
| 설명 | `expln` | string | 최대 1000자, 선택 |
|
||||
| 사용여부 | `useYn` | string | `Y`→사용, `N`→미사용 |
|
||||
| 관리 | — | — | 행별 **[수정] · [삭제]** |
|
||||
|
||||
- 행 클릭 시 별도 상세 이동 없음. 수정은 **[수정]** 버튼으로 다이얼로그 진입.
|
||||
- 페이지네이션: Spring `Pageable` (`page`, `size`, `sort`) — **UI 노출**. 기본 `size: 10`, `@common/components` 공통 페이지네이션 컴포넌트 사용.
|
||||
|
||||
### 4-3. 버튼
|
||||
|
||||
| 버튼명 | 위치 | 동작 |
|
||||
| ------ | -------------- | ---------------------------------------- |
|
||||
| 등록 | 섹션 우측 상단 | 공종코드 등록 다이얼로그 오픈 |
|
||||
| 수정 | 행별 | 상세 조회 후 수정 다이얼로그 (값 초기화) |
|
||||
| 삭제 | 행별 | 확인 후 삭제, 목록 갱신 |
|
||||
|
||||
### 4-4. 다이얼로그: 공종코드 등록 / 수정
|
||||
|
||||
다이얼로그 입력 필드는 **코드 · 코드명 · 설명 · 사용여부** 4개만 노출한다.
|
||||
|
||||
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
|
||||
| -------- | --------- | ---- | ------ | ----------------- | ------------------------------- |
|
||||
| 코드 | Input | Y | — | `cnstpSeCd` | 등록 시 입력, 수정 시 읽기 전용 |
|
||||
| 코드명 | Input | N | — | `cnstpSeCdNm` | 최대 100자 |
|
||||
| 설명 | Input | N | — | `expln` | 최대 1000자 |
|
||||
| 사용여부 | Radio | N | Y | `useYn` | `Y`→사용, `N`→미사용 |
|
||||
|
||||
> UI에 노출하지 않는 API 필드(`upCnstpCd`, `cdLvNo`): 등록 시 `cdLvNo: 1` 등 기본값 전송, 수정 시 `findById` 응답값 유지.
|
||||
|
||||
| 버튼명 | 동작 |
|
||||
| ------ | ------------------------------------------------ |
|
||||
| 저장 | POST(등록) 또는 PUT(수정) 후 `findAll` 목록 갱신 |
|
||||
| 취소 | 다이얼로그 닫기, 입력값 폐기 |
|
||||
| 닫기 | 다이얼로그 닫기 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 영역 B: 회계코드 관리
|
||||
|
||||
백엔드 **회계구분관리**(`AcntgSeMng`) CRUD. 영역 A와 동일한 **평면 목록** UI 패턴을 따른다.
|
||||
|
||||
### 5-1. 헤더 / 툴바
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ----------- | ------------------------------------- |
|
||||
| 섹션 타이틀 | 회계코드 관리 |
|
||||
| 우측 버튼 | **[등록]** — 회계코드 등록 다이얼로그 |
|
||||
|
||||
### 5-2. 그리드 컬럼
|
||||
|
||||
| 컬럼명 | Field | 타입 | 비고 |
|
||||
| -------- | ------------- | ------ | ------------------------ |
|
||||
| 코드 | `acntgSeCd` | string | PK, 최대 10자 |
|
||||
| 코드명 | `acntgSeCdNm` | string | 최대 100자 |
|
||||
| 설명 | `expln` | string | 최대 1000자, 선택 |
|
||||
| 사용여부 | `useYn` | string | `Y`→사용, `N`→미사용 |
|
||||
| 관리 | — | — | 행별 **[수정] · [삭제]** |
|
||||
|
||||
- 페이지네이션: 영역 A와 동일 — **UI 노출**, 기본 `size: 10`.
|
||||
|
||||
### 5-3. 버튼
|
||||
|
||||
| 버튼명 | 위치 | 동작 |
|
||||
| ------ | -------------- | ---------------------------------------- |
|
||||
| 등록 | 섹션 우측 상단 | 회계코드 등록 다이얼로그 오픈 |
|
||||
| 수정 | 행별 | 상세 조회 후 수정 다이얼로그 (값 초기화) |
|
||||
| 삭제 | 행별 | 확인 후 삭제, 목록 갱신 |
|
||||
|
||||
### 5-4. 다이얼로그: 회계코드 등록 / 수정
|
||||
|
||||
다이얼로그 입력 필드는 **코드 · 코드명 · 설명 · 사용여부** 4개만 노출한다. (영역 A와 동일 패턴)
|
||||
|
||||
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
|
||||
| -------- | --------- | ---- | ------ | ----------------- | ------------------------------- |
|
||||
| 코드 | Input | Y | — | `acntgSeCd` | 등록 시 입력, 수정 시 읽기 전용 |
|
||||
| 코드명 | Input | Y | — | `acntgSeCdNm` | 최대 100자 |
|
||||
| 설명 | Input | N | — | `expln` | 최대 1000자 |
|
||||
| 사용여부 | Radio | N | Y | `useYn` | `Y`→사용, `N`→미사용 |
|
||||
|
||||
> UI에 노출하지 않는 API 필드(`upAcntgSeCd`, `cdLvNo`): 등록 시 `cdLvNo: 1` 등 기본값 전송, 수정 시 `findById` 응답값 유지.
|
||||
|
||||
| 버튼명 | 동작 |
|
||||
| ------ | ------------------------------------------------ |
|
||||
| 저장 | POST(등록) 또는 PUT(수정) 후 `findAll` 목록 갱신 |
|
||||
| 취소 | 다이얼로그 닫기, 입력값 폐기 |
|
||||
| 닫기 | 다이얼로그 닫기 |
|
||||
|
||||
---
|
||||
|
||||
## 6. API/DTO 명세
|
||||
|
||||
### 6-1. 공통
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ----------- | ---------------------------------------------------------------------------------- |
|
||||
| 서비스 | `as-service` |
|
||||
| Base prefix | `/as-service/api/v1` (`ENDPOINT.AS_COMMON_SERVICE`) |
|
||||
| 인증 | JWT (`Authorization` 헤더), `X-Site-Id`, `token-id` |
|
||||
| 페이징 | Spring `Pageable`: `page`, `size`, `sort` → `PageResponse<T>` |
|
||||
| 엔티티 | `@/entities/cnstp` (`cnstpSeMngService`), `@/entities/acntg` (`acntgSeMngService`) |
|
||||
|
||||
**부가 API (본 화면 목록 외 참조용)**
|
||||
|
||||
| 리소스 | Method | URL | Response | 용도 |
|
||||
| ------------ | ------ | --------------------- | --------------------- | --------------------------------- |
|
||||
| 공종구분코드 | GET | `/cnstp-se/list/tree` | `CnstpSeMngTreeDto[]` | 계층 트리 (타 화면·상위코드 선택) |
|
||||
| 회계구분관리 | GET | `/acntg-se/list/tree` | `AcntgSeMngTreeDto[]` | 계층 트리 (타 화면·상위코드 선택) |
|
||||
|
||||
### 6-2. 공종구분코드 (영역 A) — `cnstp-se`
|
||||
|
||||
| 기능 | Method | URL | Request / Query | Response | 클라이언트 |
|
||||
| --------- | ------ | ----------------------- | -------------------------------- | ----------------------------- | ---------------------------- |
|
||||
| 목록 조회 | GET | `/cnstp-se` | `CnstpSeMngSearchDto` + Pageable | `Page<CnstpSeMngResponseDto>` | `cnstpSeMngService.findAll` |
|
||||
| 트리 조회 | GET | `/cnstp-se/list/tree` | — | `CnstpSeMngTreeDto[]` | `cnstpSeMngService.findTree` |
|
||||
| 상세 조회 | GET | `/cnstp-se/{cnstpSeCd}` | — | `CnstpSeMngResponseDto` | `cnstpSeMngService.findById` |
|
||||
| 등록 | POST | `/cnstp-se` | `CnstpSeMngSaveRequestDto` | `string` (cnstpSeCd) | `cnstpSeMngService.save` |
|
||||
| 수정 | PUT | `/cnstp-se/{cnstpSeCd}` | `CnstpSeMngSaveRequestDto` | `string` (cnstpSeCd) | `cnstpSeMngService.update` |
|
||||
| 삭제 | DELETE | `/cnstp-se/{cnstpSeCd}` | — | `string` (cnstpSeCd) | `cnstpSeMngService.delete` |
|
||||
|
||||
**CnstpSeMngResponseDto**
|
||||
|
||||
| Field | 타입 | 설명 |
|
||||
| ------------- | ------- | ------------------ |
|
||||
| `cnstpSeCd` | string | 공종구분코드 |
|
||||
| `cnstpSeCdNm` | string | 공종구분코드명 |
|
||||
| `upCnstpCd` | string? | 상위공종코드 |
|
||||
| `cdLvNo` | number | 코드레벨번호 |
|
||||
| `useYn` | string | 사용여부 |
|
||||
| `expln` | string? | 설명 (최대 1000자) |
|
||||
| `rgtr` | string? | 등록자 UUID |
|
||||
| `rgtrNm` | string? | 등록자명 |
|
||||
| `mdfr` | string? | 수정자 UUID |
|
||||
| `regDt` | string? | 등록일시 |
|
||||
| `mdfcnDt` | string? | 수정일시 |
|
||||
|
||||
**CnstpSeMngSaveRequestDto**: `cnstpSeCd`, `cnstpSeCdNm?`, `upCnstpCd?`, `cdLvNo?`, `useYn?`, `expln?`
|
||||
|
||||
**CnstpSeMngSearchDto**: `cnstpSeCd?`, `cnstpSeCdNm?`, `useYn?` + `page`, `size`, `sort`
|
||||
|
||||
**React Query hooks**: `useCnstpSeMngPage`, `useCnstpSeMngTree`, `useCnstpSeMng`, `useSaveCnstpSeMng`, `useUpdateCnstpSeMng`, `useDeleteCnstpSeMng`
|
||||
|
||||
### 6-3. 회계구분관리 (영역 B) — `acntg-se`
|
||||
|
||||
| 기능 | Method | URL | Request / Query | Response | 클라이언트 |
|
||||
| --------- | ------ | ----------------------- | -------------------------------- | ----------------------------- | ---------------------------- |
|
||||
| 목록 조회 | GET | `/acntg-se` | `AcntgSeMngSearchDto` + Pageable | `Page<AcntgSeMngResponseDto>` | `acntgSeMngService.findAll` |
|
||||
| 트리 조회 | GET | `/acntg-se/list/tree` | — | `AcntgSeMngTreeDto[]` | `acntgSeMngService.findTree` |
|
||||
| 상세 조회 | GET | `/acntg-se/{acntgSeCd}` | — | `AcntgSeMngResponseDto` | `acntgSeMngService.findById` |
|
||||
| 등록 | POST | `/acntg-se` | `AcntgSeMngSaveRequestDto` | `string` (acntgSeCd) | `acntgSeMngService.save` |
|
||||
| 수정 | PUT | `/acntg-se/{acntgSeCd}` | `AcntgSeMngSaveRequestDto` | `string` (acntgSeCd) | `acntgSeMngService.update` |
|
||||
| 삭제 | DELETE | `/acntg-se/{acntgSeCd}` | — | `string` (acntgSeCd) | `acntgSeMngService.delete` |
|
||||
|
||||
**AcntgSeMngResponseDto**
|
||||
|
||||
| Field | 타입 | 설명 |
|
||||
| ------------- | ------- | ------------------ |
|
||||
| `acntgSeCd` | string | 회계구분코드 |
|
||||
| `acntgSeCdNm` | string | 회계구분코드명 |
|
||||
| `upAcntgSeCd` | string? | 상위회계구분코드 |
|
||||
| `cdLvNo` | number | 코드레벨번호 |
|
||||
| `useYn` | string | 사용여부 |
|
||||
| `expln` | string? | 설명 (최대 1000자) |
|
||||
| `rgtr` | string? | 등록자 UUID |
|
||||
| `rgtrNm` | string? | 등록자명 |
|
||||
| `mdfr` | string? | 수정자 UUID |
|
||||
| `regDt` | string? | 등록일시 |
|
||||
| `mdfcnDt` | string? | 수정일시 |
|
||||
|
||||
**AcntgSeMngSaveRequestDto**: `acntgSeCd`, `acntgSeCdNm`, `upAcntgSeCd?`, `cdLvNo`, `useYn?`, `expln?`
|
||||
|
||||
**AcntgSeMngSearchDto**: `acntgSeCd?`, `acntgSeCdNm?`, `useYn?` + `page`, `size`, `sort`
|
||||
|
||||
**React Query hooks**: `useAcntgSeMngPage`, `useAcntgSeMngTree`, `useAcntgSeMng`, `useSaveAcntgSeMng`, `useUpdateAcntgSeMng`, `useDeleteAcntgSeMng`
|
||||
|
||||
---
|
||||
|
||||
## 7. UI 구현 가이드
|
||||
|
||||
### 7-1. 기술 스택·원칙
|
||||
|
||||
- Next.js App Router, React Query (`@tanstack/react-query`)
|
||||
- `@common/components` — `Card`, `Button`, `Dialog`, `DataTable` / `useDataTable`
|
||||
- `@tanstack/react-table` — 컬럼 정의는 `model/*-table-columns.tsx` 분리
|
||||
- 기존 entity 레이어 재사용: `@/entities/cnstp`, `@/entities/acntg` (service·queries·interface 구현 완료)
|
||||
- 좌·우 2열 레이아웃: `grid grid-cols-1 lg:grid-cols-2 gap-4` (모바일 1열 스택)
|
||||
- 삭제 전 `confirm` / Toast 피드백 — 프로젝트 공통 패턴 준수
|
||||
- 목록 하단 **페이지네이션 UI 노출** — 영역 A·B 각각 독립 page state
|
||||
- **검색 필터 UI 미제공** — `findAll` 호출 시 `page`, `size`만 전달
|
||||
- **등록/수정 다이얼로그** — 코드·코드명·설명은 `Input`, 사용여부는 `Radio` (4필드만 노출)
|
||||
- 상위 코드 삭제 시 **하위 코드 연쇄 삭제 UI·로직 미구현** — 단건 삭제만 수행
|
||||
|
||||
### 7-2. 모듈·파일 배치
|
||||
|
||||
| 구분 | 경로 (제안) |
|
||||
| ----------------- | -------------------------------------------------------------------- |
|
||||
| 라우트 | `apps/as/src/app/(authenticated)/site/code-management/page.tsx` |
|
||||
| 페이지 컴포넌트 | `apps/as/src/pages/code-management/ui/code-management-page.tsx` |
|
||||
| 공종 그리드 컬럼 | `apps/as/src/pages/code-management/model/cnstp-se-table-columns.tsx` |
|
||||
| 회계 그리드 컬럼 | `apps/as/src/pages/code-management/model/acntg-se-table-columns.tsx` |
|
||||
| 공종 등록/수정 폼 | `apps/as/src/pages/code-management/ui/cnstp-se-form-dialog.tsx` |
|
||||
| 회계 등록/수정 폼 | `apps/as/src/pages/code-management/ui/acntg-se-form-dialog.tsx` |
|
||||
| API·타입 (기존) | `@/entities/cnstp`, `@/entities/acntg` |
|
||||
|
||||
### 7-3. 영역별 UI 매핑
|
||||
|
||||
| 영역 | UI 컴포넌트 | 데이터 소스 | 비고 |
|
||||
| ------------ | ------------------------------------- | ------------------------------------------- | ------------------------------ |
|
||||
| A | `CnstpSeSection` + Table + Pagination | `useCnstpSeMngPage({ page, size })` | 독립 page state·mutation |
|
||||
| B | `AcntgSeSection` + Table + Pagination | `useAcntgSeMngPage({ page, size })` | A와 상호 의존 없음 |
|
||||
| A 다이얼로그 | `CnstpSeFormDialog` | `useCnstpSeMng`, `useSave/UpdateCnstpSeMng` | Input 4필드 + Radio 사용여부 |
|
||||
| B 다이얼로그 | `AcntgSeFormDialog` | `useAcntgSeMng`, `useSave/UpdateAcntgSeMng` | Input 4필드 + Radio 사용여부 |
|
||||
|
||||
### 7-4. 다이얼로그·라우팅
|
||||
|
||||
| 다이얼로그 | 트리거 | 라우팅 |
|
||||
| ------------- | ---------------- | -------------------- |
|
||||
| 공종코드 등록 | 영역 A [등록] | URL 변경 없음 (모달) |
|
||||
| 공종코드 수정 | 영역 A 행 [수정] | URL 변경 없음 (모달) |
|
||||
| 회계코드 등록 | 영역 B [등록] | URL 변경 없음 (모달) |
|
||||
| 회계코드 수정 | 영역 B 행 [수정] | URL 변경 없음 (모달) |
|
||||
|
||||
### 7-5. 데이터 흐름 (구현 관점)
|
||||
|
||||
```
|
||||
BEGIN
|
||||
|
||||
// 페이지 마운트 — 검색 조건 없이 page·size만 전달
|
||||
const cnstpQuery = useCnstpSeMngPage({ page: 0, size: 20 })
|
||||
const acntgQuery = useAcntgSeMngPage({ page: 0, size: 20 })
|
||||
|
||||
DISPLAY CodeManagementPage
|
||||
├─ CnstpSeSection(data=cnstpQuery.data, pagination)
|
||||
└─ AcntgSeSection(data=acntgQuery.data, pagination)
|
||||
|
||||
// 페이지 변경
|
||||
ON pagination page/size CHANGE DO
|
||||
refetch cnstpQuery | acntgQuery with new page, size
|
||||
END ON
|
||||
|
||||
// mutation 성공 시
|
||||
ON save/update/delete SUCCESS DO
|
||||
invalidateQueries cnstp-se | acntg-se baseKey
|
||||
TOAST "저장되었습니다." | "삭제되었습니다."
|
||||
END ON
|
||||
|
||||
END
|
||||
```
|
||||
@@ -0,0 +1,284 @@
|
||||
# 화면설계서: 필수첨부파일관리
|
||||
|
||||
## 1. 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ---------- | ---------------------------------------------- |
|
||||
| 버전 | 0.1 |
|
||||
| 프로세스명 | UI설계 |
|
||||
| 화면명 | 필수첨부파일관리 |
|
||||
| 화면 유형 | [v] 조회 [v] 입력 [v] 수정 [v] 삭제 [ ] 출력 |
|
||||
| 메뉴 경로 | 디지털행정지원 > 사이트관리 > 필수첨부파일관리 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 화면 목적
|
||||
|
||||
문서(업무) 구분별로 **필수 첨부파일** 항목을 등록·조회·수정·삭제한다. 상단 검색 영역에서 **문서구분**으로 목록을 필터링하고, **등록** 또는 행별 **수정**·**삭제**로 마스터 데이터를 유지한다. 등록·수정 시 **필수첨부파일아이디**는 클라이언트에서 입력(또는 생성)하여 요청 본문에 포함한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 화면 구성
|
||||
|
||||
### 3-1. 전체 레이아웃
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 페이지 타이틀: 필수첨부파일관리 브레드크럼: 디지털행정지원 > … > │
|
||||
├──────────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ [영역 A] 검색 / 액션 │
|
||||
│ ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 문서구분 [전체 ▼] [조회] [등록] │ │
|
||||
│ └────────────────────────────────────────────────────────────────────────────────────────────────┘ │
|
||||
├──────────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ [영역 B] 필수첨부파일 목록 │
|
||||
│ ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 필수첨부파일아이디 | 문서구분 | 필수첨부파일명 | 필수첨부여부 | 비고 | 등록일시 | 수정일시 | 관리 │
|
||||
│ ├────────────────────────────────────────────────────────────────────────────────────────────────┤ │
|
||||
│ │ EAF-0001 | 설치인가 | 사업계획서 | 예 | 서명본 첨부 | 2026-04-22 10:10:00 | — | [수정][삭제] │ │
|
||||
│ │ EAF-0002 | 기본계획 | 변경사유서 | 예 | — | 2026-04-22 11:00:00 | — | [수정][삭제] │ │
|
||||
│ └────────────────────────────────────────────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
(모달) [다이얼로그 C] 필수첨부파일 등록 / 수정
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ 필수첨부파일 등록 [닫기] │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ 필수첨부파일아이디(자동발급) [EAF-0004] │
|
||||
│ 문서구분 [설치인가 ▼] │
|
||||
│ 필수첨부파일명 [예: 사업계획서] │
|
||||
│ 필수첨부여부 [ ] 예 [ ] 아니오 │
|
||||
│ 비고 [비고를 입력하세요] │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ [취소] [저장] │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3-2. 화면 데이터 흐름
|
||||
|
||||
```
|
||||
BEGIN
|
||||
|
||||
// 최초 진입
|
||||
CALL 필수첨부파일 목록 조회 (findPage)
|
||||
DISPLAY 필수첨부파일 그리드 (영역 B)
|
||||
|
||||
// 영역 A: 검색
|
||||
ON [조회] 버튼 CLICK DO
|
||||
CALL 필수첨부파일 목록 조회 (문서구분 = 선택값, findPage)
|
||||
DISPLAY 필수첨부파일 그리드 (영역 B)
|
||||
END ON
|
||||
|
||||
// 영역 A: 등록
|
||||
ON [등록] 버튼 CLICK DO
|
||||
OPEN 필수첨부파일 등록 다이얼로그 (다이얼로그 C)
|
||||
ON [저장] DO
|
||||
CONFIRM "등록하시겠습니까?"
|
||||
VALIDATE 문서구분, 필수첨부파일명, 필수첨부여부
|
||||
CALL 필수첨부파일 등록 (create)
|
||||
CALL 필수첨부파일 목록 조회
|
||||
CLOSE 다이얼로그
|
||||
TOAST "저장되었습니다."
|
||||
END ON
|
||||
ON [취소] / [닫기] DO
|
||||
CLOSE 다이얼로그
|
||||
END ON
|
||||
END ON
|
||||
|
||||
// 영역 B: 수정
|
||||
ON 행 [수정] CLICK DO
|
||||
OPEN 필수첨부파일 수정 다이얼로그 (기존 값 초기화)
|
||||
ON [저장] DO
|
||||
VALIDATE 문서구분, 필수첨부파일명, 필수첨부여부
|
||||
CALL 필수첨부파일 수정 (update)
|
||||
CALL 필수첨부파일 목록 조회
|
||||
CLOSE 다이얼로그
|
||||
END ON
|
||||
END ON
|
||||
|
||||
// 영역 B: 삭제
|
||||
ON 행 [삭제] CLICK DO
|
||||
CONFIRM "삭제하시겠습니까?"
|
||||
CALL 필수첨부파일 삭제 (remove)
|
||||
CALL 필수첨부파일 목록 조회
|
||||
END ON
|
||||
|
||||
END
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 영역 A: 검색 / 액션
|
||||
|
||||
문서구분 조건으로 목록을 조회하고, 신규 등록 다이얼로그를 연다.
|
||||
|
||||
### 4-1. 검색 필드
|
||||
|
||||
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
|
||||
| -------- | ---------- | ---- | ------ | ----------------- | ------------------------------ |
|
||||
| 문서구분 | CodeSelect | N | 전체 | `docSeComCd?` | "전체" 선택 시 파라미터 미전송 |
|
||||
|
||||
### 4-2. 버튼
|
||||
|
||||
| 버튼명 | 위치 | 동작 |
|
||||
| ------ | -------------- | ----------------------------------------------- |
|
||||
| 조회 | 검색 필드 우측 | 현재 검색 조건으로 영역 B 목록 갱신 |
|
||||
| 등록 | 영역 우측 끝 | 필수첨부파일 등록 다이얼로그(다이얼로그 C) 오픈 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 영역 B: 필수첨부파일 목록
|
||||
|
||||
필수 첨부파일 마스터를 테이블로 표시한다. 페이지네이션 UI 적용.
|
||||
|
||||
### 5-1. 헤더
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ----------- | ----------------- |
|
||||
| 섹션 타이틀 | 필수첨부파일 목록 |
|
||||
|
||||
### 5-2. 그리드 컬럼
|
||||
|
||||
| 컬럼명 | Field | 타입 | 비고 |
|
||||
| ------------------ | ------------------- | ------ | ------------------------------------- |
|
||||
| 필수첨부파일아이디 | `esntlAtchFileId` | string | 예: `EAF-0001`, 클라이언트 생성/입력 |
|
||||
| 문서구분 | `docSeComCd` | string | 코드명 표시 — 공통코드 (TBD 상위코드) |
|
||||
| 필수첨부파일명 | `esntlAtchFileNm` | string | |
|
||||
| 필수첨부여부 | `esntlAtchYn` | string | `Y`→예, `N`→아니오 |
|
||||
| 비고 | `esntlAtchFileRmrk` | string | 없으면 `—` 표시 |
|
||||
| 등록일시 | `regDt` | string | `yyyy-MM-dd HH:mm:ss` |
|
||||
| 수정일시 | `mdfcnDt` | string | 미수정 시 빈 값 또는 `—` |
|
||||
| 관리 | — | — | 행별 **[수정] · [삭제]** |
|
||||
|
||||
- 행 클릭 시 별도 상세 화면 없음. 편집은 **[수정]** 버튼으로 다이얼로그 C를 연다.
|
||||
|
||||
### 5-3. 버튼
|
||||
|
||||
| 버튼명 | 위치 | 동작 |
|
||||
| ------ | ---- | -------------------------------------- |
|
||||
| 수정 | 행별 | 필수첨부파일 수정 다이얼로그 (기존 값) |
|
||||
| 삭제 | 행별 | 확인 후 삭제 API 호출 → 목록 갱신 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 다이얼로그 C: 필수첨부파일 등록 / 수정
|
||||
|
||||
등록·수정 공용 폼 다이얼로그. 등록 시 제목 **「필수첨부파일 등록」**, 수정 시 **「필수첨부파일 수정」**.
|
||||
|
||||
### 6-1. 폼 필드
|
||||
|
||||
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
|
||||
| ------------------ | ---------- | ---- | ------ | ------------------- | -------------------------------- |
|
||||
| 필수첨부파일아이디 | (Readonly) | Y | — | `esntlAtchFileId` | 등록 시 미노출 |
|
||||
| 문서구분 | CodeSelect | Y | — | `docSeComCd` | 예: 설치인가, 기본계획 |
|
||||
| 필수첨부파일명 | Text | Y | — | `esntlAtchFileNm` | placeholder: "예: 사업계획서" |
|
||||
| 필수첨부여부 | RadioGroup | Y | Y | `esntlAtchYn` | Y/N, UI 표시: 예/아니오 |
|
||||
| 비고 | Textarea | N | — | `esntlAtchFileRmrk` | placeholder: "비고를 입력하세요" |
|
||||
|
||||
### 6-2. 버튼
|
||||
|
||||
| 버튼명 | 동작 |
|
||||
| ------ | -------------------------------------- |
|
||||
| 저장 | POST(등록) 또는 PUT(수정) 후 목록 갱신 |
|
||||
| 취소 | 다이얼로그 닫기, 입력값 폐기 |
|
||||
| 닫기 | 다이얼로그 닫기 (헤더 우측) |
|
||||
|
||||
---
|
||||
|
||||
## 7. API/DTO 명세
|
||||
|
||||
### 7-1. 공통
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ----------- | ----------------------------------------------------- |
|
||||
| 서비스 | AS Common Service |
|
||||
| Base prefix | `/as-service/api/v1/esntl-atch-file` |
|
||||
| 인증 | JWT (`Authorization`), `X-Site-Id` 등 |
|
||||
| 페이징 | Spring `Pageable` 사용 (`page`, `size`, `sort`) |
|
||||
| 엔티티 | `@/entities/esntl` (`esntl-service.ts`, `queries.ts`) |
|
||||
|
||||
### 7-2. 필수첨부파일 API
|
||||
|
||||
| 기능 | Method | URL | Request DTO | Response DTO | API (클라이언트 함수명) |
|
||||
| --------- | ------ | -------------------- | ----------------------------- | ----------------------------------- | ----------------------- |
|
||||
| 트리 조회 | GET | `/list/tree` | Query: `docSeComCd`(필수) | `EsntlAtchFileMngTreeResponseDto[]` | `findTree` |
|
||||
| 목록 조회 | GET | `/` | Query: Search + Pageable | `PageResponse<EsntlAtchFileMng>` | `findPage` |
|
||||
| 단건 조회 | GET | `/{esntlAtchFileId}` | Path: `esntlAtchFileId` | `EsntlAtchFileMng` | `findById` |
|
||||
| 등록 | POST | `/` | `EsntlAtchFileMngSaveRequest` | `string` (생성된 ID) | `create` |
|
||||
| 수정 | PUT | `/{esntlAtchFileId}` | `EsntlAtchFileMngSaveRequest` | `string` (수정된 ID) | `update` |
|
||||
| 삭제 | DELETE | `/{esntlAtchFileId}` | Path: `esntlAtchFileId` | `string` (삭제된 ID) | `remove` |
|
||||
|
||||
**응답 DTO**
|
||||
|
||||
| Field | 타입 | 설명 |
|
||||
| ------------------- | ------- | -------------------------------- |
|
||||
| `esntlAtchFileId` | string | 필수첨부파일아이디 |
|
||||
| `docSeComCd` | string | 문서구분공통코드 |
|
||||
| `esntlAtchFileNm` | string | 필수첨부파일명 |
|
||||
| `esntlAtchYn` | string | 필수첨부여부 (Y/N) |
|
||||
| `esntlAtchFileRmrk` | string? | 필수첨부파일비고 |
|
||||
| `rgtr` | string? | 등록자 UUID |
|
||||
| `rgtrNm` | string? | 등록자명 |
|
||||
| `mdfr` | string? | 수정자 UUID |
|
||||
| `regDt` | string? | 등록일시 (`yyyy-MM-dd HH:mm:ss`) |
|
||||
| `mdfcnDt` | string? | 수정일시 |
|
||||
|
||||
**저장 Request**
|
||||
|
||||
| Field | 필수 | 설명 |
|
||||
| ------------------- | ---- | ------------------ |
|
||||
| `esntlAtchFileId` | Y | 필수첨부파일아이디 |
|
||||
| `docSeComCd` | N | 문서구분 |
|
||||
| `esntlAtchFileNm` | N | 필수첨부파일명 |
|
||||
| `esntlAtchYn` | N | 필수첨부여부 |
|
||||
| `esntlAtchFileRmrk` | N | 비고 |
|
||||
|
||||
**검색 Query**
|
||||
|
||||
| Field | 필수 | 설명 |
|
||||
| ----------------- | ---- | ----------------------------- |
|
||||
| `docSeComCd` | N | 문서구분 (전체 시 미전송) |
|
||||
| `esntlAtchFileNm` | N | 필수첨부파일명 부분/일치 검색 |
|
||||
| `esntlAtchYn` | N | 필수첨부여부 (`Y`/`N`) |
|
||||
| `page` | N | 페이지 번호 (0-base) |
|
||||
| `size` | N | 페이지 크기 (기본값: 100) |
|
||||
| `sort` | N | 정렬 조건 (예: `regDt,desc`) |
|
||||
|
||||
### 7-3. 공통코드
|
||||
|
||||
| UI 필드 | 코드 그룹 | 예시 값 |
|
||||
| ------------ | --------- | ------------------ |
|
||||
| 문서구분 | ASDOCSECD | 설치인가, 기본계획 |
|
||||
| 필수첨부여부 | 고정 | Y(예), N(아니오) |
|
||||
|
||||
---
|
||||
|
||||
## 8. UI 구현 가이드
|
||||
|
||||
### 8-1. 기술 스택·원칙
|
||||
|
||||
- **공통 컴포넌트**: `@common/components` (`Dialog`, `Button`, `Input`, `Select`, `Textarea`, `Label` 등)
|
||||
- **테이블**: `@tanstack/react-table` + `useDataTable`
|
||||
- **폼**: `react-hook-form`
|
||||
- **데이터**: React Query — `@/entities/esntl/api/queries.ts` 훅 사용
|
||||
- **공통코드**: `@/entities/code` — `CodeSelect`, `findCodeLabel`
|
||||
- **확인**: `@frontend-opensource/use-react-hooks` — `useConfirm` (등록·삭제 확인)
|
||||
|
||||
### 8-2. 모듈·파일 배치
|
||||
|
||||
| 구분 | 경로 |
|
||||
| ------ | ---------------------------------------------------------------------------------------------------- |
|
||||
| 라우트 | `apps/as/app/(with-base-layout)/esntl-atch-file-mng/page.tsx` |
|
||||
| 페이지 | `apps/as/src/pages/esntl-atch-file-mng/` (Public API: page만 export) |
|
||||
| UI | `.../ui/esntl-atch-file-mng-page.tsx`, `esntl-atch-file-list.tsx`, `esntl-atch-file-form-dialog.tsx` |
|
||||
| model | `.../model/esntl-atch-file-columns.tsx`, `esntl-atch-file-search-params.ts` |
|
||||
| 엔티티 | `apps/as/src/entities/esntl/` (`esntl-service.ts`, `queries.ts`, `esntl-interface.ts`) |
|
||||
| 문서 | `apps/as/docs/essential-attachment-file-management/SPEC.md` |
|
||||
|
||||
### 8-3. 데이터 흐름(요약)
|
||||
|
||||
- **진입**: 검색 params 기본값(`docSeComCd` = 전체) → 목록 query → 영역 B DataTable.
|
||||
- **조회**: [조회] 클릭 시 search state 반영 → 목록 query refetch.
|
||||
- **등록**: [등록] → Dialog `mode=create` → [저장] → POST → 목록 invalidate.
|
||||
- **수정**: [수정] → Dialog `mode=edit` + row 초기값 → [저장] → PUT → 목록 invalidate.
|
||||
- **삭제**: confirm → DELETE → 목록 invalidate.
|
||||
@@ -0,0 +1,370 @@
|
||||
# 화면설계서: 검토인력관리
|
||||
|
||||
## 1. 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ---------- | -------------------------------------------- |
|
||||
| 버전 | 0.2 |
|
||||
| 프로세스명 | UI설계 |
|
||||
| 화면명 | 검토인력관리 |
|
||||
| 화면 유형 | [v] 조회 [v] 입력 [ ] 수정 [v] 삭제 [ ] 출력 |
|
||||
| 메뉴 경로 | 디지털행정지원 > 사이트관리 > 검토인력관리 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 화면 목적
|
||||
|
||||
조직도에서 **한 명의 사용자**를 선택한 뒤, 결재 단계(접수·검토·승인·최종승인)별 **인원 배치** 영역에 즉시 등록한다. 등록·목록 갱신은 별도 저장 버튼 없이 **[선택 인원 배치]** 클릭 시 `rvw-mnpw` API로 바로 반영한다. 좌측 조직도는 CMS 조직·사용자 트리 API를, 우측 배치 목록은 검토인력관리 API를 사용한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 화면 구성
|
||||
|
||||
### 3-1. 전체 레이아웃
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 페이지 타이틀: 검토인력관리 브레드크럼: 디지털행정지원 > 사이트관리 > 검토인력관리 │
|
||||
├─────────────────────────────────┬──────────────────────────────────────────────────────────────────┤
|
||||
│ [영역 A] 조직도 │ [영역 B] 인원 배치 (결재구분별 4블록, 세로 스택) │
|
||||
│ 제목: 조직도 │ │
|
||||
│ [A-1] 검색: │ ┌─ [B-1] 접수 ───────────── count ─ [선택 인원 배치] ─┐ │
|
||||
│ [성명·부서 검색] [조회][초기화]│ │ (배치 목록 또는 빈 상태 문구) │ │
|
||||
│ 선택 안내: │ └──────────────────────────────────────────────────────┘ │
|
||||
│ "선택된 인원: {이름} │ ┌─ [B-2] 검토 ───────────── count ─ [선택 인원 배치] ──┐ │
|
||||
│ ({부서} / {직급})" │ └──────────────────────────────────────────────────────┘ │
|
||||
│ [A-2] 조직 트리 + 사용자 행 │ ┌─ [B-3] 승인 ───────────── count ─ [선택 인원 배치] ──┐ │
|
||||
│ ┌────────────────────┐ │ └──────────────────────────────────────────────────────┘ │
|
||||
│ │ 조직 트리 + 사용자 │ │ ┌─ [B-4] 최종승인 ───────── count ─ [선택 인원 배치] ──┐ │
|
||||
│ │ (단일 선택 강조) │ │ └──────────────────────────────────────────────────────┘ │
|
||||
│ └────────────────────┘ │ │
|
||||
└─────────────────────────────────┴──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3-2. 화면 데이터 흐름
|
||||
|
||||
```
|
||||
BEGIN
|
||||
|
||||
// 최초 진입
|
||||
CALL userOgnzService.findOgnzUserTree()
|
||||
DISPLAY 조직도 트리 (영역 A)
|
||||
|
||||
CALL rvwMnpwMngService.findPage({ size: 충분히 큰 값 }) // 또는 atrzSeComCd별 분할 조회
|
||||
GROUP BY atrzSeComCd
|
||||
DISPLAY 인원 배치 4블록 (영역 B-1 ~ B-4) + 건수 뱃지
|
||||
|
||||
// 영역 A-1: 조직도 검색 (클라이언트 필터 — 서버 API 없음)
|
||||
ON [조회] 버튼 CLICK / 검색 폼 SUBMIT DO
|
||||
SET displayTree = filterOgnzUserTree(원본트리, keyword)
|
||||
SET expandedOgnzSnIds = collectExpandedOgnzSnIds(displayTree) // 매칭 노드 자동 펼침
|
||||
DISPLAY 필터된 트리 (영역 A-2)
|
||||
END ON
|
||||
|
||||
ON [초기화] 버튼 CLICK DO
|
||||
RESET keyword = ""
|
||||
RESET displayTree = 원본트리
|
||||
RESET expandedOgnzSnIds = 빈 Set (또는 기본 접힘)
|
||||
DISPLAY 전체 트리 (영역 A-2)
|
||||
END ON
|
||||
|
||||
// 영역 A-2: 사용자 단일 선택
|
||||
ON 조직도 사용자 행 CLICK DO
|
||||
VALIDATE 동일 행 재클릭이 아닌 경우만 변경
|
||||
SET selectedPerson = { userUuid, userNm, ognzSn, ognzNm, jbgdSeCd, ... }
|
||||
DISPLAY "선택된 인원: {userNm} ({ognzNm} / {직급명})"
|
||||
END ON
|
||||
|
||||
// 영역 B-k: 선택 인원 즉시 배치
|
||||
ON [B-k] [선택 인원 배치] 버튼 CLICK DO
|
||||
VALIDATE selectedPerson 존재
|
||||
SET targetAtrzSeComCd = 블록 k 에 매핑된 결재구분 코드 (§4-2 표)
|
||||
CALL rvwMnpwMngService.save({
|
||||
atrzSeComCd: targetAtrzSeComCd,
|
||||
ognzSn: selectedPerson.ognzSn,
|
||||
userUuid: selectedPerson.userUuid
|
||||
})
|
||||
ON 성공 DO
|
||||
CALL rvwMnpwMngService.findPage (또는 해당 블록 목록만 갱신)
|
||||
DISPLAY [B-k] 배치 목록·건수 뱃지 갱신
|
||||
TOAST "배치되었습니다."
|
||||
END ON
|
||||
ON 실패(이미 존재) DO
|
||||
TOAST (백엔드 메시지 연동)
|
||||
END ON
|
||||
END ON
|
||||
|
||||
// 영역 B-k: 배치 인원 제거 (UI 상 삭제 UX — 스크린샷 미표기, API는 제공됨)
|
||||
ON 배치 인원 [삭제] / [제거] CLICK DO
|
||||
CONFIRM "해당 인원을 배치에서 제거하시겠습니까?"
|
||||
CALL rvwMnpwMngService.delete(atrzSeComCd, ognzSn, userUuid)
|
||||
CALL rvwMnpwMngService.findPage
|
||||
DISPLAY [B-k] 목록·뱃지 갱신
|
||||
END ON
|
||||
|
||||
END
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 영역 A — 조직도
|
||||
|
||||
### 4-1. 표시·상호작용
|
||||
|
||||
| 요소 | 내용 |
|
||||
| ---------------- | --------------------------------------------------------------------- |
|
||||
| 섹션 제목 | 조직도 |
|
||||
| 검색 폼 (A-1) | 성명·부서 키워드 입력 + **[조회]** + **[초기화]** — §4-3 |
|
||||
| 선택 안내 문구 | `선택된 인원: {userNm} ({ognzNm} / {직급표시})` — 미선택 시 안내 문구 |
|
||||
| 트리 (A-2) | 본부 → 팀 계층 + 팀 소속 사용자 목록 (`displayTree` 기준 표시) |
|
||||
| 선택 규칙 | **사용자 1명만** 선택 가능 (라디오형 단일 선택) |
|
||||
| 선택 시각 피드백 | 선택된 사용자 행 강조(배경·테두리) |
|
||||
| 로딩/오류/빈값 | Skeleton / 오류 문구 / "등록된 조직/사용자가 없습니다." |
|
||||
| 검색 결과 없음 | 필터 후 매칭 노드·사용자 0건. "조회된 인원이 없습니다." 문구 노출 |
|
||||
|
||||
### 4-2. 조직도 데이터 매핑
|
||||
|
||||
| UI 표시 | 트리 DTO 필드 (`OgnzUserTreeResponseDto`) | 비고 |
|
||||
| --------------- | ----------------------------------------- | ----------------------------------------- |
|
||||
| 조직명 | `ognzNm` | 접기/펼치기 노드 |
|
||||
| 사용자명 | `users[].userNm` | 클릭 시 선택 |
|
||||
| 부서(안내 문구) | `ognzNm` (소속 팀 노드) | 선택 시 `selectedPerson.ognzNm` |
|
||||
| 직급(안내 문구) | `users[].jbgdSeCd` 또는 `jbpsSeCd` | 공통코드 라벨 변환 |
|
||||
| 저장용 조직 SN | `ognzSn` | 사용자가 속한 **리프/팀 노드**의 `ognzSn` |
|
||||
| 저장용 사용자 | `users[].userUnqId` | API body `userUuid`로 전달 (§6-3) |
|
||||
|
||||
### 4-3. 조직도 검색 (영역 A-1)
|
||||
|
||||
결재선 관리 `AprvrPickerDialog`와 동일한 **클라이언트 사이드 필터** 패턴을 사용한다. 별도 검색 API는 없으며, `useOgnzUserTree`로 받은 **원본 트리**를 `filterOgnzUserTree`로 가공해 `displayTree`에 반영한다.
|
||||
|
||||
#### 필드 정의
|
||||
|
||||
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
|
||||
| -------------- | --------- | ---- | ------ | ----------------- | ----------------------------------------- |
|
||||
| 성명·부서 검색 | Text | N | (빈값) | — (클라이언트만) | `placeholder="성명·부서 검색"` |
|
||||
| 조회 | Button | — | — | — | `type="submit"` — 폼 제출 시 필터 적용 |
|
||||
| 초기화 | Button | — | — | — | `type="button"` — 키워드·트리·펼침 초기화 |
|
||||
|
||||
#### 필터 규칙 (`filterOgnzUserTree`)
|
||||
|
||||
| 대상 | 매칭 조건 |
|
||||
| ---------------- | ----------------------------------------------------- |
|
||||
| 조직명 | `ognzNm`에 키워드 포함 (대소문자 무시) |
|
||||
| 사용자명 | `userNm`에 키워드 포함 (대소문자 무시) |
|
||||
| 조직 매칭 시 | 해당 노드 **소속 사용자 전원** 유지 |
|
||||
| 사용자만 매칭 시 | 매칭 사용자만 남기고 상위 조직 노드는 유지(경로 보존) |
|
||||
| 하위 조직 | 재귀 필터 — 자식·손자 노드 매칭 시 상위도 표시 |
|
||||
| 키워드 빈값 | 원본 트리 전체 반환 |
|
||||
|
||||
#### 조회 후 트리 펼침 (`collectExpandedOgnzSnIds`)
|
||||
|
||||
- **[조회]** 성공 후 필터 결과 트리의 **모든 노드** `ognzSn`을 `expandedOgnzSnIds`에 넣어 자동 펼침
|
||||
- 검색 결과에서 사용자·하위 조직을 바로 확인할 수 있도록 함
|
||||
|
||||
#### 초기화 동작
|
||||
|
||||
| 상태 | 초기화 후 값 |
|
||||
| ------------------- | --------------------------------------- |
|
||||
| `keyword` | `""` |
|
||||
| `displayTree` | `useOgnzUserTree` 원본 `data` |
|
||||
| `expandedOgnzSnIds` | `new Set()` (전체 접힘) |
|
||||
| `selectedPerson` | **유지** — 검색 초기화와 선택 해제 분리 |
|
||||
|
||||
#### 버튼
|
||||
|
||||
| 버튼명 | 동작 |
|
||||
| ------ | --------------------------------------------------------- |
|
||||
| 조회 | `filterOgnzUserTree(data, keyword)` → `displayTree` 갱신 |
|
||||
| 초기화 | 키워드·표시 트리·펼침 상태 리셋 (§3-2 `ON [초기화]` 블록) |
|
||||
|
||||
---
|
||||
|
||||
## 5. 영역 B — 인원 배치
|
||||
|
||||
### 5-1. 블록 공통
|
||||
|
||||
| 요소 | 내용 |
|
||||
| ------------------ | ------------------------------------------------- |
|
||||
| 블록 제목 | 접수 / 검토 / 승인 / 최종승인 |
|
||||
| 건수 뱃지 | 해당 `atrzSeComCd`에 배치된 인원 수 |
|
||||
| 액션 버튼 | **[선택 인원 배치]** — 클릭 시 즉시 `save` (§3-2) |
|
||||
| 빈 상태 | "배치된 인원이 없습니다." |
|
||||
| 배치 목록(있을 때) | 인원명·부서·직급 등 |
|
||||
|
||||
### 5-2. 결재구분 ↔ 블록 매핑
|
||||
|
||||
| 블록 | UI 제목 | `atrzSeComCd` | 비고 |
|
||||
| ---- | -------- | ------------- | -------------------------------------------------------------- |
|
||||
| B-1 | 접수 | `ATRZ00` | 결재선 관리 `ATRZ_RECEIVER_CD`와 동일 |
|
||||
| B-2 | 검토 | `ATRZ01` | 결재선 `ATRZ_REVIEWER_1_CD`. `ATRZ02` 별도 블록 여부 — **TBD** |
|
||||
| B-3 | 승인 | `ATRZ03` | 결재선 `ATRZ_APPROVER_CD` |
|
||||
| B-4 | 최종승인 | `ATRZ99` | 결재선 `ATRZ_FINAL_CD` |
|
||||
|
||||
공통코드 마스터 `ATRZSE` / 화면 셀렉트용 `ATRZSECD`와 정합성은 배포 환경 코드표로 최종 확인.
|
||||
|
||||
### 5-3. 배치 목록 항목 (표시)
|
||||
|
||||
| 표시명 | Field (응답 DTO) | 비고 |
|
||||
| -------- | ---------------- | ---------------------------- |
|
||||
| 사용자명 | `userUuidNm` | `@CodeName` 미적용 시 TBD |
|
||||
| 결재구분 | `atrzSeComCdNm` | 블록 제목과 중복 — 생략 가능 |
|
||||
| 조직 SN | `ognzSn` | 삭제 API path에 사용 |
|
||||
| 사용자 | `userUuid` | 삭제 API path에 사용 |
|
||||
|
||||
### 5-4. 버튼
|
||||
|
||||
| 버튼명 | 위치 | 동작 |
|
||||
| -------------- | ----------- | --------------------------------- |
|
||||
| 선택 인원 배치 | 각 B-k 헤더 | `save` 후 목록·뱃지 갱신 (§3-2) |
|
||||
| 제거 | 목록 항목 | `delete` 후 목록·뱃지 갱신 (§3-2) |
|
||||
|
||||
### 5-5. 검증·제약
|
||||
|
||||
| 규칙 | 내용 |
|
||||
| ------------- | ------------------------------------------------------------------- |
|
||||
| 선택 필수 | `[선택 인원 배치]` 시 `selectedPerson` 없으면 버튼 비활성 또는 안내 |
|
||||
| 단일 선택 | 조직도에서 사용자 1명만 `selectedPerson` 유지 |
|
||||
| 중복 등록 | 동일 PK `(atrzSeComCd, ognzSn, userUuid)` 재등록 시 서버 오류 |
|
||||
| 다건 배치 | 동일 블록에 서로 다른 사용자·조직은 **복수 행** 가능 (PK가 다름) |
|
||||
| 결재구분 변경 | 본 화면 주 흐름은 `save`만 |
|
||||
|
||||
---
|
||||
|
||||
## 6. API/DTO 명세
|
||||
|
||||
### 6-1. 공통
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ------------- | ------------------------------------------------------------- |
|
||||
| 인증 | JWT (`Authorization` 헤더) |
|
||||
| 페이징 | Spring `Pageable`: `page`, `size`, `sort` → `PageResponse<T>` |
|
||||
| 프론트 엔티티 | `@/entities/user-organization`, `@/entities/rvw-mnpw` |
|
||||
|
||||
### 6-2. 조직·사용자 트리 (영역 A) — `user-ognz`
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ----------- | ------------------------------- |
|
||||
| 서비스 | `cms-service` |
|
||||
| Base prefix | `/cms-service/api/v1/user-ognz` |
|
||||
|
||||
| 기능 | Method | URL | Request DTO | Response DTO | API |
|
||||
| ---------------- | ------ | ----------- | ----------- | --------------------------- | ---------------------------------- |
|
||||
| 조직·사용자 트리 | GET | `/treeuser` | — | `OgnzUserTreeResponseDto[]` | `userOgnzService.findOgnzUserTree` |
|
||||
|
||||
**`OgnzUserTreeResponseDto`**: `ognzSn`, `upOgnzSn`, `ognzNm`, `users[]`, `children[]`
|
||||
|
||||
**`OgnzUserTreeUserDto`**: `userUnqId`, `userNm`, `jbpsSeCd`, `jbgdSeCd`
|
||||
|
||||
**React Query**: `useOgnzUserTree`
|
||||
|
||||
### 6-3. 검토인력관리 (영역 B) — `rvw-mnpw`
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ----------- | ------------------------------------- |
|
||||
| 서비스 | `as-service` |
|
||||
| Base prefix | `/as-service/api/v1/rvw-mnpw` |
|
||||
| ENDPOINT | `ENDPOINT.AS_COMMON_SERVICE.RVW_MNPW` |
|
||||
|
||||
| 기능 | Method | URL | Request DTO / Query | Response DTO | API |
|
||||
| --------------- | ------ | ------------------------------------ | -------------------------------- | ----------------------------- | ---------------------------- |
|
||||
| 목록 조회 | GET | `/` | `RvwMnpwMngSearchDto` + Pageable | `Page<RvwMnpwMngResponseDto>` | `rvwMnpwMngService.findPage` |
|
||||
| 상세 조회 | GET | `/{atrzSeComCd}/{ognzSn}/{userUuid}` | Path | `RvwMnpwMngResponseDto` | `rvwMnpwMngService.findById` |
|
||||
| 등록(즉시 배치) | POST | `/` | `RvwMnpwMngSaveRequestDto` | `RvwMnpwMngResponseDto` (201) | `rvwMnpwMngService.save` |
|
||||
| 결재구분 변경 | PUT | `/{atrzSeComCd}/{ognzSn}/{userUuid}` | `RvwMnpwMngUpdateRequestDto` | `RvwMnpwMngResponseDto` | `rvwMnpwMngService.update` |
|
||||
| 삭제 | DELETE | `/{atrzSeComCd}/{ognzSn}/{userUuid}` | Path | `string` | `rvwMnpwMngService.delete` |
|
||||
|
||||
**`RvwMnpwMngSearchDto`**: `atrzSeComCd?`, `ognzSn?`, `userUuid?` + `page`, `size`, `sort`
|
||||
|
||||
**`RvwMnpwMngSaveRequestDto`**: `atrzSeComCd*`, `ognzSn*`, `userUuid*`
|
||||
|
||||
**`RvwMnpwMngUpdateRequestDto`**: `newAtrzSeComCd*` — 본 화면 기본 흐름에서는 미사용
|
||||
|
||||
**`RvwMnpwMngResponseDto`**: `atrzSeComCd`, `atrzSeComCdNm?`, `ognzSn`, `userUuid`, `userUuidNm?`, `rgtr?`, `regDt?`
|
||||
|
||||
### 6-4. 조직도 ↔ 검토인력 필드 매핑 (저장 시)
|
||||
|
||||
| 조직도 (`OgnzUserTreeUserDto` + 노드) | `RvwMnpwMngSaveRequestDto` | 비고 |
|
||||
| ------------------------------------- | -------------------------- | --------------------- |
|
||||
| (블록 k의 `atrzSeComCd`) | `atrzSeComCd` | §5-2 표 |
|
||||
| 소속 팀 `ognzSn` | `ognzSn` | |
|
||||
| `userUnqId` | `userUuid` | 문자열(UUID)로 직렬화 |
|
||||
|
||||
---
|
||||
|
||||
## 7. UI 구현 가이드
|
||||
|
||||
### 7-1. 기술 스택·원칙
|
||||
|
||||
- **공통 컴포넌트**: `@common/components` (`Button`, `Badge`, `Card` 또는 섹션 레이아웃)
|
||||
- **데이터**: React Query — `useOgnzUserTree`, `useRvwMnpwMngPage`, `useSaveRvwMnpwMng`, `useDeleteRvwMnpwMng`
|
||||
- **상태**: `selectedPerson`(단일), `keyword`, `displayTree`, `expandedOgnzSnIds`, 블록별 배치 목록(서버 데이터 파생)
|
||||
- **조직도 검색**: `filterOgnzUserTree`, `collectExpandedOgnzSnIds` (`widgets/ognz-user-tree/lib` 또는 페이지 `lib`)
|
||||
- **저장 UX**: 별도 화면 저장 없음 — **[선택 인원 배치]** = `save` mutation 즉시 호출
|
||||
- **조직도 UI**: 결재선 관리 `ognz-user-tree.tsx`를 **widgets**로 승격해 공통화
|
||||
|
||||
### 7-2. 모듈·파일 배치(제안)
|
||||
|
||||
| 구분 | 경로 |
|
||||
| -------- | ---------------------------------------------------------------------------------- |
|
||||
| 라우트 | `apps/as/app/(with-base-layout)/rvw-mnpw-mng/page.tsx` |
|
||||
| 페이지 | `apps/as/src/pages/rvw-mnpw-mng/` |
|
||||
| UI | `.../ui/rvw-mnpw-mng-page.tsx`, `rvw-mnpw-assignment-panel.tsx` 등 |
|
||||
| model | `.../model/selected-ognz-user.ts`, `rvw-mnpw-block-config.ts` (`atrzSeComCd` 상수) |
|
||||
| lib | `.../lib/group-rvw-mnpw-by-atrz-se.ts` (목록 → B-1~B-4 분배) |
|
||||
| **위젯** | `apps/as/src/widgets/ognz-user-tree/` — 아래 이전 대상 |
|
||||
| 엔티티 | `entities/user-organization/`, `entities/rvw-mnpw/` (구현 완료) |
|
||||
|
||||
#### widgets/ognz-user-tree 이전 (결재선 → 공통)
|
||||
|
||||
| 현재 (결재선 관리) | 이전 후 (widgets) |
|
||||
| ------------------------------------------------------ | ------------------------------------------------------------------------------------ |
|
||||
| `pages/approval-line-management/ui/ognz-user-tree.tsx` | `widgets/ognz-user-tree/ui/ognz-user-tree.tsx` |
|
||||
| `pages/.../ui/ognz-user-tree-node.tsx` | `widgets/ognz-user-tree/ui/ognz-user-tree-node.tsx` |
|
||||
| `pages/.../model/reviewer-option.ts` | `widgets/ognz-user-tree/model/selected-ognz-user.ts` (이름·필드 확장: `ognzSn` 필수) |
|
||||
| `pages/.../lib/filter-ognz-user-tree.ts` | `widgets/ognz-user-tree/lib/filter-ognz-user-tree.ts` (**필수** — 검색 공통화) |
|
||||
| `widgets/ognz-user-tree/index.ts` | `OgnzUserTree`, `OgnzUserTreeNode`, 타입 export |
|
||||
|
||||
결재선 `AprvrPickerDialog`는 `@/widgets/ognz-user-tree` import로 교체. 검토인력관리 페이지는 **인라인 조직도**(다이얼로그 없음)로 동일 위젯 사용.
|
||||
|
||||
**`SelectedOgnzUser`** — `ReviewerOption` 확장:
|
||||
|
||||
```typescript
|
||||
export interface SelectedOgnzUser {
|
||||
userUuid: string; // OgnzUserTreeUserDto.userUnqId
|
||||
userNm: string;
|
||||
ognzSn: number;
|
||||
ognzNm: string;
|
||||
jbgdSeCd?: string;
|
||||
jbpsSeCd?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 7-3. 영역별 UI 매핑
|
||||
|
||||
| 영역 | 주요 UI |
|
||||
| ---- | -------------------------------------------------------------------- |
|
||||
| A-1 | 검색 폼 (`Input` + 조회/초기화) — `AprvrPickerDialog` 90~103행 패턴 |
|
||||
| A-2 | `OgnzUserTree(nodes={displayTree})` + 선택 안내 + `useOgnzUserTree` |
|
||||
| B-k | 제목·Badge(count)·`선택 인원 배치`·배치 목록(또는 empty) |
|
||||
| 공통 | 페이지 헤더·브레드크럼·2열 레이아웃 (`grid` / `flex`, 모바일은 스택) |
|
||||
|
||||
### 7-4. 다이얼로그·라우팅
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ---------- | ----------------------------------- |
|
||||
| 다이얼로그 | **없음** (조직도 인라인, 즉시 저장) |
|
||||
| 라우팅 | 메뉴 `검토인력관리` 단일 페이지 |
|
||||
|
||||
### 7-5. 데이터 흐름(구현 요약)
|
||||
|
||||
- **진입**: `useOgnzUserTree` → `displayTree = data` + `useRvwMnpwMngPage({ page: 0, size: N })` → 4패널.
|
||||
- **검색**: `keyword` 로컬 상태 → **[조회]** 시 `filterOgnzUserTree` + `collectExpandedOgnzSnIds` → `OgnzUserTree`에 `displayTree` 전달.
|
||||
- **초기화**: `keyword`·`displayTree`·`expandedOgnzSnIds` 리셋 (`selectedPerson` 유지).
|
||||
- **선택**: 트리 `onSelect` → `selectedPerson` (반드시 `ognzSn` 포함).
|
||||
- **배치**: `useSaveRvwMnpwMng().mutate({ atrzSeComCd, ognzSn, userUuid })` → `onSuccess`에서 `rvw-mnpw` query invalidate.
|
||||
- **삭제**: 목록 항목에서 `useDeleteRvwMnpwMng` — UI 확정 후 연결.
|
||||
|
||||
### 7-6. 미확정 / 검증 필요
|
||||
|
||||
- [ ] `ATRZ02`(검토2) 별도 블록 필요 여부
|
||||
- [ ] 권한(역할별 CRUD)
|
||||
+25
-3
@@ -1,8 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { PORT, PROXY_HOST, SERVER_API_URL, SITE_ID } from "@/shared/config";
|
||||
import { AUTH_USER_ID, CLAIM_NAME, PORT, PROXY_HOST, SERVER_API_URL, SITE_ID } from "@/shared/config";
|
||||
|
||||
const serverApiUrl = process.env.SERVER_API_URL ?? "http://localhost:3000";
|
||||
const serverApiUrl = process.env.SERVER_API_URL;
|
||||
|
||||
// CSP 헤더 설정 함수 (img-src만)
|
||||
function addSecurityHeaders(response: NextResponse): NextResponse {
|
||||
@@ -34,6 +34,28 @@ export function middleware(req: NextRequest) {
|
||||
return addSecurityHeaders(response);
|
||||
}
|
||||
|
||||
|
||||
// tus-js-client PATCH 요청: Location 헤더로 /server 없이 반환된 경로 직접 처리
|
||||
// (예: /attach-service/api/v1/tus/upload/{id})
|
||||
if (pathname.startsWith("/attach-service")) {
|
||||
const requestHeaders = new Headers(req.headers);
|
||||
const token = req.cookies.get(CLAIM_NAME)?.value;
|
||||
const userId = req.cookies.get(AUTH_USER_ID)?.value;
|
||||
const newUrl = `${serverApiUrl}${pathname}`;
|
||||
|
||||
if (token) requestHeaders.set(CLAIM_NAME, token);
|
||||
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
|
||||
|
||||
logMessages.push(` [TUS Proxy] Rewritten URL: ${newUrl}`);
|
||||
logMessages.push("\n--------------------------------\n");
|
||||
console.log(logMessages.join("\n"));
|
||||
|
||||
const response = NextResponse.rewrite(new URL(newUrl), {
|
||||
headers: requestHeaders,
|
||||
});
|
||||
return addSecurityHeaders(response);
|
||||
}
|
||||
|
||||
// if (pathname === "/") {
|
||||
// const response = NextResponse.redirect(new URL("/dashboard", req.url));
|
||||
// return addSecurityHeaders(response);
|
||||
@@ -47,5 +69,5 @@ export function middleware(req: NextRequest) {
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/server/:path*", "/"],
|
||||
matcher: ["/server/:path*", "/attach-service/:path*", "/"],
|
||||
};
|
||||
|
||||
Vendored
-6
@@ -1,6 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference types="next/navigation-types/compat/navigation" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
+11
-3
@@ -3,16 +3,22 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "next dev -p 3003",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"start": "next start -p 3003",
|
||||
"lint": "next lint",
|
||||
"lint:fsd": "steiger ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@common/assets": "workspace:*",
|
||||
"@common/auth": "workspace:*",
|
||||
"@common/components": "workspace:*",
|
||||
"@common/network": "workspace:*",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@faker-js/faker": "^9.9.0",
|
||||
"@frontend-opensource/use-react-hooks": "^1.1.4",
|
||||
"@hookform/resolvers": "^4.1.3",
|
||||
"@tanstack/react-query": "^5.75.5",
|
||||
"@tanstack/react-query-devtools": "^5.75.5",
|
||||
@@ -20,8 +26,10 @@
|
||||
"axios": "^1.8.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"js-cookie": "^3.0.5",
|
||||
"next": "15.3.8",
|
||||
"lucide-react": "^0.474.0",
|
||||
"next": "15.5.18",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"tus-js-client": "^4.3.1",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { usePathname, useRouter } from "next/navigation";
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useMenuStore } from "@/shared/lib";
|
||||
import { PageLayout } from "@/shared/ui/page-layout";
|
||||
import type { MenuItem } from "@/entities/menu";
|
||||
import { BaseLayout } from "@common/components";
|
||||
@@ -18,11 +19,14 @@ export function BaseLayoutWrapper({ children }: BaseLayoutWrapperProps) {
|
||||
const pathname = usePathname();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const setCurrentMenuInfo = useMenuStore((state) => state.setCurrentMenuInfo);
|
||||
|
||||
const handleMenuClick = useCallback(
|
||||
(item: MenuItem) => {
|
||||
setCurrentMenuInfo(item);
|
||||
router.push(item.urlPath);
|
||||
},
|
||||
[router],
|
||||
[router, setCurrentMenuInfo],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { ConfirmProvider } from "@frontend-opensource/use-react-hooks";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
|
||||
import { queryClient } from "@/shared/lib";
|
||||
import { StoreProvider } from "@/shared/providers";
|
||||
import { AlertDialog } from "@/shared/ui/alert-dialog";
|
||||
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
|
||||
import { AuthWrapper } from "@/entities/auth";
|
||||
import { MenuWrapper } from "@/entities/menu";
|
||||
|
||||
interface ProvidersProps {
|
||||
children: React.ReactNode;
|
||||
@@ -15,7 +19,13 @@ export function Providers({ children }: ProvidersProps) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<StoreProvider>
|
||||
<AuthWrapper>{children}</AuthWrapper>
|
||||
<ConfirmProvider>
|
||||
<AuthWrapper>
|
||||
<MenuWrapper>{children}</MenuWrapper>
|
||||
<AlertDialog />
|
||||
<ConfirmDialog />
|
||||
</AuthWrapper>
|
||||
</ConfirmProvider>
|
||||
</StoreProvider>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
|
||||
import {
|
||||
ACNTG_SE_SAVE_MODE,
|
||||
type AcntgSeMngPutRequestDto,
|
||||
type AcntgSeMngResponseDto,
|
||||
type AcntgSeMngSaveRequestDto,
|
||||
type AcntgSeMngSearchDto,
|
||||
type AcntgSeMngService,
|
||||
type AcntgSeMngTreeDto,
|
||||
} from "../model/acntg-interface";
|
||||
|
||||
const BASE = ENDPOINT.AS_COMMON_SERVICE.ACNTG_SE;
|
||||
|
||||
async function findAll(params: AcntgSeMngSearchDto): Promise<PageResponse<AcntgSeMngResponseDto>> {
|
||||
const response = await client.get<PageResponse<AcntgSeMngResponseDto>>(BASE, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findTree(): Promise<AcntgSeMngTreeDto[]> {
|
||||
const response = await client.get<AcntgSeMngTreeDto[]>(`${BASE}/list/tree`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findById(acntgSeCd: string): Promise<AcntgSeMngResponseDto> {
|
||||
const response = await client.get<AcntgSeMngResponseDto>(`${BASE}/${acntgSeCd}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function saveMulti(body: AcntgSeMngSaveRequestDto[]): Promise<AcntgSeMngResponseDto[]> {
|
||||
const response = await client.post<AcntgSeMngResponseDto[]>(BASE, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function update(acntgSeCd: string, body: AcntgSeMngPutRequestDto): Promise<string> {
|
||||
const response = await client.put<string>(`${BASE}/${acntgSeCd}`, {
|
||||
...body,
|
||||
acntgSeCd,
|
||||
mode: ACNTG_SE_SAVE_MODE.UPDATE,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function deleteAcntgSeCd(acntgSeCd: string): Promise<string> {
|
||||
const response = await client.delete<string>(`${BASE}/${acntgSeCd}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const acntgSeMngService = {
|
||||
findAll,
|
||||
findTree,
|
||||
findById,
|
||||
saveMulti,
|
||||
update,
|
||||
delete: deleteAcntgSeCd,
|
||||
} satisfies AcntgSeMngService;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type {
|
||||
AcntgSeMngPutRequestDto,
|
||||
AcntgSeMngSaveRequestDto,
|
||||
AcntgSeMngSearchDto,
|
||||
} from "../model/acntg-interface";
|
||||
|
||||
import { acntgSeMngService } from "./acntg-service";
|
||||
|
||||
const acntgSeMngBaseKey = [ENDPOINT.AS_COMMON_SERVICE.ACNTG_SE] as const;
|
||||
|
||||
export function useAcntgSeMngPage(params: AcntgSeMngSearchDto) {
|
||||
return useQuery({
|
||||
queryKey: [...acntgSeMngBaseKey, "findAll", params],
|
||||
queryFn: () => acntgSeMngService.findAll(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAcntgSeMngTree() {
|
||||
return useQuery({
|
||||
queryKey: [...acntgSeMngBaseKey, "findTree"],
|
||||
queryFn: () => acntgSeMngService.findTree(),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAcntgSeMng(acntgSeCd: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...acntgSeMngBaseKey, "findById", acntgSeCd],
|
||||
queryFn: () => acntgSeMngService.findById(acntgSeCd!),
|
||||
enabled: Boolean(acntgSeCd),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveMultiAcntgSeMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (body: AcntgSeMngSaveRequestDto[]) => acntgSeMngService.saveMulti(body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: acntgSeMngBaseKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAcntgSeMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ acntgSeCd, body }: { acntgSeCd: string; body: AcntgSeMngPutRequestDto }) =>
|
||||
acntgSeMngService.update(acntgSeCd, body),
|
||||
onSuccess: (_data, { acntgSeCd }) => {
|
||||
queryClient.invalidateQueries({ queryKey: acntgSeMngBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...acntgSeMngBaseKey, "findById", acntgSeCd],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAcntgSeMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (acntgSeCd: string) => acntgSeMngService.delete(acntgSeCd),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: acntgSeMngBaseKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export { acntgSeMngService } from "./api/acntg-service";
|
||||
export {
|
||||
useAcntgSeMng,
|
||||
useAcntgSeMngPage,
|
||||
useAcntgSeMngTree,
|
||||
useDeleteAcntgSeMng,
|
||||
useSaveMultiAcntgSeMng,
|
||||
useUpdateAcntgSeMng,
|
||||
} from "./api/queries";
|
||||
export type {
|
||||
AcntgSeMngResponseDto,
|
||||
AcntgSeMngSaveRequestDto,
|
||||
AcntgSeMngSearchDto,
|
||||
AcntgSeMngService,
|
||||
AcntgSeMngTreeDto,
|
||||
AcntgSeSaveMode,
|
||||
BizCstTreeDto,
|
||||
} from "./model/acntg-interface";
|
||||
export { ACNTG_SE_SAVE_MODE } from "./model/acntg-interface";
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { PageableParams, PageResponse } from "@/shared/model";
|
||||
|
||||
export const ACNTG_SE_SAVE_MODE = {
|
||||
CREATE: "C",
|
||||
UPDATE: "U",
|
||||
DELETE: "D",
|
||||
} as const;
|
||||
|
||||
export type AcntgSeSaveMode = (typeof ACNTG_SE_SAVE_MODE)[keyof typeof ACNTG_SE_SAVE_MODE];
|
||||
|
||||
/** 백엔드 AcntgSeMngResponseDto */
|
||||
export interface AcntgSeMngResponseDto {
|
||||
acntgSeCd: string;
|
||||
acntgSeCdNm: string;
|
||||
upAcntgSeCd?: string;
|
||||
cdLvNo: number;
|
||||
useYn: string;
|
||||
expln?: string;
|
||||
rgtr?: string;
|
||||
rgtrNm?: string;
|
||||
mdfr?: string;
|
||||
regDt?: string;
|
||||
mdfcnDt?: string;
|
||||
}
|
||||
|
||||
/** 백엔드 AcntgSeMngSearchDto + Pageable */
|
||||
export interface AcntgSeMngSearchDto extends PageableParams {
|
||||
acntgSeCd?: string;
|
||||
acntgSeCdNm?: string;
|
||||
useYn?: string;
|
||||
}
|
||||
|
||||
/** 백엔드 AcntgSeMngSaveRequestDto */
|
||||
export interface AcntgSeMngSaveRequestDto {
|
||||
acntgSeCd: string;
|
||||
mode: AcntgSeSaveMode;
|
||||
acntgSeCdNm: string;
|
||||
upAcntgSeCd?: string;
|
||||
cdLvNo: number;
|
||||
useYn?: string;
|
||||
expln?: string;
|
||||
}
|
||||
|
||||
/** PUT /{acntgSeCd} 요청 (mode는 서비스에서 U로 설정) */
|
||||
export interface AcntgSeMngPutRequestDto {
|
||||
acntgSeCd: string;
|
||||
acntgSeCdNm: string;
|
||||
upAcntgSeCd?: string;
|
||||
cdLvNo: number;
|
||||
useYn?: string;
|
||||
expln?: string;
|
||||
}
|
||||
|
||||
/** 백엔드 AcntgSeMngTreeDto */
|
||||
export interface AcntgSeMngTreeDto {
|
||||
indAcntgSeCdNm: string;
|
||||
acntgSeCd: string;
|
||||
acntgSeCdNm: string;
|
||||
upAcntgSeCd?: string;
|
||||
useYn: string;
|
||||
treeLevel: number;
|
||||
treePath: string;
|
||||
}
|
||||
|
||||
/** 백엔드 BizCstTreeDto (InstlSncnMngDetResponseDto 등에서 참조) */
|
||||
export interface BizCstTreeDto {
|
||||
indAcntgSeCdNm: string;
|
||||
acntgSeCd: string;
|
||||
acntgSeCdNm: string;
|
||||
useYn: string;
|
||||
treeLevel: number;
|
||||
treePath: string;
|
||||
bscPlanAmt?: number;
|
||||
bgtRfltAmt?: number;
|
||||
tstmDesignAmt?: number;
|
||||
}
|
||||
|
||||
/** Controller public 메서드명·시그니처와 동일 */
|
||||
export interface AcntgSeMngService {
|
||||
findAll(params: AcntgSeMngSearchDto): Promise<PageResponse<AcntgSeMngResponseDto>>;
|
||||
findTree(): Promise<AcntgSeMngTreeDto[]>;
|
||||
findById(acntgSeCd: string): Promise<AcntgSeMngResponseDto>;
|
||||
saveMulti(body: AcntgSeMngSaveRequestDto[]): Promise<AcntgSeMngResponseDto[]>;
|
||||
update(acntgSeCd: string, body: AcntgSeMngPutRequestDto): Promise<string>;
|
||||
delete(acntgSeCd: string): Promise<string>;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ACCESS_TOKEN, AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config";
|
||||
import { getRemainingMs } from "@common/auth";
|
||||
|
||||
import { clearAuthToken, setToken, setupRefreshTimer } from "../lib/token-manager";
|
||||
import type { AuthPayload, AuthService } from "../model/auth-interface";
|
||||
|
||||
async function login(payload: AuthPayload): Promise<void> {
|
||||
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const accessToken = data[ACCESS_TOKEN];
|
||||
const tokenId = data[AUTH_USER_ID];
|
||||
|
||||
if (!accessToken || !tokenId) {
|
||||
throw new Error("인증 토큰을 받지 못했습니다.");
|
||||
}
|
||||
|
||||
setToken(CLAIM_NAME, accessToken);
|
||||
setToken(AUTH_USER_ID, tokenId);
|
||||
|
||||
const remainingMs = getRemainingMs(accessToken);
|
||||
if (remainingMs > 0) {
|
||||
setupRefreshTimer(silentRefresh, remainingMs);
|
||||
}
|
||||
}
|
||||
|
||||
async function silentRefresh(): Promise<void> {
|
||||
console.log(`[silentRefresh] Called at ${new Date().toISOString()}`);
|
||||
try {
|
||||
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, {
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.message ?? "토큰 갱신에 실패했습니다.");
|
||||
}
|
||||
|
||||
const accessToken = data[ACCESS_TOKEN];
|
||||
const tokenId = data[AUTH_USER_ID];
|
||||
|
||||
if (!accessToken || !tokenId) {
|
||||
throw new Error("갱신된 인증 토큰을 받지 못했습니다.");
|
||||
}
|
||||
|
||||
setToken(CLAIM_NAME, accessToken);
|
||||
setToken(AUTH_USER_ID, tokenId);
|
||||
|
||||
const remainingMs = getRemainingMs(accessToken);
|
||||
if (remainingMs > 0) {
|
||||
setupRefreshTimer(silentRefresh, remainingMs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[silentRefresh] Failed at ${new Date().toISOString()}`, error);
|
||||
clearAuthToken();
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("auth:logout"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
clearAuthToken();
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("auth:logout"));
|
||||
}
|
||||
}
|
||||
|
||||
export const authService: AuthService = {
|
||||
login,
|
||||
silentRefresh,
|
||||
logout,
|
||||
};
|
||||
@@ -1,92 +0,0 @@
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
AUTH_USER_ID,
|
||||
CLAIM_NAME,
|
||||
ENDPOINT,
|
||||
JWT_EXPIRATION_TIME,
|
||||
} from "@/shared/config";
|
||||
|
||||
import { tokenManager } from "../lib/token-manager";
|
||||
import type { ILogin, LoginService } from "../model/login-interface";
|
||||
|
||||
export class LoginApiService implements LoginService {
|
||||
private static instance: LoginApiService;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): LoginApiService {
|
||||
if (!LoginApiService.instance) {
|
||||
LoginApiService.instance = new LoginApiService();
|
||||
}
|
||||
return LoginApiService.instance;
|
||||
}
|
||||
|
||||
public async login(payload: ILogin): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
userId: payload.userId,
|
||||
password: payload.password,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
console.log("data ", data);
|
||||
const accessToken = data[ACCESS_TOKEN];
|
||||
const tokenId = data[AUTH_USER_ID];
|
||||
|
||||
if (!accessToken || !tokenId) {
|
||||
if (data.message == "Invalid Credentials") {
|
||||
throw new Error("아이디와 비밀번호가 일치하지 않습니다.");
|
||||
} else if (data.message == "User not found") {
|
||||
throw new Error("존재하지 않는 아이디입니다.");
|
||||
} else if (data.message == "User is not active") {
|
||||
throw new Error("활성화되지 않은 계정입니다.");
|
||||
} else if (data.message == "User is not verified") {
|
||||
throw new Error("이메일 인증이 필요합니다.");
|
||||
} else {
|
||||
throw new Error(data.message);
|
||||
}
|
||||
}
|
||||
|
||||
tokenManager.setToken(CLAIM_NAME, accessToken);
|
||||
tokenManager.setToken(AUTH_USER_ID, tokenId);
|
||||
tokenManager.setupRefreshTimer(() => this.silentRefresh(), JWT_EXPIRATION_TIME);
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
throw new Error(`Login 실패 ${error.response?.data?.message}`);
|
||||
}
|
||||
throw new Error(`Login 실패 ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async silentRefresh(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, {
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
const accessToken = data[ACCESS_TOKEN];
|
||||
const tokenId = data[AUTH_USER_ID];
|
||||
|
||||
if (!accessToken || !tokenId) {
|
||||
throw new Error("Missing required authentication tokens");
|
||||
}
|
||||
|
||||
tokenManager.setToken(CLAIM_NAME, accessToken);
|
||||
tokenManager.setToken(AUTH_USER_ID, tokenId);
|
||||
} catch (error) {
|
||||
tokenManager.clearAuthToken();
|
||||
if (error instanceof AxiosError) {
|
||||
throw new Error(`Silent refresh failed: ${error.response?.data?.message}`);
|
||||
}
|
||||
throw new Error(`Silent refresh failed: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const LoginApi = LoginApiService.getInstance();
|
||||
@@ -1,2 +1,3 @@
|
||||
export { LoginApi } from "./api/login-service";
|
||||
export { authService } from "./api/auth-service";
|
||||
export { AuthWrapper } from "./lib/auth-wrapper";
|
||||
export { tokenManager } from "./lib/token-manager";
|
||||
|
||||
@@ -1,42 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { isAxiosError } from "axios";
|
||||
|
||||
import { client } from "@/shared/api";
|
||||
import { AUTH_LOGIN_REDIRECT_URL, AUTH_USER_ID, ENDPOINT } from "@/shared/config";
|
||||
import { getCookie, useUserStore } from "@/shared/lib";
|
||||
import { AUTH_LOGIN_REDIRECT_URL, AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config";
|
||||
import { useUserStore } from "@/shared/lib";
|
||||
import type { IUserInfo } from "@/shared/store";
|
||||
import { getRemainingMs } from "@common/auth";
|
||||
import { isAtLoginRedirectUrl } from "@common/network";
|
||||
|
||||
import { authService } from "../api/auth-service";
|
||||
|
||||
import { getToken, setupRefreshTimer } from "./token-manager";
|
||||
|
||||
const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
const { userInfo, setUserInfo } = useUserStore((state) => state);
|
||||
|
||||
async function authCheck() {
|
||||
if (isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = getCookie(AUTH_USER_ID);
|
||||
if (!userId) {
|
||||
throw new Error("Missing required authentication tokens");
|
||||
}
|
||||
|
||||
const response = await client.get<IUserInfo>(`${ENDPOINT.USER_SERVICE.USERS}/info/me`);
|
||||
if (!userInfo) setUserInfo(response.data);
|
||||
}
|
||||
const router = useRouter();
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
const setUserInfo = useUserStore((state) => state.setUserInfo);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
await authCheck();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setUserInfo(null);
|
||||
if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
|
||||
window.location.assign(AUTH_LOGIN_REDIRECT_URL);
|
||||
}
|
||||
const token = getToken(CLAIM_NAME);
|
||||
if (token) {
|
||||
const remainingMs = getRemainingMs(token);
|
||||
if (remainingMs > 0) {
|
||||
console.log(`[AuthWrapper] Refresh timer set: ${remainingMs}ms (${remainingMs / 1000}s)`);
|
||||
setupRefreshTimer(authService.silentRefresh, remainingMs);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
if (isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) return;
|
||||
|
||||
async function authCheck() {
|
||||
const userId = getToken(AUTH_USER_ID);
|
||||
if (!userId) {
|
||||
router.replace(AUTH_LOGIN_REDIRECT_URL);
|
||||
return;
|
||||
}
|
||||
|
||||
if (userInfo) return;
|
||||
|
||||
const response = await client.get<IUserInfo>(`${ENDPOINT.USER_SERVICE.USERS}/info/me`);
|
||||
setUserInfo(response.data);
|
||||
}
|
||||
|
||||
authCheck().catch((error) => {
|
||||
if (isAxiosError(error) && error.response?.status === 401) return;
|
||||
console.error(error);
|
||||
setUserInfo(null);
|
||||
if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
|
||||
router.replace(AUTH_LOGIN_REDIRECT_URL);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleAuthLogout() {
|
||||
setUserInfo(null);
|
||||
if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
|
||||
router.replace(AUTH_LOGIN_REDIRECT_URL);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("auth:logout", handleAuthLogout);
|
||||
return () => window.removeEventListener("auth:logout", handleAuthLogout);
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
@@ -1,82 +1,15 @@
|
||||
import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config";
|
||||
import { getCookie, removeCookie,setCookie } from "@/shared/lib";
|
||||
import { authConfig } from "@/shared/config/auth";
|
||||
import { createTokenManager } from "@common/auth";
|
||||
|
||||
export class TokenManager {
|
||||
private static instance: TokenManager;
|
||||
private refreshTimer: NodeJS.Timeout | null = null;
|
||||
export const tokenManager = createTokenManager(authConfig);
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): TokenManager {
|
||||
if (!TokenManager.instance) {
|
||||
TokenManager.instance = new TokenManager();
|
||||
}
|
||||
return TokenManager.instance;
|
||||
}
|
||||
|
||||
public getToken(key: string): string | undefined {
|
||||
return getCookie(key);
|
||||
}
|
||||
|
||||
public setToken(key: string, value: string): void {
|
||||
const domain = this.getCookieDomain();
|
||||
|
||||
setCookie(key, value, {
|
||||
secure: window.location.protocol === "https:",
|
||||
sameSite: "lax",
|
||||
domain: domain,
|
||||
path: "/", // 모든 경로에서 접근 가능
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private getCookieDomain(): string | undefined {
|
||||
const hostname = window.location.hostname;
|
||||
|
||||
// 개발 환경에서는 localhost 도메인 사용
|
||||
if (hostname === "localhost" || hostname === "127.0.0.1") {
|
||||
return undefined; // localhost에서는 도메인 설정하지 않음
|
||||
}
|
||||
|
||||
// 운영 환경에서는 서브도메인을 포함한 최상위 도메인 사용
|
||||
if (hostname.includes("hasuon.or.kr")) {
|
||||
return ".hasuon.or.kr";
|
||||
}
|
||||
|
||||
// 기타 환경에서는 현재 호스트명 사용
|
||||
return hostname;
|
||||
}
|
||||
|
||||
public removeToken(key: string): void {
|
||||
const domain = this.getCookieDomain();
|
||||
removeCookie(key, { domain, path: "/" });
|
||||
}
|
||||
|
||||
public setupRefreshTimer(
|
||||
callback: () => Promise<void>,
|
||||
expirationTime: number,
|
||||
): void {
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
}
|
||||
|
||||
this.refreshTimer = setTimeout(() => {
|
||||
callback().catch((error) => {
|
||||
console.error("Silent refresh failed:", error);
|
||||
this.clearAuthToken();
|
||||
});
|
||||
}, expirationTime - 60000);
|
||||
}
|
||||
|
||||
public clearAuthToken(): void {
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
|
||||
this.removeToken(CLAIM_NAME);
|
||||
this.removeToken(AUTH_USER_ID);
|
||||
}
|
||||
}
|
||||
|
||||
export const tokenManager = TokenManager.getInstance();
|
||||
export const {
|
||||
getToken,
|
||||
setToken,
|
||||
removeToken,
|
||||
setupRefreshTimer,
|
||||
clearAuthToken,
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
getUserId,
|
||||
} = tokenManager;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface AuthPayload {
|
||||
userId: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AuthService {
|
||||
login(payload: AuthPayload): Promise<void>;
|
||||
silentRefresh(): Promise<void>;
|
||||
logout(): Promise<void>;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export interface ILogin {
|
||||
userId: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginService {
|
||||
login(payload: ILogin): Promise<void>;
|
||||
silentRefresh(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
|
||||
import type {
|
||||
CklstArtcl,
|
||||
CklstArtclId,
|
||||
CklstArtclSaveRequest,
|
||||
CklstArtclSearchParams,
|
||||
CklstArtclService,
|
||||
CklstArtclUpdateRequest,
|
||||
} from "../model/cklst-artcl-interface";
|
||||
|
||||
async function findPage(params: CklstArtclSearchParams): Promise<PageResponse<CklstArtcl>> {
|
||||
const response = await client.get<PageResponse<CklstArtcl>>(
|
||||
ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findByCklstSn(cklstSn: number): Promise<CklstArtcl[]> {
|
||||
const response = await client.get<CklstArtcl[]>(
|
||||
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL}/by-cklst/${cklstSn}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findById(cklstArtclSn: number, cklstSn: number): Promise<CklstArtcl> {
|
||||
const response = await client.get<CklstArtcl>(
|
||||
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL}/${cklstArtclSn}/${cklstSn}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function create(body: CklstArtclSaveRequest): Promise<CklstArtclId> {
|
||||
const response = await client.post<CklstArtclId>(
|
||||
ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL,
|
||||
body,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function update(
|
||||
cklstArtclSn: number,
|
||||
cklstSn: number,
|
||||
body: CklstArtclUpdateRequest,
|
||||
): Promise<CklstArtclId> {
|
||||
const response = await client.put<CklstArtclId>(
|
||||
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL}/${cklstArtclSn}/${cklstSn}`,
|
||||
body,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function remove(cklstArtclSn: number, cklstSn: number): Promise<CklstArtclId> {
|
||||
const response = await client.delete<CklstArtclId>(
|
||||
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL}/${cklstArtclSn}/${cklstSn}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const cklstArtclService = {
|
||||
findPage,
|
||||
findByCklstSn,
|
||||
findById,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
} satisfies CklstArtclService;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
|
||||
import type {
|
||||
CklstMng,
|
||||
CklstMngSaveRequest,
|
||||
CklstMngSearchParams,
|
||||
CklstMngService,
|
||||
CklstTreeNode,
|
||||
CklstTreeSearchParams,
|
||||
} from "../model/cklst-mng-interface";
|
||||
|
||||
async function findPage(params: CklstMngSearchParams): Promise<PageResponse<CklstMng>> {
|
||||
const response = await client.get<PageResponse<CklstMng>>(ENDPOINT.AS_CHECKLIST_SERVICE.CKLST, {
|
||||
params,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findById(cklstSn: number): Promise<CklstMng> {
|
||||
const response = await client.get<CklstMng>(`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/${cklstSn}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findTree(params: CklstTreeSearchParams): Promise<CklstTreeNode[]> {
|
||||
const response = await client.get<CklstTreeNode[]>(`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/list/tree`, {
|
||||
params,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function create(body: CklstMngSaveRequest): Promise<number> {
|
||||
const response = await client.post<number>(ENDPOINT.AS_CHECKLIST_SERVICE.CKLST, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function update(cklstSn: number, body: CklstMngSaveRequest): Promise<number> {
|
||||
const response = await client.put<number>(
|
||||
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/${cklstSn}`,
|
||||
body,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function remove(cklstSn: number): Promise<number> {
|
||||
const response = await client.delete<number>(`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/${cklstSn}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const cklstMngService = {
|
||||
findPage,
|
||||
findById,
|
||||
findTree,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
} satisfies CklstMngService;
|
||||
@@ -0,0 +1,79 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
|
||||
import type {
|
||||
CklstRvw,
|
||||
CklstRvwId,
|
||||
CklstRvwSaveRequest,
|
||||
CklstRvwSearchParams,
|
||||
CklstRvwService,
|
||||
CklstRvwUpdateRequest,
|
||||
} from "../model/cklst-rvw-interface";
|
||||
|
||||
async function findPage(params: CklstRvwSearchParams): Promise<PageResponse<CklstRvw>> {
|
||||
const response = await client.get<PageResponse<CklstRvw>>(
|
||||
ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findByArtcl(cklstSn: number, cklstArtclSn: number): Promise<CklstRvw[]> {
|
||||
const response = await client.get<CklstRvw[]>(
|
||||
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW}/by-artcl/${cklstSn}/${cklstArtclSn}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findById(
|
||||
cklstRvwSn: number,
|
||||
cklstArtclSn: number,
|
||||
cklstSn: number,
|
||||
): Promise<CklstRvw> {
|
||||
const response = await client.get<CklstRvw>(
|
||||
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function create(body: CklstRvwSaveRequest): Promise<CklstRvwId> {
|
||||
const response = await client.post<CklstRvwId>(
|
||||
ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW,
|
||||
body,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function update(
|
||||
cklstRvwSn: number,
|
||||
cklstArtclSn: number,
|
||||
cklstSn: number,
|
||||
body: CklstRvwUpdateRequest,
|
||||
): Promise<CklstRvwId> {
|
||||
const response = await client.put<CklstRvwId>(
|
||||
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`,
|
||||
body,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function remove(
|
||||
cklstRvwSn: number,
|
||||
cklstArtclSn: number,
|
||||
cklstSn: number,
|
||||
): Promise<CklstRvwId> {
|
||||
const response = await client.delete<CklstRvwId>(
|
||||
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const cklstRvwService = {
|
||||
findPage,
|
||||
findByArtcl,
|
||||
findById,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
} satisfies CklstRvwService;
|
||||
@@ -0,0 +1,281 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type {
|
||||
CklstArtclSaveRequest,
|
||||
CklstArtclSearchParams,
|
||||
CklstArtclUpdateRequest,
|
||||
} from "../model/cklst-artcl-interface";
|
||||
import type { CklstMngSaveRequest, CklstMngSearchParams } from "../model/cklst-mng-interface";
|
||||
import type {
|
||||
CklstRvwSaveRequest,
|
||||
CklstRvwSearchParams,
|
||||
CklstRvwUpdateRequest,
|
||||
} from "../model/cklst-rvw-interface";
|
||||
|
||||
import { cklstArtclService } from "./cklst-artcl-service";
|
||||
import { cklstMngService } from "./cklst-mng-service";
|
||||
import { cklstRvwService } from "./cklst-rvw-service";
|
||||
|
||||
const cklstMngBaseKey = [ENDPOINT.AS_CHECKLIST_SERVICE.CKLST] as const;
|
||||
const cklstArtclBaseKey = [ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL] as const;
|
||||
const cklstRvwBaseKey = [ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW] as const;
|
||||
|
||||
export function useCklstMngPage(params: CklstMngSearchParams) {
|
||||
return useQuery({
|
||||
queryKey: [...cklstMngBaseKey, "page", params],
|
||||
queryFn: () => cklstMngService.findPage(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCklstMng(cklstSn: number | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...cklstMngBaseKey, cklstSn],
|
||||
queryFn: () => cklstMngService.findById(cklstSn!),
|
||||
enabled: cklstSn != null,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateCklstMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (body: CklstMngSaveRequest) => cklstMngService.create(body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: cklstMngBaseKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCklstMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ cklstSn, body }: { cklstSn: number; body: CklstMngSaveRequest }) =>
|
||||
cklstMngService.update(cklstSn, body),
|
||||
onSuccess: (_data, { cklstSn }) => {
|
||||
queryClient.invalidateQueries({ queryKey: cklstMngBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...cklstMngBaseKey, cklstSn],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCklstMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (cklstSn: number) => cklstMngService.remove(cklstSn),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: cklstMngBaseKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCklstArtclPage(params: CklstArtclSearchParams) {
|
||||
const { cklstSn } = params;
|
||||
|
||||
return useQuery({
|
||||
queryKey: [...cklstArtclBaseKey, "page", params],
|
||||
queryFn: () => cklstArtclService.findPage(params),
|
||||
enabled: cklstSn != null,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCklstArtclList(cklstSn: number | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...cklstArtclBaseKey, "by-cklst", cklstSn],
|
||||
queryFn: () => cklstArtclService.findByCklstSn(cklstSn!),
|
||||
enabled: cklstSn != null,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCklstArtcl(params: {
|
||||
cklstArtclSn: number | null | undefined;
|
||||
cklstSn: number | null | undefined;
|
||||
}) {
|
||||
const { cklstArtclSn, cklstSn } = params;
|
||||
|
||||
return useQuery({
|
||||
queryKey: [...cklstArtclBaseKey, cklstArtclSn, cklstSn],
|
||||
queryFn: () => cklstArtclService.findById(cklstArtclSn!, cklstSn!),
|
||||
enabled: cklstArtclSn != null && cklstSn != null,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateCklstArtcl() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (body: CklstArtclSaveRequest) => cklstArtclService.create(body),
|
||||
onSuccess: (_data, { cklstSn }) => {
|
||||
queryClient.invalidateQueries({ queryKey: cklstArtclBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...cklstArtclBaseKey, "page", { cklstSn }],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...cklstArtclBaseKey, "by-cklst", cklstSn],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCklstArtcl() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
cklstArtclSn,
|
||||
cklstSn,
|
||||
body,
|
||||
}: {
|
||||
cklstArtclSn: number;
|
||||
cklstSn: number;
|
||||
body: CklstArtclUpdateRequest;
|
||||
}) => cklstArtclService.update(cklstArtclSn, cklstSn, body),
|
||||
onSuccess: (_data, { cklstArtclSn, cklstSn }) => {
|
||||
queryClient.invalidateQueries({ queryKey: cklstArtclBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...cklstArtclBaseKey, cklstArtclSn, cklstSn],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...cklstArtclBaseKey, "page", { cklstSn }],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...cklstArtclBaseKey, "by-cklst", cklstSn],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCklstArtcl() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
cklstArtclSn,
|
||||
cklstSn,
|
||||
}: {
|
||||
cklstArtclSn: number;
|
||||
cklstSn: number;
|
||||
}) => cklstArtclService.remove(cklstArtclSn, cklstSn),
|
||||
onSuccess: (_data, { cklstSn }) => {
|
||||
queryClient.invalidateQueries({ queryKey: cklstArtclBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...cklstArtclBaseKey, "page", { cklstSn }],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...cklstArtclBaseKey, "by-cklst", cklstSn],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCklstRvwPage(params: CklstRvwSearchParams) {
|
||||
const { cklstSn } = params;
|
||||
|
||||
return useQuery({
|
||||
queryKey: [...cklstRvwBaseKey, "page", params],
|
||||
queryFn: () => cklstRvwService.findPage(params),
|
||||
enabled: cklstSn != null,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCklstRvwByArtcl(
|
||||
cklstSn: number | null | undefined,
|
||||
cklstArtclSn: number | null | undefined,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: [...cklstRvwBaseKey, "by-artcl", cklstSn, cklstArtclSn],
|
||||
queryFn: () => cklstRvwService.findByArtcl(cklstSn!, cklstArtclSn!),
|
||||
enabled: cklstSn != null && cklstArtclSn != null,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCklstRvw(params: {
|
||||
cklstRvwSn: number | null | undefined;
|
||||
cklstArtclSn: number | null | undefined;
|
||||
cklstSn: number | null | undefined;
|
||||
}) {
|
||||
const { cklstRvwSn, cklstArtclSn, cklstSn } = params;
|
||||
|
||||
return useQuery({
|
||||
queryKey: [...cklstRvwBaseKey, cklstRvwSn, cklstArtclSn, cklstSn],
|
||||
queryFn: () => cklstRvwService.findById(cklstRvwSn!, cklstArtclSn!, cklstSn!),
|
||||
enabled: cklstRvwSn != null && cklstArtclSn != null && cklstSn != null,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateCklstRvw() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (body: CklstRvwSaveRequest) => cklstRvwService.create(body),
|
||||
onSuccess: (_data, { cklstSn, cklstArtclSn }) => {
|
||||
queryClient.invalidateQueries({ queryKey: cklstRvwBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...cklstRvwBaseKey, "page", { cklstSn }],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...cklstRvwBaseKey, "by-artcl", cklstSn, cklstArtclSn],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCklstRvw() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
cklstRvwSn,
|
||||
cklstArtclSn,
|
||||
cklstSn,
|
||||
body,
|
||||
}: {
|
||||
cklstRvwSn: number;
|
||||
cklstArtclSn: number;
|
||||
cklstSn: number;
|
||||
body: CklstRvwUpdateRequest;
|
||||
}) => cklstRvwService.update(cklstRvwSn, cklstArtclSn, cklstSn, body),
|
||||
onSuccess: (_data, { cklstRvwSn, cklstArtclSn, cklstSn }) => {
|
||||
queryClient.invalidateQueries({ queryKey: cklstRvwBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...cklstRvwBaseKey, cklstRvwSn, cklstArtclSn, cklstSn],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...cklstRvwBaseKey, "page", { cklstSn }],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...cklstRvwBaseKey, "by-artcl", cklstSn, cklstArtclSn],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCklstRvw() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
cklstRvwSn,
|
||||
cklstArtclSn,
|
||||
cklstSn,
|
||||
}: {
|
||||
cklstRvwSn: number;
|
||||
cklstArtclSn: number;
|
||||
cklstSn: number;
|
||||
}) => cklstRvwService.remove(cklstRvwSn, cklstArtclSn, cklstSn),
|
||||
onSuccess: (_data, { cklstSn, cklstArtclSn }) => {
|
||||
queryClient.invalidateQueries({ queryKey: cklstRvwBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...cklstRvwBaseKey, "page", { cklstSn }],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...cklstRvwBaseKey, "by-artcl", cklstSn, cklstArtclSn],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
export { cklstArtclService } from "./api/cklst-artcl-service";
|
||||
export { cklstMngService } from "./api/cklst-mng-service";
|
||||
export { cklstRvwService } from "./api/cklst-rvw-service";
|
||||
export {
|
||||
useCklstArtcl,
|
||||
useCklstArtclList,
|
||||
useCklstArtclPage,
|
||||
useCklstMng,
|
||||
useCklstMngPage,
|
||||
useCklstRvw,
|
||||
useCklstRvwByArtcl,
|
||||
useCklstRvwPage,
|
||||
useCreateCklstArtcl,
|
||||
useCreateCklstMng,
|
||||
useCreateCklstRvw,
|
||||
useDeleteCklstArtcl,
|
||||
useDeleteCklstMng,
|
||||
useDeleteCklstRvw,
|
||||
useUpdateCklstArtcl,
|
||||
useUpdateCklstMng,
|
||||
useUpdateCklstRvw,
|
||||
} from "./api/queries";
|
||||
export type {
|
||||
CklstArtcl,
|
||||
CklstArtclId,
|
||||
CklstArtclSaveRequest,
|
||||
CklstArtclSearchParams,
|
||||
CklstArtclService,
|
||||
CklstArtclUpdateRequest,
|
||||
} from "./model/cklst-artcl-interface";
|
||||
export type {
|
||||
CklstMng,
|
||||
CklstMngSaveRequest,
|
||||
CklstMngSearchParams,
|
||||
CklstMngService,
|
||||
CklstTreeNode,
|
||||
CklstTreeSearchParams,
|
||||
} from "./model/cklst-mng-interface";
|
||||
export type {
|
||||
CklstRvw,
|
||||
CklstRvwId,
|
||||
CklstRvwSaveRequest,
|
||||
CklstRvwSearchParams,
|
||||
CklstRvwService,
|
||||
CklstRvwUpdateRequest,
|
||||
} from "./model/cklst-rvw-interface";
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { PageableParams, PageResponse } from "@/shared/model";
|
||||
|
||||
export interface CklstArtclId {
|
||||
cklstArtclSn: number;
|
||||
cklstSn: number;
|
||||
}
|
||||
|
||||
export interface CklstArtcl {
|
||||
cklstArtclSn: number;
|
||||
cklstSn: number;
|
||||
cklstArtclNm?: string;
|
||||
useYn?: "Y" | "N";
|
||||
rgtr?: string;
|
||||
rgtrNm?: string;
|
||||
mdfr?: string;
|
||||
regDt?: string;
|
||||
mdfcnDt?: string;
|
||||
}
|
||||
|
||||
export interface CklstArtclSearchParams extends PageableParams {
|
||||
cklstSn?: number;
|
||||
cklstArtclNm?: string;
|
||||
useYn?: "Y" | "N";
|
||||
}
|
||||
|
||||
export interface CklstArtclSaveRequest {
|
||||
cklstSn: number;
|
||||
cklstArtclNm?: string;
|
||||
useYn?: "Y" | "N";
|
||||
}
|
||||
|
||||
export interface CklstArtclUpdateRequest {
|
||||
cklstArtclNm?: string;
|
||||
useYn?: "Y" | "N";
|
||||
}
|
||||
|
||||
export interface CklstArtclService {
|
||||
findPage(params: CklstArtclSearchParams): Promise<PageResponse<CklstArtcl>>;
|
||||
findByCklstSn(cklstSn: number): Promise<CklstArtcl[]>;
|
||||
findById(cklstArtclSn: number, cklstSn: number): Promise<CklstArtcl>;
|
||||
create(body: CklstArtclSaveRequest): Promise<CklstArtclId>;
|
||||
update(
|
||||
cklstArtclSn: number,
|
||||
cklstSn: number,
|
||||
body: CklstArtclUpdateRequest,
|
||||
): Promise<CklstArtclId>;
|
||||
remove(cklstArtclSn: number, cklstSn: number): Promise<CklstArtclId>;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { PageableParams, PageResponse } from "@/shared/model";
|
||||
|
||||
export interface CklstMng {
|
||||
cklstSn: number;
|
||||
cklstNm: string;
|
||||
taskSeComCd: string;
|
||||
useYn?: "Y" | "N";
|
||||
docSeComCd?: string;
|
||||
rgtr?: string;
|
||||
rgtrNm?: string;
|
||||
mdfr?: string;
|
||||
regDt?: string;
|
||||
mdfcnDt?: string;
|
||||
}
|
||||
|
||||
export interface CklstTreeNode {
|
||||
cklstSn: number;
|
||||
cklstNm: string;
|
||||
cklstArtclSn: number;
|
||||
cklstArtclNm: string;
|
||||
cklstRvwSn: number;
|
||||
upCklstRvwSn: number | null;
|
||||
treeLevel: number;
|
||||
rvwCn: string;
|
||||
bchckUseYn: "Y" | "N";
|
||||
sortSeq: number;
|
||||
// optional: backend may include the taskSeComCd per node
|
||||
taskSeComCd?: string;
|
||||
}
|
||||
|
||||
export interface CklstTreeSearchParams {
|
||||
docSeComCd?: string;
|
||||
taskSeComCd?: string;
|
||||
}
|
||||
|
||||
export interface CklstMngSearchParams extends PageableParams {
|
||||
cklstNm?: string;
|
||||
taskSeComCd?: string;
|
||||
useYn?: "Y" | "N";
|
||||
docSeComCd?: string;
|
||||
}
|
||||
|
||||
export interface CklstMngSaveRequest {
|
||||
cklstNm: string;
|
||||
taskSeComCd: string;
|
||||
useYn?: "Y" | "N";
|
||||
docSeComCd?: string;
|
||||
}
|
||||
|
||||
export interface CklstMngService {
|
||||
findPage(params: CklstMngSearchParams): Promise<PageResponse<CklstMng>>;
|
||||
findById(cklstSn: number): Promise<CklstMng>;
|
||||
findTree(params: CklstTreeSearchParams): Promise<CklstTreeNode[]>;
|
||||
create(body: CklstMngSaveRequest): Promise<number>;
|
||||
update(cklstSn: number, body: CklstMngSaveRequest): Promise<number>;
|
||||
remove(cklstSn: number): Promise<number>;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { PageableParams, PageResponse } from "@/shared/model";
|
||||
|
||||
export interface CklstRvwId {
|
||||
cklstRvwSn: number;
|
||||
cklstArtclSn: number;
|
||||
cklstSn: number;
|
||||
}
|
||||
|
||||
export interface CklstRvw {
|
||||
cklstRvwSn: number;
|
||||
cklstArtclSn: number;
|
||||
cklstSn: number;
|
||||
upCklstRvwSn?: number;
|
||||
rvwCnLvNo?: number;
|
||||
rvwCn?: string;
|
||||
bchckUseYn?: "Y" | "N";
|
||||
sortSeq?: number;
|
||||
useYn?: "Y" | "N";
|
||||
rgtr?: string;
|
||||
rgtrNm?: string;
|
||||
mdfr?: string;
|
||||
regDt?: string;
|
||||
mdfcnDt?: string;
|
||||
}
|
||||
|
||||
export interface CklstRvwSearchParams extends PageableParams {
|
||||
cklstSn?: number;
|
||||
cklstArtclSn?: number;
|
||||
useYn?: "Y" | "N";
|
||||
}
|
||||
|
||||
export interface CklstRvwSaveRequest {
|
||||
cklstArtclSn: number;
|
||||
cklstSn: number;
|
||||
upCklstRvwSn?: number;
|
||||
rvwCnLvNo?: number;
|
||||
rvwCn?: string;
|
||||
bchckUseYn?: "Y" | "N";
|
||||
sortSeq?: number;
|
||||
useYn?: "Y" | "N";
|
||||
}
|
||||
|
||||
export interface CklstRvwUpdateRequest {
|
||||
upCklstRvwSn?: number;
|
||||
rvwCnLvNo?: number;
|
||||
rvwCn?: string;
|
||||
bchckUseYn?: "Y" | "N";
|
||||
sortSeq?: number;
|
||||
useYn?: "Y" | "N";
|
||||
}
|
||||
|
||||
export interface CklstRvwService {
|
||||
findPage(params: CklstRvwSearchParams): Promise<PageResponse<CklstRvw>>;
|
||||
findByArtcl(cklstSn: number, cklstArtclSn: number): Promise<CklstRvw[]>;
|
||||
findById(cklstRvwSn: number, cklstArtclSn: number, cklstSn: number): Promise<CklstRvw>;
|
||||
create(body: CklstRvwSaveRequest): Promise<CklstRvwId>;
|
||||
update(
|
||||
cklstRvwSn: number,
|
||||
cklstArtclSn: number,
|
||||
cklstSn: number,
|
||||
body: CklstRvwUpdateRequest,
|
||||
): Promise<CklstRvwId>;
|
||||
remove(cklstRvwSn: number, cklstArtclSn: number, cklstSn: number): Promise<CklstRvwId>;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
|
||||
import {
|
||||
CNSTP_SE_SAVE_MODE,
|
||||
type CnstpSeMngPutRequestDto,
|
||||
type CnstpSeMngResponseDto,
|
||||
type CnstpSeMngSaveRequestDto,
|
||||
type CnstpSeMngSearchDto,
|
||||
type CnstpSeMngService,
|
||||
type CnstpSeMngTreeDto,
|
||||
} from "../model/cnstp-interface";
|
||||
|
||||
const BASE = ENDPOINT.AS_COMMON_SERVICE.CNSTP_SE;
|
||||
|
||||
async function findAll(params: CnstpSeMngSearchDto): Promise<PageResponse<CnstpSeMngResponseDto>> {
|
||||
const response = await client.get<PageResponse<CnstpSeMngResponseDto>>(BASE, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findTree(): Promise<CnstpSeMngTreeDto[]> {
|
||||
const response = await client.get<CnstpSeMngTreeDto[]>(`${BASE}/list/tree`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findById(cnstpSeCd: string): Promise<CnstpSeMngResponseDto> {
|
||||
const response = await client.get<CnstpSeMngResponseDto>(`${BASE}/${cnstpSeCd}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function saveMulti(body: CnstpSeMngSaveRequestDto[]): Promise<CnstpSeMngResponseDto[]> {
|
||||
const response = await client.post<CnstpSeMngResponseDto[]>(BASE, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function update(cnstpSeCd: string, body: CnstpSeMngPutRequestDto): Promise<string> {
|
||||
const response = await client.put<string>(`${BASE}/${cnstpSeCd}`, {
|
||||
...body,
|
||||
cnstpSeCd,
|
||||
mode: CNSTP_SE_SAVE_MODE.UPDATE,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function deleteCnstpSeCd(cnstpSeCd: string): Promise<string> {
|
||||
const response = await client.delete<string>(`${BASE}/${cnstpSeCd}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const cnstpSeMngService = {
|
||||
findAll,
|
||||
findTree,
|
||||
findById,
|
||||
saveMulti,
|
||||
update,
|
||||
delete: deleteCnstpSeCd,
|
||||
} satisfies CnstpSeMngService;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type {
|
||||
CnstpSeMngPutRequestDto,
|
||||
CnstpSeMngSaveRequestDto,
|
||||
CnstpSeMngSearchDto,
|
||||
} from "../model/cnstp-interface";
|
||||
|
||||
import { cnstpSeMngService } from "./cnstp-service";
|
||||
|
||||
const cnstpSeMngBaseKey = [ENDPOINT.AS_COMMON_SERVICE.CNSTP_SE] as const;
|
||||
|
||||
export function useCnstpSeMngPage(params: CnstpSeMngSearchDto) {
|
||||
return useQuery({
|
||||
queryKey: [...cnstpSeMngBaseKey, "findAll", params],
|
||||
queryFn: () => cnstpSeMngService.findAll(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCnstpSeMngTree() {
|
||||
return useQuery({
|
||||
queryKey: [...cnstpSeMngBaseKey, "findTree"],
|
||||
queryFn: () => cnstpSeMngService.findTree(),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCnstpSeMng(cnstpSeCd: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...cnstpSeMngBaseKey, "findById", cnstpSeCd],
|
||||
queryFn: () => cnstpSeMngService.findById(cnstpSeCd!),
|
||||
enabled: Boolean(cnstpSeCd),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveMultiCnstpSeMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (body: CnstpSeMngSaveRequestDto[]) => cnstpSeMngService.saveMulti(body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: cnstpSeMngBaseKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCnstpSeMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ cnstpSeCd, body }: { cnstpSeCd: string; body: CnstpSeMngPutRequestDto }) =>
|
||||
cnstpSeMngService.update(cnstpSeCd, body),
|
||||
onSuccess: (_data, { cnstpSeCd }) => {
|
||||
queryClient.invalidateQueries({ queryKey: cnstpSeMngBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...cnstpSeMngBaseKey, "findById", cnstpSeCd],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCnstpSeMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (cnstpSeCd: string) => cnstpSeMngService.delete(cnstpSeCd),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: cnstpSeMngBaseKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export { cnstpSeMngService } from "./api/cnstp-service";
|
||||
export {
|
||||
useCnstpSeMng,
|
||||
useCnstpSeMngPage,
|
||||
useCnstpSeMngTree,
|
||||
useDeleteCnstpSeMng,
|
||||
useSaveMultiCnstpSeMng,
|
||||
useUpdateCnstpSeMng,
|
||||
} from "./api/queries";
|
||||
export type {
|
||||
BizSclTreeDto,
|
||||
CnstpSeMngResponseDto,
|
||||
CnstpSeMngSaveRequestDto,
|
||||
CnstpSeMngSearchDto,
|
||||
CnstpSeMngService,
|
||||
CnstpSeMngTreeDto,
|
||||
CnstpSeSaveMode,
|
||||
} from "./model/cnstp-interface";
|
||||
export { CNSTP_SE_SAVE_MODE } from "./model/cnstp-interface";
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { PageableParams, PageResponse } from "@/shared/model";
|
||||
|
||||
export const CNSTP_SE_SAVE_MODE = {
|
||||
CREATE: "C",
|
||||
UPDATE: "U",
|
||||
DELETE: "D",
|
||||
} as const;
|
||||
|
||||
export type CnstpSeSaveMode = (typeof CNSTP_SE_SAVE_MODE)[keyof typeof CNSTP_SE_SAVE_MODE];
|
||||
|
||||
/** 백엔드 CnstpSeMngResponseDto */
|
||||
export interface CnstpSeMngResponseDto {
|
||||
cnstpSeCd: string;
|
||||
cnstpSeCdNm: string;
|
||||
upCnstpCd?: string;
|
||||
cdLvNo: number;
|
||||
useYn: string;
|
||||
expln?: string;
|
||||
rgtr?: string;
|
||||
rgtrNm?: string;
|
||||
mdfr?: string;
|
||||
regDt?: string;
|
||||
mdfcnDt?: string;
|
||||
}
|
||||
|
||||
/** 백엔드 CnstpSeMngSearchDto + Pageable */
|
||||
export interface CnstpSeMngSearchDto extends PageableParams {
|
||||
cnstpSeCd?: string;
|
||||
cnstpSeCdNm?: string;
|
||||
useYn?: string;
|
||||
}
|
||||
|
||||
/** 백엔드 CnstpSeMngSaveRequestDto */
|
||||
export interface CnstpSeMngSaveRequestDto {
|
||||
cnstpSeCd: string;
|
||||
mode: CnstpSeSaveMode;
|
||||
cnstpSeCdNm?: string;
|
||||
upCnstpCd?: string;
|
||||
cdLvNo?: number;
|
||||
useYn?: string;
|
||||
expln?: string;
|
||||
}
|
||||
|
||||
/** PUT /{cnstpSeCd} 요청 (mode는 서비스에서 U로 설정) */
|
||||
export interface CnstpSeMngPutRequestDto {
|
||||
cnstpSeCd: string;
|
||||
cnstpSeCdNm?: string;
|
||||
upCnstpCd?: string;
|
||||
cdLvNo?: number;
|
||||
useYn?: string;
|
||||
expln?: string;
|
||||
}
|
||||
|
||||
/** 백엔드 CnstpSeMngTreeDto */
|
||||
export interface CnstpSeMngTreeDto {
|
||||
indCnstpSeCdNm: string;
|
||||
cnstpSeCd: string;
|
||||
cnstpSeCdNm: string;
|
||||
upCnstpCd?: string;
|
||||
useYn: string;
|
||||
treeLevel: number;
|
||||
treePath: string;
|
||||
}
|
||||
|
||||
/** 백엔드 BizSclTreeDto */
|
||||
export interface BizSclTreeDto {
|
||||
indCnstpSeCdNm: string;
|
||||
cnstpSeCd: string;
|
||||
cnstpSeCdNm: string;
|
||||
useYn: string;
|
||||
treeLevel: number;
|
||||
treePath: string;
|
||||
bscPlanQty?: number;
|
||||
bgtRfltQty?: number;
|
||||
tstmDesignQty?: number;
|
||||
}
|
||||
|
||||
/** Controller public 메서드명·시그니처와 동일 */
|
||||
export interface CnstpSeMngService {
|
||||
findAll(params: CnstpSeMngSearchDto): Promise<PageResponse<CnstpSeMngResponseDto>>;
|
||||
findTree(): Promise<CnstpSeMngTreeDto[]>;
|
||||
findById(cnstpSeCd: string): Promise<CnstpSeMngResponseDto>;
|
||||
saveMulti(body: CnstpSeMngSaveRequestDto[]): Promise<CnstpSeMngResponseDto[]>;
|
||||
update(cnstpSeCd: string, body: CnstpSeMngPutRequestDto): Promise<string>;
|
||||
delete(cnstpSeCd: string): Promise<string>;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type { Code, CodeService } from "../model/code-interface";
|
||||
|
||||
async function getList(upCd: string): Promise<Code[]> {
|
||||
const response = await client.get<Code[]>(`${ENDPOINT.CMS_SERVICE.CODE}/${upCd}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function getListByUseYn(upCd: string, useYn: string): Promise<Code[]> {
|
||||
const response = await client.get<Code[]>(`${ENDPOINT.CMS_SERVICE.CODE}/${upCd}/${useYn}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function getCodeName(upCd: string, lwpoCd: string): Promise<string> {
|
||||
const response = await client.get<string>(
|
||||
`${ENDPOINT.CMS_SERVICE.CODE}/${upCd}/lwpoCd/${lwpoCd}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const codeService = {
|
||||
getList,
|
||||
getListByUseYn,
|
||||
getCodeName,
|
||||
} satisfies CodeService;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import { codeService } from "./code-service";
|
||||
|
||||
const codeBaseKey = [ENDPOINT.CMS_SERVICE.CODE] as const;
|
||||
|
||||
const CODE_STALE_TIME = 1000 * 60 * 10;
|
||||
|
||||
export function useCodeList(upCd: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...codeBaseKey, upCd],
|
||||
queryFn: () => codeService.getList(upCd!),
|
||||
enabled: Boolean(upCd),
|
||||
staleTime: CODE_STALE_TIME,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCodeListByUseYn(upCd: string | undefined, useYn: string) {
|
||||
return useQuery({
|
||||
queryKey: [...codeBaseKey, upCd, useYn],
|
||||
queryFn: () => codeService.getListByUseYn(upCd!, useYn),
|
||||
enabled: Boolean(upCd && useYn),
|
||||
staleTime: CODE_STALE_TIME,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCodeName(upCd: string | undefined, lwpoCd: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...codeBaseKey, upCd, "name", lwpoCd],
|
||||
queryFn: () => codeService.getCodeName(upCd!, lwpoCd!),
|
||||
enabled: Boolean(upCd && lwpoCd),
|
||||
staleTime: CODE_STALE_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { codeService } from "./api/code-service";
|
||||
export { useCodeList, useCodeListByUseYn, useCodeName } from "./api/queries";
|
||||
export { findCodeLabel } from "./lib/find-code-label";
|
||||
export type { Code, CodeService } from "./model/code-interface";
|
||||
export { CodeSelect } from "./ui/code-select";
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { Code } from "../model/code-interface";
|
||||
|
||||
export function findCodeLabel(codes: Code[] | undefined, lwpoCd?: string): string {
|
||||
if (!lwpoCd) return "";
|
||||
if (!codes?.length) return lwpoCd;
|
||||
return codes.find((c) => c.lwpoCd === lwpoCd)?.lwpoCdNm ?? lwpoCd;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/** cms-service CodeResponseDto */
|
||||
export interface Code {
|
||||
lwpoCd: string;
|
||||
upCd: string;
|
||||
lwpoCdNm: string;
|
||||
unit?: string;
|
||||
prntLwpoCd?: string;
|
||||
}
|
||||
|
||||
export interface CodeService {
|
||||
getList(upCd: string): Promise<Code[]>;
|
||||
getListByUseYn(upCd: string, useYn: string): Promise<Code[]>;
|
||||
getCodeName(upCd: string, lwpoCd: string): Promise<string>;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
cn,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@common/components";
|
||||
|
||||
import { useCodeList, useCodeListByUseYn } from "../api/queries";
|
||||
|
||||
interface CodeSelectProps extends React.ComponentPropsWithoutRef<typeof Select> {
|
||||
upCd: string;
|
||||
useYn?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function CodeSelect({
|
||||
upCd,
|
||||
useYn,
|
||||
placeholder = "선택",
|
||||
className,
|
||||
...props
|
||||
}: CodeSelectProps) {
|
||||
const listQuery = useCodeList(useYn ? undefined : upCd);
|
||||
const listByUseYnQuery = useCodeListByUseYn(useYn ? upCd : undefined, useYn ?? "");
|
||||
|
||||
const codes = useYn ? listByUseYnQuery.data : listQuery.data;
|
||||
const isLoading = useYn ? listByUseYnQuery.isPending : listQuery.isPending;
|
||||
|
||||
return (
|
||||
<Select {...props} disabled={props.disabled || isLoading}>
|
||||
<SelectTrigger className={cn("w-[12rem]", className)}>
|
||||
<SelectValue placeholder={isLoading ? "로딩 중…" : placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{codes?.map((item) => (
|
||||
<SelectItem key={item.lwpoCd} value={item.lwpoCd}>
|
||||
{item.lwpoCdNm}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export { CodeSelect };
|
||||
@@ -0,0 +1,54 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
|
||||
import type {
|
||||
ElaprApln,
|
||||
ElaprAplnListParams,
|
||||
ElaprAplnReorderRequest,
|
||||
ElaprAplnSaveRequest,
|
||||
ElaprAplnService,
|
||||
ElaprAplnUpdateRequest,
|
||||
} from "../model/elapr-apln-interface";
|
||||
|
||||
async function findPage(params: ElaprAplnListParams): Promise<PageResponse<ElaprApln>> {
|
||||
const response = await client.get<PageResponse<ElaprApln>>(ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN, {
|
||||
params,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findById(aplnId: string): Promise<ElaprApln> {
|
||||
const response = await client.get<ElaprApln>(`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN}/${aplnId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function saveMulti(body: ElaprAplnSaveRequest[]): Promise<ElaprApln[]> {
|
||||
const response = await client.post<ElaprApln[]>(ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function update(aplnId: string, body: ElaprAplnUpdateRequest): Promise<ElaprApln> {
|
||||
const response = await client.put<ElaprApln>(
|
||||
`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN}/${aplnId}`,
|
||||
body,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function remove(aplnId: string): Promise<void> {
|
||||
await client.delete(`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN}/${aplnId}`);
|
||||
}
|
||||
|
||||
async function reorder(body: ElaprAplnReorderRequest): Promise<void> {
|
||||
await client.put(`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN}/reorder`, body);
|
||||
}
|
||||
|
||||
export const elaprAplnService = {
|
||||
findPage,
|
||||
findById,
|
||||
saveMulti,
|
||||
update,
|
||||
remove,
|
||||
reorder,
|
||||
} satisfies ElaprAplnService;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type {
|
||||
ElaprDmndResponse,
|
||||
ElaprDmndSaveRequest,
|
||||
ElaprDmndService,
|
||||
} from "../model/elapr-dmnd-interface";
|
||||
|
||||
async function create(body: ElaprDmndSaveRequest): Promise<ElaprDmndResponse> {
|
||||
const response = await client.post<ElaprDmndResponse>(ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const ElaprDmndApi: ElaprDmndService = {
|
||||
create,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { client } from "@/shared/api";
|
||||
|
||||
export async function processElaprPrgrs(elaprPrgrsId: string, atrzPrgrsSttsComCd = "ATRZ_STTS_003"): Promise<void> {
|
||||
await client.put(`/as-elapr-service/api/v1/elapr-prgrs-stts/${elaprPrgrsId}/process`, {
|
||||
atrzPrgrsSttsComCd,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
|
||||
import type {
|
||||
ElaprTmplt,
|
||||
ElaprTmpltSaveRequest,
|
||||
ElaprTmpltSearchParams,
|
||||
ElaprTmpltService,
|
||||
ElaprTmpltUpdateRequest,
|
||||
} from "../model/elapr-tmplt-interface";
|
||||
|
||||
async function findPage(params: ElaprTmpltSearchParams): Promise<PageResponse<ElaprTmplt>> {
|
||||
const response = await client.get<PageResponse<ElaprTmplt>>(
|
||||
ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findById(elaprTmpltId: string): Promise<ElaprTmplt> {
|
||||
const response = await client.get<ElaprTmplt>(
|
||||
`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT}/${elaprTmpltId}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function create(body: ElaprTmpltSaveRequest): Promise<ElaprTmplt> {
|
||||
const response = await client.post<ElaprTmplt>(ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function update(elaprTmpltId: string, body: ElaprTmpltUpdateRequest): Promise<ElaprTmplt> {
|
||||
const response = await client.put<ElaprTmplt>(
|
||||
`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT}/${elaprTmpltId}`,
|
||||
body,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function remove(elaprTmpltId: string): Promise<void> {
|
||||
await client.delete(`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT}/${elaprTmpltId}`);
|
||||
}
|
||||
|
||||
export const elaprTmpltService = {
|
||||
findPage,
|
||||
findById,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
} satisfies ElaprTmpltService;
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type {
|
||||
ElaprAplnListParams,
|
||||
ElaprAplnReorderRequest,
|
||||
ElaprAplnSaveRequest,
|
||||
ElaprAplnUpdateRequest,
|
||||
} from "../model/elapr-apln-interface";
|
||||
import type {
|
||||
ElaprTmpltSaveRequest,
|
||||
ElaprTmpltSearchParams,
|
||||
ElaprTmpltUpdateRequest,
|
||||
} from "../model/elapr-tmplt-interface";
|
||||
|
||||
import { elaprAplnService } from "./elapr-apln-service";
|
||||
import { elaprTmpltService } from "./elapr-tmplt-service";
|
||||
|
||||
const elaprTmpltBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT] as const;
|
||||
const elaprAplnBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN] as const;
|
||||
|
||||
export function useElaprTmpltPage(params: ElaprTmpltSearchParams) {
|
||||
return useQuery({
|
||||
queryKey: [...elaprTmpltBaseKey, "page", params],
|
||||
queryFn: () => elaprTmpltService.findPage(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useElaprTmplt(elaprTmpltId: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...elaprTmpltBaseKey, elaprTmpltId],
|
||||
queryFn: () => elaprTmpltService.findById(elaprTmpltId!),
|
||||
enabled: Boolean(elaprTmpltId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateElaprTmplt() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (body: ElaprTmpltSaveRequest) => elaprTmpltService.create(body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: elaprTmpltBaseKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateElaprTmplt() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
elaprTmpltId,
|
||||
body,
|
||||
}: {
|
||||
elaprTmpltId: string;
|
||||
body: ElaprTmpltUpdateRequest;
|
||||
}) => elaprTmpltService.update(elaprTmpltId, body),
|
||||
onSuccess: (_data, { elaprTmpltId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: elaprTmpltBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...elaprTmpltBaseKey, elaprTmpltId],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteElaprTmplt() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (elaprTmpltId: string) => elaprTmpltService.remove(elaprTmpltId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: elaprTmpltBaseKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useElaprAplnPage(params: ElaprAplnListParams) {
|
||||
const { elaprTmpltId } = params;
|
||||
|
||||
return useQuery({
|
||||
queryKey: [...elaprAplnBaseKey, "page", params],
|
||||
queryFn: () => elaprAplnService.findPage(params),
|
||||
enabled: Boolean(elaprTmpltId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useElaprApln(aplnId: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...elaprAplnBaseKey, aplnId],
|
||||
queryFn: () => elaprAplnService.findById(aplnId!),
|
||||
enabled: Boolean(aplnId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveMultiElaprApln() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
body,
|
||||
}: {
|
||||
body: ElaprAplnSaveRequest[];
|
||||
elaprTmpltId: string;
|
||||
}) => elaprAplnService.saveMulti(body),
|
||||
onSuccess: (_data, { elaprTmpltId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: elaprAplnBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...elaprAplnBaseKey, "page", { elaprTmpltId }],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateElaprApln() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
aplnId,
|
||||
body,
|
||||
}: {
|
||||
aplnId: string;
|
||||
body: ElaprAplnUpdateRequest;
|
||||
}) => elaprAplnService.update(aplnId, body),
|
||||
onSuccess: (_data, { body, aplnId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: elaprAplnBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...elaprAplnBaseKey, aplnId],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...elaprAplnBaseKey, "page", { elaprTmpltId: body.elaprTmpltId }],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteElaprApln() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
aplnId,
|
||||
}: {
|
||||
aplnId: string;
|
||||
elaprTmpltId: string;
|
||||
}) => elaprAplnService.remove(aplnId),
|
||||
onSuccess: (_data, { elaprTmpltId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: elaprAplnBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...elaprAplnBaseKey, "page", { elaprTmpltId }],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useReorderElaprApln() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
body,
|
||||
}: {
|
||||
body: ElaprAplnReorderRequest;
|
||||
elaprTmpltId: string;
|
||||
}) => elaprAplnService.reorder(body),
|
||||
onSuccess: (_data, { elaprTmpltId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: elaprAplnBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...elaprAplnBaseKey, "page", { elaprTmpltId }],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export { elaprAplnService } from "./api/elapr-apln-service";
|
||||
export { elaprTmpltService } from "./api/elapr-tmplt-service";
|
||||
export {
|
||||
useCreateElaprTmplt,
|
||||
useDeleteElaprApln,
|
||||
useDeleteElaprTmplt,
|
||||
useElaprApln,
|
||||
useElaprAplnPage,
|
||||
useElaprTmplt,
|
||||
useElaprTmpltPage,
|
||||
useReorderElaprApln,
|
||||
useSaveMultiElaprApln,
|
||||
useUpdateElaprApln,
|
||||
useUpdateElaprTmplt,
|
||||
} from "./api/queries";
|
||||
export {
|
||||
ELAPR_APLN_SAVE_MODE,
|
||||
type ElaprApln,
|
||||
type ElaprAplnListParams,
|
||||
type ElaprAplnReorderItem,
|
||||
type ElaprAplnReorderRequest,
|
||||
type ElaprAplnSaveMode,
|
||||
type ElaprAplnSaveRequest,
|
||||
type ElaprAplnService,
|
||||
type ElaprAplnUpdateRequest,
|
||||
} from "./model/elapr-apln-interface";
|
||||
export type {
|
||||
ElaprTmplt,
|
||||
ElaprTmpltSaveRequest,
|
||||
ElaprTmpltSearchParams,
|
||||
ElaprTmpltService,
|
||||
ElaprTmpltUpdateRequest,
|
||||
} from "./model/elapr-tmplt-interface";
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { PageableParams, PageResponse } from "@/shared/model";
|
||||
|
||||
export const ELAPR_APLN_SAVE_MODE = {
|
||||
CREATE: "C",
|
||||
UPDATE: "U",
|
||||
DELETE: "D",
|
||||
} as const;
|
||||
|
||||
export type ElaprAplnSaveMode =
|
||||
(typeof ELAPR_APLN_SAVE_MODE)[keyof typeof ELAPR_APLN_SAVE_MODE];
|
||||
|
||||
export interface ElaprApln {
|
||||
aplnId: string;
|
||||
elaprTmpltId: string;
|
||||
aprvrUuid?: string;
|
||||
aprvrNm?: string;
|
||||
atrzSeComCd?: string;
|
||||
atrzSeComNm?: string;
|
||||
atrzSeqNo?: number;
|
||||
regDt?: string;
|
||||
rgtrUuid?: string;
|
||||
mdfcnDt?: string;
|
||||
mdfrUuid?: string;
|
||||
}
|
||||
|
||||
export interface ElaprAplnListParams extends PageableParams {
|
||||
elaprTmpltId?: string;
|
||||
}
|
||||
|
||||
export interface ElaprAplnSaveRequest {
|
||||
mode: ElaprAplnSaveMode;
|
||||
aplnId?: string;
|
||||
elaprTmpltId?: string;
|
||||
aprvrUuid?: string;
|
||||
atrzSeComCd?: string;
|
||||
atrzSeqNo?: number;
|
||||
}
|
||||
|
||||
export interface ElaprAplnUpdateRequest {
|
||||
elaprTmpltId: string;
|
||||
aprvrUuid?: string;
|
||||
atrzSeComCd?: string;
|
||||
atrzSeqNo?: number;
|
||||
}
|
||||
|
||||
export interface ElaprAplnReorderItem {
|
||||
aplnId: string;
|
||||
atrzSeqNo: number;
|
||||
}
|
||||
|
||||
export interface ElaprAplnReorderRequest {
|
||||
items: ElaprAplnReorderItem[];
|
||||
}
|
||||
|
||||
export interface ElaprAplnService {
|
||||
findPage(params: ElaprAplnListParams): Promise<PageResponse<ElaprApln>>;
|
||||
findById(aplnId: string): Promise<ElaprApln>;
|
||||
saveMulti(body: ElaprAplnSaveRequest[]): Promise<ElaprApln[]>;
|
||||
update(aplnId: string, body: ElaprAplnUpdateRequest): Promise<ElaprApln>;
|
||||
remove(aplnId: string): Promise<void>;
|
||||
reorder(body: ElaprAplnReorderRequest): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export interface ElaprDmndSaveRequest {
|
||||
elaprDocNo: string;
|
||||
elaprTmpltId: string;
|
||||
dmndTtl: string;
|
||||
dmndCn: string;
|
||||
}
|
||||
|
||||
export interface ElaprDmndResponse {
|
||||
elaprDmndId: string;
|
||||
elaprDocNo: string;
|
||||
elaprTmpltId: string;
|
||||
dmndTtl: string;
|
||||
dmndCn: string;
|
||||
atrzSttsComCd: string;
|
||||
rqstrUuid: string;
|
||||
regDt: string;
|
||||
updtDt: string;
|
||||
}
|
||||
|
||||
export interface ElaprDmndService {
|
||||
create(body: ElaprDmndSaveRequest): Promise<ElaprDmndResponse>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { PageableParams, PageResponse } from "@/shared/model";
|
||||
|
||||
export interface ElaprTmplt {
|
||||
elaprTmpltId: string;
|
||||
elaprTmpltNm: string;
|
||||
elaprTmpltExpln?: string;
|
||||
docSeComCd?: string;
|
||||
docSeComNm?: string;
|
||||
regDt?: string;
|
||||
rgtrUuid?: string;
|
||||
mdfcnDt?: string;
|
||||
mdfrUuid?: string;
|
||||
}
|
||||
|
||||
export interface ElaprTmpltSearchParams extends PageableParams {
|
||||
elaprTmpltNm?: string;
|
||||
docSeComCd?: string;
|
||||
regDtStart?: string;
|
||||
regDtEnd?: string;
|
||||
}
|
||||
|
||||
export interface ElaprTmpltSaveRequest {
|
||||
elaprTmpltNm: string;
|
||||
elaprTmpltExpln?: string;
|
||||
docSeComCd?: string;
|
||||
}
|
||||
|
||||
export interface ElaprTmpltUpdateRequest {
|
||||
elaprTmpltNm: string;
|
||||
elaprTmpltExpln?: string;
|
||||
docSeComCd?: string;
|
||||
}
|
||||
|
||||
export interface ElaprTmpltService {
|
||||
findPage(params: ElaprTmpltSearchParams): Promise<PageResponse<ElaprTmplt>>;
|
||||
findById(elaprTmpltId: string): Promise<ElaprTmplt>;
|
||||
create(body: ElaprTmpltSaveRequest): Promise<ElaprTmplt>;
|
||||
update(elaprTmpltId: string, body: ElaprTmpltUpdateRequest): Promise<ElaprTmplt>;
|
||||
remove(elaprTmpltId: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
|
||||
import type {
|
||||
EsntlAtchFileMng,
|
||||
EsntlAtchFileMngSaveRequest,
|
||||
EsntlAtchFileMngSearchParams,
|
||||
EsntlAtchFileMngService,
|
||||
EsntlAtchFileMngTreeResponseDto,
|
||||
EsntlAtchFileMngTreeSearchParams,
|
||||
} from "../model/esntl-interface";
|
||||
|
||||
async function findTree(
|
||||
params: EsntlAtchFileMngTreeSearchParams,
|
||||
): Promise<EsntlAtchFileMngTreeResponseDto[]> {
|
||||
const response = await client.get<EsntlAtchFileMngTreeResponseDto[]>(
|
||||
`${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/list/tree`,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findPage(
|
||||
params: EsntlAtchFileMngSearchParams,
|
||||
): Promise<PageResponse<EsntlAtchFileMng>> {
|
||||
const response = await client.get<PageResponse<EsntlAtchFileMng>>(
|
||||
ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findById(esntlAtchFileId: string): Promise<EsntlAtchFileMng> {
|
||||
const response = await client.get<EsntlAtchFileMng>(
|
||||
`${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function create(body: EsntlAtchFileMngSaveRequest): Promise<string> {
|
||||
const response = await client.post<string>(ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function update(esntlAtchFileId: string, body: EsntlAtchFileMngSaveRequest): Promise<string> {
|
||||
const response = await client.put<string>(
|
||||
`${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`,
|
||||
body,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function remove(esntlAtchFileId: string): Promise<string> {
|
||||
const response = await client.delete<string>(
|
||||
`${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const esntlAtchFileMngService = {
|
||||
findTree,
|
||||
findPage,
|
||||
findById,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
} satisfies EsntlAtchFileMngService;
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type {
|
||||
EsntlAtchFileMngSaveRequest,
|
||||
EsntlAtchFileMngSearchParams,
|
||||
EsntlAtchFileMngTreeSearchParams,
|
||||
} from "../model/esntl-interface";
|
||||
|
||||
import { esntlAtchFileMngService } from "./esntl-service";
|
||||
|
||||
const esntlAtchFileMngBaseKey = [ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE] as const;
|
||||
|
||||
export function useEsntlAtchFileMngTree(params: EsntlAtchFileMngTreeSearchParams) {
|
||||
return useQuery({
|
||||
queryKey: [...esntlAtchFileMngBaseKey, "tree", params],
|
||||
queryFn: () => esntlAtchFileMngService.findTree(params),
|
||||
enabled: Boolean(params.docSeComCd),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEsntlAtchFileMngPage(params: EsntlAtchFileMngSearchParams) {
|
||||
return useQuery({
|
||||
queryKey: [...esntlAtchFileMngBaseKey, "page", params],
|
||||
queryFn: () => esntlAtchFileMngService.findPage(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useEsntlAtchFileMng(esntlAtchFileId: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...esntlAtchFileMngBaseKey, esntlAtchFileId],
|
||||
queryFn: () => esntlAtchFileMngService.findById(esntlAtchFileId!),
|
||||
enabled: Boolean(esntlAtchFileId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateEsntlAtchFileMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (body: EsntlAtchFileMngSaveRequest) => esntlAtchFileMngService.create(body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: esntlAtchFileMngBaseKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateEsntlAtchFileMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
esntlAtchFileId,
|
||||
body,
|
||||
}: {
|
||||
esntlAtchFileId: string;
|
||||
body: EsntlAtchFileMngSaveRequest;
|
||||
}) => esntlAtchFileMngService.update(esntlAtchFileId, body),
|
||||
onSuccess: (_data, { esntlAtchFileId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: esntlAtchFileMngBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...esntlAtchFileMngBaseKey, esntlAtchFileId],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteEsntlAtchFileMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (esntlAtchFileId: string) => esntlAtchFileMngService.remove(esntlAtchFileId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: esntlAtchFileMngBaseKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export { esntlAtchFileMngService } from "./api/esntl-service";
|
||||
export {
|
||||
useCreateEsntlAtchFileMng,
|
||||
useDeleteEsntlAtchFileMng,
|
||||
useEsntlAtchFileMng,
|
||||
useEsntlAtchFileMngPage,
|
||||
useEsntlAtchFileMngTree,
|
||||
useUpdateEsntlAtchFileMng,
|
||||
} from "./api/queries";
|
||||
export type {
|
||||
EsntlAtchFileMng,
|
||||
EsntlAtchFileMngSaveRequest,
|
||||
EsntlAtchFileMngSearchParams,
|
||||
EsntlAtchFileMngService,
|
||||
EsntlAtchFileMngTreeResponseDto,
|
||||
EsntlAtchFileMngTreeSearchParams,
|
||||
} from "./model/esntl-interface";
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { PageableParams, PageResponse } from "@/shared/model";
|
||||
|
||||
/** 필수첨부파일관리 트리 조회 응답 DTO */
|
||||
export interface EsntlAtchFileMngTreeResponseDto {
|
||||
/** 필수첨부파일아이디 */
|
||||
esntlAtchFileId: string;
|
||||
/** 문서구분공통코드 */
|
||||
docSeComCd: string;
|
||||
/** 필수첨부파일명 */
|
||||
esntlAtchFileNm: string;
|
||||
/** 필수첨부여부 ("Y" | "N") */
|
||||
esntlAtchYn: "Y" | "N";
|
||||
/** 필수첨부파일비고 (파일형식 등) */
|
||||
esntlAtchFileRmrk: string;
|
||||
}
|
||||
|
||||
/** 필수첨부파일관리 엔티티 (목록/상세 공용 응답 DTO) */
|
||||
export interface EsntlAtchFileMng {
|
||||
/** 필수첨부파일아이디 */
|
||||
esntlAtchFileId: string;
|
||||
/** 문서구분공통코드 */
|
||||
docSeComCd: string;
|
||||
/** 필수첨부파일명 */
|
||||
esntlAtchFileNm: string;
|
||||
/** 필수첨부여부 ("Y" | "N") */
|
||||
esntlAtchYn: "Y" | "N";
|
||||
/** 필수첨부파일비고 */
|
||||
esntlAtchFileRmrk?: string;
|
||||
/** 등록자UUID */
|
||||
rgtr?: string;
|
||||
/** 등록자명 */
|
||||
rgtrNm?: string;
|
||||
/** 수정자UUID */
|
||||
mdfr?: string;
|
||||
/** 등록일시 (yyyy-MM-dd HH:mm:ss) */
|
||||
regDt?: string;
|
||||
/** 수정일시 */
|
||||
mdfcnDt?: string;
|
||||
}
|
||||
|
||||
/** 필수첨부파일관리 트리 조회 파라미터 */
|
||||
export interface EsntlAtchFileMngTreeSearchParams {
|
||||
/** 문서구분공통코드 (예: "INSTL") */
|
||||
docSeComCd: string;
|
||||
}
|
||||
|
||||
/** 필수첨부파일관리 목록 조회 파라미터 (페이징 포함) */
|
||||
export interface EsntlAtchFileMngSearchParams extends PageableParams {
|
||||
/** 문서구분공통코드 (예: "INSTL") */
|
||||
docSeComCd?: string;
|
||||
/** 필수첨부파일명 */
|
||||
esntlAtchFileNm?: string;
|
||||
/** 필수첨부여부 ("Y" | "N") */
|
||||
esntlAtchYn?: "Y" | "N";
|
||||
}
|
||||
|
||||
/** 필수첨부파일관리 서비스 인터페이스 */
|
||||
export interface EsntlAtchFileMngService {
|
||||
/** 필수첨부파일 트리 조회 */
|
||||
findTree(params: EsntlAtchFileMngTreeSearchParams): Promise<EsntlAtchFileMngTreeResponseDto[]>;
|
||||
/** 필수첨부파일관리 목록 조회 (페이징) */
|
||||
findPage(params: EsntlAtchFileMngSearchParams): Promise<PageResponse<EsntlAtchFileMng>>;
|
||||
/** 필수첨부파일관리 상세 조회 */
|
||||
findById(esntlAtchFileId: string): Promise<EsntlAtchFileMng>;
|
||||
/** 필수첨부파일관리 등록 */
|
||||
create(body: EsntlAtchFileMngSaveRequest): Promise<string>;
|
||||
/** 필수첨부파일관리 수정 */
|
||||
update(esntlAtchFileId: string, body: EsntlAtchFileMngSaveRequest): Promise<string>;
|
||||
/** 필수첨부파일관리 삭제 */
|
||||
remove(esntlAtchFileId: string): Promise<string>;
|
||||
}
|
||||
|
||||
/** 필수첨부파일관리 저장 요청 DTO */
|
||||
export interface EsntlAtchFileMngSaveRequest {
|
||||
/** 문서구분공통코드 */
|
||||
docSeComCd: string;
|
||||
/** 필수첨부파일명 */
|
||||
esntlAtchFileNm?: string;
|
||||
/** 필수첨부여부 ("Y" | "N") */
|
||||
esntlAtchYn?: "Y" | "N";
|
||||
/** 필수첨부파일비고 */
|
||||
esntlAtchFileRmrk?: string;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type {
|
||||
InstlSncnApplySaveRequestDto,
|
||||
InstlSncnMng,
|
||||
InstlSncnMngSearchParams,
|
||||
InstlSncnMngService,
|
||||
SpringPage,
|
||||
InstlSncnMngDetResponse,
|
||||
} from "../model/instl-sncn-interface";
|
||||
|
||||
export class InstlSncnApiService implements InstlSncnMngService {
|
||||
public async getPage(params: InstlSncnMngSearchParams): Promise<SpringPage<InstlSncnMng>> {
|
||||
const response = await client.get<SpringPage<InstlSncnMng>>(ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN, {
|
||||
params,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
public async getById(id: string): Promise<InstlSncnMngDetResponse> {
|
||||
const response = await client.get<InstlSncnMngDetResponse>(`${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
public async saveApply(payload: InstlSncnApplySaveRequestDto): Promise<string> {
|
||||
const response = await client.post<string>(
|
||||
`${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/save-apply`,
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
public async techReview(payload: InstlSncnApplySaveRequestDto): Promise<string> {
|
||||
const response = await client.post<string>(
|
||||
`${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/tech-review`,
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const InstlSncnApi = new InstlSncnApiService();
|
||||
@@ -0,0 +1,12 @@
|
||||
export { InstlSncnApi } from "./api/instl-sncn-service";
|
||||
export {
|
||||
INSTL_SNCN_REGISTER_SELECTION_KEY,
|
||||
type InstlSncnApplySaveRequestDto,
|
||||
type InstlSncnBizCstSaveRequestDto,
|
||||
type InstlSncnBzScaleSaveDto,
|
||||
type InstlSncnCklstResultSaveDto,
|
||||
type InstlSncnMng,
|
||||
type InstlSncnMngSearchParams,
|
||||
type SpringPage,
|
||||
type ElaprDocPrgrsClientResponseDto,
|
||||
} from "./model/instl-sncn-interface";
|
||||
@@ -0,0 +1,149 @@
|
||||
export const INSTL_SNCN_REGISTER_SELECTION_KEY = "instlSncnRegisterSelection";
|
||||
|
||||
export interface SpringPage<T> {
|
||||
content: T[];
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
number: number;
|
||||
size: number;
|
||||
first: boolean;
|
||||
last: boolean;
|
||||
numberOfElements: number;
|
||||
}
|
||||
|
||||
export interface InstlSncnMngSearchParams {
|
||||
bizNm?: string;
|
||||
taskSeComCd?: string;
|
||||
sidoComCd?: string;
|
||||
sggComCd?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export interface InstlSncnMng {
|
||||
gugun: string;
|
||||
sido: string;
|
||||
instlSncnDocNo: string;
|
||||
admdstCd: string;
|
||||
sidoNm?: string;
|
||||
sggNm?: string;
|
||||
chrgrNm?: string;
|
||||
instNm?: string;
|
||||
envoSeComCd: string;
|
||||
sncnSeComCd: string;
|
||||
envoSeCdNm: string;
|
||||
taskSeComCd: string;
|
||||
taskSeCdNm: string;
|
||||
bgtBizCd: string;
|
||||
bgtAplyYr: string;
|
||||
bizNm: string;
|
||||
bizBgngYr: string;
|
||||
bizEndYr: string;
|
||||
bizCn: string;
|
||||
aplcntNm: string;
|
||||
aplcntMblTelno: string;
|
||||
aplcntOgdpNm: string;
|
||||
aplcntEmlAddr: string;
|
||||
dsgnrNm: string;
|
||||
dsgnrTelno: string;
|
||||
dsgnrOgdpNm: string;
|
||||
dsgnrEmlAddr: string;
|
||||
atchFileGroupId: string;
|
||||
prgrsSttsComCd: string;
|
||||
prgrsSttsCdNm: string;
|
||||
regDt: string;
|
||||
mdfcnDt: string;
|
||||
rgtrNm: string;
|
||||
}
|
||||
|
||||
/** 설치인가 신청 - 사업규모 저장 DTO(배열 원소) */
|
||||
export interface InstlSncnBzScaleSaveDto {
|
||||
/** 공종구분코드 */
|
||||
cnstpSeCd: string;
|
||||
/** 기본계획(A) */
|
||||
bscPl: number;
|
||||
/** 예산반영(B) */
|
||||
budgetReflct: number;
|
||||
/** 금회설계시(C) */
|
||||
thtiDsgn: number;
|
||||
/** 증감(C-B) */
|
||||
diff: number;
|
||||
}
|
||||
|
||||
/** 설치인가 신청 - 총사업비 저장 DTO(배열 원소) */
|
||||
export interface InstlSncnBizCstSaveRequestDto {
|
||||
/** 회계구분코드 */
|
||||
acntgSeCd: string;
|
||||
/** 기본계획금액 */
|
||||
bscPlanAmt: number;
|
||||
/** 예산반영금액 */
|
||||
bgtRfltAmt: number;
|
||||
/** 금회설계금액 */
|
||||
tstmDesignAmt: number;
|
||||
}
|
||||
|
||||
/** 설치인가 신청 - 체크리스트 결과 저장 DTO(배열 원소) */
|
||||
export interface InstlSncnCklstResultSaveDto {
|
||||
cklstSn: number;
|
||||
cklstArtclSn: number;
|
||||
cklstRvwSn: number;
|
||||
rvwStpComCd: string;
|
||||
selected: "Y" | "N" | null;
|
||||
picRvwCn?: string | null;
|
||||
}
|
||||
|
||||
/** 설치인가 신청 - 필수첨부파일 저장 DTO(배열 원소) */
|
||||
export interface InstlSncnEsntlAtchFileSaveDto {
|
||||
esntlAtchFileId: string;
|
||||
atchFileGroupId: string;
|
||||
}
|
||||
|
||||
/** 설치인가 신청 - 통합 저장 요청 DTO */
|
||||
export interface InstlSncnApplySaveRequestDto {
|
||||
instlSncnMng: InstlSncnMng;
|
||||
bzScaleList: InstlSncnBzScaleSaveDto[];
|
||||
bizCstList: InstlSncnBizCstSaveRequestDto[];
|
||||
cklstResultList: InstlSncnCklstResultSaveDto[];
|
||||
esntlAtchFileList: InstlSncnEsntlAtchFileSaveDto[];
|
||||
}
|
||||
|
||||
export type ReviewStepCode = "LOCAL" | "ENV" | "KECO";
|
||||
|
||||
|
||||
|
||||
/** 전자결재 진행상태 (클라이언트 응답용) */
|
||||
export interface ElaprDocPrgrsClientResponseDto {
|
||||
elaprDocNo?: string;
|
||||
elaprDmndId?: string;
|
||||
elaprPrgrsId?: string;
|
||||
atrzSeqNo?: number;
|
||||
atrzSeComCd?: string;
|
||||
atrzSeComNm?: string;
|
||||
aprvrUuid?: string;
|
||||
aprvrUuidNm?: string;
|
||||
atrzPrgrsSttsComCd?: string;
|
||||
atrzPrgrsSttsComNm?: string;
|
||||
rjctRsn?: string | null;
|
||||
atrzDt?: string | null;
|
||||
jbpsSeNm?: string;
|
||||
jbgdSeNm?: string;
|
||||
ognzNm?: string;
|
||||
}
|
||||
|
||||
// det 응답에 전자결재 진행상태 목록을 포함
|
||||
export interface InstlSncnMngDetResponse {
|
||||
detDto: InstlSncnMng;
|
||||
bizSclList: any[];
|
||||
bizCstList: any[];
|
||||
cklstResultList?: any[];
|
||||
esntlFileList: any[];
|
||||
etcFileList: any[];
|
||||
elaprDocPrgrsList?: ElaprDocPrgrsClientResponseDto[] | null;
|
||||
}
|
||||
|
||||
export interface InstlSncnMngService {
|
||||
getPage: (params: InstlSncnMngSearchParams) => Promise<SpringPage<InstlSncnMng>>;
|
||||
getById: (id: string) => Promise<InstlSncnMngDetResponse>;
|
||||
saveApply: (payload: InstlSncnApplySaveRequestDto) => Promise<string>;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { MenuAuthorityApi } from "../api/menu-authority-service";
|
||||
@@ -0,0 +1,19 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type {
|
||||
MenuAuthorityService,
|
||||
PageMenuAuthority,
|
||||
} from "../model/menu-authority-interface";
|
||||
|
||||
export class MenuAuthorityApiService implements MenuAuthorityService {
|
||||
/** MenuAuthrtApiController GET /api/v1/menu-authrts/pageinfo/{menuCd} */
|
||||
async getPageMenuAuthority(menuCd: number): Promise<PageMenuAuthority[]> {
|
||||
const response = await client.get<PageMenuAuthority[]>(
|
||||
`${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}/pageinfo/${menuCd}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const MenuAuthorityApi = new MenuAuthorityApiService();
|
||||
@@ -0,0 +1,2 @@
|
||||
export { MenuAuthorityApi } from "./api/menu-authority-service";
|
||||
export type { PageMenuAuthority } from "./model/menu-authority-interface";
|
||||
@@ -0,0 +1,12 @@
|
||||
/** MenuAuthrtApiController GET /pageinfo/{menuCd} 응답 DTO */
|
||||
export interface PageMenuAuthority {
|
||||
mngAuthrtYn: string;
|
||||
inqAuthrtYn: string;
|
||||
regAuthrtYn: string;
|
||||
mdfcnAuthrtYn: string;
|
||||
delAuthrtYn: string;
|
||||
}
|
||||
|
||||
export interface MenuAuthorityService {
|
||||
getPageMenuAuthority(menuCd: number): Promise<PageMenuAuthority[]>;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import { mapMenuTreeResponse } from "../lib/map-menu-tree-response";
|
||||
import type { MenuTreeResponseDto } from "../model/menu-tree-response";
|
||||
import type { MenuItem } from "../model/types";
|
||||
|
||||
export class MenuApiService {
|
||||
/** MenuRoleApiController GET /api/v1/menu-roles/{siteId} */
|
||||
async getMenuRoles(siteId: string): Promise<MenuItem[]> {
|
||||
const response = await client.get<MenuTreeResponseDto[]>(
|
||||
`${ENDPOINT.CMS_SERVICE.MENU_ROLES}/${siteId}`,
|
||||
);
|
||||
return response.data.map(mapMenuTreeResponse);
|
||||
}
|
||||
}
|
||||
|
||||
export const MenuApi = new MenuApiService();
|
||||
@@ -1 +1,4 @@
|
||||
export { MenuApi } from "./api/menu-service";
|
||||
export { MenuWrapper } from "./lib/menu-wrapper";
|
||||
export { getCurrentMenu } from "./lib/utils";
|
||||
export { type MenuItem } from "./model/types";
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { MenuTreeResponseDto } from "../model/menu-tree-response";
|
||||
import type { MenuItem } from "../model/types";
|
||||
|
||||
function isRequiredUrlPath(menuLnkgTypeCd: string): boolean {
|
||||
return menuLnkgTypeCd === "BOARD" || menuLnkgTypeCd === "CONTENTS";
|
||||
}
|
||||
|
||||
export function mapMenuTreeResponse(dto: MenuTreeResponseDto): MenuItem {
|
||||
return {
|
||||
menuCd: dto.menuCd,
|
||||
menuKornNm: dto.menuKornNm,
|
||||
menuEngNm: dto.menuEngNm,
|
||||
parentId: dto.parentId,
|
||||
sortSeq: dto.sortSeq,
|
||||
iconNm: dto.iconNm,
|
||||
children: dto.children?.map(mapMenuTreeResponse) ?? null,
|
||||
levelNo: dto.levelNo,
|
||||
urlPath: dto.urlPath,
|
||||
menuLnkgTypeCd: dto.menuLnkgTypeCd,
|
||||
menuLnkgSn: dto.menuLnkgSn,
|
||||
menuPath: dto.menuPath,
|
||||
qrcd: dto.qrcd,
|
||||
useYn: dto.useYn,
|
||||
otptYn: dto.otptYn,
|
||||
newWndwYn: dto.newWndwYn,
|
||||
requiredUrlPath: isRequiredUrlPath(dto.menuLnkgTypeCd),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
|
||||
import { SITE_ID } from "@/shared/config";
|
||||
import { getCookie, useAlertStore, useMenuStore } from "@/shared/lib";
|
||||
import { MenuAuthorityApi } from "@/entities/menu-authority/@x/menu";
|
||||
|
||||
import { MenuApi } from "../api/menu-service";
|
||||
|
||||
import { getCurrentMenu } from "./utils";
|
||||
|
||||
function MenuWrapper({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const { pageMenuAuthority, setCurrentMenuInfo, setPageMenuAuthority } =
|
||||
useMenuStore((state) => state);
|
||||
|
||||
const getCurrentMenuInfo = async () => {
|
||||
const menuRoles = await MenuApi.getMenuRoles(SITE_ID);
|
||||
if (!menuRoles) return;
|
||||
return getCurrentMenu(menuRoles, pathname ?? "");
|
||||
};
|
||||
|
||||
const getPageMenuAuthority = async () => {
|
||||
const menuCd = getCookie("menu-code-key");
|
||||
if (!menuCd) return;
|
||||
const response = await MenuAuthorityApi.getPageMenuAuthority(
|
||||
parseInt(menuCd, 10),
|
||||
);
|
||||
return response;
|
||||
};
|
||||
|
||||
const checkInquiryAuthority = () => {
|
||||
return pageMenuAuthority?.inqAuthrtYn === "Y";
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const currentMenu = await getCurrentMenuInfo();
|
||||
if (currentMenu) setCurrentMenuInfo(currentMenu);
|
||||
|
||||
const response = await getPageMenuAuthority();
|
||||
if (response?.length) setPageMenuAuthority(response[0]!);
|
||||
})();
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pageMenuAuthority) return;
|
||||
const inquiryAuthority = checkInquiryAuthority();
|
||||
if (!inquiryAuthority) {
|
||||
alert("페이지에 대한 조회 권한이 없습니다.", () => router.push("/"));
|
||||
}
|
||||
}, [pageMenuAuthority]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export { MenuWrapper };
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { MenuItem } from "../model/types";
|
||||
|
||||
export function getCurrentMenu(
|
||||
menus: MenuItem[],
|
||||
pathname: string,
|
||||
): MenuItem | null {
|
||||
for (const menu of menus) {
|
||||
if (
|
||||
menu.urlPath !== "" &&
|
||||
(menu.urlPath === pathname || pathname.startsWith(menu.urlPath))
|
||||
) {
|
||||
return menu;
|
||||
}
|
||||
|
||||
if (menu.children) {
|
||||
const child = getCurrentMenu(menu.children, pathname);
|
||||
if (child) return child;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/** MenuRoleApiController GET /api/v1/menu-roles/{siteId} 응답 DTO */
|
||||
export interface MenuTreeResponseDto {
|
||||
menuCd: number;
|
||||
menuKornNm: string;
|
||||
menuEngNm: string;
|
||||
parentId: number | null;
|
||||
sortSeq: number;
|
||||
iconNm?: string;
|
||||
children: MenuTreeResponseDto[] | null;
|
||||
levelNo: number;
|
||||
urlPath: string;
|
||||
menuLnkgTypeCd: string;
|
||||
menuLnkgSn: number;
|
||||
menuPath: string;
|
||||
qrcd: string;
|
||||
useYn: string;
|
||||
otptYn: string;
|
||||
newWndwYn: string;
|
||||
menuHlpCn?: string;
|
||||
menuSubKornNm?: string;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type {
|
||||
RvwMnpwMngSaveRequestDto,
|
||||
RvwMnpwMngSearchDto,
|
||||
RvwMnpwMngUpdateRequestDto,
|
||||
} from "../model/rvw-mnpw-interface";
|
||||
|
||||
import { rvwMnpwMngService } from "./rvw-mnpw-service";
|
||||
|
||||
const rvwMnpwMngBaseKey = [ENDPOINT.AS_COMMON_SERVICE.RVW_MNPW] as const;
|
||||
|
||||
export function useRvwMnpwMngPage(params: RvwMnpwMngSearchDto, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: [...rvwMnpwMngBaseKey, "findPage", params],
|
||||
queryFn: () => rvwMnpwMngService.findPage(params),
|
||||
enabled: options?.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRvwMnpwMng(
|
||||
atrzSeComCd: string | null | undefined,
|
||||
ognzSn: number | null | undefined,
|
||||
userUuid: string | null | undefined,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: [...rvwMnpwMngBaseKey, "findById", atrzSeComCd, ognzSn, userUuid],
|
||||
queryFn: () => rvwMnpwMngService.findById(atrzSeComCd!, ognzSn!, userUuid!),
|
||||
enabled: Boolean(atrzSeComCd && ognzSn != null && userUuid),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveRvwMnpwMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (body: RvwMnpwMngSaveRequestDto) => rvwMnpwMngService.save(body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: rvwMnpwMngBaseKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateRvwMnpwMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
atrzSeComCd,
|
||||
ognzSn,
|
||||
userUuid,
|
||||
body,
|
||||
}: {
|
||||
atrzSeComCd: string;
|
||||
ognzSn: number;
|
||||
userUuid: string;
|
||||
body: RvwMnpwMngUpdateRequestDto;
|
||||
}) => rvwMnpwMngService.update(atrzSeComCd, ognzSn, userUuid, body),
|
||||
onSuccess: (_data, { atrzSeComCd, ognzSn, userUuid }) => {
|
||||
queryClient.invalidateQueries({ queryKey: rvwMnpwMngBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...rvwMnpwMngBaseKey, "findById", atrzSeComCd, ognzSn, userUuid],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteRvwMnpwMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
atrzSeComCd,
|
||||
ognzSn,
|
||||
userUuid,
|
||||
}: {
|
||||
atrzSeComCd: string;
|
||||
ognzSn: number;
|
||||
userUuid: string;
|
||||
}) => rvwMnpwMngService.delete(atrzSeComCd, ognzSn, userUuid),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: rvwMnpwMngBaseKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
|
||||
import type {
|
||||
RvwMnpwMngResponseDto,
|
||||
RvwMnpwMngSaveRequestDto,
|
||||
RvwMnpwMngSearchDto,
|
||||
RvwMnpwMngService,
|
||||
RvwMnpwMngUpdateRequestDto,
|
||||
} from "../model/rvw-mnpw-interface";
|
||||
|
||||
const BASE = ENDPOINT.AS_COMMON_SERVICE.RVW_MNPW;
|
||||
|
||||
function pkPath(atrzSeComCd: string, ognzSn: number, userUuid: string): string {
|
||||
return `${BASE}/${atrzSeComCd}/${ognzSn}/${userUuid}`;
|
||||
}
|
||||
|
||||
async function findPage(params: RvwMnpwMngSearchDto): Promise<PageResponse<RvwMnpwMngResponseDto>> {
|
||||
const response = await client.get<PageResponse<RvwMnpwMngResponseDto>>(BASE, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findById(
|
||||
atrzSeComCd: string,
|
||||
ognzSn: number,
|
||||
userUuid: string,
|
||||
): Promise<RvwMnpwMngResponseDto> {
|
||||
const response = await client.get<RvwMnpwMngResponseDto>(pkPath(atrzSeComCd, ognzSn, userUuid));
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function save(body: RvwMnpwMngSaveRequestDto): Promise<RvwMnpwMngResponseDto> {
|
||||
const response = await client.post<RvwMnpwMngResponseDto>(BASE, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function update(
|
||||
atrzSeComCd: string,
|
||||
ognzSn: number,
|
||||
userUuid: string,
|
||||
body: RvwMnpwMngUpdateRequestDto,
|
||||
): Promise<RvwMnpwMngResponseDto> {
|
||||
const response = await client.put<RvwMnpwMngResponseDto>(
|
||||
pkPath(atrzSeComCd, ognzSn, userUuid),
|
||||
body,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function deleteRvwMnpw(
|
||||
atrzSeComCd: string,
|
||||
ognzSn: number,
|
||||
userUuid: string,
|
||||
): Promise<string> {
|
||||
const response = await client.delete<string>(pkPath(atrzSeComCd, ognzSn, userUuid));
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const rvwMnpwMngService = {
|
||||
findPage,
|
||||
findById,
|
||||
save,
|
||||
update,
|
||||
delete: deleteRvwMnpw,
|
||||
} satisfies RvwMnpwMngService;
|
||||
@@ -0,0 +1,15 @@
|
||||
export {
|
||||
useDeleteRvwMnpwMng,
|
||||
useRvwMnpwMng,
|
||||
useRvwMnpwMngPage,
|
||||
useSaveRvwMnpwMng,
|
||||
useUpdateRvwMnpwMng,
|
||||
} from "./api/queries";
|
||||
export { rvwMnpwMngService } from "./api/rvw-mnpw-service";
|
||||
export type {
|
||||
RvwMnpwMngResponseDto,
|
||||
RvwMnpwMngSaveRequestDto,
|
||||
RvwMnpwMngSearchDto,
|
||||
RvwMnpwMngService,
|
||||
RvwMnpwMngUpdateRequestDto,
|
||||
} from "./model/rvw-mnpw-interface";
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { PageableParams, PageResponse } from "@/shared/model";
|
||||
|
||||
/** 백엔드 RvwMnpwMngResponseDto */
|
||||
export interface RvwMnpwMngResponseDto {
|
||||
atrzSeComCd: string;
|
||||
atrzSeComCdNm?: string;
|
||||
ognzSn: number;
|
||||
userUuid: string;
|
||||
userUuidNm?: string;
|
||||
ognzNm?: string;
|
||||
jbpsSeNm?: string;
|
||||
jbgdSeNm?: string;
|
||||
rgtr?: string;
|
||||
regDt?: string;
|
||||
}
|
||||
|
||||
/** 백엔드 RvwMnpwMngSearchDto + Pageable */
|
||||
export interface RvwMnpwMngSearchDto extends PageableParams {
|
||||
atrzSeComCd?: string;
|
||||
ognzSn?: number;
|
||||
userUuid?: string;
|
||||
}
|
||||
|
||||
/** 백엔드 RvwMnpwMngSaveRequestDto */
|
||||
export interface RvwMnpwMngSaveRequestDto {
|
||||
atrzSeComCd: string;
|
||||
ognzSn: number;
|
||||
userUuid: string;
|
||||
}
|
||||
|
||||
/** 백엔드 RvwMnpwMngUpdateRequestDto */
|
||||
export interface RvwMnpwMngUpdateRequestDto {
|
||||
newAtrzSeComCd: string;
|
||||
}
|
||||
|
||||
/** Controller public 메서드명·시그니처와 동일 */
|
||||
export interface RvwMnpwMngService {
|
||||
findPage(params: RvwMnpwMngSearchDto): Promise<PageResponse<RvwMnpwMngResponseDto>>;
|
||||
findById(atrzSeComCd: string, ognzSn: number, userUuid: string): Promise<RvwMnpwMngResponseDto>;
|
||||
save(body: RvwMnpwMngSaveRequestDto): Promise<RvwMnpwMngResponseDto>;
|
||||
update(
|
||||
atrzSeComCd: string,
|
||||
ognzSn: number,
|
||||
userUuid: string,
|
||||
body: RvwMnpwMngUpdateRequestDto,
|
||||
): Promise<RvwMnpwMngResponseDto>;
|
||||
delete(atrzSeComCd: string, ognzSn: number, userUuid: string): Promise<string>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import { userOgnzService } from "./user-ognz-service";
|
||||
|
||||
const userOgnzTreeBaseKey = [ENDPOINT.CMS_SERVICE.USER_ORGANIZATION, "treeuser"] as const;
|
||||
|
||||
export function useOgnzUserTree(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: userOgnzTreeBaseKey,
|
||||
queryFn: () => userOgnzService.findOgnzUserTree(),
|
||||
enabled: options?.enabled ?? true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type {
|
||||
OgnzUserTreeResponseDto,
|
||||
UserOgnzService,
|
||||
} from "../model/ognz-user-tree-interface";
|
||||
|
||||
async function findOgnzUserTree(): Promise<OgnzUserTreeResponseDto[]> {
|
||||
const response = await client.get<OgnzUserTreeResponseDto[]>(
|
||||
`${ENDPOINT.CMS_SERVICE.USER_ORGANIZATION}/treeuser`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const userOgnzService = {
|
||||
findOgnzUserTree,
|
||||
} satisfies UserOgnzService;
|
||||
@@ -0,0 +1,7 @@
|
||||
export { useOgnzUserTree } from "./api/queries";
|
||||
export { userOgnzService } from "./api/user-ognz-service";
|
||||
export type {
|
||||
OgnzUserTreeResponseDto,
|
||||
OgnzUserTreeUserDto,
|
||||
UserOgnzService,
|
||||
} from "./model/ognz-user-tree-interface";
|
||||
@@ -0,0 +1,25 @@
|
||||
/** OgnzUserTreeResponseDto.UserDto */
|
||||
export interface OgnzUserTreeUserDto {
|
||||
userUnqId: string;
|
||||
userNm: string;
|
||||
jbpsSeCd: string;
|
||||
jbgdSeCd: string;
|
||||
jbpsSeNm: string;
|
||||
jbgdSeNm: string;
|
||||
}
|
||||
|
||||
/** OgnzUserTreeResponseDto */
|
||||
export interface OgnzUserTreeResponseDto {
|
||||
ognzSn: number;
|
||||
upOgnzSn: number | null;
|
||||
ognzNm: string;
|
||||
ognzExpln?: string;
|
||||
sortSeq?: number;
|
||||
useYn?: string;
|
||||
users: OgnzUserTreeUserDto[];
|
||||
children: OgnzUserTreeResponseDto[];
|
||||
}
|
||||
|
||||
export interface UserOgnzService {
|
||||
findOgnzUserTree(): Promise<OgnzUserTreeResponseDto[]>;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { AcntgCodeManagementPage } from "./ui/acntg-code-management-page";
|
||||
@@ -0,0 +1,245 @@
|
||||
import type { AcntgSeMngResponseDto } from "@/entities/acntg";
|
||||
|
||||
import type {
|
||||
AcntgDisplaySlot,
|
||||
AcntgSeBaseEntry,
|
||||
AcntgSeGridState,
|
||||
} from "../model/acntg-se-grid-types";
|
||||
import type { AcntgSeRow } from "../model/acntg-se-row-types";
|
||||
import { buildAcntgDisplayRows } from "../model/build-acntg-display-rows";
|
||||
|
||||
export function createLoadedRowKey(index: number, acntgSeCd: string): string {
|
||||
return `loaded-${index}-${acntgSeCd}`;
|
||||
}
|
||||
|
||||
export function createDraftRowKey(): string {
|
||||
return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
export function createEmptyGridState(): AcntgSeGridState {
|
||||
return {
|
||||
baseByKey: {},
|
||||
structure: [],
|
||||
drafts: {},
|
||||
patches: {},
|
||||
deletedKeys: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
function toInitialRow(entry: AcntgSeBaseEntry): AcntgSeRow {
|
||||
return {
|
||||
...entry,
|
||||
isNew: false,
|
||||
isDirty: false,
|
||||
isDeleted: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildStructureFromBaseRows(
|
||||
baseByKey: Record<string, AcntgSeBaseEntry>,
|
||||
): AcntgDisplaySlot[] {
|
||||
const rows = Object.values(baseByKey).map(toInitialRow);
|
||||
const display = buildAcntgDisplayRows(rows);
|
||||
const cdToRowKey = new Map(rows.map((row) => [row.acntgSeCd, row.rowKey]));
|
||||
|
||||
return display.map(({ row, depth }) => ({
|
||||
rowKey: row.rowKey,
|
||||
depth,
|
||||
parentRowKey: row.upAcntgSeCd?.trim() ? (cdToRowKey.get(row.upAcntgSeCd.trim()) ?? null) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function hydrateGridFromServer(content: AcntgSeMngResponseDto[]): AcntgSeGridState {
|
||||
const baseByKey: Record<string, AcntgSeBaseEntry> = {};
|
||||
|
||||
content.forEach((item, index) => {
|
||||
const rowKey = createLoadedRowKey(index, item.acntgSeCd);
|
||||
baseByKey[rowKey] = { ...item, rowKey };
|
||||
});
|
||||
|
||||
return {
|
||||
baseByKey,
|
||||
structure: buildStructureFromBaseRows(baseByKey),
|
||||
drafts: {},
|
||||
patches: {},
|
||||
deletedKeys: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveParentAcntgSeCd(
|
||||
state: AcntgSeGridState,
|
||||
parentRowKey: string | null,
|
||||
): string | undefined {
|
||||
if (!parentRowKey) return undefined;
|
||||
const parent = mergeAcntgRow(state, parentRowKey);
|
||||
const cd = parent?.acntgSeCd?.trim();
|
||||
return cd || undefined;
|
||||
}
|
||||
|
||||
export function mergeAcntgRow(state: AcntgSeGridState, rowKey: string): AcntgSeRow | null {
|
||||
const draft = state.drafts[rowKey];
|
||||
if (draft) {
|
||||
return {
|
||||
rowKey,
|
||||
acntgSeCd: draft.acntgSeCd,
|
||||
acntgSeCdNm: draft.acntgSeCdNm,
|
||||
expln: draft.expln,
|
||||
useYn: draft.useYn,
|
||||
cdLvNo: draft.cdLvNo,
|
||||
upAcntgSeCd: resolveParentAcntgSeCd(state, draft.parentRowKey),
|
||||
isNew: true,
|
||||
isDirty: true,
|
||||
isDeleted: false,
|
||||
};
|
||||
}
|
||||
|
||||
const base = state.baseByKey[rowKey];
|
||||
if (!base) return null;
|
||||
|
||||
const patch = state.patches[rowKey] ?? {};
|
||||
const hasPatch = Object.keys(patch).length > 0;
|
||||
|
||||
return {
|
||||
...base,
|
||||
...patch,
|
||||
rowKey,
|
||||
isNew: false,
|
||||
isDirty: hasPatch,
|
||||
isDeleted: state.deletedKeys.has(rowKey),
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeAllAcntgRows(state: AcntgSeGridState): AcntgSeRow[] {
|
||||
const keys = new Set([...Object.keys(state.baseByKey), ...Object.keys(state.drafts)]);
|
||||
|
||||
return [...keys]
|
||||
.map((rowKey) => mergeAcntgRow(state, rowKey))
|
||||
.filter((row): row is AcntgSeRow => row != null);
|
||||
}
|
||||
|
||||
export function hasAcntgGridPendingChanges(state: AcntgSeGridState): boolean {
|
||||
return (
|
||||
state.deletedKeys.size > 0 ||
|
||||
Object.keys(state.drafts).length > 0 ||
|
||||
Object.keys(state.patches).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function collectDescendantRowKeys(
|
||||
structure: AcntgDisplaySlot[],
|
||||
rootRowKey: string,
|
||||
): Set<string> {
|
||||
const result = new Set<string>([rootRowKey]);
|
||||
const queue = [rootRowKey];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const parentKey = queue.shift()!;
|
||||
for (const slot of structure) {
|
||||
if (slot.parentRowKey === parentKey && !result.has(slot.rowKey)) {
|
||||
result.add(slot.rowKey);
|
||||
queue.push(slot.rowKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function insertRootSlot(
|
||||
structure: AcntgDisplaySlot[],
|
||||
slot: AcntgDisplaySlot,
|
||||
): AcntgDisplaySlot[] {
|
||||
const firstRootIndex = structure.findIndex((item) => item.parentRowKey === null);
|
||||
if (firstRootIndex === -1) return [slot, ...structure];
|
||||
return [...structure.slice(0, firstRootIndex), slot, ...structure.slice(firstRootIndex)];
|
||||
}
|
||||
|
||||
export function insertChildSlot(
|
||||
structure: AcntgDisplaySlot[],
|
||||
parentRowKey: string,
|
||||
slot: AcntgDisplaySlot,
|
||||
): AcntgDisplaySlot[] {
|
||||
const parentIndex = structure.findIndex((item) => item.rowKey === parentRowKey);
|
||||
if (parentIndex === -1) return [...structure, slot];
|
||||
|
||||
const insertAt = parentIndex + 1;
|
||||
return [...structure.slice(0, insertAt), slot, ...structure.slice(insertAt)];
|
||||
}
|
||||
|
||||
export function removeSlotsFromStructure(
|
||||
structure: AcntgDisplaySlot[],
|
||||
rowKeysToRemove: Set<string>,
|
||||
): AcntgDisplaySlot[] {
|
||||
return structure.filter((slot) => !rowKeysToRemove.has(slot.rowKey));
|
||||
}
|
||||
|
||||
export function patchAcntgGridRow(
|
||||
state: AcntgSeGridState,
|
||||
rowKey: string,
|
||||
patch: Partial<AcntgSeRow>,
|
||||
): AcntgSeGridState {
|
||||
const draft = state.drafts[rowKey];
|
||||
if (draft) {
|
||||
return {
|
||||
...state,
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[rowKey]: {
|
||||
...draft,
|
||||
...(patch.acntgSeCd !== undefined ? { acntgSeCd: patch.acntgSeCd } : {}),
|
||||
...(patch.acntgSeCdNm !== undefined ? { acntgSeCdNm: patch.acntgSeCdNm ?? "" } : {}),
|
||||
...(patch.expln !== undefined ? { expln: patch.expln ?? "" } : {}),
|
||||
...(patch.useYn !== undefined ? { useYn: patch.useYn } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { rowKey: _rk, isNew: _n, isDirty: _d, isDeleted: _del, ...dtoPatch } = patch;
|
||||
|
||||
return {
|
||||
...state,
|
||||
patches: {
|
||||
...state.patches,
|
||||
[rowKey]: { ...state.patches[rowKey], ...dtoPatch },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function markAcntgRowsDeleted(
|
||||
state: AcntgSeGridState,
|
||||
rootRowKey: string,
|
||||
): AcntgSeGridState {
|
||||
const keysToDelete = collectDescendantRowKeys(state.structure, rootRowKey);
|
||||
const deletedKeys = new Set(state.deletedKeys);
|
||||
for (const key of keysToDelete) {
|
||||
deletedKeys.add(key);
|
||||
}
|
||||
return { ...state, deletedKeys };
|
||||
}
|
||||
|
||||
export function validateDraftParentAcntgSeCd(
|
||||
state: AcntgSeGridState,
|
||||
rowKey: string,
|
||||
): string | null {
|
||||
const draft = state.drafts[rowKey];
|
||||
if (!draft?.parentRowKey) return null;
|
||||
|
||||
const parent = mergeAcntgRow(state, draft.parentRowKey);
|
||||
if (!parent?.acntgSeCd?.trim()) {
|
||||
return "하위 항목을 추가하려면 상위 코드를 먼저 입력하세요.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function removeDraftFromGrid(state: AcntgSeGridState, rowKey: string): AcntgSeGridState {
|
||||
const { [rowKey]: _removed, ...restDrafts } = state.drafts;
|
||||
const keysToRemove = collectDescendantRowKeys(state.structure, rowKey);
|
||||
|
||||
return {
|
||||
...state,
|
||||
drafts: restDrafts,
|
||||
structure: removeSlotsFromStructure(state.structure, keysToRemove),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ACNTG_SE_SAVE_MODE, type AcntgSeMngSaveRequestDto } from "@/entities/acntg";
|
||||
|
||||
import type { AcntgSeRow } from "../model/acntg-se-row-types";
|
||||
|
||||
import { getVisibleRows } from "./row-state";
|
||||
|
||||
function compareCdLvNoDesc(a: AcntgSeRow, b: AcntgSeRow): number {
|
||||
return (b.cdLvNo ?? 1) - (a.cdLvNo ?? 1);
|
||||
}
|
||||
|
||||
function compareCdLvNoAsc(a: AcntgSeRow, b: AcntgSeRow): number {
|
||||
return (a.cdLvNo ?? 1) - (b.cdLvNo ?? 1);
|
||||
}
|
||||
|
||||
export function buildSaveMultiPayload(rows: AcntgSeRow[]): AcntgSeMngSaveRequestDto[] {
|
||||
const payload: AcntgSeMngSaveRequestDto[] = [];
|
||||
|
||||
const deletedRows = rows.filter((row) => row.isDeleted && !row.isNew).sort(compareCdLvNoDesc);
|
||||
|
||||
for (const row of deletedRows) {
|
||||
payload.push({
|
||||
mode: ACNTG_SE_SAVE_MODE.DELETE,
|
||||
acntgSeCd: row.acntgSeCd,
|
||||
acntgSeCdNm: row.acntgSeCdNm?.trim() || "",
|
||||
upAcntgSeCd: row.upAcntgSeCd,
|
||||
cdLvNo: row.cdLvNo,
|
||||
useYn: row.useYn,
|
||||
expln: row.expln?.trim() || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const visibleRows = getVisibleRows(rows).sort(compareCdLvNoAsc);
|
||||
|
||||
for (const row of visibleRows) {
|
||||
if (row.isNew) {
|
||||
payload.push({
|
||||
mode: ACNTG_SE_SAVE_MODE.CREATE,
|
||||
acntgSeCd: row.acntgSeCd.trim(),
|
||||
acntgSeCdNm: row.acntgSeCdNm.trim(),
|
||||
upAcntgSeCd: row.upAcntgSeCd,
|
||||
cdLvNo: row.cdLvNo,
|
||||
useYn: row.useYn,
|
||||
expln: row.expln?.trim() || undefined,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (row.isDirty) {
|
||||
payload.push({
|
||||
mode: ACNTG_SE_SAVE_MODE.UPDATE,
|
||||
acntgSeCd: row.acntgSeCd.trim(),
|
||||
acntgSeCdNm: row.acntgSeCdNm.trim(),
|
||||
upAcntgSeCd: row.upAcntgSeCd,
|
||||
cdLvNo: row.cdLvNo,
|
||||
useYn: row.useYn,
|
||||
expln: row.expln?.trim() || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { AcntgSeRow } from "../model/acntg-se-row-types";
|
||||
|
||||
export function getVisibleRows(rows: AcntgSeRow[]): AcntgSeRow[] {
|
||||
return rows.filter((row) => !row.isDeleted);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { AcntgSeMngResponseDto } from "@/entities/acntg";
|
||||
|
||||
import type { AcntgSeGridState } from "../model/acntg-se-grid-types";
|
||||
import type { AcntgSeRow } from "../model/acntg-se-row-types";
|
||||
|
||||
import {
|
||||
createDraftRowKey,
|
||||
createEmptyGridState,
|
||||
hasAcntgGridPendingChanges,
|
||||
hydrateGridFromServer,
|
||||
insertChildSlot,
|
||||
insertRootSlot,
|
||||
markAcntgRowsDeleted,
|
||||
mergeAcntgRow,
|
||||
mergeAllAcntgRows,
|
||||
patchAcntgGridRow,
|
||||
removeDraftFromGrid,
|
||||
validateDraftParentAcntgSeCd,
|
||||
} from "./acntg-se-grid";
|
||||
import { buildSaveMultiPayload } from "./build-save-multi-payload";
|
||||
|
||||
function validateAcntgSeRow(row: AcntgSeRow): string | null {
|
||||
if (!row.acntgSeCd.trim()) return "코드를 입력하세요.";
|
||||
if (!row.acntgSeCdNm?.trim()) return "코드명을 입력하세요.";
|
||||
const parentCd = row.upAcntgSeCd?.trim();
|
||||
if (parentCd && row.acntgSeCd.trim() === parentCd) {
|
||||
return "상위 코드와 동일한 코드는 사용할 수 없습니다.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface UseAcntgSeRowsOptions {
|
||||
serverContent: AcntgSeMngResponseDto[] | undefined;
|
||||
onAlert: (message: string) => void;
|
||||
onSave: (payload: ReturnType<typeof buildSaveMultiPayload>) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useAcntgSeRows({ serverContent, onAlert, onSave }: UseAcntgSeRowsOptions) {
|
||||
const [grid, setGrid] = useState<AcntgSeGridState>(createEmptyGridState);
|
||||
const hasPendingRef = useRef(false);
|
||||
|
||||
hasPendingRef.current = hasAcntgGridPendingChanges(grid);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverContent == null) return;
|
||||
if (hasPendingRef.current) return;
|
||||
setGrid(hydrateGridFromServer(serverContent));
|
||||
}, [serverContent]);
|
||||
|
||||
const displayRows = useMemo(() => {
|
||||
return grid.structure
|
||||
.filter((slot) => !grid.deletedKeys.has(slot.rowKey))
|
||||
.map((slot) => {
|
||||
const row = mergeAcntgRow(grid, slot.rowKey);
|
||||
return row ? { row, depth: slot.depth } : null;
|
||||
})
|
||||
.filter((item): item is { row: AcntgSeRow; depth: number } => item != null);
|
||||
}, [grid]);
|
||||
|
||||
const canSave = hasAcntgGridPendingChanges(grid);
|
||||
|
||||
const updateRow = useCallback((rowKey: string, patch: Partial<AcntgSeRow>) => {
|
||||
setGrid((prev) => patchAcntgGridRow(prev, rowKey, patch));
|
||||
}, []);
|
||||
|
||||
const addRootRow = useCallback(() => {
|
||||
const rowKey = createDraftRowKey();
|
||||
setGrid((prev) => ({
|
||||
...prev,
|
||||
drafts: {
|
||||
...prev.drafts,
|
||||
[rowKey]: {
|
||||
rowKey,
|
||||
parentRowKey: null,
|
||||
cdLvNo: 1,
|
||||
acntgSeCd: "",
|
||||
acntgSeCdNm: "",
|
||||
expln: "",
|
||||
useYn: "Y",
|
||||
},
|
||||
},
|
||||
structure: insertRootSlot(prev.structure, { rowKey, depth: 0, parentRowKey: null }),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const addChildRow = useCallback((parent: AcntgSeRow) => {
|
||||
const rowKey = createDraftRowKey();
|
||||
const slot = {
|
||||
rowKey,
|
||||
depth: 0,
|
||||
parentRowKey: parent.rowKey,
|
||||
};
|
||||
|
||||
setGrid((prev) => {
|
||||
const parentSlot = prev.structure.find((item) => item.rowKey === parent.rowKey);
|
||||
const depth = (parentSlot?.depth ?? 0) + 1;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
drafts: {
|
||||
...prev.drafts,
|
||||
[rowKey]: {
|
||||
rowKey,
|
||||
parentRowKey: parent.rowKey,
|
||||
cdLvNo: (parent.cdLvNo ?? 1) + 1,
|
||||
acntgSeCd: "",
|
||||
acntgSeCdNm: "",
|
||||
expln: "",
|
||||
useYn: "Y",
|
||||
},
|
||||
},
|
||||
structure: insertChildSlot(prev.structure, parent.rowKey, {
|
||||
...slot,
|
||||
depth,
|
||||
}),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeDraft = useCallback((rowKey: string) => {
|
||||
setGrid((prev) => removeDraftFromGrid(prev, rowKey));
|
||||
}, []);
|
||||
|
||||
const markDeleted = useCallback((rowKey: string) => {
|
||||
setGrid((prev) => markAcntgRowsDeleted(prev, rowKey));
|
||||
}, []);
|
||||
|
||||
const resetFromServer = useCallback((content: AcntgSeMngResponseDto[]) => {
|
||||
setGrid(hydrateGridFromServer(content));
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const mergedRows = mergeAllAcntgRows(grid);
|
||||
const payload = buildSaveMultiPayload(mergedRows);
|
||||
if (payload.length === 0) return false;
|
||||
|
||||
for (const row of mergedRows) {
|
||||
if (!row.isDeleted && (row.isNew || row.isDirty)) {
|
||||
const parentCodeError = validateDraftParentAcntgSeCd(grid, row.rowKey);
|
||||
if (parentCodeError) {
|
||||
onAlert(parentCodeError);
|
||||
return false;
|
||||
}
|
||||
|
||||
const fieldError = validateAcntgSeRow(row);
|
||||
if (fieldError) {
|
||||
onAlert(fieldError);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await onSave(payload);
|
||||
return true;
|
||||
}, [grid, onAlert, onSave]);
|
||||
|
||||
return {
|
||||
displayRows,
|
||||
canSave,
|
||||
updateRow,
|
||||
addRootRow,
|
||||
addChildRow,
|
||||
removeDraft,
|
||||
markDeleted,
|
||||
resetFromServer,
|
||||
save,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { AcntgSeMngResponseDto } from "@/entities/acntg";
|
||||
|
||||
export interface AcntgDisplaySlot {
|
||||
rowKey: string;
|
||||
depth: number;
|
||||
parentRowKey: string | null;
|
||||
}
|
||||
|
||||
export interface AcntgSeDraft {
|
||||
rowKey: string;
|
||||
parentRowKey: string | null;
|
||||
cdLvNo: number;
|
||||
acntgSeCd: string;
|
||||
acntgSeCdNm: string;
|
||||
expln: string;
|
||||
useYn: string;
|
||||
}
|
||||
|
||||
export interface AcntgSeBaseEntry extends AcntgSeMngResponseDto {
|
||||
rowKey: string;
|
||||
}
|
||||
|
||||
export interface AcntgSeGridState {
|
||||
baseByKey: Record<string, AcntgSeBaseEntry>;
|
||||
structure: AcntgDisplaySlot[];
|
||||
drafts: Record<string, AcntgSeDraft>;
|
||||
patches: Record<string, Partial<AcntgSeMngResponseDto>>;
|
||||
deletedKeys: Set<string>;
|
||||
}
|
||||
|
||||
export interface AcntgSeDisplayItem {
|
||||
rowKey: string;
|
||||
depth: number;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { AcntgSeMngResponseDto } from "@/entities/acntg";
|
||||
|
||||
export interface AcntgSeRow extends AcntgSeMngResponseDto {
|
||||
rowKey: string;
|
||||
isNew: boolean;
|
||||
isDirty: boolean;
|
||||
isDeleted: boolean;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { AcntgSeRow } from "./acntg-se-row-types";
|
||||
|
||||
export interface AcntgSeDisplayRow {
|
||||
row: AcntgSeRow;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
export function formatCdLvNo(lvNo?: number): string {
|
||||
return `${lvNo ?? 1}단계`;
|
||||
}
|
||||
|
||||
export function canAddChildRow(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function formatTreeBranchPrefix(depth: number): string {
|
||||
if (depth <= 0) return "";
|
||||
const vertical = "\u2502 ";
|
||||
const branch = "\u251C\u2500";
|
||||
return vertical.repeat(depth - 1) + branch;
|
||||
}
|
||||
|
||||
function isRootRow(row: AcntgSeRow): boolean {
|
||||
return (row.cdLvNo ?? 1) === 1 && !row.upAcntgSeCd?.trim();
|
||||
}
|
||||
|
||||
function rowOrderIndex(rows: AcntgSeRow[], row: AcntgSeRow): number {
|
||||
return rows.findIndex((candidate) => candidate.rowKey === row.rowKey);
|
||||
}
|
||||
|
||||
function pushDisplayRow(
|
||||
result: AcntgSeDisplayRow[],
|
||||
displayedRowKeys: Set<string>,
|
||||
row: AcntgSeRow,
|
||||
depth: number,
|
||||
) {
|
||||
if (displayedRowKeys.has(row.rowKey)) return;
|
||||
displayedRowKeys.add(row.rowKey);
|
||||
result.push({ row, depth });
|
||||
}
|
||||
|
||||
export function buildAcntgDisplayRows(rows: AcntgSeRow[]): AcntgSeDisplayRow[] {
|
||||
const result: AcntgSeDisplayRow[] = [];
|
||||
const displayedRowKeys = new Set<string>();
|
||||
|
||||
const roots = rows.filter(isRootRow);
|
||||
|
||||
function appendSubtree(parent: AcntgSeRow, depth: number) {
|
||||
const children = rows
|
||||
.filter(
|
||||
(row) =>
|
||||
row.rowKey !== parent.rowKey &&
|
||||
row.upAcntgSeCd != null &&
|
||||
row.upAcntgSeCd === parent.acntgSeCd &&
|
||||
row.acntgSeCd !== parent.acntgSeCd,
|
||||
)
|
||||
.sort((a, b) => rowOrderIndex(rows, a) - rowOrderIndex(rows, b));
|
||||
|
||||
for (const child of children) {
|
||||
pushDisplayRow(result, displayedRowKeys, child, depth);
|
||||
appendSubtree(child, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const root of roots) {
|
||||
pushDisplayRow(result, displayedRowKeys, root, 0);
|
||||
appendSubtree(root, 1);
|
||||
}
|
||||
|
||||
const orphans = rows.filter((row) => !displayedRowKeys.has(row.rowKey));
|
||||
|
||||
for (const orphan of orphans) {
|
||||
pushDisplayRow(result, displayedRowKeys, orphan, Math.max(0, (orphan.cdLvNo ?? 1) - 1));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { useAcntgSeMngPage, useSaveMultiAcntgSeMng } from "@/entities/acntg";
|
||||
import {
|
||||
Button,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import { useAcntgSeRows } from "../lib/use-acntg-se-rows";
|
||||
import type { AcntgSeRow } from "../model/acntg-se-row-types";
|
||||
|
||||
import { AcntgSeRowComponent } from "./acntg-se-row";
|
||||
|
||||
function AcntgCodeManagementPage() {
|
||||
const { confirm } = useConfirm();
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
|
||||
const pageQuery = useAcntgSeMngPage({
|
||||
page: 0,
|
||||
size: 500,
|
||||
sort: "cdLvNo,asc",
|
||||
});
|
||||
|
||||
const saveMultiMutation = useSaveMultiAcntgSeMng();
|
||||
|
||||
const handlePersist = useCallback(
|
||||
async (payload: Parameters<typeof saveMultiMutation.mutateAsync>[0]) => {
|
||||
await saveMultiMutation.mutateAsync(payload);
|
||||
},
|
||||
[saveMultiMutation],
|
||||
);
|
||||
|
||||
const {
|
||||
displayRows,
|
||||
canSave,
|
||||
updateRow,
|
||||
addRootRow,
|
||||
addChildRow,
|
||||
removeDraft,
|
||||
markDeleted,
|
||||
resetFromServer,
|
||||
save,
|
||||
} = useAcntgSeRows({
|
||||
serverContent: pageQuery.data?.content,
|
||||
onAlert: alert,
|
||||
onSave: handlePersist,
|
||||
});
|
||||
|
||||
const handleDelete = async (row: AcntgSeRow) => {
|
||||
if (!(await confirm("삭제하시겠습니까?"))) return;
|
||||
|
||||
if (row.isNew) {
|
||||
removeDraft(row.rowKey);
|
||||
return;
|
||||
}
|
||||
|
||||
markDeleted(row.rowKey);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!(await confirm("저장하시겠습니까?"))) return;
|
||||
|
||||
const saved = await save();
|
||||
if (!saved) return;
|
||||
|
||||
alert("저장되었습니다.");
|
||||
const result = await pageQuery.refetch();
|
||||
resetFromServer(result.data?.content ?? []);
|
||||
};
|
||||
|
||||
const isSaving = saveMultiMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-[1.8rem] pb-6">
|
||||
<section className="card card-border mx-0 bg-background pb-6 pt-0">
|
||||
<div className="card-header flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-2xl font-semibold">회계코드 관리</h3>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button type="button" color="green" onClick={addRootRow}>
|
||||
코드 추가
|
||||
</Button>
|
||||
<Button type="button" disabled={!canSave || isSaving} onClick={handleSave}>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pageQuery.isPending ? (
|
||||
<div className="flex flex-col gap-2 px-4 pb-4">
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[16rem]" />
|
||||
</div>
|
||||
) : displayRows.length === 0 ? (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
등록된 코드가 없습니다. [코드 추가]로 1단계 코드를 등록하세요.
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-[60rem] overflow-y-auto pt-3">
|
||||
<Table className="w-full">
|
||||
<colgroup>
|
||||
<col width="15%" />
|
||||
<col width="20%" />
|
||||
<col width="35%" />
|
||||
<col width="12%" />
|
||||
<col width="8%" />
|
||||
<col width="10%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>코드</TableHead>
|
||||
<TableHead>코드명</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead>사용 여부</TableHead>
|
||||
<TableHead>구분</TableHead>
|
||||
<TableHead>관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{displayRows.map(({ row, depth }) => (
|
||||
<AcntgSeRowComponent
|
||||
key={row.rowKey}
|
||||
row={row}
|
||||
depth={depth}
|
||||
onChange={(patch) => updateRow(row.rowKey, patch)}
|
||||
onAddChild={() => addChildRow(row)}
|
||||
onDelete={() => handleDelete(row)}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { AcntgCodeManagementPage };
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
TableCell,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import type { AcntgSeRow } from "../model/acntg-se-row-types";
|
||||
import {
|
||||
canAddChildRow,
|
||||
formatCdLvNo,
|
||||
formatTreeBranchPrefix,
|
||||
} from "../model/build-acntg-display-rows";
|
||||
|
||||
interface AcntgSeRowProps {
|
||||
row: AcntgSeRow;
|
||||
depth: number;
|
||||
onChange: (patch: Partial<AcntgSeRow>) => void;
|
||||
onAddChild: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function AcntgSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: AcntgSeRowProps) {
|
||||
const levelLabel = formatCdLvNo(row.cdLvNo);
|
||||
const branchPrefix = formatTreeBranchPrefix(depth);
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="flex min-w-[10rem] items-center gap-1">
|
||||
{branchPrefix ? (
|
||||
<span className="shrink-0 font-mono text-muted">{branchPrefix}</span>
|
||||
) : null}
|
||||
<Input
|
||||
value={row.acntgSeCd}
|
||||
onChange={(e) => onChange({ acntgSeCd: e.target.value })}
|
||||
placeholder="코드"
|
||||
maxLength={10}
|
||||
disabled={!row.isNew}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={row.acntgSeCdNm ?? ""}
|
||||
onChange={(e) => onChange({ acntgSeCdNm: e.target.value })}
|
||||
placeholder="코드명"
|
||||
maxLength={100}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={row.expln ?? ""}
|
||||
onChange={(e) => onChange({ expln: e.target.value })}
|
||||
placeholder="설명"
|
||||
maxLength={1000}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<RadioGroup
|
||||
value={row.useYn === "N" ? "N" : "Y"}
|
||||
onValueChange={(v) => onChange({ useYn: v })}
|
||||
className="flex justify-center gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="Y" id={`${row.rowKey}-use-y`} />
|
||||
<Label htmlFor={`${row.rowKey}-use-y`}>예</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="N" id={`${row.rowKey}-use-n`} />
|
||||
<Label htmlFor={`${row.rowKey}-use-n`}>아니오</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</TableCell>
|
||||
<TableCell align="center">{levelLabel}</TableCell>
|
||||
<TableCell align="center">
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="white"
|
||||
disabled={!canAddChildRow()}
|
||||
onClick={onAddChild}
|
||||
>
|
||||
하위추가
|
||||
</Button>
|
||||
<Button type="button" size="sm" color="gray" onClick={onDelete}>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export { AcntgSeRowComponent };
|
||||
@@ -0,0 +1 @@
|
||||
export { ApprovalLineManagementPage } from "./ui/approval-line-management-page";
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ElaprAplnRow } from "../model/apln-row-types";
|
||||
|
||||
/** 그리드 행 순서(1-based)로 결재순서번호 일괄 채번 */
|
||||
export function assignAtrzSeqNumbers(rows: ElaprAplnRow[]): ElaprAplnRow[] {
|
||||
return rows.map((row, index) => ({ ...row, atrzSeqNo: index + 1 }));
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ELAPR_APLN_SAVE_MODE, type ElaprApln, type ElaprAplnSaveRequest } from "@/entities/elapr";
|
||||
|
||||
import { assignAtrzSeqNumbers } from "./assign-atrz-seq";
|
||||
import { getVisibleRows } from "./validators";
|
||||
import type { ElaprAplnRow } from "../model/apln-row-types";
|
||||
|
||||
function rowDiffersFromServer(row: ElaprAplnRow, serverRow: ElaprApln): boolean {
|
||||
return (
|
||||
row.aprvrUuid !== serverRow.aprvrUuid ||
|
||||
row.atrzSeComCd !== serverRow.atrzSeComCd ||
|
||||
row.atrzSeqNo !== serverRow.atrzSeqNo
|
||||
);
|
||||
}
|
||||
|
||||
function shouldIncludeUpdate(row: ElaprAplnRow, serverRows: ElaprApln[]): boolean {
|
||||
if (row.isDirty) return true;
|
||||
if (!row.aplnId) return false;
|
||||
|
||||
const serverRow = serverRows.find((s) => s.aplnId === row.aplnId);
|
||||
if (!serverRow) return false;
|
||||
|
||||
return rowDiffersFromServer(row, serverRow);
|
||||
}
|
||||
|
||||
export function buildSaveMultiPayload(
|
||||
rows: ElaprAplnRow[],
|
||||
elaprTmpltId: string,
|
||||
serverRows: ElaprApln[],
|
||||
): ElaprAplnSaveRequest[] {
|
||||
const payload: ElaprAplnSaveRequest[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.isDeleted && row.aplnId) {
|
||||
payload.push({
|
||||
mode: ELAPR_APLN_SAVE_MODE.DELETE,
|
||||
aplnId: row.aplnId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const visibleWithSeq = assignAtrzSeqNumbers(getVisibleRows(rows));
|
||||
|
||||
for (const row of visibleWithSeq) {
|
||||
if (row.isNew) {
|
||||
payload.push({
|
||||
mode: ELAPR_APLN_SAVE_MODE.CREATE,
|
||||
elaprTmpltId,
|
||||
aprvrUuid: row.aprvrUuid,
|
||||
atrzSeComCd: row.atrzSeComCd,
|
||||
atrzSeqNo: row.atrzSeqNo,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!row.aplnId) continue;
|
||||
|
||||
if (shouldIncludeUpdate(row, serverRows)) {
|
||||
payload.push({
|
||||
mode: ELAPR_APLN_SAVE_MODE.UPDATE,
|
||||
aplnId: row.aplnId,
|
||||
elaprTmpltId,
|
||||
aprvrUuid: row.aprvrUuid,
|
||||
atrzSeComCd: row.atrzSeComCd,
|
||||
atrzSeqNo: row.atrzSeqNo,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user