feat: initial commit with seeded database and premium ui changes
This commit is contained in:
+53
@@ -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?
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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=="],
|
||||
}
|
||||
}
|
||||
Generated
+940
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,7 @@
|
||||
const { Pool } = require("pg");
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
});
|
||||
|
||||
module.exports = pool;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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 -->
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AuthPage } from "@/components/auth-page";
|
||||
|
||||
export default function LoginPage() {
|
||||
return <AuthPage />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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 |
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
“Aplikasi ini berguna dalam membantu mengelola data spasial
|
||||
dengan lebih efisien.”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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">"{mName}" — 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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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">"{shapeName}" — 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)} m²`}
|
||||
/>
|
||||
)}
|
||||
{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)} m²`}
|
||||
/>
|
||||
)}
|
||||
{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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: '© OpenStreetMap contributors'
|
||||
});
|
||||
|
||||
const satellite = L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", {
|
||||
attribution: 'Tiles © Esri — 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}, {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)}, {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 0–100 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 1–10 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)}, {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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user