Compare commits
971
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be14fe2603 | ||
|
|
d7f101ee30 | ||
|
|
35b9f813c4 | ||
|
|
aafb326662 | ||
|
|
aa1b309b26 | ||
|
|
bf0aef59cd | ||
|
|
b2ddcd0cf3 | ||
|
|
49ace44c9b | ||
|
|
91576f401b | ||
|
|
febcd18eda | ||
|
|
8cc9f0d291 | ||
|
|
a8f2c8f41a | ||
|
|
4d685d5a67 | ||
|
|
96196229f4 | ||
|
|
94b2e84c10 | ||
|
|
80d2230c9a | ||
|
|
d5f0c3bc95 | ||
|
|
b7134cb3e4 | ||
|
|
ec78658e46 | ||
|
|
44361af392 | ||
|
|
76130caa6d | ||
|
|
352a78a0c5 | ||
|
|
65bee9586d | ||
|
|
31cda8579a | ||
|
|
1dc40b9efc | ||
|
|
6cb3d8ad65 | ||
|
|
225e2732d1 | ||
|
|
d82ae99b6a | ||
|
|
23341b93c7 | ||
|
|
b99f4904b0 | ||
|
|
f742feca4b | ||
|
|
569e003827 | ||
|
|
2db5dd59af | ||
|
|
8a8504dca9 | ||
|
|
7e82c6a802 | ||
|
|
bdc6251474 | ||
|
|
479cf5c4a0 | ||
|
|
2b3502161c | ||
|
|
0544a72011 | ||
|
|
34ba09ce1b | ||
|
|
aa5a71ae68 | ||
|
|
9067e645b3 | ||
|
|
10d3fc096a | ||
|
|
7fd95ba6f9 | ||
|
|
e707637c88 | ||
|
|
5da89e8ee7 | ||
|
|
f9ff279895 | ||
|
|
4cde9b67c1 | ||
|
|
80d93c1631 | ||
|
|
872b3d08c0 | ||
|
|
cb191817a8 | ||
|
|
8198fda45b | ||
|
|
37374cb754 | ||
|
|
9e36edec7f | ||
|
|
3204bba71b | ||
|
|
3cdcf6a6f7 | ||
|
|
922861c872 | ||
|
|
4a3945f1e4 | ||
|
|
a1caa1b860 | ||
|
|
166dea2b86 | ||
|
|
3f7262e213 | ||
|
|
231ee10ff8 | ||
|
|
5aa9ab4f39 | ||
|
|
8c46f7ca06 | ||
|
|
23e15daa37 | ||
|
|
dd56ed60ad | ||
|
|
18129905d8 | ||
|
|
d03263a3c6 | ||
|
|
be46bcbd17 | ||
|
|
201dbf577f | ||
|
|
884dd71ca7 | ||
|
|
70efdc9db5 | ||
|
|
36549e7281 | ||
|
|
c1ffc33834 | ||
|
|
2c3961644c | ||
|
|
2fe3631a0d | ||
|
|
e0cdba00be | ||
|
|
5e206dd210 | ||
|
|
2008a5a9ee | ||
|
|
53aeeccc2a | ||
|
|
c7738a6d6c | ||
|
|
baf29df950 | ||
|
|
0a13f34092 | ||
|
|
0b45361135 | ||
|
|
83420c76b8 | ||
|
|
42f83bcd24 | ||
|
|
ccfbe6ae32 | ||
|
|
8126fb844a | ||
|
|
23c9ddf4ce | ||
|
|
63388ccc90 | ||
|
|
bda3228d0f | ||
|
|
95ca40913f | ||
|
|
8bfe1c9ab2 | ||
|
|
0dc981819f | ||
|
|
4b7ada9362 | ||
|
|
71c5dfb8dd | ||
|
|
7e3911dbd6 | ||
|
|
839d31b9ca | ||
|
|
386fdade59 | ||
|
|
0cbbc3b934 | ||
|
|
f5051225ea | ||
|
|
f7218fc98a | ||
|
|
c9e3ff0d91 | ||
|
|
474ad1ab26 | ||
|
|
099e000f88 | ||
|
|
7fa4538667 | ||
|
|
91e11a7857 | ||
|
|
d582293e5f | ||
|
|
d8c5344451 | ||
|
|
d23a16905d | ||
|
|
2649ce286b | ||
|
|
2f9e97b65b | ||
|
|
e1940043e9 | ||
|
|
7f1c804a0c | ||
|
|
d07bb6074d | ||
|
|
4109691eea | ||
|
|
4eaac5dbf1 | ||
|
|
b99fe8daf1 | ||
|
|
3f4e3581dd | ||
|
|
f00627270f | ||
|
|
1858739bb9 | ||
|
|
3d320d0e19 | ||
|
|
9d2c0afb98 | ||
|
|
29f2a9169e | ||
|
|
493acc9ebd | ||
|
|
d3bdc58994 | ||
|
|
20efccb4d9 | ||
|
|
f58daf3bac | ||
|
|
b42810d29a | ||
|
|
2e6a75141b | ||
|
|
ce60e5f5fb | ||
|
|
c9d85a8ce4 | ||
|
|
0f8b6cd6d0 | ||
|
|
b11658e1d6 | ||
|
|
bcf5d3caec | ||
|
|
474676af17 | ||
|
|
3cc0e9de14 | ||
|
|
ff7be74437 | ||
|
|
f52f22e80f | ||
|
|
86e00d48dc | ||
|
|
bf61f6f76a | ||
|
|
62c41297c4 | ||
|
|
09b5cc3798 | ||
|
|
155a727a77 | ||
|
|
a52dacf500 | ||
|
|
e8e38310ee | ||
|
|
2652e2cfe0 | ||
|
|
5a76ae8a88 | ||
|
|
7d9b97aac9 | ||
|
|
69c3183e35 | ||
|
|
5e6477cddd | ||
|
|
c13cab394a | ||
|
|
73f1940cb0 | ||
|
|
8a35d232ef | ||
|
|
728981cfe6 | ||
|
|
11cb2b9044 | ||
|
|
c1d6cd7b8e | ||
|
|
477426c384 | ||
|
|
ec60a05b01 | ||
|
|
90f0e4691b | ||
|
|
b41a317788 | ||
|
|
559e4ee352 | ||
|
|
a1d43dc65c | ||
|
|
d26cd90b02 | ||
|
|
45f72f7333 | ||
|
|
196d31a084 | ||
|
|
2f0448a98e | ||
|
|
5e0693e057 | ||
|
|
1708555133 | ||
|
|
c38d95fd60 | ||
|
|
667f7b2094 | ||
|
|
d17909df41 | ||
|
|
88113fca1c | ||
|
|
81fc947c9d | ||
|
|
c684440e65 | ||
|
|
4c6222acf1 | ||
|
|
2131ecc315 | ||
|
|
f53c72f31f | ||
|
|
1d3910c157 | ||
|
|
3b2f5c2d5d | ||
|
|
903cdaad45 | ||
|
|
386fc61a3f | ||
|
|
6e354690c5 | ||
|
|
adae670318 | ||
|
|
bdbefbf600 | ||
|
|
939089a79c | ||
|
|
f8354344ff | ||
|
|
e596c1f0a6 | ||
|
|
7f53badcf3 | ||
|
|
1dcf01bb0e | ||
|
|
eec25ab1c4 | ||
|
|
f90f093385 | ||
|
|
befe56fd44 | ||
|
|
47d91424c5 | ||
|
|
ddd72f71d2 | ||
|
|
1a1ff2ec30 | ||
|
|
10d89279bc | ||
|
|
a6f9ba7526 | ||
|
|
19dfe45b0b | ||
|
|
f584e21480 | ||
|
|
6acacbfcf4 | ||
|
|
52a79eca60 | ||
|
|
70faadb27e | ||
|
|
2eb1809f36 | ||
|
|
d9febf8010 | ||
|
|
d4c5787360 | ||
|
|
c8c461e8e8 | ||
|
|
9e2da003f1 | ||
|
|
ae41483bb7 | ||
|
|
d062b8a7d4 | ||
|
|
18825dd0c3 | ||
|
|
6b2ea58b66 | ||
|
|
fdfb37d4d0 | ||
|
|
450de39f8c | ||
|
|
c87ad8ea1b | ||
|
|
b336651503 | ||
|
|
7a1949fc6f | ||
|
|
35a63d7c5c | ||
|
|
031f5e2ed8 | ||
|
|
189b4b7599 | ||
|
|
6ada32cb9f | ||
|
|
5491c524c5 | ||
|
|
8d29f25101 | ||
|
|
28d4b2d831 | ||
|
|
399645a80a | ||
|
|
9d13809d01 | ||
|
|
e8c8db7383 | ||
|
|
b41814ed0c | ||
|
|
d71f2043de | ||
|
|
87ba3e8fa2 | ||
|
|
eabbbf456f | ||
|
|
ebfba7ac8c | ||
|
|
fe4a4a420e | ||
|
|
14ae432833 | ||
|
|
009a38f3ca | ||
|
|
cfc73e4094 | ||
|
|
748be6faa5 | ||
|
|
330e2e5c69 | ||
|
|
343a3f81bf | ||
|
|
13c7d5a0c5 | ||
|
|
a9c38b356a | ||
|
|
4181257afd | ||
|
|
0141ed56c2 | ||
|
|
35dc205810 | ||
|
|
d10a17eaf6 | ||
|
|
a623c53fa5 | ||
|
|
3db8babee7 | ||
|
|
ca7ac992e2 | ||
|
|
a956c9894e | ||
|
|
6c84ba6aeb | ||
|
|
e99b7d8162 | ||
|
|
09bb613929 | ||
|
|
761b0dd502 | ||
|
|
8c72cb3b72 | ||
|
|
35b6f4ae54 | ||
|
|
cf5ade639a | ||
|
|
0dfd5efa97 | ||
|
|
e6cbb9dfd8 | ||
|
|
52c7215cd8 | ||
|
|
fe88732a80 | ||
|
|
aa5f85a3c1 | ||
|
|
843519c3b5 | ||
|
|
c74917d1d9 | ||
|
|
9dca199d78 | ||
|
|
4fa7542aca | ||
|
|
1cd24b5dd5 | ||
|
|
34f1bf5e6a | ||
|
|
675e5f58d1 | ||
|
|
127fb41145 | ||
|
|
8abf32bb53 | ||
|
|
b567e5df13 | ||
|
|
40acdafed2 | ||
|
|
7f6b05e4e5 | ||
|
|
0d4a698c11 | ||
|
|
5bacfb04b5 | ||
|
|
1e8514a0bc | ||
|
|
b6714dd8a4 | ||
|
|
e05c2a03e0 | ||
|
|
914df505ae | ||
|
|
7e59ae6b66 | ||
|
|
eb84a23d7e | ||
|
|
1076120516 | ||
|
|
54ce26adbc | ||
|
|
28d54b615b | ||
|
|
424fa576fb | ||
|
|
7f8e5fe9f9 | ||
|
|
47f1bd012c | ||
|
|
0c2e762cee | ||
|
|
68124e6abb | ||
|
|
5587e6b6a3 | ||
|
|
ac1e98fbaa | ||
|
|
d25f9f3a01 | ||
|
|
fb638752eb | ||
|
|
8c3dd62f6d | ||
|
|
e79635b81a | ||
|
|
e3a9851de6 | ||
|
|
34811d4571 | ||
|
|
3167a9a6e3 | ||
|
|
a29d1f0cc2 | ||
|
|
e76cddd45b | ||
|
|
3c214f4725 | ||
|
|
80861938f3 | ||
|
|
401f2dba13 | ||
|
|
c4d7c516ef | ||
|
|
2f11b1185b | ||
|
|
42274a71b7 | ||
|
|
f1fcfa9ee4 | ||
|
|
afcfbbbf61 | ||
|
|
defba2962d | ||
|
|
4c7666169c | ||
|
|
5580b4301f | ||
|
|
421ff8cb40 | ||
|
|
96b95e0eb9 | ||
|
|
cea2d39df8 | ||
|
|
f4eae0d33e | ||
|
|
716a20cc22 | ||
|
|
873a74492a | ||
|
|
516d93d6fa | ||
|
|
958a8ffe2c | ||
|
|
0286e2a92e | ||
|
|
01e339cc61 | ||
|
|
0e7c9b4a96 | ||
|
|
d5814043d9 | ||
|
|
e81210ec04 | ||
|
|
8d17623d3a | ||
|
|
ba9d7c8172 | ||
|
|
06f5a3cde0 | ||
|
|
25417dcda1 | ||
|
|
9a3b332130 | ||
|
|
1236a24b61 | ||
|
|
f50de743e3 | ||
|
|
0c2568aad0 | ||
|
|
b15cbeb568 | ||
|
|
afdc06638f | ||
|
|
6fb610d9aa | ||
|
|
c14d536133 | ||
|
|
3008ae2eeb | ||
|
|
3b4d9df0e8 | ||
|
|
48d4a5e135 | ||
|
|
ff33565513 | ||
|
|
b40bae1b50 | ||
|
|
0533d229e6 | ||
|
|
bbb755bf56 | ||
|
|
31caee87db | ||
|
|
87cda451f3 | ||
|
|
e2b8f901a3 | ||
|
|
951e2c1f89 | ||
|
|
20fd291cc3 | ||
|
|
ee3ddce41d | ||
|
|
fb160e712b | ||
|
|
9a6cc1779f | ||
|
|
b459d0fbcb | ||
|
|
9816a9c46a | ||
|
|
f4b8f9d65d | ||
|
|
08e8d7343c | ||
|
|
5505791b6e | ||
|
|
1862477641 | ||
|
|
ff9daa2bfd | ||
|
|
23b13e3fdc | ||
|
|
f93f2674df | ||
|
|
1d7d34b7c1 | ||
|
|
9837a7af71 | ||
|
|
d3ef4094c6 | ||
|
|
bf86291016 | ||
|
|
37ace8b34c | ||
|
|
e170790e4c | ||
|
|
c0888ea2a4 | ||
|
|
0fe1aa983c | ||
|
|
180eb1f59d | ||
|
|
1ead18b53c | ||
|
|
9ca61dd60d | ||
|
|
52a0635810 | ||
|
|
2337b4cce2 | ||
|
|
6f51b90178 | ||
|
|
07db5b1fbd | ||
|
|
162e18142c | ||
|
|
6110abebb4 | ||
|
|
05e9082831 | ||
|
|
76a634cf9a | ||
|
|
d4797617a6 | ||
|
|
be67395f13 | ||
|
|
793c673414 | ||
|
|
20e17861e1 | ||
|
|
deb7375d6b | ||
|
|
2a76c279eb | ||
|
|
2767260505 | ||
|
|
bba4a8eeba | ||
|
|
5d1ba3fd87 | ||
|
|
c9786adc77 | ||
|
|
407051287d | ||
|
|
3d178dd6bc | ||
|
|
8ddedc60ac | ||
|
|
ddb8031e55 | ||
|
|
99db9a0b77 | ||
|
|
4126bc2234 | ||
|
|
d4f1ec095e | ||
|
|
bbf3ce3948 | ||
|
|
46cf24c193 | ||
|
|
fb5b8356c7 | ||
|
|
1333bd040a | ||
|
|
013c07ef59 | ||
|
|
29fd59a321 | ||
|
|
3bc152c341 | ||
|
|
ea274f8f7d | ||
|
|
7ac53e56d7 | ||
|
|
7b1c12e952 | ||
|
|
17dafe6530 | ||
|
|
ef331363f7 | ||
|
|
4f10864c2c | ||
|
|
4b5ceb0e0a | ||
|
|
4fa50161fd | ||
|
|
3d27e07ccb | ||
|
|
ca1ca71a62 | ||
|
|
bdd6c7b804 | ||
|
|
04b8082f04 | ||
|
|
82ab1376cc | ||
|
|
2c27a67cc3 | ||
|
|
3451b19b96 | ||
|
|
ae7c1ea1c4 | ||
|
|
9c76b955e2 | ||
|
|
6c4bfe2798 | ||
|
|
98189cd14a | ||
|
|
823ce262bb | ||
|
|
34358400e8 | ||
|
|
da9d069a9a | ||
|
|
58a03678e4 | ||
|
|
5ea68435ed | ||
|
|
58ca85ce90 | ||
|
|
f5c281cf86 | ||
|
|
c72bcea7d9 | ||
|
|
49cd160e76 | ||
|
|
5ca93a25ca | ||
|
|
2ea207a63a | ||
|
|
55554fc781 | ||
|
|
8e78c10304 | ||
|
|
0eb74b46ba | ||
|
|
f21dde8f7d | ||
|
|
e47c00f0b6 | ||
|
|
1fc6707bb3 | ||
|
|
44c5d66afa | ||
|
|
04edb49c9f | ||
|
|
7483d351c3 | ||
|
|
e3eb542e59 | ||
|
|
428b199dd3 | ||
|
|
3c4a6efeea | ||
|
|
dae7024043 | ||
|
|
64e5c26e8c | ||
|
|
84ae60edd6 | ||
|
|
d4c9989069 | ||
|
|
6cda413913 | ||
|
|
a11eaf031d | ||
|
|
3c701359fd | ||
|
|
85ba9fe0ab | ||
|
|
71077f4c8e | ||
|
|
4c7ff53d78 | ||
|
|
bdc3a94ef1 | ||
|
|
83a3810301 | ||
|
|
b31691dd9c | ||
|
|
0647df733a | ||
|
|
0131cae0d7 | ||
|
|
f37b19eab6 | ||
|
|
7c1db5e0e0 | ||
|
|
c6309bf14d | ||
|
|
2c495ab8fd | ||
|
|
ced1b7084d | ||
|
|
c10a2ae1c8 | ||
|
|
3dd09d9fc7 | ||
|
|
46f05dbfa0 | ||
|
|
b0c871eff7 | ||
|
|
4148a87f32 | ||
|
|
bd83fa3417 | ||
|
|
c0b63a655e | ||
|
|
cc21aaede1 | ||
|
|
0c71c80a25 | ||
|
|
823d1d7812 | ||
|
|
a274b5a261 | ||
|
|
49e42757bc | ||
|
|
0964f442be | ||
|
|
73ceeb1b38 | ||
|
|
06f68ec392 | ||
|
|
e5dc347276 | ||
|
|
7c6c43b334 | ||
|
|
8737d1eb0a | ||
|
|
abf53afafc | ||
|
|
fc70963fa9 | ||
|
|
814365d7de | ||
|
|
8c1a976b5b | ||
|
|
66db81071e | ||
|
|
6df6e612bc | ||
|
|
84013993c9 | ||
|
|
a123588e32 | ||
|
|
3fee2a781f | ||
|
|
acaf2dd9f4 | ||
|
|
55839f0913 | ||
|
|
f68131fc13 | ||
|
|
7b9081a125 | ||
|
|
0947b76a6e | ||
|
|
23c18da399 | ||
|
|
ec18f17c79 | ||
|
|
cdfa1e56e5 | ||
|
|
434c3cfc08 | ||
|
|
aee3449578 | ||
|
|
54d3a35b38 | ||
|
|
b640fffd0d | ||
|
|
981e4aab54 | ||
|
|
6b68894d23 | ||
|
|
cc91225b01 | ||
|
|
97a8b2811e | ||
|
|
af8db9457f | ||
|
|
2c3839fc5f | ||
|
|
e9caa1b84d | ||
|
|
5860cf3a22 | ||
|
|
d2d5502f8d | ||
|
|
77f2546cc4 | ||
|
|
4df353518f | ||
|
|
ee114dec7e | ||
|
|
d58b2db786 | ||
|
|
1c2eaef5a4 | ||
|
|
bb7cce47ac | ||
|
|
ba910d6bfe | ||
|
|
2a8c2ae542 | ||
|
|
56f52194a6 | ||
|
|
a71b20ba6f | ||
|
|
b2282accb1 | ||
|
|
5def2ec5ee | ||
|
|
9d6cc405a9 | ||
|
|
1c37591bab | ||
|
|
9a8350dae8 | ||
|
|
77fd719f96 | ||
|
|
aa831141f6 | ||
|
|
fb148a77c1 | ||
|
|
d8d98aee6d | ||
|
|
87f9b90dbf | ||
|
|
b704db99b1 | ||
|
|
f231564548 | ||
|
|
db04b13310 | ||
|
|
9958ea72b8 | ||
|
|
35f765d870 | ||
|
|
b6afaba02a | ||
|
|
c7c69ea5a7 | ||
|
|
9b9edec627 | ||
|
|
d4302550b4 | ||
|
|
6432b9f44a | ||
|
|
98f24d8a96 | ||
|
|
887549c755 | ||
|
|
d3ca402e4a | ||
|
|
9ca0e96525 | ||
|
|
4c28db79df | ||
|
|
6e2a957e99 | ||
|
|
b5eea1a476 | ||
|
|
5d31c70e16 | ||
|
|
a6f05b3850 | ||
|
|
49ec6423bd | ||
|
|
68ac1168e9 | ||
|
|
e875df1e55 | ||
|
|
69bd924cd2 | ||
|
|
164bffc5de | ||
|
|
613f2a36d9 | ||
|
|
0eee6bf338 | ||
|
|
c2fdc3aa71 | ||
|
|
e98fe69d68 | ||
|
|
426e3ffcdf | ||
|
|
2e25a0311c | ||
|
|
97df2c47de | ||
|
|
f7c1cf0718 | ||
|
|
4d0e39721f | ||
|
|
a87f887635 | ||
|
|
153f3b7fa7 | ||
|
|
668e750b03 | ||
|
|
7e152cacd2 | ||
|
|
ac00f978be | ||
|
|
c313c1d6cc | ||
|
|
215d742270 | ||
|
|
aa213ceead | ||
|
|
4eb031ae67 | ||
|
|
b0cd0c772f | ||
|
|
e75e38a0b8 | ||
|
|
86a95ca6e4 | ||
|
|
99fe41b41d | ||
|
|
a78059ec67 | ||
|
|
50bc502251 | ||
|
|
7cbde80466 | ||
|
|
f952f8c262 | ||
|
|
44edac8fbb | ||
|
|
0e8e646547 | ||
|
|
357dce2161 | ||
|
|
430148d843 | ||
|
|
21dab0ef1f | ||
|
|
da8f66fca1 | ||
|
|
31ee840072 | ||
|
|
e3d022fcd7 | ||
|
|
09d4750a67 | ||
|
|
8ab63870d4 | ||
|
|
d3434060ba | ||
|
|
641132dd56 | ||
|
|
28bd0cbbf0 | ||
|
|
df4208d4e1 | ||
|
|
ad13875ce8 | ||
|
|
cdaaa34569 | ||
|
|
17637146be | ||
|
|
9673ee0ed2 | ||
|
|
d864cf73b9 | ||
|
|
b4033071a1 | ||
|
|
9dec42a584 | ||
|
|
9b4d2d410d | ||
|
|
9e7dea7e9f | ||
|
|
6938c1e96f | ||
|
|
4783bbacbd | ||
|
|
0ef375310f | ||
|
|
3eb028b075 | ||
|
|
c1a03fcabc | ||
|
|
24c446b880 | ||
|
|
48172aa3f0 | ||
|
|
adf5e288d3 | ||
|
|
d8d5755849 | ||
|
|
9f803fd4ad | ||
|
|
cc4b2ca084 | ||
|
|
ab3c4e77ee | ||
|
|
3c0a16ea67 | ||
|
|
a6da87837a | ||
|
|
21533c8c0e | ||
|
|
3f75d2751a | ||
|
|
c5a5b3bde3 | ||
|
|
0c66a8f3b7 | ||
|
|
75e619a997 | ||
|
|
02f5fae903 | ||
|
|
1e3a2fea8d | ||
|
|
a4e73284ed | ||
|
|
c94d9019d3 | ||
|
|
a9d78a7e0d | ||
|
|
d93157b10a | ||
|
|
ad18cf1c16 | ||
|
|
90834f3493 | ||
|
|
535a6f1ef2 | ||
|
|
86566c7692 | ||
|
|
51204e2ec1 | ||
|
|
25affc06b9 | ||
|
|
ad5fc8a2de | ||
|
|
e2cf4f3f34 | ||
|
|
8272093e07 | ||
|
|
ba42b74578 | ||
|
|
fba84202f0 | ||
|
|
f70a20c0c7 | ||
|
|
ac75188980 | ||
|
|
404624a583 | ||
|
|
696fdf84d2 | ||
|
|
91e505f37f | ||
|
|
294fea4f71 | ||
|
|
148275626c | ||
|
|
7a0a8f23b7 | ||
|
|
45bf7b10f3 | ||
|
|
246f6472a3 | ||
|
|
965b8f3feb | ||
|
|
c37696dee3 | ||
|
|
fb61080d8d | ||
|
|
d4f46ca344 | ||
|
|
50ed22b4bc | ||
|
|
b0dbd78410 | ||
|
|
b508568e86 | ||
|
|
8384743a30 | ||
|
|
ef7bae98d4 | ||
|
|
ea9bd64088 | ||
|
|
758d5e7fac | ||
|
|
658a20f587 | ||
|
|
9661369708 | ||
|
|
4e8f492006 | ||
|
|
d2a1f5e48b | ||
|
|
69b717f517 | ||
|
|
6d0461eaef | ||
|
|
12e9c2a30c | ||
|
|
6539e5bb80 | ||
|
|
91116ccdf7 | ||
|
|
2bcc49e090 | ||
|
|
c013759869 | ||
|
|
bdbba42616 | ||
|
|
a84b233435 | ||
|
|
4340a57180 | ||
|
|
20aa1b37d0 | ||
|
|
87b5a5739a | ||
|
|
de4c2d6dbd | ||
|
|
49e31134b6 | ||
|
|
9c5c533db8 | ||
|
|
25320d43c4 | ||
|
|
f75fd342a3 | ||
|
|
1cde3b0998 | ||
|
|
c1cab1455f | ||
|
|
be72a594d7 | ||
|
|
8cc4f58076 | ||
|
|
3f07739756 | ||
|
|
c04e754345 | ||
|
|
cec0701087 | ||
|
|
c121698713 | ||
|
|
1cc6b4b565 | ||
|
|
4a6cfed9bb | ||
|
|
dd2ab3cbfd | ||
|
|
5214819a1b | ||
|
|
d0babd838b | ||
|
|
f94b18a64d | ||
|
|
9604e4591b | ||
|
|
31b885a497 | ||
|
|
cc24a16c77 | ||
|
|
0b441494ba | ||
|
|
afabf9b79a | ||
|
|
0e067bb5fc | ||
|
|
26b98717f5 | ||
|
|
202fbc0c6b | ||
|
|
016b074c84 | ||
|
|
715dec5fe5 | ||
|
|
dc6f50dae9 | ||
|
|
8d8b010717 | ||
|
|
49026af708 | ||
|
|
b58b1f91c8 | ||
|
|
4928778b02 | ||
|
|
39a0b4b5d1 | ||
|
|
d07c5fab1a | ||
|
|
c35017d05c | ||
|
|
0fd2466f67 | ||
|
|
446bd83210 | ||
|
|
e8b851e75a | ||
|
|
435fa3b7c1 | ||
|
|
b0a35d6832 | ||
|
|
09c9bee457 | ||
|
|
f98348173e | ||
|
|
b8a1dad2b7 | ||
|
|
94130bdec2 | ||
|
|
21048e5acb | ||
|
|
8c180c0116 | ||
|
|
a24bb15990 | ||
|
|
262a443fec | ||
|
|
bc4d200021 | ||
|
|
4189fae701 | ||
|
|
28a16ffff8 | ||
|
|
61f433e899 | ||
|
|
675a16a294 | ||
|
|
e10ebf4219 | ||
|
|
679ea8a6b4 | ||
|
|
291b4a3a0a | ||
|
|
18b552f491 | ||
|
|
62494eba30 | ||
|
|
edc2fd5720 | ||
|
|
addd4579e8 | ||
|
|
9b8d48d395 | ||
|
|
bd1e1e3d1f | ||
|
|
02655510bd | ||
|
|
b12b76da3b | ||
|
|
0be504f3fa | ||
|
|
083bda1752 | ||
|
|
4a38adb20e | ||
|
|
83e6db06b1 | ||
|
|
cdc3542ff9 | ||
|
|
f344ff50d7 | ||
|
|
7dbaf755b3 | ||
|
|
b38dfd261b | ||
|
|
9fff1f2f25 | ||
|
|
3c8ef48ee5 | ||
|
|
1bfd1cfe46 | ||
|
|
9898251563 | ||
|
|
852fe5375b | ||
|
|
e41c9975af | ||
|
|
b6d7e47aeb | ||
|
|
f2e68af1b3 | ||
|
|
aed61e464d | ||
|
|
0bb2b4d052 | ||
|
|
9ac72c111b | ||
|
|
0b11a50765 | ||
|
|
638c99ac90 | ||
|
|
2d044750ae | ||
|
|
5f04e775b4 | ||
|
|
4d23e07be0 | ||
|
|
c2f8bd5a83 | ||
|
|
920922e21b | ||
|
|
c66a0be0ea | ||
|
|
74f14dec37 | ||
|
|
2e3998976a | ||
|
|
2fcd735d18 | ||
|
|
0b300a2f0e | ||
|
|
c06c97bdd5 | ||
|
|
a5e50c0db7 | ||
|
|
8daaec291d | ||
|
|
a644e80191 | ||
|
|
3302f7e8e0 | ||
|
|
ef3c32045b | ||
|
|
31ce13397e | ||
|
|
ab170a2b92 | ||
|
|
935c51562f | ||
|
|
718fc9fca3 | ||
|
|
a0a523f9bf | ||
|
|
9f9c6f30cb | ||
|
|
52d41166be | ||
|
|
29f6b7f765 | ||
|
|
d1885c1589 | ||
|
|
2a4d159aef | ||
|
|
4a57b363eb | ||
|
|
1b8c6f7586 | ||
|
|
33d8447851 | ||
|
|
262c386e2e | ||
|
|
260c1105e2 | ||
|
|
5515a98749 | ||
|
|
d81952810f | ||
|
|
2cbe84d874 | ||
|
|
d015432fc6 | ||
|
|
9b4d373f7d | ||
|
|
3637d09d03 | ||
|
|
109c5b14e6 | ||
|
|
7baf877528 | ||
|
|
062e26c6d9 | ||
|
|
9ad2f3944a | ||
|
|
7e241f964c | ||
|
|
6d5ebdc739 | ||
|
|
5f56e9a207 | ||
|
|
871e488216 | ||
|
|
216cec8cc7 | ||
|
|
de1d6f9e43 | ||
|
|
54f834ceff | ||
|
|
2f7f128a48 | ||
|
|
aca72fa4e0 | ||
|
|
f2aaf9696e | ||
|
|
d6d2900142 | ||
|
|
4cd2aee92e | ||
|
|
9fc2fc8942 | ||
|
|
3f63cc915b | ||
|
|
3b170b0992 | ||
|
|
73e1499583 | ||
|
|
697ce8a7c0 | ||
|
|
5cef8dc664 | ||
|
|
d6a049758e | ||
|
|
f21e902edd | ||
|
|
d98003b3ec | ||
|
|
54bd38ae5f | ||
|
|
c6575a8182 | ||
|
|
78defdd050 | ||
|
|
47b582c5cc | ||
|
|
4644c0ba3a | ||
|
|
945d182651 | ||
|
|
5dae10af6f | ||
|
|
3293893b7f | ||
|
|
8689b0a8a6 | ||
|
|
0b7a264f06 | ||
|
|
06131c8821 | ||
|
|
a72e4ae4bd | ||
|
|
af5bbbd56e | ||
|
|
d462c50684 | ||
|
|
e0a6410c37 | ||
|
|
9d573e6ad1 | ||
|
|
3da3b88d05 | ||
|
|
f507afdfe9 | ||
|
|
86467ada6d | ||
|
|
c36e3946bb | ||
|
|
c7a6509221 | ||
|
|
10eb78fc6c | ||
|
|
1715c8c88b | ||
|
|
0bb227d147 | ||
|
|
2e858dc901 | ||
|
|
91263b77dd | ||
|
|
983075e4d0 | ||
|
|
84beab1f4e | ||
|
|
6cb654a4b5 | ||
|
|
a5e50fc576 | ||
|
|
9c7e71e65a | ||
|
|
d0a808882d | ||
|
|
67b3d978a4 | ||
|
|
e30d62b429 | ||
|
|
663fc7cdc8 | ||
|
|
8f9469e8ae | ||
|
|
34a0324124 | ||
|
|
e8de6d66f8 | ||
|
|
79cf19d023 | ||
|
|
237ec3bc88 | ||
|
|
723cb3139a | ||
|
|
c9a73889f1 | ||
|
|
16c8fe716d | ||
|
|
dbcbd5f8b2 | ||
|
|
c950652f34 | ||
|
|
1d3272f836 | ||
|
|
8dd2bf6eb4 | ||
|
|
b5bcdb168f | ||
|
|
1120112fa8 | ||
|
|
c7a05cc77d | ||
|
|
f10157c178 | ||
|
|
3641a21fc5 | ||
|
|
4a26dae6b4 | ||
|
|
2ced8d9b5c | ||
|
|
b1beff9ef0 | ||
|
|
5376757f0f | ||
|
|
cf6df58e7a | ||
|
|
0075c720dc | ||
|
|
3bfbc698fe | ||
|
|
4805f8ebe3 | ||
|
|
8b11f65d8f | ||
|
|
a5092087db | ||
|
|
10d8658dc7 | ||
|
|
6f3f431761 | ||
|
|
44431a7905 | ||
|
|
3141777447 | ||
|
|
11b0070fe1 | ||
|
|
89891c6bda | ||
|
|
2fddc9194c | ||
|
|
e3cd2592a4 | ||
|
|
041694e161 | ||
|
|
97ae53ad5d | ||
|
|
b56a291512 | ||
|
|
d54399a2d0 | ||
|
|
2040984be9 | ||
|
|
ea5db79bb6 | ||
|
|
a2b0469275 | ||
|
|
6a3124ed60 | ||
|
|
f9b3b367fc | ||
|
|
1279efbd75 | ||
|
|
13219b7d4e | ||
|
|
77dc0905b6 | ||
|
|
5a59b3f0eb | ||
|
|
68f77151bf | ||
|
|
43fe3a1a99 | ||
|
|
4d778af385 | ||
|
|
5777f000d4 | ||
|
|
9d18ef7527 | ||
|
|
a1f02d3717 | ||
|
|
d976e5fdfa | ||
|
|
207268b7fc | ||
|
|
8965a62d94 | ||
|
|
8ae9cfe050 | ||
|
|
1d4329e058 | ||
|
|
85304447b2 | ||
|
|
dcdee0f758 | ||
|
|
5cbe57aa8d | ||
|
|
6dc42ae4b1 | ||
|
|
5e0d1b8b63 | ||
|
|
6422b3013a | ||
|
|
7bf86e8619 | ||
|
|
36b4393134 | ||
|
|
ed5971aed7 | ||
|
|
ee65714d01 | ||
|
|
d817292955 | ||
|
|
6ea99e47d8 | ||
|
|
07e842ecb2 | ||
|
|
e808e79391 | ||
|
|
f73901c78c | ||
|
|
6c17c12384 | ||
|
|
51b1daf083 | ||
|
|
e130e92376 | ||
|
|
1489fdc664 | ||
|
|
046b2c0553 | ||
|
|
0e5ec20c58 | ||
|
|
1c60f0f51b | ||
|
|
4c4c031e03 | ||
|
|
a606afef2f | ||
|
|
2f7d860b89 | ||
|
|
011a68e5f2 | ||
|
|
86d899d708 | ||
|
|
4dce22a30f | ||
|
|
c8391fe8eb | ||
|
|
c79c484c1b | ||
|
|
9c323e6b5d | ||
|
|
43fc4bbcb3 | ||
|
|
fdb3b23165 | ||
|
|
620f4275ae | ||
|
|
f769c508ad | ||
|
|
b7e343b24f | ||
|
|
bd53c17fec | ||
|
|
240311e540 | ||
|
|
f788a1ae08 | ||
|
|
98d53fb4bd | ||
|
|
43e59b869e | ||
|
|
717732aad3 | ||
|
|
b34d131c1a | ||
|
|
26d01a7032 | ||
|
|
6e7b3dd539 | ||
|
|
947751431e | ||
|
|
355e0ef1e4 | ||
|
|
c9b2ef35b7 | ||
|
|
8491d0243a |
@@ -0,0 +1,33 @@
|
||||
name: Deploy Frontend
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- deploy
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh-keyscan -p 28 -H 192.168.0.4 >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Deploy
|
||||
run: |
|
||||
ssh -i ~/.ssh/deploy_key -p 28 onasys@192.168.0.4 << 'EOF'
|
||||
cd ~/frontend
|
||||
git restore .
|
||||
git clean -fd
|
||||
git pull origin deploy
|
||||
pnpm install
|
||||
pnpm turbo build
|
||||
pm2 reload all
|
||||
EOF
|
||||
@@ -29,4 +29,5 @@ settings.json
|
||||
.agent
|
||||
|
||||
apps/**/tsconfig.tsbuildinfo
|
||||
packages/**/tsconfig.tsbuildinfo
|
||||
logs/
|
||||
|
||||
Vendored
+88
@@ -0,0 +1,88 @@
|
||||
{
|
||||
// IntelliSense를 사용하여 가능한 특성에 대해 알아보세요.
|
||||
// 기존 특성에 대한 설명을 보려면 가리킵니다.
|
||||
// 자세한 내용을 보려면 https://go.microsoft.com/fwlink/?linkid=830387을(를) 방문하세요.
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "dev:as 디버그",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"dev:as"
|
||||
],
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
"sourceMaps": true,
|
||||
"env": {
|
||||
"NODE_OPTIONS": "--inspect"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "dev:cs 디버그",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"dev:cs"
|
||||
],
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
"sourceMaps": true,
|
||||
"env": {
|
||||
"NODE_OPTIONS": "--inspect"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "attach",
|
||||
"name": "dev:cs 서버 연결 (attach)",
|
||||
"port": 9229,
|
||||
"restart": true,
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"sourceMaps": true,
|
||||
"resolveSourceMapLocations": [
|
||||
"${workspaceFolder}/apps/cs/**",
|
||||
"!**/node_modules/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"name": "dev:cs 클라이언트 디버그 (Chrome)",
|
||||
"url": "http://localhost:3000",
|
||||
"webRoot": "${workspaceFolder}/apps/cs",
|
||||
"sourceMaps": true
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "dev:st 디버그",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"dev:st"
|
||||
],
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
"sourceMaps": true,
|
||||
"env": {
|
||||
"NODE_OPTIONS": "--inspect"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
"pnpm": true
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
# apps/as/shared/config/env.ts 참고
|
||||
# src/shared/config/env.ts 참고
|
||||
NEXT_PUBLIC_PORT=
|
||||
NEXT_PUBLIC_PROXY_HOST=
|
||||
NEXT_PUBLIC_SITE_ID=
|
||||
NEXT_PUBLIC_SERVER_API_URL=
|
||||
SERVER_API_URL=
|
||||
|
||||
# 401 시 브라우저 리다이렉트 대상
|
||||
NEXT_PUBLIC_AUTH_LOGIN_REDIRECT_URL=/auth/login
|
||||
|
||||
+44
-105
@@ -1,4 +1,4 @@
|
||||
# AS Application
|
||||
# Template Application
|
||||
|
||||
Next.js 애플리케이션 with FSD(Feature Sliced Design) 아키텍처
|
||||
|
||||
@@ -13,46 +13,40 @@ Next.js 애플리케이션 with FSD(Feature Sliced Design) 아키텍처
|
||||
- **HTTP 클라이언트**: Axios
|
||||
- **날짜 처리**: Day.js
|
||||
|
||||
## 프로젝트 구조 (FSD)
|
||||
## 프로젝트 구조 (FSD + Next.js App Router)
|
||||
|
||||
이 프로젝트는 **Feature-Sliced Design v2.1**을 따르며, 계층적 아키텍처로 구성되어 있습니다.
|
||||
이 프로젝트는 **Feature-Sliced Design v2.1**과 **FSD 공식 Next.js 가이드**에 맞춰 구성되어 있습니다. Next.js `app/`은 라우트만 담당하고, 실제 레이어는 `src/` 아래에 있습니다.
|
||||
|
||||
```
|
||||
as/
|
||||
├── app/ # App 레이어 (Next.js App Router)
|
||||
│ ├── (routes)/ # 라우트 구조
|
||||
│ ├── layout.tsx # 루트 레이아웃
|
||||
│ ├── page.tsx # 홈 페이지
|
||||
│ ├── globals.css # 글로벌 스타일
|
||||
│ └── providers.tsx # 전역 providers
|
||||
_template/
|
||||
├── app/ # Next.js App Router (루트) — 라우트·레이아웃 진입점만
|
||||
│ ├── layout.tsx # 루트 레이아웃 (src/app에서 providers, styles import)
|
||||
│ └── (with-base-layout)/
|
||||
│ ├── layout.tsx # BaseLayout 래퍼 (src/app/layouts에서 import)
|
||||
│ └── page.tsx # re-export: export { MainPage as default } from '@/pages/home'
|
||||
│
|
||||
├── widgets/ # Widgets 레이어
|
||||
│ └── [widget-name]/ # 큰 독립적 UI 블록 (여러 페이지에서 재사용)
|
||||
│ ├── ui/
|
||||
│ ├── model/
|
||||
│ └── index.ts
|
||||
├── pages/ # 빈 폴더 + README (Next.js가 src/pages를 Pages Router로 쓰지 않도록)
|
||||
│ └── README.md
|
||||
│
|
||||
├── features/ # Features 레이어
|
||||
│ └── [feature-name]/ # 사용자 상호작용 기능 (여러 페이지에서 재사용)
|
||||
│ ├── ui/
|
||||
│ ├── api/
|
||||
│ ├── model/
|
||||
│ └── index.ts
|
||||
│
|
||||
├── entities/ # Entities 레이어
|
||||
│ └── [entity-name]/ # 비즈니스 엔티티 (User, Product 등)
|
||||
│ ├── model/
|
||||
│ ├── api/
|
||||
│ ├── ui/
|
||||
│ └── index.ts
|
||||
│
|
||||
└── shared/ # Shared 레이어
|
||||
├── ui/ # UI Kit (비즈니스 로직 없는 공통 컴포넌트)
|
||||
├── api/ # API 클라이언트 설정
|
||||
├── lib/ # 내부 라이브러리 (dates, colors 등)
|
||||
├── config/ # 환경 설정, feature flags
|
||||
├── types/ # 공통 타입 정의
|
||||
└── routes/ # 라우트 상수
|
||||
└── src/ # FSD 레이어 (실제 코드)
|
||||
├── app/ # App 레이어 (슬라이스 없음, 세그먼트만)
|
||||
│ ├── providers/ # 전역 Providers (QueryClient 등)
|
||||
│ ├── styles/ # 글로벌 스타일 (globals.css)
|
||||
│ └── layouts/ # 공통 레이아웃 컴포넌트 (BaseLayoutWrapper)
|
||||
│
|
||||
├── pages/ # Pages 레이어 (페이지 슬라이스)
|
||||
│ └── home/
|
||||
│ ├── ui/
|
||||
│ └── index.ts # Public API
|
||||
│
|
||||
├── widgets/ # Widgets 레이어
|
||||
├── features/ # Features 레이어
|
||||
├── entities/ # Entities 레이어
|
||||
└── shared/ # Shared 레이어
|
||||
├── ui/
|
||||
├── api/
|
||||
├── lib/
|
||||
└── config/
|
||||
```
|
||||
|
||||
### 계층 구조 이해하기
|
||||
@@ -86,10 +80,10 @@ shared/ ↓ 가장 낮은 책임, 의존성 없음
|
||||
- **특징**: 슬라이스가 없고 세그먼트로만 구성
|
||||
- **의존성**: 모든 하위 레이어 사용 가능
|
||||
|
||||
### 2. Pages (app 디렉토리)
|
||||
### 2. Pages (src/pages)
|
||||
- **목적**: 웹사이트나 애플리케이션을 구성하는 페이지/화면
|
||||
- **내용**: 페이지 UI, 로딩 상태, 에러 바운더리, 데이터 fetching
|
||||
- **특징**: 한 페이지당 한 슬라이스 (유사한 페이지는 그룹화 가능)
|
||||
- **특징**: 한 페이지당 한 슬라이스 (유사한 페이지는 그룹화 가능). Next.js `app/`의 route 파일에서 re-export
|
||||
- **의존성**: `widgets`, `features`, `entities`, `shared` 사용 가능
|
||||
|
||||
> 💡 **FSD v2.1 권장사항**: "pages-first" 접근 방식을 따라, 재사용되지 않는 큰 UI 블록과 비즈니스 로직은 페이지 슬라이스에 유지하세요.
|
||||
@@ -118,29 +112,6 @@ shared/ ↓ 가장 낮은 책임, 의존성 없음
|
||||
- **특징**: 비즈니스 로직 없음, 슬라이스가 없고 세그먼트로만 구성
|
||||
- **의존성**: 외부 라이브러리만 사용 가능 (다른 레이어 불가)
|
||||
|
||||
## 레이어 간 Import 규칙
|
||||
|
||||
> **핵심 규칙**: 슬라이스 내의 모듈(파일)은 **오직 하위 레이어**에 있는 다른 슬라이스만 import할 수 있습니다.
|
||||
|
||||
예시:
|
||||
- `features/auth-login/ui/form.tsx`는 `entities/user`와 `shared/ui`를 import 가능
|
||||
- 하지만 다른 `features/post-create`나 상위 레이어인 `widgets`는 import 불가
|
||||
- 같은 슬라이스 내부(`features/auth-login/`)의 다른 파일들은 자유롭게 import 가능
|
||||
|
||||
## Public API 규칙
|
||||
|
||||
> **슬라이스 외부의 모듈은 슬라이스의 Public API만 참조할 수 있으며, 내부 파일 구조에 직접 접근할 수 없습니다.**
|
||||
|
||||
모든 슬라이스(및 슬라이스가 없는 레이어의 세그먼트)는 반드시 `index.ts`를 통해 Public API를 정의해야 합니다.
|
||||
|
||||
```typescript
|
||||
// ✅ 올바른 import
|
||||
import { LoginForm } from 'features/auth-login';
|
||||
|
||||
// ❌ 잘못된 import (내부 구조 직접 접근)
|
||||
import { LoginForm } from 'features/auth-login/ui/login-form';
|
||||
```
|
||||
|
||||
## 시작하기
|
||||
|
||||
```bash
|
||||
@@ -174,16 +145,16 @@ NEXT_PUBLIC_API_URL=http://localhost:3000/api
|
||||
```typescript
|
||||
// ✅ 올바른 import (하위 레이어)
|
||||
// features/auth-login/ui/form.tsx
|
||||
import { User } from 'entities/user';
|
||||
import { Button } from 'shared/ui/button';
|
||||
import { User } from "entities/user";
|
||||
import { Button } from "shared/ui/button";
|
||||
|
||||
// ❌ 잘못된 import (같은 레이어)
|
||||
// features/auth-login/ui/form.tsx
|
||||
import { PostForm } from 'features/post-create'; // 금지!
|
||||
import { PostForm } from "features/post-create"; // 금지!
|
||||
|
||||
// ❌ 잘못된 import (상위 레이어)
|
||||
// features/auth-login/ui/form.tsx
|
||||
import { Header } from 'widgets/header'; // 금지!
|
||||
import { Header } from "widgets/header"; // 금지!
|
||||
```
|
||||
|
||||
### 2. Public API 규칙
|
||||
@@ -192,10 +163,10 @@ import { Header } from 'widgets/header'; // 금지!
|
||||
|
||||
```typescript
|
||||
// ✅ 올바른 import (Public API 사용)
|
||||
import { LoginForm } from 'features/auth-login';
|
||||
import { LoginForm } from "features/auth-login";
|
||||
|
||||
// ❌ 잘못된 import (내부 구조 직접 접근)
|
||||
import { LoginForm } from 'features/auth-login/ui/login-form';
|
||||
import { LoginForm } from "features/auth-login/ui/login-form";
|
||||
```
|
||||
|
||||
**Public API 작성 시 주의사항**:
|
||||
@@ -205,44 +176,12 @@ import { LoginForm } from 'features/auth-login/ui/login-form';
|
||||
|
||||
```typescript
|
||||
// ❌ 나쁜 예시
|
||||
export * from './ui/Comment';
|
||||
export * from './model/comments';
|
||||
export * from "./ui/Comment";
|
||||
export * from "./model/comments";
|
||||
|
||||
// ✅ 좋은 예시
|
||||
export { Comment } from './ui/comment';
|
||||
export { CommentForm } from './ui/comment-form';
|
||||
export { useComments } from './model/use-comments';
|
||||
export type { Comment, CommentFormData } from './model/types';
|
||||
```
|
||||
|
||||
### 3. 슬라이스 독립성
|
||||
|
||||
- **Zero Coupling**: 같은 레이어의 슬라이스들은 서로 독립적이어야 합니다
|
||||
- **High Cohesion**: 슬라이스는 주요 목표와 관련된 대부분의 코드를 포함해야 합니다
|
||||
- 예외: Entities 레이어에서 필요한 경우 `@x` 표기법으로 cross-import 가능
|
||||
|
||||
### 4. 세그먼트 명명 규칙
|
||||
|
||||
세그먼트 이름은 **목적**을 설명해야 하며, **본질**을 설명하면 안 됩니다:
|
||||
|
||||
- ❌ **나쁜 이름**: `components`, `hooks`, `types` (무엇인지만 설명)
|
||||
- ✅ **좋은 이름**: `ui`, `api`, `model`, `lib` (왜 있는지 설명)
|
||||
|
||||
## 코드 규칙
|
||||
|
||||
- TypeScript 사용 (strict mode)
|
||||
- 함수형 컴포넌트 사용
|
||||
- Named exports 선호
|
||||
- Tailwind CSS 사용
|
||||
- ESLint + Prettier 적용
|
||||
- Public API는 명시적으로 정의 (와일드카드 export 지양)
|
||||
|
||||
## 아키텍처 린팅
|
||||
|
||||
코드베이스의 FSD 규칙 준수를 자동으로 검증하려면 [Steiger](https://github.com/feature-sliced/steiger)를 사용하세요.
|
||||
|
||||
```bash
|
||||
# Steiger 설치 및 실행
|
||||
pnpm add -D @feature-sliced/steiger
|
||||
npx steiger src
|
||||
export { Comment } from "./ui/comment";
|
||||
export { CommentForm } from "./ui/comment-form";
|
||||
export { useComments } from "./model/use-comments";
|
||||
export type { Comment, CommentFormData } from "./model/types";
|
||||
```
|
||||
|
||||
@@ -1,31 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { BaseLayout } from "@common/components";
|
||||
import type { MenuItem } from "@/entities/menu";
|
||||
import { PageLayout } from "@/shared/ui/page-layout";
|
||||
|
||||
export default function WithBaseLayout({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleMenuClick = useCallback(
|
||||
(item: MenuItem) => {
|
||||
router.push(item.urlPath);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseLayout
|
||||
queryClient={queryClient}
|
||||
currentPath={pathname ?? ""}
|
||||
handleMenuClick={handleMenuClick}
|
||||
>
|
||||
<PageLayout>{children}</PageLayout>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
import type { ReactNode } from "react";
|
||||
import { BaseLayoutWrapper } from "@/app/layouts/base-layout";
|
||||
|
||||
interface WithBaseLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function WithBaseLayout({ children }: WithBaseLayoutProps) {
|
||||
return <BaseLayoutWrapper>{children}</BaseLayoutWrapper>;
|
||||
}
|
||||
|
||||
@@ -1,9 +1 @@
|
||||
"use client";
|
||||
|
||||
export default function MainPage() {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center py-10">
|
||||
<h1 className="mb-4 text-3xl font-bold">Home</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { MainPage as default } from "@/pages/home";
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Noto_Sans_KR } from "next/font/google";
|
||||
import { Providers } from "./providers";
|
||||
import "./globals.css";
|
||||
|
||||
const notoSansKR = Noto_Sans_KR({
|
||||
subsets: ["latin"],
|
||||
weight: ["100", "300", "400", "500", "700", "900"],
|
||||
display: "swap",
|
||||
variable: "--font-noto-sans-kr",
|
||||
});
|
||||
import { cookies } from "next/headers";
|
||||
import { Providers } from "@/app/providers";
|
||||
import "@/app/styles/globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "행정지원시스템",
|
||||
@@ -19,10 +12,12 @@ interface RootLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: RootLayoutProps) {
|
||||
export default async function RootLayout({ children }: RootLayoutProps) {
|
||||
const theme = (await cookies()).get("site-theme-key");
|
||||
|
||||
return (
|
||||
<html lang="ko" className={notoSansKR.variable}>
|
||||
<body className={`${notoSansKR.className} antialiased`}>
|
||||
<html lang="ko" data-theme={theme?.value}>
|
||||
<body>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { PORT, PROXY_HOST, SERVER_API_URL, SITE_ID } from "@/shared/config";
|
||||
|
||||
const serverApiUrl = process.env.SERVER_API_URL ?? "http://localhost:3000";
|
||||
|
||||
// CSP 헤더 설정 함수 (img-src만)
|
||||
function addSecurityHeaders(response: NextResponse): NextResponse {
|
||||
response.headers.set("Content-Security-Policy", "img-src 'self' data: https: blob:;");
|
||||
return response;
|
||||
}
|
||||
|
||||
export function middleware(req: NextRequest) {
|
||||
const pathname = req.nextUrl.pathname;
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
|
||||
const logMessages: string[] = [
|
||||
`[Middleware Request] ${new Date().toISOString()}`,
|
||||
`[Environment]\n Mode: ${process.env.NODE_ENV}\n Port: ${PORT}\n Proxy Host: ${PROXY_HOST}\n Server API URL: ${SERVER_API_URL}\n Site ID: ${SITE_ID}`,
|
||||
`[Request]\n Original Pathname: ${pathname}`,
|
||||
];
|
||||
|
||||
if (pathname.startsWith("/server")) {
|
||||
const queryParams = searchParams.toString();
|
||||
const newUrl = `${serverApiUrl}${pathname.replace("/server", "")}${
|
||||
queryParams ? `?${queryParams}` : ""
|
||||
}`;
|
||||
|
||||
logMessages.push(` Rewritten URL: ${newUrl}`);
|
||||
logMessages.push("\n--------------------------------\n");
|
||||
console.log(logMessages.join("\n"));
|
||||
|
||||
const response = NextResponse.rewrite(new URL(newUrl));
|
||||
return addSecurityHeaders(response);
|
||||
}
|
||||
|
||||
// if (pathname === "/") {
|
||||
// const response = NextResponse.redirect(new URL("/dashboard", req.url));
|
||||
// return addSecurityHeaders(response);
|
||||
// }
|
||||
|
||||
logMessages.push("\n--------------------------------\n");
|
||||
console.log(logMessages.join("\n"));
|
||||
|
||||
const response = NextResponse.next();
|
||||
return addSecurityHeaders(response);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/server/:path*", "/"],
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "as",
|
||||
"name": "template",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -19,7 +19,7 @@
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
"axios": "^1.8.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"next": "15.3.8",
|
||||
"next": "15.5.18",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
@@ -33,4 +33,4 @@
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# 이 폴더는 비워 둡니다 (FSD + Next.js App Router)
|
||||
|
||||
Next.js가 `src/pages`를 **Pages Router**용 `pages` 디렉터리로 인식하지 않도록,
|
||||
프로젝트 루트에 **빈 `pages` 폴더**를 두는 것이 FSD 공식 가이드 권장사항입니다.
|
||||
|
||||
- 실제 페이지(FSD Pages 레이어)는 **`src/pages`**에 있습니다.
|
||||
- Next.js 라우트는 **`app/`** 에서 `src/pages`를 re-export 하는 방식으로 연결됩니다.
|
||||
|
||||
[Feature-Sliced Design - Usage with Next.js (App Router)](https://feature-sliced.design/docs/guides/tech/with-nextjs)
|
||||
@@ -1,11 +0,0 @@
|
||||
export const PORT = process.env.NEXT_PUBLIC_PORT || "3000";
|
||||
export const PROXY_HOST =
|
||||
process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`;
|
||||
export const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL;
|
||||
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "AS";
|
||||
export const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
|
||||
export const ACCESS_TOKEN =
|
||||
process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token";
|
||||
export const REFRESH_TOKEN =
|
||||
process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token";
|
||||
export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { BaseLayout } from "@common/components";
|
||||
import type { MenuItem } from "@/entities/menu";
|
||||
import { PageLayout } from "@/shared/ui/page-layout";
|
||||
|
||||
interface BaseLayoutWrapperProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function BaseLayoutWrapper({ children }: BaseLayoutWrapperProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleMenuClick = useCallback(
|
||||
(item: MenuItem) => {
|
||||
router.push(item.urlPath);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseLayout
|
||||
queryClient={queryClient}
|
||||
currentPath={pathname ?? ""}
|
||||
handleMenuClick={handleMenuClick}
|
||||
>
|
||||
<PageLayout>{children}</PageLayout>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { queryClient } from "@/shared/lib";
|
||||
|
||||
interface ProvidersProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Providers({ children }: ProvidersProps) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { queryClient } from "@/shared/lib";
|
||||
|
||||
interface ProvidersProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Providers({ children }: ProvidersProps) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,6 @@ html,
|
||||
body {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
font-family: var(--font-noto-sans-kr), sans-serif;
|
||||
font-family: "Noto Sans KR", sans-serif;
|
||||
font-size: 10px;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
# Features Layer
|
||||
|
||||
**Features**는 앱의 주요 사용자 상호작용과 기능을 구현하는 레이어입니다. 사용자가 실제로 수행하는 작업들로, 주로 비즈니스 엔티티와 관련이 있습니다.
|
||||
|
||||
## 목적
|
||||
|
||||
- 사용자가 중요하게 생각하는 주요 상호작용 구현
|
||||
- 재사용 가능한 비즈니스 기능 제공
|
||||
- 여러 페이지에서 사용되는 기능의 중복 제거
|
||||
|
||||
## Feature로 만들어야 하는 경우
|
||||
|
||||
> ⚠️ **중요**: 모든 것을 feature로 만들 필요는 없습니다.
|
||||
|
||||
Feature로 만드는 것이 좋은 지표:
|
||||
|
||||
- **여러 페이지에서 재사용됨**: 예를 들어, 여러 에디터가 있고 모두 댓글 기능이 있다면, 댓글은 재사용 가능한 feature입니다
|
||||
- **중요한 비즈니스 기능**: 신규 프로젝트 참여자가 Features 폴더를 보고 앱의 주요 기능을 빠르게 파악할 수 있어야 합니다
|
||||
|
||||
Feature가 너무 많으면 중요한 기능이 묻히므로, 신규 참여자의 경험을 최적화하여 결정하세요.
|
||||
|
||||
## 구조
|
||||
|
||||
각 feature 슬라이스는 다음 세그먼트로 구성됩니다:
|
||||
|
||||
```
|
||||
features/
|
||||
└── [feature-name]/
|
||||
├── ui/ # 상호작용을 수행하는 UI (폼, 버튼 등)
|
||||
├── api/ # 액션 수행에 필요한 API 호출 (선택적)
|
||||
├── model/ # 검증 및 내부 상태 관리 (선택적)
|
||||
├── lib/ # 유틸리티 함수 (선택적)
|
||||
├── config/ # feature flags (선택적)
|
||||
└── index.ts # Public API
|
||||
```
|
||||
|
||||
### 세그먼트 설명
|
||||
|
||||
- **ui**: 사용자 상호작용을 위한 UI 컴포넌트 (폼, 모달, 버튼 등)
|
||||
- **api**: feature 실행에 필요한 API 요청 함수
|
||||
- **model**: 폼 검증, 내부 상태, 비즈니스 로직
|
||||
- **lib**: feature 내부에서만 사용되는 유틸리티
|
||||
- **config**: feature flags, feature별 설정값
|
||||
- **index.ts**: 슬라이스의 Public API (외부에서 사용할 수 있는 것만 export)
|
||||
|
||||
## 예시
|
||||
|
||||
```typescript
|
||||
// features/auth-login/ui/login-form.tsx
|
||||
import { useState } from 'react';
|
||||
import { loginUser } from '../api/login-user';
|
||||
import type { LoginCredentials } from '../model/types';
|
||||
|
||||
export function LoginForm() {
|
||||
const [credentials, setCredentials] = useState<LoginCredentials>({
|
||||
email: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.SubmitEvent) => {
|
||||
e.preventDefault();
|
||||
await loginUser(credentials);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="email"
|
||||
value={credentials.email}
|
||||
onChange={(e) => setCredentials({ ...credentials, email: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={credentials.password}
|
||||
onChange={(e) => setCredentials({ ...credentials, password: e.target.value })}
|
||||
/>
|
||||
<button type="submit">로그인</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// features/auth-login/api/login-user.ts
|
||||
import type { LoginCredentials } from '../model/types';
|
||||
|
||||
export async function loginUser(credentials: LoginCredentials) {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(credentials),
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// features/auth-login/model/types.ts
|
||||
export interface LoginCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
// features/auth-login/index.ts
|
||||
export { LoginForm } from './ui/login-form';
|
||||
export { loginUser } from './api/login-user';
|
||||
export type { LoginCredentials } from './model/types';
|
||||
```
|
||||
|
||||
## 레이어 의존성 규칙
|
||||
|
||||
✅ **허용되는 의존성**:
|
||||
|
||||
- `shared` 레이어의 모듈
|
||||
- `entities` 레이어의 모듈
|
||||
- 같은 feature 슬라이스 내의 다른 세그먼트
|
||||
|
||||
❌ **금지된 의존성**:
|
||||
|
||||
- 다른 `features` 슬라이스 (feature 간 직접 의존 금지)
|
||||
- `widgets` 레이어
|
||||
- `pages` 레이어
|
||||
- `app` 레이어
|
||||
|
||||
> 💡 **팁**: 여러 features가 공통 기능을 필요로 한다면, 그 기능을 `shared` 레이어로 이동시키거나 `entities` 레이어에서 관리하세요.
|
||||
|
||||
## 명명 규칙
|
||||
|
||||
- 소문자와 대시 사용: `auth-login`, `post-create`, `comment-edit`
|
||||
- 동사 형태 권장: 사용자 액션을 나타내는 이름 사용
|
||||
- 명확하고 구체적으로: 기능의 목적이 명확하게 드러나도록
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **하나의 기능에만 집중**: 각 feature는 단일 사용자 시나리오를 구현해야 합니다
|
||||
2. **재사용성 고려**: 여러 페이지에서 사용될 수 있도록 설계
|
||||
3. **독립성 유지**: 다른 features에 의존하지 않고 독립적으로 작동
|
||||
4. **적절한 추상화**: 너무 많은 feature를 만들지 말고, 중요한 기능 위주로 구성
|
||||
@@ -0,0 +1 @@
|
||||
export { MainPage } from "./ui/main-page";
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
export function MainPage() {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center py-10">
|
||||
<h1 className="mb-4 text-3xl font-bold">Home</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export const PORT = process.env.NEXT_PUBLIC_PORT || "3000";
|
||||
export const PROXY_HOST = process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`;
|
||||
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "AS";
|
||||
export const SERVER_API_URL = process.env.SERVER_API_URL;
|
||||
export const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
|
||||
export const ACCESS_TOKEN = process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token";
|
||||
export const REFRESH_TOKEN = process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token";
|
||||
export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
|
||||
@@ -18,9 +18,6 @@ const config: Config = {
|
||||
background: "var(--background)",
|
||||
foreground: "var(--foreground)",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["var(--font-noto-sans-kr)", "Noto Sans KR", "sans-serif"],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
|
||||
@@ -19,13 +19,7 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"],
|
||||
"@/app/*": ["./app/*"],
|
||||
"@/shared/*": ["./shared/*"],
|
||||
"@/entities/*": ["./entities/*"],
|
||||
"@/features/*": ["./features/*"],
|
||||
"@/widgets/*": ["./widgets/*"],
|
||||
"@/pages/*": ["./pages/*"],
|
||||
"@/*": ["./src/*"],
|
||||
"@common/components": ["../../packages/common/components/dist/"],
|
||||
"@common/network": ["../../packages/common/network/"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env.*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"singleQuote": false,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
# Template Application
|
||||
|
||||
Next.js 애플리케이션 with FSD(Feature Sliced Design) 아키텍처
|
||||
|
||||
## 기술 스택
|
||||
|
||||
- **프레임워크**: Next.js 15 + TypeScript
|
||||
- **스타일링**: Tailwind CSS
|
||||
- **상태관리**: Zustand
|
||||
- **데이터 페칭**: TanStack Query (React Query)
|
||||
- **폼 관리**: React Hook Form + Zod
|
||||
- **테이블**: TanStack Table
|
||||
- **HTTP 클라이언트**: Axios
|
||||
- **날짜 처리**: Day.js
|
||||
|
||||
## 프로젝트 구조 (FSD + Next.js App Router)
|
||||
|
||||
이 프로젝트는 **Feature-Sliced Design v2.1**과 **FSD 공식 Next.js 가이드**에 맞춰 구성되어 있습니다. Next.js `app/`은 라우트만 담당하고, 실제 레이어는 `src/` 아래에 있습니다.
|
||||
|
||||
```
|
||||
_template/
|
||||
├── app/ # Next.js App Router (루트) — 라우트·레이아웃 진입점만
|
||||
│ ├── layout.tsx # 루트 레이아웃 (src/app에서 providers, styles import)
|
||||
│ └── (with-base-layout)/
|
||||
│ ├── layout.tsx # BaseLayout 래퍼 (src/app/layouts에서 import)
|
||||
│ └── page.tsx # re-export: export { MainPage as default } from '@/pages/home'
|
||||
│
|
||||
├── pages/ # 빈 폴더 + README (Next.js가 src/pages를 Pages Router로 쓰지 않도록)
|
||||
│ └── README.md
|
||||
│
|
||||
└── src/ # FSD 레이어 (실제 코드)
|
||||
├── app/ # App 레이어 (슬라이스 없음, 세그먼트만)
|
||||
│ ├── providers/ # 전역 Providers (QueryClient 등)
|
||||
│ ├── styles/ # 글로벌 스타일 (globals.css)
|
||||
│ └── layouts/ # 공통 레이아웃 컴포넌트 (BaseLayoutWrapper)
|
||||
│
|
||||
├── pages/ # Pages 레이어 (페이지 슬라이스)
|
||||
│ └── home/
|
||||
│ ├── ui/
|
||||
│ └── index.ts # Public API
|
||||
│
|
||||
├── widgets/ # Widgets 레이어
|
||||
├── features/ # Features 레이어
|
||||
├── entities/ # Entities 레이어
|
||||
└── shared/ # Shared 레이어
|
||||
├── ui/
|
||||
├── api/
|
||||
├── lib/
|
||||
└── config/
|
||||
```
|
||||
|
||||
### 계층 구조 이해하기
|
||||
|
||||
FSD는 **3단계 계층**으로 구성됩니다:
|
||||
|
||||
1. **Layers (레이어)**: 책임과 의존성에 따른 수평적 분리 (app, pages, widgets, features, entities, shared)
|
||||
2. **Slices (슬라이스)**: 비즈니스 도메인에 따른 수직적 분리 (user, product, auth-login 등)
|
||||
3. **Segments (세그먼트)**: 기술적 목적에 따른 분리 (ui, api, model, lib 등)
|
||||
|
||||
> 📚 각 레이어의 자세한 설명은 해당 폴더의 README.md를 참고하세요.
|
||||
|
||||
## FSD 레이어 설명
|
||||
|
||||
FSD는 계층적 아키텍처로, 각 레이어는 책임의 범위와 의존성의 정도에 따라 구분됩니다. 7개의 표준화된 레이어가 있으며, 프로젝트에 필요한 레이어만 사용하면 됩니다.
|
||||
|
||||
### 레이어 계층 구조 (위에서 아래로)
|
||||
|
||||
```
|
||||
app/ ↑ 가장 높은 책임, 가장 많은 의존성
|
||||
pages/ |
|
||||
widgets/ |
|
||||
features/ |
|
||||
entities/ |
|
||||
shared/ ↓ 가장 낮은 책임, 의존성 없음
|
||||
```
|
||||
|
||||
### 1. App
|
||||
- **목적**: 앱 전체에 영향을 주는 기술적/비즈니스적 사항
|
||||
- **내용**: Context providers, 글로벌 스타일, 스토어 설정, 라우터 설정, 애널리틱스
|
||||
- **특징**: 슬라이스가 없고 세그먼트로만 구성
|
||||
- **의존성**: 모든 하위 레이어 사용 가능
|
||||
|
||||
### 2. Pages (src/pages)
|
||||
- **목적**: 웹사이트나 애플리케이션을 구성하는 페이지/화면
|
||||
- **내용**: 페이지 UI, 로딩 상태, 에러 바운더리, 데이터 fetching
|
||||
- **특징**: 한 페이지당 한 슬라이스 (유사한 페이지는 그룹화 가능). Next.js `app/`의 route 파일에서 re-export
|
||||
- **의존성**: `widgets`, `features`, `entities`, `shared` 사용 가능
|
||||
|
||||
> 💡 **FSD v2.1 권장사항**: "pages-first" 접근 방식을 따라, 재사용되지 않는 큰 UI 블록과 비즈니스 로직은 페이지 슬라이스에 유지하세요.
|
||||
|
||||
### 3. Widgets
|
||||
- **목적**: 큰 규모의 독립적인 UI 블록
|
||||
- **내용**: 여러 features와 entities를 조합한 복합 UI
|
||||
- **사용 시기**: 여러 페이지에서 재사용되거나, 페이지에 여러 개의 큰 독립적 블록이 있을 때
|
||||
- **의존성**: `features`, `entities`, `shared` 사용 가능 (다른 `widgets` 불가)
|
||||
|
||||
### 4. Features
|
||||
- **목적**: 사용자가 수행하는 주요 상호작용과 비즈니스 기능
|
||||
- **내용**: 사용자 시나리오 (예: 로그인, 상품 검색, 댓글 작성)
|
||||
- **사용 시기**: 여러 페이지에서 재사용되는 기능
|
||||
- **의존성**: `entities`, `shared` 사용 가능 (다른 `features` 불가)
|
||||
|
||||
### 5. Entities
|
||||
- **목적**: 비즈니스가 다루는 실제 개념 (User, Product, Order 등)
|
||||
- **내용**: 데이터 모델, 타입, API 요청, 엔티티의 시각적 표현
|
||||
- **특징**: 비즈니스 용어로 명명
|
||||
- **의존성**: 다른 `entities`, `shared` 사용 가능
|
||||
|
||||
### 6. Shared
|
||||
- **목적**: 애플리케이션의 기반 제공
|
||||
- **내용**: API 클라이언트, UI Kit, 유틸리티 라이브러리, 글로벌 설정
|
||||
- **특징**: 비즈니스 로직 없음, 슬라이스가 없고 세그먼트로만 구성
|
||||
- **의존성**: 외부 라이브러리만 사용 가능 (다른 레이어 불가)
|
||||
|
||||
## 시작하기
|
||||
|
||||
```bash
|
||||
# 의존성 설치
|
||||
pnpm install
|
||||
|
||||
# 개발 서버 실행
|
||||
pnpm run dev
|
||||
|
||||
# 프로덕션 빌드
|
||||
pnpm run build
|
||||
|
||||
# 프로덕션 서버 실행
|
||||
pnpm start
|
||||
```
|
||||
|
||||
## 환경 변수
|
||||
|
||||
`.env.local` 파일 생성:
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3000/api
|
||||
```
|
||||
|
||||
## FSD 핵심 규칙
|
||||
|
||||
### 1. Import 규칙
|
||||
|
||||
> **슬라이스 내의 모듈(파일)은 오직 하위 레이어에 있는 슬라이스만 import할 수 있습니다.**
|
||||
|
||||
```typescript
|
||||
// ✅ 올바른 import (하위 레이어)
|
||||
// features/auth-login/ui/form.tsx
|
||||
import { User } from "entities/user";
|
||||
import { Button } from "shared/ui/button";
|
||||
|
||||
// ❌ 잘못된 import (같은 레이어)
|
||||
// features/auth-login/ui/form.tsx
|
||||
import { PostForm } from "features/post-create"; // 금지!
|
||||
|
||||
// ❌ 잘못된 import (상위 레이어)
|
||||
// features/auth-login/ui/form.tsx
|
||||
import { Header } from "widgets/header"; // 금지!
|
||||
```
|
||||
|
||||
### 2. Public API 규칙
|
||||
|
||||
> **슬라이스 외부에서는 Public API(index.ts)를 통해서만 접근해야 합니다.**
|
||||
|
||||
```typescript
|
||||
// ✅ 올바른 import (Public API 사용)
|
||||
import { LoginForm } from "features/auth-login";
|
||||
|
||||
// ❌ 잘못된 import (내부 구조 직접 접근)
|
||||
import { LoginForm } from "features/auth-login/ui/login-form";
|
||||
```
|
||||
|
||||
**Public API 작성 시 주의사항**:
|
||||
- 와일드카드 re-export(`export * from`) 사용 지양
|
||||
- 필요한 것만 명시적으로 export
|
||||
- 슬라이스의 인터페이스를 명확하게 표현
|
||||
|
||||
```typescript
|
||||
// ❌ 나쁜 예시
|
||||
export * from "./ui/Comment";
|
||||
export * from "./model/comments";
|
||||
|
||||
// ✅ 좋은 예시
|
||||
export { Comment } from "./ui/comment";
|
||||
export { CommentForm } from "./ui/comment-form";
|
||||
export { useComments } from "./model/use-comments";
|
||||
export type { Comment, CommentFormData } from "./model/types";
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
export { ApprovalLineManagementPage as default } from "@/pages/approval-line-management";
|
||||
@@ -0,0 +1 @@
|
||||
export { ChecklistManagementPage as default } from "@/pages/checklist-management";
|
||||
@@ -0,0 +1 @@
|
||||
export { CodeManagementPage as default } from "@/pages/code-management";
|
||||
@@ -0,0 +1 @@
|
||||
export { EsntlAtchFileMngPage as default } from "@/pages/esntl-atch-file-mng";
|
||||
@@ -0,0 +1,10 @@
|
||||
import { FnrsEditPage } from "@/pages/fnrs-cnsltn";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function Page({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
return <FnrsEditPage id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { FnrsViewPage } from "@/pages/fnrs-cnsltn";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function Page({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
return <FnrsViewPage id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { FnrsListPage } from "@/pages/fnrs-cnsltn";
|
||||
|
||||
export default FnrsListPage;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { FnrsRegistPage } from "@/pages/fnrs-cnsltn";
|
||||
import { parseTaskSeComCdFromApi } from "@/pages/fnrs-cnsltn/lib/fnrs-cnsltn-regist-schema";
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ taskSeComCd?: string }>;
|
||||
}
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
const params = await searchParams;
|
||||
const taskSeComCd = parseTaskSeComCdFromApi(params.taskSeComCd);
|
||||
return <FnrsRegistPage taskSeComCd={taskSeComCd.length ? taskSeComCd : undefined} />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { InstlEditPage } from "@/pages/instl-sncn";
|
||||
|
||||
interface InstlSncnEditRouteProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function Page({ params }: InstlSncnEditRouteProps) {
|
||||
const { id } = await params;
|
||||
return <InstlEditPage id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { InstlViewPage } from "@/pages/instl-sncn";
|
||||
|
||||
export default async function InstlSncnDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return <InstlViewPage id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { InstlListPage } from "@/pages/instl-sncn";
|
||||
|
||||
export default InstlListPage;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { InstlRegistPage } from "@/pages/instl-sncn";
|
||||
|
||||
interface InstlSncnRegistRouteProps {
|
||||
searchParams: Promise<{ taskSeComCd?: string }>;
|
||||
}
|
||||
|
||||
export default async function Page({ searchParams }: InstlSncnRegistRouteProps) {
|
||||
const params = await searchParams;
|
||||
const taskSeComCd = params.taskSeComCd
|
||||
? params.taskSeComCd.split(",").filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
return <InstlRegistPage taskSeComCd={taskSeComCd} />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { BaseLayoutWrapper } from "@/app/layouts/base-layout";
|
||||
|
||||
interface WithBaseLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function WithBaseLayout({ children }: WithBaseLayoutProps) {
|
||||
return <BaseLayoutWrapper>{children}</BaseLayoutWrapper>;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { MainPage as default } from "@/pages/home";
|
||||
@@ -0,0 +1 @@
|
||||
export { RvwMnpwMngPage as default } from "@/pages/rvw-mnpw-mng";
|
||||
@@ -0,0 +1,169 @@
|
||||
# App Layer
|
||||
|
||||
**App**은 애플리케이션 전체에 영향을 미치는 기술적, 비즈니스적 사항을 다루는 최상위 레이어입니다.
|
||||
|
||||
## 목적
|
||||
|
||||
- 애플리케이션의 진입점(entrypoint) 제공
|
||||
- 전역 Context Providers 설정
|
||||
- 라우터 구성
|
||||
- 글로벌 스타일 정의
|
||||
- 전역 상태 저장소 설정
|
||||
- 애널리틱스 및 모니터링 설정
|
||||
|
||||
## 특징
|
||||
|
||||
- **슬라이스 없음**: App 레이어는 Shared처럼 슬라이스가 없고 세그먼트로만 구성됩니다
|
||||
- **전체 앱 관심사**: 특정 비즈니스 도메인이 아닌 앱 전체와 관련된 코드만 포함
|
||||
- **최상위 레이어**: 모든 하위 레이어의 모듈을 자유롭게 import 가능
|
||||
|
||||
## 구조 (Next.js App Router)
|
||||
|
||||
Next.js를 사용하는 경우, App 레이어는 주로 `app/` 디렉토리로 표현됩니다:
|
||||
|
||||
```
|
||||
app/
|
||||
├── (routes)/ # 라우트 그룹 및 페이지
|
||||
│ ├── (auth)/
|
||||
│ │ ├── login/
|
||||
│ │ │ └── page.tsx
|
||||
│ │ └── register/
|
||||
│ │ └── page.tsx
|
||||
│ └── (main)/
|
||||
│ ├── dashboard/
|
||||
│ │ └── page.tsx
|
||||
│ └── settings/
|
||||
│ └── page.tsx
|
||||
├── layout.tsx # 루트 레이아웃
|
||||
├── page.tsx # 홈 페이지
|
||||
├── globals.css # 글로벌 스타일
|
||||
├── providers.tsx # 전역 Providers 설정
|
||||
└── error.tsx # 글로벌 에러 핸들러
|
||||
```
|
||||
|
||||
### 일반적인 FSD 세그먼트 구조
|
||||
|
||||
FSD 표준 세그먼트를 사용하는 경우:
|
||||
|
||||
```
|
||||
app/
|
||||
├── entrypoint/ # 애플리케이션 진입점 (프레임워크별)
|
||||
│ └── index.tsx
|
||||
├── providers/ # Context Providers
|
||||
│ ├── query-provider.tsx
|
||||
│ ├── theme-provider.tsx
|
||||
│ └── index.tsx
|
||||
├── styles/ # 글로벌 스타일
|
||||
│ ├── globals.css
|
||||
│ └── theme.css
|
||||
├── store/ # 글로벌 상태 저장소 설정
|
||||
│ └── index.ts
|
||||
└── routes/ # 라우터 설정
|
||||
└── index.tsx
|
||||
```
|
||||
|
||||
## 예시
|
||||
|
||||
### Next.js Providers 설정
|
||||
|
||||
```typescript
|
||||
// app/providers.tsx
|
||||
'use client';
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ThemeProvider } from 'next-themes';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(() => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// app/layout.tsx
|
||||
import { Providers } from './providers';
|
||||
import './globals.css';
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="ko">
|
||||
<body>
|
||||
<Providers>
|
||||
{children}
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 글로벌 설정
|
||||
|
||||
```typescript
|
||||
// app/config/analytics.ts
|
||||
export function initAnalytics() {
|
||||
if (typeof window !== 'undefined') {
|
||||
// 애널리틱스 초기화
|
||||
}
|
||||
}
|
||||
|
||||
// app/config/monitoring.ts
|
||||
export function initMonitoring() {
|
||||
// 에러 모니터링 설정
|
||||
}
|
||||
```
|
||||
|
||||
## Next.js App Router와 FSD
|
||||
|
||||
Next.js의 `app/` 디렉토리는 FSD의 **App 레이어**와 **Pages 레이어**를 동시에 담당합니다:
|
||||
|
||||
- **루트 레벨 파일들** (`layout.tsx`, `providers.tsx`, `globals.css`): App 레이어
|
||||
- **라우트 폴더들** (`dashboard/`, `login/` 등): Pages 레이어
|
||||
|
||||
각 페이지별 비즈니스 로직은:
|
||||
- 재사용되지 않는 UI는 페이지 슬라이스(`app/dashboard/` 등) 안에 유지
|
||||
- 재사용되는 기능은 `features/` 레이어로 분리
|
||||
- 여러 페이지에서 재사용되는 큰 UI 블록은 `widgets/` 레이어로 분리
|
||||
|
||||
## 레이어 의존성 규칙
|
||||
|
||||
✅ **허용되는 의존성**:
|
||||
- 모든 하위 레이어 (`pages`, `widgets`, `features`, `entities`, `shared`)
|
||||
- 같은 `app` 레이어 내의 다른 세그먼트
|
||||
- 외부 라이브러리
|
||||
|
||||
❌ **금지된 의존성**:
|
||||
- 없음 (App은 최상위 레이어)
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **비대화 방지**: App 레이어가 너무 커지면 관리가 어려워집니다. 대부분의 로직은 하위 레이어에 배치하세요
|
||||
2. **전역 관심사만**: 특정 페이지나 기능에만 관련된 코드는 해당 레이어로 이동
|
||||
3. **라우터 레벨 로직**: 라우터 및 서버 레벨 로직은 이 레이어에 유지
|
||||
4. **Processes 레이어 사용 지양**: Processes 레이어는 deprecated되었으므로 해당 로직은 `app`이나 `features`에 배치
|
||||
|
||||
## FSD v2.1 권장사항
|
||||
|
||||
최신 FSD 버전(v2.1)은 **"pages-first"** 접근 방식을 권장합니다:
|
||||
|
||||
- 더 많은 코드를 페이지 슬라이스에 유지
|
||||
- 큰 UI 블록과 재사용되지 않는 비즈니스 로직은 즉시 추출하지 말고 페이지에 보관
|
||||
- 실제로 재사용이 필요할 때만 `features`나 `widgets`로 분리
|
||||
|
||||
이 접근 방식은 과도한 추상화를 방지하고 코드를 더 쉽게 탐색할 수 있게 합니다.
|
||||
@@ -0,0 +1,94 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
AUTH_USER_ID,
|
||||
CLAIM_NAME,
|
||||
COOKIE_DOMAIN,
|
||||
REFRESH_TOKEN,
|
||||
SERVER_API_URL,
|
||||
SITE_ID,
|
||||
} from "@/shared/config";
|
||||
|
||||
async function handler(
|
||||
request: Request,
|
||||
params: { url: string[] },
|
||||
method: "GET" | "POST" | "PUT" | "DELETE",
|
||||
) {
|
||||
const cookieStore = await cookies();
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Site-Id": SITE_ID,
|
||||
};
|
||||
const deviceId = request.headers.get("X-Device-Id");
|
||||
if (deviceId) {
|
||||
headers["X-Device-Id"] = deviceId;
|
||||
}
|
||||
let body = {};
|
||||
const isRefreshHandler = params.url.includes("refresh");
|
||||
|
||||
// silent refresh
|
||||
if (isRefreshHandler) {
|
||||
const refreshToken = cookieStore.get(REFRESH_TOKEN);
|
||||
headers[CLAIM_NAME] = refreshToken?.value ?? "";
|
||||
|
||||
if (!headers[CLAIM_NAME]) {
|
||||
return NextResponse.json({ message: "Can't refresh" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isRefreshHandler) {
|
||||
body = await request.json();
|
||||
}
|
||||
|
||||
const targetUrl = `${SERVER_API_URL}/${params.url.join("/")}`;
|
||||
const result = await fetch(targetUrl, {
|
||||
method,
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return NextResponse.json({ message: "Invalid Credentials" }, { status: 401 });
|
||||
}
|
||||
|
||||
const accessToken = result.headers.get(ACCESS_TOKEN);
|
||||
const refreshToken = result.headers.get(REFRESH_TOKEN);
|
||||
const userId = result.headers.get(AUTH_USER_ID);
|
||||
|
||||
if (accessToken && userId) {
|
||||
const response = NextResponse.json({
|
||||
[ACCESS_TOKEN]: accessToken,
|
||||
[AUTH_USER_ID]: userId,
|
||||
});
|
||||
|
||||
if (refreshToken) {
|
||||
cookieStore.set(REFRESH_TOKEN, refreshToken, {
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
domain: COOKIE_DOMAIN,
|
||||
path: "/",
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: "Invalid Credentials" }, { status: 401 });
|
||||
} catch (error) {
|
||||
console.error(`[Proxy API] Error caught in handler:`, error);
|
||||
return NextResponse.json({ message: "Server Error", error }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request, props: { params: Promise<{ url: string[] }> }) {
|
||||
const params = await props.params;
|
||||
return handler(request, params, "POST");
|
||||
}
|
||||
|
||||
export async function PUT(request: Request, props: { params: Promise<{ url: string[] }> }) {
|
||||
const params = await props.params;
|
||||
return handler(request, params, "PUT");
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
import { Providers } from "@/app/providers";
|
||||
|
||||
import "@/app/styles/globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "행정지원시스템",
|
||||
description: "행정지원시스템",
|
||||
};
|
||||
|
||||
interface RootLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: RootLayoutProps) {
|
||||
const theme = (await cookies()).get("site-theme-key");
|
||||
|
||||
return (
|
||||
<html lang="ko" data-theme={theme?.value}>
|
||||
<body>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
# 화면설계서: 결재선 관리
|
||||
|
||||
## 1. 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ---------- | -------------------------------------------- |
|
||||
| 버전 | 0.3 |
|
||||
| 프로세스명 | UI설계 |
|
||||
| 화면명 | 결재선 관리 |
|
||||
| 화면 유형 | [v] 조회 [v] 입력 [v] 수정 [v] 삭제 [ ] 출력 |
|
||||
| 메뉴 경로 | 디지털행정지원 > 시스템관리 > 결재선 관리 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 화면 목적
|
||||
|
||||
전자결재 처리에 사용할 **결재서식**(템플릿)을 등록·관리하고, 각 서식별로 **결재선**(결재구분·결재자·순서)을 구성한다. 상단의 결재서식 목록에서 하나를 선택하면 하단 영역에 해당 서식의 결재선이 표시되며, 사용자는 **그리드 인라인 폼**으로 결재자를 등록·수정한다. **행 추가 또는 한 행 수정 중에는 다른 행을 편집할 수 없다**(단일 행 편집 잠금). 드래그로 순서를 변경한 뒤 **[결재선 저장]** 으로 서버에 반영한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 화면 구성
|
||||
|
||||
### 3-1. 전체 레이아웃
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 페이지 타이틀: 결재선 관리 브레드크럼: 디지털행정지원 > … > │
|
||||
├───────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ [영역 A] 전자결재선 관리 목록 [서식 등록] │
|
||||
│ ┌──────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 전자결재서식아이디 | 결재서식명 | 문서구분 | 등록일시 | 관리 │ │
|
||||
│ ├──────────────────────────────────────────────────────────────────────┤ │
|
||||
│ │ TMPL-0001 (선택 행 강조) … [수정][삭제] │ │
|
||||
│ └──────────────────────────────────────────────────────────────────────┘ │
|
||||
├───────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ [영역 B] 결재선 관리 선택된 서식: TMPL-0001 [결재선 저장] │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ [영역 B-2] 결재선 그리드 (인라인 편집 + 행 드래그 — @dnd-kit) │ │
|
||||
│ │ ⋮⋮ | 결재선아이디 | 결재순서 | 결재구분 ▼ | 결재자 [선택] | 등록일시 | 관리 │ │
|
||||
│ │ ⋮⋮ | APLN-0001 | [ 1 ] | [검토자 ▼] | [김민수 ][선택] | … | [저장][삭제] │ │
|
||||
│ │ ⋮⋮ | (신규) | [ 2 ] | [승인자 ▼] | [ ][선택] | — | [저장][취소] │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────────────────────────┘ │
|
||||
│ [행 추가] │
|
||||
│ ※ 결재자 등록·수정은 그리드 셀에서 직접 입력. 검토인력만 [선택] 팝업/다이얼로그 │
|
||||
│ ※ 행 추가·수정 중: 해당 행만 편집 가능, 나머지 행·[행 추가]·드래그 비활성 │
|
||||
└───────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3-2. 화면 데이터 흐름
|
||||
|
||||
```
|
||||
BEGIN
|
||||
|
||||
// 최초 진입
|
||||
CALL GET /as-elapr-service/api/v1/elapr-tmplt
|
||||
DISPLAY 결재서식 그리드 (영역 A)
|
||||
DISPLAY 빈 상태 안내 (영역 B) // 서식 미선택 시 그리드·툴바 비활성
|
||||
|
||||
// 영역 A: 서식 행 선택
|
||||
ON 영역 A 행 CLICK DO
|
||||
SET 선택된 서식 = 행.elaprTmpltId
|
||||
CALL GET /as-elapr-service/api/v1/elapr-apln?elaprTmpltId={id}
|
||||
DISPLAY 결재선 그리드 (영역 B-2) — 조회 모드
|
||||
END ON
|
||||
|
||||
// 영역 A: 신규 서식 등록
|
||||
ON [서식 등록] 버튼 CLICK DO
|
||||
OPEN 결재서식 등록 다이얼로그
|
||||
ON 확인 DO
|
||||
CALL POST /as-elapr-service/api/v1/elapr-tmplt
|
||||
CALL GET /as-elapr-service/api/v1/elapr-tmplt
|
||||
END ON
|
||||
END ON
|
||||
|
||||
// 영역 A: 서식 수정·삭제 (다이얼로그 유지)
|
||||
ON 영역 A [수정] / [삭제] DO
|
||||
(기존과 동일 — PUT / DELETE elapr-tmplt)
|
||||
END ON
|
||||
|
||||
// 영역 B: 행 추가 (인라인 신규 행)
|
||||
ON [행 추가] 버튼 CLICK DO
|
||||
VALIDATE 서식 선택됨
|
||||
VALIDATE 편집 중인 행 없음 (editingRowKey == null)
|
||||
ADD 영역 B-2 그리드 맨 아래 편집 가능 행
|
||||
SET editingRowKey = rowKey (draft-{timestamp})
|
||||
SET 편집상태 = 신규
|
||||
SET aplnId = 미설정 (POST 저장 시 서버 fn_gen_apln_id 발급)
|
||||
SET atrzSeqNo = 현재 행 수 + 1
|
||||
SET atrzSeComCd, aprvrUuid = 빈 값
|
||||
DISABLE 나머지 행 편집 · [행 추가] · 드래그 · [결재선 저장](정책에 따라)
|
||||
END ON
|
||||
|
||||
// 영역 B-2: 기존 행 수정 진입
|
||||
ON 행별 [수정] CLICK DO
|
||||
VALIDATE 편집 중인 행 없음
|
||||
SET editingRowId = 행.aplnId
|
||||
SET 해당 행만 편집 모드
|
||||
DISABLE 나머지 행 편집 · [행 추가] · 드래그
|
||||
END ON
|
||||
|
||||
// 영역 B-2: 인라인 편집
|
||||
ON 그리드 셀 변경 DO
|
||||
VALIDATE 변경 대상 행 == editingRowId
|
||||
SET 해당 행 dirty = true
|
||||
END ON
|
||||
|
||||
ON 셀 [선택] (검토인력) CLICK DO
|
||||
OPEN 검토인력 선택 다이얼로그 (단일 선택)
|
||||
ON 선택 DO
|
||||
SET 행.aprvrUuid = 선택인력.uuid
|
||||
SET 행.aprvrNm = 선택인력.성명 // UI 표시용 (응답 DTO에 없음 — 로컬 보조 필드)
|
||||
END ON
|
||||
END ON
|
||||
|
||||
// 영역 B-2: 행별 저장
|
||||
ON 행별 [저장] CLICK DO
|
||||
VALIDATE atrzSeComCd, aprvrUuid, atrzSeqNo
|
||||
IF 편집상태 = 신규 THEN
|
||||
CALL POST /as-elapr-service/api/v1/elapr-apln
|
||||
ELSE
|
||||
CALL PUT /as-elapr-service/api/v1/elapr-apln/{aplnId}
|
||||
END IF
|
||||
CALL GET /as-elapr-service/api/v1/elapr-apln?elaprTmpltId={id}
|
||||
CLEAR editingRowId
|
||||
ENABLE 전체 행 조회 모드 · [행 추가] · 드래그
|
||||
END ON
|
||||
|
||||
// 영역 B: 결재선 저장 (드래그 순서 일괄 반영)
|
||||
ON [결재선 저장] 버튼 CLICK DO
|
||||
VALIDATE 서식 선택됨
|
||||
VALIDATE 편집 중인 행 없음
|
||||
REORDER atrzSeqNo = 그리드 행 순서 (1..n)
|
||||
FOR EACH dirty 또는 순서 변경된 행 DO
|
||||
CALL PUT /as-elapr-service/api/v1/elapr-apln/{aplnId} // atrzSeqNo 등 반영
|
||||
END FOR
|
||||
FOR EACH 신규 미저장 행 DO
|
||||
CALL POST /as-elapr-service/api/v1/elapr-apln
|
||||
END FOR
|
||||
CALL GET /as-elapr-service/api/v1/elapr-apln?elaprTmpltId={id}
|
||||
TOAST "결재선이 저장되었습니다."
|
||||
END ON
|
||||
|
||||
// 영역 B-2: 행 드래그
|
||||
ON 영역 B-2 행 DRAG END DO
|
||||
VALIDATE 편집 중인 행 없음
|
||||
REORDER 그리드 행
|
||||
SET 각 행 atrzSeqNo = 행 인덱스 + 1 (UI 즉시 반영, 서버는 [결재선 저장] 또는 행별 [저장])
|
||||
END ON
|
||||
|
||||
// 영역 B-2: 행 삭제
|
||||
ON 행별 [삭제] CLICK DO
|
||||
CONFIRM "선택한 결재선을 삭제하시겠습니까?"
|
||||
IF 서버 저장된 행 THEN
|
||||
CALL DELETE /as-elapr-service/api/v1/elapr-apln/{aplnId}
|
||||
END IF
|
||||
REMOVE 그리드 행 (신규 미저장 행은 API 호출 없이 제거)
|
||||
CALL GET /as-elapr-service/api/v1/elapr-apln?elaprTmpltId={id}
|
||||
END ON
|
||||
|
||||
ON 행별 [취소] CLICK DO // 신규·수정 공통
|
||||
IF 편집상태 = 신규 THEN REMOVE 임시 행
|
||||
ELSE RESTORE 행 스냅샷 (수정 취소)
|
||||
CLEAR editingRowId
|
||||
ENABLE 전체 행 조회 모드 · [행 추가] · 드래그
|
||||
END ON
|
||||
|
||||
END
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 영역 A: 전자결재선 관리 목록
|
||||
|
||||
선택된 결재서식이 영역 B의 작업 대상이 된다. 행 클릭으로 선택 상태를 표시(좌측 컬러 바·배경 강조)한다.
|
||||
|
||||
### 4-1. 헤더 / 툴바
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ----------- | ----------------------------------------------- |
|
||||
| 섹션 타이틀 | 전자결재선 관리 목록 |
|
||||
| 우측 버튼 | **[서식 등록]** — 결재서식 등록 다이얼로그 오픈 |
|
||||
|
||||
### 4-2. 그리드 컬럼
|
||||
|
||||
| 컬럼명 | Field | 타입 | 비고 |
|
||||
| ------------------ | -------------- | ------ | --------------------------------------- |
|
||||
| 전자결재서식아이디 | `elaprTmpltId` | string | 등록 시 서버(DB 함수) 자동 발급 |
|
||||
| 결재서식명 | `elaprTmpltNm` | string | |
|
||||
| 문서구분 | `docSeComNm` | string | 코드 `docSeComCd`, 상위코드 `ASDOCSECD` |
|
||||
| 등록일시 | `regDt` | string | `yyyy-MM-dd HH:mm:ss` |
|
||||
| 관리 | — | — | 행별 **[수정] · [삭제]** 버튼 |
|
||||
|
||||
- 행 클릭 시: `elaprTmpltId` 선택 → 영역 B 라벨·B-2 그리드 갱신.
|
||||
- 페이지네이션: Spring `Pageable` (`page`, `size`, `sort`).
|
||||
|
||||
### 4-3. 버튼
|
||||
|
||||
| 버튼명 | 위치 | 동작 |
|
||||
| --------- | -------------- | ----------------------------------------------- |
|
||||
| 서식 등록 | 섹션 우측 상단 | 결재서식 등록 다이얼로그 오픈 |
|
||||
| 수정 | 행별 | 결재서식 수정 다이얼로그 (기존 값 초기화) |
|
||||
| 삭제 | 행별 | 확인 후 삭제. 선택 중인 서식이면 영역 B 초기화. |
|
||||
|
||||
### 4-4. 다이얼로그(개요)
|
||||
|
||||
| 다이얼로그명 | 트리거 | 입력 항목 | 비고 |
|
||||
| ------------------ | ----------- | ----------------------------------------------- | ----------------------------------------- |
|
||||
| 결재서식 등록 | [서식 등록] | `elaprTmpltNm`, `docSeComCd` | `elaprTmpltExpln` 선택, ID는 저장 후 응답 |
|
||||
| 결재서식 수정 | 행별 [수정] | `elaprTmpltNm`, `docSeComCd`, `elaprTmpltExpln` | ID는 변경 불가 |
|
||||
| 결재서식 삭제 확인 | 행별 [삭제] | (확인/취소) | |
|
||||
|
||||
---
|
||||
|
||||
## 5. 영역 B: 결재선 관리
|
||||
|
||||
영역 A에서 선택된 `elaprTmpltId` 기준으로 결재선을 관리한다. **등록·수정은 B-2 그리드 인라인 폼**으로 수행하며, 검토인력 지정만 **[선택]** 보조 다이얼로그를 사용한다.
|
||||
|
||||
### 5-1. 헤더 / 툴바
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ----------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 섹션 타이틀 | 결재선 관리 |
|
||||
| 우측 라벨 | `선택된 서식: {elaprTmpltId}` — 미선택 시 영역 B 비활성 |
|
||||
| 우측 버튼 | **[행 추가]** — 편집 중인 행이 없을 때만 활성 · **[결재선 저장]** — 편집 중인 행이 없을 때만 활성(순서·미저장 행 일괄 반영) |
|
||||
|
||||
### 5-2. 영역 B-2: 결재선 그리드 (인라인 편집)
|
||||
|
||||
#### 그리드 컬럼
|
||||
|
||||
| 컬럼명 | Field | 입력 UI (편집 모드) | 비고 |
|
||||
| ------------- | ------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------- |
|
||||
| (드래그 핸들) | — | — | `useSortable` 연동 |
|
||||
| 결재선아이디 | `aplnId` | 읽기 전용 | 신규 행: `—`, 저장 후 서버 `aplnId` 표시 |
|
||||
| 결재순서번호 | `atrzSeqNo` | 읽기 전용 (그리드 행 순서 1부터 표시) | 저장 시 행 순서 기준 자동 채번 |
|
||||
| 결재구분 | `atrzSeComCd` | Select (`ATRZSECD` 공통코드) | 표시명 `atrzSeComNm` (조회 행) |
|
||||
| 결재자 | `aprvrUuid` | 읽기 전용 이름 + **[선택]** 버튼 | 응답에 성명 없음 — 선택 시 로컬 `aprvrNm` 유지 |
|
||||
| 등록일시 | `regDt` | 읽기 전용 | 신규 행은 `—` |
|
||||
| 관리 | — | 편집 중인 행: **[저장] [취소]** · 조회 행: **[수정] [삭제]** (다른 행 편집 중이면 비활성) |
|
||||
|
||||
#### 인라인 편집 정책
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **단일 행 편집** | **행 추가 또는 한 행 수정 중에는 다른 행을 편집할 수 없다.** `editingRowId` 로 현재 편집 행 1건만 허용. |
|
||||
| 편집 잠금 시 비활성 | 다른 행: 셀·[수정]·[삭제] 비활성 · `[행 추가]` 비활성 · 행 드래그 비활성 · (선택) `[결재선 저장]` 비활성 |
|
||||
| 편집 해제 | 행별 **[저장]** 성공 또는 **[취소]** 시 `editingRowId` 초기화 후 전체 조회 모드 복귀 |
|
||||
| 조회 행 | 편집 중이 아닐 때 읽기 전용. **[수정]** 클릭 시 해당 행만 편집 모드 진입 |
|
||||
| 신규 행 | `[행 추가]` 시 맨 아래 편집 행 1건 생성 (`editingRowId` = 임시 키) |
|
||||
| 필수값 | `atrzSeComCd`, `aprvrUuid` |
|
||||
| 순서 중복 | 저장 시 행 순서로 자동 채번하므로 중복 없음 |
|
||||
| 결재구분 코드 | `ATRZSECD` — 예: `ATRZ01` 검토자, `ATRZ02` 승인자, `ATRZ03` 최종승인자 |
|
||||
| **순서 제약** | `ATRZ01`은 최상단 블록, `ATRZ03`은 최하단 블록. 드래그 종료·저장 시 `validateAtrzOrder`로 검증, 위반 시 적용/저장 불가 (결재구분 변경 시에는 미검증) |
|
||||
| 저장 단위 | 행별 **[저장]** (단건 POST/PUT) 또는 툴바 **[결재선 저장]** (일괄) |
|
||||
|
||||
#### 행 드래그 정렬 (`@dnd-kit` + `useSortable`)
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ---------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| 목적 | 결재 단계 순서를 그리드에서 직접 변경 |
|
||||
| 라이브러리 | `@dnd-kit/core`, `@dnd-kit/sortable` — 테이블 행 POC 후 확정 |
|
||||
| 저장 시점 | 드래그는 클라이언트 상태만 변경 → **[결재선 저장]** 또는 행별 **[저장]** 시 `atrzSeqNo` PUT |
|
||||
| 편집 중 | `editingRowKey != null` 이면 드래그 **불가** |
|
||||
| 순서 제약 | 드래그 종료 시 `validateAtrzOrder` — 실패하면 순서 변경 미적용 + 안내. 결재구분 미선택 행은 드래그 불가 |
|
||||
|
||||
### 5-3. 보조 다이얼로그 (검토인력만)
|
||||
|
||||
| 다이얼로그명 | 트리거 | 결과 |
|
||||
| ------------- | ----------------------- | ---------------------------------- |
|
||||
| 검토인력 선택 | 그리드 결재자 셀 [선택] | `aprvrUuid` 및 UI용 `aprvrNm` 반영 |
|
||||
|
||||
> 결재자 **등록·수정 다이얼로그는 사용하지 않음** (v0.2 변경).
|
||||
|
||||
---
|
||||
|
||||
## 6. API/DTO 명세
|
||||
|
||||
### 6-1. 공통
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ----------- | ------------------------------------------------------------- |
|
||||
| 서비스 | `as-elapr-service` |
|
||||
| Base prefix | `/as-elapr-service/api/v1` (프록시 경유) |
|
||||
| 인증 | JWT (`Authorization` 헤더) |
|
||||
| 페이징 | Spring `Pageable`: `page`, `size`, `sort` → `PageResponse<T>` |
|
||||
| 엔티티 | `@/entities/elapr` (`elaprTmpltService`, `elaprAplnService`) |
|
||||
|
||||
### 6-2. 전자결재서식 (영역 A) — `elapr-tmplt`
|
||||
|
||||
| 기능 | Method | URL | Request / Query | Response | 클라이언트 |
|
||||
| --------- | ------ | ----------------------------- | ------------------------------------------ | ------------------ | ---------------------------- |
|
||||
| 목록 조회 | GET | `/elapr-tmplt` | Query: `ElaprTmpltSearchParams` + Pageable | `Page<ElaprTmplt>` | `elaprTmpltService.findPage` |
|
||||
| 단건 조회 | GET | `/elapr-tmplt/{elaprTmpltId}` | — | `ElaprTmplt` | `elaprTmpltService.findById` |
|
||||
| 등록 | POST | `/elapr-tmplt` | `ElaprTmpltSaveRequest` | `ElaprTmplt` (201) | `elaprTmpltService.create` |
|
||||
| 수정 | PUT | `/elapr-tmplt/{elaprTmpltId}` | `ElaprTmpltUpdateRequest` | `ElaprTmplt` | `elaprTmpltService.update` |
|
||||
| 삭제 | DELETE | `/elapr-tmplt/{elaprTmpltId}` | — | 204 | `elaprTmpltService.remove` |
|
||||
|
||||
**ElaprTmplt (응답)**
|
||||
|
||||
| Field | 타입 | 설명 |
|
||||
| ----------------- | ------- | ------------------ |
|
||||
| `elaprTmpltId` | string | 전자결재서식아이디 |
|
||||
| `elaprTmpltNm` | string | 결재서식명 |
|
||||
| `elaprTmpltExpln` | string? | 서식 설명 |
|
||||
| `docSeComCd` | string? | 문서구분 코드 |
|
||||
| `docSeComNm` | string? | 문서구분명 |
|
||||
| `regDt` | string? | 등록일시 |
|
||||
|
||||
**ElaprTmpltSaveRequest**: `elaprTmpltNm`\_, `elaprTmpltExpln?`, `docSeComCd?`
|
||||
|
||||
**ElaprTmpltUpdateRequest**: `elaprTmpltNm`\*, `elaprTmpltExpln?`, `docSeComCd?`
|
||||
|
||||
**ElaprTmpltSearchParams**: `elaprTmpltNm?`, `docSeComCd?`, `regDtStart?`, `regDtEnd?` + `page`, `size`, `sort`
|
||||
|
||||
### 6-3. 결재선 (영역 B) — `elapr-apln`
|
||||
|
||||
| 기능 | Method | URL | Request / Query | Response | 클라이언트 |
|
||||
| --------- | ------ | ---------------------- | ------------------------------- | ----------------- | --------------------------- |
|
||||
| 목록 조회 | GET | `/elapr-apln` | Query: `elaprTmpltId`, Pageable | `Page<ElaprApln>` | `elaprAplnService.findPage` |
|
||||
| 단건 조회 | GET | `/elapr-apln/{aplnId}` | — | `ElaprApln` | `elaprAplnService.findById` |
|
||||
| 등록 | POST | `/elapr-apln` | `ElaprAplnSaveRequest` | `ElaprApln` (201) | `elaprAplnService.create` |
|
||||
| 수정 | PUT | `/elapr-apln/{aplnId}` | `ElaprAplnUpdateRequest` | `ElaprApln` | `elaprAplnService.update` |
|
||||
| 삭제 | DELETE | `/elapr-apln/{aplnId}` | — | 204 | `elaprAplnService.remove` |
|
||||
|
||||
> **일괄 저장 API 없음** — UI의 [결재선 저장]은 변경된 행에 대해 POST/PUT을 순차(또는 병렬) 호출하는 클라이언트 오케스트레이션으로 구현.
|
||||
|
||||
**ElaprApln (응답)**
|
||||
|
||||
| Field | 타입 | 설명 |
|
||||
| -------------- | ------- | ------------------ |
|
||||
| `aplnId` | string | 결재선아이디 |
|
||||
| `elaprTmpltId` | string | 전자결재서식아이디 |
|
||||
| `aprvrUuid` | string? | 결재자 UUID |
|
||||
| `atrzSeComCd` | string? | 결재구분 코드 |
|
||||
| `atrzSeComNm` | string? | 결재구분명 |
|
||||
| `atrzSeqNo` | number? | 결재순서번호 |
|
||||
| `regDt` | string? | 등록일시 |
|
||||
|
||||
**ElaprAplnSaveRequest**: `elaprTmpltId`\_, `aprvrUuid?`, `atrzSeComCd?`, `atrzSeqNo?`
|
||||
|
||||
**ElaprAplnUpdateRequest**: `elaprTmpltId`\*, `aprvrUuid?`, `atrzSeComCd?`, `atrzSeqNo?`
|
||||
|
||||
### 6-4. 검토인력 (보조)
|
||||
|
||||
| 기능 | Method | URL | 비고 |
|
||||
| ------------------ | ------ | --- | --------------------------- |
|
||||
| 검토인력 목록 조회 | TBD | TBD | 사용자 서비스 또는 별도 API |
|
||||
|
||||
### 6-5. Backend ↔ 화면 필드 매핑 (구 SPEC 대비)
|
||||
|
||||
| 구 SPEC 필드 | 백엔드 필드 |
|
||||
| ----------------- | ---------------------------------------------------- |
|
||||
| `elctAtrzTmpltId` | `elaprTmpltId` |
|
||||
| `atrzTmpltNm` | `elaprTmpltNm` |
|
||||
| `docSeNm` | `docSeComNm` |
|
||||
| `atrzLineId` | `aplnId` |
|
||||
| `atrzSeNm` | `atrzSeComNm` |
|
||||
| `atrzUserNm` | (없음) `aprvrUuid` + 사용자 조회/선택 시 로컬 표시명 |
|
||||
|
||||
---
|
||||
|
||||
## 7. UI 구현 가이드
|
||||
|
||||
### 7-1. 기술 스택·원칙
|
||||
|
||||
- **공통 컴포넌트**: `@common/components`
|
||||
- **테이블**: `@tanstack/react-table` + 인라인 편집 셀 (`Select`, `Input`, `Button`)
|
||||
- **드래그**: `@dnd-kit` — B-2 행 정렬 (POC 필수)
|
||||
- **데이터**: React Query — `@/entities/elapr` hooks
|
||||
- **상태**: 선택된 `elaprTmpltId`, `editingRowId` (단일 행 편집 잠금), B-2 행 배열(dirty·신규·`aprvrNm`·수정 전 스냅샷)
|
||||
- **모의 데이터**: API 연동 전 `@faker-js/faker`
|
||||
|
||||
### 7-2. 모듈·파일 배치(제안)
|
||||
|
||||
| 구분 | 경로 |
|
||||
| ------ | -------------------------------------------------------------------------------------- |
|
||||
| 라우트 | `apps/as/app/(with-base-layout)/approval-line-management/page.tsx` |
|
||||
| 페이지 | `apps/as/src/pages/approval-line-management/` (Public API: page만 export) |
|
||||
| UI | `.../ui/elapr-tmplt-list.tsx`, `elapr-tmplt-form-dialog.tsx`, `elapr-apln-grid.tsx` 등 |
|
||||
| model | `.../model/columns.tsx`, `apln-row-types.ts`, `mock-reviewers.ts` |
|
||||
| lib | `.../lib/use-apln-rows.ts`, `validators.ts`, `use-dnd-sensors.ts` |
|
||||
| 엔티티 | `apps/as/src/entities/elapr/` |
|
||||
|
||||
### 7-3. 영역별 UI 매핑
|
||||
|
||||
| 영역 | 주요 UI |
|
||||
| ------ | -------------------------------------------------------------------------------------------- |
|
||||
| A | DataTable + 행 선택 + 서식 등록/수정 다이얼로그 |
|
||||
| B 헤더 | 상태 라벨 + `행 추가`, `결재선 저장` |
|
||||
| B-2 | 인라인 편집 DataTable + `editingRowId` 단일 행 잠금 + 드래그 핸들 + 행별 수정/저장/삭제/취소 |
|
||||
| 공통 | 서식 Dialog, 검토인력 선택 Dialog만 |
|
||||
|
||||
### 7-4. 데이터 흐름(요약)
|
||||
|
||||
- **진입**: `useElaprTmpltPage` → 영역 A. B 비활성.
|
||||
- **서식 선택**: `useElaprAplnPage({ elaprTmpltId })` → B-2.
|
||||
- **행 추가**: `editingRowId` 없을 때만 가능 → 신규 행 1건 편집 잠금 → 저장/취소 후 해제.
|
||||
- **인라인 수정**: [수정]으로 `editingRowId` 설정 → 해당 행만 편집 → `useUpdateElaprApln` 후 해제.
|
||||
- **드래그**: `editingRowId == null` 일 때만 가능 → 로컬 `atrzSeqNo` 재부여 → 일괄 PUT.
|
||||
- **삭제**: `useDeleteElaprApln` 또는 신규 행 로컬 제거.
|
||||
|
||||
### 7-5. 미확정 / 검증 필요
|
||||
|
||||
- 검토인력 API 경로·DTO.
|
||||
- `@dnd-kit` + react-table 행 인라인 Input 동시 적용 POC.
|
||||
- 결재자 성명 표시: `aprvrUuid` → 사용자명 조회 API.
|
||||
- 영역 A·B 페이지네이션·검색 UI 노출 여부.
|
||||
- 권한(역할별 CRUD).
|
||||
@@ -0,0 +1,487 @@
|
||||
# 화면설계서: 체크리스트관리
|
||||
|
||||
## 1. 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ---------- | -------------------------------------------- |
|
||||
| 버전 | 0.1 |
|
||||
| 프로세스명 | UI설계 |
|
||||
| 화면명 | 체크리스트관리 |
|
||||
| 화면 유형 | [v] 조회 [v] 입력 [v] 수정 [v] 삭제 [ ] 출력 |
|
||||
| 메뉴 경로 | 디지털행정지원 > 시스템관리 > 체크리스트관리 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 화면 목적
|
||||
|
||||
업무별·문서별 **점검표**(체크리스트)를 등록·관리하고, **점검항목** 마스터를 유지한다. 상단 좌측에서 점검표를 선택하면 하단 **체크리스트 항목** 영역에 해당 점검표의 검토 구성(계층형 검토내용)을 편집한다. 상단 우측 **점검항목 관리**는 점검표에 연결할 항목 풀을 등록·수정·삭제한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 화면 구성
|
||||
|
||||
### 3-1. 전체 레이아웃
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 페이지 타이틀: 체크리스트관리 브레드크럼: 디지털행정지원 > … > │
|
||||
├──────────────────────────────────────────────┬────────────────────────────────────────────────┤
|
||||
│ [영역 A] 점검표 관리 │ [영역 B] 점검항목 관리 │
|
||||
│ [점검표 추가] │ [점검항목 추가] │
|
||||
│ ┌────────────────────────────────────────┐ │ ┌──────────────────────────────────────────┐ │
|
||||
│ │ 점검표일련번호 | 점검표명 | 문서구분 │ │ │ 점검항목코드 | 점검항목명 | 사용여부 │ │
|
||||
│ │ 업무구분 | 사용여부 | 등록일시 | 관리 │ │ │ 등록일시 | 관리 │ │
|
||||
│ ├────────────────────────────────────────┤ │ ├──────────────────────────────────────────┤ │
|
||||
│ │ 1 (선택 행 강조) [관리] [삭제] │ │ │ ITEM_001 요건검토 … [관리][삭제] │ │
|
||||
│ │ │ │ │ ITEM_002 법령검토 … [관리][삭제] │ │
|
||||
│ └────────────────────────────────────────┘ │ └──────────────────────────────────────────┘ │
|
||||
├──────────────────────────────────────────────┴────────────────────────────────────────────────┤
|
||||
│ [영역 C] 체크리스트 항목 [미리보기] [추가] [저장] │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 점검항목 ▼ | 주요 검토내용 | 사용 여부 | 구분 | 관리 │ │
|
||||
│ │ L1 [요건검토 ▼] | (입력) | ○예 ○아니오 | 1단계 | [하위추가]│ │
|
||||
│ │ L2 [요건검토 ▼] | (입력) | ○예 ○아니오 | 2단계 | [삭제] │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────────────────────────┘ │
|
||||
└───────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3-2. 화면 데이터 흐름
|
||||
|
||||
```
|
||||
BEGIN
|
||||
|
||||
// 최초 진입
|
||||
CALL GET /as-checklist-service/api/v1/cklst
|
||||
DISPLAY 점검표 그리드 (영역 A)
|
||||
CALL GET /as-checklist-service/api/v1/cklst-artcl
|
||||
DISPLAY 점검항목 그리드 (영역 B)
|
||||
DISPLAY 빈 상태 안내 (영역 C) // 점검표 미선택 시 C 비활성
|
||||
|
||||
// 영역 A: 점검표 행 선택
|
||||
ON 영역 A 행 CLICK DO
|
||||
SET 선택된 점검표 = 행.cklstSn
|
||||
CALL GET /as-checklist-service/api/v1/cklst-rvw?cklstSn={cklstSn}
|
||||
// TBD: 점검표 단위 전체 검토 트리 조회 API — 현재는 항목별 by-artcl 조합 또는 클라이언트 병합
|
||||
DISPLAY 체크리스트 항목 그리드 (영역 C)
|
||||
END ON
|
||||
|
||||
// 영역 A: 점검표 추가
|
||||
ON [점검표 추가] 버튼 CLICK DO
|
||||
OPEN 점검표 추가 다이얼로그
|
||||
ON [저장] DO
|
||||
VALIDATE cklstNm, taskSeComCd
|
||||
CALL POST /as-checklist-service/api/v1/cklst
|
||||
CALL GET /as-checklist-service/api/v1/cklst
|
||||
CLOSE 다이얼로그
|
||||
END ON
|
||||
ON [취소] / [닫기] DO
|
||||
CLOSE 다이얼로그
|
||||
END ON
|
||||
END ON
|
||||
|
||||
// 영역 A: 점검표 수정·삭제
|
||||
ON 영역 A 행 [관리] CLICK DO
|
||||
OPEN 점검표 수정 다이얼로그 (기존 값 초기화)
|
||||
ON [저장] DO
|
||||
CALL PUT /as-checklist-service/api/v1/cklst/{cklstSn}
|
||||
CALL GET /as-checklist-service/api/v1/cklst
|
||||
END ON
|
||||
END ON
|
||||
|
||||
ON 영역 A 행 [삭제] CLICK DO
|
||||
CONFIRM "선택한 점검표를 삭제하시겠습니까?"
|
||||
CALL DELETE /as-checklist-service/api/v1/cklst/{cklstSn}
|
||||
IF 삭제 대상 == 선택된 점검표 THEN RESET 영역 C
|
||||
CALL GET /as-checklist-service/api/v1/cklst
|
||||
END ON
|
||||
|
||||
// 영역 B: 점검항목 추가
|
||||
ON [점검항목 추가] 버튼 CLICK DO
|
||||
OPEN 점검항목 추가 다이얼로그
|
||||
ON [저장] DO
|
||||
VALIDATE cklstArtclNm
|
||||
// TBD: cklstSn 필수 — UI에 소속 점검표 선택 없음. 백엔드 정책 확인
|
||||
CALL POST /as-checklist-service/api/v1/cklst-artcl
|
||||
CALL GET /as-checklist-service/api/v1/cklst-artcl
|
||||
CLOSE 다이얼로그
|
||||
END ON
|
||||
END ON
|
||||
|
||||
// 영역 B: 점검항목 수정·삭제
|
||||
ON 영역 B 행 [관리] CLICK DO
|
||||
OPEN 점검항목 수정 다이얼로그
|
||||
ON [저장] DO
|
||||
CALL PUT /as-checklist-service/api/v1/cklst-artcl/{cklstArtclSn}/{cklstSn}
|
||||
END ON
|
||||
END ON
|
||||
|
||||
ON 영역 B 행 [삭제] CLICK DO
|
||||
CONFIRM "선택한 점검항목을 삭제하시겠습니까?"
|
||||
CALL DELETE /as-checklist-service/api/v1/cklst-artcl/{cklstArtclSn}/{cklstSn}
|
||||
CALL GET /as-checklist-service/api/v1/cklst-artcl
|
||||
END ON
|
||||
|
||||
// 영역 C: 검토 행 추가
|
||||
ON [추가] 버튼 CLICK DO
|
||||
VALIDATE 선택된 점검표 존재
|
||||
ADD 영역 C 그리드 맨 아래 편집 가능 행 (1단계, rvwCnLvNo=1)
|
||||
END ON
|
||||
|
||||
// 영역 C: 하위 검토 추가
|
||||
ON 행 [하위추가] CLICK DO
|
||||
ADD 부모행 하위 편집 행 (2단계, upCklstRvwSn=부모.cklstRvwSn, rvwCnLvNo=2)
|
||||
END ON
|
||||
|
||||
// 영역 C: 일괄 저장
|
||||
ON [저장] 버튼 CLICK DO
|
||||
VALIDATE 선택된 점검표 존재
|
||||
FOR EACH 신규·변경된 검토 행 DO
|
||||
IF 신규 THEN
|
||||
CALL POST /as-checklist-service/api/v1/cklst-rvw
|
||||
ELSE
|
||||
CALL PUT /as-checklist-service/api/v1/cklst-rvw/{cklstRvwSn}/{cklstArtclSn}/{cklstSn}
|
||||
END IF
|
||||
END FOR
|
||||
CALL GET /as-checklist-service/api/v1/cklst-rvw?cklstSn={cklstSn}
|
||||
TOAST "체크리스트 항목이 저장되었습니다."
|
||||
END ON
|
||||
|
||||
// 영역 C: 행 삭제
|
||||
ON 행 [삭제] CLICK DO
|
||||
CONFIRM "선택한 항목을 삭제하시겠습니까?"
|
||||
IF 서버 저장된 행 THEN
|
||||
CALL DELETE /as-checklist-service/api/v1/cklst-rvw/{cklstRvwSn}/{cklstArtclSn}/{cklstSn}
|
||||
END IF
|
||||
REMOVE 그리드 행 (미저장 행은 API 호출 없이 제거)
|
||||
END ON
|
||||
|
||||
// 영역 C: 미리보기
|
||||
ON [미리보기] 버튼 CLICK DO
|
||||
OPEN 체크리스트 미리보기 다이얼로그 (읽기 전용 트리)
|
||||
// TBD: 미리보기 UI·API
|
||||
END ON
|
||||
|
||||
END
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 영역 A: 점검표 관리
|
||||
|
||||
선택된 점검표가 영역 C의 편집 대상이 된다. 행 클릭으로 선택 상태를 표시한다.
|
||||
|
||||
### 4-1. 헤더 / 툴바
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ----------- | ------------------------------------------ |
|
||||
| 섹션 타이틀 | 점검표 관리 |
|
||||
| 우측 버튼 | **[점검표 추가]** — 점검표 등록 다이얼로그 |
|
||||
|
||||
### 4-2. 그리드 컬럼
|
||||
|
||||
| 컬럼명 | Field | 타입 | 비고 |
|
||||
| ---------------- | ------------- | ------ | ---------------------------------------------------- |
|
||||
| 점검표일련번호 | `cklstSn` | number | PK |
|
||||
| 점검표명 | `cklstNm` | string | |
|
||||
| 문서구분공통코드 | `docSeComCd` | string | 코드명 표시 — `ASDOCSECD` 등 공통코드 (TBD 상위코드) |
|
||||
| 업무구분공통코드 | `taskSeComCd` | string | RE/SF/SP 등 — 공통코드 (TBD 상위코드) |
|
||||
| 사용여부 | `useYn` | string | `Y`→예, `N`→아니오 |
|
||||
| 등록일시 | `regDt` | string | `yyyy-MM-dd HH:mm:ss` |
|
||||
| 관리 | — | — | 행별 **[관리] · [삭제]** |
|
||||
|
||||
- 행 클릭 시: `cklstSn` 선택 → 영역 C 헤더·그리드 갱신.
|
||||
- 페이지네이션: Spring `Pageable` (`page`, `size`, `sort`).
|
||||
|
||||
### 4-3. 버튼
|
||||
|
||||
| 버튼명 | 위치 | 동작 |
|
||||
| ----------- | -------------- | ------------------------------------------------- |
|
||||
| 점검표 추가 | 섹션 우측 상단 | 점검표 추가 다이얼로그 오픈 |
|
||||
| 관리 | 행별 | 점검표 수정 다이얼로그 (기존 값 초기화) |
|
||||
| 삭제 | 행별 | 확인 후 삭제. 선택 중인 점검표이면 영역 C 초기화. |
|
||||
|
||||
### 4-4. 다이얼로그: 점검표 추가 / 수정
|
||||
|
||||
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
|
||||
| -------- | ---------------- | ---- | ------ | ----------------- | ------------------------------------------------ |
|
||||
| 점검표명 | Text | Y | — | `cklstNm` | placeholder: "예: 설치인가 체크리스트" |
|
||||
| 문서구분 | Select(공통코드) | N | — | `docSeComCd` | 예: FNRS, INSTL, PWRK, DESIGN |
|
||||
| 업무구분 | Select(공통코드) | Y | — | `taskSeComCd` | 예: RE(재이용수), SF(하수처리시설), SP(하수관로) |
|
||||
| 사용여부 | Select | N | Y | `useYn` | Y/N, UI 표시: 예/아니오 |
|
||||
|
||||
| 버튼명 | 동작 |
|
||||
| ------ | -------------------------------------- |
|
||||
| 저장 | POST(등록) 또는 PUT(수정) 후 목록 갱신 |
|
||||
| 취소 | 다이얼로그 닫기, 입력값 폐기 |
|
||||
| 닫기 | 다이얼로그 닫기 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 영역 B: 점검항목 관리
|
||||
|
||||
점검표 하단(영역 C)에서 선택할 **점검항목** 마스터를 관리한다.
|
||||
|
||||
### 5-1. 헤더 / 툴바
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ----------- | ---------------------------------------------- |
|
||||
| 섹션 타이틀 | 점검항목 관리 |
|
||||
| 우측 버튼 | **[점검항목 추가]** — 점검항목 등록 다이얼로그 |
|
||||
|
||||
### 5-2. 그리드 컬럼
|
||||
|
||||
| 컬럼명 | Field | 타입 | 비고 |
|
||||
| ------------ | -------------- | ------ | ------------------------------------- |
|
||||
| 점검항목코드 | `cklstArtclSn` | number | UI mock `ITEM_001` 형식 표시 여부 TBD |
|
||||
| 점검항목명 | `cklstArtclNm` | string | |
|
||||
| 사용여부 | `useYn` | string | `Y`→예, `N`→아니오 |
|
||||
| 등록일시 | `regDt` | string | `yyyy-MM-dd HH:mm:ss` |
|
||||
| 관리 | — | — | 행별 **[관리] · [삭제]** |
|
||||
|
||||
> **TBD**: 백엔드 `CklstArtcl`은 복합 PK(`cklstArtclSn`, `cklstSn`)이며 등록 시 `cklstSn` 필수. UI 다이얼로그에는 `cklstSn` 입력이 없음 — 소속 점검표 지정 방식·전역 마스터 여부 백엔드 정책 확인.
|
||||
|
||||
### 5-3. 다이얼로그: 점검항목 추가 / 수정
|
||||
|
||||
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
|
||||
| ---------- | --------- | ---- | ------ | ----------------- | --------------------------- |
|
||||
| 점검항목명 | Text | Y | — | `cklstArtclNm` | placeholder: "예: 법령검토" |
|
||||
| 사용여부 | Select | N | Y | `useYn` | Y/N |
|
||||
|
||||
| 버튼명 | 동작 |
|
||||
| ------ | -------------------------------------- |
|
||||
| 저장 | POST(등록) 또는 PUT(수정) 후 목록 갱신 |
|
||||
| 취소 | 다이얼로그 닫기 |
|
||||
| 닫기 | 다이얼로그 닫기 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 영역 C: 체크리스트 항목 (선택된 점검표)
|
||||
|
||||
영역 A에서 선택된 `cklstSn` 기준으로 **점검표검토**(계층형 검토내용)를 구성한다.
|
||||
|
||||
### 6-1. 헤더 / 툴바
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ----------- | -------------------------------------------------------------------- |
|
||||
| 섹션 타이틀 | 체크리스트 항목 (선택된 점검표: {cklstNm}) |
|
||||
| 우측 버튼 | **[미리보기]** · **[추가]** · **[저장]** — 점검표 미선택 시 C 비활성 |
|
||||
|
||||
### 6-2. 그리드 컬럼
|
||||
|
||||
| 컬럼명 | Field | 입력 UI | 비고 |
|
||||
| ------------- | -------------- | ------------------------------------- | ------------------------------------ |
|
||||
| 점검항목 | `cklstArtclSn` | Select (영역 B 항목 목록) | L1/L2 접두 표시, `cklstArtclNm` 라벨 |
|
||||
| 주요 검토내용 | `rvwCn` | Text / Textarea | 최대 4000자 |
|
||||
| 사용 여부 | `useYn` | Radio (예/아니오) | `Y` / `N` |
|
||||
| 구분 | `rvwCnLvNo` | 읽기 전용 | 1→1단계, 2→2단계, 3→3단계 |
|
||||
| 관리 | — | **[하위추가]** (1·2단계, 저장 후) · **[삭제]** | 3단계 행은 [하위추가] 미표시 |
|
||||
|
||||
#### 계층 정책
|
||||
|
||||
| 항목 | 내용 |
|
||||
| -------------- | -------------------------------------------------------------------- |
|
||||
| 최대 깊이 | 3단계 (`rvwCnLvNo`: 1, 2, 3) |
|
||||
| 상위-하위 연결 | `upCklstRvwSn` — 2단계 행은 부모 1단계 `cklstRvwSn` 참조 |
|
||||
| 정렬 | `sortSeq` — 동일 레벨 내 순서 (TBD: UI 드래그 정렬 여부) |
|
||||
| 체크 사용 | `bchckUseYn` — 미리보기·실사용 화면 체크박스 노출 여부 (TBD UI 반영) |
|
||||
|
||||
### 6-3. 버튼
|
||||
|
||||
| 버튼명 | 동작 |
|
||||
| -------- | ---------------------------------------------------- |
|
||||
| 추가 | 1단계 검토 행 1건 추가 (편집 가능) |
|
||||
| 저장 | 변경·신규 검토 행 일괄 POST/PUT (일괄 저장 API 없음) |
|
||||
| 미리보기 | 선택 점검표 체크리스트 읽기 전용 미리보기 (TBD) |
|
||||
| 하위추가 | 선택 1·2단계 행 아래 하위 단계(2·3단계) 자식 행 추가 |
|
||||
| 삭제 | 확인 후 DELETE 또는 미저장 행 로컬 제거 |
|
||||
|
||||
---
|
||||
|
||||
## 7. API/DTO 명세
|
||||
|
||||
### 7-1. 공통
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ------------- | ------------------------------------------------------------- |
|
||||
| 서비스 | `as-checklist-service` |
|
||||
| Base prefix | `/as-checklist-service/api/v1` (게이트웨이 프록시 경유) |
|
||||
| 인증 | JWT (`Authorization` 헤더), `X-Site-Id`, `token-id` |
|
||||
| 페이징 | Spring `Pageable`: `page`, `size`, `sort` → `PageResponse<T>` |
|
||||
| 엔티티 | `@/entities/cklst` (`cklstMngService`, `cklstArtclService`, `cklstRvwService`) |
|
||||
|
||||
### 7-2. 점검표 (영역 A) — `cklst`
|
||||
|
||||
| 기능 | Method | URL | Request / Query | Response | 클라이언트 |
|
||||
| --------- | ------ | ------------------ | --------------------------------- | ------------------ | -------------------------- |
|
||||
| 목록 조회 | GET | `/cklst` | `CklstMngSearchParams` + Pageable | `Page<CklstMng>` | `cklstMngService.findPage` |
|
||||
| 단건 조회 | GET | `/cklst/{cklstSn}` | — | `CklstMng` | `cklstMngService.findById` |
|
||||
| 등록 | POST | `/cklst` | `CklstMngSaveRequest` | `number` (cklstSn) | `cklstMngService.create` |
|
||||
| 수정 | PUT | `/cklst/{cklstSn}` | `CklstMngSaveRequest` | `number` (cklstSn) | `cklstMngService.update` |
|
||||
| 삭제 | DELETE | `/cklst/{cklstSn}` | — | `number` (cklstSn) | `cklstMngService.remove` |
|
||||
|
||||
**CklstMng (응답)**
|
||||
|
||||
| Field | 타입 | 설명 |
|
||||
| ------------- | ------- | ---------------- |
|
||||
| `cklstSn` | number | 점검표일련번호 |
|
||||
| `cklstNm` | string | 점검표명 |
|
||||
| `taskSeComCd` | string | 업무구분공통코드 |
|
||||
| `useYn` | string? | 사용여부 |
|
||||
| `docSeComCd` | string? | 문서구분공통코드 |
|
||||
| `rgtr` | string? | 등록자 UUID |
|
||||
| `rgtrNm` | string? | 등록자명 |
|
||||
| `mdfr` | string? | 수정자 UUID |
|
||||
| `regDt` | string? | 등록일시 |
|
||||
| `mdfcnDt` | string? | 수정일시 |
|
||||
|
||||
**CklstMngSaveRequest**: `cklstNm`\*, `taskSeComCd`\*, `useYn?`, `docSeComCd?`
|
||||
|
||||
**CklstMngSearchParams**: `cklstNm?`, `taskSeComCd?`, `useYn?`, `docSeComCd?` + `page`, `size`, `sort`
|
||||
|
||||
**React Query hooks**: `useCklstMngPage`, `useCklstMng`, `useCreateCklstMng`, `useUpdateCklstMng`, `useDeleteCklstMng`
|
||||
|
||||
### 7-3. 점검표항목 (영역 B) — `cklst-artcl`
|
||||
|
||||
| 기능 | Method | URL | Request / Query | Response | 클라이언트 |
|
||||
| ------------------ | ------ | --------------------------------------- | ----------------------------------- | ------------------ | --------------------------------- |
|
||||
| 목록 조회 (페이징) | GET | `/cklst-artcl` | `CklstArtclSearchParams` + Pageable | `Page<CklstArtcl>` | `cklstArtclService.findPage` |
|
||||
| 점검표별 전체 목록 | GET | `/cklst-artcl/by-cklst/{cklstSn}` | — | `CklstArtcl[]` | `cklstArtclService.findByCklstSn` |
|
||||
| 단건 조회 | GET | `/cklst-artcl/{cklstArtclSn}/{cklstSn}` | — | `CklstArtcl` | `cklstArtclService.findById` |
|
||||
| 등록 | POST | `/cklst-artcl` | `CklstArtclSaveRequest` | `CklstArtclId` | `cklstArtclService.create` |
|
||||
| 수정 | PUT | `/cklst-artcl/{cklstArtclSn}/{cklstSn}` | `CklstArtclUpdateRequest` | `CklstArtclId` | `cklstArtclService.update` |
|
||||
| 삭제 | DELETE | `/cklst-artcl/{cklstArtclSn}/{cklstSn}` | — | `CklstArtclId` | `cklstArtclService.remove` |
|
||||
|
||||
**CklstArtcl (응답)**
|
||||
|
||||
| Field | 타입 | 설명 |
|
||||
| ----------------- | ------- | ------------------ |
|
||||
| `cklstArtclSn` | number | 점검표항목일련번호 |
|
||||
| `cklstSn` | number | 점검표일련번호 |
|
||||
| `cklstArtclNm` | string? | 점검표항목명 |
|
||||
| `useYn` | string? | 사용여부 |
|
||||
| `rgtr` / `rgtrNm` | string? | 등록자 |
|
||||
| `regDt` | string? | 등록일시 |
|
||||
|
||||
**CklstArtclSaveRequest**: `cklstSn`\*, `cklstArtclNm?`, `useYn?`
|
||||
|
||||
**CklstArtclUpdateRequest**: `cklstArtclNm?`, `useYn?`
|
||||
|
||||
**CklstArtclId**: `{ cklstArtclSn: number; cklstSn: number }`
|
||||
|
||||
**React Query hooks**: `useCklstArtclPage`, `useCklstArtclList`, `useCklstArtcl`, `useCreateCklstArtcl`, `useUpdateCklstArtcl`, `useDeleteCklstArtcl`
|
||||
|
||||
### 7-4. 점검표검토 (영역 C) — `cklst-rvw`
|
||||
|
||||
| 기능 | Method | URL | Request / Query | Response | 클라이언트 |
|
||||
| --------------------- | ------ | -------------------------------------------------- | ------------------------------ | ---------------- | ------------------------------- |
|
||||
| 목록 조회 (페이징) | GET | `/cklst-rvw` | `CklstRvwSearchParams` + Pageable | `Page<CklstRvw>` | `cklstRvwService.findPage` |
|
||||
| 항목별 검토 전체 목록 | GET | `/cklst-rvw/by-artcl/{cklstSn}/{cklstArtclSn}` | — | `CklstRvw[]` | `cklstRvwService.findByArtcl` |
|
||||
| 단건 조회 | GET | `/cklst-rvw/{cklstRvwSn}/{cklstArtclSn}/{cklstSn}` | — | `CklstRvw` | `cklstRvwService.findById` |
|
||||
| 등록 | POST | `/cklst-rvw` | `CklstRvwSaveRequest` | `CklstRvwId` | `cklstRvwService.create` |
|
||||
| 수정 | PUT | `/cklst-rvw/{cklstRvwSn}/{cklstArtclSn}/{cklstSn}` | `CklstRvwUpdateRequest` | `CklstRvwId` | `cklstRvwService.update` |
|
||||
| 삭제 | DELETE | `/cklst-rvw/{cklstRvwSn}/{cklstArtclSn}/{cklstSn}` | — | `CklstRvwId` | `cklstRvwService.remove` |
|
||||
|
||||
**CklstRvw (응답)**
|
||||
|
||||
| Field | 타입 | 설명 |
|
||||
| -------------- | ------- | ---------------------- |
|
||||
| `cklstRvwSn` | number | 점검표검토일련번호 |
|
||||
| `cklstArtclSn` | number | 점검표항목일련번호 |
|
||||
| `cklstSn` | number | 점검표일련번호 |
|
||||
| `upCklstRvwSn` | number? | 상위점검표검토일련번호 |
|
||||
| `rvwCnLvNo` | number? | 검토내용레벨번호 |
|
||||
| `rvwCn` | string? | 검토내용 |
|
||||
| `bchckUseYn` | string? | 체크사용여부 |
|
||||
| `sortSeq` | number? | 정렬순서 |
|
||||
| `useYn` | string? | 사용여부 |
|
||||
| `regDt` | string? | 등록일시 |
|
||||
|
||||
**CklstRvwSaveRequest**: `cklstArtclSn`\*, `cklstSn`\*, `upCklstRvwSn?`, `rvwCnLvNo?`, `rvwCn?`, `bchckUseYn?`, `sortSeq?`, `useYn?`
|
||||
|
||||
**CklstRvwUpdateRequest**: `upCklstRvwSn?`, `rvwCnLvNo?`, `rvwCn?`, `bchckUseYn?`, `sortSeq?`, `useYn?`
|
||||
|
||||
**CklstRvwSearchParams**: `cklstSn?`, `cklstArtclSn?`, `useYn?` + `page`, `size`, `sort`
|
||||
|
||||
**CklstRvwId**: `{ cklstRvwSn: number; cklstArtclSn: number; cklstSn: number }`
|
||||
|
||||
**React Query hooks**: `useCklstRvwPage`, `useCklstRvwByArtcl`, `useCklstRvw`, `useCreateCklstRvw`, `useUpdateCklstRvw`, `useDeleteCklstRvw`
|
||||
|
||||
### 7-5. 공통코드 (보조)
|
||||
|
||||
| UI 필드 | 코드 그룹 (TBD) | 예시 값 |
|
||||
| -------- | --------------- | -------------------------------------------- |
|
||||
| 문서구분 | `ASDOCSECD` 등 | FNRS, INSTL, PWRK, DESIGN |
|
||||
| 업무구분 | TBD | RE(재이용수), SF(하수처리시설), SP(하수관로) |
|
||||
| 사용여부 | 고정 | Y, N |
|
||||
|
||||
공통코드 조회: `@/entities/code` — `codeService`, `CodeSelect`
|
||||
|
||||
---
|
||||
|
||||
## 8. UI 구현 가이드
|
||||
|
||||
### 8-1. 기술 스택·원칙
|
||||
|
||||
- **공통 컴포넌트**: `@common/components` (`Dialog`, `Button`, `Input`, `Select`, `RadioGroup` 등)
|
||||
- **테이블**: `@tanstack/react-table` + `useDataTable`
|
||||
- **데이터**: React Query — `@/entities/cklst` hooks (`useCklstRvwPage` 등 영역 C 포함)
|
||||
- **공통코드**: `@/entities/code` — `CodeSelect`, `findCodeLabel`
|
||||
- **상태**: 선택된 `cklstSn`, 영역 C 검토 행 배열(신규·dirty·계층 트리)
|
||||
- **모의 데이터**: API 연동 전 `@faker-js/faker`
|
||||
|
||||
### 8-2. 모듈·파일 배치(제안)
|
||||
|
||||
| 구분 | 경로 |
|
||||
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 라우트 | `apps/as/app/(with-base-layout)/checklist-management/page.tsx` |
|
||||
| 페이지 | `apps/as/src/pages/checklist-management/` (Public API: page만 export) |
|
||||
| UI | `.../ui/cklst-mng-list.tsx`, `cklst-mng-form-dialog.tsx`, `cklst-artcl-list.tsx`, `cklst-artcl-form-dialog.tsx`, `cklst-rvw-grid.tsx`, `cklst-preview-dialog.tsx` |
|
||||
| model | `.../model/cklst-mng-columns.tsx`, `cklst-artcl-columns.tsx`, `cklst-rvw-row-types.ts` |
|
||||
| lib | `.../lib/use-cklst-rvw-rows.ts`, `validators.ts`, `build-rvw-tree.ts` |
|
||||
| 엔티티 | `apps/as/src/entities/cklst/` (`cklstMng`, `cklstArtcl`, `cklstRvw` 서비스·hooks) |
|
||||
| 페이지 | `apps/as/src/pages/checklist-management/` — `/checklist-management` |
|
||||
| 문서 | `apps/as/docs/checklist-management/SPEC.md` |
|
||||
|
||||
### 8-3. 영역별 UI 매핑
|
||||
|
||||
| 영역 | 주요 UI |
|
||||
| ---- | ---------------------------------------------------------------------------- |
|
||||
| A | DataTable + 행 선택 + 점검표 추가/수정 Dialog |
|
||||
| B | DataTable + 점검항목 추가/수정 Dialog |
|
||||
| C | 계층형 편집 그리드(1·2단계) + [추가][저장][미리보기] + 행별 [하위추가][삭제] |
|
||||
| 공통 | `CodeSelect` (문서구분·업무구분), 사용여부 Y/N Select/Radio |
|
||||
|
||||
### 8-4. 다이얼로그·라우팅
|
||||
|
||||
| 다이얼로그명 | 트리거 | API 연동 |
|
||||
| ------------------- | --------------- | ------------------------- |
|
||||
| 점검표 추가 | [점검표 추가] | `useCreateCklstMng` |
|
||||
| 점검표 수정 | 행 [관리] | `useUpdateCklstMng` |
|
||||
| 점검표 삭제 확인 | 행 [삭제] | `useDeleteCklstMng` |
|
||||
| 점검항목 추가 | [점검항목 추가] | `useCreateCklstArtcl` |
|
||||
| 점검항목 수정 | 행 [관리] | `useUpdateCklstArtcl` |
|
||||
| 체크리스트 미리보기 | [미리보기] | TBD — 읽기 전용 트리 렌더 |
|
||||
|
||||
라우트: `/checklist-management` (메뉴 URL은 CMS 등록값과 일치시킴)
|
||||
|
||||
### 8-5. 데이터 흐름(요약)
|
||||
|
||||
- **진입**: `useCklstMngPage` → 영역 A, `useCklstArtclPage` → 영역 B. C 비활성.
|
||||
- **점검표 선택**: `cklstSn` 설정 → 영역 C 활성 → `useCklstRvwPage({ cklstSn })` → 트리 그리드 표시.
|
||||
- **점검표 CRUD**: Dialog → `useCreateCklstMng` / `useUpdateCklstMng` / `useDeleteCklstMng`.
|
||||
- **점검항목 CRUD**: Dialog → `useCreateCklstArtcl` / `useUpdateCklstArtcl` / `useDeleteCklstArtcl`.
|
||||
- **검토 저장**: [저장] → 신규 POST / 기존 PUT 순차 호출 (일괄 API 없음).
|
||||
- **영역 C 점검항목 Select**: `useCklstArtclList(cklstSn)` 또는 영역 B 전체 목록과 교차 — **TBD**.
|
||||
|
||||
### 8-6. 미확정 / 검증 필요
|
||||
|
||||
- [ ] 영역 B 점검항목 등록 시 `cklstSn` 필수 vs UI 무소속 마스터 — 백엔드·기획 확인
|
||||
- [ ] `점검항목코드`(ITEM_001) 표시 형식 vs `cklstArtclSn` 숫자 PK
|
||||
- [ ] 영역 C 전체 검토 트리 조회: `cklstSn` 단위 일괄 API 부재 — `by-artcl` 다건 호출 또는 백엔드 확장
|
||||
- [x] `@/entities/cklst` — `cklstRvwService`·hooks 구현
|
||||
- [x] `pages/checklist-management` — 영역 A·B·C UI 구현 (`/checklist-management`)
|
||||
- [ ] 문서구분·업무구분 공통코드 상위코드 ID (`ASDOCSECD` 등)
|
||||
- [ ] [미리보기] 다이얼로그 UI·데이터 소스
|
||||
- [ ] `bchckUseYn` UI 반영 여부
|
||||
- [ ] 영역 A·B 페이지네이션·검색 UI 노출 여부
|
||||
- [ ] 권한(역할별 CRUD)
|
||||
@@ -0,0 +1,385 @@
|
||||
# 화면설계서: 코드관리
|
||||
|
||||
## 1. 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ---------- | -------------------------------------------- |
|
||||
| 버전 | 0.1 |
|
||||
| 프로세스명 | UI설계 |
|
||||
| 화면명 | 코드관리 |
|
||||
| 화면 유형 | [v] 조회 [v] 입력 [v] 수정 [v] 삭제 [ ] 출력 |
|
||||
| 메뉴 경로 | 디지털행정지원 > 사이트관리 > 코드관리 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 화면 목적
|
||||
|
||||
설치인가 등 AS 업무에서 참조하는 **공종구분코드**(공종코드)와 **회계구분코드**(회계코드)를 한 화면에서 조회·등록·수정·삭제한다. 좌측 **공종코드 관리**와 우측 **회계코드 관리**는 독립된 목록·CRUD 영역이며, 각 섹션은 자체 API(`cnstp-se`, `acntg-se`)와 연동한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 화면 구성
|
||||
|
||||
### 3-1. 전체 레이아웃
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 페이지 타이틀: 코드관리 브레드크럼: 디지털행정지원 > 사이트관리 > 코드관리 │
|
||||
├──────────────────────────────────────────────────┬────────────────────────────────────────────────┤
|
||||
│ [영역 A] 공종코드 관리 │ [영역 B] 회계코드 관리 │
|
||||
│ [등록] │ [등록] │
|
||||
│ ┌────────────────────────────────────────────┐ │ ┌──────────────────────────────────────────┐ │
|
||||
│ │ 코드 | 코드명 | 설명 | 사용여부 | 관리 │ │ │ 코드 | 코드명 | 설명 | 사용여부 | 관리 │ │
|
||||
│ ├────────────────────────────────────────────┤ │ ├──────────────────────────────────────────┤ │
|
||||
│ │ WC001 공공하수처리시설 … 사용 [수정][삭제]│ │ │ AC101 시설비 … 사용 [수정][삭제] │ │
|
||||
│ │ WC002 하수관로정비 … 사용 [수정][삭제]│ │ │ AC102 용역비 … 사용 [수정][삭제] │ │
|
||||
│ │ WC003 재이용시설 … 사용 [수정][삭제]│ │ │ AC103 운영비 … 미사용 [수정][삭제] │ │
|
||||
│ └────────────────────────────────────────────┘ │ └──────────────────────────────────────────┘ │
|
||||
│ « 1 2 3 … » │ « 1 2 3 … » │
|
||||
└──────────────────────────────────────────────────┴────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3-2. 화면 데이터 흐름
|
||||
|
||||
```
|
||||
BEGIN
|
||||
|
||||
// 최초 진입 — 좌·우 독립 조회
|
||||
CALL cnstpSeMngService.findAll({ page, size })
|
||||
DISPLAY 공종코드 그리드 (영역 A)
|
||||
|
||||
CALL acntgSeMngService.findAll({ page, size })
|
||||
DISPLAY 회계코드 그리드 (영역 B)
|
||||
|
||||
// 영역 A: 공종코드 등록
|
||||
ON 영역 A [등록] 버튼 CLICK DO
|
||||
OPEN 공종코드 등록 다이얼로그
|
||||
ON [저장] DO
|
||||
VALIDATE cnstpSeCd
|
||||
CALL cnstpSeMngService.save(CnstpSeMngSaveRequestDto)
|
||||
CALL cnstpSeMngService.findAll
|
||||
CLOSE 다이얼로그
|
||||
END ON
|
||||
ON [취소] / [닫기] DO
|
||||
CLOSE 다이얼로그
|
||||
END ON
|
||||
END ON
|
||||
|
||||
// 영역 A: 공종코드 수정
|
||||
ON 영역 A 행 [수정] CLICK DO
|
||||
CALL cnstpSeMngService.findById(cnstpSeCd)
|
||||
OPEN 공종코드 수정 다이얼로그 (기존 값 초기화)
|
||||
ON [저장] DO
|
||||
CALL cnstpSeMngService.update(cnstpSeCd, CnstpSeMngSaveRequestDto)
|
||||
CALL cnstpSeMngService.findAll
|
||||
CLOSE 다이얼로그
|
||||
END ON
|
||||
END ON
|
||||
|
||||
// 영역 A: 공종코드 삭제
|
||||
ON 영역 A 행 [삭제] CLICK DO
|
||||
CONFIRM "선택한 공종코드를 삭제하시겠습니까?"
|
||||
CALL cnstpSeMngService.delete(cnstpSeCd)
|
||||
CALL cnstpSeMngService.findAll
|
||||
END ON
|
||||
|
||||
// 영역 B: 회계코드 등록
|
||||
ON 영역 B [등록] 버튼 CLICK DO
|
||||
OPEN 회계코드 등록 다이얼로그
|
||||
ON [저장] DO
|
||||
VALIDATE acntgSeCd, acntgSeCdNm
|
||||
CALL acntgSeMngService.save(AcntgSeMngSaveRequestDto)
|
||||
CALL acntgSeMngService.findAll
|
||||
CLOSE 다이얼로그
|
||||
END ON
|
||||
ON [취소] / [닫기] DO
|
||||
CLOSE 다이얼로그
|
||||
END ON
|
||||
END ON
|
||||
|
||||
// 영역 B: 회계코드 수정
|
||||
ON 영역 B 행 [수정] CLICK DO
|
||||
CALL acntgSeMngService.findById(acntgSeCd)
|
||||
OPEN 회계코드 수정 다이얼로그 (기존 값 초기화)
|
||||
ON [저장] DO
|
||||
CALL acntgSeMngService.update(acntgSeCd, AcntgSeMngSaveRequestDto)
|
||||
CALL acntgSeMngService.findAll
|
||||
CLOSE 다이얼로그
|
||||
END ON
|
||||
END ON
|
||||
|
||||
// 영역 B: 회계코드 삭제
|
||||
ON 영역 B 행 [삭제] CLICK DO
|
||||
CONFIRM "선택한 회계코드를 삭제하시겠습니까?"
|
||||
CALL acntgSeMngService.delete(acntgSeCd)
|
||||
CALL acntgSeMngService.findAll
|
||||
END ON
|
||||
|
||||
END
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 영역 A: 공종코드 관리
|
||||
|
||||
백엔드 **공종구분코드**(`CnstpSeMng`) CRUD. 계층 구조(`upCnstpCd`, `cdLvNo`)를 지원하나, 본 화면은 **평면 목록**으로 표시.
|
||||
|
||||
### 4-1. 헤더 / 툴바
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ----------- | ------------------------------------- |
|
||||
| 섹션 타이틀 | 공종코드 관리 |
|
||||
| 우측 버튼 | **[등록]** — 공종코드 등록 다이얼로그 |
|
||||
|
||||
### 4-2. 그리드 컬럼
|
||||
|
||||
| 컬럼명 | Field | 타입 | 비고 |
|
||||
| -------- | ------------- | ------ | ------------------------ |
|
||||
| 코드 | `cnstpSeCd` | string | PK, 최대 10자 |
|
||||
| 코드명 | `cnstpSeCdNm` | string | 최대 100자 |
|
||||
| 설명 | `expln` | string | 최대 1000자, 선택 |
|
||||
| 사용여부 | `useYn` | string | `Y`→사용, `N`→미사용 |
|
||||
| 관리 | — | — | 행별 **[수정] · [삭제]** |
|
||||
|
||||
- 행 클릭 시 별도 상세 이동 없음. 수정은 **[수정]** 버튼으로 다이얼로그 진입.
|
||||
- 페이지네이션: Spring `Pageable` (`page`, `size`, `sort`) — **UI 노출**. 기본 `size: 10`, `@common/components` 공통 페이지네이션 컴포넌트 사용.
|
||||
|
||||
### 4-3. 버튼
|
||||
|
||||
| 버튼명 | 위치 | 동작 |
|
||||
| ------ | -------------- | ---------------------------------------- |
|
||||
| 등록 | 섹션 우측 상단 | 공종코드 등록 다이얼로그 오픈 |
|
||||
| 수정 | 행별 | 상세 조회 후 수정 다이얼로그 (값 초기화) |
|
||||
| 삭제 | 행별 | 확인 후 삭제, 목록 갱신 |
|
||||
|
||||
### 4-4. 다이얼로그: 공종코드 등록 / 수정
|
||||
|
||||
다이얼로그 입력 필드는 **코드 · 코드명 · 설명 · 사용여부** 4개만 노출한다.
|
||||
|
||||
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
|
||||
| -------- | --------- | ---- | ------ | ----------------- | ------------------------------- |
|
||||
| 코드 | Input | Y | — | `cnstpSeCd` | 등록 시 입력, 수정 시 읽기 전용 |
|
||||
| 코드명 | Input | N | — | `cnstpSeCdNm` | 최대 100자 |
|
||||
| 설명 | Input | N | — | `expln` | 최대 1000자 |
|
||||
| 사용여부 | Radio | N | Y | `useYn` | `Y`→사용, `N`→미사용 |
|
||||
|
||||
> UI에 노출하지 않는 API 필드(`upCnstpCd`, `cdLvNo`): 등록 시 `cdLvNo: 1` 등 기본값 전송, 수정 시 `findById` 응답값 유지.
|
||||
|
||||
| 버튼명 | 동작 |
|
||||
| ------ | ------------------------------------------------ |
|
||||
| 저장 | POST(등록) 또는 PUT(수정) 후 `findAll` 목록 갱신 |
|
||||
| 취소 | 다이얼로그 닫기, 입력값 폐기 |
|
||||
| 닫기 | 다이얼로그 닫기 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 영역 B: 회계코드 관리
|
||||
|
||||
백엔드 **회계구분관리**(`AcntgSeMng`) CRUD. 영역 A와 동일한 **평면 목록** UI 패턴을 따른다.
|
||||
|
||||
### 5-1. 헤더 / 툴바
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ----------- | ------------------------------------- |
|
||||
| 섹션 타이틀 | 회계코드 관리 |
|
||||
| 우측 버튼 | **[등록]** — 회계코드 등록 다이얼로그 |
|
||||
|
||||
### 5-2. 그리드 컬럼
|
||||
|
||||
| 컬럼명 | Field | 타입 | 비고 |
|
||||
| -------- | ------------- | ------ | ------------------------ |
|
||||
| 코드 | `acntgSeCd` | string | PK, 최대 10자 |
|
||||
| 코드명 | `acntgSeCdNm` | string | 최대 100자 |
|
||||
| 설명 | `expln` | string | 최대 1000자, 선택 |
|
||||
| 사용여부 | `useYn` | string | `Y`→사용, `N`→미사용 |
|
||||
| 관리 | — | — | 행별 **[수정] · [삭제]** |
|
||||
|
||||
- 페이지네이션: 영역 A와 동일 — **UI 노출**, 기본 `size: 10`.
|
||||
|
||||
### 5-3. 버튼
|
||||
|
||||
| 버튼명 | 위치 | 동작 |
|
||||
| ------ | -------------- | ---------------------------------------- |
|
||||
| 등록 | 섹션 우측 상단 | 회계코드 등록 다이얼로그 오픈 |
|
||||
| 수정 | 행별 | 상세 조회 후 수정 다이얼로그 (값 초기화) |
|
||||
| 삭제 | 행별 | 확인 후 삭제, 목록 갱신 |
|
||||
|
||||
### 5-4. 다이얼로그: 회계코드 등록 / 수정
|
||||
|
||||
다이얼로그 입력 필드는 **코드 · 코드명 · 설명 · 사용여부** 4개만 노출한다. (영역 A와 동일 패턴)
|
||||
|
||||
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
|
||||
| -------- | --------- | ---- | ------ | ----------------- | ------------------------------- |
|
||||
| 코드 | Input | Y | — | `acntgSeCd` | 등록 시 입력, 수정 시 읽기 전용 |
|
||||
| 코드명 | Input | Y | — | `acntgSeCdNm` | 최대 100자 |
|
||||
| 설명 | Input | N | — | `expln` | 최대 1000자 |
|
||||
| 사용여부 | Radio | N | Y | `useYn` | `Y`→사용, `N`→미사용 |
|
||||
|
||||
> UI에 노출하지 않는 API 필드(`upAcntgSeCd`, `cdLvNo`): 등록 시 `cdLvNo: 1` 등 기본값 전송, 수정 시 `findById` 응답값 유지.
|
||||
|
||||
| 버튼명 | 동작 |
|
||||
| ------ | ------------------------------------------------ |
|
||||
| 저장 | POST(등록) 또는 PUT(수정) 후 `findAll` 목록 갱신 |
|
||||
| 취소 | 다이얼로그 닫기, 입력값 폐기 |
|
||||
| 닫기 | 다이얼로그 닫기 |
|
||||
|
||||
---
|
||||
|
||||
## 6. API/DTO 명세
|
||||
|
||||
### 6-1. 공통
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ----------- | ---------------------------------------------------------------------------------- |
|
||||
| 서비스 | `as-service` |
|
||||
| Base prefix | `/as-service/api/v1` (`ENDPOINT.AS_COMMON_SERVICE`) |
|
||||
| 인증 | JWT (`Authorization` 헤더), `X-Site-Id`, `token-id` |
|
||||
| 페이징 | Spring `Pageable`: `page`, `size`, `sort` → `PageResponse<T>` |
|
||||
| 엔티티 | `@/entities/cnstp` (`cnstpSeMngService`), `@/entities/acntg` (`acntgSeMngService`) |
|
||||
|
||||
**부가 API (본 화면 목록 외 참조용)**
|
||||
|
||||
| 리소스 | Method | URL | Response | 용도 |
|
||||
| ------------ | ------ | --------------------- | --------------------- | --------------------------------- |
|
||||
| 공종구분코드 | GET | `/cnstp-se/list/tree` | `CnstpSeMngTreeDto[]` | 계층 트리 (타 화면·상위코드 선택) |
|
||||
| 회계구분관리 | GET | `/acntg-se/list/tree` | `AcntgSeMngTreeDto[]` | 계층 트리 (타 화면·상위코드 선택) |
|
||||
|
||||
### 6-2. 공종구분코드 (영역 A) — `cnstp-se`
|
||||
|
||||
| 기능 | Method | URL | Request / Query | Response | 클라이언트 |
|
||||
| --------- | ------ | ----------------------- | -------------------------------- | ----------------------------- | ---------------------------- |
|
||||
| 목록 조회 | GET | `/cnstp-se` | `CnstpSeMngSearchDto` + Pageable | `Page<CnstpSeMngResponseDto>` | `cnstpSeMngService.findAll` |
|
||||
| 트리 조회 | GET | `/cnstp-se/list/tree` | — | `CnstpSeMngTreeDto[]` | `cnstpSeMngService.findTree` |
|
||||
| 상세 조회 | GET | `/cnstp-se/{cnstpSeCd}` | — | `CnstpSeMngResponseDto` | `cnstpSeMngService.findById` |
|
||||
| 등록 | POST | `/cnstp-se` | `CnstpSeMngSaveRequestDto` | `string` (cnstpSeCd) | `cnstpSeMngService.save` |
|
||||
| 수정 | PUT | `/cnstp-se/{cnstpSeCd}` | `CnstpSeMngSaveRequestDto` | `string` (cnstpSeCd) | `cnstpSeMngService.update` |
|
||||
| 삭제 | DELETE | `/cnstp-se/{cnstpSeCd}` | — | `string` (cnstpSeCd) | `cnstpSeMngService.delete` |
|
||||
|
||||
**CnstpSeMngResponseDto**
|
||||
|
||||
| Field | 타입 | 설명 |
|
||||
| ------------- | ------- | ------------------ |
|
||||
| `cnstpSeCd` | string | 공종구분코드 |
|
||||
| `cnstpSeCdNm` | string | 공종구분코드명 |
|
||||
| `upCnstpCd` | string? | 상위공종코드 |
|
||||
| `cdLvNo` | number | 코드레벨번호 |
|
||||
| `useYn` | string | 사용여부 |
|
||||
| `expln` | string? | 설명 (최대 1000자) |
|
||||
| `rgtr` | string? | 등록자 UUID |
|
||||
| `rgtrNm` | string? | 등록자명 |
|
||||
| `mdfr` | string? | 수정자 UUID |
|
||||
| `regDt` | string? | 등록일시 |
|
||||
| `mdfcnDt` | string? | 수정일시 |
|
||||
|
||||
**CnstpSeMngSaveRequestDto**: `cnstpSeCd`, `cnstpSeCdNm?`, `upCnstpCd?`, `cdLvNo?`, `useYn?`, `expln?`
|
||||
|
||||
**CnstpSeMngSearchDto**: `cnstpSeCd?`, `cnstpSeCdNm?`, `useYn?` + `page`, `size`, `sort`
|
||||
|
||||
**React Query hooks**: `useCnstpSeMngPage`, `useCnstpSeMngTree`, `useCnstpSeMng`, `useSaveCnstpSeMng`, `useUpdateCnstpSeMng`, `useDeleteCnstpSeMng`
|
||||
|
||||
### 6-3. 회계구분관리 (영역 B) — `acntg-se`
|
||||
|
||||
| 기능 | Method | URL | Request / Query | Response | 클라이언트 |
|
||||
| --------- | ------ | ----------------------- | -------------------------------- | ----------------------------- | ---------------------------- |
|
||||
| 목록 조회 | GET | `/acntg-se` | `AcntgSeMngSearchDto` + Pageable | `Page<AcntgSeMngResponseDto>` | `acntgSeMngService.findAll` |
|
||||
| 트리 조회 | GET | `/acntg-se/list/tree` | — | `AcntgSeMngTreeDto[]` | `acntgSeMngService.findTree` |
|
||||
| 상세 조회 | GET | `/acntg-se/{acntgSeCd}` | — | `AcntgSeMngResponseDto` | `acntgSeMngService.findById` |
|
||||
| 등록 | POST | `/acntg-se` | `AcntgSeMngSaveRequestDto` | `string` (acntgSeCd) | `acntgSeMngService.save` |
|
||||
| 수정 | PUT | `/acntg-se/{acntgSeCd}` | `AcntgSeMngSaveRequestDto` | `string` (acntgSeCd) | `acntgSeMngService.update` |
|
||||
| 삭제 | DELETE | `/acntg-se/{acntgSeCd}` | — | `string` (acntgSeCd) | `acntgSeMngService.delete` |
|
||||
|
||||
**AcntgSeMngResponseDto**
|
||||
|
||||
| Field | 타입 | 설명 |
|
||||
| ------------- | ------- | ------------------ |
|
||||
| `acntgSeCd` | string | 회계구분코드 |
|
||||
| `acntgSeCdNm` | string | 회계구분코드명 |
|
||||
| `upAcntgSeCd` | string? | 상위회계구분코드 |
|
||||
| `cdLvNo` | number | 코드레벨번호 |
|
||||
| `useYn` | string | 사용여부 |
|
||||
| `expln` | string? | 설명 (최대 1000자) |
|
||||
| `rgtr` | string? | 등록자 UUID |
|
||||
| `rgtrNm` | string? | 등록자명 |
|
||||
| `mdfr` | string? | 수정자 UUID |
|
||||
| `regDt` | string? | 등록일시 |
|
||||
| `mdfcnDt` | string? | 수정일시 |
|
||||
|
||||
**AcntgSeMngSaveRequestDto**: `acntgSeCd`, `acntgSeCdNm`, `upAcntgSeCd?`, `cdLvNo`, `useYn?`, `expln?`
|
||||
|
||||
**AcntgSeMngSearchDto**: `acntgSeCd?`, `acntgSeCdNm?`, `useYn?` + `page`, `size`, `sort`
|
||||
|
||||
**React Query hooks**: `useAcntgSeMngPage`, `useAcntgSeMngTree`, `useAcntgSeMng`, `useSaveAcntgSeMng`, `useUpdateAcntgSeMng`, `useDeleteAcntgSeMng`
|
||||
|
||||
---
|
||||
|
||||
## 7. UI 구현 가이드
|
||||
|
||||
### 7-1. 기술 스택·원칙
|
||||
|
||||
- Next.js App Router, React Query (`@tanstack/react-query`)
|
||||
- `@common/components` — `Card`, `Button`, `Dialog`, `DataTable` / `useDataTable`
|
||||
- `@tanstack/react-table` — 컬럼 정의는 `model/*-table-columns.tsx` 분리
|
||||
- 기존 entity 레이어 재사용: `@/entities/cnstp`, `@/entities/acntg` (service·queries·interface 구현 완료)
|
||||
- 좌·우 2열 레이아웃: `grid grid-cols-1 lg:grid-cols-2 gap-4` (모바일 1열 스택)
|
||||
- 삭제 전 `confirm` / Toast 피드백 — 프로젝트 공통 패턴 준수
|
||||
- 목록 하단 **페이지네이션 UI 노출** — 영역 A·B 각각 독립 page state
|
||||
- **검색 필터 UI 미제공** — `findAll` 호출 시 `page`, `size`만 전달
|
||||
- **등록/수정 다이얼로그** — 코드·코드명·설명은 `Input`, 사용여부는 `Radio` (4필드만 노출)
|
||||
- 상위 코드 삭제 시 **하위 코드 연쇄 삭제 UI·로직 미구현** — 단건 삭제만 수행
|
||||
|
||||
### 7-2. 모듈·파일 배치
|
||||
|
||||
| 구분 | 경로 (제안) |
|
||||
| ----------------- | -------------------------------------------------------------------- |
|
||||
| 라우트 | `apps/as/src/app/(authenticated)/site/code-management/page.tsx` |
|
||||
| 페이지 컴포넌트 | `apps/as/src/pages/code-management/ui/code-management-page.tsx` |
|
||||
| 공종 그리드 컬럼 | `apps/as/src/pages/code-management/model/cnstp-se-table-columns.tsx` |
|
||||
| 회계 그리드 컬럼 | `apps/as/src/pages/code-management/model/acntg-se-table-columns.tsx` |
|
||||
| 공종 등록/수정 폼 | `apps/as/src/pages/code-management/ui/cnstp-se-form-dialog.tsx` |
|
||||
| 회계 등록/수정 폼 | `apps/as/src/pages/code-management/ui/acntg-se-form-dialog.tsx` |
|
||||
| API·타입 (기존) | `@/entities/cnstp`, `@/entities/acntg` |
|
||||
|
||||
### 7-3. 영역별 UI 매핑
|
||||
|
||||
| 영역 | UI 컴포넌트 | 데이터 소스 | 비고 |
|
||||
| ------------ | ------------------------------------- | ------------------------------------------- | ------------------------------ |
|
||||
| A | `CnstpSeSection` + Table + Pagination | `useCnstpSeMngPage({ page, size })` | 독립 page state·mutation |
|
||||
| B | `AcntgSeSection` + Table + Pagination | `useAcntgSeMngPage({ page, size })` | A와 상호 의존 없음 |
|
||||
| A 다이얼로그 | `CnstpSeFormDialog` | `useCnstpSeMng`, `useSave/UpdateCnstpSeMng` | Input 4필드 + Radio 사용여부 |
|
||||
| B 다이얼로그 | `AcntgSeFormDialog` | `useAcntgSeMng`, `useSave/UpdateAcntgSeMng` | Input 4필드 + Radio 사용여부 |
|
||||
|
||||
### 7-4. 다이얼로그·라우팅
|
||||
|
||||
| 다이얼로그 | 트리거 | 라우팅 |
|
||||
| ------------- | ---------------- | -------------------- |
|
||||
| 공종코드 등록 | 영역 A [등록] | URL 변경 없음 (모달) |
|
||||
| 공종코드 수정 | 영역 A 행 [수정] | URL 변경 없음 (모달) |
|
||||
| 회계코드 등록 | 영역 B [등록] | URL 변경 없음 (모달) |
|
||||
| 회계코드 수정 | 영역 B 행 [수정] | URL 변경 없음 (모달) |
|
||||
|
||||
### 7-5. 데이터 흐름 (구현 관점)
|
||||
|
||||
```
|
||||
BEGIN
|
||||
|
||||
// 페이지 마운트 — 검색 조건 없이 page·size만 전달
|
||||
const cnstpQuery = useCnstpSeMngPage({ page: 0, size: 20 })
|
||||
const acntgQuery = useAcntgSeMngPage({ page: 0, size: 20 })
|
||||
|
||||
DISPLAY CodeManagementPage
|
||||
├─ CnstpSeSection(data=cnstpQuery.data, pagination)
|
||||
└─ AcntgSeSection(data=acntgQuery.data, pagination)
|
||||
|
||||
// 페이지 변경
|
||||
ON pagination page/size CHANGE DO
|
||||
refetch cnstpQuery | acntgQuery with new page, size
|
||||
END ON
|
||||
|
||||
// mutation 성공 시
|
||||
ON save/update/delete SUCCESS DO
|
||||
invalidateQueries cnstp-se | acntg-se baseKey
|
||||
TOAST "저장되었습니다." | "삭제되었습니다."
|
||||
END ON
|
||||
|
||||
END
|
||||
```
|
||||
@@ -0,0 +1,284 @@
|
||||
# 화면설계서: 필수첨부파일관리
|
||||
|
||||
## 1. 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ---------- | ---------------------------------------------- |
|
||||
| 버전 | 0.1 |
|
||||
| 프로세스명 | UI설계 |
|
||||
| 화면명 | 필수첨부파일관리 |
|
||||
| 화면 유형 | [v] 조회 [v] 입력 [v] 수정 [v] 삭제 [ ] 출력 |
|
||||
| 메뉴 경로 | 디지털행정지원 > 사이트관리 > 필수첨부파일관리 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 화면 목적
|
||||
|
||||
문서(업무) 구분별로 **필수 첨부파일** 항목을 등록·조회·수정·삭제한다. 상단 검색 영역에서 **문서구분**으로 목록을 필터링하고, **등록** 또는 행별 **수정**·**삭제**로 마스터 데이터를 유지한다. 등록·수정 시 **필수첨부파일아이디**는 클라이언트에서 입력(또는 생성)하여 요청 본문에 포함한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 화면 구성
|
||||
|
||||
### 3-1. 전체 레이아웃
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 페이지 타이틀: 필수첨부파일관리 브레드크럼: 디지털행정지원 > … > │
|
||||
├──────────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ [영역 A] 검색 / 액션 │
|
||||
│ ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 문서구분 [전체 ▼] [조회] [등록] │ │
|
||||
│ └────────────────────────────────────────────────────────────────────────────────────────────────┘ │
|
||||
├──────────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ [영역 B] 필수첨부파일 목록 │
|
||||
│ ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 필수첨부파일아이디 | 문서구분 | 필수첨부파일명 | 필수첨부여부 | 비고 | 등록일시 | 수정일시 | 관리 │
|
||||
│ ├────────────────────────────────────────────────────────────────────────────────────────────────┤ │
|
||||
│ │ EAF-0001 | 설치인가 | 사업계획서 | 예 | 서명본 첨부 | 2026-04-22 10:10:00 | — | [수정][삭제] │ │
|
||||
│ │ EAF-0002 | 기본계획 | 변경사유서 | 예 | — | 2026-04-22 11:00:00 | — | [수정][삭제] │ │
|
||||
│ └────────────────────────────────────────────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
(모달) [다이얼로그 C] 필수첨부파일 등록 / 수정
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ 필수첨부파일 등록 [닫기] │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ 필수첨부파일아이디(자동발급) [EAF-0004] │
|
||||
│ 문서구분 [설치인가 ▼] │
|
||||
│ 필수첨부파일명 [예: 사업계획서] │
|
||||
│ 필수첨부여부 [ ] 예 [ ] 아니오 │
|
||||
│ 비고 [비고를 입력하세요] │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ [취소] [저장] │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3-2. 화면 데이터 흐름
|
||||
|
||||
```
|
||||
BEGIN
|
||||
|
||||
// 최초 진입
|
||||
CALL 필수첨부파일 목록 조회 (findPage)
|
||||
DISPLAY 필수첨부파일 그리드 (영역 B)
|
||||
|
||||
// 영역 A: 검색
|
||||
ON [조회] 버튼 CLICK DO
|
||||
CALL 필수첨부파일 목록 조회 (문서구분 = 선택값, findPage)
|
||||
DISPLAY 필수첨부파일 그리드 (영역 B)
|
||||
END ON
|
||||
|
||||
// 영역 A: 등록
|
||||
ON [등록] 버튼 CLICK DO
|
||||
OPEN 필수첨부파일 등록 다이얼로그 (다이얼로그 C)
|
||||
ON [저장] DO
|
||||
CONFIRM "등록하시겠습니까?"
|
||||
VALIDATE 문서구분, 필수첨부파일명, 필수첨부여부
|
||||
CALL 필수첨부파일 등록 (create)
|
||||
CALL 필수첨부파일 목록 조회
|
||||
CLOSE 다이얼로그
|
||||
TOAST "저장되었습니다."
|
||||
END ON
|
||||
ON [취소] / [닫기] DO
|
||||
CLOSE 다이얼로그
|
||||
END ON
|
||||
END ON
|
||||
|
||||
// 영역 B: 수정
|
||||
ON 행 [수정] CLICK DO
|
||||
OPEN 필수첨부파일 수정 다이얼로그 (기존 값 초기화)
|
||||
ON [저장] DO
|
||||
VALIDATE 문서구분, 필수첨부파일명, 필수첨부여부
|
||||
CALL 필수첨부파일 수정 (update)
|
||||
CALL 필수첨부파일 목록 조회
|
||||
CLOSE 다이얼로그
|
||||
END ON
|
||||
END ON
|
||||
|
||||
// 영역 B: 삭제
|
||||
ON 행 [삭제] CLICK DO
|
||||
CONFIRM "삭제하시겠습니까?"
|
||||
CALL 필수첨부파일 삭제 (remove)
|
||||
CALL 필수첨부파일 목록 조회
|
||||
END ON
|
||||
|
||||
END
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 영역 A: 검색 / 액션
|
||||
|
||||
문서구분 조건으로 목록을 조회하고, 신규 등록 다이얼로그를 연다.
|
||||
|
||||
### 4-1. 검색 필드
|
||||
|
||||
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
|
||||
| -------- | ---------- | ---- | ------ | ----------------- | ------------------------------ |
|
||||
| 문서구분 | CodeSelect | N | 전체 | `docSeComCd?` | "전체" 선택 시 파라미터 미전송 |
|
||||
|
||||
### 4-2. 버튼
|
||||
|
||||
| 버튼명 | 위치 | 동작 |
|
||||
| ------ | -------------- | ----------------------------------------------- |
|
||||
| 조회 | 검색 필드 우측 | 현재 검색 조건으로 영역 B 목록 갱신 |
|
||||
| 등록 | 영역 우측 끝 | 필수첨부파일 등록 다이얼로그(다이얼로그 C) 오픈 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 영역 B: 필수첨부파일 목록
|
||||
|
||||
필수 첨부파일 마스터를 테이블로 표시한다. 페이지네이션 UI 적용.
|
||||
|
||||
### 5-1. 헤더
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ----------- | ----------------- |
|
||||
| 섹션 타이틀 | 필수첨부파일 목록 |
|
||||
|
||||
### 5-2. 그리드 컬럼
|
||||
|
||||
| 컬럼명 | Field | 타입 | 비고 |
|
||||
| ------------------ | ------------------- | ------ | ------------------------------------- |
|
||||
| 필수첨부파일아이디 | `esntlAtchFileId` | string | 예: `EAF-0001`, 클라이언트 생성/입력 |
|
||||
| 문서구분 | `docSeComCd` | string | 코드명 표시 — 공통코드 (TBD 상위코드) |
|
||||
| 필수첨부파일명 | `esntlAtchFileNm` | string | |
|
||||
| 필수첨부여부 | `esntlAtchYn` | string | `Y`→예, `N`→아니오 |
|
||||
| 비고 | `esntlAtchFileRmrk` | string | 없으면 `—` 표시 |
|
||||
| 등록일시 | `regDt` | string | `yyyy-MM-dd HH:mm:ss` |
|
||||
| 수정일시 | `mdfcnDt` | string | 미수정 시 빈 값 또는 `—` |
|
||||
| 관리 | — | — | 행별 **[수정] · [삭제]** |
|
||||
|
||||
- 행 클릭 시 별도 상세 화면 없음. 편집은 **[수정]** 버튼으로 다이얼로그 C를 연다.
|
||||
|
||||
### 5-3. 버튼
|
||||
|
||||
| 버튼명 | 위치 | 동작 |
|
||||
| ------ | ---- | -------------------------------------- |
|
||||
| 수정 | 행별 | 필수첨부파일 수정 다이얼로그 (기존 값) |
|
||||
| 삭제 | 행별 | 확인 후 삭제 API 호출 → 목록 갱신 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 다이얼로그 C: 필수첨부파일 등록 / 수정
|
||||
|
||||
등록·수정 공용 폼 다이얼로그. 등록 시 제목 **「필수첨부파일 등록」**, 수정 시 **「필수첨부파일 수정」**.
|
||||
|
||||
### 6-1. 폼 필드
|
||||
|
||||
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
|
||||
| ------------------ | ---------- | ---- | ------ | ------------------- | -------------------------------- |
|
||||
| 필수첨부파일아이디 | (Readonly) | Y | — | `esntlAtchFileId` | 등록 시 미노출 |
|
||||
| 문서구분 | CodeSelect | Y | — | `docSeComCd` | 예: 설치인가, 기본계획 |
|
||||
| 필수첨부파일명 | Text | Y | — | `esntlAtchFileNm` | placeholder: "예: 사업계획서" |
|
||||
| 필수첨부여부 | RadioGroup | Y | Y | `esntlAtchYn` | Y/N, UI 표시: 예/아니오 |
|
||||
| 비고 | Textarea | N | — | `esntlAtchFileRmrk` | placeholder: "비고를 입력하세요" |
|
||||
|
||||
### 6-2. 버튼
|
||||
|
||||
| 버튼명 | 동작 |
|
||||
| ------ | -------------------------------------- |
|
||||
| 저장 | POST(등록) 또는 PUT(수정) 후 목록 갱신 |
|
||||
| 취소 | 다이얼로그 닫기, 입력값 폐기 |
|
||||
| 닫기 | 다이얼로그 닫기 (헤더 우측) |
|
||||
|
||||
---
|
||||
|
||||
## 7. API/DTO 명세
|
||||
|
||||
### 7-1. 공통
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ----------- | ----------------------------------------------------- |
|
||||
| 서비스 | AS Common Service |
|
||||
| Base prefix | `/as-service/api/v1/esntl-atch-file` |
|
||||
| 인증 | JWT (`Authorization`), `X-Site-Id` 등 |
|
||||
| 페이징 | Spring `Pageable` 사용 (`page`, `size`, `sort`) |
|
||||
| 엔티티 | `@/entities/esntl` (`esntl-service.ts`, `queries.ts`) |
|
||||
|
||||
### 7-2. 필수첨부파일 API
|
||||
|
||||
| 기능 | Method | URL | Request DTO | Response DTO | API (클라이언트 함수명) |
|
||||
| --------- | ------ | -------------------- | ----------------------------- | ----------------------------------- | ----------------------- |
|
||||
| 트리 조회 | GET | `/list/tree` | Query: `docSeComCd`(필수) | `EsntlAtchFileMngTreeResponseDto[]` | `findTree` |
|
||||
| 목록 조회 | GET | `/` | Query: Search + Pageable | `PageResponse<EsntlAtchFileMng>` | `findPage` |
|
||||
| 단건 조회 | GET | `/{esntlAtchFileId}` | Path: `esntlAtchFileId` | `EsntlAtchFileMng` | `findById` |
|
||||
| 등록 | POST | `/` | `EsntlAtchFileMngSaveRequest` | `string` (생성된 ID) | `create` |
|
||||
| 수정 | PUT | `/{esntlAtchFileId}` | `EsntlAtchFileMngSaveRequest` | `string` (수정된 ID) | `update` |
|
||||
| 삭제 | DELETE | `/{esntlAtchFileId}` | Path: `esntlAtchFileId` | `string` (삭제된 ID) | `remove` |
|
||||
|
||||
**응답 DTO**
|
||||
|
||||
| Field | 타입 | 설명 |
|
||||
| ------------------- | ------- | -------------------------------- |
|
||||
| `esntlAtchFileId` | string | 필수첨부파일아이디 |
|
||||
| `docSeComCd` | string | 문서구분공통코드 |
|
||||
| `esntlAtchFileNm` | string | 필수첨부파일명 |
|
||||
| `esntlAtchYn` | string | 필수첨부여부 (Y/N) |
|
||||
| `esntlAtchFileRmrk` | string? | 필수첨부파일비고 |
|
||||
| `rgtr` | string? | 등록자 UUID |
|
||||
| `rgtrNm` | string? | 등록자명 |
|
||||
| `mdfr` | string? | 수정자 UUID |
|
||||
| `regDt` | string? | 등록일시 (`yyyy-MM-dd HH:mm:ss`) |
|
||||
| `mdfcnDt` | string? | 수정일시 |
|
||||
|
||||
**저장 Request**
|
||||
|
||||
| Field | 필수 | 설명 |
|
||||
| ------------------- | ---- | ------------------ |
|
||||
| `esntlAtchFileId` | Y | 필수첨부파일아이디 |
|
||||
| `docSeComCd` | N | 문서구분 |
|
||||
| `esntlAtchFileNm` | N | 필수첨부파일명 |
|
||||
| `esntlAtchYn` | N | 필수첨부여부 |
|
||||
| `esntlAtchFileRmrk` | N | 비고 |
|
||||
|
||||
**검색 Query**
|
||||
|
||||
| Field | 필수 | 설명 |
|
||||
| ----------------- | ---- | ----------------------------- |
|
||||
| `docSeComCd` | N | 문서구분 (전체 시 미전송) |
|
||||
| `esntlAtchFileNm` | N | 필수첨부파일명 부분/일치 검색 |
|
||||
| `esntlAtchYn` | N | 필수첨부여부 (`Y`/`N`) |
|
||||
| `page` | N | 페이지 번호 (0-base) |
|
||||
| `size` | N | 페이지 크기 (기본값: 100) |
|
||||
| `sort` | N | 정렬 조건 (예: `regDt,desc`) |
|
||||
|
||||
### 7-3. 공통코드
|
||||
|
||||
| UI 필드 | 코드 그룹 | 예시 값 |
|
||||
| ------------ | --------- | ------------------ |
|
||||
| 문서구분 | ASDOCSECD | 설치인가, 기본계획 |
|
||||
| 필수첨부여부 | 고정 | Y(예), N(아니오) |
|
||||
|
||||
---
|
||||
|
||||
## 8. UI 구현 가이드
|
||||
|
||||
### 8-1. 기술 스택·원칙
|
||||
|
||||
- **공통 컴포넌트**: `@common/components` (`Dialog`, `Button`, `Input`, `Select`, `Textarea`, `Label` 등)
|
||||
- **테이블**: `@tanstack/react-table` + `useDataTable`
|
||||
- **폼**: `react-hook-form`
|
||||
- **데이터**: React Query — `@/entities/esntl/api/queries.ts` 훅 사용
|
||||
- **공통코드**: `@/entities/code` — `CodeSelect`, `findCodeLabel`
|
||||
- **확인**: `@frontend-opensource/use-react-hooks` — `useConfirm` (등록·삭제 확인)
|
||||
|
||||
### 8-2. 모듈·파일 배치
|
||||
|
||||
| 구분 | 경로 |
|
||||
| ------ | ---------------------------------------------------------------------------------------------------- |
|
||||
| 라우트 | `apps/as/app/(with-base-layout)/esntl-atch-file-mng/page.tsx` |
|
||||
| 페이지 | `apps/as/src/pages/esntl-atch-file-mng/` (Public API: page만 export) |
|
||||
| UI | `.../ui/esntl-atch-file-mng-page.tsx`, `esntl-atch-file-list.tsx`, `esntl-atch-file-form-dialog.tsx` |
|
||||
| model | `.../model/esntl-atch-file-columns.tsx`, `esntl-atch-file-search-params.ts` |
|
||||
| 엔티티 | `apps/as/src/entities/esntl/` (`esntl-service.ts`, `queries.ts`, `esntl-interface.ts`) |
|
||||
| 문서 | `apps/as/docs/essential-attachment-file-management/SPEC.md` |
|
||||
|
||||
### 8-3. 데이터 흐름(요약)
|
||||
|
||||
- **진입**: 검색 params 기본값(`docSeComCd` = 전체) → 목록 query → 영역 B DataTable.
|
||||
- **조회**: [조회] 클릭 시 search state 반영 → 목록 query refetch.
|
||||
- **등록**: [등록] → Dialog `mode=create` → [저장] → POST → 목록 invalidate.
|
||||
- **수정**: [수정] → Dialog `mode=edit` + row 초기값 → [저장] → PUT → 목록 invalidate.
|
||||
- **삭제**: confirm → DELETE → 목록 invalidate.
|
||||
@@ -0,0 +1,367 @@
|
||||
# 재원협의(fnrs-cnsltn) vs 설치인가(instl-sncn) 차이 분석
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------- | ----------------------------------------------------------------------------- |
|
||||
| 작성일 | 2026-07-09 |
|
||||
| 기준 구현 | `fnrs-cnsltn` (추가 요구사항 반영 완료) |
|
||||
| 비교 대상 | `instl-sncn` |
|
||||
| 목적 | 두 페이지의 동작·구조 차이를 정리하고, 설치인가 정렬 시 이식 대상을 명확히 함 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 요약
|
||||
|
||||
두 모듈은 **동일한 PBADMS(공공행정 지원) 업무 패턴**을 따르는 형제 페이지다.
|
||||
|
||||
공통 흐름:
|
||||
|
||||
```
|
||||
목록 → TaskSe 선택 후 신청 → 저장(SV) → 신청완료(ST)
|
||||
→ 환경청제출(LE) → 기술검토 → 검토결과 회신(EP)
|
||||
→ 인가승인(TA) → 고시(TB)
|
||||
```
|
||||
|
||||
| 구분 | fnrs-cnsltn | instl-sncn |
|
||||
| ------------- | ----------------- | ---------------- |
|
||||
| 업무명 | 재원협의 | 설치인가 |
|
||||
| 문서번호 | `fnrsCnsltnDocNo` | `instlSncnDocNo` |
|
||||
| docSeComCd | `FNRS` | `INSTL` |
|
||||
| 라우트 | `/fnrs-cnsltn/*` | `/instl-sncn/*` |
|
||||
| pages 파일 수 | 39 | 32 |
|
||||
|
||||
**결론:** `fnrs-cnsltn`이 추가 요구사항이 반영된 기준(reference) 구현이고, `instl-sncn`은 동일 골격이지만 보완요청·결재선 편집·등록/수정 폼 완성도 등에서 미반영·TODO·단순화 상태다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 파일/폴더 구조
|
||||
|
||||
### 2-1. fnrs-cnsltn에만 존재
|
||||
|
||||
| 경로 | 역할 |
|
||||
| -------------------------------------------- | ------------------------- |
|
||||
| `ui/sections/fnrs-shar-section.tsx` | 재원분담 조회 섹션 |
|
||||
| `ui/sections/supplement-demand-section.tsx` | 보완요청 이력 섹션 |
|
||||
| `ui/components/sortable-prgrs-stts-row.tsx` | 결재선 DnD 편집 행 |
|
||||
| `ui/components/sortable-apln-picker-row.tsx` | 결재선 템플릿 피커 DnD 행 |
|
||||
| `ui/components/aprvr-picker-dialog.tsx` | 결재자 선택 다이얼로그 |
|
||||
| `ui/components/elapr-apln-picker-grid.tsx` | 결재선 편집 그리드 |
|
||||
| `ui/components/elapr-tmplt-select-table.tsx` | 결재서식 선택 테이블 |
|
||||
| `model/reviewer-option.ts` | 결재자 옵션 타입 |
|
||||
|
||||
### 2-2. instl-sncn에만 존재
|
||||
|
||||
| 경로 | 역할 | 비고 |
|
||||
| -------------------------------------- | ------------------- | ------------------------------------------ |
|
||||
| `ui/sections/history-info-section.tsx` | 등록/수정 이력 표시 | **어디에서도 import되지 않음 (dead code)** |
|
||||
|
||||
### 2-3. 공통 구조
|
||||
|
||||
```
|
||||
index.ts
|
||||
ui/
|
||||
{list,view,form,regist,edit}-page.tsx
|
||||
sections/ (basic-info, contact, scale, cost, checklist, attachments, tech-review)
|
||||
components/ (floating-action-buttons, elapr-approval-dialog, elapr-tmplt-picker-dialog, checklist-task-panel)
|
||||
lib/ button-config, save-payload-utils, form-data-mapper, regist-schema, checklist/review utils
|
||||
model/ columns.tsx, view-types.ts
|
||||
```
|
||||
|
||||
App Router도 동일: `page.tsx`, `regist/page.tsx`, `[id]/page.tsx`, `[id]/edit/page.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 3. 목록 페이지
|
||||
|
||||
### 3-1. 검색 필터
|
||||
|
||||
| 항목 | fnrs-cnsltn | instl-sncn |
|
||||
| ------------------ | ----------------------------------------- | --------------------------------- |
|
||||
| 시도/시군구 API | `@/entities/adstdstr` | 동일 |
|
||||
| 지역 검색 파라미터 | **`admdstCd`** (시군구 우선, 없으면 시도) | **`sidoComCd` + `sggComCd`** 분리 |
|
||||
| "전체" 옵션 | `ALL_SELECT_VALUE = "__ALL__"` SelectItem | 없음 (placeholder만) |
|
||||
| 구분 CodeSelect | `upCd="INSTL"` ※ | `upCd="INSTL"` |
|
||||
| 사업명 | 동일 | 동일 |
|
||||
|
||||
> ※ fnrs 목록의 구분 필터가 `INSTL` 코드를 사용 중이며, TaskSePickerDialog는 `codeUpCd="FNRS"`를 쓴다. **불일치 가능성**이 있어 확인이 필요하다.
|
||||
|
||||
### 3-2. 정렬·페이지네이션
|
||||
|
||||
| 항목 | fnrs-cnsltn | instl-sncn |
|
||||
| ------------------------ | ---------------------- | ------------------------------------- |
|
||||
| 기본 sort (list-page) | `fnrsCnsltnDocNo,desc` | `instlSncnDocNo,desc` |
|
||||
| fallback sort (list.tsx) | `fnrsCnsltnDocNo,desc` | **`regDt,desc`** (list-page와 불일치) |
|
||||
| 페이지 크기 | 10 | 10 |
|
||||
| URL 동기화 | 없음 (`useState` 로컬) | 없음 |
|
||||
|
||||
### 3-3. 테이블 컬럼
|
||||
|
||||
`model/columns.tsx` 로직은 사실상 동일하다. DTO 타입명만 다르다.
|
||||
|
||||
컬럼: 시도, 시군구, 사업명, 업무구분, 담당자, 해당기관, 진행상황, 신청일
|
||||
|
||||
### 3-4. 행 클릭·신청
|
||||
|
||||
| 항목 | fnrs-cnsltn | instl-sncn |
|
||||
| ------------ | ------------------------------------------------------------------ | ------------------ |
|
||||
| 행 클릭 | **조건부 라우팅**: `SV`/`SL` + 등록자 본인 → `/edit`, 그 외 → view | **항상 view** |
|
||||
| useUserStore | 사용 | 미사용 |
|
||||
| 신청 버튼 | TaskSePickerDialog `codeUpCd="FNRS"` | `codeUpCd="INSTL"` |
|
||||
|
||||
```46:54:apps/as/src/pages/fnrs-cnsltn/ui/fnrs-list.tsx
|
||||
const handleRowClick = (row: FnrsCnsltnMngResponseDto) => {
|
||||
const canEdit =
|
||||
(row.prgrsSttsComCd === "SV" || row.prgrsSttsComCd === "SL") &&
|
||||
row.rgtr === userInfo?.userUnqId;
|
||||
|
||||
router.push(
|
||||
canEdit ? `/fnrs-cnsltn/${row.fnrsCnsltnDocNo}/edit` : `/fnrs-cnsltn/${row.fnrsCnsltnDocNo}`,
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 상세(View) 페이지
|
||||
|
||||
### 4-1. 섹션 구성
|
||||
|
||||
| 섹션 | fnrs-cnsltn | instl-sncn |
|
||||
| ------------------------------- | ------------------------------------------ | -------------------------- |
|
||||
| BasicInfo | 환경청구분, 확정예산사업번호, 예산신청연도 | **사업구분(`sncnSeCdNm`)** |
|
||||
| ContactInfo | 동일 패턴 | 동일 |
|
||||
| BusinessScale / Cost | 동일 | 동일 |
|
||||
| **FnrsShar (재원분담)** | **있음** | **없음** (도메인 고유) |
|
||||
| Checklist | 동일 | 동일 |
|
||||
| Attachments | 동일 | 동일 |
|
||||
| **SupplementDemand (보완요청)** | **있음** | **없음** |
|
||||
| TechReviewOpinion | 동일 | 동일 |
|
||||
| HistoryInfo | 없음 | 파일만 존재, view 미연결 |
|
||||
| FloatingActionButtons | `docSeComCd="FNRS"` | `docSeComCd="INSTL"` |
|
||||
|
||||
### 4-2. 데이터 훅
|
||||
|
||||
| 항목 | fnrs-cnsltn | instl-sncn |
|
||||
| --------- | ---------------------------- | -------------------- |
|
||||
| 상세 훅 | `useFnrsCnsltnMng` | `useInstlSncnDetail` |
|
||||
| 에러 판정 | `isError \|\| !data?.detDto` | `isError \|\| !data` |
|
||||
| 로딩 | Skeleton | Skeleton |
|
||||
|
||||
---
|
||||
|
||||
## 5. 등록/수정(Form) 페이지
|
||||
|
||||
### 5-1. 폼 완성도
|
||||
|
||||
| 항목 | fnrs-cnsltn | instl-sncn |
|
||||
| ------------- | --------------------------------- | --------------- |
|
||||
| 저장(SV) | O | O (SV 고정) |
|
||||
| 신청완료(ST) | O (edit 모드) | **없음** |
|
||||
| 삭제 | O (edit 모드, `useConfirm`) | **없음** |
|
||||
| 재원분담 섹션 | `FnrsSharFormSection` + 잔액 검증 | 없음 |
|
||||
| 저장 후 이동 | **`/edit` 유지** | **view로 이동** |
|
||||
| 목록 버튼 | `/fnrs-cnsltn` 고정 | `router.back()` |
|
||||
|
||||
### 5-2. 기본정보 Form 필드
|
||||
|
||||
| 필드 | fnrs | instl |
|
||||
| ----------------- | ---------------- | --------------------------------------- |
|
||||
| taskSe 코드 | `FNRS` | `INSTL` |
|
||||
| 사업구분 | 없음 | **`sncnSeComCd` (SNCNSE 라디오, 필수)** |
|
||||
| BnsPicker 연동 | bizNm ~ bizCn | + **`bgtBizCd`, `bgtAplyYr`** |
|
||||
| 재이용(RE) 조건부 | rtlztnFclt / Usg | 동일 |
|
||||
|
||||
### 5-3. Schema
|
||||
|
||||
| 항목 | fnrs | instl |
|
||||
| ------------------- | ------------------- | -------------- |
|
||||
| 관리 객체 | `fnrsCnsltnMng` | `instlSncnMng` |
|
||||
| 예산 필드 | `cfmtnBgtBizNo` | `bgtBizCd` |
|
||||
| 재원분담 | `fnrsSharList` 배열 | 없음 |
|
||||
| sharAtchFileGroupId | 있음 | 없음 |
|
||||
| privacyConsent | 있음 | 있음 |
|
||||
|
||||
### 5-4. Edit 페이지 래퍼
|
||||
|
||||
| 항목 | fnrs | instl |
|
||||
| ------------------ | ------------------------------- | ------------------------------- |
|
||||
| 로딩 Skeleton | 있음 | **없음** |
|
||||
| 데이터 없음 메시지 | "상세 정보를 찾을 수 없습니다." | "수정 대상을 찾을 수 없습니다." |
|
||||
|
||||
---
|
||||
|
||||
## 6. FloatingActionButtons / 결재 / 보완
|
||||
|
||||
### 6-1. FAB 동작 차이
|
||||
|
||||
공통: 역할×상태 기반 버튼(`button-config.ts`), checklist 진행률 검증, elapr pending approval 체크
|
||||
|
||||
| 기능 | fnrs-cnsltn | instl-sncn |
|
||||
| ------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| 보완완료 (`splmntCmptn`) | **`submitSupplement` API 연동** | **`// TODO` stub** |
|
||||
| 보완요청 (`dmndSplmnt`) | **`buildSupplementReqPayload` + `submitReqSupplement`** | **`// TODO` stub** (문자열만 전달) |
|
||||
| 검토완료 (`rvwCmptn`) | `techReViewSaveCd: "SV"` 후 **결재 다이얼로그 오픈** | `techReViewSaveCd: "CP"`, **다이얼로그 없이 즉시 완료** |
|
||||
| 검토자지정 | `buildElaprPrgrsSttsListFromAplnRows(aplnRows)` | `buildElaprPrgrsSttsListFromApln(aplnPage.content)` |
|
||||
| atrzAprv / atrzRply / grndsChck | TODO handler | TODO handler |
|
||||
|
||||
```576:617:apps/as/src/pages/instl-sncn/ui/components/floating-action-buttons.tsx
|
||||
async function handleSplmntCmptn(context: ActionHandlerContext): Promise<void> {
|
||||
// TODO: 보완완료 API 호출
|
||||
context.onSuccess?.();
|
||||
}
|
||||
// ...
|
||||
async function handleDmndSplmnt(context: ActionHandlerContext): Promise<void> {
|
||||
// TODO: 보완요청 API 호출
|
||||
context.onSuccess?.();
|
||||
}
|
||||
```
|
||||
|
||||
### 6-2. ElaprApprovalDialog (가장 큰 UX 격차)
|
||||
|
||||
| 항목 | fnrs (419줄) | instl (231줄) |
|
||||
| ---------------- | ------------------------------------------------------ | -------------------------------- |
|
||||
| 결재선 표시 | DnD sortable 편집 테이블 | **읽기 전용** 테이블 |
|
||||
| 결재선 추가/저장 | **지원** (`usePrgrsSttsRows`, `useSaveElaprPrgrsStts`) | 없음 |
|
||||
| 결재자 변경 | **AprvrPickerDialog** | 없음 |
|
||||
| 전결 체크박스 | **있음** (`finalApprovalYn`) | 없음 |
|
||||
| 반려 사유 | API에 `rjctRsn` 전달 | UI는 있으나 **payload에 미전달** |
|
||||
| 저장 전 검증 | `hasUnsavedPrgrsSttsChanges` | 없음 |
|
||||
|
||||
### 6-3. ElaprTmpltPickerDialog
|
||||
|
||||
| 항목 | fnrs | instl |
|
||||
| --------------- | -------------------------------------------------------------------- | ----------------------- |
|
||||
| UI | 2-pane: 서식 테이블 + **결재선 편집 그리드** | 단일 테이블 + 선택 버튼 |
|
||||
| docSeComCd 필터 | `FNRS` | `INSTL` |
|
||||
| onConfirm | `{ tmplt, aplnRows }` 편집 결과 | `onSelect(tmplt)`만 |
|
||||
| 검증 | `validateAllRowsForSave`, `validateAtrzOrder`, `validateUniqueSeqNo` | 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 7. Entity / API / Hook
|
||||
|
||||
### 7-1. Service 메서드
|
||||
|
||||
| 메서드 | fnrs | instl |
|
||||
| ----------------------- | --------------------------- | --------- |
|
||||
| 목록 | `findPage` | `getPage` |
|
||||
| 상세 | `findById` | `getById` |
|
||||
| saveApply | O | O |
|
||||
| techReview | O | O |
|
||||
| changePrgrsSttsComCd | O | O |
|
||||
| remove | O | O |
|
||||
| **submitSupplement** | **O** (`/submitsupplement`) | **X** |
|
||||
| **submitReqSupplement** | **O** (`/reqsupplement`) | **X** |
|
||||
|
||||
### 7-2. 상세 DTO 확장
|
||||
|
||||
**fnrs 추가 필드**
|
||||
|
||||
- `fnrsSharList`, `sharAtchFileList`
|
||||
- `splmntDmndList` (typed)
|
||||
|
||||
**instl 추가 필드**
|
||||
|
||||
- `sncnSeComCd` / `sncnSeCdNm` (인가구분)
|
||||
- `bgtBizCd` (예산사업코드)
|
||||
- `rtlztnFcltSeComNm`, `rtlztnUsgSeComNm`
|
||||
- `splmntDmndList` 타입은 base에 존재하나 view/API 미연결
|
||||
- `instlSncnRegionService` (시도/시군구 — 목록은 adstdstr 사용, 미활용)
|
||||
|
||||
### 7-3. save-payload-utils
|
||||
|
||||
| 함수 | fnrs | instl |
|
||||
| -------------------------------- | ---------------- | --------- |
|
||||
| buildSavePayload | + `fnrsSharList` | shar 없음 |
|
||||
| buildTechReviewReplyPayload | O | O |
|
||||
| **buildSupplementReqPayload** | **O** | **X** |
|
||||
| **buildSupplementSubmitPayload** | **O** | **X** |
|
||||
|
||||
---
|
||||
|
||||
## 8. 비즈니스 도메인 차이
|
||||
|
||||
| 영역 | fnrs-cnsltn | instl-sncn |
|
||||
| -------------- | ----------------------------------------- | ------------------------ |
|
||||
| 업무 코드 | `FNRS` | `INSTL` |
|
||||
| TaskSe | FNRS 하위 | INSTL 하위 |
|
||||
| 고시 버튼 라벨 | "재원협의 고시" | "설치인가 고시" |
|
||||
| 재원분담 | **핵심 도메인** (`fnrs-shar-se`) | 해당 없음 |
|
||||
| 인가구분 | 없음 | **`SNCNSE` 코드 (필수)** |
|
||||
| 예산 식별 | `cfmtnBgtBizNo` | `bgtBizCd` |
|
||||
| 진행상태 코드 | SV, ST, LE, ER, TE, EP, TA, TB, SL, TP 등 | **동일 세트 공유** |
|
||||
| 역할 매핑 | ROLE_USER05/04/03/01 | 동일 |
|
||||
|
||||
---
|
||||
|
||||
## 9. fnrs-cnsltn에만 있는 기능 (추가 요구사항 후보)
|
||||
|
||||
instl-sncn 정렬 시 **우선 이식 대상**:
|
||||
|
||||
1. 목록 행 클릭 조건부 edit 라우팅 (`SV`/`SL` + 등록자)
|
||||
2. 보완요청 전체 플로우 — view 섹션, payload builder, API, FAB handler
|
||||
3. 보완완료 API 연동 (`submitSupplement`)
|
||||
4. 등록/수정 폼 완성 — 신청완료, 삭제, 저장 후 edit 유지
|
||||
5. 결재선 DnD 편집형 `ElaprApprovalDialog` (+ aprvr-picker, sortable rows)
|
||||
6. 결재선 편집형 `ElaprTmpltPickerDialog` (+ apln grid, validation)
|
||||
7. 검토완료 → 결재 다이얼로그 연계 흐름
|
||||
8. 검색 필터 "전체" 옵션 및 `admdstCd` 통합 방식 (백엔드 API에 맞게)
|
||||
9. Edit 페이지 로딩 Skeleton
|
||||
10. entity에 supplement API/hooks 추가
|
||||
|
||||
---
|
||||
|
||||
## 10. instl-sncn에만 있는 기능 (유지 필요)
|
||||
|
||||
1. **`sncnSeComCd` (사업구분/인가구분)** — 도메인 고유
|
||||
2. **`bgtBizCd` / `bgtAplyYr`** BnsPicker 연동
|
||||
3. **`history-info-section.tsx`** — 구현만 있고 view 미연결 (활성화 또는 삭제 결정 필요)
|
||||
4. entity **`instlSncnRegionService`** — 목록은 adstdstr 사용 중이라 중복/레거시 가능
|
||||
5. **`INSTL_SNCN_REGISTER_SELECTION_KEY`** sessionStorage 키
|
||||
|
||||
---
|
||||
|
||||
## 11. 동일·거의 동일 패턴
|
||||
|
||||
| 영역 | 동일도 |
|
||||
| ----------------------------------------------------- | --------------------- |
|
||||
| App Router 4-route 구조 | 100% |
|
||||
| `button-config.ts` 역할×상태 매트릭스 | ~99% (액션명만 다름) |
|
||||
| `columns.tsx` 진행상황 Badge | ~100% |
|
||||
| checklist / contact / scale / cost / attachments 섹션 | ~95% (import 경로만) |
|
||||
| review-step-utils 로직 | ~100% (상수명만 다름) |
|
||||
| TaskSePickerDialog 신청 플로우 | 100% |
|
||||
| FAB 전체 구조·대부분 handler | ~85% |
|
||||
| checklist-form-state, business-cost-utils | 거의 동일 |
|
||||
| shared widgets (`@/widgets/pbadms-sprt`) | 동일 |
|
||||
|
||||
---
|
||||
|
||||
## 12. instl-sncn 정렬 권장 우선순위
|
||||
|
||||
| 우선순위 | 항목 | 근거 |
|
||||
| -------- | ------------------------------------------------------- | ---------------------------- |
|
||||
| P0 | 보완요청/보완완료 API + UI | fnrs 완전 구현 vs instl TODO |
|
||||
| P0 | ElaprApprovalDialog 고도화 | 결재선 편집·전결·반려사유 |
|
||||
| P0 | ElaprTmpltPickerDialog 2-pane | 검토자지정 품질 |
|
||||
| P1 | Form 신청완료/삭제 + 저장 후 edit 유지 | 등록 UX parity |
|
||||
| P1 | 목록 행 클릭 edit 라우팅 | fnrs 추가 요구사항 |
|
||||
| P1 | 검토완료 → 결재 다이얼로그 연계 | 흐름 통일 |
|
||||
| P2 | sort fallback 불일치 수정 (`regDt` vs `instlSncnDocNo`) | instl 내부 버그성 |
|
||||
| P2 | history-info-section 연결 또는 삭제 | dead code 정리 |
|
||||
| P3 | URL search params 동기화 | 양쪽 모두 미구현 (공통 개선) |
|
||||
|
||||
---
|
||||
|
||||
## 13. 참고 코드 위치
|
||||
|
||||
| 주제 | 경로 |
|
||||
| ---------------------------- | ------------------------------------------------------------ |
|
||||
| 목록 edit 라우팅 (fnrs) | `pages/fnrs-cnsltn/ui/fnrs-list.tsx` |
|
||||
| 목록 항상 view (instl) | `pages/instl-sncn/ui/instl-list.tsx` |
|
||||
| view 추가 섹션 (fnrs) | `pages/fnrs-cnsltn/ui/fnrs-view-page.tsx` |
|
||||
| FAB 보완 TODO (instl) | `pages/instl-sncn/ui/components/floating-action-buttons.tsx` |
|
||||
| 결재 다이얼로그 (fnrs) | `pages/fnrs-cnsltn/ui/components/elapr-approval-dialog.tsx` |
|
||||
| 결재 다이얼로그 (instl) | `pages/instl-sncn/ui/components/elapr-approval-dialog.tsx` |
|
||||
| 폼 완성도 (fnrs) | `pages/fnrs-cnsltn/ui/fnrs-form-page.tsx` |
|
||||
| 폼 단순화 (instl) | `pages/instl-sncn/ui/instl-form-page.tsx` |
|
||||
| supplement API (fnrs entity) | `entities/fnrs-cnsltn/api/fnrs-cnsltn-service.ts` |
|
||||
@@ -0,0 +1,370 @@
|
||||
# 화면설계서: 검토인력관리
|
||||
|
||||
## 1. 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ---------- | -------------------------------------------- |
|
||||
| 버전 | 0.2 |
|
||||
| 프로세스명 | UI설계 |
|
||||
| 화면명 | 검토인력관리 |
|
||||
| 화면 유형 | [v] 조회 [v] 입력 [ ] 수정 [v] 삭제 [ ] 출력 |
|
||||
| 메뉴 경로 | 디지털행정지원 > 사이트관리 > 검토인력관리 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 화면 목적
|
||||
|
||||
조직도에서 **한 명의 사용자**를 선택한 뒤, 결재 단계(접수·검토·승인·최종승인)별 **인원 배치** 영역에 즉시 등록한다. 등록·목록 갱신은 별도 저장 버튼 없이 **[선택 인원 배치]** 클릭 시 `rvw-mnpw` API로 바로 반영한다. 좌측 조직도는 CMS 조직·사용자 트리 API를, 우측 배치 목록은 검토인력관리 API를 사용한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 화면 구성
|
||||
|
||||
### 3-1. 전체 레이아웃
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 페이지 타이틀: 검토인력관리 브레드크럼: 디지털행정지원 > 사이트관리 > 검토인력관리 │
|
||||
├─────────────────────────────────┬──────────────────────────────────────────────────────────────────┤
|
||||
│ [영역 A] 조직도 │ [영역 B] 인원 배치 (결재구분별 4블록, 세로 스택) │
|
||||
│ 제목: 조직도 │ │
|
||||
│ [A-1] 검색: │ ┌─ [B-1] 접수 ───────────── count ─ [선택 인원 배치] ─┐ │
|
||||
│ [성명·부서 검색] [조회][초기화]│ │ (배치 목록 또는 빈 상태 문구) │ │
|
||||
│ 선택 안내: │ └──────────────────────────────────────────────────────┘ │
|
||||
│ "선택된 인원: {이름} │ ┌─ [B-2] 검토 ───────────── count ─ [선택 인원 배치] ──┐ │
|
||||
│ ({부서} / {직급})" │ └──────────────────────────────────────────────────────┘ │
|
||||
│ [A-2] 조직 트리 + 사용자 행 │ ┌─ [B-3] 승인 ───────────── count ─ [선택 인원 배치] ──┐ │
|
||||
│ ┌────────────────────┐ │ └──────────────────────────────────────────────────────┘ │
|
||||
│ │ 조직 트리 + 사용자 │ │ ┌─ [B-4] 최종승인 ───────── count ─ [선택 인원 배치] ──┐ │
|
||||
│ │ (단일 선택 강조) │ │ └──────────────────────────────────────────────────────┘ │
|
||||
│ └────────────────────┘ │ │
|
||||
└─────────────────────────────────┴──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3-2. 화면 데이터 흐름
|
||||
|
||||
```
|
||||
BEGIN
|
||||
|
||||
// 최초 진입
|
||||
CALL userOgnzService.findOgnzUserTree()
|
||||
DISPLAY 조직도 트리 (영역 A)
|
||||
|
||||
CALL rvwMnpwMngService.findPage({ size: 충분히 큰 값 }) // 또는 atrzSeComCd별 분할 조회
|
||||
GROUP BY atrzSeComCd
|
||||
DISPLAY 인원 배치 4블록 (영역 B-1 ~ B-4) + 건수 뱃지
|
||||
|
||||
// 영역 A-1: 조직도 검색 (클라이언트 필터 — 서버 API 없음)
|
||||
ON [조회] 버튼 CLICK / 검색 폼 SUBMIT DO
|
||||
SET displayTree = filterOgnzUserTree(원본트리, keyword)
|
||||
SET expandedOgnzSnIds = collectExpandedOgnzSnIds(displayTree) // 매칭 노드 자동 펼침
|
||||
DISPLAY 필터된 트리 (영역 A-2)
|
||||
END ON
|
||||
|
||||
ON [초기화] 버튼 CLICK DO
|
||||
RESET keyword = ""
|
||||
RESET displayTree = 원본트리
|
||||
RESET expandedOgnzSnIds = 빈 Set (또는 기본 접힘)
|
||||
DISPLAY 전체 트리 (영역 A-2)
|
||||
END ON
|
||||
|
||||
// 영역 A-2: 사용자 단일 선택
|
||||
ON 조직도 사용자 행 CLICK DO
|
||||
VALIDATE 동일 행 재클릭이 아닌 경우만 변경
|
||||
SET selectedPerson = { userUuid, userNm, ognzSn, ognzNm, jbgdSeCd, ... }
|
||||
DISPLAY "선택된 인원: {userNm} ({ognzNm} / {직급명})"
|
||||
END ON
|
||||
|
||||
// 영역 B-k: 선택 인원 즉시 배치
|
||||
ON [B-k] [선택 인원 배치] 버튼 CLICK DO
|
||||
VALIDATE selectedPerson 존재
|
||||
SET targetAtrzSeComCd = 블록 k 에 매핑된 결재구분 코드 (§4-2 표)
|
||||
CALL rvwMnpwMngService.save({
|
||||
atrzSeComCd: targetAtrzSeComCd,
|
||||
ognzSn: selectedPerson.ognzSn,
|
||||
userUuid: selectedPerson.userUuid
|
||||
})
|
||||
ON 성공 DO
|
||||
CALL rvwMnpwMngService.findPage (또는 해당 블록 목록만 갱신)
|
||||
DISPLAY [B-k] 배치 목록·건수 뱃지 갱신
|
||||
TOAST "배치되었습니다."
|
||||
END ON
|
||||
ON 실패(이미 존재) DO
|
||||
TOAST (백엔드 메시지 연동)
|
||||
END ON
|
||||
END ON
|
||||
|
||||
// 영역 B-k: 배치 인원 제거 (UI 상 삭제 UX — 스크린샷 미표기, API는 제공됨)
|
||||
ON 배치 인원 [삭제] / [제거] CLICK DO
|
||||
CONFIRM "해당 인원을 배치에서 제거하시겠습니까?"
|
||||
CALL rvwMnpwMngService.delete(atrzSeComCd, ognzSn, userUuid)
|
||||
CALL rvwMnpwMngService.findPage
|
||||
DISPLAY [B-k] 목록·뱃지 갱신
|
||||
END ON
|
||||
|
||||
END
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 영역 A — 조직도
|
||||
|
||||
### 4-1. 표시·상호작용
|
||||
|
||||
| 요소 | 내용 |
|
||||
| ---------------- | --------------------------------------------------------------------- |
|
||||
| 섹션 제목 | 조직도 |
|
||||
| 검색 폼 (A-1) | 성명·부서 키워드 입력 + **[조회]** + **[초기화]** — §4-3 |
|
||||
| 선택 안내 문구 | `선택된 인원: {userNm} ({ognzNm} / {직급표시})` — 미선택 시 안내 문구 |
|
||||
| 트리 (A-2) | 본부 → 팀 계층 + 팀 소속 사용자 목록 (`displayTree` 기준 표시) |
|
||||
| 선택 규칙 | **사용자 1명만** 선택 가능 (라디오형 단일 선택) |
|
||||
| 선택 시각 피드백 | 선택된 사용자 행 강조(배경·테두리) |
|
||||
| 로딩/오류/빈값 | Skeleton / 오류 문구 / "등록된 조직/사용자가 없습니다." |
|
||||
| 검색 결과 없음 | 필터 후 매칭 노드·사용자 0건. "조회된 인원이 없습니다." 문구 노출 |
|
||||
|
||||
### 4-2. 조직도 데이터 매핑
|
||||
|
||||
| UI 표시 | 트리 DTO 필드 (`OgnzUserTreeResponseDto`) | 비고 |
|
||||
| --------------- | ----------------------------------------- | ----------------------------------------- |
|
||||
| 조직명 | `ognzNm` | 접기/펼치기 노드 |
|
||||
| 사용자명 | `users[].userNm` | 클릭 시 선택 |
|
||||
| 부서(안내 문구) | `ognzNm` (소속 팀 노드) | 선택 시 `selectedPerson.ognzNm` |
|
||||
| 직급(안내 문구) | `users[].jbgdSeCd` 또는 `jbpsSeCd` | 공통코드 라벨 변환 |
|
||||
| 저장용 조직 SN | `ognzSn` | 사용자가 속한 **리프/팀 노드**의 `ognzSn` |
|
||||
| 저장용 사용자 | `users[].userUnqId` | API body `userUuid`로 전달 (§6-3) |
|
||||
|
||||
### 4-3. 조직도 검색 (영역 A-1)
|
||||
|
||||
결재선 관리 `AprvrPickerDialog`와 동일한 **클라이언트 사이드 필터** 패턴을 사용한다. 별도 검색 API는 없으며, `useOgnzUserTree`로 받은 **원본 트리**를 `filterOgnzUserTree`로 가공해 `displayTree`에 반영한다.
|
||||
|
||||
#### 필드 정의
|
||||
|
||||
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
|
||||
| -------------- | --------- | ---- | ------ | ----------------- | ----------------------------------------- |
|
||||
| 성명·부서 검색 | Text | N | (빈값) | — (클라이언트만) | `placeholder="성명·부서 검색"` |
|
||||
| 조회 | Button | — | — | — | `type="submit"` — 폼 제출 시 필터 적용 |
|
||||
| 초기화 | Button | — | — | — | `type="button"` — 키워드·트리·펼침 초기화 |
|
||||
|
||||
#### 필터 규칙 (`filterOgnzUserTree`)
|
||||
|
||||
| 대상 | 매칭 조건 |
|
||||
| ---------------- | ----------------------------------------------------- |
|
||||
| 조직명 | `ognzNm`에 키워드 포함 (대소문자 무시) |
|
||||
| 사용자명 | `userNm`에 키워드 포함 (대소문자 무시) |
|
||||
| 조직 매칭 시 | 해당 노드 **소속 사용자 전원** 유지 |
|
||||
| 사용자만 매칭 시 | 매칭 사용자만 남기고 상위 조직 노드는 유지(경로 보존) |
|
||||
| 하위 조직 | 재귀 필터 — 자식·손자 노드 매칭 시 상위도 표시 |
|
||||
| 키워드 빈값 | 원본 트리 전체 반환 |
|
||||
|
||||
#### 조회 후 트리 펼침 (`collectExpandedOgnzSnIds`)
|
||||
|
||||
- **[조회]** 성공 후 필터 결과 트리의 **모든 노드** `ognzSn`을 `expandedOgnzSnIds`에 넣어 자동 펼침
|
||||
- 검색 결과에서 사용자·하위 조직을 바로 확인할 수 있도록 함
|
||||
|
||||
#### 초기화 동작
|
||||
|
||||
| 상태 | 초기화 후 값 |
|
||||
| ------------------- | --------------------------------------- |
|
||||
| `keyword` | `""` |
|
||||
| `displayTree` | `useOgnzUserTree` 원본 `data` |
|
||||
| `expandedOgnzSnIds` | `new Set()` (전체 접힘) |
|
||||
| `selectedPerson` | **유지** — 검색 초기화와 선택 해제 분리 |
|
||||
|
||||
#### 버튼
|
||||
|
||||
| 버튼명 | 동작 |
|
||||
| ------ | --------------------------------------------------------- |
|
||||
| 조회 | `filterOgnzUserTree(data, keyword)` → `displayTree` 갱신 |
|
||||
| 초기화 | 키워드·표시 트리·펼침 상태 리셋 (§3-2 `ON [초기화]` 블록) |
|
||||
|
||||
---
|
||||
|
||||
## 5. 영역 B — 인원 배치
|
||||
|
||||
### 5-1. 블록 공통
|
||||
|
||||
| 요소 | 내용 |
|
||||
| ------------------ | ------------------------------------------------- |
|
||||
| 블록 제목 | 접수 / 검토 / 승인 / 최종승인 |
|
||||
| 건수 뱃지 | 해당 `atrzSeComCd`에 배치된 인원 수 |
|
||||
| 액션 버튼 | **[선택 인원 배치]** — 클릭 시 즉시 `save` (§3-2) |
|
||||
| 빈 상태 | "배치된 인원이 없습니다." |
|
||||
| 배치 목록(있을 때) | 인원명·부서·직급 등 |
|
||||
|
||||
### 5-2. 결재구분 ↔ 블록 매핑
|
||||
|
||||
| 블록 | UI 제목 | `atrzSeComCd` | 비고 |
|
||||
| ---- | -------- | ------------- | -------------------------------------------------------------- |
|
||||
| B-1 | 접수 | `ATRZ00` | 결재선 관리 `ATRZ_RECEIVER_CD`와 동일 |
|
||||
| B-2 | 검토 | `ATRZ01` | 결재선 `ATRZ_REVIEWER_1_CD`. `ATRZ02` 별도 블록 여부 — **TBD** |
|
||||
| B-3 | 승인 | `ATRZ03` | 결재선 `ATRZ_APPROVER_CD` |
|
||||
| B-4 | 최종승인 | `ATRZ99` | 결재선 `ATRZ_FINAL_CD` |
|
||||
|
||||
공통코드 마스터 `ATRZSE` / 화면 셀렉트용 `ATRZSECD`와 정합성은 배포 환경 코드표로 최종 확인.
|
||||
|
||||
### 5-3. 배치 목록 항목 (표시)
|
||||
|
||||
| 표시명 | Field (응답 DTO) | 비고 |
|
||||
| -------- | ---------------- | ---------------------------- |
|
||||
| 사용자명 | `userUuidNm` | `@CodeName` 미적용 시 TBD |
|
||||
| 결재구분 | `atrzSeComCdNm` | 블록 제목과 중복 — 생략 가능 |
|
||||
| 조직 SN | `ognzSn` | 삭제 API path에 사용 |
|
||||
| 사용자 | `userUuid` | 삭제 API path에 사용 |
|
||||
|
||||
### 5-4. 버튼
|
||||
|
||||
| 버튼명 | 위치 | 동작 |
|
||||
| -------------- | ----------- | --------------------------------- |
|
||||
| 선택 인원 배치 | 각 B-k 헤더 | `save` 후 목록·뱃지 갱신 (§3-2) |
|
||||
| 제거 | 목록 항목 | `delete` 후 목록·뱃지 갱신 (§3-2) |
|
||||
|
||||
### 5-5. 검증·제약
|
||||
|
||||
| 규칙 | 내용 |
|
||||
| ------------- | ------------------------------------------------------------------- |
|
||||
| 선택 필수 | `[선택 인원 배치]` 시 `selectedPerson` 없으면 버튼 비활성 또는 안내 |
|
||||
| 단일 선택 | 조직도에서 사용자 1명만 `selectedPerson` 유지 |
|
||||
| 중복 등록 | 동일 PK `(atrzSeComCd, ognzSn, userUuid)` 재등록 시 서버 오류 |
|
||||
| 다건 배치 | 동일 블록에 서로 다른 사용자·조직은 **복수 행** 가능 (PK가 다름) |
|
||||
| 결재구분 변경 | 본 화면 주 흐름은 `save`만 |
|
||||
|
||||
---
|
||||
|
||||
## 6. API/DTO 명세
|
||||
|
||||
### 6-1. 공통
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ------------- | ------------------------------------------------------------- |
|
||||
| 인증 | JWT (`Authorization` 헤더) |
|
||||
| 페이징 | Spring `Pageable`: `page`, `size`, `sort` → `PageResponse<T>` |
|
||||
| 프론트 엔티티 | `@/entities/user-organization`, `@/entities/rvw-mnpw` |
|
||||
|
||||
### 6-2. 조직·사용자 트리 (영역 A) — `user-ognz`
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ----------- | ------------------------------- |
|
||||
| 서비스 | `cms-service` |
|
||||
| Base prefix | `/cms-service/api/v1/user-ognz` |
|
||||
|
||||
| 기능 | Method | URL | Request DTO | Response DTO | API |
|
||||
| ---------------- | ------ | ----------- | ----------- | --------------------------- | ---------------------------------- |
|
||||
| 조직·사용자 트리 | GET | `/treeuser` | — | `OgnzUserTreeResponseDto[]` | `userOgnzService.findOgnzUserTree` |
|
||||
|
||||
**`OgnzUserTreeResponseDto`**: `ognzSn`, `upOgnzSn`, `ognzNm`, `users[]`, `children[]`
|
||||
|
||||
**`OgnzUserTreeUserDto`**: `userUnqId`, `userNm`, `jbpsSeCd`, `jbgdSeCd`
|
||||
|
||||
**React Query**: `useOgnzUserTree`
|
||||
|
||||
### 6-3. 검토인력관리 (영역 B) — `rvw-mnpw`
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ----------- | ------------------------------------- |
|
||||
| 서비스 | `as-service` |
|
||||
| Base prefix | `/as-service/api/v1/rvw-mnpw` |
|
||||
| ENDPOINT | `ENDPOINT.AS_COMMON_SERVICE.RVW_MNPW` |
|
||||
|
||||
| 기능 | Method | URL | Request DTO / Query | Response DTO | API |
|
||||
| --------------- | ------ | ------------------------------------ | -------------------------------- | ----------------------------- | ---------------------------- |
|
||||
| 목록 조회 | GET | `/` | `RvwMnpwMngSearchDto` + Pageable | `Page<RvwMnpwMngResponseDto>` | `rvwMnpwMngService.findPage` |
|
||||
| 상세 조회 | GET | `/{atrzSeComCd}/{ognzSn}/{userUuid}` | Path | `RvwMnpwMngResponseDto` | `rvwMnpwMngService.findById` |
|
||||
| 등록(즉시 배치) | POST | `/` | `RvwMnpwMngSaveRequestDto` | `RvwMnpwMngResponseDto` (201) | `rvwMnpwMngService.save` |
|
||||
| 결재구분 변경 | PUT | `/{atrzSeComCd}/{ognzSn}/{userUuid}` | `RvwMnpwMngUpdateRequestDto` | `RvwMnpwMngResponseDto` | `rvwMnpwMngService.update` |
|
||||
| 삭제 | DELETE | `/{atrzSeComCd}/{ognzSn}/{userUuid}` | Path | `string` | `rvwMnpwMngService.delete` |
|
||||
|
||||
**`RvwMnpwMngSearchDto`**: `atrzSeComCd?`, `ognzSn?`, `userUuid?` + `page`, `size`, `sort`
|
||||
|
||||
**`RvwMnpwMngSaveRequestDto`**: `atrzSeComCd*`, `ognzSn*`, `userUuid*`
|
||||
|
||||
**`RvwMnpwMngUpdateRequestDto`**: `newAtrzSeComCd*` — 본 화면 기본 흐름에서는 미사용
|
||||
|
||||
**`RvwMnpwMngResponseDto`**: `atrzSeComCd`, `atrzSeComCdNm?`, `ognzSn`, `userUuid`, `userUuidNm?`, `rgtr?`, `regDt?`
|
||||
|
||||
### 6-4. 조직도 ↔ 검토인력 필드 매핑 (저장 시)
|
||||
|
||||
| 조직도 (`OgnzUserTreeUserDto` + 노드) | `RvwMnpwMngSaveRequestDto` | 비고 |
|
||||
| ------------------------------------- | -------------------------- | --------------------- |
|
||||
| (블록 k의 `atrzSeComCd`) | `atrzSeComCd` | §5-2 표 |
|
||||
| 소속 팀 `ognzSn` | `ognzSn` | |
|
||||
| `userUnqId` | `userUuid` | 문자열(UUID)로 직렬화 |
|
||||
|
||||
---
|
||||
|
||||
## 7. UI 구현 가이드
|
||||
|
||||
### 7-1. 기술 스택·원칙
|
||||
|
||||
- **공통 컴포넌트**: `@common/components` (`Button`, `Badge`, `Card` 또는 섹션 레이아웃)
|
||||
- **데이터**: React Query — `useOgnzUserTree`, `useRvwMnpwMngPage`, `useSaveRvwMnpwMng`, `useDeleteRvwMnpwMng`
|
||||
- **상태**: `selectedPerson`(단일), `keyword`, `displayTree`, `expandedOgnzSnIds`, 블록별 배치 목록(서버 데이터 파생)
|
||||
- **조직도 검색**: `filterOgnzUserTree`, `collectExpandedOgnzSnIds` (`widgets/ognz-user-tree/lib` 또는 페이지 `lib`)
|
||||
- **저장 UX**: 별도 화면 저장 없음 — **[선택 인원 배치]** = `save` mutation 즉시 호출
|
||||
- **조직도 UI**: 결재선 관리 `ognz-user-tree.tsx`를 **widgets**로 승격해 공통화
|
||||
|
||||
### 7-2. 모듈·파일 배치(제안)
|
||||
|
||||
| 구분 | 경로 |
|
||||
| -------- | ---------------------------------------------------------------------------------- |
|
||||
| 라우트 | `apps/as/app/(with-base-layout)/rvw-mnpw-mng/page.tsx` |
|
||||
| 페이지 | `apps/as/src/pages/rvw-mnpw-mng/` |
|
||||
| UI | `.../ui/rvw-mnpw-mng-page.tsx`, `rvw-mnpw-assignment-panel.tsx` 등 |
|
||||
| model | `.../model/selected-ognz-user.ts`, `rvw-mnpw-block-config.ts` (`atrzSeComCd` 상수) |
|
||||
| lib | `.../lib/group-rvw-mnpw-by-atrz-se.ts` (목록 → B-1~B-4 분배) |
|
||||
| **위젯** | `apps/as/src/widgets/ognz-user-tree/` — 아래 이전 대상 |
|
||||
| 엔티티 | `entities/user-organization/`, `entities/rvw-mnpw/` (구현 완료) |
|
||||
|
||||
#### widgets/ognz-user-tree 이전 (결재선 → 공통)
|
||||
|
||||
| 현재 (결재선 관리) | 이전 후 (widgets) |
|
||||
| ------------------------------------------------------ | ------------------------------------------------------------------------------------ |
|
||||
| `pages/approval-line-management/ui/ognz-user-tree.tsx` | `widgets/ognz-user-tree/ui/ognz-user-tree.tsx` |
|
||||
| `pages/.../ui/ognz-user-tree-node.tsx` | `widgets/ognz-user-tree/ui/ognz-user-tree-node.tsx` |
|
||||
| `pages/.../model/reviewer-option.ts` | `widgets/ognz-user-tree/model/selected-ognz-user.ts` (이름·필드 확장: `ognzSn` 필수) |
|
||||
| `pages/.../lib/filter-ognz-user-tree.ts` | `widgets/ognz-user-tree/lib/filter-ognz-user-tree.ts` (**필수** — 검색 공통화) |
|
||||
| `widgets/ognz-user-tree/index.ts` | `OgnzUserTree`, `OgnzUserTreeNode`, 타입 export |
|
||||
|
||||
결재선 `AprvrPickerDialog`는 `@/widgets/ognz-user-tree` import로 교체. 검토인력관리 페이지는 **인라인 조직도**(다이얼로그 없음)로 동일 위젯 사용.
|
||||
|
||||
**`SelectedOgnzUser`** — `ReviewerOption` 확장:
|
||||
|
||||
```typescript
|
||||
export interface SelectedOgnzUser {
|
||||
userUuid: string; // OgnzUserTreeUserDto.userUnqId
|
||||
userNm: string;
|
||||
ognzSn: number;
|
||||
ognzNm: string;
|
||||
jbgdSeCd?: string;
|
||||
jbpsSeCd?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 7-3. 영역별 UI 매핑
|
||||
|
||||
| 영역 | 주요 UI |
|
||||
| ---- | -------------------------------------------------------------------- |
|
||||
| A-1 | 검색 폼 (`Input` + 조회/초기화) — `AprvrPickerDialog` 90~103행 패턴 |
|
||||
| A-2 | `OgnzUserTree(nodes={displayTree})` + 선택 안내 + `useOgnzUserTree` |
|
||||
| B-k | 제목·Badge(count)·`선택 인원 배치`·배치 목록(또는 empty) |
|
||||
| 공통 | 페이지 헤더·브레드크럼·2열 레이아웃 (`grid` / `flex`, 모바일은 스택) |
|
||||
|
||||
### 7-4. 다이얼로그·라우팅
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ---------- | ----------------------------------- |
|
||||
| 다이얼로그 | **없음** (조직도 인라인, 즉시 저장) |
|
||||
| 라우팅 | 메뉴 `검토인력관리` 단일 페이지 |
|
||||
|
||||
### 7-5. 데이터 흐름(구현 요약)
|
||||
|
||||
- **진입**: `useOgnzUserTree` → `displayTree = data` + `useRvwMnpwMngPage({ page: 0, size: N })` → 4패널.
|
||||
- **검색**: `keyword` 로컬 상태 → **[조회]** 시 `filterOgnzUserTree` + `collectExpandedOgnzSnIds` → `OgnzUserTree`에 `displayTree` 전달.
|
||||
- **초기화**: `keyword`·`displayTree`·`expandedOgnzSnIds` 리셋 (`selectedPerson` 유지).
|
||||
- **선택**: 트리 `onSelect` → `selectedPerson` (반드시 `ognzSn` 포함).
|
||||
- **배치**: `useSaveRvwMnpwMng().mutate({ atrzSeComCd, ognzSn, userUuid })` → `onSuccess`에서 `rvw-mnpw` query invalidate.
|
||||
- **삭제**: 목록 항목에서 `useDeleteRvwMnpwMng` — UI 확정 후 연결.
|
||||
|
||||
### 7-6. 미확정 / 검증 필요
|
||||
|
||||
- [ ] `ATRZ02`(검토2) 별도 블록 필요 여부
|
||||
- [ ] 권한(역할별 CRUD)
|
||||
@@ -0,0 +1,33 @@
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import baseEsLintConfig from "../../eslint.config.mjs";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
...baseEsLintConfig,
|
||||
{
|
||||
rules: {
|
||||
"react-hooks/exhaustive-deps": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
caughtErrorsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-wrapper-object-types": "warn",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { AUTH_USER_ID, CLAIM_NAME, PORT, PROXY_HOST, SERVER_API_URL, SITE_ID } from "@/shared/config";
|
||||
|
||||
const serverApiUrl = process.env.SERVER_API_URL;
|
||||
|
||||
// CSP 헤더 설정 함수 (img-src만)
|
||||
function addSecurityHeaders(response: NextResponse): NextResponse {
|
||||
response.headers.set("Content-Security-Policy", "img-src 'self' data: https: blob:;");
|
||||
return response;
|
||||
}
|
||||
|
||||
export function middleware(req: NextRequest) {
|
||||
const pathname = req.nextUrl.pathname;
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
|
||||
const logMessages: string[] = [
|
||||
`[Middleware Request] ${new Date().toISOString()}`,
|
||||
`[Environment]\n Mode: ${process.env.NODE_ENV}\n Port: ${PORT}\n Proxy Host: ${PROXY_HOST}\n Server API URL: ${SERVER_API_URL}\n Site ID: ${SITE_ID}`,
|
||||
`[Request]\n Original Pathname: ${pathname}`,
|
||||
];
|
||||
|
||||
if (pathname.startsWith("/server")) {
|
||||
const queryParams = searchParams.toString();
|
||||
const newUrl = `${serverApiUrl}${pathname.replace("/server", "")}${
|
||||
queryParams ? `?${queryParams}` : ""
|
||||
}`;
|
||||
|
||||
logMessages.push(` Rewritten URL: ${newUrl}`);
|
||||
logMessages.push("\n--------------------------------\n");
|
||||
console.log(logMessages.join("\n"));
|
||||
|
||||
const response = NextResponse.rewrite(new URL(newUrl));
|
||||
return addSecurityHeaders(response);
|
||||
}
|
||||
|
||||
|
||||
// tus-js-client PATCH 요청: Location 헤더로 /server 없이 반환된 경로 직접 처리
|
||||
// (예: /attach-service/api/v1/tus/upload/{id})
|
||||
if (pathname.startsWith("/attach-service")) {
|
||||
const requestHeaders = new Headers(req.headers);
|
||||
const token = req.cookies.get(CLAIM_NAME)?.value;
|
||||
const userId = req.cookies.get(AUTH_USER_ID)?.value;
|
||||
const newUrl = `${serverApiUrl}${pathname}`;
|
||||
|
||||
if (token) requestHeaders.set(CLAIM_NAME, token);
|
||||
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
|
||||
|
||||
logMessages.push(` [TUS Proxy] Rewritten URL: ${newUrl}`);
|
||||
logMessages.push("\n--------------------------------\n");
|
||||
console.log(logMessages.join("\n"));
|
||||
|
||||
const response = NextResponse.rewrite(new URL(newUrl), {
|
||||
headers: requestHeaders,
|
||||
});
|
||||
return addSecurityHeaders(response);
|
||||
}
|
||||
|
||||
// if (pathname === "/") {
|
||||
// const response = NextResponse.redirect(new URL("/dashboard", req.url));
|
||||
// return addSecurityHeaders(response);
|
||||
// }
|
||||
|
||||
logMessages.push("\n--------------------------------\n");
|
||||
console.log(logMessages.join("\n"));
|
||||
|
||||
const response = NextResponse.next();
|
||||
return addSecurityHeaders(response);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/server/:path*", "/attach-service/:path*", "/"],
|
||||
};
|
||||
Vendored
-5
@@ -1,5 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
reactStrictMode: true,
|
||||
transpilePackages: ["@common/assets"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "as",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3003",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3003",
|
||||
"lint": "next lint",
|
||||
"lint:fsd": "steiger ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@common/assets": "workspace:*",
|
||||
"@common/auth": "workspace:*",
|
||||
"@common/components": "workspace:*",
|
||||
"@common/network": "workspace:*",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@faker-js/faker": "^9.9.0",
|
||||
"@frontend-opensource/use-react-hooks": "^1.1.4",
|
||||
"@hookform/resolvers": "^4.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.75.5",
|
||||
"@tanstack/react-query-devtools": "^5.75.5",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
"axios": "^1.8.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.474.0",
|
||||
"next": "15.5.18",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"tus-js-client": "^4.3.1",
|
||||
"uuid": "^14.0.1",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-next": "15.3.8",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"steiger": "^0.5.11",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# 이 폴더는 비워 둡니다 (FSD + Next.js App Router)
|
||||
|
||||
Next.js가 `src/pages`를 **Pages Router**용 `pages` 디렉터리로 인식하지 않도록,
|
||||
프로젝트 루트에 **빈 `pages` 폴더**를 두는 것이 FSD 공식 가이드 권장사항입니다.
|
||||
|
||||
- 실제 페이지(FSD Pages 레이어)는 **`src/pages`**에 있습니다.
|
||||
- Next.js 라우트는 **`app/`** 에서 `src/pages`를 re-export 하는 방식으로 연결됩니다.
|
||||
|
||||
[Feature-Sliced Design - Usage with Next.js (App Router)](https://feature-sliced.design/docs/guides/tech/with-nextjs)
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useMenuStore } from "@/shared/lib";
|
||||
import { PageLayout } from "@/shared/ui/page-layout";
|
||||
import { authService } from "@/entities/auth";
|
||||
import type { MenuItem } from "@/entities/menu";
|
||||
import { BaseLayout } from "@common/components";
|
||||
|
||||
interface BaseLayoutWrapperProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function BaseLayoutWrapper({ children }: BaseLayoutWrapperProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const setCurrentMenuInfo = useMenuStore((state) => state.setCurrentMenuInfo);
|
||||
|
||||
const handleMenuClick = useCallback(
|
||||
(item: MenuItem) => {
|
||||
setCurrentMenuInfo(item);
|
||||
router.push(item.urlPath);
|
||||
},
|
||||
[router, setCurrentMenuInfo],
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseLayout
|
||||
queryClient={queryClient}
|
||||
authService={authService}
|
||||
currentPath={pathname ?? ""}
|
||||
handleMenuClick={handleMenuClick}
|
||||
>
|
||||
<PageLayout>{children}</PageLayout>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { ConfirmProvider } from "@frontend-opensource/use-react-hooks";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
|
||||
import { queryClient } from "@/shared/lib";
|
||||
import { StoreProvider } from "@/shared/providers";
|
||||
import { AlertDialog } from "@/shared/ui/alert-dialog";
|
||||
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
|
||||
import { AuthWrapper } from "@/entities/auth";
|
||||
import { MenuWrapper } from "@/entities/menu";
|
||||
|
||||
interface ProvidersProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Providers({ children }: ProvidersProps) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<StoreProvider>
|
||||
<ConfirmProvider>
|
||||
<AuthWrapper>
|
||||
<MenuWrapper>{children}</MenuWrapper>
|
||||
<AlertDialog />
|
||||
<ConfirmDialog />
|
||||
</AuthWrapper>
|
||||
</ConfirmProvider>
|
||||
</StoreProvider>
|
||||
<div style={{ fontSize: "1.4rem" }}>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
@import "@common/assets/styles/base.css";
|
||||
@import "@common/assets/styles/diveicon.css";
|
||||
@import "@common/components/index.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html,
|
||||
body {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
font-family: "Noto Sans KR", sans-serif;
|
||||
font-size: 10px;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
# Entities Layer
|
||||
|
||||
**Entities**는 프로젝트가 다루는 실제 비즈니스 개념을 나타내는 레이어입니다. 비즈니스에서 제품을 설명할 때 사용하는 용어들(예: User, Product, Order)이 주로 엔티티가 됩니다.
|
||||
|
||||
## 목적
|
||||
|
||||
- 비즈니스 엔티티의 데이터 모델 및 타입 정의
|
||||
- 엔티티 관련 API 요청 함수 관리
|
||||
- 엔티티의 시각적 표현 제공 (재사용 가능한 UI)
|
||||
- 여러 페이지에서 동일한 외관을 재사용할 수 있도록 지원
|
||||
|
||||
## 구조
|
||||
|
||||
각 엔티티 슬라이스는 다음 세그먼트로 구성됩니다:
|
||||
|
||||
```
|
||||
entities/
|
||||
└── [entity-name]/
|
||||
├── model/ # 데이터 저장소, 검증 스키마, 상태 관리
|
||||
├── api/ # 엔티티 관련 API 요청 함수
|
||||
├── ui/ # 엔티티 시각적 표현 (선택적)
|
||||
├── lib/ # 엔티티 관련 유틸리티 (선택적)
|
||||
├── @x/ # 다른 엔티티로부터의 cross-import용 Public API (선택적)
|
||||
└── index.ts # Public API
|
||||
```
|
||||
|
||||
### 세그먼트 설명
|
||||
|
||||
- **model**: 데이터 모델, 타입 정의, 상태 저장소 (Zustand 등)
|
||||
- **api**: 백엔드 API 호출 함수
|
||||
- **ui**: 엔티티를 표시하는 UI 컴포넌트 (완전한 UI 블록이 아니라 재사용 가능한 표현)
|
||||
- **lib**: 엔티티 관련 유틸리티 함수
|
||||
- **@x**: 다른 엔티티가 이 엔티티를 import할 때 사용하는 명시적 cross-import API
|
||||
- **index.ts**: 슬라이스의 Public API (외부에서 사용할 수 있는 것만 export)
|
||||
|
||||
## 예시
|
||||
|
||||
```typescript
|
||||
// entities/user/model/user.ts
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: 'admin' | 'user';
|
||||
}
|
||||
|
||||
// entities/user/api/get-user.ts
|
||||
import type { User } from '../model/user';
|
||||
|
||||
export async function getUser(id: string): Promise<User> {
|
||||
const response = await fetch(`/api/users/${id}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// entities/user/ui/user-card.tsx
|
||||
import type { User } from '../model/user';
|
||||
|
||||
interface UserCardProps {
|
||||
user: User;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function UserCard({ user, onClick }: UserCardProps) {
|
||||
return (
|
||||
<div onClick={onClick}>
|
||||
<h3>{user.name}</h3>
|
||||
<p>{user.email}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// entities/user/index.ts
|
||||
export { type User } from './model/user';
|
||||
export { getUser } from './api/get-user';
|
||||
export { UserCard } from './ui/user-card';
|
||||
```
|
||||
|
||||
## 엔티티 간 관계
|
||||
|
||||
엔티티가 서로 연결되어 있을 때는 `@x` 표기법을 사용하여 명시적으로 cross-import를 관리하는 것이 권장됩니다:
|
||||
|
||||
```typescript
|
||||
// entities/artist/model/artist.ts
|
||||
import type { Song } from 'entities/song/@x/artist';
|
||||
|
||||
export interface Artist {
|
||||
name: string;
|
||||
songs: Array<Song>;
|
||||
}
|
||||
|
||||
// entities/song/@x/artist.ts
|
||||
export type { Song } from '../model/song';
|
||||
```
|
||||
|
||||
> `@x` 폴더를 사용하면 엔티티 간 연결을 명확하게 만들어 함께 리팩터링이 필요한 코드를 쉽게 찾을 수 있습니다.
|
||||
|
||||
## 레이어 의존성 규칙
|
||||
|
||||
✅ **허용되는 의존성**:
|
||||
- `shared` 레이어의 모듈
|
||||
- 같은 엔티티 슬라이스 내의 다른 세그먼트
|
||||
- 다른 엔티티 슬라이스 (필요한 경우, `@x` 표기법 권장)
|
||||
|
||||
❌ **금지된 의존성**:
|
||||
- `features` 레이어
|
||||
- `widgets` 레이어
|
||||
- `pages` 레이어
|
||||
- `app` 레이어
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **UI 컴포넌트는 완전한 비즈니스 로직을 포함하지 않음**: UI는 주로 props나 slots를 통해 다양한 비즈니스 로직을 연결할 수 있도록 설계
|
||||
2. **엔티티 간 비즈니스 로직은 상위 레이어에**: 엔티티 간 상호작용의 비즈니스 로직은 Features나 Pages 레이어에서 처리
|
||||
3. **데이터 관리에 집중**: 복잡한 비즈니스 로직보다는 데이터 모델, 검증, 저장에 집중
|
||||
@@ -0,0 +1,58 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
|
||||
import {
|
||||
ACNTG_SE_SAVE_MODE,
|
||||
type AcntgSeMngPutRequestDto,
|
||||
type AcntgSeMngResponseDto,
|
||||
type AcntgSeMngSaveRequestDto,
|
||||
type AcntgSeMngSearchDto,
|
||||
type AcntgSeMngService,
|
||||
type AcntgSeMngTreeDto,
|
||||
} from "../model/acntg-interface";
|
||||
|
||||
const BASE = ENDPOINT.AS_SERVICE.ACNTG_SE;
|
||||
|
||||
async function findAll(params: AcntgSeMngSearchDto): Promise<PageResponse<AcntgSeMngResponseDto>> {
|
||||
const response = await client.get<PageResponse<AcntgSeMngResponseDto>>(BASE, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findTree(): Promise<AcntgSeMngTreeDto[]> {
|
||||
const response = await client.get<AcntgSeMngTreeDto[]>(`${BASE}/list/tree`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findById(acntgSeCd: string): Promise<AcntgSeMngResponseDto> {
|
||||
const response = await client.get<AcntgSeMngResponseDto>(`${BASE}/${acntgSeCd}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function saveMulti(body: AcntgSeMngSaveRequestDto[]): Promise<AcntgSeMngResponseDto[]> {
|
||||
const response = await client.post<AcntgSeMngResponseDto[]>(BASE, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function update(acntgSeCd: string, body: AcntgSeMngPutRequestDto): Promise<string> {
|
||||
const response = await client.put<string>(`${BASE}/${acntgSeCd}`, {
|
||||
...body,
|
||||
acntgSeCd,
|
||||
mode: ACNTG_SE_SAVE_MODE.UPDATE,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function deleteAcntgSeCd(acntgSeCd: string): Promise<string> {
|
||||
const response = await client.delete<string>(`${BASE}/${acntgSeCd}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const acntgSeMngService = {
|
||||
findAll,
|
||||
findTree,
|
||||
findById,
|
||||
saveMulti,
|
||||
update,
|
||||
delete: deleteAcntgSeCd,
|
||||
} satisfies AcntgSeMngService;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type {
|
||||
AcntgSeMngPutRequestDto,
|
||||
AcntgSeMngSaveRequestDto,
|
||||
AcntgSeMngSearchDto,
|
||||
} from "../model/acntg-interface";
|
||||
|
||||
import { acntgSeMngService } from "./acntg-service";
|
||||
|
||||
const acntgSeMngBaseKey = [ENDPOINT.AS_SERVICE.ACNTG_SE] as const;
|
||||
|
||||
export function useAcntgSeMngPage(params: AcntgSeMngSearchDto) {
|
||||
return useQuery({
|
||||
queryKey: [...acntgSeMngBaseKey, "findAll", params],
|
||||
queryFn: () => acntgSeMngService.findAll(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAcntgSeMngTree() {
|
||||
return useQuery({
|
||||
queryKey: [...acntgSeMngBaseKey, "findTree"],
|
||||
queryFn: () => acntgSeMngService.findTree(),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAcntgSeMng(acntgSeCd: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...acntgSeMngBaseKey, "findById", acntgSeCd],
|
||||
queryFn: () => acntgSeMngService.findById(acntgSeCd!),
|
||||
enabled: Boolean(acntgSeCd),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveMultiAcntgSeMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (body: AcntgSeMngSaveRequestDto[]) => acntgSeMngService.saveMulti(body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: acntgSeMngBaseKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAcntgSeMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ acntgSeCd, body }: { acntgSeCd: string; body: AcntgSeMngPutRequestDto }) =>
|
||||
acntgSeMngService.update(acntgSeCd, body),
|
||||
onSuccess: (_data, { acntgSeCd }) => {
|
||||
queryClient.invalidateQueries({ queryKey: acntgSeMngBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...acntgSeMngBaseKey, "findById", acntgSeCd],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAcntgSeMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (acntgSeCd: string) => acntgSeMngService.delete(acntgSeCd),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: acntgSeMngBaseKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export { acntgSeMngService } from "./api/acntg-service";
|
||||
export {
|
||||
useAcntgSeMng,
|
||||
useAcntgSeMngPage,
|
||||
useAcntgSeMngTree,
|
||||
useDeleteAcntgSeMng,
|
||||
useSaveMultiAcntgSeMng,
|
||||
useUpdateAcntgSeMng,
|
||||
} from "./api/queries";
|
||||
export type {
|
||||
AcntgSeMngResponseDto,
|
||||
AcntgSeMngSaveRequestDto,
|
||||
AcntgSeMngSearchDto,
|
||||
AcntgSeMngService,
|
||||
AcntgSeMngTreeDto,
|
||||
AcntgSeSaveMode,
|
||||
BizCstTreeDto,
|
||||
} from "./model/acntg-interface";
|
||||
export { ACNTG_SE_SAVE_MODE } from "./model/acntg-interface";
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { PageableParams, PageResponse } from "@/shared/model";
|
||||
|
||||
export const ACNTG_SE_SAVE_MODE = {
|
||||
CREATE: "C",
|
||||
UPDATE: "U",
|
||||
DELETE: "D",
|
||||
} as const;
|
||||
|
||||
export type AcntgSeSaveMode = (typeof ACNTG_SE_SAVE_MODE)[keyof typeof ACNTG_SE_SAVE_MODE];
|
||||
|
||||
/** 백엔드 AcntgSeMngResponseDto */
|
||||
export interface AcntgSeMngResponseDto {
|
||||
acntgSeCd: string;
|
||||
acntgSeCdNm: string;
|
||||
upAcntgSeCd?: string;
|
||||
cdLvNo: number;
|
||||
useYn: string;
|
||||
expln?: string;
|
||||
sortSeq?: number;
|
||||
rgtr?: string;
|
||||
rgtrNm?: string;
|
||||
mdfr?: string;
|
||||
regDt?: string;
|
||||
mdfcnDt?: string;
|
||||
}
|
||||
|
||||
/** 백엔드 AcntgSeMngSearchDto + Pageable */
|
||||
export interface AcntgSeMngSearchDto extends PageableParams {
|
||||
acntgSeCd?: string;
|
||||
acntgSeCdNm?: string;
|
||||
useYn?: string;
|
||||
}
|
||||
|
||||
/** 백엔드 AcntgSeMngSaveRequestDto */
|
||||
export interface AcntgSeMngSaveRequestDto {
|
||||
acntgSeCd: string;
|
||||
mode: AcntgSeSaveMode;
|
||||
acntgSeCdNm: string;
|
||||
upAcntgSeCd?: string;
|
||||
cdLvNo: number;
|
||||
useYn?: string;
|
||||
expln?: string;
|
||||
sortSeq?: number;
|
||||
}
|
||||
|
||||
/** PUT /{acntgSeCd} 요청 (mode는 서비스에서 U로 설정) */
|
||||
export interface AcntgSeMngPutRequestDto {
|
||||
acntgSeCd: string;
|
||||
acntgSeCdNm: string;
|
||||
upAcntgSeCd?: string;
|
||||
cdLvNo: number;
|
||||
useYn?: string;
|
||||
expln?: string;
|
||||
}
|
||||
|
||||
/** 백엔드 AcntgSeMngTreeDto */
|
||||
export interface AcntgSeMngTreeDto {
|
||||
indAcntgSeCdNm: string;
|
||||
acntgSeCd: string;
|
||||
acntgSeCdNm: string;
|
||||
upAcntgSeCd?: string;
|
||||
useYn: string;
|
||||
treeLevel: number;
|
||||
treePath: string;
|
||||
}
|
||||
|
||||
/** 백엔드 BizCstTreeDto (InstlSncnMngDetResponseDto 등에서 참조) */
|
||||
export interface BizCstTreeDto {
|
||||
indAcntgSeCdNm: string;
|
||||
acntgSeCd: string;
|
||||
acntgSeCdNm: string;
|
||||
useYn: string;
|
||||
treeLevel: number;
|
||||
treePath: string;
|
||||
bscPlanAmt?: number;
|
||||
bgtRfltAmt?: number;
|
||||
tstmDesignAmt?: number;
|
||||
}
|
||||
|
||||
/** Controller public 메서드명·시그니처와 동일 */
|
||||
export interface AcntgSeMngService {
|
||||
findAll(params: AcntgSeMngSearchDto): Promise<PageResponse<AcntgSeMngResponseDto>>;
|
||||
findTree(): Promise<AcntgSeMngTreeDto[]>;
|
||||
findById(acntgSeCd: string): Promise<AcntgSeMngResponseDto>;
|
||||
saveMulti(body: AcntgSeMngSaveRequestDto[]): Promise<AcntgSeMngResponseDto[]>;
|
||||
update(acntgSeCd: string, body: AcntgSeMngPutRequestDto): Promise<string>;
|
||||
delete(acntgSeCd: string): Promise<string>;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type {
|
||||
CmCdAdstDstrComboResponseDto,
|
||||
CmCdAdstDstrService,
|
||||
} from "../model/adstdstr-interface";
|
||||
|
||||
const BASE = ENDPOINT.AS_SERVICE.ADSTDSTR;
|
||||
|
||||
async function findSidoCombo(): Promise<CmCdAdstDstrComboResponseDto[]> {
|
||||
const response = await client.get<CmCdAdstDstrComboResponseDto[]>(`${BASE}/sido`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findGugunCombo(sido: string): Promise<CmCdAdstDstrComboResponseDto[]> {
|
||||
const response = await client.get<CmCdAdstDstrComboResponseDto[]>(`${BASE}/gugun`, {
|
||||
params: { sido },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const cmCdAdstDstrService = {
|
||||
findSidoCombo,
|
||||
findGugunCombo,
|
||||
} satisfies CmCdAdstDstrService;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import { cmCdAdstDstrService } from "./adstdstr-service";
|
||||
|
||||
const cmCdAdstDstrBaseKey = [ENDPOINT.AS_SERVICE.ADSTDSTR] as const;
|
||||
|
||||
export function useFindSidoCombo() {
|
||||
return useQuery({
|
||||
queryKey: [...cmCdAdstDstrBaseKey, "findSidoCombo"],
|
||||
queryFn: () => cmCdAdstDstrService.findSidoCombo(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useFindGugunCombo(sido: string) {
|
||||
return useQuery({
|
||||
queryKey: [...cmCdAdstDstrBaseKey, "findGugunCombo", sido],
|
||||
queryFn: () => cmCdAdstDstrService.findGugunCombo(sido),
|
||||
enabled: Boolean(sido),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { cmCdAdstDstrService } from "./api/adstdstr-service";
|
||||
export { useFindGugunCombo, useFindSidoCombo } from "./api/queries";
|
||||
export type {
|
||||
CmCdAdstDstrComboResponseDto,
|
||||
CmCdAdstDstrService,
|
||||
} from "./model/adstdstr-interface";
|
||||
@@ -0,0 +1,11 @@
|
||||
/** 백엔드 CmCdAdstDstrComboResponseDto */
|
||||
export interface CmCdAdstDstrComboResponseDto {
|
||||
adstDstrCd: string;
|
||||
adstDstrNm: string;
|
||||
}
|
||||
|
||||
/** Controller public 메서드명·시그니처와 동일 */
|
||||
export interface CmCdAdstDstrService {
|
||||
findSidoCombo(): Promise<CmCdAdstDstrComboResponseDto[]>;
|
||||
findGugunCombo(sido: string): Promise<CmCdAdstDstrComboResponseDto[]>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type { AttachService } from "../model/attach-interface";
|
||||
|
||||
async function deleteAttachmentByAtflId(atflId: string): Promise<void> {
|
||||
if (!atflId) return;
|
||||
await client.delete(`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/${atflId}`);
|
||||
}
|
||||
|
||||
async function downloadAttachmentByAtflCd(atflCd: string): Promise<AxiosResponse> {
|
||||
return await client.get<Blob>(`${ENDPOINT.ATTACH_SERVICE.DOWNLOAD}/${atflCd}`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
}
|
||||
|
||||
export const attachService = {
|
||||
deleteAttachmentByAtflId,
|
||||
downloadAttachmentByAtflCd,
|
||||
} satisfies AttachService;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import { parseFilenameFromResponse } from "../lib/parse-filename-from-response";
|
||||
|
||||
import { attachService } from "./attach-service";
|
||||
|
||||
const attachBaseKey = [ENDPOINT.ATTACH_SERVICE.ATTACHMENTS] as const;
|
||||
const attachDownloadBaseKey = [ENDPOINT.ATTACH_SERVICE.DOWNLOAD] as const;
|
||||
|
||||
export function useDeleteAttachment() {
|
||||
return useMutation({
|
||||
mutationKey: [...attachBaseKey, "delete"],
|
||||
mutationFn: (atflId: string) => attachService.deleteAttachmentByAtflId(atflId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDownloadAttachmentByAtflCd() {
|
||||
return useMutation({
|
||||
mutationKey: [...attachDownloadBaseKey, "download"],
|
||||
mutationFn: async (atflCd: string) => {
|
||||
const response = await attachService.downloadAttachmentByAtflCd(atflCd);
|
||||
const filename = parseFilenameFromResponse(response);
|
||||
|
||||
const blob = new Blob([response.data]);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(link);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { attachService } from "./api/attach-service";
|
||||
export { useDeleteAttachment, useDownloadAttachmentByAtflCd } from "./api/queries";
|
||||
export type { Attach, AttachService } from "./model/attach-interface";
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { AxiosResponse } from "axios";
|
||||
|
||||
export function parseFilenameFromResponse(response: AxiosResponse): string {
|
||||
const contentDisposition = response.headers["content-disposition"];
|
||||
if (!contentDisposition) return "download";
|
||||
|
||||
const filenameMatch = contentDisposition.match(/filename\*=UTF-8''([^;]+)/);
|
||||
if (!filenameMatch?.[1]) return "download";
|
||||
|
||||
return decodeURIComponent(filenameMatch[1]);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
/** 백엔드 AtflMngResponseDto */
|
||||
export interface Attach {
|
||||
atflCd: string;
|
||||
atflSn: number;
|
||||
atflId: string;
|
||||
physFileNm: string;
|
||||
orgnlFileNm: string;
|
||||
atflFlieSz: number;
|
||||
atflType: string;
|
||||
dwnldNocs: number;
|
||||
delYn: string;
|
||||
lnkgDmnId: string;
|
||||
lnkgDmnNm: string;
|
||||
regDt: string;
|
||||
}
|
||||
|
||||
export interface AttachService {
|
||||
deleteAttachmentByAtflId: (atflId: string) => Promise<void>;
|
||||
downloadAttachmentByAtflCd: (atflCd: string) => Promise<AxiosResponse>;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { ACCESS_TOKEN, AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config";
|
||||
import { type AuthPayload, type AuthService, getRemainingMs } from "@common/auth";
|
||||
|
||||
import { clearDeviceId, getOrCreateDeviceId } from "../lib/device-id";
|
||||
import { clearRefreshTimer, setToken, setupRefreshTimer, tokenManager } from "../lib/token-manager";
|
||||
|
||||
async function login(payload: AuthPayload): Promise<void> {
|
||||
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Device-Id": getOrCreateDeviceId(),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const accessToken = data[ACCESS_TOKEN];
|
||||
const tokenId = data[AUTH_USER_ID];
|
||||
|
||||
if (!accessToken || !tokenId) {
|
||||
throw new Error("인증 토큰을 받지 못했습니다.");
|
||||
}
|
||||
|
||||
setToken(CLAIM_NAME, accessToken);
|
||||
setToken(AUTH_USER_ID, tokenId);
|
||||
|
||||
const remainingMs = getRemainingMs(accessToken);
|
||||
if (remainingMs > 0) {
|
||||
setupRefreshTimer(silentRefresh, remainingMs);
|
||||
}
|
||||
}
|
||||
|
||||
async function silentRefresh(): Promise<void> {
|
||||
console.log(`[silentRefresh] Called at ${new Date().toISOString()}`);
|
||||
try {
|
||||
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"X-Device-Id": getOrCreateDeviceId(),
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.message ?? "토큰 갱신에 실패했습니다.");
|
||||
}
|
||||
|
||||
const accessToken = data[ACCESS_TOKEN];
|
||||
const tokenId = data[AUTH_USER_ID];
|
||||
|
||||
if (!accessToken || !tokenId) {
|
||||
throw new Error("갱신된 인증 토큰을 받지 못했습니다.");
|
||||
}
|
||||
|
||||
setToken(CLAIM_NAME, accessToken);
|
||||
setToken(AUTH_USER_ID, tokenId);
|
||||
|
||||
const remainingMs = getRemainingMs(accessToken);
|
||||
if (remainingMs > 0) {
|
||||
setupRefreshTimer(silentRefresh, remainingMs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[silentRefresh] Failed at ${new Date().toISOString()}`, error);
|
||||
tokenManager.clearAuthToken();
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("auth:logout"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
tokenManager.clearAuthToken();
|
||||
clearDeviceId();
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("auth:logout"));
|
||||
}
|
||||
}
|
||||
|
||||
export const authService: AuthService = {
|
||||
login,
|
||||
silentRefresh,
|
||||
declineSessionExtend: clearRefreshTimer,
|
||||
logout,
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { authService } from "./api/auth-service";
|
||||
export { AuthWrapper } from "./lib/auth-wrapper";
|
||||
export { tokenManager } from "./lib/token-manager";
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { isAxiosError } from "axios";
|
||||
|
||||
import { client } from "@/shared/api";
|
||||
import { AUTH_LOGIN_REDIRECT_URL, AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config";
|
||||
import { useUserStore } from "@/shared/lib";
|
||||
import type { IUserInfo } from "@/shared/store";
|
||||
import { getRemainingMs } from "@common/auth";
|
||||
import { isAtLoginRedirectUrl } from "@common/network";
|
||||
|
||||
import { authService } from "../api/auth-service";
|
||||
|
||||
import { getToken, setupRefreshTimer } from "./token-manager";
|
||||
|
||||
const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
const router = useRouter();
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
const setUserInfo = useUserStore((state) => state.setUserInfo);
|
||||
|
||||
useEffect(() => {
|
||||
const token = getToken(CLAIM_NAME);
|
||||
if (token) {
|
||||
const remainingMs = getRemainingMs(token);
|
||||
if (remainingMs > 0) {
|
||||
console.log(`[AuthWrapper] Refresh timer set: ${remainingMs}ms (${remainingMs / 1000}s)`);
|
||||
setupRefreshTimer(authService.silentRefresh, remainingMs);
|
||||
}
|
||||
}
|
||||
|
||||
if (isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) return;
|
||||
|
||||
async function authCheck() {
|
||||
const userId = getToken(AUTH_USER_ID);
|
||||
if (!userId) {
|
||||
router.replace(AUTH_LOGIN_REDIRECT_URL);
|
||||
return;
|
||||
}
|
||||
|
||||
if (userInfo) return;
|
||||
|
||||
const response = await client.get<IUserInfo>(`${ENDPOINT.USER_SERVICE.USERS}/info/me`);
|
||||
setUserInfo(response.data);
|
||||
}
|
||||
|
||||
authCheck().catch((error) => {
|
||||
if (isAxiosError(error) && error.response?.status === 401) return;
|
||||
console.error(error);
|
||||
setUserInfo(null);
|
||||
if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
|
||||
router.replace(AUTH_LOGIN_REDIRECT_URL);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleAuthLogout() {
|
||||
setUserInfo(null);
|
||||
if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
|
||||
router.replace(AUTH_LOGIN_REDIRECT_URL);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("auth:logout", handleAuthLogout);
|
||||
return () => window.removeEventListener("auth:logout", handleAuthLogout);
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export { AuthWrapper };
|
||||
@@ -0,0 +1,17 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
const DEVICE_ID_KEY = "x-device-id";
|
||||
|
||||
export function getOrCreateDeviceId(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
const existing = localStorage.getItem(DEVICE_ID_KEY);
|
||||
if (existing) return existing;
|
||||
const id = uuidv4();
|
||||
localStorage.setItem(DEVICE_ID_KEY, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function clearDeviceId(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.removeItem(DEVICE_ID_KEY);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { authConfig } from "@/shared/config/auth";
|
||||
import { createTokenManager } from "@common/auth";
|
||||
|
||||
export const tokenManager = createTokenManager(authConfig);
|
||||
|
||||
export const {
|
||||
getToken,
|
||||
setToken,
|
||||
removeToken,
|
||||
setupRefreshTimer,
|
||||
clearRefreshTimer,
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
getUserId,
|
||||
} = tokenManager;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import { buildBnsSearchDto } from "../lib/build-bns-search-dto";
|
||||
import type {
|
||||
BnsDetailResponseWrapperDto,
|
||||
BnsIfService,
|
||||
BnsListResponseWrapperDto,
|
||||
BnsSearchDto,
|
||||
EmCodeListResponseDto,
|
||||
} from "../model/bns-if-interface";
|
||||
|
||||
const BASE = ENDPOINT.AS_SERVICE.BNS_IF;
|
||||
|
||||
async function getBnsList(params: BnsSearchDto): Promise<BnsListResponseWrapperDto> {
|
||||
const response = await client.get<BnsListResponseWrapperDto>(`${BASE}/bns`, {
|
||||
params: buildBnsSearchDto(params),
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function getBnsDetail(
|
||||
gbn: string,
|
||||
yr: string,
|
||||
seqNo: string,
|
||||
): Promise<BnsDetailResponseWrapperDto> {
|
||||
const response = await client.get<BnsDetailResponseWrapperDto>(`${BASE}/bns/detail`, {
|
||||
params: { gbn, yr, seqNo },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function getEmCodeList(
|
||||
dpmCd?: string,
|
||||
lclgvrnmntCd?: string,
|
||||
): Promise<EmCodeListResponseDto> {
|
||||
const response = await client.get<EmCodeListResponseDto>(`${BASE}/emCodeList`, {
|
||||
params: { dpmCd, lclgvrnmntCd },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const bnsIfService = {
|
||||
getBnsList,
|
||||
getBnsDetail,
|
||||
getEmCodeList,
|
||||
} satisfies BnsIfService;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type { BnsSearchDto } from "../model/bns-if-interface";
|
||||
|
||||
import { bnsIfService } from "./bns-if-service";
|
||||
|
||||
const bnsIfBaseKey = [ENDPOINT.AS_SERVICE.BNS_IF] as const;
|
||||
|
||||
export function useBnsList(params: BnsSearchDto, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: [...bnsIfBaseKey, "getBnsList", params],
|
||||
queryFn: () => bnsIfService.getBnsList(params),
|
||||
enabled: options?.enabled,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
export function useBnsDetail(gbn: string, yr: string, seqNo: string) {
|
||||
return useQuery({
|
||||
queryKey: [...bnsIfBaseKey, "getBnsDetail", gbn, yr, seqNo],
|
||||
queryFn: () => bnsIfService.getBnsDetail(gbn, yr, seqNo),
|
||||
enabled: Boolean(gbn && yr && seqNo),
|
||||
});
|
||||
}
|
||||
|
||||
export function useEmCodeList(dpmCd?: string, lclgvrnmntCd?: string) {
|
||||
return useQuery({
|
||||
queryKey: [...bnsIfBaseKey, "getEmCodeList", dpmCd, lclgvrnmntCd],
|
||||
queryFn: () => bnsIfService.getEmCodeList(dpmCd, lclgvrnmntCd),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export { bnsIfService } from "./api/bns-if-service";
|
||||
export { useBnsDetail, useBnsList, useEmCodeList } from "./api/queries";
|
||||
export { buildBnsSearchDto } from "./lib/build-bns-search-dto";
|
||||
export type {
|
||||
BnsDetailResponseDto,
|
||||
BnsDetailResponseWrapperDto,
|
||||
BnsIfService,
|
||||
BnsListResponseDto,
|
||||
BnsListResponseWrapperDto,
|
||||
BnsSearchDto,
|
||||
EmCodeItemDto,
|
||||
EmCodeListResponseDto,
|
||||
} from "./model/bns-if-interface";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user