From 6be7cef65f0105bccfca6bd1af9c1fd2b6c4f09b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=91=90=EC=84=A0=EC=95=84?= Date: Thu, 11 Jun 2026 18:05:27 +0900 Subject: [PATCH] Refactor frontend layout and shared UI --- .gitignore | 23 + frontend/package-lock.json | 838 ++- frontend/package.json | 4 +- frontend/src/App.tsx | 6143 +--------------- frontend/src/{ => app/apis}/apiClient.ts | 0 frontend/src/{ => app/apis}/apiConfig.ts | 0 frontend/src/app/apis/index.ts | 2 + frontend/src/app/routes/index.tsx | 12 + frontend/src/components/.gitkeep | 1 + frontend/src/constants/.gitkeep | 1 + frontend/src/contexts/.gitkeep | 1 + .../DocumentAutomation.tsx | 6174 +++++++++++++++++ .../document-library}/DocumentLibrary.tsx | 2 +- .../export-jobs}/ExportJobHistory.tsx | 0 .../{ => features/export-jobs}/exportJobs.ts | 4 +- .../module-workspace}/ModuleWorkspace.tsx | 10 +- .../workflow-builder}/WorkflowBuilder.tsx | 10 +- frontend/src/hooks/.gitkeep | 1 + frontend/src/i18n/.gitkeep | 1 + frontend/src/layouts/.gitkeep | 1 + frontend/src/layouts/AppLayout.tsx | 62 + frontend/src/lib/.gitkeep | 1 + frontend/src/main.tsx | 1 + frontend/src/pages/DocumentAutomationPage.tsx | 5 + frontend/src/styles.css | 36 +- frontend/src/utils/.gitkeep | 1 + frontend/tsconfig.json | 4 + frontend/vite.config.ts | 6 + 28 files changed, 7181 insertions(+), 6163 deletions(-) rename frontend/src/{ => app/apis}/apiClient.ts (100%) rename frontend/src/{ => app/apis}/apiConfig.ts (100%) create mode 100644 frontend/src/app/apis/index.ts create mode 100644 frontend/src/app/routes/index.tsx create mode 100644 frontend/src/components/.gitkeep create mode 100644 frontend/src/constants/.gitkeep create mode 100644 frontend/src/contexts/.gitkeep create mode 100644 frontend/src/features/document-automation/DocumentAutomation.tsx rename frontend/src/{ => features/document-library}/DocumentLibrary.tsx (99%) rename frontend/src/{ => features/export-jobs}/ExportJobHistory.tsx (100%) rename frontend/src/{ => features/export-jobs}/exportJobs.ts (97%) rename frontend/src/{ => features/module-workspace}/ModuleWorkspace.tsx (99%) rename frontend/src/{ => features/workflow-builder}/WorkflowBuilder.tsx (99%) create mode 100644 frontend/src/hooks/.gitkeep create mode 100644 frontend/src/i18n/.gitkeep create mode 100644 frontend/src/layouts/.gitkeep create mode 100644 frontend/src/layouts/AppLayout.tsx create mode 100644 frontend/src/lib/.gitkeep create mode 100644 frontend/src/pages/DocumentAutomationPage.tsx create mode 100644 frontend/src/utils/.gitkeep diff --git a/.gitignore b/.gitignore index d4b5891..a86cbb8 100644 --- a/.gitignore +++ b/.gitignore @@ -77,6 +77,28 @@ frontend/src/* !frontend/src/*.ts !frontend/src/*.tsx !frontend/src/*.css +!frontend/src/app/ +!frontend/src/app/** +!frontend/src/components/ +!frontend/src/components/** +!frontend/src/constants/ +!frontend/src/constants/** +!frontend/src/contexts/ +!frontend/src/contexts/** +!frontend/src/features/ +!frontend/src/features/** +!frontend/src/hooks/ +!frontend/src/hooks/** +!frontend/src/i18n/ +!frontend/src/i18n/** +!frontend/src/layouts/ +!frontend/src/layouts/** +!frontend/src/lib/ +!frontend/src/lib/** +!frontend/src/pages/ +!frontend/src/pages/** +!frontend/src/utils/ +!frontend/src/utils/** # Template data and final README media. !docs/ @@ -135,6 +157,7 @@ test-results/ frontend/node_modules/ frontend/dist/ frontend/.vite/ +frontend/.npmrc frontend/coverage/ frontend/*.tsbuildinfo backend/*.db* diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e7b4ea0..7353cd8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,10 +8,12 @@ "name": "document-automation-workspace-frontend", "version": "0.1.0", "dependencies": { + "@genai/ui": "^0.1.2", "@xyflow/react": "^12.10.2", "lucide-react": "0.562.0", "react": "^19.2.1", - "react-dom": "^19.2.1" + "react-dom": "^19.2.1", + "react-router-dom": "^7.6.0" }, "devDependencies": { "@types/node": "^25.0.0", @@ -304,6 +306,12 @@ "node": ">=6.9.0" } }, + "node_modules/@date-fns/tz": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz", + "integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -746,6 +754,72 @@ "node": ">=18" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@genai/ui": { + "version": "0.1.2", + "resolved": "http://server.interxlab.io:30000/api/v4/projects/430/packages/npm/@genai/ui/-/@genai/ui-0.1.2.tgz", + "integrity": "sha1-vgTMuiDBaHXRmA+/05L9iD9RiQ8=", + "dependencies": { + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-scroll-area": "^1.2.10", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "lucide-react": "^0.546.0", + "react-day-picker": "^9.14.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.3.1" + }, + "peerDependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + } + }, + "node_modules/@genai/ui/node_modules/lucide-react": { + "version": "0.546.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.546.0.tgz", + "integrity": "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -796,6 +870,486 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@radix-ui/number": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz", + "integrity": "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", + "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.9.tgz", + "integrity": "sha512-yqHW5WQ/cTpU/un7dqqIKNy2iRU8BC0JB78PEzTfCCYvZu1U6W9KwObAniMk9nhSfyotKPQTYaUD/HB0f5muig==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", + "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", + "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.12.tgz", + "integrity": "sha512-MhoruH6xEzsbvOmo4TNgMfmtvRGyDZw4MDSdf4ybMHfezjqwzv6hyd4lsMzBp8K9Sn6sGzCF62x1I7BYUECXOg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-escape-keydown": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", + "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.9.tgz", + "integrity": "sha512-9Se8t+Zry+1rEOL7Y6l/4ANYU/TOtAtf8O2fKdwLltcaMcm6kOqYGbzO4tMFQ0bvzO920pRAoHpFZ4W85S3keQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-callback-ref": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", + "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.16.tgz", + "integrity": "sha512-8brVpAU5Uq7Bh0c8EFc4ZTf2JJTYn0o+1L+CUJB3UYIOkTjKGMgoHvduylrahdmNlr3DfH0rFq2DrbNZXgaspw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.12", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.9", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.0", + "@radix-ui/react-portal": "1.1.11", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-slot": "1.2.5", + "@radix-ui/react-use-controllable-state": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.0.tgz", + "integrity": "sha512-9PB589e1aWZbrlFUHdz6WiPCL+xLZHQFX7oibqG/6Q0SwOkxDyQX9W/cyPa+sAPPKuC8cpLCpRczE5a/1DiwVQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.9", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-rect": "1.1.2", + "@radix-ui/react-use-size": "1.1.2", + "@radix-ui/rect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.11.tgz", + "integrity": "sha512-UEytdjgEh2tJGgD/gZK4FUx6t1rNIlM3U0DENhSrG7I75FGm1DnaDuVUWF1pWAWUwGmn1sCJ1VGHn8LhN1aTOw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", + "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.5.tgz", + "integrity": "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.11.tgz", + "integrity": "sha512-DS39ziOgea75U/TrXKU2/oKp0be2jrDHnzFLvahg/0iNAT1Zq16e4Uw0WXwyXvsK+mG3BRyMb7A3NRZMDuEXtQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.5.tgz", + "integrity": "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", + "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", + "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", + "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz", + "integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz", + "integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", + "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz", + "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -1192,6 +1746,15 @@ "win32" ] }, + "node_modules/@tabby_ai/hijri-converter": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@tabby_ai/hijri-converter/-/hijri-converter-1.0.5.tgz", + "integrity": "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1307,7 +1870,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1376,6 +1939,18 @@ "d3-zoom": "^3.0.0" } }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.31", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz", @@ -1450,6 +2025,15 @@ "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1457,11 +2041,24 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/d3-color": { @@ -1569,6 +2166,22 @@ "node": ">=12" } }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-jalali": { + "version": "4.1.0-0", + "resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", + "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1587,6 +2200,12 @@ } } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.359", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.359.tgz", @@ -1689,6 +2308,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1832,6 +2460,28 @@ "node": ">=0.10.0" } }, + "node_modules/react-day-picker": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.14.0.tgz", + "integrity": "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "@tabby_ai/hijri-converter": "1.0.5", + "date-fns": "^4.1.0", + "date-fns-jalali": "4.1.0-0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/react-dom": { "version": "19.2.6", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", @@ -1854,6 +2504,113 @@ "node": ">=0.10.0" } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.0.tgz", + "integrity": "sha512-GGufuHIVCJDbnIAXP3P9Sxzq3UUsddG3rrI3ut1q6m0FI6vxVBF3JoPQ38+W/blslLH4a5Yutp8drkEpXoddGQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.0.tgz", + "integrity": "sha512-DYgm6RDEuKdopSyGOWZGtDfSm7Aofb8CCzgkliTjtu/eDuB0gcsv6qdFhhi8HdtmA+KHkt5MfZ5K2PdzjugYsA==", + "license": "MIT", + "dependencies": { + "react-router": "7.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/rollup": { "version": "4.60.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", @@ -1915,6 +2672,22 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1925,6 +2698,16 @@ "node": ">=0.10.0" } }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -1942,6 +2725,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1994,6 +2783,49 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 320716e..6c9fca6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,10 +9,12 @@ "preview": "vite preview --host 127.0.0.1" }, "dependencies": { + "@genai/ui": "^0.1.2", "@xyflow/react": "^12.10.2", "lucide-react": "0.562.0", "react": "^19.2.1", - "react-dom": "^19.2.1" + "react-dom": "^19.2.1", + "react-router-dom": "^7.6.0" }, "devDependencies": { "@types/node": "^25.0.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 15895ae..ab9cc83 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6144 +1,7 @@ -import { - CheckSquare, - ChevronDown, - ChevronLeft, - ChevronRight, - ChevronUp, - CircleHelp, - ClipboardList, - Copy, - Download, - FileDown, - FileJson, - FileSpreadsheet, - FileUp, - Filter, - FolderOpen, - GripVertical, - History, - Loader2, - Maximize2, - Menu, - PanelLeft, - Play, - Plus, - RotateCw, - Save, - Settings, - Sparkles, - Trash2, - UploadCloud, - X, - ZoomIn, - ZoomOut -} from "lucide-react"; -import { ChangeEvent, DragEvent, PointerEvent, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { CSSProperties, ReactNode, UIEvent } from "react"; -import { apiFetch } from "./apiClient"; -import { API_BASE } from "./apiConfig"; -import { DocumentLibraryScreen, DocumentPickerButton, LibraryDocument, uploadLibraryFiles } from "./DocumentLibrary"; -import { ExportJobHistory } from "./ExportJobHistory"; -import { ModuleWorkspace } from "./ModuleWorkspace"; -import { WorkflowBuilder, WorkflowRunResultWindow } from "./WorkflowBuilder"; -import { createAndDownloadExportJob } from "./exportJobs"; +import { RouterProvider } from "react-router-dom"; -const WORKSPACE_STATE_KEY = "digitize_workspace_state_v1"; -const LEFT_PANE_PERCENT_KEY = "digitize_left_pane_percent_v1"; -const OUTPUT_FORMATS = ["string", "float", "date", "bool"] as const; -const KIE_FILE_ACCEPT = ".pdf,.png,.jpg,.jpeg,.docx,.pptx"; -const KIE_FILE_EXTENSIONS = new Set(["pdf", "png", "jpg", "jpeg", "docx", "pptx"]); -const RAW_FILE_ACCEPT = ".docx,.xlsx,.pptx,.pdf"; -const DEFAULT_UPLOAD_CHUNK_FILES = 10; -const BATCH_FILE_ROW_HEIGHT = 84; -const BATCH_FILE_OVERSCAN = 8; -const DEFAULT_MAX_BATCH_UPLOAD_FILES = 10000; -const EXTRACTION_TERMINAL_STATUSES = new Set(["completed", "failed", "needs_review", "canceled"]); -const EXTRACTION_LONG_RUNNING_NOTICE_MS = 60_000; -const SAMPLE_SCHEMA_FIELDS: FieldDefinition[] = [ - { - key_name: "문서번호", - description: "문서 상단 근처의 문서번호, 영수증 번호, 신청 번호 또는 거래 번호입니다.", - output_format: "string" - }, - { - key_name: "문서일자", - description: "문서에 인쇄된 발급일, 제출일 또는 효력 발생일입니다.", - output_format: "date" - }, - { - key_name: "발급기관", - description: "문서를 발급한 기관, 은행, 업체 또는 권한 있는 조직입니다.", - output_format: "string" - }, - { - key_name: "수신자", - description: "문서의 수신자 또는 문서가 귀속되는 사람/조직입니다.", - output_format: "string" - }, - { - key_name: "금액", - description: "보이는 경우 최종 합계, 잔액 또는 거래 금액입니다.", - output_format: "float" - } -]; - -type OutputFormat = (typeof OUTPUT_FORMATS)[number]; -type AppMode = "home" | "documents" | "raw" | "key-info" | "classifier" | "required-checker" | "workflow" | "workflow-result"; -type ExportFormat = "json" | "csv" | "xlsx"; -type ExportMenuOption = { format: ExportFormat; href?: string; label?: string; onExport?: () => Promise | unknown }; -type Step = "upload" | "schema" | "review"; -type ReviewFilter = "needs_review" | "all" | "warning" | "null" | "changed" | "low_confidence" | "unreviewed" | "ai_corrected" | "ai_review_failed"; -type HistoryTab = "documents" | "schemas" | "jobs"; -type ZoomMode = "manual" | "fitWidth" | "fitPage"; - -type RawExtractionOptions = { - includeImages: boolean; - includeFormulas: boolean; -}; - -type FieldRegion = { - page: number; - x: number; - y: number; - width: number; - height: number; -}; - -type SchemaRegion = FieldRegion & { - id: string; - name: string; -}; - -type DocumentPage = { - id: string; - page: number; - image_url: string; - width: number; - height: number; -}; - -type RegionEditorPage = { - id: string; - page: number; - image_url: string; -}; - -type RegionEditorTarget = { - page_count: number; - pages: RegionEditorPage[]; -}; - -type UploadedDocument = { - document_id: string; - filename: string; - library_path: string | null; - mime_type: string; - size_bytes: number; - page_count: number; - status: string; - error_message?: string | null; - document_type: string | null; - language: string | null; - ai_summary: string | null; - recommendation_reasoning: string | null; - pages: DocumentPage[]; - created_at: string; - deleted_at?: string | null; -}; - -type FieldDefinition = { - key_name: string; - description: string; - output_format: OutputFormat; - region_id?: string | null; - region?: FieldRegion | null; - judgement_enabled?: boolean; -}; - -type SchemaField = FieldDefinition & { - local_id: string; -}; - -type SavedSchema = { - id: string; - name: string; - display_name: string | null; - description: string | null; - is_template: boolean; - template_category: string | null; - pinned: boolean; - ephemeral: boolean; - archived: boolean; - regions: SchemaRegion[]; - fields: FieldDefinition[]; - created_at: string; - updated_at: string; -}; - -type SchemaRecommendation = { - name: string; - display_name: string | null; - description: string | null; - document_type: string | null; - language: string | null; - reasoning: string | null; - fields: FieldDefinition[]; -}; - -type SchemaDescriptionRecommendation = { - description: string; - reasoning: string | null; -}; - -type ExtractionValue = { - value: unknown; - normalized_value: unknown; - page: number | null; - confidence: number | null; - evidence: string | null; - warnings: string[]; - ai_review?: { - enabled?: boolean; - mode?: "full_page" | "region" | string; - judgement_status?: "correct" | "needs_correction" | "failed" | string; - judgement_reason?: string | null; - judgement_confidence?: number | null; - initial_value?: unknown; - initial_evidence?: string | null; - corrected?: boolean; - correction_reason?: string | null; - }; -}; - -type ValidatedOutput = { - document_id: string; - schema_id: string; - status: string; - values: Record; -}; - -type ExtractionResult = { - id: string; - job_id: string; - raw_model_output: Record; - validated_output: ValidatedOutput; - corrected_output: ValidatedOutput | null; - validation_warnings: string[]; - reviewed_fields: string[]; - created_at: string; - updated_at: string; -}; - -type ExtractionJob = { - job_id: string; - document_id: string; - schema_id: string; - status: string; - error_message: string | null; - result_id: string | null; - result: ExtractionResult | null; - created_at: string; - started_at: string | null; - completed_at: string | null; -}; - -type RawExtraction = { - id: string; - filename: string; - source_format: string; - size_bytes: number; - status: string; - pdf_url: string | null; - html_url: string | null; - warnings: string[]; - error_message: string | null; - created_at: string; - updated_at: string; -}; - -type SystemStatus = { - app_env: string; - vlm_provider: string; - vlm_model_name: string | null; - has_vlm_credentials: boolean; - is_mock: boolean; - upload_max_batch_files: number; - upload_chunk_files: number; - preprocess_max_workers: number; - workflow_max_workers: number; - vlm_max_concurrent_requests: number; - document_page_max_long_edge: number; - document_page_jpeg_quality: number; -}; - -type VlmSettings = { - provider: string; - model_name: string | null; - base_url: string | null; - libreoffice_path: string | null; - inference_params: Record; - reasoning_effort: string | null; - verbosity: string | null; - temperature: string | null; - max_completion_tokens: string | null; - top_p: string | null; - service_tier: string | null; - workflow_max_workers: number; - vlm_max_concurrent_requests: number; - vlm_timeout_seconds: number; - kie_field_group_size: number; - has_api_key: boolean; - env_path: string; - runtime_settings_writable: boolean; -}; - -type BankPocSeed = { - template_key: string; - created: Record; - workflow: { - id: string; - name: string; - }; - sample_document: LibraryDocument | null; - sample_documents?: LibraryDocument[]; -}; - -type HomeWorkflowRun = { - id: string; - status: string; - total_count: number; - completed_count: number; - failed_count: number; - needs_review_count: number; - canceled_count?: number; - uploaded_count?: number; - preprocessing_count?: number; - ready_count?: number; - queued_count?: number; - running_count?: number; - progress_phase?: string; - progress: number; - items: { status: string }[]; - created_at: string; -}; - -type HomeModuleBatch = { - id: string; - status: string; - total_count: number; - completed_count: number; - failed_count: number; - canceled_count: number; - uploaded_count?: number; - preprocessing_count?: number; - ready_count?: number; - queued_count?: number; - running_count?: number; - needs_review_count?: number; - progress_phase?: string; - progress: number; - items: { status: string }[]; - created_at: string; - completed_at: string | null; -}; - -type HomeMonitorTarget = "workflow" | "key-info" | "classifier" | "required-checker"; - -type HomeMonitorItem = { - id: string; - target: HomeMonitorTarget; - moduleLabel: string; - title: string; - status: string; - progress: number; - totalCount: number; - doneCount: number; - uploadedCount: number; - preprocessingCount: number; - runningCount: number; - queuedCount: number; - needsReviewCount: number; - failedCount: number; - canceledCount: number; - pausedCount: number; - createdAt: string; -}; - -type MaintenanceClearResponse = { - status: string; - counts: Record; - removed_paths: string[]; -}; - -type ExportPresetField = { - key_name: string; - column_name?: string | null; - include: boolean; -}; - -type ExportPreset = { - id: string; - schema_id: string | null; - name: string; - fields: ExportPresetField[]; - created_at: string; - updated_at: string; -}; - -type BatchItem = { - id: string; - document_id: string; - job_id: string; - filename: string; - status: string; - result_id: string | null; - error_message: string | null; - created_at: string; -}; - -type Batch = { - id: string; - schema_id: string; - status: string; - total_count: number; - completed_count: number; - failed_count: number; - canceled_count: number; - uploaded_count?: number; - preprocessing_count?: number; - ready_count?: number; - queued_count?: number; - running_count?: number; - needs_review_count?: number; - progress_phase?: string; - progress: number; - items: BatchItem[]; - created_at: string; - completed_at: string | null; -}; - -type ArchiveSearchResult = { - document_id: string; - filename: string; - document_type: string | null; - language: string | null; - job_id: string | null; - result_id: string | null; - schema_id: string | null; - schema_name: string | null; - status: string | null; - matched_text: string | null; - created_at: string; -}; - -type PersistedWorkspaceState = { - mode: AppMode; - step: Step; - document_id: string | null; - schema_id: string | null; - job_id: string | null; - batch_id: string | null; - batch_item_id: string | null; - raw_id: string | null; - active_page: number; -}; - -type AuditEvent = { - id: string; - entity_type: string; - entity_id: string; - action: string; - message: string | null; - metadata: Record; - created_at: string; -}; - -type WebkitFileSystemEntry = { - isFile: boolean; - isDirectory: boolean; - name: string; - fullPath?: string; -}; - -type WebkitFileSystemFileEntry = WebkitFileSystemEntry & { - file: (success: (file: File) => void, error?: (error: DOMException) => void) => void; -}; - -type WebkitFileSystemDirectoryEntry = WebkitFileSystemEntry & { - createReader: () => { - readEntries: (success: (entries: WebkitFileSystemEntry[]) => void, error?: (error: DOMException) => void) => void; - }; -}; - -type DataTransferItemWithEntry = DataTransferItem & { - webkitGetAsEntry?: () => WebkitFileSystemEntry | null; -}; - -const initialFields: SchemaField[] = [ - { - local_id: "field_1", - key_name: "", - description: "", - output_format: "string" - } -]; - -function extractionValuesFromResult(result: ExtractionResult | null | undefined): Record { - return result?.corrected_output?.values ?? result?.validated_output.values ?? {}; -} - -function modeFromLocation(): AppMode { - const hash = window.location.hash.replace("#", ""); - if (hash.startsWith("workflow-result:")) return "workflow-result"; - if (isAppMode(hash)) return hash; - return "home"; -} - -function isAppMode(value: unknown): value is AppMode { - return value === "home" || value === "documents" || value === "raw" || value === "key-info" || value === "classifier" || value === "required-checker" || value === "workflow" || value === "workflow-result"; -} - -function replaceModeHash(nextMode: AppMode) { - const hash = nextMode === "home" ? "" : `#${nextMode}`; - window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}${hash}`); -} - -function workflowResultRunIdFromLocation() { - const hash = window.location.hash.replace("#", ""); - if (!hash.startsWith("workflow-result:")) return ""; - return decodeURIComponent(hash.slice("workflow-result:".length)); -} - -function savePersistedWorkspaceState(state: PersistedWorkspaceState) { - try { - window.localStorage.setItem(WORKSPACE_STATE_KEY, JSON.stringify(state)); - } catch { - // localStorage can be unavailable in private or restricted browser contexts. - } -} - -function readPersistedWorkspaceState(): PersistedWorkspaceState | null { - try { - const raw = window.localStorage.getItem(WORKSPACE_STATE_KEY); - if (!raw) return null; - const parsed = JSON.parse(raw) as Partial; - const mode = isAppMode(parsed.mode) ? parsed.mode : "home"; - const step = parsed.step === "upload" || parsed.step === "schema" || parsed.step === "review" ? parsed.step : "upload"; - return { - mode, - step, - document_id: typeof parsed.document_id === "string" ? parsed.document_id : null, - schema_id: typeof parsed.schema_id === "string" ? parsed.schema_id : null, - job_id: typeof parsed.job_id === "string" ? parsed.job_id : null, - batch_id: typeof parsed.batch_id === "string" ? parsed.batch_id : null, - batch_item_id: typeof parsed.batch_item_id === "string" ? parsed.batch_item_id : null, - raw_id: typeof parsed.raw_id === "string" ? parsed.raw_id : null, - active_page: typeof parsed.active_page === "number" ? parsed.active_page : 0 - }; - } catch { - return null; - } -} - -function clearPersistedWorkspaceState() { - try { - window.localStorage.removeItem(WORKSPACE_STATE_KEY); - } catch { - // Ignore unavailable storage. - } -} - -function readPersistedLeftPanePercent() { - try { - const raw = window.localStorage.getItem(LEFT_PANE_PERCENT_KEY); - if (!raw) return 50; - const parsed = Number.parseFloat(raw); - if (Number.isNaN(parsed)) return 50; - return Math.min(78, Math.max(35, parsed)); - } catch { - return 50; - } -} - -function savePersistedLeftPanePercent(percent: number) { - try { - window.localStorage.setItem(LEFT_PANE_PERCENT_KEY, String(percent)); - } catch { - // Ignore unavailable storage. - } -} - -function useObjectUrl(file: File | null) { - const [url, setUrl] = useState(null); - - useEffect(() => { - if (!file) { - setUrl(null); - return; - } - const nextUrl = URL.createObjectURL(file); - setUrl(nextUrl); - return () => URL.revokeObjectURL(nextUrl); - }, [file]); - - return url; -} - -function useVirtualFileList(count: number, activeIndex: number) { - const containerRef = useRef(null); - const [scrollTop, setScrollTop] = useState(0); - const [viewportHeight, setViewportHeight] = useState(480); - - useEffect(() => { - const element = containerRef.current; - if (!element) return; - - const updateHeight = () => setViewportHeight(element.clientHeight || 480); - updateHeight(); - - if (typeof ResizeObserver === "undefined") { - window.addEventListener("resize", updateHeight); - return () => window.removeEventListener("resize", updateHeight); - } - - const observer = new ResizeObserver(updateHeight); - observer.observe(element); - return () => observer.disconnect(); - }, []); - - useEffect(() => { - const element = containerRef.current; - if (!element || activeIndex < 0 || count <= 0) return; - - const rowTop = activeIndex * BATCH_FILE_ROW_HEIGHT; - const rowBottom = rowTop + BATCH_FILE_ROW_HEIGHT; - const viewTop = element.scrollTop; - const viewBottom = viewTop + element.clientHeight; - if (rowTop < viewTop) { - element.scrollTop = Math.max(0, rowTop - BATCH_FILE_ROW_HEIGHT * 2); - setScrollTop(element.scrollTop); - } else if (rowBottom > viewBottom) { - element.scrollTop = Math.max(0, rowBottom - element.clientHeight + BATCH_FILE_ROW_HEIGHT * 2); - setScrollTop(element.scrollTop); - } - }, [activeIndex, count]); - - const onScroll = useCallback((event: UIEvent) => { - setScrollTop(event.currentTarget.scrollTop); - }, []); - - const start = Math.max(0, Math.floor(scrollTop / BATCH_FILE_ROW_HEIGHT) - BATCH_FILE_OVERSCAN); - const visibleCount = Math.ceil(viewportHeight / BATCH_FILE_ROW_HEIGHT) + BATCH_FILE_OVERSCAN * 2; - const end = Math.min(count, start + visibleCount); - const spacerStyle = useMemo( - () => ({ height: Math.max(1, count) * BATCH_FILE_ROW_HEIGHT }), - [count] - ); - const windowStyle = useMemo( - () => ({ transform: `translateY(${start * BATCH_FILE_ROW_HEIGHT}px)` }), - [start] - ); - - return { containerRef, onScroll, start, end, spacerStyle, windowStyle }; -} - -async function filesFromDataTransfer(dataTransfer: DataTransfer) { - const items = Array.from(dataTransfer.items ?? []); - if (!items.length) return Array.from(dataTransfer.files ?? []); - - const files: File[] = []; - for (const item of items) { - if (item.kind !== "file") continue; - const entry = (item as DataTransferItemWithEntry).webkitGetAsEntry?.(); - if (entry) { - files.push(...(await filesFromEntry(entry))); - } else { - const file = item.getAsFile(); - if (file) files.push(file); - } - } - return files.length ? files : Array.from(dataTransfer.files ?? []); -} - -async function filesFromEntry(entry: WebkitFileSystemEntry, parentPath = ""): Promise { - if (entry.isFile) { - const file = await new Promise((resolve, reject) => { - (entry as WebkitFileSystemFileEntry).file(resolve, reject); - }); - const relativePath = `${parentPath}${file.name}`; - try { - Object.defineProperty(file, "webkitRelativePath", { - configurable: true, - value: relativePath - }); - } catch { - // Some browsers keep File metadata read-only. The file is still usable. - } - return [file]; - } - - if (!entry.isDirectory) return []; - - const directory = entry as WebkitFileSystemDirectoryEntry; - const reader = directory.createReader(); - const entries: WebkitFileSystemEntry[] = []; - while (true) { - const batch = await new Promise((resolve, reject) => { - reader.readEntries(resolve, reject); - }); - if (!batch.length) break; - entries.push(...batch); - } - - const nextPath = `${parentPath}${entry.name}/`; - const nested = await Promise.all(entries.map((item) => filesFromEntry(item, nextPath))); - return nested.flat(); -} - -function useUploadPickerMenu() { - const [open, setOpen] = useState(false); - const ref = useRef(null); - - useEffect(() => { - if (!open) return; - const onPointerDown = (event: globalThis.PointerEvent) => { - if (event.target instanceof globalThis.Node && ref.current?.contains(event.target)) return; - setOpen(false); - }; - window.addEventListener("pointerdown", onPointerDown); - return () => window.removeEventListener("pointerdown", onPointerDown); - }, [open]); - - return { - open, - ref, - close: () => setOpen(false), - toggle: () => setOpen((current) => !current) - }; -} - -function UnifiedUploadPicker(props: { - accept: string; - disabled?: boolean; - includeFolder?: boolean; - label?: string; - multiple?: boolean; - selectedCount?: number; - align?: "left" | "right"; - onSelectFiles: (files: FileList | null) => void; -}) { - const menu = useUploadPickerMenu(); - const includeFolder = props.includeFolder ?? true; - const multiple = props.multiple ?? true; - const triggerLabel = props.selectedCount ? `${props.selectedCount.toLocaleString()}개 파일 선택됨` : props.label ?? "업로드"; - const onChange = (event: ChangeEvent) => { - menu.close(); - props.onSelectFiles(event.target.files); - event.currentTarget.value = ""; - }; - - return ( -
- - {menu.open && !props.disabled && ( -
- - {includeFolder && ( - - )} -
- )} -
- ); -} - -function ExportMenuButton(props: { - options: ExportMenuOption[]; - compact?: boolean; - align?: "left" | "right"; -}) { - const menu = useUploadPickerMenu(); - const [pendingFormat, setPendingFormat] = useState(null); - const iconSize = props.compact ? 14 : 16; - const buttonClass = props.compact ? "secondary compact" : "secondary"; - const handleExport = async (option: ExportMenuOption) => { - if (!option.onExport || pendingFormat) return; - menu.close(); - setPendingFormat(option.format); - try { - await option.onExport(); - } catch (exc) { - window.alert(exc instanceof Error ? exc.message : "Export 요청에 실패했습니다."); - } finally { - setPendingFormat(null); - } - }; - - return ( -
- - {menu.open && ( -
- {props.options.map((option) => - option.onExport ? ( - - ) : ( - - {exportFormatIcon(option.format, iconSize)} {option.label ?? option.format.toUpperCase()} - - ) - )} -
- )} -
- ); -} - -function PolicyNotice({ compact = false }: { compact?: boolean }) { - return ( -
- -

- 업로드 문서는 추출과 검수를 위해 설정된 VLM provider로 전송될 수 있습니다. 외부 베타 기본 보존 기간은 24시간이며, - 문서 보관함 삭제는 원본과 page image payload를 삭제합니다. -

-
- ); -} +import { routes } from "@/app/routes"; export default function App() { - const [mode, setMode] = useState(() => modeFromLocation()); - const [step, setStep] = useState("upload"); - const [document, setDocument] = useState(null); - const [schemaName, setSchemaName] = useState("document_schema"); - const [schemaDescription, setSchemaDescription] = useState(""); - const [fields, setFields] = useState(initialFields); - const [regions, setRegions] = useState([]); - const [schema, setSchema] = useState(null); - const [schemaDirty, setSchemaDirty] = useState(false); - const [schemaSaveStatus, setSchemaSaveStatus] = useState<"idle" | "pending" | "saving" | "saved" | "error">("idle"); - const [schemaSaveMessage, setSchemaSaveMessage] = useState(null); - const [schemaJsonInput, setSchemaJsonInput] = useState(""); - const [job, setJob] = useState(null); - const [edits, setEdits] = useState>({}); - const [editsResultId, setEditsResultId] = useState(null); - const [editedKeys, setEditedKeys] = useState([]); - const [reviewFilter, setReviewFilter] = useState("all"); - const [activePage, setActivePage] = useState(0); - const [zoom, setZoom] = useState(1); - const [zoomMode, setZoomMode] = useState("fitWidth"); - const [rotation, setRotation] = useState(0); - const [regionsVisible, setRegionsVisible] = useState(false); - const [leftPanePercent, setLeftPanePercent] = useState(() => readPersistedLeftPanePercent()); - const [recentDocuments, setRecentDocuments] = useState([]); - const [recentSchemas, setRecentSchemas] = useState([]); - const [recentJobs, setRecentJobs] = useState([]); - const [rawExtraction, setRawExtraction] = useState(null); - const [recentRawExtractions, setRecentRawExtractions] = useState([]); - const [rawOptions, setRawOptions] = useState({ includeImages: true, includeFormulas: false }); - const [rawHistoryCollapsed, setRawHistoryCollapsed] = useState(false); - const [systemStatus, setSystemStatus] = useState(null); - const [vlmSettings, setVlmSettings] = useState(null); - const [homeWorkflowRuns, setHomeWorkflowRuns] = useState([]); - const [homeClassificationBatches, setHomeClassificationBatches] = useState([]); - const [homeRequiredBatches, setHomeRequiredBatches] = useState([]); - const [vlmApiKey, setVlmApiKey] = useState(""); - const [vlmModelName, setVlmModelName] = useState(""); - const [vlmBaseUrl, setVlmBaseUrl] = useState(""); - const [libreOfficePath, setLibreOfficePath] = useState("/Applications/LibreOffice.app/Contents/MacOS/soffice"); - const [vlmReasoningEffort, setVlmReasoningEffort] = useState("off"); - const [vlmVerbosity, setVlmVerbosity] = useState(""); - const [vlmTemperature, setVlmTemperature] = useState("0"); - const [vlmMaxCompletionTokens, setVlmMaxCompletionTokens] = useState(""); - const [vlmTopP, setVlmTopP] = useState(""); - const [vlmServiceTier, setVlmServiceTier] = useState(""); - const [workflowMaxWorkers, setWorkflowMaxWorkers] = useState("16"); - const [vlmMaxConcurrentRequests, setVlmMaxConcurrentRequests] = useState("128"); - const [vlmTimeoutSeconds, setVlmTimeoutSeconds] = useState("120"); - const [kieFieldGroupSize, setKieFieldGroupSize] = useState("2"); - const [settingsMessage, setSettingsMessage] = useState(null); - const [settingsOpen, setSettingsOpen] = useState(false); - const [pendingRecommendation, setPendingRecommendation] = useState(null); - const [archiveQuery, setArchiveQuery] = useState(""); - const [archiveStatus, setArchiveStatus] = useState(""); - const [archiveResults, setArchiveResults] = useState([]); - const [batches, setBatches] = useState([]); - const [exportPresets, setExportPresets] = useState([]); - const [selectedPresetId, setSelectedPresetId] = useState(""); - const [reviewedFields, setReviewedFields] = useState([]); - const [auditEvents, setAuditEvents] = useState([]); - const [historyTab, setHistoryTab] = useState("documents"); - const [historyOpen, setHistoryOpen] = useState(false); - const [archiveOpen, setArchiveOpen] = useState(false); - const [batchOpen, setBatchOpen] = useState(false); - const [schemaLibraryOpen, setSchemaLibraryOpen] = useState(false); - const [batchFiles, setBatchFiles] = useState([]); - const [selectedLibraryDocuments, setSelectedLibraryDocuments] = useState([]); - const [selectedWorkflowId, setSelectedWorkflowId] = useState(""); - const [draftBatchIndex, setDraftBatchIndex] = useState(0); - const [batchMessage, setBatchMessage] = useState(null); - const [activeBatchId, setActiveBatchId] = useState(null); - const [activeBatchItemId, setActiveBatchItemId] = useState(null); - const [busy, setBusy] = useState(null); - const [error, setError] = useState(null); - const topbarMenu = useUploadPickerMenu(); - const [workspaceRestored, setWorkspaceRestored] = useState(false); - const documentCacheRef = useRef>(new Map()); - const schemaCacheRef = useRef>(new Map()); - const jobCacheRef = useRef>(new Map()); - const loadJobRequestRef = useRef(0); - const loadJobAbortRef = useRef(null); - const schemaAutoSaveTimerRef = useRef(null); - const schemaAutoSaveRequestRef = useRef(0); - const schemaDraftKeyRef = useRef(""); - - useEffect(() => { - void bootstrapWorkspace(); - }, []); - - useEffect(() => { - if (!workspaceRestored) return; - if (mode === "workflow-result") return; - savePersistedWorkspaceState({ - mode, - step, - document_id: document?.document_id ?? null, - schema_id: schema?.id ?? null, - job_id: job?.job_id ?? null, - batch_id: activeBatchId, - batch_item_id: activeBatchItemId, - raw_id: rawExtraction?.id ?? null, - active_page: activePage - }); - }, [ - workspaceRestored, - mode, - step, - document?.document_id, - schema?.id, - job?.job_id, - activeBatchId, - activeBatchItemId, - rawExtraction?.id, - activePage - ]); - - useEffect(() => { - const onPopState = () => setMode(modeFromLocation()); - window.addEventListener("popstate", onPopState); - window.addEventListener("hashchange", onPopState); - return () => { - window.removeEventListener("popstate", onPopState); - window.removeEventListener("hashchange", onPopState); - }; - }, []); - - useEffect(() => { - if (schema?.id) { - void loadExportPresets(schema.id); - } - }, [schema?.id]); - - useEffect(() => { - if (!batchFiles.length) { - setDraftBatchIndex(0); - return; - } - setDraftBatchIndex((index) => Math.min(index, batchFiles.length - 1)); - }, [batchFiles.length]); - - const schemaPayloadFields = useMemo(() => fields.map(stripLocalId), [fields]); - const schemaPayloadRegions = useMemo(() => regions.map(normalizeSchemaRegion).filter(Boolean) as SchemaRegion[], [regions]); - const assignedSchemaRegions = useMemo(() => { - const assignedRegionIds = new Set(schemaPayloadFields.map((field) => field.region_id).filter(Boolean)); - return schemaPayloadRegions.filter((region) => assignedRegionIds.has(region.id)); - }, [schemaPayloadFields, schemaPayloadRegions]); - const assignedRegionsVisible = regionsVisible && assignedSchemaRegions.length > 0; - const schemaPreview = useMemo( - () => - JSON.stringify( - { - name: schemaName, - display_name: schemaName, - description: schemaDescription || null, - regions: schemaPayloadRegions, - fields: schemaPayloadFields - }, - null, - 2 - ), - [schemaName, schemaDescription, schemaPayloadFields, schemaPayloadRegions] - ); - - useEffect(() => { - schemaDraftKeyRef.current = schemaPreview; - }, [schemaPreview]); - - const schemaDownloadUrl = useMemo( - () => `data:application/json;charset=utf-8,${encodeURIComponent(schemaPreview)}`, - [schemaPreview] - ); - - const activeImageUrl = useMemo(() => { - if (!document?.pages.length) return null; - return documentPageImageSrc(document.pages[activePage]); - }, [document, activePage]); - - const rawPdfUrl = useMemo(() => (rawExtraction?.pdf_url ? `${API_BASE}${rawExtraction.pdf_url}` : null), [rawExtraction]); - const rawHtmlUrl = useMemo(() => (rawExtraction?.html_url ? `${API_BASE}${rawExtraction.html_url}` : null), [rawExtraction]); - const selectedDraftFile = batchFiles[draftBatchIndex] ?? batchFiles[0] ?? null; - const selectedDraftUrl = useObjectUrl(selectedDraftFile ?? null); - const draftRegionTarget = useMemo(() => { - if (!selectedDraftFile || !selectedDraftUrl || !isImageFile(selectedDraftFile)) return null; - return { - page_count: 1, - pages: [{ id: `draft_${draftBatchIndex}`, page: 1, image_url: selectedDraftUrl }] - }; - }, [draftBatchIndex, selectedDraftFile, selectedDraftUrl]); - const documentRegionTarget = useMemo(() => { - if (!document) return null; - return { - page_count: document.page_count, - pages: document.pages.map((page) => ({ id: page.id, page: page.page, image_url: page.image_url })) - }; - }, [document]); - const activeRegionTarget = documentRegionTarget ?? draftRegionTarget; - const activeRegionPage = document ? activePage : 0; - - const result = job?.result ?? null; - const resultValues = extractionValuesFromResult(result); - const currentValues = - result && editsResultId === result.id && Object.keys(edits).length ? edits : resultValues; - const templates = recentSchemas.filter((item) => item.is_template || item.pinned); - const schemaNameConflict = useMemo( - () => findSavedSchemaNameConflict(schemaName, recentSchemas, schema?.ephemeral ? null : schema), - [schemaName, recentSchemas, schema] - ); - const schemaValidationError = useMemo( - () => validateFields(schemaPayloadFields, schemaPayloadRegions), - [schemaPayloadFields, schemaPayloadRegions] - ); - - function applyResultReviewState(nextResult: ExtractionResult) { - setEdits(extractionValuesFromResult(nextResult)); - setEditsResultId(nextResult.id); - setReviewedFields(nextResult.reviewed_fields ?? []); - setEditedKeys([]); - setReviewFilter("all"); - } - - function clearResultReviewState() { - setEdits({}); - setEditsResultId(null); - setEditedKeys([]); - setReviewedFields([]); - } - - const activeSchemaSummary = { - name: schema?.display_name || schema?.name || schemaName.trim() || "Untitled schema", - fieldCount: schemaPayloadFields.length, - regionCount: schemaPayloadRegions.length, - ready: Boolean(schemaName.trim()) && !schemaValidationError && !schemaNameConflict, - status: schemaSaveStatusLabel(schemaSaveStatus, schema), - message: !schemaName.trim() - ? "Schema 이름을 입력하세요." - : schemaNameConflict - ? `이미 같은 이름의 schema가 있습니다: ${schemaNameConflict.display_name || schemaNameConflict.name}` - : schemaValidationHint(schemaValidationError) - }; - const activeBatch = useMemo( - () => (activeBatchId ? batches.find((batch) => batch.id === activeBatchId) ?? null : null), - [batches, activeBatchId] - ); - const activeBatchItem = useMemo(() => { - if (!activeBatch) return null; - return activeBatch.items.find((item) => item.id === activeBatchItemId) ?? activeBatch.items[0] ?? null; - }, [activeBatch, activeBatchItemId]); - const hasActiveBatch = useMemo( - () => - batches.some( - (batch) => - batchIsActive(batch) || - batch.items.some((item) => ["preprocessing", "queued", "running"].includes(item.status)) - ), - [batches] - ); - const shouldPollActiveBatch = Boolean(activeBatchId && (!activeBatch || batchIsActive(activeBatch))); - const batchPollingActive = shouldPollActiveBatch || hasActiveBatch; - const uploadMaxBatchFiles = systemStatus?.upload_max_batch_files ?? DEFAULT_MAX_BATCH_UPLOAD_FILES; - const uploadChunkFiles = systemStatus?.upload_chunk_files ?? DEFAULT_UPLOAD_CHUNK_FILES; - const hasPreparedSchema = - Boolean(document) || Boolean(schema) || batchFiles.length > 0 || schemaDirty || hasMeaningfulSchema(fields); - const schemaLibraryVisible = schemaLibraryOpen && hasPreparedSchema; - const keyInfoWorkspaceColumns = schemaLibraryVisible ? "minmax(0, 1fr) minmax(420px, 460px)" : "minmax(0, 1fr)"; - const keyInfoPaneColumns = `minmax(320px, ${leftPanePercent}%) 12px minmax(380px, 1fr)`; - const homeMonitorActive = useMemo( - () => - hasRunningHomeItems([ - ...homeWorkflowRuns, - ...batches, - ...homeClassificationBatches, - ...homeRequiredBatches - ]), - [homeWorkflowRuns, batches, homeClassificationBatches, homeRequiredBatches] - ); - - useEffect(() => { - if (!workspaceRestored || mode !== "key-info") return; - if (schemaAutoSaveTimerRef.current) { - window.clearTimeout(schemaAutoSaveTimerRef.current); - schemaAutoSaveTimerRef.current = null; - } - - if (!schemaDirty) { - setSchemaSaveStatus(schema ? "saved" : "idle"); - setSchemaSaveMessage(null); - return; - } - if (!schemaName.trim()) { - setSchemaSaveStatus("pending"); - setSchemaSaveMessage("Schema 이름을 입력하면 자동 저장됩니다."); - return; - } - if (schemaValidationError) { - setSchemaSaveStatus("pending"); - setSchemaSaveMessage(schemaValidationError); - return; - } - if (schemaNameConflict) { - setSchemaSaveStatus("error"); - setSchemaSaveMessage(`이미 저장된 schema 이름입니다: ${schemaNameConflict.display_name || schemaNameConflict.name}`); - return; - } - - setSchemaSaveStatus("pending"); - setSchemaSaveMessage(null); - const draftKey = schemaPreview; - schemaAutoSaveTimerRef.current = window.setTimeout(() => { - void autoSaveSchema(draftKey); - }, 900); - - return () => { - if (schemaAutoSaveTimerRef.current) { - window.clearTimeout(schemaAutoSaveTimerRef.current); - schemaAutoSaveTimerRef.current = null; - } - }; - }, [ - workspaceRestored, - mode, - schemaDirty, - schemaName, - schemaPreview, - schemaValidationError, - schemaNameConflict, - schema - ]); - - useEffect(() => { - if (!batchPollingActive) return; - const pollBatch = () => { - if (activeBatchId && shouldPollActiveBatch) { - void refreshBatch(activeBatchId); - } else { - void refreshBatches(); - } - void refreshActiveBatchItemJob(); - }; - pollBatch(); - const intervalId = window.setInterval(() => { - pollBatch(); - }, 1000); - return () => window.clearInterval(intervalId); - }, [batchPollingActive, shouldPollActiveBatch, activeBatchId, activeBatchItemId, job?.job_id, job?.result_id]); - - useEffect(() => { - if (mode !== "key-info" || batchPollingActive) return; - const intervalId = window.setInterval(() => { - void refreshBatches(); - }, 5000); - return () => window.clearInterval(intervalId); - }, [mode, batchPollingActive]); - - useEffect(() => { - if (mode !== "home") return; - const refreshHome = () => { - void refreshHomeMonitor(); - void refreshBatches(); - }; - refreshHome(); - const intervalId = window.setInterval(refreshHome, homeMonitorActive ? 1800 : 6000); - return () => window.clearInterval(intervalId); - }, [mode, homeMonitorActive]); - - async function bootstrapWorkspace() { - try { - await refreshAll(false); - await restoreWorkspaceState(); - setWorkspaceRestored(true); - } catch (err) { - setError(toFriendlyError(err)); - setWorkspaceRestored(true); - } - } - - async function seedBankPocTemplate() { - try { - setError(null); - setBusy("은행 서류 데모 준비 중"); - const seeded = await api("/api/templates/bank-documents-poc/seed", { method: "POST" }); - const seededDocuments = seeded.sample_documents?.length - ? seeded.sample_documents - : seeded.sample_document - ? [seeded.sample_document] - : []; - setSelectedLibraryDocuments(seededDocuments); - setSelectedWorkflowId(seeded.workflow.id); - window.localStorage.setItem("digitize_bank_poc_tour_pending_v1", "1"); - await Promise.all([refreshHomeMonitor(), refreshHistory()]); - navigateMode("workflow"); - } catch (err) { - setError(toFriendlyError(err)); - } finally { - setBusy(null); - } - } - - async function refreshAll(reloadCurrent = true) { - await Promise.all([refreshHistory(), refreshRawHistory(), refreshSystemStatus(), loadVlmSettings(), refreshHomeMonitor(), refreshBatches(), searchArchive()]); - if (reloadCurrent) { - await refreshCurrentWorkspace(); - } - } - - async function refreshCurrentWorkspace() { - try { - if (mode === "raw" && rawExtraction?.id) { - await loadRawExtraction(rawExtraction.id); - return; - } - if (mode === "key-info" && activeBatchId && activeBatchItem) { - await refreshBatches(); - await refreshActiveBatchItemJob(); - return; - } - if (job?.job_id) { - await loadJob(job.job_id); - return; - } - if (document?.document_id) { - const loadedDocument = await api(`/api/documents/${document.document_id}`); - setDocument(loadedDocument); - setActivePage((page) => Math.min(Math.max(0, page), Math.max(0, loadedDocument.page_count - 1))); - } - if (schema?.id) { - const loadedSchema = await api(`/api/schemas/${schema.id}`); - applySchema(loadedSchema); - } - } catch (err) { - setError(toFriendlyError(err)); - } - } - - async function restoreWorkspaceState() { - if (modeFromLocation() === "workflow-result") return; - const saved = readPersistedWorkspaceState(); - if (!saved) return; - const savedMode = isAppMode(saved.mode) ? saved.mode : modeFromLocation(); - replaceModeHash(savedMode); - setMode(savedMode); - try { - if (savedMode === "raw" && saved.raw_id) { - const loadedRaw = await api(`/api/raw-extractions/${saved.raw_id}`); - setRawExtraction(loadedRaw); - return; - } - - if (savedMode !== "key-info") { - return; - } - - if (savedMode === "key-info" && saved.batch_id) { - const loadedBatch = await api(`/api/batches/${saved.batch_id}`); - setBatches((current) => [loadedBatch, ...current.filter((batch) => batch.id !== loadedBatch.id)].slice(0, 12)); - const selectedItem = - loadedBatch.items.find((item) => item.id === saved.batch_item_id) ?? loadedBatch.items[0] ?? null; - setActiveBatchId(loadedBatch.id); - setActiveBatchItemId(selectedItem?.id ?? null); - if (selectedItem) { - await loadJob(selectedItem.job_id, { preserveBatch: true, forceReviewStep: true, silent: true }); - setStep("review"); - } - return; - } - - if (saved.job_id) { - const loadedJob = await api(`/api/extraction-jobs/${saved.job_id}`); - const [loadedDocument, loadedSchema] = await Promise.all([ - api(`/api/documents/${loadedJob.document_id}`), - api(`/api/schemas/${loadedJob.schema_id}`) - ]); - applyDocument(loadedDocument); - applySchema(loadedSchema); - setJob(loadedJob); - if (loadedJob.result) { - applyResultReviewState(loadedJob.result); - void loadAuditEvents("extraction_result", loadedJob.result.id); - } - setStep(saved.step ?? (loadedJob.result ? "review" : "schema")); - setActivePage(Math.min(Math.max(0, saved.active_page ?? 0), Math.max(0, loadedDocument.page_count - 1))); - return; - } - - if (saved.document_id) { - const loadedDocument = await api(`/api/documents/${saved.document_id}`); - applyDocument(loadedDocument); - setActivePage(Math.min(Math.max(0, saved.active_page ?? 0), Math.max(0, loadedDocument.page_count - 1))); - } - if (saved.schema_id) { - const loadedSchema = await api(`/api/schemas/${saved.schema_id}`); - applySchema(loadedSchema); - } - if (saved.document_id || saved.schema_id) { - setStep(saved.step === "review" ? "schema" : saved.step); - } - } catch { - clearPersistedWorkspaceState(); - } - } - - async function refreshHistory() { - try { - const [documents, schemas, jobs] = await Promise.all([ - api("/api/documents?limit=12"), - api("/api/schemas"), - api("/api/extraction-jobs?limit=12") - ]); - setRecentDocuments(documents); - setRecentSchemas(schemas); - setRecentJobs(jobs); - } catch { - // History should not block the primary workflow. - } - } - - async function refreshSystemStatus() { - try { - setSystemStatus(await api("/api/system/status")); - } catch { - setSystemStatus(null); - } - } - - async function refreshHomeMonitor() { - const [workflowRuns, classificationBatches, requiredBatches] = await Promise.all([ - safeApiList("/api/workflow-runs?limit=6"), - safeApiList("/api/classification-batches?limit=6&include_items=false"), - safeApiList("/api/required-field-check-batches?limit=6&include_items=false") - ]); - setHomeWorkflowRuns(workflowRuns); - setHomeClassificationBatches(classificationBatches); - setHomeRequiredBatches(requiredBatches); - } - - async function loadVlmSettings() { - try { - const settings = await api("/api/settings/vlm"); - setVlmSettings(settings); - setVlmModelName(settings.model_name ?? ""); - setVlmBaseUrl(settings.base_url ?? ""); - setLibreOfficePath(settings.libreoffice_path ?? "/Applications/LibreOffice.app/Contents/MacOS/soffice"); - setVlmReasoningEffort(settings.inference_params?.reasoning_effort ?? settings.reasoning_effort ?? "off"); - setVlmVerbosity(settings.inference_params?.verbosity ?? settings.verbosity ?? ""); - setVlmTemperature(settings.inference_params?.temperature ?? settings.temperature ?? "0"); - setVlmMaxCompletionTokens(settings.max_completion_tokens ?? ""); - setVlmTopP(settings.top_p ?? ""); - setVlmServiceTier(settings.service_tier ?? ""); - setWorkflowMaxWorkers(String(settings.workflow_max_workers ?? 16)); - setVlmMaxConcurrentRequests(String(settings.vlm_max_concurrent_requests ?? 128)); - setVlmTimeoutSeconds(String(settings.vlm_timeout_seconds ?? 120)); - setKieFieldGroupSize(String(settings.kie_field_group_size ?? 2)); - } catch { - setVlmSettings(null); - } - } - - async function saveVlmSettings() { - setBusy(".env 저장 중"); - setError(null); - setSettingsMessage(null); - try { - const settings = await api("/api/settings/vlm", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - api_key: vlmApiKey, - model_name: vlmModelName, - base_url: vlmBaseUrl, - libreoffice_path: libreOfficePath, - inference_params: { - reasoning_effort: vlmReasoningEffort, - thinking: vlmReasoningEffort, - temperature: vlmTemperature, - verbosity: vlmVerbosity, - max_completion_tokens: vlmMaxCompletionTokens, - top_p: vlmTopP, - service_tier: vlmServiceTier - }, - workflow_max_workers: Number.parseInt(workflowMaxWorkers, 10) || 16, - vlm_max_concurrent_requests: Number.parseInt(vlmMaxConcurrentRequests, 10) || 128, - vlm_timeout_seconds: Number.parseInt(vlmTimeoutSeconds, 10) || 120, - kie_field_group_size: Number.parseInt(kieFieldGroupSize, 10) || 2, - provider: "auto" - }) - }); - setVlmSettings(settings); - setVlmApiKey(""); - setSettingsMessage(".env 저장 완료"); - setSettingsOpen(false); - await refreshSystemStatus(); - } catch (err) { - setError(toFriendlyError(err)); - } finally { - setBusy(null); - } - } - - async function clearParsingHistory() { - const confirmed = window.confirm( - "저장된 문서, 추출 job/result, batch, raw extraction 기록을 모두 삭제합니다. 저장된 schema는 유지됩니다. 계속할까요?" - ); - if (!confirmed) return; - setBusy("파싱 기록 삭제 중"); - setError(null); - setSettingsMessage(null); - try { - const cleared = await api("/api/maintenance/parsing-history", { method: "DELETE" }); - setDocument(null); - setJob(null); - setRawExtraction(null); - setActiveBatchId(null); - setActiveBatchItemId(null); - setBatchFiles([]); - setBatches([]); - setRecentDocuments([]); - setRecentJobs([]); - setRecentRawExtractions([]); - setArchiveResults([]); - clearResultReviewState(); - setAuditEvents([]); - setReviewFilter("all"); - setStep("upload"); - clearPersistedWorkspaceState(); - await refreshHistory(); - setSettingsMessage(`파싱 기록 삭제 완료: ${cleared.counts.documents ?? 0}개 문서, ${cleared.counts.extraction_jobs ?? 0}개 job`); - } catch (err) { - setError(toFriendlyError(err)); - } finally { - setBusy(null); - } - } - - async function refreshBatches() { - try { - const items = await api("/api/batches?limit=12"); - if (activeBatchId && !items.some((batch) => batch.id === activeBatchId)) { - try { - const active = await api(`/api/batches/${activeBatchId}`); - setBatches([active, ...items].slice(0, 12)); - return; - } catch { - // If the active batch no longer exists, fall back to recent batches. - } - } - setBatches(items); - } catch { - // Keep the current UI state. A transient polling failure should not stop the next polling tick. - } - } - - async function refreshBatch(batchId: string) { - try { - const nextBatch = await api(`/api/batches/${batchId}/summary`); - setBatches((current) => { - const existing = current.find((item) => item.id === nextBatch.id); - const merged = nextBatch.items.length || !existing ? nextBatch : { ...nextBatch, items: existing.items }; - return [merged, ...current.filter((item) => item.id !== nextBatch.id)].slice(0, 12); - }); - } catch { - await refreshBatches(); - } - } - - async function refreshRawHistory() { - try { - const items = await api("/api/raw-extractions?limit=12"); - setRecentRawExtractions(items); - } catch { - setRecentRawExtractions([]); - } - } - - async function uploadRawFile(file: File, options: RawExtractionOptions) { - setBusy("원문 데이터 추출 중"); - setError(null); - try { - const form = new FormData(); - form.append("file", file); - form.append("include_images", String(options.includeImages)); - form.append("include_formulas", String(options.includeFormulas)); - const extracted = await api("/api/raw-extractions", { - method: "POST", - body: form - }); - setRawExtraction(extracted); - await refreshRawHistory(); - if (extracted.status === "failed") { - setError(extracted.error_message || "원문 데이터 추출에 실패했습니다."); - } - } catch (err) { - setError(toFriendlyError(err)); - } finally { - setBusy(null); - } - } - - async function loadRawExtraction(rawId: string) { - setBusy("원문 추출 결과 로드 중"); - setError(null); - try { - const loaded = await api(`/api/raw-extractions/${rawId}`); - setRawExtraction(loaded); - if (loaded.status === "failed") { - setError(loaded.error_message || "원문 데이터 추출에 실패했습니다."); - } - } catch (err) { - setError(toFriendlyError(err)); - } finally { - setBusy(null); - } - } - - async function searchArchive(nextQuery = archiveQuery, nextStatus = archiveStatus) { - try { - const params = new URLSearchParams(); - if (nextQuery.trim()) params.set("q", nextQuery.trim()); - if (nextStatus) params.set("status", nextStatus); - params.set("limit", "12"); - setArchiveResults(await api(`/api/archive/search?${params.toString()}`)); - } catch { - setArchiveResults([]); - } - } - - async function loadAuditEvents(entityType: string, entityId: string) { - try { - setAuditEvents(await api(`/api/audit-events?entity_type=${entityType}&entity_id=${entityId}&limit=8`)); - } catch { - setAuditEvents([]); - } - } - - async function loadExportPresets(schemaId: string) { - try { - setExportPresets(await api(`/api/export-presets?schema_id=${schemaId}`)); - } catch { - setExportPresets([]); - } - } - - async function uploadFile(file: File) { - setBusy("문서 업로드 중"); - setError(null); - try { - const form = new FormData(); - form.append("file", file); - const uploaded = await api("/api/documents", { - method: "POST", - body: form - }); - setActiveBatchId(null); - setActiveBatchItemId(null); - applyDocument(uploaded); - setStep("schema"); - await refreshHistory(); - } catch (err) { - setError(toFriendlyError(err)); - } finally { - setBusy(null); - } - } - - async function recommendSchema() { - setError(null); - try { - let sourceDocument = document; - if (!sourceDocument && selectedLibraryDocuments.length) { - const libraryDocument = selectedLibraryDocuments[draftBatchIndex] ?? selectedLibraryDocuments[0]; - sourceDocument = await api(`/api/documents/${libraryDocument.document_id}`); - setActiveBatchId(null); - setActiveBatchItemId(null); - applyDocument(sourceDocument); - setStep("schema"); - if (selectedLibraryDocuments.length === 1) { - setSelectedLibraryDocuments([]); - } - } - if (!sourceDocument && batchFiles.length) { - const sourceFile = batchFiles[draftBatchIndex] ?? batchFiles[0]; - if (sourceFile) { - setBusy("AI schema 추천용 문서 업로드 중"); - const form = new FormData(); - form.append("file", sourceFile); - sourceDocument = await api("/api/documents", { - method: "POST", - body: form - }); - setActiveBatchId(null); - setActiveBatchItemId(null); - applyDocument(sourceDocument); - setStep("schema"); - if (batchFiles.length === 1) { - setBatchFiles([]); - setDraftBatchIndex(0); - } - await refreshHistory(); - } - } - if (!sourceDocument) { - setError("AI 추천 schema를 사용하려면 먼저 문서나 파일을 선택하세요."); - return; - } - setBusy("AI schema 추천 중"); - const recommendation = await api("/api/schemas/recommendations", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ document_id: sourceDocument.document_id }) - }); - if (schemaDirty || hasMeaningfulSchema(fields)) { - setPendingRecommendation(recommendation); - } else { - applyRecommendation(recommendation); - } - const updatedDocument = await api(`/api/documents/${sourceDocument.document_id}`); - setDocument(updatedDocument); - await refreshHistory(); - } catch (err) { - setError(toFriendlyError(err)); - } finally { - setBusy(null); - } - } - - async function recommendSchemaDescription() { - const validationError = validateFields(schemaPayloadFields, schemaPayloadRegions); - if (validationError) { - setError(validationError); - return; - } - setBusy("스키마 설명 수정 중"); - setError(null); - try { - const recommendation = await api("/api/schemas/description-recommendations", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - document_id: document?.document_id ?? null, - name: schemaName.trim() || "draft_schema", - current_description: schemaDescription || null, - regions: schemaPayloadRegions, - fields: schemaPayloadFields - }) - }); - setSchemaDescription(recommendation.description); - setSchemaDirty(true); - } catch (err) { - setError(toFriendlyError(err)); - } finally { - setBusy(null); - } - } - - function applyRecommendation(recommendation: SchemaRecommendation) { - setSchema(null); - setSchemaName(recommendation.name || "ai_recommended_schema"); - setSchemaDescription(recommendation.description ?? ""); - setFields(toSchemaFields(recommendation.fields)); - setRegions([]); - setSchemaDirty(true); - setPendingRecommendation(null); - setStep("schema"); - } - - async function persistSchema(options: { silent: boolean; draftKey?: string }) { - const validationError = validateFields(schemaPayloadFields, schemaPayloadRegions); - if (validationError) { - if (!options.silent) setError(validationError); - setSchemaSaveMessage(validationError); - return null; - } - const conflict = findSavedSchemaNameConflict(schemaName, recentSchemas, schema?.ephemeral ? null : schema); - if (conflict) { - const message = `이미 저장된 schema 이름입니다: ${conflict.display_name || conflict.name}. 드롭다운에서 불러오거나 다른 이름으로 저장하세요.`; - if (!options.silent) { - setError(message); - } - setSchemaSaveMessage(message); - return null; - } - if (!options.silent) { - setBusy(schema ? "Schema 저장 중" : "Schema 생성 중"); - setError(null); - } - try { - const body = JSON.stringify({ - name: schemaName, - display_name: schemaName, - description: schemaDescription || null, - regions: schemaPayloadRegions, - fields: schemaPayloadFields - }); - const saved = await api(schema ? `/api/schemas/${schema.id}` : "/api/schemas", { - method: schema ? "PATCH" : "POST", - headers: { "Content-Type": "application/json" }, - body - }); - setSchema(saved); - schemaCacheRef.current.set(saved.id, saved); - if (!options.draftKey || schemaDraftKeyRef.current === options.draftKey) { - setSchemaDirty(false); - setSchemaSaveStatus("saved"); - setSchemaSaveMessage(null); - } - await refreshHistory(); - return saved; - } catch (err) { - const message = toFriendlyError(err); - setSchemaSaveMessage(message); - if (!options.silent) setError(message); - return null; - } finally { - if (!options.silent) setBusy(null); - } - } - - async function autoSaveSchema(draftKey: string) { - const requestId = ++schemaAutoSaveRequestRef.current; - setSchemaSaveStatus("saving"); - const saved = await persistSchema({ silent: true, draftKey }); - if (requestId !== schemaAutoSaveRequestRef.current) return; - if (saved) { - setSchemaSaveStatus(schemaDraftKeyRef.current === draftKey ? "saved" : "pending"); - } else { - setSchemaSaveStatus("error"); - } - } - - async function runExtraction() { - if (!document) { - setError("먼저 문서를 업로드하세요."); - return; - } - const validationError = validateFields(schemaPayloadFields, schemaPayloadRegions); - if (validationError) { - setError(validationError); - return; - } - const useSavedSchema = Boolean(schema && !schemaDirty && !schema.ephemeral); - - setBusy("핵심 정보 추출 중"); - setError(null); - try { - const created = useSavedSchema - ? await api("/api/extraction-jobs", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - document_id: document.document_id, - schema_id: schema!.id - }) - }) - : await api("/api/extraction-jobs/draft", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - document_id: document.document_id, - schema: { - name: schemaName.trim() || "draft_schema", - display_name: schemaName.trim() || "draft_schema", - description: schemaDescription || null, - regions: schemaPayloadRegions, - fields: schemaPayloadFields - } - }) - }); - setActiveBatchId(null); - setActiveBatchItemId(null); - setJob(created); - clearResultReviewState(); - const completed = await pollJob(created.job_id, { - onProgress: (nextJob, elapsedMs) => { - setJob(nextJob); - if (elapsedMs >= EXTRACTION_LONG_RUNNING_NOTICE_MS) { - setBusy(`핵심 정보 추출 중 · ${formatElapsedTime(elapsedMs)} 경과 · 로컬 VLM은 수 분 이상 걸릴 수 있습니다`); - } - } - }); - setJob(completed); - if (completed.result) { - applyResultReviewState(completed.result); - setStep("review"); - void loadAuditEvents("extraction_result", completed.result.id); - } - if (completed.status === "failed") { - setError(completed.error_message || "추출에 실패했습니다."); - } - await refreshHistory(); - } catch (err) { - setError(toFriendlyError(err)); - } finally { - setBusy(null); - } - } - - async function saveCorrections() { - if (!result) return; - setBusy("수정 결과 저장 중"); - setError(null); - try { - const correctedOutput: ValidatedOutput = { - ...(result.corrected_output ?? result.validated_output), - values: currentValues - }; - const updated = await api(`/api/extraction-results/${result.id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ corrected_output: correctedOutput, reviewed_fields: reviewedFields }) - }); - setJob((current) => (current ? { ...current, result: updated } : current)); - applyResultReviewState(updated); - await loadAuditEvents("extraction_result", updated.id); - await refreshHistory(); - } catch (err) { - setError(toFriendlyError(err)); - } finally { - setBusy(null); - } - } - - function prepareExtractionRetry() { - setJob(null); - clearResultReviewState(); - setReviewFilter("needs_review"); - setError(null); - setStep("schema"); - } - - async function loadDocument(documentId: string) { - setBusy("문서 로드 중"); - setError(null); - try { - setActiveBatchId(null); - setActiveBatchItemId(null); - const loaded = await api(`/api/documents/${documentId}`); - applyDocument(loaded); - void loadAuditEvents("document", loaded.document_id); - const jobs = await api(`/api/extraction-jobs?document_id=${documentId}&limit=1`); - if (jobs[0]) { - setJob(jobs[0]); - const loadedSchema = await api(`/api/schemas/${jobs[0].schema_id}`); - applySchema(loadedSchema); - if (jobs[0].result) { - applyResultReviewState(jobs[0].result); - setStep("review"); - void loadAuditEvents("extraction_result", jobs[0].result.id); - } else { - clearResultReviewState(); - setStep("schema"); - } - } else { - setStep("schema"); - } - } catch (err) { - setError(toFriendlyError(err)); - } finally { - setBusy(null); - } - } - - async function loadSchema(schemaId: string) { - setBusy("Schema 로드 중"); - setError(null); - try { - setActiveBatchId(null); - setActiveBatchItemId(null); - const loaded = await getCachedSchema(schemaId, { force: true }); - applySchema(loaded); - setStep("schema"); - } catch (err) { - setError(toFriendlyError(err)); - } finally { - setBusy(null); - } - } - - async function deleteSchema(schemaId: string) { - const target = recentSchemas.find((item) => item.id === schemaId) ?? (schema?.id === schemaId ? schema : null); - if (!target) return; - setBusy("Schema 삭제 중"); - setError(null); - try { - await api(`/api/schemas/${schemaId}`, { method: "DELETE" }); - schemaCacheRef.current.delete(schemaId); - setRecentSchemas((current) => current.filter((item) => item.id !== schemaId)); - if (schema?.id === schemaId) { - startNewSchemaDraft(); - } - await refreshHistory(); - } catch (err) { - setError(toFriendlyError(err)); - } finally { - setBusy(null); - } - } - - async function duplicateSchema(schemaId: string) { - setBusy("Schema 복제 중"); - setError(null); - try { - const duplicated = await api(`/api/schemas/${schemaId}/duplicate`, { method: "POST" }); - schemaCacheRef.current.set(duplicated.id, duplicated); - setRecentSchemas((current) => [duplicated, ...current.filter((item) => item.id !== duplicated.id)]); - applySchema(duplicated); - setSchemaDirty(false); - setSchemaSaveStatus("saved"); - setSchemaSaveMessage(null); - setStep("schema"); - await refreshHistory(); - } catch (err) { - setError(toFriendlyError(err)); - } finally { - setBusy(null); - } - } - - async function getCachedDocument(documentId: string, options: { signal?: AbortSignal } = {}) { - const cached = documentCacheRef.current.get(documentId); - if (cached) return cached; - const loaded = await api(`/api/documents/${documentId}`, { signal: options.signal }); - documentCacheRef.current.set(documentId, loaded); - return loaded; - } - - async function getCachedSchema(schemaId: string, options: { force?: boolean; signal?: AbortSignal } = {}) { - const cached = schemaCacheRef.current.get(schemaId); - if (cached && !options.force) return cached; - const loaded = await api(`/api/schemas/${schemaId}`, { signal: options.signal }); - schemaCacheRef.current.set(schemaId, loaded); - return loaded; - } - - function applyLoadedJob( - loadedJob: ExtractionJob, - loadedDocument: UploadedDocument, - loadedSchema: SavedSchema, - options: { forceReviewStep?: boolean } = {} - ) { - applyDocument(loadedDocument, { clearExtractionState: false }); - if (!schema || schema.id !== loadedSchema.id) { - applySchema(loadedSchema); - } - setJob(loadedJob); - if (loadedJob.result) { - applyResultReviewState(loadedJob.result); - setStep("review"); - void loadAuditEvents("extraction_result", loadedJob.result.id); - } else { - clearResultReviewState(); - setStep(options.forceReviewStep ? "review" : "schema"); - } - } - - async function loadJob( - jobId: string, - options: { preserveBatch?: boolean; forceReviewStep?: boolean; silent?: boolean } = {} - ) { - const requestId = ++loadJobRequestRef.current; - loadJobAbortRef.current?.abort(); - const controller = new AbortController(); - loadJobAbortRef.current = controller; - if (!options.silent) setBusy("추출 결과 로드 중"); - setError(null); - try { - if (!options.preserveBatch) { - setActiveBatchId(null); - setActiveBatchItemId(null); - } - - const cachedJob = jobCacheRef.current.get(jobId); - const cachedDocument = cachedJob ? documentCacheRef.current.get(cachedJob.document_id) : null; - const cachedSchema = cachedJob ? schemaCacheRef.current.get(cachedJob.schema_id) : null; - if (cachedJob && cachedDocument && cachedSchema) { - applyLoadedJob(cachedJob, cachedDocument, cachedSchema, { forceReviewStep: options.forceReviewStep }); - } - - const loadedJob = await api(`/api/extraction-jobs/${jobId}`, { signal: controller.signal }); - jobCacheRef.current.set(jobId, loadedJob); - const [loadedDocument, loadedSchema] = await Promise.all([ - getCachedDocument(loadedJob.document_id, { signal: controller.signal }), - getCachedSchema(loadedJob.schema_id, { signal: controller.signal }) - ]); - if (requestId !== loadJobRequestRef.current || controller.signal.aborted) return; - applyLoadedJob(loadedJob, loadedDocument, loadedSchema, { forceReviewStep: options.forceReviewStep }); - } catch (err) { - if (isAbortError(err)) return; - setError(toFriendlyError(err)); - } finally { - if (loadJobAbortRef.current === controller) loadJobAbortRef.current = null; - if (!options.silent && requestId === loadJobRequestRef.current) setBusy(null); - } - } - - async function openBatchItem(batchId: string, itemId: string, batchOverride?: Batch) { - const sourceBatch = batchOverride ?? batches.find((batch) => batch.id === batchId) ?? (await api(`/api/batches/${batchId}`)); - const item = sourceBatch.items.find((candidate) => candidate.id === itemId) ?? sourceBatch.items[0]; - if (!item) return; - if (sourceBatch.id === activeBatchId && item.id === activeBatchItemId && job?.job_id === item.job_id) return; - setBatches((current) => [sourceBatch, ...current.filter((batch) => batch.id !== sourceBatch.id)].slice(0, 12)); - setActiveBatchId(sourceBatch.id); - setActiveBatchItemId(item.id); - setStep("review"); - await loadJob(item.job_id, { preserveBatch: true, forceReviewStep: true, silent: true }); - } - - function openNextBatchReviewItem(batch: Batch) { - const reviewItems = batch.items.filter((item) => item.status === "needs_review"); - if (!reviewItems.length) return; - const currentIndex = reviewItems.findIndex((item) => item.id === activeBatchItemId); - const nextItem = reviewItems[(currentIndex + 1) % reviewItems.length]; - setReviewFilter("needs_review"); - void openBatchItem(batch.id, nextItem.id, batch); - } - - async function refreshActiveBatchItemJob() { - if (!activeBatchItem) return; - try { - const loadedJob = await api(`/api/extraction-jobs/${activeBatchItem.job_id}`); - jobCacheRef.current.set(loadedJob.job_id, loadedJob); - if (loadedJob.job_id !== job?.job_id) { - await loadJob(loadedJob.job_id, { preserveBatch: true, forceReviewStep: true, silent: true }); - return; - } - setJob(loadedJob); - if (loadedJob.result && (loadedJob.result_id !== job?.result_id || !result)) { - applyResultReviewState(loadedJob.result); - setStep("review"); - void loadAuditEvents("extraction_result", loadedJob.result.id); - } - } catch { - // Polling should not interrupt the visible batch review workflow. - } - } - - function applyDocument(nextDocument: UploadedDocument, options: { clearExtractionState?: boolean } = {}) { - const isSameDocument = document?.document_id === nextDocument.document_id; - documentCacheRef.current.set(nextDocument.document_id, nextDocument); - setDocument((current) => (current?.document_id === nextDocument.document_id ? current : nextDocument)); - if (!isSameDocument) { - setActivePage(0); - setRotation(0); - } - if (options.clearExtractionState === false) return; - setJob(null); - clearResultReviewState(); - setAuditEvents([]); - } - - function clearDocumentForNewUpload() { - setDocument(null); - setActivePage(0); - setRotation(0); - setJob(null); - setActiveBatchId(null); - setActiveBatchItemId(null); - clearResultReviewState(); - setAuditEvents([]); - setReviewFilter("all"); - setStep("upload"); - } - - function applySchema(nextSchema: SavedSchema) { - const normalized = normalizeSchemaFieldsAndRegions(nextSchema.fields, nextSchema.regions ?? []); - schemaCacheRef.current.set(nextSchema.id, nextSchema); - setSchema(nextSchema.ephemeral ? null : nextSchema); - setSchemaName(nextSchema.name); - setSchemaDescription(nextSchema.description ?? ""); - setRegions(normalized.regions); - if (normalized.regions.length) setRegionsVisible(true); - setFields(toSchemaFields(normalized.fields)); - setSchemaDirty(false); - } - - function startNewSchemaDraft() { - setSchema(null); - setSchemaName("document_schema"); - setSchemaDescription(""); - setRegions([]); - setFields(initialFields.map((field) => ({ ...field, local_id: createLocalId() }))); - setSchemaDirty(true); - setSchemaSaveStatus("pending"); - setSchemaSaveMessage("Schema 이름과 필드를 입력하면 자동 저장됩니다."); - setStep("schema"); - } - - function applySampleSchema() { - setSchema(null); - setSchemaName("sample_document_schema"); - setSchemaDescription("일반 업무 문서에 바로 쓸 수 있는 시작용 schema입니다."); - setRegions([]); - setFields(toSchemaFields(SAMPLE_SCHEMA_FIELDS)); - setSchemaDirty(true); - setStep("schema"); - } - - function importSchemaJson() { - try { - const parsed = JSON.parse(schemaJsonInput) as Partial; - if (!parsed.name || !Array.isArray(parsed.fields)) { - setError("Schema JSON에는 name과 fields가 필요합니다."); - return; - } - const fieldsFromJson = parsed.fields.map((field) => ({ - key_name: String(field.key_name ?? "").trim(), - description: String(field.description ?? "").trim(), - output_format: field.output_format as OutputFormat, - region_id: typeof field.region_id === "string" ? field.region_id.trim() || null : null, - region: normalizeRegion(field.region), - judgement_enabled: Boolean(field.judgement_enabled) - })); - const regionsFromJson = Array.isArray((parsed as { regions?: unknown }).regions) - ? ((parsed as { regions?: unknown[] }).regions ?? []).map(normalizeSchemaRegion).filter(Boolean) as SchemaRegion[] - : []; - const normalized = normalizeSchemaFieldsAndRegions(fieldsFromJson, regionsFromJson); - const validationError = validateFields(normalized.fields, normalized.regions); - if (validationError) { - setError(validationError); - return; - } - setSchema(null); - setSchemaName(parsed.name); - setSchemaDescription(parsed.description ?? ""); - setRegions(normalized.regions); - if (normalized.regions.length) setRegionsVisible(true); - setFields(toSchemaFields(normalized.fields)); - setSchemaDirty(true); - setError(null); - } catch { - setError("Schema JSON을 해석할 수 없습니다."); - } - } - - function updateField(index: number, patch: Partial) { - setSchemaDirty(true); - setFields((current) => current.map((field, fieldIndex) => (fieldIndex === index ? { ...field, ...patch } : field))); - } - - function saveRegion(region: SchemaRegion) { - const normalized = normalizeSchemaRegion(region); - if (!normalized) return; - setSchemaDirty(true); - setRegionsVisible(true); - setRegions((current) => { - const exists = current.some((item) => item.id === normalized.id); - return exists ? current.map((item) => (item.id === normalized.id ? normalized : item)) : [...current, normalized]; - }); - } - - function removeRegion(regionId: string) { - setSchemaDirty(true); - setRegions((current) => current.filter((region) => region.id !== regionId)); - setFields((current) => - current.map((field) => (field.region_id === regionId ? { ...field, region_id: null } : field)) - ); - } - - function addField() { - setSchemaDirty(true); - setFields((current) => [ - ...current, - { - local_id: createLocalId(), - key_name: `field_${current.length + 1}`, - description: "", - output_format: "string" - } - ]); - } - - function removeField(index: number) { - setSchemaDirty(true); - setFields((current) => current.filter((_, fieldIndex) => fieldIndex !== index)); - } - - function updateEdit(key: string, rawValue: string) { - if (!result) return; - const field = fields.find((item) => item.key_name === key); - const parsed = parseEditedValue(rawValue, field?.output_format ?? "string"); - setEditsResultId(result.id); - setEditedKeys((current) => (current.includes(key) ? current : [...current, key])); - setEdits((current) => { - const baseValues = - editsResultId === result.id && Object.keys(current).length ? current : resultValues; - return { - ...baseValues, - [key]: { - ...baseValues[key], - value: parsed, - normalized_value: parsed - } - }; - }); - } - - function toggleReviewed(key: string) { - setReviewedFields((current) => (current.includes(key) ? current.filter((item) => item !== key) : [...current, key])); - } - - async function markSchemaAsTemplate(category = "일반") { - if (!schema) { - setError("템플릿으로 추가하기 전에 schema를 저장하세요."); - return; - } - setBusy("템플릿 저장 중"); - setError(null); - try { - const updated = await api(`/api/schemas/${schema.id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ is_template: true, template_category: category, pinned: true }) - }); - applySchema(updated); - await refreshHistory(); - } catch (err) { - setError(toFriendlyError(err)); - } finally { - setBusy(null); - } - } - - function openBatchExtraction() { - setBatchMessage(null); - setBatchOpen(true); - void refreshBatches(); - } - - function selectBatchFiles(files: FileList | null) { - const selected = files ? Array.from(files) : []; - const supported = sortFilesByDisplayName( - selected.filter((file) => KIE_FILE_EXTENSIONS.has(file.name.split(".").pop()?.toLowerCase() ?? "")) - ); - const ignoredCount = selected.length - supported.length; - if (supported.length > uploadMaxBatchFiles) { - setBatchFiles([]); - setBatchMessage(`한 번에 최대 ${uploadMaxBatchFiles.toLocaleString()}개 파일까지 업로드할 수 있습니다.`); - return; - } - setSelectedLibraryDocuments([]); - setBatchMessage(ignoredCount ? `지원하지 않는 파일 ${ignoredCount}개는 제외했습니다.` : null); - setBatchFiles(supported); - } - - function selectKieUploadFiles(files: FileList | File[] | null) { - const selected = files ? Array.from(files) : []; - const supported = sortFilesByDisplayName( - selected.filter((file) => KIE_FILE_EXTENSIONS.has(file.name.split(".").pop()?.toLowerCase() ?? "")) - ); - const ignoredCount = selected.length - supported.length; - if (supported.length > uploadMaxBatchFiles) { - setBatchFiles([]); - setDraftBatchIndex(0); - setBatchMessage(`한 번에 최대 ${uploadMaxBatchFiles.toLocaleString()}개 파일까지 업로드할 수 있습니다.`); - return; - } - if (!supported.length) { - setBatchFiles([]); - setDraftBatchIndex(0); - setSelectedLibraryDocuments([]); - setBatchMessage(ignoredCount ? `지원하지 않는 파일 ${ignoredCount}개는 제외했습니다.` : null); - return; - } - setSelectedLibraryDocuments([]); - setBatchFiles(supported); - setDraftBatchIndex(0); - setBatchMessage(ignoredCount ? `지원하지 않는 파일 ${ignoredCount}개는 제외했습니다.` : null); - } - - async function runKieUploadSelection() { - if (!batchFiles.length && !selectedLibraryDocuments.length) { - setBatchMessage("실행할 파일이나 폴더를 선택하세요."); - return; - } - if (selectedLibraryDocuments.length === 1 && !batchFiles.length) { - const [libraryDocument] = selectedLibraryDocuments; - setSelectedLibraryDocuments([]); - setBatchMessage(null); - await loadDocument(libraryDocument.document_id); - return; - } - if (selectedLibraryDocuments.length > 1 && !batchFiles.length) { - await runBatchUpload(); - return; - } - if (batchFiles.length === 1) { - const [file] = batchFiles; - setBatchFiles([]); - setDraftBatchIndex(0); - setBatchMessage(null); - await uploadFile(file); - return; - } - await runBatchUpload(); - } - - async function runBatchUpload() { - if (!batchFiles.length && !selectedLibraryDocuments.length) { - setBatchMessage("배치 처리할 파일이나 폴더를 선택하세요."); - return; - } - if (!schemaName.trim()) { - setBatchMessage("우측 schema 영역에서 schema 이름을 먼저 입력하세요."); - return; - } - if (schemaValidationError) { - setBatchMessage(`우측 schema를 먼저 완성하세요. ${schemaValidationError}`); - return; - } - setBusy("배치 추출 준비 중"); - setError(null); - setBatchMessage(null); - try { - let selectedSchema = schema && !schema.ephemeral ? schema : null; - if (schemaDirty || !selectedSchema) { - setSchemaSaveStatus("saving"); - const saved = await persistSchema({ silent: true, draftKey: schemaDraftKeyRef.current }); - if (!saved) { - setBatchMessage("현재 활성 schema를 저장하지 못해 배치 추출을 시작하지 않았습니다. 우측 schema 상태를 확인하세요."); - return; - } - selectedSchema = saved; - } - if (selectedLibraryDocuments.length) { - const batch = await api("/api/batches/from-documents", { - method: "POST", - body: JSON.stringify({ - schema_id: selectedSchema.id, - document_ids: selectedLibraryDocuments.map((item) => item.document_id) - }) - }); - setBatches((current) => [batch, ...current.filter((item) => item.id !== batch.id)].slice(0, 12)); - setActiveBatchId(batch.id); - setActiveBatchItemId(batch.items[0]?.id ?? null); - setSelectedLibraryDocuments([]); - setBatchMessage( - `${batch.total_count}개 보관 문서의 배치 추출을 시작했습니다. 준비 중인 문서는 변환이 끝나면 자동으로 실행됩니다.` - ); - if (batch.items[0]) { - await openBatchItem(batch.id, batch.items[0].id, batch); - } else { - setStep("review"); - } - await refreshBatches(); - return; - } - const uploadFiles = sortFilesByDisplayName(batchFiles); - const uploadedDocuments: LibraryDocument[] = []; - let uploadedCount = 0; - for (let chunkStart = 0; chunkStart < uploadFiles.length; chunkStart += uploadChunkFiles) { - const chunk = uploadFiles.slice(chunkStart, chunkStart + uploadChunkFiles); - setBusy(`${uploadedCount.toLocaleString()} / ${uploadFiles.length.toLocaleString()} 문서 보관함 업로드 중`); - const uploaded = await uploadLibraryFiles(chunk); - uploadedDocuments.push(...uploaded); - uploadedCount += chunk.length; - setBusy(`${uploadedCount.toLocaleString()} / ${uploadFiles.length.toLocaleString()} 문서 보관함 업로드 중`); - } - const batch = await api("/api/batches/from-documents", { - method: "POST", - body: JSON.stringify({ - schema_id: selectedSchema.id, - document_ids: uploadedDocuments.map((item) => item.document_id) - }) - }); - if (!batch) throw new Error("배치 작업을 생성하지 못했습니다."); - setBatches((current) => [batch, ...current.filter((item) => item.id !== batch.id)].slice(0, 12)); - setActiveBatchId(batch.id); - setActiveBatchItemId(batch.items[0]?.id ?? null); - setBatchFiles([]); - setBatchMessage( - `${batch.total_count}개 파일의 배치 추출을 시작했습니다. 좌측 파일 목록에서 항목을 선택해 결과를 확인하세요.` - ); - if (batch.items[0]) { - await openBatchItem(batch.id, batch.items[0].id, batch); - } else { - setStep("review"); - } - await refreshBatches(); - } catch (err) { - setError(toFriendlyError(err)); - } finally { - setBusy(null); - } - } - - async function discardBatch(batchId: string) { - setBusy("배치 추출 중단·정리 중"); - setError(null); - setBatchMessage(null); - try { - const discarded = await api(`/api/batches/${batchId}/discard`, { method: "POST" }); - setBatches((current) => [discarded, ...current.filter((item) => item.id !== discarded.id)].slice(0, 12)); - setActiveBatchItemId(null); - setDocument(null); - setJob(null); - clearResultReviewState(); - setBatchMessage("배치 기록만 남기고 업로드 산출물을 정리했습니다."); - await refreshBatches(); - } catch (err) { - setError(toFriendlyError(err)); - } finally { - setBusy(null); - } - } - - async function resumeBatch(batchId: string) { - setBusy("배치 추출 계속 처리 중"); - setError(null); - setBatchMessage(null); - try { - const resumed = await api(`/api/batches/${batchId}/resume`, { method: "POST" }); - setBatches((current) => [resumed, ...current.filter((item) => item.id !== resumed.id)].slice(0, 12)); - setActiveBatchId(resumed.id); - setActiveBatchItemId(resumed.items[0]?.id ?? null); - setBatchMessage("등록된 파일 기준으로 배치 처리를 계속합니다."); - await refreshBatches(); - } catch (err) { - setError(toFriendlyError(err)); - } finally { - setBusy(null); - } - } - - async function saveDefaultExportPreset() { - if (!schema) { - setError("Export preset을 만들기 전에 schema를 저장하세요."); - return; - } - const name = `${schemaName || "schema"} export`; - setBusy("Export preset 저장 중"); - setError(null); - try { - const preset = await api("/api/export-presets", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - schema_id: schema.id, - name, - fields: fields.map((field) => ({ key_name: field.key_name, column_name: field.key_name, include: true })) - }) - }); - setExportPresets((current) => [preset, ...current]); - setSelectedPresetId(preset.id); - } catch (err) { - setError(toFriendlyError(err)); - } finally { - setBusy(null); - } - } - - async function loadArchiveResult(item: ArchiveSearchResult) { - if (item.job_id) { - await loadJob(item.job_id); - } else { - await loadDocument(item.document_id); - } - } - - function navigateMode(nextMode: AppMode) { - topbarMenu.close(); - const hash = nextMode === "home" ? "" : `#${nextMode}`; - window.history.pushState(null, "", `${window.location.pathname}${window.location.search}${hash}`); - if (nextMode !== "key-info") setSchemaLibraryOpen(false); - setMode(nextMode); - } - - async function handleLibraryRunSelection(target: "raw" | "key-info" | "classifier" | "required-checker" | "workflow", documents: LibraryDocument[]) { - if (!documents.length) return; - if (target === "raw") { - await runRawFromLibraryDocument(documents[0]); - return; - } - setSelectedLibraryDocuments(documents); - setBatchFiles([]); - setDraftBatchIndex(0); - navigateMode(target); - } - - async function runRawFromLibraryDocument(libraryDocument: LibraryDocument) { - setBusy("원문 데이터 추출 중"); - setError(null); - try { - const created = await api("/api/raw-extractions/from-document", { - method: "POST", - body: JSON.stringify({ - document_id: libraryDocument.document_id, - include_images: rawOptions.includeImages, - include_formulas: rawOptions.includeFormulas - }) - }); - setRawExtraction(created); - setRecentRawExtractions((items) => [created, ...items.filter((item) => item.id !== created.id)].slice(0, 12)); - navigateMode("raw"); - } catch (err) { - setError(toFriendlyError(err)); - } finally { - setBusy(null); - } - } - - function modeTitle(currentMode: AppMode) { - if (currentMode === "home") return "Document Automation Workspace"; - if (currentMode === "documents") return "문서 보관함"; - if (currentMode === "raw") return "원문 데이터 추출"; - if (currentMode === "classifier") return "문서 분류"; - if (currentMode === "required-checker") return "필수 항목 확인"; - if (currentMode === "workflow") return "워크플로우 빌더"; - if (currentMode === "workflow-result") return "워크플로우 실행 결과"; - return "핵심 정보 추출"; - } - - function goToPage(page: number | null) { - if (!document || !page) return; - setActivePage(Math.min(document.page_count - 1, Math.max(0, page - 1))); - } - - function startResize(event: PointerEvent) { - event.preventDefault(); - const workspace = - event.currentTarget.closest(".resize-scope") ?? - event.currentTarget.closest(".workspace"); - if (!workspace) return; - const rect = workspace.getBoundingClientRect(); - const pointerId = event.pointerId; - event.currentTarget.setPointerCapture(pointerId); - - const update = (clientX: number) => { - const percent = ((clientX - rect.left) / rect.width) * 100; - const minLeftWidth = 320; - const minRightWidth = 380; - const splitterWidth = 12; - const minPercent = Math.max(28, (minLeftWidth / rect.width) * 100); - const maxPercent = Math.min(78, ((rect.width - splitterWidth - minRightWidth) / rect.width) * 100); - const lowerBound = Math.min(minPercent, maxPercent); - const upperBound = Math.max(minPercent, maxPercent); - const nextPercent = Math.min(upperBound, Math.max(lowerBound, percent)); - setLeftPanePercent(nextPercent); - savePersistedLeftPanePercent(nextPercent); - }; - - update(event.clientX); - const onMove = (moveEvent: globalThis.PointerEvent) => update(moveEvent.clientX); - const onUp = () => { - window.removeEventListener("pointermove", onMove); - window.removeEventListener("pointerup", onUp); - }; - window.addEventListener("pointermove", onMove); - window.addEventListener("pointerup", onUp); - } - - return ( -
-
-
-

Document Automation Workspace

-

{modeTitle(mode)}

-
-
- -
- - {mode !== "home" && ( - - )} - {mode === "key-info" && ( - <> - - - - - )} - {mode === "home" && ( - - )} -
- -
- 사용 흐름 - 문서를 업로드한 뒤 직접 schema를 정의하거나 AI 추천을 사용하세요. - Schema 변경은 필드 수정 후 자동 저장됩니다. - Export 전에 경고, 누락값, 수정값, 근거, 페이지를 검수하세요. -
-
-
-
-
- - {error &&
{error}
} - {busy && ( -
- - {busy} -
- )} - - {mode === "home" ? ( - navigateMode("documents")} - onRaw={() => navigateMode("raw")} - onKie={() => navigateMode("key-info")} - onClassifier={() => navigateMode("classifier")} - onRequiredChecker={() => navigateMode("required-checker")} - onWorkflow={() => navigateMode("workflow")} - onSeedBankPoc={() => void seedBankPocTemplate()} - pocBusy={Boolean(busy)} - showMonitor - systemStatus={systemStatus} - vlmSettings={vlmSettings} - workflowRuns={homeWorkflowRuns} - kieBatches={batches} - classificationBatches={homeClassificationBatches} - requiredBatches={homeRequiredBatches} - /> - ) : mode === "documents" ? ( - void handleLibraryRunSelection(target, documents)} - /> - ) : mode === "raw" ? ( - void uploadRawFile(file, options)} - onLoad={(id) => void loadRawExtraction(id)} - onRawOptions={setRawOptions} - onToggleHistory={() => setRawHistoryCollapsed((collapsed) => !collapsed)} - onResize={startResize} - /> - ) : mode === "classifier" || mode === "required-checker" ? ( - setSelectedLibraryDocuments([])} - onResize={startResize} - /> - ) : mode === "workflow" ? ( - setSelectedLibraryDocuments([])} - initialWorkflowId={selectedWorkflowId} - onConsumeInitialWorkflowId={() => setSelectedWorkflowId("")} - onCreateSchema={() => navigateMode("key-info")} - onCreateClassifier={() => navigateMode("classifier")} - onCreateChecklist={() => navigateMode("required-checker")} - /> - ) : mode === "workflow-result" ? ( - - ) : ( -
-
-
- {!document ? ( - { - setSelectedLibraryDocuments(documents); - setBatchFiles([]); - setDraftBatchIndex(0); - setBatchMessage(documents.length ? `${documents.length.toLocaleString()}개 보관 문서를 선택했습니다.` : null); - }} - onShowRegions={setRegionsVisible} - onClearFiles={() => { - setBatchFiles([]); - setSelectedLibraryDocuments([]); - setDraftBatchIndex(0); - setBatchMessage(null); - }} - onRunBatch={() => void runKieUploadSelection()} - /> - ) : ( -
- {activeBatch && ( - void openBatchItem(activeBatch.id, itemId)} - onDiscardBatch={(batchId) => void discardBatch(batchId)} - onResumeBatch={(batchId) => void resumeBatch(batchId)} - onNextReviewItem={() => openNextBatchReviewItem(activeBatch)} - /> - )} -
- void uploadFile(file)} - onClear={clearDocumentForNewUpload} - /> -
-
- )} -
- - - - -
- {schemaLibraryVisible && ( - { - setSchemaName(value); - setSchemaDirty(true); - }} - onSchemaDescription={(value) => { - setSchemaDescription(value); - setSchemaDirty(true); - }} - onLoadSavedSchema={(schemaId) => void loadSchema(schemaId)} - onNewSchema={startNewSchemaDraft} - onDeleteSchema={(schemaId) => void deleteSchema(schemaId)} - onDuplicateSchema={(schemaId) => void duplicateSchema(schemaId)} - onSchemaJsonInput={setSchemaJsonInput} - onImportSchemaJson={importSchemaJson} - onSaveRegion={saveRegion} - onRemoveRegion={removeRegion} - onRecommendSchemaDescription={recommendSchemaDescription} - onSampleSchema={applySampleSchema} - onLoadTemplate={(template) => { - applySchema(template); - setSchema(null); - setSchemaDirty(true); - }} - onSaveTemplate={() => void markSchemaAsTemplate()} - onClose={() => setSchemaLibraryOpen(false)} - /> - )} -
- )} - {archiveOpen && ( - setArchiveOpen(false)}> - { - setArchiveQuery(value); - void searchArchive(value, archiveStatus); - }} - onStatus={(value) => { - setArchiveStatus(value); - void searchArchive(archiveQuery, value); - }} - onOpen={(item) => { - setArchiveOpen(false); - void loadArchiveResult(item); - }} - /> - - )} - {historyOpen && ( - setHistoryOpen(false)}> - { - setHistoryOpen(false); - void loadDocument(id); - }} - onLoadSchema={(id) => { - setHistoryOpen(false); - void loadSchema(id); - }} - onLoadJob={(id) => { - setHistoryOpen(false); - void loadJob(id); - }} - onToggle={() => setHistoryOpen(false)} - /> - - )} - {batchOpen && ( - setBatchOpen(false)}> - { - setBatchFiles([]); - setDraftBatchIndex(0); - setBatchMessage(null); - }} - onRunBatch={() => void runBatchUpload()} - onDiscardBatch={(batchId) => void discardBatch(batchId)} - onResumeBatch={(batchId) => void resumeBatch(batchId)} - onOpenItem={(batchId, itemId) => { - setBatchOpen(false); - void openBatchItem(batchId, itemId); - }} - /> - - )} - {pendingRecommendation && ( - applyRecommendation(pendingRecommendation)} - onCancel={() => setPendingRecommendation(null)} - /> - )} - {settingsOpen && ( - void saveVlmSettings()} - onClearParsingHistory={() => void clearParsingHistory()} - onClose={() => setSettingsOpen(false)} - /> - )} -
- ); -} - -function KieUtilityDock(props: { onArchive: () => void; onBatch: () => void }) { - return ( -
- - -
- ); -} - -function UtilityModal(props: { title: string; eyebrow: string; children: ReactNode; onClose: () => void }) { - return ( -
-
-
-
-

{props.eyebrow}

-

{props.title}

-
- -
- {props.children} -
-
- ); -} - -function HomeScreen(props: { - onDocuments: () => void; - onRaw: () => void; - onKie: () => void; - onClassifier: () => void; - onRequiredChecker: () => void; - onWorkflow: () => void; - onSeedBankPoc: () => void; - pocBusy: boolean; - showMonitor: boolean; - systemStatus: SystemStatus | null; - vlmSettings: VlmSettings | null; - workflowRuns: HomeWorkflowRun[]; - kieBatches: Batch[]; - classificationBatches: HomeModuleBatch[]; - requiredBatches: HomeModuleBatch[]; -}) { - const monitorItems = buildHomeMonitorItems( - props.workflowRuns, - props.kieBatches, - props.classificationBatches, - props.requiredBatches - ); - const activeMonitorCount = monitorItems.filter((item) => isActiveHomeStatus(item.status)).length; - - return ( -
-
-
-

작업 공간

-

문서 검수를 한 번에 자동화하세요

-

분류, 필수 항목 확인, 핵심 정보 추출, export를 하나의 워크플로우로 연결합니다.

-
- - -
-
-
- 01 - 반복 분류 감소 -

문서 class 후보를 정해두고 접수 문서를 같은 기준으로 나눕니다.

-
-
- 02 - 누락 검수 빠르게 -

서명, 날짜, 체크박스처럼 빠지면 안 되는 항목을 먼저 확인합니다.

-
-
- 03 - 검수 결과 export -

문서 이미지와 결과 table을 검수한 뒤 CSV, JSON, XLSX로 내보냅니다.

-
-
-
- {props.showMonitor && ( - - )} -
- -
-

핵심 기능

-

필요한 작업을 바로 실행하거나 워크플로우에 연결하세요

-
-
- - - - - -
-
- ); -} - -function HomeMonitorPanel(props: { - activeCount: number; - items: HomeMonitorItem[]; -}) { - return ( -
-
-
-

최근 실행

-

{props.activeCount ? `진행 중인 작업 ${props.activeCount}개` : "작업 진행 현황"}

-
-
- {props.items.length ? ( -
- {props.items.map((item) => ( -
-
-
- {item.moduleLabel} - {statusLabel(item.status)} -
- {item.title} - - {item.doneCount.toLocaleString()} / {item.totalCount.toLocaleString()} 처리 - {item.uploadedCount ? ` · ${item.uploadedCount.toLocaleString()} 업로드됨` : ""} - {item.preprocessingCount ? ` · ${item.preprocessingCount.toLocaleString()} 전처리` : ""} - {item.runningCount ? ` · ${item.runningCount.toLocaleString()} 실행 중` : ""} - {item.queuedCount ? ` · ${item.queuedCount.toLocaleString()} 대기` : ""} - {item.pausedCount ? ` · ${item.pausedCount.toLocaleString()} 일시중단` : ""} - {item.needsReviewCount ? ` · ${item.needsReviewCount.toLocaleString()} 검토` : ""} - {item.failedCount ? ` · ${item.failedCount.toLocaleString()} 실패` : ""} - {item.canceledCount ? ` · ${item.canceledCount.toLocaleString()} 취소` : ""} - - {homeMonitorStopReason(item) && {homeMonitorStopReason(item)}} -
-
- -
- {Math.round(item.progress * 100)}% -
-
-
- ))} -
- ) : ( -
- 실행 중인 작업이 없습니다. - 데모 실행 후 여기에 표시됩니다. -
- )} -
- ); -} - -function buildHomeMonitorItems( - workflowRuns: HomeWorkflowRun[], - kieBatches: Batch[], - classificationBatches: HomeModuleBatch[], - requiredBatches: HomeModuleBatch[] -) { - const workflowItems: HomeMonitorItem[] = workflowRuns.map((run) => { - const pausedCount = run.items.filter((item) => item.status === "paused").length; - const canceledCount = run.canceled_count ?? run.items.filter((item) => item.status === "canceled").length; - return { - id: run.id, - target: "workflow", - moduleLabel: "워크플로우", - title: "워크플로우 실행", - status: run.status, - progress: run.progress ?? progressFromCounts(run.items, run.total_count, run.progress), - totalCount: run.total_count, - doneCount: Math.min(run.total_count, run.completed_count + run.failed_count + run.needs_review_count + canceledCount), - uploadedCount: run.uploaded_count ?? run.items.length, - preprocessingCount: run.preprocessing_count ?? run.items.filter((item) => item.status === "preprocessing").length, - runningCount: run.running_count ?? run.items.filter((item) => item.status === "running").length, - queuedCount: run.queued_count ?? run.items.filter((item) => item.status === "queued").length, - needsReviewCount: run.needs_review_count, - failedCount: run.failed_count, - canceledCount, - pausedCount, - createdAt: run.created_at - }; - }); - - const moduleItems = [ - ...kieBatches.map((batch) => homeBatchToMonitorItem(batch, "key-info", "핵심 정보 추출")), - ...classificationBatches.map((batch) => homeBatchToMonitorItem(batch, "classifier", "문서 분류")), - ...requiredBatches.map((batch) => homeBatchToMonitorItem(batch, "required-checker", "필수 항목 확인")) - ]; - - const items = [...workflowItems, ...moduleItems].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); - const activeItems = items.filter((item) => isActiveHomeStatus(item.status)); - const recentItems = items.filter((item) => !isActiveHomeStatus(item.status)); - return [...activeItems, ...recentItems].slice(0, 5); -} - -function homeBatchToMonitorItem(batch: HomeModuleBatch | Batch, target: HomeMonitorTarget, moduleLabel: string): HomeMonitorItem { - const runningCount = batch.running_count ?? batch.items.filter((item) => item.status === "running").length; - const queuedCount = batch.queued_count ?? batch.items.filter((item) => item.status === "queued").length; - const needsReviewCount = batch.needs_review_count ?? batch.items.filter((item) => item.status === "needs_review").length; - const pausedCount = batch.items.filter((item) => item.status === "paused").length; - const doneCount = Math.min(batch.total_count, batch.completed_count + batch.failed_count + batch.canceled_count + needsReviewCount); - return { - id: batch.id, - target, - moduleLabel, - title: `${moduleLabel} 배치`, - status: batch.status, - progress: batch.progress ?? progressFromCounts(batch.items, batch.total_count, batch.progress), - totalCount: batch.total_count, - doneCount, - uploadedCount: batch.uploaded_count ?? batch.items.length, - preprocessingCount: batch.preprocessing_count ?? batch.items.filter((item) => item.status === "preprocessing").length, - runningCount, - queuedCount, - needsReviewCount, - failedCount: batch.failed_count, - canceledCount: batch.canceled_count, - pausedCount, - createdAt: batch.created_at - }; -} - -function hasRunningHomeItems(items: { status: string }[]) { - return items.some((item) => isActiveHomeStatus(item.status)); -} - -function isActiveHomeStatus(status: string) { - return ["uploading", "preprocessing", "queued", "running", "cancel_requested", "canceling"].includes(status); -} - -function isStoppedHomeStatus(status: string) { - return ["paused", "interrupted", "failed", "canceled", "completed_with_errors"].includes(status); -} - -function homeMonitorStopReason(item: HomeMonitorItem) { - if (item.status === "paused") return "진행 중 아님 · 사용자가 일시중단했습니다."; - if (item.status === "interrupted") return "진행 중 아님 · 업로드 또는 실행이 중간에 끊겼습니다."; - if (item.status === "canceled") return "진행 중 아님 · 취소 또는 중단·정리된 작업입니다."; - if (item.status === "failed") return "진행 중 아님 · 실패로 종료되었습니다."; - if (item.status === "completed_with_errors") return "진행 중 아님 · 일부 항목이 실패 또는 취소된 상태로 종료되었습니다."; - return null; -} - -function terminalItemCount(items: { status: string }[], totalCount: number) { - const count = items.filter((item) => ["completed", "completed_with_errors", "needs_review", "failed", "canceled"].includes(item.status)).length; - return Math.min(Math.max(0, count), Math.max(0, totalCount)); -} - -function progressFromCounts(items: { status: string }[], totalCount: number, fallbackProgress: number) { - const total = Math.max(0, totalCount); - if (total > 0 && items.length) return clampProgress(terminalItemCount(items, total) / total); - return clampProgress(fallbackProgress); -} - -function clampProgress(value: number) { - if (!Number.isFinite(value)) return 0; - return Math.max(0, Math.min(1, value)); -} - -function RawWorkspace(props: { - rawExtraction: RawExtraction | null; - recentRawExtractions: RawExtraction[]; - rawOptions: RawExtractionOptions; - historyCollapsed: boolean; - pdfUrl: string | null; - htmlUrl: string | null; - leftPanePercent: number; - onUpload: (file: File, options: RawExtractionOptions) => void; - onLoad: (id: string) => void; - onRawOptions: (options: RawExtractionOptions) => void; - onToggleHistory: () => void; - onResize: (event: PointerEvent) => void; -}) { - function onDrop(event: DragEvent) { - event.preventDefault(); - const file = event.dataTransfer.files[0]; - if (file) props.onUpload(file, props.rawOptions); - } - - function onFileChange(event: ChangeEvent) { - const file = event.target.files?.[0]; - if (file) props.onUpload(file, props.rawOptions); - event.currentTarget.value = ""; - } - - function onSelectUploadFiles(files: FileList | null) { - const file = files?.[0]; - if (file) props.onUpload(file, props.rawOptions); - } - - return ( -
-
-
-
-

PDF 미리보기

-

{props.rawExtraction?.filename || "원문 문서 업로드"}

-
-
- {props.pdfUrl ? ( -