feat: initial commit with seeded database and premium ui changes

This commit is contained in:
Dodo
2026-06-11 18:41:42 +07:00
commit edf94ae5c1
1684 changed files with 33073 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
# Dependencies
node_modules/
.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# Next.js Build Output
.next/
out/
build/
# Python Cache
__pycache__/
*.pyc
*.pyo
*.pyd
.venv/
env/
venv/
ENV/
.pytest_cache/
.coverage
htmlcov/
# Logs and system files
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
.DS_Store
Thumbs.db
# Environmental variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
*.pem
# Editor/IDE configs
.idea/
.vscode/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+4
View File
@@ -0,0 +1,4 @@
PORT=4000
JWT_SECRET=replace-with-secure-random-secret
FRONTEND_URL=http://localhost:3000
DATABASE_URL=postgres://sig:sig_secret@localhost:5432/sig
+12
View File
@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src ./src
EXPOSE 4000
CMD ["node", "src/server.js"]
+218
View File
@@ -0,0 +1,218 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "sig-backend",
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"pg": "^8.13.3",
},
},
},
"packages": {
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
"bcryptjs": ["bcryptjs@2.4.3", "", {}, "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ=="],
"body-parser": ["body-parser@1.20.5", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA=="],
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnsigP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"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=="],
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"express": ["express@4.22.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q=="],
"finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
"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=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="],
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
"lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
"lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="],
"lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="],
"lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="],
"lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="],
"lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
"methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="],
"mime": ["mime@1.6.0", "", { "bin": "cli.js" }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="],
"pg": ["pg@8.21.0", "", { "dependencies": { "pg-connection-string": "^2.13.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.14.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA=="],
"pg-cloudflare": ["pg-cloudflare@1.4.0", "", {}, "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A=="],
"pg-connection-string": ["pg-connection-string@2.13.0", "", {}, "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig=="],
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
"pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="],
"pg-protocol": ["pg-protocol@1.14.0", "", {}, "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA=="],
"pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="],
"pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="],
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
"postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="],
"postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="],
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"semver": ["semver@7.8.3", "", { "bin": "bin/semver.js" }, "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg=="],
"send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="],
"serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="],
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
}
}
+940
View File
@@ -0,0 +1,940 @@
{
"name": "sig-backend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sig-backend",
"version": "1.0.0",
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"pg": "^8.13.3"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"license": "MIT"
},
"node_modules/bcryptjs": {
"version": "2.4.3",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.5",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"license": "BSD-3-Clause"
},
"node_modules/bytes": {
"version": "3.1.2",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.6",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/debug": {
"version": "2.6.9",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/debug/node_modules/ms": {
"version": "2.0.0",
"license": "MIT"
},
"node_modules/depd": {
"version": "2.0.0",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.2",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/ms": {
"version": "2.1.3",
"license": "MIT"
},
"node_modules/jwa": {
"version": "2.0.1",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"license": "MIT"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/negotiator": {
"version": "0.6.3",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"license": "MIT"
},
"node_modules/pg": {
"version": "8.21.0",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.13.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.14.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.13.0",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.14.0",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.2",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.3",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": {
"version": "0.19.2",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.1",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/split2": {
"version": "4.2.0",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "sig-backend",
"version": "1.0.0",
"description": "Auth backend for SIG dashboard",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"pg": "^8.13.3"
}
}
+27
View File
@@ -0,0 +1,27 @@
require("dotenv").config();
const express = require("express");
const cors = require("cors");
const authRoutes = require("./routes/auth.routes");
const householdsRoutes = require("./routes/households.routes");
const poiRoutes = require("./routes/poi.routes");
const landRoutes = require("./routes/land.routes");
const gasStationRoutes = require("./routes/gas-station.routes");
const app = express();
app.use(cors({
origin: process.env.FRONTEND_URL || "http://localhost:3000",
credentials: true,
}));
app.use(express.json());
app.use("/auth", authRoutes);
app.use("/households", householdsRoutes);
app.use("/poi", poiRoutes);
app.use("/land", landRoutes);
app.use("/gas-station", gasStationRoutes);
app.get("/health", (_req, res) => res.json({ status: "ok" }));
module.exports = app;
+253
View File
@@ -0,0 +1,253 @@
const bcrypt = require("bcryptjs");
const pool = require("./db");
const {
SEED_HOUSEHOLDS,
SEED_POI,
SEED_LAND_MARKERS,
SEED_LAND_SHAPES,
SEED_GAS_STATIONS,
} = require("./seed-data");
const SALT = 10;
const SEED_USERS = [
{ username: "superadmin", email: "superadmin@app.local", role: "superadmin", password: "Super@1234" },
{ username: "admin_poverty", email: "poverty@app.local", role: "admin_poverty", password: "Poverty@1234" },
{ username: "admin_lands", email: "lands@app.local", role: "admin_lands_roads", password: "Lands@1234" },
{ username: "admin_gas", email: "gas@app.local", role: "admin_gas_stations",password: "Gas@1234" },
];
async function initDb() {
await pool.query(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
role VARCHAR(100) NOT NULL,
password_hash TEXT NOT NULL
)
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS poor_households (
id SERIAL PRIMARY KEY,
head_name VARCHAR(255) NOT NULL,
family_count INT NOT NULL DEFAULT 1,
poverty_level VARCHAR(10) NOT NULL DEFAULT 'Miskin',
latitude DECIMAL(10, 8) NOT NULL,
longitude DECIMAL(11, 8) NOT NULL,
notes TEXT,
penghasilan INT NOT NULL DEFAULT 0,
kondisi_rumah SMALLINT NOT NULL DEFAULT 5,
kepemilikan_aset SMALLINT NOT NULL DEFAULT 5,
marker_type VARCHAR(50) DEFAULT 'marker',
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS poi_markers (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
poi_type VARCHAR(50) NOT NULL,
religion_subtype VARCHAR(50),
latitude DECIMAL(10,8) NOT NULL,
longitude DECIMAL(11,8) NOT NULL,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await pool.query(`
ALTER TABLE poor_households
ADD COLUMN IF NOT EXISTS fuzzy_score DECIMAL(5,2) DEFAULT NULL
`);
await pool.query(`
ALTER TABLE poor_households
ADD COLUMN IF NOT EXISTS fuzzy_label VARCHAR(50) DEFAULT NULL
`);
await pool.query(`
ALTER TABLE poi_markers
ADD COLUMN IF NOT EXISTS radius_meters INT DEFAULT 0
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS land_markers (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
marker_type VARCHAR(50) NOT NULL,
latitude DECIMAL(10,8) NOT NULL,
longitude DECIMAL(11,8) NOT NULL,
notes TEXT,
created_by INT REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await pool.query(`
CREATE INDEX IF NOT EXISTS idx_land_markers_created_by ON land_markers(created_by)
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS land_shapes (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
shape_type VARCHAR(50) NOT NULL,
status VARCHAR(50),
latitude DECIMAL(10,8) NOT NULL,
longitude DECIMAL(11,8) NOT NULL,
coordinates JSONB,
radius_meters DECIMAL(10,2),
notes TEXT,
created_by INT REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await pool.query(`
CREATE INDEX IF NOT EXISTS idx_land_shapes_created_by ON land_shapes(created_by)
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS gas_station_markers (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
marker_category VARCHAR(50) NOT NULL,
brand VARCHAR(50),
sub_type VARCHAR(100),
gas_types JSONB,
operating_hours VARCHAR(255),
status VARCHAR(50),
latitude DECIMAL(10,8) NOT NULL,
longitude DECIMAL(11,8) NOT NULL,
notes TEXT,
created_by INT REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await pool.query(`
CREATE INDEX IF NOT EXISTS idx_gas_station_markers_created_by ON gas_station_markers(created_by)
`);
await pool.query(`
CREATE INDEX IF NOT EXISTS idx_gas_station_markers_category ON gas_station_markers(marker_category)
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS pertamina_fuel_prices (
id SERIAL PRIMARY KEY,
last_fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
raw_data JSONB NOT NULL
)
`);
// ── Seed Users ──────────────────────────────────────────────────────────────
for (const u of SEED_USERS) {
const hash = await bcrypt.hash(u.password, SALT);
await pool.query(
`INSERT INTO users (username, email, role, password_hash)
VALUES ($1, $2, $3, $4)
ON CONFLICT (username) DO NOTHING`,
[u.username, u.email, u.role, hash]
);
}
console.log(`[seed] users seeded (${SEED_USERS.length} entries)`);
// ── Seed Poor Households ────────────────────────────────────────────────────
const hhCount = await pool.query("SELECT COUNT(*)::int AS c FROM poor_households");
if (hhCount.rows[0].c === 0) {
for (const h of SEED_HOUSEHOLDS) {
await pool.query(
`INSERT INTO poor_households
(head_name, family_count, poverty_level, latitude, longitude, notes,
penghasilan, kondisi_rumah, kepemilikan_aset, fuzzy_score, fuzzy_label)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
[
h.head_name, h.family_count, h.poverty_level,
h.latitude, h.longitude, h.notes,
h.penghasilan, h.kondisi_rumah, h.kepemilikan_aset,
h.fuzzy_score, h.fuzzy_label,
]
);
}
console.log(`[seed] poor_households seeded (${SEED_HOUSEHOLDS.length} entries)`);
}
// ── Seed POI Markers ────────────────────────────────────────────────────────
const poiCount = await pool.query("SELECT COUNT(*)::int AS c FROM poi_markers");
if (poiCount.rows[0].c === 0) {
for (const p of SEED_POI) {
await pool.query(
`INSERT INTO poi_markers (name, poi_type, religion_subtype, latitude, longitude, notes, radius_meters)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[p.name, p.poi_type, p.religion_subtype, p.latitude, p.longitude, p.notes, p.radius_meters]
);
}
console.log(`[seed] poi_markers seeded (${SEED_POI.length} entries)`);
}
// ── Seed Land Markers ───────────────────────────────────────────────────────
const lmCount = await pool.query("SELECT COUNT(*)::int AS c FROM land_markers");
if (lmCount.rows[0].c === 0) {
// Get superadmin id for created_by
const adminRow = await pool.query("SELECT id FROM users WHERE username = 'superadmin' LIMIT 1");
const createdBy = adminRow.rows.length ? adminRow.rows[0].id : null;
for (const lm of SEED_LAND_MARKERS) {
await pool.query(
`INSERT INTO land_markers (name, marker_type, latitude, longitude, notes, created_by)
VALUES ($1,$2,$3,$4,$5,$6)`,
[lm.name, lm.marker_type, lm.latitude, lm.longitude, lm.notes, createdBy]
);
}
console.log(`[seed] land_markers seeded (${SEED_LAND_MARKERS.length} entries)`);
}
// ── Seed Land Shapes ────────────────────────────────────────────────────────
const lsCount = await pool.query("SELECT COUNT(*)::int AS c FROM land_shapes");
if (lsCount.rows[0].c === 0) {
const adminRow2 = await pool.query("SELECT id FROM users WHERE username = 'superadmin' LIMIT 1");
const createdBy2 = adminRow2.rows.length ? adminRow2.rows[0].id : null;
for (const ls of SEED_LAND_SHAPES) {
await pool.query(
`INSERT INTO land_shapes (name, shape_type, status, latitude, longitude, coordinates, radius_meters, notes, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
[ls.name, ls.shape_type, ls.status, ls.latitude, ls.longitude, ls.coordinates, ls.radius_meters, ls.notes, createdBy2]
);
}
console.log(`[seed] land_shapes seeded (${SEED_LAND_SHAPES.length} entries)`);
}
// ── Seed Gas Station Markers ────────────────────────────────────────────────
const gsCount = await pool.query("SELECT COUNT(*)::int AS c FROM gas_station_markers");
if (gsCount.rows[0].c === 0) {
const adminRow3 = await pool.query("SELECT id FROM users WHERE username = 'superadmin' LIMIT 1");
const createdBy3 = adminRow3.rows.length ? adminRow3.rows[0].id : null;
for (const gs of SEED_GAS_STATIONS) {
await pool.query(
`INSERT INTO gas_station_markers
(name, marker_category, brand, sub_type, gas_types, operating_hours, status, latitude, longitude, notes, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
[
gs.name, gs.marker_category, gs.brand, gs.sub_type,
gs.gas_types, gs.operating_hours, gs.status,
gs.latitude, gs.longitude, gs.notes, createdBy3,
]
);
}
console.log(`[seed] gas_station_markers seeded (${SEED_GAS_STATIONS.length} entries)`);
}
console.log("[seed] Database initialization complete.");
}
module.exports = initDb;
+7
View File
@@ -0,0 +1,7 @@
const { Pool } = require("pg");
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
module.exports = pool;
+71
View File
@@ -0,0 +1,71 @@
const express = require("express");
const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken");
const { findByUsernameOrEmail } = require("../users");
const router = express.Router();
const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production";
const JWT_EXPIRES_IN = "8h";
router.post("/login", async (req, res) => {
const username = req.body.username?.trim();
const password = req.body.password;
if (!username || !password) {
return res.status(400).json({ error: "username and password are required" });
}
const user = await findByUsernameOrEmail(username);
if (!user) {
return res.status(401).json({ error: "Invalid credentials" });
}
const match = await bcrypt.compare(password, user.password_hash);
if (!match) {
return res.status(401).json({ error: "Invalid credentials" });
}
const payload = {
sub: user.id,
username: user.username,
email: user.email,
role: user.role,
};
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
return res.json({
token,
user: {
id: user.id,
username: user.username,
email: user.email,
role: user.role,
},
});
});
router.get("/me", (req, res) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ error: "Unauthorized" });
}
const token = authHeader.slice(7);
try {
const payload = jwt.verify(token, JWT_SECRET);
return res.json({
user: {
id: payload.sub,
username: payload.username,
email: payload.email,
role: payload.role,
},
});
} catch {
return res.status(401).json({ error: "Invalid token" });
}
});
module.exports = router;
+339
View File
@@ -0,0 +1,339 @@
const express = require("express");
const jwt = require("jsonwebtoken");
const pool = require("../db");
const fs = require("fs");
const path = require("path");
const router = express.Router();
const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production";
// Parser helper
function parseFuelData(apiResponse) {
if (!apiResponse || !apiResponse.data || !apiResponse.data.content) {
throw new Error("Invalid API response structure");
}
const content = apiResponse.data.content;
const productTableNode = Object.values(content).find(
node => node && node.type && node.type.resolvedName === "ProductTable"
);
if (!productTableNode || !productTableNode.props || !Array.isArray(productTableNode.props.items)) {
throw new Error("ProductTable not found in content");
}
const items = productTableNode.props.items;
const headingNode = Object.values(content).find(
node => node && node.type && node.type.resolvedName === "Heading"
);
const lastUpdated = headingNode?.props?.text || apiResponse.data.title || "Update terbaru";
const gasoline = [];
const diesel = [];
items.forEach(item => {
const isGasoline = item.title && (item.title.toLowerCase() === "gasoline" || item.title.toLowerCase() === "bensin");
const list = isGasoline ? gasoline : diesel;
if (Array.isArray(item.data)) {
item.data.forEach(row => {
const parsed = {
region: row.REGION?.trim(),
pertalite: null,
pertamax: null,
pertamaxGreen: null,
pertamaxTurbo: null,
pertamaxPertashop: null,
pertaminaDex: null,
dexlite: null,
bioSolarNonSubsidi: null,
bioSolarSubsidi: null
};
for (const [key, value] of Object.entries(row)) {
if (key === "REGION") continue;
const cleanVal = value?.trim().replace(/,/g, "");
const numericVal = (cleanVal && cleanVal !== "-" && !isNaN(cleanVal)) ? parseInt(cleanVal, 10) : null;
if (key.includes("product-table-pertalite.png")) {
parsed.pertalite = numericVal;
} else if (key.includes("product-table-pertamax.png")) {
parsed.pertamax = numericVal;
} else if (key.includes("product-table-pertamax-green-95.png")) {
parsed.pertamaxGreen = numericVal;
} else if (key.includes("product-table-pertamax-turbo.png")) {
parsed.pertamaxTurbo = numericVal;
} else if (key.includes("harga-produk-pertamax-pertashop.jpg")) {
parsed.pertamaxPertashop = numericVal;
} else if (key.includes("product-table-pertamina-dex.png")) {
parsed.pertaminaDex = numericVal;
} else if (key.includes("product-table-dexlite.png")) {
parsed.dexlite = numericVal;
} else if (key.includes("harga-produk-bio-solar-non-subsidi.jpg")) {
parsed.bioSolarNonSubsidi = numericVal;
} else if (key.includes("harga-produk-bio-solar-subsidi.jpg")) {
parsed.bioSolarSubsidi = numericVal;
}
}
list.push(parsed);
});
}
});
return {
succeeded: true,
lastUpdated,
gasoline,
diesel
};
}
// GET latest Pertamina fuel prices (cached, daily fetch)
router.get("/fuel-prices", async (_req, res) => {
try {
// 1. Check if we have a fresh DB cache
const { rows } = await pool.query(
"SELECT * FROM pertamina_fuel_prices ORDER BY last_fetched_at DESC LIMIT 1"
);
let rawData = null;
let shouldFetch = true;
if (rows.length > 0) {
const cache = rows[0];
const now = new Date();
const lastFetched = new Date(cache.last_fetched_at);
const diffMs = now - lastFetched;
// If cached less than 24 hours ago, use cache
if (diffMs < 24 * 60 * 60 * 1000) {
rawData = cache.raw_data;
shouldFetch = false;
console.log("Serving fuel prices from DB cache");
}
}
if (shouldFetch) {
console.log("DB Cache stale or missing. Fetching fresh fuel prices from Pertamina API...");
try {
const response = await fetch(
"https://pertaminapatraniaga.com/api/api/v1/post/get-by-slug/page/harga-terbaru-bbm?language=id"
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
rawData = await response.json();
// Save to DB cache
await pool.query(
"INSERT INTO pertamina_fuel_prices (last_fetched_at, raw_data) VALUES (NOW(), $1)",
[JSON.stringify(rawData)]
);
console.log("Successfully cached fresh fuel prices to DB");
} catch (fetchErr) {
console.error("Failed to fetch fresh fuel prices from Pertamina API:", fetchErr);
// Fall back to stale cache from DB if available
if (rows.length > 0) {
rawData = rows[0].raw_data;
console.warn("Serving stale fuel prices from DB cache after fetch failure");
} else {
// If no DB cache exists, read from local fallback file
console.warn("No DB cache available. Loading from pertamina-fallback.json file");
const fallbackPath = path.join(__dirname, "pertamina-fallback.json");
const fileData = fs.readFileSync(fallbackPath, "utf8");
rawData = JSON.parse(fileData);
}
}
}
const parsed = parseFuelData(rawData);
res.json(parsed);
} catch (err) {
console.error("Error in /fuel-prices endpoint:", err);
res.status(500).json({ error: "Internal server error" });
}
});
function requireAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
return res.status(401).json({ error: "Unauthorized" });
}
try {
req.user = jwt.verify(authHeader.slice(7), JWT_SECRET);
next();
} catch {
res.status(401).json({ error: "Invalid token" });
}
}
// GET all gas station markers
router.get("/markers", async (_req, res) => {
try {
const { rows } = await pool.query(`
SELECT gsm.*, u.username AS created_by_username
FROM gas_station_markers gsm
LEFT JOIN users u ON u.id = gsm.created_by
ORDER BY gsm.created_at DESC
`);
res.json(rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: "Database error" });
}
});
// GET stats
router.get("/stats", async (_req, res) => {
try {
const totalResult = await pool.query("SELECT COUNT(*) FROM gas_station_markers");
const categoryResult = await pool.query(`
SELECT marker_category, COUNT(*) as count
FROM gas_station_markers
GROUP BY marker_category
`);
const activeEvResult = await pool.query(`
SELECT COUNT(*) FROM gas_station_markers
WHERE marker_category = 'charging-station' AND status = 'Active'
`);
const stats = {
total: parseInt(totalResult.rows[0].count, 10),
gas_pump: 0,
charging_station: 0,
charging_station_active: parseInt(activeEvResult.rows[0].count, 10),
wrench: 0
};
categoryResult.rows.forEach(r => {
const cat = r.marker_category;
if (cat === "gas-pump") stats.gas_pump = parseInt(r.count, 10);
else if (cat === "charging-station") stats.charging_station = parseInt(r.count, 10);
else if (cat === "wrench") stats.wrench = parseInt(r.count, 10);
});
res.json(stats);
} catch (err) {
console.error(err);
res.status(500).json({ error: "Database error" });
}
});
// GET single marker
router.get("/markers/:id", async (req, res) => {
try {
const { rows } = await pool.query(`
SELECT gsm.*, u.username AS created_by_username
FROM gas_station_markers gsm
LEFT JOIN users u ON u.id = gsm.created_by
WHERE gsm.id = $1
`, [req.params.id]);
if (!rows.length) return res.status(404).json({ error: "Not found" });
res.json(rows[0]);
} catch (err) {
console.error(err);
res.status(500).json({ error: "Database error" });
}
});
// POST new marker
router.post("/markers", requireAuth, async (req, res) => {
const { name, marker_category, brand, sub_type, gas_types, operating_hours, status, latitude, longitude, notes } = req.body;
if (!name || !marker_category || latitude == null || longitude == null) {
return res.status(400).json({ error: "name, marker_category, latitude, longitude are required" });
}
try {
const { rows } = await pool.query(
`INSERT INTO gas_station_markers (
name, marker_category, brand, sub_type, gas_types, operating_hours, status, latitude, longitude, notes, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *`,
[
name,
marker_category,
brand ?? null,
sub_type ?? null,
gas_types ? JSON.stringify(gas_types) : null,
operating_hours ?? null,
status ?? null,
latitude,
longitude,
notes ?? null,
req.user.sub
]
);
res.status(201).json({ ...rows[0], created_by_username: req.user.username });
} catch (err) {
console.error(err);
res.status(500).json({ error: "Database error" });
}
});
// PUT update marker
router.put("/markers/:id", requireAuth, async (req, res) => {
const { name, marker_category, brand, sub_type, gas_types, operating_hours, status, latitude, longitude, notes } = req.body;
try {
const { rows } = await pool.query(`
WITH updated AS (
UPDATE gas_station_markers
SET name = COALESCE($1, name),
marker_category = COALESCE($2, marker_category),
brand = $3,
sub_type = $4,
gas_types = $5,
operating_hours = $6,
status = $7,
latitude = COALESCE($8, latitude),
longitude = COALESCE($9, longitude),
notes = $10,
updated_at = NOW()
WHERE id = $11
RETURNING *
)
SELECT updated.*, u.username AS created_by_username
FROM updated
LEFT JOIN users u ON u.id = updated.created_by
`, [
name ?? null,
marker_category ?? null,
brand ?? null,
sub_type ?? null,
gas_types ? JSON.stringify(gas_types) : null,
operating_hours ?? null,
status ?? null,
latitude ?? null,
longitude ?? null,
notes ?? null,
req.params.id
]);
if (!rows.length) return res.status(404).json({ error: "Not found" });
res.json(rows[0]);
} catch (err) {
console.error(err);
res.status(500).json({ error: "Database error" });
}
});
// DELETE marker
router.delete("/markers/:id", requireAuth, async (req, res) => {
try {
const { rowCount } = await pool.query(
"DELETE FROM gas_station_markers WHERE id = $1",
[req.params.id]
);
if (!rowCount) return res.status(404).json({ error: "Not found" });
res.status(204).send();
} catch (err) {
console.error(err);
res.status(500).json({ error: "Database error" });
}
});
module.exports = router;
+311
View File
@@ -0,0 +1,311 @@
const express = require("express");
const jwt = require("jsonwebtoken");
const pool = require("../db");
const router = express.Router();
const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production";
const FUZZY_URL = process.env.FUZZY_SERVICE_URL ?? "http://localhost:5001";
function requireAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
return res.status(401).json({ error: "Unauthorized" });
}
try {
req.user = jwt.verify(authHeader.slice(7), JWT_SECRET);
next();
} catch {
res.status(401).json({ error: "Invalid token" });
}
}
const FUZZY_LABEL_TO_POVERTY_LEVEL = {
"SANGAT TINGGI": "Extreme",
"TINGGI": "Extreme",
"SEDANG": "Miskin",
"RENDAH": "Miskin",
"TIDAK PRIORITAS": "Rentan",
};
async function callFuzzyService(id, penghasilan, tanggungan, kondisi_rumah, kepemilikan_aset) {
try {
const res = await fetch(`${FUZZY_URL}/compute`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, penghasilan, tanggungan, kondisi_rumah, kepemilikan_aset }),
});
if (!res.ok) return null;
return await res.json(); // { id, score, label }
} catch {
return null; // degrade gracefully when service is unavailable
}
}
router.get("/stats-fuzzy", async (_req, res) => {
try {
const { rows } = await pool.query(`
SELECT fuzzy_label, COUNT(*) AS count
FROM poor_households
WHERE fuzzy_label IS NOT NULL
GROUP BY fuzzy_label
`);
const labels = ["SANGAT TINGGI", "TINGGI", "SEDANG", "RENDAH", "TIDAK PRIORITAS"];
const stats = Object.fromEntries(labels.map((l) => [l, 0]));
for (const row of rows) stats[row.fuzzy_label] = parseInt(row.count, 10);
res.json(stats);
} catch {
res.status(500).json({ error: "Database error" });
}
});
// Aggregated, DB-backed numbers for the poverty dashboard.
router.get("/stats-overview", async (_req, res) => {
try {
const [totalsQ, labelsQ, monthlyQ, poiQ] = await Promise.all([
pool.query(`
SELECT COUNT(*)::int AS household_count,
COALESCE(SUM(family_count),0)::int AS total_dependents,
COALESCE(AVG(penghasilan),0)::float AS avg_income,
AVG(fuzzy_score)::float AS avg_score
FROM poor_households
`),
pool.query(`
SELECT fuzzy_label, COUNT(*)::int AS count
FROM poor_households
WHERE fuzzy_label IS NOT NULL
GROUP BY fuzzy_label
`),
pool.query(`
SELECT to_char(date_trunc('month', created_at), 'YYYY-MM') AS month,
COUNT(*)::int AS count,
AVG(fuzzy_score)::float AS avg_score
FROM poor_households
WHERE created_at >= date_trunc('month', NOW()) - INTERVAL '11 months'
GROUP BY 1
ORDER BY 1
`),
pool.query(`
SELECT poi_type, COUNT(*)::int AS count
FROM poi_markers
GROUP BY poi_type
`),
]);
const labels = ["SANGAT TINGGI", "TINGGI", "SEDANG", "RENDAH", "TIDAK PRIORITAS"];
const byLabel = Object.fromEntries(labels.map((l) => [l, 0]));
for (const row of labelsQ.rows) byLabel[row.fuzzy_label] = row.count;
res.json({
totals: totalsQ.rows[0],
by_label: byLabel,
monthly: monthlyQ.rows,
poi: Object.fromEntries(poiQ.rows.map((r) => [r.poi_type, r.count])),
});
} catch {
res.status(500).json({ error: "Database error" });
}
});
// Re-score every stored household with the current fuzzy engine.
router.post("/recompute-fuzzy", requireAuth, async (_req, res) => {
try {
const { rows } = await pool.query(
`SELECT id, penghasilan, family_count, kondisi_rumah, kepemilikan_aset
FROM poor_households`
);
if (!rows.length) return res.json({ updated: 0, total: 0 });
const fuzzyRes = await fetch(`${FUZZY_URL}/batch`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
households: rows.map((r) => ({
id: r.id,
penghasilan: r.penghasilan,
tanggungan: r.family_count,
kondisi_rumah: r.kondisi_rumah,
kepemilikan_aset: r.kepemilikan_aset,
})),
}),
});
if (!fuzzyRes.ok) {
return res.status(502).json({ error: "Fuzzy service unavailable" });
}
const { results } = await fuzzyRes.json();
let updated = 0;
for (const r of results) {
const povertyLevel = FUZZY_LABEL_TO_POVERTY_LEVEL[r.label];
const { rowCount } = await pool.query(
`UPDATE poor_households
SET fuzzy_score = $1, fuzzy_label = $2,
poverty_level = COALESCE($3, poverty_level)
WHERE id = $4`,
[r.score, r.label, povertyLevel ?? null, r.id]
);
updated += rowCount;
}
res.json({ updated, total: rows.length });
} catch {
res.status(500).json({ error: "Recompute failed" });
}
});
router.get("/", async (_req, res) => {
try {
const { rows } = await pool.query(
"SELECT * FROM poor_households ORDER BY created_at DESC"
);
res.json(rows);
} catch (err) {
res.status(500).json({ error: "Database error" });
}
});
router.post("/", requireAuth, async (req, res) => {
const {
head_name,
family_count,
latitude,
longitude,
notes,
penghasilan,
kondisi_rumah,
kepemilikan_aset,
marker_type,
} = req.body;
if (!head_name || latitude == null || longitude == null) {
return res.status(400).json({ error: "head_name, latitude, longitude are required" });
}
try {
const { rows } = await pool.query(
`INSERT INTO poor_households
(head_name, family_count, poverty_level, latitude, longitude,
notes, penghasilan, kondisi_rumah, kepemilikan_aset, marker_type)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *`,
[
head_name,
family_count ?? 1,
"Miskin",
latitude,
longitude,
notes ?? null,
penghasilan ?? 0,
kondisi_rumah ?? 5,
kepemilikan_aset ?? 5,
marker_type ?? "marker",
]
);
const household = rows[0];
const fuzzyResult = await callFuzzyService(
household.id,
household.penghasilan,
household.family_count, // family_count = tanggungan
household.kondisi_rumah,
household.kepemilikan_aset
);
if (fuzzyResult) {
const povertyLevel = FUZZY_LABEL_TO_POVERTY_LEVEL[fuzzyResult.label] ?? household.poverty_level;
await pool.query(
`UPDATE poor_households SET fuzzy_score = $1, fuzzy_label = $2, poverty_level = $3 WHERE id = $4`,
[fuzzyResult.score, fuzzyResult.label, povertyLevel, household.id]
);
household.fuzzy_score = fuzzyResult.score;
household.fuzzy_label = fuzzyResult.label;
household.fuzzy_detail = fuzzyResult.faktor ?? null; // not persisted, response only
household.poverty_level = povertyLevel;
}
res.status(201).json(household);
} catch (err) {
res.status(500).json({ error: "Database error" });
}
});
router.put("/:id", requireAuth, async (req, res) => {
const {
head_name,
family_count,
latitude,
longitude,
notes,
penghasilan,
kondisi_rumah,
kepemilikan_aset,
} = req.body;
try {
const { rows } = await pool.query(
`UPDATE poor_households
SET head_name = COALESCE($1, head_name),
family_count = COALESCE($2, family_count),
latitude = COALESCE($3, latitude),
longitude = COALESCE($4, longitude),
notes = $5,
penghasilan = COALESCE($6, penghasilan),
kondisi_rumah = COALESCE($7, kondisi_rumah),
kepemilikan_aset= COALESCE($8, kepemilikan_aset)
WHERE id = $9
RETURNING *`,
[
head_name ?? null,
family_count ?? null,
latitude ?? null,
longitude ?? null,
notes ?? null,
penghasilan ?? null,
kondisi_rumah ?? null,
kepemilikan_aset ?? null,
req.params.id,
]
);
if (!rows.length) return res.status(404).json({ error: "Not found" });
const household = rows[0];
const fuzzyResult = await callFuzzyService(
household.id,
household.penghasilan,
household.family_count,
household.kondisi_rumah,
household.kepemilikan_aset
);
if (fuzzyResult) {
const povertyLevel = FUZZY_LABEL_TO_POVERTY_LEVEL[fuzzyResult.label] ?? household.poverty_level;
await pool.query(
`UPDATE poor_households SET fuzzy_score = $1, fuzzy_label = $2, poverty_level = $3 WHERE id = $4`,
[fuzzyResult.score, fuzzyResult.label, povertyLevel, household.id]
);
household.fuzzy_score = fuzzyResult.score;
household.fuzzy_label = fuzzyResult.label;
household.fuzzy_detail = fuzzyResult.faktor ?? null; // not persisted, response only
household.poverty_level = povertyLevel;
}
res.json(household);
} catch {
res.status(500).json({ error: "Database error" });
}
});
router.delete("/:id", requireAuth, async (req, res) => {
try {
const { rowCount } = await pool.query(
"DELETE FROM poor_households WHERE id = $1",
[req.params.id]
);
if (!rowCount) return res.status(404).json({ error: "Not found" });
res.status(204).send();
} catch {
res.status(500).json({ error: "Database error" });
}
});
module.exports = router;
+233
View File
@@ -0,0 +1,233 @@
const express = require("express");
const jwt = require("jsonwebtoken");
const pool = require("../db");
const router = express.Router();
const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production";
function requireAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
return res.status(401).json({ error: "Unauthorized" });
}
try {
req.user = jwt.verify(authHeader.slice(7), JWT_SECRET);
next();
} catch {
res.status(401).json({ error: "Invalid token" });
}
}
/* ───────── land_markers (marker / flag / protected / registry) ───────── */
router.get("/markers", async (_req, res) => {
try {
const { rows } = await pool.query(`
SELECT lm.*, u.username AS created_by_username
FROM land_markers lm
LEFT JOIN users u ON u.id = lm.created_by
ORDER BY lm.created_at DESC
`);
res.json(rows);
} catch {
res.status(500).json({ error: "Database error" });
}
});
router.get("/markers/:id", async (req, res) => {
try {
const { rows } = await pool.query(`
SELECT lm.*, u.username AS created_by_username
FROM land_markers lm
LEFT JOIN users u ON u.id = lm.created_by
WHERE lm.id = $1
`, [req.params.id]);
if (!rows.length) return res.status(404).json({ error: "Not found" });
res.json(rows[0]);
} catch {
res.status(500).json({ error: "Database error" });
}
});
router.post("/markers", requireAuth, async (req, res) => {
const { name, marker_type, latitude, longitude, notes } = req.body;
if (!name || !marker_type || latitude == null || longitude == null) {
return res.status(400).json({ error: "name, marker_type, latitude, longitude are required" });
}
try {
const { rows } = await pool.query(
`INSERT INTO land_markers (name, marker_type, latitude, longitude, notes, created_by)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[name, marker_type, latitude, longitude, notes ?? null, req.user.sub]
);
res.status(201).json({ ...rows[0], created_by_username: req.user.username });
} catch {
res.status(500).json({ error: "Database error" });
}
});
router.put("/markers/:id", requireAuth, async (req, res) => {
const { name, marker_type, latitude, longitude, notes } = req.body;
try {
const { rows } = await pool.query(`
WITH updated AS (
UPDATE land_markers
SET name = COALESCE($1, name),
marker_type = COALESCE($2, marker_type),
latitude = COALESCE($3, latitude),
longitude = COALESCE($4, longitude),
notes = $5,
updated_at = NOW()
WHERE id = $6
RETURNING *
)
SELECT updated.*, u.username AS created_by_username
FROM updated
LEFT JOIN users u ON u.id = updated.created_by
`, [
name ?? null,
marker_type ?? null,
latitude ?? null,
longitude ?? null,
notes ?? null,
req.params.id
]);
if (!rows.length) return res.status(404).json({ error: "Not found" });
res.json(rows[0]);
} catch {
res.status(500).json({ error: "Database error" });
}
});
router.delete("/markers/:id", requireAuth, async (req, res) => {
try {
const { rowCount } = await pool.query(
"DELETE FROM land_markers WHERE id = $1",
[req.params.id]
);
if (!rowCount) return res.status(404).json({ error: "Not found" });
res.status(204).send();
} catch {
res.status(500).json({ error: "Database error" });
}
});
/* ───────── land_shapes (line / polygon / circle) ───────── */
router.get("/shapes", async (_req, res) => {
try {
const { rows } = await pool.query(`
SELECT ls.*, u.username AS created_by_username
FROM land_shapes ls
LEFT JOIN users u ON u.id = ls.created_by
ORDER BY ls.created_at DESC
`);
res.json(rows);
} catch {
res.status(500).json({ error: "Database error" });
}
});
router.get("/shapes/:id", async (req, res) => {
try {
const { rows } = await pool.query(`
SELECT ls.*, u.username AS created_by_username
FROM land_shapes ls
LEFT JOIN users u ON u.id = ls.created_by
WHERE ls.id = $1
`, [req.params.id]);
if (!rows.length) return res.status(404).json({ error: "Not found" });
res.json(rows[0]);
} catch {
res.status(500).json({ error: "Database error" });
}
});
router.post("/shapes", requireAuth, async (req, res) => {
const { name, shape_type, status, latitude, longitude, coordinates, radius_meters, notes } = req.body;
if (!name || !shape_type || latitude == null || longitude == null) {
return res.status(400).json({ error: "name, shape_type, latitude, longitude are required" });
}
try {
const { rows } = await pool.query(
`INSERT INTO land_shapes (name, shape_type, status, latitude, longitude, coordinates, radius_meters, notes, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *`,
[
name,
shape_type,
status ?? null,
latitude,
longitude,
coordinates ? JSON.stringify(coordinates) : null,
radius_meters ?? null,
notes ?? null,
req.user.sub
]
);
res.status(201).json({ ...rows[0], created_by_username: req.user.username });
} catch {
res.status(500).json({ error: "Database error" });
}
});
router.put("/shapes/:id", requireAuth, async (req, res) => {
const { name, shape_type, status, latitude, longitude, coordinates, radius_meters, notes } = req.body;
try {
const { rows } = await pool.query(`
WITH updated AS (
UPDATE land_shapes
SET name = COALESCE($1, name),
shape_type = COALESCE($2, shape_type),
status = $3,
latitude = COALESCE($4, latitude),
longitude = COALESCE($5, longitude),
coordinates = COALESCE($6, coordinates),
radius_meters = COALESCE($7, radius_meters),
notes = $8,
updated_at = NOW()
WHERE id = $9
RETURNING *
)
SELECT updated.*, u.username AS created_by_username
FROM updated
LEFT JOIN users u ON u.id = updated.created_by
`, [
name ?? null,
shape_type ?? null,
status ?? null,
latitude ?? null,
longitude ?? null,
coordinates ? JSON.stringify(coordinates) : null,
radius_meters ?? null,
notes ?? null,
req.params.id
]);
if (!rows.length) return res.status(404).json({ error: "Not found" });
res.json(rows[0]);
} catch {
res.status(500).json({ error: "Database error" });
}
});
router.delete("/shapes/:id", requireAuth, async (req, res) => {
try {
const { rowCount } = await pool.query(
"DELETE FROM land_shapes WHERE id = $1",
[req.params.id]
);
if (!rowCount) return res.status(404).json({ error: "Not found" });
res.status(204).send();
} catch {
res.status(500).json({ error: "Database error" });
}
});
module.exports = router;
File diff suppressed because one or more lines are too long
+112
View File
@@ -0,0 +1,112 @@
const express = require("express");
const jwt = require("jsonwebtoken");
const pool = require("../db");
const router = express.Router();
const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production";
function requireAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
return res.status(401).json({ error: "Unauthorized" });
}
try {
req.user = jwt.verify(authHeader.slice(7), JWT_SECRET);
next();
} catch {
res.status(401).json({ error: "Invalid token" });
}
}
router.get("/", async (_req, res) => {
try {
const { rows } = await pool.query(
"SELECT * FROM poi_markers ORDER BY created_at DESC"
);
res.json(rows);
} catch {
res.status(500).json({ error: "Database error" });
}
});
router.get("/:id", async (req, res) => {
try {
const { rows } = await pool.query(
"SELECT * FROM poi_markers WHERE id = $1",
[req.params.id]
);
if (!rows.length) return res.status(404).json({ error: "Not found" });
res.json(rows[0]);
} catch {
res.status(500).json({ error: "Database error" });
}
});
router.post("/", requireAuth, async (req, res) => {
const { name, poi_type, religion_subtype, latitude, longitude, notes, radius_meters } = req.body;
if (!name || !poi_type || latitude == null || longitude == null) {
return res.status(400).json({ error: "name, poi_type, latitude, longitude are required" });
}
try {
const { rows } = await pool.query(
`INSERT INTO poi_markers (name, poi_type, religion_subtype, latitude, longitude, notes, radius_meters)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *`,
[name, poi_type, religion_subtype ?? null, latitude, longitude, notes ?? null, radius_meters ?? 0]
);
res.status(201).json(rows[0]);
} catch {
res.status(500).json({ error: "Database error" });
}
});
router.put("/:id", requireAuth, async (req, res) => {
const { name, poi_type, religion_subtype, latitude, longitude, notes, radius_meters } = req.body;
try {
const { rows } = await pool.query(
`UPDATE poi_markers
SET name = COALESCE($1, name),
poi_type = COALESCE($2, poi_type),
religion_subtype = $3,
latitude = COALESCE($4, latitude),
longitude = COALESCE($5, longitude),
notes = $6,
radius_meters = COALESCE($7, radius_meters),
updated_at = NOW()
WHERE id = $8
RETURNING *`,
[
name ?? null,
poi_type ?? null,
religion_subtype ?? null,
latitude ?? null,
longitude ?? null,
notes ?? null,
radius_meters ?? null,
req.params.id
]
);
if (!rows.length) return res.status(404).json({ error: "Not found" });
res.json(rows[0]);
} catch {
res.status(500).json({ error: "Database error" });
}
});
router.delete("/:id", requireAuth, async (req, res) => {
try {
const { rowCount } = await pool.query(
"DELETE FROM poi_markers WHERE id = $1",
[req.params.id]
);
if (!rowCount) return res.status(404).json({ error: "Not found" });
res.status(204).send();
} catch {
res.status(500).json({ error: "Database error" });
}
});
module.exports = router;
+358
View File
@@ -0,0 +1,358 @@
/**
* Seed data for all dashboard tables.
* All coordinates are in the Pontianak / Kalimantan Barat area.
*/
// ─── Poverty Households (poor_households) ────────────────────────────────────
const SEED_HOUSEHOLDS = [
{
head_name: "Budi Santoso",
family_count: 5,
poverty_level: "Extreme",
latitude: -0.0263,
longitude: 109.3425,
notes: "Rumah semi-permanen, atap bocor",
penghasilan: 800000,
kondisi_rumah: 2,
kepemilikan_aset: 1,
fuzzy_score: 92.5,
fuzzy_label: "SANGAT TINGGI",
},
{
head_name: "Siti Aminah",
family_count: 4,
poverty_level: "Extreme",
latitude: -0.0301,
longitude: 109.3388,
notes: "Janda, 3 anak sekolah",
penghasilan: 600000,
kondisi_rumah: 2,
kepemilikan_aset: 2,
fuzzy_score: 88.3,
fuzzy_label: "SANGAT TINGGI",
},
{
head_name: "Ahmad Hidayat",
family_count: 6,
poverty_level: "Miskin",
latitude: -0.0195,
longitude: 109.3501,
notes: "Buruh harian lepas",
penghasilan: 1200000,
kondisi_rumah: 3,
kepemilikan_aset: 3,
fuzzy_score: 72.1,
fuzzy_label: "TINGGI",
},
{
head_name: "Dewi Lestari",
family_count: 3,
poverty_level: "Miskin",
latitude: -0.0352,
longitude: 109.3290,
notes: "Pedagang kecil di pasar",
penghasilan: 1500000,
kondisi_rumah: 4,
kepemilikan_aset: 3,
fuzzy_score: 65.0,
fuzzy_label: "TINGGI",
},
{
head_name: "Rahmat Kurniawan",
family_count: 7,
poverty_level: "Extreme",
latitude: -0.0410,
longitude: 109.3460,
notes: "Tinggal di bantaran sungai",
penghasilan: 500000,
kondisi_rumah: 1,
kepemilikan_aset: 1,
fuzzy_score: 95.8,
fuzzy_label: "SANGAT TINGGI",
},
{
head_name: "Nurul Hasanah",
family_count: 2,
poverty_level: "Miskin",
latitude: -0.0180,
longitude: 109.3550,
notes: "Ibu rumah tangga, suami sakit",
penghasilan: 900000,
kondisi_rumah: 3,
kepemilikan_aset: 2,
fuzzy_score: 78.4,
fuzzy_label: "TINGGI",
},
{
head_name: "Hasan Basri",
family_count: 4,
poverty_level: "Rentan",
latitude: -0.0225,
longitude: 109.3610,
notes: "Tukang ojek online",
penghasilan: 2000000,
kondisi_rumah: 5,
kepemilikan_aset: 4,
fuzzy_score: 45.2,
fuzzy_label: "SEDANG",
},
{
head_name: "Fatimah Zahra",
family_count: 3,
poverty_level: "Rentan",
latitude: -0.0290,
longitude: 109.3320,
notes: "Penjahit rumahan",
penghasilan: 1800000,
kondisi_rumah: 5,
kepemilikan_aset: 5,
fuzzy_score: 38.6,
fuzzy_label: "RENDAH",
},
{
head_name: "Eko Prasetyo",
family_count: 5,
poverty_level: "Miskin",
latitude: -0.0150,
longitude: 109.3480,
notes: "Nelayan kecil, pendapatan tidak tetap",
penghasilan: 1100000,
kondisi_rumah: 3,
kepemilikan_aset: 2,
fuzzy_score: 70.9,
fuzzy_label: "TINGGI",
},
{
head_name: "Yanti Suryani",
family_count: 2,
poverty_level: "Rentan",
latitude: -0.0330,
longitude: 109.3570,
notes: "Pekerja warung makan",
penghasilan: 2200000,
kondisi_rumah: 6,
kepemilikan_aset: 5,
fuzzy_score: 28.4,
fuzzy_label: "RENDAH",
},
{
head_name: "Muhammad Rizki",
family_count: 8,
poverty_level: "Extreme",
latitude: -0.0380,
longitude: 109.3400,
notes: "Pengangguran, 7 tanggungan",
penghasilan: 400000,
kondisi_rumah: 1,
kepemilikan_aset: 1,
fuzzy_score: 97.2,
fuzzy_label: "SANGAT TINGGI",
},
{
head_name: "Sri Wahyuni",
family_count: 3,
poverty_level: "Rentan",
latitude: -0.0205,
longitude: 109.3650,
notes: "Guru honorer",
penghasilan: 2500000,
kondisi_rumah: 6,
kepemilikan_aset: 6,
fuzzy_score: 15.3,
fuzzy_label: "TIDAK PRIORITAS",
},
];
// ─── POI Markers (poi_markers) ───────────────────────────────────────────────
const SEED_POI = [
{ name: "Masjid Jami' Pontianak", poi_type: "religion", religion_subtype: "mosque", latitude: -0.0220, longitude: 109.3420, notes: "Masjid terbesar di pusat kota", radius_meters: 500 },
{ name: "Gereja Katedral Pontianak", poi_type: "religion", religion_subtype: "church", latitude: -0.0245, longitude: 109.3380, notes: "Gereja bersejarah", radius_meters: 300 },
{ name: "Vihara Bodhisattva Karaniya", poi_type: "religion", religion_subtype: "temple", latitude: -0.0198, longitude: 109.3455, notes: "Vihara terbesar", radius_meters: 200 },
{ name: "Puskesmas Pontianak Selatan", poi_type: "clinic", religion_subtype: null, latitude: -0.0310, longitude: 109.3350, notes: "Puskesmas utama kecamatan selatan", radius_meters: 1000 },
{ name: "Klinik Pratama Sehat Sentosa", poi_type: "clinic", religion_subtype: null, latitude: -0.0270, longitude: 109.3500, notes: "Klinik 24 jam", radius_meters: 800 },
{ name: "Lumbung Pangan Desa Siantan", poi_type: "food-bank",religion_subtype: null, latitude: -0.0160, longitude: 109.3440, notes: "Distribusi beras untuk warga miskin",radius_meters: 1500 },
{ name: "SDN 01 Pontianak Barat", poi_type: "school", religion_subtype: null, latitude: -0.0235, longitude: 109.3310, notes: "Sekolah dasar negeri", radius_meters: 600 },
{ name: "SMP Negeri 3 Pontianak", poi_type: "school", religion_subtype: null, latitude: -0.0180, longitude: 109.3520, notes: "SMP favorit", radius_meters: 700 },
];
// ─── Land Markers (land_markers) ─────────────────────────────────────────────
const SEED_LAND_MARKERS = [
{ name: "Tugu Khatulistiwa", marker_type: "marker", latitude: 0.0000, longitude: 109.3228, notes: "Monumen landmark utama" },
{ name: "Kantor Gubernur Kalbar", marker_type: "flag", latitude: -0.0234, longitude: 109.3412, notes: "Pusat pemerintahan provinsi" },
{ name: "Hutan Lindung Gunung Palung", marker_type: "protected", latitude: -1.2100, longitude: 110.1500, notes: "Kawasan hutan lindung nasional" },
{ name: "BPN Pontianak", marker_type: "registry", latitude: -0.0280, longitude: 109.3360, notes: "Kantor pertanahan kota" },
{ name: "Area Mangrove Kakap", marker_type: "protected", latitude: -0.1050, longitude: 109.1200, notes: "Zona konservasi mangrove" },
{ name: "Terminal Ambawang", marker_type: "flag", latitude: -0.0800, longitude: 109.4100, notes: "Terminal bus utama" },
];
// ─── Land Shapes (land_shapes) ───────────────────────────────────────────────
const SEED_LAND_SHAPES = [
{
name: "Zona Permukiman Siantan",
shape_type: "polygon",
status: "Hak Milik",
latitude: -0.0150,
longitude: 109.3440,
coordinates: JSON.stringify([
[-0.0130, 109.3420], [-0.0130, 109.3460],
[-0.0170, 109.3460], [-0.0170, 109.3420],
]),
radius_meters: null,
notes: "Kawasan permukiman padat penduduk",
},
{
name: "Lahan Pertanian Rasau Jaya",
shape_type: "polygon",
status: "Hak Guna Usaha",
latitude: -0.1500,
longitude: 109.3800,
coordinates: JSON.stringify([
[-0.1450, 109.3750], [-0.1450, 109.3850],
[-0.1550, 109.3850], [-0.1550, 109.3750],
]),
radius_meters: null,
notes: "Lahan pertanian padi, luas ~100 ha",
},
{
name: "Area Industri Sei Raya",
shape_type: "circle",
status: "Hak Guna Bangunan",
latitude: -0.0500,
longitude: 109.3600,
coordinates: null,
radius_meters: 800,
notes: "Kawasan industri ringan",
},
{
name: "Jalan Tanjungpura",
shape_type: "line",
status: "Tanah Negara",
latitude: -0.0250,
longitude: 109.3400,
coordinates: JSON.stringify([
[-0.0200, 109.3350], [-0.0300, 109.3450],
]),
radius_meters: null,
notes: "Jalan arteri utama kota, panjang ~3 km",
},
];
// ─── Gas Station Markers (gas_station_markers) ───────────────────────────────
const SEED_GAS_STATIONS = [
{
name: "SPBU 64.751.01 Pontianak",
marker_category: "gas-pump",
brand: "Pertamina",
sub_type: null,
gas_types: JSON.stringify(["Pertalite", "Pertamax", "Pertamina Dex", "Bio Solar"]),
operating_hours: "24 Jam",
status: null,
latitude: -0.0230,
longitude: 109.3390,
notes: "SPBU utama di pusat kota",
},
{
name: "SPBU 64.751.03 Ahmad Yani",
marker_category: "gas-pump",
brand: "Pertamina",
sub_type: null,
gas_types: JSON.stringify(["Pertalite", "Pertamax", "Bio Solar"]),
operating_hours: "06:00 - 22:00",
status: null,
latitude: -0.0310,
longitude: 109.3450,
notes: "Dekat jalan Ahmad Yani",
},
{
name: "SPBU 64.751.08 Siantan",
marker_category: "gas-pump",
brand: "Pertamina",
sub_type: null,
gas_types: JSON.stringify(["Pertalite", "Bio Solar"]),
operating_hours: "06:00 - 21:00",
status: null,
latitude: -0.0155,
longitude: 109.3520,
notes: "SPBU kawasan Siantan",
},
{
name: "Shell Gajah Mada",
marker_category: "gas-pump",
brand: "Shell",
sub_type: null,
gas_types: JSON.stringify(["Shell Super", "Shell V-Power", "Shell Diesel"]),
operating_hours: "24 Jam",
status: null,
latitude: -0.0280,
longitude: 109.3310,
notes: "Shell pertama di Pontianak",
},
{
name: "SPBU 64.751.12 Kubu Raya",
marker_category: "gas-pump",
brand: "Pertamina",
sub_type: null,
gas_types: JSON.stringify(["Pertalite", "Pertamax", "Bio Solar"]),
operating_hours: "06:00 - 22:00",
status: null,
latitude: -0.0620,
longitude: 109.3700,
notes: "SPBU di kabupaten Kubu Raya",
},
{
name: "EV Charging PLN Pontianak",
marker_category: "charging-station",
brand: null,
sub_type: "Type 2 AC",
gas_types: null,
operating_hours: "24 Jam",
status: "Active",
latitude: -0.0240,
longitude: 109.3430,
notes: "Stasiun pengisian kendaraan listrik PLN",
},
{
name: "EV Charging Mall Ayani Mega",
marker_category: "charging-station",
brand: null,
sub_type: "CCS2 DC",
gas_types: null,
operating_hours: "10:00 - 22:00",
status: "Active",
latitude: -0.0335,
longitude: 109.3470,
notes: "Fast charging di basement mall",
},
{
name: "Bengkel Pontianak Motor",
marker_category: "wrench",
brand: null,
sub_type: null,
gas_types: null,
operating_hours: "08:00 - 17:00",
status: null,
latitude: -0.0260,
longitude: 109.3480,
notes: "Bengkel umum motor dan mobil",
},
{
name: "Bengkel Jaya Abadi",
marker_category: "wrench",
brand: null,
sub_type: null,
gas_types: null,
operating_hours: "08:00 - 18:00",
status: null,
latitude: -0.0190,
longitude: 109.3560,
notes: "Bengkel spesialis AC dan kelistrikan",
},
];
module.exports = {
SEED_HOUSEHOLDS,
SEED_POI,
SEED_LAND_MARKERS,
SEED_LAND_SHAPES,
SEED_GAS_STATIONS,
};
+16
View File
@@ -0,0 +1,16 @@
require("dotenv").config();
const app = require("./app");
const initDb = require("./db-init");
const PORT = process.env.PORT || 4000;
initDb()
.then(() => {
app.listen(PORT, () => {
console.log(`Auth server running on port ${PORT}`);
});
})
.catch((err) => {
console.error("Failed to initialize database:", err);
process.exit(1);
});
+11
View File
@@ -0,0 +1,11 @@
const pool = require("./db");
async function findByUsernameOrEmail(identifier) {
const { rows } = await pool.query(
"SELECT * FROM users WHERE username = $1 OR email = $1 LIMIT 1",
[identifier]
);
return rows[0] ?? null;
}
module.exports = { findByUsernameOrEmail };
+69
View File
@@ -0,0 +1,69 @@
# Compose file for Coolify deployments.
# In Coolify: create a "Docker Compose" resource pointing at this repo and set
# "Docker Compose Location" to /docker-compose.coolify.yml.
#
# No host ports are published here on purpose: Coolify's proxy routes the
# auto-assigned domains to the container ports declared via the
# SERVICE_FQDN_<SERVICE>_<PORT> magic variables, so ports never clash with
# other apps on the server. postgres and fuzzy-service stay private on the
# internal network. SERVICE_PASSWORD_* / SERVICE_BASE64_* values are
# generated by Coolify and editable in its UI.
#
# docker-compose.yml (with fixed host ports) remains for local development.
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-sig}
POSTGRES_USER: ${POSTGRES_USER:-sig}
POSTGRES_PASSWORD: ${SERVICE_PASSWORD_POSTGRES}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-sig} -d ${POSTGRES_DB:-sig}"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
fuzzy-service:
build:
context: ./fuzzy-service
dockerfile: Dockerfile
restart: unless-stopped
backend:
build:
context: ./backend
dockerfile: Dockerfile
environment:
- SERVICE_FQDN_BACKEND_4000
- PORT=4000
- JWT_SECRET=${SERVICE_BASE64_64_JWT}
- FRONTEND_URL=${SERVICE_URL_FRONTEND}
- DATABASE_URL=postgres://${POSTGRES_USER:-sig}:${SERVICE_PASSWORD_POSTGRES}@postgres:5432/${POSTGRES_DB:-sig}
- FUZZY_SERVICE_URL=http://fuzzy-service:5001
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.frontend
args:
# Baked into the browser bundle at build time; must be the backend's
# public URL, which Coolify provides via the magic variable below.
NEXT_PUBLIC_BACKEND_URL: ${SERVICE_URL_BACKEND}
# Server-side rewrites go over the internal network.
BACKEND_URL: http://backend:4000
environment:
- SERVICE_FQDN_FRONTEND_3000
depends_on:
- backend
restart: unless-stopped
volumes:
postgres_data:
+53
View File
@@ -0,0 +1,53 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: sig
POSTGRES_USER: sig
POSTGRES_PASSWORD: sig_secret
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
fuzzy-service:
build:
context: ./fuzzy-service
dockerfile: Dockerfile
ports:
- "5001:5001"
restart: unless-stopped
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "4000:4000"
environment:
PORT: 4000
JWT_SECRET: replace-with-secure-secret-in-production
FRONTEND_URL: http://localhost:3000
DATABASE_URL: postgres://sig:sig_secret@postgres:5432/sig
FUZZY_SERVICE_URL: http://fuzzy-service:5001
depends_on:
- postgres
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.frontend
args:
NEXT_PUBLIC_BACKEND_URL: http://localhost:4000
ports:
- "3000:3000"
environment:
NODE_ENV: production
depends_on:
- backend
restart: unless-stopped
volumes:
postgres_data:
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+5
View File
@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+26
View File
@@ -0,0 +1,26 @@
FROM oven/bun:1-alpine AS deps
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install
FROM oven/bun:1-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG NEXT_PUBLIC_BACKEND_URL=http://localhost:4000
ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL
# Rewrites in next.config.ts are resolved at build time
ARG BACKEND_URL=http://localhost:4000
ENV BACKEND_URL=$BACKEND_URL
RUN bun run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+5
View File
@@ -0,0 +1,5 @@
import { AuthPage } from "@/components/auth-page";
export default function LoginPage() {
return <AuthPage />;
}
+322
View File
@@ -0,0 +1,322 @@
"use client";
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/set-state-in-effect */
import { useState, useMemo, useEffect } from "react";
import dynamic from "next/dynamic";
import { AppShell } from "@/components/app-shell";
import { MapPageHeader } from "@/components/maps/map-page-header";
import { GasStationStatsCards } from "@/components/gas-station/gas-station-stats-cards";
import { GasStationDirectory } from "@/components/gas-station/gas-station-directory";
import { GasStationInspector } from "@/components/gas-station/gas-station-inspector";
import { PertaminaPriceTable } from "@/components/gas-station/pertamina-price-table";
import { GasStationHud } from "@/components/gas-station/gas-station-hud";
import { GasStationDisclosureCard } from "@/components/ui/collection-grid-disclosure";
import type { UserMarker } from "@/components/leaflet-map";
import { FuelIcon } from "@/components/ui/phosphor-icons";
import { StationPlacementDialog } from "@/components/gas-station/station-placement-dialog";
import { StationEditDialog } from "@/components/gas-station/station-edit-dialog";
import { toast } from "sonner";
const PLACING_LABELS: Record<string, string> = {
"charging-station": "Pengisi Daya EV",
"gas-pump": "SPBU",
wrench: "Bengkel",
};
const LeafletMap = dynamic(() => import("@/components/leaflet-map"), { ssr: false });
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000";
export default function GasStationMapPage() {
const [selectedStation, setSelectedStation] = useState<UserMarker | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [brandFilter, setBrandFilter] = useState<string>("All");
const [fuelPriceDisplay, setFuelPriceDisplay] = useState<"ron92" | "ron95" | "diesel">("ron95");
const [evOnlyFilter, setEvOnlyFilter] = useState(false);
const [useLeaflet, setUseLeaflet] = useState(false);
const [mapStyle, setMapStyle] = useState<"street" | "satellite">("street");
const [userMarkers, setUserMarkers] = useState<UserMarker[]>([]);
const [placingMarkerType, setPlacingMarkerType] = useState<string | null>(null);
const [dragDropEnabled, setDragDropEnabled] = useState(false);
// Custom Marker Dialog State
const [pendingClick, setPendingClick] = useState<{ lat: number; lng: number } | null>(null);
const [pendingMarkerType, setPendingMarkerType] = useState<string | null>(null);
const [editingMarker, setEditingMarker] = useState<UserMarker | null>(null);
const [hydrated, setHydrated] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const [fuelPrices, setFuelPrices] = useState<any>(null);
const [fuelPricesLoading, setFuelPricesLoading] = useState(true);
// Fetch dynamic Pertamina fuel prices
useEffect(() => {
setFuelPricesLoading(true);
fetch(`${BACKEND_URL}/gas-station/fuel-prices`)
.then((res) => res.json())
.then((data) => {
if (data && data.succeeded) {
setFuelPrices(data);
}
})
.catch((err) => console.error("Gagal memuat harga BBM:", err))
.finally(() => setFuelPricesLoading(false));
}, []);
const filteredStations = useMemo(() => {
return userMarkers.filter((st) => {
const matchesSearch = st.name.toLowerCase().includes(searchQuery.toLowerCase());
const matchesBrand = brandFilter === "All" || (st.type === "gas-pump" && st.meta?.poi_type === brandFilter);
const matchesEv = !evOnlyFilter || st.type === "charging-station";
return matchesSearch && matchesBrand && matchesEv;
});
}, [userMarkers, searchQuery, brandFilter, evOnlyFilter]);
const activeSelectedStation = useMemo(() => {
if (filteredStations.length === 0) return null;
const found = filteredStations.find((st) => st.id === selectedStation?.id);
if (found) return found;
return filteredStations[0];
}, [filteredStations, selectedStation]);
// Load existing markers when page loads or refreshKey changes
useEffect(() => {
const token = localStorage.getItem("auth_token");
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
fetch(`${BACKEND_URL}/gas-station/markers`, { headers })
.then((r) => r.json())
.then((rows) => {
if (Array.isArray(rows)) {
const mapped = rows.map((row: any) => {
let finalPoiType = "";
let finalNotes = "";
if (row.marker_category === "charging-station") {
finalPoiType = row.sub_type;
finalNotes = row.status ? `Status: ${row.status}` : "";
} else if (row.marker_category === "gas-pump") {
finalPoiType = row.brand;
finalNotes = row.operating_hours ? `Jam: ${row.operating_hours}` : "";
} else if (row.marker_category === "wrench") {
finalPoiType = row.sub_type;
finalNotes = row.status;
}
return {
id: `gsm-${row.id}`,
lat: parseFloat(row.latitude),
lng: parseFloat(row.longitude),
type: row.marker_category,
name: row.name,
meta: {
poi_type: finalPoiType,
notes: finalNotes,
gas_types: row.gas_types || undefined,
},
};
});
setUserMarkers(mapped);
}
})
.catch((err) => {
console.error("Gagal memuat markers:", err);
});
}, [refreshKey]);
// Session storage synchronization (identical pattern to poverty)
useEffect(() => {
setUseLeaflet(sessionStorage.getItem("gas_useLeaflet") === "true");
setMapStyle((sessionStorage.getItem("gas_mapStyle") as "street" | "satellite") ?? "street");
setBrandFilter(sessionStorage.getItem("gas_brandFilter") ?? "All");
setFuelPriceDisplay((sessionStorage.getItem("gas_fuelPriceDisplay") as "ron92" | "ron95" | "diesel") ?? "ron95");
setHydrated(true);
}, []);
useEffect(() => { if (hydrated) sessionStorage.setItem("gas_useLeaflet", String(useLeaflet)); }, [useLeaflet, hydrated]);
useEffect(() => { if (hydrated) sessionStorage.setItem("gas_mapStyle", mapStyle); }, [mapStyle, hydrated]);
useEffect(() => { if (hydrated) sessionStorage.setItem("gas_brandFilter", brandFilter); }, [brandFilter, hydrated]);
useEffect(() => { if (hydrated) sessionStorage.setItem("gas_fuelPriceDisplay", fuelPriceDisplay); }, [fuelPriceDisplay, hydrated]);
// FULL BLEED MAP LAYOUT
if (useLeaflet) {
return (
<AppShell activePath="/dashboard/gas-station" fullBleed onExitFullBleed={() => setUseLeaflet(false)}>
<div className="relative w-full h-full overflow-hidden">
{/* Leaflet Map */}
<LeafletMap
mode="gas-station"
selectedId={activeSelectedStation?.id}
onSelect={(id) => {
const found = userMarkers.find((st) => st.id === id);
if (found) setSelectedStation(found);
}}
fuelPriceDisplay={fuelPriceDisplay}
mapStyle={mapStyle}
userMarkers={userMarkers}
onMarkerClick={(m) => {
if (["charging-station", "gas-pump", "wrench"].includes(m.type)) {
setEditingMarker(m);
}
}}
onMapClick={(lat, lng) => {
if (placingMarkerType) {
setPendingClick({ lat, lng });
setPendingMarkerType(placingMarkerType);
setPlacingMarkerType(null);
}
}}
dragDropEnabled={dragDropEnabled}
onMarkerDragEnd={handleMarkerDragEnd}
/>
{/* Placing Marker Banner */}
{placingMarkerType && (
<div className="absolute top-20 left-1/2 -translate-x-1/2 z-[1000] bg-background/95 backdrop-blur-md border border-primary/20 px-4 py-2 rounded-full shadow-lg flex items-center gap-3 animate-in fade-in slide-in-from-top-4 duration-300 pointer-events-auto">
<span className="text-xs font-semibold text-foreground flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-primary animate-pulse" />
Menempatkan {PLACING_LABELS[placingMarkerType] ?? placingMarkerType.replace("-", " ")}... Klik pada peta untuk menentukan posisi
</span>
<button
onClick={() => setPlacingMarkerType(null)}
className="text-[10px] uppercase font-bold text-muted-foreground hover:text-foreground border px-2 py-0.5 rounded-full transition-colors"
>
Batal
</button>
</div>
)}
{/* Floating Filters HUD */}
<GasStationHud
fuelPriceDisplay={fuelPriceDisplay}
setFuelPriceDisplay={setFuelPriceDisplay}
brandFilter={brandFilter}
setBrandFilter={setBrandFilter}
onCloseMap={() => setUseLeaflet(false)}
/>
{/* Floating Collection Grid Disclosure */}
<div className="absolute bottom-6 right-4 z-[999] max-h-[calc(100vh-8rem)] overflow-y-auto no-scrollbar pointer-events-auto">
<GasStationDisclosureCard
mapStyle={mapStyle}
onMapStyleChange={setMapStyle}
activeTool={placingMarkerType}
onToolSelect={setPlacingMarkerType}
compact
dragDropEnabled={dragDropEnabled}
onDragDropToggle={setDragDropEnabled}
/>
</div>
{/* Placement Dialog */}
<StationPlacementDialog
open={!!pendingClick}
onOpenChange={(v) => { if (!v) { setPendingClick(null); setPendingMarkerType(null); } }}
pendingClick={pendingClick}
markerType={pendingMarkerType}
onConfirm={(newMarker) => {
setUserMarkers((prev) => [...prev, newMarker]);
setPendingClick(null);
setPendingMarkerType(null);
setRefreshKey((k) => k + 1);
}}
/>
{/* Edit Dialog */}
<StationEditDialog
open={!!editingMarker}
onOpenChange={(v) => { if (!v) setEditingMarker(null); }}
marker={editingMarker}
onUpdated={(updated) => {
setUserMarkers((prev) => prev.map((m) => m.id === updated.id ? updated : m));
setEditingMarker(null);
setRefreshKey((k) => k + 1);
}}
onDeleted={(deleted) => {
setUserMarkers((prev) => prev.filter((m) => m.id !== deleted.id));
setEditingMarker(null);
setRefreshKey((k) => k + 1);
}}
/>
</div>
</AppShell>
);
}
async function handleMarkerDragEnd(marker: UserMarker, newLat: number, newLng: number) {
const dbId = marker.id.split("-").slice(1).join("-");
const token = localStorage.getItem("auth_token");
const headers = {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
try {
const res = await fetch(`${BACKEND_URL}/gas-station/markers/${dbId}`, {
method: "PUT",
headers,
body: JSON.stringify({ latitude: newLat, longitude: newLng }),
});
if (!res.ok) throw new Error("Gagal memperbarui posisi di server");
setUserMarkers((prev) =>
prev.map((m) =>
m.id === marker.id ? { ...m, lat: newLat, lng: newLng } : m
)
);
toast.success(`Posisi "${marker.name}" berhasil diperbarui`);
setRefreshKey((k) => k + 1);
} catch (err) {
console.error(err);
toast.error(err instanceof Error ? err.message : "Gagal memindahkan penanda");
}
}
// STANDARD DASHBOARD LAYOUT
return (
<AppShell activePath="/dashboard/gas-station">
<div className="flex flex-col gap-6">
{/* Top Header Card */}
<MapPageHeader
title="Pencari SPBU & Charger"
description="Pencari stasiun waktu nyata, indeks harga, info antrean, dan pengisi daya EV. Buka tampilan Leaflet JS untuk memeriksa koordinat pemetaan."
onOpenMap={() => setUseLeaflet(true)}
buttonText="Peta Stasiun"
icon={<FuelIcon className="h-4 w-4" />}
/>
{/* Unified Grid Layout */}
<div className="grid grid-cols-1 gap-px bg-border border rounded-xl overflow-hidden md:grid-cols-2 lg:grid-cols-4 shadow-none">
{/* Row 1: 4 stats cards (rendered inside GasStationStatsCards as direct children) */}
<GasStationStatsCards fuelPrices={fuelPrices} loading={fuelPricesLoading} />
{/* Row 2: Directory */}
<div className="md:col-span-2 lg:col-span-3 flex flex-col h-full">
<GasStationDirectory
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
brandFilter={brandFilter}
setBrandFilter={setBrandFilter}
evOnlyFilter={evOnlyFilter}
setEvOnlyFilter={setEvOnlyFilter}
filteredStations={filteredStations}
selectedStationId={activeSelectedStation?.id ?? ""}
onSelectStation={setSelectedStation}
/>
</div>
{/* Row 2: Inspector (right column) */}
<div className="md:col-span-2 lg:col-span-1 flex flex-col h-full">
<GasStationInspector selectedStation={activeSelectedStation} />
</div>
{/* Row 3: Pricing Table (full width) */}
<div className="md:col-span-2 lg:col-span-4 flex flex-col h-full">
<PertaminaPriceTable fuelPrices={fuelPrices} loading={fuelPricesLoading} />
</div>
</div>
</div>
</AppShell>
);
}
+400
View File
@@ -0,0 +1,400 @@
"use client";
import { useState, useMemo, useEffect } from "react";
import dynamic from "next/dynamic";
import { AppShell } from "@/components/app-shell";
import { MapPageHeader } from "@/components/maps/map-page-header";
import { LandStatsCards } from "@/components/land/land-stats-cards";
import { LandAllocationChart } from "@/components/land/land-allocation-chart";
import { LandDirectory } from "@/components/land/land-directory";
import { LandInspectorTop, LandInspectorBottom } from "@/components/land/land-inspector";
import { LAND_MARKER_LABELS } from "@/lib/map-data";
import { LayersIcon } from "@/components/ui/phosphor-icons";
import { LandDisclosureCard } from "@/components/ui/collection-grid-disclosure";
import {
ShapePlacementDialog,
LAND_STATUSES,
calcLineLength,
calcPolygonArea,
} from "@/components/land/shape-placement-dialog";
import { ShapeEditDialog } from "@/components/land/shape-edit-dialog";
import type { UserMarker } from "@/components/leaflet-map";
import { toast } from "sonner";
const LeafletMap = dynamic(() => import("@/components/leaflet-map"), { ssr: false });
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000";
function authHeaders(json = false) {
const token = localStorage.getItem("auth_token");
return { ...(json ? { "Content-Type": "application/json" } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) };
}
export default function LandMapPage() {
const [searchQuery, setSearchQuery] = useState("");
const [visibilityFilters, setVisibilityFilters] = useState<Record<string, boolean>>({
marker: true,
flag: true,
protected: true,
registry: true,
line: true,
polygon: true,
circle: true,
});
const [useLeaflet, setUseLeaflet] = useState(false);
const [mapStyle, setMapStyle] = useState<"street" | "satellite">("street");
const [hydrated, setHydrated] = useState(false);
const [userMarkers, setUserMarkers] = useState<UserMarker[]>([]);
const [refreshKey, setRefreshKey] = useState(0);
const [placingMarkerType, setPlacingMarkerType] = useState<string | null>(null);
const [dragDropEnabled, setDragDropEnabled] = useState(false);
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
// Shape/marker dialog state
const [pendingShape, setPendingShape] = useState<UserMarker | null>(null);
const [editingShape, setEditingShape] = useState<UserMarker | null>(null);
// Hydrate persisted view state from sessionStorage on mount
useEffect(() => {
setUseLeaflet(sessionStorage.getItem("land_useLeaflet") === "true");
setMapStyle((sessionStorage.getItem("land_mapStyle") as "street" | "satellite") ?? "street");
const storedFilters = sessionStorage.getItem("land_visibilityFilters");
if (storedFilters) {
try {
setVisibilityFilters(JSON.parse(storedFilters));
} catch {
/* keep defaults */
}
}
setSelectedItemId(sessionStorage.getItem("land_selectedItemId"));
setHydrated(true);
}, []);
useEffect(() => { if (hydrated) sessionStorage.setItem("land_useLeaflet", String(useLeaflet)); }, [useLeaflet, hydrated]);
useEffect(() => { if (hydrated) sessionStorage.setItem("land_mapStyle", mapStyle); }, [mapStyle, hydrated]);
useEffect(() => { if (hydrated) sessionStorage.setItem("land_visibilityFilters", JSON.stringify(visibilityFilters)); }, [visibilityFilters, hydrated]);
useEffect(() => {
if (!hydrated) return;
if (selectedItemId) sessionStorage.setItem("land_selectedItemId", selectedItemId);
else sessionStorage.removeItem("land_selectedItemId");
}, [selectedItemId, hydrated]);
// Always-on data fetch — feeds both the dashboard widgets and the map.
useEffect(() => {
const token = localStorage.getItem("auth_token");
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
Promise.allSettled([
fetch(`${BACKEND_URL}/land/markers`, { headers }).then((r) => r.json()),
fetch(`${BACKEND_URL}/land/shapes`, { headers }).then((r) => r.json()),
]).then(([markersResult, shapesResult]) => {
const markerRows = markersResult.status === "fulfilled" && Array.isArray(markersResult.value) ? markersResult.value : [];
const shapeRows = shapesResult.status === "fulfilled" && Array.isArray(shapesResult.value) ? shapesResult.value : [];
const pointMarkers: UserMarker[] = markerRows.map((r: any) => ({
id: `lm-${r.id}`,
lat: parseFloat(r.latitude),
lng: parseFloat(r.longitude),
type: r.marker_type,
name: r.name,
meta: {
notes: r.notes ?? undefined,
created_by_username: r.created_by_username ?? undefined,
created_at: r.created_at ?? undefined,
updated_at: r.updated_at ?? undefined,
},
}));
const shapeMarkers: UserMarker[] = shapeRows.map((r: any) => ({
id: `ls-${r.id}`,
lat: parseFloat(r.latitude),
lng: parseFloat(r.longitude),
type: r.shape_type,
name: r.name,
meta: {
poi_type: r.status ?? undefined,
coordinates: r.coordinates ?? undefined,
radius: r.radius_meters != null ? parseFloat(r.radius_meters) : undefined,
notes: r.notes ?? undefined,
created_by_username: r.created_by_username ?? undefined,
created_at: r.created_at ?? undefined,
updated_at: r.updated_at ?? undefined,
},
}));
setUserMarkers([...pointMarkers, ...shapeMarkers]);
});
}, [refreshKey]);
const landMarkerItems = useMemo(() => userMarkers.filter((m) => m.id.startsWith("lm-")), [userMarkers]);
const landShapeItems = useMemo(() => userMarkers.filter((m) => m.id.startsWith("ls-")), [userMarkers]);
const filteredItems = useMemo(() => {
const q = searchQuery.toLowerCase();
return userMarkers.filter((m) => {
const label = LAND_MARKER_LABELS[m.type] ?? m.type;
return m.name.toLowerCase().includes(q) || label.toLowerCase().includes(q);
});
}, [searchQuery, userMarkers]);
const aggregateStats = useMemo(() => {
let totalAreaHa = 0;
let totalRoadKm = 0;
landShapeItems.forEach((m) => {
if (m.type === "polygon" && m.meta?.coordinates) totalAreaHa += calcPolygonArea(m.meta.coordinates) / 10000;
else if (m.type === "line" && m.meta?.coordinates) totalRoadKm += calcLineLength(m.meta.coordinates) / 1000;
});
return {
totalParcelArea: totalAreaHa.toLocaleString(undefined, { maximumFractionDigits: 1 }),
totalRoadLength: totalRoadKm.toLocaleString(undefined, { maximumFractionDigits: 1 }),
activeMarkers: landMarkerItems.length,
};
}, [landShapeItems, landMarkerItems]);
const pieChartData = useMemo(() => {
const totals: Record<string, number> = {};
landShapeItems.forEach((m) => {
if (m.type === "polygon" && m.meta?.coordinates) {
const status = m.meta.poi_type ?? "Unknown";
totals[status] = (totals[status] ?? 0) + calcPolygonArea(m.meta.coordinates) / 10000;
}
});
return Object.entries(totals).map(([status, value]) => {
const found = LAND_STATUSES.find((s) => s.value === status);
return { name: found?.label ?? status, value: parseFloat(value.toFixed(1)), color: found?.color ?? "#6b7280" };
});
}, [landShapeItems]);
const selectedItem = useMemo(() => userMarkers.find((m) => m.id === selectedItemId) ?? null, [selectedItemId, userMarkers]);
const recentFlags = useMemo(
() => landMarkerItems
.filter((m) => m.type === "flag")
.sort((a, b) => (b.meta?.created_at ?? "").localeCompare(a.meta?.created_at ?? ""))
.slice(0, 2),
[landMarkerItems]
);
const toggleFilter = (type: string) => {
setVisibilityFilters((prev) => ({
...prev,
[type]: !prev[type],
}));
};
async function handleConfirmPlacement(item: UserMarker) {
const isPointType = !["line", "polygon", "circle"].includes(item.type);
try {
const res = await fetch(`${BACKEND_URL}/land/${isPointType ? "markers" : "shapes"}`, {
method: "POST",
headers: authHeaders(true),
body: JSON.stringify(
isPointType
? { name: item.name, marker_type: item.type, latitude: item.lat, longitude: item.lng, notes: item.meta?.notes ?? null }
: {
name: item.name,
shape_type: item.type,
status: item.meta?.poi_type ?? null,
latitude: item.lat,
longitude: item.lng,
coordinates: item.meta?.coordinates ?? null,
radius_meters: item.meta?.radius ?? null,
notes: item.meta?.notes ?? null,
}
),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? "Gagal menyimpan");
setPendingShape(null);
setRefreshKey((k) => k + 1);
toast.success(`"${item.name}" berhasil disimpan`);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Gagal menyimpan");
}
}
async function handleUpdateItem(updated: UserMarker) {
const isMarker = updated.id.startsWith("lm-");
const dbId = updated.id.split("-").slice(1).join("-");
const url = `${BACKEND_URL}/land/${isMarker ? "markers" : "shapes"}/${dbId}`;
const body = isMarker
? { name: updated.name, marker_type: updated.type, notes: updated.meta?.notes ?? null }
: { name: updated.name, shape_type: updated.type, status: updated.meta?.poi_type ?? null, notes: updated.meta?.notes ?? null };
try {
const res = await fetch(url, { method: "PUT", headers: authHeaders(true), body: JSON.stringify(body) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? "Gagal memperbarui");
setEditingShape(null);
setRefreshKey((k) => k + 1);
toast.success(`"${updated.name}" berhasil diperbarui`);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Gagal memperbarui");
}
}
async function handleDeleteItem(deleted: UserMarker) {
const isMarker = deleted.id.startsWith("lm-");
const dbId = deleted.id.split("-").slice(1).join("-");
try {
const res = await fetch(`${BACKEND_URL}/land/${isMarker ? "markers" : "shapes"}/${dbId}`, { method: "DELETE", headers: authHeaders() });
if (!res.ok) throw new Error("Gagal menghapus");
setEditingShape(null);
if (selectedItemId === deleted.id) setSelectedItemId(null);
setRefreshKey((k) => k + 1);
toast.success(`"${deleted.name}" berhasil dihapus`);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Gagal menghapus");
}
}
async function handleMarkerDragEnd(marker: UserMarker, newLat: number, newLng: number) {
const isMarker = marker.id.startsWith("lm-");
const dbId = marker.id.split("-").slice(1).join("-");
try {
const res = await fetch(`${BACKEND_URL}/land/${isMarker ? "markers" : "shapes"}/${dbId}`, {
method: "PUT",
headers: authHeaders(true),
body: JSON.stringify({ latitude: newLat, longitude: newLng }),
});
if (!res.ok) throw new Error("Gagal memindahkan penanda");
setUserMarkers((prev) => prev.map((m) => (m.id === marker.id ? { ...m, lat: newLat, lng: newLng } : m)));
toast.success(`Posisi "${marker.name}" berhasil diperbarui`);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Gagal memindahkan penanda");
}
}
// FULL BLEED MAP LAYOUT
if (useLeaflet) {
return (
<AppShell activePath="/dashboard/land" fullBleed onExitFullBleed={() => setUseLeaflet(false)}>
<div className="relative w-full h-full overflow-hidden">
{/* Leaflet Map */}
<LeafletMap
mode="land"
visibilityFilters={visibilityFilters}
mapStyle={mapStyle}
userMarkers={userMarkers}
placingMarkerType={placingMarkerType}
onShapeCreated={(shape) => {
// Open placement dialog instead of adding directly
setPendingShape(shape);
setPlacingMarkerType(null);
}}
onMarkerClick={(marker) => {
setEditingShape(marker);
setSelectedItemId(marker.id);
}}
onMapClick={(lat, lng) => {
if (placingMarkerType && !["line", "polygon", "circle"].includes(placingMarkerType)) {
setPendingShape({ id: `pending-${Date.now()}`, lat, lng, type: placingMarkerType, name: "" });
setPlacingMarkerType(null);
}
}}
dragDropEnabled={dragDropEnabled}
onMarkerDragEnd={handleMarkerDragEnd}
/>
{/* Placing Marker Banner */}
{placingMarkerType && (
<div className="absolute top-20 left-1/2 -translate-x-1/2 z-[1000] bg-background/95 backdrop-blur-md border border-primary/20 px-4 py-2 rounded-full shadow-lg flex items-center gap-3 animate-in fade-in slide-in-from-top-4 duration-300 pointer-events-auto">
<span className="text-xs font-semibold text-foreground flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-primary animate-pulse" />
Menempatkan {LAND_MARKER_LABELS[placingMarkerType] ?? placingMarkerType.replace("-", " ")}... Klik pada peta untuk menentukan posisi
</span>
<button
onClick={() => setPlacingMarkerType(null)}
className="text-[10px] uppercase font-bold text-muted-foreground hover:text-foreground border px-2 py-0.5 rounded-full transition-colors"
>
Batal
</button>
</div>
)}
{/* Floating Collection Grid Disclosure */}
<div className="absolute bottom-6 right-4 z-[999] max-h-[calc(100vh-8rem)] overflow-y-auto no-scrollbar pointer-events-auto">
<LandDisclosureCard
mapStyle={mapStyle}
onMapStyleChange={setMapStyle}
activeTool={placingMarkerType}
onToolSelect={setPlacingMarkerType}
compact
dragDropEnabled={dragDropEnabled}
onDragDropToggle={setDragDropEnabled}
/>
</div>
{/* Shape Placement Dialog */}
<ShapePlacementDialog
open={!!pendingShape}
onOpenChange={(v) => { if (!v) setPendingShape(null); }}
pendingShape={pendingShape}
onConfirm={handleConfirmPlacement}
/>
{/* Shape Edit Dialog */}
<ShapeEditDialog
open={!!editingShape}
onOpenChange={(v) => { if (!v) setEditingShape(null); }}
shape={editingShape}
onUpdated={handleUpdateItem}
onDeleted={handleDeleteItem}
/>
</div>
</AppShell>
);
}
// STANDARD DASHBOARD LAYOUT
return (
<AppShell activePath="/dashboard/land">
<div className="flex flex-col gap-6">
{/* Top Header Card */}
<MapPageHeader
title="Statistik Penggunaan Lahan"
description="Klasifikasi GIS, catatan kepemilikan, dan registrasi luas zona. Buka tampilan Leaflet JS untuk memeriksa pemetaan geografis."
onOpenMap={() => setUseLeaflet(true)}
buttonText="Peta Lahan"
icon={<LayersIcon className="h-4 w-4" />}
/>
{/* Unified Grid Layout */}
<div className="grid grid-cols-1 gap-px bg-border border rounded-xl overflow-hidden md:grid-cols-2 lg:grid-cols-3 shadow-none">
{/* Row 1: 3 stats cards (rendered inside LandStatsCards as direct children) */}
<LandStatsCards aggregateStats={aggregateStats} />
{/* Row 2: Allocation Chart */}
<div className="md:col-span-2 lg:col-start-1 lg:col-span-2 lg:row-start-2 lg:row-end-3 flex flex-col h-full">
<LandAllocationChart pieChartData={pieChartData} />
</div>
{/* Row 2: Inspector Top */}
<div className="md:col-span-2 lg:col-start-3 lg:col-span-1 lg:row-start-2 lg:row-end-3 flex flex-col h-full">
<LandInspectorTop
selectedItem={selectedItem}
visibilityFilters={visibilityFilters}
toggleFilter={toggleFilter}
/>
</div>
{/* Row 3: Inspector Bottom */}
<div className="md:col-span-2 lg:col-start-3 lg:col-span-1 lg:row-start-3 lg:row-end-4 flex flex-col h-full">
<LandInspectorBottom recentFlags={recentFlags} />
</div>
{/* Row 3: Directory */}
<div className="md:col-span-2 lg:col-start-1 lg:col-span-2 lg:row-start-3 lg:row-end-4 flex flex-col h-full">
<LandDirectory
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
filteredItems={filteredItems}
selectedItemId={selectedItemId}
visibilityFilters={visibilityFilters}
onSelectItem={(item) => setSelectedItemId(item.id)}
/>
</div>
</div>
</div>
</AppShell>
);
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { AppShell } from "@/components/app-shell";
import { Dashboard } from "@/components/dashboard";
import { HouseIcon } from "@/components/ui/phosphor-icons";
export default function DashboardPage() {
return (
<AppShell activePath="/dashboard">
<div className="flex flex-col gap-6">
{/* Page Header */}
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2.5">
<h1 className="text-2xl md:text-3xl font-semibold text-foreground tracking-tight">
Beranda Analitik
</h1>
</div>
<p className="text-xs text-muted-foreground">
Overview status statistik, data kemiskinan, peta wilayah, dan operasional SPBU.
</p>
</div>
<Dashboard />
</div>
</AppShell>
);
}
+389
View File
@@ -0,0 +1,389 @@
"use client";
import { useState, useMemo, useEffect, useRef } from "react";
import dynamic from "next/dynamic";
import { DndContext, DragOverlay, useDroppable, useSensor, useSensors, PointerSensor, type DragEndEvent } from "@dnd-kit/core";
import { AppShell } from "@/components/app-shell";
import { MapPageHeader } from "@/components/maps/map-page-header";
import { PovertyStatsCards } from "@/components/poverty/poverty-stats-cards";
import { PovertyTrendChart } from "@/components/poverty/poverty-trend-chart";
import { PovertyDirectory } from "@/components/poverty/poverty-directory";
import { PovertyInspectorTop, PovertyInspectorBottom } from "@/components/poverty/poverty-inspector";
import { PovertyHud } from "@/components/poverty/poverty-hud";
import { TrendingDownIcon } from "@/components/ui/phosphor-icons";
import { PovertyDisclosureCard } from "@/components/ui/collection-grid-disclosure";
import type { UserMarker } from "@/components/leaflet-map";
import type { HouseholdRow, PovertyOverview } from "@/lib/poverty-types";
import { MarkerPlacementDialog } from "@/components/poverty/marker-placement-dialog";
import { ReligionDialog } from "@/components/poverty/religion-dialog";
import { MarkerEditDialog } from "@/components/poverty/marker-edit-dialog";
import { PovertyHouseholdsTable } from "@/components/poverty/poverty-households-table";
import { FuzzyPriorityStats } from "@/components/poverty/fuzzy-priority-stats";
import { toast } from "sonner";
const LeafletMap = dynamic(() => import("@/components/leaflet-map"), { ssr: false });
const PLACING_LABELS: Record<string, string> = {
marker: "Data Warga",
religion: "Rumah Ibadah",
clinic: "Klinik",
"food-bank": "Lumbung Pangan",
school: "Sekolah",
};
function MapDropZone() {
const { setNodeRef } = useDroppable({ id: "map-canvas" });
return <div ref={setNodeRef} className="absolute inset-0 z-[1] pointer-events-none" />;
}
export default function PovertyMapPage() {
const [searchQuery, setSearchQuery] = useState("");
// Initialize deterministically (matches SSR) and hydrate from sessionStorage
// after mount to avoid a hydration mismatch that regenerates the whole tree.
const [useLeaflet, setUseLeaflet] = useState(false);
const [mapStyle, setMapStyle] = useState<"street" | "satellite">("street");
const [hydrated, setHydrated] = useState(false);
const [userMarkers, setUserMarkers] = useState<UserMarker[]>([]);
const [placingMarkerType, setPlacingMarkerType] = useState<string | null>(null);
const [dragDropEnabled, setDragDropEnabled] = useState(false);
const [pendingClick, setPendingClick] = useState<{ lat: number; lng: number } | null>(null);
const [householdsRefreshKey, setHouseholdsRefreshKey] = useState(0);
const [editingMarker, setEditingMarker] = useState<UserMarker | null>(null);
const [draggedItemId, setDraggedItemId] = useState<string | null>(null);
const [households, setHouseholds] = useState<HouseholdRow[]>([]);
const [householdsLoading, setHouseholdsLoading] = useState(true);
const [overview, setOverview] = useState<PovertyOverview | null>(null);
const [selectedHouseholdId, setSelectedHouseholdId] = useState<number | null>(null);
const mapInstanceRef = useRef<any>(null);
const mapContainerRef = useRef<HTMLDivElement>(null);
// Require a small drag distance before activating dnd, so a plain click on a
// disclosure item is treated as a tool toggle (not a drag). Without this the
// click is swallowed and the marker tool never activates — the dialog would
// then only appear via drag-drop instead of the click-tool → click-map flow.
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } })
);
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000";
// Dashboard data — households + aggregated overview, straight from the DB.
useEffect(() => {
const token = localStorage.getItem("auth_token");
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
Promise.allSettled([
fetch(`${BACKEND_URL}/households`, { headers }).then((r) => r.json()),
fetch(`${BACKEND_URL}/households/stats-overview`, { headers }).then((r) => r.json()),
]).then(([hhResult, overviewResult]) => {
if (hhResult.status === "fulfilled" && Array.isArray(hhResult.value)) {
setHouseholds(hhResult.value as HouseholdRow[]);
}
if (overviewResult.status === "fulfilled" && overviewResult.value?.totals) {
setOverview(overviewResult.value as PovertyOverview);
}
setHouseholdsLoading(false);
});
}, [householdsRefreshKey]);
// Load existing markers when map view opens. Households are reused from the
// dashboard fetch above; only POIs need their own request here.
useEffect(() => {
if (!useLeaflet) return;
const token = localStorage.getItem("auth_token");
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
fetch(`${BACKEND_URL}/poi`, { headers })
.then((r) => r.json())
.catch(() => [])
.then((poiRows: any[]) => {
const hhMarkers: UserMarker[] = households.map((r) => ({
id: `hh-${r.id}`,
lat: parseFloat(r.latitude),
lng: parseFloat(r.longitude),
type: "marker",
name: r.head_name,
meta: {
poverty_level: r.poverty_level,
family_count: r.family_count,
penghasilan: r.penghasilan,
fuzzy_label: r.fuzzy_label ?? undefined,
fuzzy_score: r.fuzzy_score != null ? Number(r.fuzzy_score) : undefined,
notes: r.notes ?? undefined,
},
}));
const poiMarkers: UserMarker[] = (Array.isArray(poiRows) ? poiRows : []).map((r) => ({
id: `poi-${r.id}`,
lat: parseFloat(r.latitude),
lng: parseFloat(r.longitude),
type: r.religion_subtype ?? r.poi_type,
name: r.name,
meta: {
poi_type: r.poi_type,
notes: r.notes ?? undefined,
radius: r.radius_meters ?? 0,
},
}));
setUserMarkers([...hhMarkers, ...poiMarkers]);
});
}, [useLeaflet, households]);
useEffect(() => {
setUseLeaflet(sessionStorage.getItem("poverty_useLeaflet") === "true");
setMapStyle((sessionStorage.getItem("poverty_mapStyle") as "street" | "satellite") ?? "street");
setHydrated(true);
}, []);
useEffect(() => { if (hydrated) sessionStorage.setItem("poverty_useLeaflet", String(useLeaflet)); }, [useLeaflet, hydrated]);
useEffect(() => { if (hydrated) sessionStorage.setItem("poverty_mapStyle", mapStyle); }, [mapStyle, hydrated]);
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
setDraggedItemId(null);
if (over?.id !== "map-canvas") return;
const itemId = (active.data.current as any)?.itemId as string | undefined;
if (!itemId || !mapInstanceRef.current) return;
// Get drop coordinates in the map container
const nativeEvent = event.activatorEvent as PointerEvent;
const rect = mapContainerRef.current?.getBoundingClientRect();
if (!rect) return;
const L = (window as any).L;
if (!L) return;
const x = nativeEvent.clientX + (event.delta?.x ?? 0) - rect.left;
const y = nativeEvent.clientY + (event.delta?.y ?? 0) - rect.top;
const latLng = mapInstanceRef.current.containerPointToLatLng(L.point(x, y));
setPendingClick({ lat: latLng.lat, lng: latLng.lng });
setPlacingMarkerType(itemId);
}
const demographics = useMemo(() => {
const householdMarkers = userMarkers.filter((m) => m.type === "marker");
return {
population: householdMarkers.reduce((sum, h) => sum + (h.meta?.family_count ?? 0), 0),
beneficiaries: householdMarkers.filter((h) => h.meta?.poverty_level === "Extreme" || h.meta?.poverty_level === "Miskin").length,
socialSecurity: householdMarkers.filter((h) => h.meta?.poverty_level === "Rentan").length,
};
}, [userMarkers]);
// Default the inspector to the highest-priority household.
const selectedHousehold = useMemo(() => {
if (households.length === 0) return null;
const found = households.find((h) => h.id === selectedHouseholdId);
if (found) return found;
return [...households].sort(
(a, b) => Number(b.fuzzy_score ?? -1) - Number(a.fuzzy_score ?? -1)
)[0];
}, [households, selectedHouseholdId]);
async function handleMarkerDragEnd(marker: UserMarker, newLat: number, newLng: number) {
const isHousehold = marker.id.startsWith("hh-");
const dbId = marker.id.split("-").slice(1).join("-");
const token = localStorage.getItem("auth_token");
const headers = { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) };
try {
const url = isHousehold ? `${BACKEND_URL}/households/${dbId}` : `${BACKEND_URL}/poi/${dbId}`;
const res = await fetch(url, {
method: "PUT",
headers,
body: JSON.stringify({ latitude: newLat, longitude: newLng }),
});
if (!res.ok) throw new Error("Gagal memindahkan penanda");
setUserMarkers((prev) =>
prev.map((m) =>
m.id === marker.id ? { ...m, lat: newLat, lng: newLng } : m
)
);
toast.success(`Posisi "${marker.name}" berhasil diperbarui`);
if (isHousehold) {
setHouseholdsRefreshKey((k) => k + 1);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : "Gagal memindahkan penanda");
}
}
// FULL BLEED MAP LAYOUT
if (useLeaflet) {
return (
<DndContext
sensors={sensors}
onDragStart={(e) => setDraggedItemId((e.active.data.current as any)?.itemId ?? null)}
onDragEnd={handleDragEnd}
onDragCancel={() => setDraggedItemId(null)}
>
<AppShell activePath="/dashboard/poverty" fullBleed onExitFullBleed={() => setUseLeaflet(false)}>
<div ref={mapContainerRef} className="relative w-full h-full overflow-hidden">
<MapDropZone />
{/* Leaflet Map filling the screen */}
<LeafletMap
mode="poverty"
mapStyle={mapStyle}
userMarkers={userMarkers}
onMapClick={(lat, lng) => {
if (placingMarkerType && !editingMarker) {
setPendingClick({ lat, lng });
}
}}
onMarkerClick={(marker) => {
if (!placingMarkerType) setEditingMarker(marker);
}}
onMapReady={(map) => { mapInstanceRef.current = map; }}
dragDropEnabled={dragDropEnabled}
onMarkerDragEnd={handleMarkerDragEnd}
/>
{/* Placing Marker Banner — hidden while the dialog is open */}
{placingMarkerType && !pendingClick && (
<div className="absolute top-20 left-1/2 -translate-x-1/2 z-[1000] bg-background/95 backdrop-blur-md border border-primary/20 px-4 py-2 rounded-full shadow-lg flex items-center gap-3 animate-in fade-in slide-in-from-top-4 duration-300 pointer-events-auto">
<span className="text-xs font-semibold text-foreground flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-primary animate-pulse" />
Menempatkan {PLACING_LABELS[placingMarkerType] ?? placingMarkerType.replace("-", " ")}... Klik pada peta untuk menentukan posisi
</span>
<button
onClick={() => setPlacingMarkerType(null)}
className="text-[10px] uppercase font-bold text-muted-foreground hover:text-foreground border px-2 py-0.5 rounded-full transition-colors"
>
Batal
</button>
</div>
)}
{/* Floating Legend HUD overlay */}
<PovertyHud />
{/* Floating Collection Grid Disclosure */}
<div className="absolute bottom-6 right-4 z-[999] max-h-[calc(100vh-8rem)] overflow-y-auto no-scrollbar pointer-events-auto">
<PovertyDisclosureCard
mapStyle={mapStyle}
onMapStyleChange={setMapStyle}
activeTool={placingMarkerType}
onToolSelect={setPlacingMarkerType}
demographics={demographics}
compact
dragDropEnabled={dragDropEnabled}
onDragDropToggle={setDragDropEnabled}
/>
</div>
{/* Marker Placement Dialog (households + non-religion POIs) */}
<MarkerPlacementDialog
open={!!pendingClick && placingMarkerType !== "religion"}
onOpenChange={(v) => { if (!v) { setPendingClick(null); setPlacingMarkerType(null); } }}
markerType={placingMarkerType ?? "marker"}
pendingClick={pendingClick}
onConfirm={(marker) => {
setUserMarkers((prev) => [...prev, marker]);
setPendingClick(null);
setPlacingMarkerType(null);
if (marker.type === "marker") setHouseholdsRefreshKey((k) => k + 1);
}}
/>
{/* Religion Place Dialog */}
<ReligionDialog
open={!!pendingClick && placingMarkerType === "religion"}
onOpenChange={(v) => { if (!v) { setPendingClick(null); setPlacingMarkerType(null); } }}
pendingClick={pendingClick}
onConfirm={(marker) => {
setUserMarkers((prev) => [...prev, marker]);
setPendingClick(null);
setPlacingMarkerType(null);
}}
/>
{/* Marker Edit/Delete Dialog */}
<MarkerEditDialog
open={!!editingMarker}
onOpenChange={(v) => { if (!v) setEditingMarker(null); }}
marker={editingMarker}
onUpdated={(updated) => {
setUserMarkers((prev) => prev.map((m) => m.id === updated.id ? updated : m));
setEditingMarker(null);
if (updated.type === "marker") setHouseholdsRefreshKey((k) => k + 1);
}}
onDeleted={(deleted) => {
setUserMarkers((prev) => prev.filter((m) => m.id !== deleted.id));
setEditingMarker(null);
if (deleted.type === "marker") setHouseholdsRefreshKey((k) => k + 1);
}}
/>
</div>
</AppShell>
{/* Drag overlay badge */}
<DragOverlay>
{draggedItemId && (
<div className="flex items-center gap-1.5 rounded-full bg-foreground/90 px-3 py-1.5 text-xs font-medium text-background shadow-lg">
<span className="capitalize">{PLACING_LABELS[draggedItemId] ?? draggedItemId.replace("-", " ")}</span>
</div>
)}
</DragOverlay>
</DndContext>
);
}
// STANDARD DASHBOARD LAYOUT (DEFAULT)
return (
<AppShell activePath="/dashboard/poverty">
<div className="flex flex-col gap-6">
<MapPageHeader
title="Statistik Kemiskinan"
description="Statistik kemiskinan langsung dari data warga dan fasilitas yang terpetakan. Buka tampilan Leaflet untuk mengelola marker."
onOpenMap={() => setUseLeaflet(true)}
buttonText="Peta Kemiskinan"
icon={<TrendingDownIcon className="h-4 w-4" />}
/>
{/* Unified Grid Layout */}
<div className="grid grid-cols-1 gap-px bg-border border rounded-xl overflow-hidden md:grid-cols-2 lg:grid-cols-4 shadow-none">
{/* Row 1: 4 stats cards (rendered inside PovertyStatsCards as direct children) */}
<PovertyStatsCards overview={overview} />
{/* Row 2: Trend Chart */}
<div className="md:col-span-2 lg:col-start-1 lg:col-span-3 lg:row-start-2 lg:row-end-3 flex flex-col h-full">
<PovertyTrendChart monthly={overview?.monthly ?? []} />
</div>
{/* Row 2: Inspector Top */}
<div className="md:col-span-2 lg:col-start-4 lg:col-span-1 lg:row-start-2 lg:row-end-3 flex flex-col h-full">
<PovertyInspectorTop selected={selectedHousehold} />
</div>
{/* Row 3: Inspector Bottom */}
<div className="md:col-span-2 lg:col-start-4 lg:col-span-1 lg:row-start-3 lg:row-end-4 flex flex-col h-full">
<PovertyInspectorBottom selected={selectedHousehold} />
</div>
{/* Row 3: Directory */}
<div className="md:col-span-2 lg:col-start-1 lg:col-span-3 lg:row-start-3 lg:row-end-4 flex flex-col h-full">
<PovertyDirectory
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
households={households}
selectedId={selectedHousehold?.id ?? null}
onSelect={(h) => setSelectedHouseholdId(h.id)}
/>
</div>
{/* Row 4: Fuzzy Priority Distribution */}
<FuzzyPriorityStats
refreshKey={householdsRefreshKey}
onRecomputed={() => setHouseholdsRefreshKey((k) => k + 1)}
/>
{/* Row 5: Households Table (full width) */}
<div className="md:col-span-2 lg:col-span-4 flex flex-col h-full">
<PovertyHouseholdsTable rows={households} loading={householdsLoading} />
</div>
</div>
</div>
</AppShell>
);
}
+229
View File
@@ -0,0 +1,229 @@
"use client";
import { useState, useEffect } from "react";
import { toast } from "sonner";
import { AppShell } from "@/components/app-shell";
import { useAuth } from "@/lib/auth-context";
import { Input } from "@/components/ui/input";
import type { Role } from "@/lib/auth-types";
import {
UserIcon,
AtSignIcon,
KeyRoundIcon,
SettingsIcon
} from "@/components/ui/phosphor-icons";
const ROLE_DETAILS = [
{
value: "superadmin" as Role,
label: "Super Administrator",
description: "Akses penuh ke semua peta (Kemiskinan, Lahan & Jalan, SPBU) serta statistik analitik.",
},
{
value: "admin_poverty" as Role,
label: "Admin Poverty Map",
description: "Akses terbatas khusus untuk peta kemiskinan dan analisis data warga miskin.",
},
{
value: "admin_lands_roads" as Role,
label: "Admin Lahan & Jalan",
description: "Akses terbatas khusus untuk manajemen lahan tata ruang dan jalan transportasi.",
},
{
value: "admin_gas_stations" as Role,
label: "Admin SPBU",
description: "Akses terbatas khusus untuk pemetaan SPBU, EV Charger, dan Bengkel.",
}
];
export default function SettingsPage() {
const { user, token, login } = useAuth();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [selectedRole, setSelectedRole] = useState<Role>("superadmin");
const [mapStyle, setMapStyle] = useState<"street" | "satellite">("street");
const [saving, setSaving] = useState(false);
// Load user details when loaded
useEffect(() => {
if (user) {
setName(user.username || "");
setEmail(user.email || "");
setSelectedRole(user.role || "superadmin");
// Load map preference from localStorage if set
const savedMapStyle = localStorage.getItem("default_map_style") as "street" | "satellite";
if (savedMapStyle) {
setMapStyle(savedMapStyle);
}
}
}, [user]);
function handleSave(e: React.FormEvent) {
e.preventDefault();
if (!user || !token) return;
setSaving(true);
setTimeout(() => {
const updatedUser = {
...user,
username: name.trim(),
email: email.trim(),
role: selectedRole
};
// Save profile and role to AuthContext
login(token, updatedUser);
// Save map preferences to localStorage
localStorage.setItem("default_map_style", mapStyle);
toast.success("Pengaturan berhasil disimpan!");
setSaving(false);
}, 800);
}
return (
<AppShell activePath="/dashboard/settings">
<div className="max-w-4xl mx-auto flex flex-col gap-8 py-6 px-4">
{/* Page Header */}
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2.5">
<div className="p-2 rounded-md bg-primary/10 text-primary">
<SettingsIcon className="h-5 w-5" />
</div>
<h1 className="text-2xl md:text-3xl font-semibold text-foreground tracking-tight">
Pengaturan Akun
</h1>
</div>
<p className="text-xs text-muted-foreground">
Kelola informasi profil, peran administrator sistem, dan preferensi tampilan peta global.
</p>
</div>
<form onSubmit={handleSave} className="flex flex-col gap-6">
{/* Profile Card */}
<div className="rounded-xl border border-border bg-card p-6 flex flex-col gap-5">
<h2 className="text-base font-semibold text-foreground">Informasi Profil</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider flex items-center gap-1.5">
<UserIcon className="h-3.5 w-3.5 text-muted-foreground" />
<span>Nama Pengguna</span>
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Masukkan nama pengguna"
required
className="bg-background border border-border focus-visible:border-ring rounded-md h-10 text-sm"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider flex items-center gap-1.5">
<AtSignIcon className="h-3.5 w-3.5 text-muted-foreground" />
<span>Email</span>
</label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Masukkan email"
required
className="bg-background border border-border focus-visible:border-ring rounded-md h-10 text-sm"
/>
</div>
</div>
</div>
{/* Role Gating Card */}
<div className="rounded-xl border border-border bg-card p-6 flex flex-col gap-5">
<div className="flex flex-col gap-1">
<h2 className="text-base font-semibold text-foreground flex items-center gap-1.5">
<KeyRoundIcon className="h-4 w-4 text-muted-foreground" />
<span>Peran &amp; Hak Akses</span>
</h2>
<p className="text-[11.5px] text-muted-foreground leading-normal">
Pilih peran admin untuk membatasi atau membuka modul visualisasi peta di sidebar.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{ROLE_DETAILS.map((roleOpt) => {
const isSelected = selectedRole === roleOpt.value;
return (
<div
key={roleOpt.value}
onClick={() => setSelectedRole(roleOpt.value)}
className={`p-4 border rounded-xl cursor-pointer flex flex-col gap-2 transition-all select-none ${
isSelected
? "border-primary bg-primary/5 shadow-sm"
: "border-border/60 bg-background hover:bg-muted/50"
}`}
>
<div className="flex items-center justify-between">
<span className={`text-xs font-semibold ${isSelected ? "text-primary" : "text-foreground"}`}>
{roleOpt.label}
</span>
{isSelected && (
<span className="h-2 w-2 rounded-full bg-primary animate-pulse" />
)}
</div>
<p className="text-[10px] text-muted-foreground leading-relaxed">
{roleOpt.description}
</p>
</div>
);
})}
</div>
</div>
{/* Map Preferences Card */}
<div className="rounded-xl border border-border bg-card p-6 flex flex-col gap-5">
<h2 className="text-base font-semibold text-foreground">Preferensi Peta</h2>
<div className="flex flex-col gap-2">
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
Default Map Style
</span>
<div className="flex gap-2">
{["street", "satellite"].map((style) => {
const isSelected = mapStyle === style;
return (
<button
key={style}
type="button"
onClick={() => setMapStyle(style as any)}
className={`h-9 px-4 rounded-md border text-xs font-medium uppercase tracking-wider transition-all cursor-pointer ${
isSelected
? "bg-primary border-transparent text-primary-foreground shadow-sm"
: "bg-background border-border text-foreground/75 hover:bg-muted"
}`}
>
{style === "street" ? "Street View" : "Satellite View"}
</button>
);
})}
</div>
</div>
</div>
{/* Submit Action */}
<div className="flex justify-end gap-2 border-t pt-4">
<button
type="submit"
disabled={saving || !name.trim() || !email.trim()}
className="bg-primary hover:bg-primary/90 text-primary-foreground font-medium rounded-md px-6 py-2.5 h-10 text-xs uppercase tracking-wider transition-colors disabled:opacity-40 flex items-center justify-center gap-2 cursor-pointer"
>
{saving ? "Menyimpan..." : "Simpan Perubahan"}
</button>
</div>
</form>
</div>
</AppShell>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+181
View File
@@ -0,0 +1,181 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-vermilion-pulse: #ff3b1f;
--color-cobalt-glow: #5683d2;
--color-obsidian: #0c0a08;
--color-paper: #ffffff;
--color-limestone: #f4f2f0;
--color-charcoal: #1a1919;
--color-slate: #4d505d;
--color-fog: #999ba3;
--color-bone: #d2cecb;
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
/* Ramp's strict dual-radius system: 4px for functional controls, 12px for surfaces */
--radius-sm: calc(var(--radius) - 8px);
--radius-md: calc(var(--radius) - 8px);
--radius-lg: var(--radius);
--radius-xl: var(--radius);
--radius-2xl: var(--radius);
--radius-3xl: var(--radius);
--radius-4xl: 9999px;
}
:root {
--background: #ffffff; /* Paper */
--foreground: #0c0a08; /* Obsidian */
--card: #f4f2f0; /* Limestone — separates surfaces without drop shadows */
--card-foreground: #0c0a08;
--popover: #ffffff;
--popover-foreground: #0c0a08;
--primary: #ff3b1f; /* Vermilion Pulse — the single bold ignition accent */
--primary-foreground: #ffffff; /* white text for contrast on vermilion */
--secondary: #f4f2f0; /* Limestone */
--secondary-foreground: #0c0a08;
--muted: #f4f2f0; /* Limestone */
--muted-foreground: #999ba3; /* Fog */
--accent: #f4f2f0; /* Limestone */
--accent-foreground: #0c0a08;
--destructive: oklch(0.577 0.245 27.325);
--border: #d2cecb; /* Bone — hairline borders on light surfaces */
--input: #d2cecb;
--ring: #5683d2; /* Cobalt Glow — atmospheric accent reserved for focus rings */
--chart-1: #5683d2; /* Cobalt Glow */
--chart-2: #4d505d; /* Slate */
--chart-3: #999ba3; /* Fog */
--chart-4: #d2cecb; /* Bone */
--chart-5: #1a1919; /* Charcoal */
--radius: 0.75rem; /* 12px surface radius — controls derive 4px via --radius-sm/md */
--sidebar: #f4f2f0;
--sidebar-foreground: #0c0a08;
--sidebar-primary: #0c0a08;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #ffffff;
--sidebar-accent-foreground: #0c0a08;
--sidebar-border: #d2cecb;
--sidebar-ring: #5683d2;
}
.dark {
--background: #0c0a08; /* Obsidian ground */
--foreground: #ffffff; /* Paper */
--card: #1a1919; /* Charcoal — one step lifted from Obsidian */
--card-foreground: #ffffff;
--popover: #1a1919;
--popover-foreground: #ffffff;
--primary: #ff3b1f; /* Vermilion Pulse stays the only chromatic accent */
--primary-foreground: #ffffff;
--secondary: #1a1919;
--secondary-foreground: #ffffff;
--muted: #1a1919;
--muted-foreground: #999ba3; /* Fog */
--accent: #1a1919;
--accent-foreground: #ffffff;
--destructive: oklch(0.704 0.191 22.216);
--border: #4d505d; /* Slate */
--input: #4d505d;
--ring: #5683d2; /* Cobalt Glow */
--chart-1: #5683d2;
--chart-2: #999ba3;
--chart-3: #d2cecb;
--chart-4: #4d505d;
--chart-5: #f4f2f0;
--sidebar: #1a1919;
--sidebar-foreground: #ffffff;
--sidebar-primary: #ff3b1f;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #0c0a08;
--sidebar-accent-foreground: #ffffff;
--sidebar-border: #4d505d;
--sidebar-ring: #5683d2;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
@media (min-width: 768px) {
[data-sonner-toaster] {
transition: left 0.2s ease, right 0.2s ease, transform 0.2s ease !important;
}
html:has([data-slot="sidebar"][data-state="expanded"]) [data-sonner-toaster][data-x-position="left"] {
left: calc(16rem + 16px) !important;
}
html:has([data-slot="sidebar"][data-state="collapsed"]) [data-sonner-toaster][data-x-position="left"] {
left: calc(3rem + 16px) !important;
}
}
/* Custom glassmorphism utility classes */
.liquid-glass {
background: rgba(255, 255, 255, 0.01);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.1), 0 1px 2px rgba(0, 0, 0, 0.05);
position: relative;
}
.liquid-glass::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.02));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.liquid-glass-strong {
background: rgba(255, 255, 255, 0.01);
backdrop-filter: blur(50px);
-webkit-backdrop-filter: blur(50px);
box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.1), 0 1px 2px rgba(0, 0, 0, 0.05);
}
+41
View File
@@ -0,0 +1,41 @@
import type { Metadata } from "next";
import { Geist_Mono, Plus_Jakarta_Sans } from "next/font/google";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/sonner";
import { AuthProvider } from "@/lib/auth-context";
import "./globals.css";
const fontSans = Plus_Jakarta_Sans({
variable: "--font-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${fontSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">
<AuthProvider>
<TooltipProvider>{children}</TooltipProvider>
<Toaster position="bottom-left" />
</AuthProvider>
</body>
</html>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { LandingHeader } from "@/components/landing/landing-header";
import { LandingHero } from "@/components/landing/landing-hero";
import { LandingAbout } from "@/components/landing/landing-about";
import { LandingProjects } from "@/components/landing/landing-projects";
import { LandingCta } from "@/components/landing/landing-cta";
import { LandingFooter } from "@/components/landing/landing-footer";
export default function Home() {
return (
<div className="min-h-screen bg-white font-sans text-gray-900 selection:bg-primary selection:text-primary-foreground">
<LandingHeader />
<LandingHero />
<LandingAbout />
<LandingProjects />
<LandingCta />
<LandingFooter />
</div>
);
}
+1560
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {
"@efferd": "https://efferd.com/r/{style}/{name}.json"
}
}
+32
View File
@@ -0,0 +1,32 @@
import type { ReactNode } from "react";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
} from "@/components/ui/breadcrumb";
/** Current page segment shown in the header — pass a nav item or `{ title, icon? }`. */
export type AppBreadcrumbPage = {
title: string;
icon?: ReactNode;
};
export function AppBreadcrumbs({ page }: { page?: AppBreadcrumbPage | null }) {
if (!page?.title) {
return null;
}
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbPage className="flex items-center gap-2 [&>svg]:size-3.5">
{page.icon}
{page.title}
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
import { DecorIcon } from "@/components/decor-icon";
import { AppBreadcrumbs } from "@/components/app-breadcrumbs";
import { MapsBreadcrumb } from "@/components/maps/maps-breadcrumb";
import { getNavLinks } from "@/components/app-shared";
import { CustomSidebarTrigger } from "@/components/custom-sidebar-trigger";
import { NavUser } from "@/components/nav-user";
import { TrendingDownIcon, RoadHorizonIcon, FuelIcon } from "@/components/ui/phosphor-icons";
const MAP_PAGES: Record<string, { label: string; icon: React.ReactNode }> = {
"/dashboard/poverty": { label: "Peta Kemiskinan", icon: <TrendingDownIcon className="size-3.5" /> },
"/dashboard/land": { label: "Lahan dan Jalan", icon: <RoadHorizonIcon className="size-3.5" /> },
"/dashboard/gas-station": { label: "SPBU dan Charger", icon: <FuelIcon className="size-3.5" /> },
};
export function AppHeader({ activePath, fullBleed = false, onExitFullBleed }: { activePath?: string; fullBleed?: boolean; onExitFullBleed?: () => void }) {
const links = getNavLinks(activePath);
const activeItem = links.find((item) => item.isActive);
const mapPage = activePath ? MAP_PAGES[activePath] : undefined;
return (
<header
className={cn(
"sticky top-0 z-50 flex h-14 shrink-0 items-center justify-between gap-2 border-b px-4 md:px-6",
"bg-background/95 backdrop-blur-sm supports-backdrop-filter:bg-background/50"
)}
>
<DecorIcon className="hidden md:block" position="bottom-left" />
<div className="flex items-center gap-3">
<CustomSidebarTrigger />
<Separator
className="mr-2 h-4 data-[orientation=vertical]:self-center"
orientation="vertical"
/>
{mapPage ? (
<MapsBreadcrumb currentPage={mapPage.label} currentIcon={mapPage.icon} isLeafletOpen={fullBleed} onExitMap={onExitFullBleed} />
) : (
<AppBreadcrumbs page={activeItem} />
)}
</div>
<div className="flex items-center gap-3">
<NavUser />
</div>
</header>
);
}
+121
View File
@@ -0,0 +1,121 @@
import type { ReactNode } from "react";
import {
FuelIcon,
SettingsIcon,
HelpCircleIcon,
BookOpenIcon,
TrendingDownIcon,
RoadHorizonIcon,
HouseIcon
} from "@/components/ui/phosphor-icons";
export type SidebarNavItem = {
title: string;
path?: string;
icon?: ReactNode;
isActive?: boolean;
subItems?: SidebarNavItem[];
};
export type SidebarNavGroup = {
label?: string;
items: SidebarNavItem[];
};
const ROLE_ALLOWED_MAP_PATHS: Record<string, string[]> = {
superadmin: ["/dashboard/poverty", "/dashboard/land", "/dashboard/gas-station"],
admin_poverty: ["/dashboard/poverty"],
admin_lands_roads: ["/dashboard/land"],
admin_gas_stations: ["/dashboard/gas-station"],
};
const checkIsActive = (activePath?: string, itemPath?: string): boolean => {
if (!activePath || !itemPath) return false;
if (itemPath.startsWith("#")) return false; // Ignore hash placeholders
if (itemPath === "/dashboard") {
return activePath === "/dashboard";
}
return activePath === itemPath || activePath.startsWith(itemPath + "/");
};
export const getNavGroups = (activePath?: string, role?: string): SidebarNavGroup[] => {
const allowedMapPaths = role ? (ROLE_ALLOWED_MAP_PATHS[role] ?? []) : null;
const allMapItems = [
{
title: "Peta Kemiskinan",
path: "/dashboard/poverty",
icon: <TrendingDownIcon />,
},
{
title: "Lahan & Jalan",
path: "/dashboard/land",
icon: <RoadHorizonIcon />,
},
{
title: "SPBU & Charger",
path: "/dashboard/gas-station",
icon: <FuelIcon />,
},
];
const mapItems = allMapItems
.filter((item) => !allowedMapPaths || allowedMapPaths.includes(item.path))
.map((item) => ({ ...item, isActive: checkIsActive(activePath, item.path) }));
return [
{
label: "Ikhtisar",
items: [
{
title: "Beranda",
path: "/dashboard",
icon: <HouseIcon />,
isActive: checkIsActive(activePath, "/dashboard"),
},
],
},
{
label: "Peta Visual",
items: mapItems,
},
{
label: "Administrasi",
items: [
{
title: "Pengaturan",
path: "/dashboard/settings",
icon: <SettingsIcon />,
isActive: checkIsActive(activePath, "/dashboard/settings"),
},
],
},
];
};
export const navGroups = getNavGroups();
export const footerNavLinks: SidebarNavItem[] = [
{
title: "Pusat Bantuan",
path: "#/help",
icon: <HelpCircleIcon />,
},
{
title: "Dokumentasi",
path: "#/documentation",
icon: <BookOpenIcon />,
},
];
export const getNavLinks = (activePath?: string, role?: string): SidebarNavItem[] => [
...getNavGroups(activePath, role).flatMap((group) =>
group.items.flatMap((item) =>
item.subItems?.length ? [item, ...item.subItems] : [item]
)
),
...footerNavLinks,
];
export const navLinks = getNavLinks();
+39
View File
@@ -0,0 +1,39 @@
import { cn } from "@/lib/utils";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { AppHeader } from "@/components/app-header";
import { AppSidebar } from "@/components/app-sidebar";
export function AppShell({
children,
activePath,
fullBleed,
onExitFullBleed,
hideHeader = false
}: {
children: React.ReactNode;
activePath?: string;
fullBleed?: boolean;
onExitFullBleed?: () => void;
hideHeader?: boolean;
}) {
return (
<SidebarProvider className={cn("[--app-wrapper-max-width:80rem]", fullBleed && "h-screen overflow-hidden")}>
<AppSidebar activePath={activePath} />
<SidebarInset className={cn(fullBleed && "h-screen overflow-hidden flex flex-col")}>
{!hideHeader && (
<AppHeader activePath={activePath} fullBleed={fullBleed} onExitFullBleed={onExitFullBleed} />
)}
<div
className={cn(
"flex flex-1 flex-col",
fullBleed
? cn("p-0 max-w-none overflow-hidden", hideHeader ? "h-screen" : "h-[calc(100vh-3.5rem)]")
: "p-4 md:p-6 mx-auto w-full max-w-(--app-wrapper-max-width)"
)}
>
{children}
</div>
</SidebarInset>
</SidebarProvider>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
import { cn } from "@/lib/utils";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { footerNavLinks, getNavGroups } from "@/components/app-shared";
import { NavGroup } from "@/components/nav-group";
import { useAuth } from "@/lib/auth-context";
export function AppSidebar({ activePath }: { activePath?: string }) {
const { user } = useAuth();
const groups = getNavGroups(activePath, user?.role);
return (
<Sidebar
className={cn(
"*:data-[slot=sidebar-inner]:bg-background",
"*:data-[slot=sidebar-inner]:dark:bg-[radial-gradient(60%_18%_at_10%_0%,--theme(--color-foreground/.08),transparent)]",
// Default text/icon colors for sidebar buttons (faded)
"**:data-[slot=sidebar-menu-button]:[&>span]:text-foreground/60",
"**:data-[slot=sidebar-menu-button]:[&>svg]:text-foreground/50",
"**:data-[slot=sidebar-menu-button]:[&>span]:transition-colors",
"**:data-[slot=sidebar-menu-button]:[&>svg]:transition-colors",
// Hover states for sidebar buttons
"**:data-[slot=sidebar-menu-button]:hover:bg-sidebar-accent/50",
"**:data-[slot=sidebar-menu-button]:hover:[&>span]:text-foreground",
"**:data-[slot=sidebar-menu-button]:hover:[&>svg]:text-foreground",
// Active state (Vermilion Pulse theme color)
"**:data-[slot=sidebar-menu-button]:data-[active]:bg-primary/10!",
"**:data-[slot=sidebar-menu-button]:data-[active]:[&>span]:text-primary!",
"**:data-[slot=sidebar-menu-button]:data-[active]:[&>span]:font-semibold",
"**:data-[slot=sidebar-menu-button]:data-[active]:[&>svg]:text-primary!",
"**:data-[slot=sidebar-menu-button]:data-[active]:hover:bg-primary/15!",
// Default text/icon colors for sidebar sub-buttons (faded)
"**:data-[slot=sidebar-menu-sub-button]:[&>span]:text-foreground/60",
"**:data-[slot=sidebar-menu-sub-button]:[&>span]:transition-colors",
// Hover states for sub-buttons
"**:data-[slot=sidebar-menu-sub-button]:hover:bg-sidebar-accent/50",
"**:data-[slot=sidebar-menu-sub-button]:hover:[&>span]:text-foreground",
// Active state for sub-buttons
"**:data-[slot=sidebar-menu-sub-button]:data-[active]:bg-primary/10!",
"**:data-[slot=sidebar-menu-sub-button]:data-[active]:[&>span]:text-primary!",
"**:data-[slot=sidebar-menu-sub-button]:data-[active]:[&>span]:font-semibold",
"**:data-[slot=sidebar-menu-sub-button]:data-[active]:hover:bg-primary/15!"
)}
collapsible="icon"
variant="sidebar"
>
<SidebarHeader className="h-14 justify-center border-b px-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<SidebarMenuButton render={<a href="/dashboard" />}><img src="/waras_io/favicon-32x32.png" className="size-5 shrink-0 rounded" alt="Waras Logo" /><span className="font-medium text-foreground!">Waras</span></SidebarMenuButton>
</SidebarHeader>
<SidebarContent>
{groups.map((group, index) => (
<NavGroup key={`sidebar-group-${index}`} {...group} />
))}
</SidebarContent>
<SidebarFooter className="gap-0 p-0">
<SidebarMenu className="border-t p-2">
{footerNavLinks.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton className="text-muted-foreground" isActive={item.isActive} size="sm" tooltip={item.title} render={<a href={item.path} />}>{item.icon}<span>{item.title}</span></SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
<div className="px-4 pt-4 pb-2 transition-opacity group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:opacity-0">
<p className="text-nowrap text-[9px] text-muted-foreground">
© {new Date().getFullYear()} Waras
</p>
</div>
</SidebarFooter>
</Sidebar>
);
}
+16
View File
@@ -0,0 +1,16 @@
import type React from "react";
export function AuthDivider({
children,
...props
}: React.ComponentProps<"div">) {
return (
<div className="relative flex w-full items-center" {...props}>
<div className="w-full border-t" />
<div className="flex w-max justify-center text-nowrap px-2 text-muted-foreground text-xs">
{children}
</div>
<div className="w-full border-t" />
</div>
);
}
+251
View File
@@ -0,0 +1,251 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth-context";
import { Logo } from "@/components/logo";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group";
import { FloatingPaths } from "@/components/floating-paths";
import {
ArrowLeftIcon,
AtSignIcon,
EyeIcon,
EyeOffIcon,
KeyRoundIcon,
} from "@/components/ui/phosphor-icons";
const BACKEND_URL =
process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:4000";
const ROLE_HOME: Record<string, string> = {
superadmin: "/dashboard",
admin_poverty: "/dashboard/poverty",
admin_lands_roads: "/dashboard/land",
admin_gas_stations: "/dashboard/gas-station",
};
const DEMO_CREDENTIALS = [
{
label: "Super Admin",
username: "superadmin",
password: "Super@1234",
routes: "All routes",
},
{
label: "Admin Poverty Map",
username: "admin_poverty",
password: "Poverty@1234",
routes: "/dashboard/poverty only",
},
{
label: "Admin Lahan & Jalan",
username: "admin_lands",
password: "Lands@1234",
routes: "/dashboard/land only",
},
{
label: "Admin SPBU",
username: "admin_gas",
password: "Gas@1234",
routes: "/dashboard/gas-station only",
},
];
export function AuthPage() {
const { login } = useAuth();
const router = useRouter();
const [identifier, setIdentifier] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setIsLoading(true);
try {
const res = await fetch(`${BACKEND_URL}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: identifier.trim(),
password,
}),
});
const data = await res.json();
if (!res.ok) {
setError(data.error ?? "Login failed");
return;
}
login(data.token, data.user);
const params = new URLSearchParams(window.location.search);
const redirectTo = params.get("redirectTo");
router.replace(
redirectTo ?? ROLE_HOME[data.user.role] ?? "/dashboard",
);
} catch {
setError("Cannot connect to server. Please try again.");
} finally {
setIsLoading(false);
}
}
return (
<main className="relative md:h-screen md:overflow-hidden lg:grid lg:grid-cols-2">
<div className="relative hidden h-full flex-col border-r bg-secondary p-10 lg:flex dark:bg-secondary/20">
<div className="absolute inset-0 bg-linear-to-b from-transparent via-transparent to-background" />
<Logo className="relative z-10 mr-auto h-10 w-auto" />
<div className="z-10 mt-auto">
<blockquote className="space-y-2">
<p className="text-xl">
&ldquo;Aplikasi ini berguna dalam membantu mengelola data spasial
dengan lebih efisien.&rdquo;
</p>
<footer className="font-mono font-semibold text-sm">
~ Pangkywara
</footer>
</blockquote>
</div>
<div className="absolute inset-0">
<FloatingPaths position={1} />
<FloatingPaths position={-1} />
</div>
</div>
<div className="relative flex min-h-screen flex-col justify-center px-8">
<Link
href="/"
className="absolute top-6 left-6 z-20 inline-flex h-6 items-center justify-center gap-2 rounded-md px-3 font-medium text-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ArrowLeftIcon className="size-4" />
Kembali
</Link>
{/* Top Shades */}
<div
aria-hidden
className="absolute inset-0 isolate -z-10 opacity-60 contain-strict"
>
<div className="absolute top-0 right-0 h-320 w-140 -translate-y-87.5 rounded-full bg-[radial-gradient(68.54%_68.72%_at_55.02%_31.46%,--theme(--color-foreground/.06)_0,hsla(0,0%,55%,.02)_50%,--theme(--color-foreground/.01)_80%)]" />
<div className="absolute top-0 right-0 h-320 w-60 rounded-full bg-[radial-gradient(50%_50%_at_50%_50%,--theme(--color-foreground/.04)_0,--theme(--color-foreground/.01)_80%,transparent_100%)] [translate:5%_-50%]" />
<div className="absolute top-0 right-0 h-320 w-60 -translate-y-87.5 rounded-full bg-[radial-gradient(50%_50%_at_50%_50%,--theme(--color-foreground/.04)_0,--theme(--color-foreground/.01)_80%,transparent_100%)]" />
</div>
<div className="mx-auto space-y-4 sm:w-sm">
<Logo className="h-4.5 w-auto lg:hidden" />
<div className="flex flex-col space-y-1">
<h1 className="font-bold text-2xl tracking-wide">Login</h1>
<p className="text-base text-muted-foreground">
Masuk ke akun Anda untuk mengakses dashboard.
</p>
</div>
<form className="space-y-3" onSubmit={handleSubmit}>
<InputGroup>
<InputGroupInput
placeholder="Username or email"
type="text"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
autoComplete="username"
required
/>
<InputGroupAddon align="inline-start">
<AtSignIcon />
</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupInput
placeholder="Password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
<InputGroupAddon align="inline-start">
<KeyRoundIcon />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<InputGroupButton
type="button"
aria-label={
showPassword ? "Hide password" : "Show password"
}
onClick={() => setShowPassword((value) => !value)}
>
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
{error && (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
)}
<Button className="w-full" type="submit" disabled={isLoading}>
{isLoading ? "Logging in…" : "Login Sekarang"}
</Button>
</form>
{/* Quick Demo Credentials */}
<div className="pt-4 border-t border-border/50 flex flex-col gap-2.5">
<div className="flex flex-col gap-0.5">
<h2 className="text-[11px] font-bold text-foreground/60 uppercase tracking-wider">Demo Credentials (Quick Login)</h2>
<p className="text-[11px] text-muted-foreground">Click a role to auto-fill the login form:</p>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{DEMO_CREDENTIALS.map((cred) => (
<button
key={cred.username}
type="button"
onClick={() => {
setIdentifier(cred.username);
setPassword(cred.password);
}}
className="flex flex-col items-start gap-0.5 p-2.5 rounded-lg border border-border bg-card/50 text-left transition-all hover:bg-muted/80 hover:border-primary/40 group cursor-pointer"
>
<span className="text-xs font-bold text-foreground group-hover:text-primary transition-colors">
{cred.label}
</span>
<span className="text-[10px] text-muted-foreground font-mono truncate w-full">
U: {cred.username} | P: {cred.password}
</span>
<span className="text-[9.5px] font-medium text-primary bg-primary/10 px-1 rounded-sm mt-0.5">
{cred.routes}
</span>
</button>
))}
</div>
</div>
</div>
</div>
</main>
);
}
@@ -0,0 +1,22 @@
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import { SidebarTrigger } from "@/components/ui/sidebar";
export function CustomSidebarTrigger() {
return (
<Tooltip>
<TooltipTrigger delay={1000} render={<SidebarTrigger />} />
<TooltipContent className="px-2 py-1" side="right">
Toggle Sidebar{" "}
<KbdGroup>
<Kbd></Kbd>
<Kbd>b</Kbd>
</KbdGroup>
</TooltipContent>
</Tooltip>
);
}
@@ -0,0 +1,80 @@
import {
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { DashboardCard } from "@/components/dashboard-card";
import {
FuelIcon,
LayersIcon,
UserPlusIcon,
} from "@/components/ui/phosphor-icons";
import type { ActivityItem, ActivityKind } from "@/lib/dashboard-types";
interface DashboardActivityProps {
items: ActivityItem[];
}
const KIND_ICONS: Record<ActivityKind, React.ReactNode> = {
household: <UserPlusIcon />,
land: <LayersIcon />,
gas: <FuelIcon />,
};
function timeAgo(iso: string): string {
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return "";
const diffMs = Date.now() - then;
const minutes = Math.floor(diffMs / 60_000);
if (minutes < 1) return "Baru saja";
if (minutes < 60) return `${minutes} menit lalu`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} jam lalu`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days} hari lalu`;
return new Date(iso).toLocaleDateString("id-ID", {
day: "numeric",
month: "short",
year: "numeric",
});
}
export function DashboardActivity({ items }: DashboardActivityProps) {
return (
<DashboardCard className="gap-0">
<CardHeader className="border-b">
<CardTitle>Aktivitas</CardTitle>
<CardDescription>Pembaruan terbaru di basis data.</CardDescription>
</CardHeader>
<CardContent className="px-0">
{items.length === 0 ? (
<div className="flex h-48 items-center justify-center text-xs text-muted-foreground">
Belum ada aktivitas.
</div>
) : (
<ul className="flex flex-col divide-y divide-border">
{items.map((item) => (
<li className="flex h-16 items-center gap-3 px-6" key={item.id}>
<span
aria-hidden="true"
className="flex size-10 shrink-0 items-center justify-center [&_svg]:size-4"
>
{KIND_ICONS[item.kind]}
</span>
<div className="min-w-0 flex-1 space-y-1">
<p className="line-clamp-1 text-pretty text-foreground text-sm leading-snug">
{item.title}
</p>
<p className="text-muted-foreground text-xs">
{timeAgo(item.created_at)}
</p>
</div>
</li>
))}
</ul>
)}
</CardContent>
</DashboardCard>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils";
import type * as React from "react";
import { Card } from "@/components/ui/card";
export function DashboardCard({
className,
...props
}: React.ComponentProps<typeof Card>) {
return (
<Card
className={cn("rounded-none bg-background shadow-none ring-0 border-none", className)}
{...props}
/>
);
}
@@ -0,0 +1,20 @@
import { cn } from "@/lib/utils";
export function DashboardSkeleton() {
return (
<div
className={cn(
"grid grid-cols-2 gap-px bg-border p-px lg:grid-cols-4",
"*:min-h-48 *:w-full *:bg-background/90"
)}
>
<div />
<div />
<div />
<div />
<div className="col-span-2 min-h-114! lg:col-span-4" />
<div className="col-span-2 min-h-92! lg:col-span-2" />
<div className="col-span-2 min-h-92! lg:col-span-2" />
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { DashboardActivity } from "@/components/dashboard-activity";
import { DashboardSkeleton } from "@/components/dashboard-skeleton";
import { HouseholdsTrendChart } from "@/components/households-trend-chart";
import { PriorityBreakdown } from "@/components/priority-breakdown";
import { PriorityScoreChart } from "@/components/priority-score-chart";
import { RecentHouseholds } from "@/components/recent-households";
import { DashboardStats } from "@/components/stats";
import type { ActivityItem, GasStationStats } from "@/lib/dashboard-types";
import type { HouseholdRow, PovertyOverview } from "@/lib/poverty-types";
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000";
type NamedRow = { id: number; name: string; created_at?: string };
export function Dashboard() {
const [loading, setLoading] = useState(true);
const [overview, setOverview] = useState<PovertyOverview | null>(null);
const [households, setHouseholds] = useState<HouseholdRow[]>([]);
const [gasStats, setGasStats] = useState<GasStationStats | null>(null);
const [landMarkers, setLandMarkers] = useState<NamedRow[]>([]);
const [landShapes, setLandShapes] = useState<NamedRow[]>([]);
const [gasMarkers, setGasMarkers] = useState<NamedRow[]>([]);
// Aggregates everything the overview widgets need, straight from the DB.
useEffect(() => {
const token = localStorage.getItem("auth_token");
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
const get = (path: string) => fetch(`${BACKEND_URL}${path}`, { headers }).then((r) => r.json());
const asRows = (r: PromiseSettledResult<unknown>) =>
r.status === "fulfilled" && Array.isArray(r.value) ? (r.value as NamedRow[]) : [];
Promise.allSettled([
get("/households/stats-overview"),
get("/households"),
get("/gas-station/stats"),
get("/land/markers"),
get("/land/shapes"),
get("/gas-station/markers"),
]).then(([overviewR, hhR, gasStatsR, landMarkersR, landShapesR, gasMarkersR]) => {
if (overviewR.status === "fulfilled" && overviewR.value?.totals) {
setOverview(overviewR.value as PovertyOverview);
}
if (hhR.status === "fulfilled" && Array.isArray(hhR.value)) {
setHouseholds(hhR.value as HouseholdRow[]);
}
if (gasStatsR.status === "fulfilled" && typeof gasStatsR.value?.total === "number") {
setGasStats(gasStatsR.value as GasStationStats);
}
setLandMarkers(asRows(landMarkersR));
setLandShapes(asRows(landShapesR));
setGasMarkers(asRows(gasMarkersR));
setLoading(false);
});
}, []);
const activity = useMemo<ActivityItem[]>(() => {
const items: ActivityItem[] = [
...households.map((h) => ({
id: `hh-${h.id}`,
kind: "household" as const,
title: `Warga "${h.head_name}" terdata`,
created_at: h.created_at,
})),
...landMarkers.map((m) => ({
id: `lm-${m.id}`,
kind: "land" as const,
title: `Penanda lahan "${m.name}" ditambahkan`,
created_at: m.created_at ?? "",
})),
...landShapes.map((s) => ({
id: `ls-${s.id}`,
kind: "land" as const,
title: `Bidang lahan "${s.name}" digambar`,
created_at: s.created_at ?? "",
})),
...gasMarkers.map((g) => ({
id: `gs-${g.id}`,
kind: "gas" as const,
title: `Fasilitas "${g.name}" dipetakan`,
created_at: g.created_at ?? "",
})),
];
return items
.filter((i) => i.created_at)
.sort((a, b) => b.created_at.localeCompare(a.created_at))
.slice(0, 4);
}, [households, landMarkers, landShapes, gasMarkers]);
if (loading) return <DashboardSkeleton />;
return (
<div className="grid grid-cols-1 gap-px bg-border border rounded-xl overflow-hidden md:grid-cols-2 lg:grid-cols-4 shadow-none">
<DashboardStats
overview={overview}
gasStats={gasStats}
landMarkerCount={landMarkers.length}
landShapeCount={landShapes.length}
/>
<HouseholdsTrendChart monthly={overview?.monthly ?? []} />
<PriorityScoreChart monthly={overview?.monthly ?? []} />
<RecentHouseholds households={households.slice(0, 4)} />
<PriorityBreakdown byLabel={overview?.by_label ?? null} />
<DashboardActivity items={activity} />
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { cn } from "@/lib/utils";
import { cva, type VariantProps } from "class-variance-authority";
const DecorIconVariants = cva(
"pointer-events-none absolute z-1 size-5 shrink-0 stroke-1 stroke-muted-foreground",
{
variants: {
position: {
"top-left":
"top-0 left-0 -translate-x-[calc(50%+0.5px)] -translate-y-[calc(50%+0.5px)]",
"top-right":
"top-0 right-0 translate-x-[calc(50%+0.5px)] -translate-y-[calc(50%+0.5px)]",
"bottom-right":
"right-0 bottom-0 translate-x-[calc(50%+0.5px)] translate-y-[calc(50%+0.5px)]",
"bottom-left":
"bottom-0 left-0 -translate-x-[calc(50%+0.5px)] translate-y-[calc(50%+0.5px)]",
},
},
defaultVariants: {
position: "top-left",
},
}
);
type DecorIconProps = React.ComponentProps<"svg"> &
VariantProps<typeof DecorIconVariants>;
export function DecorIcon({ position, className, ...props }: DecorIconProps) {
return (
<svg
aria-hidden="true"
className={cn(DecorIconVariants({ position, className }))}
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path d="M5 12h14" />
<path d="M12 5v14" />
</svg>
);
}
+179
View File
@@ -0,0 +1,179 @@
"use client";
import { cn } from "@/lib/utils";
import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { MinusIcon, TrendingUpIcon, ArrowUpIcon, ChevronUpIcon, TrendingDownIcon, ArrowDownIcon, ChevronDownIcon } from "@/components/ui/phosphor-icons";
type DeltaIconVariant = "default" | "trend" | "arrow";
type DeltaVariant = "default" | "badge";
type DeltaContextValue = {
value: number;
};
const DeltaContext = React.createContext<DeltaContextValue | null>(null);
function useDeltaValue() {
const context = React.useContext(DeltaContext);
if (!context) {
throw new Error(
"DeltaIcon and DeltaValue must be used inside a `Delta` component."
);
}
return context.value;
}
function Delta({
className,
value,
variant = "default",
...props
}: React.ComponentProps<"div"> & {
value: number;
variant?: DeltaVariant;
}) {
return (
<DeltaContext.Provider value={{ value }}>
{variant === "badge" ? (
<Badge
className={cn(
"gap-1 border-none tabular-nums [&_svg]:size-4 [&_svg]:shrink-0",
value > 0
? "bg-emerald-500/10 text-emerald-500"
: "bg-red-500/10 text-red-500",
className
)}
data-slot="delta"
variant="secondary"
{...(props as React.ComponentProps<typeof Badge>)}
/>
) : (
<div
className={cn(
"inline-flex items-center gap-1 text-muted-foreground tabular-nums",
"[&_svg]:size-3 [&_svg]:shrink-0",
value > 0 ? "text-emerald-600 dark:text-emerald-400" : "",
value < 0 ? "text-rose-600 dark:text-rose-400" : "",
className
)}
data-slot="delta"
{...props}
/>
)}
</DeltaContext.Provider>
);
}
function FilledShell({
value,
children,
}: {
value: number;
children: React.ReactNode;
}) {
return (
<span
className={cn(
"inline-flex size-3 shrink-0 items-center justify-center rounded-full",
"[&_svg]:size-2! [&_svg]:shrink-0 [&_svg]:stroke-3! [&_svg]:text-background",
value > 0 && "bg-emerald-500",
value < 0 && "bg-red-500",
(!value || value === 0) && "bg-muted-foreground"
)}
data-slot="delta-icon"
>
{children}
</span>
);
}
function DeltaIcon({
variant = "default",
filled = false,
className,
...props
}: Omit<React.ComponentProps<"svg">, "fill"> & {
variant?: DeltaIconVariant;
filled?: boolean;
}) {
const resolvedValue = useDeltaValue();
const mergedClassName = cn(className);
const shell = (node: React.ReactElement) =>
filled ? <FilledShell value={resolvedValue}>{node}</FilledShell> : node;
const slotProps = filled ? {} : { "data-slot": "delta-icon" as const };
if (!resolvedValue || resolvedValue === 0) {
return shell(
<MinusIcon {...slotProps} className={mergedClassName} {...props} />
);
}
if (resolvedValue > 0) {
if (variant === "trend") {
return shell(
<TrendingUpIcon {...slotProps} className={mergedClassName} {...props} />
);
}
if (variant === "arrow") {
return shell(
<ArrowUpIcon {...slotProps} className={mergedClassName} {...props} />
);
}
return shell(
<ChevronUpIcon {...slotProps} className={mergedClassName} {...props} />
);
}
if (variant === "trend") {
return shell(
<TrendingDownIcon {...slotProps} className={mergedClassName} {...props} />
);
}
if (variant === "arrow") {
return shell(
<ArrowDownIcon {...slotProps} className={mergedClassName} {...props} />
);
}
return shell(
<ChevronDownIcon {...slotProps} className={mergedClassName} {...props} />
);
}
function DeltaValue({
className,
precision = 1,
suffix = "%",
absolute = true,
...props
}: React.ComponentProps<"span"> & {
precision?: number;
suffix?: string;
absolute?: boolean;
}) {
const resolvedValue = useDeltaValue();
const formattedValue = (
absolute ? Math.abs(resolvedValue) : resolvedValue
).toFixed(precision);
return (
<span
className={cn("tabular-nums", className)}
data-slot="delta-value"
{...props}
>
{formattedValue}
{suffix}
</span>
);
}
export { Delta, DeltaIcon, DeltaValue };
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { motion } from "motion/react";
export function FloatingPaths({ position }: { position: number }) {
const paths = Array.from({ length: 36 }, (_, i) => ({
id: i,
d: `M-${380 - i * 5 * position} -${189 + i * 6}C-${
380 - i * 5 * position
} -${189 + i * 6} -${312 - i * 5 * position} ${216 - i * 6} ${
152 - i * 5 * position
} ${343 - i * 6}C${616 - i * 5 * position} ${470 - i * 6} ${
684 - i * 5 * position
} ${875 - i * 6} ${684 - i * 5 * position} ${875 - i * 6}`,
color: `rgba(15,23,42,${0.1 + i * 0.03})`,
width: 0.5 + i * 0.03,
}));
return (
<div className="pointer-events-none absolute inset-0">
<svg
className="h-full w-full text-primary"
fill="none"
viewBox="0 0 696 316"
>
<title>Background Paths</title>
{paths.map((path) => (
<motion.path
animate={{
pathLength: 1,
opacity: [0.3, 0.6, 0.3],
}}
d={path.d}
initial={{ pathLength: 0.3, opacity: 0.6 }}
key={path.id}
stroke="currentColor"
strokeOpacity={0.1 + path.id * 0.03}
strokeWidth={path.width}
transition={{
// Animating pathOffset forces a stroke-dasharray recompute on
// every frame for all 36 paths — opacity alone is far cheaper.
pathLength: { duration: 2, ease: "easeOut" },
opacity: {
duration: 20 + (path.id % 10),
repeat: Number.POSITIVE_INFINITY,
ease: "linear",
},
}}
/>
))}
</svg>
</div>
);
}
+95
View File
@@ -0,0 +1,95 @@
export const DASHBOARD_LOCALE = "en-US";
/** Noon anchor avoids off-by-one labels around timezone boundaries for ISO date strings. */
export function parseIsoCalendarDate(isoDate: string): Date {
return new Date(`${isoDate}T12:00:00`);
}
export type DashboardDateStyle = "month" | "day-month" | "full";
export function formatDate(isoDate: string, style: DashboardDateStyle): string {
const date = parseIsoCalendarDate(isoDate);
if (style === "month") {
return date.toLocaleDateString(DASHBOARD_LOCALE, { month: "short" });
}
if (style === "day-month") {
return date.toLocaleDateString(DASHBOARD_LOCALE, {
day: "numeric",
month: "short",
});
}
return date.toLocaleDateString(DASHBOARD_LOCALE, {
day: "numeric",
month: "short",
year: "numeric",
});
}
/** X-axis for range charts: weekday when showing ~a week, otherwise month + day. */
export function formatChartAxisTick(
isoDate: string,
periodDays: number
): string {
const date = parseIsoCalendarDate(isoDate);
if (periodDays <= 7) {
return date.toLocaleDateString(DASHBOARD_LOCALE, { weekday: "short" });
}
return formatDate(isoDate, "day-month");
}
export type ChartTooltipWeekdayStyle = "short" | "long";
/** Tooltip label for a chart point (weekday + month + day). */
export function formatChartTooltipDate(
isoDate: string,
weekdayStyle: ChartTooltipWeekdayStyle = "short"
): string {
const date = parseIsoCalendarDate(isoDate);
return date.toLocaleDateString(DASHBOARD_LOCALE, {
weekday: weekdayStyle,
day: "numeric",
month: "short",
});
}
export function formatCompactCurrency(
value: number,
options?: { maximumFractionDigits?: number }
) {
const { maximumFractionDigits = 0 } = options ?? {};
return new Intl.NumberFormat(DASHBOARD_LOCALE, {
currency: "USD",
maximumFractionDigits,
notation: "compact",
style: "currency",
}).format(value);
}
/** Full-precision USD (e.g. average order value). */
export function formatFullCurrency(value: number) {
return new Intl.NumberFormat(DASHBOARD_LOCALE, {
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
style: "currency",
}).format(value);
}
export function formatCompactNumber(value: number) {
return new Intl.NumberFormat(DASHBOARD_LOCALE, {
maximumFractionDigits: 1,
notation: "compact",
}).format(value);
}
/** Whole numbers with grouping (visits, sessions, counts). */
export function formatInteger(value: number) {
return new Intl.NumberFormat(DASHBOARD_LOCALE, {
maximumFractionDigits: 0,
}).format(value);
}
/** Percentage with fixed decimal places (e.g. conversion rate). */
export function formatPercent(value: number, fractionDigits = 2) {
return `${value.toFixed(fractionDigits)}%`;
}
@@ -0,0 +1,151 @@
"use client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { SearchIcon, ZapIcon } from "@/components/ui/phosphor-icons";
import { BRAND_COLORS } from "@/lib/map-data";
import type { UserMarker } from "@/components/leaflet-map";
import { ContinuousTabs } from "@/components/ui/continuous-tabs";
interface GasStationDirectoryProps {
searchQuery: string;
setSearchQuery: (query: string) => void;
brandFilter: string;
setBrandFilter: (brand: string) => void;
evOnlyFilter: boolean;
setEvOnlyFilter: (evOnly: boolean) => void;
filteredStations: UserMarker[];
selectedStationId: string;
onSelectStation: (station: UserMarker) => void;
}
export function GasStationDirectory({
searchQuery,
setSearchQuery,
brandFilter,
setBrandFilter,
evOnlyFilter,
setEvOnlyFilter,
filteredStations,
selectedStationId,
onSelectStation,
}: GasStationDirectoryProps) {
return (
<Card className="bg-background rounded-none border-none shadow-none ring-0 h-full flex flex-col">
<CardHeader className="border-b bg-muted/20 px-6 py-4 lg:h-[73px] lg:py-0 flex flex-col justify-center">
<CardTitle className="text-base font-semibold">Direktori Stasiun Aktif</CardTitle>
<CardDescription>Menyaring {filteredStations.length} target stasiun regional aktif dari database.</CardDescription>
</CardHeader>
<CardContent className="p-6 flex-1">
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-3 mb-4 items-center">
<div className="relative flex-grow w-full">
<SearchIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Cari berdasarkan nama lokasi..."
className="pl-9 bg-background h-10"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<ContinuousTabs
tabs={[
{ id: "All", label: "Semua" },
{ id: "Pertamina", label: "Pertamina" },
{ id: "Shell", label: "Shell" },
{ id: "BP", label: "BP" },
{ id: "Vivo", label: "Vivo" },
]}
activeId={brandFilter}
onChange={setBrandFilter}
size="sm"
/>
</div>
<div className="flex items-center gap-2 mb-4">
<Button
variant={evOnlyFilter ? "default" : "outline"}
onClick={() => setEvOnlyFilter(!evOnlyFilter)}
className="h-8 text-xs gap-1.5"
>
<ZapIcon className="h-3.5 w-3.5 text-amber-500 fill-amber-500" />
Pengisi Daya EV Tersedia
</Button>
</div>
{/* Grid List */}
{filteredStations.length === 0 ? (
<div className="py-8 text-center text-xs text-muted-foreground">
Tidak ada lokasi yang cocok. Tambah lokasi baru melalui peta stasiun.
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 max-h-[360px] overflow-y-auto pr-1">
{filteredStations.map((station) => {
const isSelected = selectedStationId === station.id;
const brandName = station.type === "gas-pump" ? station.meta?.poi_type : null;
const brandColor = BRAND_COLORS[brandName as keyof typeof BRAND_COLORS] || { bg: "#6b7280", text: "#ffffff" };
return (
<div
key={station.id}
onClick={() => onSelectStation(station)}
className={`p-4 border rounded-lg cursor-pointer flex flex-col gap-1.5 transition-all ${
isSelected
? "border-primary bg-primary/5 font-semibold"
: "border-border/60 hover:bg-muted"
}`}
>
<div className="flex justify-between items-start">
<span className="text-xs text-foreground font-bold">{station.name}</span>
{station.type === "gas-pump" && brandName ? (
<span
className="text-[9px] uppercase font-bold px-2 py-0.5 rounded border shrink-0 ml-2"
style={{
color: brandColor.bg,
borderColor: `${brandColor.bg}33`,
backgroundColor: `${brandColor.bg}11`
}}
>
{brandName}
</span>
) : (
<span className="text-[9px] uppercase font-bold px-2 py-0.5 rounded border shrink-0 ml-2 bg-primary/10 text-primary border-primary/20">
{station.type === "charging-station" ? "EV Charger" : "Bengkel"}
</span>
)}
</div>
<p className="text-[10px] text-muted-foreground font-mono tabular-nums">
Koordinat: {station.lat.toFixed(5)}, {station.lng.toFixed(5)}
</p>
{station.type === "gas-pump" && station.meta?.gas_types && station.meta.gas_types.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{station.meta.gas_types.map((gt) => (
<span
key={gt}
className="px-1.5 py-0.5 rounded text-[8px] font-medium bg-muted text-muted-foreground border border-border/40"
>
{gt}
</span>
))}
</div>
)}
<div className="flex justify-between items-center text-[10px] mt-1 text-muted-foreground border-t pt-2 mt-auto">
{station.type === "gas-pump" ? (
<span>Operasional: {station.meta?.notes?.replace("Jam: ", "") || "24 Jam"}</span>
) : station.type === "charging-station" ? (
<span>Tipe: {station.meta?.poi_type} · {station.meta?.notes}</span>
) : (
<span>Spesialisasi: {station.meta?.poi_type} · {station.meta?.notes}</span>
)}
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,52 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { ArrowLeftIcon } from "@/components/ui/phosphor-icons";
import { ContinuousTabs } from "@/components/ui/continuous-tabs";
interface GasStationHudProps {
fuelPriceDisplay: "ron92" | "ron95" | "diesel";
setFuelPriceDisplay: (price: "ron92" | "ron95" | "diesel") => void;
brandFilter: string;
setBrandFilter: (brand: string) => void;
onCloseMap: () => void;
}
export function GasStationHud({
fuelPriceDisplay,
setFuelPriceDisplay,
brandFilter,
setBrandFilter,
onCloseMap,
}: GasStationHudProps) {
return (
<div className="absolute top-4 left-4 z-[999] flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-3 bg-background/90 backdrop-blur-md border border-border/40 p-2.5 rounded-lg shadow-lg max-w-[90vw]">
<span className="text-[10px] font-bold text-muted-foreground">TAMPILAN:</span>
<ContinuousTabs
tabs={[
{ id: "ron92", label: "RON 92" },
{ id: "ron95", label: "RON 95" },
{ id: "diesel", label: "Diesel" },
]}
activeId={fuelPriceDisplay}
onChange={(id) => setFuelPriceDisplay(id as any)}
size="sm"
/>
<Separator orientation="vertical" className="h-5" />
<ContinuousTabs
tabs={[
{ id: "All", label: "Semua" },
{ id: "Pertamina", label: "Pertamina" },
{ id: "Shell", label: "Shell" },
{ id: "BP", label: "BP" },
]}
activeId={brandFilter}
onChange={setBrandFilter}
size="sm"
/>
</div>
</div>
);
}
@@ -0,0 +1,166 @@
"use client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { MapPinIcon, ZapIcon, ClockIcon, NavigationIcon } from "@/components/ui/phosphor-icons";
import { BRAND_COLORS } from "@/lib/map-data";
import type { UserMarker } from "@/components/leaflet-map";
interface GasStationInspectorProps {
selectedStation: UserMarker | null;
}
const BRAND_PRICES = {
Pertamina: { ron92: 13200, ron95: 14400, diesel: 14900 },
Shell: { ron92: 13850, ron95: 14600, diesel: 15100 },
BP: { ron92: 13400, ron95: 14500, diesel: 14800 },
Vivo: { ron92: 13400, ron95: 14500, diesel: 14800 }
};
export function GasStationInspector({ selectedStation }: GasStationInspectorProps) {
if (!selectedStation) {
return (
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-center items-center h-full p-6 text-center text-muted-foreground/60 border-l border-border/40">
<MapPinIcon className="h-8 w-8 text-muted-foreground/30 mb-2" />
<p className="text-xs">Pilih stasiun dari direktori atau peta untuk melihat detail.</p>
</Card>
);
}
const isGasPump = selectedStation.type === "gas-pump";
const isEvCharger = selectedStation.type === "charging-station";
const isWrench = selectedStation.type === "wrench";
const brandName = isGasPump ? selectedStation.meta?.poi_type : null;
const brandColor = BRAND_COLORS[brandName as keyof typeof BRAND_COLORS] || { bg: "#6b7280", text: "#ffffff" };
// Get price index for gas stations
const prices = isGasPump
? (BRAND_PRICES[brandName as keyof typeof BRAND_PRICES] || BRAND_PRICES.Pertamina)
: null;
// Extract clean values from meta
const cleanNotes = selectedStation.meta?.notes ?? "";
const cleanPoiType = selectedStation.meta?.poi_type ?? "";
return (
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full border-l border-border/40">
<CardHeader className="border-b bg-muted/20 px-6 py-4 lg:h-[73px] lg:py-0 flex flex-col justify-center">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<MapPinIcon className="h-4 w-4 text-primary" />
Detail Stasiun
</CardTitle>
<CardDescription>Metrik stasiun langsung &amp; detail dari database.</CardDescription>
</CardHeader>
<CardContent className="p-6 flex flex-col gap-5 flex-1 overflow-y-auto">
<div>
<h3 className="text-xl font-bold text-foreground leading-tight">{selectedStation.name}</h3>
<span
className="inline-block mt-2 text-[9px] uppercase font-bold tracking-wider px-2 py-0.5 rounded border"
style={isGasPump && brandName ? {
color: brandColor.bg,
borderColor: `${brandColor.bg}33`,
backgroundColor: `${brandColor.bg}11`
} : undefined}
>
{isGasPump ? `Jaringan ${brandName}` : isEvCharger ? "Pengisi Daya EV" : "Bengkel (Repair Shop)"}
</span>
</div>
{/* Gas Pump: Pricing Cards */}
{isGasPump && prices && (
<div className="flex flex-col gap-2 border rounded-lg p-3 bg-muted/10">
<div className="flex justify-between items-center text-xs py-1 border-b">
<span className="font-semibold text-foreground">Harga RON 92</span>
<span className="font-bold text-foreground">Rp {prices.ron92.toLocaleString()} / L</span>
</div>
<div className="flex justify-between items-center text-xs py-1 border-b">
<span className="font-semibold text-primary font-semibold">Harga RON 95</span>
<span className="font-bold text-primary">Rp {prices.ron95.toLocaleString()} / L</span>
</div>
<div className="flex justify-between items-center text-xs py-1">
<span className="font-semibold text-foreground">Harga Diesel</span>
<span className="font-bold text-foreground">Rp {prices.diesel.toLocaleString()} / L</span>
</div>
</div>
)}
{/* Gas Pump: Fuel Types */}
{isGasPump && selectedStation.meta?.gas_types && selectedStation.meta.gas_types.length > 0 && (
<div className="flex flex-col gap-2">
<span className="text-xs font-semibold text-muted-foreground">Tipe Bahan Bakar</span>
<div className="flex flex-wrap gap-1">
{selectedStation.meta.gas_types.map((gt) => (
<span
key={gt}
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-primary/10 text-primary border border-primary/20"
>
{gt}
</span>
))}
</div>
</div>
)}
{/* EV Charger Details */}
{isEvCharger && (
<div className="flex flex-col gap-3 border rounded-lg p-3 bg-muted/15">
<div className="flex justify-between items-center text-xs py-1 border-b">
<span className="font-semibold text-muted-foreground">Tipe Charger</span>
<span className="font-bold text-foreground">{cleanPoiType}</span>
</div>
<div className="flex justify-between items-center text-xs py-1">
<span className="font-semibold text-muted-foreground">Status Operasional</span>
<span className={`font-bold px-2 py-0.5 rounded text-[10px] uppercase ${
cleanNotes.includes("Active") ? "bg-emerald-500/10 text-emerald-600 border border-emerald-500/20" :
cleanNotes.includes("Maintenance") ? "bg-amber-500/10 text-amber-600 border border-amber-500/20" :
"bg-red-500/10 text-red-600 border border-red-500/20"
}`}>
{cleanNotes.replace("Status: ", "")}
</span>
</div>
</div>
)}
{/* Repair Shop Details */}
{isWrench && (
<div className="flex flex-col gap-3 border rounded-lg p-3 bg-muted/15">
<div className="flex justify-between items-center text-xs py-1 border-b">
<span className="font-semibold text-muted-foreground">Spesialisasi</span>
<span className="font-bold text-foreground">{cleanPoiType}</span>
</div>
<div className="flex justify-between items-center text-xs py-1">
<span className="font-semibold text-muted-foreground">Status / Jam Buka</span>
<span className="font-bold text-foreground">{cleanNotes}</span>
</div>
</div>
)}
{/* General: Operating Hours HUD style */}
{isGasPump && (
<div className="flex flex-col gap-2">
<span className="text-xs font-semibold text-muted-foreground">Jam Operasional</span>
<div className="flex items-center gap-3 p-3 border rounded-lg bg-background">
<ClockIcon className="h-6 w-6 shrink-0 text-emerald-500" />
<div>
<p className="text-xs font-bold text-foreground">Buka Layanan</p>
<p className="text-[10px] text-muted-foreground">{cleanNotes.replace("Jam: ", "") || "24 Jam"}</p>
</div>
</div>
</div>
)}
{/* Coordinates & Route Buttons */}
<div className="border-t pt-4 text-xs text-muted-foreground flex flex-col gap-2 mt-auto">
<p className="flex items-center gap-1 font-mono">
<MapPinIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
Lat: {selectedStation.lat.toFixed(5)}, Lng: {selectedStation.lng.toFixed(5)}
</p>
<Button className="w-full mt-2 h-9 gap-1.5" variant="outline">
<NavigationIcon className="h-4 w-4" /> Dapatkan Arah Rute
</Button>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,117 @@
import { Card } from "@/components/ui/card";
import { FuelIcon } from "@/components/ui/phosphor-icons";
export interface FuelPriceData {
region: string;
pertalite: number | null;
pertamax: number | null;
pertamaxGreen: number | null;
pertamaxTurbo: number | null;
pertamaxPertashop: number | null;
pertaminaDex: number | null;
dexlite: number | null;
bioSolarNonSubsidi: number | null;
bioSolarSubsidi: number | null;
}
export interface FuelPricesResponse {
succeeded: boolean;
lastUpdated: string;
gasoline: FuelPriceData[];
diesel: FuelPriceData[];
}
interface GasStationStatsCardsProps {
fuelPrices: FuelPricesResponse | null;
loading: boolean;
}
export function GasStationStatsCards({ fuelPrices, loading }: GasStationStatsCardsProps) {
// Find Kalimantan Barat prices
const kalbarGas = fuelPrices?.gasoline?.find(
(g) => g.region === "Prov. Kalimantan Barat"
);
const kalbarDiesel = fuelPrices?.diesel?.find(
(d) => d.region === "Prov. Kalimantan Barat"
);
const formatPrice = (p: number | null | undefined) => {
if (loading) return "Loading...";
if (p == null) return "Rp —";
return "Rp " + p.toLocaleString("id-ID");
};
const lastUpdatedText = fuelPrices?.lastUpdated || "Update terbaru";
return (
<>
{/* Card 1: Pertalite */}
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full">
<div className="px-6 pt-6 pb-4">
<div className="flex justify-between items-center pb-1">
<span className="text-xs font-medium text-muted-foreground">Pertalite (RON 90)</span>
<FuelIcon className="h-4 w-4 text-emerald-500" />
</div>
<div className="text-3xl font-bold text-foreground mt-2">
{formatPrice(kalbarGas?.pertalite)}
</div>
</div>
<div className="border-t px-6 py-3 flex items-center justify-between text-[10px] text-muted-foreground mt-auto font-sans">
<span>Prov. Kalimantan Barat</span>
<span className="text-muted-foreground/80">{lastUpdatedText}</span>
</div>
</Card>
{/* Card 2: Pertamax */}
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full">
<div className="px-6 pt-6 pb-4">
<div className="flex justify-between items-center pb-1">
<span className="text-xs font-medium text-muted-foreground">Pertamax (RON 92)</span>
<FuelIcon className="h-4 w-4 text-emerald-500" />
</div>
<div className="text-3xl font-bold text-foreground mt-2">
{formatPrice(kalbarGas?.pertamax)}
</div>
</div>
<div className="border-t px-6 py-3 flex items-center justify-between text-[10px] text-muted-foreground mt-auto font-sans">
<span>Prov. Kalimantan Barat</span>
<span className="text-muted-foreground/80">{lastUpdatedText}</span>
</div>
</Card>
{/* Card 3: Dexlite */}
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full">
<div className="px-6 pt-6 pb-4">
<div className="flex justify-between items-center pb-1">
<span className="text-xs font-medium text-muted-foreground">Dexlite (Diesel)</span>
<FuelIcon className="h-4 w-4 text-emerald-500" />
</div>
<div className="text-3xl font-bold text-foreground mt-2">
{formatPrice(kalbarDiesel?.dexlite)}
</div>
</div>
<div className="border-t px-6 py-3 flex items-center justify-between text-[10px] text-muted-foreground mt-auto font-sans">
<span>Prov. Kalimantan Barat</span>
<span className="text-muted-foreground/80">{lastUpdatedText}</span>
</div>
</Card>
{/* Card 4: Bio Solar (Subsidi) */}
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full">
<div className="px-6 pt-6 pb-4">
<div className="flex justify-between items-center pb-1">
<span className="text-xs font-medium text-muted-foreground">Bio Solar (Subsidi)</span>
<FuelIcon className="h-4 w-4 text-emerald-500" />
</div>
<div className="text-3xl font-bold text-foreground mt-2">
{formatPrice(kalbarDiesel?.bioSolarSubsidi)}
</div>
</div>
<div className="border-t px-6 py-3 flex items-center justify-between text-[10px] text-muted-foreground mt-auto font-sans">
<span>Prov. Kalimantan Barat</span>
<span className="text-muted-foreground/80">{lastUpdatedText}</span>
</div>
</Card>
</>
);
}
@@ -0,0 +1,156 @@
"use client";
import { useState, useMemo } from "react";
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { DashboardCard } from "@/components/dashboard-card";
import { Input } from "@/components/ui/input";
import { SearchIcon, FuelIcon } from "@/components/ui/phosphor-icons";
import { ContinuousTabs } from "@/components/ui/continuous-tabs";
import type { FuelPricesResponse } from "./gas-station-stats-cards";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
interface PertaminaPriceTableProps {
fuelPrices: FuelPricesResponse | null;
loading: boolean;
}
export function PertaminaPriceTable({ fuelPrices, loading }: PertaminaPriceTableProps) {
const [activeTab, setActiveTab] = useState<string>("gasoline");
const [searchQuery, setSearchQuery] = useState<string>("");
const dataList = useMemo(() => {
if (!fuelPrices) return [];
return activeTab === "gasoline" ? fuelPrices.gasoline : fuelPrices.diesel;
}, [fuelPrices, activeTab]);
const filteredData = useMemo(() => {
return dataList.filter((row) =>
row.region?.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [dataList, searchQuery]);
const formatVal = (val: number | null) => {
if (val == null) return <span className="text-muted-foreground/45"></span>;
return <span className="font-semibold text-foreground">Rp {val.toLocaleString("id-ID")}</span>;
};
return (
<DashboardCard className="relative gap-0 h-full flex flex-col">
<CardHeader className="border-b border-border/60 bg-muted/10 px-6 py-4 flex flex-col sm:flex-row justify-between sm:items-center gap-4">
<div>
<CardTitle className="text-base font-semibold flex items-center gap-2">
<FuelIcon className="h-5 w-5 text-primary" />
Daftar Harga BBM Pertamina Seluruh Indonesia
</CardTitle>
<CardDescription>
Data real-time disinkronkan dari Pertamina Patra Niaga ({fuelPrices?.lastUpdated || "Update terbaru"}).
</CardDescription>
</div>
<div className="flex flex-col sm:flex-row gap-3 items-center w-full sm:w-auto">
<div className="relative w-full sm:w-64">
<SearchIcon className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Cari provinsi..."
className="pl-9 bg-background h-9 text-xs"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<ContinuousTabs
tabs={[
{ id: "gasoline", label: "Bensin (Gasoline)" },
{ id: "diesel", label: "Solar (Gasoil)" },
]}
activeId={activeTab}
onChange={setActiveTab}
size="sm"
/>
</div>
</CardHeader>
<CardContent className="p-0 flex-1 flex flex-col">
{loading ? (
<div className="py-12 text-center text-sm text-muted-foreground">
Memuat data harga BBM terbaru...
</div>
) : filteredData.length === 0 ? (
<div className="py-12 text-center text-sm text-muted-foreground">
Tidak ada data provinsi yang cocok.
</div>
) : (
<div className="overflow-x-auto w-full max-h-[420px] overflow-y-auto">
<Table>
<TableHeader className="bg-muted/30 sticky top-0 backdrop-blur z-10">
<TableRow>
<TableHead className="px-6 py-3 min-w-[200px] text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Provinsi / Wilayah</TableHead>
{activeTab === "gasoline" ? (
<>
<TableHead className="px-6 py-3 text-right text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Pertalite (90)</TableHead>
<TableHead className="px-6 py-3 text-right text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Pertamax (92)</TableHead>
<TableHead className="px-6 py-3 text-right text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Pertamax Green 95</TableHead>
<TableHead className="px-6 py-3 text-right text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Pertamax Turbo (98)</TableHead>
<TableHead className="px-6 py-3 text-right text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Pertamax Pertashop</TableHead>
</>
) : (
<>
<TableHead className="px-6 py-3 text-right text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Bio Solar (Subsidi)</TableHead>
<TableHead className="px-6 py-3 text-right text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Bio Solar (Non-Sub)</TableHead>
<TableHead className="px-6 py-3 text-right text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Dexlite</TableHead>
<TableHead className="px-6 py-3 text-right text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Pertamina Dex</TableHead>
</>
)}
</TableRow>
</TableHeader>
<TableBody>
{filteredData.map((row) => {
const isKalbar = row.region === "Prov. Kalimantan Barat";
return (
<TableRow
key={row.region}
className={`text-xs transition-colors hover:bg-muted/40 h-12 ${
isKalbar
? "bg-amber-500/5 hover:bg-amber-500/10 border-l-4 border-l-amber-500 font-semibold"
: ""
}`}
>
<TableCell className="px-6 py-3.5 font-medium flex items-center gap-2">
{isKalbar && (
<span className="h-1.5 w-1.5 rounded-full bg-amber-500 animate-pulse" />
)}
{row.region}
</TableCell>
{activeTab === "gasoline" ? (
<>
<TableCell className="px-6 py-3.5 text-right">{formatVal(row.pertalite)}</TableCell>
<TableCell className="px-6 py-3.5 text-right">{formatVal(row.pertamax)}</TableCell>
<TableCell className="px-6 py-3.5 text-right">{formatVal(row.pertamaxGreen)}</TableCell>
<TableCell className="px-6 py-3.5 text-right">{formatVal(row.pertamaxTurbo)}</TableCell>
<TableCell className="px-6 py-3.5 text-right">{formatVal(row.pertamaxPertashop)}</TableCell>
</>
) : (
<>
<TableCell className="px-6 py-3.5 text-right">{formatVal(row.bioSolarSubsidi)}</TableCell>
<TableCell className="px-6 py-3.5 text-right">{formatVal(row.bioSolarNonSubsidi)}</TableCell>
<TableCell className="px-6 py-3.5 text-right">{formatVal(row.dexlite)}</TableCell>
<TableCell className="px-6 py-3.5 text-right">{formatVal(row.pertaminaDex)}</TableCell>
</>
)}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</DashboardCard>
);
}
@@ -0,0 +1,571 @@
"use client";
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/set-state-in-effect */
import { useState, useEffect, useRef } from "react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { UserMarker } from "@/components/leaflet-map";
import { TimedUndoAction } from "@/components/time-undo-action";
import {
EV_TYPES,
EV_STATUSES,
GAS_BRANDS,
REPAIR_SPECIALITIES,
GAS_TYPES_BY_BRAND,
} from "@/components/gas-station/station-placement-dialog";
/* ─── Helpers ───────────────────────────────────── */
function Field({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
return (
<div className={cn("flex flex-col gap-1.5", className)}>
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
{label}
</span>
{children}
</div>
);
}
function ChipSelector({
options,
value,
onChange,
}: {
options: { value: string; label: string; color: string }[];
value: string;
onChange: (v: string) => void;
}) {
return (
<div className="flex flex-wrap gap-1.5">
{options.map((opt) => {
const isSelected = value === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
className={cn(
"h-8 rounded-lg border px-3 text-[11px] font-semibold transition-all cursor-pointer",
isSelected
? "border-transparent text-white shadow-sm animate-in fade-in zoom-in-95 duration-150"
: "border-border text-foreground/70 hover:bg-muted"
)}
style={isSelected ? { backgroundColor: opt.color } : undefined}
>
{opt.label}
</button>
);
})}
</div>
);
}
function MultiChipSelector({
options,
values,
onChange,
color,
}: {
options: string[];
values: string[];
onChange: (v: string[]) => void;
color: string;
}) {
return (
<div className="flex flex-wrap gap-1.5">
{options.map((opt) => {
const isSelected = values.includes(opt);
return (
<button
key={opt}
type="button"
onClick={() => {
if (isSelected) {
onChange(values.filter((v) => v !== opt));
} else {
onChange([...values, opt]);
}
}}
className={cn(
"h-8 rounded-lg border px-3 text-[11px] font-semibold transition-all cursor-pointer",
isSelected
? "border-transparent text-white shadow-sm"
: "border-border text-foreground/70 hover:bg-muted"
)}
style={isSelected ? { backgroundColor: color } : undefined}
>
{opt}
</button>
);
})}
</div>
);
}
/* ─── Edit Dialog ───────────────────────────────── */
interface StationEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
marker: UserMarker | null;
onUpdated: (updated: UserMarker) => void;
onDeleted: (deleted: UserMarker) => void;
}
export function StationEditDialog({
open,
onOpenChange,
marker,
onUpdated,
onDeleted,
}: StationEditDialogProps) {
const [category, setCategory] = useState("gas-pump");
const [name, setName] = useState("");
// Form fields for EV Charger
const [evType, setEvType] = useState("DC Fast");
const [evStatus, setEvStatus] = useState("Active");
// Form fields for Gas Station
const [gasBrand, setGasBrand] = useState("Pertamina");
const [gasHours, setGasHours] = useState("24 Jam");
const [selectedGasTypes, setSelectedGasTypes] = useState<string[]>([]);
// Form fields for Repair Shop
const [repairSpeciality, setRepairSpeciality] = useState("Umum");
const [repairStatus, setRepairStatus] = useState("Buka 08:00 - 17:00");
// Timed Delete State
const DELETE_SECONDS = 5;
const TOAST_ID = "station-delete";
const [deleteActive, setDeleteActive] = useState(false);
const [deleteCountdown, setDeleteCountdown] = useState(DELETE_SECONDS);
const pendingDeleteMarker = useRef<UserMarker | null>(null);
// Fetching and saving state
const [isLoading, setIsLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000";
// Synchronize when marker opens and load latest from DB
useEffect(() => {
if (!open || !marker) return;
setDeleteActive(false);
setDeleteCountdown(DELETE_SECONDS);
pendingDeleteMarker.current = null;
toast.dismiss(TOAST_ID);
setError(null);
const dbId = marker.id.split("-").slice(1).join("-");
const token = localStorage.getItem("auth_token");
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
setIsLoading(true);
fetch(`${BACKEND_URL}/gas-station/markers/${dbId}`, { headers })
.then((r) => {
if (!r.ok) throw new Error("Gagal memuat data dari server");
return r.json();
})
.then((row) => {
setCategory(row.marker_category);
setName(row.name);
if (row.marker_category === "charging-station") {
setEvType(row.sub_type ?? "DC Fast");
setEvStatus(row.status ?? "Active");
} else if (row.marker_category === "gas-pump") {
setGasBrand(row.brand ?? "Pertamina");
setGasHours(row.operating_hours ?? "24 Jam");
setSelectedGasTypes(row.gas_types ?? GAS_TYPES_BY_BRAND[row.brand ?? "Pertamina"] ?? []);
} else if (row.marker_category === "wrench") {
setRepairSpeciality(row.sub_type ?? "Umum");
setRepairStatus(row.status ?? "Buka 08:00 - 17:00");
}
})
.catch((err) => {
console.error(err);
setError("Gagal memuat data terbaru, menggunakan data lokal.");
// Fallback
setCategory(marker.type);
setName(marker.name);
const meta = marker.meta;
if (marker.type === "charging-station") {
setEvType(meta?.poi_type ?? "DC Fast");
setEvStatus(meta?.notes?.replace("Status: ", "") ?? "Active");
} else if (marker.type === "gas-pump") {
setGasBrand(meta?.poi_type ?? "Pertamina");
setGasHours(meta?.notes?.replace("Jam: ", "") ?? "24 Jam");
setSelectedGasTypes(meta?.gas_types ?? GAS_TYPES_BY_BRAND[meta?.poi_type ?? "Pertamina"] ?? []);
} else if (marker.type === "wrench") {
setRepairSpeciality(meta?.poi_type ?? "Umum");
setRepairStatus(meta?.notes ?? "Buka 08:00 - 17:00");
}
})
.finally(() => setIsLoading(false));
}, [open, marker]);
async function handleDelete() {
const target = pendingDeleteMarker.current;
if (!target) return;
const dbId = target.id.split("-").slice(1).join("-");
const token = localStorage.getItem("auth_token");
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
setIsDeleting(true);
setError(null);
try {
const res = await fetch(`${BACKEND_URL}/gas-station/markers/${dbId}`, {
method: "DELETE",
headers,
});
if (!res.ok && res.status !== 204) {
throw new Error("Gagal menghapus lokasi dari server");
}
pendingDeleteMarker.current = null;
onDeleted(target);
onOpenChange(false);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Terjadi kesalahan saat menghapus");
toast.error(err instanceof Error ? err.message : "Gagal menghapus lokasi");
} finally {
setIsDeleting(false);
}
}
// Countdown tick
useEffect(() => {
if (!deleteActive) return;
const id = setInterval(() => setDeleteCountdown((p) => Math.max(0, p - 1)), 1000);
return () => clearInterval(id);
}, [deleteActive]);
// Fire delete when countdown hits 0
useEffect(() => {
if (!deleteActive || deleteCountdown > 0) return;
setDeleteActive(false);
setDeleteCountdown(DELETE_SECONDS);
toast.dismiss(TOAST_ID);
handleDelete();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deleteActive, deleteCountdown]);
// Toast showing countdown
useEffect(() => {
if (!deleteActive || !pendingDeleteMarker.current) {
toast.dismiss(TOAST_ID);
return;
}
const mName = pendingDeleteMarker.current.name;
const pct = (deleteCountdown / DELETE_SECONDS) * 100;
toast.custom(
() => (
<div className="relative flex items-center gap-3 w-72 rounded-xl border border-border bg-popover px-4 py-3 shadow-lg overflow-hidden font-sans">
<div
className="absolute bottom-0 left-0 h-[3px] bg-red-500 transition-[width] duration-1000 ease-linear"
style={{ width: `${pct}%` }}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-foreground truncate">Menghapus lokasi</p>
<p className="text-xs text-muted-foreground truncate">&quot;{mName}&quot; dalam {deleteCountdown} detik</p>
</div>
<button
onClick={() => {
setDeleteActive(false);
setDeleteCountdown(DELETE_SECONDS);
pendingDeleteMarker.current = null;
toast.dismiss(TOAST_ID);
}}
className="shrink-0 h-7 rounded-full border border-border bg-background px-3 text-xs font-medium hover:bg-muted transition-colors cursor-pointer"
>
Batalkan
</button>
</div>
),
{ id: TOAST_ID, duration: Infinity, position: "bottom-left" },
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deleteActive, deleteCountdown]);
function toggleDelete() {
setDeleteActive((prev) => {
if (!prev) {
pendingDeleteMarker.current = marker;
setDeleteCountdown(DELETE_SECONDS);
} else {
pendingDeleteMarker.current = null;
toast.dismiss(TOAST_ID);
}
return !prev;
});
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!marker) return;
setIsSubmitting(true);
setError(null);
let finalPoiType = "";
let finalNotes = "";
const brand = category === "gas-pump" ? gasBrand : null;
const sub_type = category === "charging-station" ? evType : (category === "wrench" ? repairSpeciality : null);
const gas_types = category === "gas-pump" ? selectedGasTypes : null;
const operating_hours = category === "gas-pump" ? gasHours : null;
const status = category === "charging-station" ? evStatus : (category === "wrench" ? repairStatus : null);
if (category === "charging-station") {
finalPoiType = evType;
finalNotes = `Status: ${evStatus}`;
} else if (category === "gas-pump") {
finalPoiType = gasBrand;
finalNotes = `Jam: ${gasHours}`;
} else if (category === "wrench") {
finalPoiType = repairSpeciality;
finalNotes = repairStatus;
}
try {
const dbId = marker.id.split("-").slice(1).join("-");
const token = localStorage.getItem("auth_token");
const headers = {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
const res = await fetch(`${BACKEND_URL}/gas-station/markers/${dbId}`, {
method: "PUT",
headers,
body: JSON.stringify({
name: name.trim() || marker.name,
marker_category: category,
brand,
sub_type,
gas_types,
operating_hours,
status,
notes: null,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? "Gagal menyimpan perubahan");
}
const saved = await res.json();
const updated: UserMarker = {
...marker,
type: category,
name: saved.name,
meta: {
...marker.meta,
poi_type: finalPoiType,
notes: finalNotes,
gas_types: category === "gas-pump" ? selectedGasTypes : undefined,
},
};
onUpdated(updated);
onOpenChange(false);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Terjadi kesalahan");
} finally {
setIsSubmitting(false);
}
}
const categoryLabels: Record<string, string> = {
"charging-station": "Edit EV Charger",
"gas-pump": "Edit Gas Station",
wrench: "Edit Bengkel",
};
const titleLabel = categoryLabels[category] || "Edit Lokasi";
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onOpenChange(false); }}>
<DialogContent showCloseButton={false} className="sm:max-w-lg w-full max-h-[90vh] overflow-y-auto rounded-2xl p-0 gap-0">
<form onSubmit={handleSubmit} className="flex flex-col">
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<DialogTitle className="text-center text-base font-semibold">{titleLabel}</DialogTitle>
<p className="text-center text-xs text-muted-foreground font-mono tabular-nums mt-1">
{marker ? `${marker.lat.toFixed(5)}, ${marker.lng.toFixed(5)}` : ""}
</p>
</DialogHeader>
{/* Body */}
<div className="px-6 py-5 flex flex-col gap-5">
{isLoading ? (
<p className="text-sm text-muted-foreground text-center py-4">Memuat data</p>
) : (
<>
{/* Category Selector */}
<Field label="Kategori Lokasi">
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full h-10 rounded-lg border border-border bg-muted/50 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-ring cursor-pointer"
>
<option value="charging-station">EV Charger</option>
<option value="gas-pump">Gas Station</option>
<option value="wrench">Repair Shop (Bengkel)</option>
</select>
</Field>
{/* Location Name */}
<Field label="Nama Lokasi">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
required
className="bg-muted/50 border border-border focus-visible:border-ring"
/>
</Field>
{/* Dynamic Fields: EV Charger */}
{category === "charging-station" && (
<>
<Field label="Tipe Charging">
<ChipSelector options={EV_TYPES} value={evType} onChange={setEvType} />
</Field>
<Field label="Status Operasional">
<ChipSelector options={EV_STATUSES} value={evStatus} onChange={setEvStatus} />
</Field>
</>
)}
{/* Dynamic Fields: Gas Station */}
{category === "gas-pump" && (
<>
<Field label="Brand SPBU">
<ChipSelector
options={GAS_BRANDS}
value={gasBrand}
onChange={(brand) => {
setGasBrand(brand);
setSelectedGasTypes(GAS_TYPES_BY_BRAND[brand] ?? []);
}}
/>
</Field>
<Field label="Tipe Bahan Bakar">
<MultiChipSelector
options={GAS_TYPES_BY_BRAND[gasBrand] ?? []}
values={selectedGasTypes}
onChange={setSelectedGasTypes}
color={GAS_BRANDS.find((b) => b.value === gasBrand)?.color ?? "#ff3b1f"}
/>
</Field>
<Field label="Jam Operasional">
<Input
value={gasHours}
onChange={(e) => setGasHours(e.target.value)}
required
className="bg-muted/50 border border-border focus-visible:border-ring"
/>
<div className="flex gap-1.5 mt-1">
{["24 Jam", "06:00 - 22:00", "07:00 - 21:00"].map((h) => (
<button
key={h}
type="button"
onClick={() => setGasHours(h)}
className="text-[10px] bg-muted hover:bg-muted-foreground/20 text-muted-foreground px-2 py-0.5 rounded transition-colors cursor-pointer"
>
{h}
</button>
))}
</div>
</Field>
</>
)}
{/* Dynamic Fields: Repair Shop */}
{category === "wrench" && (
<>
<Field label="Spesialisasi">
<ChipSelector options={REPAIR_SPECIALITIES} value={repairSpeciality} onChange={setRepairSpeciality} />
</Field>
<Field label="Status / Jam Buka">
<Input
value={repairStatus}
onChange={(e) => setRepairStatus(e.target.value)}
required
className="bg-muted/50 border border-border focus-visible:border-ring"
/>
<div className="flex gap-1.5 mt-1">
{["Buka 24 Jam", "Buka 08:00 - 17:00", "Tutup"].map((s) => (
<button
key={s}
type="button"
onClick={() => setRepairStatus(s)}
className="text-[10px] bg-muted hover:bg-muted-foreground/20 text-muted-foreground px-2 py-0.5 rounded transition-colors cursor-pointer"
>
{s}
</button>
))}
</div>
</Field>
</>
)}
</>
)}
{error && (
<p className="text-xs text-destructive mt-2">{error}</p>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between gap-2 px-6 py-4 border-t">
<TimedUndoAction
compact
deleteLabel="Hapus Lokasi"
undoLabel="Batal"
isDeleting={deleteActive}
countDown={deleteCountdown}
onToggle={toggleDelete}
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => onOpenChange(false)}
disabled={isSubmitting || isDeleting}
className="h-9 rounded-full border border-border px-5 text-sm font-medium transition-colors hover:bg-muted disabled:opacity-50 cursor-pointer"
>
Batal
</button>
<button
type="submit"
disabled={isSubmitting || isLoading || isDeleting || !name.trim()}
className="h-9 rounded-full bg-primary text-primary-foreground px-5 text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-40 cursor-pointer"
>
{isSubmitting ? "Menyimpan…" : "Simpan"}
</button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,449 @@
"use client";
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/set-state-in-effect */
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { UserMarker } from "@/components/leaflet-map";
/* ─── Selectors Options ────────────────────────── */
const EV_TYPES = [
{ value: "DC Fast", label: "DC Fast", color: "#10b981" },
{ value: "Supercharger", label: "Supercharger", color: "#14b8a6" },
{ value: "AC Level 2", label: "AC Level 2", color: "#3b82f6" },
];
const EV_STATUSES = [
{ value: "Active", label: "Active", color: "#10b981" },
{ value: "Maintenance", label: "Maintenance", color: "#eab308" },
{ value: "Offline", label: "Offline", color: "#ef4444" },
];
const GAS_BRANDS = [
{ value: "Pertamina", label: "Pertamina", color: "#ef4444" },
{ value: "Shell", label: "Shell", color: "#eab308" },
{ value: "BP", label: "BP", color: "#10b981" },
{ value: "Vivo", label: "Vivo", color: "#3b82f6" },
];
const GAS_TYPES_BY_BRAND: Record<string, string[]> = {
Pertamina: ["Pertalite", "Pertamax", "Pertamax Green", "Pertamax Turbo", "Dexlite", "Pertamina Dex", "Solar"],
Shell: ["Shell Super", "Shell V-Power", "Shell V-Power Nitro+", "Shell V-Power Diesel"],
BP: ["BP 92", "BP 95", "BP Ultimate", "BP Ultimate Diesel"],
Vivo: ["Revvo 90", "Revvo 92", "Revvo 95"],
};
const REPAIR_SPECIALITIES = [
{ value: "Umum", label: "Umum", color: "#6b7280" },
{ value: "AC & Kelistrikan",label: "AC & Kelistrikan",color: "#0ea5e9" },
{ value: "Mesin", label: "Mesin", color: "#f97316" },
{ value: "Ban & Velg", label: "Ban & Velg", color: "#f59e0b" },
];
/* ─── Helpers ───────────────────────────────────── */
function Field({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
return (
<div className={cn("flex flex-col gap-1.5", className)}>
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
{label}
</span>
{children}
</div>
);
}
function ChipSelector({
options,
value,
onChange,
}: {
options: { value: string; label: string; color: string }[];
value: string;
onChange: (v: string) => void;
}) {
return (
<div className="flex flex-wrap gap-1.5">
{options.map((opt) => {
const isSelected = value === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
className={cn(
"h-8 rounded-lg border px-3 text-[11px] font-semibold transition-all cursor-pointer",
isSelected
? "border-transparent text-white shadow-sm animate-in fade-in zoom-in-95 duration-150"
: "border-border text-foreground/70 hover:bg-muted"
)}
style={isSelected ? { backgroundColor: opt.color } : undefined}
>
{opt.label}
</button>
);
})}
</div>
);
}
function MultiChipSelector({
options,
values,
onChange,
color,
}: {
options: string[];
values: string[];
onChange: (v: string[]) => void;
color: string;
}) {
return (
<div className="flex flex-wrap gap-1.5">
{options.map((opt) => {
const isSelected = values.includes(opt);
return (
<button
key={opt}
type="button"
onClick={() => {
if (isSelected) {
onChange(values.filter((v) => v !== opt));
} else {
onChange([...values, opt]);
}
}}
className={cn(
"h-8 rounded-lg border px-3 text-[11px] font-semibold transition-all cursor-pointer",
isSelected
? "border-transparent text-white shadow-sm"
: "border-border text-foreground/70 hover:bg-muted"
)}
style={isSelected ? { backgroundColor: color } : undefined}
>
{opt}
</button>
);
})}
</div>
);
}
/* ─── Placement Dialog ─────────────────────────── */
interface StationPlacementDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
pendingClick: { lat: number; lng: number } | null;
markerType: string | null;
onConfirm: (marker: UserMarker) => void;
}
export function StationPlacementDialog({
open,
onOpenChange,
pendingClick,
markerType,
onConfirm,
}: StationPlacementDialogProps) {
const [category, setCategory] = useState("gas-pump");
const [name, setName] = useState("");
// Form fields for EV Charger
const [evType, setEvType] = useState("DC Fast");
const [evStatus, setEvStatus] = useState("Active");
// Form fields for Gas Station
const [gasBrand, setGasBrand] = useState("Pertamina");
const [gasHours, setGasHours] = useState("24 Jam");
const [selectedGasTypes, setSelectedGasTypes] = useState<string[]>([]);
// Form fields for Repair Shop
const [repairSpeciality, setRepairSpeciality] = useState("Umum");
const [repairStatus, setRepairStatus] = useState("Buka 08:00 - 17:00");
// Submission state
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000";
// Synchronize with initial markerType when dialog opens
useEffect(() => {
if (open && markerType) {
setCategory(markerType);
setName("");
setEvType("DC Fast");
setEvStatus("Active");
setGasBrand("Pertamina");
setGasHours("24 Jam");
setSelectedGasTypes(GAS_TYPES_BY_BRAND["Pertamina"]);
setRepairSpeciality("Umum");
setRepairStatus("Buka 08:00 - 17:00");
setError(null);
setIsSubmitting(false);
}
}, [open, markerType]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!pendingClick) return;
setIsSubmitting(true);
setError(null);
let finalPoiType = "";
let finalNotes = "";
const brand = category === "gas-pump" ? gasBrand : null;
const sub_type = category === "charging-station" ? evType : (category === "wrench" ? repairSpeciality : null);
const gas_types = category === "gas-pump" ? selectedGasTypes : null;
const operating_hours = category === "gas-pump" ? gasHours : null;
const status = category === "charging-station" ? evStatus : (category === "wrench" ? repairStatus : null);
if (category === "charging-station") {
finalPoiType = evType;
finalNotes = `Status: ${evStatus}`;
} else if (category === "gas-pump") {
finalPoiType = gasBrand;
finalNotes = `Jam: ${gasHours}`;
} else if (category === "wrench") {
finalPoiType = repairSpeciality;
finalNotes = repairStatus;
}
try {
const token = localStorage.getItem("auth_token");
const res = await fetch(`${BACKEND_URL}/gas-station/markers`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
name: name.trim() || `New ${category.replace("-", " ")}`,
marker_category: category,
brand,
sub_type,
gas_types,
operating_hours,
status,
latitude: pendingClick.lat,
longitude: pendingClick.lng,
notes: null,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? "Gagal menyimpan data");
}
const saved = await res.json();
const savedMarker: UserMarker = {
id: `gsm-${saved.id}`,
lat: pendingClick.lat,
lng: pendingClick.lng,
type: category,
name: saved.name,
meta: {
poi_type: finalPoiType,
notes: finalNotes,
gas_types: category === "gas-pump" ? selectedGasTypes : undefined,
},
};
onConfirm(savedMarker);
handleCancel();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Terjadi kesalahan");
} finally {
setIsSubmitting(false);
}
}
function handleCancel() {
setName("");
setError(null);
setIsSubmitting(false);
onOpenChange(false);
}
const categoryLabels: Record<string, string> = {
"charging-station": "EV Charger Baru",
"gas-pump": "Gas Station Baru",
wrench: "Bengkel Baru",
};
const titleLabel = categoryLabels[category] || "Lokasi Baru";
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) handleCancel(); }}>
<DialogContent showCloseButton={false} className="sm:max-w-lg w-full max-h-[90vh] overflow-y-auto rounded-2xl p-0 gap-0">
<form onSubmit={handleSubmit} className="flex flex-col">
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<DialogTitle className="text-center text-base font-semibold">{titleLabel}</DialogTitle>
<p className="text-center text-xs text-muted-foreground font-mono tabular-nums mt-1">
{pendingClick ? `${pendingClick.lat.toFixed(5)}, ${pendingClick.lng.toFixed(5)}` : ""}
</p>
</DialogHeader>
{/* Body */}
<div className="px-6 py-5 flex flex-col gap-5">
{/* Category Selector */}
<Field label="Kategori Lokasi">
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full h-10 rounded-lg border border-border bg-muted/50 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-ring cursor-pointer"
>
<option value="charging-station">EV Charger</option>
<option value="gas-pump">Gas Station</option>
<option value="wrench">Repair Shop (Bengkel)</option>
</select>
</Field>
{/* Location Name */}
<Field label="Nama Lokasi">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={
category === "charging-station"
? "Contoh: VoltCharge EV Station"
: category === "gas-pump"
? "Contoh: EcoFuel Station Menteng"
: "Contoh: Bengkel Motor Jaya"
}
required
autoFocus
className="bg-muted/50 border border-border focus-visible:border-ring"
/>
</Field>
{/* Dynamic Fields: EV Charger */}
{category === "charging-station" && (
<>
<Field label="Tipe Charging">
<ChipSelector options={EV_TYPES} value={evType} onChange={setEvType} />
</Field>
<Field label="Status Operasional">
<ChipSelector options={EV_STATUSES} value={evStatus} onChange={setEvStatus} />
</Field>
</>
)}
{/* Dynamic Fields: Gas Station */}
{category === "gas-pump" && (
<>
<Field label="Brand SPBU">
<ChipSelector
options={GAS_BRANDS}
value={gasBrand}
onChange={(brand) => {
setGasBrand(brand);
setSelectedGasTypes(GAS_TYPES_BY_BRAND[brand] ?? []);
}}
/>
</Field>
<Field label="Tipe Bahan Bakar">
<MultiChipSelector
options={GAS_TYPES_BY_BRAND[gasBrand] ?? []}
values={selectedGasTypes}
onChange={setSelectedGasTypes}
color={GAS_BRANDS.find((b) => b.value === gasBrand)?.color ?? "#ff3b1f"}
/>
</Field>
<Field label="Jam Operasional">
<Input
value={gasHours}
onChange={(e) => setGasHours(e.target.value)}
placeholder="Contoh: 24 Jam atau 06:00 - 22:00"
required
className="bg-muted/50 border border-border focus-visible:border-ring"
/>
<div className="flex gap-1.5 mt-1">
{["24 Jam", "06:00 - 22:00", "07:00 - 21:00"].map((h) => (
<button
key={h}
type="button"
onClick={() => setGasHours(h)}
className="text-[10px] bg-muted hover:bg-muted-foreground/20 text-muted-foreground px-2 py-0.5 rounded transition-colors cursor-pointer"
>
{h}
</button>
))}
</div>
</Field>
</>
)}
{/* Dynamic Fields: Repair Shop */}
{category === "wrench" && (
<>
<Field label="Spesialisasi">
<ChipSelector options={REPAIR_SPECIALITIES} value={repairSpeciality} onChange={setRepairSpeciality} />
</Field>
<Field label="Status / Jam Buka">
<Input
value={repairStatus}
onChange={(e) => setRepairStatus(e.target.value)}
placeholder="Contoh: Buka 08:00 - 17:00 atau Tutup"
required
className="bg-muted/50 border border-border focus-visible:border-ring"
/>
<div className="flex gap-1.5 mt-1">
{["Buka 24 Jam", "Buka 08:00 - 17:00", "Tutup"].map((s) => (
<button
key={s}
type="button"
onClick={() => setRepairStatus(s)}
className="text-[10px] bg-muted hover:bg-muted-foreground/20 text-muted-foreground px-2 py-0.5 rounded transition-colors cursor-pointer"
>
{s}
</button>
))}
</div>
</Field>
</>
)}
{error && (
<p className="text-xs text-destructive mt-2">{error}</p>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-2 px-6 py-4 border-t">
<button
type="button"
onClick={handleCancel}
disabled={isSubmitting}
className="h-9 rounded-full border border-border px-5 text-sm font-medium transition-colors hover:bg-muted disabled:opacity-50 cursor-pointer"
>
Batal
</button>
<button
type="submit"
disabled={isSubmitting || !name.trim()}
className="h-9 rounded-full bg-primary text-primary-foreground px-5 text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-40 cursor-pointer"
>
{isSubmitting ? "Menyimpan…" : "Simpan & Pasang"}
</button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
export { EV_TYPES, EV_STATUSES, GAS_BRANDS, REPAIR_SPECIALITIES, GAS_TYPES_BY_BRAND };
+65
View File
@@ -0,0 +1,65 @@
"use client";
import { cn } from "@/lib/utils";
import { Logo } from "@/components/logo";
import { useScroll } from "@/hooks/use-scroll";
import { Button } from "@/components/ui/button";
import { MobileNav } from "@/components/mobile-nav";
export const navLinks = [
{
label: "Features",
href: "#",
},
{
label: "Pricing",
href: "#",
},
{
label: "About",
href: "#",
},
];
export function Header() {
const scrolled = useScroll(10);
return (
<header
className={cn(
"sticky top-0 z-50 mx-auto w-full max-w-4xl border-transparent border-b md:rounded-md md:border md:transition-all md:ease-out",
{
"border-border bg-background/95 backdrop-blur-sm supports-backdrop-filter:bg-background/50 md:top-2 md:max-w-3xl md:shadow":
scrolled,
}
)}
>
<nav
className={cn(
"flex h-14 w-full items-center justify-between px-4 md:h-12 md:transition-all md:ease-out",
{
"md:px-2": scrolled,
}
)}
>
<a
className="rounded-md p-2 hover:bg-muted dark:hover:bg-muted/50"
href="#"
>
<Logo className="h-4" />
</a>
<div className="hidden items-center gap-2 md:flex">
<div>
{navLinks.map((link) => (
<Button key={link.label} size="sm" variant="ghost" render={<a href={link.href} />} nativeButton={false}>{link.label}</Button>
))}
</div>
<Button size="sm" variant="outline">
Sign In
</Button>
<Button size="sm">Get Started</Button>
</div>
<MobileNav />
</nav>
</header>
);
}
@@ -0,0 +1,128 @@
"use client";
import type * as React from "react";
import { Bar, BarChart, XAxis } from "recharts";
import {
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import { Delta, DeltaIcon, DeltaValue } from "@/components/delta";
import { DashboardCard } from "@/components/dashboard-card";
import { formatDate } from "@/components/formater";
import type { PovertyOverview } from "@/lib/poverty-types";
interface HouseholdsTrendChartProps {
monthly: PovertyOverview["monthly"];
}
const chartConfig = {
count: {
label: "KK Terdata",
color: "var(--chart-2)",
},
} satisfies ChartConfig;
function CustomGradientBar(
props: React.SVGProps<SVGRectElement> & {
index?: number;
dataKey?: string | number;
}
) {
const {
fill,
x = 0,
y = 0,
width = 0,
height = 0,
dataKey = "count",
index = 0,
} = props;
const gid = `gradient-bar-${String(dataKey)}-${index}`;
return (
<>
<rect
fill={`url(#${gid})`}
height={height}
stroke="none"
width={width}
x={x}
y={y}
/>
<rect fill={fill} height={2} stroke="none" width={width} x={x} y={y} />
<defs>
<linearGradient id={gid} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={fill} stopOpacity={0.5} />
<stop offset="100%" stopColor={fill} stopOpacity={0} />
</linearGradient>
</defs>
</>
);
}
export function HouseholdsTrendChart({ monthly }: HouseholdsTrendChartProps) {
const chartRows = monthly.map((m) => ({ month: m.month, count: m.count }));
const first = chartRows[0]?.count ?? 0;
const last = chartRows.at(-1)?.count ?? 0;
const growthPct = first > 0 ? ((last - first) / first) * 100 : 0;
return (
<DashboardCard className="gap-0 md:col-span-2">
<CardHeader className="gap-2">
<div className="flex flex-wrap items-center gap-2">
<CardTitle>Pendataan Warga</CardTitle>
{chartRows.length > 1 && (
<Delta value={Number(growthPct.toFixed(1))} variant="badge">
<DeltaIcon variant="trend" />
<DeltaValue />
</Delta>
)}
</div>
<CardDescription>
KK baru terdata per bulan, 12 bulan terakhir.
</CardDescription>
</CardHeader>
<CardContent>
{chartRows.length === 0 ? (
<div className="flex h-60 w-full items-center justify-center text-xs text-muted-foreground md:h-80">
Belum ada data pendataan warga.
</div>
) : (
<ChartContainer
className="aspect-auto h-60 w-full md:h-80"
config={chartConfig}
>
<BarChart accessibilityLayer data={chartRows}>
<XAxis
axisLine={false}
dataKey="month"
interval={0}
tickFormatter={(value) => formatDate(`${value}-01`, "month")}
tickLine={false}
tickMargin={10}
/>
<ChartTooltip
content={<ChartTooltipContent hideLabel />}
cursor={false}
/>
<Bar
dataKey="count"
fill="var(--color-count)"
shape={<CustomGradientBar />}
/>
</BarChart>
</ChartContainer>
)}
</CardContent>
</DashboardCard>
);
}
+18
View File
@@ -0,0 +1,18 @@
export function AppleIcon({
fill = "currentColor",
...props
}: React.ComponentProps<"svg">) {
return (
<svg
fill={fill}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g>
<path d="M18.546,12.763c0.024-1.87,1.004-3.597,2.597-4.576c-1.009-1.442-2.64-2.323-4.399-2.378 c-1.851-0.194-3.645,1.107-4.588,1.107c-0.961,0-2.413-1.088-3.977-1.056C6.122,5.927,4.25,7.068,3.249,8.867 c-2.131,3.69-0.542,9.114,1.5,12.097c1.022,1.461,2.215,3.092,3.778,3.035c1.529-0.063,2.1-0.975,3.945-0.975 c1.828,0,2.364,0.975,3.958,0.938c1.64-0.027,2.674-1.467,3.66-2.942c0.734-1.041,1.299-2.191,1.673-3.408 C19.815,16.788,18.548,14.879,18.546,12.763z" />
<path d="M15.535,3.847C16.429,2.773,16.87,1.393,16.763,0c-1.366,0.144-2.629,0.797-3.535,1.829 c-0.895,1.019-1.349,2.351-1.261,3.705C13.352,5.548,14.667,4.926,15.535,3.847z" />
</g>
</svg>
);
}
+10
View File
@@ -0,0 +1,10 @@
export function GithubIcon(props: React.ComponentProps<"svg">) {
return (
<svg viewBox="0 0 438.549 438.549" {...props}>
<path
d="M409.132 114.573c-19.608-33.596-46.205-60.194-79.798-79.8-33.598-19.607-70.277-29.408-110.063-29.408-39.781 0-76.472 9.804-110.063 29.408-33.596 19.605-60.192 46.204-79.8 79.8C9.803 148.168 0 184.854 0 224.63c0 47.78 13.94 90.745 41.827 128.906 27.884 38.164 63.906 64.572 108.063 79.227 5.14.954 8.945.283 11.419-1.996 2.475-2.282 3.711-5.14 3.711-8.562 0-.571-.049-5.708-.144-15.417a2549.81 2549.81 0 01-.144-25.406l-6.567 1.136c-4.187.767-9.469 1.092-15.846 1-6.374-.089-12.991-.757-19.842-1.999-6.854-1.231-13.229-4.086-19.13-8.559-5.898-4.473-10.085-10.328-12.56-17.556l-2.855-6.57c-1.903-4.374-4.899-9.233-8.992-14.559-4.093-5.331-8.232-8.945-12.419-10.848l-1.999-1.431c-1.332-.951-2.568-2.098-3.711-3.429-1.142-1.331-1.997-2.663-2.568-3.997-.572-1.335-.098-2.43 1.427-3.289 1.525-.859 4.281-1.276 8.28-1.276l5.708.853c3.807.763 8.516 3.042 14.133 6.851 5.614 3.806 10.229 8.754 13.846 14.842 4.38 7.806 9.657 13.754 15.846 17.847 6.184 4.093 12.419 6.136 18.699 6.136 6.28 0 11.704-.476 16.274-1.423 4.565-.952 8.848-2.383 12.847-4.285 1.713-12.758 6.377-22.559 13.988-29.41-10.848-1.14-20.601-2.857-29.264-5.14-8.658-2.286-17.605-5.996-26.835-11.14-9.235-5.137-16.896-11.516-22.985-19.126-6.09-7.614-11.088-17.61-14.987-29.979-3.901-12.374-5.852-26.648-5.852-42.826 0-23.035 7.52-42.637 22.557-58.817-7.044-17.318-6.379-36.732 1.997-58.24 5.52-1.715 13.706-.428 24.554 3.853 10.85 4.283 18.794 7.952 23.84 10.994 5.046 3.041 9.089 5.618 12.135 7.708 17.705-4.947 35.976-7.421 54.818-7.421s37.117 2.474 54.823 7.421l10.849-6.849c7.419-4.57 16.18-8.758 26.262-12.565 10.088-3.805 17.802-4.853 23.134-3.138 8.562 21.509 9.325 40.922 2.279 58.24 15.036 16.18 22.559 35.787 22.559 58.817 0 16.178-1.958 30.497-5.853 42.966-3.9 12.471-8.941 22.457-15.125 29.979-6.191 7.521-13.901 13.85-23.131 18.986-9.232 5.14-18.182 8.85-26.84 11.136-8.662 2.286-18.415 4.004-29.263 5.146 9.894 8.562 14.842 22.077 14.842 40.539v60.237c0 3.422 1.19 6.279 3.572 8.562 2.379 2.279 6.136 2.95 11.276 1.995 44.163-14.653 80.185-41.062 108.068-79.226 27.88-38.161 41.825-81.126 41.825-128.906-.01-39.771-9.818-76.454-29.414-110.049z"
fill="currentColor"
/>
</svg>
);
}
+14
View File
@@ -0,0 +1,14 @@
export function GoogleIcon(props: React.ComponentProps<"svg">) {
return (
<svg
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g>
<path d="M12.479,14.265v-3.279h11.049c0.108,0.571,0.164,1.247,0.164,1.979c0,2.46-0.672,5.502-2.84,7.669C18.744,22.829,16.051,24,12.483,24C5.869,24,0.308,18.613,0.308,12S5.869,0,12.483,0c3.659,0,6.265,1.436,8.223,3.307L18.392,5.62c-1.404-1.317-3.307-2.341-5.913-2.341C7.65,3.279,3.873,7.171,3.873,12s3.777,8.721,8.606,8.721c3.132,0,4.916-1.258,6.059-2.401c0.927-0.927,1.537-2.251,1.777-4.059L12.479,14.265z" />
</g>
</svg>
);
}
@@ -0,0 +1,79 @@
/* eslint-disable react-hooks/set-state-in-effect */
import { useState, useEffect } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts";
interface LandAllocationChartProps {
pieChartData: Array<{
name: string;
value: number;
color: string;
}>;
}
export function LandAllocationChart({ pieChartData }: LandAllocationChartProps) {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
return (
<Card className="bg-background rounded-none border-none shadow-none ring-0 h-full flex flex-col">
<CardHeader>
<CardTitle className="text-base font-semibold font-sans">Land Status Breakdown</CardTitle>
<CardDescription>Acreage and share representation of registered parcels by ownership status.</CardDescription>
</CardHeader>
<CardContent className="flex-1 flex flex-col sm:flex-row items-center justify-around gap-6 py-6 min-h-[260px]">
{pieChartData.length > 0 ? (
<>
<div className="h-56 w-56 shrink-0">
{!isMounted ? (
<div className="h-full w-full" />
) : (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={pieChartData}
cx="50%"
cy="50%"
innerRadius={65}
outerRadius={85}
paddingAngle={3}
dataKey="value"
>
{pieChartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
background: "rgba(12, 10, 8, 0.95)",
border: "1px solid #4d505d",
borderRadius: "6px",
fontSize: "12px",
color: "#ffffff"
}}
/>
</PieChart>
</ResponsiveContainer>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-xs w-full max-w-sm">
{pieChartData.map((d, index) => (
<div key={index} className="flex items-center gap-2 border p-2 rounded bg-background">
<div className="h-3 w-3 rounded shrink-0" style={{ backgroundColor: d.color }} />
<div className="flex flex-col min-w-0">
<span className="font-semibold text-foreground truncate">{d.name}</span>
<span className="text-[10px] text-muted-foreground">{d.value} Hectares</span>
</div>
</div>
))}
</div>
</>
) : (
<p className="text-center text-xs text-muted-foreground py-12">Belum ada data poligon lahan.</p>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,96 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { SearchIcon } from "@/components/ui/phosphor-icons";
import { LAND_MARKER_LABELS, LAND_MARKER_COLORS } from "@/lib/map-data";
import type { UserMarker } from "@/components/leaflet-map";
import { calcLineLength, calcPolygonArea } from "@/components/land/shape-placement-dialog";
interface LandDirectoryProps {
searchQuery: string;
setSearchQuery: (query: string) => void;
filteredItems: UserMarker[];
selectedItemId: string | null;
visibilityFilters: Record<string, boolean>;
onSelectItem: (item: UserMarker) => void;
}
function itemBadge(item: UserMarker): string {
const coordinates = item.meta?.coordinates ?? [];
if (item.type === "polygon" && coordinates.length > 0) {
return `${(calcPolygonArea(coordinates) / 10000).toFixed(2)} Ha`;
}
if (item.type === "line" && coordinates.length > 0) {
const length = calcLineLength(coordinates);
return length >= 1000 ? `${(length / 1000).toFixed(2)} km` : `${length.toFixed(1)} m`;
}
if (item.type === "circle") {
const radius = item.meta?.radius ?? 0;
return `${radius.toFixed(1)} m`;
}
return LAND_MARKER_LABELS[item.type] ?? item.type;
}
export function LandDirectory({
searchQuery,
setSearchQuery,
filteredItems,
selectedItemId,
visibilityFilters,
onSelectItem,
}: LandDirectoryProps) {
return (
<Card className="bg-background rounded-none border-none shadow-none ring-0 h-full flex flex-col">
<CardHeader className="py-4">
<CardTitle className="text-base font-semibold">GIS Layer Directory</CardTitle>
<CardDescription>Select a marker or shape to review survey metadata and inspector details.</CardDescription>
</CardHeader>
<CardContent className="pb-6 flex-1">
<div className="relative mb-4">
<SearchIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by name or type..."
className="pl-9 bg-background h-10"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{filteredItems.length > 0 ? (
<div className="grid gap-2 sm:grid-cols-2">
{filteredItems.map((item) => {
const isSelected = selectedItemId === item.id;
const isVisible = visibilityFilters[item.type] !== false;
const color = LAND_MARKER_COLORS[item.type] ?? "#6b7280";
return (
<div
key={item.id}
onClick={() => onSelectItem(item)}
className={`p-3 border rounded-lg cursor-pointer flex justify-between items-center gap-2 transition-all ${
isSelected
? "border-primary bg-primary/5 text-primary font-semibold"
: "border-border/60 hover:bg-muted"
} ${!isVisible ? "opacity-45" : ""}`}
>
<span className="text-xs text-foreground font-medium truncate">{item.name}</span>
<span
className="text-[9px] border px-2 py-0.5 rounded font-bold shrink-0"
style={{
color,
borderColor: `${color}33`,
backgroundColor: `${color}11`
}}
>
{itemBadge(item)}
</span>
</div>
);
})}
</div>
) : (
<p className="text-center text-xs text-muted-foreground py-12">
Belum ada penanda atau bentuk yang ditambahkan. Buka peta untuk mulai menambahkan.
</p>
)}
</CardContent>
</Card>
);
}
+168
View File
@@ -0,0 +1,168 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { LayersIcon, EyeIcon, EyeOffIcon, ShieldAlertIcon } from "@/components/ui/phosphor-icons";
import { LAND_MARKER_LABELS, LAND_MARKER_COLORS } from "@/lib/map-data";
import type { UserMarker } from "@/components/leaflet-map";
import { calcLineLength, calcPolygonArea } from "@/components/land/shape-placement-dialog";
function formatDate(iso?: string): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("id-ID", { year: "numeric", month: "short", day: "numeric" });
}
interface LandInspectorTopProps {
selectedItem: UserMarker | null;
visibilityFilters: Record<string, boolean>;
toggleFilter: (type: string) => void;
}
export function LandInspectorTop({ selectedItem, visibilityFilters, toggleFilter }: LandInspectorTopProps) {
if (!selectedItem) {
return (
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full">
<CardHeader className="border-b bg-muted/20 px-6 py-4 flex flex-col justify-center">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<LayersIcon className="h-4 w-4 text-primary" />
Boundary Inspector
</CardTitle>
<CardDescription>Reviewing border registries.</CardDescription>
</CardHeader>
<CardContent className="p-6 flex-1 flex items-center justify-center">
<p className="text-center text-xs text-muted-foreground">
Belum ada item dipilih. Buka peta atau pilih item dari direktori.
</p>
</CardContent>
</Card>
);
}
const color = LAND_MARKER_COLORS[selectedItem.type] ?? "#6b7280";
const label = LAND_MARKER_LABELS[selectedItem.type] ?? selectedItem.type;
const coordinates = selectedItem.meta?.coordinates ?? [];
const radius = selectedItem.meta?.radius ?? 0;
const length = selectedItem.type === "line" ? calcLineLength(coordinates) : 0;
return (
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full">
<CardHeader className="border-b bg-muted/20 px-6 py-4 flex flex-col justify-center">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<LayersIcon className="h-4 w-4 text-primary" />
Boundary Inspector
</CardTitle>
<CardDescription>Reviewing border registries.</CardDescription>
</CardHeader>
<CardContent className="p-6 flex flex-col gap-5 flex-1 justify-center">
<div>
<h3 className="text-xl font-bold text-foreground">{selectedItem.name}</h3>
<span
className="inline-block mt-1.5 text-[10px] uppercase font-bold tracking-wider px-2 py-0.5 rounded border"
style={{
color,
borderColor: `${color}33`,
backgroundColor: `${color}11`
}}
>
{label}
</span>
</div>
<div className="flex flex-col gap-2.5 text-xs py-3 border-y">
{selectedItem.type === "polygon" && coordinates.length > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Luas Terdaftar:</span>
<span className="font-semibold text-foreground">{(calcPolygonArea(coordinates) / 10000).toFixed(2)} Ha</span>
</div>
)}
{selectedItem.type === "line" && coordinates.length > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Panjang:</span>
<span className="font-semibold text-foreground">
{length >= 1000 ? `${(length / 1000).toFixed(2)} km` : `${length.toFixed(1)} m`}
</span>
</div>
)}
{selectedItem.type === "circle" && (
<div className="flex justify-between">
<span className="text-muted-foreground">Radius:</span>
<span className="font-semibold text-foreground">{radius.toFixed(1)} m</span>
</div>
)}
{(selectedItem.type === "line" || selectedItem.type === "polygon") && selectedItem.meta?.poi_type && (
<div className="flex justify-between">
<span className="text-muted-foreground">Status:</span>
<span className="font-semibold text-foreground">{selectedItem.meta.poi_type}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-muted-foreground">Created By:</span>
<span className="font-semibold text-foreground text-right max-w-44 truncate">{selectedItem.meta?.created_by_username ?? "—"}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Created:</span>
<span className="font-semibold text-foreground">{formatDate(selectedItem.meta?.created_at)}</span>
</div>
</div>
<div className="flex flex-col gap-2 justify-center py-2">
<span className="text-[10px] font-bold text-muted-foreground uppercase">Visibility filter status:</span>
<div className="flex items-center gap-2 text-xs">
<Button
variant="outline"
size="sm"
onClick={() => toggleFilter(selectedItem.type)}
className="h-8 gap-1 text-xs"
>
{visibilityFilters[selectedItem.type] !== false ? (
<>
<EyeOffIcon className="h-3.5 w-3.5" />
Hide Category
</>
) : (
<>
<EyeIcon className="h-3.5 w-3.5" />
Show Category
</>
)}
</Button>
</div>
</div>
</CardContent>
</Card>
);
}
interface LandInspectorBottomProps {
recentFlags: UserMarker[];
}
export function LandInspectorBottom({ recentFlags }: LandInspectorBottomProps) {
return (
<Card className="bg-background rounded-none border-none shadow-none ring-0 h-full flex flex-col justify-between">
<CardHeader className="py-3 px-6 border-b bg-muted/10">
<CardTitle className="text-xs font-bold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
<ShieldAlertIcon className="h-3.5 w-3.5 text-primary" />
Surveyor Logs
</CardTitle>
</CardHeader>
<CardContent className="p-4 flex flex-col gap-2 flex-1">
{recentFlags.length > 0 ? (
recentFlags.map((flag) => (
<div key={flag.id} className="p-2 border rounded bg-background text-[11px] flex flex-col gap-1">
<div className="flex justify-between font-bold text-primary">
<span className="truncate">{flag.name}</span>
</div>
{flag.meta?.notes && (
<p className="text-muted-foreground">{flag.meta.notes}</p>
)}
<span className="text-[9px] text-muted-foreground/60">Reported: {formatDate(flag.meta?.created_at)}</span>
</div>
))
) : (
<div className="flex-1 flex items-center justify-center">
<p className="text-center text-xs text-muted-foreground py-6">Belum ada tengara lahan tercatat.</p>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,55 @@
import { Card } from "@/components/ui/card";
import { LayersIcon, RoadHorizonIcon, MapPinIcon } from "@/components/ui/phosphor-icons";
interface LandStatsCardsProps {
aggregateStats: {
totalParcelArea: string;
totalRoadLength: string;
activeMarkers: number;
};
}
export function LandStatsCards({ aggregateStats }: LandStatsCardsProps) {
return (
<>
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full">
<div className="px-6 pt-6 pb-4">
<div className="flex justify-between items-center pb-2">
<span className="text-xs font-medium text-muted-foreground">Total Parcel Area</span>
<LayersIcon className="h-4 w-4 text-emerald-500" />
</div>
<div className="text-3xl font-bold text-foreground mt-2">{aggregateStats.totalParcelArea} Ha</div>
</div>
<div className="border-t px-6 py-3 text-xs text-muted-foreground mt-auto">
Sum of all registered polygon parcels
</div>
</Card>
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full">
<div className="px-6 pt-6 pb-4">
<div className="flex justify-between items-center pb-2">
<span className="text-xs font-medium text-muted-foreground">Total Road Length</span>
<RoadHorizonIcon className="h-4 w-4 text-cobalt-glow" />
</div>
<div className="text-3xl font-bold text-foreground mt-2">{aggregateStats.totalRoadLength} km</div>
</div>
<div className="border-t px-6 py-3 text-xs text-muted-foreground mt-auto">
Sum of all measured road/line segments
</div>
</Card>
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full">
<div className="px-6 pt-6 pb-4">
<div className="flex justify-between items-center pb-2">
<span className="text-xs font-medium text-muted-foreground">Active Markers</span>
<MapPinIcon className="h-4 w-4 text-primary" />
</div>
<div className="text-3xl font-bold text-foreground mt-2">{aggregateStats.activeMarkers} Markers</div>
</div>
<div className="border-t px-6 py-3 text-xs text-muted-foreground mt-auto">
Land pins, survey flags, protected zones &amp; registries
</div>
</Card>
</>
);
}
@@ -0,0 +1,338 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { UserMarker } from "@/components/leaflet-map";
import { TimedUndoAction } from "@/components/time-undo-action";
import {
ROAD_STATUSES,
LAND_STATUSES,
calcLineLength,
calcPolygonArea,
} from "@/components/land/shape-placement-dialog";
import { LAND_MARKER_LABELS } from "@/lib/map-data";
/* ─── Shared Field component ─────────────────────────────── */
function Field({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
return (
<div className={cn("flex flex-col gap-1.5", className)}>
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
{label}
</span>
{children}
</div>
);
}
/* ─── Stat Chip ──────────────────────────────────────────── */
function StatChip({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2">
<span className="text-[10px] font-semibold text-foreground/50 uppercase tracking-wider">{label}</span>
<span className="text-sm font-semibold text-foreground tabular-nums ml-auto">{value}</span>
</div>
);
}
/* ─── Status Chip Selector ───────────────────────────────── */
function StatusSelector({
options,
value,
onChange,
}: {
options: { value: string; label: string; color: string }[];
value: string;
onChange: (v: string) => void;
}) {
return (
<div className="flex flex-wrap gap-1.5">
{options.map((opt) => {
const isSelected = value === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
className={cn(
"h-8 rounded-lg border px-3 text-[11px] font-semibold transition-all",
isSelected
? "border-transparent text-white shadow-sm"
: "border-border text-foreground/70 hover:bg-muted"
)}
style={isSelected ? { backgroundColor: opt.color } : undefined}
>
{opt.label}
</button>
);
})}
</div>
);
}
/* ─── Dialog Component ───────────────────────────────────── */
interface ShapeEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
shape: UserMarker | null;
onUpdated: (updated: UserMarker) => void;
onDeleted: (deleted: UserMarker) => void;
}
export function ShapeEditDialog({
open,
onOpenChange,
shape,
onUpdated,
onDeleted,
}: ShapeEditDialogProps) {
const [name, setName] = useState("");
const [status, setStatus] = useState("");
const [notes, setNotes] = useState("");
const DELETE_SECONDS = 5;
const TOAST_ID = "shape-delete";
const [deleteActive, setDeleteActive] = useState(false);
const [deleteCountdown, setDeleteCountdown] = useState(DELETE_SECONDS);
const pendingDeleteShape = useRef<UserMarker | null>(null);
const shapeType = shape?.type ?? "";
const isLine = shapeType === "line";
const isPolygon = shapeType === "polygon";
const isCircle = shapeType === "circle";
const titleLabel = `Edit ${LAND_MARKER_LABELS[shapeType] ?? "Item"}`;
const coordinates = shape?.meta?.coordinates ?? [];
const radius = shape?.meta?.radius ?? 0;
const length = isLine ? calcLineLength(coordinates) : 0;
const area = isPolygon
? calcPolygonArea(coordinates)
: isCircle
? Math.PI * radius * radius
: 0;
const statusOptions = isLine ? ROAD_STATUSES : isPolygon ? LAND_STATUSES : [];
// Reset when different shape opens
useEffect(() => {
if (!shape?.id) return;
setName(shape.name);
setStatus(shape.meta?.poi_type ?? (isLine ? "Regency Road" : isPolygon ? "SHM" : ""));
setNotes(shape.meta?.notes ?? "");
setDeleteActive(false);
setDeleteCountdown(DELETE_SECONDS);
pendingDeleteShape.current = null;
toast.dismiss(TOAST_ID);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shape?.id]);
// Countdown tick
useEffect(() => {
if (!deleteActive) return;
const id = setInterval(() => setDeleteCountdown((p) => Math.max(0, p - 1)), 1000);
return () => clearInterval(id);
}, [deleteActive]);
// Fire delete when countdown hits 0
useEffect(() => {
if (!deleteActive || deleteCountdown > 0) return;
setDeleteActive(false);
setDeleteCountdown(DELETE_SECONDS);
toast.dismiss(TOAST_ID);
handleDelete();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deleteActive, deleteCountdown]);
// Toast while countdown is running
useEffect(() => {
if (!deleteActive || !pendingDeleteShape.current) {
toast.dismiss(TOAST_ID);
return;
}
const shapeName = pendingDeleteShape.current.name;
const pct = (deleteCountdown / DELETE_SECONDS) * 100;
toast.custom(
() => (
<div className="relative flex items-center gap-3 w-72 rounded-xl border border-border bg-popover px-4 py-3 shadow-lg overflow-hidden font-sans">
<div
className="absolute bottom-0 left-0 h-[3px] bg-red-500 transition-[width] duration-1000 ease-linear"
style={{ width: `${pct}%` }}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-foreground truncate">Menghapus shape</p>
<p className="text-xs text-muted-foreground truncate">&quot;{shapeName}&quot; dalam {deleteCountdown} detik</p>
</div>
<button
onClick={() => {
setDeleteActive(false);
setDeleteCountdown(DELETE_SECONDS);
pendingDeleteShape.current = null;
toast.dismiss(TOAST_ID);
}}
className="shrink-0 h-7 rounded-full border border-border bg-background px-3 text-xs font-medium hover:bg-muted transition-colors cursor-pointer"
>
Batalkan
</button>
</div>
),
{ id: TOAST_ID, duration: Infinity, position: "bottom-left" },
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deleteActive, deleteCountdown]);
function toggleDelete() {
setDeleteActive((prev) => {
if (!prev) {
pendingDeleteShape.current = shape;
setDeleteCountdown(DELETE_SECONDS);
} else {
pendingDeleteShape.current = null;
toast.dismiss(TOAST_ID);
}
return !prev;
});
}
function handleDelete() {
const target = pendingDeleteShape.current;
if (!target) return;
pendingDeleteShape.current = null;
onDeleted(target);
onOpenChange(false);
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!shape) return;
const updated: UserMarker = {
...shape,
name: name.trim() || shape.name,
meta: {
...shape.meta,
poi_type: status || undefined,
notes: notes.trim() || undefined,
},
};
onUpdated(updated);
onOpenChange(false);
}
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onOpenChange(false); }}>
<DialogContent showCloseButton={false} className="sm:max-w-lg w-full max-h-[90vh] overflow-y-auto rounded-2xl p-0 gap-0">
<form onSubmit={handleSubmit} className="flex flex-col">
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<DialogTitle className="text-center text-base font-semibold">{titleLabel}</DialogTitle>
<p className="text-center text-xs text-muted-foreground font-mono tabular-nums mt-1">
{shape ? `${shape.lat.toFixed(5)}, ${shape.lng.toFixed(5)}` : ""}
</p>
</DialogHeader>
{/* Body */}
<div className="px-6 py-5 flex flex-col gap-5">
{/* Name */}
<Field label={isLine ? "Nama Jalan" : isPolygon ? "Nama Lahan" : isCircle ? "Nama Area" : "Nama Penanda"}>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
required
className="bg-muted/50 border border-border focus-visible:border-ring"
/>
</Field>
{/* Notes (optional, all types) */}
<Field label="Catatan">
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Catatan tambahan (opsional)"
rows={2}
className="w-full min-w-0 rounded-lg border border-input bg-muted/50 px-2.5 py-1.5 text-sm transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 resize-none"
/>
</Field>
{/* Status (line / polygon only) */}
{statusOptions.length > 0 && (
<Field label="Status">
<StatusSelector options={statusOptions} value={status} onChange={setStatus} />
</Field>
)}
{/* Auto-calculated metrics */}
<div className={cn("grid gap-3", isCircle ? "grid-cols-2" : "grid-cols-1")}>
{isLine && (
<StatChip
label="Panjang"
value={length >= 1000
? `${(length / 1000).toFixed(2)} km`
: `${length.toFixed(1)} m`}
/>
)}
{(isPolygon || isCircle) && (
<StatChip
label="Luas"
value={area >= 10000
? `${(area / 10000).toFixed(2)} ha`
: `${area.toFixed(1)}`}
/>
)}
{isCircle && (
<StatChip label="Radius" value={`${radius.toFixed(1)} m`} />
)}
</div>
{(isLine || isPolygon) && coordinates.length > 0 && (
<p className="text-[10px] text-muted-foreground">
{coordinates.length} titik koordinat
</p>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between gap-2 px-6 py-4 border-t">
<TimedUndoAction
compact
deleteLabel="Hapus Shape"
undoLabel="Batal"
isDeleting={deleteActive}
countDown={deleteCountdown}
onToggle={toggleDelete}
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => onOpenChange(false)}
className="h-9 rounded-full border border-border px-5 text-sm font-medium transition-colors hover:bg-muted"
>
Batal
</button>
<button
type="submit"
disabled={!name.trim()}
className="h-9 rounded-full bg-primary text-primary-foreground px-5 text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-40"
>
Simpan
</button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,306 @@
"use client";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { UserMarker } from "@/components/leaflet-map";
import { LAND_MARKER_LABELS } from "@/lib/map-data";
/* ─── Status options per shape type ──────────────────────── */
const ROAD_STATUSES = [
{ value: "National Road", label: "National", color: "#ef4444" },
{ value: "Provincial Road", label: "Provincial", color: "#eab308" },
{ value: "Regency Road", label: "Regency", color: "#22c55e" },
];
const LAND_STATUSES = [
{ value: "SHM", label: "SHM", color: "#a855f7" },
{ value: "HGB", label: "HGB", color: "#f97316" },
{ value: "HGU", label: "HGU", color: "#14b8a6" },
{ value: "HP", label: "HP", color: "#ec4899" },
];
/* ─── Geometry helpers ───────────────────────────────────── */
/** Calculate polyline length from [lat, lng][] coordinates in meters */
function calcLineLength(coords: [number, number][]): number {
let total = 0;
for (let i = 1; i < coords.length; i++) {
total += haversineDistance(coords[i - 1], coords[i]);
}
return total;
}
/** Calculate polygon area from [lat, lng][] using shoelfoot formula + spherical correction */
function calcPolygonArea(coords: [number, number][]): number {
// Shoelace formula on projected coords (approximate for small areas)
const R = 6371000; // Earth radius in meters
let area = 0;
const n = coords.length;
for (let i = 0; i < n; i++) {
const j = (i + 1) % n;
const lat1 = coords[i][0] * Math.PI / 180;
const lat2 = coords[j][0] * Math.PI / 180;
const lng1 = coords[i][1] * Math.PI / 180;
const lng2 = coords[j][1] * Math.PI / 180;
area += (lng2 - lng1) * (2 + Math.sin(lat1) + Math.sin(lat2));
}
area = Math.abs(area * R * R / 2);
return area;
}
/** Haversine distance between two [lat, lng] points in meters */
function haversineDistance(a: [number, number], b: [number, number]): number {
const R = 6371000;
const dLat = (b[0] - a[0]) * Math.PI / 180;
const dLng = (b[1] - a[1]) * Math.PI / 180;
const lat1 = a[0] * Math.PI / 180;
const lat2 = b[0] * Math.PI / 180;
const s = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) * Math.sin(dLng / 2);
return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s));
}
/* ─── Shared Field component ─────────────────────────────── */
function Field({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
return (
<div className={cn("flex flex-col gap-1.5", className)}>
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
{label}
</span>
{children}
</div>
);
}
/* ─── Stat Chip (for read-only auto-calculated values) ──── */
function StatChip({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2">
<span className="text-[10px] font-semibold text-foreground/50 uppercase tracking-wider">{label}</span>
<span className="text-sm font-semibold text-foreground tabular-nums ml-auto">{value}</span>
</div>
);
}
/* ─── Status Chip Selector ───────────────────────────────── */
function StatusSelector({
options,
value,
onChange,
}: {
options: { value: string; label: string; color: string }[];
value: string;
onChange: (v: string) => void;
}) {
return (
<div className="flex flex-wrap gap-1.5">
{options.map((opt) => {
const isSelected = value === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
className={cn(
"h-8 rounded-lg border px-3 text-[11px] font-semibold transition-all",
isSelected
? "border-transparent text-white shadow-sm"
: "border-border text-foreground/70 hover:bg-muted"
)}
style={isSelected ? { backgroundColor: opt.color } : undefined}
>
{opt.label}
</button>
);
})}
</div>
);
}
/* ─── Dialog Component ───────────────────────────────────── */
interface ShapePlacementDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
pendingShape: UserMarker | null;
onConfirm: (shape: UserMarker) => void;
}
export function ShapePlacementDialog({
open,
onOpenChange,
pendingShape,
onConfirm,
}: ShapePlacementDialogProps) {
const [name, setName] = useState("");
const [status, setStatus] = useState("");
const [notes, setNotes] = useState("");
const shapeType = pendingShape?.type ?? "";
const isLine = shapeType === "line";
const isPolygon = shapeType === "polygon";
const isCircle = shapeType === "circle";
const titleLabel = isLine ? "Jalan Baru" : isPolygon ? "Lahan Baru" : isCircle ? "Area Baru" : (LAND_MARKER_LABELS[shapeType] ?? "Item Baru");
// Auto-calculated metrics
const coordinates = pendingShape?.meta?.coordinates ?? [];
const radius = pendingShape?.meta?.radius ?? 0;
const length = isLine ? calcLineLength(coordinates) : 0;
const area = isPolygon
? calcPolygonArea(coordinates)
: isCircle
? Math.PI * radius * radius
: 0;
const statusOptions = isLine ? ROAD_STATUSES : isPolygon ? LAND_STATUSES : [];
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!pendingShape) return;
const finalName = name.trim() || pendingShape.name;
const updated: UserMarker = {
...pendingShape,
name: finalName,
meta: {
...pendingShape.meta,
poi_type: status || undefined,
notes: notes.trim() || undefined,
},
};
onConfirm(updated);
// Reset
setName("");
setStatus("");
setNotes("");
}
function handleCancel() {
setName("");
setStatus("");
setNotes("");
onOpenChange(false);
}
// Reset form when dialog opens with a new shape
useEffect(() => {
if (!pendingShape?.id) return;
setName("");
const t = pendingShape.type;
setStatus(t === "line" ? "Regency Road" : t === "polygon" ? "SHM" : "");
setNotes(pendingShape.meta?.notes ?? "");
}, [pendingShape?.id]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) handleCancel(); }}>
<DialogContent showCloseButton={false} className="sm:max-w-lg w-full max-h-[90vh] overflow-y-auto rounded-2xl p-0 gap-0">
<form onSubmit={handleSubmit} className="flex flex-col">
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<DialogTitle className="text-center text-base font-semibold">{titleLabel}</DialogTitle>
<p className="text-center text-xs text-muted-foreground font-mono tabular-nums mt-1">
{pendingShape ? `${pendingShape.lat.toFixed(5)}, ${pendingShape.lng.toFixed(5)}` : ""}
</p>
</DialogHeader>
{/* Body */}
<div className="px-6 py-5 flex flex-col gap-5">
{/* Name */}
<Field label={isLine ? "Nama Jalan" : isPolygon ? "Nama Lahan" : isCircle ? "Nama Area" : "Nama Penanda"}>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={isLine ? "Contoh: Jl. Merdeka" : isPolygon ? "Contoh: Lahan Sawah A1" : isCircle ? "Contoh: Buffer Zone A" : "Contoh: Patok Batas Utara"}
required
className="bg-muted/50 border border-border focus-visible:border-ring"
/>
</Field>
{/* Notes (optional, all types) */}
<Field label="Catatan">
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Catatan tambahan (opsional)"
rows={2}
className="w-full min-w-0 rounded-lg border border-input bg-muted/50 px-2.5 py-1.5 text-sm transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 resize-none"
/>
</Field>
{/* Status selector (line / polygon only) */}
{statusOptions.length > 0 && (
<Field label="Status">
<StatusSelector options={statusOptions} value={status} onChange={setStatus} />
</Field>
)}
{/* Auto-calculated metrics */}
<div className={cn("grid gap-3", isCircle ? "grid-cols-2" : "grid-cols-1")}>
{isLine && (
<StatChip
label="Panjang"
value={length >= 1000
? `${(length / 1000).toFixed(2)} km`
: `${length.toFixed(1)} m`}
/>
)}
{(isPolygon || isCircle) && (
<StatChip
label="Luas"
value={area >= 10000
? `${(area / 10000).toFixed(2)} ha`
: `${area.toFixed(1)}`}
/>
)}
{isCircle && (
<StatChip label="Radius" value={`${radius.toFixed(1)} m`} />
)}
</div>
{/* Coordinate count */}
{(isLine || isPolygon) && coordinates.length > 0 && (
<p className="text-[10px] text-muted-foreground">
{coordinates.length} titik koordinat
</p>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-2 px-6 py-4 border-t">
<button
type="button"
onClick={handleCancel}
className="h-9 rounded-full border border-border px-5 text-sm font-medium transition-colors hover:bg-muted"
>
Batal
</button>
<button
type="submit"
disabled={!name.trim()}
className="h-9 rounded-full bg-primary text-primary-foreground px-5 text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-40"
>
Simpan &amp; Pasang
</button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
// Re-export helpers for use in edit dialog and leaflet-map
export { ROAD_STATUSES, LAND_STATUSES, calcLineLength, calcPolygonArea };
@@ -0,0 +1,111 @@
import Link from "next/link";
import { ArrowRight } from "lucide-react";
export function LandingAbout() {
return (
<section id="about" className="bg-white pt-16 sm:pt-20 lg:pt-32 pb-12 sm:pb-16 lg:pb-24 overflow-hidden w-full">
<div className="w-full max-w-[1440px] mx-auto">
{/* Badge Row */}
<div className="px-5 sm:px-8 lg:px-12 flex items-center gap-3 mb-6 sm:mb-8">
<span className="w-6 h-6 sm:w-7 sm:h-7 rounded-full bg-gray-900 text-white text-[11px] sm:text-[12px] font-semibold flex items-center justify-center">
1
</span>
<span className="text-[12px] sm:text-[13px] font-medium text-gray-900 border border-gray-200 rounded-full px-3 sm:px-4 py-1 sm:py-1.5">
Introducing Waras GIS
</span>
</div>
{/* Heading */}
<div className="px-5 sm:px-8 lg:px-12">
<h2 className="font-medium leading-[1.12] tracking-[-0.02em] text-gray-900 mb-12 sm:mb-16 lg:mb-28 text-[clamp(1.5rem,4vw,3.2rem)] max-w-[900px]">
Kecerdasan spasial terpadu, menyajikan <br className="hidden sm:block" />
visualisasi data yang akurat & interaktif.
</h2>
</div>
{/* Content Area - Mobile & Tablet Stacked */}
<div className="lg:hidden px-5 sm:px-8 flex flex-col gap-8">
<div className="flex flex-col items-start gap-4">
<p className="text-[15px] sm:text-[17px] leading-[1.6] font-medium text-gray-900 max-w-[600px]">
Melalui integrasi data kemiskinan daerah, tata guna lahan, dan jaringan fasilitas pengisian energi, kami membantu mewujudkan tata ruang wilayah yang transparan dan berbasis data.
</p>
{/* About Button with Text Roll and Arrow - routes to Login */}
<Link href="/auth/login" className="group flex items-center gap-3 bg-primary hover:bg-primary/80 text-primary-foreground text-[13px] font-medium rounded-full pl-5 pr-2 py-2 transition-colors duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] cursor-pointer mt-2">
<span className="overflow-hidden h-[20px] relative inline-block">
<span className="flex flex-col transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:-translate-y-1/2">
<span className="h-[20px] flex items-center">Pelajari Selengkapnya</span>
<span className="h-[20px] flex items-center">Pelajari Selengkapnya</span>
</span>
</span>
<span className="w-7 h-7 rounded-full bg-white flex items-center justify-center text-primary transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:rotate-[-45deg] shrink-0">
<ArrowRight className="w-3.5 h-3.5" />
</span>
</Link>
</div>
{/* Images */}
<div className="flex flex-col sm:flex-row gap-4 sm:gap-5 w-full">
<img
src="https://images.higgs.ai/?default=1&output=webp&url=https%3A%2F%2Fd8j0ntlcm91z4.cloudfront.net%2Fuser_38xzZboKViGWJOttwIXH07lWA1P%2Fhf_20260516_090123_74be96d4-9c1b-40cf-932a-96f4f4babed3.png&w=1280&q=85"
alt="Analisis spasial tim perencanaan"
className="w-full sm:w-[45%] aspect-[438/346] rounded-xl sm:rounded-2xl object-cover"
/>
<img
src="https://images.higgs.ai/?default=1&output=webp&url=https%3A%2F%2Fd8j0ntlcm91z4.cloudfront.net%2Fuser_38xzZboKViGWJOttwIXH07lWA1P%2Fhf_20260516_090133_c157d30b-a99a-4477-bec1-a446149ec3f2.png&w=1280&q=85"
alt="Stasiun kerja visualisasi GIS"
className="w-full sm:w-[55%] aspect-[900/600] rounded-xl sm:rounded-2xl object-cover"
/>
</div>
</div>
{/* Content Area - Desktop Grid */}
<div className="hidden lg:grid grid-cols-[26%_1fr_48%] items-end gap-6 xl:gap-8 px-8 lg:px-12">
{/* Left Column Image */}
<div className="w-full">
<img
src="https://images.higgs.ai/?default=1&output=webp&url=https%3A%2F%2Fd8j0ntlcm91z4.cloudfront.net%2Fuser_38xzZboKViGWJOttwIXH07lWA1P%2Fhf_20260516_090123_74be96d4-9c1b-40cf-932a-96f4f4babed3.png&w=1280&q=85"
alt="Analisis spasial tim perencanaan"
className="w-full aspect-[438/346] rounded-2xl object-cover shadow-[0_4px_20px_rgba(0,0,0,0.02)]"
/>
</div>
{/* Center Column Copy & CTA */}
<div className="flex flex-col items-start justify-between self-start h-full pl-6 pr-4">
<p className="text-[16px] xl:text-[18px] leading-[1.65] font-medium text-gray-900 whitespace-nowrap">
Melalui integrasi data kemiskinan daerah,<br />
tata guna lahan, dan jaringan SPBU/EV,<br />
kami membantu pengambilan keputusan.
</p>
{/* About Button - routes to Login */}
<Link href="/auth/login" className="group flex items-center gap-3 bg-primary hover:bg-primary/80 text-primary-foreground text-[13px] font-medium rounded-full pl-5 pr-2 py-2 transition-colors duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] cursor-pointer mt-8">
<span className="overflow-hidden h-[20px] relative inline-block">
<span className="flex flex-col transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:-translate-y-1/2">
<span className="h-[20px] flex items-center">Pelajari Selengkapnya</span>
<span className="h-[20px] flex items-center">Pelajari Selengkapnya</span>
</span>
</span>
<span className="w-7 h-7 rounded-full bg-white flex items-center justify-center text-primary transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:rotate-[-45deg] shrink-0">
<ArrowRight className="w-3.5 h-3.5" />
</span>
</Link>
</div>
{/* Right Column Image */}
<div className="w-full">
<img
src="https://images.higgs.ai/?default=1&output=webp&url=https%3A%2F%2Fd8j0ntlcm91z4.cloudfront.net%2Fuser_38xzZboKViGWJOttwIXH07lWA1P%2Fhf_20260516_090133_c157d30b-a99a-4477-bec1-a446149ec3f2.png&w=1280&q=85"
alt="Stasiun kerja visualisasi GIS"
className="w-full aspect-[3/2] rounded-2xl object-cover shadow-[0_4px_24px_rgba(0,0,0,0.03)]"
/>
</div>
</div>
</div>
</section>
);
}
@@ -0,0 +1,96 @@
import Link from "next/link";
import { ArrowRight } from "lucide-react";
export function LandingCta() {
return (
<section className="bg-white py-24 sm:py-32 relative overflow-hidden w-full border-t border-gray-100">
<div className="w-full max-w-[1440px] mx-auto px-5 sm:px-8 lg:px-12 relative flex flex-col items-center justify-center text-center">
{/* Left Side ASCII Art - Enlarged Variant */}
<div className="absolute left-2 sm:left-6 lg:left-10 top-1/2 -translate-y-1/2 hidden md:block pointer-events-none z-10 select-none opacity-25">
<pre className="text-[10px] sm:text-[13px] md:text-[14px] leading-[1.15] font-mono text-gray-400 text-left">
{` .
...
..\\\\/ .
..AAA.AA
......AA .
..A .AAAAA .
..A .AAAA * . ..A . ..hA\\/h .
. . . .. * . .. V . Vh .
. . . . . . .. h A - V .
A . . ..A A . .V . V4 .
4V44A.*.VVV A444V4---AA
..A4\\\\----.V . .AAAA444444A4V444A...
A.--444444444V4444A..* .AVA
..V--AAA. V4....... ..A .
... A44V4 .. ...
.. . VVV .
.
`}
</pre>
</div>
{/* Right Side ASCII Art - Enlarged Variant */}
<div className="absolute right-2 sm:right-6 lg:right-10 top-1/2 -translate-y-1/2 hidden md:block pointer-events-none z-10 select-none opacity-25">
<pre className="text-[10px] sm:text-[13px] md:text-[14px] leading-[1.15] font-mono text-gray-400 text-left">
{` A
-.
..A .
A444A .
..A . 4 .
.... AA . .
..A .AAAA . ..A . . . .
.....AAAA . . ..AA . .A .AVA .
. . A . ..A . .. .V4 . .4V44A .
. A . ..A4V . A44444444V4 .
4V4V4.--AA
..AAAA444444A4V444A...
A.AVAAAAA4A4A....
A4 ..V.AVA
..V4V4A...
..A...
..A .
.. .
.
`}
</pre>
</div>
{/* Center Heading */}
<h2 className="font-medium leading-[1.12] tracking-[-0.03em] text-gray-900 text-[clamp(1.75rem,5vw,3.5rem)] max-w-[850px] mb-10 sm:mb-12 relative z-20">
Kelola data pemetaan daerah Anda <br className="hidden sm:block" /> tanpa hambatan.
</h2>
{/* CTA Buttons */}
<div className="flex flex-col sm:flex-row items-center gap-4 sm:gap-5 relative z-20">
{/* Orange Button */}
<Link href="/auth/login" className="group flex items-center gap-3 bg-primary hover:bg-primary/80 text-primary-foreground text-[13px] sm:text-[14px] font-medium rounded-full pl-5 sm:pl-6 pr-2 py-2 transition-colors duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] cursor-pointer">
<span className="overflow-hidden h-[20px] relative inline-block">
<span className="flex flex-col transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:-translate-y-1/2">
<span className="h-[20px] flex items-center">Buka Dashboard</span>
<span className="h-[20px] flex items-center">Buka Dashboard</span>
</span>
</span>
<span className="w-7 h-7 sm:w-8 sm:h-8 rounded-full bg-white flex items-center justify-center text-primary transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:rotate-[-45deg] shrink-0">
<ArrowRight className="w-4 h-4" />
</span>
</Link>
{/* Outline Button */}
<Link href="/auth/login" className="group flex items-center gap-3 border border-gray-900 text-gray-900 hover:bg-gray-900 hover:text-white text-[13px] sm:text-[14px] font-medium rounded-full pl-5 sm:pl-6 pr-2 py-2 transition-all duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] cursor-pointer">
<span className="overflow-hidden h-[20px] relative inline-block">
<span className="flex flex-col transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:-translate-y-1/2">
<span className="h-[20px] flex items-center">Pelajari Dokumentasi</span>
<span className="h-[20px] flex items-center text-white">Pelajari Dokumentasi</span>
</span>
</span>
<span className="w-7 h-7 sm:w-8 sm:h-8 rounded-full bg-gray-900 text-white flex items-center justify-center transition-all duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:bg-white group-hover:text-gray-900 group-hover:rotate-[-45deg] shrink-0">
<ArrowRight className="w-4 h-4" />
</span>
</Link>
</div>
</div>
</section>
);
}
@@ -0,0 +1,96 @@
import Link from "next/link";
export function LandingFooter() {
return (
<footer className="bg-[#0b0a09] text-gray-400 relative overflow-hidden pt-20 sm:pt-28">
{/*
Gradient Layer matching image_7203fe.jpg:
A smooth radial ambient glow originating from the center-bottom to softly illuminate
the giant typography, fading into a richer onyx black near the top.
*/}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center_bottom,_rgba(40,38,35,0.45)_0%,_rgba(11,10,9,0)_70%)] pointer-events-none z-0"></div>
{/*
High-fidelity Film Grain Texture Overlay:
Using a dense, fine-grained SVG noise filter pattern to replicate the textured,
premium look from the reference image.
*/}
<div className="absolute inset-0 pointer-events-none opacity-[0.18] mix-blend-overlay z-10 select-none">
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%">
<filter id="grainNoise">
<feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="4" stitchTiles="stitch" />
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.15 0" />
</filter>
<rect width="100%" height="100%" filter="url(#grainNoise)" />
</svg>
</div>
<div className="w-full max-w-[1440px] mx-auto px-5 sm:px-8 lg:px-12 relative z-20">
{/* Top Info and Links columns */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 md:gap-12 pb-16 sm:pb-20">
{/* Logo and Pitch */}
<div className="col-span-2 flex flex-col items-start gap-4 pr-0 sm:pr-8">
<div className="w-10 h-10 bg-white text-gray-900 rounded-full flex items-center justify-center font-bold text-[11px] tracking-tight">
W
</div>
<p className="text-[14px] leading-relaxed text-gray-400 max-w-[320px] mt-2">
Sistem Informasi Geografis terpadu untuk pemetaan statistik kemiskinan, tata guna lahan, dan jaringan fasilitas infrastruktur energi.
</p>
</div>
{/* Column 1: Modul Peta */}
<div className="flex flex-col items-start">
<span className="text-[11px] tracking-wider text-gray-500 font-bold uppercase mb-4 sm:mb-5">
Modul Peta
</span>
<div className="flex flex-col gap-2.5 sm:gap-3 text-[14px]">
<a href="#projects" className="hover:text-white transition-colors duration-300">Peta Kemiskinan</a>
<a href="#projects" className="hover:text-white transition-colors duration-300">Lahan & Jalan</a>
<a href="#projects" className="hover:text-white transition-colors duration-300">SPBU & EV Charger</a>
<Link href="/auth/login" className="hover:text-white transition-colors duration-300">Analisis Wilayah</Link>
</div>
</div>
{/* Column 2: Dokumen & Kontak */}
<div className="flex flex-col gap-8">
{/* Dokumen */}
<div className="flex flex-col items-start">
<span className="text-[11px] tracking-wider text-gray-500 font-bold uppercase mb-4">
Dokumen
</span>
<div className="flex flex-col gap-2.5 text-[14px]">
<Link href="/auth/login" className="hover:text-white transition-colors duration-300">Pusat Bantuan</Link>
<Link href="/auth/login" className="hover:text-white transition-colors duration-300 font-medium text-gray-300">Play by the Rules</Link>
<Link href="/auth/login" className="hover:text-white transition-colors duration-300">Privacy Policy</Link>
</div>
</div>
{/* Kontak */}
<div className="flex flex-col items-start">
<span className="text-[11px] tracking-wider text-gray-500 font-bold uppercase mb-4">
Kontak
</span>
<div className="flex flex-col gap-2.5 text-[14px]">
<Link href="/auth/login" className="hover:text-white transition-colors duration-300">Hubungi Kami</Link>
<Link href="/auth/login" className="hover:text-white transition-colors duration-300">Keamanan Data</Link>
</div>
</div>
</div>
</div>
{/* Giant typography section matching the second image layout */}
<div className="w-full text-center select-none pointer-events-none overflow-hidden relative h-[10vw] sm:h-[12vw] flex items-end justify-center mt-8 sm:mt-16">
<span className="text-[28vw] font-bold text-white leading-[0.7] tracking-normal block opacity-95 translate-y-[20%] select-none">
Waras
</span>
</div>
</div>
</footer>
);
}
@@ -0,0 +1,128 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { ArrowRight, Menu, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { useScroll } from "@/hooks/use-scroll";
import { Portal, PortalBackdrop } from "@/components/portal";
const navLinks = [
{ label: "Tentang", href: "#about" },
{ label: "Modul Peta", href: "#projects" },
{ label: "Dokumentasi", href: "/auth/login" },
];
export function LandingHeader() {
const scrolled = useScroll(10);
const [open, setOpen] = useState(false);
return (
<header className="fixed inset-x-0 top-0 z-50">
<div className="w-full max-w-[1440px] mx-auto p-2 sm:p-3">
<nav
className={cn(
"flex items-center justify-between rounded-full p-[5px] border border-transparent transition-all duration-300 ease-[cubic-bezier(0.25,0.1,0.25,1)]",
scrolled
? "bg-white shadow-[0_2px_12px_rgba(0,0,0,0.06)] border-gray-100/50"
: "bg-white/70 backdrop-blur-sm"
)}
>
{/* Brand */}
<Link
href="/"
className="w-9 h-9 sm:w-10 sm:h-10 bg-gray-900 rounded-full flex items-center justify-center text-white font-bold tracking-tight text-[10px] sm:text-[11px] hover:bg-gray-800 transition-colors duration-300 shrink-0"
>
W
</Link>
{/* Desktop nav links */}
<div className="hidden md:flex items-center gap-6">
{navLinks.map((link) => (
<Link
key={link.label}
href={link.href}
className="text-[14px] font-medium text-gray-900 hover:text-gray-500 transition-colors duration-300"
>
{link.label}
</Link>
))}
</div>
{/* Desktop CTA - routes to Login */}
<Link
href="/auth/login"
className="group hidden md:flex items-center gap-3 bg-primary hover:bg-primary/80 text-primary-foreground text-[14px] font-medium rounded-full pl-6 pr-2 py-2 transition-colors duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] cursor-pointer"
>
<span className="overflow-hidden h-[20px] relative inline-block">
<span className="flex flex-col transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:-translate-y-1/2">
<span className="h-[20px] flex items-center">Login</span>
<span className="h-[20px] flex items-center">Login</span>
</span>
</span>
<span className="w-8 h-8 rounded-full bg-white flex items-center justify-center text-primary transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:rotate-[-45deg] shrink-0">
<ArrowRight className="w-4 h-4" />
</span>
</Link>
{/* Mobile menu trigger */}
<button
onClick={() => setOpen((value) => !value)}
className="md:hidden w-9 h-9 sm:w-10 sm:h-10 bg-gray-900 rounded-full flex items-center justify-center text-white transition-transform duration-300 active:scale-95 cursor-pointer shrink-0"
aria-label="Toggle Navigation Menu"
aria-controls="landing-mobile-nav"
aria-expanded={open}
>
{open ? <X className="w-4 h-4" /> : <Menu className="w-4 h-4" />}
</button>
</nav>
</div>
{/* Mobile Menu Overlay */}
{open && (
<Portal className="md:hidden justify-end" id="landing-mobile-nav">
<PortalBackdrop onClick={() => setOpen(false)} />
<div className="bg-white rounded-2xl mx-3 mb-3 p-6 flex flex-col gap-6 shadow-2xl animate-in slide-in-from-bottom duration-500 ease-[cubic-bezier(0.32,0.72,0,1)]">
<div className="flex justify-between items-center border-b pb-4 border-gray-100">
<span className="text-[12px] font-semibold tracking-wider uppercase text-gray-900">
Waras Portal GIS
</span>
<button
onClick={() => setOpen(false)}
className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center text-gray-700 hover:bg-gray-200 transition-colors"
aria-label="Tutup Menu"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="flex flex-col gap-4 py-2">
{navLinks.map((link) => (
<Link
key={link.label}
href={link.href}
onClick={() => setOpen(false)}
className="text-[28px] sm:text-[32px] font-medium text-gray-900 hover:text-primary transition-colors"
>
{link.label}
</Link>
))}
</div>
{/* Action Button - routes to Login */}
<Link
href="/auth/login"
onClick={() => setOpen(false)}
className="group w-full flex items-center justify-between bg-primary text-primary-foreground text-[14px] font-medium rounded-full pl-6 pr-2 py-2.5 transition-all duration-300 hover:bg-primary/80"
>
<span>Login</span>
<span className="w-8 h-8 rounded-full bg-white flex items-center justify-center text-primary">
<ArrowRight className="w-4 h-4" />
</span>
</Link>
</div>
</Portal>
)}
</header>
);
}
@@ -0,0 +1,80 @@
"use client";
import Link from "next/link";
import dynamic from "next/dynamic";
import { ArrowRight } from "lucide-react";
// Dynamically import shaders to prevent SSR errors (WebGL/Canvas requires browser APIs)
const Shader = dynamic(() => import("shaders/react").then((mod) => mod.Shader), { ssr: false });
const Swirl = dynamic(() => import("shaders/react").then((mod) => mod.Swirl), { ssr: false });
const ChromaFlow = dynamic(() => import("shaders/react").then((mod) => mod.ChromaFlow), { ssr: false });
const FlutedGlass = dynamic(() => import("shaders/react").then((mod) => mod.FlutedGlass), { ssr: false });
const FilmGrain = dynamic(() => import("shaders/react").then((mod) => mod.FilmGrain), { ssr: false });
export function LandingHero() {
return (
<section className="relative flex min-h-screen w-full flex-col justify-center overflow-hidden bg-[#EFEFEF]">
{/* Animated WebGL Shader Overlay */}
<div className="absolute inset-0 z-10 pointer-events-none w-full h-full">
<Shader className="w-full h-full">
<Swirl colorA="#ffffff" colorB="#f0f0f0" detail={1.7} />
<ChromaFlow
baseColor="#ffffff"
downColor="#ff5f03"
leftColor="#ff5f03"
rightColor="#ff5f03"
upColor="#ff5f03"
momentum={13}
radius={3.5}
/>
<FlutedGlass
aberration={0.61}
angle={31}
frequency={8}
highlight={0.12}
highlightSoftness={0}
lightAngle={-90}
refraction={4}
shape="rounded"
softness={1}
speed={0.15}
/>
<FilmGrain strength={0.05} />
</Shader>
</div>
{/* Hero Content */}
<div className="relative z-20 w-full max-w-[1440px] mx-auto px-5 sm:px-8 lg:px-12 pb-14 sm:pb-16 lg:pb-20">
<div className="text-[13px] sm:text-[14px] text-gray-900 font-semibold tracking-wider uppercase mb-5 sm:mb-8">
Waras Portal GIS
</div>
<h1 className="font-medium leading-[1.08] tracking-[-0.03em] text-gray-900 text-[clamp(1.75rem,7vw,4.2rem)] sm:text-[clamp(2.5rem,5vw,4.2rem)] max-w-[1100px]">
Satu peta untuk keputusan daerah yang lebih baik.
</h1>
{/* CTA Row */}
<div className="mt-8 sm:mt-12 flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:gap-5">
{/* Orange Button with Text Roll and Rotated Arrow - routes to Login */}
<Link href="/auth/login" className="group flex items-center gap-3 bg-primary hover:bg-primary/80 text-primary-foreground text-[13px] sm:text-[14px] font-medium rounded-full pl-5 sm:pl-6 pr-2 py-2 transition-colors duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] cursor-pointer">
<span className="overflow-hidden h-[20px] relative inline-block">
<span className="flex flex-col transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:-translate-y-1/2">
<span className="h-[20px] flex items-center">Buka Dashboard</span>
<span className="h-[20px] flex items-center">Buka Dashboard</span>
</span>
</span>
<span className="w-7 h-7 sm:w-8 sm:h-8 rounded-full bg-white flex items-center justify-center text-primary transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:rotate-[-45deg] shrink-0">
<ArrowRight className="w-4 h-4" />
</span>
</Link>
</div>
</div>
</section>
);
}
@@ -0,0 +1,97 @@
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { LinkIcon } from "@/components/landing/link-icon";
export function LandingProjects() {
return (
<section id="projects" className="bg-[#F5F5F5] pt-16 sm:pt-20 lg:pt-28 pb-16 sm:pb-20 lg:pb-28 w-full">
<div className="w-full max-w-[1440px] mx-auto">
{/* Badge Row */}
<div className="px-5 sm:px-8 lg:px-12 flex items-center gap-3 mb-6 sm:mb-8">
<span className="w-6 h-6 sm:w-7 sm:h-7 rounded-full bg-gray-900 text-white text-[11px] sm:text-[12px] font-semibold flex items-center justify-center">
2
</span>
<span className="text-[12px] sm:text-[13px] font-medium text-gray-900 border border-gray-300 rounded-full px-3 sm:px-4 py-1 sm:py-1.5">
Modul Peta Visual
</span>
</div>
{/* Heading */}
<div className="px-5 sm:px-8 lg:px-12 mb-10 sm:mb-14 lg:mb-16">
<h2 className="font-medium leading-[1.08] tracking-[-0.03em] text-gray-900 text-[clamp(1.75rem,7vw,4.2rem)] sm:text-[clamp(2.5rem,5vw,4.2rem)]">
Modul Sistem
</h2>
</div>
{/* Grid Layout */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5 sm:gap-6 lg:gap-7 px-5 sm:px-8 lg:px-12">
{/* Card 1: Poverty Map - links to Login */}
<Link href="/auth/login" className="flex flex-col group cursor-pointer text-left">
{/* Media Container */}
<div className="relative aspect-[329/246] rounded-2xl overflow-hidden bg-[#1a1d2e] shadow-[0_4px_16px_rgba(0,0,0,0.015)]">
<video
src="https://d8j0ntlcm91z4.cloudfront.net/user_38xzZboKViGWJOttwIXH07lWA1P/hf_20260516_122702_390f5305-8719-41d5-ae80-d23ab3796c28.mp4"
autoPlay
muted
loop
playsInline
className="w-full h-full object-cover transition-transform duration-700 ease-out group-hover:scale-102"
/>
{/* Hover expandable white circle button */}
<div className="absolute bottom-4 left-4 h-9 bg-white text-gray-900 rounded-full flex items-center overflow-hidden transition-all duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] w-9 group-hover:w-[148px] px-2.5 justify-between shadow-md">
<span className="text-[13px] font-medium whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity duration-300 delay-100 pl-1 text-gray-900">
Buka Peta
</span>
<LinkIcon className="w-3.5 h-3.5 shrink-0 text-gray-900 transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] -rotate-45 group-hover:rotate-0" />
</div>
</div>
{/* Descriptions */}
<p className="text-[13px] sm:text-[14px] text-gray-600 mt-4 leading-relaxed font-normal">
Sistem analisis kemiskinan terpadu dengan visualisasi statistik desil wilayah dan indeks prioritas bantuan.
</p>
<h3 className="text-[14px] sm:text-[15px] font-semibold text-gray-900 mt-1">
Peta & Statistik Kemiskinan
</h3>
</Link>
{/* Card 2: Gas Station Map - links to Login */}
<Link href="/auth/login" className="flex flex-col group cursor-pointer text-left">
{/* Media Container */}
<div className="relative aspect-square rounded-2xl overflow-hidden bg-[#6b6b6b] shadow-[0_4px_16px_rgba(0,0,0,0.015)]">
<video
src="https://d8j0ntlcm91z4.cloudfront.net/user_38xzZboKViGWJOttwIXH07lWA1P/hf_20260516_123323_f909c2b8-ff6c-4edf-882b-8ebcdbe389b5.mp4"
autoPlay
muted
loop
playsInline
className="w-full h-full object-cover transition-transform duration-700 ease-out group-hover:scale-102"
/>
{/* Hover expandable dark circle button */}
<div className="absolute bottom-4 left-4 h-9 bg-gray-900 text-white rounded-full flex items-center overflow-hidden transition-all duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] w-9 group-hover:w-[168px] px-2.5 justify-between shadow-md">
<span className="text-[13px] font-medium whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity duration-300 delay-100 pl-1 text-white">
Buka Peta
</span>
<ArrowRight className="w-3.5 h-3.5 shrink-0 text-white transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] -rotate-45 group-hover:rotate-0" />
</div>
</div>
{/* Descriptions */}
<p className="text-[13px] sm:text-[14px] text-gray-600 mt-4 leading-relaxed font-normal">
Pemetaan stasiun pengisian energi (SPBU & EV Charger) beserta rincian fasilitas bengkel dan status operasional 24 jam.
</p>
<h3 className="text-[14px] sm:text-[15px] font-semibold text-gray-900 mt-1">
Peta SPBU, EV Charger & Bengkel
</h3>
</Link>
</div>
</div>
</section>
);
}
+19
View File
@@ -0,0 +1,19 @@
import type { SVGProps } from "react";
export function LinkIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
);
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import { cn } from "@/lib/utils";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { XIcon } from "@/components/ui/phosphor-icons";
const latestChange = {
badge: "CHANGELOG",
title: "Product update",
description: "Performance boosts and UI polish.", // TIP: Use a single line of text for the description. (max 5 words)
readMore: { href: "#", label: "Learn more" },
} as const;
export function LatestChange() {
const [isOpen, setIsOpen] = useState(true);
if (!isOpen) {
return null;
}
return (
<div
className={cn(
"group/latest-change size-full min-h-27 justify-center border-t",
"relative flex size-full flex-col gap-1 overflow-hidden px-4 pt-3 pb-1 *:text-nowrap",
"transition-opacity group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:opacity-0"
)}
>
<span className="font-light font-mono text-[10px] text-muted-foreground">
{latestChange.badge}
</span>
<p className="font-medium text-xs">{latestChange.title}</p>
<span className="text-[10px] text-muted-foreground">
{latestChange.description}
</span>
<Button className="w-max px-0 font-light text-xs" size="sm" variant="link" render={<a href={latestChange.readMore.href} />} nativeButton={false}>{latestChange.readMore.label}</Button>
<Button
className="absolute top-2 right-2 z-10 size-6 rounded-full opacity-0 transition-opacity group-hover/latest-change:opacity-100"
onClick={() => setIsOpen(false)}
size="icon-sm"
variant="ghost"
>
<XIcon className="size-3.5 text-muted-foreground" />{" "}
</Button>
</div>
);
}
+810
View File
@@ -0,0 +1,810 @@
"use client";
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { BRAND_COLORS } from "@/lib/map-data";
export interface UserMarker {
id: string;
lat: number;
lng: number;
type: string;
name: string;
meta?: {
poverty_level?: string;
family_count?: number;
penghasilan?: number;
fuzzy_label?: string;
fuzzy_score?: number;
fuzzy_detail?: Record<string, { kategori: string; derajat: number }>;
notes?: string;
poi_type?: string;
radius?: number;
coordinates?: [number, number][];
gas_types?: string[];
created_by_username?: string;
created_at?: string;
updated_at?: string;
};
}
interface LeafletMapProps {
mode: "poverty" | "land" | "gas-station";
selectedYear?: number;
selectedId?: string;
onSelect?: (id: string) => void;
visibilityFilters?: Record<string, boolean>;
fuelPriceDisplay?: "ron92" | "ron95" | "diesel";
fullBleed?: boolean;
mapStyle?: "street" | "satellite";
onMapClick?: (lat: number, lng: number) => void;
userMarkers?: UserMarker[];
onMarkerClick?: (marker: UserMarker) => void;
onMapReady?: (mapInstance: any) => void;
onShapeCreated?: (shape: UserMarker) => void;
placingMarkerType?: string | null;
dragDropEnabled?: boolean;
onMarkerDragEnd?: (marker: UserMarker, newLat: number, newLng: number) => void;
}
const POVERTY_LEVEL_COLOR: Record<string, string> = {
Extreme: "#ef4444",
Miskin: "#f59e0b",
Rentan: "#3b82f6",
};
const FUZZY_LABEL_COLOR: Record<string, string> = {
"SANGAT TINGGI": "#ef4444",
"TINGGI": "#f97316",
"SEDANG": "#f59e0b",
"RENDAH": "#10b981",
"TIDAK PRIORITAS":"#6b7280",
};
function fmt(n: number) {
return "Rp " + n.toLocaleString("id-ID");
}
const POI_TYPE_LABELS: Record<string, string> = {
marker: "Data Warga",
mosque: "Masjid",
church: "Gereja",
cathedral: "Gereja Katolik",
temple: "Pura",
vihara: "Vihara",
klenteng: "Klenteng",
synagogue: "Sinagog",
clinic: "Klinik",
"food-bank": "Lumbung Pangan",
school: "Sekolah",
flag: "Tengara",
protected: "Kawasan Lindung",
registry: "Kantor Pendaftaran",
"charging-station": "Pengisi Daya EV",
"gas-pump": "SPBU",
wrench: "Bengkel",
line: "Garis",
polygon: "Poligon",
circle: "Lingkaran",
};
function buildMarkerTooltip(m: UserMarker): string {
const meta = m.meta;
const isHousehold = m.type === "marker";
const typeLabel = POI_TYPE_LABELS[m.type] ?? m.type.replace(/-/g, " ");
const base = `
<div style="font-family:system-ui,sans-serif;min-width:160px;max-width:220px;">
<div style="font-size:13px;font-weight:700;margin-bottom:4px;border-bottom:1px solid rgba(0,0,0,0.1);padding-bottom:4px;">
${m.name}
</div>`;
if (isHousehold && meta) {
const lvlColor = POVERTY_LEVEL_COLOR[meta.poverty_level ?? ""] ?? "#6b7280";
const fuzzyColor = FUZZY_LABEL_COLOR[meta.fuzzy_label ?? ""] ?? "#6b7280";
const displayPovertyLevel = meta.poverty_level === "Extreme" ? "Ekstrem" : (meta.poverty_level ?? "—");
return base + `
<div style="display:grid;grid-template-columns:auto 1fr;gap:2px 8px;font-size:11px;color:#374151;">
<span style="color:#9ca3af;">Tingkat</span>
<span style="font-weight:600;color:${lvlColor};">${displayPovertyLevel}</span>
<span style="color:#9ca3af;">Tanggungan</span>
<span>${meta.family_count ?? "—"} jiwa</span>
<span style="color:#9ca3af;">Penghasilan</span>
<span>${meta.penghasilan != null ? fmt(meta.penghasilan) : "—"}/bln</span>
${meta.fuzzy_label ? `
<span style="color:#9ca3af;">Prioritas</span>
<span style="font-weight:600;color:${fuzzyColor};">${meta.fuzzy_label}</span>
` : ""}
${meta.fuzzy_score != null ? `
<span style="color:#9ca3af;">Skor</span>
<span>${meta.fuzzy_score.toFixed(2)}</span>
` : ""}
${meta.notes ? `
<span style="color:#9ca3af;">Catatan</span>
<span style="font-style:italic;">${meta.notes}</span>
` : ""}
</div>
<div style="margin-top:4px;font-size:10px;color:#9ca3af;">
${m.lat.toFixed(5)}, ${m.lng.toFixed(5)}
</div>
</div>`;
}
const gasTypesHtml = m.type === "gas-pump" && meta?.gas_types && meta.gas_types.length > 0
? `<div style="margin-top:4px;margin-bottom:4px;display:flex;flex-wrap:wrap;gap:2px;">
${meta.gas_types.map((gt: string) => `<span style="background-color:rgba(255,59,31,0.1);color:#ff3b1f;border:1px solid rgba(255,59,31,0.2);padding:1px 4px;border-radius:3px;font-size:9px;font-weight:600;white-space:nowrap;">${gt}</span>`).join("")}
</div>`
: "";
return base + `
<div style="font-size:11px;color:#6b7280;text-transform:capitalize;margin-bottom:4px;">${typeLabel}</div>
${meta?.poi_type ? `<div style="font-size:11px;font-weight:600;color:#374151;margin-bottom:2px;">${meta.poi_type}</div>` : ""}
${meta?.notes ? `<div style="font-size:11px;color:#374151;font-style:italic;margin-bottom:2px;">${meta.notes}</div>` : ""}
${gasTypesHtml}
<div style="margin-top:4px;font-size:10px;color:#9ca3af;">
${m.lat.toFixed(5)}, ${m.lng.toFixed(5)}
</div>
</div>`;
}
export default function LeafletMap({
mode,
selectedYear = 2026,
selectedId,
onSelect,
visibilityFilters,
fuelPriceDisplay = "ron95",
fullBleed = true,
mapStyle = "street",
onMapClick,
userMarkers,
onMarkerClick,
onMapReady,
onShapeCreated,
placingMarkerType,
dragDropEnabled = false,
onMarkerDragEnd,
}: LeafletMapProps) {
const mapContainerRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<any>(null);
const [leafletLoaded, setLeafletLoaded] = useState(false);
const layersRef = useRef<any[]>([]);
const streetLayerRef = useRef<any>(null);
const satelliteLayerRef = useRef<any>(null);
// 1. Inject Leaflet JS/CSS and optionally Geoman for land drawing
useEffect(() => {
let isMounted = true;
// Promise-based script loader that handles already-loaded scripts
const loadScript = (id: string, src: string): Promise<void> => {
return new Promise((resolve, reject) => {
const existing = document.getElementById(id) as HTMLScriptElement | null;
if (existing) {
// Script tag exists — check if it already finished loading
// by testing for the global it should have created
if (id === "leaflet-js" && (window as any).L) { resolve(); return; }
if (id === "geoman-js" && (window as any).L?.PM) { resolve(); return; }
// Still loading — listen for completion
const onLoad = () => { existing.removeEventListener("load", onLoad); resolve(); };
const onError = () => { existing.removeEventListener("error", onError); reject(new Error(`Script ${id} failed`)); };
existing.addEventListener("load", onLoad);
existing.addEventListener("error", onError);
return;
}
const script = document.createElement("script");
script.id = id;
script.src = src;
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`Failed to load ${src}`));
document.body.appendChild(script);
});
};
const loadAll = async () => {
if (typeof window === "undefined") return;
// Inject Leaflet CSS
if (!document.getElementById("leaflet-css")) {
const link = document.createElement("link");
link.id = "leaflet-css";
link.rel = "stylesheet";
link.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
document.head.appendChild(link);
}
// Inject Geoman CSS for land mode
if (mode === "land" && !document.getElementById("geoman-css")) {
const link = document.createElement("link");
link.id = "geoman-css";
link.rel = "stylesheet";
link.href = "https://unpkg.com/@geoman-io/leaflet-geoman-free@2.17.0/dist/leaflet-geoman.css";
document.head.appendChild(link);
}
try {
// Step 1: Load Leaflet core (skip if already loaded)
if (!(window as any).L) {
await loadScript("leaflet-js", "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js");
}
// Step 2: For land mode, load Geoman AFTER Leaflet (skip if already loaded)
if (mode === "land" && !(window as any).L?.PM) {
await loadScript("geoman-js", "https://unpkg.com/@geoman-io/leaflet-geoman-free@2.17.0/dist/leaflet-geoman.js");
}
// Both scripts loaded — safe to create the map
if (isMounted) setLeafletLoaded(true);
} catch (err) {
console.error("[LeafletMap] Script loading failed:", err);
// Still allow non-land modes to proceed without Geoman
if (mode !== "land" && (window as any).L && isMounted) {
setLeafletLoaded(true);
}
}
};
loadAll();
return () => {
isMounted = false;
};
}, [mode]);
// 2. Initialize Leaflet Map
useEffect(() => {
if (!leafletLoaded || !mapContainerRef.current) return;
const L = (window as any).L;
if (!L) return;
// For land mode, verify Geoman is truly available before creating the map
if (mode === "land" && !L.PM) {
console.warn("[LeafletMap] Geoman not available for land mode — aborting map init");
return;
}
// Create Map Instance centered in Jakarta
const map = L.map(mapContainerRef.current, {
center: [-6.20, 106.82],
zoom: 12,
zoomControl: true
});
// Safety net: if Geoman loaded but map.pm is still undefined,
// manually re-initialize Geoman on this map instance
if (mode === "land" && L.PM && !map.pm) {
try {
L.PM.reInitLayer(map);
} catch (e) {
console.warn("[LeafletMap] L.PM.reInitLayer failed, trying manual init:", e);
// Fallback: Geoman v2 attaches via addInitHook, try calling it directly
if (typeof map._initPathRoot === "function") map._initPathRoot();
}
}
// Hide Geoman's default toolbar — we drive drawing programmatically
if (mode === "land" && map.pm) {
try {
map.pm.addControls({ position: "topleft" });
map.pm.removeControls();
} catch (_) { /* ignore */ }
}
const street = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: '&copy; OpenStreetMap contributors'
});
const satellite = L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", {
attribution: 'Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community'
});
streetLayerRef.current = street;
satelliteLayerRef.current = satellite;
// Only the active layer lives on the map — a hidden (opacity 0) tile
// layer still downloads tiles on every pan/zoom.
const active = mapStyle === "satellite" ? satellite : street;
active.setOpacity(1);
active.addTo(map);
mapInstanceRef.current = map;
onMapReady?.(map);
return () => {
if (mapInstanceRef.current) {
mapInstanceRef.current.remove();
mapInstanceRef.current = null;
}
};
}, [leafletLoaded]);
// 2.5 Handle Map Style Cross-Fade Animation
useEffect(() => {
const map = mapInstanceRef.current;
const street = streetLayerRef.current;
const satellite = satelliteLayerRef.current;
if (!map || !street || !satellite) return;
const incoming = mapStyle === "satellite" ? satellite : street;
const outgoing = mapStyle === "satellite" ? street : satellite;
if (!map.hasLayer(incoming)) {
incoming.setOpacity(0);
incoming.addTo(map);
}
if (!map.hasLayer(outgoing)) {
// Initial mount — active layer already on the map, nothing to fade.
incoming.setOpacity(1);
return;
}
let rafId = 0;
let start: number | null = null;
const duration = 400; // 400ms transition
const animate = (timestamp: number) => {
if (!start) start = timestamp;
const progress = Math.min((timestamp - start) / duration, 1);
incoming.setOpacity(progress);
outgoing.setOpacity(1 - progress);
if (progress < 1) {
rafId = requestAnimationFrame(animate);
} else {
// Detach the hidden layer so it stops requesting tiles.
map.removeLayer(outgoing);
}
};
rafId = requestAnimationFrame(animate);
return () => cancelAnimationFrame(rafId);
}, [mapStyle, leafletLoaded]);
// 3. Render Layers based on Props
useEffect(() => {
const map = mapInstanceRef.current;
const L = (window as any).L;
if (!map || !L) return;
// Clear existing layers
layersRef.current.forEach((layer) => map.removeLayer(layer));
layersRef.current = [];
if (mode === "poverty") {
// Poverty mode renders only real data: household + POI markers from
// the database (drawn below from userMarkers). No mock choropleth.
} else if (mode === "land") {
// Land mode renders only real data: land_markers + land_shapes from
// the database (drawn below from userMarkers). No mock zone polygons.
} else if (mode === "gas-station") {
// Gas station mode renders only real data from the database (drawn below from userMarkers)
}
// Status-based colors for shapes
const ROAD_STATUS_COLORS: Record<string, string> = {
"National Road": "#ef4444",
"Provincial Road": "#eab308",
"Regency Road": "#22c55e",
};
const LAND_STATUS_COLORS: Record<string, string> = {
"SHM": "#a855f7",
"HGB": "#f97316",
"HGU": "#14b8a6",
"HP": "#ec4899",
};
// Draw User-Placed Markers and Shapes
const RELIGION_TYPES = new Set(["mosque","church","cathedral","temple","vihara","klenteng","synagogue"]);
if (userMarkers && userMarkers.length > 0) {
userMarkers.forEach((m) => {
// Visibility filter (land mode): skip rendering if this type is hidden.
// visibilityFilters is undefined for poverty/gas-station, so this is a no-op there.
if (visibilityFilters && visibilityFilters[m.type] === false) return;
// Render user-placed polylines (lines)
if (m.type === "line" && m.meta?.coordinates) {
const validCoords = m.meta.coordinates.filter(
(c: any) => Array.isArray(c) && typeof c[0] === "number" && !isNaN(c[0]) && typeof c[1] === "number" && !isNaN(c[1])
);
if (validCoords.length >= 2) {
const lineColor = ROAD_STATUS_COLORS[m.meta?.poi_type ?? ""] ?? "#ff3b1f";
const polyline = L.polyline(validCoords, {
color: lineColor,
weight: 4,
opacity: 0.85
}).addTo(map);
const statusLabel = m.meta?.poi_type ? ` · ${m.meta.poi_type}` : "";
polyline.bindTooltip(`<strong>${m.name}</strong><br/><span style="color:${lineColor}">GARIS${statusLabel}</span>`, { sticky: true });
polyline.on("click", (e: any) => {
e.originalEvent?.stopPropagation();
onMarkerClick?.(m);
});
layersRef.current.push(polyline);
}
return;
}
// Render user-placed polygons
if (m.type === "polygon" && m.meta?.coordinates) {
const validCoords = m.meta.coordinates.filter(
(c: any) => Array.isArray(c) && typeof c[0] === "number" && !isNaN(c[0]) && typeof c[1] === "number" && !isNaN(c[1])
);
if (validCoords.length >= 3) {
const polyColor = LAND_STATUS_COLORS[m.meta?.poi_type ?? ""] ?? "#ff3b1f";
const polygon = L.polygon(validCoords, {
fillColor: polyColor,
fillOpacity: 0.25,
color: polyColor,
weight: 2,
opacity: 0.85
}).addTo(map);
const statusLabel = m.meta?.poi_type ? ` · ${m.meta.poi_type}` : "";
polygon.bindTooltip(`<strong>${m.name}</strong><br/><span style="color:${polyColor}">POLIGON${statusLabel}</span>`, { sticky: true });
polygon.on("click", (e: any) => {
e.originalEvent?.stopPropagation();
onMarkerClick?.(m);
});
layersRef.current.push(polygon);
}
return;
}
// Render user-placed circles
if (m.type === "circle" && typeof m.lat === "number" && !isNaN(m.lat) && typeof m.lng === "number" && !isNaN(m.lng) && (m.meta?.radius ?? 0) > 0) {
const circle = L.circle([m.lat, m.lng], {
radius: m.meta!.radius!,
fillColor: "#ff3b1f",
fillOpacity: 0.2,
color: "#ff3b1f",
weight: 2,
opacity: 0.85
}).addTo(map);
circle.bindTooltip(`<strong>${m.name}</strong><br/>Jenis: LINGKARAN<br/>Radius: ${m.meta!.radius!.toFixed(1)}m`, { sticky: true });
circle.on("click", (e: any) => {
e.originalEvent?.stopPropagation();
onMarkerClick?.(m);
});
layersRef.current.push(circle);
return;
}
let markerColor = "rgb(59, 130, 246)";
let iconName = "map-pin";
switch (m.type) {
case "marker":
markerColor = FUZZY_LABEL_COLOR[m.meta?.fuzzy_label ?? ""] ?? markerColor;
iconName = "map-pin";
break;
case "mosque":
markerColor = "rgb(16, 185, 129)";
iconName = "mosque";
break;
case "church":
markerColor = "rgb(139, 92, 246)";
iconName = "church";
break;
case "cathedral":
markerColor = "rgb(124, 58, 237)";
iconName = "crown-cross";
break;
case "temple":
markerColor = "rgb(234, 88, 12)";
iconName = "hands-praying";
break;
case "vihara":
markerColor = "rgb(202, 138, 4)";
iconName = "hands-praying";
break;
case "klenteng":
markerColor = "rgb(185, 28, 28)";
iconName = "hands-praying";
break;
case "synagogue":
markerColor = "rgb(245, 158, 11)";
iconName = "synagogue";
break;
case "clinic":
markerColor = "rgb(239, 68, 68)";
iconName = "first-aid";
break;
case "food-bank":
markerColor = "rgb(217, 70, 239)";
iconName = "bowl-food";
break;
case "school":
markerColor = "rgb(6, 182, 212)";
iconName = "graduation-cap";
break;
case "flag":
markerColor = "rgb(244, 63, 94)";
iconName = "flag";
break;
case "protected":
markerColor = "rgb(4, 120, 87)";
iconName = "tree";
break;
case "registry":
markerColor = "rgb(29, 78, 216)";
iconName = "bank";
break;
case "charging-station": {
iconName = "charging-station";
const evType = m.meta?.poi_type ?? "";
if (evType === "DC Fast") {
markerColor = "#10b981"; // Emerald
} else if (evType === "Supercharger") {
markerColor = "#14b8a6"; // Teal
} else if (evType === "AC Level 2") {
markerColor = "#3b82f6"; // Blue
} else {
markerColor = "rgb(16, 185, 129)"; // Default green
}
break;
}
case "gas-pump": {
iconName = "gas-pump";
const brand = m.meta?.poi_type ?? "";
if (brand === "Pertamina") {
markerColor = "#ef4444"; // Red
} else if (brand === "Shell") {
markerColor = "#eab308"; // Yellow
} else if (brand === "BP") {
markerColor = "#10b981"; // Green
} else if (brand === "Vivo") {
markerColor = "#3b82f6"; // Blue
} else {
markerColor = "rgb(220, 38, 38)"; // Default red
}
break;
}
case "wrench": {
iconName = "wrench";
const spec = m.meta?.poi_type ?? "";
if (spec === "Umum") {
markerColor = "#6b7280"; // Gray
} else if (spec === "AC & Kelistrikan") {
markerColor = "#0ea5e9"; // Sky
} else if (spec === "Mesin") {
markerColor = "#f97316"; // Orange
} else if (spec === "Ban & Velg") {
markerColor = "#f59e0b"; // Amber
} else {
markerColor = "rgb(107, 114, 128)"; // Default gray
}
break;
}
}
let borderStyle = "2px solid white";
if (m.type === "gas-pump" || m.type === "charging-station" || m.type === "wrench") {
const notesLower = (m.meta?.notes ?? "").toLowerCase();
if (notesLower.includes("24 jam") || notesLower.includes("24 hours")) {
borderStyle = "2.5px solid #22c55e"; // Emerald green border for 24h
} else if (notesLower.includes("tutup") || notesLower.includes("offline") || notesLower.includes("maintenance")) {
borderStyle = "2.5px solid #ef4444"; // Red border for closed/offline
} else if (notesLower.includes("buka") || notesLower.includes("active") || notesLower.includes("open") || notesLower.includes("jam:")) {
borderStyle = "2.5px solid #3b82f6"; // Blue border for active/open/hours
}
}
const customIcon = L.divIcon({
className: "custom-user-marker",
html: `
<div style="
background-color: ${markerColor};
color: white;
width: 32px;
height: 32px;
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
display: flex;
align-items: center;
justify-content: center;
border: ${borderStyle};
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
">
<div style="
transform: rotate(45deg);
width: 16px;
height: 16px;
background-color: white;
mask-image: url(/light/${iconName}-light.svg);
-webkit-mask-image: url(/light/${iconName}-light.svg);
mask-size: contain;
-webkit-mask-size: contain;
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
mask-position: center;
-webkit-mask-position: center;
"></div>
</div>
`,
iconSize: [32, 32],
iconAnchor: [16, 32],
popupAnchor: [0, -32]
});
const isDraggableMarker = !!dragDropEnabled && (m.type === "marker" || RELIGION_TYPES.has(m.type) || ["clinic", "food-bank", "school", "flag", "protected", "registry", "charging-station", "gas-pump", "wrench"].includes(m.type));
const marker = L.marker([m.lat, m.lng], {
icon: customIcon,
draggable: isDraggableMarker
}).addTo(map);
if (isDraggableMarker) {
marker.on("dragend", (e: any) => {
const newLatLng = e.target.getLatLng();
onMarkerDragEnd?.(m, newLatLng.lat, newLatLng.lng);
});
}
const tooltipHtml = buildMarkerTooltip(m);
marker.bindTooltip(tooltipHtml, {
direction: "top",
className: "user-marker-tooltip",
});
marker.on("click", (e: any) => {
e.originalEvent?.stopPropagation();
onMarkerClick?.(m);
});
layersRef.current.push(marker);
if (RELIGION_TYPES.has(m.type) && (m.meta?.radius ?? 0) > 0) {
const circle = L.circle([m.lat, m.lng], {
radius: m.meta!.radius!,
color: markerColor,
fillColor: markerColor,
fillOpacity: 0.08,
weight: 1.5,
dashArray: "5 4",
interactive: false,
}).addTo(map);
layersRef.current.push(circle);
}
});
}
}, [mode, visibilityFilters, leafletLoaded, userMarkers, dragDropEnabled]);
// 4. Handle Map Click Callback
useEffect(() => {
const map = mapInstanceRef.current;
if (!map || !onMapClick) return;
const handleMapClick = (e: any) => {
onMapClick(e.latlng.lat, e.latlng.lng);
};
map.on("click", handleMapClick);
return () => {
map.off("click", handleMapClick);
};
}, [onMapClick, leafletLoaded]);
// 5. Handle Geoman Shape Creation
useEffect(() => {
const map = mapInstanceRef.current;
if (!map || mode !== "land" || !map.pm) return;
const handlePmCreate = (e: any) => {
try {
const layer = e.layer;
const type = String(e.shape || e.layerType || "").toLowerCase();
// Remove Geoman temp layer asynchronously in the next tick to prevent event loop crashes
setTimeout(() => {
try { layer.remove(); } catch (_) { /* ignore */ }
}, 0);
const id = `shape-${Date.now()}`;
let newShape: UserMarker | null = null;
const geojson = layer.toGeoJSON();
if (type === "line" || type === "polyline") {
const geomCoords = geojson.geometry.coordinates;
if (Array.isArray(geomCoords) && geomCoords.length > 0) {
const coordinates: [number, number][] = geomCoords.map(([lng, lat]: [number, number]) => [lat, lng]);
newShape = {
id,
lat: coordinates[0][0],
lng: coordinates[0][1],
type: "line",
name: "Survey Line " + new Date().toLocaleDateString("id-ID"),
meta: { coordinates }
};
}
} else if (type === "polygon" || type === "rectangle") {
const geomCoords = geojson.geometry.coordinates[0];
if (Array.isArray(geomCoords) && geomCoords.length > 0) {
const coordinates: [number, number][] = geomCoords.map(([lng, lat]: [number, number]) => [lat, lng]);
newShape = {
id,
lat: coordinates[0][0],
lng: coordinates[0][1],
type: "polygon",
name: "Survey Area " + new Date().toLocaleDateString("id-ID"),
meta: { coordinates }
};
}
} else if (type === "circle") {
const latlng = layer.getLatLng();
const radius = layer.getRadius();
newShape = {
id,
lat: latlng.lat,
lng: latlng.lng,
type: "circle",
name: "Buffer Zone " + new Date().toLocaleDateString("id-ID"),
meta: { radius }
};
}
if (newShape && onShapeCreated) {
onShapeCreated(newShape);
}
} catch (err) {
console.error("[LeafletMap] pm:create handler error:", err);
}
};
map.on("pm:create", handlePmCreate);
return () => {
map.off("pm:create", handlePmCreate);
};
}, [leafletLoaded, mode, onShapeCreated]);
// 6. Programmatically enable Geoman drawing tools based on placingMarkerType
useEffect(() => {
const map = mapInstanceRef.current;
if (!map || mode !== "land" || !map.pm) return;
try {
map.pm.disableDraw();
if (!placingMarkerType) return;
const options = {
templineStyle: { color: "#ff3b1f" },
hintlineStyle: { color: "#ff3b1f", dashArray: "5 5" },
pathOptions: { color: "#ff3b1f", fillColor: "#ff3b1f", fillOpacity: 0.2 }
};
if (placingMarkerType === "line") {
map.pm.enableDraw("Line", options);
} else if (placingMarkerType === "polygon") {
map.pm.enableDraw("Polygon", options);
} else if (placingMarkerType === "circle") {
map.pm.enableDraw("Circle", options);
}
} catch (err) {
console.error("[LeafletMap] Geoman enableDraw error:", err);
}
}, [placingMarkerType, leafletLoaded, mode]);
return (
<div className={cn(
"relative w-full h-full min-h-[380px] bg-paper dark:bg-obsidian overflow-hidden",
!fullBleed && "rounded-lg border"
)}>
{!leafletLoaded && (
<div className="absolute inset-0 flex items-center justify-center bg-card/80 backdrop-blur-sm z-50">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<p className="text-xs font-semibold text-muted-foreground">Menginisialisasi peta Leaflet...</p>
</div>
</div>
)}
<div ref={mapContainerRef} className="w-full h-full min-h-[380px]" style={{ zIndex: 1 }} />
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import type React from "react";
export const LogoIcon = (props: React.ComponentProps<"svg">) => (
<svg fill="currentColor" viewBox="0 -2.5 29 29" {...props}>
<path d="M 0.09375 23.921875 C 0 23.851562 -0.00390625 23.640625 0.0078125 21.367188 L 0.0234375 18.890625 L 3.324219 18.871094 L 6.628906 18.859375 L 6.664062 18.992188 C 6.683594 19.070312 6.699219 20.191406 6.699219 21.492188 C 6.699219 23.246094 6.683594 23.867188 6.628906 23.921875 C 6.523438 24.027344 0.238281 24.023438 0.09375 23.921875 Z M 0.09375 23.921875 " />
<path d="M 9.679688 23.890625 C 9.625 23.75 9.625 19.289062 9.6875 19.085938 L 9.726562 18.925781 L 11.4375 18.90625 L 13.152344 18.890625 L 13.371094 18.722656 C 13.492188 18.628906 13.632812 18.492188 13.679688 18.40625 C 13.8125 18.214844 13.851562 17.328125 13.792969 15.925781 C 13.75 14.808594 13.75 14.800781 13.5625 14.492188 C 13.460938 14.320312 13.265625 14.089844 13.121094 13.984375 L 12.875 13.792969 L 10.101562 13.792969 C 7.679688 13.789062 7.316406 13.773438 7.242188 13.699219 C 7.15625 13.613281 7.140625 13.25 7.117188 11.023438 C 7.09375 8.96875 7.070312 8.414062 7.003906 8.296875 C 6.832031 7.984375 6.570312 7.707031 6.28125 7.515625 L 5.976562 7.316406 L 3.082031 7.335938 C 0.457031 7.347656 0.179688 7.335938 0.0898438 7.246094 C 0 7.15625 -0.00390625 6.832031 0.0078125 3.640625 C 0.0195312 1.707031 0.046875 0.109375 0.0703125 0.078125 C 0.148438 0.0078125 7.121094 -0.0195312 7.195312 0.0546875 C 7.230469 0.0898438 7.265625 1.097656 7.277344 2.816406 C 7.289062 4.304688 7.3125 5.65625 7.328125 5.832031 C 7.355469 6.085938 7.414062 6.203125 7.679688 6.546875 C 8.230469 7.257812 8.042969 7.210938 10.617188 7.226562 L 12.847656 7.246094 L 13.167969 6.988281 C 13.566406 6.671875 13.859375 6.339844 13.964844 6.074219 C 14.035156 5.914062 14.046875 5.355469 14.042969 3.097656 C 14.035156 0.542969 14.042969 0.304688 14.136719 0.160156 L 14.238281 0 L 28.691406 0 L 29.0625 0.347656 L 29.429688 0.691406 L 29.441406 3.34375 C 29.46875 6.988281 29.46875 6.972656 29.253906 7.1875 C 29.128906 7.316406 29.003906 7.371094 28.796875 7.394531 C 28.640625 7.414062 26.457031 7.414062 23.941406 7.382812 C 21.429688 7.359375 18.386719 7.347656 17.175781 7.351562 L 14.980469 7.371094 L 14.753906 7.515625 C 14.503906 7.667969 14.21875 8.027344 14.082031 8.351562 C 14.019531 8.511719 14 8.988281 14 10.765625 L 14 12.980469 L 14.207031 13.292969 C 14.417969 13.609375 14.753906 13.875 15.128906 14.03125 C 15.300781 14.101562 15.910156 14.117188 18.640625 14.117188 C 21.269531 14.117188 21.964844 14.132812 22.023438 14.191406 C 22.085938 14.253906 22.109375 15.191406 22.121094 18.902344 C 22.132812 23.0625 22.125 23.546875 22.035156 23.675781 C 21.828125 24.003906 22.019531 23.992188 15.632812 23.992188 C 10.058594 23.992188 9.722656 23.988281 9.679688 23.890625 Z M 9.679688 23.890625 " />
</svg>
);
export const Logo = (props: React.ComponentProps<"svg">) => (
<svg
fill="currentColor"
viewBox="0 0 114 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path d="M 0.09375 23.921875 C 0 23.851562 -0.00390625 23.640625 0.0078125 21.367188 L 0.0234375 18.890625 L 3.324219 18.871094 L 6.628906 18.859375 L 6.664062 18.992188 C 6.683594 19.070312 6.699219 20.191406 6.699219 21.492188 C 6.699219 23.246094 6.683594 23.867188 6.628906 23.921875 C 6.523438 24.027344 0.238281 24.023438 0.09375 23.921875 Z M 0.09375 23.921875 " />
<path d="M 9.679688 23.890625 C 9.625 23.75 9.625 19.289062 9.6875 19.085938 L 9.726562 18.925781 L 11.4375 18.90625 L 13.152344 18.890625 L 13.371094 18.722656 C 13.492188 18.628906 13.632812 18.492188 13.679688 18.40625 C 13.8125 18.214844 13.851562 17.328125 13.792969 15.925781 C 13.75 14.808594 13.75 14.800781 13.5625 14.492188 C 13.460938 14.320312 13.265625 14.089844 13.121094 13.984375 L 12.875 13.792969 L 10.101562 13.792969 C 7.679688 13.789062 7.316406 13.773438 7.242188 13.699219 C 7.15625 13.613281 7.140625 13.25 7.117188 11.023438 C 7.09375 8.96875 7.070312 8.414062 7.003906 8.296875 C 6.832031 7.984375 6.570312 7.707031 6.28125 7.515625 L 5.976562 7.316406 L 3.082031 7.335938 C 0.457031 7.347656 0.179688 7.335938 0.0898438 7.246094 C 0 7.15625 -0.00390625 6.832031 0.0078125 3.640625 C 0.0195312 1.707031 0.046875 0.109375 0.0703125 0.078125 C 0.148438 0.0078125 7.121094 -0.0195312 7.195312 0.0546875 C 7.230469 0.0898438 7.265625 1.097656 7.277344 2.816406 C 7.289062 4.304688 7.3125 5.65625 7.328125 5.832031 C 7.355469 6.085938 7.414062 6.203125 7.679688 6.546875 C 8.230469 7.257812 8.042969 7.210938 10.617188 7.226562 L 12.847656 7.246094 L 13.167969 6.988281 C 13.566406 6.671875 13.859375 6.339844 13.964844 6.074219 C 14.035156 5.914062 14.046875 5.355469 14.042969 3.097656 C 14.035156 0.542969 14.042969 0.304688 14.136719 0.160156 L 14.238281 0 L 28.691406 0 L 29.0625 0.347656 L 29.429688 0.691406 L 29.441406 3.34375 C 29.46875 6.988281 29.46875 6.972656 29.253906 7.1875 C 29.128906 7.316406 29.003906 7.371094 28.796875 7.394531 C 28.640625 7.414062 26.457031 7.414062 23.941406 7.382812 C 21.429688 7.359375 18.386719 7.347656 17.175781 7.351562 L 14.980469 7.371094 L 14.753906 7.515625 C 14.503906 7.667969 14.21875 8.027344 14.082031 8.351562 C 14.019531 8.511719 14 8.988281 14 10.765625 L 14 12.980469 L 14.207031 13.292969 C 14.417969 13.609375 14.753906 13.875 15.128906 14.03125 C 15.300781 14.101562 15.910156 14.117188 18.640625 14.117188 C 21.269531 14.117188 21.964844 14.132812 22.023438 14.191406 C 22.085938 14.253906 22.109375 15.191406 22.121094 18.902344 C 22.132812 23.0625 22.125 23.546875 22.035156 23.675781 C 21.828125 24.003906 22.019531 23.992188 15.632812 23.992188 C 10.058594 23.992188 9.722656 23.988281 9.679688 23.890625 Z M 9.679688 23.890625 " />
<path d="M 28.703125 22.320312 C 27.429688 22.054688 26.429688 21.3125 26.058594 20.371094 C 25.652344 19.320312 25.664062 19.492188 25.664062 15.644531 L 25.664062 12.152344 L 25.851562 12.105469 C 26.046875 12.050781 29.984375 11.941406 30.148438 11.984375 C 30.226562 12.003906 30.25 12.128906 30.28125 12.636719 C 30.304688 12.976562 30.332031 13.285156 30.355469 13.316406 C 30.398438 13.386719 37.679688 13.386719 37.792969 13.316406 C 37.859375 13.273438 37.875 13.082031 37.863281 12.378906 C 37.851562 11.398438 37.800781 11.238281 37.425781 10.96875 C 37.234375 10.832031 37.234375 10.832031 34.851562 10.832031 C 33.539062 10.832031 32.445312 10.808594 32.417969 10.785156 C 32.394531 10.753906 32.363281 9.957031 32.351562 9 C 32.339844 7.652344 32.351562 7.25 32.40625 7.214844 C 32.445312 7.191406 33.835938 7.167969 35.484375 7.164062 C 38.652344 7.15625 39.199219 7.191406 40.023438 7.453125 C 41.371094 7.871094 42.136719 8.703125 42.421875 10.058594 C 42.523438 10.527344 42.542969 10.878906 42.554688 12.863281 L 42.566406 15.128906 L 42.40625 15.519531 C 42.167969 16.089844 41.90625 16.335938 41.269531 16.542969 C 41.160156 16.582031 39.171875 16.597656 35.769531 16.59375 L 30.445312 16.585938 L 30.410156 16.789062 C 30.386719 16.902344 30.382812 17.296875 30.394531 17.667969 L 30.410156 18.328125 L 30.683594 18.589844 C 30.902344 18.800781 31.011719 18.855469 31.265625 18.890625 C 31.4375 18.914062 34.007812 18.925781 36.972656 18.914062 C 41.722656 18.890625 42.382812 18.902344 42.429688 18.980469 C 42.464844 19.027344 42.488281 19.773438 42.488281 20.6875 C 42.488281 22.171875 42.480469 22.316406 42.386719 22.359375 C 42.328125 22.386719 39.34375 22.417969 35.753906 22.421875 C 29.574219 22.433594 29.195312 22.429688 28.703125 22.320312 Z M 28.703125 22.320312 " />
<path d="M 56.488281 22.398438 C 56.261719 22.382812 56.203125 22.351562 56.171875 22.246094 C 56.15625 22.171875 56.136719 19.996094 56.132812 17.398438 C 56.125 14.808594 56.109375 12.28125 56.085938 11.789062 L 56.050781 10.890625 L 55.8125 10.832031 C 55.679688 10.804688 55.539062 10.75 55.503906 10.726562 C 55.445312 10.691406 55.425781 10.277344 55.425781 9.007812 L 55.425781 7.339844 L 55.59375 7.339844 C 55.679688 7.339844 55.816406 7.300781 55.890625 7.246094 C 56.019531 7.15625 56.019531 7.144531 56.019531 5.382812 C 56.019531 4.058594 56.042969 3.503906 56.109375 3.191406 C 56.28125 2.371094 56.839844 1.5 57.414062 1.15625 C 57.847656 0.90625 58.625 0.675781 59.285156 0.597656 C 59.996094 0.511719 65.769531 0.53125 65.851562 0.613281 C 65.929688 0.699219 65.949219 4.230469 65.867188 4.332031 C 65.8125 4.40625 65.402344 4.417969 63.53125 4.417969 C 61.070312 4.417969 61.1875 4.398438 60.914062 4.769531 C 60.773438 4.972656 60.765625 5 60.765625 5.992188 C 60.765625 6.554688 60.792969 7.042969 60.816406 7.078125 C 60.84375 7.125 61.554688 7.15625 63.0625 7.179688 C 64.28125 7.199219 65.308594 7.234375 65.34375 7.257812 C 65.46875 7.335938 65.332031 10.714844 65.195312 10.84375 C 65.171875 10.875 64.179688 10.898438 62.992188 10.910156 L 60.839844 10.929688 L 60.796875 13.359375 C 60.773438 14.695312 60.765625 17.242188 60.785156 19.023438 C 60.816406 21.863281 60.808594 22.273438 60.730469 22.351562 C 60.65625 22.429688 60.359375 22.441406 58.703125 22.429688 C 57.632812 22.421875 56.636719 22.410156 56.488281 22.398438 Z M 56.488281 22.398438 " />
<path d="M 69.371094 22.320312 C 68.445312 22.121094 67.640625 21.726562 67.234375 21.265625 C 66.945312 20.929688 66.566406 20.171875 66.421875 19.636719 C 66.328125 19.285156 66.316406 18.859375 66.316406 14.921875 C 66.316406 10.648438 66.316406 10.589844 66.445312 10.027344 C 66.714844 8.910156 67.332031 8.007812 68.0625 7.675781 C 68.59375 7.4375 69.273438 7.289062 70.027344 7.257812 C 71.484375 7.1875 78.792969 7.164062 79.515625 7.222656 C 80.472656 7.300781 81.105469 7.492188 81.6875 7.882812 C 82.226562 8.25 82.488281 8.546875 82.792969 9.144531 C 83.207031 9.980469 83.230469 10.160156 83.230469 12.890625 C 83.230469 15.230469 83.226562 15.316406 83.105469 15.582031 C 82.875 16.066406 82.335938 16.507812 81.878906 16.570312 C 81.742188 16.585938 79.238281 16.609375 76.316406 16.617188 L 71.003906 16.621094 L 70.988281 17.339844 C 70.980469 17.730469 70.988281 18.121094 71.003906 18.199219 C 71.058594 18.40625 71.484375 18.789062 71.707031 18.832031 C 71.8125 18.84375 74.382812 18.867188 77.425781 18.878906 C 80.472656 18.882812 82.980469 18.914062 83.003906 18.945312 C 83.027344 18.96875 83.058594 19.71875 83.070312 20.613281 C 83.089844 22.066406 83.082031 22.257812 82.992188 22.347656 C 82.902344 22.433594 82.351562 22.441406 76.386719 22.433594 C 70.335938 22.429688 69.839844 22.421875 69.371094 22.320312 Z M 78.441406 13.296875 C 78.542969 13.195312 78.535156 11.542969 78.433594 11.339844 C 78.394531 11.261719 78.246094 11.113281 78.109375 11.019531 L 77.855469 10.832031 L 74.726562 10.851562 L 71.597656 10.863281 L 71.332031 11.03125 C 71.183594 11.121094 71.035156 11.261719 70.992188 11.339844 C 70.910156 11.5 70.890625 12.308594 70.957031 12.96875 L 70.992188 13.371094 L 74.679688 13.371094 C 77.472656 13.371094 78.386719 13.351562 78.441406 13.296875 Z M 78.441406 13.296875 " />
<path d="M 85.308594 22.398438 C 85.148438 22.386719 84.980469 22.347656 84.941406 22.308594 C 84.875 22.261719 84.863281 21.082031 84.878906 16.269531 C 84.898438 9.628906 84.878906 9.925781 85.308594 9.042969 C 85.710938 8.195312 86.3125 7.710938 87.367188 7.390625 C 87.792969 7.257812 87.867188 7.257812 91.640625 7.234375 C 95.398438 7.214844 95.480469 7.214844 95.539062 7.335938 C 95.617188 7.476562 95.539062 8.300781 95.335938 9.488281 C 95.253906 9.96875 95.1875 10.4375 95.1875 10.535156 C 95.1875 10.636719 95.148438 10.777344 95.09375 10.851562 L 95.003906 10.980469 L 92.703125 10.980469 C 91.433594 10.980469 90.316406 11 90.222656 11.019531 C 90.125 11.035156 89.914062 11.167969 89.753906 11.308594 L 89.460938 11.566406 L 89.425781 16.921875 C 89.402344 20.996094 89.375 22.285156 89.320312 22.339844 C 89.265625 22.394531 88.773438 22.410156 87.425781 22.417969 C 86.421875 22.417969 85.472656 22.410156 85.308594 22.398438 Z M 85.308594 22.398438 " />
<path d="M 99.632812 22.351562 C 98.210938 22.089844 97.1875 21.300781 96.738281 20.113281 C 96.394531 19.21875 96.371094 18.832031 96.386719 14.527344 L 96.410156 10.652344 L 96.566406 10.0625 C 96.75 9.394531 97.113281 8.671875 97.433594 8.339844 C 97.871094 7.882812 98.511719 7.566406 99.4375 7.363281 C 99.855469 7.269531 100.410156 7.257812 104.597656 7.226562 L 109.285156 7.191406 L 109.300781 3.898438 C 109.3125 0.835938 109.320312 0.597656 109.421875 0.542969 C 109.582031 0.460938 113.820312 0.453125 113.917969 0.542969 C 113.992188 0.597656 114.003906 1.929688 113.996094 9.214844 C 113.996094 13.941406 113.972656 18.179688 113.945312 18.621094 C 113.882812 19.539062 113.789062 19.964844 113.492188 20.574219 C 113.078125 21.445312 112.414062 21.964844 111.300781 22.273438 C 110.828125 22.410156 110.828125 22.410156 105.457031 22.421875 C 101.066406 22.429688 100.003906 22.417969 99.632812 22.351562 Z M 108.898438 18.632812 C 108.980469 18.574219 109.105469 18.429688 109.183594 18.324219 L 109.3125 18.125 L 109.300781 14.570312 L 109.285156 11.011719 L 101.527344 11.011719 L 101.265625 11.296875 L 101.003906 11.589844 L 101.003906 14.734375 C 101.003906 17.882812 101.003906 17.882812 101.140625 18.144531 C 101.214844 18.289062 101.375 18.492188 101.5 18.585938 L 101.730469 18.769531 L 104.941406 18.800781 C 106.707031 18.820312 108.289062 18.8125 108.453125 18.789062 C 108.613281 18.765625 108.816406 18.691406 108.898438 18.632812 Z M 108.898438 18.632812 " />
<path d="M 44.839844 22.011719 C 44.8125 21.804688 44.796875 19.238281 44.785156 16.304688 C 44.773438 11.644531 44.761719 10.96875 44.683594 10.921875 C 44.636719 10.890625 44.371094 10.863281 44.089844 10.863281 C 43.8125 10.863281 43.542969 10.832031 43.496094 10.804688 C 43.417969 10.753906 43.402344 10.488281 43.390625 9.125 C 43.378906 8.238281 43.382812 7.441406 43.402344 7.363281 C 43.4375 7.234375 43.460938 7.226562 44.109375 7.210938 L 44.773438 7.191406 L 44.808594 5.402344 C 44.832031 4.417969 44.875 3.460938 44.914062 3.28125 C 45.230469 1.773438 45.996094 0.992188 47.472656 0.675781 C 47.925781 0.578125 48.328125 0.566406 51.3125 0.566406 C 53.148438 0.566406 54.667969 0.585938 54.683594 0.609375 C 54.707031 0.632812 54.730469 1.492188 54.738281 2.519531 L 54.742188 4.386719 L 52.328125 4.417969 L 49.917969 4.445312 L 49.765625 4.589844 C 49.675781 4.671875 49.582031 4.847656 49.542969 4.976562 C 49.457031 5.300781 49.527344 7.109375 49.628906 7.179688 C 49.667969 7.203125 50.179688 7.222656 50.765625 7.214844 C 51.355469 7.210938 52.328125 7.210938 52.925781 7.214844 L 54.011719 7.222656 L 53.988281 8.945312 C 53.972656 10.273438 53.949219 10.691406 53.890625 10.765625 C 53.816406 10.851562 53.566406 10.863281 51.679688 10.863281 C 50.507812 10.863281 49.542969 10.867188 49.539062 10.878906 C 49.527344 10.886719 49.507812 13.472656 49.492188 16.621094 L 49.460938 22.351562 L 47.164062 22.371094 L 44.875 22.382812 Z M 44.839844 22.011719 " />
</svg>
);
@@ -0,0 +1,35 @@
import { CosmicButton } from "@/components/ui/cosmic-button";
import { MapIcon } from "@/components/ui/phosphor-icons";
interface MapPageHeaderProps {
title: string;
description: string;
onOpenMap: () => void;
buttonText: string;
icon?: React.ReactNode;
children?: React.ReactNode;
}
export function MapPageHeader({ title, description, onOpenMap, buttonText, icon, children }: MapPageHeaderProps) {
return (
<div className="flex flex-col justify-between gap-4 border-b pb-5 md:flex-row md:items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight">{title}</h1>
<p className="text-muted-foreground">{description}</p>
</div>
<div className="flex items-center gap-3">
<CosmicButton
as="button"
size="sm"
onClick={onOpenMap}
>
<span className="flex items-center gap-1.5">
{icon ?? <MapIcon className="h-4 w-4" />}
<span className="capitalize">{buttonText}</span>
</span>
</CosmicButton>
{children}
</div>
</div>
);
}
@@ -0,0 +1,82 @@
"use client";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@/components/ui/breadcrumb";
import { HouseIcon, GlobeHemisphereWestIcon, CaretDoubleRightIcon } from "@/components/ui/phosphor-icons";
import type { ReactNode } from "react";
interface MapsBreadcrumbProps {
currentPage: string;
currentIcon?: ReactNode;
isLeafletOpen?: boolean;
onExitMap?: () => void;
}
export function MapsBreadcrumb({ currentPage, currentIcon, isLeafletOpen = false, onExitMap }: MapsBreadcrumbProps) {
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/dashboard" className="flex items-center gap-1">
<HouseIcon className="size-3.5" />
Beranda
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator>
<CaretDoubleRightIcon />
</BreadcrumbSeparator>
{!isLeafletOpen ? (
<BreadcrumbItem>
<BreadcrumbPage className="flex items-center gap-1">
{currentIcon}
{currentPage}
</BreadcrumbPage>
</BreadcrumbItem>
) : (
<>
<BreadcrumbItem>
{onExitMap ? (
<BreadcrumbLink
render={<button type="button" onClick={onExitMap} />}
className="flex items-center gap-1"
>
{currentIcon}
{currentPage}
</BreadcrumbLink>
) : (
<BreadcrumbLink
href={
currentPage === "Peta Kemiskinan" || currentPage === "Poverty" ? "/dashboard/poverty" :
currentPage === "Lahan dan Jalan" || currentPage === "Lands and Roads" ? "/dashboard/land" :
currentPage === "SPBU dan Charger" || currentPage === "Gas Stations" ? "/dashboard/gas-station" :
"/dashboard"
}
className="flex items-center gap-1"
>
{currentIcon}
{currentPage}
</BreadcrumbLink>
)}
</BreadcrumbItem>
<BreadcrumbSeparator>
<CaretDoubleRightIcon />
</BreadcrumbSeparator>
<BreadcrumbItem>
<BreadcrumbPage className="flex items-center gap-1">
<GlobeHemisphereWestIcon className="size-3.5" />
Peta
</BreadcrumbPage>
</BreadcrumbItem>
</>
)}
</BreadcrumbList>
</Breadcrumb>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { cn } from "@/lib/utils";
import React from "react";
import { Button } from "@/components/ui/button";
import { Portal, PortalBackdrop } from "@/components/portal";
import { navLinks } from "@/components/header";
import { XIcon, MenuIcon } from "lucide-react";
export function MobileNav() {
const [open, setOpen] = React.useState(false);
return (
<div className="md:hidden">
<Button
aria-controls="mobile-menu"
aria-expanded={open}
aria-label="Toggle menu"
className="md:hidden"
onClick={() => setOpen(!open)}
size="icon"
variant="outline"
>
{open ? (
<XIcon className="size-4.5" />
) : (
<MenuIcon className="size-4.5" />
)}
</Button>
{open && (
<Portal className="top-14" id="mobile-menu">
<PortalBackdrop />
<div
className={cn(
"data-[slot=open]:zoom-in-97 ease-out data-[slot=open]:animate-in",
"size-full p-4"
)}
data-slot={open ? "open" : "closed"}
>
<div className="grid gap-y-2">
{navLinks.map((link) => (
<Button className="justify-start" key={link.label} variant="ghost" render={<a href={link.href} />} nativeButton={false}>{link.label}</Button>
))}
</div>
<div className="mt-12 flex flex-col gap-2">
<Button className="w-full" variant="outline">
Sign In
</Button>
<Button className="w-full">Get Started</Button>
</div>
</div>
</Portal>
)}
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "@/components/ui/sidebar";
import type { SidebarNavGroup } from "@/components/app-shared";
import { ChevronRightIcon } from "@/components/ui/phosphor-icons";
export function NavGroup({ label, items }: SidebarNavGroup) {
return (
<SidebarGroup>
{label && <SidebarGroupLabel>{label}</SidebarGroupLabel>}
<SidebarMenu>
{items.map((item) => (
<Collapsible className="group/collapsible" defaultOpen={
!!item.isActive ||
item.subItems?.some((i) => !!i.isActive)
} key={item.title} render={<SidebarMenuItem />}>{item.subItems?.length ? (
<>
<CollapsibleTrigger render={<SidebarMenuButton isActive={item.isActive} tooltip={item.title} />}>{item.icon}<span>{item.title}</span><ChevronRightIcon className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" /></CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.subItems?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton isActive={subItem.isActive} render={<a href={subItem.path} />}>{subItem.icon}<span>{subItem.title}</span></SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</>
) : (
<SidebarMenuButton isActive={item.isActive} tooltip={item.title} render={<a href={item.path} />}>{item.icon}<span>{item.title}</span></SidebarMenuButton>
)}</Collapsible>
))}
</SidebarMenu>
</SidebarGroup>
);
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import {
Avatar,
AvatarFallback,
} from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SettingsIcon, LogOutIcon } from "@/components/ui/phosphor-icons";
import { useAuth } from "@/lib/auth-context";
export function NavUser() {
const { user, logout } = useAuth();
const displayName = user?.username ?? "User";
const displayEmail = user?.email ?? "";
const initials = displayName.charAt(0).toUpperCase();
return (
<DropdownMenu>
<DropdownMenuTrigger nativeButton={false} render={<Avatar className="size-8" />}>
<AvatarFallback>{initials}</AvatarFallback>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuItem className="flex items-center justify-start gap-2">
<div className="flex items-center gap-3">
<Avatar className="size-10">
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div>
<span className="font-medium text-foreground">{displayName}</span>
<br />
<div className="max-w-full overflow-hidden overflow-ellipsis whitespace-nowrap text-muted-foreground text-xs">
{displayEmail}
</div>
</div>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<SettingsIcon />
Pengaturan
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
className="w-full cursor-pointer"
variant="destructive"
onClick={logout}
>
<LogOutIcon />
Keluar
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { cn } from "@/lib/utils";
import React from "react";
import { createPortal } from "react-dom";
function Portal({ className, ...props }: React.ComponentProps<"div">) {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
const originalStyle = window.getComputedStyle(document.body).overflow;
const scrollbarWidth =
window.innerWidth - document.documentElement.clientWidth;
const originalPaddingRight = document.body.style.paddingRight;
document.body.style.overflow = "hidden";
if (scrollbarWidth > 0) {
document.body.style.paddingRight = `${scrollbarWidth}px`;
}
return () => {
document.body.style.overflow = originalStyle;
document.body.style.paddingRight = originalPaddingRight;
};
}, []);
if (!mounted) {
return null;
}
return createPortal(
<div
className={cn("fixed inset-0 isolate z-40 flex flex-col", className)}
{...props}
/>,
document.body
);
}
function PortalBackdrop({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 -z-1 bg-background/95 backdrop-blur-sm duration-500 data-[state=closed]:animate-out data-[state=open]:animate-in supports-backdrop-filter:bg-background/60",
className
)}
{...props}
/>
);
}
export { Portal, PortalBackdrop };
@@ -0,0 +1,120 @@
"use client";
import { useEffect, useState } from "react";
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { DashboardCard } from "@/components/dashboard-card";
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000";
const LABELS = [
"SANGAT TINGGI",
"TINGGI",
"SEDANG",
"RENDAH",
"TIDAK PRIORITAS",
] as const;
const LABEL_STYLES: Record<string, { bar: string; text: string; dot: string }> = {
"SANGAT TINGGI": { bar: "bg-red-500", text: "text-red-700", dot: "bg-red-500" },
"TINGGI": { bar: "bg-orange-500", text: "text-orange-700", dot: "bg-orange-500" },
"SEDANG": { bar: "bg-amber-500", text: "text-amber-700", dot: "bg-amber-500" },
"RENDAH": { bar: "bg-emerald-500", text: "text-emerald-700", dot: "bg-emerald-500" },
"TIDAK PRIORITAS": { bar: "bg-blue-500", text: "text-blue-700", dot: "bg-blue-500" },
};
export function FuzzyPriorityStats({
refreshKey,
onRecomputed,
}: {
refreshKey?: number;
onRecomputed?: () => void;
}) {
const [stats, setStats] = useState<Record<string, number> | null>(null);
const [recomputing, setRecomputing] = useState(false);
const [recomputeMsg, setRecomputeMsg] = useState<string | null>(null);
useEffect(() => {
fetch(`${BACKEND_URL}/households/stats-fuzzy`)
.then((r) => r.json())
.then(setStats)
.catch(() => setStats(null));
}, [refreshKey]);
async function handleRecompute() {
setRecomputing(true);
setRecomputeMsg(null);
try {
const token = localStorage.getItem("auth_token");
const res = await fetch(`${BACKEND_URL}/households/recompute-fuzzy`, {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? "Gagal menghitung ulang");
}
const data = await res.json();
setRecomputeMsg(`${data.updated} dari ${data.total} warga dihitung ulang`);
onRecomputed?.();
} catch (err) {
setRecomputeMsg(err instanceof Error ? err.message : "Terjadi kesalahan");
} finally {
setRecomputing(false);
}
}
if (!stats) return null;
const total = LABELS.reduce((sum, l) => sum + (stats[l] ?? 0), 0);
if (total === 0) return null;
return (
<div className="md:col-span-2 lg:col-span-4 flex flex-col h-full">
<DashboardCard className="gap-0 h-full py-0">
<CardHeader className="border-b pt-4 flex flex-row items-center justify-between">
<div>
<CardTitle className="text-base">Distribusi Prioritas Fuzzy</CardTitle>
<CardDescription>
{recomputeMsg ?? `${total} warga teranalisis oleh sistem fuzzy`}
</CardDescription>
</div>
<button
type="button"
onClick={handleRecompute}
disabled={recomputing}
className="h-8 shrink-0 rounded-full border border-border px-4 text-xs font-medium transition-colors hover:bg-muted disabled:opacity-50"
>
{recomputing ? "Menghitung…" : "Hitung Ulang Semua"}
</button>
</CardHeader>
<CardContent className="px-0 py-0 flex-1 flex flex-col">
<div className="grid grid-cols-5 divide-x flex-1">
{LABELS.map((label) => {
const count = stats[label] ?? 0;
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
const style = LABEL_STYLES[label];
return (
<div key={label} className="flex flex-col items-center gap-2 px-4 py-5">
<div className="flex items-center gap-1.5">
<span className={`h-2 w-2 rounded-full shrink-0 ${style.dot}`} />
<span className={`text-[11px] font-medium leading-tight text-center ${style.text}`}>
{label}
</span>
</div>
<span className="text-2xl font-bold tabular-nums">{count}</span>
<div className="w-full h-1.5 rounded-full bg-border overflow-hidden">
<div
className={`h-full rounded-full transition-all ${style.bar}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums">{pct}%</span>
</div>
);
})}
</div>
</CardContent>
</DashboardCard>
</div>
);
}
@@ -0,0 +1,138 @@
"use client";
import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { UserMarker } from "@/components/leaflet-map";
export const FUZZY_BADGE: Record<string, { bg: string; text: string; bar: string }> = {
"SANGAT TINGGI": { bg: "#fef2f2", text: "#b91c1c", bar: "#ef4444" },
"TINGGI": { bg: "#fff7ed", text: "#c2410c", bar: "#f97316" },
"SEDANG": { bg: "#fffbeb", text: "#b45309", bar: "#f59e0b" },
"RENDAH": { bg: "#f0fdf4", text: "#15803d", bar: "#10b981" },
"TIDAK PRIORITAS": { bg: "#eff6ff", text: "#1d4ed8", bar: "#6b7280" },
};
const FACTOR_LABELS: Record<string, string> = {
penghasilan: "Penghasilan",
tanggungan: "Tanggungan",
kondisi_rumah: "Kondisi Rumah",
kepemilikan_aset: "Kepemilikan Aset",
};
interface FuzzyResultStepProps {
marker: UserMarker;
confirmLabel: string;
onConfirm: () => void;
}
export function FuzzyResultStep({ marker, confirmLabel, onConfirm }: FuzzyResultStepProps) {
const label = marker.meta?.fuzzy_label ?? "";
const score = marker.meta?.fuzzy_score ?? null;
const detail = marker.meta?.fuzzy_detail ?? null;
const badge = FUZZY_BADGE[label] ?? { bg: "#f3f4f6", text: "#374151", bar: "#6b7280" };
return (
<div className="flex flex-col">
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<DialogTitle className="text-center text-base font-semibold">
Hasil Analisis Fuzzy
</DialogTitle>
<p className="text-center text-[11px] text-muted-foreground mt-0.5">
{marker.name}
</p>
</DialogHeader>
<div className="flex flex-col gap-5 px-6 py-6">
{/* Priority badge */}
<div className="flex flex-col items-center gap-2">
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
Tingkat Prioritas
</span>
<span
className="px-5 py-1.5 rounded-full text-sm font-bold"
style={{ backgroundColor: badge.bg, color: badge.text }}
>
{label || "—"}
</span>
</div>
{/* Score bar */}
{score != null && (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">Skor Fuzzy</span>
<span className="text-sm font-bold tabular-nums">{score.toFixed(1)}</span>
</div>
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full"
style={{ width: `${score}%`, backgroundColor: badge.bar }}
/>
</div>
<div className="flex justify-between text-[9px] text-muted-foreground">
<span>0</span>
<span>100</span>
</div>
</div>
)}
{/* Factor breakdown — dominant fuzzy category per input */}
{detail && (
<div className="flex flex-col gap-2">
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
Faktor Penilaian
</span>
<div className="rounded-xl border border-border divide-y divide-border overflow-hidden">
{Object.entries(FACTOR_LABELS).map(([key, name]) => {
const f = detail[key];
if (!f) return null;
return (
<div key={key} className="flex items-center justify-between px-4 py-2 text-[11px]">
<span className="text-muted-foreground">{name}</span>
<span className="flex items-center gap-2">
<span className="font-medium">{f.kategori}</span>
<span className="w-12 h-1 rounded-full bg-muted overflow-hidden">
<span
className="block h-full rounded-full"
style={{ width: `${Math.round(f.derajat * 100)}%`, backgroundColor: badge.bar }}
/>
</span>
</span>
</div>
);
})}
</div>
</div>
)}
{/* Summary grid */}
<div className="rounded-xl bg-muted/50 border border-border px-4 py-3 grid grid-cols-2 gap-y-2 gap-x-6 text-[11px]">
<span className="text-muted-foreground">Kemiskinan</span>
<span className="font-medium text-right">{marker.meta?.poverty_level ?? "—"}</span>
<span className="text-muted-foreground">Tanggungan</span>
<span className="font-medium text-right">{marker.meta?.family_count ?? "—"} jiwa</span>
<span className="text-muted-foreground">Penghasilan</span>
<span className="font-medium text-right">
Rp {(marker.meta?.penghasilan ?? 0).toLocaleString("id-ID")}/bln
</span>
<span className="text-muted-foreground">Koordinat</span>
<span className="font-mono text-right text-[10px]">
{marker.lat.toFixed(5)}, {marker.lng.toFixed(5)}
</span>
</div>
</div>
<div className="flex justify-end px-6 py-4 border-t">
<button
type="button"
onClick={onConfirm}
className="h-9 rounded-full bg-primary text-primary-foreground px-5 text-sm font-medium transition-colors hover:bg-primary/90"
>
{confirmLabel}
</button>
</div>
</div>
);
}
@@ -0,0 +1,473 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
} from "@/components/ui/dropdown-menu";
import { PhosphorIcon } from "@/components/ui/collection-grid-disclosure";
import { ChevronDownIcon } from "@/components/ui/phosphor-icons";
import { cn } from "@/lib/utils";
import { RadiusSlider } from "@/components/poverty/radius-slider";
import type { UserMarker } from "@/components/leaflet-map";
import { TimedUndoAction } from "@/components/time-undo-action";
import { FuzzyResultStep } from "@/components/poverty/fuzzy-result-step";
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000";
const RELIGION_OPTIONS = [
{ value: "mosque", label: "Masjid (Islam)", icon: "mosque" },
{ value: "church", label: "Gereja (Kristen)", icon: "church" },
{ value: "cathedral", label: "Gereja Katolik", icon: "crown-cross" },
{ value: "temple", label: "Pura (Hindu)", icon: "hands-praying" },
{ value: "vihara", label: "Vihara (Buddha)", icon: "hands-praying" },
{ value: "klenteng", label: "Klenteng (Konghucu)", icon: "hands-praying" },
{ value: "synagogue", label: "Sinagog (Yahudi)", icon: "synagogue" },
];
const MARKER_LABELS: Record<string, string> = {
marker: "Warga Miskin",
mosque: "Masjid",
church: "Gereja",
cathedral: "Gereja Katolik",
temple: "Pura",
vihara: "Vihara",
klenteng: "Klenteng",
synagogue: "Sinagog",
clinic: "Klinik",
"food-bank": "Bank Makanan",
school: "Sekolah",
};
function Field({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
return (
<div className={`flex flex-col gap-1.5 ${className ?? ""}`}>
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
{label}
</span>
{children}
</div>
);
}
function AdaptiveSlider({ value, onChange, hint }: { value: number; onChange: (v: number) => void; hint: string }) {
const pct = ((value - 1) / 9) * 100;
const color = value <= 3 ? "#ef4444" : value <= 6 ? "#f59e0b" : "#10b981";
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<input
type="range" min={1} max={10} value={value}
onChange={(e) => onChange(Number(e.target.value))}
style={{ background: `linear-gradient(to right, ${color} 0%, ${color} ${pct}%, var(--border) ${pct}%, var(--border) 100%)` }}
className="flex-1 h-[3px] cursor-pointer appearance-none rounded-full outline-none [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow [&::-webkit-slider-thumb]:border [&::-webkit-slider-thumb]:border-border"
/>
<span className="w-6 shrink-0 text-center text-xs font-bold tabular-nums" style={{ color }}>{value}</span>
</div>
<p className="text-[10px] text-muted-foreground">{hint}</p>
</div>
);
}
interface MarkerEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
marker: UserMarker | null;
onUpdated: (updated: UserMarker) => void;
onDeleted: (deleted: UserMarker) => void;
}
export function MarkerEditDialog({ open, onOpenChange, marker, onUpdated, onDeleted }: MarkerEditDialogProps) {
const isHousehold = marker?.id.startsWith("hh-");
const isPOI = marker?.id.startsWith("poi-");
const isReligion = isPOI && RELIGION_OPTIONS.some((o) => o.value === marker?.type);
// Household fields
const [headName, setHeadName] = useState("");
const [familyCount, setFamilyCount] = useState(3);
const [penghasilan, setPenghasilan] = useState(1500000);
const [kondisiRumah, setKondisiRumah] = useState(5);
const [kepemilikanAset, setKepemilikanAset] = useState(5);
const [notes, setNotes] = useState("");
// POI fields
const [placeName, setPlaceName] = useState("");
const [religionType, setReligionType] = useState<string | null>(null);
const [radiusMeters, setRadiusMeters] = useState(0);
const selectedReligion = RELIGION_OPTIONS.find((o) => o.value === religionType);
const DELETE_SECONDS = 5;
const TOAST_ID = "marker-delete";
const [isLoading, setIsLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Post-save fuzzy result step (household only)
const [step, setStep] = useState<"form" | "result">("form");
const [savedMarker, setSavedMarker] = useState<UserMarker | null>(null);
// Delete countdown — lives here so it survives dialog close
const [deleteActive, setDeleteActive] = useState(false);
const [deleteCountdown, setDeleteCountdown] = useState(DELETE_SECONDS);
// Holds the marker being deleted so handleDelete works after dialog closes
const pendingDeleteMarker = useRef<UserMarker | null>(null);
// Reset only when a DIFFERENT non-null marker is opened — skip on dialog close (marker → null)
useEffect(() => {
if (!marker?.id) return;
setDeleteActive(false);
setDeleteCountdown(DELETE_SECONDS);
pendingDeleteMarker.current = null;
toast.dismiss(TOAST_ID);
setStep("form");
setSavedMarker(null);
}, [marker?.id]);
// Tick
useEffect(() => {
if (!deleteActive) return;
const id = setInterval(() => setDeleteCountdown((p) => Math.max(0, p - 1)), 1000);
return () => clearInterval(id);
}, [deleteActive]);
// Fire when countdown hits 0
useEffect(() => {
if (!deleteActive || deleteCountdown > 0) return;
setDeleteActive(false);
setDeleteCountdown(DELETE_SECONDS);
toast.dismiss(TOAST_ID);
handleDelete();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deleteActive, deleteCountdown]);
// Show / update toast while countdown is running
useEffect(() => {
if (!deleteActive || !pendingDeleteMarker.current) {
toast.dismiss(TOAST_ID);
return;
}
const name = pendingDeleteMarker.current.name;
const pct = (deleteCountdown / DELETE_SECONDS) * 100;
toast.custom(
() => (
<div className="relative flex items-center gap-3 w-72 rounded-xl border border-border bg-popover px-4 py-3 shadow-lg overflow-hidden font-sans">
<div
className="absolute bottom-0 left-0 h-[3px] bg-red-500 transition-[width] duration-1000 ease-linear"
style={{ width: `${pct}%` }}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-foreground truncate">Menghapus marker</p>
<p className="text-xs text-muted-foreground truncate">"{name}" dalam {deleteCountdown} detik</p>
</div>
<button
onClick={() => {
setDeleteActive(false);
setDeleteCountdown(DELETE_SECONDS);
pendingDeleteMarker.current = null;
toast.dismiss(TOAST_ID);
}}
className="shrink-0 h-7 rounded-full border border-border bg-background px-3 text-xs font-medium hover:bg-muted transition-colors cursor-pointer"
>
Batalkan
</button>
</div>
),
{ id: TOAST_ID, duration: Infinity, position: "bottom-left" },
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deleteActive, deleteCountdown]);
function toggleDelete() {
setDeleteActive((prev) => {
if (!prev) {
pendingDeleteMarker.current = marker;
setDeleteCountdown(DELETE_SECONDS);
} else {
pendingDeleteMarker.current = null;
toast.dismiss(TOAST_ID);
}
return !prev;
});
}
// Load existing data when dialog opens
useEffect(() => {
if (!open || !marker) return;
const dbId = marker.id.split("-").slice(1).join("-");
const token = localStorage.getItem("auth_token");
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {} as Record<string, string>;
setIsLoading(true);
setError(null);
if (isHousehold) {
fetch(`${BACKEND_URL}/households`, { headers })
.then((r) => r.json())
.then((rows: any[]) => {
const row = rows.find((r: any) => String(r.id) === dbId);
if (row) {
setHeadName(row.head_name ?? "");
setFamilyCount(row.family_count ?? 3);
setPenghasilan(row.penghasilan ?? 1500000);
setKondisiRumah(row.kondisi_rumah ?? 5);
setKepemilikanAset(row.kepemilikan_aset ?? 5);
setNotes(row.notes ?? "");
}
})
.catch(() => setError("Gagal memuat data"))
.finally(() => setIsLoading(false));
} else if (isPOI) {
fetch(`${BACKEND_URL}/poi/${dbId}`, { headers })
.then((r) => r.json())
.then((row: any) => {
setPlaceName(row.name ?? "");
setReligionType(row.religion_subtype ?? null);
setRadiusMeters(row.radius_meters ?? 0);
})
.catch(() => setError("Gagal memuat data"))
.finally(() => setIsLoading(false));
} else {
setIsLoading(false);
}
}, [open, marker]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!marker) return;
const dbId = marker.id.split("-").slice(1).join("-");
const token = localStorage.getItem("auth_token");
const headers = { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) };
setIsSubmitting(true);
setError(null);
try {
if (isHousehold) {
const res = await fetch(`${BACKEND_URL}/households/${dbId}`, {
method: "PUT", headers,
body: JSON.stringify({ head_name: headName, family_count: familyCount, notes: notes || null, penghasilan, kondisi_rumah: kondisiRumah, kepemilikan_aset: kepemilikanAset }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? "Gagal menyimpan");
const saved = await res.json();
setSavedMarker({
...marker,
name: saved.head_name,
meta: {
poverty_level: saved.poverty_level,
family_count: saved.family_count,
penghasilan: saved.penghasilan,
fuzzy_label: saved.fuzzy_label ?? undefined,
fuzzy_score: saved.fuzzy_score != null ? parseFloat(saved.fuzzy_score) : undefined,
fuzzy_detail: saved.fuzzy_detail ?? undefined,
notes: saved.notes ?? undefined,
},
});
setStep("result");
setIsSubmitting(false);
return;
} else {
const name = placeName.trim() || (marker.name);
const body: any = { name };
if (isReligion) { body.poi_type = "religion"; body.religion_subtype = religionType; body.radius_meters = radiusMeters; }
const res = await fetch(`${BACKEND_URL}/poi/${dbId}`, {
method: "PUT", headers,
body: JSON.stringify(body),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? "Gagal menyimpan");
onUpdated({
...marker,
name,
...(isReligion && religionType ? { type: religionType } : {}),
...(isReligion ? { meta: { ...marker.meta, radius: radiusMeters } } : {}),
});
}
onOpenChange(false);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Terjadi kesalahan");
} finally {
setIsSubmitting(false);
}
}
async function handleDelete() {
const target = pendingDeleteMarker.current;
if (!target) return;
const dbId = target.id.split("-").slice(1).join("-");
const isTargetHousehold = target.id.startsWith("hh-");
const token = localStorage.getItem("auth_token");
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
setIsDeleting(true);
setError(null);
try {
const url = isTargetHousehold ? `${BACKEND_URL}/households/${dbId}` : `${BACKEND_URL}/poi/${dbId}`;
const res = await fetch(url, { method: "DELETE", headers });
if (!res.ok && res.status !== 204) throw new Error("Gagal menghapus");
pendingDeleteMarker.current = null;
onDeleted(target);
onOpenChange(false);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Terjadi kesalahan");
} finally {
setIsDeleting(false);
}
}
const title = isHousehold
? `Edit ${MARKER_LABELS["marker"]}`
: `Edit ${MARKER_LABELS[marker?.type ?? ""] ?? marker?.type ?? "Marker"}`;
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) { onOpenChange(false); } }}>
<DialogContent showCloseButton={false} className="sm:max-w-lg w-full max-h-[90vh] overflow-y-auto rounded-2xl p-0 gap-0">
{step === "result" && savedMarker && (
<FuzzyResultStep
marker={savedMarker}
confirmLabel="Selesai"
onConfirm={() => {
onUpdated(savedMarker);
setStep("form");
setSavedMarker(null);
onOpenChange(false);
}}
/>
)}
{step === "form" && (
<form onSubmit={handleSubmit} className="flex flex-col">
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<DialogTitle className="text-center text-base font-semibold">{title}</DialogTitle>
{marker && (
<p className="text-center font-mono text-[10px] text-muted-foreground mt-0.5">
{marker.lat.toFixed(6)},&nbsp;{marker.lng.toFixed(6)}
</p>
)}
</DialogHeader>
<div className="flex flex-col gap-5 px-6 py-5">
{isLoading ? (
<p className="text-sm text-muted-foreground text-center py-4">Memuat data</p>
) : isHousehold ? (
<>
<Field label="Nama Kepala Keluarga">
<Input value={headName} onChange={(e) => setHeadName(e.target.value)} required className="bg-muted/50 border border-border focus-visible:border-ring" />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field label="Penghasilan / Bulan">
<div className="relative">
<span className="absolute left-3 top-2.5 text-xs text-muted-foreground font-semibold">Rp</span>
<Input
type="text"
value={penghasilan ? penghasilan.toLocaleString("id-ID") : ""}
onChange={(e) => {
const clean = e.target.value.replace(/\D/g, "");
setPenghasilan(clean ? parseInt(clean, 10) : 0);
}}
className="pl-9 bg-muted/50 border border-border focus-visible:border-ring"
/>
</div>
</Field>
<Field label="Jumlah Tanggungan (jiwa)">
<Input type="number" min={1} max={20} value={familyCount} onChange={(e) => setFamilyCount(Number(e.target.value))} className="bg-muted/50 border border-border focus-visible:border-ring" />
</Field>
</div>
<div className="grid grid-cols-2 gap-4">
<Field label="Kondisi Rumah"><AdaptiveSlider value={kondisiRumah} onChange={setKondisiRumah} hint="1 = sangat buruk · 10 = sangat baik" /></Field>
<Field label="Kepemilikan Aset"><AdaptiveSlider value={kepemilikanAset} onChange={setKepemilikanAset} hint="1 = tidak ada · 10 = banyak" /></Field>
</div>
<Field label="Catatan">
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} className="min-h-[72px] bg-muted/50 border border-border focus-visible:border-ring resize-none" />
</Field>
</>
) : (
<>
{isReligion && (
<Field label="Jenis Tempat Ibadah">
<DropdownMenu>
<DropdownMenuTrigger
className="flex w-full items-center justify-between gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm transition-colors hover:bg-muted focus-visible:border-ring focus-visible:outline-none"
>
<span className="flex min-w-0 items-center gap-2">
{selectedReligion?.icon && (
<span className="size-4 shrink-0 text-foreground">
<PhosphorIcon name={selectedReligion.icon} className="size-4" />
</span>
)}
<span className={cn("truncate", !selectedReligion && "text-muted-foreground")}>
{selectedReligion ? selectedReligion.label : "Pilih jenis…"}
</span>
</span>
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-(--anchor-width)">
<DropdownMenuRadioGroup
value={religionType ?? ""}
onValueChange={setReligionType}
>
{RELIGION_OPTIONS.map((opt) => (
<DropdownMenuRadioItem key={opt.value} value={opt.value}>
<span className="size-4 shrink-0 text-foreground">
<PhosphorIcon name={opt.icon} className="size-4" />
</span>
{opt.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</Field>
)}
<Field label="Nama Tempat">
<Input value={placeName} onChange={(e) => setPlaceName(e.target.value)} className="bg-muted/50 border border-border focus-visible:border-ring" />
</Field>
{isReligion && (
<Field label="Radius Jangkauan (meter)">
<RadiusSlider value={radiusMeters} onChange={setRadiusMeters} />
</Field>
)}
</>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
<div className="flex items-center justify-between gap-2 px-6 py-4 border-t">
<TimedUndoAction
compact
deleteLabel="Hapus Marker"
undoLabel="Batal"
isDeleting={deleteActive}
countDown={deleteCountdown}
onToggle={toggleDelete}
/>
<div className="flex gap-2">
<button type="button" onClick={() => onOpenChange(false)} disabled={isSubmitting}
className="h-9 rounded-full border border-border px-5 text-sm font-medium transition-colors hover:bg-muted disabled:opacity-50">
Batal
</button>
<button type="submit" disabled={isSubmitting || isLoading || (isHousehold && !headName.trim())}
className="h-9 rounded-full bg-primary text-primary-foreground px-5 text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-40">
{isSubmitting ? "Menyimpan…" : "Simpan"}
</button>
</div>
</div>
</form>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,393 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import type { UserMarker } from "@/components/leaflet-map";
import { FuzzyResultStep } from "@/components/poverty/fuzzy-result-step";
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000";
const MARKER_LABELS: Record<string, string> = {
marker: "Warga Miskin",
mosque: "Masjid",
church: "Gereja",
synagogue: "Sinagog",
clinic: "Klinik",
"food-bank": "Bank Makanan",
school: "Sekolah",
};
interface PendingClick {
lat: number;
lng: number;
}
interface MarkerPlacementDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
markerType: string;
pendingClick: PendingClick | null;
onConfirm: (marker: UserMarker) => void;
}
/* ─── Adaptive Slider ─────────────────────────────────────── */
function AdaptiveSlider({
value,
onChange,
hint,
}: {
value: number;
onChange: (v: number) => void;
hint: string;
}) {
const pct = ((value - 1) / 9) * 100;
const color =
value <= 3 ? "#ef4444" : value <= 6 ? "#f59e0b" : "#10b981";
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<input
type="range"
min={1}
max={10}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
style={{
background: `linear-gradient(to right, ${color} 0%, ${color} ${pct}%, var(--border) ${pct}%, var(--border) 100%)`,
}}
className="
flex-1 h-[3px] cursor-pointer appearance-none rounded-full outline-none
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-white
[&::-webkit-slider-thumb]:shadow
[&::-webkit-slider-thumb]:border
[&::-webkit-slider-thumb]:border-border
[&::-moz-range-thumb]:w-3
[&::-moz-range-thumb]:h-3
[&::-moz-range-thumb]:rounded-full
[&::-moz-range-thumb]:bg-white
[&::-moz-range-thumb]:border
[&::-moz-range-thumb]:border-border
"
/>
<span
className="w-6 shrink-0 text-center text-xs font-bold tabular-nums"
style={{ color }}
>
{value}
</span>
</div>
<p className="text-[10px] text-muted-foreground">{hint}</p>
</div>
);
}
/* ─── Field wrapper ───────────────────────────────────────── */
function Field({
label,
children,
className,
}: {
label: string;
children: React.ReactNode;
className?: string;
}) {
return (
<div className={`flex flex-col gap-1.5 ${className ?? ""}`}>
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
{label}
</span>
{children}
</div>
);
}
/* ─── Main dialog ─────────────────────────────────────────── */
export function MarkerPlacementDialog({
open,
onOpenChange,
markerType,
pendingClick,
onConfirm,
}: MarkerPlacementDialogProps) {
const isHousehold = markerType === "marker";
const [headName, setHeadName] = useState("");
const [familyCount, setFamilyCount] = useState(3);
const [penghasilan, setPenghasilan] = useState(1500000);
const [kondisiRumah, setKondisiRumah] = useState(5);
const [kepemilikanAset, setKepemilikanAset] = useState(5);
const [notes, setNotes] = useState("");
const [placeName, setPlaceName] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [step, setStep] = useState<"form" | "result">("form");
const [savedMarker, setSavedMarker] = useState<UserMarker | null>(null);
function reset() {
setHeadName(""); setFamilyCount(3); setPenghasilan(1500000);
setKondisiRumah(5); setKepemilikanAset(5);
setNotes(""); setPlaceName(""); setError(null);
setStep("form"); setSavedMarker(null);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!pendingClick) return;
setIsSubmitting(true);
setError(null);
try {
if (isHousehold) {
const token = localStorage.getItem("auth_token");
const res = await fetch(`${BACKEND_URL}/households`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
head_name: headName,
family_count: familyCount,
latitude: pendingClick.lat,
longitude: pendingClick.lng,
notes: notes || null,
penghasilan,
kondisi_rumah: kondisiRumah,
kepemilikan_aset: kepemilikanAset,
marker_type: "marker",
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? "Gagal menyimpan data");
}
const saved = await res.json();
const marker: UserMarker = {
id: `hh-${saved.id}`,
lat: pendingClick.lat,
lng: pendingClick.lng,
type: "marker",
name: saved.head_name,
meta: {
poverty_level: saved.poverty_level,
family_count: saved.family_count,
penghasilan: saved.penghasilan,
fuzzy_label: saved.fuzzy_label ?? undefined,
fuzzy_score: saved.fuzzy_score != null ? parseFloat(saved.fuzzy_score) : undefined,
fuzzy_detail: saved.fuzzy_detail ?? undefined,
notes: saved.notes ?? undefined,
},
};
setSavedMarker(marker);
setStep("result");
setIsSubmitting(false);
return;
} else {
const token = localStorage.getItem("auth_token");
const name = placeName.trim() || MARKER_LABELS[markerType] || markerType;
const res = await fetch(`${BACKEND_URL}/poi`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
name,
poi_type: markerType,
latitude: pendingClick.lat,
longitude: pendingClick.lng,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? "Gagal menyimpan data");
}
const saved = await res.json();
onConfirm({
id: `poi-${saved.id}`,
lat: pendingClick.lat,
lng: pendingClick.lng,
type: markerType,
name,
meta: { poi_type: markerType, notes: saved.notes ?? undefined },
});
}
reset();
onOpenChange(false);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Terjadi kesalahan");
} finally {
setIsSubmitting(false);
}
}
function handleCancel() {
reset();
onOpenChange(false);
}
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) handleCancel(); }}>
<DialogContent
showCloseButton={false}
className="sm:max-w-lg w-full max-h-[90vh] overflow-y-auto rounded-2xl p-0 gap-0"
>
{/* ── Fuzzy Result Step ── */}
{step === "result" && savedMarker && (
<FuzzyResultStep
marker={savedMarker}
confirmLabel="Selesai & Pasang Marker"
onConfirm={() => {
onConfirm(savedMarker);
reset();
onOpenChange(false);
}}
/>
)}
{/* ── Form Step ── */}
{step === "form" && (
<form onSubmit={handleSubmit} className="flex flex-col">
{/* ── Header ── */}
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<DialogTitle className="text-center text-base font-semibold">
{isHousehold
? "Tambah Data Warga Miskin"
: `Tambah ${MARKER_LABELS[markerType] ?? markerType}`}
</DialogTitle>
{pendingClick && (
<p className="text-center font-mono text-[10px] text-muted-foreground mt-0.5">
{pendingClick.lat.toFixed(6)},&nbsp;{pendingClick.lng.toFixed(6)}
</p>
)}
</DialogHeader>
{/* ── Body ── */}
<div className="flex flex-col gap-5 px-6 py-5">
{isHousehold ? (
<>
{/* Row 1: Nama KK */}
<Field label="Nama Kepala Keluarga">
<Input
placeholder="cth. Budi Santoso"
value={headName}
onChange={(e) => setHeadName(e.target.value)}
required
className="bg-muted/50 border border-border focus-visible:border-ring"
/>
</Field>
{/* Row 2: Penghasilan + Tanggungan */}
<div className="grid grid-cols-2 gap-4">
<Field label="Penghasilan / Bulan">
<div className="relative">
<span className="absolute left-3 top-2.5 text-xs text-muted-foreground font-semibold">Rp</span>
<Input
type="text"
value={penghasilan ? penghasilan.toLocaleString("id-ID") : ""}
onChange={(e) => {
const clean = e.target.value.replace(/\D/g, "");
setPenghasilan(clean ? parseInt(clean, 10) : 0);
}}
className="pl-9 bg-muted/50 border border-border focus-visible:border-ring"
/>
</div>
</Field>
<Field label="Jumlah Tanggungan (jiwa)">
<Input
type="number"
min={1}
max={20}
value={familyCount}
onChange={(e) => setFamilyCount(Number(e.target.value))}
className="bg-muted/50 border border-border focus-visible:border-ring"
/>
</Field>
</div>
{/* Row 3: Sliders */}
<div className="grid grid-cols-2 gap-4">
<Field label="Kondisi Rumah">
<AdaptiveSlider
value={kondisiRumah}
onChange={setKondisiRumah}
hint="1 = sangat buruk · 10 = sangat baik"
/>
</Field>
<Field label="Kepemilikan Aset">
<AdaptiveSlider
value={kepemilikanAset}
onChange={setKepemilikanAset}
hint="1 = tidak ada · 10 = banyak"
/>
</Field>
</div>
{/* Row 4: Catatan */}
<Field label="Catatan">
<Textarea
placeholder="Informasi tambahan (opsional)"
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="min-h-[72px] bg-muted/50 border border-border focus-visible:border-ring resize-none"
/>
</Field>
</>
) : (
<Field label="Nama Tempat">
<Input
placeholder={MARKER_LABELS[markerType] ?? "Nama lokasi"}
value={placeName}
onChange={(e) => setPlaceName(e.target.value)}
className="bg-muted/50 border border-border focus-visible:border-ring"
/>
</Field>
)}
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
</div>
{/* ── Footer ── */}
<div className="flex justify-end gap-2 px-6 py-4 border-t">
<button
type="button"
onClick={handleCancel}
disabled={isSubmitting}
className="h-9 rounded-full border border-border px-5 text-sm font-medium transition-colors hover:bg-muted disabled:opacity-50"
>
Batal
</button>
<button
type="submit"
disabled={isSubmitting || (isHousehold && !headName.trim())}
className="h-9 rounded-full bg-primary text-primary-foreground px-5 text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-40"
>
{isSubmitting ? "Menyimpan…" : "Simpan & Pasang Marker"}
</button>
</div>
</form>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,82 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { SearchIcon } from "@/components/ui/phosphor-icons";
import type { HouseholdRow } from "@/lib/poverty-types";
const SCORE_COLOR: Record<string, string> = {
"SANGAT TINGGI": "text-red-600",
"TINGGI": "text-orange-600",
"SEDANG": "text-amber-600",
"RENDAH": "text-emerald-600",
"TIDAK PRIORITAS": "text-blue-600",
};
interface PovertyDirectoryProps {
searchQuery: string;
setSearchQuery: (query: string) => void;
households: HouseholdRow[];
selectedId: number | null;
onSelect: (household: HouseholdRow) => void;
}
export function PovertyDirectory({
searchQuery,
setSearchQuery,
households,
selectedId,
onSelect,
}: PovertyDirectoryProps) {
const filtered = households
.filter((h) => h.head_name.toLowerCase().includes(searchQuery.toLowerCase()))
.sort((a, b) => Number(b.fuzzy_score ?? -1) - Number(a.fuzzy_score ?? -1));
return (
<Card className="bg-background rounded-none border-none shadow-none ring-0 h-full flex flex-col">
<CardHeader className="py-4">
<CardTitle className="text-base font-semibold">Direktori Warga Prioritas</CardTitle>
<CardDescription>Diurutkan dari skor fuzzy tertinggi. Pilih warga untuk melihat detail di panel inspektur.</CardDescription>
</CardHeader>
<CardContent className="pb-6 flex-1">
<div className="relative mb-4">
<SearchIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Cari nama kepala keluarga..."
className="pl-9 bg-background h-10"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{filtered.length === 0 ? (
<div className="py-8 text-center text-xs text-muted-foreground">
{households.length === 0
? "Belum ada warga terdata. Tambah marker di peta untuk memulai."
: "Tidak ada warga yang cocok dengan pencarian."}
</div>
) : (
<div className="grid gap-2 sm:grid-cols-2 max-h-[320px] overflow-y-auto pr-1">
{filtered.map((h) => {
const isSelected = selectedId === h.id;
const score = h.fuzzy_score != null ? Number(h.fuzzy_score).toFixed(1) : "—";
return (
<div
key={h.id}
onClick={() => onSelect(h)}
className={`p-3 border rounded-lg cursor-pointer flex justify-between items-center transition-all ${
isSelected
? "border-primary bg-primary/5 text-primary font-semibold"
: "border-border/60 hover:bg-muted"
}`}
>
<span className="text-xs text-foreground font-medium truncate me-2">{h.head_name}</span>
<span className={`text-xs bg-muted border px-2 py-0.5 rounded font-bold tabular-nums shrink-0 ${SCORE_COLOR[h.fuzzy_label ?? ""] ?? "text-muted-foreground"}`}>
{score}
</span>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,190 @@
"use client";
import { useState } from "react";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { DashboardCard } from "@/components/dashboard-card";
import { ArrowRightIcon, ArrowUpIcon } from "@/components/ui/phosphor-icons";
import { cn } from "@/lib/utils";
import type { HouseholdRow } from "@/lib/poverty-types";
const PREVIEW_COUNT = 5;
function PovertyBadge({ level }: { level: string }) {
if (level === "Extreme") {
return (
<Badge className="bg-red-100 text-red-700 border-red-200 dark:bg-red-950/40 dark:text-red-400 dark:border-red-800">
Ekstrem
</Badge>
);
}
if (level === "Miskin") {
return (
<Badge className="bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-950/40 dark:text-amber-400 dark:border-amber-800">
Miskin
</Badge>
);
}
return (
<Badge className="bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950/40 dark:text-blue-400 dark:border-blue-800">
Rentan
</Badge>
);
}
function FuzzyPriorityBadge({ label }: { label: string | null }) {
if (!label) return <span className="text-xs text-muted-foreground"></span>;
const colors: Record<string, string> = {
"SANGAT TINGGI": "bg-red-100 text-red-700 border-red-200",
"TINGGI": "bg-orange-100 text-orange-700 border-orange-200",
"SEDANG": "bg-amber-100 text-amber-700 border-amber-200",
"RENDAH": "bg-emerald-100 text-emerald-700 border-emerald-200",
"TIDAK PRIORITAS": "bg-blue-100 text-blue-700 border-blue-200",
};
return (
<Badge className={colors[label] ?? "bg-muted text-muted-foreground"}>
{label}
</Badge>
);
}
function ScoreBar({ value }: { value: number }) {
const pct = Math.round((value / 10) * 100);
const color =
value <= 3 ? "bg-red-400" : value <= 6 ? "bg-amber-400" : "bg-emerald-400";
return (
<div className="flex items-center gap-1.5">
<div className="w-14 h-1.5 rounded-full bg-border overflow-hidden">
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs tabular-nums text-muted-foreground">{value}/10</span>
</div>
);
}
export function PovertyHouseholdsTable({
rows,
loading = false,
}: {
rows: HouseholdRow[];
loading?: boolean;
}) {
const [showAll, setShowAll] = useState(false);
const visible = showAll ? rows : rows.slice(0, PREVIEW_COUNT);
const hasMore = rows.length > PREVIEW_COUNT;
return (
<DashboardCard className="relative gap-0 h-full flex flex-col">
<CardHeader className="border-b">
<CardTitle className="text-base">Data Warga Miskin</CardTitle>
<CardDescription>
{loading ? "Memuat data…" : `${rows.length} entri tersimpan dari peta · skor prioritas fuzzy`}
</CardDescription>
</CardHeader>
<CardContent className={cn("px-0 flex-1", !showAll && hasMore && "mask-b-from-50% mask-b-to-100%")}>
{loading && (
<div className="flex items-center justify-center py-12 text-xs text-muted-foreground">
Memuat data...
</div>
)}
{!loading && rows.length === 0 && (
<div className="flex items-center justify-center py-12 text-xs text-muted-foreground">
Belum ada data. Tambah marker di peta untuk mencatat warga miskin.
</div>
)}
{!loading && rows.length > 0 && (
<Table>
<TableCaption className="sr-only">
Data warga miskin dengan skor prioritas fuzzy.
</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="ps-6 w-8">#</TableHead>
<TableHead>Kepala Keluarga</TableHead>
<TableHead className="text-center">Jiwa</TableHead>
<TableHead>Rumah</TableHead>
<TableHead>Aset</TableHead>
<TableHead>Tingkat</TableHead>
<TableHead className="text-right">Penghasilan</TableHead>
<TableHead className="text-right tabular-nums">Skor Fuzzy</TableHead>
<TableHead className="pe-6">Prioritas</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visible.map((row, i) => (
<TableRow className="h-12" key={row.id}>
<TableCell className="ps-6 text-xs text-muted-foreground tabular-nums">
{i + 1}
</TableCell>
<TableCell className="font-medium max-w-[160px] truncate">
{row.head_name}
</TableCell>
<TableCell className="text-center text-xs tabular-nums text-muted-foreground">
{row.family_count}
</TableCell>
<TableCell>
<ScoreBar value={Number(row.kondisi_rumah)} />
</TableCell>
<TableCell>
<ScoreBar value={Number(row.kepemilikan_aset)} />
</TableCell>
<TableCell>
<PovertyBadge level={row.poverty_level} />
</TableCell>
<TableCell className="text-right text-xs tabular-nums">
Rp {Number(row.penghasilan).toLocaleString("id-ID")}
</TableCell>
<TableCell className="text-right text-xs tabular-nums">
{row.fuzzy_score != null ? Number(row.fuzzy_score).toFixed(1) : "—"}
</TableCell>
<TableCell className="pe-6">
<FuzzyPriorityBadge label={row.fuzzy_label} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
{/* View All overlay — only when collapsed and there are hidden rows */}
{!showAll && hasMore && (
<div className="mask-t-from-30% absolute inset-x-0 bottom-0 flex h-1/5 items-center justify-center bg-background">
<Button variant="ghost" onClick={() => setShowAll(true)}>
Lihat Semua ({rows.length})
<ArrowRightIcon aria-hidden="true" />
</Button>
</div>
)}
{/* Collapse button — only when expanded */}
{showAll && rows.length > PREVIEW_COUNT && (
<div className="flex items-center justify-center border-t py-3">
<Button variant="ghost" onClick={() => setShowAll(false)}>
<ArrowUpIcon aria-hidden="true" />
Tutup
</Button>
</div>
)}
</DashboardCard>
);
}
@@ -0,0 +1,26 @@
const LEGEND = [
{ label: "Sangat Tinggi", color: "#ef4444" },
{ label: "Tinggi", color: "#f97316" },
{ label: "Sedang", color: "#f59e0b" },
{ label: "Rendah", color: "#10b981" },
{ label: "Tidak Prioritas", color: "#6b7280" },
];
export function PovertyHud() {
return (
<>
{/* Floating Legend — fuzzy priority marker colors */}
<div className="absolute bottom-6 left-4 z-[999] bg-background/90 backdrop-blur-md border border-border/40 p-3.5 rounded-lg shadow-lg text-xs flex flex-col gap-2">
<span className="font-bold text-foreground">Prioritas Fuzzy</span>
<div className="flex flex-col gap-1">
{LEGEND.map((item) => (
<div key={item.label} className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full shrink-0" style={{ backgroundColor: item.color }} />
<span className="text-muted-foreground text-[10px]">{item.label}</span>
</div>
))}
</div>
</div>
</>
);
}
@@ -0,0 +1,90 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import type { HouseholdRow } from "@/lib/poverty-types";
const LABEL_BADGE: Record<string, string> = {
"SANGAT TINGGI": "bg-red-100 text-red-700 border-red-200",
"TINGGI": "bg-orange-100 text-orange-700 border-orange-200",
"SEDANG": "bg-amber-100 text-amber-700 border-amber-200",
"RENDAH": "bg-emerald-100 text-emerald-700 border-emerald-200",
"TIDAK PRIORITAS": "bg-blue-100 text-blue-700 border-blue-200",
};
interface PovertyInspectorTopProps {
selected: HouseholdRow | null;
}
export function PovertyInspectorTop({ selected }: PovertyInspectorTopProps) {
return (
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col h-full">
<CardHeader className="border-b bg-muted/20 px-6 py-4 flex flex-col justify-center">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-primary" />
Inspektur Warga
</CardTitle>
<CardDescription>
{selected ? `Analisis untuk ${selected.head_name}.` : "Pilih warga dari direktori."}
</CardDescription>
</CardHeader>
<CardContent className="px-6 py-6 flex-1 flex flex-col justify-center">
{selected ? (
<>
<h3 className="text-2xl font-bold text-foreground truncate">{selected.head_name}</h3>
<span className={`inline-block mt-2 text-[10px] uppercase font-bold tracking-wider px-2 py-0.5 rounded border w-fit ${LABEL_BADGE[selected.fuzzy_label ?? ""] ?? "bg-muted text-muted-foreground border-border"}`}>
{selected.fuzzy_label ?? "Belum dianalisis"}
</span>
</>
) : (
<p className="text-sm text-muted-foreground">Belum ada warga dipilih.</p>
)}
</CardContent>
</Card>
);
}
interface PovertyInspectorBottomProps {
selected: HouseholdRow | null;
}
export function PovertyInspectorBottom({ selected }: PovertyInspectorBottomProps) {
const score = selected?.fuzzy_score != null ? Number(selected.fuzzy_score).toFixed(1) : "—";
return (
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full">
<CardContent className="px-0 py-6 flex flex-col justify-between flex-1">
<div className="grid grid-cols-2 gap-4 px-6">
<div className="p-3 rounded-lg border bg-muted/10">
<span className="text-xs text-muted-foreground">Skor Fuzzy</span>
<p className="text-xl font-bold mt-1 text-foreground tabular-nums">{score}</p>
</div>
<div className="p-3 rounded-lg border bg-muted/10">
<span className="text-xs text-muted-foreground">Penghasilan</span>
<p className="text-xl font-bold mt-1 text-foreground tabular-nums">
{selected ? `Rp ${Number(selected.penghasilan).toLocaleString("id-ID")}` : "—"}
</p>
</div>
<div className="p-3 rounded-lg border bg-muted/10">
<span className="text-xs text-muted-foreground">Tanggungan</span>
<p className="text-xl font-bold mt-1 text-foreground tabular-nums">
{selected ? `${selected.family_count} jiwa` : "—"}
</p>
</div>
<div className="p-3 rounded-lg border bg-muted/10">
<span className="text-xs text-muted-foreground">Rumah · Aset</span>
<p className="text-xl font-bold mt-1 text-foreground tabular-nums">
{selected ? `${selected.kondisi_rumah} · ${selected.kepemilikan_aset}` : "—"}
</p>
</div>
</div>
<div className="border-t mt-6 pt-6 px-6 text-xs text-muted-foreground flex flex-col gap-2">
<p>
Skor fuzzy 0100 dihitung dari penghasilan, tanggungan, kondisi rumah, dan kepemilikan aset. Semakin tinggi skor, semakin prioritas untuk bantuan.
</p>
<p>
Kondisi rumah dan kepemilikan aset dinilai pada skala 110 saat pendataan di peta.
</p>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,71 @@
import { Card } from "@/components/ui/card";
import type { PovertyOverview } from "@/lib/poverty-types";
interface PovertyStatsCardsProps {
overview: PovertyOverview | null;
}
export function PovertyStatsCards({ overview }: PovertyStatsCardsProps) {
const totals = overview?.totals;
const byLabel = overview?.by_label ?? {};
const highPriority = (byLabel["SANGAT TINGGI"] ?? 0) + (byLabel["TINGGI"] ?? 0);
const poiTotal = Object.values(overview?.poi ?? {}).reduce((s, n) => s + n, 0);
const avgDependents =
totals && totals.household_count > 0
? (totals.total_dependents / totals.household_count).toFixed(1)
: null;
return (
<>
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between">
<div className="px-6 pt-6 pb-4">
<span className="text-xs font-medium text-muted-foreground">Warga Terdata</span>
<div className="text-3xl font-bold text-foreground mt-2">
{totals ? totals.household_count : "—"} <span className="text-base font-semibold text-muted-foreground">KK</span>
</div>
</div>
<div className="border-t px-6 py-3 text-xs text-muted-foreground mt-auto">
{poiTotal} fasilitas umum terpetakan
</div>
</Card>
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between">
<div className="px-6 pt-6 pb-4">
<span className="text-xs font-medium text-muted-foreground">Total Tanggungan</span>
<div className="text-3xl font-bold text-foreground mt-2">
{totals ? totals.total_dependents : "—"} <span className="text-base font-semibold text-muted-foreground">jiwa</span>
</div>
</div>
<div className="border-t px-6 py-3 text-xs text-muted-foreground mt-auto">
{avgDependents ? `rata-rata ${avgDependents} jiwa per KK` : "belum ada data"}
</div>
</Card>
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between">
<div className="px-6 pt-6 pb-4">
<span className="text-xs font-medium text-muted-foreground">Rata-rata Penghasilan</span>
<div className="text-3xl font-bold text-foreground mt-2">
{totals ? `Rp ${Math.round(totals.avg_income).toLocaleString("id-ID")}` : "—"}
</div>
</div>
<div className="border-t px-6 py-3 text-xs text-muted-foreground mt-auto">
per kepala keluarga per bulan
</div>
</Card>
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between">
<div className="px-6 pt-6 pb-4">
<span className="text-xs font-medium text-muted-foreground">Rata-rata Skor Fuzzy</span>
<div className="text-3xl font-bold text-foreground mt-2">
{totals?.avg_score != null ? Number(totals.avg_score).toFixed(1) : "—"}
<span className="text-base font-semibold text-muted-foreground"> / 100</span>
</div>
</div>
<div className="border-t px-6 py-3 text-xs mt-auto">
<span className="font-medium text-rose-500">{highPriority} KK</span>
<span className="text-muted-foreground"> prioritas tinggi ke atas</span>
</div>
</Card>
</>
);
}
@@ -0,0 +1,86 @@
/* eslint-disable react-hooks/set-state-in-effect */
import { useState, useEffect } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer } from "recharts";
import type { PovertyOverview } from "@/lib/poverty-types";
const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "Mei", "Jun", "Jul", "Agu", "Sep", "Okt", "Nov", "Des"];
function formatMonth(ym: string) {
const [year, month] = ym.split("-");
return `${MONTH_NAMES[Number(month) - 1] ?? month} '${year.slice(2)}`;
}
interface PovertyTrendChartProps {
monthly: PovertyOverview["monthly"];
}
export function PovertyTrendChart({ monthly }: PovertyTrendChartProps) {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
const chartData = monthly.map((m) => ({
month: formatMonth(m.month),
pendataan: m.count,
skor: m.avg_score != null ? Number(Number(m.avg_score).toFixed(1)) : null,
}));
return (
<Card className="bg-background rounded-none border-none shadow-none ring-0 h-full flex flex-col">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-base font-semibold">Tren Pendataan Warga</CardTitle>
<CardDescription>Jumlah pendataan baru dan rata-rata skor prioritas fuzzy per bulan, dari basis data.</CardDescription>
</div>
</CardHeader>
<CardContent className="flex-1 w-full pt-4 min-h-[320px] relative">
{chartData.length === 0 ? (
<div className="h-full flex items-center justify-center text-xs text-muted-foreground">
Belum ada data pendataan. Tambah marker warga di peta untuk memulai.
</div>
) : !isMounted ? (
<div className="h-full w-full" />
) : (
<div className="absolute inset-0 pt-4 px-6 pb-4">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 10, right: 15, left: -15, bottom: 0 }}>
<XAxis dataKey="month" tickLine={false} style={{ fontSize: 11 }} stroke="#999ba3" />
<YAxis tickLine={false} style={{ fontSize: 11 }} stroke="#999ba3" />
<Tooltip
contentStyle={{
background: "rgba(12, 10, 8, 0.95)",
border: "1px solid #4d505d",
borderRadius: "6px",
fontSize: "12px",
color: "#ffffff"
}}
/>
<Line
type="monotone"
dataKey="skor"
stroke="#ff3b1f"
strokeWidth={3}
dot={{ fill: "#ff3b1f", r: 4 }}
activeDot={{ r: 6 }}
name="Rata-rata Skor Fuzzy"
connectNulls
/>
<Line
type="monotone"
dataKey="pendataan"
stroke="#5683d2"
strokeWidth={2}
dot={{ fill: "#5683d2", r: 3 }}
activeDot={{ r: 5 }}
name="Pendataan Baru (KK)"
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,54 @@
"use client";
interface RadiusSliderProps {
value: number;
onChange: (value: number) => void;
}
export function RadiusSlider({ value, onChange }: RadiusSliderProps) {
const pct = (value / 2000) * 100;
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<input
type="range"
min={0}
max={2000}
step={50}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
style={{
background: value === 0
? "var(--border)"
: `linear-gradient(to right, #10b981 0%, #10b981 ${pct}%, var(--border) ${pct}%, var(--border) 100%)`,
}}
className="
flex-1 h-[3px] cursor-pointer appearance-none rounded-full outline-none
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-white
[&::-webkit-slider-thumb]:shadow
[&::-webkit-slider-thumb]:border
[&::-webkit-slider-thumb]:border-border
[&::-moz-range-thumb]:w-3
[&::-moz-range-thumb]:h-3
[&::-moz-range-thumb]:rounded-full
[&::-moz-range-thumb]:bg-white
[&::-moz-range-thumb]:border
[&::-moz-range-thumb]:border-border
"
/>
<span
className="w-12 shrink-0 text-center text-xs font-bold tabular-nums"
style={{ color: value === 0 ? "var(--muted-foreground)" : "#10b981" }}
>
{value === 0 ? "—" : `${value}m`}
</span>
</div>
<p className="text-[10px] text-muted-foreground">0 = tidak tampilkan lingkaran</p>
</div>
);
}
@@ -0,0 +1,224 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
} from "@/components/ui/dropdown-menu";
import { PhosphorIcon } from "@/components/ui/collection-grid-disclosure";
import { ChevronDownIcon } from "@/components/ui/phosphor-icons";
import { cn } from "@/lib/utils";
import { RadiusSlider } from "@/components/poverty/radius-slider";
import type { UserMarker } from "@/components/leaflet-map";
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000";
const RELIGION_OPTIONS = [
{ value: "mosque", label: "Masjid (Islam)", icon: "mosque" },
{ value: "church", label: "Gereja (Kristen)", icon: "church" },
{ value: "cathedral", label: "Gereja Katolik", icon: "crown-cross" },
{ value: "temple", label: "Pura (Hindu)", icon: "hands-praying" },
{ value: "vihara", label: "Vihara (Buddha)", icon: "hands-praying" },
{ value: "klenteng", label: "Klenteng (Konghucu)", icon: "hands-praying" },
{ value: "synagogue", label: "Sinagog (Yahudi)", icon: "synagogue" },
];
interface PendingClick { lat: number; lng: number; }
interface ReligionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
pendingClick: PendingClick | null;
onConfirm: (marker: UserMarker) => void;
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
{label}
</span>
{children}
</div>
);
}
export function ReligionDialog({
open,
onOpenChange,
pendingClick,
onConfirm,
}: ReligionDialogProps) {
const [religionType, setReligionType] = useState<string | null>(null);
const [placeName, setPlaceName] = useState("");
const [radiusMeters, setRadiusMeters] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const selectedReligion = RELIGION_OPTIONS.find((o) => o.value === religionType);
function reset() {
setReligionType(null);
setPlaceName("");
setRadiusMeters(0);
setError(null);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!pendingClick || !religionType) return;
setIsSubmitting(true);
setError(null);
try {
const token = localStorage.getItem("auth_token");
const label = RELIGION_OPTIONS.find((o) => o.value === religionType)?.label ?? religionType;
const name = placeName.trim() || label;
const res = await fetch(`${BACKEND_URL}/poi`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
name,
poi_type: "religion",
religion_subtype: religionType,
latitude: pendingClick.lat,
longitude: pendingClick.lng,
radius_meters: radiusMeters,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? "Gagal menyimpan data");
}
const saved = await res.json();
onConfirm({
id: `poi-${saved.id}`,
lat: pendingClick.lat,
lng: pendingClick.lng,
type: religionType,
name,
meta: { poi_type: "religion", notes: saved.notes ?? undefined, radius: saved.radius_meters ?? 0 },
});
reset();
onOpenChange(false);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Terjadi kesalahan");
} finally {
setIsSubmitting(false);
}
}
function handleCancel() {
reset();
onOpenChange(false);
}
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) handleCancel(); }}>
<DialogContent
showCloseButton={false}
className="sm:max-w-sm w-full rounded-2xl p-0 gap-0"
>
<form onSubmit={handleSubmit} className="flex flex-col">
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<DialogTitle className="text-center text-base font-semibold">
Tambah Tempat Ibadah
</DialogTitle>
{pendingClick && (
<p className="text-center font-mono text-[10px] text-muted-foreground mt-0.5">
{pendingClick.lat.toFixed(6)},&nbsp;{pendingClick.lng.toFixed(6)}
</p>
)}
</DialogHeader>
<div className="flex flex-col gap-5 px-6 py-5">
<Field label="Jenis Tempat Ibadah">
<DropdownMenu>
<DropdownMenuTrigger
className="flex w-full items-center justify-between gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm transition-colors hover:bg-muted focus-visible:border-ring focus-visible:outline-none"
>
<span className="flex min-w-0 items-center gap-2">
{selectedReligion?.icon && (
<span className="size-4 shrink-0 text-foreground">
<PhosphorIcon name={selectedReligion.icon} className="size-4" />
</span>
)}
<span className={cn("truncate", !selectedReligion && "text-muted-foreground")}>
{selectedReligion ? selectedReligion.label : "Pilih jenis…"}
</span>
</span>
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-(--anchor-width)">
<DropdownMenuRadioGroup
value={religionType ?? ""}
onValueChange={setReligionType}
>
{RELIGION_OPTIONS.map((opt) => (
<DropdownMenuRadioItem key={opt.value} value={opt.value}>
<span className="size-4 shrink-0 text-foreground">
<PhosphorIcon name={opt.icon} className="size-4" />
</span>
{opt.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</Field>
<Field label="Nama Tempat Ibadah">
<Input
placeholder="cth. Masjid Al-Ikhlas"
value={placeName}
onChange={(e) => setPlaceName(e.target.value)}
className="bg-muted/50 border border-border focus-visible:border-ring"
/>
</Field>
<Field label="Radius Jangkauan (meter)">
<RadiusSlider value={radiusMeters} onChange={setRadiusMeters} />
</Field>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
<div className="flex justify-end gap-2 px-6 py-4 border-t">
<button
type="button"
onClick={handleCancel}
disabled={isSubmitting}
className="h-9 rounded-full border border-border px-5 text-sm font-medium transition-colors hover:bg-muted disabled:opacity-50"
>
Batal
</button>
<button
type="submit"
disabled={isSubmitting || !religionType}
className="h-9 rounded-full bg-primary text-primary-foreground px-5 text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-40"
>
{isSubmitting ? "Menyimpan…" : "Simpan & Pasang Marker"}
</button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { Button } from "@/components/ui/button";
import {
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { DashboardCard } from "@/components/dashboard-card";
import { CircleCheckIcon, ArrowRightIcon } from "@/components/ui/phosphor-icons";
interface PriorityBreakdownProps {
byLabel: Record<string, number> | null;
}
const LABELS: { key: string; label: string; dotClass: string }[] = [
{ key: "SANGAT TINGGI", label: "Sangat Tinggi", dotClass: "bg-rose-500" },
{ key: "TINGGI", label: "Tinggi", dotClass: "bg-orange-500" },
{ key: "SEDANG", label: "Sedang", dotClass: "bg-amber-500" },
{ key: "RENDAH", label: "Rendah", dotClass: "bg-sky-500" },
{ key: "TIDAK PRIORITAS", label: "Tidak Prioritas", dotClass: "bg-emerald-500" },
];
export function PriorityBreakdown({ byLabel }: PriorityBreakdownProps) {
const total = LABELS.reduce((sum, l) => sum + (byLabel?.[l.key] ?? 0), 0);
return (
<DashboardCard className="gap-0">
<CardHeader className="border-b">
<CardTitle className="text-balance text-base">
Prioritas Bantuan
</CardTitle>
<CardDescription className="text-pretty">
Distribusi skor fuzzy seluruh KK terdata.
</CardDescription>
</CardHeader>
<CardContent className="flex h-full flex-col px-0">
{total === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<CircleCheckIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Belum ada skor fuzzy.</EmptyTitle>
<EmptyDescription className="text-xs">
Tambahkan data warga atau jalankan perhitungan ulang skor di
halaman kemiskinan.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button
variant="ghost"
render={<a href="/dashboard/poverty" />}
nativeButton={false}
>
Kelola data warga
<ArrowRightIcon aria-hidden="true" />
</Button>
</EmptyContent>
</Empty>
) : (
<ul className="flex flex-col divide-y divide-border">
{LABELS.map((l) => {
const count = byLabel?.[l.key] ?? 0;
const pct = total > 0 ? (count / total) * 100 : 0;
return (
<li className="flex flex-col gap-1.5 px-6 py-3" key={l.key}>
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-2 text-sm text-foreground">
<span
aria-hidden="true"
className={`size-2 shrink-0 rounded-full ${l.dotClass}`}
/>
{l.label}
</span>
<span className="text-xs text-muted-foreground tabular-nums">
{count} KK
</span>
</div>
<div className="h-1 w-full overflow-hidden rounded-full bg-muted">
<div
className={`h-full rounded-full ${l.dotClass}`}
style={{ width: `${pct}%` }}
/>
</div>
</li>
);
})}
</ul>
)}
</CardContent>
</DashboardCard>
);
}

Some files were not shown because too many files have changed in this diff Show More