This commit is contained in:
bipproduction
2025-10-12 21:49:54 +08:00
parent 86d5b435f7
commit 9850fab34d
44 changed files with 8533 additions and 2108 deletions

230
bun.lock
View File

@@ -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=="],
}
}

BIN
image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

1530
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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[]

View File

@@ -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>
);
}

View File

@@ -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
View 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>
);
}

View File

@@ -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;

View File

@@ -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 />;
}

View File

@@ -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;

View File

@@ -1,4 +1,3 @@
export default function Home() {
return (
<div>
@@ -6,4 +5,3 @@ export default function Home() {
</div>
);
}

View File

@@ -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>
)
);
}

View File

@@ -1,4 +1,3 @@
export default function NotFound() {
return (
<div>
@@ -6,4 +5,3 @@ export default function NotFound() {
</div>
);
}

View 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>
);
}

View File

@@ -0,0 +1,5 @@
import { Outlet } from "react-router-dom";
export default function DarmasabaLayout() {
return <Outlet />;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
}

View File

@@ -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>
)
}

View File

@@ -1,11 +0,0 @@
import apiFetch from "@/lib/apiFetch";
import { Button } from "@mantine/core";
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
</div>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,7 @@
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
</div>
);
}

View 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>
);
}

View 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 />;
}

View File

@@ -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] ??= {}

View File

@@ -16,7 +16,7 @@ type JWT = {
const ApiKeyRoute = new Elysia({
prefix: '/apikey',
detail: { tags: ['apikey'] },
tags: ["apikey"],
})
.post(
'/create',

View File

@@ -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({

View File

@@ -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
}

View File

@@ -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",

View 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

View 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
View 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"])