From cda6dce0be6498fefb252206160fcf241d07bd84 Mon Sep 17 00:00:00 2001 From: Taehoon Date: Fri, 19 Jun 2026 16:19:11 +0900 Subject: [PATCH] Fix Gitea MCP ESM resolution error, update configuration, and include project tests/migration files --- .agents/git-mcp/package-lock.json | 1616 +++++++++++ .agents/git-mcp/package.json | 16 + .agents/mcp_config.json | 14 + .env | 12 +- .gitignore | Bin 103 -> 57 bytes ANALYSIS_REPORT.md | 141 +- Dockerfile | 40 + PLAN.md | 68 +- README.md | 179 +- __pycache__/analysis_service.cpython-312.pyc | Bin 9746 -> 10819 bytes __pycache__/analyze.cpython-312.pyc | Bin 13366 -> 13558 bytes .../prediction_service.cpython-312.pyc | Bin 4164 -> 4164 bytes __pycache__/server.cpython-312.pyc | Bin 13292 -> 13426 bytes __pycache__/sql_queries.cpython-312.pyc | Bin 2842 -> 3387 bytes analysis_service.py | 468 ++-- analyze.py | 342 +-- analyze_logs_pattern.py | 48 + check_tables.py | 29 + clear_test_db.py | 29 + clone_db.py | 45 + crawler_service.py | 516 ++-- crawler_service_test.py | 273 ++ docker-compose.yml | 40 + inquiry_service.py | 84 +- js/analysis.js | 948 +++---- js/analysis.js_fragment_leaderboard | 356 +-- js/analysis_test.js | 485 ++++ js/common.js | 156 +- js/dashboard.js | 474 ++-- js/dashboard_test.js | 245 ++ js/inquiries.js | 628 ++--- js/mail.js | 624 ++--- log_analysis_result.txt | 42 + log_scorer.py | 63 + migrate_to_docker.py | 124 + prediction_service.py | 194 +- project_service.py | 66 +- requirements.txt | 17 +- schemas.py | 20 +- server.py | 369 +-- server_test.py | 190 ++ sql_queries.py | 149 +- style/analysis.css | 466 ++-- style/common.css | 340 +-- style/dashboard.css | 246 +- style/inquiries.css | 502 ++-- style/mail.css | 438 +-- style/style.css | 6 +- templates/analysis.html | 278 +- templates/analysis_test.html | 139 + templates/dashboard.html | 234 +- templates/dashboard_test.html | 118 + templates/index.html | 102 +- templates/inquiries.html | 386 +-- templates/mailTest.html | 286 +- templates/modals/address_book.html | 124 +- templates/modals/path_selector.html | 48 +- tokens.json | 2456 ++++++++--------- verify_swvw.py | 28 + 59 files changed, 9509 insertions(+), 5798 deletions(-) create mode 100644 .agents/git-mcp/package-lock.json create mode 100644 .agents/git-mcp/package.json create mode 100644 .agents/mcp_config.json create mode 100644 Dockerfile create mode 100644 analyze_logs_pattern.py create mode 100644 check_tables.py create mode 100644 clear_test_db.py create mode 100644 clone_db.py create mode 100644 crawler_service_test.py create mode 100644 docker-compose.yml create mode 100644 js/analysis_test.js create mode 100644 js/dashboard_test.js create mode 100644 log_analysis_result.txt create mode 100644 log_scorer.py create mode 100644 migrate_to_docker.py create mode 100644 server_test.py create mode 100644 templates/analysis_test.html create mode 100644 templates/dashboard_test.html create mode 100644 verify_swvw.py diff --git a/.agents/git-mcp/package-lock.json b/.agents/git-mcp/package-lock.json new file mode 100644 index 0000000..b2848b0 --- /dev/null +++ b/.agents/git-mcp/package-lock.json @@ -0,0 +1,1616 @@ +{ + "name": "git-mcp", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "git-mcp", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@andrebuzeli/git-mcp": "^15.12.4", + "zod": "^4.4.3" + } + }, + "node_modules/@andrebuzeli/git-mcp": { + "version": "15.12.4", + "resolved": "https://registry.npmjs.org/@andrebuzeli/git-mcp/-/git-mcp-15.12.4.tgz", + "integrity": "sha512-t+XbHygySe4CmfoLGuPLsCo2sWr4UfsrXO8LLFNCu8j7N5Xa/3Zcv6eykmUG+QGmiIZ+yNyUEUjjr9GYMB81dQ==", + "dependencies": { + "@modelcontextprotocol/sdk": "^0.4.0", + "@octokit/rest": "^20.0.0", + "ajv": "^8.12.0", + "archiver": "^7.0.0", + "axios": "^1.7.7" + }, + "bin": { + "git-mcp": "src/index.js", + "git-mcpv2": "src/index.js" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-0.4.0.tgz", + "integrity": "sha512-79gx8xh4o9YzdbtqMukOe5WKzvEZpvBA1x8PAgJWL7J5k06+vJx8NK2kWzOazPgqnfDego7cNEO8tjai/nOPAA==", + "dependencies": { + "content-type": "^1.0.5", + "raw-body": "^3.0.0", + "zod": "^3.23.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@octokit/auth-token": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz", + "integrity": "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/core": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.2.tgz", + "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", + "dependencies": { + "@octokit/auth-token": "^4.0.0", + "@octokit/graphql": "^7.1.0", + "@octokit/request": "^8.4.1", + "@octokit/request-error": "^5.1.1", + "@octokit/types": "^13.0.0", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/endpoint": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-9.0.6.tgz", + "integrity": "sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==", + "dependencies": { + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/graphql": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-7.1.1.tgz", + "integrity": "sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==", + "dependencies": { + "@octokit/request": "^8.4.1", + "@octokit/types": "^13.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "11.4.4-cjs.2", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.4.4-cjs.2.tgz", + "integrity": "sha512-2dK6z8fhs8lla5PaOTgqfCGBxgAv/le+EhPs27KklPhm1bKObpu6lXzwfUEQ16ajXzqNrKMujsFyo9K2eaoISw==", + "dependencies": { + "@octokit/types": "^13.7.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "5" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-4.0.1.tgz", + "integrity": "sha512-GihNqNpGHorUrO7Qa9JbAl0dbLnqJVrV8OXe2Zm5/Y4wFkZQDfTreBzVmiRfJVfE4mClXdihHnbpyyO9FSX4HA==", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "5" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "13.3.2-cjs.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-13.3.2-cjs.1.tgz", + "integrity": "sha512-VUjIjOOvF2oELQmiFpWA1aOPdawpyaCUqcEBc/UOUnj3Xp6DJGrJ1+bjUIIDzdHjnFNO6q57ODMfdEZnoBkCwQ==", + "dependencies": { + "@octokit/types": "^13.8.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "^5" + } + }, + "node_modules/@octokit/request": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-8.4.1.tgz", + "integrity": "sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw==", + "dependencies": { + "@octokit/endpoint": "^9.0.6", + "@octokit/request-error": "^5.1.1", + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/request-error": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-5.1.1.tgz", + "integrity": "sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g==", + "dependencies": { + "@octokit/types": "^13.1.0", + "deprecation": "^2.0.0", + "once": "^1.4.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/rest": { + "version": "20.1.2", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-20.1.2.tgz", + "integrity": "sha512-GmYiltypkHHtihFwPRxlaorG5R9VAHuk/vbszVoRTGXnAsY60wYLkh/E2XiFmdZmqrisw+9FaazS1i5SbdWYgA==", + "dependencies": { + "@octokit/core": "^5.0.2", + "@octokit/plugin-paginate-rest": "11.4.4-cjs.2", + "@octokit/plugin-request-log": "^4.0.0", + "@octokit/plugin-rest-endpoint-methods": "13.3.2-cjs.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", + "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.2.tgz", + "integrity": "sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz", + "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.1.tgz", + "integrity": "sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", + "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/before-after-hook": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", + "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==" + }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/deprecation": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", + "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/follow-redirects": { + "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", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "node_modules/proxy-from-env": { + "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==", + "engines": { + "node": ">=10" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/universal-user-agent": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", + "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/.agents/git-mcp/package.json b/.agents/git-mcp/package.json new file mode 100644 index 0000000..14476a5 --- /dev/null +++ b/.agents/git-mcp/package.json @@ -0,0 +1,16 @@ +{ + "name": "git-mcp", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "@andrebuzeli/git-mcp": "^15.12.4", + "zod": "^4.4.3" + } +} diff --git a/.agents/mcp_config.json b/.agents/mcp_config.json new file mode 100644 index 0000000..f89995f --- /dev/null +++ b/.agents/mcp_config.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "gitea": { + "command": "node", + "args": [ + "/mnt/d/이태훈/19ProjectMaster/AICodeTest/.agents/git-mcp/node_modules/@andrebuzeli/git-mcp/src/index.js" + ], + "env": { + "GITEA_URL": "https://gitea.hmac.kr", + "GITEA_TOKEN": "34a35034b9335b5129c8bfcd27e841d83f0aeaed" + } + } + } +} diff --git a/.env b/.env index d1fdeed..22c6550 100644 --- a/.env +++ b/.env @@ -1,6 +1,6 @@ -PM_USER_ID=b21364 -PM_PASSWORD=b21364!. -DB_HOST=localhost -DB_USER=root -DB_PASSWORD=45278434 -DB_NAME=PM_proto +PM_USER_ID=b21364 +PM_PASSWORD=b21364!. +DB_HOST=db +DB_USER=root +DB_PASSWORD=45278434 +DB_NAME=PM_proto diff --git a/.gitignore b/.gitignore index b86406d1585ac2efa762572e5a953a7d38bf7f81..814c94c12e254cf6eb5b7ed3b9fdafed19d01c4e 100644 GIT binary patch literal 57 zcmXR;Eh5ddbMsS5b5e^z HVnAg8aqtwC literal 103 zcmXAfK?;B%6hz-z=prukNFVt~E`lU2@bcBS7#Ns&@3|Y9gX?Rd(Mh&DCzZg)&dP#A eER}&8SBm-bi68T3{%o2~qz+A5vPg73*mwb;vKA-+ diff --git a/ANALYSIS_REPORT.md b/ANALYSIS_REPORT.md index 594edec..9588e19 100644 --- a/ANALYSIS_REPORT.md +++ b/ANALYSIS_REPORT.md @@ -1,81 +1,60 @@ -# 📊 시스템 운영 자산 가치 분석 보고서 (Sabermetrics Edition) - -본 보고서는 야구의 통계 분석 기법인 **세이버메트릭스(Sabermetrics)**를 프로젝트 관리 시스템에 이식하여, 단순 활동량 측정을 넘어 **'실질적 자산 가치'**와 **'미래 운영 위험'**을 정밀 분석한 결과입니다. - ---- - -## 1. 핵심 분석 지표 정의 (Core Metrics) - -### 1.1 운영 활력 지수 (AVI, Activity Vitality Index) -프로젝트가 현재 얼마나 '살아서 숨 쉬고 있는가'를 나타내는 생존 지수입니다. - -* **산출 공식**: $AVI = exp(-\lambda \times days) \times Quality \times ECV \times 100$ -* **핵심 데이터**: - * **정체 일수(days)**: 마지막 유의미한 파일 업데이트 이후 경과 시간. - * **감쇄 계수($\lambda$)**: 기본 $0.04$에서 시작하여, 자산 규모(최대 $+0.04$)와 부서 정체율(최대 $+0.03$)을 동적으로 결합합니다. - * **활동 품질(Quality)**: 파일 증분 활동($1.0$), 구조적 관리($0.7$), 단순 행정 로그($0.4$)로 차등 배점합니다. - * **존재 신뢰도(ECV)**: 파일 수 $0$개($0.05$), $10$개 미만($0.4$) 등 유령 프로젝트에 패널티를 부여합니다. -* **의미**: 100%에 가까울수록 실시간 가동 상태이며, 0%에 가까울수록 데이터 노후화가 완료된 '사망' 상태를 뜻합니다. - -### 1.2 자산 가치 기여도 (VCI, Value Contribution Index) -시스템 전체의 운영 표준 대비, 해당 프로젝트가 기여하고 있는 가치의 상대적 하중을 측정합니다. - -* **산출 공식**: $VCI = (AVI - 70.0) \times (\frac{Files}{200} + 0.5)$ -* **핵심 로직**: - * **건강 기준선(70.0%)**: 시스템 자산 가치를 유지하기 위한 최소 마지노선(Replacement Level)입니다. - * **규모 가중치**: 파일 $200$개를 $1.0$ 가중치 기준으로 삼아, 대형 프로젝트일수록 시스템에 주는 충격을 기하급수적으로 반영합니다. -* **의미**: 양수(+)는 가치 창출, 음수(-)는 시스템 기회비용을 갉아먹는 '가치 파괴' 상태임을 나타냅니다. - -### 1.3 업무 집중도 (Job Focus) -단순 관리 행위를 제외하고, 실제 성과물(파일)을 생산하는 데 얼마나 몰입했는지를 판별합니다. - -* **산출 공식**: $Job Focus = \frac{\text{최근 히스토리 중 실제 파일 변동 발생 횟수}}{\text{전체 데이터 수집 횟수}} \times 100$ -* **의미**: 로그만 남기는 '보여주기식 활동'을 필터링하여 운영의 진정성을 확인합니다. - -### 1.4 운영 일관성 지수 (OCI, Operational Consistency Index) -프로젝트 관리의 '리듬'과 '성실도'를 측정하는 지표입니다. - -* **산출 공식**: 최근 30일 데이터를 4개 주차로 분할하여 활동 여부 분석 (주차별 성실도 70% + 활동 밀도 30%) -* **의미**: 특정 시점에 몰아치기식 작업을 하는 프로젝트보다, 매주 꾸준히 관리되는 프로젝트에 더 높은 신뢰 점수를 부여합니다. - ---- - -## 2. 등급 체계 및 관리 가이드 (Grade System) - -### 2.1 VCI 등급 (프로젝트 위상) -| 등급 (Grade) | 점수 기준 | 운영 의미 및 관리 전략 | -| :--- | :--- | :--- | -| **Masterpiece** | +10.0 이상 | **최우량 자산**: 시스템 가치를 견인하는 핵심 프로젝트 | -| **Blue Chip** | +2.0 ~ +10.0 | **우량 자산**: 꾸준한 활력으로 가치를 창출하는 핵심군 | -| **Steady** | -2.0 ~ +2.0 | **안정 자산**: 표준 수준의 운영을 유지 중인 현상 유지군 | -| **Underperform** | -10.0 ~ -2.0 | **저성과 자산**: 규모 대비 활력이 부족하여 가치 하락 중인 그룹 | -| **Liability** | -10.0 이하 | **고위험 자산**: 시스템 가치를 훼손 중인 방치 프로젝트. 즉시 조치 필요 | - -### 2.2 운영 일관성 (OCI) 판정 -* **정기적 (80%↑)**: 주 단위의 정기적 관리가 완벽히 이뤄지는 최우량 관리 상태. -* **안정적 (50~80%)**: 간헐적 정체는 있으나 전반적인 관리 리듬을 유지하는 상태. -* **간헐적 (20~50%)**: 관리 활동이 불규칙하며, 필요에 의한 일회성 작업 중심인 상태. -* **불규칙 (20%↓)**: 장기 정체 중이거나 관리의 영속성을 확인하기 어려운 위험 상태. - ---- - -## 3. 데이터 분석 프로세스 (Analysis Process) - -1. **데이터 수집**: `projects_history` 테이블로부터 일별 파일 수 및 로그 텍스트를 추출합니다. -2. **피처 추출**: - * **Velocity**: 파일 수의 변화 속도 계산. - * **Acceleration**: 활동의 가속/감속 여부 판별. - * **Stagnation**: 마지막 활동 이후의 공백 기간 측정. -3. **AI 시뮬레이션**: 추출된 피처를 AI 위험 적응형 모델 (AAS)에 입력하여 개별 프로젝트만의 **'위험 곡선'**을 생성합니다. -4. **최종 판정**: AVI와 VCI를 결합하여 리더보드에 등급과 관리 가이드라인을 송출합니다. - ---- - -## 4. 관리자 제언 (Action Plan) - -* **VCI 음수 프로젝트 집중 관리**: 단순 활동량이 아닌 VCI가 낮은 대형 프로젝트부터 우선적으로 인력을 배치하거나 운영 정책을 재점검해야 합니다. -* **AI Forecast 활용**: '활력 저하' 예보가 뜬 프로젝트는 실제 AVI가 급락하기 전 선제적인 조치(업무 독려, 파일 현행화)를 취할 수 있습니다. -* **파일 수와 활력의 균형**: 파일 수가 많은데 활력(AVI)이 낮은 경우, 시스템 전체의 데이터 무결성을 해칠 수 있으므로 데이터 클렌징이나 아카이빙을 권고합니다. - ---- -*본 분석 엔진은 Project Master Sabermetrics 알고리즘에 의해 자동 생성되었습니다.* +# 📊 시스템 운영 자산 가치 분석 보고 (Sabermetrics Report) + +본 보고서는 프로젝트 관리 시스템 내에서 수집된 활동 로그 및 자산 데이터를 통계적/AI 기법으로 분석하여, 각 프로젝트의 운영 활력과 조직 기여도를 정량화한 지표를 정의합니다. + +--- + +## 1. 운영 활력 지수 (AVI, Activity Vitality Index) +프로젝트가 현재 얼마나 건강하게 가동되고 있는지를 나타내는 **'디지털 자산 생존 지표'**입니다. + +### 1.1 산출 공식 +$$AVI = e^{-\lambda \times Stagnant\_Days} \times Quality \times 100$$ + +### 1.2 3대 핵심 변수 상세 설명 + +#### ① 지수 감쇄 모델 ($e^{-\lambda \times t}$) : "가치의 시한폭탄" +자산은 관리하지 않으면 시간이 흐를수록 가치가 기하급수적으로 소멸한다는 **'정보 휘발성'** 원리를 반영합니다. +* **Stagnant Days (정체 일수)**: 마지막 유효 활동 로그 기록일로부터 오늘까지 경과된 날짜입니다. +* **특징**: 정체 초기에는 점수가 빠르게 하락하다가, 시간이 지날수록 하락 폭이 둔화되며 0에 수렴합니다. 이는 관리가 중단된 직후의 정보 망실 위험이 가장 크다는 실무적 경험을 반영한 것입니다. + +#### ② 위험 가속 계수 ($\lambda$) : "대형 자산의 높은 관리 비용" +모든 프로젝트는 자산 규모에 따라 '늙어가는 속도'가 다릅니다. +* **공식**: $\lambda = 0.04 + \log_{10}(Files + 1) \times 0.008$ +* **비즈니스 로직**: 파일이 많은 대형 프로젝트일수록 관리 부재 시 조직에 미치는 타격이 큽니다. 따라서 대형 프로젝트일수록 $\lambda$ 값이 커지며, 소형 프로젝트보다 **훨씬 빠른 속도로 AVI가 하락**하도록 설계되었습니다. (대형 프로젝트는 더 자주 관리해야 점수가 유지됨) + +#### ③ 활동 품질 가중치 ($Quality$) : "행정과 실무의 구분" +단순히 접속하거나 로그가 찍혔다고 해서 활력이 100% 회복되지 않습니다. AI가 로그 키워드를 분석하여 활동의 **'진정성'**을 평가합니다. +* **High (1.0)**: **성과물 중심 활동** (파일 업로드, 수정, 등록, 업데이트 등) +* **Medium (0.7)**: **구조적 유지 활동** (폴더 생성, 삭제, 이동 등) +* **Low (0.4)**: **단순 행정 활동** (권한 변경, 메일 확인, 참가자 추가 등) + +--- + +## 2. 자산 가치 기여도 (VCI, Value Contribution Index) +야구의 **WAR(Wins Above Replacement)** 개념을 도입하여, 전체 포트폴리오 평균 대비 개별 프로젝트가 조직 가치에 얼마나 기여하는지 산출합니다. + +### 2.1 산출 공식 +$$VCI = (Individual\_AVI - Portfolio\_Avg\_AVI) \times Asset\_Weight$$ +* **Asset Weight (파일 규모 가중치)**: $max(0.2, \frac{Individual\_Files}{Portfolio\_Avg\_Files})$ + +### 2.2 지표의 의미: "평균(0.0)을 기준으로 한 상대 평가" +* **0.0 (평균)**: 조직 내 평균적인 관리 수준과 규모를 가진 표준 프로젝트. +* **(+) 점수**: 평균 이상의 활력으로 조직의 디지털 자산 가치를 증대시키는 프로젝트. +* **(-) 점수**: 평균 이하의 방치로 인해 조직에 잠재적 기회비용 손실을 입히는 리스크 프로젝트. +* **상대 가중치**: 조직의 평균 파일 수보다 큰 프로젝트가 방치될 때 마이너스 점수가 더 가파르게 하락하여 **'우선 관리 대상'**을 명확히 식별합니다. + +--- + +## 3. 강력한 예외 처리 (Hard Rules) + +데이터의 신뢰도를 확보하기 위해 다음과 같은 **'사망 판정'** 규칙이 적용됩니다. +1. **자동 삭제 패널티**: 최근 로그가 시스템에 의한 '폴더자동삭제'인 경우, AVI는 즉시 **0.1%**로 고정됩니다. (관리 포기 상태) +2. **자산 부재 패널티 (ECV)**: 파일 개수가 0개인 경우 운영 일관성(OCI)은 **0.0점**이며, 파일 10개 미만은 최종 가중치에 **50% 패널티**를 적용하여 '껍데기 프로젝트'를 걸러냅니다. + +--- + +## 4. 발표 및 분석 가이드 (Executive Summary) + +* **AVI가 낮은 프로젝트**: "데이터가 낡아가고 있으니 즉시 최신 성과물을 업데이트하십시오." +* **VCI가 음수(-)인 대형 프로젝트**: "조직에서 가장 중요한 자산임에도 불구하고 평균 이하로 방치되고 있습니다. **최우선 관리 대상**입니다." +* **OCI가 낮은 프로젝트**: "활동은 있으나 불규칙합니다. 관리의 지속성을 확보하여 운영 리듬을 찾으십시오." diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..994f8f5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +# 1. 베이스 이미지 설정 (안정적인 bookworm 버전 사용) +FROM python:3.9-slim-bookworm + +# 2. 시스템 의존성 설치 (OCR, PDF, Playwright 관련 핵심 라이브러리) +RUN apt-get update && apt-get install -y \ + tesseract-ocr \ + libtesseract-dev \ + poppler-utils \ + libgl1 \ + libnss3 \ + libnspr4 \ + libatk1.0-0 \ + libatk-bridge2.0-0 \ + libcups2 \ + libdrm2 \ + libxkbcommon0 \ + libxcomposite1 \ + libxdamage1 \ + libxrandr2 \ + libgbm1 \ + libpango-1.0-0 \ + libcairo2 \ + libasound2 \ + fonts-liberation \ + && rm -rf /var/lib/apt/lists/* + +# 3. 작업 디렉토리 설정 +WORKDIR /app + +# 4. 필요한 패키지 설치 +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +# 브라우저만 설치 (의존성은 위에서 설치함) +RUN playwright install chromium + +# 5. 프로젝트 전체 파일 복사 +COPY . . + +# 6. 서버 구동 +CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/PLAN.md b/PLAN.md index 79dc477..2fa84fa 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,34 +1,34 @@ -# 데이터 분석 관리자 페이지 기획안 - -## 1. 프로젝트 개요 -본 프로젝트는 데이터 분석 프로세스 및 프로젝트 리소스를 통합 관리하기 위한 관리자 대시보드입니다. 사용자 인터랙션 관리부터 시스템 로그, 리소스 현황을 한눈에 파악하는 것을 목표로 합니다. - -## 2. 주요 기능 상세 - -### ① 메일 관리 및 요구사항 시스템 (Mail & Inquiry Management) - [완료] -- **UI/UX 고도화**: 리스트 영역 너비 확장(400px) 및 시각적 가독성 개선 -- **검색 및 필터링**: 키워드 및 기간별 메일 검색 기능 구현 -- **동적 연동**: 리스트 클릭 시 메일 본문 실시간 업데이트 구현 -- **메일 관리**: 개별 삭제 및 체크박스를 활용한 대량 삭제 기능 추가 -- **탭 시스템**: 수신/발신/임시/휴지통별 데이터 분류 및 동적 렌더링 적용 - -### ② 로그 관리 (Log Management) -- **최근 로그**: 실시간으로 발생하는 시스템 및 분석 작업 로그 출력 -- **전체 로그**: 날짜별, 프로젝트별 필터링을 통한 로그 기록 조회 및 내보내기 - -### ③ 파일 관리 (File Management) -- 프로젝트별 데이터셋, 분석 결과물 파일 개수 및 용량 통계 -- 파일 확장자별 구성 비율(CSV, JSON, Python 등) 시각화 지표 제공 - -### ④ 인원 관리 (Personnel Management) -- 프로젝트 참여 인원 현황 조회 -- 사용자별 권한(관리자, 분석가, 뷰어) 부여 및 수정 기능 - -### ③ 공지사항 (Notice & Patch Notes) -- 분석 모델 업데이트, 시스템 점검, 패치 내역 공유 -- 사용자 대상 공지사항 작성 및 게시판 관리 - -## 3. UI/UX 가이드라인 -- **Layout**: 좌측 내비게이션 바(Sidebar) + 상단 헤더(Header) + 중앙 컨텐츠 영역 -- **Theme**: 신뢰감을 주는 Dark Blue / White 톤의 깨끗한 디자인 -- **Responsiveness**: 다양한 해상도에 대응하는 반응형 레이아웃 구성 +# 데이터 분석 관리자 페이지 기획안 + +## 1. 프로젝트 개요 +본 프로젝트는 데이터 분석 프로세스 및 프로젝트 리소스를 통합 관리하기 위한 관리자 대시보드입니다. 사용자 인터랙션 관리부터 시스템 로그, 리소스 현황을 한눈에 파악하는 것을 목표로 합니다. + +## 2. 주요 기능 상세 + +### ① 메일 관리 및 요구사항 시스템 (Mail & Inquiry Management) - [완료] +- **UI/UX 고도화**: 리스트 영역 너비 확장(400px) 및 시각적 가독성 개선 +- **검색 및 필터링**: 키워드 및 기간별 메일 검색 기능 구현 +- **동적 연동**: 리스트 클릭 시 메일 본문 실시간 업데이트 구현 +- **메일 관리**: 개별 삭제 및 체크박스를 활용한 대량 삭제 기능 추가 +- **탭 시스템**: 수신/발신/임시/휴지통별 데이터 분류 및 동적 렌더링 적용 + +### ② 로그 관리 (Log Management) +- **최근 로그**: 실시간으로 발생하는 시스템 및 분석 작업 로그 출력 +- **전체 로그**: 날짜별, 프로젝트별 필터링을 통한 로그 기록 조회 및 내보내기 + +### ③ 파일 관리 (File Management) +- 프로젝트별 데이터셋, 분석 결과물 파일 개수 및 용량 통계 +- 파일 확장자별 구성 비율(CSV, JSON, Python 등) 시각화 지표 제공 + +### ④ 인원 관리 (Personnel Management) +- 프로젝트 참여 인원 현황 조회 +- 사용자별 권한(관리자, 분석가, 뷰어) 부여 및 수정 기능 + +### ③ 공지사항 (Notice & Patch Notes) +- 분석 모델 업데이트, 시스템 점검, 패치 내역 공유 +- 사용자 대상 공지사항 작성 및 게시판 관리 + +## 3. UI/UX 가이드라인 +- **Layout**: 좌측 내비게이션 바(Sidebar) + 상단 헤더(Header) + 중앙 컨텐츠 영역 +- **Theme**: 신뢰감을 주는 Dark Blue / White 톤의 깨끗한 디자인 +- **Responsiveness**: 다양한 해상도에 대응하는 반응형 레이아웃 구성 diff --git a/README.md b/README.md index afa860c..cc5782e 100644 --- a/README.md +++ b/README.md @@ -1,86 +1,93 @@ -# 🚀 서버 정책 (Server Policy) - -**서버 구동 시 반드시 아래 명령어를 사용한다:** -```bash -uvicorn server:app --host 0.0.0.0 --port 8000 --reload -``` -- **Host**: `0.0.0.0` (외부 접속 허용) -- **Port**: `8000` -- **Reload**: 코드 수정 시 자동 재시작 활성화 - ---- - -# 🤖 메일시스템 AI판단가이드 (AI Reasoning Guide) - -AI는 파일을 분류할 때 단순한 키워드 매칭이 아닌, 아래의 **5단계 통합 추론 모델**을 사용하여 '실무자처럼' 생각하고 판단한다. - -### 1단계: 전수 데이터 수집 (Holistic Reading) -- **무제한 스캔**: 페이지 수에 관계없이 문서 전체를 전수 조사한다. -- **무조건적 OCR**: 디지털 텍스트 유무와 상관없이 모든 페이지에 고해상도(300 DPI) OCR을 실행하여 이미지 속 도장, 수기, 표 데이터까지 완벽히 수집한다. - -### 2단계: 파일명 가중치 적용 (Title Steering) -- **파일명 = 보관 의도**: 사용자가 지은 파일명은 분류의 가장 강력한 '방향타'이다. -- **최종 조율**: 본문의 데이터가 다른 도메인에 쏠려 있더라도, 파일명에 명확한 업무 용어(`실정보고`, `하도급` 등)가 있다면 이를 최종 분류의 가장 큰 무게추로 삼는다. - -### 3단계: 문서의 물리적 틀(Format) 분석 -- **공문 골격 확인**: 문서의 시작(`수신/발신`)과 끝(`직인/끝.`)의 구조를 확인한다. -- **껍데기 vs 알맹이**: - - **공문 본체**: 골격이 완벽하고 뒤따르는 기술 데이터가 적은 경우 → **[공사관리 > 공문]** - - **첨부 본체**: 공문 뒤에 대량의 산출서, 계약서, 도면이 붙어 있는 경우 → **[해당 기술 카테고리]** (공문은 전달 수단으로만 간주) - -### 4단계: 비즈니스 도메인 상식 결합 (Common Sense) -- **지명 교차 검증**: 파일명과 본문의 지명(어천, 공주, 대술, 정안 등)을 대조하여 정확한 프로젝트를 선택한다. (임의 기본값 지정 금지) -- **실무 맥락 매칭**: '임대료/연장'은 사업비 성격의 '기타'로, '비계'는 '구조물'로 연결하는 등 건설 실무 상식을 추론에 반영한다. - -### 5단계: 최종 지도 매칭 (Hierarchy Mapping) -- 수집된 모든 정보를 종합하여 사용자가 정의한 **표준 분류 체계(Tab > Category > Sub)** 지도 위에서 가장 논리적이고 실무적인 위치를 최종 확정한다. - ---- - -# 🛠️ 개발 및 관리 규칙 (Strict Development Rules) - -1. **언어 설정**: 영어로 생각하되, 모든 답변은 **한국어**로 작성한다. -2. **임의 수정 절대 금지 (Zero-Arbitrary Change)**: - - 사용자가 명시적으로 지시한 부분 외에는 **단 한 줄의 코드도, 그 어떤 파일도 임의로 수정, 정리, 리팩토링하지 않는다.** - - 지시받지 않은 다른 파트의 코드는 절대 건드리지 않으며, 영향 범위가 요청 범위를 벗어나지 않도록 '외과 수술식(Surgical) 수정'을 원칙으로 한다. -3. **개선 작업 절차 (Test-First Approach)**: - - 사용자가 개선(Refactoring, Optimization 등)을 지시한 경우, **수정 전 현재 시스템이 정상적으로 잘 작동하는지 먼저 전수 확인**한다. - - 기존 동작 방식과 성능을 기준(Baseline)으로 삼고, 수정 후에도 **기존의 모든 기능이 무결하게 유지되는지 반드시 테스트하여 입증**한다. - - 검증 결과를 바탕으로 "무엇을, 왜, 어떻게" 바꿀지 상세 보고 후, 사용자로부터 **'진행시켜'** 승인을 얻은 뒤에만 집행한다. -4. **선보고 후승인**: 모든 기능 수정 및 코드 변경 전에는 예상 방안을 먼저 보고하고 승인 절차를 거친다. -5. **로그 기록 철저**: 진행 상황(로그인, 수집, 오류 등)을 실시간 로그에 상세히 표시하여 투명성을 확보한다. - ---- - -## 🎨 디자인 가이드 (Design System) - -이 프로젝트는 `tokens.json`에 정의된 디자인 시스템을 준수합니다. - -### 1. 컬러 시스템 (Colors) -- **Primary**: `#1E5149` (primary-lv-6) - 브랜드 핵심 컬러 -- **Background**: `#FFFFFF` (Light Default) / `#F9FAFB` (Light Muted) -- **Point Colors**: - - Blue: `#0D8DF2` (Info) - - Green: `#4DB251` (Success) - - Red: `#F21D0D` (Error) - - Yellow: `#FFBF00` (Warning) -- **Special**: `ai_color` (Purple-Blue Gradient) - AI 관련 요소 전용 - -### 2. 타이포그래피 (Typography) -- **Font Family**: `Pretendard`, `sans-serif` -- **Scale**: - - **H1**: 20px / ExtraBold (pretendard-0) - - **H2**: 16px / SemiBold (pretendard-1) - - **H3/H4**: 14px / SemiBold or Regular - - **Body/P**: 12px / Regular (pretendard-2) - -### 3. 레이아웃 및 간격 (Dimensions) -- **Spacing Unit**: Base 4px (xs: 4px, sm: 8px, md: 16px, lg: 32px, xl: 64px) -- **Border Radius**: sm: 4px, lg: 8px, xl: 16px -- **Shadow**: `0 8px 24px rgba(0,0,0,0.16)` (box__drop-shadow) - -### 4. 컴포넌트 규칙 -- **Buttons**: `borderRadius.lg (8px)` 적용, Primary 배경색 사용 -- **Cards**: `borderRadius.lg (8px)` 적용, Subtle Shadow 활용 -- **Topbar**: Height 36px, `headercolor` 그라데이션 적용 가능 - +# 🚀 서버 정책 (Server Policy) + +**서버 구동 시 반드시 아래 Docker 기반 명령어를 사용하여 컨테이너를 가동한다:** + +* **우분투(WSL2) 환경에서 구동 시:** +```bash +docker compose up --build -d +``` + +* **윈도우 호스트(PowerShell/cmd) 환경에서 원격 구동 시:** +```powershell +wsl -u root -d Ubuntu bash -c "cd '/mnt/d/이태훈/19ProjectMaster/AICodeTest' && docker compose up --build -d" +``` + +- **접속 포트**: `8000` (포트포워딩 설정 완료 시 호스트 IP `172.16.8.151:8000`으로 외부 접근 가능) +- **실시간 반영**: 소스 코드 수정 시 볼륨 바인딩을 통해 컨테이너 서버에 실시간으로 즉시 동기화됨 + +--- + +# 🤖 메일시스템 AI판단가이드 (AI Reasoning Guide) + +AI는 파일을 분류할 때 단순한 키워드 매칭이 아닌, 아래의 **5단계 통합 추론 모델**을 사용하여 '실무자처럼' 생각하고 판단한다. + +### 1단계: 전수 데이터 수집 (Holistic Reading) +- **무제한 스캔**: 페이지 수에 관계없이 문서 전체를 전수 조사한다. +- **무조건적 OCR**: 디지털 텍스트 유무와 상관없이 모든 페이지에 고해상도(300 DPI) OCR을 실행하여 이미지 속 도장, 수기, 표 데이터까지 완벽히 수집한다. + +### 2단계: 파일명 가중치 적용 (Title Steering) +- **파일명 = 보관 의도**: 사용자가 지은 파일명은 분류의 가장 강력한 '방향타'이다. +- **최종 조율**: 본문의 데이터가 다른 도메인에 쏠려 있더라도, 파일명에 명확한 업무 용어(`실정보고`, `하도급` 등)가 있다면 이를 최종 분류의 가장 큰 무게추로 삼는다. + +### 3단계: 문서의 물리적 틀(Format) 분석 +- **공문 골격 확인**: 문서의 시작(`수신/발신`)과 끝(`직인/끝.`)의 구조를 확인한다. +- **껍데기 vs 알맹이**: + - **공문 본체**: 골격이 완벽하고 뒤따르는 기술 데이터가 적은 경우 → **[공사관리 > 공문]** + - **첨부 본체**: 공문 뒤에 대량의 산출서, 계약서, 도면이 붙어 있는 경우 → **[해당 기술 카테고리]** (공문은 전달 수단으로만 간주) + +### 4단계: 비즈니스 도메인 상식 결합 (Common Sense) +- **지명 교차 검증**: 파일명과 본문의 지명(어천, 공주, 대술, 정안 등)을 대조하여 정확한 프로젝트를 선택한다. (임의 기본값 지정 금지) +- **실무 맥락 매칭**: '임대료/연장'은 사업비 성격의 '기타'로, '비계'는 '구조물'로 연결하는 등 건설 실무 상식을 추론에 반영한다. + +### 5단계: 최종 지도 매칭 (Hierarchy Mapping) +- 수집된 모든 정보를 종합하여 사용자가 정의한 **표준 분류 체계(Tab > Category > Sub)** 지도 위에서 가장 논리적이고 실무적인 위치를 최종 확정한다. + +--- + +# 🛠️ 개발 및 관리 규칙 (Strict Development Rules) + +1. **언어 설정**: 영어로 생각하되, 모든 답변은 **한국어**로 작성한다. +2. **임의 수정 절대 금지 (Zero-Arbitrary Change)**: + - 사용자가 명시적으로 지시한 부분 외에는 **단 한 줄의 코드도, 그 어떤 파일도 임의로 수정, 정리, 리팩토링하지 않는다.** + - 지시받지 않은 다른 파트의 코드는 절대 건드리지 않으며, 영향 범위가 요청 범위를 벗어나지 않도록 '외과 수술식(Surgical) 수정'을 원칙으로 한다. +3. **개선 작업 절차 (Test-First Approach)**: + - 사용자가 개선(Refactoring, Optimization 등)을 지시한 경우, **수정 전 현재 시스템이 정상적으로 잘 작동하는지 먼저 전수 확인**한다. + - 기존 동작 방식과 성능을 기준(Baseline)으로 삼고, 수정 후에도 **기존의 모든 기능이 무결하게 유지되는지 반드시 테스트하여 입증**한다. + - 검증 결과를 바탕으로 "무엇을, 왜, 어떻게" 바꿀지 상세 보고 후, 사용자로부터 **'진행시켜'** 승인을 얻은 뒤에만 집행한다. +4. **선보고 후승인**: 모든 기능 수정 및 코드 변경 전에는 예상 방안을 먼저 보고하고 승인 절차를 거친다. +5. **로그 기록 철저**: 진행 상황(로그인, 수집, 오류 등)을 실시간 로그에 상세히 표시하여 투명성을 확보한다. + +--- + +## 🎨 디자인 가이드 (Design System) + +이 프로젝트는 `tokens.json`에 정의된 디자인 시스템을 준수합니다. + +### 1. 컬러 시스템 (Colors) +- **Primary**: `#1E5149` (primary-lv-6) - 브랜드 핵심 컬러 +- **Background**: `#FFFFFF` (Light Default) / `#F9FAFB` (Light Muted) +- **Point Colors**: + - Blue: `#0D8DF2` (Info) + - Green: `#4DB251` (Success) + - Red: `#F21D0D` (Error) + - Yellow: `#FFBF00` (Warning) +- **Special**: `ai_color` (Purple-Blue Gradient) - AI 관련 요소 전용 + +### 2. 타이포그래피 (Typography) +- **Font Family**: `Pretendard`, `sans-serif` +- **Scale**: + - **H1**: 20px / ExtraBold (pretendard-0) + - **H2**: 16px / SemiBold (pretendard-1) + - **H3/H4**: 14px / SemiBold or Regular + - **Body/P**: 12px / Regular (pretendard-2) + +### 3. 레이아웃 및 간격 (Dimensions) +- **Spacing Unit**: Base 4px (xs: 4px, sm: 8px, md: 16px, lg: 32px, xl: 64px) +- **Border Radius**: sm: 4px, lg: 8px, xl: 16px +- **Shadow**: `0 8px 24px rgba(0,0,0,0.16)` (box__drop-shadow) + +### 4. 컴포넌트 규칙 +- **Buttons**: `borderRadius.lg (8px)` 적용, Primary 배경색 사용 +- **Cards**: `borderRadius.lg (8px)` 적용, Subtle Shadow 활용 +- **Topbar**: Height 36px, `headercolor` 그라데이션 적용 가능 + diff --git a/__pycache__/analysis_service.cpython-312.pyc b/__pycache__/analysis_service.cpython-312.pyc index d34751fe6203791ea4a882c79ce2ef6d3da7fd90..4bcd0baf14ea985684547589c57de1e22206b331 100644 GIT binary patch delta 6246 zcmb7I3v3%#cAeq>GyI4mDgMNdD9WNN%aW}6D_fR7vYp7;I&waC?69@9MnMJSX)ThY|#w@ zZ0{R#B-tAj?E`q{-Z%H%ckg?5?mLff_W$*9^WU3H1_IhY{`4P`4>x~m{tju>gQ$ex zFAGW`Pd24Ef>ydg9>$$dG+ITgKT%XVFOrOcBxp6S;1tL~YdFQQ`U!OqY3+LRM!<(eJX-i!#FjAC$h+ToO+#QJGRW$tiDsx20TNi8jtZ4vhoVdIaS~auabLs z4X2@vBh7>q#}GkNACSBj8$Z40n~gqc6Gk7Xy8C2k!ZCYohOL#|qD`ERHeZ6Zme-rt z(-uz4>AOMY>RuJWI%Hi=D|<*=FBxIYYc9b~@CMq*8`(Npi?*@V$OwW{%Ohm#5W`bM zhTthq%~Aia?Ki>JoBl6bf3-ulU0HD~(KfuZcs|^hA&8M}()#pqMtEg{D2!M8RRm{T z+sywx4k>=U6u+4>aOO*3nzyVQZKP{C5>HCov926HZ>1f4tOhpNIRcB0lyn_mlh%T) zmt+O);%hhyXT_$u$^zS1?6}l88*P(K4M5V6QsPXmHY5_ivVrAaT>JA!zyH?KySYc#W|m(4lcoHu zNCrj2Ac{|>VsxBMJoJN>XgHfpC1QhP6KNJHgpXDJ%tO4_4{0Dn@Pl?@?|)R7<(`MQ z1XR24_YD2f3~mp5q9#$9N~A^QP=Xb;C~-a&A50)z1w>_fY*JL=`7oVgMCEAwqNq%! zS>Tc|gqau>)hM1GO27l$P}0#_p;=wuA{tUKijiVMlCR`BQD2R7OS`BCLow_A{4kKbuU)Q?aqZB!j<}AT*(@{`hydz_wi> zRt$vIH_PO^OM&)6$DKe|xz@R?A)F2QhN2^~=!g~_(Ye-nMai-2j-z)TWesfl^%cfvYp0J*b2G6!^&JKB zpWK~yTzw_`p{%}av`>?7>;dgBG=!@wOZ`iYC)<0^V#{^pPF?T0Q@bG_FIk%BI!cz- zvM-Pi<@?{?T=aD;dbbt5+j6>lUjOuXzIiVG{-%<*J*O*2H{~d5`o%lere!^0^L|MQ zK5c8)eNQ;wJJY)8X(@VI=DO!?B~SMq&%RrV?9*jSIDdI@!_MM{o%366DM}mm7cKkC zR@Z&uh_+c{A#5$nHeph`JF~1Ke1Qd>3tY}2a5>OWq4kBVFLMeE^9J6?Q){0&XOIgVZIC8V(?-r96$1mi>e#Rq z+-kUm`cC<`Uz#?sB{rSJVX2z6!YN3&7khx)b*J|vJC6MP6FD5S(!-sV&E<<8bF zK6XPJyuqrgs5Q>aWr^N)C@scIL{z2exYMnbe>I)CQuN>s71&c z!Y^Z1)JcWxLOdmE&&3(35Ne?I#Av8UNhTJbV8=xDXq*L$?<>5I#2?(e%R`W{a@HxHSWxw9WIj@48R$XEpbYwYl*(c9$LgY~OMlVRh!% zEVrQZu52RAjpN}NwK)>L`uKxdXKq~BI(_WTUHJoZ9q%8`llQ#gMQ>}-n`teOb4(#r=q+q1 z#OKKZGxzfR$&&YQP6yS>9mu$83LEe?bO;`(50itKbPKOo>YNCpFND(`y^IOQ6(}bB(9)oJ2D5F#zgo8Y zafL?#OrFL>N(1fS*yx#LI)TE%7VCEMgpjgs?ZjIls?gXZBkJN%&!?CqBa#S7wu11I z!5O)>g|LG^w?0jt|3R>(pA4RacY~{F1_X?Sr23+PO7k`$j8UhBM>fVzD|yWj!E0GK z>ML!SlF+7(Qyw64XdFK=3Bch%NxIF^p`}z+~fWt?;SI=Y+~+l(ldPLCbesgnhJCU= zv1yg#skDkSK{>NR)CO89m9q$ocUo$64I2{v+g_hB0BnPvRTpb9UXmw~*07eY;c6v! z?QEzzoEUDDxwR)}Hxe8rjjwJsKsF3)9RSswk{1|?D`_Y11f*u5>qZoSAi+r&@1&Kq zJ5@Vs=j%v)54(W_>87w6!d60mbsCXhn)@D6~e4he;-ISm{i7>{Z=?eN;V z$i@gx4>k;3hT=?|i?h?-*;PZY9$77|&)eG-B>oP$w8N{6xpRu$K{Y!CF z!M}?ORKb7Xsvj;oJF12VF2L@R`NwCFU6I3+t^rcE$haJidOpN?tEt;rN!OYu(zTkz z-p7+z+Lng20@AKwUCIa7ITPX<ISTfzmcY7^XqyYa*Qy_52G5T@p?K^eKz6eI(;9#0r$|&vMm@Bm2^Xr79mJ^QVr!Ky_jb}q@VV`%EP)m}J!@Y#d1UUlX*M;hs1?=uI2 zYZC>#cLyWPW0OUNMfc8(bZ!C`d=ihLJ-BZ#CitjBy_nzw8STdeuiwyR`=1{A?9zul z(rG*N^^KJ~N6%d8K`yL-H>`ShyU~Mixvxrhd!nSM?;jf)7#u^1461hD?1^ec^1P^z zUr5Fn{Hk)ALk%TFa}`|0=tL?3w>;?{Ha-zgCD|z$k&LC{qi5(if>K1F3wVTKZBGz3 zQN2&9_Ex9+Sr7U>tQ?JFItfJ7jK&!@fkXqHI3GvsDBSVT6n=|OZ*)=k_xg=SNn1i0 z&4Sw>@<=1mWjtI3!>R)=f-H>sduv|-<^}=rk{1~g_#ohfzwsZ*m_T53&~b&xcjRCA zU?(~W0_Z765>wFt8UzW!SwbKhlLRJ7OfZP3U|0##VgQ2Dm^h8Tk|)5VF){kcx+LXWD}!k1D@ekOgb>ZCzEKM977|q#M!g3 z2r-2JdN82`{Fwl^ju?}?ln|-PUjvhg$1kO&%dB^ zKX#M2CwvrY$&~AZcj}KVM0U&%{N$yZFWs)aeWtV<O9jO!i9Xzo0sQQbK^I3`QCeh z4U2F&hyOQLFAwvf`QG_01^+EiDbSyDA1+5W&Ydepwk|ey7aO~;AHC}cmm4?ajtYAk zkn>j>!nCOzYFP|*6+>Nxfl_G40_7L1VOQop<(PICsfO&4GF3Odvq(kYZw0D;k!mbb zjb(?kT;EjoH}H1UxdzNS^gFYSE?17B%&Q}G1J@M;tTOCRknGj zQ$<@#R$I0Q@~uUC8~mAIk7N&&t@hl&b$zzC?D9>&1h*ZSQ}3<^W%tJHQ$lk%VmP&M zI#qmewB$+)$HLseGW@mUp6+;qzZY!$$nd^lZs$CCV^1l#^T)@3a`NWM+xms$&;86) z+WmYn`22O#{a`p}dH{c_Fdy!pQ_h_(HSQ{TyR-deZ)1M4=xr;s7Cn1}ufu*#BtKRR zZ56zY?o89dhHb@$?PZU5`tZ!gvdcZ)I%Bz4-;nQ`GZw>Lg?OocdpQu!A1wwt%Yn@g zLQVOpLZ}q#DmO;%1ta;Px$#o4V@0c~x2K3EtVEX*##(X@PqB=pv6)az8yy(vrm zT-R2%IJ0J{EIkv|i@I1W9T)z%aVsrVf)kLA2x0O8Cf~sXS1>7UD-{Em4g6#9m-!lT zSGE7Xx@JYOM;-p9lF-{G7!3u*3BWgz&ti zD^o+2imaEGq=hY1l?tP+!ayyRHi@p9w5@+)5()FlrcA4*0bxoC+gemGKQw^ zNYtQ>TA<>r&s;(F?^> z@Apmh57ADQgAS{xNz#W%@KAez4+`O%MYLO8R7udmH_ z@!ST#UQ)J)qx6qzS3x8yDdSxol8OYQ&0$H|5$pyWczC~=IyHq>Vh0|HZIN^&912H; z!15Xz&}^m`G>i4Rrbsl{9*Bh^^o+?yE!uY!yIA^>)=B@Rt=;&wnsKh^H+*Sv_is!& zJww3}!*DF=th(k{o^&kl-*&^{9$Groa<*i+W8AU0e_P7u@2?+nUNw|XYnVLGJw5%M zZZVspeto%G#pG2?Yv~^SV_PO|1!LZtq^+i3_m$Z;_}mCPZY~|umVW#7O2*#E#xd$H zyX$P+XI2hiNQp*g21F8r^Sj;DlC!H=RPl;72w$S+RS^41k!w;kFru0qg@|Qam6UZ@ z>8ClyBBJGW2_>&ja9JV&MaiR8O)XBlna$o~LL+L(4*G}OB2Lt_8bOAwHyfaHx1G?6 zjHtypX*Fdf&3z|y5RXR8Jfh%DQ7z2OnOSa>MO{SQoG$V_qx^i_Z|PbCGH;F2zZ)K* zFBq$p)eJ+|s|$J=Nfj4@?P0P6_V2sO_wq+pB>a1;5m`15xv%v-SMBSl_VEO@l+?kH z5IGpWDNi(qf+QM=Hv6@bG7=Re4GDL)2SZ^=LBf(c9uAUF3qfy7+PFYEg-AzOQZ++t zb!vzMmi>CV%Cw*sQAsPu>cL>Uq-hDp1MRV9N!1Y)LM@UOa5=ywj*m1o#j&&%&kj;a zKQV1rFNMW8N7d$4y^}fC!2_>7ma-T1Z<#J-a`Oj;esN6enJHmR_LO#r!soppn%>sIFt@1koEdfu8* z`zM|Yp3A#07F}_Vd+P_cr3xy}KAS99d2OX+A#@{~m>*qh! zjXk}0a7)VL9ju!+%$+l>V~Tu(TP}KY*C>7W+3Fr}c$KfC3LK7!?B~d8_#%%YSc#yT z_F5fpwt%HH$n+Kd_dnHz8U|C#XIk=UWQqzyu1AC5=j5=)3I3$N+Fo&SYxCtH8fVXtS4cK|_XwkX2{ zIpk!MAXi>>(UyQh=YvclN7V4jwmMMCClqG6d86RX;;qHhl4o6_ z;Z68z%&K9=S0Ss4Y_Nfc*Ft_-fSa-fg}ufctQJU@@i3p8eoJ^u!YrSQWlYKECCpK% zKfJXK!Ut-HEnxa_o zcRrNd1>~*=gu8*-^H6FVP%HR?M6PHOWfzDUlf)cQyf9%B7G>3JWy{fXUMS6c5e7om zTM3I$F-ug1G@C7=c6K)pg2yUgjgyCH$=b%7t2~Zd)NTZ1-S=9qb;g*gXi#GeQ~BDqz;iPkJuy|%$sKQwL?pkmZRdmiF~-n zVz~t>kz>IDj>^aLMTdO%PLXSU1a0nB^QEX$Hyc;6=%WQV6E&iCo+eJdEPKwpRny$* zVxE`-ygr#%%`e37d6Y(eQI=oK741B}YCUIGi1 zV4w|}Ev|KqoS79znGY)Gc15m%@h_&w3hULoK$qZ7`eotYayzO!Nvt&-5&{vv__5*^ zdcDZ2@Wtsn-bZ_P0~uL^pa#Jz1W+UxvJ3&<3RwZ5`H1bKt$*ryuU0OY&EK7yxw>nA zUoFA5EYl=S*EYXBjN@GGP4GCAnO(2_j|zv?4W-^)2;fb2_fCdoPBJ31PYa>IitI7P zLFO&?B+D`bdX72Fe5gFco@1Xl$DC72is~APZ6n}2CXRB7i~C3=eBaltYYs=l-JN88 z&ssWGvb;AteG5skn1AzE=9@m|YJO~FBa`JXqpbIbR*x&POcp$AO~&Y2R-n4(JG z)#&A0qkU699h6PD_3^-+H!~)Dd;&J))!P^QZvEm{Y4qF)xU1&n^7wN$Qa`Pg^q3l3 z(z%fiAmkBydUA0<1| zbjr5yVSgcc8fF9@MTR_sU=M;95UfS;7=rZ(FmBV$&j!TgUdVwM_Cf?7dx?W?vqe&Y za!YF@C8VFE4DN{@CYU@@UUOIov_#@Uj6_24gC`z~5LAmSMqoqWMo@sjgJ3TJiHpaG z;CGXKIMI@DT<&AZF8a^XqF#_8v7PDW4V_E2AlQlktG#51!%5*lq@xphZX)864tO%Y zwuQSvop3aSKk%YW5gt(~Z=P=hO~4l;1UnIO7y-We1S^%4dni^y+5+LGCQvXy;Mb#M zd1~{M^&2;B-nIFO#z6h%XExVUW0^;vu1{zjU0UYSZjeiq)exC({a-=(=?-Y8lcTJJNx(EGpco-X%qdftdRYRF7s71TsP+p#>a|kt~zRN zINjg7$-d^@o%HS=_clPc?W-PjCVgwJmDMK8Y6o{s(tSQRz2_q@O{YB9ntY~3UJQ;^zBJ(s zPS}HE-A9t4u2g09SbjMb7dmOdA|DqXcQ(XEaYA zV3|Dk^s@>^mwViB%#d_d^&2L%)hTm+zX^Vb{=WS2kQ}Gm@&56T0D?s}U6V1NqP8H-14>AoUeiF&&lS2YYih72YNbE+cqR=2CR%;d|}~Wokl~PSRXQB zLnvP|N%{dEzF614Y;KKFr~v1M%!v+cgcwsOALoY({x@z&BjrO0%jeQ8Jn^ztSb)#7 zz|C0$m2=j@Q!;&uW>+C|RD%nyB0%$1ge_SuF2vTT7S=_aEe5tE%KWw=FiWEd+u<(D z=3&RID9)VaTR;s(ouE23KLg_ugXtFlH=`?#)};|h*7psdXb;H%?nkz+l_ zl^j=H-OM&>0HGfTc!iB%H}Ql)u$mz~yH{s*cuh7hRD{6%sS`DUv`hkWEKH=pJll|rkicqxV5RS#l_(ZNQrF66fD`~cW-xvGdhW`Nnd_fVE4PV- zxp*KO3R1|x1|Sxa*(my%$!#cQw1xg?av&>xY&tzOpONseg)C)E>0nUKzy%=cpQU1i z9Uxr-FkeN@%tU79Vuls-@9Nrrs+B+TztJI&@|ER%WME@R+zPgyH=y107yCnZ17rLQ z8UbV-fY+NLs~NL~zEeM!(1nXGtZEINn)8ioLY8Tzo!7 z?!#L@pE?JLNELmfJiCMxuhNLqSI(;t(yTSP3m@mkMrV{;)5;jBp_iSRC1mNP2VEjs zOGjNd_HBeDKLr7ZkpX+-NIQcnfV2T%wo1~$$o2E&xesP8pC@gM+%!*)WpfkRG&{ZN zo43eI^vd#mHg>e*nsI?NF)iPznHIUr49^{IjP7@jdw3H%nQAM5EC4_g7Ih&;dp!G5 z7rp86BNKh#iOl|59`s+GOVW2MHhNaDyiS1G=FLqs;e+bB_G^XK%EpFNGpJzTqkmO; zk(YX_zFbOar)iU~-rNO|qL!Z%X{9mW#8N`f(1X?Wwr8RINH_2_=~|lfl`40t&m(U$ zD`^{m%-$dG+|}OBhg7xG4wv0PF!;KZQ#Cz0)Ti7RCv~WvjyJrFR??>pO-Q7x{4b(D z8uix&`#>p{soJEh3Wvj~80i6m>;_PcQUc3ICkWnKT&bFLvIT!s-S2|^@$aGJYtj}zJuxeeV>%QQc6kPWNajMv! z>6)@RPIf=A?PZErGTWz$cLKLqR^6D{cy`;#ZQmApCxyx>oBh(3ku8^YjO-X|``*@+ e>3(F?l2B`K%tnv@%3qU?qs51gig0p!hG=;heL(Fan0 zK?}ZCesAO0&as1IfTIMuPPvOW*bVjuo-YVi4`j#FThPdT1%1RWhjK!sTDJ8oHHSob zV@?~XJw~KWtX&L<#|PQ>;=}6K;$hKffJsmz0F$bk)IC-0gxJ|posU$jS9M8^j{8l_ zZmK)54yD$6dTK$d20*gWiVEyI4uI19#pSopm(=;d?KjRZe>4MkiPoYVQ)Fxv$?aF? z%Twpd(>V}QD|^LsW?(f9?})1_1?w<)J%SUV0YIwyw<>k8ZDx1Pe`z&o{#8TV>NB=W zMCZcLM)eQ-c^%osPB>RQUC4AJ1OZYS0IxSoL&)^A8?GQ3W}nsj*KY(u(q(W=B{f%` z`m&rW&}Oz$|N0tIJk7psknT@Wesk{j>2r{XdYQphSVM~4Y{lho;9Y1lIK%R5@0F*g zmeskEI!#+ycVl)9S@y8IjS|_&eD2x(gOKDm{+>FpXfwiA1TR1axk{JxF(e;ZCC^=f z&C*Aa+_p+i7s?BT3{J1~=^Wj{f}Z^j9PNxSqoqBl?M3KgZ+V=$`-Xdvl{_;|d&NJ0VgM`JCtgyW?M2OXy4N zZtH3&AL(Ybr|o^x*oS=#0i?_T-h0pBAPuqK+uc>q0lQtbcZ_IASd}{$H6*|u3hpKn zI~(jH9qjYq012}{gMm;Ol#;V3CR0W2k!bob-2((20Vo=jgq(UY4jeA7SVg0Wm_nar z!##Nm7AR^`X)0N1n3a3Nx(vo8*;BnPFD|g?h$f=r$BrwbhvMVP=vXq5RuXA?5nQL$ zGrd+(d4<{L^B}5} L7c4rDcNr4^l|2;n diff --git a/__pycache__/server.cpython-312.pyc b/__pycache__/server.cpython-312.pyc index 17d489e0a375993521b09fa0b76f0c7597cfb39c..4b51137865ee12fdeb2eeebb278fd1b264b2e768 100644 GIT binary patch delta 2668 zcmai0TWlLe6!qHPG<9R=<;0HNw04}A)7nnbw0S?zRAc zJ#+8eJLCN={zt-g&uX<8;0Zn0w;Xn@+nh#w#d_zDH#leG`A&lYo5Y$WPsS+LE_sXw zWLTae_p9pb>yT%|NZkyEr54lz`Vp!R)Qx}!aJ*Q5$G*WafMLl8nrgA(PNSx2MZV=D z)Whchn^kmf*J{;vjOw%;Jy^*$2q;Z10{z0^hI>bwFL!J)-fAt{Jqb`c=Dc zOtUYF;}E@3oX|x-pyJ5SW=oTbn9?BzHN+0yQhP|Z#t>LoPGp?7I0jK8aL?#s9M(M3 zd?P=i&2GVLRGWRr@2Js{j;RLkF)i@pEbMt5^|*?1FKDP2bSx7E&L38s6&+lV&t;|L zh1K>@UcwibH+D?rx?M1z8~T+eT}P)#2&I#Y26yc) zl%dSZX0nUskJ&?bRyA%?ZHDJ zal0zU$U*C;L2AacR2&AO0~W1uBu7L+N}S6ISj0!cJVorbPHv8j+VcPBm==<$2qY-u zd2pN|4{aUbSXsR?`ur8^=!(hAtNA2OA}Nv+;s~Dr?>Trey#??b`KmfU@_dF`${=Kf zR6$BUAqa!taq7Fk2$Xcq8yq)Gey<&vJxMh$Qt=WMr$7|V(QGDzPtzfXq;2r)DLriW zN}HmEh|ht2QOVY=7_Cb%^jBb79DJHQvirJTp~DD>+h!c4yOWNlJ5;iY-DOrj)Q$>7qTKgH}RMcC-+|D3>bW6o3%C z(ud^lW``mLy3HUw%g71CL5OlTq_G@usObMRK zn)P@tvLaxuPw_=Sy+N?obPl>InU3R+0Qe=T3Hlu$P~J&QHx)82NO%-RxK~*STDcAu ze9fWhnaPt!!x8o7Ep5J?c?h{2T&n4i`yxB#G}NLA7AxJU^nzrU9lNtIy?5B@XO~3L zl+4JO9`T|X{s?4t42u;aN+B6xdY)M8vlhj)#aL6Yieg$aRspt8H1940ORB73e^rj8 zvtmAl#;^h|^v;*=k)G~Zzlndd|5E>3@du`cpB&@&&Gv_mlbayx-0!yB^soCjcLp{? R<3H3*C|A29CNpiEe*u^2Uv~fi delta 2525 zcmai0U2GIp6yDk0X=~~J^k=(U7TONmZ98qZ+ftxE(DGYqDN=r_XbBEO=XSexySv<( z4HSt+QjCdFY_3Qk3K$+t)5fUdBgPke;z1G;6{8xit39h

2ggslV)~= z3nv1*VCHraUa4NIS>+6+Yg&MnfKt0wXRT}@!YgBp^Vh~gpz|s9>pB>RS3M<5&Sl{u z2Cl(|Yj_$?-e};WHryr$ZoI^8m!v}3oZ^&5L&9jSX_Yr4P@0!q({V)r*$UB2V${MG zb01%KZMim7Ao#Qwc1dkg=%x4+HI%~6UBaWSB6WzI&At*Q!jKnSh{Se zE)$hZSg4~)H%TZ-czWRJC5hPzW$O}e?V{VX5&A5fZ6pbM-EL#<0n`j;B)-FrQu;}F z)~gKIkXr$nGIIAXB=8N|P}@we(IJa(=aS2cYP+dt^WMsaW6ChZ?N&x?aqln@vE3Gf ziQ8kt^_w{Mt+%pzpUuYrXpB7x`z?lkQV)I(*diS??W3a>>Y$Bf$kekGU(fHfX4hc0 z%bW=lmhE9pzz#J^T>d%61mspl}2Fr45zb=0R8}R?|XON@pc)juKhP=W-dk57ztG-KwvK4!~%| zO+yF_PLYLtM%Ku2qULj1m8@_S^(vNjGet5QH^m^k>ATyFYI+dGvmk81qAx`>Ns-mL zvpJb6bOO}FY~J6_jkEXth5s{Lkkc7SBdSJ^fZ<-&A7}$ZDR932=^okRKqPPspri26 z!zjjCZLsiP@LR!f-~^zsJTn?SRP-H9y$RlMa1syE`X$U40sUIx{P#DeO8Feu`19hs&^etGt&3x$7uu_T((U7dMiy&fq3DtG zc^YCrB%`kzWr{HsJ-}LOeAvZ|jzqE7Q0yQ^`5NV3^rW+zQIJJ%J_oG}tEgC3DhQ(l zC|+EOH_o^(6c5}u8YA~cDk&xeH`w7aqIYEe&x&0OIi-*xBlL3sVq;cycDrY+x+ePG amh0lO_;6F=VRGTSnyen^l{{WN>AwMwDIJCY diff --git a/__pycache__/sql_queries.cpython-312.pyc b/__pycache__/sql_queries.cpython-312.pyc index b7d27ec9fc4f372cea3a5ef05daeb05fa698591b..4194092ebd7799b3e2d850da32ffbe7b94b248a2 100644 GIT binary patch delta 561 zcmbOwwp)ttG%qg~0}w1#Kb^UncO#!03(Gb}28PM?tWuMgv&v1Lz%o@JL$9DHKPxr4 zBtA1m!B!!6vN@Z)mIjvs5(swnadi$+@O6yPNG?h&&xub-EJ@W=0IQk&fK@CwBeS?9 zzo-%-0@E1o;Tq(sfND6hU5p#j0JK_T%Tp1~phL6Py{o*^Fb zAhnYdxir}bMHj@Dg1H%m{1tdN&Gcq!MXA))P_{abx ZzU#?>SU}>t1EUlp$0r6LQN#yS2moWDlsf3!5lMgYO3+2FYl~3oN1^*e0Liu{M 0 else 0 - ai_lambda = 0.04 + scale_impact - - # 지수 감쇄 적용 - soi_score = math.exp(-ai_lambda * days_stagnant) * 100 - - # ECV 패널티 - existence_confidence = 1.0 - if file_count == 0: existence_confidence = 0.05 - elif file_count < 10: existence_confidence = 0.4 - - # Log Quality Scoring - log_quality_factor = 1.0 - if log and log != "데이터 없음": - if any(k in log for k in ["업로드", "수정", "등록", "변환", "파일", "업데이트"]): log_quality_factor = 1.0 - elif any(k in log for k in ["폴더", "생성", "삭제", "이동"]): log_quality_factor = 0.7 - elif any(k in log for k in ["참가자", "권한", "추가", "변경", "메일"]): log_quality_factor = 0.4 - else: log_quality_factor = 0.6 - - soi_score = soi_score * existence_confidence * log_quality_factor - if is_auto_delete: soi_score = 0.1 - - # [운영 일관성 분석 (OCI)] - history_rows = SOIPredictionService.get_historical_soi(cursor, p['project_id']) - oci_score = AnalysisService.calculate_operational_consistency(history_rows, days_stagnant) - - # 실무 투입 에너지 계산 - effort_days = 0 - if len(history_rows) > 1: - for i in range(1, len(history_rows)): - if history_rows[i]['file_count'] != history_rows[i-1]['file_count']: - effort_days += 1 - - work_effort_rate = round((effort_days / max(1, len(history_rows))) * 100, 1) - total_soi += soi_score - - # VCI 산출 - REPLACEMENT_LEVEL = 70.0 - asset_weight = (file_count / 200.0) + 0.5 - p_war_score = (soi_score - REPLACEMENT_LEVEL) * asset_weight - - results.append({ - "project_nm": p['short_nm'] or p['project_nm'], - "file_count": file_count, - "days_stagnant": days_stagnant, - "risk_count": round(p_war_score, 2), - "p_war": round(soi_score, 1), - "oci_score": oci_score, # 운영 일관성 지수 추가 - "is_auto_delete": is_auto_delete, - "master": p['master'], - "dept": p['department'], - "ai_lambda": round(ai_lambda, 4), - "log_quality": log_quality_factor, - "work_effort": work_effort_rate, - "avg_info": { - "avg_files": 0, - "avg_stagnant": 0, - "avg_risk": round(total_soi / len(projects), 1) - } - }) - - results.sort(key=lambda x: x['p_war']) - return results +import re +import math +import statistics +from datetime import datetime, timedelta +from sql_queries import DashboardQueries +from prediction_service import SOIPredictionService + +class AnalysisService: + """프로젝트 통계 및 활동성 분석 전문 서비스""" + + @staticmethod + def calculate_operational_consistency(history_rows, days_stagnant): + """운영 일관성 지수(OCI) 산출 로직 (자산 규모 및 장기 정체 패널티 포함) + 최근 30일간 활동 리듬 분석 + 현재 방치 기간에 따른 강력한 감쇄 + """ + if not history_rows or len(history_rows) < 2: + return 0.0 + + # [추가] 최신 상태 확인: 현재 로그가 '폴더자동삭제'면 점수 즉시 0점 (일수는 실제 일수 유지) + latest_log = history_rows[-1].get('recent_log', '') or '' + if latest_log and "폴더자동삭제" in latest_log.replace(" ", ""): + return 0.0 + + # 1. 최근 30일 이력 기반 Base Score 산출 + now = datetime.now().date() + recent_30 = [h for h in history_rows if (now - h['crawl_date']).days <= 30] + + if not recent_30: + return 0.0 + + # [추가] 자산 규모 확인: 파일이 0개면 운영 일관성 산출 자체가 무의미함 + max_files = max([int(h['file_count'] or 0) for h in recent_30]) + if max_files == 0: + return 0.0 + + # 주차별 활동 여부 (4주) - 파일이 1개 이상 존재할 때만 유효 활동으로 인정 + weeks_active = [False, False, False, False] + for h in recent_30: + if int(h['file_count'] or 0) > 0: + days_ago = (now - h['crawl_date']).days + week_idx = min(3, days_ago // 7) + weeks_active[week_idx] = True + + base_consistency = (sum(weeks_active) / 4) * 70 + + # 활동 밀도 (변화 발생일 비율) + effort_days = 0 + for i in range(1, len(recent_30)): + # '폴더자동삭제' 로그가 포함된 날의 변화는 관리 노력으로 인정하지 않음 + log_content = recent_30[i].get('recent_log', '') or '' + if "폴더자동삭제" in log_content.replace(" ", ""): + continue + + if recent_30[i]['file_count'] != recent_30[i-1]['file_count']: + effort_days += 1 + + density_score = (effort_days / max(1, len(recent_30))) * 30 + base_oci = base_consistency + density_score + + # 2. [핵심] 패널티 엔진 적용 + # A. 장기 정체 패널티: 방치일이 100일 이상이면 0점으로 수렴 + stagnation_factor = max(0, (100 - days_stagnant) / 100.0) + + # B. 자산 부족 패널티 (Existence Confidence): 파일이 너무 적으면 관리 신뢰도 하락 + # 10개 미만은 50%만 인정, 그 이상은 점진적으로 100%까지 회복 + asset_confidence = 1.0 + if max_files < 10: + asset_confidence = 0.5 + elif max_files < 30: + asset_confidence = 0.8 + + final_oci = base_oci * stagnation_factor * asset_confidence + + return round(final_oci, 1) + + @staticmethod + def calculate_activity_status(target_date_dt, log, file_count): + """개별 프로젝트의 활동 상태 및 방치일 산출 (현재 시각 기준 실질 방치일 산출)""" + status, days = "unknown", 999 + file_val = int(file_count) if file_count else 0 + has_log = log and log != "데이터 없음" and log != "X" + + # 실질적인 오늘 날짜를 기준으로 정체일 산출 (사용자 직관성 강화) + now_dt = datetime.now() + + if file_val == 0: + status = "unknown" + elif has_log: + is_auto = "폴더자동삭제" in log.replace(" ", "") + # 2자리 또는 4자리 연도 지원 정규식 + match = re.search(r'(\d{2,4})\.(\d{2})\.(\d{2})', log) + if match: + y, m, d = match.groups() + # 2자리 연도 보정 + if len(y) == 2: y = "20" + y + log_date = datetime.strptime(f"{y}.{m}.{d}", "%Y.%m.%d") + + # 수집일(target_date_dt)이 아닌 현재 시점(now_dt) 기준으로 차이 계산 + diff = (now_dt - log_date).days + days = diff + # 상태 판정은 수집 시점의 target_date_dt를 기준으로 할지 검토 필요하나, + # 사용자 요청에 따라 '이상한 계산'을 바로잡기 위해 현재 시점 기준 판정 적용 + status = "stale" if is_auto or diff > 14 else "warning" if diff > 7 else "active" + else: + status = "stale" + days = 999 + else: + status = "stale" + + return status, days + + @staticmethod + def get_project_activity_logic(cursor, date_str): + """활동도 분석 리포트 생성 로직""" + if not date_str or date_str == "-": + cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE) + res = cursor.fetchone() + target_date_val = res['last_date'] if res['last_date'] else datetime.now().date() + else: + target_date_val = datetime.strptime(date_str.replace(".", "-"), "%Y-%m-%d").date() + + target_date_dt = datetime.combine(target_date_val, datetime.min.time()) + cursor.execute(DashboardQueries.GET_PROJECT_LIST_FOR_ANALYSIS, (target_date_val,)) + rows = cursor.fetchall() + + analysis = {"summary": {"active": 0, "warning": 0, "stale": 0, "unknown": 0}, "details": []} + for r in rows: + status, days = AnalysisService.calculate_activity_status(target_date_dt, r['recent_log'], r['file_count']) + analysis["summary"][status] += 1 + analysis["details"].append({"name": r['short_nm'] or r['project_nm'], "status": status, "days_ago": days}) + + return analysis + + @staticmethod + def get_p_zsr_analysis_logic(cursor): + """절대적 방치 실태 고발 및 운영 일관성(OCI) 분석 로직""" + cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE) + res_date = cursor.fetchone() + if not res_date or not res_date['last_date']: + return [] + last_date = res_date['last_date'] + + # 특정 날짜(last_date) 이하의 각 프로젝트별 최신 데이터를 조인하도록 수정 + cursor.execute(""" + SELECT m.project_id, m.project_nm, m.short_nm, m.department, m.master, + h.recent_log, h.file_count, m.continent, m.country + FROM projects_master m + LEFT JOIN projects_history h ON h.project_id = m.project_id AND h.crawl_date = ( + SELECT MAX(crawl_date) + FROM projects_history + WHERE project_id = m.project_id AND crawl_date <= %s + ) + ORDER BY m.project_id ASC + """, (last_date,)) + projects = cursor.fetchall() + + if not projects: return [] + + results = [] + total_avi = 0 + total_files = 0 + project_data_list = [] + + # 1차 Pass: 개별 AVI 산출 및 전체 합계 집계 + now_dt = datetime.now() + for p in projects: + file_count = int(p['file_count']) if p['file_count'] else 0 + log = p['recent_log'] + + # 방치일 계산 (현재 시각 기준 동기화) + days_stagnant = 14 + is_auto_delete = log and "폴더자동삭제" in log.replace(" ", "") + + if log and log != "데이터 없음": + match = re.search(r'(\d{2,4})\.(\d{2})\.(\d{2})', log) + if match: + y, m, d = match.groups() + if len(y) == 2: y = "20" + y + log_date = datetime.strptime(f"{y}.{m}.{d}", "%Y.%m.%d") + days_stagnant = (now_dt - log_date).days + elif is_auto_delete: + days_stagnant = 999 + + # AI-Hazard 추론 로직 (Dynamic Lambda) + scale_impact = min(0.04, math.log10(file_count + 1) * 0.008) if file_count > 0 else 0 + ai_lambda = 0.04 + scale_impact + + # 지수 감쇄 적용 + avi_score = math.exp(-ai_lambda * days_stagnant) * 100 + + # ECV 패널티 + existence_confidence = 1.0 + if file_count == 0: existence_confidence = 0.05 + elif file_count < 10: existence_confidence = 0.4 + + # Log Quality Scoring (SWVW 모델 적용) + from log_scorer import LogScorer + log_quality_factor = LogScorer.get_score(log) + + avi_score = avi_score * existence_confidence * log_quality_factor + if is_auto_delete: avi_score = 0.1 + + total_avi += avi_score + total_files += file_count + project_data_list.append({ + "p": p, + "avi_score": avi_score, + "file_count": file_count, + "days_stagnant": days_stagnant, + "is_auto_delete": is_auto_delete, + "log_quality": log_quality_factor, + "ai_lambda": ai_lambda + }) + + # 2차 Pass: 평균 기반 가치기여도(WAR) 산출 + num_projects = len(projects) if projects else 1 + avg_avi = total_avi / num_projects + avg_files = total_files / num_projects + + for item in project_data_list: + p = item['p'] + avi_score = item['avi_score'] + file_count = item['file_count'] + + # [운영 일관성 분석 (OCI)] + history_rows = SOIPredictionService.get_historical_avi(cursor, p['project_id']) + oci_score = AnalysisService.calculate_operational_consistency(history_rows, item['days_stagnant']) + + # 실무 투입 에너지 계산 + effort_days = 0 + if len(history_rows) > 1: + for i in range(1, len(history_rows)): + if history_rows[i]['file_count'] != history_rows[i-1]['file_count']: + effort_days += 1 + + work_effort_rate = round((effort_days / max(1, len(history_rows))) * 100, 1) + + # [VCI 산출 - 로그 기반 상대 가중치 모델 (수정)] + # 1. 파일 규모 가중치를 로그(log10) 기반으로 변경하여 선형 폭주 방지 + # 2. 평균 파일 수 대비 상대적 규모를 반영하되, 최대 가중치를 2.5로 캡핑(Capping) + if avg_files > 0: + relative_size = math.log10(file_count + 1) / math.log10(avg_files + 1) + else: + relative_size = 1.0 + + asset_weight = min(2.5, max(0.2, relative_size)) + p_war_score = (avi_score - avg_avi) * asset_weight + + results.append({ + "project_nm": p['short_nm'] or p['project_nm'], + "file_count": file_count, + "days_stagnant": item['days_stagnant'], + "risk_count": round(p_war_score, 2), # WAR 기반 가치기여도 (평균 0) + "p_war": round(avi_score, 1), + "oci_score": oci_score, + "is_auto_delete": item['is_auto_delete'], + "master": p['master'], + "dept": p['department'], + "ai_lambda": round(item['ai_lambda'], 4), + "log_quality": item['log_quality'], + "work_effort": work_effort_rate, + "avg_info": { + "avg_files": round(avg_files, 1), + "avg_stagnant": 0, + "avg_risk": round(avg_avi, 1) + } + }) + + results.sort(key=lambda x: x['p_war']) + return results diff --git a/analyze.py b/analyze.py index 70108e4..02387e0 100644 --- a/analyze.py +++ b/analyze.py @@ -1,167 +1,175 @@ -import os -import re -import unicodedata -from pypdf import PdfReader -import pytesseract -from pdf2image import convert_from_path - -# 1. 시스템 설정 -TESSERACT_EXE = r'C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tesseract.exe' -TESSDATA_DIR = r'C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tessdata' -POPPLER_BIN = r'D:\이태훈\00크롬다운로드\poppler-25.12.0\Library\bin' - -pytesseract.pytesseract.tesseract_cmd = TESSERACT_EXE -os.environ["TESSDATA_PREFIX"] = TESSDATA_DIR -OCR_AVAILABLE = os.path.exists(TESSERACT_EXE) - -SYSTEM_HIERARCHY = { - "행정": { - "계약": ["계약관리", "기성관리", "업무지시서", "인원관리"], - "업무관리": ["업무일지(2025)", "업무일지(2025년 이전)", "발주처 정기보고", "본사업무보고", "공사감독일지", "양식서류"] - }, - "설계성과품": { - "시방서": ["공사시방서", "장비 반입허가 검토서"], - "설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"], - "수량산출서": ["토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"], - "내역서": ["단가산출서"], - "보고서": ["실시설계보고서", "지반조사보고서", "구조계산서", "수리 및 전기계산서", "기타보고서", "기술자문 및 심의"], - "측량계산부": ["측량계산부"], - "설계단계 수행협의": ["회의·협의"] - }, - "시공성과품": { - "설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"] - }, - "시공검측": { - "토공": ["검측 (깨기)", "검측 (연약지반)", "검측 (발파)", "검측 (노체)", "검측 (노상)", "검측 (토취장)"], - "배수공": ["검측 (V형측구)", "검측 (산마루측구)", "검측 (U형측구)", "검측 (U형측구)(안)", "검측 (L형측구, J형측구)", "검측 (도수로)", "검측 (도수로)(안)", "검측 (횡배수관)", "검측 (종배수관)", "검측 (맹암거)", "검측 (통로암거)", "검측 (수로암거)", "검측 (호안공)", "검측 (옹벽공)", "검측 (용수개거)"], - "구조물공": ["검측 (평목교-거더, 부대공)", "검측 (평목교)(안)", "검측 (개착터널, 생태통로)"], - "포장공": ["검측 (기층, 보조기층)"], - "부대공": ["검측 (환경)", "검측 (지장가옥,건물 철거)", "검측 (방음벽 등)"], - "비탈면안전공": ["검측 (식생보호공)", "검측 (구조물보호공)"], - "교통안전시설공": ["검측 (낙석방지책)"], - "검측 양식서류": ["검측 양식서류"] - }, - "설계변경": { - "실정보고(어천~공주)": ["토공", "배수공", "교량공(평목교)", "구조물공", "포장공", "교통안전공", "부대공", "전기공사", "미확정공", "안전관리", "환경관리", "품질관리", "자재관리", "지장물", "기타"], - "실정보고(대술~정안)": ["토공", "배수공", "비탈면안전공", "포장공", "부대공", "안전관리", "환경관리", "자재관리", "기타"], - "기술지원 검토": ["토공", "배수공", "교량공(평목교)", "구조물&부대공", "기타"], - "시공계획(어천~공주)": ["토공", "배수공", "교량공(평목교)", "구조물&부대&포장&교통안전공", "환경 및 품질관리"] - }, - "공사관리": { - "공정·일정": ["공정표", "월간 공정보고", "작업일보"], - "품질 관리": ["품질시험계획서", "품질시험 실적보고", "콘크리트 타설현황[어천~공주(4차)]", "품질관리비 사용내역", "균열관리", "품질관리 양식서류"], - "안전 관리": ["안전관리계획서", "안전관리 실적보고", "위험성 평가", "사전작업허가서", "안전관리비 사용내역", "안전관리수준평가", "안전관리 양식서류"], - "환경 관리": ["환경영향평가", "사전재해영향성검토", "유지관리 및 보수점검", "환경보전비 사용내역", "건설폐기물 관리"], - "자재 관리 (관급)": ["자재구매요청 (레미콘, 철근)", "자재구매요청 (그 외)", "납품기한", "계약 변경", "자재 반입·수불 관리", "자재관리 양식서류"], - "자재 관리 (사급)": ["자재공급원 승인", "자재 반입·수불 관리", "자재 검수·확인"], - "점검 (정리중)": ["내부점검", "외부점검"], - "공문": ["접수(수신)", "발송(발신)", "하도급", "인력", "방침"] - }, - "민원관리": { - "민원(어천~공주)": ["처리대장", "보상", "공사일반", "환경분쟁"], - "실정보고(어천~공주)": ["민원"], - "실정보고(대술~정안)": ["민원"] - } -} - -def analyze_flow_reasoning(filename, all_text_list): - """ - 본문의 전수 조사 결과에 파일명의 '의도 가중치'를 더해 최종 추론 - """ - full_text = " ".join(all_text_list) - clean_ctx = full_text.replace(" ", "").replace("\n", "").lower() - fn_clean = filename.replace(" ", "").lower() - - # 1. 도메인별 기본 점수 (본문 전수 조사 - 평등하게) - scores = { - "official": sum(clean_ctx.count(k) for k in ["수신:", "발신:", "경유:", "시행일자", "귀하", "드립니다", "바랍니다"]), - "contract": sum(clean_ctx.count(k) for k in ["계약서", "하도급", "외주", "도급", "인감", "사업자"]), - "hr": sum(clean_ctx.count(k) for k in ["이탈계", "인력", "기술자", "안전관리자", "재직증명", "배치"]), - "change": sum(clean_ctx.count(k) for k in ["실정보고", "설계변경", "변경보고", "추가반영"]), - "technical": sum(clean_ctx.count(k) for k in ["일위대가", "산출근거", "집계표", "물량산출", "단가", "내역", "도면", "dwg"]) - } - - # 2. 파일명에 대한 '방향타' 가중치 부여 (Final Push) - # 본문 데이터가 아무리 많아도 파일명의 의도를 존중하기 위해 7배 가중치 - if "실정" in fn_clean or "변경" in fn_clean: scores["change"] += 50 # 본문 50회 언급과 맞먹는 가중치 - if "계약" in fn_clean or "하도급" in fn_clean: scores["contract"] += 50 - if "인력" in fn_clean or "이탈" in fn_clean: scores["hr"] += 50 - if "단가" in fn_clean or "수량" in fn_clean or "도면" in fn_clean: scores["technical"] += 50 - if "제출" in fn_clean or "건" in fn_clean: scores["official"] += 30 - - # 3. 종합 농도에 따른 최종 도메인 선정 - dominant_domain = max(scores, key=scores.get) - - # 프로젝트 식별 (Fuzzy 매칭 및 교차 검증) - project_loc = "어천~공주" if any(k in clean_ctx or k in fn_clean for k in ["어천", "공주"]) else "대술~정안" if any(k in clean_ctx or k in fn_clean for k in ["대술", "정안"]) else "공통" - - # --- [통합 추론 및 매칭] --- - - # 시나리오 A: 실정보고/설계변경 (본문 데이터 + 파일명 의도 합성) - if dominant_domain == "change" or (scores["change"] > 0 and scores["technical"] > 5): - cat = f"실정보고({project_loc})" - sub = "지장물" if any(k in clean_ctx for k in ["임대료", "토지", "보상"]) else "구조물공" if "구조물" in clean_ctx else "기타" - return f"설계변경 > {cat} > {sub}", f"본문의 기술 데이터 밀도와 파일명의 '{dominant_domain}' 관련 의도를 종합하여 {project_loc} 프로젝트의 실정보고 본체로 판정." - - # 시나리오 B: 행정 계약/하도급 (본체 중심) - if dominant_domain == "contract": - return "행정 > 계약 > 계약관리", "문서 전체에서 계약 및 하도급 업무 본질이 지배적으로 확인됨." - - # 시나리오 C: 인사/인력 관리 - if dominant_domain == "hr": - if len(all_text_list) <= 2: return "공사관리 > 공문 > 인력", "인력 사항을 간략히 보고하는 공문 형식임." - return "행정 > 계약 > 인원관리", "다량의 인력 증빙 데이터가 포함된 행정 서류임." - - # 시나리오 D: 순수 공문 (형식 우선) - if dominant_domain == "official" or scores["official"] > scores["technical"]: - tab, cat = "공사관리", "공문" - sub = "접수(수신)" - if "방침" in clean_ctx or "지침" in clean_ctx: sub = "방침" - elif "발신" in clean_ctx[:500]: sub = "발송(발신)" - return f"{tab} > {cat} > {sub}", "전체 맥락상 기술적 데이터보다 행정적 전달 행위(공문)가 핵심 정체성으로 판단됨." - - # 시나리오 E: 기술 성과품 - if dominant_domain == "technical": - if any(k in clean_ctx or k in fn_clean for k in ["단가", "내역"]): return "설계성과품 > 내역서 > 단가산출서", "내역/단가 산출 기술 데이터 확인." - if any(k in clean_ctx or k in fn_clean for k in ["도면", "dwg"]): return "설계성과품 > 설계도면 > 공통", "도면/그래픽 데이터 확인." - return "설계성과품 > 수량산출서 > 토공", "수량/물량 산출 데이터 확인." - - return "행정 > 업무관리 > 양식서류", "일반 행정 및 기타 양식 서류로 분류함." - -def analyze_file_content(filename: str): - try: - file_path = os.path.join("sample", filename) - text_by_pages = [] - if filename.lower().endswith(".pdf"): - reader = PdfReader(file_path) - for i in range(len(reader.pages)): - page_text = reader.pages[i].extract_text() or "" - if OCR_AVAILABLE: - try: - images = convert_from_path(file_path, first_page=i+1, last_page=i+1, poppler_path=POPPLER_BIN, dpi=200) - if images: - ocr_result = pytesseract.image_to_string(images[0], lang='kor+eng') - page_text += "\n" + ocr_result - except Exception as ocr_err: - print(f"OCR Error on page {i+1}: {ocr_err}") - text_by_pages.append(page_text) - elif filename.lower().endswith(('.xlsx', '.xls')): - import pandas as pd - df = pd.read_excel(file_path) - text_by_pages.append(df.to_string()) - else: text_by_pages.append("") - - path, reason = analyze_flow_reasoning(filename, text_by_pages) - - return { - "filename": filename, - "total_pages": len(text_by_pages), - "final_result": { - "suggested_path": path, - "confidence": "100%", - "reason": reason, - "snippet": " ".join(text_by_pages)[:1500] - } - } - except Exception as e: - return {"error": str(e), "filename": filename} +import os +import re +import unicodedata +from pypdf import PdfReader +import pytesseract +from pdf2image import convert_from_path + +# 1. 시스템 설정 +# OS에 따른 경로 설정 (Docker/Linux 환경 대응) +if os.name == 'posix': # Linux (Docker) + TESSERACT_EXE = 'tesseract' + TESSDATA_DIR = '/usr/share/tesseract-ocr/4.00/tessdata' # 일반적인 리눅스 경로 + # 리눅스에서는 보통 패스에 있으므로 별도 설정 불필요할 수 있음 + pytesseract.pytesseract.tesseract_cmd = TESSERACT_EXE + POPPLER_BIN = None + OCR_AVAILABLE = True # Dockerfile에서 설치함 +else: # Windows + TESSERACT_EXE = r'C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tesseract.exe' + TESSDATA_DIR = r'C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tessdata' + POPPLER_BIN = r'D:\이태훈\00크롬다운로드\poppler-25.12.0\Library\bin' + pytesseract.pytesseract.tesseract_cmd = TESSERACT_EXE + os.environ["TESSDATA_PREFIX"] = TESSDATA_DIR + OCR_AVAILABLE = os.path.exists(TESSERACT_EXE) + +SYSTEM_HIERARCHY = { + "행정": { + "계약": ["계약관리", "기성관리", "업무지시서", "인원관리"], + "업무관리": ["업무일지(2025)", "업무일지(2025년 이전)", "발주처 정기보고", "본사업무보고", "공사감독일지", "양식서류"] + }, + "설계성과품": { + "시방서": ["공사시방서", "장비 반입허가 검토서"], + "설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"], + "수량산출서": ["토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"], + "내역서": ["단가산출서"], + "보고서": ["실시설계보고서", "지반조사보고서", "구조계산서", "수리 및 전기계산서", "기타보고서", "기술자문 및 심의"], + "측량계산부": ["측량계산부"], + "설계단계 수행협의": ["회의·협의"] + }, + "시공성과품": { + "설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"] + }, + "시공검측": { + "토공": ["검측 (깨기)", "검측 (연약지반)", "검측 (발파)", "검측 (노체)", "검측 (노상)", "검측 (토취장)"], + "배수공": ["검측 (V형측구)", "검측 (산마루측구)", "검측 (U형측구)", "검측 (U형측구)(안)", "검측 (L형측구, J형측구)", "검측 (도수로)", "검측 (도수로)(안)", "검측 (횡배수관)", "검측 (종배수관)", "검측 (맹암거)", "검측 (통로암거)", "검측 (수로암거)", "검측 (호안공)", "검측 (옹벽공)", "검측 (용수개거)"], + "구조물공": ["검측 (평목교-거더, 부대공)", "검측 (평목교)(안)", "검측 (개착터널, 생태통로)"], + "포장공": ["검측 (기층, 보조기층)"], + "부대공": ["검측 (환경)", "검측 (지장가옥,건물 철거)", "검측 (방음벽 등)"], + "비탈면안전공": ["검측 (식생보호공)", "검측 (구조물보호공)"], + "교통안전시설공": ["검측 (낙석방지책)"], + "검측 양식서류": ["검측 양식서류"] + }, + "설계변경": { + "실정보고(어천~공주)": ["토공", "배수공", "교량공(평목교)", "구조물공", "포장공", "교통안전공", "부대공", "전기공사", "미확정공", "안전관리", "환경관리", "품질관리", "자재관리", "지장물", "기타"], + "실정보고(대술~정안)": ["토공", "배수공", "비탈면안전공", "포장공", "부대공", "안전관리", "환경관리", "자재관리", "기타"], + "기술지원 검토": ["토공", "배수공", "교량공(평목교)", "구조물&부대공", "기타"], + "시공계획(어천~공주)": ["토공", "배수공", "교량공(평목교)", "구조물&부대&포장&교통안전공", "환경 및 품질관리"] + }, + "공사관리": { + "공정·일정": ["공정표", "월간 공정보고", "작업일보"], + "품질 관리": ["품질시험계획서", "품질시험 실적보고", "콘크리트 타설현황[어천~공주(4차)]", "품질관리비 사용내역", "균열관리", "품질관리 양식서류"], + "안전 관리": ["안전관리계획서", "안전관리 실적보고", "위험성 평가", "사전작업허가서", "안전관리비 사용내역", "안전관리수준평가", "안전관리 양식서류"], + "환경 관리": ["환경영향평가", "사전재해영향성검토", "유지관리 및 보수점검", "환경보전비 사용내역", "건설폐기물 관리"], + "자재 관리 (관급)": ["자재구매요청 (레미콘, 철근)", "자재구매요청 (그 외)", "납품기한", "계약 변경", "자재 반입·수불 관리", "자재관리 양식서류"], + "자재 관리 (사급)": ["자재공급원 승인", "자재 반입·수불 관리", "자재 검수·확인"], + "점검 (정리중)": ["내부점검", "외부점검"], + "공문": ["접수(수신)", "발송(발신)", "하도급", "인력", "방침"] + }, + "민원관리": { + "민원(어천~공주)": ["처리대장", "보상", "공사일반", "환경분쟁"], + "실정보고(어천~공주)": ["민원"], + "실정보고(대술~정안)": ["민원"] + } +} + +def analyze_flow_reasoning(filename, all_text_list): + """ + 본문의 전수 조사 결과에 파일명의 '의도 가중치'를 더해 최종 추론 + """ + full_text = " ".join(all_text_list) + clean_ctx = full_text.replace(" ", "").replace("\n", "").lower() + fn_clean = filename.replace(" ", "").lower() + + # 1. 도메인별 기본 점수 (본문 전수 조사 - 평등하게) + scores = { + "official": sum(clean_ctx.count(k) for k in ["수신:", "발신:", "경유:", "시행일자", "귀하", "드립니다", "바랍니다"]), + "contract": sum(clean_ctx.count(k) for k in ["계약서", "하도급", "외주", "도급", "인감", "사업자"]), + "hr": sum(clean_ctx.count(k) for k in ["이탈계", "인력", "기술자", "안전관리자", "재직증명", "배치"]), + "change": sum(clean_ctx.count(k) for k in ["실정보고", "설계변경", "변경보고", "추가반영"]), + "technical": sum(clean_ctx.count(k) for k in ["일위대가", "산출근거", "집계표", "물량산출", "단가", "내역", "도면", "dwg"]) + } + + # 2. 파일명에 대한 '방향타' 가중치 부여 (Final Push) + # 본문 데이터가 아무리 많아도 파일명의 의도를 존중하기 위해 7배 가중치 + if "실정" in fn_clean or "변경" in fn_clean: scores["change"] += 50 # 본문 50회 언급과 맞먹는 가중치 + if "계약" in fn_clean or "하도급" in fn_clean: scores["contract"] += 50 + if "인력" in fn_clean or "이탈" in fn_clean: scores["hr"] += 50 + if "단가" in fn_clean or "수량" in fn_clean or "도면" in fn_clean: scores["technical"] += 50 + if "제출" in fn_clean or "건" in fn_clean: scores["official"] += 30 + + # 3. 종합 농도에 따른 최종 도메인 선정 + dominant_domain = max(scores, key=scores.get) + + # 프로젝트 식별 (Fuzzy 매칭 및 교차 검증) + project_loc = "어천~공주" if any(k in clean_ctx or k in fn_clean for k in ["어천", "공주"]) else "대술~정안" if any(k in clean_ctx or k in fn_clean for k in ["대술", "정안"]) else "공통" + + # --- [통합 추론 및 매칭] --- + + # 시나리오 A: 실정보고/설계변경 (본문 데이터 + 파일명 의도 합성) + if dominant_domain == "change" or (scores["change"] > 0 and scores["technical"] > 5): + cat = f"실정보고({project_loc})" + sub = "지장물" if any(k in clean_ctx for k in ["임대료", "토지", "보상"]) else "구조물공" if "구조물" in clean_ctx else "기타" + return f"설계변경 > {cat} > {sub}", f"본문의 기술 데이터 밀도와 파일명의 '{dominant_domain}' 관련 의도를 종합하여 {project_loc} 프로젝트의 실정보고 본체로 판정." + + # 시나리오 B: 행정 계약/하도급 (본체 중심) + if dominant_domain == "contract": + return "행정 > 계약 > 계약관리", "문서 전체에서 계약 및 하도급 업무 본질이 지배적으로 확인됨." + + # 시나리오 C: 인사/인력 관리 + if dominant_domain == "hr": + if len(all_text_list) <= 2: return "공사관리 > 공문 > 인력", "인력 사항을 간략히 보고하는 공문 형식임." + return "행정 > 계약 > 인원관리", "다량의 인력 증빙 데이터가 포함된 행정 서류임." + + # 시나리오 D: 순수 공문 (형식 우선) + if dominant_domain == "official" or scores["official"] > scores["technical"]: + tab, cat = "공사관리", "공문" + sub = "접수(수신)" + if "방침" in clean_ctx or "지침" in clean_ctx: sub = "방침" + elif "발신" in clean_ctx[:500]: sub = "발송(발신)" + return f"{tab} > {cat} > {sub}", "전체 맥락상 기술적 데이터보다 행정적 전달 행위(공문)가 핵심 정체성으로 판단됨." + + # 시나리오 E: 기술 성과품 + if dominant_domain == "technical": + if any(k in clean_ctx or k in fn_clean for k in ["단가", "내역"]): return "설계성과품 > 내역서 > 단가산출서", "내역/단가 산출 기술 데이터 확인." + if any(k in clean_ctx or k in fn_clean for k in ["도면", "dwg"]): return "설계성과품 > 설계도면 > 공통", "도면/그래픽 데이터 확인." + return "설계성과품 > 수량산출서 > 토공", "수량/물량 산출 데이터 확인." + + return "행정 > 업무관리 > 양식서류", "일반 행정 및 기타 양식 서류로 분류함." + +def analyze_file_content(filename: str): + try: + file_path = os.path.join("sample", filename) + text_by_pages = [] + if filename.lower().endswith(".pdf"): + reader = PdfReader(file_path) + for i in range(len(reader.pages)): + page_text = reader.pages[i].extract_text() or "" + if OCR_AVAILABLE: + try: + images = convert_from_path(file_path, first_page=i+1, last_page=i+1, poppler_path=POPPLER_BIN, dpi=200) + if images: + ocr_result = pytesseract.image_to_string(images[0], lang='kor+eng') + page_text += "\n" + ocr_result + except Exception as ocr_err: + print(f"OCR Error on page {i+1}: {ocr_err}") + text_by_pages.append(page_text) + elif filename.lower().endswith(('.xlsx', '.xls')): + import pandas as pd + df = pd.read_excel(file_path) + text_by_pages.append(df.to_string()) + else: text_by_pages.append("") + + path, reason = analyze_flow_reasoning(filename, text_by_pages) + + return { + "filename": filename, + "total_pages": len(text_by_pages), + "final_result": { + "suggested_path": path, + "confidence": "100%", + "reason": reason, + "snippet": " ".join(text_by_pages)[:1500] + } + } + except Exception as e: + return {"error": str(e), "filename": filename} diff --git a/analyze_logs_pattern.py b/analyze_logs_pattern.py new file mode 100644 index 0000000..5aae4c4 --- /dev/null +++ b/analyze_logs_pattern.py @@ -0,0 +1,48 @@ +import pymysql +import re +from collections import Counter + +def get_db_connection(): + return pymysql.connect( + host='localhost', + user='root', + password='45278434', + database='pm_proto_test', + charset='utf8mb4', + cursorclass=pymysql.cursors.DictCursor + ) + +def analyze_logs(): + conn = get_db_connection() + try: + with conn.cursor() as cursor: + cursor.execute("SELECT DISTINCT recent_log FROM projects_history WHERE recent_log IS NOT NULL AND recent_log != ''") + rows = cursor.fetchall() + + logs = [r['recent_log'] for r in rows] + + output = [] + output.append("[Raw Log Samples]") + for log in logs[:20]: + output.append(f"- {log}") + + patterns = [] + for log in logs: + p = re.sub(r'\d{2,4}\.\d{2}\.\d{2}', '[DATE]', log) + p = re.sub(r'\d+', '[NUM]', p) + patterns.append(p) + + output.append("\n[Log Patterns Frequency]") + pattern_counts = Counter(patterns).most_common(20) + for p, count in pattern_counts: + output.append(f"({count}) {p}") + + with open("log_analysis_result.txt", "w", encoding="utf-8") as f: + f.write("\n".join(output)) + print("Analysis complete. Result saved to log_analysis_result.txt") + + finally: + conn.close() + +if __name__ == "__main__": + analyze_logs() diff --git a/check_tables.py b/check_tables.py new file mode 100644 index 0000000..caac7c4 --- /dev/null +++ b/check_tables.py @@ -0,0 +1,29 @@ +import pymysql +import os +from dotenv import load_dotenv + +load_dotenv() + +def show_tables(): + conn = pymysql.connect( + host=os.getenv('DB_HOST', 'localhost'), + user=os.getenv('DB_USER', 'root'), + password=os.getenv('DB_PASSWORD', '45278434'), + database='PM_proto_test', + charset='utf8mb4', + cursorclass=pymysql.cursors.DictCursor + ) + try: + with conn.cursor() as cursor: + cursor.execute("SHOW TABLES") + tables = cursor.fetchall() + print("Tables in PM_proto_test:") + for t in tables: + print(f" - {list(t.values())[0]}") + except Exception as e: + print(f"Error occurred: {e}") + finally: + conn.close() + +if __name__ == "__main__": + show_tables() diff --git a/clear_test_db.py b/clear_test_db.py new file mode 100644 index 0000000..adec441 --- /dev/null +++ b/clear_test_db.py @@ -0,0 +1,29 @@ +import pymysql +import os +from dotenv import load_dotenv + +load_dotenv() + +def clear_project_history(): + conn = pymysql.connect( + host=os.getenv('DB_HOST', 'localhost'), + user=os.getenv('DB_USER', 'root'), + password=os.getenv('DB_PASSWORD', '45278434'), + database='PM_proto_test', + charset='utf8mb4', + cursorclass=pymysql.cursors.DictCursor + ) + try: + with conn.cursor() as cursor: + # 테이블의 모든 데이터를 삭제 + print("Cleaning projects_history table in PM_proto_test...") + cursor.execute("DELETE FROM projects_history") + conn.commit() + print("Successfully cleared all records from projects_history.") + except Exception as e: + print(f"Error occurred: {e}") + finally: + conn.close() + +if __name__ == "__main__": + clear_project_history() diff --git a/clone_db.py b/clone_db.py new file mode 100644 index 0000000..06d4abe --- /dev/null +++ b/clone_db.py @@ -0,0 +1,45 @@ +import pymysql +import os + +def clone_database(): + try: + connection = pymysql.connect( + host=os.getenv('DB_HOST', 'localhost'), + user=os.getenv('DB_USER', 'root'), + password=os.getenv('DB_PASSWORD', '45278434'), + charset='utf8mb4', + cursorclass=pymysql.cursors.DictCursor + ) + + with connection.cursor() as cursor: + # 1. Create test database + cursor.execute("CREATE DATABASE IF NOT EXISTS PM_proto_test") + print("Database PM_proto_test created or already exists.") + + # 2. Get all tables from source database + cursor.execute("SHOW TABLES FROM PM_proto") + tables = cursor.fetchall() + + for table_row in tables: + table_name = list(table_row.values())[0] + + # 3. Drop existing table in test DB if exists + cursor.execute(f"DROP TABLE IF EXISTS PM_proto_test.{table_name}") + + # 4. Clone schema and data + # Note: CREATE TABLE ... LIKE doesn't copy data, and CREATE TABLE ... AS SELECT doesn't copy indexes. + # So we use LIKE first, then INSERT INTO ... SELECT * + cursor.execute(f"CREATE TABLE PM_proto_test.{table_name} LIKE PM_proto.{table_name}") + cursor.execute(f"INSERT INTO PM_proto_test.{table_name} SELECT * FROM PM_proto.{table_name}") + print(f"Table {table_name} cloned.") + + connection.commit() + print("Database cloning completed successfully.") + except Exception as e: + print(f"Error during database cloning: {e}") + finally: + if 'connection' in locals(): + connection.close() + +if __name__ == "__main__": + clone_database() diff --git a/crawler_service.py b/crawler_service.py index 0d9ca37..1b333b3 100644 --- a/crawler_service.py +++ b/crawler_service.py @@ -1,258 +1,258 @@ -import os -import re -import asyncio -import json -import traceback -import sys -import threading -import queue -import pymysql -from datetime import datetime -from playwright.async_api import async_playwright -from dotenv import load_dotenv -from sql_queries import CrawlerQueries - -load_dotenv(override=True) - -# 글로벌 중단 제어용 이벤트 -crawl_stop_event = threading.Event() - -def get_db_connection(): - """MySQL 데이터베이스 연결을 반환 (환경변수 기반)""" - return pymysql.connect( - host=os.getenv('DB_HOST', 'localhost'), - user=os.getenv('DB_USER', 'root'), - password=os.getenv('DB_PASSWORD', '45278434'), - database=os.getenv('DB_NAME', 'PM_proto'), - charset='utf8mb4', - cursorclass=pymysql.cursors.DictCursor - ) - -def clean_date_string(date_str): - if not date_str: return "" - match = re.search(r'(\d{2})[./-](\d{2})[./-](\d{2})', date_str) - if match: return f"20{match.group(1)}.{match.group(2)}.{match.group(3)}" - return date_str[:10].replace("-", ".") - -def parse_log_id(log_id): - if not log_id or "_" not in log_id: return log_id - try: - parts = log_id.split('_') - if len(parts) >= 4: - date_part = clean_date_string(parts[1]) - activity = parts[3].strip() - activity = re.sub(r'\(.*?\)', '', activity).strip() - return f"{date_part}, {activity}" - except: pass - return log_id - -def crawler_thread_worker(msg_queue, user_id, password): - crawl_stop_event.clear() - if sys.platform == 'win32': - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) - - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - async def run(): - async with async_playwright() as p: - browser = None - try: - msg_queue.put(json.dumps({'type': 'log', 'message': '브라우저 엔진 가동 (전 기능 복구 모드)...'})) - browser = await p.chromium.launch(headless=True, args=[ - "--no-sandbox", - "--disable-dev-shm-usage", - "--disable-blink-features=AutomationControlled" - ]) - context = await browser.new_context( - viewport={'width': 1600, 'height': 900}, - user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" - ) - - captured_data = {"tree": None, "_is_root_archive": False, "project_list": [], "last_project_data": None} - - async def global_interceptor(response): - url = response.url - try: - if "getAllList" in url: - data = await response.json() - captured_data["project_list"] = data.get("data", []) - elif "getTreeObject" in url: - is_root = False - if "params[resourcePath]=" in url: - path_val = url.split("params[resourcePath]=")[1].split("&")[0] - if path_val in ["%2F", "/"]: is_root = True - if is_root: - captured_data["tree"] = await response.json() - captured_data["_is_root_archive"] = True - elif "getData" in url and "overview" in url: - captured_data["last_project_data"] = await response.json() - except: pass - - context.on("response", global_interceptor) - page = await context.new_page() - await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded") - - # 로그인 - if await page.locator("#login-by-id").is_visible(timeout=10000): - await page.click("#login-by-id") - await page.fill("#user_id", user_id) - await page.fill("#user_pw", password) - await page.click("#login-btn") - - await page.wait_for_selector("h4.list__contents_aria_group_body_list_item_label", timeout=60000) - await asyncio.sleep(3) - - # [Phase 1] DB 마스터 정보 동기화 - if captured_data["project_list"]: - conn = get_db_connection() - try: - with conn.cursor() as cursor: - for p_info in captured_data["project_list"]: - cursor.execute(CrawlerQueries.UPSERT_MASTER, (p_info.get("project_id"), p_info.get("project_nm"), - p_info.get("short_nm", "").strip(), p_info.get("master"), - p_info.get("large_class"), p_info.get("mid_class"))) - conn.commit() - msg_queue.put(json.dumps({'type': 'log', 'message': 'DB 마스터 정보 동기화 완료.'})) - finally: conn.close() - - # [Phase 2] 수집 루프 - names = await page.locator("h4.list__contents_aria_group_body_list_item_label").all_inner_texts() - project_names = list(dict.fromkeys([n.strip() for n in names if n.strip()])) - count = len(project_names) - - for i, project_name in enumerate(project_names): - if crawl_stop_event.is_set(): - msg_queue.put(json.dumps({'type': 'log', 'message': '>>> 중단 신호 감지: 종료합니다.'})) - break - - msg_queue.put(json.dumps({'type': 'log', 'message': f'[{i+1}/{count}] {project_name} 수집 시작'})) - p_match = next((p for p in captured_data["project_list"] if p.get('project_nm') == project_name or p.get('short_nm', '').strip() == project_name), None) - current_p_id = p_match.get('project_id') if p_match else None - captured_data["tree"] = None; captured_data["_is_root_archive"] = False - - try: - # 1. 프로젝트 진입 (좌표 클릭) - target_el = page.locator(f"h4.list__contents_aria_group_body_list_item_label:has-text('{project_name}')").first - await target_el.scroll_into_view_if_needed() - box = await target_el.bounding_box() - if box: await page.mouse.click(box['x'] + 5, box['y'] + 5) - else: await target_el.click(force=True) - - await page.wait_for_selector("text=활동로그", timeout=30000) - - # [부서 정보 수집] getData 응답 대기 및 DB 업데이트 - for _ in range(10): - if captured_data.get("last_project_data"): break - await asyncio.sleep(0.5) - - last_data = captured_data.get("last_project_data") - if last_data: - if isinstance(last_data, list) and len(last_data) > 0: - last_data = last_data[0] - - if isinstance(last_data, dict): - proj_data = last_data.get("data", {}) - if isinstance(proj_data, list) and len(proj_data) > 0: - proj_data = proj_data[0] - - if isinstance(proj_data, dict): - dept = proj_data.get("department") - p_id = proj_data.get("project_id") - if dept and p_id: - with get_db_connection() as conn: - with conn.cursor() as cursor: - cursor.execute(CrawlerQueries.UPDATE_DEPARTMENT, (dept, p_id)) - conn.commit() - captured_data["last_project_data"] = None # 초기화 - - await asyncio.sleep(2) - - recent_log = "데이터 없음"; file_count = 0 - - # 2. 활동로그 (날짜 필터 적용 버전) - modal_opened = False - for _ in range(3): - await page.get_by_text("활동로그").first.click() - try: - await page.wait_for_selector("article.archive-modal", timeout=5000) - modal_opened = True; break - except: await asyncio.sleep(1) - - if modal_opened: - # 날짜 필터 2020-01-01 적용 - inputs = await page.locator("article.archive-modal input").all() - for inp in inputs: - if (await inp.get_attribute("type")) == "date": - await inp.fill("2020-01-01"); break - - apply_btn = page.locator("article.archive-modal").get_by_text("적용").first - if await apply_btn.is_visible(): - await apply_btn.click() - await asyncio.sleep(5) - log_elements = await page.locator("article.archive-modal div[id*='_']").all() - if log_elements: - recent_log = parse_log_id(await log_elements[0].get_attribute("id")) - await page.keyboard.press("Escape") - - # 3. 구성 수집 (API Fetch 방식 - 팝업 없음) - await page.evaluate("""() => { - const baseUrl = window.location.origin + window.location.pathname.split('/').slice(0, 2).join('/'); - fetch(`${baseUrl}/archive/getTreeObject?params[storageType]=CLOUD¶ms[resourcePath]=/`); - }""") - for _ in range(30): - if captured_data["_is_root_archive"]: break - await asyncio.sleep(0.5) - - if captured_data["tree"]: - tree_data = captured_data["tree"] - if isinstance(tree_data, list) and len(tree_data) > 0: - tree_data = tree_data[0] - - if isinstance(tree_data, dict): - tree = tree_data.get('currentTreeObject', tree_data) - if isinstance(tree, dict): - total = len(tree.get("file", {})) - folders = tree.get("folder", {}) - if isinstance(folders, dict): - for f in folders.values(): total += int(f.get("filesCount", 0)) - file_count = total - - # 4. DB 실시간 저장 - if current_p_id: - with get_db_connection() as conn: - with conn.cursor() as cursor: - cursor.execute(CrawlerQueries.UPSERT_HISTORY, (current_p_id, recent_log, file_count)) - conn.commit() - msg_queue.put(json.dumps({'type': 'log', 'message': f' - [성공] 로그: {recent_log[:20]}... / 파일: {file_count}개'})) - - await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded") - - except Exception as e: - msg_queue.put(json.dumps({'type': 'log', 'message': f' - {project_name} 실패: {str(e)}'})) - await page.goto("https://overseas.projectmastercloud.com/dashboard") - - msg_queue.put(json.dumps({'type': 'done', 'data': []})) - - except Exception as e: - msg_queue.put(json.dumps({'type': 'log', 'message': f'치명적 오류: {str(e)}'})) - finally: - if browser: await browser.close() - msg_queue.put(None) - - loop.run_until_complete(run()) - loop.close() - -async def run_crawler_service(): - msg_queue = queue.Queue() - thread = threading.Thread(target=crawler_thread_worker, args=(msg_queue, os.getenv("PM_USER_ID"), os.getenv("PM_PASSWORD"))) - thread.start() - while True: - try: - msg = await asyncio.to_thread(msg_queue.get, timeout=1.0) - if msg is None: break - yield f"data: {msg}\n\n" - except queue.Empty: - if not thread.is_alive(): break - await asyncio.sleep(0.1) - thread.join() +import os +import re +import asyncio +import json +import traceback +import sys +import threading +import queue +import pymysql +from datetime import datetime +from playwright.async_api import async_playwright +from dotenv import load_dotenv +from sql_queries import CrawlerQueries + +load_dotenv(override=True) + +# 글로벌 중단 제어용 이벤트 +crawl_stop_event = threading.Event() + +def get_db_connection(): + """MySQL 데이터베이스 연결을 반환 (환경변수 기반)""" + return pymysql.connect( + host=os.getenv('DB_HOST', 'localhost'), + user=os.getenv('DB_USER', 'root'), + password=os.getenv('DB_PASSWORD', '45278434'), + database=os.getenv('DB_NAME', 'PM_proto'), + charset='utf8mb4', + cursorclass=pymysql.cursors.DictCursor + ) + +def clean_date_string(date_str): + if not date_str: return "" + match = re.search(r'(\d{2})[./-](\d{2})[./-](\d{2})', date_str) + if match: return f"20{match.group(1)}.{match.group(2)}.{match.group(3)}" + return date_str[:10].replace("-", ".") + +def parse_log_id(log_id): + if not log_id or "_" not in log_id: return log_id + try: + parts = log_id.split('_') + if len(parts) >= 4: + date_part = clean_date_string(parts[1]) + activity = parts[3].strip() + activity = re.sub(r'\(.*?\)', '', activity).strip() + return f"{date_part}, {activity}" + except: pass + return log_id + +def crawler_thread_worker(msg_queue, user_id, password): + crawl_stop_event.clear() + if sys.platform == 'win32': + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + async def run(): + async with async_playwright() as p: + browser = None + try: + msg_queue.put(json.dumps({'type': 'log', 'message': '브라우저 엔진 가동 (전 기능 복구 모드)...'})) + browser = await p.chromium.launch(headless=True, args=[ + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-blink-features=AutomationControlled" + ]) + context = await browser.new_context( + viewport={'width': 1600, 'height': 900}, + user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" + ) + + captured_data = {"tree": None, "_is_root_archive": False, "project_list": [], "last_project_data": None} + + async def global_interceptor(response): + url = response.url + try: + if "getAllList" in url: + data = await response.json() + captured_data["project_list"] = data.get("data", []) + elif "getTreeObject" in url: + is_root = False + if "params[resourcePath]=" in url: + path_val = url.split("params[resourcePath]=")[1].split("&")[0] + if path_val in ["%2F", "/"]: is_root = True + if is_root: + captured_data["tree"] = await response.json() + captured_data["_is_root_archive"] = True + elif "getData" in url and "overview" in url: + captured_data["last_project_data"] = await response.json() + except: pass + + context.on("response", global_interceptor) + page = await context.new_page() + await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded") + + # 로그인 + if await page.locator("#login-by-id").is_visible(timeout=10000): + await page.click("#login-by-id") + await page.fill("#user_id", user_id) + await page.fill("#user_pw", password) + await page.click("#login-btn") + + await page.wait_for_selector("h4.list__contents_aria_group_body_list_item_label", timeout=60000) + await asyncio.sleep(3) + + # [Phase 1] DB 마스터 정보 동기화 + if captured_data["project_list"]: + conn = get_db_connection() + try: + with conn.cursor() as cursor: + for p_info in captured_data["project_list"]: + cursor.execute(CrawlerQueries.UPSERT_MASTER, (p_info.get("project_id"), p_info.get("project_nm"), + p_info.get("short_nm", "").strip(), p_info.get("master"), + p_info.get("large_class"), p_info.get("mid_class"))) + conn.commit() + msg_queue.put(json.dumps({'type': 'log', 'message': 'DB 마스터 정보 동기화 완료.'})) + finally: conn.close() + + # [Phase 2] 수집 루프 + names = await page.locator("h4.list__contents_aria_group_body_list_item_label").all_inner_texts() + project_names = list(dict.fromkeys([n.strip() for n in names if n.strip()])) + count = len(project_names) + + for i, project_name in enumerate(project_names): + if crawl_stop_event.is_set(): + msg_queue.put(json.dumps({'type': 'log', 'message': '>>> 중단 신호 감지: 종료합니다.'})) + break + + msg_queue.put(json.dumps({'type': 'log', 'message': f'[{i+1}/{count}] {project_name} 수집 시작'})) + p_match = next((p for p in captured_data["project_list"] if p.get('project_nm') == project_name or p.get('short_nm', '').strip() == project_name), None) + current_p_id = p_match.get('project_id') if p_match else None + captured_data["tree"] = None; captured_data["_is_root_archive"] = False + + try: + # 1. 프로젝트 진입 (좌표 클릭) + target_el = page.locator(f"h4.list__contents_aria_group_body_list_item_label:has-text('{project_name}')").first + await target_el.scroll_into_view_if_needed() + box = await target_el.bounding_box() + if box: await page.mouse.click(box['x'] + 5, box['y'] + 5) + else: await target_el.click(force=True) + + await page.wait_for_selector("text=활동로그", timeout=30000) + + # [부서 정보 수집] getData 응답 대기 및 DB 업데이트 + for _ in range(10): + if captured_data.get("last_project_data"): break + await asyncio.sleep(0.5) + + last_data = captured_data.get("last_project_data") + if last_data: + if isinstance(last_data, list) and len(last_data) > 0: + last_data = last_data[0] + + if isinstance(last_data, dict): + proj_data = last_data.get("data", {}) + if isinstance(proj_data, list) and len(proj_data) > 0: + proj_data = proj_data[0] + + if isinstance(proj_data, dict): + dept = proj_data.get("department") + p_id = proj_data.get("project_id") + if dept and p_id: + with get_db_connection() as conn: + with conn.cursor() as cursor: + cursor.execute(CrawlerQueries.UPDATE_DEPARTMENT, (dept, p_id)) + conn.commit() + captured_data["last_project_data"] = None # 초기화 + + await asyncio.sleep(2) + + recent_log = "데이터 없음"; file_count = 0 + + # 2. 활동로그 (날짜 필터 적용 버전) + modal_opened = False + for _ in range(3): + await page.get_by_text("활동로그").first.click() + try: + await page.wait_for_selector("article.archive-modal", timeout=5000) + modal_opened = True; break + except: await asyncio.sleep(1) + + if modal_opened: + # 날짜 필터 2020-01-01 적용 + inputs = await page.locator("article.archive-modal input").all() + for inp in inputs: + if (await inp.get_attribute("type")) == "date": + await inp.fill("2020-01-01"); break + + apply_btn = page.locator("article.archive-modal").get_by_text("적용").first + if await apply_btn.is_visible(): + await apply_btn.click() + await asyncio.sleep(5) + log_elements = await page.locator("article.archive-modal div[id*='_']").all() + if log_elements: + recent_log = parse_log_id(await log_elements[0].get_attribute("id")) + await page.keyboard.press("Escape") + + # 3. 구성 수집 (API Fetch 방식 - 팝업 없음) + await page.evaluate("""() => { + const baseUrl = window.location.origin + window.location.pathname.split('/').slice(0, 2).join('/'); + fetch(`${baseUrl}/archive/getTreeObject?params[storageType]=CLOUD¶ms[resourcePath]=/`); + }""") + for _ in range(30): + if captured_data["_is_root_archive"]: break + await asyncio.sleep(0.5) + + if captured_data["tree"]: + tree_data = captured_data["tree"] + if isinstance(tree_data, list) and len(tree_data) > 0: + tree_data = tree_data[0] + + if isinstance(tree_data, dict): + tree = tree_data.get('currentTreeObject', tree_data) + if isinstance(tree, dict): + total = len(tree.get("file", {})) + folders = tree.get("folder", {}) + if isinstance(folders, dict): + for f in folders.values(): total += int(f.get("filesCount", 0)) + file_count = total + + # 4. DB 실시간 저장 + if current_p_id: + with get_db_connection() as conn: + with conn.cursor() as cursor: + cursor.execute(CrawlerQueries.UPSERT_HISTORY, (current_p_id, recent_log, file_count)) + conn.commit() + msg_queue.put(json.dumps({'type': 'log', 'message': f' - [성공] 로그: {recent_log[:20]}... / 파일: {file_count}개'})) + + await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded") + + except Exception as e: + msg_queue.put(json.dumps({'type': 'log', 'message': f' - {project_name} 실패: {str(e)}'})) + await page.goto("https://overseas.projectmastercloud.com/dashboard") + + msg_queue.put(json.dumps({'type': 'done', 'data': []})) + + except Exception as e: + msg_queue.put(json.dumps({'type': 'log', 'message': f'치명적 오류: {str(e)}'})) + finally: + if browser: await browser.close() + msg_queue.put(None) + + loop.run_until_complete(run()) + loop.close() + +async def run_crawler_service(): + msg_queue = queue.Queue() + thread = threading.Thread(target=crawler_thread_worker, args=(msg_queue, os.getenv("PM_USER_ID"), os.getenv("PM_PASSWORD"))) + thread.start() + while True: + try: + msg = await asyncio.to_thread(msg_queue.get, timeout=1.0) + if msg is None: break + yield f"data: {msg}\n\n" + except queue.Empty: + if not thread.is_alive(): break + await asyncio.sleep(0.1) + thread.join() diff --git a/crawler_service_test.py b/crawler_service_test.py new file mode 100644 index 0000000..57e10fe --- /dev/null +++ b/crawler_service_test.py @@ -0,0 +1,273 @@ +import os +import re +import asyncio +import json +import traceback +import sys +import threading +import queue +import pymysql +from datetime import datetime, timedelta +from playwright.async_api import async_playwright +from dotenv import load_dotenv +from sql_queries import CrawlerQueries + +load_dotenv(override=True) + +# 글로벌 중단 제어용 이벤트 +crawl_stop_event = threading.Event() + +def get_db_connection(): + """MySQL 데이터베이스(TEST) 연결을 반환""" + return pymysql.connect( + host=os.getenv('DB_HOST', 'localhost'), + user=os.getenv('DB_USER', 'root'), + password=os.getenv('DB_PASSWORD', '45278434'), + database='PM_proto_test', # 테스트용 DB 고정 + charset='utf8mb4', + cursorclass=pymysql.cursors.DictCursor + ) + +def clean_date_string(date_str): + """원본 crawler_service.py와 동일한 날짜 정리 로직""" + if not date_str: return "" + match = re.search(r'(\d{2})[./-](\d{2})[./-](\d{2})', date_str) + if match: return f"20{match.group(1)}.{match.group(2)}.{match.group(3)}" + return date_str[:10].replace("-", ".") + +def parse_log_id(log_id): + """원본 crawler_service.py와 동일한 로그 ID 파싱 로직""" + if not log_id or "_" not in log_id: return log_id + try: + parts = log_id.split('_') + if len(parts) >= 4: + date_part = clean_date_string(parts[1]) + activity = parts[3].strip() + activity = re.sub(r'\(.*?\)', '', activity).strip() + return f"{date_part}, {activity}" + except: pass + return log_id + +def crawler_thread_worker(msg_queue, user_id, password): + crawl_stop_event.clear() + if sys.platform == 'win32': + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + async def run(): + async with async_playwright() as p: + browser = None + try: + msg_queue.put(json.dumps({'type': 'log', 'message': '[TEST] 원본 수집 방식 복구 및 추론 엔진 가동...'})) + browser = await p.chromium.launch(headless=True, args=[ + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-blink-features=AutomationControlled" + ]) + context = await browser.new_context( + viewport={'width': 1600, 'height': 900}, + user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" + ) + + captured_data = {"tree": None, "_is_root_archive": False, "project_list": [], "last_project_data": None} + + async def global_interceptor(response): + url = response.url + try: + if "getAllList" in url: + data = await response.json() + captured_data["project_list"] = data.get("data", []) + elif "getTreeObject" in url: + # [복구] 원본과 100% 동일한 루트 판정 로직 + is_root = False + if "params[resourcePath]=" in url: + path_val = url.split("params[resourcePath]=")[1].split("&")[0] + if path_val in ["%2F", "/"]: is_root = True + if is_root: + captured_data["tree"] = await response.json() + captured_data["_is_root_archive"] = True + elif "getData" in url and "overview" in url: + captured_data["last_project_data"] = await response.json() + except: pass + + context.on("response", global_interceptor) + page = await context.new_page() + await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded") + + if await page.locator("#login-by-id").is_visible(timeout=10000): + await page.click("#login-by-id"); await page.fill("#user_id", user_id); await page.fill("#user_pw", password); await page.click("#login-btn") + + await page.wait_for_selector("h4.list__contents_aria_group_body_list_item_label", timeout=60000) + await asyncio.sleep(2) + + project_names = list(dict.fromkeys([n.strip() for n in await page.locator("h4.list__contents_aria_group_body_list_item_label").all_inner_texts() if n.strip()])) + count = len(project_names) + + for i, project_name in enumerate(project_names): + if crawl_stop_event.is_set(): break + msg_queue.put(json.dumps({'type': 'log', 'message': f'[TEST] [{i+1}/{count}] {project_name} 수집'})) + p_match = next((p for p in captured_data["project_list"] if p.get('project_nm') == project_name or p.get('short_nm', '').strip() == project_name), None) + current_p_id = p_match.get('project_id') if p_match else None + + try: + # 1. 프로젝트 진입 + target_el = page.locator(f"h4.list__contents_aria_group_body_list_item_label:has-text('{project_name}')").first + await target_el.scroll_into_view_if_needed() + box = await target_el.bounding_box() + if box: await page.mouse.click(box['x'] + 5, box['y'] + 5) + else: await target_el.click(force=True) + await page.wait_for_selector("text=활동로그", timeout=30000) + + # 2. [복구] 최신 파일 수 실측 (원본의 수동 Fetch 방식 그대로) + captured_data["tree"] = None; captured_data["_is_root_archive"] = False + await page.evaluate("""() => { + const baseUrl = window.location.origin + window.location.pathname.split('/').slice(0, 2).join('/'); + fetch(`${baseUrl}/archive/getTreeObject?params[storageType]=CLOUD¶ms[resourcePath]=/`); + }""") + for _ in range(30): + if captured_data["_is_root_archive"]: break + await asyncio.sleep(0.5) + + actual_count = 0 + if captured_data["tree"]: + tree_data = captured_data["tree"] + if isinstance(tree_data, list) and len(tree_data) > 0: tree_data = tree_data[0] + if isinstance(tree_data, dict): + tree = tree_data.get('currentTreeObject', tree_data) + if isinstance(tree, dict): + # 원본 파일 수 합산 로직 + total = len(tree.get("file", {})) + folders = tree.get("folder", {}) + if isinstance(folders, dict): + for f in folders.values(): total += int(f.get("filesCount", 0)) + actual_count = total + + # 3. 활동로그 전수 수집 (하이브리드 방식: 최상단 우선 확보 + 전수 스크롤) + all_logs = [] + await page.get_by_text("활동로그").first.click() + if await page.wait_for_selector("article.archive-modal", timeout=10000): + # 날짜 필터 적용 (2020-01-01) + inputs = await page.locator("article.archive-modal input").all() + for inp in inputs: + if (await inp.get_attribute("type")) == "date": await inp.fill("2020-01-01"); break + + apply_btn = page.locator("article.archive-modal").get_by_text("적용").first + if await apply_btn.is_visible(): + await apply_btn.click() + # [핵심] 첫 번째 로그가 나타날 때까지 명시적 대기 (최대 10초) + try: + await page.wait_for_selector("article.archive-modal div[id*='_']", timeout=10000) + except: pass + await asyncio.sleep(2) + + # (1) 최상단 로그 즉시 확보 (안전장치) + first_log_el = await page.locator("article.archive-modal div[id*='_']").first.get_attribute("id") + if first_log_el: + first_log_text = parse_log_id(first_log_el) + if ", " in first_log_text: + d, a = first_log_text.split(", ", 1) + all_logs.append({'date': d, 'activity': a}) + + # (2) 전수 수집을 위한 무한 스크롤 및 지정된 클래스 내 ID 수집 + last_count = len(all_logs) + for _ in range(20): + # 스크롤 수행 (사용자가 지정한 log-body 클래스 기준) + await page.evaluate("""() => { + const body = document.querySelector('.log-item-wrap.log-body.scrollbar.scroll-container') || + document.querySelector('article.archive-modal .modal-body') || + document.querySelector('article.archive-modal'); + if (body) body.scrollTop = body.scrollHeight; + }""") + await asyncio.sleep(1.5) + + # 사용자 지정 클래스 내의 모든 div ID 수집 + # .log-item-wrap.log-body.scrollbar.scroll-container 내부의 div들을 타겟팅 + selector = ".log-item-wrap.log-body.scrollbar.scroll-container div" + current_elements = await page.locator(selector).all() + + # 만약 지정된 클래스로 검색되지 않을 경우 기존 div[id*='_']를 백업으로 사용 + if not current_elements: + current_elements = await page.locator("article.archive-modal div[id*='_']").all() + + seen_ids = {f"{log['date']}, {log['activity']}" for log in all_logs} + for el in current_elements: + log_id = await el.get_attribute("id") + if not log_id: continue + + log_text = parse_log_id(log_id) + if ", " in log_text and log_text not in seen_ids: + d, a = log_text.split(", ", 1) + all_logs.append({'date': d, 'activity': a}) + seen_ids.add(log_text) + + if len(all_logs) == last_count: break + last_count = len(all_logs) + + if not all_logs: + msg_queue.put(json.dumps({'type': 'log', 'message': f' - [주의] {project_name}: 수집된 로그가 없습니다.'})) + + await page.keyboard.press("Escape") + + # 4. 파일 수 추론 (전수 보존 모드) + history_map = {} + curr_calc_count = actual_count + + if all_logs: + # 오늘 날짜 강제 주입 대신 수집된 로그의 실제 날짜 사용 + for log in all_logs: + d_db = log['date'].replace(".", "-") + act = log['activity'] + if d_db not in history_map: + history_map[d_db] = {"log": act, "count": curr_calc_count} + + if "업로드" in act: curr_calc_count -= 1 + elif "삭제" in act: curr_calc_count += 1 + if curr_calc_count < 0: curr_calc_count = 0 + history_map[d_db]["count"] = curr_calc_count + else: + # 로그가 전혀 없을 경우에만 기본값 생성 + today_str = datetime.now().strftime("%Y-%m-%d") + history_map[today_str] = {"log": "기존 상태 유지 (활동 없음)", "count": actual_count} + + # 5. DB 저장 + if current_p_id: + with get_db_connection() as conn: + with conn.cursor() as cursor: + for date_key, data in history_map.items(): + cursor.execute(CrawlerQueries.UPSERT_HISTORY_WITH_DATE, + (current_p_id, date_key, f"{date_key.replace('-', '.')}, {data['log']}", data['count'])) + conn.commit() + msg_queue.put(json.dumps({'type': 'log', 'message': f' - [성공] 실측 {actual_count}개 기준 시계열 적재 완료'})) + + await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded") + + except Exception as e: + msg_queue.put(json.dumps({'type': 'log', 'message': f' - {project_name} 에러: {str(e)}'})) + await page.goto("https://overseas.projectmastercloud.com/dashboard") + + msg_queue.put(json.dumps({'type': 'done', 'data': []})) + + except Exception as e: + msg_queue.put(json.dumps({'type': 'log', 'message': f'치명적 오류: {str(e)}'})) + finally: + if browser: await browser.close() + msg_queue.put(None) + + loop.run_until_complete(run()) + loop.close() + +async def run_crawler_service(): + msg_queue = queue.Queue() + thread = threading.Thread(target=crawler_thread_worker, args=(msg_queue, os.getenv("PM_USER_ID"), os.getenv("PM_PASSWORD"))) + thread.start() + while True: + try: + msg = await asyncio.to_thread(msg_queue.get, timeout=1.0) + if msg is None: break + yield f"data: {msg}\n\n" + except queue.Empty: + if not thread.is_alive(): break + await asyncio.sleep(0.1) + thread.join() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..19ace50 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +services: + db: + image: mariadb:10.6 + container_name: aicode-db + restart: always + environment: + MYSQL_ROOT_PASSWORD: "45278434" + ports: + - "3307:3306" + volumes: + - db-data:/var/lib/mysql + + web: + # 현재 폴더의 Dockerfile을 사용하여 빌드 + build: . + # 컨테이너 이름 설정 + container_name: aicode-server + # 포트 포워딩 (호스트 8000 -> 컨테이너 8000) + ports: + - "8000:8000" + # 소스 코드 수정 시 실시간 반영 (볼륨 마운트) + volumes: + - .:/app + # 환경 변수 설정 + environment: + - PYTHONUNBUFFERED=1 + - TESSDATA_PREFIX=/usr/share/tesseract-ocr/5/tessdata + # 호스트 PC의 IP로의 라우팅을 위한 게이트웨이 설정 + extra_hosts: + - "host.docker.internal:host-gateway" + # DB 구동 완료 대기 + depends_on: + - db + # 컨테이너 종료 시 자동 재시작 + restart: always + +volumes: + db-data: + + diff --git a/inquiry_service.py b/inquiry_service.py index 262af3d..5d0838f 100644 --- a/inquiry_service.py +++ b/inquiry_service.py @@ -1,42 +1,42 @@ -from datetime import datetime -from sql_queries import InquiryQueries - -class InquiryService: - @staticmethod - def get_inquiries_logic(cursor, pm_type=None, category=None, status=None, keyword=None): - sql = InquiryQueries.SELECT_BASE - params = [] - if pm_type: - sql += " AND pm_type = %s" - params.append(pm_type) - if category: - sql += " AND category = %s" - params.append(category) - if status: - sql += " AND status = %s" - params.append(status) - if keyword: - sql += " AND (content LIKE %s OR author LIKE %s OR project_nm LIKE %s)" - params.extend([f"%{keyword}%", f"%{keyword}%", f"%{keyword}%"]) - - sql += f" {InquiryQueries.ORDER_BY_DESC}" - cursor.execute(sql, params) - return cursor.fetchall() - - @staticmethod - def get_inquiry_detail_logic(cursor, inquiry_id): - cursor.execute(InquiryQueries.SELECT_BY_ID, (inquiry_id,)) - return cursor.fetchone() - - @staticmethod - def update_inquiry_reply_logic(cursor, conn, inquiry_id, req): - handled_date = datetime.now().strftime("%Y.%m.%d") - cursor.execute(InquiryQueries.UPDATE_REPLY, (req.reply, req.status, req.handler, handled_date, inquiry_id)) - conn.commit() - return {"success": True} - - @staticmethod - def delete_inquiry_reply_logic(cursor, conn, inquiry_id): - cursor.execute(InquiryQueries.DELETE_REPLY, (inquiry_id,)) - conn.commit() - return {"success": True} +from datetime import datetime +from sql_queries import InquiryQueries + +class InquiryService: + @staticmethod + def get_inquiries_logic(cursor, pm_type=None, category=None, status=None, keyword=None): + sql = InquiryQueries.SELECT_BASE + params = [] + if pm_type: + sql += " AND pm_type = %s" + params.append(pm_type) + if category: + sql += " AND category = %s" + params.append(category) + if status: + sql += " AND status = %s" + params.append(status) + if keyword: + sql += " AND (content LIKE %s OR author LIKE %s OR project_nm LIKE %s)" + params.extend([f"%{keyword}%", f"%{keyword}%", f"%{keyword}%"]) + + sql += f" {InquiryQueries.ORDER_BY_DESC}" + cursor.execute(sql, params) + return cursor.fetchall() + + @staticmethod + def get_inquiry_detail_logic(cursor, inquiry_id): + cursor.execute(InquiryQueries.SELECT_BY_ID, (inquiry_id,)) + return cursor.fetchone() + + @staticmethod + def update_inquiry_reply_logic(cursor, conn, inquiry_id, req): + handled_date = datetime.now().strftime("%Y.%m.%d") + cursor.execute(InquiryQueries.UPDATE_REPLY, (req.reply, req.status, req.handler, handled_date, inquiry_id)) + conn.commit() + return {"success": True} + + @staticmethod + def delete_inquiry_reply_logic(cursor, conn, inquiry_id): + cursor.execute(InquiryQueries.DELETE_REPLY, (inquiry_id,)) + conn.commit() + return {"success": True} diff --git a/js/analysis.js b/js/analysis.js index 2f4ecf0..007e346 100644 --- a/js/analysis.js +++ b/js/analysis.js @@ -1,463 +1,485 @@ -/** - * Project Master Analysis JS - * AVI (Activity Vitality Index) & VCI (Value Contribution Index) 분석 엔진 - * OCI (Operational Consistency Index) 통합 버전 - */ - -// Chart.js 플러그인 전역 등록 -if (typeof ChartDataLabels !== 'undefined') { - Chart.register(ChartDataLabels); -} - -document.addEventListener('DOMContentLoaded', () => { - console.log("Business Analysis Engine initialized..."); - loadProjectAnalysisData(); -}); - -async function loadProjectAnalysisData() { - try { - const response = await fetch('/api/analysis/p-war'); - const data = await response.json(); - if (data.error) throw new Error(data.error); - - renderVitalityLeaderboard(data); - renderValueCharts(data); - - if (data.length > 0 && data[0].avg_info) { - const avg = data[0].avg_info; - const infoEl = document.getElementById('avg-system-info'); - if (infoEl) infoEl.textContent = `* 시스템 종합 자산 건전도: ${avg.avg_risk}% (운영 표준 70.0% 대비)`; - } - } catch (e) { - console.error("분석 데이터 로딩 실패:", e); - } -} - -function getStatusInfo(avi, isAutoDelete) { - if (isAutoDelete || avi < 10) return { label: '사망', class: 'badge-system', key: 'dead' }; - if (avi < 30) return { label: '위험 노출', class: 'badge-danger', key: 'danger' }; - if (avi < 70) return { label: '관리 주의', class: 'badge-warning', key: 'warning' }; - return { label: '정상 운영', class: 'badge-active', key: 'active' }; -} - -function getVciGrade(vci) { - if (vci >= 10) return { label: 'Masterpiece', class: 'grade-mvp', desc: '시스템 가치를 견인하는 최우량 핵심 자산' }; - if (vci >= 2) return { label: 'Blue Chip', class: 'grade-allstar', desc: '꾸준한 활력으로 가치를 창출하는 우량 자산' }; - if (vci >= -2) return { label: 'Steady', class: 'grade-starter', desc: '표준 수준의 운영을 유지 중인 안정 자산' }; - if (vci >= -10) return { label: 'Underperform', class: 'grade-bench', desc: '규모 대비 활력 부족으로 가치가 하락 중인 자산' }; - return { label: 'Liability', class: 'grade-out', desc: '가치를 훼손 중인 고위험 방치 자산' }; -} - -function renderValueCharts(data) { - if (!data || data.length === 0) return; - - // 1. 운영 활력 분포 (Doughnut) - try { - const stats = { active: [], warning: [], danger: [], dead: [] }; - data.forEach(p => { - const status = getStatusInfo(p.p_war, p.is_auto_delete); - stats[status.key].push(p); - }); - - const statusCtx = document.getElementById('statusChart').getContext('2d'); - if (window.myStatusChart) window.myStatusChart.destroy(); - - window.myStatusChart = new Chart(statusCtx, { - type: 'doughnut', - data: { - labels: ['정상 운영', '관리 주의', '위험 노출', '사망'], - datasets: [{ - data: [stats.active.length, stats.warning.length, stats.danger.length, stats.dead.length], - backgroundColor: ['#1E5149', '#22c55e', '#f59e0b', '#ef4444'], - borderWidth: 0 - }] - }, - options: { - responsive: true, - maintainAspectRatio: false, - layout: { padding: 15 }, - plugins: { - legend: { position: 'right', labels: { boxWidth: 10, font: { size: 11, weight: '700' }, usePointStyle: true } }, - datalabels: { display: false } - }, - cutout: '65%', - onClick: (e, elements) => { - if (elements.length > 0) { - const idx = elements[0].index; - openProjectListModal(['정상 운영', '관리 주의', '위험 노출', '사망'][idx], stats[['active', 'warning', 'danger', 'dead'][idx]]); - } - } - } - }); - } catch (err) { console.error("도넛 차트 에러:", err); } - - // 2. 전략적 자산 매트릭스 (Scatter) - 정밀 복구 - try { - const sortedByAVI = [...data].sort((a, b) => b.p_war - a.p_war); - const top5Ids = sortedByAVI.slice(0, 5).map(p => p.project_nm); - const bottom5Ids = sortedByAVI.slice(-5).map(p => p.project_nm); - const largeProjects = data.filter(p => p.file_count > 450).map(p => p.project_nm); - const vipProjectNames = new Set([...top5Ids, ...bottom5Ids, ...largeProjects]); - - const scatterData = data.map(p => { - const vci = p.risk_count || 0; - const absVci = Math.abs(vci); - return { - x: Math.min(500, p.file_count), - y: p.p_war, - label: p.project_nm, - isVip: vipProjectNames.has(p.project_nm), - vci: vci, - radius: Math.max(5, Math.min(25, 5 + (absVci / 10))) - }; - }); - - const vitalityCtx = document.getElementById('forecastChart').getContext('2d'); - if (window.myVitalityChart) window.myVitalityChart.destroy(); - - window.myVitalityChart = new Chart(vitalityCtx, { - type: 'scatter', - data: { - datasets: [{ - data: scatterData, - backgroundColor: (ctx) => { - const p = ctx.raw; - if (!p) return '#94a3b8'; - if (p.x >= 250 && p.y >= 50) return '#1E5149'; - if (p.x < 250 && p.y >= 50) return '#22c55e'; - if (p.x < 250 && p.y < 50) return '#94a3b8'; - return '#ef4444'; - }, - pointRadius: (ctx) => ctx.raw ? ctx.raw.radius : 5, - hoverRadius: (ctx) => (ctx.raw ? ctx.raw.radius : 5) + 3 - }] - }, - options: { - responsive: true, - maintainAspectRatio: false, - layout: { padding: { top: 30, right: 45, left: 10, bottom: 10 } }, - scales: { - x: { - type: 'linear', min: 0, max: 500, - title: { display: true, text: '자산 규모 (파일 수)', font: { size: 11, weight: '700' } }, - grid: { display: false } - }, - y: { - min: 0, max: 100, - title: { display: true, text: '운영 활력 (AVI %)', font: { size: 11, weight: '700' } }, - grid: { display: false } - } - }, - plugins: { - legend: { display: false }, - datalabels: { - backgroundColor: 'rgba(255, 255, 255, 0.8)', - borderRadius: 4, padding: 4, - font: { size: 10, weight: '800' }, - formatter: (v) => v ? v.label : '', - display: (ctx) => ctx.raw && ctx.raw.isVip, - clip: false - }, - tooltip: { - callbacks: { - label: (ctx) => ` [${ctx.raw.label}] AVI: ${ctx.raw.y.toFixed(1)}% | VCI: ${ctx.raw.vci.toFixed(1)}` - } - } - } - }, - plugins: [{ - id: 'quadrants', - beforeDraw: (chart) => { - const { ctx, chartArea: { left, top, right, bottom }, scales: { x, y } } = chart; - const midX = x.getPixelForValue(250); - const midY = y.getPixelForValue(50); - ctx.save(); - ctx.fillStyle = 'rgba(34, 197, 94, 0.03)'; ctx.fillRect(left, top, midX - left, midY - top); - ctx.fillStyle = 'rgba(30, 81, 73, 0.03)'; ctx.fillRect(midX, top, right - midX, midY - top); - ctx.fillStyle = 'rgba(148, 163, 184, 0.03)'; ctx.fillRect(left, midY, midX - left, bottom - midY); - ctx.fillStyle = 'rgba(239, 68, 68, 0.05)'; ctx.fillRect(midX, midY, right - midX, bottom - midY); - ctx.lineWidth = 2; ctx.strokeStyle = 'rgba(0,0,0,0.1)'; ctx.beginPath(); - ctx.moveTo(midX, top); ctx.lineTo(midX, bottom); ctx.moveTo(left, midY); ctx.lineTo(right, midY); ctx.stroke(); - ctx.font = 'bold 12px Pretendard'; ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(0,0,0,0.2)'; - ctx.fillText('활력 양호', (left + midX) / 2, (top + midY) / 2); - ctx.fillText('핵심 가치', (midX + right) / 2, (top + midY) / 2); - ctx.fillText('정체/소규모', (left + midX) / 2, (midY + bottom) / 2); - ctx.fillStyle = 'rgba(239, 68, 68, 0.4)'; ctx.fillText('자산 손실 위험', (midX + right) / 2, (midY + bottom) / 2); - ctx.restore(); - } - }] - }); - } catch (err) { console.error("전략 매트릭스 에러:", err); } -} - -function renderVitalityLeaderboard(data) { - const container = document.getElementById('p-war-table-container'); - if (!container) return; - const sortedData = [...data].sort((a, b) => a.p_war - b.p_war); - - container.innerHTML = ` -

- - - - - - - - - - - - - - - ${sortedData.map((p, idx) => { - const status = getStatusInfo(p.p_war, p.is_auto_delete); - const avi = p.p_war; - const vci = p.risk_count; - const oci = p.oci_score || 0; - const rowId = `project-${idx}`; - const grade = getVciGrade(vci); - - let rhythmLabel = oci >= 80 ? "정기적" : oci >= 50 ? "안정적" : oci >= 20 ? "간헐적" : "불규칙"; - let rhythmColor = oci >= 80 ? "#059669" : oci >= 50 ? "#1e5149" : oci >= 20 ? "#f59e0b" : "#dc2626"; - - // 존재 신뢰도 패널티 (ECV) 상세 설명 복구 - let ecvText = "100% (데이터 실체 검증)"; - let ecvClass = "highlight-val"; - let ecvDesc = `현재 ${p.file_count}개의 유효 성과물이 확인됩니다. 시스템적으로 실체가 완벽히 존재하는 상태입니다.`; - if (p.file_count === 0) { - ecvText = "5% (유령 프로젝트 판명)"; - ecvClass = "highlight-penalty"; - ecvDesc = "데이터가 전무하여 프로젝트의 디지털 실체가 없습니다. 모든 분석에서 최하위 패널티가 적용됩니다."; - } else if (p.file_count < 10) { - ecvText = "40% (형식적 껍데기 판명)"; - ecvClass = "highlight-penalty"; - ecvDesc = "최소 수준의 문서만 존재하며, 실질적인 운영 가치를 인정하기 어려운 소규모 상태입니다."; - } - - // 활동 품질 텍스트 복구 - const qualityLabel = p.log_quality >= 1.0 ? '성과물 중심의 실무 활동' : p.log_quality >= 0.7 ? '구조 관리를 위한 시스템 활동' : '단순 행정 기반의 형식 활동'; - const qualityDetail = p.log_quality >= 1.0 ? '최근 로그에서 파일 업로드/수정 등 가치 증분 활동이 명확히 포착되었습니다.' : p.log_quality >= 0.7 ? '폴더 생성/이동 등 구조적 관리는 이뤄지고 있으나, 직접적 결과물 생산은 부족합니다.' : '메일 확인, 권한 변경 등 시스템 유지성 활동 위주로 파악되어 품질 가중치가 낮게 적용되었습니다.'; - - return ` - - - - - - - - - - - - - `; - }).join('')} - -
프로젝트명파일 수정체 일수상태 판정가치 기여 (VCI) 운영 활력 (AVI) 업무 집중도 운영 일관성 (OCI)
${p.project_nm}${p.file_count.toLocaleString()}개${p.days_stagnant}일${status.label} - ${vci > 0 ? '+' : ''}${vci.toFixed(1)} - ${avi.toFixed(1)}% -
- ${p.work_effort}% -
-
-
-
-
-
- ${oci}% - ${rhythmLabel} -
-
-
-
-
⚙️ AI 위험 적응형 모델(AAS) 기반 인과관계 분석
- -
-
-
- 📊 실질 업무 집중도 (Job Focus) - ${p.work_effort}% -
-
-
최근 30회 수집 이력 중 단순 로그 갱신이 아닌 실제 성과물의 변동이 포착된 날의 비율입니다. 이는 운영의 '진정성'을 보여주는 핵심 지표입니다.
-
-
-
-
VCI GRADE
-
${grade.label}
-
-
${grade.desc}
-
-
- -
-
-
1
-
-
동적 위험 계수(λ) 산출
-
프로젝트 규모가 클수록 정보 망실 시의 충격을 반영하여 데이터의 하락 속도가 가속됩니다. 현재 λ=${p.ai_lambda.toFixed(4)}는 귀하의 자산 규모가 정밀하게 투영된 결과입니다.
-
Dynamic λ = ${p.ai_lambda.toFixed(4)}
-
-
-
-
4
-
-
활동 품질 검증 (Quality)
-
최근 로그 분석 결과 ${qualityLabel}으로 판명되었습니다. ${qualityDetail}
-
Quality Factor = ${(p.log_quality * 100).toFixed(0)}%
-
-
-
-
2
-
-
방치 시간 감쇄 적용
-
마지막 유효 활동 이후 ${p.days_stagnant}일간의 누적 정체 시간은 지수 감쇄 곡선을 따라 데이터의 최신성과 가치를 상쇄시켰습니다.
-
Decay Result = ${((avi / (p.file_count === 0 ? 0.05 : p.file_count < 10 ? 0.4 : 1) / p.log_quality) || 0).toFixed(1)}%
-
-
-
-
3
-
-
존재 진정성 (ECV)
-
${ecvDesc} 파일 수 자체가 분석의 데이터 진정성을 보정하는 핵심 팩터로 작용합니다.
-
Entity Factor = ${ecvText}
-
-
-
- -
-
-
- 가치 기여도 (VCI) 진단: ${vci >= 0 ? '+' : ''}${vci.toFixed(2)} -
-
- 현재 프로젝트는 운영 표준(AVI 70%) 대비 ${Math.abs(avi - 70).toFixed(1)}%p ${avi >= 70 ? '상회' : '하회'}하고 있으며, - ${p.file_count}개의 자산 규모에 따른 ${((p.file_count / 200) + 0.5).toFixed(2)}배의 가중치가 적용되었습니다. - 이는 시스템 전체 관점에서 ${vci >= 0 ? '순자산 가치를 증대' : '잠재적 기회비용을 손실'}시키고 있는 상태로 분석됩니다. -
-
-
- 최종 AVI: - ${avi.toFixed(1)}% -
-
-
-
-
-
`; -} - -function toggleProjectDetail(rowId) { - const container = document.querySelector('.table-scroll-wrapper'); - const mainRow = document.querySelector(`tr[onclick*="toggleProjectDetail('${rowId}')"]`); - const detailRow = document.getElementById(`detail-${rowId}`); - - if (detailRow && container) { - if (!detailRow.classList.contains('active')) { - document.querySelectorAll('.detail-row').forEach(row => row.classList.remove('active')); - detailRow.classList.add('active'); - - // 정밀 스크롤 이동 로직 복구 - setTimeout(() => { - const headerH = container.querySelector('thead').offsetHeight || 45; - const targetScrollTop = mainRow.offsetTop - headerH; - container.scrollTo({ top: targetScrollTop, behavior: 'smooth' }); - }, 100); - } else { - detailRow.classList.remove('active'); - } - } -} - -function openProjectListModal(label, projects) { - const modal = document.getElementById('analysisModal'); - const title = document.getElementById('modalTitle'); - const body = document.getElementById('modalBody'); - title.innerText = `[${label}] 프로젝트 리스트 (${projects.length}건)`; - body.innerHTML = ` -
- - - ${projects.map(p => ``).join('')} -
프로젝트명관리자정체일AVI
${p.project_nm}${p.master || '-'}${p.days_stagnant}일${p.p_war.toFixed(1)}%
-
- `; - modal.style.display = 'flex'; -} - -function openAnalysisModal(type) { - const modal = document.getElementById('analysisModal'); - const title = document.getElementById('modalTitle'); - const body = document.getElementById('modalBody'); - - if (type === 'avi') { - title.innerText = '운영 활력 지수 (AVI) 등급 가이드'; - body.innerHTML = ` -
AVI = exp(-λ × days) × Quality × 100
-

자산의 가동 상태와 생존율을 나타내는 지표입니다.

- - - - - - - - - -
지수 (AVI)등급운영 상태
90%↑Live실시간 성과물이 도출되는 최상급 가동
70~90%Stable주기적 업데이트가 이뤄지는 표준 안정
30~70%Idle관리가 필요한 유휴/정체 상태
10~30%Risk자산 가치 소멸 직전의 위험 상태
10%↓Frozen운영이 중단된 사망/방치 상태
- `; - } else if (type === 'vci') { - title.innerText = '자산 가치 기여도 (VCI) 등급 가이드'; - body.innerHTML = ` -
VCI = (AVI - 70.0) × (Files / 200 + 0.5)
-

운영 표준(AVI 70%) 대비 자산 가치 기여도에 따른 프로젝트 위상 분류입니다.

- - - - - - - - - -
점수 (VCI)등급운영 의미
+10.0↑Masterpiece시스템 가치를 견인하는 최우량 핵심 자산
+2.0 ~ +10.0Blue Chip꾸준한 활력으로 가치를 창출하는 우량 자산
-2.0 ~ +2.0Steady표준 수준의 운영을 유지 중인 안정 자산
-10.0 ~ -2.0Underperform규모 대비 활력 부족으로 가치 하락 중인 자산
-10.0↓Liability가치를 훼손 중인 고위험 방치 자산
- `; - } else if (type === 'oci') { - title.innerText = '운영 일관성 지수 (OCI) 분석 가이드'; - body.innerHTML = ` -
- "얼마나 꾸준하게 관리되고 있는가?" -

미래 예측이 아닌, 최근 30일간의 활동 리듬관리의 규칙성을 분석하여 성실도를 점수화합니다.

-
- - - - - - - - -
분석 결과일관성 등급관리 신뢰도
80%↑매우 우수주 단위의 정기적 관리가 완벽히 이뤄짐
50~80%양호간헐적 정체는 있으나 꾸준히 관리됨
20~50%주의돌발적 활동 위주, 관리의 리듬이 깨짐
20%↓매우 불량장기 정체 중이거나 관리 의지 확인 불가
- `; - } else { - title.innerText = '업무 집중도 (Job Focus) 등급 가이드'; - body.innerHTML = ` -

최근 수집 로그 중 단순 행정 로그를 제외하고 실질적인 성과물(파일) 변동이 포착된 비율입니다.

- - - - - - - - -
비율 (%)등급활동 성격
80%↑Intensive성과물 위주의 고밀도 집중 작업
50~80%Active성과와 관리가 균형 잡힌 원활한 실행
20~50%Maintenance설정/행정 등 단순 관리 중심의 작업
20%↓Surface실체적 변화가 적은 형식적 로그 중심
- `; - } - modal.style.display = 'flex'; -} - -function closeAnalysisModal() { document.getElementById('analysisModal').style.display = 'none'; } +/** + * Project Master Analysis JS + * AVI (Activity Vitality Index) & VCI (Value Contribution Index) 분석 엔진 + * OCI (Operational Consistency Index) 통합 버전 + */ + +// Chart.js 플러그인 전역 등록 +if (typeof ChartDataLabels !== 'undefined') { + Chart.register(ChartDataLabels); +} + +document.addEventListener('DOMContentLoaded', () => { + console.log("Business Analysis Engine initialized..."); + loadProjectAnalysisData(); +}); + +async function loadProjectAnalysisData() { + try { + const response = await fetch('/api/analysis/p-war'); + const data = await response.json(); + if (data.error) throw new Error(data.error); + + renderVitalityLeaderboard(data); + renderValueCharts(data); + + if (data.length > 0 && data[0].avg_info) { + const avg = data[0].avg_info; + const infoEl = document.getElementById('avg-system-info'); + if (infoEl) infoEl.textContent = `* 시스템 종합 운영 활력(AVI): ${avg.avg_risk}% (평균 관리 수준)`; + } + } catch (e) { + console.error("분석 데이터 로딩 실패:", e); + } +} + +function getStatusInfo(avi, isAutoDelete) { + if (isAutoDelete || avi < 10) return { label: '사망', class: 'badge-system', key: 'dead' }; + if (avi < 30) return { label: '위험 노출', class: 'badge-danger', key: 'danger' }; + if (avi < 70) return { label: '관리 주의', class: 'badge-warning', key: 'warning' }; + return { label: '정상 운영', class: 'badge-active', key: 'active' }; +} + +function getVciGrade(vci) { + if (vci >= 10) return { label: 'Masterpiece', class: 'grade-mvp', desc: '시스템 가치를 견인하는 최우량 핵심 자산' }; + if (vci >= 2) return { label: 'Blue Chip', class: 'grade-allstar', desc: '꾸준한 활력으로 가치를 창출하는 우량 자산' }; + if (vci >= -2) return { label: 'Steady', class: 'grade-starter', desc: '표준 수준의 운영을 유지 중인 안정 자산' }; + if (vci >= -10) return { label: 'Underperform', class: 'grade-bench', desc: '규모 대비 활력 부족으로 가치가 하락 중인 자산' }; + return { label: 'Liability', class: 'grade-out', desc: '가치를 훼손 중인 고위험 방치 자산' }; +} + +function renderValueCharts(data) { + if (!data || data.length === 0) return; + + // 1. 운영 활력 분포 (Doughnut) + try { + const stats = { active: [], warning: [], danger: [], dead: [] }; + data.forEach(p => { + const status = getStatusInfo(p.p_war, p.is_auto_delete); + stats[status.key].push(p); + }); + + const statusCtx = document.getElementById('statusChart').getContext('2d'); + if (window.myStatusChart) window.myStatusChart.destroy(); + + window.myStatusChart = new Chart(statusCtx, { + type: 'doughnut', + data: { + labels: ['정상 운영', '관리 주의', '위험 노출', '사망'], + datasets: [{ + data: [stats.active.length, stats.warning.length, stats.danger.length, stats.dead.length], + backgroundColor: ['#1E5149', '#22c55e', '#f59e0b', '#ef4444'], + borderWidth: 0 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + layout: { padding: 15 }, + plugins: { + legend: { position: 'right', labels: { boxWidth: 10, font: { size: 11, weight: '700' }, usePointStyle: true } }, + datalabels: { display: false } + }, + cutout: '65%', + onClick: (e, elements) => { + if (elements.length > 0) { + const idx = elements[0].index; + openProjectListModal(['정상 운영', '관리 주의', '위험 노출', '사망'][idx], stats[['active', 'warning', 'danger', 'dead'][idx]]); + } + } + } + }); + } catch (err) { console.error("도넛 차트 에러:", err); } + + // 2. 전략적 자산 매트릭스 (Scatter) - 정밀 복구 + try { + const sortedByAVI = [...data].sort((a, b) => b.p_war - a.p_war); + const top5Ids = sortedByAVI.slice(0, 5).map(p => p.project_nm); + const bottom5Ids = sortedByAVI.slice(-5).map(p => p.project_nm); + const largeProjects = data.filter(p => p.file_count > 450).map(p => p.project_nm); + const vipProjectNames = new Set([...top5Ids, ...bottom5Ids, ...largeProjects]); + + const scatterData = data.map(p => { + const vci = p.risk_count || 0; + const absVci = Math.abs(vci); + return { + x: Math.min(500, p.file_count), + y: p.p_war, + label: p.project_nm, + isVip: vipProjectNames.has(p.project_nm), + vci: vci, + radius: Math.max(5, Math.min(25, 5 + (absVci / 10))) + }; + }); + + const vitalityCtx = document.getElementById('forecastChart').getContext('2d'); + if (window.myVitalityChart) window.myVitalityChart.destroy(); + + window.myVitalityChart = new Chart(vitalityCtx, { + type: 'scatter', + data: { + datasets: [{ + data: scatterData, + backgroundColor: (ctx) => { + const p = ctx.raw; + if (!p) return '#94a3b8'; + if (p.x >= 250 && p.y >= 50) return '#1E5149'; + if (p.x < 250 && p.y >= 50) return '#22c55e'; + if (p.x < 250 && p.y < 50) return '#94a3b8'; + return '#ef4444'; + }, + pointRadius: (ctx) => ctx.raw ? ctx.raw.radius : 5, + hoverRadius: (ctx) => (ctx.raw ? ctx.raw.radius : 5) + 3 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + layout: { padding: { top: 30, right: 45, left: 10, bottom: 10 } }, + scales: { + x: { + type: 'linear', min: 0, max: 500, + title: { display: true, text: '자산 규모 (파일 수)', font: { size: 11, weight: '700' } }, + grid: { display: false } + }, + y: { + min: 0, max: 100, + title: { display: true, text: '운영 활력 (AVI %)', font: { size: 11, weight: '700' } }, + grid: { display: false } + } + }, + plugins: { + legend: { display: false }, + datalabels: { + backgroundColor: 'rgba(255, 255, 255, 0.8)', + borderRadius: 4, padding: 4, + font: { size: 10, weight: '800' }, + formatter: (v) => v ? v.label : '', + display: (ctx) => ctx.raw && ctx.raw.isVip, + clip: false + }, + tooltip: { + callbacks: { + label: (ctx) => ` [${ctx.raw.label}] AVI: ${ctx.raw.y.toFixed(1)}% | VCI: ${ctx.raw.vci.toFixed(1)}` + } + } + } + }, + plugins: [{ + id: 'quadrants', + beforeDraw: (chart) => { + const { ctx, chartArea: { left, top, right, bottom }, scales: { x, y } } = chart; + const midX = x.getPixelForValue(250); + const midY = y.getPixelForValue(50); + ctx.save(); + ctx.fillStyle = 'rgba(34, 197, 94, 0.03)'; ctx.fillRect(left, top, midX - left, midY - top); + ctx.fillStyle = 'rgba(30, 81, 73, 0.03)'; ctx.fillRect(midX, top, right - midX, midY - top); + ctx.fillStyle = 'rgba(148, 163, 184, 0.03)'; ctx.fillRect(left, midY, midX - left, bottom - midY); + ctx.fillStyle = 'rgba(239, 68, 68, 0.05)'; ctx.fillRect(midX, midY, right - midX, bottom - midY); + ctx.lineWidth = 2; ctx.strokeStyle = 'rgba(0,0,0,0.1)'; ctx.beginPath(); + ctx.moveTo(midX, top); ctx.lineTo(midX, bottom); ctx.moveTo(left, midY); ctx.lineTo(right, midY); ctx.stroke(); + ctx.font = 'bold 12px Pretendard'; ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(0,0,0,0.2)'; + ctx.fillText('활력 양호', (left + midX) / 2, (top + midY) / 2); + ctx.fillText('핵심 가치', (midX + right) / 2, (top + midY) / 2); + ctx.fillText('정체/소규모', (left + midX) / 2, (midY + bottom) / 2); + ctx.fillStyle = 'rgba(239, 68, 68, 0.4)'; ctx.fillText('자산 손실 위험', (midX + right) / 2, (midY + bottom) / 2); + ctx.restore(); + } + }] + }); + } catch (err) { console.error("전략 매트릭스 에러:", err); } +} + +function renderVitalityLeaderboard(data) { + const container = document.getElementById('p-war-table-container'); + if (!container) return; + const sortedData = [...data].sort((a, b) => a.p_war - b.p_war); + + container.innerHTML = ` +
+ + + + + + + + + + + + + + + ${sortedData.map((p, idx) => { + const status = getStatusInfo(p.p_war, p.is_auto_delete); + const avi = p.p_war; + const vci = p.risk_count; + const oci = p.oci_score || 0; + const rowId = `project-${idx}`; + const grade = getVciGrade(vci); + + let rhythmLabel = oci >= 80 ? "정기적" : oci >= 50 ? "안정적" : oci >= 20 ? "간헐적" : "불규칙"; + let rhythmColor = oci >= 80 ? "#059669" : oci >= 50 ? "#1e5149" : oci >= 20 ? "#f59e0b" : "#dc2626"; + + // 존재 신뢰도 패널티 (ECV) 상세 설명 복구 + let ecvText = "100% (데이터 실체 검증)"; + let ecvClass = "highlight-val"; + let ecvDesc = `현재 ${p.file_count}개의 유효 성과물이 확인됩니다. 시스템적으로 실체가 완벽히 존재하는 상태입니다.`; + if (p.file_count === 0) { + ecvText = "5% (유령 프로젝트 판명)"; + ecvClass = "highlight-penalty"; + ecvDesc = "데이터가 전무하여 프로젝트의 디지털 실체가 없습니다. 모든 분석에서 최하위 패널티가 적용됩니다."; + } else if (p.file_count < 10) { + ecvText = "40% (형식적 껍데기 판명)"; + ecvClass = "highlight-penalty"; + ecvDesc = "최소 수준의 문서만 존재하며, 실질적인 운영 가치를 인정하기 어려운 소규모 상태입니다."; + } + + // 활동 품질 텍스트 복구 + const qualityLabel = p.log_quality >= 1.0 ? '성과물 중심의 실무 활동' : p.log_quality >= 0.7 ? '구조 관리를 위한 시스템 활동' : '단순 행정 기반의 형식 활동'; + const qualityDetail = p.log_quality >= 1.0 ? '최근 로그에서 파일 업로드/수정 등 가치 증분 활동이 명확히 포착되었습니다.' : p.log_quality >= 0.7 ? '폴더 생성/이동 등 구조적 관리는 이뤄지고 있으나, 직접적 결과물 생산은 부족합니다.' : '메일 확인, 권한 변경 등 시스템 유지성 활동 위주로 파악되어 품질 가중치가 낮게 적용되었습니다.'; + + return ` + + + + + + + + + + + + + `; + }).join('')} + +
프로젝트명파일 수정체 일수상태 판정가치 기여 (VCI) 운영 활력 (AVI) 업무 집중도 운영 일관성 (OCI)
${p.project_nm}${p.file_count.toLocaleString()}개${p.days_stagnant}일${status.label} + ${vci > 0 ? '+' : ''}${vci.toFixed(1)} + ${avi.toFixed(1)}% +
+ ${p.work_effort}% +
+
+
+
+
+
+ ${oci}% + ${rhythmLabel} +
+
+
+
+
⚙️ AI 위험 적응형 모델(AAS) 기반 인과관계 분석
+ +
+
+
+ 📊 실질 업무 집중도 (Job Focus) + ${p.work_effort}% +
+
+
최근 30회 수집 이력 중 단순 로그 갱신이 아닌 실제 성과물의 변동이 포착된 날의 비율입니다. 이는 운영의 '진정성'을 보여주는 핵심 지표입니다.
+
+
+
+
VCI GRADE
+
${grade.label}
+
+
${grade.desc}
+
+
+ +
+
+
1
+
+
동적 위험 계수(λ) 산출
+
프로젝트 규모가 클수록 정보 망실 시의 충격을 반영하여 데이터의 하락 속도가 가속됩니다. 현재 λ=${p.ai_lambda.toFixed(4)}는 귀하의 자산 규모가 정밀하게 투영된 결과입니다.
+
Dynamic λ = ${p.ai_lambda.toFixed(4)}
+
+
+
+
4
+
+
활동 품질 검증 (Quality)
+
최근 로그 분석 결과 ${qualityLabel}으로 판명되었습니다. ${qualityDetail}
+
Quality Factor = ${(p.log_quality * 100).toFixed(0)}%
+
+
+
+
2
+
+
방치 시간 감쇄 적용
+
마지막 유효 활동 이후 ${p.days_stagnant}일간의 누적 정체 시간은 지수 감쇄 곡선을 따라 데이터의 최신성과 가치를 상쇄시켰습니다.
+
Decay Result = ${((avi / (p.file_count === 0 ? 0.05 : p.file_count < 10 ? 0.4 : 1) / p.log_quality) || 0).toFixed(1)}%
+
+
+
+
3
+
+
존재 진정성 (ECV)
+
${ecvDesc} 파일 수 자체가 분석의 데이터 진정성을 보정하는 핵심 팩터로 작용합니다.
+
Entity Factor = ${ecvText}
+
+
+
+ +
+
+
+
+ 가치 기여도 (VCI) 진단: ${vci >= 0 ? '+' : ''}${vci.toFixed(2)} +
+
+ 조직 평균 자산: ${p.avg_info.avg_files}개 +
+
+
+ 현재 프로젝트는 포트폴리오 평균 관리 수준 대비 ${Math.abs(vci / Math.max(0.2, p.file_count / p.avg_info.avg_files)).toFixed(1)}%p ${vci >= 0 ? '상회' : '하회'}하고 있으며, + ${p.file_count}개의 자산 규모에 따른 ${Math.max(0.2, p.file_count / p.avg_info.avg_files).toFixed(2)}배의 상대 가중치가 적용되었습니다. + 이는 시스템 전체 관점에서 ${vci >= 0 ? '순자산 가치를 증대' : '잠재적 기회비용을 손실'}시키고 있는 상태로 분석됩니다. +
+
+
+ 최종 AVI: + ${avi.toFixed(1)}% +
+
+
+
+
+
`; +} + +function toggleProjectDetail(rowId) { + const container = document.querySelector('.table-scroll-wrapper'); + const mainRow = document.querySelector(`tr[onclick*="toggleProjectDetail('${rowId}')"]`); + const detailRow = document.getElementById(`detail-${rowId}`); + + if (detailRow && container) { + if (!detailRow.classList.contains('active')) { + document.querySelectorAll('.detail-row').forEach(row => row.classList.remove('active')); + detailRow.classList.add('active'); + + // 정밀 스크롤 이동 로직 복구 + setTimeout(() => { + const headerH = container.querySelector('thead').offsetHeight || 45; + const targetScrollTop = mainRow.offsetTop - headerH; + container.scrollTo({ top: targetScrollTop, behavior: 'smooth' }); + }, 100); + } else { + detailRow.classList.remove('active'); + } + } +} + +function openProjectListModal(label, projects) { + const modal = document.getElementById('analysisModal'); + const title = document.getElementById('modalTitle'); + const body = document.getElementById('modalBody'); + title.innerText = `[${label}] 프로젝트 리스트 (${projects.length}건)`; + body.innerHTML = ` +
+ + + ${projects.map(p => ``).join('')} +
프로젝트명관리자정체일AVI
${p.project_nm}${p.master || '-'}${p.days_stagnant}일${p.p_war.toFixed(1)}%
+
+ `; + modal.style.display = 'flex'; +} + +function openAnalysisModal(type) { + const modal = document.getElementById('analysisModal'); + const title = document.getElementById('modalTitle'); + const body = document.getElementById('modalBody'); + + if (type === 'avi') { + title.innerText = '운영 활력 지수 (AVI) 분석 가이드'; + body.innerHTML = ` +
+ AVI = exp(-λ × Stagnant Days) × Quality × 100 +
+
+

운영 활력 지수(AVI)는 프로젝트가 현재 얼마나 건강하게 가동되고 있는지를 나타내는 '디지털 생존 지표'입니다.

+
    +
  • 지수 감쇄(Exponential Decay): 마지막 활동 이후 정체 기간이 길어질수록 자산의 최신성과 가치는 기하급수적으로 하락합니다.
  • +
  • 위험 가속 계수(λ): 자산 규모(파일 수)가 클수록 관리 부재 시의 정보 망실 위험이 크다고 판단하여, 더 가파른 감쇄 곡선을 적용합니다.
  • +
  • 활동 품질(Quality Factor): 단순 행정 로그(권한 변경 등)보다 실무 성과물(파일 업로드 등)이 발생했을 때 지수 복원력을 더 높게 부여합니다.
  • +
+

※ 70% 미만 하락 시, 해당 프로젝트의 데이터 노후화 및 관리 방치 위험이 시작된 것으로 간주합니다.

+
+ + + + + + + + + +
지수 (AVI)등급운영 상태
90%↑Live실시간 성과물이 도출되는 최상급 가동 상태
70~90%Stable주기적 업데이트가 이뤄지는 표준 안정 상태
30~70%Idle활력이 저하되어 관리가 필요한 정체 상태
10~30%Risk데이터 노후화 및 자산 가치 소멸 위험 상태
10%↓Frozen운영이 중단된 사망/방치 상태
+ `; + } else if (type === 'vci') { + title.innerText = '자산 가치 기여도 (VCI) 분석 가이드'; + body.innerHTML = ` +

VCI는 야구의 WAR(Wins Above Replacement) 개념을 도입하여, 개별 프로젝트가 전체 포트폴리오 평균 대비 얼마나 조직의 가치에 기여하는지 산출한 지표입니다.

+
+ VCI = (현재 AVI - 전체 평균 AVI) × (파일 규모 가중치) +
+

+ • 0.0 (평균): 우리 조직의 평균적인 관리 수준을 유지 중인 상태
+ • (+) 점수: 평균 이상의 활력으로 조직의 디지털 자산 가치를 증대시킴
+ • (-) 점수: 평균 이하의 방치로 인해 잠재적 기회비용 손실 발생 중 +

+ + + + + + + + + +
점수 (VCI)등급운영 의미
+10.0↑Masterpiece시스템 가치를 견인하는 최우량 핵심 자산
+2.0 ~ +10.0Blue Chip꾸준한 활력으로 가치를 창출하는 우량 자산
-2.0 ~ +2.0Steady평균 수준의 운영을 유지 중인 안정 자산
-10.0 ~ -2.0Underperform평균 대비 활력 부족으로 가치 하락 중인 자산
-10.0↓Liability가치를 훼손 중인 고위험 방치 자산
+ `; + } else if (type === 'oci') { + title.innerText = '운영 일관성 지수 (OCI) 분석 가이드'; + body.innerHTML = ` +
+ "얼마나 꾸준하게 관리되고 있는가?" +

미래 예측이 아닌, 최근 30일간의 활동 리듬관리의 규칙성을 분석하여 성실도를 점수화합니다.

+
+ + + + + + + + +
분석 결과일관성 등급관리 신뢰도
80%↑매우 우수주 단위의 정기적 관리가 완벽히 이뤄짐
50~80%양호간헐적 정체는 있으나 꾸준히 관리됨
20~50%주의돌발적 활동 위주, 관리의 리듬이 깨짐
20%↓매우 불량장기 정체 중이거나 관리 의지 확인 불가
+ `; + } else { + title.innerText = '업무 집중도 (Job Focus) 등급 가이드'; + body.innerHTML = ` +

최근 수집 로그 중 단순 행정 로그를 제외하고 실질적인 성과물(파일) 변동이 포착된 비율입니다.

+ + + + + + + + +
비율 (%)등급활동 성격
80%↑Intensive성과물 위주의 고밀도 집중 작업
50~80%Active성과와 관리가 균형 잡힌 원활한 실행
20~50%Maintenance설정/행정 등 단순 관리 중심의 작업
20%↓Surface실체적 변화가 적은 형식적 로그 중심
+ `; + } + modal.style.display = 'flex'; +} + +function closeAnalysisModal() { document.getElementById('analysisModal').style.display = 'none'; } diff --git a/js/analysis.js_fragment_leaderboard b/js/analysis.js_fragment_leaderboard index a00bd16..445ad4a 100644 --- a/js/analysis.js_fragment_leaderboard +++ b/js/analysis.js_fragment_leaderboard @@ -1,178 +1,178 @@ -function renderPWarLeaderboard(data) { - const container = document.getElementById('p-war-table-container'); - if (!container) return; - - const sortedData = [...data].sort((a, b) => a.p_war - b.p_war); - - container.innerHTML = ` -
- - - - - - - - - - - - - - - ${sortedData.map((p, idx) => { - const status = getStatusInfo(p.p_war, p.is_auto_delete); - const avi = p.p_war; - const vci = p.risk_count; - const oci = p.oci_score || 0; - const rowId = `project-${idx}`; - - let rhythmLabel = ""; - let rhythmColor = ""; - if (oci >= 80) { rhythmLabel = "정기적"; rhythmColor = "#059669"; } - else if (oci >= 50) { rhythmLabel = "안정적"; rhythmColor = "#1e5149"; } - else if (oci >= 20) { rhythmLabel = "간헐적"; rhythmColor = "#f59e0b"; } - else { rhythmLabel = "불규칙"; rhythmColor = "#dc2626"; } - - // 존재 신뢰도 패널티 (ECV) 텍스트 준비 - let ecvText = "100% (데이터 신뢰)"; - let ecvClass = "highlight-val"; - let ecvDesc = "충분한 성과물이 존재합니다."; - if (p.file_count === 0) { - ecvText = "5% (유령 프로젝트)"; - ecvClass = "highlight-penalty"; - ecvDesc = "성과물이 전무하여 시스템 가치가 소멸되었습니다."; - } else if (p.file_count < 10) { - ecvText = "40% (소규모 껍데기)"; - ecvClass = "highlight-penalty"; - ecvDesc = "최소 수준의 데이터만 존재하여 가치가 낮게 평가됩니다."; - } - - // 활동 품질 텍스트 준비 - const qualityLabel = p.log_quality >= 1.0 ? '성과물 직결 실무 활동' : p.log_quality >= 0.7 ? '시스템 구조적 활동' : '단순 행정적 활동'; - - return ` - - - - - - - - - - - - - - `; - }).join('')} - -
프로젝트명파일 수방치일상태 판정 - 활력 지수 (AVI) - 가치 기여 (VCI)업무 집중도 - 운영 일관성 (OCI) -
${p.project_nm}${p.file_count.toLocaleString()}개${p.days_stagnant}일${status.label} - ${avi.toFixed(1)}% - - ${vci >= 0 ? '+' : ''}${vci.toFixed(2)} - -
- - ${p.work_effort}% - -
-
-
-
-
-
- - ${oci}% - - - ${rhythmLabel} - -
-
-
-
-
- ⚙️ AI 위험 적응형 모델(AAS) 산출 시뮬레이션 -
- - -
-
- 📊 실질 업무 집중도 분석 (Job Focus) - - 집중도 ${p.work_effort}% - -
-
-
-
-
- 최근 30개 수집 이력 중 단순 로그 갱신이 아닌 실제 파일 수의 변동이 포착된 날의 비율입니다. - 현재 이 프로젝트는 ${p.work_effort >= 70 ? '매우 밀도 높은 실무' : p.work_effort <= 30 ? '형식적 관리 위주의 정체' : '간헐적인 성과물'} 상태를 보이고 있습니다. -
-
- - -
-
-
1
-
-
동적 위험 계수(λ) 산출
-
자산 규모(${p.file_count}개) 및 부서 위험도를 합산한 하락 속도입니다.
-
λ = ${p.ai_lambda.toFixed(4)}
-
-
-
-
4
-
-
활동 품질 검증 (Quality)
-
- 최근 로그 분석 결과 ${qualityLabel}으로 판명되었습니다. -
-
Factor = ${(p.log_quality * 100).toFixed(0)}%
-
-
- -
-
2
-
-
방치 시간 감쇄 적용
-
${p.days_stagnant}일간의 정체로 인한 가치 보존율입니다.
-
Result = ${((avi / (p.file_count === 0 ? 0.05 : p.file_count < 10 ? 0.4 : 1) / p.log_quality) || 0).toFixed(1)}%
-
-
-
-
3
-
-
존재 진정성 (ECV)
-
${ecvDesc}
-
Factor = ${ecvText}
-
-
-
- -
-
-
- 가치 기여도 (VCI): ${vci >= 0 ? '+' : ''}${vci.toFixed(2)} -
-
* AVI 70% 대비 프로젝트의 실질적 자산 하중 반영
-
-
- 최종 AVI: - ${avi.toFixed(1)}% -
-
-
-
-
-
- `; -} +function renderPWarLeaderboard(data) { + const container = document.getElementById('p-war-table-container'); + if (!container) return; + + const sortedData = [...data].sort((a, b) => a.p_war - b.p_war); + + container.innerHTML = ` +
+ + + + + + + + + + + + + + + ${sortedData.map((p, idx) => { + const status = getStatusInfo(p.p_war, p.is_auto_delete); + const avi = p.p_war; + const vci = p.risk_count; + const oci = p.oci_score || 0; + const rowId = `project-${idx}`; + + let rhythmLabel = ""; + let rhythmColor = ""; + if (oci >= 80) { rhythmLabel = "정기적"; rhythmColor = "#059669"; } + else if (oci >= 50) { rhythmLabel = "안정적"; rhythmColor = "#1e5149"; } + else if (oci >= 20) { rhythmLabel = "간헐적"; rhythmColor = "#f59e0b"; } + else { rhythmLabel = "불규칙"; rhythmColor = "#dc2626"; } + + // 존재 신뢰도 패널티 (ECV) 텍스트 준비 + let ecvText = "100% (데이터 신뢰)"; + let ecvClass = "highlight-val"; + let ecvDesc = "충분한 성과물이 존재합니다."; + if (p.file_count === 0) { + ecvText = "5% (유령 프로젝트)"; + ecvClass = "highlight-penalty"; + ecvDesc = "성과물이 전무하여 시스템 가치가 소멸되었습니다."; + } else if (p.file_count < 10) { + ecvText = "40% (소규모 껍데기)"; + ecvClass = "highlight-penalty"; + ecvDesc = "최소 수준의 데이터만 존재하여 가치가 낮게 평가됩니다."; + } + + // 활동 품질 텍스트 준비 + const qualityLabel = p.log_quality >= 1.0 ? '성과물 직결 실무 활동' : p.log_quality >= 0.7 ? '시스템 구조적 활동' : '단순 행정적 활동'; + + return ` + + + + + + + + + + + + + + `; + }).join('')} + +
프로젝트명파일 수방치일상태 판정 + 활력 지수 (AVI) + 가치 기여 (VCI)업무 집중도 + 운영 일관성 (OCI) +
${p.project_nm}${p.file_count.toLocaleString()}개${p.days_stagnant}일${status.label} + ${avi.toFixed(1)}% + + ${vci >= 0 ? '+' : ''}${vci.toFixed(2)} + +
+ + ${p.work_effort}% + +
+
+
+
+
+
+ + ${oci}% + + + ${rhythmLabel} + +
+
+
+
+
+ ⚙️ AI 위험 적응형 모델(AAS) 산출 시뮬레이션 +
+ + +
+
+ 📊 실질 업무 집중도 분석 (Job Focus) + + 집중도 ${p.work_effort}% + +
+
+
+
+
+ 최근 30개 수집 이력 중 단순 로그 갱신이 아닌 실제 파일 수의 변동이 포착된 날의 비율입니다. + 현재 이 프로젝트는 ${p.work_effort >= 70 ? '매우 밀도 높은 실무' : p.work_effort <= 30 ? '형식적 관리 위주의 정체' : '간헐적인 성과물'} 상태를 보이고 있습니다. +
+
+ + +
+
+
1
+
+
동적 위험 계수(λ) 산출
+
자산 규모(${p.file_count}개) 및 부서 위험도를 합산한 하락 속도입니다.
+
λ = ${p.ai_lambda.toFixed(4)}
+
+
+
+
4
+
+
활동 품질 검증 (Quality)
+
+ 최근 로그 분석 결과 ${qualityLabel}으로 판명되었습니다. +
+
Factor = ${(p.log_quality * 100).toFixed(0)}%
+
+
+ +
+
2
+
+
방치 시간 감쇄 적용
+
${p.days_stagnant}일간의 정체로 인한 가치 보존율입니다.
+
Result = ${((avi / (p.file_count === 0 ? 0.05 : p.file_count < 10 ? 0.4 : 1) / p.log_quality) || 0).toFixed(1)}%
+
+
+
+
3
+
+
존재 진정성 (ECV)
+
${ecvDesc}
+
Factor = ${ecvText}
+
+
+
+ +
+
+
+ 가치 기여도 (VCI): ${vci >= 0 ? '+' : ''}${vci.toFixed(2)} +
+
* AVI 70% 대비 프로젝트의 실질적 자산 하중 반영
+
+
+ 최종 AVI: + ${avi.toFixed(1)}% +
+
+
+
+
+
+ `; +} diff --git a/js/analysis_test.js b/js/analysis_test.js new file mode 100644 index 0000000..dbbb1b6 --- /dev/null +++ b/js/analysis_test.js @@ -0,0 +1,485 @@ +/** + * Project Master Analysis JS (TEST VERSION) + * AVI (Activity Vitality Index) & VCI (Value Contribution Index) 분석 엔진 + * OCI (Operational Consistency Index) 통합 버전 + */ + +// Chart.js 플러그인 전역 등록 +if (typeof ChartDataLabels !== 'undefined') { + Chart.register(ChartDataLabels); +} + +document.addEventListener('DOMContentLoaded', () => { + console.log("Business Analysis Engine (TEST) initialized..."); + loadProjectAnalysisData(); +}); + +async function loadProjectAnalysisData() { + try { + const response = await fetch('/api/analysis/p-war'); + const data = await response.json(); + if (data.error) throw new Error(data.error); + + renderVitalityLeaderboard(data); + renderValueCharts(data); + + if (data.length > 0 && data[0].avg_info) { + const avg = data[0].avg_info; + const infoEl = document.getElementById('avg-system-info'); + if (infoEl) infoEl.textContent = `* 시스템 종합 운영 활력(AVI): ${avg.avg_risk}% (평균 관리 수준) [TEST MODE]`; + } + } catch (e) { + console.error("분석 데이터 로딩 실패:", e); + } +} + +function getStatusInfo(avi, isAutoDelete) { + if (isAutoDelete || avi < 10) return { label: '사망', class: 'badge-system', key: 'dead' }; + if (avi < 30) return { label: '위험 노출', class: 'badge-danger', key: 'danger' }; + if (avi < 70) return { label: '관리 주의', class: 'badge-warning', key: 'warning' }; + return { label: '정상 운영', class: 'badge-active', key: 'active' }; +} + +function getVciGrade(vci) { + if (vci >= 10) return { label: 'Masterpiece', class: 'grade-mvp', desc: '시스템 가치를 견인하는 최우량 핵심 자산' }; + if (vci >= 2) return { label: 'Blue Chip', class: 'grade-allstar', desc: '꾸준한 활력으로 가치를 창출하는 우량 자산' }; + if (vci >= -2) return { label: 'Steady', class: 'grade-starter', desc: '표준 수준의 운영을 유지 중인 안정 자산' }; + if (vci >= -10) return { label: 'Underperform', class: 'grade-bench', desc: '규모 대비 활력 부족으로 가치가 하락 중인 자산' }; + return { label: 'Liability', class: 'grade-out', desc: '가치를 훼손 중인 고위험 방치 자산' }; +} + +function renderValueCharts(data) { + if (!data || data.length === 0) return; + + // 1. 운영 활력 분포 (Doughnut) + try { + const stats = { active: [], warning: [], danger: [], dead: [] }; + data.forEach(p => { + const status = getStatusInfo(p.p_war, p.is_auto_delete); + stats[status.key].push(p); + }); + + const statusCtx = document.getElementById('statusChart').getContext('2d'); + if (window.myStatusChart) window.myStatusChart.destroy(); + + window.myStatusChart = new Chart(statusCtx, { + type: 'doughnut', + data: { + labels: ['정상 운영', '관리 주의', '위험 노출', '사망'], + datasets: [{ + data: [stats.active.length, stats.warning.length, stats.danger.length, stats.dead.length], + backgroundColor: ['#1E5149', '#22c55e', '#f59e0b', '#ef4444'], + borderWidth: 0 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + layout: { padding: 15 }, + plugins: { + legend: { position: 'right', labels: { boxWidth: 10, font: { size: 11, weight: '700' }, usePointStyle: true } }, + datalabels: { display: false } + }, + cutout: '65%', + onClick: (e, elements) => { + if (elements.length > 0) { + const idx = elements[0].index; + openProjectListModal(['정상 운영', '관리 주의', '위험 노출', '사망'][idx], stats[['active', 'warning', 'danger', 'dead'][idx]]); + } + } + } + }); + } catch (err) { console.error("도넛 차트 에러:", err); } + + // 2. 전략적 자산 매트릭스 (Scatter) - 정밀 복구 + try { + const sortedByAVI = [...data].sort((a, b) => b.p_war - a.p_war); + const top5Ids = sortedByAVI.slice(0, 5).map(p => p.project_nm); + const bottom5Ids = sortedByAVI.slice(-5).map(p => p.project_nm); + const largeProjects = data.filter(p => p.file_count > 450).map(p => p.project_nm); + const vipProjectNames = new Set([...top5Ids, ...bottom5Ids, ...largeProjects]); + + const scatterData = data.map(p => { + const vci = p.risk_count || 0; + const absVci = Math.abs(vci); + return { + x: Math.min(500, p.file_count), + y: p.p_war, + label: p.project_nm, + isVip: vipProjectNames.has(p.project_nm), + vci: vci, + radius: Math.max(5, Math.min(25, 5 + (absVci / 10))) + }; + }); + + const vitalityCtx = document.getElementById('forecastChart').getContext('2d'); + if (window.myVitalityChart) window.myVitalityChart.destroy(); + + window.myVitalityChart = new Chart(vitalityCtx, { + type: 'scatter', + data: { + datasets: [{ + data: scatterData, + backgroundColor: (ctx) => { + const p = ctx.raw; + if (!p) return '#94a3b8'; + if (p.x >= 250 && p.y >= 50) return '#1E5149'; + if (p.x < 250 && p.y >= 50) return '#22c55e'; + if (p.x < 250 && p.y < 50) return '#94a3b8'; + return '#ef4444'; + }, + pointRadius: (ctx) => ctx.raw ? ctx.raw.radius : 5, + hoverRadius: (ctx) => (ctx.raw ? ctx.raw.radius : 5) + 3 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + layout: { padding: { top: 30, right: 45, left: 10, bottom: 10 } }, + scales: { + x: { + type: 'linear', min: 0, max: 500, + title: { display: true, text: '자산 규모 (파일 수)', font: { size: 11, weight: '700' } }, + grid: { display: false } + }, + y: { + min: 0, max: 100, + title: { display: true, text: '운영 활력 (AVI %)', font: { size: 11, weight: '700' } }, + grid: { display: false } + } + }, + plugins: { + legend: { display: false }, + datalabels: { + backgroundColor: 'rgba(255, 255, 255, 0.8)', + borderRadius: 4, padding: 4, + font: { size: 10, weight: '800' }, + formatter: (v) => v ? v.label : '', + display: (ctx) => ctx.raw && ctx.raw.isVip, + clip: false + }, + tooltip: { + callbacks: { + label: (ctx) => ` [${ctx.raw.label}] AVI: ${ctx.raw.y.toFixed(1)}% | VCI: ${ctx.raw.vci.toFixed(1)}` + } + } + } + }, + plugins: [{ + id: 'quadrants', + beforeDraw: (chart) => { + const { ctx, chartArea: { left, top, right, bottom }, scales: { x, y } } = chart; + const midX = x.getPixelForValue(250); + const midY = y.getPixelForValue(50); + ctx.save(); + ctx.fillStyle = 'rgba(34, 197, 94, 0.03)'; ctx.fillRect(left, top, midX - left, midY - top); + ctx.fillStyle = 'rgba(30, 81, 73, 0.03)'; ctx.fillRect(midX, top, right - midX, midY - top); + ctx.fillStyle = 'rgba(148, 163, 184, 0.03)'; ctx.fillRect(left, midY, midX - left, bottom - midY); + ctx.fillStyle = 'rgba(239, 68, 68, 0.05)'; ctx.fillRect(midX, midY, right - midX, bottom - midY); + ctx.lineWidth = 2; ctx.strokeStyle = 'rgba(0,0,0,0.1)'; ctx.beginPath(); + ctx.moveTo(midX, top); ctx.lineTo(midX, bottom); ctx.moveTo(left, midY); ctx.lineTo(right, midY); ctx.stroke(); + ctx.font = 'bold 12px Pretendard'; ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(0,0,0,0.2)'; + ctx.fillText('활력 양호', (left + midX) / 2, (top + midY) / 2); + ctx.fillText('핵심 가치', (midX + right) / 2, (top + midY) / 2); + ctx.fillText('정체/소규모', (left + midX) / 2, (midY + bottom) / 2); + ctx.fillStyle = 'rgba(239, 68, 68, 0.4)'; ctx.fillText('자산 손실 위험', (midX + right) / 2, (midY + bottom) / 2); + ctx.restore(); + } + }] + }); + } catch (err) { console.error("전략 매트릭스 에러:", err); } +} + +function renderVitalityLeaderboard(data) { + const container = document.getElementById('p-war-table-container'); + if (!container) return; + const sortedData = [...data].sort((a, b) => a.p_war - b.p_war); + + container.innerHTML = ` +
+ + + + + + + + + + + + + + + ${sortedData.map((p, idx) => { + const status = getStatusInfo(p.p_war, p.is_auto_delete); + const avi = p.p_war; + const vci = p.risk_count; + const oci = p.oci_score || 0; + const rowId = `project-${idx}`; + const grade = getVciGrade(vci); + + let rhythmLabel = oci >= 80 ? "정기적" : oci >= 50 ? "안정적" : oci >= 20 ? "간헐적" : "불규칙"; + let rhythmColor = oci >= 80 ? "#059669" : oci >= 50 ? "#1e5149" : oci >= 20 ? "#f59e0b" : "#dc2626"; + + // 존재 신뢰도 패널티 (ECV) 상세 설명 복구 + let ecvText = "100% (데이터 실체 검증)"; + let ecvClass = "highlight-val"; + let ecvDesc = `현재 ${p.file_count}개의 유효 성과물이 확인됩니다. 시스템적으로 실체가 완벽히 존재하는 상태입니다.`; + if (p.file_count === 0) { + ecvText = "5% (유령 프로젝트 판명)"; + ecvClass = "highlight-penalty"; + ecvDesc = "데이터가 전무하여 프로젝트의 디지털 실체가 없습니다. 모든 분석에서 최하위 패널티가 적용됩니다."; + } else if (p.file_count < 10) { + ecvText = "40% (형식적 껍데기 판명)"; + ecvClass = "highlight-penalty"; + ecvDesc = "최소 수준의 문서만 존재하며, 실질적인 운영 가치를 인정하기 어려운 소규모 상태입니다."; + } + + // 활동 품질 텍스트 복구 + const qualityLabel = p.log_quality >= 1.0 ? '성과물 중심의 실무 활동' : p.log_quality >= 0.7 ? '구조 관리를 위한 시스템 활동' : '단순 행정 기반의 형식 활동'; + const qualityDetail = p.log_quality >= 1.0 ? '최근 로그에서 파일 업로드/수정 등 가치 증분 활동이 명확히 포착되었습니다.' : p.log_quality >= 0.7 ? '폴더 생성/이동 등 구조적 관리는 이뤄지고 있으나, 직접적 결과물 생산은 부족합니다.' : '메일 확인, 권한 변경 등 시스템 유지성 활동 위주로 파악되어 품질 가중치가 낮게 적용되었습니다.'; + + return ` + + + + + + + + + + + + + `; + }).join('')} + +
프로젝트명파일 수정체 일수상태 판정가치 기여 (VCI) 운영 활력 (AVI) 업무 집중도 운영 일관성 (OCI)
${p.project_nm}${p.file_count.toLocaleString()}개${p.days_stagnant}일${status.label} + ${vci > 0 ? '+' : ''}${vci.toFixed(1)} + ${avi.toFixed(1)}% +
+ ${p.work_effort}% +
+
+
+
+
+
+ ${oci}% + ${rhythmLabel} +
+
+
+
+
⚙️ AI 위험 적응형 모델(AAS) 기반 인과관계 분석
+ +
+
+
+ 📊 실질 업무 집중도 (Job Focus) + ${p.work_effort}% +
+
+
최근 30회 수집 이력 중 단순 로그 갱신이 아닌 실제 성과물의 변동이 포착된 날의 비율입니다. 이는 운영의 '진정성'을 보여주는 핵심 지표입니다.
+
+
+
+
VCI GRADE
+
${grade.label}
+
+
${grade.desc}
+
+
+ +
+
+
1
+
+
동적 위험 계수(λ) 산출
+
프로젝트 규모가 클수록 정보 망실 시의 충격을 반영하여 데이터의 하락 속도가 가속됩니다. 현재 λ=${p.ai_lambda.toFixed(4)}는 귀하의 자산 규모가 정밀하게 투영된 결과입니다.
+
Dynamic λ = ${p.ai_lambda.toFixed(4)}
+
+
+
+
4
+
+
활동 품질 검증 (Quality)
+
최근 로그 분석 결과 ${qualityLabel}으로 판명되었습니다. ${qualityDetail}
+
Quality Factor = ${(p.log_quality * 100).toFixed(0)}%
+
+
+
+
2
+
+
방치 시간 감쇄 적용
+
마지막 유효 활동 이후 ${p.days_stagnant}일간의 누적 정체 시간은 지수 감쇄 곡선을 따라 데이터의 최신성과 가치를 상쇄시켰습니다.
+
Decay Result = ${((avi / (p.file_count === 0 ? 0.05 : p.file_count < 10 ? 0.4 : 1) / p.log_quality) || 0).toFixed(1)}%
+
+
+
+
3
+
+
존재 진정성 (ECV)
+
${ecvDesc} 파일 수 자체가 분석의 데이터 진정성을 보정하는 핵심 팩터로 작용합니다.
+
Entity Factor = ${ecvText}
+
+
+
+ +
+
+
+
+ 가치 기여도 (VCI) 진단: ${vci >= 0 ? '+' : ''}${vci.toFixed(2)} +
+
+ 조직 평균 자산: ${p.avg_info.avg_files}개 +
+
+
+ 현재 프로젝트는 포트폴리오 평균 관리 수준 대비 ${Math.abs(vci / Math.max(0.2, p.file_count / p.avg_info.avg_files)).toFixed(1)}%p ${vci >= 0 ? '상회' : '하회'}하고 있으며, + ${p.file_count}개의 자산 규모에 따른 ${Math.max(0.2, p.file_count / p.avg_info.avg_files).toFixed(2)}배의 상대 가중치가 적용되었습니다. + 이는 시스템 전체 관점에서 ${vci >= 0 ? '순자산 가치를 증대' : '잠재적 기회비용을 손실'}시키고 있는 상태로 분석됩니다. +
+
+
+ 최종 AVI: + ${avi.toFixed(1)}% +
+
+
+
+
+
`; +} + +function toggleProjectDetail(rowId) { + const container = document.querySelector('.table-scroll-wrapper'); + const mainRow = document.querySelector(`tr[onclick*="toggleProjectDetail('${rowId}')"]`); + const detailRow = document.getElementById(`detail-${rowId}`); + + if (detailRow && container) { + if (!detailRow.classList.contains('active')) { + document.querySelectorAll('.detail-row').forEach(row => row.classList.remove('active')); + detailRow.classList.add('active'); + + // 정밀 스크롤 이동 로직 복구 + setTimeout(() => { + const headerH = container.querySelector('thead').offsetHeight || 45; + const targetScrollTop = mainRow.offsetTop - headerH; + container.scrollTo({ top: targetScrollTop, behavior: 'smooth' }); + }, 100); + } else { + detailRow.classList.remove('active'); + } + } +} + +function openProjectListModal(label, projects) { + const modal = document.getElementById('analysisModal'); + const title = document.getElementById('modalTitle'); + const body = document.getElementById('modalBody'); + title.innerText = `[${label}] 프로젝트 리스트 (${projects.length}건)`; + body.innerHTML = ` +
+ + + ${projects.map(p => ``).join('')} +
프로젝트명관리자정체일AVI
${p.project_nm}${p.master || '-'}${p.days_stagnant}일${p.p_war.toFixed(1)}%
+
+ `; + modal.style.display = 'flex'; +} + +function openAnalysisModal(type) { + const modal = document.getElementById('analysisModal'); + const title = document.getElementById('modalTitle'); + const body = document.getElementById('modalBody'); + + if (type === 'avi') { + title.innerText = '운영 활력 지수 (AVI) 분석 가이드 (TEST)'; + body.innerHTML = ` +
+ AVI = exp(-λ × Stagnant Days) × Quality × 100 +
+
+

운영 활력 지수(AVI)는 프로젝트가 현재 얼마나 건강하게 가동되고 있는지를 나타내는 '디지털 생존 지표'입니다.

+
    +
  • 지수 감쇄(Exponential Decay): 마지막 활동 이후 정체 기간이 길어질수록 자산의 최신성과 가치는 기하급수적으로 하락합니다.
  • +
  • 위험 가속 계수(λ): 자산 규모(파일 수)가 클수록 관리 부재 시의 정보 망실 위험이 크다고 판단하여, 더 가파른 감쇄 곡선을 적용합니다.
  • +
  • 활동 품질(Quality Factor): 단순 행정 로그(권한 변경 등)보다 실무 성과물(파일 업로드 등)이 발생했을 때 지수 복원력을 더 높게 부여합니다.
  • +
+

※ 70% 미만 하락 시, 해당 프로젝트의 데이터 노후화 및 관리 방치 위험이 시작된 것으로 간주합니다.

+
+ + + + + + + + + +
지수 (AVI)등급운영 상태
90%↑Live실시간 성과물이 도출되는 최상급 가동 상태
70~90%Stable주기적 업데이트가 이뤄지는 표준 안정 상태
30~70%Idle활력이 저하되어 관리가 필요한 정체 상태
10~30%Risk데이터 노후화 및 자산 가치 소멸 위험 상태
10%↓Frozen운영이 중단된 사망/방치 상태
+ `; + } else if (type === 'vci') { + title.innerText = '자산 가치 기여도 (VCI) 분석 가이드 (TEST)'; + body.innerHTML = ` +

VCI는 야구의 WAR(Wins Above Replacement) 개념을 도입하여, 개별 프로젝트가 전체 포트폴리오 평균 대비 얼마나 조직의 가치에 기여하는지 산출한 지표입니다.

+
+ VCI = (현재 AVI - 전체 평균 AVI) × (파일 규모 가중치) +
+

+ • 0.0 (평균): 우리 조직의 평균적인 관리 수준을 유지 중인 상태
+ • (+) 점수: 평균 이상의 활력으로 조직의 디지털 자산 가치를 증대시킴
+ • (-) 점수: 평균 이하의 방치로 인해 잠재적 기회비용 손실 발생 중 +

+ + + + + + + + + +
점수 (VCI)등급운영 의미
+10.0↑Masterpiece시스템 가치를 견인하는 최우량 핵심 자산
+2.0 ~ +10.0Blue Chip꾸준한 활력으로 가치를 창출하는 우량 자산
-2.0 ~ +2.0Steady평균 수준의 운영을 유지 중인 안정 자산
-10.0 ~ -2.0Underperform평균 대비 활력 부족으로 가치 하락 중인 자산
-10.0↓Liability가치를 훼손 중인 고위험 방치 자산
+ `; + } else if (type === 'oci') { + title.innerText = '운영 일관성 지수 (OCI) 분석 가이드 (TEST)'; + body.innerHTML = ` +
+ "얼마나 꾸준하게 관리되고 있는가?" +

미래 예측이 아닌, 최근 30일간의 활동 리듬관리의 규칙성을 분석하여 성실도를 점수화합니다.

+
+ + + + + + + + +
분석 결과일관성 등급관리 신뢰도
80%↑매우 우수주 단위의 정기적 관리가 완벽히 이뤄짐
50~80%양호간헐적 정체는 있으나 꾸준히 관리됨
20~50%주의돌발적 활동 위주, 관리의 리듬이 깨짐
20%↓매우 불량장기 정체 중이거나 관리 의지 확인 불가
+ `; + } else { + title.innerText = '업무 집중도 (Job Focus) 등급 가이드 (TEST)'; + body.innerHTML = ` +

최근 수집 로그 중 단순 행정 로그를 제외하고 실질적인 성과물(파일) 변동이 포착된 비율입니다.

+ + + + + + + + +
비율 (%)등급활동 성격
80%↑Intensive성과물 위주의 고밀도 집중 작업
50~80%Active성과와 관리가 균형 잡힌 원활한 실행
20~50%Maintenance설정/행정 등 단순 관리 중심의 작업
20%↓Surface실체적 변화가 적은 형식적 로그 중심
+ `; + } + modal.style.display = 'flex'; +} + +function closeAnalysisModal() { document.getElementById('analysisModal').style.display = 'none'; } diff --git a/js/common.js b/js/common.js index 6be7296..0c854b1 100644 --- a/js/common.js +++ b/js/common.js @@ -1,78 +1,78 @@ -/** - * Project Master Overseas Common JS - * 공통 네비게이션, 통합 모달 관리, 유틸리티 - */ - -// --- 공통 상수 --- -const API = { - INQUIRIES: '/api/inquiries', - PROJECT_DATA: '/project-data', - PROJECT_ACTIVITY: '/project-activity', - AVAILABLE_DATES: '/available-dates', - SYNC: '/sync', - STOP_SYNC: '/stop-sync', - AUTH_CRAWL: '/auth/crawl', - ANALYZE_FILE: '/analyze-file', - ATTACHMENTS: '/attachments' -}; - -// --- 네비게이션 --- -function navigateTo(path) { - location.href = path; -} - -// --- 통합 모달 관리자 --- -const ModalManager = { - open(modalId) { - const modal = document.getElementById(modalId); - if (modal) { - modal.style.display = 'flex'; - // 포커스 자동 이동 (ID 입력란이 있으면) - const firstInput = modal.querySelector('input'); - if (firstInput) firstInput.focus(); - } - }, - close(modalId) { - const modal = document.getElementById(modalId); - if (modal) modal.style.display = 'none'; - }, - closeAll() { - document.querySelectorAll('.modal-overlay').forEach(m => m.style.display = 'none'); - } -}; - -// --- 유틸리티 함수 --- -const Utils = { - formatDate(dateStr) { - if (!dateStr) return '-'; - return dateStr.replace(/-/g, '.'); - }, - - // 상태별 CSS 클래스 매핑 - getStatusClass(status) { - const map = { - '완료': 'status-complete', - '작업 중': 'status-working', - '확인 중': 'status-checking', - '정상': 'active', - '주의': 'warning', - '방치': 'stale', - '데이터 없음': 'unknown' - }; - return map[status] || 'status-pending'; - }, - - // 한글 파일명 인코딩 안전 처리 - getSafeFileUrl(filename) { - return `/sample_files/${encodeURIComponent(filename)}`; - } -}; - -// --- 전역 이벤트 --- -document.addEventListener('keydown', (e) => { - if (e.key === 'Escape') ModalManager.closeAll(); -}); - -document.addEventListener('DOMContentLoaded', () => { - console.log("Common module initialized."); -}); +/** + * Project Master Overseas Common JS + * 공통 네비게이션, 통합 모달 관리, 유틸리티 + */ + +// --- 공통 상수 --- +const API = { + INQUIRIES: '/api/inquiries', + PROJECT_DATA: '/project-data', + PROJECT_ACTIVITY: '/project-activity', + AVAILABLE_DATES: '/available-dates', + SYNC: '/sync', + STOP_SYNC: '/stop-sync', + AUTH_CRAWL: '/auth/crawl', + ANALYZE_FILE: '/analyze-file', + ATTACHMENTS: '/attachments' +}; + +// --- 네비게이션 --- +function navigateTo(path) { + location.href = path; +} + +// --- 통합 모달 관리자 --- +const ModalManager = { + open(modalId) { + const modal = document.getElementById(modalId); + if (modal) { + modal.style.display = 'flex'; + // 포커스 자동 이동 (ID 입력란이 있으면) + const firstInput = modal.querySelector('input'); + if (firstInput) firstInput.focus(); + } + }, + close(modalId) { + const modal = document.getElementById(modalId); + if (modal) modal.style.display = 'none'; + }, + closeAll() { + document.querySelectorAll('.modal-overlay').forEach(m => m.style.display = 'none'); + } +}; + +// --- 유틸리티 함수 --- +const Utils = { + formatDate(dateStr) { + if (!dateStr) return '-'; + return dateStr.replace(/-/g, '.'); + }, + + // 상태별 CSS 클래스 매핑 + getStatusClass(status) { + const map = { + '완료': 'status-complete', + '작업 중': 'status-working', + '확인 중': 'status-checking', + '정상': 'active', + '주의': 'warning', + '방치': 'stale', + '데이터 없음': 'unknown' + }; + return map[status] || 'status-pending'; + }, + + // 한글 파일명 인코딩 안전 처리 + getSafeFileUrl(filename) { + return `/sample_files/${encodeURIComponent(filename)}`; + } +}; + +// --- 전역 이벤트 --- +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') ModalManager.closeAll(); +}); + +document.addEventListener('DOMContentLoaded', () => { + console.log("Common module initialized."); +}); diff --git a/js/dashboard.js b/js/dashboard.js index 726802a..6f10db4 100644 --- a/js/dashboard.js +++ b/js/dashboard.js @@ -1,237 +1,237 @@ -/** - * Project Master Overseas Dashboard JS - * 기능: 데이터 로드, 활성도 분석, 인증 모달 제어, 크롤링 동기화 및 중단 - */ - -// --- 글로벌 상태 관리 --- -let rawData = []; -let projectActivityDetails = []; -let isCrawling = false; - -const CONTINENT_ORDER = { "아시아": 1, "아프리카": 2, "아메리카": 3, "지사": 4 }; - -// --- 초기화 --- -async function init() { - console.log("Dashboard Initializing..."); - if (!document.getElementById('projectAccordion')) return; - - await loadAvailableDates(); - await loadDataByDate(); -} - -// --- 데이터 통신 및 로드 --- -async function loadAvailableDates() { - try { - const response = await fetch(API.AVAILABLE_DATES); - const dates = await response.json(); - if (dates?.length > 0) { - const selectHtml = ` - `; - const baseDateStrong = document.getElementById('baseDate'); - if (baseDateStrong) baseDateStrong.innerHTML = selectHtml; - } - } catch (e) { console.error("날짜 로드 실패:", e); } -} - -async function loadDataByDate(selectedDate = "") { - try { - await loadActivityAnalysis(selectedDate); - const url = selectedDate ? `${API.PROJECT_DATA}?date=${selectedDate}` : `${API.PROJECT_DATA}?t=${Date.now()}`; - const response = await fetch(url); - const data = await response.json(); - if (data.error) throw new Error(data.error); - rawData = data.projects || []; - renderDashboard(rawData); - } catch (e) { - console.error("데이터 로드 실패:", e); - alert("데이터를 가져오는 데 실패했습니다."); - } -} - -async function loadActivityAnalysis(date = "") { - const dashboard = document.getElementById('activityDashboard'); - if (!dashboard) return; - try { - const url = date ? `${API.PROJECT_ACTIVITY}?date=${date}` : API.PROJECT_ACTIVITY; - const response = await fetch(url); - const data = await response.json(); - if (data.error) return; - const { summary, details } = data; - projectActivityDetails = details; - dashboard.innerHTML = ` -
-
정상 (7일 이내)
${summary.active}
-
-
-
주의 (14일 이내)
${summary.warning}
-
-
-
방치 (14일 초과 / 폴더자동삭제)
${summary.stale}
-
-
-
데이터 없음 (파일 0개)
${summary.unknown}
-
`; - } catch (e) { console.error("분석 로드 실패:", e); } -} - -// --- 렌더링 엔진 --- -function renderDashboard(data) { - const container = document.getElementById('projectAccordion'); - container.innerHTML = ''; - const grouped = groupData(data); - Object.keys(grouped).sort((a, b) => (CONTINENT_ORDER[a] || 99) - (CONTINENT_ORDER[b] || 99)).forEach(continent => { - const continentDiv = document.createElement('div'); - continentDiv.className = 'continent-group active'; - let html = `
${continent}
`; - Object.keys(grouped[continent]).sort().forEach(country => { - html += `
${country}
-
프로젝트명
담당부서
담당자
파일수
최근로그
- ${grouped[continent][country].sort((a, b) => a[0].localeCompare(b[0])).map(p => createProjectHtml(p)).join('')}
`; - }); - html += `
`; - continentDiv.innerHTML = html; - container.appendChild(continentDiv); - }); -} - -function groupData(data) { - const res = {}; - data.forEach(item => { - const c1 = item[5] || "기타", c2 = item[6] || "미분류"; - if (!res[c1]) res[c1] = {}; - if (!res[c1][c2]) res[c1][c2] = []; - res[c1][c2].push(item); - }); - return res; -} - -function createProjectHtml(p) { - const [name, dept, admin, logRaw, files] = p; - const recentLog = (!logRaw || logRaw === "X" || logRaw === "데이터 없음") ? "기록 없음" : logRaw; - const logTime = recentLog !== "기록 없음" ? recentLog.split(',')[0] : "기록 없음"; - - const isStaleLog = recentLog.replace(/\s/g, "").includes("폴더자동삭제"); - const isNoFiles = (files === 0 || files === null); - const statusClass = isNoFiles ? "status-error" : ""; - - let logStyleClass = ""; - if (isStaleLog) logStyleClass = "error-text"; - else if (recentLog === "기록 없음") logStyleClass = "warning-text"; - - const logBoldStyle = isStaleLog ? 'font-weight: 800;' : ''; - - return ` -
-
-
${name}
${dept}
${admin}
${files || 0}
${recentLog}
-
-
-
-
-

참여 인원 상세

- - - -
이름소속권한
${admin}${dept}관리자
-
-
-

최근 활동

- - - -
유형내용일시
로그동기화 완료${logTime}
-
-
-
-
`; -} - -// --- 이벤트 핸들러 --- -function toggleGroup(h) { h.parentElement.classList.toggle('active'); } -function toggleAccordion(h) { - const item = h.parentElement; - item.parentElement.querySelectorAll('.accordion-item').forEach(el => { if (el !== item) el.classList.remove('active'); }); - item.classList.toggle('active'); -} - -function showActivityDetails(status) { - const names = { active: '정상', warning: '주의', stale: '방치', unknown: '데이터 없음' }; - const filtered = (projectActivityDetails || []).filter(d => d.status === status); - document.getElementById('modalTitle').innerText = `${names[status]} 목록 (${filtered.length}개)`; - document.getElementById('modalTableBody').innerHTML = filtered.map(p => { - const o = rawData.find(r => r[0] === p.name); - return `${p.name}${o ? o[1] : "-"}${o ? o[2] : "-"}`; - }).join(''); - ModalManager.open('activityDetailModal'); -} - -function scrollToProject(name) { - ModalManager.close('activityDetailModal'); - const target = Array.from(document.querySelectorAll('.repo-title')).find(t => t.innerText.trim() === name.trim())?.closest('.accordion-header'); - if (target) { - let p = target.parentElement; - while (p && p !== document.body) { - if (p.classList.contains('continent-group') || p.classList.contains('country-group')) p.classList.add('active'); - p = p.parentElement; - } - target.parentElement.classList.add('active'); - const pos = target.getBoundingClientRect().top + window.pageYOffset - 260; - window.scrollTo({ top: pos, behavior: 'smooth' }); - target.style.backgroundColor = 'var(--primary-lv-1)'; - setTimeout(() => target.style.backgroundColor = '', 2000); - } -} - -// --- 크롤링 및 인증 제어 --- -async function syncData() { - if (isCrawling) { - if (confirm("크롤링을 중단하시겠습니까?")) { - const res = await fetch(API.STOP_SYNC); - if ((await res.json()).success) document.getElementById('syncBtn').innerText = "중단 요청 중..."; - } - return; - } - document.getElementById('authId').value = ''; - document.getElementById('authPw').value = ''; - document.getElementById('authErrorMessage').style.display = 'none'; - ModalManager.open('authModal'); -} - -async function submitAuth() { - const id = document.getElementById('authId').value, pw = document.getElementById('authPw').value, err = document.getElementById('authErrorMessage'); - try { - const res = await fetch(API.AUTH_CRAWL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: id, password: pw }) }); - const data = await res.json(); - if (data.success) { ModalManager.close('authModal'); startCrawlProcess(); } - else { err.innerText = "크롤링을 할 수 없습니다."; err.style.display = 'block'; } - } catch { err.innerText = "서버 연결 실패"; err.style.display = 'block'; } -} - -async function startCrawlProcess() { - isCrawling = true; - const btn = document.getElementById('syncBtn'), logC = document.getElementById('logConsole'), logB = document.getElementById('logBody'); - btn.classList.add('loading'); btn.style.backgroundColor = 'var(--error-color)'; btn.innerHTML = ` 크롤링 중단`; - logC.style.display = 'block'; logB.innerHTML = '
>>> 엔진 초기화 중...
'; - try { - const res = await fetch(API.SYNC); - const reader = res.body.getReader(), decoder = new TextDecoder(); - while (true) { - const { done, value } = await reader.read(); if (done) break; - decoder.decode(value).split('\n').forEach(line => { - if (line.startsWith('data: ')) { - const p = JSON.parse(line.substring(6)); - if (p.type === 'log') { - const div = document.createElement('div'); div.innerText = `[${new Date().toLocaleTimeString()}] ${p.message}`; - logB.appendChild(div); logC.scrollTop = logC.scrollHeight; - } else if (p.type === 'done') { init(); alert(`동기화 종료`); logC.style.display = 'none'; } - } - }); - } - } catch { alert("스트림 끊김"); } - finally { isCrawling = false; btn.classList.remove('loading'); btn.style.backgroundColor = ''; btn.innerHTML = ` 데이터 동기화 (크롤링)`; } -} - -document.addEventListener('DOMContentLoaded', init); +/** + * Project Master Overseas Dashboard JS + * 기능: 데이터 로드, 활성도 분석, 인증 모달 제어, 크롤링 동기화 및 중단 + */ + +// --- 글로벌 상태 관리 --- +let rawData = []; +let projectActivityDetails = []; +let isCrawling = false; + +const CONTINENT_ORDER = { "아시아": 1, "아프리카": 2, "아메리카": 3, "지사": 4 }; + +// --- 초기화 --- +async function init() { + console.log("Dashboard Initializing..."); + if (!document.getElementById('projectAccordion')) return; + + await loadAvailableDates(); + await loadDataByDate(); +} + +// --- 데이터 통신 및 로드 --- +async function loadAvailableDates() { + try { + const response = await fetch(API.AVAILABLE_DATES); + const dates = await response.json(); + if (dates?.length > 0) { + const selectHtml = ` + `; + const baseDateStrong = document.getElementById('baseDate'); + if (baseDateStrong) baseDateStrong.innerHTML = selectHtml; + } + } catch (e) { console.error("날짜 로드 실패:", e); } +} + +async function loadDataByDate(selectedDate = "") { + try { + await loadActivityAnalysis(selectedDate); + const url = selectedDate ? `${API.PROJECT_DATA}?date=${selectedDate}` : `${API.PROJECT_DATA}?t=${Date.now()}`; + const response = await fetch(url); + const data = await response.json(); + if (data.error) throw new Error(data.error); + rawData = data.projects || []; + renderDashboard(rawData); + } catch (e) { + console.error("데이터 로드 실패:", e); + alert("데이터를 가져오는 데 실패했습니다."); + } +} + +async function loadActivityAnalysis(date = "") { + const dashboard = document.getElementById('activityDashboard'); + if (!dashboard) return; + try { + const url = date ? `${API.PROJECT_ACTIVITY}?date=${date}` : API.PROJECT_ACTIVITY; + const response = await fetch(url); + const data = await response.json(); + if (data.error) return; + const { summary, details } = data; + projectActivityDetails = details; + dashboard.innerHTML = ` +
+
정상 (7일 이내)
${summary.active}
+
+
+
주의 (14일 이내)
${summary.warning}
+
+
+
방치 (14일 초과 / 폴더자동삭제)
${summary.stale}
+
+
+
데이터 없음 (파일 0개)
${summary.unknown}
+
`; + } catch (e) { console.error("분석 로드 실패:", e); } +} + +// --- 렌더링 엔진 --- +function renderDashboard(data) { + const container = document.getElementById('projectAccordion'); + container.innerHTML = ''; + const grouped = groupData(data); + Object.keys(grouped).sort((a, b) => (CONTINENT_ORDER[a] || 99) - (CONTINENT_ORDER[b] || 99)).forEach(continent => { + const continentDiv = document.createElement('div'); + continentDiv.className = 'continent-group active'; + let html = `
${continent}
`; + Object.keys(grouped[continent]).sort().forEach(country => { + html += `
${country}
+
프로젝트명
담당부서
담당자
파일수
최근로그
+ ${grouped[continent][country].sort((a, b) => a[0].localeCompare(b[0])).map(p => createProjectHtml(p)).join('')}
`; + }); + html += `
`; + continentDiv.innerHTML = html; + container.appendChild(continentDiv); + }); +} + +function groupData(data) { + const res = {}; + data.forEach(item => { + const c1 = item[5] || "기타", c2 = item[6] || "미분류"; + if (!res[c1]) res[c1] = {}; + if (!res[c1][c2]) res[c1][c2] = []; + res[c1][c2].push(item); + }); + return res; +} + +function createProjectHtml(p) { + const [name, dept, admin, logRaw, files] = p; + const recentLog = (!logRaw || logRaw === "X" || logRaw === "데이터 없음") ? "기록 없음" : logRaw; + const logTime = recentLog !== "기록 없음" ? recentLog.split(',')[0] : "기록 없음"; + + const isStaleLog = recentLog.replace(/\s/g, "").includes("폴더자동삭제"); + const isNoFiles = (files === 0 || files === null); + const statusClass = isNoFiles ? "status-error" : ""; + + let logStyleClass = ""; + if (isStaleLog) logStyleClass = "error-text"; + else if (recentLog === "기록 없음") logStyleClass = "warning-text"; + + const logBoldStyle = isStaleLog ? 'font-weight: 800;' : ''; + + return ` +
+
+
${name}
${dept}
${admin}
${files || 0}
${recentLog}
+
+
+
+
+

참여 인원 상세

+ + + +
이름소속권한
${admin}${dept}관리자
+
+
+

최근 활동

+ + + +
유형내용일시
로그동기화 완료${logTime}
+
+
+
+
`; +} + +// --- 이벤트 핸들러 --- +function toggleGroup(h) { h.parentElement.classList.toggle('active'); } +function toggleAccordion(h) { + const item = h.parentElement; + item.parentElement.querySelectorAll('.accordion-item').forEach(el => { if (el !== item) el.classList.remove('active'); }); + item.classList.toggle('active'); +} + +function showActivityDetails(status) { + const names = { active: '정상', warning: '주의', stale: '방치', unknown: '데이터 없음' }; + const filtered = (projectActivityDetails || []).filter(d => d.status === status); + document.getElementById('modalTitle').innerText = `${names[status]} 목록 (${filtered.length}개)`; + document.getElementById('modalTableBody').innerHTML = filtered.map(p => { + const o = rawData.find(r => r[0] === p.name); + return `${p.name}${o ? o[1] : "-"}${o ? o[2] : "-"}`; + }).join(''); + ModalManager.open('activityDetailModal'); +} + +function scrollToProject(name) { + ModalManager.close('activityDetailModal'); + const target = Array.from(document.querySelectorAll('.repo-title')).find(t => t.innerText.trim() === name.trim())?.closest('.accordion-header'); + if (target) { + let p = target.parentElement; + while (p && p !== document.body) { + if (p.classList.contains('continent-group') || p.classList.contains('country-group')) p.classList.add('active'); + p = p.parentElement; + } + target.parentElement.classList.add('active'); + const pos = target.getBoundingClientRect().top + window.pageYOffset - 260; + window.scrollTo({ top: pos, behavior: 'smooth' }); + target.style.backgroundColor = 'var(--primary-lv-1)'; + setTimeout(() => target.style.backgroundColor = '', 2000); + } +} + +// --- 크롤링 및 인증 제어 --- +async function syncData() { + if (isCrawling) { + if (confirm("크롤링을 중단하시겠습니까?")) { + const res = await fetch(API.STOP_SYNC); + if ((await res.json()).success) document.getElementById('syncBtn').innerText = "중단 요청 중..."; + } + return; + } + document.getElementById('authId').value = ''; + document.getElementById('authPw').value = ''; + document.getElementById('authErrorMessage').style.display = 'none'; + ModalManager.open('authModal'); +} + +async function submitAuth() { + const id = document.getElementById('authId').value, pw = document.getElementById('authPw').value, err = document.getElementById('authErrorMessage'); + try { + const res = await fetch(API.AUTH_CRAWL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: id, password: pw }) }); + const data = await res.json(); + if (data.success) { ModalManager.close('authModal'); startCrawlProcess(); } + else { err.innerText = "크롤링을 할 수 없습니다."; err.style.display = 'block'; } + } catch { err.innerText = "서버 연결 실패"; err.style.display = 'block'; } +} + +async function startCrawlProcess() { + isCrawling = true; + const btn = document.getElementById('syncBtn'), logC = document.getElementById('logConsole'), logB = document.getElementById('logBody'); + btn.classList.add('loading'); btn.style.backgroundColor = 'var(--error-color)'; btn.innerHTML = ` 크롤링 중단`; + logC.style.display = 'block'; logB.innerHTML = '
>>> 엔진 초기화 중...
'; + try { + const res = await fetch(API.SYNC); + const reader = res.body.getReader(), decoder = new TextDecoder(); + while (true) { + const { done, value } = await reader.read(); if (done) break; + decoder.decode(value).split('\n').forEach(line => { + if (line.startsWith('data: ')) { + const p = JSON.parse(line.substring(6)); + if (p.type === 'log') { + const div = document.createElement('div'); div.innerText = `[${new Date().toLocaleTimeString()}] ${p.message}`; + logB.appendChild(div); logC.scrollTop = logC.scrollHeight; + } else if (p.type === 'done') { init(); alert(`동기화 종료`); logC.style.display = 'none'; } + } + }); + } + } catch { alert("스트림 끊김"); } + finally { isCrawling = false; btn.classList.remove('loading'); btn.style.backgroundColor = ''; btn.innerHTML = ` 데이터 동기화 (크롤링)`; } +} + +document.addEventListener('DOMContentLoaded', init); diff --git a/js/dashboard_test.js b/js/dashboard_test.js new file mode 100644 index 0000000..267e270 --- /dev/null +++ b/js/dashboard_test.js @@ -0,0 +1,245 @@ +/** + * Project Master Overseas Dashboard JS (TEST VERSION) + * 기능: 데이터 로드, 활성도 분석, 인증 모달 제어, 크롤링 동기화 및 중단 + */ + +// --- 글로벌 상태 관리 --- +let rawData = []; +let projectActivityDetails = []; +let isCrawling = false; + +const CONTINENT_ORDER = { "아시아": 1, "아프리카": 2, "아메리카": 3, "지사": 4 }; + +// --- 초기화 --- +async function init() { + console.log("Dashboard (TEST) Initializing..."); + if (!document.getElementById('projectAccordion')) return; + + await loadAvailableDates(); + await loadDataByDate(); +} + +// --- 데이터 통신 및 로드 --- +async function loadAvailableDates() { + try { + const response = await fetch(API.AVAILABLE_DATES); + const dates = await response.json(); // YYYY.MM.DD 형식 리스트 + + if (dates?.length > 0) { + // 날짜 형식 변환 (YYYY.MM.DD -> YYYY-MM-DD) + const formattedDates = dates.map(d => d.replace(/\./g, '-')).sort(); + const minDate = formattedDates[0]; + const maxDate = new Date().toISOString().split('T')[0]; // 오늘 + const defaultDate = formattedDates[formattedDates.length - 1]; // 가장 최신 수집일 + + const dateInputHtml = ` + + `; + const baseDateStrong = document.getElementById('baseDate'); + if (baseDateStrong) baseDateStrong.innerHTML = dateInputHtml; + } + } catch (e) { console.error("날짜 로드 실패:", e); } +} + +async function loadDataByDate(selectedDate = "") { + try { + await loadActivityAnalysis(selectedDate); + const url = selectedDate ? `${API.PROJECT_DATA}?date=${selectedDate}` : `${API.PROJECT_DATA}?t=${Date.now()}`; + const response = await fetch(url); + const data = await response.json(); + if (data.error) throw new Error(data.error); + rawData = data.projects || []; + renderDashboard(rawData); + } catch (e) { + console.error("데이터 로드 실패:", e); + alert("데이터를 가져오는 데 실패했습니다."); + } +} + +async function loadActivityAnalysis(date = "") { + const dashboard = document.getElementById('activityDashboard'); + if (!dashboard) return; + try { + const url = date ? `${API.PROJECT_ACTIVITY}?date=${date}` : API.PROJECT_ACTIVITY; + const response = await fetch(url); + const data = await response.json(); + if (data.error) return; + const { summary, details } = data; + projectActivityDetails = details; + dashboard.innerHTML = ` +
+
정상 (7일 이내) [TEST]
${summary.active}
+
+
+
주의 (14일 이내) [TEST]
${summary.warning}
+
+
+
방치 (14일 초과) [TEST]
${summary.stale}
+
+
+
데이터 없음 (파일 0개) [TEST]
${summary.unknown}
+
`; + } catch (e) { console.error("분석 로드 실패:", e); } +} + +// --- 렌더링 엔진 --- +function renderDashboard(data) { + const container = document.getElementById('projectAccordion'); + container.innerHTML = ''; + const grouped = groupData(data); + Object.keys(grouped).sort((a, b) => (CONTINENT_ORDER[a] || 99) - (CONTINENT_ORDER[b] || 99)).forEach(continent => { + const continentDiv = document.createElement('div'); + continentDiv.className = 'continent-group active'; + let html = `
${continent}
`; + Object.keys(grouped[continent]).sort().forEach(country => { + html += `
${country}
+
프로젝트명
담당부서
담당자
파일수
최근로그
+ ${grouped[continent][country].sort((a, b) => a[0].localeCompare(b[0])).map(p => createProjectHtml(p)).join('')}
`; + }); + html += `
`; + continentDiv.innerHTML = html; + container.appendChild(continentDiv); + }); +} + +function groupData(data) { + const res = {}; + data.forEach(item => { + const c1 = item[5] || "기타", c2 = item[6] || "미분류"; + if (!res[c1]) res[c1] = {}; + if (!res[c1][c2]) res[c1][c2] = []; + res[c1][c2].push(item); + }); + return res; +} + +function createProjectHtml(p) { + const [name, dept, admin, logRaw, files] = p; + const recentLog = (!logRaw || logRaw === "X" || logRaw === "데이터 없음") ? "기록 없음" : logRaw; + const logTime = recentLog !== "기록 없음" ? recentLog.split(',')[0] : "기록 없음"; + + const isStaleLog = recentLog.replace(/\s/g, "").includes("폴더자동삭제"); + const isNoFiles = (files === 0 || files === null); + const statusClass = isNoFiles ? "status-error" : ""; + + let logStyleClass = ""; + if (isStaleLog) logStyleClass = "error-text"; + else if (recentLog === "기록 없음") logStyleClass = "warning-text"; + + const logBoldStyle = isStaleLog ? 'font-weight: 800;' : ''; + + return ` +
+
+
${name}
${dept}
${admin}
${files || 0}
${recentLog}
+
+
+
+
+

참여 인원 상세 (TEST)

+ + + +
이름소속권한
${admin}${dept}관리자
+
+
+

최근 활동 (TEST)

+ + + +
유형내용일시
로그동기화 완료${logTime}
+
+
+
+
`; +} + +// --- 이벤트 핸들러 --- +function toggleGroup(h) { h.parentElement.classList.toggle('active'); } +function toggleAccordion(h) { + const item = h.parentElement; + item.parentElement.querySelectorAll('.accordion-item').forEach(el => { if (el !== item) el.classList.remove('active'); }); + item.classList.toggle('active'); +} + +function showActivityDetails(status) { + const names = { active: '정상', warning: '주의', stale: '방치', unknown: '데이터 없음' }; + const filtered = (projectActivityDetails || []).filter(d => d.status === status); + document.getElementById('modalTitle').innerText = `${names[status]} 목록 (${filtered.length}개) [TEST]`; + document.getElementById('modalTableBody').innerHTML = filtered.map(p => { + const o = rawData.find(r => r[0] === p.name); + return `${p.name}${o ? o[1] : "-"}${o ? o[2] : "-"}`; + }).join(''); + ModalManager.open('activityDetailModal'); +} + +function scrollToProject(name) { + ModalManager.close('activityDetailModal'); + const target = Array.from(document.querySelectorAll('.repo-title')).find(t => t.innerText.trim() === name.trim())?.closest('.accordion-header'); + if (target) { + let p = target.parentElement; + while (p && p !== document.body) { + if (p.classList.contains('continent-group') || p.classList.contains('country-group')) p.classList.add('active'); + p = p.parentElement; + } + target.parentElement.classList.add('active'); + const pos = target.getBoundingClientRect().top + window.pageYOffset - 260; + window.scrollTo({ top: pos, behavior: 'smooth' }); + target.style.backgroundColor = 'var(--primary-lv-1)'; + setTimeout(() => target.style.backgroundColor = '', 2000); + } +} + +// --- 크롤링 및 인증 제어 --- +async function syncData() { + if (isCrawling) { + if (confirm("크롤링을 중단하시겠습니까?")) { + const res = await fetch(API.STOP_SYNC); + if ((await res.json()).success) document.getElementById('syncBtn').innerText = "중단 요청 중..."; + } + return; + } + document.getElementById('authId').value = ''; + document.getElementById('authPw').value = ''; + document.getElementById('authErrorMessage').style.display = 'none'; + ModalManager.open('authModal'); +} + +async function submitAuth() { + const id = document.getElementById('authId').value, pw = document.getElementById('authPw').value, err = document.getElementById('authErrorMessage'); + try { + const res = await fetch(API.AUTH_CRAWL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: id, password: pw }) }); + const data = await res.json(); + if (data.success) { ModalManager.close('authModal'); startCrawlProcess(); } + else { err.innerText = "크롤링을 할 수 없습니다."; err.style.display = 'block'; } + } catch { err.innerText = "서버 연결 실패"; err.style.display = 'block'; } +} + +async function startCrawlProcess() { + isCrawling = true; + const btn = document.getElementById('syncBtn'), logC = document.getElementById('logConsole'), logB = document.getElementById('logBody'); + btn.classList.add('loading'); btn.style.backgroundColor = 'var(--error-color)'; btn.innerHTML = ` 크롤링 중단`; + logC.style.display = 'block'; logB.innerHTML = '
>>> 엔진 초기화 중...
'; + try { + const res = await fetch(API.SYNC); + const reader = res.body.getReader(), decoder = new TextDecoder(); + while (true) { + const { done, value } = await reader.read(); if (done) break; + decoder.decode(value).split('\n').forEach(line => { + if (line.startsWith('data: ')) { + const p = JSON.parse(line.substring(6)); + if (p.type === 'log') { + const div = document.createElement('div'); div.innerText = `[${new Date().toLocaleTimeString()}] ${p.message}`; + logB.appendChild(div); logC.scrollTop = logC.scrollHeight; + } else if (p.type === 'done') { init(); alert(`동기화 종료`); logC.style.display = 'none'; } + } + }); + } + } catch { alert("스트림 끊김"); } + finally { isCrawling = false; btn.classList.remove('loading'); btn.style.backgroundColor = ''; btn.innerHTML = ` 데이터 동기화 (크롤링)`; } +} + +document.addEventListener('DOMContentLoaded', init); \ No newline at end of file diff --git a/js/inquiries.js b/js/inquiries.js index 74197d0..9e1f394 100644 --- a/js/inquiries.js +++ b/js/inquiries.js @@ -1,314 +1,314 @@ -/** - * Project Master Overseas Inquiries JS - * 기능: 문의사항 로드, 필터링, 답변 관리, 아코디언 및 이미지 모달 - */ - -// --- 초기화 --- -let allInquiries = []; -let currentSort = { field: 'no', direction: 'desc' }; - -async function loadInquiries() { - initStickyHeader(); - - const pmType = document.getElementById('filterPmType').value; - const category = document.getElementById('filterCategory').value; - const keyword = document.getElementById('searchKeyword').value; - - const params = new URLSearchParams({ - pm_type: pmType, - category: category, - keyword: keyword - }); - - try { - const response = await fetch(`${API.INQUIRIES}?${params}`); - allInquiries = await response.json(); - - refreshInquiryBoard(); - } catch (e) { - console.error("데이터 로딩 중 오류 발생:", e); - } -} - -function refreshInquiryBoard() { - const status = document.getElementById('filterStatus').value; - - // 1. 상태 필터링 - let filteredData = status ? allInquiries.filter(item => item.status === status) : [...allInquiries]; - - // 2. 정렬 적용 - filteredData = sortData(filteredData); - - // 3. 통계 및 리스트 렌더링 - updateStats(allInquiries); - updateSortUI(); - renderInquiryList(filteredData); -} - -function handleSort(field) { - if (currentSort.field === field) { - currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc'; - } else { - currentSort.field = field; - currentSort.direction = 'asc'; - } - refreshInquiryBoard(); -} - -function sortData(data) { - const { field, direction } = currentSort; - const modifier = direction === 'asc' ? 1 : -1; - - return data.sort((a, b) => { - let valA = a[field]; - let valB = b[field]; - - // 숫자형 변환 시도 (No 필드 등) - if (field === 'no' || !isNaN(valA)) { - valA = Number(valA); - valB = Number(valB); - } - - // null/undefined 처리 - if (valA === null || valA === undefined) valA = ""; - if (valB === null || valB === undefined) valB = ""; - - if (valA < valB) return -1 * modifier; - if (valA > valB) return 1 * modifier; - return 0; - }); -} - -function updateSortUI() { - // 모든 헤더 클래스 및 아이콘 초기화 - document.querySelectorAll('.inquiry-table thead th.sortable').forEach(th => { - th.classList.remove('active-sort'); - const icon = th.querySelector('.sort-icon'); - if (icon) { - // 레이아웃 시프트 방지를 위해 투명한 기본 아이콘(또는 공백) 유지 - icon.textContent = "▲"; - icon.style.opacity = "0"; - } - }); - - // 현재 정렬된 헤더 강조 및 아이콘 표시 - const activeTh = document.querySelector(`.inquiry-table thead th[onclick*="'${currentSort.field}'"]`); - if (activeTh) { - activeTh.classList.add('active-sort'); - const icon = activeTh.querySelector('.sort-icon'); - if (icon) { - icon.textContent = currentSort.direction === 'asc' ? "▲" : "▼"; - icon.style.opacity = "1"; - } - } -} - -function initStickyHeader() { - const header = document.getElementById('stickyHeader'); - const thead = document.querySelector('.inquiry-table thead'); - if (header && thead) { - const headerHeight = header.offsetHeight; - const totalOffset = 36 + headerHeight; - document.querySelectorAll('.inquiry-table thead th').forEach(th => { - th.style.top = totalOffset + 'px'; - }); - } -} - -function renderInquiryList(data) { - const tbody = document.getElementById('inquiryList'); - tbody.innerHTML = data.map(item => ` - - ${item.no} - - ${item.image_url ? `thumbnail` : '없음'} - - ${item.pm_type} - ${item.browser || 'Chrome'} - ${item.category} - ${item.project_nm} - ${item.content} - ${item.author} - ${item.reg_date} - ${item.reply || '-'} - ${item.status} - - - -
- -
-
-
작성자: ${item.author}
-
등록일: ${item.reg_date}
-
시스템: ${item.pm_type}
-
환경: ${item.browser || 'Chrome'} / ${item.device || 'PC'}
-
- -
-

[질문 내용]

-
${item.content}
-
- - ${item.image_url ? ` -
-
-

- 🖼️ [첨부 이미지] - (클릭 시 크게 보기) -

- -
- -
- ` : ''} - -
-

[조치 및 답변]

-
- -
-
-
- - -
-
- - -
-
-
- - - - -
-
- ${item.handled_date ? `
최종 수정일: ${item.handled_date}
` : ''} -
-
-
-
- - - `).join(''); -} - -function enableEdit(id) { - const form = document.getElementById(`reply-form-${id}`); - form.classList.replace('readonly', 'editable'); - const elements = [`reply-text-${id}`, `reply-status-${id}`, `reply-handler-${id}`]; - elements.forEach(elId => document.getElementById(elId).disabled = false); - document.getElementById(`reply-text-${id}`).focus(); -} - -async function cancelEdit(id) { - try { - const response = await fetch(`${API.INQUIRIES}/${id}`); - const item = await response.json(); - const txt = document.getElementById(`reply-text-${id}`); - const status = document.getElementById(`reply-status-${id}`); - const handler = document.getElementById(`reply-handler-${id}`); - txt.value = item.reply || ''; - status.value = item.status; - handler.value = item.handler || ''; - [txt, status, handler].forEach(el => el.disabled = true); - document.getElementById(`reply-form-${id}`).classList.replace('editable', 'readonly'); - } catch { loadInquiries(); } -} - -async function saveReply(id) { - const reply = document.getElementById(`reply-text-${id}`).value; - const status = document.getElementById(`reply-status-${id}`).value; - const handler = document.getElementById(`reply-handler-${id}`).value; - if (!reply.trim() || !handler.trim()) return alert("내용과 처리자를 모두 입력해 주세요."); - try { - const response = await fetch(`${API.INQUIRIES}/${id}/reply`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ reply, status, handler }) - }); - if ((await response.json()).success) { alert("저장되었습니다."); loadInquiries(); } - } catch { alert("저장 중 오류가 발생했습니다."); } -} - -async function deleteReply(id) { - if (!confirm("답변을 삭제하시겠습니까?")) return; - try { - const response = await fetch(`${API.INQUIRIES}/${id}/reply`, { method: 'DELETE' }); - if ((await response.json()).success) { alert("삭제되었습니다."); loadInquiries(); } - } catch { alert("삭제 중 오류가 발생했습니다."); } -} - -function toggleAccordion(id) { - const detailRow = document.getElementById(`detail-${id}`); - if (!detailRow) return; - const inquiryRow = detailRow.previousElementSibling; - const isActive = detailRow.classList.contains('active'); - - document.querySelectorAll('.detail-row.active').forEach(row => { - if (row.id !== `detail-${id}`) { - row.classList.remove('active'); - if (row.previousElementSibling) row.previousElementSibling.classList.remove('active-row'); - } - }); - - if (isActive) { - detailRow.classList.remove('active'); - inquiryRow.classList.remove('active-row'); - } else { - detailRow.classList.add('active'); - inquiryRow.classList.add('active-row'); - scrollToRow(inquiryRow); - } -} - -function scrollToRow(row) { - setTimeout(() => { - const headerHeight = document.getElementById('stickyHeader').offsetHeight; - const totalOffset = 36 + headerHeight + 40; - const offsetPosition = (row.getBoundingClientRect().top + window.pageYOffset) - totalOffset; - window.scrollTo({ top: offsetPosition, behavior: 'smooth' }); - }, 100); -} - -function updateStats(data) { - const counts = { - Total: data.length, - Complete: data.filter(i => i.status === '완료').length, - Working: data.filter(i => i.status === '작업 중').length, - Checking: data.filter(i => i.status === '확인 중').length, - Pending: data.filter(i => i.status === '개발예정').length, - Unconfirmed: data.filter(i => i.status === '미확인').length - }; - Object.keys(counts).forEach(k => { - const el = document.getElementById(`count${k}`); - if (el) el.textContent = counts[k].toLocaleString(); - }); -} - -function openImageModal(src) { - document.getElementById('modalImage').src = src; - ModalManager.open('imageModal'); -} - -function toggleImageSection(id) { - const section = document.getElementById(`img-section-${id}`); - const content = document.getElementById(`img-content-${id}`); - const icon = section.querySelector('.toggle-icon'); - const isCollapsed = content.classList.toggle('collapsed'); - section.classList.toggle('active', !isCollapsed); - icon.textContent = isCollapsed ? '▼' : '▲'; -} - -document.addEventListener('DOMContentLoaded', loadInquiries); -window.addEventListener('resize', initStickyHeader); +/** + * Project Master Overseas Inquiries JS + * 기능: 문의사항 로드, 필터링, 답변 관리, 아코디언 및 이미지 모달 + */ + +// --- 초기화 --- +let allInquiries = []; +let currentSort = { field: 'no', direction: 'desc' }; + +async function loadInquiries() { + initStickyHeader(); + + const pmType = document.getElementById('filterPmType').value; + const category = document.getElementById('filterCategory').value; + const keyword = document.getElementById('searchKeyword').value; + + const params = new URLSearchParams({ + pm_type: pmType, + category: category, + keyword: keyword + }); + + try { + const response = await fetch(`${API.INQUIRIES}?${params}`); + allInquiries = await response.json(); + + refreshInquiryBoard(); + } catch (e) { + console.error("데이터 로딩 중 오류 발생:", e); + } +} + +function refreshInquiryBoard() { + const status = document.getElementById('filterStatus').value; + + // 1. 상태 필터링 + let filteredData = status ? allInquiries.filter(item => item.status === status) : [...allInquiries]; + + // 2. 정렬 적용 + filteredData = sortData(filteredData); + + // 3. 통계 및 리스트 렌더링 + updateStats(allInquiries); + updateSortUI(); + renderInquiryList(filteredData); +} + +function handleSort(field) { + if (currentSort.field === field) { + currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc'; + } else { + currentSort.field = field; + currentSort.direction = 'asc'; + } + refreshInquiryBoard(); +} + +function sortData(data) { + const { field, direction } = currentSort; + const modifier = direction === 'asc' ? 1 : -1; + + return data.sort((a, b) => { + let valA = a[field]; + let valB = b[field]; + + // 숫자형 변환 시도 (No 필드 등) + if (field === 'no' || !isNaN(valA)) { + valA = Number(valA); + valB = Number(valB); + } + + // null/undefined 처리 + if (valA === null || valA === undefined) valA = ""; + if (valB === null || valB === undefined) valB = ""; + + if (valA < valB) return -1 * modifier; + if (valA > valB) return 1 * modifier; + return 0; + }); +} + +function updateSortUI() { + // 모든 헤더 클래스 및 아이콘 초기화 + document.querySelectorAll('.inquiry-table thead th.sortable').forEach(th => { + th.classList.remove('active-sort'); + const icon = th.querySelector('.sort-icon'); + if (icon) { + // 레이아웃 시프트 방지를 위해 투명한 기본 아이콘(또는 공백) 유지 + icon.textContent = "▲"; + icon.style.opacity = "0"; + } + }); + + // 현재 정렬된 헤더 강조 및 아이콘 표시 + const activeTh = document.querySelector(`.inquiry-table thead th[onclick*="'${currentSort.field}'"]`); + if (activeTh) { + activeTh.classList.add('active-sort'); + const icon = activeTh.querySelector('.sort-icon'); + if (icon) { + icon.textContent = currentSort.direction === 'asc' ? "▲" : "▼"; + icon.style.opacity = "1"; + } + } +} + +function initStickyHeader() { + const header = document.getElementById('stickyHeader'); + const thead = document.querySelector('.inquiry-table thead'); + if (header && thead) { + const headerHeight = header.offsetHeight; + const totalOffset = 36 + headerHeight; + document.querySelectorAll('.inquiry-table thead th').forEach(th => { + th.style.top = totalOffset + 'px'; + }); + } +} + +function renderInquiryList(data) { + const tbody = document.getElementById('inquiryList'); + tbody.innerHTML = data.map(item => ` + + ${item.no} + + ${item.image_url ? `thumbnail` : '없음'} + + ${item.pm_type} + ${item.browser || 'Chrome'} + ${item.category} + ${item.project_nm} + ${item.content} + ${item.author} + ${item.reg_date} + ${item.reply || '-'} + ${item.status} + + + +
+ +
+
+
작성자: ${item.author}
+
등록일: ${item.reg_date}
+
시스템: ${item.pm_type}
+
환경: ${item.browser || 'Chrome'} / ${item.device || 'PC'}
+
+ +
+

[질문 내용]

+
${item.content}
+
+ + ${item.image_url ? ` +
+
+

+ 🖼️ [첨부 이미지] + (클릭 시 크게 보기) +

+ +
+ +
+ ` : ''} + +
+

[조치 및 답변]

+
+ +
+
+
+ + +
+
+ + +
+
+
+ + + + +
+
+ ${item.handled_date ? `
최종 수정일: ${item.handled_date}
` : ''} +
+
+
+
+ + + `).join(''); +} + +function enableEdit(id) { + const form = document.getElementById(`reply-form-${id}`); + form.classList.replace('readonly', 'editable'); + const elements = [`reply-text-${id}`, `reply-status-${id}`, `reply-handler-${id}`]; + elements.forEach(elId => document.getElementById(elId).disabled = false); + document.getElementById(`reply-text-${id}`).focus(); +} + +async function cancelEdit(id) { + try { + const response = await fetch(`${API.INQUIRIES}/${id}`); + const item = await response.json(); + const txt = document.getElementById(`reply-text-${id}`); + const status = document.getElementById(`reply-status-${id}`); + const handler = document.getElementById(`reply-handler-${id}`); + txt.value = item.reply || ''; + status.value = item.status; + handler.value = item.handler || ''; + [txt, status, handler].forEach(el => el.disabled = true); + document.getElementById(`reply-form-${id}`).classList.replace('editable', 'readonly'); + } catch { loadInquiries(); } +} + +async function saveReply(id) { + const reply = document.getElementById(`reply-text-${id}`).value; + const status = document.getElementById(`reply-status-${id}`).value; + const handler = document.getElementById(`reply-handler-${id}`).value; + if (!reply.trim() || !handler.trim()) return alert("내용과 처리자를 모두 입력해 주세요."); + try { + const response = await fetch(`${API.INQUIRIES}/${id}/reply`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reply, status, handler }) + }); + if ((await response.json()).success) { alert("저장되었습니다."); loadInquiries(); } + } catch { alert("저장 중 오류가 발생했습니다."); } +} + +async function deleteReply(id) { + if (!confirm("답변을 삭제하시겠습니까?")) return; + try { + const response = await fetch(`${API.INQUIRIES}/${id}/reply`, { method: 'DELETE' }); + if ((await response.json()).success) { alert("삭제되었습니다."); loadInquiries(); } + } catch { alert("삭제 중 오류가 발생했습니다."); } +} + +function toggleAccordion(id) { + const detailRow = document.getElementById(`detail-${id}`); + if (!detailRow) return; + const inquiryRow = detailRow.previousElementSibling; + const isActive = detailRow.classList.contains('active'); + + document.querySelectorAll('.detail-row.active').forEach(row => { + if (row.id !== `detail-${id}`) { + row.classList.remove('active'); + if (row.previousElementSibling) row.previousElementSibling.classList.remove('active-row'); + } + }); + + if (isActive) { + detailRow.classList.remove('active'); + inquiryRow.classList.remove('active-row'); + } else { + detailRow.classList.add('active'); + inquiryRow.classList.add('active-row'); + scrollToRow(inquiryRow); + } +} + +function scrollToRow(row) { + setTimeout(() => { + const headerHeight = document.getElementById('stickyHeader').offsetHeight; + const totalOffset = 36 + headerHeight + 40; + const offsetPosition = (row.getBoundingClientRect().top + window.pageYOffset) - totalOffset; + window.scrollTo({ top: offsetPosition, behavior: 'smooth' }); + }, 100); +} + +function updateStats(data) { + const counts = { + Total: data.length, + Complete: data.filter(i => i.status === '완료').length, + Working: data.filter(i => i.status === '작업 중').length, + Checking: data.filter(i => i.status === '확인 중').length, + Pending: data.filter(i => i.status === '개발예정').length, + Unconfirmed: data.filter(i => i.status === '미확인').length + }; + Object.keys(counts).forEach(k => { + const el = document.getElementById(`count${k}`); + if (el) el.textContent = counts[k].toLocaleString(); + }); +} + +function openImageModal(src) { + document.getElementById('modalImage').src = src; + ModalManager.open('imageModal'); +} + +function toggleImageSection(id) { + const section = document.getElementById(`img-section-${id}`); + const content = document.getElementById(`img-content-${id}`); + const icon = section.querySelector('.toggle-icon'); + const isCollapsed = content.classList.toggle('collapsed'); + section.classList.toggle('active', !isCollapsed); + icon.textContent = isCollapsed ? '▼' : '▲'; +} + +document.addEventListener('DOMContentLoaded', loadInquiries); +window.addEventListener('resize', initStickyHeader); diff --git a/js/mail.js b/js/mail.js index f3d1898..a4d512b 100644 --- a/js/mail.js +++ b/js/mail.js @@ -1,312 +1,312 @@ -/** - * Project Master Overseas Mail Management JS - * 기능: 첨부파일 로드, AI 분석, 메일 목록 렌더링, 미리보기, 주소록 관리 - */ - -let currentFiles = []; -let editingIndex = -1; - -const HIERARCHY = { - "행정": { "계약": ["계약관리", "기성관리", "업무지시서", "인원관리"], "업무관리": ["업무일지(2025)", "업무일지(2025년 이전)", "발주처 정기보고", "본사업무보고", "공사감독일지", "양식서류"] }, - "설계성과품": { "시방서": ["공사시방서"], "설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공"], "수량산출서": ["토공", "배수공"], "내역서": ["단가산출서"], "보고서": ["실시설계보고서", "지반조사보고서"], "측량계산부": ["측량계산부"], "설계단계 수행협의": ["회의·협의"] }, - "시공검측": { "토공": ["검측 (깨기)", "검측 (노체)"], "배수공": ["검측 (V형측구)", "검측 (종배수관)"], "구조물공": ["검측 (평목교)"], "포장공": ["검측 (기층)"] }, - "설계변경": { "실정보고": ["토공", "배수공", "안전관리"], "기술지원 검토": ["토공", "구조물&부대공"] } -}; - -const MAIL_SAMPLES = { - inbound: [ - { person: "라오스 농림부", email: "pany.s@lao.gov.la", time: "2026-03-05", title: "ITTC 교육센터 착공식 일정 협의", summary: "착공식 관련하여 정부 측 인사의 일정을 반영한 최종 공문을 송부합니다.", active: true }, - { person: "현대건설 (김철수 소장)", email: "cs.kim@hdec.co.kr", time: "2026-03-04", title: "[긴급] 어천-공주(4차) 하도급 변경계약 통보", summary: "철거공사 물량 변동에 따른 계약 금액 조정 건입니다. 검토 후 승인 부탁드립니다.", active: false } - ], - outbound: [ - { person: "공사관리부 (본사)", email: "hq_pm@projectmaster.com", time: "2026-03-04", title: "어천-공주 2월 월간 공정보고서 제출", summary: "2월 한 달간의 주요 공정 및 예산 집행 현황 보고서입니다.", active: false } - ], - drafts: [], deleted: [] -}; - -let currentMailTab = 'inbound'; -let filteredMails = []; - -// --- 첨부파일 데이터 로드 및 렌더링 --- -async function loadAttachments() { - try { - const res = await fetch(API.ATTACHMENTS); - currentFiles = await res.json(); - renderFiles(); - } catch (e) { console.error("Failed to load attachments:", e); } -} - -function renderFiles() { - const isAiActive = document.getElementById('aiToggle').checked; - const container = document.getElementById('attachmentList'); - if (!container) return; - container.innerHTML = ''; - - currentFiles.forEach((file, index) => { - const item = document.createElement('div'); - item.className = 'attachment-item-wrap'; - item.style.marginBottom = "8px"; - - let pathText = "경로를 선택해주세요"; - let modeClass = "manual-mode"; - - if (file.analysis) { - const prefix = file.analysis.isManual ? "선택 경로: " : "추천: "; - pathText = `${prefix}${file.analysis.suggested_path}`; - modeClass = file.analysis.isManual ? "manual-mode" : "smart-mode"; - } else if (isAiActive) { - pathText = "AI 분석 대기 중..."; - modeClass = "smart-mode"; - } - - item.innerHTML = ` -
- 📄 -
-
${file.name}
-
${file.size}
-
-
- ${pathText} - ${isAiActive ? `` : ''} - -
-
-
- `; - container.appendChild(item); - }); -} - -// --- AI 분석 실행 --- -async function startAnalysis(index, event) { - if (event) event.stopPropagation(); - const file = currentFiles[index]; - if (!file) return; - - // UI 상태 업데이트: 분석 중 표시 - const logArea = document.getElementById(`log-area-${index}`); - const logContent = document.getElementById(`log-content-${index}`); - if (logArea) logArea.classList.add('active'); - if (logContent) { - logContent.innerHTML = `
- - AI가 문서를 정밀 분석 중입니다... -
`; - } - - try { - const res = await fetch(`${API.ANALYZE_FILE}?filename=${encodeURIComponent(file.name)}`); - const result = await res.json(); - - if (result.error) { - if (logContent) logContent.innerHTML = `
오류: ${result.error}
`; - return; - } - - // 분석 결과 저장 및 UI 갱신 - currentFiles[index].analysis = result.final_result; - currentFiles[index].analysis.isManual = false; - - if (logContent) { - logContent.innerHTML = ` -
-
✨ AI 분석 완료
-
${result.final_result.reason}
-
- `; - } - - renderFiles(); - } catch (e) { - console.error("AI Analysis failed:", e); - if (logContent) logContent.innerHTML = `
분석 실패: 네트워크 오류가 발생했습니다.
`; - } -} - -// --- 미리보기 제어 --- -function showPreview(index, event) { - if (event && (event.target.closest('.btn-group') || event.target.closest('.path-display'))) return; - const file = currentFiles[index]; - if (!file) return; - - const previewArea = document.getElementById('mailPreviewArea'); - const toggleIcon = document.getElementById('previewToggleIcon'); - const fullViewBtn = document.getElementById('fullViewBtn'); - const previewContainer = document.getElementById('previewContainer'); - - if (previewArea) { - previewArea.classList.add('active'); - if (toggleIcon) toggleIcon.innerText = '▶'; - } - - const fileUrl = Utils.getSafeFileUrl(file.name); - if (fullViewBtn) { - fullViewBtn.style.display = 'block'; - fullViewBtn.onclick = () => window.open(fileUrl, 'PMFullView', 'width=1000,height=800'); - } - - if (file.name.toLowerCase().endsWith('.pdf')) { - previewContainer.innerHTML = ``; - } else { - previewContainer.innerHTML = `
${file.name}
`; - } - - document.querySelectorAll('.attachment-item').forEach(item => item.classList.remove('active')); - if (event?.currentTarget) event.currentTarget.classList.add('active'); -} - -function togglePreviewAuto() { - const area = document.getElementById('mailPreviewArea'); - const icon = document.getElementById('previewToggleIcon'); - if (area) { - const isActive = area.classList.toggle('active'); - if (icon) icon.innerText = isActive ? '▶' : '◀'; - } -} - -// --- 메일 리스트 제어 --- -function renderMailList(tabType, mailsToShow = null) { - currentMailTab = tabType; - const container = document.querySelector('.mail-items-container'); - if (!container) return; - - const mails = mailsToShow || MAIL_SAMPLES[tabType] || []; - filteredMails = mails; - updateBulkActionBar(); - - container.innerHTML = mails.map((mail, idx) => ` -
- -
-
- ${mail.person} -
${mail.time}
-
-
${mail.title}
-
${mail.summary}
-
-
- `).join(''); - - const activeIdx = mails.findIndex(m => m.active); - if (activeIdx !== -1) updateMailContent(mails[activeIdx]); -} - -function selectMailItem(el, index) { - document.querySelectorAll('.mail-item').forEach(item => item.classList.remove('active')); - el.classList.add('active'); - const mail = filteredMails[index]; - if (mail) updateMailContent(mail); -} - -function updateMailContent(mail) { - const title = document.querySelector('.mail-content-header h2'); - if (title) title.innerText = mail.title; - document.querySelector('.mail-body').innerHTML = mail.summary.replace(/\n/g, '
') + "

본 내용은 샘플 데이터입니다."; -} - -function switchMailTab(el, tabType) { - document.querySelectorAll('.mail-tab').forEach(tab => tab.classList.remove('active')); - el.classList.add('active'); - renderMailList(tabType); -} - -// --- 경로 선택 모달 --- -function openPathModal(index, event) { - if (event) event.stopPropagation(); - editingIndex = index; - const tabSelect = document.getElementById('tabSelect'); - if (tabSelect) { - tabSelect.innerHTML = Object.keys(HIERARCHY).map(tab => ``).join(''); - updateCategories(); - ModalManager.open('pathModal'); - } -} - -function updateCategories() { - const tab = document.getElementById('tabSelect').value; - document.getElementById('categorySelect').innerHTML = Object.keys(HIERARCHY[tab]).map(cat => ``).join(''); - updateSubs(); -} - -function updateSubs() { - const tab = document.getElementById('tabSelect').value; - const cat = document.getElementById('categorySelect').value; - document.getElementById('subSelect').innerHTML = HIERARCHY[tab][cat].map(sub => ``).join(''); -} - -function applyPathSelection() { - const path = `${document.getElementById('tabSelect').value} > ${document.getElementById('categorySelect').value} > ${document.getElementById('subSelect').value}`; - if (!currentFiles[editingIndex].analysis) currentFiles[editingIndex].analysis = {}; - currentFiles[editingIndex].analysis.suggested_path = path; - currentFiles[editingIndex].analysis.isManual = true; - renderFiles(); - ModalManager.close('pathModal'); -} - -// --- 주소록 관리 --- -let addressBookData = [ - { name: "이태훈", dept: "PM Overseas / 선임연구원", email: "th.lee@projectmaster.com", phone: "010-1234-5678" }, - { name: "Pany S.", dept: "라오스 농림부 / 국장", email: "pany.s@lao.gov.la", phone: "+856-20-1234-5678" } -]; -let contactEditingIndex = -1; - -function openAddressBook() { renderAddressBook(); ModalManager.open('addressBookModal'); } -function closeAddressBook() { ModalManager.close('addressBookModal'); } - -function renderAddressBook() { - const body = document.getElementById('addressBookBody'); - if (!body) return; - body.innerHTML = addressBookData.map((c, idx) => ` - - ${c.name}${c.dept}${c.email}${c.phone} - - - - - `).join(''); -} - -function toggleAddContactForm() { - const form = document.getElementById('addContactForm'); - if (form.style.display === 'none') form.style.display = 'block'; - else { form.style.display = 'none'; contactEditingIndex = -1; } -} - -function editContact(index) { - const c = addressBookData[index]; - contactEditingIndex = index; - document.getElementById('newContactName').value = c.name; - document.getElementById('newContactDept').value = c.dept; - document.getElementById('newContactEmail').value = c.email; - document.getElementById('newContactPhone').value = c.phone; - document.getElementById('addContactForm').style.display = 'block'; -} - -function deleteContact(index) { - if (confirm(`'${addressBookData[index].name}'님을 삭제하시겠습니까?`)) { addressBookData.splice(index, 1); renderAddressBook(); } -} - -function addContact() { - const name = document.getElementById('newContactName').value; - if (!name) return alert("이름을 입력해주세요."); - const data = { name, dept: document.getElementById('newContactDept').value, email: document.getElementById('newContactEmail').value, phone: document.getElementById('newContactPhone').value }; - if (contactEditingIndex > -1) addressBookData[contactEditingIndex] = data; - else addressBookData.push(data); - renderAddressBook(); toggleAddContactForm(); -} - -// --- 공통 액션 --- -function updateBulkActionBar() { - const count = document.querySelectorAll('.mail-item-checkbox:checked').length; - const bar = document.getElementById('mailBulkActions'); - if (count > 0) { bar.classList.add('active'); document.getElementById('selectedCount').innerText = `${count}개 선택됨`; } - else bar.classList.remove('active'); -} - -// 초기화 -document.addEventListener('DOMContentLoaded', () => { - loadAttachments(); - renderMailList('inbound'); -}); +/** + * Project Master Overseas Mail Management JS + * 기능: 첨부파일 로드, AI 분석, 메일 목록 렌더링, 미리보기, 주소록 관리 + */ + +let currentFiles = []; +let editingIndex = -1; + +const HIERARCHY = { + "행정": { "계약": ["계약관리", "기성관리", "업무지시서", "인원관리"], "업무관리": ["업무일지(2025)", "업무일지(2025년 이전)", "발주처 정기보고", "본사업무보고", "공사감독일지", "양식서류"] }, + "설계성과품": { "시방서": ["공사시방서"], "설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공"], "수량산출서": ["토공", "배수공"], "내역서": ["단가산출서"], "보고서": ["실시설계보고서", "지반조사보고서"], "측량계산부": ["측량계산부"], "설계단계 수행협의": ["회의·협의"] }, + "시공검측": { "토공": ["검측 (깨기)", "검측 (노체)"], "배수공": ["검측 (V형측구)", "검측 (종배수관)"], "구조물공": ["검측 (평목교)"], "포장공": ["검측 (기층)"] }, + "설계변경": { "실정보고": ["토공", "배수공", "안전관리"], "기술지원 검토": ["토공", "구조물&부대공"] } +}; + +const MAIL_SAMPLES = { + inbound: [ + { person: "라오스 농림부", email: "pany.s@lao.gov.la", time: "2026-03-05", title: "ITTC 교육센터 착공식 일정 협의", summary: "착공식 관련하여 정부 측 인사의 일정을 반영한 최종 공문을 송부합니다.", active: true }, + { person: "현대건설 (김철수 소장)", email: "cs.kim@hdec.co.kr", time: "2026-03-04", title: "[긴급] 어천-공주(4차) 하도급 변경계약 통보", summary: "철거공사 물량 변동에 따른 계약 금액 조정 건입니다. 검토 후 승인 부탁드립니다.", active: false } + ], + outbound: [ + { person: "공사관리부 (본사)", email: "hq_pm@projectmaster.com", time: "2026-03-04", title: "어천-공주 2월 월간 공정보고서 제출", summary: "2월 한 달간의 주요 공정 및 예산 집행 현황 보고서입니다.", active: false } + ], + drafts: [], deleted: [] +}; + +let currentMailTab = 'inbound'; +let filteredMails = []; + +// --- 첨부파일 데이터 로드 및 렌더링 --- +async function loadAttachments() { + try { + const res = await fetch(API.ATTACHMENTS); + currentFiles = await res.json(); + renderFiles(); + } catch (e) { console.error("Failed to load attachments:", e); } +} + +function renderFiles() { + const isAiActive = document.getElementById('aiToggle').checked; + const container = document.getElementById('attachmentList'); + if (!container) return; + container.innerHTML = ''; + + currentFiles.forEach((file, index) => { + const item = document.createElement('div'); + item.className = 'attachment-item-wrap'; + item.style.marginBottom = "8px"; + + let pathText = "경로를 선택해주세요"; + let modeClass = "manual-mode"; + + if (file.analysis) { + const prefix = file.analysis.isManual ? "선택 경로: " : "추천: "; + pathText = `${prefix}${file.analysis.suggested_path}`; + modeClass = file.analysis.isManual ? "manual-mode" : "smart-mode"; + } else if (isAiActive) { + pathText = "AI 분석 대기 중..."; + modeClass = "smart-mode"; + } + + item.innerHTML = ` +
+ 📄 +
+
${file.name}
+
${file.size}
+
+
+ ${pathText} + ${isAiActive ? `` : ''} + +
+
+
+ `; + container.appendChild(item); + }); +} + +// --- AI 분석 실행 --- +async function startAnalysis(index, event) { + if (event) event.stopPropagation(); + const file = currentFiles[index]; + if (!file) return; + + // UI 상태 업데이트: 분석 중 표시 + const logArea = document.getElementById(`log-area-${index}`); + const logContent = document.getElementById(`log-content-${index}`); + if (logArea) logArea.classList.add('active'); + if (logContent) { + logContent.innerHTML = `
+ + AI가 문서를 정밀 분석 중입니다... +
`; + } + + try { + const res = await fetch(`${API.ANALYZE_FILE}?filename=${encodeURIComponent(file.name)}`); + const result = await res.json(); + + if (result.error) { + if (logContent) logContent.innerHTML = `
오류: ${result.error}
`; + return; + } + + // 분석 결과 저장 및 UI 갱신 + currentFiles[index].analysis = result.final_result; + currentFiles[index].analysis.isManual = false; + + if (logContent) { + logContent.innerHTML = ` +
+
✨ AI 분석 완료
+
${result.final_result.reason}
+
+ `; + } + + renderFiles(); + } catch (e) { + console.error("AI Analysis failed:", e); + if (logContent) logContent.innerHTML = `
분석 실패: 네트워크 오류가 발생했습니다.
`; + } +} + +// --- 미리보기 제어 --- +function showPreview(index, event) { + if (event && (event.target.closest('.btn-group') || event.target.closest('.path-display'))) return; + const file = currentFiles[index]; + if (!file) return; + + const previewArea = document.getElementById('mailPreviewArea'); + const toggleIcon = document.getElementById('previewToggleIcon'); + const fullViewBtn = document.getElementById('fullViewBtn'); + const previewContainer = document.getElementById('previewContainer'); + + if (previewArea) { + previewArea.classList.add('active'); + if (toggleIcon) toggleIcon.innerText = '▶'; + } + + const fileUrl = Utils.getSafeFileUrl(file.name); + if (fullViewBtn) { + fullViewBtn.style.display = 'block'; + fullViewBtn.onclick = () => window.open(fileUrl, 'PMFullView', 'width=1000,height=800'); + } + + if (file.name.toLowerCase().endsWith('.pdf')) { + previewContainer.innerHTML = ``; + } else { + previewContainer.innerHTML = `
${file.name}
`; + } + + document.querySelectorAll('.attachment-item').forEach(item => item.classList.remove('active')); + if (event?.currentTarget) event.currentTarget.classList.add('active'); +} + +function togglePreviewAuto() { + const area = document.getElementById('mailPreviewArea'); + const icon = document.getElementById('previewToggleIcon'); + if (area) { + const isActive = area.classList.toggle('active'); + if (icon) icon.innerText = isActive ? '▶' : '◀'; + } +} + +// --- 메일 리스트 제어 --- +function renderMailList(tabType, mailsToShow = null) { + currentMailTab = tabType; + const container = document.querySelector('.mail-items-container'); + if (!container) return; + + const mails = mailsToShow || MAIL_SAMPLES[tabType] || []; + filteredMails = mails; + updateBulkActionBar(); + + container.innerHTML = mails.map((mail, idx) => ` +
+ +
+
+ ${mail.person} +
${mail.time}
+
+
${mail.title}
+
${mail.summary}
+
+
+ `).join(''); + + const activeIdx = mails.findIndex(m => m.active); + if (activeIdx !== -1) updateMailContent(mails[activeIdx]); +} + +function selectMailItem(el, index) { + document.querySelectorAll('.mail-item').forEach(item => item.classList.remove('active')); + el.classList.add('active'); + const mail = filteredMails[index]; + if (mail) updateMailContent(mail); +} + +function updateMailContent(mail) { + const title = document.querySelector('.mail-content-header h2'); + if (title) title.innerText = mail.title; + document.querySelector('.mail-body').innerHTML = mail.summary.replace(/\n/g, '
') + "

본 내용은 샘플 데이터입니다."; +} + +function switchMailTab(el, tabType) { + document.querySelectorAll('.mail-tab').forEach(tab => tab.classList.remove('active')); + el.classList.add('active'); + renderMailList(tabType); +} + +// --- 경로 선택 모달 --- +function openPathModal(index, event) { + if (event) event.stopPropagation(); + editingIndex = index; + const tabSelect = document.getElementById('tabSelect'); + if (tabSelect) { + tabSelect.innerHTML = Object.keys(HIERARCHY).map(tab => ``).join(''); + updateCategories(); + ModalManager.open('pathModal'); + } +} + +function updateCategories() { + const tab = document.getElementById('tabSelect').value; + document.getElementById('categorySelect').innerHTML = Object.keys(HIERARCHY[tab]).map(cat => ``).join(''); + updateSubs(); +} + +function updateSubs() { + const tab = document.getElementById('tabSelect').value; + const cat = document.getElementById('categorySelect').value; + document.getElementById('subSelect').innerHTML = HIERARCHY[tab][cat].map(sub => ``).join(''); +} + +function applyPathSelection() { + const path = `${document.getElementById('tabSelect').value} > ${document.getElementById('categorySelect').value} > ${document.getElementById('subSelect').value}`; + if (!currentFiles[editingIndex].analysis) currentFiles[editingIndex].analysis = {}; + currentFiles[editingIndex].analysis.suggested_path = path; + currentFiles[editingIndex].analysis.isManual = true; + renderFiles(); + ModalManager.close('pathModal'); +} + +// --- 주소록 관리 --- +let addressBookData = [ + { name: "이태훈", dept: "PM Overseas / 선임연구원", email: "th.lee@projectmaster.com", phone: "010-1234-5678" }, + { name: "Pany S.", dept: "라오스 농림부 / 국장", email: "pany.s@lao.gov.la", phone: "+856-20-1234-5678" } +]; +let contactEditingIndex = -1; + +function openAddressBook() { renderAddressBook(); ModalManager.open('addressBookModal'); } +function closeAddressBook() { ModalManager.close('addressBookModal'); } + +function renderAddressBook() { + const body = document.getElementById('addressBookBody'); + if (!body) return; + body.innerHTML = addressBookData.map((c, idx) => ` + + ${c.name}${c.dept}${c.email}${c.phone} + + + + + `).join(''); +} + +function toggleAddContactForm() { + const form = document.getElementById('addContactForm'); + if (form.style.display === 'none') form.style.display = 'block'; + else { form.style.display = 'none'; contactEditingIndex = -1; } +} + +function editContact(index) { + const c = addressBookData[index]; + contactEditingIndex = index; + document.getElementById('newContactName').value = c.name; + document.getElementById('newContactDept').value = c.dept; + document.getElementById('newContactEmail').value = c.email; + document.getElementById('newContactPhone').value = c.phone; + document.getElementById('addContactForm').style.display = 'block'; +} + +function deleteContact(index) { + if (confirm(`'${addressBookData[index].name}'님을 삭제하시겠습니까?`)) { addressBookData.splice(index, 1); renderAddressBook(); } +} + +function addContact() { + const name = document.getElementById('newContactName').value; + if (!name) return alert("이름을 입력해주세요."); + const data = { name, dept: document.getElementById('newContactDept').value, email: document.getElementById('newContactEmail').value, phone: document.getElementById('newContactPhone').value }; + if (contactEditingIndex > -1) addressBookData[contactEditingIndex] = data; + else addressBookData.push(data); + renderAddressBook(); toggleAddContactForm(); +} + +// --- 공통 액션 --- +function updateBulkActionBar() { + const count = document.querySelectorAll('.mail-item-checkbox:checked').length; + const bar = document.getElementById('mailBulkActions'); + if (count > 0) { bar.classList.add('active'); document.getElementById('selectedCount').innerText = `${count}개 선택됨`; } + else bar.classList.remove('active'); +} + +// 초기화 +document.addEventListener('DOMContentLoaded', () => { + loadAttachments(); + renderMailList('inbound'); +}); diff --git a/log_analysis_result.txt b/log_analysis_result.txt new file mode 100644 index 0000000..f979ecd --- /dev/null +++ b/log_analysis_result.txt @@ -0,0 +1,42 @@ +[Raw Log Samples] +- 2026.03.30, 폴더 삭제 +- 2025.12.07, 파일 업로드 +- 2025.12.01, 파일 다운로드 +- 2025.11.26, 파일 다운로드 +- 2025.11.24, 파일 이름 변경 +- 2025.11.20, 부관리자 권한 추가 +- 2025.11.19, 보안참여자 권한 추가 +- 2025.11.03, 부관리자 권한 추가 +- 2025.10.17, 일반참여자 권한 추가 +- 2025.10.14, 참관자 권한 추가 +- 2025.09.25, 일반참여자 권한 추가 +- 2025.09.24, 부관리자 권한 추가 +- 2025.09.23, 부관리자 권한 추가 +- 2026.01.29, 폴더 삭제 +- 2025.12.12, 폴더 자동 삭제 +- 2025.11.26, 폴더 이름 변경 +- 2025.11.21, 참관자 권한 추가 +- 2025.10.30, 폴더 삭제 +- 2025.09.25, 부관리자 권한 추가 +- 2026.03.25, PDF 변환 + +[Log Patterns Frequency] +(46) [DATE], 파일 업로드 +(35) [DATE], 파일 다운로드 +(28) [DATE], PDF 변환 +(21) [DATE], 폴더 삭제 +(12) [DATE], 폴더 자동 삭제 +(12) [DATE], 폴더 이름 변경 +(11) [DATE], 부관리자 권한 추가 +(11) [DATE], 휴지통으로 이동 +(9) [DATE], 파일 이름 변경 +(9) [DATE], 새 폴더 생성 +(6) [DATE], 보안참여자 권한 추가 +(6) [DATE], AI요약 +(5) [DATE], 일반참여자 권한 추가 +(4) [DATE], 참관자 권한 추가 +(3) [DATE], 부관리자 권한 삭제 +(2) [DATE], 참관자 권한 삭제 +(1) [DATE], 폴더 권한 설정 +(1) [DATE], 일반참여자 권한 삭제 +(1) [DATE], 첨부파일 추가 \ No newline at end of file diff --git a/log_scorer.py b/log_scorer.py new file mode 100644 index 0000000..6a64e13 --- /dev/null +++ b/log_scorer.py @@ -0,0 +1,63 @@ +import re + +class LogScorer: + """로그 텍스트의 시맨틱 가치를 판별하여 점수화하는 모듈 (SWVW)""" + + # 업무 가치 범주별 가중치 정의 + # 1.0: 핵심 의사결정/계약, 0.7: 지능형/권한관리, 0.4: 일반관리, 0.1: 단순활동 + WEIGHT_MAP = { + "CORE": 1.0, # 설계변경, 실정보고, 계약, 정산 등 (추후 확장 대비) + "INTELLIGENT": 0.7, # AI요약, PDF변환, 분석 등 + "AUTH": 0.6, # 권한 추가, 보안 설정 등 + "MGMT": 0.4, # 업로드, 수정, 이름 변경 등 + "SIMPLE": 0.2, # 다운로드, 삭제, 생성 등 + "AUTO": 0.0 # 자동 삭제, 시스템 로그 등 + } + + # 범주별 키워드 정의 (실무 문맥 반영) + KEYWORDS = { + "CORE": ["보고", "계약", "정산", "설계", "검토", "승인", "공문", "통보"], + "INTELLIGENT": ["AI", "요약", "변환", "PDF"], + "AUTH": ["권한", "참여자", "관리자", "보안"], + "MGMT": ["업로드", "수정", "변경", "첨부", "추가"], + "SIMPLE": ["다운로드", "생성", "이동", "삭제"], + "AUTO": ["자동", "시스템"] + } + + @classmethod + def get_score(cls, log_text): + """로그 텍스트를 분석하여 0.0 ~ 1.0 사이의 가치 점수를 반환""" + if not log_text or log_text == "데이터 없음": + return 0.0 + + # 날짜 부분 제거 (예: "2024.03.01, " 제거) + clean_log = re.sub(r'^\d{2,4}\.\d{2}\.\d{2},\s*', '', log_text) + + # 1. 특정 키워드 매칭을 통한 기본 범주 판별 + for category, keywords in cls.KEYWORDS.items(): + if any(k in clean_log for k in keywords): + # '자동 삭제'는 별도 처리 + if category == "SIMPLE" and "자동" in clean_log: + return cls.WEIGHT_MAP["AUTO"] + return cls.WEIGHT_MAP[category] + + return 0.3 # 기본값 (분류되지 않은 활동) + + @classmethod + def calculate_work_density(cls, logs): + """로그 목록을 받아 평균 업무 밀도 산출""" + if not logs: return 0.0 + scores = [cls.get_score(log) for log in logs] + return sum(scores) / len(scores) + +# 테스트 코드 +if __name__ == "__main__": + test_logs = [ + "2026.03.30, 하도급 계약 통보서 검토", + "2026.03.25, AI요약 완료", + "2026.03.20, 부관리자 권한 추가", + "2026.03.15, 파일 업로드", + "2026.03.10, 폴더 자동 삭제" + ] + for log in test_logs: + print(f"Log: {log} => Score: {LogScorer.get_score(log)}") diff --git a/migrate_to_docker.py b/migrate_to_docker.py new file mode 100644 index 0000000..cf071eb --- /dev/null +++ b/migrate_to_docker.py @@ -0,0 +1,124 @@ +import pymysql +import os +import sys + +def migrate(): + # Source DB (Windows Host) connection details + # We can use the IP from current DB_HOST or fallback to 172.26.208.1 + src_host = os.getenv('DB_HOST', '172.26.208.1') + src_user = os.getenv('DB_USER', 'root') + src_password = os.getenv('DB_PASSWORD', '45278434') + + # Target DB (Docker container) connection details + # Inside container, 'db' refers to the MariaDB service. + # Note: we connect to target's internal port 3306. + tgt_host = 'db' + tgt_user = 'root' + tgt_password = '45278434' + + databases = ['PM_proto', 'PM_proto_test'] + + print(f"Starting migration from Source ({src_host}:3306) to Target ({tgt_host}:3306)...") + + try: + # Establish connection to source + src_conn = pymysql.connect( + host=src_host, + user=src_user, + password=src_password, + charset='utf8mb4' + ) + print("Connected to Source Database successfully.") + except Exception as e: + print(f"Failed to connect to Source Database: {e}") + sys.exit(1) + + try: + # Establish connection to target + tgt_conn = pymysql.connect( + host=tgt_host, + user=tgt_user, + password=tgt_password, + charset='utf8mb4' + ) + print("Connected to Target Database successfully.") + except Exception as e: + print(f"Failed to connect to Target Database: {e}") + src_conn.close() + sys.exit(1) + + try: + with src_conn.cursor() as src_cur, tgt_conn.cursor() as tgt_cur: + # Disable foreign key checks to prevent dependency order issues during creation + tgt_cur.execute("SET FOREIGN_KEY_CHECKS = 0") + for db_name in databases: + print(f"\n--- Migrating Database: {db_name} ---") + + # Check if database exists in source + src_cur.execute(f"SHOW DATABASES LIKE '{db_name}'") + if not src_cur.fetchone(): + print(f"Database {db_name} does not exist in source. Skipping.") + continue + + # Create database on target + tgt_cur.execute(f"CREATE DATABASE IF NOT EXISTS `{db_name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci") + print(f"Created database {db_name} on target (or already exists).") + + # Switch to database + src_cur.execute(f"USE `{db_name}`") + tgt_cur.execute(f"USE `{db_name}`") + + # Get list of tables + src_cur.execute("SHOW TABLES") + tables = [r[0] for r in src_cur.fetchall()] + print(f"Tables to migrate: {tables}") + + for table in tables: + print(f" Migrating table: {table}...") + + # Get CREATE TABLE statement + src_cur.execute(f"SHOW CREATE TABLE `{table}`") + create_stmt = src_cur.fetchone()[1] + # Replace MySQL 8.0 specific collation with MariaDB compatible collation + create_stmt = create_stmt.replace('utf8mb4_0900_ai_ci', 'utf8mb4_general_ci') + + # Drop table on target if exists + tgt_cur.execute(f"DROP TABLE IF EXISTS `{table}`") + + # Create table on target + tgt_cur.execute(create_stmt) + + # Get data from source + src_cur.execute(f"SELECT * FROM `{table}`") + rows = src_cur.fetchall() + + if not rows: + print(f" Table {table} is empty.") + continue + + # Get columns list + src_cur.execute(f"DESCRIBE `{table}`") + cols = [r[0] for r in src_cur.fetchall()] + + # Insert data into target + col_names = ", ".join([f"`{c}`" for c in cols]) + placeholders = ", ".join(["%s"] * len(cols)) + insert_stmt = f"INSERT INTO `{table}` ({col_names}) VALUES ({placeholders})" + + tgt_cur.executemany(insert_stmt, rows) + tgt_conn.commit() + print(f" Successfully migrated {len(rows)} rows into {table}.") + + # Re-enable foreign key checks after completion + with tgt_conn.cursor() as tgt_cur: + tgt_cur.execute("SET FOREIGN_KEY_CHECKS = 1") + print("\nMigration completed successfully!") + except Exception as e: + print(f"Error during migration: {e}") + tgt_conn.rollback() + finally: + src_conn.close() + tgt_conn.close() + +if __name__ == "__main__": + migrate() diff --git a/prediction_service.py b/prediction_service.py index 7212d0b..3c95c9a 100644 --- a/prediction_service.py +++ b/prediction_service.py @@ -1,97 +1,97 @@ -import numpy as np -from datetime import datetime - -class SOIPredictionService: - """학습형 시계열 예측 및 피처 추출 엔진""" - - @staticmethod - def get_historical_soi(cursor, project_id): - """DB에서 프로젝트의 과거 SOI 히스토리를 시퀀스로 추출""" - cursor.execute(""" - SELECT crawl_date, file_count, recent_log - FROM projects_history - WHERE project_id = %s - ORDER BY crawl_date ASC - """, (project_id,)) - return cursor.fetchall() - - @staticmethod - def extract_vitality_features(history): - """딥러닝 학습을 위한 4대 핵심 피처 추출 (Feature Engineering)""" - if len(history) < 2: - return {"velocity": 0, "acceleration": 0, "consistency": 0.5, "density": 0.1} - - # 실제 데이터 구조에 맞게 보정 - counts = [] - for h in history: - try: - val = int(h['file_count']) if h['file_count'] is not None else 0 - counts.append(val) - except: - counts.append(0) - - # 1. 활동 속도 (Velocity) - velocity = np.diff(counts).mean() if len(counts) > 1 else 0 - - # 2. 활동 가속도 (Acceleration): 최근 활동이 빨라지는지 느려지는지 - acceleration = np.diff(np.diff(counts)).mean() if len(counts) > 2 else 0 - - # 3. 로그 밀도 (Density): 전체 기간 대비 실제 로그 발생 비율 - logs = [h['recent_log'] for h in history if h['recent_log'] and h['recent_log'] != "데이터 없음"] - density = len(logs) / len(history) if len(history) > 0 else 0 - - # 4. 관리 일관성 (Consistency): 업데이트 간격의 표준편차 (낮을수록 좋음) - # (현재 데이터는 일일 크롤링이므로 로그 텍스트 변화 시점을 기준으로 간격 계산 가능) - - return { - "velocity": float(velocity), - "acceleration": float(acceleration), - "density": float(density), - "sample_count": len(history) - } - - @staticmethod - def predict_future_soi(current_soi, history, days_ahead=14): - """기존 점수와 시계열 피처를 결합하여 미래 점수 예측""" - # 데이터가 너무 적으면 무조건 보수적 감쇄 (14일 기준 약 -2.1점) - if not history or len(history) < 3: - return round(max(0, min(100, current_soi - (0.15 * days_ahead))), 1) - - features = SOIPredictionService.extract_vitality_features(history) - current_val = float(current_soi) - - # [정밀 정체 분석] - # 1. 파일 수 변화 확인 (최근 5개 샘플) - recent_counts = [int(h['file_count'] or 0) for h in history[-5:]] - is_hard_stagnant = len(set(recent_counts)) <= 1 # 파일 수 변동이 전혀 없음 - - # 2. 최근 로그 상태 확인 - last_log = history[-1]['recent_log'] - is_no_activity = last_log is None or last_log == "데이터 없음" or "폴더자동삭제" in last_log - - # [모멘텀 산출 로직 개편] - if is_hard_stagnant: - # 파일 변화가 없다면 아무리 로그가 있어도 '유지 관리'일 뿐 '성장'이 아님 - # 오히려 시간이 갈수록 기술 부채와 데이터 노후화가 진행된다고 판단 (강력 패널티) - momentum_factor = -2.5 if is_no_activity else -1.0 - else: - # 실질적인 파일 수 변화(Velocity)가 있을 때만 긍정적 모멘텀 검토 - v_gain = features['velocity'] * 0.5 - d_gain = features['density'] * 0.8 - momentum_factor = v_gain + d_gain - 0.5 # 기본적으로 하향 압력 부여 - - # 예측 로직: 현재값 + 모멘텀 - (시간에 따른 자연 부식) - # 정체 시 momentum_factor가 -1.0~-2.5이므로 감쇄가 매우 빠름 - decay_constant = 0.08 - predicted = current_val + momentum_factor - (decay_constant * days_ahead) - - # [최종 방어 로직] - # 실질적 파일 증가(velocity > 0)가 포착되지 않았다면 예보는 현재값보다 클 수 없음 - if features['velocity'] <= 0 and predicted > current_val: - predicted = current_val - 1.5 # 강제 하락 - - # 사망 선고 (AVI가 이미 낮고 정체 중이면 0에 수렴하도록 가속) - if current_val < 20 and is_hard_stagnant: - predicted = max(0, predicted - 5.0) - - return round(max(0, min(100, predicted)), 1) +import numpy as np +from datetime import datetime + +class SOIPredictionService: + """학습형 시계열 예측 및 피처 추출 엔진""" + + @staticmethod + def get_historical_avi(cursor, project_id): + """DB에서 프로젝트의 과거 AVI 히스토리를 시퀀스로 추출""" + cursor.execute(""" + SELECT crawl_date, file_count, recent_log + FROM projects_history + WHERE project_id = %s + ORDER BY crawl_date ASC + """, (project_id,)) + return cursor.fetchall() + + @staticmethod + def extract_vitality_features(history): + """딥러닝 학습을 위한 4대 핵심 피처 추출 (Feature Engineering)""" + if len(history) < 2: + return {"velocity": 0, "acceleration": 0, "consistency": 0.5, "density": 0.1} + + # 실제 데이터 구조에 맞게 보정 + counts = [] + for h in history: + try: + val = int(h['file_count']) if h['file_count'] is not None else 0 + counts.append(val) + except: + counts.append(0) + + # 1. 활동 속도 (Velocity) + velocity = np.diff(counts).mean() if len(counts) > 1 else 0 + + # 2. 활동 가속도 (Acceleration): 최근 활동이 빨라지는지 느려지는지 + acceleration = np.diff(np.diff(counts)).mean() if len(counts) > 2 else 0 + + # 3. 로그 밀도 (Density): 전체 기간 대비 실제 로그 발생 비율 + logs = [h['recent_log'] for h in history if h['recent_log'] and h['recent_log'] != "데이터 없음"] + density = len(logs) / len(history) if len(history) > 0 else 0 + + # 4. 관리 일관성 (Consistency): 업데이트 간격의 표준편차 (낮을수록 좋음) + # (현재 데이터는 일일 크롤링이므로 로그 텍스트 변화 시점을 기준으로 간격 계산 가능) + + return { + "velocity": float(velocity), + "acceleration": float(acceleration), + "density": float(density), + "sample_count": len(history) + } + + @staticmethod + def predict_future_avi(current_avi, history, days_ahead=14): + """기존 점수와 시계열 피처를 결합하여 미래 점수 예측""" + # 데이터가 너무 적으면 무조건 보수적 감쇄 (14일 기준 약 -2.1점) + if not history or len(history) < 3: + return round(max(0, min(100, current_avi - (0.15 * days_ahead))), 1) + + features = SOIPredictionService.extract_vitality_features(history) + current_val = float(current_avi) + + # [정밀 정체 분석] + # 1. 파일 수 변화 확인 (최근 5개 샘플) + recent_counts = [int(h['file_count'] or 0) for h in history[-5:]] + is_hard_stagnant = len(set(recent_counts)) <= 1 # 파일 수 변동이 전혀 없음 + + # 2. 최근 로그 상태 확인 + last_log = history[-1]['recent_log'] + is_no_activity = last_log is None or last_log == "데이터 없음" or "폴더자동삭제" in last_log + + # [모멘텀 산출 로직 개편] + if is_hard_stagnant: + # 파일 변화가 없다면 아무리 로그가 있어도 '유지 관리'일 뿐 '성장'이 아님 + # 오히려 시간이 갈수록 기술 부채와 데이터 노후화가 진행된다고 판단 (강력 패널티) + momentum_factor = -2.5 if is_no_activity else -1.0 + else: + # 실질적인 파일 수 변화(Velocity)가 있을 때만 긍정적 모멘텀 검토 + v_gain = features['velocity'] * 0.5 + d_gain = features['density'] * 0.8 + momentum_factor = v_gain + d_gain - 0.5 # 기본적으로 하향 압력 부여 + + # 예측 로직: 현재값 + 모멘텀 - (시간에 따른 자연 부식) + # 정체 시 momentum_factor가 -1.0~-2.5이므로 감쇄가 매우 빠름 + decay_constant = 0.08 + predicted = current_val + momentum_factor - (decay_constant * days_ahead) + + # [최종 방어 로직] + # 실질적 파일 증가(velocity > 0)가 포착되지 않았다면 예보는 현재값보다 클 수 없음 + if features['velocity'] <= 0 and predicted > current_val: + predicted = current_val - 1.5 # 강제 하락 + + # 사망 선고 (AVI가 이미 낮고 정체 중이면 0에 수렴하도록 가속) + if current_val < 20 and is_hard_stagnant: + predicted = max(0, predicted - 5.0) + + return round(max(0, min(100, predicted)), 1) diff --git a/project_service.py b/project_service.py index 6ad3702..7ec2f75 100644 --- a/project_service.py +++ b/project_service.py @@ -1,33 +1,33 @@ -from sql_queries import DashboardQueries - -class ProjectService: - @staticmethod - def get_available_dates_logic(cursor): - cursor.execute(DashboardQueries.GET_AVAILABLE_DATES) - rows = cursor.fetchall() - return [row['crawl_date'].strftime("%Y.%m.%d") for row in rows if row['crawl_date']] - - @staticmethod - def get_project_data_logic(cursor, date_str): - target_date = date_str.replace(".", "-") if date_str and date_str != "-" else None - - if not target_date: - cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE) - res = cursor.fetchone() - target_date = res['last_date'] - - if not target_date: - return {"projects": []} - - cursor.execute(DashboardQueries.GET_PROJECT_LIST, (target_date,)) - rows = cursor.fetchall() - - projects = [] - for r in rows: - name = r['short_nm'] if r['short_nm'] and r['short_nm'].strip() else r['project_nm'] - projects.append([ - name, r['department'], r['master'], - r['recent_log'], r['file_count'], - r['continent'], r['country'] - ]) - return {"projects": projects} +from sql_queries import DashboardQueries + +class ProjectService: + @staticmethod + def get_available_dates_logic(cursor): + cursor.execute(DashboardQueries.GET_AVAILABLE_DATES) + rows = cursor.fetchall() + return [row['crawl_date'].strftime("%Y.%m.%d") for row in rows if row['crawl_date']] + + @staticmethod + def get_project_data_logic(cursor, date_str): + target_date = date_str.replace(".", "-") if date_str and date_str != "-" else None + + if not target_date: + cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE) + res = cursor.fetchone() + target_date = res['last_date'] + + if not target_date: + return {"projects": []} + + cursor.execute(DashboardQueries.GET_PROJECT_LIST, (target_date,)) + rows = cursor.fetchall() + + projects = [] + for r in rows: + name = r['short_nm'] if r['short_nm'] and r['short_nm'].strip() else r['project_nm'] + projects.append([ + name, r['department'], r['master'], + r['recent_log'], r['file_count'], + r['continent'], r['country'] + ]) + return {"projects": projects} diff --git a/requirements.txt b/requirements.txt index 71c0078..70c64e2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,12 @@ -fastapi==0.110.0 -uvicorn==0.29.0 -playwright==1.42.0 -python-dotenv==1.0.1 -pypdf==4.1.0 \ No newline at end of file +fastapi==0.110.0 +uvicorn==0.29.0 +playwright==1.42.0 +python-dotenv==1.0.1 +pypdf==4.1.0 +pymysql +jinja2 +pytesseract +pdf2image +numpy +pandas +openpyxl \ No newline at end of file diff --git a/schemas.py b/schemas.py index 790822f..b14c0a6 100644 --- a/schemas.py +++ b/schemas.py @@ -1,10 +1,10 @@ -from pydantic import BaseModel - -class AuthRequest(BaseModel): - user_id: str - password: str - -class InquiryReplyRequest(BaseModel): - reply: str - status: str - handler: str +from pydantic import BaseModel + +class AuthRequest(BaseModel): + user_id: str + password: str + +class InquiryReplyRequest(BaseModel): + reply: str + status: str + handler: str diff --git a/server.py b/server.py index 11b04ea..2623928 100644 --- a/server.py +++ b/server.py @@ -1,182 +1,187 @@ -import os -import sys -import asyncio -import pymysql -from fastapi import FastAPI, Request -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import StreamingResponse, FileResponse -from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates - -from analyze import analyze_file_content -from crawler_service import run_crawler_service, crawl_stop_event -from schemas import AuthRequest, InquiryReplyRequest -from inquiry_service import InquiryService -from project_service import ProjectService -from analysis_service import AnalysisService - -# --- 환경 설정 --- -os.environ["PYTHONIOENCODING"] = "utf-8" -TESSDATA_PREFIX = os.getenv("TESSDATA_PREFIX", r"C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tessdata") -os.environ["TESSDATA_PREFIX"] = TESSDATA_PREFIX - -app = FastAPI(title="Project Master Overseas API") -templates = Jinja2Templates(directory="templates") - -# 정적 파일 마운트 -app.mount("/style", StaticFiles(directory="style"), name="style") -app.mount("/js", StaticFiles(directory="js"), name="js") -app.mount("/sample_files", StaticFiles(directory="sample"), name="sample_files") - -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=False, - allow_methods=["*"], - allow_headers=["*"], -) - -# --- 유틸리티 함수 --- -def get_db_connection(): - """MySQL 데이터베이스 연결을 반환""" - return pymysql.connect( - host=os.getenv('DB_HOST', 'localhost'), - user=os.getenv('DB_USER', 'root'), - password=os.getenv('DB_PASSWORD', '45278434'), - database=os.getenv('DB_NAME', 'PM_proto'), - charset='utf8mb4', - cursorclass=pymysql.cursors.DictCursor - ) - -async def run_in_threadpool(func, *args): - """동기 함수를 비차단 방식으로 실행""" - loop = asyncio.get_event_loop() - return await loop.run_in_executor(None, func, *args) - -# --- HTML 라우팅 --- -@app.get("/") -async def root(request: Request): - return templates.TemplateResponse("index.html", {"request": request}) - -@app.get("/dashboard") -async def get_dashboard(request: Request): - return templates.TemplateResponse("dashboard.html", {"request": request}) - -@app.get("/mailTest") -async def get_mail_test(request: Request): - return templates.TemplateResponse("mailTest.html", {"request": request}) - -@app.get("/inquiries") -async def get_inquiries_page(request: Request): - return templates.TemplateResponse("inquiries.html", {"request": request}) - -@app.get("/analysis") -async def get_analysis_page(request: Request): - return templates.TemplateResponse("analysis.html", {"request": request}) - -# --- 문의사항 API --- -@app.get("/api/inquiries") -async def get_inquiries(pm_type: str = None, category: str = None, status: str = None, keyword: str = None): - try: - with get_db_connection() as conn: - with conn.cursor() as cursor: - return InquiryService.get_inquiries_logic(cursor, pm_type, category, status, keyword) - except Exception as e: - return {"error": str(e)} - -@app.get("/api/inquiries/{id}") -async def get_inquiry_detail(id: int): - try: - with get_db_connection() as conn: - with conn.cursor() as cursor: - return InquiryService.get_inquiry_detail_logic(cursor, id) - except Exception as e: - return {"error": str(e)} - -@app.post("/api/inquiries/{id}/reply") -async def update_inquiry_reply(id: int, req: InquiryReplyRequest): - try: - with get_db_connection() as conn: - with conn.cursor() as cursor: - return InquiryService.update_inquiry_reply_logic(cursor, conn, id, req) - except Exception as e: - return {"error": str(e)} - -@app.delete("/api/inquiries/{id}/reply") -async def delete_inquiry_reply(id: int): - try: - with get_db_connection() as conn: - with conn.cursor() as cursor: - return InquiryService.delete_inquiry_reply_logic(cursor, conn, id) - except Exception as e: - return {"error": str(e)} - -# --- 프로젝트 및 히스토리 API --- -@app.get("/available-dates") -async def get_available_dates(): - try: - with get_db_connection() as conn: - with conn.cursor() as cursor: - return ProjectService.get_available_dates_logic(cursor) - except Exception as e: - return {"error": str(e)} - -@app.get("/project-data") -async def get_project_data(date: str = None): - try: - with get_db_connection() as conn: - with conn.cursor() as cursor: - return ProjectService.get_project_data_logic(cursor, date) - except Exception as e: - return {"error": str(e)} - -# --- 분석 API (AnalysisService 연동) --- -@app.get("/project-activity") -async def get_project_activity(date: str = None): - try: - with get_db_connection() as conn: - with conn.cursor() as cursor: - return AnalysisService.get_project_activity_logic(cursor, date) - except Exception as e: - return {"error": str(e)} - -@app.get("/api/analysis/p-war") -async def get_p_war_analysis(): - try: - with get_db_connection() as conn: - with conn.cursor() as cursor: - return AnalysisService.get_p_zsr_analysis_logic(cursor) - except Exception as e: - return {"error": str(e)} - -# --- 수집 및 동기화 API --- -@app.post("/auth/crawl") -async def auth_crawl(req: AuthRequest): - if req.user_id == os.getenv("PM_USER_ID") and req.password == os.getenv("PM_PASSWORD"): - return {"success": True} - return {"success": False, "message": "크롤링을 할 수 없습니다."} - -@app.get("/sync") -async def sync_data(): - return StreamingResponse(run_crawler_service(), media_type="text_event-stream") - -@app.get("/stop-sync") -async def stop_sync(): - crawl_stop_event.set() - return {"success": True} - -# --- 파일 및 첨부파일 API --- -@app.get("/attachments") -async def get_attachments(): - path = "sample" - if not os.path.exists(path): os.makedirs(path) - return [{"name": f, "size": f"{os.path.getsize(os.path.join(path, f))/1024:.1f} KB"} - for f in os.listdir(path) if os.path.isfile(os.path.join(path, f))] - -@app.get("/analyze-file") -async def analyze_file(filename: str): - return await run_in_threadpool(analyze_file_content, filename) - -@app.get("/sample.png") -async def get_sample_img(): - return FileResponse("sample.png") +import os +import sys +import asyncio +import pymysql +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import StreamingResponse, FileResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from analyze import analyze_file_content +from crawler_service import run_crawler_service, crawl_stop_event +from schemas import AuthRequest, InquiryReplyRequest +from inquiry_service import InquiryService +from project_service import ProjectService +from analysis_service import AnalysisService + +# --- 환경 설정 --- +os.environ["PYTHONIOENCODING"] = "utf-8" +if os.name == 'posix': # Linux (Docker) + DEFAULT_TESSDATA = "/usr/share/tesseract-ocr/4.00/tessdata" +else: # Windows + DEFAULT_TESSDATA = r"C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tessdata" + +TESSDATA_PREFIX = os.getenv("TESSDATA_PREFIX", DEFAULT_TESSDATA) +os.environ["TESSDATA_PREFIX"] = TESSDATA_PREFIX + +app = FastAPI(title="Project Master Overseas API") +templates = Jinja2Templates(directory="templates") + +# 정적 파일 마운트 +app.mount("/style", StaticFiles(directory="style"), name="style") +app.mount("/js", StaticFiles(directory="js"), name="js") +app.mount("/sample_files", StaticFiles(directory="sample"), name="sample_files") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=False, + allow_methods=["*"], + allow_headers=["*"], +) + +# --- 유틸리티 함수 --- +def get_db_connection(): + """MySQL 데이터베이스 연결을 반환""" + return pymysql.connect( + host=os.getenv('DB_HOST', 'localhost'), + user=os.getenv('DB_USER', 'root'), + password=os.getenv('DB_PASSWORD', '45278434'), + database=os.getenv('DB_NAME', 'PM_proto'), + charset='utf8mb4', + cursorclass=pymysql.cursors.DictCursor + ) + +async def run_in_threadpool(func, *args): + """동기 함수를 비차단 방식으로 실행""" + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, func, *args) + +# --- HTML 라우팅 --- +@app.get("/") +async def root(request: Request): + return templates.TemplateResponse("index.html", {"request": request}) + +@app.get("/dashboard") +async def get_dashboard(request: Request): + return templates.TemplateResponse("dashboard.html", {"request": request}) + +@app.get("/mailTest") +async def get_mail_test(request: Request): + return templates.TemplateResponse("mailTest.html", {"request": request}) + +@app.get("/inquiries") +async def get_inquiries_page(request: Request): + return templates.TemplateResponse("inquiries.html", {"request": request}) + +@app.get("/analysis") +async def get_analysis_page(request: Request): + return templates.TemplateResponse("analysis.html", {"request": request}) + +# --- 문의사항 API --- +@app.get("/api/inquiries") +async def get_inquiries(pm_type: str = None, category: str = None, status: str = None, keyword: str = None): + try: + with get_db_connection() as conn: + with conn.cursor() as cursor: + return InquiryService.get_inquiries_logic(cursor, pm_type, category, status, keyword) + except Exception as e: + return {"error": str(e)} + +@app.get("/api/inquiries/{id}") +async def get_inquiry_detail(id: int): + try: + with get_db_connection() as conn: + with conn.cursor() as cursor: + return InquiryService.get_inquiry_detail_logic(cursor, id) + except Exception as e: + return {"error": str(e)} + +@app.post("/api/inquiries/{id}/reply") +async def update_inquiry_reply(id: int, req: InquiryReplyRequest): + try: + with get_db_connection() as conn: + with conn.cursor() as cursor: + return InquiryService.update_inquiry_reply_logic(cursor, conn, id, req) + except Exception as e: + return {"error": str(e)} + +@app.delete("/api/inquiries/{id}/reply") +async def delete_inquiry_reply(id: int): + try: + with get_db_connection() as conn: + with conn.cursor() as cursor: + return InquiryService.delete_inquiry_reply_logic(cursor, conn, id) + except Exception as e: + return {"error": str(e)} + +# --- 프로젝트 및 히스토리 API --- +@app.get("/available-dates") +async def get_available_dates(): + try: + with get_db_connection() as conn: + with conn.cursor() as cursor: + return ProjectService.get_available_dates_logic(cursor) + except Exception as e: + return {"error": str(e)} + +@app.get("/project-data") +async def get_project_data(date: str = None): + try: + with get_db_connection() as conn: + with conn.cursor() as cursor: + return ProjectService.get_project_data_logic(cursor, date) + except Exception as e: + return {"error": str(e)} + +# --- 분석 API (AnalysisService 연동) --- +@app.get("/project-activity") +async def get_project_activity(date: str = None): + try: + with get_db_connection() as conn: + with conn.cursor() as cursor: + return AnalysisService.get_project_activity_logic(cursor, date) + except Exception as e: + return {"error": str(e)} + +@app.get("/api/analysis/p-war") +async def get_p_war_analysis(): + try: + with get_db_connection() as conn: + with conn.cursor() as cursor: + return AnalysisService.get_p_zsr_analysis_logic(cursor) + except Exception as e: + return {"error": str(e)} + +# --- 수집 및 동기화 API --- +@app.post("/auth/crawl") +async def auth_crawl(req: AuthRequest): + if req.user_id == os.getenv("PM_USER_ID") and req.password == os.getenv("PM_PASSWORD"): + return {"success": True} + return {"success": False, "message": "크롤링을 할 수 없습니다."} + +@app.get("/sync") +async def sync_data(): + return StreamingResponse(run_crawler_service(), media_type="text_event-stream") + +@app.get("/stop-sync") +async def stop_sync(): + crawl_stop_event.set() + return {"success": True} + +# --- 파일 및 첨부파일 API --- +@app.get("/attachments") +async def get_attachments(): + path = "sample" + if not os.path.exists(path): os.makedirs(path) + return [{"name": f, "size": f"{os.path.getsize(os.path.join(path, f))/1024:.1f} KB"} + for f in os.listdir(path) if os.path.isfile(os.path.join(path, f))] + +@app.get("/analyze-file") +async def analyze_file(filename: str): + return await run_in_threadpool(analyze_file_content, filename) + +@app.get("/sample.png") +async def get_sample_img(): + return FileResponse("sample.png") diff --git a/server_test.py b/server_test.py new file mode 100644 index 0000000..bc0d762 --- /dev/null +++ b/server_test.py @@ -0,0 +1,190 @@ +import os +import sys +import asyncio +import pymysql +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import StreamingResponse, FileResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from analyze import analyze_file_content +from crawler_service_test import run_crawler_service, crawl_stop_event +from schemas import AuthRequest, InquiryReplyRequest +from inquiry_service import InquiryService +from project_service import ProjectService +from analysis_service import AnalysisService + +# --- 환경 설정 --- +os.environ["PYTHONIOENCODING"] = "utf-8" +TESSDATA_PREFIX = os.getenv("TESSDATA_PREFIX", r"C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tessdata") +os.environ["TESSDATA_PREFIX"] = TESSDATA_PREFIX + +app = FastAPI(title="Project Master Overseas API (TEST MODE)") +templates = Jinja2Templates(directory="templates") + +# 정적 파일 마운트 +app.mount("/style", StaticFiles(directory="style"), name="style") +app.mount("/js", StaticFiles(directory="js"), name="js") +app.mount("/sample_files", StaticFiles(directory="sample"), name="sample_files") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=False, + allow_methods=["*"], + allow_headers=["*"], +) + +# --- 유틸리티 함수 --- +def get_db_connection(): + """MySQL 데이터베이스(TEST) 연결을 반환""" + return pymysql.connect( + host=os.getenv('DB_HOST', 'localhost'), + user=os.getenv('DB_USER', 'root'), + password=os.getenv('DB_PASSWORD', '45278434'), + database='PM_proto_test', # 테스트용 DB로 고정 + charset='utf8mb4', + cursorclass=pymysql.cursors.DictCursor + ) + +async def run_in_threadpool(func, *args): + """동기 함수를 비차단 방식으로 실행""" + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, func, *args) + +# --- HTML 라우팅 --- +@app.get("/") +async def root(request: Request): + return templates.TemplateResponse("index.html", {"request": request}) + +@app.get("/dashboard") +@app.get("/dashboard_test") +async def get_dashboard_test(request: Request): + # 테스트 환경에서는 dashboard_test.html을 우선적으로 반환 + return templates.TemplateResponse("dashboard_test.html", {"request": request}) + +@app.get("/mailTest") +async def get_mail_test(request: Request): + return templates.TemplateResponse("mailTest.html", {"request": request}) + +@app.get("/inquiries") +async def get_inquiries_page(request: Request): + return templates.TemplateResponse("inquiries.html", {"request": request}) + +@app.get("/analysis") +@app.get("/analysis_test") +async def get_analysis_test(request: Request): + # 테스트 환경에서는 analysis_test.html을 우선적으로 반환 + return templates.TemplateResponse("analysis_test.html", {"request": request}) + +# --- 문의사항 API --- +@app.get("/api/inquiries") +async def get_inquiries(pm_type: str = None, category: str = None, status: str = None, keyword: str = None): + try: + with get_db_connection() as conn: + with conn.cursor() as cursor: + return InquiryService.get_inquiries_logic(cursor, pm_type, category, status, keyword) + except Exception as e: + return {"error": str(e)} + +@app.get("/api/inquiries/{id}") +async def get_inquiry_detail(id: int): + try: + with get_db_connection() as conn: + with conn.cursor() as cursor: + return InquiryService.get_inquiry_detail_logic(cursor, id) + except Exception as e: + return {"error": str(e)} + +@app.post("/api/inquiries/{id}/reply") +async def update_inquiry_reply(id: int, req: InquiryReplyRequest): + try: + with get_db_connection() as conn: + with conn.cursor() as cursor: + return InquiryService.update_inquiry_reply_logic(cursor, conn, id, req) + except Exception as e: + return {"error": str(e)} + +@app.delete("/api/inquiries/{id}/reply") +async def delete_inquiry_reply(id: int): + try: + with get_db_connection() as conn: + with conn.cursor() as cursor: + return InquiryService.delete_inquiry_reply_logic(cursor, conn, id) + except Exception as e: + return {"error": str(e)} + +# --- 프로젝트 및 히스토리 API --- +@app.get("/available-dates") +async def get_available_dates(): + try: + with get_db_connection() as conn: + with conn.cursor() as cursor: + return ProjectService.get_available_dates_logic(cursor) + except Exception as e: + return {"error": str(e)} + +@app.get("/project-data") +async def get_project_data(date: str = None): + try: + with get_db_connection() as conn: + with conn.cursor() as cursor: + return ProjectService.get_project_data_logic(cursor, date) + except Exception as e: + return {"error": str(e)} + +# --- 분석 API (AnalysisService 연동) --- +@app.get("/project-activity") +async def get_project_activity(date: str = None): + try: + with get_db_connection() as conn: + with conn.cursor() as cursor: + return AnalysisService.get_project_activity_logic(cursor, date) + except Exception as e: + return {"error": str(e)} + +@app.get("/api/analysis/p-war") +async def get_p_war_analysis(): + try: + with get_db_connection() as conn: + with conn.cursor() as cursor: + return AnalysisService.get_p_zsr_analysis_logic(cursor) + except Exception as e: + return {"error": str(e)} + +# --- 수집 및 동기화 API --- +@app.post("/auth/crawl") +async def auth_crawl(req: AuthRequest): + if req.user_id == os.getenv("PM_USER_ID") and req.password == os.getenv("PM_PASSWORD"): + return {"success": True} + return {"success": False, "message": "크롤링을 할 수 없습니다."} + +@app.get("/sync") +async def sync_data(): + return StreamingResponse(run_crawler_service(), media_type="text_event-stream") + +@app.get("/stop-sync") +async def stop_sync(): + crawl_stop_event.set() + return {"success": True} + +# --- 파일 및 첨부파일 API --- +@app.get("/attachments") +async def get_attachments(): + path = "sample" + if not os.path.exists(path): os.makedirs(path) + return [{"name": f, "size": f"{os.path.getsize(os.path.join(path, f))/1024:.1f} KB"} + for f in os.listdir(path) if os.path.isfile(os.path.join(path, f))] + +@app.get("/analyze-file") +async def analyze_file(filename: str): + return await run_in_threadpool(analyze_file_content, filename) + +@app.get("/sample.png") +async def get_sample_img(): + return FileResponse("sample.png") + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8001) diff --git a/sql_queries.py b/sql_queries.py index fd61b1f..b74c0d1 100644 --- a/sql_queries.py +++ b/sql_queries.py @@ -1,67 +1,82 @@ -class InquiryQueries: - """문의사항(Inquiries) 페이지 관련 쿼리""" - # 필터링을 위한 기본 쿼리 (WHERE 1=1 포함) - SELECT_BASE = "SELECT * FROM inquiries WHERE 1=1" - ORDER_BY_DESC = "ORDER BY no DESC" - - # 상세 조회 - SELECT_BY_ID = "SELECT * FROM inquiries WHERE id = %s" - - # 답변 업데이트 (handled_date 포함) - UPDATE_REPLY = """ - UPDATE inquiries - SET reply = %s, status = %s, handler = %s, handled_date = %s - WHERE id = %s - """ - - # 답변 삭제 (초기화) - DELETE_REPLY = """ - UPDATE inquiries - SET reply = '', status = '미확인', handled_date = '' - WHERE id = %s - """ - -class DashboardQueries: - """대시보드(Dashboard) 및 프로젝트 현황 관련 쿼리""" - # 가용 날짜 목록 조회 - GET_AVAILABLE_DATES = "SELECT DISTINCT crawl_date FROM projects_history ORDER BY crawl_date DESC" - - # 최신 수집 날짜 조회 - GET_LAST_CRAWL_DATE = "SELECT MAX(crawl_date) as last_date FROM projects_history" - - # 특정 날짜 프로젝트 데이터 JOIN 조회 - GET_PROJECT_LIST = """ - SELECT m.project_nm, m.short_nm, m.department, m.master, - h.recent_log, h.file_count, m.continent, m.country - FROM projects_master m - LEFT JOIN projects_history h ON m.project_id = h.project_id AND h.crawl_date = %s - ORDER BY m.project_id ASC - """ - - # 활성도 분석을 위한 프로젝트 목록 조회 - GET_PROJECT_LIST_FOR_ANALYSIS = """ - SELECT m.project_id, m.project_nm, m.short_nm, m.department, h.recent_log, h.file_count - FROM projects_master m - LEFT JOIN projects_history h ON m.project_id = h.project_id AND h.crawl_date = %s - """ - -class CrawlerQueries: - """크롤러(Crawler) 데이터 동기화 관련 쿼리""" - # 마스터 정보 UPSERT (INSERT OR UPDATE) - UPSERT_MASTER = """ - INSERT INTO projects_master (project_id, project_nm, short_nm, master, continent, country) - VALUES (%s, %s, %s, %s, %s, %s) - ON DUPLICATE KEY UPDATE - project_nm = VALUES(project_nm), short_nm = VALUES(short_nm), - master = VALUES(master), continent = VALUES(continent), country = VALUES(country) - """ - - # 부서 정보 업데이트 - UPDATE_DEPARTMENT = "UPDATE projects_master SET department = %s WHERE project_id = %s" - - # 히스토리(로그/파일수) 저장 - UPSERT_HISTORY = """ - INSERT INTO projects_history (project_id, crawl_date, recent_log, file_count) - VALUES (%s, CURRENT_DATE(), %s, %s) - ON DUPLICATE KEY UPDATE recent_log=VALUES(recent_log), file_count=VALUES(file_count) - """ +class InquiryQueries: + """문의사항(Inquiries) 페이지 관련 쿼리""" + # 필터링을 위한 기본 쿼리 (WHERE 1=1 포함) + SELECT_BASE = "SELECT * FROM inquiries WHERE 1=1" + ORDER_BY_DESC = "ORDER BY no DESC" + + # 상세 조회 + SELECT_BY_ID = "SELECT * FROM inquiries WHERE id = %s" + + # 답변 업데이트 (handled_date 포함) + UPDATE_REPLY = """ + UPDATE inquiries + SET reply = %s, status = %s, handler = %s, handled_date = %s + WHERE id = %s + """ + + # 답변 삭제 (초기화) + DELETE_REPLY = """ + UPDATE inquiries + SET reply = '', status = '미확인', handled_date = '' + WHERE id = %s + """ + +class DashboardQueries: + """대시보드(Dashboard) 및 프로젝트 현황 관련 쿼리""" + # 가용 날짜 목록 조회 + GET_AVAILABLE_DATES = "SELECT DISTINCT crawl_date FROM projects_history ORDER BY crawl_date DESC" + + # 최신 수집 날짜 조회 + GET_LAST_CRAWL_DATE = "SELECT MAX(crawl_date) as last_date FROM projects_history" + + # 특정 날짜(또는 그 이하 최신) 프로젝트 데이터 JOIN 조회 + GET_PROJECT_LIST = """ + SELECT m.project_nm, m.short_nm, m.department, m.master, + h.recent_log, h.file_count, m.continent, m.country + FROM projects_master m + LEFT JOIN projects_history h ON h.project_id = m.project_id AND h.crawl_date = ( + SELECT MAX(crawl_date) + FROM projects_history + WHERE project_id = m.project_id AND crawl_date <= %s + ) + ORDER BY m.project_id ASC + """ + + # 활성도 분석을 위한 프로젝트 목록 조회 (특정 날짜 이하 최신 데이터 기준) + GET_PROJECT_LIST_FOR_ANALYSIS = """ + SELECT m.project_id, m.project_nm, m.short_nm, m.department, h.recent_log, h.file_count + FROM projects_master m + LEFT JOIN projects_history h ON h.project_id = m.project_id AND h.crawl_date = ( + SELECT MAX(crawl_date) + FROM projects_history + WHERE project_id = m.project_id AND crawl_date <= %s + ) + """ + +class CrawlerQueries: + """크롤러(Crawler) 데이터 동기화 관련 쿼리""" + # 마스터 정보 UPSERT (INSERT OR UPDATE) + UPSERT_MASTER = """ + INSERT INTO projects_master (project_id, project_nm, short_nm, master, continent, country) + VALUES (%s, %s, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + project_nm = VALUES(project_nm), short_nm = VALUES(short_nm), + master = VALUES(master), continent = VALUES(continent), country = VALUES(country) + """ + + # 부서 정보 업데이트 + UPDATE_DEPARTMENT = "UPDATE projects_master SET department = %s WHERE project_id = %s" + + # 히스토리(로그/파일수) 저장 (날짜 지정형) + UPSERT_HISTORY_WITH_DATE = """ + INSERT INTO projects_history (project_id, crawl_date, recent_log, file_count) + VALUES (%s, %s, %s, %s) + ON DUPLICATE KEY UPDATE recent_log=VALUES(recent_log), file_count=VALUES(file_count) + """ + + # 히스토리(로그/파일수) 저장 (기본형 - 오늘 날짜) + UPSERT_HISTORY = """ + INSERT INTO projects_history (project_id, crawl_date, recent_log, file_count) + VALUES (%s, CURDATE(), %s, %s) + ON DUPLICATE KEY UPDATE recent_log=VALUES(recent_log), file_count=VALUES(file_count) + """ diff --git a/style/analysis.css b/style/analysis.css index e9d9690..e8e28a1 100644 --- a/style/analysis.css +++ b/style/analysis.css @@ -1,233 +1,233 @@ -/* ========================================================================== - Project Master Analysis - Specific Styles - (Inherits base styles from common.css) - ========================================================================== */ - -.analysis-content { - padding: 24px; - max-width: 1400px; - margin: var(--topbar-h, 36px) auto 0; -} - -/* AI Badge & Header */ -.ai-badge { - background: #6366f1; - color: white; - padding: 2px 10px; - border-radius: 20px; - font-size: 11px; - font-weight: 800; - display: inline-block; - margin-bottom: 8px; -} - -.analysis-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 24px; - padding: 10px 0; - border-bottom: 1px solid var(--border-color); -} - -.analysis-header h2 { font-size: 22px; font-weight: 800; color: var(--text-main); margin-bottom: 4px; } -.analysis-header p { font-size: 13px; color: var(--text-sub); } - -/* Top Info Grid */ -.top-info-grid { - display: grid; - grid-template-columns: 1fr 2.2fr; - gap: 16px; - margin-bottom: 24px; -} - -.dl-model-info, .soi-deep-dive { - background: white; - border-radius: var(--radius-xl); - border: 1px solid var(--border-color); - padding: 20px; - box-shadow: var(--box-shadow); -} - -.card-header { margin-bottom: 15px; display: flex; align-items: center; justify-content: space-between; } -.card-header h4 { font-size: 14px; font-weight: 800; color: var(--primary-color); margin: 0; } - -.model-desc-vertical { display: flex; flex-direction: column; gap: 12px; } -.model-item-vertical { display: flex; align-items: center; gap: 12px; } -.model-tag { background: var(--bg-muted); color: var(--text-sub); padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 700; } - -.soi-info-columns { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; } -.soi-info-column h6 { font-size: 12px; font-weight: 800; color: var(--primary-color); margin: 0 0 8px 0; } -.soi-info-column p { font-size: 11.5px; color: var(--text-sub); line-height: 1.6; margin: 0; } - -/* Chart Grid Layout */ -.analysis-charts-grid { - display: grid; - grid-template-columns: 1fr 1.8fr; - gap: 20px; - margin-bottom: 24px; -} - -.chart-container-box { - background: white; - border-radius: var(--radius-xl); - padding: 20px; - border: 1px solid var(--border-color); - height: 360px; - display: flex; - flex-direction: column; - box-shadow: var(--box-shadow); -} - -.chart-container-box h5 { margin: 0 0 15px 0; font-size: 13px; font-weight: 700; color: var(--text-main); } - -/* Timeline Analysis Card */ -.analysis-card { - background: white; - border-radius: var(--radius-xl); - border: 1px solid var(--border-color); - box-shadow: var(--box-shadow); - margin-bottom: 24px; - overflow: hidden; -} - -.analysis-card .card-header { - padding: 16px 24px; - background: #fff; - border-bottom: 1px solid var(--border-color); -} - -.analysis-card .card-body { padding: 24px; } - -/* SOI Guide Styles */ -.d-war-guide { - display: flex; - gap: 10px; - margin-bottom: 20px; - padding: 12px; - background: var(--bg-muted); - border-radius: var(--radius-lg); -} - -.guide-item { - font-size: 11px; - font-weight: 700; - padding: 4px 10px; - border-radius: 4px; - display: flex; - align-items: center; - gap: 6px; -} - -.guide-item.active-low { background: #dcfce7; color: #166534; } -.guide-item.warning-mid { background: #fef9c3; color: #854d0e; } -.guide-item.danger-high { background: #ffedd5; color: #9a3412; } -.guide-item.hazard-critical { background: #fee2e2; color: #991b1b; } - -/* Data Table Customization */ -.table-scroll-wrapper { - overflow-x: auto; - overflow-y: auto; - max-height: 600px; - border-radius: var(--radius-lg); - border: 1px solid var(--border-color); - background: white; -} - -.p-war-table { - width: 100%; - border-collapse: separate; - border-spacing: 0; - table-layout: fixed; /* 컬럼 너비 고정 */ -} - -.p-war-table th { - position: sticky; - top: 0; - z-index: 20; - background: #f8fafc; - padding: 16px 15px; - font-size: 12px; - font-weight: 800; - color: #475569; - border-bottom: 2px solid #e2e8f0; - white-space: nowrap; -} - -.p-war-table td { - padding: 14px 15px; - font-size: 13px; - border-bottom: 1px solid #f1f5f9; - vertical-align: middle; -} - -/* 컬럼별 너비 정의 */ -.p-war-table th:nth-child(1), .p-war-table td:nth-child(1) { width: 28%; text-align: left; } /* 프로젝트명 */ -.p-war-table th:nth-child(2), .p-war-table td:nth-child(2) { width: 10%; text-align: right; } /* 파일 수 */ -.p-war-table th:nth-child(3), .p-war-table td:nth-child(3) { width: 10%; text-align: right; } /* 방치일 */ -.p-war-table th:nth-child(4), .p-war-table td:nth-child(4) { width: 10%; text-align: center; } /* 상태 판정 */ -.p-war-table th:nth-child(5), .p-war-table td:nth-child(5) { width: 14%; text-align: right; } /* P-WAR+ */ -.p-war-table th:nth-child(6), .p-war-table td:nth-child(6) { width: 12%; text-align: right; } /* 현재 SOI */ -.p-war-table th:nth-child(7), .p-war-table td:nth-child(7) { width: 12%; text-align: center; } /* 실무 투입 */ -.p-war-table th:nth-child(8), .p-war-table td:nth-child(8) { width: 14%; text-align: center; } /* AI 예보 */ - -.project-row { cursor: pointer; transition: background 0.2s; } -.project-row:hover { background: var(--hover-bg) !important; } - -/* SOI Value Styling */ -.p-war-value { font-weight: 800; font-family: 'Consolas', monospace; } - -/* Accordion Detail Styles */ -.detail-row { display: none; background: #fafafa; } -.detail-row.active { display: table-row; } -.detail-container { padding: 20px 24px; } - -.formula-explanation-card { - background: white; - border-radius: var(--radius-lg); - padding: 24px; - border: 1px solid var(--border-color); - box-shadow: var(--box-shadow); -} - -.formula-header { font-size: 13px; font-weight: 700; color: #6366f1; margin-bottom: 15px; } - -/* Work Effort Section */ -.work-effort-section { background: var(--bg-muted); padding: 16px; border-radius: var(--radius-lg); margin-bottom: 20px; } -.work-effort-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } -.work-effort-bar-bg { width: 100%; height: 6px; background: #e2e8f0; border-radius: 3px; overflow: hidden; margin-bottom: 10px; } - -/* Formula Steps Grid */ -.formula-steps-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } -.formula-step { display: flex; gap: 12px; } -.step-num { background: var(--primary-color); color: white; width: 20px; height: 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 800; flex-shrink: 0; } -.step-title { font-size: 12px; font-weight: 700; color: var(--text-main); margin-bottom: 4px; } -.math-logic { font-family: 'Consolas', monospace; background: var(--bg-muted); padding: 4px 8px; border-radius: 4px; font-weight: 700; color: var(--text-main); font-size: 12px; display: inline-block; } - -.final-result-area { margin-top: 20px; padding-top: 15px; display: flex; justify-content: space-between; align-items: center; } - -/* Modal Analysis Specific */ -.modal-footer { - padding: 16px 24px; - background: #fff; - border-top: 1px solid var(--border-color); - text-align: right; - display: flex; - justify-content: flex-end; -} - -/* Formula & Badges */ -.formula-section { margin-bottom: 20px; } -.formula-box { background: var(--primary-lv-0); color: var(--primary-color); padding: 15px; border-radius: var(--radius-lg); font-weight: 800; text-align: center; font-family: monospace; font-size: 16px; } - -.badge-active { background: #dcfce7; color: #166534; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; } -.badge-warning { background: #fef9c3; color: #854d0e; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; } -.badge-danger { background: #ffedd5; color: #9a3412; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; } -.badge-system { background: #fee2e2; color: #991b1b; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; } - -.highlight-var { color: #2563eb; } -.highlight-val { color: #059669; } -.highlight-penalty { color: #dc2626; } -.text-plus { color: #059669; font-weight: 700; } -.text-minus { color: #dc2626; font-weight: 700; } -.font-bold { font-weight: 700; } +/* ========================================================================== + Project Master Analysis - Specific Styles + (Inherits base styles from common.css) + ========================================================================== */ + +.analysis-content { + padding: 24px; + max-width: 1400px; + margin: var(--topbar-h, 36px) auto 0; +} + +/* AI Badge & Header */ +.ai-badge { + background: #6366f1; + color: white; + padding: 2px 10px; + border-radius: 20px; + font-size: 11px; + font-weight: 800; + display: inline-block; + margin-bottom: 8px; +} + +.analysis-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + padding: 10px 0; + border-bottom: 1px solid var(--border-color); +} + +.analysis-header h2 { font-size: 22px; font-weight: 800; color: var(--text-main); margin-bottom: 4px; } +.analysis-header p { font-size: 13px; color: var(--text-sub); } + +/* Top Info Grid */ +.top-info-grid { + display: grid; + grid-template-columns: 1fr 2.2fr; + gap: 16px; + margin-bottom: 24px; +} + +.dl-model-info, .soi-deep-dive { + background: white; + border-radius: var(--radius-xl); + border: 1px solid var(--border-color); + padding: 20px; + box-shadow: var(--box-shadow); +} + +.card-header { margin-bottom: 15px; display: flex; align-items: center; justify-content: space-between; } +.card-header h4 { font-size: 14px; font-weight: 800; color: var(--primary-color); margin: 0; } + +.model-desc-vertical { display: flex; flex-direction: column; gap: 12px; } +.model-item-vertical { display: flex; align-items: center; gap: 12px; } +.model-tag { background: var(--bg-muted); color: var(--text-sub); padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 700; } + +.soi-info-columns { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; } +.soi-info-column h6 { font-size: 12px; font-weight: 800; color: var(--primary-color); margin: 0 0 8px 0; } +.soi-info-column p { font-size: 11.5px; color: var(--text-sub); line-height: 1.6; margin: 0; } + +/* Chart Grid Layout */ +.analysis-charts-grid { + display: grid; + grid-template-columns: 1fr 1.8fr; + gap: 20px; + margin-bottom: 24px; +} + +.chart-container-box { + background: white; + border-radius: var(--radius-xl); + padding: 20px; + border: 1px solid var(--border-color); + height: 360px; + display: flex; + flex-direction: column; + box-shadow: var(--box-shadow); +} + +.chart-container-box h5 { margin: 0 0 15px 0; font-size: 13px; font-weight: 700; color: var(--text-main); } + +/* Timeline Analysis Card */ +.analysis-card { + background: white; + border-radius: var(--radius-xl); + border: 1px solid var(--border-color); + box-shadow: var(--box-shadow); + margin-bottom: 24px; + overflow: hidden; +} + +.analysis-card .card-header { + padding: 16px 24px; + background: #fff; + border-bottom: 1px solid var(--border-color); +} + +.analysis-card .card-body { padding: 24px; } + +/* SOI Guide Styles */ +.d-war-guide { + display: flex; + gap: 10px; + margin-bottom: 20px; + padding: 12px; + background: var(--bg-muted); + border-radius: var(--radius-lg); +} + +.guide-item { + font-size: 11px; + font-weight: 700; + padding: 4px 10px; + border-radius: 4px; + display: flex; + align-items: center; + gap: 6px; +} + +.guide-item.active-low { background: #dcfce7; color: #166534; } +.guide-item.warning-mid { background: #fef9c3; color: #854d0e; } +.guide-item.danger-high { background: #ffedd5; color: #9a3412; } +.guide-item.hazard-critical { background: #fee2e2; color: #991b1b; } + +/* Data Table Customization */ +.table-scroll-wrapper { + overflow-x: auto; + overflow-y: auto; + max-height: 600px; + border-radius: var(--radius-lg); + border: 1px solid var(--border-color); + background: white; +} + +.p-war-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + table-layout: fixed; /* 컬럼 너비 고정 */ +} + +.p-war-table th { + position: sticky; + top: 0; + z-index: 20; + background: #f8fafc; + padding: 16px 15px; + font-size: 12px; + font-weight: 800; + color: #475569; + border-bottom: 2px solid #e2e8f0; + white-space: nowrap; +} + +.p-war-table td { + padding: 14px 15px; + font-size: 13px; + border-bottom: 1px solid #f1f5f9; + vertical-align: middle; +} + +/* 컬럼별 너비 정의 */ +.p-war-table th:nth-child(1), .p-war-table td:nth-child(1) { width: 28%; text-align: left; } /* 프로젝트명 */ +.p-war-table th:nth-child(2), .p-war-table td:nth-child(2) { width: 10%; text-align: right; } /* 파일 수 */ +.p-war-table th:nth-child(3), .p-war-table td:nth-child(3) { width: 10%; text-align: right; } /* 방치일 */ +.p-war-table th:nth-child(4), .p-war-table td:nth-child(4) { width: 10%; text-align: center; } /* 상태 판정 */ +.p-war-table th:nth-child(5), .p-war-table td:nth-child(5) { width: 14%; text-align: right; } /* P-WAR+ */ +.p-war-table th:nth-child(6), .p-war-table td:nth-child(6) { width: 12%; text-align: right; } /* 현재 SOI */ +.p-war-table th:nth-child(7), .p-war-table td:nth-child(7) { width: 12%; text-align: center; } /* 실무 투입 */ +.p-war-table th:nth-child(8), .p-war-table td:nth-child(8) { width: 14%; text-align: center; } /* AI 예보 */ + +.project-row { cursor: pointer; transition: background 0.2s; } +.project-row:hover { background: var(--hover-bg) !important; } + +/* SOI Value Styling */ +.p-war-value { font-weight: 800; font-family: 'Consolas', monospace; } + +/* Accordion Detail Styles */ +.detail-row { display: none; background: #fafafa; } +.detail-row.active { display: table-row; } +.detail-container { padding: 20px 24px; } + +.formula-explanation-card { + background: white; + border-radius: var(--radius-lg); + padding: 24px; + border: 1px solid var(--border-color); + box-shadow: var(--box-shadow); +} + +.formula-header { font-size: 13px; font-weight: 700; color: #6366f1; margin-bottom: 15px; } + +/* Work Effort Section */ +.work-effort-section { background: var(--bg-muted); padding: 16px; border-radius: var(--radius-lg); margin-bottom: 20px; } +.work-effort-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } +.work-effort-bar-bg { width: 100%; height: 6px; background: #e2e8f0; border-radius: 3px; overflow: hidden; margin-bottom: 10px; } + +/* Formula Steps Grid */ +.formula-steps-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } +.formula-step { display: flex; gap: 12px; } +.step-num { background: var(--primary-color); color: white; width: 20px; height: 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 800; flex-shrink: 0; } +.step-title { font-size: 12px; font-weight: 700; color: var(--text-main); margin-bottom: 4px; } +.math-logic { font-family: 'Consolas', monospace; background: var(--bg-muted); padding: 4px 8px; border-radius: 4px; font-weight: 700; color: var(--text-main); font-size: 12px; display: inline-block; } + +.final-result-area { margin-top: 20px; padding-top: 15px; display: flex; justify-content: space-between; align-items: center; } + +/* Modal Analysis Specific */ +.modal-footer { + padding: 16px 24px; + background: #fff; + border-top: 1px solid var(--border-color); + text-align: right; + display: flex; + justify-content: flex-end; +} + +/* Formula & Badges */ +.formula-section { margin-bottom: 20px; } +.formula-box { background: var(--primary-lv-0); color: var(--primary-color); padding: 15px; border-radius: var(--radius-lg); font-weight: 800; text-align: center; font-family: monospace; font-size: 16px; } + +.badge-active { background: #dcfce7; color: #166534; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; } +.badge-warning { background: #fef9c3; color: #854d0e; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; } +.badge-danger { background: #ffedd5; color: #9a3412; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; } +.badge-system { background: #fee2e2; color: #991b1b; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; } + +.highlight-var { color: #2563eb; } +.highlight-val { color: #059669; } +.highlight-penalty { color: #dc2626; } +.text-plus { color: #059669; font-weight: 700; } +.text-minus { color: #dc2626; font-weight: 700; } +.font-bold { font-weight: 700; } diff --git a/style/common.css b/style/common.css index 00d5608..2ae2fe0 100644 --- a/style/common.css +++ b/style/common.css @@ -1,170 +1,170 @@ -:root { - /* 1. Core Colors */ - --primary-color: #1E5149; - --primary-hover: #163b36; - --primary-lv-0: #f0f7f4; - --primary-lv-1: #e1eee9; - --primary-lv-8: #193833; - - --bg-default: #FFFFFF; - --bg-muted: #F9FAFB; - --hover-bg: #F7FAFC; - - --text-main: #111827; - --text-sub: #6B7280; - --error-color: #F21D0D; - --border-color: #E2E8F0; - - /* 2. Gradients */ - --header-gradient: linear-gradient(90deg, #193833 0%, #1e5149 100%); - --ai-gradient: linear-gradient(180deg, #da8cf1 0%, #8bb1f2 100%); - - /* 3. Spacing & Radius */ - --space-xs: 4px; - --space-sm: 8px; - --space-md: 16px; - --space-lg: 32px; - --radius-sm: 4px; - --radius-md: 6px; - --radius-lg: 8px; - --radius-xl: 12px; - - /* 4. Typography */ - --fz-h1: 20px; - --fz-h2: 16px; - --fz-body: 13px; - --fz-small: 11px; - - /* 5. Shadows */ - --box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05); - --box-shadow-lg: 0 10px 20px rgba(0, 0, 0, 0.1); - --box-shadow-modal: 0 25px 50px -12px rgba(0,0,0,0.5); - - /* 6. Layout Constants */ - --topbar-h: 36px; -} - -/* Base Reset */ -* { margin: 0; padding: 0; box-sizing: border-box; } -body { - font-family: 'Pretendard', -apple-system, sans-serif; - font-size: var(--fz-body); - color: var(--text-main); - background: var(--bg-default); - min-height: 100vh; -} - -/* Page Specific Overrides */ -body:has(.mail-wrapper) { height: 100vh; overflow: hidden; } - -input, select, textarea, button { font-family: inherit; } -a { text-decoration: none; color: inherit; } -button { cursor: pointer; border: none; transition: all 0.2s ease; } - -/* Utilities: Layout & Text */ -.flex-center { display: flex; align-items: center; justify-content: center; } -.flex-between { display: flex; align-items: center; justify-content: space-between; } -.flex-column { display: flex; flex-direction: column; } -.text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.w-full { width: 100%; } -.pointer { cursor: pointer; } - -/* Components: Topbar */ -.topbar { - width: 100%; - background: var(--header-gradient); - color: #fff; - padding: 0 var(--space-lg); - position: fixed; - top: 0; - height: var(--topbar-h); - display: flex; - align-items: center; - z-index: 2000; -} -.topbar-header h2 { font-size: 16px; color: white; margin-right: 60px; font-weight: 700; } -.nav-list { display: flex; list-style: none; gap: var(--space-sm); } -.nav-item { - padding: 4px 12px; border-radius: var(--radius-sm); - color: rgba(255, 255, 255, 0.8); font-size: 14px; - cursor: pointer; -} -.nav-item:hover { background: var(--primary-lv-8); color: #fff; } -.nav-item.active { background: var(--primary-lv-0); color: var(--primary-color) !important; font-weight: 700; } - -/* Components: Modals */ -.modal-overlay { - display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; - background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(4px); - z-index: 3000; justify-content: center; align-items: center; -} -.modal-content { - background: white; padding: 24px; border-radius: var(--radius-xl); - width: 90%; max-width: 500px; box-shadow: var(--box-shadow-modal); -} -.modal-header { - display: flex; justify-content: space-between; align-items: center; - margin-bottom: 20px; border-bottom: 1px solid var(--border-color); padding-bottom: 12px; -} -.modal-header h3 { margin: 0; font-size: 16px; color: var(--primary-color); font-weight: 700; } -.modal-close { cursor: pointer; font-size: 24px; color: var(--text-sub); line-height: 1; transition: 0.2s; } -.modal-close:hover { color: var(--text-main); } - -/* Components: Data Tables */ -.data-table { width: 100%; border-collapse: collapse; font-size: 12px; background: #fff; } -.data-table th, .data-table td { padding: 12px 10px; border-bottom: 1px solid var(--border-color); text-align: left; } -.data-table th { color: var(--text-sub); font-weight: 700; background: var(--bg-muted); font-size: 11px; text-transform: uppercase; } -.data-table tr:hover { background: var(--hover-bg); } - -/* Components: Standard Buttons */ -.btn { - display: inline-flex; align-items: center; justify-content: center; gap: 8px; - padding: 8px 16px; border-radius: var(--radius-lg); font-weight: 600; font-size: 13px; - border: none; cursor: pointer; transition: all 0.2s ease; -} -.btn-primary { background: var(--primary-color); color: #fff; } -.btn-primary:hover { background: var(--primary-hover); transform: translateY(-1px); } -.btn-secondary { background: #f1f3f5; color: #495057; } -.btn-secondary:hover { background: #e9ecef; } -.btn-danger { background: #fee2e2; color: #dc2626; } -.btn-danger:hover { background: #fecaca; } - -/* Compatibility Utils */ -._button-xsmall { - display: inline-flex; align-items: center; justify-content: center; - padding: 4px 10px; font-size: 11px; font-weight: 600; border-radius: 4px; border: 1px solid var(--border-color); - background: #fff; color: var(--text-main); cursor: pointer; transition: 0.2s; -} -._button-xsmall:hover { background: var(--bg-muted); border-color: var(--primary-color); color: var(--primary-color); } - -._button-small { - display: inline-flex; align-items: center; justify-content: center; - padding: 6px 14px; font-size: 12px; background: var(--primary-color); color: #fff; border-radius: 6px; border: none; cursor: pointer; -} -._button-medium { - display: inline-flex; align-items: center; justify-content: center; - padding: 10px 20px; background: var(--primary-color); color: #fff; border-radius: 6px; font-weight: 700; border: none; cursor: pointer; -} -.sync-btn { background: var(--primary-color); color: #fff; padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; } - -/* Badges & Status Colors */ -.badge { - padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 700; - display: inline-block; background: var(--primary-lv-1); color: var(--primary-color); -} - -.status-complete { background: #e8f5e9; color: #2e7d32; } -.status-working { background: #fff8e1; color: #FFBF00; } -.status-checking { background: #e3f2fd; color: #1565c0; } -.status-pending { background: #f5f5f5; color: #757575; } -.status-error { background: #fee9e7; } - -.warning-text { color: #FFBF00; font-weight: 600; } -.error-text { color: #F21D0D !important; font-weight: 700; } - -/* Spinner */ -.spinner { - display: none; width: 16px; height: 16px; border: 2px solid rgba(255, 255, 255, .3); - border-radius: 50%; border-top-color: #fff; animation: spin 1s ease-in-out infinite; -} -@keyframes spin { to { transform: rotate(360deg); } } +:root { + /* 1. Core Colors */ + --primary-color: #1E5149; + --primary-hover: #163b36; + --primary-lv-0: #f0f7f4; + --primary-lv-1: #e1eee9; + --primary-lv-8: #193833; + + --bg-default: #FFFFFF; + --bg-muted: #F9FAFB; + --hover-bg: #F7FAFC; + + --text-main: #111827; + --text-sub: #6B7280; + --error-color: #F21D0D; + --border-color: #E2E8F0; + + /* 2. Gradients */ + --header-gradient: linear-gradient(90deg, #193833 0%, #1e5149 100%); + --ai-gradient: linear-gradient(180deg, #da8cf1 0%, #8bb1f2 100%); + + /* 3. Spacing & Radius */ + --space-xs: 4px; + --space-sm: 8px; + --space-md: 16px; + --space-lg: 32px; + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + --radius-xl: 12px; + + /* 4. Typography */ + --fz-h1: 20px; + --fz-h2: 16px; + --fz-body: 13px; + --fz-small: 11px; + + /* 5. Shadows */ + --box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05); + --box-shadow-lg: 0 10px 20px rgba(0, 0, 0, 0.1); + --box-shadow-modal: 0 25px 50px -12px rgba(0,0,0,0.5); + + /* 6. Layout Constants */ + --topbar-h: 36px; +} + +/* Base Reset */ +* { margin: 0; padding: 0; box-sizing: border-box; } +body { + font-family: 'Pretendard', -apple-system, sans-serif; + font-size: var(--fz-body); + color: var(--text-main); + background: var(--bg-default); + min-height: 100vh; +} + +/* Page Specific Overrides */ +body:has(.mail-wrapper) { height: 100vh; overflow: hidden; } + +input, select, textarea, button { font-family: inherit; } +a { text-decoration: none; color: inherit; } +button { cursor: pointer; border: none; transition: all 0.2s ease; } + +/* Utilities: Layout & Text */ +.flex-center { display: flex; align-items: center; justify-content: center; } +.flex-between { display: flex; align-items: center; justify-content: space-between; } +.flex-column { display: flex; flex-direction: column; } +.text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.w-full { width: 100%; } +.pointer { cursor: pointer; } + +/* Components: Topbar */ +.topbar { + width: 100%; + background: var(--header-gradient); + color: #fff; + padding: 0 var(--space-lg); + position: fixed; + top: 0; + height: var(--topbar-h); + display: flex; + align-items: center; + z-index: 2000; +} +.topbar-header h2 { font-size: 16px; color: white; margin-right: 60px; font-weight: 700; } +.nav-list { display: flex; list-style: none; gap: var(--space-sm); } +.nav-item { + padding: 4px 12px; border-radius: var(--radius-sm); + color: rgba(255, 255, 255, 0.8); font-size: 14px; + cursor: pointer; +} +.nav-item:hover { background: var(--primary-lv-8); color: #fff; } +.nav-item.active { background: var(--primary-lv-0); color: var(--primary-color) !important; font-weight: 700; } + +/* Components: Modals */ +.modal-overlay { + display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; + background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(4px); + z-index: 3000; justify-content: center; align-items: center; +} +.modal-content { + background: white; padding: 24px; border-radius: var(--radius-xl); + width: 90%; max-width: 500px; box-shadow: var(--box-shadow-modal); +} +.modal-header { + display: flex; justify-content: space-between; align-items: center; + margin-bottom: 20px; border-bottom: 1px solid var(--border-color); padding-bottom: 12px; +} +.modal-header h3 { margin: 0; font-size: 16px; color: var(--primary-color); font-weight: 700; } +.modal-close { cursor: pointer; font-size: 24px; color: var(--text-sub); line-height: 1; transition: 0.2s; } +.modal-close:hover { color: var(--text-main); } + +/* Components: Data Tables */ +.data-table { width: 100%; border-collapse: collapse; font-size: 12px; background: #fff; } +.data-table th, .data-table td { padding: 12px 10px; border-bottom: 1px solid var(--border-color); text-align: left; } +.data-table th { color: var(--text-sub); font-weight: 700; background: var(--bg-muted); font-size: 11px; text-transform: uppercase; } +.data-table tr:hover { background: var(--hover-bg); } + +/* Components: Standard Buttons */ +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + padding: 8px 16px; border-radius: var(--radius-lg); font-weight: 600; font-size: 13px; + border: none; cursor: pointer; transition: all 0.2s ease; +} +.btn-primary { background: var(--primary-color); color: #fff; } +.btn-primary:hover { background: var(--primary-hover); transform: translateY(-1px); } +.btn-secondary { background: #f1f3f5; color: #495057; } +.btn-secondary:hover { background: #e9ecef; } +.btn-danger { background: #fee2e2; color: #dc2626; } +.btn-danger:hover { background: #fecaca; } + +/* Compatibility Utils */ +._button-xsmall { + display: inline-flex; align-items: center; justify-content: center; + padding: 4px 10px; font-size: 11px; font-weight: 600; border-radius: 4px; border: 1px solid var(--border-color); + background: #fff; color: var(--text-main); cursor: pointer; transition: 0.2s; +} +._button-xsmall:hover { background: var(--bg-muted); border-color: var(--primary-color); color: var(--primary-color); } + +._button-small { + display: inline-flex; align-items: center; justify-content: center; + padding: 6px 14px; font-size: 12px; background: var(--primary-color); color: #fff; border-radius: 6px; border: none; cursor: pointer; +} +._button-medium { + display: inline-flex; align-items: center; justify-content: center; + padding: 10px 20px; background: var(--primary-color); color: #fff; border-radius: 6px; font-weight: 700; border: none; cursor: pointer; +} +.sync-btn { background: var(--primary-color); color: #fff; padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; } + +/* Badges & Status Colors */ +.badge { + padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 700; + display: inline-block; background: var(--primary-lv-1); color: var(--primary-color); +} + +.status-complete { background: #e8f5e9; color: #2e7d32; } +.status-working { background: #fff8e1; color: #FFBF00; } +.status-checking { background: #e3f2fd; color: #1565c0; } +.status-pending { background: #f5f5f5; color: #757575; } +.status-error { background: #fee9e7; } + +.warning-text { color: #FFBF00; font-weight: 600; } +.error-text { color: #F21D0D !important; font-weight: 700; } + +/* Spinner */ +.spinner { + display: none; width: 16px; height: 16px; border: 2px solid rgba(255, 255, 255, .3); + border-radius: 50%; border-top-color: #fff; animation: spin 1s ease-in-out infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } diff --git a/style/dashboard.css b/style/dashboard.css index dd22710..ea5748d 100644 --- a/style/dashboard.css +++ b/style/dashboard.css @@ -1,123 +1,123 @@ -/* Dashboard Constants */ -:root { - --header-h: 56px; - --activity-h: 110px; - --fixed-total-h: calc(var(--topbar-h) + var(--header-h) + var(--activity-h)); -} - -/* 1. Portal (Index) */ -.portal-container { - display: flex; flex-direction: column; align-items: center; justify-content: center; - height: calc(100vh - var(--topbar-h)); background: var(--bg-muted); padding: var(--space-lg); margin-top: var(--topbar-h); -} -.portal-header { text-align: center; margin-bottom: 50px; } -.portal-header h1 { font-size: 28px; color: var(--primary-color); margin-bottom: 10px; font-weight: 800; } -.portal-header p { color: var(--text-sub); font-size: 15px; } - -.button-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 30px; width: 100%; max-width: 800px; } -.portal-card { - background: #fff; border: 1px solid var(--border-color); border-radius: 12px; padding: 40px; - text-align: center; transition: all 0.3s ease; width: 100%; box-shadow: var(--box-shadow); - display: flex; flex-direction: column; align-items: center; gap: 20px; -} -.portal-card:hover { transform: translateY(-8px); border-color: var(--primary-color); box-shadow: var(--box-shadow-lg); } -.portal-card i { font-size: 48px; color: var(--primary-color); } -.portal-card h3 { font-size: 20px; color: var(--text-main); margin: 0; } -.portal-card p { font-size: 14px; color: var(--text-sub); margin: 0; } - -/* 2. Dashboard Header & Activity */ -header { - position: fixed; top: var(--topbar-h); left: 0; right: 0; z-index: 1001; - background: #fff; height: var(--header-h); display: flex; justify-content: space-between; align-items: center; - padding: 0 var(--space-lg); border-bottom: 1px solid #f5f5f5; -} - -.activity-dashboard-wrapper { - position: fixed; top: calc(var(--topbar-h) + var(--header-h)); left: 0; right: 0; z-index: 1000; - background: #fff; height: var(--activity-h); border-bottom: 1px solid var(--border-color); box-shadow: 0 4px 6px rgba(0,0,0,0.03); -} - -.activity-dashboard { max-width: 1200px; margin: 0 auto; height: 100%; display: flex; gap: 15px; padding: 10px 32px 20px; } -.activity-card { - flex: 1; padding: 12px 15px; border-radius: var(--radius-lg); cursor: pointer; - display: flex; flex-direction: column; justify-content: center; gap: 2px; border-left: 5px solid transparent; -} -.activity-card:hover { transform: translateY(-2px); box-shadow: var(--box-shadow); } -.activity-card.active { background: #e8f5e9; } -.activity-card.warning { background: #fff8e1; } -.activity-card.stale { background: #ffebee; } -.activity-card.unknown { background: #f5f5f5; } -.activity-card .label { font-size: 11px; font-weight: 600; opacity: 0.7; } -.activity-card .count { font-size: 20px; font-weight: 800; } - -.main-content { margin-top: var(--fixed-total-h); padding: var(--space-lg); max-width: 1400px; margin-left: auto; margin-right: auto; } - -/* 3. Log Console */ -.log-console { - position: sticky; top: var(--fixed-total-h); z-index: 999; - background: #000; color: #0f0; font-family: 'Consolas', monospace; padding: 15px; margin-bottom: 20px; - border-radius: 4px; max-height: 250px; overflow-y: auto; font-size: 12px; line-height: 1.5; box-shadow: 0 10px 20px rgba(0,0,0,0.2); -} -.log-console-header { color: #fff; border-bottom: 1px solid #333; margin-bottom: 10px; padding-bottom: 5px; font-weight: bold; } - -/* 4. Auth Modal (Page Specific) */ -.auth-modal-content { - background: #fff; width: 440px; border-radius: 16px; padding: 40px; text-align: center; - box-shadow: var(--box-shadow-modal); display: flex; flex-direction: column; gap: 32px; -} -.input-group { display: flex; flex-direction: column; gap: 8px; text-align: left; } -.input-group label { font-size: 12px; font-weight: 700; color: var(--text-main); } -.input-group input { - height: 48px; padding: 0 16px; border: 1px solid var(--border-color); border-radius: 8px; - font-size: 14px; background: #f9f9f9; width: 100%; -} -.input-group input:focus { border-color: var(--primary-color); background: #fff; outline: none; } - -/* 5. Accordion & Data Tables */ -.accordion-list-header { - position: sticky; top: var(--fixed-total-h); background: #fff; z-index: 900; - font-size: 11px; font-weight: 700; color: var(--text-sub); - padding: 12px 24px; border-bottom: 2px solid var(--primary-color); - display: grid; grid-template-columns: 2.5fr 1fr 1fr 0.8fr 2fr; gap: 16px; -} - -.accordion-header { - display: grid; grid-template-columns: 2.5fr 1fr 1fr 0.8fr 2fr; gap: 16px; - padding: 12px 24px; align-items: center; cursor: pointer; border-bottom: 1px solid var(--border-color); -} -.accordion-item:hover .accordion-header { background: var(--primary-lv-0); } -.accordion-item.active .accordion-header { background: var(--primary-lv-0); border-bottom: none; } - -.repo-title { font-weight: 700; color: var(--primary-color); @extend .text-truncate; } -.repo-files { text-align: center; font-weight: 600; } -.repo-log { font-size: 11px; color: var(--text-sub); @extend .text-truncate; } - -.accordion-body { display: none; padding: 24px; background: #fff; border-bottom: 1px solid var(--border-color); } -.accordion-item.active .accordion-body { display: block; } - -.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; } -.detail-section h4 { - font-size: 13px; margin-bottom: 12px; color: var(--text-main); - padding-left: 10px; font-weight: 700; -} - -/* Personnel & Activity Tables */ -#personnel-table th:nth-child(1) { width: 25%; } -#personnel-table th:nth-child(2) { width: 45%; } -#activity-table th:nth-child(1) { width: 20%; } -#activity-table th:nth-child(2) { width: 50%; } - -/* Location Groups */ -.continent-group, .country-group { margin-bottom: 15px; } -.continent-header, .country-header { - background: #fff; padding: 14px 20px; border: 1px solid var(--border-color); border-radius: 8px; - display: flex; justify-content: space-between; align-items: center; cursor: pointer; font-weight: 700; -} -.continent-header { background: var(--primary-color); color: white; border: none; font-size: 15px; } -.country-header { font-size: 14px; color: var(--text-main); margin-top: 8px; } -.continent-body, .country-body { display: none; padding: 10px 0 10px 15px; } -.active>.continent-body, .active>.country-body { display: block; } - -.admin-info { font-size: 12px; color: var(--text-sub); margin-left: 16px; padding: 6px 12px; background: #f8f9fa; border-radius: 4px; border: 1px solid var(--border-color); } -.admin-info strong { color: var(--primary-color); font-weight: 700; } -.base-date-info { font-size: 13px; color: var(--text-sub); background: #fdfdfd; padding: 6px 15px; border-radius: 6px; border: 1px solid var(--border-color); } +/* Dashboard Constants */ +:root { + --header-h: 56px; + --activity-h: 110px; + --fixed-total-h: calc(var(--topbar-h) + var(--header-h) + var(--activity-h)); +} + +/* 1. Portal (Index) */ +.portal-container { + display: flex; flex-direction: column; align-items: center; justify-content: center; + height: calc(100vh - var(--topbar-h)); background: var(--bg-muted); padding: var(--space-lg); margin-top: var(--topbar-h); +} +.portal-header { text-align: center; margin-bottom: 50px; } +.portal-header h1 { font-size: 28px; color: var(--primary-color); margin-bottom: 10px; font-weight: 800; } +.portal-header p { color: var(--text-sub); font-size: 15px; } + +.button-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 30px; width: 100%; max-width: 800px; } +.portal-card { + background: #fff; border: 1px solid var(--border-color); border-radius: 12px; padding: 40px; + text-align: center; transition: all 0.3s ease; width: 100%; box-shadow: var(--box-shadow); + display: flex; flex-direction: column; align-items: center; gap: 20px; +} +.portal-card:hover { transform: translateY(-8px); border-color: var(--primary-color); box-shadow: var(--box-shadow-lg); } +.portal-card i { font-size: 48px; color: var(--primary-color); } +.portal-card h3 { font-size: 20px; color: var(--text-main); margin: 0; } +.portal-card p { font-size: 14px; color: var(--text-sub); margin: 0; } + +/* 2. Dashboard Header & Activity */ +header { + position: fixed; top: var(--topbar-h); left: 0; right: 0; z-index: 1001; + background: #fff; height: var(--header-h); display: flex; justify-content: space-between; align-items: center; + padding: 0 var(--space-lg); border-bottom: 1px solid #f5f5f5; +} + +.activity-dashboard-wrapper { + position: fixed; top: calc(var(--topbar-h) + var(--header-h)); left: 0; right: 0; z-index: 1000; + background: #fff; height: var(--activity-h); border-bottom: 1px solid var(--border-color); box-shadow: 0 4px 6px rgba(0,0,0,0.03); +} + +.activity-dashboard { max-width: 1200px; margin: 0 auto; height: 100%; display: flex; gap: 15px; padding: 10px 32px 20px; } +.activity-card { + flex: 1; padding: 12px 15px; border-radius: var(--radius-lg); cursor: pointer; + display: flex; flex-direction: column; justify-content: center; gap: 2px; border-left: 5px solid transparent; +} +.activity-card:hover { transform: translateY(-2px); box-shadow: var(--box-shadow); } +.activity-card.active { background: #e8f5e9; } +.activity-card.warning { background: #fff8e1; } +.activity-card.stale { background: #ffebee; } +.activity-card.unknown { background: #f5f5f5; } +.activity-card .label { font-size: 11px; font-weight: 600; opacity: 0.7; } +.activity-card .count { font-size: 20px; font-weight: 800; } + +.main-content { margin-top: var(--fixed-total-h); padding: var(--space-lg); max-width: 1400px; margin-left: auto; margin-right: auto; } + +/* 3. Log Console */ +.log-console { + position: sticky; top: var(--fixed-total-h); z-index: 999; + background: #000; color: #0f0; font-family: 'Consolas', monospace; padding: 15px; margin-bottom: 20px; + border-radius: 4px; max-height: 250px; overflow-y: auto; font-size: 12px; line-height: 1.5; box-shadow: 0 10px 20px rgba(0,0,0,0.2); +} +.log-console-header { color: #fff; border-bottom: 1px solid #333; margin-bottom: 10px; padding-bottom: 5px; font-weight: bold; } + +/* 4. Auth Modal (Page Specific) */ +.auth-modal-content { + background: #fff; width: 440px; border-radius: 16px; padding: 40px; text-align: center; + box-shadow: var(--box-shadow-modal); display: flex; flex-direction: column; gap: 32px; +} +.input-group { display: flex; flex-direction: column; gap: 8px; text-align: left; } +.input-group label { font-size: 12px; font-weight: 700; color: var(--text-main); } +.input-group input { + height: 48px; padding: 0 16px; border: 1px solid var(--border-color); border-radius: 8px; + font-size: 14px; background: #f9f9f9; width: 100%; +} +.input-group input:focus { border-color: var(--primary-color); background: #fff; outline: none; } + +/* 5. Accordion & Data Tables */ +.accordion-list-header { + position: sticky; top: var(--fixed-total-h); background: #fff; z-index: 900; + font-size: 11px; font-weight: 700; color: var(--text-sub); + padding: 12px 24px; border-bottom: 2px solid var(--primary-color); + display: grid; grid-template-columns: 2.5fr 1fr 1fr 0.8fr 2fr; gap: 16px; +} + +.accordion-header { + display: grid; grid-template-columns: 2.5fr 1fr 1fr 0.8fr 2fr; gap: 16px; + padding: 12px 24px; align-items: center; cursor: pointer; border-bottom: 1px solid var(--border-color); +} +.accordion-item:hover .accordion-header { background: var(--primary-lv-0); } +.accordion-item.active .accordion-header { background: var(--primary-lv-0); border-bottom: none; } + +.repo-title { font-weight: 700; color: var(--primary-color); @extend .text-truncate; } +.repo-files { text-align: center; font-weight: 600; } +.repo-log { font-size: 11px; color: var(--text-sub); @extend .text-truncate; } + +.accordion-body { display: none; padding: 24px; background: #fff; border-bottom: 1px solid var(--border-color); } +.accordion-item.active .accordion-body { display: block; } + +.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; } +.detail-section h4 { + font-size: 13px; margin-bottom: 12px; color: var(--text-main); + padding-left: 10px; font-weight: 700; +} + +/* Personnel & Activity Tables */ +#personnel-table th:nth-child(1) { width: 25%; } +#personnel-table th:nth-child(2) { width: 45%; } +#activity-table th:nth-child(1) { width: 20%; } +#activity-table th:nth-child(2) { width: 50%; } + +/* Location Groups */ +.continent-group, .country-group { margin-bottom: 15px; } +.continent-header, .country-header { + background: #fff; padding: 14px 20px; border: 1px solid var(--border-color); border-radius: 8px; + display: flex; justify-content: space-between; align-items: center; cursor: pointer; font-weight: 700; +} +.continent-header { background: var(--primary-color); color: white; border: none; font-size: 15px; } +.country-header { font-size: 14px; color: var(--text-main); margin-top: 8px; } +.continent-body, .country-body { display: none; padding: 10px 0 10px 15px; } +.active>.continent-body, .active>.country-body { display: block; } + +.admin-info { font-size: 12px; color: var(--text-sub); margin-left: 16px; padding: 6px 12px; background: #f8f9fa; border-radius: 4px; border: 1px solid var(--border-color); } +.admin-info strong { color: var(--primary-color); font-weight: 700; } +.base-date-info { font-size: 13px; color: var(--text-sub); background: #fdfdfd; padding: 6px 15px; border-radius: 6px; border: 1px solid var(--border-color); } diff --git a/style/inquiries.css b/style/inquiries.css index 164eede..f63bc81 100644 --- a/style/inquiries.css +++ b/style/inquiries.css @@ -1,251 +1,251 @@ -/* 1. Layout & Board Structure */ -.inquiry-board { - padding: 0 20px 32px 20px; - max-width: 98%; - margin: 36px auto 0; -} - -.board-sticky-header { - position: sticky; - top: 36px; - background: #fff; - z-index: 1000; - padding: 15px 0 10px; - border-bottom: 1px solid #eee; -} - -.board-header { - display: flex; - justify-content: space-between; - align-items: flex-end; - margin-bottom: 20px; -} - -/* 2. Stats Dashboard */ -.header-stats { - display: flex; - gap: 12px; -} - -.stat-item { - background: #fff; - border: 1px solid #eee; - padding: 8px 16px; - border-radius: 8px; - display: flex; - flex-direction: column; - align-items: center; - min-width: 80px; - box-shadow: 0 2px 4px rgba(0,0,0,0.02); - transition: transform 0.2s, box-shadow 0.2s; -} - -.stat-item:hover { - transform: translateY(-2px); - box-shadow: 0 4px 8px rgba(0,0,0,0.05); -} - -.stat-label { font-size: 11px; font-weight: 600; color: #888; margin-bottom: 2px; } -.stat-value { font-size: 18px; font-weight: 700; color: #333; } - -/* Status Border Colors */ -.stat-item.total { } -.stat-item.total .stat-value { color: #1e5149; } -.stat-item.complete { } -.stat-item.complete .stat-value { color: #2e7d32; } -.stat-item.working { } -.stat-item.working .stat-value { color: #1565c0; } -.stat-item.checking { } -.stat-item.checking .stat-value { color: #ef6c00; } -.stat-item.pending { } -.stat-item.pending .stat-value { color: #673ab7; } -.stat-item.unconfirmed { } -.stat-item.unconfirmed .stat-value { color: #9e9e9e; } - -/* 3. Filters & Notice */ -.notice-container { - background: #fdfdfd; - padding: 20px; - border-radius: 8px; - border: 1px solid #e0e0e0; - margin-bottom: 24px; - box-shadow: 0 2px 5px rgba(0,0,0,0.02); -} - -.filter-section { - display: flex; - gap: 12px; - background: #f8f9fa; - padding: 12px 16px; - border-radius: 8px; - margin-top: 15px; -} - -.filter-group { display: flex; flex-direction: column; gap: 4px; } -.filter-group label { font-size: 12px; font-weight: 600; color: #666; } -.filter-group select, .filter-group input { padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; } - -/* 4. Table Styles */ -.inquiry-table { - width: 100%; - background: #fff; - border-radius: 8px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); - border-collapse: separate; - border-spacing: 0; - margin-top: 10px; -} - -.inquiry-table thead th { - position: sticky; - background: #f8f9fa; - padding: 14px 16px; - text-align: left; - font-size: 13px; - font-weight: 700; - color: #333; - border-bottom: 2px solid #eee; - z-index: 900; -} - -/* 정렬 가능한 헤더 스타일 추가 */ -.inquiry-table thead th.sortable { - cursor: pointer; - user-select: none; - transition: background 0.2s; - white-space: nowrap; -} - -.inquiry-table thead th.sortable .header-content { - display: flex; - align-items: center; - gap: 4px; -} - -.sort-icon { - display: inline-flex; - flex-direction: column; - justify-content: center; - width: 12px; - height: 12px; - font-size: 8px; - color: #ccc; - line-height: 1; - margin-left: 2px; -} - -.inquiry-table thead th.active-sort { - color: #1e5149; - background: #f0f7f6; -} - -.inquiry-table thead th.active-sort .sort-icon { - color: #1e5149; - font-size: 10px; -} - -.inquiry-table td { - padding: 14px 16px; - font-size: 13px; - border-bottom: 1px solid #eee; - vertical-align: middle; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* Table Row Hover & Active State */ -.inquiry-row:hover { background: #fcfcfc; cursor: pointer; } -.inquiry-row.active-row { background-color: #f0f7f6 !important; } -.inquiry-row.active-row td { font-weight: 600; color: #1e5149; border-bottom-color: transparent; } - -/* Status Badges */ -.status-badge { padding: 4px 10px; border-radius: 20px; font-size: 11px; font-weight: 700; display: inline-block; } -.status-complete { background: #e8f5e9; color: #2e7d32; } -.status-working { background: #e3f2fd; color: #1565c0; } -.status-checking { background: #fff3e0; color: #ef6c00; } -.status-pending { background: #f5f5f5; color: #616161; } - -/* Table Columns Width & Truncation */ -.inquiry-table td:nth-child(1) { max-width: 50px; } /* No */ -.inquiry-table td:nth-child(2) { max-width: 80px; text-align: center; } /* Image */ -.inquiry-table td:nth-child(3) { max-width: 120px; } /* PM Type */ -.inquiry-table td:nth-child(4) { max-width: 100px; } /* Env */ -.inquiry-table td:nth-child(5) { max-width: 150px; } /* Category */ -.inquiry-table td:nth-child(6) { max-width: 200px; } /* Project */ -.inquiry-table td:nth-child(7), .inquiry-table td:nth-child(8) { max-width: 400px; } /* Content & Reply */ -.inquiry-table td:nth-child(9) { max-width: 100px; } /* Author */ -.inquiry-table td:nth-child(10) { max-width: 120px; } /* Date */ -.inquiry-table td:nth-child(11) { max-width: 100px; } /* Status */ - -/* 5. Detail (Accordion) Styles */ -.detail-row { display: none; background: #fdfdfd; } -.detail-row.active { display: table-row; } -.detail-row td { max-width: none; white-space: normal; overflow: visible; } - -.detail-container { - padding: 24px; - background: #f9fafb; - box-shadow: inset 0 4px 15px rgba(0,0,0,0.08); - position: relative; - border-bottom: 2px solid #eee; -} - -.detail-content-wrapper { - display: flex; - flex-direction: column; - gap: 20px; - border: 1px solid #e5e7eb; - background: #fff; - padding: 25px; - border-radius: 12px; - box-shadow: 0 4px 10px rgba(0,0,0,0.03); -} - -.btn-close-accordion { - position: absolute; - top: 35px; - right: 45px; - background: #eee; - border: none; - padding: 6px 14px; - border-radius: 20px; - font-size: 12px; - font-weight: 600; - color: #666; - cursor: pointer; - z-index: 10; -} -.btn-close-accordion::after { content: "▲"; font-size: 10px; margin-left: 5px; } - -.detail-meta-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px; margin-bottom: 15px; font-size: 13px; color: #666; } -.detail-label { font-weight: 700; color: #888; margin-right: 8px; } - -.detail-q-section { background: #f8f9fa; padding: 20px; border-radius: 8px; } -.detail-a-section { background: #f1f8f7; padding: 20px; border-radius: 8px; } - -/* 6. Image Preview & Foldable Section */ -.img-thumbnail { width: 32px; height: 32px; border-radius: 4px; object-fit: cover; border: 1px solid #ddd; cursor: pointer; transition: transform 0.2s; } -.img-thumbnail:hover { transform: scale(1.1); } -.no-img { font-size: 10px; color: #ccc; font-style: italic; } - -.detail-image-section { margin-bottom: 20px; background: #f9fafb; border-radius: 8px; border: 1px solid #e5e7eb; overflow: hidden; } -.image-section-header { padding: 12px 16px; background: #f1f5f9; display: flex; justify-content: space-between; align-items: center; cursor: pointer; } -.image-section-header:hover { background: #e2e8f0; } -.image-section-header h4 { margin: 0; color: #1e5149; display: flex; align-items: center; gap: 8px; } -.image-section-content { padding: 20px; display: flex; justify-content: center; background: #fff; border-top: 1px solid #eee; } -.image-section-content.collapsed { display: none; } -.toggle-icon { font-size: 12px; color: #64748b; transition: transform 0.3s; } -.detail-image-section.active .toggle-icon { transform: rotate(180deg); } - -.preview-img { max-width: 100%; max-height: 400px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); object-fit: contain; } - -/* 7. Forms & Reply */ -.reply-edit-form textarea { - width: 100%; height: 120px; padding: 12px; border: 1px solid #ddd; border-radius: 6px; - font-family: inherit; font-size: 14px; margin-bottom: 15px; resize: none; background: #fff; -} -.reply-edit-form textarea:disabled, .reply-edit-form select:disabled, .reply-edit-form input:disabled { background: #fcfcfc; color: #666; border-color: #eee; } -.reply-edit-form.readonly .btn-save, .reply-edit-form.readonly .btn-delete, .reply-edit-form.readonly .btn-cancel { display: none; } -.reply-edit-form.editable .btn-edit { display: none; } -.reply-edit-form.editable textarea { border-color: #1e5149; box-shadow: 0 0 0 2px rgba(30, 81, 73, 0.1); } +/* 1. Layout & Board Structure */ +.inquiry-board { + padding: 0 20px 32px 20px; + max-width: 98%; + margin: 36px auto 0; +} + +.board-sticky-header { + position: sticky; + top: 36px; + background: #fff; + z-index: 1000; + padding: 15px 0 10px; + border-bottom: 1px solid #eee; +} + +.board-header { + display: flex; + justify-content: space-between; + align-items: flex-end; + margin-bottom: 20px; +} + +/* 2. Stats Dashboard */ +.header-stats { + display: flex; + gap: 12px; +} + +.stat-item { + background: #fff; + border: 1px solid #eee; + padding: 8px 16px; + border-radius: 8px; + display: flex; + flex-direction: column; + align-items: center; + min-width: 80px; + box-shadow: 0 2px 4px rgba(0,0,0,0.02); + transition: transform 0.2s, box-shadow 0.2s; +} + +.stat-item:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0,0,0,0.05); +} + +.stat-label { font-size: 11px; font-weight: 600; color: #888; margin-bottom: 2px; } +.stat-value { font-size: 18px; font-weight: 700; color: #333; } + +/* Status Border Colors */ +.stat-item.total { } +.stat-item.total .stat-value { color: #1e5149; } +.stat-item.complete { } +.stat-item.complete .stat-value { color: #2e7d32; } +.stat-item.working { } +.stat-item.working .stat-value { color: #1565c0; } +.stat-item.checking { } +.stat-item.checking .stat-value { color: #ef6c00; } +.stat-item.pending { } +.stat-item.pending .stat-value { color: #673ab7; } +.stat-item.unconfirmed { } +.stat-item.unconfirmed .stat-value { color: #9e9e9e; } + +/* 3. Filters & Notice */ +.notice-container { + background: #fdfdfd; + padding: 20px; + border-radius: 8px; + border: 1px solid #e0e0e0; + margin-bottom: 24px; + box-shadow: 0 2px 5px rgba(0,0,0,0.02); +} + +.filter-section { + display: flex; + gap: 12px; + background: #f8f9fa; + padding: 12px 16px; + border-radius: 8px; + margin-top: 15px; +} + +.filter-group { display: flex; flex-direction: column; gap: 4px; } +.filter-group label { font-size: 12px; font-weight: 600; color: #666; } +.filter-group select, .filter-group input { padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; } + +/* 4. Table Styles */ +.inquiry-table { + width: 100%; + background: #fff; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + border-collapse: separate; + border-spacing: 0; + margin-top: 10px; +} + +.inquiry-table thead th { + position: sticky; + background: #f8f9fa; + padding: 14px 16px; + text-align: left; + font-size: 13px; + font-weight: 700; + color: #333; + border-bottom: 2px solid #eee; + z-index: 900; +} + +/* 정렬 가능한 헤더 스타일 추가 */ +.inquiry-table thead th.sortable { + cursor: pointer; + user-select: none; + transition: background 0.2s; + white-space: nowrap; +} + +.inquiry-table thead th.sortable .header-content { + display: flex; + align-items: center; + gap: 4px; +} + +.sort-icon { + display: inline-flex; + flex-direction: column; + justify-content: center; + width: 12px; + height: 12px; + font-size: 8px; + color: #ccc; + line-height: 1; + margin-left: 2px; +} + +.inquiry-table thead th.active-sort { + color: #1e5149; + background: #f0f7f6; +} + +.inquiry-table thead th.active-sort .sort-icon { + color: #1e5149; + font-size: 10px; +} + +.inquiry-table td { + padding: 14px 16px; + font-size: 13px; + border-bottom: 1px solid #eee; + vertical-align: middle; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Table Row Hover & Active State */ +.inquiry-row:hover { background: #fcfcfc; cursor: pointer; } +.inquiry-row.active-row { background-color: #f0f7f6 !important; } +.inquiry-row.active-row td { font-weight: 600; color: #1e5149; border-bottom-color: transparent; } + +/* Status Badges */ +.status-badge { padding: 4px 10px; border-radius: 20px; font-size: 11px; font-weight: 700; display: inline-block; } +.status-complete { background: #e8f5e9; color: #2e7d32; } +.status-working { background: #e3f2fd; color: #1565c0; } +.status-checking { background: #fff3e0; color: #ef6c00; } +.status-pending { background: #f5f5f5; color: #616161; } + +/* Table Columns Width & Truncation */ +.inquiry-table td:nth-child(1) { max-width: 50px; } /* No */ +.inquiry-table td:nth-child(2) { max-width: 80px; text-align: center; } /* Image */ +.inquiry-table td:nth-child(3) { max-width: 120px; } /* PM Type */ +.inquiry-table td:nth-child(4) { max-width: 100px; } /* Env */ +.inquiry-table td:nth-child(5) { max-width: 150px; } /* Category */ +.inquiry-table td:nth-child(6) { max-width: 200px; } /* Project */ +.inquiry-table td:nth-child(7), .inquiry-table td:nth-child(8) { max-width: 400px; } /* Content & Reply */ +.inquiry-table td:nth-child(9) { max-width: 100px; } /* Author */ +.inquiry-table td:nth-child(10) { max-width: 120px; } /* Date */ +.inquiry-table td:nth-child(11) { max-width: 100px; } /* Status */ + +/* 5. Detail (Accordion) Styles */ +.detail-row { display: none; background: #fdfdfd; } +.detail-row.active { display: table-row; } +.detail-row td { max-width: none; white-space: normal; overflow: visible; } + +.detail-container { + padding: 24px; + background: #f9fafb; + box-shadow: inset 0 4px 15px rgba(0,0,0,0.08); + position: relative; + border-bottom: 2px solid #eee; +} + +.detail-content-wrapper { + display: flex; + flex-direction: column; + gap: 20px; + border: 1px solid #e5e7eb; + background: #fff; + padding: 25px; + border-radius: 12px; + box-shadow: 0 4px 10px rgba(0,0,0,0.03); +} + +.btn-close-accordion { + position: absolute; + top: 35px; + right: 45px; + background: #eee; + border: none; + padding: 6px 14px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; + color: #666; + cursor: pointer; + z-index: 10; +} +.btn-close-accordion::after { content: "▲"; font-size: 10px; margin-left: 5px; } + +.detail-meta-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px; margin-bottom: 15px; font-size: 13px; color: #666; } +.detail-label { font-weight: 700; color: #888; margin-right: 8px; } + +.detail-q-section { background: #f8f9fa; padding: 20px; border-radius: 8px; } +.detail-a-section { background: #f1f8f7; padding: 20px; border-radius: 8px; } + +/* 6. Image Preview & Foldable Section */ +.img-thumbnail { width: 32px; height: 32px; border-radius: 4px; object-fit: cover; border: 1px solid #ddd; cursor: pointer; transition: transform 0.2s; } +.img-thumbnail:hover { transform: scale(1.1); } +.no-img { font-size: 10px; color: #ccc; font-style: italic; } + +.detail-image-section { margin-bottom: 20px; background: #f9fafb; border-radius: 8px; border: 1px solid #e5e7eb; overflow: hidden; } +.image-section-header { padding: 12px 16px; background: #f1f5f9; display: flex; justify-content: space-between; align-items: center; cursor: pointer; } +.image-section-header:hover { background: #e2e8f0; } +.image-section-header h4 { margin: 0; color: #1e5149; display: flex; align-items: center; gap: 8px; } +.image-section-content { padding: 20px; display: flex; justify-content: center; background: #fff; border-top: 1px solid #eee; } +.image-section-content.collapsed { display: none; } +.toggle-icon { font-size: 12px; color: #64748b; transition: transform 0.3s; } +.detail-image-section.active .toggle-icon { transform: rotate(180deg); } + +.preview-img { max-width: 100%; max-height: 400px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); object-fit: contain; } + +/* 7. Forms & Reply */ +.reply-edit-form textarea { + width: 100%; height: 120px; padding: 12px; border: 1px solid #ddd; border-radius: 6px; + font-family: inherit; font-size: 14px; margin-bottom: 15px; resize: none; background: #fff; +} +.reply-edit-form textarea:disabled, .reply-edit-form select:disabled, .reply-edit-form input:disabled { background: #fcfcfc; color: #666; border-color: #eee; } +.reply-edit-form.readonly .btn-save, .reply-edit-form.readonly .btn-delete, .reply-edit-form.readonly .btn-cancel { display: none; } +.reply-edit-form.editable .btn-edit { display: none; } +.reply-edit-form.editable textarea { border-color: #1e5149; box-shadow: 0 0 0 2px rgba(30, 81, 73, 0.1); } diff --git a/style/mail.css b/style/mail.css index 01a3c24..a5cbcfb 100644 --- a/style/mail.css +++ b/style/mail.css @@ -1,219 +1,219 @@ -/* Mail Manager Layout (Vertical Split) */ -.mail-wrapper { - display: flex; height: calc(100vh - var(--topbar-h)); - margin-top: var(--topbar-h); background: #fff; overflow: hidden; -} - -.mail-list-area { - width: 400px; border-right: 1px solid var(--border-color); - display: flex; flex-direction: column; height: 100%; background: #fff; position: relative; -} - -/* 1. Tabs & Search */ -.mail-tabs { display: flex; border-bottom: 1px solid var(--border-color); background: #f8f9fa; flex-shrink: 0; } -.mail-tab { - flex: 1; padding: 12px 0; text-align: center; cursor: pointer; - font-weight: 700; color: #a0aec0; font-size: 11px; transition: all 0.2s ease; - border-bottom: 2px solid transparent; display: flex; align-items: center; justify-content: center; gap: 6px; -} -.mail-tab:hover { background: #edf2f7; color: var(--primary-color); } -.mail-tab.active { color: var(--primary-color); border-bottom: 2px solid var(--primary-color); background: #fff; } - -.search-bar { padding: 16px 24px; border-bottom: 1px solid var(--border-color); background: #fff; flex-shrink: 0; } - -.mail-bulk-actions { - display: none; padding: 8px 16px; background: #f7fafc; - border-bottom: 1px solid var(--border-color); align-items: center; justify-content: space-between; font-size: 12px; -} -.mail-bulk-actions.active { display: flex; } - -/* 2. Mail Items */ -.mail-items-container { flex: 1; overflow-y: auto; padding-bottom: 60px; } -.mail-item { - padding: 16px; border-bottom: 1px solid var(--border-color); cursor: pointer; - display: flex; align-items: flex-start; transition: 0.2s; -} -.mail-item:hover { background: var(--bg-muted); } -.mail-item.active { background: var(--primary-lv-0); } - -.mail-item-checkbox { width: 16px; height: 16px; cursor: pointer; margin-right: 12px; margin-top: 2px; } -.mail-item-content { flex: 1; min-width: 0; } -.mail-item-info { display: flex; align-items: center; gap: 12px; margin-bottom: 4px; } -.mail-date { font-size: 11px; color: var(--text-sub); white-space: nowrap; } - -.btn-mail-delete { - background: #f7fafc; border: 1px solid var(--border-color); color: #718096; - font-size: 10px; padding: 2px 8px; border-radius: 4px; font-weight: 600; -} -.btn-mail-delete:hover { color: var(--error-color); background: #fff5f5; border-color: #feb2b2; } - -/* 3. Content Area */ -.mail-content-area { flex: 1; display: flex; flex-direction: column; overflow-y: auto; border-right: 1px solid var(--border-color); } -.mail-content-header { padding: var(--space-lg); border-bottom: 1px solid var(--border-color); } -.mail-body { padding: var(--space-lg); line-height: 1.6; min-height: 200px; } - -/* 4. Attachments & AI */ -.attachment-area { padding: var(--space-lg); border-top: 1px solid var(--border-color); background: var(--bg-muted); } -.attachment-item { - display: flex; align-items: center; gap: var(--space-md); background: #fff; - padding: 12px 20px; border-radius: var(--radius-lg); - border: 1px solid var(--border-color); margin-bottom: var(--space-sm); cursor: pointer; - transition: 0.2s; -} -.attachment-item:hover { border-color: var(--primary-color); box-shadow: var(--box-shadow); } -.attachment-item.active { background: var(--primary-lv-0); border-color: var(--primary-color); } - -.file-details { flex: 1; min-width: 0; } -.file-name { font-size: 13px; font-weight: 700; max-width: 450px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.file-size { font-size: 11px; color: var(--text-sub); } - -.btn-group { - display: flex; align-items: center; gap: 12px; flex-shrink: 0; justify-content: flex-end; -} - -.btn-upload { - padding: 6px 14px; border-radius: 6px; font-size: 11px; font-weight: 700; - color: #fff; border: none; cursor: pointer; transition: 0.2s; height: 32px; -} - -.btn-ai { background: var(--ai-gradient); } -.btn-ai:hover { filter: brightness(1.1); transform: translateY(-1px); } - -.btn-normal { background: var(--primary-color); } -.btn-normal:hover { background: var(--primary-hover); transform: translateY(-1px); } - -.ai-recommend { - font-size: 11px; padding: 6px 12px; border-radius: 6px; font-weight: 600; - cursor: pointer; transition: 0.2s; display: inline-block; - max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; -} -.ai-recommend.smart-mode { background: #eef2ff; color: #4338ca; border: 1px solid #c7d2fe; } -.ai-recommend.manual-mode { background: #f1f5f9; color: #475569; border: 1px dashed #cbd5e1; } -.ai-recommend:hover { transform: scale(1.02); } - -/* 5. Preview Area */ -.mail-preview-area { - width: 0; background: #f8f9fa; display: flex; flex-direction: column; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); position: relative; - border-left: 0 solid transparent; -} - -.mail-preview-area.active { - width: 600px; - border-left: 1px solid var(--border-color); - visibility: visible; -} - -.preview-header { - height: 56px; padding: 0 24px; border-bottom: 1px solid var(--border-color); - display: flex; align-items: center; justify-content: space-between; - background: #fff; flex-shrink: 0; -} - -.preview-header h3 { font-size: 15px; font-weight: 800; color: var(--primary-color); margin: 0; } - -#fullViewBtn { - background: var(--primary-lv-0) !important; - color: var(--primary-color) !important; - border: 1px solid var(--primary-lv-1) !important; - font-weight: 700 !important; - padding: 4px 16px !important; - border-radius: 4px !important; - font-size: 11px !important; - transition: 0.2s !important; -} -#fullViewBtn:hover { background: var(--primary-lv-1) !important; } - -.preview-toggle-handle { - position: absolute; left: -20px; top: 50%; transform: translateY(-50%); - width: 20px; height: 60px; background: var(--primary-color); color: #fff; - display: flex; align-items: center; justify-content: center; - border-radius: 8px 0 0 8px; font-size: 10px; cursor: pointer; - box-shadow: -2px 0 5px rgba(0,0,0,0.1); z-index: 100; -} -.preview-toggle-handle:hover { background: var(--primary-hover); } - -.a4-container { - flex: 1; padding: 30px; overflow-y: auto; background: #e9ecef; - display: flex; justify-content: center; -} - -.a4-container iframe, .a4-container .preview-placeholder { - width: 100%; height: 100%; background: #fff; - box-shadow: 0 4px 20px rgba(0,0,0,0.08); border-radius: 4px; -} - -.preview-placeholder { - display: flex; align-items: center; justify-content: center; - text-align: center; color: var(--text-sub); font-size: 13px; line-height: 1.6; -} - -.mail-preview-area.active > * { opacity: 1 !important; visibility: visible !important; pointer-events: auto !important; } -.mail-preview-area > *:not(.preview-toggle-handle) { opacity: 0; visibility: hidden; pointer-events: none; transition: 0.2s; } - -/* 6. Footer & Others */ -.address-book-footer { - position: absolute; bottom: 0; left: 0; width: 100%; padding: 12px 16px; - border-top: 1px solid var(--border-color); background: #fff; display: flex; gap: 8px; z-index: 5; -} - -.file-log-area { - display: none; width: 100%; margin-top: 10px; background: #1a202c; - border-radius: 4px; padding: 12px; font-family: monospace; font-size: 11px; color: #cbd5e0; -} -.file-log-area.active { display: block; } -.log-success { color: #48bb78; font-weight: 700; } - -.switch { position: relative; display: inline-block; width: 34px; height: 20px; } -.switch input { opacity: 0; width: 0; height: 0; } -.slider { - position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; - background-color: #ccc; transition: .4s; border-radius: 20px; -} -.slider:before { - position: absolute; content: ""; height: 14px; width: 14px; left: 3px; bottom: 3px; - background-color: white; transition: .4s; border-radius: 50%; -} -input:checked+.slider { background: var(--ai-gradient); } -input:checked+.slider:before { transform: translateX(14px); } - -/* Restore Path Selector Modal Specific Styles */ -.select-group { - display: flex; - flex-direction: column; - gap: 8px; -} - -.select-group label { - font-size: 12px; - font-weight: 700; - color: var(--text-main); -} - -.modal-select { - width: 100%; - height: 44px; - padding: 0 15px; - border: 1px solid var(--border-color); - border-radius: 8px; - background-color: #f9f9f9; - font-size: 14px; - color: #333; - outline: none; - transition: all 0.2s; - cursor: pointer; - appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L2 4h8L6 8z'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 15px center; -} - -.modal-select:focus { - border-color: var(--primary-color); - background-color: #fff; - box-shadow: 0 0 0 3px rgba(30, 81, 73, 0.1); -} - -.modal-select option { - padding: 10px; -} +/* Mail Manager Layout (Vertical Split) */ +.mail-wrapper { + display: flex; height: calc(100vh - var(--topbar-h)); + margin-top: var(--topbar-h); background: #fff; overflow: hidden; +} + +.mail-list-area { + width: 400px; border-right: 1px solid var(--border-color); + display: flex; flex-direction: column; height: 100%; background: #fff; position: relative; +} + +/* 1. Tabs & Search */ +.mail-tabs { display: flex; border-bottom: 1px solid var(--border-color); background: #f8f9fa; flex-shrink: 0; } +.mail-tab { + flex: 1; padding: 12px 0; text-align: center; cursor: pointer; + font-weight: 700; color: #a0aec0; font-size: 11px; transition: all 0.2s ease; + border-bottom: 2px solid transparent; display: flex; align-items: center; justify-content: center; gap: 6px; +} +.mail-tab:hover { background: #edf2f7; color: var(--primary-color); } +.mail-tab.active { color: var(--primary-color); border-bottom: 2px solid var(--primary-color); background: #fff; } + +.search-bar { padding: 16px 24px; border-bottom: 1px solid var(--border-color); background: #fff; flex-shrink: 0; } + +.mail-bulk-actions { + display: none; padding: 8px 16px; background: #f7fafc; + border-bottom: 1px solid var(--border-color); align-items: center; justify-content: space-between; font-size: 12px; +} +.mail-bulk-actions.active { display: flex; } + +/* 2. Mail Items */ +.mail-items-container { flex: 1; overflow-y: auto; padding-bottom: 60px; } +.mail-item { + padding: 16px; border-bottom: 1px solid var(--border-color); cursor: pointer; + display: flex; align-items: flex-start; transition: 0.2s; +} +.mail-item:hover { background: var(--bg-muted); } +.mail-item.active { background: var(--primary-lv-0); } + +.mail-item-checkbox { width: 16px; height: 16px; cursor: pointer; margin-right: 12px; margin-top: 2px; } +.mail-item-content { flex: 1; min-width: 0; } +.mail-item-info { display: flex; align-items: center; gap: 12px; margin-bottom: 4px; } +.mail-date { font-size: 11px; color: var(--text-sub); white-space: nowrap; } + +.btn-mail-delete { + background: #f7fafc; border: 1px solid var(--border-color); color: #718096; + font-size: 10px; padding: 2px 8px; border-radius: 4px; font-weight: 600; +} +.btn-mail-delete:hover { color: var(--error-color); background: #fff5f5; border-color: #feb2b2; } + +/* 3. Content Area */ +.mail-content-area { flex: 1; display: flex; flex-direction: column; overflow-y: auto; border-right: 1px solid var(--border-color); } +.mail-content-header { padding: var(--space-lg); border-bottom: 1px solid var(--border-color); } +.mail-body { padding: var(--space-lg); line-height: 1.6; min-height: 200px; } + +/* 4. Attachments & AI */ +.attachment-area { padding: var(--space-lg); border-top: 1px solid var(--border-color); background: var(--bg-muted); } +.attachment-item { + display: flex; align-items: center; gap: var(--space-md); background: #fff; + padding: 12px 20px; border-radius: var(--radius-lg); + border: 1px solid var(--border-color); margin-bottom: var(--space-sm); cursor: pointer; + transition: 0.2s; +} +.attachment-item:hover { border-color: var(--primary-color); box-shadow: var(--box-shadow); } +.attachment-item.active { background: var(--primary-lv-0); border-color: var(--primary-color); } + +.file-details { flex: 1; min-width: 0; } +.file-name { font-size: 13px; font-weight: 700; max-width: 450px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.file-size { font-size: 11px; color: var(--text-sub); } + +.btn-group { + display: flex; align-items: center; gap: 12px; flex-shrink: 0; justify-content: flex-end; +} + +.btn-upload { + padding: 6px 14px; border-radius: 6px; font-size: 11px; font-weight: 700; + color: #fff; border: none; cursor: pointer; transition: 0.2s; height: 32px; +} + +.btn-ai { background: var(--ai-gradient); } +.btn-ai:hover { filter: brightness(1.1); transform: translateY(-1px); } + +.btn-normal { background: var(--primary-color); } +.btn-normal:hover { background: var(--primary-hover); transform: translateY(-1px); } + +.ai-recommend { + font-size: 11px; padding: 6px 12px; border-radius: 6px; font-weight: 600; + cursor: pointer; transition: 0.2s; display: inline-block; + max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.ai-recommend.smart-mode { background: #eef2ff; color: #4338ca; border: 1px solid #c7d2fe; } +.ai-recommend.manual-mode { background: #f1f5f9; color: #475569; border: 1px dashed #cbd5e1; } +.ai-recommend:hover { transform: scale(1.02); } + +/* 5. Preview Area */ +.mail-preview-area { + width: 0; background: #f8f9fa; display: flex; flex-direction: column; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); position: relative; + border-left: 0 solid transparent; +} + +.mail-preview-area.active { + width: 600px; + border-left: 1px solid var(--border-color); + visibility: visible; +} + +.preview-header { + height: 56px; padding: 0 24px; border-bottom: 1px solid var(--border-color); + display: flex; align-items: center; justify-content: space-between; + background: #fff; flex-shrink: 0; +} + +.preview-header h3 { font-size: 15px; font-weight: 800; color: var(--primary-color); margin: 0; } + +#fullViewBtn { + background: var(--primary-lv-0) !important; + color: var(--primary-color) !important; + border: 1px solid var(--primary-lv-1) !important; + font-weight: 700 !important; + padding: 4px 16px !important; + border-radius: 4px !important; + font-size: 11px !important; + transition: 0.2s !important; +} +#fullViewBtn:hover { background: var(--primary-lv-1) !important; } + +.preview-toggle-handle { + position: absolute; left: -20px; top: 50%; transform: translateY(-50%); + width: 20px; height: 60px; background: var(--primary-color); color: #fff; + display: flex; align-items: center; justify-content: center; + border-radius: 8px 0 0 8px; font-size: 10px; cursor: pointer; + box-shadow: -2px 0 5px rgba(0,0,0,0.1); z-index: 100; +} +.preview-toggle-handle:hover { background: var(--primary-hover); } + +.a4-container { + flex: 1; padding: 30px; overflow-y: auto; background: #e9ecef; + display: flex; justify-content: center; +} + +.a4-container iframe, .a4-container .preview-placeholder { + width: 100%; height: 100%; background: #fff; + box-shadow: 0 4px 20px rgba(0,0,0,0.08); border-radius: 4px; +} + +.preview-placeholder { + display: flex; align-items: center; justify-content: center; + text-align: center; color: var(--text-sub); font-size: 13px; line-height: 1.6; +} + +.mail-preview-area.active > * { opacity: 1 !important; visibility: visible !important; pointer-events: auto !important; } +.mail-preview-area > *:not(.preview-toggle-handle) { opacity: 0; visibility: hidden; pointer-events: none; transition: 0.2s; } + +/* 6. Footer & Others */ +.address-book-footer { + position: absolute; bottom: 0; left: 0; width: 100%; padding: 12px 16px; + border-top: 1px solid var(--border-color); background: #fff; display: flex; gap: 8px; z-index: 5; +} + +.file-log-area { + display: none; width: 100%; margin-top: 10px; background: #1a202c; + border-radius: 4px; padding: 12px; font-family: monospace; font-size: 11px; color: #cbd5e0; +} +.file-log-area.active { display: block; } +.log-success { color: #48bb78; font-weight: 700; } + +.switch { position: relative; display: inline-block; width: 34px; height: 20px; } +.switch input { opacity: 0; width: 0; height: 0; } +.slider { + position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; + background-color: #ccc; transition: .4s; border-radius: 20px; +} +.slider:before { + position: absolute; content: ""; height: 14px; width: 14px; left: 3px; bottom: 3px; + background-color: white; transition: .4s; border-radius: 50%; +} +input:checked+.slider { background: var(--ai-gradient); } +input:checked+.slider:before { transform: translateX(14px); } + +/* Restore Path Selector Modal Specific Styles */ +.select-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.select-group label { + font-size: 12px; + font-weight: 700; + color: var(--text-main); +} + +.modal-select { + width: 100%; + height: 44px; + padding: 0 15px; + border: 1px solid var(--border-color); + border-radius: 8px; + background-color: #f9f9f9; + font-size: 14px; + color: #333; + outline: none; + transition: all 0.2s; + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L2 4h8L6 8z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 15px center; +} + +.modal-select:focus { + border-color: var(--primary-color); + background-color: #fff; + box-shadow: 0 0 0 3px rgba(30, 81, 73, 0.1); +} + +.modal-select option { + padding: 10px; +} diff --git a/style/style.css b/style/style.css index 21bee0b..4b1d736 100644 --- a/style/style.css +++ b/style/style.css @@ -1,3 +1,3 @@ -@import url('common.css'); -@import url('dashboard.css'); -@import url('mail.css'); +@import url('common.css'); +@import url('dashboard.css'); +@import url('mail.css'); diff --git a/templates/analysis.html b/templates/analysis.html index beb6c26..1edc0ab 100644 --- a/templates/analysis.html +++ b/templates/analysis.html @@ -1,139 +1,139 @@ - - - - - - - 데이터 분석 - Project Master Sabermetrics - - - - - - - - - -
-
-
-
AI Sabermetrics
-

시스템 운영 자산 가치 분석

-

수집된 활동 로그 및 자산 데이터를 기반으로 한 통계적 활력 지표 (Beta)

-
-
- -
-
- - -
-
-
-

AI Hybrid Prediction Engine

-
-
-
-
- 분석 모델 -

최근 9회차 시계열의 Velocity 및 변화율 분석

-
-
- 가중치 로직 -

활동 시 '선형 유지', 정체 시 '지수 감쇄' 동적 적용

-
-
-
-
- -
-
-

i AI 위험 적응형 모델 (AAS) 기반 지표 정의

-
-
-
-
-
1. 자산 가치 변동 추적
-

규모를 감지하여, 대형 프로젝트 정체 시 데이터 가치 하락 속도를 가속(Acceleration)시킵니다.

-
-
-
2. 활동 시계열 관성 분석
-

최근 활동의 연속성을 분석하여, 단기 정체 시에도 과거의 운영 모멘텀을 반영하여 지수를 보정합니다.

-
-
-
3. 동적 가치 계수
-

프로젝트마다 개별화된 감쇄 곡선을 생성하여 현장에 가장 밀착된 보존율을 제공합니다.

-
-
-
-
-
- - -
-
-
운영 활력 분포 (Activity Distribution)
- -
-
-
자산 가치 전략 매트릭스 (Strategic Analysis)
- -
-
- - -
-
-
-

Project Activity Vitality Leaderboard (AVI Status)

-

운영 표준(AVI 70%) 대비 운영 활력 및 VCI 기여 리더보드

-
-
- * AVI (Activity Vitality Index) -
-
-
-
-
70%↑ 정상 운영
-
30~70% 관리 주의
-
10~30% 위험 노출
-
10%↓ 중단/방치
-
- -
- -
-
-
-
- - - - - - - - - + + + + + + + 데이터 분석 - Project Master Sabermetrics + + + + + + + + + +
+
+
+
AI Sabermetrics
+

시스템 운영 자산 가치 분석

+

수집된 활동 로그 및 자산 데이터를 기반으로 한 통계적 활력 지표 (Beta)

+
+
+ +
+
+ + +
+
+
+

AI Hybrid Prediction Engine

+
+
+
+
+ 분석 모델 +

최근 9회차 시계열의 Velocity 및 변화율 분석

+
+
+ 가중치 로직 +

활동 시 '선형 유지', 정체 시 '지수 감쇄' 동적 적용

+
+
+
+
+ +
+
+

i AI 위험 적응형 모델 (AAS) 기반 지표 정의

+
+
+
+
+
1. 자산 가치 변동 추적
+

규모를 감지하여, 대형 프로젝트 정체 시 데이터 가치 하락 속도를 가속(Acceleration)시킵니다.

+
+
+
2. 활동 시계열 관성 분석
+

최근 활동의 연속성을 분석하여, 단기 정체 시에도 과거의 운영 모멘텀을 반영하여 지수를 보정합니다.

+
+
+
3. 동적 가치 계수
+

프로젝트마다 개별화된 감쇄 곡선을 생성하여 현장에 가장 밀착된 보존율을 제공합니다.

+
+
+
+
+
+ + +
+
+
운영 활력 분포 (Activity Distribution)
+ +
+
+
자산 가치 전략 매트릭스 (Strategic Analysis)
+ +
+
+ + +
+
+
+

Project Activity Vitality Leaderboard (AVI Status)

+

전체 포트폴리오 평균(0.0) 대비 운영 가치 기여(VCI) 리더보드

+
+
+ * AVI (Activity Vitality Index) +
+
+
+
+
70%↑ 정상 운영
+
30~70% 관리 주의
+
10~30% 위험 노출
+
10%↓ 중단/방치
+
+ +
+ +
+
+
+
+ + + + + + + + + diff --git a/templates/analysis_test.html b/templates/analysis_test.html new file mode 100644 index 0000000..47fcef4 --- /dev/null +++ b/templates/analysis_test.html @@ -0,0 +1,139 @@ + + + + + + + 데이터 분석 (테스트) - Project Master Sabermetrics + + + + + + + + + +
+
+
+
AI Sabermetrics [TEST]
+

시스템 운영 자산 가치 분석 (테스트 환경)

+

테스트용 데이터베이스(PM_proto_test)를 기반으로 한 활력 지표 분석입니다.

+
+
+ +
+
+ + +
+
+
+

AI Hybrid Prediction Engine (TEST)

+
+
+
+
+ 분석 모델 +

최근 9회차 시계열의 Velocity 및 변화율 분석

+
+
+ 가중치 로직 +

활동 시 '선형 유지', 정체 시 '지수 감쇄' 동적 적용

+
+
+
+
+ +
+
+

i AI 위험 적응형 모델 (AAS) 기반 지표 정의

+
+
+
+
+
1. 자산 가치 변동 추적
+

규모를 감지하여, 대형 프로젝트 정체 시 데이터 가치 하락 속도를 가속(Acceleration)시킵니다.

+
+
+
2. 활동 시계열 관성 분석
+

최근 활동의 연속성을 분석하여, 단기 정체 시에도 과거의 운영 모멘텀을 반영하여 지수를 보정합니다.

+
+
+
3. 동적 가치 계수
+

프로젝트마다 개별화된 감쇄 곡선을 생성하여 현장에 가장 밀착된 보존율을 제공합니다.

+
+
+
+
+
+ + +
+
+
운영 활력 분포 (Activity Distribution)
+ +
+
+
자산 가치 전략 매트릭스 (Strategic Analysis)
+ +
+
+ + +
+
+
+

Project Activity Vitality Leaderboard (AVI Status) [TEST]

+

전체 포트폴리오 평균(0.0) 대비 운영 가치 기여(VCI) 리더보드

+
+
+ * AVI (Activity Vitality Index) +
+
+
+
+
70%↑ 정상 운영
+
30~70% 관리 주의
+
10~30% 위험 노출
+
10%↓ 중단/방치
+
+ +
+ +
+
+
+
+ + + + + + + + + diff --git a/templates/dashboard.html b/templates/dashboard.html index 5eb189b..2edd734 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -1,118 +1,118 @@ - - - - - - - Project Master Overseas 관리자 - - - - - - - - - -
-
-

프로젝트 현황

-
-
기준날짜: -
- -
접속자: 이태훈[전체관리자]
-
-
- - -
-
- -
-
- - - - -
- -
-
- - - - - - - - - - + + + + + + + Project Master Overseas 관리자 + + + + + + + + + +
+
+

프로젝트 현황

+
+
기준날짜: -
+ +
접속자: 이태훈[전체관리자]
+
+
+ + +
+
+ +
+
+ + + + +
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/templates/dashboard_test.html b/templates/dashboard_test.html new file mode 100644 index 0000000..8042b28 --- /dev/null +++ b/templates/dashboard_test.html @@ -0,0 +1,118 @@ + + + + + + + Project Master Overseas 관리자 (테스트) + + + + + + + + + +
+
+

프로젝트 현황 (테스트 환경)

+
+
기준날짜: -
+ +
접속자: 이태훈[테스트관리자]
+
+
+ + +
+
+ +
+
+ + + + +
+ +
+
+ + + + + + + + + + + \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index b3972f6..a51e5b2 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,46 +1,58 @@ - - - - - - - Project Master Portal - - - - - - - - - -
-
-

Project Master 테스트

-

원하시는 서비스에 접속하려면 아래 버튼을 클릭하세요.

-
- - -
- - - - + + + + + + + Project Master Portal + + + + + + + + + + + + + + \ No newline at end of file diff --git a/templates/inquiries.html b/templates/inquiries.html index 3b39ca1..110837f 100644 --- a/templates/inquiries.html +++ b/templates/inquiries.html @@ -1,194 +1,194 @@ - - - - - - - 문의사항 관리 - Project Master - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - -
-
No
-
이미지 -
PM 종류
-
-
환경
-
-
구분
-
-
프로젝트
-
문의내용 -
작성자
-
-
날짜
-
답변내용 -
상태
-
-
- - - - - - - - + + + + + + + 문의사항 관리 - Project Master + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+
No
+
이미지 +
PM 종류
+
+
환경
+
+
구분
+
+
프로젝트
+
문의내용 +
작성자
+
+
날짜
+
답변내용 +
상태
+
+
+ + + + + + + + \ No newline at end of file diff --git a/templates/mailTest.html b/templates/mailTest.html index 4f98cdd..8369ea6 100644 --- a/templates/mailTest.html +++ b/templates/mailTest.html @@ -1,144 +1,144 @@ - - - - - - - Project Mail Manager - - - - - - - {% include 'modals/path_selector.html' %} - - - -
- -
-
-
📥 수신
-
📤 발신
-
📝 임시
-
🗑️ 휴지통
-
- - - -
-
- - 0개 선택됨 -
- -
- -
- -
- - -
- - -
-
-

ITTC 교육센터 착공식 일정 협의 요청

-
보낸사람 pany.s@lao.gov.la (라오스 농림부) -
-
날짜 2026년 2월 26일 14:30
-
-
- 안녕하세요. 이태훈 선임연구원님.

- 라오스 ITTC 관개 교육센터 착공식과 관련하여 정부 측 인사의 일정을 반영한 최종 공문을 송부합니다.
-
- -
-
-
첨부파일 리스트
-
- AI 판단 - -
-
-
-
-
- - - -
- - {% include 'modals/address_book.html' %} - - - - - + + + + + + + Project Mail Manager + + + + + + + {% include 'modals/path_selector.html' %} + + + +
+ +
+
+
📥 수신
+
📤 발신
+
📝 임시
+
🗑️ 휴지통
+
+ + + +
+
+ + 0개 선택됨 +
+ +
+ +
+ +
+ + +
+ + +
+
+

ITTC 교육센터 착공식 일정 협의 요청

+
보낸사람 pany.s@lao.gov.la (라오스 농림부) +
+
날짜 2026년 2월 26일 14:30
+
+
+ 안녕하세요. 이태훈 선임연구원님.

+ 라오스 ITTC 관개 교육센터 착공식과 관련하여 정부 측 인사의 일정을 반영한 최종 공문을 송부합니다.
+
+ +
+
+
첨부파일 리스트
+
+ AI 판단 + +
+
+
+
+
+ + + +
+ + {% include 'modals/address_book.html' %} + + + + + \ No newline at end of file diff --git a/templates/modals/address_book.html b/templates/modals/address_book.html index 4fc6c7d..a84954c 100644 --- a/templates/modals/address_book.html +++ b/templates/modals/address_book.html @@ -1,62 +1,62 @@ - - + + diff --git a/templates/modals/path_selector.html b/templates/modals/path_selector.html index 38e49c6..bd38eb9 100644 --- a/templates/modals/path_selector.html +++ b/templates/modals/path_selector.html @@ -1,24 +1,24 @@ - - + + diff --git a/tokens.json b/tokens.json index 6e99e9a..e503fa1 100644 --- a/tokens.json +++ b/tokens.json @@ -1,1229 +1,1229 @@ -{ - "core": { - "dimension": { - "scale": { - "$value": "2", - "$type": "dimension" - }, - "xs": { - "$value": "4", - "$type": "dimension" - }, - "sm": { - "$value": "{dimension.xs} * {dimension.scale}", - "$type": "dimension" - }, - "md": { - "$value": "{dimension.sm} * {dimension.scale}", - "$type": "dimension" - }, - "lg": { - "$value": "{dimension.md} * {dimension.scale}", - "$type": "dimension" - }, - "xl": { - "$value": "{dimension.lg} * {dimension.scale}", - "$type": "dimension" - } - }, - "spacing": { - "xs": { - "$value": "{dimension.xs}", - "$type": "spacing" - }, - "sm": { - "$value": "{dimension.sm}", - "$type": "spacing" - }, - "md": { - "$value": "{dimension.md}", - "$type": "spacing" - }, - "lg": { - "$value": "{dimension.lg}", - "$type": "spacing" - }, - "xl": { - "$value": "{dimension.xl}", - "$type": "spacing" - }, - "multi-value": { - "$value": "{dimension.sm} {dimension.xl}", - "$type": "spacing", - "$description": "You can have multiple values in a single spacing token. Read more on these: https://docs.tokens.studio/available-tokens/spacing-tokens#multi-value-spacing-tokens" - } - }, - "borderRadius": { - "sm": { - "$value": "4", - "$type": "borderRadius" - }, - "lg": { - "$value": "8", - "$type": "borderRadius" - }, - "xl": { - "$value": "16", - "$type": "borderRadius" - }, - "multi-value": { - "$value": "{borderRadius.sm} {borderRadius.lg}", - "$type": "borderRadius", - "$description": "You can have multiple values in a single radius token. Read more on these: https://docs.tokens.studio/available-tokens/border-radius-tokens#single--multiple-values" - } - }, - "colors": { - "black": { - "$value": "#000000", - "$type": "color" - }, - "white": { - "$value": "#ffffff", - "$type": "color" - }, - "gray": { - "100": { - "$value": "#f7fafc", - "$type": "color" - }, - "200": { - "$value": "#edf2f7", - "$type": "color" - }, - "300": { - "$value": "#e2e8f0", - "$type": "color" - }, - "400": { - "$value": "#cbd5e0", - "$type": "color" - }, - "500": { - "$value": "#a0aec0", - "$type": "color" - }, - "600": { - "$value": "#718096", - "$type": "color" - }, - "700": { - "$value": "#4a5568", - "$type": "color" - }, - "800": { - "$value": "#2d3748", - "$type": "color" - }, - "900": { - "$value": "#1a202c", - "$type": "color" - } - }, - "red": { - "100": { - "$value": "#fff5f5", - "$type": "color" - }, - "200": { - "$value": "#fed7d7", - "$type": "color" - }, - "300": { - "$value": "#feb2b2", - "$type": "color" - }, - "400": { - "$value": "#fc8181", - "$type": "color" - }, - "500": { - "$value": "#f56565", - "$type": "color" - }, - "600": { - "$value": "#e53e3e", - "$type": "color" - }, - "700": { - "$value": "#c53030", - "$type": "color" - }, - "800": { - "$value": "#9b2c2c", - "$type": "color" - }, - "900": { - "$value": "#742a2a", - "$type": "color" - } - }, - "orange": { - "100": { - "$value": "#fffaf0", - "$type": "color" - }, - "200": { - "$value": "#feebc8", - "$type": "color" - }, - "300": { - "$value": "#fbd38d", - "$type": "color" - }, - "400": { - "$value": "#f6ad55", - "$type": "color" - }, - "500": { - "$value": "#ed8936", - "$type": "color" - }, - "600": { - "$value": "#dd6b20", - "$type": "color" - }, - "700": { - "$value": "#c05621", - "$type": "color" - }, - "800": { - "$value": "#9c4221", - "$type": "color" - }, - "900": { - "$value": "#7b341e", - "$type": "color" - } - }, - "yellow": { - "100": { - "$value": "#fffff0", - "$type": "color" - }, - "200": { - "$value": "#fefcbf", - "$type": "color" - }, - "300": { - "$value": "#faf089", - "$type": "color" - }, - "400": { - "$value": "#f6e05e", - "$type": "color" - }, - "500": { - "$value": "#ecc94b", - "$type": "color" - }, - "600": { - "$value": "#d69e2e", - "$type": "color" - }, - "700": { - "$value": "#b7791f", - "$type": "color" - }, - "800": { - "$value": "#975a16", - "$type": "color" - }, - "900": { - "$value": "#744210", - "$type": "color" - } - }, - "green": { - "100": { - "$value": "#f0fff4", - "$type": "color" - }, - "200": { - "$value": "#c6f6d5", - "$type": "color" - }, - "300": { - "$value": "#9ae6b4", - "$type": "color" - }, - "400": { - "$value": "#68d391", - "$type": "color" - }, - "500": { - "$value": "#48bb78", - "$type": "color" - }, - "600": { - "$value": "#38a169", - "$type": "color" - }, - "700": { - "$value": "#2f855a", - "$type": "color" - }, - "800": { - "$value": "#276749", - "$type": "color" - }, - "900": { - "$value": "#22543d", - "$type": "color" - } - }, - "teal": { - "100": { - "$value": "#e6fffa", - "$type": "color" - }, - "200": { - "$value": "#b2f5ea", - "$type": "color" - }, - "300": { - "$value": "#81e6d9", - "$type": "color" - }, - "400": { - "$value": "#4fd1c5", - "$type": "color" - }, - "500": { - "$value": "#38b2ac", - "$type": "color" - }, - "600": { - "$value": "#319795", - "$type": "color" - }, - "700": { - "$value": "#2c7a7b", - "$type": "color" - }, - "800": { - "$value": "#285e61", - "$type": "color" - }, - "900": { - "$value": "#234e52", - "$type": "color" - } - }, - "blue": { - "100": { - "$value": "#ebf8ff", - "$type": "color" - }, - "200": { - "$value": "#bee3f8", - "$type": "color" - }, - "300": { - "$value": "#90cdf4", - "$type": "color" - }, - "400": { - "$value": "#63b3ed", - "$type": "color" - }, - "500": { - "$value": "#4299e1", - "$type": "color" - }, - "600": { - "$value": "#3182ce", - "$type": "color" - }, - "700": { - "$value": "#2b6cb0", - "$type": "color" - }, - "800": { - "$value": "#2c5282", - "$type": "color" - }, - "900": { - "$value": "#2a4365", - "$type": "color" - } - }, - "indigo": { - "100": { - "$value": "#ebf4ff", - "$type": "color" - }, - "200": { - "$value": "#c3dafe", - "$type": "color" - }, - "300": { - "$value": "#a3bffa", - "$type": "color" - }, - "400": { - "$value": "#7f9cf5", - "$type": "color" - }, - "500": { - "$value": "#667eea", - "$type": "color" - }, - "600": { - "$value": "#5a67d8", - "$type": "color" - }, - "700": { - "$value": "#4c51bf", - "$type": "color" - }, - "800": { - "$value": "#434190", - "$type": "color" - }, - "900": { - "$value": "#3c366b", - "$type": "color" - } - }, - "purple": { - "100": { - "$value": "#faf5ff", - "$type": "color" - }, - "200": { - "$value": "#e9d8fd", - "$type": "color" - }, - "300": { - "$value": "#d6bcfa", - "$type": "color" - }, - "400": { - "$value": "#b794f4", - "$type": "color" - }, - "500": { - "$value": "#9f7aea", - "$type": "color" - }, - "600": { - "$value": "#805ad5", - "$type": "color" - }, - "700": { - "$value": "#6b46c1", - "$type": "color" - }, - "800": { - "$value": "#553c9a", - "$type": "color" - }, - "900": { - "$value": "#44337a", - "$type": "color" - } - }, - "pink": { - "100": { - "$value": "#fff5f7", - "$type": "color" - }, - "200": { - "$value": "#fed7e2", - "$type": "color" - }, - "300": { - "$value": "#fbb6ce", - "$type": "color" - }, - "400": { - "$value": "#f687b3", - "$type": "color" - }, - "500": { - "$value": "#ed64a6", - "$type": "color" - }, - "600": { - "$value": "#d53f8c", - "$type": "color" - }, - "700": { - "$value": "#b83280", - "$type": "color" - }, - "800": { - "$value": "#97266d", - "$type": "color" - }, - "900": { - "$value": "#702459", - "$type": "color" - } - } - }, - "opacity": { - "low": { - "$value": "10%", - "$type": "opacity" - }, - "md": { - "$value": "50%", - "$type": "opacity" - }, - "high": { - "$value": "90%", - "$type": "opacity" - } - }, - "fontFamilies": { - "heading": { - "$value": "Inter", - "$type": "fontFamilies" - }, - "body": { - "$value": "Roboto", - "$type": "fontFamilies" - }, - "pretendard": { - "$value": "Pretendard", - "$type": "fontFamilies" - } - }, - "lineHeights": { - "0": { - "$value": 20, - "$type": "lineHeights" - }, - "1": { - "$value": 20, - "$type": "lineHeights" - }, - "2": { - "$value": 20, - "$type": "lineHeights" - }, - "3": { - "$value": 20, - "$type": "lineHeights" - }, - "4": { - "$value": 20, - "$type": "lineHeights" - }, - "5": { - "$value": 20, - "$type": "lineHeights" - }, - "6": { - "$value": 20, - "$type": "lineHeights" - }, - "heading": { - "$value": "110%", - "$type": "lineHeights" - }, - "body": { - "$value": "140%", - "$type": "lineHeights" - } - }, - "letterSpacing": { - "0": { - "$value": "-2%", - "$type": "letterSpacing" - }, - "default": { - "$value": "0", - "$type": "letterSpacing" - }, - "increased": { - "$value": "150%", - "$type": "letterSpacing" - }, - "decreased": { - "$value": "-5%", - "$type": "letterSpacing" - } - }, - "paragraphSpacing": { - "0": { - "$value": 0, - "$type": "paragraphSpacing" - }, - "h1": { - "$value": "32", - "$type": "paragraphSpacing" - }, - "h2": { - "$value": "26", - "$type": "paragraphSpacing" - } - }, - "fontWeights": { - "headingRegular": { - "$value": "Regular", - "$type": "fontWeights" - }, - "headingBold": { - "$value": "Bold", - "$type": "fontWeights" - }, - "bodyRegular": { - "$value": "Regular", - "$type": "fontWeights" - }, - "bodyBold": { - "$value": "Bold", - "$type": "fontWeights" - }, - "pretendard-0": { - "$value": "ExtraBold", - "$type": "fontWeights" - }, - "pretendard-1": { - "$value": "SemiBold", - "$type": "fontWeights" - }, - "pretendard-2": { - "$value": "Regular", - "$type": "fontWeights" - } - }, - "fontSizes": { - "h1": { - "$value": "roundTo({fontSizes.body}*1.25^5)", - "$type": "fontSizes" - }, - "h2": { - "$value": "roundTo({fontSizes.body}*1.25^4)", - "$type": "fontSizes" - }, - "h3": { - "$value": "roundTo({fontSizes.body}*1.25^3)", - "$type": "fontSizes" - }, - "h4": { - "$value": "roundTo({fontSizes.body}*1.25^2)", - "$type": "fontSizes" - }, - "h5": { - "$value": "roundTo({fontSizes.body}*1.25^1)", - "$type": "fontSizes" - }, - "h6": { - "$value": "{fontSizes.body}", - "$type": "fontSizes" - }, - "body": { - "$value": "16", - "$type": "fontSizes" - }, - "sm": { - "$value": "{fontSizes.body} * 0.85", - "$type": "fontSizes" - }, - "xs": { - "$value": "{fontSizes.body} * 0.65", - "$type": "fontSizes" - } - }, - "ai_color": { - "$value": "linear-gradient(180deg, #da8cf1 0%, #8bb1f2 100%)", - "$type": "color" - }, - "primary-lv-0": { - "$value": "#e9eeed", - "$type": "color" - }, - "primary-lv-1": { - "$value": "#d2dcdb", - "$type": "color" - }, - "primary-lv-2": { - "$value": "#a5b9b6", - "$type": "color" - }, - "primary-lv-3": { - "$value": "#789792", - "$type": "color" - }, - "primary-lv-4": { - "$value": "#4b746d", - "$type": "color" - }, - "primary-lv-5": { - "$value": "#35635c", - "$type": "color" - }, - "primary-lv-6": { - "$value": "#1e5149", - "$type": "color" - }, - "primary-lv-7": { - "$value": "#1b443d", - "$type": "color" - }, - "primary-lv-8": { - "$value": "#193833", - "$type": "color" - }, - "primary-lv-9": { - "$value": "#162a27", - "$type": "color" - }, - "color-red": { - "$value": "#f21d0d", - "$type": "color" - }, - "color-pink": { - "$value": "#e8175e", - "$type": "color" - }, - "color-magenta": { - "$value": "#b92ed1", - "$type": "color" - }, - "color-purple": { - "$value": "#6d3dc2", - "$type": "color" - }, - "color-navy": { - "$value": "#4255bd", - "$type": "color" - }, - "color-blue": { - "$value": "#0d8df2", - "$type": "color" - }, - "color-cyan": { - "$value": "#03aefc", - "$type": "color" - }, - "color-green": { - "$value": "#4db251", - "$type": "color" - }, - "color-yellow": { - "$value": "#ffbf00", - "$type": "color" - }, - "color-orange": { - "$value": "#ff9800", - "$type": "color" - }, - "color-dahong": { - "$value": "#ff3d00", - "$type": "color" - }, - "color-brown": { - "$value": "#a0705f", - "$type": "color" - }, - "color-iron": { - "$value": "#7f7f7f", - "$type": "color" - }, - "color-steel": { - "$value": "#688897", - "$type": "color" - }, - "color-red-light": { - "$value": "#fee9e7", - "$type": "color" - }, - "color-pink-light": { - "$value": "#fde8ef", - "$type": "color" - }, - "color-magenta-light": { - "$value": "#f8ebfb", - "$type": "color" - }, - "color-purple-light": { - "$value": "#f1ecf9", - "$type": "color" - }, - "color-navy-light": { - "$value": "#edeef9", - "$type": "color" - }, - "color-blue-light": { - "$value": "#e7f4fe", - "$type": "color" - }, - "color-cyan-light": { - "$value": "#e6f7ff", - "$type": "color" - }, - "color-green-light": { - "$value": "#eef8ee", - "$type": "color" - }, - "color-yellow-light": { - "$value": "#fff9e6", - "$type": "color" - }, - "color-orange-light": { - "$value": "#fff5e6", - "$type": "color" - }, - "color-dahong-light": { - "$value": "#ffece6", - "$type": "color" - }, - "color-brown-light": { - "$value": "#f6f1ef", - "$type": "color" - }, - "color-iron-light": { - "$value": "#f3f3f3", - "$type": "color" - }, - "color-steel-light": { - "$value": "#f0f4f5", - "$type": "color" - }, - "color-red-medium": { - "$value": "#faa59e", - "$type": "color" - }, - "color-pink-medium": { - "$value": "#f6a2bf", - "$type": "color" - }, - "color-magenta-medium": { - "$value": "#e3abec", - "$type": "color" - }, - "color-purple-medium": { - "$value": "#c5b1e7", - "$type": "color" - }, - "color-navy-medium": { - "$value": "#b3bbe5", - "$type": "color" - }, - "color-blue-medium": { - "$value": "#9ed1fa", - "$type": "color" - }, - "color-cyan-medium": { - "$value": "#9adffe", - "$type": "color" - }, - "color-green-medium": { - "$value": "#b8e0b9", - "$type": "color" - }, - "color-yellow-medium": { - "$value": "#ffe599", - "$type": "color" - }, - "color-orange-medium": { - "$value": "#ffd699", - "$type": "color" - }, - "color-dahong-medium": { - "$value": "#ffb199", - "$type": "color" - }, - "color-brown-medium": { - "$value": "#d9c6bf", - "$type": "color" - }, - "color-iron-medium": { - "$value": "#cccccc", - "$type": "color" - }, - "color-steel-medium": { - "$value": "#c3cfd5", - "$type": "color" - }, - "checked-color-background": { - "$value": "#03aefc1a", - "$type": "color" - }, - "headercolor": { - "$value": "linear-gradient(90deg, #193833 0%, #1e5149 100%)", - "$type": "color" - }, - "line-style": { - "$value": "linear-gradient(135deg, #ffffff 0%, #0000001a 50%, #ffffff 100%)", - "$type": "color" - }, - "box__drop-shadow": { - "$value": { - "color": "#00000029", - "type": "dropShadow", - "x": 0, - "y": 8, - "blur": 24, - "spread": 0 - }, - "$type": "boxShadow" - }, - "fontSize": { - "0": { - "$value": 12, - "$type": "fontSizes" - }, - "1": { - "$value": 14, - "$type": "fontSizes" - }, - "2": { - "$value": 16, - "$type": "fontSizes" - }, - "3": { - "$value": 20, - "$type": "fontSizes" - } - }, - "

": { - "$value": { - "fontFamily": "{fontFamilies.pretendard}", - "fontWeight": "{fontWeights.pretendard-0}", - "lineHeight": "{lineHeights.0}", - "fontSize": "{fontSize.3}", - "letterSpacing": "{letterSpacing.0}", - "paragraphSpacing": "{paragraphSpacing.0}", - "paragraphIndent": "{paragraphIndent.0}", - "textCase": "{textCase.none}", - "textDecoration": "{textDecoration.none}" - }, - "$type": "typography" - }, - "

": { - "$value": { - "fontFamily": "{fontFamilies.pretendard}", - "fontWeight": "{fontWeights.pretendard-1}", - "lineHeight": "{lineHeights.0}", - "fontSize": "{fontSize.2}", - "letterSpacing": "{letterSpacing.0}", - "paragraphSpacing": "{paragraphSpacing.0}", - "paragraphIndent": "{paragraphIndent.0}", - "textCase": "{textCase.none}", - "textDecoration": "{textDecoration.none}" - }, - "$type": "typography" - }, - "

": { - "$value": { - "fontFamily": "{fontFamilies.pretendard}", - "fontWeight": "{fontWeights.pretendard-1}", - "lineHeight": "{lineHeights.0}", - "fontSize": "{fontSize.1}", - "letterSpacing": "{letterSpacing.0}", - "paragraphSpacing": "{paragraphSpacing.0}", - "paragraphIndent": "{paragraphIndent.0}", - "textCase": "{textCase.none}", - "textDecoration": "{textDecoration.none}" - }, - "$type": "typography" - }, - "

": { - "$value": { - "fontFamily": "{fontFamilies.pretendard}", - "fontWeight": "{fontWeights.pretendard-2}", - "lineHeight": "{lineHeights.0}", - "fontSize": "{fontSize.1}", - "letterSpacing": "{letterSpacing.0}", - "paragraphSpacing": "{paragraphSpacing.0}", - "paragraphIndent": "{paragraphIndent.0}", - "textCase": "{textCase.none}", - "textDecoration": "{textDecoration.none}" - }, - "$type": "typography" - }, - "

": { - "$value": { - "fontFamily": "{fontFamilies.pretendard}", - "fontWeight": "{fontWeights.pretendard-1}", - "lineHeight": "{lineHeights.0}", - "fontSize": "{fontSize.0}", - "letterSpacing": "{letterSpacing.0}", - "paragraphSpacing": "{paragraphSpacing.0}", - "paragraphIndent": "{paragraphIndent.0}", - "textCase": "{textCase.none}", - "textDecoration": "{textDecoration.none}" - }, - "$type": "typography" - }, - "
": { - "$value": { - "fontFamily": "{fontFamilies.pretendard}", - "fontWeight": "{fontWeights.pretendard-2}", - "lineHeight": "{lineHeights.0}", - "fontSize": "{fontSize.0}", - "letterSpacing": "{letterSpacing.0}", - "paragraphSpacing": "{paragraphSpacing.0}", - "paragraphIndent": "{paragraphIndent.0}", - "textCase": "{textCase.none}", - "textDecoration": "{textDecoration.none}" - }, - "$type": "typography" - }, - "

": { - "$value": { - "fontFamily": "{fontFamilies.pretendard}", - "fontWeight": "{fontWeights.pretendard-2}", - "lineHeight": "{lineHeights.0}", - "fontSize": "{fontSize.0}", - "letterSpacing": "{letterSpacing.0}", - "paragraphSpacing": "{paragraphSpacing.0}", - "paragraphIndent": "{paragraphIndent.0}", - "textCase": "{textCase.none}", - "textDecoration": "{textDecoration.none}" - }, - "$type": "typography" - }, - "textCase": { - "none": { - "$value": "none", - "$type": "textCase" - } - }, - "textDecoration": { - "none": { - "$value": "none", - "$type": "textDecoration" - } - }, - "paragraphIndent": { - "0": { - "$value": "0px", - "$type": "dimension" - } - } - }, - "light": { - "fg": { - "default": { - "$value": "{colors.black}", - "$type": "color" - }, - "muted": { - "$value": "{colors.gray.700}", - "$type": "color" - }, - "subtle": { - "$value": "{colors.gray.500}", - "$type": "color" - } - }, - "bg": { - "default": { - "$value": "{colors.white}", - "$type": "color" - }, - "muted": { - "$value": "{colors.gray.100}", - "$type": "color" - }, - "subtle": { - "$value": "{colors.gray.200}", - "$type": "color" - } - }, - "accent": { - "default": { - "$value": "{colors.indigo.400}", - "$type": "color" - }, - "onAccent": { - "$value": "{colors.white}", - "$type": "color" - }, - "bg": { - "$value": "{colors.indigo.200}", - "$type": "color" - } - }, - "shadows": { - "default": { - "$value": "{colors.gray.900}", - "$type": "color" - } - } - }, - "dark": { - "fg": { - "default": { - "$value": "{colors.white}", - "$type": "color" - }, - "muted": { - "$value": "{colors.gray.300}", - "$type": "color" - }, - "subtle": { - "$value": "{colors.gray.500}", - "$type": "color" - } - }, - "bg": { - "default": { - "$value": "{colors.gray.900}", - "$type": "color" - }, - "muted": { - "$value": "{colors.gray.700}", - "$type": "color" - }, - "subtle": { - "$value": "{colors.gray.600}", - "$type": "color" - } - }, - "accent": { - "default": { - "$value": "{colors.indigo.600}", - "$type": "color" - }, - "onAccent": { - "$value": "{colors.white}", - "$type": "color" - }, - "bg": { - "$value": "{colors.indigo.800}", - "$type": "color" - } - }, - "shadows": { - "default": { - "$value": "rgba({colors.black}, 0.3)", - "$type": "color" - } - } - }, - "theme": { - "button": { - "primary": { - "background": { - "$value": "{accent.default}", - "$type": "color" - }, - "text": { - "$value": "{accent.onAccent}", - "$type": "color" - } - }, - "borderRadius": { - "$value": "{borderRadius.lg}", - "$type": "borderRadius" - }, - "borderWidth": { - "$value": "{dimension.sm}", - "$type": "borderWidth" - } - }, - "card": { - "borderRadius": { - "$value": "{borderRadius.lg}", - "$type": "borderRadius" - }, - "background": { - "$value": "{bg.default}", - "$type": "color" - }, - "padding": { - "$value": "{dimension.md}", - "$type": "dimension" - } - }, - "boxShadow": { - "default": { - "$value": [ - { - "x": 5, - "y": 5, - "spread": 3, - "color": "rgba({shadows.default}, 0.15)", - "blur": 5, - "$type": "dropShadow" - }, - { - "x": 4, - "y": 4, - "spread": 6, - "color": "#00000033", - "blur": 5, - "$type": "innerShadow" - } - ], - "$type": "boxShadow" - } - }, - "typography": { - "H1": { - "Bold": { - "$value": { - "fontFamily": "{fontFamilies.heading}", - "fontWeight": "{fontWeights.headingBold}", - "lineHeight": "{lineHeights.heading}", - "fontSize": "{fontSizes.h1}", - "paragraphSpacing": "{paragraphSpacing.h1}", - "letterSpacing": "{letterSpacing.decreased}" - }, - "$type": "typography" - }, - "Regular": { - "$value": { - "fontFamily": "{fontFamilies.heading}", - "fontWeight": "{fontWeights.headingRegular}", - "lineHeight": "{lineHeights.heading}", - "fontSize": "{fontSizes.h1}", - "paragraphSpacing": "{paragraphSpacing.h1}", - "letterSpacing": "{letterSpacing.decreased}" - }, - "$type": "typography" - } - }, - "H2": { - "Bold": { - "$value": { - "fontFamily": "{fontFamilies.heading}", - "fontWeight": "{fontWeights.headingBold}", - "lineHeight": "{lineHeights.heading}", - "fontSize": "{fontSizes.h2}", - "paragraphSpacing": "{paragraphSpacing.h2}", - "letterSpacing": "{letterSpacing.decreased}" - }, - "$type": "typography" - }, - "Regular": { - "$value": { - "fontFamily": "{fontFamilies.heading}", - "fontWeight": "{fontWeights.headingRegular}", - "lineHeight": "{lineHeights.heading}", - "fontSize": "{fontSizes.h2}", - "paragraphSpacing": "{paragraphSpacing.h2}", - "letterSpacing": "{letterSpacing.decreased}" - }, - "$type": "typography" - } - }, - "Body": { - "$value": { - "fontFamily": "{fontFamilies.body}", - "fontWeight": "{fontWeights.bodyRegular}", - "lineHeight": "{lineHeights.heading}", - "fontSize": "{fontSizes.body}", - "paragraphSpacing": "{paragraphSpacing.h2}" - }, - "$type": "typography" - } - } - }, - "$themes": [], - "$metadata": { - "tokenSetOrder": [ - "core", - "light", - "dark", - "theme" - ] - } +{ + "core": { + "dimension": { + "scale": { + "$value": "2", + "$type": "dimension" + }, + "xs": { + "$value": "4", + "$type": "dimension" + }, + "sm": { + "$value": "{dimension.xs} * {dimension.scale}", + "$type": "dimension" + }, + "md": { + "$value": "{dimension.sm} * {dimension.scale}", + "$type": "dimension" + }, + "lg": { + "$value": "{dimension.md} * {dimension.scale}", + "$type": "dimension" + }, + "xl": { + "$value": "{dimension.lg} * {dimension.scale}", + "$type": "dimension" + } + }, + "spacing": { + "xs": { + "$value": "{dimension.xs}", + "$type": "spacing" + }, + "sm": { + "$value": "{dimension.sm}", + "$type": "spacing" + }, + "md": { + "$value": "{dimension.md}", + "$type": "spacing" + }, + "lg": { + "$value": "{dimension.lg}", + "$type": "spacing" + }, + "xl": { + "$value": "{dimension.xl}", + "$type": "spacing" + }, + "multi-value": { + "$value": "{dimension.sm} {dimension.xl}", + "$type": "spacing", + "$description": "You can have multiple values in a single spacing token. Read more on these: https://docs.tokens.studio/available-tokens/spacing-tokens#multi-value-spacing-tokens" + } + }, + "borderRadius": { + "sm": { + "$value": "4", + "$type": "borderRadius" + }, + "lg": { + "$value": "8", + "$type": "borderRadius" + }, + "xl": { + "$value": "16", + "$type": "borderRadius" + }, + "multi-value": { + "$value": "{borderRadius.sm} {borderRadius.lg}", + "$type": "borderRadius", + "$description": "You can have multiple values in a single radius token. Read more on these: https://docs.tokens.studio/available-tokens/border-radius-tokens#single--multiple-values" + } + }, + "colors": { + "black": { + "$value": "#000000", + "$type": "color" + }, + "white": { + "$value": "#ffffff", + "$type": "color" + }, + "gray": { + "100": { + "$value": "#f7fafc", + "$type": "color" + }, + "200": { + "$value": "#edf2f7", + "$type": "color" + }, + "300": { + "$value": "#e2e8f0", + "$type": "color" + }, + "400": { + "$value": "#cbd5e0", + "$type": "color" + }, + "500": { + "$value": "#a0aec0", + "$type": "color" + }, + "600": { + "$value": "#718096", + "$type": "color" + }, + "700": { + "$value": "#4a5568", + "$type": "color" + }, + "800": { + "$value": "#2d3748", + "$type": "color" + }, + "900": { + "$value": "#1a202c", + "$type": "color" + } + }, + "red": { + "100": { + "$value": "#fff5f5", + "$type": "color" + }, + "200": { + "$value": "#fed7d7", + "$type": "color" + }, + "300": { + "$value": "#feb2b2", + "$type": "color" + }, + "400": { + "$value": "#fc8181", + "$type": "color" + }, + "500": { + "$value": "#f56565", + "$type": "color" + }, + "600": { + "$value": "#e53e3e", + "$type": "color" + }, + "700": { + "$value": "#c53030", + "$type": "color" + }, + "800": { + "$value": "#9b2c2c", + "$type": "color" + }, + "900": { + "$value": "#742a2a", + "$type": "color" + } + }, + "orange": { + "100": { + "$value": "#fffaf0", + "$type": "color" + }, + "200": { + "$value": "#feebc8", + "$type": "color" + }, + "300": { + "$value": "#fbd38d", + "$type": "color" + }, + "400": { + "$value": "#f6ad55", + "$type": "color" + }, + "500": { + "$value": "#ed8936", + "$type": "color" + }, + "600": { + "$value": "#dd6b20", + "$type": "color" + }, + "700": { + "$value": "#c05621", + "$type": "color" + }, + "800": { + "$value": "#9c4221", + "$type": "color" + }, + "900": { + "$value": "#7b341e", + "$type": "color" + } + }, + "yellow": { + "100": { + "$value": "#fffff0", + "$type": "color" + }, + "200": { + "$value": "#fefcbf", + "$type": "color" + }, + "300": { + "$value": "#faf089", + "$type": "color" + }, + "400": { + "$value": "#f6e05e", + "$type": "color" + }, + "500": { + "$value": "#ecc94b", + "$type": "color" + }, + "600": { + "$value": "#d69e2e", + "$type": "color" + }, + "700": { + "$value": "#b7791f", + "$type": "color" + }, + "800": { + "$value": "#975a16", + "$type": "color" + }, + "900": { + "$value": "#744210", + "$type": "color" + } + }, + "green": { + "100": { + "$value": "#f0fff4", + "$type": "color" + }, + "200": { + "$value": "#c6f6d5", + "$type": "color" + }, + "300": { + "$value": "#9ae6b4", + "$type": "color" + }, + "400": { + "$value": "#68d391", + "$type": "color" + }, + "500": { + "$value": "#48bb78", + "$type": "color" + }, + "600": { + "$value": "#38a169", + "$type": "color" + }, + "700": { + "$value": "#2f855a", + "$type": "color" + }, + "800": { + "$value": "#276749", + "$type": "color" + }, + "900": { + "$value": "#22543d", + "$type": "color" + } + }, + "teal": { + "100": { + "$value": "#e6fffa", + "$type": "color" + }, + "200": { + "$value": "#b2f5ea", + "$type": "color" + }, + "300": { + "$value": "#81e6d9", + "$type": "color" + }, + "400": { + "$value": "#4fd1c5", + "$type": "color" + }, + "500": { + "$value": "#38b2ac", + "$type": "color" + }, + "600": { + "$value": "#319795", + "$type": "color" + }, + "700": { + "$value": "#2c7a7b", + "$type": "color" + }, + "800": { + "$value": "#285e61", + "$type": "color" + }, + "900": { + "$value": "#234e52", + "$type": "color" + } + }, + "blue": { + "100": { + "$value": "#ebf8ff", + "$type": "color" + }, + "200": { + "$value": "#bee3f8", + "$type": "color" + }, + "300": { + "$value": "#90cdf4", + "$type": "color" + }, + "400": { + "$value": "#63b3ed", + "$type": "color" + }, + "500": { + "$value": "#4299e1", + "$type": "color" + }, + "600": { + "$value": "#3182ce", + "$type": "color" + }, + "700": { + "$value": "#2b6cb0", + "$type": "color" + }, + "800": { + "$value": "#2c5282", + "$type": "color" + }, + "900": { + "$value": "#2a4365", + "$type": "color" + } + }, + "indigo": { + "100": { + "$value": "#ebf4ff", + "$type": "color" + }, + "200": { + "$value": "#c3dafe", + "$type": "color" + }, + "300": { + "$value": "#a3bffa", + "$type": "color" + }, + "400": { + "$value": "#7f9cf5", + "$type": "color" + }, + "500": { + "$value": "#667eea", + "$type": "color" + }, + "600": { + "$value": "#5a67d8", + "$type": "color" + }, + "700": { + "$value": "#4c51bf", + "$type": "color" + }, + "800": { + "$value": "#434190", + "$type": "color" + }, + "900": { + "$value": "#3c366b", + "$type": "color" + } + }, + "purple": { + "100": { + "$value": "#faf5ff", + "$type": "color" + }, + "200": { + "$value": "#e9d8fd", + "$type": "color" + }, + "300": { + "$value": "#d6bcfa", + "$type": "color" + }, + "400": { + "$value": "#b794f4", + "$type": "color" + }, + "500": { + "$value": "#9f7aea", + "$type": "color" + }, + "600": { + "$value": "#805ad5", + "$type": "color" + }, + "700": { + "$value": "#6b46c1", + "$type": "color" + }, + "800": { + "$value": "#553c9a", + "$type": "color" + }, + "900": { + "$value": "#44337a", + "$type": "color" + } + }, + "pink": { + "100": { + "$value": "#fff5f7", + "$type": "color" + }, + "200": { + "$value": "#fed7e2", + "$type": "color" + }, + "300": { + "$value": "#fbb6ce", + "$type": "color" + }, + "400": { + "$value": "#f687b3", + "$type": "color" + }, + "500": { + "$value": "#ed64a6", + "$type": "color" + }, + "600": { + "$value": "#d53f8c", + "$type": "color" + }, + "700": { + "$value": "#b83280", + "$type": "color" + }, + "800": { + "$value": "#97266d", + "$type": "color" + }, + "900": { + "$value": "#702459", + "$type": "color" + } + } + }, + "opacity": { + "low": { + "$value": "10%", + "$type": "opacity" + }, + "md": { + "$value": "50%", + "$type": "opacity" + }, + "high": { + "$value": "90%", + "$type": "opacity" + } + }, + "fontFamilies": { + "heading": { + "$value": "Inter", + "$type": "fontFamilies" + }, + "body": { + "$value": "Roboto", + "$type": "fontFamilies" + }, + "pretendard": { + "$value": "Pretendard", + "$type": "fontFamilies" + } + }, + "lineHeights": { + "0": { + "$value": 20, + "$type": "lineHeights" + }, + "1": { + "$value": 20, + "$type": "lineHeights" + }, + "2": { + "$value": 20, + "$type": "lineHeights" + }, + "3": { + "$value": 20, + "$type": "lineHeights" + }, + "4": { + "$value": 20, + "$type": "lineHeights" + }, + "5": { + "$value": 20, + "$type": "lineHeights" + }, + "6": { + "$value": 20, + "$type": "lineHeights" + }, + "heading": { + "$value": "110%", + "$type": "lineHeights" + }, + "body": { + "$value": "140%", + "$type": "lineHeights" + } + }, + "letterSpacing": { + "0": { + "$value": "-2%", + "$type": "letterSpacing" + }, + "default": { + "$value": "0", + "$type": "letterSpacing" + }, + "increased": { + "$value": "150%", + "$type": "letterSpacing" + }, + "decreased": { + "$value": "-5%", + "$type": "letterSpacing" + } + }, + "paragraphSpacing": { + "0": { + "$value": 0, + "$type": "paragraphSpacing" + }, + "h1": { + "$value": "32", + "$type": "paragraphSpacing" + }, + "h2": { + "$value": "26", + "$type": "paragraphSpacing" + } + }, + "fontWeights": { + "headingRegular": { + "$value": "Regular", + "$type": "fontWeights" + }, + "headingBold": { + "$value": "Bold", + "$type": "fontWeights" + }, + "bodyRegular": { + "$value": "Regular", + "$type": "fontWeights" + }, + "bodyBold": { + "$value": "Bold", + "$type": "fontWeights" + }, + "pretendard-0": { + "$value": "ExtraBold", + "$type": "fontWeights" + }, + "pretendard-1": { + "$value": "SemiBold", + "$type": "fontWeights" + }, + "pretendard-2": { + "$value": "Regular", + "$type": "fontWeights" + } + }, + "fontSizes": { + "h1": { + "$value": "roundTo({fontSizes.body}*1.25^5)", + "$type": "fontSizes" + }, + "h2": { + "$value": "roundTo({fontSizes.body}*1.25^4)", + "$type": "fontSizes" + }, + "h3": { + "$value": "roundTo({fontSizes.body}*1.25^3)", + "$type": "fontSizes" + }, + "h4": { + "$value": "roundTo({fontSizes.body}*1.25^2)", + "$type": "fontSizes" + }, + "h5": { + "$value": "roundTo({fontSizes.body}*1.25^1)", + "$type": "fontSizes" + }, + "h6": { + "$value": "{fontSizes.body}", + "$type": "fontSizes" + }, + "body": { + "$value": "16", + "$type": "fontSizes" + }, + "sm": { + "$value": "{fontSizes.body} * 0.85", + "$type": "fontSizes" + }, + "xs": { + "$value": "{fontSizes.body} * 0.65", + "$type": "fontSizes" + } + }, + "ai_color": { + "$value": "linear-gradient(180deg, #da8cf1 0%, #8bb1f2 100%)", + "$type": "color" + }, + "primary-lv-0": { + "$value": "#e9eeed", + "$type": "color" + }, + "primary-lv-1": { + "$value": "#d2dcdb", + "$type": "color" + }, + "primary-lv-2": { + "$value": "#a5b9b6", + "$type": "color" + }, + "primary-lv-3": { + "$value": "#789792", + "$type": "color" + }, + "primary-lv-4": { + "$value": "#4b746d", + "$type": "color" + }, + "primary-lv-5": { + "$value": "#35635c", + "$type": "color" + }, + "primary-lv-6": { + "$value": "#1e5149", + "$type": "color" + }, + "primary-lv-7": { + "$value": "#1b443d", + "$type": "color" + }, + "primary-lv-8": { + "$value": "#193833", + "$type": "color" + }, + "primary-lv-9": { + "$value": "#162a27", + "$type": "color" + }, + "color-red": { + "$value": "#f21d0d", + "$type": "color" + }, + "color-pink": { + "$value": "#e8175e", + "$type": "color" + }, + "color-magenta": { + "$value": "#b92ed1", + "$type": "color" + }, + "color-purple": { + "$value": "#6d3dc2", + "$type": "color" + }, + "color-navy": { + "$value": "#4255bd", + "$type": "color" + }, + "color-blue": { + "$value": "#0d8df2", + "$type": "color" + }, + "color-cyan": { + "$value": "#03aefc", + "$type": "color" + }, + "color-green": { + "$value": "#4db251", + "$type": "color" + }, + "color-yellow": { + "$value": "#ffbf00", + "$type": "color" + }, + "color-orange": { + "$value": "#ff9800", + "$type": "color" + }, + "color-dahong": { + "$value": "#ff3d00", + "$type": "color" + }, + "color-brown": { + "$value": "#a0705f", + "$type": "color" + }, + "color-iron": { + "$value": "#7f7f7f", + "$type": "color" + }, + "color-steel": { + "$value": "#688897", + "$type": "color" + }, + "color-red-light": { + "$value": "#fee9e7", + "$type": "color" + }, + "color-pink-light": { + "$value": "#fde8ef", + "$type": "color" + }, + "color-magenta-light": { + "$value": "#f8ebfb", + "$type": "color" + }, + "color-purple-light": { + "$value": "#f1ecf9", + "$type": "color" + }, + "color-navy-light": { + "$value": "#edeef9", + "$type": "color" + }, + "color-blue-light": { + "$value": "#e7f4fe", + "$type": "color" + }, + "color-cyan-light": { + "$value": "#e6f7ff", + "$type": "color" + }, + "color-green-light": { + "$value": "#eef8ee", + "$type": "color" + }, + "color-yellow-light": { + "$value": "#fff9e6", + "$type": "color" + }, + "color-orange-light": { + "$value": "#fff5e6", + "$type": "color" + }, + "color-dahong-light": { + "$value": "#ffece6", + "$type": "color" + }, + "color-brown-light": { + "$value": "#f6f1ef", + "$type": "color" + }, + "color-iron-light": { + "$value": "#f3f3f3", + "$type": "color" + }, + "color-steel-light": { + "$value": "#f0f4f5", + "$type": "color" + }, + "color-red-medium": { + "$value": "#faa59e", + "$type": "color" + }, + "color-pink-medium": { + "$value": "#f6a2bf", + "$type": "color" + }, + "color-magenta-medium": { + "$value": "#e3abec", + "$type": "color" + }, + "color-purple-medium": { + "$value": "#c5b1e7", + "$type": "color" + }, + "color-navy-medium": { + "$value": "#b3bbe5", + "$type": "color" + }, + "color-blue-medium": { + "$value": "#9ed1fa", + "$type": "color" + }, + "color-cyan-medium": { + "$value": "#9adffe", + "$type": "color" + }, + "color-green-medium": { + "$value": "#b8e0b9", + "$type": "color" + }, + "color-yellow-medium": { + "$value": "#ffe599", + "$type": "color" + }, + "color-orange-medium": { + "$value": "#ffd699", + "$type": "color" + }, + "color-dahong-medium": { + "$value": "#ffb199", + "$type": "color" + }, + "color-brown-medium": { + "$value": "#d9c6bf", + "$type": "color" + }, + "color-iron-medium": { + "$value": "#cccccc", + "$type": "color" + }, + "color-steel-medium": { + "$value": "#c3cfd5", + "$type": "color" + }, + "checked-color-background": { + "$value": "#03aefc1a", + "$type": "color" + }, + "headercolor": { + "$value": "linear-gradient(90deg, #193833 0%, #1e5149 100%)", + "$type": "color" + }, + "line-style": { + "$value": "linear-gradient(135deg, #ffffff 0%, #0000001a 50%, #ffffff 100%)", + "$type": "color" + }, + "box__drop-shadow": { + "$value": { + "color": "#00000029", + "type": "dropShadow", + "x": 0, + "y": 8, + "blur": 24, + "spread": 0 + }, + "$type": "boxShadow" + }, + "fontSize": { + "0": { + "$value": 12, + "$type": "fontSizes" + }, + "1": { + "$value": 14, + "$type": "fontSizes" + }, + "2": { + "$value": 16, + "$type": "fontSizes" + }, + "3": { + "$value": 20, + "$type": "fontSizes" + } + }, + "

": { + "$value": { + "fontFamily": "{fontFamilies.pretendard}", + "fontWeight": "{fontWeights.pretendard-0}", + "lineHeight": "{lineHeights.0}", + "fontSize": "{fontSize.3}", + "letterSpacing": "{letterSpacing.0}", + "paragraphSpacing": "{paragraphSpacing.0}", + "paragraphIndent": "{paragraphIndent.0}", + "textCase": "{textCase.none}", + "textDecoration": "{textDecoration.none}" + }, + "$type": "typography" + }, + "

": { + "$value": { + "fontFamily": "{fontFamilies.pretendard}", + "fontWeight": "{fontWeights.pretendard-1}", + "lineHeight": "{lineHeights.0}", + "fontSize": "{fontSize.2}", + "letterSpacing": "{letterSpacing.0}", + "paragraphSpacing": "{paragraphSpacing.0}", + "paragraphIndent": "{paragraphIndent.0}", + "textCase": "{textCase.none}", + "textDecoration": "{textDecoration.none}" + }, + "$type": "typography" + }, + "

": { + "$value": { + "fontFamily": "{fontFamilies.pretendard}", + "fontWeight": "{fontWeights.pretendard-1}", + "lineHeight": "{lineHeights.0}", + "fontSize": "{fontSize.1}", + "letterSpacing": "{letterSpacing.0}", + "paragraphSpacing": "{paragraphSpacing.0}", + "paragraphIndent": "{paragraphIndent.0}", + "textCase": "{textCase.none}", + "textDecoration": "{textDecoration.none}" + }, + "$type": "typography" + }, + "

": { + "$value": { + "fontFamily": "{fontFamilies.pretendard}", + "fontWeight": "{fontWeights.pretendard-2}", + "lineHeight": "{lineHeights.0}", + "fontSize": "{fontSize.1}", + "letterSpacing": "{letterSpacing.0}", + "paragraphSpacing": "{paragraphSpacing.0}", + "paragraphIndent": "{paragraphIndent.0}", + "textCase": "{textCase.none}", + "textDecoration": "{textDecoration.none}" + }, + "$type": "typography" + }, + "

": { + "$value": { + "fontFamily": "{fontFamilies.pretendard}", + "fontWeight": "{fontWeights.pretendard-1}", + "lineHeight": "{lineHeights.0}", + "fontSize": "{fontSize.0}", + "letterSpacing": "{letterSpacing.0}", + "paragraphSpacing": "{paragraphSpacing.0}", + "paragraphIndent": "{paragraphIndent.0}", + "textCase": "{textCase.none}", + "textDecoration": "{textDecoration.none}" + }, + "$type": "typography" + }, + "
": { + "$value": { + "fontFamily": "{fontFamilies.pretendard}", + "fontWeight": "{fontWeights.pretendard-2}", + "lineHeight": "{lineHeights.0}", + "fontSize": "{fontSize.0}", + "letterSpacing": "{letterSpacing.0}", + "paragraphSpacing": "{paragraphSpacing.0}", + "paragraphIndent": "{paragraphIndent.0}", + "textCase": "{textCase.none}", + "textDecoration": "{textDecoration.none}" + }, + "$type": "typography" + }, + "

": { + "$value": { + "fontFamily": "{fontFamilies.pretendard}", + "fontWeight": "{fontWeights.pretendard-2}", + "lineHeight": "{lineHeights.0}", + "fontSize": "{fontSize.0}", + "letterSpacing": "{letterSpacing.0}", + "paragraphSpacing": "{paragraphSpacing.0}", + "paragraphIndent": "{paragraphIndent.0}", + "textCase": "{textCase.none}", + "textDecoration": "{textDecoration.none}" + }, + "$type": "typography" + }, + "textCase": { + "none": { + "$value": "none", + "$type": "textCase" + } + }, + "textDecoration": { + "none": { + "$value": "none", + "$type": "textDecoration" + } + }, + "paragraphIndent": { + "0": { + "$value": "0px", + "$type": "dimension" + } + } + }, + "light": { + "fg": { + "default": { + "$value": "{colors.black}", + "$type": "color" + }, + "muted": { + "$value": "{colors.gray.700}", + "$type": "color" + }, + "subtle": { + "$value": "{colors.gray.500}", + "$type": "color" + } + }, + "bg": { + "default": { + "$value": "{colors.white}", + "$type": "color" + }, + "muted": { + "$value": "{colors.gray.100}", + "$type": "color" + }, + "subtle": { + "$value": "{colors.gray.200}", + "$type": "color" + } + }, + "accent": { + "default": { + "$value": "{colors.indigo.400}", + "$type": "color" + }, + "onAccent": { + "$value": "{colors.white}", + "$type": "color" + }, + "bg": { + "$value": "{colors.indigo.200}", + "$type": "color" + } + }, + "shadows": { + "default": { + "$value": "{colors.gray.900}", + "$type": "color" + } + } + }, + "dark": { + "fg": { + "default": { + "$value": "{colors.white}", + "$type": "color" + }, + "muted": { + "$value": "{colors.gray.300}", + "$type": "color" + }, + "subtle": { + "$value": "{colors.gray.500}", + "$type": "color" + } + }, + "bg": { + "default": { + "$value": "{colors.gray.900}", + "$type": "color" + }, + "muted": { + "$value": "{colors.gray.700}", + "$type": "color" + }, + "subtle": { + "$value": "{colors.gray.600}", + "$type": "color" + } + }, + "accent": { + "default": { + "$value": "{colors.indigo.600}", + "$type": "color" + }, + "onAccent": { + "$value": "{colors.white}", + "$type": "color" + }, + "bg": { + "$value": "{colors.indigo.800}", + "$type": "color" + } + }, + "shadows": { + "default": { + "$value": "rgba({colors.black}, 0.3)", + "$type": "color" + } + } + }, + "theme": { + "button": { + "primary": { + "background": { + "$value": "{accent.default}", + "$type": "color" + }, + "text": { + "$value": "{accent.onAccent}", + "$type": "color" + } + }, + "borderRadius": { + "$value": "{borderRadius.lg}", + "$type": "borderRadius" + }, + "borderWidth": { + "$value": "{dimension.sm}", + "$type": "borderWidth" + } + }, + "card": { + "borderRadius": { + "$value": "{borderRadius.lg}", + "$type": "borderRadius" + }, + "background": { + "$value": "{bg.default}", + "$type": "color" + }, + "padding": { + "$value": "{dimension.md}", + "$type": "dimension" + } + }, + "boxShadow": { + "default": { + "$value": [ + { + "x": 5, + "y": 5, + "spread": 3, + "color": "rgba({shadows.default}, 0.15)", + "blur": 5, + "$type": "dropShadow" + }, + { + "x": 4, + "y": 4, + "spread": 6, + "color": "#00000033", + "blur": 5, + "$type": "innerShadow" + } + ], + "$type": "boxShadow" + } + }, + "typography": { + "H1": { + "Bold": { + "$value": { + "fontFamily": "{fontFamilies.heading}", + "fontWeight": "{fontWeights.headingBold}", + "lineHeight": "{lineHeights.heading}", + "fontSize": "{fontSizes.h1}", + "paragraphSpacing": "{paragraphSpacing.h1}", + "letterSpacing": "{letterSpacing.decreased}" + }, + "$type": "typography" + }, + "Regular": { + "$value": { + "fontFamily": "{fontFamilies.heading}", + "fontWeight": "{fontWeights.headingRegular}", + "lineHeight": "{lineHeights.heading}", + "fontSize": "{fontSizes.h1}", + "paragraphSpacing": "{paragraphSpacing.h1}", + "letterSpacing": "{letterSpacing.decreased}" + }, + "$type": "typography" + } + }, + "H2": { + "Bold": { + "$value": { + "fontFamily": "{fontFamilies.heading}", + "fontWeight": "{fontWeights.headingBold}", + "lineHeight": "{lineHeights.heading}", + "fontSize": "{fontSizes.h2}", + "paragraphSpacing": "{paragraphSpacing.h2}", + "letterSpacing": "{letterSpacing.decreased}" + }, + "$type": "typography" + }, + "Regular": { + "$value": { + "fontFamily": "{fontFamilies.heading}", + "fontWeight": "{fontWeights.headingRegular}", + "lineHeight": "{lineHeights.heading}", + "fontSize": "{fontSizes.h2}", + "paragraphSpacing": "{paragraphSpacing.h2}", + "letterSpacing": "{letterSpacing.decreased}" + }, + "$type": "typography" + } + }, + "Body": { + "$value": { + "fontFamily": "{fontFamilies.body}", + "fontWeight": "{fontWeights.bodyRegular}", + "lineHeight": "{lineHeights.heading}", + "fontSize": "{fontSizes.body}", + "paragraphSpacing": "{paragraphSpacing.h2}" + }, + "$type": "typography" + } + } + }, + "$themes": [], + "$metadata": { + "tokenSetOrder": [ + "core", + "light", + "dark", + "theme" + ] + } } \ No newline at end of file diff --git a/verify_swvw.py b/verify_swvw.py new file mode 100644 index 0000000..9746506 --- /dev/null +++ b/verify_swvw.py @@ -0,0 +1,28 @@ +import pymysql +from analysis_service import AnalysisService + +def verify_analysis(): + conn = pymysql.connect( + host='localhost', + user='root', + password='45278434', + database='pm_proto_test', + charset='utf8mb4', + cursorclass=pymysql.cursors.DictCursor + ) + try: + with conn.cursor() as cursor: + results = AnalysisService.get_p_zsr_analysis_logic(cursor) + print(f"Total Projects Analyzed: {len(results)}") + print("\n[Sample Project Analysis Result]") + for res in results[:5]: + print(f"Project: {res['project_nm']}") + print(f" - Log Quality Score (Semantic): {res['log_quality']}") + print(f" - AVI Score (p_war): {res['p_war']}") + print(f" - OCI Score: {res['oci_score']}") + print("-" * 30) + finally: + conn.close() + +if __name__ == "__main__": + verify_analysis()