tambahan
This commit is contained in:
230
bun.lock
230
bun.lock
@@ -9,15 +9,19 @@
|
||||
"@elysiajs/jwt": "^1.4.0",
|
||||
"@elysiajs/swagger": "^1.3.1",
|
||||
"@mantine/core": "^8.3.3",
|
||||
"@mantine/dates": "^8.3.4",
|
||||
"@mantine/form": "^8.3.4",
|
||||
"@mantine/hooks": "^8.3.3",
|
||||
"@mantine/notifications": "^8.3.3",
|
||||
"@modelcontextprotocol/sdk": "^1.19.1",
|
||||
"@prisma/client": "^6.7.0",
|
||||
"@tabler/icons-react": "^3.35.0",
|
||||
"@types/jwt-decode": "^3.1.0",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"add": "^2.0.6",
|
||||
"elysia": "^1.4.9",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
"react-router-dom": "^7.9.3",
|
||||
@@ -28,6 +32,8 @@
|
||||
"@types/bun": "latest",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"biome": "^0.3.3",
|
||||
"oxlint": "^1.22.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
@@ -58,6 +64,10 @@
|
||||
|
||||
"@mantine/core": ["@mantine/core@8.3.3", "", { "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", "react-number-format": "^5.4.4", "react-remove-scroll": "^2.7.1", "react-textarea-autosize": "8.5.9", "type-fest": "^4.41.0" }, "peerDependencies": { "@mantine/hooks": "8.3.3", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-OdTAQ0lsXjEqfea0KyXJ1rV9cZb/Rtqv5l3luG2m8Sx5BTGMqXas6mKHtdj4LwIiUKeFkIkZYjNmH6ri1HXjSA=="],
|
||||
|
||||
"@mantine/dates": ["@mantine/dates@8.3.4", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "@mantine/core": "8.3.4", "@mantine/hooks": "8.3.4", "dayjs": ">=1.0.0", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-Je7R86lIpU8gCMdSueCD5TZrIOEFPiRr3t+/uvgBGG4Om6KmU2FJw/8f8wbVDmj0KJAl2xzXy2UiKJGpnLdl1A=="],
|
||||
|
||||
"@mantine/form": ["@mantine/form@8.3.4", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "klona": "^2.0.6" }, "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-mfJ5tFTLP5IGBn1yY+ZpOMNF0/NPjjv+C8T1wL6Xcoik1FvfavdE/1viE1Dy7VlFZWxIb1mr0L0OrqRTL/8g0A=="],
|
||||
|
||||
"@mantine/hooks": ["@mantine/hooks@8.3.3", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-nmspxbFSjFkimRXYhgAujnyBwGeAWDSP1WKHFR+Yl5x3Q0IkmsiOTE9yJPjMjmjffZfunFXQFwQDl1OF3m42Pw=="],
|
||||
|
||||
"@mantine/notifications": ["@mantine/notifications@8.3.3", "", { "dependencies": { "@mantine/store": "8.3.3", "react-transition-group": "4.4.5" }, "peerDependencies": { "@mantine/core": "8.3.3", "@mantine/hooks": "8.3.3", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-tEp2nGxx9gd8616V7T93l6D6XAXmEa+H2MERwxsBs6IGjGcswda8MUc10SLhLCJgDzB0RX0Pcod4r+tpGbXz/Q=="],
|
||||
@@ -66,6 +76,22 @@
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.19.1", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-3Y2h3MZKjec1eAqSTBclATlX+AbC6n1LgfVzRMJLt3v6w0RCYgwLrjbxPDbhsYHt6Wdqc/aCceNJYgj448ELQQ=="],
|
||||
|
||||
"@oxlint/darwin-arm64": ["@oxlint/darwin-arm64@1.22.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vfgwTA1CowVaU3QXFBjfGjbPsHbdjAiJnWX5FBaq8uXS8tksGgl0ue14MK6fVnXncWK9j69LRnkteGTixxDAfA=="],
|
||||
|
||||
"@oxlint/darwin-x64": ["@oxlint/darwin-x64@1.22.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-70x7Y+e0Ddb2Cf2IZsYGnXZrnB/MZgOTi/VkyXZucbnQcpi2VoaYS4Ve662DaNkzvTxdKOGmyJVMmD/digdJLQ=="],
|
||||
|
||||
"@oxlint/linux-arm64-gnu": ["@oxlint/linux-arm64-gnu@1.22.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Rv94lOyEV8WEuzhjJSpCW3DbL/tlOVizPxth1v5XAFuQdM5rgpOMs3TsAf/YFUn52/qenwVglyvQZL8oAUYlpg=="],
|
||||
|
||||
"@oxlint/linux-arm64-musl": ["@oxlint/linux-arm64-musl@1.22.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Aau6V6Osoyb3SFmRejP3rRhs1qhep4aJTdotFf1RVMVSLJkF7Ir0p+eGZSaIJyylFZuCCxHpud3hWasphmZnzw=="],
|
||||
|
||||
"@oxlint/linux-x64-gnu": ["@oxlint/linux-x64-gnu@1.22.0", "", { "os": "linux", "cpu": "x64" }, "sha512-6eOtv+2gHrKw/hxUkV6hJdvYhzr0Dqzb4oc7sNlWxp64jU6I19tgMwSlmtn02r34YNSn+/NpZ/ECvQrycKUUFQ=="],
|
||||
|
||||
"@oxlint/linux-x64-musl": ["@oxlint/linux-x64-musl@1.22.0", "", { "os": "linux", "cpu": "x64" }, "sha512-c4O7qD7TCEfPE/FFKYvakF2sQoIP0LFZB8F5AQK4K9VYlyT1oENNRCdIiMu6irvLelOzJzkUM0XrvUCL9Kkxrw=="],
|
||||
|
||||
"@oxlint/win32-arm64": ["@oxlint/win32-arm64@1.22.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-6DJwF5A9VoIbSWNexLYubbuteAL23l3YN00wUL7Wt4ZfEZu2f/lWtGB9yC9BfKLXzudq8MvGkrS0szmV0bc1VQ=="],
|
||||
|
||||
"@oxlint/win32-x64": ["@oxlint/win32-x64@1.22.0", "", { "os": "win32", "cpu": "x64" }, "sha512-nf8EZnIUgIrHlP9k26iOFMZZPoJG16KqZBXu5CG5YTAtVcu4CWlee9Q/cOS/rgQNGjLF+WPw8sVA5P3iGlYGQQ=="],
|
||||
|
||||
"@prisma/client": ["@prisma/client@6.16.3", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-JfNfAtXG+/lIopsvoZlZiH2k5yNx87mcTS4t9/S5oufM1nKdXYxOvpDC1XoTCFBa5cQh7uXnbMPsmZrwZY80xw=="],
|
||||
|
||||
"@prisma/config": ["@prisma/config@6.16.3", "", { "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", "effect": "3.16.12", "empathic": "2.0.0" } }, "sha512-VlsLnG4oOuKGGMToEeVaRhoTBZu5H3q51jTQXb/diRags3WV0+BQK5MolJTtP6G7COlzoXmWeS11rNBtvg+qFQ=="],
|
||||
@@ -98,6 +124,8 @@
|
||||
|
||||
"@types/jwt-decode": ["@types/jwt-decode@3.1.0", "", { "dependencies": { "jwt-decode": "*" } }, "sha512-tthwik7TKkou3mVnBnvVuHnHElbjtdbM63pdBCbZTirCt3WAdM73Y79mOri7+ljsS99ZVwUFZHLMxJuJnv/z1w=="],
|
||||
|
||||
"@types/lodash": ["@types/lodash@4.17.20", "", {}, "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA=="],
|
||||
|
||||
"@types/node": ["@types/node@24.7.0", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.0", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA=="],
|
||||
@@ -112,8 +140,36 @@
|
||||
|
||||
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
|
||||
|
||||
"ansi-escapes": ["ansi-escapes@1.4.0", "", {}, "sha512-wiXutNjDUlNEDWHcYH3jtZUhd3c4/VojassD8zHdHCY13xbZy2XbW+NKQwA0tWGBVzDA9qEzYwfoSsWmviidhw=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@2.1.1", "", {}, "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@2.2.1", "", {}, "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA=="],
|
||||
|
||||
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
|
||||
|
||||
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
|
||||
|
||||
"assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="],
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"aws-sign2": ["aws-sign2@0.7.0", "", {}, "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA=="],
|
||||
|
||||
"aws4": ["aws4@1.13.2", "", {}, "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
|
||||
|
||||
"biome": ["biome@0.3.3", "", { "dependencies": { "bluebird": "^3.4.1", "chalk": "^1.1.3", "commander": "^2.9.0", "editor": "^1.0.0", "fs-promise": "^0.5.0", "inquirer-promise": "0.0.3", "request-promise": "^3.0.0", "untildify": "^3.0.2", "user-home": "^2.0.0" }, "bin": { "biome": "./dist/index.js" } }, "sha512-4LXjrQYbn9iTXu9Y4SKT7ABzTV0WnLDHCVSd2fPUOKsy1gQ+E4xPFmlY1zcWexoi0j7fGHItlL6OWA2CZ/yYAQ=="],
|
||||
|
||||
"bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="],
|
||||
|
||||
"body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
|
||||
"bun-types": ["bun-types@1.2.23", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
@@ -126,12 +182,28 @@
|
||||
|
||||
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
|
||||
|
||||
"caseless": ["caseless@0.12.0", "", {}, "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="],
|
||||
|
||||
"chalk": ["chalk@1.1.3", "", { "dependencies": { "ansi-styles": "^2.2.1", "escape-string-regexp": "^1.0.2", "has-ansi": "^2.0.0", "strip-ansi": "^3.0.0", "supports-color": "^2.0.0" } }, "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A=="],
|
||||
|
||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
|
||||
|
||||
"cli-cursor": ["cli-cursor@1.0.2", "", { "dependencies": { "restore-cursor": "^1.0.1" } }, "sha512-25tABq090YNKkF6JH7lcwO0zFJTRke4Jcq9iX2nr/Sz0Cjjv4gckmwlW6Ty/aoyFd6z3ysR2hMGC2GFugmBo6A=="],
|
||||
|
||||
"cli-width": ["cli-width@1.1.1", "", {}, "sha512-eMU2akIeEIkCxGXUNmDnJq1KzOIiPnJ+rKqRe6hcxE3vIOPvpMrBYOn/Bl7zNlYJj/zQxXquAnozHUCf9Whnsg=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"code-point-at": ["code-point-at@1.1.0", "", {}, "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA=="],
|
||||
|
||||
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
|
||||
"commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||
|
||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
|
||||
"confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="],
|
||||
|
||||
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
|
||||
@@ -144,6 +216,10 @@
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"core-js": ["core-js@2.6.12", "", {}, "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ=="],
|
||||
|
||||
"core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
|
||||
|
||||
"cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
@@ -152,12 +228,18 @@
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"dashdash": ["dashdash@1.14.1", "", { "dependencies": { "assert-plus": "^1.0.0" } }, "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g=="],
|
||||
|
||||
"dayjs": ["dayjs@1.11.18", "", {}, "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="],
|
||||
|
||||
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
|
||||
|
||||
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
@@ -172,6 +254,12 @@
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"earlgrey-runtime": ["earlgrey-runtime@0.1.2", "", { "dependencies": { "core-js": "^2.4.0", "kaiser": ">=0.0.4", "lodash": "^4.17.2", "regenerator-runtime": "^0.9.5" } }, "sha512-T4qoScXi5TwALDv8nlGTvOuCT8jXcKcxtO8qVdqv46IA2GHJfQzwoBPbkOmORnyhu3A98cVVuhWLsM2CzPljJg=="],
|
||||
|
||||
"ecc-jsbn": ["ecc-jsbn@0.1.2", "", { "dependencies": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" } }, "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw=="],
|
||||
|
||||
"editor": ["editor@1.0.0", "", {}, "sha512-SoRmbGStwNYHgKfjOrX2L0mUvp9bUVv0uPppZSOMAntEbcFtoC3MKF5b3T6HQPXKIV+QGY3xPO3JK5it5lVkuw=="],
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"effect": ["effect@3.16.12", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-N39iBk0K71F9nb442TLbTkjl24FLUzuvx2i1I2RsEAQsdAdUTuUoW0vlfUXgkMTUOnYqKnWcFfqw4hK4Pw27hg=="],
|
||||
@@ -190,6 +278,8 @@
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
|
||||
|
||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
|
||||
@@ -198,12 +288,18 @@
|
||||
|
||||
"exact-mirror": ["exact-mirror@0.2.2", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-CrGe+4QzHZlnrXZVlo/WbUZ4qQZq8C0uATQVGVgXIrNXgHDBBNFD1VRfssRA2C9t3RYvh3MadZSdg2Wy7HBoQA=="],
|
||||
|
||||
"exit-hook": ["exit-hook@1.1.1", "", {}, "sha512-MsG3prOVw1WtLXAZbM3KiYtooKR1LvxHh3VHsVtIy0uiUu8usxgB/94DP2HxtD/661lLdB6yzQ09lGJSQr6nkg=="],
|
||||
|
||||
"express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="],
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="],
|
||||
|
||||
"exsolve": ["exsolve@1.0.7", "", {}, "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"extsprintf": ["extsprintf@1.3.0", "", {}, "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g=="],
|
||||
|
||||
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
|
||||
|
||||
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
|
||||
@@ -214,12 +310,24 @@
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"figures": ["figures@1.7.0", "", { "dependencies": { "escape-string-regexp": "^1.0.5", "object-assign": "^4.1.0" } }, "sha512-UxKlfCRuCBxSXU4C6t9scbDyWZ4VlaFFdojKtzJuSkuOBQ5CNFum+zZXFwHjo+CxBC1t6zlYPgHIgFjL8ggoEQ=="],
|
||||
|
||||
"finalhandler": ["finalhandler@2.1.0", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q=="],
|
||||
|
||||
"forever-agent": ["forever-agent@0.6.1", "", {}, "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw=="],
|
||||
|
||||
"form-data": ["form-data@2.3.3", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.6", "mime-types": "^2.1.12" } }, "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ=="],
|
||||
|
||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
|
||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
"fs-extra": ["fs-extra@0.26.7", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^2.1.0", "klaw": "^1.0.0", "path-is-absolute": "^1.0.0", "rimraf": "^2.2.8" } }, "sha512-waKu+1KumRhYv8D8gMRCKJGAMI9pRnPuEb1mvgYD0f7wBscg+h6bW4FDTmEZhB9VKxvoTtxW+Y7bnIlB7zja6Q=="],
|
||||
|
||||
"fs-promise": ["fs-promise@0.5.0", "", { "dependencies": { "any-promise": "^1.0.0", "fs-extra": "^0.26.5", "mz": "^2.3.1", "thenify-all": "^1.6.0" } }, "sha512-Y+4F4ujhEcayCJt6JmzcOun9MYGQwz+bVUiuBmTkJImhBHKpBvmVPZR9wtfiF7k3ffwAOAuurygQe+cPLSFQhw=="],
|
||||
|
||||
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
@@ -228,10 +336,22 @@
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"getpass": ["getpass@0.1.7", "", { "dependencies": { "assert-plus": "^1.0.0" } }, "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng=="],
|
||||
|
||||
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
|
||||
|
||||
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"har-schema": ["har-schema@2.0.0", "", {}, "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q=="],
|
||||
|
||||
"har-validator": ["har-validator@5.1.5", "", { "dependencies": { "ajv": "^6.12.3", "har-schema": "^2.0.0" } }, "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w=="],
|
||||
|
||||
"has-ansi": ["has-ansi@2.0.0", "", { "dependencies": { "ansi-regex": "^2.0.0" } }, "sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
@@ -240,26 +360,58 @@
|
||||
|
||||
"http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
|
||||
|
||||
"http-signature": ["http-signature@1.2.0", "", { "dependencies": { "assert-plus": "^1.0.0", "jsprim": "^1.2.2", "sshpk": "^1.7.0" } }, "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="],
|
||||
|
||||
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"inquirer": ["inquirer@0.11.4", "", { "dependencies": { "ansi-escapes": "^1.1.0", "ansi-regex": "^2.0.0", "chalk": "^1.0.0", "cli-cursor": "^1.0.1", "cli-width": "^1.0.1", "figures": "^1.3.5", "lodash": "^3.3.1", "readline2": "^1.0.1", "run-async": "^0.1.0", "rx-lite": "^3.1.2", "string-width": "^1.0.1", "strip-ansi": "^3.0.0", "through": "^2.3.6" } }, "sha512-QR+2TW90jnKk9LUUtbcA3yQXKt2rDEKMh6+BAZQIeumtzHexnwVLdPakSslGijXYLJCzFv7GMXbFCn0pA00EUw=="],
|
||||
|
||||
"inquirer-promise": ["inquirer-promise@0.0.3", "", { "dependencies": { "earlgrey-runtime": ">=0.0.11", "inquirer": "^0.11.3" } }, "sha512-82CQX586JAV9GAgU9yXZsMDs+NorjA0nLhkfFx9+PReyOnuoHRbHrC1Z90sS95bFJI1Tm1gzMObuE0HabzkJpg=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@1.0.0", "", { "dependencies": { "number-is-nan": "^1.0.0" } }, "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw=="],
|
||||
|
||||
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||
|
||||
"is-typedarray": ["is-typedarray@1.0.0", "", {}, "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"isstream": ["isstream@0.1.2", "", {}, "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"jose": ["jose@6.1.0", "", {}, "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsbn": ["jsbn@0.1.1", "", {}, "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg=="],
|
||||
|
||||
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
|
||||
|
||||
"jsonfile": ["jsonfile@2.4.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-PKllAqbgLgxHaj8TElYymKCAgrASebJrWpTnEkOaTowt23VKXXN0sUeriJ+eh7y6ufb/CC5ap11pz71/cM0hUw=="],
|
||||
|
||||
"jsprim": ["jsprim@1.4.2", "", { "dependencies": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", "json-schema": "0.4.0", "verror": "1.10.0" } }, "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw=="],
|
||||
|
||||
"jwt-decode": ["jwt-decode@4.0.0", "", {}, "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA=="],
|
||||
|
||||
"kaiser": ["kaiser@0.0.4", "", { "dependencies": { "earlgrey-runtime": ">=0.0.10" } }, "sha512-m8ju+rmBqvclZmyrOXgGGhOYSjKJK6RN1NhqEltemY87UqZOxEkizg9TOy1vQSyJ01Wx6SAPuuN0iO2Mgislvw=="],
|
||||
|
||||
"klaw": ["klaw@1.3.1", "", { "optionalDependencies": { "graceful-fs": "^4.1.9" } }, "sha512-TED5xi9gGQjGpNnvRWknrwAB1eL5GciPfVFOt3Vk1OJCVDQbzuSfrF3hkUQKlsgKrG1F+0t5W0m+Fje1jIt8rw=="],
|
||||
|
||||
"klona": ["klona@2.0.6", "", {}, "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA=="],
|
||||
|
||||
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
@@ -272,16 +424,26 @@
|
||||
|
||||
"mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="],
|
||||
|
||||
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"mute-stream": ["mute-stream@0.0.5", "", {}, "sha512-EbrziT4s8cWPmzr47eYVW3wimS4HsvlnV5ri1xw1aR6JQo/OrJX5rkl32K/QQHdxeabJETtfeaROGhd8W7uBgg=="],
|
||||
|
||||
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
||||
|
||||
"number-is-nan": ["number-is-nan@1.0.1", "", {}, "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ=="],
|
||||
|
||||
"nypm": ["nypm@0.6.2", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g=="],
|
||||
|
||||
"oauth-sign": ["oauth-sign@0.9.0", "", {}, "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
@@ -292,10 +454,18 @@
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"onetime": ["onetime@1.1.0", "", {}, "sha512-GZ+g4jayMqzCRMgB2sol7GiCLjKfS1PINkjmx8spcKce1LiVqcbQreXwqs2YAFXC6R03VIG28ZS31t8M866v6A=="],
|
||||
|
||||
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
|
||||
|
||||
"os-homedir": ["os-homedir@1.0.2", "", {}, "sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ=="],
|
||||
|
||||
"oxlint": ["oxlint@1.22.0", "", { "optionalDependencies": { "@oxlint/darwin-arm64": "1.22.0", "@oxlint/darwin-x64": "1.22.0", "@oxlint/linux-arm64-gnu": "1.22.0", "@oxlint/linux-arm64-musl": "1.22.0", "@oxlint/linux-x64-gnu": "1.22.0", "@oxlint/linux-x64-musl": "1.22.0", "@oxlint/win32-arm64": "1.22.0", "@oxlint/win32-x64": "1.22.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.2.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint", "oxc_language_server": "bin/oxc_language_server" } }, "sha512-/HYT1Cfanveim9QUM6KlPKJe9y+WPnh3SxIB7z1InWnag9S0nzxLaWEUiW1P4UGzh/No3KvtNmBv2IOiwAl2/w=="],
|
||||
|
||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
||||
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
|
||||
@@ -304,6 +474,8 @@
|
||||
|
||||
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
|
||||
|
||||
"performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
@@ -334,6 +506,8 @@
|
||||
|
||||
"proxy-compare": ["proxy-compare@3.0.1", "", {}, "sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q=="],
|
||||
|
||||
"psl": ["psl@1.15.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
|
||||
@@ -370,8 +544,24 @@
|
||||
|
||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"readline2": ["readline2@1.0.1", "", { "dependencies": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", "mute-stream": "0.0.5" } }, "sha512-8/td4MmwUB6PkZUbV25uKz7dfrmjYWxsW8DVfibWdlHRk/l/DfHKn4pU+dfcoGLFgWOdyGCzINRQD7jn+Bv+/g=="],
|
||||
|
||||
"regenerator-runtime": ["regenerator-runtime@0.9.6", "", {}, "sha512-D0Y/JJ4VhusyMOd/o25a3jdUqN/bC85EFsaoL9Oqmy/O4efCh+xhp7yj2EEOsj974qvMkcW8AwUzJ1jB/MbxCw=="],
|
||||
|
||||
"request": ["request@2.88.2", "", { "dependencies": { "aws-sign2": "~0.7.0", "aws4": "^1.8.0", "caseless": "~0.12.0", "combined-stream": "~1.0.6", "extend": "~3.0.2", "forever-agent": "~0.6.1", "form-data": "~2.3.2", "har-validator": "~5.1.3", "http-signature": "~1.2.0", "is-typedarray": "~1.0.0", "isstream": "~0.1.2", "json-stringify-safe": "~5.0.1", "mime-types": "~2.1.19", "oauth-sign": "~0.9.0", "performance-now": "^2.1.0", "qs": "~6.5.2", "safe-buffer": "^5.1.2", "tough-cookie": "~2.5.0", "tunnel-agent": "^0.6.0", "uuid": "^3.3.2" } }, "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw=="],
|
||||
|
||||
"request-promise": ["request-promise@3.0.0", "", { "dependencies": { "bluebird": "^3.3", "lodash": "^4.6.1", "request": "^2.34" } }, "sha512-wVGUX+BoKxYsavTA72i6qHcyLbjzM4LR4y/AmDCqlbuMAursZdDWO7PmgbGAUvD2SeEJ5iB99VSq/U51i/DNbw=="],
|
||||
|
||||
"restore-cursor": ["restore-cursor@1.0.1", "", { "dependencies": { "exit-hook": "^1.0.0", "onetime": "^1.0.0" } }, "sha512-reSjH4HuiFlxlaBaFCiS6O76ZGG2ygKoSlCsipKdaZuKSPx/+bt9mULkn4l0asVzbEfQQmXRg6Wp6gv6m0wElw=="],
|
||||
|
||||
"rimraf": ["rimraf@2.7.1", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w=="],
|
||||
|
||||
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||
|
||||
"run-async": ["run-async@0.1.0", "", { "dependencies": { "once": "^1.3.0" } }, "sha512-qOX+w+IxFgpUpJfkv2oGN0+ExPs68F4sZHfaRRx4dDexAQkG83atugKVEylyT5ARees3HBbfmuvnjbrd8j9Wjw=="],
|
||||
|
||||
"rx-lite": ["rx-lite@3.1.2", "", {}, "sha512-1I1+G2gteLB8Tkt8YI1sJvSIfa0lWuRtC8GjvtyPBcLSF5jBCCJJqKrpER5JU5r6Bhe+i9/pK3VMuUcXu0kdwQ=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
@@ -400,22 +590,42 @@
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"sshpk": ["sshpk@1.18.0", "", { "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", "bcrypt-pbkdf": "^1.0.0", "dashdash": "^1.12.0", "ecc-jsbn": "~0.1.1", "getpass": "^0.1.1", "jsbn": "~0.1.0", "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" }, "bin": { "sshpk-conv": "bin/sshpk-conv", "sshpk-sign": "bin/sshpk-sign", "sshpk-verify": "bin/sshpk-verify" } }, "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ=="],
|
||||
|
||||
"statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="],
|
||||
|
||||
"string-width": ["string-width@1.0.2", "", { "dependencies": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", "strip-ansi": "^3.0.0" } }, "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@3.0.1", "", { "dependencies": { "ansi-regex": "^2.0.0" } }, "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg=="],
|
||||
|
||||
"sugarss": ["sugarss@5.0.1", "", { "peerDependencies": { "postcss": "^8.3.3" } }, "sha512-ctS5RYCBVvPoZAnzIaX5QSShK8ZiZxD5HUqSxlusvEMC+QZQIPCPOIJg6aceFX+K2rf4+SH89eu++h1Zmsr2nw=="],
|
||||
|
||||
"supports-color": ["supports-color@2.0.0", "", {}, "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g=="],
|
||||
|
||||
"swr": ["swr@2.3.6", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw=="],
|
||||
|
||||
"tabbable": ["tabbable@6.2.0", "", {}, "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="],
|
||||
|
||||
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
|
||||
|
||||
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||
|
||||
"through": ["through@2.3.8", "", {}, "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.0.1", "", {}, "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"tough-cookie": ["tough-cookie@2.5.0", "", { "dependencies": { "psl": "^1.1.28", "punycode": "^2.1.1" } }, "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
|
||||
|
||||
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
|
||||
|
||||
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||
|
||||
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
|
||||
@@ -424,6 +634,8 @@
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"untildify": ["untildify@3.0.3", "", {}, "sha512-iSk/J8efr8uPT/Z4eSUywnqyrQU7DSdMfdqK4iWEaUVVmcP5JcnpRqmVMwcwcnmI1ATFNgC5V90u09tBynNFKA=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
|
||||
@@ -438,12 +650,18 @@
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||
|
||||
"user-home": ["user-home@2.0.0", "", { "dependencies": { "os-homedir": "^1.0.0" } }, "sha512-KMWqdlOcjCYdtIJpicDSFBQ8nFwS2i9sslAd6f4+CBGcU4gist2REnr2fxj2YocvJFxSF3ZOHLYLVZnUxv4BZQ=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"uuid": ["uuid@3.4.0", "", { "bin": { "uuid": "./bin/uuid" } }, "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A=="],
|
||||
|
||||
"valtio": ["valtio@2.1.8", "", { "dependencies": { "proxy-compare": "^3.0.1" }, "peerDependencies": { "@types/react": ">=18.0.0", "react": ">=18.0.0" }, "optionalPeers": ["@types/react", "react"] }, "sha512-fjTPbJyKEmfVBZUOh3V0OtMHoFUGr4+4XpejjxhNJE/IS2l8rDbyJuzi3w/fZWBDyk7BJOpG+lmvTK5iiVhXuQ=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"verror": ["verror@1.10.0", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
@@ -462,14 +680,26 @@
|
||||
|
||||
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"giget/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"inquirer/lodash": ["lodash@3.10.1", "", {}, "sha512-9mDDwqVIma6OZX79ZlDACZl8sBm0TEnkf99zV3iMA4GzkIT/9hiqP5mY0HoT1iNLCrKc/R1HByV+yJfRWVJryQ=="],
|
||||
|
||||
"nypm/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"request/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"request/qs": ["qs@6.5.3", "", {}, "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA=="],
|
||||
|
||||
"@scalar/themes/@scalar/types/@scalar/openapi-types": ["@scalar/openapi-types@0.2.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-waiKk12cRCqyUCWTOX0K1WEVX46+hVUK+zRPzAahDJ7G0TApvbNkuy5wx7aoUyEk++HHde0XuQnshXnt8jsddA=="],
|
||||
|
||||
"@scalar/themes/@scalar/types/nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="],
|
||||
|
||||
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"request/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
}
|
||||
}
|
||||
|
||||
1530
package-lock.json
generated
1530
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,8 @@
|
||||
"dev": "bun --hot src/index.tsx",
|
||||
"build": "bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='BUN_PUBLIC_*'",
|
||||
"start": "NODE_ENV=production bun src/index.tsx",
|
||||
"seed": "bun prisma/seed.ts"
|
||||
"seed": "bun prisma/seed.ts",
|
||||
"lint": "bunx oxlint src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@elysiajs/cors": "^1.4.0",
|
||||
@@ -15,15 +16,19 @@
|
||||
"@elysiajs/jwt": "^1.4.0",
|
||||
"@elysiajs/swagger": "^1.3.1",
|
||||
"@mantine/core": "^8.3.3",
|
||||
"@mantine/dates": "^8.3.4",
|
||||
"@mantine/form": "^8.3.4",
|
||||
"@mantine/hooks": "^8.3.3",
|
||||
"@mantine/notifications": "^8.3.3",
|
||||
"@modelcontextprotocol/sdk": "^1.19.1",
|
||||
"@prisma/client": "^6.7.0",
|
||||
"@tabler/icons-react": "^3.35.0",
|
||||
"@types/jwt-decode": "^3.1.0",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"add": "^2.0.6",
|
||||
"elysia": "^1.4.9",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
"react-router-dom": "^7.9.3",
|
||||
@@ -34,6 +39,8 @@
|
||||
"@types/bun": "latest",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"biome": "^0.3.3",
|
||||
"oxlint": "^1.22.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
|
||||
@@ -13,6 +13,7 @@ model User {
|
||||
name String?
|
||||
email String? @unique
|
||||
password String?
|
||||
phone String? @unique
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
ApiKey ApiKey[]
|
||||
|
||||
19
src/App.tsx
19
src/App.tsx
@@ -1,15 +1,16 @@
|
||||
import "@mantine/core/styles.css";
|
||||
import "@mantine/notifications/styles.css";
|
||||
import '@mantine/dates/styles.css'
|
||||
import { Notifications } from "@mantine/notifications";
|
||||
|
||||
import '@mantine/core/styles.css';
|
||||
import '@mantine/notifications/styles.css';
|
||||
import { Notifications } from '@mantine/notifications';
|
||||
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import AppRoutes from './AppRoutes';
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import AppRoutes from "./AppRoutes";
|
||||
|
||||
export function App() {
|
||||
return <MantineProvider defaultColorScheme='dark'>
|
||||
return (
|
||||
<MantineProvider defaultColorScheme="dark">
|
||||
<Notifications />
|
||||
<AppRoutes />
|
||||
</MantineProvider>;
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +1,104 @@
|
||||
|
||||
// ⚡ Auto-generated by generateRoutes.ts — DO NOT EDIT MANUALLY
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import Home from "./pages/Home";
|
||||
import NotFound from "./pages/NotFound";
|
||||
import Login from "./pages/Login";
|
||||
import ProtectedRoute from "./components/ProtectedRoute";
|
||||
import Dashboard from "./pages/dashboard/dashboard_page";
|
||||
import DashboardLayout from "./pages/dashboard/dashboard_layout";
|
||||
import ApiKeyPage from "./pages/dashboard/apikey/apikey_page";
|
||||
import CredentialPage from "./pages/dashboard/credential/credential_page";
|
||||
import DarmasabaLayout from "./pages/darmasaba/darmasaba_layout";
|
||||
import FormSuratKeteranganUsaha from "./pages/darmasaba/form_surat_keterangan_usaha";
|
||||
import FormSuratKeteranganTidakMampu from "./pages/darmasaba/form_surat_keterangan_tidak_mampu";
|
||||
import DarmasabaHome from "./pages/darmasaba/darmasaba_home";
|
||||
import FormKartuTandaPenduduk from "./pages/darmasaba/form_kartu_tanda_penduduk";
|
||||
import FormKartuKeluarga from "./pages/darmasaba/form_kartu_keluarga";
|
||||
import FormLaporanSampah from "./pages/darmasaba/form_laporan_sampah";
|
||||
import FormSuratKeteranganPenghasilan from "./pages/darmasaba/form_surat_keterangan_penghasilan";
|
||||
import FormSuratKeteranganDomisiliOrganisasi from "./pages/darmasaba/form_surat_keterangan_domisili_organisasi";
|
||||
import FormSuratKeteranganBelumKawin from "./pages/darmasaba/form_surat_keterangan_belum_kawin";
|
||||
import FormKeteranganKelahiran from "./pages/darmasaba/form_keterangan_kelahiran";
|
||||
import FormSuratKeteranganTempatUsaha from "./pages/darmasaba/form_surat_keterangan_tempat_usaha";
|
||||
import FormSuratKeteranganKelakuanBaik from "./pages/darmasaba/form_surat_keterangan_kelakuan_baik";
|
||||
import Home from "./pages/Home";
|
||||
import CredentialPage from "./pages/scr/dashboard/credential/credential_page";
|
||||
import DashboardHome from "./pages/scr/dashboard/dashboard_home";
|
||||
import ApikeyPage from "./pages/scr/dashboard/apikey/apikey_page";
|
||||
import DashboardLayout from "./pages/scr/dashboard/dashboard_layout";
|
||||
import ScrLayout from "./pages/scr/scr_layout";
|
||||
import NotFound from "./pages/NotFound";
|
||||
|
||||
export default function AppRoutes() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/dashboard" element={<DashboardLayout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="landing" element={<Dashboard />} />
|
||||
<Route path="apikey" element={<ApiKeyPage />} />
|
||||
<Route path="credential" element={<CredentialPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<NotFound />} />
|
||||
<Route path="/darmasaba" element={<DarmasabaLayout />}>
|
||||
<Route index element={<DarmasabaHome />} />
|
||||
|
||||
<Route
|
||||
path="/darmasaba/surat-keterangan-usaha"
|
||||
element={<FormSuratKeteranganUsaha />}
|
||||
/>
|
||||
<Route
|
||||
path="/darmasaba/surat-keterangan-tidak-mampu"
|
||||
element={<FormSuratKeteranganTidakMampu />}
|
||||
/>
|
||||
<Route path="/darmasaba/darmasaba-home" element={<DarmasabaHome />} />
|
||||
<Route
|
||||
path="/darmasaba/kartu-tanda-penduduk"
|
||||
element={<FormKartuTandaPenduduk />}
|
||||
/>
|
||||
<Route
|
||||
path="/darmasaba/kartu-keluarga"
|
||||
element={<FormKartuKeluarga />}
|
||||
/>
|
||||
<Route
|
||||
path="/darmasaba/laporan-sampah"
|
||||
element={<FormLaporanSampah />}
|
||||
/>
|
||||
<Route
|
||||
path="/darmasaba/surat-keterangan-penghasilan"
|
||||
element={<FormSuratKeteranganPenghasilan />}
|
||||
/>
|
||||
<Route
|
||||
path="/darmasaba/surat-keterangan-domisili-organisasi"
|
||||
element={<FormSuratKeteranganDomisiliOrganisasi />}
|
||||
/>
|
||||
<Route
|
||||
path="/darmasaba/surat-keterangan-belum-kawin"
|
||||
element={<FormSuratKeteranganBelumKawin />}
|
||||
/>
|
||||
<Route
|
||||
path="/darmasaba/keterangan-kelahiran"
|
||||
element={<FormKeteranganKelahiran />}
|
||||
/>
|
||||
<Route
|
||||
path="/darmasaba/surat-keterangan-tempat-usaha"
|
||||
element={<FormSuratKeteranganTempatUsaha />}
|
||||
/>
|
||||
<Route
|
||||
path="/darmasaba/surat-keterangan-kelakuan-baik"
|
||||
element={<FormSuratKeteranganKelakuanBaik />}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/" element={<Home />} />
|
||||
|
||||
<Route path="/scr" element={<ScrLayout />}>
|
||||
<Route path="/scr/dashboard" element={<DashboardLayout />}>
|
||||
<Route index element={<DashboardHome />} />
|
||||
|
||||
<Route
|
||||
path="/scr/dashboard/credential/credential"
|
||||
element={<CredentialPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/scr/dashboard/dashboard-home"
|
||||
element={<DashboardHome />}
|
||||
/>
|
||||
<Route
|
||||
path="/scr/dashboard/apikey/apikey"
|
||||
element={<ApikeyPage />}
|
||||
/>
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="/*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
34
src/AppRoutes.txt
Normal file
34
src/AppRoutes.txt
Normal file
@@ -0,0 +1,34 @@
|
||||
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import Home from "./pages/Home";
|
||||
import NotFound from "./pages/NotFound";
|
||||
import Login from "./pages/Login";
|
||||
import ProtectedRoute from "./components/ProtectedRoute";
|
||||
import Dashboard from "./pages/dashboard/dashboard_page";
|
||||
import DashboardLayout from "./pages/dashboard/dashboard_layout";
|
||||
import ApiKeyPage from "./pages/dashboard/apikey/apikey_page";
|
||||
import CredentialPage from "./pages/dashboard/credential/credential_page";
|
||||
|
||||
export default function AppRoutes() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/dashboard" element={<DashboardLayout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="landing" element={<Dashboard />} />
|
||||
<Route path="apikey" element={<ApiKeyPage />} />
|
||||
<Route path="credential" element={<CredentialPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
// AUTO-GENERATED FILE
|
||||
const clientRoutes = {
|
||||
"/": "/",
|
||||
"/login": "/login",
|
||||
"/dashboard": "/dashboard",
|
||||
"/dashboard/landing": "/dashboard/landing",
|
||||
"/dashboard/apikey": "/dashboard/apikey",
|
||||
"/dashboard/credential": "/dashboard/credential",
|
||||
"/darmasaba": "/darmasaba",
|
||||
"/darmasaba/surat-keterangan-usaha": "/darmasaba/surat-keterangan-usaha",
|
||||
"/darmasaba/surat-keterangan-tidak-mampu": "/darmasaba/surat-keterangan-tidak-mampu",
|
||||
"/darmasaba/darmasaba-home": "/darmasaba/darmasaba-home",
|
||||
"/darmasaba/kartu-tanda-penduduk": "/darmasaba/kartu-tanda-penduduk",
|
||||
"/darmasaba/kartu-keluarga": "/darmasaba/kartu-keluarga",
|
||||
"/darmasaba/laporan-sampah": "/darmasaba/laporan-sampah",
|
||||
"/darmasaba/surat-keterangan-penghasilan": "/darmasaba/surat-keterangan-penghasilan",
|
||||
"/darmasaba/surat-keterangan-domisili-organisasi": "/darmasaba/surat-keterangan-domisili-organisasi",
|
||||
"/darmasaba/surat-keterangan-belum-kawin": "/darmasaba/surat-keterangan-belum-kawin",
|
||||
"/darmasaba/keterangan-kelahiran": "/darmasaba/keterangan-kelahiran",
|
||||
"/darmasaba/surat-keterangan-tempat-usaha": "/darmasaba/surat-keterangan-tempat-usaha",
|
||||
"/darmasaba/surat-keterangan-kelakuan-baik": "/darmasaba/surat-keterangan-kelakuan-baik",
|
||||
"/": "/",
|
||||
"/scr": "/scr",
|
||||
"/scr/dashboard": "/scr/dashboard",
|
||||
"/scr/dashboard/credential/credential": "/scr/dashboard/credential/credential",
|
||||
"/scr/dashboard/dashboard-home": "/scr/dashboard/dashboard-home",
|
||||
"/scr/dashboard/apikey/apikey": "/scr/dashboard/apikey/apikey",
|
||||
"/*": "/*"
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Navigate, Outlet } from 'react-router-dom'
|
||||
import clientRoutes from '@/clientRoutes'
|
||||
import apiFetch from '@/lib/apiFetch'
|
||||
import { useEffect, useState } from "react";
|
||||
import { Navigate, Outlet } from "react-router-dom";
|
||||
import clientRoutes from "@/clientRoutes";
|
||||
import apiFetch from "@/lib/apiFetch";
|
||||
|
||||
export default function ProtectedRoute() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null)
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function checkSession() {
|
||||
try {
|
||||
// backend otomatis baca cookie JWT dari request
|
||||
const res = await apiFetch.api.user.find.get()
|
||||
setIsAuthenticated(res.status === 200)
|
||||
const res = await apiFetch.api.user.find.get();
|
||||
setIsAuthenticated(res.status === 200);
|
||||
} catch {
|
||||
setIsAuthenticated(false)
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
}
|
||||
checkSession()
|
||||
}, [])
|
||||
checkSession();
|
||||
}, []);
|
||||
|
||||
if (isAuthenticated === null) return null // or loading spinner
|
||||
if (!isAuthenticated) return <Navigate to={clientRoutes['/login']} replace />
|
||||
return <Outlet />
|
||||
if (isAuthenticated === null) return null; // or loading spinner
|
||||
if (!isAuthenticated) return <Navigate to={clientRoutes["/login"]} replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
|
||||
import Swagger from "@elysiajs/swagger";
|
||||
import Elysia from "elysia";
|
||||
import type { User } from "generated/prisma";
|
||||
import html from "./index.html";
|
||||
import apiAuth from "./server/middlewares/apiAuth";
|
||||
import ApiKeyRoute from "./server/routes/apikey_route";
|
||||
@@ -9,55 +7,49 @@ import Auth from "./server/routes/auth_route";
|
||||
import CredentialRoute from "./server/routes/credential_route";
|
||||
import DarmasabaRoute from "./server/routes/darmasaba_route";
|
||||
import { convertOpenApiToMcp } from "./server/lib/mcp-converter";
|
||||
import UserRoute from "./server/routes/user_route";
|
||||
import LayananRoute from "./server/routes/layanan_route";
|
||||
|
||||
const Docs = new Elysia()
|
||||
.use(Swagger({
|
||||
const Docs = new Elysia({
|
||||
tags: ["docs"],
|
||||
}).use(
|
||||
Swagger({
|
||||
path: "/docs",
|
||||
}))
|
||||
|
||||
const ApiUser = new Elysia({
|
||||
prefix: "/user",
|
||||
})
|
||||
.get('/find', (ctx) => {
|
||||
const { user } = ctx as any
|
||||
return {
|
||||
user: user as User
|
||||
}
|
||||
},{
|
||||
detail: {
|
||||
summary: "find",
|
||||
description: "find user",
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
const Api = new Elysia({
|
||||
prefix: "/api",
|
||||
tags: ["api"],
|
||||
})
|
||||
.use(apiAuth)
|
||||
.use(ApiKeyRoute)
|
||||
.use(DarmasabaRoute)
|
||||
.use(ApiUser)
|
||||
.use(CredentialRoute)
|
||||
.use(UserRoute)
|
||||
.use(LayananRoute);
|
||||
|
||||
const app = new Elysia()
|
||||
.use(Api)
|
||||
.use(Docs)
|
||||
.use(Auth)
|
||||
.get("/.well-known/mcp.json", async () => {
|
||||
const baseUrl = process.env.BUN_PUBLIC_BASE_URL!
|
||||
return await convertOpenApiToMcp(baseUrl)
|
||||
}, {
|
||||
.get(
|
||||
"/.well-known/mcp.json",
|
||||
async () => {
|
||||
const baseUrl = process.env.BUN_PUBLIC_BASE_URL!;
|
||||
return await convertOpenApiToMcp(baseUrl);
|
||||
},
|
||||
{
|
||||
detail: {
|
||||
description: "MCP manifest",
|
||||
tags: ["MCP"],
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
// .use(McpRoute)
|
||||
.get("*", html)
|
||||
.listen(3000, () => {
|
||||
console.log("Server running at http://localhost:3000");
|
||||
});
|
||||
|
||||
|
||||
export type ServerApp = typeof app;
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div>
|
||||
@@ -6,4 +5,3 @@ export default function Home() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,46 +1,63 @@
|
||||
import { Button, Container, Group, Stack, Text, TextInput } from "@mantine/core";
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import apiFetch from "../lib/apiFetch";
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setLoading(true)
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiFetch.auth.login.post({
|
||||
email,
|
||||
password,
|
||||
})
|
||||
});
|
||||
|
||||
if (response.data?.token) {
|
||||
localStorage.setItem('token', response.data.token)
|
||||
window.location.href = '/dashboard'
|
||||
return
|
||||
localStorage.setItem("token", response.data.token);
|
||||
window.location.href = "/dashboard";
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.error) {
|
||||
alert(JSON.stringify(response.error))
|
||||
alert(JSON.stringify(response.error));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Stack>
|
||||
<Text>Login</Text>
|
||||
<TextInput placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<TextInput placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
<TextInput
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Group justify="right">
|
||||
<Button onClick={handleSubmit} disabled={loading}>Login</Button>
|
||||
<Button onClick={handleSubmit} disabled={loading}>
|
||||
Login
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Container>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div>
|
||||
@@ -6,4 +5,3 @@ export default function NotFound() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
82
src/pages/darmasaba/darmasaba_home.tsx
Normal file
82
src/pages/darmasaba/darmasaba_home.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import clientRoutes from "@/clientRoutes";
|
||||
import { Button, Container, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export default function DarmasabaPage() {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<Container size={"md"} w={"100%"}>
|
||||
<Stack>
|
||||
<Text>Form Darmasaba</Text>
|
||||
<SimpleGrid
|
||||
cols={{
|
||||
base: 1,
|
||||
sm: 2,
|
||||
md: 3,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(clientRoutes["/darmasaba/kartu-keluarga"])}
|
||||
>
|
||||
Form KK
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(clientRoutes["/darmasaba/kartu-tanda-penduduk"])}
|
||||
>
|
||||
Form KTP
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(clientRoutes["/darmasaba/laporan-sampah"])}
|
||||
>
|
||||
Form Laporan Sampah
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(clientRoutes["/darmasaba/surat-keterangan-domisili-organisasi"])}
|
||||
>
|
||||
Form SKDO
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(clientRoutes["/darmasaba/surat-keterangan-penghasilan"])}
|
||||
>
|
||||
Form SKP
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(clientRoutes["/darmasaba/surat-keterangan-tidak-mampu"])}
|
||||
>
|
||||
Form SKTM
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(clientRoutes["/darmasaba/surat-keterangan-kelakuan-baik"])}
|
||||
>
|
||||
Form SKK
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(clientRoutes["/darmasaba/surat-keterangan-usaha"])}
|
||||
>
|
||||
Form SKU
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(clientRoutes["/darmasaba/surat-keterangan-tempat-usaha"])}
|
||||
>
|
||||
Form SKTU
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(clientRoutes["/darmasaba/surat-keterangan-belum-kawin"])}
|
||||
>
|
||||
Form Belum Kawin
|
||||
</Button>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
5
src/pages/darmasaba/darmasaba_layout.tsx
Normal file
5
src/pages/darmasaba/darmasaba_layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
export default function DarmasabaLayout() {
|
||||
return <Outlet />;
|
||||
}
|
||||
835
src/pages/darmasaba/form_kartu_keluarga.tsx
Normal file
835
src/pages/darmasaba/form_kartu_keluarga.tsx
Normal file
@@ -0,0 +1,835 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
Accordion,
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Divider,
|
||||
Grid,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { DatePicker } from "@mantine/dates";
|
||||
import { useForm } from "@mantine/form";
|
||||
import {
|
||||
IconCheck,
|
||||
IconInfoCircle,
|
||||
IconPlus,
|
||||
IconTrash,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
|
||||
// -----------------------------
|
||||
// Types derived from provided JSON schema
|
||||
// -----------------------------
|
||||
|
||||
type JenisPermohonan =
|
||||
| "Baru"
|
||||
| "Tambah Anggota Keluarga"
|
||||
| "Pengurangan Anggota Keluarga"
|
||||
| "Perubahan Data";
|
||||
|
||||
type JenisKelamin = "Laki-laki" | "Perempuan";
|
||||
|
||||
type Agama =
|
||||
| "Islam"
|
||||
| "Kristen"
|
||||
| "Katolik"
|
||||
| "Hindu"
|
||||
| "Buddha"
|
||||
| "Konghucu"
|
||||
| "Lainnya";
|
||||
|
||||
type StatusHubungan =
|
||||
| "Kepala Keluarga"
|
||||
| "Istri"
|
||||
| "Anak"
|
||||
| "Orang Tua"
|
||||
| "Famili Lain"
|
||||
| "Lainnya";
|
||||
|
||||
type StatusPerkawinan = "Belum Kawin" | "Kawin" | "Cerai Hidup" | "Cerai Mati";
|
||||
|
||||
type Kewarganegaraan = "WNI" | "WNA";
|
||||
|
||||
interface AnggotaKeluargaItem {
|
||||
no: number;
|
||||
namaLengkap: string;
|
||||
nik: string;
|
||||
jenisKelamin: JenisKelamin | "";
|
||||
tempatTanggalLahir: string;
|
||||
agama: Agama | "";
|
||||
pendidikan: string;
|
||||
pekerjaan: string;
|
||||
statusHubungan: StatusHubungan | "";
|
||||
statusPerkawinan: StatusPerkawinan | "";
|
||||
kewarganegaraan: Kewarganegaraan | "";
|
||||
noPasporKitas?: string;
|
||||
namaAyah?: string;
|
||||
namaIbu?: string;
|
||||
}
|
||||
|
||||
interface KepalaKeluarga {
|
||||
namaLengkap: string;
|
||||
nik: string;
|
||||
tempatTanggalLahir: string;
|
||||
alamat: string;
|
||||
rt: string;
|
||||
rw: string;
|
||||
desaKelurahan: string;
|
||||
kecamatan: string;
|
||||
kabupatenKota: string;
|
||||
kodePos: string;
|
||||
telepon: string;
|
||||
}
|
||||
|
||||
interface Pemohon {
|
||||
nama: string;
|
||||
tandaTangan: string;
|
||||
}
|
||||
|
||||
interface Pengesahan {
|
||||
kepalaDesaLurah: string;
|
||||
camat: string;
|
||||
petugasRegistrasi: string;
|
||||
}
|
||||
|
||||
interface KKFormValues {
|
||||
jenisPermohonan: JenisPermohonan | "";
|
||||
kepalaKeluarga: KepalaKeluarga;
|
||||
anggotaKeluarga: AnggotaKeluargaItem[];
|
||||
pernyataanPemohon: string;
|
||||
tanggalPengajuan: Date | null;
|
||||
pemohon: Pemohon;
|
||||
pengesahan: Pengesahan;
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Reusable small components
|
||||
// -----------------------------
|
||||
|
||||
function FieldLabel({
|
||||
label,
|
||||
description,
|
||||
}: {
|
||||
label: React.ReactNode;
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<Group justify="apart" style={{ width: "100%" }}>
|
||||
<Group gap={6}>
|
||||
<Text size="sm" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
{description && (
|
||||
<Tooltip label={description} withArrow>
|
||||
<ActionIcon size="xs" variant="transparent">
|
||||
<IconInfoCircle size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
// Render a form field based on a simple schema mapping. This keeps the main component tidy.
|
||||
function FormField(props: {
|
||||
children?: React.ReactNode;
|
||||
label: string;
|
||||
description?: string;
|
||||
error?: string | null;
|
||||
}) {
|
||||
const { children, label, description, error } = props;
|
||||
return (
|
||||
<Stack gap={6} style={{ width: "100%" }}>
|
||||
<FieldLabel label={label} description={description} />
|
||||
{children}
|
||||
{error ? (
|
||||
<Text size="xs" color="red" aria-live="polite">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Main Dynamic KK Form component
|
||||
// -----------------------------
|
||||
|
||||
export default function DynamicKKForm() {
|
||||
// Form initialization with sensible defaults — helps user with example values and keyboard navigation.
|
||||
const form = useForm<KKFormValues>({
|
||||
initialValues: {
|
||||
jenisPermohonan: "",
|
||||
kepalaKeluarga: {
|
||||
namaLengkap: "",
|
||||
nik: "",
|
||||
tempatTanggalLahir: "",
|
||||
alamat: "",
|
||||
rt: "",
|
||||
rw: "",
|
||||
desaKelurahan: "",
|
||||
kecamatan: "",
|
||||
kabupatenKota: "",
|
||||
kodePos: "",
|
||||
telepon: "",
|
||||
},
|
||||
anggotaKeluarga: [
|
||||
{
|
||||
no: 1,
|
||||
namaLengkap: "",
|
||||
nik: "",
|
||||
jenisKelamin: "",
|
||||
tempatTanggalLahir: "",
|
||||
agama: "",
|
||||
pendidikan: "",
|
||||
pekerjaan: "",
|
||||
statusHubungan: "",
|
||||
statusPerkawinan: "",
|
||||
kewarganegaraan: "",
|
||||
noPasporKitas: "",
|
||||
namaAyah: "",
|
||||
namaIbu: "",
|
||||
},
|
||||
],
|
||||
pernyataanPemohon: "",
|
||||
tanggalPengajuan: new Date(),
|
||||
pemohon: { nama: "", tandaTangan: "" },
|
||||
pengesahan: { kepalaDesaLurah: "", camat: "", petugasRegistrasi: "" },
|
||||
},
|
||||
|
||||
validate: {
|
||||
// Simple validation rules matching schema descriptions.
|
||||
jenisPermohonan: (value) => (value ? null : "Pilih jenis permohonan"),
|
||||
kepalaKeluarga: {
|
||||
namaLengkap: (v) =>
|
||||
v && v.length > 1 ? null : "Nama lengkap wajib diisi",
|
||||
nik: (v) => (/^\d{16}$/.test(v) ? null : "NIK harus 16 digit angka"),
|
||||
telepon: (v) =>
|
||||
v && v.length >= 7 ? null : "Masukkan nomor telepon/HP yang valid",
|
||||
},
|
||||
pemohon: {
|
||||
nama: (v) => (v ? null : "Nama pemohon harus diisi"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Helper: add a new anggota with next sequential no
|
||||
function addAnggota() {
|
||||
const nextNo = form.values.anggotaKeluarga.length + 1;
|
||||
form.setFieldValue("anggotaKeluarga", [
|
||||
...form.values.anggotaKeluarga,
|
||||
{
|
||||
no: nextNo,
|
||||
namaLengkap: "",
|
||||
nik: "",
|
||||
jenisKelamin: "",
|
||||
tempatTanggalLahir: "",
|
||||
agama: "",
|
||||
pendidikan: "",
|
||||
pekerjaan: "",
|
||||
statusHubungan: "",
|
||||
statusPerkawinan: "",
|
||||
kewarganegaraan: "",
|
||||
noPasporKitas: "",
|
||||
namaAyah: "",
|
||||
namaIbu: "",
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
// Remove anggota by index
|
||||
function removeAnggota(index: number) {
|
||||
const list = [...form.values.anggotaKeluarga];
|
||||
list.splice(index, 1);
|
||||
// re-number
|
||||
const renumbered = list.map((a, i) => ({ ...a, no: i + 1 }));
|
||||
form.setFieldValue("anggotaKeluarga", renumbered);
|
||||
}
|
||||
|
||||
// Submit handler — in production you'd call an API. Here we show console and a modal.
|
||||
const [submitted, setSubmitted] = useState<any>(null);
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
function handleSubmit(values: KKFormValues) {
|
||||
// sanitize & prepare payload
|
||||
const payload = {
|
||||
...values,
|
||||
tanggalPengajuan:
|
||||
values.tanggalPengajuan?.toISOString().slice(0, 10) ?? null,
|
||||
};
|
||||
|
||||
console.log("KK form submitted:", payload);
|
||||
setSubmitted(payload);
|
||||
setOpened(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="md" w="100%">
|
||||
<Card shadow="md" radius="md" p="lg">
|
||||
<Group justify="apart" align="flex-start">
|
||||
<Group>
|
||||
<Avatar color="blue" radius="xl">
|
||||
KK
|
||||
</Avatar>
|
||||
<div>
|
||||
<Title order={3}>Formulir Permohonan Kartu Keluarga (KK)</Title>
|
||||
<Text size="sm" color="dimmed">
|
||||
Blangko resmi untuk pengajuan Kartu Keluarga — pembuatan,
|
||||
perubahan, atau penambahan/ pengurangan anggota keluarga.
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Divider my="sm" />
|
||||
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="lg">
|
||||
{/* Jenis Permohonan */}
|
||||
<FormField
|
||||
label="Jenis Permohonan"
|
||||
description="Jenis permohonan pembuatan atau perubahan KK."
|
||||
error={form.errors.jenisPermohonan as any}
|
||||
>
|
||||
<Select
|
||||
data={[
|
||||
"Baru",
|
||||
"Tambah Anggota Keluarga",
|
||||
"Pengurangan Anggota Keluarga",
|
||||
"Perubahan Data",
|
||||
]}
|
||||
placeholder="Pilih jenis permohonan"
|
||||
{...form.getInputProps("jenisPermohonan")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Kepala Keluarga Section */}
|
||||
<Accordion
|
||||
variant="separated"
|
||||
defaultValue="kepala"
|
||||
chevronPosition="left"
|
||||
>
|
||||
<Accordion.Item value="kepala">
|
||||
<Accordion.Control>
|
||||
<Group justify="apart" style={{ width: "100%" }}>
|
||||
<Group>
|
||||
<Text fw={700}>Kepala Keluarga</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Data kepala keluarga sesuai KTP
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
|
||||
<Accordion.Panel>
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
label="Nama Lengkap"
|
||||
description="Nama lengkap kepala keluarga sesuai KTP."
|
||||
error={
|
||||
(form.errors.kepalaKeluarga as any)
|
||||
?.namaLengkap as any
|
||||
}
|
||||
>
|
||||
<TextInput
|
||||
placeholder="Contoh: Budi Santoso"
|
||||
{...form.getInputProps("kepalaKeluarga.namaLengkap")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
label="NIK"
|
||||
description="Nomor Induk Kependudukan (16 digit)."
|
||||
error={(form.errors.kepalaKeluarga as any)?.nik as any}
|
||||
>
|
||||
<TextInput
|
||||
placeholder="16 digit NIK"
|
||||
{...form.getInputProps("kepalaKeluarga.nik")}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
label="Tempat & Tanggal Lahir"
|
||||
description="Contoh: Denpasar, 1990-01-01"
|
||||
>
|
||||
<TextInput
|
||||
placeholder="Tempat, yyyy-mm-dd"
|
||||
{...form.getInputProps(
|
||||
"kepalaKeluarga.tempatTanggalLahir",
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
label="Telepon"
|
||||
description="Nomor HP yang bisa dihubungi"
|
||||
error={
|
||||
(form.errors.kepalaKeluarga as any)?.telepon as any
|
||||
}
|
||||
>
|
||||
<TextInput
|
||||
placeholder="08xx-xxxx-xxxx"
|
||||
{...form.getInputProps("kepalaKeluarga.telepon")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={12}>
|
||||
<FormField
|
||||
label="Alamat Lengkap"
|
||||
description="Sesuai domisili"
|
||||
>
|
||||
<Textarea
|
||||
placeholder="Alamat lengkap"
|
||||
minRows={2}
|
||||
{...form.getInputProps("kepalaKeluarga.alamat")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={3}>
|
||||
<FormField label="RT" description="Nomor RT">
|
||||
<TextInput
|
||||
placeholder="001"
|
||||
{...form.getInputProps("kepalaKeluarga.rt")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={3}>
|
||||
<FormField label="RW" description="Nomor RW">
|
||||
<TextInput
|
||||
placeholder="002"
|
||||
{...form.getInputProps("kepalaKeluarga.rw")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={3}>
|
||||
<FormField
|
||||
label="Kode Pos"
|
||||
description="Kode pos wilayah"
|
||||
>
|
||||
<TextInput
|
||||
placeholder="80361"
|
||||
{...form.getInputProps("kepalaKeluarga.kodePos")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={3}>
|
||||
<FormField
|
||||
label="Desa / Kelurahan"
|
||||
description="Nama desa atau kelurahan"
|
||||
>
|
||||
<TextInput
|
||||
placeholder="Contoh: Kuta"
|
||||
{...form.getInputProps(
|
||||
"kepalaKeluarga.desaKelurahan",
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<FormField label="Kecamatan" description="Nama kecamatan">
|
||||
<TextInput
|
||||
placeholder="Contoh: Kuta"
|
||||
{...form.getInputProps("kepalaKeluarga.kecamatan")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
label="Kabupaten / Kota"
|
||||
description="Nama kabupaten atau kota"
|
||||
>
|
||||
<TextInput
|
||||
placeholder="Contoh: Badung"
|
||||
{...form.getInputProps(
|
||||
"kepalaKeluarga.kabupatenKota",
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
|
||||
{/* Anggota Keluarga (array) */}
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Group justify="apart" mb="sm">
|
||||
<Group>
|
||||
<Text fw={700}>Anggota Keluarga</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Daftar anggota keluarga dalam KK
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
<Button
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={addAnggota}
|
||||
aria-label="Tambah anggota"
|
||||
>
|
||||
Tambah Anggota
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Stack gap="sm">
|
||||
{form.values.anggotaKeluarga.map((anggota, idx) => (
|
||||
<Paper key={idx} withBorder radius="md" p="md">
|
||||
<Grid align="center">
|
||||
<Grid.Col span={12}>
|
||||
<Group justify="apart">
|
||||
<Group>
|
||||
<Badge>{`#${anggota.no}`}</Badge>
|
||||
<Text fw={600} size="sm">
|
||||
{anggota.namaLengkap || "(Belum diisi)"}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
<ActionIcon
|
||||
color="red"
|
||||
onClick={() => removeAnggota(idx)}
|
||||
aria-label={`Hapus anggota ${idx + 1}`}
|
||||
>
|
||||
<IconTrash />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<FormField
|
||||
label="Nama Lengkap"
|
||||
description="Nama lengkap anggota keluarga"
|
||||
>
|
||||
<TextInput
|
||||
placeholder="Contoh: Siti"
|
||||
{...form.getInputProps(
|
||||
`anggotaKeluarga.${idx}.namaLengkap`,
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<FormField label="NIK" description="16 digit NIK">
|
||||
<TextInput
|
||||
placeholder="NIK"
|
||||
{...form.getInputProps(
|
||||
`anggotaKeluarga.${idx}.nik`,
|
||||
)}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<FormField label="Jenis Kelamin">
|
||||
<Select
|
||||
data={["Laki-laki", "Perempuan"]}
|
||||
placeholder="Pilih"
|
||||
{...form.getInputProps(
|
||||
`anggotaKeluarga.${idx}.jenisKelamin`,
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<FormField label="Tempat & Tanggal Lahir">
|
||||
<TextInput
|
||||
placeholder="Contoh: Denpasar, 1995-03-12"
|
||||
{...form.getInputProps(
|
||||
`anggotaKeluarga.${idx}.tempatTanggalLahir`,
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<FormField label="Agama">
|
||||
<Select
|
||||
data={[
|
||||
"Islam",
|
||||
"Kristen",
|
||||
"Katolik",
|
||||
"Hindu",
|
||||
"Buddha",
|
||||
"Konghucu",
|
||||
"Lainnya",
|
||||
]}
|
||||
placeholder="Pilih"
|
||||
{...form.getInputProps(
|
||||
`anggotaKeluarga.${idx}.agama`,
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<FormField label="Pendidikan">
|
||||
<TextInput
|
||||
placeholder="Pendidikan terakhir"
|
||||
{...form.getInputProps(
|
||||
`anggotaKeluarga.${idx}.pendidikan`,
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<FormField label="Pekerjaan">
|
||||
<TextInput
|
||||
placeholder="Pekerjaan"
|
||||
{...form.getInputProps(
|
||||
`anggotaKeluarga.${idx}.pekerjaan`,
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<FormField label="Status Hubungan">
|
||||
<Select
|
||||
data={[
|
||||
"Kepala Keluarga",
|
||||
"Istri",
|
||||
"Anak",
|
||||
"Orang Tua",
|
||||
"Famili Lain",
|
||||
"Lainnya",
|
||||
]}
|
||||
placeholder="Pilih"
|
||||
{...form.getInputProps(
|
||||
`anggotaKeluarga.${idx}.statusHubungan`,
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<FormField label="Status Perkawinan">
|
||||
<Select
|
||||
data={[
|
||||
"Belum Kawin",
|
||||
"Kawin",
|
||||
"Cerai Hidup",
|
||||
"Cerai Mati",
|
||||
]}
|
||||
placeholder="Pilih"
|
||||
{...form.getInputProps(
|
||||
`anggotaKeluarga.${idx}.statusPerkawinan`,
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<FormField label="Kewarganegaraan">
|
||||
<Select
|
||||
data={["WNI", "WNA"]}
|
||||
placeholder="Pilih"
|
||||
{...form.getInputProps(
|
||||
`anggotaKeluarga.${idx}.kewarganegaraan`,
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<FormField label="No Paspor / KITAS">
|
||||
<TextInput
|
||||
placeholder="Jika ada"
|
||||
{...form.getInputProps(
|
||||
`anggotaKeluarga.${idx}.noPasporKitas`,
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<FormField label="Nama Ayah">
|
||||
<TextInput
|
||||
placeholder="Nama ayah"
|
||||
{...form.getInputProps(
|
||||
`anggotaKeluarga.${idx}.namaAyah`,
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<FormField label="Nama Ibu">
|
||||
<TextInput
|
||||
placeholder="Nama ibu"
|
||||
{...form.getInputProps(
|
||||
`anggotaKeluarga.${idx}.namaIbu`,
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Pernyataan Pemohon, Tanggal, Pemohon, Pengesahan */}
|
||||
<Grid>
|
||||
<Grid.Col span={12}>
|
||||
<FormField
|
||||
label="Pernyataan Pemohon"
|
||||
description="Pernyataan kebenaran data oleh pemohon."
|
||||
>
|
||||
<Textarea
|
||||
placeholder="Saya menyatakan bahwa data yang saya berikan adalah benar..."
|
||||
minRows={3}
|
||||
{...form.getInputProps("pernyataanPemohon")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<FormField
|
||||
label="Tanggal Pengajuan"
|
||||
description="Tanggal pengajuan formulir"
|
||||
>
|
||||
<DatePicker {...form.getInputProps("tanggalPengajuan")} />
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={8}>
|
||||
<Card withBorder radius="md" p="sm">
|
||||
<Text fw={700} size="sm" mb="xs">
|
||||
Data Pemohon
|
||||
</Text>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={8}>
|
||||
<FormField label="Nama Pemohon">
|
||||
<TextInput
|
||||
placeholder="Nama lengkap"
|
||||
{...form.getInputProps("pemohon.nama")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<FormField
|
||||
label="Tanda Tangan (scan)"
|
||||
description="Unggah file scan tanda tangan jika ada"
|
||||
>
|
||||
<TextInput
|
||||
placeholder="Nama file / URL"
|
||||
{...form.getInputProps("pemohon.tandaTangan")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={12}>
|
||||
<Card withBorder radius="md" p="sm">
|
||||
<Text fw={700} size="sm" mb="xs">
|
||||
Pengesahan
|
||||
</Text>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={4}>
|
||||
<FormField label="Kepala Desa / Lurah">
|
||||
<TextInput
|
||||
placeholder="Nama"
|
||||
{...form.getInputProps("pengesahan.kepalaDesaLurah")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<FormField label="Camat">
|
||||
<TextInput
|
||||
placeholder="Nama"
|
||||
{...form.getInputProps("pengesahan.camat")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<FormField label="Petugas Registrasi">
|
||||
<TextInput
|
||||
placeholder="Nama"
|
||||
{...form.getInputProps(
|
||||
"pengesahan.petugasRegistrasi",
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{/* Submit / Reset actions */}
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => form.reset()}
|
||||
leftSection={<IconX />}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button type="submit" leftSection={<IconCheck />}>
|
||||
Kirim Permohonan
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
title="Preview Payload"
|
||||
size="lg"
|
||||
>
|
||||
<ScrollArea style={{ height: 400 }}>
|
||||
<pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
||||
{JSON.stringify(submitted, null, 2)}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
641
src/pages/darmasaba/form_kartu_tanda_penduduk.tsx
Normal file
641
src/pages/darmasaba/form_kartu_tanda_penduduk.tsx
Normal file
@@ -0,0 +1,641 @@
|
||||
import {
|
||||
Accordion,
|
||||
ActionIcon,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Divider,
|
||||
FileButton,
|
||||
Grid,
|
||||
Group,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { DatePicker } from "@mantine/dates";
|
||||
import { useForm } from "@mantine/form";
|
||||
import {
|
||||
IconBuildingBank,
|
||||
IconCalendar,
|
||||
IconCheck,
|
||||
IconId,
|
||||
IconInfoCircle,
|
||||
IconUpload,
|
||||
IconUser,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import React, { useState } from "react";
|
||||
|
||||
// ---------------------------
|
||||
// Types - strong typing for schema-driven form
|
||||
// ---------------------------
|
||||
|
||||
type EnumField = {
|
||||
type: "enum";
|
||||
options: string[];
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type PrimitiveField = {
|
||||
type: "string" | "number" | "boolean";
|
||||
format?: string; // e.g. date
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type ObjectField = {
|
||||
[key: string]: PrimitiveField | EnumField;
|
||||
};
|
||||
|
||||
type KTPSchema = {
|
||||
formTitle: string;
|
||||
description?: string;
|
||||
jenisPermohonan: EnumField;
|
||||
dataPemohon: ObjectField;
|
||||
pernyataanPemohon: PrimitiveField;
|
||||
tanggalPengajuan: PrimitiveField & { format?: string };
|
||||
pengesahan: ObjectField;
|
||||
};
|
||||
|
||||
// ---------------------------
|
||||
// Helper: convert file to base64 (used for foto/tandaTangan/sidikJari)
|
||||
// ---------------------------
|
||||
async function fileToBase64(file: File | null): Promise<string | null> {
|
||||
if (!file) return null;
|
||||
return await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = (err) => reject(err);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// Reusable small components
|
||||
// ---------------------------
|
||||
|
||||
function FieldLabel({
|
||||
label,
|
||||
description,
|
||||
}: {
|
||||
label: React.ReactNode;
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<Group justify="apart" style={{ width: "100%" }}>
|
||||
<Group gap={6}>
|
||||
<Text size="sm" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
{description && (
|
||||
<ActionIcon size={18} variant="subtle" aria-hidden>
|
||||
<IconInfoCircle size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
{description && (
|
||||
<Text size="sm" c="dimmed" mt={4} style={{ lineHeight: 1.2 }}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// Main Form Component
|
||||
// ---------------------------
|
||||
|
||||
const schema: KTPSchema = {
|
||||
formTitle: "Formulir Permohonan Kartu Tanda Penduduk (KTP)",
|
||||
description:
|
||||
"Blangko resmi untuk pengajuan KTP elektronik (e-KTP). Digunakan untuk pembuatan KTP baru, penggantian karena hilang/rusak, atau perubahan data.",
|
||||
jenisPermohonan: {
|
||||
type: "enum",
|
||||
options: [
|
||||
"Baru",
|
||||
"Perpanjangan",
|
||||
"Penggantian Hilang",
|
||||
"Penggantian Rusak",
|
||||
"Perubahan Data",
|
||||
],
|
||||
description: "Jenis permohonan pembuatan atau perubahan KTP.",
|
||||
},
|
||||
dataPemohon: {
|
||||
namaLengkap: {
|
||||
type: "string",
|
||||
description: "Nama lengkap sesuai akta kelahiran.",
|
||||
},
|
||||
nik: {
|
||||
type: "string",
|
||||
description: "Nomor Induk Kependudukan (16 digit).",
|
||||
},
|
||||
jenisKelamin: {
|
||||
type: "enum",
|
||||
options: ["Laki-laki", "Perempuan"],
|
||||
description: "Jenis kelamin pemohon.",
|
||||
} as any,
|
||||
tempatTanggalLahir: {
|
||||
type: "string",
|
||||
description: "Tempat dan tanggal lahir pemohon.",
|
||||
},
|
||||
golonganDarah: {
|
||||
type: "enum",
|
||||
options: ["A", "B", "AB", "O", "Tidak Tahu"],
|
||||
description: "Golongan darah pemohon.",
|
||||
} as any,
|
||||
alamat: { type: "string", description: "Alamat lengkap domisili." },
|
||||
rt: { type: "string", description: "Nomor RT." },
|
||||
rw: { type: "string", description: "Nomor RW." },
|
||||
desaKelurahan: {
|
||||
type: "string",
|
||||
description: "Nama desa atau kelurahan tempat tinggal.",
|
||||
},
|
||||
kecamatan: {
|
||||
type: "string",
|
||||
description: "Nama kecamatan tempat tinggal.",
|
||||
},
|
||||
kabupatenKota: { type: "string", description: "Nama kabupaten atau kota." },
|
||||
agama: {
|
||||
type: "enum",
|
||||
options: [
|
||||
"Islam",
|
||||
"Kristen",
|
||||
"Katolik",
|
||||
"Hindu",
|
||||
"Buddha",
|
||||
"Konghucu",
|
||||
"Lainnya",
|
||||
],
|
||||
description: "Agama pemohon.",
|
||||
} as any,
|
||||
statusPerkawinan: {
|
||||
type: "enum",
|
||||
options: ["Belum Kawin", "Kawin", "Cerai Hidup", "Cerai Mati"],
|
||||
description: "Status perkawinan pemohon.",
|
||||
} as any,
|
||||
pekerjaan: { type: "string", description: "Jenis pekerjaan pemohon." },
|
||||
kewarganegaraan: {
|
||||
type: "enum",
|
||||
options: ["WNI", "WNA"],
|
||||
description: "Kewarganegaraan pemohon.",
|
||||
} as any,
|
||||
foto: {
|
||||
type: "string",
|
||||
description: "File foto pemohon ukuran 4x6 (upload path/base64).",
|
||||
},
|
||||
tandaTangan: {
|
||||
type: "string",
|
||||
description: "Tanda tangan digital pemohon (upload path/base64).",
|
||||
},
|
||||
sidikJari: {
|
||||
type: "string",
|
||||
description: "Hasil rekam sidik jari pemohon (scan/file).",
|
||||
},
|
||||
},
|
||||
pernyataanPemohon: {
|
||||
type: "string",
|
||||
description: "Pernyataan bahwa data yang diberikan benar dan sah.",
|
||||
},
|
||||
tanggalPengajuan: {
|
||||
type: "string",
|
||||
format: "date",
|
||||
description: "Tanggal pengajuan formulir.",
|
||||
},
|
||||
pengesahan: {
|
||||
petugasRegistrasi: {
|
||||
type: "string",
|
||||
description: "Nama petugas registrasi kependudukan yang memproses.",
|
||||
},
|
||||
kepalaDinas: {
|
||||
type: "string",
|
||||
description:
|
||||
"Nama Kepala Dinas Kependudukan dan Catatan Sipil yang mengesahkan.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default function FormKartuTandaPenduduk() {
|
||||
// Initial values - sensible defaults / smart placeholders
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
jenisPermohonan: schema.jenisPermohonan.options[0],
|
||||
// dataPemohon
|
||||
namaLengkap: "",
|
||||
nik: "",
|
||||
jenisKelamin: "",
|
||||
tempatTanggalLahir: "",
|
||||
golonganDarah: "",
|
||||
alamat: "",
|
||||
rt: "",
|
||||
rw: "",
|
||||
desaKelurahan: "",
|
||||
kecamatan: "",
|
||||
kabupatenKota: "",
|
||||
agama: "",
|
||||
statusPerkawinan: "",
|
||||
pekerjaan: "",
|
||||
kewarganegaraan: "WNI",
|
||||
foto: null as string | null,
|
||||
tandaTangan: null as string | null,
|
||||
sidikJari: null as string | null,
|
||||
pernyataanPemohon:
|
||||
"Saya menyatakan data yang saya berikan adalah benar dan sah.",
|
||||
tanggalPengajuan: null as Date | null,
|
||||
petugasRegistrasi: "",
|
||||
kepalaDinas: "",
|
||||
},
|
||||
|
||||
validate: {
|
||||
namaLengkap: (value) => (!value ? "Nama lengkap harus diisi" : null),
|
||||
nik: (value) => {
|
||||
if (!value) return "NIK harus diisi";
|
||||
const digits = value.replace(/\D/g, "");
|
||||
if (digits.length !== 16) return "NIK harus 16 digit";
|
||||
return null;
|
||||
},
|
||||
jenisKelamin: (v) => (!v ? "Pilih jenis kelamin" : null),
|
||||
alamat: (v) => (!v ? "Alamat harus diisi" : null),
|
||||
pernyataanPemohon: (v) =>
|
||||
!v || v.length < 10
|
||||
? "Pernyataan harus diisi minimal 10 karakter"
|
||||
: null,
|
||||
tanggalPengajuan: (v) => (!v ? "Pilih tanggal pengajuan" : null),
|
||||
},
|
||||
});
|
||||
|
||||
// local UI state for file upload previews
|
||||
const [fotoName, setFotoName] = useState<string | null>(null);
|
||||
const [ttdName, setTtdName] = useState<string | null>(null);
|
||||
const [sidikName, setSidikName] = useState<string | null>(null);
|
||||
|
||||
// submit handler - in real app this would call an API
|
||||
const handleSubmit = async (values: typeof form.values) => {
|
||||
// For demo: convert any stored File objects into base64 is handled at selection time.
|
||||
// Compose payload
|
||||
const payload = {
|
||||
...values,
|
||||
tanggalPengajuan: values.tanggalPengajuan
|
||||
? values.tanggalPengajuan.toISOString().slice(0, 10)
|
||||
: null,
|
||||
};
|
||||
|
||||
// Here you'd normally POST to server
|
||||
// We'll just console.log and show success
|
||||
console.log("Submitting KTP form:", payload);
|
||||
alert("Form submitted — cek console (development).\nNIK: " + values.nik);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="md" w={"100%"}>
|
||||
<Card shadow="sm" radius="md" p="xl">
|
||||
<Stack gap="md">
|
||||
<Group justify="apart">
|
||||
<Group>
|
||||
<IconBuildingBank size={28} />
|
||||
<div>
|
||||
<Text fw={700} size="lg">
|
||||
{schema.formTitle}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{schema.description}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<form
|
||||
onSubmit={form.onSubmit(async (values) => {
|
||||
await handleSubmit(values);
|
||||
form.reset();
|
||||
})}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
{/* Jenis Permohonan */}
|
||||
<Card withBorder p="md">
|
||||
<FieldLabel
|
||||
label={<span>Jenis Permohonan</span>}
|
||||
description={schema.jenisPermohonan.description}
|
||||
/>
|
||||
<Select
|
||||
mt="sm"
|
||||
data={schema.jenisPermohonan.options}
|
||||
placeholder="Pilih jenis permohonan"
|
||||
{...form.getInputProps("jenisPermohonan")}
|
||||
leftSection={<IconId size={16} />}
|
||||
aria-label="jenis permohonan"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Data Pemohon - collapsible */}
|
||||
<Accordion variant="separated" defaultValue="dataPemohon">
|
||||
<Accordion.Item value="dataPemohon">
|
||||
<Accordion.Control icon={<IconUser size={16} />}>
|
||||
Data Pemohon
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="Nama Lengkap"
|
||||
placeholder="Contoh: Budi Santoso"
|
||||
description={
|
||||
schema.dataPemohon?.namaLengkap?.description
|
||||
}
|
||||
{...form.getInputProps("namaLengkap")}
|
||||
required
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="NIK"
|
||||
placeholder="16 digit NIK"
|
||||
description={schema.dataPemohon?.nik?.description}
|
||||
{...form.getInputProps("nik")}
|
||||
required
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Select
|
||||
label="Jenis Kelamin"
|
||||
placeholder="Pilih..."
|
||||
data={["Laki-laki", "Perempuan"]}
|
||||
description={
|
||||
schema.dataPemohon?.jenisKelamin?.description
|
||||
}
|
||||
{...form.getInputProps("jenisKelamin")}
|
||||
required
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="Tempat, Tanggal Lahir"
|
||||
placeholder="Contoh: Denpasar, 01 Januari 1990"
|
||||
description={
|
||||
schema.dataPemohon?.tempatTanggalLahir?.description
|
||||
}
|
||||
{...form.getInputProps("tempatTanggalLahir")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Select
|
||||
label="Golongan Darah"
|
||||
placeholder="Pilih..."
|
||||
data={["A", "B", "AB", "O", "Tidak Tahu"]}
|
||||
description={
|
||||
schema.dataPemohon?.golonganDarah?.description
|
||||
}
|
||||
{...form.getInputProps("golonganDarah")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={12}>
|
||||
<Textarea
|
||||
label="Alamat"
|
||||
placeholder="Alamat lengkap domisili"
|
||||
description={schema.dataPemohon?.alamat?.description}
|
||||
autosize
|
||||
minRows={2}
|
||||
{...form.getInputProps("alamat")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={3}>
|
||||
<TextInput label="RT" {...form.getInputProps("rt")} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={3}>
|
||||
<TextInput label="RW" {...form.getInputProps("rw")} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="Desa / Kelurahan"
|
||||
{...form.getInputProps("desaKelurahan")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="Kecamatan"
|
||||
{...form.getInputProps("kecamatan")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="Kabupaten / Kota"
|
||||
{...form.getInputProps("kabupatenKota")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Select
|
||||
label="Agama"
|
||||
data={[
|
||||
"Islam",
|
||||
"Kristen",
|
||||
"Katolik",
|
||||
"Hindu",
|
||||
"Buddha",
|
||||
"Konghucu",
|
||||
"Lainnya",
|
||||
]}
|
||||
{...form.getInputProps("agama")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Select
|
||||
label="Status Perkawinan"
|
||||
data={[
|
||||
"Belum Kawin",
|
||||
"Kawin",
|
||||
"Cerai Hidup",
|
||||
"Cerai Mati",
|
||||
]}
|
||||
{...form.getInputProps("statusPerkawinan")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="Pekerjaan"
|
||||
{...form.getInputProps("pekerjaan")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Select
|
||||
label="Kewarganegaraan"
|
||||
data={["WNI", "WNA"]}
|
||||
{...form.getInputProps("kewarganegaraan")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
{/* Uploads: foto, tanda tangan, sidik jari */}
|
||||
<Grid.Col span={4}>
|
||||
<FieldLabel
|
||||
label={<span>Foto (4x6)</span>}
|
||||
description={schema.dataPemohon?.foto?.description}
|
||||
/>
|
||||
<FileButton
|
||||
onChange={async (file) => {
|
||||
if (!file) return;
|
||||
const base64 = await fileToBase64(file);
|
||||
form.setFieldValue("foto", base64);
|
||||
setFotoName(file.name);
|
||||
}}
|
||||
accept="image/*"
|
||||
>
|
||||
{(props) => (
|
||||
<Button
|
||||
leftSection={<IconUpload size={16} />}
|
||||
{...props}
|
||||
mt="sm"
|
||||
>
|
||||
{fotoName || "Upload Foto"}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<FieldLabel
|
||||
label={<span>Tanda Tangan</span>}
|
||||
description={
|
||||
schema.dataPemohon?.tandaTangan?.description
|
||||
}
|
||||
/>
|
||||
<FileButton
|
||||
onChange={async (file) => {
|
||||
if (!file) return;
|
||||
const base64 = await fileToBase64(file);
|
||||
form.setFieldValue("tandaTangan", base64);
|
||||
setTtdName(file.name);
|
||||
}}
|
||||
accept="image/*"
|
||||
>
|
||||
{(props) => (
|
||||
<Button
|
||||
leftSection={<IconUpload size={16} />}
|
||||
{...props}
|
||||
mt="sm"
|
||||
>
|
||||
{ttdName || "Upload TTD"}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<FieldLabel
|
||||
label={<span>Sidik Jari</span>}
|
||||
description={
|
||||
schema.dataPemohon?.sidikJari?.description
|
||||
}
|
||||
/>
|
||||
<FileButton
|
||||
onChange={async (file) => {
|
||||
if (!file) return;
|
||||
const base64 = await fileToBase64(file);
|
||||
form.setFieldValue("sidikJari", base64);
|
||||
setSidikName(file.name);
|
||||
}}
|
||||
accept="image/*,application/pdf"
|
||||
>
|
||||
{(props) => (
|
||||
<Button
|
||||
leftSection={<IconUpload size={16} />}
|
||||
{...props}
|
||||
mt="sm"
|
||||
>
|
||||
{sidikName || "Upload Sidik Jari"}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
{/* Pernyataan Pemohon */}
|
||||
<Accordion.Item value="pernyataanPemohon">
|
||||
<Accordion.Control icon={<IconInfoCircle size={16} />}>
|
||||
Pernyataan Pemohon
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Textarea
|
||||
label="Pernyataan"
|
||||
autosize
|
||||
minRows={3}
|
||||
{...form.getInputProps("pernyataanPemohon")}
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
{/* Tanggal Pengajuan */}
|
||||
<Accordion.Item value="tanggal">
|
||||
<Accordion.Control icon={<IconCalendar size={16} />}>
|
||||
Tanggal Pengajuan
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<DatePicker {...form.getInputProps("tanggalPengajuan")} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
{/* Pengesahan */}
|
||||
<Accordion.Item value="pengesahan">
|
||||
<Accordion.Control icon={<IconBuildingBank size={16} />}>
|
||||
Pengesahan
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="Petugas Registrasi"
|
||||
{...form.getInputProps("petugasRegistrasi")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="Kepala Dinas"
|
||||
{...form.getInputProps("kepalaDinas")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Divider />
|
||||
|
||||
<Group justify="right" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => form.reset()}
|
||||
leftSection={<IconX size={16} />}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button type="submit" leftSection={<IconCheck size={16} />}>
|
||||
Submit
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
Tip: Semua input penting memiliki validasi inline. NIK harus 16
|
||||
digit.
|
||||
</Text>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
945
src/pages/darmasaba/form_keterangan_kelahiran.tsx
Normal file
945
src/pages/darmasaba/form_keterangan_kelahiran.tsx
Normal file
@@ -0,0 +1,945 @@
|
||||
import {
|
||||
Accordion,
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Divider,
|
||||
Grid,
|
||||
Group,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import {
|
||||
IconCheck,
|
||||
IconInfoCircle,
|
||||
IconPlus,
|
||||
IconTrash,
|
||||
IconUser,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import React from "react";
|
||||
// Date/Time pickers live in @mantine/dates
|
||||
import { DatePicker, TimeInput } from "@mantine/dates";
|
||||
|
||||
/* ----------------------------- Types ----------------------------- */
|
||||
|
||||
/**
|
||||
* Strongly-typed form shape inferred from provided JSON schema.
|
||||
* Keep aligned with the JSON schema structure.
|
||||
*/
|
||||
type SaksiItem = {
|
||||
namaLengkap: string;
|
||||
nik: string;
|
||||
alamat: string;
|
||||
};
|
||||
|
||||
type Pengesahan = {
|
||||
kepalaDesaLurah?: string;
|
||||
camat?: string;
|
||||
petugasRegistrasi?: string;
|
||||
};
|
||||
|
||||
export type BirthFormValues = {
|
||||
// dataBayi
|
||||
dataBayi: {
|
||||
namaLengkap?: string;
|
||||
jenisKelamin?: string;
|
||||
tempatLahir?: string;
|
||||
tanggalLahir?: Date | null;
|
||||
jamLahir?: Date | null;
|
||||
beratBadan?: number | null;
|
||||
panjangBadan?: number | null;
|
||||
};
|
||||
// dataIbu
|
||||
dataIbu: {
|
||||
namaLengkap?: string;
|
||||
nik?: string;
|
||||
tempatTanggalLahir?: string;
|
||||
pekerjaan?: string;
|
||||
alamat?: string;
|
||||
};
|
||||
// dataAyah
|
||||
dataAyah: {
|
||||
namaLengkap?: string;
|
||||
nik?: string;
|
||||
tempatTanggalLahir?: string;
|
||||
pekerjaan?: string;
|
||||
alamat?: string;
|
||||
};
|
||||
// dataPelapor
|
||||
dataPelapor: {
|
||||
namaLengkap?: string;
|
||||
nik?: string;
|
||||
hubunganDenganBayi?: string;
|
||||
alamat?: string;
|
||||
};
|
||||
// saksi: array
|
||||
saksi: SaksiItem[];
|
||||
// other
|
||||
keteranganTambahan?: string;
|
||||
tanggalPelaporan?: Date | null;
|
||||
pengesahan: Pengesahan;
|
||||
};
|
||||
|
||||
/* ------------------------- Reusable Components ------------------------- */
|
||||
|
||||
/**
|
||||
* FormField: wraps label, description (helper), input control and error UI.
|
||||
* Keeps consistent spacing/typography and supports left-side icon tooltips when needed.
|
||||
*/
|
||||
function FormField({
|
||||
label,
|
||||
description,
|
||||
children,
|
||||
required = false,
|
||||
id,
|
||||
}: {
|
||||
label: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
required?: boolean;
|
||||
id?: string;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap="xs" style={{ width: "100%" }}>
|
||||
<Group justify="apart" gap="xs" align="center" style={{ width: "100%" }}>
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={600} size="sm" component="label" htmlFor={id}>
|
||||
{label}
|
||||
</Text>
|
||||
{description ? (
|
||||
<Tooltip label={description} withArrow>
|
||||
<ActionIcon size="sm" aria-label={`${label} info`}>
|
||||
<IconInfoCircle size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
{required ? <Badge c="red">Wajib</Badge> : null}
|
||||
</Group>
|
||||
|
||||
<Box>{children}</Box>
|
||||
|
||||
{description ? (
|
||||
<Text size="xs" color="dimmed" mt="xs">
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* FormSection: card with optional accordion/collapse for nested object grouping.
|
||||
*/
|
||||
function FormSection({
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card shadow="sm" radius="md" p="md" withBorder>
|
||||
<Accordion variant="separated">
|
||||
<Accordion.Item value="open">
|
||||
<Accordion.Control>
|
||||
<Group justify="apart" align="center" gap="md">
|
||||
<Group gap="sm" align="center">
|
||||
{icon}
|
||||
<div>
|
||||
<Text fw={700}>{title}</Text>
|
||||
{subtitle ? (
|
||||
<Text size="xs" color="dimmed">
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
</Group>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
|
||||
<Accordion.Panel>
|
||||
<Stack gap="md">{children}</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* --------------------------- Main Component --------------------------- */
|
||||
|
||||
export default function FormKeteranganKelahiran() {
|
||||
// Setup form with sensible defaults (smart defaults: empty strings, null dates)
|
||||
const form = useForm<BirthFormValues>({
|
||||
initialValues: {
|
||||
dataBayi: {
|
||||
namaLengkap: "",
|
||||
jenisKelamin: undefined,
|
||||
tempatLahir: "",
|
||||
tanggalLahir: null,
|
||||
jamLahir: null,
|
||||
beratBadan: null,
|
||||
panjangBadan: null,
|
||||
},
|
||||
dataIbu: {
|
||||
namaLengkap: "",
|
||||
nik: "",
|
||||
tempatTanggalLahir: "",
|
||||
pekerjaan: "",
|
||||
alamat: "",
|
||||
},
|
||||
dataAyah: {
|
||||
namaLengkap: "",
|
||||
nik: "",
|
||||
tempatTanggalLahir: "",
|
||||
pekerjaan: "",
|
||||
alamat: "",
|
||||
},
|
||||
dataPelapor: {
|
||||
namaLengkap: "",
|
||||
nik: "",
|
||||
hubunganDenganBayi: "",
|
||||
alamat: "",
|
||||
},
|
||||
saksi: [
|
||||
// start with one empty witness to guide user
|
||||
{ namaLengkap: "", nik: "", alamat: "" },
|
||||
],
|
||||
keteranganTambahan: "",
|
||||
tanggalPelaporan: null,
|
||||
pengesahan: {
|
||||
kepalaDesaLurah: "",
|
||||
camat: "",
|
||||
petugasRegistrasi: "",
|
||||
},
|
||||
},
|
||||
|
||||
// Validation rules derived from schema and best practices
|
||||
validate: {
|
||||
dataBayi: {
|
||||
namaLengkap: (val) =>
|
||||
val && val.trim().length > 0 ? null : "Nama bayi diperlukan.",
|
||||
jenisKelamin: (val) => (val ? null : "Pilih jenis kelamin."),
|
||||
tanggalLahir: (val) => (val ? null : "Tanggal lahir diperlukan."),
|
||||
beratBadan: (val) =>
|
||||
val == null || val > 0 ? null : "Berat harus lebih dari 0.",
|
||||
panjangBadan: (val) =>
|
||||
val == null || val > 0 ? null : "Panjang harus lebih dari 0.",
|
||||
},
|
||||
dataIbu: {
|
||||
namaLengkap: (val) =>
|
||||
val && val.trim().length > 0 ? null : "Nama ibu diperlukan.",
|
||||
nik: (val) =>
|
||||
val && /^[0-9]{16}$/.test(val.trim()) ? null : "NIK harus 16 digit.",
|
||||
},
|
||||
dataAyah: {
|
||||
// if father provided, validate NIK format when non-empty
|
||||
nik: (val) =>
|
||||
val === "" || /^[0-9]{16}$/.test(val?.trim() || "")
|
||||
? null
|
||||
: "NIK harus 16 digit.",
|
||||
},
|
||||
dataPelapor: {
|
||||
namaLengkap: (val) =>
|
||||
val && val.trim().length > 0 ? null : "Nama pelapor diperlukan.",
|
||||
nik: (val) =>
|
||||
val && /^[0-9]{16}$/.test(val.trim()) ? null : "NIK harus 16 digit.",
|
||||
},
|
||||
saksi: {
|
||||
// top-level validation for array minimal length handled in submit
|
||||
} as any,
|
||||
tanggalPelaporan: (val) => (val ? null : "Tanggal pelaporan diperlukan."),
|
||||
},
|
||||
});
|
||||
|
||||
/* ------------------- Dynamic saksi (witness) helpers ------------------- */
|
||||
|
||||
const addSaksi = () => {
|
||||
form.setFieldValue("saksi", [
|
||||
...form.values.saksi,
|
||||
{ namaLengkap: "", nik: "", alamat: "" },
|
||||
]);
|
||||
};
|
||||
|
||||
const removeSaksi = (index: number) => {
|
||||
const arr = [...form.values.saksi];
|
||||
arr.splice(index, 1);
|
||||
form.setFieldValue("saksi", arr);
|
||||
};
|
||||
|
||||
/* ---------------------- Submit / Reset handlers ---------------------- */
|
||||
|
||||
const handleSubmit = (values: BirthFormValues) => {
|
||||
// Extra validation: ensure at least one saksi is filled meaningfully
|
||||
const hasValidSaksi =
|
||||
values.saksi.length > 0 &&
|
||||
values.saksi.some((s) => (s.namaLengkap || "").trim().length > 0);
|
||||
|
||||
if (!hasValidSaksi) {
|
||||
form.setFieldError("saksi", "Minimal satu saksi harus diisi.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Final normalized payload (dates converted to ISO)
|
||||
const payload = {
|
||||
...values,
|
||||
dataBayi: {
|
||||
...values.dataBayi,
|
||||
tanggalLahir: values.dataBayi.tanggalLahir
|
||||
? values.dataBayi.tanggalLahir.toISOString().split("T")[0]
|
||||
: null,
|
||||
jamLahir: values.dataBayi.jamLahir
|
||||
? values.dataBayi.jamLahir.toISOString().split("T")[1]?.slice(0, 8)
|
||||
: null,
|
||||
},
|
||||
tanggalPelaporan: values.tanggalPelaporan
|
||||
? values.tanggalPelaporan.toISOString().split("T")[0]
|
||||
: null,
|
||||
};
|
||||
|
||||
// For demo: print to console. Integrate with API in real app.
|
||||
// Accessibility: focus the first invalid field if any (not shown here).
|
||||
console.log("Submitted Birth Certificate Payload:", payload);
|
||||
|
||||
// Visual confirmation: we'll set a small success field (in production, use notification)
|
||||
// Reset form or keep values based on UX decision. We'll keep values and indicate success.
|
||||
// For example, you can call form.reset() to clear.
|
||||
// form.reset();
|
||||
alert("Form berhasil disubmit. Lihat console untuk payload.");
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
form.reset();
|
||||
};
|
||||
|
||||
/* ------------------------------ Render ------------------------------ */
|
||||
|
||||
return (
|
||||
<Container size={"md"} w={"100%"}>
|
||||
<Box>
|
||||
<Stack gap="lg" style={{ maxWidth: 980, margin: "0 auto" }}>
|
||||
<Group justify="apart">
|
||||
<Title order={3}>Formulir Surat Keterangan Kelahiran</Title>
|
||||
|
||||
<Group>
|
||||
<Badge variant="light" c="gray">
|
||||
Blangko resmi
|
||||
</Badge>
|
||||
<Text size="sm" color="dimmed">
|
||||
Blangko untuk pelaporan kelahiran & dasar penerbitan Akta
|
||||
Kelahiran
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
handleSubmit(values);
|
||||
})}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{/* Section: Data Bayi */}
|
||||
<FormSection
|
||||
title="Data Bayi"
|
||||
subtitle="Informasi lengkap tentang bayi yang lahir"
|
||||
icon={<IconUser size={20} />}
|
||||
>
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
id="bayi-namaLengkap"
|
||||
label="Nama Lengkap"
|
||||
description="Nama lengkap bayi yang baru lahir (jika sudah ditentukan)."
|
||||
required
|
||||
>
|
||||
<TextInput
|
||||
id="bayi-namaLengkap"
|
||||
placeholder="Contoh: Putu Gede"
|
||||
{...form.getInputProps("dataBayi.namaLengkap")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
id="bayi-jenisKelamin"
|
||||
label="Jenis Kelamin"
|
||||
description="Pilih jenis kelamin bayi."
|
||||
required
|
||||
>
|
||||
<Select
|
||||
id="bayi-jenisKelamin"
|
||||
placeholder="Pilih"
|
||||
data={["Laki-laki", "Perempuan"]}
|
||||
{...form.getInputProps("dataBayi.jenisKelamin")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
label="Tempat Lahir"
|
||||
id="bayi-tempatLahir"
|
||||
description="Nama desa/kelurahan/kecamatan/kabupaten/kota."
|
||||
>
|
||||
<TextInput
|
||||
id="bayi-tempatLahir"
|
||||
placeholder="Contoh: RS X, Kecamatan Y"
|
||||
{...form.getInputProps("dataBayi.tempatLahir")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={3}>
|
||||
<FormField
|
||||
id="bayi-tanggalLahir"
|
||||
label="Tanggal Lahir"
|
||||
description="Tanggal lahir bayi."
|
||||
required
|
||||
>
|
||||
<DatePicker
|
||||
id="bayi-tanggalLahir"
|
||||
value={form.values.dataBayi.tanggalLahir}
|
||||
onChange={(d) =>
|
||||
form.setFieldValue("dataBayi.tanggalLahir", d as any)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={3}>
|
||||
<FormField
|
||||
id="bayi-jamLahir"
|
||||
label="Jam Lahir"
|
||||
description="Jam lahir bayi."
|
||||
>
|
||||
<TimeInput
|
||||
id="bayi-jamLahir"
|
||||
placeholder="HH:MM"
|
||||
value={form.values.dataBayi.jamLahir as any}
|
||||
onChange={(d) =>
|
||||
form.setFieldValue("dataBayi.jamLahir", d as any)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={3}>
|
||||
<FormField
|
||||
id="bayi-beratBadan"
|
||||
label="Berat Badan (kg)"
|
||||
description="Berat dalam kilogram."
|
||||
>
|
||||
<NumberInput
|
||||
id="bayi-beratBadan"
|
||||
placeholder="3.2"
|
||||
step={0.01}
|
||||
min={0}
|
||||
{...form.getInputProps("dataBayi.beratBadan")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={3}>
|
||||
<FormField
|
||||
id="bayi-panjangBadan"
|
||||
label="Panjang Badan (cm)"
|
||||
description="Panjang dalam sentimeter."
|
||||
>
|
||||
<NumberInput
|
||||
id="bayi-panjangBadan"
|
||||
placeholder="50"
|
||||
step={0.5}
|
||||
min={0}
|
||||
{...form.getInputProps("dataBayi.panjangBadan")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</FormSection>
|
||||
|
||||
{/* Section: Data Ibu */}
|
||||
<FormSection
|
||||
title="Data Ibu"
|
||||
subtitle="Data identitas ibu kandung"
|
||||
icon={<IconUser size={20} />}
|
||||
>
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
id="ibu-nama"
|
||||
label="Nama Lengkap Ibu"
|
||||
required
|
||||
description="Nama lengkap ibu kandung bayi."
|
||||
>
|
||||
<TextInput
|
||||
id="ibu-nama"
|
||||
placeholder="Nama lengkap"
|
||||
{...form.getInputProps("dataIbu.namaLengkap")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
id="ibu-nik"
|
||||
label="NIK Ibu"
|
||||
required
|
||||
description="Nomor Induk Kependudukan 16 digit."
|
||||
>
|
||||
<TextInput
|
||||
id="ibu-nik"
|
||||
placeholder="16 digit NIK"
|
||||
{...form.getInputProps("dataIbu.nik")}
|
||||
inputMode="numeric"
|
||||
aria-label="NIK Ibu"
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
id="ibu-ttl"
|
||||
label="Tempat & Tanggal Lahir Ibu"
|
||||
description="Format: Kota, DD/MM/YYYY (bebas text)."
|
||||
>
|
||||
<TextInput
|
||||
id="ibu-ttl"
|
||||
placeholder="Contoh: Denpasar, 01/01/1990"
|
||||
{...form.getInputProps("dataIbu.tempatTanggalLahir")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
id="ibu-pekerjaan"
|
||||
label="Pekerjaan Ibu"
|
||||
description="Pekerjaan ibu."
|
||||
>
|
||||
<TextInput
|
||||
id="ibu-pekerjaan"
|
||||
placeholder="Contoh: Ibu Rumah Tangga"
|
||||
{...form.getInputProps("dataIbu.pekerjaan")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={12}>
|
||||
<FormField
|
||||
id="ibu-alamat"
|
||||
label="Alamat Ibu"
|
||||
description="Alamat lengkap sesuai KTP."
|
||||
>
|
||||
<Textarea
|
||||
id="ibu-alamat"
|
||||
placeholder="Alamat lengkap"
|
||||
autosize
|
||||
minRows={2}
|
||||
{...form.getInputProps("dataIbu.alamat")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</FormSection>
|
||||
|
||||
{/* Section: Data Ayah */}
|
||||
<FormSection
|
||||
title="Data Ayah"
|
||||
subtitle="Data identitas ayah kandung (jika tersedia)"
|
||||
icon={<IconUser size={20} />}
|
||||
>
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
id="ayah-nama"
|
||||
label="Nama Lengkap Ayah"
|
||||
description="Nama lengkap ayah kandung bayi."
|
||||
>
|
||||
<TextInput
|
||||
id="ayah-nama"
|
||||
placeholder="Nama lengkap"
|
||||
{...form.getInputProps("dataAyah.namaLengkap")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
id="ayah-nik"
|
||||
label="NIK Ayah"
|
||||
description="Nomor Induk Kependudukan (16 digit)."
|
||||
>
|
||||
<TextInput
|
||||
id="ayah-nik"
|
||||
placeholder="16 digit NIK"
|
||||
{...form.getInputProps("dataAyah.nik")}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
id="ayah-ttl"
|
||||
label="Tempat & Tanggal Lahir Ayah"
|
||||
description="Format: Kota, DD/MM/YYYY (bebas text)."
|
||||
>
|
||||
<TextInput
|
||||
id="ayah-ttl"
|
||||
placeholder="Contoh: Badung, 15/05/1988"
|
||||
{...form.getInputProps("dataAyah.tempatTanggalLahir")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
id="ayah-pekerjaan"
|
||||
label="Pekerjaan Ayah"
|
||||
description="Pekerjaan ayah."
|
||||
>
|
||||
<TextInput
|
||||
id="ayah-pekerjaan"
|
||||
placeholder="Contoh: Petani"
|
||||
{...form.getInputProps("dataAyah.pekerjaan")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={12}>
|
||||
<FormField
|
||||
id="ayah-alamat"
|
||||
label="Alamat Ayah"
|
||||
description="Alamat lengkap ayah."
|
||||
>
|
||||
<Textarea
|
||||
id="ayah-alamat"
|
||||
placeholder="Alamat lengkap"
|
||||
autosize
|
||||
minRows={2}
|
||||
{...form.getInputProps("dataAyah.alamat")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</FormSection>
|
||||
|
||||
{/* Section: Data Pelapor */}
|
||||
<FormSection
|
||||
title="Data Pelapor"
|
||||
subtitle="Orang yang melaporkan kelahiran"
|
||||
icon={<IconUser size={20} />}
|
||||
>
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
id="pelapor-nama"
|
||||
label="Nama Pelapor"
|
||||
required
|
||||
description="Bisa ayah/ibu/kerabat."
|
||||
>
|
||||
<TextInput
|
||||
id="pelapor-nama"
|
||||
placeholder="Nama pelapor"
|
||||
{...form.getInputProps("dataPelapor.namaLengkap")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
id="pelapor-nik"
|
||||
label="NIK Pelapor"
|
||||
required
|
||||
description="NIK 16 digit."
|
||||
>
|
||||
<TextInput
|
||||
id="pelapor-nik"
|
||||
placeholder="16 digit NIK"
|
||||
{...form.getInputProps("dataPelapor.nik")}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
id="pelapor-hubungan"
|
||||
label="Hubungan dengan Bayi"
|
||||
description="Contoh: Ayah, Ibu, Kakek, Nenek, dll."
|
||||
>
|
||||
<TextInput
|
||||
id="pelapor-hubungan"
|
||||
placeholder="Contoh: Ayah"
|
||||
{...form.getInputProps(
|
||||
"dataPelapor.hubunganDenganBayi",
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
id="pelapor-alamat"
|
||||
label="Alamat Pelapor"
|
||||
description="Alamat lengkap pelapor."
|
||||
>
|
||||
<Textarea
|
||||
id="pelapor-alamat"
|
||||
autosize
|
||||
minRows={2}
|
||||
{...form.getInputProps("dataPelapor.alamat")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</FormSection>
|
||||
|
||||
{/* Section: Saksi (array) */}
|
||||
<FormSection
|
||||
title="Saksi"
|
||||
subtitle="Daftar saksi yang menyaksikan proses kelahiran"
|
||||
icon={<IconUser size={20} />}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
{form.values.saksi.map((s, idx) => (
|
||||
<Card key={idx} radius="md" p="sm" withBorder>
|
||||
<Grid align="center">
|
||||
<Grid.Col span={10}>
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
id={`saksi-${idx}-nama`}
|
||||
label={`Saksi ${idx + 1} - Nama Lengkap`}
|
||||
description="Nama lengkap saksi."
|
||||
>
|
||||
<TextInput
|
||||
id={`saksi-${idx}-nama`}
|
||||
placeholder="Nama lengkap"
|
||||
value={form.values.saksi[idx]?.namaLengkap}
|
||||
onChange={(e) => {
|
||||
const arr = [...form.values.saksi] as any;
|
||||
arr[idx] = {
|
||||
...arr[idx],
|
||||
namaLengkap: e.target.value,
|
||||
};
|
||||
form.setFieldValue("saksi", arr);
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
id={`saksi-${idx}-nik`}
|
||||
label="NIK"
|
||||
description="NIK 16 digit (opsional jika tidak punya)."
|
||||
>
|
||||
<TextInput
|
||||
id={`saksi-${idx}-nik`}
|
||||
placeholder="16 digit NIK"
|
||||
value={form.values.saksi[idx]?.nik}
|
||||
onChange={(e) => {
|
||||
const arr = [...form.values.saksi] as any;
|
||||
arr[idx] = {
|
||||
...arr[idx],
|
||||
nik: e.target.value,
|
||||
};
|
||||
form.setFieldValue("saksi", arr);
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={12}>
|
||||
<FormField
|
||||
id={`saksi-${idx}-alamat`}
|
||||
label="Alamat Saksi"
|
||||
description="Alamat lengkap saksi."
|
||||
>
|
||||
<Textarea
|
||||
id={`saksi-${idx}-alamat`}
|
||||
autosize
|
||||
minRows={2}
|
||||
value={form.values.saksi[idx]?.alamat}
|
||||
onChange={(e) => {
|
||||
const arr = [...form.values.saksi] as any;
|
||||
arr[idx] = {
|
||||
...arr[idx],
|
||||
alamat: e.target.value,
|
||||
};
|
||||
form.setFieldValue("saksi", arr);
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={2}>
|
||||
<Group justify="right">
|
||||
<ActionIcon
|
||||
color="red"
|
||||
variant="subtle"
|
||||
onClick={() => removeSaksi(idx)}
|
||||
aria-label={`Hapus saksi ${idx + 1}`}
|
||||
>
|
||||
<IconTrash />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Group justify="left">
|
||||
<Button
|
||||
leftSection={<IconPlus />}
|
||||
variant="outline"
|
||||
onClick={addSaksi}
|
||||
aria-label="Tambah saksi"
|
||||
>
|
||||
Tambah Saksi
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Display saksi-level error if exists */}
|
||||
{form.errors.saksi ? (
|
||||
<Text color="red" size="sm">
|
||||
{form.errors.saksi as unknown as string}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</FormSection>
|
||||
|
||||
{/* Additional notes, pelaporan, pengesahan */}
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
id="keteranganTambahan"
|
||||
label="Keterangan Tambahan"
|
||||
description="Catatan atau keterangan lain yang perlu dicantumkan."
|
||||
>
|
||||
<Textarea
|
||||
id="keteranganTambahan"
|
||||
autosize
|
||||
minRows={3}
|
||||
{...form.getInputProps("keteranganTambahan")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
id="tanggalPelaporan"
|
||||
label="Tanggal Pelaporan"
|
||||
description="Tanggal saat pelaporan surat keterangan kelahiran."
|
||||
required
|
||||
>
|
||||
<DatePicker
|
||||
id="tanggalPelaporan"
|
||||
value={form.values.tanggalPelaporan}
|
||||
onChange={(d) =>
|
||||
form.setFieldValue("tanggalPelaporan", d as any)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<FormSection
|
||||
title="Pengesahan"
|
||||
subtitle="Pihak-pihak yang menandatangani/pengesahan"
|
||||
icon={<IconUser size={20} />}
|
||||
>
|
||||
<Grid>
|
||||
<Grid.Col span={4}>
|
||||
<FormField
|
||||
id="pengesahan-kepala"
|
||||
label="Kepala Desa / Lurah"
|
||||
description="Nama Kepala Desa atau Lurah yang mengesahkan."
|
||||
>
|
||||
<TextInput
|
||||
id="pengesahan-kepala"
|
||||
placeholder="Nama"
|
||||
{...form.getInputProps("pengesahan.kepalaDesaLurah")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<FormField
|
||||
id="pengesahan-camat"
|
||||
label="Camat"
|
||||
description="Nama Camat yang mengesahkan."
|
||||
>
|
||||
<TextInput
|
||||
id="pengesahan-camat"
|
||||
placeholder="Nama"
|
||||
{...form.getInputProps("pengesahan.camat")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<FormField
|
||||
id="pengesahan-petugas"
|
||||
label="Petugas Registrasi"
|
||||
description="Nama petugas pencatat sipil."
|
||||
>
|
||||
<TextInput
|
||||
id="pengesahan-petugas"
|
||||
placeholder="Nama"
|
||||
{...form.getInputProps("pengesahan.petugasRegistrasi")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</FormSection>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Submit / Reset */}
|
||||
<Group justify="right" gap="sm">
|
||||
<Button
|
||||
leftSection={<IconX />}
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
aria-label="Reset form"
|
||||
type="button"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
leftSection={<IconCheck />}
|
||||
type="submit"
|
||||
aria-label="Submit form"
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
649
src/pages/darmasaba/form_laporan_sampah.tsx
Normal file
649
src/pages/darmasaba/form_laporan_sampah.tsx
Normal file
@@ -0,0 +1,649 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
Stack,
|
||||
Group,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Select,
|
||||
MultiSelect,
|
||||
NumberInput,
|
||||
Switch,
|
||||
Button,
|
||||
FileButton,
|
||||
Divider,
|
||||
Accordion,
|
||||
Grid,
|
||||
ActionIcon,
|
||||
Container,
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import {
|
||||
IconMapPin,
|
||||
IconUpload,
|
||||
IconUser,
|
||||
IconPhoto,
|
||||
IconVideo,
|
||||
IconFileText,
|
||||
IconTrash,
|
||||
IconPlus,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
// ---------------------------
|
||||
// Types generated from provided schema
|
||||
// ---------------------------
|
||||
|
||||
type ReportType =
|
||||
| "Sampah Liar"
|
||||
| "Sampah Terbakar"
|
||||
| "Penimbunan Ilegal"
|
||||
| "Lainnya";
|
||||
type StatusType =
|
||||
| "Pending"
|
||||
| "Terverifikasi"
|
||||
| "Dalam Penanganan"
|
||||
| "Selesai"
|
||||
| "Ditolak";
|
||||
type PriorityType = "Rendah" | "Sedang" | "Tinggi" | "Darurat";
|
||||
|
||||
type Reporter = {
|
||||
isAnonymous: boolean;
|
||||
name?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
userId?: string | null;
|
||||
};
|
||||
|
||||
type Location = {
|
||||
address?: string | null;
|
||||
village?: string | null;
|
||||
subDistrict?: string | null;
|
||||
city?: string | null;
|
||||
province?: string | null;
|
||||
postalCode?: string | null;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
placeType?: string | null;
|
||||
};
|
||||
|
||||
type WasteDetails = {
|
||||
wasteTypes: string[];
|
||||
estimatedVolume?: string | null;
|
||||
hazardous?: boolean;
|
||||
detailB3?: string | null;
|
||||
};
|
||||
|
||||
type Evidence = {
|
||||
photos: string[]; // urls or base64
|
||||
videos: string[]; // urls or base64
|
||||
attachments: string[]; // other files
|
||||
};
|
||||
|
||||
type ReportFormValues = {
|
||||
reportId?: string | null;
|
||||
reportType?: ReportType | null;
|
||||
status?: StatusType | null;
|
||||
priority?: PriorityType | null;
|
||||
reporter: Reporter;
|
||||
location: Location;
|
||||
wasteDetails: WasteDetails;
|
||||
evidence: Evidence;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
// ---------------------------
|
||||
// Helper: file -> base64
|
||||
// ---------------------------
|
||||
async function fileToBase64(file: File | null): Promise<string | null> {
|
||||
if (!file) return null;
|
||||
return await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = (err) => reject(err);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// Main component
|
||||
// ---------------------------
|
||||
|
||||
export default function FormLaporanSampah() {
|
||||
const form = useForm<ReportFormValues>({
|
||||
initialValues: {
|
||||
reportId: "",
|
||||
reportType: null,
|
||||
status: "Pending",
|
||||
priority: "Sedang",
|
||||
reporter: {
|
||||
isAnonymous: false,
|
||||
name: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
userId: "",
|
||||
},
|
||||
location: {
|
||||
address: "",
|
||||
village: "",
|
||||
subDistrict: "",
|
||||
city: "",
|
||||
province: "",
|
||||
postalCode: "",
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
placeType: "",
|
||||
},
|
||||
wasteDetails: {
|
||||
wasteTypes: [],
|
||||
estimatedVolume: "",
|
||||
hazardous: false,
|
||||
detailB3: "",
|
||||
},
|
||||
evidence: {
|
||||
photos: [],
|
||||
videos: [],
|
||||
attachments: [],
|
||||
},
|
||||
notes: "",
|
||||
},
|
||||
|
||||
validate: {
|
||||
// basic validations
|
||||
reportType: (v) => (!v ? "Pilih tipe laporan" : null),
|
||||
status: (v) => (!v ? "Status diperlukan" : null),
|
||||
// location requires at least address OR lat/lng
|
||||
// We'll validate in onSubmit for combined rules
|
||||
},
|
||||
});
|
||||
|
||||
// small UI helpers
|
||||
const [photoUploadName, setPhotoUploadName] = useState<string | null>(null);
|
||||
const [videoUploadName, setVideoUploadName] = useState<string | null>(null);
|
||||
const [attachmentName, setAttachmentName] = useState<string | null>(null);
|
||||
|
||||
// add photo/video/attachment entry from URL or uploaded file
|
||||
const addPhotoUrl = (url: string) => {
|
||||
if (!url) return;
|
||||
const arr = [...form.values.evidence.photos, url];
|
||||
form.setFieldValue("evidence", { ...form.values.evidence, photos: arr });
|
||||
};
|
||||
const addVideoUrl = (url: string) => {
|
||||
if (!url) return;
|
||||
const arr = [...form.values.evidence.videos, url];
|
||||
form.setFieldValue("evidence", { ...form.values.evidence, videos: arr });
|
||||
};
|
||||
const addAttachmentUrl = (url: string) => {
|
||||
if (!url) return;
|
||||
const arr = [...form.values.evidence.attachments, url];
|
||||
form.setFieldValue("evidence", {
|
||||
...form.values.evidence,
|
||||
attachments: arr,
|
||||
});
|
||||
};
|
||||
|
||||
// submit handler
|
||||
const handleSubmit = async (values: ReportFormValues) => {
|
||||
// composite validations
|
||||
const hasAddress = Boolean(
|
||||
values.location.address && values.location.address.trim(),
|
||||
);
|
||||
const hasCoords =
|
||||
typeof values.location.latitude === "number" &&
|
||||
typeof values.location.longitude === "number";
|
||||
if (!hasAddress && !hasCoords) {
|
||||
alert("Mohon isi alamat atau koordinat (latitude & longitude).");
|
||||
return;
|
||||
}
|
||||
|
||||
// if hazardous true, ensure detailB3 exists
|
||||
if (values.wasteDetails.hazardous && !values.wasteDetails.detailB3) {
|
||||
alert("Jika terdapat sampah berbahaya, mohon isi rincian B3.");
|
||||
return;
|
||||
}
|
||||
|
||||
// if reporter anonymous, clear personal fields
|
||||
const payload = { ...values };
|
||||
if (payload.reporter.isAnonymous) {
|
||||
payload.reporter = { isAnonymous: true } as Reporter;
|
||||
}
|
||||
|
||||
// TODO: send to API — for now console
|
||||
console.log("Submitting report:", payload);
|
||||
alert("Laporan berhasil disubmit (demo). Cek console untuk payload.");
|
||||
form.reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="md" w="100%">
|
||||
<Card shadow="sm" radius="md" p="xl">
|
||||
<Stack gap="md">
|
||||
<Group justify="apart">
|
||||
<div>
|
||||
<Text fw={700} size="lg">
|
||||
Form Laporan Sampah & Lingkungan
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Gunakan formulir ini untuk melaporkan masalah sampah/lingkungan.
|
||||
Sertakan bukti foto/video bila memungkinkan.
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
handleSubmit(values);
|
||||
})}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<Grid>
|
||||
<Grid.Col span={4}>
|
||||
<Select
|
||||
label="Tipe Laporan"
|
||||
placeholder="Pilih tipe"
|
||||
data={[
|
||||
"Sampah Liar",
|
||||
"Sampah Terbakar",
|
||||
"Penimbunan Ilegal",
|
||||
"Lainnya",
|
||||
]}
|
||||
{...form.getInputProps("reportType")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<Select
|
||||
label="Status"
|
||||
data={[
|
||||
"Pending",
|
||||
"Terverifikasi",
|
||||
"Dalam Penanganan",
|
||||
"Selesai",
|
||||
"Ditolak",
|
||||
]}
|
||||
{...form.getInputProps("status")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<Select
|
||||
label="Prioritas"
|
||||
data={["Rendah", "Sedang", "Tinggi", "Darurat"]}
|
||||
{...form.getInputProps("priority")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Accordion variant="separated" defaultValue="reporter">
|
||||
<Accordion.Item value="reporter">
|
||||
<Accordion.Control icon={<IconUser size={16} />}>
|
||||
Informasi Pelapor
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Group gap="md" align="center">
|
||||
<Switch
|
||||
label="Laporkan sebagai anonim"
|
||||
{...form.getInputProps("reporter.isAnonymous", {
|
||||
type: "checkbox",
|
||||
})}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{!form.values.reporter.isAnonymous && (
|
||||
<Grid mt="sm">
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="Nama"
|
||||
placeholder="Nama pelapor"
|
||||
{...form.getInputProps("reporter.name")}
|
||||
></TextInput>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="Telepon"
|
||||
placeholder="08xx..."
|
||||
{...form.getInputProps("reporter.phone")}
|
||||
></TextInput>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="Email"
|
||||
placeholder="email@contoh.com"
|
||||
{...form.getInputProps("reporter.email")}
|
||||
></TextInput>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="User ID (jika terdaftar)"
|
||||
placeholder="user-uuid"
|
||||
{...form.getInputProps("reporter.userId")}
|
||||
></TextInput>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="location">
|
||||
<Accordion.Control icon={<IconMapPin size={16} />}>
|
||||
Lokasi Kejadian
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Grid>
|
||||
<Grid.Col span={12}>
|
||||
<Textarea
|
||||
label="Alamat deskriptif"
|
||||
placeholder="Jalan, RT/RW, desa/kelurahan"
|
||||
{...form.getInputProps("location.address")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<TextInput
|
||||
label="Kelurahan/Desa"
|
||||
{...form.getInputProps("location.village")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={4}>
|
||||
<TextInput
|
||||
label="Kecamatan"
|
||||
{...form.getInputProps("location.subDistrict")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={4}>
|
||||
<TextInput
|
||||
label="Kabupaten/Kota"
|
||||
{...form.getInputProps("location.city")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<TextInput
|
||||
label="Provinsi"
|
||||
{...form.getInputProps("location.province")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={4}>
|
||||
<TextInput
|
||||
label="Kode Pos"
|
||||
{...form.getInputProps("location.postalCode")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={4}>
|
||||
<Select
|
||||
label="Jenis Lokasi"
|
||||
data={[
|
||||
"Pinggir Jalan",
|
||||
"Sungai/Drainase",
|
||||
"Lapangan",
|
||||
"Hutan",
|
||||
"Permukiman",
|
||||
"Lainnya",
|
||||
]}
|
||||
{...form.getInputProps("location.placeType")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<NumberInput
|
||||
label="Latitude"
|
||||
placeholder="-6.200000"
|
||||
style={{
|
||||
precision: 6,
|
||||
}}
|
||||
{...form.getInputProps("location.latitude")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<NumberInput
|
||||
label="Longitude"
|
||||
placeholder="106.816666"
|
||||
style={{
|
||||
precision: 6,
|
||||
}}
|
||||
{...form.getInputProps("location.longitude")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="wasteDetails">
|
||||
<Accordion.Control icon={<IconFileText size={16} />}>
|
||||
Rincian Sampah
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Grid>
|
||||
<Grid.Col span={12}>
|
||||
<MultiSelect
|
||||
label="Jenis Sampah"
|
||||
placeholder="Pilih atau ketik jenis sampah"
|
||||
data={[
|
||||
"Plastik",
|
||||
"Organik",
|
||||
"Elektronik",
|
||||
"Konstruksi",
|
||||
"Ban",
|
||||
"Kertas",
|
||||
"Kaca",
|
||||
"Lainnya",
|
||||
]}
|
||||
searchable
|
||||
{...form.getInputProps("wasteDetails.wasteTypes")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="Estimasi Volume"
|
||||
placeholder="Mis. '2 karung', '3 m3'"
|
||||
{...form.getInputProps(
|
||||
"wasteDetails.estimatedVolume",
|
||||
)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Group justify="left" gap="sm" align="center">
|
||||
<Switch
|
||||
label="Mengandung B3 (Berbahaya)"
|
||||
{...form.getInputProps("wasteDetails.hazardous", {
|
||||
type: "checkbox",
|
||||
})}
|
||||
/>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
|
||||
{form.values.wasteDetails.hazardous && (
|
||||
<Grid.Col span={12}>
|
||||
<Textarea
|
||||
label="Rincian B3"
|
||||
placeholder="Jelaskan bahan berbahaya"
|
||||
{...form.getInputProps("wasteDetails.detailB3")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
)}
|
||||
</Grid>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="evidence">
|
||||
<Accordion.Control icon={<IconPhoto size={16} />}>
|
||||
Bukti & Lampiran
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
Unggah foto, video, atau lampiran lain. Anda bisa
|
||||
mengunggah file (disimpan sebagai base64) atau
|
||||
menempelkan URL.
|
||||
</Text>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<Group>
|
||||
<FileButton
|
||||
onChange={async (file) => {
|
||||
if (!file) return;
|
||||
const base64 = await fileToBase64(file);
|
||||
if (!base64) return;
|
||||
addPhotoUrl(base64);
|
||||
setPhotoUploadName(file.name);
|
||||
}}
|
||||
accept="image/*"
|
||||
>
|
||||
{(props) => (
|
||||
<Button
|
||||
leftSection={<IconUpload size={16} />}
|
||||
{...props}
|
||||
>
|
||||
Upload Foto
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
|
||||
<ActionIcon
|
||||
onClick={() => {
|
||||
// quick add placeholder example photo (smart default)
|
||||
addPhotoUrl(
|
||||
"https://via.placeholder.com/800x600.png?text=Foto+contoh",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<IconPlus />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
<Stack mt="sm">
|
||||
{form.values.evidence.photos.map((p, idx) => (
|
||||
<Group key={idx} justify="apart">
|
||||
<Text
|
||||
size="sm"
|
||||
style={{ wordBreak: "break-all" }}
|
||||
>
|
||||
{p.length > 60 ? p.slice(0, 60) + "..." : p}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
color="red"
|
||||
onClick={() => {
|
||||
const arr =
|
||||
form.values.evidence.photos.filter(
|
||||
(_, i) => i !== idx,
|
||||
);
|
||||
form.setFieldValue("evidence", {
|
||||
...form.values.evidence,
|
||||
photos: arr,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Group>
|
||||
<FileButton
|
||||
onChange={async (file) => {
|
||||
if (!file) return;
|
||||
const base64 = await fileToBase64(file);
|
||||
if (!base64) return;
|
||||
addAttachmentUrl(base64);
|
||||
setAttachmentName(file.name);
|
||||
}}
|
||||
accept="*/*"
|
||||
>
|
||||
{(props) => (
|
||||
<Button
|
||||
leftSection={<IconUpload size={16} />}
|
||||
{...props}
|
||||
>
|
||||
Upload Lampiran
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
|
||||
<FileButton
|
||||
onChange={async (file) => {
|
||||
if (!file) return;
|
||||
const base64 = await fileToBase64(file);
|
||||
if (!base64) return;
|
||||
addVideoUrl(base64);
|
||||
setVideoUploadName(file.name);
|
||||
}}
|
||||
accept="video/*"
|
||||
>
|
||||
{(props) => (
|
||||
<Button
|
||||
leftSection={<IconUpload size={16} />}
|
||||
{...props}
|
||||
>
|
||||
Upload Video
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
</Group>
|
||||
|
||||
<Stack mt="sm">
|
||||
{form.values.evidence.videos.map((v, idx) => (
|
||||
<Group key={idx} justify="apart">
|
||||
<Text
|
||||
size="sm"
|
||||
style={{ wordBreak: "break-all" }}
|
||||
>
|
||||
{v.length > 60 ? v.slice(0, 60) + "..." : v}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
color="red"
|
||||
onClick={() => {
|
||||
const arr =
|
||||
form.values.evidence.videos.filter(
|
||||
(_, i) => i !== idx,
|
||||
);
|
||||
form.setFieldValue("evidence", {
|
||||
...form.values.evidence,
|
||||
videos: arr,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Textarea
|
||||
label="Catatan Tambahan"
|
||||
placeholder="Informasi lain yang relevan"
|
||||
{...form.getInputProps("notes")}
|
||||
/>
|
||||
|
||||
<Group justify="right" gap="sm">
|
||||
<Button variant="default" onClick={() => form.reset()}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button type="submit">Kirim Laporan</Button>
|
||||
</Group>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
Tip: Sertakan minimal 1 foto jika memungkinkan untuk mempercepat
|
||||
verifikasi.
|
||||
</Text>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
549
src/pages/darmasaba/form_surat_keterangan_belum_kawin.tsx
Normal file
549
src/pages/darmasaba/form_surat_keterangan_belum_kawin.tsx
Normal file
@@ -0,0 +1,549 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// pages/surat-keterangan-belum-kawin/page.tsx
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Container,
|
||||
Card,
|
||||
Stack,
|
||||
Title,
|
||||
Text,
|
||||
Divider,
|
||||
Group,
|
||||
SimpleGrid,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Select,
|
||||
RadioGroup,
|
||||
Radio,
|
||||
FileInput,
|
||||
Button,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { DatePicker } from "@mantine/dates";
|
||||
import { showNotification } from "@mantine/notifications";
|
||||
import {
|
||||
IconFileText,
|
||||
IconUser,
|
||||
IconId,
|
||||
IconMapPin,
|
||||
IconCalendar,
|
||||
IconBuildingStore,
|
||||
IconBadge,
|
||||
IconUpload,
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconAlertCircle,
|
||||
} from "@tabler/icons-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export default function FormSuratKeteranganBelumKawin() {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [ttdPreview, setTtdPreview] = useState<string | null>(null);
|
||||
const [stempelPreview, setStempelPreview] = useState<string | null>(null);
|
||||
|
||||
// Mantine form state + validation rules
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
instansiPenerbit: {
|
||||
kabupatenKota: "",
|
||||
kecamatan: "",
|
||||
desaKelurahan: "",
|
||||
nomorSurat: "",
|
||||
},
|
||||
dataPemohon: {
|
||||
namaLengkap: "",
|
||||
nik: "",
|
||||
tempatTanggalLahir: "",
|
||||
jenisKelamin: "Laki-laki",
|
||||
agama: "Islam",
|
||||
pekerjaan: "",
|
||||
alamat: "",
|
||||
statusPerkawinan: "Belum Kawin",
|
||||
},
|
||||
isiSurat: {
|
||||
pernyataan:
|
||||
"Yang bertanda tangan di bawah ini menerangkan bahwa yang bersangkutan benar-benar belum pernah menikah sampai dengan tanggal surat ini.",
|
||||
tujuan: "",
|
||||
},
|
||||
tanggalPenerbitan: null,
|
||||
pengesahan: {
|
||||
kepalaDesaLurah: "",
|
||||
jabatan: "",
|
||||
tandaTangan: null,
|
||||
stempel: null,
|
||||
},
|
||||
},
|
||||
validate: {
|
||||
instansiPenerbit: {
|
||||
kabupatenKota: (value: any) =>
|
||||
value.trim().length > 0 ? null : "Kabupaten/Kota wajib diisi.",
|
||||
kecamatan: (value: any) =>
|
||||
value.trim().length > 0 ? null : "Kecamatan wajib diisi.",
|
||||
desaKelurahan: (value: any) =>
|
||||
value.trim().length > 0 ? null : "Desa/Kelurahan wajib diisi.",
|
||||
nomorSurat: (value: any) =>
|
||||
value.trim().length > 0 ? null : "Nomor surat wajib diisi.",
|
||||
},
|
||||
dataPemohon: {
|
||||
namaLengkap: (value: any) =>
|
||||
value.trim() ? null : "Nama lengkap wajib diisi.",
|
||||
nik: (value: any) =>
|
||||
/^\d{16}$/.test(value)
|
||||
? null
|
||||
: "NIK harus 16 digit angka tanpa spasi.",
|
||||
tempatTanggalLahir: (value: any) =>
|
||||
value.trim().length > 0
|
||||
? null
|
||||
: "Tempat dan tanggal lahir wajib diisi.",
|
||||
pekerjaan: (value: any) =>
|
||||
value.trim() ? null : "Pekerjaan wajib diisi.",
|
||||
alamat: (value: any) => (value.trim() ? null : "Alamat wajib diisi."),
|
||||
},
|
||||
isiSurat: {
|
||||
pernyataan: (value: any) =>
|
||||
value.trim().length > 20
|
||||
? null
|
||||
: "Pernyataan harus singkat tapi jelas (min 20 karakter).",
|
||||
tujuan: (value: any) =>
|
||||
value.trim() ? null : "Sebutkan tujuan penerbitan surat.",
|
||||
},
|
||||
tanggalPenerbitan: (value: any) =>
|
||||
value ? null : "Tanggal penerbitan wajib dipilih.",
|
||||
pengesahan: {
|
||||
kepalaDesaLurah: (value: any) =>
|
||||
value.trim() ? null : "Nama kepala desa/lurah wajib diisi.",
|
||||
jabatan: (value: any) => (value.trim() ? null : "Jabatan wajib diisi."),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Handle file inputs and provide previews
|
||||
const handleTtdChange = (file: File | null) => {
|
||||
form.setFieldValue("pengesahan.tandaTangan", file as any);
|
||||
if (file) {
|
||||
const url = URL.createObjectURL(file);
|
||||
setTtdPreview(url);
|
||||
} else {
|
||||
setTtdPreview(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStempelChange = (file: File | null) => {
|
||||
form.setFieldValue("pengesahan.stempel", file as any);
|
||||
if (file) {
|
||||
const url = URL.createObjectURL(file);
|
||||
setStempelPreview(url);
|
||||
} else {
|
||||
setStempelPreview(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Simulate submit (replace with real API call)
|
||||
const handleSubmit = async (values: any) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Example: prepare form data for file upload
|
||||
const payload = new FormData();
|
||||
payload.append(
|
||||
"data",
|
||||
JSON.stringify({
|
||||
...values,
|
||||
tanggalPenerbitan:
|
||||
values.tanggalPenerbitan?.toISOString().slice(0, 10) ?? null,
|
||||
}),
|
||||
);
|
||||
if (values.pengesahan.tandaTangan) {
|
||||
payload.append("tandaTangan", values.pengesahan.tandaTangan);
|
||||
}
|
||||
if (values.pengesahan.stempel) {
|
||||
payload.append("stempel", values.pengesahan.stempel);
|
||||
}
|
||||
|
||||
// Replace the URL below with your API endpoint
|
||||
const res = await fetch("/api/surat/belum-kawin", {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error("Gagal mengirim data ke server.");
|
||||
|
||||
showNotification({
|
||||
title: "Sukses",
|
||||
message: "Surat berhasil diajukan / disimpan.",
|
||||
color: "green",
|
||||
icon: <IconCheck />,
|
||||
});
|
||||
|
||||
// optional: navigate back or clear form
|
||||
navigate("/darmasaba");
|
||||
form.reset();
|
||||
setTtdPreview(null);
|
||||
setStempelPreview(null);
|
||||
} catch (err: any) {
|
||||
showNotification({
|
||||
title: "Terjadi Kesalahan",
|
||||
message: err?.message || "Gagal mengirim data.",
|
||||
color: "red",
|
||||
icon: <IconX />,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="md" py="xl">
|
||||
<Stack gap="xl">
|
||||
<Group justify="apart" align="center">
|
||||
<Title order={2}>
|
||||
<Group gap="xs">
|
||||
<IconFileText />
|
||||
<span>Surat Keterangan Belum Kawin</span>
|
||||
</Group>
|
||||
</Title>
|
||||
</Group>
|
||||
|
||||
<Text color="dimmed">
|
||||
Blangko resmi untuk menyatakan bahwa seseorang belum pernah menikah.
|
||||
Lengkapi semua data lalu tekan <strong>Ajukan</strong>.
|
||||
</Text>
|
||||
|
||||
<Card withBorder radius="md" p="lg" aria-labelledby="instansi-heading">
|
||||
<Stack gap="sm">
|
||||
<Group justify="apart" align="center">
|
||||
<Title order={4} id="instansi-heading">
|
||||
<Group gap="xs">
|
||||
<IconBuildingStore />
|
||||
Instansi Penerbit
|
||||
</Group>
|
||||
</Title>
|
||||
</Group>
|
||||
<Divider />
|
||||
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
required
|
||||
label="Kabupaten / Kota"
|
||||
placeholder="Contoh: Kabupaten Badung"
|
||||
description="Nama Kabupaten/Kota penerbit surat"
|
||||
leftSection={<IconBadge />}
|
||||
{...form.getInputProps("instansiPenerbit.kabupatenKota")}
|
||||
aria-label="Kabupaten atau Kota"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
required
|
||||
label="Kecamatan"
|
||||
placeholder="Contoh: Kuta"
|
||||
description="Nama Kecamatan penerbit surat"
|
||||
leftSection={<IconMapPin />}
|
||||
{...form.getInputProps("instansiPenerbit.kecamatan")}
|
||||
aria-label="Kecamatan"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
required
|
||||
label="Desa / Kelurahan"
|
||||
placeholder="Contoh: Desa XYZ"
|
||||
description="Nama Desa atau Kelurahan penerbit"
|
||||
{...form.getInputProps("instansiPenerbit.desaKelurahan")}
|
||||
aria-label="Desa atau Kelurahan"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
required
|
||||
label="Nomor Surat"
|
||||
placeholder="Format: 123/ABC/2025"
|
||||
description="Nomor surat sesuai register desa/kelurahan"
|
||||
{...form.getInputProps("instansiPenerbit.nomorSurat")}
|
||||
aria-label="Nomor surat"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder radius="md" p="lg" aria-labelledby="pemohon-heading">
|
||||
<Stack gap="sm">
|
||||
<Group justify="apart" align="center">
|
||||
<Title order={4} id="pemohon-heading">
|
||||
<Group gap="xs">
|
||||
<IconUser />
|
||||
Data Pemohon
|
||||
</Group>
|
||||
</Title>
|
||||
</Group>
|
||||
<Divider />
|
||||
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
required
|
||||
label="Nama Lengkap"
|
||||
placeholder="Nama sesuai KTP"
|
||||
description="Masukkan nama lengkap seperti tertera di KTP"
|
||||
leftSection={<IconUser />}
|
||||
{...form.getInputProps("dataPemohon.namaLengkap")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
required
|
||||
label="NIK (16 digit)"
|
||||
placeholder="Contoh: 3272011201010001"
|
||||
description="Nomor Induk Kependudukan, 16 digit tanpa spasi"
|
||||
leftSection={<IconId />}
|
||||
{...form.getInputProps("dataPemohon.nik")}
|
||||
inputMode="numeric"
|
||||
maxLength={16}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
required
|
||||
label="Tempat, Tanggal Lahir"
|
||||
placeholder="Contoh: Denpasar, 01 Januari 1990"
|
||||
description="Masukkan tempat dan tanggal lahir"
|
||||
{...form.getInputProps("dataPemohon.tempatTanggalLahir")}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Agama"
|
||||
placeholder="Pilih agama"
|
||||
data={[
|
||||
"Islam",
|
||||
"Kristen",
|
||||
"Katolik",
|
||||
"Hindu",
|
||||
"Buddha",
|
||||
"Konghucu",
|
||||
"Lainnya",
|
||||
]}
|
||||
description="Agama sesuai identitas"
|
||||
{...form.getInputProps("dataPemohon.agama")}
|
||||
/>
|
||||
<RadioGroup
|
||||
label="Jenis Kelamin"
|
||||
{...form.getInputProps("dataPemohon.jenisKelamin")}
|
||||
>
|
||||
<Radio value="Laki-laki" label="Laki-laki" />
|
||||
<Radio value="Perempuan" label="Perempuan" />
|
||||
</RadioGroup>
|
||||
|
||||
<TextInput
|
||||
label="Pekerjaan"
|
||||
placeholder="Contoh: Petani / PNS / Swasta"
|
||||
{...form.getInputProps("dataPemohon.pekerjaan")}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
minRows={2}
|
||||
label="Alamat"
|
||||
placeholder="Alamat lengkap sesuai domisili"
|
||||
description="Contoh: Jl. Mawar No. 1 RT 01 RW 02"
|
||||
{...form.getInputProps("dataPemohon.alamat")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Status Perkawinan"
|
||||
description="Dalam surat ini nilai default harus 'Belum Kawin'"
|
||||
{...form.getInputProps("dataPemohon.statusPerkawinan")}
|
||||
readOnly
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder radius="md" p="lg" aria-labelledby="isi-heading">
|
||||
<Stack gap="sm">
|
||||
<Group justify="apart" align="center">
|
||||
<Title order={4} id="isi-heading">
|
||||
<Group gap="xs">
|
||||
<IconFileText />
|
||||
Isi Surat
|
||||
</Group>
|
||||
</Title>
|
||||
</Group>
|
||||
<Divider />
|
||||
|
||||
<Textarea
|
||||
label="Pernyataan"
|
||||
minRows={4}
|
||||
description="Teks pernyataan resmi (boleh diedit)."
|
||||
placeholder="Tulis pernyataan singkat yang menyatakan pemohon belum pernah menikah..."
|
||||
{...form.getInputProps("isiSurat.pernyataan")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Tujuan Penerbitan Surat"
|
||||
placeholder="Contoh: Untuk syarat pernikahan / beasiswa / administrasi"
|
||||
description="Jelaskan singkat tujuan pembuatan surat"
|
||||
{...form.getInputProps("isiSurat.tujuan")}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder radius="md" p="lg" aria-labelledby="tanggal-heading">
|
||||
<Stack gap="sm">
|
||||
<Group justify="apart" align="center">
|
||||
<Title order={4} id="tanggal-heading">
|
||||
<Group gap="xs">
|
||||
<IconCalendar />
|
||||
Tanggal Penerbitan
|
||||
</Group>
|
||||
</Title>
|
||||
</Group>
|
||||
<Divider />
|
||||
|
||||
<DatePicker
|
||||
{...form.getInputProps("tanggalPenerbitan")}
|
||||
aria-label="Tanggal penerbitan surat"
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
p="lg"
|
||||
aria-labelledby="pengesahan-heading"
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Group justify="apart" align="center">
|
||||
<Title order={4} id="pengesahan-heading">
|
||||
<Group gap="xs">
|
||||
<IconBadge />
|
||||
Pengesahan
|
||||
</Group>
|
||||
</Title>
|
||||
</Group>
|
||||
<Divider />
|
||||
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="Nama Kepala Desa / Lurah"
|
||||
placeholder="Nama pejabat yang menandatangani"
|
||||
description="Masukkan nama pejabat yang menandatangani"
|
||||
{...form.getInputProps("pengesahan.kepalaDesaLurah")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Jabatan"
|
||||
placeholder="Contoh: Kepala Desa"
|
||||
description="Jabatan pejabat yang mengesahkan"
|
||||
{...form.getInputProps("pengesahan.jabatan")}
|
||||
/>
|
||||
|
||||
<FileInputWrapper
|
||||
label="Tanda Tangan (scan)"
|
||||
placeholder="Upload file tanda tangan (jpg,png,pdf)"
|
||||
accept="image/*,application/pdf"
|
||||
onChange={handleTtdChange}
|
||||
preview={ttdPreview}
|
||||
name="pengesahan.tandaTangan"
|
||||
/>
|
||||
|
||||
<FileInputWrapper
|
||||
label="Stempel (scan)"
|
||||
placeholder="Upload file stempel (jpg,png,pdf)"
|
||||
accept="image/*,application/pdf"
|
||||
onChange={handleStempelChange}
|
||||
preview={stempelPreview}
|
||||
name="pengesahan.stempel"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Group justify="right" mt="md">
|
||||
<Tooltip
|
||||
label="Pastikan semua data sudah benar sebelum mengajukan"
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
leftSection={<IconUpload />}
|
||||
onClick={() =>
|
||||
form.validate() && form.isValid() && handleSubmit(form.values)
|
||||
}
|
||||
loading={loading}
|
||||
>
|
||||
Ajukan
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
setTtdPreview(null);
|
||||
setStempelPreview(null);
|
||||
}}
|
||||
disabled={loading}
|
||||
leftSection={<IconAlertCircle />}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Text size="sm" color="dimmed">
|
||||
Catatan: Dokumen yang diupload hanya akan dipakai untuk verifikasi
|
||||
administrasi. Pastikan file bersih dan terbaca.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Small wrapper component for file input + preview with accessible labels.
|
||||
* Kept inside the same file for simplicity — extract to components/ when reusing.
|
||||
*/
|
||||
function FileInputWrapper({
|
||||
label,
|
||||
placeholder,
|
||||
accept,
|
||||
onChange,
|
||||
preview,
|
||||
name,
|
||||
}: {
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
accept?: string;
|
||||
onChange: (file: File | null) => void;
|
||||
preview?: string | null;
|
||||
name: string;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group justify="apart" align="center">
|
||||
<Text fw={500}>{label}</Text>
|
||||
<Tooltip label="Upload scan yang jelas (jpg/png/pdf)">
|
||||
<IconFileText size={16} />
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<FileInput
|
||||
accept={accept}
|
||||
placeholder={placeholder}
|
||||
onChange={(f) => onChange(f)}
|
||||
leftSection={<IconUpload />}
|
||||
aria-label={label}
|
||||
name={name}
|
||||
/>
|
||||
|
||||
{preview ? (
|
||||
<div>
|
||||
<Text size="xs" color="dimmed">
|
||||
Preview:
|
||||
</Text>
|
||||
{/* If preview is an image it will show; pdf preview might not render as image */}
|
||||
{/* Use <object> or <img> depending on file type — keep simple here */}
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<img
|
||||
src={preview}
|
||||
alt={`${label} preview`}
|
||||
style={{ maxWidth: "200px", borderRadius: 4 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,677 @@
|
||||
import {
|
||||
Accordion,
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Divider,
|
||||
FileInput,
|
||||
Grid,
|
||||
Group,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { DatePicker } from "@mantine/dates";
|
||||
import { useForm } from "@mantine/form";
|
||||
import {
|
||||
IconBuildingStore,
|
||||
IconCalendar,
|
||||
IconCheck,
|
||||
IconFileUpload,
|
||||
IconInfoCircle,
|
||||
IconMapPin,
|
||||
IconShieldCheck,
|
||||
IconUser,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import React from "react";
|
||||
|
||||
/* ---------------------------
|
||||
Types (strong typing)
|
||||
--------------------------- */
|
||||
|
||||
type MasaBerlaku = {
|
||||
mulai: Date | null;
|
||||
sampai: Date | null;
|
||||
};
|
||||
|
||||
type Organisasi = {
|
||||
namaOrganisasi?: string;
|
||||
jenisOrganisasi?: string;
|
||||
bidangKegiatan?: string;
|
||||
aktePendirian?: string;
|
||||
npwp?: string;
|
||||
};
|
||||
|
||||
type AlamatDomisili = {
|
||||
alamatLengkap?: string;
|
||||
rt?: string;
|
||||
rw?: string;
|
||||
desaKelurahan?: string;
|
||||
kecamatan?: string;
|
||||
kabupatenKota?: string;
|
||||
provinsi?: string;
|
||||
kodePos?: string;
|
||||
};
|
||||
|
||||
type PenanggungJawab = {
|
||||
namaLengkap?: string;
|
||||
nik?: string;
|
||||
jabatan?: string;
|
||||
kontak?: string;
|
||||
};
|
||||
|
||||
type Pengesahan = {
|
||||
dikeluarkanDi?: string;
|
||||
tanggalDikeluarkan?: Date | null;
|
||||
lurahAtauCamat?: string;
|
||||
jabatanPejabat?: string;
|
||||
tandaTanganStempel?: File | null;
|
||||
};
|
||||
|
||||
export type SkdoFormValues = {
|
||||
nomorSurat?: string;
|
||||
organisasi: Organisasi;
|
||||
alamatDomisili: AlamatDomisili;
|
||||
penanggungJawab: PenanggungJawab;
|
||||
keperluan?: string;
|
||||
masaBerlaku: MasaBerlaku;
|
||||
pengesahan: Pengesahan;
|
||||
// extra helpers (e.g. draft toggle)
|
||||
isDraft?: boolean;
|
||||
};
|
||||
|
||||
/* ---------------------------
|
||||
Reusable smaller components
|
||||
--------------------------- */
|
||||
|
||||
/** Label with an info tooltip icon */
|
||||
function LabelWithInfo({
|
||||
label,
|
||||
info,
|
||||
Icon = IconInfoCircle,
|
||||
}: {
|
||||
label: string;
|
||||
info?: string;
|
||||
Icon?: React.FC<any>;
|
||||
}) {
|
||||
return (
|
||||
<Group gap="xs" justify="apart" align="center" style={{ width: "100%" }}>
|
||||
<Text fw={600} size="sm">
|
||||
{label}
|
||||
</Text>
|
||||
{info ? (
|
||||
<Tooltip label={info} withArrow>
|
||||
<ActionIcon aria-label={`${label} info`}>
|
||||
<Icon size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------
|
||||
Main Form Component
|
||||
--------------------------- */
|
||||
|
||||
export default function FormSuratKeteranganDomisiliOrganisasi() {
|
||||
// sensible defaults (smart defaults)
|
||||
const form = useForm<SkdoFormValues>({
|
||||
initialValues: {
|
||||
nomorSurat: "",
|
||||
organisasi: {
|
||||
namaOrganisasi: "",
|
||||
jenisOrganisasi: "",
|
||||
bidangKegiatan: "",
|
||||
aktePendirian: "",
|
||||
npwp: "",
|
||||
},
|
||||
alamatDomisili: {
|
||||
alamatLengkap: "",
|
||||
rt: "",
|
||||
rw: "",
|
||||
desaKelurahan: "",
|
||||
kecamatan: "",
|
||||
kabupatenKota: "",
|
||||
provinsi: "",
|
||||
kodePos: "",
|
||||
},
|
||||
penanggungJawab: {
|
||||
namaLengkap: "",
|
||||
nik: "",
|
||||
jabatan: "",
|
||||
kontak: "",
|
||||
},
|
||||
keperluan: "",
|
||||
masaBerlaku: {
|
||||
mulai: null,
|
||||
sampai: null,
|
||||
},
|
||||
pengesahan: {
|
||||
dikeluarkanDi: "",
|
||||
tanggalDikeluarkan: null,
|
||||
lurahAtauCamat: "",
|
||||
jabatanPejabat: "",
|
||||
tandaTanganStempel: null,
|
||||
},
|
||||
isDraft: false,
|
||||
},
|
||||
validate: {
|
||||
// nomorSurat optional, but validate length if filled
|
||||
nomorSurat: (value) =>
|
||||
value && value.length > 100 ? "Nomor surat terlalu panjang" : null,
|
||||
organisasi: {
|
||||
namaOrganisasi: (v: any) =>
|
||||
!v || v.trim().length === 0 ? "Nama organisasi wajib diisi" : null,
|
||||
// jenisOrganisasi optional but if "Lainnya" require additional explanation? (not in schema)
|
||||
} as any,
|
||||
penanggungJawab: {
|
||||
namaLengkap: (v: any) =>
|
||||
!v || v.trim().length === 0
|
||||
? "Nama penanggung jawab wajib diisi"
|
||||
: null,
|
||||
nik: (v: any) => {
|
||||
if (!v) return "NIK wajib diisi";
|
||||
const digits = v.replace(/\D/g, "");
|
||||
if (digits.length !== 16) return "NIK harus 16 digit";
|
||||
return null;
|
||||
},
|
||||
kontak: (v: any) => {
|
||||
if (!v) return "Kontak wajib diisi";
|
||||
if (!/^[\d+\-\s()]{6,20}$/.test(v))
|
||||
return "Masukkan nomor telepon/HP yang valid";
|
||||
return null;
|
||||
},
|
||||
} as any,
|
||||
keperluan: (v) =>
|
||||
!v || v.trim().length === 0 ? "Keperluan wajib diisi" : null,
|
||||
masaBerlaku: {
|
||||
mulai: (v: any) => (v === null ? "Tanggal mulai wajib diisi" : null),
|
||||
sampai: (v: any, values: any) => {
|
||||
if (v === null) return "Tanggal sampai wajib diisi";
|
||||
if (values.masaBerlaku.mulai && v < values.masaBerlaku.mulai)
|
||||
return "Tanggal sampai harus setelah atau sama dengan tanggal mulai";
|
||||
return null;
|
||||
},
|
||||
} as any,
|
||||
pengesahan: {
|
||||
tanggalDikeluarkan: (v: any) =>
|
||||
v === null ? "Tanggal dikeluarkan wajib diisi" : null,
|
||||
lurahAtauCamat: (v: any) => (!v ? "Nama pejabat wajib diisi" : null),
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
|
||||
/* ---------------------------
|
||||
Submit & Reset handlers
|
||||
--------------------------- */
|
||||
|
||||
const handleSubmit = (values: SkdoFormValues) => {
|
||||
// In a real app: send to API, show toast, handle file upload, etc.
|
||||
// Here we simply log the values (and convert File to name).
|
||||
const sanitized = {
|
||||
...values,
|
||||
pengesahan: {
|
||||
...values.pengesahan,
|
||||
tandaTanganStempel: values.pengesahan.tandaTanganStempel
|
||||
? (values.pengesahan.tandaTanganStempel as File).name
|
||||
: null,
|
||||
},
|
||||
};
|
||||
// Simple success UX: console + accessible focus
|
||||
// Replace with notifications/toasts in production
|
||||
|
||||
console.log("SKDO Submitted:", sanitized);
|
||||
// accessible focus to top message (not implemented here) or show UI feedback
|
||||
alert("Form submitted — lihat console untuk data (demo)");
|
||||
};
|
||||
|
||||
const handleReset = () => form.reset();
|
||||
|
||||
/* ---------------------------
|
||||
UI Layout
|
||||
--------------------------- */
|
||||
|
||||
return (
|
||||
<Container size="md" w="100%">
|
||||
<Card radius="md" p="lg" withBorder>
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<Group justify="apart" align="center" gap="sm">
|
||||
<Group align="center" gap="sm">
|
||||
<IconShieldCheck size={28} />
|
||||
<Box>
|
||||
<Text fw={700} size="lg">
|
||||
Surat Keterangan Domisili Organisasi (SKDO)
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Blangko resmi untuk permohonan pembuatan Surat Keterangan
|
||||
Domisili Organisasi.
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
<Switch
|
||||
aria-label="Save as draft"
|
||||
label="Simpan sebagai draft"
|
||||
checked={form.values.isDraft}
|
||||
onChange={(e) =>
|
||||
form.setFieldValue("isDraft", e.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Top-level basic fields */}
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<Stack gap="xs">
|
||||
<LabelWithInfo
|
||||
label="Nomor Surat"
|
||||
info="Nomor surat (diisi oleh kantor kelurahan/kecamatan jika ada)."
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="e.g. 123/SKDO/2025"
|
||||
{...form.getInputProps("nomorSurat")}
|
||||
aria-label="Nomor Surat"
|
||||
/>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Stack gap="xs">
|
||||
<LabelWithInfo
|
||||
label="Keperluan"
|
||||
info="Tujuan pembuatan surat domisili."
|
||||
/>
|
||||
<Textarea
|
||||
placeholder="Contoh: Pengajuan izin operasional / Pendaftaran NPWP / Pembukaan rekening bank"
|
||||
minRows={2}
|
||||
{...form.getInputProps("keperluan")}
|
||||
aria-label="Keperluan"
|
||||
/>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{/* Organisasi section */}
|
||||
<Accordion variant="contained" chevronPosition="right" multiple>
|
||||
<Accordion.Item value="organisasi">
|
||||
<Accordion.Control icon={<IconBuildingStore />}>
|
||||
Data Organisasi
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Grid>
|
||||
<Grid.Col span={12}>
|
||||
<Stack gap="xs">
|
||||
<LabelWithInfo
|
||||
label="Nama Organisasi"
|
||||
info="Nama lengkap organisasi/lembaga."
|
||||
Icon={IconBuildingStore}
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="Nama organisasi"
|
||||
{...form.getInputProps("organisasi.namaOrganisasi")}
|
||||
aria-label="Nama Organisasi"
|
||||
/>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<LabelWithInfo
|
||||
label="Jenis Organisasi"
|
||||
info="Pilih jenis organisasi."
|
||||
/>
|
||||
<Select
|
||||
placeholder="Pilih jenis organisasi"
|
||||
data={[
|
||||
"Yayasan",
|
||||
"Perkumpulan",
|
||||
"Lembaga Sosial",
|
||||
"Organisasi Keagamaan",
|
||||
"Komunitas",
|
||||
"Lainnya",
|
||||
]}
|
||||
{...form.getInputProps("organisasi.jenisOrganisasi")}
|
||||
aria-label="Jenis Organisasi"
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<LabelWithInfo
|
||||
label="Bidang Kegiatan"
|
||||
info="Contoh: sosial, pendidikan, lingkungan, olahraga."
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="Bidang kegiatan"
|
||||
{...form.getInputProps("organisasi.bidangKegiatan")}
|
||||
aria-label="Bidang Kegiatan"
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<LabelWithInfo
|
||||
label="Nomor Akta Pendirian"
|
||||
info="Jika ada."
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="Nomor akta (opsional)"
|
||||
{...form.getInputProps("organisasi.aktePendirian")}
|
||||
aria-label="Akte Pendirian"
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<LabelWithInfo label="NPWP Organisasi" info="Jika ada." />
|
||||
<TextInput
|
||||
placeholder="NPWP (opsional)"
|
||||
{...form.getInputProps("organisasi.npwp")}
|
||||
aria-label="NPWP Organisasi"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
{/* Alamat Domisili */}
|
||||
<Accordion.Item value="alamat">
|
||||
<Accordion.Control icon={<IconMapPin />}>
|
||||
Alamat Domisili
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Grid>
|
||||
<Grid.Col span={12}>
|
||||
<LabelWithInfo
|
||||
label="Alamat Lengkap"
|
||||
info="Alamat tempat organisasi berdomisili."
|
||||
/>
|
||||
<Textarea
|
||||
minRows={2}
|
||||
placeholder="Jalan, nomor gedung, blok, dsb."
|
||||
{...form.getInputProps("alamatDomisili.alamatLengkap")}
|
||||
aria-label="Alamat Lengkap"
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={2}>
|
||||
<Text fw={700} size="sm">
|
||||
RT
|
||||
</Text>
|
||||
<TextInput
|
||||
placeholder="001"
|
||||
{...form.getInputProps("alamatDomisili.rt")}
|
||||
aria-label="RT"
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={2}>
|
||||
<Text fw={700} size="sm">
|
||||
RW
|
||||
</Text>
|
||||
<TextInput
|
||||
placeholder="002"
|
||||
{...form.getInputProps("alamatDomisili.rw")}
|
||||
aria-label="RW"
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<LabelWithInfo label="Desa / Kelurahan" />
|
||||
<TextInput
|
||||
placeholder="Nama desa/kelurahan"
|
||||
{...form.getInputProps("alamatDomisili.desaKelurahan")}
|
||||
aria-label="Desa Kelurahan"
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<LabelWithInfo label="Kecamatan" />
|
||||
<TextInput
|
||||
placeholder="Nama kecamatan"
|
||||
{...form.getInputProps("alamatDomisili.kecamatan")}
|
||||
aria-label="Kecamatan"
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<LabelWithInfo label="Kabupaten / Kota" />
|
||||
<TextInput
|
||||
placeholder="Nama kabupaten/kota"
|
||||
{...form.getInputProps("alamatDomisili.kabupatenKota")}
|
||||
aria-label="Kabupaten Kota"
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<LabelWithInfo label="Provinsi" />
|
||||
<TextInput
|
||||
placeholder="Nama provinsi"
|
||||
{...form.getInputProps("alamatDomisili.provinsi")}
|
||||
aria-label="Provinsi"
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={2}>
|
||||
<LabelWithInfo label="Kode Pos" />
|
||||
<TextInput
|
||||
placeholder="Kode pos"
|
||||
{...form.getInputProps("alamatDomisili.kodePos")}
|
||||
aria-label="Kode Pos"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
{/* Penanggung Jawab */}
|
||||
<Accordion.Item value="penanggungJawab">
|
||||
<Accordion.Control icon={<IconUser />}>
|
||||
Penanggung Jawab
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<LabelWithInfo
|
||||
label="Nama Lengkap"
|
||||
info="Nama lengkap ketua/penanggung jawab organisasi."
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="Nama lengkap"
|
||||
{...form.getInputProps("penanggungJawab.namaLengkap")}
|
||||
aria-label="Nama Penanggung Jawab"
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<LabelWithInfo
|
||||
label="NIK"
|
||||
info="16 digit Nomor Induk Kependudukan"
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="16 digit NIK"
|
||||
{...form.getInputProps("penanggungJawab.nik")}
|
||||
aria-label="NIK"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<LabelWithInfo label="Jabatan" />
|
||||
<TextInput
|
||||
placeholder="Ketua / Sekretaris / Direktur"
|
||||
{...form.getInputProps("penanggungJawab.jabatan")}
|
||||
aria-label="Jabatan"
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<LabelWithInfo
|
||||
label="Kontak"
|
||||
info="Nomor telepon/HP penanggung jawab"
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="+62 812-3456-7890"
|
||||
{...form.getInputProps("penanggungJawab.kontak")}
|
||||
aria-label="Kontak"
|
||||
inputMode="tel"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
{/* Masa Berlaku */}
|
||||
<Accordion.Item value="masa">
|
||||
<Accordion.Control icon={<IconCalendar />}>
|
||||
Masa Berlaku
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<LabelWithInfo
|
||||
label="Mulai"
|
||||
info="Tanggal mulai berlaku."
|
||||
Icon={IconCalendar}
|
||||
/>
|
||||
<DatePicker
|
||||
{...form.getInputProps("masaBerlaku.mulai")}
|
||||
aria-label="Tanggal Mulai"
|
||||
style={{
|
||||
"&::after": {
|
||||
content: " *",
|
||||
color: "red",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<LabelWithInfo
|
||||
label="Sampai"
|
||||
info="Tanggal berakhir berlaku."
|
||||
Icon={IconCalendar}
|
||||
/>
|
||||
<DatePicker
|
||||
{...form.getInputProps("masaBerlaku.sampai")}
|
||||
aria-label="Tanggal Sampai"
|
||||
style={{
|
||||
"&::after": {
|
||||
content: " *",
|
||||
color: "red",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
{/* Pengesahan */}
|
||||
<Accordion.Item value="pengesahan">
|
||||
<Accordion.Control icon={<IconShieldCheck />}>
|
||||
Pengesahan
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<LabelWithInfo label="Dikeluarkan di" />
|
||||
<TextInput
|
||||
placeholder="Nama kota/kabupaten"
|
||||
{...form.getInputProps("pengesahan.dikeluarkanDi")}
|
||||
aria-label="Dikeluarkan Di"
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<LabelWithInfo label="Tanggal Dikeluarkan" />
|
||||
<DatePicker
|
||||
{...form.getInputProps("pengesahan.tanggalDikeluarkan")}
|
||||
aria-label="Tanggal Dikeluarkan"
|
||||
style={{
|
||||
"&::after": {
|
||||
content: " *",
|
||||
color: "red",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<LabelWithInfo label="Nama Pejabat (Lurah/Camat)" />
|
||||
<TextInput
|
||||
placeholder="Nama pejabat penandatangan"
|
||||
{...form.getInputProps("pengesahan.lurahAtauCamat")}
|
||||
aria-label="Nama Pejabat"
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<LabelWithInfo label="Jabatan Pejabat" />
|
||||
<TextInput
|
||||
placeholder="Jabatan pejabat (mis. Lurah Kelurahan Sukamaju)"
|
||||
{...form.getInputProps("pengesahan.jabatanPejabat")}
|
||||
aria-label="Jabatan Pejabat"
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={12}>
|
||||
<LabelWithInfo
|
||||
label="Tanda Tangan & Stempel (scan)"
|
||||
info="Upload file scan tanda tangan dan stempel resmi."
|
||||
Icon={IconFileUpload}
|
||||
/>
|
||||
<FileInput
|
||||
placeholder="Pilih file (jpg, png, pdf)"
|
||||
{...form.getInputProps("pengesahan.tandaTanganStempel")}
|
||||
accept="image/*,application/pdf"
|
||||
leftSection={<IconFileUpload size={16} />}
|
||||
aria-label="Tanda Tangan Stempel"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Action buttons */}
|
||||
<Group justify="right" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleReset}
|
||||
leftSection={<IconX />}
|
||||
aria-label="Reset form"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => form.onSubmit(handleSubmit)}
|
||||
leftSection={<IconCheck />}
|
||||
aria-label="Submit form"
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Minimal accessibility & developer hints */}
|
||||
<Text size="xs" c="dimmed">
|
||||
Tip: Gunakan Tab / Shift+Tab untuk navigasi keyboard. Semua input
|
||||
memiliki label yang dapat dibaca screen reader.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
531
src/pages/darmasaba/form_surat_keterangan_kelakuan_baik.tsx
Normal file
531
src/pages/darmasaba/form_surat_keterangan_kelakuan_baik.tsx
Normal file
@@ -0,0 +1,531 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// pages/surat-keterangan-kelakuan-baik/page.tsx
|
||||
// Single-file Next.js App Router page implementing the form described by the JSON schema.
|
||||
// Also contains TypeScript interfaces and some small reusable components/hooks for clarity.
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import {
|
||||
Container,
|
||||
Card,
|
||||
Title,
|
||||
Text,
|
||||
Divider,
|
||||
Stack,
|
||||
Group,
|
||||
SimpleGrid,
|
||||
TextInput,
|
||||
Select,
|
||||
Textarea,
|
||||
Button,
|
||||
Badge,
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { DatePicker } from "@mantine/dates";
|
||||
import { showNotification } from "@mantine/notifications";
|
||||
import {
|
||||
IconUser,
|
||||
IconMapPin,
|
||||
IconCalendar,
|
||||
IconFileText,
|
||||
IconBuildingBank,
|
||||
IconChecklist,
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconCards,
|
||||
IconFile,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
// ----------------------
|
||||
// TypeScript interfaces
|
||||
// ----------------------
|
||||
|
||||
export interface Instansi {
|
||||
kabupatenKota: string;
|
||||
kecamatan: string;
|
||||
desaKelurahan: string;
|
||||
}
|
||||
|
||||
export type JenisKelamin = "Laki-laki" | "Perempuan";
|
||||
export type Agama =
|
||||
| "Islam"
|
||||
| "Kristen"
|
||||
| "Katolik"
|
||||
| "Hindu"
|
||||
| "Buddha"
|
||||
| "Konghucu"
|
||||
| "Lainnya";
|
||||
export type StatusPerkawinan =
|
||||
| "Belum Kawin"
|
||||
| "Kawin"
|
||||
| "Cerai Hidup"
|
||||
| "Cerai Mati";
|
||||
|
||||
export interface DataPemohon {
|
||||
namaLengkap: string;
|
||||
nik: string; // expected 16 digits
|
||||
tempatTanggalLahir: string;
|
||||
jenisKelamin: JenisKelamin;
|
||||
agama: Agama;
|
||||
statusPerkawinan: StatusPerkawinan;
|
||||
pekerjaan: string;
|
||||
alamat: string;
|
||||
}
|
||||
|
||||
export interface Pengesahan {
|
||||
tempatTerbit: string;
|
||||
tanggalTerbit: string; // ISO date
|
||||
kepalaDesaLurah: string;
|
||||
jabatan: string;
|
||||
tandaTanganCap: string;
|
||||
}
|
||||
|
||||
export interface SKCKPengantarForm {
|
||||
formTitle: string;
|
||||
description?: string;
|
||||
instansi: Instansi;
|
||||
nomorSurat: string;
|
||||
dataPemohon: DataPemohon;
|
||||
keterangan: string;
|
||||
keperluan: string;
|
||||
berlakuHingga: string; // ISO date
|
||||
pengesahan: Pengesahan;
|
||||
}
|
||||
|
||||
// ----------------------
|
||||
// Helper validation
|
||||
// ----------------------
|
||||
|
||||
function isValidNIK(nik: string) {
|
||||
// strict: 16 digits numeric
|
||||
return /^\d{16}$/.test(nik);
|
||||
}
|
||||
|
||||
// ----------------------
|
||||
// Reusable small component
|
||||
// ----------------------
|
||||
|
||||
function SectionCard({
|
||||
title,
|
||||
icon,
|
||||
children,
|
||||
description,
|
||||
}: {
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card shadow="sm" radius="md" withBorder aria-labelledby={title}>
|
||||
<Group justify="apart" align="center" style={{ marginBottom: 10 }}>
|
||||
<Group>
|
||||
{icon}
|
||||
<div>
|
||||
<Title order={4} id={title} style={{ lineHeight: 1 }}>
|
||||
{title}
|
||||
</Title>
|
||||
{description && (
|
||||
<Text size="sm" color="dimmed">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
</Group>
|
||||
<Divider my="sm" />
|
||||
<div>{children}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------
|
||||
// Main Page Component
|
||||
// ----------------------
|
||||
|
||||
export default function FormSuratKeteranganKelakuanBaik() {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const form = useForm<any>({
|
||||
initialValues: {
|
||||
formTitle: "Surat Keterangan Kelakuan Baik (Pengantar SKCK)",
|
||||
description:
|
||||
"Blangko resmi dari Kelurahan/Desa sebagai pengantar untuk pembuatan SKCK di Kepolisian.",
|
||||
instansi: {
|
||||
kabupatenKota: "",
|
||||
kecamatan: "",
|
||||
desaKelurahan: "",
|
||||
},
|
||||
nomorSurat: "",
|
||||
dataPemohon: {
|
||||
namaLengkap: "",
|
||||
nik: "",
|
||||
tempatTanggalLahir: "",
|
||||
jenisKelamin: "Laki-laki",
|
||||
agama: "Islam",
|
||||
statusPerkawinan: "Belum Kawin",
|
||||
pekerjaan: "",
|
||||
alamat: "",
|
||||
},
|
||||
keterangan: "",
|
||||
keperluan: "",
|
||||
berlakuHingga: new Date().toISOString().slice(0, 10),
|
||||
pengesahan: {
|
||||
tempatTerbit: "",
|
||||
tanggalTerbit: new Date().toISOString().slice(0, 10),
|
||||
kepalaDesaLurah: "",
|
||||
jabatan: "",
|
||||
tandaTanganCap: "",
|
||||
},
|
||||
},
|
||||
|
||||
validate: {
|
||||
// top-level validations
|
||||
nomorSurat: (value) =>
|
||||
value.trim().length === 0 ? "Nomor surat wajib diisi" : null,
|
||||
keperluan: (value) =>
|
||||
value.trim().length === 0 ? "Keperluan wajib diisi" : null,
|
||||
keterangan: (value) =>
|
||||
value.trim().length === 0 ? "Keterangan wajib diisi" : null,
|
||||
|
||||
"dataPemohon.namaLengkap": (value: string) =>
|
||||
!value || value.trim().length < 3
|
||||
? "Nama lengkap minimal 3 karakter"
|
||||
: null,
|
||||
"dataPemohon.nik": (value: string) =>
|
||||
!isValidNIK(value) ? "NIK harus 16 digit angka tanpa spasi" : null,
|
||||
"dataPemohon.tempatTanggalLahir": (value: string) =>
|
||||
!value ? "Tempat dan tanggal lahir wajib diisi" : null,
|
||||
"dataPemohon.pekerjaan": (value: string) =>
|
||||
!value ? "Pekerjaan wajib diisi" : null,
|
||||
"dataPemohon.alamat": (value: string) =>
|
||||
!value ? "Alamat wajib diisi" : null,
|
||||
|
||||
"pengesahan.tempatTerbit": (value: string) =>
|
||||
!value ? "Tempat terbit wajib diisi" : null,
|
||||
"pengesahan.tanggalTerbit": (value: string) =>
|
||||
!value ? "Tanggal terbit wajib diisi" : null,
|
||||
"pengesahan.kepalaDesaLurah": (value: string) =>
|
||||
!value ? "Nama penandatangan wajib diisi" : null,
|
||||
},
|
||||
});
|
||||
|
||||
const agamaOptions = useMemo(
|
||||
() => [
|
||||
"Islam",
|
||||
"Kristen",
|
||||
"Katolik",
|
||||
"Hindu",
|
||||
"Buddha",
|
||||
"Konghucu",
|
||||
"Lainnya",
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const statusOptions = useMemo(
|
||||
() => ["Belum Kawin", "Kawin", "Cerai Hidup", "Cerai Mati"],
|
||||
[],
|
||||
);
|
||||
|
||||
async function handleSubmit() {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// simulate API call - replace with your actual endpoint
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
|
||||
showNotification({
|
||||
title: "Berhasil",
|
||||
message: "Surat pengantar berhasil disimpan.",
|
||||
icon: <IconCheck size={18} />,
|
||||
color: "green",
|
||||
});
|
||||
|
||||
// Optionally redirect or reset form
|
||||
// router.push('/surat/list')
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
showNotification({
|
||||
title: "Gagal",
|
||||
message: "Terjadi kesalahan saat menyimpan. Coba lagi.",
|
||||
icon: <IconX size={18} />,
|
||||
color: "red",
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<Stack gap="lg">
|
||||
<Group justify="apart" align="center">
|
||||
<div>
|
||||
<Title order={2}>
|
||||
Surat Keterangan Kelakuan Baik (Pengantar SKCK)
|
||||
</Title>
|
||||
<Text color="dimmed">
|
||||
Blangko resmi dari Kelurahan/Desa sebagai pengantar pembuatan SKCK
|
||||
di Kepolisian.
|
||||
</Text>
|
||||
</div>
|
||||
<Badge variant="outline" radius="sm">
|
||||
Formulir Resmi
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<form
|
||||
onSubmit={form.onSubmit(() => handleSubmit())}
|
||||
aria-label="Formulir Pengantar SKCK"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={3} spacing="md">
|
||||
<SectionCard
|
||||
title="Identitas Instansi"
|
||||
icon={<IconBuildingBank size={28} />}
|
||||
description="Informasi penerbit (kelurahan/desa)"
|
||||
>
|
||||
<SimpleGrid cols={3} spacing="sm">
|
||||
<TextInput
|
||||
label="Kabupaten / Kota"
|
||||
placeholder="Contoh: Badung"
|
||||
leftSection={<IconMapPin size={16} />}
|
||||
required
|
||||
{...form.getInputProps("instansi.kabupatenKota")}
|
||||
aria-label="kabupaten-kota"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Kecamatan"
|
||||
placeholder="Contoh: Kuta"
|
||||
leftSection={<IconMapPin size={16} />}
|
||||
required
|
||||
{...form.getInputProps("instansi.kecamatan")}
|
||||
aria-label="kecamatan"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Desa / Kelurahan"
|
||||
placeholder="Contoh: Tuban"
|
||||
leftSection={<IconMapPin size={16} />}
|
||||
required
|
||||
{...form.getInputProps("instansi.desaKelurahan")}
|
||||
aria-label="desa-kelurahan"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Nomor & Masa Berlaku"
|
||||
icon={<IconFileText size={28} />}
|
||||
description="Nomor registrasi surat dan tanggal masa berlaku"
|
||||
>
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<TextInput
|
||||
label="Nomor Surat"
|
||||
placeholder="2025/KT-001/123"
|
||||
leftSection={<IconFileText size={16} />}
|
||||
required
|
||||
{...form.getInputProps("nomorSurat")}
|
||||
aria-label="nomor-surat"
|
||||
/>
|
||||
|
||||
<DatePicker
|
||||
{...form.getInputProps("berlakuHingga")}
|
||||
value={
|
||||
form.values.berlakuHingga
|
||||
? new Date(form.values.berlakuHingga)
|
||||
: null
|
||||
}
|
||||
onChange={(d) =>
|
||||
form.setFieldValue("berlakuHingga", d as any)
|
||||
}
|
||||
aria-label="berlaku-hingga"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Data Pemohon"
|
||||
icon={<IconUser size={28} />}
|
||||
description="Data pemohon sesuai KTP"
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<SimpleGrid cols={2}>
|
||||
<TextInput
|
||||
label="Nama Lengkap"
|
||||
placeholder="Nama sesuai KTP"
|
||||
required
|
||||
{...form.getInputProps("dataPemohon.namaLengkap")}
|
||||
leftSection={<IconUser size={16} />}
|
||||
aria-label="nama-lengkap"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="NIK (16 digit)"
|
||||
placeholder="Contoh: 3574xxxxxxxxxxxx"
|
||||
required
|
||||
{...form.getInputProps("dataPemohon.nik")}
|
||||
leftSection={<IconCards size={16} />}
|
||||
aria-label="nik"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={2} spacing={"md"}>
|
||||
<TextInput
|
||||
label="Tempat, Tanggal Lahir"
|
||||
placeholder="Contoh: Denpasar, 01 Januari 1990"
|
||||
required
|
||||
{...form.getInputProps("dataPemohon.tempatTanggalLahir")}
|
||||
leftSection={<IconCalendar size={16} />}
|
||||
aria-label="tempat-tanggal-lahir"
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Jenis Kelamin"
|
||||
data={[
|
||||
{ value: "Laki-laki", label: "Laki-laki" },
|
||||
{ value: "Perempuan", label: "Perempuan" },
|
||||
]}
|
||||
{...form.getInputProps("dataPemohon.jenisKelamin")}
|
||||
aria-label="jenis-kelamin"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={3} spacing={"md"}>
|
||||
<Select
|
||||
label="Agama"
|
||||
data={agamaOptions.map((a) => ({ value: a, label: a }))}
|
||||
{...form.getInputProps("dataPemohon.agama")}
|
||||
aria-label="agama"
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Status Perkawinan"
|
||||
data={statusOptions.map((s) => ({ value: s, label: s }))}
|
||||
{...form.getInputProps("dataPemohon.statusPerkawinan")}
|
||||
aria-label="status-perkawinan"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Pekerjaan"
|
||||
placeholder="Contoh: Buruh, PNS, Wiraswasta"
|
||||
{...form.getInputProps("dataPemohon.pekerjaan")}
|
||||
aria-label="pekerjaan"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Textarea
|
||||
label="Alamat Domisili"
|
||||
placeholder="Alamat lengkap sesuai KTP"
|
||||
autosize
|
||||
minRows={2}
|
||||
{...form.getInputProps("dataPemohon.alamat")}
|
||||
aria-label="alamat"
|
||||
/>
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Keterangan & Keperluan"
|
||||
icon={<IconChecklist size={28} />}
|
||||
description="Pernyataan resmi kelurahan/desa"
|
||||
>
|
||||
<Stack>
|
||||
<Textarea
|
||||
label="Keterangan"
|
||||
placeholder="Contoh: Pemohon berkelakuan baik..."
|
||||
minRows={3}
|
||||
required
|
||||
{...form.getInputProps("keterangan")}
|
||||
leftSection={<IconFileText size={16} />}
|
||||
aria-label="keterangan"
|
||||
/>
|
||||
<TextInput
|
||||
label="Keperluan"
|
||||
placeholder="Contoh: Melamar pekerjaan di PT. X"
|
||||
required
|
||||
{...form.getInputProps("keperluan")}
|
||||
aria-label="keperluan"
|
||||
/>
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Pengesahan & Penandatangan"
|
||||
icon={<IconFile size={28} />}
|
||||
description="Informasi pejabat yang menandatangani dan cap instansi"
|
||||
>
|
||||
<SimpleGrid cols={2} spacing={"md"}>
|
||||
<TextInput
|
||||
label="Tempat Terbit"
|
||||
placeholder="Contoh: Kuta"
|
||||
{...form.getInputProps("pengesahan.tempatTerbit")}
|
||||
aria-label="tempat-terbit"
|
||||
/>
|
||||
|
||||
<DatePicker
|
||||
value={
|
||||
form.values.pengesahan.tanggalTerbit
|
||||
? new Date(form.values.pengesahan.tanggalTerbit)
|
||||
: null
|
||||
}
|
||||
{...form.getInputProps("pengesahan.tanggalTerbit")}
|
||||
aria-label="tanggal-terbit"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={2} spacing={"md"}>
|
||||
<TextInput
|
||||
label="Nama Kepala Desa / Lurah"
|
||||
placeholder="Nama penandatangan"
|
||||
{...form.getInputProps("pengesahan.kepalaDesaLurah")}
|
||||
aria-label="kepala-desa"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Jabatan"
|
||||
placeholder="Contoh: Kepala Desa"
|
||||
{...form.getInputProps("pengesahan.jabatan")}
|
||||
aria-label="jabatan"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Textarea
|
||||
label="Tanda Tangan & Cap (keterangan)"
|
||||
placeholder="Contoh: Tanda tangan basah, cap stempel instansi"
|
||||
minRows={2}
|
||||
{...form.getInputProps("pengesahan.tandaTanganCap")}
|
||||
aria-label="tanda-tangan-cap"
|
||||
/>
|
||||
</SectionCard>
|
||||
</SimpleGrid>
|
||||
|
||||
<Group justify="right" mt="md">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => form.reset()}
|
||||
leftSection={<IconX size={16} />}
|
||||
aria-label="reset-form"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
leftSection={<IconChecklist size={16} />}
|
||||
loading={submitting}
|
||||
aria-label="submit-form"
|
||||
>
|
||||
Simpan & Cetak
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
<Text size="xs" color="dimmed">
|
||||
Pastikan semua data sesuai dokumen resmi. SKCK biasanya berlaku 3
|
||||
bulan sejak diterbitkan.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
622
src/pages/darmasaba/form_surat_keterangan_penghasilan.tsx
Normal file
622
src/pages/darmasaba/form_surat_keterangan_penghasilan.tsx
Normal file
@@ -0,0 +1,622 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
Accordion,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Divider,
|
||||
Grid,
|
||||
Group,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { DatePicker } from "@mantine/dates";
|
||||
import { useForm } from "@mantine/form";
|
||||
import {
|
||||
IconBuildingStore,
|
||||
IconCalendarEvent,
|
||||
IconCheck,
|
||||
IconCurrencyDollar,
|
||||
IconFileText,
|
||||
IconInfoCircle,
|
||||
IconRefresh,
|
||||
IconUser,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
// -----------------------------
|
||||
// Types that mirror the JSON schema
|
||||
// -----------------------------
|
||||
|
||||
type Currency = "IDR" | string;
|
||||
|
||||
interface IncomeValue {
|
||||
value: number;
|
||||
currency: Currency;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface Issuer {
|
||||
name: string;
|
||||
position: string;
|
||||
company: string;
|
||||
address: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface Employee {
|
||||
fullName: string;
|
||||
nik: string;
|
||||
placeOfBirth: string;
|
||||
dateOfBirth?: Date | null;
|
||||
position: string;
|
||||
employmentStatus: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface Validity {
|
||||
startDate?: Date | null;
|
||||
endDate?: Date | null;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface Signatory {
|
||||
name: string;
|
||||
position: string;
|
||||
signature: string; // could be base64, url, or a typed name
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface CertificateFormSchema {
|
||||
documentType: string;
|
||||
documentNumber: { value: string; description?: string };
|
||||
issuer: Issuer;
|
||||
employee: Employee;
|
||||
incomeDetails: {
|
||||
basicSalary: IncomeValue;
|
||||
allowances: IncomeValue;
|
||||
deductions: IncomeValue;
|
||||
netIncome: IncomeValue;
|
||||
};
|
||||
validity: Validity;
|
||||
purpose: { value: string; description?: string };
|
||||
signatory: Signatory;
|
||||
issueDate?: Date | null;
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Reusable small components
|
||||
// -----------------------------
|
||||
|
||||
function SectionTitle({
|
||||
title,
|
||||
icon: Icon,
|
||||
subtitle,
|
||||
}: {
|
||||
title: string;
|
||||
icon?: any;
|
||||
subtitle?: string;
|
||||
}) {
|
||||
return (
|
||||
<Group justify="apart" style={{ width: "100%", marginBottom: 8 }}>
|
||||
<Group>
|
||||
{Icon && <Icon size={18} />}
|
||||
<div>
|
||||
<Title order={5} style={{ margin: 0 }}>
|
||||
{title}
|
||||
</Title>
|
||||
{subtitle && (
|
||||
<Text size="xs" color="dimmed">
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
// Single field renderer that maps JSON-like field meta to Mantine controls
|
||||
function FormField({
|
||||
label,
|
||||
description,
|
||||
children,
|
||||
leftIcon,
|
||||
}: {
|
||||
label: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
leftIcon?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap="md" style={{ width: "100%" }}>
|
||||
<Group gap="md" align="flex-start">
|
||||
{leftIcon}
|
||||
<Text fw={600}>{label}</Text>
|
||||
</Group>
|
||||
<div>{children}</div>
|
||||
{description && (
|
||||
<Text size="xs" color="dimmed">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Main form component
|
||||
// -----------------------------
|
||||
|
||||
export default function SuratKeteranganPenghasilan() {
|
||||
// default values: smart defaults (some example data to guide user)
|
||||
const form = useForm<CertificateFormSchema>({
|
||||
initialValues: {
|
||||
documentType: "Surat Keterangan Penghasilan",
|
||||
documentNumber: {
|
||||
value: "",
|
||||
description:
|
||||
"Nomor surat keterangan penghasilan yang dikeluarkan oleh instansi/perusahaan.",
|
||||
},
|
||||
issuer: {
|
||||
name: "",
|
||||
position: "",
|
||||
company: "",
|
||||
address: "",
|
||||
description:
|
||||
"Data pihak yang mengeluarkan surat keterangan penghasilan, biasanya HRD/atasan langsung/perusahaan.",
|
||||
},
|
||||
employee: {
|
||||
fullName: "",
|
||||
nik: "",
|
||||
placeOfBirth: "",
|
||||
dateOfBirth: null,
|
||||
position: "",
|
||||
employmentStatus: "Karyawan Tetap",
|
||||
description: "Data karyawan/pegawai yang bersangkutan.",
|
||||
},
|
||||
incomeDetails: {
|
||||
basicSalary: {
|
||||
value: 0,
|
||||
currency: "IDR",
|
||||
description: "Gaji pokok per bulan.",
|
||||
},
|
||||
allowances: {
|
||||
value: 0,
|
||||
currency: "IDR",
|
||||
description:
|
||||
"Tunjangan-tunjangan tetap (transport, makan, jabatan, dll).",
|
||||
},
|
||||
deductions: {
|
||||
value: 0,
|
||||
currency: "IDR",
|
||||
description: "Potongan gaji bulanan (BPJS, pajak, koperasi, dll).",
|
||||
},
|
||||
netIncome: {
|
||||
value: 0,
|
||||
currency: "IDR",
|
||||
description: "Total penghasilan bersih per bulan setelah potongan.",
|
||||
},
|
||||
},
|
||||
validity: {
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
description: "Masa berlaku surat keterangan penghasilan.",
|
||||
},
|
||||
purpose: {
|
||||
value: "",
|
||||
description:
|
||||
"Tujuan diterbitkannya surat keterangan penghasilan (contoh: pengajuan KPR, kredit, beasiswa, dll).",
|
||||
},
|
||||
signatory: {
|
||||
name: "",
|
||||
position: "",
|
||||
signature: "",
|
||||
description:
|
||||
"Pihak yang menandatangani surat resmi, biasanya pimpinan perusahaan atau pejabat berwenang.",
|
||||
},
|
||||
issueDate: null,
|
||||
},
|
||||
|
||||
// lightweight validation rules
|
||||
validate: {
|
||||
documentNumber: (val) =>
|
||||
val.value.trim().length === 0 ? "Nomor dokumen diperlukan" : null,
|
||||
employee: (val) =>
|
||||
(val.fullName.trim().length === 0
|
||||
? { fullName: "Nama lengkap diperlukan" }
|
||||
: null) as any,
|
||||
incomeDetails: (val) => {
|
||||
if (val.netIncome.value <= 0)
|
||||
return {
|
||||
netIncome: { value: "Net income harus lebih dari 0" },
|
||||
} as any;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// helper to compute net income automatically when basic/allowances/deductions change
|
||||
function recalcNetIncome() {
|
||||
const basic = form.values.incomeDetails.basicSalary.value || 0;
|
||||
const allowances = form.values.incomeDetails.allowances.value || 0;
|
||||
const deductions = form.values.incomeDetails.deductions.value || 0;
|
||||
const net = basic + allowances - deductions;
|
||||
form.setFieldValue(
|
||||
"incomeDetails.netIncome.value",
|
||||
Math.max(0, Math.round(net)),
|
||||
);
|
||||
}
|
||||
|
||||
// Submit handler: production apps would call API here
|
||||
function handleSubmit(values: CertificateFormSchema) {
|
||||
// simulate transformation: format currency, dates, etc.
|
||||
const payload = {
|
||||
...values,
|
||||
issueDate: values.issueDate
|
||||
? values.issueDate.toISOString().slice(0, 10)
|
||||
: null,
|
||||
employee: {
|
||||
...values.employee,
|
||||
dateOfBirth: values.employee.dateOfBirth
|
||||
? values.employee.dateOfBirth.toISOString().slice(0, 10)
|
||||
: null,
|
||||
},
|
||||
validity: {
|
||||
startDate: values.validity.startDate
|
||||
? values.validity.startDate.toISOString().slice(0, 10)
|
||||
: null,
|
||||
endDate: values.validity.endDate
|
||||
? values.validity.endDate.toISOString().slice(0, 10)
|
||||
: null,
|
||||
description: values.validity.description,
|
||||
},
|
||||
};
|
||||
|
||||
// For demo: log and show a subtle success
|
||||
// Replace with real API call (fetch/axios) in production.
|
||||
|
||||
console.log("Submitting Surat Keterangan Penghasilan:", payload);
|
||||
alert(
|
||||
"Form submitted — cek console untuk payload (demo).\nUntuk produksi, hubungkan endpoint API.",
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="md" w={"100%"}>
|
||||
<form onSubmit={form.onSubmit((v) => handleSubmit(v))}>
|
||||
<Card shadow="sm" radius="md" padding="lg" withBorder>
|
||||
<SectionTitle
|
||||
title="Surat Keterangan Penghasilan"
|
||||
icon={IconFileText}
|
||||
subtitle="Isi data sesuai blangko resmi. Gunakan tab untuk berpindah antar field."
|
||||
/>
|
||||
|
||||
<Grid gutter="md">
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
label="Tipe Dokumen"
|
||||
leftIcon={<IconFileText size={18} />}
|
||||
>
|
||||
<TextInput
|
||||
{...form.getInputProps("documentType.value" as any)}
|
||||
value={form.values.documentType}
|
||||
onChange={(e) =>
|
||||
form.setFieldValue("documentType", e.target.value as any)
|
||||
}
|
||||
placeholder="Surat Keterangan Penghasilan"
|
||||
aria-label="Tipe dokumen"
|
||||
/>
|
||||
<Text size="xs" color="dimmed">
|
||||
Jenis dokumen (tidak wajib diubah).
|
||||
</Text>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
label="Nomor Dokumen"
|
||||
leftIcon={<IconInfoCircle size={18} />}
|
||||
description={form.values.documentNumber.description}
|
||||
>
|
||||
<TextInput
|
||||
placeholder="e.g. SKP-2025-0001"
|
||||
{...form.getInputProps("documentNumber.value" as any)}
|
||||
aria-label="Nomor dokumen"
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={12}>
|
||||
<Accordion variant="separated">
|
||||
<Accordion.Item value="issuer">
|
||||
<Accordion.Control>
|
||||
{" "}
|
||||
<Group>
|
||||
{" "}
|
||||
<IconBuildingStore size={16} />{" "}
|
||||
<Text fw={700}>Pihak Penerbit</Text>{" "}
|
||||
</Group>{" "}
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="Nama Penerbit"
|
||||
placeholder="Contoh: PT. Contoh Perkasa"
|
||||
{...form.getInputProps("issuer.name" as any)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="Jabatan"
|
||||
placeholder="HRD / Manager"
|
||||
{...form.getInputProps("issuer.position" as any)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Textarea
|
||||
label="Alamat Perusahaan"
|
||||
placeholder="Alamat lengkap penerbit"
|
||||
{...form.getInputProps("issuer.address" as any)}
|
||||
minRows={2}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="employee">
|
||||
<Accordion.Control>
|
||||
{" "}
|
||||
<Group>
|
||||
{" "}
|
||||
<IconUser size={16} />{" "}
|
||||
<Text fw={700}>Data Karyawan</Text>{" "}
|
||||
</Group>{" "}
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="Nama Lengkap"
|
||||
placeholder="Nama sesuai KTP"
|
||||
{...form.getInputProps("employee.fullName" as any)}
|
||||
required
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="NIK"
|
||||
placeholder="Nomor Induk KTP"
|
||||
{...form.getInputProps("employee.nik" as any)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="Tempat Lahir"
|
||||
placeholder="Kota kelahiran"
|
||||
{...form.getInputProps(
|
||||
"employee.placeOfBirth" as any,
|
||||
)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<DatePicker
|
||||
{...form.getInputProps("employee.dateOfBirth" as any)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="Jabatan"
|
||||
placeholder="Posisi di perusahaan"
|
||||
{...form.getInputProps("employee.position" as any)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<Select
|
||||
label="Status Kerja"
|
||||
data={[
|
||||
"Karyawan Tetap",
|
||||
"Karyawan Kontrak",
|
||||
"Magang",
|
||||
"Konsultan",
|
||||
]}
|
||||
{...form.getInputProps(
|
||||
"employee.employmentStatus" as any,
|
||||
)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={12}>
|
||||
<Textarea
|
||||
label="Catatan / Deskripsi"
|
||||
placeholder="Opsional"
|
||||
{...form.getInputProps("employee.description" as any)}
|
||||
minRows={2}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="income">
|
||||
<Accordion.Control>
|
||||
{" "}
|
||||
<Group>
|
||||
{" "}
|
||||
<IconCurrencyDollar size={16} />{" "}
|
||||
<Text fw={700}>Rincian Penghasilan</Text>{" "}
|
||||
</Group>{" "}
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<NumberInput
|
||||
label="Gaji Pokok (per bulan)"
|
||||
pattern="^[0-9]*$"
|
||||
min={0}
|
||||
placeholder="0"
|
||||
{...form.getInputProps(
|
||||
"incomeDetails.basicSalary.value" as any,
|
||||
)}
|
||||
onBlur={recalcNetIncome}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<NumberInput
|
||||
label="Tunjangan (per bulan)"
|
||||
pattern="^[0-9]*$"
|
||||
min={0}
|
||||
placeholder="0"
|
||||
{...form.getInputProps(
|
||||
"incomeDetails.allowances.value" as any,
|
||||
)}
|
||||
onBlur={recalcNetIncome}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<NumberInput
|
||||
label="Potongan (per bulan)"
|
||||
pattern="^[0-9]*$"
|
||||
min={0}
|
||||
placeholder="0"
|
||||
{...form.getInputProps(
|
||||
"incomeDetails.deductions.value" as any,
|
||||
)}
|
||||
onBlur={recalcNetIncome}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<NumberInput
|
||||
label="Penghasilan Bersih (per bulan)"
|
||||
readOnly
|
||||
value={form.values.incomeDetails.netIncome.value}
|
||||
pattern="^[0-9]*$"
|
||||
min={0}
|
||||
placeholder="0"
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={12}>
|
||||
<Text size="xs" color="dimmed">
|
||||
{form.values.incomeDetails.basicSalary.description}
|
||||
</Text>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="validity">
|
||||
<Accordion.Control>
|
||||
{" "}
|
||||
<Group>
|
||||
{" "}
|
||||
<IconCalendarEvent size={16} />{" "}
|
||||
<Text fw={700}>Masa Berlaku</Text>{" "}
|
||||
</Group>{" "}
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<DatePicker
|
||||
{...form.getInputProps("validity.startDate" as any)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<DatePicker
|
||||
{...form.getInputProps("validity.endDate" as any)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Textarea
|
||||
label="Keterangan Masa Berlaku"
|
||||
placeholder="Contoh: Berlaku 1 tahun sejak diterbitkan"
|
||||
{...form.getInputProps("validity.description" as any)}
|
||||
minRows={2}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="signatory">
|
||||
<Accordion.Control>
|
||||
{" "}
|
||||
<Group>
|
||||
{" "}
|
||||
<IconCheck size={16} />{" "}
|
||||
<Text fw={700}>Penandatangan</Text>{" "}
|
||||
</Group>{" "}
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="Nama Penandatangan"
|
||||
placeholder="Nama pejabat"
|
||||
{...form.getInputProps("signatory.name" as any)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="Jabatan Penandatangan"
|
||||
placeholder="Direktur / Manager"
|
||||
{...form.getInputProps("signatory.position" as any)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<TextInput
|
||||
label="Tanda Tangan (nama atau link)"
|
||||
placeholder="bila tersedia"
|
||||
{...form.getInputProps("signatory.signature" as any)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={12}>
|
||||
<Divider my="sm" />
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<DatePicker {...form.getInputProps("issueDate" as any)} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="Tujuan Penggunaan"
|
||||
placeholder="Contoh: Pengajuan KPR"
|
||||
{...form.getInputProps("purpose.value" as any)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={12}>
|
||||
<Group justify="right">
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
onClick={() => form.reset()}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button type="submit">Simpan & Cetak</Button>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Card>
|
||||
</form>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
502
src/pages/darmasaba/form_surat_keterangan_tempat_usaha.tsx
Normal file
502
src/pages/darmasaba/form_surat_keterangan_tempat_usaha.tsx
Normal file
@@ -0,0 +1,502 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// pages/sktu/page.tsx
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Container,
|
||||
Card,
|
||||
Title,
|
||||
Text,
|
||||
Divider,
|
||||
Stack,
|
||||
Group,
|
||||
SimpleGrid,
|
||||
TextInput,
|
||||
NumberInput,
|
||||
Textarea,
|
||||
Select,
|
||||
FileInput,
|
||||
Button,
|
||||
Notification,
|
||||
Tooltip,
|
||||
Loader,
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { DatePicker } from "@mantine/dates";
|
||||
import {
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconInfoCircle,
|
||||
IconUser,
|
||||
IconId,
|
||||
IconMapPin,
|
||||
IconPhone,
|
||||
IconBuildingStore,
|
||||
IconCategory,
|
||||
IconSquarePlus,
|
||||
IconRuler,
|
||||
IconUsers,
|
||||
IconFileText,
|
||||
IconSignature,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
// types/form-types.ts
|
||||
export type StatusTempat =
|
||||
| "Milik Sendiri"
|
||||
| "Kontrak/Sewa"
|
||||
| "Pinjam Pakai"
|
||||
| "Lainnya";
|
||||
|
||||
export interface SKTUFormValues {
|
||||
// Data Pemohon
|
||||
namaLengkap: string;
|
||||
nik: string;
|
||||
tempatTanggalLahir: string;
|
||||
alamatPemohon: string;
|
||||
telepon: string;
|
||||
|
||||
// Data Usaha
|
||||
namaUsaha: string;
|
||||
jenisUsaha: string;
|
||||
bidangUsaha: string;
|
||||
alamatUsaha: string;
|
||||
statusTempat: StatusTempat;
|
||||
luasTempat: string; // kept as string to allow free-text like "36" or "36 (sebagian)"
|
||||
jumlahKaryawan: number;
|
||||
npwp?: string;
|
||||
|
||||
// Keterangan Tambahan
|
||||
keteranganTambahan?: string;
|
||||
|
||||
// Tanggal pengajuan
|
||||
tanggalPengajuan: Date | null;
|
||||
|
||||
// Pemohon (penandatangan)
|
||||
pemohon_nama: string;
|
||||
pemohon_tandaTangan: File | null;
|
||||
|
||||
// Pengesahan
|
||||
kepalaDesaLurah: string;
|
||||
camat: string;
|
||||
petugasRegistrasi: string;
|
||||
}
|
||||
|
||||
export default function FormSuratKeteranganTempatUsaha() {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const form = useForm<SKTUFormValues>({
|
||||
initialValues: {
|
||||
// dataPemohon
|
||||
namaLengkap: "",
|
||||
nik: "",
|
||||
tempatTanggalLahir: "",
|
||||
alamatPemohon: "",
|
||||
telepon: "",
|
||||
// dataUsaha
|
||||
namaUsaha: "",
|
||||
jenisUsaha: "",
|
||||
bidangUsaha: "",
|
||||
alamatUsaha: "",
|
||||
statusTempat: "Milik Sendiri",
|
||||
luasTempat: "",
|
||||
jumlahKaryawan: 0,
|
||||
npwp: "",
|
||||
// keteranganTambahan
|
||||
keteranganTambahan: "",
|
||||
// tanggalPengajuan
|
||||
tanggalPengajuan: null,
|
||||
// pemohon
|
||||
pemohon_nama: "",
|
||||
pemohon_tandaTangan: null,
|
||||
// pengesahan
|
||||
kepalaDesaLurah: "",
|
||||
camat: "",
|
||||
petugasRegistrasi: "",
|
||||
},
|
||||
validate: {
|
||||
namaLengkap: (v: any) =>
|
||||
v.trim().length > 0 ? null : "Nama lengkap pemohon diperlukan.",
|
||||
nik: (v: any) => {
|
||||
const digits = v.replace(/\D/g, "");
|
||||
if (!digits) return "NIK diperlukan.";
|
||||
if (digits.length !== 16) return "NIK harus 16 digit angka.";
|
||||
return null;
|
||||
},
|
||||
tempatTanggalLahir: (v: any) =>
|
||||
v.trim().length > 0 ? null : "Tempat dan tanggal lahir diperlukan.",
|
||||
alamatPemohon: (v: any) =>
|
||||
v.trim().length > 0 ? null : "Alamat sesuai KTP diperlukan.",
|
||||
telepon: (v: any) => {
|
||||
const digits = v.replace(/\D/g, "");
|
||||
if (!digits) return "Nomor telepon diperlukan.";
|
||||
if (digits.length < 8) return "Nomor telepon tampak terlalu pendek.";
|
||||
return null;
|
||||
},
|
||||
namaUsaha: (v: any) => (v.trim() ? null : "Nama usaha diperlukan."),
|
||||
jenisUsaha: (v: any) => (v.trim() ? null : "Jenis usaha diperlukan."),
|
||||
alamatUsaha: (v: any) => (v.trim() ? null : "Alamat usaha diperlukan."),
|
||||
luasTempat: (v: any) => {
|
||||
if (!v.trim()) return "Luas tempat diperlukan.";
|
||||
const n = Number(v);
|
||||
if (Number.isNaN(n) || n <= 0)
|
||||
return "Masukkan luas valid (nomor > 0).";
|
||||
return null;
|
||||
},
|
||||
jumlahKaryawan: (v: any) =>
|
||||
typeof v === "number" && v >= 0 ? null : "Jumlah karyawan harus >= 0.",
|
||||
tanggalPengajuan: (v: any) =>
|
||||
v ? null : "Tanggal pengajuan harus diisi.",
|
||||
pemohon_nama: (v: any) =>
|
||||
v.trim() ? null : "Nama pemohon (penandatangan) diperlukan.",
|
||||
kepalaDesaLurah: (v: any) =>
|
||||
v.trim() ? null : "Nama kepala desa/lurah diperlukan.",
|
||||
camat: (v: any) => (v.trim() ? null : "Nama camat diperlukan."),
|
||||
petugasRegistrasi: (v: any) =>
|
||||
v.trim() ? null : "Nama petugas registrasi diperlukan.",
|
||||
},
|
||||
});
|
||||
|
||||
async function handleSubmit(values: SKTUFormValues) {
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
// Simulasi panggilan API
|
||||
await new Promise((res) => setTimeout(res, 1100));
|
||||
|
||||
// Example: convert file to metadata, transform date to ISO
|
||||
const payload = {
|
||||
...values,
|
||||
tanggalPengajuan: values.tanggalPengajuan
|
||||
? values.tanggalPengajuan.toISOString()
|
||||
: null,
|
||||
pemohon_tandaTangan: values.pemohon_tandaTangan
|
||||
? values.pemohon_tandaTangan.name
|
||||
: null,
|
||||
};
|
||||
|
||||
// TODO: ganti dengan fetch() ke API nyata
|
||||
console.log("SKTU payload", payload);
|
||||
|
||||
setSuccess("Permohonan SKTU berhasil dikirim.");
|
||||
form.reset();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError("Gagal mengirim permohonan. Coba lagi.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
<Stack justify="xl">
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Group justify="apart" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Title order={2} aria-live="polite">
|
||||
Formulir Surat Keterangan Tempat Usaha (SKTU)
|
||||
</Title>
|
||||
<Text size="sm" color="dimmed" mt={6}>
|
||||
Blangko resmi untuk pengajuan SKTU — digunakan sebagai bukti
|
||||
legalitas usaha.
|
||||
</Text>
|
||||
</div>
|
||||
<Tooltip
|
||||
label="Form ini membantu mengumpulkan data pemohon dan usaha"
|
||||
withArrow
|
||||
>
|
||||
<IconInfoCircle size={20} aria-hidden />
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<Divider my="md" />
|
||||
|
||||
<form onSubmit={form.onSubmit(handleSubmit)} aria-label="Form SKTU">
|
||||
<Stack justify="lg">
|
||||
{/* Data Pemohon */}
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
p="md"
|
||||
aria-labelledby="pemohon-heading"
|
||||
>
|
||||
<Group justify="apart" align="flex-start" wrap="nowrap">
|
||||
<Title order={4} id="pemohon-heading">
|
||||
Data Pemohon
|
||||
</Title>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={1} mt="md">
|
||||
<TextInput
|
||||
required
|
||||
label="Nama Lengkap"
|
||||
placeholder="Nama sesuai KTP"
|
||||
leftSection={<IconUser size={18} />}
|
||||
{...form.getInputProps("namaLengkap")}
|
||||
aria-label="Nama Lengkap"
|
||||
description="Nama pemilik usaha sesuai KTP."
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
required
|
||||
label="NIK"
|
||||
placeholder="16 digit NIK"
|
||||
leftSection={<IconId size={18} />}
|
||||
{...form.getInputProps("nik")}
|
||||
aria-label="NIK"
|
||||
inputMode="numeric"
|
||||
description="Masukkan 16 digit NIK (hanya angka)."
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={1} mt="sm">
|
||||
<TextInput
|
||||
required
|
||||
label="Tempat & Tanggal Lahir"
|
||||
placeholder="Contoh: Denpasar, 01 Januari 1990"
|
||||
leftSection={<IconMapPin size={18} />}
|
||||
{...form.getInputProps("tempatTanggalLahir")}
|
||||
aria-label="Tempat dan tanggal lahir"
|
||||
description="Cantumkan tempat dan tanggal lahir (format bebas)."
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
required
|
||||
label="Telepon"
|
||||
placeholder="08xxxxxxxxxx"
|
||||
leftSection={<IconPhone size={18} />}
|
||||
{...form.getInputProps("telepon")}
|
||||
aria-label="Telepon"
|
||||
description="Nomor yang dapat dihubungi untuk verifikasi."
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Textarea
|
||||
mt="sm"
|
||||
required
|
||||
label="Alamat Pemohon"
|
||||
placeholder="Alamat lengkap sesuai KTP"
|
||||
minRows={2}
|
||||
{...form.getInputProps("alamatPemohon")}
|
||||
aria-label="Alamat Pemohon"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Data Usaha */}
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
p="md"
|
||||
aria-labelledby="usaha-heading"
|
||||
>
|
||||
<Title order={4} id="usaha-heading">
|
||||
Data Usaha
|
||||
</Title>
|
||||
|
||||
<SimpleGrid cols={1} mt="md">
|
||||
<TextInput
|
||||
required
|
||||
label="Nama Usaha"
|
||||
placeholder="Nama usaha / toko"
|
||||
leftSection={<IconBuildingStore size={18} />}
|
||||
{...form.getInputProps("namaUsaha")}
|
||||
aria-label="Nama Usaha"
|
||||
description="Nama dagang yang digunakan di lapangan."
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
required
|
||||
label="Jenis Usaha"
|
||||
placeholder="Contoh: Dagang, Jasa, Produksi"
|
||||
leftSection={<IconCategory size={18} />}
|
||||
{...form.getInputProps("jenisUsaha")}
|
||||
aria-label="Jenis Usaha"
|
||||
description="Pilih/isi jenis usaha secara singkat."
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<TextInput
|
||||
mt="sm"
|
||||
label="Bidang Usaha"
|
||||
placeholder="Contoh: Warung makan, bengkel motor"
|
||||
{...form.getInputProps("bidangUsaha")}
|
||||
description="Spesifikkan bidang usaha Anda (opsional tapi direkomendasikan)."
|
||||
leftSection={<IconSquarePlus size={18} />}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
mt="sm"
|
||||
required
|
||||
label="Alamat Usaha"
|
||||
placeholder="Alamat lengkap tempat usaha"
|
||||
minRows={2}
|
||||
{...form.getInputProps("alamatUsaha")}
|
||||
leftSection={<IconMapPin size={18} />}
|
||||
/>
|
||||
|
||||
<SimpleGrid cols={1} mt="sm">
|
||||
<Select
|
||||
label="Status Tempat"
|
||||
data={[
|
||||
"Milik Sendiri",
|
||||
"Kontrak/Sewa",
|
||||
"Pinjam Pakai",
|
||||
"Lainnya",
|
||||
]}
|
||||
{...form.getInputProps("statusTempat")}
|
||||
description="Status kepemilikan atau penggunaan tempat usaha."
|
||||
aria-label="Status Tempat"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Luas Tempat (m²)"
|
||||
placeholder="Contoh: 36"
|
||||
leftSection={<IconRuler size={18} />}
|
||||
{...form.getInputProps("luasTempat")}
|
||||
aria-label="Luas Tempat"
|
||||
description="Isi angka luas bangunan / ruangan (meter persegi)."
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Jumlah Karyawan"
|
||||
min={0}
|
||||
step={1}
|
||||
{...form.getInputProps("jumlahKaryawan")}
|
||||
leftSection={<IconUsers size={18} />}
|
||||
aria-label="Jumlah Karyawan"
|
||||
description="Jumlah pekerja tetap/harian di usaha ini."
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<TextInput
|
||||
mt="sm"
|
||||
label="NPWP (jika ada)"
|
||||
placeholder="Nomor NPWP"
|
||||
{...form.getInputProps("npwp")}
|
||||
leftSection={<IconFileText size={18} />}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Keterangan Tambahan & Tanggal */}
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Title order={5}>Keterangan Tambahan & Tanggal</Title>
|
||||
|
||||
<Textarea
|
||||
mt="sm"
|
||||
label="Keterangan Tambahan (opsional)"
|
||||
placeholder="Informasi tambahan mengenai usaha"
|
||||
minRows={3}
|
||||
{...form.getInputProps("keteranganTambahan")}
|
||||
/>
|
||||
|
||||
<DatePicker
|
||||
mt="sm"
|
||||
{...form.getInputProps("tanggalPengajuan")}
|
||||
aria-label="Tanggal Pengajuan"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Pemohon (penandatangan) */}
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Title order={5}>Pemohon (Penandatangan)</Title>
|
||||
|
||||
<SimpleGrid cols={1} mt="md">
|
||||
<TextInput
|
||||
required
|
||||
label="Nama Pemohon (penandatangan)"
|
||||
placeholder="Nama yang menandatangani surat"
|
||||
{...form.getInputProps("pemohon_nama")}
|
||||
leftSection={<IconUser size={18} />}
|
||||
aria-label="Nama Penandatangan"
|
||||
/>
|
||||
|
||||
<FileInput
|
||||
label="Tanda Tangan (scan / file)"
|
||||
placeholder="Upload file tanda tangan (jpg, png, pdf)"
|
||||
accept="image/png, image/jpeg, application/pdf"
|
||||
{...form.getInputProps("pemohon_tandaTangan")}
|
||||
leftSection={<IconSignature size={18} />}
|
||||
aria-label="Tanda Tangan"
|
||||
description="Scan tanda tangan yang digunakan untuk verifikasi."
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Card>
|
||||
|
||||
{/* Pengesahan */}
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Title order={5}>Pengesahan</Title>
|
||||
|
||||
<SimpleGrid cols={1} mt="md">
|
||||
<TextInput
|
||||
required
|
||||
label="Kepala Desa / Lurah"
|
||||
placeholder="Nama Kepala Desa atau Lurah"
|
||||
{...form.getInputProps("kepalaDesaLurah")}
|
||||
aria-label="Kepala Desa Lurah"
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
label="Camat"
|
||||
placeholder="Nama Camat"
|
||||
{...form.getInputProps("camat")}
|
||||
aria-label="Camat"
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
label="Petugas Registrasi"
|
||||
placeholder="Nama petugas registrasi"
|
||||
{...form.getInputProps("petugasRegistrasi")}
|
||||
aria-label="Petugas Registrasi"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Card>
|
||||
|
||||
{/* Submission + Feedback */}
|
||||
<Group justify="right" mt="md">
|
||||
<Button
|
||||
type="submit"
|
||||
leftSection={
|
||||
submitting ? <Loader size={16} /> : <IconCheck size={16} />
|
||||
}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "Mengirim..." : "Kirim Permohonan"}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{success && (
|
||||
<Notification
|
||||
icon={<IconCheck size={18} />}
|
||||
color="teal"
|
||||
onClose={() => setSuccess(null)}
|
||||
title="Berhasil"
|
||||
>
|
||||
{success}
|
||||
</Notification>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Notification
|
||||
icon={<IconX size={18} />}
|
||||
color="red"
|
||||
onClose={() => setError(null)}
|
||||
title="Error"
|
||||
>
|
||||
{error}
|
||||
</Notification>
|
||||
)}
|
||||
</Stack>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Text size="xs" color="dimmed" ta="center">
|
||||
Pastikan semua data telah terisi sesuai dokumen resmi. Untuk bantuan,
|
||||
hubungi kantor kelurahan setempat.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
600
src/pages/darmasaba/form_surat_keterangan_tidak_mampu.tsx
Normal file
600
src/pages/darmasaba/form_surat_keterangan_tidak_mampu.tsx
Normal file
@@ -0,0 +1,600 @@
|
||||
import {
|
||||
Accordion,
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Divider,
|
||||
FileInput,
|
||||
Grid,
|
||||
Group,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { DatePicker } from "@mantine/dates";
|
||||
import { useForm } from "@mantine/form";
|
||||
import {
|
||||
IconBuildingCommunity,
|
||||
IconCalendarEvent,
|
||||
IconInfoCircle,
|
||||
IconMailCheck,
|
||||
IconMapPin,
|
||||
IconUser,
|
||||
} from "@tabler/icons-react";
|
||||
import React from "react";
|
||||
|
||||
// =========================
|
||||
// Types (strong typing for the form state)
|
||||
// =========================
|
||||
|
||||
type Header = {
|
||||
instansi: string;
|
||||
kecamatan: string;
|
||||
desaKelurahan: string;
|
||||
nomorSurat: string;
|
||||
};
|
||||
|
||||
type DataPemohon = {
|
||||
namaLengkap: string;
|
||||
nik: string;
|
||||
tempatTanggalLahir: string;
|
||||
jenisKelamin: "Laki-laki" | "Perempuan" | "";
|
||||
agama: string;
|
||||
statusPerkawinan: string;
|
||||
pekerjaan: string;
|
||||
alamat: string;
|
||||
rt: string;
|
||||
rw: string;
|
||||
desaKelurahan: string;
|
||||
kecamatan: string;
|
||||
kabupatenKota: string;
|
||||
};
|
||||
|
||||
type Keterangan = {
|
||||
isiSurat: string;
|
||||
keperluan: string;
|
||||
};
|
||||
|
||||
type Penutup = {
|
||||
tempat: string;
|
||||
tanggal: Date | null;
|
||||
};
|
||||
|
||||
type Pengesahan = {
|
||||
kepalaDesaLurah: string;
|
||||
jabatan: string;
|
||||
tandaTangan: File | null;
|
||||
stempel: File | null;
|
||||
};
|
||||
|
||||
type SKTMFormValues = {
|
||||
header: Header;
|
||||
dataPemohon: DataPemohon;
|
||||
keterangan: Keterangan;
|
||||
penutup: Penutup;
|
||||
pengesahan: Pengesahan;
|
||||
};
|
||||
|
||||
// =========================
|
||||
// Reusable UI components
|
||||
// =========================
|
||||
|
||||
function FieldLabel({ label, hint }: { label: string; hint?: string }) {
|
||||
return (
|
||||
<Group justify="apart" gap="xs" align="center">
|
||||
<Text fw={600}>{label}</Text>
|
||||
{hint && (
|
||||
<Tooltip label={hint} withArrow>
|
||||
<ActionIcon size={24} variant="subtle">
|
||||
<IconInfoCircle size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function FormSection({
|
||||
title,
|
||||
icon,
|
||||
children,
|
||||
description,
|
||||
}: {
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card radius="md" shadow="sm" withBorder>
|
||||
<Group justify="apart" align="center" mb="xs">
|
||||
<Group align="center" gap="xs">
|
||||
{icon}
|
||||
<Text fw={700}>{title}</Text>
|
||||
</Group>
|
||||
{description && <Badge variant="light">{description}</Badge>}
|
||||
</Group>
|
||||
|
||||
<Divider mb="sm" />
|
||||
<Stack gap="sm">{children}</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// =========================
|
||||
// Helper validators
|
||||
// =========================
|
||||
|
||||
const isRequired = (val: any) =>
|
||||
val === undefined || val === null || String(val).trim() === ""
|
||||
? "Wajib diisi"
|
||||
: null;
|
||||
const validateNIK = (val: string) => {
|
||||
if (!val) return "Wajib diisi";
|
||||
const digits = val.replace(/\D/g, "");
|
||||
if (digits.length !== 16) return "NIK harus 16 digit";
|
||||
return null;
|
||||
};
|
||||
|
||||
// =========================
|
||||
// Main form component
|
||||
// =========================
|
||||
|
||||
export default function FormSuratKeteranganTidakMampu() {
|
||||
// initialize form with sensible defaults
|
||||
const form = useForm<SKTMFormValues>({
|
||||
initialValues: {
|
||||
header: {
|
||||
instansi: "PEMERINTAH KABUPATEN / KOTA",
|
||||
kecamatan: "",
|
||||
desaKelurahan: "",
|
||||
nomorSurat: "",
|
||||
},
|
||||
dataPemohon: {
|
||||
namaLengkap: "",
|
||||
nik: "",
|
||||
tempatTanggalLahir: "",
|
||||
jenisKelamin: "",
|
||||
agama: "",
|
||||
statusPerkawinan: "",
|
||||
pekerjaan: "",
|
||||
alamat: "",
|
||||
rt: "",
|
||||
rw: "",
|
||||
desaKelurahan: "",
|
||||
kecamatan: "",
|
||||
kabupatenKota: "",
|
||||
},
|
||||
keterangan: {
|
||||
isiSurat: "",
|
||||
keperluan: "",
|
||||
},
|
||||
penutup: {
|
||||
tempat: "",
|
||||
tanggal: null,
|
||||
},
|
||||
pengesahan: {
|
||||
kepalaDesaLurah: "",
|
||||
jabatan: "Kepala Desa",
|
||||
tandaTangan: null,
|
||||
stempel: null,
|
||||
},
|
||||
},
|
||||
|
||||
validate: {
|
||||
// header validators
|
||||
header: {
|
||||
instansi: (val) => isRequired(val),
|
||||
kecamatan: (val) => isRequired(val),
|
||||
desaKelurahan: (val) => isRequired(val),
|
||||
nomorSurat: (val) => isRequired(val),
|
||||
},
|
||||
// data pemohon validators
|
||||
dataPemohon: {
|
||||
namaLengkap: (val) => isRequired(val),
|
||||
nik: (val) => validateNIK(val),
|
||||
tempatTanggalLahir: (val) => isRequired(val),
|
||||
jenisKelamin: (val) => (val ? null : "Pilih jenis kelamin"),
|
||||
agama: (val) => isRequired(val),
|
||||
statusPerkawinan: (val) => isRequired(val),
|
||||
pekerjaan: (val) => isRequired(val),
|
||||
alamat: (val) => isRequired(val),
|
||||
rt: (val) => isRequired(val),
|
||||
rw: (val) => isRequired(val),
|
||||
desaKelurahan: (val) => isRequired(val),
|
||||
kecamatan: (val) => isRequired(val),
|
||||
kabupatenKota: (val) => isRequired(val),
|
||||
},
|
||||
// keterangan
|
||||
keterangan: {
|
||||
isiSurat: (val) => isRequired(val),
|
||||
keperluan: (val) => isRequired(val),
|
||||
},
|
||||
// penutup
|
||||
penutup: {
|
||||
tempat: (val) => isRequired(val),
|
||||
tanggal: (val) => (val ? null : "Pilih tanggal penerbitan"),
|
||||
},
|
||||
// pengesahan
|
||||
pengesahan: {
|
||||
kepalaDesaLurah: (val) => isRequired(val),
|
||||
jabatan: (val) => isRequired(val),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Submit handler
|
||||
const handleSubmit = (values: SKTMFormValues) => {
|
||||
// Convert files to metadata or prepare multipart form if needed.
|
||||
// Here we'll just console.log for demo purposes.
|
||||
console.log("Form submitted:", values);
|
||||
// In production: send to API endpoint (multipart/form-data) or convert File to base64.
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="md" w={"100%"}>
|
||||
<Box>
|
||||
<Stack gap="lg">
|
||||
<Group justify="apart" align="center">
|
||||
<Group align="center">
|
||||
<IconBuildingCommunity size={28} />
|
||||
<div>
|
||||
<Text fw={800} size="xl">
|
||||
Surat Keterangan Tidak Mampu (SKTM)
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Blangko resmi untuk pengajuan Surat Keterangan Tidak Mampu —
|
||||
digunakan untuk keperluan pendidikan, kesehatan, atau
|
||||
administrasi.
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group>
|
||||
<Badge radius="sm">Form Length: 5 Sections</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
handleSubmit(values);
|
||||
})}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
{/* Header Section */}
|
||||
<FormSection
|
||||
title="Header Surat"
|
||||
icon={<IconMailCheck size={20} />}
|
||||
description="Informasi identitas surat"
|
||||
>
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Instansi Penerbit"
|
||||
hint="Contoh: PEMERINTAH KABUPATEN/KOTA"
|
||||
/>
|
||||
}
|
||||
placeholder="PEMERINTAH KABUPATEN/KOTA"
|
||||
{...form.getInputProps("header.instansi")}
|
||||
leftSection={<IconBuildingCommunity size={16} />}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Nomor Surat"
|
||||
hint="Nomor resmi surat"
|
||||
/>
|
||||
}
|
||||
placeholder="123/SKTM/2025"
|
||||
{...form.getInputProps("header.nomorSurat")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<TextInput
|
||||
label={<FieldLabel label="Kecamatan" />}
|
||||
placeholder="Kecamatan"
|
||||
{...form.getInputProps("header.kecamatan")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<TextInput
|
||||
label={<FieldLabel label="Desa / Kelurahan" />}
|
||||
placeholder="Desa / Kelurahan"
|
||||
{...form.getInputProps("header.desaKelurahan")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</FormSection>
|
||||
|
||||
{/* Data Pemohon Section */}
|
||||
<Accordion variant="separated" radius="md" defaultValue="pemohon">
|
||||
<Accordion.Item value="pemohon">
|
||||
<Accordion.Control icon={<IconUser size={16} />}>
|
||||
Data Pemohon
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<FormSection
|
||||
title="Data Pemohon"
|
||||
description="Informasi identitas pemohon"
|
||||
>
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Nama Lengkap"
|
||||
hint="Sesuai KTP"
|
||||
/>
|
||||
}
|
||||
placeholder="Nama lengkap"
|
||||
{...form.getInputProps("dataPemohon.namaLengkap")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label={
|
||||
<FieldLabel
|
||||
label="NIK"
|
||||
hint="16 digit, tanpa spasi"
|
||||
/>
|
||||
}
|
||||
placeholder="3201xxxxxxxxxxxx"
|
||||
{...form.getInputProps("dataPemohon.nik")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Tempat, Tanggal Lahir"
|
||||
hint="Contoh: Denpasar, 31-12-1990"
|
||||
/>
|
||||
}
|
||||
placeholder="Tempat, tanggal lahir"
|
||||
{...form.getInputProps(
|
||||
"dataPemohon.tempatTanggalLahir",
|
||||
)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Select
|
||||
label={<FieldLabel label="Jenis Kelamin" />}
|
||||
placeholder="Pilih jenis kelamin"
|
||||
data={["Laki-laki", "Perempuan"]}
|
||||
{...form.getInputProps("dataPemohon.jenisKelamin")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Select
|
||||
label={<FieldLabel label="Agama" />}
|
||||
placeholder="Pilih agama"
|
||||
data={[
|
||||
"Islam",
|
||||
"Kristen",
|
||||
"Katolik",
|
||||
"Hindu",
|
||||
"Buddha",
|
||||
"Konghucu",
|
||||
"Lainnya",
|
||||
]}
|
||||
{...form.getInputProps("dataPemohon.agama")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Select
|
||||
label={<FieldLabel label="Status Perkawinan" />}
|
||||
placeholder="Pilih status"
|
||||
data={[
|
||||
"Belum Kawin",
|
||||
"Kawin",
|
||||
"Cerai Hidup",
|
||||
"Cerai Mati",
|
||||
]}
|
||||
{...form.getInputProps(
|
||||
"dataPemohon.statusPerkawinan",
|
||||
)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label={<FieldLabel label="Pekerjaan" />}
|
||||
placeholder="Pekerjaan"
|
||||
{...form.getInputProps("dataPemohon.pekerjaan")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={12}>
|
||||
<Textarea
|
||||
label={<FieldLabel label="Alamat Lengkap" />}
|
||||
placeholder="Alamat domisili"
|
||||
minRows={2}
|
||||
{...form.getInputProps("dataPemohon.alamat")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={2}>
|
||||
<TextInput
|
||||
label={<FieldLabel label="RT" />}
|
||||
placeholder="001"
|
||||
{...form.getInputProps("dataPemohon.rt")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={2}>
|
||||
<TextInput
|
||||
label={<FieldLabel label="RW" />}
|
||||
placeholder="002"
|
||||
{...form.getInputProps("dataPemohon.rw")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<TextInput
|
||||
label={<FieldLabel label="Desa / Kelurahan" />}
|
||||
placeholder="Desa"
|
||||
{...form.getInputProps("dataPemohon.desaKelurahan")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<TextInput
|
||||
label={<FieldLabel label="Kecamatan" />}
|
||||
placeholder="Kecamatan"
|
||||
{...form.getInputProps("dataPemohon.kecamatan")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<TextInput
|
||||
label={<FieldLabel label="Kabupaten / Kota" />}
|
||||
placeholder="Kabupaten / Kota"
|
||||
{...form.getInputProps("dataPemohon.kabupatenKota")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</FormSection>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
|
||||
{/* Keterangan Section */}
|
||||
<FormSection
|
||||
title="Keterangan"
|
||||
icon={<IconMapPin size={18} />}
|
||||
description="Isi pernyataan SKTM"
|
||||
>
|
||||
<Textarea
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Isi Surat"
|
||||
hint="Jelaskan kondisi ekonomi secara singkat"
|
||||
/>
|
||||
}
|
||||
placeholder="Pernyataan resmi bahwa yang bersangkutan benar-benar tergolong keluarga tidak mampu..."
|
||||
minRows={4}
|
||||
{...form.getInputProps("keterangan.isiSurat")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Keperluan"
|
||||
hint="Contoh: Beasiswa pendidikan / Perawatan kesehatan"
|
||||
/>
|
||||
}
|
||||
placeholder="Keperluan surat"
|
||||
{...form.getInputProps("keterangan.keperluan")}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
{/* Penutup Section */}
|
||||
<FormSection
|
||||
title="Penutup"
|
||||
icon={<IconCalendarEvent size={18} />}
|
||||
description="Tempat dan tanggal penerbitan"
|
||||
>
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label={<FieldLabel label="Tempat" />}
|
||||
placeholder="Contoh: Denpasar"
|
||||
{...form.getInputProps("penutup.tempat")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<DatePicker {...form.getInputProps("penutup.tanggal")} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</FormSection>
|
||||
|
||||
{/* Pengesahan Section */}
|
||||
<FormSection
|
||||
title="Pengesahan"
|
||||
description="Tanda tangan & stempel instansi"
|
||||
>
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label={<FieldLabel label="Kepala Desa / Lurah" />}
|
||||
placeholder="Nama pejabat"
|
||||
{...form.getInputProps("pengesahan.kepalaDesaLurah")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label={<FieldLabel label="Jabatan" />}
|
||||
placeholder="Contoh: Kepala Desa"
|
||||
{...form.getInputProps("pengesahan.jabatan")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<FileInput
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Scan Tanda Tangan"
|
||||
hint="Upload file scan tanda tangan (PNG/JPG/PDF)"
|
||||
/>
|
||||
}
|
||||
placeholder="Pilih file..."
|
||||
accept="image/png, image/jpeg, .pdf"
|
||||
{...form.getInputProps("pengesahan.tandaTangan")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<FileInput
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Stempel / Cap"
|
||||
hint="Upload file stempel (PNG/JPG/PDF)"
|
||||
/>
|
||||
}
|
||||
placeholder="Pilih file..."
|
||||
accept="image/png, image/jpeg, .pdf"
|
||||
{...form.getInputProps("pengesahan.stempel")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</FormSection>
|
||||
|
||||
{/* Actions */}
|
||||
<Group justify="right" mt="md">
|
||||
<Button variant="default" onClick={() => form.reset()}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button type="submit">Kirim / Simpan</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
Tip: Form ini otomatis menerjemahkan skema JSON ke komponen Mantine.
|
||||
Anda dapat memperluas validasi (contoh: cek format NIK, unggah file
|
||||
maksimal 2MB, dsb) sesuai kebutuhan produksi.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
641
src/pages/darmasaba/form_surat_keterangan_usaha.tsx
Normal file
641
src/pages/darmasaba/form_surat_keterangan_usaha.tsx
Normal file
@@ -0,0 +1,641 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
Accordion,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Divider,
|
||||
FileInput,
|
||||
Grid,
|
||||
Group,
|
||||
Notification,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { DatePicker } from "@mantine/dates";
|
||||
import { useForm } from "@mantine/form";
|
||||
import {
|
||||
IconBuildingStore,
|
||||
IconCheck,
|
||||
IconClipboardText,
|
||||
IconFileText,
|
||||
IconId,
|
||||
IconInfoCircle,
|
||||
IconUser,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import React from "react";
|
||||
|
||||
/* -------------------------
|
||||
Types derived from schema
|
||||
------------------------- */
|
||||
type PemohonData = {
|
||||
namaLengkap: string;
|
||||
nik: string;
|
||||
tempatTanggalLahir: string;
|
||||
jenisKelamin: "Laki-laki" | "Perempuan" | "";
|
||||
pekerjaan: string;
|
||||
alamat: string;
|
||||
desaKelurahan: string;
|
||||
kecamatan: string;
|
||||
kabupatenKota: string;
|
||||
};
|
||||
|
||||
type UsahaData = {
|
||||
namaUsaha: string;
|
||||
jenisUsaha: string;
|
||||
alamatUsaha: string;
|
||||
lamaUsaha: string;
|
||||
statusTempat: "Milik Sendiri" | "Sewa/Kontrak" | "Pinjam" | "";
|
||||
};
|
||||
|
||||
type PemohonSignature = {
|
||||
nama: string;
|
||||
tandaTangan: File | null;
|
||||
};
|
||||
|
||||
type Pengesahan = {
|
||||
kepalaDesaLurah: string;
|
||||
camat?: string;
|
||||
stempel?: File | null;
|
||||
};
|
||||
|
||||
export type SkuFormValues = {
|
||||
dataPemohon: PemohonData;
|
||||
dataUsaha: UsahaData;
|
||||
tujuanPembuatan: string;
|
||||
tanggalPengajuan: Date | null;
|
||||
pemohon: PemohonSignature;
|
||||
pengesahan: Pengesahan;
|
||||
};
|
||||
|
||||
/* -------------------------
|
||||
Reusable small components
|
||||
------------------------- */
|
||||
|
||||
/**
|
||||
* FormField:
|
||||
* Maps a tiny field descriptor to a Mantine input with label, description and error handling.
|
||||
* For brevity each field mapping is explicit — easy to extend to a dynamic mapping.
|
||||
*/
|
||||
function FormField(props: {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { id, label, description, children } = props;
|
||||
return (
|
||||
<Stack style={{ width: "100%" }}>
|
||||
<Group justify="apart" style={{ alignItems: "flex-start" }}>
|
||||
<Text fw={600} c="dark" id={`${id}-label`}>
|
||||
{label}
|
||||
</Text>
|
||||
{description ? (
|
||||
<Tooltip label={description} withArrow>
|
||||
<IconInfoCircle size={18} aria-hidden />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
<div aria-labelledby={`${id}-label`}>{children}</div>
|
||||
{description ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* FormSection:
|
||||
* Collapsible card/accordion for grouping nested objects (dataPemohon, dataUsaha, pengesahan).
|
||||
*/
|
||||
function FormSection(props: {
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
defaultOpened?: boolean;
|
||||
}) {
|
||||
const { title, icon, children, defaultOpened = true } = props;
|
||||
return (
|
||||
<Accordion multiple defaultValue={defaultOpened ? ["section"] : []}>
|
||||
<Accordion.Item value="section">
|
||||
<Accordion.Control icon={icon ?? <IconClipboardText size={18} />}>
|
||||
<Text fw={700}>{title}</Text>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>{children}</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------
|
||||
Main Dynamic Form Component
|
||||
------------------------- */
|
||||
|
||||
export default function FormSuratKeteranganUsaha() {
|
||||
// initial values follow the schema and provide sensible defaults.
|
||||
const form = useForm<SkuFormValues>({
|
||||
initialValues: {
|
||||
dataPemohon: {
|
||||
namaLengkap: "",
|
||||
nik: "",
|
||||
tempatTanggalLahir: "",
|
||||
jenisKelamin: "",
|
||||
pekerjaan: "",
|
||||
alamat: "",
|
||||
desaKelurahan: "",
|
||||
kecamatan: "",
|
||||
kabupatenKota: "",
|
||||
},
|
||||
dataUsaha: {
|
||||
namaUsaha: "",
|
||||
jenisUsaha: "",
|
||||
alamatUsaha: "",
|
||||
lamaUsaha: "",
|
||||
statusTempat: "",
|
||||
},
|
||||
tujuanPembuatan: "",
|
||||
tanggalPengajuan: null,
|
||||
pemohon: {
|
||||
nama: "",
|
||||
tandaTangan: null,
|
||||
},
|
||||
pengesahan: {
|
||||
kepalaDesaLurah: "",
|
||||
camat: "",
|
||||
stempel: null,
|
||||
},
|
||||
},
|
||||
|
||||
// Validation rules inspired by schema descriptions
|
||||
validate: {
|
||||
dataPemohon: {
|
||||
namaLengkap: (v) =>
|
||||
v.trim().length === 0 ? "Nama lengkap harus diisi" : null,
|
||||
nik: (v) =>
|
||||
v.trim().length === 0
|
||||
? "NIK harus diisi"
|
||||
: !/^\d{16}$/.test(v.trim())
|
||||
? "NIK harus berupa 16 digit angka"
|
||||
: null,
|
||||
tempatTanggalLahir: (v) =>
|
||||
v.trim().length === 0 ? "Tempat/tanggal lahir harus diisi" : null,
|
||||
jenisKelamin: (v) => (v === "" ? "Pilih jenis kelamin" : null),
|
||||
pekerjaan: (v) =>
|
||||
v.trim().length === 0 ? "Pekerjaan harus diisi" : null,
|
||||
alamat: (v) => (v.trim().length === 0 ? "Alamat harus diisi" : null),
|
||||
desaKelurahan: (v) =>
|
||||
v.trim().length === 0 ? "Nama desa/kelurahan harus diisi" : null,
|
||||
kecamatan: (v) =>
|
||||
v.trim().length === 0 ? "Kecamatan harus diisi" : null,
|
||||
kabupatenKota: (v) =>
|
||||
v.trim().length === 0 ? "Kabupaten/Kota harus diisi" : null,
|
||||
},
|
||||
dataUsaha: {
|
||||
namaUsaha: (v) =>
|
||||
v.trim().length === 0 ? "Nama usaha harus diisi" : null,
|
||||
jenisUsaha: (v) =>
|
||||
v.trim().length === 0 ? "Jenis usaha harus diisi" : null,
|
||||
alamatUsaha: (v) =>
|
||||
v.trim().length === 0 ? "Alamat usaha harus diisi" : null,
|
||||
lamaUsaha: (v) =>
|
||||
v.trim().length === 0 ? "Lama usaha harus diisi" : null,
|
||||
statusTempat: (v) =>
|
||||
v === "" ? "Pilih status kepemilikan tempat usaha" : null,
|
||||
},
|
||||
tujuanPembuatan: (v) =>
|
||||
v.trim().length === 0 ? "Tujuan pembuatan harus diisi" : null,
|
||||
tanggalPengajuan: (v) => (v === null ? "Pilih tanggal pengajuan" : null),
|
||||
pemohon: {
|
||||
nama: (v) =>
|
||||
v.trim().length === 0 ? "Nama pemohon harus diisi" : null,
|
||||
// tandaTangan optional but we can require at least a file for UX if desired:
|
||||
tandaTangan: (_) => null,
|
||||
},
|
||||
pengesahan: {
|
||||
kepalaDesaLurah: (v) =>
|
||||
v.trim().length === 0 ? "Nama kepala desa/lurah harus diisi" : null,
|
||||
camat: () => null,
|
||||
stempel: () => null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Simulated submit handler — replace with real API call.
|
||||
const [submitStatus, setSubmitStatus] = React.useState<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
|
||||
const handleSubmit = (values: SkuFormValues) => {
|
||||
// We intentionally don't send anything asynchronously here in this demo.
|
||||
// Convert File objects to metadata strings for preview if present.
|
||||
const payload = {
|
||||
...values,
|
||||
pemohon: {
|
||||
...values.pemohon,
|
||||
tandaTangan: values.pemohon.tandaTangan
|
||||
? values.pemohon.tandaTangan.name
|
||||
: null,
|
||||
},
|
||||
pengesahan: {
|
||||
...values.pengesahan,
|
||||
stempel: values.pengesahan.stempel
|
||||
? values.pengesahan.stempel.name
|
||||
: null,
|
||||
},
|
||||
tanggalPengajuan: values.tanggalPengajuan
|
||||
? values.tanggalPengajuan.toISOString().slice(0, 10)
|
||||
: null,
|
||||
};
|
||||
|
||||
// For now: show success and JSON preview
|
||||
setSubmitStatus({
|
||||
success: true,
|
||||
message: "Form berhasil divalidasi. Lihat payload.",
|
||||
});
|
||||
|
||||
console.log("SKU payload:", payload);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
form.reset();
|
||||
setSubmitStatus(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size={"md"} w={"100%"}>
|
||||
<Card shadow="sm" radius="md" p="lg">
|
||||
<Stack gap="md">
|
||||
<Group justify="apart" gap="sm">
|
||||
<Title order={3}>
|
||||
<Group>
|
||||
<IconClipboardText size={22} />
|
||||
<span>Surat Keterangan Usaha (SKU)</span>
|
||||
</Group>
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Blangko resmi dari desa/kelurahan
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
Blangko resmi untuk keterangan usaha dari pemerintah desa/kelurahan
|
||||
sebagai syarat administrasi.
|
||||
</Text>
|
||||
|
||||
{/* Data Pemohon Section */}
|
||||
<FormSection title="Data Pemohon" icon={<IconUser size={18} />}>
|
||||
<Grid>
|
||||
<Grid.Col span={12}>
|
||||
<FormField
|
||||
id="namaLengkap"
|
||||
label="Nama Lengkap"
|
||||
description="Nama lengkap pemohon sesuai KTP."
|
||||
>
|
||||
<TextInput
|
||||
placeholder="Contoh: Budi Santoso"
|
||||
{...form.getInputProps("dataPemohon.namaLengkap")}
|
||||
aria-describedby="namaLengkap-desc"
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
id="nik"
|
||||
label="NIK"
|
||||
description="Nomor Induk Kependudukan (16 digit)."
|
||||
>
|
||||
<TextInput
|
||||
placeholder="16 digit NIK"
|
||||
maxLength={16}
|
||||
inputMode="numeric"
|
||||
{...form.getInputProps("dataPemohon.nik")}
|
||||
aria-describedby="nik-desc"
|
||||
leftSection={<IconId size={16} />}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
id="ttl"
|
||||
label="Tempat, Tanggal Lahir"
|
||||
description="Contoh: Denpasar, 1 Januari 1990"
|
||||
>
|
||||
<TextInput
|
||||
placeholder="Tempat, Tanggal Lahir"
|
||||
{...form.getInputProps("dataPemohon.tempatTanggalLahir")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
id="jenisKelamin"
|
||||
label="Jenis Kelamin"
|
||||
description="Pilih jenis kelamin pemohon."
|
||||
>
|
||||
<Select
|
||||
placeholder="Pilih"
|
||||
data={["Laki-laki", "Perempuan"]}
|
||||
{...form.getInputProps("dataPemohon.jenisKelamin")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
id="pekerjaan"
|
||||
label="Pekerjaan"
|
||||
description="Pekerjaan utama pemohon."
|
||||
>
|
||||
<TextInput
|
||||
placeholder="Pekerjaan"
|
||||
{...form.getInputProps("dataPemohon.pekerjaan")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={12}>
|
||||
<FormField
|
||||
id="alamat"
|
||||
label="Alamat"
|
||||
description="Alamat lengkap sesuai domisili."
|
||||
>
|
||||
<Textarea
|
||||
placeholder="Alamat lengkap"
|
||||
minRows={2}
|
||||
{...form.getInputProps("dataPemohon.alamat")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<FormField
|
||||
id="desaKelurahan"
|
||||
label="Desa / Kelurahan"
|
||||
description="Nama desa/kelurahan."
|
||||
>
|
||||
<TextInput
|
||||
{...form.getInputProps("dataPemohon.desaKelurahan")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<FormField
|
||||
id="kecamatan"
|
||||
label="Kecamatan"
|
||||
description="Nama kecamatan."
|
||||
>
|
||||
<TextInput {...form.getInputProps("dataPemohon.kecamatan")} />
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<FormField
|
||||
id="kabupatenKota"
|
||||
label="Kabupaten / Kota"
|
||||
description="Nama kabupaten/kota."
|
||||
>
|
||||
<TextInput
|
||||
{...form.getInputProps("dataPemohon.kabupatenKota")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</FormSection>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Data Usaha Section */}
|
||||
<FormSection
|
||||
title="Data Usaha"
|
||||
icon={<IconBuildingStore size={18} />}
|
||||
>
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<FormField id="namaUsaha" label="Nama Usaha">
|
||||
<TextInput
|
||||
placeholder="Nama usaha"
|
||||
{...form.getInputProps("dataUsaha.namaUsaha")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
id="jenisUsaha"
|
||||
label="Jenis Usaha"
|
||||
description="Contoh: warung makan, bengkel, toko kelontong"
|
||||
>
|
||||
<TextInput
|
||||
placeholder="Jenis usaha"
|
||||
{...form.getInputProps("dataUsaha.jenisUsaha")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={12}>
|
||||
<FormField id="alamatUsaha" label="Alamat Usaha">
|
||||
<Textarea
|
||||
placeholder="Alamat lengkap lokasi usaha"
|
||||
{...form.getInputProps("dataUsaha.alamatUsaha")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
id="lamaUsaha"
|
||||
label="Lama Usaha"
|
||||
description="Contoh: 3 tahun"
|
||||
>
|
||||
<TextInput
|
||||
placeholder="Lama usaha (mis. 3 tahun)"
|
||||
{...form.getInputProps("dataUsaha.lamaUsaha")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
id="statusTempat"
|
||||
label="Status Tempat"
|
||||
description="Pilih status kepemilikan tempat usaha"
|
||||
>
|
||||
<Select
|
||||
placeholder="Pilih..."
|
||||
data={["Milik Sendiri", "Sewa/Kontrak", "Pinjam"]}
|
||||
{...form.getInputProps("dataUsaha.statusTempat")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</FormSection>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Tujuan & Tanggal */}
|
||||
<Grid>
|
||||
<Grid.Col span={8}>
|
||||
<FormField
|
||||
id="tujuanPembuatan"
|
||||
label="Tujuan Pembuatan"
|
||||
description="Contoh: pengajuan kredit bank"
|
||||
>
|
||||
<Textarea
|
||||
placeholder="Tujuan permohonan SKU"
|
||||
minRows={2}
|
||||
{...form.getInputProps("tujuanPembuatan")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={4}>
|
||||
<FormField
|
||||
id="tanggalPengajuan"
|
||||
label="Tanggal Pengajuan"
|
||||
description="Tanggal pemohon mengajukan permohonan."
|
||||
>
|
||||
<DatePicker
|
||||
value={form.values.tanggalPengajuan}
|
||||
onChange={(d) =>
|
||||
form.setFieldValue("tanggalPengajuan", d as any)
|
||||
}
|
||||
aria-label="Tanggal Pengajuan"
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Pemohon Signature */}
|
||||
<FormSection
|
||||
title="Pemohon (Tanda Tangan)"
|
||||
icon={<IconFileText size={18} />}
|
||||
>
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
id="pemohonNama"
|
||||
label="Nama Pemohon"
|
||||
description="Ditulis ulang sebagai tanda tangan."
|
||||
>
|
||||
<TextInput
|
||||
{...form.getInputProps("pemohon.nama")}
|
||||
placeholder="Nama pemohon"
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<FormField
|
||||
id="tandaTangan"
|
||||
label="Tanda Tangan (scan/file)"
|
||||
description="Unggah file tanda tangan (scan)."
|
||||
>
|
||||
<FileInput
|
||||
placeholder="Pilih file"
|
||||
accept="image/*, .pdf"
|
||||
value={form.values.pemohon.tandaTangan}
|
||||
onChange={(f) =>
|
||||
form.setFieldValue("pemohon.tandaTangan", f)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</FormSection>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Pengesahan */}
|
||||
<FormSection title="Pengesahan" icon={<IconCheck size={18} />}>
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<FormField id="kepalaDesa" label="Kepala Desa / Lurah">
|
||||
<TextInput
|
||||
{...form.getInputProps("pengesahan.kepalaDesaLurah")}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<FormField id="camat" label="Camat (opsional)">
|
||||
<TextInput {...form.getInputProps("pengesahan.camat")} />
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={12}>
|
||||
<FormField
|
||||
id="stempel"
|
||||
label="Stempel (opsional)"
|
||||
description="Unggah gambar stempel resmi jika tersedia."
|
||||
>
|
||||
<FileInput
|
||||
placeholder="Pilih file stempel"
|
||||
accept="image/*, .pdf"
|
||||
value={form.values.pengesahan.stempel}
|
||||
onChange={(f) =>
|
||||
form.setFieldValue("pengesahan.stempel", f)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</FormSection>
|
||||
|
||||
{/* Submit / Reset actions */}
|
||||
<Group justify="right" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleReset}
|
||||
leftSection={<IconX size={16} />}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const result = form.validate();
|
||||
if (result.hasErrors) {
|
||||
setSubmitStatus({
|
||||
success: false,
|
||||
message:
|
||||
"Terdapat kesalahan pada form. Mohon periksa kembali.",
|
||||
});
|
||||
// scroll to first error? could enhance later.
|
||||
return;
|
||||
}
|
||||
handleSubmit(form.values);
|
||||
}}
|
||||
leftSection={<IconCheck size={16} />}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Submission feedback */}
|
||||
{submitStatus ? (
|
||||
<Notification
|
||||
color={submitStatus.success ? "teal" : "red"}
|
||||
icon={submitStatus.success ? <IconCheck /> : <IconX />}
|
||||
>
|
||||
{submitStatus.message}
|
||||
</Notification>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { Button, Card, Container, Group, Stack, Table, Text, TextInput } from "@mantine/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import apiFetch from "@/lib/apiFetch";
|
||||
import { showNotification } from "@mantine/notifications";
|
||||
|
||||
export default function ApiKeyPage() {
|
||||
return (
|
||||
<Container size="md" w={"100%"}>
|
||||
<Stack>
|
||||
<Text>API Key</Text>
|
||||
<CreateApiKey />
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateApiKey() {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [expiredAt, setExpiredAt] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
const res = await apiFetch.api.apikey.create.post({ name, description, expiredAt });
|
||||
if (res.status === 200) {
|
||||
setName('');
|
||||
setDescription('');
|
||||
setExpiredAt('');
|
||||
showNotification({
|
||||
title: 'Success',
|
||||
message: 'API key created successfully',
|
||||
color: 'green',
|
||||
})
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
return (
|
||||
<Card >
|
||||
<Stack>
|
||||
<Text>API Create</Text>
|
||||
<TextInput label="Name" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<TextInput label="Description" placeholder="Description" value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||
<TextInput label="Expired At" placeholder="Expired At" type="date" value={expiredAt} onChange={(e) => setExpiredAt(e.target.value)} />
|
||||
<Group>
|
||||
<Button variant="outline" onClick={() => { setName(''); setDescription(''); setExpiredAt(''); }}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} type="submit" loading={loading}>Save</Button>
|
||||
</Group>
|
||||
|
||||
<ListApiKey />
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ListApiKey() {
|
||||
const [apiKeys, setApiKeys] = useState<any[]>([]);
|
||||
useEffect(() => {
|
||||
const fetchApiKeys = async () => {
|
||||
const res = await apiFetch.api.apikey.list.get();
|
||||
if (res.status === 200) {
|
||||
setApiKeys(res.data?.apiKeys || []);
|
||||
}
|
||||
}
|
||||
fetchApiKeys();
|
||||
}, []);
|
||||
return (
|
||||
<Card>
|
||||
<Stack>
|
||||
<Text>API List</Text>
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Expired At</th>
|
||||
<th>Created At</th>
|
||||
<th>Updated At</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{apiKeys.map((apiKey: any, index: number) => (
|
||||
<tr key={index}>
|
||||
<td>{apiKey.name}</td>
|
||||
<td>{apiKey.description}</td>
|
||||
<td>{apiKey.expiredAt.toISOString().split('T')[0]}</td>
|
||||
<td>{apiKey.createdAt.toISOString().split('T')[0]}</td>
|
||||
<td>{apiKey.updatedAt.toISOString().split('T')[0]}</td>
|
||||
<td>
|
||||
<Button variant="outline" onClick={() => {
|
||||
apiFetch.api.apikey.delete.delete({ id: apiKey.id })
|
||||
setApiKeys(apiKeys.filter((api: any) => api.id !== apiKey.id))
|
||||
}}>Delete</Button>
|
||||
<Button variant="outline" onClick={() => {
|
||||
navigator.clipboard.writeText(apiKey.key)
|
||||
showNotification({
|
||||
title: 'Success',
|
||||
message: 'API key copied to clipboard',
|
||||
color: 'green',
|
||||
})
|
||||
}}>Copy</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import apiFetch from "@/lib/apiFetch";
|
||||
import { Button, Card, Container, Flex, Group, Paper, Stack, Text, TextInput, Title } from "@mantine/core";
|
||||
import { useShallowEffect } from "@mantine/hooks";
|
||||
import { showNotification } from "@mantine/notifications";
|
||||
import { useState } from "react";
|
||||
import useSwr from 'swr'
|
||||
import { proxy, subscribe, useSnapshot } from 'valtio'
|
||||
|
||||
const state = proxy({
|
||||
reload: ""
|
||||
})
|
||||
|
||||
function reloadState() {
|
||||
state.reload = Math.random().toString()
|
||||
}
|
||||
|
||||
export default function CredentialPage() {
|
||||
return <Container size={"md"} w={"100%"}>
|
||||
<Stack>
|
||||
<CredentialCreate />
|
||||
<CredentialList />
|
||||
</Stack>
|
||||
</Container>
|
||||
}
|
||||
|
||||
function CredentialCreate() {
|
||||
const [name, setName] = useState("")
|
||||
const [apikey, setApikey] = useState("")
|
||||
|
||||
async function handleSubmit() {
|
||||
const { data } = await apiFetch.api.credential.create.post({
|
||||
name: name,
|
||||
value: apikey
|
||||
})
|
||||
|
||||
setName("")
|
||||
setApikey("")
|
||||
|
||||
showNotification({
|
||||
message: data?.message
|
||||
})
|
||||
|
||||
reloadState()
|
||||
}
|
||||
return <Card>
|
||||
<Stack>
|
||||
<Title>Credential Create</Title>
|
||||
<TextInput placeholder="name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<TextInput placeholder="apikey" value={apikey} onChange={(e) => setApikey(e.target.value)} />
|
||||
<Group>
|
||||
<Button onClick={handleSubmit}>Save</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
}
|
||||
|
||||
function CredentialList() {
|
||||
const { data, mutate } = useSwr("/", () => apiFetch.api.credential.list.get())
|
||||
|
||||
useShallowEffect(() => {
|
||||
const unsubscribe = subscribe(state, async () => {
|
||||
console.log('state has changed to', state)
|
||||
mutate()
|
||||
})
|
||||
|
||||
return () => unsubscribe()
|
||||
}, [])
|
||||
|
||||
async function handleRm(id: string) {
|
||||
await apiFetch.api.credential.rm.delete({
|
||||
id: id
|
||||
})
|
||||
|
||||
reloadState()
|
||||
|
||||
}
|
||||
return <Card>
|
||||
<Stack>
|
||||
{data?.data?.list.map((v, k) => <Stack key={k}>
|
||||
<Flex justify={"space-between"}>
|
||||
<Text>{v.name}</Text>
|
||||
<Group>
|
||||
<Button onClick={() => handleRm(v.id)}>delete</Button>
|
||||
</Group>
|
||||
</Flex>
|
||||
</Stack>)}
|
||||
</Stack>
|
||||
</Card>
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import {
|
||||
ActionIcon,
|
||||
AppShell,
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Flex,
|
||||
Group,
|
||||
NavLink,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip
|
||||
} from '@mantine/core'
|
||||
import { useLocalStorage } from '@mantine/hooks'
|
||||
import {
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconDashboard,
|
||||
IconKey,
|
||||
IconLock
|
||||
} from '@tabler/icons-react'
|
||||
import type { User } from 'generated/prisma'
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { default as clientRoute, default as clientRoutes } from '@/clientRoutes'
|
||||
import apiFetch from '@/lib/apiFetch'
|
||||
|
||||
|
||||
function Logout() {
|
||||
return <Group>
|
||||
<Button variant='transparent' size='compact-xs' onClick={async () => {
|
||||
await apiFetch.auth.logout.delete()
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
}}>Logout</Button>
|
||||
</Group>
|
||||
}
|
||||
|
||||
export default function DashboardLayout() {
|
||||
const [opened, setOpened] = useLocalStorage({
|
||||
key: 'nav_open',
|
||||
defaultValue: true,
|
||||
})
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
padding="md"
|
||||
navbar={{
|
||||
width: 260,
|
||||
breakpoint: 'sm',
|
||||
collapsed: { mobile: !opened, desktop: !opened },
|
||||
}}
|
||||
>
|
||||
<AppShell.Navbar>
|
||||
<AppShell.Section>
|
||||
<Group justify="flex-end" p="xs">
|
||||
<Tooltip
|
||||
label={opened ? 'Collapse navigation' : 'Expand navigation'}
|
||||
withArrow
|
||||
>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="gray"
|
||||
onClick={() => setOpened(v => !v)}
|
||||
aria-label="Toggle navigation"
|
||||
radius="xl"
|
||||
>
|
||||
{opened ? <IconChevronLeft /> : <IconChevronRight />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</AppShell.Section>
|
||||
|
||||
<AppShell.Section grow component={ScrollArea} flex={1}>
|
||||
<NavigationDashboard />
|
||||
</AppShell.Section>
|
||||
|
||||
<AppShell.Section>
|
||||
<HostView />
|
||||
</AppShell.Section>
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main>
|
||||
<Stack>
|
||||
<Paper withBorder shadow="md" radius="lg" p="md">
|
||||
<Flex align="center" gap="md">
|
||||
{!opened && (
|
||||
<Tooltip label="Open navigation menu" withArrow>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="gray"
|
||||
onClick={() => setOpened(true)}
|
||||
aria-label="Open navigation"
|
||||
radius="xl"
|
||||
>
|
||||
<IconChevronRight />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Title order={3} fw={600}>
|
||||
App Dashboard
|
||||
</Title>
|
||||
</Flex>
|
||||
</Paper>
|
||||
<Outlet />
|
||||
</Stack>
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
/* ----------------------- Host Info ----------------------- */
|
||||
function HostView() {
|
||||
const [host, setHost] = useState<User | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchHost() {
|
||||
const { data } = await apiFetch.api.user.find.get()
|
||||
setHost(data?.user ?? null)
|
||||
}
|
||||
fetchHost()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Card radius="lg" withBorder shadow="sm" p="md">
|
||||
{host ? (
|
||||
<Stack>
|
||||
<Flex gap="md" align="center">
|
||||
<Avatar size="md" radius="xl" color="blue">
|
||||
{host.name?.[0]}
|
||||
</Avatar>
|
||||
<Stack gap={2}>
|
||||
<Text fw={600}>{host.name}</Text>
|
||||
<Text size="sm" c="dimmed">{host.email}</Text>
|
||||
</Stack>
|
||||
</Flex>
|
||||
<Divider />
|
||||
<Logout />
|
||||
</Stack>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
No host information available
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/* ----------------------- Navigation ----------------------- */
|
||||
function NavigationDashboard() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const isActive = (path: keyof typeof clientRoute) =>
|
||||
location.pathname.startsWith(clientRoute[path])
|
||||
|
||||
return (
|
||||
<Stack gap="xs" p="sm">
|
||||
<NavLink
|
||||
active={isActive('/dashboard/landing')}
|
||||
leftSection={<IconDashboard size={20} />}
|
||||
label="Dashboard Overview"
|
||||
description="Quick summary and activity highlights"
|
||||
onClick={() => navigate(clientRoutes['/dashboard/landing'])}
|
||||
/>
|
||||
<NavLink
|
||||
active={isActive('/dashboard/apikey')}
|
||||
leftSection={<IconKey size={20} />}
|
||||
label="Dashboard Overview"
|
||||
description="Quick summary and activity highlights"
|
||||
onClick={() => navigate(clientRoutes['/dashboard/apikey'])}
|
||||
/>
|
||||
<NavLink
|
||||
active={isActive('/dashboard/credential')}
|
||||
leftSection={<IconLock size={20} />}
|
||||
label="Dashboard Overview"
|
||||
description="Quick summary and activity highlights"
|
||||
onClick={() => navigate(clientRoutes['/dashboard/credential'])}
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import apiFetch from "@/lib/apiFetch";
|
||||
import { Button } from "@mantine/core";
|
||||
|
||||
export default function Dashboard() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
164
src/pages/scr/dashboard/apikey/apikey_page.tsx
Normal file
164
src/pages/scr/dashboard/apikey/apikey_page.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import apiFetch from "@/lib/apiFetch";
|
||||
import { showNotification } from "@mantine/notifications";
|
||||
|
||||
export default function ApiKeyPage() {
|
||||
return (
|
||||
<Container size="md" w={"100%"}>
|
||||
<Stack>
|
||||
<Text>API Key</Text>
|
||||
<CreateApiKey />
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateApiKey() {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [expiredAt, setExpiredAt] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
const res = await apiFetch.api.apikey.create.post({
|
||||
name,
|
||||
description,
|
||||
expiredAt,
|
||||
});
|
||||
if (res.status === 200) {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setExpiredAt("");
|
||||
showNotification({
|
||||
title: "Success",
|
||||
message: "API key created successfully",
|
||||
color: "green",
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
return (
|
||||
<Card>
|
||||
<Stack>
|
||||
<Text>API Create</Text>
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Description"
|
||||
placeholder="Description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Expired At"
|
||||
placeholder="Expired At"
|
||||
type="date"
|
||||
value={expiredAt}
|
||||
onChange={(e) => setExpiredAt(e.target.value)}
|
||||
/>
|
||||
<Group>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setExpiredAt("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} type="submit" loading={loading}>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<ListApiKey />
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ListApiKey() {
|
||||
const [apiKeys, setApiKeys] = useState<any[]>([]);
|
||||
useEffect(() => {
|
||||
const fetchApiKeys = async () => {
|
||||
const res = await apiFetch.api.apikey.list.get();
|
||||
if (res.status === 200) {
|
||||
setApiKeys(res.data?.apiKeys || []);
|
||||
}
|
||||
};
|
||||
fetchApiKeys();
|
||||
}, []);
|
||||
return (
|
||||
<Card>
|
||||
<Stack>
|
||||
<Text>API List</Text>
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Expired At</th>
|
||||
<th>Created At</th>
|
||||
<th>Updated At</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{apiKeys.map((apiKey: any, index: number) => (
|
||||
<tr key={index}>
|
||||
<td>{apiKey.name}</td>
|
||||
<td>{apiKey.description}</td>
|
||||
<td>{apiKey.expiredAt.toISOString().split("T")[0]}</td>
|
||||
<td>{apiKey.createdAt.toISOString().split("T")[0]}</td>
|
||||
<td>{apiKey.updatedAt.toISOString().split("T")[0]}</td>
|
||||
<td>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
apiFetch.api.apikey.delete.delete({ id: apiKey.id });
|
||||
setApiKeys(
|
||||
apiKeys.filter((api: any) => api.id !== apiKey.id),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(apiKey.key);
|
||||
showNotification({
|
||||
title: "Success",
|
||||
message: "API key copied to clipboard",
|
||||
color: "green",
|
||||
});
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
116
src/pages/scr/dashboard/credential/credential_page.tsx
Normal file
116
src/pages/scr/dashboard/credential/credential_page.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import apiFetch from "@/lib/apiFetch";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Flex,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useShallowEffect } from "@mantine/hooks";
|
||||
import { showNotification } from "@mantine/notifications";
|
||||
import { useState } from "react";
|
||||
import useSwr from "swr";
|
||||
import { proxy, subscribe } from "valtio";
|
||||
|
||||
const state = proxy({
|
||||
reload: "",
|
||||
});
|
||||
|
||||
function reloadState() {
|
||||
state.reload = Math.random().toString();
|
||||
}
|
||||
|
||||
export default function CredentialPage() {
|
||||
return (
|
||||
<Container size={"md"} w={"100%"}>
|
||||
<Stack>
|
||||
<CredentialCreate />
|
||||
<CredentialList />
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function CredentialCreate() {
|
||||
const [name, setName] = useState("");
|
||||
const [apikey, setApikey] = useState("");
|
||||
|
||||
async function handleSubmit() {
|
||||
const { data } = await apiFetch.api.credential.create.post({
|
||||
name: name,
|
||||
value: apikey,
|
||||
});
|
||||
|
||||
setName("");
|
||||
setApikey("");
|
||||
|
||||
showNotification({
|
||||
message: data?.message,
|
||||
});
|
||||
|
||||
reloadState();
|
||||
}
|
||||
return (
|
||||
<Card>
|
||||
<Stack>
|
||||
<Title>Credential Create</Title>
|
||||
<TextInput
|
||||
placeholder="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="apikey"
|
||||
value={apikey}
|
||||
onChange={(e) => setApikey(e.target.value)}
|
||||
/>
|
||||
<Group>
|
||||
<Button onClick={handleSubmit}>Save</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function CredentialList() {
|
||||
const { data, mutate } = useSwr("/", () =>
|
||||
apiFetch.api.credential.list.get(),
|
||||
);
|
||||
|
||||
useShallowEffect(() => {
|
||||
const unsubscribe = subscribe(state, async () => {
|
||||
console.log("state has changed to", state);
|
||||
mutate();
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
async function handleRm(id: string) {
|
||||
await apiFetch.api.credential.rm.delete({
|
||||
id: id,
|
||||
});
|
||||
|
||||
reloadState();
|
||||
}
|
||||
return (
|
||||
<Card>
|
||||
<Stack>
|
||||
{data?.data?.list.map((v, k) => (
|
||||
<Stack key={k}>
|
||||
<Flex justify={"space-between"}>
|
||||
<Text>{v.name}</Text>
|
||||
<Group>
|
||||
<Button onClick={() => handleRm(v.id)}>delete</Button>
|
||||
</Group>
|
||||
</Flex>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
7
src/pages/scr/dashboard/dashboard_home.tsx
Normal file
7
src/pages/scr/dashboard/dashboard_home.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function Dashboard() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
202
src/pages/scr/dashboard/dashboard_layout.tsx
Normal file
202
src/pages/scr/dashboard/dashboard_layout.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
ActionIcon,
|
||||
AppShell,
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Flex,
|
||||
Group,
|
||||
NavLink,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
import {
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconDashboard,
|
||||
IconKey,
|
||||
IconLock,
|
||||
} from "@tabler/icons-react";
|
||||
import type { User } from "generated/prisma";
|
||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
default as clientRoute,
|
||||
default as clientRoutes,
|
||||
} from "@/clientRoutes";
|
||||
import apiFetch from "@/lib/apiFetch";
|
||||
|
||||
function Logout() {
|
||||
return (
|
||||
<Group>
|
||||
<Button
|
||||
variant="transparent"
|
||||
size="compact-xs"
|
||||
onClick={async () => {
|
||||
await apiFetch.auth.logout.delete();
|
||||
localStorage.removeItem("token");
|
||||
window.location.href = "/login";
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardLayout() {
|
||||
const [opened, setOpened] = useLocalStorage({
|
||||
key: "nav_open",
|
||||
defaultValue: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
padding="md"
|
||||
navbar={{
|
||||
width: 260,
|
||||
breakpoint: "sm",
|
||||
collapsed: { mobile: !opened, desktop: !opened },
|
||||
}}
|
||||
>
|
||||
<AppShell.Navbar>
|
||||
<AppShell.Section>
|
||||
<Group justify="flex-end" p="xs">
|
||||
<Tooltip
|
||||
label={opened ? "Collapse navigation" : "Expand navigation"}
|
||||
withArrow
|
||||
>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="gray"
|
||||
onClick={() => setOpened((v) => !v)}
|
||||
aria-label="Toggle navigation"
|
||||
radius="xl"
|
||||
>
|
||||
{opened ? <IconChevronLeft /> : <IconChevronRight />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</AppShell.Section>
|
||||
|
||||
<AppShell.Section grow component={ScrollArea} flex={1}>
|
||||
<NavigationDashboard />
|
||||
</AppShell.Section>
|
||||
|
||||
<AppShell.Section>
|
||||
<HostView />
|
||||
</AppShell.Section>
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main>
|
||||
<Stack>
|
||||
<Paper withBorder shadow="md" radius="lg" p="md">
|
||||
<Flex align="center" gap="md">
|
||||
{!opened && (
|
||||
<Tooltip label="Open navigation menu" withArrow>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="gray"
|
||||
onClick={() => setOpened(true)}
|
||||
aria-label="Open navigation"
|
||||
radius="xl"
|
||||
>
|
||||
<IconChevronRight />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Title order={3} fw={600}>
|
||||
App Dashboard
|
||||
</Title>
|
||||
</Flex>
|
||||
</Paper>
|
||||
<Outlet />
|
||||
</Stack>
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------------- Host Info ----------------------- */
|
||||
function HostView() {
|
||||
const [host, setHost] = useState<User | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchHost() {
|
||||
const { data } = await apiFetch.api.user.find.get();
|
||||
setHost(data?.user ?? null);
|
||||
}
|
||||
fetchHost();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card radius="lg" withBorder shadow="sm" p="md">
|
||||
{host ? (
|
||||
<Stack>
|
||||
<Flex gap="md" align="center">
|
||||
<Avatar size="md" radius="xl" color="blue">
|
||||
{host.name?.[0]}
|
||||
</Avatar>
|
||||
<Stack gap={2}>
|
||||
<Text fw={600}>{host.name}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{host.email}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Flex>
|
||||
<Divider />
|
||||
<Logout />
|
||||
</Stack>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
No host information available
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------------- Navigation ----------------------- */
|
||||
function NavigationDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const isActive = (path: keyof typeof clientRoute) =>
|
||||
location.pathname.startsWith(clientRoute[path]);
|
||||
|
||||
return (
|
||||
<Stack gap="xs" p="sm">
|
||||
<NavLink
|
||||
active={isActive("/scr/dashboard/dashboard-home")}
|
||||
leftSection={<IconDashboard size={20} />}
|
||||
label="Dashboard Overview"
|
||||
description="Quick summary and activity highlights"
|
||||
onClick={() => navigate(clientRoutes["/scr/dashboard/dashboard-home"])}
|
||||
/>
|
||||
<NavLink
|
||||
active={isActive("/scr/dashboard/apikey/apikey")}
|
||||
leftSection={<IconKey size={20} />}
|
||||
label="Dashboard Overview"
|
||||
description="Quick summary and activity highlights"
|
||||
onClick={() => navigate(clientRoutes["/scr/dashboard/apikey/apikey"])}
|
||||
/>
|
||||
<NavLink
|
||||
active={isActive("/scr/dashboard/credential/credential")}
|
||||
leftSection={<IconLock size={20} />}
|
||||
label="Dashboard Overview"
|
||||
description="Quick summary and activity highlights"
|
||||
onClick={() =>
|
||||
navigate(clientRoutes["/scr/dashboard/credential/credential"])
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
25
src/pages/scr/scr_layout.tsx
Normal file
25
src/pages/scr/scr_layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Navigate, Outlet } from "react-router-dom";
|
||||
import clientRoutes from "@/clientRoutes";
|
||||
import apiFetch from "@/lib/apiFetch";
|
||||
|
||||
export default function ProtectedRoute() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function checkSession() {
|
||||
try {
|
||||
// backend otomatis baca cookie JWT dari request
|
||||
const res = await apiFetch.api.user.find.get();
|
||||
setIsAuthenticated(res.status === 200);
|
||||
} catch {
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
}
|
||||
checkSession();
|
||||
}, []);
|
||||
|
||||
if (isAuthenticated === null) return null; // or loading spinner
|
||||
if (!isAuthenticated) return <Navigate to={clientRoutes["/login"]} replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export async function convertOpenApiToMcp(baseUrl: string): Promise<McpManifest>
|
||||
for (const [method, def] of Object.entries<any>(methods)) {
|
||||
const tags = def.tags || ["default"]
|
||||
const tag = tags[0]
|
||||
const operationId = def.operationId || `${method}_${path.replace(/[\/{}]/g, "_")}`
|
||||
const operationId = def.operationId || `${method}_${path.replace(/\//g, "_")}`
|
||||
|
||||
manifest.capabilities[tag] ??= {}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ type JWT = {
|
||||
|
||||
const ApiKeyRoute = new Elysia({
|
||||
prefix: '/apikey',
|
||||
detail: { tags: ['apikey'] },
|
||||
tags: ["apikey"],
|
||||
})
|
||||
.post(
|
||||
'/create',
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
import { jwt as jwtPlugin, type JWTPayloadSpec } from '@elysiajs/jwt'
|
||||
import Elysia, { t, type Cookie, type HTTPHeaders, type StatusMap } from 'elysia'
|
||||
import { type ElysiaCookie } from 'elysia/cookies'
|
||||
|
||||
import { prisma } from '@/server/lib/prisma'
|
||||
import type { User } from 'generated/prisma'
|
||||
|
||||
const secret = process.env.JWT_SECRET
|
||||
if (!secret) {
|
||||
@@ -109,7 +107,7 @@ async function login({
|
||||
|
||||
const Auth = new Elysia({
|
||||
prefix: '/auth',
|
||||
detail: { description: 'Auth API', summary: 'Auth API', tags: ['auth'] },
|
||||
tags: ["auth"],
|
||||
})
|
||||
.use(
|
||||
jwtPlugin({
|
||||
|
||||
@@ -2,7 +2,8 @@ import Elysia, { t } from "elysia";
|
||||
import { prisma } from "../lib/prisma";
|
||||
|
||||
const CredentialRoute = new Elysia({
|
||||
prefix: "/credential"
|
||||
prefix: "/credential",
|
||||
tags: ["credential"],
|
||||
})
|
||||
.post("/create", async (ctx) => {
|
||||
const { name, value } = ctx.body
|
||||
@@ -26,7 +27,7 @@ const CredentialRoute = new Elysia({
|
||||
description: 'create credential',
|
||||
}
|
||||
})
|
||||
.get("/list", async (ctx) => {
|
||||
.get("/list", async () => {
|
||||
const list = await prisma.credential.findMany()
|
||||
return {
|
||||
message: "success",
|
||||
@@ -40,7 +41,7 @@ const CredentialRoute = new Elysia({
|
||||
})
|
||||
.delete("/rm", async (ctx) => {
|
||||
const { id } = ctx.body
|
||||
const rm = await prisma.credential.delete({
|
||||
await prisma.credential.delete({
|
||||
where: {
|
||||
id: id
|
||||
}
|
||||
|
||||
@@ -5,34 +5,6 @@ const url = "https://cld-dkr-makuro-seafile.wibudev.com/api2"
|
||||
const TOKEN = "fa49bf1774cad2ec89d2882ae2c6ac1f5d7df445"
|
||||
const REPO_ID = "de64ff3c-0081-45f3-a5a6-6c799a098649"
|
||||
|
||||
const pengaduanDesa: string[] = [
|
||||
"Pengaduan Pelayanan Publik Desa",
|
||||
"Pengaduan Bantuan Sosial (Bansos)",
|
||||
"Pengaduan Penyalahgunaan Dana Desa",
|
||||
"Pengaduan Infrastruktur Rusak (jalan, jembatan, saluran air)",
|
||||
"Pengaduan Lingkungan (sampah, pencemaran, banjir)",
|
||||
"Pengaduan Keamanan dan Ketertiban",
|
||||
"Pengaduan Sengketa Tanah Desa",
|
||||
"Pengaduan Ketenagakerjaan (tenaga kerja lokal, proyek desa)",
|
||||
"Pengaduan Disiplin Aparat Desa",
|
||||
"Pengaduan Administrasi Kependudukan (KTP, KK, surat menyurat)",
|
||||
"Pengaduan Layanan Kesehatan Masyarakat",
|
||||
"Pengaduan Pendidikan (sekolah, bantuan siswa miskin)",
|
||||
"Pengaduan Usaha Mikro dan UMKM Desa",
|
||||
"Pengaduan Kegiatan Bumdes",
|
||||
"Pengaduan Pungutan Liar atau Gratifikasi",
|
||||
"Pengaduan Kekerasan Rumah Tangga atau Sosial",
|
||||
"Pengaduan Pelanggaran Adat dan Norma Sosial",
|
||||
"Pengaduan Proyek Pembangunan Tidak Transparan",
|
||||
"Pengaduan Bencana Alam dan Penanganannya",
|
||||
"Pengaduan Diskriminasi atau Ketidakadilan Sosial",
|
||||
"Pengaduan Pelanggaran Hak Tanah Kas Desa",
|
||||
"Pengaduan Penyaluran Air dan Irigasi",
|
||||
"Pengaduan Akses Internet atau Telekomunikasi Desa",
|
||||
"Pengaduan Fasilitas Umum Tidak Layak",
|
||||
"Pengaduan Kegiatan Tidak Berizin di Wilayah Desa"
|
||||
] as const;
|
||||
|
||||
|
||||
const DarmasabaRoute = new Elysia({
|
||||
prefix: "/darmasaba",
|
||||
|
||||
52
src/server/routes/layanan_route.ts
Normal file
52
src/server/routes/layanan_route.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import Elysia, { t } from "elysia";
|
||||
|
||||
const layanan = `
|
||||
KTP
|
||||
Kartu Keluarga
|
||||
surat keterangan domisili
|
||||
surat pengantar nikah
|
||||
akta kelahiran
|
||||
akta kematian
|
||||
surat pindah penduduk
|
||||
surat keterangan usaha
|
||||
surat keterangan tidak mampu
|
||||
surat keterangan waris
|
||||
surat perizinan usaha kecil
|
||||
`
|
||||
|
||||
const LayananRoute = new Elysia({
|
||||
prefix: "layanan",
|
||||
tags: ["layanan"],
|
||||
})
|
||||
.get("/list", () => {
|
||||
return {
|
||||
success: true,
|
||||
data: layanan.split("\n")
|
||||
}
|
||||
}, {
|
||||
detail: {
|
||||
summary: "list",
|
||||
description: "list layanan yang ada",
|
||||
}
|
||||
})
|
||||
.post("create-ktp", () => {
|
||||
return {
|
||||
success: true,
|
||||
data: ""
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
jenis: t.Union([
|
||||
t.Literal("ktp"),
|
||||
t.Literal("kk"),
|
||||
]),
|
||||
nama: t.String(),
|
||||
deskripsi: t.String(),
|
||||
}),
|
||||
detail: {
|
||||
summary: "create",
|
||||
description: "create layanan",
|
||||
}
|
||||
})
|
||||
|
||||
export default LayananRoute
|
||||
51
src/server/routes/user_route.ts
Normal file
51
src/server/routes/user_route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import Elysia, { t } from "elysia";
|
||||
import type { User } from "generated/prisma";
|
||||
import { prisma } from "../lib/prisma";
|
||||
|
||||
const UserRoute = new Elysia({
|
||||
prefix: "user",
|
||||
tags: ["user"],
|
||||
})
|
||||
.get('/find', (ctx) => {
|
||||
const { user } = ctx as any
|
||||
return {
|
||||
user: user as User
|
||||
}
|
||||
}, {
|
||||
detail: {
|
||||
summary: "find",
|
||||
description: "find user",
|
||||
}
|
||||
})
|
||||
.post("/upsert", async (ctx) => {
|
||||
const { name, phone } = ctx.body
|
||||
const upsert = await prisma.user.upsert({
|
||||
where: {
|
||||
phone
|
||||
},
|
||||
update: {
|
||||
name
|
||||
},
|
||||
create: {
|
||||
name,
|
||||
phone
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
upsert
|
||||
}
|
||||
|
||||
}, {
|
||||
body: t.Object({
|
||||
name: t.String({ minLength: 1, error: "name is required" }),
|
||||
phone: t.String({ minLength: 1, error: "phone is required" })
|
||||
}),
|
||||
detail: {
|
||||
summary: "upsert",
|
||||
description: "upsert user",
|
||||
}
|
||||
})
|
||||
|
||||
export default UserRoute
|
||||
127
xx.ts
Normal file
127
xx.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { readdirSync, statSync, writeFileSync } from "fs";
|
||||
import _ from "lodash";
|
||||
import { basename, extname, join, relative } from "path";
|
||||
|
||||
const PAGES_DIR = join(process.cwd(), "src/pages");
|
||||
const OUTPUT_FILE = join(process.cwd(), "src/AppRoutes.tsx");
|
||||
|
||||
// 🧩 Ubah nama file ke nama komponen (PascalCase)
|
||||
const toComponentName = (fileName: string) =>
|
||||
fileName
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
.replace(/\s/g, "");
|
||||
|
||||
// 🧩 Ubah nama file ke path route
|
||||
function toRoutePath(name: string): string {
|
||||
if (name.toLowerCase() === "home") return "/";
|
||||
if (name.toLowerCase() === "login") return "/login";
|
||||
if (name.toLowerCase() === "notfound") return "/*";
|
||||
if (name.endsWith("_page")) return name.replace("_page", "").toLowerCase();
|
||||
if (name.startsWith("form_")) return name.replace("form_", "").toLowerCase();
|
||||
return name.toLowerCase();
|
||||
}
|
||||
|
||||
// 🧭 Scan folder pages secara rekursif
|
||||
function scan(dir: string): any[] {
|
||||
const items = readdirSync(dir);
|
||||
const routes: any[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const full = join(dir, item);
|
||||
const stat = statSync(full);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
routes.push({
|
||||
name: item,
|
||||
path: item.toLowerCase(),
|
||||
children: scan(full),
|
||||
});
|
||||
} else if (extname(item) === ".tsx") {
|
||||
routes.push({
|
||||
name: basename(item, ".tsx"),
|
||||
filePath: relative(join(process.cwd(), "src"), full).replace(/\\/g, "/"),
|
||||
});
|
||||
}
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
// 🏗️ Generate <Route> JSX dari struktur folder
|
||||
function generateJSX(routes: any[], parentPath = ""): string {
|
||||
let jsx = "";
|
||||
|
||||
for (const route of routes) {
|
||||
if (route.children) {
|
||||
// cari layout di folder
|
||||
const layout = route.children.find((r: any) => r.name.endsWith("_layout"));
|
||||
if (layout) {
|
||||
const LayoutComponent = toComponentName(layout.name.replace("_layout", "Layout"));
|
||||
const nested = route.children.filter((r: any) => r !== layout);
|
||||
const nestedRoutes = generateJSX(nested, `${parentPath}/${route.path}`);
|
||||
jsx += `
|
||||
<Route path="${parentPath}/${route.path}" element={<${LayoutComponent} />}>
|
||||
${nestedRoutes}
|
||||
</Route>
|
||||
`;
|
||||
} else {
|
||||
jsx += generateJSX(route.children, `${parentPath}/${route.path}`);
|
||||
}
|
||||
} else {
|
||||
const Component = toComponentName(route.name);
|
||||
const routePath = toRoutePath(route.name);
|
||||
|
||||
// Hapus duplikasi segmen
|
||||
const fullPath =
|
||||
routePath.startsWith("/")
|
||||
? routePath
|
||||
: `${parentPath}/${_.kebabCase(routePath)}`.replace(/\/+/g, "/");
|
||||
|
||||
jsx += `<Route path="${fullPath}" element={<${Component} />} />\n`;
|
||||
}
|
||||
}
|
||||
return jsx;
|
||||
}
|
||||
|
||||
// 🧾 Generate import otomatis
|
||||
function generateImports(routes: any[]): string {
|
||||
const imports = new Set<string>();
|
||||
|
||||
function collect(rs: any[]) {
|
||||
for (const r of rs) {
|
||||
if (r.children) collect(r.children);
|
||||
else {
|
||||
const Comp = toComponentName(r.name);
|
||||
imports.add(`import ${Comp} from "./${r.filePath.replace(/\.tsx$/, "")}";`);
|
||||
}
|
||||
}
|
||||
}
|
||||
collect(routes);
|
||||
return Array.from(imports).join("\n");
|
||||
}
|
||||
|
||||
// 🧠 Main generator
|
||||
const allRoutes = scan(PAGES_DIR);
|
||||
const imports = generateImports(allRoutes);
|
||||
const jsxRoutes = generateJSX(allRoutes);
|
||||
|
||||
const finalCode = `
|
||||
// ⚡ Auto-generated by generateRoutes.ts — DO NOT EDIT MANUALLY
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import ProtectedRoute from "./components/ProtectedRoute";
|
||||
${imports}
|
||||
|
||||
export default function AppRoutes() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
${jsxRoutes}
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
writeFileSync(OUTPUT_FILE, finalCode);
|
||||
console.log("✅ Routes generated successfully → src/AppRoutes.generated.tsx");
|
||||
Bun.spawnSync(["bunx", "prettier", "--write", "src/**/*.tsx"])
|
||||
Reference in New Issue
Block a user