Compare commits
471 Commits
common/fea
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
147d187901 | |
|
|
d09a6977f7 | |
|
|
faf4100056 | |
|
|
410b4a7b14 | |
|
|
e4667cce5f | |
|
|
c282d5c611 | |
|
|
d4afc06f4a | |
|
|
f2ab4f11bd | |
|
|
514d852fa6 | |
|
|
8603fddbcb | |
|
|
58adc0a100 | |
|
|
0382c94d73 | |
|
|
49f67451eb | |
|
|
e3852aca5d | |
|
|
df8065503d | |
|
|
0a85146564 | |
|
|
ad3b853d04 | |
|
|
2a3cc7ba00 | |
|
|
ee273c5103 | |
|
|
50a25cb9de | |
|
|
d1631d15ff | |
|
|
a020985630 | |
|
|
351ecbb35d | |
|
|
d32e933c03 | |
|
|
4497985104 | |
|
|
b97b0cc7d7 | |
|
|
160ad87395 | |
|
|
4972f26cee | |
|
|
02eee979ea | |
|
|
08de1372c5 | |
|
|
ab52c49492 | |
|
|
8a865ac1f4 | |
|
|
0a89cc2fb0 | |
|
|
ab3a493abb | |
|
|
ac0f461832 | |
|
|
c2256de8ec | |
|
|
484c98da9e | |
|
|
b2dc06d0f2 | |
|
|
efa95af4b9 | |
|
|
e8bdcbb95c | |
|
|
60ae073606 | |
|
|
a36802ab10 | |
|
|
98c489ee22 | |
|
|
c77c6290d3 | |
|
|
9dc549be09 | |
|
|
40a226ca30 | |
|
|
5d89b69451 | |
|
|
7fd3364aef | |
|
|
2326c3548b | |
|
|
220ce57be1 | |
|
|
0ac83b1551 | |
|
|
3f474ecddd | |
|
|
ddf5ed4006 | |
|
|
c4ee084a1d | |
|
|
2e02ace388 | |
|
|
435eb90763 | |
|
|
98870b3348 | |
|
|
b7b750d134 | |
|
|
ac334db0b1 | |
|
|
16c9c71a23 | |
|
|
059ea6b30a | |
|
|
14f8714ea1 | |
|
|
a27cb85007 | |
|
|
b5d2195cd5 | |
|
|
0a3d42f3ad | |
|
|
b5c2e85496 | |
|
|
f321aaf7aa | |
|
|
26bb93ab6e | |
|
|
f9575d7b5f | |
|
|
c26b346054 | |
|
|
24315215de | |
|
|
ca73685bc2 | |
|
|
61a7f585b4 | |
|
|
cf97db7fbf | |
|
|
18b5161398 | |
|
|
b576837f18 | |
|
|
ef27e0e38f | |
|
|
d7d7dabe84 | |
|
|
d22fd078be | |
|
|
28fe908704 | |
|
|
1b5ae5fe1c | |
|
|
905a9f62c3 | |
|
|
989b7e53a7 | |
|
|
20e144af36 | |
|
|
e2a22bb853 | |
|
|
0deb466557 | |
|
|
f64279d084 | |
|
|
c74e97d66e | |
|
|
0beb8b20a3 | |
|
|
054da65a26 | |
|
|
75e6c9eb1a | |
|
|
0f2d0bb053 | |
|
|
306de370f1 | |
|
|
b6fefe2ebd | |
|
|
f799402564 | |
|
|
033f5eaf7e | |
|
|
d094b58ebf | |
|
|
3fa57ad2ae | |
|
|
821955cfac | |
|
|
b358a46c33 | |
|
|
b2add92abf | |
|
|
c2836a0209 | |
|
|
472fc8633c | |
|
|
4801ee5ca4 | |
|
|
87189c792e | |
|
|
9cc5bbbf05 | |
|
|
5f991db9c4 | |
|
|
9e7253a293 | |
|
|
31e87e0bca | |
|
|
0773989c74 | |
|
|
6732e7d969 | |
|
|
35f83c1937 | |
|
|
8aa6008351 | |
|
|
47b61a9a35 | |
|
|
d22c2ec96e | |
|
|
3677c77da0 | |
|
|
c11e80a43c | |
|
|
f8fb7d687e | |
|
|
a6569909a2 | |
|
|
5c9dda6826 | |
|
|
bcf512d2b5 | |
|
|
4d41cb40b6 | |
|
|
bf74dd0f92 | |
|
|
85ae1c1521 | |
|
|
38455325dd | |
|
|
f493f8ac80 | |
|
|
7fc341bca8 | |
|
|
ba2a281245 | |
|
|
aa0698556e | |
|
|
c76123a927 | |
|
|
ba20a2bf42 | |
|
|
23c9604672 | |
|
|
64c6942de3 | |
|
|
f07448ac17 | |
|
|
d49883d25f | |
|
|
217e390fe9 | |
|
|
363ef44586 | |
|
|
48aa004a7f | |
|
|
ee3a648917 | |
|
|
819a281df4 | |
|
|
dd1d3bb44d | |
|
|
52e6824e76 | |
|
|
80cf20e142 | |
|
|
abddb67a30 | |
|
|
a0a9253d2c | |
|
|
222a00b8a9 | |
|
|
e8516d9d6b | |
|
|
150a40e2a8 | |
|
|
cea3aa53ae | |
|
|
af4072cef1 | |
|
|
a50222e7d5 | |
|
|
69711f4e4b | |
|
|
2eccd1982c | |
|
|
0baffafac1 | |
|
|
910d070055 | |
|
|
8f4c95d20d | |
|
|
65e1c1a995 | |
|
|
d2c15d519d | |
|
|
583c6c8c79 | |
|
|
a52ab0b206 | |
|
|
551e893f15 | |
|
|
85f8637ce0 | |
|
|
b85b3cd578 | |
|
|
b8c8b31033 | |
|
|
0f57309d74 | |
|
|
4dfa82d3dd | |
|
|
34e48993e4 | |
|
|
9821afe9cd | |
|
|
38599a1bef | |
|
|
11e25694b9 | |
|
|
8928d851ca | |
|
|
3f81c449ad | |
|
|
00006bf2e2 | |
|
|
3e9bf29bcf | |
|
|
34ac1b0c42 | |
|
|
df94d73662 | |
|
|
dc449f6c69 | |
|
|
dcf3a63d9b | |
|
|
a3c83c834e | |
|
|
980c929d83 | |
|
|
a146667615 | |
|
|
2645d627da | |
|
|
f33d989202 | |
|
|
6a1343b847 | |
|
|
b61cb17aea | |
|
|
83eb92cb27 | |
|
|
5321ea5b80 | |
|
|
d90a403ed9 | |
|
|
c181385f11 | |
|
|
23ebae95d6 | |
|
|
17498b1b2b | |
|
|
384106dd95 | |
|
|
6f4c9b7fdd | |
|
|
26c61ee5b6 | |
|
|
d8ff49d1db | |
|
|
8c525673ab | |
|
|
47ac9ecd8a | |
|
|
1b633e55d2 | |
|
|
b279f8d58d | |
|
|
48e9840fa0 | |
|
|
62226918a7 | |
|
|
df70538027 | |
|
|
26020a29a0 | |
|
|
52df163fbb | |
|
|
777429af48 | |
|
|
856db80a36 | |
|
|
cd7adce874 | |
|
|
ca260aa260 | |
|
|
42d1a3fc5e | |
|
|
7c165a724e | |
|
|
0ce0860dcc | |
|
|
c6ff839e54 | |
|
|
e308fd0ccc | |
|
|
f2cb7d14ca | |
|
|
b5b229122b | |
|
|
126da9b46f | |
|
|
c365f06ed7 | |
|
|
563081fa1c | |
|
|
24331687d4 | |
|
|
ea848b97ee | |
|
|
15fc166683 | |
|
|
26fdab5b4e | |
|
|
12d3419b7f | |
|
|
a2b701a4bf | |
|
|
2213ad51b2 | |
|
|
7120d5edc3 | |
|
|
0eb005ce35 | |
|
|
4828488c72 | |
|
|
c1425be57f | |
|
|
25b7e637de | |
|
|
ad39374e54 | |
|
|
77bb917248 | |
|
|
6bf914d9b1 | |
|
|
e08c50c771 | |
|
|
0f027f2382 | |
|
|
09d574fb8a | |
|
|
6ae0778b4c | |
|
|
58b0e1b79b | |
|
|
f0322a49ad | |
|
|
5e27d21257 | |
|
|
efc9175fec | |
|
|
eb61506acd | |
|
|
75b5530d04 | |
|
|
cded99d644 | |
|
|
40fd5f9055 | |
|
|
0709b8df25 | |
|
|
6bfc1a97a3 | |
|
|
9ea0f1b84f | |
|
|
7cb8026979 | |
|
|
4f77c38207 | |
|
|
68017ed0e9 | |
|
|
338c885cfa | |
|
|
b3ee2b50e8 | |
|
|
64105bf525 | |
|
|
6925e3af3f | |
|
|
714698c20f | |
|
|
2a7066b6fd | |
|
|
5fbc76f85d | |
|
|
e747162058 | |
|
|
914f3d57f3 | |
|
|
12baad75c9 | |
|
|
85519e302f | |
|
|
98e96a1fb0 | |
|
|
76d7d5149b | |
|
|
239e4800c7 | |
|
|
6c75adb61d | |
|
|
7caf2dea94 | |
|
|
ad76bfe3b0 | |
|
|
4ad58ba942 | |
|
|
36a723b1a0 | |
|
|
5a94afc1d5 | |
|
|
2889e4c82c | |
|
|
c15ec8f7b9 | |
|
|
eb868965df | |
|
|
417e1d297b | |
|
|
0b1dc98e5c | |
|
|
fff10a1911 | |
|
|
2842930dba | |
|
|
5bdc903b0d | |
|
|
4ce0411809 | |
|
|
bd49db16c6 | |
|
|
113ef24bdf | |
|
|
7d6bff49aa | |
|
|
7b773f57b4 | |
|
|
58233e51de | |
|
|
4421ccaa71 | |
|
|
183f68e89a | |
|
|
fb82d2f5a1 | |
|
|
84a3956b02 | |
|
|
c78326bae1 | |
|
|
b45f4870e8 | |
|
|
fd58e9cce2 | |
|
|
c7efe8ec33 | |
|
|
3c4e251e9b | |
|
|
e902987e44 | |
|
|
54ca51258c | |
|
|
06d5069566 | |
|
|
e1d6c1740f | |
|
|
c32bd8a4bf | |
|
|
e040b94a62 | |
|
|
6476a83d86 | |
|
|
ea3b6d2083 | |
|
|
87caa4b3ca | |
|
|
e30b1cc01a | |
|
|
89ce2a9cd0 | |
|
|
ef991b3b26 | |
|
|
22e0ce1fc5 | |
|
|
00376202fd | |
|
|
6365ce4921 | |
|
|
47b23d1aa3 | |
|
|
f63399c1e1 | |
|
|
7ece757d3d | |
|
|
722b4787e2 | |
|
|
486e5ee29b | |
|
|
171ed6e938 | |
|
|
c20e393a1a | |
|
|
f300b637d1 | |
|
|
386ce629ac | |
|
|
a299195b42 | |
|
|
352f9f441f | |
|
|
aa283d11da | |
|
|
6bd25c8a9e | |
|
|
9878f1f502 | |
|
|
3396834417 | |
|
|
718788110a | |
|
|
ed2e0a1c6b | |
|
|
9fe22bc422 | |
|
|
859d68fff8 | |
|
|
a7edd74574 | |
|
|
755bbc0c58 | |
|
|
67471b2518 | |
|
|
542c0bae94 | |
|
|
82a7ff62ee | |
|
|
83f171189b | |
|
|
050a183c96 | |
|
|
e1567d3f77 | |
|
|
da195200a8 | |
|
|
c910572754 | |
|
|
4187ec0745 | |
|
|
73cc969bd8 | |
|
|
5f406fbe88 | |
|
|
533eaf5c9f | |
|
|
7875d8ab86 | |
|
|
e1a032933d | |
|
|
99c0960325 | |
|
|
ae6d917ec4 | |
|
|
5f26e998e3 | |
|
|
b85b888007 | |
|
|
9493d81903 | |
|
|
d7f015b37d | |
|
|
e8b581f5da | |
|
|
d90e68905e | |
|
|
f1c4891924 | |
|
|
002c71f9e8 | |
|
|
117912045f | |
|
|
6a4ebf362c | |
|
|
0decfe95de | |
|
|
2b912105a8 | |
|
|
acc867e38d | |
|
|
c5cb4336e5 | |
|
|
01778661ed | |
|
|
6fced32e29 | |
|
|
1cadafea0e | |
|
|
9162e3aa96 | |
|
|
79b3c19c68 | |
|
|
43ae8d1c49 | |
|
|
a8bc7983c0 | |
|
|
506a31df02 | |
|
|
8789b2b864 | |
|
|
8d34b73a45 | |
|
|
ea01309158 | |
|
|
45749c99c8 | |
|
|
43a6fb675f | |
|
|
961e7e9a14 | |
|
|
f38447be8e | |
|
|
a1b05b8982 | |
|
|
932eb288c6 | |
|
|
09f477172c | |
|
|
958624012d | |
|
|
483dbf8a1f | |
|
|
9fb94da493 | |
|
|
f1c775b691 | |
|
|
69754a31cb | |
|
|
9684a83f37 | |
|
|
2e7a215066 | |
|
|
228c497569 | |
|
|
01422e035b | |
|
|
adb21a5308 | |
|
|
228fd33a2a | |
|
|
c86140fad3 | |
|
|
9902b65598 | |
|
|
981ec27ed7 | |
|
|
849343ecfd | |
|
|
51c788cae8 | |
|
|
06d2cf7f72 | |
|
|
fdb9ef9167 | |
|
|
84efaed1eb | |
|
|
bdb70ce5b7 | |
|
|
8306d7961c | |
|
|
61ceab1a7b | |
|
|
90d136ca85 | |
|
|
da24db8f37 | |
|
|
a617c26721 | |
|
|
66bd21ee65 | |
|
|
1c6eb2ae61 | |
|
|
cf8a5a3d93 | |
|
|
a24654c867 | |
|
|
79c1a456f0 | |
|
|
ca86c0a10f | |
|
|
4e987f208a | |
|
|
bca6de9811 | |
|
|
f03b247db2 | |
|
|
176e9cf421 | |
|
|
7ca4eea5c1 | |
|
|
41442dccc2 | |
|
|
c5f24dc789 | |
|
|
ac8961160d | |
|
|
36bac321b8 | |
|
|
403bd0f8a1 | |
|
|
75e5326b3e | |
|
|
1fd428c016 | |
|
|
c3f066f88f | |
|
|
061fd45bc8 | |
|
|
f1a670ca9a | |
|
|
ff3c51c457 | |
|
|
0ed8e686c0 | |
|
|
0abe87ae1a | |
|
|
6c7807e1d1 | |
|
|
c6f0750050 | |
|
|
ffd31fc923 | |
|
|
7b30f6c7f2 | |
|
|
3589e4a5b9 | |
|
|
60b4bffdf9 | |
|
|
fb4b5b7e26 | |
|
|
8687c88f70 | |
|
|
6dcace3135 | |
|
|
b7b881ee86 | |
|
|
f47a0c770b | |
|
|
6f7a76febe | |
|
|
44f5265105 | |
|
|
e50ddd03d3 | |
|
|
ae38e0f249 | |
|
|
52db6fd43c | |
|
|
7acb4981b5 | |
|
|
5e0dae0aae | |
|
|
2e122b0703 | |
|
|
132cf4cd7d | |
|
|
0ec6d082d6 | |
|
|
0810debd2b | |
|
|
857e46eab6 | |
|
|
be916d3db7 | |
|
|
ccbbf46faf | |
|
|
1995c3dca4 | |
|
|
3d287bb883 | |
|
|
31746e8a0b | |
|
|
0832e7b6eb | |
|
|
00afa77d87 | |
|
|
d6f40f3cd3 | |
|
|
a73b37f558 | |
|
|
3a55ea3b64 | |
|
|
963e0c2d24 | |
|
|
f7e3c1924c | |
|
|
342042d761 | |
|
|
d8329d31e4 | |
|
|
56608001ff | |
|
|
4e74c7b5ba | |
|
|
b3e6613d66 | |
|
|
cb38864ad8 | |
|
|
109380b9e5 | |
|
|
6449eb5ac3 | |
|
|
3c73c20292 |
|
|
@ -0,0 +1,559 @@
|
||||||
|
# 다국어 지원 컴포넌트 개발 가이드
|
||||||
|
|
||||||
|
새로운 화면 컴포넌트를 개발할 때 반드시 다국어 시스템을 고려해야 합니다.
|
||||||
|
이 가이드는 컴포넌트가 다국어 자동 생성 및 매핑 시스템과 호환되도록 하는 방법을 설명합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 타입 정의 시 다국어 필드 추가
|
||||||
|
|
||||||
|
### 기본 원칙
|
||||||
|
|
||||||
|
텍스트가 표시되는 **모든 속성**에 `langKeyId`와 `langKey` 필드를 함께 정의해야 합니다.
|
||||||
|
|
||||||
|
### 단일 텍스트 속성
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface MyComponentConfig {
|
||||||
|
// 기본 텍스트
|
||||||
|
title?: string;
|
||||||
|
// 다국어 키 (필수 추가)
|
||||||
|
titleLangKeyId?: number;
|
||||||
|
titleLangKey?: string;
|
||||||
|
|
||||||
|
// 라벨
|
||||||
|
label?: string;
|
||||||
|
labelLangKeyId?: number;
|
||||||
|
labelLangKey?: string;
|
||||||
|
|
||||||
|
// 플레이스홀더
|
||||||
|
placeholder?: string;
|
||||||
|
placeholderLangKeyId?: number;
|
||||||
|
placeholderLangKey?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 배열/목록 속성 (컬럼, 탭 등)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ColumnConfig {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
// 다국어 키 (필수 추가)
|
||||||
|
langKeyId?: number;
|
||||||
|
langKey?: string;
|
||||||
|
// 기타 속성
|
||||||
|
width?: number;
|
||||||
|
align?: "left" | "center" | "right";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabConfig {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
// 다국어 키 (필수 추가)
|
||||||
|
langKeyId?: number;
|
||||||
|
langKey?: string;
|
||||||
|
// 탭 제목도 별도로
|
||||||
|
title?: string;
|
||||||
|
titleLangKeyId?: number;
|
||||||
|
titleLangKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MyComponentConfig {
|
||||||
|
columns?: ColumnConfig[];
|
||||||
|
tabs?: TabConfig[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 버튼 컴포넌트
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ButtonComponentConfig {
|
||||||
|
text?: string;
|
||||||
|
// 다국어 키 (필수 추가)
|
||||||
|
langKeyId?: number;
|
||||||
|
langKey?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 실제 예시: 분할 패널
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SplitPanelLayoutConfig {
|
||||||
|
leftPanel?: {
|
||||||
|
title?: string;
|
||||||
|
langKeyId?: number; // 좌측 패널 제목 다국어
|
||||||
|
langKey?: string;
|
||||||
|
columns?: Array<{
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
langKeyId?: number; // 각 컬럼 다국어
|
||||||
|
langKey?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
rightPanel?: {
|
||||||
|
title?: string;
|
||||||
|
langKeyId?: number; // 우측 패널 제목 다국어
|
||||||
|
langKey?: string;
|
||||||
|
columns?: Array<{
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
langKeyId?: number;
|
||||||
|
langKey?: string;
|
||||||
|
}>;
|
||||||
|
additionalTabs?: Array<{
|
||||||
|
label: string;
|
||||||
|
langKeyId?: number; // 탭 라벨 다국어
|
||||||
|
langKey?: string;
|
||||||
|
title?: string;
|
||||||
|
titleLangKeyId?: number; // 탭 제목 다국어
|
||||||
|
titleLangKey?: string;
|
||||||
|
columns?: Array<{
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
langKeyId?: number;
|
||||||
|
langKey?: string;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 라벨 추출 로직 등록
|
||||||
|
|
||||||
|
### 파일 위치
|
||||||
|
|
||||||
|
`frontend/lib/utils/multilangLabelExtractor.ts`
|
||||||
|
|
||||||
|
### `extractMultilangLabels` 함수에 추가
|
||||||
|
|
||||||
|
새 컴포넌트의 라벨을 추출하는 로직을 추가해야 합니다.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 새 컴포넌트 타입 체크
|
||||||
|
if (comp.componentType === "my-new-component") {
|
||||||
|
const config = comp.componentConfig as MyComponentConfig;
|
||||||
|
|
||||||
|
// 1. 제목 추출
|
||||||
|
if (config?.title) {
|
||||||
|
addLabel({
|
||||||
|
id: `${comp.id}_title`,
|
||||||
|
componentId: `${comp.id}_title`,-
|
||||||
|
label: config.title,
|
||||||
|
type: "title",
|
||||||
|
parentType: "my-new-component",
|
||||||
|
parentLabel: config.title,
|
||||||
|
langKeyId: config.titleLangKeyId,
|
||||||
|
langKey: config.titleLangKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 컬럼 추출
|
||||||
|
if (config?.columns && Array.isArray(config.columns)) {
|
||||||
|
config.columns.forEach((col, index) => {
|
||||||
|
const colLabel = col.label || col.name;
|
||||||
|
addLabel({
|
||||||
|
id: `${comp.id}_col_${index}`,
|
||||||
|
componentId: `${comp.id}_col_${index}`,
|
||||||
|
label: colLabel,
|
||||||
|
type: "column",
|
||||||
|
parentType: "my-new-component",
|
||||||
|
parentLabel: config.title || "새 컴포넌트",
|
||||||
|
langKeyId: col.langKeyId,
|
||||||
|
langKey: col.langKey,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 버튼 텍스트 추출 (버튼 컴포넌트인 경우)
|
||||||
|
if (config?.text) {
|
||||||
|
addLabel({
|
||||||
|
id: `${comp.id}_button`,
|
||||||
|
componentId: `${comp.id}_button`,
|
||||||
|
label: config.text,
|
||||||
|
type: "button",
|
||||||
|
parentType: "my-new-component",
|
||||||
|
parentLabel: config.text,
|
||||||
|
langKeyId: config.langKeyId,
|
||||||
|
langKey: config.langKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 추출해야 할 라벨 타입
|
||||||
|
|
||||||
|
| 타입 | 설명 | 예시 |
|
||||||
|
| ------------- | ------------------ | ------------------------ |
|
||||||
|
| `title` | 컴포넌트/패널 제목 | 분할패널 제목, 카드 제목 |
|
||||||
|
| `label` | 입력 필드 라벨 | 텍스트 입력 라벨 |
|
||||||
|
| `button` | 버튼 텍스트 | 저장, 취소, 삭제 |
|
||||||
|
| `column` | 테이블 컬럼 헤더 | 품목명, 수량, 금액 |
|
||||||
|
| `tab` | 탭 라벨 | 기본정보, 상세정보 |
|
||||||
|
| `filter` | 검색 필터 라벨 | 검색어, 기간 |
|
||||||
|
| `placeholder` | 플레이스홀더 | "검색어를 입력하세요" |
|
||||||
|
| `action` | 액션 버튼/링크 | 수정, 삭제, 상세보기 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 매핑 적용 로직 등록
|
||||||
|
|
||||||
|
### 파일 위치
|
||||||
|
|
||||||
|
`frontend/lib/utils/multilangLabelExtractor.ts`
|
||||||
|
|
||||||
|
### `applyMultilangMappings` 함수에 추가
|
||||||
|
|
||||||
|
다국어 키가 선택되면 컴포넌트에 `langKeyId`와 `langKey`를 저장하는 로직을 추가합니다.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 새 컴포넌트 매핑 적용
|
||||||
|
if (comp.componentType === "my-new-component") {
|
||||||
|
const config = comp.componentConfig as MyComponentConfig;
|
||||||
|
|
||||||
|
// 1. 제목 매핑
|
||||||
|
const titleMapping = mappingMap.get(`${comp.id}_title`);
|
||||||
|
if (titleMapping) {
|
||||||
|
updated.componentConfig = {
|
||||||
|
...updated.componentConfig,
|
||||||
|
titleLangKeyId: titleMapping.keyId,
|
||||||
|
titleLangKey: titleMapping.langKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 컬럼 매핑
|
||||||
|
if (config?.columns && Array.isArray(config.columns)) {
|
||||||
|
const updatedColumns = config.columns.map((col, index) => {
|
||||||
|
const colMapping = mappingMap.get(`${comp.id}_col_${index}`);
|
||||||
|
if (colMapping) {
|
||||||
|
return {
|
||||||
|
...col,
|
||||||
|
langKeyId: colMapping.keyId,
|
||||||
|
langKey: colMapping.langKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return col;
|
||||||
|
});
|
||||||
|
updated.componentConfig = {
|
||||||
|
...updated.componentConfig,
|
||||||
|
columns: updatedColumns,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 버튼 매핑 (버튼 컴포넌트인 경우)
|
||||||
|
const buttonMapping = mappingMap.get(`${comp.id}_button`);
|
||||||
|
if (buttonMapping) {
|
||||||
|
updated.componentConfig = {
|
||||||
|
...updated.componentConfig,
|
||||||
|
langKeyId: buttonMapping.keyId,
|
||||||
|
langKey: buttonMapping.langKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 주의사항
|
||||||
|
|
||||||
|
- **객체 참조 유지**: 매핑 시 기존 `updated.componentConfig`를 기반으로 업데이트해야 합니다.
|
||||||
|
- **중첩 구조**: 중첩된 객체(예: `leftPanel.columns`)는 상위 객체부터 순서대로 업데이트합니다.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 잘못된 방법 - 이전 업데이트 덮어쓰기
|
||||||
|
updated.componentConfig = { ...config, langKeyId: mapping.keyId }; // ❌
|
||||||
|
updated.componentConfig = { ...config, columns: updatedColumns }; // langKeyId 사라짐!
|
||||||
|
|
||||||
|
// 올바른 방법 - 이전 업데이트 유지
|
||||||
|
updated.componentConfig = {
|
||||||
|
...updated.componentConfig,
|
||||||
|
langKeyId: mapping.keyId,
|
||||||
|
}; // ✅
|
||||||
|
updated.componentConfig = {
|
||||||
|
...updated.componentConfig,
|
||||||
|
columns: updatedColumns,
|
||||||
|
}; // ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 번역 표시 로직 구현
|
||||||
|
|
||||||
|
### 파일 위치
|
||||||
|
|
||||||
|
새 컴포넌트 파일 (예: `frontend/lib/registry/components/my-component/MyComponent.tsx`)
|
||||||
|
|
||||||
|
### Context 사용
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
|
||||||
|
|
||||||
|
const MyComponent = ({ component }: Props) => {
|
||||||
|
const { getTranslatedText } = useScreenMultiLang();
|
||||||
|
const config = component.componentConfig;
|
||||||
|
|
||||||
|
// 제목 번역
|
||||||
|
const displayTitle = config?.titleLangKey
|
||||||
|
? getTranslatedText(config.titleLangKey, config.title || "")
|
||||||
|
: config?.title || "";
|
||||||
|
|
||||||
|
// 컬럼 헤더 번역
|
||||||
|
const translatedColumns = config?.columns?.map((col) => ({
|
||||||
|
...col,
|
||||||
|
displayLabel: col.langKey
|
||||||
|
? getTranslatedText(col.langKey, col.label)
|
||||||
|
: col.label,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 버튼 텍스트 번역
|
||||||
|
const buttonText = config?.langKey
|
||||||
|
? getTranslatedText(config.langKey, config.text || "")
|
||||||
|
: config?.text || "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>{displayTitle}</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{translatedColumns?.map((col, idx) => (
|
||||||
|
<th key={idx}>{col.displayLabel}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>
|
||||||
|
<button>{buttonText}</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### getTranslatedText 함수
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 첫 번째 인자: langKey (다국어 키)
|
||||||
|
// 두 번째 인자: fallback (키가 없거나 번역이 없을 때 기본값)
|
||||||
|
const text = getTranslatedText(
|
||||||
|
"screen.company_1.Sales.OrderList.품목명",
|
||||||
|
"품목명"
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 주의사항
|
||||||
|
|
||||||
|
- `langKey`가 없으면 원본 텍스트를 표시합니다.
|
||||||
|
- `useScreenMultiLang`은 반드시 `ScreenMultiLangProvider` 내부에서 사용해야 합니다.
|
||||||
|
- 화면 페이지(`/screens/[screenId]/page.tsx`)에서 이미 Provider로 감싸져 있습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. ScreenMultiLangContext에 키 수집 로직 추가
|
||||||
|
|
||||||
|
### 파일 위치
|
||||||
|
|
||||||
|
`frontend/contexts/ScreenMultiLangContext.tsx`
|
||||||
|
|
||||||
|
### `collectLangKeys` 함수에 추가
|
||||||
|
|
||||||
|
번역을 미리 로드하기 위해 컴포넌트에서 사용하는 모든 `langKey`를 수집해야 합니다.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const collectLangKeys = (comps: ComponentData[]): Set<string> => {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
|
||||||
|
const processComponent = (comp: ComponentData) => {
|
||||||
|
const config = comp.componentConfig;
|
||||||
|
|
||||||
|
// 새 컴포넌트의 langKey 수집
|
||||||
|
if (comp.componentType === "my-new-component") {
|
||||||
|
// 제목
|
||||||
|
if (config?.titleLangKey) {
|
||||||
|
keys.add(config.titleLangKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컬럼
|
||||||
|
if (config?.columns && Array.isArray(config.columns)) {
|
||||||
|
config.columns.forEach((col: any) => {
|
||||||
|
if (col.langKey) {
|
||||||
|
keys.add(col.langKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 버튼
|
||||||
|
if (config?.langKey) {
|
||||||
|
keys.add(config.langKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자식 컴포넌트 재귀 처리
|
||||||
|
if (comp.children && Array.isArray(comp.children)) {
|
||||||
|
comp.children.forEach(processComponent);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
comps.forEach(processComponent);
|
||||||
|
return keys;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. MultilangSettingsModal에 표시 로직 추가
|
||||||
|
|
||||||
|
### 파일 위치
|
||||||
|
|
||||||
|
`frontend/components/screen/modals/MultilangSettingsModal.tsx`
|
||||||
|
|
||||||
|
### `extractLabelsFromComponents` 함수에 추가
|
||||||
|
|
||||||
|
다국어 설정 모달에서 새 컴포넌트의 라벨이 표시되도록 합니다.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 새 컴포넌트 라벨 추출
|
||||||
|
if (comp.componentType === "my-new-component") {
|
||||||
|
const config = comp.componentConfig as MyComponentConfig;
|
||||||
|
|
||||||
|
// 제목
|
||||||
|
if (config?.title) {
|
||||||
|
addLabel({
|
||||||
|
id: `${comp.id}_title`,
|
||||||
|
componentId: `${comp.id}_title`,
|
||||||
|
label: config.title,
|
||||||
|
type: "title",
|
||||||
|
parentType: "my-new-component",
|
||||||
|
parentLabel: config.title,
|
||||||
|
langKeyId: config.titleLangKeyId,
|
||||||
|
langKey: config.titleLangKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컬럼
|
||||||
|
if (config?.columns) {
|
||||||
|
config.columns.forEach((col, index) => {
|
||||||
|
// columnLabelMap에서 라벨 가져오기 (테이블 컬럼인 경우)
|
||||||
|
const tableName = config.tableName;
|
||||||
|
const displayLabel =
|
||||||
|
tableName && columnLabelMap[tableName]?.[col.name]
|
||||||
|
? columnLabelMap[tableName][col.name]
|
||||||
|
: col.label || col.name;
|
||||||
|
|
||||||
|
addLabel({
|
||||||
|
id: `${comp.id}_col_${index}`,
|
||||||
|
componentId: `${comp.id}_col_${index}`,
|
||||||
|
label: displayLabel,
|
||||||
|
type: "column",
|
||||||
|
parentType: "my-new-component",
|
||||||
|
parentLabel: config.title || "새 컴포넌트",
|
||||||
|
langKeyId: col.langKeyId,
|
||||||
|
langKey: col.langKey,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 테이블명 추출 (테이블 사용 컴포넌트인 경우)
|
||||||
|
|
||||||
|
### 파일 위치
|
||||||
|
|
||||||
|
`frontend/lib/utils/multilangLabelExtractor.ts`
|
||||||
|
|
||||||
|
### `extractTableNames` 함수에 추가
|
||||||
|
|
||||||
|
컴포넌트가 테이블을 사용하는 경우, 테이블명을 추출해야 컬럼 라벨을 가져올 수 있습니다.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const extractTableNames = (comps: ComponentData[]): Set<string> => {
|
||||||
|
const tableNames = new Set<string>();
|
||||||
|
|
||||||
|
const processComponent = (comp: ComponentData) => {
|
||||||
|
const config = comp.componentConfig;
|
||||||
|
|
||||||
|
// 새 컴포넌트의 테이블명 추출
|
||||||
|
if (comp.componentType === "my-new-component") {
|
||||||
|
if (config?.tableName) {
|
||||||
|
tableNames.add(config.tableName);
|
||||||
|
}
|
||||||
|
if (config?.selectedTable) {
|
||||||
|
tableNames.add(config.selectedTable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자식 컴포넌트 재귀 처리
|
||||||
|
if (comp.children && Array.isArray(comp.children)) {
|
||||||
|
comp.children.forEach(processComponent);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
comps.forEach(processComponent);
|
||||||
|
return tableNames;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 체크리스트
|
||||||
|
|
||||||
|
새 컴포넌트 개발 시 다음 항목을 확인하세요:
|
||||||
|
|
||||||
|
### 타입 정의
|
||||||
|
|
||||||
|
- [ ] 모든 텍스트 속성에 `langKeyId`, `langKey` 필드 추가
|
||||||
|
- [ ] 배열 속성(columns, tabs 등)의 각 항목에도 다국어 필드 추가
|
||||||
|
|
||||||
|
### 라벨 추출 (multilangLabelExtractor.ts)
|
||||||
|
|
||||||
|
- [ ] `extractMultilangLabels` 함수에 라벨 추출 로직 추가
|
||||||
|
- [ ] `extractTableNames` 함수에 테이블명 추출 로직 추가 (해당되는 경우)
|
||||||
|
|
||||||
|
### 매핑 적용 (multilangLabelExtractor.ts)
|
||||||
|
|
||||||
|
- [ ] `applyMultilangMappings` 함수에 매핑 적용 로직 추가
|
||||||
|
|
||||||
|
### 번역 표시 (컴포넌트 파일)
|
||||||
|
|
||||||
|
- [ ] `useScreenMultiLang` 훅 사용
|
||||||
|
- [ ] `getTranslatedText`로 텍스트 번역 적용
|
||||||
|
|
||||||
|
### 키 수집 (ScreenMultiLangContext.tsx)
|
||||||
|
|
||||||
|
- [ ] `collectLangKeys` 함수에 langKey 수집 로직 추가
|
||||||
|
|
||||||
|
### 설정 모달 (MultilangSettingsModal.tsx)
|
||||||
|
|
||||||
|
- [ ] `extractLabelsFromComponents`에 라벨 표시 로직 추가
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 관련 파일 목록
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
| -------------------------------------------------------------- | ----------------------- |
|
||||||
|
| `frontend/lib/utils/multilangLabelExtractor.ts` | 라벨 추출 및 매핑 적용 |
|
||||||
|
| `frontend/contexts/ScreenMultiLangContext.tsx` | 번역 Context 및 키 수집 |
|
||||||
|
| `frontend/components/screen/modals/MultilangSettingsModal.tsx` | 다국어 설정 UI |
|
||||||
|
| `frontend/components/screen/ScreenDesigner.tsx` | 다국어 생성 버튼 처리 |
|
||||||
|
| `backend-node/src/services/multilangService.ts` | 다국어 키 생성 서비스 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 주의사항
|
||||||
|
|
||||||
|
1. **componentId 형식 일관성**: 라벨 추출과 매핑 적용에서 동일한 ID 형식 사용
|
||||||
|
|
||||||
|
- 제목: `${comp.id}_title`
|
||||||
|
- 컬럼: `${comp.id}_col_${index}`
|
||||||
|
- 버튼: `${comp.id}_button`
|
||||||
|
|
||||||
|
2. **중첩 구조 주의**: 분할패널처럼 중첩된 구조는 경로를 명확히 지정
|
||||||
|
|
||||||
|
- `${comp.id}_left_title`, `${comp.id}_right_col_${index}`
|
||||||
|
|
||||||
|
3. **기존 값 보존**: 매핑 적용 시 `updated.componentConfig`를 기반으로 업데이트
|
||||||
|
|
||||||
|
4. **라벨 타입 구분**: 입력 폼의 `label`과 다른 컴포넌트의 `label`을 구분하여 처리
|
||||||
|
|
||||||
|
5. **테스트**: 다국어 생성 → 다국어 설정 → 언어 변경 순서로 테스트
|
||||||
|
|
@ -0,0 +1,592 @@
|
||||||
|
# 테이블 타입 관리 SQL 작성 가이드
|
||||||
|
|
||||||
|
테이블 타입 관리에서 테이블 생성 시 적용되는 컬럼, 타입, 메타데이터 등록 로직을 기반으로 한 SQL 작성 가이드입니다.
|
||||||
|
|
||||||
|
## 핵심 원칙
|
||||||
|
|
||||||
|
1. **모든 비즈니스 컬럼은 `VARCHAR(500)`로 통일**: 날짜 타입 외 모든 컬럼은 `VARCHAR(500)`
|
||||||
|
2. **날짜/시간 컬럼만 `TIMESTAMP` 사용**: `created_date`, `updated_date` 등
|
||||||
|
3. **기본 컬럼 5개 자동 포함**: 모든 테이블에 id, created_date, updated_date, writer, company_code 필수
|
||||||
|
4. **3개 메타데이터 테이블 등록 필수**: `table_labels`, `column_labels`, `table_type_columns`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 테이블 생성 DDL 템플릿
|
||||||
|
|
||||||
|
### 기본 구조
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE "테이블명" (
|
||||||
|
-- 시스템 기본 컬럼 (자동 포함)
|
||||||
|
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||||
|
"created_date" timestamp DEFAULT now(),
|
||||||
|
"updated_date" timestamp DEFAULT now(),
|
||||||
|
"writer" varchar(500) DEFAULT NULL,
|
||||||
|
"company_code" varchar(500),
|
||||||
|
|
||||||
|
-- 사용자 정의 컬럼 (모두 VARCHAR(500))
|
||||||
|
"컬럼1" varchar(500),
|
||||||
|
"컬럼2" varchar(500),
|
||||||
|
"컬럼3" varchar(500)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시: 고객 테이블 생성
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE "customer_info" (
|
||||||
|
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||||
|
"created_date" timestamp DEFAULT now(),
|
||||||
|
"updated_date" timestamp DEFAULT now(),
|
||||||
|
"writer" varchar(500) DEFAULT NULL,
|
||||||
|
"company_code" varchar(500),
|
||||||
|
|
||||||
|
"customer_name" varchar(500),
|
||||||
|
"customer_code" varchar(500),
|
||||||
|
"phone" varchar(500),
|
||||||
|
"email" varchar(500),
|
||||||
|
"address" varchar(500),
|
||||||
|
"status" varchar(500),
|
||||||
|
"registration_date" varchar(500)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 메타데이터 테이블 등록
|
||||||
|
|
||||||
|
테이블 생성 시 반드시 아래 3개 테이블에 메타데이터를 등록해야 합니다.
|
||||||
|
|
||||||
|
### 2.1 table_labels (테이블 메타데이터)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
|
||||||
|
VALUES ('테이블명', '테이블 라벨', '테이블 설명', now(), now())
|
||||||
|
ON CONFLICT (table_name)
|
||||||
|
DO UPDATE SET
|
||||||
|
table_label = EXCLUDED.table_label,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
updated_date = now();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 table_type_columns (컬럼 타입 정보)
|
||||||
|
|
||||||
|
**필수 컬럼**: `table_name`, `column_name`, `company_code`, `input_type`, `display_order`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 기본 컬럼 등록 (display_order: -5 ~ -1)
|
||||||
|
INSERT INTO table_type_columns (
|
||||||
|
table_name, column_name, company_code, input_type, detail_settings,
|
||||||
|
is_nullable, display_order, created_date, updated_date
|
||||||
|
) VALUES
|
||||||
|
('테이블명', 'id', '*', 'text', '{}', 'Y', -5, now(), now()),
|
||||||
|
('테이블명', 'created_date', '*', 'date', '{}', 'Y', -4, now(), now()),
|
||||||
|
('테이블명', 'updated_date', '*', 'date', '{}', 'Y', -3, now(), now()),
|
||||||
|
('테이블명', 'writer', '*', 'text', '{}', 'Y', -2, now(), now()),
|
||||||
|
('테이블명', 'company_code', '*', 'text', '{}', 'Y', -1, now(), now())
|
||||||
|
ON CONFLICT (table_name, column_name, company_code)
|
||||||
|
DO UPDATE SET
|
||||||
|
input_type = EXCLUDED.input_type,
|
||||||
|
display_order = EXCLUDED.display_order,
|
||||||
|
updated_date = now();
|
||||||
|
|
||||||
|
-- 사용자 정의 컬럼 등록 (display_order: 0부터 시작)
|
||||||
|
INSERT INTO table_type_columns (
|
||||||
|
table_name, column_name, company_code, input_type, detail_settings,
|
||||||
|
is_nullable, display_order, created_date, updated_date
|
||||||
|
) VALUES
|
||||||
|
('테이블명', '컬럼1', '*', 'text', '{}', 'Y', 0, now(), now()),
|
||||||
|
('테이블명', '컬럼2', '*', 'number', '{}', 'Y', 1, now(), now()),
|
||||||
|
('테이블명', '컬럼3', '*', 'code', '{"codeCategory":"카테고리코드"}', 'Y', 2, now(), now())
|
||||||
|
ON CONFLICT (table_name, column_name, company_code)
|
||||||
|
DO UPDATE SET
|
||||||
|
input_type = EXCLUDED.input_type,
|
||||||
|
detail_settings = EXCLUDED.detail_settings,
|
||||||
|
display_order = EXCLUDED.display_order,
|
||||||
|
updated_date = now();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 column_labels (레거시 호환용 - 필수)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 기본 컬럼 등록
|
||||||
|
INSERT INTO column_labels (
|
||||||
|
table_name, column_name, column_label, input_type, detail_settings,
|
||||||
|
description, display_order, is_visible, created_date, updated_date
|
||||||
|
) VALUES
|
||||||
|
('테이블명', 'id', 'ID', 'text', '{}', '기본키 (자동생성)', -5, true, now(), now()),
|
||||||
|
('테이블명', 'created_date', '생성일시', 'date', '{}', '레코드 생성일시', -4, true, now(), now()),
|
||||||
|
('테이블명', 'updated_date', '수정일시', 'date', '{}', '레코드 수정일시', -3, true, now(), now()),
|
||||||
|
('테이블명', 'writer', '작성자', 'text', '{}', '레코드 작성자', -2, true, now(), now()),
|
||||||
|
('테이블명', 'company_code', '회사코드', 'text', '{}', '회사 구분 코드', -1, true, now(), now())
|
||||||
|
ON CONFLICT (table_name, column_name)
|
||||||
|
DO UPDATE SET
|
||||||
|
column_label = EXCLUDED.column_label,
|
||||||
|
input_type = EXCLUDED.input_type,
|
||||||
|
detail_settings = EXCLUDED.detail_settings,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
display_order = EXCLUDED.display_order,
|
||||||
|
is_visible = EXCLUDED.is_visible,
|
||||||
|
updated_date = now();
|
||||||
|
|
||||||
|
-- 사용자 정의 컬럼 등록
|
||||||
|
INSERT INTO column_labels (
|
||||||
|
table_name, column_name, column_label, input_type, detail_settings,
|
||||||
|
description, display_order, is_visible, created_date, updated_date
|
||||||
|
) VALUES
|
||||||
|
('테이블명', '컬럼1', '컬럼1 라벨', 'text', '{}', '컬럼1 설명', 0, true, now(), now()),
|
||||||
|
('테이블명', '컬럼2', '컬럼2 라벨', 'number', '{}', '컬럼2 설명', 1, true, now(), now())
|
||||||
|
ON CONFLICT (table_name, column_name)
|
||||||
|
DO UPDATE SET
|
||||||
|
column_label = EXCLUDED.column_label,
|
||||||
|
input_type = EXCLUDED.input_type,
|
||||||
|
detail_settings = EXCLUDED.detail_settings,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
display_order = EXCLUDED.display_order,
|
||||||
|
is_visible = EXCLUDED.is_visible,
|
||||||
|
updated_date = now();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Input Type 정의
|
||||||
|
|
||||||
|
### 지원되는 Input Type 목록
|
||||||
|
|
||||||
|
| input_type | 설명 | DB 저장 타입 | UI 컴포넌트 |
|
||||||
|
| ---------- | ------------- | ------------ | -------------------- |
|
||||||
|
| `text` | 텍스트 입력 | VARCHAR(500) | Input |
|
||||||
|
| `number` | 숫자 입력 | VARCHAR(500) | Input (type=number) |
|
||||||
|
| `date` | 날짜/시간 | VARCHAR(500) | DatePicker |
|
||||||
|
| `code` | 공통코드 선택 | VARCHAR(500) | Select (코드 목록) |
|
||||||
|
| `entity` | 엔티티 참조 | VARCHAR(500) | Select (테이블 참조) |
|
||||||
|
| `select` | 선택 목록 | VARCHAR(500) | Select |
|
||||||
|
| `checkbox` | 체크박스 | VARCHAR(500) | Checkbox |
|
||||||
|
| `radio` | 라디오 버튼 | VARCHAR(500) | RadioGroup |
|
||||||
|
| `textarea` | 긴 텍스트 | VARCHAR(500) | Textarea |
|
||||||
|
| `file` | 파일 업로드 | VARCHAR(500) | FileUpload |
|
||||||
|
|
||||||
|
### WebType → InputType 변환 규칙
|
||||||
|
|
||||||
|
```
|
||||||
|
text, textarea, email, tel, url, password → text
|
||||||
|
number, decimal → number
|
||||||
|
date, datetime, time → date
|
||||||
|
select, dropdown → select
|
||||||
|
checkbox, boolean → checkbox
|
||||||
|
radio → radio
|
||||||
|
code → code
|
||||||
|
entity → entity
|
||||||
|
file → text
|
||||||
|
button → text
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Detail Settings 설정
|
||||||
|
|
||||||
|
### 4.1 Code 타입 (공통코드 참조)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"codeCategory": "코드_카테고리_ID"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO table_type_columns (..., input_type, detail_settings, ...)
|
||||||
|
VALUES (..., 'code', '{"codeCategory":"STATUS_CODE"}', ...);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Entity 타입 (테이블 참조)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"referenceTable": "참조_테이블명",
|
||||||
|
"referenceColumn": "참조_컬럼명(보통 id)",
|
||||||
|
"displayColumn": "표시할_컬럼명"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO table_type_columns (..., input_type, detail_settings, ...)
|
||||||
|
VALUES (..., 'entity', '{"referenceTable":"user_info","referenceColumn":"id","displayColumn":"user_name"}', ...);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Select 타입 (정적 옵션)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"options": [
|
||||||
|
{ "label": "옵션1", "value": "value1" },
|
||||||
|
{ "label": "옵션2", "value": "value2" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 전체 예시: 주문 테이블 생성
|
||||||
|
|
||||||
|
### Step 1: DDL 실행
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE "order_info" (
|
||||||
|
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||||
|
"created_date" timestamp DEFAULT now(),
|
||||||
|
"updated_date" timestamp DEFAULT now(),
|
||||||
|
"writer" varchar(500) DEFAULT NULL,
|
||||||
|
"company_code" varchar(500),
|
||||||
|
|
||||||
|
"order_no" varchar(500),
|
||||||
|
"order_date" varchar(500),
|
||||||
|
"customer_id" varchar(500),
|
||||||
|
"total_amount" varchar(500),
|
||||||
|
"status" varchar(500),
|
||||||
|
"notes" varchar(500)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: table_labels 등록
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
|
||||||
|
VALUES ('order_info', '주문 정보', '주문 관리 테이블', now(), now())
|
||||||
|
ON CONFLICT (table_name)
|
||||||
|
DO UPDATE SET
|
||||||
|
table_label = EXCLUDED.table_label,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
updated_date = now();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: table_type_columns 등록
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 기본 컬럼
|
||||||
|
INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date)
|
||||||
|
VALUES
|
||||||
|
('order_info', 'id', '*', 'text', '{}', 'Y', -5, now(), now()),
|
||||||
|
('order_info', 'created_date', '*', 'date', '{}', 'Y', -4, now(), now()),
|
||||||
|
('order_info', 'updated_date', '*', 'date', '{}', 'Y', -3, now(), now()),
|
||||||
|
('order_info', 'writer', '*', 'text', '{}', 'Y', -2, now(), now()),
|
||||||
|
('order_info', 'company_code', '*', 'text', '{}', 'Y', -1, now(), now())
|
||||||
|
ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, display_order = EXCLUDED.display_order, updated_date = now();
|
||||||
|
|
||||||
|
-- 사용자 정의 컬럼
|
||||||
|
INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date)
|
||||||
|
VALUES
|
||||||
|
('order_info', 'order_no', '*', 'text', '{}', 'Y', 0, now(), now()),
|
||||||
|
('order_info', 'order_date', '*', 'date', '{}', 'Y', 1, now(), now()),
|
||||||
|
('order_info', 'customer_id', '*', 'entity', '{"referenceTable":"customer_info","referenceColumn":"id","displayColumn":"customer_name"}', 'Y', 2, now(), now()),
|
||||||
|
('order_info', 'total_amount', '*', 'number', '{}', 'Y', 3, now(), now()),
|
||||||
|
('order_info', 'status', '*', 'code', '{"codeCategory":"ORDER_STATUS"}', 'Y', 4, now(), now()),
|
||||||
|
('order_info', 'notes', '*', 'textarea', '{}', 'Y', 5, now(), now())
|
||||||
|
ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, display_order = EXCLUDED.display_order, updated_date = now();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: column_labels 등록 (레거시 호환)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 기본 컬럼
|
||||||
|
INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date)
|
||||||
|
VALUES
|
||||||
|
('order_info', 'id', 'ID', 'text', '{}', '기본키', -5, true, now(), now()),
|
||||||
|
('order_info', 'created_date', '생성일시', 'date', '{}', '레코드 생성일시', -4, true, now(), now()),
|
||||||
|
('order_info', 'updated_date', '수정일시', 'date', '{}', '레코드 수정일시', -3, true, now(), now()),
|
||||||
|
('order_info', 'writer', '작성자', 'text', '{}', '레코드 작성자', -2, true, now(), now()),
|
||||||
|
('order_info', 'company_code', '회사코드', 'text', '{}', '회사 구분 코드', -1, true, now(), now())
|
||||||
|
ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, updated_date = now();
|
||||||
|
|
||||||
|
-- 사용자 정의 컬럼
|
||||||
|
INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date)
|
||||||
|
VALUES
|
||||||
|
('order_info', 'order_no', '주문번호', 'text', '{}', '주문 식별 번호', 0, true, now(), now()),
|
||||||
|
('order_info', 'order_date', '주문일자', 'date', '{}', '주문 발생 일자', 1, true, now(), now()),
|
||||||
|
('order_info', 'customer_id', '고객', 'entity', '{"referenceTable":"customer_info","referenceColumn":"id","displayColumn":"customer_name"}', '주문 고객', 2, true, now(), now()),
|
||||||
|
('order_info', 'total_amount', '총금액', 'number', '{}', '주문 총 금액', 3, true, now(), now()),
|
||||||
|
('order_info', 'status', '상태', 'code', '{"codeCategory":"ORDER_STATUS"}', '주문 상태', 4, true, now(), now()),
|
||||||
|
('order_info', 'notes', '비고', 'textarea', '{}', '추가 메모', 5, true, now(), now())
|
||||||
|
ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, description = EXCLUDED.description, display_order = EXCLUDED.display_order, updated_date = now();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 컬럼 추가 시
|
||||||
|
|
||||||
|
### DDL
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE "테이블명" ADD COLUMN "새컬럼명" varchar(500);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 메타데이터 등록
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- table_type_columns
|
||||||
|
INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date)
|
||||||
|
VALUES ('테이블명', '새컬럼명', '*', 'text', '{}', 'Y', (SELECT COALESCE(MAX(display_order), 0) + 1 FROM table_type_columns WHERE table_name = '테이블명'), now(), now())
|
||||||
|
ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, display_order = EXCLUDED.display_order, updated_date = now();
|
||||||
|
|
||||||
|
-- column_labels
|
||||||
|
INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date)
|
||||||
|
VALUES ('테이블명', '새컬럼명', '새컬럼 라벨', 'text', '{}', '새컬럼 설명', (SELECT COALESCE(MAX(display_order), 0) + 1 FROM column_labels WHERE table_name = '테이블명'), true, now(), now())
|
||||||
|
ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, updated_date = now();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 로그 테이블 생성 (선택사항)
|
||||||
|
|
||||||
|
변경 이력 추적이 필요한 테이블에는 로그 테이블을 생성할 수 있습니다.
|
||||||
|
|
||||||
|
### 7.1 로그 테이블 DDL 템플릿
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 로그 테이블 생성
|
||||||
|
CREATE TABLE 테이블명_log (
|
||||||
|
log_id SERIAL PRIMARY KEY,
|
||||||
|
operation_type VARCHAR(10) NOT NULL, -- INSERT/UPDATE/DELETE
|
||||||
|
original_id VARCHAR(100), -- 원본 테이블 PK 값
|
||||||
|
changed_column VARCHAR(100), -- 변경된 컬럼명
|
||||||
|
old_value TEXT, -- 변경 전 값
|
||||||
|
new_value TEXT, -- 변경 후 값
|
||||||
|
changed_by VARCHAR(50), -- 변경자 ID
|
||||||
|
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 변경 시각
|
||||||
|
ip_address VARCHAR(50), -- 변경 요청 IP
|
||||||
|
user_agent TEXT, -- User Agent
|
||||||
|
full_row_before JSONB, -- 변경 전 전체 행
|
||||||
|
full_row_after JSONB -- 변경 후 전체 행
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 인덱스 생성
|
||||||
|
CREATE INDEX idx_테이블명_log_original_id ON 테이블명_log(original_id);
|
||||||
|
CREATE INDEX idx_테이블명_log_changed_at ON 테이블명_log(changed_at);
|
||||||
|
CREATE INDEX idx_테이블명_log_operation ON 테이블명_log(operation_type);
|
||||||
|
|
||||||
|
-- 코멘트 추가
|
||||||
|
COMMENT ON TABLE 테이블명_log IS '테이블명 테이블 변경 이력';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 트리거 함수 DDL 템플릿
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE OR REPLACE FUNCTION 테이블명_log_trigger_func()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_column_name TEXT;
|
||||||
|
v_old_value TEXT;
|
||||||
|
v_new_value TEXT;
|
||||||
|
v_user_id VARCHAR(50);
|
||||||
|
v_ip_address VARCHAR(50);
|
||||||
|
BEGIN
|
||||||
|
v_user_id := current_setting('app.user_id', TRUE);
|
||||||
|
v_ip_address := current_setting('app.ip_address', TRUE);
|
||||||
|
|
||||||
|
IF (TG_OP = 'INSERT') THEN
|
||||||
|
INSERT INTO 테이블명_log (operation_type, original_id, changed_by, ip_address, full_row_after)
|
||||||
|
VALUES ('INSERT', NEW.id, v_user_id, v_ip_address, row_to_json(NEW)::jsonb);
|
||||||
|
RETURN NEW;
|
||||||
|
|
||||||
|
ELSIF (TG_OP = 'UPDATE') THEN
|
||||||
|
FOR v_column_name IN
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = '테이블명'
|
||||||
|
AND table_schema = 'public'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name)
|
||||||
|
INTO v_old_value, v_new_value
|
||||||
|
USING OLD, NEW;
|
||||||
|
|
||||||
|
IF v_old_value IS DISTINCT FROM v_new_value THEN
|
||||||
|
INSERT INTO 테이블명_log (
|
||||||
|
operation_type, original_id, changed_column, old_value, new_value,
|
||||||
|
changed_by, ip_address, full_row_before, full_row_after
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'UPDATE', NEW.id, v_column_name, v_old_value, v_new_value,
|
||||||
|
v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
RETURN NEW;
|
||||||
|
|
||||||
|
ELSIF (TG_OP = 'DELETE') THEN
|
||||||
|
INSERT INTO 테이블명_log (operation_type, original_id, changed_by, ip_address, full_row_before)
|
||||||
|
VALUES ('DELETE', OLD.id, v_user_id, v_ip_address, row_to_json(OLD)::jsonb);
|
||||||
|
RETURN OLD;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 트리거 DDL 템플릿
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TRIGGER 테이블명_audit_trigger
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON 테이블명
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION 테이블명_log_trigger_func();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 로그 설정 등록
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO table_log_config (
|
||||||
|
original_table_name, log_table_name, trigger_name,
|
||||||
|
trigger_function_name, is_active, created_by, created_at
|
||||||
|
) VALUES (
|
||||||
|
'테이블명', '테이블명_log', '테이블명_audit_trigger',
|
||||||
|
'테이블명_log_trigger_func', 'Y', '생성자ID', now()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.5 table_labels에 use_log_table 플래그 설정
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE table_labels
|
||||||
|
SET use_log_table = 'Y', updated_date = now()
|
||||||
|
WHERE table_name = '테이블명';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.6 전체 예시: order_info 로그 테이블 생성
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Step 1: 로그 테이블 생성
|
||||||
|
CREATE TABLE order_info_log (
|
||||||
|
log_id SERIAL PRIMARY KEY,
|
||||||
|
operation_type VARCHAR(10) NOT NULL,
|
||||||
|
original_id VARCHAR(100),
|
||||||
|
changed_column VARCHAR(100),
|
||||||
|
old_value TEXT,
|
||||||
|
new_value TEXT,
|
||||||
|
changed_by VARCHAR(50),
|
||||||
|
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ip_address VARCHAR(50),
|
||||||
|
user_agent TEXT,
|
||||||
|
full_row_before JSONB,
|
||||||
|
full_row_after JSONB
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_order_info_log_original_id ON order_info_log(original_id);
|
||||||
|
CREATE INDEX idx_order_info_log_changed_at ON order_info_log(changed_at);
|
||||||
|
CREATE INDEX idx_order_info_log_operation ON order_info_log(operation_type);
|
||||||
|
|
||||||
|
COMMENT ON TABLE order_info_log IS 'order_info 테이블 변경 이력';
|
||||||
|
|
||||||
|
-- Step 2: 트리거 함수 생성
|
||||||
|
CREATE OR REPLACE FUNCTION order_info_log_trigger_func()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_column_name TEXT;
|
||||||
|
v_old_value TEXT;
|
||||||
|
v_new_value TEXT;
|
||||||
|
v_user_id VARCHAR(50);
|
||||||
|
v_ip_address VARCHAR(50);
|
||||||
|
BEGIN
|
||||||
|
v_user_id := current_setting('app.user_id', TRUE);
|
||||||
|
v_ip_address := current_setting('app.ip_address', TRUE);
|
||||||
|
|
||||||
|
IF (TG_OP = 'INSERT') THEN
|
||||||
|
INSERT INTO order_info_log (operation_type, original_id, changed_by, ip_address, full_row_after)
|
||||||
|
VALUES ('INSERT', NEW.id, v_user_id, v_ip_address, row_to_json(NEW)::jsonb);
|
||||||
|
RETURN NEW;
|
||||||
|
ELSIF (TG_OP = 'UPDATE') THEN
|
||||||
|
FOR v_column_name IN
|
||||||
|
SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = 'order_info' AND table_schema = 'public'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name)
|
||||||
|
INTO v_old_value, v_new_value USING OLD, NEW;
|
||||||
|
IF v_old_value IS DISTINCT FROM v_new_value THEN
|
||||||
|
INSERT INTO order_info_log (operation_type, original_id, changed_column, old_value, new_value, changed_by, ip_address, full_row_before, full_row_after)
|
||||||
|
VALUES ('UPDATE', NEW.id, v_column_name, v_old_value, v_new_value, v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb);
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
RETURN NEW;
|
||||||
|
ELSIF (TG_OP = 'DELETE') THEN
|
||||||
|
INSERT INTO order_info_log (operation_type, original_id, changed_by, ip_address, full_row_before)
|
||||||
|
VALUES ('DELETE', OLD.id, v_user_id, v_ip_address, row_to_json(OLD)::jsonb);
|
||||||
|
RETURN OLD;
|
||||||
|
END IF;
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Step 3: 트리거 생성
|
||||||
|
CREATE TRIGGER order_info_audit_trigger
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON order_info
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION order_info_log_trigger_func();
|
||||||
|
|
||||||
|
-- Step 4: 로그 설정 등록
|
||||||
|
INSERT INTO table_log_config (original_table_name, log_table_name, trigger_name, trigger_function_name, is_active, created_by, created_at)
|
||||||
|
VALUES ('order_info', 'order_info_log', 'order_info_audit_trigger', 'order_info_log_trigger_func', 'Y', 'system', now());
|
||||||
|
|
||||||
|
-- Step 5: table_labels 플래그 업데이트
|
||||||
|
UPDATE table_labels SET use_log_table = 'Y', updated_date = now() WHERE table_name = 'order_info';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.7 로그 테이블 삭제
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 트리거 삭제
|
||||||
|
DROP TRIGGER IF EXISTS 테이블명_audit_trigger ON 테이블명;
|
||||||
|
|
||||||
|
-- 트리거 함수 삭제
|
||||||
|
DROP FUNCTION IF EXISTS 테이블명_log_trigger_func();
|
||||||
|
|
||||||
|
-- 로그 테이블 삭제
|
||||||
|
DROP TABLE IF EXISTS 테이블명_log;
|
||||||
|
|
||||||
|
-- 로그 설정 삭제
|
||||||
|
DELETE FROM table_log_config WHERE original_table_name = '테이블명';
|
||||||
|
|
||||||
|
-- table_labels 플래그 업데이트
|
||||||
|
UPDATE table_labels SET use_log_table = 'N', updated_date = now() WHERE table_name = '테이블명';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 체크리스트
|
||||||
|
|
||||||
|
### 테이블 생성/수정 시 반드시 확인할 사항:
|
||||||
|
|
||||||
|
- [ ] DDL에 기본 5개 컬럼 포함 (id, created_date, updated_date, writer, company_code)
|
||||||
|
- [ ] 모든 비즈니스 컬럼은 `VARCHAR(500)` 타입 사용
|
||||||
|
- [ ] `table_labels`에 테이블 메타데이터 등록
|
||||||
|
- [ ] `table_type_columns`에 모든 컬럼 등록 (company_code = '\*')
|
||||||
|
- [ ] `column_labels`에 모든 컬럼 등록 (레거시 호환)
|
||||||
|
- [ ] 기본 컬럼 display_order: -5 ~ -1
|
||||||
|
- [ ] 사용자 정의 컬럼 display_order: 0부터 순차
|
||||||
|
- [ ] code/entity 타입은 detail_settings에 참조 정보 포함
|
||||||
|
- [ ] ON CONFLICT 절로 중복 시 UPDATE 처리
|
||||||
|
|
||||||
|
### 로그 테이블 생성 시 확인할 사항 (선택):
|
||||||
|
|
||||||
|
- [ ] 로그 테이블 생성 (`테이블명_log`)
|
||||||
|
- [ ] 인덱스 3개 생성 (original_id, changed_at, operation_type)
|
||||||
|
- [ ] 트리거 함수 생성 (`테이블명_log_trigger_func`)
|
||||||
|
- [ ] 트리거 생성 (`테이블명_audit_trigger`)
|
||||||
|
- [ ] `table_log_config`에 로그 설정 등록
|
||||||
|
- [ ] `table_labels.use_log_table = 'Y'` 업데이트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 금지 사항
|
||||||
|
|
||||||
|
1. **DB 타입 직접 지정 금지**: NUMBER, INTEGER, DATE 등 DB 타입 직접 사용 금지
|
||||||
|
2. **VARCHAR 길이 변경 금지**: 반드시 `VARCHAR(500)` 사용
|
||||||
|
3. **기본 컬럼 누락 금지**: id, created_date, updated_date, writer, company_code 필수
|
||||||
|
4. **메타데이터 미등록 금지**: 3개 테이블 모두 등록 필수
|
||||||
|
5. **web_type 사용 금지**: 레거시 컬럼이므로 `input_type` 사용
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 참조 파일
|
||||||
|
|
||||||
|
- `backend-node/src/services/ddlExecutionService.ts`: DDL 실행 서비스
|
||||||
|
- `backend-node/src/services/tableManagementService.ts`: 로그 테이블 생성 서비스
|
||||||
|
- `backend-node/src/types/ddl.ts`: DDL 타입 정의
|
||||||
|
- `backend-node/src/controllers/ddlController.ts`: DDL API 컨트롤러
|
||||||
|
- `backend-node/src/controllers/tableManagementController.ts`: 로그 테이블 API 컨트롤러
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 329 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 342 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
70
PLAN.MD
70
PLAN.MD
|
|
@ -1,4 +1,72 @@
|
||||||
# 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원)
|
# 프로젝트: 화면 관리 기능 개선 (복제/삭제/그룹 관리/테이블 설정)
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
화면 관리 시스템의 복제, 삭제, 수정, 테이블 설정 기능을 전면 개선하여 효율적인 화면 관리를 지원합니다.
|
||||||
|
|
||||||
|
## 핵심 기능
|
||||||
|
|
||||||
|
### 1. 단일 화면 복제
|
||||||
|
- [x] 우클릭 컨텍스트 메뉴에서 "복제" 선택
|
||||||
|
- [x] 화면명, 화면 코드 자동 생성 (중복 시 `_COPY` 접미사 추가)
|
||||||
|
- [x] 연결된 모달 화면 함께 복제
|
||||||
|
- [x] 대상 그룹 선택 가능
|
||||||
|
- [x] 복제 후 목록 자동 새로고침
|
||||||
|
|
||||||
|
### 2. 그룹(폴더) 전체 복제
|
||||||
|
- [x] 대분류 폴더 복제 시 모든 하위 폴더 + 화면 재귀적 복제
|
||||||
|
- [x] 정렬 순서(display_order) 유지
|
||||||
|
- [x] 대분류(최상위 그룹) 복제 시 경고 문구 표시
|
||||||
|
- [x] 정렬 순서 입력 필드 추가
|
||||||
|
- [x] 복제 모드 선택: 전체(폴더+화면), 폴더만, 화면만
|
||||||
|
- [x] 모달 스크롤 지원 (max-h-[90vh] overflow-y-auto)
|
||||||
|
|
||||||
|
### 3. 고급 옵션: 이름 일괄 변경
|
||||||
|
- [x] 찾을 텍스트 / 대체할 텍스트 (Find & Replace)
|
||||||
|
- [x] 미리보기 기능
|
||||||
|
|
||||||
|
### 4. 삭제 기능
|
||||||
|
- [x] 단일 화면 삭제 (휴지통으로 이동)
|
||||||
|
- [x] 그룹 삭제 (화면 함께 삭제 옵션)
|
||||||
|
- [x] 삭제 시 로딩 프로그레스 바 표시
|
||||||
|
|
||||||
|
### 5. 화면 수정 기능
|
||||||
|
- [x] 우클릭 "수정" 메뉴로 화면 이름/그룹/역할/정렬 순서 변경
|
||||||
|
- [x] 그룹 추가/수정 시 상위 그룹 기반 자동 회사 코드 설정
|
||||||
|
|
||||||
|
### 6. 테이블 설정 기능 (TableSettingModal)
|
||||||
|
- [x] 화면 설정 모달에 "테이블 설정" 탭 추가
|
||||||
|
- [x] 입력 타입 변경 시 관련 참조 필드 자동 초기화
|
||||||
|
- 엔티티→텍스트: referenceTable, referenceColumn, displayColumn 초기화
|
||||||
|
- 코드→다른 타입: codeCategory, codeValue 초기화
|
||||||
|
- [x] 데이터 일관성 유지 (inputType ↔ referenceTable 연동)
|
||||||
|
- [x] 조인 배지 단일화 (FK 배지 제거, 조인 배지만 표시)
|
||||||
|
|
||||||
|
### 7. 회사 코드 지원 (최고 관리자)
|
||||||
|
- [x] 대상 회사 선택 가능
|
||||||
|
- [x] 상위 그룹 선택 시 자동 회사 코드 설정
|
||||||
|
|
||||||
|
## 관련 파일
|
||||||
|
- `frontend/components/screen/CopyScreenModal.tsx` - 복제 모달
|
||||||
|
- `frontend/components/screen/ScreenGroupTreeView.tsx` - 트리 뷰 + 컨텍스트 메뉴
|
||||||
|
- `frontend/components/screen/TableSettingModal.tsx` - 테이블 설정 모달
|
||||||
|
- `frontend/components/screen/ScreenSettingModal.tsx` - 화면 설정 모달 (테이블 설정 탭 포함)
|
||||||
|
- `frontend/lib/api/screen.ts` - 화면 API
|
||||||
|
- `frontend/lib/api/screenGroup.ts` - 그룹 API
|
||||||
|
- `frontend/lib/api/tableManagement.ts` - 테이블 관리 API
|
||||||
|
|
||||||
|
## 진행 상태
|
||||||
|
- [완료] 단일 화면 복제 + 새로고침
|
||||||
|
- [완료] 그룹 전체 복제 (재귀적)
|
||||||
|
- [완료] 고급 옵션: 이름 일괄 변경 (Find & Replace)
|
||||||
|
- [완료] 단일 화면/그룹 삭제 + 로딩 프로그레스
|
||||||
|
- [완료] 화면 수정 (이름/그룹/역할/순서)
|
||||||
|
- [완료] 테이블 설정 탭 추가
|
||||||
|
- [완료] 입력 타입 변경 시 관련 필드 초기화
|
||||||
|
- [완료] 그룹 복제 모달 스크롤 문제 수정
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 이전 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원)
|
||||||
|
|
||||||
## 개요
|
## 개요
|
||||||
현재 GET 방식 위주로 구현된 외부 REST API 커넥션 관리 기능을 확장하여, POST, PUT, DELETE 등 다양한 HTTP 메서드와 JSON Request Body를 설정하고 테스트할 수 있도록 개선합니다. 이를 통해 토큰 발급 API나 데이터 전송 API 등 다양한 외부 시스템과의 연동을 지원합니다.
|
현재 GET 방식 위주로 구현된 외부 REST API 커넥션 관리 기능을 확장하여, POST, PUT, DELETE 등 다양한 HTTP 메서드와 JSON Request Body를 설정하고 테스트할 수 있도록 개선합니다. 이를 통해 토큰 발급 API나 데이터 전송 API 등 다양한 외부 시스템과의 연동을 지원합니다.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,680 @@
|
||||||
|
# Screen Designer 2.0 리뉴얼 계획: 컴포넌트 통합 및 속성 기반 고도화
|
||||||
|
|
||||||
|
## 1. 개요
|
||||||
|
|
||||||
|
현재 **68개 이상**으로 파편화된 화면 관리 컴포넌트들을 **9개의 핵심 통합 컴포넌트(Unified Components)**로 재편합니다.
|
||||||
|
각 컴포넌트는 **속성(Config)** 설정을 통해 다양한 형태(View Mode)와 기능(Behavior)을 수행하도록 설계되어, 유지보수성과 확장성을 극대화합니다.
|
||||||
|
|
||||||
|
### 현재 컴포넌트 현황 (AS-IS)
|
||||||
|
|
||||||
|
| 카테고리 | 파일 수 | 주요 파일들 |
|
||||||
|
| :------------- | :-----: | :------------------------------------------------------------------ |
|
||||||
|
| Widget 타입별 | 14개 | TextWidget, NumberWidget, SelectWidget, DateWidget, EntityWidget 등 |
|
||||||
|
| Config Panel | 28개 | TextConfigPanel, SelectConfigPanel, DateConfigPanel 등 |
|
||||||
|
| WebType Config | 11개 | TextTypeConfigPanel, SelectTypeConfigPanel 등 |
|
||||||
|
| 기타 패널 | 15개+ | PropertiesPanel, DetailSettingsPanel 등 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 통합 전략: 9 Core Widgets
|
||||||
|
|
||||||
|
### A. 입력 위젯 (Input Widgets) - 5종
|
||||||
|
|
||||||
|
단순 데이터 입력 필드를 통합합니다.
|
||||||
|
|
||||||
|
| 통합 컴포넌트 (TO-BE) | 포함되는 기존 컴포넌트 (AS-IS) | 핵심 속성 (Configuration) |
|
||||||
|
| :-------------------- | :------------------------------------------------------------------ | :----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **1. Unified Select** | Select, Radio, Checkbox, Boolean, Code, Entity, Combobox, Toggle | **`mode`**: "dropdown" / "radio" / "check" / "tag"<br>**`source`**: "static" / "code" / "db" / "api"<br>**`dependency`**: { parentField: "..." } |
|
||||||
|
| **2. Unified Input** | Text, Number, Email, Tel, Password, Color, Search, Integer, Decimal | **`type`**: "text" / "number" / "password"<br>**`format`**: "email", "currency", "biz_no"<br>**`mask`**: "000-0000-0000" |
|
||||||
|
| **3. Unified Date** | Date, Time, DateTime, DateRange, Month, Year | **`type`**: "date" / "time" / "datetime"<br>**`range`**: true/false |
|
||||||
|
| **4. Unified Text** | Textarea, RichEditor, Markdown, HTML | **`mode`**: "simple" / "rich" / "code"<br>**`rows`**: number |
|
||||||
|
| **5. Unified Media** | File, Image, Video, Audio, Attachment | **`type`**: "file" / "image"<br>**`multiple`**: true/false<br>**`preview`**: true/false |
|
||||||
|
|
||||||
|
### B. 구조/데이터 위젯 (Structure & Data Widgets) - 4종
|
||||||
|
|
||||||
|
레이아웃 배치와 데이터 시각화를 담당합니다.
|
||||||
|
|
||||||
|
| 통합 컴포넌트 (TO-BE) | 포함되는 기존 컴포넌트 (AS-IS) | 핵심 속성 (Configuration) | 활용 예시 |
|
||||||
|
| :-------------------- | :-------------------------------------------------- | :------------------------------------------------------------------------ | :----------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **6. Unified List** | **Table, Card List, Repeater, DataGrid, List View** | **`viewMode`**: "table" / "card" / "kanban"<br>**`editable`**: true/false | - `viewMode='table'`: 엑셀형 리스트<br>- `viewMode='card'`: **카드 디스플레이**<br>- `editable=true`: **반복 필드 그룹** |
|
||||||
|
| **7. Unified Layout** | **Row, Col, Split Panel, Grid, Spacer** | **`type`**: "grid" / "split" / "flex"<br>**`columns`**: number | - `type='split'`: **화면 분할 패널**<br>- `type='grid'`: 격자 레이아웃 |
|
||||||
|
| **8. Unified Group** | Tab, Accordion, FieldSet, Modal, Section | **`type`**: "tab" / "accordion" / "modal" | - 탭이나 아코디언으로 내용 그룹화 |
|
||||||
|
| **9. Unified Biz** | **Rack Structure**, Calendar, Gantt | **`type`**: "rack" / "calendar" / "gantt" | - `type='rack'`: **랙 구조 설정**<br>- 특수 비즈니스 로직 플러그인 탑재 |
|
||||||
|
|
||||||
|
### C. Config Panel 통합 전략 (핵심)
|
||||||
|
|
||||||
|
현재 28개의 ConfigPanel을 **1개의 DynamicConfigPanel**로 통합합니다.
|
||||||
|
|
||||||
|
| AS-IS | TO-BE | 방식 |
|
||||||
|
| :-------------------- | :--------------------- | :------------------------------- |
|
||||||
|
| TextConfigPanel.tsx | | |
|
||||||
|
| SelectConfigPanel.tsx | **DynamicConfigPanel** | DB의 `sys_input_type` 테이블에서 |
|
||||||
|
| DateConfigPanel.tsx | (단일 컴포넌트) | JSON Schema를 읽어 |
|
||||||
|
| NumberConfigPanel.tsx | | 속성 UI를 동적 생성 |
|
||||||
|
| ... 24개 더 | | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 구현 시나리오 (속성 기반 변신)
|
||||||
|
|
||||||
|
### Case 1: "테이블을 카드 리스트로 변경"
|
||||||
|
|
||||||
|
- **AS-IS**: `DataTable` 컴포넌트를 삭제하고 `CardList` 컴포넌트를 새로 추가해야 함.
|
||||||
|
- **TO-BE**: `UnifiedList`의 속성창에서 **[View Mode]**를 `Table` → `Card`로 변경하면 즉시 반영.
|
||||||
|
|
||||||
|
### Case 2: "단일 선택을 라디오 버튼으로 변경"
|
||||||
|
|
||||||
|
- **AS-IS**: `SelectWidget`을 삭제하고 `RadioWidget` 추가.
|
||||||
|
- **TO-BE**: `UnifiedSelect` 속성창에서 **[Display Mode]**를 `Dropdown` → `Radio`로 변경.
|
||||||
|
|
||||||
|
### Case 3: "입력 폼에 반복 필드(Repeater) 추가"
|
||||||
|
|
||||||
|
- **TO-BE**: `UnifiedList` 컴포넌트 배치 후 `editable: true`, `viewMode: "table"` 설정.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 실행 로드맵 (Action Plan)
|
||||||
|
|
||||||
|
### Phase 0: 준비 단계 (1주)
|
||||||
|
|
||||||
|
통합 작업 전 필수 분석 및 설계를 진행합니다.
|
||||||
|
|
||||||
|
- [ ] 기존 컴포넌트 사용 현황 분석 (화면별 위젯 사용 빈도 조사)
|
||||||
|
- [ ] 데이터 마이그레이션 전략 설계 (`widgetType` → `UnifiedWidget.type` 매핑 정의)
|
||||||
|
- [ ] `sys_input_type` 테이블 JSON Schema 설계
|
||||||
|
- [ ] DynamicConfigPanel 프로토타입 설계
|
||||||
|
|
||||||
|
### Phase 1: 입력 위젯 통합 (2주)
|
||||||
|
|
||||||
|
가장 중복이 많고 효과가 즉각적인 입력 필드부터 통합합니다.
|
||||||
|
|
||||||
|
- [ ] **UnifiedInput 구현**: Text, Number, Email, Tel, Password 통합
|
||||||
|
- [ ] **UnifiedSelect 구현**: Select, Radio, Checkbox, Boolean 통합
|
||||||
|
- [ ] **UnifiedDate 구현**: Date, DateTime, Time 통합
|
||||||
|
- [ ] 기존 위젯과 **병행 운영** (deprecated 마킹, 삭제하지 않음)
|
||||||
|
|
||||||
|
### Phase 2: Config Panel 통합 (2주)
|
||||||
|
|
||||||
|
28개의 ConfigPanel을 단일 동적 패널로 통합합니다.
|
||||||
|
|
||||||
|
- [ ] **DynamicConfigPanel 구현**: DB 스키마 기반 속성 UI 자동 생성
|
||||||
|
- [ ] `sys_input_type` 테이블에 위젯별 JSON Schema 정의 저장
|
||||||
|
- [ ] 기존 ConfigPanel과 **병행 운영** (삭제하지 않음)
|
||||||
|
|
||||||
|
### Phase 3: 데이터/레이아웃 위젯 통합 (2주)
|
||||||
|
|
||||||
|
프로젝트의 데이터를 보여주는 핵심 뷰를 통합합니다.
|
||||||
|
|
||||||
|
- [ ] **UnifiedList 구현**: Table, Card, Repeater 통합 렌더러 개발
|
||||||
|
- [ ] **UnifiedLayout 구현**: Split Panel, Grid, Flex 통합
|
||||||
|
- [ ] **UnifiedGroup 구현**: Tab, Accordion, Modal 통합
|
||||||
|
|
||||||
|
### Phase 4: 안정화 및 마이그레이션 (2주)
|
||||||
|
|
||||||
|
신규 컴포넌트 안정화 후 점진적 전환을 진행합니다.
|
||||||
|
|
||||||
|
- [ ] 신규 화면은 Unified 컴포넌트만 사용하도록 가이드
|
||||||
|
- [ ] 기존 화면 데이터 마이그레이션 스크립트 개발
|
||||||
|
- [ ] 마이그레이션 테스트 (스테이징 환경)
|
||||||
|
- [ ] 문서화 및 개발 가이드 작성
|
||||||
|
|
||||||
|
### Phase 5: 레거시 정리 (추후 결정)
|
||||||
|
|
||||||
|
충분한 안정화 기간 후 레거시 컴포넌트 정리를 검토합니다.
|
||||||
|
|
||||||
|
- [ ] 사용 현황 재분석 (Unified 전환율 확인)
|
||||||
|
- [ ] 미전환 화면 목록 정리
|
||||||
|
- [ ] 레거시 컴포넌트 삭제 여부 결정 (별도 회의)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 데이터 마이그레이션 전략
|
||||||
|
|
||||||
|
### 5.1 위젯 타입 매핑 테이블
|
||||||
|
|
||||||
|
기존 `widgetType`을 신규 Unified 컴포넌트로 매핑합니다.
|
||||||
|
|
||||||
|
| 기존 widgetType | 신규 컴포넌트 | 속성 설정 |
|
||||||
|
| :-------------- | :------------ | :------------------------------ |
|
||||||
|
| `text` | UnifiedInput | `type: "text"` |
|
||||||
|
| `number` | UnifiedInput | `type: "number"` |
|
||||||
|
| `email` | UnifiedInput | `type: "text", format: "email"` |
|
||||||
|
| `tel` | UnifiedInput | `type: "text", format: "tel"` |
|
||||||
|
| `select` | UnifiedSelect | `mode: "dropdown"` |
|
||||||
|
| `radio` | UnifiedSelect | `mode: "radio"` |
|
||||||
|
| `checkbox` | UnifiedSelect | `mode: "check"` |
|
||||||
|
| `date` | UnifiedDate | `type: "date"` |
|
||||||
|
| `datetime` | UnifiedDate | `type: "datetime"` |
|
||||||
|
| `textarea` | UnifiedText | `mode: "simple"` |
|
||||||
|
| `file` | UnifiedMedia | `type: "file"` |
|
||||||
|
| `image` | UnifiedMedia | `type: "image"` |
|
||||||
|
|
||||||
|
### 5.2 마이그레이션 원칙
|
||||||
|
|
||||||
|
1. **비파괴적 전환**: 기존 데이터 구조 유지, 신규 필드 추가 방식
|
||||||
|
2. **하위 호환성**: 기존 `widgetType` 필드는 유지, `unifiedType` 필드 추가
|
||||||
|
3. **점진적 전환**: 화면 수정 시점에 자동 또는 수동 전환
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 기대 효과
|
||||||
|
|
||||||
|
1. **컴포넌트 수 감소**: 68개 → **9개** (관리 포인트 87% 감소)
|
||||||
|
2. **Config Panel 통합**: 28개 → **1개** (DynamicConfigPanel)
|
||||||
|
3. **유연한 UI 변경**: 컴포넌트 교체 없이 속성 변경만으로 UI 모드 전환 가능
|
||||||
|
4. **Low-Code 확장성**: 새로운 유형의 입력 방식이 필요할 때 코딩 없이 DB 설정만으로 추가 가능
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 리스크 및 대응 방안
|
||||||
|
|
||||||
|
| 리스크 | 영향도 | 대응 방안 |
|
||||||
|
| :----------------------- | :----: | :-------------------------------- |
|
||||||
|
| 기존 화면 호환성 깨짐 | 높음 | 병행 운영 + 하위 호환성 유지 |
|
||||||
|
| 마이그레이션 데이터 손실 | 높음 | 백업 필수 + 롤백 스크립트 준비 |
|
||||||
|
| 개발자 학습 곡선 | 중간 | 상세 가이드 문서 + 예제 코드 제공 |
|
||||||
|
| 성능 저하 (동적 렌더링) | 중간 | 메모이제이션 + 지연 로딩 적용 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 현재 컴포넌트 매핑 분석
|
||||||
|
|
||||||
|
### 8.1 Registry 등록 컴포넌트 전수 조사 (44개)
|
||||||
|
|
||||||
|
현재 `frontend/lib/registry/components/`에 등록된 모든 컴포넌트의 통합 가능 여부를 분석했습니다.
|
||||||
|
|
||||||
|
#### UnifiedInput으로 통합 (4개)
|
||||||
|
|
||||||
|
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||||
|
| :------------- | :--------------- | :------------- |
|
||||||
|
| text-input | `type: "text"` | |
|
||||||
|
| number-input | `type: "number"` | |
|
||||||
|
| slider-basic | `type: "slider"` | 속성 추가 필요 |
|
||||||
|
| button-primary | `type: "button"` | 별도 검토 |
|
||||||
|
|
||||||
|
#### UnifiedSelect로 통합 (8개)
|
||||||
|
|
||||||
|
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||||
|
| :------------------------ | :----------------------------------- | :------------- |
|
||||||
|
| select-basic | `mode: "dropdown"` | |
|
||||||
|
| checkbox-basic | `mode: "check"` | |
|
||||||
|
| radio-basic | `mode: "radio"` | |
|
||||||
|
| toggle-switch | `mode: "toggle"` | 속성 추가 필요 |
|
||||||
|
| autocomplete-search-input | `mode: "dropdown", searchable: true` | |
|
||||||
|
| entity-search-input | `source: "entity"` | |
|
||||||
|
| mail-recipient-selector | `mode: "multi", type: "email"` | 복합 컴포넌트 |
|
||||||
|
| location-swap-selector | `mode: "swap"` | 특수 UI |
|
||||||
|
|
||||||
|
#### UnifiedDate로 통합 (1개)
|
||||||
|
|
||||||
|
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||||
|
| :------------ | :------------- | :--- |
|
||||||
|
| date-input | `type: "date"` | |
|
||||||
|
|
||||||
|
#### UnifiedText로 통합 (1개)
|
||||||
|
|
||||||
|
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||||
|
| :------------- | :--------------- | :--- |
|
||||||
|
| textarea-basic | `mode: "simple"` | |
|
||||||
|
|
||||||
|
#### UnifiedMedia로 통합 (3개)
|
||||||
|
|
||||||
|
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||||
|
| :------------ | :------------------------------ | :--- |
|
||||||
|
| file-upload | `type: "file"` | |
|
||||||
|
| image-widget | `type: "image"` | |
|
||||||
|
| image-display | `type: "image", readonly: true` | |
|
||||||
|
|
||||||
|
#### UnifiedList로 통합 (8개)
|
||||||
|
|
||||||
|
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||||
|
| :-------------------- | :------------------------------------ | :------------ |
|
||||||
|
| table-list | `viewMode: "table"` | |
|
||||||
|
| card-display | `viewMode: "card"` | |
|
||||||
|
| repeater-field-group | `editable: true` | |
|
||||||
|
| modal-repeater-table | `viewMode: "table", modal: true` | |
|
||||||
|
| simple-repeater-table | `viewMode: "table", simple: true` | |
|
||||||
|
| repeat-screen-modal | `viewMode: "card", modal: true` | |
|
||||||
|
| table-search-widget | `viewMode: "table", searchable: true` | |
|
||||||
|
| tax-invoice-list | `viewMode: "table", bizType: "tax"` | 특수 비즈니스 |
|
||||||
|
|
||||||
|
#### UnifiedLayout으로 통합 (4개)
|
||||||
|
|
||||||
|
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||||
|
| :------------------ | :-------------------------- | :------------- |
|
||||||
|
| split-panel-layout | `type: "split"` | |
|
||||||
|
| split-panel-layout2 | `type: "split", version: 2` | |
|
||||||
|
| divider-line | `type: "divider"` | 속성 추가 필요 |
|
||||||
|
| screen-split-panel | `type: "screen-embed"` | 화면 임베딩 |
|
||||||
|
|
||||||
|
#### UnifiedGroup으로 통합 (5개)
|
||||||
|
|
||||||
|
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||||
|
| :------------------- | :--------------------- | :------------ |
|
||||||
|
| accordion-basic | `type: "accordion"` | |
|
||||||
|
| tabs | `type: "tabs"` | |
|
||||||
|
| section-paper | `type: "section"` | |
|
||||||
|
| section-card | `type: "card-section"` | |
|
||||||
|
| universal-form-modal | `type: "form-modal"` | 복합 컴포넌트 |
|
||||||
|
|
||||||
|
#### UnifiedBiz로 통합 (7개)
|
||||||
|
|
||||||
|
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||||
|
| :-------------------- | :------------------------ | :--------------- |
|
||||||
|
| flow-widget | `type: "flow"` | 플로우 관리 |
|
||||||
|
| rack-structure | `type: "rack"` | 창고 렉 구조 |
|
||||||
|
| map | `type: "map"` | 지도 |
|
||||||
|
| numbering-rule | `type: "numbering"` | 채번 규칙 |
|
||||||
|
| category-manager | `type: "category"` | 카테고리 관리 |
|
||||||
|
| customer-item-mapping | `type: "mapping"` | 거래처-품목 매핑 |
|
||||||
|
| related-data-buttons | `type: "related-buttons"` | 연관 데이터 |
|
||||||
|
|
||||||
|
#### 별도 검토 필요 (3개)
|
||||||
|
|
||||||
|
| 현재 컴포넌트 | 문제점 | 제안 |
|
||||||
|
| :-------------------------- | :------------------- | :------------------------------ |
|
||||||
|
| conditional-container | 조건부 렌더링 로직 | 공통 속성으로 분리 |
|
||||||
|
| selected-items-detail-input | 복합 (선택+상세입력) | UnifiedList + UnifiedGroup 조합 |
|
||||||
|
| text-display | 읽기 전용 텍스트 | UnifiedInput (readonly: true) |
|
||||||
|
|
||||||
|
### 8.2 매핑 분석 결과
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ 전체 44개 컴포넌트 분석 결과 │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ ✅ 즉시 통합 가능 : 36개 (82%) │
|
||||||
|
│ ⚠️ 속성 추가 후 통합 : 5개 (11%) │
|
||||||
|
│ 🔄 별도 검토 필요 : 3개 (7%) │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 속성 확장 필요 사항
|
||||||
|
|
||||||
|
#### UnifiedInput 속성 확장
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기존
|
||||||
|
type: "text" | "number" | "password";
|
||||||
|
|
||||||
|
// 확장
|
||||||
|
type: "text" | "number" | "password" | "slider" | "color" | "button";
|
||||||
|
```
|
||||||
|
|
||||||
|
#### UnifiedSelect 속성 확장
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기존
|
||||||
|
mode: "dropdown" | "radio" | "check" | "tag";
|
||||||
|
|
||||||
|
// 확장
|
||||||
|
mode: "dropdown" | "radio" | "check" | "tag" | "toggle" | "swap";
|
||||||
|
```
|
||||||
|
|
||||||
|
#### UnifiedLayout 속성 확장
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기존
|
||||||
|
type: "grid" | "split" | "flex";
|
||||||
|
|
||||||
|
// 확장
|
||||||
|
type: "grid" | "split" | "flex" | "divider" | "screen-embed";
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.4 조건부 렌더링 공통화
|
||||||
|
|
||||||
|
`conditional-container`의 기능을 모든 컴포넌트에서 사용 가능한 공통 속성으로 분리합니다.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 모든 Unified 컴포넌트에 적용 가능한 공통 속성
|
||||||
|
interface BaseUnifiedProps {
|
||||||
|
// ... 기존 속성
|
||||||
|
|
||||||
|
/** 조건부 렌더링 설정 */
|
||||||
|
conditional?: {
|
||||||
|
enabled: boolean;
|
||||||
|
field: string; // 참조할 필드명
|
||||||
|
operator: "=" | "!=" | ">" | "<" | "in" | "notIn";
|
||||||
|
value: any; // 비교 값
|
||||||
|
hideOnFalse?: boolean; // false일 때 숨김 (기본: true)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 계층 구조(Hierarchy) 컴포넌트 전략
|
||||||
|
|
||||||
|
### 9.1 현재 계층 구조 지원 현황
|
||||||
|
|
||||||
|
DB 테이블 `cascading_hierarchy_group`에서 4가지 계층 타입을 지원합니다:
|
||||||
|
|
||||||
|
| 타입 | 설명 | 예시 |
|
||||||
|
| :----------------- | :---------------------- | :--------------- |
|
||||||
|
| **MULTI_TABLE** | 다중 테이블 계층 | 국가 > 도시 > 구 |
|
||||||
|
| **SELF_REFERENCE** | 자기 참조 (단일 테이블) | 조직도, 메뉴 |
|
||||||
|
| **BOM** | 자재명세서 구조 | 부품 > 하위부품 |
|
||||||
|
| **TREE** | 일반 트리 | 카테고리 |
|
||||||
|
|
||||||
|
### 9.2 통합 방안: UnifiedHierarchy 신설 (10번째 컴포넌트)
|
||||||
|
|
||||||
|
계층 구조는 일반 입력/표시 위젯과 성격이 다르므로 **별도 컴포넌트로 분리**합니다.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface UnifiedHierarchyProps {
|
||||||
|
/** 계층 유형 */
|
||||||
|
type: "tree" | "org" | "bom" | "cascading";
|
||||||
|
|
||||||
|
/** 표시 방식 */
|
||||||
|
viewMode: "tree" | "table" | "indent" | "dropdown";
|
||||||
|
|
||||||
|
/** 계층 그룹 코드 (cascading_hierarchy_group 연동) */
|
||||||
|
source: string;
|
||||||
|
|
||||||
|
/** 편집 가능 여부 */
|
||||||
|
editable?: boolean;
|
||||||
|
|
||||||
|
/** 드래그 정렬 가능 */
|
||||||
|
draggable?: boolean;
|
||||||
|
|
||||||
|
/** BOM 수량 표시 (BOM 타입 전용) */
|
||||||
|
showQty?: boolean;
|
||||||
|
|
||||||
|
/** 최대 레벨 제한 */
|
||||||
|
maxLevel?: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 활용 예시
|
||||||
|
|
||||||
|
| 설정 | 결과 |
|
||||||
|
| :---------------------------------------- | :------------------------- |
|
||||||
|
| `type: "tree", viewMode: "tree"` | 카테고리 트리뷰 |
|
||||||
|
| `type: "org", viewMode: "tree"` | 조직도 |
|
||||||
|
| `type: "bom", viewMode: "indent"` | BOM 들여쓰기 테이블 |
|
||||||
|
| `type: "cascading", viewMode: "dropdown"` | 연쇄 셀렉트 (국가>도시>구) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 최종 통합 컴포넌트 목록 (10개)
|
||||||
|
|
||||||
|
| # | 컴포넌트 | 역할 | 커버 범위 |
|
||||||
|
| :-: | :------------------- | :------------- | :----------------------------------- |
|
||||||
|
| 1 | **UnifiedInput** | 단일 값 입력 | text, number, slider, button 등 |
|
||||||
|
| 2 | **UnifiedSelect** | 선택 입력 | dropdown, radio, checkbox, toggle 등 |
|
||||||
|
| 3 | **UnifiedDate** | 날짜/시간 입력 | date, datetime, time, range |
|
||||||
|
| 4 | **UnifiedText** | 다중 행 텍스트 | textarea, rich editor, markdown |
|
||||||
|
| 5 | **UnifiedMedia** | 파일/미디어 | file, image, video, audio |
|
||||||
|
| 6 | **UnifiedList** | 데이터 목록 | table, card, repeater, kanban |
|
||||||
|
| 7 | **UnifiedLayout** | 레이아웃 배치 | grid, split, flex, divider |
|
||||||
|
| 8 | **UnifiedGroup** | 콘텐츠 그룹화 | tabs, accordion, section, modal |
|
||||||
|
| 9 | **UnifiedBiz** | 비즈니스 특화 | flow, rack, map, numbering 등 |
|
||||||
|
| 10 | **UnifiedHierarchy** | 계층 구조 | tree, org, bom, cascading |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 연쇄관계 관리 메뉴 통합 전략
|
||||||
|
|
||||||
|
### 11.1 현재 연쇄관계 관리 현황
|
||||||
|
|
||||||
|
**관리 메뉴**: `연쇄 드롭다운 통합 관리` (6개 탭)
|
||||||
|
|
||||||
|
| 탭 | DB 테이블 | 실제 데이터 | 복잡도 |
|
||||||
|
| :--------------- | :--------------------------------------- | :---------: | :----: |
|
||||||
|
| 2단계 연쇄관계 | `cascading_relation` | 2건 | 낮음 |
|
||||||
|
| 다단계 계층 | `cascading_hierarchy_group/level` | 1건 | 높음 |
|
||||||
|
| 조건부 필터 | `cascading_condition` | 0건 | 중간 |
|
||||||
|
| 자동 입력 | `cascading_auto_fill_group/mapping` | 0건 | 낮음 |
|
||||||
|
| 상호 배제 | `cascading_mutual_exclusion` | 0건 | 낮음 |
|
||||||
|
| 카테고리 값 연쇄 | `category_value_cascading_group/mapping` | 2건 | 중간 |
|
||||||
|
|
||||||
|
### 11.2 통합 방향: 속성 기반 vs 공통 정의
|
||||||
|
|
||||||
|
#### 판단 기준
|
||||||
|
|
||||||
|
| 기능 | 재사용 빈도 | 설정 복잡도 | 권장 방식 |
|
||||||
|
| :--------------- | :---------: | :---------: | :----------------------- |
|
||||||
|
| 2단계 연쇄 | 낮음 | 간단 | **속성에 inline 정의** |
|
||||||
|
| 다단계 계층 | 높음 | 복잡 | **공통 정의 유지** |
|
||||||
|
| 조건부 필터 | 낮음 | 간단 | **속성에 inline 정의** |
|
||||||
|
| 자동 입력 | 낮음 | 간단 | **속성에 inline 정의** |
|
||||||
|
| 상호 배제 | 낮음 | 간단 | **속성에 inline 정의** |
|
||||||
|
| 카테고리 값 연쇄 | 중간 | 중간 | **카테고리 관리와 통합** |
|
||||||
|
|
||||||
|
### 11.3 속성 통합 설계
|
||||||
|
|
||||||
|
#### 2단계 연쇄 → UnifiedSelect 속성
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// AS-IS: 별도 관리 메뉴에서 정의 후 참조
|
||||||
|
<SelectWidget cascadingRelation="WAREHOUSE_LOCATION" />
|
||||||
|
|
||||||
|
// TO-BE: 컴포넌트 속성에서 직접 정의
|
||||||
|
<UnifiedSelect
|
||||||
|
source="db"
|
||||||
|
table="warehouse_location"
|
||||||
|
valueColumn="location_code"
|
||||||
|
labelColumn="location_name"
|
||||||
|
cascading={{
|
||||||
|
parentField: "warehouse_code", // 같은 화면 내 부모 필드
|
||||||
|
filterColumn: "warehouse_code", // 필터링할 컬럼
|
||||||
|
clearOnChange: true // 부모 변경 시 초기화
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 조건부 필터 → 공통 conditional 속성
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// AS-IS: 별도 관리 메뉴에서 조건 정의
|
||||||
|
// cascading_condition 테이블에 저장
|
||||||
|
|
||||||
|
// TO-BE: 모든 컴포넌트에 공통 속성으로 적용
|
||||||
|
<UnifiedInput
|
||||||
|
conditional={{
|
||||||
|
enabled: true,
|
||||||
|
field: "order_type", // 참조할 필드
|
||||||
|
operator: "=", // 비교 연산자
|
||||||
|
value: "EXPORT", // 비교 값
|
||||||
|
action: "show", // show | hide | disable | enable
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 자동 입력 → autoFill 속성
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// AS-IS: cascading_auto_fill_group 테이블에 정의
|
||||||
|
|
||||||
|
// TO-BE: 컴포넌트 속성에서 직접 정의
|
||||||
|
<UnifiedInput
|
||||||
|
autoFill={{
|
||||||
|
enabled: true,
|
||||||
|
sourceTable: "company_mng", // 조회할 테이블
|
||||||
|
filterColumn: "company_code", // 필터링 컬럼
|
||||||
|
userField: "companyCode", // 사용자 정보 필드
|
||||||
|
displayColumn: "company_name", // 표시할 컬럼
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 상호 배제 → mutualExclusion 속성
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// AS-IS: cascading_mutual_exclusion 테이블에 정의
|
||||||
|
|
||||||
|
// TO-BE: 컴포넌트 속성에서 직접 정의
|
||||||
|
<UnifiedSelect
|
||||||
|
mutualExclusion={{
|
||||||
|
enabled: true,
|
||||||
|
targetField: "sub_category", // 상호 배제 대상 필드
|
||||||
|
type: "exclusive", // exclusive | inclusive
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.4 관리 메뉴 정리 계획
|
||||||
|
|
||||||
|
| 현재 메뉴 | TO-BE | 비고 |
|
||||||
|
| :-------------------------- | :----------------------- | :-------------------- |
|
||||||
|
| **연쇄 드롭다운 통합 관리** | **삭제** | 6개 탭 전체 제거 |
|
||||||
|
| ├─ 2단계 연쇄관계 | UnifiedSelect 속성 | inline 정의 |
|
||||||
|
| ├─ 다단계 계층 | **테이블관리로 이동** | 복잡한 구조 유지 필요 |
|
||||||
|
| ├─ 조건부 필터 | 공통 conditional 속성 | 모든 컴포넌트에 적용 |
|
||||||
|
| ├─ 자동 입력 | autoFill 속성 | 컴포넌트별 정의 |
|
||||||
|
| ├─ 상호 배제 | mutualExclusion 속성 | 컴포넌트별 정의 |
|
||||||
|
| └─ 카테고리 값 연쇄 | **카테고리 관리로 이동** | 기존 메뉴 통합 |
|
||||||
|
|
||||||
|
### 11.5 DB 테이블 정리 (Phase 5)
|
||||||
|
|
||||||
|
| 테이블 | 조치 | 시점 |
|
||||||
|
| :--------------------------- | :----------------------- | :------ |
|
||||||
|
| `cascading_relation` | 마이그레이션 후 삭제 | Phase 5 |
|
||||||
|
| `cascading_condition` | 삭제 (데이터 없음) | Phase 5 |
|
||||||
|
| `cascading_auto_fill_*` | 삭제 (데이터 없음) | Phase 5 |
|
||||||
|
| `cascading_mutual_exclusion` | 삭제 (데이터 없음) | Phase 5 |
|
||||||
|
| `cascading_hierarchy_*` | **유지** | - |
|
||||||
|
| `category_value_cascading_*` | **유지** (카테고리 관리) | - |
|
||||||
|
|
||||||
|
### 11.6 마이그레이션 스크립트 필요 항목
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- cascading_relation → 화면 레이아웃 데이터로 마이그레이션
|
||||||
|
-- 기존 2건의 연쇄관계를 사용하는 화면을 찾아서
|
||||||
|
-- 해당 컴포넌트의 cascading 속성으로 변환
|
||||||
|
|
||||||
|
-- 예시: WAREHOUSE_LOCATION 연쇄관계
|
||||||
|
-- 이 관계를 사용하는 화면의 컴포넌트에
|
||||||
|
-- cascading: { parentField: "warehouse_code", filterColumn: "warehouse_code" }
|
||||||
|
-- 속성 추가
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 최종 아키텍처 요약
|
||||||
|
|
||||||
|
### 12.1 통합 컴포넌트 (10개)
|
||||||
|
|
||||||
|
| # | 컴포넌트 | 역할 |
|
||||||
|
| :-: | :------------------- | :--------------------------------------- |
|
||||||
|
| 1 | **UnifiedInput** | 단일 값 입력 (text, number, slider 등) |
|
||||||
|
| 2 | **UnifiedSelect** | 선택 입력 (dropdown, radio, checkbox 등) |
|
||||||
|
| 3 | **UnifiedDate** | 날짜/시간 입력 |
|
||||||
|
| 4 | **UnifiedText** | 다중 행 텍스트 (textarea, rich editor) |
|
||||||
|
| 5 | **UnifiedMedia** | 파일/미디어 (file, image) |
|
||||||
|
| 6 | **UnifiedList** | 데이터 목록 (table, card, repeater) |
|
||||||
|
| 7 | **UnifiedLayout** | 레이아웃 배치 (grid, split, flex) |
|
||||||
|
| 8 | **UnifiedGroup** | 콘텐츠 그룹화 (tabs, accordion, section) |
|
||||||
|
| 9 | **UnifiedBiz** | 비즈니스 특화 (flow, rack, map 등) |
|
||||||
|
| 10 | **UnifiedHierarchy** | 계층 구조 (tree, org, bom, cascading) |
|
||||||
|
|
||||||
|
### 12.2 공통 속성 (모든 컴포넌트에 적용)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface BaseUnifiedProps {
|
||||||
|
// 기본 속성
|
||||||
|
id: string;
|
||||||
|
label?: string;
|
||||||
|
required?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
|
||||||
|
// 스타일
|
||||||
|
style?: ComponentStyle;
|
||||||
|
className?: string;
|
||||||
|
|
||||||
|
// 조건부 렌더링 (conditional-container 대체)
|
||||||
|
conditional?: {
|
||||||
|
enabled: boolean;
|
||||||
|
field: string;
|
||||||
|
operator:
|
||||||
|
| "="
|
||||||
|
| "!="
|
||||||
|
| ">"
|
||||||
|
| "<"
|
||||||
|
| "in"
|
||||||
|
| "notIn"
|
||||||
|
| "isEmpty"
|
||||||
|
| "isNotEmpty";
|
||||||
|
value: any;
|
||||||
|
action: "show" | "hide" | "disable" | "enable";
|
||||||
|
};
|
||||||
|
|
||||||
|
// 자동 입력 (autoFill 대체)
|
||||||
|
autoFill?: {
|
||||||
|
enabled: boolean;
|
||||||
|
sourceTable: string;
|
||||||
|
filterColumn: string;
|
||||||
|
userField: "companyCode" | "userId" | "deptCode";
|
||||||
|
displayColumn: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 유효성 검사
|
||||||
|
validation?: ValidationRule[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.3 UnifiedSelect 전용 속성
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface UnifiedSelectProps extends BaseUnifiedProps {
|
||||||
|
// 표시 모드
|
||||||
|
mode: "dropdown" | "radio" | "check" | "tag" | "toggle" | "swap";
|
||||||
|
|
||||||
|
// 데이터 소스
|
||||||
|
source: "static" | "code" | "db" | "api" | "entity";
|
||||||
|
|
||||||
|
// static 소스
|
||||||
|
options?: Array<{ value: string; label: string }>;
|
||||||
|
|
||||||
|
// db 소스
|
||||||
|
table?: string;
|
||||||
|
valueColumn?: string;
|
||||||
|
labelColumn?: string;
|
||||||
|
|
||||||
|
// code 소스
|
||||||
|
codeGroup?: string;
|
||||||
|
|
||||||
|
// 연쇄 관계 (cascading_relation 대체)
|
||||||
|
cascading?: {
|
||||||
|
parentField: string; // 부모 필드명
|
||||||
|
filterColumn: string; // 필터링할 컬럼
|
||||||
|
clearOnChange?: boolean; // 부모 변경 시 초기화
|
||||||
|
};
|
||||||
|
|
||||||
|
// 상호 배제 (mutual_exclusion 대체)
|
||||||
|
mutualExclusion?: {
|
||||||
|
enabled: boolean;
|
||||||
|
targetField: string; // 상호 배제 대상
|
||||||
|
type: "exclusive" | "inclusive";
|
||||||
|
};
|
||||||
|
|
||||||
|
// 다중 선택
|
||||||
|
multiple?: boolean;
|
||||||
|
maxSelect?: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.4 관리 메뉴 정리 결과
|
||||||
|
|
||||||
|
| AS-IS | TO-BE |
|
||||||
|
| :---------------------------- | :----------------------------------- |
|
||||||
|
| 연쇄 드롭다운 통합 관리 (6탭) | **삭제** |
|
||||||
|
| - 2단계 연쇄관계 | → UnifiedSelect.cascading 속성 |
|
||||||
|
| - 다단계 계층 | → 테이블관리 > 계층 구조 설정 |
|
||||||
|
| - 조건부 필터 | → 공통 conditional 속성 |
|
||||||
|
| - 자동 입력 | → 공통 autoFill 속성 |
|
||||||
|
| - 상호 배제 | → UnifiedSelect.mutualExclusion 속성 |
|
||||||
|
| - 카테고리 값 연쇄 | → 카테고리 관리와 통합 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 주의사항
|
||||||
|
|
||||||
|
> **기존 컴포넌트 삭제 금지**
|
||||||
|
> 모든 Phase에서 기존 컴포넌트는 삭제하지 않고 **병행 운영**합니다.
|
||||||
|
> 레거시 정리는 Phase 5에서 충분한 안정화 후 별도 검토합니다.
|
||||||
|
|
||||||
|
> **연쇄관계 마이그레이션 필수**
|
||||||
|
> 관리 메뉴 삭제 전 기존 `cascading_relation` 데이터(2건)를
|
||||||
|
> 해당 화면의 컴포넌트 속성으로 마이그레이션해야 합니다.
|
||||||
|
|
@ -12,12 +12,15 @@
|
||||||
"@types/mssql": "^9.1.8",
|
"@types/mssql": "^9.1.8",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
"bwip-js": "^4.8.0",
|
||||||
"compression": "^1.7.4",
|
"compression": "^1.7.4",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"docx": "^9.5.1",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^7.1.5",
|
"express-rate-limit": "^7.1.5",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
|
"html-to-docx": "^1.8.0",
|
||||||
"iconv-lite": "^0.7.0",
|
"iconv-lite": "^0.7.0",
|
||||||
"imap": "^0.8.19",
|
"imap": "^0.8.19",
|
||||||
"joi": "^17.11.0",
|
"joi": "^17.11.0",
|
||||||
|
|
@ -39,6 +42,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/bwip-js": "^3.2.3",
|
||||||
"@types/compression": "^1.7.5",
|
"@types/compression": "^1.7.5",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
|
|
@ -1040,6 +1044,7 @@
|
||||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.3",
|
"@babel/generator": "^7.28.3",
|
||||||
|
|
@ -2256,6 +2261,93 @@
|
||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@oozcitak/dom": {
|
||||||
|
"version": "1.15.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-1.15.6.tgz",
|
||||||
|
"integrity": "sha512-k4uEIa6DI3FCrFJMGq/05U/59WnS9DjME0kaPqBRCJAqBTkmopbYV1Xs4qFKbDJ/9wOg8W97p+1E0heng/LH7g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@oozcitak/infra": "1.0.5",
|
||||||
|
"@oozcitak/url": "1.0.0",
|
||||||
|
"@oozcitak/util": "8.3.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oozcitak/infra": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-o+zZH7M6l5e3FaAWy3ojaPIVN5eusaYPrKm6MZQt0DKNdgXa2wDYExjpP0t/zx+GoQgQKzLu7cfD8rHCLt8JrQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@oozcitak/util": "8.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oozcitak/infra/node_modules/@oozcitak/util": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-+9Hq6yuoq/3TRV/n/xcpydGBq2qN2/DEDMqNTG7rm95K6ZE2/YY/sPyx62+1n8QsE9O26e5M1URlXsk+AnN9Jw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oozcitak/url": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-LGrMeSxeLzsdaitxq3ZmBRVOrlRRQIgNNci6L0VRnOKlJFuRIkNm4B+BObXPCJA6JT5bEJtrrwjn30jueHJYZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@oozcitak/infra": "1.0.3",
|
||||||
|
"@oozcitak/util": "1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oozcitak/url/node_modules/@oozcitak/infra": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-9O2wxXGnRzy76O1XUxESxDGsXT5kzETJPvYbreO4mv6bqe1+YSuux2cZTagjJ/T4UfEwFJz5ixanOqB0QgYAag==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@oozcitak/util": "1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oozcitak/url/node_modules/@oozcitak/infra/node_modules/@oozcitak/util": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-dFwFqcKrQnJ2SapOmRD1nQWEZUtbtIy9Y6TyJquzsalWNJsKIPxmTI0KG6Ypyl8j7v89L2wixH9fQDNrF78hKg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oozcitak/url/node_modules/@oozcitak/util": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-4n8B1cWlJleSOSba5gxsMcN4tO8KkkcvXhNWW+ADqvq9Xj+Lrl9uCa90GRpjekqQJyt84aUX015DG81LFpZYXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oozcitak/util": {
|
||||||
|
"version": "8.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.3.4.tgz",
|
||||||
|
"integrity": "sha512-6gH/bLQJSJEg7OEpkH4wGQdA8KXHRbzL1YkGyUO12YNAgV3jxKy4K9kvfXj4+9T0OLug5k58cnPCKSSIKzp7pg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@paralleldrive/cuid2": {
|
"node_modules/@paralleldrive/cuid2": {
|
||||||
"version": "2.2.2",
|
"version": "2.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz",
|
||||||
|
|
@ -2280,6 +2372,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
||||||
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cluster-key-slot": "1.1.2",
|
"cluster-key-slot": "1.1.2",
|
||||||
"generic-pool": "3.9.0",
|
"generic-pool": "3.9.0",
|
||||||
|
|
@ -3124,6 +3217,16 @@
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/bwip-js": {
|
||||||
|
"version": "3.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/bwip-js/-/bwip-js-3.2.3.tgz",
|
||||||
|
"integrity": "sha512-kgL1GOW7n5FhlC5aXnckaEim0rz1cFM4t9/xUwuNXCIDnWLx8ruQ4JQkG6znq4GQFovNLhQy5JdgbDwJw4D/zg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/compression": {
|
"node_modules/@types/compression": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz",
|
||||||
|
|
@ -3373,6 +3476,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
|
||||||
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
|
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
|
|
@ -3609,6 +3713,7 @@
|
||||||
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "6.21.0",
|
"@typescript-eslint/scope-manager": "6.21.0",
|
||||||
"@typescript-eslint/types": "6.21.0",
|
"@typescript-eslint/types": "6.21.0",
|
||||||
|
|
@ -3826,6 +3931,7 @@
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
|
|
@ -4326,6 +4432,12 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/browser-split": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/browser-split/-/browser-split-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-JhvgRb2ihQhsljNda3BI8/UcRHVzrVwo3Q+P8vDtSiyobXuFpuZ9mq+MbRGMnC22CjW3RrfXdg6j6ITX8M+7Ow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.26.2",
|
"version": "4.26.2",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz",
|
||||||
|
|
@ -4346,6 +4458,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.3",
|
"baseline-browser-mapping": "^2.8.3",
|
||||||
"caniuse-lite": "^1.0.30001741",
|
"caniuse-lite": "^1.0.30001741",
|
||||||
|
|
@ -4445,6 +4558,15 @@
|
||||||
"node": ">=10.16.0"
|
"node": ">=10.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bwip-js": {
|
||||||
|
"version": "4.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bwip-js/-/bwip-js-4.8.0.tgz",
|
||||||
|
"integrity": "sha512-gUDkDHSTv8/DJhomSIbO0fX/Dx0MO/sgllLxJyJfu4WixCQe9nfGJzmHm64ZCbxo+gUYQEsQcRmqcwcwPRwUkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"bwip-js": "bin/bwip-js.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
|
|
@ -4521,6 +4643,15 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/camelize": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001745",
|
"version": "1.0.30001745",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz",
|
||||||
|
|
@ -5202,6 +5333,56 @@
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/docx": {
|
||||||
|
"version": "9.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/docx/-/docx-9.5.1.tgz",
|
||||||
|
"integrity": "sha512-ABDI7JEirFD2+bHhOBlsGZxaG1UgZb2M/QMKhLSDGgVNhxDesTCDcP+qoDnDGjZ4EOXTRfUjUgwHVuZ6VSTfWQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "^24.0.1",
|
||||||
|
"hash.js": "^1.1.7",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
|
"nanoid": "^5.1.3",
|
||||||
|
"xml": "^1.0.1",
|
||||||
|
"xml-js": "^1.6.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/docx/node_modules/@types/node": {
|
||||||
|
"version": "24.10.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz",
|
||||||
|
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~7.16.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/docx/node_modules/nanoid": {
|
||||||
|
"version": "5.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
|
||||||
|
"integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"nanoid": "bin/nanoid.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18 || >=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/docx/node_modules/undici-types": {
|
||||||
|
"version": "7.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||||
|
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dom-serializer": {
|
"node_modules/dom-serializer": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||||
|
|
@ -5216,6 +5397,11 @@
|
||||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dom-walk": {
|
||||||
|
"version": "0.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
|
||||||
|
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
|
||||||
|
},
|
||||||
"node_modules/domelementtype": {
|
"node_modules/domelementtype": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||||
|
|
@ -5349,6 +5535,27 @@
|
||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ent": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.3",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"punycode": "^1.4.1",
|
||||||
|
"safe-regex-test": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ent/node_modules/punycode": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/entities": {
|
"node_modules/entities": {
|
||||||
"version": "4.5.0",
|
"version": "4.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
|
|
@ -5361,6 +5568,16 @@
|
||||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/error": {
|
||||||
|
"version": "4.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/error/-/error-4.4.0.tgz",
|
||||||
|
"integrity": "sha512-SNDKualLUtT4StGFP7xNfuFybL2f6iJujFtrWuvJqGbVQGaN+adE23veqzPz1hjUjTunLi2EnJ+0SJxtbJreKw==",
|
||||||
|
"dependencies": {
|
||||||
|
"camelize": "^1.0.0",
|
||||||
|
"string-template": "~0.2.0",
|
||||||
|
"xtend": "~4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/error-ex": {
|
"node_modules/error-ex": {
|
||||||
"version": "1.3.4",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
|
||||||
|
|
@ -5452,6 +5669,7 @@
|
||||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
|
|
@ -5643,6 +5861,14 @@
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ev-store": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ev-store/-/ev-store-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-otazchNRnGzp2YarBJ+GXKVGvhxVATB1zmaStxJBYet0Dyq7A9VhH8IUEB/gRcL6Ch52lfpgPTRJ2m49epyMsQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"individual": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/event-target-shim": {
|
"node_modules/event-target-shim": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||||
|
|
@ -6279,6 +6505,16 @@
|
||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/global": {
|
||||||
|
"version": "4.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
|
||||||
|
"integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"min-document": "^2.19.0",
|
||||||
|
"process": "^0.11.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/globals": {
|
"node_modules/globals": {
|
||||||
"version": "13.24.0",
|
"version": "13.24.0",
|
||||||
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
|
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
|
||||||
|
|
@ -6413,6 +6649,16 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hash.js": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"minimalistic-assert": "^1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
|
|
@ -6443,6 +6689,22 @@
|
||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-entities": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/mdevils"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://patreon.com/mdevils"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/html-escaper": {
|
"node_modules/html-escaper": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||||
|
|
@ -6450,6 +6712,27 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/html-to-docx": {
|
||||||
|
"version": "1.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-to-docx/-/html-to-docx-1.8.0.tgz",
|
||||||
|
"integrity": "sha512-IiMBWIqXM4+cEsW//RKoonWV7DlXAJBmmKI73XJSVWTIXjGUaxSr2ck1jqzVRZknpvO8xsFnVicldKVAWrBYBA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@oozcitak/dom": "1.15.6",
|
||||||
|
"@oozcitak/util": "8.3.4",
|
||||||
|
"color-name": "^1.1.4",
|
||||||
|
"html-entities": "^2.3.3",
|
||||||
|
"html-to-vdom": "^0.7.0",
|
||||||
|
"image-size": "^1.0.0",
|
||||||
|
"image-to-base64": "^2.2.0",
|
||||||
|
"jszip": "^3.7.1",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"mime-types": "^2.1.35",
|
||||||
|
"nanoid": "^3.1.25",
|
||||||
|
"virtual-dom": "^2.1.1",
|
||||||
|
"xmlbuilder2": "2.1.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/html-to-text": {
|
"node_modules/html-to-text": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
||||||
|
|
@ -6466,6 +6749,106 @@
|
||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-to-vdom": {
|
||||||
|
"version": "0.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-to-vdom/-/html-to-vdom-0.7.0.tgz",
|
||||||
|
"integrity": "sha512-k+d2qNkbx0JO00KezQsNcn6k2I/xSBP4yXYFLvXbcasTTDh+RDLUJS3puxqyNnpdyXWRHFGoKU7cRmby8/APcQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"ent": "^2.0.0",
|
||||||
|
"htmlparser2": "^3.8.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/html-to-vdom/node_modules/dom-serializer": {
|
||||||
|
"version": "0.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz",
|
||||||
|
"integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.0.1",
|
||||||
|
"entities": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/html-to-vdom/node_modules/dom-serializer/node_modules/domelementtype": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/html-to-vdom/node_modules/dom-serializer/node_modules/entities": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/html-to-vdom/node_modules/domelementtype": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/html-to-vdom/node_modules/domhandler": {
|
||||||
|
"version": "2.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz",
|
||||||
|
"integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/html-to-vdom/node_modules/domutils": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"dom-serializer": "0",
|
||||||
|
"domelementtype": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/html-to-vdom/node_modules/entities": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/html-to-vdom/node_modules/htmlparser2": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^1.3.1",
|
||||||
|
"domhandler": "^2.3.0",
|
||||||
|
"domutils": "^1.5.1",
|
||||||
|
"entities": "^1.1.1",
|
||||||
|
"inherits": "^2.0.1",
|
||||||
|
"readable-stream": "^3.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/html-to-vdom/node_modules/readable-stream": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/htmlparser2": {
|
"node_modules/htmlparser2": {
|
||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||||
|
|
@ -6590,6 +6973,30 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/image-size": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"queue": "6.0.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"image-size": "bin/image-size.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/image-to-base64": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/image-to-base64/-/image-to-base64-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-Z+aMwm/91UOQqHhrz7Upre2ytKhWejZlWV/JxUTD1sT7GWWKFDJUEV5scVQKnkzSgPHFuQBUEWcanO+ma0PSVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-fetch": "^2.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/imap": {
|
"node_modules/imap": {
|
||||||
"version": "0.8.19",
|
"version": "0.8.19",
|
||||||
"resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz",
|
"resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz",
|
||||||
|
|
@ -6626,6 +7033,12 @@
|
||||||
"integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==",
|
"integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/immediate": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
|
|
@ -6673,6 +7086,11 @@
|
||||||
"node": ">=0.8.19"
|
"node": ">=0.8.19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/individual": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/individual/-/individual-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-rUY5vtT748NMRbEMrTNiFfy29BgGZwGXUi2NFUVMWQrogSLzlJvQV9eeMWi+g1aVaQ53tpyLAQtd5x/JH0Nh1g=="
|
||||||
|
},
|
||||||
"node_modules/inflight": {
|
"node_modules/inflight": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||||
|
|
@ -6854,6 +7272,15 @@
|
||||||
"node": ">=0.12.0"
|
"node": ">=0.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-object": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-path-inside": {
|
"node_modules/is-path-inside": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
|
||||||
|
|
@ -7005,6 +7432,7 @@
|
||||||
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
|
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jest/core": "^29.7.0",
|
"@jest/core": "^29.7.0",
|
||||||
"@jest/types": "^29.6.3",
|
"@jest/types": "^29.6.3",
|
||||||
|
|
@ -7696,6 +8124,18 @@
|
||||||
"npm": ">=6"
|
"npm": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jszip": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||||
|
"license": "(MIT OR GPL-3.0-or-later)",
|
||||||
|
"dependencies": {
|
||||||
|
"lie": "~3.3.0",
|
||||||
|
"pako": "~1.0.2",
|
||||||
|
"readable-stream": "~2.3.6",
|
||||||
|
"setimmediate": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jwa": {
|
"node_modules/jwa": {
|
||||||
"version": "1.4.2",
|
"version": "1.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
||||||
|
|
@ -7812,6 +8252,15 @@
|
||||||
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
|
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lie": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"immediate": "~3.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lines-and-columns": {
|
"node_modules/lines-and-columns": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||||
|
|
@ -7953,7 +8402,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||||
},
|
},
|
||||||
|
|
@ -8177,6 +8625,21 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/min-document": {
|
||||||
|
"version": "2.19.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.2.tgz",
|
||||||
|
"integrity": "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dom-walk": "^0.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minimalistic-assert": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "9.0.3",
|
"version": "9.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
|
||||||
|
|
@ -8300,6 +8763,24 @@
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nanoid": {
|
||||||
|
"version": "3.3.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
|
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"nanoid": "bin/nanoid.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/native-duplexpair": {
|
"node_modules/native-duplexpair": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz",
|
||||||
|
|
@ -8329,6 +8810,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/next-tick": {
|
||||||
|
"version": "0.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-0.2.2.tgz",
|
||||||
|
"integrity": "sha512-f7h4svPtl+QidoBv4taKXUjJ70G2asaZ8G28nS0OkqaalX8dwwrtWtyxEDPK62AC00ur/+/E0pUwBwY5EPn15Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/node-cron": {
|
"node_modules/node-cron": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
|
||||||
|
|
@ -8670,6 +9157,12 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
"node_modules/parchment": {
|
"node_modules/parchment": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
|
||||||
|
|
@ -8797,6 +9290,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||||
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.9.1",
|
"pg-connection-string": "^2.9.1",
|
||||||
"pg-pool": "^3.10.1",
|
"pg-pool": "^3.10.1",
|
||||||
|
|
@ -9179,6 +9673,15 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/queue": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "~2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
|
|
@ -9595,6 +10098,23 @@
|
||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-regex-test": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"is-regex": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/safe-stable-stringify": {
|
"node_modules/safe-stable-stringify": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||||
|
|
@ -9610,12 +10130,17 @@
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/sax": {
|
||||||
|
"version": "1.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz",
|
||||||
|
"integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
|
||||||
|
"license": "BlueOak-1.0.0"
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.23.2",
|
"version": "0.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
}
|
}
|
||||||
|
|
@ -9744,6 +10269,12 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/setimmediate": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/setprototypeof": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
|
|
@ -10020,6 +10551,11 @@
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string-template": {
|
||||||
|
"version": "0.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-template/-/string-template-0.2.1.tgz",
|
||||||
|
"integrity": "sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw=="
|
||||||
|
},
|
||||||
"node_modules/string-width": {
|
"node_modules/string-width": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
|
@ -10413,6 +10949,7 @@
|
||||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cspotcode/source-map-support": "^0.8.0",
|
"@cspotcode/source-map-support": "^0.8.0",
|
||||||
"@tsconfig/node10": "^1.0.7",
|
"@tsconfig/node10": "^1.0.7",
|
||||||
|
|
@ -10518,6 +11055,7 @@
|
||||||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|
@ -10685,6 +11223,22 @@
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/virtual-dom": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/virtual-dom/-/virtual-dom-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-wb6Qc9Lbqug0kRqo/iuApfBpJJAq14Sk1faAnSmtqXiwahg7PVTvWMs9L02Z8nNIMqbwsxzBAA90bbtRLbw0zg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"browser-split": "0.0.1",
|
||||||
|
"error": "^4.3.0",
|
||||||
|
"ev-store": "^7.0.0",
|
||||||
|
"global": "^4.3.0",
|
||||||
|
"is-object": "^1.0.1",
|
||||||
|
"next-tick": "^0.2.2",
|
||||||
|
"x-is-array": "0.1.0",
|
||||||
|
"x-is-string": "0.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/walker": {
|
"node_modules/walker": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
|
||||||
|
|
@ -10862,6 +11416,80 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/x-is-array": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/x-is-array/-/x-is-array-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-goHPif61oNrr0jJgsXRfc8oqtYzvfiMJpTqwE7Z4y9uH+T3UozkGqQ4d2nX9mB9khvA8U2o/UbPOFjgC7hLWIA=="
|
||||||
|
},
|
||||||
|
"node_modules/x-is-string": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/x-is-string/-/x-is-string-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-GojqklwG8gpzOVEVki5KudKNoq7MbbjYZCbyWzEz7tyPA7eleiE0+ePwOWQQRb5fm86rD3S8Tc0tSFf3AOv50w=="
|
||||||
|
},
|
||||||
|
"node_modules/xml": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/xml-js": {
|
||||||
|
"version": "1.6.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
|
||||||
|
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"sax": "^1.2.4"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"xml-js": "bin/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlbuilder2": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-PI710tmtVlQ5VmwzbRTuhmVhKnj9pM8Si+iOZCV2g2SNo3gCrpzR2Ka9wNzZtqfD+mnP+xkrqoNy0sjKZqP4Dg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@oozcitak/dom": "1.15.5",
|
||||||
|
"@oozcitak/infra": "1.0.5",
|
||||||
|
"@oozcitak/util": "8.3.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlbuilder2/node_modules/@oozcitak/dom": {
|
||||||
|
"version": "1.15.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-1.15.5.tgz",
|
||||||
|
"integrity": "sha512-L6v3Mwb0TaYBYgeYlIeBaHnc+2ZEaDSbFiRm5KmqZQSoBlbPlf+l6aIH/sD5GUf2MYwULw00LT7+dOnEuAEC0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@oozcitak/infra": "1.0.5",
|
||||||
|
"@oozcitak/url": "1.0.0",
|
||||||
|
"@oozcitak/util": "8.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlbuilder2/node_modules/@oozcitak/dom/node_modules/@oozcitak/util": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-+9Hq6yuoq/3TRV/n/xcpydGBq2qN2/DEDMqNTG7rm95K6ZE2/YY/sPyx62+1n8QsE9O26e5M1URlXsk+AnN9Jw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlbuilder2/node_modules/@oozcitak/util": {
|
||||||
|
"version": "8.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.3.3.tgz",
|
||||||
|
"integrity": "sha512-Ufpab7G5PfnEhQyy5kDg9C8ltWJjsVT1P/IYqacjstaqydG4Q21HAT2HUZQYBrC/a1ZLKCz87pfydlDvv8y97w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xtend": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,15 @@
|
||||||
"@types/mssql": "^9.1.8",
|
"@types/mssql": "^9.1.8",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
"bwip-js": "^4.8.0",
|
||||||
"compression": "^1.7.4",
|
"compression": "^1.7.4",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"docx": "^9.5.1",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^7.1.5",
|
"express-rate-limit": "^7.1.5",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
|
"html-to-docx": "^1.8.0",
|
||||||
"iconv-lite": "^0.7.0",
|
"iconv-lite": "^0.7.0",
|
||||||
"imap": "^0.8.19",
|
"imap": "^0.8.19",
|
||||||
"joi": "^17.11.0",
|
"joi": "^17.11.0",
|
||||||
|
|
@ -53,6 +56,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/bwip-js": "^3.2.3",
|
||||||
"@types/compression": "^1.7.5",
|
"@types/compression": "^1.7.5",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관
|
||||||
import todoRoutes from "./routes/todoRoutes"; // To-Do 관리
|
import todoRoutes from "./routes/todoRoutes"; // To-Do 관리
|
||||||
import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
|
import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
|
||||||
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
|
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
|
||||||
|
import excelMappingRoutes from "./routes/excelMappingRoutes"; // 엑셀 매핑 템플릿
|
||||||
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 3D 필드
|
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 3D 필드
|
||||||
//import materialRoutes from "./routes/materialRoutes"; // 자재 관리
|
//import materialRoutes from "./routes/materialRoutes"; // 자재 관리
|
||||||
import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제)
|
import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제)
|
||||||
|
|
@ -72,6 +73,7 @@ import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
|
||||||
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
|
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
|
||||||
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
|
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
|
||||||
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
||||||
|
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
|
||||||
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||||
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
||||||
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
|
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
|
||||||
|
|
@ -80,6 +82,7 @@ import cascadingAutoFillRoutes from "./routes/cascadingAutoFillRoutes"; // 자
|
||||||
import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리
|
import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리
|
||||||
import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리
|
import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리
|
||||||
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
|
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
|
||||||
|
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
|
||||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||||
|
|
@ -195,6 +198,7 @@ app.use("/api/multilang", multilangRoutes);
|
||||||
app.use("/api/table-management", tableManagementRoutes);
|
app.use("/api/table-management", tableManagementRoutes);
|
||||||
app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능
|
app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능
|
||||||
app.use("/api/screen-management", screenManagementRoutes);
|
app.use("/api/screen-management", screenManagementRoutes);
|
||||||
|
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
|
||||||
app.use("/api/common-codes", commonCodeRoutes);
|
app.use("/api/common-codes", commonCodeRoutes);
|
||||||
app.use("/api/dynamic-form", dynamicFormRoutes);
|
app.use("/api/dynamic-form", dynamicFormRoutes);
|
||||||
app.use("/api/files", fileRoutes);
|
app.use("/api/files", fileRoutes);
|
||||||
|
|
@ -219,6 +223,7 @@ app.use("/api/external-rest-api-connections", externalRestApiConnectionRoutes);
|
||||||
app.use("/api/multi-connection", multiConnectionRoutes);
|
app.use("/api/multi-connection", multiConnectionRoutes);
|
||||||
app.use("/api/screen-files", screenFileRoutes);
|
app.use("/api/screen-files", screenFileRoutes);
|
||||||
app.use("/api/batch-configs", batchRoutes);
|
app.use("/api/batch-configs", batchRoutes);
|
||||||
|
app.use("/api/excel-mapping", excelMappingRoutes); // 엑셀 매핑 템플릿
|
||||||
app.use("/api/batch-management", batchManagementRoutes);
|
app.use("/api/batch-management", batchManagementRoutes);
|
||||||
app.use("/api/batch-execution-logs", batchExecutionLogRoutes);
|
app.use("/api/batch-execution-logs", batchExecutionLogRoutes);
|
||||||
// app.use("/api/db-type-categories", dbTypeCategoryRoutes); // 파일이 존재하지 않음
|
// app.use("/api/db-type-categories", dbTypeCategoryRoutes); // 파일이 존재하지 않음
|
||||||
|
|
@ -255,6 +260,7 @@ app.use("/api/cascading-auto-fill", cascadingAutoFillRoutes); // 자동 입력
|
||||||
app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연쇄 관리
|
app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연쇄 관리
|
||||||
app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리
|
app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리
|
||||||
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
|
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
|
||||||
|
app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
|
||||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||||
|
|
|
||||||
|
|
@ -553,10 +553,24 @@ export const setUserLocale = async (
|
||||||
|
|
||||||
const { locale } = req.body;
|
const { locale } = req.body;
|
||||||
|
|
||||||
if (!locale || !["ko", "en", "ja", "zh"].includes(locale)) {
|
if (!locale) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "유효하지 않은 로케일입니다. (ko, en, ja, zh 중 선택)",
|
message: "로케일이 필요합니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// language_master 테이블에서 유효한 언어 코드인지 확인
|
||||||
|
const validLang = await queryOne<{ lang_code: string }>(
|
||||||
|
"SELECT lang_code FROM language_master WHERE lang_code = $1 AND is_active = 'Y'",
|
||||||
|
[locale]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!validLang) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `유효하지 않은 로케일입니다: ${locale}`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1165,6 +1179,33 @@ export async function saveMenu(
|
||||||
|
|
||||||
logger.info("메뉴 저장 성공", { savedMenu });
|
logger.info("메뉴 저장 성공", { savedMenu });
|
||||||
|
|
||||||
|
// 다국어 메뉴 카테고리 자동 생성
|
||||||
|
try {
|
||||||
|
const { MultiLangService } = await import("../services/multilangService");
|
||||||
|
const multilangService = new MultiLangService();
|
||||||
|
|
||||||
|
// 회사명 조회
|
||||||
|
const companyInfo = await queryOne<{ company_name: string }>(
|
||||||
|
`SELECT company_name FROM company_mng WHERE company_code = $1`,
|
||||||
|
[companyCode]
|
||||||
|
);
|
||||||
|
const companyName = companyCode === "*" ? "공통" : (companyInfo?.company_name || companyCode);
|
||||||
|
|
||||||
|
// 메뉴 경로 조회 및 카테고리 생성
|
||||||
|
const menuPath = await multilangService.getMenuPath(savedMenu.objid.toString());
|
||||||
|
await multilangService.ensureMenuCategory(companyCode, companyName, menuPath);
|
||||||
|
|
||||||
|
logger.info("메뉴 다국어 카테고리 생성 완료", {
|
||||||
|
menuObjId: savedMenu.objid.toString(),
|
||||||
|
menuPath,
|
||||||
|
});
|
||||||
|
} catch (categoryError) {
|
||||||
|
logger.warn("메뉴 다국어 카테고리 생성 실패 (메뉴 저장은 성공)", {
|
||||||
|
menuObjId: savedMenu.objid.toString(),
|
||||||
|
error: categoryError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const response: ApiResponse<any> = {
|
const response: ApiResponse<any> = {
|
||||||
success: true,
|
success: true,
|
||||||
message: "메뉴가 성공적으로 저장되었습니다.",
|
message: "메뉴가 성공적으로 저장되었습니다.",
|
||||||
|
|
@ -1376,6 +1417,75 @@ export async function updateMenu(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 재귀적으로 모든 하위 메뉴 ID를 수집하는 헬퍼 함수
|
||||||
|
*/
|
||||||
|
async function collectAllChildMenuIds(parentObjid: number): Promise<number[]> {
|
||||||
|
const allIds: number[] = [];
|
||||||
|
|
||||||
|
// 직접 자식 메뉴들 조회
|
||||||
|
const children = await query<any>(
|
||||||
|
`SELECT objid FROM menu_info WHERE parent_obj_id = $1`,
|
||||||
|
[parentObjid]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const child of children) {
|
||||||
|
allIds.push(child.objid);
|
||||||
|
// 자식의 자식들도 재귀적으로 수집
|
||||||
|
const grandChildren = await collectAllChildMenuIds(child.objid);
|
||||||
|
allIds.push(...grandChildren);
|
||||||
|
}
|
||||||
|
|
||||||
|
return allIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메뉴 및 관련 데이터 정리 헬퍼 함수
|
||||||
|
*/
|
||||||
|
async function cleanupMenuRelatedData(menuObjid: number): Promise<void> {
|
||||||
|
// 1. category_column_mapping에서 menu_objid를 NULL로 설정
|
||||||
|
await query(
|
||||||
|
`UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||||
|
[menuObjid]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. code_category에서 menu_objid를 NULL로 설정
|
||||||
|
await query(
|
||||||
|
`UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||||
|
[menuObjid]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. code_info에서 menu_objid를 NULL로 설정
|
||||||
|
await query(
|
||||||
|
`UPDATE code_info SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||||
|
[menuObjid]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. numbering_rules에서 menu_objid를 NULL로 설정
|
||||||
|
await query(
|
||||||
|
`UPDATE numbering_rules SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||||
|
[menuObjid]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. rel_menu_auth에서 관련 권한 삭제
|
||||||
|
await query(
|
||||||
|
`DELETE FROM rel_menu_auth WHERE menu_objid = $1`,
|
||||||
|
[menuObjid]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. screen_menu_assignments에서 관련 할당 삭제
|
||||||
|
await query(
|
||||||
|
`DELETE FROM screen_menu_assignments WHERE menu_objid = $1`,
|
||||||
|
[menuObjid]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 7. screen_groups에서 menu_objid를 NULL로 설정
|
||||||
|
await query(
|
||||||
|
`UPDATE screen_groups SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||||
|
[menuObjid]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 메뉴 삭제
|
* 메뉴 삭제
|
||||||
*/
|
*/
|
||||||
|
|
@ -1402,7 +1512,7 @@ export async function deleteMenu(
|
||||||
|
|
||||||
// 삭제하려는 메뉴 조회
|
// 삭제하려는 메뉴 조회
|
||||||
const currentMenu = await queryOne<any>(
|
const currentMenu = await queryOne<any>(
|
||||||
`SELECT objid, company_code FROM menu_info WHERE objid = $1`,
|
`SELECT objid, company_code, menu_name_kor FROM menu_info WHERE objid = $1`,
|
||||||
[Number(menuId)]
|
[Number(menuId)]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1437,67 +1547,50 @@ export async function deleteMenu(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 외래키 제약 조건이 있는 관련 테이블 데이터 먼저 정리
|
|
||||||
const menuObjid = Number(menuId);
|
const menuObjid = Number(menuId);
|
||||||
|
|
||||||
// 1. category_column_mapping에서 menu_objid를 NULL로 설정
|
// 하위 메뉴들 재귀적으로 수집
|
||||||
await query(
|
const childMenuIds = await collectAllChildMenuIds(menuObjid);
|
||||||
`UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`,
|
const allMenuIdsToDelete = [menuObjid, ...childMenuIds];
|
||||||
[menuObjid]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. code_category에서 menu_objid를 NULL로 설정
|
logger.info(`메뉴 삭제 대상: 본인(${menuObjid}) + 하위 메뉴 ${childMenuIds.length}개`, {
|
||||||
await query(
|
menuName: currentMenu.menu_name_kor,
|
||||||
`UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`,
|
totalCount: allMenuIdsToDelete.length,
|
||||||
[menuObjid]
|
childMenuIds,
|
||||||
);
|
});
|
||||||
|
|
||||||
// 3. code_info에서 menu_objid를 NULL로 설정
|
|
||||||
await query(
|
|
||||||
`UPDATE code_info SET menu_objid = NULL WHERE menu_objid = $1`,
|
|
||||||
[menuObjid]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 4. numbering_rules에서 menu_objid를 NULL로 설정
|
|
||||||
await query(
|
|
||||||
`UPDATE numbering_rules SET menu_objid = NULL WHERE menu_objid = $1`,
|
|
||||||
[menuObjid]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 5. rel_menu_auth에서 관련 권한 삭제
|
|
||||||
await query(
|
|
||||||
`DELETE FROM rel_menu_auth WHERE menu_objid = $1`,
|
|
||||||
[menuObjid]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 6. screen_menu_assignments에서 관련 할당 삭제
|
|
||||||
await query(
|
|
||||||
`DELETE FROM screen_menu_assignments WHERE menu_objid = $1`,
|
|
||||||
[menuObjid]
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.info("메뉴 관련 데이터 정리 완료", { menuObjid });
|
// 모든 삭제 대상 메뉴에 대해 관련 데이터 정리
|
||||||
|
for (const objid of allMenuIdsToDelete) {
|
||||||
|
await cleanupMenuRelatedData(objid);
|
||||||
|
}
|
||||||
|
|
||||||
// Raw Query를 사용한 메뉴 삭제
|
logger.info("메뉴 관련 데이터 정리 완료", {
|
||||||
const [deletedMenu] = await query<any>(
|
menuObjid,
|
||||||
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`,
|
totalCleaned: allMenuIdsToDelete.length
|
||||||
[menuObjid]
|
});
|
||||||
);
|
|
||||||
|
|
||||||
logger.info("메뉴 삭제 성공", { deletedMenu });
|
// 하위 메뉴부터 역순으로 삭제 (외래키 제약 회피)
|
||||||
|
// 가장 깊은 하위부터 삭제해야 하므로 역순으로
|
||||||
|
const reversedIds = [...allMenuIdsToDelete].reverse();
|
||||||
|
|
||||||
|
for (const objid of reversedIds) {
|
||||||
|
await query(`DELETE FROM menu_info WHERE objid = $1`, [objid]);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("메뉴 삭제 성공", {
|
||||||
|
deletedMenuObjid: menuObjid,
|
||||||
|
deletedMenuName: currentMenu.menu_name_kor,
|
||||||
|
totalDeleted: allMenuIdsToDelete.length,
|
||||||
|
});
|
||||||
|
|
||||||
const response: ApiResponse<any> = {
|
const response: ApiResponse<any> = {
|
||||||
success: true,
|
success: true,
|
||||||
message: "메뉴가 성공적으로 삭제되었습니다.",
|
message: `메뉴가 성공적으로 삭제되었습니다. (하위 메뉴 ${childMenuIds.length}개 포함)`,
|
||||||
data: {
|
data: {
|
||||||
objid: deletedMenu.objid.toString(),
|
objid: menuObjid.toString(),
|
||||||
menuNameKor: deletedMenu.menu_name_kor,
|
menuNameKor: currentMenu.menu_name_kor,
|
||||||
menuNameEng: deletedMenu.menu_name_eng,
|
deletedCount: allMenuIdsToDelete.length,
|
||||||
menuUrl: deletedMenu.menu_url,
|
deletedChildCount: childMenuIds.length,
|
||||||
menuDesc: deletedMenu.menu_desc,
|
|
||||||
status: deletedMenu.status,
|
|
||||||
writer: deletedMenu.writer,
|
|
||||||
regdate: new Date(deletedMenu.regdate).toISOString(),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1582,18 +1675,49 @@ export async function deleteMenusBatch(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 모든 삭제 대상 메뉴 ID 수집 (하위 메뉴 포함)
|
||||||
|
const allMenuIdsToDelete = new Set<number>();
|
||||||
|
|
||||||
|
for (const menuId of menuIds) {
|
||||||
|
const objid = Number(menuId);
|
||||||
|
allMenuIdsToDelete.add(objid);
|
||||||
|
|
||||||
|
// 하위 메뉴들 재귀적으로 수집
|
||||||
|
const childMenuIds = await collectAllChildMenuIds(objid);
|
||||||
|
childMenuIds.forEach(id => allMenuIdsToDelete.add(Number(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const allIdsArray = Array.from(allMenuIdsToDelete);
|
||||||
|
|
||||||
|
logger.info(`메뉴 일괄 삭제 대상: 선택 ${menuIds.length}개 + 하위 메뉴 포함 총 ${allIdsArray.length}개`, {
|
||||||
|
selectedMenuIds: menuIds,
|
||||||
|
totalWithChildren: allIdsArray.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 모든 삭제 대상 메뉴에 대해 관련 데이터 정리
|
||||||
|
for (const objid of allIdsArray) {
|
||||||
|
await cleanupMenuRelatedData(objid);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("메뉴 관련 데이터 정리 완료", {
|
||||||
|
totalCleaned: allIdsArray.length
|
||||||
|
});
|
||||||
|
|
||||||
// Raw Query를 사용한 메뉴 일괄 삭제
|
// Raw Query를 사용한 메뉴 일괄 삭제
|
||||||
let deletedCount = 0;
|
let deletedCount = 0;
|
||||||
let failedCount = 0;
|
let failedCount = 0;
|
||||||
const deletedMenus: any[] = [];
|
const deletedMenus: any[] = [];
|
||||||
const failedMenuIds: string[] = [];
|
const failedMenuIds: string[] = [];
|
||||||
|
|
||||||
|
// 하위 메뉴부터 삭제하기 위해 역순으로 정렬
|
||||||
|
const reversedIds = [...allIdsArray].reverse();
|
||||||
|
|
||||||
// 각 메뉴 ID에 대해 삭제 시도
|
// 각 메뉴 ID에 대해 삭제 시도
|
||||||
for (const menuId of menuIds) {
|
for (const menuObjid of reversedIds) {
|
||||||
try {
|
try {
|
||||||
const result = await query<any>(
|
const result = await query<any>(
|
||||||
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`,
|
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`,
|
||||||
[Number(menuId)]
|
[menuObjid]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.length > 0) {
|
if (result.length > 0) {
|
||||||
|
|
@ -1604,20 +1728,20 @@ export async function deleteMenusBatch(
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
failedCount++;
|
failedCount++;
|
||||||
failedMenuIds.push(menuId);
|
failedMenuIds.push(String(menuObjid));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`메뉴 삭제 실패 (ID: ${menuId}):`, error);
|
logger.error(`메뉴 삭제 실패 (ID: ${menuObjid}):`, error);
|
||||||
failedCount++;
|
failedCount++;
|
||||||
failedMenuIds.push(menuId);
|
failedMenuIds.push(String(menuObjid));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("메뉴 일괄 삭제 완료", {
|
logger.info("메뉴 일괄 삭제 완료", {
|
||||||
total: menuIds.length,
|
requested: menuIds.length,
|
||||||
|
totalWithChildren: allIdsArray.length,
|
||||||
deletedCount,
|
deletedCount,
|
||||||
failedCount,
|
failedCount,
|
||||||
deletedMenus,
|
|
||||||
failedMenuIds,
|
failedMenuIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2649,6 +2773,24 @@ export const createCompany = async (
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 다국어 카테고리 자동 생성
|
||||||
|
try {
|
||||||
|
const { MultiLangService } = await import("../services/multilangService");
|
||||||
|
const multilangService = new MultiLangService();
|
||||||
|
await multilangService.ensureCompanyCategory(
|
||||||
|
createdCompany.company_code,
|
||||||
|
createdCompany.company_name
|
||||||
|
);
|
||||||
|
logger.info("회사 다국어 카테고리 생성 완료", {
|
||||||
|
companyCode: createdCompany.company_code,
|
||||||
|
});
|
||||||
|
} catch (categoryError) {
|
||||||
|
logger.warn("회사 다국어 카테고리 생성 실패 (회사 등록은 성공)", {
|
||||||
|
companyCode: createdCompany.company_code,
|
||||||
|
error: categoryError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
logger.info("회사 등록 성공", {
|
logger.info("회사 등록 성공", {
|
||||||
companyCode: createdCompany.company_code,
|
companyCode: createdCompany.company_code,
|
||||||
companyName: createdCompany.company_name,
|
companyName: createdCompany.company_name,
|
||||||
|
|
@ -3058,6 +3200,23 @@ export const updateProfile = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
if (locale !== undefined) {
|
if (locale !== undefined) {
|
||||||
|
// language_master 테이블에서 유효한 언어 코드인지 확인
|
||||||
|
const validLang = await queryOne<{ lang_code: string }>(
|
||||||
|
"SELECT lang_code FROM language_master WHERE lang_code = $1 AND is_active = 'Y'",
|
||||||
|
[locale]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!validLang) {
|
||||||
|
res.status(400).json({
|
||||||
|
result: false,
|
||||||
|
error: {
|
||||||
|
code: "INVALID_LOCALE",
|
||||||
|
details: `유효하지 않은 로케일입니다: ${locale}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
updateFields.push(`locale = $${paramIndex}`);
|
updateFields.push(`locale = $${paramIndex}`);
|
||||||
updateValues.push(locale);
|
updateValues.push(locale);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
|
|
@ -3245,6 +3404,7 @@ export const resetUserPassword = async (
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 테이블 스키마 조회 (엑셀 업로드 컬럼 매핑용)
|
* 테이블 스키마 조회 (엑셀 업로드 컬럼 매핑용)
|
||||||
|
* column_labels 테이블에서 라벨 정보도 함께 가져옴
|
||||||
*/
|
*/
|
||||||
export async function getTableSchema(
|
export async function getTableSchema(
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
|
|
@ -3264,20 +3424,25 @@ export async function getTableSchema(
|
||||||
|
|
||||||
logger.info("테이블 스키마 조회", { tableName, companyCode });
|
logger.info("테이블 스키마 조회", { tableName, companyCode });
|
||||||
|
|
||||||
// information_schema에서 컬럼 정보 가져오기
|
// information_schema와 column_labels를 JOIN하여 컬럼 정보와 라벨 정보 함께 가져오기
|
||||||
const schemaQuery = `
|
const schemaQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
column_name,
|
ic.column_name,
|
||||||
data_type,
|
ic.data_type,
|
||||||
is_nullable,
|
ic.is_nullable,
|
||||||
column_default,
|
ic.column_default,
|
||||||
character_maximum_length,
|
ic.character_maximum_length,
|
||||||
numeric_precision,
|
ic.numeric_precision,
|
||||||
numeric_scale
|
ic.numeric_scale,
|
||||||
FROM information_schema.columns
|
cl.column_label,
|
||||||
WHERE table_schema = 'public'
|
cl.display_order
|
||||||
AND table_name = $1
|
FROM information_schema.columns ic
|
||||||
ORDER BY ordinal_position
|
LEFT JOIN column_labels cl
|
||||||
|
ON cl.table_name = ic.table_name
|
||||||
|
AND cl.column_name = ic.column_name
|
||||||
|
WHERE ic.table_schema = 'public'
|
||||||
|
AND ic.table_name = $1
|
||||||
|
ORDER BY COALESCE(cl.display_order, ic.ordinal_position), ic.ordinal_position
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const columns = await query<any>(schemaQuery, [tableName]);
|
const columns = await query<any>(schemaQuery, [tableName]);
|
||||||
|
|
@ -3290,9 +3455,10 @@ export async function getTableSchema(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 컬럼 정보를 간단한 형태로 변환
|
// 컬럼 정보를 간단한 형태로 변환 (라벨 정보 포함)
|
||||||
const columnList = columns.map((col: any) => ({
|
const columnList = columns.map((col: any) => ({
|
||||||
name: col.column_name,
|
name: col.column_name,
|
||||||
|
label: col.column_label || col.column_name, // 라벨이 없으면 컬럼명 사용
|
||||||
type: col.data_type,
|
type: col.data_type,
|
||||||
nullable: col.is_nullable === "YES",
|
nullable: col.is_nullable === "YES",
|
||||||
default: col.column_default,
|
default: col.column_default,
|
||||||
|
|
@ -3387,13 +3553,23 @@ export async function copyMenu(
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// 추가 복사 옵션 (카테고리, 코드, 채번규칙 등)
|
||||||
|
const additionalCopyOptions = req.body.additionalCopyOptions
|
||||||
|
? {
|
||||||
|
copyCodeCategory: req.body.additionalCopyOptions.copyCodeCategory === true,
|
||||||
|
copyNumberingRules: req.body.additionalCopyOptions.copyNumberingRules === true,
|
||||||
|
copyCategoryMapping: req.body.additionalCopyOptions.copyCategoryMapping === true,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// 메뉴 복사 실행
|
// 메뉴 복사 실행
|
||||||
const menuCopyService = new MenuCopyService();
|
const menuCopyService = new MenuCopyService();
|
||||||
const result = await menuCopyService.copyMenu(
|
const result = await menuCopyService.copyMenu(
|
||||||
parseInt(menuObjid, 10),
|
parseInt(menuObjid, 10),
|
||||||
targetCompanyCode,
|
targetCompanyCode,
|
||||||
userId,
|
userId,
|
||||||
screenNameConfig
|
screenNameConfig,
|
||||||
|
additionalCopyOptions
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info("✅ 메뉴 복사 API 성공");
|
logger.info("✅ 메뉴 복사 API 성공");
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,110 @@ export class AuthController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/auth/switch-company
|
||||||
|
* WACE 관리자 전용: 다른 회사로 전환
|
||||||
|
*/
|
||||||
|
static async switchCompany(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { companyCode } = req.body;
|
||||||
|
const authHeader = req.get("Authorization");
|
||||||
|
const token = authHeader && authHeader.split(" ")[1];
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: "인증 토큰이 필요합니다.",
|
||||||
|
error: { code: "TOKEN_MISSING" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현재 사용자 정보 확인
|
||||||
|
const currentUser = JwtUtils.verifyToken(token);
|
||||||
|
|
||||||
|
// WACE 관리자 권한 체크 (userType = "SUPER_ADMIN"만 확인)
|
||||||
|
// 이미 다른 회사로 전환한 상태(companyCode != "*")에서도 다시 전환 가능해야 함
|
||||||
|
if (currentUser.userType !== "SUPER_ADMIN") {
|
||||||
|
logger.warn(`회사 전환 권한 없음: userId=${currentUser.userId}, userType=${currentUser.userType}, companyCode=${currentUser.companyCode}`);
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "회사 전환은 최고 관리자(SUPER_ADMIN)만 가능합니다.",
|
||||||
|
error: { code: "FORBIDDEN" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전환할 회사 코드 검증
|
||||||
|
if (!companyCode || companyCode.trim() === "") {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "전환할 회사 코드가 필요합니다.",
|
||||||
|
error: { code: "INVALID_INPUT" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`=== WACE 관리자 회사 전환 ===`, {
|
||||||
|
userId: currentUser.userId,
|
||||||
|
originalCompanyCode: currentUser.companyCode,
|
||||||
|
targetCompanyCode: companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 회사 코드 존재 여부 확인 (company_code가 "*"가 아닌 경우만)
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
const { query } = await import("../database/db");
|
||||||
|
const companies = await query<any>(
|
||||||
|
"SELECT company_code, company_name FROM company_mng WHERE company_code = $1",
|
||||||
|
[companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (companies.length === 0) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "존재하지 않는 회사 코드입니다.",
|
||||||
|
error: { code: "COMPANY_NOT_FOUND" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새로운 JWT 토큰 발급 (company_code만 변경)
|
||||||
|
const newPersonBean: PersonBean = {
|
||||||
|
...currentUser,
|
||||||
|
companyCode: companyCode.trim(), // 전환할 회사 코드로 변경
|
||||||
|
};
|
||||||
|
|
||||||
|
const newToken = JwtUtils.generateToken(newPersonBean);
|
||||||
|
|
||||||
|
logger.info(`✅ 회사 전환 성공: ${currentUser.userId} → ${companyCode}`);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "회사 전환 완료",
|
||||||
|
data: {
|
||||||
|
token: newToken,
|
||||||
|
companyCode: companyCode.trim(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`회사 전환 API 오류: ${error instanceof Error ? error.message : error}`
|
||||||
|
);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "회사 전환 중 오류가 발생했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "SERVER_ERROR",
|
||||||
|
details:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "알 수 없는 오류가 발생했습니다.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/auth/logout
|
* POST /api/auth/logout
|
||||||
* 기존 Java ApiLoginController.logout() 메서드 포팅
|
* 기존 Java ApiLoginController.logout() 메서드 포팅
|
||||||
|
|
@ -226,13 +330,14 @@ export class AuthController {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 프론트엔드 호환성을 위해 더 많은 사용자 정보 반환
|
// 프론트엔드 호환성을 위해 더 많은 사용자 정보 반환
|
||||||
|
// ⚠️ JWT 토큰의 companyCode를 우선 사용 (회사 전환 기능 지원)
|
||||||
const userInfoResponse: any = {
|
const userInfoResponse: any = {
|
||||||
userId: dbUserInfo.userId,
|
userId: dbUserInfo.userId,
|
||||||
userName: dbUserInfo.userName || "",
|
userName: dbUserInfo.userName || "",
|
||||||
deptName: dbUserInfo.deptName || "",
|
deptName: dbUserInfo.deptName || "",
|
||||||
companyCode: dbUserInfo.companyCode || "ILSHIN",
|
companyCode: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
|
||||||
company_code: dbUserInfo.companyCode || "ILSHIN", // 프론트엔드 호환성
|
company_code: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
|
||||||
userType: dbUserInfo.userType || "USER",
|
userType: userInfo.userType || dbUserInfo.userType || "USER", // JWT 토큰 우선
|
||||||
userTypeName: dbUserInfo.userTypeName || "일반사용자",
|
userTypeName: dbUserInfo.userTypeName || "일반사용자",
|
||||||
email: dbUserInfo.email || "",
|
email: dbUserInfo.email || "",
|
||||||
photo: dbUserInfo.photo,
|
photo: dbUserInfo.photo,
|
||||||
|
|
|
||||||
|
|
@ -662,6 +662,10 @@ export const getParentOptions = async (
|
||||||
/**
|
/**
|
||||||
* 연쇄 관계로 자식 옵션 조회
|
* 연쇄 관계로 자식 옵션 조회
|
||||||
* 실제 연쇄 드롭다운에서 사용하는 API
|
* 실제 연쇄 드롭다운에서 사용하는 API
|
||||||
|
*
|
||||||
|
* 다중 부모값 지원:
|
||||||
|
* - parentValue: 단일 값 (예: "공정검사")
|
||||||
|
* - parentValues: 다중 값 (예: "공정검사,출하검사" 또는 배열)
|
||||||
*/
|
*/
|
||||||
export const getCascadingOptions = async (
|
export const getCascadingOptions = async (
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
|
|
@ -669,10 +673,26 @@ export const getCascadingOptions = async (
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const { code } = req.params;
|
const { code } = req.params;
|
||||||
const { parentValue } = req.query;
|
const { parentValue, parentValues } = req.query;
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
if (!parentValue) {
|
// 다중 부모값 파싱
|
||||||
|
let parentValueArray: string[] = [];
|
||||||
|
|
||||||
|
if (parentValues) {
|
||||||
|
// parentValues가 있으면 우선 사용 (다중 선택)
|
||||||
|
if (Array.isArray(parentValues)) {
|
||||||
|
parentValueArray = parentValues.map(v => String(v));
|
||||||
|
} else {
|
||||||
|
// 콤마로 구분된 문자열
|
||||||
|
parentValueArray = String(parentValues).split(',').map(v => v.trim()).filter(v => v);
|
||||||
|
}
|
||||||
|
} else if (parentValue) {
|
||||||
|
// 기존 단일 값 호환
|
||||||
|
parentValueArray = [String(parentValue)];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parentValueArray.length === 0) {
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: [],
|
data: [],
|
||||||
|
|
@ -714,13 +734,17 @@ export const getCascadingOptions = async (
|
||||||
|
|
||||||
const relation = relationResult.rows[0];
|
const relation = relationResult.rows[0];
|
||||||
|
|
||||||
// 자식 옵션 조회
|
// 자식 옵션 조회 - 다중 부모값에 대해 IN 절 사용
|
||||||
|
// SQL Injection 방지를 위해 파라미터화된 쿼리 사용
|
||||||
|
const placeholders = parentValueArray.map((_, idx) => `$${idx + 1}`).join(', ');
|
||||||
|
|
||||||
let optionsQuery = `
|
let optionsQuery = `
|
||||||
SELECT
|
SELECT DISTINCT
|
||||||
${relation.child_value_column} as value,
|
${relation.child_value_column} as value,
|
||||||
${relation.child_label_column} as label
|
${relation.child_label_column} as label,
|
||||||
|
${relation.child_filter_column} as parent_value
|
||||||
FROM ${relation.child_table}
|
FROM ${relation.child_table}
|
||||||
WHERE ${relation.child_filter_column} = $1
|
WHERE ${relation.child_filter_column} IN (${placeholders})
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// 멀티테넌시 적용 (테이블에 company_code가 있는 경우)
|
// 멀티테넌시 적용 (테이블에 company_code가 있는 경우)
|
||||||
|
|
@ -730,7 +754,8 @@ export const getCascadingOptions = async (
|
||||||
[relation.child_table]
|
[relation.child_table]
|
||||||
);
|
);
|
||||||
|
|
||||||
const optionsParams: any[] = [parentValue];
|
const optionsParams: any[] = [...parentValueArray];
|
||||||
|
let paramIndex = parentValueArray.length + 1;
|
||||||
|
|
||||||
// company_code = "*"는 최고 관리자 전용이므로 일반 회사는 자기 회사 데이터만
|
// company_code = "*"는 최고 관리자 전용이므로 일반 회사는 자기 회사 데이터만
|
||||||
if (
|
if (
|
||||||
|
|
@ -738,8 +763,9 @@ export const getCascadingOptions = async (
|
||||||
tableInfoResult.rowCount > 0 &&
|
tableInfoResult.rowCount > 0 &&
|
||||||
companyCode !== "*"
|
companyCode !== "*"
|
||||||
) {
|
) {
|
||||||
optionsQuery += ` AND company_code = $2`;
|
optionsQuery += ` AND company_code = $${paramIndex}`;
|
||||||
optionsParams.push(companyCode);
|
optionsParams.push(companyCode);
|
||||||
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 정렬
|
// 정렬
|
||||||
|
|
@ -751,9 +777,9 @@ export const getCascadingOptions = async (
|
||||||
|
|
||||||
const optionsResult = await pool.query(optionsQuery, optionsParams);
|
const optionsResult = await pool.query(optionsQuery, optionsParams);
|
||||||
|
|
||||||
logger.info("연쇄 옵션 조회", {
|
logger.info("연쇄 옵션 조회 (다중 부모값 지원)", {
|
||||||
relationCode: code,
|
relationCode: code,
|
||||||
parentValue,
|
parentValues: parentValueArray,
|
||||||
optionsCount: optionsResult.rowCount,
|
optionsCount: optionsResult.rowCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -282,3 +282,175 @@ export async function previewCodeMerge(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 값 기반 코드 병합 - 모든 테이블의 모든 컬럼에서 해당 값을 찾아 변경
|
||||||
|
* 컬럼명에 상관없이 oldValue를 가진 모든 곳을 newValue로 변경
|
||||||
|
*/
|
||||||
|
export async function mergeCodeByValue(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
const { oldValue, newValue } = req.body;
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 입력값 검증
|
||||||
|
if (!oldValue || !newValue) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 필드가 누락되었습니다. (oldValue, newValue)",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!companyCode) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: "인증 정보가 없습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 같은 값으로 병합 시도 방지
|
||||||
|
if (oldValue === newValue) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "기존 값과 새 값이 동일합니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("값 기반 코드 병합 시작", {
|
||||||
|
oldValue,
|
||||||
|
newValue,
|
||||||
|
companyCode,
|
||||||
|
userId: req.user?.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// PostgreSQL 함수 호출
|
||||||
|
const result = await pool.query(
|
||||||
|
"SELECT * FROM merge_code_by_value($1, $2, $3)",
|
||||||
|
[oldValue, newValue, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 결과 처리
|
||||||
|
const affectedData = Array.isArray(result) ? result : ((result as any).rows || []);
|
||||||
|
const totalRows = affectedData.reduce(
|
||||||
|
(sum: number, row: any) => sum + parseInt(row.out_rows_updated || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("값 기반 코드 병합 완료", {
|
||||||
|
oldValue,
|
||||||
|
newValue,
|
||||||
|
affectedTablesCount: affectedData.length,
|
||||||
|
totalRowsUpdated: totalRows,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `코드 병합 완료: ${oldValue} → ${newValue}`,
|
||||||
|
data: {
|
||||||
|
oldValue,
|
||||||
|
newValue,
|
||||||
|
affectedData: affectedData.map((row: any) => ({
|
||||||
|
tableName: row.out_table_name,
|
||||||
|
columnName: row.out_column_name,
|
||||||
|
rowsUpdated: parseInt(row.out_rows_updated),
|
||||||
|
})),
|
||||||
|
totalRowsUpdated: totalRows,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("값 기반 코드 병합 실패:", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
oldValue,
|
||||||
|
newValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "코드 병합 중 오류가 발생했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "CODE_MERGE_BY_VALUE_ERROR",
|
||||||
|
details: error.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 값 기반 코드 병합 미리보기
|
||||||
|
* 컬럼명에 상관없이 해당 값을 가진 모든 테이블/컬럼 조회
|
||||||
|
*/
|
||||||
|
export async function previewMergeCodeByValue(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
const { oldValue } = req.body;
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!oldValue) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 필드가 누락되었습니다. (oldValue)",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!companyCode) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: "인증 정보가 없습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("값 기반 코드 병합 미리보기", { oldValue, companyCode });
|
||||||
|
|
||||||
|
// PostgreSQL 함수 호출
|
||||||
|
const result = await pool.query(
|
||||||
|
"SELECT * FROM preview_merge_code_by_value($1, $2)",
|
||||||
|
[oldValue, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
const preview = Array.isArray(result) ? result : ((result as any).rows || []);
|
||||||
|
const totalRows = preview.reduce(
|
||||||
|
(sum: number, row: any) => sum + parseInt(row.out_affected_rows || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("값 기반 코드 병합 미리보기 완료", {
|
||||||
|
tablesCount: preview.length,
|
||||||
|
totalRows,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "코드 병합 미리보기 완료",
|
||||||
|
data: {
|
||||||
|
oldValue,
|
||||||
|
preview: preview.map((row: any) => ({
|
||||||
|
tableName: row.out_table_name,
|
||||||
|
columnName: row.out_column_name,
|
||||||
|
affectedRows: parseInt(row.out_affected_rows),
|
||||||
|
})),
|
||||||
|
totalAffectedRows: totalRows,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("값 기반 코드 병합 미리보기 실패:", error);
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "코드 병합 미리보기 중 오류가 발생했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "PREVIEW_BY_VALUE_ERROR",
|
||||||
|
details: error.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,9 @@ export class CommonCodeController {
|
||||||
sortOrder: code.sort_order,
|
sortOrder: code.sort_order,
|
||||||
isActive: code.is_active,
|
isActive: code.is_active,
|
||||||
useYn: code.is_active,
|
useYn: code.is_active,
|
||||||
companyCode: code.company_code, // 추가
|
companyCode: code.company_code,
|
||||||
|
parentCodeValue: code.parent_code_value, // 계층구조: 부모 코드값
|
||||||
|
depth: code.depth, // 계층구조: 깊이
|
||||||
|
|
||||||
// 기존 필드명도 유지 (하위 호환성)
|
// 기존 필드명도 유지 (하위 호환성)
|
||||||
code_category: code.code_category,
|
code_category: code.code_category,
|
||||||
|
|
@ -103,7 +105,9 @@ export class CommonCodeController {
|
||||||
code_name_eng: code.code_name_eng,
|
code_name_eng: code.code_name_eng,
|
||||||
sort_order: code.sort_order,
|
sort_order: code.sort_order,
|
||||||
is_active: code.is_active,
|
is_active: code.is_active,
|
||||||
company_code: code.company_code, // 추가
|
company_code: code.company_code,
|
||||||
|
parent_code_value: code.parent_code_value, // 계층구조: 부모 코드값
|
||||||
|
// depth는 위에서 이미 정의됨 (snake_case와 camelCase 동일)
|
||||||
created_date: code.created_date,
|
created_date: code.created_date,
|
||||||
created_by: code.created_by,
|
created_by: code.created_by,
|
||||||
updated_date: code.updated_date,
|
updated_date: code.updated_date,
|
||||||
|
|
@ -286,19 +290,17 @@ export class CommonCodeController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!menuObjid) {
|
// menuObjid가 없으면 공통코드관리 메뉴의 기본 OBJID 사용 (전역 코드)
|
||||||
return res.status(400).json({
|
// 공통코드관리 메뉴 OBJID: 1757401858940
|
||||||
success: false,
|
const DEFAULT_CODE_MANAGEMENT_MENU_OBJID = 1757401858940;
|
||||||
message: "메뉴 OBJID는 필수입니다.",
|
const effectiveMenuObjid = menuObjid ? Number(menuObjid) : DEFAULT_CODE_MANAGEMENT_MENU_OBJID;
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const code = await this.commonCodeService.createCode(
|
const code = await this.commonCodeService.createCode(
|
||||||
categoryCode,
|
categoryCode,
|
||||||
codeData,
|
codeData,
|
||||||
userId,
|
userId,
|
||||||
companyCode,
|
companyCode,
|
||||||
Number(menuObjid)
|
effectiveMenuObjid
|
||||||
);
|
);
|
||||||
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
|
|
@ -588,4 +590,129 @@ export class CommonCodeController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층구조 코드 조회
|
||||||
|
* GET /api/common-codes/categories/:categoryCode/hierarchy
|
||||||
|
* Query: parentCodeValue (optional), depth (optional), menuObjid (optional)
|
||||||
|
*/
|
||||||
|
async getHierarchicalCodes(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { categoryCode } = req.params;
|
||||||
|
const { parentCodeValue, depth, menuObjid } = req.query;
|
||||||
|
const userCompanyCode = req.user?.companyCode;
|
||||||
|
const menuObjidNum = menuObjid ? Number(menuObjid) : undefined;
|
||||||
|
|
||||||
|
// parentCodeValue가 빈 문자열이면 최상위 코드 조회
|
||||||
|
const parentValue = parentCodeValue === '' || parentCodeValue === undefined
|
||||||
|
? null
|
||||||
|
: parentCodeValue as string;
|
||||||
|
|
||||||
|
const codes = await this.commonCodeService.getHierarchicalCodes(
|
||||||
|
categoryCode,
|
||||||
|
parentValue,
|
||||||
|
depth ? parseInt(depth as string) : undefined,
|
||||||
|
userCompanyCode,
|
||||||
|
menuObjidNum
|
||||||
|
);
|
||||||
|
|
||||||
|
// 프론트엔드 형식으로 변환
|
||||||
|
const transformedData = codes.map((code: any) => ({
|
||||||
|
codeValue: code.code_value,
|
||||||
|
codeName: code.code_name,
|
||||||
|
codeNameEng: code.code_name_eng,
|
||||||
|
description: code.description,
|
||||||
|
sortOrder: code.sort_order,
|
||||||
|
isActive: code.is_active,
|
||||||
|
parentCodeValue: code.parent_code_value,
|
||||||
|
depth: code.depth,
|
||||||
|
// 기존 필드도 유지
|
||||||
|
code_category: code.code_category,
|
||||||
|
code_value: code.code_value,
|
||||||
|
code_name: code.code_name,
|
||||||
|
code_name_eng: code.code_name_eng,
|
||||||
|
sort_order: code.sort_order,
|
||||||
|
is_active: code.is_active,
|
||||||
|
parent_code_value: code.parent_code_value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: transformedData,
|
||||||
|
message: `계층구조 코드 조회 성공 (${categoryCode})`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`계층구조 코드 조회 실패 (${req.params.categoryCode}):`, error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "계층구조 코드 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 코드 트리 조회
|
||||||
|
* GET /api/common-codes/categories/:categoryCode/tree
|
||||||
|
*/
|
||||||
|
async getCodeTree(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { categoryCode } = req.params;
|
||||||
|
const { menuObjid } = req.query;
|
||||||
|
const userCompanyCode = req.user?.companyCode;
|
||||||
|
const menuObjidNum = menuObjid ? Number(menuObjid) : undefined;
|
||||||
|
|
||||||
|
const result = await this.commonCodeService.getCodeTree(
|
||||||
|
categoryCode,
|
||||||
|
userCompanyCode,
|
||||||
|
menuObjidNum
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: `코드 트리 조회 성공 (${categoryCode})`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`코드 트리 조회 실패 (${req.params.categoryCode}):`, error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "코드 트리 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자식 코드 존재 여부 확인
|
||||||
|
* GET /api/common-codes/categories/:categoryCode/codes/:codeValue/has-children
|
||||||
|
*/
|
||||||
|
async hasChildren(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { categoryCode, codeValue } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
|
||||||
|
const hasChildren = await this.commonCodeService.hasChildren(
|
||||||
|
categoryCode,
|
||||||
|
codeValue,
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: { hasChildren },
|
||||||
|
message: "자식 코드 확인 완료",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`자식 코드 확인 실패 (${req.params.categoryCode}.${req.params.codeValue}):`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "자식 코드 확인 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -231,7 +231,7 @@ export const deleteFormData = async (
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { companyCode, userId } = req.user as any;
|
const { companyCode, userId } = req.user as any;
|
||||||
const { tableName } = req.body;
|
const { tableName, screenId } = req.body;
|
||||||
|
|
||||||
if (!tableName) {
|
if (!tableName) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
|
|
@ -240,7 +240,16 @@ export const deleteFormData = async (
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await dynamicFormService.deleteFormData(id, tableName, companyCode, userId); // userId 추가
|
// screenId를 숫자로 변환 (문자열로 전달될 수 있음)
|
||||||
|
const parsedScreenId = screenId ? parseInt(screenId, 10) : undefined;
|
||||||
|
|
||||||
|
await dynamicFormService.deleteFormData(
|
||||||
|
id,
|
||||||
|
tableName,
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
parsedScreenId // screenId 추가 (제어관리 실행용)
|
||||||
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -66,11 +66,23 @@ export class EntityJoinController {
|
||||||
const userField = parsedAutoFilter.userField || "companyCode";
|
const userField = parsedAutoFilter.userField || "companyCode";
|
||||||
const userValue = ((req as any).user as any)[userField];
|
const userValue = ((req as any).user as any)[userField];
|
||||||
|
|
||||||
if (userValue) {
|
// 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용)
|
||||||
searchConditions[filterColumn] = userValue;
|
let finalCompanyCode = userValue;
|
||||||
|
if (parsedAutoFilter.companyCodeOverride && userValue === "*") {
|
||||||
|
// 최고 관리자만 다른 회사 코드로 오버라이드 가능
|
||||||
|
finalCompanyCode = parsedAutoFilter.companyCodeOverride;
|
||||||
|
logger.info("🔓 최고 관리자 회사 코드 오버라이드:", {
|
||||||
|
originalCompanyCode: userValue,
|
||||||
|
overrideCompanyCode: parsedAutoFilter.companyCodeOverride,
|
||||||
|
tableName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalCompanyCode) {
|
||||||
|
searchConditions[filterColumn] = finalCompanyCode;
|
||||||
logger.info("🔒 Entity 조인에 멀티테넌시 필터 적용:", {
|
logger.info("🔒 Entity 조인에 멀티테넌시 필터 적용:", {
|
||||||
filterColumn,
|
filterColumn,
|
||||||
userValue,
|
finalCompanyCode,
|
||||||
tableName,
|
tableName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -424,18 +436,16 @@ export class EntityJoinController {
|
||||||
config.referenceTable
|
config.referenceTable
|
||||||
);
|
);
|
||||||
|
|
||||||
// 현재 display_column으로 사용 중인 컬럼 제외
|
// 현재 display_column 정보 (참고용으로만 사용, 필터링하지 않음)
|
||||||
const currentDisplayColumn =
|
const currentDisplayColumn =
|
||||||
config.displayColumn || config.displayColumns[0];
|
config.displayColumn || config.displayColumns[0];
|
||||||
const availableColumns = columns.filter(
|
|
||||||
(col) => col.columnName !== currentDisplayColumn
|
// 모든 컬럼 표시 (기본 표시 컬럼도 포함)
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
joinConfig: config,
|
joinConfig: config,
|
||||||
tableName: config.referenceTable,
|
tableName: config.referenceTable,
|
||||||
currentDisplayColumn: currentDisplayColumn,
|
currentDisplayColumn: currentDisplayColumn,
|
||||||
availableColumns: availableColumns.map((col) => ({
|
availableColumns: columns.map((col) => ({
|
||||||
columnName: col.columnName,
|
columnName: col.columnName,
|
||||||
columnLabel: col.displayName || col.columnName,
|
columnLabel: col.displayName || col.columnName,
|
||||||
dataType: col.dataType,
|
dataType: col.dataType,
|
||||||
|
|
|
||||||
|
|
@ -107,14 +107,88 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 추가 필터 조건 (존재하는 컬럼만)
|
// 추가 필터 조건 (존재하는 컬럼만)
|
||||||
|
// 지원 연산자: =, !=, >, <, >=, <=, in, notIn, like
|
||||||
|
// 특수 키 형식: column__operator (예: division__in, name__like)
|
||||||
const additionalFilter = JSON.parse(filterCondition as string);
|
const additionalFilter = JSON.parse(filterCondition as string);
|
||||||
for (const [key, value] of Object.entries(additionalFilter)) {
|
for (const [key, value] of Object.entries(additionalFilter)) {
|
||||||
if (existingColumns.has(key)) {
|
// 특수 키 형식 파싱: column__operator
|
||||||
whereConditions.push(`${key} = $${paramIndex}`);
|
let columnName = key;
|
||||||
params.push(value);
|
let operator = "=";
|
||||||
paramIndex++;
|
|
||||||
} else {
|
if (key.includes("__")) {
|
||||||
logger.warn("엔티티 검색: 필터 조건에 존재하지 않는 컬럼 제외", { tableName, key });
|
const parts = key.split("__");
|
||||||
|
columnName = parts[0];
|
||||||
|
operator = parts[1] || "=";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existingColumns.has(columnName)) {
|
||||||
|
logger.warn("엔티티 검색: 필터 조건에 존재하지 않는 컬럼 제외", { tableName, key, columnName });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 연산자별 WHERE 조건 생성
|
||||||
|
switch (operator) {
|
||||||
|
case "=":
|
||||||
|
whereConditions.push(`"${columnName}" = $${paramIndex}`);
|
||||||
|
params.push(value);
|
||||||
|
paramIndex++;
|
||||||
|
break;
|
||||||
|
case "!=":
|
||||||
|
whereConditions.push(`"${columnName}" != $${paramIndex}`);
|
||||||
|
params.push(value);
|
||||||
|
paramIndex++;
|
||||||
|
break;
|
||||||
|
case ">":
|
||||||
|
whereConditions.push(`"${columnName}" > $${paramIndex}`);
|
||||||
|
params.push(value);
|
||||||
|
paramIndex++;
|
||||||
|
break;
|
||||||
|
case "<":
|
||||||
|
whereConditions.push(`"${columnName}" < $${paramIndex}`);
|
||||||
|
params.push(value);
|
||||||
|
paramIndex++;
|
||||||
|
break;
|
||||||
|
case ">=":
|
||||||
|
whereConditions.push(`"${columnName}" >= $${paramIndex}`);
|
||||||
|
params.push(value);
|
||||||
|
paramIndex++;
|
||||||
|
break;
|
||||||
|
case "<=":
|
||||||
|
whereConditions.push(`"${columnName}" <= $${paramIndex}`);
|
||||||
|
params.push(value);
|
||||||
|
paramIndex++;
|
||||||
|
break;
|
||||||
|
case "in":
|
||||||
|
// IN 연산자: 값이 배열이거나 쉼표로 구분된 문자열
|
||||||
|
const inValues = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim());
|
||||||
|
if (inValues.length > 0) {
|
||||||
|
const placeholders = inValues.map((_, i) => `$${paramIndex + i}`).join(", ");
|
||||||
|
whereConditions.push(`"${columnName}" IN (${placeholders})`);
|
||||||
|
params.push(...inValues);
|
||||||
|
paramIndex += inValues.length;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "notIn":
|
||||||
|
// NOT IN 연산자
|
||||||
|
const notInValues = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim());
|
||||||
|
if (notInValues.length > 0) {
|
||||||
|
const placeholders = notInValues.map((_, i) => `$${paramIndex + i}`).join(", ");
|
||||||
|
whereConditions.push(`"${columnName}" NOT IN (${placeholders})`);
|
||||||
|
params.push(...notInValues);
|
||||||
|
paramIndex += notInValues.length;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "like":
|
||||||
|
whereConditions.push(`"${columnName}"::text ILIKE $${paramIndex}`);
|
||||||
|
params.push(`%${value}%`);
|
||||||
|
paramIndex++;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// 알 수 없는 연산자는 등호로 처리
|
||||||
|
whereConditions.push(`"${columnName}" = $${paramIndex}`);
|
||||||
|
params.push(value);
|
||||||
|
paramIndex++;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
import { Response } from "express";
|
||||||
|
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||||
|
import excelMappingService from "../services/excelMappingService";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엑셀 컬럼 구조로 매핑 템플릿 조회
|
||||||
|
* POST /api/excel-mapping/find
|
||||||
|
*/
|
||||||
|
export async function findMappingByColumns(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { tableName, excelColumns } = req.body;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
if (!tableName || !excelColumns || !Array.isArray(excelColumns)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "tableName과 excelColumns(배열)가 필요합니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("엑셀 매핑 템플릿 조회 요청", {
|
||||||
|
tableName,
|
||||||
|
excelColumns,
|
||||||
|
companyCode,
|
||||||
|
userId: req.user?.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const template = await excelMappingService.findMappingByColumns(
|
||||||
|
tableName,
|
||||||
|
excelColumns,
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
|
||||||
|
if (template) {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: template,
|
||||||
|
message: "기존 매핑 템플릿을 찾았습니다.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: null,
|
||||||
|
message: "일치하는 매핑 템플릿이 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("매핑 템플릿 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "매핑 템플릿 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매핑 템플릿 저장 (UPSERT)
|
||||||
|
* POST /api/excel-mapping/save
|
||||||
|
*/
|
||||||
|
export async function saveMappingTemplate(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { tableName, excelColumns, columnMappings } = req.body;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
|
||||||
|
if (!tableName || !excelColumns || !columnMappings) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "tableName, excelColumns, columnMappings가 필요합니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("엑셀 매핑 템플릿 저장 요청", {
|
||||||
|
tableName,
|
||||||
|
excelColumns,
|
||||||
|
columnMappings,
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const template = await excelMappingService.saveMappingTemplate(
|
||||||
|
tableName,
|
||||||
|
excelColumns,
|
||||||
|
columnMappings,
|
||||||
|
companyCode,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: template,
|
||||||
|
message: "매핑 템플릿이 저장되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("매핑 템플릿 저장 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "매핑 템플릿 저장 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블의 매핑 템플릿 목록 조회
|
||||||
|
* GET /api/excel-mapping/list/:tableName
|
||||||
|
*/
|
||||||
|
export async function getMappingTemplates(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { tableName } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
if (!tableName) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "tableName이 필요합니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("매핑 템플릿 목록 조회 요청", {
|
||||||
|
tableName,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const templates = await excelMappingService.getMappingTemplates(
|
||||||
|
tableName,
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: templates,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("매핑 템플릿 목록 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "매핑 템플릿 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매핑 템플릿 삭제
|
||||||
|
* DELETE /api/excel-mapping/:id
|
||||||
|
*/
|
||||||
|
export async function deleteMappingTemplate(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "id가 필요합니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("매핑 템플릿 삭제 요청", {
|
||||||
|
id,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleted = await excelMappingService.deleteMappingTemplate(
|
||||||
|
parseInt(id),
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deleted) {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "매핑 템플릿이 삭제되었습니다.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "삭제할 매핑 템플릿을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("매핑 템플릿 삭제 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "매핑 템플릿 삭제 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -10,7 +10,10 @@ import {
|
||||||
SaveLangTextsRequest,
|
SaveLangTextsRequest,
|
||||||
GetUserTextParams,
|
GetUserTextParams,
|
||||||
BatchTranslationRequest,
|
BatchTranslationRequest,
|
||||||
|
GenerateKeyRequest,
|
||||||
|
CreateOverrideKeyRequest,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
|
LangCategory,
|
||||||
} from "../types/multilang";
|
} from "../types/multilang";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -187,7 +190,7 @@ export const getLangKeys = async (
|
||||||
res: Response
|
res: Response
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { companyCode, menuCode, keyType, searchText } = req.query;
|
const { companyCode, menuCode, keyType, searchText, categoryId } = req.query;
|
||||||
logger.info("다국어 키 목록 조회 요청", {
|
logger.info("다국어 키 목록 조회 요청", {
|
||||||
query: req.query,
|
query: req.query,
|
||||||
user: req.user,
|
user: req.user,
|
||||||
|
|
@ -199,6 +202,7 @@ export const getLangKeys = async (
|
||||||
menuCode: menuCode as string,
|
menuCode: menuCode as string,
|
||||||
keyType: keyType as string,
|
keyType: keyType as string,
|
||||||
searchText: searchText as string,
|
searchText: searchText as string,
|
||||||
|
categoryId: categoryId ? parseInt(categoryId as string, 10) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response: ApiResponse<any[]> = {
|
const response: ApiResponse<any[]> = {
|
||||||
|
|
@ -630,6 +634,391 @@ export const deleteLanguage = async (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 카테고리 관련 API
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/multilang/categories
|
||||||
|
* 카테고리 목록 조회 API (트리 구조)
|
||||||
|
*/
|
||||||
|
export const getCategories = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
logger.info("카테고리 목록 조회 요청", { user: req.user });
|
||||||
|
|
||||||
|
const multiLangService = new MultiLangService();
|
||||||
|
const categories = await multiLangService.getCategories();
|
||||||
|
|
||||||
|
const response: ApiResponse<LangCategory[]> = {
|
||||||
|
success: true,
|
||||||
|
message: "카테고리 목록 조회 성공",
|
||||||
|
data: categories,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(200).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("카테고리 목록 조회 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "카테고리 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "CATEGORY_LIST_ERROR",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/multilang/categories/:categoryId
|
||||||
|
* 카테고리 상세 조회 API
|
||||||
|
*/
|
||||||
|
export const getCategoryById = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { categoryId } = req.params;
|
||||||
|
logger.info("카테고리 상세 조회 요청", { categoryId, user: req.user });
|
||||||
|
|
||||||
|
const multiLangService = new MultiLangService();
|
||||||
|
const category = await multiLangService.getCategoryById(parseInt(categoryId));
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "카테고리를 찾을 수 없습니다.",
|
||||||
|
error: {
|
||||||
|
code: "CATEGORY_NOT_FOUND",
|
||||||
|
details: `Category ID ${categoryId} not found`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: ApiResponse<LangCategory> = {
|
||||||
|
success: true,
|
||||||
|
message: "카테고리 상세 조회 성공",
|
||||||
|
data: category,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(200).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("카테고리 상세 조회 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "카테고리 상세 조회 중 오류가 발생했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "CATEGORY_DETAIL_ERROR",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/multilang/categories/:categoryId/path
|
||||||
|
* 카테고리 경로 조회 API (부모 포함)
|
||||||
|
*/
|
||||||
|
export const getCategoryPath = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { categoryId } = req.params;
|
||||||
|
logger.info("카테고리 경로 조회 요청", { categoryId, user: req.user });
|
||||||
|
|
||||||
|
const multiLangService = new MultiLangService();
|
||||||
|
const path = await multiLangService.getCategoryPath(parseInt(categoryId));
|
||||||
|
|
||||||
|
const response: ApiResponse<LangCategory[]> = {
|
||||||
|
success: true,
|
||||||
|
message: "카테고리 경로 조회 성공",
|
||||||
|
data: path,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(200).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("카테고리 경로 조회 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "카테고리 경로 조회 중 오류가 발생했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "CATEGORY_PATH_ERROR",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 자동 생성 및 오버라이드 관련 API
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/multilang/keys/generate
|
||||||
|
* 키 자동 생성 API
|
||||||
|
*/
|
||||||
|
export const generateKey = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const generateData: GenerateKeyRequest = req.body;
|
||||||
|
logger.info("키 자동 생성 요청", { generateData, user: req.user });
|
||||||
|
|
||||||
|
// 필수 입력값 검증
|
||||||
|
if (!generateData.companyCode || !generateData.categoryId || !generateData.keyMeaning) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "회사 코드, 카테고리 ID, 키 의미는 필수입니다.",
|
||||||
|
error: {
|
||||||
|
code: "MISSING_REQUIRED_FIELDS",
|
||||||
|
details: "companyCode, categoryId, and keyMeaning are required",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 권한 검사: 공통 키(*)는 최고 관리자만 생성 가능
|
||||||
|
if (generateData.companyCode === "*" && req.user?.companyCode !== "*") {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "공통 키는 최고 관리자만 생성할 수 있습니다.",
|
||||||
|
error: {
|
||||||
|
code: "PERMISSION_DENIED",
|
||||||
|
details: "Only super admin can create common keys",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 회사 관리자는 자기 회사 키만 생성 가능
|
||||||
|
if (generateData.companyCode !== "*" &&
|
||||||
|
req.user?.companyCode !== "*" &&
|
||||||
|
generateData.companyCode !== req.user?.companyCode) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "다른 회사의 키를 생성할 권한이 없습니다.",
|
||||||
|
error: {
|
||||||
|
code: "PERMISSION_DENIED",
|
||||||
|
details: "Cannot create keys for other companies",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const multiLangService = new MultiLangService();
|
||||||
|
const keyId = await multiLangService.generateKey({
|
||||||
|
...generateData,
|
||||||
|
createdBy: req.user?.userId || "system",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: ApiResponse<number> = {
|
||||||
|
success: true,
|
||||||
|
message: "키가 성공적으로 생성되었습니다.",
|
||||||
|
data: keyId,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(201).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("키 자동 생성 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "키 자동 생성 중 오류가 발생했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "KEY_GENERATE_ERROR",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/multilang/keys/preview
|
||||||
|
* 키 미리보기 API
|
||||||
|
*/
|
||||||
|
export const previewKey = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { categoryId, keyMeaning, companyCode } = req.body;
|
||||||
|
logger.info("키 미리보기 요청", { categoryId, keyMeaning, companyCode, user: req.user });
|
||||||
|
|
||||||
|
if (!categoryId || !keyMeaning || !companyCode) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "카테고리 ID, 키 의미, 회사 코드는 필수입니다.",
|
||||||
|
error: {
|
||||||
|
code: "MISSING_REQUIRED_FIELDS",
|
||||||
|
details: "categoryId, keyMeaning, and companyCode are required",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const multiLangService = new MultiLangService();
|
||||||
|
const preview = await multiLangService.previewGeneratedKey(
|
||||||
|
parseInt(categoryId),
|
||||||
|
keyMeaning,
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
|
||||||
|
const response: ApiResponse<{
|
||||||
|
langKey: string;
|
||||||
|
exists: boolean;
|
||||||
|
isOverride: boolean;
|
||||||
|
baseKeyId?: number;
|
||||||
|
}> = {
|
||||||
|
success: true,
|
||||||
|
message: "키 미리보기 성공",
|
||||||
|
data: preview,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(200).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("키 미리보기 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "키 미리보기 중 오류가 발생했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "KEY_PREVIEW_ERROR",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/multilang/keys/override
|
||||||
|
* 오버라이드 키 생성 API
|
||||||
|
*/
|
||||||
|
export const createOverrideKey = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const overrideData: CreateOverrideKeyRequest = req.body;
|
||||||
|
logger.info("오버라이드 키 생성 요청", { overrideData, user: req.user });
|
||||||
|
|
||||||
|
// 필수 입력값 검증
|
||||||
|
if (!overrideData.companyCode || !overrideData.baseKeyId) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "회사 코드와 원본 키 ID는 필수입니다.",
|
||||||
|
error: {
|
||||||
|
code: "MISSING_REQUIRED_FIELDS",
|
||||||
|
details: "companyCode and baseKeyId are required",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최고 관리자(*)는 오버라이드 키를 만들 수 없음 (이미 공통 키)
|
||||||
|
if (overrideData.companyCode === "*") {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "공통 키에 대한 오버라이드는 생성할 수 없습니다.",
|
||||||
|
error: {
|
||||||
|
code: "INVALID_OVERRIDE",
|
||||||
|
details: "Cannot create override for common keys",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 회사 관리자는 자기 회사 오버라이드만 생성 가능
|
||||||
|
if (req.user?.companyCode !== "*" &&
|
||||||
|
overrideData.companyCode !== req.user?.companyCode) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "다른 회사의 오버라이드 키를 생성할 권한이 없습니다.",
|
||||||
|
error: {
|
||||||
|
code: "PERMISSION_DENIED",
|
||||||
|
details: "Cannot create override keys for other companies",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const multiLangService = new MultiLangService();
|
||||||
|
const keyId = await multiLangService.createOverrideKey({
|
||||||
|
...overrideData,
|
||||||
|
createdBy: req.user?.userId || "system",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: ApiResponse<number> = {
|
||||||
|
success: true,
|
||||||
|
message: "오버라이드 키가 성공적으로 생성되었습니다.",
|
||||||
|
data: keyId,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(201).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("오버라이드 키 생성 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "오버라이드 키 생성 중 오류가 발생했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "OVERRIDE_KEY_CREATE_ERROR",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/multilang/keys/overrides/:companyCode
|
||||||
|
* 회사별 오버라이드 키 목록 조회 API
|
||||||
|
*/
|
||||||
|
export const getOverrideKeys = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { companyCode } = req.params;
|
||||||
|
logger.info("오버라이드 키 목록 조회 요청", { companyCode, user: req.user });
|
||||||
|
|
||||||
|
// 권한 검사: 최고 관리자 또는 해당 회사 관리자만 조회 가능
|
||||||
|
if (req.user?.companyCode !== "*" && companyCode !== req.user?.companyCode) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "다른 회사의 오버라이드 키를 조회할 권한이 없습니다.",
|
||||||
|
error: {
|
||||||
|
code: "PERMISSION_DENIED",
|
||||||
|
details: "Cannot view override keys for other companies",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const multiLangService = new MultiLangService();
|
||||||
|
const keys = await multiLangService.getOverrideKeys(companyCode);
|
||||||
|
|
||||||
|
const response: ApiResponse<any[]> = {
|
||||||
|
success: true,
|
||||||
|
message: "오버라이드 키 목록 조회 성공",
|
||||||
|
data: keys,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(200).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("오버라이드 키 목록 조회 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "오버라이드 키 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "OVERRIDE_KEYS_LIST_ERROR",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/multilang/batch
|
* POST /api/multilang/batch
|
||||||
* 다국어 텍스트 배치 조회 API
|
* 다국어 텍스트 배치 조회 API
|
||||||
|
|
@ -710,3 +1099,86 @@ export const getBatchTranslations = async (
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/multilang/screen-labels
|
||||||
|
* 화면 라벨 다국어 키 자동 생성 API
|
||||||
|
*/
|
||||||
|
export const generateScreenLabelKeys = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { screenId, menuObjId, labels } = req.body;
|
||||||
|
|
||||||
|
logger.info("화면 라벨 다국어 키 생성 요청", {
|
||||||
|
screenId,
|
||||||
|
menuObjId,
|
||||||
|
labelCount: labels?.length,
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 필수 파라미터 검증
|
||||||
|
if (!screenId) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "screenId는 필수입니다.",
|
||||||
|
error: { code: "MISSING_SCREEN_ID" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!labels || !Array.isArray(labels) || labels.length === 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "labels 배열이 필요합니다.",
|
||||||
|
error: { code: "MISSING_LABELS" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 화면의 회사 정보 조회 (사용자 회사가 아닌 화면 소속 회사 기준)
|
||||||
|
const { queryOne } = await import("../database/db");
|
||||||
|
const screenInfo = await queryOne<{ company_code: string }>(
|
||||||
|
`SELECT company_code FROM screen_definitions WHERE screen_id = $1`,
|
||||||
|
[screenId]
|
||||||
|
);
|
||||||
|
const companyCode = screenInfo?.company_code || req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
// 회사명 조회
|
||||||
|
const companyInfo = await queryOne<{ company_name: string }>(
|
||||||
|
`SELECT company_name FROM company_mng WHERE company_code = $1`,
|
||||||
|
[companyCode]
|
||||||
|
);
|
||||||
|
const companyName = companyCode === "*" ? "공통" : (companyInfo?.company_name || companyCode);
|
||||||
|
|
||||||
|
logger.info("화면 소속 회사 정보", { screenId, companyCode, companyName });
|
||||||
|
|
||||||
|
const multiLangService = new MultiLangService();
|
||||||
|
const results = await multiLangService.generateScreenLabelKeys({
|
||||||
|
screenId: Number(screenId),
|
||||||
|
companyCode,
|
||||||
|
companyName,
|
||||||
|
menuObjId,
|
||||||
|
labels,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: ApiResponse<typeof results> = {
|
||||||
|
success: true,
|
||||||
|
message: `${results.length}개의 다국어 키가 생성되었습니다.`,
|
||||||
|
data: results,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(200).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("화면 라벨 다국어 키 생성 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "화면 라벨 다국어 키 생성 중 오류가 발생했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "SCREEN_LABEL_KEY_GENERATION_ERROR",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -217,11 +217,14 @@ router.post("/:ruleId/allocate", authenticateToken, async (req: AuthenticatedReq
|
||||||
const companyCode = req.user!.companyCode;
|
const companyCode = req.user!.companyCode;
|
||||||
const { ruleId } = req.params;
|
const { ruleId } = req.params;
|
||||||
|
|
||||||
|
logger.info("코드 할당 요청", { ruleId, companyCode });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode);
|
const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode);
|
||||||
|
logger.info("코드 할당 성공", { ruleId, allocatedCode });
|
||||||
return res.json({ success: true, data: { generatedCode: allocatedCode } });
|
return res.json({ success: true, data: { generatedCode: allocatedCode } });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error("코드 할당 실패", { error: error.message });
|
logger.error("코드 할당 실패", { ruleId, companyCode, error: error.message });
|
||||||
return res.status(500).json({ success: false, error: error.message });
|
return res.status(500).json({ success: false, error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -30,6 +30,29 @@ export const getCategoryColumns = async (req: AuthenticatedRequest, res: Respons
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 테이블의 카테고리 컬럼 목록 조회 (Select 옵션 설정용)
|
||||||
|
*/
|
||||||
|
export const getAllCategoryColumns = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
|
||||||
|
const columns = await tableCategoryValueService.getAllCategoryColumns(companyCode);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: columns,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`전체 카테고리 컬럼 조회 실패: ${error.message}`);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "전체 카테고리 컬럼 조회 중 오류가 발생했습니다",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 카테고리 값 목록 조회 (메뉴 스코프 적용)
|
* 카테고리 값 목록 조회 (메뉴 스코프 적용)
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -767,20 +767,33 @@ export async function getTableData(
|
||||||
|
|
||||||
const tableManagementService = new TableManagementService();
|
const tableManagementService = new TableManagementService();
|
||||||
|
|
||||||
// 🆕 현재 사용자 필터 적용
|
// 🆕 현재 사용자 필터 적용 (autoFilter가 없거나 enabled가 명시적으로 false가 아니면 기본 적용)
|
||||||
let enhancedSearch = { ...search };
|
let enhancedSearch = { ...search };
|
||||||
if (autoFilter?.enabled && req.user) {
|
const shouldApplyAutoFilter = autoFilter?.enabled !== false; // 기본값: true
|
||||||
const filterColumn = autoFilter.filterColumn || "company_code";
|
if (shouldApplyAutoFilter && req.user) {
|
||||||
const userField = autoFilter.userField || "companyCode";
|
const filterColumn = autoFilter?.filterColumn || "company_code";
|
||||||
|
const userField = autoFilter?.userField || "companyCode";
|
||||||
const userValue = (req.user as any)[userField];
|
const userValue = (req.user as any)[userField];
|
||||||
|
|
||||||
if (userValue) {
|
// 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용)
|
||||||
enhancedSearch[filterColumn] = userValue;
|
let finalCompanyCode = userValue;
|
||||||
|
if (autoFilter?.companyCodeOverride && userValue === "*") {
|
||||||
|
// 최고 관리자만 다른 회사 코드로 오버라이드 가능
|
||||||
|
finalCompanyCode = autoFilter.companyCodeOverride;
|
||||||
|
logger.info("🔓 최고 관리자 회사 코드 오버라이드:", {
|
||||||
|
originalCompanyCode: userValue,
|
||||||
|
overrideCompanyCode: autoFilter.companyCodeOverride,
|
||||||
|
tableName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalCompanyCode) {
|
||||||
|
enhancedSearch[filterColumn] = finalCompanyCode;
|
||||||
|
|
||||||
logger.info("🔍 현재 사용자 필터 적용:", {
|
logger.info("🔍 현재 사용자 필터 적용:", {
|
||||||
filterColumn,
|
filterColumn,
|
||||||
userField,
|
userField,
|
||||||
userValue,
|
userValue: finalCompanyCode,
|
||||||
tableName,
|
tableName,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -877,7 +890,17 @@ export async function addTableData(
|
||||||
const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code");
|
const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code");
|
||||||
if (hasCompanyCodeColumn) {
|
if (hasCompanyCodeColumn) {
|
||||||
data.company_code = companyCode;
|
data.company_code = companyCode;
|
||||||
logger.info(`🔒 멀티테넌시: company_code 자동 추가 - ${companyCode}`);
|
logger.info(`멀티테넌시: company_code 자동 추가 - ${companyCode}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 writer 컬럼 자동 추가 (테이블에 writer 컬럼이 있고 값이 없는 경우)
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
if (userId && !data.writer) {
|
||||||
|
const hasWriterColumn = await tableManagementService.hasColumn(tableName, "writer");
|
||||||
|
if (hasWriterColumn) {
|
||||||
|
data.writer = userId;
|
||||||
|
logger.info(`writer 자동 추가 - ${userId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1962,15 +1985,21 @@ export async function multiTableSave(
|
||||||
for (const subTableConfig of subTables || []) {
|
for (const subTableConfig of subTables || []) {
|
||||||
const { tableName, linkColumn, items, options } = subTableConfig;
|
const { tableName, linkColumn, items, options } = subTableConfig;
|
||||||
|
|
||||||
if (!tableName || !items || items.length === 0) {
|
// saveMainAsFirst가 활성화된 경우, items가 비어있어도 메인 데이터를 서브 테이블에 저장해야 함
|
||||||
logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음`);
|
const hasSaveMainAsFirst = options?.saveMainAsFirst &&
|
||||||
|
options?.mainFieldMappings &&
|
||||||
|
options.mainFieldMappings.length > 0;
|
||||||
|
|
||||||
|
if (!tableName || (!items?.length && !hasSaveMainAsFirst)) {
|
||||||
|
logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음 (saveMainAsFirst: ${hasSaveMainAsFirst})`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`서브 테이블 ${tableName} 저장 시작:`, {
|
logger.info(`서브 테이블 ${tableName} 저장 시작:`, {
|
||||||
itemsCount: items.length,
|
itemsCount: items?.length || 0,
|
||||||
linkColumn,
|
linkColumn,
|
||||||
options,
|
options,
|
||||||
|
hasSaveMainAsFirst,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 기존 데이터 삭제 옵션
|
// 기존 데이터 삭제 옵션
|
||||||
|
|
@ -1988,7 +2017,15 @@ export async function multiTableSave(
|
||||||
}
|
}
|
||||||
|
|
||||||
// 메인 데이터도 서브 테이블에 저장 (옵션)
|
// 메인 데이터도 서브 테이블에 저장 (옵션)
|
||||||
if (options?.saveMainAsFirst && options?.mainFieldMappings && linkColumn?.subColumn) {
|
// mainFieldMappings가 비어 있으면 건너뜀 (필수 컬럼 누락 방지)
|
||||||
|
logger.info(`saveMainAsFirst 옵션 확인:`, {
|
||||||
|
saveMainAsFirst: options?.saveMainAsFirst,
|
||||||
|
mainFieldMappings: options?.mainFieldMappings,
|
||||||
|
mainFieldMappingsLength: options?.mainFieldMappings?.length,
|
||||||
|
linkColumn,
|
||||||
|
mainDataKeys: Object.keys(mainData),
|
||||||
|
});
|
||||||
|
if (options?.saveMainAsFirst && options?.mainFieldMappings && options.mainFieldMappings.length > 0 && linkColumn?.subColumn) {
|
||||||
const mainSubItem: Record<string, any> = {
|
const mainSubItem: Record<string, any> = {
|
||||||
[linkColumn.subColumn]: savedPkValue,
|
[linkColumn.subColumn]: savedPkValue,
|
||||||
};
|
};
|
||||||
|
|
@ -2142,3 +2179,104 @@ export async function multiTableSave(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 두 테이블 간 엔티티 관계 조회
|
||||||
|
* column_labels의 entity/category 타입 설정을 기반으로 두 테이블 간의 관계를 조회
|
||||||
|
*/
|
||||||
|
export async function getTableEntityRelations(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { leftTable, rightTable } = req.query;
|
||||||
|
|
||||||
|
if (!leftTable || !rightTable) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "leftTable과 rightTable 파라미터가 필요합니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("=== 테이블 엔티티 관계 조회 ===", { leftTable, rightTable });
|
||||||
|
|
||||||
|
// 두 테이블의 컬럼 라벨 정보 조회
|
||||||
|
const columnLabelsQuery = `
|
||||||
|
SELECT
|
||||||
|
table_name,
|
||||||
|
column_name,
|
||||||
|
column_label,
|
||||||
|
web_type,
|
||||||
|
detail_settings
|
||||||
|
FROM column_labels
|
||||||
|
WHERE table_name IN ($1, $2)
|
||||||
|
AND web_type IN ('entity', 'category')
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await query(columnLabelsQuery, [leftTable, rightTable]);
|
||||||
|
|
||||||
|
// 관계 분석
|
||||||
|
const relations: Array<{
|
||||||
|
fromTable: string;
|
||||||
|
fromColumn: string;
|
||||||
|
toTable: string;
|
||||||
|
toColumn: string;
|
||||||
|
relationType: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const row of result) {
|
||||||
|
try {
|
||||||
|
const detailSettings = typeof row.detail_settings === "string"
|
||||||
|
? JSON.parse(row.detail_settings)
|
||||||
|
: row.detail_settings;
|
||||||
|
|
||||||
|
if (detailSettings && detailSettings.referenceTable) {
|
||||||
|
const refTable = detailSettings.referenceTable;
|
||||||
|
const refColumn = detailSettings.referenceColumn || "id";
|
||||||
|
|
||||||
|
// leftTable과 rightTable 간의 관계인지 확인
|
||||||
|
if (
|
||||||
|
(row.table_name === leftTable && refTable === rightTable) ||
|
||||||
|
(row.table_name === rightTable && refTable === leftTable)
|
||||||
|
) {
|
||||||
|
relations.push({
|
||||||
|
fromTable: row.table_name,
|
||||||
|
fromColumn: row.column_name,
|
||||||
|
toTable: refTable,
|
||||||
|
toColumn: refColumn,
|
||||||
|
relationType: row.web_type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
logger.warn("detail_settings 파싱 오류:", {
|
||||||
|
table: row.table_name,
|
||||||
|
column: row.column_name,
|
||||||
|
error: parseError
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("테이블 엔티티 관계 조회 완료", {
|
||||||
|
leftTable,
|
||||||
|
rightTable,
|
||||||
|
relationsCount: relations.length
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
leftTable,
|
||||||
|
rightTable,
|
||||||
|
relations,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("테이블 엔티티 관계 조회 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "테이블 엔티티 관계 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,4 +47,10 @@ router.post("/refresh", AuthController.refreshToken);
|
||||||
*/
|
*/
|
||||||
router.post("/signup", AuthController.signup);
|
router.post("/signup", AuthController.signup);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/auth/switch-company
|
||||||
|
* WACE 관리자 전용: 다른 회사로 전환
|
||||||
|
*/
|
||||||
|
router.post("/switch-company", AuthController.switchCompany);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -51,3 +51,9 @@ router.get("/data/:groupCode", getAutoFillData);
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,3 +47,9 @@ router.get("/filtered-options/:relationCode", getFilteredOptions);
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,3 +63,9 @@ router.get("/:groupCode/options/:levelOrder", getLevelOptions);
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,3 +51,9 @@ router.get("/options/:exclusionCode", getExcludedOptions);
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import {
|
||||||
|
getCategoryValueCascadingGroups,
|
||||||
|
getCategoryValueCascadingGroupById,
|
||||||
|
getCategoryValueCascadingByCode,
|
||||||
|
createCategoryValueCascadingGroup,
|
||||||
|
updateCategoryValueCascadingGroup,
|
||||||
|
deleteCategoryValueCascadingGroup,
|
||||||
|
saveCategoryValueCascadingMappings,
|
||||||
|
getCategoryValueCascadingOptions,
|
||||||
|
getCategoryValueCascadingParentOptions,
|
||||||
|
getCategoryValueCascadingChildOptions,
|
||||||
|
getCategoryValueCascadingMappingsByTable,
|
||||||
|
} from "../controllers/categoryValueCascadingController";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// 모든 라우트에 인증 적용
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 카테고리 값 연쇄관계 그룹 CRUD
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// 그룹 목록 조회
|
||||||
|
router.get("/groups", getCategoryValueCascadingGroups);
|
||||||
|
|
||||||
|
// 그룹 상세 조회 (ID)
|
||||||
|
router.get("/groups/:groupId", getCategoryValueCascadingGroupById);
|
||||||
|
|
||||||
|
// 관계 코드로 조회
|
||||||
|
router.get("/code/:code", getCategoryValueCascadingByCode);
|
||||||
|
|
||||||
|
// 그룹 생성
|
||||||
|
router.post("/groups", createCategoryValueCascadingGroup);
|
||||||
|
|
||||||
|
// 그룹 수정
|
||||||
|
router.put("/groups/:groupId", updateCategoryValueCascadingGroup);
|
||||||
|
|
||||||
|
// 그룹 삭제
|
||||||
|
router.delete("/groups/:groupId", deleteCategoryValueCascadingGroup);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 카테고리 값 연쇄관계 매핑
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// 매핑 일괄 저장
|
||||||
|
router.post("/groups/:groupId/mappings", saveCategoryValueCascadingMappings);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 연쇄 옵션 조회 (실제 드롭다운에서 사용)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// 부모 카테고리 값 목록 조회
|
||||||
|
router.get("/parent-options/:code", getCategoryValueCascadingParentOptions);
|
||||||
|
|
||||||
|
// 자식 카테고리 값 목록 조회 (매핑 설정 UI용)
|
||||||
|
router.get("/child-options/:code", getCategoryValueCascadingChildOptions);
|
||||||
|
|
||||||
|
// 연쇄 옵션 조회 (부모 값 기반 자식 옵션)
|
||||||
|
router.get("/options/:code", getCategoryValueCascadingOptions);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 테이블별 매핑 조회 (테이블 목록 표시용)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// 테이블명으로 해당 테이블의 모든 연쇄관계 매핑 조회
|
||||||
|
router.get(
|
||||||
|
"/table/:tableName/mappings",
|
||||||
|
getCategoryValueCascadingMappingsByTable
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -3,6 +3,8 @@ import {
|
||||||
mergeCodeAllTables,
|
mergeCodeAllTables,
|
||||||
getTablesWithColumn,
|
getTablesWithColumn,
|
||||||
previewCodeMerge,
|
previewCodeMerge,
|
||||||
|
mergeCodeByValue,
|
||||||
|
previewMergeCodeByValue,
|
||||||
} from "../controllers/codeMergeController";
|
} from "../controllers/codeMergeController";
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
|
@ -13,7 +15,7 @@ router.use(authenticateToken);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/code-merge/merge-all-tables
|
* POST /api/code-merge/merge-all-tables
|
||||||
* 코드 병합 실행 (모든 관련 테이블에 적용)
|
* 코드 병합 실행 (모든 관련 테이블에 적용 - 같은 컬럼명만)
|
||||||
* Body: { columnName, oldValue, newValue }
|
* Body: { columnName, oldValue, newValue }
|
||||||
*/
|
*/
|
||||||
router.post("/merge-all-tables", mergeCodeAllTables);
|
router.post("/merge-all-tables", mergeCodeAllTables);
|
||||||
|
|
@ -26,10 +28,24 @@ router.get("/tables-with-column/:columnName", getTablesWithColumn);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/code-merge/preview
|
* POST /api/code-merge/preview
|
||||||
* 코드 병합 미리보기 (실제 실행 없이 영향받을 데이터 확인)
|
* 코드 병합 미리보기 (같은 컬럼명 기준)
|
||||||
* Body: { columnName, oldValue }
|
* Body: { columnName, oldValue }
|
||||||
*/
|
*/
|
||||||
router.post("/preview", previewCodeMerge);
|
router.post("/preview", previewCodeMerge);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/code-merge/merge-by-value
|
||||||
|
* 값 기반 코드 병합 (모든 테이블의 모든 컬럼에서 해당 값을 찾아 변경)
|
||||||
|
* Body: { oldValue, newValue }
|
||||||
|
*/
|
||||||
|
router.post("/merge-by-value", mergeCodeByValue);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/code-merge/preview-by-value
|
||||||
|
* 값 기반 코드 병합 미리보기 (컬럼명 상관없이 값으로 검색)
|
||||||
|
* Body: { oldValue }
|
||||||
|
*/
|
||||||
|
router.post("/preview-by-value", previewMergeCodeByValue);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,21 @@ router.put("/categories/:categoryCode/codes/reorder", (req, res) =>
|
||||||
commonCodeController.reorderCodes(req, res)
|
commonCodeController.reorderCodes(req, res)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 계층구조 코드 조회 (구체적인 경로를 먼저 배치)
|
||||||
|
router.get("/categories/:categoryCode/hierarchy", (req, res) =>
|
||||||
|
commonCodeController.getHierarchicalCodes(req, res)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 코드 트리 조회
|
||||||
|
router.get("/categories/:categoryCode/tree", (req, res) =>
|
||||||
|
commonCodeController.getCodeTree(req, res)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 자식 코드 존재 여부 확인
|
||||||
|
router.get("/categories/:categoryCode/codes/:codeValue/has-children", (req, res) =>
|
||||||
|
commonCodeController.hasChildren(req, res)
|
||||||
|
);
|
||||||
|
|
||||||
router.put("/categories/:categoryCode/codes/:codeValue", (req, res) =>
|
router.put("/categories/:categoryCode/codes/:codeValue", (req, res) =>
|
||||||
commonCodeController.updateCode(req, res)
|
commonCodeController.updateCode(req, res)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,262 @@
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { dataService } from "../services/dataService";
|
import { dataService } from "../services/dataService";
|
||||||
|
import { masterDetailExcelService } from "../services/masterDetailExcelService";
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
import { AuthenticatedRequest } from "../types/auth";
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// 마스터-디테일 엑셀 API
|
||||||
|
// ================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 관계 정보 조회
|
||||||
|
* GET /api/data/master-detail/relation/:screenId
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/master-detail/relation/:screenId",
|
||||||
|
authenticateToken,
|
||||||
|
async (req: AuthenticatedRequest, res) => {
|
||||||
|
try {
|
||||||
|
const { screenId } = req.params;
|
||||||
|
|
||||||
|
if (!screenId || isNaN(parseInt(screenId))) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "유효한 screenId가 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔍 마스터-디테일 관계 조회: screenId=${screenId}`);
|
||||||
|
|
||||||
|
const relation = await masterDetailExcelService.getMasterDetailRelation(
|
||||||
|
parseInt(screenId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!relation) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: null,
|
||||||
|
message: "마스터-디테일 구조가 아닙니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ 마스터-디테일 관계 발견:`, {
|
||||||
|
masterTable: relation.masterTable,
|
||||||
|
detailTable: relation.detailTable,
|
||||||
|
joinKey: relation.masterKeyColumn,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: relation,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("마스터-디테일 관계 조회 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "마스터-디테일 관계 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 엑셀 다운로드 데이터 조회
|
||||||
|
* POST /api/data/master-detail/download
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/master-detail/download",
|
||||||
|
authenticateToken,
|
||||||
|
async (req: AuthenticatedRequest, res) => {
|
||||||
|
try {
|
||||||
|
const { screenId, filters } = req.body;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
if (!screenId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "screenId가 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📥 마스터-디테일 엑셀 다운로드: screenId=${screenId}`);
|
||||||
|
|
||||||
|
// 1. 마스터-디테일 관계 조회
|
||||||
|
const relation = await masterDetailExcelService.getMasterDetailRelation(
|
||||||
|
parseInt(screenId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!relation) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "마스터-디테일 구조가 아닙니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. JOIN 데이터 조회
|
||||||
|
const data = await masterDetailExcelService.getJoinedData(
|
||||||
|
relation,
|
||||||
|
companyCode,
|
||||||
|
filters
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`✅ 마스터-디테일 데이터 조회 완료: ${data.data.length}행`);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("마스터-디테일 다운로드 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "마스터-디테일 다운로드 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 엑셀 업로드
|
||||||
|
* POST /api/data/master-detail/upload
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/master-detail/upload",
|
||||||
|
authenticateToken,
|
||||||
|
async (req: AuthenticatedRequest, res) => {
|
||||||
|
try {
|
||||||
|
const { screenId, data } = req.body;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
|
||||||
|
if (!screenId || !data || !Array.isArray(data)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "screenId와 data 배열이 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📤 마스터-디테일 엑셀 업로드: screenId=${screenId}, rows=${data.length}`);
|
||||||
|
|
||||||
|
// 1. 마스터-디테일 관계 조회
|
||||||
|
const relation = await masterDetailExcelService.getMasterDetailRelation(
|
||||||
|
parseInt(screenId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!relation) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "마스터-디테일 구조가 아닙니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 데이터 업로드
|
||||||
|
const result = await masterDetailExcelService.uploadJoinedData(
|
||||||
|
relation,
|
||||||
|
data,
|
||||||
|
companyCode,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`✅ 마스터-디테일 업로드 완료:`, {
|
||||||
|
masterInserted: result.masterInserted,
|
||||||
|
masterUpdated: result.masterUpdated,
|
||||||
|
detailInserted: result.detailInserted,
|
||||||
|
errors: result.errors.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: result.success,
|
||||||
|
data: result,
|
||||||
|
message: result.success
|
||||||
|
? `마스터 ${result.masterInserted + result.masterUpdated}건, 디테일 ${result.detailInserted}건 처리되었습니다.`
|
||||||
|
: "업로드 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("마스터-디테일 업로드 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "마스터-디테일 업로드 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 간단 모드 엑셀 업로드
|
||||||
|
* - 마스터 정보는 UI에서 선택
|
||||||
|
* - 디테일 정보만 엑셀에서 업로드
|
||||||
|
* - 채번 규칙을 통해 마스터 키 자동 생성
|
||||||
|
*
|
||||||
|
* POST /api/data/master-detail/upload-simple
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/master-detail/upload-simple",
|
||||||
|
authenticateToken,
|
||||||
|
async (req: AuthenticatedRequest, res) => {
|
||||||
|
try {
|
||||||
|
const { screenId, detailData, masterFieldValues, numberingRuleId, afterUploadFlowId, afterUploadFlows } = req.body;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const userId = req.user?.userId || "system";
|
||||||
|
|
||||||
|
if (!screenId || !detailData || !Array.isArray(detailData)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "screenId와 detailData 배열이 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📤 마스터-디테일 간단 모드 업로드: screenId=${screenId}, rows=${detailData.length}`);
|
||||||
|
console.log(` 마스터 필드 값:`, masterFieldValues);
|
||||||
|
console.log(` 채번 규칙 ID:`, numberingRuleId);
|
||||||
|
console.log(` 업로드 후 제어:`, afterUploadFlows?.length > 0 ? `${afterUploadFlows.length}개` : afterUploadFlowId || "없음");
|
||||||
|
|
||||||
|
// 업로드 실행
|
||||||
|
const result = await masterDetailExcelService.uploadSimple(
|
||||||
|
parseInt(screenId),
|
||||||
|
detailData,
|
||||||
|
masterFieldValues || {},
|
||||||
|
numberingRuleId,
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
afterUploadFlowId, // 업로드 후 제어 실행 (단일, 하위 호환성)
|
||||||
|
afterUploadFlows // 업로드 후 제어 실행 (다중)
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`✅ 마스터-디테일 간단 모드 업로드 완료:`, {
|
||||||
|
masterInserted: result.masterInserted,
|
||||||
|
detailInserted: result.detailInserted,
|
||||||
|
generatedKey: result.generatedKey,
|
||||||
|
errors: result.errors.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: result.success,
|
||||||
|
data: result,
|
||||||
|
message: result.success
|
||||||
|
? `마스터 1건(${result.generatedKey}), 디테일 ${result.detailInserted}건 처리되었습니다.`
|
||||||
|
: "업로드 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("마스터-디테일 간단 모드 업로드 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "마스터-디테일 업로드 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// 기존 데이터 API
|
||||||
|
// ================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 조인 데이터 조회 API (다른 라우트보다 먼저 정의)
|
* 조인 데이터 조회 API (다른 라우트보다 먼저 정의)
|
||||||
* GET /api/data/join?leftTable=...&rightTable=...&leftColumn=...&rightColumn=...&leftValue=...
|
* GET /api/data/join?leftTable=...&rightTable=...&leftColumn=...&rightColumn=...&leftValue=...
|
||||||
|
|
@ -698,6 +950,7 @@ router.post(
|
||||||
try {
|
try {
|
||||||
const { tableName } = req.params;
|
const { tableName } = req.params;
|
||||||
const filterConditions = req.body;
|
const filterConditions = req.body;
|
||||||
|
const userCompany = req.user?.companyCode;
|
||||||
|
|
||||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
|
|
@ -706,11 +959,12 @@ router.post(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions });
|
console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions, userCompany });
|
||||||
|
|
||||||
const result = await dataService.deleteGroupRecords(
|
const result = await dataService.deleteGroupRecords(
|
||||||
tableName,
|
tableName,
|
||||||
filterConditions
|
filterConditions,
|
||||||
|
userCompany // 회사 코드 전달
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,73 @@ router.delete("/:flowId", async (req: Request, res: Response) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플로우 소스 테이블 조회
|
||||||
|
* GET /api/dataflow/node-flows/:flowId/source-table
|
||||||
|
* 플로우의 첫 번째 소스 노드(tableSource, externalDBSource)에서 테이블명 추출
|
||||||
|
*/
|
||||||
|
router.get("/:flowId/source-table", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { flowId } = req.params;
|
||||||
|
|
||||||
|
const flow = await queryOne<{ flow_data: any }>(
|
||||||
|
`SELECT flow_data FROM node_flows WHERE flow_id = $1`,
|
||||||
|
[flowId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!flow) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "플로우를 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const flowData =
|
||||||
|
typeof flow.flow_data === "string"
|
||||||
|
? JSON.parse(flow.flow_data)
|
||||||
|
: flow.flow_data;
|
||||||
|
|
||||||
|
const nodes = flowData.nodes || [];
|
||||||
|
|
||||||
|
// 소스 노드 찾기 (tableSource, externalDBSource 타입)
|
||||||
|
const sourceNode = nodes.find(
|
||||||
|
(node: any) =>
|
||||||
|
node.type === "tableSource" || node.type === "externalDBSource"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sourceNode || !sourceNode.data?.tableName) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
sourceTable: null,
|
||||||
|
sourceNodeType: null,
|
||||||
|
message: "소스 노드가 없거나 테이블명이 설정되지 않았습니다.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`플로우 소스 테이블 조회: flowId=${flowId}, table=${sourceNode.data.tableName}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
sourceTable: sourceNode.data.tableName,
|
||||||
|
sourceNodeType: sourceNode.type,
|
||||||
|
sourceNodeId: sourceNode.id,
|
||||||
|
displayName: sourceNode.data.displayName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("플로우 소스 테이블 조회 실패:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "플로우 소스 테이블을 조회하지 못했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 플로우 실행
|
* 플로우 실행
|
||||||
* POST /api/dataflow/node-flows/:flowId/execute
|
* POST /api/dataflow/node-flows/:flowId/execute
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
import {
|
||||||
|
findMappingByColumns,
|
||||||
|
saveMappingTemplate,
|
||||||
|
getMappingTemplates,
|
||||||
|
deleteMappingTemplate,
|
||||||
|
} from "../controllers/excelMappingController";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// 엑셀 컬럼 구조로 매핑 템플릿 조회
|
||||||
|
router.post("/find", authenticateToken, findMappingByColumns);
|
||||||
|
|
||||||
|
// 매핑 템플릿 저장 (UPSERT)
|
||||||
|
router.post("/save", authenticateToken, saveMappingTemplate);
|
||||||
|
|
||||||
|
// 테이블의 매핑 템플릿 목록 조회
|
||||||
|
router.get("/list/:tableName", authenticateToken, getMappingTemplates);
|
||||||
|
|
||||||
|
// 매핑 템플릿 삭제
|
||||||
|
router.delete("/:id", authenticateToken, deleteMappingTemplate);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
|
@ -21,6 +21,20 @@ import {
|
||||||
getUserText,
|
getUserText,
|
||||||
getLangText,
|
getLangText,
|
||||||
getBatchTranslations,
|
getBatchTranslations,
|
||||||
|
|
||||||
|
// 카테고리 관리 API
|
||||||
|
getCategories,
|
||||||
|
getCategoryById,
|
||||||
|
getCategoryPath,
|
||||||
|
|
||||||
|
// 자동 생성 및 오버라이드 API
|
||||||
|
generateKey,
|
||||||
|
previewKey,
|
||||||
|
createOverrideKey,
|
||||||
|
getOverrideKeys,
|
||||||
|
|
||||||
|
// 화면 라벨 다국어 API
|
||||||
|
generateScreenLabelKeys,
|
||||||
} from "../controllers/multilangController";
|
} from "../controllers/multilangController";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
@ -51,4 +65,18 @@ router.post("/keys/:keyId/texts", saveLangTexts); // 다국어 텍스트 저장/
|
||||||
router.get("/user-text/:companyCode/:menuCode/:langKey", getUserText); // 사용자별 다국어 텍스트 조회
|
router.get("/user-text/:companyCode/:menuCode/:langKey", getUserText); // 사용자별 다국어 텍스트 조회
|
||||||
router.get("/text/:companyCode/:langKey/:langCode", getLangText); // 특정 키의 다국어 텍스트 조회
|
router.get("/text/:companyCode/:langKey/:langCode", getLangText); // 특정 키의 다국어 텍스트 조회
|
||||||
|
|
||||||
|
// 카테고리 관리 API
|
||||||
|
router.get("/categories", getCategories); // 카테고리 트리 조회
|
||||||
|
router.get("/categories/:categoryId", getCategoryById); // 카테고리 상세 조회
|
||||||
|
router.get("/categories/:categoryId/path", getCategoryPath); // 카테고리 경로 조회
|
||||||
|
|
||||||
|
// 자동 생성 및 오버라이드 API
|
||||||
|
router.post("/keys/generate", generateKey); // 키 자동 생성
|
||||||
|
router.post("/keys/preview", previewKey); // 키 미리보기
|
||||||
|
router.post("/keys/override", createOverrideKey); // 오버라이드 키 생성
|
||||||
|
router.get("/keys/overrides/:companyCode", getOverrideKeys); // 오버라이드 키 목록 조회
|
||||||
|
|
||||||
|
// 화면 라벨 다국어 자동 생성 API
|
||||||
|
router.post("/screen-labels", generateScreenLabelKeys); // 화면 라벨 다국어 키 자동 생성
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,11 @@ router.post("/upload-image", upload.single("image"), (req, res, next) =>
|
||||||
reportController.uploadImage(req, res, next)
|
reportController.uploadImage(req, res, next)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// WORD(DOCX) 내보내기
|
||||||
|
router.post("/export-word", (req, res, next) =>
|
||||||
|
reportController.exportToWord(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
// 리포트 목록
|
// 리포트 목록
|
||||||
router.get("/", (req, res, next) =>
|
router.get("/", (req, res, next) =>
|
||||||
reportController.getReports(req, res, next)
|
reportController.getReports(req, res, next)
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,15 @@ const router = Router();
|
||||||
// 모든 role 라우트에 인증 미들웨어 적용
|
// 모든 role 라우트에 인증 미들웨어 적용
|
||||||
router.use(authenticateToken);
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 권한 그룹 조회 (/:id 보다 먼저 정의해야 함)
|
||||||
|
*/
|
||||||
|
// 현재 사용자가 속한 권한 그룹 조회
|
||||||
|
router.get("/user/my-groups", getUserRoleGroups);
|
||||||
|
|
||||||
|
// 특정 사용자가 속한 권한 그룹 조회
|
||||||
|
router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 권한 그룹 CRUD
|
* 권한 그룹 CRUD
|
||||||
*/
|
*/
|
||||||
|
|
@ -67,13 +76,4 @@ router.get("/:id/menu-permissions", requireAdmin, getMenuPermissions);
|
||||||
// 메뉴 권한 설정
|
// 메뉴 권한 설정
|
||||||
router.put("/:id/menu-permissions", requireAdmin, setMenuPermissions);
|
router.put("/:id/menu-permissions", requireAdmin, setMenuPermissions);
|
||||||
|
|
||||||
/**
|
|
||||||
* 사용자 권한 그룹 조회
|
|
||||||
*/
|
|
||||||
// 현재 사용자가 속한 권한 그룹 조회
|
|
||||||
router.get("/user/my-groups", getUserRoleGroups);
|
|
||||||
|
|
||||||
// 특정 사용자가 속한 권한 그룹 조회
|
|
||||||
router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups);
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
import {
|
||||||
|
// 화면 그룹
|
||||||
|
getScreenGroups,
|
||||||
|
getScreenGroup,
|
||||||
|
createScreenGroup,
|
||||||
|
updateScreenGroup,
|
||||||
|
deleteScreenGroup,
|
||||||
|
// 화면-그룹 연결
|
||||||
|
addScreenToGroup,
|
||||||
|
removeScreenFromGroup,
|
||||||
|
updateScreenInGroup,
|
||||||
|
// 필드 조인
|
||||||
|
getFieldJoins,
|
||||||
|
createFieldJoin,
|
||||||
|
updateFieldJoin,
|
||||||
|
deleteFieldJoin,
|
||||||
|
// 데이터 흐름
|
||||||
|
getDataFlows,
|
||||||
|
createDataFlow,
|
||||||
|
updateDataFlow,
|
||||||
|
deleteDataFlow,
|
||||||
|
// 화면-테이블 관계
|
||||||
|
getTableRelations,
|
||||||
|
createTableRelation,
|
||||||
|
updateTableRelation,
|
||||||
|
deleteTableRelation,
|
||||||
|
// 화면 레이아웃 요약
|
||||||
|
getScreenLayoutSummary,
|
||||||
|
getMultipleScreenLayoutSummary,
|
||||||
|
// 화면 서브 테이블 관계
|
||||||
|
getScreenSubTables,
|
||||||
|
// 메뉴-화면그룹 동기화
|
||||||
|
syncScreenGroupsToMenuController,
|
||||||
|
syncMenuToScreenGroupsController,
|
||||||
|
getSyncStatusController,
|
||||||
|
syncAllCompaniesController,
|
||||||
|
} from "../controllers/screenGroupController";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// 모든 라우트에 인증 미들웨어 적용
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 화면 그룹 (screen_groups)
|
||||||
|
// ============================================================
|
||||||
|
router.get("/groups", getScreenGroups);
|
||||||
|
router.get("/groups/:id", getScreenGroup);
|
||||||
|
router.post("/groups", createScreenGroup);
|
||||||
|
router.put("/groups/:id", updateScreenGroup);
|
||||||
|
router.delete("/groups/:id", deleteScreenGroup);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 화면-그룹 연결 (screen_group_screens)
|
||||||
|
// ============================================================
|
||||||
|
router.post("/group-screens", addScreenToGroup);
|
||||||
|
router.put("/group-screens/:id", updateScreenInGroup);
|
||||||
|
router.delete("/group-screens/:id", removeScreenFromGroup);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 필드 조인 설정 (screen_field_joins)
|
||||||
|
// ============================================================
|
||||||
|
router.get("/field-joins", getFieldJoins);
|
||||||
|
router.post("/field-joins", createFieldJoin);
|
||||||
|
router.put("/field-joins/:id", updateFieldJoin);
|
||||||
|
router.delete("/field-joins/:id", deleteFieldJoin);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 데이터 흐름 (screen_data_flows)
|
||||||
|
// ============================================================
|
||||||
|
router.get("/data-flows", getDataFlows);
|
||||||
|
router.post("/data-flows", createDataFlow);
|
||||||
|
router.put("/data-flows/:id", updateDataFlow);
|
||||||
|
router.delete("/data-flows/:id", deleteDataFlow);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 화면-테이블 관계 (screen_table_relations)
|
||||||
|
// ============================================================
|
||||||
|
router.get("/table-relations", getTableRelations);
|
||||||
|
router.post("/table-relations", createTableRelation);
|
||||||
|
router.put("/table-relations/:id", updateTableRelation);
|
||||||
|
router.delete("/table-relations/:id", deleteTableRelation);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 화면 레이아웃 요약 (미리보기용)
|
||||||
|
// ============================================================
|
||||||
|
router.get("/layout-summary/:screenId", getScreenLayoutSummary);
|
||||||
|
router.post("/layout-summary/batch", getMultipleScreenLayoutSummary);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 화면 서브 테이블 관계 (조인/참조 테이블)
|
||||||
|
// ============================================================
|
||||||
|
router.post("/sub-tables/batch", getScreenSubTables);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 메뉴-화면그룹 동기화
|
||||||
|
// ============================================================
|
||||||
|
// 동기화 상태 조회
|
||||||
|
router.get("/sync/status", getSyncStatusController);
|
||||||
|
// 화면관리 → 메뉴 동기화
|
||||||
|
router.post("/sync/screen-to-menu", syncScreenGroupsToMenuController);
|
||||||
|
// 메뉴 → 화면관리 동기화
|
||||||
|
router.post("/sync/menu-to-screen", syncMenuToScreenGroupsController);
|
||||||
|
// 전체 회사 동기화 (최고 관리자만)
|
||||||
|
router.post("/sync/all", syncAllCompaniesController);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import {
|
import {
|
||||||
getCategoryColumns,
|
getCategoryColumns,
|
||||||
|
getAllCategoryColumns,
|
||||||
getCategoryValues,
|
getCategoryValues,
|
||||||
addCategoryValue,
|
addCategoryValue,
|
||||||
updateCategoryValue,
|
updateCategoryValue,
|
||||||
|
|
@ -22,6 +23,10 @@ const router = Router();
|
||||||
// 모든 라우트에 인증 미들웨어 적용
|
// 모든 라우트에 인증 미들웨어 적용
|
||||||
router.use(authenticateToken);
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// 모든 테이블의 카테고리 컬럼 목록 조회 (Select 옵션 설정용)
|
||||||
|
// 주의: 더 구체적인 라우트보다 먼저 와야 함
|
||||||
|
router.get("/all-columns", getAllCategoryColumns);
|
||||||
|
|
||||||
// 테이블의 카테고리 컬럼 목록 조회
|
// 테이블의 카테고리 컬럼 목록 조회
|
||||||
router.get("/:tableName/columns", getCategoryColumns);
|
router.get("/:tableName/columns", getCategoryColumns);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
toggleLogTable,
|
toggleLogTable,
|
||||||
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
|
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
|
||||||
multiTableSave, // 🆕 범용 다중 테이블 저장
|
multiTableSave, // 🆕 범용 다중 테이블 저장
|
||||||
|
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
|
||||||
} from "../controllers/tableManagementController";
|
} from "../controllers/tableManagementController";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
@ -38,6 +39,15 @@ router.use(authenticateToken);
|
||||||
*/
|
*/
|
||||||
router.get("/tables", getTableList);
|
router.get("/tables", getTableList);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 두 테이블 간 엔티티 관계 조회
|
||||||
|
* GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy
|
||||||
|
*
|
||||||
|
* column_labels에서 정의된 엔티티/카테고리 타입 설정을 기반으로
|
||||||
|
* 두 테이블 간의 외래키 관계를 자동으로 감지합니다.
|
||||||
|
*/
|
||||||
|
router.get("/tables/entity-relations", getTableEntityRelations);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 테이블 컬럼 정보 조회
|
* 테이블 컬럼 정보 조회
|
||||||
* GET /api/table-management/tables/:tableName/columns
|
* GET /api/table-management/tables/:tableName/columns
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,13 @@ export class AdminService {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시
|
||||||
|
// TODO: 권한 체크 다시 활성화 필요
|
||||||
|
logger.info(
|
||||||
|
`⚠️ [임시 비활성화] 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시`
|
||||||
|
);
|
||||||
|
|
||||||
|
/* [원본 코드 - 권한 그룹 체크]
|
||||||
if (userType === "COMPANY_ADMIN") {
|
if (userType === "COMPANY_ADMIN") {
|
||||||
// 회사 관리자: 권한 그룹 기반 필터링 적용
|
// 회사 관리자: 권한 그룹 기반 필터링 적용
|
||||||
if (userRoleGroups.length > 0) {
|
if (userRoleGroups.length > 0) {
|
||||||
|
|
@ -141,6 +148,7 @@ export class AdminService {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
} else if (
|
} else if (
|
||||||
menuType !== undefined &&
|
menuType !== undefined &&
|
||||||
userType === "SUPER_ADMIN" &&
|
userType === "SUPER_ADMIN" &&
|
||||||
|
|
@ -412,9 +420,18 @@ export class AdminService {
|
||||||
let queryParams: any[] = [userLang];
|
let queryParams: any[] = [userLang];
|
||||||
let paramIndex = 2;
|
let paramIndex = 2;
|
||||||
|
|
||||||
if (userType === "SUPER_ADMIN" && userCompanyCode === "*") {
|
// [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시
|
||||||
// SUPER_ADMIN: 권한 그룹 체크 없이 공통 메뉴만 표시
|
// TODO: 권한 체크 다시 활성화 필요
|
||||||
logger.info("✅ 좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시");
|
logger.info(
|
||||||
|
`⚠️ [임시 비활성화] getUserMenuList 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시`
|
||||||
|
);
|
||||||
|
authFilter = "";
|
||||||
|
unionFilter = "";
|
||||||
|
|
||||||
|
/* [원본 코드 - getUserMenuList 권한 그룹 체크]
|
||||||
|
if (userType === "SUPER_ADMIN") {
|
||||||
|
// SUPER_ADMIN: 권한 그룹 체크 없이 해당 회사의 모든 메뉴 표시
|
||||||
|
logger.info(`✅ 좌측 사이드바 (SUPER_ADMIN): 회사 ${userCompanyCode}의 모든 메뉴 표시`);
|
||||||
authFilter = "";
|
authFilter = "";
|
||||||
unionFilter = "";
|
unionFilter = "";
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -471,6 +488,7 @@ export class AdminService {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
// 2. 회사별 필터링 조건 생성
|
// 2. 회사별 필터링 조건 생성
|
||||||
let companyFilter = "";
|
let companyFilter = "";
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ export interface CodeInfo {
|
||||||
is_active: string;
|
is_active: string;
|
||||||
company_code: string;
|
company_code: string;
|
||||||
menu_objid?: number | null; // 메뉴 기반 코드 관리용
|
menu_objid?: number | null; // 메뉴 기반 코드 관리용
|
||||||
|
parent_code_value?: string | null; // 계층구조: 부모 코드값
|
||||||
|
depth?: number; // 계층구조: 깊이 (1, 2, 3단계)
|
||||||
created_date?: Date | null;
|
created_date?: Date | null;
|
||||||
created_by?: string | null;
|
created_by?: string | null;
|
||||||
updated_date?: Date | null;
|
updated_date?: Date | null;
|
||||||
|
|
@ -61,6 +63,8 @@ export interface CreateCodeData {
|
||||||
description?: string;
|
description?: string;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
isActive?: string;
|
isActive?: string;
|
||||||
|
parentCodeValue?: string; // 계층구조: 부모 코드값
|
||||||
|
depth?: number; // 계층구조: 깊이 (1, 2, 3단계)
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CommonCodeService {
|
export class CommonCodeService {
|
||||||
|
|
@ -86,11 +90,12 @@ export class CommonCodeService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 회사별 필터링 (최고 관리자가 아닌 경우)
|
// 회사별 필터링 (최고 관리자가 아닌 경우)
|
||||||
|
// company_code = '*'인 공통 데이터도 함께 조회
|
||||||
if (userCompanyCode && userCompanyCode !== "*") {
|
if (userCompanyCode && userCompanyCode !== "*") {
|
||||||
whereConditions.push(`company_code = $${paramIndex}`);
|
whereConditions.push(`(company_code = $${paramIndex} OR company_code = '*')`);
|
||||||
values.push(userCompanyCode);
|
values.push(userCompanyCode);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
logger.info(`회사별 코드 카테고리 필터링: ${userCompanyCode}`);
|
logger.info(`회사별 코드 카테고리 필터링: ${userCompanyCode} (공통 데이터 포함)`);
|
||||||
} else if (userCompanyCode === "*") {
|
} else if (userCompanyCode === "*") {
|
||||||
// 최고 관리자는 모든 데이터 조회 가능
|
// 최고 관리자는 모든 데이터 조회 가능
|
||||||
logger.info(`최고 관리자: 모든 코드 카테고리 조회`);
|
logger.info(`최고 관리자: 모든 코드 카테고리 조회`);
|
||||||
|
|
@ -116,7 +121,7 @@ export class CommonCodeService {
|
||||||
|
|
||||||
const offset = (page - 1) * size;
|
const offset = (page - 1) * size;
|
||||||
|
|
||||||
// 카테고리 조회
|
// code_category 테이블에서만 조회 (comm_code 제거)
|
||||||
const categories = await query<CodeCategory>(
|
const categories = await query<CodeCategory>(
|
||||||
`SELECT * FROM code_category
|
`SELECT * FROM code_category
|
||||||
${whereClause}
|
${whereClause}
|
||||||
|
|
@ -134,7 +139,7 @@ export class CommonCodeService {
|
||||||
const total = parseInt(countResult?.count || "0");
|
const total = parseInt(countResult?.count || "0");
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`카테고리 조회 완료: ${categories.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"})`
|
`카테고리 조회 완료: code_category ${categories.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"})`
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -224,7 +229,7 @@ export class CommonCodeService {
|
||||||
paramIndex,
|
paramIndex,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 코드 조회
|
// code_info 테이블에서만 코드 조회 (comm_code fallback 제거)
|
||||||
const codes = await query<CodeInfo>(
|
const codes = await query<CodeInfo>(
|
||||||
`SELECT * FROM code_info
|
`SELECT * FROM code_info
|
||||||
${whereClause}
|
${whereClause}
|
||||||
|
|
@ -242,20 +247,9 @@ export class CommonCodeService {
|
||||||
const total = parseInt(countResult?.count || "0");
|
const total = parseInt(countResult?.count || "0");
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`✅ [getCodes] 코드 조회 완료: ${categoryCode} - ${codes.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"}, menuObjid: ${menuObjid || "없음"})`
|
`코드 조회 완료: ${categoryCode} - ${codes.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"}, menuObjid: ${menuObjid || "없음"})`
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info(`📊 [getCodes] 조회된 코드 상세:`, {
|
|
||||||
categoryCode,
|
|
||||||
menuObjid,
|
|
||||||
codes: codes.map((c) => ({
|
|
||||||
code_value: c.code_value,
|
|
||||||
code_name: c.code_name,
|
|
||||||
menu_objid: c.menu_objid,
|
|
||||||
company_code: c.company_code,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
return { data: codes, total };
|
return { data: codes, total };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`코드 조회 중 오류 (${categoryCode}):`, error);
|
logger.error(`코드 조회 중 오류 (${categoryCode}):`, error);
|
||||||
|
|
@ -415,11 +409,22 @@ export class CommonCodeService {
|
||||||
menuObjid: number
|
menuObjid: number
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
// 계층구조: depth 계산 (부모가 있으면 부모의 depth + 1, 없으면 1)
|
||||||
|
let depth = 1;
|
||||||
|
if (data.parentCodeValue) {
|
||||||
|
const parentCode = await queryOne<CodeInfo>(
|
||||||
|
`SELECT depth FROM code_info
|
||||||
|
WHERE code_category = $1 AND code_value = $2 AND company_code = $3`,
|
||||||
|
[categoryCode, data.parentCodeValue, companyCode]
|
||||||
|
);
|
||||||
|
depth = parentCode ? (parentCode.depth || 1) + 1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
const code = await queryOne<CodeInfo>(
|
const code = await queryOne<CodeInfo>(
|
||||||
`INSERT INTO code_info
|
`INSERT INTO code_info
|
||||||
(code_category, code_value, code_name, code_name_eng, description, sort_order,
|
(code_category, code_value, code_name, code_name_eng, description, sort_order,
|
||||||
is_active, menu_objid, company_code, created_by, updated_by, created_date, updated_date)
|
is_active, menu_objid, company_code, parent_code_value, depth, created_by, updated_by, created_date, updated_date)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7, $8, $9, $10, NOW(), NOW())
|
VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7, $8, $9, $10, $11, $12, NOW(), NOW())
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
categoryCode,
|
categoryCode,
|
||||||
|
|
@ -430,13 +435,15 @@ export class CommonCodeService {
|
||||||
data.sortOrder || 0,
|
data.sortOrder || 0,
|
||||||
menuObjid,
|
menuObjid,
|
||||||
companyCode,
|
companyCode,
|
||||||
|
data.parentCodeValue || null,
|
||||||
|
depth,
|
||||||
createdBy,
|
createdBy,
|
||||||
createdBy,
|
createdBy,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`코드 생성 완료: ${categoryCode}.${data.codeValue} (메뉴: ${menuObjid}, 회사: ${companyCode})`
|
`코드 생성 완료: ${categoryCode}.${data.codeValue} (메뉴: ${menuObjid}, 회사: ${companyCode}, 부모: ${data.parentCodeValue || '없음'}, 깊이: ${depth})`
|
||||||
);
|
);
|
||||||
return code;
|
return code;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -501,6 +508,24 @@ export class CommonCodeService {
|
||||||
updateFields.push(`is_active = $${paramIndex++}`);
|
updateFields.push(`is_active = $${paramIndex++}`);
|
||||||
values.push(activeValue);
|
values.push(activeValue);
|
||||||
}
|
}
|
||||||
|
// 계층구조: 부모 코드값 수정
|
||||||
|
if (data.parentCodeValue !== undefined) {
|
||||||
|
updateFields.push(`parent_code_value = $${paramIndex++}`);
|
||||||
|
values.push(data.parentCodeValue || null);
|
||||||
|
|
||||||
|
// depth도 함께 업데이트
|
||||||
|
let newDepth = 1;
|
||||||
|
if (data.parentCodeValue) {
|
||||||
|
const parentCode = await queryOne<CodeInfo>(
|
||||||
|
`SELECT depth FROM code_info
|
||||||
|
WHERE code_category = $1 AND code_value = $2`,
|
||||||
|
[categoryCode, data.parentCodeValue]
|
||||||
|
);
|
||||||
|
newDepth = parentCode ? (parentCode.depth || 1) + 1 : 1;
|
||||||
|
}
|
||||||
|
updateFields.push(`depth = $${paramIndex++}`);
|
||||||
|
values.push(newDepth);
|
||||||
|
}
|
||||||
|
|
||||||
// WHERE 절 구성
|
// WHERE 절 구성
|
||||||
let whereClause = `WHERE code_category = $${paramIndex++} AND code_value = $${paramIndex}`;
|
let whereClause = `WHERE code_category = $${paramIndex++} AND code_value = $${paramIndex}`;
|
||||||
|
|
@ -857,4 +882,170 @@ export class CommonCodeService {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층구조 코드 조회 (특정 depth 또는 부모코드 기준)
|
||||||
|
* @param categoryCode 카테고리 코드
|
||||||
|
* @param parentCodeValue 부모 코드값 (없으면 최상위 코드만 조회)
|
||||||
|
* @param depth 특정 깊이만 조회 (선택)
|
||||||
|
*/
|
||||||
|
async getHierarchicalCodes(
|
||||||
|
categoryCode: string,
|
||||||
|
parentCodeValue?: string | null,
|
||||||
|
depth?: number,
|
||||||
|
userCompanyCode?: string,
|
||||||
|
menuObjid?: number
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const whereConditions: string[] = ["code_category = $1", "is_active = 'Y'"];
|
||||||
|
const values: any[] = [categoryCode];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
// 부모 코드값 필터링
|
||||||
|
if (parentCodeValue === null || parentCodeValue === undefined) {
|
||||||
|
// 최상위 코드 (부모가 없는 코드)
|
||||||
|
whereConditions.push("(parent_code_value IS NULL OR parent_code_value = '')");
|
||||||
|
} else if (parentCodeValue !== '') {
|
||||||
|
whereConditions.push(`parent_code_value = $${paramIndex}`);
|
||||||
|
values.push(parentCodeValue);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 특정 깊이 필터링
|
||||||
|
if (depth !== undefined) {
|
||||||
|
whereConditions.push(`depth = $${paramIndex}`);
|
||||||
|
values.push(depth);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메뉴별 필터링 (형제 메뉴 포함)
|
||||||
|
if (menuObjid) {
|
||||||
|
const { getSiblingMenuObjids } = await import('./menuService');
|
||||||
|
const siblingMenuObjids = await getSiblingMenuObjids(menuObjid);
|
||||||
|
whereConditions.push(`menu_objid = ANY($${paramIndex})`);
|
||||||
|
values.push(siblingMenuObjids);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 회사별 필터링
|
||||||
|
if (userCompanyCode && userCompanyCode !== "*") {
|
||||||
|
whereConditions.push(`company_code = $${paramIndex}`);
|
||||||
|
values.push(userCompanyCode);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = `WHERE ${whereConditions.join(" AND ")}`;
|
||||||
|
|
||||||
|
const codes = await query<CodeInfo>(
|
||||||
|
`SELECT * FROM code_info
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY sort_order ASC, code_value ASC`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`계층구조 코드 조회: ${categoryCode}, 부모: ${parentCodeValue || '최상위'}, 깊이: ${depth || '전체'} - ${codes.length}개`
|
||||||
|
);
|
||||||
|
|
||||||
|
return codes;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`계층구조 코드 조회 중 오류 (${categoryCode}):`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층구조 코드 트리 전체 조회 (카테고리 기준)
|
||||||
|
*/
|
||||||
|
async getCodeTree(
|
||||||
|
categoryCode: string,
|
||||||
|
userCompanyCode?: string,
|
||||||
|
menuObjid?: number
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const whereConditions: string[] = ["code_category = $1", "is_active = 'Y'"];
|
||||||
|
const values: any[] = [categoryCode];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
// 메뉴별 필터링 (형제 메뉴 포함)
|
||||||
|
if (menuObjid) {
|
||||||
|
const { getSiblingMenuObjids } = await import('./menuService');
|
||||||
|
const siblingMenuObjids = await getSiblingMenuObjids(menuObjid);
|
||||||
|
whereConditions.push(`menu_objid = ANY($${paramIndex})`);
|
||||||
|
values.push(siblingMenuObjids);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 회사별 필터링
|
||||||
|
if (userCompanyCode && userCompanyCode !== "*") {
|
||||||
|
whereConditions.push(`company_code = $${paramIndex}`);
|
||||||
|
values.push(userCompanyCode);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = `WHERE ${whereConditions.join(" AND ")}`;
|
||||||
|
|
||||||
|
const allCodes = await query<CodeInfo>(
|
||||||
|
`SELECT * FROM code_info
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY depth ASC, sort_order ASC, code_value ASC`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
// 트리 구조로 변환
|
||||||
|
const buildTree = (codes: CodeInfo[], parentValue: string | null = null): any[] => {
|
||||||
|
return codes
|
||||||
|
.filter(code => {
|
||||||
|
const codeParent = code.parent_code_value || null;
|
||||||
|
return codeParent === parentValue;
|
||||||
|
})
|
||||||
|
.map(code => ({
|
||||||
|
...code,
|
||||||
|
children: buildTree(codes, code.code_value)
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const tree = buildTree(allCodes);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`코드 트리 조회 완료: ${categoryCode} - 전체 ${allCodes.length}개`
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
flat: allCodes,
|
||||||
|
tree
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`코드 트리 조회 중 오류 (${categoryCode}):`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자식 코드가 있는지 확인
|
||||||
|
*/
|
||||||
|
async hasChildren(
|
||||||
|
categoryCode: string,
|
||||||
|
codeValue: string,
|
||||||
|
companyCode?: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
let sql = `SELECT COUNT(*) as count FROM code_info
|
||||||
|
WHERE code_category = $1 AND parent_code_value = $2`;
|
||||||
|
const values: any[] = [categoryCode, codeValue];
|
||||||
|
|
||||||
|
if (companyCode && companyCode !== "*") {
|
||||||
|
sql += ` AND company_code = $3`;
|
||||||
|
values.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await queryOne<{ count: string }>(sql, values);
|
||||||
|
const count = parseInt(result?.count || "0");
|
||||||
|
|
||||||
|
return count > 0;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`자식 코드 확인 중 오류 (${categoryCode}.${codeValue}):`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -254,7 +254,10 @@ class DataService {
|
||||||
key !== "limit" &&
|
key !== "limit" &&
|
||||||
key !== "offset" &&
|
key !== "offset" &&
|
||||||
key !== "orderBy" &&
|
key !== "orderBy" &&
|
||||||
key !== "userLang"
|
key !== "userLang" &&
|
||||||
|
key !== "page" &&
|
||||||
|
key !== "pageSize" &&
|
||||||
|
key !== "size"
|
||||||
) {
|
) {
|
||||||
// 컬럼명 검증 (SQL 인젝션 방지)
|
// 컬럼명 검증 (SQL 인젝션 방지)
|
||||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
|
||||||
|
|
@ -1189,6 +1192,13 @@ class DataService {
|
||||||
[tableName]
|
[tableName]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log(`🔍 테이블 ${tableName}의 Primary Key 조회 결과:`, {
|
||||||
|
pkColumns: pkResult.map((r) => r.attname),
|
||||||
|
pkCount: pkResult.length,
|
||||||
|
inputId: typeof id === "object" ? JSON.stringify(id).substring(0, 200) + "..." : id,
|
||||||
|
inputIdType: typeof id,
|
||||||
|
});
|
||||||
|
|
||||||
let whereClauses: string[] = [];
|
let whereClauses: string[] = [];
|
||||||
let params: any[] = [];
|
let params: any[] = [];
|
||||||
|
|
||||||
|
|
@ -1216,17 +1226,31 @@ class DataService {
|
||||||
params.push(typeof id === "object" ? id[pkColumn] : id);
|
params.push(typeof id === "object" ? id[pkColumn] : id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(" AND ")}`;
|
const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(" AND ")} RETURNING *`;
|
||||||
console.log(`🗑️ 삭제 쿼리:`, queryText, params);
|
console.log(`🗑️ 삭제 쿼리:`, queryText, params);
|
||||||
|
|
||||||
const result = await query<any>(queryText, params);
|
const result = await query<any>(queryText, params);
|
||||||
|
|
||||||
|
// 삭제된 행이 없으면 실패 처리
|
||||||
|
if (result.length === 0) {
|
||||||
|
console.warn(
|
||||||
|
`⚠️ 레코드 삭제 실패: ${tableName}, 해당 조건에 맞는 레코드가 없습니다.`,
|
||||||
|
{ whereClauses, params }
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "삭제할 레코드를 찾을 수 없습니다. 이미 삭제되었거나 권한이 없습니다.",
|
||||||
|
error: "RECORD_NOT_FOUND",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}`
|
`✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
data: result[0], // 삭제된 레코드 정보 반환
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`레코드 삭제 오류 (${tableName}):`, error);
|
console.error(`레코드 삭제 오류 (${tableName}):`, error);
|
||||||
|
|
@ -1240,10 +1264,14 @@ class DataService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 조건에 맞는 모든 레코드 삭제 (그룹 삭제)
|
* 조건에 맞는 모든 레코드 삭제 (그룹 삭제)
|
||||||
|
* @param tableName 테이블명
|
||||||
|
* @param filterConditions 삭제 조건
|
||||||
|
* @param userCompany 사용자 회사 코드 (멀티테넌시 필터링)
|
||||||
*/
|
*/
|
||||||
async deleteGroupRecords(
|
async deleteGroupRecords(
|
||||||
tableName: string,
|
tableName: string,
|
||||||
filterConditions: Record<string, any>
|
filterConditions: Record<string, any>,
|
||||||
|
userCompany?: string
|
||||||
): Promise<ServiceResponse<{ deleted: number }>> {
|
): Promise<ServiceResponse<{ deleted: number }>> {
|
||||||
try {
|
try {
|
||||||
const validation = await this.validateTableAccess(tableName);
|
const validation = await this.validateTableAccess(tableName);
|
||||||
|
|
@ -1255,6 +1283,7 @@ class DataService {
|
||||||
const whereValues: any[] = [];
|
const whereValues: any[] = [];
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// 사용자 필터 조건 추가
|
||||||
for (const [key, value] of Object.entries(filterConditions)) {
|
for (const [key, value] of Object.entries(filterConditions)) {
|
||||||
whereConditions.push(`"${key}" = $${paramIndex}`);
|
whereConditions.push(`"${key}" = $${paramIndex}`);
|
||||||
whereValues.push(value);
|
whereValues.push(value);
|
||||||
|
|
@ -1269,10 +1298,24 @@ class DataService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔒 멀티테넌시: company_code 필터링 (최고 관리자 제외)
|
||||||
|
const hasCompanyCode = await this.checkColumnExists(tableName, "company_code");
|
||||||
|
if (hasCompanyCode && userCompany && userCompany !== "*") {
|
||||||
|
whereConditions.push(`"company_code" = $${paramIndex}`);
|
||||||
|
whereValues.push(userCompany);
|
||||||
|
paramIndex++;
|
||||||
|
console.log(`🔒 멀티테넌시 필터 적용: company_code = ${userCompany}`);
|
||||||
|
}
|
||||||
|
|
||||||
const whereClause = whereConditions.join(" AND ");
|
const whereClause = whereConditions.join(" AND ");
|
||||||
const deleteQuery = `DELETE FROM "${tableName}" WHERE ${whereClause} RETURNING *`;
|
const deleteQuery = `DELETE FROM "${tableName}" WHERE ${whereClause} RETURNING *`;
|
||||||
|
|
||||||
console.log(`🗑️ 그룹 삭제:`, { tableName, conditions: filterConditions });
|
console.log(`🗑️ 그룹 삭제:`, {
|
||||||
|
tableName,
|
||||||
|
conditions: filterConditions,
|
||||||
|
userCompany,
|
||||||
|
whereClause,
|
||||||
|
});
|
||||||
|
|
||||||
const result = await pool.query(deleteQuery, whereValues);
|
const result = await pool.query(deleteQuery, whereValues);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { query, queryOne, transaction, getPool } from "../database/db";
|
import { query, queryOne, transaction, getPool } from "../database/db";
|
||||||
import { EventTriggerService } from "./eventTriggerService";
|
import { EventTriggerService } from "./eventTriggerService";
|
||||||
import { DataflowControlService } from "./dataflowControlService";
|
import { DataflowControlService } from "./dataflowControlService";
|
||||||
|
import tableCategoryValueService from "./tableCategoryValueService";
|
||||||
|
|
||||||
export interface FormDataResult {
|
export interface FormDataResult {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -427,6 +428,24 @@ export class DynamicFormService {
|
||||||
dataToInsert,
|
dataToInsert,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 카테고리 타입 컬럼의 라벨 값을 코드 값으로 변환 (엑셀 업로드 등 지원)
|
||||||
|
console.log("🏷️ 카테고리 라벨→코드 변환 시작...");
|
||||||
|
const companyCodeForCategory = company_code || "*";
|
||||||
|
const { convertedData: categoryConvertedData, conversions } =
|
||||||
|
await tableCategoryValueService.convertCategoryLabelsToCodesForData(
|
||||||
|
tableName,
|
||||||
|
companyCodeForCategory,
|
||||||
|
dataToInsert
|
||||||
|
);
|
||||||
|
|
||||||
|
if (conversions.length > 0) {
|
||||||
|
console.log(`🏷️ 카테고리 라벨→코드 변환 완료: ${conversions.length}개`, conversions);
|
||||||
|
// 변환된 데이터로 교체
|
||||||
|
Object.assign(dataToInsert, categoryConvertedData);
|
||||||
|
} else {
|
||||||
|
console.log("🏷️ 카테고리 라벨→코드 변환 없음 (카테고리 컬럼 없거나 이미 코드 값)");
|
||||||
|
}
|
||||||
|
|
||||||
// 테이블 컬럼 정보 조회하여 타입 변환 적용
|
// 테이블 컬럼 정보 조회하여 타입 변환 적용
|
||||||
console.log("🔍 테이블 컬럼 정보 조회 중...");
|
console.log("🔍 테이블 컬럼 정보 조회 중...");
|
||||||
const columnInfo = await this.getTableColumnInfo(tableName);
|
const columnInfo = await this.getTableColumnInfo(tableName);
|
||||||
|
|
@ -854,6 +873,11 @@ export class DynamicFormService {
|
||||||
if (tableColumns.includes("updated_at")) {
|
if (tableColumns.includes("updated_at")) {
|
||||||
changedFields.updated_at = new Date();
|
changedFields.updated_at = new Date();
|
||||||
}
|
}
|
||||||
|
// updated_date 컬럼도 지원 (sales_order_mng 등)
|
||||||
|
if (tableColumns.includes("updated_date")) {
|
||||||
|
changedFields.updated_date = new Date();
|
||||||
|
console.log("📅 updated_date 자동 추가:", changedFields.updated_date);
|
||||||
|
}
|
||||||
|
|
||||||
console.log("🎯 실제 업데이트할 필드들:", changedFields);
|
console.log("🎯 실제 업데이트할 필드들:", changedFields);
|
||||||
|
|
||||||
|
|
@ -1168,12 +1192,18 @@ export class DynamicFormService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 폼 데이터 삭제 (실제 테이블에서 직접 삭제)
|
* 폼 데이터 삭제 (실제 테이블에서 직접 삭제)
|
||||||
|
* @param id 삭제할 레코드 ID
|
||||||
|
* @param tableName 테이블명
|
||||||
|
* @param companyCode 회사 코드
|
||||||
|
* @param userId 사용자 ID
|
||||||
|
* @param screenId 화면 ID (제어관리 실행용, 선택사항)
|
||||||
*/
|
*/
|
||||||
async deleteFormData(
|
async deleteFormData(
|
||||||
id: string | number,
|
id: string | number,
|
||||||
tableName: string,
|
tableName: string,
|
||||||
companyCode?: string,
|
companyCode?: string,
|
||||||
userId?: string
|
userId?: string,
|
||||||
|
screenId?: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", {
|
console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", {
|
||||||
|
|
@ -1286,14 +1316,19 @@ export class DynamicFormService {
|
||||||
const recordCompanyCode =
|
const recordCompanyCode =
|
||||||
deletedRecord?.company_code || companyCode || "*";
|
deletedRecord?.company_code || companyCode || "*";
|
||||||
|
|
||||||
await this.executeDataflowControlIfConfigured(
|
// screenId가 전달되지 않으면 제어관리를 실행하지 않음
|
||||||
0, // DELETE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요)
|
if (screenId && screenId > 0) {
|
||||||
tableName,
|
await this.executeDataflowControlIfConfigured(
|
||||||
deletedRecord,
|
screenId,
|
||||||
"delete",
|
tableName,
|
||||||
userId || "system",
|
deletedRecord,
|
||||||
recordCompanyCode
|
"delete",
|
||||||
);
|
userId || "system",
|
||||||
|
recordCompanyCode
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log("ℹ️ screenId가 전달되지 않아 제어관리를 건너뜁니다. (screenId:", screenId, ")");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (controlError) {
|
} catch (controlError) {
|
||||||
console.error("⚠️ 제어관리 실행 오류:", controlError);
|
console.error("⚠️ 제어관리 실행 오류:", controlError);
|
||||||
|
|
@ -1638,10 +1673,16 @@ export class DynamicFormService {
|
||||||
!!properties?.webTypeConfig?.dataflowConfig?.flowControls,
|
!!properties?.webTypeConfig?.dataflowConfig?.flowControls,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우
|
// 버튼 컴포넌트이고 제어관리가 활성화된 경우
|
||||||
|
// triggerType에 맞는 액션 타입 매칭: insert/update -> save, delete -> delete
|
||||||
|
const buttonActionType = properties?.componentConfig?.action?.type;
|
||||||
|
const isMatchingAction =
|
||||||
|
(triggerType === "delete" && buttonActionType === "delete") ||
|
||||||
|
((triggerType === "insert" || triggerType === "update") && buttonActionType === "save");
|
||||||
|
|
||||||
if (
|
if (
|
||||||
properties?.componentType === "button-primary" &&
|
properties?.componentType === "button-primary" &&
|
||||||
properties?.componentConfig?.action?.type === "save" &&
|
isMatchingAction &&
|
||||||
properties?.webTypeConfig?.enableDataflowControl === true
|
properties?.webTypeConfig?.enableDataflowControl === true
|
||||||
) {
|
) {
|
||||||
const dataflowConfig = properties?.webTypeConfig?.dataflowConfig;
|
const dataflowConfig = properties?.webTypeConfig?.dataflowConfig;
|
||||||
|
|
|
||||||
|
|
@ -186,8 +186,13 @@ export class EntityJoinService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 별칭 컬럼명 생성 (writer -> writer_name)
|
// 🎯 별칭 컬럼명 생성 - 사용자가 선택한 displayColumns 기반으로 동적 생성
|
||||||
const aliasColumn = `${column.column_name}_name`;
|
// 단일 컬럼: manager + user_name → manager_user_name
|
||||||
|
// 여러 컬럼: 첫 번째 컬럼 기준 (나머지는 개별 alias로 처리됨)
|
||||||
|
const firstDisplayColumn = displayColumns[0] || "name";
|
||||||
|
const aliasColumn = `${column.column_name}_${firstDisplayColumn}`;
|
||||||
|
|
||||||
|
logger.info(`🔧 별칭 컬럼명 생성: ${column.column_name} + ${firstDisplayColumn} → ${aliasColumn}`);
|
||||||
|
|
||||||
const joinConfig: EntityJoinConfig = {
|
const joinConfig: EntityJoinConfig = {
|
||||||
sourceTable: tableName,
|
sourceTable: tableName,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,283 @@
|
||||||
|
import { getPool } from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
export interface ExcelMappingTemplate {
|
||||||
|
id?: number;
|
||||||
|
tableName: string;
|
||||||
|
excelColumns: string[];
|
||||||
|
excelColumnsHash: string;
|
||||||
|
columnMappings: Record<string, string | null>; // { "엑셀컬럼": "시스템컬럼" }
|
||||||
|
companyCode: string;
|
||||||
|
createdDate?: Date;
|
||||||
|
updatedDate?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExcelMappingService {
|
||||||
|
/**
|
||||||
|
* 엑셀 컬럼 목록으로 해시 생성
|
||||||
|
* 정렬 후 MD5 해시 생성하여 동일한 컬럼 구조 식별
|
||||||
|
*/
|
||||||
|
generateColumnsHash(columns: string[]): string {
|
||||||
|
// 컬럼 목록을 정렬하여 순서와 무관하게 동일한 해시 생성
|
||||||
|
const sortedColumns = [...columns].sort();
|
||||||
|
const columnsString = sortedColumns.join("|");
|
||||||
|
return crypto.createHash("md5").update(columnsString).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엑셀 컬럼 구조로 매핑 템플릿 조회
|
||||||
|
* 동일한 컬럼 구조가 있으면 기존 매핑 반환
|
||||||
|
*/
|
||||||
|
async findMappingByColumns(
|
||||||
|
tableName: string,
|
||||||
|
excelColumns: string[],
|
||||||
|
companyCode: string
|
||||||
|
): Promise<ExcelMappingTemplate | null> {
|
||||||
|
try {
|
||||||
|
const hash = this.generateColumnsHash(excelColumns);
|
||||||
|
|
||||||
|
logger.info("엑셀 매핑 템플릿 조회", {
|
||||||
|
tableName,
|
||||||
|
excelColumns,
|
||||||
|
hash,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
// 회사별 매핑 먼저 조회, 없으면 공통(*) 매핑 조회
|
||||||
|
let query: string;
|
||||||
|
let params: any[];
|
||||||
|
|
||||||
|
if (companyCode === "*") {
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
table_name as "tableName",
|
||||||
|
excel_columns as "excelColumns",
|
||||||
|
excel_columns_hash as "excelColumnsHash",
|
||||||
|
column_mappings as "columnMappings",
|
||||||
|
company_code as "companyCode",
|
||||||
|
created_date as "createdDate",
|
||||||
|
updated_date as "updatedDate"
|
||||||
|
FROM excel_mapping_template
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND excel_columns_hash = $2
|
||||||
|
ORDER BY updated_date DESC
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
params = [tableName, hash];
|
||||||
|
} else {
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
table_name as "tableName",
|
||||||
|
excel_columns as "excelColumns",
|
||||||
|
excel_columns_hash as "excelColumnsHash",
|
||||||
|
column_mappings as "columnMappings",
|
||||||
|
company_code as "companyCode",
|
||||||
|
created_date as "createdDate",
|
||||||
|
updated_date as "updatedDate"
|
||||||
|
FROM excel_mapping_template
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND excel_columns_hash = $2
|
||||||
|
AND (company_code = $3 OR company_code = '*')
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN company_code = $3 THEN 0 ELSE 1 END,
|
||||||
|
updated_date DESC
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
params = [tableName, hash, companyCode];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(query, params);
|
||||||
|
|
||||||
|
if (result.rows.length > 0) {
|
||||||
|
logger.info("기존 매핑 템플릿 발견", {
|
||||||
|
id: result.rows[0].id,
|
||||||
|
tableName,
|
||||||
|
});
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("매핑 템플릿 없음 - 새 구조", { tableName, hash });
|
||||||
|
return null;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`매핑 템플릿 조회 실패: ${error.message}`, { error });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매핑 템플릿 저장 (UPSERT)
|
||||||
|
* 동일한 테이블+컬럼구조+회사코드가 있으면 업데이트, 없으면 삽입
|
||||||
|
*/
|
||||||
|
async saveMappingTemplate(
|
||||||
|
tableName: string,
|
||||||
|
excelColumns: string[],
|
||||||
|
columnMappings: Record<string, string | null>,
|
||||||
|
companyCode: string,
|
||||||
|
userId?: string
|
||||||
|
): Promise<ExcelMappingTemplate> {
|
||||||
|
try {
|
||||||
|
const hash = this.generateColumnsHash(excelColumns);
|
||||||
|
|
||||||
|
logger.info("엑셀 매핑 템플릿 저장 (UPSERT)", {
|
||||||
|
tableName,
|
||||||
|
excelColumns,
|
||||||
|
hash,
|
||||||
|
columnMappings,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
INSERT INTO excel_mapping_template (
|
||||||
|
table_name,
|
||||||
|
excel_columns,
|
||||||
|
excel_columns_hash,
|
||||||
|
column_mappings,
|
||||||
|
company_code,
|
||||||
|
created_date,
|
||||||
|
updated_date
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
|
||||||
|
ON CONFLICT (table_name, excel_columns_hash, company_code)
|
||||||
|
DO UPDATE SET
|
||||||
|
column_mappings = EXCLUDED.column_mappings,
|
||||||
|
updated_date = NOW()
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
table_name as "tableName",
|
||||||
|
excel_columns as "excelColumns",
|
||||||
|
excel_columns_hash as "excelColumnsHash",
|
||||||
|
column_mappings as "columnMappings",
|
||||||
|
company_code as "companyCode",
|
||||||
|
created_date as "createdDate",
|
||||||
|
updated_date as "updatedDate"
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [
|
||||||
|
tableName,
|
||||||
|
excelColumns,
|
||||||
|
hash,
|
||||||
|
JSON.stringify(columnMappings),
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("매핑 템플릿 저장 완료", {
|
||||||
|
id: result.rows[0].id,
|
||||||
|
tableName,
|
||||||
|
hash,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.rows[0];
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`매핑 템플릿 저장 실패: ${error.message}`, { error });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블의 모든 매핑 템플릿 조회
|
||||||
|
*/
|
||||||
|
async getMappingTemplates(
|
||||||
|
tableName: string,
|
||||||
|
companyCode: string
|
||||||
|
): Promise<ExcelMappingTemplate[]> {
|
||||||
|
try {
|
||||||
|
logger.info("테이블 매핑 템플릿 목록 조회", { tableName, companyCode });
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
let query: string;
|
||||||
|
let params: any[];
|
||||||
|
|
||||||
|
if (companyCode === "*") {
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
table_name as "tableName",
|
||||||
|
excel_columns as "excelColumns",
|
||||||
|
excel_columns_hash as "excelColumnsHash",
|
||||||
|
column_mappings as "columnMappings",
|
||||||
|
company_code as "companyCode",
|
||||||
|
created_date as "createdDate",
|
||||||
|
updated_date as "updatedDate"
|
||||||
|
FROM excel_mapping_template
|
||||||
|
WHERE table_name = $1
|
||||||
|
ORDER BY updated_date DESC
|
||||||
|
`;
|
||||||
|
params = [tableName];
|
||||||
|
} else {
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
table_name as "tableName",
|
||||||
|
excel_columns as "excelColumns",
|
||||||
|
excel_columns_hash as "excelColumnsHash",
|
||||||
|
column_mappings as "columnMappings",
|
||||||
|
company_code as "companyCode",
|
||||||
|
created_date as "createdDate",
|
||||||
|
updated_date as "updatedDate"
|
||||||
|
FROM excel_mapping_template
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND (company_code = $2 OR company_code = '*')
|
||||||
|
ORDER BY updated_date DESC
|
||||||
|
`;
|
||||||
|
params = [tableName, companyCode];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(query, params);
|
||||||
|
|
||||||
|
logger.info(`매핑 템플릿 ${result.rows.length}개 조회`, { tableName });
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`매핑 템플릿 목록 조회 실패: ${error.message}`, { error });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매핑 템플릿 삭제
|
||||||
|
*/
|
||||||
|
async deleteMappingTemplate(
|
||||||
|
id: number,
|
||||||
|
companyCode: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
logger.info("매핑 템플릿 삭제", { id, companyCode });
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
let query: string;
|
||||||
|
let params: any[];
|
||||||
|
|
||||||
|
if (companyCode === "*") {
|
||||||
|
query = `DELETE FROM excel_mapping_template WHERE id = $1`;
|
||||||
|
params = [id];
|
||||||
|
} else {
|
||||||
|
query = `DELETE FROM excel_mapping_template WHERE id = $1 AND company_code = $2`;
|
||||||
|
params = [id, companyCode];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(query, params);
|
||||||
|
|
||||||
|
if (result.rowCount && result.rowCount > 0) {
|
||||||
|
logger.info("매핑 템플릿 삭제 완료", { id });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn("삭제할 매핑 템플릿 없음", { id, companyCode });
|
||||||
|
return false;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`매핑 템플릿 삭제 실패: ${error.message}`, { error });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new ExcelMappingService();
|
||||||
|
|
||||||
|
|
@ -0,0 +1,908 @@
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 엑셀 처리 서비스
|
||||||
|
*
|
||||||
|
* 분할 패널 화면의 마스터-디테일 구조를 자동 감지하고
|
||||||
|
* 엑셀 다운로드/업로드 시 JOIN 및 그룹화 처리를 수행합니다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query, queryOne, transaction, getPool } from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// 인터페이스 정의
|
||||||
|
// ================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 관계 정보
|
||||||
|
*/
|
||||||
|
export interface MasterDetailRelation {
|
||||||
|
masterTable: string;
|
||||||
|
detailTable: string;
|
||||||
|
masterKeyColumn: string; // 마스터 테이블의 키 컬럼 (예: order_no)
|
||||||
|
detailFkColumn: string; // 디테일 테이블의 FK 컬럼 (예: order_no)
|
||||||
|
masterColumns: ColumnInfo[];
|
||||||
|
detailColumns: ColumnInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼 정보
|
||||||
|
*/
|
||||||
|
export interface ColumnInfo {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
inputType: string;
|
||||||
|
isFromMaster: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 분할 패널 설정
|
||||||
|
*/
|
||||||
|
export interface SplitPanelConfig {
|
||||||
|
leftPanel: {
|
||||||
|
tableName: string;
|
||||||
|
columns: Array<{ name: string; label: string; width?: number }>;
|
||||||
|
};
|
||||||
|
rightPanel: {
|
||||||
|
tableName: string;
|
||||||
|
columns: Array<{ name: string; label: string; width?: number }>;
|
||||||
|
relation?: {
|
||||||
|
type: string;
|
||||||
|
foreignKey?: string;
|
||||||
|
leftColumn?: string;
|
||||||
|
// 복합키 지원 (새로운 방식)
|
||||||
|
keys?: Array<{
|
||||||
|
leftColumn: string;
|
||||||
|
rightColumn: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엑셀 다운로드 결과
|
||||||
|
*/
|
||||||
|
export interface ExcelDownloadData {
|
||||||
|
headers: string[]; // 컬럼 라벨들
|
||||||
|
columns: string[]; // 컬럼명들
|
||||||
|
data: Record<string, any>[];
|
||||||
|
masterColumns: string[]; // 마스터 컬럼 목록
|
||||||
|
detailColumns: string[]; // 디테일 컬럼 목록
|
||||||
|
joinKey: string; // 조인 키
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엑셀 업로드 결과
|
||||||
|
*/
|
||||||
|
export interface ExcelUploadResult {
|
||||||
|
success: boolean;
|
||||||
|
masterInserted: number;
|
||||||
|
masterUpdated: number;
|
||||||
|
detailInserted: number;
|
||||||
|
detailDeleted: number;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// 서비스 클래스
|
||||||
|
// ================================
|
||||||
|
|
||||||
|
class MasterDetailExcelService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면 ID로 분할 패널 설정 조회
|
||||||
|
*/
|
||||||
|
async getSplitPanelConfig(screenId: number): Promise<SplitPanelConfig | null> {
|
||||||
|
try {
|
||||||
|
logger.info(`분할 패널 설정 조회: screenId=${screenId}`);
|
||||||
|
|
||||||
|
// screen_layouts에서 split-panel-layout 컴포넌트 찾기
|
||||||
|
const result = await queryOne<any>(
|
||||||
|
`SELECT properties->>'componentConfig' as config
|
||||||
|
FROM screen_layouts
|
||||||
|
WHERE screen_id = $1
|
||||||
|
AND component_type = 'component'
|
||||||
|
AND properties->>'componentType' = 'split-panel-layout'
|
||||||
|
LIMIT 1`,
|
||||||
|
[screenId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result || !result.config) {
|
||||||
|
logger.info(`분할 패널 없음: screenId=${screenId}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = typeof result.config === "string"
|
||||||
|
? JSON.parse(result.config)
|
||||||
|
: result.config;
|
||||||
|
|
||||||
|
logger.info(`분할 패널 설정 발견:`, {
|
||||||
|
leftTable: config.leftPanel?.tableName,
|
||||||
|
rightTable: config.rightPanel?.tableName,
|
||||||
|
relation: config.rightPanel?.relation,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
leftPanel: config.leftPanel,
|
||||||
|
rightPanel: config.rightPanel,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`분할 패널 설정 조회 실패: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* column_labels에서 Entity 관계 정보 조회
|
||||||
|
* 디테일 테이블에서 마스터 테이블을 참조하는 컬럼 찾기
|
||||||
|
*/
|
||||||
|
async getEntityRelation(
|
||||||
|
detailTable: string,
|
||||||
|
masterTable: string
|
||||||
|
): Promise<{ detailFkColumn: string; masterKeyColumn: string } | null> {
|
||||||
|
try {
|
||||||
|
logger.info(`Entity 관계 조회: ${detailTable} -> ${masterTable}`);
|
||||||
|
|
||||||
|
const result = await queryOne<any>(
|
||||||
|
`SELECT column_name, reference_column
|
||||||
|
FROM column_labels
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND input_type = 'entity'
|
||||||
|
AND reference_table = $2
|
||||||
|
LIMIT 1`,
|
||||||
|
[detailTable, masterTable]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
logger.warn(`Entity 관계 없음: ${detailTable} -> ${masterTable}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Entity 관계 발견: ${detailTable}.${result.column_name} -> ${masterTable}.${result.reference_column}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
detailFkColumn: result.column_name,
|
||||||
|
masterKeyColumn: result.reference_column,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`Entity 관계 조회 실패: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블의 컬럼 라벨 정보 조회
|
||||||
|
*/
|
||||||
|
async getColumnLabels(tableName: string): Promise<Map<string, string>> {
|
||||||
|
try {
|
||||||
|
const result = await query<any>(
|
||||||
|
`SELECT column_name, column_label
|
||||||
|
FROM column_labels
|
||||||
|
WHERE table_name = $1`,
|
||||||
|
[tableName]
|
||||||
|
);
|
||||||
|
|
||||||
|
const labelMap = new Map<string, string>();
|
||||||
|
for (const row of result) {
|
||||||
|
labelMap.set(row.column_name, row.column_label || row.column_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return labelMap;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`컬럼 라벨 조회 실패: ${error.message}`);
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 관계 정보 조합
|
||||||
|
*/
|
||||||
|
async getMasterDetailRelation(
|
||||||
|
screenId: number
|
||||||
|
): Promise<MasterDetailRelation | null> {
|
||||||
|
try {
|
||||||
|
// 1. 분할 패널 설정 조회
|
||||||
|
const splitPanel = await this.getSplitPanelConfig(screenId);
|
||||||
|
if (!splitPanel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const masterTable = splitPanel.leftPanel.tableName;
|
||||||
|
const detailTable = splitPanel.rightPanel.tableName;
|
||||||
|
|
||||||
|
if (!masterTable || !detailTable) {
|
||||||
|
logger.warn("마스터 또는 디테일 테이블명 없음");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 분할 패널의 relation 정보가 있으면 우선 사용
|
||||||
|
// 🔥 keys 배열을 우선 사용 (새로운 복합키 지원 방식)
|
||||||
|
let masterKeyColumn: string | undefined;
|
||||||
|
let detailFkColumn: string | undefined;
|
||||||
|
|
||||||
|
const relationKeys = splitPanel.rightPanel.relation?.keys;
|
||||||
|
if (relationKeys && relationKeys.length > 0) {
|
||||||
|
// keys 배열에서 첫 번째 키 사용
|
||||||
|
masterKeyColumn = relationKeys[0].leftColumn;
|
||||||
|
detailFkColumn = relationKeys[0].rightColumn;
|
||||||
|
logger.info(`keys 배열에서 관계 정보 사용: ${masterKeyColumn} -> ${detailFkColumn}`);
|
||||||
|
} else {
|
||||||
|
// 하위 호환성: 기존 leftColumn/foreignKey 사용
|
||||||
|
masterKeyColumn = splitPanel.rightPanel.relation?.leftColumn;
|
||||||
|
detailFkColumn = splitPanel.rightPanel.relation?.foreignKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. relation 정보가 없으면 column_labels에서 Entity 관계 조회
|
||||||
|
if (!masterKeyColumn || !detailFkColumn) {
|
||||||
|
const entityRelation = await this.getEntityRelation(detailTable, masterTable);
|
||||||
|
if (entityRelation) {
|
||||||
|
masterKeyColumn = entityRelation.masterKeyColumn;
|
||||||
|
detailFkColumn = entityRelation.detailFkColumn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!masterKeyColumn || !detailFkColumn) {
|
||||||
|
logger.warn("조인 키 정보를 찾을 수 없음");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 컬럼 라벨 정보 조회
|
||||||
|
const masterLabels = await this.getColumnLabels(masterTable);
|
||||||
|
const detailLabels = await this.getColumnLabels(detailTable);
|
||||||
|
|
||||||
|
// 5. 마스터 컬럼 정보 구성
|
||||||
|
const masterColumns: ColumnInfo[] = splitPanel.leftPanel.columns.map(col => ({
|
||||||
|
name: col.name,
|
||||||
|
label: masterLabels.get(col.name) || col.label || col.name,
|
||||||
|
inputType: "text",
|
||||||
|
isFromMaster: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 6. 디테일 컬럼 정보 구성 (FK 컬럼 제외)
|
||||||
|
const detailColumns: ColumnInfo[] = splitPanel.rightPanel.columns
|
||||||
|
.filter(col => col.name !== detailFkColumn) // FK 컬럼 제외
|
||||||
|
.map(col => ({
|
||||||
|
name: col.name,
|
||||||
|
label: detailLabels.get(col.name) || col.label || col.name,
|
||||||
|
inputType: "text",
|
||||||
|
isFromMaster: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
logger.info(`마스터-디테일 관계 구성 완료:`, {
|
||||||
|
masterTable,
|
||||||
|
detailTable,
|
||||||
|
masterKeyColumn,
|
||||||
|
detailFkColumn,
|
||||||
|
masterColumnCount: masterColumns.length,
|
||||||
|
detailColumnCount: detailColumns.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
masterTable,
|
||||||
|
detailTable,
|
||||||
|
masterKeyColumn,
|
||||||
|
detailFkColumn,
|
||||||
|
masterColumns,
|
||||||
|
detailColumns,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`마스터-디테일 관계 조회 실패: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 JOIN 데이터 조회 (엑셀 다운로드용)
|
||||||
|
*/
|
||||||
|
async getJoinedData(
|
||||||
|
relation: MasterDetailRelation,
|
||||||
|
companyCode: string,
|
||||||
|
filters?: Record<string, any>
|
||||||
|
): Promise<ExcelDownloadData> {
|
||||||
|
try {
|
||||||
|
const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation;
|
||||||
|
|
||||||
|
// 조인 컬럼과 일반 컬럼 분리
|
||||||
|
// 조인 컬럼 형식: "테이블명.컬럼명" (예: customer_mng.customer_name)
|
||||||
|
const entityJoins: Array<{
|
||||||
|
refTable: string;
|
||||||
|
refColumn: string;
|
||||||
|
sourceColumn: string;
|
||||||
|
alias: string;
|
||||||
|
displayColumn: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// SELECT 절 구성
|
||||||
|
const selectParts: string[] = [];
|
||||||
|
let aliasIndex = 0;
|
||||||
|
|
||||||
|
// 마스터 컬럼 처리
|
||||||
|
for (const col of masterColumns) {
|
||||||
|
if (col.name.includes(".")) {
|
||||||
|
// 조인 컬럼: 테이블명.컬럼명
|
||||||
|
const [refTable, displayColumn] = col.name.split(".");
|
||||||
|
const alias = `ej${aliasIndex++}`;
|
||||||
|
|
||||||
|
// column_labels에서 FK 컬럼 찾기
|
||||||
|
const fkColumn = await this.findForeignKeyColumn(masterTable, refTable);
|
||||||
|
if (fkColumn) {
|
||||||
|
entityJoins.push({
|
||||||
|
refTable,
|
||||||
|
refColumn: fkColumn.referenceColumn,
|
||||||
|
sourceColumn: fkColumn.sourceColumn,
|
||||||
|
alias,
|
||||||
|
displayColumn,
|
||||||
|
});
|
||||||
|
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
|
||||||
|
} else {
|
||||||
|
// FK를 못 찾으면 NULL로 처리
|
||||||
|
selectParts.push(`NULL AS "${col.name}"`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 일반 컬럼
|
||||||
|
selectParts.push(`m."${col.name}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 디테일 컬럼 처리
|
||||||
|
for (const col of detailColumns) {
|
||||||
|
if (col.name.includes(".")) {
|
||||||
|
// 조인 컬럼: 테이블명.컬럼명
|
||||||
|
const [refTable, displayColumn] = col.name.split(".");
|
||||||
|
const alias = `ej${aliasIndex++}`;
|
||||||
|
|
||||||
|
// column_labels에서 FK 컬럼 찾기
|
||||||
|
const fkColumn = await this.findForeignKeyColumn(detailTable, refTable);
|
||||||
|
if (fkColumn) {
|
||||||
|
entityJoins.push({
|
||||||
|
refTable,
|
||||||
|
refColumn: fkColumn.referenceColumn,
|
||||||
|
sourceColumn: fkColumn.sourceColumn,
|
||||||
|
alias,
|
||||||
|
displayColumn,
|
||||||
|
});
|
||||||
|
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
|
||||||
|
} else {
|
||||||
|
selectParts.push(`NULL AS "${col.name}"`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 일반 컬럼
|
||||||
|
selectParts.push(`d."${col.name}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectClause = selectParts.join(", ");
|
||||||
|
|
||||||
|
// 엔티티 조인 절 구성
|
||||||
|
const entityJoinClauses = entityJoins.map(ej =>
|
||||||
|
`LEFT JOIN "${ej.refTable}" ${ej.alias} ON m."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"`
|
||||||
|
).join("\n ");
|
||||||
|
|
||||||
|
// WHERE 절 구성
|
||||||
|
const whereConditions: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// 회사 코드 필터 (최고 관리자 제외)
|
||||||
|
if (companyCode && companyCode !== "*") {
|
||||||
|
whereConditions.push(`m.company_code = $${paramIndex}`);
|
||||||
|
params.push(companyCode);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 추가 필터 적용
|
||||||
|
if (filters) {
|
||||||
|
for (const [key, value] of Object.entries(filters)) {
|
||||||
|
if (value !== undefined && value !== null && value !== "") {
|
||||||
|
// 조인 컬럼인지 확인
|
||||||
|
if (key.includes(".")) continue;
|
||||||
|
// 마스터 테이블 컬럼인지 확인
|
||||||
|
const isMasterCol = masterColumns.some(c => c.name === key);
|
||||||
|
const tableAlias = isMasterCol ? "m" : "d";
|
||||||
|
whereConditions.push(`${tableAlias}."${key}" = $${paramIndex}`);
|
||||||
|
params.push(value);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = whereConditions.length > 0
|
||||||
|
? `WHERE ${whereConditions.join(" AND ")}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// JOIN 쿼리 실행
|
||||||
|
const sql = `
|
||||||
|
SELECT ${selectClause}
|
||||||
|
FROM "${masterTable}" m
|
||||||
|
LEFT JOIN "${detailTable}" d
|
||||||
|
ON m."${masterKeyColumn}" = d."${detailFkColumn}"
|
||||||
|
AND m.company_code = d.company_code
|
||||||
|
${entityJoinClauses}
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY m."${masterKeyColumn}", d.id
|
||||||
|
`;
|
||||||
|
|
||||||
|
logger.info(`마스터-디테일 JOIN 쿼리:`, { sql, params });
|
||||||
|
|
||||||
|
const data = await query<any>(sql, params);
|
||||||
|
|
||||||
|
// 헤더 및 컬럼 정보 구성
|
||||||
|
const headers = [...masterColumns.map(c => c.label), ...detailColumns.map(c => c.label)];
|
||||||
|
const columns = [...masterColumns.map(c => c.name), ...detailColumns.map(c => c.name)];
|
||||||
|
|
||||||
|
logger.info(`마스터-디테일 데이터 조회 완료: ${data.length}행`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
headers,
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
masterColumns: masterColumns.map(c => c.name),
|
||||||
|
detailColumns: detailColumns.map(c => c.name),
|
||||||
|
joinKey: masterKeyColumn,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`마스터-디테일 데이터 조회 실패: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 테이블에서 참조 테이블로의 FK 컬럼 찾기
|
||||||
|
*/
|
||||||
|
private async findForeignKeyColumn(
|
||||||
|
sourceTable: string,
|
||||||
|
referenceTable: string
|
||||||
|
): Promise<{ sourceColumn: string; referenceColumn: string } | null> {
|
||||||
|
try {
|
||||||
|
const result = await query<{ column_name: string; reference_column: string }>(
|
||||||
|
`SELECT column_name, reference_column
|
||||||
|
FROM column_labels
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND reference_table = $2
|
||||||
|
AND input_type = 'entity'
|
||||||
|
LIMIT 1`,
|
||||||
|
[sourceTable, referenceTable]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.length > 0) {
|
||||||
|
return {
|
||||||
|
sourceColumn: result[0].column_name,
|
||||||
|
referenceColumn: result[0].reference_column,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`FK 컬럼 조회 실패: ${sourceTable} -> ${referenceTable}`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 데이터 업로드 (엑셀 업로드용)
|
||||||
|
*
|
||||||
|
* 처리 로직:
|
||||||
|
* 1. 엑셀 데이터를 마스터 키로 그룹화
|
||||||
|
* 2. 각 그룹의 첫 번째 행에서 마스터 데이터 추출 → UPSERT
|
||||||
|
* 3. 해당 마스터 키의 기존 디테일 삭제
|
||||||
|
* 4. 새 디테일 데이터 INSERT
|
||||||
|
*/
|
||||||
|
async uploadJoinedData(
|
||||||
|
relation: MasterDetailRelation,
|
||||||
|
data: Record<string, any>[],
|
||||||
|
companyCode: string,
|
||||||
|
userId?: string
|
||||||
|
): Promise<ExcelUploadResult> {
|
||||||
|
const result: ExcelUploadResult = {
|
||||||
|
success: false,
|
||||||
|
masterInserted: 0,
|
||||||
|
masterUpdated: 0,
|
||||||
|
detailInserted: 0,
|
||||||
|
detailDeleted: 0,
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation;
|
||||||
|
|
||||||
|
// 1. 데이터를 마스터 키로 그룹화
|
||||||
|
const groupedData = new Map<string, Record<string, any>[]>();
|
||||||
|
|
||||||
|
for (const row of data) {
|
||||||
|
const masterKey = row[masterKeyColumn];
|
||||||
|
if (!masterKey) {
|
||||||
|
result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!groupedData.has(masterKey)) {
|
||||||
|
groupedData.set(masterKey, []);
|
||||||
|
}
|
||||||
|
groupedData.get(masterKey)!.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`데이터 그룹화 완료: ${groupedData.size}개 마스터 그룹`);
|
||||||
|
|
||||||
|
// 2. 각 그룹 처리
|
||||||
|
for (const [masterKey, rows] of groupedData.entries()) {
|
||||||
|
try {
|
||||||
|
// 2a. 마스터 데이터 추출 (첫 번째 행에서)
|
||||||
|
const masterData: Record<string, any> = {};
|
||||||
|
for (const col of masterColumns) {
|
||||||
|
if (rows[0][col.name] !== undefined) {
|
||||||
|
masterData[col.name] = rows[0][col.name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 회사 코드, 작성자 추가
|
||||||
|
masterData.company_code = companyCode;
|
||||||
|
if (userId) {
|
||||||
|
masterData.writer = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2b. 마스터 UPSERT
|
||||||
|
const existingMaster = await client.query(
|
||||||
|
`SELECT id FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`,
|
||||||
|
[masterKey, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingMaster.rows.length > 0) {
|
||||||
|
// UPDATE
|
||||||
|
const updateCols = Object.keys(masterData)
|
||||||
|
.filter(k => k !== masterKeyColumn && k !== "id")
|
||||||
|
.map((k, i) => `"${k}" = $${i + 1}`);
|
||||||
|
const updateValues = Object.keys(masterData)
|
||||||
|
.filter(k => k !== masterKeyColumn && k !== "id")
|
||||||
|
.map(k => masterData[k]);
|
||||||
|
|
||||||
|
if (updateCols.length > 0) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE "${masterTable}"
|
||||||
|
SET ${updateCols.join(", ")}, updated_date = NOW()
|
||||||
|
WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`,
|
||||||
|
[...updateValues, masterKey, companyCode]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
result.masterUpdated++;
|
||||||
|
} else {
|
||||||
|
// INSERT
|
||||||
|
const insertCols = Object.keys(masterData);
|
||||||
|
const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`);
|
||||||
|
const insertValues = insertCols.map(k => masterData[k]);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO "${masterTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date)
|
||||||
|
VALUES (${insertPlaceholders.join(", ")}, NOW())`,
|
||||||
|
insertValues
|
||||||
|
);
|
||||||
|
result.masterInserted++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2c. 기존 디테일 삭제
|
||||||
|
const deleteResult = await client.query(
|
||||||
|
`DELETE FROM "${detailTable}" WHERE "${detailFkColumn}" = $1 AND company_code = $2`,
|
||||||
|
[masterKey, companyCode]
|
||||||
|
);
|
||||||
|
result.detailDeleted += deleteResult.rowCount || 0;
|
||||||
|
|
||||||
|
// 2d. 새 디테일 INSERT
|
||||||
|
for (const row of rows) {
|
||||||
|
const detailData: Record<string, any> = {};
|
||||||
|
|
||||||
|
// FK 컬럼 추가
|
||||||
|
detailData[detailFkColumn] = masterKey;
|
||||||
|
detailData.company_code = companyCode;
|
||||||
|
if (userId) {
|
||||||
|
detailData.writer = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 디테일 컬럼 데이터 추출
|
||||||
|
for (const col of detailColumns) {
|
||||||
|
if (row[col.name] !== undefined) {
|
||||||
|
detailData[col.name] = row[col.name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertCols = Object.keys(detailData);
|
||||||
|
const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`);
|
||||||
|
const insertValues = insertCols.map(k => detailData[k]);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO "${detailTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date)
|
||||||
|
VALUES (${insertPlaceholders.join(", ")}, NOW())`,
|
||||||
|
insertValues
|
||||||
|
);
|
||||||
|
result.detailInserted++;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
result.errors.push(`마스터 키 ${masterKey} 처리 실패: ${error.message}`);
|
||||||
|
logger.error(`마스터 키 ${masterKey} 처리 실패:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
result.success = result.errors.length === 0 || result.masterInserted + result.masterUpdated > 0;
|
||||||
|
|
||||||
|
logger.info(`마스터-디테일 업로드 완료:`, {
|
||||||
|
masterInserted: result.masterInserted,
|
||||||
|
masterUpdated: result.masterUpdated,
|
||||||
|
detailInserted: result.detailInserted,
|
||||||
|
detailDeleted: result.detailDeleted,
|
||||||
|
errors: result.errors.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
result.errors.push(`트랜잭션 실패: ${error.message}`);
|
||||||
|
logger.error(`마스터-디테일 업로드 트랜잭션 실패:`, error);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 간단 모드 업로드
|
||||||
|
*
|
||||||
|
* 마스터 정보는 UI에서 선택하고, 엑셀은 디테일 데이터만 포함
|
||||||
|
* 채번 규칙을 통해 마스터 키 자동 생성
|
||||||
|
*
|
||||||
|
* @param screenId 화면 ID
|
||||||
|
* @param detailData 디테일 데이터 배열
|
||||||
|
* @param masterFieldValues UI에서 선택한 마스터 필드 값
|
||||||
|
* @param numberingRuleId 채번 규칙 ID (optional)
|
||||||
|
* @param companyCode 회사 코드
|
||||||
|
* @param userId 사용자 ID
|
||||||
|
* @param afterUploadFlowId 업로드 후 실행할 노드 플로우 ID (optional, 하위 호환성)
|
||||||
|
* @param afterUploadFlows 업로드 후 실행할 노드 플로우 배열 (optional)
|
||||||
|
*/
|
||||||
|
async uploadSimple(
|
||||||
|
screenId: number,
|
||||||
|
detailData: Record<string, any>[],
|
||||||
|
masterFieldValues: Record<string, any>,
|
||||||
|
numberingRuleId: string | undefined,
|
||||||
|
companyCode: string,
|
||||||
|
userId: string,
|
||||||
|
afterUploadFlowId?: string,
|
||||||
|
afterUploadFlows?: Array<{ flowId: string; order: number }>
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
masterInserted: number;
|
||||||
|
detailInserted: number;
|
||||||
|
generatedKey: string;
|
||||||
|
errors: string[];
|
||||||
|
controlResult?: any;
|
||||||
|
}> {
|
||||||
|
const result: {
|
||||||
|
success: boolean;
|
||||||
|
masterInserted: number;
|
||||||
|
detailInserted: number;
|
||||||
|
generatedKey: string;
|
||||||
|
errors: string[];
|
||||||
|
controlResult?: any;
|
||||||
|
} = {
|
||||||
|
success: false,
|
||||||
|
masterInserted: 0,
|
||||||
|
detailInserted: 0,
|
||||||
|
generatedKey: "",
|
||||||
|
errors: [] as string[],
|
||||||
|
};
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
// 1. 마스터-디테일 관계 정보 조회
|
||||||
|
const relation = await this.getMasterDetailRelation(screenId);
|
||||||
|
if (!relation) {
|
||||||
|
throw new Error("마스터-디테일 관계 정보를 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { masterTable, detailTable, masterKeyColumn, detailFkColumn } = relation;
|
||||||
|
|
||||||
|
// 2. 채번 처리
|
||||||
|
let generatedKey: string;
|
||||||
|
|
||||||
|
if (numberingRuleId) {
|
||||||
|
// 채번 규칙으로 키 생성
|
||||||
|
generatedKey = await this.generateNumberWithRule(client, numberingRuleId, companyCode);
|
||||||
|
} else {
|
||||||
|
// 채번 규칙 없으면 마스터 필드에서 키 값 사용
|
||||||
|
generatedKey = masterFieldValues[masterKeyColumn];
|
||||||
|
if (!generatedKey) {
|
||||||
|
throw new Error(`마스터 키(${masterKeyColumn}) 값이 필요합니다.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.generatedKey = generatedKey;
|
||||||
|
logger.info(`채번 결과: ${generatedKey}`);
|
||||||
|
|
||||||
|
// 3. 마스터 레코드 생성
|
||||||
|
const masterData: Record<string, any> = {
|
||||||
|
...masterFieldValues,
|
||||||
|
[masterKeyColumn]: generatedKey,
|
||||||
|
company_code: companyCode,
|
||||||
|
writer: userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 마스터 컬럼명 목록 구성
|
||||||
|
const masterCols = Object.keys(masterData).filter(k => masterData[k] !== undefined);
|
||||||
|
const masterPlaceholders = masterCols.map((_, i) => `$${i + 1}`);
|
||||||
|
const masterValues = masterCols.map(k => masterData[k]);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO "${masterTable}" (${masterCols.map(c => `"${c}"`).join(", ")}, created_date)
|
||||||
|
VALUES (${masterPlaceholders.join(", ")}, NOW())`,
|
||||||
|
masterValues
|
||||||
|
);
|
||||||
|
result.masterInserted = 1;
|
||||||
|
logger.info(`마스터 레코드 생성: ${masterTable}, key=${generatedKey}`);
|
||||||
|
|
||||||
|
// 4. 디테일 레코드들 생성 (삽입된 데이터 수집)
|
||||||
|
const insertedDetailRows: Record<string, any>[] = [];
|
||||||
|
|
||||||
|
for (const row of detailData) {
|
||||||
|
try {
|
||||||
|
const detailRowData: Record<string, any> = {
|
||||||
|
...row,
|
||||||
|
[detailFkColumn]: generatedKey,
|
||||||
|
company_code: companyCode,
|
||||||
|
writer: userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 빈 값 필터링 및 id 제외
|
||||||
|
const detailCols = Object.keys(detailRowData).filter(k =>
|
||||||
|
k !== "id" &&
|
||||||
|
detailRowData[k] !== undefined &&
|
||||||
|
detailRowData[k] !== null &&
|
||||||
|
detailRowData[k] !== ""
|
||||||
|
);
|
||||||
|
const detailPlaceholders = detailCols.map((_, i) => `$${i + 1}`);
|
||||||
|
const detailValues = detailCols.map(k => detailRowData[k]);
|
||||||
|
|
||||||
|
// RETURNING *로 삽입된 데이터 반환받기
|
||||||
|
const insertResult = await client.query(
|
||||||
|
`INSERT INTO "${detailTable}" (${detailCols.map(c => `"${c}"`).join(", ")}, created_date)
|
||||||
|
VALUES (${detailPlaceholders.join(", ")}, NOW())
|
||||||
|
RETURNING *`,
|
||||||
|
detailValues
|
||||||
|
);
|
||||||
|
|
||||||
|
if (insertResult.rows && insertResult.rows[0]) {
|
||||||
|
insertedDetailRows.push(insertResult.rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.detailInserted++;
|
||||||
|
} catch (error: any) {
|
||||||
|
result.errors.push(`디테일 행 처리 실패: ${error.message}`);
|
||||||
|
logger.error(`디테일 행 처리 실패:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`디테일 레코드 ${insertedDetailRows.length}건 삽입 완료`);
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
result.success = result.errors.length === 0 || result.detailInserted > 0;
|
||||||
|
|
||||||
|
logger.info(`마스터-디테일 간단 모드 업로드 완료:`, {
|
||||||
|
masterInserted: result.masterInserted,
|
||||||
|
detailInserted: result.detailInserted,
|
||||||
|
generatedKey: result.generatedKey,
|
||||||
|
errors: result.errors.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 업로드 후 제어 실행 (단일 또는 다중)
|
||||||
|
const flowsToExecute = afterUploadFlows && afterUploadFlows.length > 0
|
||||||
|
? afterUploadFlows // 다중 제어
|
||||||
|
: afterUploadFlowId
|
||||||
|
? [{ flowId: afterUploadFlowId, order: 1 }] // 단일 (하위 호환성)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (flowsToExecute.length > 0 && result.success) {
|
||||||
|
try {
|
||||||
|
const { NodeFlowExecutionService } = await import("./nodeFlowExecutionService");
|
||||||
|
|
||||||
|
// 마스터 데이터 구성
|
||||||
|
const masterData = {
|
||||||
|
...masterFieldValues,
|
||||||
|
[relation!.masterKeyColumn]: result.generatedKey,
|
||||||
|
company_code: companyCode,
|
||||||
|
};
|
||||||
|
|
||||||
|
const controlResults: any[] = [];
|
||||||
|
|
||||||
|
// 순서대로 제어 실행
|
||||||
|
for (const flow of flowsToExecute.sort((a, b) => a.order - b.order)) {
|
||||||
|
logger.info(`업로드 후 제어 실행: flowId=${flow.flowId}, order=${flow.order}`);
|
||||||
|
logger.info(` 전달 데이터: 마스터 1건, 디테일 ${insertedDetailRows.length}건`);
|
||||||
|
|
||||||
|
// 🆕 삽입된 디테일 데이터를 sourceData로 전달 (성능 최적화)
|
||||||
|
// - 전체 테이블 조회 대신 방금 INSERT한 데이터만 처리
|
||||||
|
// - tableSource 노드가 context-data 모드일 때 이 데이터를 사용
|
||||||
|
const controlResult = await NodeFlowExecutionService.executeFlow(
|
||||||
|
parseInt(flow.flowId),
|
||||||
|
{
|
||||||
|
sourceData: insertedDetailRows.length > 0 ? insertedDetailRows : [masterData],
|
||||||
|
dataSourceType: "excelUpload", // 엑셀 업로드 데이터임을 명시
|
||||||
|
buttonId: "excel-upload-button",
|
||||||
|
screenId: screenId,
|
||||||
|
userId: userId,
|
||||||
|
companyCode: companyCode,
|
||||||
|
formData: masterData,
|
||||||
|
// 추가 컨텍스트: 마스터/디테일 정보
|
||||||
|
masterData: masterData,
|
||||||
|
detailData: insertedDetailRows,
|
||||||
|
masterTable: relation!.masterTable,
|
||||||
|
detailTable: relation!.detailTable,
|
||||||
|
masterKeyColumn: relation!.masterKeyColumn,
|
||||||
|
detailFkColumn: relation!.detailFkColumn,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
controlResults.push({
|
||||||
|
flowId: flow.flowId,
|
||||||
|
order: flow.order,
|
||||||
|
success: controlResult.success,
|
||||||
|
message: controlResult.message,
|
||||||
|
executedNodes: controlResult.nodes?.length || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
result.controlResult = {
|
||||||
|
success: controlResults.every(r => r.success),
|
||||||
|
executedFlows: controlResults.length,
|
||||||
|
results: controlResults,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info(`업로드 후 제어 실행 완료: ${controlResults.length}개 실행`, result.controlResult);
|
||||||
|
} catch (controlError: any) {
|
||||||
|
logger.error(`업로드 후 제어 실행 실패:`, controlError);
|
||||||
|
result.controlResult = {
|
||||||
|
success: false,
|
||||||
|
message: `제어 실행 실패: ${controlError.message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
result.errors.push(`트랜잭션 실패: ${error.message}`);
|
||||||
|
logger.error(`마스터-디테일 간단 모드 업로드 실패:`, error);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 채번 규칙으로 번호 생성 (기존 numberingRuleService 사용)
|
||||||
|
*/
|
||||||
|
private async generateNumberWithRule(
|
||||||
|
client: any,
|
||||||
|
ruleId: string,
|
||||||
|
companyCode: string
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
// 기존 numberingRuleService를 사용하여 코드 할당
|
||||||
|
const { numberingRuleService } = await import("./numberingRuleService");
|
||||||
|
const generatedCode = await numberingRuleService.allocateCode(ruleId, companyCode);
|
||||||
|
|
||||||
|
logger.info(`채번 생성 (numberingRuleService): rule=${ruleId}, result=${generatedCode}`);
|
||||||
|
|
||||||
|
return generatedCode;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`채번 생성 실패: rule=${ruleId}, error=${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const masterDetailExcelService = new MasterDetailExcelService();
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,969 @@
|
||||||
|
import { getPool } from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메뉴-화면그룹 동기화 서비스
|
||||||
|
*
|
||||||
|
* 양방향 동기화:
|
||||||
|
* 1. screen_groups → menu_info: 화면관리 폴더 구조를 메뉴로 동기화
|
||||||
|
* 2. menu_info → screen_groups: 사용자 메뉴를 화면관리 폴더로 동기화
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 타입 정의
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
interface SyncResult {
|
||||||
|
success: boolean;
|
||||||
|
created: number;
|
||||||
|
linked: number;
|
||||||
|
skipped: number;
|
||||||
|
errors: string[];
|
||||||
|
details: SyncDetail[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SyncDetail {
|
||||||
|
action: 'created' | 'linked' | 'skipped' | 'error';
|
||||||
|
sourceName: string;
|
||||||
|
sourceId: number | string;
|
||||||
|
targetId?: number | string;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 화면관리 → 메뉴 동기화
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* screen_groups를 menu_info로 동기화
|
||||||
|
*
|
||||||
|
* 로직:
|
||||||
|
* 1. 해당 회사의 screen_groups 조회 (폴더 구조)
|
||||||
|
* 2. 이미 menu_objid가 연결된 것은 제외
|
||||||
|
* 3. 이름으로 기존 menu_info 매칭 시도
|
||||||
|
* - 매칭되면: 양쪽에 연결 ID 업데이트
|
||||||
|
* - 매칭 안되면: menu_info에 새로 생성
|
||||||
|
* 4. 계층 구조(parent) 유지
|
||||||
|
*/
|
||||||
|
export async function syncScreenGroupsToMenu(
|
||||||
|
companyCode: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<SyncResult> {
|
||||||
|
const result: SyncResult = {
|
||||||
|
success: true,
|
||||||
|
created: 0,
|
||||||
|
linked: 0,
|
||||||
|
skipped: 0,
|
||||||
|
errors: [],
|
||||||
|
details: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
logger.info("화면관리 → 메뉴 동기화 시작", { companyCode, userId });
|
||||||
|
|
||||||
|
// 1. 해당 회사의 screen_groups 조회 (아직 menu_objid가 없는 것)
|
||||||
|
const screenGroupsQuery = `
|
||||||
|
SELECT
|
||||||
|
sg.id,
|
||||||
|
sg.group_name,
|
||||||
|
sg.group_code,
|
||||||
|
sg.parent_group_id,
|
||||||
|
sg.group_level,
|
||||||
|
sg.display_order,
|
||||||
|
sg.description,
|
||||||
|
sg.icon,
|
||||||
|
sg.menu_objid,
|
||||||
|
-- 부모 그룹의 menu_objid도 조회 (계층 연결용)
|
||||||
|
parent.menu_objid as parent_menu_objid
|
||||||
|
FROM screen_groups sg
|
||||||
|
LEFT JOIN screen_groups parent ON sg.parent_group_id = parent.id
|
||||||
|
WHERE sg.company_code = $1
|
||||||
|
ORDER BY sg.group_level ASC, sg.display_order ASC
|
||||||
|
`;
|
||||||
|
const screenGroupsResult = await client.query(screenGroupsQuery, [companyCode]);
|
||||||
|
|
||||||
|
// 2. 해당 회사의 기존 menu_info 조회 (사용자 메뉴, menu_type=1)
|
||||||
|
// 경로 기반 매칭을 위해 부모 이름도 조회
|
||||||
|
const existingMenusQuery = `
|
||||||
|
SELECT
|
||||||
|
m.objid,
|
||||||
|
m.menu_name_kor,
|
||||||
|
m.parent_obj_id,
|
||||||
|
m.screen_group_id,
|
||||||
|
p.menu_name_kor as parent_name
|
||||||
|
FROM menu_info m
|
||||||
|
LEFT JOIN menu_info p ON m.parent_obj_id = p.objid
|
||||||
|
WHERE m.company_code = $1 AND m.menu_type = 1
|
||||||
|
`;
|
||||||
|
const existingMenusResult = await client.query(existingMenusQuery, [companyCode]);
|
||||||
|
|
||||||
|
// 경로(부모이름 > 이름) → 메뉴 매핑 (screen_group_id가 없는 것만)
|
||||||
|
// 단순 이름 매칭도 유지 (하위 호환)
|
||||||
|
const menuByPath: Map<string, any> = new Map();
|
||||||
|
const menuByName: Map<string, any> = new Map();
|
||||||
|
existingMenusResult.rows.forEach((menu: any) => {
|
||||||
|
if (!menu.screen_group_id) {
|
||||||
|
const menuName = menu.menu_name_kor?.trim().toLowerCase() || '';
|
||||||
|
const parentName = menu.parent_name?.trim().toLowerCase() || '';
|
||||||
|
const pathKey = parentName ? `${parentName}>${menuName}` : menuName;
|
||||||
|
|
||||||
|
menuByPath.set(pathKey, menu);
|
||||||
|
// 단순 이름 매핑은 첫 번째 것만 (중복 방지)
|
||||||
|
if (!menuByName.has(menuName)) {
|
||||||
|
menuByName.set(menuName, menu);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 모든 메뉴의 objid 집합 (삭제 확인용)
|
||||||
|
const existingMenuObjids = new Set(existingMenusResult.rows.map((m: any) => Number(m.objid)));
|
||||||
|
|
||||||
|
// 3. 사용자 메뉴의 루트 찾기 (parent_obj_id = 0인 사용자 메뉴)
|
||||||
|
// 없으면 생성
|
||||||
|
let userMenuRootObjid: number | null = null;
|
||||||
|
const rootMenuQuery = `
|
||||||
|
SELECT objid FROM menu_info
|
||||||
|
WHERE company_code = $1 AND menu_type = 1 AND parent_obj_id = 0
|
||||||
|
ORDER BY seq ASC
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
const rootMenuResult = await client.query(rootMenuQuery, [companyCode]);
|
||||||
|
|
||||||
|
if (rootMenuResult.rows.length > 0) {
|
||||||
|
userMenuRootObjid = Number(rootMenuResult.rows[0].objid);
|
||||||
|
} else {
|
||||||
|
// 루트 메뉴가 없으면 생성
|
||||||
|
const newObjid = Date.now();
|
||||||
|
const createRootQuery = `
|
||||||
|
INSERT INTO menu_info (objid, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_type, company_code, writer, regdate, status)
|
||||||
|
VALUES ($1, 0, '사용자', 'User', 1, 1, $2, $3, NOW(), 'active')
|
||||||
|
RETURNING objid
|
||||||
|
`;
|
||||||
|
const createRootResult = await client.query(createRootQuery, [newObjid, companyCode, userId]);
|
||||||
|
userMenuRootObjid = Number(createRootResult.rows[0].objid);
|
||||||
|
logger.info("사용자 메뉴 루트 생성", { companyCode, objid: userMenuRootObjid });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. screen_groups ID → menu_objid 매핑 (순차 처리를 위해)
|
||||||
|
const groupToMenuMap: Map<number, number> = new Map();
|
||||||
|
|
||||||
|
// screen_groups의 부모 이름 조회를 위한 매핑
|
||||||
|
const groupIdToName: Map<number, string> = new Map();
|
||||||
|
screenGroupsResult.rows.forEach((g: any) => {
|
||||||
|
groupIdToName.set(g.id, g.group_name?.trim().toLowerCase() || '');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. 최상위 회사 폴더 ID 찾기 (level 0, parent_group_id IS NULL)
|
||||||
|
// 이 폴더는 메뉴로 생성하지 않고, 하위 폴더들을 사용자 루트 바로 아래에 배치
|
||||||
|
const topLevelCompanyFolderIds = new Set<number>();
|
||||||
|
for (const group of screenGroupsResult.rows) {
|
||||||
|
if (group.group_level === 0 && group.parent_group_id === null) {
|
||||||
|
topLevelCompanyFolderIds.add(group.id);
|
||||||
|
// 최상위 폴더 → 사용자 루트에 매핑 (하위 폴더의 부모로 사용)
|
||||||
|
groupToMenuMap.set(group.id, userMenuRootObjid!);
|
||||||
|
logger.info("최상위 회사 폴더 스킵", { groupId: group.id, groupName: group.group_name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 각 screen_group 처리
|
||||||
|
for (const group of screenGroupsResult.rows) {
|
||||||
|
const groupId = group.id;
|
||||||
|
const groupName = group.group_name?.trim();
|
||||||
|
const groupNameLower = groupName?.toLowerCase() || '';
|
||||||
|
|
||||||
|
// 최상위 회사 폴더는 메뉴로 생성하지 않고 스킵
|
||||||
|
if (topLevelCompanyFolderIds.has(groupId)) {
|
||||||
|
result.skipped++;
|
||||||
|
result.details.push({
|
||||||
|
action: 'skipped',
|
||||||
|
sourceName: groupName,
|
||||||
|
sourceId: groupId,
|
||||||
|
reason: '최상위 회사 폴더 (메뉴 생성 스킵)',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 연결된 경우 - 실제로 메뉴가 존재하는지 확인
|
||||||
|
if (group.menu_objid) {
|
||||||
|
const menuExists = existingMenuObjids.has(Number(group.menu_objid));
|
||||||
|
|
||||||
|
if (menuExists) {
|
||||||
|
// 메뉴가 존재하면 스킵
|
||||||
|
result.skipped++;
|
||||||
|
result.details.push({
|
||||||
|
action: 'skipped',
|
||||||
|
sourceName: groupName,
|
||||||
|
sourceId: groupId,
|
||||||
|
targetId: group.menu_objid,
|
||||||
|
reason: '이미 메뉴와 연결됨',
|
||||||
|
});
|
||||||
|
groupToMenuMap.set(groupId, Number(group.menu_objid));
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
// 메뉴가 삭제되었으면 연결 해제하고 재생성
|
||||||
|
logger.info("삭제된 메뉴 연결 해제", { groupId, deletedMenuObjid: group.menu_objid });
|
||||||
|
await client.query(
|
||||||
|
`UPDATE screen_groups SET menu_objid = NULL, updated_date = NOW() WHERE id = $1`,
|
||||||
|
[groupId]
|
||||||
|
);
|
||||||
|
// 계속 진행하여 재생성 또는 재연결
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부모 그룹 이름 조회 (경로 기반 매칭용)
|
||||||
|
const parentGroupName = group.parent_group_id ? groupIdToName.get(group.parent_group_id) : '';
|
||||||
|
const pathKey = parentGroupName ? `${parentGroupName}>${groupNameLower}` : groupNameLower;
|
||||||
|
|
||||||
|
// 경로로 기존 메뉴 매칭 시도 (우선순위: 경로 매칭 > 이름 매칭)
|
||||||
|
let matchedMenu = menuByPath.get(pathKey);
|
||||||
|
if (!matchedMenu) {
|
||||||
|
// 경로 매칭 실패시 이름으로 시도 (하위 호환)
|
||||||
|
matchedMenu = menuByName.get(groupNameLower);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedMenu) {
|
||||||
|
// 매칭된 메뉴와 연결
|
||||||
|
const menuObjid = Number(matchedMenu.objid);
|
||||||
|
|
||||||
|
// screen_groups에 menu_objid 업데이트
|
||||||
|
await client.query(
|
||||||
|
`UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`,
|
||||||
|
[menuObjid, groupId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// menu_info에 screen_group_id 업데이트
|
||||||
|
await client.query(
|
||||||
|
`UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`,
|
||||||
|
[groupId, menuObjid]
|
||||||
|
);
|
||||||
|
|
||||||
|
groupToMenuMap.set(groupId, menuObjid);
|
||||||
|
result.linked++;
|
||||||
|
result.details.push({
|
||||||
|
action: 'linked',
|
||||||
|
sourceName: groupName,
|
||||||
|
sourceId: groupId,
|
||||||
|
targetId: menuObjid,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 매칭된 메뉴는 Map에서 제거 (중복 매칭 방지)
|
||||||
|
menuByPath.delete(pathKey);
|
||||||
|
menuByName.delete(groupNameLower);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// 새 메뉴 생성
|
||||||
|
const newObjid = Date.now() + groupId; // 고유 ID 보장
|
||||||
|
|
||||||
|
// 부모 메뉴 objid 결정
|
||||||
|
// 우선순위: groupToMenuMap > parent_menu_objid (존재 확인 필수)
|
||||||
|
let parentMenuObjid = userMenuRootObjid;
|
||||||
|
if (group.parent_group_id && groupToMenuMap.has(group.parent_group_id)) {
|
||||||
|
// 현재 트랜잭션에서 생성된 부모 메뉴 사용
|
||||||
|
parentMenuObjid = groupToMenuMap.get(group.parent_group_id)!;
|
||||||
|
} else if (group.parent_group_id && group.parent_menu_objid) {
|
||||||
|
// 기존 parent_menu_objid가 실제로 존재하는지 확인
|
||||||
|
const parentMenuExists = existingMenuObjids.has(Number(group.parent_menu_objid));
|
||||||
|
if (parentMenuExists) {
|
||||||
|
parentMenuObjid = Number(group.parent_menu_objid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 같은 부모 아래에서 가장 높은 seq 조회 후 +1
|
||||||
|
let nextSeq = 1;
|
||||||
|
const maxSeqQuery = `
|
||||||
|
SELECT COALESCE(MAX(seq), 0) + 1 as next_seq
|
||||||
|
FROM menu_info
|
||||||
|
WHERE parent_obj_id = $1 AND company_code = $2 AND menu_type = 1
|
||||||
|
`;
|
||||||
|
const maxSeqResult = await client.query(maxSeqQuery, [parentMenuObjid, companyCode]);
|
||||||
|
if (maxSeqResult.rows.length > 0) {
|
||||||
|
nextSeq = parseInt(maxSeqResult.rows[0].next_seq) || 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// menu_info에 삽입
|
||||||
|
const insertMenuQuery = `
|
||||||
|
INSERT INTO menu_info (
|
||||||
|
objid, parent_obj_id, menu_name_kor, menu_name_eng,
|
||||||
|
seq, menu_type, company_code, writer, regdate, status, screen_group_id, menu_desc
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'active', $8, $9)
|
||||||
|
RETURNING objid
|
||||||
|
`;
|
||||||
|
await client.query(insertMenuQuery, [
|
||||||
|
newObjid,
|
||||||
|
parentMenuObjid,
|
||||||
|
groupName,
|
||||||
|
group.group_code || groupName,
|
||||||
|
nextSeq,
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
groupId,
|
||||||
|
group.description || null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// screen_groups에 menu_objid 업데이트
|
||||||
|
await client.query(
|
||||||
|
`UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`,
|
||||||
|
[newObjid, groupId]
|
||||||
|
);
|
||||||
|
|
||||||
|
groupToMenuMap.set(groupId, newObjid);
|
||||||
|
result.created++;
|
||||||
|
result.details.push({
|
||||||
|
action: 'created',
|
||||||
|
sourceName: groupName,
|
||||||
|
sourceId: groupId,
|
||||||
|
targetId: newObjid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
logger.info("화면관리 → 메뉴 동기화 완료", {
|
||||||
|
companyCode,
|
||||||
|
created: result.created,
|
||||||
|
linked: result.linked,
|
||||||
|
skipped: result.skipped
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
logger.error("화면관리 → 메뉴 동기화 실패", { companyCode, error: error.message });
|
||||||
|
result.success = false;
|
||||||
|
result.errors.push(error.message);
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 메뉴 → 화면관리 동기화
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* menu_info를 screen_groups로 동기화
|
||||||
|
*
|
||||||
|
* 로직:
|
||||||
|
* 1. 해당 회사의 사용자 메뉴(menu_type=1) 조회
|
||||||
|
* 2. 이미 screen_group_id가 연결된 것은 제외
|
||||||
|
* 3. 이름으로 기존 screen_groups 매칭 시도
|
||||||
|
* - 매칭되면: 양쪽에 연결 ID 업데이트
|
||||||
|
* - 매칭 안되면: screen_groups에 새로 생성 (폴더로)
|
||||||
|
* 4. 계층 구조(parent) 유지
|
||||||
|
*/
|
||||||
|
export async function syncMenuToScreenGroups(
|
||||||
|
companyCode: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<SyncResult> {
|
||||||
|
const result: SyncResult = {
|
||||||
|
success: true,
|
||||||
|
created: 0,
|
||||||
|
linked: 0,
|
||||||
|
skipped: 0,
|
||||||
|
errors: [],
|
||||||
|
details: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
logger.info("메뉴 → 화면관리 동기화 시작", { companyCode, userId });
|
||||||
|
|
||||||
|
// 0. 회사 이름 조회 (회사 폴더 찾기/생성용)
|
||||||
|
const companyNameQuery = `SELECT company_name FROM company_mng WHERE company_code = $1`;
|
||||||
|
const companyNameResult = await client.query(companyNameQuery, [companyCode]);
|
||||||
|
const companyName = companyNameResult.rows[0]?.company_name || companyCode;
|
||||||
|
|
||||||
|
// 1. 해당 회사의 사용자 메뉴 조회 (menu_type=1)
|
||||||
|
const menusQuery = `
|
||||||
|
SELECT
|
||||||
|
m.objid,
|
||||||
|
m.menu_name_kor,
|
||||||
|
m.menu_name_eng,
|
||||||
|
m.parent_obj_id,
|
||||||
|
m.seq,
|
||||||
|
m.menu_url,
|
||||||
|
m.menu_desc,
|
||||||
|
m.screen_group_id,
|
||||||
|
-- 부모 메뉴의 screen_group_id도 조회 (계층 연결용)
|
||||||
|
parent.screen_group_id as parent_screen_group_id
|
||||||
|
FROM menu_info m
|
||||||
|
LEFT JOIN menu_info parent ON m.parent_obj_id = parent.objid
|
||||||
|
WHERE m.company_code = $1 AND m.menu_type = 1
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN m.parent_obj_id = 0 THEN 0 ELSE 1 END,
|
||||||
|
m.parent_obj_id,
|
||||||
|
m.seq
|
||||||
|
`;
|
||||||
|
const menusResult = await client.query(menusQuery, [companyCode]);
|
||||||
|
|
||||||
|
// 2. 해당 회사의 기존 screen_groups 조회 (경로 기반 매칭을 위해 부모 이름도 조회)
|
||||||
|
const existingGroupsQuery = `
|
||||||
|
SELECT
|
||||||
|
g.id,
|
||||||
|
g.group_name,
|
||||||
|
g.menu_objid,
|
||||||
|
g.parent_group_id,
|
||||||
|
p.group_name as parent_name
|
||||||
|
FROM screen_groups g
|
||||||
|
LEFT JOIN screen_groups p ON g.parent_group_id = p.id
|
||||||
|
WHERE g.company_code = $1
|
||||||
|
`;
|
||||||
|
const existingGroupsResult = await client.query(existingGroupsQuery, [companyCode]);
|
||||||
|
|
||||||
|
// 경로(부모이름 > 이름) → 그룹 매핑 (menu_objid가 없는 것만)
|
||||||
|
// 단순 이름 매칭도 유지 (하위 호환)
|
||||||
|
const groupByPath: Map<string, any> = new Map();
|
||||||
|
const groupByName: Map<string, any> = new Map();
|
||||||
|
existingGroupsResult.rows.forEach((group: any) => {
|
||||||
|
if (!group.menu_objid) {
|
||||||
|
const groupName = group.group_name?.trim().toLowerCase() || '';
|
||||||
|
const parentName = group.parent_name?.trim().toLowerCase() || '';
|
||||||
|
const pathKey = parentName ? `${parentName}>${groupName}` : groupName;
|
||||||
|
|
||||||
|
groupByPath.set(pathKey, group);
|
||||||
|
// 단순 이름 매핑은 첫 번째 것만 (중복 방지)
|
||||||
|
if (!groupByName.has(groupName)) {
|
||||||
|
groupByName.set(groupName, group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 모든 그룹의 id 집합 (삭제 확인용)
|
||||||
|
const existingGroupIds = new Set(existingGroupsResult.rows.map((g: any) => Number(g.id)));
|
||||||
|
|
||||||
|
// 3. 회사 폴더 찾기 또는 생성 (루트 레벨에 회사명으로 된 폴더)
|
||||||
|
let companyFolderId: number | null = null;
|
||||||
|
const companyFolderQuery = `
|
||||||
|
SELECT id FROM screen_groups
|
||||||
|
WHERE company_code = $1 AND parent_group_id IS NULL AND group_level = 0
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
const companyFolderResult = await client.query(companyFolderQuery, [companyCode]);
|
||||||
|
|
||||||
|
if (companyFolderResult.rows.length > 0) {
|
||||||
|
companyFolderId = companyFolderResult.rows[0].id;
|
||||||
|
logger.info("회사 폴더 발견", { companyCode, companyFolderId, companyName });
|
||||||
|
} else {
|
||||||
|
// 회사 폴더가 없으면 생성
|
||||||
|
// 루트 레벨에서 가장 높은 display_order 조회 후 +1
|
||||||
|
let nextRootOrder = 1;
|
||||||
|
const maxRootOrderQuery = `
|
||||||
|
SELECT COALESCE(MAX(display_order), 0) + 1 as next_order
|
||||||
|
FROM screen_groups
|
||||||
|
WHERE parent_group_id IS NULL
|
||||||
|
`;
|
||||||
|
const maxRootOrderResult = await client.query(maxRootOrderQuery);
|
||||||
|
if (maxRootOrderResult.rows.length > 0) {
|
||||||
|
nextRootOrder = parseInt(maxRootOrderResult.rows[0].next_order) || 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createFolderQuery = `
|
||||||
|
INSERT INTO screen_groups (
|
||||||
|
group_name, group_code, parent_group_id, group_level,
|
||||||
|
display_order, company_code, writer, hierarchy_path
|
||||||
|
) VALUES ($1, $2, NULL, 0, $3, $4, $5, '/')
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
const createFolderResult = await client.query(createFolderQuery, [
|
||||||
|
companyName,
|
||||||
|
companyCode.toLowerCase(),
|
||||||
|
nextRootOrder,
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
]);
|
||||||
|
companyFolderId = createFolderResult.rows[0].id;
|
||||||
|
|
||||||
|
// hierarchy_path 업데이트
|
||||||
|
await client.query(
|
||||||
|
`UPDATE screen_groups SET hierarchy_path = $1 WHERE id = $2`,
|
||||||
|
[`/${companyFolderId}/`, companyFolderId]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("회사 폴더 생성", { companyCode, companyFolderId, companyName });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. menu_objid → screen_group_id 매핑 (순차 처리를 위해)
|
||||||
|
const menuToGroupMap: Map<number, number> = new Map();
|
||||||
|
|
||||||
|
// 부모 메뉴 중 이미 screen_group_id가 있는 것 등록
|
||||||
|
menusResult.rows.forEach((menu: any) => {
|
||||||
|
if (menu.screen_group_id) {
|
||||||
|
menuToGroupMap.set(Number(menu.objid), Number(menu.screen_group_id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 루트 메뉴(parent_obj_id = 0)의 objid 찾기 → 회사 폴더와 매핑
|
||||||
|
let rootMenuObjid: number | null = null;
|
||||||
|
for (const menu of menusResult.rows) {
|
||||||
|
if (Number(menu.parent_obj_id) === 0) {
|
||||||
|
rootMenuObjid = Number(menu.objid);
|
||||||
|
// 루트 메뉴는 회사 폴더와 연결
|
||||||
|
if (companyFolderId) {
|
||||||
|
menuToGroupMap.set(rootMenuObjid, companyFolderId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 각 메뉴 처리
|
||||||
|
for (const menu of menusResult.rows) {
|
||||||
|
const menuObjid = Number(menu.objid);
|
||||||
|
const menuName = menu.menu_name_kor?.trim();
|
||||||
|
|
||||||
|
// 루트 메뉴(parent_obj_id = 0)는 스킵 (이미 회사 폴더와 매핑됨)
|
||||||
|
if (Number(menu.parent_obj_id) === 0) {
|
||||||
|
result.skipped++;
|
||||||
|
result.details.push({
|
||||||
|
action: 'skipped',
|
||||||
|
sourceName: menuName,
|
||||||
|
sourceId: menuObjid,
|
||||||
|
targetId: companyFolderId || undefined,
|
||||||
|
reason: '루트 메뉴 → 회사 폴더와 매핑됨',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 연결된 경우 - 실제로 그룹이 존재하는지 확인
|
||||||
|
if (menu.screen_group_id) {
|
||||||
|
const groupExists = existingGroupIds.has(Number(menu.screen_group_id));
|
||||||
|
|
||||||
|
if (groupExists) {
|
||||||
|
// 그룹이 존재하면 스킵
|
||||||
|
result.skipped++;
|
||||||
|
result.details.push({
|
||||||
|
action: 'skipped',
|
||||||
|
sourceName: menuName,
|
||||||
|
sourceId: menuObjid,
|
||||||
|
targetId: menu.screen_group_id,
|
||||||
|
reason: '이미 화면그룹과 연결됨',
|
||||||
|
});
|
||||||
|
menuToGroupMap.set(menuObjid, Number(menu.screen_group_id));
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
// 그룹이 삭제되었으면 연결 해제하고 재생성
|
||||||
|
logger.info("삭제된 그룹 연결 해제", { menuObjid, deletedGroupId: menu.screen_group_id });
|
||||||
|
await client.query(
|
||||||
|
`UPDATE menu_info SET screen_group_id = NULL WHERE objid = $1`,
|
||||||
|
[menuObjid]
|
||||||
|
);
|
||||||
|
// 계속 진행하여 재생성 또는 재연결
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuNameLower = menuName?.toLowerCase() || '';
|
||||||
|
|
||||||
|
// 부모 메뉴 이름 조회 (경로 기반 매칭용)
|
||||||
|
const parentMenu = menusResult.rows.find((m: any) => Number(m.objid) === Number(menu.parent_obj_id));
|
||||||
|
const parentMenuName = parentMenu?.menu_name_kor?.trim().toLowerCase() || '';
|
||||||
|
const pathKey = parentMenuName ? `${parentMenuName}>${menuNameLower}` : menuNameLower;
|
||||||
|
|
||||||
|
// 경로로 기존 그룹 매칭 시도 (우선순위: 경로 매칭 > 이름 매칭)
|
||||||
|
let matchedGroup = groupByPath.get(pathKey);
|
||||||
|
if (!matchedGroup) {
|
||||||
|
// 경로 매칭 실패시 이름으로 시도 (하위 호환)
|
||||||
|
matchedGroup = groupByName.get(menuNameLower);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedGroup) {
|
||||||
|
// 매칭된 그룹과 연결
|
||||||
|
const groupId = Number(matchedGroup.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// menu_info에 screen_group_id 업데이트
|
||||||
|
await client.query(
|
||||||
|
`UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`,
|
||||||
|
[groupId, menuObjid]
|
||||||
|
);
|
||||||
|
|
||||||
|
// screen_groups에 menu_objid 업데이트
|
||||||
|
await client.query(
|
||||||
|
`UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`,
|
||||||
|
[menuObjid, groupId]
|
||||||
|
);
|
||||||
|
|
||||||
|
menuToGroupMap.set(menuObjid, groupId);
|
||||||
|
result.linked++;
|
||||||
|
result.details.push({
|
||||||
|
action: 'linked',
|
||||||
|
sourceName: menuName,
|
||||||
|
sourceId: menuObjid,
|
||||||
|
targetId: groupId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 매칭된 그룹은 Map에서 제거 (중복 매칭 방지)
|
||||||
|
groupByPath.delete(pathKey);
|
||||||
|
groupByName.delete(menuNameLower);
|
||||||
|
} catch (linkError: any) {
|
||||||
|
logger.error("그룹 연결 중 에러", { menuName, menuObjid, groupId, error: linkError.message, stack: linkError.stack });
|
||||||
|
throw linkError;
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// 새 screen_group 생성
|
||||||
|
// 부모 그룹 ID 결정
|
||||||
|
let parentGroupId: number | null = null;
|
||||||
|
let groupLevel = 1; // 기본값은 1 (회사 폴더 아래)
|
||||||
|
|
||||||
|
// 우선순위 1: menuToGroupMap에서 부모 메뉴의 새 그룹 ID 조회 (같은 트랜잭션에서 생성된 것)
|
||||||
|
if (menuToGroupMap.has(Number(menu.parent_obj_id))) {
|
||||||
|
parentGroupId = menuToGroupMap.get(Number(menu.parent_obj_id))!;
|
||||||
|
}
|
||||||
|
// 우선순위 2: 부모 메뉴가 루트 메뉴면 회사 폴더 사용
|
||||||
|
else if (Number(menu.parent_obj_id) === rootMenuObjid) {
|
||||||
|
parentGroupId = companyFolderId;
|
||||||
|
}
|
||||||
|
// 우선순위 3: 부모 메뉴의 screen_group_id가 있고, 해당 그룹이 실제로 존재하면 사용
|
||||||
|
else if (menu.parent_screen_group_id && existingGroupIds.has(Number(menu.parent_screen_group_id))) {
|
||||||
|
parentGroupId = Number(menu.parent_screen_group_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부모 그룹의 레벨 조회
|
||||||
|
if (parentGroupId) {
|
||||||
|
const parentLevelQuery = `SELECT group_level FROM screen_groups WHERE id = $1`;
|
||||||
|
const parentLevelResult = await client.query(parentLevelQuery, [parentGroupId]);
|
||||||
|
if (parentLevelResult.rows.length > 0) {
|
||||||
|
groupLevel = (parentLevelResult.rows[0].group_level || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 같은 부모 아래에서 가장 높은 display_order 조회 후 +1
|
||||||
|
let nextDisplayOrder = 1;
|
||||||
|
const maxOrderQuery = parentGroupId
|
||||||
|
? `SELECT COALESCE(MAX(display_order), 0) + 1 as next_order FROM screen_groups WHERE parent_group_id = $1 AND company_code = $2`
|
||||||
|
: `SELECT COALESCE(MAX(display_order), 0) + 1 as next_order FROM screen_groups WHERE parent_group_id IS NULL AND company_code = $1`;
|
||||||
|
const maxOrderParams = parentGroupId ? [parentGroupId, companyCode] : [companyCode];
|
||||||
|
const maxOrderResult = await client.query(maxOrderQuery, maxOrderParams);
|
||||||
|
if (maxOrderResult.rows.length > 0) {
|
||||||
|
nextDisplayOrder = parseInt(maxOrderResult.rows[0].next_order) || 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// group_code 생성 (영문명 또는 이름 기반)
|
||||||
|
const groupCode = (menu.menu_name_eng || menuName || 'group')
|
||||||
|
.replace(/\s+/g, '_')
|
||||||
|
.toLowerCase()
|
||||||
|
.substring(0, 50);
|
||||||
|
|
||||||
|
// screen_groups에 삽입
|
||||||
|
const insertGroupQuery = `
|
||||||
|
INSERT INTO screen_groups (
|
||||||
|
group_name, group_code, parent_group_id, group_level,
|
||||||
|
display_order, company_code, writer, menu_objid, description
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
|
||||||
|
let newGroupId: number;
|
||||||
|
try {
|
||||||
|
logger.info("새 그룹 생성 시도", {
|
||||||
|
menuName,
|
||||||
|
menuObjid,
|
||||||
|
groupCode: groupCode + '_' + menuObjid,
|
||||||
|
parentGroupId,
|
||||||
|
groupLevel,
|
||||||
|
nextDisplayOrder,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const insertResult = await client.query(insertGroupQuery, [
|
||||||
|
menuName,
|
||||||
|
groupCode + '_' + menuObjid, // 고유성 보장
|
||||||
|
parentGroupId,
|
||||||
|
groupLevel,
|
||||||
|
nextDisplayOrder,
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
menuObjid,
|
||||||
|
menu.menu_desc || null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
newGroupId = insertResult.rows[0].id;
|
||||||
|
} catch (insertError: any) {
|
||||||
|
logger.error("그룹 생성 중 에러", {
|
||||||
|
menuName,
|
||||||
|
menuObjid,
|
||||||
|
parentGroupId,
|
||||||
|
groupLevel,
|
||||||
|
error: insertError.message,
|
||||||
|
stack: insertError.stack,
|
||||||
|
code: insertError.code,
|
||||||
|
detail: insertError.detail,
|
||||||
|
});
|
||||||
|
throw insertError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// hierarchy_path 업데이트
|
||||||
|
let hierarchyPath = `/${newGroupId}/`;
|
||||||
|
if (parentGroupId) {
|
||||||
|
const parentPathQuery = `SELECT hierarchy_path FROM screen_groups WHERE id = $1`;
|
||||||
|
const parentPathResult = await client.query(parentPathQuery, [parentGroupId]);
|
||||||
|
if (parentPathResult.rows.length > 0 && parentPathResult.rows[0].hierarchy_path) {
|
||||||
|
hierarchyPath = `${parentPathResult.rows[0].hierarchy_path}${newGroupId}/`.replace('//', '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await client.query(
|
||||||
|
`UPDATE screen_groups SET hierarchy_path = $1 WHERE id = $2`,
|
||||||
|
[hierarchyPath, newGroupId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// menu_info에 screen_group_id 업데이트
|
||||||
|
await client.query(
|
||||||
|
`UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`,
|
||||||
|
[newGroupId, menuObjid]
|
||||||
|
);
|
||||||
|
|
||||||
|
menuToGroupMap.set(menuObjid, newGroupId);
|
||||||
|
result.created++;
|
||||||
|
result.details.push({
|
||||||
|
action: 'created',
|
||||||
|
sourceName: menuName,
|
||||||
|
sourceId: menuObjid,
|
||||||
|
targetId: newGroupId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
logger.info("메뉴 → 화면관리 동기화 완료", {
|
||||||
|
companyCode,
|
||||||
|
created: result.created,
|
||||||
|
linked: result.linked,
|
||||||
|
skipped: result.skipped
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
logger.error("메뉴 → 화면관리 동기화 실패", {
|
||||||
|
companyCode,
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
code: error.code,
|
||||||
|
detail: error.detail,
|
||||||
|
});
|
||||||
|
result.success = false;
|
||||||
|
result.errors.push(error.message);
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 동기화 상태 조회
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 동기화 상태 조회
|
||||||
|
*
|
||||||
|
* - 연결된 항목 수
|
||||||
|
* - 연결 안 된 항목 수
|
||||||
|
* - 양방향 비교
|
||||||
|
*/
|
||||||
|
export async function getSyncStatus(companyCode: string): Promise<{
|
||||||
|
screenGroups: { total: number; linked: number; unlinked: number };
|
||||||
|
menuItems: { total: number; linked: number; unlinked: number };
|
||||||
|
potentialMatches: Array<{ menuName: string; groupName: string; similarity: string }>;
|
||||||
|
}> {
|
||||||
|
// screen_groups 상태
|
||||||
|
const sgQuery = `
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
COUNT(menu_objid) as linked
|
||||||
|
FROM screen_groups
|
||||||
|
WHERE company_code = $1
|
||||||
|
`;
|
||||||
|
const sgResult = await pool.query(sgQuery, [companyCode]);
|
||||||
|
|
||||||
|
// menu_info 상태 (사용자 메뉴만, 루트 제외)
|
||||||
|
const menuQuery = `
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
COUNT(screen_group_id) as linked
|
||||||
|
FROM menu_info
|
||||||
|
WHERE company_code = $1 AND menu_type = 1 AND parent_obj_id != 0
|
||||||
|
`;
|
||||||
|
const menuResult = await pool.query(menuQuery, [companyCode]);
|
||||||
|
|
||||||
|
// 이름이 같은 잠재적 매칭 후보 조회
|
||||||
|
const matchQuery = `
|
||||||
|
SELECT
|
||||||
|
m.menu_name_kor as menu_name,
|
||||||
|
sg.group_name
|
||||||
|
FROM menu_info m
|
||||||
|
JOIN screen_groups sg ON LOWER(TRIM(m.menu_name_kor)) = LOWER(TRIM(sg.group_name))
|
||||||
|
WHERE m.company_code = $1
|
||||||
|
AND sg.company_code = $1
|
||||||
|
AND m.menu_type = 1
|
||||||
|
AND m.screen_group_id IS NULL
|
||||||
|
AND sg.menu_objid IS NULL
|
||||||
|
LIMIT 10
|
||||||
|
`;
|
||||||
|
const matchResult = await pool.query(matchQuery, [companyCode]);
|
||||||
|
|
||||||
|
const sgTotal = parseInt(sgResult.rows[0].total);
|
||||||
|
const sgLinked = parseInt(sgResult.rows[0].linked);
|
||||||
|
const menuTotal = parseInt(menuResult.rows[0].total);
|
||||||
|
const menuLinked = parseInt(menuResult.rows[0].linked);
|
||||||
|
|
||||||
|
return {
|
||||||
|
screenGroups: {
|
||||||
|
total: sgTotal,
|
||||||
|
linked: sgLinked,
|
||||||
|
unlinked: sgTotal - sgLinked,
|
||||||
|
},
|
||||||
|
menuItems: {
|
||||||
|
total: menuTotal,
|
||||||
|
linked: menuLinked,
|
||||||
|
unlinked: menuTotal - menuLinked,
|
||||||
|
},
|
||||||
|
potentialMatches: matchResult.rows.map((row: any) => ({
|
||||||
|
menuName: row.menu_name,
|
||||||
|
groupName: row.group_name,
|
||||||
|
similarity: 'exact',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 전체 동기화 (모든 회사)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
interface AllCompaniesSyncResult {
|
||||||
|
success: boolean;
|
||||||
|
totalCompanies: number;
|
||||||
|
successCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
results: Array<{
|
||||||
|
companyCode: string;
|
||||||
|
companyName: string;
|
||||||
|
direction: 'screens-to-menus' | 'menus-to-screens';
|
||||||
|
created: number;
|
||||||
|
linked: number;
|
||||||
|
skipped: number;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 회사에 대해 양방향 동기화 수행
|
||||||
|
*
|
||||||
|
* 로직:
|
||||||
|
* 1. 모든 회사 조회
|
||||||
|
* 2. 각 회사별로 양방향 동기화 수행
|
||||||
|
* - 화면관리 → 메뉴 동기화
|
||||||
|
* - 메뉴 → 화면관리 동기화
|
||||||
|
* 3. 결과 집계
|
||||||
|
*/
|
||||||
|
export async function syncAllCompanies(
|
||||||
|
userId: string
|
||||||
|
): Promise<AllCompaniesSyncResult> {
|
||||||
|
const result: AllCompaniesSyncResult = {
|
||||||
|
success: true,
|
||||||
|
totalCompanies: 0,
|
||||||
|
successCount: 0,
|
||||||
|
failedCount: 0,
|
||||||
|
results: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info("전체 동기화 시작", { userId });
|
||||||
|
|
||||||
|
// 모든 회사 조회 (최고 관리자 전용 회사 제외)
|
||||||
|
const companiesQuery = `
|
||||||
|
SELECT company_code, company_name
|
||||||
|
FROM company_mng
|
||||||
|
WHERE company_code != '*'
|
||||||
|
ORDER BY company_name
|
||||||
|
`;
|
||||||
|
const companiesResult = await pool.query(companiesQuery);
|
||||||
|
|
||||||
|
result.totalCompanies = companiesResult.rows.length;
|
||||||
|
|
||||||
|
// 각 회사별로 양방향 동기화
|
||||||
|
for (const company of companiesResult.rows) {
|
||||||
|
const companyCode = company.company_code;
|
||||||
|
const companyName = company.company_name;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 화면관리 → 메뉴 동기화
|
||||||
|
const screensToMenusResult = await syncScreenGroupsToMenu(companyCode, userId);
|
||||||
|
result.results.push({
|
||||||
|
companyCode,
|
||||||
|
companyName,
|
||||||
|
direction: 'screens-to-menus',
|
||||||
|
created: screensToMenusResult.created,
|
||||||
|
linked: screensToMenusResult.linked,
|
||||||
|
skipped: screensToMenusResult.skipped,
|
||||||
|
success: screensToMenusResult.success,
|
||||||
|
error: screensToMenusResult.errors.length > 0 ? screensToMenusResult.errors.join(', ') : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 메뉴 → 화면관리 동기화
|
||||||
|
const menusToScreensResult = await syncMenuToScreenGroups(companyCode, userId);
|
||||||
|
result.results.push({
|
||||||
|
companyCode,
|
||||||
|
companyName,
|
||||||
|
direction: 'menus-to-screens',
|
||||||
|
created: menusToScreensResult.created,
|
||||||
|
linked: menusToScreensResult.linked,
|
||||||
|
skipped: menusToScreensResult.skipped,
|
||||||
|
success: menusToScreensResult.success,
|
||||||
|
error: menusToScreensResult.errors.length > 0 ? menusToScreensResult.errors.join(', ') : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (screensToMenusResult.success && menusToScreensResult.success) {
|
||||||
|
result.successCount++;
|
||||||
|
} else {
|
||||||
|
result.failedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("회사 동기화 실패", { companyCode, companyName, error: error.message });
|
||||||
|
result.results.push({
|
||||||
|
companyCode,
|
||||||
|
companyName,
|
||||||
|
direction: 'screens-to-menus',
|
||||||
|
created: 0,
|
||||||
|
linked: 0,
|
||||||
|
skipped: 0,
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
result.failedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("전체 동기화 완료", {
|
||||||
|
totalCompanies: result.totalCompanies,
|
||||||
|
successCount: result.successCount,
|
||||||
|
failedCount: result.failedCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("전체 동기화 실패", { error: error.message });
|
||||||
|
result.success = false;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -969,21 +969,56 @@ export class NodeFlowExecutionService {
|
||||||
const insertedData = { ...data };
|
const insertedData = { ...data };
|
||||||
|
|
||||||
console.log("🗺️ 필드 매핑 처리 중...");
|
console.log("🗺️ 필드 매핑 처리 중...");
|
||||||
fieldMappings.forEach((mapping: any) => {
|
|
||||||
|
// 🔥 채번 규칙 서비스 동적 import
|
||||||
|
const { numberingRuleService } = await import("./numberingRuleService");
|
||||||
|
|
||||||
|
for (const mapping of fieldMappings) {
|
||||||
fields.push(mapping.targetField);
|
fields.push(mapping.targetField);
|
||||||
const value =
|
let value: any;
|
||||||
mapping.staticValue !== undefined
|
|
||||||
? mapping.staticValue
|
// 🔥 값 생성 유형에 따른 처리
|
||||||
: data[mapping.sourceField];
|
const valueType = mapping.valueType || (mapping.staticValue !== undefined ? "static" : "source");
|
||||||
|
|
||||||
console.log(
|
if (valueType === "autoGenerate" && mapping.numberingRuleId) {
|
||||||
` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}`
|
// 자동 생성 (채번 규칙)
|
||||||
);
|
const companyCode = context.buttonContext?.companyCode || "*";
|
||||||
|
try {
|
||||||
|
value = await numberingRuleService.allocateCode(
|
||||||
|
mapping.numberingRuleId,
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` 🔢 자동 생성(채번): ${mapping.targetField} = ${value} (규칙: ${mapping.numberingRuleId})`
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`채번 규칙 적용 실패: ${error.message}`);
|
||||||
|
console.error(
|
||||||
|
` ❌ 채번 실패 → ${mapping.targetField}: ${error.message}`
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`채번 규칙 '${mapping.numberingRuleName || mapping.numberingRuleId}' 적용 실패: ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (valueType === "static" || mapping.staticValue !== undefined) {
|
||||||
|
// 고정값
|
||||||
|
value = mapping.staticValue;
|
||||||
|
console.log(
|
||||||
|
` 📌 고정값: ${mapping.targetField} = ${value}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 소스 필드
|
||||||
|
value = data[mapping.sourceField];
|
||||||
|
console.log(
|
||||||
|
` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
values.push(value);
|
values.push(value);
|
||||||
|
|
||||||
// 🔥 삽입된 값을 데이터에 반영
|
// 🔥 삽입된 값을 데이터에 반영
|
||||||
insertedData[mapping.targetField] = value;
|
insertedData[mapping.targetField] = value;
|
||||||
});
|
}
|
||||||
|
|
||||||
// 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우)
|
// 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우)
|
||||||
const hasWriterMapping = fieldMappings.some(
|
const hasWriterMapping = fieldMappings.some(
|
||||||
|
|
@ -1528,16 +1563,24 @@ export class NodeFlowExecutionService {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🔑 Primary Key 자동 추가 (context-data 모드)
|
// 🔑 Primary Key 자동 추가 여부 결정:
|
||||||
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
// whereConditions가 명시적으로 설정되어 있으면 PK 자동 추가를 하지 않음
|
||||||
const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK(
|
// (사용자가 직접 조건을 설정한 경우 의도를 존중)
|
||||||
whereConditions,
|
let finalWhereConditions: any[];
|
||||||
data,
|
if (whereConditions && whereConditions.length > 0) {
|
||||||
targetTable
|
console.log("📋 사용자 정의 WHERE 조건 사용 (PK 자동 추가 안 함)");
|
||||||
);
|
finalWhereConditions = whereConditions;
|
||||||
|
} else {
|
||||||
|
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
||||||
|
finalWhereConditions = await this.enhanceWhereConditionsWithPK(
|
||||||
|
whereConditions,
|
||||||
|
data,
|
||||||
|
targetTable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const whereResult = this.buildWhereClause(
|
const whereResult = this.buildWhereClause(
|
||||||
enhancedWhereConditions,
|
finalWhereConditions,
|
||||||
data,
|
data,
|
||||||
paramIndex
|
paramIndex
|
||||||
);
|
);
|
||||||
|
|
@ -1907,22 +1950,30 @@ export class NodeFlowExecutionService {
|
||||||
return deletedDataArray;
|
return deletedDataArray;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 context-data 모드: 개별 삭제 (PK 자동 추가)
|
// 🆕 context-data 모드: 개별 삭제
|
||||||
console.log("🎯 context-data 모드: 개별 삭제 시작");
|
console.log("🎯 context-data 모드: 개별 삭제 시작");
|
||||||
|
|
||||||
for (const data of dataArray) {
|
for (const data of dataArray) {
|
||||||
console.log("🔍 WHERE 조건 처리 중...");
|
console.log("🔍 WHERE 조건 처리 중...");
|
||||||
|
|
||||||
// 🔑 Primary Key 자동 추가 (context-data 모드)
|
// 🔑 Primary Key 자동 추가 여부 결정:
|
||||||
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
// whereConditions가 명시적으로 설정되어 있으면 PK 자동 추가를 하지 않음
|
||||||
const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK(
|
// (사용자가 직접 조건을 설정한 경우 의도를 존중)
|
||||||
whereConditions,
|
let finalWhereConditions: any[];
|
||||||
data,
|
if (whereConditions && whereConditions.length > 0) {
|
||||||
targetTable
|
console.log("📋 사용자 정의 WHERE 조건 사용 (PK 자동 추가 안 함)");
|
||||||
);
|
finalWhereConditions = whereConditions;
|
||||||
|
} else {
|
||||||
|
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
||||||
|
finalWhereConditions = await this.enhanceWhereConditionsWithPK(
|
||||||
|
whereConditions,
|
||||||
|
data,
|
||||||
|
targetTable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const whereResult = this.buildWhereClause(
|
const whereResult = this.buildWhereClause(
|
||||||
enhancedWhereConditions,
|
finalWhereConditions,
|
||||||
data,
|
data,
|
||||||
1
|
1
|
||||||
);
|
);
|
||||||
|
|
@ -2282,6 +2333,7 @@ export class NodeFlowExecutionService {
|
||||||
UPDATE ${targetTable}
|
UPDATE ${targetTable}
|
||||||
SET ${setClauses.join(", ")}
|
SET ${setClauses.join(", ")}
|
||||||
WHERE ${updateWhereConditions}
|
WHERE ${updateWhereConditions}
|
||||||
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
logger.info(`🔄 UPDATE 실행:`, {
|
logger.info(`🔄 UPDATE 실행:`, {
|
||||||
|
|
@ -2292,8 +2344,14 @@ export class NodeFlowExecutionService {
|
||||||
values: updateValues,
|
values: updateValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
await txClient.query(updateSql, updateValues);
|
const updateResult = await txClient.query(updateSql, updateValues);
|
||||||
updatedCount++;
|
updatedCount++;
|
||||||
|
|
||||||
|
// 🆕 UPDATE 결과를 입력 데이터에 병합 (다음 노드에서 id 등 사용 가능)
|
||||||
|
if (updateResult.rows && updateResult.rows[0]) {
|
||||||
|
Object.assign(data, updateResult.rows[0]);
|
||||||
|
logger.info(` 📦 UPDATE 결과 병합: id=${updateResult.rows[0].id}`);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 3-B. 없으면 INSERT
|
// 3-B. 없으면 INSERT
|
||||||
const columns: string[] = [];
|
const columns: string[] = [];
|
||||||
|
|
@ -2340,6 +2398,7 @@ export class NodeFlowExecutionService {
|
||||||
const insertSql = `
|
const insertSql = `
|
||||||
INSERT INTO ${targetTable} (${columns.join(", ")})
|
INSERT INTO ${targetTable} (${columns.join(", ")})
|
||||||
VALUES (${placeholders})
|
VALUES (${placeholders})
|
||||||
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
logger.info(`➕ INSERT 실행:`, {
|
logger.info(`➕ INSERT 실행:`, {
|
||||||
|
|
@ -2348,8 +2407,14 @@ export class NodeFlowExecutionService {
|
||||||
conflictKeyValues,
|
conflictKeyValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
await txClient.query(insertSql, values);
|
const insertResult = await txClient.query(insertSql, values);
|
||||||
insertedCount++;
|
insertedCount++;
|
||||||
|
|
||||||
|
// 🆕 INSERT 결과를 입력 데이터에 병합 (다음 노드에서 id 등 사용 가능)
|
||||||
|
if (insertResult.rows && insertResult.rows[0]) {
|
||||||
|
Object.assign(data, insertResult.rows[0]);
|
||||||
|
logger.info(` 📦 INSERT 결과 병합: id=${insertResult.rows[0].id}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2357,11 +2422,10 @@ export class NodeFlowExecutionService {
|
||||||
`✅ UPSERT 완료 (내부 DB): ${targetTable}, INSERT ${insertedCount}건, UPDATE ${updatedCount}건`
|
`✅ UPSERT 완료 (내부 DB): ${targetTable}, INSERT ${insertedCount}건, UPDATE ${updatedCount}건`
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
// 🔥 다음 노드에 전달할 데이터 반환
|
||||||
insertedCount,
|
// dataArray에는 Object.assign으로 UPSERT 결과(id 등)가 이미 병합되어 있음
|
||||||
updatedCount,
|
// 카운트 정보도 함께 반환하여 기존 호환성 유지
|
||||||
totalCount: insertedCount + updatedCount,
|
return dataArray;
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 🔥 클라이언트가 전달되었으면 사용, 아니면 독립 트랜잭션 생성
|
// 🔥 클라이언트가 전달되었으면 사용, 아니면 독립 트랜잭션 생성
|
||||||
|
|
@ -2707,28 +2771,48 @@ export class NodeFlowExecutionService {
|
||||||
const trueData: any[] = [];
|
const trueData: any[] = [];
|
||||||
const falseData: any[] = [];
|
const falseData: any[] = [];
|
||||||
|
|
||||||
inputData.forEach((item: any) => {
|
// 배열의 각 항목에 대해 조건 평가 (EXISTS 조건은 비동기)
|
||||||
const results = conditions.map((condition: any) => {
|
for (const item of inputData) {
|
||||||
|
const results: boolean[] = [];
|
||||||
|
|
||||||
|
for (const condition of conditions) {
|
||||||
const fieldValue = item[condition.field];
|
const fieldValue = item[condition.field];
|
||||||
|
|
||||||
let compareValue = condition.value;
|
// EXISTS 계열 연산자 처리
|
||||||
if (condition.valueType === "field") {
|
if (
|
||||||
compareValue = item[condition.value];
|
condition.operator === "EXISTS_IN" ||
|
||||||
|
condition.operator === "NOT_EXISTS_IN"
|
||||||
|
) {
|
||||||
|
const existsResult = await this.evaluateExistsCondition(
|
||||||
|
fieldValue,
|
||||||
|
condition.operator,
|
||||||
|
condition.lookupTable,
|
||||||
|
condition.lookupField,
|
||||||
|
context.buttonContext?.companyCode
|
||||||
|
);
|
||||||
|
results.push(existsResult);
|
||||||
logger.info(
|
logger.info(
|
||||||
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
|
`🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
logger.info(
|
// 일반 연산자 처리
|
||||||
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
|
let compareValue = condition.value;
|
||||||
|
if (condition.valueType === "field") {
|
||||||
|
compareValue = item[condition.value];
|
||||||
|
logger.info(
|
||||||
|
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(
|
||||||
|
this.evaluateCondition(fieldValue, condition.operator, compareValue)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return this.evaluateCondition(
|
|
||||||
fieldValue,
|
|
||||||
condition.operator,
|
|
||||||
compareValue
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const result =
|
const result =
|
||||||
logic === "OR"
|
logic === "OR"
|
||||||
|
|
@ -2740,7 +2824,7 @@ export class NodeFlowExecutionService {
|
||||||
} else {
|
} else {
|
||||||
falseData.push(item);
|
falseData.push(item);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`🔍 조건 필터링 결과: TRUE ${trueData.length}건 / FALSE ${falseData.length}건 (${logic} 로직)`
|
`🔍 조건 필터링 결과: TRUE ${trueData.length}건 / FALSE ${falseData.length}건 (${logic} 로직)`
|
||||||
|
|
@ -2755,27 +2839,46 @@ export class NodeFlowExecutionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 단일 객체인 경우
|
// 단일 객체인 경우
|
||||||
const results = conditions.map((condition: any) => {
|
const results: boolean[] = [];
|
||||||
|
|
||||||
|
for (const condition of conditions) {
|
||||||
const fieldValue = inputData[condition.field];
|
const fieldValue = inputData[condition.field];
|
||||||
|
|
||||||
let compareValue = condition.value;
|
// EXISTS 계열 연산자 처리
|
||||||
if (condition.valueType === "field") {
|
if (
|
||||||
compareValue = inputData[condition.value];
|
condition.operator === "EXISTS_IN" ||
|
||||||
|
condition.operator === "NOT_EXISTS_IN"
|
||||||
|
) {
|
||||||
|
const existsResult = await this.evaluateExistsCondition(
|
||||||
|
fieldValue,
|
||||||
|
condition.operator,
|
||||||
|
condition.lookupTable,
|
||||||
|
condition.lookupField,
|
||||||
|
context.buttonContext?.companyCode
|
||||||
|
);
|
||||||
|
results.push(existsResult);
|
||||||
logger.info(
|
logger.info(
|
||||||
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
|
`🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
logger.info(
|
// 일반 연산자 처리
|
||||||
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
|
let compareValue = condition.value;
|
||||||
|
if (condition.valueType === "field") {
|
||||||
|
compareValue = inputData[condition.value];
|
||||||
|
logger.info(
|
||||||
|
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(
|
||||||
|
this.evaluateCondition(fieldValue, condition.operator, compareValue)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return this.evaluateCondition(
|
|
||||||
fieldValue,
|
|
||||||
condition.operator,
|
|
||||||
compareValue
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const result =
|
const result =
|
||||||
logic === "OR"
|
logic === "OR"
|
||||||
|
|
@ -2784,7 +2887,7 @@ export class NodeFlowExecutionService {
|
||||||
|
|
||||||
logger.info(`🔍 조건 평가 결과: ${result} (${logic} 로직)`);
|
logger.info(`🔍 조건 평가 결과: ${result} (${logic} 로직)`);
|
||||||
|
|
||||||
// ⚠️ 조건 노드는 TRUE/FALSE 브랜치를 위한 특별한 처리 필요
|
// 조건 노드는 TRUE/FALSE 브랜치를 위한 특별한 처리 필요
|
||||||
// 조건 결과를 저장하고, 원본 데이터는 항상 반환
|
// 조건 결과를 저장하고, 원본 데이터는 항상 반환
|
||||||
// 다음 노드에서 sourceHandle을 기반으로 필터링됨
|
// 다음 노드에서 sourceHandle을 기반으로 필터링됨
|
||||||
return {
|
return {
|
||||||
|
|
@ -2795,6 +2898,69 @@ export class NodeFlowExecutionService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EXISTS_IN / NOT_EXISTS_IN 조건 평가
|
||||||
|
* 다른 테이블에 값이 존재하는지 확인
|
||||||
|
*/
|
||||||
|
private static async evaluateExistsCondition(
|
||||||
|
fieldValue: any,
|
||||||
|
operator: string,
|
||||||
|
lookupTable: string,
|
||||||
|
lookupField: string,
|
||||||
|
companyCode?: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!lookupTable || !lookupField) {
|
||||||
|
logger.warn("⚠️ EXISTS 조건: lookupTable 또는 lookupField가 없습니다");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldValue === null || fieldValue === undefined || fieldValue === "") {
|
||||||
|
logger.info(
|
||||||
|
`⚠️ EXISTS 조건: 필드값이 비어있어 FALSE 반환 (빈 값은 조건 검사하지 않음)`
|
||||||
|
);
|
||||||
|
// 값이 비어있으면 조건 검사 자체가 무의미하므로 항상 false 반환
|
||||||
|
// 이렇게 하면 빈 값으로 인한 의도치 않은 INSERT/UPDATE/DELETE가 방지됨
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 멀티테넌시: company_code 필터 적용 여부 확인
|
||||||
|
// company_mng 테이블은 제외
|
||||||
|
const hasCompanyCode = lookupTable !== "company_mng" && companyCode;
|
||||||
|
|
||||||
|
let sql: string;
|
||||||
|
let params: any[];
|
||||||
|
|
||||||
|
if (hasCompanyCode) {
|
||||||
|
sql = `SELECT EXISTS(SELECT 1 FROM "${lookupTable}" WHERE "${lookupField}" = $1 AND company_code = $2) as exists_result`;
|
||||||
|
params = [fieldValue, companyCode];
|
||||||
|
} else {
|
||||||
|
sql = `SELECT EXISTS(SELECT 1 FROM "${lookupTable}" WHERE "${lookupField}" = $1) as exists_result`;
|
||||||
|
params = [fieldValue];
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`🔍 EXISTS 쿼리: ${sql}, params: ${JSON.stringify(params)}`);
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
const existsInTable = result[0]?.exists_result === true;
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`🔍 EXISTS 결과: ${fieldValue}이(가) ${lookupTable}.${lookupField}에 ${existsInTable ? "존재함" : "존재하지 않음"}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// EXISTS_IN: 존재하면 true
|
||||||
|
// NOT_EXISTS_IN: 존재하지 않으면 true
|
||||||
|
if (operator === "EXISTS_IN") {
|
||||||
|
return existsInTable;
|
||||||
|
} else {
|
||||||
|
return !existsInTable;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`❌ EXISTS 조건 평가 실패: ${error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WHERE 절 생성
|
* WHERE 절 생성
|
||||||
*/
|
*/
|
||||||
|
|
@ -4280,6 +4446,8 @@ export class NodeFlowExecutionService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 산술 연산 계산
|
* 산술 연산 계산
|
||||||
|
* 다중 연산 지원: (leftOperand operator rightOperand) 이후 additionalOperations 순차 적용
|
||||||
|
* 예: (width * height) / 1000000 * qty
|
||||||
*/
|
*/
|
||||||
private static evaluateArithmetic(
|
private static evaluateArithmetic(
|
||||||
arithmetic: any,
|
arithmetic: any,
|
||||||
|
|
@ -4306,27 +4474,67 @@ export class NodeFlowExecutionService {
|
||||||
const leftNum = Number(left) || 0;
|
const leftNum = Number(left) || 0;
|
||||||
const rightNum = Number(right) || 0;
|
const rightNum = Number(right) || 0;
|
||||||
|
|
||||||
switch (arithmetic.operator) {
|
// 기본 연산 수행
|
||||||
|
let result = this.applyOperator(leftNum, arithmetic.operator, rightNum);
|
||||||
|
|
||||||
|
if (result === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 추가 연산 처리 (다중 연산 지원)
|
||||||
|
if (arithmetic.additionalOperations && Array.isArray(arithmetic.additionalOperations)) {
|
||||||
|
for (const addOp of arithmetic.additionalOperations) {
|
||||||
|
const operandValue = this.getOperandValue(
|
||||||
|
addOp.operand,
|
||||||
|
sourceRow,
|
||||||
|
targetRow,
|
||||||
|
resultValues
|
||||||
|
);
|
||||||
|
const operandNum = Number(operandValue) || 0;
|
||||||
|
|
||||||
|
result = this.applyOperator(result, addOp.operator, operandNum);
|
||||||
|
|
||||||
|
if (result === null) {
|
||||||
|
logger.warn(`⚠️ 추가 연산 실패: ${addOp.operator}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(` 추가 연산: ${addOp.operator} ${operandNum} = ${result}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단일 연산자 적용
|
||||||
|
*/
|
||||||
|
private static applyOperator(
|
||||||
|
left: number,
|
||||||
|
operator: string,
|
||||||
|
right: number
|
||||||
|
): number | null {
|
||||||
|
switch (operator) {
|
||||||
case "+":
|
case "+":
|
||||||
return leftNum + rightNum;
|
return left + right;
|
||||||
case "-":
|
case "-":
|
||||||
return leftNum - rightNum;
|
return left - right;
|
||||||
case "*":
|
case "*":
|
||||||
return leftNum * rightNum;
|
return left * right;
|
||||||
case "/":
|
case "/":
|
||||||
if (rightNum === 0) {
|
if (right === 0) {
|
||||||
logger.warn(`⚠️ 0으로 나누기 시도`);
|
logger.warn(`⚠️ 0으로 나누기 시도`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return leftNum / rightNum;
|
return left / right;
|
||||||
case "%":
|
case "%":
|
||||||
if (rightNum === 0) {
|
if (right === 0) {
|
||||||
logger.warn(`⚠️ 0으로 나머지 연산 시도`);
|
logger.warn(`⚠️ 0으로 나머지 연산 시도`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return leftNum % rightNum;
|
return left % right;
|
||||||
default:
|
default:
|
||||||
throw new Error(`지원하지 않는 연산자: ${arithmetic.operator}`);
|
throw new Error(`지원하지 않는 연산자: ${operator}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -234,10 +234,23 @@ export class ReportService {
|
||||||
`;
|
`;
|
||||||
const queries = await query<ReportQuery>(queriesQuery, [reportId]);
|
const queries = await query<ReportQuery>(queriesQuery, [reportId]);
|
||||||
|
|
||||||
|
// 메뉴 매핑 조회
|
||||||
|
const menuMappingQuery = `
|
||||||
|
SELECT menu_objid
|
||||||
|
FROM report_menu_mapping
|
||||||
|
WHERE report_id = $1
|
||||||
|
ORDER BY created_at
|
||||||
|
`;
|
||||||
|
const menuMappings = await query<{ menu_objid: number }>(menuMappingQuery, [
|
||||||
|
reportId,
|
||||||
|
]);
|
||||||
|
const menuObjids = menuMappings?.map((m) => Number(m.menu_objid)) || [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
report,
|
report,
|
||||||
layout,
|
layout,
|
||||||
queries: queries || [],
|
queries: queries || [],
|
||||||
|
menuObjids,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -477,6 +490,12 @@ export class ReportService {
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// components가 이미 문자열이면 그대로, 객체면 JSON.stringify
|
||||||
|
const componentsData =
|
||||||
|
typeof originalLayout.components === "string"
|
||||||
|
? originalLayout.components
|
||||||
|
: JSON.stringify(originalLayout.components);
|
||||||
|
|
||||||
await client.query(copyLayoutQuery, [
|
await client.query(copyLayoutQuery, [
|
||||||
newLayoutId,
|
newLayoutId,
|
||||||
newReportId,
|
newReportId,
|
||||||
|
|
@ -487,7 +506,7 @@ export class ReportService {
|
||||||
originalLayout.margin_bottom,
|
originalLayout.margin_bottom,
|
||||||
originalLayout.margin_left,
|
originalLayout.margin_left,
|
||||||
originalLayout.margin_right,
|
originalLayout.margin_right,
|
||||||
JSON.stringify(originalLayout.components),
|
componentsData,
|
||||||
userId,
|
userId,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -561,7 +580,7 @@ export class ReportService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 레이아웃 저장 (쿼리 포함)
|
* 레이아웃 저장 (쿼리 포함) - 페이지 기반 구조
|
||||||
*/
|
*/
|
||||||
async saveLayout(
|
async saveLayout(
|
||||||
reportId: string,
|
reportId: string,
|
||||||
|
|
@ -569,6 +588,19 @@ export class ReportService {
|
||||||
userId: string
|
userId: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
return transaction(async (client) => {
|
return transaction(async (client) => {
|
||||||
|
// 첫 번째 페이지 정보를 기본 레이아웃으로 사용
|
||||||
|
const firstPage = data.layoutConfig.pages[0];
|
||||||
|
const canvasWidth = firstPage?.width || 210;
|
||||||
|
const canvasHeight = firstPage?.height || 297;
|
||||||
|
const pageOrientation =
|
||||||
|
canvasWidth > canvasHeight ? "landscape" : "portrait";
|
||||||
|
const margins = firstPage?.margins || {
|
||||||
|
top: 20,
|
||||||
|
bottom: 20,
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
};
|
||||||
|
|
||||||
// 1. 레이아웃 저장
|
// 1. 레이아웃 저장
|
||||||
const existingQuery = `
|
const existingQuery = `
|
||||||
SELECT layout_id FROM report_layout WHERE report_id = $1
|
SELECT layout_id FROM report_layout WHERE report_id = $1
|
||||||
|
|
@ -576,7 +608,7 @@ export class ReportService {
|
||||||
const existing = await client.query(existingQuery, [reportId]);
|
const existing = await client.query(existingQuery, [reportId]);
|
||||||
|
|
||||||
if (existing.rows.length > 0) {
|
if (existing.rows.length > 0) {
|
||||||
// 업데이트
|
// 업데이트 - components 컬럼에 전체 layoutConfig 저장
|
||||||
const updateQuery = `
|
const updateQuery = `
|
||||||
UPDATE report_layout
|
UPDATE report_layout
|
||||||
SET
|
SET
|
||||||
|
|
@ -594,14 +626,14 @@ export class ReportService {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
await client.query(updateQuery, [
|
await client.query(updateQuery, [
|
||||||
data.canvasWidth,
|
canvasWidth,
|
||||||
data.canvasHeight,
|
canvasHeight,
|
||||||
data.pageOrientation,
|
pageOrientation,
|
||||||
data.marginTop,
|
margins.top,
|
||||||
data.marginBottom,
|
margins.bottom,
|
||||||
data.marginLeft,
|
margins.left,
|
||||||
data.marginRight,
|
margins.right,
|
||||||
JSON.stringify(data.components),
|
JSON.stringify(data.layoutConfig), // 전체 layoutConfig 저장
|
||||||
userId,
|
userId,
|
||||||
reportId,
|
reportId,
|
||||||
]);
|
]);
|
||||||
|
|
@ -627,14 +659,14 @@ export class ReportService {
|
||||||
await client.query(insertQuery, [
|
await client.query(insertQuery, [
|
||||||
layoutId,
|
layoutId,
|
||||||
reportId,
|
reportId,
|
||||||
data.canvasWidth,
|
canvasWidth,
|
||||||
data.canvasHeight,
|
canvasHeight,
|
||||||
data.pageOrientation,
|
pageOrientation,
|
||||||
data.marginTop,
|
margins.top,
|
||||||
data.marginBottom,
|
margins.bottom,
|
||||||
data.marginLeft,
|
margins.left,
|
||||||
data.marginRight,
|
margins.right,
|
||||||
JSON.stringify(data.components),
|
JSON.stringify(data.layoutConfig), // 전체 layoutConfig 저장
|
||||||
userId,
|
userId,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -677,6 +709,43 @@ export class ReportService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. 메뉴 매핑 저장 (있는 경우)
|
||||||
|
if (data.menuObjids !== undefined) {
|
||||||
|
// 기존 메뉴 매핑 모두 삭제
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM report_menu_mapping WHERE report_id = $1`,
|
||||||
|
[reportId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 새 메뉴 매핑 삽입
|
||||||
|
if (data.menuObjids.length > 0) {
|
||||||
|
// 리포트의 company_code 조회
|
||||||
|
const reportResult = await client.query(
|
||||||
|
`SELECT company_code FROM report_master WHERE report_id = $1`,
|
||||||
|
[reportId]
|
||||||
|
);
|
||||||
|
const companyCode = reportResult.rows[0]?.company_code || "*";
|
||||||
|
|
||||||
|
const insertMappingSql = `
|
||||||
|
INSERT INTO report_menu_mapping (
|
||||||
|
report_id,
|
||||||
|
menu_objid,
|
||||||
|
company_code,
|
||||||
|
created_by
|
||||||
|
) VALUES ($1, $2, $3, $4)
|
||||||
|
`;
|
||||||
|
|
||||||
|
for (const menuObjid of data.menuObjids) {
|
||||||
|
await client.query(insertMappingSql, [
|
||||||
|
reportId,
|
||||||
|
menuObjid,
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1751,7 +1751,7 @@ export class ScreenManagementService {
|
||||||
// 기타
|
// 기타
|
||||||
label: "text-display",
|
label: "text-display",
|
||||||
code: "select-basic",
|
code: "select-basic",
|
||||||
entity: "select-basic",
|
entity: "entity-search-input", // 엔티티는 entity-search-input 사용
|
||||||
category: "select-basic",
|
category: "select-basic",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -2597,10 +2597,10 @@ export class ScreenManagementService {
|
||||||
// 없으면 원본과 같은 회사에 복사
|
// 없으면 원본과 같은 회사에 복사
|
||||||
const targetCompanyCode = copyData.targetCompanyCode || sourceScreen.company_code;
|
const targetCompanyCode = copyData.targetCompanyCode || sourceScreen.company_code;
|
||||||
|
|
||||||
// 3. 화면 코드 중복 체크 (대상 회사 기준)
|
// 3. 화면 코드 중복 체크 (대상 회사 기준, 삭제되지 않은 화면만)
|
||||||
const existingScreens = await client.query<any>(
|
const existingScreens = await client.query<any>(
|
||||||
`SELECT screen_id FROM screen_definitions
|
`SELECT screen_id FROM screen_definitions
|
||||||
WHERE screen_code = $1 AND company_code = $2
|
WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[copyData.screenCode, targetCompanyCode]
|
[copyData.screenCode, targetCompanyCode]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,82 @@ class TableCategoryValueService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 테이블의 카테고리 컬럼 목록 조회 (Select 옵션 설정용)
|
||||||
|
* 테이블 선택 없이 등록된 모든 카테고리 컬럼을 조회합니다.
|
||||||
|
*/
|
||||||
|
async getAllCategoryColumns(
|
||||||
|
companyCode: string
|
||||||
|
): Promise<CategoryColumn[]> {
|
||||||
|
try {
|
||||||
|
logger.info("전체 카테고리 컬럼 목록 조회", { companyCode });
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
let query: string;
|
||||||
|
let params: any[];
|
||||||
|
|
||||||
|
if (companyCode === "*") {
|
||||||
|
// 최고 관리자: 모든 카테고리 컬럼 조회 (중복 제거)
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
tc.table_name AS "tableName",
|
||||||
|
tc.column_name AS "columnName",
|
||||||
|
tc.column_name AS "columnLabel",
|
||||||
|
COALESCE(cv_count.cnt, 0) AS "valueCount"
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT table_name, column_name, MIN(display_order) as display_order
|
||||||
|
FROM table_type_columns
|
||||||
|
WHERE input_type = 'category'
|
||||||
|
GROUP BY table_name, column_name
|
||||||
|
) tc
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT table_name, column_name, COUNT(*) as cnt
|
||||||
|
FROM table_column_category_values
|
||||||
|
WHERE is_active = true
|
||||||
|
GROUP BY table_name, column_name
|
||||||
|
) cv_count ON tc.table_name = cv_count.table_name AND tc.column_name = cv_count.column_name
|
||||||
|
ORDER BY tc.table_name, tc.display_order, tc.column_name
|
||||||
|
`;
|
||||||
|
params = [];
|
||||||
|
} else {
|
||||||
|
// 일반 회사: 자신의 카테고리 값만 카운트 (중복 제거)
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
tc.table_name AS "tableName",
|
||||||
|
tc.column_name AS "columnName",
|
||||||
|
tc.column_name AS "columnLabel",
|
||||||
|
COALESCE(cv_count.cnt, 0) AS "valueCount"
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT table_name, column_name, MIN(display_order) as display_order
|
||||||
|
FROM table_type_columns
|
||||||
|
WHERE input_type = 'category'
|
||||||
|
GROUP BY table_name, column_name
|
||||||
|
) tc
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT table_name, column_name, COUNT(*) as cnt
|
||||||
|
FROM table_column_category_values
|
||||||
|
WHERE is_active = true AND company_code = $1
|
||||||
|
GROUP BY table_name, column_name
|
||||||
|
) cv_count ON tc.table_name = cv_count.table_name AND tc.column_name = cv_count.column_name
|
||||||
|
ORDER BY tc.table_name, tc.display_order, tc.column_name
|
||||||
|
`;
|
||||||
|
params = [companyCode];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(query, params);
|
||||||
|
|
||||||
|
logger.info(`전체 카테고리 컬럼 ${result.rows.length}개 조회 완료`, {
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`전체 카테고리 컬럼 조회 실패: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 특정 컬럼의 카테고리 값 목록 조회 (메뉴 스코프)
|
* 특정 컬럼의 카테고리 값 목록 조회 (메뉴 스코프)
|
||||||
*
|
*
|
||||||
|
|
@ -111,71 +187,68 @@ class TableCategoryValueService {
|
||||||
logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids });
|
logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 카테고리 값 조회 (형제 메뉴 포함)
|
// 2. 카테고리 값 조회 (메뉴 스코프 또는 형제 메뉴 포함)
|
||||||
let query: string;
|
let query: string;
|
||||||
let params: any[];
|
let params: any[];
|
||||||
|
|
||||||
|
const baseSelect = `
|
||||||
|
SELECT
|
||||||
|
value_id AS "valueId",
|
||||||
|
table_name AS "tableName",
|
||||||
|
column_name AS "columnName",
|
||||||
|
value_code AS "valueCode",
|
||||||
|
value_label AS "valueLabel",
|
||||||
|
value_order AS "valueOrder",
|
||||||
|
parent_value_id AS "parentValueId",
|
||||||
|
depth,
|
||||||
|
description,
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
is_active AS "isActive",
|
||||||
|
is_default AS "isDefault",
|
||||||
|
company_code AS "companyCode",
|
||||||
|
menu_objid AS "menuObjid",
|
||||||
|
created_at AS "createdAt",
|
||||||
|
updated_at AS "updatedAt",
|
||||||
|
created_by AS "createdBy",
|
||||||
|
updated_by AS "updatedBy"
|
||||||
|
FROM table_column_category_values
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND column_name = $2
|
||||||
|
`;
|
||||||
|
|
||||||
if (companyCode === "*") {
|
if (companyCode === "*") {
|
||||||
// 최고 관리자: 모든 카테고리 값 조회
|
// 최고 관리자: menuObjid가 있으면 해당 메뉴(및 형제 메뉴)의 값만 조회
|
||||||
// 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유
|
if (menuObjid && siblingObjids.length > 0) {
|
||||||
query = `
|
query = baseSelect + ` AND menu_objid = ANY($3::numeric[])`;
|
||||||
SELECT
|
params = [tableName, columnName, siblingObjids];
|
||||||
value_id AS "valueId",
|
logger.info("최고 관리자 메뉴 스코프 카테고리 값 조회", { menuObjid, siblingObjids });
|
||||||
table_name AS "tableName",
|
} else if (menuObjid) {
|
||||||
column_name AS "columnName",
|
query = baseSelect + ` AND menu_objid = $3`;
|
||||||
value_code AS "valueCode",
|
params = [tableName, columnName, menuObjid];
|
||||||
value_label AS "valueLabel",
|
logger.info("최고 관리자 단일 메뉴 카테고리 값 조회", { menuObjid });
|
||||||
value_order AS "valueOrder",
|
} else {
|
||||||
parent_value_id AS "parentValueId",
|
// menuObjid 없으면 모든 값 조회 (중복 가능)
|
||||||
depth,
|
query = baseSelect;
|
||||||
description,
|
params = [tableName, columnName];
|
||||||
color,
|
logger.info("최고 관리자 전체 카테고리 값 조회 (menuObjid 없음)");
|
||||||
icon,
|
}
|
||||||
is_active AS "isActive",
|
|
||||||
is_default AS "isDefault",
|
|
||||||
company_code AS "companyCode",
|
|
||||||
menu_objid AS "menuObjid",
|
|
||||||
created_at AS "createdAt",
|
|
||||||
updated_at AS "updatedAt",
|
|
||||||
created_by AS "createdBy",
|
|
||||||
updated_by AS "updatedBy"
|
|
||||||
FROM table_column_category_values
|
|
||||||
WHERE table_name = $1
|
|
||||||
AND column_name = $2
|
|
||||||
`;
|
|
||||||
params = [tableName, columnName];
|
|
||||||
logger.info("최고 관리자 카테고리 값 조회");
|
|
||||||
} else {
|
} else {
|
||||||
// 일반 회사: 자신의 카테고리 값만 조회
|
// 일반 회사: 자신의 회사 + menuObjid로 필터링
|
||||||
// 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유
|
if (menuObjid && siblingObjids.length > 0) {
|
||||||
query = `
|
query = baseSelect + ` AND company_code = $3 AND menu_objid = ANY($4::numeric[])`;
|
||||||
SELECT
|
params = [tableName, columnName, companyCode, siblingObjids];
|
||||||
value_id AS "valueId",
|
logger.info("회사별 메뉴 스코프 카테고리 값 조회", { companyCode, menuObjid, siblingObjids });
|
||||||
table_name AS "tableName",
|
} else if (menuObjid) {
|
||||||
column_name AS "columnName",
|
query = baseSelect + ` AND company_code = $3 AND menu_objid = $4`;
|
||||||
value_code AS "valueCode",
|
params = [tableName, columnName, companyCode, menuObjid];
|
||||||
value_label AS "valueLabel",
|
logger.info("회사별 단일 메뉴 카테고리 값 조회", { companyCode, menuObjid });
|
||||||
value_order AS "valueOrder",
|
} else {
|
||||||
parent_value_id AS "parentValueId",
|
// menuObjid 없으면 회사 전체 조회 (중복 가능하지만 회사별로 제한)
|
||||||
depth,
|
query = baseSelect + ` AND company_code = $3`;
|
||||||
description,
|
params = [tableName, columnName, companyCode];
|
||||||
color,
|
logger.info("회사별 카테고리 값 조회 (menuObjid 없음)", { companyCode });
|
||||||
icon,
|
}
|
||||||
is_active AS "isActive",
|
|
||||||
is_default AS "isDefault",
|
|
||||||
company_code AS "companyCode",
|
|
||||||
menu_objid AS "menuObjid",
|
|
||||||
created_at AS "createdAt",
|
|
||||||
updated_at AS "updatedAt",
|
|
||||||
created_by AS "createdBy",
|
|
||||||
updated_by AS "updatedBy"
|
|
||||||
FROM table_column_category_values
|
|
||||||
WHERE table_name = $1
|
|
||||||
AND column_name = $2
|
|
||||||
AND company_code = $3
|
|
||||||
`;
|
|
||||||
params = [tableName, columnName, companyCode];
|
|
||||||
logger.info("회사별 카테고리 값 조회", { companyCode });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!includeInactive) {
|
if (!includeInactive) {
|
||||||
|
|
@ -1322,6 +1395,220 @@ class TableCategoryValueService {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블의 카테고리 타입 컬럼과 해당 값 매핑 조회 (라벨 → 코드 변환용)
|
||||||
|
*
|
||||||
|
* 엑셀 업로드 등에서 라벨 값을 코드 값으로 변환할 때 사용
|
||||||
|
*
|
||||||
|
* @param tableName - 테이블명
|
||||||
|
* @param companyCode - 회사 코드
|
||||||
|
* @returns { [columnName]: { [label]: code } } 형태의 매핑 객체
|
||||||
|
*/
|
||||||
|
async getCategoryLabelToCodeMapping(
|
||||||
|
tableName: string,
|
||||||
|
companyCode: string
|
||||||
|
): Promise<Record<string, Record<string, string>>> {
|
||||||
|
try {
|
||||||
|
logger.info("카테고리 라벨→코드 매핑 조회", { tableName, companyCode });
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
// 1. 해당 테이블의 카테고리 타입 컬럼 조회
|
||||||
|
const categoryColumnsQuery = `
|
||||||
|
SELECT column_name
|
||||||
|
FROM table_type_columns
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND input_type = 'category'
|
||||||
|
`;
|
||||||
|
const categoryColumnsResult = await pool.query(categoryColumnsQuery, [tableName]);
|
||||||
|
|
||||||
|
if (categoryColumnsResult.rows.length === 0) {
|
||||||
|
logger.info("카테고리 타입 컬럼 없음", { tableName });
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryColumns = categoryColumnsResult.rows.map(row => row.column_name);
|
||||||
|
logger.info(`카테고리 컬럼 ${categoryColumns.length}개 발견`, { categoryColumns });
|
||||||
|
|
||||||
|
// 2. 각 카테고리 컬럼의 라벨→코드 매핑 조회
|
||||||
|
const result: Record<string, Record<string, string>> = {};
|
||||||
|
|
||||||
|
for (const columnName of categoryColumns) {
|
||||||
|
let query: string;
|
||||||
|
let params: any[];
|
||||||
|
|
||||||
|
if (companyCode === "*") {
|
||||||
|
// 최고 관리자: 모든 카테고리 값 조회
|
||||||
|
query = `
|
||||||
|
SELECT value_code, value_label
|
||||||
|
FROM table_column_category_values
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND column_name = $2
|
||||||
|
AND is_active = true
|
||||||
|
`;
|
||||||
|
params = [tableName, columnName];
|
||||||
|
} else {
|
||||||
|
// 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회
|
||||||
|
query = `
|
||||||
|
SELECT value_code, value_label
|
||||||
|
FROM table_column_category_values
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND column_name = $2
|
||||||
|
AND is_active = true
|
||||||
|
AND (company_code = $3 OR company_code = '*')
|
||||||
|
`;
|
||||||
|
params = [tableName, columnName, companyCode];
|
||||||
|
}
|
||||||
|
|
||||||
|
const valuesResult = await pool.query(query, params);
|
||||||
|
|
||||||
|
// { [label]: code } 형태로 변환
|
||||||
|
const labelToCodeMap: Record<string, string> = {};
|
||||||
|
for (const row of valuesResult.rows) {
|
||||||
|
// 라벨을 소문자로 변환하여 대소문자 구분 없이 매핑
|
||||||
|
labelToCodeMap[row.value_label] = row.value_code;
|
||||||
|
// 소문자 키도 추가 (대소문자 무시 검색용)
|
||||||
|
labelToCodeMap[row.value_label.toLowerCase()] = row.value_code;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(labelToCodeMap).length > 0) {
|
||||||
|
result[columnName] = labelToCodeMap;
|
||||||
|
logger.info(`컬럼 ${columnName}의 라벨→코드 매핑 ${valuesResult.rows.length}개 조회`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`카테고리 라벨→코드 매핑 조회 완료`, {
|
||||||
|
tableName,
|
||||||
|
columnCount: Object.keys(result).length
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`카테고리 라벨→코드 매핑 조회 실패: ${error.message}`, { error });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터의 카테고리 라벨 값을 코드 값으로 변환
|
||||||
|
*
|
||||||
|
* 엑셀 업로드 등에서 사용자가 입력한 라벨 값을 DB 저장용 코드 값으로 변환
|
||||||
|
*
|
||||||
|
* @param tableName - 테이블명
|
||||||
|
* @param companyCode - 회사 코드
|
||||||
|
* @param data - 변환할 데이터 객체
|
||||||
|
* @returns 라벨이 코드로 변환된 데이터 객체
|
||||||
|
*/
|
||||||
|
async convertCategoryLabelsToCodesForData(
|
||||||
|
tableName: string,
|
||||||
|
companyCode: string,
|
||||||
|
data: Record<string, any>
|
||||||
|
): Promise<{ convertedData: Record<string, any>; conversions: Array<{ column: string; label: string; code: string }> }> {
|
||||||
|
try {
|
||||||
|
// 라벨→코드 매핑 조회
|
||||||
|
const labelToCodeMapping = await this.getCategoryLabelToCodeMapping(tableName, companyCode);
|
||||||
|
|
||||||
|
if (Object.keys(labelToCodeMapping).length === 0) {
|
||||||
|
// 카테고리 컬럼 없음
|
||||||
|
return { convertedData: data, conversions: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const convertedData = { ...data };
|
||||||
|
const conversions: Array<{ column: string; label: string; code: string }> = [];
|
||||||
|
|
||||||
|
for (const [columnName, labelCodeMap] of Object.entries(labelToCodeMapping)) {
|
||||||
|
const value = data[columnName];
|
||||||
|
|
||||||
|
if (value !== undefined && value !== null && value !== "") {
|
||||||
|
const stringValue = String(value).trim();
|
||||||
|
|
||||||
|
// 다중 값 확인 (쉼표로 구분된 경우)
|
||||||
|
if (stringValue.includes(",")) {
|
||||||
|
// 다중 카테고리 값 처리
|
||||||
|
const labels = stringValue.split(",").map(s => s.trim()).filter(s => s !== "");
|
||||||
|
const convertedCodes: string[] = [];
|
||||||
|
let allConverted = true;
|
||||||
|
|
||||||
|
for (const label of labels) {
|
||||||
|
// 정확한 라벨 매칭 시도
|
||||||
|
let matchedCode = labelCodeMap[label];
|
||||||
|
|
||||||
|
// 대소문자 무시 매칭
|
||||||
|
if (!matchedCode) {
|
||||||
|
matchedCode = labelCodeMap[label.toLowerCase()];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedCode) {
|
||||||
|
convertedCodes.push(matchedCode);
|
||||||
|
conversions.push({
|
||||||
|
column: columnName,
|
||||||
|
label: label,
|
||||||
|
code: matchedCode,
|
||||||
|
});
|
||||||
|
logger.info(`카테고리 라벨→코드 변환 (다중): ${columnName} "${label}" → "${matchedCode}"`);
|
||||||
|
} else {
|
||||||
|
// 이미 코드값인지 확인
|
||||||
|
const isAlreadyCode = Object.values(labelCodeMap).includes(label);
|
||||||
|
if (isAlreadyCode) {
|
||||||
|
// 이미 코드값이면 그대로 사용
|
||||||
|
convertedCodes.push(label);
|
||||||
|
} else {
|
||||||
|
// 라벨도 코드도 아니면 원래 값 유지
|
||||||
|
convertedCodes.push(label);
|
||||||
|
allConverted = false;
|
||||||
|
logger.warn(`카테고리 값 매핑 없음 (다중): ${columnName} = "${label}" (라벨도 코드도 아님)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 변환된 코드들을 쉼표로 합쳐서 저장
|
||||||
|
convertedData[columnName] = convertedCodes.join(",");
|
||||||
|
logger.info(`다중 카테고리 변환 완료: ${columnName} "${stringValue}" → "${convertedData[columnName]}"`);
|
||||||
|
} else {
|
||||||
|
// 단일 값 처리
|
||||||
|
// 정확한 라벨 매칭 시도
|
||||||
|
let matchedCode = labelCodeMap[stringValue];
|
||||||
|
|
||||||
|
// 대소문자 무시 매칭
|
||||||
|
if (!matchedCode) {
|
||||||
|
matchedCode = labelCodeMap[stringValue.toLowerCase()];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedCode) {
|
||||||
|
// 라벨 값을 코드 값으로 변환
|
||||||
|
convertedData[columnName] = matchedCode;
|
||||||
|
conversions.push({
|
||||||
|
column: columnName,
|
||||||
|
label: stringValue,
|
||||||
|
code: matchedCode,
|
||||||
|
});
|
||||||
|
logger.info(`카테고리 라벨→코드 변환: ${columnName} "${stringValue}" → "${matchedCode}"`);
|
||||||
|
} else {
|
||||||
|
// 이미 코드값인지 확인 (역방향 확인)
|
||||||
|
const isAlreadyCode = Object.values(labelCodeMap).includes(stringValue);
|
||||||
|
if (!isAlreadyCode) {
|
||||||
|
logger.warn(`카테고리 값 매핑 없음: ${columnName} = "${stringValue}" (라벨도 코드도 아님)`);
|
||||||
|
}
|
||||||
|
// 변환 없이 원래 값 유지
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`카테고리 라벨→코드 변환 완료`, {
|
||||||
|
tableName,
|
||||||
|
conversionCount: conversions.length,
|
||||||
|
conversions,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { convertedData, conversions };
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`카테고리 라벨→코드 변환 실패: ${error.message}`, { error });
|
||||||
|
// 실패 시 원본 데이터 반환
|
||||||
|
return { convertedData: data, conversions: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new TableCategoryValueService();
|
export default new TableCategoryValueService();
|
||||||
|
|
|
||||||
|
|
@ -1306,6 +1306,48 @@ export class TableManagementService {
|
||||||
paramCount: number;
|
paramCount: number;
|
||||||
} | null> {
|
} | null> {
|
||||||
try {
|
try {
|
||||||
|
// 🆕 배열 값 처리 (다중 값 검색 - 분할패널 엔티티 타입에서 "2,3" 형태 지원)
|
||||||
|
// 좌측에서 "2"를 선택해도, 우측에서 "2,3"을 가진 행이 표시되도록 함
|
||||||
|
if (Array.isArray(value) && value.length > 0) {
|
||||||
|
// 배열의 각 값에 대해 OR 조건으로 검색
|
||||||
|
// 우측 컬럼에 "2,3" 같은 다중 값이 있을 수 있으므로
|
||||||
|
// 각 값을 LIKE 또는 = 조건으로 처리
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
|
||||||
|
value.forEach((v: any, idx: number) => {
|
||||||
|
const safeValue = String(v).trim();
|
||||||
|
// 정확히 일치하거나, 콤마로 구분된 값 중 하나로 포함
|
||||||
|
// 예: "2,3" 컬럼에서 "2"를 찾으려면:
|
||||||
|
// - 정확히 "2"
|
||||||
|
// - "2," 로 시작
|
||||||
|
// - ",2" 로 끝남
|
||||||
|
// - ",2," 중간에 포함
|
||||||
|
const paramBase = paramIndex + idx * 4;
|
||||||
|
conditions.push(`(
|
||||||
|
${columnName}::text = $${paramBase} OR
|
||||||
|
${columnName}::text LIKE $${paramBase + 1} OR
|
||||||
|
${columnName}::text LIKE $${paramBase + 2} OR
|
||||||
|
${columnName}::text LIKE $${paramBase + 3}
|
||||||
|
)`);
|
||||||
|
values.push(
|
||||||
|
safeValue,
|
||||||
|
`${safeValue},%`,
|
||||||
|
`%,${safeValue}`,
|
||||||
|
`%,${safeValue},%`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`🔍 다중 값 배열 검색: ${columnName} IN [${value.join(", ")}]`
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
whereClause: `(${conditions.join(" OR ")})`,
|
||||||
|
values,
|
||||||
|
paramCount: values.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위)
|
// 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위)
|
||||||
if (typeof value === "string" && value.includes("|")) {
|
if (typeof value === "string" && value.includes("|")) {
|
||||||
const columnInfo = await this.getColumnWebTypeInfo(
|
const columnInfo = await this.getColumnWebTypeInfo(
|
||||||
|
|
@ -1447,7 +1489,8 @@ export class TableManagementService {
|
||||||
tableName,
|
tableName,
|
||||||
columnName,
|
columnName,
|
||||||
actualValue,
|
actualValue,
|
||||||
paramIndex
|
paramIndex,
|
||||||
|
operator // operator 전달 (equals면 직접 매칭)
|
||||||
);
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|
@ -1676,7 +1719,8 @@ export class TableManagementService {
|
||||||
tableName: string,
|
tableName: string,
|
||||||
columnName: string,
|
columnName: string,
|
||||||
value: any,
|
value: any,
|
||||||
paramIndex: number
|
paramIndex: number,
|
||||||
|
operator: string = "contains" // 연결 필터에서 "equals"로 전달되면 직접 매칭
|
||||||
): Promise<{
|
): Promise<{
|
||||||
whereClause: string;
|
whereClause: string;
|
||||||
values: any[];
|
values: any[];
|
||||||
|
|
@ -1688,7 +1732,7 @@ export class TableManagementService {
|
||||||
columnName
|
columnName
|
||||||
);
|
);
|
||||||
|
|
||||||
// 🆕 배열 처리: IN 절 사용
|
// 배열 처리: IN 절 사용
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
if (value.length === 0) {
|
if (value.length === 0) {
|
||||||
// 빈 배열이면 항상 false 조건
|
// 빈 배열이면 항상 false 조건
|
||||||
|
|
@ -1720,14 +1764,44 @@ export class TableManagementService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === "string" && value.trim() !== "") {
|
if (typeof value === "string" && value.trim() !== "") {
|
||||||
const displayColumn = entityTypeInfo.displayColumn || "name";
|
// equals 연산자인 경우: 직접 값 매칭 (연결 필터에서 코드 값으로 필터링 시 사용)
|
||||||
|
if (operator === "equals") {
|
||||||
|
logger.info(
|
||||||
|
`🔍 [buildEntitySearchCondition] equals 연산자 - 직접 매칭: ${columnName} = ${value}`
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
whereClause: `${columnName} = $${paramIndex}`,
|
||||||
|
values: [value],
|
||||||
|
paramCount: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// contains 연산자 (기본): 참조 테이블의 표시 컬럼으로 검색
|
||||||
const referenceColumn = entityTypeInfo.referenceColumn || "id";
|
const referenceColumn = entityTypeInfo.referenceColumn || "id";
|
||||||
|
const referenceTable = entityTypeInfo.referenceTable;
|
||||||
|
|
||||||
|
// displayColumn이 비어있거나 "none"이면 참조 테이블에서 자동 감지 (entityJoinService와 동일한 로직)
|
||||||
|
let displayColumn = entityTypeInfo.displayColumn;
|
||||||
|
if (
|
||||||
|
!displayColumn ||
|
||||||
|
displayColumn === "none" ||
|
||||||
|
displayColumn === ""
|
||||||
|
) {
|
||||||
|
displayColumn = await this.findDisplayColumnForTable(
|
||||||
|
referenceTable,
|
||||||
|
referenceColumn
|
||||||
|
);
|
||||||
|
logger.info(
|
||||||
|
`🔍 [buildEntitySearchCondition] displayColumn 자동 감지: ${referenceTable} -> ${displayColumn}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 참조 테이블의 표시 컬럼으로 검색
|
// 참조 테이블의 표시 컬럼으로 검색
|
||||||
|
// 🔧 main. 접두사 추가: EXISTS 서브쿼리에서 외부 테이블 참조 시 명시적으로 지정
|
||||||
return {
|
return {
|
||||||
whereClause: `EXISTS (
|
whereClause: `EXISTS (
|
||||||
SELECT 1 FROM ${entityTypeInfo.referenceTable} ref
|
SELECT 1 FROM ${referenceTable} ref
|
||||||
WHERE ref.${referenceColumn} = ${columnName}
|
WHERE ref.${referenceColumn} = main.${columnName}
|
||||||
AND ref.${displayColumn} ILIKE $${paramIndex}
|
AND ref.${displayColumn} ILIKE $${paramIndex}
|
||||||
)`,
|
)`,
|
||||||
values: [`%${value}%`],
|
values: [`%${value}%`],
|
||||||
|
|
@ -1754,6 +1828,66 @@ export class TableManagementService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 참조 테이블에서 표시 컬럼 자동 감지 (entityJoinService와 동일한 우선순위)
|
||||||
|
* 우선순위: *_name > name > label/*_label > title > referenceColumn
|
||||||
|
*/
|
||||||
|
private async findDisplayColumnForTable(
|
||||||
|
tableName: string,
|
||||||
|
referenceColumn?: string
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
const result = await query<{ column_name: string }>(
|
||||||
|
`SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND table_schema = 'public'
|
||||||
|
ORDER BY ordinal_position`,
|
||||||
|
[tableName]
|
||||||
|
);
|
||||||
|
|
||||||
|
const allColumns = result.map((r) => r.column_name);
|
||||||
|
|
||||||
|
// entityJoinService와 동일한 우선순위
|
||||||
|
// 1. *_name 컬럼 (item_name, customer_name, process_name 등) - company_name 제외
|
||||||
|
const nameColumn = allColumns.find(
|
||||||
|
(col) => col.endsWith("_name") && col !== "company_name"
|
||||||
|
);
|
||||||
|
if (nameColumn) {
|
||||||
|
return nameColumn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. name 컬럼
|
||||||
|
if (allColumns.includes("name")) {
|
||||||
|
return "name";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. label 또는 *_label 컬럼
|
||||||
|
const labelColumn = allColumns.find(
|
||||||
|
(col) => col === "label" || col.endsWith("_label")
|
||||||
|
);
|
||||||
|
if (labelColumn) {
|
||||||
|
return labelColumn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. title 컬럼
|
||||||
|
if (allColumns.includes("title")) {
|
||||||
|
return "title";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 참조 컬럼 (referenceColumn)
|
||||||
|
if (referenceColumn && allColumns.includes(referenceColumn)) {
|
||||||
|
return referenceColumn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 기본값: 첫 번째 비-id 컬럼 또는 id
|
||||||
|
return allColumns.find((col) => col !== "id") || "id";
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`표시 컬럼 감지 실패: ${tableName}`, error);
|
||||||
|
return referenceColumn || "id"; // 오류 시 기본값
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 불린 검색 조건 구성
|
* 불린 검색 조건 구성
|
||||||
*/
|
*/
|
||||||
|
|
@ -2031,14 +2165,14 @@ export class TableManagementService {
|
||||||
// 안전한 테이블명 검증
|
// 안전한 테이블명 검증
|
||||||
const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, "");
|
const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, "");
|
||||||
|
|
||||||
// 전체 개수 조회
|
// 전체 개수 조회 (main 별칭 추가 - buildWhereClause가 main. 접두사를 사용하므로 필요)
|
||||||
const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} ${whereClause}`;
|
const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} main ${whereClause}`;
|
||||||
const countResult = await query<any>(countQuery, searchValues);
|
const countResult = await query<any>(countQuery, searchValues);
|
||||||
const total = parseInt(countResult[0].count);
|
const total = parseInt(countResult[0].count);
|
||||||
|
|
||||||
// 데이터 조회
|
// 데이터 조회 (main 별칭 추가)
|
||||||
const dataQuery = `
|
const dataQuery = `
|
||||||
SELECT * FROM ${safeTableName}
|
SELECT main.* FROM ${safeTableName} main
|
||||||
${whereClause}
|
${whereClause}
|
||||||
${orderClause}
|
${orderClause}
|
||||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||||
|
|
@ -2177,11 +2311,12 @@ export class TableManagementService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 테이블에 데이터 추가
|
* 테이블에 데이터 추가
|
||||||
|
* @returns 무시된 컬럼 정보 (디버깅용)
|
||||||
*/
|
*/
|
||||||
async addTableData(
|
async addTableData(
|
||||||
tableName: string,
|
tableName: string,
|
||||||
data: Record<string, any>
|
data: Record<string, any>
|
||||||
): Promise<void> {
|
): Promise<{ skippedColumns: string[]; savedColumns: string[] }> {
|
||||||
try {
|
try {
|
||||||
logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`);
|
logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`);
|
||||||
logger.info(`추가할 데이터:`, data);
|
logger.info(`추가할 데이터:`, data);
|
||||||
|
|
@ -2205,10 +2340,48 @@ export class TableManagementService {
|
||||||
|
|
||||||
logger.info(`컬럼 타입 정보:`, Object.fromEntries(columnTypeMap));
|
logger.info(`컬럼 타입 정보:`, Object.fromEntries(columnTypeMap));
|
||||||
|
|
||||||
// 컬럼명과 값을 분리하고 타입에 맞게 변환
|
// created_date 컬럼이 있고 값이 없으면 자동으로 현재 시간 추가
|
||||||
const columns = Object.keys(data);
|
const hasCreatedDate = columnTypeMap.has("created_date");
|
||||||
const values = Object.values(data).map((value, index) => {
|
if (hasCreatedDate && !data.created_date) {
|
||||||
const columnName = columns[index];
|
data.created_date = new Date().toISOString();
|
||||||
|
logger.info(`created_date 자동 추가: ${data.created_date}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시)
|
||||||
|
const skippedColumns: string[] = [];
|
||||||
|
const existingColumns = Object.keys(data).filter((col) => {
|
||||||
|
const exists = columnTypeMap.has(col);
|
||||||
|
if (!exists) {
|
||||||
|
skippedColumns.push(col);
|
||||||
|
}
|
||||||
|
return exists;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 무시된 컬럼이 있으면 경고 로그 출력
|
||||||
|
if (skippedColumns.length > 0) {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ [${tableName}] 테이블에 존재하지 않는 컬럼 ${skippedColumns.length}개 무시됨: ${skippedColumns.join(", ")}`
|
||||||
|
);
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ [${tableName}] 무시된 컬럼 상세:`,
|
||||||
|
skippedColumns.map((col) => ({ column: col, value: data[col] }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingColumns.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`저장할 유효한 컬럼이 없습니다. 테이블: ${tableName}, 전달된 컬럼: ${Object.keys(data).join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`✅ [${tableName}] 저장될 컬럼 ${existingColumns.length}개: ${existingColumns.join(", ")}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 컬럼명과 값을 분리하고 타입에 맞게 변환 (존재하는 컬럼만)
|
||||||
|
const columns = existingColumns;
|
||||||
|
const values = columns.map((columnName) => {
|
||||||
|
const value = data[columnName];
|
||||||
const dataType = columnTypeMap.get(columnName) || "text";
|
const dataType = columnTypeMap.get(columnName) || "text";
|
||||||
const convertedValue = this.convertValueForPostgreSQL(value, dataType);
|
const convertedValue = this.convertValueForPostgreSQL(value, dataType);
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -2264,6 +2437,12 @@ export class TableManagementService {
|
||||||
await query(insertQuery, values);
|
await query(insertQuery, values);
|
||||||
|
|
||||||
logger.info(`테이블 데이터 추가 완료: ${tableName}`);
|
logger.info(`테이블 데이터 추가 완료: ${tableName}`);
|
||||||
|
|
||||||
|
// 무시된 컬럼과 저장된 컬럼 정보 반환
|
||||||
|
return {
|
||||||
|
skippedColumns,
|
||||||
|
savedColumns: existingColumns,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`테이블 데이터 추가 오류: ${tableName}`, error);
|
logger.error(`테이블 데이터 추가 오류: ${tableName}`, error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -2310,12 +2489,27 @@ export class TableManagementService {
|
||||||
logger.info(`컬럼 타입 정보:`, Object.fromEntries(columnTypeMap));
|
logger.info(`컬럼 타입 정보:`, Object.fromEntries(columnTypeMap));
|
||||||
logger.info(`PRIMARY KEY 컬럼들:`, primaryKeys);
|
logger.info(`PRIMARY KEY 컬럼들:`, primaryKeys);
|
||||||
|
|
||||||
|
// updated_date 컬럼이 있으면 자동으로 현재 시간 추가
|
||||||
|
const hasUpdatedDate = columnTypeMap.has("updated_date");
|
||||||
|
if (hasUpdatedDate && !updatedData.updated_date) {
|
||||||
|
updatedData.updated_date = new Date().toISOString();
|
||||||
|
logger.info(`updated_date 자동 추가: ${updatedData.updated_date}`);
|
||||||
|
}
|
||||||
|
|
||||||
// SET 절 생성 (수정할 데이터) - 먼저 생성
|
// SET 절 생성 (수정할 데이터) - 먼저 생성
|
||||||
|
// 🔧 테이블에 존재하는 컬럼만 UPDATE (가상 컬럼 제외)
|
||||||
const setConditions: string[] = [];
|
const setConditions: string[] = [];
|
||||||
const setValues: any[] = [];
|
const setValues: any[] = [];
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
|
const skippedColumns: string[] = [];
|
||||||
|
|
||||||
Object.keys(updatedData).forEach((column) => {
|
Object.keys(updatedData).forEach((column) => {
|
||||||
|
// 테이블에 존재하지 않는 컬럼은 스킵
|
||||||
|
if (!columnTypeMap.has(column)) {
|
||||||
|
skippedColumns.push(column);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const dataType = columnTypeMap.get(column) || "text";
|
const dataType = columnTypeMap.get(column) || "text";
|
||||||
setConditions.push(
|
setConditions.push(
|
||||||
`"${column}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}`
|
`"${column}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}`
|
||||||
|
|
@ -2326,6 +2520,12 @@ export class TableManagementService {
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (skippedColumns.length > 0) {
|
||||||
|
logger.info(
|
||||||
|
`⚠️ 테이블에 존재하지 않는 컬럼 스킵: ${skippedColumns.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// WHERE 조건 생성 (PRIMARY KEY 우선, 없으면 모든 원본 데이터 사용)
|
// WHERE 조건 생성 (PRIMARY KEY 우선, 없으면 모든 원본 데이터 사용)
|
||||||
let whereConditions: string[] = [];
|
let whereConditions: string[] = [];
|
||||||
let whereValues: any[] = [];
|
let whereValues: any[] = [];
|
||||||
|
|
@ -2528,6 +2728,12 @@ export class TableManagementService {
|
||||||
filterColumn?: string;
|
filterColumn?: string;
|
||||||
filterValue?: any;
|
filterValue?: any;
|
||||||
}; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
|
}; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
|
||||||
|
deduplication?: {
|
||||||
|
enabled: boolean;
|
||||||
|
groupByColumn: string;
|
||||||
|
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||||
|
sortColumn?: string;
|
||||||
|
}; // 🆕 중복 제거 설정
|
||||||
}
|
}
|
||||||
): Promise<EntityJoinResponse> {
|
): Promise<EntityJoinResponse> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
@ -2578,33 +2784,74 @@ export class TableManagementService {
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const additionalColumn of options.additionalJoinColumns) {
|
for (const additionalColumn of options.additionalJoinColumns) {
|
||||||
// 🔍 sourceColumn을 기준으로 기존 조인 설정 찾기 (dept_code로 찾기)
|
// 🔍 1차: sourceColumn을 기준으로 기존 조인 설정 찾기
|
||||||
const baseJoinConfig = joinConfigs.find(
|
let baseJoinConfig = joinConfigs.find(
|
||||||
(config) => config.sourceColumn === additionalColumn.sourceColumn
|
(config) => config.sourceColumn === additionalColumn.sourceColumn
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 🔍 2차: referenceTable을 기준으로 찾기 (프론트엔드가 customer_mng.customer_name 같은 형식을 요청할 때)
|
||||||
|
// 실제 소스 컬럼이 partner_id인데 프론트엔드가 customer_id로 추론하는 경우 대응
|
||||||
|
if (!baseJoinConfig && (additionalColumn as any).referenceTable) {
|
||||||
|
baseJoinConfig = joinConfigs.find(
|
||||||
|
(config) =>
|
||||||
|
config.referenceTable ===
|
||||||
|
(additionalColumn as any).referenceTable
|
||||||
|
);
|
||||||
|
if (baseJoinConfig) {
|
||||||
|
logger.info(
|
||||||
|
`🔄 referenceTable로 조인 설정 찾음: ${(additionalColumn as any).referenceTable} → ${baseJoinConfig.sourceColumn}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (baseJoinConfig) {
|
if (baseJoinConfig) {
|
||||||
// joinAlias에서 실제 컬럼명 추출 (예: dept_code_location_name -> location_name)
|
// joinAlias에서 실제 컬럼명 추출
|
||||||
// sourceColumn을 제거한 나머지 부분이 실제 컬럼명
|
const sourceColumn = baseJoinConfig.sourceColumn; // 실제 소스 컬럼 (예: partner_id)
|
||||||
const sourceColumn = baseJoinConfig.sourceColumn; // dept_code
|
const originalJoinAlias = additionalColumn.joinAlias; // 프론트엔드가 보낸 별칭 (예: customer_id_customer_name)
|
||||||
const joinAlias = additionalColumn.joinAlias; // dept_code_company_name
|
|
||||||
const actualColumnName = joinAlias.replace(`${sourceColumn}_`, ""); // company_name
|
// 🔄 프론트엔드가 잘못된 소스 컬럼으로 추론한 경우 처리
|
||||||
|
// customer_id_customer_name → customer_name 추출 (customer_id_ 부분 제거)
|
||||||
|
// 또는 partner_id_customer_name → customer_name 추출 (partner_id_ 부분 제거)
|
||||||
|
let actualColumnName: string;
|
||||||
|
|
||||||
|
// 프론트엔드가 보낸 joinAlias에서 실제 컬럼명 추출
|
||||||
|
const frontendSourceColumn = additionalColumn.sourceColumn; // 프론트엔드가 추론한 소스 컬럼 (customer_id)
|
||||||
|
if (originalJoinAlias.startsWith(`${frontendSourceColumn}_`)) {
|
||||||
|
// 프론트엔드가 추론한 소스 컬럼으로 시작하면 그 부분 제거
|
||||||
|
actualColumnName = originalJoinAlias.replace(
|
||||||
|
`${frontendSourceColumn}_`,
|
||||||
|
""
|
||||||
|
);
|
||||||
|
} else if (originalJoinAlias.startsWith(`${sourceColumn}_`)) {
|
||||||
|
// 실제 소스 컬럼으로 시작하면 그 부분 제거
|
||||||
|
actualColumnName = originalJoinAlias.replace(
|
||||||
|
`${sourceColumn}_`,
|
||||||
|
""
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 어느 것도 아니면 원본 사용
|
||||||
|
actualColumnName = originalJoinAlias;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 올바른 joinAlias 재생성 (실제 소스 컬럼 기반)
|
||||||
|
const correctedJoinAlias = `${sourceColumn}_${actualColumnName}`;
|
||||||
|
|
||||||
logger.info(`🔍 조인 컬럼 상세 분석:`, {
|
logger.info(`🔍 조인 컬럼 상세 분석:`, {
|
||||||
sourceColumn,
|
sourceColumn,
|
||||||
joinAlias,
|
frontendSourceColumn,
|
||||||
|
originalJoinAlias,
|
||||||
|
correctedJoinAlias,
|
||||||
actualColumnName,
|
actualColumnName,
|
||||||
referenceTable: additionalColumn.sourceTable,
|
referenceTable: (additionalColumn as any).referenceTable,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🚨 기본 Entity 조인과 중복되지 않도록 체크
|
// 🚨 기본 Entity 조인과 중복되지 않도록 체크
|
||||||
const isBasicEntityJoin =
|
const isBasicEntityJoin =
|
||||||
additionalColumn.joinAlias ===
|
correctedJoinAlias === `${sourceColumn}_name`;
|
||||||
`${baseJoinConfig.sourceColumn}_name`;
|
|
||||||
|
|
||||||
if (isBasicEntityJoin) {
|
if (isBasicEntityJoin) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`⚠️ 기본 Entity 조인과 중복: ${additionalColumn.joinAlias} - 건너뜀`
|
`⚠️ 기본 Entity 조인과 중복: ${correctedJoinAlias} - 건너뜀`
|
||||||
);
|
);
|
||||||
continue; // 기본 Entity 조인과 중복되면 추가하지 않음
|
continue; // 기본 Entity 조인과 중복되면 추가하지 않음
|
||||||
}
|
}
|
||||||
|
|
@ -2612,14 +2859,14 @@ export class TableManagementService {
|
||||||
// 추가 조인 컬럼 설정 생성
|
// 추가 조인 컬럼 설정 생성
|
||||||
const additionalJoinConfig: EntityJoinConfig = {
|
const additionalJoinConfig: EntityJoinConfig = {
|
||||||
sourceTable: tableName,
|
sourceTable: tableName,
|
||||||
sourceColumn: baseJoinConfig.sourceColumn, // 원본 컬럼 (dept_code)
|
sourceColumn: sourceColumn, // 실제 소스 컬럼 (partner_id)
|
||||||
referenceTable:
|
referenceTable:
|
||||||
(additionalColumn as any).referenceTable ||
|
(additionalColumn as any).referenceTable ||
|
||||||
baseJoinConfig.referenceTable, // 참조 테이블 (dept_info)
|
baseJoinConfig.referenceTable, // 참조 테이블 (customer_mng)
|
||||||
referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (dept_code)
|
referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (customer_code)
|
||||||
displayColumns: [actualColumnName], // 표시할 컬럼들 (company_name)
|
displayColumns: [actualColumnName], // 표시할 컬럼들 (customer_name)
|
||||||
displayColumn: actualColumnName, // 하위 호환성
|
displayColumn: actualColumnName, // 하위 호환성
|
||||||
aliasColumn: additionalColumn.joinAlias, // 별칭 (dept_code_company_name)
|
aliasColumn: correctedJoinAlias, // 수정된 별칭 (partner_id_customer_name)
|
||||||
separator: " - ", // 기본 구분자
|
separator: " - ", // 기본 구분자
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -2979,8 +3226,10 @@ export class TableManagementService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entity 조인 컬럼 검색이 있는지 확인 (기본 조인 + 추가 조인 컬럼 모두 포함)
|
// Entity 조인 컬럼 검색이 있는지 확인 (기본 조인 + 추가 조인 컬럼 모두 포함)
|
||||||
|
// 🔧 sourceColumn도 포함: search={"order_no":"..."} 형태도 Entity 검색으로 인식
|
||||||
const allEntityColumns = [
|
const allEntityColumns = [
|
||||||
...joinConfigs.map((config) => config.aliasColumn),
|
...joinConfigs.map((config) => config.aliasColumn),
|
||||||
|
...joinConfigs.map((config) => config.sourceColumn), // 🔧 소스 컬럼도 포함
|
||||||
// 추가 조인 컬럼들도 포함 (writer_dept_code, company_code_status 등)
|
// 추가 조인 컬럼들도 포함 (writer_dept_code, company_code_status 등)
|
||||||
...joinConfigs.flatMap((config) => {
|
...joinConfigs.flatMap((config) => {
|
||||||
const additionalColumns = [];
|
const additionalColumns = [];
|
||||||
|
|
@ -3386,8 +3635,10 @@ export class TableManagementService {
|
||||||
});
|
});
|
||||||
|
|
||||||
// main. 접두사 추가 (조인 쿼리용)
|
// main. 접두사 추가 (조인 쿼리용)
|
||||||
|
// 🔧 이미 접두사(. 앞)가 있는 경우는 교체하지 않음 (ref.column, main.column 등)
|
||||||
|
// Negative lookbehind (?<!\.) 사용: 앞에 .이 없는 경우만 매칭
|
||||||
condition = condition.replace(
|
condition = condition.replace(
|
||||||
new RegExp(`\\b${columnName}\\b`, "g"),
|
new RegExp(`(?<!\\.)\\b${columnName}\\b`, "g"),
|
||||||
`main.${columnName}`
|
`main.${columnName}`
|
||||||
);
|
);
|
||||||
conditions.push(condition);
|
conditions.push(condition);
|
||||||
|
|
@ -3586,6 +3837,18 @@ export class TableManagementService {
|
||||||
const cacheableJoins: EntityJoinConfig[] = [];
|
const cacheableJoins: EntityJoinConfig[] = [];
|
||||||
const dbJoins: EntityJoinConfig[] = [];
|
const dbJoins: EntityJoinConfig[] = [];
|
||||||
|
|
||||||
|
// 🔒 멀티테넌시: 회사별 데이터 테이블은 캐시 사용 불가 (company_code 필터링 필요)
|
||||||
|
const companySpecificTables = [
|
||||||
|
"supplier_mng",
|
||||||
|
"customer_mng",
|
||||||
|
"item_info",
|
||||||
|
"dept_info",
|
||||||
|
"sales_order_mng", // 🔧 수주관리 테이블 추가
|
||||||
|
"sales_order_detail", // 🔧 수주상세 테이블 추가
|
||||||
|
"partner_info", // 🔧 거래처 테이블 추가
|
||||||
|
// 필요시 추가
|
||||||
|
];
|
||||||
|
|
||||||
for (const config of joinConfigs) {
|
for (const config of joinConfigs) {
|
||||||
// table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
|
// table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
|
||||||
if (config.referenceTable === "table_column_category_values") {
|
if (config.referenceTable === "table_column_category_values") {
|
||||||
|
|
@ -3594,6 +3857,13 @@ export class TableManagementService {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔒 회사별 데이터 테이블은 캐시 사용 불가 (멀티테넌시)
|
||||||
|
if (companySpecificTables.includes(config.referenceTable)) {
|
||||||
|
dbJoins.push(config);
|
||||||
|
console.log(`🔗 DB 조인 (멀티테넌시): ${config.referenceTable}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// 캐시 가능성 확인
|
// 캐시 가능성 확인
|
||||||
const cachedData = await referenceCacheService.getCachedReference(
|
const cachedData = await referenceCacheService.getCachedReference(
|
||||||
config.referenceTable,
|
config.referenceTable,
|
||||||
|
|
@ -3832,9 +4102,10 @@ export class TableManagementService {
|
||||||
`컬럼 입력타입 정보 조회: ${tableName}, company: ${companyCode}`
|
`컬럼 입력타입 정보 조회: ${tableName}, company: ${companyCode}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// table_type_columns에서 입력타입 정보 조회 (company_code 필터링)
|
// table_type_columns에서 입력타입 정보 조회
|
||||||
|
// 회사별 설정 우선, 없으면 기본 설정(*) fallback
|
||||||
const rawInputTypes = await query<any>(
|
const rawInputTypes = await query<any>(
|
||||||
`SELECT
|
`SELECT DISTINCT ON (ttc.column_name)
|
||||||
ttc.column_name as "columnName",
|
ttc.column_name as "columnName",
|
||||||
COALESCE(cl.column_label, ttc.column_name) as "displayName",
|
COALESCE(cl.column_label, ttc.column_name) as "displayName",
|
||||||
ttc.input_type as "inputType",
|
ttc.input_type as "inputType",
|
||||||
|
|
@ -3848,8 +4119,10 @@ export class TableManagementService {
|
||||||
LEFT JOIN information_schema.columns ic
|
LEFT JOIN information_schema.columns ic
|
||||||
ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name
|
ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name
|
||||||
WHERE ttc.table_name = $1
|
WHERE ttc.table_name = $1
|
||||||
AND ttc.company_code = $2
|
AND ttc.company_code IN ($2, '*')
|
||||||
ORDER BY ttc.display_order, ttc.column_name`,
|
ORDER BY ttc.column_name,
|
||||||
|
CASE WHEN ttc.company_code = $2 THEN 0 ELSE 1 END,
|
||||||
|
ttc.display_order`,
|
||||||
[tableName, companyCode]
|
[tableName, companyCode]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -3863,17 +4136,20 @@ export class TableManagementService {
|
||||||
const mappingTableExists = tableExistsResult[0]?.table_exists === true;
|
const mappingTableExists = tableExistsResult[0]?.table_exists === true;
|
||||||
|
|
||||||
// 카테고리 컬럼의 경우, 매핑된 메뉴 목록 조회
|
// 카테고리 컬럼의 경우, 매핑된 메뉴 목록 조회
|
||||||
|
// 회사별 설정 우선, 없으면 기본 설정(*) fallback
|
||||||
let categoryMappings: Map<string, number[]> = new Map();
|
let categoryMappings: Map<string, number[]> = new Map();
|
||||||
if (mappingTableExists) {
|
if (mappingTableExists) {
|
||||||
logger.info("카테고리 매핑 조회 시작", { tableName, companyCode });
|
logger.info("카테고리 매핑 조회 시작", { tableName, companyCode });
|
||||||
|
|
||||||
const mappings = await query<any>(
|
const mappings = await query<any>(
|
||||||
`SELECT
|
`SELECT DISTINCT ON (logical_column_name, menu_objid)
|
||||||
logical_column_name as "columnName",
|
logical_column_name as "columnName",
|
||||||
menu_objid as "menuObjid"
|
menu_objid as "menuObjid"
|
||||||
FROM category_column_mapping
|
FROM category_column_mapping
|
||||||
WHERE table_name = $1
|
WHERE table_name = $1
|
||||||
AND company_code = $2`,
|
AND company_code IN ($2, '*')
|
||||||
|
ORDER BY logical_column_name, menu_objid,
|
||||||
|
CASE WHEN company_code = $2 THEN 0 ELSE 1 END`,
|
||||||
[tableName, companyCode]
|
[tableName, companyCode]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -4476,4 +4752,110 @@ export class TableManagementService {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 두 테이블 간의 엔티티 관계 자동 감지
|
||||||
|
* column_labels에서 엔티티 타입 설정을 기반으로 테이블 간 관계를 찾습니다.
|
||||||
|
*
|
||||||
|
* @param leftTable 좌측 테이블명
|
||||||
|
* @param rightTable 우측 테이블명
|
||||||
|
* @returns 감지된 엔티티 관계 배열
|
||||||
|
*/
|
||||||
|
async detectTableEntityRelations(
|
||||||
|
leftTable: string,
|
||||||
|
rightTable: string
|
||||||
|
): Promise<
|
||||||
|
Array<{
|
||||||
|
leftColumn: string;
|
||||||
|
rightColumn: string;
|
||||||
|
direction: "left_to_right" | "right_to_left";
|
||||||
|
inputType: string;
|
||||||
|
displayColumn?: string;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
logger.info(
|
||||||
|
`두 테이블 간 엔티티 관계 감지 시작: ${leftTable} <-> ${rightTable}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const relations: Array<{
|
||||||
|
leftColumn: string;
|
||||||
|
rightColumn: string;
|
||||||
|
direction: "left_to_right" | "right_to_left";
|
||||||
|
inputType: string;
|
||||||
|
displayColumn?: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// 1. 우측 테이블에서 좌측 테이블을 참조하는 엔티티 컬럼 찾기
|
||||||
|
// 예: right_table의 customer_id -> left_table(customer_mng)의 customer_code
|
||||||
|
const rightToLeftRels = await query<{
|
||||||
|
column_name: string;
|
||||||
|
reference_column: string;
|
||||||
|
input_type: string;
|
||||||
|
display_column: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT column_name, reference_column, input_type, display_column
|
||||||
|
FROM column_labels
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND input_type IN ('entity', 'category')
|
||||||
|
AND reference_table = $2
|
||||||
|
AND reference_column IS NOT NULL
|
||||||
|
AND reference_column != ''`,
|
||||||
|
[rightTable, leftTable]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const rel of rightToLeftRels) {
|
||||||
|
relations.push({
|
||||||
|
leftColumn: rel.reference_column,
|
||||||
|
rightColumn: rel.column_name,
|
||||||
|
direction: "right_to_left",
|
||||||
|
inputType: rel.input_type,
|
||||||
|
displayColumn: rel.display_column || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 좌측 테이블에서 우측 테이블을 참조하는 엔티티 컬럼 찾기
|
||||||
|
// 예: left_table의 item_id -> right_table(item_info)의 item_number
|
||||||
|
const leftToRightRels = await query<{
|
||||||
|
column_name: string;
|
||||||
|
reference_column: string;
|
||||||
|
input_type: string;
|
||||||
|
display_column: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT column_name, reference_column, input_type, display_column
|
||||||
|
FROM column_labels
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND input_type IN ('entity', 'category')
|
||||||
|
AND reference_table = $2
|
||||||
|
AND reference_column IS NOT NULL
|
||||||
|
AND reference_column != ''`,
|
||||||
|
[leftTable, rightTable]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const rel of leftToRightRels) {
|
||||||
|
relations.push({
|
||||||
|
leftColumn: rel.column_name,
|
||||||
|
rightColumn: rel.reference_column,
|
||||||
|
direction: "left_to_right",
|
||||||
|
inputType: rel.input_type,
|
||||||
|
displayColumn: rel.display_column || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`엔티티 관계 감지 완료: ${relations.length}개 발견`);
|
||||||
|
relations.forEach((rel, idx) => {
|
||||||
|
logger.info(
|
||||||
|
` ${idx + 1}. ${leftTable}.${rel.leftColumn} <-> ${rightTable}.${rel.rightColumn} (${rel.direction})`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return relations;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`엔티티 관계 감지 실패: ${leftTable} <-> ${rightTable}`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,30 @@ export interface LangKey {
|
||||||
langKey: string;
|
langKey: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
isActive: string;
|
isActive: string;
|
||||||
|
categoryId?: number;
|
||||||
|
keyMeaning?: string;
|
||||||
|
usageNote?: string;
|
||||||
|
baseKeyId?: number;
|
||||||
createdDate?: Date;
|
createdDate?: Date;
|
||||||
createdBy?: string;
|
createdBy?: string;
|
||||||
updatedDate?: Date;
|
updatedDate?: Date;
|
||||||
updatedBy?: string;
|
updatedBy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 카테고리 인터페이스
|
||||||
|
export interface LangCategory {
|
||||||
|
categoryId: number;
|
||||||
|
categoryCode: string;
|
||||||
|
categoryName: string;
|
||||||
|
parentId?: number | null;
|
||||||
|
level: number;
|
||||||
|
keyPrefix: string;
|
||||||
|
description?: string;
|
||||||
|
sortOrder: number;
|
||||||
|
isActive: string;
|
||||||
|
children?: LangCategory[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface LangText {
|
export interface LangText {
|
||||||
textId?: number;
|
textId?: number;
|
||||||
keyId: number;
|
keyId: number;
|
||||||
|
|
@ -63,10 +81,38 @@ export interface CreateLangKeyRequest {
|
||||||
langKey: string;
|
langKey: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
isActive?: string;
|
isActive?: string;
|
||||||
|
categoryId?: number;
|
||||||
|
keyMeaning?: string;
|
||||||
|
usageNote?: string;
|
||||||
|
baseKeyId?: number;
|
||||||
createdBy?: string;
|
createdBy?: string;
|
||||||
updatedBy?: string;
|
updatedBy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 자동 키 생성 요청
|
||||||
|
export interface GenerateKeyRequest {
|
||||||
|
companyCode: string;
|
||||||
|
categoryId: number;
|
||||||
|
keyMeaning: string;
|
||||||
|
usageNote?: string;
|
||||||
|
texts: Array<{
|
||||||
|
langCode: string;
|
||||||
|
langText: string;
|
||||||
|
}>;
|
||||||
|
createdBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 오버라이드 키 생성 요청
|
||||||
|
export interface CreateOverrideKeyRequest {
|
||||||
|
companyCode: string;
|
||||||
|
baseKeyId: number;
|
||||||
|
texts: Array<{
|
||||||
|
langCode: string;
|
||||||
|
langText: string;
|
||||||
|
}>;
|
||||||
|
createdBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UpdateLangKeyRequest {
|
export interface UpdateLangKeyRequest {
|
||||||
companyCode?: string;
|
companyCode?: string;
|
||||||
menuName?: string;
|
menuName?: string;
|
||||||
|
|
@ -90,6 +136,8 @@ export interface GetLangKeysParams {
|
||||||
menuCode?: string;
|
menuCode?: string;
|
||||||
keyType?: string;
|
keyType?: string;
|
||||||
searchText?: string;
|
searchText?: string;
|
||||||
|
categoryId?: number;
|
||||||
|
includeOverrides?: boolean;
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,11 +71,12 @@ export interface ReportQuery {
|
||||||
updated_by: string | null;
|
updated_by: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 리포트 상세 (마스터 + 레이아웃 + 쿼리)
|
// 리포트 상세 (마스터 + 레이아웃 + 쿼리 + 연결된 메뉴)
|
||||||
export interface ReportDetail {
|
export interface ReportDetail {
|
||||||
report: ReportMaster;
|
report: ReportMaster;
|
||||||
layout: ReportLayout | null;
|
layout: ReportLayout | null;
|
||||||
queries: ReportQuery[];
|
queries: ReportQuery[];
|
||||||
|
menuObjids?: number[]; // 연결된 메뉴 ID 목록
|
||||||
}
|
}
|
||||||
|
|
||||||
// 리포트 목록 조회 파라미터
|
// 리포트 목록 조회 파라미터
|
||||||
|
|
@ -116,23 +117,67 @@ export interface UpdateReportRequest {
|
||||||
useYn?: string;
|
useYn?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 워터마크 설정
|
||||||
|
export interface WatermarkConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
type: "text" | "image";
|
||||||
|
// 텍스트 워터마크
|
||||||
|
text?: string;
|
||||||
|
fontSize?: number;
|
||||||
|
fontColor?: string;
|
||||||
|
// 이미지 워터마크
|
||||||
|
imageUrl?: string;
|
||||||
|
// 공통 설정
|
||||||
|
opacity: number; // 0~1
|
||||||
|
style: "diagonal" | "center" | "tile";
|
||||||
|
rotation?: number; // 대각선일 때 각도 (기본 -45)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 페이지 설정
|
||||||
|
export interface PageConfig {
|
||||||
|
page_id: string;
|
||||||
|
page_name: string;
|
||||||
|
page_order: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
background_color: string;
|
||||||
|
margins: {
|
||||||
|
top: number;
|
||||||
|
bottom: number;
|
||||||
|
left: number;
|
||||||
|
right: number;
|
||||||
|
};
|
||||||
|
components: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레이아웃 설정
|
||||||
|
export interface ReportLayoutConfig {
|
||||||
|
pages: PageConfig[];
|
||||||
|
watermark?: WatermarkConfig; // 전체 페이지 공유 워터마크
|
||||||
|
}
|
||||||
|
|
||||||
// 레이아웃 저장 요청
|
// 레이아웃 저장 요청
|
||||||
export interface SaveLayoutRequest {
|
export interface SaveLayoutRequest {
|
||||||
canvasWidth: number;
|
layoutConfig: ReportLayoutConfig;
|
||||||
canvasHeight: number;
|
|
||||||
pageOrientation: string;
|
|
||||||
marginTop: number;
|
|
||||||
marginBottom: number;
|
|
||||||
marginLeft: number;
|
|
||||||
marginRight: number;
|
|
||||||
components: any[];
|
|
||||||
queries?: Array<{
|
queries?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: "MASTER" | "DETAIL";
|
type: "MASTER" | "DETAIL";
|
||||||
sqlQuery: string;
|
sqlQuery: string;
|
||||||
parameters: string[];
|
parameters: string[];
|
||||||
|
externalConnectionId?: number;
|
||||||
}>;
|
}>;
|
||||||
|
menuObjids?: number[]; // 연결할 메뉴 ID 목록
|
||||||
|
}
|
||||||
|
|
||||||
|
// 리포트-메뉴 매핑
|
||||||
|
export interface ReportMenuMapping {
|
||||||
|
mapping_id: number;
|
||||||
|
report_id: string;
|
||||||
|
menu_objid: number;
|
||||||
|
company_code: string;
|
||||||
|
created_at: Date;
|
||||||
|
created_by: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 템플릿 목록 응답
|
// 템플릿 목록 응답
|
||||||
|
|
@ -150,3 +195,113 @@ export interface CreateTemplateRequest {
|
||||||
layoutConfig?: any;
|
layoutConfig?: any;
|
||||||
defaultQueries?: any;
|
defaultQueries?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 컴포넌트 설정 (프론트엔드와 동기화)
|
||||||
|
export interface ComponentConfig {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
zIndex: number;
|
||||||
|
fontSize?: number;
|
||||||
|
fontFamily?: string;
|
||||||
|
fontWeight?: string;
|
||||||
|
fontColor?: string;
|
||||||
|
backgroundColor?: string;
|
||||||
|
borderWidth?: number;
|
||||||
|
borderColor?: string;
|
||||||
|
borderRadius?: number;
|
||||||
|
textAlign?: string;
|
||||||
|
padding?: number;
|
||||||
|
queryId?: string;
|
||||||
|
fieldName?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
format?: string;
|
||||||
|
visible?: boolean;
|
||||||
|
printable?: boolean;
|
||||||
|
conditional?: string;
|
||||||
|
locked?: boolean;
|
||||||
|
groupId?: string;
|
||||||
|
// 이미지 전용
|
||||||
|
imageUrl?: string;
|
||||||
|
objectFit?: "contain" | "cover" | "fill" | "none";
|
||||||
|
// 구분선 전용
|
||||||
|
orientation?: "horizontal" | "vertical";
|
||||||
|
lineStyle?: "solid" | "dashed" | "dotted" | "double";
|
||||||
|
lineWidth?: number;
|
||||||
|
lineColor?: string;
|
||||||
|
// 서명/도장 전용
|
||||||
|
showLabel?: boolean;
|
||||||
|
labelText?: string;
|
||||||
|
labelPosition?: "top" | "left" | "bottom" | "right";
|
||||||
|
showUnderline?: boolean;
|
||||||
|
personName?: string;
|
||||||
|
// 테이블 전용
|
||||||
|
tableColumns?: Array<{
|
||||||
|
field: string;
|
||||||
|
header: string;
|
||||||
|
width?: number;
|
||||||
|
align?: "left" | "center" | "right";
|
||||||
|
}>;
|
||||||
|
headerBackgroundColor?: string;
|
||||||
|
headerTextColor?: string;
|
||||||
|
showBorder?: boolean;
|
||||||
|
rowHeight?: number;
|
||||||
|
// 페이지 번호 전용
|
||||||
|
pageNumberFormat?: "number" | "numberTotal" | "koreanNumber";
|
||||||
|
// 카드 컴포넌트 전용
|
||||||
|
cardTitle?: string;
|
||||||
|
cardItems?: Array<{
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
fieldName?: string;
|
||||||
|
}>;
|
||||||
|
labelWidth?: number;
|
||||||
|
showCardBorder?: boolean;
|
||||||
|
showCardTitle?: boolean;
|
||||||
|
titleFontSize?: number;
|
||||||
|
labelFontSize?: number;
|
||||||
|
valueFontSize?: number;
|
||||||
|
titleColor?: string;
|
||||||
|
labelColor?: string;
|
||||||
|
valueColor?: string;
|
||||||
|
// 계산 컴포넌트 전용
|
||||||
|
calcItems?: Array<{
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
operator: "+" | "-" | "x" | "÷";
|
||||||
|
fieldName?: string;
|
||||||
|
}>;
|
||||||
|
resultLabel?: string;
|
||||||
|
resultColor?: string;
|
||||||
|
resultFontSize?: number;
|
||||||
|
showCalcBorder?: boolean;
|
||||||
|
numberFormat?: "none" | "comma" | "currency";
|
||||||
|
currencySuffix?: string;
|
||||||
|
// 바코드 컴포넌트 전용
|
||||||
|
barcodeType?: "CODE128" | "CODE39" | "EAN13" | "EAN8" | "UPC" | "QR";
|
||||||
|
barcodeValue?: string;
|
||||||
|
barcodeFieldName?: string;
|
||||||
|
showBarcodeText?: boolean;
|
||||||
|
barcodeColor?: string;
|
||||||
|
barcodeBackground?: string;
|
||||||
|
barcodeMargin?: number;
|
||||||
|
qrErrorCorrectionLevel?: "L" | "M" | "Q" | "H";
|
||||||
|
// QR코드 다중 필드 (JSON 형식)
|
||||||
|
qrDataFields?: Array<{
|
||||||
|
fieldName: string;
|
||||||
|
label: string;
|
||||||
|
}>;
|
||||||
|
qrUseMultiField?: boolean;
|
||||||
|
qrIncludeAllRows?: boolean;
|
||||||
|
// 체크박스 컴포넌트 전용
|
||||||
|
checkboxChecked?: boolean; // 체크 상태 (고정값)
|
||||||
|
checkboxFieldName?: string; // 쿼리 필드 바인딩 (truthy/falsy 값)
|
||||||
|
checkboxLabel?: string; // 체크박스 옆 레이블 텍스트
|
||||||
|
checkboxSize?: number; // 체크박스 크기 (px)
|
||||||
|
checkboxColor?: string; // 체크 색상
|
||||||
|
checkboxBorderColor?: string; // 테두리 색상
|
||||||
|
checkboxLabelPosition?: "left" | "right"; // 레이블 위치
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -583,3 +583,9 @@ const result = await executeNodeFlow(flowId, {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,597 @@
|
||||||
|
# 다국어 관리 시스템 개선 계획서
|
||||||
|
|
||||||
|
## 1. 개요
|
||||||
|
|
||||||
|
### 1.1 현재 시스템 분석
|
||||||
|
|
||||||
|
현재 ERP 시스템의 다국어 관리 시스템은 기본적인 기능은 갖추고 있으나 다음과 같은 한계점이 있습니다.
|
||||||
|
|
||||||
|
| 항목 | 현재 상태 | 문제점 |
|
||||||
|
|------|----------|--------|
|
||||||
|
| 회사별 다국어 | `company_code` 컬럼 존재하나 `*`(공통)만 사용 | 회사별 커스텀 번역 불가 |
|
||||||
|
| 언어 키 입력 | 수동 입력 (`button.add` 등) | 명명 규칙 불일치, 오타, 중복 위험 |
|
||||||
|
| 카테고리 분류 | 없음 (`menu_name` 텍스트만 존재) | 체계적 분류/검색 불가 |
|
||||||
|
| 권한 관리 | 없음 | 모든 사용자가 모든 키 수정 가능 |
|
||||||
|
| 조회 우선순위 | 없음 | 회사별 오버라이드 불가 |
|
||||||
|
|
||||||
|
### 1.2 개선 목표
|
||||||
|
|
||||||
|
1. **회사별 다국어 오버라이드 시스템**: 공통 키를 기본으로 사용하되, 회사별 커스텀 번역 지원
|
||||||
|
2. **권한 기반 접근 제어**: 공통 키는 최고 관리자만, 회사 키는 해당 회사만 수정
|
||||||
|
3. **카테고리 기반 분류**: 2단계 계층 구조로 체계적 분류
|
||||||
|
4. **자동 키 생성**: 카테고리 선택 + 의미 입력으로 규칙화된 키 자동 생성
|
||||||
|
5. **실시간 중복 체크**: 키 생성 시 중복 여부 즉시 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 데이터베이스 스키마 설계
|
||||||
|
|
||||||
|
### 2.1 신규 테이블: multi_lang_category (카테고리 마스터)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE multi_lang_category (
|
||||||
|
category_id SERIAL PRIMARY KEY,
|
||||||
|
category_code VARCHAR(50) NOT NULL, -- BUTTON, FORM, MESSAGE 등
|
||||||
|
category_name VARCHAR(100) NOT NULL, -- 버튼, 폼, 메시지 등
|
||||||
|
parent_id INT4 REFERENCES multi_lang_category(category_id),
|
||||||
|
level INT4 DEFAULT 1, -- 1=대분류, 2=세부분류
|
||||||
|
key_prefix VARCHAR(50) NOT NULL, -- 키 생성용 prefix
|
||||||
|
description TEXT,
|
||||||
|
sort_order INT4 DEFAULT 0,
|
||||||
|
is_active CHAR(1) DEFAULT 'Y',
|
||||||
|
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by VARCHAR(50),
|
||||||
|
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_by VARCHAR(50),
|
||||||
|
UNIQUE(category_code, COALESCE(parent_id, 0))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 인덱스
|
||||||
|
CREATE INDEX idx_lang_category_parent ON multi_lang_category(parent_id);
|
||||||
|
CREATE INDEX idx_lang_category_level ON multi_lang_category(level);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 기존 테이블 수정: multi_lang_key_master
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 카테고리 연결 컬럼 추가
|
||||||
|
ALTER TABLE multi_lang_key_master
|
||||||
|
ADD COLUMN category_id INT4 REFERENCES multi_lang_category(category_id);
|
||||||
|
|
||||||
|
-- 키 의미 컬럼 추가 (자동 생성 시 사용자 입력값)
|
||||||
|
ALTER TABLE multi_lang_key_master
|
||||||
|
ADD COLUMN key_meaning VARCHAR(100);
|
||||||
|
|
||||||
|
-- 원본 키 참조 (오버라이드 시 원본 추적)
|
||||||
|
ALTER TABLE multi_lang_key_master
|
||||||
|
ADD COLUMN base_key_id INT4 REFERENCES multi_lang_key_master(key_id);
|
||||||
|
|
||||||
|
-- menu_name을 usage_note로 변경 (사용 위치 메모)
|
||||||
|
ALTER TABLE multi_lang_key_master
|
||||||
|
RENAME COLUMN menu_name TO usage_note;
|
||||||
|
|
||||||
|
-- 인덱스 추가
|
||||||
|
CREATE INDEX idx_lang_key_category ON multi_lang_key_master(category_id);
|
||||||
|
CREATE INDEX idx_lang_key_company_category ON multi_lang_key_master(company_code, category_id);
|
||||||
|
CREATE INDEX idx_lang_key_base ON multi_lang_key_master(base_key_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 테이블 관계도
|
||||||
|
|
||||||
|
```
|
||||||
|
multi_lang_category (1) ◀────────┐
|
||||||
|
├── category_id (PK) │
|
||||||
|
├── category_code │
|
||||||
|
├── parent_id (자기참조) │
|
||||||
|
└── key_prefix │
|
||||||
|
│
|
||||||
|
multi_lang_key_master (N) ────────┘
|
||||||
|
├── key_id (PK)
|
||||||
|
├── company_code ('*' = 공통)
|
||||||
|
├── category_id (FK)
|
||||||
|
├── lang_key (자동 생성)
|
||||||
|
├── key_meaning (사용자 입력)
|
||||||
|
├── base_key_id (오버라이드 시 원본)
|
||||||
|
└── usage_note (사용 위치 메모)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
multi_lang_text (N)
|
||||||
|
├── text_id (PK)
|
||||||
|
├── key_id (FK)
|
||||||
|
├── lang_code (FK → language_master)
|
||||||
|
└── lang_text
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 카테고리 체계
|
||||||
|
|
||||||
|
### 3.1 대분류 (Level 1)
|
||||||
|
|
||||||
|
| category_code | category_name | key_prefix | 설명 |
|
||||||
|
|---------------|---------------|------------|------|
|
||||||
|
| COMMON | 공통 | common | 범용 텍스트 |
|
||||||
|
| BUTTON | 버튼 | button | 버튼 텍스트 |
|
||||||
|
| FORM | 폼 | form | 폼 라벨, 플레이스홀더 |
|
||||||
|
| TABLE | 테이블 | table | 테이블 헤더, 빈 상태 |
|
||||||
|
| MESSAGE | 메시지 | message | 알림, 경고, 성공 메시지 |
|
||||||
|
| MENU | 메뉴 | menu | 메뉴명, 네비게이션 |
|
||||||
|
| MODAL | 모달 | modal | 모달/다이얼로그 |
|
||||||
|
| VALIDATION | 검증 | validation | 유효성 검사 메시지 |
|
||||||
|
| STATUS | 상태 | status | 상태 표시 텍스트 |
|
||||||
|
| TOOLTIP | 툴팁 | tooltip | 툴팁, 도움말 |
|
||||||
|
|
||||||
|
### 3.2 세부분류 (Level 2)
|
||||||
|
|
||||||
|
#### BUTTON 하위
|
||||||
|
| category_code | category_name | key_prefix |
|
||||||
|
|---------------|---------------|------------|
|
||||||
|
| ACTION | 액션 | action |
|
||||||
|
| NAVIGATION | 네비게이션 | nav |
|
||||||
|
| TOGGLE | 토글 | toggle |
|
||||||
|
|
||||||
|
#### FORM 하위
|
||||||
|
| category_code | category_name | key_prefix |
|
||||||
|
|---------------|---------------|------------|
|
||||||
|
| LABEL | 라벨 | label |
|
||||||
|
| PLACEHOLDER | 플레이스홀더 | placeholder |
|
||||||
|
| HELPER | 도움말 | helper |
|
||||||
|
|
||||||
|
#### MESSAGE 하위
|
||||||
|
| category_code | category_name | key_prefix |
|
||||||
|
|---------------|---------------|------------|
|
||||||
|
| SUCCESS | 성공 | success |
|
||||||
|
| ERROR | 에러 | error |
|
||||||
|
| WARNING | 경고 | warning |
|
||||||
|
| INFO | 안내 | info |
|
||||||
|
| CONFIRM | 확인 | confirm |
|
||||||
|
|
||||||
|
#### TABLE 하위
|
||||||
|
| category_code | category_name | key_prefix |
|
||||||
|
|---------------|---------------|------------|
|
||||||
|
| HEADER | 헤더 | header |
|
||||||
|
| EMPTY | 빈 상태 | empty |
|
||||||
|
| PAGINATION | 페이지네이션 | pagination |
|
||||||
|
|
||||||
|
#### MENU 하위
|
||||||
|
| category_code | category_name | key_prefix |
|
||||||
|
|---------------|---------------|------------|
|
||||||
|
| ADMIN | 관리자 | admin |
|
||||||
|
| USER | 사용자 | user |
|
||||||
|
|
||||||
|
#### MODAL 하위
|
||||||
|
| category_code | category_name | key_prefix |
|
||||||
|
|---------------|---------------|------------|
|
||||||
|
| TITLE | 제목 | title |
|
||||||
|
| DESCRIPTION | 설명 | description |
|
||||||
|
|
||||||
|
### 3.3 키 자동 생성 규칙
|
||||||
|
|
||||||
|
**형식**: `{대분류_prefix}.{세부분류_prefix}.{key_meaning}`
|
||||||
|
|
||||||
|
**예시**:
|
||||||
|
| 대분류 | 세부분류 | 의미 입력 | 생성 키 |
|
||||||
|
|--------|----------|----------|---------|
|
||||||
|
| BUTTON | ACTION | save | `button.action.save` |
|
||||||
|
| BUTTON | ACTION | delete_selected | `button.action.delete_selected` |
|
||||||
|
| FORM | LABEL | user_name | `form.label.user_name` |
|
||||||
|
| FORM | PLACEHOLDER | search | `form.placeholder.search` |
|
||||||
|
| MESSAGE | SUCCESS | save_complete | `message.success.save_complete` |
|
||||||
|
| MESSAGE | ERROR | network_fail | `message.error.network_fail` |
|
||||||
|
| TABLE | HEADER | created_date | `table.header.created_date` |
|
||||||
|
| MENU | ADMIN | user_management | `menu.admin.user_management` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 회사별 다국어 시스템
|
||||||
|
|
||||||
|
### 4.1 조회 우선순위
|
||||||
|
|
||||||
|
다국어 텍스트 조회 시 다음 우선순위를 적용합니다:
|
||||||
|
|
||||||
|
1. **회사 전용 키** (`company_code = 'COMPANY_A'`)
|
||||||
|
2. **공통 키** (`company_code = '*'`)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 조회 쿼리 예시
|
||||||
|
WITH ranked_keys AS (
|
||||||
|
SELECT
|
||||||
|
km.lang_key,
|
||||||
|
mt.lang_text,
|
||||||
|
km.company_code,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY km.lang_key
|
||||||
|
ORDER BY CASE WHEN km.company_code = $1 THEN 1 ELSE 2 END
|
||||||
|
) as priority
|
||||||
|
FROM multi_lang_key_master km
|
||||||
|
JOIN multi_lang_text mt ON km.key_id = mt.key_id
|
||||||
|
WHERE km.lang_key = ANY($2)
|
||||||
|
AND mt.lang_code = $3
|
||||||
|
AND km.is_active = 'Y'
|
||||||
|
AND km.company_code IN ($1, '*')
|
||||||
|
)
|
||||||
|
SELECT lang_key, lang_text
|
||||||
|
FROM ranked_keys
|
||||||
|
WHERE priority = 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 오버라이드 프로세스
|
||||||
|
|
||||||
|
1. 회사 관리자가 공통 키에서 "이 회사 전용으로 복사" 클릭
|
||||||
|
2. 시스템이 `base_key_id`에 원본 키를 참조하는 새 키 생성
|
||||||
|
3. 기존 번역 텍스트 복사
|
||||||
|
4. 회사 관리자가 번역 수정
|
||||||
|
5. 이후 해당 회사 사용자는 회사 전용 번역 사용
|
||||||
|
|
||||||
|
### 4.3 권한 매트릭스
|
||||||
|
|
||||||
|
| 작업 | 최고 관리자 (`*`) | 회사 관리자 | 일반 사용자 |
|
||||||
|
|------|------------------|-------------|-------------|
|
||||||
|
| 공통 키 조회 | O | O | O |
|
||||||
|
| 공통 키 생성 | O | X | X |
|
||||||
|
| 공통 키 수정 | O | X | X |
|
||||||
|
| 공통 키 삭제 | O | X | X |
|
||||||
|
| 회사 키 조회 | O | 자사만 | 자사만 |
|
||||||
|
| 회사 키 생성 (오버라이드) | O | O | X |
|
||||||
|
| 회사 키 수정 | O | 자사만 | X |
|
||||||
|
| 회사 키 삭제 | O | 자사만 | X |
|
||||||
|
| 카테고리 관리 | O | X | X |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API 설계
|
||||||
|
|
||||||
|
### 5.1 카테고리 API
|
||||||
|
|
||||||
|
| 엔드포인트 | 메서드 | 설명 | 권한 |
|
||||||
|
|-----------|--------|------|------|
|
||||||
|
| `/multilang/categories` | GET | 카테고리 목록 조회 | 인증 필요 |
|
||||||
|
| `/multilang/categories/tree` | GET | 계층 구조로 조회 | 인증 필요 |
|
||||||
|
| `/multilang/categories` | POST | 카테고리 생성 | 최고 관리자 |
|
||||||
|
| `/multilang/categories/:id` | PUT | 카테고리 수정 | 최고 관리자 |
|
||||||
|
| `/multilang/categories/:id` | DELETE | 카테고리 삭제 | 최고 관리자 |
|
||||||
|
|
||||||
|
### 5.2 다국어 키 API (개선)
|
||||||
|
|
||||||
|
| 엔드포인트 | 메서드 | 설명 | 권한 |
|
||||||
|
|-----------|--------|------|------|
|
||||||
|
| `/multilang/keys` | GET | 키 목록 조회 (카테고리/회사 필터) | 인증 필요 |
|
||||||
|
| `/multilang/keys` | POST | 키 생성 | 공통: 최고관리자, 회사: 회사관리자 |
|
||||||
|
| `/multilang/keys/:keyId` | PUT | 키 수정 | 공통: 최고관리자, 회사: 해당회사 |
|
||||||
|
| `/multilang/keys/:keyId` | DELETE | 키 삭제 | 공통: 최고관리자, 회사: 해당회사 |
|
||||||
|
| `/multilang/keys/:keyId/override` | POST | 공통 키를 회사 전용으로 복사 | 회사 관리자 |
|
||||||
|
| `/multilang/keys/check` | GET | 키 중복 체크 | 인증 필요 |
|
||||||
|
| `/multilang/keys/generate-preview` | POST | 키 자동 생성 미리보기 | 인증 필요 |
|
||||||
|
|
||||||
|
### 5.3 API 요청/응답 예시
|
||||||
|
|
||||||
|
#### 키 생성 요청
|
||||||
|
```json
|
||||||
|
POST /multilang/keys
|
||||||
|
{
|
||||||
|
"categoryId": 11, // 세부분류 ID (BUTTON > ACTION)
|
||||||
|
"keyMeaning": "save_changes",
|
||||||
|
"description": "변경사항 저장 버튼",
|
||||||
|
"usageNote": "사용자 관리, 설정 화면",
|
||||||
|
"texts": [
|
||||||
|
{ "langCode": "KR", "langText": "저장하기" },
|
||||||
|
{ "langCode": "US", "langText": "Save Changes" },
|
||||||
|
{ "langCode": "JP", "langText": "保存する" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 키 생성 응답
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "다국어 키가 생성되었습니다.",
|
||||||
|
"data": {
|
||||||
|
"keyId": 175,
|
||||||
|
"langKey": "button.action.save_changes",
|
||||||
|
"companyCode": "*",
|
||||||
|
"categoryId": 11
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 오버라이드 요청
|
||||||
|
```json
|
||||||
|
POST /multilang/keys/123/override
|
||||||
|
{
|
||||||
|
"texts": [
|
||||||
|
{ "langCode": "KR", "langText": "등록하기" },
|
||||||
|
{ "langCode": "US", "langText": "Register" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 프론트엔드 UI 설계
|
||||||
|
|
||||||
|
### 6.1 다국어 관리 페이지 리뉴얼
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 다국어 관리 │
|
||||||
|
│ 다국어 키와 번역 텍스트를 관리합니다 │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ [언어 관리] [다국어 키 관리] [카테고리 관리] │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────┐ ┌───────────────────────────────────────────────┤
|
||||||
|
│ │ 카테고리 필터 │ │ │
|
||||||
|
│ │ │ │ 검색: [________________] 회사: [전체 ▼] │
|
||||||
|
│ │ ▼ 버튼 (45) │ │ [초기화] [+ 키 등록] │
|
||||||
|
│ │ ├ 액션 (30) │ │───────────────────────────────────────────────│
|
||||||
|
│ │ ├ 네비게이션 (10)│ │ ☐ │ 키 │ 카테고리 │ 회사 │ 상태 │
|
||||||
|
│ │ └ 토글 (5) │ │───────────────────────────────────────────────│
|
||||||
|
│ │ ▼ 폼 (60) │ │ ☐ │ button.action.save │ 버튼>액션 │ 공통 │ 활성 │
|
||||||
|
│ │ ├ 라벨 (35) │ │ ☐ │ button.action.save │ 버튼>액션 │ A사 │ 활성 │
|
||||||
|
│ │ ├ 플레이스홀더(15)│ │ ☐ │ button.action.delete │ 버튼>액션 │ 공통 │ 활성 │
|
||||||
|
│ │ └ 도움말 (10) │ │ ☐ │ form.label.user_name │ 폼>라벨 │ 공통 │ 활성 │
|
||||||
|
│ │ ▶ 메시지 (40) │ │───────────────────────────────────────────────│
|
||||||
|
│ │ ▶ 테이블 (20) │ │ 페이지: [1] [2] [3] ... [10] │
|
||||||
|
│ │ ▶ 메뉴 (9) │ │ │
|
||||||
|
│ └────────────────────┘ └───────────────────────────────────────────────┤
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 키 등록 모달
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 다국어 키 등록 │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ① 카테고리 선택 │
|
||||||
|
│ ┌───────────────────────────────────────────────────────────────┤
|
||||||
|
│ │ 대분류 * │ 세부 분류 * │
|
||||||
|
│ │ ┌─────────────────────────┐ │ ┌─────────────────────────┐ │
|
||||||
|
│ │ │ 공통 │ │ │ (대분류 먼저 선택) │ │
|
||||||
|
│ │ │ ● 버튼 │ │ │ ● 액션 │ │
|
||||||
|
│ │ │ 폼 │ │ │ 네비게이션 │ │
|
||||||
|
│ │ │ 테이블 │ │ │ 토글 │ │
|
||||||
|
│ │ │ 메시지 │ │ │ │ │
|
||||||
|
│ │ └─────────────────────────┘ │ └─────────────────────────┘ │
|
||||||
|
│ └───────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ② 키 정보 입력 │
|
||||||
|
│ ┌───────────────────────────────────────────────────────────────┤
|
||||||
|
│ │ 키 의미 (영문) * │
|
||||||
|
│ │ [ save_changes ] │
|
||||||
|
│ │ 영문 소문자, 밑줄(_) 사용. 예: save, add_new, delete_all │
|
||||||
|
│ │ │
|
||||||
|
│ │ ───────────────────────────────────────────────────────── │
|
||||||
|
│ │ 자동 생성 키: │
|
||||||
|
│ │ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ │ button.action.save_changes │ │
|
||||||
|
│ │ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │ ✓ 사용 가능한 키입니다 │
|
||||||
|
│ └───────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ③ 설명 및 번역 │
|
||||||
|
│ ┌───────────────────────────────────────────────────────────────┤
|
||||||
|
│ │ 설명 (선택) │
|
||||||
|
│ │ [ 변경사항을 저장하는 버튼 ] │
|
||||||
|
│ │ │
|
||||||
|
│ │ 사용 위치 메모 (선택) │
|
||||||
|
│ │ [ 사용자 관리, 설정 화면 ] │
|
||||||
|
│ │ │
|
||||||
|
│ │ ───────────────────────────────────────────────────────── │
|
||||||
|
│ │ 번역 텍스트 │
|
||||||
|
│ │ │
|
||||||
|
│ │ 한국어 (KR) * [ 저장하기 ] │
|
||||||
|
│ │ English (US) [ Save Changes ] │
|
||||||
|
│ │ 日本語 (JP) [ 保存する ] │
|
||||||
|
│ └───────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ [취소] [등록] │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 공통 키 편집 모달 (회사 관리자용)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 다국어 키 상세 │
|
||||||
|
│ button.action.save (공통) │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 카테고리: 버튼 > 액션 │
|
||||||
|
│ 설명: 저장 버튼 │
|
||||||
|
│ │
|
||||||
|
│ ───────────────────────────────────────────────────────────── │
|
||||||
|
│ 번역 텍스트 (읽기 전용) │
|
||||||
|
│ │
|
||||||
|
│ 한국어 (KR) 저장 │
|
||||||
|
│ English (US) Save │
|
||||||
|
│ 日本語 (JP) 保存 │
|
||||||
|
│ │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ 공통 키는 수정할 수 없습니다. │
|
||||||
|
│ 이 회사만의 번역이 필요하시면 아래 버튼을 클릭하세요. │
|
||||||
|
│ │
|
||||||
|
│ [이 회사 전용으로 복사] │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ [닫기] │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 회사 전용 키 생성 모달 (오버라이드)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 회사 전용 키 생성 │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 원본 키: button.action.save (공통) │
|
||||||
|
│ │
|
||||||
|
│ 원본 번역: │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 한국어: 저장 │ │
|
||||||
|
│ │ English: Save │ │
|
||||||
|
│ │ 日本語: 保存 │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ───────────────────────────────────────────────────────────── │
|
||||||
|
│ │
|
||||||
|
│ 이 회사 전용 번역 텍스트: │
|
||||||
|
│ │
|
||||||
|
│ 한국어 (KR) * [ 등록하기 ] │
|
||||||
|
│ English (US) [ Register ] │
|
||||||
|
│ 日本語 (JP) [ 登録 ] │
|
||||||
|
│ │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ 회사 전용 키를 생성하면 공통 키 대신 사용됩니다. │
|
||||||
|
│ 원본 키가 변경되어도 회사 전용 키는 영향받지 않습니다. │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ [취소] [생성] │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 구현 계획
|
||||||
|
|
||||||
|
### 7.1 Phase 1: 데이터베이스 마이그레이션
|
||||||
|
|
||||||
|
**예상 소요 시간: 2시간**
|
||||||
|
|
||||||
|
1. 카테고리 테이블 생성
|
||||||
|
2. 기본 카테고리 데이터 삽입 (대분류 10개, 세부분류 약 20개)
|
||||||
|
3. multi_lang_key_master 스키마 변경
|
||||||
|
4. 기존 174개 키 카테고리 자동 분류 (패턴 매칭)
|
||||||
|
|
||||||
|
**마이그레이션 파일**: `db/migrations/075_multilang_category_system.sql`
|
||||||
|
|
||||||
|
### 7.2 Phase 2: 백엔드 API 개발
|
||||||
|
|
||||||
|
**예상 소요 시간: 4시간**
|
||||||
|
|
||||||
|
1. 카테고리 CRUD API
|
||||||
|
2. 키 조회 로직 수정 (우선순위 적용)
|
||||||
|
3. 권한 검사 미들웨어
|
||||||
|
4. 오버라이드 API
|
||||||
|
5. 키 중복 체크 API
|
||||||
|
6. 키 자동 생성 미리보기 API
|
||||||
|
|
||||||
|
**관련 파일**:
|
||||||
|
- `backend-node/src/controllers/multilangController.ts`
|
||||||
|
- `backend-node/src/services/multilangService.ts`
|
||||||
|
- `backend-node/src/routes/multilangRoutes.ts`
|
||||||
|
|
||||||
|
### 7.3 Phase 3: 프론트엔드 UI 개발
|
||||||
|
|
||||||
|
**예상 소요 시간: 6시간**
|
||||||
|
|
||||||
|
1. 카테고리 트리 컴포넌트
|
||||||
|
2. 키 등록 모달 리뉴얼 (단계별 입력)
|
||||||
|
3. 키 편집 모달 (권한별 UI 분기)
|
||||||
|
4. 오버라이드 모달
|
||||||
|
5. 카테고리 관리 탭 추가
|
||||||
|
|
||||||
|
**관련 파일**:
|
||||||
|
- `frontend/app/(main)/admin/systemMng/i18nList/page.tsx`
|
||||||
|
- `frontend/components/multilang/LangKeyModal.tsx` (리뉴얼)
|
||||||
|
- `frontend/components/multilang/CategoryTree.tsx` (신규)
|
||||||
|
- `frontend/components/multilang/OverrideModal.tsx` (신규)
|
||||||
|
|
||||||
|
### 7.4 Phase 4: 테스트 및 마이그레이션
|
||||||
|
|
||||||
|
**예상 소요 시간: 2시간**
|
||||||
|
|
||||||
|
1. API 테스트
|
||||||
|
2. UI 테스트
|
||||||
|
3. 기존 데이터 마이그레이션 검증
|
||||||
|
4. 권한 테스트 (최고 관리자, 회사 관리자)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 상세 구현 일정
|
||||||
|
|
||||||
|
| 단계 | 작업 | 예상 시간 | 의존성 |
|
||||||
|
|------|------|----------|--------|
|
||||||
|
| 1.1 | 마이그레이션 SQL 작성 | 30분 | - |
|
||||||
|
| 1.2 | 카테고리 기본 데이터 삽입 | 30분 | 1.1 |
|
||||||
|
| 1.3 | 기존 키 카테고리 자동 분류 | 30분 | 1.2 |
|
||||||
|
| 1.4 | 스키마 변경 검증 | 30분 | 1.3 |
|
||||||
|
| 2.1 | 카테고리 API 개발 | 1시간 | 1.4 |
|
||||||
|
| 2.2 | 키 조회 로직 수정 (우선순위) | 1시간 | 2.1 |
|
||||||
|
| 2.3 | 권한 검사 로직 추가 | 30분 | 2.2 |
|
||||||
|
| 2.4 | 오버라이드 API 개발 | 1시간 | 2.3 |
|
||||||
|
| 2.5 | 키 생성 API 개선 (자동 생성) | 30분 | 2.4 |
|
||||||
|
| 3.1 | 카테고리 트리 컴포넌트 | 1시간 | 2.5 |
|
||||||
|
| 3.2 | 키 등록 모달 리뉴얼 | 2시간 | 3.1 |
|
||||||
|
| 3.3 | 키 편집/상세 모달 | 1시간 | 3.2 |
|
||||||
|
| 3.4 | 오버라이드 모달 | 1시간 | 3.3 |
|
||||||
|
| 3.5 | 카테고리 관리 탭 | 1시간 | 3.4 |
|
||||||
|
| 4.1 | 통합 테스트 | 1시간 | 3.5 |
|
||||||
|
| 4.2 | 버그 수정 및 마무리 | 1시간 | 4.1 |
|
||||||
|
|
||||||
|
**총 예상 시간: 약 14시간**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 기대 효과
|
||||||
|
|
||||||
|
### 9.1 개선 전후 비교
|
||||||
|
|
||||||
|
| 항목 | 현재 | 개선 후 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 키 명명 규칙 | 불규칙 (수동 입력) | 규칙화 (자동 생성) |
|
||||||
|
| 카테고리 분류 | 없음 | 2단계 계층 구조 |
|
||||||
|
| 회사별 다국어 | 미활용 | 오버라이드 지원 |
|
||||||
|
| 조회 우선순위 | 없음 | 회사 전용 > 공통 |
|
||||||
|
| 권한 관리 | 없음 | 역할별 접근 제어 |
|
||||||
|
| 중복 체크 | 저장 시에만 | 실시간 검증 |
|
||||||
|
| 검색/필터 | 키 이름만 | 카테고리 + 회사 + 키 |
|
||||||
|
|
||||||
|
### 9.2 사용자 경험 개선
|
||||||
|
|
||||||
|
1. **일관된 키 명명**: 자동 생성으로 규칙 준수
|
||||||
|
2. **빠른 검색**: 카테고리 기반 필터링
|
||||||
|
3. **회사별 커스터마이징**: 브랜드에 맞는 번역 사용
|
||||||
|
4. **안전한 수정**: 권한 기반 보호
|
||||||
|
|
||||||
|
### 9.3 유지보수 개선
|
||||||
|
|
||||||
|
1. **체계적 분류**: 어떤 텍스트가 어디에 사용되는지 명확
|
||||||
|
2. **변경 영향 파악**: 오버라이드 추적으로 영향 범위 확인
|
||||||
|
3. **권한 분리**: 공통 키 보호, 회사별 자율성 보장
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 참고 자료
|
||||||
|
|
||||||
|
### 10.1 관련 파일
|
||||||
|
|
||||||
|
| 파일 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| `frontend/hooks/useMultiLang.ts` | 다국어 훅 |
|
||||||
|
| `frontend/lib/utils/multilang.ts` | 다국어 유틸리티 |
|
||||||
|
| `frontend/app/(main)/admin/systemMng/i18nList/page.tsx` | 다국어 관리 페이지 |
|
||||||
|
| `backend-node/src/controllers/multilangController.ts` | API 컨트롤러 |
|
||||||
|
| `backend-node/src/services/multilangService.ts` | 비즈니스 로직 |
|
||||||
|
| `docs/다국어_시스템_가이드.md` | 기존 시스템 가이드 |
|
||||||
|
|
||||||
|
### 10.2 데이터베이스 테이블
|
||||||
|
|
||||||
|
| 테이블 | 설명 |
|
||||||
|
|--------|------|
|
||||||
|
| `language_master` | 언어 마스터 (KR, US, JP) |
|
||||||
|
| `multi_lang_key_master` | 다국어 키 마스터 |
|
||||||
|
| `multi_lang_text` | 다국어 번역 텍스트 |
|
||||||
|
| `multi_lang_category` | 다국어 카테고리 (신규) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 변경 이력
|
||||||
|
|
||||||
|
| 버전 | 날짜 | 작성자 | 변경 내용 |
|
||||||
|
|------|------|--------|----------|
|
||||||
|
| 1.0 | 2026-01-13 | AI | 최초 작성 |
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -356,3 +356,9 @@
|
||||||
- [ ] 발송 버튼의 데이터 소스가 올바르게 설정되어 있는가?
|
- [ ] 발송 버튼의 데이터 소스가 올바르게 설정되어 있는가?
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,350 @@
|
||||||
|
# 즉시 저장(quickInsert) 버튼 액션 구현 계획서
|
||||||
|
|
||||||
|
## 1. 개요
|
||||||
|
|
||||||
|
### 1.1 목적
|
||||||
|
화면에서 entity 타입 선택박스로 데이터를 선택한 후, 버튼 클릭 시 특정 테이블에 즉시 INSERT하는 기능 구현
|
||||||
|
|
||||||
|
### 1.2 사용 사례
|
||||||
|
- **공정별 설비 관리**: 좌측에서 공정 선택 → 우측에서 설비 선택 → "설비 추가" 버튼 클릭 → `process_equipment` 테이블에 즉시 저장
|
||||||
|
|
||||||
|
### 1.3 화면 구성 예시
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ [entity 선택박스] [버튼: quickInsert] │
|
||||||
|
│ ┌─────────────────────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ MCT-01 - 머시닝센터 #1 ▼ │ │ + 설비 추가 │ │
|
||||||
|
│ └─────────────────────────────┘ └──────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 기술 설계
|
||||||
|
|
||||||
|
### 2.1 버튼 액션 타입 추가
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// types/screen-management.ts
|
||||||
|
type ButtonActionType =
|
||||||
|
| "save"
|
||||||
|
| "cancel"
|
||||||
|
| "delete"
|
||||||
|
| "edit"
|
||||||
|
| "add"
|
||||||
|
| "search"
|
||||||
|
| "reset"
|
||||||
|
| "submit"
|
||||||
|
| "close"
|
||||||
|
| "popup"
|
||||||
|
| "navigate"
|
||||||
|
| "custom"
|
||||||
|
| "quickInsert" // 🆕 즉시 저장
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 quickInsert 설정 구조
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface QuickInsertColumnMapping {
|
||||||
|
targetColumn: string; // 저장할 테이블의 컬럼명
|
||||||
|
sourceType: "component" | "leftPanel" | "fixed" | "currentUser";
|
||||||
|
|
||||||
|
// sourceType별 추가 설정
|
||||||
|
sourceComponentId?: string; // component: 값을 가져올 컴포넌트 ID
|
||||||
|
sourceColumn?: string; // leftPanel: 좌측 선택 데이터의 컬럼명
|
||||||
|
fixedValue?: any; // fixed: 고정값
|
||||||
|
userField?: string; // currentUser: 사용자 정보 필드 (userId, userName, companyCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuickInsertConfig {
|
||||||
|
targetTable: string; // 저장할 테이블명
|
||||||
|
columnMappings: QuickInsertColumnMapping[];
|
||||||
|
|
||||||
|
// 저장 후 동작
|
||||||
|
afterInsert?: {
|
||||||
|
refreshRightPanel?: boolean; // 우측 패널 새로고침
|
||||||
|
clearComponents?: string[]; // 초기화할 컴포넌트 ID 목록
|
||||||
|
showSuccessMessage?: boolean; // 성공 메시지 표시
|
||||||
|
successMessage?: string; // 커스텀 성공 메시지
|
||||||
|
};
|
||||||
|
|
||||||
|
// 중복 체크 (선택사항)
|
||||||
|
duplicateCheck?: {
|
||||||
|
enabled: boolean;
|
||||||
|
columns: string[]; // 중복 체크할 컬럼들
|
||||||
|
errorMessage?: string; // 중복 시 에러 메시지
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ButtonComponentConfig {
|
||||||
|
// 기존 설정들...
|
||||||
|
actionType: ButtonActionType;
|
||||||
|
|
||||||
|
// 🆕 quickInsert 전용 설정
|
||||||
|
quickInsertConfig?: QuickInsertConfig;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 데이터 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 사용자가 entity 선택박스에서 설비 선택
|
||||||
|
└─ equipment_code = "EQ-001" (내부값)
|
||||||
|
└─ 표시: "MCT-01 - 머시닝센터 #1"
|
||||||
|
|
||||||
|
2. 사용자가 "설비 추가" 버튼 클릭
|
||||||
|
|
||||||
|
3. quickInsert 핸들러 실행
|
||||||
|
├─ columnMappings 순회
|
||||||
|
│ ├─ equipment_code: component에서 값 가져오기 → "EQ-001"
|
||||||
|
│ └─ process_code: leftPanel에서 값 가져오기 → "PRC-001"
|
||||||
|
│
|
||||||
|
└─ INSERT 데이터 구성
|
||||||
|
{
|
||||||
|
equipment_code: "EQ-001",
|
||||||
|
process_code: "PRC-001",
|
||||||
|
company_code: "COMPANY_7", // 자동 추가
|
||||||
|
writer: "wace" // 자동 추가
|
||||||
|
}
|
||||||
|
|
||||||
|
4. API 호출: POST /api/table-management/tables/process_equipment/add
|
||||||
|
|
||||||
|
5. 성공 시
|
||||||
|
├─ 성공 메시지 표시
|
||||||
|
├─ 우측 패널(카드/테이블) 새로고침
|
||||||
|
└─ 선택박스 초기화
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 구현 계획
|
||||||
|
|
||||||
|
### 3.1 Phase 1: 타입 정의 및 설정 UI
|
||||||
|
|
||||||
|
| 작업 | 파일 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| 1-1 | `frontend/types/screen-management.ts` | QuickInsertConfig 타입 추가 |
|
||||||
|
| 1-2 | `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` | quickInsert 설정 UI 추가 |
|
||||||
|
|
||||||
|
### 3.2 Phase 2: 버튼 액션 핸들러 구현
|
||||||
|
|
||||||
|
| 작업 | 파일 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| 2-1 | `frontend/components/screen/InteractiveScreenViewerDynamic.tsx` | quickInsert 핸들러 추가 |
|
||||||
|
| 2-2 | 컴포넌트 값 수집 로직 | 같은 화면의 다른 컴포넌트에서 값 가져오기 |
|
||||||
|
|
||||||
|
### 3.3 Phase 3: 테스트 및 검증
|
||||||
|
|
||||||
|
| 작업 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| 3-1 | 공정별 설비 화면에서 테스트 |
|
||||||
|
| 3-2 | 중복 저장 방지 테스트 |
|
||||||
|
| 3-3 | 에러 처리 테스트 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 상세 구현
|
||||||
|
|
||||||
|
### 4.1 ButtonConfigPanel 설정 UI
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 버튼 액션 타입 │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 즉시 저장 (quickInsert) ▼ │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ─────────────── 즉시 저장 설정 ─────────────── │
|
||||||
|
│ │
|
||||||
|
│ 대상 테이블 * │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ process_equipment ▼ │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 컬럼 매핑 [+ 추가] │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 매핑 #1 [삭제] │ │
|
||||||
|
│ │ 대상 컬럼: equipment_code │ │
|
||||||
|
│ │ 값 소스: 컴포넌트 선택 │ │
|
||||||
|
│ │ 컴포넌트: [equipment-select ▼] │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 매핑 #2 [삭제] │ │
|
||||||
|
│ │ 대상 컬럼: process_code │ │
|
||||||
|
│ │ 값 소스: 좌측 패널 데이터 │ │
|
||||||
|
│ │ 소스 컬럼: process_code │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ─────────────── 저장 후 동작 ─────────────── │
|
||||||
|
│ │
|
||||||
|
│ ☑ 우측 패널 새로고침 │
|
||||||
|
│ ☑ 선택박스 초기화 │
|
||||||
|
│ ☑ 성공 메시지 표시 │
|
||||||
|
│ │
|
||||||
|
│ ─────────────── 중복 체크 (선택) ─────────────── │
|
||||||
|
│ │
|
||||||
|
│ ☐ 중복 체크 활성화 │
|
||||||
|
│ 체크 컬럼: equipment_code, process_code │
|
||||||
|
│ 에러 메시지: 이미 등록된 설비입니다. │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 핸들러 구현 (의사 코드)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const handleQuickInsert = async (config: QuickInsertConfig) => {
|
||||||
|
// 1. 컬럼 매핑에서 값 수집
|
||||||
|
const insertData: Record<string, any> = {};
|
||||||
|
|
||||||
|
for (const mapping of config.columnMappings) {
|
||||||
|
let value: any;
|
||||||
|
|
||||||
|
switch (mapping.sourceType) {
|
||||||
|
case "component":
|
||||||
|
// 같은 화면의 컴포넌트에서 값 가져오기
|
||||||
|
value = getComponentValue(mapping.sourceComponentId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "leftPanel":
|
||||||
|
// 분할 패널 좌측 선택 데이터에서 값 가져오기
|
||||||
|
value = splitPanelContext?.selectedLeftData?.[mapping.sourceColumn];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "fixed":
|
||||||
|
value = mapping.fixedValue;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "currentUser":
|
||||||
|
value = user?.[mapping.userField];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value !== undefined && value !== null && value !== "") {
|
||||||
|
insertData[mapping.targetColumn] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 필수값 검증
|
||||||
|
if (Object.keys(insertData).length === 0) {
|
||||||
|
toast.error("저장할 데이터가 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 중복 체크 (설정된 경우)
|
||||||
|
if (config.duplicateCheck?.enabled) {
|
||||||
|
const isDuplicate = await checkDuplicate(
|
||||||
|
config.targetTable,
|
||||||
|
config.duplicateCheck.columns,
|
||||||
|
insertData
|
||||||
|
);
|
||||||
|
if (isDuplicate) {
|
||||||
|
toast.error(config.duplicateCheck.errorMessage || "이미 존재하는 데이터입니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. API 호출
|
||||||
|
try {
|
||||||
|
await tableTypeApi.addTableData(config.targetTable, insertData);
|
||||||
|
|
||||||
|
// 5. 성공 후 동작
|
||||||
|
if (config.afterInsert?.showSuccessMessage) {
|
||||||
|
toast.success(config.afterInsert.successMessage || "저장되었습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.afterInsert?.refreshRightPanel) {
|
||||||
|
// 우측 패널 새로고침 트리거
|
||||||
|
onRefresh?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.afterInsert?.clearComponents) {
|
||||||
|
// 지정된 컴포넌트 초기화
|
||||||
|
for (const componentId of config.afterInsert.clearComponents) {
|
||||||
|
clearComponentValue(componentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("저장에 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 컴포넌트 간 통신 방안
|
||||||
|
|
||||||
|
### 5.1 문제점
|
||||||
|
- 버튼 컴포넌트에서 같은 화면의 entity 선택박스 값을 가져와야 함
|
||||||
|
- 현재는 각 컴포넌트가 독립적으로 동작
|
||||||
|
|
||||||
|
### 5.2 해결 방안: formData 활용
|
||||||
|
|
||||||
|
현재 `InteractiveScreenViewerDynamic`에서 `formData` 상태로 모든 입력값을 관리하고 있음.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// InteractiveScreenViewerDynamic.tsx
|
||||||
|
const [localFormData, setLocalFormData] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
|
// entity 선택박스에서 값 변경 시
|
||||||
|
const handleFormDataChange = (fieldName: string, value: any) => {
|
||||||
|
setLocalFormData(prev => ({ ...prev, [fieldName]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 버튼 클릭 시 formData에서 값 가져오기
|
||||||
|
const getComponentValue = (componentId: string) => {
|
||||||
|
// componentId로 컴포넌트의 columnName 찾기
|
||||||
|
const component = allComponents.find(c => c.id === componentId);
|
||||||
|
if (component?.columnName) {
|
||||||
|
return formData[component.columnName];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 테스트 시나리오
|
||||||
|
|
||||||
|
### 6.1 정상 케이스
|
||||||
|
1. 좌측 테이블에서 공정 "PRC-001" 선택
|
||||||
|
2. 우측 설비 선택박스에서 "MCT-01" 선택
|
||||||
|
3. "설비 추가" 버튼 클릭
|
||||||
|
4. `process_equipment` 테이블에 데이터 저장 확인
|
||||||
|
5. 우측 카드/테이블에 새 항목 표시 확인
|
||||||
|
|
||||||
|
### 6.2 에러 케이스
|
||||||
|
1. 좌측 미선택 상태에서 버튼 클릭 → "좌측에서 항목을 선택해주세요" 메시지
|
||||||
|
2. 설비 미선택 상태에서 버튼 클릭 → "설비를 선택해주세요" 메시지
|
||||||
|
3. 중복 데이터 저장 시도 → "이미 등록된 설비입니다" 메시지
|
||||||
|
|
||||||
|
### 6.3 엣지 케이스
|
||||||
|
1. 동일 설비 연속 추가 시도
|
||||||
|
2. 네트워크 오류 시 재시도
|
||||||
|
3. 권한 없는 사용자의 저장 시도
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 일정
|
||||||
|
|
||||||
|
| Phase | 작업 | 예상 시간 |
|
||||||
|
|-------|------|----------|
|
||||||
|
| Phase 1 | 타입 정의 및 설정 UI | 1시간 |
|
||||||
|
| Phase 2 | 버튼 액션 핸들러 구현 | 1시간 |
|
||||||
|
| Phase 3 | 테스트 및 검증 | 30분 |
|
||||||
|
| **합계** | | **2시간 30분** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 향후 확장 가능성
|
||||||
|
|
||||||
|
1. **다중 행 추가**: 여러 설비를 한 번에 선택하여 추가
|
||||||
|
2. **수정 모드**: 기존 데이터 수정 기능
|
||||||
|
3. **조건부 저장**: 특정 조건 만족 시에만 저장
|
||||||
|
4. **연쇄 저장**: 한 번의 클릭으로 여러 테이블에 저장
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -51,7 +51,7 @@ export default function DraftsPage() {
|
||||||
content: draft.htmlContent,
|
content: draft.htmlContent,
|
||||||
accountId: draft.accountId,
|
accountId: draft.accountId,
|
||||||
});
|
});
|
||||||
router.push(`/admin/mail/send?${params.toString()}`);
|
router.push(`/admin/automaticMng/mail/send?${params.toString()}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
|
|
@ -1056,7 +1056,7 @@ ${data.originalBody}`;
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => router.push(`/admin/mail/templates`)}
|
onClick={() => router.push(`/admin/automaticMng/mail/templates`)}
|
||||||
className="flex items-center gap-1"
|
className="flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<Settings className="w-3 h-3" />
|
<Settings className="w-3 h-3" />
|
||||||
|
|
@ -336,7 +336,7 @@ export default function SentMailPage() {
|
||||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
||||||
새로고침
|
새로고침
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => router.push("/admin/mail/send")} size="sm">
|
<Button onClick={() => router.push("/admin/automaticMng/mail/send")} size="sm">
|
||||||
<Mail className="w-4 h-4 mr-2" />
|
<Mail className="w-4 h-4 mr-2" />
|
||||||
메일 작성
|
메일 작성
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Link2, Layers, Filter, FormInput, Ban } from "lucide-react";
|
import { Link2, Layers, Filter, FormInput, Ban, Tags, Columns } from "lucide-react";
|
||||||
|
|
||||||
// 탭별 컴포넌트
|
// 탭별 컴포넌트
|
||||||
import CascadingRelationsTab from "./tabs/CascadingRelationsTab";
|
import CascadingRelationsTab from "./tabs/CascadingRelationsTab";
|
||||||
|
|
@ -11,6 +11,8 @@ import AutoFillTab from "./tabs/AutoFillTab";
|
||||||
import HierarchyTab from "./tabs/HierarchyTab";
|
import HierarchyTab from "./tabs/HierarchyTab";
|
||||||
import ConditionTab from "./tabs/ConditionTab";
|
import ConditionTab from "./tabs/ConditionTab";
|
||||||
import MutualExclusionTab from "./tabs/MutualExclusionTab";
|
import MutualExclusionTab from "./tabs/MutualExclusionTab";
|
||||||
|
import CategoryValueCascadingTab from "./tabs/CategoryValueCascadingTab";
|
||||||
|
import HierarchyColumnTab from "./tabs/HierarchyColumnTab";
|
||||||
|
|
||||||
export default function CascadingManagementPage() {
|
export default function CascadingManagementPage() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
@ -20,7 +22,7 @@ export default function CascadingManagementPage() {
|
||||||
// URL 쿼리 파라미터에서 탭 설정
|
// URL 쿼리 파라미터에서 탭 설정
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tab = searchParams.get("tab");
|
const tab = searchParams.get("tab");
|
||||||
if (tab && ["relations", "hierarchy", "condition", "autofill", "exclusion"].includes(tab)) {
|
if (tab && ["relations", "hierarchy", "condition", "autofill", "exclusion", "category-value", "hierarchy-column"].includes(tab)) {
|
||||||
setActiveTab(tab);
|
setActiveTab(tab);
|
||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
@ -46,7 +48,7 @@ export default function CascadingManagementPage() {
|
||||||
|
|
||||||
{/* 탭 네비게이션 */}
|
{/* 탭 네비게이션 */}
|
||||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-5">
|
<TabsList className="grid w-full grid-cols-6">
|
||||||
<TabsTrigger value="relations" className="gap-2">
|
<TabsTrigger value="relations" className="gap-2">
|
||||||
<Link2 className="h-4 w-4" />
|
<Link2 className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">2단계 연쇄관계</span>
|
<span className="hidden sm:inline">2단계 연쇄관계</span>
|
||||||
|
|
@ -72,6 +74,11 @@ export default function CascadingManagementPage() {
|
||||||
<span className="hidden sm:inline">상호 배제</span>
|
<span className="hidden sm:inline">상호 배제</span>
|
||||||
<span className="sm:hidden">배제</span>
|
<span className="sm:hidden">배제</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="category-value" className="gap-2">
|
||||||
|
<Tags className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">카테고리값</span>
|
||||||
|
<span className="sm:hidden">카테고리</span>
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* 탭 컨텐츠 */}
|
{/* 탭 컨텐츠 */}
|
||||||
|
|
@ -95,6 +102,10 @@ export default function CascadingManagementPage() {
|
||||||
<TabsContent value="exclusion">
|
<TabsContent value="exclusion">
|
||||||
<MutualExclusionTab />
|
<MutualExclusionTab />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="category-value">
|
||||||
|
<CategoryValueCascadingTab />
|
||||||
|
</TabsContent>
|
||||||
</div>
|
</div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,626 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Plus, Pencil, Trash2, Database, RefreshCw, Layers } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||||
|
import {
|
||||||
|
hierarchyColumnApi,
|
||||||
|
HierarchyColumnGroup,
|
||||||
|
CreateHierarchyGroupRequest,
|
||||||
|
} from "@/lib/api/hierarchyColumn";
|
||||||
|
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||||
|
import apiClient from "@/lib/api/client";
|
||||||
|
|
||||||
|
interface TableInfo {
|
||||||
|
tableName: string;
|
||||||
|
displayName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnInfo {
|
||||||
|
columnName: string;
|
||||||
|
displayName?: string;
|
||||||
|
dataType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryInfo {
|
||||||
|
categoryCode: string;
|
||||||
|
categoryName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HierarchyColumnTab() {
|
||||||
|
// 상태
|
||||||
|
const [groups, setGroups] = useState<HierarchyColumnGroup[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [selectedGroup, setSelectedGroup] = useState<HierarchyColumnGroup | null>(null);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
|
||||||
|
// 폼 상태
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
groupCode: "",
|
||||||
|
groupName: "",
|
||||||
|
description: "",
|
||||||
|
codeCategory: "",
|
||||||
|
tableName: "",
|
||||||
|
maxDepth: 3,
|
||||||
|
mappings: [
|
||||||
|
{ depth: 1, levelLabel: "대분류", columnName: "", placeholder: "대분류 선택", isRequired: true },
|
||||||
|
{ depth: 2, levelLabel: "중분류", columnName: "", placeholder: "중분류 선택", isRequired: false },
|
||||||
|
{ depth: 3, levelLabel: "소분류", columnName: "", placeholder: "소분류 선택", isRequired: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 참조 데이터
|
||||||
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||||
|
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
const [categories, setCategories] = useState<CategoryInfo[]>([]);
|
||||||
|
const [loadingTables, setLoadingTables] = useState(false);
|
||||||
|
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||||
|
const [loadingCategories, setLoadingCategories] = useState(false);
|
||||||
|
|
||||||
|
// 그룹 목록 로드
|
||||||
|
const loadGroups = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await hierarchyColumnApi.getAll();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setGroups(response.data);
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "계층구조 그룹 로드 실패");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("계층구조 그룹 로드 에러:", error);
|
||||||
|
toast.error("계층구조 그룹을 로드하는 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 테이블 목록 로드
|
||||||
|
const loadTables = useCallback(async () => {
|
||||||
|
setLoadingTables(true);
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get("/table-management/tables");
|
||||||
|
if (response.data?.success && response.data?.data) {
|
||||||
|
setTables(response.data.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("테이블 로드 에러:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingTables(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 카테고리 목록 로드
|
||||||
|
const loadCategories = useCallback(async () => {
|
||||||
|
setLoadingCategories(true);
|
||||||
|
try {
|
||||||
|
const response = await commonCodeApi.categories.getList();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setCategories(
|
||||||
|
response.data.map((cat: any) => ({
|
||||||
|
categoryCode: cat.categoryCode || cat.category_code,
|
||||||
|
categoryName: cat.categoryName || cat.category_name,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("카테고리 로드 에러:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingCategories(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 테이블 선택 시 컬럼 로드
|
||||||
|
const loadColumns = useCallback(async (tableName: string) => {
|
||||||
|
if (!tableName) {
|
||||||
|
setColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadingColumns(true);
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
||||||
|
if (response.data?.success && response.data?.data) {
|
||||||
|
setColumns(response.data.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("컬럼 로드 에러:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingColumns(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 초기 로드
|
||||||
|
useEffect(() => {
|
||||||
|
loadGroups();
|
||||||
|
loadTables();
|
||||||
|
loadCategories();
|
||||||
|
}, [loadGroups, loadTables, loadCategories]);
|
||||||
|
|
||||||
|
// 테이블 선택 변경 시 컬럼 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (formData.tableName) {
|
||||||
|
loadColumns(formData.tableName);
|
||||||
|
}
|
||||||
|
}, [formData.tableName, loadColumns]);
|
||||||
|
|
||||||
|
// 폼 초기화
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormData({
|
||||||
|
groupCode: "",
|
||||||
|
groupName: "",
|
||||||
|
description: "",
|
||||||
|
codeCategory: "",
|
||||||
|
tableName: "",
|
||||||
|
maxDepth: 3,
|
||||||
|
mappings: [
|
||||||
|
{ depth: 1, levelLabel: "대분류", columnName: "", placeholder: "대분류 선택", isRequired: true },
|
||||||
|
{ depth: 2, levelLabel: "중분류", columnName: "", placeholder: "중분류 선택", isRequired: false },
|
||||||
|
{ depth: 3, levelLabel: "소분류", columnName: "", placeholder: "소분류 선택", isRequired: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
setSelectedGroup(null);
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모달 열기 (신규)
|
||||||
|
const openCreateModal = () => {
|
||||||
|
resetForm();
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모달 열기 (수정)
|
||||||
|
const openEditModal = (group: HierarchyColumnGroup) => {
|
||||||
|
setSelectedGroup(group);
|
||||||
|
setIsEditing(true);
|
||||||
|
|
||||||
|
// 매핑 데이터 변환
|
||||||
|
const mappings = [1, 2, 3].map((depth) => {
|
||||||
|
const existing = group.mappings?.find((m) => m.depth === depth);
|
||||||
|
return {
|
||||||
|
depth,
|
||||||
|
levelLabel: existing?.level_label || (depth === 1 ? "대분류" : depth === 2 ? "중분류" : "소분류"),
|
||||||
|
columnName: existing?.column_name || "",
|
||||||
|
placeholder: existing?.placeholder || `${depth === 1 ? "대분류" : depth === 2 ? "중분류" : "소분류"} 선택`,
|
||||||
|
isRequired: existing?.is_required === "Y",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setFormData({
|
||||||
|
groupCode: group.group_code,
|
||||||
|
groupName: group.group_name,
|
||||||
|
description: group.description || "",
|
||||||
|
codeCategory: group.code_category,
|
||||||
|
tableName: group.table_name,
|
||||||
|
maxDepth: group.max_depth,
|
||||||
|
mappings,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 컬럼 로드
|
||||||
|
loadColumns(group.table_name);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 확인 열기
|
||||||
|
const openDeleteDialog = (group: HierarchyColumnGroup) => {
|
||||||
|
setSelectedGroup(group);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
const handleSave = async () => {
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!formData.groupCode || !formData.groupName || !formData.codeCategory || !formData.tableName) {
|
||||||
|
toast.error("필수 필드를 모두 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최소 1개 컬럼 매핑 검증
|
||||||
|
const validMappings = formData.mappings
|
||||||
|
.filter((m) => m.depth <= formData.maxDepth && m.columnName)
|
||||||
|
.map((m) => ({
|
||||||
|
depth: m.depth,
|
||||||
|
levelLabel: m.levelLabel,
|
||||||
|
columnName: m.columnName,
|
||||||
|
placeholder: m.placeholder,
|
||||||
|
isRequired: m.isRequired,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (validMappings.length === 0) {
|
||||||
|
toast.error("최소 하나의 컬럼 매핑이 필요합니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEditing && selectedGroup) {
|
||||||
|
// 수정
|
||||||
|
const response = await hierarchyColumnApi.update(selectedGroup.group_id, {
|
||||||
|
groupName: formData.groupName,
|
||||||
|
description: formData.description,
|
||||||
|
maxDepth: formData.maxDepth,
|
||||||
|
mappings: validMappings,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success("계층구조 그룹이 수정되었습니다.");
|
||||||
|
setModalOpen(false);
|
||||||
|
loadGroups();
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "수정 실패");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 생성
|
||||||
|
const request: CreateHierarchyGroupRequest = {
|
||||||
|
groupCode: formData.groupCode,
|
||||||
|
groupName: formData.groupName,
|
||||||
|
description: formData.description,
|
||||||
|
codeCategory: formData.codeCategory,
|
||||||
|
tableName: formData.tableName,
|
||||||
|
maxDepth: formData.maxDepth,
|
||||||
|
mappings: validMappings,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await hierarchyColumnApi.create(request);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success("계층구조 그룹이 생성되었습니다.");
|
||||||
|
setModalOpen(false);
|
||||||
|
loadGroups();
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "생성 실패");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("저장 에러:", error);
|
||||||
|
toast.error("저장 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!selectedGroup) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await hierarchyColumnApi.delete(selectedGroup.group_id);
|
||||||
|
if (response.success) {
|
||||||
|
toast.success("계층구조 그룹이 삭제되었습니다.");
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
loadGroups();
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "삭제 실패");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("삭제 에러:", error);
|
||||||
|
toast.error("삭제 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 매핑 컬럼 변경
|
||||||
|
const handleMappingChange = (depth: number, field: string, value: any) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
mappings: prev.mappings.map((m) =>
|
||||||
|
m.depth === depth ? { ...m, [field]: value } : m
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">계층구조 컬럼 그룹</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
공통코드 계층구조를 테이블 컬럼에 매핑하여 대분류/중분류/소분류를 각각 별도 컬럼에 저장합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={loadGroups} disabled={loading}>
|
||||||
|
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={openCreateModal}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
새 그룹 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 그룹 목록 */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<LoadingSpinner />
|
||||||
|
<span className="ml-2 text-muted-foreground">로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : groups.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Layers className="h-12 w-12 text-muted-foreground" />
|
||||||
|
<p className="mt-4 text-muted-foreground">계층구조 컬럼 그룹이 없습니다.</p>
|
||||||
|
<Button className="mt-4" onClick={openCreateModal}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
첫 번째 그룹 만들기
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<Card key={group.group_id} className="hover:shadow-md transition-shadow">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">{group.group_name}</CardTitle>
|
||||||
|
<CardDescription className="text-xs">{group.group_code}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEditModal(group)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => openDeleteDialog(group)}>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Database className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="font-medium">{group.table_name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline">{group.code_category}</Badge>
|
||||||
|
<Badge variant="secondary">{group.max_depth}단계</Badge>
|
||||||
|
</div>
|
||||||
|
{group.mappings && group.mappings.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{group.mappings.map((mapping) => (
|
||||||
|
<div key={mapping.depth} className="flex items-center gap-2 text-xs">
|
||||||
|
<Badge variant="outline" className="w-14 justify-center">
|
||||||
|
{mapping.level_label}
|
||||||
|
</Badge>
|
||||||
|
<span className="font-mono text-muted-foreground">{mapping.column_name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 생성/수정 모달 */}
|
||||||
|
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||||
|
<DialogContent className="max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEditing ? "계층구조 그룹 수정" : "계층구조 그룹 생성"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
공통코드 계층구조를 테이블 컬럼에 매핑합니다.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
{/* 기본 정보 */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>그룹 코드 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.groupCode}
|
||||||
|
onChange={(e) => setFormData({ ...formData, groupCode: e.target.value.toUpperCase() })}
|
||||||
|
placeholder="예: ITEM_CAT_HIERARCHY"
|
||||||
|
disabled={isEditing}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>그룹명 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.groupName}
|
||||||
|
onChange={(e) => setFormData({ ...formData, groupName: e.target.value })}
|
||||||
|
placeholder="예: 품목분류 계층"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>설명</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
placeholder="계층구조에 대한 설명"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>공통코드 카테고리 *</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.codeCategory}
|
||||||
|
onValueChange={(value) => setFormData({ ...formData, codeCategory: value })}
|
||||||
|
disabled={isEditing}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="카테고리 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{loadingCategories ? (
|
||||||
|
<SelectItem value="_loading" disabled>로딩 중...</SelectItem>
|
||||||
|
) : (
|
||||||
|
categories.map((cat) => (
|
||||||
|
<SelectItem key={cat.categoryCode} value={cat.categoryCode}>
|
||||||
|
{cat.categoryName} ({cat.categoryCode})
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>적용 테이블 *</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.tableName}
|
||||||
|
onValueChange={(value) => setFormData({ ...formData, tableName: value })}
|
||||||
|
disabled={isEditing}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="테이블 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{loadingTables ? (
|
||||||
|
<SelectItem value="_loading" disabled>로딩 중...</SelectItem>
|
||||||
|
) : (
|
||||||
|
tables.map((table) => (
|
||||||
|
<SelectItem key={table.tableName} value={table.tableName}>
|
||||||
|
{table.displayName || table.tableName}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>최대 깊이</Label>
|
||||||
|
<Select
|
||||||
|
value={String(formData.maxDepth)}
|
||||||
|
onValueChange={(value) => setFormData({ ...formData, maxDepth: Number(value) })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">1단계 (대분류만)</SelectItem>
|
||||||
|
<SelectItem value="2">2단계 (대/중분류)</SelectItem>
|
||||||
|
<SelectItem value="3">3단계 (대/중/소분류)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컬럼 매핑 */}
|
||||||
|
<div className="space-y-3 border-t pt-4">
|
||||||
|
<Label className="text-base font-medium">컬럼 매핑</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
각 계층 레벨에 저장할 컬럼을 선택합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{formData.mappings
|
||||||
|
.filter((m) => m.depth <= formData.maxDepth)
|
||||||
|
.map((mapping) => (
|
||||||
|
<div key={mapping.depth} className="grid grid-cols-4 gap-2 items-center">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={mapping.depth === 1 ? "default" : "outline"}>
|
||||||
|
{mapping.depth}단계
|
||||||
|
</Badge>
|
||||||
|
<Input
|
||||||
|
value={mapping.levelLabel}
|
||||||
|
onChange={(e) => handleMappingChange(mapping.depth, "levelLabel", e.target.value)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
placeholder="라벨"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={mapping.columnName || "_none"}
|
||||||
|
onValueChange={(value) => handleMappingChange(mapping.depth, "columnName", value === "_none" ? "" : value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="_none">컬럼 선택</SelectItem>
|
||||||
|
{loadingColumns ? (
|
||||||
|
<SelectItem value="_loading" disabled>로딩 중...</SelectItem>
|
||||||
|
) : (
|
||||||
|
columns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.displayName || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
value={mapping.placeholder}
|
||||||
|
onChange={(e) => handleMappingChange(mapping.depth, "placeholder", e.target.value)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
placeholder="플레이스홀더"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={mapping.isRequired}
|
||||||
|
onChange={(e) => handleMappingChange(mapping.depth, "isRequired", e.target.checked)}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">필수</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setModalOpen(false)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>
|
||||||
|
{isEditing ? "수정" : "생성"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>계층구조 그룹 삭제</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
"{selectedGroup?.group_name}" 그룹을 삭제하시겠습니까?
|
||||||
|
<br />
|
||||||
|
이 작업은 되돌릴 수 없습니다.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDelete}>
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { DepartmentManagement } from "@/components/admin/department/DepartmentManagement";
|
|
||||||
|
|
||||||
export default function DepartmentManagementPage() {
|
|
||||||
const params = useParams();
|
|
||||||
const companyCode = params.companyCode as string;
|
|
||||||
|
|
||||||
return <DepartmentManagement companyCode={companyCode} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import { CompanyManagement } from "@/components/admin/CompanyManagement";
|
|
||||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 회사 관리 페이지
|
|
||||||
*/
|
|
||||||
export default function CompanyPage() {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen flex-col bg-background">
|
|
||||||
<div className="space-y-6 p-6">
|
|
||||||
{/* 페이지 헤더 */}
|
|
||||||
<div className="space-y-2 border-b pb-4">
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">회사 관리</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">시스템에서 사용하는 회사 정보를 관리합니다</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 메인 컨텐츠 */}
|
|
||||||
<CompanyManagement />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scroll to Top 버튼 */}
|
|
||||||
<ScrollToTop />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,449 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { dashboardApi } from "@/lib/api/dashboard";
|
|
||||||
import { Dashboard } from "@/lib/api/dashboard";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
import { Pagination, PaginationInfo } from "@/components/common/Pagination";
|
|
||||||
import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal";
|
|
||||||
import { Plus, Search, Edit, Trash2, Copy, MoreVertical, AlertCircle, RefreshCw } from "lucide-react";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 대시보드 목록 클라이언트 컴포넌트
|
|
||||||
* - CSR 방식으로 초기 데이터 로드
|
|
||||||
* - 대시보드 목록 조회
|
|
||||||
* - 대시보드 생성/수정/삭제/복사
|
|
||||||
*/
|
|
||||||
export default function DashboardListClient() {
|
|
||||||
const router = useRouter();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
// 상태 관리
|
|
||||||
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true); // CSR이므로 초기 로딩 true
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
|
||||||
|
|
||||||
// 페이지네이션 상태
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [pageSize, setPageSize] = useState(10);
|
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
|
||||||
|
|
||||||
// 모달 상태
|
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null);
|
|
||||||
|
|
||||||
// 대시보드 목록 로드
|
|
||||||
const loadDashboards = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const result = await dashboardApi.getMyDashboards({
|
|
||||||
search: searchTerm,
|
|
||||||
page: currentPage,
|
|
||||||
limit: pageSize,
|
|
||||||
});
|
|
||||||
setDashboards(result.dashboards);
|
|
||||||
setTotalCount(result.pagination.total);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load dashboards:", err);
|
|
||||||
setError(
|
|
||||||
err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: "대시보드 목록을 불러오는데 실패했습니다. 네트워크 연결을 확인하거나 잠시 후 다시 시도해주세요.",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 검색어/페이지 변경 시 fetch (초기 로딩 포함)
|
|
||||||
useEffect(() => {
|
|
||||||
loadDashboards();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [searchTerm, currentPage, pageSize]);
|
|
||||||
|
|
||||||
// 페이지네이션 정보 계산
|
|
||||||
const paginationInfo: PaginationInfo = {
|
|
||||||
currentPage,
|
|
||||||
totalPages: Math.ceil(totalCount / pageSize) || 1,
|
|
||||||
totalItems: totalCount,
|
|
||||||
itemsPerPage: pageSize,
|
|
||||||
startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1,
|
|
||||||
endItem: Math.min(currentPage * pageSize, totalCount),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 페이지 변경 핸들러
|
|
||||||
const handlePageChange = (page: number) => {
|
|
||||||
setCurrentPage(page);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 페이지 크기 변경 핸들러
|
|
||||||
const handlePageSizeChange = (size: number) => {
|
|
||||||
setPageSize(size);
|
|
||||||
setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로
|
|
||||||
};
|
|
||||||
|
|
||||||
// 대시보드 삭제 확인 모달 열기
|
|
||||||
const handleDeleteClick = (id: string, title: string) => {
|
|
||||||
setDeleteTarget({ id, title });
|
|
||||||
setDeleteDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 대시보드 삭제 실행
|
|
||||||
const handleDeleteConfirm = async () => {
|
|
||||||
if (!deleteTarget) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await dashboardApi.deleteDashboard(deleteTarget.id);
|
|
||||||
setDeleteDialogOpen(false);
|
|
||||||
setDeleteTarget(null);
|
|
||||||
toast({
|
|
||||||
title: "성공",
|
|
||||||
description: "대시보드가 삭제되었습니다.",
|
|
||||||
});
|
|
||||||
loadDashboards();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to delete dashboard:", err);
|
|
||||||
setDeleteDialogOpen(false);
|
|
||||||
toast({
|
|
||||||
title: "오류",
|
|
||||||
description: "대시보드 삭제에 실패했습니다.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 대시보드 복사
|
|
||||||
const handleCopy = async (dashboard: Dashboard) => {
|
|
||||||
try {
|
|
||||||
const fullDashboard = await dashboardApi.getDashboard(dashboard.id);
|
|
||||||
|
|
||||||
await dashboardApi.createDashboard({
|
|
||||||
title: `${fullDashboard.title} (복사본)`,
|
|
||||||
description: fullDashboard.description,
|
|
||||||
elements: fullDashboard.elements || [],
|
|
||||||
isPublic: false,
|
|
||||||
tags: fullDashboard.tags,
|
|
||||||
category: fullDashboard.category,
|
|
||||||
settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string },
|
|
||||||
});
|
|
||||||
toast({
|
|
||||||
title: "성공",
|
|
||||||
description: "대시보드가 복사되었습니다.",
|
|
||||||
});
|
|
||||||
loadDashboards();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to copy dashboard:", err);
|
|
||||||
toast({
|
|
||||||
title: "오류",
|
|
||||||
description: "대시보드 복사에 실패했습니다.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 포맷팅 헬퍼
|
|
||||||
const formatDate = (dateString: string) => {
|
|
||||||
return new Date(dateString).toLocaleDateString("ko-KR", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* 검색 및 액션 */}
|
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="relative w-full sm:w-[300px]">
|
|
||||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
|
||||||
<Input
|
|
||||||
placeholder="대시보드 검색..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
className="h-10 pl-10 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground text-sm">
|
|
||||||
총 <span className="text-foreground font-semibold">{totalCount.toLocaleString()}</span> 건
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button onClick={() => router.push("/admin/dashboard/new")} className="h-10 gap-2 text-sm font-medium">
|
|
||||||
<Plus className="h-4 w-4" />새 대시보드 생성
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 대시보드 목록 */}
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
{/* 데스크톱 테이블 스켈레톤 */}
|
|
||||||
<div className="bg-card hidden shadow-sm lg:block">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">생성자</TableHead>
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
|
||||||
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{Array.from({ length: 10 }).map((_, index) => (
|
|
||||||
<TableRow key={index} className="border-b">
|
|
||||||
<TableCell className="h-16">
|
|
||||||
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16">
|
|
||||||
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16">
|
|
||||||
<div className="bg-muted h-4 w-20 animate-pulse rounded"></div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16">
|
|
||||||
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16">
|
|
||||||
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16 text-right">
|
|
||||||
<div className="bg-muted ml-auto h-8 w-8 animate-pulse rounded"></div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 모바일/태블릿 카드 스켈레톤 */}
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
|
||||||
{Array.from({ length: 6 }).map((_, index) => (
|
|
||||||
<div key={index} className="bg-card rounded-lg border p-4 shadow-sm">
|
|
||||||
<div className="mb-4 flex items-start justify-between">
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<div className="bg-muted h-5 w-32 animate-pulse rounded"></div>
|
|
||||||
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 border-t pt-4">
|
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
|
||||||
<div key={i} className="flex justify-between">
|
|
||||||
<div className="bg-muted h-4 w-16 animate-pulse rounded"></div>
|
|
||||||
<div className="bg-muted h-4 w-32 animate-pulse rounded"></div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : error ? (
|
|
||||||
<div className="border-destructive/50 bg-destructive/10 flex flex-col items-center justify-center rounded-lg border p-12">
|
|
||||||
<div className="flex flex-col items-center gap-4 text-center">
|
|
||||||
<div className="bg-destructive/20 flex h-16 w-16 items-center justify-center rounded-full">
|
|
||||||
<AlertCircle className="text-destructive h-8 w-8" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-destructive mb-2 text-lg font-semibold">데이터를 불러올 수 없습니다</h3>
|
|
||||||
<p className="text-destructive/80 max-w-md text-sm">{error}</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={loadDashboards} variant="outline" className="mt-2 gap-2">
|
|
||||||
<RefreshCw className="h-4 w-4" />
|
|
||||||
다시 시도
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : dashboards.length === 0 ? (
|
|
||||||
<div className="bg-card flex h-64 flex-col items-center justify-center shadow-sm">
|
|
||||||
<div className="flex flex-col items-center gap-2 text-center">
|
|
||||||
<p className="text-muted-foreground text-sm">대시보드가 없습니다</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* 데스크톱 테이블 뷰 (lg 이상) */}
|
|
||||||
<div className="bg-card hidden shadow-sm lg:block">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">생성자</TableHead>
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
|
||||||
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{dashboards.map((dashboard) => (
|
|
||||||
<TableRow key={dashboard.id} className="hover:bg-muted/50 border-b transition-colors">
|
|
||||||
<TableCell className="h-16 text-sm font-medium">
|
|
||||||
<button
|
|
||||||
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
|
|
||||||
className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
|
|
||||||
>
|
|
||||||
{dashboard.title}
|
|
||||||
</button>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
|
|
||||||
{dashboard.description || "-"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
|
||||||
{dashboard.createdByName || dashboard.createdBy || "-"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
|
||||||
{formatDate(dashboard.createdAt)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
|
||||||
{formatDate(dashboard.updatedAt)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16 text-right">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
|
||||||
<MoreVertical className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
|
|
||||||
className="gap-2 text-sm"
|
|
||||||
>
|
|
||||||
<Edit className="h-4 w-4" />
|
|
||||||
편집
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => handleCopy(dashboard)} className="gap-2 text-sm">
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
복사
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
|
|
||||||
className="text-destructive focus:text-destructive gap-2 text-sm"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
삭제
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
|
||||||
{dashboards.map((dashboard) => (
|
|
||||||
<div
|
|
||||||
key={dashboard.id}
|
|
||||||
className="bg-card hover:bg-muted/50 rounded-lg border p-4 shadow-sm transition-colors"
|
|
||||||
>
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="mb-4 flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<button
|
|
||||||
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
|
|
||||||
className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
|
|
||||||
>
|
|
||||||
<h3 className="text-base font-semibold">{dashboard.title}</h3>
|
|
||||||
</button>
|
|
||||||
<p className="text-muted-foreground mt-1 text-sm">{dashboard.id}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 정보 */}
|
|
||||||
<div className="space-y-2 border-t pt-4">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">설명</span>
|
|
||||||
<span className="max-w-[200px] truncate font-medium">{dashboard.description || "-"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">생성자</span>
|
|
||||||
<span className="font-medium">{dashboard.createdByName || dashboard.createdBy || "-"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">생성일</span>
|
|
||||||
<span className="font-medium">{formatDate(dashboard.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">수정일</span>
|
|
||||||
<span className="font-medium">{formatDate(dashboard.updatedAt)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 액션 */}
|
|
||||||
<div className="mt-4 flex gap-2 border-t pt-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-9 flex-1 gap-2 text-sm"
|
|
||||||
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
|
|
||||||
>
|
|
||||||
<Edit className="h-4 w-4" />
|
|
||||||
편집
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-9 flex-1 gap-2 text-sm"
|
|
||||||
onClick={() => handleCopy(dashboard)}
|
|
||||||
>
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
복사
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-9 gap-2 text-sm"
|
|
||||||
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 페이지네이션 */}
|
|
||||||
{!loading && dashboards.length > 0 && (
|
|
||||||
<Pagination
|
|
||||||
paginationInfo={paginationInfo}
|
|
||||||
onPageChange={handlePageChange}
|
|
||||||
onPageSizeChange={handlePageSizeChange}
|
|
||||||
showPageSizeSelector={true}
|
|
||||||
pageSizeOptions={[10, 20, 50, 100]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 삭제 확인 모달 */}
|
|
||||||
<DeleteConfirmModal
|
|
||||||
open={deleteDialogOpen}
|
|
||||||
onOpenChange={setDeleteDialogOpen}
|
|
||||||
title="대시보드 삭제"
|
|
||||||
description={
|
|
||||||
<>
|
|
||||||
"{deleteTarget?.title}" 대시보드를 삭제하시겠습니까?
|
|
||||||
<br />이 작업은 되돌릴 수 없습니다.
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
onConfirm={handleDeleteConfirm}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { use } from "react";
|
|
||||||
import DashboardDesigner from "@/components/admin/dashboard/DashboardDesigner";
|
|
||||||
|
|
||||||
interface PageProps {
|
|
||||||
params: Promise<{ id: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 대시보드 편집 페이지
|
|
||||||
* - 기존 대시보드 편집
|
|
||||||
*/
|
|
||||||
export default function DashboardEditPage({ params }: PageProps) {
|
|
||||||
const { id } = use(params);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full">
|
|
||||||
<DashboardDesigner dashboardId={id} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import DashboardDesigner from "@/components/admin/dashboard/DashboardDesigner";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 새 대시보드 생성 페이지
|
|
||||||
*/
|
|
||||||
export default function DashboardNewPage() {
|
|
||||||
return (
|
|
||||||
<div className="h-full">
|
|
||||||
<DashboardDesigner />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import DashboardListClient from "@/app/(main)/admin/dashboard/DashboardListClient";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 대시보드 관리 페이지
|
|
||||||
* - 클라이언트 컴포넌트를 렌더링하는 래퍼
|
|
||||||
* - 초기 로딩부터 CSR로 처리
|
|
||||||
*/
|
|
||||||
export default function DashboardListPage() {
|
|
||||||
return (
|
|
||||||
<div className="bg-background flex min-h-screen flex-col">
|
|
||||||
<div className="space-y-6 p-6">
|
|
||||||
{/* 페이지 헤더 */}
|
|
||||||
<div className="space-y-2 border-b pb-4">
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">대시보드 관리</h1>
|
|
||||||
<p className="text-muted-foreground text-sm">대시보드를 생성하고 관리할 수 있습니다</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 클라이언트 컴포넌트 */}
|
|
||||||
<DashboardListClient />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import MultiLang from "@/components/admin/MultiLang";
|
|
||||||
|
|
||||||
export default function I18nPage() {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50">
|
|
||||||
<div className="w-full max-w-none px-4 py-8">
|
|
||||||
<MultiLang />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,9 +1,124 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import MonitoringDashboard from "@/components/admin/MonitoringDashboard";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { RefreshCw, Play, Pause, AlertCircle, CheckCircle, Clock } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { BatchAPI, BatchMonitoring } from "@/lib/api/batch";
|
||||||
|
|
||||||
export default function MonitoringPage() {
|
export default function MonitoringPage() {
|
||||||
|
const [monitoring, setMonitoring] = useState<BatchMonitoring | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMonitoringData();
|
||||||
|
|
||||||
|
let interval: NodeJS.Timeout;
|
||||||
|
if (autoRefresh) {
|
||||||
|
interval = setInterval(loadMonitoringData, 30000); // 30초마다 자동 새로고침
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (interval) clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [autoRefresh]);
|
||||||
|
|
||||||
|
const loadMonitoringData = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await BatchAPI.getBatchMonitoring();
|
||||||
|
setMonitoring(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("모니터링 데이터 조회 오류:", error);
|
||||||
|
toast.error("모니터링 데이터를 불러오는데 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
loadMonitoringData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAutoRefresh = () => {
|
||||||
|
setAutoRefresh(!autoRefresh);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
||||||
|
case 'failed':
|
||||||
|
return <AlertCircle className="h-4 w-4 text-red-500" />;
|
||||||
|
case 'running':
|
||||||
|
return <Play className="h-4 w-4 text-blue-500" />;
|
||||||
|
case 'pending':
|
||||||
|
return <Clock className="h-4 w-4 text-yellow-500" />;
|
||||||
|
default:
|
||||||
|
return <Clock className="h-4 w-4 text-gray-500" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
const variants = {
|
||||||
|
completed: "bg-green-100 text-green-800",
|
||||||
|
failed: "bg-destructive/20 text-red-800",
|
||||||
|
running: "bg-primary/20 text-blue-800",
|
||||||
|
pending: "bg-yellow-100 text-yellow-800",
|
||||||
|
cancelled: "bg-gray-100 text-gray-800",
|
||||||
|
};
|
||||||
|
|
||||||
|
const labels = {
|
||||||
|
completed: "완료",
|
||||||
|
failed: "실패",
|
||||||
|
running: "실행 중",
|
||||||
|
pending: "대기 중",
|
||||||
|
cancelled: "취소됨",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge className={variants[status as keyof typeof variants] || variants.pending}>
|
||||||
|
{labels[status as keyof typeof labels] || status}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (ms: number) => {
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||||
|
return `${(ms / 60000).toFixed(1)}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSuccessRate = () => {
|
||||||
|
if (!monitoring) return 0;
|
||||||
|
const total = monitoring.successful_jobs_today + monitoring.failed_jobs_today;
|
||||||
|
if (total === 0) return 100;
|
||||||
|
return Math.round((monitoring.successful_jobs_today / total) * 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!monitoring) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-center">
|
||||||
|
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-2" />
|
||||||
|
<p>모니터링 데이터를 불러오는 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||||
|
|
@ -16,7 +131,170 @@ export default function MonitoringPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 모니터링 대시보드 */}
|
{/* 모니터링 대시보드 */}
|
||||||
<MonitoringDashboard />
|
<div className="space-y-6">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-2xl font-bold">배치 모니터링</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={toggleAutoRefresh}
|
||||||
|
className={autoRefresh ? "bg-accent text-primary" : ""}
|
||||||
|
>
|
||||||
|
{autoRefresh ? <Pause className="h-4 w-4 mr-1" /> : <Play className="h-4 w-4 mr-1" />}
|
||||||
|
자동 새로고침
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 mr-1 ${isLoading ? 'animate-spin' : ''}`} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 통계 카드 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">총 작업 수</CardTitle>
|
||||||
|
<div className="text-2xl">📋</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{monitoring.total_jobs}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
활성: {monitoring.active_jobs}개
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">실행 중</CardTitle>
|
||||||
|
<div className="text-2xl">🔄</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-primary">{monitoring.running_jobs}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
현재 실행 중인 작업
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">오늘 성공</CardTitle>
|
||||||
|
<div className="text-2xl">✅</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-green-600">{monitoring.successful_jobs_today}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
성공률: {getSuccessRate()}%
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">오늘 실패</CardTitle>
|
||||||
|
<div className="text-2xl">❌</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-destructive">{monitoring.failed_jobs_today}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
주의가 필요한 작업
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 성공률 진행바 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">오늘 실행 성공률</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span>성공: {monitoring.successful_jobs_today}건</span>
|
||||||
|
<span>실패: {monitoring.failed_jobs_today}건</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={getSuccessRate()} className="h-2" />
|
||||||
|
<div className="text-center text-sm text-muted-foreground">
|
||||||
|
{getSuccessRate()}% 성공률
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 최근 실행 이력 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">최근 실행 이력</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{monitoring.recent_executions.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
최근 실행 이력이 없습니다.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>상태</TableHead>
|
||||||
|
<TableHead>작업 ID</TableHead>
|
||||||
|
<TableHead>시작 시간</TableHead>
|
||||||
|
<TableHead>완료 시간</TableHead>
|
||||||
|
<TableHead>실행 시간</TableHead>
|
||||||
|
<TableHead>오류 메시지</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{monitoring.recent_executions.map((execution) => (
|
||||||
|
<TableRow key={execution.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getStatusIcon(execution.execution_status)}
|
||||||
|
{getStatusBadge(execution.execution_status)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono">#{execution.job_id}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{execution.started_at
|
||||||
|
? new Date(execution.started_at).toLocaleString()
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{execution.completed_at
|
||||||
|
? new Date(execution.completed_at).toLocaleString()
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{execution.execution_time_ms
|
||||||
|
? formatDuration(execution.execution_time_ms)
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-xs">
|
||||||
|
{execution.error_message ? (
|
||||||
|
<span className="text-destructive text-sm truncate block">
|
||||||
|
{execution.error_message}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package } from "lucide-react";
|
import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package, Building2 } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { GlobalFileViewer } from "@/components/GlobalFileViewer";
|
import { GlobalFileViewer } from "@/components/GlobalFileViewer";
|
||||||
|
|
||||||
|
|
@ -9,6 +9,7 @@ export default function AdminPage() {
|
||||||
return (
|
return (
|
||||||
<div className="bg-background min-h-screen">
|
<div className="bg-background min-h-screen">
|
||||||
<div className="w-full max-w-none space-y-16 px-4 pt-12 pb-16">
|
<div className="w-full max-w-none space-y-16 px-4 pt-12 pb-16">
|
||||||
|
|
||||||
{/* 주요 관리 기능 */}
|
{/* 주요 관리 기능 */}
|
||||||
<div className="mx-auto max-w-7xl space-y-10">
|
<div className="mx-auto max-w-7xl space-y-10">
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
|
|
@ -168,7 +169,7 @@ export default function AdminPage() {
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/admin/external-connections" className="block">
|
<Link href="/admin/automaticMng/exconList" className="block">
|
||||||
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
|
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="bg-success/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
<div className="bg-success/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||||
|
|
@ -182,7 +183,7 @@ export default function AdminPage() {
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/admin/commonCode" className="block">
|
<Link href="/admin/systemMng/commonCodeList" className="block">
|
||||||
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
|
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||||
|
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { use } from "react";
|
|
||||||
import { RoleDetailManagement } from "@/components/admin/RoleDetailManagement";
|
|
||||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 권한 그룹 상세 페이지
|
|
||||||
* URL: /admin/roles/[id]
|
|
||||||
*
|
|
||||||
* 기능:
|
|
||||||
* - 권한 그룹 멤버 관리 (Dual List Box)
|
|
||||||
* - 메뉴 권한 설정 (CRUD 체크박스)
|
|
||||||
*/
|
|
||||||
export default function RoleDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
|
||||||
// Next.js 15: params는 Promise이므로 React.use()로 unwrap
|
|
||||||
const { id } = use(params);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-background flex min-h-screen flex-col">
|
|
||||||
<div className="space-y-6 p-6">
|
|
||||||
{/* 메인 컨텐츠 */}
|
|
||||||
<RoleDetailManagement roleId={id} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
|
|
||||||
<ScrollToTop />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { RoleManagement } from "@/components/admin/RoleManagement";
|
|
||||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 권한 그룹 관리 페이지
|
|
||||||
* URL: /admin/roles
|
|
||||||
*
|
|
||||||
* shadcn/ui 스타일 가이드 적용
|
|
||||||
*
|
|
||||||
* 기능:
|
|
||||||
* - 회사별 권한 그룹 목록 조회
|
|
||||||
* - 권한 그룹 생성/수정/삭제
|
|
||||||
* - 멤버 관리 (Dual List Box)
|
|
||||||
* - 메뉴 권한 설정 (CRUD 권한)
|
|
||||||
*/
|
|
||||||
export default function RolesPage() {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen flex-col bg-background">
|
|
||||||
<div className="space-y-6 p-6">
|
|
||||||
{/* 페이지 헤더 */}
|
|
||||||
<div className="space-y-2 border-b pb-4">
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">권한 그룹 관리</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
회사 내 권한 그룹을 생성하고 멤버를 관리합니다 (회사 관리자 이상)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 메인 컨텐츠 */}
|
|
||||||
<RoleManagement />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
|
|
||||||
<ScrollToTop />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,17 +1,18 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useRef, useCallback } from "react";
|
import React, { useState, useRef, useCallback } from "react";
|
||||||
|
import { use } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { DashboardCanvas } from "./DashboardCanvas";
|
import { DashboardCanvas } from "@/components/admin/dashboard/DashboardCanvas";
|
||||||
import { DashboardTopMenu } from "./DashboardTopMenu";
|
import { DashboardTopMenu } from "@/components/admin/dashboard/DashboardTopMenu";
|
||||||
import { WidgetConfigSidebar } from "./WidgetConfigSidebar";
|
import { WidgetConfigSidebar } from "@/components/admin/dashboard/WidgetConfigSidebar";
|
||||||
import { DashboardSaveModal } from "./DashboardSaveModal";
|
import { DashboardSaveModal } from "@/components/admin/dashboard/DashboardSaveModal";
|
||||||
import { DashboardElement, ElementType, ElementSubtype } from "./types";
|
import { DashboardElement, ElementType, ElementSubtype } from "@/components/admin/dashboard/types";
|
||||||
import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateBoxSize } from "./gridUtils";
|
import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateBoxSize } from "@/components/admin/dashboard/gridUtils";
|
||||||
import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector";
|
import { Resolution, RESOLUTIONS, detectScreenResolution } from "@/components/admin/dashboard/ResolutionSelector";
|
||||||
import { DashboardProvider } from "@/contexts/DashboardContext";
|
import { DashboardProvider } from "@/contexts/DashboardContext";
|
||||||
import { useMenu } from "@/contexts/MenuContext";
|
import { useMenu } from "@/contexts/MenuContext";
|
||||||
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
|
import { useKeyboardShortcuts } from "@/components/admin/dashboard/hooks/useKeyboardShortcuts";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -32,18 +33,24 @@ import {
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CheckCircle2 } from "lucide-react";
|
import { CheckCircle2 } from "lucide-react";
|
||||||
|
|
||||||
interface DashboardDesignerProps {
|
|
||||||
dashboardId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 대시보드 설계 도구 메인 컴포넌트
|
* 대시보드 생성/편집 페이지
|
||||||
|
* URL: /admin/screenMng/dashboardList/[id]
|
||||||
|
* - id가 "new"면 새 대시보드 생성
|
||||||
|
* - id가 숫자면 기존 대시보드 편집
|
||||||
|
*
|
||||||
|
* 기능:
|
||||||
* - 드래그 앤 드롭으로 차트/위젯 배치
|
* - 드래그 앤 드롭으로 차트/위젯 배치
|
||||||
* - 그리드 기반 레이아웃 (12 컬럼)
|
* - 그리드 기반 레이아웃 (12 컬럼)
|
||||||
* - 요소 이동, 크기 조절, 삭제 기능
|
* - 요소 이동, 크기 조절, 삭제 기능
|
||||||
* - 레이아웃 저장/불러오기 기능
|
* - 레이아웃 저장/불러오기 기능
|
||||||
*/
|
*/
|
||||||
export default function DashboardDesigner({ dashboardId: initialDashboardId }: DashboardDesignerProps = {}) {
|
export default function DashboardDesignerPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id: paramId } = use(params);
|
||||||
|
|
||||||
|
// "new"면 생성 모드, 아니면 편집 모드
|
||||||
|
const initialDashboardId = paramId === "new" ? undefined : paramId;
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { refreshMenus } = useMenu();
|
const { refreshMenus } = useMenu();
|
||||||
const [elements, setElements] = useState<DashboardElement[]>([]);
|
const [elements, setElements] = useState<DashboardElement[]>([]);
|
||||||
|
|
@ -643,7 +650,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
open={successModalOpen}
|
open={successModalOpen}
|
||||||
onOpenChange={() => {
|
onOpenChange={() => {
|
||||||
setSuccessModalOpen(false);
|
setSuccessModalOpen(false);
|
||||||
router.push("/admin/dashboard");
|
router.push("/admin/screenMng/dashboardList");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
|
|
@ -660,7 +667,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSuccessModalOpen(false);
|
setSuccessModalOpen(false);
|
||||||
router.push("/admin/dashboard");
|
router.push("/admin/screenMng/dashboardList");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
확인
|
확인
|
||||||
|
|
@ -0,0 +1,458 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { dashboardApi } from "@/lib/api/dashboard";
|
||||||
|
import { Dashboard } from "@/lib/api/dashboard";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { Pagination, PaginationInfo } from "@/components/common/Pagination";
|
||||||
|
import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal";
|
||||||
|
import { Plus, Search, Edit, Trash2, Copy, MoreVertical, AlertCircle, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 관리 페이지
|
||||||
|
* - CSR 방식으로 초기 데이터 로드
|
||||||
|
* - 대시보드 목록 조회
|
||||||
|
* - 대시보드 생성/수정/삭제/복사
|
||||||
|
*/
|
||||||
|
export default function DashboardListPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// 상태 관리
|
||||||
|
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true); // CSR이므로 초기 로딩 true
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
|
// 페이지네이션 상태
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null);
|
||||||
|
|
||||||
|
// 대시보드 목록 로드
|
||||||
|
const loadDashboards = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const result = await dashboardApi.getMyDashboards({
|
||||||
|
search: searchTerm,
|
||||||
|
page: currentPage,
|
||||||
|
limit: pageSize,
|
||||||
|
});
|
||||||
|
setDashboards(result.dashboards);
|
||||||
|
setTotalCount(result.pagination.total);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load dashboards:", err);
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "대시보드 목록을 불러오는데 실패했습니다. 네트워크 연결을 확인하거나 잠시 후 다시 시도해주세요.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 검색어/페이지 변경 시 fetch (초기 로딩 포함)
|
||||||
|
useEffect(() => {
|
||||||
|
loadDashboards();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [searchTerm, currentPage, pageSize]);
|
||||||
|
|
||||||
|
// 페이지네이션 정보 계산
|
||||||
|
const paginationInfo: PaginationInfo = {
|
||||||
|
currentPage,
|
||||||
|
totalPages: Math.ceil(totalCount / pageSize) || 1,
|
||||||
|
totalItems: totalCount,
|
||||||
|
itemsPerPage: pageSize,
|
||||||
|
startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1,
|
||||||
|
endItem: Math.min(currentPage * pageSize, totalCount),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 페이지 변경 핸들러
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 페이지 크기 변경 핸들러
|
||||||
|
const handlePageSizeChange = (size: number) => {
|
||||||
|
setPageSize(size);
|
||||||
|
setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로
|
||||||
|
};
|
||||||
|
|
||||||
|
// 대시보드 삭제 확인 모달 열기
|
||||||
|
const handleDeleteClick = (id: string, title: string) => {
|
||||||
|
setDeleteTarget({ id, title });
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 대시보드 삭제 실행
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dashboardApi.deleteDashboard(deleteTarget.id);
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
toast({
|
||||||
|
title: "성공",
|
||||||
|
description: "대시보드가 삭제되었습니다.",
|
||||||
|
});
|
||||||
|
loadDashboards();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete dashboard:", err);
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
toast({
|
||||||
|
title: "오류",
|
||||||
|
description: "대시보드 삭제에 실패했습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 대시보드 복사
|
||||||
|
const handleCopy = async (dashboard: Dashboard) => {
|
||||||
|
try {
|
||||||
|
const fullDashboard = await dashboardApi.getDashboard(dashboard.id);
|
||||||
|
|
||||||
|
await dashboardApi.createDashboard({
|
||||||
|
title: `${fullDashboard.title} (복사본)`,
|
||||||
|
description: fullDashboard.description,
|
||||||
|
elements: fullDashboard.elements || [],
|
||||||
|
isPublic: false,
|
||||||
|
tags: fullDashboard.tags,
|
||||||
|
category: fullDashboard.category,
|
||||||
|
settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string },
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: "성공",
|
||||||
|
description: "대시보드가 복사되었습니다.",
|
||||||
|
});
|
||||||
|
loadDashboards();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to copy dashboard:", err);
|
||||||
|
toast({
|
||||||
|
title: "오류",
|
||||||
|
description: "대시보드 복사에 실패했습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 포맷팅 헬퍼
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString("ko-KR", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex min-h-screen flex-col">
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
{/* 페이지 헤더 */}
|
||||||
|
<div className="space-y-2 border-b pb-4">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">대시보드 관리</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">대시보드를 생성하고 관리할 수 있습니다</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 및 액션 */}
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative w-full sm:w-[300px]">
|
||||||
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
placeholder="대시보드 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="h-10 pl-10 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
총 <span className="text-foreground font-semibold">{totalCount.toLocaleString()}</span> 건
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => router.push("/admin/screenMng/dashboardList/new")} className="h-10 gap-2 text-sm font-medium">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
새 대시보드 생성
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 대시보드 목록 */}
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
{/* 데스크톱 테이블 스켈레톤 */}
|
||||||
|
<div className="bg-card hidden shadow-sm lg:block">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">생성자</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
||||||
|
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{Array.from({ length: 10 }).map((_, index) => (
|
||||||
|
<TableRow key={index} className="border-b">
|
||||||
|
<TableCell className="h-16">
|
||||||
|
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-16">
|
||||||
|
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-16">
|
||||||
|
<div className="bg-muted h-4 w-20 animate-pulse rounded"></div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-16">
|
||||||
|
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-16">
|
||||||
|
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-16 text-right">
|
||||||
|
<div className="bg-muted ml-auto h-8 w-8 animate-pulse rounded"></div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모바일/태블릿 카드 스켈레톤 */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
||||||
|
{Array.from({ length: 6 }).map((_, index) => (
|
||||||
|
<div key={index} className="bg-card rounded-lg border p-4 shadow-sm">
|
||||||
|
<div className="mb-4 flex items-start justify-between">
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="bg-muted h-5 w-32 animate-pulse rounded"></div>
|
||||||
|
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 border-t pt-4">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex justify-between">
|
||||||
|
<div className="bg-muted h-4 w-16 animate-pulse rounded"></div>
|
||||||
|
<div className="bg-muted h-4 w-32 animate-pulse rounded"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : error ? (
|
||||||
|
<div className="border-destructive/50 bg-destructive/10 flex flex-col items-center justify-center rounded-lg border p-12">
|
||||||
|
<div className="flex flex-col items-center gap-4 text-center">
|
||||||
|
<div className="bg-destructive/20 flex h-16 w-16 items-center justify-center rounded-full">
|
||||||
|
<AlertCircle className="text-destructive h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-destructive mb-2 text-lg font-semibold">데이터를 불러올 수 없습니다</h3>
|
||||||
|
<p className="text-destructive/80 max-w-md text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={loadDashboards} variant="outline" className="mt-2 gap-2">
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
다시 시도
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : dashboards.length === 0 ? (
|
||||||
|
<div className="bg-card flex h-64 flex-col items-center justify-center shadow-sm">
|
||||||
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
|
<p className="text-muted-foreground text-sm">대시보드가 없습니다</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 데스크톱 테이블 뷰 (lg 이상) */}
|
||||||
|
<div className="bg-card hidden shadow-sm lg:block">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">생성자</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
||||||
|
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{dashboards.map((dashboard) => (
|
||||||
|
<TableRow key={dashboard.id} className="hover:bg-muted/50 border-b transition-colors">
|
||||||
|
<TableCell className="h-16 text-sm font-medium">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
|
||||||
|
className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
|
||||||
|
>
|
||||||
|
{dashboard.title}
|
||||||
|
</button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
|
||||||
|
{dashboard.description || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||||
|
{dashboard.createdByName || dashboard.createdBy || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||||
|
{formatDate(dashboard.createdAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||||
|
{formatDate(dashboard.updatedAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-16 text-right">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
|
||||||
|
className="gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
편집
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleCopy(dashboard)} className="gap-2 text-sm">
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
복사
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
|
||||||
|
className="text-destructive focus:text-destructive gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
삭제
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
||||||
|
{dashboards.map((dashboard) => (
|
||||||
|
<div
|
||||||
|
key={dashboard.id}
|
||||||
|
className="bg-card hover:bg-muted/50 rounded-lg border p-4 shadow-sm transition-colors"
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="mb-4 flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
|
||||||
|
className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
|
||||||
|
>
|
||||||
|
<h3 className="text-base font-semibold">{dashboard.title}</h3>
|
||||||
|
</button>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">{dashboard.id}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 정보 */}
|
||||||
|
<div className="space-y-2 border-t pt-4">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">설명</span>
|
||||||
|
<span className="max-w-[200px] truncate font-medium">{dashboard.description || "-"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">생성자</span>
|
||||||
|
<span className="font-medium">{dashboard.createdByName || dashboard.createdBy || "-"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">생성일</span>
|
||||||
|
<span className="font-medium">{formatDate(dashboard.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">수정일</span>
|
||||||
|
<span className="font-medium">{formatDate(dashboard.updatedAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 액션 */}
|
||||||
|
<div className="mt-4 flex gap-2 border-t pt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 flex-1 gap-2 text-sm"
|
||||||
|
onClick={() => router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
편집
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 flex-1 gap-2 text-sm"
|
||||||
|
onClick={() => handleCopy(dashboard)}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
복사
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-9 gap-2 text-sm"
|
||||||
|
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
{!loading && dashboards.length > 0 && (
|
||||||
|
<Pagination
|
||||||
|
paginationInfo={paginationInfo}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onPageSizeChange={handlePageSizeChange}
|
||||||
|
showPageSizeSelector={true}
|
||||||
|
pageSizeOptions={[10, 20, 50, 100]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 삭제 확인 모달 */}
|
||||||
|
<DeleteConfirmModal
|
||||||
|
open={deleteDialogOpen}
|
||||||
|
onOpenChange={setDeleteDialogOpen}
|
||||||
|
title="대시보드 삭제"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
"{deleteTarget?.title}" 대시보드를 삭제하시겠습니까?
|
||||||
|
<br />이 작업은 되돌릴 수 없습니다.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,129 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { ArrowLeft } from "lucide-react";
|
|
||||||
import ScreenList from "@/components/screen/ScreenList";
|
|
||||||
import ScreenDesigner from "@/components/screen/ScreenDesigner";
|
|
||||||
import TemplateManager from "@/components/screen/TemplateManager";
|
|
||||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
|
||||||
import { ScreenDefinition } from "@/types/screen";
|
|
||||||
|
|
||||||
// 단계별 진행을 위한 타입 정의
|
|
||||||
type Step = "list" | "design" | "template";
|
|
||||||
|
|
||||||
export default function ScreenManagementPage() {
|
|
||||||
const [currentStep, setCurrentStep] = useState<Step>("list");
|
|
||||||
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
|
|
||||||
const [stepHistory, setStepHistory] = useState<Step[]>(["list"]);
|
|
||||||
|
|
||||||
// 화면 설계 모드일 때는 전체 화면 사용
|
|
||||||
const isDesignMode = currentStep === "design";
|
|
||||||
|
|
||||||
// 단계별 제목과 설명
|
|
||||||
const stepConfig = {
|
|
||||||
list: {
|
|
||||||
title: "화면 목록 관리",
|
|
||||||
description: "생성된 화면들을 확인하고 관리하세요",
|
|
||||||
},
|
|
||||||
design: {
|
|
||||||
title: "화면 설계",
|
|
||||||
description: "드래그앤드롭으로 화면을 설계하세요",
|
|
||||||
},
|
|
||||||
template: {
|
|
||||||
title: "템플릿 관리",
|
|
||||||
description: "화면 템플릿을 관리하고 재사용하세요",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// 다음 단계로 이동
|
|
||||||
const goToNextStep = (nextStep: Step) => {
|
|
||||||
setStepHistory((prev) => [...prev, nextStep]);
|
|
||||||
setCurrentStep(nextStep);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 이전 단계로 이동
|
|
||||||
const goToPreviousStep = () => {
|
|
||||||
if (stepHistory.length > 1) {
|
|
||||||
const newHistory = stepHistory.slice(0, -1);
|
|
||||||
const previousStep = newHistory[newHistory.length - 1];
|
|
||||||
setStepHistory(newHistory);
|
|
||||||
setCurrentStep(previousStep);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 특정 단계로 이동
|
|
||||||
const goToStep = (step: Step) => {
|
|
||||||
setCurrentStep(step);
|
|
||||||
// 해당 단계까지의 히스토리만 유지
|
|
||||||
const stepIndex = stepHistory.findIndex((s) => s === step);
|
|
||||||
if (stepIndex !== -1) {
|
|
||||||
setStepHistory(stepHistory.slice(0, stepIndex + 1));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 화면 설계 모드일 때는 레이아웃 없이 전체 화면 사용 (고정 높이)
|
|
||||||
if (isDesignMode) {
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 bg-background">
|
|
||||||
<ScreenDesigner selectedScreen={selectedScreen} onBackToList={() => goToStep("list")} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen flex-col bg-background">
|
|
||||||
<div className="space-y-6 p-6">
|
|
||||||
{/* 페이지 헤더 */}
|
|
||||||
<div className="space-y-2 border-b pb-4">
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">화면 관리</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">화면을 설계하고 템플릿을 관리합니다</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 단계별 내용 */}
|
|
||||||
<div className="flex-1">
|
|
||||||
{/* 화면 목록 단계 */}
|
|
||||||
{currentStep === "list" && (
|
|
||||||
<ScreenList
|
|
||||||
onScreenSelect={setSelectedScreen}
|
|
||||||
selectedScreen={selectedScreen}
|
|
||||||
onDesignScreen={(screen) => {
|
|
||||||
setSelectedScreen(screen);
|
|
||||||
goToNextStep("design");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 템플릿 관리 단계 */}
|
|
||||||
{currentStep === "template" && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between rounded-lg border bg-card p-4 shadow-sm">
|
|
||||||
<h2 className="text-xl font-semibold">{stepConfig.template.title}</h2>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={goToPreviousStep}
|
|
||||||
className="h-10 gap-2 text-sm font-medium"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
이전 단계
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => goToStep("list")}
|
|
||||||
className="h-10 gap-2 text-sm font-medium"
|
|
||||||
>
|
|
||||||
목록으로 돌아가기
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<TemplateManager selectedScreen={selectedScreen} onBackToList={() => goToStep("list")} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scroll to Top 버튼 */}
|
|
||||||
<ScrollToTop />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue