Compare commits
743
Commits
fe3ff594e0
..
CODEX
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee3741254d | ||
|
|
08e075c027 | ||
|
|
31252b7772 | ||
|
|
9e7eecb6e0 | ||
|
|
dc94c232f0 | ||
|
|
590764e49f | ||
|
|
863988f73d | ||
|
|
2ba21a6bdd | ||
|
|
322cd150cc | ||
|
|
481001ca65 | ||
|
|
2faec5fb14 | ||
|
|
92c10f5cc8 | ||
|
|
bad2e6cb6d | ||
|
|
de7e441faf | ||
|
|
d4bfeeb217 | ||
|
|
b9ab962b9f | ||
|
|
38f056e36a | ||
|
|
a0c292ba05 | ||
|
|
79ca220823 | ||
|
|
5884c7f0bd | ||
|
|
78f436b136 | ||
|
|
26399f0dd8 | ||
|
|
03c13dacf7 | ||
|
|
fff2845117 | ||
|
|
ecccaa2a33 | ||
|
|
66cb7afbab | ||
|
|
675d62b588 | ||
|
|
994b577038 | ||
|
|
5f5da8119b | ||
|
|
da510d3cbc | ||
|
|
cd1ef69d2b | ||
|
|
15e08b28cb | ||
|
|
9499ff16cc | ||
|
|
ff881aaeb0 | ||
|
|
21f404bba6 | ||
|
|
13b80d8581 | ||
|
|
605cc090ac | ||
|
|
cbe0c17505 | ||
|
|
59a3351ac4 | ||
|
|
b79dd24066 | ||
|
|
c6f84a2c64 | ||
|
|
5d61d1de58 | ||
|
|
935d6acbe4 | ||
|
|
66442a5efc | ||
|
|
37634e2c26 | ||
|
|
c6fc11cf13 | ||
|
|
b33ad8fc06 | ||
|
|
9663a41b4a | ||
|
|
bd3c48c525 | ||
|
|
acf7570da4 | ||
|
|
2334a2abcf | ||
|
|
aa2845eef7 | ||
|
|
9e2c034d9f | ||
|
|
d9217a54d5 | ||
|
|
0d2b5251d4 | ||
|
|
190a857270 | ||
|
|
d769278764 | ||
|
|
629f7c10c8 | ||
|
|
4ee2a4b74b | ||
|
|
dc8ae0f60f | ||
|
|
0c85cf6b04 | ||
|
|
ce457265b9 | ||
|
|
f1a1c15053 | ||
|
|
6eb3c551be | ||
|
|
f334dee816 | ||
|
|
0e59629d69 | ||
|
|
08e2b211a9 | ||
|
|
9fc5673b73 | ||
|
|
3f5c38c90d | ||
|
|
ed0059d544 | ||
|
|
f5a972d0e7 | ||
|
|
cce4e0721c | ||
|
|
407481dc8d | ||
|
|
2ab764ee6a | ||
|
|
966669bb34 | ||
|
|
83ad5b1507 | ||
|
|
b94259f3b5 | ||
|
|
9b4b56edc6 | ||
|
|
7110299075 | ||
|
|
1ca99ed3ae | ||
|
|
a86a03932c | ||
|
|
d26da8e6db | ||
|
|
94bd0d1b9f | ||
|
|
40ff4a551c | ||
|
|
1fd2521e82 | ||
|
|
8990729a6d | ||
|
|
72795f7497 | ||
|
|
b892257e21 | ||
|
|
b30cd5c6eb | ||
|
|
b2c61d2e9a | ||
|
|
912f4e7216 | ||
|
|
ac1124b9f1 | ||
|
|
bc094969ac | ||
|
|
1855bf7ae2 | ||
|
|
7d39f90a0e | ||
|
|
83b6238109 | ||
|
|
fe8ace2777 | ||
|
|
17256bfac8 | ||
|
|
3fd094eef3 | ||
|
|
58a9b3e8b7 | ||
|
|
32192e819a | ||
|
|
3fba3857cf | ||
|
|
543d7bb8a5 | ||
|
|
dbf1e87285 | ||
|
|
b464fe0a7f | ||
|
|
a22f03426f | ||
|
|
6b3b1e92a4 | ||
|
|
411ad7de7d | ||
|
|
10bc3686e2 | ||
|
|
80667d8555 | ||
|
|
bf40ca52a4 | ||
|
|
a7d17a6c25 | ||
|
|
576b12eb7d | ||
|
|
0c5f9b6485 | ||
|
|
3c1ba6333d | ||
|
|
3df8cb9b38 | ||
|
|
b5cdd13fbe | ||
|
|
a93670db03 | ||
|
|
7e0a7b5e15 | ||
|
|
4ddf0d6f42 | ||
|
|
4041e61871 | ||
|
|
f535c233a6 | ||
|
|
0b3315438a | ||
|
|
191b7ece27 | ||
|
|
0ae4a87f17 | ||
|
|
af8802e697 | ||
|
|
ebec54aa38 | ||
|
|
0973c11218 | ||
|
|
8301955d6a | ||
|
|
2bd6963334 | ||
|
|
6578620948 | ||
|
|
02d2c5a751 | ||
|
|
ff044bfe7b | ||
|
|
b7cafc6221 | ||
|
|
822ef680e3 | ||
|
|
2fa1c7d29b | ||
|
|
6d10f9603c | ||
|
|
da6a6e75c4 | ||
|
|
9176593d22 | ||
|
|
d8dc15ba12 | ||
|
|
abed39f61b | ||
|
|
1e512c2c0b | ||
|
|
7741a4086c | ||
|
|
e1f7b5f62d | ||
|
|
e909f8efc3 | ||
|
|
8350e270ff | ||
|
|
aaa0dff71b | ||
|
|
8cb06d0bea | ||
|
|
9d42cb065b | ||
|
|
4915f85227 | ||
|
|
73ec041e10 | ||
|
|
46600a230b | ||
|
|
ef24de8b63 | ||
|
|
6a5669d59c | ||
|
|
aaff1be817 | ||
|
|
a21788db30 | ||
|
|
db2f4bf7bc | ||
|
|
ca4fcf0364 | ||
|
|
bc1314e94c | ||
|
|
848f8bbc93 | ||
|
|
fd77c511be | ||
|
|
bcedbbc118 | ||
|
|
e2a938f2cf | ||
|
|
5067ee4478 | ||
|
|
c7c004ef17 | ||
|
|
46b52b28a6 | ||
|
|
ba72671c58 | ||
|
|
41c1304761 | ||
|
|
b1dbd9c3cb | ||
|
|
0da4defcce | ||
|
|
0fe3ffb1c3 | ||
|
|
31a753b7fd | ||
|
|
285aeec1dd | ||
|
|
f13fbf70ee | ||
|
|
caa9612cbd | ||
|
|
6e2f17a55b | ||
|
|
0b48e39026 | ||
|
|
58d43c16f9 | ||
|
|
ad35aa777e | ||
|
|
c77cb1fa3d | ||
|
|
436fee7037 | ||
|
|
8d1b8cd5f0 | ||
|
|
5b0d7fadaa | ||
|
|
c721fa852d | ||
|
|
78b545da71 | ||
|
|
45f8c6cdd5 | ||
|
|
d7bef83a81 | ||
|
|
2944f22b2a | ||
|
|
da49db8ce7 | ||
|
|
b7a0235c8b | ||
|
|
51cfb6ff44 | ||
|
|
795b861dae | ||
|
|
1f337d20d9 | ||
|
|
6a721484c9 | ||
|
|
18d097c745 | ||
|
|
2563272c69 | ||
|
|
bd5dd9cb21 | ||
|
|
aa818345b1 | ||
|
|
be4d99cf62 | ||
|
|
be2e677d27 | ||
|
|
48a746d5b7 | ||
|
|
b2aa2a987e | ||
|
|
4db230bfff | ||
|
|
7403aef9bc | ||
|
|
cc63e49ab3 | ||
|
|
3d6f59e494 | ||
|
|
4a264d961e | ||
|
|
ecc4ed372d | ||
|
|
33209b8677 | ||
|
|
3ded39875f | ||
|
|
0bc5606638 | ||
|
|
c7a8ed7cf9 | ||
|
|
c426fa2ad6 | ||
|
|
408cd7b6c7 | ||
|
|
ca88552f49 | ||
|
|
c318fa7e0d | ||
|
|
b31468787a | ||
|
|
35ccc26afa | ||
|
|
e70fc184ae | ||
|
|
7ca49764ce | ||
|
|
d9276467b1 | ||
|
|
e3236558bd | ||
|
|
a171ed3abc | ||
|
|
ad90e72260 | ||
|
|
3ad007683c | ||
|
|
5a9491bd69 | ||
|
|
ffc76efe9c | ||
|
|
2b7091d5a5 | ||
|
|
903eb8234e | ||
|
|
fd4830556e | ||
|
|
74a524202e | ||
|
|
1c0868b788 | ||
|
|
fda2527c4b | ||
|
|
00cb6db66f | ||
|
|
572680d78a | ||
|
|
74ab8d1d71 | ||
|
|
27fcbde040 | ||
|
|
68817d7175 | ||
|
|
03602ea138 | ||
|
|
9b6c2e7057 | ||
|
|
1e992ca0e3 | ||
|
|
4347d25c99 | ||
|
|
43b1c12765 | ||
|
|
7cdc7b08da | ||
|
|
11ef83985f | ||
|
|
6541b65834 | ||
|
|
d4458ec894 | ||
|
|
26a24889e6 | ||
|
|
e6fe5232e5 | ||
|
|
308b893e17 | ||
|
|
67f090879f | ||
|
|
7a4f1da235 | ||
|
|
4112da7b7d | ||
|
|
524dc8febc | ||
|
|
31d9a099be | ||
|
|
4e1b7cc5ef | ||
|
|
26f12e081f | ||
|
|
68f1c20e65 | ||
|
|
88ce573e63 | ||
|
|
c535de7b31 | ||
|
|
46f8b7611d | ||
|
|
b1d092efeb | ||
|
|
8087c73060 | ||
|
|
5066c6bb27 | ||
|
|
022a8d854d | ||
|
|
a507d37545 | ||
|
|
476fd6d8ee | ||
|
|
a6d7851e83 | ||
|
|
1cce06780e | ||
|
|
1e1fb9f811 | ||
|
|
ae959d9ec9 | ||
|
|
e7fc09bf53 | ||
|
|
71aef816f0 | ||
|
|
95d5876495 | ||
|
|
b0fdc84931 | ||
|
|
0674fad4f2 | ||
|
|
8cf26e0b0c | ||
|
|
e96a0ef8ac | ||
|
|
f49e31e40e | ||
|
|
42eddb7037 | ||
|
|
28310476a0 | ||
|
|
6d46dda543 | ||
|
|
6f84708909 | ||
|
|
c704f18982 | ||
|
|
79e4bba1bb | ||
|
|
611f5413f3 | ||
|
|
f0b8882a84 | ||
|
|
a97ad41c47 | ||
|
|
16fc214b17 | ||
|
|
f393abdeec | ||
|
|
e700c7ca3d | ||
|
|
5d603ddb27 | ||
|
|
09ea0148e3 | ||
|
|
766282cab6 | ||
|
|
1192f4c264 | ||
|
|
1df0a7fb05 | ||
|
|
068909eb9a | ||
|
|
b717724457 | ||
|
|
d40d4312f5 | ||
|
|
27f117b97b | ||
|
|
5df5c3ef12 | ||
|
|
66c77a7445 | ||
|
|
5b4aed1b20 | ||
|
|
cbcfc18436 | ||
|
|
1a415793c9 | ||
|
|
1baf761c4d | ||
|
|
ecc4a651a4 | ||
|
|
61d2ed5997 | ||
|
|
e1ad3d3625 | ||
|
|
dcce47b6d9 | ||
|
|
6cf72bbf11 | ||
|
|
07ac61d4d6 | ||
|
|
d21f258da6 | ||
|
|
36c437d9a1 | ||
|
|
5617f9e891 | ||
|
|
ba1658de68 | ||
|
|
3db416fc7b | ||
|
|
fcb9513aee | ||
|
|
aaf9407c9a | ||
|
|
fdb07fa4ab | ||
|
|
1cf32deaca | ||
|
|
ba5c2e99d4 | ||
|
|
8d7214ba55 | ||
|
|
bbe3f0f1a7 | ||
|
|
1aad41ec0e | ||
|
|
7dc557037b | ||
|
|
9242cd15e5 | ||
|
|
3ad7678e15 | ||
|
|
f8d024fc28 | ||
|
|
29922434d6 | ||
|
|
24e03f78de | ||
|
|
d847f724cf | ||
|
|
91bb01c3f3 | ||
|
|
8f5a573ebc | ||
|
|
a2d1471931 | ||
|
|
54ce67ceb8 | ||
|
|
789c78db2d | ||
|
|
fe8d0b8359 | ||
|
|
364d22ff6f | ||
|
|
54ba657ff1 | ||
|
|
c507fe237f | ||
|
|
0e28b5be4a | ||
|
|
516719d50d | ||
|
|
9469956b47 | ||
|
|
af92f0553e | ||
|
|
4ff4855767 | ||
|
|
eb132f13d0 | ||
|
|
80a635d3b9 | ||
|
|
43a59a6893 | ||
|
|
6cb877925f | ||
|
|
134a727c9c | ||
|
|
18a0639253 | ||
|
|
0b158636cc | ||
|
|
8739148bba | ||
|
|
ab84c763b7 | ||
|
|
eee8f9a459 | ||
|
|
8fde124df5 | ||
|
|
1871b6ffc2 | ||
|
|
3d79d2dab8 | ||
|
|
c67171551f | ||
|
|
fe07dd8a45 | ||
|
|
628349e15b | ||
|
|
5cf7c1c5b1 | ||
|
|
ef0a407fda | ||
|
|
18b1a085d8 | ||
|
|
6c28357fb7 | ||
|
|
056ddd8822 | ||
|
|
9e8a1c1f63 | ||
|
|
ddd73349f2 | ||
|
|
22e53ec4a7 | ||
|
|
74708c77e5 | ||
|
|
277311955b | ||
|
|
0c141396ec | ||
|
|
0f8efaa424 | ||
|
|
2a18f5e9f5 | ||
|
|
c8269a969f | ||
|
|
19e6b807da | ||
|
|
88d370b7ff | ||
|
|
29d5792d94 | ||
|
|
2f25468019 | ||
|
|
e0fc236608 | ||
|
|
3336902c17 | ||
|
|
cb48dc214a | ||
|
|
5946ded7d0 | ||
|
|
7e2338a807 | ||
|
|
65c2de2e9c | ||
|
|
c6776d5488 | ||
|
|
af201fbbd3 | ||
|
|
7418571b63 | ||
|
|
78dd044998 | ||
|
|
fd2a777c91 | ||
|
|
28da700d47 | ||
|
|
869bfe7c5d | ||
|
|
eda867a1dc | ||
|
|
d822a08a5e | ||
|
|
54fc4f1b1f | ||
|
|
8fed757fc1 | ||
|
|
5273f61b87 | ||
|
|
33700dc940 | ||
|
|
8e35375c54 | ||
|
|
cc4b6a6674 | ||
|
|
fd2a6851c7 | ||
|
|
937661c573 | ||
|
|
e5a1cdf841 | ||
|
|
cb613507ae | ||
|
|
66d0fd58b9 | ||
|
|
3dddb53997 | ||
|
|
45b614ca55 | ||
|
|
eb30b3f16a | ||
|
|
c7cd7d4ea0 | ||
|
|
d202a3a6d8 | ||
|
|
9269e9da97 | ||
|
|
453ac49dd8 | ||
|
|
f30bc9e82f | ||
|
|
d1df48c45f | ||
|
|
1ee1668368 | ||
|
|
2b3ba84fe5 | ||
|
|
5969278df3 | ||
|
|
3f71305ddb | ||
|
|
85e11265b5 | ||
|
|
d2ab8ee8cc | ||
|
|
fcd45afe9f | ||
|
|
85432ae68c | ||
|
|
df2368a48f | ||
|
|
9872ad66f5 | ||
|
|
d6c84a78c5 | ||
|
|
a5edf43d6e | ||
|
|
84dd9ce663 | ||
|
|
cca0e09cd2 | ||
|
|
a774b4e97b | ||
|
|
fa33abb5f0 | ||
|
|
8193f29ae9 | ||
|
|
218d981f8e | ||
|
|
9ec240cb46 | ||
|
|
509e469d56 | ||
|
|
52a085eb0c | ||
|
|
f0ecaa122d | ||
|
|
6c4f852ecc | ||
|
|
466fa56540 | ||
|
|
f4fc6f9e5f | ||
|
|
0d45014e38 | ||
|
|
27c5eba521 | ||
|
|
8a42a7fe75 | ||
|
|
abbf61971a | ||
|
|
e8662131e0 | ||
|
|
bad349783b | ||
|
|
2abd3d46ae | ||
|
|
3b1dd532aa | ||
|
|
7b90d28e22 | ||
|
|
3ea0c0fdf2 | ||
|
|
deabd978c0 | ||
|
|
d584cdeb9e | ||
|
|
e14c36204f | ||
|
|
31cdd84737 | ||
|
|
2a93b92050 | ||
|
|
b34a028794 | ||
|
|
dcb023f4e6 | ||
|
|
981e0f26f1 | ||
|
|
14547d4f20 | ||
|
|
96326eaa35 | ||
|
|
d787f3faa5 | ||
|
|
69e68a761b | ||
|
|
72df53ad54 | ||
|
|
ff127b22cc | ||
|
|
fd976dcde3 | ||
|
|
5006b3de21 | ||
|
|
2911dccc55 | ||
|
|
7fb9ef8c55 | ||
|
|
37e9924082 | ||
|
|
cb7fc50c69 | ||
|
|
3c78322350 | ||
|
|
e799fbf345 | ||
|
|
336b9df326 | ||
|
|
1438216801 | ||
|
|
fbb6902576 | ||
|
|
672dbbd1b7 | ||
|
|
f5d3e89890 | ||
|
|
2d2c62defa | ||
|
|
5d7291b89a | ||
|
|
e75a99f7db | ||
|
|
50b5efd8f6 | ||
|
|
f1419cda96 | ||
|
|
0fafd3fbc8 | ||
|
|
9516116af5 | ||
|
|
97f306a7af | ||
|
|
accf8ecc4f | ||
|
|
a9a0219329 | ||
|
|
afb7da9824 | ||
|
|
49fa7e5fbe | ||
|
|
2e99091673 | ||
|
|
20720ea6f8 | ||
|
|
34b9acfb4f | ||
|
|
3137182e5d | ||
|
|
4697db29d3 | ||
|
|
99a47bec03 | ||
|
|
5ec3c395b2 | ||
|
|
1ce609e0d2 | ||
|
|
4a54497795 | ||
|
|
83b5040c8e | ||
|
|
7ad2f2c133 | ||
|
|
17ae4f85a6 | ||
|
|
9de99218de | ||
|
|
533826d613 | ||
|
|
69a138af00 | ||
|
|
2c134b440f | ||
|
|
932ec987dd | ||
|
|
7d209c56f6 | ||
|
|
321e63f9c1 | ||
|
|
438c3660ca | ||
|
|
9052cb7411 | ||
|
|
c2f7694e1d | ||
|
|
1dfe6bfefb | ||
|
|
3eb18bf74b | ||
|
|
aaa36625e8 | ||
|
|
8a9f23c6e3 | ||
|
|
eb063c50bc | ||
|
|
e2b047ab1a | ||
|
|
018177408b | ||
|
|
03e00fe15d | ||
|
|
55c787d5af | ||
|
|
28469de625 | ||
|
|
d76d40f614 | ||
|
|
047f3a2798 | ||
|
|
078e168f7d | ||
|
|
8473968f95 | ||
|
|
5bae4c9587 | ||
|
|
b5462afa94 | ||
|
|
5ef189ca45 | ||
|
|
3abbe74f58 | ||
|
|
609dd98fe2 | ||
|
|
12b063af26 | ||
|
|
226e6c305d | ||
|
|
96b0e4c8fb | ||
|
|
67e631ac8c | ||
|
|
6c645585f4 | ||
|
|
108ecc3c83 | ||
|
|
0158c940cb | ||
|
|
b353daecfc | ||
|
|
39c0d163ff | ||
|
|
1fc2885f76 | ||
|
|
2d459f7b13 | ||
|
|
ce65d1df3b | ||
|
|
3e5dd641f1 | ||
|
|
ba650fe63f | ||
|
|
8c200dfc77 | ||
|
|
868dc8e37f | ||
|
|
df735e99a8 | ||
|
|
1eb636353c | ||
|
|
46f9c57fa0 | ||
|
|
314783a027 | ||
|
|
dad45e997b | ||
|
|
646799e902 | ||
|
|
15226e8563 | ||
|
|
296255e357 | ||
|
|
083f320ac4 | ||
|
|
9848bd01b9 | ||
|
|
25021f9371 | ||
|
|
745d164177 | ||
|
|
3b4ce57d1e | ||
|
|
4a62f05caf | ||
|
|
126c251256 | ||
|
|
7753f8c2c1 | ||
|
|
b76420dc3c | ||
|
|
b67dcc804c | ||
|
|
f759216242 | ||
|
|
a35aef7086 | ||
|
|
a2981c6337 | ||
|
|
022c7e086b | ||
|
|
03736cba18 | ||
|
|
2a76ee1816 | ||
|
|
dd2499364b | ||
|
|
295dbf622f | ||
|
|
3cf1a02935 | ||
|
|
34e58a5fda | ||
|
|
d24851d1a4 | ||
|
|
41cdf22d82 | ||
|
|
e2abd5a3c2 | ||
|
|
7818dbf4ef | ||
|
|
eb2661ab18 | ||
|
|
6db065e2e1 | ||
|
|
77d1bfbdc5 | ||
|
|
e6fda191a2 | ||
|
|
768ae40d4f | ||
|
|
92ffd41bc5 | ||
|
|
a0d0b0354e | ||
|
|
d562f09935 | ||
|
|
a3730d227e | ||
|
|
348003cf5a | ||
|
|
67ced35ade | ||
|
|
65e3c919ca | ||
|
|
5f993e4075 | ||
|
|
e48ed90093 | ||
|
|
6008a1e7ae | ||
|
|
acfacb3a38 | ||
|
|
4cd2930ff3 | ||
|
|
8ed369b209 | ||
|
|
14b222dcb5 | ||
|
|
13f37b4f56 | ||
|
|
92546d48db | ||
|
|
9731b133fe | ||
|
|
10d6b30e6d | ||
|
|
d660f2a12e | ||
|
|
fc7affb019 | ||
|
|
8099a90ced | ||
|
|
5fb3e089b2 | ||
|
|
da9eead7e5 | ||
|
|
1930b80aa4 | ||
|
|
12a60baeeb | ||
|
|
db1f31a326 | ||
|
|
833e8e5772 | ||
|
|
d297d3384e | ||
|
|
266c0a9eec | ||
|
|
aacd21dd7c | ||
|
|
19fed5b90f | ||
|
|
7322c5af12 | ||
|
|
2be767d186 | ||
|
|
306a251712 | ||
|
|
b571d7cecc | ||
|
|
2f5cec2d61 | ||
|
|
1004768037 | ||
|
|
71c20453e4 | ||
|
|
2ea4883273 | ||
|
|
42b94ed737 | ||
|
|
dfd4810bf1 | ||
|
|
4451bbe6a5 | ||
|
|
7df1df5702 | ||
|
|
f609dd2767 | ||
|
|
cd8837d9c3 | ||
|
|
76c123d4e6 | ||
|
|
6714c363b7 | ||
|
|
712aac8e39 | ||
|
|
0c7569e5bb | ||
|
|
e2dfbe687b | ||
|
|
9b11fccbef | ||
|
|
2cecd38f35 | ||
|
|
ae549f913c | ||
|
|
2c4078e187 | ||
|
|
608de92696 | ||
|
|
3b2b830786 | ||
|
|
ad1d104776 | ||
|
|
d415923d16 | ||
|
|
97b7111c06 | ||
|
|
8ba872384d | ||
|
|
91c743382b | ||
|
|
f552441529 | ||
|
|
b0c86116e6 | ||
|
|
47eba22e54 | ||
|
|
11d377d9e0 | ||
|
|
d761e2d543 | ||
|
|
70a8e15303 | ||
|
|
cf73a51fdb | ||
|
|
f0a8f48ac6 | ||
|
|
4f02421acc | ||
|
|
316ffa0b14 | ||
|
|
8135ac3ca2 | ||
|
|
6ba9d76605 | ||
|
|
e837e5965e | ||
|
|
93ac4ab22e | ||
|
|
2fe9ca154a | ||
|
|
ac5a0eb685 | ||
|
|
f013343c1e | ||
|
|
a684820394 | ||
|
|
87ec8628a8 | ||
|
|
227fd8245b | ||
|
|
24068f65e4 | ||
|
|
87dcc4b288 | ||
|
|
b0d908c68b | ||
|
|
802e632b70 | ||
|
|
716af60df7 | ||
|
|
73fa2e612a | ||
|
|
5701d671d8 | ||
|
|
043a3b4516 | ||
|
|
0e200cf74a | ||
|
|
2e66b0a6ce | ||
|
|
66f77cceeb | ||
|
|
e0dfc5cbaa | ||
|
|
84c2b968ec | ||
|
|
cf823bb6f6 | ||
|
|
bb89baa557 | ||
|
|
58cee6404e | ||
|
|
3a89267520 | ||
|
|
0e72719a5d | ||
|
|
8b07250aa0 | ||
|
|
52b86b4620 | ||
|
|
12ec81be08 | ||
|
|
c7d814d7be | ||
|
|
43600bbdaa | ||
|
|
e5955b705d | ||
|
|
b188ba3dd5 | ||
|
|
9be09eed9d | ||
|
|
2c848702ea | ||
|
|
ab514e8d10 | ||
|
|
1fe49ad241 | ||
|
|
a02ea70d2e | ||
|
|
2d64a76fc3 | ||
|
|
45192daf30 | ||
|
|
90f4e6ab20 | ||
|
|
5c1a5e14bc | ||
|
|
7018453a88 | ||
|
|
60a287419d | ||
|
|
fdc06bbc26 | ||
|
|
46c7f8e41c | ||
|
|
679a759e84 | ||
|
|
892e156efe | ||
|
|
62d63c0d17 | ||
|
|
d9a5733865 | ||
|
|
74d2ba25f8 | ||
|
|
184c66e14e | ||
|
|
499e52ca73 | ||
|
|
520c7dc406 | ||
|
|
ebf51d8b35 | ||
|
|
34165ec66a | ||
|
|
f8d7776284 | ||
|
|
124c3976d8 | ||
|
|
7b9e91f7e2 | ||
|
|
dd24ebc5c9 | ||
|
|
5f9240c9e3 | ||
|
|
8e2d8d6bb3 | ||
|
|
e00676aa1b | ||
|
|
eb9082dc30 | ||
|
|
817826c1af | ||
|
|
ab2bdc78df | ||
|
|
66925bdfad | ||
|
|
22430f20b5 | ||
|
|
7bd6e8d7d1 | ||
|
|
ef0b4ae385 | ||
|
|
7f98edac2c | ||
|
|
bd7e6552cd | ||
|
|
35207e211d | ||
|
|
29f7cafbda | ||
|
|
b1043526ab | ||
|
|
ae9388e96c | ||
|
|
50ecb5abed | ||
|
|
920783a9b0 | ||
|
|
783b6c5a28 | ||
|
|
e242183fed | ||
|
|
67e4c27658 | ||
|
|
bf6976fab6 | ||
|
|
687484bdc3 | ||
|
|
88d0a4ecd4 | ||
|
|
7c1266bcb4 | ||
|
|
9d98f1f2ad | ||
|
|
62fa53e927 |
@@ -59,9 +59,13 @@ export const STATE_REGISTRY = {
|
||||
"drainage-width": { bucket: "pref", scope: "global", legacy: () => "b05-route-drainage-width" },
|
||||
/** 종단도 오른쪽 판(배수유역도) 켬 · 끔 — 두 보기가 같은 값(계획서 24-2c · 24-2e). */
|
||||
"drainage-visible": { bucket: "pref", scope: "global" },
|
||||
/** M02 면적산출 · 재료산출 오른쪽 조합 패널 — 접힘 · 폭 */
|
||||
/** M02 면적산출 오른쪽 일위대가 패널(`AreaPanel`) — 접힘 · 폭(옛 조합 패널 키 그대로) */
|
||||
"m02-combo-panel-collapsed": { bucket: "pref", scope: "global" },
|
||||
"m02-combo-panel-width": { bucket: "pref", scope: "global" },
|
||||
/** M02 면적산출 카드 미리보기 아래 알약 줄 보임(기본 숨김 · 상세는 늘 보임) */
|
||||
"m02-card-pills": { bucket: "pref", scope: "global" },
|
||||
/** M01 단가산출 로직 상세 오른쪽 — 열린 패널 하나(flow · source · none) */
|
||||
"m01-logic-panel": { bucket: "pref", scope: "global" },
|
||||
/** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */
|
||||
"routeedit-view": { bucket: "pref", scope: "project" },
|
||||
/** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */
|
||||
@@ -297,6 +301,8 @@ const PREFS_ENDPOINT = "/api/dashboard/me/ui-prefs";
|
||||
const PREFS_PUSH_DELAY_MS = 1500;
|
||||
let applyingServerPrefs = false;
|
||||
let prefsPushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** 계정 취향 받기 — 받기 전에 올리면 이 브라우저 옛 값이 계정 값을 덮으므로 올리기는 이것 뒤에 */
|
||||
let prefsPull: Promise<void> | null = null;
|
||||
|
||||
function localPrefs(): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
@@ -318,17 +324,26 @@ function pushUiPrefs(): void {
|
||||
if (prefsPushTimer) clearTimeout(prefsPushTimer);
|
||||
prefsPushTimer = setTimeout(() => {
|
||||
prefsPushTimer = null;
|
||||
void fetch(PREFS_ENDPOINT, {
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prefs: localPrefs() }),
|
||||
}).catch(() => undefined);
|
||||
void (prefsPull ?? Promise.resolve())
|
||||
.then(() =>
|
||||
fetch(PREFS_ENDPOINT, {
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prefs: localPrefs() }),
|
||||
}),
|
||||
)
|
||||
.catch(() => undefined);
|
||||
}, PREFS_PUSH_DELAY_MS);
|
||||
}
|
||||
|
||||
/** 계정에 저장된 취향을 받아 로컬에 얹는다 — 로그인 뒤 한 번. */
|
||||
export async function syncUiPrefs(): Promise<void> {
|
||||
/** 계정에 저장된 취향을 받아 로컬에 얹는다 — 로그인 뒤 한 번(올리기는 받은 뒤로 미룸). */
|
||||
export function syncUiPrefs(): Promise<void> {
|
||||
prefsPull = pullUiPrefs();
|
||||
return prefsPull;
|
||||
}
|
||||
|
||||
async function pullUiPrefs(): Promise<void> {
|
||||
let stored: Record<string, unknown> = {};
|
||||
try {
|
||||
const response = await fetch(PREFS_ENDPOINT, { credentials: "include" });
|
||||
|
||||
@@ -112,6 +112,13 @@ export function createDesignPanel(ctx: DesignPanelContext, view: DesignView): De
|
||||
const dockParts: HTMLElement[] = [];
|
||||
const dock = createDesignDock(view, dockParts, ctx.dock);
|
||||
|
||||
// 칸은 공용 스크롤 칸(쪼갠 뒤) 안에 둔다 — 쪼개기 전이면 패널 루트의 단추 묶음 앞.
|
||||
const place = (element: HTMLElement): void => {
|
||||
const scroll = root.querySelector<HTMLElement>(":scope > .ui-sidebar-scroll");
|
||||
if (scroll) scroll.append(element);
|
||||
else root.insertBefore(element, dock.root);
|
||||
};
|
||||
|
||||
const show = (target: DesignView): void => {
|
||||
const wanted = new Set(panelSectionsFor(target).map((def) => def.id));
|
||||
for (const def of panelSectionsFor(target)) {
|
||||
@@ -120,12 +127,12 @@ export function createDesignPanel(ctx: DesignPanelContext, view: DesignView): De
|
||||
const sectionRoot = "root" in made ? made.root : made;
|
||||
if ("root" in made && made.dock) dock.root.prepend(made.dock);
|
||||
built.set(def.id, sectionRoot);
|
||||
root.insertBefore(sectionRoot, dock.root);
|
||||
place(sectionRoot);
|
||||
}
|
||||
// 차례를 정의 순서로 다시 맞춘다 — 늦게 만들어진 칸이 맨 아래에 남지 않게.
|
||||
for (const def of panelSectionsFor(target)) {
|
||||
const element = built.get(def.id);
|
||||
if (element) root.insertBefore(element, dock.root);
|
||||
if (element) place(element);
|
||||
}
|
||||
built.forEach((element, id) => {
|
||||
// 칸이 스스로 숨긴 것(선택 포인트)은 건드리지 않고, 보기 밖 칸만 가리고 되살린다.
|
||||
@@ -144,7 +151,9 @@ export function createDesignPanel(ctx: DesignPanelContext, view: DesignView): De
|
||||
dock,
|
||||
setView: (next) => {
|
||||
dock.setView(next);
|
||||
root.className = next === "profile" ? "b05-route__panel" : "b06-profile__form";
|
||||
// 클래스 통째 덮기 금지 — 공용 쪼개기가 붙인 ui-sidebar-fill 이 지워져 단추 묶음이 바닥에서 뜬다.
|
||||
root.classList.toggle("b05-route__panel", next === "profile");
|
||||
root.classList.toggle("b06-profile__form", next !== "profile");
|
||||
show(next);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { splitSidebarActions } from "@ui/ui_template_overlay";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../b_workflow_nav";
|
||||
import { navigateTo } from "../router";
|
||||
@@ -229,6 +230,9 @@ export function sharedPanel(shared: DesignPageShared): DesignPanel {
|
||||
shared.view(),
|
||||
);
|
||||
shared.left.append(panel.root);
|
||||
// 틀이 먼저 섰으면 좌측 자리가 빈 채로 지나갔다 — 붙인 지금 [스크롤 칸][아래 고정 단추]로 쪼갠다.
|
||||
const body = shared.left.closest<HTMLElement>(".ui-workflow-overlay__body");
|
||||
if (body) splitSidebarActions(body);
|
||||
shared.panel = panel;
|
||||
return panel;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import "./random_uuid_polyfill";
|
||||
import "@ui/ui_template_theme.css";
|
||||
import { injectBaseStyles } from "@ui/ui_template_elements";
|
||||
import { blockNativeMenu, NATIVE_MENU_ROUTE } from "@ui/ui_template_native_menu";
|
||||
import { ROUTES, type RoutePath } from "@config/config_frontend";
|
||||
import { initThemeAndLang, injectShellStyles, renderShell } from "./app_shell";
|
||||
import {
|
||||
@@ -51,6 +52,9 @@ function bootstrap(): void {
|
||||
// 2. 테마/언어 복원
|
||||
initThemeAndLang();
|
||||
|
||||
// B · M 페이지는 브라우저 기본 오른쪽 클릭 메뉴를 막음 — 우리 메뉴는 그대로(계획서 39-9a)
|
||||
blockNativeMenu(window, () => NATIVE_MENU_ROUTE.test(currentRoute()));
|
||||
|
||||
// 3. 셸 렌더 + 현재 라우트 렌더를 함께 수행하는 헬퍼
|
||||
let shown = "";
|
||||
let shownHash = "";
|
||||
|
||||
@@ -22,7 +22,9 @@ import { createCalcEngine } from "./spreadsheet_graph";
|
||||
import { createGrid } from "./spreadsheet_grid";
|
||||
import { createHistory } from "./spreadsheet_history";
|
||||
import { attachKeys } from "./spreadsheet_keys";
|
||||
import type { ConditionOptions } from "./spreadsheet_condition";
|
||||
import type { LinkOptions, Links } from "./spreadsheet_links";
|
||||
import { linkCommand, type LinkInfo } from "./spreadsheet_link_set";
|
||||
import { attachMenu } from "./spreadsheet_menu";
|
||||
import { attachMouse } from "./spreadsheet_mouse";
|
||||
import { LAST_C, LAST_R, selectCell } from "./spreadsheet_selection";
|
||||
@@ -47,6 +49,7 @@ import type {
|
||||
|
||||
export type { Workbook, CalcValues, CalcValue, LinkMaterial } from "./spreadsheet_types";
|
||||
export type { LinkItem, LinkOptions } from "./spreadsheet_links";
|
||||
export type { LinkInfo } from "./spreadsheet_link_set";
|
||||
|
||||
export interface SpreadsheetOptions {
|
||||
/** 읽기만 — 도구 모음 · 메뉴 없음 · 편집 · 붙여넣기 · 끌기 막음(고르기 · 복사는 됨) */
|
||||
@@ -62,6 +65,8 @@ export interface SpreadsheetOptions {
|
||||
/** 미리보기 수정(카드 안 작은 표) — 도구 모음 · 오른쪽 클릭 메뉴 없이 수식 입력 줄 · 칸 고침 · 단축키 ·
|
||||
* 알약 막대만. 다른 화면 모양은 그대로 */
|
||||
preview?: boolean;
|
||||
/** 칸 우클릭 「조건 만들기」 — 이것을 줄 때만 붙음(늦게 불러옴) · 안 주면 화면 그대로 */
|
||||
conditions?: ConditionOptions;
|
||||
}
|
||||
|
||||
export interface SpreadsheetHandle {
|
||||
@@ -71,8 +76,11 @@ export interface SpreadsheetHandle {
|
||||
setDoc(book: Workbook): void;
|
||||
/** 전부 다시 풀고 다시 그림 · 결과(저장 모양) */
|
||||
recalc(): CalcValues;
|
||||
/** 알약 키 → 연결 칸 값(빈 칸 0) */
|
||||
/** 알약 키 → 연결 칸 값(빈 칸 = 값 · 식 없는 칸은 빠짐 — 「수량 없음」) */
|
||||
getLinkedValues(): Record<string, CalcValue>;
|
||||
/** 알약 키를 지금 시트 `칸`(A1)에 잇기 · 풀기(null) — 알약 누름과 같은 규칙 · 되돌리기 됨 ·
|
||||
* 그 칸 다른 알약은 함께 둠(`replace` 면 뺌) · 주소가 틀리거나 읽기만이면 false */
|
||||
setLink(key: string, cell: string | null, info?: LinkInfo, replace?: boolean): boolean;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
@@ -165,6 +173,10 @@ export function createSpreadsheet(
|
||||
grid.render();
|
||||
select(ctx.selection);
|
||||
loadLinks();
|
||||
if (opts.conditions && !readOnly)
|
||||
void import("./spreadsheet_condition").then((m) => {
|
||||
if (root.isConnected) others.push(m.attachCondition(ctx, opts.conditions!));
|
||||
});
|
||||
|
||||
function notify(message: string): void {
|
||||
toast.textContent = message;
|
||||
@@ -277,10 +289,20 @@ export function createSpreadsheet(
|
||||
for (const s of ctx.book.시트)
|
||||
for (const l of s.연결 ?? []) {
|
||||
const p = parseA1(l.칸);
|
||||
if (p) out[l.키] = toCalcValue(ctx.engine.value(s.id, p.r, p.c));
|
||||
const cell = s.칸[l.칸];
|
||||
const empty = !cell || (cell.식 === undefined && (cell.값 ?? "") === "");
|
||||
if (p && !empty) out[l.키] = toCalcValue(ctx.engine.value(s.id, p.r, p.c));
|
||||
}
|
||||
return out;
|
||||
},
|
||||
setLink(key, cell, info, replace) {
|
||||
if (readOnly) return false;
|
||||
const command = linkCommand(ctx.book, ctx.sheet().id, key, cell, info, replace);
|
||||
if (command === false) return false;
|
||||
if (command) dispatch(command);
|
||||
loadLinks(); // 처음 연결이면 알약 막대 · 칸 세모를 불러옴
|
||||
return true;
|
||||
},
|
||||
destroy() {
|
||||
clearTimeout(toastTimer);
|
||||
detachKeys();
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
.ss-cond {
|
||||
min-width: min(46rem, 96vw);
|
||||
}
|
||||
|
||||
.ss-cond-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-4) 0;
|
||||
}
|
||||
|
||||
.ss-cond-label {
|
||||
min-width: 5em;
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.ss-cond-pick {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.ss-cond-cell,
|
||||
.ss-cond-value {
|
||||
width: 7em;
|
||||
}
|
||||
|
||||
.ss-cond .ui-input.is-bad {
|
||||
border-color: var(--color-danger);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.ss-cond-note {
|
||||
margin: 0 0 var(--spacing-8);
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-left: 4px solid var(--color-warning);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.ss-cond-formula {
|
||||
flex: 1 1 20em;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.ss-cond-foot {
|
||||
margin-top: var(--spacing-8);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_condition.ts
|
||||
* 엑셀 「조건 만들기」 — 칸 우클릭 → 줄 판: 만약 [칸·알약] [비교] [값] → [칸·알약·값] · 아니면 만약 … · 아니면 → 고른 칸에 IF 식(중첩 IF)
|
||||
* 엑셀로 쓴 IF 도 같은 줄로 다시 엶(`formulaToDraft`) · 못 바꾸는 식은 「식 그대로 고침」 안내
|
||||
* 부르는 쪽이 `conditions` 선택을 줄 때만 붙음(`attachCondition` · 늦게 불러옴) → 그 밖 화면은 그대로
|
||||
* 앞 절반(DraftToFormula · formulaToDraft)은 DOM 을 모름 — 시험이 이 구역만 떼어 Node 로 돌림
|
||||
* 글 [한, 영] 짝은 아래 TEXT
|
||||
* ========================================================================== */
|
||||
|
||||
import { createMapContextMenu } from "@ui/ui_template_context_menu";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
import { toA1 } from "./spreadsheet_address";
|
||||
import {
|
||||
COND_OPS,
|
||||
CELL,
|
||||
draftToFormula,
|
||||
formulaToDraft,
|
||||
type CondBranch,
|
||||
type CondDraft,
|
||||
type CondOp,
|
||||
type CondOperand,
|
||||
} from "./spreadsheet_condition_text";
|
||||
import "./spreadsheet_condition.css";
|
||||
|
||||
/** 부르는 쪽이 주는 것 — valuesFor(a1) = 그 칸에 올 수 있는 값 목록(없으면 null → 값 칸) · pills = 이름 붙은 칸 */
|
||||
export interface ConditionOptions {
|
||||
valuesFor(a1: string): (string | number)[] | null;
|
||||
pills?(): { name: string; cell: string }[];
|
||||
}
|
||||
|
||||
export { COND_OPS, draftToFormula, formulaToDraft };
|
||||
export type { CondBranch, CondDraft, CondOp, CondOperand };
|
||||
|
||||
/* ── 글 ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const TEXT = {
|
||||
Menu: ["조건 만들기", "Make a condition"],
|
||||
Title: ["조건 만들기", "Make a condition"],
|
||||
If: ["만약", "If"],
|
||||
ElseIf: ["아니면 만약", "Else if"],
|
||||
Then: ["→", "→"],
|
||||
Otherwise: ["아니면 →", "Otherwise →"],
|
||||
AddElseIf: ["+ 아니면 만약", "+ Else if"],
|
||||
Remove: ["지움", "Remove"],
|
||||
Make: ["만들기", "Make"],
|
||||
Cancel: ["취소", "Cancel"],
|
||||
Close: ["닫기", "Close"],
|
||||
CellPick: ["칸 직접", "A cell"],
|
||||
ValuePick: ["값", "A value"],
|
||||
Cell: ["칸 주소", "Cell address"],
|
||||
Value: ["값", "Value"],
|
||||
BadCell: ["칸 주소가 아님 — 예: B2", "Not a cell address — e.g. B2"],
|
||||
Target: ["식이 들어갈 칸", "Formula goes into"],
|
||||
AsIs: [
|
||||
"이 식은 조건 줄로 바꿀 수 없음 — 식 그대로 고침",
|
||||
"This formula can't be shown as lines — edit the formula as it is",
|
||||
],
|
||||
} as const;
|
||||
|
||||
const tc = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
||||
|
||||
/* ── 줄 판 ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
const OWN = "\u0001cell";
|
||||
const OWN_VALUE = "\u0001value";
|
||||
|
||||
function select(
|
||||
options: { value: string; text: string }[],
|
||||
value: string,
|
||||
onPick: (v: string) => void,
|
||||
aria: string,
|
||||
): HTMLSelectElement {
|
||||
const s = el("select", { className: "ui-select ui-select--sm", attrs: { "aria-label": aria } });
|
||||
for (const o of options) s.append(el("option", { attrs: { value: o.value }, text: o.text }));
|
||||
s.value = value;
|
||||
s.addEventListener("change", () => onPick(s.value));
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 칸 고르기 — 이름 붙은 칸(알약) 아니면 칸 주소 직접 */
|
||||
function cellPick(
|
||||
opts: ConditionOptions,
|
||||
value: string,
|
||||
onChange: (cell: string) => void,
|
||||
aria: string,
|
||||
): HTMLElement {
|
||||
const pills = opts.pills?.() ?? [];
|
||||
const hit = pills.find((p) => p.cell === value);
|
||||
const options = [
|
||||
...pills.map((p) => ({ value: p.cell, text: `${p.name} (${p.cell})` })),
|
||||
{ value: OWN, text: tc("CellPick") },
|
||||
];
|
||||
const box = el("span", { className: "ss-cond-pick" });
|
||||
const sel = select(options, hit ? value : OWN, (v) => v !== OWN && onChange(v), aria);
|
||||
box.append(sel);
|
||||
if (!hit) {
|
||||
const input = el("input", {
|
||||
className: "ui-input ui-input--sm ss-cond-cell",
|
||||
attrs: { type: "text", "aria-label": tc("Cell"), placeholder: "B2" },
|
||||
});
|
||||
input.value = value;
|
||||
input.addEventListener("change", () => {
|
||||
const m = CELL.exec(input.value.trim().toUpperCase());
|
||||
input.classList.toggle("is-bad", !m);
|
||||
input.title = m ? "" : tc("BadCell");
|
||||
if (m) onChange(`${m[1]}${m[2]}`);
|
||||
});
|
||||
box.append(input);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 값 쪽 고르기 — 칸 · 알약 · 값(목록이 있으면 고르기) */
|
||||
function operandPick(
|
||||
opts: ConditionOptions,
|
||||
o: CondOperand,
|
||||
hint: string | null,
|
||||
onChange: (o: CondOperand) => void,
|
||||
aria: string,
|
||||
): HTMLElement {
|
||||
const box = el("span", { className: "ss-cond-pick" });
|
||||
const pills = opts.pills?.() ?? [];
|
||||
const isPill = o.kind === "cell" && pills.some((p) => p.cell === o.cell);
|
||||
const options = [
|
||||
...pills.map((p) => ({ value: p.cell, text: `${p.name} (${p.cell})` })),
|
||||
{ value: OWN, text: tc("CellPick") },
|
||||
{ value: OWN_VALUE, text: tc("ValuePick") },
|
||||
];
|
||||
const mode = o.kind === "value" ? OWN_VALUE : isPill ? o.cell : OWN;
|
||||
box.append(
|
||||
select(
|
||||
options,
|
||||
mode,
|
||||
(v) => {
|
||||
if (v === OWN_VALUE) onChange({ kind: "value", value: 0 });
|
||||
else if (v === OWN) onChange({ kind: "cell", cell: "B2" });
|
||||
else onChange({ kind: "cell", cell: v });
|
||||
},
|
||||
aria,
|
||||
),
|
||||
);
|
||||
if (o.kind === "cell" && !isPill) {
|
||||
const input = el("input", {
|
||||
className: "ui-input ui-input--sm ss-cond-cell",
|
||||
attrs: { type: "text", "aria-label": tc("Cell") },
|
||||
});
|
||||
input.value = o.cell;
|
||||
input.addEventListener("change", () => {
|
||||
const m = CELL.exec(input.value.trim().toUpperCase());
|
||||
input.classList.toggle("is-bad", !m);
|
||||
if (m) onChange({ kind: "cell", cell: `${m[1]}${m[2]}` });
|
||||
});
|
||||
box.append(input);
|
||||
}
|
||||
if (o.kind === "value") {
|
||||
const list = hint ? opts.valuesFor(hint) : null;
|
||||
if (list && list.length) {
|
||||
const key = (v: string | number): string => `${typeof v}:${v}`;
|
||||
box.append(
|
||||
select(
|
||||
list.map((v) => ({ value: key(v), text: String(v) })),
|
||||
key(o.value),
|
||||
(k) => {
|
||||
const hitv = list.find((v) => key(v) === k);
|
||||
if (hitv !== undefined) onChange({ kind: "value", value: hitv });
|
||||
},
|
||||
tc("Value"),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const input = el("input", {
|
||||
className: "ui-input ui-input--sm ss-cond-value",
|
||||
attrs: { type: "text", "aria-label": tc("Value") },
|
||||
});
|
||||
input.value = String(o.value);
|
||||
input.addEventListener("change", () => {
|
||||
const t = input.value.trim();
|
||||
onChange({ kind: "value", value: t !== "" && Number.isFinite(Number(t)) ? Number(t) : t });
|
||||
});
|
||||
box.append(input);
|
||||
}
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
function openJob(
|
||||
ctx: SpreadsheetContext,
|
||||
opts: ConditionOptions,
|
||||
a1: string,
|
||||
draft: CondDraft | null,
|
||||
existing: string | null,
|
||||
): void {
|
||||
openModal({
|
||||
title: `${tc("Title")} — ${a1}`,
|
||||
closeLabel: tc("Close"),
|
||||
dialogClass: "ss-cond",
|
||||
mount: (body, close) => {
|
||||
let d: CondDraft = draft ?? {
|
||||
branches: [
|
||||
{
|
||||
left: "B1",
|
||||
op: "=",
|
||||
right: { kind: "value", value: "" },
|
||||
then: { kind: "value", value: 0 },
|
||||
},
|
||||
],
|
||||
otherwise: { kind: "value", value: 0 },
|
||||
};
|
||||
if (existing !== null && !draft) {
|
||||
body.append(
|
||||
el("p", { className: "ss-cond-note", text: tc("AsIs") }),
|
||||
el("code", { className: "ss-cond-formula", text: `=${existing}` }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const draw = (): void => {
|
||||
const set = (next: CondDraft): void => ((d = next), draw());
|
||||
const rows = d.branches.map((b, i) => {
|
||||
const edit = (patch: Partial<CondBranch>): void =>
|
||||
set({ ...d, branches: d.branches.map((x, j) => (j === i ? { ...x, ...patch } : x)) });
|
||||
return el("div", {
|
||||
className: "ss-cond-row",
|
||||
children: [
|
||||
el("span", { className: "ss-cond-label", text: tc(i ? "ElseIf" : "If") }),
|
||||
cellPick(opts, b.left, (cell) => edit({ left: cell }), tc("If")),
|
||||
select(
|
||||
COND_OPS.map((o) => ({ value: o, text: o })),
|
||||
b.op,
|
||||
(v) => edit({ op: v as CondOp }),
|
||||
"op",
|
||||
),
|
||||
operandPick(opts, b.right, b.left, (right) => edit({ right }), tc("Value")),
|
||||
tc("Then"),
|
||||
operandPick(opts, b.then, null, (then) => edit({ then }), tc("Then")),
|
||||
d.branches.length > 1
|
||||
? createButton({
|
||||
label: tc("Remove"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => set({ ...d, branches: d.branches.filter((_, j) => j !== i) }),
|
||||
})
|
||||
: "",
|
||||
],
|
||||
});
|
||||
});
|
||||
const last = d.branches[d.branches.length - 1];
|
||||
body.replaceChildren(
|
||||
...rows,
|
||||
el("div", {
|
||||
className: "ss-cond-row",
|
||||
children: [
|
||||
createButton({
|
||||
label: tc("AddElseIf"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
set({
|
||||
...d,
|
||||
branches: [
|
||||
...d.branches,
|
||||
{
|
||||
left: last.left,
|
||||
op: "=",
|
||||
right: { kind: "value", value: "" },
|
||||
then: { kind: "value", value: 0 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "ss-cond-row",
|
||||
children: [
|
||||
el("span", { className: "ss-cond-label", text: tc("Otherwise") }),
|
||||
operandPick(
|
||||
opts,
|
||||
d.otherwise,
|
||||
null,
|
||||
(otherwise) => set({ ...d, otherwise }),
|
||||
tc("Otherwise"),
|
||||
),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "ss-cond-row ss-cond-foot",
|
||||
children: [
|
||||
el("code", {
|
||||
className: "ss-cond-formula",
|
||||
text: `${tc("Target")} ${a1}: =${draftToFormula(d)}`,
|
||||
}),
|
||||
createButton({
|
||||
label: tc("Make"),
|
||||
variant: "filled",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
const sheet = ctx.sheet();
|
||||
const { 값: _v, ...keep } = sheet.칸[a1] ?? {};
|
||||
ctx.dispatch({
|
||||
종류: "칸",
|
||||
시트: sheet.id,
|
||||
칸: { [a1]: { ...keep, 식: draftToFormula(d) } },
|
||||
});
|
||||
close();
|
||||
},
|
||||
}),
|
||||
createButton({ label: tc("Cancel"), variant: "ghost", size: "sm", onClick: close }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
};
|
||||
draw();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 「조건 만들기」 한 줄 — conditions 를 준 편집기에만 붙음(계획서 39-9b).
|
||||
* 칸 메뉴(`spreadsheet_menu.ts`)가 있으면 그 끝 한 줄 · 없으면(미리보기 편집기) 같은 공용 메뉴에 이 줄만 */
|
||||
export function attachCondition(ctx: SpreadsheetContext, opts: ConditionOptions): PartHandle {
|
||||
const items = (r: number, c: number): Array<[string, () => void]> => {
|
||||
if (ctx.readOnly) return [];
|
||||
const a1 = toA1(r, c);
|
||||
return [
|
||||
[
|
||||
tc("Menu"),
|
||||
() => {
|
||||
const existing = ctx.sheet().칸[a1]?.식 ?? null;
|
||||
const draft = existing !== null ? formulaToDraft(existing) : null;
|
||||
openJob(ctx, opts, a1, draft, existing !== null && !draft ? existing : null);
|
||||
},
|
||||
],
|
||||
];
|
||||
};
|
||||
if (ctx.cellMenuExtra) {
|
||||
ctx.cellMenuExtra = items;
|
||||
return { root: null, refresh() {}, destroy: () => (ctx.cellMenuExtra = () => []) };
|
||||
}
|
||||
const menu = createMapContextMenu("ss");
|
||||
ctx.root.append(menu.element);
|
||||
const onContext = (e: MouseEvent): void => {
|
||||
const hit = ctx.grid.hitTest(e.clientX, e.clientY);
|
||||
if (!hit || hit.kind !== "cell") return;
|
||||
const list = items(hit.r, hit.c);
|
||||
if (!list.length) return;
|
||||
e.preventDefault();
|
||||
const box = ctx.root.getBoundingClientRect();
|
||||
menu.open(e.clientX - box.left, e.clientY - box.top, list);
|
||||
};
|
||||
ctx.root.addEventListener("contextmenu", onContext);
|
||||
return {
|
||||
root: menu.element,
|
||||
refresh() {},
|
||||
destroy() {
|
||||
ctx.root.removeEventListener("contextmenu", onContext);
|
||||
menu.close();
|
||||
menu.element.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_condition_text.ts
|
||||
* 「조건 만들기」 의 글 쪽(DOM 없음) — 줄들 ↔ IF 식 글 · 바깥 쉼표 가름 · 시험이 이 파일만 떼어 Node 로 돌림
|
||||
* ========================================================================== */
|
||||
|
||||
export type CondOp = "=" | "≠" | "≥" | "≤" | ">" | "<";
|
||||
export type CondOperand =
|
||||
{ kind: "cell"; cell: string } | { kind: "value"; value: number | string };
|
||||
export interface CondBranch {
|
||||
/** 비교하는 칸 */
|
||||
left: string;
|
||||
op: CondOp;
|
||||
right: CondOperand;
|
||||
then: CondOperand;
|
||||
}
|
||||
export interface CondDraft {
|
||||
branches: CondBranch[];
|
||||
otherwise: CondOperand;
|
||||
}
|
||||
|
||||
export const XL_OP: Record<CondOp, string> = {
|
||||
"=": "=",
|
||||
"≠": "<>",
|
||||
"≥": ">=",
|
||||
"≤": "<=",
|
||||
">": ">",
|
||||
"<": "<",
|
||||
};
|
||||
export const FROM_XL: Record<string, CondOp> = {
|
||||
"=": "=",
|
||||
"<>": "≠",
|
||||
">=": "≥",
|
||||
"<=": "≤",
|
||||
">": ">",
|
||||
"<": "<",
|
||||
};
|
||||
export const COND_OPS: CondOp[] = ["=", "≠", "≥", "≤", ">", "<"];
|
||||
|
||||
export const CELL = /^\$?([A-Z]{1,3})\$?(\d+)$/;
|
||||
|
||||
/* ── 글 ↔ 줄 (DOM 없음) ───────────────────────────────────────────────── */
|
||||
|
||||
export const operandText = (o: CondOperand): string =>
|
||||
o.kind === "cell"
|
||||
? o.cell
|
||||
: typeof o.value === "number"
|
||||
? String(o.value)
|
||||
: `"${o.value.replace(/"/g, '""')}"`;
|
||||
|
||||
/** 줄들 → 식 글(앞 = 뺌) — 아니면 만약 은 안쪽 IF */
|
||||
export function draftToFormula(d: CondDraft): string {
|
||||
return d.branches.reduceRight(
|
||||
(rest, b) =>
|
||||
`IF(${b.left}${XL_OP[b.op]}${operandText(b.right)},${operandText(b.then)},${rest})`,
|
||||
operandText(d.otherwise),
|
||||
);
|
||||
}
|
||||
|
||||
/** 바깥 쉼표로 가름 — 따옴표 · 괄호 안은 건너뜀 */
|
||||
export function splitArgs(s: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let quote = false;
|
||||
let from = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (ch === '"') quote = !quote;
|
||||
else if (quote) continue;
|
||||
else if (ch === "(") depth++;
|
||||
else if (ch === ")") depth--;
|
||||
else if (ch === "," && depth === 0) {
|
||||
out.push(s.slice(from, i));
|
||||
from = i + 1;
|
||||
}
|
||||
}
|
||||
out.push(s.slice(from));
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseOperand(raw: string): CondOperand | null {
|
||||
const t = raw.trim();
|
||||
const cell = CELL.exec(t);
|
||||
if (cell) return { kind: "cell", cell: `${cell[1]}${cell[2]}` };
|
||||
if (/^-?\d+(\.\d+)?$/.test(t)) return { kind: "value", value: Number(t) };
|
||||
const q = /^"((?:[^"]|"")*)"$/.exec(t);
|
||||
return q ? { kind: "value", value: q[1].replace(/""/g, '"') } : null;
|
||||
}
|
||||
|
||||
/** 바깥 괄호 하나로 감싼 IF( … ) 의 안쪽 글 — 아니면 null */
|
||||
export function ifBody(f: string): string | null {
|
||||
const t = f.trim();
|
||||
if (!/^IF\(/i.test(t) || !t.endsWith(")")) return null;
|
||||
let depth = 0;
|
||||
let quote = false;
|
||||
for (let i = 2; i < t.length; i++) {
|
||||
if (t[i] === '"') quote = !quote;
|
||||
else if (quote) continue;
|
||||
else if (t[i] === "(") depth++;
|
||||
else if (t[i] === ")" && --depth === 0 && i !== t.length - 1) return null;
|
||||
}
|
||||
return t.slice(3, -1);
|
||||
}
|
||||
|
||||
/** 식 글(앞 = 뺌) → 줄들 — 이 판이 만드는 꼴(칸 비교 · 단순 값 · 아니면 쪽 중첩)만 읽고 나머지는 null */
|
||||
export function formulaToDraft(formula: string): CondDraft | null {
|
||||
const body = ifBody(formula);
|
||||
if (body === null) return null;
|
||||
const args = splitArgs(body);
|
||||
if (args.length !== 3) return null;
|
||||
const m = /^\s*(.+?)\s*(>=|<=|<>|=|>|<)\s*(.+?)\s*$/.exec(args[0]);
|
||||
if (!m) return null;
|
||||
const left = parseOperand(m[1]);
|
||||
const right = parseOperand(m[3]);
|
||||
const then = parseOperand(args[1]);
|
||||
if (!left || left.kind !== "cell" || !right || !then) return null;
|
||||
const branch: CondBranch = { left: left.cell, op: FROM_XL[m[2]], right, then };
|
||||
const plain = parseOperand(args[2]);
|
||||
if (plain) return { branches: [branch], otherwise: plain };
|
||||
const rest = formulaToDraft(args[2]);
|
||||
return rest ? { branches: [branch, ...rest.branches], otherwise: rest.otherwise } : null;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_link_ask.ts (주인 D)
|
||||
* 같은 칸에 이미 다른 알약 — [함께 잇기](기본 · Enter) · [바꾸기] · [취소](Esc) 물음(40 마무리).
|
||||
* 알약 누름 · 끌어 놓기(`spreadsheet_links`) · M02 칸 지정 모달 · 엑셀 칸 고르기가 같이 씀 ·
|
||||
* 모양은 공용 확인창(`ui-confirm`) 그대로.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
|
||||
const TEXT = {
|
||||
ask: "{cell} 칸에 이미 {names} — 이 알약도 함께 이을까?",
|
||||
together: "함께 잇기",
|
||||
replace: "바꾸기",
|
||||
cancel: "취소",
|
||||
};
|
||||
|
||||
/** 「함께」 = 그 칸 다른 알약 그대로 · 「바꾸기」 = 다른 알약을 뺌 · null = 그만둠 */
|
||||
export type ShareChoice = "함께" | "바꾸기" | null;
|
||||
|
||||
export function askShare(cell: string, names: string[]): Promise<ShareChoice> {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = el("div", { className: "ui-confirm" });
|
||||
const back = document.activeElement as HTMLElement | null; // 닫힐 때 초점을 돌려놓을 자리
|
||||
const done = (choice: ShareChoice): void => {
|
||||
overlay.remove();
|
||||
if (back?.isConnected && back !== document.body) back.focus({ preventScroll: true });
|
||||
resolve(choice);
|
||||
};
|
||||
const together = createButton({
|
||||
label: TEXT.together,
|
||||
variant: "filled",
|
||||
onClick: () => done("함께"),
|
||||
});
|
||||
overlay.addEventListener("keydown", (ev) => {
|
||||
if (ev.key !== "Escape") return;
|
||||
ev.stopPropagation(); // 뒤 창 · 페이지 Esc 까지 닫지 않음
|
||||
done(null);
|
||||
});
|
||||
const message = TEXT.ask
|
||||
.replace("{cell}", cell)
|
||||
.replace("{names}", names.map((n) => `「${n}」`).join(" · "));
|
||||
overlay.append(
|
||||
el("div", {
|
||||
className: "ui-confirm__panel",
|
||||
children: [
|
||||
el("p", { className: "ui-confirm__message", text: message }),
|
||||
el("div", {
|
||||
className: "ui-confirm__actions",
|
||||
children: [
|
||||
createButton({ label: TEXT.cancel, variant: "ghost", onClick: () => done(null) }),
|
||||
createButton({
|
||||
label: TEXT.replace,
|
||||
variant: "ghost",
|
||||
onClick: () => done("바꾸기"),
|
||||
}),
|
||||
together,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
document.body.append(overlay);
|
||||
together.focus(); // Enter = 함께 잇기(기본)
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_link_color.ts (주인 D)
|
||||
* 이은 알약 색 — 알약 키로 정해지는 색상(hue). 알약 · 이은 칸 배경 · 칸 세모가 같은 색(PLAN 40-3).
|
||||
* 칸 24 개(30° 바퀴 12 + 15° 어긋난 바퀴 12) — 키 해시로 첫 자리 · 겹치면 다음 빈 자리(같은 문서 안에서 서로 다르게).
|
||||
* 밝기 · 채도는 테마마다 CSS(`spreadsheet_links.css` `--ss-link-l`) · 여기는 색상만 · DOM 모름(시험이 Node 로 부름).
|
||||
* ========================================================================== */
|
||||
|
||||
const SLOTS = 24;
|
||||
|
||||
/** FNV-1a 32비트 — 키 글자로 늘 같은 수 */
|
||||
function hash(text: string): number {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
h ^= text.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193) >>> 0;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
/** 자리 → 색상(°) — 0~11 = 30° 간격 · 12~23 = 그 사이(15° 어긋남) */
|
||||
const hueOf = (slot: number): number => (slot % 12) * 30 + (slot >= 12 ? 15 : 0);
|
||||
|
||||
/** 이은 알약 키들 → 키마다 색상(°) — 키 차례와 무관(정렬해 자리 배정) · 24 개 넘으면 다시 돎 */
|
||||
export function linkHues(keys: Iterable<string>): Map<string, number> {
|
||||
const out = new Map<string, number>();
|
||||
const used = new Set<number>();
|
||||
for (const key of [...new Set(keys)].sort()) {
|
||||
if (used.size >= SLOTS) used.clear();
|
||||
const start = hash(key) % 12;
|
||||
// 첫 바퀴(30°)를 다 쓴 뒤에야 둘째 바퀴 — 적을 때 서로 30° 넘게 벌어짐
|
||||
let slot = start;
|
||||
for (let i = 1; i < SLOTS && used.has(slot); i += 1)
|
||||
slot = (i < 12 ? 0 : 12) + ((start + i) % 12);
|
||||
used.add(slot);
|
||||
out.set(key, hueOf(slot));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_link_set.ts (주인 D)
|
||||
* 알약 키를 칸에 잇기 · 풀기 — 밖(M02 칸 지정 모달)에서 부르는 길(손잡이 `setLink`).
|
||||
* 알약 누름(`spreadsheet_links.ts` `link`)과 같은 규칙: 키 하나 = 칸 하나 · 칸 하나에 알약 여럿(같은 수량을 여러
|
||||
* 로직이 씀 · 40 마무리) — `replace` 면 그 칸의 다른 알약을 뺌 · 다른 시트에 있던 같은 키 연결은 빠짐. 바꿈은 `시트통째`(되돌리기 짝) · DOM 을 모름(시험이 Node 로 부름).
|
||||
* ========================================================================== */
|
||||
|
||||
import { parseA1, toA1 } from "./spreadsheet_address";
|
||||
import type { CellLink, Command, LinkMaterial, Workbook } from "./spreadsheet_types";
|
||||
|
||||
/** 연결에 같이 적을 알약 정보 — 없으면 있던 연결 것을 그대로 */
|
||||
export interface LinkInfo {
|
||||
이름?: string;
|
||||
단위?: string;
|
||||
자재?: LinkMaterial;
|
||||
}
|
||||
|
||||
/** 칸 주소 맞춤(「 b6 」 → 「B6」) — 틀리면 null */
|
||||
export function normA1(cell: string): string | null {
|
||||
const at = parseA1(cell.trim().toUpperCase());
|
||||
return at ? toA1(at.r, at.c) : null;
|
||||
}
|
||||
|
||||
/** 그 시트 그 칸에 이은 다른 알약(`key` 빼고 · 이은 차례) — 물음 「함께 잇기 · 바꾸기」 에 씀 */
|
||||
export function linksAt(book: Workbook, sheetId: string, cell: string, key: string): CellLink[] {
|
||||
const a1 = normA1(cell);
|
||||
const sheet = book.시트.find((s) => s.id === sheetId);
|
||||
return (sheet?.연결 ?? []).filter((l) => l.칸 === a1 && l.키 !== key);
|
||||
}
|
||||
|
||||
/** `칸`(A1 · null = 풀기)을 `sheetId` 시트에 이을 명령 — 바뀔 것 없으면 null · 칸 주소가 틀리면 false ·
|
||||
* `replace` = 그 칸의 다른 알약 연결을 뺌(아니면 함께 둠) */
|
||||
export function linkCommand(
|
||||
book: Workbook,
|
||||
sheetId: string,
|
||||
key: string,
|
||||
cell: string | null,
|
||||
info: LinkInfo = {},
|
||||
replace = false,
|
||||
): Command | null | false {
|
||||
const at = cell === null ? null : parseA1(cell.trim().toUpperCase());
|
||||
if (cell !== null && !at) return false;
|
||||
const a1 = at ? toA1(at.r, at.c) : null;
|
||||
const old = book.시트.flatMap((s) => s.연결 ?? []).find((l) => l.키 === key);
|
||||
const home = book.시트.find((s) => s.id === sheetId);
|
||||
if (a1 && !home) return false;
|
||||
const others = a1 && replace ? linksAt(book, sheetId, a1, key).length : 0;
|
||||
if (a1 && !others && home?.연결?.some((l) => l.키 === key && l.칸 === a1)) return null; // 이미 그 칸
|
||||
const one: CellLink | null = a1 ? { 키: key, 칸: a1 } : null;
|
||||
if (one) {
|
||||
const 이름 = info.이름 ?? old?.이름;
|
||||
const 단위 = info.단위 ?? old?.단위;
|
||||
const 자재 = info.자재 ?? old?.자재;
|
||||
if (이름) one.이름 = 이름;
|
||||
if (단위) one.단위 = 단위;
|
||||
if (자재) one.자재 = 자재;
|
||||
}
|
||||
const commands: Command[] = [];
|
||||
for (const s of book.시트) {
|
||||
const was = s.연결 ?? [];
|
||||
const rest = was.filter((l) => l.키 !== key && !(replace && s === home && a1 && l.칸 === a1));
|
||||
const next = s === home && one ? [...rest, one] : rest;
|
||||
if (next.length === was.length && next.every((l, i) => l === was[i])) continue;
|
||||
const copy = structuredClone(s);
|
||||
if (next.length) copy.연결 = next;
|
||||
else delete copy.연결;
|
||||
commands.push({ 종류: "시트통째", 시트: copy });
|
||||
}
|
||||
if (!commands.length) return null;
|
||||
return commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands };
|
||||
}
|
||||
|
||||
/** 면적 문서에 알약 키를 잇기 · 풀기(`칸` null) — 활성 시트에 · 새 문서를 돌려줌(받은 문서는 그대로 ·
|
||||
* 번호 등 계약 밖 칸도 그대로) · 칸 주소가 틀리면 null · 같은 칸 다른 알약은 함께 둠(`replace` 면 뺌). 일위대가 [저장] 이 면적 문서를 함께 쓸 때 부름 */
|
||||
export function linkAreaCell<T extends Workbook>(
|
||||
book: T,
|
||||
key: string,
|
||||
cell: string | null,
|
||||
info: LinkInfo = {},
|
||||
replace = false,
|
||||
): T | null {
|
||||
const sheet = book.시트.find((s) => s.id === book.활성) ?? book.시트[0];
|
||||
if (!sheet) return null;
|
||||
const command = linkCommand(book, sheet.id, key, cell, info, replace);
|
||||
if (command === false) return null;
|
||||
const next = structuredClone(book);
|
||||
const list = !command ? [] : command.종류 === "묶음" ? command.명령 : [command];
|
||||
for (const c of list)
|
||||
if (c.종류 === "시트통째") next.시트 = next.시트.map((s) => (s.id === c.시트.id ? c.시트 : s));
|
||||
return next;
|
||||
}
|
||||
@@ -33,14 +33,55 @@
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* 연결됨 — 흐림(연결 현황) */
|
||||
.ss-link.is-linked {
|
||||
opacity: 0.5;
|
||||
border-style: dashed;
|
||||
/* 이은 알약 색(40-3) — 색상 = 알약 키(`--ss-link-h` · spreadsheet_link_color) · 밝기 · 옅기 = 테마 */
|
||||
.ss {
|
||||
--ss-link-s: 70%;
|
||||
--ss-link-l: 31%;
|
||||
--ss-link-fill: 0.16;
|
||||
}
|
||||
|
||||
.ss-link.is-linked:hover {
|
||||
opacity: 0.8;
|
||||
:root[data-theme="dark"] .ss {
|
||||
--ss-link-l: 66%;
|
||||
--ss-link-fill: 0.26;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .ss {
|
||||
--ss-link-l: 66%;
|
||||
--ss-link-fill: 0.26;
|
||||
}
|
||||
}
|
||||
|
||||
/* 연결됨 — 알약 색 테두리 · 왼쪽 띠 · 옅은 배경(글은 본문 색 그대로 · 읽힘) */
|
||||
.ss-link.is-linked {
|
||||
border-color: hsl(var(--ss-link-h, 0) var(--ss-link-s) var(--ss-link-l));
|
||||
background: hsl(var(--ss-link-h, 0) var(--ss-link-s) 50% / var(--ss-link-fill));
|
||||
box-shadow: inset 4px 0 0 hsl(var(--ss-link-h, 0) var(--ss-link-s) var(--ss-link-l));
|
||||
}
|
||||
|
||||
.ss-link.is-linked code {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.ss-link[draggable="true"] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
/* 이은 칸 — 알약 색 옅게(칸 글 위 · 누름은 칸으로 지나감) */
|
||||
.ss-link-cell {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
background: hsl(var(--ss-link-h, 0) var(--ss-link-s) 50% / var(--ss-link-fill));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 알약을 끌 때 놓일 칸 */
|
||||
.ss-link-drop {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
box-sizing: border-box;
|
||||
border: 2px dashed var(--color-accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ss-link--add {
|
||||
@@ -55,7 +96,7 @@
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 6px solid transparent;
|
||||
border-left: 6px solid var(--color-accent);
|
||||
border-left: 6px solid hsl(var(--ss-link-h, 0) var(--ss-link-s) var(--ss-link-l));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -136,3 +177,21 @@
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 칸 하나에 알약 여럿 — 오른위 작은 수(먼저 이은 알약 색) */
|
||||
.ss-link-count {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
min-width: 14px;
|
||||
height: 14px;
|
||||
padding: 0 3px;
|
||||
border-radius: 7px;
|
||||
background: hsl(var(--ss-link-h, 0) var(--ss-link-s) var(--ss-link-l));
|
||||
color: var(--color-canvas);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 14px;
|
||||
text-align: center;
|
||||
transform: translate(-100%, 0);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_links.ts (주인 D)
|
||||
* 아래 알약 막대(표 · 탭 · 합계 줄 아래 맨 끝) — 면적산출(입력값) · 재료산출(자재) 알약을 칸에 연결(PLAN 13장).
|
||||
* 칸을 고른 채 밝은 알약을 누르면 그 칸에 연결(알약 흐려짐 + 칸 주소 · 칸 왼아래 작은 세모) ·
|
||||
* 흐린 알약을 누르면 연결 칸으로 · 우클릭 [연결 풀기] · 자재 모드는 [자재 더하기] → 고르기 창 → 새 알약.
|
||||
* 칸을 고른 채 밝은 알약을 누르면 그 칸에 연결 · 알약을 끌어 칸에 놓아도 연결(40-3) · 칸 하나에 알약 여럿 —
|
||||
* 이미 다른 알약이 있으면 [함께 잇기] · [바꾸기] · [취소] 물음(`spreadsheet_link_ask`) · 이은 알약 = 키로 정해지는
|
||||
* 색(`spreadsheet_link_color`) · 이은 칸 = 먼저 이은 알약 색 옅게 + 왼아래 세모 + 여럿이면 오른위 작은 수 ·
|
||||
* 칸에 마우스를 올리면 이은 알약 이름들 · 이은 알약을 누르면 연결 칸으로 · 우클릭 [연결 풀기] ·
|
||||
* 자재 모드는 [자재 더하기] → 고르기 창 → 새 알약.
|
||||
* 자재 모드 알약 목록 = 호스트(M02)가 `항목` 으로 줌(자재 정보 포함 · 저장은 마스터 자재 조합) — [자재 더하기] ·
|
||||
* 우클릭 [목록에서 빼기] 때 `onItemsChange(목록)` 으로 알림(부른 쪽이 마스터에 적음 · 여기는 안 적음).
|
||||
* 저장 = 시트 `연결`(계약) · 바꿈은 `시트통째` 명령(되돌리기 짝) · 행열 넣기 · 지우기는 `applyLinked` 가
|
||||
@@ -14,6 +17,9 @@ import { createMapContextMenu } from "@ui/ui_template_context_menu";
|
||||
import { el, showToast } from "@ui/ui_template_elements";
|
||||
import { parseA1, quoteSheet, toA1 } from "./spreadsheet_address";
|
||||
import { applyCommand } from "./spreadsheet_commands";
|
||||
import { askShare } from "./spreadsheet_link_ask";
|
||||
import { linkHues } from "./spreadsheet_link_color";
|
||||
import { linkCommand, linksAt } from "./spreadsheet_link_set";
|
||||
import type { slotOf } from "./spreadsheet_editor";
|
||||
import { shiftSpan } from "./spreadsheet_refshift";
|
||||
import { selectCell } from "./spreadsheet_selection";
|
||||
@@ -55,8 +61,8 @@ export interface Links extends PartHandle {
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
bar: "알약 — 칸을 고른 채 누르면 연결 · 흐린 알약은 누르면 연결 칸으로 · 우클릭 풀기",
|
||||
free: "칸을 고른 채 누르면 이 칸에 연결",
|
||||
bar: "알약 — 칸을 고른 채 누르거나 끌어 칸에 놓으면 연결 · 색 알약은 누르면 연결 칸으로 · 우클릭 풀기",
|
||||
free: "칸을 고른 채 누르거나 끌어 칸에 놓으면 이 칸에 연결",
|
||||
linked: "누르면 연결 칸으로 · 우클릭 연결 풀기",
|
||||
unlink: "연결 풀기",
|
||||
remove: "목록에서 빼기",
|
||||
@@ -136,6 +142,9 @@ export function mountLinks(
|
||||
/** 알약 목록 — 호스트가 준 항목 + 더한 자재(자재 모드 · 바뀌면 호스트에 알림) */
|
||||
const items: LinkItem[] = opts ? [...opts.항목] : [];
|
||||
let dots: HTMLElement[] = [];
|
||||
/** 끄는 중인 알약 · 놓을 칸 표시 */
|
||||
let dragging: LinkItem | null = null;
|
||||
const dropMark = el("div", { className: "ss-link-drop" });
|
||||
let pick: HTMLElement | null = null;
|
||||
|
||||
/** 키 → 연결(시트 · 줄) */
|
||||
@@ -153,15 +162,22 @@ export function mountLinks(
|
||||
ctx.dispatch({ 종류: "시트통째", 시트: next });
|
||||
}
|
||||
|
||||
function link(item: LinkItem): void {
|
||||
/** 알약을 칸에 이음 — `at` 없으면 지금 고른 칸 · 그 칸에 다른 알약이 있으면 함께 · 바꾸기 · 취소 물음 */
|
||||
async function link(item: LinkItem, at?: string): Promise<void> {
|
||||
if (ctx.readOnly) return;
|
||||
const sheet = ctx.sheet();
|
||||
const 칸 = toA1(ctx.selection.활성.r, ctx.selection.활성.c);
|
||||
const rest = (sheet.연결 ?? []).filter((l) => l.칸 !== 칸 && l.키 !== item.키);
|
||||
const one: CellLink = { 키: item.키, 이름: item.이름, 칸 };
|
||||
if (item.단위) one.단위 = item.단위;
|
||||
if (item.자재) one.자재 = item.자재;
|
||||
put(sheet, [...rest, one]);
|
||||
const 칸 = at ?? toA1(ctx.selection.활성.r, ctx.selection.활성.c);
|
||||
const others = linksAt(ctx.book, sheet.id, 칸, item.키);
|
||||
const choice = others.length
|
||||
? await askShare(
|
||||
칸,
|
||||
others.map((l) => l.이름 ?? l.키),
|
||||
)
|
||||
: "함께";
|
||||
if (!choice) return back();
|
||||
const info = { 이름: item.이름, 단위: item.단위, 자재: item.자재 };
|
||||
const command = linkCommand(ctx.book, sheet.id, item.키, 칸, info, choice === "바꾸기");
|
||||
if (command) ctx.dispatch(command);
|
||||
back();
|
||||
}
|
||||
|
||||
@@ -184,7 +200,11 @@ export function mountLinks(
|
||||
|
||||
const commit = (): void => opts?.onItemsChange?.(items.map((i) => ({ ...i })));
|
||||
|
||||
function pill(item: LinkItem, hit: { sheet: Sheet; link: CellLink } | undefined) {
|
||||
function pill(
|
||||
item: LinkItem,
|
||||
hit: { sheet: Sheet; link: CellLink } | undefined,
|
||||
hue: number | undefined,
|
||||
) {
|
||||
const where = hit
|
||||
? hit.sheet.id === ctx.selection.시트
|
||||
? hit.link.칸
|
||||
@@ -199,8 +219,20 @@ export function mountLinks(
|
||||
...(where ? [el("code", { text: where })] : []),
|
||||
],
|
||||
});
|
||||
b.addEventListener("mousedown", (e) => e.preventDefault()); // 초점은 격자에 둠
|
||||
b.addEventListener("click", () => (hit ? goTo(hit.sheet, hit.link.칸) : link(item)));
|
||||
if (hue !== undefined) b.style.setProperty("--ss-link-h", String(hue));
|
||||
b.draggable = !ctx.readOnly;
|
||||
b.addEventListener("dragstart", (e) => {
|
||||
dragging = item;
|
||||
e.dataTransfer?.setData("text/plain", item.키);
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = "link";
|
||||
});
|
||||
b.addEventListener("dragend", () => {
|
||||
dragging = null;
|
||||
dropMark.remove();
|
||||
});
|
||||
// 초점은 격자에 둠 — 끌 수 있는 알약은 막으면 끌기가 안 돼 그대로(누른 뒤 `back` 이 격자로 돌림)
|
||||
if (!b.draggable) b.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
b.addEventListener("click", () => (hit ? goTo(hit.sheet, hit.link.칸) : void link(item)));
|
||||
const unlink = (): void => {
|
||||
if (hit)
|
||||
put(
|
||||
@@ -274,7 +306,8 @@ export function mountLinks(
|
||||
? ofMaterial(link.자재)
|
||||
: { 키: link.키, 이름: link.이름 ?? link.키, 단위: link.단위 },
|
||||
);
|
||||
const pills = shown.map((item) => pill(item, map.get(item.키)));
|
||||
const hues = linkHues(map.keys());
|
||||
const pills = shown.map((item) => pill(item, map.get(item.키), hues.get(item.키)));
|
||||
if (opts.모드 === "자재" && opts.자재찾기 && !ctx.readOnly) {
|
||||
const more = el("button", {
|
||||
className: "ss-link ss-link--add",
|
||||
@@ -287,23 +320,80 @@ export function mountLinks(
|
||||
bar.replaceChildren(...pills);
|
||||
}
|
||||
|
||||
/** 칸 왼아래 작은 세모 — 칸이 든 판 층에 얹어 스크롤을 따라감 */
|
||||
/** 이은 칸 — 알약 색 옅은 배경 + 왼아래 작은 세모 · 칸이 든 판 층에 얹어 스크롤을 따라감 */
|
||||
function placeDots(): void {
|
||||
for (const d of dots) d.remove();
|
||||
dots = [];
|
||||
for (const l of ctx.sheet().연결 ?? []) {
|
||||
const p = parseA1(l.칸);
|
||||
const hues = linkHues(found().keys());
|
||||
// 칸마다 이은 알약(이은 차례) — 색 = 먼저 이은 알약 · 둘 넘으면 수
|
||||
const byCell = new Map<string, CellLink[]>();
|
||||
for (const l of ctx.sheet().연결 ?? []) byCell.set(l.칸, [...(byCell.get(l.칸) ?? []), l]);
|
||||
for (const [a1, list] of byCell) {
|
||||
const p = parseA1(a1);
|
||||
if (!p) continue;
|
||||
const { layer, box } = slot(ctx, p.r, p.c);
|
||||
if (!box.w || !box.h) continue; // 숨긴 행열
|
||||
const first = list[0];
|
||||
const tint = el("div", { className: "ss-link-cell", attrs: { "data-key": first.키 } });
|
||||
tint.style.cssText = `left:${box.x}px;top:${box.y}px;width:${box.w}px;height:${box.h}px`;
|
||||
const dot = el("div", { className: "ss-link-dot" });
|
||||
dot.style.left = `${box.x}px`;
|
||||
dot.style.top = `${box.y + box.h - 7}px`;
|
||||
layer.append(dot);
|
||||
dots.push(dot);
|
||||
const marks = [tint, dot];
|
||||
if (list.length > 1) {
|
||||
const count = el("div", { className: "ss-link-count", text: String(list.length) });
|
||||
count.style.left = `${box.x + box.w}px`;
|
||||
count.style.top = `${box.y}px`;
|
||||
marks.push(count);
|
||||
}
|
||||
for (const d of marks) d.style.setProperty("--ss-link-h", String(hues.get(first.키) ?? 0));
|
||||
layer.append(...marks);
|
||||
dots.push(...marks);
|
||||
}
|
||||
}
|
||||
|
||||
/** 칸에 마우스를 올리면 이은 알약 이름들(격자 title) — 이은 칸이 아니면 지움 */
|
||||
let tipped = false;
|
||||
const onHover = (e: MouseEvent): void => {
|
||||
const hit = ctx.grid.hitTest(e.clientX, e.clientY);
|
||||
const a1 = hit && hit.r >= 0 && hit.c >= 0 ? toA1(hit.r, hit.c) : null;
|
||||
const names = a1
|
||||
? (ctx.sheet().연결 ?? []).filter((l) => l.칸 === a1).map((l) => l.이름 ?? l.키)
|
||||
: [];
|
||||
if (names.length) ctx.grid.root.title = names.join(" · ");
|
||||
else if (tipped) ctx.grid.root.removeAttribute("title");
|
||||
tipped = names.length > 0;
|
||||
};
|
||||
|
||||
// ── 알약 끌어 칸에 놓기(40-3) ──────────────────────────────────────────
|
||||
/** 끄는 알약 아래 칸(A1) — 머리 · 격자 밖이면 null */
|
||||
function cellAt(e: DragEvent): { r: number; c: number } | null {
|
||||
const hit = ctx.grid.hitTest(e.clientX, e.clientY);
|
||||
return hit && hit.r >= 0 && hit.c >= 0 ? { r: hit.r, c: hit.c } : null;
|
||||
}
|
||||
const onDragOver = (e: DragEvent): void => {
|
||||
const at = dragging && !ctx.readOnly ? cellAt(e) : null;
|
||||
if (!at) return void dropMark.remove();
|
||||
e.preventDefault(); // 놓을 수 있음
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = "link";
|
||||
const { layer, box } = slot(ctx, at.r, at.c);
|
||||
dropMark.style.cssText = `left:${box.x}px;top:${box.y}px;width:${box.w}px;height:${box.h}px`;
|
||||
if (dropMark.parentElement !== layer) layer.append(dropMark);
|
||||
};
|
||||
const onDragLeave = (e: DragEvent): void => {
|
||||
if (!ctx.grid.root.contains(e.relatedTarget as Node | null)) dropMark.remove();
|
||||
};
|
||||
const onDrop = async (e: DragEvent): Promise<void> => {
|
||||
const item = dragging;
|
||||
const at = item && !ctx.readOnly ? cellAt(e) : null;
|
||||
dropMark.remove();
|
||||
dragging = null;
|
||||
if (!item || !at) return;
|
||||
e.preventDefault();
|
||||
ctx.select(selectCell(ctx.sheet(), at.r, at.c), true);
|
||||
await link(item, toA1(at.r, at.c));
|
||||
};
|
||||
|
||||
// ── 자재 고르기 창 ──────────────────────────────────────────────────────
|
||||
function closePick(): void {
|
||||
pick?.remove();
|
||||
@@ -384,6 +474,11 @@ export function mountLinks(
|
||||
|
||||
const onZoom = (): void => placeDots();
|
||||
ctx.grid.root.addEventListener("aislo-grid-zoom", onZoom);
|
||||
const onDropEvent = (e: DragEvent): void => void onDrop(e);
|
||||
ctx.grid.root.addEventListener("dragover", onDragOver);
|
||||
ctx.grid.root.addEventListener("dragleave", onDragLeave);
|
||||
ctx.grid.root.addEventListener("drop", onDropEvent);
|
||||
ctx.grid.root.addEventListener("mousemove", onHover);
|
||||
|
||||
return {
|
||||
root: opts ? bar : null,
|
||||
@@ -394,6 +489,11 @@ export function mountLinks(
|
||||
},
|
||||
destroy() {
|
||||
ctx.grid.root.removeEventListener("aislo-grid-zoom", onZoom);
|
||||
ctx.grid.root.removeEventListener("dragover", onDragOver);
|
||||
ctx.grid.root.removeEventListener("dragleave", onDragLeave);
|
||||
ctx.grid.root.removeEventListener("drop", onDropEvent);
|
||||
ctx.grid.root.removeEventListener("mousemove", onHover);
|
||||
dropMark.remove();
|
||||
closePick();
|
||||
for (const d of dots) d.remove();
|
||||
menu.element.remove();
|
||||
|
||||
@@ -32,6 +32,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
|
||||
|
||||
const menu = createMapContextMenu("ss");
|
||||
ctx.root.append(menu.element);
|
||||
ctx.cellMenuExtra ??= () => []; // 칸 메뉴 있음 — 조건 만들기가 이 끝에 붙음
|
||||
|
||||
function isMerged(range: CellRange): boolean {
|
||||
return (ctx.sheet().병합 ?? []).includes(rangeToA1(range));
|
||||
@@ -106,6 +107,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
|
||||
...(note
|
||||
? ([[st("MenuCommentDelete"), () => deleteComment(ctx, r, c)]] as MapContextMenuItem[])
|
||||
: []),
|
||||
...(ctx.cellMenuExtra?.(r, c) ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@
|
||||
}
|
||||
|
||||
.ss-statusbar {
|
||||
position: relative; /* 통계 고르기 메뉴(공용 `.ui-ctx-menu` · absolute)의 기준 */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
|
||||
@@ -241,39 +241,7 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── 우클릭 메뉴(그리드 · 탭 공용, `ui_template_context_menu.ts`) ───────────── */
|
||||
|
||||
.ss__context-menu {
|
||||
position: absolute;
|
||||
z-index: var(--z-dropdown);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 160px;
|
||||
padding: var(--spacing-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.ss__context-menu[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ss__context-menu-item {
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-body);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ss__context-menu-item:hover {
|
||||
background: var(--color-surface-sunken, var(--color-mist-violet));
|
||||
}
|
||||
/* ── 우클릭 메뉴(그리드 · 탭 공용, `ui_template_context_menu.ts`) — 모양 · 자리는 공용 `.ui-ctx-menu` ── */
|
||||
|
||||
.ss__context-menu-item.is-danger {
|
||||
color: var(--color-danger);
|
||||
|
||||
@@ -109,6 +109,9 @@ export interface SpreadsheetContext {
|
||||
showSheet(sheetId: string): void;
|
||||
grid: GridHandle;
|
||||
editor: EditorHandle;
|
||||
/** 칸 메뉴 끝에 붙일 줄 — 칸 메뉴(`spreadsheet_menu.ts`)가 빈 것으로 세움(= 칸 메뉴 있음) ·
|
||||
* 조건 만들기(`spreadsheet_condition.ts`)가 채움 · 없으면 칸 메뉴 없는 편집기 */
|
||||
cellMenuExtra?: (r: number, c: number) => Array<[label: string, action: () => void]>;
|
||||
}
|
||||
|
||||
/** E 부품 — D 가 `mountToolbar(ctx)` 등으로 붙이고 고름 · 문서가 바뀌면 `refresh()`. */
|
||||
|
||||
@@ -178,7 +178,6 @@ function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null)
|
||||
settingCard(L("B01_Dashboard_MasterElements"), L("B01_Dashboard_PermSourceRead"), () =>
|
||||
navigateTo(ROUTES.M01_MASTER_DATA),
|
||||
),
|
||||
m02(L("B01_Dashboard_CardUnitCombo"), "일위대가", "master", desc),
|
||||
m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", "master", desc),
|
||||
m02(L("B01_Dashboard_CardStructOption"), "옵션", "master", desc),
|
||||
m02(L("B01_Dashboard_CardDrawing"), "도면", "master", desc),
|
||||
@@ -193,10 +192,7 @@ function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null)
|
||||
L(companyAdmin ? "B01_Dashboard_PermCompanyWrite" : "B01_Dashboard_PermCompanyRead"),
|
||||
L("B01_Dashboard_PermPersonalWrite"),
|
||||
].join(" · ");
|
||||
wrap.append(
|
||||
m02(L("B01_Dashboard_CardUnitCombo"), "일위대가", root, desc),
|
||||
m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", root, desc),
|
||||
);
|
||||
wrap.append(m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", root, desc));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
API_SURFACE_BUILD_TIMEOUT_MS,
|
||||
API_TIMEOUT_MS,
|
||||
} from "@config/config_frontend";
|
||||
import type { SummaryItem } from "../common_util/common_util_summary_item";
|
||||
import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item";
|
||||
|
||||
/** 지표면 분석 실행 요청 (SurfaceAnalyzeRequest) */
|
||||
export interface SurfaceAnalyzeRequest {
|
||||
@@ -494,6 +494,8 @@ export interface DetailPipePoint {
|
||||
options?: Record<string, string | number>;
|
||||
/** 이 관이 뜻하는 구조물 집계표 항목(21-1 · options 밖 칸) — 없으면 null. */
|
||||
summary_item?: SummaryItem | null;
|
||||
/** 옵션 그룹마다 고른 집계 항목 `{그룹 id: 참조}`(PLAN 46-3) — 없으면 그룹 미지정. */
|
||||
group_items?: GroupItems | null;
|
||||
}
|
||||
|
||||
/** 편집·저장 요청에 싣는 관 1건 — 좌표(lonlat)는 서버가 다시 계산하므로 뺀다. */
|
||||
|
||||
@@ -23,6 +23,7 @@ from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import drainage_dir
|
||||
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
|
||||
from common_util.common_util_drainage_context import DrainageContext, load_drainage_context
|
||||
from common_util.common_util_drainage_detail import DrainageDetail, build_detail
|
||||
from common_util.common_util_drainage_pipes import (
|
||||
@@ -30,6 +31,7 @@ from common_util.common_util_drainage_pipes import (
|
||||
PIPE_SOURCE_USER,
|
||||
PipePoint,
|
||||
carry_facility_attributes,
|
||||
carry_indices,
|
||||
clear_pipe_points,
|
||||
load_pipe_points,
|
||||
parse_pipe_points,
|
||||
@@ -64,14 +66,40 @@ def _build(
|
||||
if points:
|
||||
_retag(detail.pipes, points)
|
||||
rebuilt = [PipePoint(chainage_m=pipe.chainage_m, source=pipe.reason) for pipe in detail.pipes]
|
||||
# 원본에 없던 자리 = 이번에 새로 놓인 관(자동 배치 · 간격 보충) — 등록부 기본값을 받을 관
|
||||
fresh = [
|
||||
pick is None
|
||||
for pick in carry_indices(
|
||||
[point.chainage_m for point in rebuilt], [point.chainage_m for point in points or []]
|
||||
)
|
||||
]
|
||||
# 계산기는 chainage만 다뤄 시설 종류·구간·옵션이 사라진다 — 원본에서 되붙인다
|
||||
# (2026-08-17 계곡 통과 시설: 배관/BOX암거/물넘이/세월교).
|
||||
carried = carry_facility_attributes(rebuilt, points or [])
|
||||
# 되붙인 뒤에 추천을 얹는다 — 순서가 뒤바뀌면 승계된 사용자 값이 추천에 덮인다.
|
||||
_apply_recommendations(detail, carried)
|
||||
_apply_new_defaults([point for point, new in zip(carried, fresh) if new])
|
||||
return detail, carried
|
||||
|
||||
|
||||
def _apply_new_defaults(points: list[PipePoint]) -> None:
|
||||
"""새로 놓인 관에만 등록부 기본값을 **빈 키에** 채움(PLAN 46-7 · 추천 뒤 — 추천 관경이 이김).
|
||||
|
||||
시설이 생길 때 기본값을 칸에 실제 값으로 넣어 두고 사용자가 고침 — 이미 있던 관의 빈 칸은 그대로
|
||||
(옛 프로젝트 빈칸 = 미확정 유지 · 사용자 결정 2026-10-05).
|
||||
"""
|
||||
types = structure_type_map()
|
||||
for point in points:
|
||||
definition = types.get(point.facility)
|
||||
if definition is None:
|
||||
continue
|
||||
options = dict(point.options or {})
|
||||
for field in definition.options:
|
||||
if field.key not in options and field.default not in (None, ""):
|
||||
options[field.key] = field.default
|
||||
point.options = options or None
|
||||
|
||||
|
||||
def _retag(pipes: list[StructureCandidate], points: list[PipePoint]) -> None:
|
||||
"""확정 좌표로 되돌아온 관에 원래의 생성 사유를 다시 붙인다."""
|
||||
by_chainage = {round(point.chainage_m, 2): point.source for point in points}
|
||||
|
||||
@@ -585,7 +585,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
event.clientX - rect.left,
|
||||
event.clientY - rect.top,
|
||||
);
|
||||
// 계획선이나 관 마커 위에서만 전용 메뉴를 띄운다 — 그 밖은 브라우저 메뉴를 그대로 둔다.
|
||||
// 계획선이나 관 마커 위에서만 전용 메뉴를 띄운다 — 그 밖은 아무 메뉴도 안 뜸(브라우저 메뉴는 공용 진입이 막음).
|
||||
if (opened) event.preventDefault();
|
||||
});
|
||||
|
||||
|
||||
@@ -87,37 +87,10 @@
|
||||
box-shadow: inset 0 0 0 1px var(--b04-layer-color);
|
||||
}
|
||||
|
||||
/* 관 매설 우클릭 메뉴 — 지도 뷰포트 기준 절대 위치. */
|
||||
/* 관 매설 우클릭 메뉴 — 지도 뷰포트 기준 절대 위치 · 모양은 공용 `.ui-ctx-menu`. */
|
||||
.b04-map__context-menu {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 140px;
|
||||
padding: var(--spacing-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: 0 4px 16px rgb(0 0 0 / 25%);
|
||||
}
|
||||
|
||||
.b04-map__context-menu[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b04-map__context-menu-item {
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border: 0;
|
||||
border-radius: var(--radius-cards);
|
||||
background: transparent;
|
||||
color: var(--color-text-body);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b04-map__context-menu-item:hover {
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
|
||||
.b04-map__viewport {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { readState, writeState } from "../A00_Common/b_page_state";
|
||||
import type { SummaryItem } from "../common_util/common_util_summary_item";
|
||||
import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item";
|
||||
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
|
||||
|
||||
/** 배치형태 — 점형(측점 1개) / 구간형(시~종점) / 부지형(위치+면적). */
|
||||
@@ -77,6 +77,15 @@ export interface StructureType {
|
||||
design_owner: string | null;
|
||||
reference_only: boolean;
|
||||
enabled: boolean;
|
||||
/** 옵션 그룹(M02 PLAN 46) — 폼의 보조 컨테이너(제목 = name) · 안의 옵션 키 · 빈 그룹도 됨. */
|
||||
groups?: StructureOptionGroup[];
|
||||
}
|
||||
|
||||
/** 옵션 그룹 한 개 — 서버가 M02 「구조물 옵션 보이기」 그룹 표로 채움. */
|
||||
export interface StructureOptionGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
options: string[];
|
||||
}
|
||||
|
||||
export interface StructureInstance {
|
||||
@@ -94,6 +103,8 @@ export interface StructureInstance {
|
||||
geometry: Record<string, unknown> | null;
|
||||
/** 이 구조물이 뜻하는 구조물 집계표 항목(21-1) — 없으면 null(미지정 · 집계표에 안 들어감). */
|
||||
summary_item?: SummaryItem | null;
|
||||
/** 옵션 그룹마다 고른 집계 항목 `{그룹 id: 참조}`(PLAN 46-3) — 없으면 그룹 미지정. */
|
||||
group_items?: GroupItems | null;
|
||||
}
|
||||
|
||||
interface StructureTypesResponse {
|
||||
|
||||
@@ -382,9 +382,10 @@
|
||||
"label": "월류 폭",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": null,
|
||||
"required": true,
|
||||
"phase": "detail"
|
||||
"default": 5,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"default_basis": "물넘이 월류 폭 기본 5m — 사용자 확정값(엔진 FORD_PAVEMENT_DEFAULT_WIDTH_M 과 같음)"
|
||||
},
|
||||
{
|
||||
"key": "ford_slope_pct",
|
||||
@@ -458,9 +459,10 @@
|
||||
"label": "월류 폭",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": null,
|
||||
"required": true,
|
||||
"phase": "detail"
|
||||
"default": 10,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"default_basis": "세월교 월류 폭 기본 10m — 사용자 확정값(엔진 FORD_DEFAULT_WIDTH_M 과 같음)"
|
||||
},
|
||||
{
|
||||
"key": "wing_in",
|
||||
|
||||
@@ -14,7 +14,7 @@ from typing import Any, Iterable
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, structure_type_map
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Options import hidden_keys
|
||||
from M02_MasterTemplete.StructOptions.M02_SO_Options import hidden_keys
|
||||
|
||||
STRUCTURES_FILE_NAME = "structures.json"
|
||||
_STAGE_DIR = os.path.join("B05_Profile", "route")
|
||||
|
||||
@@ -42,7 +42,7 @@ from common_util.common_util_drainage_pipes import (
|
||||
)
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Options import TypesWithVisible, with_visible
|
||||
from M02_MasterTemplete.StructOptions.M02_SO_Options import TypesWithVisible, with_visible
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from common_util.common_util_summary_item import parse_summary_item
|
||||
from common_util.common_util_summary_item import parse_group_items, parse_summary_item
|
||||
|
||||
# 배치형태: 점형(측점 1개) / 구간형(시~종점) / 부지형(위치+면적).
|
||||
STRUCTURE_PLACEMENTS = ("point", "interval", "site")
|
||||
@@ -160,12 +160,19 @@ class StructureInstance(BaseModel):
|
||||
geometry: dict[str, Any] | None = None
|
||||
# 구조물 집계표 항목(PLAN 21-1) — 없으면 null(옛 파일 호환) · [저장] 뒤 프로젝트 스냅샷
|
||||
summary_item: dict[str, Any] | None = None
|
||||
# 옵션 그룹(M02 PLAN 46)마다 고른 집계 항목 `{그룹 id: summary_item}` — 본체는 `summary_item`
|
||||
group_items: dict[str, Any] | None = None
|
||||
|
||||
@field_validator("summary_item")
|
||||
@classmethod
|
||||
def validate_summary_item(cls, value: Any) -> dict[str, Any] | None:
|
||||
return parse_summary_item(value)
|
||||
|
||||
@field_validator("group_items")
|
||||
@classmethod
|
||||
def validate_group_items(cls, value: Any) -> dict[str, Any] | None:
|
||||
return parse_group_items(value)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_placement_fields(self) -> "StructureInstance":
|
||||
if self.placement == "interval":
|
||||
|
||||
@@ -53,13 +53,17 @@ import {
|
||||
optionalSelect,
|
||||
OUTLET_REVET_KEYS,
|
||||
putNumber,
|
||||
REVET_COMMON_DEFAULTS,
|
||||
stepper,
|
||||
WING_IN_KEYS,
|
||||
WING_OUT_KEYS,
|
||||
} from "./B05_Profile_UI_Drainage_Facility_Fields";
|
||||
import { pipeWallMinHeightM } from "../common_util/common_util_culvert_sets";
|
||||
import type { SummaryItem } from "../common_util/common_util_summary_item";
|
||||
import {
|
||||
hasLinks,
|
||||
linkFields,
|
||||
type GroupItems,
|
||||
type SummaryItem,
|
||||
} from "../common_util/common_util_summary_item";
|
||||
import { createFordFields } from "./B05_Profile_UI_Drainage_Facility_Ford";
|
||||
import { createHiddenOptions } from "./B05_Profile_UI_Drainage_Facility_Hidden";
|
||||
|
||||
@@ -74,11 +78,13 @@ export interface FacilityAttributes {
|
||||
options?: Record<string, string | number>;
|
||||
/** 이 관이 뜻하는 구조물 집계표 항목(21-1 · options 밖) — null/없음 = 미지정. */
|
||||
summary_item?: SummaryItem | null;
|
||||
/** 옵션 그룹마다 고른 집계 항목(PLAN 46-3) — 없음 = 그룹 미지정. */
|
||||
group_items?: GroupItems | null;
|
||||
}
|
||||
|
||||
/** 확장 정보가 실제로 있는지 — 배관이면서 아무 부속도 없으면 보관할 이유가 없다. */
|
||||
function hasExtras(attributes: FacilityAttributes): boolean {
|
||||
if (attributes.summary_item) return true; // 집계 항목만 지정한 맨 배관도 보관한다
|
||||
if (hasLinks(attributes)) return true; // 집계 항목(본체 · 그룹)만 지정한 맨 배관도 보관한다
|
||||
if (attributes.facility !== "pipe") return true;
|
||||
if (attributes.start_m !== undefined || attributes.end_m !== undefined) return true;
|
||||
return !!attributes.options && Object.keys(attributes.options).length > 0;
|
||||
@@ -122,13 +128,13 @@ export function createFacilityStore() {
|
||||
end_m?: number;
|
||||
options?: Record<string, string | number>;
|
||||
summary_item?: SummaryItem | null;
|
||||
group_items?: GroupItems | null;
|
||||
}>,
|
||||
): void {
|
||||
entries = points
|
||||
.map((point) => {
|
||||
// 응답이 정본이다 — 서버는 미지정이면 칸을 안 싣는다(`as_dict`). 예전엔 칸이 없으면 화면이 쥔
|
||||
// 값을 이어받았는데, 횡단 보기에서 [해제]한 초안이 응답으로 돌아올 때 옛 지정이 되살아났다.
|
||||
const summary = point.summary_item ?? null;
|
||||
return {
|
||||
chainage: point.chainage_m,
|
||||
attributes: {
|
||||
@@ -136,7 +142,7 @@ export function createFacilityStore() {
|
||||
start_m: point.start_m,
|
||||
end_m: point.end_m,
|
||||
options: point.options,
|
||||
...(summary ? { summary_item: summary } : {}),
|
||||
...linkFields(point),
|
||||
} as FacilityAttributes,
|
||||
};
|
||||
})
|
||||
@@ -176,9 +182,9 @@ export function createFacilityStore() {
|
||||
return points.map((point, index) => {
|
||||
const pick = picks[index];
|
||||
if (pick === null) return { ...point };
|
||||
const { summary_item: summary, ...rest } = entries[pick].attributes;
|
||||
const { summary_item: _s, group_items: _g, ...rest } = entries[pick].attributes;
|
||||
// 미지정(null)은 칸을 안 싣는다 — 서버가 모르는 칸을 거절하는 동안에도 계산이 안 깨짐.
|
||||
return { ...point, ...rest, ...(summary ? { summary_item: summary } : {}) };
|
||||
return { ...point, ...rest, ...linkFields(entries[pick].attributes) };
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -239,11 +245,11 @@ export function createFacilityOptionsForm(
|
||||
root.hidden = true;
|
||||
|
||||
// ── 배관 본체 — 관종 > 관경 한 행(2026-08-17 사용자 지시 4) ──────────────
|
||||
// 관종 파형강관·관경 1000(사용자 확정 기본)은 **제안으로만** — 「안 정함」이 실제 상태(판정 ③).
|
||||
const pipeMaterial = optionalSelect("— 안 정함 (제안 파형강관) —", ["흄관", "VR관", "파형강관"]);
|
||||
// 기본값(관종 파형강관 · 관경 1000 · 등록부)은 시설이 생길 때 칸에 실제 값으로 들어옴(PLAN 46-7).
|
||||
const pipeMaterial = optionalSelect("— 안 정함 —", ["흄관", "VR관", "파형강관"]);
|
||||
const pipeDiameter = document.createElement("select");
|
||||
pipeDiameter.replaceChildren(
|
||||
new Option("— 안 정함 (제안 Ø1000) —", ""),
|
||||
new Option("— 안 정함 —", ""),
|
||||
...["800", "1000", "1200", "1500"].map((size) => new Option(`Ø${size}`, size)),
|
||||
);
|
||||
// 관보호공 날개벽 — **개소당 원단위**가 형식마다 붙박이라 고르는 칸이 있어야 물량이 선다
|
||||
@@ -257,10 +263,10 @@ export function createFacilityOptionsForm(
|
||||
|
||||
// ── 유입구 — 집수정 또는 기슭막이 택일(사용자 지시 5~9) ─────────────────
|
||||
const inletGroup = group("유입구");
|
||||
// 「안 정함」이면 기슭막이 칸을 보이되(제안) 값은 안 실음 — 기본값을 몰래 확정으로 안 바꿈(판정 ③).
|
||||
// 「안 정함」이면 기슭막이 칸을 보이되 값은 안 실음.
|
||||
const inletType = document.createElement("select");
|
||||
inletType.replaceChildren(
|
||||
new Option("— 안 정함 (제안 기슭막이) —", ""),
|
||||
new Option("— 안 정함 —", ""),
|
||||
...INLET_KINDS.map((kind) => new Option(kind.label, kind.label)),
|
||||
);
|
||||
const basinForm = optionalSelect("선택", [
|
||||
@@ -278,7 +284,7 @@ export function createFacilityOptionsForm(
|
||||
]);
|
||||
const basinMaterialField = labeled("형태", basinMaterial, "inlet_basin_material");
|
||||
// 집수정 종방향 길이 — 3D 예상형상용, 전후 동일 배분·기본 2m(2026-08-23 사용자).
|
||||
const basinLength = numberInput("0.1", "0", "제안 2");
|
||||
const basinLength = numberInput("0.1", "0");
|
||||
// 기준측점 전 · 후 — 옛 횡단 조정창에만 있던 칸을 폼으로(계획서 24-4a).
|
||||
const basinSplit = createBasinSplitFields(basinLength);
|
||||
basinSplit.onChange(() => emit());
|
||||
@@ -291,11 +297,7 @@ export function createFacilityOptionsForm(
|
||||
];
|
||||
const height = (): string =>
|
||||
String(pipeWallMinHeightM(Number(pipeDiameter.value || "1000") / 1000));
|
||||
const inletRevet = createRevetmentFields(INLET_REVET_KEYS, {
|
||||
...REVET_COMMON_DEFAULTS,
|
||||
form: "돌쌓기(찰)",
|
||||
height,
|
||||
});
|
||||
const inletRevet = createRevetmentFields(INLET_REVET_KEYS, { height });
|
||||
// 첫 행 = [구조][형태] — 구조에 따라 집수정 형태와 기슭막이 형태가 갈아 끼워진다
|
||||
// (2026-08-17 사용자 지시 3).
|
||||
// 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리(2026-08-29 사용자 지시 4).
|
||||
@@ -316,12 +318,8 @@ export function createFacilityOptionsForm(
|
||||
|
||||
// ── 유출구 — 구조 선택지는 기슭막이뿐이지만 양식을 맞춘다(사용자 지시 2·4) ──
|
||||
const outletGroup = group("유출구");
|
||||
const outletType = optionalSelect("— 안 정함 (제안 기슭막이) —", ["기슭막이"]);
|
||||
const outletRevet = createRevetmentFields(OUTLET_REVET_KEYS, {
|
||||
...REVET_COMMON_DEFAULTS,
|
||||
form: "돌쌓기(메)",
|
||||
height,
|
||||
});
|
||||
const outletType = optionalSelect("— 안 정함 —", ["기슭막이"]);
|
||||
const outletRevet = createRevetmentFields(OUTLET_REVET_KEYS, { height });
|
||||
outletGroup.body.append(
|
||||
grid(labeled("구조", outletType, "outlet_type"), outletRevet.formField),
|
||||
...outletRevet.rows,
|
||||
@@ -342,10 +340,10 @@ export function createFacilityOptionsForm(
|
||||
// 나눈다(2026-08-30 사용자). 설치 측이 한쪽이면 그 칸만 뜬다.
|
||||
// 좌·우가 저장 채널(유입/유출) 중 어느 쪽인지는 측점 지형이 정하므로 이름표만
|
||||
// 바꿔 단다(`setRevetSideLabels`) — 값은 언제나 그 채널 키로 오간다.
|
||||
const revetSide = optionalSelect("— 안 정함 (제안 양쪽) —", ["양쪽", "좌", "우"]);
|
||||
const revetSide = optionalSelect("— 안 정함 —", ["양쪽", "좌", "우"]);
|
||||
const revetTopRow = grid(labeled("설치 측", revetSide, "side"));
|
||||
const revetInlet = createRevetSideGroup(INLET_REVET_KEYS, "돌쌓기(메)");
|
||||
const revetOutlet = createRevetSideGroup(OUTLET_REVET_KEYS, "돌쌓기(메)");
|
||||
const revetInlet = createRevetSideGroup(INLET_REVET_KEYS);
|
||||
const revetOutlet = createRevetSideGroup(OUTLET_REVET_KEYS);
|
||||
/** 좌·우 이름표 — B06이 그 측점의 벽 방향을 알려 주면 바뀐다. 기본은 유입=좌. */
|
||||
let revetSideLabels: { inlet: "좌" | "우"; outlet: "좌" | "우" } = {
|
||||
inlet: "좌",
|
||||
@@ -380,12 +378,12 @@ export function createFacilityOptionsForm(
|
||||
|
||||
// ── BOX암거 — 본체 규격(프리셋 + 사용자 지정)·날개벽(유입·유출 개별) ────
|
||||
// 폭·높이 자유 입력은 "사용자 지정"을 골랐을 때만 펼친다(2026-08-17 사용자 확정).
|
||||
const boxSize = optionalSelect(`— 안 정함 (제안 ${BOX_SIZE_PRESETS[0][0]}) —`, [
|
||||
const boxSize = optionalSelect("— 안 정함 —", [
|
||||
...BOX_SIZE_PRESETS.map(([label]) => label),
|
||||
BOX_SIZE_CUSTOM,
|
||||
]);
|
||||
const boxWidth = numberInput("0.1", "0", `제안 ${BOX_SIZE_PRESETS[0][1]}`);
|
||||
const boxHeight = numberInput("0.1", "0", `제안 ${BOX_SIZE_PRESETS[0][2]}`);
|
||||
const boxWidth = numberInput("0.1", "0");
|
||||
const boxHeight = numberInput("0.1", "0");
|
||||
const boxCustomRow = grid(
|
||||
labeled("본체 폭 (m)", boxWidth, "body_width_m"),
|
||||
labeled("본체 높이 (m)", boxHeight, "body_height_m"),
|
||||
@@ -408,33 +406,14 @@ export function createFacilityOptionsForm(
|
||||
boxWidth.value = String(preset[1]);
|
||||
boxHeight.value = String(preset[2]);
|
||||
}
|
||||
// 「안 정함」도 폭·높이 칸을 접음 — 값이 없는 상태 그대로(제안은 빈 보기 이름).
|
||||
// 「안 정함」도 폭·높이 칸을 접음 — 값이 없는 상태 그대로.
|
||||
boxCustomRow.hidden = boxSize.value !== BOX_SIZE_CUSTOM;
|
||||
}
|
||||
|
||||
// ── 물넘이·세월교 — 월류 폭·높이·경사·두께·길이·련 수(`_Ford` · 기본값은 제안으로만).
|
||||
// ── 물넘이·세월교 — 월류 폭·높이·경사·두께·길이·련 수(`_Ford`).
|
||||
// 기본값(등록부)은 시설이 생길 때 칸에 실제 값으로 들어옴 — 제안값 단추 없음(PLAN 46-7).
|
||||
const ford = createFordFields(() => emit());
|
||||
|
||||
// [제안값 넣기] — 누른 때만 빈 칸에 제안값(관종·관경·구조·집수정 길이·설치 측·BOX 규격·날개벽·월류).
|
||||
// 벽 칸(기슭막이)은 벽마다 단추가 따로 있음. 기본값을 몰래 확정으로 바꾸지 않는다(2026-09-14 판정 ③).
|
||||
const suggest = document.createElement("button");
|
||||
suggest.type = "button";
|
||||
suggest.className = "b05-structure__suggest";
|
||||
suggest.textContent = "제안값 넣기";
|
||||
suggest.addEventListener("click", () => {
|
||||
if (!pipeMaterial.value) pipeMaterial.value = "파형강관";
|
||||
if (!pipeDiameter.value) pipeDiameter.value = "1000";
|
||||
if (!inletType.value) inletType.value = INLET_KINDS[0].label;
|
||||
if (!outletType.value) outletType.value = "기슭막이";
|
||||
if (!basinLength.value) basinLength.value = "2";
|
||||
if (!revetSide.value) revetSide.value = "양쪽";
|
||||
if (current === "box_culvert" && !boxSize.value) boxSize.value = BOX_SIZE_PRESETS[0][0];
|
||||
wingInFields.fillSuggested();
|
||||
wingOutFields.fillSuggested();
|
||||
if (current === "ford_pavement" || current === "ford_bridge") ford.fillSuggested(current);
|
||||
emit();
|
||||
});
|
||||
|
||||
// 10-A ⑲ 저장 규칙을 폼 머리에 드러냄 — 병합은 `_Drainage_Facility_Merge`(2026-09-14 브레인 판정).
|
||||
const saveNote = document.createElement("p");
|
||||
saveNote.className = "b05-structure__owner-note";
|
||||
@@ -444,7 +423,6 @@ export function createFacilityOptionsForm(
|
||||
// 세월교·물넘이 항목은 **관종·관경 바로 다음**에 둔다(2026-08-30 사용자 지시 2) —
|
||||
// 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다.
|
||||
root.append(
|
||||
grid(suggest),
|
||||
saveNote,
|
||||
pipeRow,
|
||||
wingRow,
|
||||
@@ -586,7 +564,7 @@ export function createFacilityOptionsForm(
|
||||
}
|
||||
const text = (key: string): string =>
|
||||
options[key] !== undefined ? String(options[key]) : "";
|
||||
// ⚠ 저장된 값만 칸에 — 없으면 「안 정함」 · 제안은 빈 보기 이름·회색 글씨(2026-09-14 판정 ③).
|
||||
// 받은 값만 칸에 — 없으면 「안 정함」(새 시설은 부르는 쪽이 등록부 기본값을 실어 줌 · 46-7).
|
||||
pipeMaterial.value = text("pipe_kind");
|
||||
pipeDiameter.value = text("pipe_diameter_mm");
|
||||
// 병합 목록: 저장된 정본 둘(inlet_type·inlet_structure)로 라벨을 되찾는다.
|
||||
@@ -609,7 +587,7 @@ export function createFacilityOptionsForm(
|
||||
inletRevet.write(options);
|
||||
outletRevet.write(options);
|
||||
// 저장된 폭·높이가 프리셋과 맞으면 그 프리셋을, 아니면 "사용자 지정"을 고른다.
|
||||
// 값이 없으면 「안 정함」(첫 프리셋 2.0×2.0 은 제안으로만).
|
||||
// 값이 없으면 「안 정함」.
|
||||
const savedWidth = Number.parseFloat(text("body_width_m"));
|
||||
const savedHeight = Number.parseFloat(text("body_height_m"));
|
||||
const hasSaved =
|
||||
|
||||
@@ -212,27 +212,13 @@ export interface RevetmentKeys {
|
||||
after?: string;
|
||||
}
|
||||
|
||||
/** 부위별 **제안값** — 형태는 유입 돌쌓기(찰)·유출 돌쌓기(메), 길이 10m. 관 벽 높이는 관경 기준
|
||||
* (함수로 받아 볼 때마다 셈).
|
||||
* ⚠ 제안값은 **칸에 채우지 않고 회색 글씨로만** 보임 — 사용자가 적거나 [제안값 넣기]를 누른 때만
|
||||
* 값이 됨(2026-09-14 브레인 판정 「기본값을 몰래 확정으로 바꾸지 않는다」). 칸에 채워 두면 저장이
|
||||
* 그 값을 적어 **미확정이 저장 한 번에 확정으로 바뀜** — 사용자가 한 일이 없는데 금액이 섬. */
|
||||
/** 높이 칸 안내 — 관 벽은 비우면 관경 기준 최소 높이(엔진 `pipe_wall_min_height_m` · 함수로 받아
|
||||
* 볼 때마다 셈) · 빈 글이면 「설계자 입력」. 형태 · 길이 · 전후 기본값은 등록부가 시설이 생길 때
|
||||
* 칸에 실제 값으로 넣음(PLAN 46-7 · 제안값 단추 없음). */
|
||||
export interface RevetmentDefaults {
|
||||
form: string;
|
||||
length: string;
|
||||
height: string | (() => string);
|
||||
before: string;
|
||||
after: string;
|
||||
}
|
||||
|
||||
/** 부위와 무관한 공통 제안 치수 — 길이 10m(전 5·후 5)·높이 2.5m(독립 기슭막이 · 관 벽은 관경 기준). */
|
||||
export const REVET_COMMON_DEFAULTS = {
|
||||
length: "10",
|
||||
height: "2.5",
|
||||
before: "5",
|
||||
after: "5",
|
||||
} as const;
|
||||
|
||||
export const INLET_REVET_KEYS: RevetmentKeys = {
|
||||
form: "inlet_revet_form",
|
||||
length: "inlet_revet_length_m",
|
||||
@@ -273,16 +259,12 @@ function sabangBasisLines(): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
export function createRevetSideGroup(keys: RevetmentKeys, formDefault: string): RevetSideGroup {
|
||||
export function createRevetSideGroup(keys: RevetmentKeys): RevetSideGroup {
|
||||
const box = group("기슭막이");
|
||||
const kind = document.createElement("select");
|
||||
kind.replaceChildren(new Option("기슭막이", "기슭막이"));
|
||||
// 높이는 제안 없음 — 설계자 입력(계획홍수위 + 여유고 · 2026-09-14 브레인 판정). 옛 2.5 는 근거 없어 걷음.
|
||||
const fields = createRevetmentFields(keys, {
|
||||
...REVET_COMMON_DEFAULTS,
|
||||
form: formDefault,
|
||||
height: "",
|
||||
});
|
||||
// 높이는 기본값 없음 — 설계자 입력(계획홍수위 + 여유고 · 2026-09-14 브레인 판정).
|
||||
const fields = createRevetmentFields(keys, { height: "" });
|
||||
const basis = document.createElement("p");
|
||||
basis.className = "b05-route__note";
|
||||
basis.replaceChildren(
|
||||
@@ -326,24 +308,24 @@ export function createRevetmentFields(
|
||||
keys: RevetmentKeys,
|
||||
defaults: RevetmentDefaults,
|
||||
): RevetmentFields {
|
||||
// ⚠ 제안값은 **회색 글씨(placeholder)로만** — 칸은 비워 둠(`RevetmentDefaults` 주석).
|
||||
const suggestHeight = (): string =>
|
||||
const wallFloor = (): string =>
|
||||
typeof defaults.height === "function" ? defaults.height() : defaults.height;
|
||||
const form = document.createElement("select");
|
||||
form.replaceChildren(
|
||||
new Option(`제안 ${defaults.form}`, ""),
|
||||
new Option("— 안 정함 —", ""),
|
||||
...REVET_FORMS.map((value) => new Option(value, value)),
|
||||
);
|
||||
const length = keys.length ? numberInput("0.1", "0", `제안 ${defaults.length}`) : null;
|
||||
// 제안이 없는 칸(독립 기슭막이 높이)은 「설계자 입력」 — 값을 지어내지 않음.
|
||||
const heightHint = (): string => (suggestHeight() ? `제안 ${suggestHeight()}` : "설계자 입력");
|
||||
const length = keys.length ? numberInput("0.1", "0") : null;
|
||||
// 관 벽은 비우면 관경 기준 최소 높이로 섬(엔진) · 독립 기슭막이 높이는 「설계자 입력」.
|
||||
const heightHint = (): string =>
|
||||
wallFloor() ? `비우면 관경 기준 ${wallFloor()}` : "설계자 입력";
|
||||
const height = numberInput("0.1", "0", heightHint());
|
||||
// 관 벽 높이 제안은 관경을 따라감 — 칸을 볼 때마다 다시 셈.
|
||||
// 관 벽 기준 높이는 관경을 따라감 — 칸을 볼 때마다 다시 셈.
|
||||
height.addEventListener("focus", () => (height.placeholder = heightHint()));
|
||||
// 기준측점 전/후(2026-08-23 사용자) — 구조물 패널(D군)과 같은 연동:
|
||||
// 전 + 후 = 길이, 나중에 고친 쪽이 살아남고 반대쪽이 재배분된다.
|
||||
const before = keys.before && length ? numberInput("0.1", "0", `제안 ${defaults.before}`) : null;
|
||||
const after = keys.after && length ? numberInput("0.1", "0", `제안 ${defaults.after}`) : null;
|
||||
const before = keys.before && length ? numberInput("0.1", "0") : null;
|
||||
const after = keys.after && length ? numberInput("0.1", "0") : null;
|
||||
let lastSplitEdit: "before" | "after" = "before";
|
||||
const readNumber = (input: HTMLInputElement | null): number => {
|
||||
const value = input ? Number.parseFloat(input.value) : Number.NaN;
|
||||
@@ -367,25 +349,10 @@ export function createRevetmentFields(
|
||||
const beforeField = before ? labeled("기준측점 전 (m)", stepper(before, 0.5), keys.before) : null;
|
||||
const afterField = after ? labeled("기준측점 후 (m)", stepper(after, 0.5), keys.after) : null;
|
||||
|
||||
// [제안값 넣기] — **누른 때만** 빈 칸에 제안값을 넣음(적은 칸은 안 건드림) · 층따기 단추와 같은 모양.
|
||||
const fill = document.createElement("button");
|
||||
fill.type = "button";
|
||||
fill.className = "b05-structure__suggest";
|
||||
fill.textContent = "제안값 넣기";
|
||||
fill.addEventListener("click", () => {
|
||||
if (!form.value) form.value = defaults.form;
|
||||
if (length && !length.value) length.value = defaults.length;
|
||||
if (!height.value) height.value = suggestHeight();
|
||||
if (before && !before.value) before.value = defaults.before;
|
||||
if (after && !after.value) after.value = defaults.after;
|
||||
form.dispatchEvent(new Event("change"));
|
||||
});
|
||||
|
||||
// 형태는 그룹 첫 행(구조 옆)에 따로 놓이므로 여기 행에서는 뺀다.
|
||||
const rows = [
|
||||
grid(...[lengthField, heightField].filter((item): item is HTMLLabelElement => item !== null)),
|
||||
...(beforeField && afterField ? [grid(beforeField, afterField)] : []),
|
||||
grid(fill),
|
||||
];
|
||||
|
||||
return {
|
||||
@@ -393,7 +360,7 @@ export function createRevetmentFields(
|
||||
rows,
|
||||
lengthInput: length,
|
||||
write(options) {
|
||||
// 저장된 값만 칸에 — 없으면 비워 두고 제안값은 회색 글씨로(몰래 채우지 않음).
|
||||
// 받은 값만 칸에 — 없으면 비워 둠(새 시설은 부르는 쪽이 등록부 기본값을 실어 줌 · 46-7).
|
||||
const text = (key: string): string =>
|
||||
options[key] !== undefined ? String(options[key]) : "";
|
||||
form.value = text(keys.form);
|
||||
@@ -447,8 +414,8 @@ export function createBasinSplitFields(length: HTMLInputElement): {
|
||||
read: (target: Record<string, string | number>) => void;
|
||||
onChange: (handler: () => void) => void;
|
||||
} {
|
||||
const before = numberInput("0.1", "0", "제안 1");
|
||||
const after = numberInput("0.1", "0", "제안 1");
|
||||
const before = numberInput("0.1", "0");
|
||||
const after = numberInput("0.1", "0");
|
||||
let last: HTMLInputElement = before;
|
||||
const sync = (): void => {
|
||||
const total = Number.parseFloat(length.value);
|
||||
@@ -528,10 +495,6 @@ export const WING_OUT_KEYS: WingKeys = {
|
||||
angle: "wing_out_angle_deg",
|
||||
};
|
||||
|
||||
/** 날개벽 기본 제원 — 설치 있음·짧은쪽 높이 1m·길이 2m·각도 45°
|
||||
* (2026-08-17 사용자 확정). 유입·유출이 같은 값을 쓴다. */
|
||||
const WING_DEFAULTS = { install: "있음", height: "1", length: "2", angle: "45" } as const;
|
||||
|
||||
interface WingFields {
|
||||
root: HTMLFieldSetElement;
|
||||
/** 횡단도에서 그 측 날개벽·측벽을 고를 때 칸을 세우는 자리(B06 전용, 2026-08-30).
|
||||
@@ -541,18 +504,16 @@ interface WingFields {
|
||||
syncVisibility: () => void;
|
||||
write: (options: Record<string, string | number>) => void;
|
||||
read: (target: Record<string, string | number>) => void;
|
||||
/** [제안값 넣기] — 빈 칸에만 기본 제원을 넣음. */
|
||||
fillSuggested: () => void;
|
||||
}
|
||||
|
||||
/** 날개벽 한 벌(설치 유무 + 짧은쪽 높이·길이·각도) — 유입·유출이 공유하는 조각.
|
||||
* 설치 "없음"이면 제원 칸을 접는다(기슭막이 보호공 치수와 같은 패턴).
|
||||
* ⚠ 기본 제원(있음·1·2·45, 사용자 확정)은 **제안으로만** — 칸은 비워 둠(2026-09-14 판정 ③). */
|
||||
* 기본 제원(있음·1·2·45, 사용자 확정)은 등록부 기본값 — 시설이 생길 때 칸에 실제 값(PLAN 46-7). */
|
||||
export function createWingFields(title: string, keys: WingKeys): WingFields {
|
||||
const install = optionalSelect(`— 안 정함 (제안 ${WING_DEFAULTS.install}) —`, ["있음", "없음"]);
|
||||
const height = numberInput("0.1", "0", `제안 ${WING_DEFAULTS.height}`);
|
||||
const length = numberInput("0.1", "0", `제안 ${WING_DEFAULTS.length}`);
|
||||
const angle = numberInput("1", "0", `제안 ${WING_DEFAULTS.angle}`);
|
||||
const install = optionalSelect("— 안 정함 —", ["있음", "없음"]);
|
||||
const height = numberInput("0.1", "0");
|
||||
const length = numberInput("0.1", "0");
|
||||
const angle = numberInput("1", "0");
|
||||
|
||||
// 숫자 칸은 기슭막이·집수정과 같은 [-][값][+] 묶음으로 맞춘다(2026-08-30 사용자).
|
||||
const heightField = labeled("짧은쪽 높이 (m)", stepper(height, 0.1), keys.height);
|
||||
@@ -594,12 +555,5 @@ export function createWingFields(title: string, keys: WingKeys): WingFields {
|
||||
putNumber(target, keys.length, length.value);
|
||||
putNumber(target, keys.angle, angle.value);
|
||||
},
|
||||
fillSuggested() {
|
||||
if (!install.value) install.value = WING_DEFAULTS.install;
|
||||
if (!height.value) height.value = WING_DEFAULTS.height;
|
||||
if (!length.value) length.value = WING_DEFAULTS.length;
|
||||
if (!angle.value) angle.value = WING_DEFAULTS.angle;
|
||||
syncVisibility();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
* 물넘이포장·세월교 칸 묶음 — 월류 폭·높이·바닥 경사·포장 두께·길이·련 수 + 개략 단면 줄.
|
||||
*
|
||||
* 서브폼 본체(`_Drainage_Facility.ts`)가 700줄에 닿아 떼어 냄(2026-09-14).
|
||||
* ⚠ 기본값을 칸에 안 채움(2026-09-14 브레인 판정 「기본값을 몰래 확정으로 바꾸지 않는다」):
|
||||
* 월류 폭 세월교 10m·물넘이 5m(사용자 확정)는 **회색 제안**, 월류 높이는 설계유량·폭으로 되짚은
|
||||
* **필요 최소 수심을 회색 제안**으로만 — 사용자가 적거나 [제안값 넣기]를 누른 때만 값.
|
||||
* 적은 높이가 필요 수심보다 낮으면 여전히 되돌림(계산값 미만은 안 받음).
|
||||
* 월류 폭 기본값(세월교 10m · 물넘이 5m · 등록부)은 시설이 생길 때 칸에 실제 값으로 들어옴
|
||||
* (PLAN 46-7 · 제안값 단추 없음). 빈 칸 안내 = 엔진이 비운 칸을 읽는 값(폭 기본 · 필요 수심).
|
||||
* 적은 높이가 필요 수심보다 낮으면 되돌림(계산값 미만은 안 받음).
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
@@ -35,7 +34,6 @@ export interface FordFields {
|
||||
sync: () => void;
|
||||
write: (kind: FordKind, options: Record<string, string | number>) => void;
|
||||
read: (kind: FordKind, target: Record<string, string | number>) => void;
|
||||
fillSuggested: (kind: FordKind) => void;
|
||||
}
|
||||
|
||||
export function createFordFields(emit: () => void): FordFields {
|
||||
@@ -66,14 +64,15 @@ export function createFordFields(emit: () => void): FordFields {
|
||||
/** 현재 조건(설계유량·월류 폭)의 필요 최소 수심(m). 계산 불가면 null. */
|
||||
let minDepthM: number | null = null;
|
||||
|
||||
const suggestedWidth = (kind: FordKind): number =>
|
||||
/** 폭을 비우면 엔진이 쓰는 폭(`B06_Section_Engine_Culvert.FORD_*_DEFAULT_WIDTH_M` 짝). */
|
||||
const emptyWidth = (kind: FordKind): number =>
|
||||
kind === "ford_bridge" ? FORD_BRIDGE_DEFAULT_WIDTH_M : FORD_PAVEMENT_DEFAULT_WIDTH_M;
|
||||
|
||||
function sync(): void {
|
||||
const widthM = Number.parseFloat(width.value || width.dataset.suggested || "");
|
||||
const widthM = Number.parseFloat(width.value || width.dataset.empty || "");
|
||||
const section = designFlowM3s !== null ? fordSection(designFlowM3s, widthM) : null;
|
||||
minDepthM = section ? Number(section.depthM.toFixed(2)) : null;
|
||||
height.placeholder = minDepthM !== null ? `제안 필요 수심 ${minDepthM.toFixed(2)}` : "";
|
||||
height.placeholder = minDepthM !== null ? `필요 수심 ${minDepthM.toFixed(2)} 이상` : "";
|
||||
if (designFlowM3s === null) {
|
||||
summary.textContent =
|
||||
"담당 유역의 설계유량이 아직 없습니다 — [유역 분석] 후 개략 단면이 나옵니다.";
|
||||
@@ -121,8 +120,8 @@ export function createFordFields(emit: () => void): FordFields {
|
||||
options[key] !== undefined ? String(options[key]) : "";
|
||||
count.value = kind === "ford_bridge" ? text("pipe_count") : "";
|
||||
width.value = text("ford_width_m");
|
||||
width.dataset.suggested = String(suggestedWidth(kind));
|
||||
width.placeholder = `제안 ${suggestedWidth(kind)}`;
|
||||
width.dataset.empty = String(emptyWidth(kind));
|
||||
width.placeholder = `비우면 ${emptyWidth(kind)}`;
|
||||
height.value = text("ford_height_m");
|
||||
slope.value = text("ford_slope_pct");
|
||||
thickness.value = text("thickness_cm");
|
||||
@@ -142,10 +141,5 @@ export function createFordFields(emit: () => void): FordFields {
|
||||
const pipes = Number.parseInt(count.value, 10);
|
||||
if (Number.isFinite(pipes) && pipes > 0) target.pipe_count = pipes;
|
||||
},
|
||||
fillSuggested(kind) {
|
||||
if (!width.value) width.value = String(suggestedWidth(kind));
|
||||
sync();
|
||||
if (!height.value && minDepthM !== null) height.value = minDepthM.toFixed(2);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,6 +84,8 @@ interface MergeableAttributes {
|
||||
options?: Record<string, string | number>;
|
||||
/** 집계 항목(options 밖) — 다음 값에 칸이 없으면(undefined) 옛 지정을 그대로 잇는다. */
|
||||
summary_item?: unknown;
|
||||
/** 옵션 그룹 집계 항목(PLAN 46-3) — 다음 값에 칸이 없으면(undefined) 옛 지정을 그대로 잇는다. */
|
||||
group_items?: unknown;
|
||||
}
|
||||
|
||||
/** 저장할 시설 정보 — 같은 종류면 폼이 모르는 옛 칸을 이어 붙임. */
|
||||
@@ -99,5 +101,14 @@ export function mergeFacilityOptions<T extends MergeableAttributes>(
|
||||
next.summary_item === undefined && previous.summary_item !== undefined
|
||||
? { summary_item: previous.summary_item }
|
||||
: {};
|
||||
return { ...next, ...summary, options: Object.keys(options).length ? options : undefined };
|
||||
const groups =
|
||||
next.group_items === undefined && previous.group_items !== undefined
|
||||
? { group_items: previous.group_items }
|
||||
: {};
|
||||
return {
|
||||
...next,
|
||||
...summary,
|
||||
...groups,
|
||||
options: Object.keys(options).length ? options : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* 그대로 다시 내보내므로 기존 import 경로는 유효하다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { SummaryItem } from "../common_util/common_util_summary_item";
|
||||
import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item";
|
||||
import type { RoutePoint } from "./B05_Profile_Api_Fetch";
|
||||
import type { FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
|
||||
import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
@@ -67,6 +67,8 @@ export interface DrainagePipeReport {
|
||||
options?: Record<string, string | number>;
|
||||
/** 이 관이 뜻하는 구조물 집계표 항목(21-1) — 없으면 미지정. */
|
||||
summary_item?: SummaryItem | null;
|
||||
/** 옵션 그룹마다 고른 집계 항목(PLAN 46-3). */
|
||||
group_items?: GroupItems | null;
|
||||
}
|
||||
|
||||
export interface DrainagePanelCallbacks {
|
||||
|
||||
@@ -212,7 +212,7 @@ export function bindPipeContextMenu(
|
||||
]);
|
||||
return;
|
||||
}
|
||||
// 계획선에서 먼 자리는 브라우저 기본 메뉴를 그대로 둔다.
|
||||
// 계획선에서 먼 자리는 아무 메뉴도 안 뜸(브라우저 메뉴는 공용 진입이 막음).
|
||||
if (!editor.canAddAt(view, x, y)) return;
|
||||
event.preventDefault();
|
||||
// 빈 자리 — 종단그래프와 같은 구조물군 → 종류 2단 메뉴(2026-08-18 일원화).
|
||||
|
||||
@@ -34,6 +34,7 @@ export function buildPipeReports(
|
||||
source: (pipe.reason || "user") as PipeSource,
|
||||
options: attributes?.options,
|
||||
summary_item: attributes?.summary_item,
|
||||
group_items: attributes?.group_items,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import { createPanelResizer } from "@ui/ui_template_resizer";
|
||||
import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
import { profilePanelMaxHeight } from "./B05_Profile_UI_Profile_Heights";
|
||||
import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul";
|
||||
import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay";
|
||||
|
||||
@@ -19,8 +20,6 @@ const MIN_CHART_HEIGHT = 100;
|
||||
const MASSHAUL_HANDLE_GUTTER_PX = 13;
|
||||
/** 패널을 끌어 줄일 수 있는 하한(px) — 이보다 낮으면 그래프도 테이블도 못 읽는다. */
|
||||
const MIN_PANEL_HEIGHT = 180;
|
||||
/** 상한은 위 칸(3D · 횡단 카드)이 완전히 가려지지 않도록 부모 높이의 90%까지만 허용한다. */
|
||||
const MAX_PANEL_HEIGHT_RATIO = 0.9;
|
||||
|
||||
/** 접힘·높이는 화면 취향(①) — 두 보기가 **같은 키 하나**를 쓴다(계획서 24-2c). */
|
||||
const HEIGHT_KEY = stateKey("profile-height") ?? "";
|
||||
@@ -147,7 +146,8 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag {
|
||||
chrome + MIN_CHART_HEIGHT + MASSHAUL_HANDLE_GUTTER_PX + massMin + tableMin,
|
||||
);
|
||||
},
|
||||
max: () => (root.parentElement?.clientHeight ?? window.innerHeight) * MAX_PANEL_HEIGHT_RATIO,
|
||||
// 상한 = 가운데 칸 높이의 90%(위 칸 3D · 횡단 카드가 다 가려지지 않게) — Heights 와 한 식
|
||||
max: () => profilePanelMaxHeight(root),
|
||||
storageKey: HEIGHT_KEY,
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,13 @@ const MASSHAUL_HANDLE_GUTTER_PX = 13;
|
||||
/** 패널 자동 확대 상한 — 메인 리사이저 max와 같은 식이어야 한다(부모 높이 비율). */
|
||||
const MAX_PANEL_HEIGHT_RATIO = 0.9;
|
||||
|
||||
/** 패널 높이 상한(px) — 메인 리사이저 max 와 자동 확대가 같이 쓴다. 기준 = 한 페이지 가운데 칸
|
||||
* `.design-page__main`(바로 위 `.design-page__profile` 은 display: contents 라 높이 0). */
|
||||
export function profilePanelMaxHeight(root: HTMLElement): number {
|
||||
const basis = root.closest<HTMLElement>(".design-page__main") ?? root.parentElement;
|
||||
return (basis?.clientHeight || window.innerHeight) * MAX_PANEL_HEIGHT_RATIO;
|
||||
}
|
||||
|
||||
export interface HeightCascadeDeps {
|
||||
/** 패널 루트 — 자리가 모자라면 이 높이를 키운다. */
|
||||
root: HTMLElement;
|
||||
@@ -155,9 +162,7 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade {
|
||||
// 상한은 메인 리사이저 max와 **같은 식**이어야 한다 — 예전 window 92% 상한은
|
||||
// 리사이저 상한(부모 90%)보다 높아, 자동 확대 직후 손잡이를 잡는 순간 낮은
|
||||
// 상한으로 clamp되며 패널이 뚝 떨어졌다(2026-08-06 분석).
|
||||
const growCap = Math.round(
|
||||
(deps.root.parentElement?.clientHeight ?? window.innerHeight) * MAX_PANEL_HEIGHT_RATIO,
|
||||
);
|
||||
const growCap = Math.round(profilePanelMaxHeight(deps.root));
|
||||
const grown = Math.min(deps.root.offsetHeight + deficit, growCap);
|
||||
if (grown > deps.root.offsetHeight + 1) {
|
||||
deps.root.style.setProperty("--b05-profile-height", `${grown}px`);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* 이름·필드·주석은 그대로이고, 패널이 다시 `export` 해 옛 임포트 경로도 그대로 동작한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { SummaryItem } from "../common_util/common_util_summary_item";
|
||||
import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item";
|
||||
import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import type { IrregularStation } from "./B05_Profile_UI_IrregularStations";
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface RouteProfilePanelCallbacks {
|
||||
options?: Record<string, string | number>;
|
||||
/** 이 관이 뜻하는 구조물 집계표 항목(21-1) — 없으면 미지정. */
|
||||
summary_item?: SummaryItem | null;
|
||||
/** 옵션 그룹마다 고른 집계 항목(PLAN 46-3). */
|
||||
group_items?: GroupItems | null;
|
||||
}>,
|
||||
) => void;
|
||||
/** 테이블에서 구조물 라인을 끌어 옮김. */
|
||||
|
||||
@@ -166,6 +166,7 @@ export async function openRouteEditModal(
|
||||
measureControls.sync();
|
||||
draw();
|
||||
},
|
||||
onHint: () => measureControls.sync(),
|
||||
});
|
||||
/** 재기 단추·작은 창 — 배선은 `_Measure` 몫. */
|
||||
const measureControls = bindMeasureControls({
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* `compute_cross_design`(서버), 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { attachSvgZoomPan } from "../A00_Common/b_svg_zoom_pan";
|
||||
import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan";
|
||||
import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_Replan";
|
||||
import {
|
||||
buildCrossSvg,
|
||||
@@ -97,10 +97,47 @@ function createPane(title: string, empty: string): CrossPane {
|
||||
const station = root.querySelector<HTMLElement>(".b05-routeedit__cross-station")!;
|
||||
const foot = root.querySelector<HTMLElement>(".b05-routeedit__cross-foot")!;
|
||||
const box = root.querySelector<HTMLElement>(".b05-routeedit__cross-box")!;
|
||||
/** 그려 둔 횡단 · 그때 판 크기 · 확대·이동 — 판 크기가 바뀌면 이것으로 다시 짓는다. */
|
||||
let drawn: CrossPreviewResponse | null = null;
|
||||
let size = { width: 0, height: 0 };
|
||||
let zoom: ZoomPanState | undefined;
|
||||
function draw(preview: CrossPreviewResponse): void {
|
||||
// 판의 실제 크기로 짓는다 — `viewBox` 가 픽셀과 1:1 이라야 휠·팬이 커서를 따라가고
|
||||
// 글자가 늘어나 보이지 않는다. 칸이 접혀 있으면(1500px 미만) 0 이 나오므로 최소치를 깔고,
|
||||
// 펼쳐지면 아래 ResizeObserver 가 실제 크기로 다시 짓는다.
|
||||
const rect = box.getBoundingClientRect();
|
||||
size = { width: Math.round(rect.width), height: Math.round(rect.height) };
|
||||
const plot = buildCrossSvg(preview, size.width || 240, size.height || 150);
|
||||
box.replaceChildren(plot.svg);
|
||||
observer.observe(box); // 창에 붙은 뒤에 지켜본다 — 붙기 전에는 isConnected 가 거짓이다.
|
||||
// 확대·이동은 **공용 조각**이 맡는다 — B06 횡단 카드와 한 코드다(2026-09-12 지시 ③).
|
||||
// 판이 둘뿐이라 여기서는 휠을 줌에 묶는다(카드 목록인 B06 은 안 묶는다).
|
||||
attachSvgZoomPan({
|
||||
svg: plot.svg,
|
||||
layer: plot.layer,
|
||||
widthPx: plot.width,
|
||||
heightPx: plot.height,
|
||||
pad: CROSS_PLOT_PAD,
|
||||
content: plot.content,
|
||||
wheelZoom: true,
|
||||
initial: zoom,
|
||||
onChange: (state) => (zoom = state),
|
||||
});
|
||||
}
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (!box.isConnected) return observer.disconnect(); // 편집 창이 닫혔다.
|
||||
const rect = box.getBoundingClientRect();
|
||||
const width = Math.round(rect.width);
|
||||
const height = Math.round(rect.height);
|
||||
if (!drawn || width === 0 || (width === size.width && height === size.height)) return;
|
||||
draw(drawn);
|
||||
});
|
||||
return {
|
||||
root,
|
||||
show(preview, intervalM) {
|
||||
box.replaceChildren();
|
||||
drawn = preview;
|
||||
zoom = undefined;
|
||||
if (!preview) {
|
||||
station.textContent = "";
|
||||
foot.textContent = empty;
|
||||
@@ -109,30 +146,12 @@ function createPane(title: string, empty: string): CrossPane {
|
||||
// 측점은 **누가거리가 아니라 측점 표기**로 낸다(계획서 0-9 ㉑) — B05 왼쪽 아래 구조물
|
||||
// 목록이 쓰는 그 규칙이다. 서버가 주는 `STA.0+100.000` 을 그대로 쓰면 표기가 갈린다.
|
||||
station.textContent = formatStation(preview.chainage_m, intervalM);
|
||||
// 판의 실제 크기로 짓는다 — `viewBox` 가 픽셀과 1:1 이라야 휠·팬이 커서를 따라간다.
|
||||
// 칸이 접혀 있으면(1500px 미만) 0 이 나오므로 최소치를 깐다.
|
||||
const rect = box.getBoundingClientRect();
|
||||
const plot = buildCrossSvg(
|
||||
preview,
|
||||
Math.max(Math.round(rect.width), 240),
|
||||
Math.max(Math.round(rect.height), 150),
|
||||
);
|
||||
box.append(plot.svg);
|
||||
// 확대·이동은 **공용 조각**이 맡는다 — B06 횡단 카드와 한 코드다(2026-09-12 지시 ③).
|
||||
// 판이 둘뿐이라 여기서는 휠을 줌에 묶는다(카드 목록인 B06 은 안 묶는다).
|
||||
attachSvgZoomPan({
|
||||
svg: plot.svg,
|
||||
layer: plot.layer,
|
||||
widthPx: plot.width,
|
||||
heightPx: plot.height,
|
||||
pad: CROSS_PLOT_PAD,
|
||||
content: plot.content,
|
||||
wheelZoom: true,
|
||||
});
|
||||
draw(preview);
|
||||
foot.textContent = summarizeCross(preview);
|
||||
},
|
||||
wait(text) {
|
||||
box.replaceChildren();
|
||||
drawn = null;
|
||||
foot.textContent = text;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,11 +4,16 @@
|
||||
*
|
||||
* Shift+클릭으로 a·b 를 찍는다. 직선·곡선을 가리지 않는다 — 그려지는 폴리라인 위라면 어디든
|
||||
* 짚을 수 있고, 누가거리는 노선 길이 표기와 같은 방식으로 잰다. 계획노선(실선)과 예상노선
|
||||
* (점선) 둘 다 잰다 — 두 점은 한 노선 위에 있어야 하고, 다른 노선을 짚으면 새로 시작한다.
|
||||
* (점선) 둘 다 잰다 — 첫 점을 찍은 노선을 기억해 둘째 점(과 커서 미리보기)은 그 노선에만
|
||||
* 붙는다 — 두 노선이 겹쳐 다른 노선이 위에 있어도 무시한다. 길이 = 두 점 누가거리의 차.
|
||||
*
|
||||
* 지반고는 **찍는 순간에만** 서버에 묻는다(`/route/elevations`). 확정된 지표면을 읽기만 하는
|
||||
* 통로라 「편집 중에는 계산이 안 나간다」(계획서 0-2 확정 7)와 부딪히지 않는다 — 다만 노드를
|
||||
* 끄는 동안에는 한 번도 부르지 않는다.
|
||||
*
|
||||
* 첫 점을 찍으면 그 노선 땅 높이를 일정 간격으로 **한 번에** 받아 둔다(계획서 48-1). 커서를
|
||||
* 움직이는 동안 길이 · 지반고 · 기울기는 이 표에서 보간해 바로 보이고 서버는 안 부른다.
|
||||
* 둘째 점을 누르면 전처럼 두 점만 다시 물어 그 값으로 고정한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { fetchRouteElevations } from "./B05_Profile_Api_Replan";
|
||||
@@ -22,6 +27,10 @@ export type MeasureLine = "planned" | "expected";
|
||||
|
||||
/** 구간 재기로 노선을 짚었다고 볼 거리(px). */
|
||||
const MEASURE_HIT_PX = 14;
|
||||
/** 커서 미리보기용 땅 높이 표 간격(m). */
|
||||
const PROFILE_STEP_M = 2;
|
||||
/** 한 번에 묻는 점 수 상한 — 긴 노선은 간격을 넓힌다. */
|
||||
const PROFILE_MAX_POINTS = 3000;
|
||||
|
||||
interface MeasurePoint extends RoutePointHit {
|
||||
line: MeasureLine;
|
||||
@@ -40,6 +49,69 @@ export interface MeasureToolParams {
|
||||
isClosed: () => boolean;
|
||||
/** 상태가 바뀌었다 — 호출부가 상태줄을 다시 적고 다시 그린다. */
|
||||
onChange: () => void;
|
||||
/** 커서 미리보기 값이 바뀌었다 — 작은 창 글만 다시 적는다(그리기는 포인터 몫). */
|
||||
onHint?: () => void;
|
||||
}
|
||||
|
||||
/** 첫 점 노선의 땅 높이 표 — 누가거리 `chainages[i]` 자리의 지반고 `z[i]`. */
|
||||
interface LineProfile {
|
||||
/** 표를 만든 노선 — 편집으로 노선이 바뀌면 배열이 새로 생겨 낡은 표를 알아챈다. */
|
||||
vertices: Vertex[];
|
||||
stepM: number;
|
||||
chainages: number[];
|
||||
/** 받는 중이면 null. */
|
||||
z: Array<number | null> | null;
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
/** 노선 전체 길이(m). */
|
||||
function lineLength(vertices: Vertex[]): number {
|
||||
let total = 0;
|
||||
for (let index = 0; index < vertices.length - 1; index += 1) {
|
||||
total += Math.hypot(
|
||||
vertices[index + 1][0] - vertices[index][0],
|
||||
vertices[index + 1][1] - vertices[index][1],
|
||||
);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** 노선을 따라 `stepM` 마다 한 점(끝점 포함) — 점과 그 누가거리. */
|
||||
function sampleAlong(vertices: Vertex[], stepM: number): { points: Vertex[]; chainages: number[] } {
|
||||
const points: Vertex[] = [];
|
||||
const chainages: number[] = [];
|
||||
let travelled = 0;
|
||||
let next = 0;
|
||||
for (let index = 0; index < vertices.length - 1; index += 1) {
|
||||
const [ax, ay] = vertices[index];
|
||||
const [bx, by] = vertices[index + 1];
|
||||
const segmentM = Math.hypot(bx - ax, by - ay);
|
||||
while (segmentM > 0 && next <= travelled + segmentM) {
|
||||
const t = (next - travelled) / segmentM;
|
||||
points.push([ax + (bx - ax) * t, ay + (by - ay) * t]);
|
||||
chainages.push(next);
|
||||
next += stepM;
|
||||
}
|
||||
travelled += segmentM;
|
||||
}
|
||||
if (points.length && travelled - chainages[chainages.length - 1] > 1e-6) {
|
||||
points.push(vertices[vertices.length - 1]);
|
||||
chainages.push(travelled);
|
||||
}
|
||||
return { points, chainages };
|
||||
}
|
||||
|
||||
/** 표에서 누가거리 자리의 지반고를 선형 보간 — 양옆 중 하나라도 모르면 null. */
|
||||
function profileZ(profile: LineProfile, chainageM: number): number | null {
|
||||
const { chainages, z } = profile;
|
||||
if (!z || chainages.length === 0) return null;
|
||||
if (chainages.length === 1) return z[0];
|
||||
const index = Math.max(0, Math.min(chainages.length - 2, Math.floor(chainageM / profile.stepM)));
|
||||
const [c0, c1] = [chainages[index], chainages[index + 1]];
|
||||
const [z0, z1] = [z[index], z[index + 1]];
|
||||
if (z0 === null || z1 === null) return null;
|
||||
const t = c1 > c0 ? Math.max(0, Math.min(1, (chainageM - c0) / (c1 - c0))) : 0;
|
||||
return z0 + (z1 - z0) * t;
|
||||
}
|
||||
|
||||
export interface MeasureMark {
|
||||
@@ -56,6 +128,8 @@ export interface MeasureTool {
|
||||
hint: () => string;
|
||||
/** 재고 있나 — 작은 창을 띄울지 정하는 값. */
|
||||
active: () => boolean;
|
||||
/** 첫 점만 찍혔을 때 커서 자리 미리보기(첫 점 노선 위). 바뀌었으면 true — 다시 그린다. */
|
||||
preview: (px: number, py: number) => boolean;
|
||||
/** 한 번 찍기. 두 점이 차면 지반고를 한 번만 물어 온다. */
|
||||
pick: (px: number, py: number) => Promise<void>;
|
||||
/** 잰 것을 지운다 — 작은 창을 닫을 때(계획서 0-9 ㉔). */
|
||||
@@ -109,34 +183,97 @@ export function bindMeasureControls(params: MeasureControlsParams): {
|
||||
export function createMeasureTool(params: MeasureToolParams): MeasureTool {
|
||||
/** 찍은 두 점. 셋째를 찍으면 새 구간의 시작이 된다. */
|
||||
let picked: MeasurePoint[] = [];
|
||||
/** 첫 점만 찍혔을 때 커서가 가리키는 둘째 점 후보. */
|
||||
let hover: MeasureMark | null = null;
|
||||
/** 첫 점 노선의 땅 높이 표 — 커서 미리보기 값의 바탕. */
|
||||
let profile: LineProfile | null = null;
|
||||
/** 두 점을 찍고 그 자리 지반고를 묻는 중 — 답이 오기 전에 「못 읽음」으로 보이지 않게. */
|
||||
let fixing = false;
|
||||
|
||||
const hint = (): string => {
|
||||
if (picked.length === 0) return "";
|
||||
const first = picked[0];
|
||||
const name = LINE_NAME[first.line];
|
||||
if (picked.length === 1) {
|
||||
return `${name}구간 재기 — 시작 ${formatStation(first.chainageM, params.stationIntervalM)}. 한 점 더.`;
|
||||
}
|
||||
const second = picked[1];
|
||||
const span = Math.abs(second.chainageM - first.chainageM);
|
||||
/** 첫 점 노선 땅 높이를 한 번에 받아 둔다. 같은 노선 표가 살아 있으면 다시 안 묻는다. */
|
||||
const loadProfile = (line: MeasureLine): void => {
|
||||
const vertices = params.lines()[line];
|
||||
if (profile && profile.vertices === vertices) return;
|
||||
const stepM = Math.max(PROFILE_STEP_M, lineLength(vertices) / PROFILE_MAX_POINTS);
|
||||
const { points, chainages } = sampleAlong(vertices, stepM);
|
||||
const asked: LineProfile = { vertices, stepM, chainages, z: null, failed: !points.length };
|
||||
profile = asked;
|
||||
if (!points.length) return;
|
||||
fetchRouteElevations(params.projectId, points)
|
||||
.then((heights) => {
|
||||
asked.z = chainages.map((_, index) => heights[index] ?? null);
|
||||
})
|
||||
.catch(() => {
|
||||
asked.failed = true;
|
||||
})
|
||||
.finally(() => {
|
||||
if (params.isClosed() || profile !== asked) return; // 그 사이 다른 노선을 짚었으면 버린다.
|
||||
params.onHint?.();
|
||||
});
|
||||
};
|
||||
|
||||
/** 구간 한 줄 — 두 점(누가거리 · 지반고)에서 길이 · 지반고 · 종단기울기. */
|
||||
const spanText = (
|
||||
name: string,
|
||||
a: { chainageM: number; z: number | null },
|
||||
b: { chainageM: number; z: number | null },
|
||||
missing: string,
|
||||
): string => {
|
||||
const span = Math.abs(b.chainageM - a.chainageM);
|
||||
const head =
|
||||
`${name}구간 ${formatStation(first.chainageM, params.stationIntervalM)} → ` +
|
||||
`${formatStation(second.chainageM, params.stationIntervalM)} · 길이 ${span.toFixed(1)}m`;
|
||||
if (first.z === null || second.z === null || span <= 1e-6) {
|
||||
return `${head} · 지반고를 못 읽어 기울기는 못 냅니다.`;
|
||||
}
|
||||
`${name}구간 ${formatStation(a.chainageM, params.stationIntervalM)} → ` +
|
||||
`${formatStation(b.chainageM, params.stationIntervalM)} · 길이 ${span.toFixed(1)}m`;
|
||||
if (a.z === null || b.z === null || span <= 1e-6) return `${head} · ${missing}`;
|
||||
// 기울기는 **노선을 따라간 길이** 기준이다 — 직선거리로 나누면 곡선부에서 과대평가된다.
|
||||
const rise = second.z - first.z;
|
||||
const rise = b.z - a.z;
|
||||
return (
|
||||
`${head} · 지반고 ${first.z.toFixed(1)} → ${second.z.toFixed(1)}m` +
|
||||
`${head} · 지반고 ${a.z.toFixed(1)} → ${b.z.toFixed(1)}m` +
|
||||
` · 종단기울기 ${((rise / span) * 100).toFixed(1)}%`
|
||||
);
|
||||
};
|
||||
|
||||
/** 클릭에 가장 가까운 노선 위 점. 재던 노선이 잡히면 그쪽이 먼저다(겹친 자리에서 튀지 않게). */
|
||||
/** 커서 미리보기 한 줄 — 받아 둔 높이 표에서 보간한 값(누르면 두 점만 다시 물어 고정). */
|
||||
const previewText = (first: MeasurePoint, at: MeasureMark): string => {
|
||||
const table =
|
||||
profile !== null && profile.vertices === params.lines()[first.line] ? profile : null;
|
||||
const zOf = (chainageM: number): number | null => (table ? profileZ(table, chainageM) : null);
|
||||
const missing = !table
|
||||
? "지반고 읽는 중…"
|
||||
: table.failed
|
||||
? "지반고를 못 읽어 기울기는 못 냅니다."
|
||||
: table.z
|
||||
? "지표면 밖이라 기울기는 못 냅니다."
|
||||
: "지반고 읽는 중…";
|
||||
const text = spanText(
|
||||
LINE_NAME[first.line],
|
||||
{ chainageM: first.chainageM, z: zOf(first.chainageM) },
|
||||
{ chainageM: at.chainageM, z: zOf(at.chainageM) },
|
||||
missing,
|
||||
);
|
||||
return `${text} · 누르면 고정`;
|
||||
};
|
||||
|
||||
const hint = (): string => {
|
||||
if (picked.length === 0) return "";
|
||||
const first = picked[0];
|
||||
if (picked.length === 1) {
|
||||
if (hover) return previewText(first, hover);
|
||||
return `${LINE_NAME[first.line]}구간 재기 — 시작 ${formatStation(first.chainageM, params.stationIntervalM)}. 한 점 더.`;
|
||||
}
|
||||
return spanText(
|
||||
LINE_NAME[first.line],
|
||||
first,
|
||||
picked[1],
|
||||
fixing ? "지반고 읽는 중…" : "지반고를 못 읽어 기울기는 못 냅니다.",
|
||||
);
|
||||
};
|
||||
|
||||
/** 클릭에 가장 가까운 노선 위 점. 첫 점이 찍혀 있으면 **그 노선만** 본다(겹친 자리에서 튀지 않게). */
|
||||
const hitAt = (px: number, py: number): (RoutePointHit & { line: MeasureLine }) | null => {
|
||||
const lines = params.lines();
|
||||
const hits = (["planned", "expected"] as const)
|
||||
const candidates: MeasureLine[] =
|
||||
picked.length === 1 ? [picked[0].line] : ["planned", "expected"];
|
||||
const hits = candidates
|
||||
.map((line) => {
|
||||
const hit = routePointAtScreen(lines[line], params.toScreen, px, py, MEASURE_HIT_PX);
|
||||
if (!hit) return null;
|
||||
@@ -145,32 +282,52 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool {
|
||||
})
|
||||
.filter((hit): hit is NonNullable<typeof hit> => hit !== null);
|
||||
if (!hits.length) return null;
|
||||
const keep = picked.length === 1 ? hits.find((hit) => hit.line === picked[0].line) : undefined;
|
||||
const best = keep ?? hits.reduce((a, b) => (b.distance < a.distance ? b : a));
|
||||
const best = hits.reduce((a, b) => (b.distance < a.distance ? b : a));
|
||||
return { point: best.point, chainageM: best.chainageM, line: best.line };
|
||||
};
|
||||
|
||||
return {
|
||||
marks: () =>
|
||||
picked.map((entry) => ({ point: entry.point, chainageM: entry.chainageM, line: entry.line })),
|
||||
marks: () => [
|
||||
...picked.map((entry) => ({
|
||||
point: entry.point,
|
||||
chainageM: entry.chainageM,
|
||||
line: entry.line,
|
||||
})),
|
||||
...(picked.length === 1 && hover ? [hover] : []),
|
||||
],
|
||||
hint,
|
||||
active: () => picked.length > 0,
|
||||
preview(px, py) {
|
||||
const next = picked.length === 1 ? hitAt(px, py) : null;
|
||||
if (!next && !hover) return false;
|
||||
hover = next;
|
||||
// 노선을 고친 뒤면 표가 낡았다 — 다시 받는다.
|
||||
if (next) loadProfile(picked[0].line);
|
||||
params.onHint?.();
|
||||
return true;
|
||||
},
|
||||
clear() {
|
||||
hover = null;
|
||||
profile = null;
|
||||
if (picked.length === 0) return;
|
||||
picked = [];
|
||||
params.onChange();
|
||||
},
|
||||
async pick(px, py) {
|
||||
const hit = hitAt(px, py);
|
||||
hover = null;
|
||||
if (!hit) {
|
||||
picked = []; // 노선을 빗나가면 재던 것을 접는다.
|
||||
params.onChange();
|
||||
return;
|
||||
}
|
||||
const fresh = picked.length >= 2 || (picked.length === 1 && picked[0].line !== hit.line);
|
||||
picked = fresh ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }];
|
||||
picked = picked.length >= 2 ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }];
|
||||
fixing = picked.length === 2;
|
||||
params.onChange();
|
||||
if (picked.length < 2) return;
|
||||
if (picked.length < 2) {
|
||||
loadProfile(hit.line);
|
||||
return;
|
||||
}
|
||||
const asked = picked;
|
||||
try {
|
||||
const heights = await fetchRouteElevations(
|
||||
@@ -183,7 +340,9 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool {
|
||||
});
|
||||
} catch {
|
||||
/* 지반고를 못 읽으면 길이만 낸다 — `hint` 가 그렇게 말한다. */
|
||||
if (picked !== asked) return;
|
||||
}
|
||||
fixing = false;
|
||||
params.onChange();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -71,7 +71,11 @@ export interface RouteEditPointerParams {
|
||||
/** 돌린 지도의 화면 좌표를 돌리기 전 자리로. */
|
||||
unrotate: (x: number, y: number) => [number, number];
|
||||
contourStepM: () => number;
|
||||
measure: { pick: (px: number, py: number) => unknown; clear: () => void };
|
||||
measure: {
|
||||
pick: (px: number, py: number) => unknown;
|
||||
preview: (px: number, py: number) => boolean;
|
||||
clear: () => void;
|
||||
};
|
||||
/** 재기 단추가 켜져 있나 — 켜져 있으면 노드 위에서도 잰다. */
|
||||
measureMode: () => boolean;
|
||||
crossPreview: { open: (chainageM: number) => unknown; refresh: () => unknown };
|
||||
@@ -287,6 +291,8 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
|
||||
params.draw();
|
||||
return;
|
||||
}
|
||||
// 재는 중 — 둘째 점 미리보기는 첫 점 노선 위에만 붙는다.
|
||||
if ((event.shiftKey || params.measureMode()) && params.measure.preview(px, py)) params.draw();
|
||||
const overNode = nodeAt(px, py);
|
||||
// 고정한 교각점 위에서는 **못 끈다**고 커서로 먼저 알린다.
|
||||
canvas.style.cursor =
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
type WeightKey,
|
||||
} from "./B05_Profile_UI_RouteEdit_Initial_Model";
|
||||
import { createRecommendMap, RECOMMEND_MAP_HTML } from "./B05_Profile_UI_RouteEdit_Recommend_Map";
|
||||
import { recommendHelpHtml, syncRecommendHelp } from "./B05_Profile_UI_RouteEdit_Recommend_Help";
|
||||
import "./B05_Profile_UI_Style_RouteRecommend.css";
|
||||
|
||||
type Vertex = [number, number];
|
||||
@@ -225,7 +226,8 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
<span class="b05-routerec__state" data-state></span>
|
||||
<button type="button" class="ui-btn ui-btn--filled" data-act="apply" disabled>적용</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="close">닫기</button>
|
||||
</div>`;
|
||||
</div>
|
||||
${recommendHelpHtml()}`;
|
||||
|
||||
const q = <T extends HTMLElement>(sel: string): T => body.querySelector<T>(sel)!;
|
||||
map = createRecommendMap(q<HTMLElement>(".b05-routerec__mapwrap"));
|
||||
@@ -342,6 +344,7 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
? criteriaLine(preview.criteria, params.planMinRadiusM, preview.logic === "gradeline")
|
||||
: "";
|
||||
line("criteria").hidden = !preview;
|
||||
syncRecommendHelp(body, logic);
|
||||
drawMap();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Recommend_Help.ts
|
||||
* [노선 추천] 모달 오른쪽 설명 칸(계획서 48-2) — 갈래 여덟 · 공통 제약 넷 · 결과 줄 읽는 법.
|
||||
*
|
||||
* 글은 서버 엔진(`B05_Profile_Engine_RouteInitial*`)이 실제로 하는 일을 따라 적는다 — 엔진을
|
||||
* 바꾸면 여기 글도 같이 고친다. 고른 갈래는 강조하고 보이는 자리로 굴린다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { RouteInitialLogic } from "./B05_Profile_Api_RouteInitial";
|
||||
import { LOGIC_LABEL } from "./B05_Profile_UI_RouteEdit_Initial_Model";
|
||||
|
||||
interface HelpEntry {
|
||||
/** 한 줄 요약. */
|
||||
summary: string;
|
||||
/** 노선을 만드는 법. */
|
||||
how: string[];
|
||||
/** 이럴 때 고름. */
|
||||
when: string;
|
||||
/** 주의할 점. */
|
||||
caution: string;
|
||||
}
|
||||
|
||||
const LOGIC_HELP: Record<RouteInitialLogic, HelpEntry> = {
|
||||
follow: {
|
||||
summary: "전처리 때 만든 예상노선(점선)을 그대로 따라가는 노선",
|
||||
how: [
|
||||
"새로 길을 찾지 않음 — 예상노선 꺾임점에 최소 곡선반지름으로 곡선만 넣음",
|
||||
"전처리 초기 계획노선과 같은 규칙이라 같은 선이 나옴",
|
||||
"종단기울기 · 계류 이격은 막지 않고, 넘은 곳만 「못 지킨 구간」에 적음",
|
||||
],
|
||||
when: "현장 답사로 정한 노선을 출발점으로 삼을 때 · 가장 빨리 나옴",
|
||||
caution: "법정 기울기 · 계류 이격을 보장하지 않음 — 못 지킨 구간을 꼭 확인",
|
||||
},
|
||||
gradeline: {
|
||||
summary: "정한 기울기로 등고선을 비스듬히 건너는 등경사선(영선) 노선",
|
||||
how: [
|
||||
"구간마다 기울기가 「목표 기울기」에 가장 가깝게 붙도록 길을 찾음",
|
||||
"목표 칸을 비우면 종단기울기 상한이 목표 · 상한보다 크게 넣으면 상한으로 잘림",
|
||||
"후보 중 목표와의 차(측점 20m 기울기 평균)가 가장 작은 것을 고름",
|
||||
],
|
||||
when: "일정한 기울기로 오르내리는 노선이 필요할 때(지형도 위 영선 잡기와 같은 일)",
|
||||
caution:
|
||||
"오르막 · 내리막을 가리지 않음 · 목표를 비우면 상한에 붙은 가파른 노선이 되기 쉬움 — 목표를 상한보다 조금 낮게 넣기를 권함",
|
||||
},
|
||||
earthwork: {
|
||||
summary: "깎고 쌓는 흙(절토 + 성토)이 가장 적은 노선",
|
||||
how: [
|
||||
"옆경사가 급한 곳 · 지반이 상한보다 가파른 곳을 비싸게 보고 길을 찾음",
|
||||
"후보 중 절토 + 성토 어림 양이 가장 적은 것을 고름",
|
||||
"흙 양 = 계획고를 상한 안으로 맞춘 선과 옆경사 단면(노폭 4m)으로 어림",
|
||||
],
|
||||
when: "토공량 · 사토 처리를 줄여야 할 때",
|
||||
caution: "비탈면 흙은 빠진 어림값 · 연장이 길어지는지 비교값으로 함께 볼 것",
|
||||
},
|
||||
gentle: {
|
||||
summary: "종단기울기가 가장 순한 노선",
|
||||
how: [
|
||||
"기울기가 상한에 가까울수록 크게 벌점을 매겨 길을 찾음",
|
||||
"후보 중 가장 가파른 구간의 기울기가 가장 낮은 것을 고름(같으면 평균 기울기)",
|
||||
],
|
||||
when: "차량 주행 · 노면 침식이 걱정되는 노선",
|
||||
caution: "거리 비용이 작아 돌아가는 긴 노선이 나올 수 있음 — 연장을 확인",
|
||||
},
|
||||
safe: {
|
||||
summary: "급한 옆경사 · 계류 가까운 곳을 피하는 노선",
|
||||
how: [
|
||||
"옆경사 35°(약 70%) 이상 · 계류 이격 거리 안을 위험 구간으로 보고 피함",
|
||||
"후보 중 위험 구간을 지나는 길이가 가장 짧은 것을 고름(같으면 평균 옆경사)",
|
||||
"35° 기준 = 「35° 이상 전폭 절토」 기술 기준",
|
||||
],
|
||||
when: "붕괴 · 유실 위험이 큰 급경사지 · 계곡 가까운 지형",
|
||||
caution:
|
||||
"계곡을 따라가는 지형에서는 위험 구간이 어디서나 길어 다른 갈래와 차이가 작음 — 계류 이격 값을 줄여 다시 셈해 볼 것",
|
||||
},
|
||||
crossings: {
|
||||
summary: "계류를 건너는 횟수가 가장 적은 노선",
|
||||
how: [
|
||||
"계류 위를 지나는 칸에 벌점을 매겨 길을 찾음",
|
||||
"후보 중 계류 횡단 수가 가장 적은 것을 고름(같으면 계류에서 더 먼 쪽)",
|
||||
"10m 안에서 여러 번 건너는 것은 한 번으로 셈",
|
||||
],
|
||||
when: "배수관 · 세월교 같은 횡단 구조물을 줄이고 싶을 때",
|
||||
caution: "소하천 · 지방하천 · 국가하천만 셈 — 작은 세류는 빠짐",
|
||||
},
|
||||
cost: {
|
||||
summary: "토공 · 연장 · 계류 횡단을 한 숫자로 묶은 공사비 어림이 가장 낮은 노선",
|
||||
how: [
|
||||
"공사비 어림 = 흙 1㎥ × 1 + 연장 1m × 2 + 계류 횡단 1곳 × 400(흙 ㎥ 상당)",
|
||||
"같은 비율을 1m 당 값으로 바꿔 길을 찾고, 후보 중 어림값이 가장 낮은 것을 고름",
|
||||
],
|
||||
when: "토공 · 연장 · 구조물 수를 한꺼번에 비교하고 싶을 때",
|
||||
caution: "실제 원가가 아닌 상대값(임시 비율) — 갈래끼리 비교용으로만 볼 것",
|
||||
},
|
||||
weighted: {
|
||||
summary: "예상 · 토공 · 기울기 세 비중(합 100)을 섞어 찾은 노선",
|
||||
how: [
|
||||
"예상 = 예상노선에서 떨어지지 않기 · 토공 = 흙 양 줄이기 · 기울기 = 순한 기울기",
|
||||
"비중대로 섞은 비용 하나로 한 번 길을 찾음 — 한쪽이 100 이면 그 갈래와 같은 노선",
|
||||
"자물쇠로 비중 하나를 고정하면 나머지 둘만 움직임 · 기본 50 / 25 / 25",
|
||||
],
|
||||
when: "예상노선을 어느 정도 살리면서 토공 · 기울기도 챙기고 싶을 때",
|
||||
caution: "제약은 벌점일 뿐이라 넘는 구간이 남을 수 있음 — 못 지킨 구간을 확인",
|
||||
},
|
||||
};
|
||||
|
||||
const CRITERIA_HELP: Array<{ name: string; text: string }> = [
|
||||
{
|
||||
name: "종단기울기 상한(%)",
|
||||
text: "이보다 가파른 구간을 만들지 않음. 비우면 임도 종류 · 지형 · 설계속도 법정 값(20km/h 일반 9% · 특수 14%). 곡선을 넣은 뒤 넘으면 상한을 조금씩 낮춰 다시 찾음",
|
||||
},
|
||||
{
|
||||
name: "최소 반지름(m)",
|
||||
text: "꺾는 곳마다 이 반지름의 곡선이 들어갈 만큼 곧게 간 뒤에만 다시 꺾음. 비우면 설계속도 · 지형 법정 값(20km/h 15m · 12m)",
|
||||
},
|
||||
{
|
||||
name: "계류 이격(m)",
|
||||
text: "계류에서 이 거리 안으로는 따라 걷지 않고 건너거나 빠져나가는 방향으로만 움직임. 비우면 100m(타당성평가 「산지계류 100m 이내」 기준)",
|
||||
},
|
||||
{
|
||||
name: "지반 고름 폭(m)",
|
||||
text: "기울기를 잴 때 지형 잔굴곡을 고르는 폭. 클수록 매끈한 지반으로 봄. 비우면 5m",
|
||||
},
|
||||
];
|
||||
|
||||
const LINES_HELP: Array<{ name: string; text: string }> = [
|
||||
{
|
||||
name: "비교값",
|
||||
text: "연장 · 기울기 최대 · 평균 · 옆경사 · 절토 · 성토 · 계류 횡단 · 공사비 어림 — 갈래를 바꿔 가며 견줌",
|
||||
},
|
||||
{
|
||||
name: "못 지킨 구간",
|
||||
text: "제약을 넘은 측점(5곳까지 · 나머지는 「외 N곳」, 전체는 마우스를 올리면 보임)",
|
||||
},
|
||||
{
|
||||
name: "상한을 풂",
|
||||
text: "지형상 상한 안으로는 시점 · 종점을 이을 수 없어 꼭 필요한 만큼만 상한을 올린 경우",
|
||||
},
|
||||
{
|
||||
name: "거의 같은 노선",
|
||||
text: "제약 안에서 갈 길이 좁은 띠 하나뿐이라 갈래가 달라도 노선이 겹치는 경우 — 제약 값을 바꾸면 벌어질 수 있음",
|
||||
},
|
||||
];
|
||||
|
||||
const LOGIC_ORDER: RouteInitialLogic[] = [
|
||||
"follow",
|
||||
"gradeline",
|
||||
"earthwork",
|
||||
"gentle",
|
||||
"safe",
|
||||
"crossings",
|
||||
"cost",
|
||||
"weighted",
|
||||
];
|
||||
|
||||
/** 설명 칸 HTML — 모달 본문 끝에 붙는다(세로 구분선 오른쪽). */
|
||||
export function recommendHelpHtml(): string {
|
||||
const logics = LOGIC_ORDER.map((key) => {
|
||||
const entry = LOGIC_HELP[key];
|
||||
return `<article class="b05-routerec__help-item" data-help-logic="${key}">
|
||||
<h4>${LOGIC_LABEL[key]}</h4>
|
||||
<p class="b05-routerec__help-summary">${entry.summary}</p>
|
||||
<ul>${entry.how.map((line) => `<li>${line}</li>`).join("")}</ul>
|
||||
<p><b>이럴 때</b> ${entry.when}</p>
|
||||
<p class="b05-routerec__help-caution"><b>주의</b> ${entry.caution}</p>
|
||||
</article>`;
|
||||
}).join("");
|
||||
const list = (items: Array<{ name: string; text: string }>): string =>
|
||||
`<dl>${items.map((item) => `<dt>${item.name}</dt><dd>${item.text}</dd>`).join("")}</dl>`;
|
||||
return `<aside class="b05-routerec__help" aria-label="노선 추천 설명">
|
||||
<h3>추천 갈래</h3>
|
||||
<p class="b05-routerec__help-lead">제약을 지키는 후보 노선 여러 벌을 찾은 뒤, 갈래마다 자기 기준이 가장 좋은 것을 고름.</p>
|
||||
${logics}
|
||||
<h3>공통 제약</h3>
|
||||
<p class="b05-routerec__help-lead">예상노선 추적 · 비중 계산을 뺀 갈래는 넘지 않음 · 칸을 비우면 기본값.</p>
|
||||
${list(CRITERIA_HELP)}
|
||||
<h3>결과 줄 읽는 법</h3>
|
||||
${list(LINES_HELP)}
|
||||
</aside>`;
|
||||
}
|
||||
|
||||
/** 고른 갈래 설명을 강조하고 보이는 자리로 굴린다. */
|
||||
export function syncRecommendHelp(body: HTMLElement, logic: RouteInitialLogic): void {
|
||||
body.querySelectorAll<HTMLElement>("[data-help-logic]").forEach((item) => {
|
||||
const on = item.dataset.helpLogic === logic;
|
||||
if (on && !item.classList.contains("is-on")) item.scrollIntoView({ block: "nearest" });
|
||||
item.classList.toggle("is-on", on);
|
||||
});
|
||||
}
|
||||
@@ -184,10 +184,17 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
|
||||
context.lineWidth = 0.8;
|
||||
drawPreparedLayer(context, scene.contours.layer, view, "dot", everyM);
|
||||
// 고른 가닥은 굵고 다른 색으로 덧그린다 — 지우고 다시 그리지 않고 위에 얹는다.
|
||||
// 끊어져 여러 가닥으로 나뉜 **같은 높이 줄은 함께** 덧그린다(높이를 모르면 고른 가닥만).
|
||||
if (scene.pickedContour >= 0) {
|
||||
const { layer } = scene.contours;
|
||||
const level = layer.features[scene.pickedContour]?.labelValue ?? null;
|
||||
context.strokeStyle = style.getPropertyValue("--map-flow-arrow") || "#7c3aed";
|
||||
context.lineWidth = 2.6;
|
||||
drawPreparedFeature(context, scene.contours.layer, scene.pickedContour, view);
|
||||
layer.features.forEach((feature, index) => {
|
||||
if (index === scene.pickedContour || (level !== null && feature.labelValue === level)) {
|
||||
drawPreparedFeature(context, layer, index, view);
|
||||
}
|
||||
});
|
||||
}
|
||||
// 높이값 라벨은 여기서 안 낸다 — 노선·눈금 **뒤에** 그려야 안 묻힌다(맨 아래 참고).
|
||||
}
|
||||
|
||||
@@ -61,9 +61,8 @@ interface OptionShape {
|
||||
}
|
||||
|
||||
/**
|
||||
* 등록부 옵션 입력 한 칸 — **저장된 값만 칸에 넣고, 등록부 기본값은 제안으로만** 보임
|
||||
* (숫자·글 칸은 회색 글씨, 고르기 칸은 빈 보기 이름). 2026-09-14 브레인 판정 「기본값을 몰래
|
||||
* 확정으로 바꾸지 않는다」 — 칸에 채워 두면 저장이 그 값을 적어 사용자가 한 일 없이 확정이 됨.
|
||||
* 등록부 옵션 입력 한 칸 — **받은 값만 칸에 넣음**. 새 구조물은 부르는 쪽이 등록부 기본값을 값으로
|
||||
* 실어 줌(PLAN 46-7 · 제안값 단추 없음) · 옛 기록의 빈 칸은 빈 칸 그대로(미확정).
|
||||
* 고르기 칸은 늘 빈 보기를 둠 — 첫 보기를 미리 고르지 않음.
|
||||
*/
|
||||
export function optionControl(
|
||||
@@ -71,15 +70,10 @@ export function optionControl(
|
||||
stored: string | number | undefined,
|
||||
): HTMLInputElement | HTMLSelectElement {
|
||||
const value = stored === undefined || stored === null ? "" : String(stored);
|
||||
const suggested = option.default === null || option.default === "" ? "" : String(option.default);
|
||||
// 툴팁 — 표가 아직 안 읽는 칸이면 그 사유가 먼저(넣어도 안 바뀜을 알아야 함), 아니면 비울 때의 뜻.
|
||||
const tip = option.not_in_table ?? option.empty_means ?? "";
|
||||
if (option.input === "select") {
|
||||
const blank = suggested
|
||||
? `— 안 정함 (제안 ${suggested}) —`
|
||||
: option.required
|
||||
? "— 선택 —"
|
||||
: "— 안 정함 —";
|
||||
const blank = option.required ? "— 선택 —" : "— 안 정함 —";
|
||||
const element = select([["", blank], ...option.choices.map((c) => [c, c] as [string, string])]);
|
||||
element.value = value;
|
||||
if (tip) element.title = tip;
|
||||
@@ -89,7 +83,7 @@ export function optionControl(
|
||||
option.input === "number" ? numberInput("0.1", "0") : document.createElement("input");
|
||||
if (option.input !== "number") element.type = "text";
|
||||
element.value = value;
|
||||
element.placeholder = suggested ? `제안 ${suggested}` : option.required ? "필수 입력" : "";
|
||||
element.placeholder = option.required ? "필수 입력" : "";
|
||||
if (tip) element.title = tip;
|
||||
const limit = option.warn_above;
|
||||
if (typeof limit === "number") {
|
||||
@@ -105,24 +99,15 @@ export function optionControl(
|
||||
return element;
|
||||
}
|
||||
|
||||
/** [제안값 넣기] — **누른 때만** 빈 칸에 등록부 기본값을 넣음(적은 칸은 안 건드림) · 층따기 단추와 같은 모양. */
|
||||
export function suggestButton(
|
||||
entries: ReadonlyArray<{ input: HTMLInputElement | HTMLSelectElement; suggested: string }>,
|
||||
): HTMLButtonElement {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "b05-structure__suggest";
|
||||
button.textContent = "제안값 넣기";
|
||||
button.addEventListener("click", () => {
|
||||
const filled = entries.filter((entry) => entry.suggested && !entry.input.value);
|
||||
filled.forEach((entry) => {
|
||||
entry.input.value = entry.suggested;
|
||||
entry.input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
// 반영은 한 번만 — 칸마다 저장 흐름이 돌지 않게 마지막 칸만 change 를 울림.
|
||||
filled.at(-1)?.input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
return button;
|
||||
/** 새 구조물 칸에 실을 등록부 기본값(PLAN 46-7) — 기본값이 있는 칸만 · 없는 칸은 빈 칸(미확정). */
|
||||
export function optionDefaults(
|
||||
options: ReadonlyArray<{ key: string; default: string | number | null }>,
|
||||
): Record<string, string | number> {
|
||||
return Object.fromEntries(
|
||||
options
|
||||
.filter((option) => option.default !== null && option.default !== "")
|
||||
.map((option) => [option.key, option.default as string | number]),
|
||||
);
|
||||
}
|
||||
|
||||
/** 측점번호 + 잔여거리 두 칸 묶음. */
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "./B05_Profile_UI_Structures_Fields";
|
||||
import { createFacilityOptionsForm } from "./B05_Profile_UI_Drainage_Facility";
|
||||
import { buildSummaryRow, type SummaryRow } from "./B05_Profile_UI_Structures_Summary_Row";
|
||||
import { createGroupBoxes, type GroupBoxes } from "./B05_Profile_UI_Structures_Groups";
|
||||
|
||||
export interface StructuresFormElements {
|
||||
root: HTMLElement;
|
||||
@@ -52,6 +53,8 @@ export interface StructuresFormElements {
|
||||
list: HTMLElement;
|
||||
/** 옵션 · 시설 서브폼 아래 「집계 항목」 줄(PLAN 21-4) — 이름 · [고르기] · [해제]. */
|
||||
summary: SummaryRow;
|
||||
/** 본체 집계 항목 아래 옵션 그룹 보조 컨테이너(PLAN 46-3) — 그룹마다 상자 · 집계 항목. */
|
||||
groupBoxes: GroupBoxes;
|
||||
}
|
||||
|
||||
/** 폼 뼈대를 세운다. 값 채우기·이벤트 배선은 호출한 쪽(패널)이 한다. */
|
||||
@@ -144,6 +147,7 @@ export function buildStructuresForm(options: {
|
||||
// 집계 항목 줄 — 종류를 고른 뒤에만 보인다(패널이 `syncPlacementFields` 에서 켠다).
|
||||
const summary = buildSummaryRow();
|
||||
summary.root.hidden = true;
|
||||
const groupBoxes = createGroupBoxes();
|
||||
|
||||
body.append(
|
||||
typeRow,
|
||||
@@ -153,6 +157,7 @@ export function buildStructuresForm(options: {
|
||||
optionRow,
|
||||
facilityOptions.root,
|
||||
summary.root,
|
||||
groupBoxes.root,
|
||||
actions,
|
||||
);
|
||||
|
||||
@@ -178,5 +183,6 @@ export function buildStructuresForm(options: {
|
||||
resetButton,
|
||||
list,
|
||||
summary,
|
||||
groupBoxes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Structures_Groups.ts
|
||||
* 구조물 폼의 **옵션 그룹 보조 컨테이너**(PLAN 46-3) — M02 「구조물 옵션 보이기」에서 종류마다 만든
|
||||
* 그룹(`StructureType.groups`)마다 상자 하나(제목 = 그룹 이름) · 그 그룹 옵션 칸 · 「집계 항목」 줄.
|
||||
*
|
||||
* · 칸은 새로 그리지 않고 **옮긴다** — 레지스트리 옵션 칸 · 계곡 통과 시설 칸이 붙인
|
||||
* `data-option-keys` 표식으로 찾아 상자 안으로 옮기고, 제자리에는 표지(주석 노드)를 둔다. 값 읽기 ·
|
||||
* 이벤트는 칸 참조 그대로라 안 바뀜. 다시 세울 때(`render`)마다 먼저 제자리로 되돌림.
|
||||
* · 키가 여럿인 칸(본체 규격 · 유입구 구조)은 **키가 다 그 그룹일 때만** 옮김(숨김 규칙과 같음 ·
|
||||
* 독립 기슭막이 채널 키는 `hiddenFormKeys` 로 넓힘).
|
||||
* · 그룹 연결 값(`{그룹 id: 참조}`)은 정의가 사라진 그룹 것도 그대로 쥠(값 보존 · 경고 한 줄).
|
||||
* · 저장은 패널이 한다(`group_items` 칸) — 여기는 값만 쥠.
|
||||
* ========================================================================== */
|
||||
|
||||
import { openItemPicker } from "@ui/ui_template_item_picker";
|
||||
import { pipeStructureId } from "@ui/ui_template_item_picker_project";
|
||||
import type { StructureType } from "./B05_Profile_Api_Structures";
|
||||
import { hiddenFormKeys } from "./B05_Profile_UI_Drainage_Facility_Merge";
|
||||
import { buildSummaryRow, type SummaryRow } from "./B05_Profile_UI_Structures_Summary_Row";
|
||||
import {
|
||||
GROUP_JOIN,
|
||||
type GroupItems,
|
||||
type SummaryItem,
|
||||
} from "../common_util/common_util_summary_item";
|
||||
|
||||
export interface GroupBoxes {
|
||||
/** 상자를 담는 자리 — 폼이 본체 「집계 항목」 줄 아래에 붙임. */
|
||||
root: HTMLElement;
|
||||
/** 종류 · 칸에 맞춰 상자를 다시 세움 — `scopes` 안 표식 칸을 그룹 상자로 옮김. */
|
||||
render: (type: StructureType | null, scopes: HTMLElement[], facility: string | null) => void;
|
||||
get: () => GroupItems;
|
||||
/** 값만 바꿔 그림 — 저장 · 알림은 안 함(폼 불러오기 · 모달 결과가 씀). */
|
||||
set: (items: GroupItems) => void;
|
||||
/** 한 그룹 값 — null = 해제. */
|
||||
setOne: (groupId: string, item: SummaryItem | null) => void;
|
||||
/** [고르기] · [해제] 배선 — 패널이 한 번 부른다. */
|
||||
bind: (handlers: { pick: (groupId: string) => void; clear: (groupId: string) => void }) => void;
|
||||
}
|
||||
|
||||
export function createGroupBoxes(): GroupBoxes {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b05-structure__optgroups";
|
||||
root.dataset.b05Groups = "";
|
||||
root.hidden = true;
|
||||
let items: GroupItems = {};
|
||||
let handlers: { pick: (id: string) => void; clear: (id: string) => void } | null = null;
|
||||
const rows = new Map<string, SummaryRow>();
|
||||
/** 옮긴 칸 · 제자리 표지 */
|
||||
const moved: Array<{ node: HTMLElement; mark: Comment }> = [];
|
||||
/** 칸이 다 빠져 가린 줄 · 소그룹 상자 */
|
||||
const emptied: HTMLElement[] = [];
|
||||
/** 그룹 상자 몸통 — 옮겨 온 칸이 원래 자리처럼 잠기게(측점 확정 전) */
|
||||
let bodies: HTMLElement[] = [];
|
||||
let lockSource: HTMLElement | null = null;
|
||||
const syncLock = (): void => {
|
||||
const locked = !!lockSource?.inert;
|
||||
bodies.forEach((body) => {
|
||||
body.inert = locked;
|
||||
body.classList.toggle("is-locked", locked);
|
||||
});
|
||||
};
|
||||
const lockWatch = typeof MutationObserver !== "undefined" ? new MutationObserver(syncLock) : null;
|
||||
|
||||
const restore = (): void => {
|
||||
for (const { node, mark } of moved.splice(0)) {
|
||||
if (mark.isConnected) mark.replaceWith(node);
|
||||
else node.remove(); // 원래 자리가 다시 그려졌음(레지스트리 칸) — 옛 칸은 버림
|
||||
}
|
||||
emptied.splice(0).forEach((node) => node.classList.remove("is-group-emptied"));
|
||||
};
|
||||
|
||||
const paintStale = (known: Set<string>): HTMLElement | null => {
|
||||
const stale = Object.keys(items).filter((id) => !known.has(id));
|
||||
if (!stale.length) return null;
|
||||
const note = document.createElement("p");
|
||||
note.className = "b05-structure__owner-note";
|
||||
note.textContent = `정의가 사라진 그룹 연결 ${stale.length}개 — 값만 남음(집계표에는 안 들어감)`;
|
||||
return note;
|
||||
};
|
||||
|
||||
return {
|
||||
root,
|
||||
render(type, scopes, facility) {
|
||||
restore();
|
||||
rows.clear();
|
||||
const groups = type?.groups ?? [];
|
||||
const boxes: HTMLElement[] = [];
|
||||
bodies = [];
|
||||
// 「구조물 옵션 보이기」에서 끈 칸 — 시설 폼 밖으로 옮긴 칸도 같은 규칙으로 가림(Hidden 과 같은 식)
|
||||
const off = hiddenFormKeys(
|
||||
facility,
|
||||
(type?.options ?? []).filter((o) => o.visible === false).map((o) => o.key),
|
||||
);
|
||||
for (const group of groups) {
|
||||
const keys = hiddenFormKeys(facility, group.options);
|
||||
const box = document.createElement("fieldset");
|
||||
box.className = "b05-structure__group b05-structure__optgroup";
|
||||
box.dataset.b05Group = group.id;
|
||||
const legend = document.createElement("legend");
|
||||
legend.textContent = group.name;
|
||||
const body = document.createElement("div");
|
||||
body.className = "b05-structure__grid";
|
||||
for (const scope of scopes) {
|
||||
const marked = [...scope.querySelectorAll<HTMLElement>("[data-option-keys]")];
|
||||
// 지금 숨은 칸(다른 시설 몫 · 안 고른 갈래)은 그 자리에 둠 — 같은 키 칸이 두 벌 옮겨 오지 않게
|
||||
const shown = (node: HTMLElement): boolean => {
|
||||
for (let at: HTMLElement | null = node; at && at !== scope; at = at.parentElement)
|
||||
if (at.hidden) return false;
|
||||
return true;
|
||||
};
|
||||
const fits = marked.filter(
|
||||
(node) =>
|
||||
shown(node) &&
|
||||
(node.dataset.optionKeys ?? "").split(" ").every((key) => key && keys.has(key)),
|
||||
);
|
||||
// 바깥 칸만 — 옮길 칸 안의 칸은 따라 옴
|
||||
for (const node of fits.filter((n) => !fits.some((o) => o !== n && o.contains(n)))) {
|
||||
const parent = node.parentElement;
|
||||
const mark = document.createComment(`옵션 그룹 ${group.id}`);
|
||||
node.before(mark);
|
||||
body.append(node);
|
||||
moved.push({ node, mark });
|
||||
const own = (node.dataset.optionKeys ?? "").split(" ");
|
||||
node.classList.toggle(
|
||||
"is-option-hidden",
|
||||
own.every((key) => off.has(key)),
|
||||
);
|
||||
// 칸이 다 빠진 줄 · 소그룹 상자는 가림(빈 줄 · 빈 상자 제목이 안 남게)
|
||||
for (let at = parent; at && at !== scope; at = at.parentElement) {
|
||||
if (at.querySelector("input, select, textarea")) break;
|
||||
at.classList.add("is-group-emptied");
|
||||
emptied.push(at);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!body.childElementCount) {
|
||||
const none = document.createElement("p");
|
||||
none.className = "b05-structure__owner-note";
|
||||
none.textContent = "옵션 없음 — 집계 항목만 지정";
|
||||
body.append(none);
|
||||
}
|
||||
const row = buildSummaryRow();
|
||||
row.set(items[group.id] ?? null);
|
||||
row.bind({
|
||||
pick: () => handlers?.pick(group.id),
|
||||
clear: () => handlers?.clear(group.id),
|
||||
});
|
||||
rows.set(group.id, row);
|
||||
box.append(legend, body, row.root);
|
||||
boxes.push(box);
|
||||
bodies.push(body);
|
||||
}
|
||||
const stale = type ? paintStale(new Set(groups.map((g) => g.id))) : null;
|
||||
root.replaceChildren(...boxes, ...(stale ? [stale] : []));
|
||||
root.hidden = !type || (!boxes.length && !stale);
|
||||
// 잠금은 계곡 통과 시설 서브폼(`inert`)을 따름 — 레지스트리 칸은 칸마다 disabled 라 그대로
|
||||
const facilityRoot = scopes.find((scope) =>
|
||||
scope.classList.contains("b05-drainage__facility"),
|
||||
);
|
||||
if (facilityRoot && facilityRoot !== lockSource) {
|
||||
lockSource = facilityRoot;
|
||||
lockWatch?.observe(facilityRoot, { attributes: true, attributeFilter: ["inert"] });
|
||||
}
|
||||
syncLock();
|
||||
},
|
||||
get: () => ({ ...items }),
|
||||
set(next) {
|
||||
items = { ...next };
|
||||
for (const [id, row] of rows) row.set(items[id] ?? null);
|
||||
},
|
||||
setOne(groupId, item) {
|
||||
if (item) items = { ...items, [groupId]: item };
|
||||
else {
|
||||
const { [groupId]: _gone, ...rest } = items;
|
||||
items = rest;
|
||||
}
|
||||
rows.get(groupId)?.set(item);
|
||||
},
|
||||
bind(next) {
|
||||
handlers = next;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 본체 · 그룹 「집계 항목」 [고르기] · [해제] 배선 — [고르기]는 21-5 모달, 결과는 폼 값으로만 두고
|
||||
* 고치는 중이면 곧바로 반영(새 항목은 종전대로 [추가]가 확정) · 안 고른 채 닫으면 그대로.
|
||||
* 복사본은 구조물마다(고치는 구조물 id · 관 지점 = `pipe@측점`) · 그룹은 `{id}__{그룹 id}` ·
|
||||
* 추가 전이면 null(원본 보기만). */
|
||||
export function bindSummaryPickers(opts: {
|
||||
summary: SummaryRow;
|
||||
groups: GroupBoxes;
|
||||
projectId: string | null;
|
||||
editingId: () => string | null;
|
||||
selectedPipeChainage: () => number | null;
|
||||
commitSummary: () => void;
|
||||
}): void {
|
||||
const base = (): string | null => {
|
||||
const pipe = opts.selectedPipeChainage();
|
||||
return opts.editingId() ?? (pipe !== null ? pipeStructureId(pipe) : null);
|
||||
};
|
||||
// canCopy — 개인 층에 쓸 수 없는 사용자의 복사는 서버가 막은 까닭을 모달이 그대로 보임
|
||||
const pick = (current: SummaryItem | null, structureId: string | null) =>
|
||||
openItemPicker({ current, canCopy: true, projectId: opts.projectId, structureId });
|
||||
opts.summary.bind({
|
||||
pick: () =>
|
||||
void pick(opts.summary.get(), base()).then((result) => {
|
||||
if (result.action === "cancel") return;
|
||||
opts.summary.set(result.action === "apply" ? result.item : null);
|
||||
opts.commitSummary();
|
||||
}),
|
||||
clear: () => {
|
||||
opts.summary.set(null);
|
||||
opts.commitSummary();
|
||||
},
|
||||
});
|
||||
opts.groups.bind({
|
||||
pick: (groupId) => {
|
||||
const id = base();
|
||||
void pick(
|
||||
opts.groups.get()[groupId] ?? null,
|
||||
id ? `${id}${GROUP_JOIN}${groupId}` : null,
|
||||
).then((result) => {
|
||||
if (result.action === "cancel") return;
|
||||
opts.groups.setOne(groupId, result.action === "apply" ? result.item : null);
|
||||
opts.commitSummary();
|
||||
});
|
||||
},
|
||||
clear: (groupId) => {
|
||||
opts.groups.setOne(groupId, null);
|
||||
opts.commitSummary();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -22,10 +22,9 @@ import {
|
||||
} from "./B05_Profile_Api_Structures";
|
||||
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
|
||||
import { field } from "./B05_Profile_UI_Structures_Fields";
|
||||
import { field, optionDefaults } from "./B05_Profile_UI_Structures_Fields";
|
||||
import { renderOptionFields as renderOptionFieldsInto } from "./B05_Profile_UI_Structures_Panel_Options";
|
||||
import { openItemPicker } from "@ui/ui_template_item_picker";
|
||||
import { pipeStructureId } from "@ui/ui_template_item_picker_project";
|
||||
import { bindSummaryPickers } from "./B05_Profile_UI_Structures_Groups";
|
||||
import { buildStructuresForm } from "./B05_Profile_UI_Structures_Form";
|
||||
import {
|
||||
bindStructuresEvents,
|
||||
@@ -67,7 +66,8 @@ export function createStructuresSection(
|
||||
// 폼 뼈대는 전용 조립기가 세운다(2026-09-03 · 700줄 제한) — 여기서는 값·검증·저장만.
|
||||
const form = buildStructuresForm({
|
||||
getInterval: () => callbacks.getInterval(),
|
||||
onFacilityChange: () => liveCommit(),
|
||||
// 갈래(유입구 구조 등)가 바뀌면 보이는 칸이 달라짐 — 그룹 상자도 다시 세움(PLAN 46-3)
|
||||
onFacilityChange: () => (regroup(), liveCommit()),
|
||||
});
|
||||
const {
|
||||
root,
|
||||
@@ -91,7 +91,15 @@ export function createStructuresSection(
|
||||
resetButton,
|
||||
list,
|
||||
summary,
|
||||
groupBoxes,
|
||||
} = form;
|
||||
/** 옵션 그룹 상자를 지금 종류 · 칸에 맞춰 다시 세움(PLAN 46-3) — 칸을 그린 뒤마다 부름. */
|
||||
const regroup = (): void =>
|
||||
groupBoxes.render(
|
||||
currentType(),
|
||||
[optionRow, facilityOptions.root],
|
||||
facilityFormKind(currentType()),
|
||||
);
|
||||
|
||||
/** 이 타입이 서브폼(계곡 통과 시설 부속)을 쓰는지 — 쓰면 레지스트리 옵션 칸 대신
|
||||
* 서브폼이 그린다. 독립 기슭막이(D4)는 서브폼을 떠나 C군과 같은 레지스트리 옵션
|
||||
@@ -232,16 +240,17 @@ export function createStructuresSection(
|
||||
|
||||
/** 종류에 맞춰 부속 옵션 서브폼을 켠다(계곡 통과 시설·독립 기슭막이, 값은 인자로). */
|
||||
function syncFacilityForm(
|
||||
options: Record<string, string | number> = {},
|
||||
options?: Record<string, string | number>,
|
||||
designFlow: number | null = null,
|
||||
): void {
|
||||
const kind = facilityFormKind(currentType());
|
||||
facilityOptions.setFacility(kind, options, designFlow);
|
||||
facilityOptions.setFacility(kind, options ?? newDefaults(), designFlow);
|
||||
// 「구조물 옵션 보이기」에서 끈 옵션은 시설 폼에서도 가림(값은 옛 값 그대로 저장)
|
||||
facilityOptions.setHiddenOptions(
|
||||
kind,
|
||||
(currentType()?.options ?? []).filter((o) => !isOptionVisible(o)).map((o) => o.key),
|
||||
);
|
||||
regroup();
|
||||
syncOptionLock();
|
||||
}
|
||||
|
||||
@@ -310,8 +319,14 @@ export function createStructuresSection(
|
||||
}
|
||||
|
||||
/** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 — 그리는 규칙은 `_Structures_Panel_Options`. */
|
||||
function renderOptionFields(values: Record<string, string | number> = {}): void {
|
||||
renderOptionFieldsInto(optionContext, values);
|
||||
function renderOptionFields(values?: Record<string, string | number>): void {
|
||||
renderOptionFieldsInto(optionContext, values ?? newDefaults());
|
||||
regroup();
|
||||
}
|
||||
|
||||
/** 값 없이 그리면 새 구조물 — 등록부 기본값을 칸에 실제 값으로(PLAN 46-7 · 옛 기록은 받은 값만). */
|
||||
function newDefaults(): Record<string, string | number> {
|
||||
return optionDefaults(currentType()?.options ?? []);
|
||||
}
|
||||
|
||||
/** 고른 구조물군의 타입을 종류 목록에 채운다 — A군은 계곡 통과 시설(managed_by)도
|
||||
@@ -491,31 +506,18 @@ export function createStructuresSection(
|
||||
},
|
||||
summary: () => summary.get(),
|
||||
setSummary: (value) => summary.set(value),
|
||||
groupItems: () => groupBoxes.get(),
|
||||
setGroupItems: (value) => groupBoxes.set(value),
|
||||
};
|
||||
bindStructuresEvents(panelContext);
|
||||
// 집계 항목 — [고르기]는 21-5 모달, 결과는 폼 값으로만 두고 고치는 중이면 곧바로 반영한다
|
||||
// (새 항목은 종전대로 [추가]가 확정). 안 고른 채 닫으면 그대로.
|
||||
summary.bind({
|
||||
pick: () => {
|
||||
// canCopy — 개인 층에 쓸 수 없는 사용자의 복사는 서버가 막은 까닭을 모달이 그대로 보임
|
||||
void openItemPicker({
|
||||
current: summary.get(),
|
||||
canCopy: true,
|
||||
projectId: sectionOptions.projectId ?? null,
|
||||
// 복사본은 구조물마다 — 고치는 구조물의 id(관 지점 = `pipe@측점`) · 추가 전이면 null(원본 보기만)
|
||||
structureId:
|
||||
editingId ??
|
||||
(selectedPipeChainage !== null ? pipeStructureId(selectedPipeChainage) : null),
|
||||
}).then((result) => {
|
||||
if (result.action === "cancel") return;
|
||||
summary.set(result.action === "apply" ? result.item : null);
|
||||
commitSummary();
|
||||
});
|
||||
},
|
||||
clear: () => {
|
||||
summary.set(null);
|
||||
commitSummary();
|
||||
},
|
||||
// 집계 항목(본체 · 옵션 그룹) — [고르기]는 21-5 모달 · 배선은 `_Structures_Groups`
|
||||
bindSummaryPickers({
|
||||
summary,
|
||||
groups: groupBoxes,
|
||||
projectId: sectionOptions.projectId ?? null,
|
||||
editingId: () => editingId,
|
||||
selectedPipeChainage: () => selectedPipeChainage,
|
||||
commitSummary,
|
||||
});
|
||||
body.insertBefore(field("메모", memoField), actions);
|
||||
syncButtons();
|
||||
@@ -643,11 +645,9 @@ export function createStructuresSection(
|
||||
endFields.write(isInterval ? chainageM + DEFAULT_INTERVAL_LENGTH_M : null, step);
|
||||
memoField.value = "";
|
||||
syncPlacementFields();
|
||||
// 기본값을 값으로 안 채움 — 칸은 비우고 제안으로만(브레인 판정 ②).
|
||||
// 새 구조물 — 등록부 기본값을 칸에 실제 값으로(PLAN 46-7 · 사용자가 고치면 됨).
|
||||
renderOptionFields();
|
||||
// BOX암거도 기본값을 안 실음 — 제안으로만(판정 ③ · 옛 「[추가]만 눌러도 제원」 걷음).
|
||||
// 비운 칸은 하류(횡단도)가 등록부 기본값으로 그림 — 저장만 안 함.
|
||||
syncFacilityForm({});
|
||||
syncFacilityForm();
|
||||
// 임시 배치도 즉시 진행(A군).
|
||||
if (type.managed_by) commitTempPipe();
|
||||
syncButtons();
|
||||
|
||||
@@ -18,7 +18,12 @@ import type { FacilityAttributes, FacilityOptionsForm } from "./B05_Profile_UI_D
|
||||
import type { StationFields } from "./B05_Profile_UI_Structures_Fields";
|
||||
import { splitPavementRange } from "./B05_Profile_UI_Structures_Pavement";
|
||||
import { mergeStructureOptions } from "./B05_Profile_UI_Structures_Summary";
|
||||
import { readSummaryItem, type SummaryItem } from "../common_util/common_util_summary_item";
|
||||
import {
|
||||
readGroupItems,
|
||||
readSummaryItem,
|
||||
type GroupItems,
|
||||
type SummaryItem,
|
||||
} from "../common_util/common_util_summary_item";
|
||||
import type {
|
||||
PipeFacilityItem,
|
||||
StructuresCallbacks,
|
||||
@@ -69,6 +74,15 @@ export interface StructuresCommitContext {
|
||||
/** 폼의 「집계 항목」 값(PLAN 21-4) — 저장은 옵션 밖 `summary_item` 칸으로 나간다. */
|
||||
summary: () => SummaryItem | null;
|
||||
setSummary: (value: SummaryItem | null) => void;
|
||||
/** 옵션 그룹 보조 컨테이너마다 고른 항목(PLAN 46-3) — 저장은 `group_items` 칸. */
|
||||
groupItems: () => GroupItems;
|
||||
setGroupItems: (value: GroupItems) => void;
|
||||
}
|
||||
|
||||
/** 저장할 그룹 연결 — 고른 것이 있을 때만 칸을 실음(빈 묶음 = 칸 없음). */
|
||||
function groupField(ctx: StructuresCommitContext): { group_items?: GroupItems } {
|
||||
const items = ctx.groupItems();
|
||||
return Object.keys(items).length ? { group_items: items } : {};
|
||||
}
|
||||
|
||||
export function loadForm(ctx: StructuresCommitContext, target: StructureInstance | null): void {
|
||||
@@ -91,6 +105,7 @@ export function loadForm(ctx: StructuresCommitContext, target: StructureInstance
|
||||
ctx.memoField.value = target.memo ?? "";
|
||||
ctx.renderOptionFields(target.options);
|
||||
ctx.setSummary(readSummaryItem(target.summary_item));
|
||||
ctx.setGroupItems(readGroupItems(target.group_items));
|
||||
// 구간 직접 입력 시절에 저장된 항목 호환 — 길이·전길이 옵션이 없으면 저장된
|
||||
// 시작~종료·기준점에서 역산해 채워 범위 표시·재저장이 이어지게 한다(2026-08-19).
|
||||
if (target.start_m != null && target.end_m != null && target.end_m > target.start_m) {
|
||||
@@ -119,6 +134,7 @@ export function loadForm(ctx: StructuresCommitContext, target: StructureInstance
|
||||
ctx.memoField.value = "";
|
||||
ctx.renderOptionFields();
|
||||
ctx.setSummary(null);
|
||||
ctx.setGroupItems({});
|
||||
ctx.syncFacilityForm();
|
||||
}
|
||||
[ctx.anchorFields, ctx.startFields, ctx.endFields].forEach((fields) => fields.clearInvalid());
|
||||
@@ -166,6 +182,7 @@ export function loadPipeForm(ctx: StructuresCommitContext, pipe: PipeFacilityIte
|
||||
ctx.memoField.value = "";
|
||||
ctx.syncFacilityForm(pipe.options ?? {}, pipe.design_flow_m3s ?? null);
|
||||
ctx.setSummary(readSummaryItem(pipe.summary_item));
|
||||
ctx.setGroupItems(readGroupItems(pipe.group_items));
|
||||
[ctx.anchorFields, ctx.startFields, ctx.endFields].forEach((fields) => fields.clearInvalid());
|
||||
ctx.syncPlacementFields();
|
||||
ctx.syncButtons();
|
||||
@@ -201,6 +218,9 @@ export function commitSummaryOnly(ctx: StructuresCommitContext): boolean {
|
||||
const summary = ctx.summary();
|
||||
if (summary) next.summary_item = summary;
|
||||
else delete next.summary_item;
|
||||
const groups = groupField(ctx);
|
||||
if (groups.group_items) next.group_items = groups.group_items;
|
||||
else delete next.group_items;
|
||||
ctx.structures[index] = next;
|
||||
emit(ctx);
|
||||
return true;
|
||||
@@ -225,6 +245,7 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
|
||||
if (Object.keys(options).length) attributes.options = options;
|
||||
// 집계 항목 — 미지정(null)도 그대로 실어 옛 지정을 풀 수 있게 한다.
|
||||
attributes.summary_item = ctx.summary();
|
||||
attributes.group_items = groupField(ctx).group_items ?? null; // 빈 것도 실어 옛 지정을 풂
|
||||
if (ctx.tempPipeChainage() !== null) {
|
||||
// 임시 배치 확정([추가]) — 관은 이미 미리보기로 들어가 있으니 옵션·위치만 반영.
|
||||
const from = ctx.tempPipeChainage() as number;
|
||||
@@ -306,6 +327,7 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
|
||||
end_m: end,
|
||||
options: readOptions(ctx),
|
||||
...(summary ? { summary_item: summary } : {}),
|
||||
...groupField(ctx),
|
||||
memo: ctx.memoField.value.trim(),
|
||||
placement_source: "manual" as const,
|
||||
status: "draft" as const,
|
||||
@@ -347,6 +369,7 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
|
||||
};
|
||||
// 해제 = 칸을 지움(옛 지정이 `...previous` 로 살아 오지 않게)
|
||||
if (!summary) delete next.summary_item;
|
||||
if (!groupField(ctx).group_items) delete next.group_items;
|
||||
ctx.structures[index] = next;
|
||||
}
|
||||
records.slice(1).forEach((record) => ctx.structures.push({ ...record, structure_id: null }));
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import { isB05Option, type StructureType } from "./B05_Profile_Api_Structures";
|
||||
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { field, optionControl, suggestButton } from "./B05_Profile_UI_Structures_Fields";
|
||||
import { field, optionControl } from "./B05_Profile_UI_Structures_Fields";
|
||||
import { isOptionVisible } from "./B05_Profile_UI_Structures_Summary";
|
||||
|
||||
/** 패널이 쥔 값 중 옵션 그리기에 필요한 것들. 이름은 분리 전 지역변수와 같다. */
|
||||
@@ -56,12 +56,9 @@ export function renderOptionFields(
|
||||
return;
|
||||
}
|
||||
optionRow.hidden = false;
|
||||
const suggestions: Array<{ input: HTMLInputElement | HTMLSelectElement; suggested: string }> = [];
|
||||
shown.forEach((option) => {
|
||||
// ⭐ 저장된 값만 칸에 · 등록부 기본값은 제안(회색 글씨·빈 보기 이름)으로만 — 고르기 칸은
|
||||
// 첫 보기를 미리 안 고름(2026-09-14 브레인 판정 ①②, `optionControl` 주석).
|
||||
// 받은 값만 칸에 — 새 구조물은 부르는 쪽(패널)이 등록부 기본값을 `values` 로 실어 줌(PLAN 46-7).
|
||||
const input = optionControl(option, values[option.key]);
|
||||
suggestions.push({ input, suggested: String(option.default ?? "") });
|
||||
const label = `${option.unit ? `${option.label} (${option.unit})` : option.label}${option.not_in_table ? " · 표에 안 쓰임" : ""}`;
|
||||
// `enabled:false` 는 칸을 남기고 잠그기만 한다 — 값은 기본값이 그대로 저장된다
|
||||
// (고를 수 없는 칸이라 제안이 아니라 프로그램 값).
|
||||
@@ -72,7 +69,9 @@ export function renderOptionFields(
|
||||
input.classList.add("is-locked");
|
||||
input.title = "지금은 고를 수 없는 항목입니다.";
|
||||
}
|
||||
optionRow.append(field(label, input, option.default_basis));
|
||||
const wrap = field(label, input, option.default_basis);
|
||||
wrap.dataset.optionKeys = option.key; // 옵션 그룹 상자가 이 표식으로 칸을 옮김(PLAN 46-3)
|
||||
optionRow.append(wrap);
|
||||
input.addEventListener("change", () => ctx.liveCommit());
|
||||
optionInputs.push({
|
||||
key: option.key,
|
||||
@@ -111,9 +110,6 @@ export function renderOptionFields(
|
||||
input.addEventListener("change", () => refresh(true));
|
||||
}
|
||||
});
|
||||
if (suggestions.some((entry) => entry.suggested)) {
|
||||
optionRow.append(suggestButton(suggestions));
|
||||
}
|
||||
ctx.syncOptionLock();
|
||||
ctx.syncRangeDisplay();
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type StructureType,
|
||||
} from "./B05_Profile_Api_Structures";
|
||||
import type { DesignSelection, ResolvedSelection } from "../A00_Common/design/design_selection";
|
||||
import type { SummaryItem } from "../common_util/common_util_summary_item";
|
||||
import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item";
|
||||
|
||||
/** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다.
|
||||
* 우클릭 메뉴(종단그래프·배수유역도)도 같은 이름을 쓴다(2026-08-18 일원화). */
|
||||
@@ -51,6 +51,8 @@ export interface PipeFacilityItem {
|
||||
options?: Record<string, string | number>;
|
||||
/** 이 관이 뜻하는 구조물 집계표 항목(21-1) — 없으면 미지정. */
|
||||
summary_item?: SummaryItem | null;
|
||||
/** 옵션 그룹마다 고른 집계 항목(PLAN 46-3). */
|
||||
group_items?: GroupItems | null;
|
||||
}
|
||||
|
||||
/** 관 지점 정본 한 건 → 목록·폼 항목. 집계 항목(21-1)도 옮긴다 — 안 옮기면 목록 색 · 폼이 잃는다. */
|
||||
@@ -69,6 +71,7 @@ export function pipeToFacilityItem(
|
||||
design_flow_m3s: pipe.design_flow_m3s,
|
||||
options: pipe.options,
|
||||
summary_item: pipe.summary_item,
|
||||
group_items: pipe.group_items,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -111,37 +111,10 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 배관 우클릭 메뉴 — 뷰포트 기준 절대 위치(B04 지도와 같은 조작). */
|
||||
/* 배관 우클릭 메뉴 — 뷰포트 기준 절대 위치(B04 지도와 같은 조작) · 모양은 공용 `.ui-ctx-menu`. */
|
||||
.b05-drainage__context-menu {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 130px;
|
||||
padding: var(--spacing-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: 0 4px 16px rgb(0 0 0 / 25%);
|
||||
}
|
||||
|
||||
.b05-drainage__context-menu[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b05-drainage__context-menu-item {
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border: 0;
|
||||
border-radius: var(--radius-cards);
|
||||
background: transparent;
|
||||
color: var(--color-text-body);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b05-drainage__context-menu-item:hover {
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
|
||||
.b05-drainage__viewport {
|
||||
|
||||
@@ -187,7 +187,7 @@
|
||||
|
||||
.b05-routeedit__cross-station {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
font-size: inherit; /* 44-2 — 제목(횡단)과 같은 크기 */
|
||||
}
|
||||
|
||||
/* 횡단 판 — 캔버스 그림이 아니라 **SVG 선**이다(2026-09-12 사용자 지시 ③).
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
z-index: calc(var(--z-modal, 1000) + 2);
|
||||
}
|
||||
|
||||
/* 오른쪽 설명 칸(계획서 48-2)만큼 넓힘 — 왼쪽 기존 칸은 예전 폭 그대로. */
|
||||
.ui-modal.b05-routerec {
|
||||
width: min(960px, 94vw);
|
||||
width: min(1320px, 96vw);
|
||||
--modal-max: 92vh;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
@@ -25,6 +26,119 @@
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
/* 왼쪽 = 기존 칸 넷(알약 · 지도 · 결과 줄 · 바닥) · 오른쪽 = 세로 구분선 + 설명 칸. */
|
||||
.ui-modal.b05-routerec .b05-routerec__body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 340px);
|
||||
align-content: start;
|
||||
column-gap: var(--spacing-16, 16px);
|
||||
}
|
||||
|
||||
.b05-routerec__body > :not(.b05-routerec__help) {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
/* 설명 칸 — 왼쪽 높이를 따라가고(제 글 길이로 창을 키우지 않음) 넘치면 제 안에서 굴림. */
|
||||
.b05-routerec__help {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 4;
|
||||
contain: size;
|
||||
overflow-y: auto;
|
||||
padding-left: var(--spacing-16, 16px);
|
||||
border-left: 1px solid var(--color-border);
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body-sm);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.b05-routerec__help h3 {
|
||||
margin: var(--spacing-12) 0 var(--spacing-4, 4px);
|
||||
color: var(--color-text-heading, var(--color-text-body));
|
||||
font-size: var(--text-body);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.b05-routerec__help h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.b05-routerec__help-lead {
|
||||
margin: 0 0 var(--spacing-8);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.b05-routerec__help-item {
|
||||
margin-bottom: var(--spacing-8);
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-8, 6px);
|
||||
}
|
||||
|
||||
/* 고른 갈래 — 알약과 같은 강조색 테두리. */
|
||||
.b05-routerec__help-item.is-on {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.b05-routerec__help-item h4 {
|
||||
margin: 0 0 2px;
|
||||
font-size: var(--text-body-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.b05-routerec__help-item.is-on h4 {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.b05-routerec__help-item p,
|
||||
.b05-routerec__help dd {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.b05-routerec__help-item ul {
|
||||
margin: 2px 0;
|
||||
padding-left: 1.1em;
|
||||
}
|
||||
|
||||
.b05-routerec__help-summary {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.b05-routerec__help-caution {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.b05-routerec__help dl {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.b05-routerec__help dt {
|
||||
margin-top: var(--spacing-8);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.b05-routerec__help dd {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 좁은 폭 — 설명 칸은 아래로(가로 구분선 · 제 높이 한도 안에서 굴림). */
|
||||
@media (max-width: 1000px) {
|
||||
.ui-modal.b05-routerec .b05-routerec__body {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.b05-routerec__help {
|
||||
grid-column: 1;
|
||||
grid-row: auto;
|
||||
contain: none;
|
||||
max-height: 280px;
|
||||
padding-top: var(--spacing-12);
|
||||
padding-left: 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 알약 — 하나만 고른 상태(라디오 묶음). */
|
||||
/* 여덟 알약 — 네 칸 두 줄로 고르게(좁은 폭은 두 칸 네 줄). */
|
||||
.b05-routerec__pills {
|
||||
|
||||
@@ -221,11 +221,6 @@
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* 근거 줄로 옆 칸이 길어져도 [제안값 넣기]가 세로로 늘지 않게. */
|
||||
.b05-structure__suggest {
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
/* 시작·기준·종료 측점 = 3행. 한 행은 [라벨][측점][+거리] 가로 배치
|
||||
* (2026-08-17 사용자 지시 2). */
|
||||
.b05-structure__position-row {
|
||||
@@ -489,3 +484,34 @@
|
||||
.b05-drainage__facility .is-option-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 옵션 그룹 보조 컨테이너(PLAN 46-3) — 그룹마다 상자 하나 · 옮겨 온 칸 · 「집계 항목」 줄 */
|
||||
.b05-structure__optgroups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.b05-structure__optgroups[hidden],
|
||||
.is-group-emptied {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b05-structure__optgroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.b05-structure__optgroup > legend {
|
||||
font-weight: 600;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.b05-structure__optgroup .is-option-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b05-structure__optgroup > .b05-structure__grid.is-locked {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@@ -194,62 +194,17 @@
|
||||
|
||||
/* ─── 선택된 비정규 측점 값 열 오버레이 (규칙 열과 같은 12행) ──────────────
|
||||
세로 점선·구조물 태그 없이, 선택 시에만 값 열을 테이블 위에 겹쳐 보여준다. */
|
||||
/* ── 구조물 우클릭 메뉴 (그래프 위 — 배관 추가 · 구조물 삭제) ─────────────── */
|
||||
/* ── 구조물 우클릭 메뉴 (그래프 위 — 배관 추가 · 구조물 삭제) · 모양은 공용 `.ui-ctx-menu` ── */
|
||||
|
||||
.b05-profile-chart__context-menu {
|
||||
position: absolute;
|
||||
z-index: 7;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 120px;
|
||||
padding: var(--spacing-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: 0 4px 16px rgb(0 0 0 / 25%);
|
||||
}
|
||||
|
||||
.b05-profile-chart__context-menu[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b05-profile-chart__context-menu-item {
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: 0;
|
||||
border-radius: var(--radius-cards);
|
||||
background: transparent;
|
||||
color: var(--color-text-body);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b05-profile-chart__context-menu-item:hover {
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
|
||||
/* 구조물군 하위 메뉴(아코디언, 2026-08-18 메뉴 일원화) — 머리글 굵게, 종류는 들여쓰기.
|
||||
종류가 많은 군이 펼쳐지면 메뉴가 화면을 넘을 수 있어 최대 높이에서 안쪽 스크롤. */
|
||||
/* 구조물군 하위 메뉴(아코디언) — 종류가 많은 군이 펼쳐지면 최대 높이에서 안쪽 스크롤. */
|
||||
.b05-profile-chart__context-menu,
|
||||
.b05-drainage__context-menu {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.b05-profile-chart__context-menu-item.is-group,
|
||||
.b05-drainage__context-menu-item.is-group {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.b05-profile-chart__context-menu-sub,
|
||||
.b05-drainage__context-menu-sub {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.b05-profile-chart__context-menu-item.is-sub,
|
||||
.b05-drainage__context-menu-item.is-sub {
|
||||
padding-left: var(--spacing-24);
|
||||
}
|
||||
|
||||
.b05-profile-table__irregular-col {
|
||||
|
||||
@@ -75,7 +75,7 @@ export function mountViewerStructureMenu(deps: ViewerMenuDeps): void {
|
||||
const model = deps.modelPointAt(event.clientX, event.clientY);
|
||||
if (!model) return;
|
||||
const hit = projectToRoute(deps.routePoints(), model.x, model.y);
|
||||
// 노선에서 먼 자리는 브라우저 기본 메뉴를 그대로 둔다 — 배치 대상이 아니다.
|
||||
// 노선에서 먼 자리는 아무 메뉴도 안 뜸 — 배치 대상이 아님(브라우저 메뉴는 공용 진입이 막음).
|
||||
if (!hit || hit.distance > ROUTE_PICK_DISTANCE_M) return;
|
||||
event.preventDefault();
|
||||
const chainage = Number(hit.chainage.toFixed(2));
|
||||
|
||||
@@ -31,7 +31,11 @@ import {
|
||||
import { readPendingPipes, writePendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||
import { mergeFacilityOptions } from "../B05_Profile/B05_Profile_UI_Drainage_Facility_Merge";
|
||||
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { readSummaryItem } from "../common_util/common_util_summary_item";
|
||||
import {
|
||||
linkFields,
|
||||
readGroupItems,
|
||||
readSummaryItem,
|
||||
} from "../common_util/common_util_summary_item";
|
||||
import { readStructurePick } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
|
||||
import {
|
||||
NO_SELECTION,
|
||||
@@ -180,6 +184,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
source: "user",
|
||||
options: attributes.options ?? {},
|
||||
summary_item: attributes.summary_item ?? null,
|
||||
group_items: attributes.group_items ?? null,
|
||||
});
|
||||
pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m);
|
||||
currentChainageM = at;
|
||||
@@ -197,13 +202,17 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
// (2026-08-29 사용자 보고: 높이만 바꿨는데 이동 안내가 떴다).
|
||||
if (Math.abs(fromChainageM - toChainageM) > PIPE_MATCH_M)
|
||||
movePipeTo(fromChainageM, toChainageM);
|
||||
if (attributes.summary_item !== undefined) {
|
||||
if (attributes.summary_item !== undefined || attributes.group_items !== undefined) {
|
||||
// 옮긴 뒤 자리(toChainageM)에서 찾는다 — 안 옮겼으면 from 과 0.51m 안이라 같은 관이다.
|
||||
const mine = pipeFacilities.find(
|
||||
(entry) => Math.abs(entry.chainage_m - toChainageM) < PIPE_MATCH_M,
|
||||
);
|
||||
if (mine && (mine.summary_item ?? null)?.key !== attributes.summary_item?.key) {
|
||||
const before = mine ? JSON.stringify(linkFields(mine)) : "";
|
||||
if (mine && attributes.summary_item !== undefined)
|
||||
mine.summary_item = attributes.summary_item;
|
||||
if (mine && attributes.group_items !== undefined)
|
||||
mine.group_items = attributes.group_items;
|
||||
if (mine && JSON.stringify(linkFields(mine)) !== before) {
|
||||
persistPipes();
|
||||
section.setPipeFacilities(pipeFacilities);
|
||||
deps.onStructuresChanged?.();
|
||||
@@ -294,6 +303,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
end_m: pipe.end_m,
|
||||
source: pipe.source,
|
||||
summary_item: readSummaryItem(pipe.summary_item),
|
||||
group_items: readGroupItems(pipe.group_items),
|
||||
// 폼 기본값 = 조정창이 쓰던 값(횡단 스펙). 저장된 옵션이 있으면 그쪽이 이긴다.
|
||||
options: withSpecDefaults(
|
||||
pipe.options,
|
||||
@@ -374,7 +384,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
...(pipe.start_m !== undefined ? { start_m: pipe.start_m } : {}),
|
||||
...(pipe.end_m !== undefined ? { end_m: pipe.end_m } : {}),
|
||||
...(pipe.options ? { options: pipe.options } : {}),
|
||||
...(pipe.summary_item ? { summary_item: pipe.summary_item } : {}),
|
||||
...linkFields(pipe),
|
||||
})),
|
||||
);
|
||||
deps.onPipesWritten?.();
|
||||
|
||||
@@ -6,6 +6,10 @@ DWG 가 필요하면 LibreDWG 의 `dxf2dwg` 를 **별도 실행 파일로** 불
|
||||
|
||||
그림(Image)은 DXF 에 그대로 담을 수 없어(외부 파일 참조 방식) 내보내지 않는다. 대신 몇 개를
|
||||
건너뛰었는지 세어 화면이 알릴 수 있게 돌려준다.
|
||||
|
||||
표(Table · B07 수량표 · M02 41장 면적산출 표)는 선 + 글로 풀어 쓴다. 짝(CLAUDE.md 5장 ①):
|
||||
`table_parts` ↔ openwebcad `src/helpers/table-geometry.ts`(`tableBorders` · `cellRect` ·
|
||||
`cellTextAnchor`) — 거울 시험 `resources/tester/test_b07_frame_export_table.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -31,6 +35,14 @@ logger = logging.getLogger(__name__)
|
||||
_DXF_VERSION = "R2018"
|
||||
_DWG_SOURCE_VERSION = "R2004"
|
||||
_DEFAULT_TEXT_MM = 3.0
|
||||
#: 표 기본 글자 크기 · 여백 — openwebcad `DEFAULT_TABLE_STYLE` 과 같게
|
||||
_TABLE_FONT = 2.2
|
||||
_TABLE_PADDING = 1.0
|
||||
_ALIGN = {
|
||||
"left": TextEntityAlignment.MIDDLE_LEFT,
|
||||
"right": TextEntityAlignment.MIDDLE_RIGHT,
|
||||
"center": TextEntityAlignment.MIDDLE_CENTER,
|
||||
}
|
||||
|
||||
|
||||
def _xy(point: Any) -> tuple[float, float] | None:
|
||||
@@ -47,6 +59,96 @@ def _layer_name(raw: Any) -> str:
|
||||
return name[:255] or "0"
|
||||
|
||||
|
||||
def _span(value: Any) -> int:
|
||||
"""병합 칸 수 — TS `Math.max(1, Math.round(v ?? 1))` 와 같게(반올림은 .5 올림)."""
|
||||
number = float(value) if isinstance(value, (int, float)) else 1.0
|
||||
return max(1, math.floor(number + 0.5))
|
||||
|
||||
|
||||
def _owners(cells: list, rows: int, columns: int) -> list[list[str | None]]:
|
||||
owners: list[list[str | None]] = [[None] * columns for _ in range(rows)]
|
||||
for row in range(rows):
|
||||
for column in range(columns):
|
||||
cell = cells[row][column] if row < len(cells) and column < len(cells[row]) else None
|
||||
if not isinstance(cell, dict):
|
||||
continue
|
||||
for r in range(row, min(rows, row + _span(cell.get("rowSpan")))):
|
||||
for c in range(column, min(columns, column + _span(cell.get("colSpan")))):
|
||||
owners[r][c] = f"{row}:{column}"
|
||||
return owners
|
||||
|
||||
|
||||
def table_parts(shape: dict[str, Any]) -> tuple[list[tuple], list[dict[str, Any]]]:
|
||||
"""표 → (경계선 [(x1, y1, x2, y2)], 글 [{text, x, y, align, height}]) — 원점 = 왼쪽 위 · 아래로.
|
||||
|
||||
바깥 테두리는 늘 · 안쪽 선은 양옆(위아래)이 같은 병합 칸이면 끊음 · 글은 칸 가운데 높이 ·
|
||||
정렬에 따라 여백만큼 안쪽."""
|
||||
origin = _xy(shape.get("origin")) or (0.0, 0.0)
|
||||
widths = [float(w) for w in shape.get("columnWidths") or []]
|
||||
heights = [float(h) for h in shape.get("rowHeights") or []]
|
||||
cells = shape.get("cells") or []
|
||||
style = shape.get("style") or {}
|
||||
rows, columns = len(heights), len(widths)
|
||||
if not rows or not columns:
|
||||
return [], []
|
||||
xs, ys = [origin[0]], [origin[1]]
|
||||
for width in widths:
|
||||
xs.append(xs[-1] + width)
|
||||
for height in heights:
|
||||
ys.append(ys[-1] - height)
|
||||
owners = _owners(cells, rows, columns)
|
||||
lines = [
|
||||
(xs[0], ys[0], xs[columns], ys[0]),
|
||||
(xs[0], ys[rows], xs[columns], ys[rows]),
|
||||
(xs[0], ys[0], xs[0], ys[rows]),
|
||||
(xs[columns], ys[0], xs[columns], ys[rows]),
|
||||
]
|
||||
for column in range(1, columns):
|
||||
for row in range(rows):
|
||||
left, right = owners[row][column - 1], owners[row][column]
|
||||
if left is None or left != right:
|
||||
lines.append((xs[column], ys[row], xs[column], ys[row + 1]))
|
||||
for row in range(1, rows):
|
||||
for column in range(columns):
|
||||
above, below = owners[row - 1][column], owners[row][column]
|
||||
if above is None or above != below:
|
||||
lines.append((xs[column], ys[row], xs[column + 1], ys[row]))
|
||||
raw = style.get("padding")
|
||||
padding = float(raw) if isinstance(raw, (int, float)) else _TABLE_PADDING
|
||||
texts = []
|
||||
for row in range(rows):
|
||||
for column in range(columns):
|
||||
cell = cells[row][column] if row < len(cells) and column < len(cells[row]) else None
|
||||
if not isinstance(cell, dict) or not cell.get("text"):
|
||||
continue
|
||||
right = xs[min(columns, column + _span(cell.get("colSpan")))]
|
||||
bottom = ys[min(rows, row + _span(cell.get("rowSpan")))]
|
||||
align = cell.get("align") if cell.get("align") in _ALIGN else "center"
|
||||
x = {"left": xs[column] + padding, "right": right - padding}.get(
|
||||
align, (xs[column] + right) / 2
|
||||
)
|
||||
height = cell.get("fontSize") or style.get("fontSize") or _TABLE_FONT
|
||||
texts.append(
|
||||
{
|
||||
"text": str(cell["text"]),
|
||||
"x": x,
|
||||
"y": (ys[row] + bottom) / 2,
|
||||
"align": align,
|
||||
"height": float(height),
|
||||
}
|
||||
)
|
||||
return lines, texts
|
||||
|
||||
|
||||
def _add_table(space: Any, shape: dict[str, Any], attribs: dict[str, Any]) -> None:
|
||||
lines, texts = table_parts(shape)
|
||||
for x1, y1, x2, y2 in lines:
|
||||
space.add_line((x1, y1), (x2, y2), dxfattribs=attribs)
|
||||
for one in texts:
|
||||
text = space.add_text(one["text"], dxfattribs={**attribs, "height": one["height"]})
|
||||
text.set_placement((one["x"], one["y"]), align=_ALIGN[one["align"]])
|
||||
|
||||
|
||||
def _add_entity(
|
||||
space: Any, entity: dict[str, Any], layers: set[str], skipped: dict[str, int]
|
||||
) -> None:
|
||||
@@ -65,6 +167,9 @@ def _add_entity(
|
||||
# 그림은 DXF 가 외부 파일을 가리키는 방식이라 그대로 옮기지 못한다.
|
||||
skipped["Image"] = skipped.get("Image", 0) + 1
|
||||
return
|
||||
if kind == "Table": # 표 = 선 + 글(칸 정렬 반영)
|
||||
_add_table(space, shape, attribs)
|
||||
return
|
||||
|
||||
start, end = _xy(shape.get("startPoint")), _xy(shape.get("endPoint"))
|
||||
if start and end:
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
revCloudToolStateMachine,
|
||||
updateDimensions,
|
||||
} from '../tools/annotate/leader-table-tools';
|
||||
import { placeTableToolStateMachine } from '../tools/annotate/place-table-tool';
|
||||
import {
|
||||
tableColumnToolStateMachine,
|
||||
tableEditToolStateMachine,
|
||||
@@ -324,6 +325,14 @@ export const TABLE_COMMANDS: CadCommand[] = [
|
||||
tool: Tool.TABLEUNMERGE,
|
||||
machine: tableUnmergeToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'PLACETABLE',
|
||||
label: '표 놓기',
|
||||
glyph: '▦',
|
||||
hint: '부모 화면이 보낸 표(면적산출 범위)를 누른 자리에 놓는다',
|
||||
tool: Tool.PLACETABLE,
|
||||
machine: placeTableToolStateMachine,
|
||||
},
|
||||
];
|
||||
|
||||
export const MARKUP_COMMANDS: CadCommand[] = [
|
||||
|
||||
@@ -53,6 +53,7 @@ export const CommandLine: FC = () => {
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.nativeEvent.isComposing) return; // 한글 조합 중 키는 IME 몫
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
getInputController().handleEscapeKey();
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface TableStyleOptions {
|
||||
textColor: string;
|
||||
/** 문자를 칸 좌우 끝에서 띄우는 거리 */
|
||||
padding: number;
|
||||
/** 면적산출에서 보낸 표의 연결 id(M02 41장) — 옮기기 · 복사(clone)에도 남아 [업데이트]가 이 표를 찾는다 */
|
||||
연결?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_TABLE_STYLE: TableStyleOptions = {
|
||||
@@ -97,6 +99,11 @@ export class TableEntity implements Entity {
|
||||
}
|
||||
|
||||
// ── 쓰기 (표 편집 명령이 쓴다)
|
||||
/** 칸 내용만 통째로 갈아 끼운다 — 자리 · 열 너비 · 행 높이는 그대로(넘치는 칸은 버리고 모자라면 빈 칸) */
|
||||
public replaceCells(cells: TableCells): void {
|
||||
this.cells = normalizeCells(cells, this.rowHeights.length, this.columnWidths.length);
|
||||
}
|
||||
|
||||
public setCell(row: number, column: number, patch: Partial<TableCell>): void {
|
||||
const current = this.cells[row]?.[column];
|
||||
if (!current) return; // 병합에 먹힌 자리는 직접 고치지 않는다
|
||||
@@ -344,9 +351,11 @@ export class TableEntity implements Entity {
|
||||
data.cells,
|
||||
data.style
|
||||
);
|
||||
table.id = jsonEntity.id;
|
||||
table.lineColor = jsonEntity.lineColor;
|
||||
table.lineWidth = jsonEntity.lineWidth;
|
||||
// 서버가 만든 표(면적산출 → 도면 · M02 41장)는 id · 선 색 · 굵기가 없을 수 있다 — 비면 새 표의
|
||||
// 기본값(선 색 = 자동색 `#fff` · 바탕 따라 흰/검정)으로 채운다. 놓기 · [업데이트] · 다시 열기가 모두 여기를 지난다.
|
||||
table.id = jsonEntity.id || table.id;
|
||||
table.lineColor = jsonEntity.lineColor || table.lineColor;
|
||||
table.lineWidth = jsonEntity.lineWidth ?? table.lineWidth;
|
||||
table.lineDash = jsonEntity.lineDash;
|
||||
return table;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ export function registerCadDebugHook(): void {
|
||||
opacity: entity.opacity ?? null,
|
||||
hidden: isEntityHidden(entity),
|
||||
color: entity.lineColor,
|
||||
// 글자 객체의 내용 — 한글 입력(IME) 검증이 읽는다
|
||||
label: (entity as { getLabel?: () => string }).getLabel?.() ?? null,
|
||||
box: [
|
||||
Math.round(box.xmin),
|
||||
Math.round(box.ymin),
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
*
|
||||
* 표는 셀 격자와 셀 문자만 책임진다. 표 위에 얹는 그림(종단표의 종단곡선 표시선 같은 것)은
|
||||
* 별도 엔티티로 겹쳐 그린다 — 그래야 나중에 종단표도 이 표 객체로 옮길 수 있다.
|
||||
*
|
||||
* 짝(CLAUDE.md 5장 ①): 서버 DXF 내보내기 `B07_DesignDetail_Engine_Frame_Export.table_parts`
|
||||
* (`tableBorders` · `cellRect` · `cellTextAnchor`) — 거울 시험 `test_b07_frame_export_table.py`.
|
||||
*/
|
||||
|
||||
export interface TableCell {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ImeSink, isImeKey } from './ime-sink.ts';
|
||||
import { InputController } from './input-controller.ts';
|
||||
|
||||
describe('한글 IME 입력', () => {
|
||||
it('조합 사건으로 「가나다」 가 들어간다', () => {
|
||||
let text = '';
|
||||
const sink = new ImeSink((data) => {
|
||||
text += data;
|
||||
});
|
||||
for (const [steps, done] of [
|
||||
[['ㄱ', '가'], '가'],
|
||||
[['ㄴ', '나'], '나'],
|
||||
[['ㄷ', '다'], '다'],
|
||||
] as const) {
|
||||
for (const step of steps) sink.handleCompositionUpdate(step);
|
||||
expect(sink.composing).toBe(steps[steps.length - 1]);
|
||||
sink.handleCompositionEnd(done);
|
||||
}
|
||||
expect(text).toBe('가나다');
|
||||
expect(sink.composing).toBe('');
|
||||
// 조합 중 input 은 compositionend 몫 — 겹쳐 넣지 않음 · 조합 뒤 띄어쓰기는 받음
|
||||
sink.handleInput({ isComposing: true, data: '다' });
|
||||
sink.handleInput({ isComposing: false, data: ' ' });
|
||||
expect(text).toBe('가나다 ');
|
||||
});
|
||||
|
||||
it('조합 중 키는 단축키로 먹지 않는다', () => {
|
||||
expect(isImeKey({ isComposing: true, keyCode: 0, key: 'r' } as KeyboardEvent)).toBe(true);
|
||||
expect(isImeKey({ isComposing: false, keyCode: 229, key: 'r' } as KeyboardEvent)).toBe(true);
|
||||
expect(isImeKey({ isComposing: false, keyCode: 0, key: 'Process' } as KeyboardEvent)).toBe(
|
||||
true
|
||||
);
|
||||
expect(isImeKey({ isComposing: false, keyCode: 76, key: 'l' } as KeyboardEvent)).toBe(false);
|
||||
|
||||
const controller = new InputController();
|
||||
const preventDefault = vi.fn();
|
||||
controller.handleKeyStroke({
|
||||
key: 'Process',
|
||||
keyCode: 229,
|
||||
isComposing: true,
|
||||
target: null,
|
||||
preventDefault,
|
||||
stopPropagation: vi.fn(),
|
||||
} as unknown as KeyboardEvent);
|
||||
expect(preventDefault).not.toHaveBeenCalled();
|
||||
controller.ime.handleCompositionEnd('가나다');
|
||||
expect((controller as unknown as { text: string }).text).toBe('가나다');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 한글 IME 받개 — 캔버스에는 글 칸이 없어 조합(compositionstart/update/end)이 생기지 않는다.
|
||||
* 문자를 기다리는 단계(TEXT · MTEXT · 표 칸 · 지시선 …)에서만 숨은 input 에 초점을 줘 조합을 받고,
|
||||
* 끝난 글자를 넘긴다. 명령 단계에서는 초점을 풀어 한글 자판이어도 L · REC 같은 영문 명령이 들어간다.
|
||||
*/
|
||||
import { getActiveToolActor } from '../state.ts';
|
||||
|
||||
/** 조합 중 키 — 단축키·글자 처리가 먹지 않고 IME 에 넘긴다 */
|
||||
export const isImeKey = (evt: KeyboardEvent): boolean =>
|
||||
evt.isComposing || evt.keyCode === 229 || evt.key === 'Process';
|
||||
|
||||
/** 활성 도구의 지금 단계가 TEXT_INPUT 을 받는가 */
|
||||
export function activeToolAcceptsText(): boolean {
|
||||
const snapshot = getActiveToolActor()?.getSnapshot();
|
||||
return !!snapshot?.machine?.states?.[snapshot?.value]?.config?.on?.TEXT_INPUT;
|
||||
}
|
||||
|
||||
export class ImeSink {
|
||||
/** 조합 중인 글자 — 커서 옆 입력 칸에 미리 보인다 */
|
||||
public composing = '';
|
||||
public element: HTMLInputElement | null = null;
|
||||
|
||||
constructor(private readonly onCommit: (text: string) => void) {}
|
||||
|
||||
public attach() {
|
||||
const el = document.createElement('input');
|
||||
el.className = 'cad-ime-sink';
|
||||
el.tabIndex = -1;
|
||||
el.autocomplete = 'off';
|
||||
el.setAttribute('aria-hidden', 'true');
|
||||
el.style.cssText =
|
||||
'position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;border:0;padding:0;pointer-events:none';
|
||||
el.addEventListener('compositionupdate', (evt) => this.handleCompositionUpdate(evt.data));
|
||||
el.addEventListener('compositionend', (evt) => this.handleCompositionEnd(evt.data));
|
||||
el.addEventListener('input', (evt) => this.handleInput(evt as InputEvent));
|
||||
document.body.appendChild(el);
|
||||
this.element = el;
|
||||
}
|
||||
|
||||
public handleCompositionUpdate(data: string) {
|
||||
this.composing = data ?? '';
|
||||
}
|
||||
|
||||
public handleCompositionEnd(data: string) {
|
||||
this.composing = '';
|
||||
if (this.element) this.element.value = '';
|
||||
if (data) this.onCommit(data);
|
||||
}
|
||||
|
||||
/** 조합 직후 IME 가 바로 넣는 글자(띄어쓰기 · 숫자) — 조합 중 input 은 compositionend 가 맡는다 */
|
||||
public handleInput(evt: Pick<InputEvent, 'isComposing' | 'data'>) {
|
||||
if (evt.isComposing || !evt.data) return;
|
||||
if (this.element) this.element.value = '';
|
||||
this.onCommit(evt.data);
|
||||
}
|
||||
|
||||
/** 문자를 기다리면 받개에 초점 · 아니면 놓음. 다른 입력 칸(명령줄 · 대화상자)의 초점은 뺏지 않는다 */
|
||||
public sync(wantsText: boolean) {
|
||||
const el = this.element;
|
||||
if (!el) return;
|
||||
const active = document.activeElement;
|
||||
if (wantsText && !active?.closest('input, textarea, select, [contenteditable]')) {
|
||||
el.focus({ preventScroll: true });
|
||||
} else if (!wantsText && active === el && !this.composing) {
|
||||
el.blur();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,21 +65,15 @@ import {
|
||||
type RelativePointInputEvent,
|
||||
type TextInputEvent,
|
||||
} from '../tools/tool.types.ts';
|
||||
|
||||
const NUMBER_REGEXP = /^[0-9]+([.][0-9]+)?$/;
|
||||
/** 부호 있는 실수 한 개 (좌표는 음수가 될 수 있다) */
|
||||
const SIGNED = '(-?[0-9]+(?:[.][0-9]+)?)';
|
||||
const ABSOLUTE_POINT_REGEXP = new RegExp(`^${SIGNED}[ ]*,[ ]*${SIGNED}$`);
|
||||
const RELATIVE_POINT_REGEXP = new RegExp(`^@${SIGNED}[ ]*,[ ]*${SIGNED}$`);
|
||||
/** 극좌표 — 거리<각도(도). `@`가 붙으면 직전 점 기준 */
|
||||
const ABSOLUTE_POLAR_REGEXP = new RegExp(`^${SIGNED}[ ]*<[ ]*${SIGNED}$`);
|
||||
const RELATIVE_POLAR_REGEXP = new RegExp(`^@${SIGNED}[ ]*<[ ]*${SIGNED}$`);
|
||||
|
||||
/** 거리·각도(도)를 x·y 변위로 바꾼다 */
|
||||
function polarToPoint(distance: number, degrees: number): Point {
|
||||
const radians = (degrees * Math.PI) / 180;
|
||||
return new Point(distance * Math.cos(radians), distance * Math.sin(radians));
|
||||
}
|
||||
import { activeToolAcceptsText, ImeSink, isImeKey } from './ime-sink.ts';
|
||||
import {
|
||||
ABSOLUTE_POINT_REGEXP,
|
||||
ABSOLUTE_POLAR_REGEXP,
|
||||
NUMBER_REGEXP,
|
||||
polarToPoint,
|
||||
RELATIVE_POINT_REGEXP,
|
||||
RELATIVE_POLAR_REGEXP,
|
||||
} from './input-patterns.ts';
|
||||
|
||||
/** 이만큼 넘게 끌면 클릭이 아니라 드래그로 본다 (화면 픽셀) */
|
||||
const DRAG_SELECT_MIN_PX = 4;
|
||||
@@ -88,11 +82,15 @@ export class InputController {
|
||||
private text = '';
|
||||
/** 왼쪽 버튼을 누른 화면 좌표 — 놓을 때 끌었는지 판단한다 */
|
||||
private leftPressLocation: Point | null = null;
|
||||
public readonly ime = new ImeSink((data) => {
|
||||
this.text += data;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
if (typeof process === 'object' && process?.env?.NODE_ENV === 'test') {
|
||||
return; // used during unit testing
|
||||
}
|
||||
this.ime.attach();
|
||||
// Listen for keystrokes
|
||||
document.addEventListener('keydown', (evt) => {
|
||||
this.handleKeyStroke(evt);
|
||||
@@ -141,10 +139,11 @@ export class InputController {
|
||||
CANVAS_INPUT_FIELD_HEIGHT,
|
||||
CANVAS_INPUT_FIELD_BACKGROUND_COLOR
|
||||
);
|
||||
// Draw text in input field
|
||||
if (this.text) {
|
||||
this.ime.sync(activeToolAcceptsText());
|
||||
// Draw text in input field (조합 중인 한글도 함께)
|
||||
if (this.text || this.ime.composing) {
|
||||
drawController.drawTextScreen(
|
||||
this.text,
|
||||
this.text + this.ime.composing,
|
||||
new Point(
|
||||
screenMouseLocation.x + CANVAS_INPUT_FIELD_MOUSE_OFFSET + 2,
|
||||
screenMouseLocation.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET - CANVAS_INPUT_FIELD_HEIGHT - 2
|
||||
@@ -217,6 +216,7 @@ export class InputController {
|
||||
return;
|
||||
}
|
||||
this.sendMouseClick(evt);
|
||||
this.ime.sync(activeToolAcceptsText()); // 클릭으로 문자 단계에 들면 바로 한글 받개로
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,9 +392,11 @@ export class InputController {
|
||||
}
|
||||
|
||||
public handleKeyStroke(evt: KeyboardEvent) {
|
||||
if ((evt.target as HTMLElement | null)?.closest('input, textarea, select')) {
|
||||
const target = evt.target as HTMLElement | null;
|
||||
if (target !== this.ime.element && target?.closest('input, textarea, select')) {
|
||||
return;
|
||||
}
|
||||
if (isImeKey(evt)) return; // 한글 조합 중 — 단축키가 먹지 않게 IME 에 넘김
|
||||
console.log(`key pressed: ${evt.key}`);
|
||||
if (evt.key === 'F12') {
|
||||
// F12 => open developer tools
|
||||
@@ -472,6 +474,7 @@ export class InputController {
|
||||
} else if (evt.key === 'Enter') {
|
||||
// User wants to submit the input or submit the action
|
||||
this.handleEnterKey();
|
||||
this.ime.sync(activeToolAcceptsText());
|
||||
} else if (evt.key === 'ArrowDown') {
|
||||
// Move the screen down
|
||||
getScreenCanvasDrawController().setScreenOffset(this.getScreenPanStep('down', evt.shiftKey));
|
||||
@@ -517,11 +520,7 @@ export class InputController {
|
||||
|
||||
public handleEnterKey() {
|
||||
// submit the text as input to the active tool and clear the input field
|
||||
const activeTool = getActiveToolActor();
|
||||
const activeToolSnapshot = activeTool?.getSnapshot();
|
||||
const activeToolState = activeToolSnapshot?.value;
|
||||
const activeToolCanHandleTextInput =
|
||||
!!activeToolSnapshot?.machine?.states?.[activeToolState]?.config?.on?.TEXT_INPUT;
|
||||
const activeToolCanHandleTextInput = activeToolAcceptsText();
|
||||
|
||||
if (this.text === '') {
|
||||
console.log('ENTER: ', {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/** 명령 입력 해석 — 숫자 · 좌표 · 극좌표 꼴 */
|
||||
import { Point } from '@flatten-js/core';
|
||||
|
||||
export const NUMBER_REGEXP = /^[0-9]+([.][0-9]+)?$/;
|
||||
/** 부호 있는 실수 한 개 (좌표는 음수가 될 수 있다) */
|
||||
const SIGNED = '(-?[0-9]+(?:[.][0-9]+)?)';
|
||||
export const ABSOLUTE_POINT_REGEXP = new RegExp(`^${SIGNED}[ ]*,[ ]*${SIGNED}$`);
|
||||
export const RELATIVE_POINT_REGEXP = new RegExp(`^@${SIGNED}[ ]*,[ ]*${SIGNED}$`);
|
||||
/** 극좌표 — 거리<각도(도). `@`가 붙으면 직전 점 기준 */
|
||||
export const ABSOLUTE_POLAR_REGEXP = new RegExp(`^${SIGNED}[ ]*<[ ]*${SIGNED}$`);
|
||||
export const RELATIVE_POLAR_REGEXP = new RegExp(`^@${SIGNED}[ ]*<[ ]*${SIGNED}$`);
|
||||
|
||||
/** 거리·각도(도)를 x·y 변위로 바꾼다 */
|
||||
export function polarToPoint(distance: number, degrees: number): Point {
|
||||
const radians = (degrees * Math.PI) / 180;
|
||||
return new Point(distance * Math.cos(radians), distance * Math.sin(radians));
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import { toast } from 'react-toastify';
|
||||
import { runCommandInput } from '../commands/run-command.ts';
|
||||
import { setRecoveryScope } from '../helpers/autosave.ts';
|
||||
import { registerBoxResizeDrag } from './box-resize-drag.ts';
|
||||
import { handleTableMessage } from './aislo-table-bridge.ts';
|
||||
|
||||
export const AISLO_DRAWING_LOAD_MESSAGE = 'aislo:b08:load-drawing';
|
||||
export const AISLO_DRAWING_READY_MESSAGE = 'aislo:b08:drawing-ready';
|
||||
@@ -190,6 +191,7 @@ function applyFramePreview(): void {
|
||||
export function registerAisloDrawingBridge() {
|
||||
window.addEventListener('message', async (event: MessageEvent<unknown>) => {
|
||||
if (event.origin !== window.location.origin || event.source !== window.parent) return;
|
||||
if (await handleTableMessage(event.data, notifyParent)) return; // 표 놓기 · 갈기(M02 41장)
|
||||
const message = event.data as Partial<DrawingSaveRequestMessage>;
|
||||
if (message.type === AISLO_DRAWING_SAVE_REQUEST_MESSAGE) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { TableEntity } from '../entities/TableEntity.ts';
|
||||
import { getEntities, isDrawingDirty, setEntities, setLayers } from '../state.ts';
|
||||
import { getPendingTable, setPendingTable, tableAt } from '../tools/annotate/place-table-tool.ts';
|
||||
import {
|
||||
AISLO_PLACE_TABLE_MESSAGE,
|
||||
AISLO_TABLE_UPDATED_MESSAGE,
|
||||
AISLO_UPDATE_TABLE_MESSAGE,
|
||||
handleTableMessage,
|
||||
updateLinkedTables,
|
||||
} from './aislo-table-bridge.ts';
|
||||
|
||||
const run = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../commands/run-command.ts', () => ({ runCommandInput: run }));
|
||||
|
||||
const layer = { id: 'layer-1', isLocked: false, isVisible: true, name: '작업' };
|
||||
|
||||
function table(link?: string): TableEntity {
|
||||
return new TableEntity(
|
||||
layer.id,
|
||||
new Point(10, 20),
|
||||
[30, 15],
|
||||
[5, 5],
|
||||
[
|
||||
[{ text: '항목', colSpan: 2, key: 'S1!A1' }, null],
|
||||
[
|
||||
{ text: '면적', key: 'S1!A2' },
|
||||
{ text: '12.5', align: 'right', key: 'S1!B2' },
|
||||
],
|
||||
],
|
||||
{ fontSize: 2.5, ...(link ? { 연결: link } : {}) }
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setLayers([layer]);
|
||||
setEntities([]);
|
||||
setPendingTable(null);
|
||||
run.mockClear();
|
||||
});
|
||||
|
||||
describe('표 놓기(PLACETABLE)', () => {
|
||||
it('미리보기 · 놓을 표는 커서 자리(왼쪽 위)에 같은 칸 · 크기 · 연결로', () => {
|
||||
const placed = tableAt(table('T1'), new Point(100, 200), layer.id);
|
||||
expect([placed.getOrigin().x, placed.getOrigin().y]).toEqual([100, 200]);
|
||||
expect(placed.getColumnWidths()).toEqual([30, 15]);
|
||||
expect(placed.getCell(1, 1)?.text).toBe('12.5');
|
||||
expect(placed.getStyle().연결).toBe('T1');
|
||||
});
|
||||
|
||||
it('place-table 메시지 = 연결을 단 표를 맡기고 PLACETABLE 을 켬', async () => {
|
||||
const notify = vi.fn();
|
||||
const handled = await handleTableMessage(
|
||||
{
|
||||
type: AISLO_PLACE_TABLE_MESSAGE,
|
||||
entity: { ...(await table().toJson()), lineColor: '#123456' },
|
||||
link: 'T7',
|
||||
},
|
||||
notify
|
||||
);
|
||||
expect(handled).toBe(true);
|
||||
expect(run).toHaveBeenCalledWith('PLACETABLE');
|
||||
expect(getPendingTable()?.getStyle().연결).toBe('T7');
|
||||
expect(getPendingTable()?.getCell(0, 0)?.colSpan).toBe(2);
|
||||
expect(getPendingTable()?.lineColor).toBe('#123456');
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('옮기기 · 복사(clone) · JSON 왕복에도 style.연결이 남음', async () => {
|
||||
const linked = table('T1');
|
||||
expect(linked.clone().getStyle().연결).toBe('T1');
|
||||
const json = await linked.toJson();
|
||||
if (!json) throw new Error('표 JSON 없음');
|
||||
const back = await TableEntity.fromJson(json);
|
||||
expect(back.getStyle().연결).toBe('T1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('선 색 없는 표(서버 표 변환 답 · 43-6)', () => {
|
||||
it('id · 선 색 · 굵기가 없으면 기본값(자동색 #fff · 1 · 새 id)으로 채워 놓고 저장 JSON 에도 남음', async () => {
|
||||
const json = await table('T8').toJson();
|
||||
if (!json) throw new Error('표 JSON 없음');
|
||||
const { id: _id, lineColor: _color, lineWidth: _width, layerId: _layer, ...bare } = json;
|
||||
await handleTableMessage(
|
||||
{ type: AISLO_PLACE_TABLE_MESSAGE, entity: bare as typeof json, link: 'T8' },
|
||||
vi.fn()
|
||||
);
|
||||
const pending = getPendingTable();
|
||||
expect(pending?.lineColor).toBe('#fff');
|
||||
expect(pending?.lineWidth).toBe(1);
|
||||
const placed = tableAt(pending as TableEntity, new Point(0, 0), layer.id);
|
||||
placed.lineColor = pending?.lineColor ?? '';
|
||||
const saved = await placed.toJson();
|
||||
expect(saved?.lineColor).toBe('#fff');
|
||||
const back = await TableEntity.fromJson(bare as typeof json);
|
||||
expect(back.id).toBeTruthy();
|
||||
expect(back.lineColor).toBe('#fff');
|
||||
});
|
||||
});
|
||||
|
||||
describe('표 칸 갈기(update-table)', () => {
|
||||
it('연결이 같은 표의 칸만 갈고 자리 · 크기는 그대로 · 되돌리기 한 걸음', () => {
|
||||
const linked = table('T1');
|
||||
const other = table('T2');
|
||||
setEntities([linked, other]);
|
||||
const count = updateLinkedTables('T1', [
|
||||
[{ text: '항목(새)', colSpan: 2 }, null],
|
||||
[{ text: '면적' }, { text: '99.0', align: 'right' }],
|
||||
]);
|
||||
expect(count).toBe(1);
|
||||
expect(linked.getCell(1, 1)?.text).toBe('99.0');
|
||||
expect(linked.getCell(0, 1)).toBeNull(); // 병합 그대로
|
||||
expect(other.getCell(1, 1)?.text).toBe('12.5');
|
||||
expect([linked.getOrigin().x, linked.getColumnWidths()]).toEqual([10, [30, 15]]);
|
||||
expect(isDrawingDirty()).toBe(true);
|
||||
});
|
||||
|
||||
it('넘치는 칸은 버리고 모자란 칸은 빈 칸', () => {
|
||||
const linked = table('T1');
|
||||
setEntities([linked]);
|
||||
updateLinkedTables('T1', [[{ text: 'a' }, { text: 'b' }, { text: '넘침' }]]);
|
||||
expect(linked.getCells().map((row) => row.map((cell) => cell?.text ?? null))).toEqual([
|
||||
['a', 'b'],
|
||||
['', ''],
|
||||
]);
|
||||
});
|
||||
|
||||
it('update-table 메시지 = table-updated{link, count} · 없는 연결은 0 · 도면 그대로', async () => {
|
||||
setEntities([table('T1')]);
|
||||
const before = getEntities();
|
||||
const notify = vi.fn();
|
||||
await handleTableMessage({ type: AISLO_UPDATE_TABLE_MESSAGE, link: 'T9', cells: [] }, notify);
|
||||
expect(notify).toHaveBeenCalledWith(AISLO_TABLE_UPDATED_MESSAGE, { link: 'T9', count: 0 });
|
||||
expect(getEntities()).toBe(before);
|
||||
});
|
||||
|
||||
it('표 메시지가 아니면 false', async () => {
|
||||
expect(await handleTableMessage({ type: 'aislo:b08:save-request' }, vi.fn())).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 부모 화면 ↔ CAD 표 메시지(M02 41장 · 면적산출 범위 → 도면 표). 짝 = `ui_template/cad_host/cad_host.ts`.
|
||||
* place-table{entity, link} 표 JSON(TableEntity 꼴) → `style.연결 = link` 로 표 놓기 도구(PLACETABLE) 시작
|
||||
* update-table{link, cells} `style.연결 === link` 인 표의 칸만 갈아 끼움(자리 · 크기 그대로 · 되돌리기 한 걸음)
|
||||
* → 부모에 table-updated{link, count}
|
||||
* 놓기 · 갈기 모두 `setEntities(…, true)` 를 거쳐 drawing-changed 가 간다. 확정 · 보기 전용이면 안 함.
|
||||
*/
|
||||
import { toast } from 'react-toastify';
|
||||
import { runCommandInput } from '../commands/run-command.ts';
|
||||
import type { JsonEntity } from '../entities/Entity.ts';
|
||||
import { TableEntity } from '../entities/TableEntity.ts';
|
||||
import type { TableCells } from '../helpers/table-geometry.ts';
|
||||
import { getEntities, isDrawingReadOnly, isViewOnly, setEntities } from '../state.ts';
|
||||
import { setPendingTable } from '../tools/annotate/place-table-tool.ts';
|
||||
|
||||
export const AISLO_PLACE_TABLE_MESSAGE = 'aislo:b08:place-table';
|
||||
export const AISLO_UPDATE_TABLE_MESSAGE = 'aislo:b08:update-table';
|
||||
export const AISLO_TABLE_UPDATED_MESSAGE = 'aislo:b08:table-updated';
|
||||
|
||||
/** 두 메시지를 한 꼴로 받음 — place = entity · link · update = link · cells */
|
||||
interface TableMessage {
|
||||
type?: string;
|
||||
entity?: JsonEntity;
|
||||
link?: string;
|
||||
cells?: TableCells;
|
||||
}
|
||||
|
||||
/** 이 표가 그 연결의 것인가 */
|
||||
export const isLinkedTable = (entity: unknown, link: string): entity is TableEntity =>
|
||||
entity instanceof TableEntity && entity.getStyle().연결 === link;
|
||||
|
||||
/** 연결 표마다 칸을 갈아 끼움 — 바꾼 표 수(0 이면 도면을 건드리지 않음) */
|
||||
export function updateLinkedTables(link: string, cells: TableCells): number {
|
||||
const tables = getEntities().filter((entity) => isLinkedTable(entity, link));
|
||||
for (const table of tables) table.replaceCells(cells);
|
||||
if (tables.length) setEntities([...getEntities()], true);
|
||||
return tables.length;
|
||||
}
|
||||
|
||||
/** 표 JSON 을 놓기 도구에 맡기고 도구를 켬 */
|
||||
export async function startPlaceTable(entity: JsonEntity, link: string): Promise<void> {
|
||||
const table = await TableEntity.fromJson(entity as Parameters<typeof TableEntity.fromJson>[0]);
|
||||
const linked = new TableEntity(
|
||||
table.layerId,
|
||||
table.getOrigin(),
|
||||
table.getColumnWidths(),
|
||||
table.getRowHeights(),
|
||||
table.getCells(),
|
||||
{ ...table.getStyle(), 연결: link }
|
||||
);
|
||||
linked.lineColor = table.lineColor; // 보낸 선 색 · 굵기 그대로
|
||||
linked.lineWidth = table.lineWidth;
|
||||
setPendingTable(linked);
|
||||
runCommandInput('PLACETABLE');
|
||||
}
|
||||
|
||||
const blocked = (): boolean => {
|
||||
if (!isDrawingReadOnly() && !isViewOnly()) return false;
|
||||
toast.info('보기 전용 도면 — 표를 넣거나 고치려면 [수정]을 먼저 누르세요.');
|
||||
return true;
|
||||
};
|
||||
|
||||
/** 표 메시지면 처리하고 true — 아니면 false(다른 메시지 갈래로) */
|
||||
export async function handleTableMessage(
|
||||
data: unknown,
|
||||
notify: (type: string, payload: Record<string, unknown>) => void
|
||||
): Promise<boolean> {
|
||||
const message = data as TableMessage | null;
|
||||
if (!message || typeof message !== 'object') return false;
|
||||
if (message.type === AISLO_PLACE_TABLE_MESSAGE && message.entity && message.link) {
|
||||
if (!blocked()) await startPlaceTable(message.entity, message.link);
|
||||
return true;
|
||||
}
|
||||
if (message.type === AISLO_UPDATE_TABLE_MESSAGE && message.link && message.cells) {
|
||||
const count = blocked() ? 0 : updateLinkedTables(message.link, message.cells);
|
||||
notify(AISLO_TABLE_UPDATED_MESSAGE, { link: message.link, count });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -113,6 +113,7 @@ export enum Tool {
|
||||
MLEADERSTYLE = 'MLEADERSTYLE',
|
||||
MLEADERALIGN = 'MLEADERALIGN',
|
||||
TABLE = 'TABLE',
|
||||
PLACETABLE = 'PLACETABLE',
|
||||
TABLESTYLE = 'TABLESTYLE',
|
||||
TABLEEDIT = 'TABLEEDIT',
|
||||
TABLEROW = 'TABLEROW',
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 표 놓기(PLACETABLE · M02 41장) — 부모 화면이 보낸 표(면적산출 범위)를 커서에 붙여 따라오게
|
||||
* 그리고, 누르면 그 자리(표 왼쪽 위)에 놓는다. Esc = 그만 · 놓기는 되돌리기 한 걸음.
|
||||
* 놓을 표는 브리지(`integration/aislo-table-bridge.ts`)가 `setPendingTable` 로 맡긴 뒤 이 명령을 켠다.
|
||||
*/
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import { TableEntity } from '../../entities/TableEntity';
|
||||
import { addEntities, getActiveLayerId } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
|
||||
let pending: TableEntity | null = null;
|
||||
|
||||
/** 다음 PLACETABLE 이 놓을 표 — 놓으면 비워진다 */
|
||||
export function setPendingTable(table: TableEntity | null): void {
|
||||
pending = table;
|
||||
}
|
||||
|
||||
export function getPendingTable(): TableEntity | null {
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** `table` 과 같은 꼴(칸 · 크기 · style.연결)을 `at`(왼쪽 위)에 둔 새 표 */
|
||||
export function tableAt(table: TableEntity, at: Point, layerId: string): TableEntity {
|
||||
const placed = new TableEntity(
|
||||
layerId,
|
||||
at.clone(),
|
||||
table.getColumnWidths(),
|
||||
table.getRowHeights(),
|
||||
table.getCells().map((row) => row.map((cell) => (cell ? { ...cell } : null))),
|
||||
table.getStyle()
|
||||
);
|
||||
placed.lineColor = table.lineColor;
|
||||
placed.lineWidth = table.lineWidth;
|
||||
return placed;
|
||||
}
|
||||
|
||||
export const placeTableToolStateMachine = createSequenceTool({
|
||||
tool: Tool.PLACETABLE,
|
||||
steps: [{ kind: 'point', instructions: '표를 놓을 자리(왼쪽 위)를 누르십시오 · Esc 그만.' }],
|
||||
helpers: true,
|
||||
restart: false,
|
||||
preview: (input) => (pending ? [tableAt(pending, input.cursor, getActiveLayerId())] : []),
|
||||
commit: (input) => {
|
||||
if (!pending) return;
|
||||
addEntities([tableAt(pending, input.point(0), getActiveLayerId())], true);
|
||||
pending = null;
|
||||
},
|
||||
});
|
||||
@@ -1,571 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_EasyCalc_Api.ts
|
||||
* 쉬운 단가산출 Api — 서버 길 감싸기(목록 · 열기 + 자동값 · 시험 계산 뒤에서 · 새로 · 본뜨기 ·
|
||||
* 고치기 · 지우기 · 쓰는 곳) · 403 · 409 · 422 를 쉬운 말로 · 기계 기종 → 부를 기계경비 로직 · 넘길 값
|
||||
* 모양은 `docs/raw/M01_쉬운단가/설계.md` 3절 그대로 · 쓰기는 `/logic/*` 만(`/save` 안 씀)
|
||||
* 시험: `resources/tester/test_m01_easycalc_api.py`(서버 왕복 + 아래 「순수」 칸을 Node 로)
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import {
|
||||
ApiError,
|
||||
copyLogic,
|
||||
fetchAuto,
|
||||
fetchLogic,
|
||||
runCalc,
|
||||
type CalcAnswer,
|
||||
type LogicInput,
|
||||
type LogicRow,
|
||||
} from "../M01_MasterData_UI_Logic_Api";
|
||||
import { safeReason } from "../M01_MasterData_UI_Logic_Text";
|
||||
|
||||
/** 목록 한 줄 */
|
||||
export interface EasyListItem {
|
||||
key: string;
|
||||
name: string;
|
||||
/** 품셈 기본(읽기만) · false = 내가 만든 것 */
|
||||
isBase: boolean;
|
||||
/** 화면 글 「품셈 기본」 · 「내가 만든 것」 */
|
||||
owner: string;
|
||||
/** 결과단위 「원/㎡」 */
|
||||
resultUnit: string;
|
||||
/** 쉬운 길로 열 수 있나 — 돈 아닌 로직(작업량 등)은 false */
|
||||
canOpen: boolean;
|
||||
/** 못 여는 까닭 쉬운 말 · 열 수 있으면 null */
|
||||
whyNot: string | null;
|
||||
/** 어디 것인지 — 「산림품셈 13장 구조물」 · 내가 만든 것은 장만 */
|
||||
where: string;
|
||||
}
|
||||
|
||||
/** 열기 답 — `trial` = 설계 때 정하는 값의 견본(자동값) */
|
||||
export interface EasyOpened {
|
||||
row: LogicRow;
|
||||
version: string;
|
||||
isBase: boolean;
|
||||
trial: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 새로 · 본뜨기 · 고치기 답 */
|
||||
export interface EasySaved {
|
||||
key: string;
|
||||
version: string;
|
||||
row: LogicRow;
|
||||
}
|
||||
|
||||
/** 쓰는 곳 한 줄 — 일위대가 조합 · 이 로직을 부르는 다른 로직
|
||||
* `where` = 회사 · 개인 층 조합의 자리 글(「회사 · 산림조합」) · 마스터 층 · 로직은 없음
|
||||
* 층을 못 읽었으면 `key` "" · `name` = 「일부를 못 읽음」 한 줄(지우기 전에 확인하게) */
|
||||
export interface EasyUse {
|
||||
kind: "combo" | "logic";
|
||||
where?: string;
|
||||
key: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** 기계 고르기 목록 한 줄 */
|
||||
export interface EasyMachine {
|
||||
분류번호: string;
|
||||
이름: string;
|
||||
규격: string;
|
||||
/** 줄 단위(「시간」) */
|
||||
단위: string;
|
||||
}
|
||||
|
||||
/** 기종 → 호표 「요소」(기계경비 로직 부름 글) · 그 로직이 받는 설계 때 정하는 값 */
|
||||
export interface EasyMachineElement {
|
||||
element: string;
|
||||
needInputs: LogicInput[];
|
||||
}
|
||||
|
||||
/** 기계 시간당 경비 로직 하나 — 기종 짝 규칙의 재료 */
|
||||
export interface MachineLogic {
|
||||
key: string;
|
||||
name: string;
|
||||
resultUnit: string;
|
||||
inputs: LogicInput[];
|
||||
}
|
||||
|
||||
/* ── 순수 시작 — 아래 끝까지 밖 이름을 안 씀(`currentLanguageIndex` 만) · 시험이 Node 로 바로 돌림 ── */
|
||||
const TEXT = {
|
||||
Base: ["품셈 기본", "Standard"],
|
||||
Mine: ["내가 만든 것", "Mine"],
|
||||
NotMoney: [
|
||||
"금액이 아닌 값(작업량 등)을 내는 것이라 쉬운 길로 못 엶 — 지금 화면에서 봄",
|
||||
"Gives a non-money value (output etc.) — open it in the full screen",
|
||||
],
|
||||
E403: [
|
||||
"품셈 기본은 바로 못 고침 — 「이걸 바탕으로 고치기」로 내 것을 만들어 고침",
|
||||
"Standard items are read-only — use “Edit a copy”",
|
||||
],
|
||||
E404: [
|
||||
"찾을 수 없음 — 이미 지워졌을 수 있음 · 목록을 다시 열어 주세요",
|
||||
"Not found — it may be deleted; reopen the list",
|
||||
],
|
||||
E409: [
|
||||
"다른 곳에서 먼저 고쳤음 — 다시 열어 최신 것으로 고쳐 주세요",
|
||||
"Changed elsewhere first — reopen and edit again",
|
||||
],
|
||||
Net: ["서버와 이어지지 않음 — 잠시 뒤 다시 해 주세요", "Server unreachable — try again"],
|
||||
Generic: [
|
||||
"저장 검사에 걸림 — 고친 줄을 다시 확인해 주세요",
|
||||
"Did not pass the check — review your changes",
|
||||
],
|
||||
More: ["그 밖 {n} 건", "{n} more"],
|
||||
UnknownName: [
|
||||
"「{name}」 값이 어디에도 없음 — 설계 때 정하는 값으로 더해 주세요",
|
||||
"「{name}」 is not defined — add it as a design value",
|
||||
],
|
||||
NeedCost: [
|
||||
"넣을 자리(사람 품값 · 재료값 · 기계·경비)를 골라 주세요",
|
||||
"Pick where it goes (labor · material · equipment)",
|
||||
],
|
||||
NoLines: [
|
||||
"줄이 하나도 없음 — 사람 · 재료 · 기계 줄을 하나 이상 더해 주세요",
|
||||
"No lines — add at least one labor, material or machine line",
|
||||
],
|
||||
Source: ["근거 글 모양이 맞지 않음", "The source text has a wrong shape"],
|
||||
CallNeeds: [
|
||||
"부르는 다른 일위대가가 「{name}」 값을 받아야 함 — 설계 때 정하는 값에 더해 주세요",
|
||||
"The called item needs 「{name}」 — add it as a design value",
|
||||
],
|
||||
CallExtra: [
|
||||
"부르는 다른 일위대가는 「{name}」 값을 받지 않음",
|
||||
"The called item does not take 「{name}」",
|
||||
],
|
||||
Kind: [
|
||||
"고른 항목과 줄 종류가 맞지 않음 — 다시 골라 주세요",
|
||||
"The picked item does not match the line type — pick again",
|
||||
],
|
||||
Loop: [
|
||||
"서로를 부르는 일위대가가 생김 — 한쪽을 빼 주세요",
|
||||
"Items call each other — remove one side",
|
||||
],
|
||||
Unread: ["수량 글을 읽지 못함 — 숫자로 넣어 주세요", "Cannot read the amount — enter a number"],
|
||||
Gone: [
|
||||
"이어진 항목이 없어짐 — 다른 곳에서 쓰고 있으면 지울 수 없음",
|
||||
"A linked item is missing — items still in use cannot be deleted",
|
||||
],
|
||||
NoName: ["이름을 넣어 주세요", "Enter a name"],
|
||||
Broken: ["저장 모양이 덜 갖춰짐 — 다시 열어 주세요", "The saved shape is incomplete — reopen it"],
|
||||
Cut: ["합계 자르기 설정이 맞지 않음", "The rounding setting is wrong"],
|
||||
Material: [
|
||||
"재료 찾는 말에 맞는 자료가 없음 — 다시 골라 주세요",
|
||||
"No material matches — pick again",
|
||||
],
|
||||
Stop: [
|
||||
"이 값으로는 셈하지 못함 — 설계 때 정하는 값을 확인해 주세요",
|
||||
"Cannot be calculated with these values — check the design values",
|
||||
],
|
||||
NoMachine: [
|
||||
"이 기계는 시간당 값을 셀 자료가 없음 — 다른 기계를 골라 주세요",
|
||||
"No hourly cost data for this machine — pick another",
|
||||
],
|
||||
AtCompany: ["회사 · {name}", "Company · {name}"],
|
||||
AtPerson: ["개인 · {name}", "Personal · {name}"],
|
||||
MyCompany: ["내 회사", "My company"],
|
||||
Me: ["나", "Me"],
|
||||
Unseen: [
|
||||
"회사 · 개인 조합 일부를 못 읽음 — 지우기 전에 확인 필요",
|
||||
"Some company or personal combos could not be read — check before deleting",
|
||||
],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
|
||||
type SayKey = keyof typeof TEXT;
|
||||
|
||||
function say(key: SayKey, fill: Record<string, string | number> = {}): string {
|
||||
const text: string = TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0];
|
||||
return text.replace(/\{(\w+)\}/g, (m, k: string) => (k in fill ? String(fill[k]) : m));
|
||||
}
|
||||
|
||||
/** 검사 글 한 줄 → 쉬운 말 — 모르는 꼴은 뭉뚱그림(키 · 날 글이 화면에 안 나가게) */
|
||||
function plainCheck(line: string): string {
|
||||
const name = (re: RegExp): string | null => re.exec(line)?.[1] ?? null;
|
||||
const unknown = name(/모르는 이름 「([^」]+)」/);
|
||||
if (unknown) return say("UnknownName", { name: unknown });
|
||||
const needs = name(/입력 「([^」]+)」 안 줌/);
|
||||
if (needs) return say("CallNeeds", { name: needs });
|
||||
const extra = name(/에 입력 「([^」]+)」 없음/);
|
||||
if (extra) return say("CallExtra", { name: extra });
|
||||
if (/「호표」 도 「결과」 도 없음/.test(line)) return say("NoLines");
|
||||
if (/비목 「/.test(line)) return say("NeedCost");
|
||||
if (/출처 모양/.test(line)) return say("Source");
|
||||
if (/요소 그룹이 종류와 다름|종류 「|고르기 조건은 종류/.test(line)) return say("Kind");
|
||||
if (/돌고 도는 참조/.test(line)) return say("Loop");
|
||||
if (/식 못 읽음/.test(line)) return say("Unread");
|
||||
if (/없는 (로직|요소|키)|로직 키/.test(line)) return say("Gone");
|
||||
if (/「이름」 이 없음|이름 없음/.test(line)) return say("NoName");
|
||||
if (/끝수/.test(line)) return say("Cut");
|
||||
if (/검색어|후보 0|고르기 조건/.test(line)) return say("Material");
|
||||
if (/칸 「[^」]+」 없음/.test(line)) return say("Broken");
|
||||
return say("Generic");
|
||||
}
|
||||
|
||||
/** 상태 번호 + 서버 몸 → 쉬운 말(여러 줄은 줄바꿈 · 셋까지) · 0 = 서버에 못 닿음 */
|
||||
function plainOf(status: number, detail: unknown): string {
|
||||
if (status === 0) return say("Net");
|
||||
if (status === 403) return say("E403");
|
||||
if (status === 404) return say("E404");
|
||||
if (status === 409) return say("E409");
|
||||
const body = detail as { errors?: unknown } | null;
|
||||
const lines =
|
||||
status === 422 && body && Array.isArray(body.errors)
|
||||
? body.errors.map((x) => String(x))
|
||||
: typeof detail === "string"
|
||||
? [detail]
|
||||
: [];
|
||||
if (!lines.length) return say("Generic");
|
||||
const plain = [...new Set(lines.map(plainCheck))];
|
||||
const shown = plain.slice(0, 3);
|
||||
if (plain.length > 3) shown.push(say("More", { n: plain.length - 3 }));
|
||||
return shown.join("\n");
|
||||
}
|
||||
|
||||
/** 계산 멈춤 까닭 — 키(`GX000001`)가 섞인 글은 뭉뚱그림 */
|
||||
function plainStop(text: string): string {
|
||||
return /[A-Z]{2}\d{6}/.test(text) ? say("Stop") : text;
|
||||
}
|
||||
|
||||
const MACHINE = "기계";
|
||||
/** 부착물 아닌 입력 — 이 밖의 입력(집게 · 브레이커)을 받는 로직은 부착 장비 전용 */
|
||||
const MACHINE_PLAIN = new Set(["기계", "지역", "보정작업", "조종원직종"]);
|
||||
|
||||
/** 그 로직이 받는 기종(분류번호) 목록 */
|
||||
function machineCodes(m: MachineLogic): string[] {
|
||||
const spec = m.inputs.find((i) => i.이름 === MACHINE);
|
||||
return (spec?.고르기 ?? []).map(String);
|
||||
}
|
||||
|
||||
/** 짝 고르는 차례 — [부착 전용, 손료만, 손료만이면서 보정, 기종 수] 가 작은 것부터.
|
||||
* 손료만 = 「지역」(연료 · 노임 값 자리)을 안 받음 · 기종 수가 작을수록 그 기종 전용(굴착기 · 조종원 없음 …) */
|
||||
function machineRank(m: MachineLogic): number[] {
|
||||
const names = m.inputs.map((i) => i.이름);
|
||||
const attach = names.some((n) => !MACHINE_PLAIN.has(n)) ? 1 : 0;
|
||||
const lossOnly = names.includes("지역") ? 0 : 1;
|
||||
const boost = lossOnly && names.includes("보정작업") ? 1 : 0;
|
||||
return [attach, lossOnly, boost, machineCodes(m).length];
|
||||
}
|
||||
|
||||
/** 기종 → 부를 기계경비 로직 하나 · 받는 로직이 없으면 null */
|
||||
export function pickMachine(family: MachineLogic[], code: string): MachineLogic | null {
|
||||
const ranked = family
|
||||
.filter((m) => machineCodes(m).includes(code))
|
||||
.map((m) => ({ m, rank: machineRank(m) }))
|
||||
.sort((a, b) => {
|
||||
for (let i = 0; i < a.rank.length; i += 1) {
|
||||
if (a.rank[i] !== b.rank[i]) return a.rank[i] - b.rank[i];
|
||||
}
|
||||
return a.m.key < b.m.key ? -1 : 1;
|
||||
});
|
||||
return ranked[0]?.m ?? null;
|
||||
}
|
||||
|
||||
/** 로직 부름 글 — 기계는 고정 분류번호 · 나머지 입력은 같은 이름의 설계 때 정하는 값으로 넘김 */
|
||||
export function machineCall(m: MachineLogic, code: string): EasyMachineElement {
|
||||
const quote = (n: string): string => (/^[\w가-힣]+$/.test(n) ? n : `'${n}'`);
|
||||
const needInputs = m.inputs.filter((i) => i.이름 !== MACHINE);
|
||||
const rest = needInputs.map((i) => `, ${quote(i.이름)}=${quote(i.이름)}`).join("");
|
||||
return { element: `로직(${m.key}, ${MACHINE}='${code}'${rest})`, needInputs };
|
||||
}
|
||||
|
||||
/** 층 조합 줄 통째(`담은로직` 포함) */
|
||||
export interface LayerCombo {
|
||||
키: string;
|
||||
이름?: string;
|
||||
담은로직?: { 로직?: string }[];
|
||||
}
|
||||
|
||||
/** 이 로직을 담은 층 조합 — `layer` = company · personal · `name` = 층 주인 이름 */
|
||||
export function holding(
|
||||
combos: LayerCombo[],
|
||||
key: string,
|
||||
layer: "company" | "personal",
|
||||
name: string,
|
||||
): EasyUse[] {
|
||||
const where = say(layer === "company" ? "AtCompany" : "AtPerson", { name });
|
||||
return combos
|
||||
.filter((c) => (c.담은로직 ?? []).some((one) => one?.로직 === key))
|
||||
.map((c): EasyUse => ({ kind: "combo", where, key: c.키, name: c.이름 ?? "" }));
|
||||
}
|
||||
/* ── 순수 끝 ── */
|
||||
|
||||
const OWN = "로직_자체.json"; // 내가 만든 것 한 자리(서버 `Store_Make.OWN`)
|
||||
const OWNER = "현장";
|
||||
const CALC_WAIT_MS = 300;
|
||||
const MACHINE_FILE = "건설기계"; // 기계 시간당 경비 로직이 든 장 파일(「08장 건설기계」)
|
||||
|
||||
/** `/api/m01` 길 · M02 층 조합 읽기는 `m02` */
|
||||
const request = <T>(path: string, body?: unknown): Promise<T> =>
|
||||
send<T>(`${API_BASE_URL}/m01${path}`, body);
|
||||
const m02 = <T>(path: string): Promise<T> => send<T>(`${API_BASE_URL}/m02${path}`);
|
||||
|
||||
async function send<T>(url: string, body?: unknown): Promise<T> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError(0, "");
|
||||
}
|
||||
const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T;
|
||||
if (!response.ok) throw new ApiError(response.status, data.detail ?? response.statusText);
|
||||
return data;
|
||||
}
|
||||
|
||||
const query = (params: Record<string, string>): string => new URLSearchParams(params).toString();
|
||||
|
||||
interface Summary {
|
||||
file: string;
|
||||
키: string;
|
||||
구분: string;
|
||||
상세구분: string;
|
||||
이름: string;
|
||||
결과단위: string;
|
||||
}
|
||||
|
||||
const isMoney = (unit: string): boolean => unit.startsWith("원");
|
||||
|
||||
/** 목록 — 내가 만든 것 먼저 · 돈 아닌 로직은 「쉬운 길로 못 엶」 */
|
||||
export async function listEasy(q: string): Promise<EasyListItem[]> {
|
||||
const got = await request<{ logics: Summary[] }>(`/logics?${query({ q })}`);
|
||||
const items = got.logics.map((s): EasyListItem => {
|
||||
const isBase = s.file !== OWN;
|
||||
const canOpen = isMoney(s.결과단위);
|
||||
return {
|
||||
key: s.키,
|
||||
name: s.이름,
|
||||
isBase,
|
||||
owner: say(isBase ? "Base" : "Mine"),
|
||||
resultUnit: s.결과단위,
|
||||
canOpen,
|
||||
whyNot: canOpen ? null : say("NotMoney"),
|
||||
where: isBase ? `${s.구분} ${s.상세구분}`.trim() : s.상세구분,
|
||||
};
|
||||
});
|
||||
return [...items.filter((x) => !x.isBase), ...items.filter((x) => x.isBase)];
|
||||
}
|
||||
|
||||
/** 열기 — 로직 줄 · 판본 · 견본 설계 값(자동값 · 못 받으면 빈 채로) */
|
||||
export async function openEasy(key: string): Promise<EasyOpened> {
|
||||
const [one, auto] = await Promise.all([fetchLogic(key), fetchAuto(key)]);
|
||||
return {
|
||||
row: one.logic,
|
||||
version: one.version,
|
||||
isBase: one.file !== OWN,
|
||||
trial: trialFor(one.logic, auto),
|
||||
};
|
||||
}
|
||||
|
||||
/** 로직이 받는 설계 값만 — 모르는 이름을 보내면 서버가 멈춤 */
|
||||
function trialFor(row: LogicRow, trial: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
(row.입력 ?? []).filter((i) => i.이름 in trial).map((i) => [i.이름, trial[i.이름]]),
|
||||
);
|
||||
}
|
||||
|
||||
let calcSeq = 0;
|
||||
let calcTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
/** 0.3초 뒤 서버 시험 계산 · 최신 응답만 `onDone` · 멈춤 · 실패는 `plain` 쉬운 말 */
|
||||
export function calcLater(
|
||||
row: LogicRow,
|
||||
trial: Record<string, unknown>,
|
||||
onDone: (ans: CalcAnswer | null, plain: string | null) => void,
|
||||
): void {
|
||||
const mine = ++calcSeq;
|
||||
clearTimeout(calcTimer);
|
||||
calcTimer = setTimeout(() => {
|
||||
runCalc({ key: row.키 || "", inputs: trialFor(row, trial), row })
|
||||
.then((ans) => {
|
||||
if (mine === calcSeq) onDone(ans, ans.ok ? null : plainStop(safeReason(ans.reason)));
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (mine === calcSeq) onDone(null, plainError(e));
|
||||
});
|
||||
}, CALC_WAIT_MS);
|
||||
}
|
||||
|
||||
/** 새로 — 키 · 갈래는 서버가 줌 · 근거 글이 비면 「자체」(검사가 빈 글을 막음) */
|
||||
export async function newEasy(row: LogicRow): Promise<EasySaved> {
|
||||
const logic = { ...row, 출처: row.출처 || "자체" };
|
||||
const got = await request<{ version: string; key: string; logic: LogicRow }>("/logic/new", {
|
||||
logic,
|
||||
owner: OWNER,
|
||||
});
|
||||
return { key: got.key, version: got.version, row: got.logic };
|
||||
}
|
||||
|
||||
/** 이걸 바탕으로 고치기 — 품셈 기본 · 내 것 모두 본뜸 */
|
||||
export async function copyEasy(key: string): Promise<EasySaved> {
|
||||
const got = await copyLogic(key);
|
||||
return { key: got.key, version: got.version, row: got.logic };
|
||||
}
|
||||
|
||||
/** 고치기 — 내가 만든 것만(품셈 기본은 403) · 판본이 다르면 409 */
|
||||
export async function editEasy(key: string, version: string, row: LogicRow): Promise<EasySaved> {
|
||||
const got = await request<{ version: string; logic: LogicRow }>("/logic/edit", {
|
||||
key,
|
||||
version,
|
||||
logic: row,
|
||||
});
|
||||
return { key, version: got.version, row: got.logic };
|
||||
}
|
||||
|
||||
/** 지우기 — 내가 만든 것만 · 쓰는 곳이 남으면 422 */
|
||||
export async function deleteEasy(key: string, version: string): Promise<{ version: string }> {
|
||||
const got = await request<{ version: string }>("/logic/delete", { key, version });
|
||||
return { version: got.version };
|
||||
}
|
||||
|
||||
/** 쓰는 곳 — 담은 일위대가 조합(마스터 · 회사 · 개인 층) + 이 로직을 부르는 내가 만든 것.
|
||||
* 지우기 누를 때 부름(무거움) · 서버 지우기 검사는 마스터 층 조합만 막음.
|
||||
* ponytail: 부르는 로직은 내가 만든 것만 하나씩 받아 훑음(품셈 기본은 내 것을 안 부름) —
|
||||
* 내 것이 수백이 되면 서버에 「부르는 로직」 길을 더할 것 */
|
||||
export async function usesOf(key: string): Promise<EasyUse[]> {
|
||||
const [combos, mine, layered] = await Promise.all([
|
||||
request<{ combos: { 키: string; 이름: string }[] }>(`/logic/combos?${query({ key })}`),
|
||||
request<{ logics: Summary[] }>(`/logics?${query({ sub: "자체" })}`),
|
||||
layerUses(key),
|
||||
]);
|
||||
const call = `로직(${key}`;
|
||||
const others = mine.logics.filter((s) => s.키 !== key);
|
||||
const rows = await Promise.all(others.map((s) => fetchLogic(s.키)));
|
||||
const callers = rows
|
||||
.filter((one) => JSON.stringify(one.logic).includes(call))
|
||||
.map((one): EasyUse => ({ kind: "logic", key: one.logic.키, name: one.logic.이름 }));
|
||||
return [
|
||||
...combos.combos.map((c): EasyUse => ({ kind: "combo", key: c.키, name: c.이름 })),
|
||||
...layered,
|
||||
...callers,
|
||||
];
|
||||
}
|
||||
|
||||
interface LayerTarget {
|
||||
layer: "company" | "personal";
|
||||
owner: string;
|
||||
name: string;
|
||||
rows?: { 키: string }[];
|
||||
}
|
||||
|
||||
/** 회사 · 개인 층 조합 가운데 이 로직을 담은 것 — M02 층 길 읽기만(시스템 관리자는 모든 층).
|
||||
* 내 층 = `/combos/all` · 남의 회사 = `/layers/company/owners` · 회사마다 사람 = `/layers/personal/owners`.
|
||||
* 못 읽은 층이 있으면 「일부를 못 읽음」 한 줄을 더함.
|
||||
* ponytail: 층마다 조합 하나씩 받아 훑음 — 조합이 수천이면 서버에 「이 로직을 담은 층 조합」 길을 더할 것 */
|
||||
async function layerUses(key: string): Promise<EasyUse[]> {
|
||||
let missed = false;
|
||||
const soft = <T>(p: Promise<T>, empty: T): Promise<T> =>
|
||||
p.catch(() => {
|
||||
missed = true;
|
||||
return empty;
|
||||
});
|
||||
type Own = { layer: string; owner: string | null; 줄: { 키: string }[] };
|
||||
type Owner = { owner: string; name: string };
|
||||
const own = await soft(m02<Own[]>("/combos/all"), []);
|
||||
const targets: LayerTarget[] = [];
|
||||
let myCompany = "";
|
||||
for (const one of own) {
|
||||
if (one.layer === "system" || !one.owner) continue;
|
||||
const company = one.layer === "company";
|
||||
if (company) myCompany = one.owner;
|
||||
targets.push({
|
||||
layer: company ? "company" : "personal",
|
||||
owner: one.owner,
|
||||
name: say(company ? "MyCompany" : "Me"),
|
||||
rows: one.줄,
|
||||
});
|
||||
}
|
||||
const companies = await soft(m02<Owner[]>("/layers/company/owners"), []);
|
||||
for (const c of companies) targets.push({ layer: "company", ...c });
|
||||
const people = await Promise.all(
|
||||
[myCompany, ...companies.map((c) => c.owner)]
|
||||
.filter(Boolean)
|
||||
.map((c) => soft(m02<Owner[]>(`/layers/personal/owners?${query({ company: c })}`), [])),
|
||||
);
|
||||
for (const p of people.flat()) targets.push({ layer: "personal", ...p });
|
||||
|
||||
const found = await Promise.all(
|
||||
targets.map(async (t) => {
|
||||
const at = `/layers/${t.layer}/combos`;
|
||||
const own = `?${query({ owner: t.owner })}`;
|
||||
const rows = t.rows ?? (await soft(m02<{ 줄: { 키: string }[] }>(at + own), { 줄: [] })).줄;
|
||||
const combos = await Promise.all(
|
||||
rows.map((r) =>
|
||||
soft(m02<{ combo: LayerCombo }>(`${at}/${encodeURIComponent(r.키)}${own}`), null),
|
||||
),
|
||||
);
|
||||
const full = combos.flatMap((x) => (x ? [x.combo] : []));
|
||||
return holding(full, key, t.layer, t.name);
|
||||
}),
|
||||
);
|
||||
const out = found.flat();
|
||||
if (missed) out.push({ kind: "combo", key: "", name: say("Unseen") });
|
||||
return out;
|
||||
}
|
||||
|
||||
let family: Promise<MachineLogic[]> | null = null;
|
||||
|
||||
/** 기계 시간당 경비 로직 묶음 — 한 번 받아 둠(실패하면 다음에 다시) */
|
||||
function machineFamily(): Promise<MachineLogic[]> {
|
||||
family ??= request<{ logics: Summary[] }>("/logics")
|
||||
.then((got) =>
|
||||
Promise.all(
|
||||
got.logics
|
||||
.filter((s) => s.file.includes(MACHINE_FILE) && isMoney(s.결과단위))
|
||||
.map((s) => fetchLogic(s.키)),
|
||||
),
|
||||
)
|
||||
.then((all) =>
|
||||
all
|
||||
.map((one) => ({
|
||||
key: one.logic.키,
|
||||
name: one.logic.이름,
|
||||
resultUnit: one.logic.결과단위,
|
||||
inputs: one.logic.입력 ?? [],
|
||||
}))
|
||||
.filter((m) => machineCodes(m).length > 0),
|
||||
)
|
||||
.catch((e: unknown) => {
|
||||
family = null;
|
||||
throw e;
|
||||
});
|
||||
return family;
|
||||
}
|
||||
|
||||
interface MachineRow {
|
||||
원문번호?: string;
|
||||
이름?: string;
|
||||
규격?: string;
|
||||
}
|
||||
|
||||
/** 기계 고르기 — 이름 · 분류번호 찾기 · 시간당 값을 셀 수 있는 기종만 */
|
||||
export async function listMachines(q: string): Promise<EasyMachine[]> {
|
||||
const [all, got] = await Promise.all([
|
||||
machineFamily(),
|
||||
request<{ items: MachineRow[] }>(`/elements?${query({ group: MACHINE, q, limit: "200" })}`),
|
||||
]);
|
||||
return got.items.flatMap((row): EasyMachine[] => {
|
||||
const code = String(row.원문번호 ?? "");
|
||||
const m = pickMachine(all, code);
|
||||
if (!m) return [];
|
||||
const unit = m.resultUnit.split("/")[1] ?? "";
|
||||
return [{ 분류번호: code, 이름: row.이름 ?? "", 규격: row.규격 ?? "", 단위: unit }];
|
||||
});
|
||||
}
|
||||
|
||||
/** 기종 → 호표 「요소」 글 · 받아야 할 설계 값(지역 등) — 호표 종류는 「로직」 */
|
||||
export async function machineElement(기종: string): Promise<EasyMachineElement> {
|
||||
const m = pickMachine(await machineFamily(), 기종);
|
||||
if (!m) throw new Error(say("NoMachine"));
|
||||
return machineCall(m, 기종);
|
||||
}
|
||||
|
||||
/** 어떤 실패든 화면에 찍을 쉬운 말 한두 줄 */
|
||||
export function plainError(e: unknown): string {
|
||||
if (e instanceof ApiError) return plainOf(e.status, e.detail);
|
||||
if (e instanceof Error && e.message === say("NoMachine")) return e.message;
|
||||
return say("Net");
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
.m01-easycards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
font-size: var(--text-body);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.m01-easycards-group {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.m01-easycards-group__head,
|
||||
.m01-easycards-group--empty {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
min-height: 44px;
|
||||
padding: var(--spacing-8) var(--spacing-16);
|
||||
}
|
||||
|
||||
.m01-easycards-group__head {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01-easycards-group--empty {
|
||||
border-style: dashed;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.m01-easycards-group__title {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.m01-easycards-group__sum {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.m01-easycards-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.m01-easycards-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-8) var(--spacing-16);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m01-easycards-card--locked {
|
||||
border-left: 4px dashed var(--color-border);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.m01-easycards-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8) var(--spacing-16);
|
||||
}
|
||||
|
||||
.m01-easycards-sentence {
|
||||
display: flex;
|
||||
flex: 1 1 24em;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01-easycards-tag {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.m01-easycards-name {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.m01-easycards-name--pick {
|
||||
min-height: 44px;
|
||||
padding: 0 var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-inputs);
|
||||
background: var(--color-surface);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01-easycards-name--pick:focus-visible {
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
}
|
||||
|
||||
.m01-easycards .ui-input,
|
||||
.m01-easycards .ui-select {
|
||||
min-height: 44px;
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m01-easycards-qty {
|
||||
flex: none;
|
||||
width: 7em;
|
||||
}
|
||||
|
||||
.m01-easycards-text {
|
||||
flex: none;
|
||||
width: 10em;
|
||||
}
|
||||
|
||||
.m01-easycards-select {
|
||||
flex: none;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.m01-easycards-auto,
|
||||
.m01-easycards-why {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m01-easycards-amount {
|
||||
margin-left: auto;
|
||||
font-weight: var(--font-weight-bold);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-easycards-amount--wait {
|
||||
opacity: 0.5;
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
|
||||
.m01-easycards-tools {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m01-easycards-tools .ui-btn,
|
||||
.m01-easycards-locknote .ui-btn {
|
||||
min-height: 44px;
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m01-easycards-warn {
|
||||
margin: 0;
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-left: 4px solid var(--color-warning);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.m01-easycards-locknote {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01-easycards-lock {
|
||||
padding: 0 var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-pills);
|
||||
font-size: var(--text-body-sm);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.m01-easycards-add {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01-easycards-add .ui-btn {
|
||||
min-height: 44px;
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.m01-easycards-amount {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.m01-easycards-tools {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,542 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_EasyCalc_Cards.ts
|
||||
* 쉬운 단가산출 카드 그리기 — 묶음 + 소계 · 문장 칸 · 잠금 카드 · 지움 · ▲▼ · 카드 안 경고 · 더하기 단추
|
||||
* 모양은 `docs/raw/M01_쉬운단가/설계.md` 3절 그대로 · 바뀐 doc 는 on.change 로만 알림(그리기는 부른 쪽이 다시)
|
||||
* 글 [한, 영] 짝은 아래 TEXT
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements";
|
||||
import type { CalcAnswer, CalcLine } from "../M01_MasterData_UI_Logic_Api";
|
||||
import { formatNumber } from "../M01_MasterData_UI_Logic_Edit";
|
||||
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
|
||||
import {
|
||||
addCard,
|
||||
addPercent,
|
||||
checkCard,
|
||||
removeCard,
|
||||
updateCard,
|
||||
withInputs,
|
||||
type CostKind,
|
||||
type EasyCard,
|
||||
type EasyDoc,
|
||||
type PickResult,
|
||||
} from "./M01_EasyCalc_Model";
|
||||
import { openEasyPick } from "./M01_EasyCalc_Pick";
|
||||
import { openEasyCond, readCond } from "./M01_EasyCalc_Cond";
|
||||
import { openEasyTable, readFinds } from "./M01_EasyCalc_Table";
|
||||
import "./M01_EasyCalc_Cards.css";
|
||||
|
||||
const TEXT = {
|
||||
Group_person: ["사람", "Workers"],
|
||||
Group_material: ["재료", "Materials"],
|
||||
Group_machine: ["기계·경비", "Machines"],
|
||||
Group_logic: ["다른 일위대가", "Other unit prices"],
|
||||
Group_percent: ["덧붙임", "Add-ons"],
|
||||
Subtotal: ["소계", "Subtotal"],
|
||||
Won: ["원", "KRW"],
|
||||
Empty: ["아직 없음", "none yet"],
|
||||
Pending: ["계산 중", "calculating"],
|
||||
Tag_person: ["사람", "Worker"],
|
||||
Tag_material: ["재료", "Material"],
|
||||
Tag_machine: ["기계", "Machine"],
|
||||
Tag_logic: ["다른 일위대가", "Other unit price"],
|
||||
Tag_percent: ["덧붙임", "Add-on"],
|
||||
Pre_person: [" 이 ", " works "],
|
||||
Post_person: [" 일함", ""],
|
||||
Pre_pick: [" 를 ", " uses "],
|
||||
Post_material: [" 씀", ""],
|
||||
Post_machine: [" 씀", ""],
|
||||
Post_logic: ["", ""],
|
||||
Pre_percent: [" 의 ", " of "],
|
||||
Mid_percent: [" % 를 ", " % as "],
|
||||
Post_percent: [" 로 더함", ""],
|
||||
Slot: ["넣을 자리", "put into"],
|
||||
Pick_Empty: ["고르기", "choose"],
|
||||
Repick: ["다른 것으로 다시 고르기", "Choose another"],
|
||||
Eg_qty: ["예: 0.072", "e.g. 0.072"],
|
||||
Eg_percent: ["예: 2", "e.g. 2"],
|
||||
Eg_name: ["예: 공구손료", "e.g. tool wear"],
|
||||
Qty: ["수량", "Amount"],
|
||||
Name: ["이름", "Name"],
|
||||
Auto: ["자동", "auto"],
|
||||
Locked: ["잠김", "Locked"],
|
||||
ToNumber: ["숫자로 바꾸기", "Make it a number"],
|
||||
ByTable: ["표 보고 고르기", "Choose from table"],
|
||||
ByCond: ["조건 보고 고치기", "Edit conditions"],
|
||||
ToNumber_Wait: ["계산이 끝난 뒤 가능", "Available after calculation"],
|
||||
ToNumber_Ask: [
|
||||
"자동 값을 그대로 숫자로 바꿈 · 이후 자동으로 달라지지 않음",
|
||||
"Freeze the auto value as a number",
|
||||
],
|
||||
ToNumber_Ok: ["바꾸기", "Convert"],
|
||||
Remove: ["지움", "Remove"],
|
||||
Up: ["위로", "Move up"],
|
||||
Down: ["아래로", "Move down"],
|
||||
Add_person: ["+ 사람", "+ Worker"],
|
||||
Add_material: ["+ 재료", "+ Material"],
|
||||
Add_machine: ["+ 기계", "+ Machine"],
|
||||
Add_logic: ["+ 다른 일위대가", "+ Other unit price"],
|
||||
Add_percent: ["+ 덧붙임", "+ Add-on"],
|
||||
Base_노무비: ["사람 품값", "labor cost"],
|
||||
Base_재료비: ["재료값", "material cost"],
|
||||
Base_경비: ["기계·경비", "machine & expenses"],
|
||||
Base_사람줄합: ["사람 줄의 합", "sum of worker lines"],
|
||||
} as const;
|
||||
|
||||
const tc = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex];
|
||||
|
||||
type Group = "person" | "material" | "machine" | "logic" | "percent";
|
||||
const GROUPS: Group[] = ["person", "material", "machine", "logic", "percent"];
|
||||
type PercentBase = NonNullable<EasyCard["percentBase"]>;
|
||||
const BASES: PercentBase[] = ["노무비", "재료비", "경비", "사람줄합"];
|
||||
const COSTS: CostKind[] = ["노무비", "재료비", "경비"];
|
||||
|
||||
interface Ctx {
|
||||
doc: EasyDoc;
|
||||
lines: Map<string, CalcLine>;
|
||||
readOnly: boolean;
|
||||
on: { change(doc: EasyDoc): void; trial?(): Record<string, unknown> };
|
||||
/** 숫자 아님으로 친 글 — 다시 그려도 칸에 남김 */
|
||||
badText: Map<string, string>;
|
||||
}
|
||||
|
||||
const badTexts = new WeakMap<HTMLElement, Map<string, string>>();
|
||||
const closedGroups = new WeakMap<HTMLElement, Set<Group>>();
|
||||
let picking = false;
|
||||
|
||||
const groupOf = (card: EasyCard): Group => {
|
||||
if (card.kind !== "locked") return card.kind;
|
||||
if (card.source?.part === "덧줄") return "percent";
|
||||
const kind = (card.raw as { 종류?: string } | null)?.종류;
|
||||
return kind === "인력" ? "person" : kind === "재료" ? "material" : "logic";
|
||||
};
|
||||
|
||||
/** Model 의 toRow 와 같은 나눔 — 줄 차례가 어긋나지 않게 */
|
||||
const partOf = (card: EasyCard): "호표" | "덧줄" =>
|
||||
card.kind === "percent"
|
||||
? "덧줄"
|
||||
: card.kind === "locked"
|
||||
? (card.source?.part ?? "덧줄")
|
||||
: "호표";
|
||||
|
||||
/** 식 수량이거나 종류를 못 알아본 줄 — 숫자로 바꾸기 전까지 잠김 */
|
||||
const isLocked = (card: EasyCard): boolean =>
|
||||
card.qtyFormula !== null || (card.kind === "locked" && card.lockedWhy !== null);
|
||||
|
||||
/** 카드 → 서버 계산 줄 — 호표 줄 차례 뒤에 덧줄 차례 · 줄 수가 안 맞으면(옛 응답) 없음 */
|
||||
function lineMap(doc: EasyDoc, ans: CalcAnswer | null): Map<string, CalcLine> {
|
||||
const out = new Map<string, CalcLine>();
|
||||
if (!ans || !ans.ok || !ans.lines) return out;
|
||||
const ordered = [
|
||||
...doc.cards.filter((c) => partOf(c) === "호표"),
|
||||
...doc.cards.filter((c) => partOf(c) === "덧줄"),
|
||||
];
|
||||
if (ordered.length !== ans.lines.length) return out;
|
||||
ordered.forEach((card, i) => out.set(card.id, ans.lines![i]));
|
||||
return out;
|
||||
}
|
||||
|
||||
const parseQty = (text: string): number | null => {
|
||||
const t = text.replace(/[,\s]/g, "");
|
||||
if (t === "") return null;
|
||||
return /^[+-]?(\d+\.?\d*|\.\d+)$/.test(t) ? Number(t) : Number.NaN;
|
||||
};
|
||||
|
||||
/** 검사가 막혀도 그리기는 계속 */
|
||||
function warnOf(ctx: Ctx, card: EasyCard): string | null {
|
||||
if (ctx.doc.isBase) return null;
|
||||
try {
|
||||
return checkCard(card, ctx.doc);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function patchCard(ctx: Ctx, card: EasyCard, patch: Partial<EasyCard>): void {
|
||||
ctx.on.change(updateCard(ctx.doc, card.id, patch));
|
||||
}
|
||||
|
||||
/** 화면 묶음 안 이웃과 자리 바꿈 — 잠금 카드도 같은 묶음이라 Model.moveCard 의 묶음과 달라 여기서 바꿈 */
|
||||
function swapInGroup(doc: EasyDoc, card: EasyCard, step: -1 | 1): EasyDoc {
|
||||
const same = doc.cards.filter((c) => groupOf(c) === groupOf(card));
|
||||
const neighbor = same[same.indexOf(card) + step];
|
||||
if (!neighbor) return doc;
|
||||
const cards = doc.cards.slice();
|
||||
const a = cards.indexOf(card);
|
||||
const b = cards.indexOf(neighbor);
|
||||
[cards[a], cards[b]] = [cards[b], cards[a]];
|
||||
return { ...doc, cards };
|
||||
}
|
||||
|
||||
async function repick(ctx: Ctx, card: EasyCard): Promise<void> {
|
||||
if (picking || card.kind === "locked" || card.kind === "percent") return;
|
||||
picking = true;
|
||||
try {
|
||||
const pick = await openEasyPick(card.kind, { qty: card.qty });
|
||||
if (!pick) return;
|
||||
const next = updateCard(ctx.doc, card.id, {
|
||||
name: pick.name,
|
||||
spec: pick.spec,
|
||||
unit: pick.unit,
|
||||
element: pick.element,
|
||||
qty: pick.qty,
|
||||
});
|
||||
ctx.on.change(withInputs(next, pick.needInputs ?? []));
|
||||
} finally {
|
||||
picking = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addByPick(ctx: Ctx, kind: PickResult["kind"]): Promise<void> {
|
||||
if (picking) return;
|
||||
picking = true;
|
||||
try {
|
||||
const pick = await openEasyPick(kind);
|
||||
if (pick) ctx.on.change(addCard(ctx.doc, pick));
|
||||
} finally {
|
||||
picking = false;
|
||||
}
|
||||
}
|
||||
|
||||
function addBlankPercent(ctx: Ctx): void {
|
||||
ctx.on.change(addPercent(ctx.doc, "", "노무비", null, "경비"));
|
||||
}
|
||||
|
||||
async function lockToNumber(ctx: Ctx, card: EasyCard, value: number): Promise<void> {
|
||||
const ask = `${tc("ToNumber_Ask")} (${formatNumber(value)})`;
|
||||
if (await showConfirmDialog(ask, tc("ToNumber_Ok"))) {
|
||||
patchCard(ctx, card, { qty: value, qtyFormula: null, lockedWhy: null });
|
||||
}
|
||||
}
|
||||
|
||||
/** 표 · 조건 창 — 답(새 식)이 있으면 그 카드 식만 바꿈 */
|
||||
async function editFormula(
|
||||
ctx: Ctx,
|
||||
card: EasyCard,
|
||||
open: typeof openEasyTable | typeof openEasyCond,
|
||||
): Promise<void> {
|
||||
if (picking) return;
|
||||
picking = true;
|
||||
try {
|
||||
const formula = await open(card, ctx.doc, ctx.on.trial?.());
|
||||
if (formula !== null) patchCard(ctx, card, { qtyFormula: formula });
|
||||
} finally {
|
||||
picking = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------ 칸 만들기 ------------------------------ */
|
||||
|
||||
const text = (cls: string, value: string): HTMLSpanElement =>
|
||||
el("span", { className: cls, text: value });
|
||||
|
||||
function qtyInput(ctx: Ctx, card: EasyCard): HTMLInputElement {
|
||||
const bad = ctx.badText.get(card.id);
|
||||
const shown = card.qty === null ? "" : Number.isNaN(card.qty) ? (bad ?? "") : String(card.qty);
|
||||
const input = el("input", {
|
||||
className: "ui-input m01-easycards-qty",
|
||||
attrs: {
|
||||
type: "text",
|
||||
inputmode: "decimal",
|
||||
placeholder: tc(card.kind === "percent" ? "Eg_percent" : "Eg_qty"),
|
||||
"aria-label": tc("Qty"),
|
||||
},
|
||||
});
|
||||
input.value = shown;
|
||||
if (ctx.readOnly) input.readOnly = true;
|
||||
input.addEventListener("change", () => {
|
||||
const qty = parseQty(input.value);
|
||||
if (Number.isNaN(qty)) ctx.badText.set(card.id, input.value);
|
||||
else ctx.badText.delete(card.id);
|
||||
if (Object.is(qty, card.qty)) return;
|
||||
patchCard(ctx, card, { qty });
|
||||
});
|
||||
return input;
|
||||
}
|
||||
|
||||
function nameWidget(ctx: Ctx, card: EasyCard): HTMLElement {
|
||||
if (card.kind === "percent") {
|
||||
const input = el("input", {
|
||||
className: "ui-input m01-easycards-text",
|
||||
attrs: { type: "text", placeholder: tc("Eg_name"), "aria-label": tc("Name") },
|
||||
});
|
||||
input.value = card.name;
|
||||
if (ctx.readOnly) input.readOnly = true;
|
||||
input.addEventListener("change", () => {
|
||||
if (input.value !== card.name) patchCard(ctx, card, { name: input.value.trim() });
|
||||
});
|
||||
return input;
|
||||
}
|
||||
const label = [card.name, card.spec].filter(Boolean).join(" ") || tc("Pick_Empty");
|
||||
if (ctx.readOnly || card.kind === "locked") return text("m01-easycards-name", label);
|
||||
const button = el("button", {
|
||||
className: "m01-easycards-name m01-easycards-name--pick",
|
||||
text: `${label} ▾`,
|
||||
attrs: { type: "button", "aria-label": `${label} — ${tc("Repick")}` },
|
||||
});
|
||||
button.addEventListener("click", () => void repick(ctx, card));
|
||||
return button;
|
||||
}
|
||||
|
||||
function selectBox<T extends string>(
|
||||
ctx: Ctx,
|
||||
values: T[],
|
||||
current: T | null,
|
||||
label: (v: T) => string,
|
||||
onPick: (v: T) => void,
|
||||
aria: string,
|
||||
): HTMLElement {
|
||||
if (ctx.readOnly) return text("m01-easycards-name", current ? label(current) : "");
|
||||
const select = el("select", {
|
||||
className: "ui-select m01-easycards-select",
|
||||
attrs: { "aria-label": aria },
|
||||
});
|
||||
if (current === null)
|
||||
select.append(el("option", { attrs: { value: "" }, text: tc("Pick_Empty") }));
|
||||
for (const v of values) select.append(el("option", { attrs: { value: v }, text: label(v) }));
|
||||
select.value = current ?? "";
|
||||
select.addEventListener("change", () => {
|
||||
if (select.value) onPick(select.value as T);
|
||||
});
|
||||
return select;
|
||||
}
|
||||
|
||||
function amountText(ctx: Ctx, card: EasyCard): HTMLElement {
|
||||
const line = ctx.lines.get(card.id);
|
||||
if (!line) return text("m01-easycards-amount m01-easycards-amount--wait", `= ${tc("Pending")}`);
|
||||
return text("m01-easycards-amount", `= ${formatMoney(line.금액)} ${tc("Won")}`);
|
||||
}
|
||||
|
||||
function toolButtons(ctx: Ctx, card: EasyCard, group: EasyCard[]): HTMLElement | null {
|
||||
if (ctx.readOnly) return null;
|
||||
const at = group.indexOf(card);
|
||||
const move = (label: string, glyph: string, step: -1 | 1, off: boolean) => {
|
||||
const b = createButton({
|
||||
label: glyph,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
disabled: off,
|
||||
onClick: () => ctx.on.change(swapInGroup(ctx.doc, card, step)),
|
||||
});
|
||||
b.setAttribute("aria-label", label);
|
||||
return b;
|
||||
};
|
||||
return el("div", {
|
||||
className: "m01-easycards-tools",
|
||||
children: [
|
||||
move(tc("Up"), "▲", -1, at <= 0),
|
||||
move(tc("Down"), "▼", 1, at >= group.length - 1),
|
||||
createButton({
|
||||
label: tc("Remove"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => ctx.on.change(removeCard(ctx.doc, card.id)),
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 한 카드 = 문장 한 줄(좁으면 두 줄로 접힘) */
|
||||
function cardView(ctx: Ctx, card: EasyCard, group: EasyCard[]): HTMLElement {
|
||||
const g = groupOf(card);
|
||||
const locked = isLocked(card);
|
||||
const percent = card.kind === "percent";
|
||||
const plain = card.kind === "locked";
|
||||
const name = nameWidget(ctx, card);
|
||||
const value = locked ? lockedValue(ctx, card) : qtyInput(ctx, card);
|
||||
const parts: (HTMLElement | string)[] = [text("m01-easycards-tag", `【${tc(`Tag_${g}`)}】`)];
|
||||
|
||||
if (percent) {
|
||||
parts.push(
|
||||
selectBox(
|
||||
ctx,
|
||||
BASES,
|
||||
card.percentBase,
|
||||
(v) => tc(`Base_${v}`),
|
||||
(v) => patchCard(ctx, card, { percentBase: v }),
|
||||
tc("Slot"),
|
||||
),
|
||||
tc("Pre_percent"),
|
||||
value,
|
||||
tc("Mid_percent"),
|
||||
name,
|
||||
tc("Post_percent"),
|
||||
);
|
||||
} else {
|
||||
parts.push(
|
||||
name,
|
||||
plain ? " " : g === "person" ? tc("Pre_person") : tc("Pre_pick"),
|
||||
value,
|
||||
text("m01-easycards-unit", ` ${card.unit}`),
|
||||
plain ? "" : tc(`Post_${g}` as "Post_person"),
|
||||
);
|
||||
}
|
||||
|
||||
const slot = percent
|
||||
? el("span", {
|
||||
className: "m01-easycards-slot",
|
||||
children: [
|
||||
` · ${tc("Slot")} `,
|
||||
selectBox(
|
||||
ctx,
|
||||
COSTS,
|
||||
card.cost,
|
||||
(v) => tc(`Base_${v}`),
|
||||
(v) => patchCard(ctx, card, { cost: v }),
|
||||
tc("Slot"),
|
||||
),
|
||||
],
|
||||
})
|
||||
: null;
|
||||
|
||||
const head = el("div", {
|
||||
className: "m01-easycards-line",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-easycards-sentence",
|
||||
children: slot ? [...parts, slot] : parts,
|
||||
}),
|
||||
amountText(ctx, card),
|
||||
toolButtons(ctx, card, group) ?? "",
|
||||
],
|
||||
});
|
||||
const rows: HTMLElement[] = [head];
|
||||
|
||||
const warn = warnOf(ctx, card);
|
||||
if (warn) rows.push(el("p", { className: "m01-easycards-warn", text: `⚠ ${warn}` }));
|
||||
if (locked) rows.push(lockedNote(ctx, card));
|
||||
return el("li", {
|
||||
className: `m01-easycards-card${locked ? " m01-easycards-card--locked" : ""}`,
|
||||
children: rows,
|
||||
});
|
||||
}
|
||||
|
||||
/** 잠금 카드 값 자리 — 「자동 0.50」 */
|
||||
function lockedValue(ctx: Ctx, card: EasyCard): HTMLElement {
|
||||
const line = ctx.lines.get(card.id);
|
||||
const value = line ? formatNumber(line.수량) : tc("Pending");
|
||||
return text("m01-easycards-auto", `(${tc("Auto")} ${value})`);
|
||||
}
|
||||
|
||||
function lockedNote(ctx: Ctx, card: EasyCard): HTMLElement {
|
||||
const line = ctx.lines.get(card.id);
|
||||
const note: (HTMLElement | string)[] = [
|
||||
text("m01-easycards-lock", `🔒 ${tc("Locked")}`),
|
||||
text("m01-easycards-why", card.lockedWhy ?? ""),
|
||||
];
|
||||
if (!ctx.readOnly) {
|
||||
const button = createButton({
|
||||
label: tc("ToNumber"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
disabled: !line,
|
||||
onClick: () => line && void lockToNumber(ctx, card, line.수량),
|
||||
});
|
||||
if (!line) button.title = tc("ToNumber_Wait");
|
||||
note.push(button);
|
||||
const formula = card.qtyFormula;
|
||||
const tools: [boolean, keyof typeof TEXT, typeof openEasyTable | typeof openEasyCond][] = [
|
||||
[formula !== null && !!readFinds(formula)?.length, "ByTable", openEasyTable],
|
||||
[formula !== null && !!readCond(formula, ctx.doc.row), "ByCond", openEasyCond],
|
||||
];
|
||||
for (const [has, label, open] of tools) {
|
||||
if (has)
|
||||
note.push(
|
||||
createButton({
|
||||
label: tc(label),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => void editFormula(ctx, card, open),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return el("div", { className: "m01-easycards-locknote", children: note });
|
||||
}
|
||||
|
||||
/* ------------------------------ 묶음 · 전체 ------------------------------ */
|
||||
|
||||
function groupView(ctx: Ctx, host: HTMLElement, group: Group, cards: EasyCard[]): HTMLElement {
|
||||
const title = tc(`Group_${group}`);
|
||||
if (!cards.length) {
|
||||
return el("section", {
|
||||
className: "m01-easycards-group m01-easycards-group--empty",
|
||||
children: [
|
||||
text("m01-easycards-group__title", title),
|
||||
text("m01-easycards-group__none", tc("Empty")),
|
||||
],
|
||||
});
|
||||
}
|
||||
const found = cards.map((c) => ctx.lines.get(c.id)).filter((l): l is CalcLine => !!l);
|
||||
const sum =
|
||||
found.length === cards.length
|
||||
? `${tc("Subtotal")} ${formatMoney(found.reduce((s, l) => s + l.금액, 0))} ${tc("Won")}`
|
||||
: `${tc("Subtotal")} ${tc("Pending")}`;
|
||||
const details = el("details", {
|
||||
className: "m01-easycards-group",
|
||||
children: [
|
||||
el("summary", {
|
||||
className: "m01-easycards-group__head",
|
||||
children: [
|
||||
text("m01-easycards-group__title", title),
|
||||
text(
|
||||
`m01-easycards-group__sum${found.length === cards.length ? "" : " m01-easycards-amount--wait"}`,
|
||||
sum,
|
||||
),
|
||||
],
|
||||
}),
|
||||
el("ul", {
|
||||
className: "m01-easycards-list",
|
||||
children: cards.map((c) => cardView(ctx, c, cards)),
|
||||
}),
|
||||
],
|
||||
});
|
||||
const closed = closedGroups.get(host) ?? new Set<Group>();
|
||||
closedGroups.set(host, closed);
|
||||
details.open = !closed.has(group);
|
||||
details.addEventListener("toggle", () => {
|
||||
if (details.open) closed.delete(group);
|
||||
else closed.add(group);
|
||||
});
|
||||
return details;
|
||||
}
|
||||
|
||||
function addBar(ctx: Ctx): HTMLElement {
|
||||
const add = (key: keyof typeof TEXT, run: () => void) =>
|
||||
createButton({ label: tc(key), variant: "ghost", onClick: run });
|
||||
return el("div", {
|
||||
className: "m01-easycards-add",
|
||||
children: [
|
||||
add("Add_person", () => void addByPick(ctx, "person")),
|
||||
add("Add_material", () => void addByPick(ctx, "material")),
|
||||
add("Add_machine", () => void addByPick(ctx, "machine")),
|
||||
add("Add_logic", () => void addByPick(ctx, "logic")),
|
||||
add("Add_percent", () => addBlankPercent(ctx)),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 카드 묶음 그리기 — ans 는 줄 금액 · null 이면 「계산 중」 */
|
||||
export function renderCards(
|
||||
host: HTMLElement,
|
||||
doc: EasyDoc,
|
||||
ans: CalcAnswer | null,
|
||||
on: { change(doc: EasyDoc): void; trial?(): Record<string, unknown> },
|
||||
): void {
|
||||
const badText = badTexts.get(host) ?? new Map<string, string>();
|
||||
badTexts.set(host, badText);
|
||||
const ctx: Ctx = { doc, lines: lineMap(doc, ans), readOnly: doc.isBase, on, badText };
|
||||
host.classList.add("m01-easycards");
|
||||
host.replaceChildren(
|
||||
...GROUPS.map((g) =>
|
||||
groupView(
|
||||
ctx,
|
||||
host,
|
||||
g,
|
||||
doc.cards.filter((c) => groupOf(c) === g),
|
||||
),
|
||||
),
|
||||
...(ctx.readOnly ? [] : [addBar(ctx)]),
|
||||
);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_EasyCalc_Cond.css — 조건 보고 고치기 창
|
||||
* ========================================================================== */
|
||||
|
||||
.m01-easycond {
|
||||
width: min(760px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.m01-easycond__lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.m01-easycond__line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.m01-easycond__slot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.m01-easycond__select,
|
||||
.m01-easycond__num {
|
||||
min-height: 36px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--color-border, #c9ced6);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface, #fff);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.m01-easycond__num {
|
||||
width: 7em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.m01-easycond__num.is-bad {
|
||||
border-color: var(--color-danger, #d64545);
|
||||
outline: 2px solid var(--color-danger, #d64545);
|
||||
}
|
||||
|
||||
.m01-easycond__word {
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface-raised, #eef1f5);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m01-easycond__chip {
|
||||
min-height: 36px;
|
||||
padding: 4px 12px;
|
||||
border: 1px dashed var(--color-accent, #3a6fd8);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--color-accent, #3a6fd8);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01-easycond__chip:disabled {
|
||||
border-color: var(--color-border, #c9ced6);
|
||||
color: inherit;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.m01-easycond__money {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface-raised, #eef1f5);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m01-easycond__money.is-waiting {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.m01-easycond__foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.m01-easycond__cannot {
|
||||
font-size: 16px;
|
||||
}
|
||||
@@ -1,620 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_EasyCalc_Cond.ts
|
||||
* 쉬운 단가산출 2단계 — 조건 보고 고치기. 잠금 카드(수량 · 그 밖 덧붙임)의 조건을 문장으로 보이고
|
||||
* 고르기 · 숫자 칸으로 고침 → 새 식 글(qtyFormula) · 취소 null
|
||||
*
|
||||
* 엔진에 식 구조를 주는 길이 없어(`/text` 는 값을 넣은 글 조각뿐) 화면에서 읽음 — 문법은 `master_formula._Parser` 와 같음
|
||||
* 덩어리 = 만약(비교, 값, 값)(아니면 자리에 만약이 이어지는 사슬 포함) · 최소/최대(값, …) · 올림/버림/반올림(값, 자리)
|
||||
* 식 전체가 덩어리이거나 · 식 속에 덩어리가 있으면(「표 값 × 만약(…)」) 덩어리만 문장으로 · 나머지 글은 그대로
|
||||
* 식이 중간 값 이름 하나면 그 중간 식을 읽음(고치면 수량 자리에 풀어 씀 · 중간 값은 그대로)
|
||||
* 다시 짠 덩어리 글이 원래와 같을 때만 씀 — 하나도 못 읽으면 「이 줄은 지금 화면에서」 한 줄
|
||||
* 금액 미리 보기 = Api `calcLater`(준비 전이면 `/calc` 를 0.3초 뒤 · 최신만)
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import {
|
||||
runCalc,
|
||||
type CalcAnswer,
|
||||
type LogicInput,
|
||||
type LogicRow,
|
||||
} from "../M01_MasterData_UI_Logic_Api";
|
||||
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
|
||||
import { calcLater } from "./M01_EasyCalc_Api";
|
||||
import { toRow, updateCard, type EasyCard, type EasyDoc } from "./M01_EasyCalc_Model";
|
||||
import "./M01_EasyCalc_Cond.css";
|
||||
|
||||
const TEXT = {
|
||||
Title: ["조건 보고 고치기", "Change by conditions"],
|
||||
Cannot: ["이 줄은 지금 화면에서 고침", "Change this line on the current screen"],
|
||||
Part: ["조건", "Condition"],
|
||||
If: ["만약", "If"],
|
||||
ElseIf: ["아니고 만약", "Else if"],
|
||||
Then: ["→", "→"],
|
||||
Else: ["아니면 →", "Otherwise →"],
|
||||
Subject: ["가", "is"],
|
||||
Big: ["가운데 큰 값", "whichever is larger"],
|
||||
Small: ["가운데 작은 값", "whichever is smaller"],
|
||||
And: ["와", "and"],
|
||||
RoundA: ["을 소수", "to"],
|
||||
RoundB: ["자리까지", "decimal places,"],
|
||||
Up: ["올림", "round up"],
|
||||
Down: ["버림", "round down"],
|
||||
Half: ["반올림", "round off"],
|
||||
OpEq: ["이면", "equal to"],
|
||||
OpNe: ["이 아니면", "not equal to"],
|
||||
OpGt: ["보다 크면", "greater than"],
|
||||
OpGe: ["이상이면", "at least"],
|
||||
OpLt: ["보다 작으면", "less than"],
|
||||
OpLe: ["이하이면", "at most"],
|
||||
ChipTable: ["표에서 찾은 값", "Value from table"],
|
||||
ChipTableCalc: ["표에서 찾은 값으로 계산", "Worked out from a table"],
|
||||
ChipInputCalc: ["설계 값으로 계산", "Worked out from design values"],
|
||||
ChipCalc: ["계산한 값", "Calculated value"],
|
||||
ToNumber: ["숫자로", "Use a number"],
|
||||
Line: ["이 줄", "This line"],
|
||||
Total: ["합계", "Total"],
|
||||
Was: ["전", "before"],
|
||||
Waiting: ["계산 중 …", "Calculating …"],
|
||||
Use: ["이걸로", "Use this"],
|
||||
Cancel: ["취소", "Cancel"],
|
||||
NotNumber: ["숫자만 넣음", "Numbers only"],
|
||||
} as const;
|
||||
|
||||
const te = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex];
|
||||
|
||||
/* ── 읽기 · 짜기 (DOM 없음 · 시험이 이 구역만 떼어 Node 로 돌림) ───────────── */
|
||||
|
||||
/** 식 마디 — src = 원래 글 · p = [처음, 끝] 자리 */
|
||||
type CNode = { src: string; p: [number, number] } & (
|
||||
| { t: "num" | "str" | "name" | "ref"; v: string }
|
||||
| { t: "call"; f: string; items: CNode[] }
|
||||
| { t: "bin"; op: string; a: CNode; b: CNode }
|
||||
| { t: "neg" | "paren"; a: CNode }
|
||||
| { t: "field"; a: CNode; f: string }
|
||||
);
|
||||
|
||||
const COND_FUNCS = ["만약", "올림", "버림", "반올림", "최소", "최대"];
|
||||
const COMPARE_OPS = ["<=", ">=", "==", "!=", "<", ">"];
|
||||
const NAME_RE = /[\p{L}\p{N}_]+/uy;
|
||||
const NUM_RE = /\d+(?:\.\d+)?/y;
|
||||
|
||||
/** 엔진 `_Parser` 와 같은 문법 · 찾기(…) · 로직(…) · ID:원문번호 는 통째 한 마디 */
|
||||
function parseCond(s: string): CNode | null {
|
||||
let i = 0;
|
||||
const fail = (): never => {
|
||||
throw new Error("parse");
|
||||
};
|
||||
const ws = (): void => {
|
||||
while (i < s.length && /\s/.test(s[i])) i++;
|
||||
};
|
||||
const take = (w: string): boolean => {
|
||||
ws();
|
||||
if (!s.startsWith(w, i)) return false;
|
||||
i += w.length;
|
||||
return true;
|
||||
};
|
||||
const need = (w: string): void => void (take(w) || fail());
|
||||
const mk = <T extends object>(start: number, body: T): CNode =>
|
||||
({ ...body, src: s.slice(start, i), p: [start, i] }) as unknown as CNode;
|
||||
const sticky = (re: RegExp): string | null => {
|
||||
re.lastIndex = i;
|
||||
const m = re.exec(s);
|
||||
if (m) i = re.lastIndex;
|
||||
return m ? m[0] : null;
|
||||
};
|
||||
const word = (): string => (ws(), sticky(NAME_RE) ?? fail());
|
||||
const quoted = (): string => {
|
||||
const end = s.indexOf("'", i);
|
||||
if (end < 0) fail();
|
||||
const v = s.slice(i, end);
|
||||
i = end + 1;
|
||||
return v;
|
||||
};
|
||||
/** 괄호 짝까지 — 작은따옴표 안은 건너뜀 */
|
||||
const skipGroup = (): void => {
|
||||
let depth = 0;
|
||||
let q = false;
|
||||
for (; i < s.length; i++) {
|
||||
if (s[i] === "'") q = !q;
|
||||
else if (!q && s[i] === "(") depth++;
|
||||
else if (!q && s[i] === ")" && --depth === 0) return void i++;
|
||||
}
|
||||
fail();
|
||||
};
|
||||
/** ID:원문번호 — 괄호 밖 , ) · 빈칸+연산자 · .칸 앞에서 멈춤 */
|
||||
const skipRef = (): void => {
|
||||
for (let depth = 0; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if ("({".includes(ch)) depth++;
|
||||
else if (")}".includes(ch) && depth-- === 0) return;
|
||||
else if (depth === 0 && (ch === "," || /^\s[-+*/<>=!]/.test(s.slice(i, i + 2)))) return;
|
||||
else if (depth === 0 && ch === "." && !/\d/.test(s[i + 1] ?? "")) return;
|
||||
}
|
||||
};
|
||||
const fields = (start: number, node: CNode): CNode => {
|
||||
while (take(".")) {
|
||||
const f = take("'") ? quoted() : word();
|
||||
node = mk(start, { t: "field", a: node, f });
|
||||
}
|
||||
return node;
|
||||
};
|
||||
const primary = (): CNode => {
|
||||
ws();
|
||||
const start = i;
|
||||
if (take("(")) {
|
||||
const a = expr();
|
||||
need(")");
|
||||
return fields(start, mk(start, { t: "paren", a }));
|
||||
}
|
||||
if (take("'")) return mk(start, { t: "str", v: quoted() });
|
||||
const num = sticky(NUM_RE);
|
||||
if (num) return mk(start, { t: "num", v: num });
|
||||
const w = word();
|
||||
if (w === "찾기" || w === "로직") {
|
||||
ws();
|
||||
if (s[i] !== "(") fail();
|
||||
skipGroup();
|
||||
return fields(start, mk(start, { t: "ref", v: w }));
|
||||
}
|
||||
if (COND_FUNCS.includes(w)) {
|
||||
need("(");
|
||||
const items = [expr()];
|
||||
while (take(",")) items.push(expr());
|
||||
need(")");
|
||||
return fields(start, mk(start, { t: "call", f: w, items }));
|
||||
}
|
||||
if (s[i] === ":") {
|
||||
i++;
|
||||
skipRef();
|
||||
return fields(start, mk(start, { t: "ref", v: w }));
|
||||
}
|
||||
return fields(start, mk(start, { t: "name", v: w }));
|
||||
};
|
||||
const unary = (): CNode => {
|
||||
ws();
|
||||
const start = i;
|
||||
return take("-") ? mk(start, { t: "neg", a: unary() }) : primary();
|
||||
};
|
||||
const chain = (next: () => CNode, ops: string[], once = false): CNode => {
|
||||
ws();
|
||||
const start = i;
|
||||
let node = next();
|
||||
for (let op = ops.find(take); op; op = once ? undefined : ops.find(take))
|
||||
node = mk(start, { t: "bin", op, a: node, b: next() });
|
||||
return node;
|
||||
};
|
||||
const mul = (): CNode => chain(unary, ["*", "/"]);
|
||||
const add = (): CNode => chain(mul, ["+", "-"]);
|
||||
const compare = (): CNode => chain(add, COMPARE_OPS, true);
|
||||
const both = (): CNode => chain(compare, ["그리고"]);
|
||||
const expr = (): CNode => chain(both, ["또는"]);
|
||||
try {
|
||||
const node = expr();
|
||||
ws();
|
||||
return i === s.length ? node : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 값 자리 — 화면이 그 자리에서 고침(같은 물건을 바꿔 씀) */
|
||||
export type CondSlot =
|
||||
| { kind: "num"; value: number }
|
||||
| { kind: "text"; value: string }
|
||||
| { kind: "input"; name: string }
|
||||
| { kind: "table" | "calc"; src: string };
|
||||
export type CondOp = "==" | "!=" | ">" | ">=" | "<" | "<=";
|
||||
export interface CondTest {
|
||||
left: CondSlot;
|
||||
op: CondOp;
|
||||
right: CondSlot;
|
||||
}
|
||||
export type CondShape =
|
||||
| { type: "if"; branches: { test: CondTest; value: CondSlot }[]; otherwise: CondSlot }
|
||||
| { type: "pick"; fn: "최소" | "최대"; items: CondSlot[] }
|
||||
| {
|
||||
type: "round";
|
||||
fn: "올림" | "버림" | "반올림";
|
||||
value: CondSlot;
|
||||
places: number;
|
||||
hadPlaces: boolean;
|
||||
};
|
||||
/** 덩어리 하나 — p = 읽은 글(text) 안 자리 */
|
||||
export interface CondBlock {
|
||||
shape: CondShape;
|
||||
p: [number, number];
|
||||
}
|
||||
/** 읽은 결과 — text = 읽은 글(중간 값을 따라가면 그 식) · via = 따라간 중간 값 이름 */
|
||||
export interface CondRead {
|
||||
text: string;
|
||||
via: string | null;
|
||||
blocks: CondBlock[];
|
||||
}
|
||||
|
||||
const unparen = (n: CNode): CNode => (n.t === "paren" ? unparen(n.a) : n);
|
||||
|
||||
function slotOf(node: CNode, inputs: LogicInput[]): CondSlot {
|
||||
const n = unparen(node);
|
||||
if (n.t === "num") return { kind: "num", value: Number(n.v) };
|
||||
if (n.t === "neg" && unparen(n.a).t === "num")
|
||||
return { kind: "num", value: -Number(unparen(n.a).src) };
|
||||
if (n.t === "str") return { kind: "text", value: n.v };
|
||||
if (n.t === "name" && inputs.some((x) => x.이름 === n.v)) return { kind: "input", name: n.v };
|
||||
if (n.t === "field" && n.a.t === "ref" && n.src.startsWith("찾기"))
|
||||
return { kind: "table", src: n.src };
|
||||
return { kind: "calc", src: node.src.trim() };
|
||||
}
|
||||
|
||||
function shapeOf(n: CNode, inputs: LogicInput[]): CondShape | null {
|
||||
if (n.t !== "call") return null;
|
||||
const slot = (x: CNode): CondSlot => slotOf(x, inputs);
|
||||
if (n.f === "만약") {
|
||||
const t = unparen(n.items[0]);
|
||||
if (n.items.length !== 3 || t.t !== "bin" || !COMPARE_OPS.includes(t.op)) return null;
|
||||
const test: CondTest = { left: slot(t.a), op: t.op as CondOp, right: slot(t.b) };
|
||||
const branch = { test, value: slot(n.items[1]) };
|
||||
const rest = shapeOf(unparen(n.items[2]), inputs);
|
||||
return rest?.type === "if"
|
||||
? { type: "if", branches: [branch, ...rest.branches], otherwise: rest.otherwise }
|
||||
: { type: "if", branches: [branch], otherwise: slot(n.items[2]) };
|
||||
}
|
||||
if (n.f === "최소" || n.f === "최대")
|
||||
return n.items.length >= 2 ? { type: "pick", fn: n.f, items: n.items.map(slot) } : null;
|
||||
const places = n.items[1] ? unparen(n.items[1]) : null;
|
||||
if (n.items.length > 2 || (places && places.t !== "num")) return null;
|
||||
const fn = n.f as "올림" | "버림" | "반올림";
|
||||
const value = slot(n.items[0]);
|
||||
return { type: "round", fn, value, places: places ? Number(places.src) : 0, hadPlaces: !!places };
|
||||
}
|
||||
|
||||
const condNum = (n: number): string => {
|
||||
const t = String(Number(n.toPrecision(12)));
|
||||
return t.includes("e") ? n.toFixed(12).replace(/\.?0+$/, "") : t;
|
||||
};
|
||||
|
||||
function slotText(s: CondSlot): string {
|
||||
if (s.kind === "num") return condNum(s.value);
|
||||
if (s.kind === "text") return `'${s.value}'`;
|
||||
return s.kind === "input" ? s.name : s.src;
|
||||
}
|
||||
|
||||
/** 덩어리 → 식 글 */
|
||||
export function writeCond(shape: CondShape): string {
|
||||
if (shape.type === "pick") return `${shape.fn}(${shape.items.map(slotText).join(", ")})`;
|
||||
if (shape.type === "round") {
|
||||
const v = slotText(shape.value);
|
||||
return shape.places === 0 && !shape.hadPlaces
|
||||
? `${shape.fn}(${v})`
|
||||
: `${shape.fn}(${v}, ${condNum(shape.places)})`;
|
||||
}
|
||||
return shape.branches.reduceRight((acc, b) => {
|
||||
const t = b.test;
|
||||
return `만약(${slotText(t.left)} ${t.op} ${slotText(t.right)}, ${slotText(b.value)}, ${acc})`;
|
||||
}, slotText(shape.otherwise));
|
||||
}
|
||||
|
||||
const flat = (s: string): string => s.replace(/[\s()]+/g, "");
|
||||
|
||||
/** 바깥부터 덩어리를 찾음 — 읽은 덩어리 안은 값 자리 · 못 읽은 부름은 안을 다시 봄 */
|
||||
function blocksOf(n: CNode, inputs: LogicInput[]): CondBlock[] {
|
||||
if (n.t === "call") {
|
||||
const shape = shapeOf(n, inputs);
|
||||
if (shape && flat(writeCond(shape)) === flat(n.src)) return [{ shape, p: n.p }];
|
||||
return n.items.flatMap((x) => blocksOf(x, inputs));
|
||||
}
|
||||
if (n.t === "bin") return [...blocksOf(n.a, inputs), ...blocksOf(n.b, inputs)];
|
||||
if (n.t === "neg" || n.t === "paren" || n.t === "field") return blocksOf(n.a, inputs);
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 식 글 → 덩어리들 · 하나도 못 읽으면 null */
|
||||
export function readCond(formula: string, row: Pick<LogicRow, "입력" | "중간">): CondRead | null {
|
||||
let text = formula.trim();
|
||||
let via: string | null = null;
|
||||
for (let hop = 0; hop < 5; hop++) {
|
||||
const mid = (row.중간 ?? []).find((m) => m.이름 === text);
|
||||
if (!mid) break;
|
||||
via = mid.이름;
|
||||
text = mid.식.trim();
|
||||
}
|
||||
const node = parseCond(text);
|
||||
const blocks = node ? blocksOf(node, row.입력 ?? []) : [];
|
||||
return blocks.length ? { text, via, blocks } : null;
|
||||
}
|
||||
|
||||
/** 덩어리를 고친 뒤 식 글 전체 — 덩어리 밖 글은 그대로 */
|
||||
export function writeRead(read: CondRead): string {
|
||||
let out = read.text;
|
||||
for (const b of [...read.blocks].sort((x, y) => y.p[0] - x.p[0]))
|
||||
out = out.slice(0, b.p[0]) + writeCond(b.shape) + out.slice(b.p[1]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── 화면 ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
const OPS: [CondOp, keyof typeof TEXT][] = [
|
||||
["==", "OpEq"],
|
||||
["!=", "OpNe"],
|
||||
[">", "OpGt"],
|
||||
[">=", "OpGe"],
|
||||
["<", "OpLt"],
|
||||
["<=", "OpLe"],
|
||||
];
|
||||
|
||||
/** 2단계 표 고르기(sub8) — 파일이 생기면 표 칩을 누를 때 엶 · 없으면 칩만 */
|
||||
const TABLE_MODULE = import.meta.glob<{
|
||||
openEasyTable: (
|
||||
card: EasyCard,
|
||||
doc: EasyDoc,
|
||||
trial?: Record<string, unknown>,
|
||||
) => Promise<string | null>;
|
||||
}>("./M01_EasyCalc_Table.ts");
|
||||
|
||||
/** 값 자리 바꿔 쓰기 — 같은 물건 그대로(문장이 쥔 자리를 놓치지 않음) */
|
||||
const put = (slot: CondSlot, next: CondSlot): void => {
|
||||
for (const k of Object.keys(slot)) delete (slot as Record<string, unknown>)[k];
|
||||
Object.assign(slot, next);
|
||||
};
|
||||
|
||||
function select(
|
||||
options: { value: string; text: string }[],
|
||||
value: string,
|
||||
onChange: (v: string) => void,
|
||||
): HTMLSelectElement {
|
||||
const sel = el("select", { className: "m01-easycond__select" });
|
||||
const all = options.some((o) => o.value === value)
|
||||
? options
|
||||
: [...options, { value, text: value }];
|
||||
for (const o of all) sel.append(el("option", { text: o.text, attrs: { value: o.value } }));
|
||||
sel.value = value;
|
||||
sel.addEventListener("change", () => onChange(sel.value));
|
||||
return sel;
|
||||
}
|
||||
|
||||
function numberBox(value: number, onChange: (v: number) => void): HTMLInputElement {
|
||||
const box = el("input", {
|
||||
className: "m01-easycond__num",
|
||||
attrs: { type: "text", inputmode: "decimal", value: condNum(value) },
|
||||
});
|
||||
box.addEventListener("input", () => {
|
||||
const ok = /^\s*-?\d+(\.\d+)?\s*$/.test(box.value);
|
||||
box.classList.toggle("is-bad", !ok);
|
||||
box.title = ok ? "" : te("NotNumber");
|
||||
if (ok) onChange(Number(box.value));
|
||||
});
|
||||
return box;
|
||||
}
|
||||
|
||||
function chipLabel(slot: CondSlot, inputs: LogicInput[]): string {
|
||||
if (slot.kind === "table") return te("ChipTable");
|
||||
if (slot.kind !== "calc") return "";
|
||||
if (/찾기\s*\(/.test(slot.src)) return te("ChipTableCalc");
|
||||
const words: string[] = slot.src.match(/[\p{L}\p{N}_]+/gu) ?? [];
|
||||
return inputs.some((x) => words.includes(x.이름)) ? te("ChipInputCalc") : te("ChipCalc");
|
||||
}
|
||||
|
||||
/** 문장 짓기 — 고칠 때마다 changed() */
|
||||
class Sentence {
|
||||
constructor(
|
||||
private card: EasyCard,
|
||||
private doc: EasyDoc,
|
||||
private changed: () => void,
|
||||
private trial: Record<string, unknown>,
|
||||
) {}
|
||||
|
||||
/** 값 칸 — 숫자는 숫자 칸 · 글 · 설계 값은 글자 · 나머지는 칩(+ 숫자로) */
|
||||
slot(s: CondSlot): HTMLElement {
|
||||
const host = el("span", { className: "m01-easycond__slot" });
|
||||
const draw = (): void => {
|
||||
host.replaceChildren();
|
||||
if (s.kind === "num") {
|
||||
host.append(numberBox(s.value, (v) => (put(s, { kind: "num", value: v }), this.changed())));
|
||||
return;
|
||||
}
|
||||
if (s.kind === "text" || s.kind === "input") {
|
||||
const text = s.kind === "text" ? s.value : s.name;
|
||||
host.append(el("span", { className: "m01-easycond__word", text }));
|
||||
return;
|
||||
}
|
||||
const chip = el("button", {
|
||||
className: "m01-easycond__chip",
|
||||
text: chipLabel(s, this.doc.inputs),
|
||||
attrs: { type: "button" },
|
||||
});
|
||||
const load = TABLE_MODULE["./M01_EasyCalc_Table.ts"];
|
||||
if (load && s.kind === "table")
|
||||
chip.addEventListener("click", async () => {
|
||||
const got = await (
|
||||
await load()
|
||||
).openEasyTable({ ...this.card, qtyFormula: s.src }, this.doc, this.trial);
|
||||
if (got) (put(s, { kind: "table", src: got }), draw(), this.changed());
|
||||
});
|
||||
else chip.disabled = true;
|
||||
const toNumber = (): void => (put(s, { kind: "num", value: 0 }), draw(), this.changed());
|
||||
host.append(
|
||||
chip,
|
||||
createButton({ label: te("ToNumber"), variant: "ghost", size: "sm", onClick: toNumber }),
|
||||
);
|
||||
};
|
||||
draw();
|
||||
return host;
|
||||
}
|
||||
|
||||
/** 비교 — 왼쪽 설계 값 고르기 · 오른쪽은 그 값의 고르기 목록 또는 값 칸 · 맺음 고르기 */
|
||||
test(t: CondTest): HTMLElement[] {
|
||||
const inputs = this.doc.inputs;
|
||||
const right = el("span", { className: "m01-easycond__slot" });
|
||||
const drawRight = (): void => {
|
||||
const name = t.left.kind === "input" ? t.left.name : null;
|
||||
const inp = inputs.find((x) => x.이름 === name);
|
||||
const choices = inp?.고르기 ?? [];
|
||||
const r = t.right;
|
||||
if (!choices.length || (r.kind !== "text" && r.kind !== "num"))
|
||||
return right.replaceChildren(this.slot(r));
|
||||
const pickOne = (v: string): void => {
|
||||
const asNum = choices.some((c) => typeof c === "number" && String(c) === v);
|
||||
put(r, asNum ? { kind: "num", value: Number(v) } : { kind: "text", value: v });
|
||||
this.changed();
|
||||
};
|
||||
const opts = choices.map((c) => ({ value: String(c), text: String(c) }));
|
||||
right.replaceChildren(select(opts, String(r.value), pickOne));
|
||||
};
|
||||
drawRight();
|
||||
const l = t.left;
|
||||
const left =
|
||||
l.kind === "input"
|
||||
? select(
|
||||
inputs.map((x) => ({ value: x.이름, text: x.이름 })),
|
||||
l.name,
|
||||
(v) => (put(l, { kind: "input", name: v }), drawRight(), this.changed()),
|
||||
)
|
||||
: this.slot(l);
|
||||
const opts = OPS.map(([v, k]) => ({ value: v, text: te(k) }));
|
||||
const op = select(opts, t.op, (v) => ((t.op = v as CondOp), this.changed()));
|
||||
return [left, el("span", { text: te("Subject") }), right, op];
|
||||
}
|
||||
|
||||
/** 덩어리 하나 → 줄들 */
|
||||
block(shape: CondShape): HTMLElement[] {
|
||||
if (shape.type === "if")
|
||||
return [
|
||||
...shape.branches.map((b, i) =>
|
||||
line(te(i ? "ElseIf" : "If"), ...this.test(b.test), te("Then"), this.slot(b.value)),
|
||||
),
|
||||
line(te("Else"), this.slot(shape.otherwise)),
|
||||
];
|
||||
if (shape.type === "pick") {
|
||||
const kids = shape.items.flatMap((s, i) => (i ? [te("And"), this.slot(s)] : [this.slot(s)]));
|
||||
const opts = [
|
||||
{ value: "최대", text: te("Big") },
|
||||
{ value: "최소", text: te("Small") },
|
||||
];
|
||||
const fn = select(opts, shape.fn, (v) => ((shape.fn = v as "최소" | "최대"), this.changed()));
|
||||
return [line(...kids, fn)];
|
||||
}
|
||||
const ways = [
|
||||
{ value: "올림", text: te("Up") },
|
||||
{ value: "버림", text: te("Down") },
|
||||
{ value: "반올림", text: te("Half") },
|
||||
];
|
||||
const setPlaces = (v: number): void => (
|
||||
(shape.places = Math.max(0, Math.round(v))),
|
||||
this.changed()
|
||||
);
|
||||
const way = select(ways, shape.fn, (v) => ((shape.fn = v as "올림"), this.changed()));
|
||||
const places = numberBox(shape.places, setPlaces);
|
||||
return [line(this.slot(shape.value), te("RoundA"), places, te("RoundB"), way)];
|
||||
}
|
||||
}
|
||||
|
||||
const line = (...kids: (HTMLElement | string)[]): HTMLElement =>
|
||||
el("div", {
|
||||
className: "m01-easycond__line",
|
||||
children: kids.map((k) => (typeof k === "string" ? el("span", { text: k }) : k)),
|
||||
});
|
||||
|
||||
/** 금액 미리 보기 — 이 줄 · 합계(처음 값과 다르면 「전」) */
|
||||
function moneyView(card: EasyCard, doc: EasyDoc, trial: Record<string, unknown>) {
|
||||
const box = el("div", { className: "m01-easycond__money", text: te("Waiting") });
|
||||
const ho = doc.cards.filter(
|
||||
(c) => c.kind !== "percent" && (c.kind !== "locked" || c.source?.part === "호표"),
|
||||
);
|
||||
const at = ho.findIndex((c) => c.id === card.id);
|
||||
let first: [number | null, number | null] | null = null;
|
||||
let seq = 0;
|
||||
let timer = 0;
|
||||
const show = (ans: CalcAnswer | null, plain: string | null): void => {
|
||||
box.classList.remove("is-waiting");
|
||||
if (!ans || !ans.ok)
|
||||
return void (box.textContent = plain ?? (ans && !ans.ok ? ans.reason : ""));
|
||||
const lineSum = at >= 0 ? (ans.lines?.[at]?.금액 ?? null) : null;
|
||||
const sums = ans.sums ?? {};
|
||||
const total = sums["계"] ?? Object.values(sums).reduce((a, b) => a + b, 0);
|
||||
first ??= [lineSum, total];
|
||||
const part = (label: string, v: number | null, was: number | null): string =>
|
||||
v === null
|
||||
? ""
|
||||
: `${label} ${formatMoney(v)} 원${was !== null && was !== v ? ` (${te("Was")} ${formatMoney(was)} 원)` : ""}`;
|
||||
const parts = [part(te("Line"), lineSum, first[0]), part(te("Total"), total, first[1])];
|
||||
box.replaceChildren(...parts.filter(Boolean).map((t) => el("div", { text: t })));
|
||||
};
|
||||
const update = (text: string): void => {
|
||||
box.classList.add("is-waiting");
|
||||
const row: LogicRow = toRow(updateCard(doc, card.id, { qtyFormula: text, qty: null }));
|
||||
const mine = ++seq;
|
||||
const done = (ans: CalcAnswer | null, plain: string | null = null): void =>
|
||||
void (mine === seq && show(ans, plain));
|
||||
try {
|
||||
calcLater(row, trial, done);
|
||||
} catch {
|
||||
// Api 준비 전 — 같은 서버 계산을 0.3초 뒤 직접(최신만)
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => {
|
||||
runCalc({ key: "", inputs: trial, row }).then(
|
||||
(a) => done(a),
|
||||
() => done(null),
|
||||
);
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
return { box, update };
|
||||
}
|
||||
|
||||
/** 잠금 카드의 조건을 문장으로 고침 → 새 식 글 · 취소 · 못 읽으면 null */
|
||||
export function openEasyCond(
|
||||
card: EasyCard,
|
||||
doc: EasyDoc,
|
||||
trial: Record<string, unknown> = {},
|
||||
): Promise<string | null> {
|
||||
const read = readCond(card.qtyFormula ?? "", doc.row);
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = (v: string | null): void => {
|
||||
if (!done) ((done = true), resolve(v));
|
||||
};
|
||||
openModal({
|
||||
title: te("Title"),
|
||||
closeLabel: te("Cancel"),
|
||||
dialogClass: "m01-easycond",
|
||||
beforeClose: () => (finish(null), true),
|
||||
mount: (body, close) => {
|
||||
if (!read)
|
||||
return void body.append(
|
||||
el("p", { className: "m01-easycond__cannot", text: te("Cannot") }),
|
||||
);
|
||||
const base = flat(writeRead(read));
|
||||
const money = moneyView(card, doc, trial);
|
||||
const use = createButton({
|
||||
label: te("Use"),
|
||||
variant: "filled",
|
||||
disabled: true,
|
||||
onClick: () => (finish(writeRead(read)), close()),
|
||||
});
|
||||
const changed = (): void => {
|
||||
const text = writeRead(read);
|
||||
use.disabled = flat(text) === base;
|
||||
money.update(text);
|
||||
};
|
||||
const say = new Sentence(card, doc, changed, trial);
|
||||
const lines = el("div", { className: "m01-easycond__lines" });
|
||||
read.blocks.forEach((b, i) => {
|
||||
if (read.blocks.length > 1)
|
||||
lines.append(
|
||||
el("div", { className: "m01-easycond__part", text: `${te("Part")} ${i + 1}` }),
|
||||
);
|
||||
lines.append(...say.block(b.shape));
|
||||
});
|
||||
const cancel = createButton({
|
||||
label: te("Cancel"),
|
||||
variant: "ghost",
|
||||
onClick: () => (finish(null), close()),
|
||||
});
|
||||
const foot = el("div", { className: "m01-easycond__foot", children: [cancel, use] });
|
||||
body.append(lines, money.box, foot);
|
||||
money.update(writeRead(read));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,567 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_EasyCalc_Entry.ts
|
||||
* 쉬운 단가산출 시험 길 — 들어가는 단추 · 큰 창 틀 · 시작 화면 · 고치는 화면(위 띠) · 모든 파일 잇기
|
||||
* 모양은 `docs/raw/M01_쉬운단가/설계.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄).
|
||||
* 잇기: 열기(openEasy) → toDoc → renderCards · renderSheet · renderTrialStrip → 바뀔 때마다
|
||||
* EasyHistory.push · calcLater → 저장(새로 newEasy · 내 것 editEasy · 품셈 기본은 copyEasy 뒤 그 복사본)
|
||||
* · 지우기(usesOf 먼저 · 물음 한 번) · Ctrl+Z · Ctrl+S
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
createTag,
|
||||
el,
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import type { CalcAnswer, LogicInput } from "../M01_MasterData_UI_Logic_Api";
|
||||
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
|
||||
import {
|
||||
calcLater,
|
||||
copyEasy,
|
||||
deleteEasy,
|
||||
editEasy,
|
||||
listEasy,
|
||||
newEasy,
|
||||
openEasy,
|
||||
plainError,
|
||||
usesOf,
|
||||
type EasyListItem,
|
||||
} from "./M01_EasyCalc_Api";
|
||||
import { renderCards } from "./M01_EasyCalc_Cards";
|
||||
import {
|
||||
checkName,
|
||||
EasyHistory,
|
||||
newDoc,
|
||||
setEasyModelLanguage,
|
||||
toDoc,
|
||||
toRow,
|
||||
type EasyCard,
|
||||
type EasyDoc,
|
||||
} from "./M01_EasyCalc_Model";
|
||||
import { renderSheet, renderTrialStrip } from "./M01_EasyCalc_Sheet";
|
||||
import "./M01_EasyCalc_Style.css";
|
||||
|
||||
const TEXT = {
|
||||
Open: ["쉬운 단가산출(시험)", "Easy unit-price (trial)"],
|
||||
Title: ["쉬운 단가산출", "Easy unit-price"],
|
||||
CloseWindow: ["창 닫기", "Close window"],
|
||||
Close: ["닫기", "Close"],
|
||||
Loading: ["불러오는 중", "Loading"],
|
||||
LoadFailed: ["목록을 못 불러옴 — {v}", "Could not load the list — {v}"],
|
||||
Start_Ask: ["무엇과 비슷한가요?", "What is it similar to?"],
|
||||
Start_Search: ["이름으로 찾기 — 예: 거푸집", "Search by name — e.g. formwork"],
|
||||
Start_Blank: ["빈 종이로 새로 만들기", "Start from a blank sheet"],
|
||||
Start_Mine: ["내가 만든 것", "Made by me"],
|
||||
Start_MineNone: ["아직 없음", "None yet"],
|
||||
Start_Found: ["찾은 것", "Found"],
|
||||
Start_Count: ["{n} 개 중 {m} 개 보임 — 더 좁혀 찾기", "Showing {m} of {n} — narrow the search"],
|
||||
Start_None: ["찾은 것 없음", "Nothing found"],
|
||||
Per: ["{u} 당", "per {u}"],
|
||||
CannotOpen: ["쉬운 길로 못 엶", "Not for the easy view"],
|
||||
Name: ["이름", "Name"],
|
||||
NamePlaceholder: ["예: 거푸집 설치", "e.g. formwork setup"],
|
||||
Spec: ["크기", "Size"],
|
||||
Unit: ["단위", "Unit"],
|
||||
Total: ["{u} 만드는 데 {v} 원", "{v} won to make {u}"],
|
||||
Undo: ["되돌리기", "Undo"],
|
||||
Save: ["저장", "Save"],
|
||||
Delete: ["지우기", "Delete"],
|
||||
Unsaved: ["저장 안 함", "Not saved"],
|
||||
Saving: ["저장 중", "Saving"],
|
||||
Saved: ["저장했음", "Saved"],
|
||||
SaveFailed: ["저장 못 함", "Not saved"],
|
||||
BaseNote: [
|
||||
"품셈 기본은 읽기만 — 고치려면 아래 단추로 내 것을 만듦",
|
||||
"Standard items are read-only — use the button below to make your own copy",
|
||||
],
|
||||
BaseCopy: ["이걸 바탕으로 고치기", "Edit a copy of this"],
|
||||
Copying: ["내 것으로 만드는 중", "Making your copy"],
|
||||
Copied: ["내 것으로 만들었음 — 이제 고칠 수 있음", "Your copy is ready — you can edit it now"],
|
||||
Cards: ["무엇이 들어가나", "What goes in"],
|
||||
Sheet: ["실무 일위대가표로 보기", "View as a unit-cost sheet"],
|
||||
BadLines: [
|
||||
"⚠ 표시 줄을 먼저 고쳐 주세요 — 빈 이름 · 숫자 아님 · 0 · 0 보다 작은 수는 저장 안 됨",
|
||||
"Fix the ⚠ lines first — empty names, non-numbers, 0 or negative amounts can't be saved",
|
||||
],
|
||||
Uses_Wait: ["쓰는 곳을 찾는 중 — 10초쯤 걸림", "Looking for places that use it — about 10 s"],
|
||||
Uses_Block: ["쓰는 곳이 있어 못 지움 — {v}", "Cannot delete — it is used by {v}"],
|
||||
Delete_Ask: ["「{v}」 을(를) 지울까요? 되살릴 수 없음", "Delete “{v}”? This cannot be undone"],
|
||||
Delete_Ok: ["지우기", "Delete"],
|
||||
Deleted: ["지웠음", "Deleted"],
|
||||
Leave_Ask: [
|
||||
"저장 안 한 것이 있음 — 저장하지 않고 닫을까요?",
|
||||
"There are unsaved changes — close without saving?",
|
||||
],
|
||||
Leave_Ok: ["저장 안 하고 닫기", "Close without saving"],
|
||||
} as const;
|
||||
|
||||
type TextKey = keyof typeof TEXT;
|
||||
|
||||
const te = (key: TextKey, vars: Record<string, string | number> = {}): string =>
|
||||
Object.entries(vars).reduce(
|
||||
(text, [k, v]) => text.replace(`{${k}}`, String(v)),
|
||||
(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string,
|
||||
);
|
||||
|
||||
/** 목록에 한 번에 보이는 줄 수 */
|
||||
const SHOWN = 50;
|
||||
|
||||
/** 「원/㎡」 → 「㎡」 */
|
||||
const unitOf = (resultUnit: string): string => resultUnit.replace(/^원\//, "");
|
||||
/** 「㎡」 → 「1㎡」 · 「100m」 은 그대로 */
|
||||
const amountOf = (resultUnit: string): string => {
|
||||
const unit = unitOf(resultUnit);
|
||||
return /^\d/.test(unit) ? unit : `1${unit}`;
|
||||
};
|
||||
|
||||
/** 저장을 막는 줄 — 이름 빔 · 수량이 숫자 아님 · 0 · 음수(서버가 0 · 음수를 받아 줌) · 같은 줄 · 너무 큼은 경고만 */
|
||||
const blocksSave = (c: EasyCard): boolean =>
|
||||
!c.name.trim() ||
|
||||
(c.qtyFormula === null && !(c.qty !== null && Number.isFinite(c.qty) && c.qty > 0));
|
||||
|
||||
/** 카드를 넣어 새로 생긴 설계 값의 시험값 — 서버 자동값(`master_auto.values`)과 같은 규칙:
|
||||
* 고르기 = 첫째 · 범위 = 낮은 끝 · 그 밖 숫자 0 · 값이 「원」 인 가격 값은 비워 둠(0 원처럼 안 보이게) */
|
||||
function fillNew(
|
||||
inputs: LogicInput[],
|
||||
trial: Record<string, unknown>,
|
||||
known: Set<string>,
|
||||
): Record<string, unknown> {
|
||||
const out = { ...trial };
|
||||
for (const spec of inputs) {
|
||||
if (known.has(spec.이름)) continue;
|
||||
known.add(spec.이름);
|
||||
if (out[spec.이름] !== undefined || String(spec.단위 ?? "").includes("원")) continue;
|
||||
out[spec.이름] = spec.고르기?.length ? spec.고르기[0] : (spec.범위?.[0] ?? 0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 「추가」 옆에 놓는 단추 — 누르면 큰 창 */
|
||||
export function createEasyCalcButton(): HTMLButtonElement {
|
||||
return createButton({ label: te("Open"), variant: "filled", onClick: openEasyCalc });
|
||||
}
|
||||
|
||||
function openEasyCalc(): void {
|
||||
setEasyModelLanguage(currentLanguageIndex);
|
||||
let body: HTMLElement;
|
||||
/** 고치는 화면이 열려 있을 때만 */
|
||||
let paper: { history: EasyHistory; save: () => Promise<void>; undo: () => void } | null = null;
|
||||
let logics: EasyListItem[] | null = null;
|
||||
let query = "";
|
||||
|
||||
/** 저장 안 한 것이 있으면 한 번 물음 — 닫아도 되면 true */
|
||||
const mayLeave = async (): Promise<boolean> =>
|
||||
!paper?.history.dirty || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok"));
|
||||
|
||||
/** Ctrl+Z · Ctrl+S — 이 창 안에서만(고르기 창 등 다른 창은 제 몫) */
|
||||
const onKey = (ev: KeyboardEvent): void => {
|
||||
if (!paper || !(ev.ctrlKey || ev.metaKey)) return;
|
||||
const key = ev.key.toLowerCase();
|
||||
if (key === "z" && !ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
paper.undo();
|
||||
} else if (key === "s") {
|
||||
ev.preventDefault();
|
||||
void paper.save();
|
||||
}
|
||||
};
|
||||
|
||||
/* ---------- 시작 화면 ---------- */
|
||||
|
||||
const listRow = (item: EasyListItem): HTMLElement => {
|
||||
const row = el("button", {
|
||||
className: "m01-easycalc__item",
|
||||
attrs: { type: "button", title: item.where },
|
||||
children: [
|
||||
el("span", { className: "m01-easycalc__item-name", text: item.name }),
|
||||
createTag(item.owner, item.isBase ? "neutral" : "accent"),
|
||||
el("span", {
|
||||
className: "m01-easycalc__item-unit",
|
||||
text: item.canOpen ? te("Per", { u: amountOf(item.resultUnit) }) : item.resultUnit,
|
||||
}),
|
||||
...(item.canOpen ? [] : [createTag(te("CannotOpen"), "warning")]),
|
||||
],
|
||||
});
|
||||
if (item.whyNot) row.title = item.whyNot;
|
||||
row.disabled = !item.canOpen;
|
||||
row.addEventListener("click", () => void openPaper(item.key));
|
||||
return row;
|
||||
};
|
||||
|
||||
const showStart = async (): Promise<void> => {
|
||||
paper = null;
|
||||
const status = el("p", { className: "m01-easycalc__muted", text: te("Loading") });
|
||||
const mineHost = el("div", { className: "m01-easycalc__list" });
|
||||
const foundHost = el("div", { className: "m01-easycalc__list" });
|
||||
const search = createInputField({
|
||||
type: "search",
|
||||
placeholder: te("Start_Search"),
|
||||
value: query,
|
||||
onInput: (v) => {
|
||||
query = v;
|
||||
drawFound();
|
||||
},
|
||||
});
|
||||
search.root.classList.add("m01-easycalc__search");
|
||||
|
||||
const drawFound = (): void => {
|
||||
if (!logics) return;
|
||||
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
const hits = words.length
|
||||
? logics.filter((x) => words.every((w) => x.name.toLowerCase().includes(w)))
|
||||
: logics;
|
||||
status.textContent = !hits.length
|
||||
? te("Start_None")
|
||||
: hits.length > SHOWN
|
||||
? te("Start_Count", { n: hits.length.toLocaleString(), m: SHOWN })
|
||||
: "";
|
||||
foundHost.replaceChildren(...hits.slice(0, SHOWN).map(listRow));
|
||||
};
|
||||
|
||||
body.replaceChildren(
|
||||
el("div", {
|
||||
className: "m01-easycalc__start",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-easycalc__start-col",
|
||||
children: [
|
||||
el("h3", { className: "m01-easycalc__ask", text: te("Start_Ask") }),
|
||||
search.root,
|
||||
el("h4", { text: te("Start_Found") }),
|
||||
status,
|
||||
foundHost,
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "m01-easycalc__start-col m01-easycalc__start-side",
|
||||
children: [
|
||||
createBigButton(te("Start_Blank"), () => openBlank()),
|
||||
el("h4", { text: te("Start_Mine") }),
|
||||
mineHost,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
search.input.focus();
|
||||
|
||||
try {
|
||||
logics ??= await listEasy("");
|
||||
} catch (error) {
|
||||
status.textContent = te("LoadFailed", { v: plainError(error) });
|
||||
return;
|
||||
}
|
||||
const mine = logics.filter((x) => !x.isBase);
|
||||
mineHost.replaceChildren(
|
||||
...(mine.length
|
||||
? mine.map(listRow)
|
||||
: [el("p", { className: "m01-easycalc__muted", text: te("Start_MineNone") })]),
|
||||
);
|
||||
drawFound();
|
||||
};
|
||||
|
||||
/** 단위 고르기 후보 — 목록에 다섯 번 넘게 쓰인 「원/…」 차례 · 설명 붙은 드문 단위는 뺌(연 로직 것은 따로 더함) */
|
||||
const unitChoices = (): string[] => {
|
||||
const count = new Map<string, number>();
|
||||
for (const x of logics ?? []) {
|
||||
if (x.canOpen) count.set(x.resultUnit, (count.get(x.resultUnit) ?? 0) + 1);
|
||||
}
|
||||
return [...count.entries()]
|
||||
.filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u))
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([u]) => u);
|
||||
};
|
||||
|
||||
/* ---------- 고치는 화면 ---------- */
|
||||
|
||||
const openBlank = (): void => showPaper(newDoc("", unitOf(unitChoices()[0] ?? "원/㎡")), {});
|
||||
|
||||
const openPaper = async (key: string): Promise<void> => {
|
||||
body.replaceChildren(el("p", { className: "m01-easycalc__muted", text: te("Loading") }));
|
||||
try {
|
||||
const opened = await openEasy(key);
|
||||
showPaper(
|
||||
toDoc(opened.row, { key, version: opened.version, isBase: opened.isBase }),
|
||||
opened.trial,
|
||||
);
|
||||
} catch (error) {
|
||||
showToast(plainError(error), "error");
|
||||
void showStart();
|
||||
}
|
||||
};
|
||||
|
||||
const showPaper = (first: EasyDoc, firstTrial: Record<string, unknown>): void => {
|
||||
let doc = first;
|
||||
let trial = firstTrial;
|
||||
let ans: CalcAnswer | null = null;
|
||||
let history = new EasyHistory();
|
||||
history.push(doc);
|
||||
let busy = false;
|
||||
|
||||
const unsaved = createTag(te("Unsaved"), "warning");
|
||||
const total = el("div", { className: "m01-easycalc__total" });
|
||||
const note = el("p", { className: "m01-easycalc__note", attrs: { role: "status" } });
|
||||
const trialHost = el("div", { className: "m01-easycalc__part m01-easycalc__trial" });
|
||||
const cardsHost = el("div", { className: "m01-easycalc__cards" });
|
||||
const sheetHost = el("div");
|
||||
let trialShape = "";
|
||||
/** 연 때 있던 설계 값 — 서버 자동값을 그대로 둠 · 이 밖에 새로 생긴 값만 채움 */
|
||||
const known = new Set(doc.inputs.map((i) => i.이름));
|
||||
|
||||
const name = createInputField({
|
||||
label: te("Name"),
|
||||
placeholder: te("NamePlaceholder"),
|
||||
onInput: (v) => edit({ ...doc, name: v }),
|
||||
});
|
||||
name.root.classList.add("m01-easycalc__name");
|
||||
const spec = createInputField({ label: te("Spec"), onInput: (v) => edit({ ...doc, spec: v }) });
|
||||
const unit = createSelectField({
|
||||
label: te("Unit"),
|
||||
options: [],
|
||||
onChange: (v) => edit({ ...doc, resultUnit: v }),
|
||||
});
|
||||
const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: () => doUndo() });
|
||||
const saveButton = createButton({ label: te("Save"), onClick: () => void doSave() });
|
||||
const deleteButton = createButton({
|
||||
label: te("Delete"),
|
||||
variant: "danger",
|
||||
onClick: () => void doDelete(),
|
||||
});
|
||||
const close = createButton({
|
||||
label: te("Close"),
|
||||
variant: "ghost",
|
||||
onClick: () =>
|
||||
void (async () => {
|
||||
if (await mayLeave()) void showStart();
|
||||
})(),
|
||||
});
|
||||
const actions = el("div", { className: "m01-easycalc__actions" });
|
||||
const baseBar = el("div", {
|
||||
className: "m01-easycalc__base",
|
||||
children: [
|
||||
el("p", { text: te("BaseNote") }),
|
||||
createBigButton(te("BaseCopy"), () => void doCopy()),
|
||||
],
|
||||
});
|
||||
|
||||
/** 위 띠 칸을 doc 로 — 열 때 · 되돌릴 때 · 저장 뒤 */
|
||||
const fillStrip = (): void => {
|
||||
const units = unitChoices();
|
||||
if (!units.includes(doc.resultUnit)) units.unshift(doc.resultUnit);
|
||||
unit.setOptions(
|
||||
units.map((u) => ({ value: u, text: unitOf(u) })),
|
||||
doc.resultUnit,
|
||||
);
|
||||
name.input.value = doc.name;
|
||||
spec.input.value = doc.spec;
|
||||
for (const box of [name.input, spec.input, unit.select]) box.disabled = doc.isBase;
|
||||
actions.replaceChildren(
|
||||
...(doc.isBase ? [] : [undo, saveButton]),
|
||||
...(doc.key && !doc.isBase ? [deleteButton] : []),
|
||||
close,
|
||||
);
|
||||
baseBar.hidden = !doc.isBase;
|
||||
};
|
||||
|
||||
/** 금액 · 카드 · 표 — 바뀔 때마다(pending) · 계산 답이 올 때마다(answer) */
|
||||
const draw = (pending: boolean, answer = false): void => {
|
||||
const value = ans?.ok ? formatMoney(ans.result ?? ans.sums?.["계"] ?? 0) : "—";
|
||||
total.textContent = te("Total", { u: amountOf(doc.resultUnit), v: value });
|
||||
total.classList.toggle("is-pending", pending);
|
||||
sheetHost.classList.toggle("is-pending", pending);
|
||||
unsaved.hidden = !history.dirty;
|
||||
undo.disabled = !history.canUndo;
|
||||
const shape = JSON.stringify(doc.inputs);
|
||||
if (shape !== trialShape) {
|
||||
trialShape = shape;
|
||||
trial = fillNew(doc.inputs, trial, known);
|
||||
renderTrialStrip(trialHost, doc.inputs, trial, (next) => {
|
||||
trial = next;
|
||||
calc();
|
||||
});
|
||||
}
|
||||
drawCards(answer);
|
||||
renderSheet(sheetHost, { name: doc.name, resultUnit: doc.resultUnit }, ans);
|
||||
};
|
||||
|
||||
/** 계산 답만 왔을 때 카드 칸에 글을 넣는 중이면 그 칸을 떠난 뒤 그림(넣던 글이 안 날아가게).
|
||||
* doc 가 바뀌면 늘 바로 그림 — 카드가 옛 doc 를 쥐고 있으면 그다음 조작이 옛 판본 위에 쌓임 */
|
||||
let waiting = false;
|
||||
const drawCards = (answer = false): void => {
|
||||
const active = document.activeElement;
|
||||
if (answer && active instanceof HTMLInputElement && cardsHost.contains(active)) {
|
||||
if (!waiting) {
|
||||
waiting = true;
|
||||
cardsHost.addEventListener(
|
||||
"focusout",
|
||||
() =>
|
||||
setTimeout(() => {
|
||||
waiting = false;
|
||||
drawCards(true);
|
||||
}),
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
renderCards(cardsHost, doc, ans, { change: edit, trial: () => trial });
|
||||
};
|
||||
|
||||
const calc = (): void => {
|
||||
draw(true);
|
||||
calcLater(toRow(doc), trial, (next, plain) => {
|
||||
ans = next;
|
||||
note.textContent = plain ?? "";
|
||||
draw(false, true);
|
||||
});
|
||||
};
|
||||
|
||||
const edit = (next: EasyDoc): void => {
|
||||
doc = next;
|
||||
history.push(doc);
|
||||
calc();
|
||||
};
|
||||
|
||||
const doUndo = (): void => {
|
||||
const back = history.undo();
|
||||
if (!back) return;
|
||||
doc = back;
|
||||
fillStrip();
|
||||
calc();
|
||||
};
|
||||
|
||||
/** 저장 뒤 · 본뜬 뒤 — 서버가 준 줄로 다시 엶 · 되돌리기 기록은 여기서 새로 */
|
||||
const reopen = (next: EasyDoc): void => {
|
||||
doc = next;
|
||||
history = new EasyHistory();
|
||||
history.push(doc);
|
||||
logics = null;
|
||||
fillStrip();
|
||||
calc();
|
||||
};
|
||||
|
||||
const doSave = async (): Promise<void> => {
|
||||
if (doc.isBase || busy) return;
|
||||
const mine = (logics ?? []).filter((x) => !x.isBase && x.key !== doc.key).map((x) => x.name);
|
||||
const bad = checkName(doc.name, mine);
|
||||
name.setError(bad ?? undefined);
|
||||
if (bad) return void name.input.focus();
|
||||
if (doc.cards.some(blocksSave)) return void (note.textContent = te("BadLines"));
|
||||
busy = true;
|
||||
saveButton.disabled = true;
|
||||
note.textContent = te("Saving");
|
||||
try {
|
||||
const row = toRow(doc);
|
||||
const saved =
|
||||
doc.key && doc.version ? await editEasy(doc.key, doc.version, row) : await newEasy(row);
|
||||
reopen(toDoc(saved.row, { key: saved.key, version: saved.version, isBase: false }));
|
||||
note.textContent = "";
|
||||
showToast(te("Saved"), "success");
|
||||
} catch (error) {
|
||||
note.textContent = `${te("SaveFailed")} — ${plainError(error)}`;
|
||||
} finally {
|
||||
busy = false;
|
||||
saveButton.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
const doCopy = async (): Promise<void> => {
|
||||
if (!doc.key || busy) return;
|
||||
busy = true;
|
||||
note.textContent = te("Copying");
|
||||
try {
|
||||
const saved = await copyEasy(doc.key);
|
||||
reopen(toDoc(saved.row, { key: saved.key, version: saved.version, isBase: false }));
|
||||
showToast(te("Copied"), "success");
|
||||
} catch (error) {
|
||||
note.textContent = plainError(error);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
};
|
||||
|
||||
const doDelete = async (): Promise<void> => {
|
||||
if (!doc.key || !doc.version || doc.isBase || busy) return;
|
||||
busy = true;
|
||||
deleteButton.disabled = true;
|
||||
note.textContent = te("Uses_Wait");
|
||||
try {
|
||||
const uses = await usesOf(doc.key);
|
||||
const blocking = uses.filter((u) => u.key);
|
||||
if (blocking.length) {
|
||||
note.textContent = te("Uses_Block", {
|
||||
v: blocking.map((u) => (u.where ? `${u.where} ${u.name}` : u.name)).join(", "),
|
||||
});
|
||||
return;
|
||||
}
|
||||
note.textContent = uses.map((u) => u.name).join(" · ");
|
||||
if (!(await showConfirmDialog(te("Delete_Ask", { v: doc.name }), te("Delete_Ok")))) {
|
||||
return;
|
||||
}
|
||||
await deleteEasy(doc.key, doc.version);
|
||||
logics = null;
|
||||
paper = null;
|
||||
showToast(te("Deleted"), "success");
|
||||
void showStart();
|
||||
} catch (error) {
|
||||
note.textContent = plainError(error);
|
||||
} finally {
|
||||
busy = false;
|
||||
deleteButton.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
paper = {
|
||||
get history() {
|
||||
return history;
|
||||
},
|
||||
save: doSave,
|
||||
undo: doUndo,
|
||||
};
|
||||
|
||||
body.replaceChildren(
|
||||
el("div", {
|
||||
className: "m01-easycalc__strip",
|
||||
children: [name.root, spec.root, unit.root, total, unsaved, actions],
|
||||
}),
|
||||
note,
|
||||
baseBar,
|
||||
trialHost,
|
||||
el("section", {
|
||||
className: "m01-easycalc__part",
|
||||
children: [el("h4", { text: te("Cards") }), cardsHost],
|
||||
}),
|
||||
el("details", {
|
||||
className: "m01-easycalc__part m01-easycalc__sheet",
|
||||
children: [el("summary", { text: te("Sheet") }), sheetHost],
|
||||
}),
|
||||
);
|
||||
fillStrip();
|
||||
calc();
|
||||
if (!doc.isBase) name.input.focus();
|
||||
};
|
||||
|
||||
// 화면 함수들을 다 만든 뒤에 엶 — mount 가 바로 showStart 를 부름
|
||||
openModal({
|
||||
title: te("Title"),
|
||||
closeLabel: te("CloseWindow"),
|
||||
dialogClass: "m01-easycalc",
|
||||
beforeClose: mayLeave,
|
||||
mount: (host) => {
|
||||
body = host;
|
||||
(host.parentElement ?? host).addEventListener("keydown", onKey);
|
||||
void showStart();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createBigButton(label: string, onClick: () => void): HTMLButtonElement {
|
||||
const button = createButton({ label, variant: "filled", onClick });
|
||||
button.classList.add("m01-easycalc__big");
|
||||
return button;
|
||||
}
|
||||
@@ -1,587 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_EasyCalc_Model.ts
|
||||
* 쉬운 단가산출 Model — 로직 줄(LogicRow) ↔ 카드(EasyDoc · EasyCard) 바꾸기 · 카드 조작 · 검사 · 되돌리기
|
||||
* DOM 없음 · 바깥 값은 타입만 가져옴(Node 시험이 이 파일을 바로 돌림) · 모양은 `설계.md` 3절 그대로
|
||||
*
|
||||
* 호표 줄 → 카드: 인력 = person · 재료 = material · 기계경비 로직(GC000267~276) 부름 = machine · 그 밖 로직 = logic
|
||||
* 수량이 숫자 글이면 qty · 아니면 qtyFormula + lockedWhy(쉬운 말)
|
||||
* 덧줄 → 카드: 「바탕 * 숫자 / 100」 + 바탕 = 노무비 · 재료비 · 경비 또는 사람 줄 노임 합 = percent · 그 밖 = locked
|
||||
* 카드 → 줄: 카드 차례대로 호표 · 덧줄을 다시 짬 · 안 바뀐 줄은 원래 글자 그대로(왕복 같음) · 안 다룬 칸은 raw 에서 살림
|
||||
* 글 [한, 영] — 화면이 `setEasyModelLanguage(currentLanguageIndex)` 로 맞춤
|
||||
* ========================================================================== */
|
||||
|
||||
import type { HoLine, LogicInput, LogicRow, NamedFormula } from "../M01_MasterData_UI_Logic_Api";
|
||||
|
||||
export type CardKind = "person" | "material" | "machine" | "logic" | "percent" | "locked";
|
||||
export type CostKind = "노무비" | "재료비" | "경비";
|
||||
|
||||
export interface EasyCard {
|
||||
/** 화면 번호 · 저장 안 함 */
|
||||
id: string;
|
||||
kind: CardKind;
|
||||
/** 보이는 이름(호표 · 덧줄 이름) */
|
||||
name: string;
|
||||
/** 크기(규격) · 없으면 "" */
|
||||
spec: string;
|
||||
/** 인 · ㎥ · 시간 · % … */
|
||||
unit: string;
|
||||
/** 숫자 수량 · 식이면 null */
|
||||
qty: number | null;
|
||||
/** 잠금 식(원문 그대로) · 숫자면 null */
|
||||
qtyFormula: string | null;
|
||||
/** 넣을 자리 · 다른 일위대가 · 기계는 null(엔진이 나눔) */
|
||||
cost: CostKind | null;
|
||||
/** 덧붙임 바탕 · 덧붙임 아니면 null */
|
||||
percentBase: CostKind | "사람줄합" | null;
|
||||
/** 호표 「요소」 칸 값 그대로(인력 LB 키 · 재료 검색어 묶음 · 로직 부름 글) */
|
||||
element: unknown;
|
||||
/** 잠금 까닭 쉬운 말 · 아니면 null */
|
||||
lockedWhy: string | null;
|
||||
/** 원래 자리 · 새 카드 null */
|
||||
source: { part: "호표" | "덧줄"; index: number } | null;
|
||||
/** 원래 줄 통째 — 카드가 안 다룬 칸을 저장 때 그대로 살림 */
|
||||
raw: unknown;
|
||||
}
|
||||
|
||||
export interface EasyDoc {
|
||||
/** 새 로직 null */
|
||||
key: string | null;
|
||||
/** 판본(409 대조) */
|
||||
version: string | null;
|
||||
/** 품셈 기본 = 읽기만 */
|
||||
isBase: boolean;
|
||||
name: string;
|
||||
spec: string;
|
||||
/** 결과단위 「원/㎡」 */
|
||||
resultUnit: string;
|
||||
/** 설계 때 정하는 값 */
|
||||
inputs: LogicInput[];
|
||||
cards: EasyCard[];
|
||||
/** 원래 로직 줄(새 로직은 최소 칸 줄) */
|
||||
row: LogicRow;
|
||||
}
|
||||
|
||||
/** 물음 셋 고르기 결과 — Pick 이 가져다 씀 */
|
||||
export interface PickResult {
|
||||
kind: "person" | "material" | "machine" | "logic";
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
element: unknown;
|
||||
qty: number | null;
|
||||
needInputs?: LogicInput[];
|
||||
}
|
||||
|
||||
/* ── 글 ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const TEXT = {
|
||||
WhyTable: ["품셈 표에서 찾는 값", "Looked up from the standard table"],
|
||||
WhyCond: ["조건에 따라 달라지는 값", "Changes with conditions"],
|
||||
WhyInput: ["설계 때 정하는 값으로 정해짐", "Set by values chosen at design time"],
|
||||
WhyCalc: ["계산으로 정해지는 값", "Worked out by a calculation"],
|
||||
WhyKind: ["쉬운 길에서 못 고치는 줄", "This line can't be changed here"],
|
||||
NoNumber: ["얼마인지 숫자로 넣어야 함", "Enter the amount as a number"],
|
||||
Zero: ["0 은 안 됨 — 빼려면 지움", "0 is not allowed — remove the line instead"],
|
||||
Negative: ["0 보다 큰 수만 됨", "Only numbers above 0"],
|
||||
NoName: ["이름이 비어 있음", "The name is empty"],
|
||||
SameLine: ["같은 줄이 이미 있음 — 한 줄에 합쳐 넣음", "Same line already exists — merge them"],
|
||||
SameName: ["내가 만든 것에 같은 이름이 있음", "One of yours already has this name"],
|
||||
/** {per} {qty} {unit} 을 채움 */
|
||||
TooBig: ["{per} 에 {qty}{unit} — 너무 큰 수 아닌지?", "{qty}{unit} for {per} — too large?"],
|
||||
} as const;
|
||||
|
||||
let lang = 0;
|
||||
|
||||
/** 화면 말 고르기 — 0 한국어 · 1 영어 */
|
||||
export function setEasyModelLanguage(index: number): void {
|
||||
lang = index === 1 ? 1 : 0;
|
||||
}
|
||||
|
||||
const tx = (key: keyof typeof TEXT): string => TEXT[key][lang];
|
||||
|
||||
/* ── 작은 도움 ──────────────────────────────────────────────────────────── */
|
||||
|
||||
const COSTS: readonly string[] = ["노무비", "재료비", "경비"];
|
||||
const isCost = (v: unknown): v is CostKind => typeof v === "string" && COSTS.includes(v);
|
||||
/** 기계경비 로직 — `로직_건설품셈_08장_건설기계.json` GC000267~276 */
|
||||
const MACHINE_CALL = /^\s*로직\(\s*GC0002(6[7-9]|7[0-6])\s*[,)]/;
|
||||
const NUMBER_TEXT = /^\s*-?\d+(\.\d+)?\s*$/;
|
||||
const SIMPLE_TERM = /^[가-힣A-Za-z0-9_.]+$/;
|
||||
|
||||
let seq = 0;
|
||||
const newId = (): string => `c${++seq}`;
|
||||
|
||||
const clone = <T>(v: T): T => (v === undefined ? v : (JSON.parse(JSON.stringify(v)) as T));
|
||||
const isObj = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
const norm = (s: string): string => s.replace(/\s+/g, "");
|
||||
|
||||
/** 수를 글로 — 지수 꼴 없이 · 뒤 0 뗌 */
|
||||
function numText(n: number): string {
|
||||
const s = String(Number(n.toPrecision(12)));
|
||||
return s.includes("e") ? n.toFixed(12).replace(/\.?0+$/, "") : s;
|
||||
}
|
||||
|
||||
/** 바깥을 통째 감싼 괄호 하나 벗김 */
|
||||
function unwrap(s: string): string {
|
||||
const t = s.trim();
|
||||
if (!t.startsWith("(") || !t.endsWith(")")) return t;
|
||||
let depth = 0;
|
||||
for (let i = 0; i < t.length; i++) {
|
||||
if (t[i] === "(") depth++;
|
||||
else if (t[i] === ")" && --depth === 0 && i < t.length - 1) return t;
|
||||
}
|
||||
return t.slice(1, -1).trim();
|
||||
}
|
||||
|
||||
/** 괄호 밖의 + 로 나눔 */
|
||||
function splitPlus(s: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let cur = "";
|
||||
for (const ch of s) {
|
||||
if (ch === "(") depth++;
|
||||
if (ch === ")") depth--;
|
||||
if (ch === "+" && depth === 0) {
|
||||
out.push(cur.trim());
|
||||
cur = "";
|
||||
} else cur += ch;
|
||||
}
|
||||
out.push(cur.trim());
|
||||
return out;
|
||||
}
|
||||
|
||||
const hasName = (formula: string, name: string): boolean => {
|
||||
const esc = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return new RegExp(`(^|[^가-힣A-Za-z0-9_.'])${esc}($|[^가-힣A-Za-z0-9_'])`).test(formula);
|
||||
};
|
||||
|
||||
/** 잠금 까닭 — 표에서 찾음 > 조건 > 설계 값 > 계산 · 중간 값은 안까지 따라감 */
|
||||
function lockedWhy(formula: string, row: LogicRow): string {
|
||||
const seen = new Set<string>();
|
||||
const flags = { table: false, cond: false, input: false };
|
||||
const walk = (f: string, depth: number): void => {
|
||||
if (/찾기\s*\(/.test(f)) flags.table = true;
|
||||
if (/(만약|최소|최대|올림|버림|반올림)\s*\(|[<>]|==|!=/.test(f)) flags.cond = true;
|
||||
for (const i of row.입력 ?? []) if (hasName(f, i.이름)) flags.input = true;
|
||||
if (depth > 6) return;
|
||||
for (const m of row.중간 ?? []) {
|
||||
if (seen.has(m.이름) || !hasName(f, m.이름)) continue;
|
||||
seen.add(m.이름);
|
||||
walk(m.식, depth + 1);
|
||||
}
|
||||
};
|
||||
walk(formula, 0);
|
||||
if (flags.table) return tx("WhyTable");
|
||||
if (flags.cond) return tx("WhyCond");
|
||||
if (flags.input) return tx("WhyInput");
|
||||
return tx("WhyCalc");
|
||||
}
|
||||
|
||||
/* ── 덧붙임(%) 꼴 ───────────────────────────────────────────────────────── */
|
||||
|
||||
/** 사람 줄 하나의 노임 조각 — 「수량 * 요소」 */
|
||||
function personTerm(qtyText: string, element: unknown): string {
|
||||
const q = SIMPLE_TERM.test(qtyText.trim()) ? qtyText.trim() : `(${qtyText.trim()})`;
|
||||
return `${q} * ${String(element)}`;
|
||||
}
|
||||
|
||||
/** 「바탕 * 숫자 / 100」 → 바탕 글 · 율 · 아니면 null */
|
||||
function parsePercent(formula: string): { base: string; rate: number } | null {
|
||||
const m = /^([\s\S]*)\*\s*(\d+(?:\.\d+)?)\s*\/\s*100\s*$/.exec(formula.trim());
|
||||
if (!m || !m[1].trim()) return null;
|
||||
return { base: unwrap(m[1]), rate: Number(m[2]) };
|
||||
}
|
||||
|
||||
/** 바탕이 사람 줄 노임 합인가 — 사람 줄 모두 · 차례대로 · 빠짐없이 */
|
||||
function isPersonSum(base: string, persons: { qtyText: string; element: unknown }[]): boolean {
|
||||
if (persons.length === 0) return false;
|
||||
const terms = splitPlus(base);
|
||||
if (terms.length !== persons.length) return false;
|
||||
return terms.every((t, i) => {
|
||||
const p = persons[i];
|
||||
const want = personTerm(p.qtyText, p.element);
|
||||
return norm(t) === norm(want) || norm(t) === norm(`${p.qtyText} * ${String(p.element)}`);
|
||||
});
|
||||
}
|
||||
|
||||
function baseOf(
|
||||
base: string,
|
||||
persons: { qtyText: string; element: unknown }[],
|
||||
): EasyCard["percentBase"] {
|
||||
if (isCost(base)) return base;
|
||||
return isPersonSum(base, persons) ? "사람줄합" : null;
|
||||
}
|
||||
|
||||
/* ── 로직 줄 → 카드 ─────────────────────────────────────────────────────── */
|
||||
|
||||
const qtyTextOf = (line: HoLine): string => String(line.수량 ?? "");
|
||||
|
||||
function hoCard(line: HoLine, index: number, row: LogicRow): EasyCard {
|
||||
const kind: CardKind =
|
||||
line.종류 === "인력"
|
||||
? "person"
|
||||
: line.종류 === "재료"
|
||||
? "material"
|
||||
: line.종류 === "로직"
|
||||
? MACHINE_CALL.test(String(line.요소))
|
||||
? "machine"
|
||||
: "logic"
|
||||
: "locked";
|
||||
const qtyText = qtyTextOf(line);
|
||||
const numeric = NUMBER_TEXT.test(qtyText);
|
||||
return {
|
||||
id: newId(),
|
||||
kind,
|
||||
name: line.이름 ?? "",
|
||||
spec: line.규격 ?? "",
|
||||
unit: line.단위 ?? "",
|
||||
qty: numeric ? Number(qtyText) : null,
|
||||
qtyFormula: numeric ? null : qtyText,
|
||||
cost: isCost(line.비목) ? line.비목 : null,
|
||||
percentBase: null,
|
||||
element: clone(line.요소),
|
||||
lockedWhy: kind === "locked" ? tx("WhyKind") : numeric ? null : lockedWhy(qtyText, row),
|
||||
source: { part: "호표", index },
|
||||
raw: clone(line),
|
||||
};
|
||||
}
|
||||
|
||||
function addCardOf(
|
||||
line: NamedFormula,
|
||||
index: number,
|
||||
row: LogicRow,
|
||||
persons: { qtyText: string; element: unknown }[],
|
||||
): EasyCard {
|
||||
const p = parsePercent(line.식);
|
||||
const base = p ? baseOf(p.base, persons) : null;
|
||||
const percent = p !== null && base !== null;
|
||||
return {
|
||||
id: newId(),
|
||||
kind: percent ? "percent" : "locked",
|
||||
name: line.이름 ?? "",
|
||||
spec: "",
|
||||
unit: percent ? "%" : "",
|
||||
qty: percent ? p.rate : null,
|
||||
qtyFormula: percent ? null : line.식,
|
||||
cost: isCost(line.비목) ? line.비목 : null,
|
||||
percentBase: percent ? base : null,
|
||||
element: null,
|
||||
lockedWhy: percent ? null : lockedWhy(line.식, row),
|
||||
source: { part: "덧줄", index },
|
||||
raw: clone(line),
|
||||
};
|
||||
}
|
||||
|
||||
export function toDoc(
|
||||
row: LogicRow,
|
||||
meta: { key: string | null; version: string | null; isBase: boolean },
|
||||
): EasyDoc {
|
||||
const ho = row.호표 ?? [];
|
||||
const persons = ho
|
||||
.filter((h) => h.종류 === "인력")
|
||||
.map((h) => ({ qtyText: qtyTextOf(h), element: h.요소 }));
|
||||
return {
|
||||
key: meta.key,
|
||||
version: meta.version,
|
||||
isBase: meta.isBase,
|
||||
name: row.이름 ?? "",
|
||||
spec: typeof row["규격"] === "string" ? (row["규격"] as string) : "",
|
||||
resultUnit: row.결과단위 ?? "",
|
||||
inputs: clone(row.입력 ?? []),
|
||||
cards: [
|
||||
...ho.map((h, i) => hoCard(h, i, row)),
|
||||
...(row.덧줄 ?? []).map((d, i) => addCardOf(d, i, row, persons)),
|
||||
],
|
||||
row: clone(row),
|
||||
};
|
||||
}
|
||||
|
||||
/** 새 로직의 최소 칸 줄 — 검토 A 4-1 · 구분 · 상세구분 · 소유 · 키는 서버가 채움 */
|
||||
export function emptyRow(name = "", per = "㎡"): LogicRow {
|
||||
return {
|
||||
키: "",
|
||||
원문번호: "",
|
||||
이름: name,
|
||||
결과단위: `원/${per}`,
|
||||
출처: "자체",
|
||||
입력: [],
|
||||
중간: [],
|
||||
호표: [],
|
||||
덧줄: [],
|
||||
끝수: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 빈 종이로 새로 */
|
||||
export function newDoc(name = "", per = "㎡"): EasyDoc {
|
||||
return toDoc(emptyRow(name, per), { key: null, version: null, isBase: false });
|
||||
}
|
||||
|
||||
/* ── 카드 → 로직 줄 ─────────────────────────────────────────────────────── */
|
||||
|
||||
const partOf = (c: EasyCard): "호표" | "덧줄" =>
|
||||
c.kind === "percent" ? "덧줄" : c.kind === "locked" ? (c.source?.part ?? "덧줄") : "호표";
|
||||
|
||||
const TYPE_OF: Record<string, string> = {
|
||||
person: "인력",
|
||||
material: "재료",
|
||||
machine: "로직",
|
||||
logic: "로직",
|
||||
};
|
||||
|
||||
/** 수량 글 — 식이면 식 · 숫자가 원래 글과 같으면 원래 글(「0.50」 보존) */
|
||||
function qtyTextFor(c: EasyCard, rawText: unknown): string {
|
||||
if (c.qtyFormula !== null) return c.qtyFormula;
|
||||
if (c.qty === null || !Number.isFinite(c.qty)) return "";
|
||||
const keep =
|
||||
typeof rawText === "string" && NUMBER_TEXT.test(rawText) && Number(rawText) === c.qty;
|
||||
return keep ? (rawText as string) : numText(c.qty);
|
||||
}
|
||||
|
||||
/** 칸 쓰기 — 원래 있었거나 값이 있을 때만(없던 빈 칸을 만들지 않음) */
|
||||
function setField(o: Record<string, unknown>, k: string, v: string): void {
|
||||
if (v !== "" || k in o) o[k] = v;
|
||||
}
|
||||
|
||||
function hoLineOf(c: EasyCard): HoLine {
|
||||
const raw = isObj(c.raw) ? clone(c.raw) : {};
|
||||
const line: Record<string, unknown> = raw;
|
||||
if (c.kind !== "locked") line.종류 = TYPE_OF[c.kind];
|
||||
line.요소 = clone(c.element);
|
||||
setField(line, "이름", c.name);
|
||||
setField(line, "규격", c.spec);
|
||||
setField(line, "단위", c.unit);
|
||||
line.수량 = qtyTextFor(c, raw.수량);
|
||||
if (c.cost !== null) line.비목 = c.cost;
|
||||
else if (isCost(raw.비목)) delete line.비목;
|
||||
return line as unknown as HoLine;
|
||||
}
|
||||
|
||||
function percentText(c: EasyCard, persons: { qtyText: string; element: unknown }[]): string {
|
||||
const rate = numText(c.qty ?? 0);
|
||||
if (c.percentBase === "사람줄합") {
|
||||
const terms = persons.map((p) => personTerm(p.qtyText, p.element));
|
||||
return `(${terms.length ? terms.join(" + ") : "0"}) * ${rate} / 100`;
|
||||
}
|
||||
return `${c.percentBase ?? "노무비"} * ${rate} / 100`;
|
||||
}
|
||||
|
||||
function addLineOf(c: EasyCard, persons: { qtyText: string; element: unknown }[]): NamedFormula {
|
||||
const raw = isObj(c.raw) ? clone(c.raw) : {};
|
||||
const line: Record<string, unknown> = raw;
|
||||
line.이름 = c.name;
|
||||
const rawText = typeof raw.식 === "string" ? raw.식 : "";
|
||||
if (c.kind === "percent") {
|
||||
const p = parsePercent(rawText);
|
||||
const same = p !== null && p.rate === c.qty && baseOf(p.base, persons) === c.percentBase;
|
||||
line.식 = same ? rawText : percentText(c, persons);
|
||||
} else {
|
||||
line.식 = c.qtyFormula ?? (c.qty !== null ? numText(c.qty) : rawText);
|
||||
}
|
||||
if (c.cost !== null) line.비목 = c.cost;
|
||||
else if (isCost(raw.비목)) delete line.비목;
|
||||
return line as unknown as NamedFormula;
|
||||
}
|
||||
|
||||
export function toRow(doc: EasyDoc): LogicRow {
|
||||
const row = clone(doc.row) as LogicRow & Record<string, unknown>;
|
||||
row.이름 = doc.name;
|
||||
setField(row, "규격", doc.spec);
|
||||
row.결과단위 = doc.resultUnit;
|
||||
row.입력 = clone(doc.inputs);
|
||||
const ho = doc.cards.filter((c) => partOf(c) === "호표").map(hoLineOf);
|
||||
const persons = ho
|
||||
.filter((h) => h.종류 === "인력")
|
||||
.map((h) => ({ qtyText: qtyTextOf(h), element: h.요소 }));
|
||||
const add = doc.cards.filter((c) => partOf(c) === "덧줄").map((c) => addLineOf(c, persons));
|
||||
if (ho.length || "호표" in row) row.호표 = ho;
|
||||
if (add.length || "덧줄" in row) row.덧줄 = add;
|
||||
return row;
|
||||
}
|
||||
|
||||
/* ── 카드 조작 (모두 새 문서를 돌려줌 · 받은 문서는 안 바꿈) ─────────────── */
|
||||
|
||||
const COST_OF: Record<PickResult["kind"], CostKind | null> = {
|
||||
person: "노무비",
|
||||
material: "재료비",
|
||||
machine: null,
|
||||
logic: null,
|
||||
};
|
||||
|
||||
/** 묶음 — 화면의 묶음과 같음(사람 · 재료 · 기계 · 다른 일위대가 · 덧붙임) */
|
||||
const groupOf = (c: EasyCard): string => (partOf(c) === "덧줄" ? "덧붙임" : c.kind);
|
||||
|
||||
/** 같은 묶음 마지막 카드 뒤 · 없으면 같은 쪽(호표 · 덧붙임) 끝 · 그것도 없으면 호표는 맨 앞 · 덧붙임은 맨 뒤 */
|
||||
function insertAt(cards: EasyCard[], card: EasyCard): EasyCard[] {
|
||||
const last = (hit: (c: EasyCard) => boolean): number => {
|
||||
let at = -1;
|
||||
cards.forEach((c, i) => {
|
||||
if (hit(c)) at = i;
|
||||
});
|
||||
return at;
|
||||
};
|
||||
let at = last((c) => groupOf(c) === groupOf(card));
|
||||
if (at < 0) at = last((c) => partOf(c) === partOf(card));
|
||||
if (at < 0 && partOf(card) === "덧줄") at = cards.length - 1;
|
||||
const out = cards.slice();
|
||||
out.splice(at + 1, 0, card);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function addCard(doc: EasyDoc, pick: PickResult): EasyDoc {
|
||||
const card: EasyCard = {
|
||||
id: newId(),
|
||||
kind: pick.kind,
|
||||
name: pick.name,
|
||||
spec: pick.spec,
|
||||
unit: pick.unit,
|
||||
qty: pick.qty,
|
||||
qtyFormula: null,
|
||||
cost: COST_OF[pick.kind],
|
||||
percentBase: null,
|
||||
element: clone(pick.element),
|
||||
lockedWhy: null,
|
||||
source: null,
|
||||
raw: null,
|
||||
};
|
||||
return withInputs({ ...doc, cards: insertAt(doc.cards, card) }, pick.needInputs ?? []);
|
||||
}
|
||||
|
||||
/** 설계 때 정하는 값 더하기 — 없는 이름만(기계를 다시 골랐을 때도 씀) */
|
||||
export function withInputs(doc: EasyDoc, needs: LogicInput[]): EasyDoc {
|
||||
const inputs = doc.inputs.slice();
|
||||
for (const need of needs) if (!inputs.some((i) => i.이름 === need.이름)) inputs.push(clone(need));
|
||||
return inputs.length === doc.inputs.length ? doc : { ...doc, inputs };
|
||||
}
|
||||
|
||||
/** 덧붙임(%) 카드 더하기 — 넣을 자리 기본은 화면이 정함(설계.md 1절 9) */
|
||||
export function addPercent(
|
||||
doc: EasyDoc,
|
||||
name: string,
|
||||
base: CostKind | "사람줄합",
|
||||
rate: number | null,
|
||||
cost: CostKind | null,
|
||||
): EasyDoc {
|
||||
const card: EasyCard = {
|
||||
id: newId(),
|
||||
kind: "percent",
|
||||
name,
|
||||
spec: "",
|
||||
unit: "%",
|
||||
qty: rate,
|
||||
qtyFormula: null,
|
||||
cost,
|
||||
percentBase: base,
|
||||
element: null,
|
||||
lockedWhy: null,
|
||||
source: null,
|
||||
raw: null,
|
||||
};
|
||||
return { ...doc, cards: insertAt(doc.cards, card) };
|
||||
}
|
||||
|
||||
/** 고치기 — 숫자로 바꾸기 = `{ qty: 값, qtyFormula: null }` (잠금 까닭도 지움) */
|
||||
export function updateCard(doc: EasyDoc, id: string, patch: Partial<EasyCard>): EasyDoc {
|
||||
return {
|
||||
...doc,
|
||||
cards: doc.cards.map((c) => {
|
||||
if (c.id !== id) return c;
|
||||
const next: EasyCard = { ...c, ...patch, id: c.id };
|
||||
if (next.qtyFormula === null && next.kind !== "locked") next.lockedWhy = null;
|
||||
return next;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function removeCard(doc: EasyDoc, id: string): EasyDoc {
|
||||
return { ...doc, cards: doc.cards.filter((c) => c.id !== id) };
|
||||
}
|
||||
|
||||
/** ▲(-1) ▼(1) — 같은 묶음 안 이웃과 자리 바꿈 · 끝이면 그대로 */
|
||||
export function moveCard(doc: EasyDoc, id: string, step: -1 | 1): EasyDoc {
|
||||
const from = doc.cards.findIndex((c) => c.id === id);
|
||||
if (from < 0) return doc;
|
||||
const g = groupOf(doc.cards[from]);
|
||||
let to = from + step;
|
||||
while (to >= 0 && to < doc.cards.length && groupOf(doc.cards[to]) !== g) to += step;
|
||||
if (to < 0 || to >= doc.cards.length) return doc;
|
||||
const cards = doc.cards.slice();
|
||||
[cards[from], cards[to]] = [cards[to], cards[from]];
|
||||
return { ...doc, cards };
|
||||
}
|
||||
|
||||
/* ── 검사 (쉬운 말 한 줄 · 없으면 null) ──────────────────────────────────── */
|
||||
|
||||
/** 단위별 어림 위 끝 — 넘으면 물음 꼴 경고 */
|
||||
const TOO_BIG: Record<string, number> = { 인: 10, 일: 10, 시간: 24, "%": 100 };
|
||||
const TOO_BIG_ELSE = 10000;
|
||||
|
||||
export function checkCard(card: EasyCard, doc: EasyDoc): string | null {
|
||||
if (!card.name.trim()) return tx("NoName");
|
||||
if (card.qtyFormula !== null) return null;
|
||||
if (card.qty === null || !Number.isFinite(card.qty)) return tx("NoNumber");
|
||||
if (card.qty === 0) return tx("Zero");
|
||||
if (card.qty < 0) return tx("Negative");
|
||||
const same = doc.cards.some(
|
||||
(c) =>
|
||||
c.id !== card.id &&
|
||||
c.kind === card.kind &&
|
||||
c.name === card.name &&
|
||||
c.spec === card.spec &&
|
||||
JSON.stringify(c.element) === JSON.stringify(card.element) &&
|
||||
(card.kind !== "percent" || c.percentBase === card.percentBase),
|
||||
);
|
||||
if (same) return tx("SameLine");
|
||||
if (card.qty > (TOO_BIG[card.unit] ?? TOO_BIG_ELSE)) {
|
||||
const per = `1${doc.resultUnit.replace(/^원\//, "") || ""}`;
|
||||
return tx("TooBig")
|
||||
.replace("{per}", per)
|
||||
.replace("{qty}", numText(card.qty))
|
||||
.replace("{unit}", card.unit);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 로직 이름 검사 — mine = 내가 만든 것 이름들(자기 이름은 빼고 넘김) */
|
||||
export function checkName(name: string, mine: string[]): string | null {
|
||||
if (!name.trim()) return tx("NoName");
|
||||
return mine.some((m) => m.trim() === name.trim()) ? tx("SameName") : null;
|
||||
}
|
||||
|
||||
/* ── 되돌리기 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
const HISTORY_MAX = 200;
|
||||
|
||||
/** 문서 기록 — 처음 연 문서를 push 한 뒤 바뀔 때마다 push · undo 는 앞 문서 */
|
||||
export class EasyHistory {
|
||||
private stack: string[] = [];
|
||||
private saved: string | null = null;
|
||||
|
||||
push(doc: EasyDoc): void {
|
||||
const s = JSON.stringify(doc);
|
||||
if (this.stack[this.stack.length - 1] === s) return;
|
||||
this.stack.push(s);
|
||||
if (this.saved === null) this.saved = s;
|
||||
if (this.stack.length > HISTORY_MAX) this.stack.shift();
|
||||
}
|
||||
|
||||
undo(): EasyDoc | null {
|
||||
if (this.stack.length < 2) return null;
|
||||
this.stack.pop();
|
||||
return JSON.parse(this.stack[this.stack.length - 1]) as EasyDoc;
|
||||
}
|
||||
|
||||
get canUndo(): boolean {
|
||||
return this.stack.length > 1;
|
||||
}
|
||||
|
||||
/** 저장한 뒤(처음은 연 뒤) 바뀜 — 「저장 안 함」 표시 */
|
||||
get dirty(): boolean {
|
||||
return this.stack.length > 0 && this.stack[this.stack.length - 1] !== this.saved;
|
||||
}
|
||||
|
||||
markSaved(): void {
|
||||
this.saved = this.stack[this.stack.length - 1] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
.m01-easypick {
|
||||
--modal-max: 86vh;
|
||||
width: min(760px, 94vw);
|
||||
font-size: var(--text-body-lg, 1.125rem);
|
||||
}
|
||||
.m01-easypick__ask {
|
||||
margin: 0;
|
||||
font-size: 1.35em;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.m01-easypick__muted {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
.m01-easypick__kinds {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
.m01-easypick__kind,
|
||||
.m01-easypick__row {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.m01-easypick__kind {
|
||||
flex-direction: column;
|
||||
min-height: 96px;
|
||||
}
|
||||
.m01-easypick__kind strong {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
.m01-easypick__kind span {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
.m01-easypick__kind:hover,
|
||||
.m01-easypick__row:hover,
|
||||
.m01-easypick__kind:focus-visible,
|
||||
.m01-easypick__row:focus-visible {
|
||||
border-color: var(--color-accent);
|
||||
outline: 3px solid var(--color-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.m01-easypick__bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
}
|
||||
.m01-easypick__bar > :last-child {
|
||||
flex: 1 1 200px;
|
||||
}
|
||||
.m01-easypick__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
max-height: 46vh;
|
||||
overflow: auto;
|
||||
}
|
||||
.m01-easypick__row {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 52px;
|
||||
}
|
||||
.m01-easypick__name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.m01-easypick__unit,
|
||||
.m01-easypick__price {
|
||||
flex: none;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
.m01-easypick__tag {
|
||||
margin-left: var(--spacing-8);
|
||||
padding: 0 var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-pills);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
.m01-easypick__chosen {
|
||||
margin: 0;
|
||||
font-size: 1.15em;
|
||||
}
|
||||
.m01-easypick__rough {
|
||||
min-height: 1.4em;
|
||||
margin: 0;
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -1,495 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_EasyCalc_Pick.ts
|
||||
* 쉬운 단가산출 고르기 창 — 물음 셋(① 무엇 → ② 어떤 것 → ③ 얼마) · 한 화면 한 물음.
|
||||
* 돌려주는 PickResult.element = 호표 「요소」 칸에 들어갈 값 그대로. 모양은 설계.md 3절.
|
||||
* 글 [한, 영] 짝은 아래 TEXT. 이 폴더째 지워도 됨.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
el,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { fetchPick, fetchSubs, type SubInfo } from "../M01_MasterData_Api_Fetch";
|
||||
import { fetchLogic, fetchMaterials, type ElementBrief } from "../M01_MasterData_UI_Logic_Api";
|
||||
import { listEasy, listMachines, machineElement, plainError } from "./M01_EasyCalc_Api";
|
||||
import type { PickResult } from "./M01_EasyCalc_Model";
|
||||
import "./M01_EasyCalc_Pick.css";
|
||||
|
||||
type Kind = PickResult["kind"];
|
||||
/** 후보 줄 — 서버는 구분 · 상세구분도 주지만 ElementBrief 타입에는 없음 */
|
||||
type Brief = ElementBrief & { 구분?: string; 상세구분?: string };
|
||||
|
||||
const TEXT = {
|
||||
Title: ["넣을 것 고르기", "Pick what to add"],
|
||||
Close: ["닫기", "Close"],
|
||||
Back: ["← 뒤로", "← Back"],
|
||||
AskKind: ["무엇을 넣을까요?", "What do you want to add?"],
|
||||
AskWhich: ["어떤 것인가요?", "Which one?"],
|
||||
AskQty: ["얼마나 쓰나요?", "How much?"],
|
||||
Person: ["사람", "People"],
|
||||
Material: ["재료", "Material"],
|
||||
Machine: ["기계", "Machine"],
|
||||
Logic: ["다른 일위대가", "Another unit-price job"],
|
||||
PersonDesc: ["일하는 사람 — 석공 · 보통인부", "Workers — mason, laborer"],
|
||||
MaterialDesc: ["쓰는 물건 — 돌 · 시멘트 · 관", "Things used — stone, cement, pipe"],
|
||||
MachineDesc: ["쓰는 기계 — 굴삭기 · 덤프트럭", "Machines used — excavator, truck"],
|
||||
LogicDesc: ["이미 만들어 둔 일을 가져다 씀", "Reuse a job already made"],
|
||||
Search: ["이름을 쳐서 찾기", "Type a name to search"],
|
||||
AllSub: ["전체", "All"],
|
||||
SubLabel: ["구분", "Group"],
|
||||
DetailLabel: ["상세구분", "Detail"],
|
||||
Hint: ["구분을 고르거나 이름을 쳐 보세요", "Choose a group or type a name"],
|
||||
Loading: ["찾는 중…", "Searching…"],
|
||||
None: ["찾는 것 없음", "Nothing found"],
|
||||
Count: ["{n} 개 중 앞 {m} 개", "First {m} of {n}"],
|
||||
NoPrice: ["단가 없음", "No price"],
|
||||
PerDay: ["하루 {p} 원", "{p} won per day"],
|
||||
PerUnit: ["{u} 당 {p} 원", "{p} won per {u}"],
|
||||
Picked: ["고른 것", "Chosen"],
|
||||
QtyPlace: ["예: 0.072", "e.g. 0.072"],
|
||||
QtyLabel: ["{u} (숫자)", "{u} (number)"],
|
||||
Rough: ["금액 어림 약 {m} 원", "About {m} won"],
|
||||
Add: ["넣기", "Add"],
|
||||
QtyBad: ["0 보다 큰 숫자를 쳐 주세요", "Enter a number above 0"],
|
||||
Failed: ["불러오지 못함", "Could not load"],
|
||||
} as const;
|
||||
|
||||
const te = (key: keyof typeof TEXT, vars: Record<string, string | number> = {}): string =>
|
||||
Object.entries(vars).reduce(
|
||||
(text, [k, v]) => text.replace(`{${k}}`, String(v)),
|
||||
TEXT[key][currentLanguageIndex] as string,
|
||||
);
|
||||
|
||||
const won = (n: number): string => Math.round(n).toLocaleString("ko-KR");
|
||||
/** 목록에 그릴 줄 수 — 더 찾으려면 이름을 쳐서 좁힘 */
|
||||
const SHOW = 100;
|
||||
const MAT_FILE = "재료_자재품목.json";
|
||||
|
||||
/** 목록 한 줄 — 고르면 PickResult(수량 빼고) 를 만들어 줌 */
|
||||
interface Candidate {
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
/** 줄 단위 1 당 단가 · 모르면 null — 금액 어림용 */
|
||||
price: number | null;
|
||||
/** 줄 오른쪽 글(단가 표시) */
|
||||
priceText: string;
|
||||
tag?: string;
|
||||
make: () => Promise<Omit<PickResult, "qty">>;
|
||||
}
|
||||
|
||||
interface Filter {
|
||||
q: string;
|
||||
sub: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
interface Source {
|
||||
title: string;
|
||||
desc: string;
|
||||
/** 구분 · 상세구분 칸을 보일지(재료만) */
|
||||
grouped?: boolean;
|
||||
/** 이름 · 구분이 비면 목록을 안 부르고 null(안내 글만) */
|
||||
load: (f: Filter) => Promise<{ total: number; items: Candidate[] } | null>;
|
||||
}
|
||||
|
||||
/** 찾는 말과 이름이 똑같으면 0 · 그 말로 시작하고 말이 거기서 끝나면(「모래, 서울」 「모래(건조)」) 1 ·
|
||||
* 더 이어지면(「모래적사장」) 2 · 나머지 3 — 앞에 놓음(같은 차례끼리 순서는 그대로) */
|
||||
const byName = <T extends { 이름?: string }>(items: T[], q: string): T[] => {
|
||||
const squash = (t: string): string => t.replace(/\s+/g, "").toLowerCase();
|
||||
const w = squash(q);
|
||||
const rank = (it: T): number => {
|
||||
const n = squash(it.이름 ?? "");
|
||||
if (n === w) return 0;
|
||||
if (!n.startsWith(w)) return 3;
|
||||
return /[가-힣a-z0-9]/.test(n.charAt(w.length)) ? 2 : 1;
|
||||
};
|
||||
return items
|
||||
.map((it, i) => ({ it, i, r: rank(it) }))
|
||||
.sort((a, b) => a.r - b.r || a.i - b.i)
|
||||
.map((x) => x.it);
|
||||
};
|
||||
|
||||
const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
||||
|
||||
const SOURCES: Partial<Record<Kind, Source>> = {
|
||||
person: {
|
||||
title: te("Person"),
|
||||
desc: te("PersonDesc"),
|
||||
load: async (f) => {
|
||||
const { total, items } = await fetchPick("job", f.q);
|
||||
return {
|
||||
total,
|
||||
items: items.map((it) => {
|
||||
const price = num(it.값);
|
||||
return {
|
||||
name: it.이름,
|
||||
spec: it.규격,
|
||||
unit: "인",
|
||||
price,
|
||||
priceText: price === null ? te("NoPrice") : te("PerDay", { p: won(price) }),
|
||||
make: async () => ({
|
||||
kind: "person",
|
||||
name: it.이름,
|
||||
spec: it.규격,
|
||||
unit: "인",
|
||||
element: it.ref,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
material: {
|
||||
title: te("Material"),
|
||||
desc: te("MaterialDesc"),
|
||||
grouped: true,
|
||||
load: async (f) => {
|
||||
if (!f.q && !f.sub) return null;
|
||||
const found = f.q
|
||||
? await fetchPick("price", f.q).then((r) => ({
|
||||
total: r.total,
|
||||
items: byName(r.items, f.q)
|
||||
.filter((it) => !f.sub || (it as Brief).구분 === f.sub)
|
||||
.map((it) => ({ ...it, 구분: (it as Brief).구분 })),
|
||||
}))
|
||||
: await fetchMaterials({ sub: f.sub, ...(f.detail ? { detail: f.detail } : {}) }).then(
|
||||
(r) => ({
|
||||
total: r.total,
|
||||
items: r.items.map((it) => ({ ...it, 구분: (it as Brief).구분 ?? f.sub })),
|
||||
}),
|
||||
);
|
||||
return {
|
||||
total: found.total,
|
||||
items: found.items.map((it) => {
|
||||
const name = it.이름 ?? "";
|
||||
const spec = it.규격 ?? "";
|
||||
const unit = typeof it.단위 === "string" ? it.단위 : "";
|
||||
const price = num(it.값);
|
||||
const element: Record<string, string> = { 구분: it.구분 ?? f.sub };
|
||||
const detail = (it as Brief).상세구분;
|
||||
if (detail) element["상세구분"] = detail;
|
||||
element["검색어"] = `${name} ${spec}`.trim();
|
||||
return {
|
||||
name,
|
||||
spec,
|
||||
unit,
|
||||
price,
|
||||
priceText:
|
||||
price === null ? te("NoPrice") : te("PerUnit", { u: unit || "1", p: won(price) }),
|
||||
tag: element["구분"],
|
||||
make: async () => ({ kind: "material", name, spec, unit, element }),
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
machine: {
|
||||
title: te("Machine"),
|
||||
desc: te("MachineDesc"),
|
||||
load: async (f) => {
|
||||
const items = await listMachines(f.q);
|
||||
return {
|
||||
total: items.length,
|
||||
items: items.map((m) => ({
|
||||
name: m.이름,
|
||||
spec: m.규격,
|
||||
unit: m.단위,
|
||||
price: null,
|
||||
priceText: "",
|
||||
make: async () => {
|
||||
const { element, needInputs } = await machineElement(m.분류번호);
|
||||
return {
|
||||
kind: "machine",
|
||||
name: m.이름,
|
||||
spec: m.규격,
|
||||
unit: m.단위,
|
||||
element,
|
||||
needInputs,
|
||||
};
|
||||
},
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
logic: {
|
||||
title: te("Logic"),
|
||||
desc: te("LogicDesc"),
|
||||
load: async (f) => {
|
||||
// 돈으로 나오는 일위대가만 — 「원/㎡」 꼴
|
||||
const items = (await listEasy(f.q)).filter((e) => e.resultUnit.startsWith("원/"));
|
||||
return {
|
||||
total: items.length,
|
||||
items: items.map((e) => {
|
||||
const unit = e.resultUnit.slice(2);
|
||||
return {
|
||||
name: e.name,
|
||||
spec: "",
|
||||
unit,
|
||||
price: null,
|
||||
priceText: "",
|
||||
tag: e.owner,
|
||||
make: async () => {
|
||||
// 그 일위대가가 받는 값은 같은 이름으로 넘기고 「설계 때 정하는 값」으로 돌려줌(기계와 같은 꼴)
|
||||
const needInputs = (await fetchLogic(e.key)).logic.입력;
|
||||
const quote = (n: string): string => (/^[\w가-힣]+$/.test(n) ? n : `'${n}'`);
|
||||
const rest = needInputs.map((i) => `, ${quote(i.이름)}=${quote(i.이름)}`).join("");
|
||||
return {
|
||||
kind: "logic",
|
||||
name: e.name,
|
||||
spec: "",
|
||||
unit,
|
||||
element: `로직(${e.key}${rest})`,
|
||||
needInputs,
|
||||
};
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** 물음 셋 — 고른 것 · 취소는 null */
|
||||
export function openEasyPick(
|
||||
kind?: Kind,
|
||||
opts: { qty?: number | null } = {},
|
||||
): Promise<PickResult | null> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (r: PickResult | null): void => {
|
||||
if (!settled) resolve(r);
|
||||
settled = true;
|
||||
};
|
||||
openModal({
|
||||
title: te("Title"),
|
||||
closeLabel: te("Close"),
|
||||
dialogClass: "m01-easypick",
|
||||
beforeClose: () => (finish(null), true),
|
||||
mount: (body, close) => {
|
||||
const done = (r: PickResult): void => (finish(r), close());
|
||||
const stepKinds = (): void => {
|
||||
body.replaceChildren(
|
||||
el("h4", { className: "m01-easypick__ask", text: te("AskKind") }),
|
||||
kindButtons(),
|
||||
);
|
||||
body.querySelector<HTMLElement>("button")?.focus();
|
||||
};
|
||||
const kindButtons = (): HTMLElement => {
|
||||
const order: Kind[] = ["person", "material", "machine", "logic"];
|
||||
const grid = el("div", { className: "m01-easypick__kinds" });
|
||||
order.forEach((k, i) => {
|
||||
const src = SOURCES[k];
|
||||
const b = el("button", {
|
||||
className: "m01-easypick__kind",
|
||||
attrs: { type: "button", "data-kind": k, "aria-keyshortcuts": String(i + 1) },
|
||||
children: [
|
||||
el("strong", { text: src?.title ?? labelOf(k) }),
|
||||
el("span", { text: src?.desc ?? descOf(k) }),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", () => stepList(k, true));
|
||||
grid.append(b);
|
||||
});
|
||||
grid.addEventListener("keydown", (ev) => {
|
||||
const k = order[Number(ev.key) - 1];
|
||||
if (k) stepList(k, true);
|
||||
});
|
||||
return grid;
|
||||
};
|
||||
const stepList = (k: Kind, canBack: boolean): void => {
|
||||
const src = SOURCES[k];
|
||||
if (!src) return;
|
||||
const back = canBack ? stepKinds : null;
|
||||
const search = createInputField({ type: "search", placeholder: te("Search") });
|
||||
const filter: Filter = { q: "", sub: "", detail: "" };
|
||||
const list = el("div", { className: "m01-easypick__list", attrs: { role: "listbox" } });
|
||||
const count = el("span", { className: "m01-easypick__muted" });
|
||||
const bar: HTMLElement[] = [search.root];
|
||||
let seq = 0;
|
||||
const load = async (): Promise<void> => {
|
||||
const mine = ++seq;
|
||||
count.textContent = "";
|
||||
list.replaceChildren(
|
||||
el("p", { className: "m01-easypick__muted", text: te("Loading") }),
|
||||
);
|
||||
try {
|
||||
const found = await src.load(filter);
|
||||
if (mine !== seq) return; // 최신 응답만
|
||||
if (!found)
|
||||
return void list.replaceChildren(
|
||||
el("p", { className: "m01-easypick__muted", text: te("Hint") }),
|
||||
);
|
||||
const shown = found.items.slice(0, SHOW);
|
||||
count.textContent = te("Count", { n: found.total, m: shown.length });
|
||||
list.replaceChildren(
|
||||
...(shown.length
|
||||
? shown.map((c) =>
|
||||
rowOf(c, () => stepQty(k, c, back ? () => stepList(k, canBack) : null)),
|
||||
)
|
||||
: [el("p", { className: "m01-easypick__muted", text: te("None") })]),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mine === seq) showToast(plainError(e), "error");
|
||||
}
|
||||
};
|
||||
if (src.grouped) bar.unshift(...subFilters(filter, load));
|
||||
let timer = 0;
|
||||
search.input.addEventListener("input", () => {
|
||||
filter.q = search.input.value.trim();
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(load, 250);
|
||||
});
|
||||
body.replaceChildren(
|
||||
...(back ? [createButton({ label: te("Back"), variant: "ghost", onClick: back })] : []),
|
||||
el("h4", { className: "m01-easypick__ask", text: `${src.title} — ${te("AskWhich")}` }),
|
||||
el("div", { className: "m01-easypick__bar", children: bar }),
|
||||
count,
|
||||
list,
|
||||
);
|
||||
body.onkeydown = (ev) => {
|
||||
if (back && ev.altKey && ev.key === "ArrowLeft") back();
|
||||
else if (ev.key === "ArrowDown" || ev.key === "ArrowUp") moveFocus(body, ev);
|
||||
};
|
||||
search.input.focus();
|
||||
void load();
|
||||
};
|
||||
const stepQty = (k: Kind, c: Candidate, back: (() => void) | null): void => {
|
||||
const qty = createInputField({
|
||||
type: "text",
|
||||
label: te("QtyLabel", { u: c.unit || "—" }),
|
||||
placeholder: te("QtyPlace"),
|
||||
});
|
||||
qty.input.inputMode = "decimal";
|
||||
if (opts.qty != null) qty.input.value = String(opts.qty); // 다시 고르기 — 지금 수량 미리 채움
|
||||
const rough = el("p", { className: "m01-easypick__rough" });
|
||||
const parse = (): number | null => {
|
||||
const n = Number(qty.input.value.trim().replace(/,/g, ""));
|
||||
return qty.input.value.trim() !== "" && Number.isFinite(n) && n > 0 ? n : null;
|
||||
};
|
||||
const refresh = (): void => {
|
||||
const n = parse();
|
||||
rough.textContent =
|
||||
n !== null && c.price !== null ? te("Rough", { m: won(n * c.price) }) : "";
|
||||
qty.setError(undefined);
|
||||
};
|
||||
const submit = async (): Promise<void> => {
|
||||
const n = parse();
|
||||
if (n === null) return qty.setError(te("QtyBad"));
|
||||
try {
|
||||
done({ ...(await c.make()), qty: n });
|
||||
} catch (e) {
|
||||
qty.setError(plainError(e));
|
||||
}
|
||||
};
|
||||
qty.input.addEventListener("input", refresh);
|
||||
qty.input.addEventListener("keydown", (ev) => ev.key === "Enter" && void submit());
|
||||
body.replaceChildren(
|
||||
...(back ? [createButton({ label: te("Back"), variant: "ghost", onClick: back })] : []),
|
||||
el("h4", { className: "m01-easypick__ask", text: te("AskQty") }),
|
||||
el("p", {
|
||||
className: "m01-easypick__chosen",
|
||||
children: [
|
||||
el("span", { className: "m01-easypick__muted", text: te("Picked") }),
|
||||
el("strong", { text: ` ${c.name} ${c.spec}`.trimEnd() }),
|
||||
],
|
||||
}),
|
||||
qty.root,
|
||||
rough,
|
||||
createButton({ label: te("Add"), variant: "filled", onClick: () => void submit() }),
|
||||
);
|
||||
body.onkeydown = (ev) => ev.altKey && ev.key === "ArrowLeft" && back?.();
|
||||
qty.input.focus();
|
||||
void k;
|
||||
};
|
||||
if (kind) stepList(kind, false);
|
||||
else stepKinds();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const labelOf = (k: Kind): string =>
|
||||
te(({ person: "Person", material: "Material", machine: "Machine", logic: "Logic" } as const)[k]);
|
||||
const descOf = (k: Kind): string =>
|
||||
te(
|
||||
(
|
||||
{
|
||||
person: "PersonDesc",
|
||||
material: "MaterialDesc",
|
||||
machine: "MachineDesc",
|
||||
logic: "LogicDesc",
|
||||
} as const
|
||||
)[k],
|
||||
);
|
||||
|
||||
/** 목록 줄 — 이름 · 크기 · 단위 · 단가 */
|
||||
function rowOf(c: Candidate, onPick: () => void): HTMLElement {
|
||||
const b = el("button", {
|
||||
className: "m01-easypick__row",
|
||||
attrs: { type: "button", role: "option" },
|
||||
children: [
|
||||
el("span", {
|
||||
className: "m01-easypick__name",
|
||||
children: [
|
||||
el("strong", { text: c.name }),
|
||||
...(c.spec ? [el("span", { className: "m01-easypick__muted", text: ` ${c.spec}` })] : []),
|
||||
...(c.tag ? [el("span", { className: "m01-easypick__tag", text: c.tag })] : []),
|
||||
],
|
||||
}),
|
||||
el("span", { className: "m01-easypick__unit", text: c.unit }),
|
||||
el("span", { className: "m01-easypick__price", text: c.priceText }),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", onPick);
|
||||
return b;
|
||||
}
|
||||
|
||||
/** 위·아래 화살표로 찾기 칸 ↔ 줄 사이 오감 */
|
||||
function moveFocus(body: HTMLElement, ev: KeyboardEvent): void {
|
||||
const items = [...body.querySelectorAll<HTMLElement>("input[type=search], .m01-easypick__row")];
|
||||
const at = items.indexOf(document.activeElement as HTMLElement);
|
||||
if (at < 0) return;
|
||||
const next = items[at + (ev.key === "ArrowDown" ? 1 : -1)];
|
||||
if (!next) return;
|
||||
ev.preventDefault();
|
||||
next.focus();
|
||||
}
|
||||
|
||||
/** 재료 구분 → 상세구분 고르기 칸 둘 — 바꾸면 load 다시 */
|
||||
function subFilters(filter: Filter, load: () => Promise<void>): HTMLElement[] {
|
||||
let subs: SubInfo[] = [];
|
||||
const all = { value: "", text: te("AllSub") };
|
||||
const subBox = createSelectField({
|
||||
options: [all],
|
||||
value: "",
|
||||
compact: true,
|
||||
ariaLabel: te("SubLabel"),
|
||||
onChange: (v) => ((filter.sub = v), (filter.detail = ""), fillDetail(), void load()),
|
||||
});
|
||||
const detailBox = createSelectField({
|
||||
options: [all],
|
||||
value: "",
|
||||
compact: true,
|
||||
ariaLabel: te("DetailLabel"),
|
||||
onChange: (v) => ((filter.detail = v), void load()),
|
||||
});
|
||||
const fillDetail = (): void => {
|
||||
const details = subs.find((s) => s.name === filter.sub)?.details ?? [];
|
||||
detailBox.root.hidden = !details.length;
|
||||
detailBox.setOptions([all, ...details.map((d) => ({ value: d, text: d }))], "");
|
||||
};
|
||||
fillDetail();
|
||||
void fetchSubs({ file: MAT_FILE })
|
||||
.then((r) => {
|
||||
subs = r;
|
||||
subBox.setOptions([all, ...r.map((s) => ({ value: s.name, text: s.name }))], filter.sub);
|
||||
})
|
||||
.catch(() => showToast(te("Failed"), "error"));
|
||||
return [subBox.root, detailBox.root];
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_EasyCalc_Sheet.ts
|
||||
* 쉬운 단가산출 — 실무 일위대가표(읽기만) · 「설계 때 정하는 값」 띠(시험값만, 저장 안 됨)
|
||||
* 계약 = docs/raw/M01_쉬운단가/설계.md 3절 Sheet. 이 폴더째 지워도 됨.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createSelectField, el } from "@ui/ui_template_elements";
|
||||
import {
|
||||
searchElements,
|
||||
type CalcAnswer,
|
||||
type CalcLine,
|
||||
type LogicInput,
|
||||
} from "../M01_MasterData_UI_Logic_Api";
|
||||
import { formatMoney, formatPrice } from "../M01_MasterData_UI_Logic_Money";
|
||||
import { safeReason } from "../M01_MasterData_UI_Logic_Text";
|
||||
import "./M01_EasyCalc_Sheet.css";
|
||||
|
||||
const TEXT = {
|
||||
Busy: ["계산 중", "Calculating"],
|
||||
Stopped: ["계산하지 못했어요", "Could not calculate"],
|
||||
Name: ["명칭", "Name"],
|
||||
Spec: ["규격", "Size"],
|
||||
Qty: ["수량", "Qty"],
|
||||
Unit: ["단위", "Unit"],
|
||||
Total: ["합계", "Total"],
|
||||
Labor: ["노무비", "Labor"],
|
||||
Material: ["재료비", "Material"],
|
||||
Expense: ["경비", "Expense"],
|
||||
Note: ["비고", "Note"],
|
||||
Price: ["단가", "Price"],
|
||||
Amount: ["금액", "Amount"],
|
||||
Strip: ["설계 때 정하는 값", "Values set at design time"],
|
||||
Trial: ["여기 값은 시험용 — 저장 안 됨", "Trial values only — not saved"],
|
||||
Range: ["{a} ∼ {b} 사이", "between {a} and {b}"],
|
||||
Pick: ["고르세요", "Choose"],
|
||||
Bad: ["숫자를 넣어 주세요", "Enter a number"],
|
||||
OutOfRange: ["범위 밖이에요", "Out of range"],
|
||||
} as const;
|
||||
|
||||
const ts = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex];
|
||||
|
||||
const COSTS = ["노무비", "재료비", "경비"] as const;
|
||||
const COST_HEAD = { 노무비: "Labor", 재료비: "Material", 경비: "Expense" } as const;
|
||||
|
||||
const qty = (n: number): string => n.toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
||||
const cell = (text: string, className = ""): HTMLElement => el("td", { className, text });
|
||||
const money = (text: string): HTMLElement => cell(text, "m01-easysheet__num");
|
||||
|
||||
/** 준용 · 산정 글에 붙은 마스터 번호는 뺌 */
|
||||
const note = (line: CalcLine): string =>
|
||||
[line.출처?.replace(/[A-Z]{2}\d{6}\./g, ""), line.단위경고].filter(Boolean).join(" · ");
|
||||
|
||||
function lineRow(line: CalcLine): HTMLElement {
|
||||
// 비목별 단가 = 줄 단가 × (그 비목 금액 ÷ 줄 금액) — 금액 반올림 오차가 단가에 안 섞임
|
||||
const per = (k: string): number =>
|
||||
line.금액 ? (line.단가 * (line.비목?.[k] ?? 0)) / line.금액 : 0;
|
||||
return el("tr", {
|
||||
children: [
|
||||
cell(line.이름),
|
||||
cell(line.품목?.규격 ?? ""),
|
||||
money(qty(line.수량)),
|
||||
cell(line.단위),
|
||||
money(formatPrice(line.단가)),
|
||||
money(formatMoney(line.금액)),
|
||||
...COSTS.flatMap((k) => [
|
||||
money(formatPrice(per(k))),
|
||||
money(formatMoney(line.비목?.[k] ?? 0)),
|
||||
]),
|
||||
cell(note(line), "m01-easysheet__note"),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 실무 일위대가표 — 읽기만 · 맨 아래 합계 줄 */
|
||||
export function renderSheet(
|
||||
host: HTMLElement,
|
||||
head: { name: string; resultUnit: string },
|
||||
ans: CalcAnswer | null,
|
||||
): void {
|
||||
const title = el("p", {
|
||||
className: "m01-easysheet__title",
|
||||
text: [head.name, head.resultUnit].filter(Boolean).join(" · "),
|
||||
});
|
||||
if (!ans || !ans.ok) {
|
||||
host.replaceChildren(
|
||||
title,
|
||||
el("p", {
|
||||
className: "m01-easysheet__status",
|
||||
text: ans ? `${ts("Stopped")} — ${safeReason(ans.reason)}` : ts("Busy"),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const lines = ans.lines ?? [];
|
||||
const sums = ans.sums ?? {};
|
||||
const group = (label: string): HTMLElement =>
|
||||
el("th", { className: "m01-easysheet__group", text: label, attrs: { colspan: "2" } });
|
||||
const one = (label: string): HTMLElement => el("th", { text: label, attrs: { rowspan: "2" } });
|
||||
const pair = (): HTMLElement[] => [ts("Price"), ts("Amount")].map((h) => el("th", { text: h }));
|
||||
const table = el("table", {
|
||||
className: "m01-easysheet__table",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", {
|
||||
children: [
|
||||
one(ts("Name")),
|
||||
one(ts("Spec")),
|
||||
one(ts("Qty")),
|
||||
one(ts("Unit")),
|
||||
group(ts("Total")),
|
||||
...COSTS.map((k) => group(ts(COST_HEAD[k]))),
|
||||
one(ts("Note")),
|
||||
],
|
||||
}),
|
||||
el("tr", { children: [...pair(), ...pair(), ...pair(), ...pair()] }),
|
||||
],
|
||||
}),
|
||||
el("tbody", { children: lines.map(lineRow) }),
|
||||
el("tfoot", {
|
||||
children: [
|
||||
el("tr", {
|
||||
children: [
|
||||
el("th", { text: ts("Total"), attrs: { colspan: "4" } }),
|
||||
cell(""),
|
||||
money(formatMoney(sums["계"] ?? 0)),
|
||||
...COSTS.flatMap((k) => [cell(""), money(formatMoney(sums[k] ?? 0))]),
|
||||
cell(""),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
host.replaceChildren(title, el("div", { className: "m01-easysheet__scroll", children: [table] }));
|
||||
}
|
||||
|
||||
/** 마스터 번호 꼴 고르기 값 → 이름(한 번 물은 것은 다시 안 물음) */
|
||||
const KEY_RE = /^[A-Z]{2}\d{6}$/;
|
||||
const KEY_GROUP: Record<string, string> = { MT: "재료", EQ: "기계", LB: "인력" };
|
||||
const nameCache = new Map<string, string | null>();
|
||||
|
||||
async function keyName(key: string): Promise<string | null> {
|
||||
if (nameCache.has(key)) return nameCache.get(key)!;
|
||||
const group = KEY_GROUP[key.slice(0, 2)];
|
||||
let name: string | null = null;
|
||||
if (group) {
|
||||
try {
|
||||
const hit = (await searchElements(group, key)).items.find((it) => it.ref === key);
|
||||
name = hit ? `${hit.이름 ?? ""}${hit.규격 ? ` ${hit.규격}` : ""}`.trim() || null : null;
|
||||
} catch {
|
||||
name = null;
|
||||
}
|
||||
}
|
||||
nameCache.set(key, name);
|
||||
return name;
|
||||
}
|
||||
|
||||
/** state = 띠가 들고 있는 시험값(고칠 때마다 같은 객체를 바꿈) · emit = 바뀐 값을 밖에 알림 */
|
||||
type Trial = Record<string, unknown>;
|
||||
|
||||
function numberBox(spec: LogicInput, state: Trial, emit: () => void): HTMLElement {
|
||||
const [lo, hi] = spec.범위 ?? [];
|
||||
const hint = spec.범위 ? ts("Range").replace("{a}", String(lo)).replace("{b}", String(hi)) : "";
|
||||
const box = el("input", {
|
||||
className: "m01-easysheet__input",
|
||||
attrs: { type: "text", inputmode: "decimal", placeholder: hint },
|
||||
});
|
||||
const warn = el("span", { className: "m01-easysheet__warn" });
|
||||
box.value = state[spec.이름] === undefined ? "" : String(state[spec.이름]);
|
||||
box.addEventListener("change", () => {
|
||||
const raw = box.value.trim();
|
||||
const num = Number(raw);
|
||||
const bad = raw !== "" && Number.isNaN(num) ? ts("Bad") : "";
|
||||
const out = !bad && raw !== "" && spec.범위 && (num < lo! || num > hi!) ? ts("OutOfRange") : "";
|
||||
warn.textContent = bad || out;
|
||||
box.toggleAttribute("aria-invalid", !!(bad || out));
|
||||
if (bad) return;
|
||||
if (raw === "") delete state[spec.이름];
|
||||
else state[spec.이름] = num;
|
||||
emit();
|
||||
});
|
||||
return el("span", { className: "m01-easysheet__ctl", children: [box, warn] });
|
||||
}
|
||||
|
||||
function pickBox(spec: LogicInput, state: Trial, emit: () => void): HTMLElement {
|
||||
const picks = spec.고르기 ?? [];
|
||||
const optionText = (o: string | number): string =>
|
||||
(typeof o === "string" && nameCache.get(o)) || String(o);
|
||||
const options = (): { value: string; text: string }[] => [
|
||||
{ value: "", text: ts("Pick") },
|
||||
...picks.map((o) => ({ value: String(o), text: optionText(o) })),
|
||||
];
|
||||
const cur = state[spec.이름] === undefined ? "" : String(state[spec.이름]);
|
||||
const field = createSelectField({
|
||||
options: options(),
|
||||
value: cur,
|
||||
compact: true,
|
||||
ariaLabel: spec.이름,
|
||||
onChange: (v) => {
|
||||
const hit = picks.find((o) => String(o) === v); // 원래 값(수) 그대로 보냄
|
||||
if (hit === undefined) delete state[spec.이름];
|
||||
else state[spec.이름] = hit;
|
||||
emit();
|
||||
},
|
||||
});
|
||||
const keys = picks.filter((o): o is string => typeof o === "string" && KEY_RE.test(o));
|
||||
if (keys.length) {
|
||||
void Promise.all(keys.map(keyName)).then(() => field.setOptions(options(), cur));
|
||||
}
|
||||
return field.root;
|
||||
}
|
||||
|
||||
/** 「설계 때 정하는 값」 띠 — 바꾸면 onChange(새 시험값) · 값이 없으면 띠를 숨김 */
|
||||
export function renderTrialStrip(
|
||||
host: HTMLElement,
|
||||
inputs: LogicInput[],
|
||||
trial: Record<string, unknown>,
|
||||
onChange: (trial: Record<string, unknown>) => void,
|
||||
): void {
|
||||
if (!inputs.length) {
|
||||
host.replaceChildren();
|
||||
host.hidden = true;
|
||||
return;
|
||||
}
|
||||
host.hidden = false;
|
||||
const state = { ...trial };
|
||||
const emit = (): void => onChange({ ...state });
|
||||
host.replaceChildren(
|
||||
el("strong", { className: "m01-easysheet__strip-title", text: ts("Strip") }),
|
||||
el("div", {
|
||||
className: "m01-easysheet__fields",
|
||||
children: inputs.map((spec) =>
|
||||
el("label", {
|
||||
className: "m01-easysheet__field",
|
||||
children: [
|
||||
el("span", { text: spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름 }),
|
||||
spec.고르기?.length ? pickBox(spec, state, emit) : numberBox(spec, state, emit),
|
||||
],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
el("p", { className: "m01-easysheet__muted", text: ts("Trial") }),
|
||||
);
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
/* 쉬운 단가산출 — 큰 창 틀 · 시작 화면 · 위 띠 */
|
||||
|
||||
/* 공용 모달(위 고정)을 화면 대부분으로 — 높이를 고정해 화면이 바뀌어도 창이 안 움직임 */
|
||||
.ui-modal.m01-easycalc {
|
||||
--modal-max: 90vh;
|
||||
width: min(1280px, 96vw);
|
||||
height: var(--modal-max);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m01-easycalc .ui-modal__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: var(--spacing-16);
|
||||
}
|
||||
|
||||
.m01-easycalc__muted {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-easycalc__big {
|
||||
min-height: 56px;
|
||||
padding: 0 var(--spacing-24);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
/* 시작 화면 — 왼쪽 찾기 · 오른쪽 새로 만들기 + 내가 만든 것 */
|
||||
.m01-easycalc__start {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
||||
gap: var(--spacing-24);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.m01-easycalc__start-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01-easycalc__start-col h4 {
|
||||
margin: var(--spacing-8) 0 0;
|
||||
}
|
||||
|
||||
.m01-easycalc__ask {
|
||||
margin: 0;
|
||||
font-size: var(--text-subheading);
|
||||
}
|
||||
|
||||
.m01-easycalc__search .ui-input {
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m01-easycalc__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m01-easycalc__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01-easycalc__item:hover:not(:disabled) {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.m01-easycalc__item:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.m01-easycalc__item-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01-easycalc__item-unit {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 고치는 화면 — 위 띠 */
|
||||
.m01-easycalc__strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--spacing-12);
|
||||
padding-bottom: var(--spacing-12);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m01-easycalc__strip .ui-field {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.m01-easycalc__name {
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
|
||||
.m01-easycalc__total {
|
||||
font-size: var(--text-subheading);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-easycalc__actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.m01-easycalc__base {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-16);
|
||||
}
|
||||
|
||||
.m01-easycalc__base p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.m01-easycalc__part {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.m01-easycalc__part h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.m01-easycalc__cards {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.m01-easycalc__sheet summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 고친 뒤 금액을 다시 셈하는 동안 옛 금액을 흐리게 */
|
||||
.m01-easycalc .is-pending {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.m01-easycalc__note {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.m01-easycalc__note:empty {
|
||||
display: none;
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
/* 쉬운 단가산출 2단계 — 표 보고 고르기 창 */
|
||||
.ui-modal.m01-easytable {
|
||||
width: min(1100px, 96vw);
|
||||
}
|
||||
|
||||
.m01-easytable .ui-modal__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
.m01-easytable__note {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-easytable__which,
|
||||
.m01-easytable__actions,
|
||||
.m01-easytable__seg {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01-easytable__which:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m01-easytable__actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.m01-easytable__head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m01-easytable__conds {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01-easytable__conds h4 {
|
||||
margin: 0;
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m01-easytable__cond {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(6rem, 10rem) minmax(5rem, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-easytable__cond-value {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m01-easytable__grid {
|
||||
max-height: 46vh;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.m01-easytable__grid table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-easytable__grid th,
|
||||
.m01-easytable__grid td {
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-easytable__grid thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m01-easytable__grid td.is-value {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.m01-easytable__grid .is-col {
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.m01-easytable__grid tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01-easytable__grid tbody tr:hover {
|
||||
background: color-mix(in srgb, var(--color-accent) 10%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.m01-easytable__grid tr.is-hit {
|
||||
background: color-mix(in srgb, var(--color-warning) 28%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.m01-easytable__grid tr.is-picked td:first-child {
|
||||
box-shadow: inset 3px 0 0 var(--color-accent);
|
||||
}
|
||||
|
||||
.m01-easytable__money {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-16);
|
||||
}
|
||||
|
||||
.m01-easytable__money.is-busy {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.m01-easytable__hint {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
margin: 0;
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-radius: var(--radius-lg);
|
||||
background: color-mix(in srgb, var(--color-accent) 10%, var(--color-canvas));
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-easytable__hint:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m01-easytable__warn {
|
||||
margin: 0;
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-easytable__warn:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.m01-easytable__cond {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.m01-easytable__seg {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
@@ -1,696 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_EasyCalc_Table.ts
|
||||
* 쉬운 단가산출 2단계 — 표 보고 고르기(설계.md 1절 7 · 3절 `openEasyTable`)
|
||||
* 잠금 카드 수량 식 속 `찾기(표, 조건=값 …).값칸` 을 읽어 그 표를 그대로 보이고,
|
||||
* 줄 · 칸을 눌러 조건 값 · 값 칸을 바꾼 새 식을 돌려줌(취소 null).
|
||||
*
|
||||
* 식 읽기 = 엔진 문법(`master_formula._Parser` 의 find · args · field) 그대로 화면에서 —
|
||||
* 서버에 찾기 마디를 돌려주는 길이 없어서. 걸린 줄 · 금액은 서버(`/text` 표 조각 `참조.줄` · `/calc`).
|
||||
* 화면 글에 식 · 키 안 보임 — 표 이름 · 칸 이름 · 값만.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { fetchTable } from "../M01_MasterData_Api_Fetch";
|
||||
import {
|
||||
fetchAuto,
|
||||
runCalc,
|
||||
runText,
|
||||
searchElements,
|
||||
type HoLine,
|
||||
type LogicRow,
|
||||
type NamedFormula,
|
||||
} from "../M01_MasterData_UI_Logic_Api";
|
||||
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
|
||||
import { toRow, type EasyCard, type EasyDoc } from "./M01_EasyCalc_Model";
|
||||
import "./M01_EasyCalc_Table.css";
|
||||
|
||||
const TEXT = {
|
||||
Title: ["표 보고 고르기", "Pick from the table"],
|
||||
Close: ["닫기", "Close"],
|
||||
Cannot: ["이 줄은 지금 화면에서 바꿀 수 없음", "This line can't be changed here yet"],
|
||||
NoTable: ["표를 불러오지 못함", "Couldn't load the table"],
|
||||
Loading: ["불러오는 중", "Loading"],
|
||||
Hint: [
|
||||
"줄을 누르면 그 줄 값으로 정함 · 노란 줄 = 지금 쓰는 줄 · 굵은 칸 = 수량을 가져오는 칸",
|
||||
"Tap a row to use it · yellow = row in use · bold = column the quantity comes from",
|
||||
],
|
||||
Conds: ["어느 줄을 쓸까", "Which row"],
|
||||
Design: ["설계 때 정함", "Set at design"],
|
||||
Fix: ["이 값으로 고정", "Fix this value"],
|
||||
AsIs: ["지금대로", "As is"],
|
||||
NotYet: ["(아직 없음)", "(none yet)"],
|
||||
Amount: ["이 줄 금액", "This line"],
|
||||
Total: ["합계", "Total"],
|
||||
Wait: ["계산 중", "Calculating"],
|
||||
Stopped: ["계산 멈춤", "Calculation stopped"],
|
||||
DesignHint: [
|
||||
"이 값은 설계 때 정함 — 늘 이 줄로 하려면",
|
||||
"This value is set at design — to always use this row,",
|
||||
],
|
||||
Mismatch: [
|
||||
"고른 줄이 이 조건으로는 안 걸림 — 조건을 다시 보세요",
|
||||
"The picked row isn't matched by these conditions",
|
||||
],
|
||||
Use: ["이걸로", "Use this"],
|
||||
Cancel: ["취소", "Cancel"],
|
||||
Which: ["표 고르기", "Choose table"],
|
||||
} as const;
|
||||
|
||||
const tt = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex as 0 | 1];
|
||||
|
||||
/* ── 식 읽기 · 짜기(DOM 없음) ─────────────────────────────────────────── */
|
||||
|
||||
export interface FindArg {
|
||||
/** 표 조건 칸 이름 */
|
||||
key: string;
|
||||
/** `=` 오른쪽 식 글 그대로 */
|
||||
rhs: string;
|
||||
}
|
||||
|
||||
export interface FindCall {
|
||||
/** 식 안 자리 — `찾기` 첫 글자부터 값 칸 끝까지 */
|
||||
start: number;
|
||||
end: number;
|
||||
table: string;
|
||||
args: FindArg[];
|
||||
col: string;
|
||||
}
|
||||
|
||||
const NAME = /[\p{L}\p{N}_]/u;
|
||||
const isName = (ch: string | undefined): boolean => !!ch && NAME.test(ch);
|
||||
|
||||
class Reader {
|
||||
i = 0;
|
||||
constructor(readonly s: string) {}
|
||||
ws(): void {
|
||||
while (this.i < this.s.length && /\s/.test(this.s[this.i])) this.i++;
|
||||
}
|
||||
take(ch: string): boolean {
|
||||
this.ws();
|
||||
if (this.s[this.i] !== ch) return false;
|
||||
this.i++;
|
||||
return true;
|
||||
}
|
||||
name(): string | null {
|
||||
this.ws();
|
||||
const start = this.i;
|
||||
while (isName(this.s[this.i])) this.i++;
|
||||
return this.i > start ? this.s.slice(start, this.i) : null;
|
||||
}
|
||||
quoted(): string | null {
|
||||
const end = this.s.indexOf("'", this.i);
|
||||
if (end < 0) return null;
|
||||
const text = this.s.slice(this.i, end);
|
||||
this.i = end + 1;
|
||||
return text;
|
||||
}
|
||||
/** 괄호 · 따옴표 밖 `,` · `)` 앞까지 */
|
||||
until(head: boolean): string | null {
|
||||
const start = this.i;
|
||||
let depth = 0;
|
||||
while (this.i < this.s.length) {
|
||||
const ch = this.s[this.i];
|
||||
if (ch === "'" && !head) {
|
||||
const end = this.s.indexOf("'", this.i + 1);
|
||||
if (end < 0) return null;
|
||||
this.i = end + 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "(" || ch === "{") depth++;
|
||||
else if (ch === ")" || ch === "}") {
|
||||
if (depth === 0) break;
|
||||
depth--;
|
||||
} else if (ch === "," && depth === 0) break;
|
||||
this.i++;
|
||||
}
|
||||
return this.i < this.s.length ? this.s.slice(start, this.i).trim() : null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 한 자리의 찾기 — 못 읽으면 null */
|
||||
function readOne(s: string, at: number): FindCall | null {
|
||||
const r = new Reader(s);
|
||||
r.i = at + 2;
|
||||
if (!r.take("(")) return null;
|
||||
r.ws();
|
||||
const table = r.until(true);
|
||||
if (!table || !/^[A-Za-z]{2}\d+$/.test(table)) return null; // {자리} 낀 키는 화면에서 안 다룸
|
||||
const args: FindArg[] = [];
|
||||
while (r.take(",")) {
|
||||
r.ws();
|
||||
const key = r.s[r.i] === "'" ? (r.i++, r.quoted()) : r.name();
|
||||
if (!key || !r.take("=")) return null;
|
||||
r.ws();
|
||||
const rhs = r.until(false);
|
||||
if (!rhs) return null;
|
||||
args.push({ key, rhs });
|
||||
}
|
||||
if (!r.take(")") || !r.take(".")) return null;
|
||||
r.ws();
|
||||
const col = r.s[r.i] === "'" ? (r.i++, r.quoted()) : r.name();
|
||||
if (!col) return null;
|
||||
return { start: at, end: r.i, table, args, col };
|
||||
}
|
||||
|
||||
/** 식 속 찾기 전부(바깥 것만 · 차례대로) — 하나라도 못 읽으면 null · 없으면 [] */
|
||||
export function readFinds(formula: string): FindCall[] | null {
|
||||
const out: FindCall[] = [];
|
||||
let i = 0;
|
||||
while (i < formula.length) {
|
||||
const ch = formula[i];
|
||||
if (ch === "'") {
|
||||
const end = formula.indexOf("'", i + 1);
|
||||
if (end < 0) return null;
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
if (formula.startsWith("찾기", i) && !isName(formula[i - 1]) && !isName(formula[i + 2])) {
|
||||
const call = readOne(formula, i);
|
||||
if (!call) return null;
|
||||
out.push(call);
|
||||
i = call.end;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const plain = (word: string): string => (/^[\p{L}\p{N}_]+$/u.test(word) ? word : `'${word}'`);
|
||||
|
||||
/** 찾기 하나를 글로 — 마스터 꼴 `찾기(표, 가=나, 다=라).칸` */
|
||||
export function writeFind(table: string, args: FindArg[], col: string): string {
|
||||
const parts = [table, ...args.map((a) => `${plain(a.key)}=${a.rhs}`)];
|
||||
return `찾기(${parts.join(", ")}).${plain(col)}`;
|
||||
}
|
||||
|
||||
/** 식의 찾기들을 갈아 끼움 — `swaps` 의 차례 = `readFinds` 차례 · null 은 그대로 */
|
||||
export function replaceFinds(formula: string, calls: FindCall[], swaps: (string | null)[]): string {
|
||||
let out = formula;
|
||||
for (let k = calls.length - 1; k >= 0; k--) {
|
||||
const text = swaps[k];
|
||||
if (text !== null && text !== undefined)
|
||||
out = out.slice(0, calls[k].start) + text + out.slice(calls[k].end);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 표 칸 값 → 식 글(수 · 따옴표 글) — 못 쓰면 null */
|
||||
export function literal(value: unknown, rule = ""): string | null {
|
||||
if (typeof value === "number") return String(value);
|
||||
if (typeof value === "string") return value.includes("'") ? null : `'${value}'`;
|
||||
if (Array.isArray(value)) {
|
||||
const n = rangeNumber(value as [number | null, number | null], rule);
|
||||
return n === null ? null : String(n);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 범위 칸 [아래, 위] 안에 드는 수 하나 — 엔진 `_match` 의 범위규칙대로 */
|
||||
export function rangeNumber(cell: [number | null, number | null], rule: string): number | null {
|
||||
const [low, high] = cell;
|
||||
const kind = (rule || "초과·이하").split(" · ")[0].split(" (")[0];
|
||||
const round = (n: number): number => Number(n.toFixed(6));
|
||||
if (low !== null && high !== null) return round((low + high) / 2);
|
||||
if (high !== null) {
|
||||
if (kind !== "이상·미만") return high;
|
||||
return round(high > 1 ? high - 1 : high / 2);
|
||||
}
|
||||
if (low !== null) return kind.startsWith("이상") ? low : low + 1;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 식 글이 따옴표 글 · 수면 그 값 */
|
||||
export function literalValue(rhs: string): string | number | null {
|
||||
const q = rhs.match(/^'([^']*)'$/);
|
||||
if (q) return q[1];
|
||||
return /^-?\d+(\.\d+)?$/.test(rhs) ? Number(rhs) : null;
|
||||
}
|
||||
|
||||
/* ── 표 · 서버 ──────────────────────────────────────────────────────── */
|
||||
|
||||
type Cell = string | number | [number | null, number | null] | null | undefined;
|
||||
interface EasyTable {
|
||||
이름?: string;
|
||||
기준?: string;
|
||||
범위규칙?: string;
|
||||
조건: Record<string, string>;
|
||||
값칸: Record<string, string>;
|
||||
줄: Record<string, Cell>[];
|
||||
}
|
||||
|
||||
const tables = new Map<string, Promise<EasyTable | null>>();
|
||||
|
||||
function loadTable(key: string): Promise<EasyTable | null> {
|
||||
const hit = tables.get(key);
|
||||
if (hit) return hit;
|
||||
const groups = key[0] === "C" ? ["계수", "소요량"] : ["소요량", "계수"];
|
||||
const job = (async (): Promise<EasyTable | null> => {
|
||||
for (const group of groups) {
|
||||
const found = await searchElements(group, key).catch(() => null);
|
||||
const file = found?.items.find((x) => x.ref === key)?.file;
|
||||
if (!file) continue;
|
||||
const got = await fetchTable(file, key).catch(() => null);
|
||||
if (got?.table) return got.table as unknown as EasyTable;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
tables.set(key, job);
|
||||
return job;
|
||||
}
|
||||
|
||||
/** 카드 줄을 새 식으로 — 호표면 `수량` · 덧붙임이면 `식` */
|
||||
function cardLine(card: EasyCard, formula: string): HoLine | NamedFormula {
|
||||
const raw = (card.raw ?? {}) as Record<string, unknown>;
|
||||
if (card.source?.part === "덧줄") return { ...raw, 이름: card.name, 식: formula } as NamedFormula;
|
||||
return {
|
||||
종류: "인력",
|
||||
요소: String(card.element ?? ""),
|
||||
이름: card.name,
|
||||
단위: card.unit,
|
||||
...raw,
|
||||
수량: formula,
|
||||
} as HoLine;
|
||||
}
|
||||
|
||||
/** 이 카드 하나만 든 시험 줄 — `/text` 답에서 그 줄 표 조각을 바로 찾으려고 */
|
||||
function probeRow(card: EasyCard, doc: EasyDoc, formula: string): LogicRow {
|
||||
const line = cardLine(card, formula);
|
||||
if (card.source?.part === "덧줄") return { ...doc.row, 키: "", 덧줄: [line as NamedFormula] };
|
||||
return { ...doc.row, 키: "", 호표: [line as HoLine], 덧줄: [] };
|
||||
}
|
||||
|
||||
/** 로직 전체 줄 — 카드 식만 바꿈 · Model 이 아직이면 원래 줄의 그 자리만 */
|
||||
function fullRow(card: EasyCard, doc: EasyDoc, formula: string): LogicRow {
|
||||
try {
|
||||
const cards = doc.cards.map((c) => (c.id === card.id ? { ...c, qtyFormula: formula } : c));
|
||||
return { ...toRow({ ...doc, cards }), 키: "" };
|
||||
} catch {
|
||||
const row: LogicRow = { ...doc.row, 키: "" };
|
||||
const at = card.source?.index ?? -1;
|
||||
if (card.source?.part === "덧줄" && row.덧줄?.[at])
|
||||
row.덧줄 = row.덧줄.map((x, i) => (i === at ? { ...x, 식: formula } : x));
|
||||
else if (row.호표?.[at])
|
||||
row.호표 = row.호표.map((x, i) => (i === at ? { ...x, 수량: formula } : x));
|
||||
return row;
|
||||
}
|
||||
}
|
||||
|
||||
interface Preview {
|
||||
ok: boolean;
|
||||
/** 찾기 차례 → 걸린 줄 차례(못 찾으면 null) */
|
||||
hits: (number | null)[];
|
||||
line: number | null;
|
||||
total: number | null;
|
||||
}
|
||||
|
||||
async function preview(
|
||||
card: EasyCard,
|
||||
doc: EasyDoc,
|
||||
formula: string,
|
||||
calls: FindCall[],
|
||||
inputs: Record<string, unknown>,
|
||||
): Promise<Preview> {
|
||||
const [text, calc] = await Promise.all([
|
||||
runText({ key: "", inputs, row: probeRow(card, doc, formula) }).catch(() => null),
|
||||
runCalc({ key: "", inputs, row: fullRow(card, doc, formula) }).catch(() => null),
|
||||
]);
|
||||
const lines = text?.ok
|
||||
? text.groups.flatMap((g) => g.줄).filter((l) => l.이름 === card.name)
|
||||
: [];
|
||||
const pieces = (lines[0]?.조각 ?? []).filter((p) => p.kind === "표");
|
||||
const seen = new Map<string, number>();
|
||||
const hits = calls.map((c) => {
|
||||
const nth = seen.get(c.table) ?? 0;
|
||||
seen.set(c.table, nth + 1);
|
||||
const piece = pieces.filter((p) => p.참조?.키 === c.table)[nth];
|
||||
return typeof piece?.참조?.줄 === "number" ? piece.참조.줄 : null;
|
||||
});
|
||||
const sum = lines.reduce((s, l) => s + (Number(l.금액) || 0), 0);
|
||||
return {
|
||||
ok: !!text?.ok && !!calc?.ok,
|
||||
hits,
|
||||
line: lines.length ? sum : null,
|
||||
total: calc?.ok && calc.sums ? (calc.sums["계"] ?? null) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 창 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
type Mode = "design" | "fix" | "asis";
|
||||
interface CondState {
|
||||
key: string;
|
||||
/** 원래 식 글 · 새로 더한 조건은 null */
|
||||
orig: string | null;
|
||||
mode: Mode;
|
||||
/** 설계 값 이름 — 있으면 「설계 때 정함」 고를 수 있음 */
|
||||
input: string | null;
|
||||
fix: Cell;
|
||||
}
|
||||
interface CallState {
|
||||
conds: CondState[];
|
||||
col: string;
|
||||
picked: number | null;
|
||||
/** 「설계 때 정함」 조건을 고른 줄 값으로 미리 봄 */
|
||||
tryValues: Record<string, unknown>;
|
||||
/** 마지막으로 누른 줄이 미리 보기만 한 「설계 때 정함」 조건 */
|
||||
designHint: string[];
|
||||
}
|
||||
|
||||
const showCell = (v: Cell): string => {
|
||||
if (v === null || v === undefined) return "";
|
||||
if (Array.isArray(v)) return `${v[0] ?? ""}~${v[1] ?? ""}`;
|
||||
return String(v);
|
||||
};
|
||||
|
||||
function startState(call: FindCall, doc: EasyDoc): CallState {
|
||||
const names = new Set(doc.inputs.map((x) => x.이름));
|
||||
const conds = call.args.map((a): CondState => {
|
||||
const input = names.has(a.rhs) ? a.rhs : names.has(a.key) ? a.key : null;
|
||||
const lit = literalValue(a.rhs);
|
||||
const mode: Mode = names.has(a.rhs) ? "design" : lit !== null ? "fix" : "asis";
|
||||
return { key: a.key, orig: a.rhs, mode, input, fix: lit };
|
||||
});
|
||||
return { conds, col: call.col, picked: null, tryValues: {}, designHint: [] };
|
||||
}
|
||||
|
||||
function stateArgs(st: CallState, table: EasyTable | null): FindArg[] | null {
|
||||
const out: FindArg[] = [];
|
||||
for (const c of st.conds) {
|
||||
if (c.mode === "design" && c.input) out.push({ key: c.key, rhs: c.input });
|
||||
else if (c.mode === "fix") {
|
||||
const rhs = literal(c.fix, table?.범위규칙);
|
||||
if (rhs === null) return null;
|
||||
out.push({ key: c.key, rhs });
|
||||
} else if (c.orig !== null) out.push({ key: c.key, rhs: c.orig });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 이 창 모양 그대로의 새 식 — 바뀐 것이 없으면 원래 식 */
|
||||
function compose(
|
||||
formula: string,
|
||||
calls: FindCall[],
|
||||
states: CallState[],
|
||||
tbl: (EasyTable | null)[],
|
||||
) {
|
||||
const swaps = calls.map((call, k) => {
|
||||
const args = stateArgs(states[k], tbl[k]);
|
||||
if (!args) return null;
|
||||
const same =
|
||||
states[k].col === call.col &&
|
||||
args.length === call.args.length &&
|
||||
args.every((a, i) => a.key === call.args[i].key && a.rhs === call.args[i].rhs);
|
||||
return same ? null : writeFind(call.table, args, states[k].col);
|
||||
});
|
||||
return replaceFinds(formula, calls, swaps);
|
||||
}
|
||||
|
||||
/** 잠금 카드 수량을 표에서 골라 바꿈 — 새 식 · 취소 null(설계.md 3절) · trial 없으면 자동값 */
|
||||
export function openEasyTable(
|
||||
card: EasyCard,
|
||||
doc: EasyDoc,
|
||||
trial?: Record<string, unknown>,
|
||||
): Promise<string | null> {
|
||||
const formula = card.qtyFormula ?? "";
|
||||
const calls = readFinds(formula);
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = (value: string | null): void => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
resolve(value);
|
||||
};
|
||||
openModal({
|
||||
title: tt("Title"),
|
||||
closeLabel: tt("Close"),
|
||||
dialogClass: "m01-easytable",
|
||||
beforeClose: () => (finish(null), true),
|
||||
mount: (body, close) => {
|
||||
if (!calls || !calls.length) {
|
||||
body.append(el("p", { className: "m01-easytable__note", text: tt("Cannot") }));
|
||||
return;
|
||||
}
|
||||
void mountTable(body, { card, doc, formula, calls, trial }, (v) => {
|
||||
finish(v);
|
||||
close();
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function mountTable(
|
||||
body: HTMLElement,
|
||||
ctx: {
|
||||
card: EasyCard;
|
||||
doc: EasyDoc;
|
||||
formula: string;
|
||||
calls: FindCall[];
|
||||
trial?: Record<string, unknown>;
|
||||
},
|
||||
finish: (value: string | null) => void,
|
||||
): Promise<void> {
|
||||
const { card, doc, formula, calls } = ctx;
|
||||
body.append(el("p", { className: "m01-easytable__note", text: tt("Loading") }));
|
||||
const [base, tbl] = await Promise.all([
|
||||
ctx.trial ?? (doc.key ? fetchAuto(doc.key) : Promise.resolve({})),
|
||||
Promise.all(calls.map((c) => loadTable(c.table))),
|
||||
]);
|
||||
const states = calls.map((c) => startState(c, doc));
|
||||
let now = 0;
|
||||
let last: Preview = { ok: false, hits: calls.map(() => null), line: null, total: null };
|
||||
let seq = 0;
|
||||
let busy = false;
|
||||
|
||||
const which = el("div", { className: "m01-easytable__which" });
|
||||
const head = el("div", { className: "m01-easytable__head" });
|
||||
const condBox = el("div", { className: "m01-easytable__conds" });
|
||||
const grid = el("div", { className: "m01-easytable__grid" });
|
||||
const money = el("div", { className: "m01-easytable__money" });
|
||||
const warn = el("p", { className: "m01-easytable__warn" });
|
||||
const hint = el("p", { className: "m01-easytable__hint" });
|
||||
const use = createButton({ label: tt("Use"), onClick: () => finish(newFormula()) });
|
||||
const cancel = createButton({
|
||||
label: tt("Cancel"),
|
||||
variant: "ghost",
|
||||
onClick: () => finish(null),
|
||||
});
|
||||
|
||||
const newFormula = (): string => compose(formula, calls, states, tbl);
|
||||
const inputs = (): Record<string, unknown> => ({ ...base, ...states[now].tryValues });
|
||||
|
||||
const refresh = async (): Promise<void> => {
|
||||
const mine = ++seq;
|
||||
busy = true;
|
||||
paint();
|
||||
const got = await preview(card, doc, newFormula(), calls, inputs());
|
||||
if (mine !== seq) return;
|
||||
last = got;
|
||||
busy = false;
|
||||
paint();
|
||||
};
|
||||
|
||||
const pickRow = (idx: number, col: string | null): void => {
|
||||
const table = tbl[now];
|
||||
const st = states[now];
|
||||
const row = table?.줄[idx];
|
||||
if (!table || !row) return;
|
||||
st.designHint = [];
|
||||
for (const key of Object.keys(table.조건)) {
|
||||
if (row[key] === undefined) continue;
|
||||
let c = st.conds.find((x) => x.key === key);
|
||||
if (!c) {
|
||||
const input = doc.inputs.some((x) => x.이름 === key) ? key : null;
|
||||
c = { key, orig: null, mode: "fix", input, fix: null };
|
||||
st.conds.push(c);
|
||||
}
|
||||
if (c.mode === "design" && c.input) {
|
||||
const n = Array.isArray(row[key])
|
||||
? rangeNumber(row[key] as [number | null, number | null], table.범위규칙 ?? "")
|
||||
: row[key];
|
||||
st.tryValues[c.input] = n;
|
||||
st.designHint.push(key);
|
||||
} else {
|
||||
c.mode = "fix";
|
||||
c.fix = row[key];
|
||||
}
|
||||
}
|
||||
if (col) st.col = col;
|
||||
st.picked = idx;
|
||||
void refresh();
|
||||
};
|
||||
|
||||
const setMode = (c: CondState, mode: Mode): void => {
|
||||
if (mode === "fix" && (c.fix === null || c.fix === undefined)) {
|
||||
const hit = last.hits[now];
|
||||
const row = hit === null ? null : tbl[now]?.줄[hit];
|
||||
c.fix = row?.[c.key] ?? (c.input ? (inputs()[c.input] as Cell) : null);
|
||||
}
|
||||
c.mode = mode;
|
||||
states[now].designHint = states[now].designHint.filter((k) => k !== c.key);
|
||||
void refresh();
|
||||
};
|
||||
|
||||
/** 누른 줄의 「설계 때 정함」 조건을 한 번에 그 줄 값으로 고정 */
|
||||
const fixPicked = (): void => {
|
||||
const st = states[now];
|
||||
const row = st.picked === null ? null : tbl[now]?.줄[st.picked];
|
||||
if (!row) return;
|
||||
for (const c of st.conds)
|
||||
if (st.designHint.includes(c.key)) {
|
||||
c.mode = "fix";
|
||||
c.fix = row[c.key];
|
||||
}
|
||||
st.designHint = [];
|
||||
void refresh();
|
||||
};
|
||||
|
||||
const paintHint = (): void => {
|
||||
const st = states[now];
|
||||
hint.replaceChildren(
|
||||
...(st.picked !== null && st.designHint.length
|
||||
? [
|
||||
el("span", { text: `${tt("DesignHint")} ` }),
|
||||
createButton({ label: tt("Fix"), size: "sm", onClick: fixPicked }),
|
||||
]
|
||||
: []),
|
||||
);
|
||||
};
|
||||
|
||||
const paintConds = (): void => {
|
||||
const st = states[now];
|
||||
condBox.replaceChildren(
|
||||
el("h4", { text: tt("Conds") }),
|
||||
...st.conds.map((c) => {
|
||||
const opts: [Mode, string][] = [];
|
||||
if (c.input) opts.push(["design", tt("Design")]);
|
||||
if (c.orig !== null && c.orig !== c.input && literalValue(c.orig) === null)
|
||||
opts.push(["asis", tt("AsIs")]);
|
||||
opts.push(["fix", tt("Fix")]);
|
||||
const hit = last.hits[now];
|
||||
const used = hit === null ? "" : showCell(tbl[now]?.줄[hit]?.[c.key]);
|
||||
const value =
|
||||
c.mode === "fix"
|
||||
? showCell(c.fix)
|
||||
: c.mode === "design" && c.input
|
||||
? showCell(inputs()[c.input] as Cell) || used
|
||||
: used;
|
||||
return el("div", {
|
||||
className: "m01-easytable__cond",
|
||||
children: [
|
||||
el("span", { className: "m01-easytable__cond-name", text: c.key }),
|
||||
el("span", { className: "m01-easytable__cond-value", text: value || tt("NotYet") }),
|
||||
el("span", {
|
||||
className: "m01-easytable__seg",
|
||||
children: opts.map(([mode, label]) => {
|
||||
const b = createButton({
|
||||
label,
|
||||
size: "sm",
|
||||
variant: c.mode === mode ? "filled" : "ghost",
|
||||
onClick: () => setMode(c, mode),
|
||||
});
|
||||
b.setAttribute("aria-pressed", String(c.mode === mode));
|
||||
return b;
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const paintGrid = (): void => {
|
||||
const table = tbl[now];
|
||||
if (!table) {
|
||||
grid.replaceChildren(el("p", { className: "m01-easytable__note", text: tt("NoTable") }));
|
||||
return;
|
||||
}
|
||||
const st = states[now];
|
||||
const conds = Object.keys(table.조건);
|
||||
const vals = Object.keys(table.값칸);
|
||||
const hit = last.hits[now];
|
||||
const th = (text: string, cls = ""): HTMLElement => el("th", { className: cls, text });
|
||||
const rows = table.줄.map((row, idx) => {
|
||||
const tr = el("tr", {
|
||||
className: `${idx === hit && !busy ? "is-hit" : ""} ${idx === st.picked ? "is-picked" : ""}`,
|
||||
children: [
|
||||
...conds.map((k) => el("td", { text: showCell(row[k]) })),
|
||||
...vals.map((k) =>
|
||||
el("td", {
|
||||
className: `is-value${k === st.col ? " is-col" : ""}`,
|
||||
text: showCell(row[k]),
|
||||
attrs: { "data-col": k },
|
||||
}),
|
||||
),
|
||||
],
|
||||
});
|
||||
tr.addEventListener("click", (ev) => {
|
||||
const cell = (ev.target as HTMLElement).closest("td");
|
||||
pickRow(idx, cell?.dataset.col ?? null);
|
||||
});
|
||||
return tr;
|
||||
});
|
||||
const unit = (k: string): string => (table.값칸[k] ? `${k} (${table.값칸[k]})` : k);
|
||||
const top = [
|
||||
...conds.map((k) => th(k)),
|
||||
...vals.map((k) => th(unit(k), k === st.col ? "is-col" : "")),
|
||||
];
|
||||
const thead = el("thead", { children: [el("tr", { children: top })] });
|
||||
grid.replaceChildren(el("table", { children: [thead, el("tbody", { children: rows })] }));
|
||||
};
|
||||
|
||||
const paint = (): void => {
|
||||
const table = tbl[now];
|
||||
which.replaceChildren(
|
||||
...(calls.length > 1
|
||||
? calls.map((_c, k) =>
|
||||
createButton({
|
||||
label: tbl[k]?.이름 ?? `${tt("Which")} ${k + 1}`,
|
||||
size: "sm",
|
||||
variant: k === now ? "filled" : "ghost",
|
||||
onClick: () => {
|
||||
now = k;
|
||||
void refresh();
|
||||
},
|
||||
}),
|
||||
)
|
||||
: []),
|
||||
);
|
||||
head.replaceChildren(
|
||||
el("strong", { text: [table?.이름, table?.기준].filter(Boolean).join(" · ") }),
|
||||
el("span", { className: "m01-easytable__note", text: tt("Hint") }),
|
||||
);
|
||||
paintConds();
|
||||
paintHint();
|
||||
paintGrid();
|
||||
const picked = states[now].picked;
|
||||
const mismatch = !busy && picked !== null && last.hits[now] !== picked;
|
||||
warn.textContent = mismatch ? tt("Mismatch") : "";
|
||||
const fmt = (v: number | null): string => (v === null ? "—" : `${formatMoney(v)} 원`);
|
||||
money.replaceChildren(
|
||||
el("span", { text: `${tt("Amount")} ${fmt(last.line)}` }),
|
||||
el("strong", { text: `${tt("Total")} ${fmt(last.total)}` }),
|
||||
el("span", {
|
||||
className: "m01-easytable__note",
|
||||
text: busy ? tt("Wait") : last.ok ? "" : tt("Stopped"),
|
||||
}),
|
||||
);
|
||||
money.classList.toggle("is-busy", busy);
|
||||
use.disabled = busy || mismatch || !last.ok;
|
||||
};
|
||||
|
||||
body.replaceChildren(
|
||||
which,
|
||||
head,
|
||||
condBox,
|
||||
hint,
|
||||
grid,
|
||||
money,
|
||||
warn,
|
||||
el("div", { className: "m01-easytable__actions", children: [cancel, use] }),
|
||||
);
|
||||
await refresh();
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_LB_Api.ts
|
||||
* 로직 만들기 판 Api — 고친 표로 예시값 계산(0.3초 · 최신만) · 표를 쓰는 로직(사용처) · 표 읽기
|
||||
* 서버 = `M01_LB_Calc.py`(`/api/m01/lb/*`) · 모양은 `docs/raw/M01_쉬운단가/로직판_A_정함.md` 「맞출 모양」
|
||||
* 시험: `resources/tester/test_m01_lb_calc.py`
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { fetchTable, fetchTables } from "../M01_MasterData_Api_Fetch";
|
||||
import { ApiError, type CalcAnswer, type LogicRow } from "../M01_MasterData_UI_Logic_Api";
|
||||
import { safeReason } from "../M01_MasterData_UI_Logic_Text";
|
||||
import { plainError } from "../EasyCalc/M01_EasyCalc_Api";
|
||||
|
||||
/** 표 키 → 표 사본(고친 값 포함 · 표 통째) — 서버가 메모리에서만 바꿔 끼움 */
|
||||
export type LbTables = Record<string, unknown>;
|
||||
|
||||
/** 표를 쓰는 로직 한 줄 — `via` = 표 「용도」 에 적힘 · 로직 글에 표 키가 있음 · 둘 다 */
|
||||
export interface LbTableUser {
|
||||
key: string;
|
||||
이름: string;
|
||||
via: "용도" | "글" | "둘";
|
||||
}
|
||||
|
||||
/** 표 한 벌 — `file` · `version` 은 표가 든 파일 */
|
||||
export interface LbTable {
|
||||
file: string;
|
||||
version: string;
|
||||
table: Record<string, unknown> & { 줄: Record<string, unknown>[] };
|
||||
}
|
||||
|
||||
const CALC_WAIT_MS = 300;
|
||||
const TABLE_GROUPS = ["소요량", "계수"]; // 표가 드는 그룹(계수 표가 소요량 파일에 든 것도 있음)
|
||||
|
||||
async function send<T>(path: string, body?: unknown): Promise<T> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_BASE_URL}/m01/lb${path}`, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError(0, "");
|
||||
}
|
||||
const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T;
|
||||
if (!response.ok) throw new ApiError(response.status, data.detail ?? response.statusText);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 로직이 받는 입력값만 — 모르는 이름을 보내면 서버가 멈춤 */
|
||||
function inputsFor(row: LogicRow, inputs: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
(row.입력 ?? []).filter((i) => i.이름 in inputs).map((i) => [i.이름, inputs[i.이름]]),
|
||||
);
|
||||
}
|
||||
|
||||
/** 계산 멈춤 까닭 — 표 키(`QF000421`)가 섞인 글은 뭉뚱그림 */
|
||||
function plainStop(reason: string): string {
|
||||
const plain = safeReason(reason);
|
||||
return /[A-Z]{2}\d{6}/.test(plain) ? safeReason("{}") : plain;
|
||||
}
|
||||
|
||||
let calcSeq = 0;
|
||||
let calcTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
/** 0.3초 뒤 고친 표로 서버 계산 · 최신 응답만 `onDone` · 멈춤 · 실패는 `plain` 쉬운 말 */
|
||||
export function lbCalcLater(
|
||||
row: LogicRow,
|
||||
inputs: Record<string, unknown>,
|
||||
tables: LbTables,
|
||||
onDone: (ans: CalcAnswer | null, plain: string | null) => void,
|
||||
): void {
|
||||
const mine = ++calcSeq;
|
||||
clearTimeout(calcTimer);
|
||||
calcTimer = setTimeout(() => {
|
||||
send<CalcAnswer>("/calc", { row, inputs: inputsFor(row, inputs), tables })
|
||||
.then((ans) => {
|
||||
if (mine === calcSeq) onDone(ans, ans.ok ? null : plainStop(ans.reason));
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (mine === calcSeq) onDone(null, plainError(e));
|
||||
});
|
||||
}, CALC_WAIT_MS);
|
||||
}
|
||||
|
||||
/** 기다림 없이 바로 셈(기대값 시험표처럼 여러 줄을 차례로) — `lbCalcLater` 의 최신만과 안 걸림.
|
||||
* 서버에 못 닿으면 null · 멈춤(ok false)은 답 그대로 · 둘 다 `onPlain` 에 쉬운 말 */
|
||||
export async function lbCalcNow(
|
||||
row: LogicRow,
|
||||
inputs: Record<string, unknown>,
|
||||
tables: LbTables,
|
||||
onPlain?: (plain: string) => void,
|
||||
): Promise<CalcAnswer | null> {
|
||||
try {
|
||||
const ans = await send<CalcAnswer>("/calc", { row, inputs: inputsFor(row, inputs), tables });
|
||||
if (!ans.ok) onPlain?.(plainStop(ans.reason));
|
||||
return ans;
|
||||
} catch (e: unknown) {
|
||||
onPlain?.(plainError(e));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 사용처 — 그 표를 쓰는 로직(표 「용도」 + 로직 글 대조) */
|
||||
export async function tableUsers(key: string): Promise<LbTableUser[]> {
|
||||
const got = await send<{ users: LbTableUser[] }>(
|
||||
`/table-users?${new URLSearchParams({ key }).toString()}`,
|
||||
);
|
||||
return got.users;
|
||||
}
|
||||
|
||||
/** 표 키 하나로 표 통째 — 든 파일을 찾아(소요량 · 계수) Api_Fetch 의 표 읽기로 */
|
||||
export async function fetchLbTable(key: string): Promise<LbTable> {
|
||||
for (const group of TABLE_GROUPS) {
|
||||
const page = await fetchTables(group, "", "", key, 1, 5);
|
||||
const head = page.tables.find((t) => t.키 === key);
|
||||
if (head) return { file: head.file, ...(await fetchTable(head.file, key)) };
|
||||
}
|
||||
throw new ApiError(404, `없는 표 「${key}」`);
|
||||
}
|
||||
@@ -1,665 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_LB_Blocks.ts
|
||||
* 로직 만들기 판 — 로직 줄 ↔ 블록(① 입력값 ② 표 조회 ③ 계산 ④ 항목 연결 ⑤ 결과값) 읽기 · 쓰기
|
||||
* DOM 없음 · 모양은 `docs/raw/M01_쉬운단가/로직판_A_설계초안.md` 7-1 · `로직판_A_정함.md` 그대로
|
||||
*
|
||||
* 정본 상태 = `lb.doc`(EasyCalc Model 의 EasyDoc) — 쓰기는 `toRow(lb.doc)` 하나
|
||||
* 읽기 = 식 자리(중간 · 수량 · 요소 · 비율 · 결과)마다 엔진 문법(`master_formula._Parser`)으로 읽어
|
||||
* ② 표 조회는 Table `readFinds` · ③ 조건 덩어리는 Cond `readCond` · 나머지 사칙 사슬은 여기서
|
||||
* 고치기 = 그 자리 글만 갈아끼운 줄을 다시 읽음(안 고친 자리는 글자까지 그대로)
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import type { LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api";
|
||||
import { readCond, writeRead, type CondRead } from "../EasyCalc/M01_EasyCalc_Cond";
|
||||
import { emptyRow, toDoc, toRow, type EasyDoc } from "../EasyCalc/M01_EasyCalc_Model";
|
||||
import { readFinds, writeFind, type FindArg } from "../EasyCalc/M01_EasyCalc_Table";
|
||||
|
||||
/** 값이 오는 곳 */
|
||||
export type LbRef =
|
||||
| { k: "input"; name: string } // ① 입력값
|
||||
| { k: "fixed"; value: number | string } // 고정값
|
||||
| { k: "lookup"; id: string } // ② 표 조회 결과
|
||||
| { k: "calc"; name: string } // ③ 이름 붙은 계산값(중간)
|
||||
| { k: "logic"; key: string; field: string | null } // 다른 로직 결과값(.노무비 등)
|
||||
| { k: "cost"; name: "노무비" | "재료비" | "경비" | "사람줄합" };
|
||||
|
||||
/** 블록으로 못 나눈 조각 — 원래 글 그대로 */
|
||||
export interface LbExpr {
|
||||
k: "expr";
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** 식 자리 — 중간 · 호표 수량 · 덧줄 · 결과 · 장비/일위대가 요소 */
|
||||
export type LbPart = "중간" | "수량" | "비율" | "결과" | "요소";
|
||||
|
||||
export interface LbLookup {
|
||||
/** 화면 번호 · 저장 안 함 */
|
||||
id: string;
|
||||
/** QF000421 · 현장 사본이면 QX… */
|
||||
table: string;
|
||||
conds: { col: string; from: LbRef | LbExpr }[];
|
||||
/** 결과 칸 */
|
||||
col: string;
|
||||
at: { part: LbPart; index: number; start: number; end: number }[];
|
||||
}
|
||||
|
||||
export type LbCalcKind =
|
||||
"값" | "곱나눔" | "할증" | "더하기" | "조건목록" | "최소최대" | "올림" | "단계";
|
||||
|
||||
export interface LbCalc {
|
||||
/** 중간 이름 · 수량 자리면 null */
|
||||
name: string | null;
|
||||
kind: LbCalcKind;
|
||||
/** 곱나눔 · 더하기 · 할증(바탕 · 율) */
|
||||
parts: (LbRef | LbExpr)[];
|
||||
ops?: ("*" | "/" | "+" | "-")[];
|
||||
/** 조건목록 · 최소최대 · 올림 — Cond 것 그대로 · 다른 틀이면 글 속 조건 덩어리 */
|
||||
cond?: CondRead;
|
||||
at: { part: Exclude<LbPart, "요소">; index: number };
|
||||
/** 원래 글(안 고치면 그대로 씀) */
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface LbLogic {
|
||||
/** ④ 항목 · ⑤ 금액 — Model 것 그대로 */
|
||||
doc: EasyDoc;
|
||||
/** ① */
|
||||
inputs: LogicInput[];
|
||||
/** ② */
|
||||
lookups: LbLookup[];
|
||||
/** ③ */
|
||||
calcs: LbCalc[];
|
||||
/** ⑤ */
|
||||
result: { kind: "금액"; unit: string } | { kind: "값"; unit: string; calc: LbCalc };
|
||||
/** 못 읽은 까닭(쉬운 말) — 비면 다 읽힘 */
|
||||
blocked: string[];
|
||||
}
|
||||
|
||||
/** 원문 표 칸 고치기(메모리 사본) */
|
||||
export interface TableEdit {
|
||||
table: string;
|
||||
row: number;
|
||||
col: string;
|
||||
value: number | string | [number | null, number | null];
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
BadText: ["읽을 수 없는 글이 있음", "Has text that can't be read"],
|
||||
CondInLookup: ["표 조회 조건에 계산이 들어 있음", "A table lookup condition is calculated"],
|
||||
AndOr: ["조건에 「그리고 · 또는」이 섞임", "A condition mixes and/or"],
|
||||
PassCalc: ["넘길 값에 계산이 들어 있음", "A passed value is calculated"],
|
||||
Unknown: ["모르는 이름이 있음", "Has an unknown name"],
|
||||
Result: ["결과값", "Result value"],
|
||||
Qty: ["수량", "quantity"],
|
||||
} as const;
|
||||
|
||||
const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
||||
|
||||
/* ── 식 읽기(엔진 문법) ─────────────────────────────────────────────────── */
|
||||
|
||||
type Node = {
|
||||
t:
|
||||
"num" | "str" | "name" | "eref" | "paren" | "neg" | "field" | "call" | "find" | "logic" | "bin";
|
||||
v?: string;
|
||||
f?: string;
|
||||
op?: string;
|
||||
a?: Node;
|
||||
b?: Node;
|
||||
items?: Node[];
|
||||
head?: string;
|
||||
args?: [string, Node][];
|
||||
src: string;
|
||||
};
|
||||
|
||||
const CMPS = ["<=", ">=", "==", "!=", "<", ">"];
|
||||
const ROUND = ["올림", "버림", "반올림"];
|
||||
|
||||
function parse(s: string): Node | null {
|
||||
let i = 0;
|
||||
const fail = (): never => {
|
||||
throw new Error("parse");
|
||||
};
|
||||
const ws = (): void => {
|
||||
while (i < s.length && /\s/.test(s[i])) i++;
|
||||
};
|
||||
const take = (w: string): boolean => {
|
||||
ws();
|
||||
if (!s.startsWith(w, i)) return false;
|
||||
i += w.length;
|
||||
return true;
|
||||
};
|
||||
const need = (w: string): void => void (take(w) || fail());
|
||||
const stick = (re: RegExp): string | null => {
|
||||
re.lastIndex = i;
|
||||
const m = re.exec(s);
|
||||
if (m) i = re.lastIndex;
|
||||
return m ? m[0] : null;
|
||||
};
|
||||
const word = (): string => (ws(), stick(/[\p{L}\p{N}_]+/uy) ?? fail());
|
||||
const quoted = (): string => {
|
||||
const e = s.indexOf("'", i);
|
||||
if (e < 0) fail();
|
||||
const v = s.slice(i, e);
|
||||
i = e + 1;
|
||||
return v;
|
||||
};
|
||||
/** 키 · 원문번호 글 — head 면 , ) 에서만 멈춤 */
|
||||
const ref = (head: boolean): string => {
|
||||
const st = i;
|
||||
for (let d = 0; i < s.length; i++) {
|
||||
const c = s[i];
|
||||
if ("({".includes(c)) d++;
|
||||
else if (")}".includes(c)) {
|
||||
if (!d) break;
|
||||
d--;
|
||||
} else if (!d && c === ",") break;
|
||||
else if (head) continue;
|
||||
else if (!d && c === "." && !/\d/.test(s[i + 1] ?? "")) break;
|
||||
else if (!d && /^\s[-+*/<>=!]/.test(s.slice(i, i + 2))) break;
|
||||
}
|
||||
return s.slice(st, i).trim();
|
||||
};
|
||||
const mk = (st: number, o: Omit<Node, "src">): Node => ({ ...o, src: s.slice(st, i).trim() });
|
||||
const fields = (st: number, n: Node): Node => {
|
||||
while (take(".")) n = mk(st, { t: "field", a: n, f: take("'") ? quoted() : word() });
|
||||
return n;
|
||||
};
|
||||
const args = (): [string, Node][] => {
|
||||
const out: [string, Node][] = [];
|
||||
while (take(",")) {
|
||||
const k = take("'") ? quoted() : word();
|
||||
need("=");
|
||||
out.push([k, expr()]);
|
||||
}
|
||||
need(")");
|
||||
return out;
|
||||
};
|
||||
const primary = (): Node => {
|
||||
ws();
|
||||
const st = i;
|
||||
if (take("(")) {
|
||||
const a = expr();
|
||||
need(")");
|
||||
return fields(st, mk(st, { t: "paren", a }));
|
||||
}
|
||||
if (take("'")) return mk(st, { t: "str", v: quoted() });
|
||||
const n = stick(/\d+(?:\.\d+)?/y);
|
||||
if (n) return mk(st, { t: "num", v: n });
|
||||
const w = word();
|
||||
if (w === "찾기" || w === "로직") {
|
||||
need("(");
|
||||
ws();
|
||||
const head = ref(true);
|
||||
return fields(st, mk(st, { t: w === "찾기" ? "find" : "logic", head, args: args() }));
|
||||
}
|
||||
if (["만약", "최소", "최대", ...ROUND].includes(w)) {
|
||||
need("(");
|
||||
const items = [expr()];
|
||||
while (take(",")) items.push(expr());
|
||||
need(")");
|
||||
return fields(st, mk(st, { t: "call", f: w, items }));
|
||||
}
|
||||
if (s[i] === ":") {
|
||||
i++;
|
||||
ref(false);
|
||||
return fields(st, mk(st, { t: "eref" }));
|
||||
}
|
||||
return fields(st, mk(st, { t: "name", v: w }));
|
||||
};
|
||||
const unary = (): Node => {
|
||||
ws();
|
||||
const st = i;
|
||||
return take("-") ? mk(st, { t: "neg", a: unary() }) : primary();
|
||||
};
|
||||
const chain = (next: () => Node, ops: string[], once = false): Node => {
|
||||
ws();
|
||||
const st = i;
|
||||
let n = next();
|
||||
for (let op = ops.find(take); op; op = once ? undefined : ops.find(take))
|
||||
n = mk(st, { t: "bin", op, a: n, b: next() });
|
||||
return n;
|
||||
};
|
||||
const mul = (): Node => chain(unary, ["*", "/"]);
|
||||
const add = (): Node => chain(mul, ["+", "-"]);
|
||||
const cmp = (): Node => chain(add, CMPS, true);
|
||||
const both = (): Node => chain(cmp, ["그리고"]);
|
||||
const expr = (): Node => chain(both, ["또는"]);
|
||||
try {
|
||||
const node = expr();
|
||||
ws();
|
||||
return i === s.length ? node : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const un = (n: Node): Node => (n.t === "paren" ? un(n.a!) : n);
|
||||
const flat = (s: string): string => s.replace(/\s+/g, "");
|
||||
const KEY = /^[A-Z]{2}\d{6}$/;
|
||||
|
||||
/* ── 읽기 ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
interface Ctx {
|
||||
row: LogicRow;
|
||||
inputs: Set<string>;
|
||||
mids: Set<string>;
|
||||
/** 찾기 글(빈칸 뺌) → 표 조회 번호 */
|
||||
finds: Map<string, string>;
|
||||
/** 사람 줄 노임 합 글(빈칸 뺌) — 비율 바탕 */
|
||||
personSum: string | null;
|
||||
bad: Set<string>;
|
||||
}
|
||||
|
||||
/** 조각의 원래 글 — 안 바뀐 조각은 이 글을 그대로 씀 */
|
||||
const SRC = new WeakMap<object, { text: string; snap: string }>();
|
||||
const keep = <T extends object>(ref: T, text: string): T => {
|
||||
SRC.set(ref, { text, snap: JSON.stringify(ref) });
|
||||
return ref;
|
||||
};
|
||||
|
||||
const isSimple = (n: Node, c: Ctx): boolean => {
|
||||
const u = un(n);
|
||||
if (u.t === "num" || u.t === "str") return true;
|
||||
if (u.t === "neg") return un(u.a!).t === "num";
|
||||
return u.t === "name" && (c.inputs.has(u.v!) || c.mids.has(u.v!));
|
||||
};
|
||||
|
||||
/** 식 나무를 훑어 못 읽는 까닭을 모음 */
|
||||
function check(n: Node, c: Ctx): void {
|
||||
const u = un(n);
|
||||
if (u.t === "bin" && (u.op === "그리고" || u.op === "또는")) c.bad.add(tl("AndOr"));
|
||||
if (u.t === "find" && u.args!.some(([, v]) => !isSimple(v, c))) c.bad.add(tl("CondInLookup"));
|
||||
if (u.t === "logic" && u.args!.some(([, v]) => !isSimple(v, c))) c.bad.add(tl("PassCalc"));
|
||||
if (u.t === "find" || u.t === "logic") return;
|
||||
if (u.t === "name") {
|
||||
const v = u.v!;
|
||||
const known =
|
||||
c.inputs.has(v) ||
|
||||
c.mids.has(v) ||
|
||||
KEY.test(v) ||
|
||||
["노무비", "재료비", "경비", "계", "줄"].includes(v);
|
||||
const item = (c.row.호표 ?? []).some((h) => h.이름 === v);
|
||||
if (!known && !item) c.bad.add(tl("Unknown"));
|
||||
}
|
||||
for (const x of [u.a, u.b, ...(u.items ?? [])]) if (x) check(x, c);
|
||||
}
|
||||
|
||||
/** 잎 하나 → 값이 오는 곳 */
|
||||
function refOf(node: Node, c: Ctx): LbRef | LbExpr {
|
||||
const u = un(node);
|
||||
const text = node.src;
|
||||
if (u.t === "num") return keep({ k: "fixed", value: Number(u.v) }, text);
|
||||
if (u.t === "neg" && un(u.a!).t === "num")
|
||||
return keep({ k: "fixed", value: -Number(un(u.a!).v) }, text);
|
||||
if (u.t === "str") return keep({ k: "fixed", value: u.v! }, text);
|
||||
if (u.t === "name" && c.inputs.has(u.v!)) return keep({ k: "input", name: u.v! }, text);
|
||||
if (u.t === "name" && c.mids.has(u.v!)) return keep({ k: "calc", name: u.v! }, text);
|
||||
if (u.t === "name" && ["노무비", "재료비", "경비"].includes(u.v!))
|
||||
return keep({ k: "cost", name: u.v as "노무비" | "재료비" | "경비" }, text);
|
||||
if (c.personSum !== null && flat(text) === c.personSum)
|
||||
return keep({ k: "cost", name: "사람줄합" }, text);
|
||||
if (u.t === "field" && u.a!.t === "find") {
|
||||
const id = c.finds.get(flat(u.src));
|
||||
if (id) return keep({ k: "lookup", id }, text);
|
||||
}
|
||||
if (u.t === "logic" || (u.t === "field" && u.a!.t === "logic")) {
|
||||
const call = u.t === "logic" ? u : u.a!;
|
||||
return keep({ k: "logic", key: call.head!, field: u.t === "field" ? u.f! : null }, text);
|
||||
}
|
||||
return { k: "expr", text };
|
||||
}
|
||||
|
||||
const isLeaf = (n: Node): boolean => {
|
||||
const u = un(n);
|
||||
return (
|
||||
["num", "str", "name", "field", "logic", "eref"].includes(u.t) ||
|
||||
(u.t === "neg" && un(u.a!).t === "num")
|
||||
);
|
||||
};
|
||||
|
||||
/** 같은 연산 사슬 — a op b op c … */
|
||||
function chainOf(n: Node, ops: string[]): { items: Node[]; ops: string[] } {
|
||||
const u = un(n);
|
||||
if (u.t !== "bin" || !ops.includes(u.op!) || u !== n) return { items: [n], ops: [] };
|
||||
const left = chainOf(u.a!, ops);
|
||||
return { items: [...left.items, u.b!], ops: [...left.ops, u.op!] };
|
||||
}
|
||||
|
||||
function calcOf(text: string, at: LbCalc["at"], name: string | null, c: Ctx): LbCalc {
|
||||
const base = { name, at, text, parts: [] as (LbRef | LbExpr)[] };
|
||||
const node = parse(text);
|
||||
if (!node) {
|
||||
c.bad.add(tl("BadText"));
|
||||
return { ...base, kind: "단계" };
|
||||
}
|
||||
check(node, c);
|
||||
const u = un(node);
|
||||
const inner = (): CondRead | undefined => {
|
||||
const r = readCond(text, c.row);
|
||||
return r && r.via === null ? r : undefined;
|
||||
};
|
||||
if (isLeaf(u)) return { ...base, kind: "값", parts: [refOf(node, c)] };
|
||||
if (u.t === "call") {
|
||||
const kind: LbCalcKind =
|
||||
u.f === "만약" ? "조건목록" : ROUND.includes(u.f!) ? "올림" : "최소최대";
|
||||
const cond = inner();
|
||||
const whole =
|
||||
cond &&
|
||||
cond.blocks.length === 1 &&
|
||||
cond.blocks[0].p[0] === 0 &&
|
||||
cond.blocks[0].p[1] >= cond.text.length;
|
||||
return whole ? { ...base, kind, cond } : { ...base, kind: "단계", cond };
|
||||
}
|
||||
if (u.t === "bin" && u.op === "*") {
|
||||
const r = un(u.b!);
|
||||
if (r.t === "bin" && r.op === "+" && un(r.a!).t === "num" && un(r.a!).v === "1") {
|
||||
const rate = un(r.b!);
|
||||
if (rate.t === "bin" && rate.op === "/" && un(rate.b!).t === "num" && un(rate.b!).v === "100")
|
||||
return { ...base, kind: "할증", parts: [refOf(u.a!, c), refOf(rate.a!, c)], cond: inner() };
|
||||
}
|
||||
}
|
||||
for (const [kind, ops] of [
|
||||
["곱나눔", ["*", "/"]],
|
||||
["더하기", ["+", "-"]],
|
||||
] as const) {
|
||||
const ch = chainOf(u === node ? node : u, [...ops]);
|
||||
if (ch.items.length > 1)
|
||||
return {
|
||||
...base,
|
||||
kind,
|
||||
parts: ch.items.map((x) => refOf(x, c)),
|
||||
ops: ch.ops as LbCalc["ops"],
|
||||
cond: inner(),
|
||||
};
|
||||
}
|
||||
return { ...base, kind: "단계", cond: inner() };
|
||||
}
|
||||
|
||||
/** 사람 줄 노임 합 — 「(수량 * 노임 + …)」 꼴 글 · 사람 줄이 없으면 null */
|
||||
function personSumText(row: LogicRow): string | null {
|
||||
const terms = (row.호표 ?? [])
|
||||
.filter((h) => h.종류 === "인력")
|
||||
.map((h) => {
|
||||
const q = String(h.수량 ?? "").trim();
|
||||
return `${/^[\p{L}\p{N}_.]+$/u.test(q) ? q : `(${q})`} * ${String(h.요소)}`;
|
||||
});
|
||||
return terms.length ? `(${terms.join(" + ")})` : null;
|
||||
}
|
||||
|
||||
/** 식 자리 모두 — 차례 = 중간 · 수량 · 요소 · 비율 · 결과 */
|
||||
function placesOf(row: LogicRow): { part: LbPart; index: number; text: string; label: string }[] {
|
||||
const ho = row.호표 ?? [];
|
||||
return [
|
||||
...(row.중간 ?? []).map((m, i) => ({
|
||||
part: "중간" as const,
|
||||
index: i,
|
||||
text: m.식,
|
||||
label: m.이름,
|
||||
})),
|
||||
...ho.map((h, i) => ({
|
||||
part: "수량" as const,
|
||||
index: i,
|
||||
text: String(h.수량 ?? ""),
|
||||
label: `${h.이름 ?? ""} ${tl("Qty")}`,
|
||||
})),
|
||||
...ho
|
||||
.map((h, i) => ({ part: "요소" as const, index: i, text: h.요소, label: h.이름 ?? "" }))
|
||||
.filter(
|
||||
(p): p is { part: "요소"; index: number; text: string; label: string } =>
|
||||
typeof p.text === "string" && /^\s*로직\s*\(/.test(p.text),
|
||||
),
|
||||
...(row.덧줄 ?? []).map((d, i) => ({
|
||||
part: "비율" as const,
|
||||
index: i,
|
||||
text: d.식,
|
||||
label: d.이름,
|
||||
})),
|
||||
...(row.결과
|
||||
? [{ part: "결과" as const, index: 0, text: row.결과.식, label: tl("Result") }]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
/** 로직 줄 → 블록 */
|
||||
export function readLogic(
|
||||
row: LogicRow,
|
||||
meta: { key: string | null; version: string | null; isBase: boolean },
|
||||
): LbLogic {
|
||||
const doc = toDoc(row, meta);
|
||||
const ps = personSumText(row);
|
||||
const c: Ctx = {
|
||||
row,
|
||||
inputs: new Set((row.입력 ?? []).map((x) => x.이름)),
|
||||
mids: new Set((row.중간 ?? []).map((x) => x.이름)),
|
||||
finds: new Map(),
|
||||
personSum: ps === null ? null : flat(ps),
|
||||
bad: new Set(),
|
||||
};
|
||||
const lookups: LbLookup[] = [];
|
||||
const blocked: string[] = [];
|
||||
const places = placesOf(row);
|
||||
for (const p of places) {
|
||||
const calls = readFinds(p.text);
|
||||
if (!calls) {
|
||||
blocked.push(`${p.label} — ${tl("BadText")}`);
|
||||
continue;
|
||||
}
|
||||
for (const call of calls) {
|
||||
const key = flat(p.text.slice(call.start, call.end));
|
||||
let id = c.finds.get(key);
|
||||
if (!id) {
|
||||
id = `L${lookups.length + 1}`;
|
||||
c.finds.set(key, id);
|
||||
const look: LbLookup = { id, table: call.table, col: call.col, conds: [], at: [] };
|
||||
look.conds = call.args.map((a) => ({ col: a.key, from: argRef(a, c) }));
|
||||
ARGS.set(look, call.args);
|
||||
lookups.push(look);
|
||||
}
|
||||
lookups
|
||||
.find((x) => x.id === id)!
|
||||
.at.push({ part: p.part, index: p.index, start: call.start, end: call.end });
|
||||
}
|
||||
}
|
||||
const calcs: LbCalc[] = [];
|
||||
for (const p of places) {
|
||||
if (p.part === "요소") {
|
||||
const n = parse(p.text);
|
||||
c.bad.clear();
|
||||
if (n) check(n, c);
|
||||
else c.bad.add(tl("BadText"));
|
||||
for (const b of c.bad) blocked.push(`${p.label} — ${b}`);
|
||||
continue;
|
||||
}
|
||||
c.bad.clear();
|
||||
const name = p.part === "중간" ? p.label : null;
|
||||
calcs.push(calcOf(p.text, { part: p.part, index: p.index }, name, c));
|
||||
for (const b of c.bad) blocked.push(`${p.label} — ${b}`);
|
||||
}
|
||||
const resultCalc = calcs.find((x) => x.at.part === "결과");
|
||||
return {
|
||||
doc,
|
||||
inputs: row.입력 ?? [],
|
||||
lookups,
|
||||
calcs,
|
||||
result: resultCalc
|
||||
? { kind: "값", unit: row.결과단위, calc: resultCalc }
|
||||
: { kind: "금액", unit: row.결과단위 },
|
||||
blocked: [...new Set(blocked)],
|
||||
};
|
||||
}
|
||||
|
||||
/** 표 조회의 원래 조건 글 — 안 바꾼 조건은 이 글 그대로 */
|
||||
const ARGS = new WeakMap<LbLookup, FindArg[]>();
|
||||
|
||||
function argRef(a: FindArg, c: Ctx): LbRef | LbExpr {
|
||||
const n = parse(a.rhs);
|
||||
return n && isSimple(n, c) ? refOf(n, c) : { k: "expr", text: a.rhs };
|
||||
}
|
||||
|
||||
/* ── 쓰기 ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 블록 → 로직 줄 */
|
||||
export function writeLogic(lb: LbLogic): LogicRow {
|
||||
return toRow(lb.doc);
|
||||
}
|
||||
|
||||
/** 단계 안내가 채울 빈 로직 */
|
||||
export function newLogic(name = "", per = "㎡"): LbLogic {
|
||||
return readLogic(emptyRow(name, per), { key: null, version: null, isBase: false });
|
||||
}
|
||||
|
||||
const metaOf = (lb: LbLogic) => ({
|
||||
key: lb.doc.key,
|
||||
version: lb.doc.version,
|
||||
isBase: lb.doc.isBase,
|
||||
});
|
||||
|
||||
/** 한 자리 글 읽기 · 쓰기 */
|
||||
function textAt(row: LogicRow, part: LbPart, index: number): string {
|
||||
if (part === "중간") return row.중간[index].식;
|
||||
if (part === "비율") return row.덧줄![index].식;
|
||||
if (part === "결과") return row.결과!.식;
|
||||
const h = row.호표![index];
|
||||
return String(part === "수량" ? h.수량 : h.요소);
|
||||
}
|
||||
|
||||
function putText(row: LogicRow, part: LbPart, index: number, text: string): void {
|
||||
if (part === "중간") row.중간[index] = { ...row.중간[index], 식: text };
|
||||
else if (part === "비율") row.덧줄![index] = { ...row.덧줄![index], 식: text };
|
||||
else if (part === "결과") row.결과 = { ...row.결과!, 식: text };
|
||||
else if (part === "수량") row.호표![index] = { ...row.호표![index], 수량: text };
|
||||
else row.호표![index] = { ...row.호표![index], 요소: text };
|
||||
}
|
||||
|
||||
/** 자리마다 [처음, 끝, 새 글] 을 뒤에서부터 갈아끼우고 다시 읽음 */
|
||||
function splice(
|
||||
lb: LbLogic,
|
||||
edits: { part: LbPart; index: number; start: number; end: number; text: string }[],
|
||||
): LbLogic {
|
||||
const row = writeLogic(lb);
|
||||
const groups = new Map<string, typeof edits>();
|
||||
for (const e of edits)
|
||||
groups.set(`${e.part}:${e.index}`, [...(groups.get(`${e.part}:${e.index}`) ?? []), e]);
|
||||
for (const list of groups.values()) {
|
||||
const { part, index } = list[0];
|
||||
let text = textAt(row, part, index);
|
||||
for (const e of [...list].sort((x, y) => y.start - x.start))
|
||||
text = text.slice(0, e.start) + e.text + text.slice(e.end);
|
||||
putText(row, part, index, text);
|
||||
}
|
||||
return readLogic(row, metaOf(lb));
|
||||
}
|
||||
|
||||
const numText = (n: number): string => {
|
||||
const t = String(Number(n.toPrecision(12)));
|
||||
return t.includes("e") ? n.toFixed(12).replace(/\.?0+$/, "") : t;
|
||||
};
|
||||
|
||||
/** 조각 → 글 — 안 바뀐 조각은 원래 글 */
|
||||
function refText(ref: LbRef | LbExpr, lb: LbLogic): string {
|
||||
const src = SRC.get(ref);
|
||||
if (src && src.snap === JSON.stringify(ref)) return src.text;
|
||||
switch (ref.k) {
|
||||
case "expr":
|
||||
return ref.text;
|
||||
case "input":
|
||||
case "calc":
|
||||
return ref.name;
|
||||
case "fixed":
|
||||
return typeof ref.value === "number" ? numText(ref.value) : `'${ref.value}'`;
|
||||
case "cost":
|
||||
return ref.name === "사람줄합" ? (personSumText(writeLogic(lb)) ?? "0") : ref.name;
|
||||
case "logic":
|
||||
return `로직(${ref.key})${ref.field ? `.${ref.field}` : ""}`;
|
||||
case "lookup": {
|
||||
const look = lb.lookups.find((x) => x.id === ref.id);
|
||||
return look ? findText(look, look.conds, look.table) : "0";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 사칙 사슬 칸 — 낮은 연산(+ -)이 든 조각은 괄호로 */
|
||||
function wrap(text: string, strong: boolean): string {
|
||||
const n = parse(text);
|
||||
const low = n && n.t === "bin" && ["+", "-"].includes(n.op!);
|
||||
const mid = n && n.t === "bin" && ["*", "/"].includes(n.op!);
|
||||
return low || (strong && mid) ? `(${text})` : text;
|
||||
}
|
||||
|
||||
function findText(look: LbLookup, conds: LbLookup["conds"], table: string): string {
|
||||
const raw = ARGS.get(look) ?? [];
|
||||
const args: FindArg[] = conds.map((cd) => {
|
||||
const old = raw.find((a) => a.key === cd.col);
|
||||
const same = old && look.conds.find((x) => x.col === cd.col)?.from === cd.from;
|
||||
return {
|
||||
key: cd.col,
|
||||
rhs: same ? old.rhs : refText(cd.from, { lookups: [] } as unknown as LbLogic),
|
||||
};
|
||||
});
|
||||
return writeFind(table, args, look.col);
|
||||
}
|
||||
|
||||
/** 표 조회 조건 칸 하나 바꾸기 · from = null 은 조건 뺌(무관) */
|
||||
export function setCond(
|
||||
lb: LbLogic,
|
||||
lookupId: string,
|
||||
col: string,
|
||||
from: LbRef | LbExpr | null,
|
||||
): LbLogic {
|
||||
const look = lb.lookups.find((x) => x.id === lookupId);
|
||||
if (!look) return lb;
|
||||
const has = look.conds.some((x) => x.col === col);
|
||||
const conds =
|
||||
from === null
|
||||
? look.conds.filter((x) => x.col !== col)
|
||||
: has
|
||||
? look.conds.map((x) => (x.col === col ? { col, from } : x))
|
||||
: [...look.conds, { col, from }];
|
||||
const text = findText(look, conds, look.table);
|
||||
return splice(
|
||||
lb,
|
||||
look.at.map((a) => ({ ...a, text })),
|
||||
);
|
||||
}
|
||||
|
||||
/** 표 조회가 보는 표 바꾸기(현장 사본 등) */
|
||||
export function setLookupTable(lb: LbLogic, lookupId: string, table: string): LbLogic {
|
||||
const look = lb.lookups.find((x) => x.id === lookupId);
|
||||
if (!look) return lb;
|
||||
const text = findText(look, look.conds, table);
|
||||
return splice(
|
||||
lb,
|
||||
look.at.map((a) => ({ ...a, text })),
|
||||
);
|
||||
}
|
||||
|
||||
/** 계산 하나 바꾸기 — patch 의 틀 · 조각 · 조건으로 그 자리 글을 다시 짬(patch.text 가 있으면 그 글) */
|
||||
export function setCalc(lb: LbLogic, calc: LbCalc, patch: Partial<LbCalc>): LbLogic {
|
||||
const next: LbCalc = { ...calc, ...patch };
|
||||
const old = textAt(writeLogic(lb), calc.at.part, calc.at.index);
|
||||
const text = patch.text !== undefined ? patch.text : buildText(next, lb, old);
|
||||
return splice(lb, [{ ...calc.at, start: 0, end: old.length, text }]);
|
||||
}
|
||||
|
||||
function buildText(c: LbCalc, lb: LbLogic, old: string): string {
|
||||
const parts = c.parts.map((p) => refText(p, lb));
|
||||
switch (c.kind) {
|
||||
case "값":
|
||||
return parts[0] ?? old;
|
||||
case "할증":
|
||||
return `${wrap(parts[0], false)} * (1 + ${wrap(parts[1], true)} / 100)`;
|
||||
case "곱나눔":
|
||||
case "더하기": {
|
||||
const ops = c.ops ?? parts.slice(1).map(() => (c.kind === "곱나눔" ? "*" : "+"));
|
||||
const strong = c.kind === "곱나눔";
|
||||
return parts.reduce(
|
||||
(acc, p, i) =>
|
||||
i ? `${acc} ${ops[i - 1]} ${wrap(p, strong && ops[i - 1] === "/")}` : wrap(p, false),
|
||||
"",
|
||||
);
|
||||
}
|
||||
default:
|
||||
return c.cond ? writeRead(c.cond) : old;
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
"""로직 만들기 판 시험 길(PLAN 35-2) — 고친 표로 시험 계산 · 표를 쓰는 로직.
|
||||
|
||||
POST /api/m01/lb/calc {row, inputs, tables: {표 키: 표 통째}} → `/calc` 와 같은 답
|
||||
GET /api/m01/lb/table-users ?key=표 키 → {key, users: [{key, 이름, via}]}
|
||||
|
||||
계산은 `/calc`(`M01_MasterData_Store.calc`)와 같은 길 — 부를 때마다 새로 읽은 마스터 사본
|
||||
(`cm.load`)에 로직 줄 · 표를 바꿔 끼워 셈. 전역 캐시 · 파일은 안 건드림(동시 요청에 안전) ·
|
||||
표 고침은 저장하지 않음(정함 5). 권한 = `/api/m01` 과 같음(등록 자리 `main.py` 한 줄 ·
|
||||
폴더째 지우면 그 줄도 지움).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
from M01_MasterData.M01_MasterData_Router import _num
|
||||
|
||||
cm = store.cm
|
||||
router = APIRouter(prefix="/api/m01/lb", tags=["M01 LogicBuilder (시험)"])
|
||||
|
||||
|
||||
class LbCalcBody(BaseModel):
|
||||
row: dict[str, Any]
|
||||
inputs: dict[str, Any] = {}
|
||||
tables: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _put_tables(files: dict[str, dict], tables: dict[str, dict]) -> None:
|
||||
"""표 키 → 고친 표 — 그 표가 든 파일 사본 자리에 바꿔 끼움 · 키는 못 바꿈 · 없는 표는 404."""
|
||||
for key, table in tables.items():
|
||||
for data in files.values():
|
||||
if data.get("그룹") not in cm.mf.TABLE_GROUPS:
|
||||
continue
|
||||
items = data.get("표") or []
|
||||
at = next((i for i, t in enumerate(items) if str(t.get("키")) == key), None)
|
||||
if at is not None:
|
||||
items[at] = {**store._dec(table), "키": key}
|
||||
break
|
||||
else:
|
||||
raise store.StoreError(404, f"없는 표 「{key}」")
|
||||
|
||||
|
||||
def calc(row: dict, inputs: dict, tables: dict[str, dict]) -> dict:
|
||||
"""`Store.calc` 와 같은 셈 + 표 바꿔 끼우기 — 답 모양도 같음."""
|
||||
given = {k: store._dec(v) for k, v in inputs.items()}
|
||||
files = cm.load(folder=store.FOLDER) # 부를 때마다 새 사본 — 캐시 아님
|
||||
_put_tables(files, tables)
|
||||
key = store._swap(files, str(row.get("키") or ""), row, None)
|
||||
try:
|
||||
given = cm.mf.fill_empty(cm.mf.Master(files).logic(key), given)
|
||||
result = cm.calc(files, key, given)
|
||||
except (cm.mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
|
||||
return {"ok": False, "reason": str(e) or type(e).__name__}
|
||||
if "결과" in result:
|
||||
return {"ok": True, "result": result["결과"], "middle": result["중간"]}
|
||||
return {
|
||||
"ok": True,
|
||||
"lines": result["줄"],
|
||||
"sums": {k: result[k] for k in cm.mf.BUNDLE},
|
||||
"middle": result["중간"],
|
||||
}
|
||||
|
||||
|
||||
def table_users(key: str) -> dict:
|
||||
"""표를 쓰는 로직 — 표 「용도.로직키」 + 로직 글에 표 키가 든 것(읽기 전용 캐시에서)."""
|
||||
files, _ = store.loaded()
|
||||
table = next(
|
||||
(
|
||||
t
|
||||
for data in files.values()
|
||||
if data.get("그룹") in cm.mf.TABLE_GROUPS
|
||||
for t in data.get("표") or []
|
||||
if str(t.get("키")) == key
|
||||
),
|
||||
None,
|
||||
)
|
||||
if table is None:
|
||||
raise store.StoreError(404, f"없는 표 「{key}」")
|
||||
listed = {str(k) for k in (table.get("용도") or {}).get("로직키") or []}
|
||||
named = re.compile(rf"(?<![A-Z0-9]){re.escape(key)}(?![0-9])")
|
||||
users = []
|
||||
for data in files.values():
|
||||
if data.get("그룹") != "로직":
|
||||
continue
|
||||
for row in data.get("줄") or []:
|
||||
in_use = str(row.get("키")) in listed
|
||||
in_text = bool(named.search(json.dumps(row, ensure_ascii=False, default=str)))
|
||||
if in_use or in_text:
|
||||
via = "둘" if in_use and in_text else "용도" if in_use else "글"
|
||||
users.append({"key": row.get("키"), "이름": row.get("이름"), "via": via})
|
||||
return {"key": key, "users": users}
|
||||
|
||||
|
||||
def _call(fn, *args):
|
||||
try:
|
||||
return _num(fn(*args))
|
||||
except store.StoreError as e:
|
||||
raise HTTPException(status_code=e.status, detail=e.detail) from e
|
||||
|
||||
|
||||
@router.post("/calc")
|
||||
def post_calc(body: LbCalcBody) -> dict:
|
||||
return _call(calc, body.row, body.inputs, body.tables)
|
||||
|
||||
|
||||
@router.get("/table-users")
|
||||
def get_table_users(key: str) -> dict:
|
||||
return _call(table_users, key)
|
||||
@@ -1,266 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_LB_Check.ts
|
||||
* 로직 만들기 판 — 검사(정함 7 · 설계초안 4절)
|
||||
* ① gaps(lb, tables) — 표 조건 칸에 묶인 입력값 선택 목록 조합 × 표 줄 → 해당 없음 · 중복 목록(브라우저 셈)
|
||||
* ② lookupHits(lb, tables, values) — 예시값으로 표마다 걸린 줄(노랑) · 해당 없음 · 중복
|
||||
* ③ runExpect(lb, tables, rows) — 기대값 시험표 · 예시값마다 시험 계산 · 통과 / 실패
|
||||
* 시험표 줄은 sessionStorage 초안만(저장 안 함)
|
||||
*
|
||||
* 줄 찾기 = 엔진 `master_formula._match · find` 와 같은 규칙(범위규칙 넷 · 빈 칸은 무관 ·
|
||||
* 여러 줄이면 위 끝 가장 작은 줄 · 결과 칸 있는 줄) — 엔진을 바꾸면 이 파일도 같이 바꿈.
|
||||
* DOM 없음.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CalcAnswer, LogicRow } from "../M01_MasterData_UI_Logic_Api";
|
||||
import { lbCalcNow, type LbTables } from "./M01_LB_Api";
|
||||
import { writeLogic, type LbLogic, type LbLookup } from "./M01_LB_Blocks";
|
||||
|
||||
type Cell = string | number | boolean | null | [number | null, number | null];
|
||||
export interface CheckTable {
|
||||
키?: string;
|
||||
범위규칙?: string;
|
||||
조건?: Record<string, string>;
|
||||
값칸?: Record<string, string>;
|
||||
줄: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
/* ── 엔진과 같은 줄 맞추기 ─────────────────────────────────────────── */
|
||||
|
||||
const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
|
||||
|
||||
function match(
|
||||
table: CheckTable,
|
||||
row: Record<string, unknown>,
|
||||
cond: string,
|
||||
want: unknown,
|
||||
): boolean {
|
||||
if (!(cond in row)) return true; // 빈 조건 칸 = 무관
|
||||
const have = row[cond] as Cell;
|
||||
if (table.조건?.[cond] === "범위") {
|
||||
if (!Array.isArray(have) || !isNum(want)) return false;
|
||||
const [low, high] = have;
|
||||
const [head, ...more] = (table.범위규칙 ?? "초과·이하").split(" · ");
|
||||
const rule = head.split(" (")[0];
|
||||
const above = (strict: boolean): boolean => low === null || (strict ? want > low : want >= low);
|
||||
if (rule === "이상·이하") return above(false) && (high === null || want <= high);
|
||||
if (rule === "초과·이하" || rule === "이하")
|
||||
return above(true) && (high === null || want <= high);
|
||||
if (rule === "이상·미만") {
|
||||
const tops = table.줄
|
||||
.map((r) => r[cond])
|
||||
.filter((c): c is [number | null, number] => Array.isArray(c) && c[1] !== null)
|
||||
.map((c) => c[1]);
|
||||
const closed =
|
||||
more.some((x) => x.includes("이하 포함")) && tops.length > 0 && high === Math.max(...tops);
|
||||
return above(false) && (high === null || want < high || (closed && want === high));
|
||||
}
|
||||
return false; // 모르는 범위규칙 — 엔진은 멈춤
|
||||
}
|
||||
if (isNum(have) && isNum(want)) return have === want;
|
||||
return typeof have === "string" && typeof want === "string" && have === want;
|
||||
}
|
||||
|
||||
/** 엔진 `find` 처럼 맞는 줄 차례 — 하나면 그 줄 · 0 · 2+ 는 그대로 돌려줌 */
|
||||
export function findRows(table: CheckTable, conds: Record<string, unknown>, want = ""): number[] {
|
||||
let hits = table.줄
|
||||
.map((row, i) => ({ row, i }))
|
||||
.filter(({ row }) => Object.entries(conds).every(([c, v]) => match(table, row, c, v)));
|
||||
const first = (table.범위규칙 ?? "").split(" · ")[0];
|
||||
if (hits.length > 1 && (first === "이하" || first === "이상·이하")) {
|
||||
for (const c of Object.keys(conds)) {
|
||||
if (table.조건?.[c] !== "범위") continue;
|
||||
const ups = hits.filter(({ row }) => row[c]).map(({ row }) => (row[c] as Cell[])[1]);
|
||||
if (!ups.length || ups.includes(null)) continue;
|
||||
const low = Math.min(...(ups as number[]));
|
||||
hits = hits.filter(({ row }) => !row[c] || (row[c] as Cell[])[1] === low);
|
||||
}
|
||||
}
|
||||
if (hits.length > 1 && want) hits = hits.filter(({ row }) => want in row);
|
||||
return hits.map((h) => h.i);
|
||||
}
|
||||
|
||||
/* ── ① 빈틈 · 중복 ──────────────────────────────────────────────────── */
|
||||
|
||||
export interface Gap {
|
||||
table: string;
|
||||
/** 어느 표 조회에서 나왔나(화면 번호) */
|
||||
lookup: string;
|
||||
conds: Record<string, unknown>;
|
||||
kind: "없음" | "중복";
|
||||
/** 중복이면 걸린 줄 차례 */
|
||||
rows: number[];
|
||||
}
|
||||
|
||||
/** 조합마다 조건 값 — 입력값은 선택 목록 · 고정값은 그 값 · 그 밖(계산값 등)은 셀 수 없어 null */
|
||||
function combos(lb: LbLogic, lk: LbLookup): Record<string, unknown>[] | null {
|
||||
const lists: [string, unknown[]][] = [];
|
||||
for (const { col, from } of lk.conds) {
|
||||
if (from.k === "fixed") lists.push([col, [from.value]]);
|
||||
else if (from.k === "input") {
|
||||
const spec = lb.inputs.find((x) => x.이름 === from.name);
|
||||
if (!spec?.고르기?.length) return null;
|
||||
lists.push([col, spec.고르기]);
|
||||
} else return null;
|
||||
}
|
||||
let out: Record<string, unknown>[] = [{}];
|
||||
for (const [col, values] of lists)
|
||||
out = out.flatMap((o) => values.map((v) => ({ ...o, [col]: v })));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 입력값 선택 목록 조합마다 표 줄 대조 — 해당 없음 · 중복만 돌려줌(셀 수 없는 표 조회는 건너뜀) */
|
||||
export function gaps(lb: LbLogic, tables: Record<string, unknown>): Gap[] {
|
||||
const out: Gap[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const lk of lb.lookups) {
|
||||
const table = tables[lk.table] as CheckTable | undefined;
|
||||
const sets = table?.줄 ? combos(lb, lk) : null;
|
||||
if (!table || !sets) continue;
|
||||
for (const conds of sets) {
|
||||
const rows = findRows(table, conds, lk.col);
|
||||
if (rows.length === 1) continue;
|
||||
const tag = `${lk.table}|${JSON.stringify(conds)}|${rows.join(",")}`;
|
||||
if (seen.has(tag)) continue;
|
||||
seen.add(tag);
|
||||
out.push({
|
||||
table: lk.table,
|
||||
lookup: lk.id,
|
||||
conds,
|
||||
kind: rows.length ? "중복" : "없음",
|
||||
rows,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 셀 수 없어 빈틈 검사를 건너뛴 표 조회(계산값 · 숫자 입력에 묶인 조건) */
|
||||
export function gapSkipped(lb: LbLogic, tables: Record<string, unknown>): string[] {
|
||||
return lb.lookups.filter((lk) => !tables[lk.table] || !combos(lb, lk)).map((lk) => lk.id);
|
||||
}
|
||||
|
||||
/* ── ② 예시값으로 걸린 줄 ─────────────────────────────────────────── */
|
||||
|
||||
/** 표 조회 번호 → 걸린 줄 차례 · 「없음」 · 「중복」 · null(계산값에 묶여 화면이 모름) */
|
||||
export type LbHit = number | null | "없음" | "중복";
|
||||
|
||||
export function lookupHits(
|
||||
lb: LbLogic,
|
||||
tables: Record<string, unknown>,
|
||||
values: Record<string, unknown>,
|
||||
): Record<string, LbHit> {
|
||||
const out: Record<string, LbHit> = {};
|
||||
for (const lk of lb.lookups) {
|
||||
const table = tables[lk.table] as CheckTable | undefined;
|
||||
const conds: Record<string, unknown> = {};
|
||||
let known = !!table?.줄;
|
||||
for (const { col, from } of lk.conds) {
|
||||
if (from.k === "fixed") conds[col] = from.value;
|
||||
else if (from.k === "input" && values[from.name] !== undefined)
|
||||
conds[col] = values[from.name];
|
||||
else known = false;
|
||||
}
|
||||
if (!known || !table) {
|
||||
out[lk.id] = null;
|
||||
continue;
|
||||
}
|
||||
const rows = findRows(table, conds, lk.col);
|
||||
out[lk.id] = rows.length === 1 ? rows[0] : rows.length ? "중복" : "없음";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── ③ 기대값 시험표 ───────────────────────────────────────────────── */
|
||||
|
||||
export interface ExpectRow {
|
||||
/** 예시값 */
|
||||
inputs: Record<string, unknown>;
|
||||
/** 볼 값 — 「계」(합계) · 「결과」 · 항목 이름(수량) · 「항목 이름 금액」 */
|
||||
expect: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface ExpectResult {
|
||||
ok: boolean;
|
||||
got: Record<string, number>;
|
||||
/** 계산 멈춤 · 서버 오류 까닭(쉬운 말은 부른 쪽이 다듬음) */
|
||||
why: string | null;
|
||||
}
|
||||
|
||||
/** 계산 답 → 볼 값 모음 */
|
||||
export function pickValues(ans: CalcAnswer): Record<string, number> {
|
||||
const got: Record<string, number> = {};
|
||||
if (!ans.ok) return got;
|
||||
if (ans.sums && isNum(ans.sums["계"])) got["계"] = ans.sums["계"];
|
||||
if (isNum(ans.result)) got["결과"] = ans.result;
|
||||
for (const line of ans.lines ?? []) {
|
||||
if (isNum(line.수량)) got[line.이름] = line.수량;
|
||||
if (isNum(line.금액)) got[`${line.이름} 금액`] = line.금액;
|
||||
}
|
||||
return got;
|
||||
}
|
||||
|
||||
/** 기대값과 같은가 — 기대값에 적은 자릿수의 반 올림 안쪽이면 같음(52,437 ↔ 52,437.06) */
|
||||
export function sameAsExpect(got: number | undefined, expect: number): boolean {
|
||||
if (!isNum(got)) return false;
|
||||
const places = (String(expect).split(".")[1] ?? "").length;
|
||||
return Math.abs(got - expect) <= 0.5 * 10 ** -places + 1e-9;
|
||||
}
|
||||
|
||||
type CalcFn = (
|
||||
row: LogicRow,
|
||||
inputs: Record<string, unknown>,
|
||||
tables: LbTables,
|
||||
) => Promise<CalcAnswer>;
|
||||
|
||||
/** LB Api 바로 계산 — 화면 예시값 계산(0.3초 · 최신만)과 안 부딪힘 · 멈춤 까닭은 쉬운 말로 */
|
||||
const calcNow: CalcFn = async (row, inputs, tables) => {
|
||||
let plain = "";
|
||||
const ans = await lbCalcNow(row, inputs, tables, (p) => (plain = p));
|
||||
if (!ans) throw new Error(plain);
|
||||
return ans.ok ? ans : { ok: false, reason: plain || ans.reason };
|
||||
};
|
||||
|
||||
/** 줄마다 시험 계산(차례로 · 하나씩) — 로직은 블록을 글로 되돌린 줄 · `calc` 는 시험에서 바꿔 끼움 */
|
||||
export async function runExpect(
|
||||
lb: LbLogic,
|
||||
tables: LbTables,
|
||||
rows: ExpectRow[],
|
||||
calc: CalcFn = calcNow,
|
||||
): Promise<ExpectResult[]> {
|
||||
const row = writeLogic(lb);
|
||||
const out: ExpectResult[] = [];
|
||||
for (const r of rows) {
|
||||
try {
|
||||
const ans = await calc(row, r.inputs, tables);
|
||||
const got = pickValues(ans);
|
||||
const ok = ans.ok && Object.entries(r.expect).every(([k, v]) => sameAsExpect(got[k], v));
|
||||
out.push({ ok, got, why: ans.ok ? null : ans.reason });
|
||||
} catch (e) {
|
||||
out.push({ ok: false, got: {}, why: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── 시험표 초안(sessionStorage · 로직마다) ───────────────────────────── */
|
||||
|
||||
const draftKey = (logic: string): string => `m01.lb.expect.${logic || "새"}`;
|
||||
|
||||
export function loadExpectRows(logic: string): ExpectRow[] {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(draftKey(logic));
|
||||
const rows = raw ? (JSON.parse(raw) as ExpectRow[]) : [];
|
||||
return Array.isArray(rows) ? rows : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveExpectRows(logic: string, rows: ExpectRow[]): void {
|
||||
try {
|
||||
if (rows.length) sessionStorage.setItem(draftKey(logic), JSON.stringify(rows));
|
||||
else sessionStorage.removeItem(draftKey(logic));
|
||||
} catch {
|
||||
/* 저장 칸이 막힌 브라우저 — 이번 화면에서만 씀 */
|
||||
}
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
/* 로직 만들기 판 — 큰 창 · 시작 화면 · 위 띠 · 세 칸 */
|
||||
|
||||
/* 공용 모달(위 고정)을 화면 대부분으로 — 높이 고정 · 칸마다 따로 굴림 */
|
||||
.ui-modal.m01-lb {
|
||||
--modal-max: 92vh;
|
||||
width: min(1680px, 98vw);
|
||||
height: var(--modal-max);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m01-lb .ui-modal__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
.m01-lb__muted {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-lb__note {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.m01-lb__note:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m01-lb__big {
|
||||
min-height: 56px;
|
||||
padding: 0 var(--spacing-24);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m01-lb .is-pending {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* 시작 화면 */
|
||||
.m01-lb__start {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
||||
gap: var(--spacing-24);
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.m01-lb__start-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01-lb__start-col h4 {
|
||||
margin: var(--spacing-8) 0 0;
|
||||
}
|
||||
|
||||
.m01-lb__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m01-lb__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01-lb__item:hover {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.m01-lb__item-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01-lb__item-unit {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 위 띠 */
|
||||
.m01-lb__strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--spacing-12);
|
||||
padding-bottom: var(--spacing-12);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m01-lb__name {
|
||||
flex: 1 1 280px;
|
||||
}
|
||||
|
||||
.m01-lb__applied {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-lb__actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* 세 칸 — 원문 · 산출근거 · 결과 */
|
||||
.m01-lb__cols {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr) minmax(0, 1fr);
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
.m01-lb__col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.m01-lb__col-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m01-lb__col-head h4 {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.m01-lb__col-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12);
|
||||
}
|
||||
|
||||
.m01-lb__col-body h5 {
|
||||
margin: var(--spacing-8) 0 0;
|
||||
}
|
||||
|
||||
.m01-lb__total {
|
||||
font-size: var(--text-subheading);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.m01-lb__examples {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-8);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.m01-lb__example-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01-lb__example {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
/* 좁은 화면 — 세 칸을 위아래로 */
|
||||
@media (max-width: 1100px) {
|
||||
.m01-lb__cols {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* 결과 칸 — 빈틈 검사 · 기대값 시험표 */
|
||||
.m01-lb__check {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding-top: var(--spacing-8);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m01-lb__check-tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01-lb__check-out ul {
|
||||
margin: 0;
|
||||
padding-left: var(--spacing-16);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-lb__expect-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 96px auto auto;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-lb__expect-row .is-ok {
|
||||
color: var(--color-success, var(--color-text));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m01-lb__expect-row .is-bad {
|
||||
color: var(--color-danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -1,693 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_LB_Entry.ts
|
||||
* 로직 만들기 판(시험) — 들어가는 단추 · 큰 창 · 시작 화면 · 세 칸(원문 · 산출근거 · 결과) · 위 띠 · 잇기
|
||||
* 모양은 `docs/raw/M01_쉬운단가/로직판_A_정함.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄).
|
||||
* 잇기: 열기(openEasy) → readLogic → 원문(renderSource · 걸린 줄 lookupHits) · 산출근거(renderSteps / renderGrid)
|
||||
* · 결과(합계 · 예시값 · 일위대가표 · 빈틈 · 기대값 시험표) → 바뀔 때마다 기록 · lbCalcLater(고친 표 사본)
|
||||
* → 저장(현장 = newEasy · editEasy · writeLogic / 표준은 복사 편집 뒤) · 실행 취소 · Ctrl+Z · Ctrl+S · 단계 안내.
|
||||
* 표 칸 고침은 메모리 사본만 — 저장 안 함(정함 5).
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createTag,
|
||||
el,
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import type { CalcAnswer, LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api";
|
||||
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
|
||||
import { formatNumber } from "../M01_MasterData_UI_Logic_Edit";
|
||||
import {
|
||||
copyEasy,
|
||||
deleteEasy,
|
||||
editEasy,
|
||||
listEasy,
|
||||
newEasy,
|
||||
openEasy,
|
||||
plainError,
|
||||
usesOf,
|
||||
type EasyListItem,
|
||||
} from "../EasyCalc/M01_EasyCalc_Api";
|
||||
import { renderSheet } from "../EasyCalc/M01_EasyCalc_Sheet";
|
||||
import { fetchLbTable, lbCalcLater, type LbTables } from "./M01_LB_Api";
|
||||
import { readLogic, setCond, writeLogic, type LbLogic, type TableEdit } from "./M01_LB_Blocks";
|
||||
import { lookupHits } from "./M01_LB_Check";
|
||||
import { drawExamples, renderChecks } from "./M01_LB_Entry_Result";
|
||||
import { renderGrid } from "./M01_LB_Grid";
|
||||
import { renderSource, type LbRole } from "./M01_LB_Source";
|
||||
import { renderSteps } from "./M01_LB_Steps";
|
||||
import { openWizard } from "./M01_LB_Wizard";
|
||||
import "./M01_LB_Entry.css";
|
||||
|
||||
const TEXT = {
|
||||
Open: ["로직 만들기(시험)", "Logic builder (trial)"],
|
||||
Title: ["로직 만들기", "Logic builder"],
|
||||
CloseWindow: ["창 닫기", "Close window"],
|
||||
Close: ["닫기", "Close"],
|
||||
Loading: ["불러오는 중", "Loading"],
|
||||
LoadFailed: ["목록을 못 불러옴 — {v}", "Could not load the list — {v}"],
|
||||
Start_Trial: ["먼저 볼 시험 로직", "Trial logics to start with"],
|
||||
Start_Search: ["이름으로 찾기 — 예: 돌쌓기", "Search by name — e.g. stone wall"],
|
||||
Start_Found: ["찾은 것", "Found"],
|
||||
Start_Count: ["{n} 개 중 {m} 개 보임 — 더 좁혀 찾기", "Showing {m} of {n} — narrow the search"],
|
||||
Start_None: ["찾은 것 없음", "Nothing found"],
|
||||
Start_Field: ["현장 일위대가", "Field unit prices"],
|
||||
Start_FieldNone: ["아직 없음", "None yet"],
|
||||
Start_Wizard: ["단계 안내로 새로 만들기", "Create step by step"],
|
||||
Standard: ["표준", "Standard"],
|
||||
Field: ["현장", "Field"],
|
||||
Per: ["{u} 당", "per {u}"],
|
||||
Name: ["이름", "Name"],
|
||||
Applied: ["적용 품셈", "Applied standard"],
|
||||
Undo: ["실행 취소", "Undo"],
|
||||
Save: ["저장", "Save"],
|
||||
CopyEdit: ["복사 편집", "Copy and edit"],
|
||||
Copying: ["현장 일위대가로 복사하는 중", "Copying to a field unit price"],
|
||||
Copied: ["현장 일위대가로 복사했음 — 이제 고칠 수 있음", "Copied — you can edit it now"],
|
||||
Unsaved: ["저장 안 함", "Not saved"],
|
||||
StandardNote: [
|
||||
"표준은 읽기만 — 고치려면 「복사 편집」으로 현장 일위대가를 만듦",
|
||||
"Standard items are read-only — use “Copy and edit” to make a field copy",
|
||||
],
|
||||
Col_Source: ["원문", "Source"],
|
||||
Col_Basis: ["산출근거", "Basis"],
|
||||
Col_Result: ["결과", "Result"],
|
||||
View_Sentence: ["문장", "Sentences"],
|
||||
View_Grid: ["격자", "Grid"],
|
||||
Money: ["{u} 만드는 데 {v} 원", "{v} won to make {u}"],
|
||||
Value: ["결과 {v} {u}", "Result {v} {u}"],
|
||||
Sheet: ["일위대가표", "Unit-cost sheet"],
|
||||
NoBlocks: ["이 로직은 블록으로 못 읽음 — 일위대가표만 봄", "This logic can't be shown as blocks"],
|
||||
Blocked: ["못 읽은 곳:", "Not readable:"],
|
||||
Role_Result: ["결과 칸 바꾸기는 아직 안 됨", "Changing the result column is not ready yet"],
|
||||
Role_NoInput: [
|
||||
"「{v}」 을(를) 조건으로 쓰려면 같은 이름 입력값이 있어야 함",
|
||||
"To use “{v}” as a condition, add an input with the same name",
|
||||
],
|
||||
NoName: ["이름을 넣어 주세요", "Enter a name"],
|
||||
Saving: ["저장 중", "Saving"],
|
||||
Saved: ["저장했음", "Saved"],
|
||||
SaveFailed: ["저장 못 함", "Not saved"],
|
||||
Delete: ["삭제", "Delete"],
|
||||
Delete_Ask: ["「{v}」 을(를) 삭제할까요? 되살릴 수 없음", "Delete “{v}”? This cannot be undone"],
|
||||
Deleted: ["삭제했음", "Deleted"],
|
||||
Uses_Wait: ["사용처를 찾는 중 — 몇 초 걸림", "Looking for places that use it"],
|
||||
Uses_Block: ["사용처가 있어 삭제 못 함 — {v}", "Cannot delete — used by {v}"],
|
||||
Leave_Ask: [
|
||||
"저장 안 한 것이 있음 — 저장하지 않고 닫을까요?",
|
||||
"There are unsaved changes — close without saving?",
|
||||
],
|
||||
Leave_Ok: ["저장 안 하고 닫기", "Close without saving"],
|
||||
} as const;
|
||||
|
||||
type TextKey = keyof typeof TEXT;
|
||||
|
||||
const te = (key: TextKey, vars: Record<string, string | number> = {}): string =>
|
||||
Object.entries(vars).reduce(
|
||||
(text, [k, v]) => text.replace(`{${k}}`, String(v)),
|
||||
(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string,
|
||||
);
|
||||
|
||||
/** 시작 화면에 먼저 보이는 시험 로직 여섯(정함 10) */
|
||||
const TRIAL_KEYS = ["GF000208", "GF000219", "GF000025", "GF000249", "GF000133", "GC000051"];
|
||||
/** 찾은 것 목록에 한 번에 보이는 줄 수 */
|
||||
const SHOWN = 50;
|
||||
|
||||
/** 「원/㎡」 → 「㎡」 */
|
||||
const unitOf = (resultUnit: string): string => resultUnit.replace(/^원\//, "");
|
||||
/** 「㎡」 → 「1㎡」 · 「100㎡」 은 그대로 */
|
||||
const amountOf = (resultUnit: string): string => {
|
||||
const unit = unitOf(resultUnit);
|
||||
return /^\d/.test(unit) ? unit : `1${unit}`;
|
||||
};
|
||||
const isMoney = (resultUnit: string): boolean => resultUnit.startsWith("원/");
|
||||
|
||||
/** 세 칸 화면이 쥔 것 — row = 연 로직 줄 · lb = 블록(못 읽으면 null) · tables = 원문 표 사본 · changed = 고친 표 */
|
||||
interface Paper {
|
||||
key: string | null;
|
||||
version: string | null;
|
||||
isBase: boolean;
|
||||
row: LogicRow;
|
||||
trial: Record<string, unknown>;
|
||||
lb: LbLogic | null;
|
||||
tables: LbTables;
|
||||
changed: Set<string>;
|
||||
}
|
||||
/** 실행 취소 한 칸 — 객체를 그대로 쥠(JSON 복사 안 함 · 안 바뀐 조각은 원래 글 보존) */
|
||||
interface Snap {
|
||||
lb: LbLogic | null;
|
||||
tables: LbTables;
|
||||
changed: Set<string>;
|
||||
}
|
||||
type View = "sentence" | "grid";
|
||||
|
||||
/** 블록 읽기 — 못 읽는 꼴이면 null(일위대가표만 보임) */
|
||||
const blocksOf = (
|
||||
row: LogicRow,
|
||||
key: string | null,
|
||||
version: string | null,
|
||||
isBase: boolean,
|
||||
): LbLogic | null => {
|
||||
try {
|
||||
return readLogic(row, { key, version, isBase });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** 「쉬운 단가산출(시험)」 옆에 놓는 단추 — 누르면 큰 창 */
|
||||
export function createLogicBuilderButton(): HTMLButtonElement {
|
||||
return createButton({ label: te("Open"), variant: "filled", onClick: openLogicBuilder });
|
||||
}
|
||||
|
||||
function openLogicBuilder(): void {
|
||||
let body: HTMLElement;
|
||||
/** 세 칸 화면이 열려 있을 때만 */
|
||||
let paper: { dirty(): boolean; save(): Promise<void>; undo(): void } | null = null;
|
||||
let logics: EasyListItem[] | null = null;
|
||||
let query = "";
|
||||
let view: View = "sentence";
|
||||
|
||||
const mayLeave = async (): Promise<boolean> =>
|
||||
!paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok"));
|
||||
|
||||
/** Ctrl+Z · Ctrl+S — 이 창 안에서만(단계 안내 등 다른 창은 제 몫) */
|
||||
const onKey = (ev: KeyboardEvent): void => {
|
||||
if (!paper || !(ev.ctrlKey || ev.metaKey)) return;
|
||||
const key = ev.key.toLowerCase();
|
||||
if (key === "z" && !ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
paper.undo();
|
||||
} else if (key === "s") {
|
||||
ev.preventDefault();
|
||||
void paper.save();
|
||||
}
|
||||
};
|
||||
|
||||
/* ---------- 시작 화면 ---------- */
|
||||
|
||||
const listRow = (item: EasyListItem): HTMLElement => {
|
||||
const row = el("button", {
|
||||
className: "m01-lb__item",
|
||||
attrs: { type: "button", title: item.where },
|
||||
children: [
|
||||
el("span", { className: "m01-lb__item-name", text: item.name }),
|
||||
createTag(item.isBase ? te("Standard") : te("Field"), item.isBase ? "neutral" : "accent"),
|
||||
el("span", {
|
||||
className: "m01-lb__item-unit",
|
||||
text: isMoney(item.resultUnit)
|
||||
? te("Per", { u: amountOf(item.resultUnit) })
|
||||
: item.resultUnit,
|
||||
}),
|
||||
],
|
||||
});
|
||||
row.addEventListener("click", () => void openPaper(item.key));
|
||||
return row;
|
||||
};
|
||||
|
||||
const showStart = async (): Promise<void> => {
|
||||
paper = null;
|
||||
const status = el("p", { className: "m01-lb__muted", text: te("Loading") });
|
||||
const trialHost = el("div", { className: "m01-lb__list" });
|
||||
const fieldHost = el("div", { className: "m01-lb__list" });
|
||||
const foundHost = el("div", { className: "m01-lb__list" });
|
||||
const search = createInputField({
|
||||
type: "search",
|
||||
placeholder: te("Start_Search"),
|
||||
value: query,
|
||||
onInput: (v) => {
|
||||
query = v;
|
||||
drawFound();
|
||||
},
|
||||
});
|
||||
|
||||
const drawFound = (): void => {
|
||||
if (!logics) return;
|
||||
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
const hits = words.length
|
||||
? logics.filter((x) => words.every((w) => x.name.toLowerCase().includes(w)))
|
||||
: [];
|
||||
status.textContent = !words.length
|
||||
? ""
|
||||
: !hits.length
|
||||
? te("Start_None")
|
||||
: hits.length > SHOWN
|
||||
? te("Start_Count", { n: hits.length.toLocaleString(), m: SHOWN })
|
||||
: "";
|
||||
foundHost.replaceChildren(...hits.slice(0, SHOWN).map(listRow));
|
||||
};
|
||||
|
||||
body.replaceChildren(
|
||||
el("div", {
|
||||
className: "m01-lb__start",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-lb__start-col",
|
||||
children: [
|
||||
el("h4", { text: te("Start_Trial") }),
|
||||
trialHost,
|
||||
search.root,
|
||||
el("h4", { text: te("Start_Found") }),
|
||||
status,
|
||||
foundHost,
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "m01-lb__start-col",
|
||||
children: [
|
||||
bigButton(te("Start_Wizard"), () => void openNew()),
|
||||
el("h4", { text: te("Start_Field") }),
|
||||
fieldHost,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
search.input.focus();
|
||||
|
||||
try {
|
||||
logics ??= await listEasy("");
|
||||
} catch (error) {
|
||||
status.textContent = te("LoadFailed", { v: plainError(error) });
|
||||
return;
|
||||
}
|
||||
const byKey = new Map(logics.map((x) => [x.key, x]));
|
||||
trialHost.replaceChildren(
|
||||
...TRIAL_KEYS.flatMap((k) => {
|
||||
const item = byKey.get(k);
|
||||
return item ? [listRow(item)] : [];
|
||||
}),
|
||||
);
|
||||
const field = logics.filter((x) => !x.isBase);
|
||||
fieldHost.replaceChildren(
|
||||
...(field.length ? field.map(listRow) : [muted(te("Start_FieldNone"))]),
|
||||
);
|
||||
drawFound();
|
||||
};
|
||||
|
||||
/* ---------- 세 칸 화면 ---------- */
|
||||
|
||||
const openPaper = async (key: string): Promise<void> => {
|
||||
body.replaceChildren(muted(te("Loading")));
|
||||
try {
|
||||
const o = await openEasy(key);
|
||||
const lb = blocksOf(o.row, key, o.version, o.isBase);
|
||||
showPaper({ key, version: o.version, isBase: o.isBase, row: o.row, lb, trial: o.trial });
|
||||
} catch (error) {
|
||||
showToast(plainError(error), "error");
|
||||
void showStart();
|
||||
}
|
||||
};
|
||||
|
||||
const openNew = async (): Promise<void> => {
|
||||
const lb = await openWizard();
|
||||
if (!lb) return;
|
||||
const row = writeLogic(lb);
|
||||
showPaper({ key: null, version: null, isBase: false, row, lb, trial: autoTrial(lb.inputs) });
|
||||
};
|
||||
|
||||
const showPaper = (first: Omit<Paper, "tables" | "changed">): void => {
|
||||
const p: Paper = { ...first, tables: {}, changed: new Set() };
|
||||
let ans: CalcAnswer | null = null;
|
||||
let busy = false;
|
||||
const savedLb = p.key ? p.lb : null;
|
||||
let stack: Snap[] = [];
|
||||
const readOnly = p.isBase || !p.lb;
|
||||
paper = { dirty: () => p.lb !== savedLb, save: () => doSave(), undo: () => doUndo() };
|
||||
|
||||
const unsaved = createTag(te("Unsaved"), "warning");
|
||||
const total = el("div", { className: "m01-lb__total" });
|
||||
const note = el("p", { className: "m01-lb__note", attrs: { role: "status" } });
|
||||
const sheetHost = el("div");
|
||||
const examplesHost = el("div", { className: "m01-lb__examples" });
|
||||
const checksHost = el("div");
|
||||
const sourceHost = el("div", { className: "m01-lb__slot" });
|
||||
const basisHost = el("div", { className: "m01-lb__slot" });
|
||||
const blockedNote = el("p", { className: "m01-lb__note" });
|
||||
|
||||
const rowNow = (): LogicRow => (p.lb ? writeLogic(p.lb) : p.row);
|
||||
const unitNow = (): string => p.lb?.doc.resultUnit ?? p.row.결과단위 ?? "";
|
||||
const tableName = (key: string): string | null =>
|
||||
(p.tables[key] as { 이름?: string } | undefined)?.이름 ?? null;
|
||||
/** 서버에 넘길 표 — 고친 표만 */
|
||||
const edited = (): LbTables => Object.fromEntries([...p.changed].map((k) => [k, p.tables[k]]));
|
||||
|
||||
const name = createInputField({
|
||||
label: te("Name"),
|
||||
value: p.lb?.doc.name ?? p.row.이름,
|
||||
onInput: (v) => {
|
||||
if (p.lb) edit({ ...p.lb, doc: { ...p.lb.doc, name: v } });
|
||||
},
|
||||
});
|
||||
name.input.disabled = readOnly;
|
||||
name.root.classList.add("m01-lb__name");
|
||||
|
||||
/** 계산 답만 왔을 때 글을 넣는 중인 칸이 있으면 그 칸을 떠난 뒤 그림(넣던 글이 안 날아가게) */
|
||||
const later = (host: HTMLElement, draw: () => void, answer: boolean): void => {
|
||||
const a = document.activeElement;
|
||||
const typing = a instanceof HTMLInputElement || a instanceof HTMLSelectElement;
|
||||
if (answer && typing && host.contains(a)) {
|
||||
host.addEventListener("focusout", () => setTimeout(draw), { once: true });
|
||||
return;
|
||||
}
|
||||
draw();
|
||||
};
|
||||
|
||||
const drawSource = (): void => {
|
||||
if (!p.lb) return void sourceHost.replaceChildren(muted(te("NoBlocks")));
|
||||
renderSource(sourceHost, p.lb, p.tables, lookupHits(p.lb, p.tables, p.trial), {
|
||||
edit: onTableEdit,
|
||||
role: onRole,
|
||||
});
|
||||
};
|
||||
const drawBasis = (): void => {
|
||||
if (!p.lb) return void basisHost.replaceChildren(muted(te("NoBlocks")));
|
||||
const on = { change: (lb: LbLogic) => edit(lb) };
|
||||
if (view === "sentence") renderSteps(basisHost, p.lb, ans, { ...on, tableName });
|
||||
else renderGrid(basisHost, p.lb, ans, on, { trial: p.trial });
|
||||
};
|
||||
|
||||
/** 합계 · 일위대가표 · 원문 · 산출근거 — 바뀔 때(pending) · 계산 답이 올 때(answer) */
|
||||
const draw = (pending: boolean, answer = false): void => {
|
||||
const unit = unitNow();
|
||||
const v = ans?.ok ? (ans.result ?? ans.sums?.["계"]) : undefined;
|
||||
total.textContent = isMoney(unit)
|
||||
? te("Money", { u: amountOf(unit), v: v === undefined ? "—" : formatMoney(v) })
|
||||
: te("Value", { u: unit, v: v === undefined ? "—" : formatNumber(v) });
|
||||
total.classList.toggle("is-pending", pending);
|
||||
sheetHost.classList.toggle("is-pending", pending);
|
||||
unsaved.hidden = !paper?.dirty();
|
||||
undo.disabled = !stack.length;
|
||||
blockedNote.textContent = p.lb?.blocked.length
|
||||
? `${te("Blocked")} ${p.lb.blocked.join(" · ")}`
|
||||
: "";
|
||||
renderSheet(sheetHost, { name: p.lb?.doc.name ?? p.row.이름, resultUnit: unit }, ans);
|
||||
later(sourceHost, drawSource, answer);
|
||||
later(basisHost, drawBasis, answer);
|
||||
};
|
||||
|
||||
const calc = (): void => {
|
||||
draw(true);
|
||||
lbCalcLater(rowNow(), p.trial, edited(), (next, plain) => {
|
||||
ans = next;
|
||||
note.textContent = plain ?? "";
|
||||
draw(false, true);
|
||||
});
|
||||
};
|
||||
|
||||
const drawExamplesNow = (): void =>
|
||||
drawExamples(examplesHost, p.lb?.inputs ?? p.row.입력 ?? [], p.trial, (next) => {
|
||||
p.trial = next;
|
||||
calc();
|
||||
});
|
||||
|
||||
/** 바뀐 조각만 새 객체로 온 lb 를 그대로 쌓음 */
|
||||
const edit = (next: LbLogic): void => {
|
||||
if (readOnly || next === p.lb) return;
|
||||
stack = [...stack, { lb: p.lb, tables: p.tables, changed: p.changed }];
|
||||
const inputsChanged = next.inputs !== p.lb?.inputs;
|
||||
p.lb = next;
|
||||
if (inputsChanged) {
|
||||
p.trial = { ...autoTrial(next.inputs), ...p.trial };
|
||||
drawExamplesNow();
|
||||
}
|
||||
void loadTables();
|
||||
calc();
|
||||
};
|
||||
|
||||
/** 표 칸 고침 — 메모리 사본만 · 저장 안 함(정함 5) */
|
||||
const onTableEdit = (e: TableEdit): void => {
|
||||
const t = p.tables[e.table] as { 줄: Record<string, unknown>[] } | undefined;
|
||||
if (!t) return;
|
||||
stack = [...stack, { lb: p.lb, tables: p.tables, changed: p.changed }];
|
||||
const 줄 = t.줄.map((r, i) => (i === e.row ? { ...r, [e.col]: e.value } : r));
|
||||
p.tables = { ...p.tables, [e.table]: { ...t, 줄 } };
|
||||
p.changed = new Set([...p.changed, e.table]);
|
||||
calc();
|
||||
};
|
||||
|
||||
/** 칸 역할 — 조건 = 같은 이름 입력값 · 고정 = 걸린 줄(없으면 첫 줄) 값 · 결과 칸 바꾸기는 아직 */
|
||||
const onRole = (table: string, col: string, role: LbRole): void => {
|
||||
const lb = p.lb;
|
||||
if (!lb || readOnly) return;
|
||||
if (role === "결과") return void (note.textContent = te("Role_Result"));
|
||||
const hits = lookupHits(lb, p.tables, p.trial);
|
||||
const rows = (p.tables[table] as { 줄?: Record<string, unknown>[] } | undefined)?.줄;
|
||||
let next = lb;
|
||||
lb.lookups.forEach((lk, i) => {
|
||||
if (lk.table !== table || lk.col === col) return;
|
||||
const id = next.lookups[i]?.id ?? lk.id;
|
||||
if (role === "조건") {
|
||||
if (lb.inputs.some((x) => x.이름 === col))
|
||||
next = setCond(next, id, col, { k: "input", name: col });
|
||||
return;
|
||||
}
|
||||
const hit = hits[lk.id];
|
||||
const cell = rows?.[typeof hit === "number" ? hit : 0]?.[col];
|
||||
const value = Array.isArray(cell) ? (cell[0] ?? cell[1]) : cell;
|
||||
if (typeof value === "number" || typeof value === "string")
|
||||
next = setCond(next, id, col, { k: "fixed", value });
|
||||
});
|
||||
if (next === lb) note.textContent = te("Role_NoInput", { v: col });
|
||||
else edit(next);
|
||||
};
|
||||
|
||||
/** 로직이 보는 원문 표 — 처음 보는 표만 받아 옴 */
|
||||
const loadTables = async (): Promise<void> => {
|
||||
const keys = [...new Set(p.lb?.lookups.map((lk) => lk.table) ?? [])];
|
||||
const want = keys.filter((k) => !(k in p.tables));
|
||||
if (!want.length) return;
|
||||
const got = await Promise.all(
|
||||
want.map((k) =>
|
||||
fetchLbTable(k).then(
|
||||
(t) => [k, t.table] as const,
|
||||
() => [k, undefined] as const,
|
||||
),
|
||||
),
|
||||
);
|
||||
p.tables = { ...p.tables, ...Object.fromEntries(got) };
|
||||
drawSource();
|
||||
drawBasis();
|
||||
};
|
||||
|
||||
const doUndo = (): void => {
|
||||
const back = stack[stack.length - 1];
|
||||
if (!back) return;
|
||||
stack = stack.slice(0, -1);
|
||||
({ lb: p.lb, tables: p.tables, changed: p.changed } = back);
|
||||
name.input.value = p.lb?.doc.name ?? p.row.이름;
|
||||
drawExamplesNow();
|
||||
calc();
|
||||
};
|
||||
|
||||
/** 저장 뒤 · 복사 편집 뒤 — 서버가 준 줄로 다시 엶 · 실행 취소 기록은 새로 */
|
||||
const reopen = (row: LogicRow, key: string, version: string): void => {
|
||||
const lb = blocksOf(row, key, version, false);
|
||||
showPaper({ key, version, isBase: false, row, lb, trial: p.trial });
|
||||
};
|
||||
|
||||
const doSave = async (): Promise<void> => {
|
||||
if (readOnly || busy || !p.lb) return;
|
||||
const row = writeLogic(p.lb);
|
||||
if (!row.이름.trim()) return void (note.textContent = te("NoName"));
|
||||
busy = true;
|
||||
note.textContent = te("Saving");
|
||||
try {
|
||||
const saved =
|
||||
p.key && p.version ? await editEasy(p.key, p.version, row) : await newEasy(row);
|
||||
logics = null;
|
||||
showToast(te("Saved"), "success");
|
||||
reopen(saved.row, saved.key, saved.version);
|
||||
} catch (error) {
|
||||
note.textContent = `${te("SaveFailed")} — ${plainError(error)}`;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
};
|
||||
|
||||
const doCopy = async (): Promise<void> => {
|
||||
if (!p.key || busy) return;
|
||||
busy = true;
|
||||
note.textContent = te("Copying");
|
||||
try {
|
||||
const saved = await copyEasy(p.key);
|
||||
logics = null;
|
||||
showToast(te("Copied"), "success");
|
||||
reopen(saved.row, saved.key, saved.version);
|
||||
} catch (error) {
|
||||
note.textContent = plainError(error);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
};
|
||||
|
||||
const doDelete = async (): Promise<void> => {
|
||||
if (!p.key || !p.version || p.isBase || busy) return;
|
||||
busy = true;
|
||||
note.textContent = te("Uses_Wait");
|
||||
try {
|
||||
const uses = (await usesOf(p.key)).filter((u) => u.key);
|
||||
if (uses.length) {
|
||||
note.textContent = te("Uses_Block", { v: uses.map((u) => u.name).join(", ") });
|
||||
return;
|
||||
}
|
||||
note.textContent = "";
|
||||
const ask = te("Delete_Ask", { v: rowNow().이름 });
|
||||
if (!(await showConfirmDialog(ask, te("Delete")))) return;
|
||||
await deleteEasy(p.key, p.version);
|
||||
logics = null;
|
||||
paper = null;
|
||||
showToast(te("Deleted"), "success");
|
||||
void showStart();
|
||||
} catch (error) {
|
||||
note.textContent = plainError(error);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
};
|
||||
|
||||
const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: doUndo });
|
||||
const close = createButton({
|
||||
label: te("Close"),
|
||||
variant: "ghost",
|
||||
onClick: () =>
|
||||
void (async () => {
|
||||
if (await mayLeave()) void showStart();
|
||||
})(),
|
||||
});
|
||||
const fieldActions = [
|
||||
undo,
|
||||
createButton({ label: te("Save"), onClick: () => void doSave() }),
|
||||
...(p.key
|
||||
? [
|
||||
createButton({
|
||||
label: te("Delete"),
|
||||
variant: "danger",
|
||||
onClick: () => void doDelete(),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
close,
|
||||
];
|
||||
const actions = el("div", {
|
||||
className: "m01-lb__actions",
|
||||
children: p.isBase
|
||||
? [createButton({ label: te("CopyEdit"), onClick: () => void doCopy() }), close]
|
||||
: fieldActions,
|
||||
});
|
||||
|
||||
const viewButtons = (["sentence", "grid"] as View[]).map((which) => {
|
||||
const b = createButton({
|
||||
label: te(which === "sentence" ? "View_Sentence" : "View_Grid"),
|
||||
variant: view === which ? "filled" : "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
view = which;
|
||||
for (const x of viewButtons) {
|
||||
const on = x.dataset.view === view;
|
||||
x.classList.toggle("ui-btn--filled", on);
|
||||
x.classList.toggle("ui-btn--ghost", !on);
|
||||
x.setAttribute("aria-pressed", String(on));
|
||||
}
|
||||
drawBasis();
|
||||
},
|
||||
});
|
||||
b.dataset.view = which;
|
||||
b.setAttribute("aria-pressed", String(view === which));
|
||||
return b;
|
||||
});
|
||||
|
||||
body.replaceChildren(
|
||||
el("div", {
|
||||
className: "m01-lb__strip",
|
||||
children: [
|
||||
name.root,
|
||||
createTag(p.isBase ? te("Standard") : te("Field"), p.isBase ? "neutral" : "accent"),
|
||||
el("span", {
|
||||
className: "m01-lb__applied",
|
||||
text: `${te("Applied")} · ${p.row.출처 || "—"}`,
|
||||
}),
|
||||
unsaved,
|
||||
actions,
|
||||
],
|
||||
}),
|
||||
note,
|
||||
...(p.isBase ? [muted(te("StandardNote"))] : []),
|
||||
el("div", {
|
||||
className: "m01-lb__cols",
|
||||
children: [
|
||||
column(te("Col_Source"), [sourceHost]),
|
||||
column(te("Col_Basis"), [blockedNote, basisHost], viewButtons),
|
||||
column(te("Col_Result"), [
|
||||
total,
|
||||
examplesHost,
|
||||
el("h5", { text: te("Sheet") }),
|
||||
sheetHost,
|
||||
checksHost,
|
||||
]),
|
||||
],
|
||||
}),
|
||||
);
|
||||
drawExamplesNow();
|
||||
if (p.lb) {
|
||||
renderChecks(checksHost, {
|
||||
lb: () => p.lb as LbLogic,
|
||||
tables: () => p.tables,
|
||||
trial: () => p.trial,
|
||||
ans: () => ans,
|
||||
draftKey: p.key ?? "새",
|
||||
});
|
||||
}
|
||||
void loadTables();
|
||||
calc();
|
||||
};
|
||||
|
||||
// 화면 함수들을 다 만든 뒤에 엶 — mount 가 바로 showStart 를 부름
|
||||
openModal({
|
||||
title: te("Title"),
|
||||
closeLabel: te("CloseWindow"),
|
||||
dialogClass: "m01-lb",
|
||||
beforeClose: mayLeave,
|
||||
mount: (host) => {
|
||||
body = host;
|
||||
(host.parentElement ?? host).addEventListener("keydown", onKey);
|
||||
void showStart();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 단계 안내 · 새 입력값의 예시값 — 서버 자동값과 같은 규칙(선택 = 첫째 · 범위 = 낮은 끝 · 숫자 0 · 가격 값은 비움) */
|
||||
function autoTrial(inputs: LogicInput[]): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const spec of inputs) {
|
||||
if (String(spec.단위 ?? "").includes("원")) continue;
|
||||
out[spec.이름] = spec.고르기?.length ? spec.고르기[0] : (spec.범위?.[0] ?? 0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const muted = (text: string): HTMLElement => el("p", { className: "m01-lb__muted", text });
|
||||
|
||||
/** 세 칸 가운데 하나 — 머리(제목 · 단추) + 몸 */
|
||||
function column(title: string, children: HTMLElement[], tools: HTMLElement[] = []): HTMLElement {
|
||||
return el("section", {
|
||||
className: "m01-lb__col",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-lb__col-head",
|
||||
children: [el("h4", { text: title }), ...tools],
|
||||
}),
|
||||
el("div", { className: "m01-lb__col-body", children }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function bigButton(label: string, onClick: () => void): HTMLButtonElement {
|
||||
const button = createButton({ label, variant: "filled", onClick });
|
||||
button.classList.add("m01-lb__big");
|
||||
return button;
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_LB_Entry_Result.ts
|
||||
* 로직 만들기 판 오른쪽 결과 칸의 도구 — 예시값 띠 · 빈틈 검사 · 기대값 시험표(Entry 가 부름)
|
||||
* 빈틈 · 시험표 셈은 Check(sub8) 것 · 시험표 줄은 화면 초안(sessionStorage)만 · 저장 안 함(정함 7)
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import type { CalcAnswer, LogicInput } from "../M01_MasterData_UI_Logic_Api";
|
||||
import { formatNumber } from "../M01_MasterData_UI_Logic_Edit";
|
||||
import type { LbTables } from "./M01_LB_Api";
|
||||
import type { LbLogic } from "./M01_LB_Blocks";
|
||||
import {
|
||||
gapSkipped,
|
||||
gaps,
|
||||
loadExpectRows,
|
||||
pickValues,
|
||||
runExpect,
|
||||
saveExpectRows,
|
||||
type ExpectRow,
|
||||
} from "./M01_LB_Check";
|
||||
|
||||
const TEXT = {
|
||||
Examples: ["예시값", "Example values"],
|
||||
Examples_Note: [
|
||||
"예시값은 계산을 보려는 값 — 저장 안 됨",
|
||||
"Example values are only for trying — not saved",
|
||||
],
|
||||
Pick: ["선택", "Choose"],
|
||||
Gaps: ["빈틈 검사", "Check for gaps"],
|
||||
Gaps_None: [
|
||||
"빈틈 없음 — 모든 선택 조합에 맞는 줄이 하나씩 있음",
|
||||
"No gaps — every combination finds one row",
|
||||
],
|
||||
Gaps_No: ["해당 없음", "No match"],
|
||||
Gaps_Dup: ["중복", "Duplicate"],
|
||||
Gaps_Rows: ["줄 {v}", "rows {v}"],
|
||||
Gaps_Skip: [
|
||||
"못 따진 표 조회 {n} 개 — 입력값이 숫자라 조합을 셀 수 없음",
|
||||
"{n} lookups not checked — numeric inputs",
|
||||
],
|
||||
Gaps_More: ["그 밖 {n} 건", "{n} more"],
|
||||
Expect: ["기대값 시험표", "Expected-value table"],
|
||||
Expect_Add: ["지금 예시값으로 한 줄 더하기", "Add a row from the current examples"],
|
||||
Expect_Run: ["모두 돌리기", "Run all"],
|
||||
Expect_Total: ["합계", "Total"],
|
||||
Expect_Ok: ["맞음", "Match"],
|
||||
Expect_Bad: ["다름 — 나온 값 {v}", "Differs — got {v}"],
|
||||
Expect_Del: ["삭제", "Delete"],
|
||||
Expect_None: [
|
||||
"줄 없음 — 예시값을 정한 뒤 한 줄 더하기",
|
||||
"No rows — set examples, then add a row",
|
||||
],
|
||||
Expect_Note: [
|
||||
"시험표는 이 화면에만 남음 — 저장 안 됨",
|
||||
"The table stays on this screen only — not saved",
|
||||
],
|
||||
} as const;
|
||||
|
||||
type TextKey = keyof typeof TEXT;
|
||||
|
||||
const tr = (key: TextKey, vars: Record<string, string | number> = {}): string =>
|
||||
Object.entries(vars).reduce(
|
||||
(text, [k, v]) => text.replace(`{${k}}`, String(v)),
|
||||
(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string,
|
||||
);
|
||||
|
||||
/** 볼 값 이름 — 합계는 「계」 · 값 결과는 「결과」 */
|
||||
const totalKey = (ans: CalcAnswer | null): "계" | "결과" =>
|
||||
ans?.ok && ans.result !== undefined ? "결과" : "계";
|
||||
|
||||
/** 예시값 띠 — 입력값마다 선택 또는 숫자 · 바꾸면 onChange(새 예시값) · 저장 안 함 */
|
||||
export function drawExamples(
|
||||
host: HTMLElement,
|
||||
inputs: LogicInput[],
|
||||
trial: Record<string, unknown>,
|
||||
onChange: (trial: Record<string, unknown>) => void,
|
||||
): void {
|
||||
host.hidden = !inputs.length;
|
||||
if (!inputs.length) return void host.replaceChildren();
|
||||
const state = { ...trial };
|
||||
const field = (spec: LogicInput): HTMLElement => {
|
||||
const label = spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름;
|
||||
let box: HTMLInputElement | HTMLSelectElement;
|
||||
if (spec.고르기?.length) {
|
||||
box = el("select", {
|
||||
className: "ui-select ui-select--sm",
|
||||
children: [
|
||||
el("option", { attrs: { value: "" }, text: tr("Pick") }),
|
||||
...spec.고르기.map((v) => el("option", { attrs: { value: String(v) }, text: String(v) })),
|
||||
],
|
||||
});
|
||||
box.value = state[spec.이름] === undefined ? "" : String(state[spec.이름]);
|
||||
box.addEventListener("change", () => {
|
||||
const picked = spec.고르기?.find((v) => String(v) === box.value);
|
||||
if (picked === undefined) delete state[spec.이름];
|
||||
else state[spec.이름] = picked;
|
||||
onChange({ ...state });
|
||||
});
|
||||
} else {
|
||||
box = el("input", {
|
||||
className: "ui-input ui-input--sm",
|
||||
attrs: {
|
||||
type: "number",
|
||||
...(spec.범위 ? { min: String(spec.범위[0]), max: String(spec.범위[1]) } : {}),
|
||||
},
|
||||
});
|
||||
box.value = state[spec.이름] == null ? "" : String(state[spec.이름]);
|
||||
box.addEventListener("change", () => {
|
||||
if (box.value === "") delete state[spec.이름];
|
||||
else state[spec.이름] = Number(box.value);
|
||||
onChange({ ...state });
|
||||
});
|
||||
}
|
||||
box.setAttribute("aria-label", spec.이름);
|
||||
return el("label", {
|
||||
className: "m01-lb__example",
|
||||
children: [el("span", { text: label }), box],
|
||||
});
|
||||
};
|
||||
host.replaceChildren(
|
||||
el("strong", { text: tr("Examples") }),
|
||||
el("div", { className: "m01-lb__example-list", children: inputs.map(field) }),
|
||||
el("p", { className: "m01-lb__muted", text: tr("Examples_Note") }),
|
||||
);
|
||||
}
|
||||
|
||||
const condText = (conds: Record<string, unknown>): string =>
|
||||
Object.entries(conds)
|
||||
.map(([k, v]) => `${k} ${Array.isArray(v) ? v.join("~") : String(v)}`)
|
||||
.join(" · ");
|
||||
|
||||
/** 빈틈 검사 · 기대값 시험표 — 화면 초안 열쇠 = 로직 번호(새 로직은 「새」) */
|
||||
export function renderChecks(
|
||||
host: HTMLElement,
|
||||
s: {
|
||||
lb(): LbLogic;
|
||||
tables(): LbTables;
|
||||
trial(): Record<string, unknown>;
|
||||
ans(): CalcAnswer | null;
|
||||
draftKey: string;
|
||||
},
|
||||
): void {
|
||||
const gapHost = el("div", { className: "m01-lb__check-out" });
|
||||
const rowsHost = el("div", { className: "m01-lb__check-out" });
|
||||
let rows: ExpectRow[] = loadExpectRows(s.draftKey);
|
||||
let results: (boolean | number | null)[] = [];
|
||||
const keep = (): void => saveExpectRows(s.draftKey, rows);
|
||||
|
||||
const runGaps = (): void => {
|
||||
const found = gaps(s.lb(), s.tables());
|
||||
const skipped = gapSkipped(s.lb(), s.tables()).length;
|
||||
const lines = found.slice(0, 20).map((g) =>
|
||||
el("li", {
|
||||
text: [
|
||||
g.kind === "없음" ? tr("Gaps_No") : tr("Gaps_Dup"),
|
||||
condText(g.conds),
|
||||
g.kind === "중복" ? tr("Gaps_Rows", { v: g.rows.map((r) => r + 1).join(", ") }) : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" — "),
|
||||
}),
|
||||
);
|
||||
gapHost.replaceChildren(
|
||||
found.length
|
||||
? el("ul", { children: lines })
|
||||
: el("p", { className: "m01-lb__muted", text: tr("Gaps_None") }),
|
||||
...(found.length > 20 ? [el("p", { text: tr("Gaps_More", { n: found.length - 20 }) })] : []),
|
||||
...(skipped
|
||||
? [el("p", { className: "m01-lb__muted", text: tr("Gaps_Skip", { n: skipped }) })]
|
||||
: []),
|
||||
);
|
||||
};
|
||||
|
||||
const drawRows = (): void => {
|
||||
if (!rows.length) {
|
||||
rowsHost.replaceChildren(el("p", { className: "m01-lb__muted", text: tr("Expect_None") }));
|
||||
return;
|
||||
}
|
||||
rowsHost.replaceChildren(
|
||||
...rows.map((row, i) => {
|
||||
const key = Object.keys(row.expect)[0] ?? "계";
|
||||
const box = el("input", {
|
||||
className: "ui-input ui-input--sm",
|
||||
attrs: { type: "number", "aria-label": tr("Expect_Total") },
|
||||
});
|
||||
box.value = String(row.expect[key] ?? "");
|
||||
box.addEventListener("change", () => {
|
||||
row.expect = { [key]: Number(box.value) };
|
||||
results[i] = null;
|
||||
keep();
|
||||
});
|
||||
const r = results[i];
|
||||
return el("div", {
|
||||
className: "m01-lb__expect-row",
|
||||
children: [
|
||||
el("span", { text: condText(row.inputs) || "—" }),
|
||||
box,
|
||||
el("span", {
|
||||
className: r === true ? "is-ok" : typeof r === "number" ? "is-bad" : "",
|
||||
text:
|
||||
r === true
|
||||
? tr("Expect_Ok")
|
||||
: typeof r === "number"
|
||||
? tr("Expect_Bad", { v: formatNumber(r) })
|
||||
: "",
|
||||
}),
|
||||
createButton({
|
||||
label: tr("Expect_Del"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
rows = rows.filter((_, j) => j !== i);
|
||||
results = results.filter((_, j) => j !== i);
|
||||
keep();
|
||||
drawRows();
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const addRow = (): void => {
|
||||
const ans = s.ans();
|
||||
const key = totalKey(ans);
|
||||
const now = ans?.ok ? pickValues(ans)[key] : undefined;
|
||||
rows = [...rows, { inputs: { ...s.trial() }, expect: { [key]: now ?? 0 } }];
|
||||
keep();
|
||||
drawRows();
|
||||
};
|
||||
|
||||
const runRows = async (): Promise<void> => {
|
||||
const got = await runExpect(s.lb(), s.tables(), rows);
|
||||
results = got.map((g, i) => {
|
||||
const key = Object.keys(rows[i].expect)[0] ?? "계";
|
||||
return g.ok ? true : (g.got[key] ?? null);
|
||||
});
|
||||
drawRows();
|
||||
};
|
||||
|
||||
host.replaceChildren(
|
||||
el("div", {
|
||||
className: "m01-lb__check",
|
||||
children: [
|
||||
createButton({ label: tr("Gaps"), variant: "ghost", size: "sm", onClick: runGaps }),
|
||||
gapHost,
|
||||
el("strong", { text: tr("Expect") }),
|
||||
el("div", {
|
||||
className: "m01-lb__check-tools",
|
||||
children: [
|
||||
createButton({
|
||||
label: tr("Expect_Add"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: addRow,
|
||||
}),
|
||||
createButton({
|
||||
label: tr("Expect_Run"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => void runRows(),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
rowsHost,
|
||||
el("p", { className: "m01-lb__muted", text: tr("Expect_Note") }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
drawRows();
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
.m01-lbgrid__scroll {
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.m01-lbgrid__table {
|
||||
width: 100%;
|
||||
min-width: 720px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-lbgrid__table th,
|
||||
.m01-lbgrid__table td {
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-right: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.m01-lbgrid__table th:first-child,
|
||||
.m01-lbgrid__table td:first-child {
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m01-lbgrid__table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-lbgrid__row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01-lbgrid__row--first td {
|
||||
border-top: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m01-lbgrid__row--sub .m01-lbgrid__name {
|
||||
padding-left: var(--spacing-16);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m01-lbgrid__step {
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-lbgrid__table td.m01-lbgrid__num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.m01-lbgrid__src {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 고른 줄 = 굵은 테두리 · 앞 줄 = 연한 칠 + 왼쪽 막대(색만으로 뜻을 안 전함) */
|
||||
.m01-lbgrid__row--on td {
|
||||
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m01-lbgrid__row--pre td {
|
||||
background: color-mix(in srgb, var(--color-warning) 28%, transparent);
|
||||
}
|
||||
|
||||
.m01-lbgrid__row--pre td:first-child {
|
||||
box-shadow: inset 4px 0 0 var(--color-warning);
|
||||
}
|
||||
|
||||
.m01-lbgrid__input {
|
||||
box-sizing: border-box;
|
||||
width: 96px;
|
||||
height: var(--control-height-sm);
|
||||
padding: 3px 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
font-size: var(--text-body-sm);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.m01-lbgrid__msg {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m01-lbgrid__note,
|
||||
.m01-lbgrid__hint {
|
||||
margin: var(--spacing-4) 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
@@ -1,540 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_LB_Grid.ts
|
||||
* 로직 만들기 판 — 가운데 격자 보기(엑셀처럼 줄 · 칸). 문장 보기(Steps)와 같은 LbLogic · 같은 on.change
|
||||
* 열 = 단계 · 이름 · 무엇으로 · 값 · 단위 · 출처 / 줄 = 입력값 · 표 조회(+조건) · 계산(+조각) · 항목 · 결과값
|
||||
* 칸은 고르기 · 숫자 칸만 · 줄을 누르면 그 줄이 쓰는 줄(앞 줄)을 칠함 · 표준은 읽기만
|
||||
* 모양 = docs/raw/M01_쉬운단가/로직판_A_정함.md · 로직판_A_설계초안.md 7-1. 이 폴더째 지워도 됨.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createSelectField, el } from "@ui/ui_template_elements";
|
||||
import type { CalcAnswer } from "../M01_MasterData_UI_Logic_Api";
|
||||
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
|
||||
import { safeReason } from "../M01_MasterData_UI_Logic_Text";
|
||||
import { updateCard, type EasyCard } from "../EasyCalc/M01_EasyCalc_Model";
|
||||
import {
|
||||
setCalc,
|
||||
setCond,
|
||||
type LbCalc,
|
||||
type LbExpr,
|
||||
type LbLogic,
|
||||
type LbRef,
|
||||
} from "./M01_LB_Blocks";
|
||||
import "./M01_LB_Grid.css";
|
||||
|
||||
const TEXT = {
|
||||
Step1: ["① 입력값", "① Input"],
|
||||
Step2: ["② 표 조회", "② Table lookup"],
|
||||
Step3: ["③ 계산", "③ Calculation"],
|
||||
Step4: ["④ 항목 연결", "④ Items"],
|
||||
Step5: ["⑤ 결과값", "⑤ Result"],
|
||||
HStep: ["단계", "Step"],
|
||||
HName: ["이름", "Name"],
|
||||
HHow: ["무엇으로", "From"],
|
||||
HValue: ["값", "Value"],
|
||||
HUnit: ["단위", "Unit"],
|
||||
HSource: ["출처", "Source"],
|
||||
Busy: ["계산 중", "Calculating"],
|
||||
Stopped: ["계산하지 못했어요", "Could not calculate"],
|
||||
ReadOnly: [
|
||||
"표준은 읽기만 — 「복사 편집」 뒤에 고칠 수 있어요",
|
||||
"Standard is read-only — use “Copy and edit” first",
|
||||
],
|
||||
Hint: ["줄을 누르면 그 줄이 쓰는 줄이 칠해져요", "Click a row to paint the rows it uses"],
|
||||
Bad: ["숫자를 넣어 주세요", "Enter a number"],
|
||||
Cant: ["지금은 이 칸을 고칠 수 없어요", "This cell cannot be changed now"],
|
||||
Typed: ["숫자 칸", "Number"],
|
||||
Choice: ["고르기", "Choice"],
|
||||
Range: ["범위", "Range"],
|
||||
FromInput: ["입력값", "Input"],
|
||||
FromCalc: ["계산", "Calc"],
|
||||
FromLookup: ["표 조회", "Lookup"],
|
||||
FromFixed: ["고정값", "Fixed"],
|
||||
FromLogic: ["다른 일위대가 결과", "Other unit-price result"],
|
||||
FromPeople: ["사람 품값 합", "People cost total"],
|
||||
FromNone: ["무관", "Any"],
|
||||
Own: ["직접 쓴 계산", "Written by hand"],
|
||||
Cond: ["조건", "Condition"],
|
||||
Table: ["표", "Table"],
|
||||
Number: ["숫자", "Number"],
|
||||
Calc: ["계산", "Calculation"],
|
||||
Sum: ["항목 합계", "Item total"],
|
||||
UseName: ["계산 이름", "Calc name"],
|
||||
UseQty: ["항목 수량", "Item amount"],
|
||||
UseRate: ["항목 비율", "Item rate"],
|
||||
UseResult: ["결과값", "Result"],
|
||||
Won: ["원", "KRW"],
|
||||
Unnamed: ["수량 계산", "Amount calc"],
|
||||
Price: ["단가", "Unit price"],
|
||||
Result: ["결과", "Result"],
|
||||
KValue: ["값", "Value"],
|
||||
KMul: ["곱하기 · 나누기", "Multiply / divide"],
|
||||
KRaise: ["할증", "Surcharge"],
|
||||
KAdd: ["더하기", "Add"],
|
||||
KCond: ["조건 목록", "Conditions"],
|
||||
KMinMax: ["최소 · 최대", "Min / max"],
|
||||
KRound: ["올림 · 버림", "Round"],
|
||||
KSteps: ["여러 단계", "Several steps"],
|
||||
Person: ["사람", "Person"],
|
||||
Material: ["재료", "Material"],
|
||||
Machine: ["기계", "Machine"],
|
||||
Logic: ["다른 일위대가", "Other unit price"],
|
||||
Percent: ["덧붙임", "Add-on %"],
|
||||
Locked: ["그 밖 줄", "Other line"],
|
||||
Base: ["바탕", "Base"],
|
||||
Rate: ["율(%)", "Rate (%)"],
|
||||
First: ["처음", "First"],
|
||||
Times: ["곱하기", "times"],
|
||||
Divide: ["나누기", "divided by"],
|
||||
Plus: ["더하기", "plus"],
|
||||
Minus: ["빼기", "minus"],
|
||||
} as const;
|
||||
|
||||
type TextKey = keyof typeof TEXT;
|
||||
const tg = (key: TextKey): string => TEXT[key][currentLanguageIndex];
|
||||
|
||||
type Part = Exclude<LbCalc["at"]["part"], never>;
|
||||
type Ref = LbRef | LbExpr;
|
||||
type Cell = string | HTMLElement;
|
||||
|
||||
interface GridRow {
|
||||
id: string;
|
||||
step: string;
|
||||
sub: boolean;
|
||||
name: Cell;
|
||||
how: Cell;
|
||||
value: Cell;
|
||||
unit: string;
|
||||
source: string;
|
||||
/** 이 줄이 쓰는 줄 번호 */
|
||||
uses: string[];
|
||||
}
|
||||
|
||||
const KIND_KEY: Record<LbCalc["kind"], TextKey> = {
|
||||
값: "KValue",
|
||||
곱나눔: "KMul",
|
||||
할증: "KRaise",
|
||||
더하기: "KAdd",
|
||||
조건목록: "KCond",
|
||||
최소최대: "KMinMax",
|
||||
올림: "KRound",
|
||||
단계: "KSteps",
|
||||
};
|
||||
const CARD_KEY: Record<EasyCard["kind"], TextKey> = {
|
||||
person: "Person",
|
||||
material: "Material",
|
||||
machine: "Machine",
|
||||
logic: "Logic",
|
||||
percent: "Percent",
|
||||
locked: "Locked",
|
||||
};
|
||||
const USE_KEY: Record<Part, TextKey> = {
|
||||
중간: "UseName",
|
||||
수량: "UseQty",
|
||||
비율: "UseRate",
|
||||
결과: "UseResult",
|
||||
};
|
||||
|
||||
const num = (n: number): string => n.toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
||||
const show = (v: unknown): string =>
|
||||
typeof v === "number"
|
||||
? num(v)
|
||||
: v === undefined || v === null || typeof v === "object"
|
||||
? ""
|
||||
: String(v);
|
||||
|
||||
/** 선택된 줄 — 다시 그려도 남도록 칸(host)마다 기억 */
|
||||
const picked = new WeakMap<HTMLElement, string>();
|
||||
|
||||
/** 이름이 글 안에 따로 있는지 — 영문 이름은 낱말 경계로 */
|
||||
function mentions(text: string, name: string): boolean {
|
||||
if (!name) return false;
|
||||
if (!/^\w+$/.test(name)) return text.includes(name);
|
||||
return new RegExp(`(^|[^A-Za-z0-9_])${name}($|[^A-Za-z0-9_])`).test(text);
|
||||
}
|
||||
|
||||
export interface GridOptions {
|
||||
/** 입력값 칸에 보일 예시값 — 없으면 계산 답에서 찾음 */
|
||||
trial?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 격자 보기 — on.change 로 고친 LbLogic 을 알림(읽기만이면 안 알림) */
|
||||
export function renderGrid(
|
||||
host: HTMLElement,
|
||||
lb: LbLogic,
|
||||
ans: CalcAnswer | null,
|
||||
on: { change(lb: LbLogic): void },
|
||||
opts: GridOptions = {},
|
||||
): void {
|
||||
const readOnly = lb.doc.isBase;
|
||||
const okAns = ans && ans.ok ? ans : null;
|
||||
const middle = okAns?.middle ?? {};
|
||||
const lines = okAns?.lines ?? [];
|
||||
const msg = el("p", { className: "m01-lbgrid__msg" });
|
||||
const calcIndex = (name: string): number => lb.calcs.findIndex((c) => c.name === name);
|
||||
const apply = (make: () => LbLogic): void => {
|
||||
if (readOnly) return;
|
||||
msg.textContent = "";
|
||||
try {
|
||||
on.change(make());
|
||||
} catch {
|
||||
msg.textContent = tg("Cant");
|
||||
}
|
||||
};
|
||||
|
||||
/* ---- 값 · 줄 번호 ---- */
|
||||
const rowOf = (r: Ref): string[] => {
|
||||
if (r.k === "input") return [`in:${r.name}`];
|
||||
if (r.k === "lookup") return [`lk:${r.id}`];
|
||||
if (r.k === "calc") return calcIndex(r.name) >= 0 ? [`ca:${calcIndex(r.name)}`] : [];
|
||||
if (r.k === "expr") return textRows(r.text);
|
||||
return [];
|
||||
};
|
||||
const textRows = (text: string, self = -1): string[] => [
|
||||
...lb.inputs.filter((i) => mentions(text, i.이름)).map((i) => `in:${i.이름}`),
|
||||
...lb.calcs.flatMap((c, i) =>
|
||||
i !== self && c.name && mentions(text, c.name) ? [`ca:${i}`] : [],
|
||||
),
|
||||
];
|
||||
const lookupsAt = (part: Part | "요소", index: number): string[] =>
|
||||
lb.lookups
|
||||
.filter((l) => l.at.some((a) => a.part === part && a.index === index))
|
||||
.map((l) => `lk:${l.id}`);
|
||||
const lineOf = (card: EasyCard) =>
|
||||
lines.find((l) => l.이름 === card.name && l.단위 === card.unit);
|
||||
const lookupValue = (id: string): unknown => {
|
||||
const solo = lb.calcs.find(
|
||||
(c) => c.parts.length === 1 && c.parts[0].k === "lookup" && c.parts[0].id === id,
|
||||
);
|
||||
if (solo?.name && middle[solo.name] !== undefined) return middle[solo.name];
|
||||
const at = lb.lookups
|
||||
.find((l) => l.id === id)
|
||||
?.at.find((a) => a.part === "수량" && a.start === 0);
|
||||
const card = lb.doc.cards.find(
|
||||
(c) => c.source?.part === "호표" && c.source.index === at?.index,
|
||||
);
|
||||
const line = card && lineOf(card);
|
||||
return line && card?.qtyFormula && at && at.end === card.qtyFormula.length ? line.수량 : "";
|
||||
};
|
||||
const valueOf = (r: Ref): unknown => {
|
||||
if (r.k === "input") return opts.trial?.[r.name] ?? middle[r.name];
|
||||
if (r.k === "fixed") return r.value;
|
||||
if (r.k === "calc") return middle[r.name];
|
||||
if (r.k === "lookup") return lookupValue(r.id);
|
||||
if (r.k === "cost") return okAns?.sums?.[r.name === "사람줄합" ? "노무비" : r.name];
|
||||
return "";
|
||||
};
|
||||
const unitOf = (r: Ref): string =>
|
||||
r.k === "input" ? (lb.inputs.find((i) => i.이름 === r.name)?.단위 ?? "") : "";
|
||||
|
||||
/* ---- 칸 만들기 ---- */
|
||||
const numberCell = (value: unknown, make: (n: number) => LbLogic): HTMLElement => {
|
||||
const box = el("input", {
|
||||
className: "m01-lbgrid__input",
|
||||
attrs: { type: "text", inputmode: "decimal", "aria-label": tg("Number") },
|
||||
});
|
||||
box.value = show(value);
|
||||
box.readOnly = readOnly;
|
||||
box.addEventListener("click", (e) => e.stopPropagation());
|
||||
box.addEventListener("change", () => {
|
||||
const n = Number(box.value.trim());
|
||||
if (box.value.trim() === "" || Number.isNaN(n)) {
|
||||
msg.textContent = tg("Bad");
|
||||
box.value = show(value);
|
||||
return;
|
||||
}
|
||||
apply(() => make(n));
|
||||
});
|
||||
return box;
|
||||
};
|
||||
|
||||
const refLabel = (r: Ref): string => {
|
||||
if (r.k === "input") return `${tg("FromInput")} · ${r.name}`;
|
||||
if (r.k === "calc") return `${tg("FromCalc")} · ${r.name}`;
|
||||
if (r.k === "lookup") {
|
||||
const l = lb.lookups.find((x) => x.id === r.id);
|
||||
return `${tg("FromLookup")} · ${l ? `${l.table} ${l.col}` : r.id}`;
|
||||
}
|
||||
if (r.k === "fixed") return tg("FromFixed");
|
||||
if (r.k === "logic") return tg("FromLogic");
|
||||
if (r.k === "cost") return r.name === "사람줄합" ? tg("FromPeople") : r.name;
|
||||
return tg("Own");
|
||||
};
|
||||
const refKey = (r: Ref): string =>
|
||||
r.k === "input"
|
||||
? `input:${r.name}`
|
||||
: r.k === "calc"
|
||||
? `calc:${r.name}`
|
||||
: r.k === "lookup"
|
||||
? `lookup:${r.id}`
|
||||
: r.k === "fixed"
|
||||
? "fixed"
|
||||
: r.k === "logic"
|
||||
? `logic:${r.key}`
|
||||
: r.k === "cost"
|
||||
? `cost:${r.name}`
|
||||
: "expr";
|
||||
|
||||
/** 고를 수 있는 곳 — 입력값 · (앞선) 계산 · 표 조회 · 지금 값 */
|
||||
const refSelect = (
|
||||
cur: Ref,
|
||||
pool: Ref[],
|
||||
none: boolean,
|
||||
pick: (r: Ref | null) => LbLogic,
|
||||
): HTMLElement => {
|
||||
const all = [...pool];
|
||||
if (!all.some((r) => refKey(r) === refKey(cur))) all.unshift(cur);
|
||||
const options = [
|
||||
...(none ? [{ value: "none", text: tg("FromNone") }] : []),
|
||||
...all.map((r) => ({ value: refKey(r), text: refLabel(r) })),
|
||||
];
|
||||
const field = createSelectField({
|
||||
options,
|
||||
value: refKey(cur),
|
||||
compact: true,
|
||||
ariaLabel: refLabel(cur),
|
||||
disabled: readOnly || cur.k === "expr",
|
||||
onChange: (v) => {
|
||||
if (v === refKey(cur)) return;
|
||||
apply(() => pick(v === "none" ? null : (all.find((r) => refKey(r) === v) ?? cur)));
|
||||
},
|
||||
});
|
||||
field.root.addEventListener("click", (e) => e.stopPropagation());
|
||||
return field.root;
|
||||
};
|
||||
const inputRefs: Ref[] = lb.inputs.map((i) => ({ k: "input", name: i.이름 }));
|
||||
const calcRefs = (before: number): Ref[] =>
|
||||
lb.calcs.flatMap((c, i): Ref[] => (c.name && i < before ? [{ k: "calc", name: c.name }] : []));
|
||||
const lookupRefs: Ref[] = lb.lookups.map((l) => ({ k: "lookup", id: l.id }));
|
||||
|
||||
/* ---- 줄 모으기 ---- */
|
||||
const rows: GridRow[] = [];
|
||||
const add = (r: Partial<GridRow> & { id: string; name: Cell }): GridRow => {
|
||||
const row: GridRow = {
|
||||
step: "",
|
||||
sub: false,
|
||||
how: "",
|
||||
value: "",
|
||||
unit: "",
|
||||
source: "",
|
||||
uses: [],
|
||||
...r,
|
||||
};
|
||||
rows.push(row);
|
||||
return row;
|
||||
};
|
||||
|
||||
lb.inputs.forEach((inp, i) => {
|
||||
add({
|
||||
id: `in:${inp.이름}`,
|
||||
step: i === 0 ? tg("Step1") : "",
|
||||
name: inp.이름,
|
||||
how: inp.고르기?.length ? tg("Choice") : tg("Typed"),
|
||||
value: show(valueOf({ k: "input", name: inp.이름 })),
|
||||
unit: inp.단위 ?? "",
|
||||
source: inp.고르기?.length
|
||||
? inp.고르기.join(" · ")
|
||||
: inp.범위
|
||||
? `${tg("Range")} ${inp.범위[0]}∼${inp.범위[1]}`
|
||||
: "",
|
||||
});
|
||||
});
|
||||
|
||||
lb.lookups.forEach((lk, i) => {
|
||||
const head = add({
|
||||
id: `lk:${lk.id}`,
|
||||
step: i === 0 ? tg("Step2") : "",
|
||||
name: lk.col,
|
||||
how: tg("FromLookup"),
|
||||
value: show(lookupValue(lk.id)),
|
||||
source: `${tg("Table")} ${lk.table}`,
|
||||
});
|
||||
lk.conds.forEach((cd) => {
|
||||
const from = cd.from;
|
||||
const fixedNum = from.k === "fixed" && typeof from.value === "number";
|
||||
const subRow = add({
|
||||
id: `lc:${lk.id}:${cd.col}`,
|
||||
sub: true,
|
||||
name: `${tg("Cond")} · ${cd.col}`,
|
||||
how: refSelect(
|
||||
from,
|
||||
[
|
||||
...inputRefs,
|
||||
...calcRefs(lb.calcs.length).filter((r) => {
|
||||
const c = lb.calcs.find((x) => x.name === (r as { name: string }).name);
|
||||
return c && !c.parts.some((p) => p.k === "lookup") && !c.text.includes("찾기(");
|
||||
}),
|
||||
],
|
||||
true,
|
||||
(r) => setCond(lb, lk.id, cd.col, r),
|
||||
),
|
||||
value:
|
||||
fixedNum && from.k === "fixed"
|
||||
? numberCell(from.value, (n) => setCond(lb, lk.id, cd.col, { k: "fixed", value: n }))
|
||||
: show(valueOf(from)),
|
||||
unit: unitOf(from),
|
||||
uses: rowOf(from),
|
||||
});
|
||||
head.uses.push(...subRow.uses);
|
||||
});
|
||||
});
|
||||
|
||||
lb.calcs.forEach((c, i) => {
|
||||
const first = i === 0;
|
||||
const own = lookupsAt(c.at.part, c.at.index);
|
||||
const head = add({
|
||||
id: `ca:${i}`,
|
||||
step: first ? tg("Step3") : "",
|
||||
name: c.name ?? `${tg("Unnamed")} ${i + 1}`,
|
||||
how: tg(KIND_KEY[c.kind]),
|
||||
value: show(c.name ? middle[c.name] : c.at.part === "수량" ? lines[c.at.index]?.수량 : ""),
|
||||
source: tg(USE_KEY[c.at.part]),
|
||||
uses: [...textRows(c.text, i), ...own],
|
||||
});
|
||||
if (!["값", "곱나눔", "할증", "더하기"].includes(c.kind)) return;
|
||||
c.parts.forEach((p, pi) => {
|
||||
const label =
|
||||
c.kind === "할증"
|
||||
? tg(pi === 0 ? "Base" : "Rate")
|
||||
: c.kind === "값"
|
||||
? tg("KValue")
|
||||
: pi === 0
|
||||
? tg("First")
|
||||
: tg(
|
||||
({ "*": "Times", "/": "Divide", "+": "Plus", "-": "Minus" } as const)[
|
||||
c.ops?.[pi - 1] ?? (c.kind === "더하기" ? "+" : "*")
|
||||
],
|
||||
);
|
||||
const fixedNum = p.k === "fixed" && typeof p.value === "number";
|
||||
const setPart = (r: Ref | null): LbLogic => {
|
||||
const parts = c.parts.slice();
|
||||
if (r) parts[pi] = r;
|
||||
return setCalc(lb, c, { parts });
|
||||
};
|
||||
const sub = add({
|
||||
id: `cp:${i}:${pi}`,
|
||||
sub: true,
|
||||
name: label,
|
||||
how: refSelect(p, [...inputRefs, ...calcRefs(i), ...lookupRefs], false, setPart),
|
||||
value:
|
||||
fixedNum && p.k === "fixed"
|
||||
? numberCell(p.value, (n) => setPart({ k: "fixed", value: n }))
|
||||
: show(valueOf(p)),
|
||||
unit: unitOf(p),
|
||||
uses: rowOf(p),
|
||||
});
|
||||
head.uses.push(...sub.uses);
|
||||
});
|
||||
});
|
||||
|
||||
lb.doc.cards.forEach((card, i) => {
|
||||
const sp = card.source;
|
||||
const parts: Part[] = sp?.part === "덧줄" ? ["비율"] : ["수량"];
|
||||
const viaLookup = sp
|
||||
? parts
|
||||
.flatMap((p) => lookupsAt(p, sp.index))
|
||||
.concat(sp.part === "호표" ? lookupsAt("요소", sp.index) : [])
|
||||
: [];
|
||||
const viaCalc = sp
|
||||
? lb.calcs.flatMap((c, ci) =>
|
||||
parts.includes(c.at.part) && c.at.index === sp.index ? [`ca:${ci}`] : [],
|
||||
)
|
||||
: [];
|
||||
const line = lineOf(card);
|
||||
const how = card.qty !== null ? tg("Number") : viaLookup.length ? tg("FromLookup") : tg("Calc");
|
||||
add({
|
||||
id: `it:${card.id}`,
|
||||
step: i === 0 ? tg("Step4") : "",
|
||||
name: card.spec ? `${card.name} ${card.spec}` : card.name,
|
||||
how: tg(CARD_KEY[card.kind]),
|
||||
value:
|
||||
card.qty !== null
|
||||
? numberCell(card.qty, (n) => ({ ...lb, doc: updateCard(lb.doc, card.id, { qty: n }) }))
|
||||
: show(line?.수량),
|
||||
unit: card.unit,
|
||||
source: `${how}${line ? ` · ${formatMoney(line.금액)} ${tg("Won")}` : ""}`,
|
||||
uses: [...viaLookup, ...viaCalc, ...(card.qtyFormula ? textRows(card.qtyFormula) : [])],
|
||||
});
|
||||
});
|
||||
|
||||
const res = lb.result;
|
||||
add({
|
||||
id: "res",
|
||||
step: tg("Step5"),
|
||||
name: tg(res.kind === "금액" ? "Price" : "Result"),
|
||||
how: res.kind,
|
||||
value:
|
||||
res.kind === "금액"
|
||||
? okAns
|
||||
? formatMoney(okAns.sums?.["계"] ?? 0)
|
||||
: ""
|
||||
: show(okAns?.result),
|
||||
unit: res.unit,
|
||||
source: res.kind === "금액" ? tg("Sum") : tg("Calc"),
|
||||
uses:
|
||||
res.kind === "금액"
|
||||
? lb.doc.cards.map((c) => `it:${c.id}`)
|
||||
: [
|
||||
...textRows(res.calc.text),
|
||||
...lb.calcs.flatMap((c, i) => (c.at.part === "결과" ? [`ca:${i}`] : [])),
|
||||
],
|
||||
});
|
||||
|
||||
/* ---- 그리기 ---- */
|
||||
const tbody = el("tbody");
|
||||
const paint = (): void => {
|
||||
const sel = picked.get(host);
|
||||
const uses = new Set(rows.find((r) => r.id === sel)?.uses ?? []);
|
||||
tbody.querySelectorAll<HTMLElement>("tr").forEach((tr) => {
|
||||
const id = tr.dataset.row ?? "";
|
||||
tr.classList.toggle("m01-lbgrid__row--on", id === sel);
|
||||
tr.classList.toggle("m01-lbgrid__row--pre", uses.has(id));
|
||||
});
|
||||
};
|
||||
const td = (c: Cell, className = ""): HTMLElement =>
|
||||
el("td", { className, ...(typeof c === "string" ? { text: c } : { children: [c] }) });
|
||||
rows.forEach((r) => {
|
||||
const tr = el("tr", {
|
||||
className: `m01-lbgrid__row${r.sub ? " m01-lbgrid__row--sub" : ""}${r.step ? " m01-lbgrid__row--first" : ""}`,
|
||||
attrs: { "data-row": r.id },
|
||||
children: [
|
||||
td(r.step, "m01-lbgrid__step"),
|
||||
td(r.name, "m01-lbgrid__name"),
|
||||
td(r.how),
|
||||
td(r.value, "m01-lbgrid__num"),
|
||||
td(r.unit),
|
||||
td(r.source, "m01-lbgrid__src"),
|
||||
],
|
||||
});
|
||||
tr.addEventListener("click", () => {
|
||||
if (picked.get(host) === r.id) picked.delete(host);
|
||||
else picked.set(host, r.id);
|
||||
paint();
|
||||
});
|
||||
tbody.append(tr);
|
||||
});
|
||||
const heads: TextKey[] = ["HStep", "HName", "HHow", "HValue", "HUnit", "HSource"];
|
||||
const status =
|
||||
ans && !ans.ok ? `${tg("Stopped")} — ${safeReason(ans.reason)}` : ans ? "" : tg("Busy");
|
||||
host.replaceChildren(
|
||||
...(readOnly ? [el("p", { className: "m01-lbgrid__note", text: tg("ReadOnly") })] : []),
|
||||
...(status ? [el("p", { className: "m01-lbgrid__note", text: status })] : []),
|
||||
el("div", {
|
||||
className: "m01-lbgrid__scroll",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "m01-lbgrid__table",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [el("tr", { children: heads.map((h) => el("th", { text: tg(h) })) })],
|
||||
}),
|
||||
tbody,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
msg,
|
||||
el("p", { className: "m01-lbgrid__hint", text: tg("Hint") }),
|
||||
);
|
||||
paint();
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
/* 로직 만들기 판 — 왼쪽 원문 */
|
||||
.m01-lbsrc {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
min-width: 0;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-lbsrc p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.m01-lbsrc__muted {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.m01-lbsrc__muted:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m01-lbsrc__preview {
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-radius: var(--radius-lg);
|
||||
background: color-mix(in srgb, var(--color-warning) 14%, var(--color-canvas));
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.m01-lbsrc__table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01-lbsrc__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01-lbsrc__users {
|
||||
margin-left: auto;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--color-accent);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.m01-lbsrc__warn {
|
||||
color: var(--color-danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 엑셀 같은 격자 */
|
||||
.m01-lbsrc__grid {
|
||||
max-height: 22rem;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid th,
|
||||
.m01-lbsrc__grid td {
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--color-paper);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid thead th span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid .m01-lbsrc__no {
|
||||
width: 2.5rem;
|
||||
background: var(--color-paper);
|
||||
color: var(--color-text-muted);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid td {
|
||||
cursor: cell;
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid td.is-value {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid td.is-col {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid tr.is-hit td {
|
||||
background: color-mix(in srgb, var(--color-warning) 28%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid td.is-edited {
|
||||
box-shadow: inset 0 0 0 2px var(--color-accent);
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid td.is-bad {
|
||||
box-shadow: inset 0 0 0 2px var(--color-danger);
|
||||
}
|
||||
|
||||
.m01-lbsrc__input {
|
||||
width: 100%;
|
||||
min-width: 4rem;
|
||||
padding: 0 var(--spacing-4);
|
||||
border: 1px solid var(--color-accent);
|
||||
font: inherit;
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* 칸 역할 칩 — 조건 파랑 · 고정 회색 · 결과 초록 · 무관 점선 */
|
||||
.m01-lbsrc__chip {
|
||||
margin-top: var(--spacing-4);
|
||||
padding: 0 var(--spacing-8);
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: var(--text-caption);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01-lbsrc__chip.is-cond {
|
||||
color: var(--color-accent);
|
||||
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||
}
|
||||
|
||||
.m01-lbsrc__chip.is-fixed {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m01-lbsrc__chip.is-result {
|
||||
color: var(--color-success, #2f6b4f);
|
||||
background: color-mix(in srgb, var(--color-success, #2f6b4f) 12%, transparent);
|
||||
}
|
||||
|
||||
.m01-lbsrc__chip.is-free {
|
||||
color: var(--color-text-muted);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.m01-lbsrc__notes summary {
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m01-lbsrc__notes ol {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
padding-left: var(--spacing-16);
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m01-lbsrc__notes li.is-used {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m01-lbsrc__tag {
|
||||
margin-left: var(--spacing-8);
|
||||
padding: 0 var(--spacing-4);
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
||||
color: var(--color-accent);
|
||||
font-size: var(--text-caption);
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_LB_Source.ts
|
||||
* 로직 만들기 판 — 왼쪽 원문(정함 5 · 7 · 설계초안 6절)
|
||||
* 로직이 쓰는 원문 표마다: 표 이름 · 원문번호 · 기준 · 엑셀 같은 격자(칸 머리 역할 칩 조건 / 고정 / 결과)
|
||||
* · 예시값으로 걸린 줄 노랑 · 해당 없음 / 중복 한 줄 · 칸 눌러 고침(메모리 사본 — on.edit) · 주 목록 · 사용처.
|
||||
* 표 고침은 미리 보기 — 저장하지 않음(정함 5). 화면 글에 키 · 식 안 씀(정함 9).
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { tableUsers, type LbTables } from "./M01_LB_Api";
|
||||
import type { LbLogic, LbLookup, TableEdit } from "./M01_LB_Blocks";
|
||||
import type { LbHit } from "./M01_LB_Check";
|
||||
import "./M01_LB_Source.css";
|
||||
|
||||
const TEXT = {
|
||||
Preview: [
|
||||
"표 고침은 미리 보기 — 저장은 결정 뒤",
|
||||
"Table edits are a preview — saving comes later",
|
||||
],
|
||||
Users: ["사용처", "Used by"],
|
||||
UsersN: ["이 표를 쓰는 로직 {n} 개", "{n} logics use this table"],
|
||||
UsersNone: ["이 표를 쓰는 로직 없음", "No logic uses this table"],
|
||||
UsersFail: ["사용처를 불러오지 못함", "Couldn't load users"],
|
||||
Loading: ["불러오는 중", "Loading"],
|
||||
Missing: ["표를 불러오지 못함", "Couldn't load the table"],
|
||||
NoRow: ["해당 없음 — 예시값에 맞는 줄이 없음", "No match — no row fits the sample values"],
|
||||
DupRow: [
|
||||
"중복 — 예시값에 맞는 줄이 둘 이상",
|
||||
"Duplicate — more than one row fits the sample values",
|
||||
],
|
||||
Notes: ["주", "Notes"],
|
||||
NoteUsed: ["계산에 쓰임", "Used in the calculation"],
|
||||
Cond: ["조건", "Condition"],
|
||||
Fixed: ["고정", "Fixed"],
|
||||
Result: ["결과", "Result"],
|
||||
Free: ["무관", "Any"],
|
||||
RoleHint: ["눌러 역할 바꿈", "Tap to change the role"],
|
||||
EditHint: [
|
||||
"칸을 눌러 고침 · Enter 로 끝 · Esc 로 취소",
|
||||
"Tap a cell to edit · Enter to finish · Esc to cancel",
|
||||
],
|
||||
Edited: ["고친 칸", "Edited cell"],
|
||||
} as const;
|
||||
|
||||
const ts = (key: keyof typeof TEXT, n?: number): string =>
|
||||
TEXT[key][currentLanguageIndex as 0 | 1].replace("{n}", String(n ?? ""));
|
||||
|
||||
export type LbRole = "조건" | "고정" | "결과";
|
||||
type Cell = string | number | boolean | null | [number | null, number | null] | undefined;
|
||||
|
||||
interface SourceTable {
|
||||
키?: string;
|
||||
이름?: string;
|
||||
원문번호?: string;
|
||||
기준?: string;
|
||||
범위규칙?: string;
|
||||
조건?: Record<string, string>;
|
||||
값칸?: Record<string, string>;
|
||||
주?: string[];
|
||||
용도?: { 로직키?: string[] };
|
||||
줄: Record<string, Cell>[];
|
||||
}
|
||||
|
||||
export interface SourceOn {
|
||||
edit(e: TableEdit): void;
|
||||
role(table: string, col: string, role: LbRole): void;
|
||||
}
|
||||
|
||||
/* ── 칸 보이기 · 읽기 ─────────────────────────────────────────────── */
|
||||
|
||||
const isRange = (v: unknown): v is [number | null, number | null] =>
|
||||
Array.isArray(v) && v.length === 2;
|
||||
|
||||
/** 칸 글 — 범위 [a, b] 는 「a~b」 · 끝이 없으면 「~b」 · 「a~」 */
|
||||
export function showCell(v: Cell): string {
|
||||
if (v === null || v === undefined) return "";
|
||||
if (isRange(v)) return `${v[0] ?? ""}~${v[1] ?? ""}`;
|
||||
return String(v);
|
||||
}
|
||||
|
||||
/** 고친 글 → 칸 값 — 원래 칸이 범위면 범위 · 수면 수 · 아니면 글 · 못 읽으면 null */
|
||||
export function parseCell(text: string, before: Cell): TableEdit["value"] | null {
|
||||
const t = text.trim();
|
||||
const num = (s: string): number | null | undefined => {
|
||||
const c = s.replace(/,/g, "").trim();
|
||||
if (!c) return null;
|
||||
return /^-?\d+(\.\d+)?$/.test(c) ? Number(c) : undefined;
|
||||
};
|
||||
if (isRange(before) || /^[^~~]*[~~][^~~]*$/.test(t)) {
|
||||
const parts = t.split(/[~~]/);
|
||||
if (parts.length !== 2) return null;
|
||||
const [a, b] = parts.map(num);
|
||||
if (a === undefined || b === undefined || (a === null && b === null)) return null;
|
||||
if (a !== null && b !== null && a > b) return null;
|
||||
return [a, b];
|
||||
}
|
||||
if (!t) return null;
|
||||
const n = num(t);
|
||||
if (typeof before === "number" || (typeof n === "number" && typeof before !== "string")) {
|
||||
return typeof n === "number" ? n : null;
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
/* ── 역할 · 주 · 걸린 줄 ─────────────────────────────────────────── */
|
||||
|
||||
/** 표 칸 하나의 역할 — 이 로직의 표 조회가 그 칸을 어떻게 쓰나(첫 조회 기준) · 안 쓰면 null */
|
||||
export function roleOf(lookups: LbLookup[], col: string): LbRole | null {
|
||||
for (const lk of lookups) {
|
||||
if (lk.col === col) return "결과";
|
||||
const c = lk.conds.find((x) => x.col === col);
|
||||
if (c) return c.from.k === "fixed" ? "고정" : "조건";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const NEXT: Record<LbRole, LbRole> = { 조건: "고정", 고정: "결과", 결과: "조건" };
|
||||
|
||||
/** 로직 글에 적힌 주 — 「13-4-1 주③」 은 그 원문번호 표의 ③ · 번호 없는 「주③」 은 모든 표 */
|
||||
export function usedNotes(row: unknown): Map<string, Set<string>> {
|
||||
const out = new Map<string, Set<string>>();
|
||||
const add = (where: string, mark: string): void => {
|
||||
const set = out.get(where) ?? new Set<string>();
|
||||
set.add(mark);
|
||||
out.set(where, set);
|
||||
};
|
||||
const walk = (v: unknown): void => {
|
||||
if (typeof v === "string") {
|
||||
for (const m of v.matchAll(/(\d+(?:-\d+)+)?\s*주\s*([①-⑳])/g)) add(m[1] ?? "*", m[2]);
|
||||
} else if (Array.isArray(v)) v.forEach(walk);
|
||||
else if (v && typeof v === "object") Object.values(v).forEach(walk);
|
||||
};
|
||||
walk(row);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 표에 걸린 줄들 · 해당 없음 · 중복 — 표 조회 번호로 찾고 없으면 표 키로 */
|
||||
function hitsOf(
|
||||
key: string,
|
||||
lookups: LbLookup[],
|
||||
hits: Record<string, LbHit | undefined>,
|
||||
): { rows: Set<number>; none: boolean; dup: boolean } {
|
||||
const got = lookups.map((lk) => hits[lk.id] ?? hits[key] ?? null);
|
||||
return {
|
||||
rows: new Set(got.filter((h): h is number => typeof h === "number")),
|
||||
none: got.includes("없음"),
|
||||
dup: got.includes("중복"),
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 그리기 ─────────────────────────────────────────────────────── */
|
||||
|
||||
/** 처음 본 표 값 — 고친 칸 표시용(화면이 열려 있는 동안) */
|
||||
const originals = new Map<string, string[][]>();
|
||||
|
||||
function snapshot(key: string, table: SourceTable, cols: string[]): string[][] {
|
||||
const had = originals.get(key);
|
||||
if (had && had.length === table.줄.length) return had;
|
||||
const shot = table.줄.map((r) => cols.map((c) => showCell(r[c])));
|
||||
originals.set(key, shot);
|
||||
return shot;
|
||||
}
|
||||
|
||||
function roleChip(
|
||||
key: string,
|
||||
col: string,
|
||||
role: LbRole | null,
|
||||
isCond: boolean,
|
||||
on: SourceOn,
|
||||
): HTMLElement {
|
||||
const shown = role ?? (isCond ? null : undefined);
|
||||
if (shown === undefined) return el("span");
|
||||
const label = shown
|
||||
? ts(shown === "조건" ? "Cond" : shown === "고정" ? "Fixed" : "Result")
|
||||
: ts("Free");
|
||||
const chip = el("button", {
|
||||
className: `m01-lbsrc__chip is-${shown ? { 조건: "cond", 고정: "fixed", 결과: "result" }[shown] : "free"}`,
|
||||
text: label,
|
||||
attrs: { type: "button", title: ts("RoleHint") },
|
||||
});
|
||||
chip.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
on.role(key, col, shown ? NEXT[shown] : "조건");
|
||||
});
|
||||
return chip;
|
||||
}
|
||||
|
||||
function editCell(
|
||||
td: HTMLTableCellElement,
|
||||
before: Cell,
|
||||
done: (v: TableEdit["value"]) => void,
|
||||
): void {
|
||||
if (td.querySelector("input")) return;
|
||||
const input = el("input", { className: "m01-lbsrc__input", attrs: { type: "text" } });
|
||||
input.value = showCell(before);
|
||||
const text = td.textContent ?? "";
|
||||
td.replaceChildren(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
let closed = false;
|
||||
const close = (save: boolean): void => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
const value = save ? parseCell(input.value, before) : null;
|
||||
const same = value !== null && showCell(value) === showCell(before);
|
||||
td.textContent = value === null || same ? text : showCell(value);
|
||||
td.classList.toggle("is-bad", save && value === null && input.value.trim() !== text);
|
||||
if (value !== null && !same) done(value);
|
||||
};
|
||||
input.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter") close(true);
|
||||
if (ev.key === "Escape") close(false);
|
||||
ev.stopPropagation();
|
||||
});
|
||||
input.addEventListener("blur", () => close(true));
|
||||
}
|
||||
|
||||
function renderTable(
|
||||
key: string,
|
||||
table: SourceTable | undefined,
|
||||
lb: LbLogic,
|
||||
hits: Record<string, LbHit | undefined>,
|
||||
notes: Map<string, Set<string>>,
|
||||
on: SourceOn,
|
||||
): HTMLElement {
|
||||
const lookups = lb.lookups.filter((lk) => lk.table === key);
|
||||
if (!table?.줄) {
|
||||
return el("section", {
|
||||
className: "m01-lbsrc__table",
|
||||
children: [el("p", { className: "m01-lbsrc__muted", text: ts("Missing") })],
|
||||
});
|
||||
}
|
||||
const conds = Object.keys(table.조건 ?? {});
|
||||
const vals = Object.keys(table.값칸 ?? {});
|
||||
const cols = [...conds, ...vals];
|
||||
const orig = snapshot(key, table, cols);
|
||||
const hit = hitsOf(key, lookups, hits);
|
||||
|
||||
const users = (table.용도?.로직키 ?? []).length;
|
||||
const userBtn = el("button", {
|
||||
className: "m01-lbsrc__users",
|
||||
text: users ? ts("UsersN", users) : ts("UsersNone"),
|
||||
attrs: { type: "button" },
|
||||
});
|
||||
const userList = el("p", { className: "m01-lbsrc__muted" });
|
||||
userBtn.addEventListener("click", () => {
|
||||
userList.textContent = ts("Loading");
|
||||
tableUsers(key)
|
||||
.then((list) => {
|
||||
userBtn.textContent = list.length ? ts("UsersN", list.length) : ts("UsersNone");
|
||||
userList.textContent = list.map((u) => u.이름).join(" · ");
|
||||
})
|
||||
.catch(() => (userList.textContent = ts("UsersFail")));
|
||||
});
|
||||
|
||||
const head = el("tr", {
|
||||
children: [
|
||||
el("th", { className: "m01-lbsrc__no", text: "" }),
|
||||
...cols.map((c) => {
|
||||
const unit = table.값칸?.[c];
|
||||
return el("th", {
|
||||
className: vals.includes(c) ? "is-value" : "",
|
||||
children: [
|
||||
el("span", { text: unit ? `${c} (${unit})` : c }),
|
||||
roleChip(key, c, roleOf(lookups, c), conds.includes(c), on),
|
||||
],
|
||||
});
|
||||
}),
|
||||
],
|
||||
});
|
||||
const body = table.줄.map((row, i) => {
|
||||
const tr = el("tr", {
|
||||
className: hit.rows.has(i) ? "is-hit" : "",
|
||||
children: [
|
||||
el("td", { className: "m01-lbsrc__no", text: String(i + 1) }),
|
||||
...cols.map((c, j) => {
|
||||
const td = el("td", {
|
||||
className: [
|
||||
vals.includes(c) ? "is-value" : "",
|
||||
lookups.some((lk) => lk.col === c) ? "is-col" : "",
|
||||
showCell(row[c]) !== orig[i]?.[j] ? "is-edited" : "",
|
||||
].join(" "),
|
||||
text: showCell(row[c]),
|
||||
});
|
||||
td.addEventListener("click", () =>
|
||||
editCell(td, row[c], (value) => {
|
||||
td.classList.toggle("is-edited", showCell(value) !== orig[i]?.[j]);
|
||||
on.edit({ table: key, row: i, col: c, value });
|
||||
}),
|
||||
);
|
||||
return td;
|
||||
}),
|
||||
],
|
||||
});
|
||||
return tr;
|
||||
});
|
||||
|
||||
const marks = new Set([...(notes.get(table.원문번호 ?? "") ?? []), ...(notes.get("*") ?? [])]);
|
||||
const noteList = (table.주 ?? []).map((n) => {
|
||||
const used = marks.has(n.trim().charAt(0));
|
||||
return el("li", {
|
||||
className: used ? "is-used" : "",
|
||||
children: [
|
||||
el("span", { text: n }),
|
||||
...(used ? [el("span", { className: "m01-lbsrc__tag", text: ts("NoteUsed") })] : []),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
return el("section", {
|
||||
className: "m01-lbsrc__table",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-lbsrc__head",
|
||||
children: [
|
||||
el("strong", { text: table.이름 ?? "" }),
|
||||
el("span", {
|
||||
className: "m01-lbsrc__muted",
|
||||
text: [table.원문번호, table.기준].filter(Boolean).join(" · "),
|
||||
}),
|
||||
userBtn,
|
||||
],
|
||||
}),
|
||||
userList,
|
||||
...(hit.none ? [el("p", { className: "m01-lbsrc__warn", text: ts("NoRow") })] : []),
|
||||
...(hit.dup ? [el("p", { className: "m01-lbsrc__warn", text: ts("DupRow") })] : []),
|
||||
el("div", {
|
||||
className: "m01-lbsrc__grid",
|
||||
children: [
|
||||
el("table", {
|
||||
children: [el("thead", { children: [head] }), el("tbody", { children: body })],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
...(noteList.length
|
||||
? [
|
||||
el("details", {
|
||||
className: "m01-lbsrc__notes",
|
||||
attrs: marks.size ? { open: "" } : {},
|
||||
children: [el("summary", { text: ts("Notes") }), el("ol", { children: noteList })],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 왼쪽 원문 — 표 조회가 쓰는 표마다 한 덩어리 · hits = 표 조회 번호(또는 표 키) → 걸린 줄 */
|
||||
export function renderSource(
|
||||
host: HTMLElement,
|
||||
lb: LbLogic,
|
||||
tables: LbTables,
|
||||
hits: Record<string, LbHit | undefined>,
|
||||
on: SourceOn,
|
||||
): void {
|
||||
const keys = [...new Set(lb.lookups.map((lk) => lk.table))];
|
||||
const notes = usedNotes(lb.doc?.row);
|
||||
host.replaceChildren(
|
||||
el("div", {
|
||||
className: "m01-lbsrc",
|
||||
children: [
|
||||
el("p", { className: "m01-lbsrc__preview", text: ts("Preview") }),
|
||||
el("p", { className: "m01-lbsrc__muted", text: ts("EditHint") }),
|
||||
...keys.map((k) =>
|
||||
renderTable(k, tables[k] as SourceTable | undefined, lb, hits, notes, on),
|
||||
),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
.m01-lbsteps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-16);
|
||||
font-size: var(--text-body);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.m01-lbsteps-banner {
|
||||
margin: 0;
|
||||
padding: var(--spacing-8) var(--spacing-16);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface-raised);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.m01-lbsteps-banner.is-warn {
|
||||
border-left: 4px solid var(--color-warning);
|
||||
}
|
||||
|
||||
.m01-lbsteps-banner p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.m01-lbsteps-why {
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
|
||||
.m01-lbsteps-sec {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface);
|
||||
padding: var(--spacing-8) var(--spacing-16) var(--spacing-16);
|
||||
}
|
||||
|
||||
.m01-lbsteps-title {
|
||||
margin: 0 0 var(--spacing-8);
|
||||
font-size: var(--text-subheading);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.m01-lbsteps-list,
|
||||
.m01-lbsteps-branches {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.m01-lbsteps-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding-top: var(--spacing-8);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m01-lbsteps-row:first-child {
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.m01-lbsteps-sentence {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4) var(--spacing-8);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01-lbsteps-ref,
|
||||
.m01-lbsteps-cond,
|
||||
.m01-lbsteps-chips {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m01-lbsteps-shapes,
|
||||
.m01-lbsteps-shape {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding-left: var(--spacing-16);
|
||||
border-left: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m01-lbsteps-branch {
|
||||
padding-left: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01-lbsteps-order {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.6em;
|
||||
height: 1.6em;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-pills);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.m01-lbsteps-name,
|
||||
.m01-lbsteps-tag {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.m01-lbsteps-chip {
|
||||
display: inline-block;
|
||||
padding: 2px var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-pills);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.m01-lbsteps-note {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-lbsteps-value,
|
||||
.m01-lbsteps-sum {
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-lbsteps-amount,
|
||||
.m01-lbsteps-total {
|
||||
margin-left: auto;
|
||||
font-weight: var(--font-weight-bold);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-lbsteps-amount.is-wait {
|
||||
opacity: 0.5;
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
|
||||
.m01-lbsteps-pick {
|
||||
min-height: 44px;
|
||||
padding: 0 var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-inputs);
|
||||
background: var(--color-surface);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01-lbsteps-pick:focus-visible {
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
}
|
||||
|
||||
.m01-lbsteps .ui-input,
|
||||
.m01-lbsteps .ui-select,
|
||||
.m01-lbsteps .ui-btn {
|
||||
min-height: 44px;
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m01-lbsteps-num {
|
||||
flex: none;
|
||||
width: 6em;
|
||||
}
|
||||
|
||||
.m01-lbsteps-text {
|
||||
flex: none;
|
||||
width: 9em;
|
||||
}
|
||||
|
||||
.m01-lbsteps-select {
|
||||
flex: none;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.m01-lbsteps .ui-input.is-bad {
|
||||
border-color: var(--color-danger);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.m01-lbsteps-add {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
margin-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.m01-lbsteps-amount,
|
||||
.m01-lbsteps-total {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_LB_Steps.ts
|
||||
* 로직 만들기 판 가운데 문장 보기 — ① 입력값 ② 표 조회 ③ 계산 ④ 항목 연결 ⑤ 결과값
|
||||
* 모양은 `docs/raw/M01_쉬운단가/로직판_A_정함.md` · `로직판_A_설계초안.md` 7-1 그대로 · 모두 고르기 · 숫자 칸 · 바뀐 lb 는 on.change 로만 알림
|
||||
* 글 [한, 영] 짝은 아래 TEXT
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
fetchLogics,
|
||||
type CalcAnswer,
|
||||
type CalcLine,
|
||||
type LogicInput,
|
||||
} from "../M01_MasterData_UI_Logic_Api";
|
||||
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
|
||||
import {
|
||||
addCard,
|
||||
addPercent,
|
||||
removeCard,
|
||||
updateCard,
|
||||
withInputs,
|
||||
type CostKind,
|
||||
type EasyCard,
|
||||
type PickResult,
|
||||
} from "../EasyCalc/M01_EasyCalc_Model";
|
||||
import { openEasyPick } from "../EasyCalc/M01_EasyCalc_Pick";
|
||||
import { setCond, type LbLogic, type LbLookup, type LbRef } from "./M01_LB_Blocks";
|
||||
import { calcBody, calcView } from "./M01_LB_Steps_Calc";
|
||||
import {
|
||||
chip,
|
||||
logicNames,
|
||||
lookupValue,
|
||||
note,
|
||||
numBox,
|
||||
parseNum,
|
||||
refText,
|
||||
selectBox,
|
||||
sentence,
|
||||
span,
|
||||
textBox,
|
||||
tx,
|
||||
valueNote,
|
||||
type Ctx,
|
||||
type TEXT,
|
||||
} from "./M01_LB_Steps_Cell";
|
||||
import "./M01_LB_Steps.css";
|
||||
|
||||
type Kind = "person" | "material" | "machine" | "logic" | "percent" | "locked";
|
||||
|
||||
let picking = false;
|
||||
let namesLoading = false;
|
||||
|
||||
/* ------------------------------ ① 입력값 ------------------------------ */
|
||||
|
||||
function inputView(ctx: Ctx, input: LogicInput, at: number): HTMLElement {
|
||||
const put = (patch: Partial<LogicInput>): void =>
|
||||
ctx.commit(() => {
|
||||
const inputs = ctx.lb.inputs.map((x, i) => (i === at ? { ...x, ...patch } : x));
|
||||
return { ...ctx.lb, inputs, doc: { ...ctx.lb.doc, inputs } };
|
||||
});
|
||||
const parts: (HTMLElement | string)[] = [span("m01-lbsteps-name", input.이름)];
|
||||
if (input.고르기) {
|
||||
parts.push(
|
||||
note(tx("PickList")),
|
||||
el("span", {
|
||||
className: "m01-lbsteps-chips",
|
||||
children: input.고르기.map((v) => chip(String(v))),
|
||||
}),
|
||||
);
|
||||
} else if (input.범위) {
|
||||
const [lo, hi] = input.범위;
|
||||
parts.push(
|
||||
note(tx("Range")),
|
||||
numBox(ctx, lo, (n) => put({ 범위: [n, hi] })),
|
||||
"~",
|
||||
numBox(ctx, hi, (n) => put({ 범위: [lo, n] })),
|
||||
);
|
||||
} else parts.push(note(tx("Number")));
|
||||
parts.push(
|
||||
note(tx("Unit")),
|
||||
textBox(ctx, input.단위 ?? "", (v) => put({ 단위: v })),
|
||||
);
|
||||
return el("li", { className: "m01-lbsteps-row", children: [sentence(...parts)] });
|
||||
}
|
||||
|
||||
/* ------------------------------ ② 표 조회 ------------------------------ */
|
||||
|
||||
function condView(ctx: Ctx, lk: LbLookup, cond: LbLookup["conds"][number]): HTMLElement {
|
||||
const from = cond.from;
|
||||
const set = (next: LbRef | null): void =>
|
||||
ctx.commit(() => setCond(ctx.lb, lk.id, cond.col, next));
|
||||
const parts: (HTMLElement | string)[] = [chip(cond.col), tx("LkIs")];
|
||||
if (from.k === "input" || from.k === "fixed") {
|
||||
const options = [
|
||||
...ctx.lb.inputs.map((i) => ({ value: `i:${i.이름}`, text: `${tx("SrcInput")}${i.이름}` })),
|
||||
{ value: "f", text: tx("SrcFixed") },
|
||||
{ value: "n", text: tx("SrcAny") },
|
||||
];
|
||||
const current = from.k === "input" ? `i:${from.name}` : "f";
|
||||
parts.push(
|
||||
selectBox(
|
||||
ctx,
|
||||
options,
|
||||
current,
|
||||
(v) =>
|
||||
set(
|
||||
v === "n"
|
||||
? null
|
||||
: v === "f"
|
||||
? { k: "fixed", value: "" }
|
||||
: { k: "input", name: v.slice(2) },
|
||||
),
|
||||
cond.col,
|
||||
),
|
||||
);
|
||||
if (from.k === "fixed") {
|
||||
parts.push(
|
||||
textBox(ctx, String(from.value), (v) => set({ k: "fixed", value: parseNum(v) ?? v })),
|
||||
);
|
||||
}
|
||||
} else parts.push(chip(refText(ctx, from)));
|
||||
return el("span", { className: "m01-lbsteps-cond", children: parts });
|
||||
}
|
||||
|
||||
function lookupView(ctx: Ctx, lk: LbLookup, n: number): HTMLElement {
|
||||
const name = ctx.on.tableName?.(lk.table) ?? lk.table;
|
||||
return el("li", {
|
||||
className: "m01-lbsteps-row",
|
||||
children: [
|
||||
sentence(
|
||||
span("m01-lbsteps-name", `${tx("LkNum")}${n}`),
|
||||
chip(name),
|
||||
tx("LkFrom"),
|
||||
...lk.conds.map((c) => condView(ctx, lk, c)),
|
||||
tx("LkRow"),
|
||||
chip(lk.col),
|
||||
valueNote(lookupValue(ctx, lk)),
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------ ④ 항목 연결 ------------------------------ */
|
||||
|
||||
const BASES: (CostKind | "사람줄합")[] = ["노무비", "재료비", "경비", "사람줄합"];
|
||||
|
||||
function lineMap(ctx: Ctx): Map<string, CalcLine> {
|
||||
const out = new Map<string, CalcLine>();
|
||||
const ans = ctx.ans;
|
||||
if (!ans || !ans.ok || !ans.lines) return out;
|
||||
const cards = ctx.lb.doc.cards;
|
||||
const part = (c: EasyCard): string =>
|
||||
c.kind === "percent" ? "덧줄" : c.kind === "locked" ? (c.source?.part ?? "덧줄") : "호표";
|
||||
const ordered = [
|
||||
...cards.filter((c) => part(c) === "호표"),
|
||||
...cards.filter((c) => part(c) === "덧줄"),
|
||||
];
|
||||
if (ordered.length === ans.lines.length) ordered.forEach((c, i) => out.set(c.id, ans.lines![i]));
|
||||
return out;
|
||||
}
|
||||
|
||||
const patchItem = (ctx: Ctx, id: string, patch: Partial<EasyCard>): void =>
|
||||
ctx.commit(() => ({ ...ctx.lb, doc: updateCard(ctx.lb.doc, id, patch) }));
|
||||
|
||||
async function pickItem(ctx: Ctx, kind: PickResult["kind"], card?: EasyCard): Promise<void> {
|
||||
if (picking) return;
|
||||
picking = true;
|
||||
try {
|
||||
const pick = await openEasyPick(kind);
|
||||
if (!pick) return;
|
||||
ctx.commit(() => {
|
||||
if (!card) return { ...ctx.lb, doc: addCard(ctx.lb.doc, pick) };
|
||||
const next = updateCard(ctx.lb.doc, card.id, {
|
||||
name: pick.name,
|
||||
spec: pick.spec,
|
||||
unit: pick.unit,
|
||||
element: pick.element,
|
||||
});
|
||||
return { ...ctx.lb, doc: withInputs(next, pick.needInputs ?? []) };
|
||||
});
|
||||
} finally {
|
||||
picking = false;
|
||||
}
|
||||
}
|
||||
|
||||
function itemView(ctx: Ctx, card: EasyCard): HTMLElement {
|
||||
const kind = card.kind as Kind;
|
||||
const percent = kind === "percent";
|
||||
const part = card.source?.part === "호표" ? "수량" : "비율";
|
||||
const calc = card.source
|
||||
? ctx.lb.calcs.find((c) => c.at.part === part && c.at.index === card.source?.index)
|
||||
: undefined;
|
||||
const line = ctx.lines.get(card.id);
|
||||
const qty: HTMLElement = calc
|
||||
? el("span", {
|
||||
className: "m01-lbsteps-ref",
|
||||
children: [chip(`${tx("Calc")}${ctx.lb.calcs.indexOf(calc) + 1}`), valueNote(line?.수량)],
|
||||
})
|
||||
: card.qtyFormula === null
|
||||
? numBox(ctx, card.qty, (n) => patchItem(ctx, card.id, { qty: n }))
|
||||
: chip(tx("Hard"));
|
||||
const name =
|
||||
ctx.ro || kind === "locked" || percent
|
||||
? span("m01-lbsteps-name", [card.name, card.spec].filter(Boolean).join(" "))
|
||||
: (() => {
|
||||
const b = el("button", {
|
||||
className: "m01-lbsteps-pick",
|
||||
text: `${[card.name, card.spec].filter(Boolean).join(" ")} ▾`,
|
||||
attrs: { type: "button", "aria-label": tx("Repick") },
|
||||
});
|
||||
b.addEventListener("click", () => void pickItem(ctx, kind as PickResult["kind"], card));
|
||||
return b;
|
||||
})();
|
||||
const head: (HTMLElement | string)[] = [span("m01-lbsteps-tag", `【${tx(`Tag_${kind}`)}】`)];
|
||||
if (percent) {
|
||||
const options = BASES.map((v) => ({ value: v, text: tx(`Base_${v}`) }));
|
||||
head.push(
|
||||
selectBox(
|
||||
ctx,
|
||||
options,
|
||||
card.percentBase ?? "노무비",
|
||||
(v) => patchItem(ctx, card.id, { percentBase: v as EasyCard["percentBase"] }),
|
||||
tx("S4"),
|
||||
),
|
||||
tx("Pre_percent"),
|
||||
qty,
|
||||
tx("Post_percent"),
|
||||
name,
|
||||
);
|
||||
} else {
|
||||
head.push(
|
||||
name,
|
||||
kind === "person" ? tx("Pre_person") : tx("Pre_pick"),
|
||||
qty,
|
||||
span("m01-lbsteps-unit", ` ${card.unit}`),
|
||||
);
|
||||
}
|
||||
head.push(
|
||||
line
|
||||
? span("m01-lbsteps-amount", `= ${formatMoney(line.금액)} ${tx("Won")}`)
|
||||
: span("m01-lbsteps-amount is-wait", `= ${tx("Pending")}`),
|
||||
);
|
||||
if (!ctx.ro) {
|
||||
head.push(
|
||||
createButton({
|
||||
label: tx("Remove"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => ctx.commit(() => ({ ...ctx.lb, doc: removeCard(ctx.lb.doc, card.id) })),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return el("li", { className: "m01-lbsteps-row", children: [sentence(...head)] });
|
||||
}
|
||||
|
||||
function addBar(ctx: Ctx): HTMLElement {
|
||||
const add = (key: keyof typeof TEXT, run: () => void): HTMLButtonElement =>
|
||||
createButton({ label: tx(key), variant: "ghost", size: "sm", onClick: run });
|
||||
const pick = (kind: PickResult["kind"]) => () => void pickItem(ctx, kind);
|
||||
return el("div", {
|
||||
className: "m01-lbsteps-add",
|
||||
children: [
|
||||
add("Add_person", pick("person")),
|
||||
add("Add_material", pick("material")),
|
||||
add("Add_machine", pick("machine")),
|
||||
add("Add_logic", pick("logic")),
|
||||
add("Add_percent", () =>
|
||||
ctx.commit(() => ({ ...ctx.lb, doc: addPercent(ctx.lb.doc, "", "노무비", null, "경비") })),
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------ ⑤ 결과값 ------------------------------ */
|
||||
|
||||
function resultView(ctx: Ctx): HTMLElement {
|
||||
const result = ctx.lb.result;
|
||||
const ans = ctx.ans;
|
||||
if (result.kind === "값") {
|
||||
const value = ans?.ok ? ans.result : undefined;
|
||||
return el("div", {
|
||||
children: [
|
||||
sentence(note(tx("ResultValue")), chip(result.unit), valueNote(value)),
|
||||
calcBody(ctx, result.calc),
|
||||
],
|
||||
});
|
||||
}
|
||||
const sums = ans?.ok ? ans.sums : undefined;
|
||||
const parts: (HTMLElement | string)[] = [note(tx("ResultMoney")), chip(result.unit)];
|
||||
if (sums) {
|
||||
for (const k of ["노무비", "재료비", "경비"]) {
|
||||
if (k in sums)
|
||||
parts.push(
|
||||
span("m01-lbsteps-sum", `${tx(`Base_${k as CostKind}`)} ${formatMoney(sums[k])}`),
|
||||
);
|
||||
}
|
||||
parts.push(
|
||||
span("m01-lbsteps-total", `${tx("Total")} ${formatMoney(sums["계"] ?? 0)} ${tx("Won")}`),
|
||||
);
|
||||
} else parts.push(span("m01-lbsteps-amount is-wait", `= ${tx("Pending")}`));
|
||||
return sentence(...parts);
|
||||
}
|
||||
|
||||
/* ------------------------------ 전체 ------------------------------ */
|
||||
|
||||
function section(title: string, items: HTMLElement[], extra?: HTMLElement): HTMLElement {
|
||||
const body = items.length
|
||||
? el("ol", { className: "m01-lbsteps-list", children: items })
|
||||
: note(tx("None"));
|
||||
return el("section", {
|
||||
className: "m01-lbsteps-sec",
|
||||
children: [
|
||||
el("h3", { className: "m01-lbsteps-title", text: title }),
|
||||
body,
|
||||
...(extra ? [extra] : []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 가운데 문장 보기 그리기 — ans 는 칸마다 곁에 보이는 예시값 · null 이면 「계산 중」 */
|
||||
export function renderSteps(
|
||||
host: HTMLElement,
|
||||
lb: LbLogic,
|
||||
ans: CalcAnswer | null,
|
||||
on: { change(lb: LbLogic): void; tableName?(table: string): string | null },
|
||||
): void {
|
||||
const ctx: Ctx = {
|
||||
lb,
|
||||
ans,
|
||||
ro: lb.doc.isBase,
|
||||
on,
|
||||
lines: new Map(),
|
||||
commit: (make) => {
|
||||
try {
|
||||
on.change(make());
|
||||
} catch {
|
||||
showToast(tx("Cannot"), "error");
|
||||
}
|
||||
},
|
||||
};
|
||||
ctx.lines = lineMap(ctx);
|
||||
host.classList.add("m01-lbsteps");
|
||||
const top: HTMLElement[] = [];
|
||||
if (ctx.ro) top.push(el("p", { className: "m01-lbsteps-banner", text: tx("BaseNote") }));
|
||||
if (lb.blocked.length) {
|
||||
top.push(
|
||||
el("div", {
|
||||
className: "m01-lbsteps-banner is-warn",
|
||||
children: [
|
||||
el("p", { text: `⚠ ${tx("Blocked")}` }),
|
||||
...lb.blocked.map((b) => el("p", { className: "m01-lbsteps-why", text: b })),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
host.replaceChildren(
|
||||
...top,
|
||||
section(
|
||||
tx("S1"),
|
||||
lb.inputs.map((x, i) => inputView(ctx, x, i)),
|
||||
),
|
||||
section(
|
||||
tx("S2"),
|
||||
lb.lookups.map((l, i) => lookupView(ctx, l, i + 1)),
|
||||
),
|
||||
section(
|
||||
tx("S3"),
|
||||
lb.calcs.map((c, i) => calcView(ctx, c, i + 1)),
|
||||
),
|
||||
section(
|
||||
tx("S4"),
|
||||
lb.doc.cards.map((c) => itemView(ctx, c)),
|
||||
ctx.ro ? undefined : addBar(ctx),
|
||||
),
|
||||
section(tx("S5"), [el("li", { className: "m01-lbsteps-row", children: [resultView(ctx)] })]),
|
||||
);
|
||||
// 다른 일위대가 이름 — 목록을 한 번 받은 뒤 다시 그림
|
||||
if (
|
||||
!logicNames.size &&
|
||||
!namesLoading &&
|
||||
JSON.stringify([lb.calcs, lb.lookups]).includes('"k":"logic"')
|
||||
) {
|
||||
namesLoading = true;
|
||||
fetchLogics()
|
||||
.then((all) => {
|
||||
for (const l of all) logicNames.set(l.키, l.이름);
|
||||
renderSteps(host, lb, ans, on);
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => (namesLoading = false));
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_LB_Steps_Calc.ts
|
||||
* 로직 만들기 판 문장 보기 ③ 계산 — 값 · 곱나눔 · 할증 · 조건 목록 · 최소/최대 · 올림
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import type { CondOp, CondRead, CondShape, CondSlot } from "../EasyCalc/M01_EasyCalc_Cond";
|
||||
import { setCalc, type LbCalc } from "./M01_LB_Blocks";
|
||||
import {
|
||||
chip,
|
||||
middleOf,
|
||||
note,
|
||||
numBox,
|
||||
refBox,
|
||||
selectBox,
|
||||
sentence,
|
||||
span,
|
||||
textBox,
|
||||
tx,
|
||||
valueNote,
|
||||
type Ctx,
|
||||
type TEXT,
|
||||
} from "./M01_LB_Steps_Cell";
|
||||
|
||||
const OPS: [CondOp, keyof typeof TEXT][] = [
|
||||
["==", "OpEq"],
|
||||
["!=", "OpNe"],
|
||||
[">", "OpGt"],
|
||||
[">=", "OpGe"],
|
||||
["<", "OpLt"],
|
||||
["<=", "OpLe"],
|
||||
];
|
||||
|
||||
function slotBox(ctx: Ctx, slot: CondSlot, put: (s: CondSlot) => void): HTMLElement {
|
||||
if (slot.kind === "num") return numBox(ctx, slot.value, (n) => put({ kind: "num", value: n }));
|
||||
if (slot.kind === "text") return textBox(ctx, slot.value, (v) => put({ kind: "text", value: v }));
|
||||
if (slot.kind === "input") {
|
||||
const options = ctx.lb.inputs.map((i) => ({ value: i.이름, text: i.이름 }));
|
||||
return selectBox(ctx, options, slot.name, (v) => put({ kind: "input", name: v }), tx("S1"));
|
||||
}
|
||||
return chip(tx(slot.kind === "table" ? "SlotTable" : "SlotCalc"));
|
||||
}
|
||||
|
||||
function shapeView(ctx: Ctx, shape: CondShape, put: (s: CondShape) => void): HTMLElement {
|
||||
if (shape.type === "pick") {
|
||||
const items: (HTMLElement | string)[] = [];
|
||||
shape.items.forEach((s, i) => {
|
||||
if (i) items.push(tx("And"));
|
||||
items.push(
|
||||
slotBox(ctx, s, (v) =>
|
||||
put({ ...shape, items: shape.items.map((x, j) => (j === i ? v : x)) }),
|
||||
),
|
||||
);
|
||||
});
|
||||
const fn = [
|
||||
{ value: "최소", text: tx("Small") },
|
||||
{ value: "최대", text: tx("Big") },
|
||||
];
|
||||
return sentence(
|
||||
...items,
|
||||
selectBox(ctx, fn, shape.fn, (v) => put({ ...shape, fn: v as "최소" | "최대" }), tx("S3")),
|
||||
);
|
||||
}
|
||||
if (shape.type === "round") {
|
||||
const fns = [
|
||||
{ value: "올림", text: tx("Up") },
|
||||
{ value: "버림", text: tx("Down") },
|
||||
{ value: "반올림", text: tx("Half") },
|
||||
];
|
||||
return sentence(
|
||||
slotBox(ctx, shape.value, (v) => put({ ...shape, value: v })),
|
||||
tx("RoundA"),
|
||||
numBox(ctx, shape.places, (n) => put({ ...shape, places: n, hadPlaces: true })),
|
||||
tx("RoundB"),
|
||||
selectBox(
|
||||
ctx,
|
||||
fns,
|
||||
shape.fn,
|
||||
(v) => put({ ...shape, fn: v as "올림" | "버림" | "반올림" }),
|
||||
tx("S3"),
|
||||
),
|
||||
);
|
||||
}
|
||||
const ops = OPS.map(([value, key]) => ({ value, text: tx(key) }));
|
||||
const rows = shape.branches.map((b, i) => {
|
||||
const edit = (patch: Partial<typeof b>): void =>
|
||||
put({ ...shape, branches: shape.branches.map((x, j) => (j === i ? { ...x, ...patch } : x)) });
|
||||
return el("li", {
|
||||
className: "m01-lbsteps-branch",
|
||||
children: [
|
||||
sentence(
|
||||
span("m01-lbsteps-order", String(i + 1)),
|
||||
slotBox(ctx, b.test.left, (v) => edit({ test: { ...b.test, left: v } })),
|
||||
tx("Subject"),
|
||||
slotBox(ctx, b.test.right, (v) => edit({ test: { ...b.test, right: v } })),
|
||||
selectBox(
|
||||
ctx,
|
||||
ops,
|
||||
b.test.op,
|
||||
(v) => edit({ test: { ...b.test, op: v as CondOp } }),
|
||||
tx("S3"),
|
||||
),
|
||||
"→",
|
||||
slotBox(ctx, b.value, (v) => edit({ value: v })),
|
||||
),
|
||||
],
|
||||
});
|
||||
});
|
||||
const last = el("li", {
|
||||
className: "m01-lbsteps-branch",
|
||||
children: [
|
||||
sentence(
|
||||
tx("Otherwise"),
|
||||
slotBox(ctx, shape.otherwise, (v) => put({ ...shape, otherwise: v })),
|
||||
),
|
||||
],
|
||||
});
|
||||
return el("div", {
|
||||
className: "m01-lbsteps-shape",
|
||||
children: [
|
||||
note(tx("Cond1")),
|
||||
el("ol", { className: "m01-lbsteps-branches", children: [...rows, last] }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function placeText(ctx: Ctx, calc: LbCalc): string {
|
||||
if (calc.name !== null) return calc.name;
|
||||
const { part, index } = calc.at;
|
||||
if (part === "결과") return tx("PlaceResult");
|
||||
const card = ctx.lb.doc.cards.find(
|
||||
(c) => c.source?.index === index && c.source.part === (part === "수량" ? "호표" : "덧줄"),
|
||||
);
|
||||
return `${card?.name ?? ""} ${tx(part === "수량" ? "PlaceQty" : "PlaceRate")}`.trim();
|
||||
}
|
||||
|
||||
export function calcBody(ctx: Ctx, calc: LbCalc): HTMLElement {
|
||||
const put = (patch: Partial<LbCalc>): void => ctx.commit(() => setCalc(ctx.lb, calc, patch));
|
||||
const refAt = (i: number): HTMLElement =>
|
||||
refBox(ctx, calc.parts[i], calc, (r) =>
|
||||
put({ parts: calc.parts.map((p, j) => (j === i ? r : p)) }),
|
||||
);
|
||||
if (calc.cond && calc.cond.blocks.length) {
|
||||
const read: CondRead = calc.cond;
|
||||
return el("div", {
|
||||
className: "m01-lbsteps-shapes",
|
||||
children: read.blocks.map((b, bi) =>
|
||||
shapeView(ctx, b.shape, (shape) =>
|
||||
put({
|
||||
cond: { ...read, blocks: read.blocks.map((x, j) => (j === bi ? { ...x, shape } : x)) },
|
||||
}),
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
if (calc.kind === "값" && calc.parts.length) return sentence(refAt(0));
|
||||
if (calc.kind === "할증" && calc.parts.length >= 2) {
|
||||
return sentence(refAt(0), tx("Mark1"), refAt(1), tx("Mark2"));
|
||||
}
|
||||
if ((calc.kind === "곱나눔" || calc.kind === "더하기") && calc.parts.length) {
|
||||
const ops = calc.kind === "곱나눔" ? ["*", "/"] : ["+", "-"];
|
||||
const glyph = (o: string): string =>
|
||||
tx(o === "*" ? "Mul" : o === "/" ? "Div" : o === "+" ? "Plus" : "Minus");
|
||||
const out: (HTMLElement | string)[] = [refAt(0)];
|
||||
calc.parts.slice(1).forEach((_, i) => {
|
||||
const op = calc.ops?.[i] ?? ops[0];
|
||||
const choices = ops.map((o) => ({ value: o, text: glyph(o) }));
|
||||
out.push(
|
||||
selectBox(
|
||||
ctx,
|
||||
choices,
|
||||
op,
|
||||
(v) =>
|
||||
put({
|
||||
ops: calc.parts
|
||||
.slice(1)
|
||||
.map((_, j) => (j === i ? v : (calc.ops?.[j] ?? ops[0])) as "*" | "/" | "+" | "-"),
|
||||
}),
|
||||
tx("S3"),
|
||||
),
|
||||
refAt(i + 1),
|
||||
);
|
||||
});
|
||||
return sentence(...out);
|
||||
}
|
||||
return sentence(note(tx("Steps")));
|
||||
}
|
||||
|
||||
export function calcView(ctx: Ctx, calc: LbCalc, n: number): HTMLElement {
|
||||
return el("li", {
|
||||
className: "m01-lbsteps-row",
|
||||
children: [
|
||||
sentence(
|
||||
span("m01-lbsteps-name", `${tx("Calc")}${n}`),
|
||||
chip(placeText(ctx, calc)),
|
||||
valueNote(middleOf(ctx, calc.name)),
|
||||
),
|
||||
calcBody(ctx, calc),
|
||||
],
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user