refactor(mpa): 탭 디렉토리를 MPA 컴포넌트 구조로 재편 #178

병합
dnlee feature/mpa-develop 에서 develop 로 6 commits 를 머지했습니다 2026-04-16 18:14:48 +09:00
22개의 변경된 파일922개의 추가작업 그리고 696개의 파일을 삭제
Showing only changes of commit 7fa3fa6a2e - Show all commits

파일 보기

@ -1945,9 +1945,9 @@
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
"integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
"cpu": [
"arm"
],
@ -1959,9 +1959,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
"integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
"cpu": [
"arm64"
],
@ -1973,9 +1973,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
"integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
"cpu": [
"arm64"
],
@ -1987,9 +1987,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
"integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
"cpu": [
"x64"
],
@ -2001,9 +2001,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
"integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
"cpu": [
"arm64"
],
@ -2015,9 +2015,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
"integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
"cpu": [
"x64"
],
@ -2029,9 +2029,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
"integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
"cpu": [
"arm"
],
@ -2043,9 +2043,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
"integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
"cpu": [
"arm"
],
@ -2057,9 +2057,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
"integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
"cpu": [
"arm64"
],
@ -2071,9 +2071,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
"integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
"cpu": [
"arm64"
],
@ -2085,9 +2085,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
"integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
"cpu": [
"loong64"
],
@ -2099,9 +2099,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
"integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
"cpu": [
"loong64"
],
@ -2113,9 +2113,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
"integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
"cpu": [
"ppc64"
],
@ -2127,9 +2127,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
"integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
"cpu": [
"ppc64"
],
@ -2141,9 +2141,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
"integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
"cpu": [
"riscv64"
],
@ -2155,9 +2155,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
"integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
"cpu": [
"riscv64"
],
@ -2169,9 +2169,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
"integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
"cpu": [
"s390x"
],
@ -2183,9 +2183,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
"integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
"cpu": [
"x64"
],
@ -2197,9 +2197,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
"integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
"cpu": [
"x64"
],
@ -2211,9 +2211,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
"integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
"cpu": [
"x64"
],
@ -2225,9 +2225,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
"integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
"cpu": [
"arm64"
],
@ -2239,9 +2239,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
"integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
"cpu": [
"arm64"
],
@ -2253,9 +2253,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
"integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
"cpu": [
"ia32"
],
@ -2267,9 +2267,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
"integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
"cpu": [
"x64"
],
@ -2281,9 +2281,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
"integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
"cpu": [
"x64"
],
@ -2711,9 +2711,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2721,13 +2721,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@ -2873,9 +2873,9 @@
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2927,9 +2927,9 @@
}
},
"node_modules/anymatch/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@ -3015,14 +3015,14 @@
}
},
"node_modules/axios": {
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
"proxy-from-env": "^2.1.0"
}
},
"node_modules/balanced-match": {
@ -3077,9 +3077,9 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -3946,9 +3946,9 @@
"license": "MIT"
},
"node_modules/fast-xml-parser": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.4.tgz",
"integrity": "sha512-jE8ugADnYOBsu1uaoayVl1tVKAMNOXyjwvv2U6udEA2ORBhDooJDWoGxTkhd4Qn4yh59JVVt/pKXtjPwx9OguQ==",
"version": "4.5.6",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.6.tgz",
"integrity": "sha512-Yd4vkROfJf8AuJrDIVMVmYfULKmIJszVsMv7Vo71aocsKgFxpdlpSHXSaInvyYfgw2PRuObQSW2GFpVMUjxu9A==",
"funding": [
{
"type": "github",
@ -4055,16 +4055,16 @@
}
},
"node_modules/flatted": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
@ -4904,9 +4904,9 @@
}
},
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@ -4938,9 +4938,9 @@
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@ -5169,9 +5169,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@ -5409,10 +5409,13 @@
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/punycode": {
"version": "2.3.1",
@ -5569,9 +5572,9 @@
}
},
"node_modules/readdirp/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@ -5633,9 +5636,9 @@
}
},
"node_modules/rollup": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -5649,31 +5652,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.57.1",
"@rollup/rollup-android-arm64": "4.57.1",
"@rollup/rollup-darwin-arm64": "4.57.1",
"@rollup/rollup-darwin-x64": "4.57.1",
"@rollup/rollup-freebsd-arm64": "4.57.1",
"@rollup/rollup-freebsd-x64": "4.57.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
"@rollup/rollup-linux-arm64-musl": "4.57.1",
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
"@rollup/rollup-linux-loong64-musl": "4.57.1",
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
"@rollup/rollup-linux-x64-gnu": "4.57.1",
"@rollup/rollup-linux-x64-musl": "4.57.1",
"@rollup/rollup-openbsd-x64": "4.57.1",
"@rollup/rollup-openharmony-arm64": "4.57.1",
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
"@rollup/rollup-win32-x64-gnu": "4.57.1",
"@rollup/rollup-win32-x64-msvc": "4.57.1",
"@rollup/rollup-android-arm-eabi": "4.60.1",
"@rollup/rollup-android-arm64": "4.60.1",
"@rollup/rollup-darwin-arm64": "4.60.1",
"@rollup/rollup-darwin-x64": "4.60.1",
"@rollup/rollup-freebsd-arm64": "4.60.1",
"@rollup/rollup-freebsd-x64": "4.60.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
"@rollup/rollup-linux-arm-musleabihf": "4.60.1",
"@rollup/rollup-linux-arm64-gnu": "4.60.1",
"@rollup/rollup-linux-arm64-musl": "4.60.1",
"@rollup/rollup-linux-loong64-gnu": "4.60.1",
"@rollup/rollup-linux-loong64-musl": "4.60.1",
"@rollup/rollup-linux-ppc64-gnu": "4.60.1",
"@rollup/rollup-linux-ppc64-musl": "4.60.1",
"@rollup/rollup-linux-riscv64-gnu": "4.60.1",
"@rollup/rollup-linux-riscv64-musl": "4.60.1",
"@rollup/rollup-linux-s390x-gnu": "4.60.1",
"@rollup/rollup-linux-x64-gnu": "4.60.1",
"@rollup/rollup-linux-x64-musl": "4.60.1",
"@rollup/rollup-openbsd-x64": "4.60.1",
"@rollup/rollup-openharmony-arm64": "4.60.1",
"@rollup/rollup-win32-arm64-msvc": "4.60.1",
"@rollup/rollup-win32-ia32-msvc": "4.60.1",
"@rollup/rollup-win32-x64-gnu": "4.60.1",
"@rollup/rollup-win32-x64-msvc": "4.60.1",
"fsevents": "~2.3.2"
}
},
@ -5801,9 +5804,9 @@
}
},
"node_modules/socket.io-parser": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
"integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
@ -6285,9 +6288,9 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
"dependencies": {

파일 보기

@ -110,12 +110,20 @@ interface DispersionZone {
angle: number;
}
interface DispersionContour {
level: string;
threshold: number;
color: string;
segments: Array<[[number, number], [number, number]]>;
}
interface DispersionResult {
zones: DispersionZone[];
timestamp: string;
windDirection: number;
substance: string;
concentration: Record<string, string>;
contours?: DispersionContour[];
}
interface MapViewProps {
@ -800,9 +808,9 @@ export function MapView({
if (dispersionHeatmap && dispersionHeatmap.length > 0) {
const maxConc = Math.max(...dispersionHeatmap.map((p) => p.concentration));
const minConc = Math.min(
...dispersionHeatmap.filter((p) => p.concentration > 0.01).map((p) => p.concentration),
...dispersionHeatmap.filter((p) => p.concentration > 0.001).map((p) => p.concentration),
);
const filtered = dispersionHeatmap.filter((p) => p.concentration > 0.01);
const filtered = dispersionHeatmap.filter((p) => p.concentration > 0.001);
console.log(
'[MapView] HNS 히트맵:',
dispersionHeatmap.length,
@ -870,7 +878,7 @@ export function MapView({
ctx.fillStyle = `rgba(${r},${g},${b},${a.toFixed(2)})`;
ctx.beginPath();
ctx.arc(px, py, 6, 0, Math.PI * 2);
ctx.arc(px, py, 12, 0, Math.PI * 2);
ctx.fill();
}
@ -890,11 +898,15 @@ export function MapView({
// --- HNS 대기확산 구역 (ScatterplotLayer, meters 단위) ---
if (dispersionResult && incidentCoord) {
// contour가 있으면 동심원 fill은 희미하게(contour가 실제 경계 표시), 없으면 진하게
const hasContours = !!(dispersionResult.contours && dispersionResult.contours.length > 0);
const zoneFillAlpha = hasContours ? 40 : 100;
const zoneLineAlpha = hasContours ? 80 : 180;
const zones = dispersionResult.zones.map((zone, idx) => ({
position: [incidentCoord.lon, incidentCoord.lat] as [number, number],
radius: zone.radius,
fillColor: hexToRgba(zone.color, 100),
lineColor: hexToRgba(zone.color, 180),
fillColor: hexToRgba(zone.color, zoneFillAlpha),
lineColor: hexToRgba(zone.color, zoneLineAlpha),
level: zone.level,
idx,
}));
@ -974,6 +986,27 @@ export function MapView({
},
}),
);
// --- HNS AEGL 등농도선 (PathLayer) ---
if (dispersionResult.contours) {
dispersionResult.contours.forEach((contour, cIdx) => {
if (contour.segments.length === 0) return;
const color = hexToRgba(contour.color, 230);
result.push(
new PathLayer({
id: `hns-contour-${cIdx}-${contour.level}`,
data: contour.segments,
getPath: (d: [[number, number], [number, number]]) => d,
getColor: color,
getWidth: 3,
widthUnits: 'pixels' as const,
capRounded: true,
jointRounded: true,
pickable: false,
}) as unknown as DeckLayer,
);
});
}
}
// --- 역추적 리플레이 ---

파일 보기

@ -325,7 +325,7 @@
gap: 4px;
padding: 5px 4px;
border-radius: 5px;
font-size: 0.6875rem;
font-size: 0.75rem;
font-weight: 600;
font-family: var(--font-korean);
cursor: pointer;
@ -360,7 +360,7 @@
width: 100%;
padding: 10px;
border-radius: 6px;
font-size: 12px;
font-size: 0.8125rem;
font-weight: 700;
cursor: pointer;
border: none;
@ -386,7 +386,7 @@
border: 1px solid rgba(6, 182, 212, 0.2);
border-radius: 6px;
color: var(--color-accent);
font-size: 0.6875rem;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
@ -411,7 +411,7 @@
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
font-size: 12px;
font-size: 0.8125rem;
font-weight: 600;
transition: all 0.15s;
background: rgba(15, 21, 36, 0.75);
@ -450,7 +450,7 @@
border-radius: 6px;
padding: 5px 14px;
font-family: var(--font-mono);
font-size: 0.6875rem;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.7);
font-weight: 400;
z-index: 20;
@ -491,7 +491,7 @@
}
.wii-value {
font-size: 0.6875rem;
font-size: 0.75rem;
font-weight: 400;
color: #ffffff;
font-family: var(--font-mono);
@ -538,7 +538,7 @@
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 14px;
font-size: 1rem;
transition: 0.2s;
}
@ -621,7 +621,7 @@
position: absolute;
top: -18px;
transform: translateX(-50%);
font-size: 12px;
font-size: 0.8125rem;
cursor: pointer;
filter: drop-shadow(0 0 4px rgba(245, 158, 11, 0.5));
}
@ -672,7 +672,7 @@
}
.tlct {
font-size: 14px;
font-size: 1rem;
font-weight: 600;
color: var(--color-accent);
font-family: var(--font-mono);
@ -841,7 +841,7 @@
}
.layer-icon {
font-size: 14px;
font-size: 1rem;
flex-shrink: 0;
}
@ -1115,7 +1115,7 @@
cursor: pointer;
border-radius: var(--radius-sm);
transition: background 0.15s;
font-size: 12px;
font-size: 0.8125rem;
font-weight: 700;
color: var(--fg-default);
font-family: var(--font-korean);
@ -1345,7 +1345,7 @@
}
.lyr-ccustom label {
font-size: 0.6875rem;
font-size: 0.75rem;
color: var(--fg-disabled);
font-family: var(--font-korean);
}
@ -1369,7 +1369,7 @@
border-radius: var(--radius-sm);
}
.lyr-style-label {
font-size: 0.6875rem;
font-size: 0.75rem;
font-weight: 700;
color: var(--fg-disabled);
font-family: var(--font-korean);
@ -1411,7 +1411,7 @@
cursor: pointer;
}
.lyr-style-val {
font-size: 0.6875rem;
font-size: 0.75rem;
color: var(--fg-disabled);
font-family: var(--font-mono);
min-width: 28px;

파일 보기

@ -364,13 +364,13 @@ function fetchTasks(): Promise<DeidentifyTask[]> {
function getStatusBadgeClass(status: TaskStatus): string {
switch (status) {
case '완료':
return 'text-emerald-400 bg-emerald-500/10';
return 'text-color-success bg-[rgba(34,197,94,0.1)]';
case '진행중':
return 'text-cyan-400 bg-cyan-500/10';
return 'text-color-accent bg-[rgba(6,182,212,0.1)]';
case '대기':
return 'text-yellow-400 bg-yellow-500/10';
return 'text-color-caution bg-[rgba(234,179,8,0.1)]';
case '오류':
return 'text-red-400 bg-red-500/10';
return 'text-color-danger bg-[rgba(239,68,68,0.1)]';
}
}
@ -378,7 +378,7 @@ function getStatusBadgeClass(status: TaskStatus): string {
function ProgressBar({ value }: { value: number }) {
const colorClass =
value === 100 ? 'bg-emerald-500' : value > 0 ? 'bg-cyan-500' : 'bg-bg-elevated';
value === 100 ? 'bg-color-success' : value > 0 ? 'bg-color-accent' : 'bg-bg-elevated';
return (
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-bg-elevated rounded-full overflow-hidden">
@ -405,13 +405,13 @@ interface TaskTableProps {
function TaskTable({ rows, loading, onAction }: TaskTableProps) {
return (
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{TABLE_HEADERS.map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
>
{h}
</th>
@ -421,7 +421,7 @@ function TaskTable({ rows, loading, onAction }: TaskTableProps) {
<tbody>
{loading && rows.length === 0
? Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-stroke-1 animate-pulse">
<tr key={i} className="border-b border-stroke animate-pulse">
{TABLE_HEADERS.map((_, j) => (
<td key={j} className="px-3 py-2">
<div className="h-3 bg-bg-elevated rounded w-14" />
@ -430,7 +430,7 @@ function TaskTable({ rows, loading, onAction }: TaskTableProps) {
</tr>
))
: rows.map((row) => (
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
<tr key={row.id} className="border-b border-stroke hover:bg-bg-surface/50">
<td className="px-3 py-2 text-t3 font-mono">{row.id}</td>
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.name}</td>
<td
@ -473,7 +473,7 @@ function TaskTable({ rows, loading, onAction }: TaskTableProps) {
</button>
<button
onClick={() => onAction('delete', row)}
className="px-2 py-0.5 text-label-2 rounded bg-bg-elevated hover:bg-bg-card text-red-400 transition-colors whitespace-nowrap"
className="px-2 py-0.5 text-label-2 rounded bg-bg-elevated hover:bg-bg-card text-color-danger transition-colors whitespace-nowrap"
>
</button>
@ -499,7 +499,7 @@ const STEP_LABELS = ['소스선택', '데이터검증', '비식별화규칙', '
function StepIndicator({ current }: { current: number }) {
return (
<div className="flex items-center justify-center gap-0 px-6 py-4 border-b border-stroke-1">
<div className="flex items-center justify-center gap-0 px-6 py-4 border-b border-stroke">
{STEP_LABELS.map((label, i) => {
const stepNum = i + 1;
const isDone = stepNum < current;
@ -508,11 +508,11 @@ function StepIndicator({ current }: { current: number }) {
<div key={label} className="flex items-center">
<div className="flex flex-col items-center">
<div
className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-semibold transition-colors ${
className={`w-7 h-7 rounded-full flex items-center justify-center text-caption font-semibold transition-colors ${
isDone
? 'bg-emerald-500 text-white'
? 'bg-color-success text-white'
: isActive
? 'bg-cyan-500 text-white'
? 'bg-color-accent text-white'
: 'bg-bg-elevated text-t3'
}`}
>
@ -531,7 +531,7 @@ function StepIndicator({ current }: { current: number }) {
</div>
<span
className={`mt-1 text-label-2 whitespace-nowrap ${
isActive ? 'text-cyan-400' : isDone ? 'text-emerald-400' : 'text-t3'
isActive ? 'text-color-accent' : isDone ? 'text-color-success' : 'text-t3'
}`}
>
{stepNum}.{label}
@ -539,7 +539,7 @@ function StepIndicator({ current }: { current: number }) {
</div>
{i < STEP_LABELS.length - 1 && (
<div
className={`w-10 h-px mx-1 mb-4 ${i + 1 < current ? 'bg-emerald-500' : 'bg-stroke-1'}`}
className={`w-10 h-px mx-1 mb-4 ${i + 1 < current ? 'bg-color-success' : 'bg-stroke-1'}`}
/>
)}
</div>
@ -567,18 +567,18 @@ function Step1({ wizard, onChange }: Step1Props) {
return (
<div className="space-y-5">
<div>
<label className="block text-xs font-medium text-t2 mb-1"> *</label>
<label className="block text-caption font-medium text-t2 mb-1"> *</label>
<input
type="text"
value={wizard.taskName}
onChange={(e) => onChange({ taskName: e.target.value })}
placeholder="작업 이름을 입력하세요"
className="w-full px-3 py-2 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 placeholder:text-t3 focus:outline-none focus:border-cyan-500"
className="w-full px-3 py-2 text-caption rounded bg-bg-elevated border border-stroke text-t1 placeholder:text-t3 focus:outline-none focus:border-color-accent"
/>
</div>
<div>
<label className="block text-xs font-medium text-t2 mb-2"> *</label>
<label className="block text-caption font-medium text-t2 mb-2"> *</label>
<div className="flex flex-col gap-2">
{(
[
@ -596,14 +596,14 @@ function Step1({ wizard, onChange }: Step1Props) {
onChange={() => onChange({ sourceType: val })}
className="accent-cyan-500"
/>
<span className="text-xs text-t1">{label}</span>
<span className="text-caption text-t1">{label}</span>
</label>
))}
</div>
</div>
{wizard.sourceType === 'db' && (
<div className="grid grid-cols-2 gap-3 p-4 rounded bg-bg-surface border border-stroke-1">
<div className="grid grid-cols-2 gap-3 p-4 rounded bg-bg-surface border border-stroke">
{(
[
['host', '호스트', 'localhost'],
@ -619,7 +619,7 @@ function Step1({ wizard, onChange }: Step1Props) {
value={wizard.dbConfig[key]}
onChange={(e) => handleDbChange(key, e.target.value)}
placeholder={placeholder}
className="w-full px-2.5 py-1.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 placeholder:text-t3 focus:outline-none focus:border-cyan-500"
className="w-full px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 placeholder:text-t3 focus:outline-none focus:border-color-accent"
/>
</div>
))}
@ -627,7 +627,7 @@ function Step1({ wizard, onChange }: Step1Props) {
)}
{wizard.sourceType === 'file' && (
<div className="p-8 rounded border-2 border-dashed border-stroke-1 bg-bg-surface flex flex-col items-center gap-2 text-center">
<div className="p-8 rounded border-2 border-dashed border-stroke bg-bg-surface flex flex-col items-center gap-2 text-center">
<svg className="w-8 h-8 text-t3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
@ -636,13 +636,13 @@ function Step1({ wizard, onChange }: Step1Props) {
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p className="text-xs text-t2"> </p>
<p className="text-caption text-t2"> </p>
<p className="text-label-2 text-t3">CSV, XLSX, JSON ( 500MB)</p>
</div>
)}
{wizard.sourceType === 'api' && (
<div className="p-4 rounded bg-bg-surface border border-stroke-1 space-y-3">
<div className="p-4 rounded bg-bg-surface border border-stroke space-y-3">
<div>
<label className="block text-label-2 text-t3 mb-1">API URL</label>
<input
@ -650,7 +650,7 @@ function Step1({ wizard, onChange }: Step1Props) {
value={wizard.apiConfig.url}
onChange={(e) => handleApiChange('url', e.target.value)}
placeholder="https://api.example.com/data"
className="w-full px-2.5 py-1.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 placeholder:text-t3 focus:outline-none focus:border-cyan-500"
className="w-full px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 placeholder:text-t3 focus:outline-none focus:border-color-accent"
/>
</div>
<div>
@ -658,7 +658,7 @@ function Step1({ wizard, onChange }: Step1Props) {
<select
value={wizard.apiConfig.method}
onChange={(e) => handleApiChange('method', e.target.value)}
className="px-2.5 py-1.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500"
className="px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
>
<option value="GET">GET</option>
<option value="POST">POST</option>
@ -688,23 +688,23 @@ function Step2({ wizard, onChange }: Step2Props) {
<div className="grid grid-cols-3 gap-3">
{[
{ label: '총 데이터 건수', value: '15,240건', color: 'text-t1' },
{ label: '중복', value: '0건', color: 'text-emerald-400' },
{ label: '누락값', value: '23건', color: 'text-yellow-400' },
{ label: '중복', value: '0건', color: 'text-color-success' },
{ label: '누락값', value: '23건', color: 'text-color-caution' },
].map((stat) => (
<div key={stat.label} className="p-3 rounded bg-bg-surface border border-stroke-1">
<div key={stat.label} className="p-3 rounded bg-bg-surface border border-stroke">
<p className="text-label-2 text-t3 mb-1">{stat.label}</p>
<p className={`text-sm font-semibold ${stat.color}`}>{stat.value}</p>
<p className={`text-body-2 font-semibold ${stat.color}`}>{stat.value}</p>
</div>
))}
</div>
<div>
<h4 className="text-xs font-medium text-t2 mb-2"> </h4>
<div className="rounded border border-stroke-1 overflow-hidden">
<table className="w-full text-xs border-collapse">
<h4 className="text-caption font-medium text-t2 mb-2"> </h4>
<div className="rounded border border-stroke overflow-hidden">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3">
<th className="px-3 py-2 text-left font-medium border-b border-stroke-1 w-8">
<th className="px-3 py-2 text-left font-medium border-b border-stroke w-8">
<input
type="checkbox"
checked={wizard.fields.every((f) => f.selected)}
@ -716,15 +716,15 @@ function Step2({ wizard, onChange }: Step2Props) {
className="accent-cyan-500"
/>
</th>
<th className="px-3 py-2 text-left font-medium border-b border-stroke-1"></th>
<th className="px-3 py-2 text-left font-medium border-b border-stroke-1">
<th className="px-3 py-2 text-left font-medium border-b border-stroke"></th>
<th className="px-3 py-2 text-left font-medium border-b border-stroke">
</th>
</tr>
</thead>
<tbody>
{wizard.fields.map((field, idx) => (
<tr key={field.name} className="border-b border-stroke-1 hover:bg-bg-surface/50">
<tr key={field.name} className="border-b border-stroke hover:bg-bg-surface/50">
<td className="px-3 py-2">
<input
type="checkbox"
@ -765,32 +765,32 @@ function Step3({ wizard, onChange }: Step3Props) {
return (
<div className="space-y-4">
<div className="rounded border border-stroke-1 overflow-hidden">
<table className="w-full text-xs border-collapse">
<div className="rounded border border-stroke overflow-hidden">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3">
<th className="px-3 py-2 text-left font-medium border-b border-stroke-1"></th>
<th className="px-3 py-2 text-left font-medium border-b border-stroke-1">
<th className="px-3 py-2 text-left font-medium border-b border-stroke"></th>
<th className="px-3 py-2 text-left font-medium border-b border-stroke">
</th>
<th className="px-3 py-2 text-left font-medium border-b border-stroke-1">
<th className="px-3 py-2 text-left font-medium border-b border-stroke">
</th>
<th className="px-3 py-2 text-left font-medium border-b border-stroke-1"></th>
<th className="px-3 py-2 text-left font-medium border-b border-stroke"></th>
</tr>
</thead>
<tbody>
{selectedFields.map((field) => {
const globalIdx = wizard.fields.findIndex((f) => f.name === field.name);
return (
<tr key={field.name} className="border-b border-stroke-1 hover:bg-bg-surface/50">
<tr key={field.name} className="border-b border-stroke hover:bg-bg-surface/50">
<td className="px-3 py-2 font-medium text-t1">{field.name}</td>
<td className="px-3 py-2 text-t3">{field.dataType}</td>
<td className="px-3 py-2">
<select
value={field.technique}
onChange={(e) => updateField(globalIdx, 'technique', e.target.value)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
>
{TECHNIQUES.map((t) => (
<option key={t} value={t}>
@ -804,7 +804,7 @@ function Step3({ wizard, onChange }: Step3Props) {
type="text"
value={field.configValue}
onChange={(e) => updateField(globalIdx, 'configValue', e.target.value)}
className="w-full px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500"
className="w-full px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
/>
</td>
</tr>
@ -822,15 +822,15 @@ function Step3({ wizard, onChange }: Step3Props) {
onChange={(e) => onChange({ saveAsTemplate: e.target.checked })}
className="accent-cyan-500"
/>
<span className="text-xs text-t2">릿 </span>
<span className="text-caption text-t2">릿 </span>
</label>
<div className="flex items-center gap-2">
<span className="text-xs text-t3"> 릿 :</span>
<span className="text-caption text-t3"> 릿 :</span>
<select
value={wizard.applyTemplate}
onChange={(e) => onChange({ applyTemplate: e.target.value })}
className="px-2.5 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500"
className="px-2.5 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
>
<option value=""> </option>
{TEMPLATES.map((t) => (
@ -881,19 +881,19 @@ function Step4({ wizard, onChange }: Step4Props) {
className="mt-0.5 accent-cyan-500"
/>
<div>
<span className="text-xs font-medium text-t1">{label}</span>
<span className="text-caption font-medium text-t1">{label}</span>
<p className="text-label-2 text-t3 mt-0.5">{desc}</p>
</div>
</label>
{val === 'scheduled' && wizard.processMode === 'scheduled' && (
<div className="mt-3 ml-5 p-4 rounded bg-bg-surface border border-stroke-1 space-y-3">
<div className="mt-3 ml-5 p-4 rounded bg-bg-surface border border-stroke space-y-3">
<div className="flex items-center gap-3">
<label className="text-xs text-t3 w-16 shrink-0"> </label>
<label className="text-caption text-t3 w-16 shrink-0"> </label>
<select
value={wizard.scheduleConfig.hour}
onChange={(e) => handleScheduleChange('hour', e.target.value)}
className="px-2.5 py-1.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500"
className="px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
>
{HOURS.map((h) => (
<option key={h} value={h}>
@ -903,7 +903,7 @@ function Step4({ wizard, onChange }: Step4Props) {
</select>
</div>
<div className="flex items-start gap-3">
<label className="text-xs text-t3 w-16 shrink-0 mt-1"></label>
<label className="text-caption text-t3 w-16 shrink-0 mt-1"></label>
<div className="flex flex-col gap-2">
{(
[
@ -921,12 +921,12 @@ function Step4({ wizard, onChange }: Step4Props) {
onChange={() => handleScheduleChange('repeatType', rt)}
className="accent-cyan-500"
/>
<span className="text-xs text-t1">{rl}</span>
<span className="text-caption text-t1">{rl}</span>
{rt === 'weekly' && wizard.scheduleConfig.repeatType === 'weekly' && (
<select
value={wizard.scheduleConfig.weekday}
onChange={(e) => handleScheduleChange('weekday', e.target.value)}
className="ml-1 px-2 py-0.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500"
className="ml-1 px-2 py-0.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
>
{WEEKDAYS.map((d) => (
<option key={d} value={d}>
@ -940,12 +940,12 @@ function Step4({ wizard, onChange }: Step4Props) {
</div>
</div>
<div className="flex items-center gap-3">
<label className="text-xs text-t3 w-16 shrink-0"></label>
<label className="text-caption text-t3 w-16 shrink-0"></label>
<input
type="date"
value={wizard.scheduleConfig.startDate}
onChange={(e) => handleScheduleChange('startDate', e.target.value)}
className="px-2.5 py-1.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500"
className="px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
/>
</div>
<div className="flex flex-col gap-1.5 mt-1">
@ -956,7 +956,7 @@ function Step4({ wizard, onChange }: Step4Props) {
onChange={(e) => handleScheduleChange('notifyOnComplete', e.target.checked)}
className="accent-cyan-500"
/>
<span className="text-xs text-t2"> </span>
<span className="text-caption text-t2"> </span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
@ -965,29 +965,29 @@ function Step4({ wizard, onChange }: Step4Props) {
onChange={(e) => handleScheduleChange('notifyOnError', e.target.checked)}
className="accent-cyan-500"
/>
<span className="text-xs text-t2"> </span>
<span className="text-caption text-t2"> </span>
</label>
</div>
</div>
)}
{val === 'oneshot' && wizard.processMode === 'oneshot' && (
<div className="mt-3 ml-5 p-4 rounded bg-bg-surface border border-stroke-1 space-y-3">
<div className="mt-3 ml-5 p-4 rounded bg-bg-surface border border-stroke space-y-3">
<div className="flex items-center gap-3">
<label className="text-xs text-t3 w-16 shrink-0"> </label>
<label className="text-caption text-t3 w-16 shrink-0"> </label>
<input
type="date"
value={wizard.oneshotConfig.date}
onChange={(e) => handleOneshotChange('date', e.target.value)}
className="px-2.5 py-1.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500"
className="px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
/>
</div>
<div className="flex items-center gap-3">
<label className="text-xs text-t3 w-16 shrink-0"> </label>
<label className="text-caption text-t3 w-16 shrink-0"> </label>
<select
value={wizard.oneshotConfig.hour}
onChange={(e) => handleOneshotChange('hour', e.target.value)}
className="px-2.5 py-1.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500"
className="px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
>
{HOURS.map((h) => (
<option key={h} value={h}>
@ -1042,11 +1042,11 @@ function Step5({ wizard, onChange }: Step5Props) {
return (
<div className="space-y-4">
<div className="rounded border border-stroke-1 overflow-hidden">
<table className="w-full text-xs border-collapse">
<div className="rounded border border-stroke overflow-hidden">
<table className="w-full text-caption border-collapse">
<tbody>
{summaryRows.map(({ label, value }) => (
<tr key={label} className="border-b border-stroke-1 last:border-b-0">
<tr key={label} className="border-b border-stroke last:border-b-0">
<td className="px-4 py-2.5 text-t3 bg-bg-elevated w-36 font-medium">{label}</td>
<td className="px-4 py-2.5 text-t1">{value}</td>
</tr>
@ -1055,14 +1055,14 @@ function Step5({ wizard, onChange }: Step5Props) {
</table>
</div>
<label className="flex items-center gap-2 cursor-pointer p-3 rounded border border-stroke-1 bg-bg-surface">
<label className="flex items-center gap-2 cursor-pointer p-3 rounded border border-stroke bg-bg-surface">
<input
type="checkbox"
checked={wizard.confirmed}
onChange={(e) => onChange({ confirmed: e.target.checked })}
className="accent-cyan-500"
/>
<span className="text-xs text-t1 font-medium"> .</span>
<span className="text-caption text-t1 font-medium"> .</span>
</label>
</div>
);
@ -1097,13 +1097,13 @@ const INITIAL_WIZARD: WizardState = {
function getAuditResultClass(type: AuditLogEntry['resultType']): string {
switch (type) {
case '성공':
return 'text-emerald-400 bg-emerald-500/10';
return 'text-color-success bg-[rgba(34,197,94,0.1)]';
case '진행중':
return 'text-cyan-400 bg-cyan-500/10';
return 'text-color-accent bg-[rgba(6,182,212,0.1)]';
case '실패':
return 'text-red-400 bg-red-500/10';
return 'text-color-danger bg-[rgba(239,68,68,0.1)]';
case '거부':
return 'text-yellow-400 bg-yellow-500/10';
return 'text-color-caution bg-[rgba(234,179,8,0.1)]';
}
}
@ -1127,10 +1127,10 @@ function AuditLogModal({ task, onClose }: AuditLogModalProps) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div className="bg-bg-card border border-stroke-1 rounded-lg shadow-2xl w-full max-w-5xl max-h-[85vh] flex flex-col">
<div className="bg-bg-card border border-stroke rounded-lg shadow-2xl w-full max-w-5xl max-h-[85vh] flex flex-col">
{/* 헤더 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1 shrink-0">
<h3 className="text-sm font-semibold text-t1"> () {task.name}</h3>
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke shrink-0">
<h3 className="text-body-2 font-semibold text-t1"> () {task.name}</h3>
<button
onClick={onClose}
className="text-t3 hover:text-t1 transition-colors text-lg leading-none"
@ -1140,26 +1140,26 @@ function AuditLogModal({ task, onClose }: AuditLogModalProps) {
</div>
{/* 필터 바 */}
<div className="flex items-center gap-3 px-5 py-2.5 border-b border-stroke-1 shrink-0 bg-bg-base">
<span className="text-xs text-t3">:</span>
<div className="flex items-center gap-3 px-5 py-2.5 border-b border-stroke shrink-0 bg-bg-base">
<span className="text-caption text-t3">:</span>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
/>
<span className="text-xs text-t3">~</span>
<span className="text-caption text-t3">~</span>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
/>
<span className="text-xs text-t3 ml-2">:</span>
<span className="text-caption text-t3 ml-2">:</span>
<select
value={filterOperator}
onChange={(e) => setFilterOperator(e.target.value)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
>
{operators.map((op) => (
<option key={op} value={op}>
@ -1171,13 +1171,13 @@ function AuditLogModal({ task, onClose }: AuditLogModalProps) {
{/* 로그 테이블 */}
<div className="flex-1 overflow-auto px-5 py-3">
<table className="w-full text-xs border-collapse">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{['시간', '작업자', '작업', '대상 데이터', '결과', '상세'].map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
>
{h}
</th>
@ -1195,7 +1195,7 @@ function AuditLogModal({ task, onClose }: AuditLogModalProps) {
filteredLogs.map((log) => (
<tr
key={log.id}
className={`border-b border-stroke-1 hover:bg-bg-surface/50 cursor-pointer ${selectedLog?.id === log.id ? 'bg-bg-surface/70' : ''}`}
className={`border-b border-stroke hover:bg-bg-surface/50 cursor-pointer ${selectedLog?.id === log.id ? 'bg-bg-surface/70' : ''}`}
onClick={() => setSelectedLog(log)}
>
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
@ -1219,7 +1219,7 @@ function AuditLogModal({ task, onClose }: AuditLogModalProps) {
e.stopPropagation();
setSelectedLog(log);
}}
className="px-2 py-0.5 text-label-2 rounded bg-bg-elevated hover:bg-bg-card text-cyan-400 transition-colors whitespace-nowrap"
className="px-2 py-0.5 text-label-2 rounded bg-bg-elevated hover:bg-bg-card text-color-accent transition-colors whitespace-nowrap"
>
</button>
@ -1233,9 +1233,9 @@ function AuditLogModal({ task, onClose }: AuditLogModalProps) {
{/* 로그 상세 정보 */}
{selectedLog && (
<div className="px-5 py-3 border-t border-stroke-1 shrink-0 bg-bg-base">
<h4 className="text-xs font-semibold text-t1 mb-2"> </h4>
<div className="bg-bg-elevated border border-stroke-1 rounded p-3 text-xs grid grid-cols-2 gap-x-6 gap-y-1.5">
<div className="px-5 py-3 border-t border-stroke shrink-0 bg-bg-base">
<h4 className="text-caption font-semibold text-t1 mb-2"> </h4>
<div className="bg-bg-elevated border border-stroke rounded p-3 text-caption grid grid-cols-2 gap-x-6 gap-y-1.5">
<div>
<span className="text-t3">ID:</span>{' '}
<span className="text-t1 font-mono">{selectedLog.id}</span>
@ -1284,16 +1284,16 @@ function AuditLogModal({ task, onClose }: AuditLogModalProps) {
)}
{/* 하단 버튼 */}
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-stroke-1 shrink-0">
<button className="px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 transition-colors">
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-stroke shrink-0">
<button className="px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 transition-colors">
()
</button>
<button className="px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 transition-colors">
<button className="px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 transition-colors">
</button>
<button
onClick={onClose}
className="px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 transition-colors"
className="px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 transition-colors"
>
</button>
@ -1337,10 +1337,10 @@ function WizardModal({ onClose, onSubmit }: WizardModalProps) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div className="bg-bg-card border border-stroke-1 rounded-lg shadow-2xl w-full max-w-4xl mx-4 flex flex-col max-h-[90vh]">
<div className="bg-bg-card border border-stroke rounded-lg shadow-2xl w-full max-w-4xl mx-4 flex flex-col max-h-[90vh]">
{/* 모달 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke-1 shrink-0">
<h3 className="text-sm font-semibold text-t1"> </h3>
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke shrink-0">
<h3 className="text-body-2 font-semibold text-t1"> </h3>
<button
onClick={onClose}
className="p-1 rounded text-t3 hover:text-t1 hover:bg-bg-elevated transition-colors"
@ -1369,18 +1369,18 @@ function WizardModal({ onClose, onSubmit }: WizardModalProps) {
</div>
{/* 푸터 버튼 */}
<div className="flex items-center justify-between px-6 py-4 border-t border-stroke-1 shrink-0">
<div className="flex items-center justify-between px-6 py-4 border-t border-stroke shrink-0">
<button
onClick={handlePrev}
disabled={wizard.step === 1}
className="px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
className="px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
</button>
<div className="flex items-center gap-2">
<button
onClick={onClose}
className="px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 transition-colors"
className="px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 transition-colors"
>
</button>
@ -1388,7 +1388,7 @@ function WizardModal({ onClose, onSubmit }: WizardModalProps) {
<button
onClick={handleNext}
disabled={!canProceed()}
className="px-3 py-1.5 text-xs rounded bg-cyan-600 hover:bg-cyan-700 text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
className="px-3 py-1.5 text-caption rounded bg-color-accent hover:bg-color-accent-muted text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
</button>
@ -1396,7 +1396,7 @@ function WizardModal({ onClose, onSubmit }: WizardModalProps) {
<button
onClick={handleSubmit}
disabled={!canProceed()}
className="px-3 py-1.5 text-xs rounded bg-cyan-600 hover:bg-cyan-700 text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
className="px-3 py-1.5 text-caption rounded bg-color-accent hover:bg-color-accent-muted text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
</button>
@ -1489,11 +1489,11 @@ export default function DeidentifyPanel() {
return (
<div className="flex flex-col h-full overflow-hidden">
{/* 헤더 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1 shrink-0">
<h2 className="text-sm font-semibold text-t1"></h2>
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke shrink-0">
<h2 className="text-body-2 font-semibold text-t1"></h2>
<button
onClick={() => setShowWizard(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-cyan-600 hover:bg-cyan-700 text-white transition-colors"
className="flex items-center gap-1.5 px-3 py-1.5 text-caption rounded bg-color-accent hover:bg-color-accent-muted text-white transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
@ -1503,37 +1503,37 @@ export default function DeidentifyPanel() {
</div>
{/* 상태 요약 */}
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke-1 bg-bg-base">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-emerald-500/10 text-emerald-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke bg-bg-base">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-[rgba(34,197,94,0.1)] text-color-success">
<span className="w-1.5 h-1.5 rounded-full bg-color-success" />
{completedCount}
</span>
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-cyan-500/10 text-cyan-400">
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400" />
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-[rgba(6,182,212,0.1)] text-color-accent">
<span className="w-1.5 h-1.5 rounded-full bg-color-accent" />
{inProgressCount}
</span>
{errorCount > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-red-500/10 text-red-400">
<span className="w-1.5 h-1.5 rounded-full bg-red-400" />
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-[rgba(239,68,68,0.1)] text-color-danger">
<span className="w-1.5 h-1.5 rounded-full bg-color-danger" />
{errorCount}
</span>
)}
<span className="text-xs text-t3"> {tasks.length}</span>
<span className="text-caption text-t3"> {tasks.length}</span>
</div>
{/* 검색/필터 */}
<div className="flex items-center gap-2 px-5 py-2.5 shrink-0 border-b border-stroke-1">
<div className="flex items-center gap-2 px-5 py-2.5 shrink-0 border-b border-stroke">
<input
type="text"
value={searchName}
onChange={(e) => setSearchName(e.target.value)}
placeholder="작업명 검색"
className="px-2.5 py-1.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 placeholder:text-t3 focus:outline-none focus:border-cyan-500 w-40"
className="px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 placeholder:text-t3 focus:outline-none focus:border-color-accent w-40"
/>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value as FilterStatus)}
className="px-2.5 py-1.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500"
className="px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
>
{(['모두', '완료', '진행중', '대기', '오류'] as FilterStatus[]).map((s) => (
<option key={s} value={s}>
@ -1544,7 +1544,7 @@ export default function DeidentifyPanel() {
<select
value={filterPeriod}
onChange={(e) => setFilterPeriod(e.target.value as '7' | '30' | '90')}
className="px-2.5 py-1.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500"
className="px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
>
<option value="7"> 7</option>
<option value="30"> 30</option>

파일 보기

@ -153,8 +153,8 @@ function PermCell({ state, onToggle, label, readOnly = false }: PermCellProps) {
break;
case 'explicit-denied':
classes = readOnly
? `${baseClasses} bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)] text-red-400 cursor-default`
: `${baseClasses} bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)] text-red-400 cursor-pointer hover:border-red-400`;
? `${baseClasses} bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)] text-color-danger cursor-default`
: `${baseClasses} bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)] text-color-danger cursor-pointer hover:border-color-danger`;
icon = '—';
break;
case 'forced-denied':
@ -219,7 +219,7 @@ function TreeRow({
return (
<>
<tr className="border-b border-stroke hover:bg-[rgba(255,255,255,0.02)] transition-colors">
<tr className="border-b border-stroke hover:bg-[rgba(6,182,212,0.04)] transition-colors">
<td className="px-3 py-1">
<div className="flex items-center" style={{ paddingLeft: indent }}>
{hasChildren ? (
@ -311,7 +311,7 @@ function PermLegend() {
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-3 h-3 rounded border bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)] text-red-400 text-center text-caption leading-3">
<span className="inline-block w-3 h-3 rounded border bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)] text-color-danger text-center text-caption leading-3">
</span>
@ -443,8 +443,7 @@ function RolePermTab({
className="flex items-center gap-1.5 px-4 py-2 border-b border-stroke bg-bg-surface overflow-x-auto"
style={{ flexShrink: 0 }}
>
{roles.map((role, idx) => {
const color = getRoleColor(role.code, idx);
{roles.map((role) => {
const isSelected = selectedRoleSn === role.sn;
return (
<div key={role.sn} className="flex items-center gap-0.5 flex-shrink-0">
@ -452,10 +451,9 @@ function RolePermTab({
onClick={() => setSelectedRoleSn(role.sn)}
className={`px-2.5 py-1 text-label-2 font-semibold rounded-md transition-all font-korean ${
isSelected
? 'border-2 shadow-[0_0_8px_rgba(6,182,212,0.2)]'
: 'border border-stroke text-fg-disabled hover:border-stroke'
? 'border-2 border-color-accent text-color-accent shadow-[0_0_8px_rgba(6,182,212,0.2)]'
: 'border border-stroke text-fg-disabled hover:border-stroke-light hover:text-fg-sub'
}`}
style={isSelected ? { borderColor: color, color } : undefined}
>
{editingRoleSn === role.sn ? (
<input
@ -495,7 +493,7 @@ function RolePermTab({
{role.code !== 'ADMIN' && (
<button
onClick={() => handleDeleteRole(role.sn, role.name)}
className="w-5 h-5 flex items-center justify-center text-fg-disabled hover:text-red-400 transition-colors"
className="w-5 h-5 flex items-center justify-center text-fg-disabled hover:text-color-danger transition-colors"
title="역할 삭제"
>
<svg
@ -612,7 +610,7 @@ function RolePermTab({
/>
</div>
{createError && (
<div className="px-3 py-2 text-label-2 text-red-400 bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.2)] rounded-md font-korean">
<div className="px-3 py-2 text-label-2 text-color-danger bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.2)] rounded-md font-korean">
{createError}
</div>
)}

파일 보기

@ -297,7 +297,7 @@ function PipelineCard({ node }: { node: PipelineNode }) {
<div
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke-1 border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
>
<div className="text-xs font-medium text-t1 leading-snug">{node.name}</div>
<div className="text-caption font-medium text-t1 leading-snug">{node.name}</div>
<span
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
>
@ -316,7 +316,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-1">
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
{i < 4 && <span className="text-t3 text-sm px-0.5"></span>}
{i < 4 && <span className="text-t3 text-body-2 px-0.5"></span>}
</div>
))}
</div>
@ -328,7 +328,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool
{nodes.map((node, idx) => (
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
<PipelineCard node={node} />
{idx < nodes.length - 1 && <span className="text-t3 text-sm shrink-0 px-0.5"></span>}
{idx < nodes.length - 1 && <span className="text-t3 text-body-2 shrink-0 px-0.5"></span>}
</div>
))}
</div>
@ -367,7 +367,7 @@ const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
return (
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{LOG_HEADERS.map((h) => (
@ -440,7 +440,7 @@ function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean
}
if (alerts.length === 0) {
return <p className="text-xs text-t3 py-2"> .</p>;
return <p className="text-caption text-t3 py-2"> .</p>;
}
return (
@ -448,7 +448,7 @@ function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean
{alerts.map((alert) => (
<div
key={alert.id}
className={`flex items-start gap-2 px-3 py-2 rounded border text-xs ${getAlertStyle(alert.level)}`}
className={`flex items-start gap-2 px-3 py-2 rounded border text-caption ${getAlertStyle(alert.level)}`}
>
<span className="font-semibold shrink-0">[{alert.level}]</span>
<span className="flex-1">{alert.message}</span>
@ -509,10 +509,10 @@ export default function RndHnsAtmosPanel() {
{/* ── 헤더 ── */}
<div className="shrink-0 border-b border-stroke-1">
<div className="flex items-center justify-between px-5 py-3">
<h2 className="text-sm font-semibold text-t1">HNS () </h2>
<h2 className="text-body-2 font-semibold text-t1">HNS () </h2>
<div className="flex items-center gap-3">
{lastUpdate && (
<span className="text-xs text-t3">
<span className="text-caption text-t3">
:{' '}
{lastUpdate.toLocaleTimeString('ko-KR', {
hour: '2-digit',
@ -524,7 +524,7 @@ export default function RndHnsAtmosPanel() {
<button
onClick={() => void fetchData()}
disabled={loading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="flex items-center gap-1.5 px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
@ -544,7 +544,7 @@ export default function RndHnsAtmosPanel() {
</div>
</div>
{/* 요약 통계 바 */}
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-caption text-t3 border-t border-stroke-1">
<span>
: <span className="text-emerald-400 font-medium">{totalReceived}</span>
</span>
@ -567,7 +567,7 @@ export default function RndHnsAtmosPanel() {
<div className="flex-1 overflow-auto">
{/* 파이프라인 현황 */}
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
<h3 className="text-xs font-semibold text-t2 mb-3 uppercase tracking-wide">
<h3 className="text-caption font-semibold text-t2 mb-3 uppercase tracking-wide">
</h3>
<PipelineFlow nodes={pipeline} loading={loading} />
@ -576,7 +576,7 @@ export default function RndHnsAtmosPanel() {
{/* 필터 바 + 수신 이력 테이블 */}
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide shrink-0">
<h3 className="text-caption font-semibold text-t2 uppercase tracking-wide shrink-0">
</h3>
<div className="flex items-center gap-2 flex-wrap">
@ -584,7 +584,7 @@ export default function RndHnsAtmosPanel() {
<select
value={filterSource}
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="all"> ()</option>
<option value="HYCOM">HYCOM</option>
@ -595,7 +595,7 @@ export default function RndHnsAtmosPanel() {
<select
value={filterReceive}
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="all"> ()</option>
<option value="수신완료"></option>
@ -607,13 +607,13 @@ export default function RndHnsAtmosPanel() {
<select
value={filterPeriod}
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="6h"> 6</option>
<option value="12h"> 12</option>
<option value="24h"> 24</option>
</select>
<span className="text-xs text-t3">{filteredLogs.length}</span>
<span className="text-caption text-t3">{filteredLogs.length}</span>
</div>
</div>
<DataLogTable rows={filteredLogs} loading={loading} />
@ -621,7 +621,7 @@ export default function RndHnsAtmosPanel() {
{/* 알림 현황 */}
<section className="px-5 pt-4 pb-5">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3"> </h3>
<h3 className="text-caption font-semibold text-t2 uppercase tracking-wide mb-3"> </h3>
<AlertList alerts={alerts} loading={loading} />
</section>
</div>

파일 보기

@ -297,7 +297,7 @@ function PipelineCard({ node }: { node: PipelineNode }) {
<div
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke-1 border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
>
<div className="text-xs font-medium text-t1 leading-snug">{node.name}</div>
<div className="text-caption font-medium text-t1 leading-snug">{node.name}</div>
<span
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
>
@ -316,7 +316,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-1">
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
{i < 4 && <span className="text-t3 text-sm px-0.5"></span>}
{i < 4 && <span className="text-t3 text-body-2 px-0.5"></span>}
</div>
))}
</div>
@ -328,7 +328,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool
{nodes.map((node, idx) => (
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
<PipelineCard node={node} />
{idx < nodes.length - 1 && <span className="text-t3 text-sm shrink-0 px-0.5"></span>}
{idx < nodes.length - 1 && <span className="text-t3 text-body-2 shrink-0 px-0.5"></span>}
</div>
))}
</div>
@ -367,7 +367,7 @@ const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
return (
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{LOG_HEADERS.map((h) => (
@ -440,7 +440,7 @@ function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean
}
if (alerts.length === 0) {
return <p className="text-xs text-t3 py-2"> .</p>;
return <p className="text-caption text-t3 py-2"> .</p>;
}
return (
@ -448,7 +448,7 @@ function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean
{alerts.map((alert) => (
<div
key={alert.id}
className={`flex items-start gap-2 px-3 py-2 rounded border text-xs ${getAlertStyle(alert.level)}`}
className={`flex items-start gap-2 px-3 py-2 rounded border text-caption ${getAlertStyle(alert.level)}`}
>
<span className="font-semibold shrink-0">[{alert.level}]</span>
<span className="flex-1">{alert.message}</span>
@ -509,10 +509,10 @@ export default function RndKospsPanel() {
{/* ── 헤더 ── */}
<div className="shrink-0 border-b border-stroke-1">
<div className="flex items-center justify-between px-5 py-3">
<h2 className="text-sm font-semibold text-t1"> (KOSPS) </h2>
<h2 className="text-body-2 font-semibold text-t1"> (KOSPS) </h2>
<div className="flex items-center gap-3">
{lastUpdate && (
<span className="text-xs text-t3">
<span className="text-caption text-t3">
:{' '}
{lastUpdate.toLocaleTimeString('ko-KR', {
hour: '2-digit',
@ -524,7 +524,7 @@ export default function RndKospsPanel() {
<button
onClick={() => void fetchData()}
disabled={loading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="flex items-center gap-1.5 px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
@ -544,7 +544,7 @@ export default function RndKospsPanel() {
</div>
</div>
{/* 요약 통계 바 */}
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-caption text-t3 border-t border-stroke-1">
<span>
: <span className="text-emerald-400 font-medium">{totalReceived}</span>
</span>
@ -567,7 +567,7 @@ export default function RndKospsPanel() {
<div className="flex-1 overflow-auto">
{/* 파이프라인 현황 */}
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
<h3 className="text-xs font-semibold text-t2 mb-3 uppercase tracking-wide">
<h3 className="text-caption font-semibold text-t2 mb-3 uppercase tracking-wide">
</h3>
<PipelineFlow nodes={pipeline} loading={loading} />
@ -576,7 +576,7 @@ export default function RndKospsPanel() {
{/* 필터 바 + 수신 이력 테이블 */}
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide shrink-0">
<h3 className="text-caption font-semibold text-t2 uppercase tracking-wide shrink-0">
</h3>
<div className="flex items-center gap-2 flex-wrap">
@ -584,7 +584,7 @@ export default function RndKospsPanel() {
<select
value={filterSource}
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="all"> ()</option>
<option value="HYCOM">HYCOM</option>
@ -595,7 +595,7 @@ export default function RndKospsPanel() {
<select
value={filterReceive}
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="all"> ()</option>
<option value="수신완료"></option>
@ -607,13 +607,13 @@ export default function RndKospsPanel() {
<select
value={filterPeriod}
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="6h"> 6</option>
<option value="12h"> 12</option>
<option value="24h"> 24</option>
</select>
<span className="text-xs text-t3">{filteredLogs.length}</span>
<span className="text-caption text-t3">{filteredLogs.length}</span>
</div>
</div>
<DataLogTable rows={filteredLogs} loading={loading} />
@ -621,7 +621,7 @@ export default function RndKospsPanel() {
{/* 알림 현황 */}
<section className="px-5 pt-4 pb-5">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3"> </h3>
<h3 className="text-caption font-semibold text-t2 uppercase tracking-wide mb-3"> </h3>
<AlertList alerts={alerts} loading={loading} />
</section>
</div>

파일 보기

@ -324,7 +324,7 @@ function PipelineCard({ node }: { node: PipelineNode }) {
<div
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke-1 border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
>
<div className="text-xs font-medium text-t1 leading-snug">{node.name}</div>
<div className="text-caption font-medium text-t1 leading-snug">{node.name}</div>
<span
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
>
@ -343,7 +343,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-1">
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
{i < 4 && <span className="text-t3 text-sm px-0.5"></span>}
{i < 4 && <span className="text-t3 text-body-2 px-0.5"></span>}
</div>
))}
</div>
@ -355,7 +355,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool
{nodes.map((node, idx) => (
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
<PipelineCard node={node} />
{idx < nodes.length - 1 && <span className="text-t3 text-sm shrink-0 px-0.5"></span>}
{idx < nodes.length - 1 && <span className="text-t3 text-body-2 shrink-0 px-0.5"></span>}
</div>
))}
</div>
@ -394,7 +394,7 @@ const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
return (
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{LOG_HEADERS.map((h) => (
@ -467,7 +467,7 @@ function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean
}
if (alerts.length === 0) {
return <p className="text-xs text-t3 py-2"> .</p>;
return <p className="text-caption text-t3 py-2"> .</p>;
}
return (
@ -475,7 +475,7 @@ function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean
{alerts.map((alert) => (
<div
key={alert.id}
className={`flex items-start gap-2 px-3 py-2 rounded border text-xs ${getAlertStyle(alert.level)}`}
className={`flex items-start gap-2 px-3 py-2 rounded border text-caption ${getAlertStyle(alert.level)}`}
>
<span className="font-semibold shrink-0">[{alert.level}]</span>
<span className="flex-1">{alert.message}</span>
@ -536,10 +536,10 @@ export default function RndPoseidonPanel() {
{/* ── 헤더 ── */}
<div className="shrink-0 border-b border-stroke-1">
<div className="flex items-center justify-between px-5 py-3">
<h2 className="text-sm font-semibold text-t1"> () </h2>
<h2 className="text-body-2 font-semibold text-t1"> () </h2>
<div className="flex items-center gap-3">
{lastUpdate && (
<span className="text-xs text-t3">
<span className="text-caption text-t3">
:{' '}
{lastUpdate.toLocaleTimeString('ko-KR', {
hour: '2-digit',
@ -551,7 +551,7 @@ export default function RndPoseidonPanel() {
<button
onClick={() => void fetchData()}
disabled={loading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="flex items-center gap-1.5 px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
@ -571,7 +571,7 @@ export default function RndPoseidonPanel() {
</div>
</div>
{/* 요약 통계 바 */}
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-caption text-t3 border-t border-stroke-1">
<span>
: <span className="text-emerald-400 font-medium">{totalReceived}</span>
</span>
@ -594,7 +594,7 @@ export default function RndPoseidonPanel() {
<div className="flex-1 overflow-auto">
{/* 파이프라인 현황 */}
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
<h3 className="text-xs font-semibold text-t2 mb-3 uppercase tracking-wide">
<h3 className="text-caption font-semibold text-t2 mb-3 uppercase tracking-wide">
</h3>
<PipelineFlow nodes={pipeline} loading={loading} />
@ -603,7 +603,7 @@ export default function RndPoseidonPanel() {
{/* 필터 바 + 수신 이력 테이블 */}
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide shrink-0">
<h3 className="text-caption font-semibold text-t2 uppercase tracking-wide shrink-0">
</h3>
<div className="flex items-center gap-2 flex-wrap">
@ -611,7 +611,7 @@ export default function RndPoseidonPanel() {
<select
value={filterSource}
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="all"> ()</option>
<option value="HYCOM">HYCOM</option>
@ -622,7 +622,7 @@ export default function RndPoseidonPanel() {
<select
value={filterReceive}
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="all"> ()</option>
<option value="수신완료"></option>
@ -634,13 +634,13 @@ export default function RndPoseidonPanel() {
<select
value={filterPeriod}
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="6h"> 6</option>
<option value="12h"> 12</option>
<option value="24h"> 24</option>
</select>
<span className="text-xs text-t3">{filteredLogs.length}</span>
<span className="text-caption text-t3">{filteredLogs.length}</span>
</div>
</div>
<DataLogTable rows={filteredLogs} loading={loading} />
@ -648,7 +648,7 @@ export default function RndPoseidonPanel() {
{/* 알림 현황 */}
<section className="px-5 pt-4 pb-5">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3"> </h3>
<h3 className="text-caption font-semibold text-t2 uppercase tracking-wide mb-3"> </h3>
<AlertList alerts={alerts} loading={loading} />
</section>
</div>

파일 보기

@ -297,7 +297,7 @@ function PipelineCard({ node }: { node: PipelineNode }) {
<div
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke-1 border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
>
<div className="text-xs font-medium text-t1 leading-snug">{node.name}</div>
<div className="text-caption font-medium text-t1 leading-snug">{node.name}</div>
<span
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
>
@ -316,7 +316,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-1">
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
{i < 4 && <span className="text-t3 text-sm px-0.5"></span>}
{i < 4 && <span className="text-t3 text-body-2 px-0.5"></span>}
</div>
))}
</div>
@ -328,7 +328,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool
{nodes.map((node, idx) => (
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
<PipelineCard node={node} />
{idx < nodes.length - 1 && <span className="text-t3 text-sm shrink-0 px-0.5"></span>}
{idx < nodes.length - 1 && <span className="text-t3 text-body-2 shrink-0 px-0.5"></span>}
</div>
))}
</div>
@ -367,7 +367,7 @@ const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
return (
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{LOG_HEADERS.map((h) => (
@ -440,7 +440,7 @@ function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean
}
if (alerts.length === 0) {
return <p className="text-xs text-t3 py-2"> .</p>;
return <p className="text-caption text-t3 py-2"> .</p>;
}
return (
@ -448,7 +448,7 @@ function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean
{alerts.map((alert) => (
<div
key={alert.id}
className={`flex items-start gap-2 px-3 py-2 rounded border text-xs ${getAlertStyle(alert.level)}`}
className={`flex items-start gap-2 px-3 py-2 rounded border text-caption ${getAlertStyle(alert.level)}`}
>
<span className="font-semibold shrink-0">[{alert.level}]</span>
<span className="flex-1">{alert.message}</span>
@ -509,10 +509,10 @@ export default function RndRescuePanel() {
{/* ── 헤더 ── */}
<div className="shrink-0 border-b border-stroke-1">
<div className="flex items-center justify-between px-5 py-3">
<h2 className="text-sm font-semibold text-t1"> </h2>
<h2 className="text-body-2 font-semibold text-t1"> </h2>
<div className="flex items-center gap-3">
{lastUpdate && (
<span className="text-xs text-t3">
<span className="text-caption text-t3">
:{' '}
{lastUpdate.toLocaleTimeString('ko-KR', {
hour: '2-digit',
@ -524,7 +524,7 @@ export default function RndRescuePanel() {
<button
onClick={() => void fetchData()}
disabled={loading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="flex items-center gap-1.5 px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
@ -544,7 +544,7 @@ export default function RndRescuePanel() {
</div>
</div>
{/* 요약 통계 바 */}
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-caption text-t3 border-t border-stroke-1">
<span>
: <span className="text-emerald-400 font-medium">{totalReceived}</span>
</span>
@ -567,7 +567,7 @@ export default function RndRescuePanel() {
<div className="flex-1 overflow-auto">
{/* 파이프라인 현황 */}
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
<h3 className="text-xs font-semibold text-t2 mb-3 uppercase tracking-wide">
<h3 className="text-caption font-semibold text-t2 mb-3 uppercase tracking-wide">
</h3>
<PipelineFlow nodes={pipeline} loading={loading} />
@ -576,7 +576,7 @@ export default function RndRescuePanel() {
{/* 필터 바 + 수신 이력 테이블 */}
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide shrink-0">
<h3 className="text-caption font-semibold text-t2 uppercase tracking-wide shrink-0">
</h3>
<div className="flex items-center gap-2 flex-wrap">
@ -584,7 +584,7 @@ export default function RndRescuePanel() {
<select
value={filterSource}
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="all"> ()</option>
<option value="HYCOM">HYCOM</option>
@ -595,7 +595,7 @@ export default function RndRescuePanel() {
<select
value={filterReceive}
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="all"> ()</option>
<option value="수신완료"></option>
@ -607,13 +607,13 @@ export default function RndRescuePanel() {
<select
value={filterPeriod}
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="6h"> 6</option>
<option value="12h"> 12</option>
<option value="24h"> 24</option>
</select>
<span className="text-xs text-t3">{filteredLogs.length}</span>
<span className="text-caption text-t3">{filteredLogs.length}</span>
</div>
</div>
<DataLogTable rows={filteredLogs} loading={loading} />
@ -621,7 +621,7 @@ export default function RndRescuePanel() {
{/* 알림 현황 */}
<section className="px-5 pt-4 pb-5">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3"> </h3>
<h3 className="text-caption font-semibold text-t2 uppercase tracking-wide mb-3"> </h3>
<AlertList alerts={alerts} loading={loading} />
</section>
</div>

파일 보기

@ -193,12 +193,12 @@ function FrameworkTab() {
<div className="flex flex-col gap-6 p-5">
{/* 1. 개발 프레임워크 구성 */}
<section>
<h3 className="text-sm font-semibold text-t1 mb-3">1. </h3>
<div className="border border-stroke-1 rounded overflow-hidden">
<h3 className="text-body-2 font-semibold text-t1 mb-3">1. </h3>
<div className="border border-stroke rounded overflow-hidden">
{/* 프레젠테이션 계층 */}
<div className="border-b border-stroke-1 p-4 bg-bg-card">
<p className="text-xs font-semibold text-t2 mb-1"> </p>
<p className="text-xs text-t3 mb-3">React 19 + TypeScript 5.9 + Tailwind CSS 3</p>
<div className="border-b border-stroke p-4 bg-bg-card">
<p className="text-caption font-semibold text-t2 mb-1"> </p>
<p className="text-caption text-t3 mb-3">React 19 + TypeScript 5.9 + Tailwind CSS 3</p>
<div className="grid grid-cols-4 gap-2">
{[
{ name: 'MapLibre', sub: 'GL JS 5' },
@ -208,18 +208,18 @@ function FrameworkTab() {
].map((item) => (
<div
key={item.name}
className="bg-bg-elevated border border-stroke-1 rounded p-2 text-center"
className="bg-bg-elevated border border-stroke rounded p-2 text-center"
>
<p className="text-xs font-medium text-t1">{item.name}</p>
<p className="text-xs text-t3 mt-0.5">{item.sub}</p>
<p className="text-caption font-medium text-t1">{item.name}</p>
<p className="text-caption text-t3 mt-0.5">{item.sub}</p>
</div>
))}
</div>
</div>
{/* 비즈니스 로직 계층 */}
<div className="border-b border-stroke-1 p-4 bg-bg-surface">
<p className="text-xs font-semibold text-t2 mb-1"> </p>
<p className="text-xs text-t3 mb-3">Express 4 + TypeScript</p>
<div className="border-b border-stroke p-4 bg-bg-surface">
<p className="text-caption font-semibold text-t2 mb-1"> </p>
<p className="text-caption text-t3 mb-3">Express 4 + TypeScript</p>
<div className="grid grid-cols-4 gap-2">
{[
{ name: 'JWT 인증', sub: 'OAuth2.0' },
@ -229,18 +229,18 @@ function FrameworkTab() {
].map((item) => (
<div
key={item.name}
className="bg-bg-elevated border border-stroke-1 rounded p-2 text-center"
className="bg-bg-elevated border border-stroke rounded p-2 text-center"
>
<p className="text-xs font-medium text-t1">{item.name}</p>
<p className="text-xs text-t3 mt-0.5">{item.sub}</p>
<p className="text-caption font-medium text-t1">{item.name}</p>
<p className="text-caption text-t3 mt-0.5">{item.sub}</p>
</div>
))}
</div>
</div>
{/* 데이터 접근 계층 */}
<div className="p-4 bg-bg-card">
<p className="text-xs font-semibold text-t2 mb-1"> </p>
<p className="text-xs text-t3 mb-3">PostgreSQL 16 + PostGIS</p>
<p className="text-caption font-semibold text-t2 mb-1"> </p>
<p className="text-caption text-t3 mb-3">PostgreSQL 16 + PostGIS</p>
<div className="grid grid-cols-3 gap-2 max-w-xs">
{[
{ name: 'wing DB', sub: '운영 DB' },
@ -249,10 +249,10 @@ function FrameworkTab() {
].map((item) => (
<div
key={item.name}
className="bg-bg-elevated border border-stroke-1 rounded p-2 text-center"
className="bg-bg-elevated border border-stroke rounded p-2 text-center"
>
<p className="text-xs font-medium text-t1">{item.name}</p>
<p className="text-xs text-t3 mt-0.5">{item.sub}</p>
<p className="text-caption font-medium text-t1">{item.name}</p>
<p className="text-caption text-t3 mt-0.5">{item.sub}</p>
</div>
))}
</div>
@ -262,15 +262,15 @@ function FrameworkTab() {
{/* 2. 기술 스택 상세 */}
<section>
<h3 className="text-sm font-semibold text-t1 mb-3">2. </h3>
<h3 className="text-body-2 font-semibold text-t1 mb-3">2. </h3>
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{['구분', '기술', '버전', '설명'].map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
>
{h}
</th>
@ -279,7 +279,7 @@ function FrameworkTab() {
</thead>
<tbody>
{TECH_STACK.map((row, idx) => (
<tr key={idx} className="border-b border-stroke-1 hover:bg-bg-surface/50">
<tr key={idx} className="border-b border-stroke hover:bg-bg-surface/50">
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">
{row.category}
</td>
@ -295,7 +295,7 @@ function FrameworkTab() {
{/* 3. 개발 표준 및 규칙 */}
<section>
<h3 className="text-sm font-semibold text-t1 mb-3">3. </h3>
<h3 className="text-body-2 font-semibold text-t1 mb-3">3. </h3>
<div className="grid grid-cols-1 gap-3">
{[
{
@ -315,9 +315,9 @@ function FrameworkTab() {
content: '입력 살균(sanitize), XSS/SQL Injection 방지, CORS 정책, Rate Limiting',
},
].map((item) => (
<div key={item.title} className="bg-bg-card border border-stroke-1 rounded p-3">
<p className="text-xs font-semibold text-t2 mb-1">{item.title}</p>
<p className="text-xs text-t2 leading-relaxed">{item.content}</p>
<div key={item.title} className="bg-bg-card border border-stroke rounded p-3">
<p className="text-caption font-semibold text-t2 mb-1">{item.title}</p>
<p className="text-caption text-t2 leading-relaxed">{item.content}</p>
</div>
))}
</div>
@ -333,26 +333,26 @@ function TargetArchTab() {
<div className="flex flex-col gap-6 p-5">
{/* 1. 시스템 전체 구성도 */}
<section>
<h3 className="text-sm font-semibold text-t1 mb-3">1. </h3>
<div className="flex flex-col items-stretch gap-0 border border-stroke-1 rounded overflow-hidden">
<h3 className="text-body-2 font-semibold text-t1 mb-3">1. </h3>
<div className="flex flex-col items-stretch gap-0 border border-stroke rounded overflow-hidden">
{/* 사용자 접근 계층 */}
<div className="p-4 bg-bg-card">
<p className="text-xs font-semibold text-t2 mb-1"> </p>
<p className="text-xs text-t1 font-medium mb-1"> (React SPA)</p>
<p className="text-xs text-t3 leading-relaxed">
<p className="text-caption font-semibold text-t2 mb-1"> </p>
<p className="text-caption text-t1 font-medium mb-1"> (React SPA)</p>
<p className="text-caption text-t3 leading-relaxed">
| HNS분석 | | | | | SCAT조사 |
|
</p>
</div>
{/* 화살표 + 프로토콜 */}
<div className="flex flex-col items-center py-2 bg-bg-base border-y border-stroke-1">
<div className="flex flex-col items-center py-2 bg-bg-base border-y border-stroke">
<span className="text-t3 text-lg"></span>
<span className="text-xs text-t3">HTTPS (TLS 1.2+)</span>
<span className="text-caption text-t3">HTTPS (TLS 1.2+)</span>
</div>
{/* API 서버 계층 */}
<div className="p-4 bg-bg-surface border-b border-stroke-1">
<p className="text-xs font-semibold text-t2 mb-1">API </p>
<p className="text-xs text-t1 font-medium mb-2">Express 4 REST API (Port 3001)</p>
<div className="p-4 bg-bg-surface border-b border-stroke">
<p className="text-caption font-semibold text-t2 mb-1">API </p>
<p className="text-caption text-t1 font-medium mb-2">Express 4 REST API (Port 3001)</p>
<div className="grid grid-cols-2 gap-1.5 sm:grid-cols-4">
{[
'JWT 인증 미들웨어',
@ -362,22 +362,22 @@ function TargetArchTab() {
].map((item) => (
<div
key={item}
className="bg-bg-elevated border border-stroke-1 rounded px-2 py-1.5 text-center"
className="bg-bg-elevated border border-stroke rounded px-2 py-1.5 text-center"
>
<p className="text-xs text-t2 leading-snug">{item}</p>
<p className="text-caption text-t2 leading-snug">{item}</p>
</div>
))}
</div>
</div>
{/* 화살표 + 프로토콜 */}
<div className="flex flex-col items-center py-2 bg-bg-base border-b border-stroke-1">
<div className="flex flex-col items-center py-2 bg-bg-base border-b border-stroke">
<span className="text-t3 text-lg"></span>
<span className="text-xs text-t3">pg connection pool</span>
<span className="text-caption text-t3">pg connection pool</span>
</div>
{/* 데이터 계층 */}
<div className="p-4 bg-bg-card">
<p className="text-xs font-semibold text-t2 mb-1"> </p>
<p className="text-xs text-t1 font-medium mb-2">PostgreSQL 16 + PostGIS</p>
<p className="text-caption font-semibold text-t2 mb-1"> </p>
<p className="text-caption text-t1 font-medium mb-2">PostgreSQL 16 + PostGIS</p>
<div className="flex gap-2">
{[
{ name: 'wing DB', sub: '운영' },
@ -385,10 +385,10 @@ function TargetArchTab() {
].map((item) => (
<div
key={item.name}
className="bg-bg-elevated border border-stroke-1 rounded p-2 text-center min-w-24"
className="bg-bg-elevated border border-stroke rounded p-2 text-center min-w-24"
>
<p className="text-xs font-medium text-t1">{item.name}</p>
<p className="text-xs text-t3 mt-0.5">({item.sub})</p>
<p className="text-caption font-medium text-t1">{item.name}</p>
<p className="text-caption text-t3 mt-0.5">({item.sub})</p>
</div>
))}
</div>
@ -398,15 +398,15 @@ function TargetArchTab() {
{/* 2. 탭 기반 업무 모듈 구조 */}
<section>
<h3 className="text-sm font-semibold text-t1 mb-3">2. </h3>
<h3 className="text-body-2 font-semibold text-t1 mb-3">2. </h3>
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{['모듈', '패키지명', '기능', '주요 연계'].map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
>
{h}
</th>
@ -415,7 +415,7 @@ function TargetArchTab() {
</thead>
<tbody>
{TAB_MODULES.map((row) => (
<tr key={row.name} className="border-b border-stroke-1 hover:bg-bg-surface/50">
<tr key={row.name} className="border-b border-stroke hover:bg-bg-surface/50">
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.module}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.name}</td>
<td className="px-3 py-2 text-t2">{row.feature}</td>
@ -429,7 +429,7 @@ function TargetArchTab() {
{/* 3. RBAC 권한 체계 */}
<section>
<h3 className="text-sm font-semibold text-t1 mb-3">3. RBAC </h3>
<h3 className="text-body-2 font-semibold text-t1 mb-3">3. RBAC </h3>
<div className="flex flex-col gap-3">
{[
{
@ -448,9 +448,9 @@ function TargetArchTab() {
'누가(사용자) / 언제(타임스탬프) / 무엇을(기능) / 어디서(IP, 메뉴) — 모든 주요 작업 자동 기록',
},
].map((item) => (
<div key={item.title} className="bg-bg-card border border-stroke-1 rounded p-3">
<p className="text-xs font-semibold text-t2 mb-1">{item.title}</p>
<p className="text-xs text-t2 leading-relaxed">{item.content}</p>
<div key={item.title} className="bg-bg-card border border-stroke rounded p-3">
<p className="text-caption font-semibold text-t2 mb-1">{item.title}</p>
<p className="text-caption text-t2 leading-relaxed">{item.content}</p>
</div>
))}
</div>
@ -468,17 +468,17 @@ function InterfaceTab() {
<div className="flex flex-col gap-6 p-5">
{/* 1. 외부 시스템 연계 구성도 */}
<section>
<h3 className="text-sm font-semibold text-t1 mb-3">1. </h3>
<h3 className="text-body-2 font-semibold text-t1 mb-3">1. </h3>
<div className="flex items-stretch gap-2">
{/* 외부 시스템 */}
<div className="flex-1 bg-bg-card border border-stroke-1 rounded p-3 flex flex-col gap-1.5">
<p className="text-xs font-semibold text-t2 mb-1 text-center"> </p>
<div className="flex-1 bg-bg-card border border-stroke rounded p-3 flex flex-col gap-1.5">
<p className="text-caption font-semibold text-t2 mb-1 text-center"> </p>
{['KHOA API', '기상청 API', '해경 KBP', 'AIS 선박'].map((item) => (
<div
key={item}
className="bg-bg-elevated border border-stroke-1 rounded px-2 py-1 text-center"
className="bg-bg-elevated border border-stroke rounded px-2 py-1 text-center"
>
<p className="text-xs text-t2">{item}</p>
<p className="text-caption text-t2">{item}</p>
</div>
))}
</div>
@ -488,17 +488,17 @@ function InterfaceTab() {
<span className="text-t3 text-lg"></span>
</div>
{/* 통합지원시스템 */}
<div className="flex-[2] bg-bg-surface border-2 border-cyan-600/40 rounded p-3 flex flex-col gap-2">
<p className="text-xs font-semibold text-t1 text-center">
<div className="flex-[2] bg-bg-surface border-2 border-stroke rounded p-3 flex flex-col gap-2">
<p className="text-caption font-semibold text-t1 text-center">
<br />
</p>
<div className="border border-stroke-1 rounded p-2 bg-bg-elevated">
<p className="text-xs font-medium text-t2 mb-1 text-center"> </p>
<div className="border border-stroke rounded p-2 bg-bg-elevated">
<p className="text-caption font-medium text-t2 mb-1 text-center"> </p>
<div className="flex flex-col gap-1">
{['수집자료 관리', '연계 모니터링', '비식별화 조치'].map((item) => (
<p key={item} className="text-xs text-t3 text-center">
<p key={item} className="text-caption text-t3 text-center">
- {item}
</p>
))}
@ -511,14 +511,14 @@ function InterfaceTab() {
<span className="text-t3 text-lg"></span>
</div>
{/* R&D 시스템 */}
<div className="flex-1 bg-bg-card border border-stroke-1 rounded p-3 flex flex-col gap-1.5">
<p className="text-xs font-semibold text-t2 mb-1 text-center">R&D </p>
<div className="flex-1 bg-bg-card border border-stroke rounded p-3 flex flex-col gap-1.5">
<p className="text-caption font-semibold text-t2 mb-1 text-center">R&D </p>
{['포세이돈', 'KOSPS', '충북대 HNS', '긴급구난'].map((item) => (
<div
key={item}
className="bg-bg-elevated border border-stroke-1 rounded px-2 py-1 text-center"
className="bg-bg-elevated border border-stroke rounded px-2 py-1 text-center"
>
<p className="text-xs text-t2">{item}</p>
<p className="text-caption text-t2">{item}</p>
</div>
))}
</div>
@ -527,15 +527,15 @@ function InterfaceTab() {
{/* 2. 연계 인터페이스 목록 */}
<section>
<h3 className="text-sm font-semibold text-t1 mb-3">2. </h3>
<h3 className="text-body-2 font-semibold text-t1 mb-3">2. </h3>
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{['연계 시스템', '연계 방식', '데이터', '주기', '프로토콜'].map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
>
{h}
</th>
@ -544,7 +544,7 @@ function InterfaceTab() {
</thead>
<tbody>
{INTERFACES.map((row) => (
<tr key={row.system} className="border-b border-stroke-1 hover:bg-bg-surface/50">
<tr key={row.system} className="border-b border-stroke hover:bg-bg-surface/50">
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.system}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.method}</td>
<td className="px-3 py-2 text-t2">{row.data}</td>
@ -559,12 +559,12 @@ function InterfaceTab() {
{/* 3. 데이터 흐름도 */}
<section>
<h3 className="text-sm font-semibold text-t1 mb-3">3. </h3>
<h3 className="text-body-2 font-semibold text-t1 mb-3">3. </h3>
<div className="flex items-center gap-1 flex-wrap">
{dataFlowSteps.map((step, idx) => (
<div key={step} className="flex items-center gap-1">
<div className="bg-bg-elevated border border-stroke-1 rounded px-3 py-2 text-center min-w-16">
<p className="text-xs font-medium text-t1">{step}</p>
<div className="bg-bg-elevated border border-stroke rounded px-3 py-2 text-center min-w-16">
<p className="text-caption font-medium text-t1">{step}</p>
</div>
{idx < dataFlowSteps.length - 1 && (
<span className="text-t3 text-lg shrink-0"></span>
@ -581,9 +581,9 @@ function InterfaceTab() {
{ step: '시각화', desc: 'MapLibre GL + deck.gl 기반 지도 레이어 렌더링' },
{ step: '의사결정지원', desc: '방제작전 시나리오, 구조분석, 경보 발령 지원' },
].map((item) => (
<div key={item.step} className="bg-bg-card border border-stroke-1 rounded p-2.5">
<p className="text-xs font-semibold text-t2 mb-1">{item.step}</p>
<p className="text-xs text-t2 leading-relaxed">{item.desc}</p>
<div key={item.step} className="bg-bg-card border border-stroke rounded p-2.5">
<p className="text-caption font-semibold text-t2 mb-1">{item.step}</p>
<p className="text-caption text-t2 leading-relaxed">{item.desc}</p>
</div>
))}
</div>
@ -591,7 +591,7 @@ function InterfaceTab() {
{/* 4. 연계 장애 대응 */}
<section>
<h3 className="text-sm font-semibold text-t1 mb-3">4. </h3>
<h3 className="text-body-2 font-semibold text-t1 mb-3">4. </h3>
<div className="flex flex-col gap-2">
{[
{
@ -611,9 +611,9 @@ function InterfaceTab() {
content: '개인정보 포함 데이터(해경 KBP 인사 등) 수집 시 자동 비식별화 처리 적용',
},
].map((item) => (
<div key={item.title} className="bg-bg-card border border-stroke-1 rounded p-3">
<p className="text-xs font-semibold text-t2 mb-1">{item.title}</p>
<p className="text-xs text-t2 leading-relaxed">{item.content}</p>
<div key={item.title} className="bg-bg-card border border-stroke rounded p-3">
<p className="text-caption font-semibold text-t2 mb-1">{item.title}</p>
<p className="text-caption text-t2 leading-relaxed">{item.content}</p>
</div>
))}
</div>
@ -837,17 +837,17 @@ function HeterogeneousTab() {
<div className="p-5 space-y-6">
{/* 1. 이기종시스템 연계 개요 */}
<section>
<h3 className="text-sm font-semibold text-t1 mb-3">1. </h3>
<p className="text-xs text-t2 leading-relaxed mb-4">
<h3 className="text-body-2 font-semibold text-t1 mb-3">1. </h3>
<p className="text-caption text-t2 leading-relaxed mb-4">
Fortran, Python, C++, Java
. REST API , ETL , ·
, · .
</p>
<div className="flex items-stretch gap-2">
<div className="flex-1 bg-bg-elevated border border-stroke-1 rounded p-3 text-center">
<p className="text-xs font-semibold text-t2 mb-2"> </p>
<div className="flex-1 bg-bg-elevated border border-stroke rounded p-3 text-center">
<p className="text-caption font-semibold text-t2 mb-2"> </p>
{['Fortran KOSPS', 'Python/C++ 충북대', 'Java 해경KBP', 'NetCDF HYCOM'].map((item) => (
<p key={item} className="text-xs text-t3 leading-relaxed">
<p key={item} className="text-caption text-t3 leading-relaxed">
{item}
</p>
))}
@ -856,10 +856,10 @@ function HeterogeneousTab() {
<span className="text-t3 text-lg"></span>
<span className="text-t3 text-lg"></span>
</div>
<div className="flex-1 bg-cyan-600/10 border border-cyan-500/30 rounded p-3 text-center">
<p className="text-xs font-semibold text-t2 mb-2"> </p>
<div className="flex-1 bg-[rgba(6,182,212,0.1)] border border-stroke rounded p-3 text-center">
<p className="text-caption font-semibold text-t2 mb-2"> </p>
{['REST API 래퍼', 'ETL 전처리', '프로토콜 변환', '인증 통합'].map((item) => (
<p key={item} className="text-xs text-t3 leading-relaxed">
<p key={item} className="text-caption text-t3 leading-relaxed">
{item}
</p>
))}
@ -868,10 +868,10 @@ function HeterogeneousTab() {
<span className="text-t3 text-lg"></span>
<span className="text-t3 text-lg"></span>
</div>
<div className="flex-1 bg-bg-elevated border border-stroke-1 rounded p-3 text-center">
<p className="text-xs font-semibold text-t2 mb-2"></p>
<div className="flex-1 bg-bg-elevated border border-stroke rounded p-3 text-center">
<p className="text-caption font-semibold text-t2 mb-2"></p>
{['Express REST API', 'PostgreSQL+PostGIS', 'React SPA', '표준 JSON'].map((item) => (
<p key={item} className="text-xs text-t3 leading-relaxed">
<p key={item} className="text-caption text-t3 leading-relaxed">
{item}
</p>
))}
@ -881,18 +881,18 @@ function HeterogeneousTab() {
{/* 2. 이기종 시스템 간의 연계 방안 */}
<section>
<h3 className="text-sm font-semibold text-t1 mb-3">2. </h3>
<h3 className="text-body-2 font-semibold text-t1 mb-3">2. </h3>
<div className="flex flex-col gap-2">
{INTEGRATION_PLANS.map((item, idx) => (
<div key={item.title} className="bg-bg-card border border-stroke-1 rounded p-3">
<p className="text-xs font-semibold text-t2 mb-1">
<div key={item.title} className="bg-bg-card border border-stroke rounded p-3">
<p className="text-caption font-semibold text-t2 mb-1">
{idx + 1}. {item.title}
</p>
<p className="text-xs text-t2 leading-relaxed">{item.description}</p>
<p className="text-caption text-t2 leading-relaxed">{item.description}</p>
{item.details && (
<ul className="mt-1.5 flex flex-col gap-1 pl-3">
{item.details.map((detail) => (
<li key={detail} className="text-xs text-t3 leading-relaxed list-disc">
<li key={detail} className="text-caption text-t3 leading-relaxed list-disc">
{detail}
</li>
))}
@ -905,15 +905,15 @@ function HeterogeneousTab() {
{/* 3. 연계 대상 이기종 시스템 목록 */}
<section>
<h3 className="text-sm font-semibold text-t1 mb-3">3. </h3>
<h3 className="text-body-2 font-semibold text-t1 mb-3">3. </h3>
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{['시스템', '구현 언어', 'OS', '위치', '연계 프로토콜', '연계 설명'].map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
>
{h}
</th>
@ -922,7 +922,7 @@ function HeterogeneousTab() {
</thead>
<tbody>
{HETEROGENEOUS_SYSTEMS.map((row) => (
<tr key={row.system} className="border-b border-stroke-1 hover:bg-bg-surface/50">
<tr key={row.system} className="border-b border-stroke hover:bg-bg-surface/50">
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.system}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.lang}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.os}</td>
@ -938,16 +938,16 @@ function HeterogeneousTab() {
{/* 4. 이기종 연계 전략 */}
<section>
<h3 className="text-sm font-semibold text-t1 mb-3">4. </h3>
<h3 className="text-body-2 font-semibold text-t1 mb-3">4. </h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{HETEROGENEOUS_STRATEGIES.map((card) => (
<div key={card.challenge} className="bg-bg-card border border-stroke-1 rounded p-3">
<div key={card.challenge} className="bg-bg-card border border-stroke rounded p-3">
<div className="flex items-center gap-1.5 mb-1.5">
<span className="text-xs font-semibold text-red-400">{card.challenge}</span>
<span className="text-t3 text-xs"></span>
<span className="text-xs font-semibold text-cyan-400">{card.solution}</span>
<span className="text-caption font-semibold text-color-danger">{card.challenge}</span>
<span className="text-t3 text-caption"></span>
<span className="text-caption font-semibold text-color-accent">{card.solution}</span>
</div>
<p className="text-xs text-t2 leading-relaxed">{card.description}</p>
<p className="text-caption text-t2 leading-relaxed">{card.description}</p>
</div>
))}
</div>
@ -955,12 +955,12 @@ function HeterogeneousTab() {
{/* 5. 이기종 데이터 변환 흐름 */}
<section>
<h3 className="text-sm font-semibold text-t1 mb-3">5. </h3>
<h3 className="text-body-2 font-semibold text-t1 mb-3">5. </h3>
<div className="flex items-center gap-1 flex-wrap">
{HETEROGENEOUS_FLOW_STEPS.map((step, idx) => (
<div key={step} className="flex items-center gap-1">
<div className="bg-bg-elevated border border-stroke-1 rounded px-3 py-2 text-center min-w-16">
<p className="text-xs font-medium text-t1">{step}</p>
<div className="bg-bg-elevated border border-stroke rounded px-3 py-2 text-center min-w-16">
<p className="text-caption font-medium text-t1">{step}</p>
</div>
{idx < HETEROGENEOUS_FLOW_STEPS.length - 1 && (
<span className="text-t3 text-lg shrink-0"></span>
@ -972,14 +972,14 @@ function HeterogeneousTab() {
{/* 6. 이기종 연계 보안 정책 */}
<section>
<h3 className="text-sm font-semibold text-t1 mb-3">6. </h3>
<h3 className="text-body-2 font-semibold text-t1 mb-3">6. </h3>
<div className="grid grid-cols-3 gap-3">
{HETEROGENEOUS_SECURITY.map((card) => (
<div key={card.title} className="bg-bg-card border border-stroke-1 rounded p-3">
<p className="text-xs font-semibold text-t2 mb-2">{card.title}</p>
<div key={card.title} className="bg-bg-card border border-stroke rounded p-3">
<p className="text-caption font-semibold text-t2 mb-2">{card.title}</p>
<ul className="flex flex-col gap-1">
{card.items.map((item) => (
<li key={item} className="text-xs text-t2 leading-relaxed">
<li key={item} className="text-caption text-t2 leading-relaxed">
· {item}
</li>
))}
@ -1065,7 +1065,7 @@ const COMMON_FEATURES: CommonFeatureItem[] = [
description: 'Tailwind CSS @layer 아키텍처 + CSS 변수 디자인 시스템',
details: [
'@layer base → components → wing 3단계 CSS 계층 구조',
'CSS 변수 기반 시맨틱 컬러 (bg-bg-base, text-t1, border-stroke-1 등)',
'CSS 변수 기반 시맨틱 컬러 (bg-bg-base, text-t1, border-stroke 등)',
'다크 모드 기본 적용 — CSS 변수 전환으로 테마 일괄 변경',
'인라인 스타일 지양, Tailwind 유틸리티 클래스 우선',
],
@ -1509,8 +1509,8 @@ const FEATURE_MATRIX: FeatureMatrixRow[] = [
];
const CATEGORY_STYLES: Record<string, string> = {
: 'bg-cyan-600/20 text-cyan-300',
: 'bg-emerald-600/20 text-emerald-300',
: 'bg-[rgba(6,182,212,0.2)] text-color-accent',
: 'bg-[rgba(34,197,94,0.2)] text-color-success',
: 'bg-bg-elevated text-t3',
};
@ -1521,8 +1521,8 @@ function CommonFeaturesTab() {
<div className="flex flex-col gap-6 p-5">
{/* 1. 방제대응 프로세스 */}
<section>
<h3 className="text-sm font-semibold text-t1 mb-3">1. </h3>
<p className="text-xs text-t2 leading-relaxed mb-4">
<h3 className="text-body-2 font-semibold text-t1 mb-3">1. </h3>
<p className="text-caption text-t2 leading-relaxed mb-4">
,
.
</p>
@ -1530,11 +1530,11 @@ function CommonFeaturesTab() {
<div className="flex items-start gap-1 flex-wrap mb-4">
{RESPONSE_PROCESS.map((step, idx) => (
<div key={step.phase} className="flex items-start gap-1">
<div className="bg-bg-elevated border border-stroke-1 rounded px-3 py-2 text-center min-w-20">
<p className="text-xs font-semibold text-t1 mb-1">{step.phase}</p>
<div className="bg-bg-elevated border border-stroke rounded px-3 py-2 text-center min-w-20">
<p className="text-caption font-semibold text-t1 mb-1">{step.phase}</p>
<div className="flex flex-col gap-0.5">
{step.modules.map((mod) => (
<span key={mod} className="text-[10px] text-cyan-400">
<span key={mod} className="text-[10px] text-color-accent">
{mod}
</span>
))}
@ -1551,20 +1551,20 @@ function CommonFeaturesTab() {
{RESPONSE_PROCESS.map((step, idx) => (
<div
key={step.phase}
className="bg-bg-card border border-stroke-1 rounded p-3 flex items-start gap-3"
className="bg-bg-card border border-stroke rounded p-3 flex items-start gap-3"
>
<span className="inline-flex items-center justify-center w-5 h-5 rounded bg-cyan-600 text-white text-xs font-semibold shrink-0 mt-0.5">
<span className="inline-flex items-center justify-center w-5 h-5 rounded bg-color-accent text-white text-caption font-semibold shrink-0 mt-0.5">
{idx + 1}
</span>
<div className="flex-1">
<p className="text-xs font-semibold text-t1 mb-0.5">{step.phase}</p>
<p className="text-xs text-t2 leading-relaxed">{step.description}</p>
<p className="text-caption font-semibold text-t1 mb-0.5">{step.phase}</p>
<p className="text-caption text-t2 leading-relaxed">{step.description}</p>
</div>
<div className="flex gap-1 shrink-0">
{step.modules.map((mod) => (
<span
key={mod}
className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-cyan-600/20 text-cyan-300"
className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-[rgba(6,182,212,0.2)] text-color-accent"
>
{mod}
</span>
@ -1577,30 +1577,30 @@ function CommonFeaturesTab() {
{/* 2. 시스템별 기능 유무 매트릭스 */}
<section>
<h3 className="text-sm font-semibold text-t1 mb-3">2. </h3>
<p className="text-xs text-t2 leading-relaxed mb-4">
<h3 className="text-body-2 font-semibold text-t1 mb-3">2. </h3>
<p className="text-caption text-t2 leading-relaxed mb-4">
( ) , (, )
.{' '}
<span className="text-cyan-400 font-medium"> </span>
<span className="text-color-accent font-medium"> </span>
.
</p>
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 tracking-wide">
<th className="px-2 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap sticky left-0 bg-bg-elevated z-10">
<th className="px-2 py-2 text-left font-medium border-b border-stroke whitespace-nowrap sticky left-0 bg-bg-elevated z-10">
</th>
<th className="px-2 py-2 text-center font-medium border-b border-stroke-1 whitespace-nowrap">
<th className="px-2 py-2 text-center font-medium border-b border-stroke whitespace-nowrap">
</th>
<th className="px-2 py-2 text-center font-medium border-b border-stroke-1 whitespace-nowrap">
<th className="px-2 py-2 text-center font-medium border-b border-stroke whitespace-nowrap">
</th>
{SYSTEM_MODULES.map((mod) => (
<th
key={mod}
className="px-1.5 py-2 text-center font-medium border-b border-stroke-1 whitespace-nowrap"
className="px-1.5 py-2 text-center font-medium border-b border-stroke whitespace-nowrap"
>
<span className="writing-mode-vertical text-[10px]">{mod}</span>
</th>
@ -1609,7 +1609,7 @@ function CommonFeaturesTab() {
</thead>
<tbody>
{FEATURE_MATRIX.map((row) => (
<tr key={row.feature} className="border-b border-stroke-1 hover:bg-bg-surface/50">
<tr key={row.feature} className="border-b border-stroke hover:bg-bg-surface/50">
<td className="px-2 py-1.5 font-medium text-t1 whitespace-nowrap sticky left-0 bg-bg-base z-10">
{row.feature}
</td>
@ -1622,7 +1622,7 @@ function CommonFeaturesTab() {
</td>
<td className="px-2 py-1.5 text-center">
{row.integrated ? (
<span className="text-cyan-400 font-semibold"></span>
<span className="text-color-accent font-semibold"></span>
) : (
<span className="text-t3"></span>
)}
@ -1630,7 +1630,7 @@ function CommonFeaturesTab() {
{SYSTEM_MODULES.map((mod) => (
<td key={mod} className="px-1.5 py-1.5 text-center">
{row.systems[mod] ? (
<span className="text-emerald-400 font-bold">O</span>
<span className="text-color-success font-bold">O</span>
) : (
<span className="text-t3/30">-</span>
)}
@ -1644,42 +1644,42 @@ function CommonFeaturesTab() {
{/* 범례 */}
<div className="flex gap-4 mt-3">
<div className="flex items-center gap-1.5">
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-cyan-600/20 text-cyan-300">
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-[rgba(6,182,212,0.2)] text-color-accent">
</span>
<span className="text-xs text-t3"> </span>
<span className="text-caption text-t3"> </span>
</div>
<div className="flex items-center gap-1.5">
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-emerald-600/20 text-emerald-300">
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-[rgba(34,197,94,0.2)] text-color-success">
</span>
<span className="text-xs text-t3">··· </span>
<span className="text-caption text-t3">··· </span>
</div>
<div className="flex items-center gap-1.5">
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-bg-elevated text-t3">
</span>
<span className="text-xs text-t3"> </span>
<span className="text-caption text-t3"> </span>
</div>
</div>
</section>
{/* 3. 공통기능 상세 */}
<section>
<h3 className="text-sm font-semibold text-t1 mb-3">3. </h3>
<h3 className="text-body-2 font-semibold text-t1 mb-3">3. </h3>
<div className="flex flex-col gap-3">
{COMMON_FEATURES.map((feature, idx) => (
<div key={feature.title} className="bg-bg-card border border-stroke-1 rounded p-3">
<div key={feature.title} className="bg-bg-card border border-stroke rounded p-3">
<div className="flex items-center gap-2 mb-1.5">
<span className="inline-flex items-center justify-center w-5 h-5 rounded bg-cyan-600 text-white text-xs font-semibold shrink-0">
<span className="inline-flex items-center justify-center w-5 h-5 rounded bg-color-accent text-white text-caption font-semibold shrink-0">
{idx + 1}
</span>
<p className="text-xs font-semibold text-t1">{feature.title}</p>
<p className="text-caption font-semibold text-t1">{feature.title}</p>
</div>
<p className="text-xs text-t2 leading-relaxed mb-2 pl-7">{feature.description}</p>
<p className="text-caption text-t2 leading-relaxed mb-2 pl-7">{feature.description}</p>
<ul className="flex flex-col gap-1 pl-7">
{feature.details.map((detail) => (
<li key={detail} className="text-xs text-t3 leading-relaxed list-disc">
<li key={detail} className="text-caption text-t3 leading-relaxed list-disc">
{detail}
</li>
))}
@ -1691,15 +1691,15 @@ function CommonFeaturesTab() {
{/* 4. 공통 모듈 구조 */}
<section>
<h3 className="text-sm font-semibold text-t1 mb-3">4. </h3>
<h3 className="text-body-2 font-semibold text-t1 mb-3">4. </h3>
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{['디렉토리', '역할', '주요 파일'].map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
>
{h}
</th>
@ -1746,7 +1746,7 @@ function CommonFeaturesTab() {
{ dir: 'common/constants/', role: '상수 정의', files: 'featureIds.ts' },
{ dir: 'common/data/', role: 'UI 데이터', files: 'layerData.ts (레이어 트리)' },
].map((row) => (
<tr key={row.dir} className="border-b border-stroke-1 hover:bg-bg-surface/50">
<tr key={row.dir} className="border-b border-stroke hover:bg-bg-surface/50">
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap font-mono">
{row.dir}
</td>
@ -1770,19 +1770,19 @@ export default function SystemArchPanel() {
return (
<div className="flex flex-col h-full overflow-hidden">
{/* 헤더 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1 shrink-0">
<h2 className="text-sm font-semibold text-t1"></h2>
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke shrink-0">
<h2 className="text-body-2 font-semibold text-t1"></h2>
</div>
{/* 탭 버튼 */}
<div className="flex gap-1.5 px-5 py-2.5 border-b border-stroke-1 shrink-0 bg-bg-base">
<div className="flex gap-1.5 px-5 py-2.5 border-b border-stroke shrink-0 bg-bg-base">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
className={`px-3 py-1.5 text-caption font-medium rounded transition-colors ${
activeTab === tab.id
? 'bg-cyan-600 text-white'
? 'bg-color-accent text-white'
: 'bg-bg-elevated text-t2 hover:bg-bg-card'
}`}
>

파일 보기

@ -108,7 +108,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
{/* 계정 */}
<div>
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
<span className="text-red-400">*</span>
<span className="text-color-danger">*</span>
</label>
<input
type="text"
@ -122,7 +122,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
{/* 비밀번호 */}
<div>
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
<span className="text-red-400">*</span>
<span className="text-color-danger">*</span>
</label>
<input
type="password"
@ -136,7 +136,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
{/* 사용자명 */}
<div>
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
<span className="text-red-400">*</span>
<span className="text-color-danger">*</span>
</label>
<input
type="text"
@ -209,7 +209,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
return (
<label
key={role.sn}
className="flex items-center gap-2 px-2 py-1.5 hover:bg-[rgba(255,255,255,0.04)] rounded cursor-pointer"
className="flex items-center gap-2 px-2 py-1.5 hover:bg-[rgba(6,182,212,0.08)] rounded cursor-pointer"
>
<input
type="checkbox"
@ -229,7 +229,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
</div>
{/* 에러 메시지 */}
{error && <p className="text-label-2 text-red-400 font-korean">{error}</p>}
{error && <p className="text-label-2 text-color-danger font-korean">{error}</p>}
</div>
{/* 푸터 */}
@ -237,7 +237,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-caption border border-stroke text-fg-sub rounded-md hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
className="px-4 py-2 text-caption border border-stroke text-fg-sub rounded-md hover:bg-[rgba(6,182,212,0.08)] transition-all font-korean"
>
</button>
@ -433,14 +433,14 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
<button
onClick={handleResetPassword}
disabled={resetPwLoading || !newPassword.trim()}
className="px-4 py-1.5 text-label-2 font-semibold rounded-md border border-yellow-400 text-yellow-400 hover:bg-[rgba(250,204,21,0.1)] transition-all disabled:opacity-50 font-korean flex-shrink-0"
className="px-4 py-1.5 text-label-2 font-semibold rounded-md border border-color-caution text-color-caution hover:bg-[rgba(234,179,8,0.12)] transition-all disabled:opacity-50 font-korean flex-shrink-0"
>
{resetPwLoading ? '초기화 중...' : resetPwDone ? '초기화 완료' : '비밀번호 초기화'}
</button>
<button
onClick={handleUnlock}
disabled={unlockLoading || user.status !== 'LOCKED'}
className="px-4 py-1.5 text-label-2 font-semibold rounded-md border border-green-400 text-green-400 hover:bg-[rgba(74,222,128,0.1)] transition-all disabled:opacity-50 font-korean flex-shrink-0"
className="px-4 py-1.5 text-label-2 font-semibold rounded-md border border-color-success text-color-success hover:bg-[rgba(34,197,94,0.12)] transition-all disabled:opacity-50 font-korean flex-shrink-0"
title={user.status !== 'LOCKED' ? '잠금 상태가 아닙니다' : ''}
>
{unlockLoading ? '해제 중...' : '패스워드잠금해제'}
@ -469,7 +469,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
{(statusLabels[user.status] || statusLabels.INACTIVE).label}
</span>
{user.failCount > 0 && (
<span className="text-caption text-red-400 font-korean">
<span className="text-caption text-color-danger font-korean">
( {user.failCount})
</span>
)}
@ -484,7 +484,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
<button
onClick={handleUnlock}
disabled={unlockLoading}
className="px-4 py-1.5 text-label-2 font-semibold rounded-md border border-green-400 text-green-400 hover:bg-[rgba(74,222,128,0.1)] transition-all disabled:opacity-50 font-korean flex-shrink-0"
className="px-4 py-1.5 text-label-2 font-semibold rounded-md border border-color-success text-color-success hover:bg-[rgba(34,197,94,0.12)] transition-all disabled:opacity-50 font-korean flex-shrink-0"
>
{unlockLoading ? '해제 중...' : '잠금 해제'}
</button>
@ -522,8 +522,8 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
<div
className={`px-3 py-2 text-label-2 rounded-md font-korean ${
message.type === 'success'
? 'text-green-400 bg-[rgba(74,222,128,0.08)] border border-[rgba(74,222,128,0.2)]'
: 'text-red-400 bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.2)]'
? 'text-color-success bg-[rgba(34,197,94,0.08)] border border-[rgba(34,197,94,0.3)]'
: 'text-color-danger bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.2)]'
}`}
>
{message.text}
@ -535,7 +535,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
<div className="flex items-center justify-end px-6 py-3 border-t border-stroke">
<button
onClick={onClose}
className="px-4 py-2 text-caption border border-stroke text-fg-sub rounded-md hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
className="px-4 py-2 text-caption border border-stroke text-fg-sub rounded-md hover:bg-[rgba(6,182,212,0.08)] transition-all font-korean"
>
</button>
@ -685,7 +685,7 @@ function UsersPanel() {
</p>
</div>
{pendingCount > 0 && (
<span className="px-2.5 py-1 text-caption font-bold rounded-full bg-[rgba(250,204,21,0.15)] text-yellow-400 border border-[rgba(250,204,21,0.3)] animate-pulse font-korean">
<span className="px-2.5 py-1 text-caption font-bold rounded-full bg-[rgba(234,179,8,0.15)] text-color-caution border border-[rgba(234,179,8,0.3)] animate-pulse font-korean">
{pendingCount}
</span>
)}
@ -747,31 +747,31 @@ function UsersPanel() {
<table className="w-full">
<thead>
<tr className="border-b border-stroke bg-bg-surface">
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean w-10">
<th className="px-4 py-3 text-left text-caption font-semibold text-fg-disabled font-korean w-10">
</th>
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-mono">
<th className="px-4 py-3 text-left text-caption font-semibold text-fg-disabled font-mono">
ID
</th>
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean">
<th className="px-4 py-3 text-left text-caption font-semibold text-fg-disabled font-korean">
</th>
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean">
<th className="px-4 py-3 text-left text-caption font-semibold text-fg-disabled font-korean">
</th>
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean">
<th className="px-4 py-3 text-left text-caption font-semibold text-fg-disabled font-korean">
</th>
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean">
<th className="px-4 py-3 text-left text-caption font-semibold text-fg-disabled font-korean">
</th>
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean">
<th className="px-4 py-3 text-left text-caption font-semibold text-fg-disabled font-korean">
</th>
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean">
<th className="px-4 py-3 text-left text-caption font-semibold text-fg-disabled font-korean">
</th>
<th className="px-4 py-3 text-right text-label-2 font-semibold text-fg-disabled font-korean">
<th className="px-4 py-3 text-right text-caption font-semibold text-fg-disabled font-korean">
</th>
</tr>
@ -793,15 +793,15 @@ function UsersPanel() {
return (
<tr
key={user.id}
className="border-b border-stroke hover:bg-[rgba(255,255,255,0.02)] transition-colors"
className="border-b border-stroke hover:bg-[rgba(6,182,212,0.04)] transition-colors"
>
{/* 번호 */}
<td className="px-4 py-3 text-label-2 text-fg-disabled font-mono text-center">
<td className="px-4 py-3 text-caption text-fg-disabled font-mono text-center">
{rowNum}
</td>
{/* ID(account) */}
<td className="px-4 py-3 text-label-1 text-fg-sub font-mono">
<td className="px-4 py-3 text-caption text-fg-sub font-mono">
{user.account}
</td>
@ -809,24 +809,24 @@ function UsersPanel() {
<td className="px-4 py-3">
<button
onClick={() => setDetailUser(user)}
className="text-label-1 text-color-accent font-semibold font-korean hover:underline"
className="text-caption text-color-accent font-semibold font-korean hover:underline"
>
{user.name}
</button>
</td>
{/* 직급 */}
<td className="px-4 py-3 text-label-1 text-fg-sub font-korean">
<td className="px-4 py-3 text-caption text-fg-sub font-korean">
{user.rank || '-'}
</td>
{/* 소속 */}
<td className="px-4 py-3 text-label-1 text-fg-sub font-korean">
<td className="px-4 py-3 text-caption text-fg-sub font-korean">
{user.orgAbbr || user.orgName || '-'}
</td>
{/* 이메일 */}
<td className="px-4 py-3 text-label-2 text-fg-disabled font-mono">
<td className="px-4 py-3 text-caption text-fg-disabled font-mono">
{user.email || '-'}
</td>
@ -839,19 +839,13 @@ function UsersPanel() {
title="클릭하여 역할 변경"
>
{user.roles.length > 0 ? (
user.roles.map((roleCode, roleIdx) => {
const color = getRoleColor(roleCode, roleIdx);
user.roles.map((roleCode) => {
const roleName =
allRoles.find((r) => r.code === roleCode)?.name || roleCode;
return (
<span
key={roleCode}
className="px-2 py-0.5 text-caption font-semibold rounded-md font-korean"
style={{
background: `${color}20`,
color: color,
border: `1px solid ${color}40`,
}}
className="px-2 py-0.5 text-caption font-semibold rounded-md font-korean text-fg-sub bg-bg-elevated border border-stroke-light"
>
{roleName}
</span>
@ -889,7 +883,7 @@ function UsersPanel() {
return (
<label
key={role.sn}
className="flex items-center gap-2 px-2 py-1.5 hover:bg-[rgba(255,255,255,0.04)] rounded cursor-pointer"
className="flex items-center gap-2 px-2 py-1.5 hover:bg-[rgba(6,182,212,0.08)] rounded cursor-pointer"
>
<input
type="checkbox"
@ -943,13 +937,13 @@ function UsersPanel() {
<>
<button
onClick={() => handleApprove(user.id)}
className="px-2 py-1 text-caption font-semibold text-green-400 border border-green-400 rounded hover:bg-[rgba(74,222,128,0.1)] transition-all font-korean"
className="px-2 py-1 text-caption font-semibold text-color-success border border-color-success rounded hover:bg-[rgba(34,197,94,0.12)] transition-all font-korean"
>
</button>
<button
onClick={() => handleReject(user.id)}
className="px-2 py-1 text-caption font-semibold text-red-400 border border-red-400 rounded hover:bg-[rgba(248,113,113,0.1)] transition-all font-korean"
className="px-2 py-1 text-caption font-semibold text-color-danger border border-color-danger rounded hover:bg-[rgba(239,68,68,0.12)] transition-all font-korean"
>
</button>
@ -958,7 +952,7 @@ function UsersPanel() {
{user.status === 'LOCKED' && (
<button
onClick={() => handleUnlock(user.id)}
className="px-2 py-1 text-caption font-semibold text-yellow-400 border border-yellow-400 rounded hover:bg-[rgba(250,204,21,0.1)] transition-all font-korean"
className="px-2 py-1 text-caption font-semibold text-color-caution border border-color-caution rounded hover:bg-[rgba(234,179,8,0.12)] transition-all font-korean"
>
</button>
@ -966,7 +960,7 @@ function UsersPanel() {
{user.status === 'ACTIVE' && (
<button
onClick={() => handleDeactivate(user.id)}
className="px-2 py-1 text-caption font-semibold text-fg-disabled border border-stroke rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
className="px-2 py-1 text-caption font-semibold text-fg-disabled border border-stroke rounded hover:bg-[rgba(6,182,212,0.08)] transition-all font-korean"
>
</button>
@ -974,7 +968,7 @@ function UsersPanel() {
{(user.status === 'INACTIVE' || user.status === 'REJECTED') && (
<button
onClick={() => handleActivate(user.id)}
className="px-2 py-1 text-caption font-semibold text-green-400 border border-green-400 rounded hover:bg-[rgba(74,222,128,0.1)] transition-all font-korean"
className="px-2 py-1 text-caption font-semibold text-color-success border border-color-success rounded hover:bg-[rgba(34,197,94,0.12)] transition-all font-korean"
>
</button>
@ -1001,7 +995,7 @@ function UsersPanel() {
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="px-2.5 py-1 text-label-2 border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
className="px-2.5 py-1 text-label-2 border border-stroke text-fg-disabled rounded hover:bg-[rgba(6,182,212,0.08)] disabled:opacity-40 transition-all font-korean"
>
</button>
@ -1045,7 +1039,7 @@ function UsersPanel() {
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="px-2.5 py-1 text-label-2 border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
className="px-2.5 py-1 text-label-2 border border-stroke text-fg-disabled rounded hover:bg-[rgba(6,182,212,0.08)] disabled:opacity-40 transition-all font-korean"
>
</button>

파일 보기

@ -10,18 +10,10 @@ interface SignalSlot {
}
// ─── 상수 ──────────────────────────────────────────────────
const SOURCE_COLORS: Record<SignalSource, string> = {
VTS: '#3b82f6',
'VTS-AIS': '#a855f7',
'V-PASS': '#22c55e',
'E-NAVI': '#f97316',
'S&P AIS': '#ec4899',
};
const STATUS_COLOR: Record<string, string> = {
ok: '#22c55e',
warn: '#eab308',
error: '#ef4444',
ok: 'var(--color-success)',
warn: 'var(--color-caution)',
error: 'var(--color-danger)',
none: 'rgba(255,255,255,0.06)',
};
@ -172,7 +164,6 @@ export default function VesselSignalPanel() {
{/* 시간축 높이 맞춤 빈칸 */}
<div className="h-5 mb-3" />
{SIGNAL_SOURCES.map((src) => {
const c = SOURCE_COLORS[src];
const st = stats.find((s) => s.src === src)!;
return (
<div
@ -180,10 +171,10 @@ export default function VesselSignalPanel() {
className="flex flex-col justify-center mb-4"
style={{ height: 20 }}
>
<span className="text-label-1 font-semibold leading-tight" style={{ color: c }}>
<span className="text-label-1 font-semibold leading-tight text-fg">
{src}
</span>
<span className="text-caption font-mono text-text-4 mt-0.5">{st.rate}%</span>
<span className="text-caption font-mono text-fg-sub mt-0.5">{st.rate}%</span>
</div>
);
})}

파일 보기

@ -39,7 +39,7 @@ interface HNSLeftPanelProps {
incidentCoord: { lon: number; lat: number } | null;
onCoordChange: (coord: { lon: number; lat: number } | null) => void;
onMapSelectClick: () => void;
onRunPrediction: () => void;
onRunPrediction: (params?: HNSInputParams) => void;
isRunningPrediction: boolean;
onParamsChange?: (params: HNSInputParams) => void;
onReset?: () => void;
@ -163,26 +163,30 @@ export function HNSLeftPanel({
}
};
// 현재 폼 state → HNSInputParams 스냅샷 (useEffect + 버튼 onClick 공용)
const buildCurrentParams = (): HNSInputParams => ({
substance,
releaseType,
emissionRate: parseFloat(emissionRate) || tox.Q,
totalRelease: parseFloat(totalRelease) || tox.QTotal,
releaseHeight: parseFloat(releaseHeight) || 0.5,
releaseDuration: parseFloat(releaseDuration) || 300,
poolRadius: parseFloat(poolRadius) || tox.poolRadius,
algorithm,
criteriaModel,
weather,
accidentDate,
accidentTime,
predictionTime,
accidentName,
});
// 파라미터 변경 시 부모에 통지
useEffect(() => {
if (onParamsChange) {
onParamsChange({
substance,
releaseType,
emissionRate: parseFloat(emissionRate) || tox.Q,
totalRelease: parseFloat(totalRelease) || tox.QTotal,
releaseHeight: parseFloat(releaseHeight) || 0.5,
releaseDuration: parseFloat(releaseDuration) || 300,
poolRadius: parseFloat(poolRadius) || tox.poolRadius,
algorithm,
criteriaModel,
weather,
accidentDate,
accidentTime,
predictionTime,
accidentName,
});
onParamsChange(buildCurrentParams());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
substance,
releaseType,
@ -709,7 +713,7 @@ export function HNSLeftPanel({
<button
className="prd-btn pri"
style={{ padding: '7px', fontSize: 'var(--font-size-label-2)' }}
onClick={onRunPrediction}
onClick={() => onRunPrediction(buildCurrentParams())}
disabled={isRunningPrediction}
>
{isRunningPrediction ? '⏳ 실행 중...' : '대기확산 예측 실행'}

파일 보기

@ -189,12 +189,7 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit, currentParams }: HNS
</button>
<button
onClick={handleRun}
className="flex-[2] py-2.5 text-label-1 font-bold rounded-md text-white"
style={{
cursor: 'pointer',
background: 'linear-gradient(135deg, var(--color-accent), #ef4444)',
border: 'none',
}}
className="flex-[2] py-2.5 text-label-1 font-bold rounded-md cursor-pointer transition-colors bg-bg-card border border-stroke text-fg-sub hover:bg-[rgba(6,182,212,0.15)] hover:border-[rgba(6,182,212,0.3)] hover:text-color-accent"
>
🔄
</button>

파일 보기

@ -197,6 +197,33 @@ function HNSManualViewer() {
);
}
/** 히트맵 포인트에서 지도 자동 줌용 바운드 산정 (15% 패딩) */
function computeBoundsFromHeatmap(
points: { lon: number; lat: number; concentration: number }[],
threshold = 0.01,
): { north: number; south: number; east: number; west: number } | null {
const filtered = points.filter((p) => p.concentration > threshold);
if (filtered.length === 0) return null;
let minLon = Infinity,
maxLon = -Infinity,
minLat = Infinity,
maxLat = -Infinity;
for (const p of filtered) {
if (p.lon < minLon) minLon = p.lon;
if (p.lon > maxLon) maxLon = p.lon;
if (p.lat < minLat) minLat = p.lat;
if (p.lat > maxLat) maxLat = p.lat;
}
const padLon = (maxLon - minLon) * 0.15 || 0.005;
const padLat = (maxLat - minLat) * 0.15 || 0.005;
return {
west: minLon - padLon,
east: maxLon + padLon,
south: minLat - padLat,
north: maxLat + padLat,
};
}
/* ─── 메인 HNSView ─── */
export function HNSView() {
const { activeSubTab, setActiveSubTab } = useSubMenu('hns');
@ -205,6 +232,12 @@ export function HNSView() {
const [rightCollapsed, setRightCollapsed] = useState(false);
const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null);
const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined);
const [fitBoundsTarget, setFitBoundsTarget] = useState<{
north: number;
south: number;
east: number;
west: number;
} | null>(null);
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
const vessels = useVesselSignals(mapBounds);
@ -307,11 +340,14 @@ export function HNSView() {
poolRadius: params?.poolRadius ?? tox.poolRadius,
};
// 동적 sim 도메인 — 풍속 기반 특성 거리 L 산정 (10km~50km)
const advectMax = meteo.windSpeed * 3600; // 1시간 이류 거리
const L = Math.max(10_000, Math.min(50_000, advectMax));
const sim: SimParams = {
xRange: [-100, 10000],
yRange: [-2000, 2000],
nx: 300,
ny: 200,
xRange: [-L * 0.05, L],
yRange: [-L * 0.4, L * 0.4],
nx: 400,
ny: 320,
zRef: 1.5,
tStart: 0,
tEnd: 600,
@ -447,6 +483,7 @@ export function HNSView() {
angle: meteo.windDirDeg,
},
].filter((z) => z.radius > 0),
contours: resultForZones.contours,
timestamp: new Date().toISOString(),
windDirection: meteo.windDirDeg,
substance: substanceName,
@ -458,6 +495,10 @@ export function HNSView() {
maxConcentration: resultForZones.maxConcentration,
});
// 지도 자동 줌 — 확산 영역 전체를 화면에 맞춤
const bounds = computeBoundsFromHeatmap(resultForZones.points);
if (bounds) setFitBoundsTarget(bounds);
// 2. 분석 레코드 DB 저장 (비동기, 실패해도 무시)
try {
const acdntDtm =
@ -654,11 +695,17 @@ export function HNSView() {
alert('좌표 정보가 없어 분석을 실행할 수 없습니다.');
return;
}
// spilUnitCd: 'g' = 순간 유출(totalRelease), 그 외('g/s') = 연속/dense_gas(emissionRate)
const isInstant = analysis.spilUnitCd === 'g';
const releaseType: HNSInputParams['releaseType'] = isInstant ? '순간 유출' : '연속 유출';
const spilQty = analysis.spilQty ?? undefined;
rslt = {
coord,
inputParams: {
substance: analysis.sbstNm ?? '톨루엔 (Toluene)',
totalRelease: analysis.spilQty ?? 5000,
releaseType,
emissionRate: !isInstant ? (spilQty ?? 10) : 10,
totalRelease: isInstant ? (spilQty ?? 5000) : 5000,
algorithm: analysis.algoCd ?? 'ALOHA (EPA)',
criteriaModel: analysis.critMdlCd ?? 'AEGL',
accidentDate: dateStr ?? '',
@ -681,7 +728,7 @@ export function HNSView() {
return;
}
// 좌표 복원
// 좌표 복원 (fit-bounds는 runComputation 후 자동)
setIncidentCoord(savedCoord);
// 입력 파라미터 복원 → HNSLeftPanel에 전달
@ -744,23 +791,24 @@ export function HNSView() {
zones: [
{
level: 'AEGL-3',
color: 'rgba(59,130,246,0.4)',
color: '#ef4444',
radius: resultForZones.aeglDistances.aegl3,
angle: meteo.windDirDeg,
},
{
level: 'AEGL-2',
color: 'rgba(6,182,212,0.3)',
color: '#f97316',
radius: resultForZones.aeglDistances.aegl2,
angle: meteo.windDirDeg,
},
{
level: 'AEGL-1',
color: 'rgba(6,182,212,0.25)',
color: '#eab308',
radius: resultForZones.aeglDistances.aegl1,
angle: meteo.windDirDeg,
},
].filter((z: { radius: number }) => z.radius > 0),
contours: resultForZones.contours,
timestamp: new Date().toISOString(),
windDirection: meteo.windDirDeg,
substance: substanceName,
@ -771,6 +819,10 @@ export function HNSView() {
},
maxConcentration: resultForZones.maxConcentration,
});
// 지도 자동 줌 — 확산 영역 전체를 화면에 맞춤
const bounds = computeBoundsFromHeatmap(resultForZones.points);
if (bounds) setFitBoundsTarget(bounds);
} catch (err) {
console.error('[HNS] 분석 재계산 실패:', err);
}
@ -932,6 +984,7 @@ export function HNSView() {
incidentCoord={incidentCoord ?? undefined}
flyToIncident={flyToCoord}
onIncidentFlyEnd={() => setFlyToCoord(undefined)}
fitBoundsTarget={fitBoundsTarget}
isSelectingLocation={isSelectingLocation}
onMapClick={handleMapClick}
oilTrajectory={[]}
@ -1009,7 +1062,7 @@ export function HNSView() {
// 좌측 패널 UI도 동기화
setLoadedParams(merged);
setInputParams(merged);
// 병합된 파라미터로 즉시 예측 실행
// 병합된 파라미터로 즉시 예측 실행 (handleRunPrediction 내부에서 fitBounds 자동 적용)
handleRunPrediction(merged);
}}
/>

파일 보기

@ -19,6 +19,7 @@ import type {
AeglDistances,
AeglAreas,
AlgorithmType,
ContourLine,
} from './dispersionTypes';
import { getSubstanceToxicity } from './toxicityData';
@ -332,6 +333,7 @@ export function computeDispersion(params: ComputeDispersionParams): DispersionGr
// g/m³ → ppm 변환 + 지도 좌표 변환
const points: DispersionPoint[] = [];
const ppmGrid = new Float64Array(sim.ny * sim.nx);
let maxConcentration = 0;
// AEGL 거리/면적 계산용
@ -350,6 +352,7 @@ export function computeDispersion(params: ComputeDispersionParams): DispersionGr
for (let i = 0; i < sim.nx; i++) {
const idx = j * sim.nx + i;
const ppm = gm3ToPpm(cGm3[idx], tox.mw, meteo.temperature, meteo.pressure);
ppmGrid[idx] = ppm;
if (ppm < 0.01) continue;
@ -391,6 +394,29 @@ export function computeDispersion(params: ComputeDispersionParams): DispersionGr
aegl3: parseFloat(((aegl3Cells * cellAreaM2) / 1e6).toFixed(2)),
};
// AEGL 등농도선 추출 (marching squares)
const contours: ContourLine[] = [];
const aeglLevels: Array<{ level: ContourLine['level']; threshold: number; color: string }> = [
{ level: 'AEGL-3', threshold: tox.aegl3, color: '#ef4444' },
{ level: 'AEGL-2', threshold: tox.aegl2, color: '#f97316' },
{ level: 'AEGL-1', threshold: tox.aegl1, color: '#eab308' },
];
for (const { level, threshold, color } of aeglLevels) {
const segments = extractContourSegments(
ppmGrid,
sim.nx,
sim.ny,
xArr,
yArr,
originLon,
originLat,
threshold,
);
if (segments.length > 0) {
contours.push({ level, threshold, color, segments });
}
}
return {
points,
maxConcentration: parseFloat(maxConcentration.toFixed(2)),
@ -399,5 +425,103 @@ export function computeDispersion(params: ComputeDispersionParams): DispersionGr
modelType,
timeStep: t,
substance: substanceName,
contours,
};
}
/**
* Marching squares로 (iso-contour)
* 2×2 4 threshold (0~15) 16 .
* .
*/
function extractContourSegments(
ppmGrid: Float64Array,
nx: number,
ny: number,
xArr: Float64Array,
yArr: Float64Array,
originLon: number,
originLat: number,
threshold: number,
): Array<[[number, number], [number, number]]> {
const segments: Array<[[number, number], [number, number]]> = [];
if (threshold <= 0) return segments;
for (let j = 0; j < ny - 1; j++) {
for (let i = 0; i < nx - 1; i++) {
const bl = ppmGrid[j * nx + i];
const br = ppmGrid[j * nx + (i + 1)];
const tr = ppmGrid[(j + 1) * nx + (i + 1)];
const tl = ppmGrid[(j + 1) * nx + i];
const mask =
(bl > threshold ? 1 : 0) |
(br > threshold ? 2 : 0) |
(tr > threshold ? 4 : 0) |
(tl > threshold ? 8 : 0);
if (mask === 0 || mask === 15) continue;
const x0 = xArr[i];
const x1 = xArr[i + 1];
const y0 = yArr[j];
const y1 = yArr[j + 1];
const lerp = (a: number, b: number): number => {
const denom = b - a;
if (Math.abs(denom) < 1e-9) return 0.5;
return Math.max(0, Math.min(1, (threshold - a) / denom));
};
// 엣지 교차점 (미터 좌표)
const mLeft: [number, number] = [x0, y0 + lerp(bl, tl) * (y1 - y0)];
const mBottom: [number, number] = [x0 + lerp(bl, br) * (x1 - x0), y0];
const mRight: [number, number] = [x1, y0 + lerp(br, tr) * (y1 - y0)];
const mTop: [number, number] = [x0 + lerp(tl, tr) * (x1 - x0), y1];
const toLL = (p: [number, number]): [number, number] =>
metersToLonLat(p[0], p[1], originLon, originLat);
const push = (a: [number, number], b: [number, number]) => {
segments.push([toLL(a), toLL(b)]);
};
switch (mask) {
case 1:
case 14:
push(mLeft, mBottom);
break;
case 2:
case 13:
push(mBottom, mRight);
break;
case 3:
case 12:
push(mLeft, mRight);
break;
case 4:
case 11:
push(mRight, mTop);
break;
case 5:
push(mLeft, mBottom);
push(mRight, mTop);
break;
case 6:
case 9:
push(mBottom, mTop);
break;
case 7:
case 8:
push(mLeft, mTop);
break;
case 10:
push(mLeft, mTop);
push(mBottom, mRight);
break;
}
}
}
return segments;
}

파일 보기

@ -67,6 +67,15 @@ export interface AeglAreas {
aegl3: number;
}
/** AEGL 등농도선 (marching squares 결과) */
export interface ContourLine {
level: 'AEGL-1' | 'AEGL-2' | 'AEGL-3';
threshold: number; // ppm
color: string; // hex
/** 선분 리스트 — 각 선분은 [lon,lat] 양끝점 */
segments: Array<[[number, number], [number, number]]>;
}
/** 확산 계산 전체 결과 */
export interface DispersionGridResult {
points: DispersionPoint[];
@ -76,6 +85,7 @@ export interface DispersionGridResult {
modelType: DispersionModel;
timeStep: number; // 현재 시간 (s)
substance: string;
contours?: ContourLine[];
}
/** computeDispersion 입력 파라미터 */

파일 보기

@ -386,7 +386,7 @@ function VesselCard({ vessel }: { vessel: BacktrackVessel }) {
</div>
<div style={{ textAlign: 'right' }}>
<div
style={{ fontSize: '22px', color: probColor, lineHeight: 1 }}
style={{ fontSize: 'var(--font-size-heading-3)', color: probColor, lineHeight: 1 }}
className="font-bold font-mono"
>
{vessel.probability}%

파일 보기

@ -53,10 +53,10 @@ const SimulationErrorModal = ({ message, onClose }: SimulationErrorModalProps) =
</svg>
</div>
<div>
<div style={{ color: 'var(--fg-default)', fontSize: 14, fontWeight: 600 }}>
<div style={{ color: 'var(--fg-default)', fontSize: 'var(--font-size-body-2)', fontWeight: 600 }}>
</div>
<div style={{ color: 'var(--fg-disabled)', fontSize: 12, marginTop: 2 }}>
<div style={{ color: 'var(--fg-disabled)', fontSize: 'var(--font-size-caption)', marginTop: 2 }}>
</div>
</div>
@ -70,7 +70,7 @@ const SimulationErrorModal = ({ message, onClose }: SimulationErrorModalProps) =
borderRadius: 'var(--radius-sm)',
padding: '10px 14px',
color: 'rgb(252, 165, 165)',
fontSize: 13,
fontSize: 'var(--font-size-label-1)',
lineHeight: 1.6,
wordBreak: 'break-word',
}}
@ -88,7 +88,7 @@ const SimulationErrorModal = ({ message, onClose }: SimulationErrorModalProps) =
border: '1px solid rgba(239, 68, 68, 0.35)',
borderRadius: 'var(--radius-sm)',
color: 'rgb(252, 165, 165)',
fontSize: 13,
fontSize: 'var(--font-size-label-1)',
fontWeight: 600,
cursor: 'pointer',
transition: 'background 0.15s',

파일 보기

@ -56,10 +56,10 @@ const SimulationLoadingOverlay = ({ status, progress }: SimulationLoadingOverlay
</svg>
</div>
<div>
<div style={{ color: 'var(--fg-default)', fontSize: 14, fontWeight: 600 }}>
<div style={{ color: 'var(--fg-default)', fontSize: 'var(--font-size-body-2)', fontWeight: 600 }}>
</div>
<div style={{ color: 'var(--fg-disabled)', fontSize: 12, marginTop: 2 }}>
<div style={{ color: 'var(--fg-disabled)', fontSize: 'var(--font-size-caption)', marginTop: 2 }}>
{statusText}
</div>
</div>
@ -92,10 +92,10 @@ const SimulationLoadingOverlay = ({ status, progress }: SimulationLoadingOverlay
marginTop: 8,
}}
>
<span style={{ color: 'var(--fg-disabled)', fontSize: 11 }}>
<span style={{ color: 'var(--fg-disabled)', fontSize: 'var(--font-size-caption)' }}>
{status === 'PENDING' ? '대기 중' : '분석 진행 중'}
</span>
<span style={{ color: 'var(--color-accent)', fontSize: 12, fontWeight: 600 }}>
<span style={{ color: 'var(--color-accent)', fontSize: 'var(--font-size-caption)', fontWeight: 600 }}>
{status === 'PENDING' ? '—' : `${displayProgress}%`}
</span>
</div>
@ -105,7 +105,7 @@ const SimulationLoadingOverlay = ({ status, progress }: SimulationLoadingOverlay
<div
style={{
color: 'var(--fg-disabled)',
fontSize: 11,
fontSize: 'var(--font-size-caption)',
lineHeight: 1.6,
borderTop: '1px solid var(--stroke-light)',
paddingTop: 12,

파일 보기

@ -7,6 +7,7 @@ import type { ScatSegment } from './scatTypes';
import type { ApiZoneItem } from '../services/scatApi';
import { esiColor } from './scatConstants';
import { hexToRgba } from '@common/components/map/mapUtils';
import { cn } from '@common/utils/cn';
interface ScatMapProps {
segments: ScatSegment[];
@ -105,6 +106,10 @@ function ScatMap({
}: ScatMapProps) {
const [zoom, setZoom] = useState(10);
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const [collapsed, setCollapsed] = useState<{ esi: boolean; progress: boolean }>({
esi: false,
progress: false,
});
// zones 첫 렌더 기준으로 초기 중심 좌표 결정 (이후 불변)
const [initialCenter] = useState<[number, number]>(() =>
@ -268,7 +273,7 @@ function ScatMap({
)}
{/* Status chips */}
<div className="absolute top-3.5 left-3.5 flex gap-2 z-[1000]">
<div className="absolute top-3.5 left-3.5 flex gap-2 z-20">
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-[color-mix(in_srgb,var(--bg-base)_92%,transparent)] backdrop-blur-xl border border-stroke rounded-full text-label-2 text-fg-sub font-korean">
<span className="w-1.5 h-1.5 rounded-full bg-color-accent shadow-[0_0_6px_var(--color-accent)]" />
Pre-SCAT
@ -279,63 +284,92 @@ function ScatMap({
</div>
{/* Right info cards */}
<div className="absolute top-3.5 right-8 w-[260px] flex flex-col gap-2 z-[1000] max-h-[calc(100%-100px)] overflow-y-auto scrollbar-thin">
<div className="absolute top-3.5 right-8 w-[260px] flex flex-col gap-2 z-20 max-h-[calc(100%-100px)] overflow-y-auto scrollbar-thin">
{/* ESI Legend */}
<div className="bg-[color-mix(in_srgb,var(--bg-base)_92%,transparent)] backdrop-blur-xl border border-stroke rounded-md p-3.5 shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
<div className="text-caption font-bold uppercase tracking-wider text-fg-disabled mb-2.5">
ESI
<div
className={cn(
'flex items-center justify-between text-caption font-bold uppercase tracking-wider text-fg-disabled',
!collapsed.esi && 'mb-2.5',
)}
>
<span className="font-korean">ESI </span>
<button
type="button"
onClick={() => setCollapsed((s) => ({ ...s, esi: !s.esi }))}
aria-label={collapsed.esi ? '펼치기' : '접기'}
className="w-5 h-5 flex items-center justify-center text-fg-sub hover:text-fg transition-colors"
>
{collapsed.esi ? '+' : ''}
</button>
</div>
{[
{ esi: 'ESI 10', label: '갯벌·습지·맹그로브', color: '#991b1b' },
{ esi: 'ESI 9', label: '쉘터 갯벌', color: '#b91c1c' },
{ esi: 'ESI 8', label: '쉘터 암반 해안', color: '#dc2626' },
{ esi: 'ESI 7', label: '노출 갯벌', color: '#ef4444' },
{ esi: 'ESI 6', label: '자갈·혼합 해안', color: '#f97316' },
{ esi: 'ESI 5', label: '혼합 모래/자갈', color: '#fb923c' },
{ esi: 'ESI 3-4', label: '모래 해안', color: '#facc15' },
{ esi: 'ESI 1-2', label: '암반·인공 구조물', color: '#4ade80' },
].map((item, i) => (
<div key={i} className="flex items-center gap-2 py-1 text-label-2">
<span
className="w-3.5 h-1.5 rounded-sm flex-shrink-0"
style={{ background: item.color }}
/>
<span className="text-fg-sub font-korean">{item.label}</span>
<span className="ml-auto font-mono text-caption text-fg">{item.esi}</span>
</div>
))}
{!collapsed.esi &&
[
{ esi: 'ESI 10', label: '갯벌·습지·맹그로브', color: '#991b1b' },
{ esi: 'ESI 9', label: '쉘터 갯벌', color: '#b91c1c' },
{ esi: 'ESI 8', label: '쉘터 암반 해안', color: '#dc2626' },
{ esi: 'ESI 7', label: '노출 갯벌', color: '#ef4444' },
{ esi: 'ESI 6', label: '자갈·혼합 해안', color: '#f97316' },
{ esi: 'ESI 5', label: '혼합 모래/자갈', color: '#fb923c' },
{ esi: 'ESI 3-4', label: '모래 해안', color: '#facc15' },
{ esi: 'ESI 1-2', label: '암반·인공 구조물', color: '#4ade80' },
].map((item, i) => (
<div key={i} className="flex items-center gap-2 py-1 text-label-2">
<span
className="w-3.5 h-1.5 rounded-sm flex-shrink-0"
style={{ background: item.color }}
/>
<span className="text-fg-sub font-korean">{item.label}</span>
<span className="ml-auto font-mono text-caption text-fg">{item.esi}</span>
</div>
))}
</div>
{/* Progress */}
<div className="bg-[color-mix(in_srgb,var(--bg-base)_92%,transparent)] backdrop-blur-xl border border-stroke rounded-md p-3.5 shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
<div className="text-caption font-bold uppercase tracking-wider text-fg-disabled mb-2.5">
<div
className={cn(
'flex items-center justify-between text-caption font-bold uppercase tracking-wider text-fg-disabled',
!collapsed.progress && 'mb-2.5',
)}
>
<span className="font-korean"> </span>
<button
type="button"
onClick={() => setCollapsed((s) => ({ ...s, progress: !s.progress }))}
aria-label={collapsed.progress ? '펼치기' : '접기'}
className="w-5 h-5 flex items-center justify-center text-fg-sub hover:text-fg transition-colors"
>
{collapsed.progress ? '+' : ''}
</button>
</div>
<div className="mt-2.5">
{[
['총 해안선', `${(totalLen / 1000).toFixed(1)} km`, ''],
['조사 완료', `${(doneLen / 1000).toFixed(1)} km`, 'var(--color-success)'],
['고민감 구간', `${(highSens / 1000).toFixed(1)} km`, 'var(--color-danger)'],
[
'방제 우선 구간',
`${segments.filter((s) => s.sensitivity === '최상').length}`,
'var(--color-warning)',
],
].map(([label, val, color], i) => (
<div
key={i}
className="flex justify-between py-1.5 border-b border-stroke last:border-b-0 text-label-2"
>
<span className="text-fg-sub font-korean">{label}</span>
<span
className="font-mono font-medium text-label-2"
style={{ color: color || undefined }}
{!collapsed.progress && (
<div className="mt-2.5">
{[
['총 해안선', `${(totalLen / 1000).toFixed(1)} km`, ''],
['조사 완료', `${(doneLen / 1000).toFixed(1)} km`, 'var(--color-success)'],
['고민감 구간', `${(highSens / 1000).toFixed(1)} km`, 'var(--color-danger)'],
[
'방제 우선 구간',
`${segments.filter((s) => s.sensitivity === '최상').length}`,
'var(--color-warning)',
],
].map(([label, val, color], i) => (
<div
key={i}
className="flex justify-between py-1.5 border-b border-stroke last:border-b-0 text-label-2"
>
{val}
</span>
</div>
))}
</div>
<span className="text-fg-sub font-korean">{label}</span>
<span
className="font-mono font-medium text-label-2"
style={{ color: color || undefined }}
>
{val}
</span>
</div>
))}
</div>
)}
</div>
</div>
</div>

파일 보기

@ -20,19 +20,6 @@ export default defineConfig({
'/scat/img': {
target: process.env.VITE_SERVER_URL || 'https://wing-demo.gc-si.dev/',
changeOrigin: true,
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq, req) => {
console.log(
`[scat-img] ${req.url}${proxyReq.protocol}//${proxyReq.host}${proxyReq.path}`,
);
});
proxy.on('proxyRes', (proxyRes, req) => {
console.log(`[scat-img] ${req.url}${proxyRes.statusCode}`);
});
proxy.on('error', (err, req) => {
console.error(`[scat-img] ${req.url} ERROR:`, err.message);
});
},
},
},
},