update view

This commit is contained in:
DmsAnhr 2025-11-24 08:58:37 +07:00
parent eba774013e
commit f6aaebc5b5
27 changed files with 2687 additions and 220 deletions

22
components.json Normal file
View File

@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": false,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

8
jsconfig.json Normal file
View File

@ -0,0 +1,8 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}

875
package-lock.json generated
View File

@ -8,17 +8,25 @@
"name": "upload_otomation_fe", "name": "upload_otomation_fe",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-tabs": "^1.1.13",
"@reduxjs/toolkit": "^2.9.2", "@reduxjs/toolkit": "^2.9.2",
"@tailwindcss/vite": "^4.1.16", "@tailwindcss/vite": "^4.1.16",
"axios": "^1.13.0", "axios": "^1.13.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.23.24", "framer-motion": "^12.23.24",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lucide-react": "^0.553.0",
"pdfjs-dist": "^5.4.394", "pdfjs-dist": "^5.4.394",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"react-router-dom": "^7.9.5", "react-router-dom": "^7.9.5",
"stream-browserify": "^3.0.0", "stream-browserify": "^3.0.0",
"tailwind-merge": "^3.4.0",
"uuid": "^13.0.0",
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
@ -31,6 +39,7 @@
"eslint-plugin-react-refresh": "^0.4.22", "eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0", "globals": "^16.4.0",
"tailwindcss": "^4.1.16", "tailwindcss": "^4.1.16",
"tw-animate-css": "^1.4.0",
"vite": "^7.1.7" "vite": "^7.1.7"
} }
}, },
@ -607,6 +616,44 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz",
"integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.7.4"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@ -889,6 +936,630 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-accordion": {
"version": "1.2.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz",
"integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collapsible": "1.1.12",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.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-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"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-collapsible": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
"integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"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-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3"
},
"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.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"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.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"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.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
"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.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "1.1.1"
},
"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-dropdown-menu": {
"version": "2.1.16",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz",
"integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-menu": "2.1.16",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.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.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
"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.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"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.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"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-menu": {
"version": "2.1.16",
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
"integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"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.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-rect": "1.1.1",
"@radix-ui/react-use-size": "1.1.1",
"@radix-ui/rect": "1.1.1"
},
"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.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"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.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"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.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"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-roving-focus": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
"integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.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.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "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-tabs": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
"integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-use-controllable-state": "1.2.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-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
"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.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"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.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"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.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"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.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"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.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
"license": "MIT",
"dependencies": {
"@radix-ui/rect": "1.1.1"
},
"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.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"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.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@reduxjs/toolkit": { "node_modules/@reduxjs/toolkit": {
"version": "2.9.2", "version": "2.9.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.2.tgz", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.2.tgz",
@ -1730,7 +2401,7 @@
"version": "19.2.2", "version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz",
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
@ -1831,6 +2502,18 @@
"dev": true, "dev": true,
"license": "Python-2.0" "license": "Python-2.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/asynckit": { "node_modules/asynckit": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@ -1919,6 +2602,27 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
"license": "Apache-2.0",
"dependencies": {
"clsx": "^2.1.1"
},
"funding": {
"url": "https://polar.sh/cva"
}
},
"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/codepage": { "node_modules/codepage": {
"version": "1.15.0", "version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
@ -2053,6 +2757,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"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/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -2564,6 +3274,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"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/get-proto": { "node_modules/get-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
@ -3101,6 +3820,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lucide-react": {
"version": "0.553.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.553.0.tgz",
"integrity": "sha512-BRgX5zrWmNy/lkVAe0dXBgd7XQdZ3HTf+Hwe3c9WK6dqgnj9h+hxV+MDncM88xDWlCq27+TKvHGE70ViODNILw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@ -3411,6 +4139,53 @@
} }
} }
}, },
"node_modules/react-remove-scroll": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
"integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==",
"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": { "node_modules/react-router": {
"version": "7.9.5", "version": "7.9.5",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.5.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.5.tgz",
@ -3449,6 +4224,28 @@
"react-dom": ">=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/readable-stream": { "node_modules/readable-stream": {
"version": "3.6.2", "version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@ -3656,6 +4453,16 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/tailwind-merge": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
"integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "4.1.16", "version": "4.1.16",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz",
@ -3697,6 +4504,16 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tw-animate-css": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
"integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Wombosvideo"
}
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@ -3720,6 +4537,49 @@
"punycode": "^2.1.0" "punycode": "^2.1.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": { "node_modules/use-sync-external-store": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
@ -3735,6 +4595,19 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "7.1.12", "version": "7.1.12",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz",

View File

@ -10,17 +10,25 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-tabs": "^1.1.13",
"@reduxjs/toolkit": "^2.9.2", "@reduxjs/toolkit": "^2.9.2",
"@tailwindcss/vite": "^4.1.16", "@tailwindcss/vite": "^4.1.16",
"axios": "^1.13.0", "axios": "^1.13.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.23.24", "framer-motion": "^12.23.24",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lucide-react": "^0.553.0",
"pdfjs-dist": "^5.4.394", "pdfjs-dist": "^5.4.394",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"react-router-dom": "^7.9.5", "react-router-dom": "^7.9.5",
"stream-browserify": "^3.0.0", "stream-browserify": "^3.0.0",
"tailwind-merge": "^3.4.0",
"uuid": "^13.0.0",
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
@ -33,6 +41,7 @@
"eslint-plugin-react-refresh": "^0.4.22", "eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0", "globals": "^16.4.0",
"tailwindcss": "^4.1.16", "tailwindcss": "^4.1.16",
"tw-animate-css": "^1.4.0",
"vite": "^7.1.7" "vite": "^7.1.7"
} }
} }

View File

@ -0,0 +1,273 @@
import { useState, useEffect } from "react";
import { v4 as uuidv4 } from "uuid";
import {
Tabs,
TabsList,
TabsTrigger,
TabsContent,
} from "./ui/tabs";
/**
* 📄 MetadataForm.jsx
* Form Metadata Geospasial berbasis ISO 19115 (Simplified)
* Menggunakan Tailwind CSS murni untuk tampilan modern dan profesional.
*/
export default function MetadataForm({ onChange }) {
const [formData, setFormData] = useState({
// 🧩 Identifikasi Dataset
title: "",
abstract: "",
keywords: "",
topicCategory: "",
dateCreated: "",
status: "",
language: "eng",
// 🧭 Referensi Spasial
crs: "EPSG:4326",
geometryType: "",
xmin: "",
xmax: "",
ymin: "",
ymax: "",
// 🌐 Distribusi / Akses Data
downloadLink: "",
serviceLink: "",
format: "",
license: "Copyright",
// 👤 Informasi Penanggung Jawab
organization: "",
contactName: "",
contactEmail: "",
contactPhone: "",
role: "",
// 🧾 Metadata Umum
metadataStandard: "ISO 19115:2003/19139",
metadataVersion: "1.0",
metadataUUID: "",
metadataDate: "",
charset: "utf8",
rsIdentifier: "WGS 1984"
});
// Generate UUID & tanggal metadata saat pertama kali load
useEffect(() => {
setFormData((prev) => ({
...prev,
metadataUUID: uuidv4(),
metadataDate: new Date().toISOString().split("T")[0],
}));
}, []);
// Update handler umum
const handleChange = (e) => {
const { name, value } = e.target;
const updated = { ...formData, [name]: value };
setFormData(updated);
if (onChange) onChange(updated);
};
return (
<div className="max-w-4xl mx-auto mt-6">
<Tabs defaultValue="identifikasi" className="w-full">
{/* TAB LIST */}
<TabsList className="grid grid-cols-2 w-full mb-6">
<TabsTrigger value="identifikasi">
🧩 Identifikasi Dataset
</TabsTrigger>
<TabsTrigger value="penanggung">
👤 Penanggung Jawab
</TabsTrigger>
</TabsList>
{/* TAB 1: IDENTIFIKASI */}
<TabsContent value="identifikasi">
<Section title="🧩 Identifikasi Dataset">
<Input
label="Judul Dataset"
name="title"
value={formData.title}
onChange={handleChange}
/>
<Textarea
label="Abstrak / Deskripsi"
name="abstract"
value={formData.abstract}
onChange={handleChange}
/>
<Input
label="Kata Kunci (pisahkan dengan koma)"
name="keywords"
value={formData.keywords}
onChange={handleChange}
/>
<Select
label="Kategori / Topik"
name="topicCategory"
value={formData.topicCategory}
onChange={handleChange}
options={[
"Environment",
"Boundaries",
"Transportation",
"Elevation",
"Imagery"
]}
/>
<Input
type="date"
label="Tanggal Pembuatan Data"
name="dateCreated"
value={formData.dateCreated}
onChange={handleChange}
/>
<Select
label="Status Dataset"
name="status"
value={formData.status}
onChange={handleChange}
options={["onGoing", "completed", "planned"]}
/>
</Section>
</TabsContent>
{/* TAB 2: PENANGGUNG JAWAB */}
<TabsContent value="penanggung">
<Section title="👤 Informasi Penanggung Jawab">
<Input
label="Nama Organisasi"
name="organization"
value={formData.organization}
onChange={handleChange}
/>
<Input
label="Nama Kontak"
name="contactName"
value={formData.contactName}
onChange={handleChange}
/>
<Input
type="email"
label="Email Kontak"
name="contactEmail"
value={formData.contactEmail}
onChange={handleChange}
/>
<Input
label="Nomor Telepon"
name="contactPhone"
value={formData.contactPhone}
onChange={handleChange}
/>
{/* <Select
label="Peran"
name="role"
value={formData.role}
onChange={handleChange}
options={[
"data_owner",
"pointOfContact",
"distributor",
"originator",
]}
/> */}
</Section>
</TabsContent>
</Tabs>
</div>
);
}
/* ---------------------------------------------------
📦 Subkomponen Reusable untuk Input/Select/Textarea
--------------------------------------------------- */
function Section({ title, children }) {
return (
<div className="mb-8">
{/* <h2 className="text-xl font-bold text-gray-800 border-b pb-2 mb-4">{title}</h2> */}
<div className="space-y-4">{children}</div>
</div>
);
}
function Input({ label, name, type = "text", value, onChange, readOnly = false }) {
return (
<div>
<label htmlFor={name} className="block text-sm font-semibold text-gray-700 mb-1">
{label}{" "}<span className="text-red-500">*</span>
</label>
<input
id={name}
name={name}
type={type}
value={value}
onChange={onChange}
readOnly={readOnly}
className={`w-full border border-gray-300 rounded-md p-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition
${readOnly ? "bg-gray-100 cursor-not-allowed" : "bg-white"}`}
/>
</div>
);
}
function Textarea({ label, name, value, onChange }) {
return (
<div>
<label htmlFor={name} className="block text-sm font-semibold text-gray-700 mb-1">
{label}{" "}<span className="text-red-500">*</span>
</label>
<textarea
id={name}
name={name}
value={value}
onChange={onChange}
rows="4"
className="w-full border border-gray-300 rounded-md p-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
></textarea>
</div>
);
}
function Select({ label, name, value, onChange, options = [] }) {
return (
<div>
<label htmlFor={name} className="block text-sm font-semibold text-gray-700 mb-1">
{label}{" "}<span className="text-red-500">*</span>
</label>
<select
id={name}
name={name}
value={value}
onChange={onChange}
className="w-full border border-gray-300 rounded-md p-2 bg-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
>
<option value="">-- Pilih --</option>
{options.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</div>
);
}

View File

@ -0,0 +1,61 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils";
function Accordion({
...props
}) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
className,
...props
}) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props} />
);
}
function AccordionTrigger({
className,
children,
...props
}) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-center justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}>
{children}
<ChevronDownIcon
className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@ -0,0 +1,221 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}) {
return (<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />);
}
function DropdownMenuTrigger({
...props
}) {
return (<DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props} />
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}) {
return (<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props} />
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}>
<span
className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}) {
return (<DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />);
}
function DropdownMenuRadioItem({
className,
children,
...props
}) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}>
<span
className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)}
{...props} />
);
}
function DropdownMenuSeparator({
className,
...props
}) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props} />
);
}
function DropdownMenuShortcut({
className,
...props
}) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
{...props} />
);
}
function DropdownMenuSub({
...props
}) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props} />
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@ -0,0 +1,89 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props} />
);
}
// function TabsList({
// className,
// ...props
// }) {
// return (
// <TabsPrimitive.List
// data-slot="tabs-list"
// className={cn(
// "bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
// className
// )}
// {...props} />
// );
// }
function TabsList({ className, ...props }) {
return (
<TabsPrimitive.List
className={cn(
"flex border-b border-gray-200", // garis bawah global
className
)}
{...props}
/>
)
}
// function TabsTrigger({
// className,
// ...props
// }) {
// return (
// <TabsPrimitive.Trigger
// data-slot="tabs-trigger"
// className={cn(
// "data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
// className
// )}
// {...props} />
// );
// }
function TabsTrigger({ className, ...props }) {
return (
<TabsPrimitive.Trigger
className={cn(
"px-4 py-2 text-sm font-medium text-gray-500 relative",
"data-[state=active]:text-blue-600",
"data-[state=active]:after:absolute data-[state=active]:after:left-0 data-[state=active]:after:right-0 data-[state=active]:after:-bottom-[1px] data-[state=active]:after:h-[2px] data-[state=active]:after:bg-blue-600",
"transition-colors",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props} />
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -10,9 +10,9 @@ export default function FilePreview({ result }) {
} = result; } = result;
return ( return (
<div className="mt-4"> <div className="mt-4 w-full">
{/* Section: Warning Table */} {/* Section: Warning Table */}
{warning_examples?.length > 0 ? ( {warning_examples?.length > 0 ?? (
<div className="mb-8"> <div className="mb-8">
<h3 className="font-semibold text-gray-700 mb-2"> <h3 className="font-semibold text-gray-700 mb-2">
Beberapa nama wilayah perlu diperiksa kembali. Beberapa nama wilayah perlu diperiksa kembali.
@ -35,19 +35,17 @@ export default function FilePreview({ result }) {
variant="warning" variant="warning"
/> />
</div> </div>
) : (
<p className="mb-4 text-sm text-green-600 font-medium"> Data 100% valid</p>
)} )}
{/* Section: File Preview */} {/* Section: File Preview */}
<div> <div>
<h3 className="font-semibold text-gray-700 mb-2">📋 Cuplikan Data</h3> {/* <h3 className="font-semibold text-gray-700 mb-2">📋 Cuplikan Data</h3> */}
<Table <Table
title="Cuplikan Data" title="Cuplikan Data"
columns={columns} columns={columns}
rows={preview} rows={preview}
total={geometry_valid} total={geometry_valid}
limit={5} limit={geometry_empty > 0 ? 5 : 15}
variant="preview" variant="preview"
/> />
</div> </div>
@ -57,10 +55,17 @@ export default function FilePreview({ result }) {
function Table({ title, columns, rows, total, limit = 100, variant = "preview" }) { function Table({ title, columns, rows, total, limit = 100, variant = "preview" }) {
const displayedRows = rows.slice(0, limit); const displayedRows = rows.slice(0, limit);
const shorten = (text, max = 80) => {
if (!text) return "—";
return text.length > max ? text.slice(0, max) + "..." : text;
};
return ( return (
<div>
<div className="overflow-x-auto border border-gray-200 rounded-lg shadow-sm bg-white"> <div className="overflow-x-auto border border-gray-200 rounded-lg shadow-sm bg-white">
<table className="min-w-full text-sm text-gray-800"> <table className="min-w-max text-sm text-gray-800">
<thead <thead
className={`border-b ${ className={`border-b ${
variant === "warning" ? "bg-red-100" : "bg-gray-100" variant === "warning" ? "bg-red-100" : "bg-gray-100"
@ -96,8 +101,15 @@ function Table({ title, columns, rows, total, limit = 100, variant = "preview" }
title={row[col] ?? ""} title={row[col] ?? ""}
> >
{row[col] !== null && row[col] !== undefined && row[col] !== "" {row[col] !== null && row[col] !== undefined && row[col] !== ""
? row[col] ? (
: <span className="text-gray-400"></span>} col === "geometry" ? (
shorten(row[col], 80)
) : (
row[col] || <span className="text-gray-400"></span>
)
) : (
<span className="text-gray-400"></span>
)}
</td> </td>
))} ))}
</tr> </tr>
@ -115,13 +127,16 @@ function Table({ title, columns, rows, total, limit = 100, variant = "preview" }
</tbody> </tbody>
</table> </table>
<div className="flex justify-between items-center p-2 text-xs text-gray-500">
</div>
<div className="flex justify-between items-center px-1 py-2 text-xs text-gray-500">
<p> <p>
Menampilkan {Math.min(limit, displayedRows.length)} dari {total} baris. Menampilkan {Math.min(limit, displayedRows.length)} dari {total} baris.
</p> </p>
{variant === "preview" && ( {variant === "preview" && (
<p className="italic text-gray-400"> <p className="italic text-gray-400">
Cuplikan sebagian data (maks. {limit} baris) Cuplikan sebagian data
{/* (maks. {limit} baris) */}
</p> </p>
)} )}
</div> </div>

View File

@ -0,0 +1,205 @@
import { useState, useEffect } from "react";
import { v4 as uuidv4 } from "uuid";
/**
* 📄 MetadataForm.jsx
* Form Metadata Geospasial berbasis ISO 19115 (Simplified)
* Menggunakan Tailwind CSS murni untuk tampilan modern dan profesional.
*/
export default function MetadataForm({ onChange }) {
const [formData, setFormData] = useState({
// 🧩 Identifikasi Dataset
title: "",
abstract: "",
keywords: "",
topicCategory: "",
dateCreated: "",
status: "",
language: "ind",
// 🧭 Referensi Spasial
crs: "",
geometryType: "",
xmin: "",
xmax: "",
ymin: "",
ymax: "",
// 🌐 Distribusi / Akses Data
downloadLink: "",
serviceLink: "",
format: "",
license: "",
// 👤 Informasi Penanggung Jawab
organization: "",
contactName: "",
contactEmail: "",
contactPhone: "",
role: "",
// 🧾 Metadata Umum
metadataStandard: "ISO 19115:2003/19139",
metadataVersion: "1.0",
metadataUUID: "",
metadataDate: "",
charset: "",
});
// Generate UUID & tanggal metadata saat pertama kali load
useEffect(() => {
setFormData((prev) => ({
...prev,
metadataUUID: uuidv4(),
metadataDate: new Date().toISOString().split("T")[0],
}));
}, []);
// Update handler umum
const handleChange = (e) => {
const { name, value } = e.target;
const updated = { ...formData, [name]: value };
setFormData(updated);
if (onChange) onChange(updated);
};
return (
<div className="max-w-4xl mx-auto">
{/* 🧩 Bagian 1 — Identifikasi Dataset */}
<Section title="🧩 Identifikasi Dataset">
<Input label="Judul Dataset" name="title" value={formData.title} onChange={handleChange} />
<Textarea label="Abstrak / Deskripsi" name="abstract" value={formData.abstract} onChange={handleChange} />
<Input label="Kata Kunci (pisahkan dengan koma)" name="keywords" value={formData.keywords} onChange={handleChange} />
<Select label="Kategori / Topik" name="topicCategory" value={formData.topicCategory} onChange={handleChange}
options={["Environment", "Boundaries", "Transportation", "Elevation", "Imagery"]} />
<Input type="date" label="Tanggal Pembuatan Data" name="dateCreated" value={formData.dateCreated} onChange={handleChange} />
<Select label="Status Dataset" name="status" value={formData.status} onChange={handleChange}
options={["onGoing", "completed", "planned"]} />
{/* <Select label="Bahasa Dataset" name="language" value={formData.language} onChange={handleChange}
options={["ind", "eng"]} /> */}
</Section>
{/* FIXXXX AUTO */}
{/* 🧭 Bagian 2 — Referensi Spasial */}
{/* <Section title="🧭 Referensi Spasial">
<Input label="Sistem Koordinat (CRS)" name="crs" value={formData.crs} onChange={handleChange} />
<Select label="Jenis Geometri" name="geometryType" value={formData.geometryType} onChange={handleChange}
options={["Point", "Line", "Polygon", "Raster"]} />
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<Input type="number" label="Batas Barat (xmin)" name="xmin" value={formData.xmin} onChange={handleChange} />
<Input type="number" label="Batas Timur (xmax)" name="xmax" value={formData.xmax} onChange={handleChange} />
<Input type="number" label="Batas Selatan (ymin)" name="ymin" value={formData.ymin} onChange={handleChange} />
<Input type="number" label="Batas Utara (ymax)" name="ymax" value={formData.ymax} onChange={handleChange} />
</div>
</Section> */}
{/* NANTI WAKTU PUBLIKASI */}
{/* 🌐 Bagian 3 — Distribusi / Akses Data */}
{/* <Section title="🌐 Distribusi / Akses Data">
<Input type="url" label="Tautan Unduhan Data" name="downloadLink" value={formData.downloadLink} onChange={handleChange} />
<Input type="url" label="Tautan Layanan (WMS/WFS)" name="serviceLink" value={formData.serviceLink} onChange={handleChange} />
<Select label="Format Data" name="format" value={formData.format} onChange={handleChange}
options={["GeoJSON", "Shapefile", "GeoTIFF", "CSV"]} />
<Select label="Lisensi / Hak Akses" name="license" value={formData.license} onChange={handleChange}
options={["CC BY 4.0", "Public Domain", "Copyright"]} />
</Section> */}
{/* 👤 Bagian 4 — Informasi Penanggung Jawab */}
<Section title="👤 Informasi Penanggung Jawab">
<Input label="Nama Organisasi" name="organization" value={formData.organization} onChange={handleChange} />
<Input label="Nama Kontak" name="contactName" value={formData.contactName} onChange={handleChange} />
<Input type="email" label="Email Kontak" name="contactEmail" value={formData.contactEmail} onChange={handleChange} />
<Input label="Nomor Telepon" name="contactPhone" value={formData.contactPhone} onChange={handleChange} />
<Select label="Peran" name="role" value={formData.role} onChange={handleChange}
options={["data_owner", "pointOfContact", "distributor", "originator"]} />
</Section>
{/* NANTI WAKTU PUBLIKASI */}
{/* 🧾 Bagian 5 — Metadata Umum */}
{/* <Section title="🧾 Metadata Umum">
<Input label="Standar Metadata" name="metadataStandard" value={formData.metadataStandard} onChange={handleChange} />
<Input label="Versi Metadata" name="metadataVersion" value={formData.metadataVersion} onChange={handleChange} />
<Input label="UUID Metadata" name="metadataUUID" value={formData.metadataUUID} readOnly />
<Input type="date" label="Tanggal Metadata Dibuat" name="metadataDate" value={formData.metadataDate} readOnly />
<Select label="Karakter Set" name="charset" value={formData.charset} onChange={handleChange}
options={["utf8", "latin1"]} />
</Section> */}
</div>
);
}
/* ---------------------------------------------------
📦 Subkomponen Reusable untuk Input/Select/Textarea
--------------------------------------------------- */
function Section({ title, children }) {
return (
<div className="bg-white shadow-md border border-gray-200 rounded-xl p-6 mb-8">
<h2 className="text-xl font-bold text-gray-800 border-b pb-2 mb-4">{title}</h2>
<div className="space-y-4">{children}</div>
</div>
);
}
function Input({ label, name, type = "text", value, onChange, readOnly = false }) {
return (
<div>
<label htmlFor={name} className="block text-sm font-semibold text-gray-700 mb-1">
{label}
</label>
<input
id={name}
name={name}
type={type}
value={value}
onChange={onChange}
readOnly={readOnly}
className={`w-full border border-gray-300 rounded-md p-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition
${readOnly ? "bg-gray-100 cursor-not-allowed" : "bg-white"}`}
/>
</div>
);
}
function Textarea({ label, name, value, onChange }) {
return (
<div>
<label htmlFor={name} className="block text-sm font-semibold text-gray-700 mb-1">
{label}
</label>
<textarea
id={name}
name={name}
value={value}
onChange={onChange}
rows="4"
className="w-full border border-gray-300 rounded-md p-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
></textarea>
</div>
);
}
function Select({ label, name, value, onChange, options = [] }) {
return (
<div>
<label htmlFor={name} className="block text-sm font-semibold text-gray-700 mb-1">
{label}
</label>
<select
id={name}
name={name}
value={value}
onChange={onChange}
className="w-full border border-gray-300 rounded-md p-2 bg-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
>
<option value="">-- Pilih --</option>
{options.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</div>
);
}

View File

@ -1 +1,120 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

6
src/lib/utils.js Normal file
View File

@ -0,0 +1,6 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs));
}

View File

@ -0,0 +1,30 @@
import { useEffect, useState } from "react";
import { fetchAllDatasets } from "./service_admin_home";
export function useAdminHomeController() {
const [datasets, setDatasets] = useState([]);
const [loading, setLoading] = useState(false);
const [errorMsg, setErrorMsg] = useState("");
const loadData = async () => {
setLoading(true);
try {
const data = await fetchAllDatasets();
setDatasets(data);
} catch (err) {
setErrorMsg(err?.message || "Terjadi kesalahan saat memuat data.");
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
return {
datasets,
loading,
errorMsg,
};
}

View File

@ -0,0 +1,11 @@
import api from "../../../services/api";
export async function fetchAllDatasets() {
try {
const res = await api.get("/dataset/metadata");
return res.data?.data || [];
} catch (err) {
console.error("Fetch datasets error:", err);
throw err.response?.data || err;
}
}

View File

@ -1,10 +1,168 @@
import Sidebar from "../../../components/Sidebar"; import LoadingOverlay from "../../../components/LoadingOverlay";
import ErrorNotification from "../../../components/ErrorNotification";
import { useAdminHomeController } from "./controller_admin_home";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator
} from "../../../components/ui/dropdown-menu";
import { Link } from "react-router-dom";
export default function ViewsAdminHome() { export default function ViewsAdminHome() {
const { datasets, loading, errorMsg } = useAdminHomeController();
return ( return (
<div> <div className="max-w-6xl mx-auto py-10">
<h1 className="text-2xl font-bold">Dashboard Home</h1>
<p className="mt-4">Selamat datang di panel admin upload automation.</p> <LoadingOverlay show={loading} text="Loading datasets..." />
<ErrorNotification message={errorMsg} onClose={() => {}} />
<div className="flex items-center justify-between mb-8">
<h1 className="text-3xl font-bold text-gray-800">📂 Metadata Dataset</h1>
<Link
to="/admin/upload"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
+ Upload Baru
</Link>
</div>
{/* Empty State */}
{datasets.length === 0 && !loading && (
<p className="text-gray-500 text-center mt-10">
Belum ada metadata dataset yang tersimpan.
</p>
)}
{/* CARD LIST */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{datasets.map((item) => (
<div
key={item.id}
className="bg-white border border-gray-200 rounded-xl shadow-sm p-6 hover:shadow-md transition"
>
<div className="flex justify-between items-start">
<h2 className="text-lg font-semibold text-gray-800">
{item.dataset_title}
</h2>
{/* STATUS BADGE */}
<span
className={`text-xs px-2 py-1 rounded-full ${
item.dataset_status === "completed"
? "bg-green-100 text-green-700"
: "bg-yellow-100 text-yellow-700"
}`}
>
{item.dataset_status}
</span>
</div>
<p className="text-gray-600 text-sm mt-1">
📅 {new Date(item.created_at).toLocaleString()}
</p>
<div className="mt-4 space-y-2 text-sm text-gray-700">
<p>
<span className="font-medium">Nama Tabel:</span>{" "}
{item.table_title}
</p>
<p>
<span className="font-medium">Kategori:</span>{" "}
{item.topic_category}
</p>
<p>
<span className="font-medium">Organisasi:</span>{" "}
{item.organization_name}
</p>
<p>
<span className="font-medium">Kontak:</span>{" "}
{item.contact_person_name}
</p>
{/* GEOM TYPE */}
<p className="mt-2">
<span className="font-medium">Tipe Geometri:</span>
</p>
<div className="flex gap-2 flex-wrap">
{item.geom_type?.map((g, i) => (
<span
key={i}
className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-md"
>
{g}
</span>
))}
</div>
{/* KEYWORDS */}
<div className="mt-3">
<p className="font-medium text-sm">Kata Kunci:</p>
<div className="flex gap-2 flex-wrap mt-1">
{item.keywords
.split(",")
.map((k, i) => (
<span
key={i}
className="text-xs bg-gray-200 text-gray-700 px-2 py-1 rounded-md"
>
#{k.trim()}
</span>
))}
</div>
</div>
</div>
{/* ACTIONS */}
<div className="mt-5 flex justify-between items-center">
{/* BUTTON: Buka di QGIS */}
<a
href={`qgis://open?table=${item.table_title}`}
className="px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition"
>
🌍 Buka di QGIS
</a>
{/* MORE MENU */}
<DropdownMenu>
<DropdownMenuTrigger>
<button className="p-2 rounded-full hover:bg-gray-200">
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-40">
<DropdownMenuItem asChild>
<Link to={`/admin/dataset/${item.id}`} className="cursor-pointer">
Lihat Detail
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => console.log("Hapus:", item.id)}
className="text-red-600 cursor-pointer"
>
Hapus
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
</div>
</div> </div>
); );
} }

View File

@ -19,7 +19,7 @@ export function useUploadController() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [selectedTable, setSelectedTable] = useState(null); const [selectedTable, setSelectedTable] = useState(null);
// const [selectedPages, setSelectedPages] = useState(""); // const [selectedPages, setSelectedPages] = useState("");
const [tableTitle, setTableTitle] = useState(""); const [tableTitle, setTableTitle] = useState("GTW");
// const [pdfPageCount, setPdfPageCount] = useState(null); // const [pdfPageCount, setPdfPageCount] = useState(null);
const [selectedSheet, setSelectedSheet] = useState(null); const [selectedSheet, setSelectedSheet] = useState(null);
const [sheetCount, setSheetCount] = useState(null); const [sheetCount, setSheetCount] = useState(null);
@ -44,19 +44,23 @@ export function useUploadController() {
// } catch (err) { // } catch (err) {
// console.error("Gagal membaca PDF:", err); // console.error("Gagal membaca PDF:", err);
// } // }
try {
const reader = new FileReader(); // try {
reader.onload = async (e) => { // const reader = new FileReader();
const typedArray = new Uint8Array(e.target.result); // reader.onload = async (e) => {
const pdf = await pdfjsLib.getDocument({ data: typedArray }).promise; // const typedArray = new Uint8Array(e.target.result);
dispatch(setPdfPageCount(pdf.numPages)); // const pdf = await pdfjsLib.getDocument({ data: typedArray }).promise;
console.log(`📄 PDF terdeteksi dengan ${pdf.numPages} halaman`); // dispatch(setPdfPageCount(pdf.numPages));
navigate("/admin/upload/pdf"); // 👈 otomatis pindah ke viewer // navigate("/admin/upload/pdf");
}; // };
reader.readAsArrayBuffer(f); // reader.readAsArrayBuffer(f);
} catch (err) { // } catch (err) {
console.error("Gagal membaca PDF:", err); // console.error("Gagal membaca PDF:", err);
} // }
navigate("/admin/upload/pdf");
} }
else if (ext === "xlsx" || ext === "xls") { else if (ext === "xlsx" || ext === "xls") {
const data = await f.arrayBuffer(); const data = await f.arrayBuffer();
@ -104,13 +108,14 @@ export function useUploadController() {
} }
}; };
const handleConfirmUpload = async () => { const handleConfirmUpload = async (metadata) => {
setLoading(true); setLoading(true);
try { try {
const data = { const data = {
title: tableTitle, title: metadata.title,
columns: result.columns, columns: result.columns,
rows: result.preview, rows: result.preview,
author: metadata
}; };
const res = await saveToDatabase(data); const res = await saveToDatabase(data);
dispatch(setValidatedData(res)); dispatch(setValidatedData(res));

View File

@ -1,8 +1,8 @@
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { useState } from "react"; import { useState } from "react";
import * as pdfjsLib from "pdfjs-dist"; import * as pdfjsLib from "pdfjs-dist";
import { setSelectedPages } from "../../../../store/slices/uploadSlice"; import { setSelectedPages, setResult } from "../../../../store/slices/uploadSlice";
import { uploadPdf } from "../service_admin_upload"; import { uploadFile } from "../service_admin_upload";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
@ -13,12 +13,15 @@ pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
export function usePdfViewerController() { export function usePdfViewerController() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const navigate = useNavigate(); const navigate = useNavigate();
const { file } = useSelector((state) => state.upload); const { file, selectedPages } = useSelector((state) => state.upload);
const [pages, setPages] = useState([]); const [pages, setPages] = useState([]);
const [selectedPages, setSelectedPagesLocal] = useState([]); const [selectedPagesLocal, setSelectedPagesLocal] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [errorMsg, setErrorMsg] = useState("");
// Render PDF menjadi gambar // Render PDF menjadi gambar
const loadPdfPages = async (pdfFile) => { const loadPdfPages = async (pdfFile) => {
setLoading(true); setLoading(true);
@ -55,7 +58,7 @@ export function usePdfViewerController() {
// Toggle halaman yang dipilih // Toggle halaman yang dipilih
const toggleSelectPage = (pageNum) => { const toggleSelectPage = (pageNum) => {
let updated = [...selectedPages]; let updated = [...selectedPagesLocal];
if (updated.includes(pageNum)) { if (updated.includes(pageNum)) {
updated = updated.filter((p) => p !== pageNum); updated = updated.filter((p) => p !== pageNum);
} else { } else {
@ -67,14 +70,21 @@ export function usePdfViewerController() {
}; };
const handleProcessPdf = async () => { const handleProcessPdf = async () => {
if (selectedPages.length === 0) return; if (selectedPagesLocal.length === 0) return;
try { try {
setLoading(true); setLoading(true);
const res = await uploadPdf({ pages: selectedPages }); const res = await uploadFile(file, selectedPagesLocal);
console.log("PDF processed:", res); dispatch(setResult(res));
if (!res.tables) {
navigate("/admin/upload/validate"); navigate("/admin/upload/validate");
} else if(!Array.isArray(res.tables)) {
setErrorMsg(res.message);
} else {
navigate("/admin/upload/table-selector");
}
} catch (err) { } catch (err) {
console.error(err); setErrorMsg(err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -84,9 +94,10 @@ export function usePdfViewerController() {
file, file,
pages, pages,
loading, loading,
selectedPages, selectedPagesLocal,
loadPdfPages, loadPdfPages,
toggleSelectPage, toggleSelectPage,
handleProcessPdf, handleProcessPdf,
errorMsg, setErrorMsg
}; };
} }

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { usePdfViewerController } from "./controller_pdf_viewer"; import { usePdfViewerController } from "./controller_pdf_viewer";
import LoadingOverlay from "../../../../components/LoadingOverlay"; import LoadingOverlay from "../../../../components/LoadingOverlay";
import ErrorNotification from "@/components/ErrorNotification";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
export default function ViewsAdminPdfViewer() { export default function ViewsAdminPdfViewer() {
@ -9,10 +10,11 @@ export default function ViewsAdminPdfViewer() {
file, file,
pages, pages,
loading, loading,
selectedPages, selectedPagesLocal,
toggleSelectPage, toggleSelectPage,
handleProcessPdf, handleProcessPdf,
loadPdfPages, loadPdfPages,
errorMsg, setErrorMsg
} = usePdfViewerController(); } = usePdfViewerController();
const navigate = useNavigate(); const navigate = useNavigate();
@ -26,7 +28,13 @@ export default function ViewsAdminPdfViewer() {
return ( return (
<div className="flex h-[calc(100vh-106px)] bg-gray-100 overflow-hidden"> <div className="flex h-[calc(100vh-106px)] bg-gray-100 overflow-hidden">
{/* Sidebar kiri */}
<ErrorNotification
message={errorMsg}
onClose={() => setErrorMsg("")}
/>
{/* Left Sidebar */}
<div className="w-64 bg-white border-r border-gray-200 p-4 flex flex-col"> <div className="w-64 bg-white border-r border-gray-200 p-4 flex flex-col">
<h2 className="text-lg font-semibold mb-4">Daftar Halaman</h2> <h2 className="text-lg font-semibold mb-4">Daftar Halaman</h2>
<div className="flex-1 overflow-y-auto space-y-2"> <div className="flex-1 overflow-y-auto space-y-2">
@ -34,12 +42,12 @@ export default function ViewsAdminPdfViewer() {
<label <label
key={p.pageNum} key={p.pageNum}
className={`flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-blue-50 transition ${ className={`flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-blue-50 transition ${
selectedPages.includes(p.pageNum) ? "bg-blue-100" : "" selectedPagesLocal.includes(p.pageNum) ? "bg-blue-100" : ""
}`} }`}
> >
<input <input
type="checkbox" type="checkbox"
checked={selectedPages.includes(p.pageNum)} checked={selectedPagesLocal.includes(p.pageNum)}
onChange={() => toggleSelectPage(p.pageNum)} onChange={() => toggleSelectPage(p.pageNum)}
/> />
<span>Halaman {p.pageNum}</span> <span>Halaman {p.pageNum}</span>
@ -50,8 +58,8 @@ export default function ViewsAdminPdfViewer() {
<div className="mt-4 border-t pt-3 text-sm text-gray-600"> <div className="mt-4 border-t pt-3 text-sm text-gray-600">
<p> <p>
<span className="font-medium">Dipilih:</span>{" "} <span className="font-medium">Dipilih:</span>{" "}
{selectedPages.length > 0 {selectedPagesLocal.length > 0
? selectedPages.join(", ") ? selectedPagesLocal.join(", ")
: "Belum ada halaman"} : "Belum ada halaman"}
</p> </p>
<p className="text-xs mt-1 text-gray-400"> <p className="text-xs mt-1 text-gray-400">
@ -60,7 +68,7 @@ export default function ViewsAdminPdfViewer() {
<button <button
onClick={handleProcessPdf} onClick={handleProcessPdf}
disabled={selectedPages.length === 0} disabled={selectedPagesLocal.length === 0}
className="mt-3 w-full py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 transition" className="mt-3 w-full py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 transition"
> >
Proses Halaman Proses Halaman
@ -70,7 +78,7 @@ export default function ViewsAdminPdfViewer() {
{/* Konten kanan (viewer) */} {/* Konten kanan (viewer) */}
<div className="flex-1 relative overflow-y-auto"> <div className="flex-1 relative overflow-y-auto">
<LoadingOverlay show={loading} text="Merender PDF..." /> <LoadingOverlay show={loading} text="Loading..." />
<div className="p-6 space-y-8"> <div className="p-6 space-y-8">
{pages.map((p) => ( {pages.map((p) => (
<motion.div <motion.div

View File

@ -38,9 +38,9 @@ export async function uploadFile(file, page = null, sheet = null) {
const response = await api.post("/upload/file", formData, { const response = await api.post("/upload/file", formData, {
headers: { "Content-Type": "multipart/form-data" }, headers: { "Content-Type": "multipart/form-data" },
}); });
return response.data; return response.data.data;
} catch (error) { } catch (error) {
throw error.response?.data.detail || "Gagal proses file."; throw error.response?.data.detail.message || "Gagal proses file.";
} }
} }
@ -49,9 +49,9 @@ export async function uploadPdf(data) {
const response = await api.post("/upload/process-pdf", data, { const response = await api.post("/upload/process-pdf", data, {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
return response.data; return response.data.data;
} catch (error) { } catch (error) {
throw error.response?.data.detail || { message: "Gagal proses file." }; throw error.response?.data.detail.message || { message: "Gagal proses file." };
} }
} }
@ -61,8 +61,8 @@ export async function saveToDatabase(data) {
const response = await api.post("/upload/to-postgis", data, { const response = await api.post("/upload/to-postgis", data, {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
return response.data; return response.data.data;
} catch (error) { } catch (error) {
throw error.response?.data.detail || { message: "Gagal upload data." }; throw error.response?.data.detail.message || { message: "Gagal upload data." };
} }
} }

View File

@ -0,0 +1,41 @@
import { useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";
import { setResult } from "../../../../store/slices/uploadSlice";
import { uploadPdf } from "../service_admin_upload";
export function useTablePickerController() {
const dispatch = useDispatch();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const { result } = useSelector((state) => state.upload); // result dari BE upload PDF
const [selectedTable, setSelectedTableLocal] = useState(
result?.tables?.[0] || null
);
const handleSelectTable = (t) => {
setSelectedTableLocal(t);
// dispatch(setSelectedTable(t));
};
const handleNext = async () => {
if (!selectedTable) return;
setLoading(true);
try {
const res = await uploadPdf(selectedTable);
dispatch(setResult(res));
navigate("/admin/upload/validate");
} catch(err){
setLoading(false);
throw err
}
};
return {
loading,
result,
selectedTable,
handleSelectTable,
handleNext,
};
}

View File

@ -0,0 +1,112 @@
import { Navigate } from "react-router-dom";
import { useTablePickerController } from "./controller_admin_table_picker";
import LoadingOverlay from "../../../../components/LoadingOverlay";
export default function ViewsAdminTablePicker() {
const { loading, result, selectedTable, handleSelectTable, handleNext } =
useTablePickerController();
if (!result) {
return <Navigate to="/admin/upload" />;
}
return (
<div className="flex h-[calc(100vh-106px)] bg-gray-100 overflow-hidden">
<LoadingOverlay show={loading} text="Proses deteksi kolom..." />
{/* Sidebar kiri */}
<div className="w-64 bg-white border-r border-gray-200 p-4 flex flex-col">
<h2 className="text-lg font-semibold mb-4">Daftar Tabel</h2>
<div className="flex-1 overflow-y-auto space-y-2">
{result.tables.map((t, i) => (
<label
key={i}
onClick={() => handleSelectTable(t)}
className={`flex items-center justify-between px-3 py-2 rounded cursor-pointer border transition ${
selectedTable?.title === t.title
? "bg-blue-100 border-blue-400 font-semibold"
: "bg-white hover:bg-blue-50 border-gray-200"
}`}
>
<span>Tabel {t.title}</span>
{selectedTable?.title === t.title && <span></span>}
</label>
))}
</div>
<div className="mt-4 border-t pt-3 text-sm text-gray-600">
<p>
<span className="font-medium">Dipilih:</span>{" "}
{selectedTable ? `Tabel ${selectedTable.title}` : "Belum ada"}
</p>
<button
onClick={handleNext}
disabled={!selectedTable}
className="mt-3 w-full py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:bg-gray-400 transition"
>
Proses Tabel
</button>
</div>
</div>
{/* Konten kanan (tabel preview) */}
<div className="flex-1 relative overflow-y-auto bg-gray-50">
<div className="p-6">
{selectedTable ? (
<div className="bg-white border border-gray-200 rounded-lg shadow-sm p-4">
<h3 className="text-lg font-semibold text-gray-700 mb-3">
📄 Tabel {selectedTable.title}
</h3>
<div className="overflow-x-auto">
<table className="min-w-full text-sm border border-gray-200 rounded-lg overflow-hidden">
<thead className="bg-gray-100">
<tr>
{selectedTable.columns.map((col, idx) => (
<th
key={idx}
className="px-3 py-2 text-left font-medium text-gray-600 border-b border-gray-200"
>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{selectedTable.rows.slice(0, 10).map((row, rowIdx) => (
<tr
key={rowIdx}
className={`${
rowIdx % 2 === 0 ? "bg-white" : "bg-gray-50"
} hover:bg-blue-50 transition`}
>
{selectedTable.columns.map((_, colIdx) => (
<td
key={colIdx}
className="px-3 py-2 text-gray-700 border-b border-gray-100"
>
{row[colIdx]}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<p className="text-xs text-gray-500 mt-2 text-right">
Menampilkan {Math.min(10, selectedTable.rows.length)} dari{" "}
{selectedTable.rows.length} baris.
</p>
</div>
) : (
<div className="text-center text-gray-500 mt-20">
Tidak ada tabel yang dipilih.
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -4,23 +4,19 @@ import { Navigate } from "react-router-dom";
export default function ViewsAdminUploadSuccess() { export default function ViewsAdminUploadSuccess() {
const { validatedData } = useSelector((state) => state.upload); const { validatedData } = useSelector((state) => state.upload);
const geomIcons = {
Point: "📍",
MultiPoint: "🔹",
LineString: "📏",
MultiLineString: "🛣️",
Polygon: "⬛",
MultiPolygon: "🗾",
GeometryCollection: "🧩",
};
if (!validatedData) { if (!validatedData) {
// return (
// <div className="max-w-3xl mx-auto py-20 text-center">
// <h1 className="text-3xl font-bold text-yellow-600 mb-4"> Tidak Ada Data</h1>
// <p className="text-gray-700 mb-6">
// Tidak ditemukan hasil upload yang baru. Silakan unggah data terlebih dahulu.
// </p>
// <Link
// to="/admin/upload"
// className="bg-blue-600 text-white px-5 py-2 rounded hover:bg-blue-700"
// >
// Kembali ke Halaman Upload
// </Link>
// </div>
// );
return <Navigate to="/admin/upload" />; return <Navigate to="/admin/upload" />;
}else{
console.log('success', validatedData);
} }
return ( return (
@ -30,7 +26,6 @@ export default function ViewsAdminUploadSuccess() {
Data Anda berhasil disimpan ke database. Data Anda berhasil disimpan ke database.
</p> </p>
{/* Ringkasan hasil dari backend */}
<div className="relative border border-gray-200 bg-gradient-to-b from-white to-gray-50 rounded-2xl shadow-md p-8 mb-10 text-left overflow-hidden"> <div className="relative border border-gray-200 bg-gradient-to-b from-white to-gray-50 rounded-2xl shadow-md p-8 mb-10 text-left overflow-hidden">
<div className="absolute top-0 right-0 w-32 h-32 bg-green-100 rounded-full blur-3xl opacity-50 pointer-events-none"></div> <div className="absolute top-0 right-0 w-32 h-32 bg-green-100 rounded-full blur-3xl opacity-50 pointer-events-none"></div>
<div className="absolute bottom-0 left-0 w-32 h-32 bg-blue-100 rounded-full blur-3xl opacity-50 pointer-events-none"></div> <div className="absolute bottom-0 left-0 w-32 h-32 bg-blue-100 rounded-full blur-3xl opacity-50 pointer-events-none"></div>
@ -57,7 +52,6 @@ export default function ViewsAdminUploadSuccess() {
</h2> </h2>
</div> </div>
{/* Detail List */}
<div className="space-y-4 relative z-10"> <div className="space-y-4 relative z-10">
{validatedData.table_name && ( {validatedData.table_name && (
<div className="flex justify-between items-center bg-gray-50 px-4 py-3 rounded-lg border border-gray-200 hover:shadow-sm transition"> <div className="flex justify-between items-center bg-gray-50 px-4 py-3 rounded-lg border border-gray-200 hover:shadow-sm transition">
@ -75,6 +69,18 @@ export default function ViewsAdminUploadSuccess() {
</div> </div>
)} )}
{validatedData.geometry_type && (
<div className="flex justify-between items-center bg-gray-50 px-4 py-3 rounded-lg border border-gray-200 hover:shadow-sm transition">
<span className="text-gray-600 font-medium">🧭 Jenis Geometry</span>
<span className="text-gray-900 font-semibold">
{/* {validatedData.geometry_type.join(", ")} */}
{validatedData.geometry_type.map(
(g) => `${geomIcons[g] || "❓"} ${g}`
).join(", ")}
</span>
</div>
)}
{validatedData.upload_time && ( {validatedData.upload_time && (
<div className="flex justify-between items-center bg-gray-50 px-4 py-3 rounded-lg border border-gray-200 hover:shadow-sm transition"> <div className="flex justify-between items-center bg-gray-50 px-4 py-3 rounded-lg border border-gray-200 hover:shadow-sm transition">
<span className="text-gray-600 font-medium">🕒 Waktu Upload</span> <span className="text-gray-600 font-medium">🕒 Waktu Upload</span>
@ -90,7 +96,8 @@ export default function ViewsAdminUploadSuccess() {
{validatedData.message && ( {validatedData.message && (
<div className="bg-green-50 border border-green-200 px-5 py-4 rounded-lg mt-4"> <div className="bg-green-50 border border-green-200 px-5 py-4 rounded-lg mt-4">
<p className="w-full text-center text-green-700 font-semibold"> <p className="w-full text-center text-green-700 font-semibold">
{validatedData.message} {/* {validatedData.message} */}
Datasets berhasil di upload
</p> </p>
</div> </div>
)} )}

View File

@ -1,14 +1,183 @@
import { useState, useEffect } from "react"; // import { useState, useEffect } from "react";
// import { useUploadController } from "./controller_admin_upload";
// import { useSelector } from "react-redux";
// import LoadingOverlay from "../../../components/LoadingOverlay";
// import Notification from "../../../components/Notification";
// import ErrorNotification from "../../../components/ErrorNotification";
// import { Navigate } from "react-router-dom";
// import FilePreview from "../../../components/upload/FilePreview";
// import MetadataForm from "../../../components/MetaDataForm";
// export default function ViewsAdminUploadValidate() {
// const { result, file } = useSelector((state) => state.upload);
// const {
// loading,
// tableTitle,
// setTableTitle,
// handleConfirmUpload,
// } = useUploadController();
// const [showAlert, setShowAlert] = useState(false);
// const [alertMessage, setAlertMessage] = useState("");
// const [alertType, setAlertType] = useState("info");
// const [errorMsg, setErrorMsg] = useState("");
// useEffect(() => {
// const handleBeforeUnload = (e) => {
// if (result && !loading) {
// e.preventDefault();
// e.returnValue =
// "Data upload Anda belum disimpan. Jika Anda meninggalkan halaman ini, data akan hilang.";
// return e.returnValue;
// }
// };
// window.addEventListener("beforeunload", handleBeforeUnload);
// return () => window.removeEventListener("beforeunload", handleBeforeUnload);
// }, [result, loading]);
// useEffect(() => {
// const handleNavigation = (e) => {
// if (result && !loading) {
// const confirmLeave = window.confirm(
// "Data upload Anda belum disimpan. Jika Anda meninggalkan halaman ini, data akan hilang."
// );
// if (!confirmLeave) {
// e.preventDefault();
// window.history.pushState(null, "", window.location.href); // tetap di halaman
// }
// }
// };
// window.addEventListener("popstate", handleNavigation);
// return () => window.removeEventListener("popstate", handleNavigation);
// }, [result, loading]);
// const handleMetadataChange = (data) => {
// console.log("Metadata Updated:", data);
// };
// // if (!result)
// // return <div className="text-center mt-10">Data belum diupload.</div>;
// if (!result) return <Navigate to="/admin/upload" />;
// const handleUploadClick = async () => {
// if (!tableTitle.trim()) {
// setAlertMessage(
// "Judul tabel belum diisi. Silakan isi sebelum melanjutkan."
// );
// setAlertType("error");
// setShowAlert(true);
// return;
// }
// // handleConfirmUpload();
// try {
// await handleConfirmUpload();
// } catch (err) {
// setErrorMsg(err);
// }
// };
// return (
// <div className="max-w-4xl mx-auto py-10">
// {showAlert && (
// <Notification
// message={alertMessage}
// type={alertType}
// onClose={() => setShowAlert(false)}
// />
// )}
// <ErrorNotification
// message={errorMsg}
// onClose={() => setErrorMsg("")}
// />
// <LoadingOverlay show={loading} text="Upload to database..." />
// <h1 className="text-2xl font-bold mb-4"> Validasi & Konfirmasi Data</h1>
// <MetadataForm onChange={handleMetadataChange} />
// <div className="w-full mx-auto mt-6">
// <label
// htmlFor="tableTitle"
// className="block text-sm font-medium text-gray-700 mb-2"
// >
// Judul Tabel
// </label>
// <input
// id="tableTitle"
// type="text"
// value={tableTitle}
// onChange={(e) => setTableTitle(e.target.value)}
// placeholder="Masukkan judul tabel..."
// className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 ${
// !tableTitle ? "border-red-400" : ""
// }`}
// />
// <small className="text-gray-500">
// Judul akan dijadikan nama tabel pada database
// </small>
// </div>
// {result && <FilePreview result={result} />}
// <div className="mt-6 flex justify-between">
// <button
// onClick={() => history.back()}
// className="px-5 py-2 text-blue-600 hover:underline"
// >
// Kembali
// </button>
// <button
// onClick={handleUploadClick}
// disabled={loading}
// className="px-5 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:bg-gray-400"
// >
// {loading ? "Mengunggah..." : "Upload ke Database"}
// </button>
// </div>
// </div>
// );
// }
// <div className="bg-white border rounded-xl shadow-sm p-6 mt-4">
// <FilePreview result={result} />
// </div>
import { useEffect, useState } from "react";
import { useUploadController } from "./controller_admin_upload"; import { useUploadController } from "./controller_admin_upload";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { Navigate } from "react-router-dom";
import LoadingOverlay from "../../../components/LoadingOverlay"; import LoadingOverlay from "../../../components/LoadingOverlay";
import Notification from "../../../components/Notification"; import Notification from "../../../components/Notification";
import ErrorNotification from "../../../components/ErrorNotification"; import ErrorNotification from "../../../components/ErrorNotification";
import { Navigate } from "react-router-dom"; import MetadataForm from "../../../components/MetaDataForm";
import FilePreview from "../../../components/upload/FilePreview"; import FilePreview from "../../../components/upload/FilePreview";
// shadcn accordion (pastikan path sesuai proyekmu)
import {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from "../../../components/ui/accordion";
export default function ViewsAdminUploadValidate() { export default function ViewsAdminUploadValidate() {
const { result, file } = useSelector((state) => state.upload); const { result } = useSelector((state) => state.upload);
const { const {
loading, loading,
tableTitle, tableTitle,
@ -16,67 +185,58 @@ export default function ViewsAdminUploadValidate() {
handleConfirmUpload, handleConfirmUpload,
} = useUploadController(); } = useUploadController();
const [errorMsg, setErrorMsg] = useState("");
const [showAlert, setShowAlert] = useState(false); const [showAlert, setShowAlert] = useState(false);
const [alertMessage, setAlertMessage] = useState(""); const [alertMessage, setAlertMessage] = useState("");
const [alertType, setAlertType] = useState("info"); const [alertType, setAlertType] = useState("info");
const [errorMsg, setErrorMsg] = useState("");
useEffect(() => { // Local state: index tabel yg dipilih (default 0)
const handleBeforeUnload = (e) => { const [selectedIndex, setSelectedIndex] = useState(0);
if (result && !loading) { // Metadata form state is emitted via onChange from MetadataForm; simpan jika perlu
e.preventDefault(); const [metadata, setMetadata] = useState(null);
e.returnValue =
"Data upload Anda belum disimpan. Jika Anda meninggalkan halaman ini, data akan hilang.";
return e.returnValue;
}
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [result, loading]);
useEffect(() => {
const handleNavigation = (e) => {
if (result && !loading) {
const confirmLeave = window.confirm(
"Data upload Anda belum disimpan. Jika Anda meninggalkan halaman ini, data akan hilang."
);
if (!confirmLeave) {
e.preventDefault();
window.history.pushState(null, "", window.location.href); // tetap di halaman
}
}
};
window.addEventListener("popstate", handleNavigation);
return () => window.removeEventListener("popstate", handleNavigation);
}, [result, loading]);
// if (!result)
// return <div className="text-center mt-10">Data belum diupload.</div>;
// Guard: jika tidak ada result -> kembalikan ke halaman upload
if (!result) return <Navigate to="/admin/upload" />; if (!result) return <Navigate to="/admin/upload" />;
// Keep selectedIndex valid ketika result berubah
useEffect(() => {
if (!result || !result.tables || result.tables.length === 0) {
setSelectedIndex(0);
return;
}
// clamp index
setSelectedIndex((idx) => {
if (!result.tables) return 0;
if (idx < 0) return 0;
if (idx >= result.tables.length) return result.tables.length - 1;
return idx;
});
}, [result]);
const handleUploadClick = async () => { const handleUploadClick = async () => {
if (!tableTitle.trim()) { if (!tableTitle || !tableTitle.trim()) {
setAlertMessage( setAlertMessage("❗Judul tabel belum diisi. Silakan isi sebelum melanjutkan.");
"❗Judul tabel belum diisi. Silakan isi sebelum melanjutkan."
);
setAlertType("error"); setAlertType("error");
setShowAlert(true); setShowAlert(true);
return; return;
} }
// handleConfirmUpload();
try { try {
await handleConfirmUpload(); await handleConfirmUpload(metadata);
} catch (err) { } catch (err) {
setErrorMsg(err); // tangani error dari controller/service
const message =
err?.response?.data?.detail ||
err?.message ||
"Terjadi kesalahan saat mengunggah ke database.";
setErrorMsg(message);
} }
}; };
const selectedTable = result.tables?.[selectedIndex] || null;
return ( return (
<div className="max-w-4xl mx-auto py-10"> <div className="py-10">
{/* Alerts */}
{showAlert && ( {showAlert && (
<Notification <Notification
message={alertMessage} message={alertMessage}
@ -84,40 +244,41 @@ export default function ViewsAdminUploadValidate() {
onClose={() => setShowAlert(false)} onClose={() => setShowAlert(false)}
/> />
)} )}
<ErrorNotification message={errorMsg} onClose={() => setErrorMsg("")} />
<ErrorNotification
message={errorMsg}
onClose={() => setErrorMsg("")}
/>
<LoadingOverlay show={loading} text="Upload to database..." /> <LoadingOverlay show={loading} text="Upload to database..." />
<h1 className="text-2xl font-bold mb-4"> Validasi & Konfirmasi Data</h1> <h1 className="text-2xl font-bold mb-4"> Validasi & Konfirmasi Data</h1>
<div className="w-full mx-auto mt-6"> {/* SINGLE ACCORDION */}
<label <Accordion type="single" collapsible defaultValue="validate-panel" className="w-full">
htmlFor="tableTitle" <AccordionItem value="validate-panel" className="bg-white rounded-xl border shadow-sm px-3 mb-4">
className="block text-sm font-medium text-gray-700 mb-2" <AccordionTrigger className="text-lg font-semibold">
> 📄 Dataset 1
Judul Tabel </AccordionTrigger>
</label>
<input <AccordionContent>
id="tableTitle" <div className="mt-4 grid grid-cols-1 lg:grid-cols-12 gap-8">
type="text" {/* LEFT: tabel preview (6 kolom pada layout 12) */}
value={tableTitle} <div className="lg:col-span-6 col-span-1 min-w-0">
onChange={(e) => setTableTitle(e.target.value)} <h3 className="text-xl font-semibold mb-3">🧾 Cuplikan Data</h3>
placeholder="Masukkan judul tabel..." <div className="mb-3">
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 ${ <div className="flex gap-2 min-w-0">
!tableTitle ? "border-red-400" : "" <FilePreview result={result} />
}`} </div>
/> </div>
<small className="text-gray-500">
Judul akan dijadikan nama tabel pada database
</small>
</div> </div>
{result && <FilePreview result={result} />} {/* RIGHT: metadata form (6 kolom) */}
<div className="lg:col-span-6 col-span-1">
<h3 className="text-xl font-semibold mb-3">🧾 Metadata</h3>
{/* MetadataForm menyimpan hasil ke parent via onChange */}
<MetadataForm onChange={(data) => setMetadata(data)}/>
</div>
</div>
{/* ACTIONS di bawah accordion content */}
<div className="mt-6 flex justify-between"> <div className="mt-6 flex justify-between">
<button <button
onClick={() => history.back()} onClick={() => history.back()}
@ -126,6 +287,14 @@ export default function ViewsAdminUploadValidate() {
Kembali Kembali
</button> </button>
<div className="flex items-center gap-3">
{/* optional: show metadata summary brief */}
{metadata && (
<div className="text-xs text-gray-600">
Metadata siap preview: <span className="font-medium">{metadata.title || "-"}</span>
</div>
)}
<button <button
onClick={handleUploadClick} onClick={handleUploadClick}
disabled={loading} disabled={loading}
@ -135,5 +304,9 @@ export default function ViewsAdminUploadValidate() {
</button> </button>
</div> </div>
</div> </div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
); );
} }

View File

@ -103,6 +103,7 @@ import ViewsAdminHome from "../pages/admin/home/views_admin_home";
import ViewsAdminUploadStep1 from "../pages/admin/upload/views_admin_upload"; import ViewsAdminUploadStep1 from "../pages/admin/upload/views_admin_upload";
import ViewsAdminUploadValidate from "../pages/admin/upload/views_admin_validate_upload"; import ViewsAdminUploadValidate from "../pages/admin/upload/views_admin_validate_upload";
import ViewsAdminPdfViewer from "../pages/admin/upload/pdf_viewer/views_admin_pdf_viewer"; import ViewsAdminPdfViewer from "../pages/admin/upload/pdf_viewer/views_admin_pdf_viewer";
import ViewsAdminTablePicker from "../pages/admin/upload/table_picker/views_admin_table_picker";
import ViewsAdminUploadSuccess from "../pages/admin/upload/views_admin_success_upload"; import ViewsAdminUploadSuccess from "../pages/admin/upload/views_admin_success_upload";
import ViewsAdminPublikasi from "../pages/admin/publikasi/views_admin_publikasi"; import ViewsAdminPublikasi from "../pages/admin/publikasi/views_admin_publikasi";
import ViewsAdminUploadRules from "../pages/admin/upload/rules/views_admin_rules_upload"; import ViewsAdminUploadRules from "../pages/admin/upload/rules/views_admin_rules_upload";
@ -129,6 +130,7 @@ const router = createBrowserRouter(
{ path: "upload", element: <ViewsAdminUploadStep1 /> }, { path: "upload", element: <ViewsAdminUploadStep1 /> },
{ path: "upload/validate", element: <ViewsAdminUploadValidate /> }, { path: "upload/validate", element: <ViewsAdminUploadValidate /> },
{ path: "upload/pdf", element: <ViewsAdminPdfViewer /> }, { path: "upload/pdf", element: <ViewsAdminPdfViewer /> },
{ path: "upload/table-selector", element: <ViewsAdminTablePicker /> },
{ path: "upload/success", element: <ViewsAdminUploadSuccess /> }, { path: "upload/success", element: <ViewsAdminUploadSuccess /> },
{ path: "upload/rules", element: <ViewsAdminUploadRules /> }, { path: "upload/rules", element: <ViewsAdminUploadRules /> },
{ path: "publikasi", element: <ViewsAdminPublikasi /> }, { path: "publikasi", element: <ViewsAdminPublikasi /> },

View File

@ -2,7 +2,9 @@ import axios from "axios";
import { getToken } from "../utils/auth"; import { getToken } from "../utils/auth";
const api = axios.create({ const api = axios.create({
baseURL: "http://labai.polinema.ac.id:808", // baseURL: "http://labai.polinema.ac.id:808",
baseURL: "http://localhost:8000",
// baseURL:"https://kkqc31ns-8000.asse.devtunnels.ms"
}); });
api.interceptors.request.use((config) => { api.interceptors.request.use((config) => {

View File

@ -5,7 +5,8 @@ const initialState = {
result: null, result: null,
validatedData: null, validatedData: null,
pdfPageCount: null, pdfPageCount: null,
selectedPages: null selectedPages: null,
validTable: null
}; };
const uploadSlice = createSlice({ const uploadSlice = createSlice({
@ -27,6 +28,9 @@ const uploadSlice = createSlice({
setSelectedPages: (state, action) => { setSelectedPages: (state, action) => {
state.selectedPages = action.payload; state.selectedPages = action.payload;
}, },
setValidTable: (state, action) => {
state.validTable = action.payload;
},
reset: (state) => { reset: (state) => {
state.file = null; state.file = null;
state.result = null; state.result = null;
@ -37,5 +41,5 @@ const uploadSlice = createSlice({
}, },
}); });
export const { setFile, setResult, setValidatedData, reset, setPdfPageCount, setSelectedPages } = uploadSlice.actions; export const { setFile, setResult, setValidatedData, reset, setPdfPageCount, setSelectedPages, setValidTable } = uploadSlice.actions;
export default uploadSlice.reducer; export default uploadSlice.reducer;

View File

@ -1,6 +1,7 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc' import react from '@vitejs/plugin-react-swc'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import * as path from "path";
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
@ -11,6 +12,7 @@ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
stream: 'stream-browserify', stream: 'stream-browserify',
"@": path.resolve(__dirname, "./src"),
}, },
}, },
}) })