diff --git a/.gitignore b/.gitignore index 82965e2d..d86875dd 100644 --- a/.gitignore +++ b/.gitignore @@ -40,8 +40,5 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts -# prettier -.prettierrc - # idea .idea diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index efda72f0..951e5472 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,76 +1,146 @@ -stages: [notify] +stages: + - build + - deploy -# --- Notify when MR is opened/updated --- -notify_discord_mr: - stage: notify - image: alpine:3.20 - rules: - - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' +.build_template: &build_template + stage: build + image: node:20-alpine + cache: + key: npm-cache + paths: + - node_modules/ variables: - WEBHOOK_URL: $DISCORD_WEBHOOK_URL - before_script: - - apk add --no-cache curl jq - script: | - MR_URL="${CI_PROJECT_URL}/-/merge_requests/${CI_MERGE_REQUEST_IID}" + NPM_CONFIG_PRODUCTION: 'false' + NODE_ENV: '' + script: + - echo "Installing dependencies..." + - npm ci --no-audit --no-fund + - echo "Building Next.js static export..." + - npx next build + artifacts: + name: 'out-$CI_COMMIT_SHORT_SHA' + paths: + - out/ + expire_in: 1 week - jq -n \ - --arg repo "$CI_PROJECT_PATH" \ - --arg mr "#${CI_MERGE_REQUEST_IID}" \ - --arg url "$MR_URL" \ - --arg requestor "${GITLAB_USER_LOGIN:-$GITLAB_USER_NAME}" \ - --arg source "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME" \ - --arg target "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" \ - --arg title "$CI_MERGE_REQUEST_TITLE" \ - '{ - username: "CI Bot - FE", - embeds: [{ - title: "📣 [LTI WEB CLIENT] Merge Request Opened/Updated", - description: ($mr + " in " + $repo), - url: $url, - color: 3447003, - fields: [ - {name: "Author", value: $requestor, inline: true}, - {name: "Source → Target", value: ($source + " → " + $target), inline: true}, - {name: "Title", value: $title} - ] - }] - }' \ - | curl -sS -H "Content-Type: application/json" -d @- "$WEBHOOK_URL" +.deploy_template: &deploy_template + stage: deploy + image: + name: amazon/aws-cli:latest + entrypoint: ['/bin/sh', '-c'] + script: + - set -e + - aws --version + - echo "Cleaning up newline characters in AWS credentials..." + - export AWS_ACCESS_KEY_ID=$(echo $AWS_ACCESS_KEY_ID | tr -d '\r\n') + - export AWS_SECRET_ACCESS_KEY=$(echo $AWS_SECRET_ACCESS_KEY | tr -d '\r\n') + - echo "Deploying to s3://$S3_BUCKET in region $AWS_REGION" + - aws s3api head-bucket --bucket "$S3_BUCKET" --region "$AWS_REGION" || aws s3api create-bucket --bucket "$S3_BUCKET" --region "$AWS_REGION" --create-bucket-configuration LocationConstraint="$AWS_REGION" + - aws s3 sync ./out "s3://$S3_BUCKET" --delete --region "$AWS_REGION" --endpoint-url "https://s3.ap-southeast-3.amazonaws.com" -# --- Notify when MR is merged --- -notify_discord_merge: - stage: notify - image: alpine:3.20 + # CloudFront invalidation + - | + STATUS="success" + if [ -n "$CLOUDFRONT_DISTRIBUTION_ID" ]; then + echo "Invalidating CloudFront cache..." + if ! aws cloudfront create-invalidation --distribution-id "$CLOUDFRONT_DISTRIBUTION_ID" --paths "/*"; then + echo "CloudFront invalidation failed." + STATUS="failed" + fi + else + echo "No CloudFront distribution specified — skipping invalidation" + fi + + # Notifikasi Discord + - | + RUN_URL="${CI_PROJECT_URL}/-/pipelines/${CI_PIPELINE_ID}" + + if [ "$CI_COMMIT_BRANCH" = "development" ]; then + ENVIRONMENT_NAME="WEB-LTI-DEV" + elif [ "$CI_COMMIT_BRANCH" = "master" ]; then + ENVIRONMENT_NAME="WEB-LTI-PROD" + else + ENVIRONMENT_NAME="UNKNOWN" + fi + + if [ "$STATUS" = "success" ]; then + COLOR=3066993 + TITLE="✅ Deployment ${ENVIRONMENT_NAME} Succeeded" + DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` completed successfully." + else + COLOR=15158332 + TITLE="❌ Deployment ${ENVIRONMENT_NAME} Failed" + DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` encountered issues." + fi + + jq -n \ + --arg title "$TITLE" \ + --arg desc "$DESC" \ + --arg color "$COLOR" \ + --arg repo "$CI_PROJECT_PATH" \ + --arg actor "$GITLAB_USER_LOGIN" \ + --arg commit "$CI_COMMIT_SHA" \ + --arg run_url "$RUN_URL" \ + '{ + username: "CI Bot - LTI WEB", + embeds: [{ + title: $title, + description: $desc, + color: ($color|tonumber), + fields: [ + {name: "Repository", value: $repo, inline: true}, + {name: "Actor", value: $actor, inline: true}, + {name: "Commit", value: $commit, inline: false}, + {name: "Pipeline", value: ("[Open run](" + $run_url + ")"), inline: false} + ] + }] + }' > payload.json + + curl -sS -H "Content-Type: application/json" -d @payload.json "$DISCORD_WEBHOOK_URL" + +# ====== DEVELOPMENT (Branch development) ====== +build:dev: + <<: *build_template rules: - # Only run for merge request pipelines that are in merged state - - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_STATE == "merged"' + - if: '$CI_COMMIT_BRANCH == "development"' + environment: + name: development variables: - WEBHOOK_URL: $DISCORD_WEBHOOK_URL - before_script: - - apk add --no-cache curl jq - script: | - MR_URL="${CI_PROJECT_URL}/-/merge_requests/${CI_MERGE_REQUEST_IID}" + NEXT_PUBLIC_API_BASE_URL: 'https://dev-api-lti.mbugroup.id' + NEXT_PUBLIC_SSO_LOGIN_URL: 'https://dev-api-sso.mbugroup.id' + +deploy:dev: + <<: *deploy_template + needs: ['build:dev'] + rules: + - if: '$CI_COMMIT_BRANCH == "development"' + variables: + S3_BUCKET: 'dev-lti-erp.mbugroup.id' + AWS_REGION: 'ap-southeast-3' + CLOUDFRONT_DISTRIBUTION_ID: 'E1Z8XTA8XF1GIV' + environment: + name: development + url: https://dev-lti-erp.mbugroup.id +# ====== PRODUCTION ====== +# build:production: +# <<: *build_template +# rules: +# # pilih salah satu: pakai branch master ATAU pakai tags rilis +# - if: '$CI_COMMIT_BRANCH == "master"' +# # - if: '$CI_COMMIT_TAG' # kalau mau rilis via tag, uncomment ini dan hapus baris di atas +# environment: +# name: production + +# deploy:production: +# <<: *deploy_template +# needs: ["build:production"] +# rules: +# - if: '$CI_COMMIT_BRANCH == "master"' +# # - if: '$CI_COMMIT_TAG' # selaras dengan rule di build:production +# variables: +# S3_BUCKET: "lti-erp.mbugroup.id" +# CLOUDFRONT_DISTRIBUTION_ID: "ddfd" +# environment: +# name: production +# url: https://royalgoldcapital.com - jq -n \ - --arg repo "$CI_PROJECT_PATH" \ - --arg mr "#${CI_MERGE_REQUEST_IID}" \ - --arg url "$MR_URL" \ - --arg requestor "${CI_MERGE_REQUEST_AUTHOR}" \ - --arg source "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME" \ - --arg target "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" \ - --arg title "$CI_MERGE_REQUEST_TITLE" \ - '{ - username: "CI Bot - FE", - embeds: [{ - title: "✅ [LTI WEB CLIENT] Merge Request Merged", - description: ($mr + " has been merged into " + $repo), - url: $url, - color: 3066993, - fields: [ - {name: "Author", value: $requestor, inline: true}, - {name: "Source → Target", value: ($source + " → " + $target), inline: true}, - {name: "Title", value: $title} - ] - }] - }' \ - | curl -sS -H "Content-Type: application/json" -d @- "$WEBHOOK_URL" diff --git a/.husky/pre-commit b/.husky/pre-commit index 66ff6a67..e7bb3165 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,2 +1,3 @@ +npm run format npm run lint npm run build diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..a3a2e197 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM node:20-alpine + +RUN apk add --no-cache git bash build-base curl + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . + +# Buat config agar Next tahu output: export +RUN echo "const config = { output: 'export', images: { unoptimized: true } }; export default config;" > next.config.mjs + +# Build project (Next.js 15 otomatis static export) +RUN NEXT_DISABLE_TURBOPACK=1 npx next build + +# Copy static assets dan hasil build agar bisa diakses +RUN mkdir -p .next/server/app/_next && \ + cp -r .next/static .next/server/app/_next/static && \ + cp -r public/* .next/server/app/ + +EXPOSE 3000 + +CMD ["npx", "serve", ".next/server/app", "-l", "3000"] \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 00000000..b89f441b --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,39 @@ +version: '3.9' + +services: + dev-web-lti: + container_name: dev-web-lti + build: + context: . + dockerfile: Dockerfile + ports: + - '3002:3000' + env_file: + - .env + environment: + NODE_ENV: production + APP_ENV: production + networks: + - dev-lti-network + restart: always + deploy: + resources: + limits: + cpus: '3.0' + memory: 3G + reservations: + cpus: '1.0' + memory: 512M + extra_hosts: + - 'host.docker.internal:host-gateway' + # Optional: aktifkan healthcheck jika punya endpoint + # healthcheck: + # test: ["CMD-SHELL", "curl -fsS http://localhost:3000/api/healthz || exit 1"] + # interval: 10s + # timeout: 3s + # retries: 10 + # start_period: 15s + +networks: + dev-lti-network: + external: true diff --git a/eslint.config.mjs b/eslint.config.mjs index 719cea2b..fa167c8d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,6 +1,6 @@ -import { dirname } from "path"; -import { fileURLToPath } from "url"; -import { FlatCompat } from "@eslint/eslintrc"; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { FlatCompat } from '@eslint/eslintrc'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -10,14 +10,14 @@ const compat = new FlatCompat({ }); const eslintConfig = [ - ...compat.extends("next/core-web-vitals", "next/typescript"), + ...compat.extends('next/core-web-vitals', 'next/typescript'), { ignores: [ - "node_modules/**", - ".next/**", - "out/**", - "build/**", - "next-env.d.ts", + 'node_modules/**', + '.next/**', + 'out/**', + 'build/**', + 'next-env.d.ts', ], }, ]; diff --git a/package-lock.json b/package-lock.json index e1f28d3e..fc5a5ebf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,11 +13,12 @@ "axios": "^1.12.2", "clsx": "^2.1.1", "formik": "^2.4.6", - "inputmask": "^5.0.9", "moment": "^2.30.1", "next": "15.5.3", "react": "19.1.0", + "react-day-picker": "^9.11.1", "react-dom": "19.1.0", + "react-dropzone": "^14.3.8", "react-hot-toast": "^2.6.0", "react-number-format": "^5.4.4", "react-select": "^5.10.2", @@ -31,7 +32,6 @@ "@eslint/eslintrc": "^3", "@iconify/react": "^6.0.2", "@tailwindcss/postcss": "^4", - "@types/inputmask": "^5.0.7", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -39,6 +39,7 @@ "eslint": "^9", "eslint-config-next": "15.5.3", "husky": "^9.1.7", + "prettier": "^3.6.2", "tailwindcss": "^4", "typescript": "^5" } @@ -195,6 +196,12 @@ "node": ">=6.9.0" } }, + "node_modules/@date-fns/tz": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", + "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", + "license": "MIT" + }, "node_modules/@emnapi/core": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.6.0.tgz", @@ -1638,13 +1645,6 @@ "@types/react": "*" } }, - "node_modules/@types/inputmask": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/@types/inputmask/-/inputmask-5.0.7.tgz", - "integrity": "sha512-uojbVPWzBQ/n/0jc/d16fLqmGasFIptbrLD2WrCPWArlk+5PgblOqH4EDkI3AoobXLAlOK5yF01V8jMmvMG5qg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1680,6 +1680,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -1749,6 +1750,7 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -2266,6 +2268,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2516,6 +2519,15 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -2799,7 +2811,8 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/daisyui": { "version": "5.3.10", @@ -2872,6 +2885,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-jalali": { + "version": "4.1.0-0", + "resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", + "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3227,6 +3256,7 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3400,6 +3430,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -3720,6 +3751,18 @@ "node": ">=16.0.0" } }, + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "license": "MIT", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -4202,12 +4245,6 @@ "node": ">=0.8.19" } }, - "node_modules/inputmask": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/inputmask/-/inputmask-5.0.9.tgz", - "integrity": "sha512-s0lUfqcEbel+EQXtehXqwCJGShutgieOaIImFKC/r4reYNvX3foyrChl6LOEvaEgxEbesePIrw1Zi2jhZaDZbQ==", - "license": "MIT" - }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -4679,9 +4716,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -5669,6 +5706,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -5728,15 +5781,38 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, + "node_modules/react-day-picker": { + "version": "9.11.1", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.11.1.tgz", + "integrity": "sha512-l3ub6o8NlchqIjPKrRFUCkTUEq6KwemQlfv3XZzzwpUeGwmDJ+0u0Upmt38hJyd7D/vn2dQoOoLV/qAp0o3uUw==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "date-fns": "^4.1.0", + "date-fns-jalali": "^4.1.0-0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/react-dom": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -5744,6 +5820,23 @@ "react": "^19.1.0" } }, + "node_modules/react-dropzone": { + "version": "14.3.8", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz", + "integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==", + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, "node_modules/react-fast-compare": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", @@ -6535,6 +6628,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6702,6 +6796,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index b371e4e7..a6372994 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "build": "next build --turbopack", "start": "next start", "lint": "eslint", - "prepare": "husky" + "prepare": "husky", + "format": "prettier --write ." }, "dependencies": { "@tanstack/match-sorter-utils": "^8.19.4", @@ -15,11 +16,12 @@ "axios": "^1.12.2", "clsx": "^2.1.1", "formik": "^2.4.6", - "inputmask": "^5.0.9", "moment": "^2.30.1", "next": "15.5.3", "react": "19.1.0", + "react-day-picker": "^9.11.1", "react-dom": "19.1.0", + "react-dropzone": "^14.3.8", "react-hot-toast": "^2.6.0", "react-number-format": "^5.4.4", "react-select": "^5.10.2", @@ -33,7 +35,6 @@ "@eslint/eslintrc": "^3", "@iconify/react": "^6.0.2", "@tailwindcss/postcss": "^4", - "@types/inputmask": "^5.0.7", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -41,6 +42,7 @@ "eslint": "^9", "eslint-config-next": "15.5.3", "husky": "^9.1.7", + "prettier": "^3.6.2", "tailwindcss": "^4", "typescript": "^5" } diff --git a/postcss.config.mjs b/postcss.config.mjs index c7bcb4b1..ba720fe5 100644 --- a/postcss.config.mjs +++ b/postcss.config.mjs @@ -1,5 +1,5 @@ const config = { - plugins: ["@tailwindcss/postcss"], + plugins: ['@tailwindcss/postcss'], }; export default config; diff --git a/src/app/expense/add/page.tsx b/src/app/expense/add/page.tsx new file mode 100644 index 00000000..afa40f48 --- /dev/null +++ b/src/app/expense/add/page.tsx @@ -0,0 +1,11 @@ +import ExpenseRequestForm from '@/components/pages/expense/form/ExpenseRequestForm'; + +const AddExpense = () => { + return ( +
+ +
+ ); +}; + +export default AddExpense; diff --git a/src/app/expense/detail/edit/page.tsx b/src/app/expense/detail/edit/page.tsx new file mode 100644 index 00000000..b37fdb8f --- /dev/null +++ b/src/app/expense/detail/edit/page.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +import ExpenseRequestForm from '@/components/pages/expense/form/ExpenseRequestForm'; + +import { ExpenseApi } from '@/services/api/expense'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; + +const ExpenseEditPage = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const expenseId = searchParams.get('expenseId'); + + const { data: expense, isLoading: isLoadingExpense } = useSWR( + expenseId, + (id: number) => ExpenseApi.getSingle(id) + ); + + if (!expenseId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingExpense && (!expense || isResponseError(expense))) { + router.replace('/404'); + return; + } + + const isExpenseRejectedOrApproved = + !isLoadingExpense && + isResponseSuccess(expense) && + (expense.data.approval.action === 'REJECTED' || + expense.data.approval.step_number === 5); + + if (isExpenseRejectedOrApproved) { + router.back(); + return; + } + + return ( +
+ {isLoadingExpense && ( + + )} + + {!isLoadingExpense && isResponseSuccess(expense) && ( + + )} +
+ ); +}; + +export default ExpenseEditPage; diff --git a/src/app/expense/detail/layout.tsx b/src/app/expense/detail/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/expense/detail/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from '@/components/helper/SuspenseHelper'; + +const Layout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return {children}; +}; + +export default Layout; diff --git a/src/app/expense/detail/page.tsx b/src/app/expense/detail/page.tsx new file mode 100644 index 00000000..a0d90f70 --- /dev/null +++ b/src/app/expense/detail/page.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +import ExpenseDetail from '@/components/pages/expense/ExpenseDetail'; + +import { ExpenseApi } from '@/services/api/expense'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; + +const ExpenseDetailPage = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const expenseId = searchParams.get('expenseId'); + + const { data: expense, isLoading: isLoadingExpense } = useSWR( + expenseId, + (id: number) => ExpenseApi.getSingle(id) + ); + + if (!expenseId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingExpense && (!expense || isResponseError(expense))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingExpense && ( + + )} + + {!isLoadingExpense && isResponseSuccess(expense) && ( + + )} +
+ ); +}; + +export default ExpenseDetailPage; diff --git a/src/app/expense/page.tsx b/src/app/expense/page.tsx new file mode 100644 index 00000000..d6b00286 --- /dev/null +++ b/src/app/expense/page.tsx @@ -0,0 +1,11 @@ +import ExpensesTable from '@/components/pages/expense/ExpensesTable'; + +const Expense = () => { + return ( +
+ +
+ ); +}; + +export default Expense; diff --git a/src/app/globals.css b/src/app/globals.css index 97be6978..e50e020d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -3,10 +3,10 @@ @import '../styles/daisyui.css'; @plugin "daisyui/theme" { - name: "lti"; + name: 'lti'; default: false; prefersdark: false; - color-scheme: "light"; + color-scheme: 'light'; --color-base-100: oklch(98% 0.001 106.423); --color-base-200: oklch(97% 0.001 106.424); --color-base-300: oklch(92% 0.003 48.717); @@ -37,8 +37,6 @@ --noise: 0; } - - :root { --color-primary: #1f74bf; } @@ -50,3 +48,8 @@ html { scrollbar-gutter: initial; } + +.react-select__menu-portal { + position: relative; + z-index: 99999 !important; +} diff --git a/src/app/inventory/adjustment/add/page.tsx b/src/app/inventory/adjustment/add/page.tsx index 3bd64573..e20eedfc 100644 --- a/src/app/inventory/adjustment/add/page.tsx +++ b/src/app/inventory/adjustment/add/page.tsx @@ -1,11 +1,11 @@ -import InventoryAdjustmentForm from "@/components/pages/inventory/adjustment/form/InventoryAdjustmentForm"; +import InventoryAdjustmentForm from '@/components/pages/inventory/adjustment/form/InventoryAdjustmentForm'; const CreateInventoryAdjustment = () => { return ( -
- +
+
); -} +}; -export default CreateInventoryAdjustment; \ No newline at end of file +export default CreateInventoryAdjustment; diff --git a/src/app/inventory/adjustment/detail/layout.tsx b/src/app/inventory/adjustment/detail/layout.tsx index b41c70f9..7220dfa1 100644 --- a/src/app/inventory/adjustment/detail/layout.tsx +++ b/src/app/inventory/adjustment/detail/layout.tsx @@ -1,11 +1,11 @@ -import SuspenseHelper from "@/components/helper/SuspenseHelper" +import SuspenseHelper from '@/components/helper/SuspenseHelper'; const Layout = ({ - children + children, }: Readonly<{ - children: React.ReactNode + children: React.ReactNode; }>) => { - return {children} -} + return {children}; +}; -export default Layout; \ No newline at end of file +export default Layout; diff --git a/src/app/inventory/adjustment/detail/page.tsx b/src/app/inventory/adjustment/detail/page.tsx index 5e96c86a..acb9f8db 100644 --- a/src/app/inventory/adjustment/detail/page.tsx +++ b/src/app/inventory/adjustment/detail/page.tsx @@ -7,11 +7,12 @@ import type { InventoryAdjustment } from '@/types/api/inventory/adjustment'; const DetailInventoryAdjustment = () => { const router = useRouter(); - const [inventoryAdjustment, setInventoryAdjustment] = useState(null); + const [inventoryAdjustment, setInventoryAdjustment] = + useState(null); // Ambil data dari router state useEffect(() => { - console.log("Router State"); + console.log('Router State'); console.log(window.history.state); const state = window.history.state?.usr as | { inventoryAdjustment?: InventoryAdjustment } @@ -24,20 +25,20 @@ const DetailInventoryAdjustment = () => { }, [router]); const finalData = inventoryAdjustment; - - console.log("Final Data"); + + console.log('Final Data'); console.log(finalData); if (!finalData) { return ( -
- +
+
); } return ( -
+
); diff --git a/src/app/marketing/sales-orders/add/page.tsx b/src/app/marketing/sales-orders/add/page.tsx new file mode 100644 index 00000000..e60085ef --- /dev/null +++ b/src/app/marketing/sales-orders/add/page.tsx @@ -0,0 +1,11 @@ +import SalesForm from '@/components/pages/marketing/sales-orders/form/SalesForm'; + +const AddSalesOrder = () => { + return ( +
+ +
+ ); +}; + +export default AddSalesOrder; diff --git a/src/app/marketing/sales-orders/detail/edit/page.tsx b/src/app/marketing/sales-orders/detail/edit/page.tsx new file mode 100644 index 00000000..86cafcb6 --- /dev/null +++ b/src/app/marketing/sales-orders/detail/edit/page.tsx @@ -0,0 +1,42 @@ +'use client'; + +import SalesForm from '@/components/pages/marketing/sales-orders/form/SalesForm'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { MarketingApi } from '@/services/api/marketing/marketing'; +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +const EditSalesOrder = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const soId = searchParams.get('salesOrderId'); + + const { data: marketing, isLoading: isLoading } = useSWR(soId, (id: number) => + MarketingApi.getSingle(id) + ); + + if (!soId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoading && (!marketing || isResponseError(marketing))) { + router.replace('/404'); + return; + } + return ( +
+ {isLoading && } + {!isLoading && isResponseSuccess(marketing) && ( + + )} +
+ ); +}; +export default EditSalesOrder; diff --git a/src/app/marketing/sales-orders/detail/layout.tsx b/src/app/marketing/sales-orders/detail/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/marketing/sales-orders/detail/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from '@/components/helper/SuspenseHelper'; + +const Layout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return {children}; +}; + +export default Layout; diff --git a/src/app/marketing/sales-orders/detail/page.tsx b/src/app/marketing/sales-orders/detail/page.tsx new file mode 100644 index 00000000..22d2651c --- /dev/null +++ b/src/app/marketing/sales-orders/detail/page.tsx @@ -0,0 +1,44 @@ +'use client'; + +import SalesOrderDetail from '@/components/pages/marketing/sales-orders/detail/SalesOrderDetail'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { MarketingApi } from '@/services/api/marketing/marketing'; +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +const DetailSalesOrder = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const soId = searchParams.get('salesOrderId'); + + const { data: marketing, isLoading: isLoading } = useSWR(soId, (id: number) => + MarketingApi.getSingle(id) + ); + + if (!soId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoading && (!marketing || isResponseError(marketing))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoading && } + {!isLoading && isResponseSuccess(marketing) && ( + + )} +
+ ); +}; + +export default DetailSalesOrder; diff --git a/src/app/marketing/sales-orders/page.tsx b/src/app/marketing/sales-orders/page.tsx new file mode 100644 index 00000000..3494b6a1 --- /dev/null +++ b/src/app/marketing/sales-orders/page.tsx @@ -0,0 +1,10 @@ +import SalesOrderTable from '@/components/pages/marketing/sales-orders/SalesOrderTable'; + +const SalesOrder = () => { + return ( +
+ +
+ ); +}; +export default SalesOrder; diff --git a/src/app/master-data/customer/add/page.tsx b/src/app/master-data/customer/add/page.tsx index a1096f02..dd75c679 100644 --- a/src/app/master-data/customer/add/page.tsx +++ b/src/app/master-data/customer/add/page.tsx @@ -1,11 +1,11 @@ -import CustomerForm from "@/components/pages/master-data/customer/form/CustomerForm"; +import CustomerForm from '@/components/pages/master-data/customer/form/CustomerForm'; const AddCustomer = () => { return ( -
- +
+
); -} +}; -export default AddCustomer; \ No newline at end of file +export default AddCustomer; diff --git a/src/app/master-data/customer/detail/page.tsx b/src/app/master-data/customer/detail/page.tsx index 263458c2..d778f83b 100644 --- a/src/app/master-data/customer/detail/page.tsx +++ b/src/app/master-data/customer/detail/page.tsx @@ -1,45 +1,47 @@ -'use client' +'use client'; -import { useRouter, useSearchParams } from "next/navigation"; -import useSWR from "swr"; +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; import { CustomerApi } from '@/services/api/master-data'; -import { isResponseError, isResponseSuccess } from "@/lib/api-helper"; -import CustomerForm from "@/components/pages/master-data/customer/form/CustomerForm"; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import CustomerForm from '@/components/pages/master-data/customer/form/CustomerForm'; const CustomerDetail = () => { const router = useRouter(); const searchParams = useSearchParams(); - const costumerId = searchParams.get("customerId"); + const costumerId = searchParams.get('customerId'); const { data: costumer, isLoading: isLoadingCostumer } = useSWR( costumerId, (id: number) => CustomerApi.getSingle(id) ); - if(!costumerId){ + if (!costumerId) { router.back(); return ( -
- +
+
); } - if(!isLoadingCostumer && (!costumer || isResponseError(costumer))){ - router.replace("/404"); + if (!isLoadingCostumer && (!costumer || isResponseError(costumer))) { + router.replace('/404'); return; } return ( -
- {isLoadingCostumer && } +
+ {isLoadingCostumer && ( + + )} {!isLoadingCostumer && isResponseSuccess(costumer) && ( - + )}
- ) + ); }; export default CustomerDetail; diff --git a/src/app/master-data/customer/page.tsx b/src/app/master-data/customer/page.tsx index b80401f1..8aec1088 100644 --- a/src/app/master-data/customer/page.tsx +++ b/src/app/master-data/customer/page.tsx @@ -1,11 +1,11 @@ -import CustomersTable from "@/components/pages/master-data/customer/CustomersTable"; +import CustomersTable from '@/components/pages/master-data/customer/CustomersTable'; const Customer = () => { return ( -
+
- ) + ); }; -export default Customer; \ No newline at end of file +export default Customer; diff --git a/src/app/master-data/flock/add/page.tsx b/src/app/master-data/flock/add/page.tsx index 5ee3958e..d038d414 100644 --- a/src/app/master-data/flock/add/page.tsx +++ b/src/app/master-data/flock/add/page.tsx @@ -1,11 +1,11 @@ -import FlockForm from "@/components/pages/master-data/flock/form/FlockForm"; +import FlockForm from '@/components/pages/master-data/flock/form/FlockForm'; const AddFlock = () => { return ( -
+
); -} +}; export default AddFlock; diff --git a/src/app/master-data/flock/detail/edit/page.tsx b/src/app/master-data/flock/detail/edit/page.tsx index c9651727..babc6653 100644 --- a/src/app/master-data/flock/detail/edit/page.tsx +++ b/src/app/master-data/flock/detail/edit/page.tsx @@ -1,10 +1,10 @@ -'use client' +'use client'; -import FlockForm from "@/components/pages/master-data/flock/form/FlockForm"; -import { isResponseError, isResponseSuccess } from "@/lib/api-helper"; -import { FlockApi } from "@/services/api/master-data"; -import { useRouter, useSearchParams } from "next/navigation"; -import useSWR from "swr"; +import FlockForm from '@/components/pages/master-data/flock/form/FlockForm'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { FlockApi } from '@/services/api/master-data'; +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; const FlockEdit = () => { const router = useRouter(); @@ -44,6 +44,6 @@ const FlockEdit = () => { )}
); -} +}; -export default FlockEdit; \ No newline at end of file +export default FlockEdit; diff --git a/src/app/master-data/flock/detail/layout.tsx b/src/app/master-data/flock/detail/layout.tsx index b41c70f9..7220dfa1 100644 --- a/src/app/master-data/flock/detail/layout.tsx +++ b/src/app/master-data/flock/detail/layout.tsx @@ -1,11 +1,11 @@ -import SuspenseHelper from "@/components/helper/SuspenseHelper" +import SuspenseHelper from '@/components/helper/SuspenseHelper'; const Layout = ({ - children + children, }: Readonly<{ - children: React.ReactNode + children: React.ReactNode; }>) => { - return {children} -} + return {children}; +}; -export default Layout; \ No newline at end of file +export default Layout; diff --git a/src/app/master-data/flock/detail/page.tsx b/src/app/master-data/flock/detail/page.tsx index 8a805911..e9620d33 100644 --- a/src/app/master-data/flock/detail/page.tsx +++ b/src/app/master-data/flock/detail/page.tsx @@ -1,10 +1,10 @@ -'use client' +'use client'; -import FlockForm from "@/components/pages/master-data/flock/form/FlockForm"; -import { isResponseError, isResponseSuccess } from "@/lib/api-helper"; -import { FlockApi } from "@/services/api/master-data"; -import { useRouter, useSearchParams } from "next/navigation"; -import useSWR from "swr"; +import FlockForm from '@/components/pages/master-data/flock/form/FlockForm'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { FlockApi } from '@/services/api/master-data'; +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; const FlockDetail = () => { const router = useRouter(); @@ -14,33 +14,36 @@ const FlockDetail = () => { const flockId = searchParams.get('flockId'); // Fetch Data - const { data: flock, isLoading: isLoadingFlock } = useSWR(flockId, (id: number) => FlockApi.getSingle(id)); + const { data: flock, isLoading: isLoadingFlock } = useSWR( + flockId, + (id: number) => FlockApi.getSingle(id) + ); - if(!flockId){ + if (!flockId) { router.back(); return ( -
- +
+
); } - if(!isLoadingFlock && (!flock || isResponseError(flock))){ + if (!isLoadingFlock && (!flock || isResponseError(flock))) { router.replace('/404'); return; } return ( -
+
{isLoadingFlock && ( - + )} {!isLoadingFlock && isResponseSuccess(flock) && ( - + )}
); -} +}; -export default FlockDetail; \ No newline at end of file +export default FlockDetail; diff --git a/src/app/master-data/flock/page.tsx b/src/app/master-data/flock/page.tsx index b317091a..76cc32c1 100644 --- a/src/app/master-data/flock/page.tsx +++ b/src/app/master-data/flock/page.tsx @@ -1,11 +1,11 @@ -import FlockTable from "@/components/pages/master-data/flock/FlocksTable"; +import FlockTable from '@/components/pages/master-data/flock/FlocksTable'; const Flock = () => { return ( -
- +
+
- ); -} + ); +}; export default Flock; diff --git a/src/app/master-data/product-category/add/page.tsx b/src/app/master-data/product-category/add/page.tsx index 0993ba7a..2331159e 100644 --- a/src/app/master-data/product-category/add/page.tsx +++ b/src/app/master-data/product-category/add/page.tsx @@ -1,11 +1,11 @@ -import ProductCategoryForm from "@/components/pages/master-data/product-category/form/ProductCategoryForm"; +import ProductCategoryForm from '@/components/pages/master-data/product-category/form/ProductCategoryForm'; const AddProductCategory = () => { return ( -
+
); }; -export default AddProductCategory; \ No newline at end of file +export default AddProductCategory; diff --git a/src/app/master-data/product-category/detail/edit/page.tsx b/src/app/master-data/product-category/detail/edit/page.tsx index 6bc10644..4cb7eb5a 100644 --- a/src/app/master-data/product-category/detail/edit/page.tsx +++ b/src/app/master-data/product-category/detail/edit/page.tsx @@ -9,39 +9,44 @@ import { ProductCategoryApi } from '@/services/api/master-data'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; const ProductCategoryEdit = () => { - const router = useRouter(); - const searchParams = useSearchParams(); + const router = useRouter(); + const searchParams = useSearchParams(); - const productCategoryId = searchParams.get('productCategoryId'); + const productCategoryId = searchParams.get('productCategoryId'); - const { data: productCategory, isLoading: isLoadingProductCategory } = useSWR( - productCategoryId, - (id: number) => ProductCategoryApi.getSingle(id) - ); + const { data: productCategory, isLoading: isLoadingProductCategory } = useSWR( + productCategoryId, + (id: number) => ProductCategoryApi.getSingle(id) + ); - if (!productCategoryId) { - router.back(); - - return ( -
- -
- ); - } - - if (!isLoadingProductCategory && (!productCategory || isResponseError(productCategory))) { - router.replace('/404'); - return; - } + if (!productCategoryId) { + router.back(); return ( -
- {isLoadingProductCategory && } - {!isLoadingProductCategory && isResponseSuccess(productCategory) && ( - - )} -
+
+ +
); -} + } -export default ProductCategoryEdit; \ No newline at end of file + if ( + !isLoadingProductCategory && + (!productCategory || isResponseError(productCategory)) + ) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingProductCategory && ( + + )} + {!isLoadingProductCategory && isResponseSuccess(productCategory) && ( + + )} +
+ ); +}; + +export default ProductCategoryEdit; diff --git a/src/app/master-data/product-category/detail/page.tsx b/src/app/master-data/product-category/detail/page.tsx index cba06fdb..c1a21aaf 100644 --- a/src/app/master-data/product-category/detail/page.tsx +++ b/src/app/master-data/product-category/detail/page.tsx @@ -29,16 +29,24 @@ const ProductCategoryDetail = () => { ); } - if (!isLoadingProductCategory && (!productCategory || isResponseError(productCategory))) { + if ( + !isLoadingProductCategory && + (!productCategory || isResponseError(productCategory)) + ) { router.replace('/404'); return; } return (
- {isLoadingProductCategory && } + {isLoadingProductCategory && ( + + )} {!isLoadingProductCategory && isResponseSuccess(productCategory) && ( - + )}
); diff --git a/src/app/master-data/product-category/page.tsx b/src/app/master-data/product-category/page.tsx index 5ec6d555..78a4fda3 100644 --- a/src/app/master-data/product-category/page.tsx +++ b/src/app/master-data/product-category/page.tsx @@ -1,11 +1,11 @@ -import ProductCategoryTable from "@/components/pages/master-data/product-category/ProductCategoryTable"; +import ProductCategoryTable from '@/components/pages/master-data/product-category/ProductCategoryTable'; const ProductCategory = () => { return ( -
+
); }; -export default ProductCategory; \ No newline at end of file +export default ProductCategory; diff --git a/src/app/master-data/product/add/page.tsx b/src/app/master-data/product/add/page.tsx index 7cc995b6..37f42691 100644 --- a/src/app/master-data/product/add/page.tsx +++ b/src/app/master-data/product/add/page.tsx @@ -2,10 +2,10 @@ import ProductForm from '@/components/pages/master-data/product/form/ProductForm const AddProduct = () => { return ( -
+
); }; -export default AddProduct; \ No newline at end of file +export default AddProduct; diff --git a/src/app/master-data/product/detail/edit/page.tsx b/src/app/master-data/product/detail/edit/page.tsx index 96cfdc42..8916a98e 100644 --- a/src/app/master-data/product/detail/edit/page.tsx +++ b/src/app/master-data/product/detail/edit/page.tsx @@ -13,9 +13,8 @@ const ProductEdit = () => { const productId = searchParams.get('productId'); - const { data: product, isLoading } = useSWR( - productId, - (id: number) => ProductApi.getSingle(id) + const { data: product, isLoading } = useSWR(productId, (id: number) => + ProductApi.getSingle(id) ); if (!productId) { @@ -42,4 +41,4 @@ const ProductEdit = () => { ); }; -export default ProductEdit; \ No newline at end of file +export default ProductEdit; diff --git a/src/app/master-data/product/detail/page.tsx b/src/app/master-data/product/detail/page.tsx index 916a44d0..34743e1f 100644 --- a/src/app/master-data/product/detail/page.tsx +++ b/src/app/master-data/product/detail/page.tsx @@ -13,9 +13,8 @@ const ProductDetail = () => { const productId = searchParams.get('productId'); - const { data: product, isLoading } = useSWR( - productId, - (id: number) => ProductApi.getSingle(id) + const { data: product, isLoading } = useSWR(productId, (id: number) => + ProductApi.getSingle(id) ); if (!productId) { @@ -42,4 +41,4 @@ const ProductDetail = () => { ); }; -export default ProductDetail; \ No newline at end of file +export default ProductDetail; diff --git a/src/app/master-data/product/page.tsx b/src/app/master-data/product/page.tsx index 6014aeb9..a385d411 100644 --- a/src/app/master-data/product/page.tsx +++ b/src/app/master-data/product/page.tsx @@ -1,11 +1,11 @@ -import ProductsTable from "@/components/pages/master-data/product/ProductTable"; +import ProductsTable from '@/components/pages/master-data/product/ProductTable'; const Product = () => { return ( -
- +
+
); }; -export default Product; \ No newline at end of file +export default Product; diff --git a/src/app/master-data/supplier/add/page.tsx b/src/app/master-data/supplier/add/page.tsx index 8a95c3c6..37df33b0 100644 --- a/src/app/master-data/supplier/add/page.tsx +++ b/src/app/master-data/supplier/add/page.tsx @@ -8,4 +8,4 @@ const AddSupplier = () => { ); }; -export default AddSupplier; \ No newline at end of file +export default AddSupplier; diff --git a/src/app/master-data/supplier/detail/page.tsx b/src/app/master-data/supplier/detail/page.tsx index 433fa043..a34ad72e 100644 --- a/src/app/master-data/supplier/detail/page.tsx +++ b/src/app/master-data/supplier/detail/page.tsx @@ -46,4 +46,4 @@ const SupplierDetail = () => { ); }; -export default SupplierDetail; \ No newline at end of file +export default SupplierDetail; diff --git a/src/app/master-data/supplier/page.tsx b/src/app/master-data/supplier/page.tsx index 1f54bd0d..8000be0a 100644 --- a/src/app/master-data/supplier/page.tsx +++ b/src/app/master-data/supplier/page.tsx @@ -1,4 +1,4 @@ -import SuppliersTable from "@/components/pages/master-data/supplier/SupplierTable"; +import SuppliersTable from '@/components/pages/master-data/supplier/SupplierTable'; const Supplier = () => { return ( diff --git a/src/app/production/chickin/add/layout.tsx b/src/app/production/chickin/add/layout.tsx deleted file mode 100644 index b41c70f9..00000000 --- a/src/app/production/chickin/add/layout.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import SuspenseHelper from "@/components/helper/SuspenseHelper" - -const Layout = ({ - children -}: Readonly<{ - children: React.ReactNode -}>) => { - return {children} -} - -export default Layout; \ No newline at end of file diff --git a/src/app/production/chickin/add/page.tsx b/src/app/production/chickin/add/page.tsx deleted file mode 100644 index 3ef73396..00000000 --- a/src/app/production/chickin/add/page.tsx +++ /dev/null @@ -1,270 +0,0 @@ -'use client'; - -import Button from '@/components/Button'; -import SelectInput, { OptionType } from '@/components/input/SelectInput'; -import Modal, { useModal } from '@/components/Modal'; -import ConfirmationModal from '@/components/modal/ConfirmationModal'; -import ChickinForm from '@/components/pages/production/chickin/form/ChickinForm'; -import Table from '@/components/Table'; -import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; -import { cn } from '@/lib/helper'; -import { ProjectFlockApi } from '@/services/api/production'; -import { useTableFilter } from '@/services/hooks/useTableFilter'; -import { BaseApiResponse } from '@/types/api/api-general'; -import { Kandang } from '@/types/api/master-data/kandang'; -import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang'; -import { Icon } from '@iconify/react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { useState } from 'react'; - -import useSWR from 'swr'; - -const AddChickin = () => { - const router = useRouter(); - const searchParams = useSearchParams(); - const projectFlockId = searchParams.get('projectFlockId'); - - // Tables Props - const { state: tableFilterState } = useTableFilter({ - initial: { search: '' }, - paramMap: { page: 'page', pageSize: 'limit' }, - }); - - // States - const [selectedKandang, setSelectedKandang] = useState( - undefined - ); - const [projectFlockKandang, setProjectFlockKandang] = - useState>(); - const [isLoadingProjectFlockKandang, setIsLoadingProjectFlockKandang] = - useState(false); - const [searchProjectFlock, setSearchProjectFlock] = useState(''); - - // Fetch Data - const { data: projectFlock, isLoading: isLoadingProjectFlock } = useSWR( - projectFlockId, - (id: number) => ProjectFlockApi.getSingle(id) - ); - const { data: listProjectFlock, isLoading: isLoadingListProjectFlock } = - useSWR( - `${ProjectFlockApi.basePath}?${new URLSearchParams({ - search: searchProjectFlock, - }).toString()}`, - ProjectFlockApi.getAllFetcher - ); - - const getProjectFlockKandangUrl = `/kandangs/lookup`; - // Mapping Options - const options = isResponseSuccess(listProjectFlock) - ? listProjectFlock?.data.map((projectFlock) => { - return { - value: projectFlock.id, - label: `${projectFlock?.flock?.name} - ${projectFlock?.category} - Periode ${projectFlock.period}`, - }; - }) - : []; - - const chickinModal = useModal(); - const alertModal = useModal(); - - if (!projectFlockId) { - router.back(); - - return ( -
- -
- ); - } - - if ( - !isLoadingProjectFlock && - (!projectFlock || isResponseError(projectFlock)) - ) { - router.replace('/404'); - return; - } - - // Handle Function - const handleChickinClick = async (kandang: Kandang) => { - setIsLoadingProjectFlockKandang(true); - setSelectedKandang(kandang); - const ProjectFlockKandangRes = await ProjectFlockApi.customRequest< - BaseApiResponse, - 'GET' - >(getProjectFlockKandangUrl, { - method: 'GET', - params: { - project_flock_id: projectFlockId ?? 0, - kandang_id: kandang.id, - }, - }); - if (isResponseSuccess(ProjectFlockKandangRes)) { - setProjectFlockKandang(ProjectFlockKandangRes); - setIsLoadingProjectFlockKandang(false); - if ( - ProjectFlockKandangRes.data.available_quantity && - ProjectFlockKandangRes.data.available_quantity > 0 - ) { - chickinModal.openModal(); - } else { - alertModal.openModal(); - } - } - }; - const handleAfterSubmit = () => { - chickinModal.closeModal(); - router.push('/production/chickin'); - }; - - return ( - <> - {isResponseSuccess(projectFlock) && ( - <> -
-
- - -
-
- - router.push( - `/production/chickin/add?projectFlockId=${ - (val as OptionType | null)?.value - }` - ) - } - onInputChange={(val) => { - setSearchProjectFlock(val); - }} - /> -
-
-
- - data={projectFlock.data?.kandangs} - columns={[ - { - header: '#', - cell: (props) => - tableFilterState.pageSize * (tableFilterState.page - 1) + - props.row.index + - 1, - }, - { - accessorKey: 'name', - header: 'Nama Kandang', - }, - { - header: 'Aksi', - cell: (props) => { - return ( - <> - - - ); - }, - }, - ]} - page={undefined} - className={{ - containerClassName: cn({ - 'mb-20': - isResponseSuccess(projectFlock) && - projectFlock.data?.kandangs?.length === 0, - }), - tableWrapperClassName: 'overflow-x-auto min-h-full!', - tableClassName: 'font-inter w-full table-auto min-h-full!', - headerRowClassName: 'border-b border-b-gray-200', - headerColumnClassName: - 'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', - bodyRowClassName: 'border-b border-b-gray-200', - bodyColumnClassName: - 'px-6 py-3 last:flex last:flex-row last:justify-end', - paginationClassName: 'hidden', - }} - /> -
- -
-

- Chickin Kandang - {selectedKandang?.name} -

- -
- {isResponseSuccess(projectFlockKandang) && - !isLoadingProjectFlockKandang && ( - - )} -
- { - alertModal.closeModal(); - }, - }} - /> - - )} - - ); -}; - -export default AddChickin; diff --git a/src/app/production/chickin/detail/layout.tsx b/src/app/production/chickin/detail/layout.tsx deleted file mode 100644 index b41c70f9..00000000 --- a/src/app/production/chickin/detail/layout.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import SuspenseHelper from "@/components/helper/SuspenseHelper" - -const Layout = ({ - children -}: Readonly<{ - children: React.ReactNode -}>) => { - return {children} -} - -export default Layout; \ No newline at end of file diff --git a/src/app/production/chickin/page.tsx b/src/app/production/chickin/page.tsx deleted file mode 100644 index ad662f65..00000000 --- a/src/app/production/chickin/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import ChickinTable from "@/components/pages/production/chickin/ChickinTable"; - -const Chickin = () => { - return ( -
- -
- ); -} -export default Chickin; \ No newline at end of file diff --git a/src/app/production/project-flock/add/page.tsx b/src/app/production/project-flock/add/page.tsx index 60141d80..b323b5f3 100644 --- a/src/app/production/project-flock/add/page.tsx +++ b/src/app/production/project-flock/add/page.tsx @@ -1,13 +1,13 @@ -'use client' +'use client'; -import ProjectFlockForm from "@/components/pages/production/project-flock/form/ProjectFlockForm"; +import ProjectFlockForm from '@/components/pages/production/project-flock/form/ProjectFlockForm'; const AddProjectFlock = () => { return ( -
- +
+
); -} +}; -export default AddProjectFlock; \ No newline at end of file +export default AddProjectFlock; diff --git a/src/app/production/project-flock/chickin/add/kandang/layout.tsx b/src/app/production/project-flock/chickin/add/kandang/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/production/project-flock/chickin/add/kandang/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from '@/components/helper/SuspenseHelper'; + +const Layout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return {children}; +}; + +export default Layout; diff --git a/src/app/production/project-flock/chickin/add/kandang/page.tsx b/src/app/production/project-flock/chickin/add/kandang/page.tsx new file mode 100644 index 00000000..a22039d1 --- /dev/null +++ b/src/app/production/project-flock/chickin/add/kandang/page.tsx @@ -0,0 +1,60 @@ +'use client'; + +import ChickinForm from '@/components/pages/production/chickin/form/ChickinForm'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { ProjectFlockKandangApi } from '@/services/api/production'; +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +export default function AddChickinKandang() { + const searchParams = useSearchParams(); + const projectFlockKandangId = searchParams.get('projectFlockKandangId'); + const projectFlockId = searchParams.get('projectFlockId'); + const router = useRouter(); + + const { + data: projectFlockKandang, + isLoading: isLoading, + mutate: refreshProjectFlockKandang, + } = useSWR( + `get-single-project-flock-kandang/${projectFlockKandangId}`, + async () => + ProjectFlockKandangApi.getSingle( + parseInt(projectFlockKandangId as string) + ) + ); + + if (!projectFlockKandangId) { + router.push(`/production/chickin/add?projectFlockId=${projectFlockId}`); + return ( +
+ +
+ ); + } + + if (!isLoading && !projectFlockKandang) { + router.replace('/404'); + return; + } + + const handleAfterSubmit = () => { + refreshProjectFlockKandang(); + }; + + return ( + <> +
+ {isLoading && } + {!isLoading && + isResponseSuccess(projectFlockKandang) && + projectFlockId && ( + + )} +
+ + ); +} diff --git a/src/app/production/project-flock/chickin/add/layout.tsx b/src/app/production/project-flock/chickin/add/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/production/project-flock/chickin/add/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from '@/components/helper/SuspenseHelper'; + +const Layout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return {children}; +}; + +export default Layout; diff --git a/src/app/production/project-flock/chickin/add/page.tsx b/src/app/production/project-flock/chickin/add/page.tsx new file mode 100644 index 00000000..3ca09c89 --- /dev/null +++ b/src/app/production/project-flock/chickin/add/page.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { FormHeader } from '@/components/helper/form/FormHeader'; +import ProjectFlockChickinDetail from '@/components/pages/production/project-flock/chickin/ProjectFlockChickinDetail'; +import { useSearchParams } from 'next/navigation'; + +const AddChickin = () => { + const searchParams = useSearchParams(); + const projectFlockId = searchParams.get('projectFlockId'); + + return ( + <> +
+ + +
+ + ); +}; + +export default AddChickin; diff --git a/src/app/production/project-flock/chickin/detail/layout.tsx b/src/app/production/project-flock/chickin/detail/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/production/project-flock/chickin/detail/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from '@/components/helper/SuspenseHelper'; + +const Layout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return {children}; +}; + +export default Layout; diff --git a/src/app/production/chickin/detail/page.tsx b/src/app/production/project-flock/chickin/detail/page.tsx similarity index 92% rename from src/app/production/chickin/detail/page.tsx rename to src/app/production/project-flock/chickin/detail/page.tsx index 96647c55..daea0f0a 100644 --- a/src/app/production/chickin/detail/page.tsx +++ b/src/app/production/project-flock/chickin/detail/page.tsx @@ -6,7 +6,7 @@ import Modal, { useModal } from '@/components/Modal'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ChickinForm from '@/components/pages/production/chickin/form/ChickinForm'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; -import { ChickinApi } from '@/services/api/production'; +import { ChickinApi } from '@/services/api/production/chickin'; import { BaseApiResponse } from '@/types/api/api-general'; import { Chickin, @@ -20,7 +20,7 @@ import useSWR from 'swr'; /** * TODO: Refactor code - pindahin detail ke reuseable component - * setelah implement approval and reject + * setelah implement approval and reject */ const DetailChickin = () => { @@ -43,9 +43,8 @@ const DetailChickin = () => { // chickin.data?.approval.step_number == 1 ? false : true true ); - const [isRejectedDisabled, setIsRejectedDisabled] = useState( - !isApprovedDisabled - ); + const [isRejectedDisabled, setIsRejectedDisabled] = + useState(!isApprovedDisabled); const [approvalAction, setApprovalAction] = useState<'APPROVED' | 'REJECTED'>( !isApprovedDisabled ? 'APPROVED' : 'REJECTED' ); @@ -171,8 +170,8 @@ const DetailChickin = () => {
Flock
{ - chickin.data.project_flock_kandang?.project_flock.flock - .name + chickin?.data?.project_flock_kandang?.project_flock?.flock + ?.name }
@@ -226,8 +225,8 @@ const DetailChickin = () => {
Flock Kandang
{ - chickin.data.project_flock_kandang?.project_flock.flock - .name + chickin?.data?.project_flock_kandang?.project_flock?.flock + ?.name }{' '} - {chickin.data.project_flock_kandang?.kandang.name}
@@ -264,7 +263,8 @@ const DetailChickin = () => { Delete -
- { - refreshChickin(); - chickinModal.closeModal(); - }} - /> { text={`Apakah anda yakin ingin ${ approvalAction == 'APPROVED' ? 'approve' : 'reject' } chickin berikut? (${ - chickin?.data.project_flock_kandang?.project_flock.flock.name + chickin?.data?.project_flock_kandang?.project_flock?.flock?.name } - ${chickin?.data.project_flock_kandang?.kandang.name})?`} secondaryButton={{ text: 'Tidak', diff --git a/src/app/production/project-flock/chickin/page.tsx b/src/app/production/project-flock/chickin/page.tsx new file mode 100644 index 00000000..5d105aab --- /dev/null +++ b/src/app/production/project-flock/chickin/page.tsx @@ -0,0 +1,10 @@ +import ChickinTable from '@/components/pages/production/chickin/ChickinTable'; + +const Chickin = () => { + return ( +
+ +
+ ); +}; +export default Chickin; diff --git a/src/app/production/project-flock/detail/edit/page.tsx b/src/app/production/project-flock/detail/edit/page.tsx index 858d0ca8..f55ce601 100644 --- a/src/app/production/project-flock/detail/edit/page.tsx +++ b/src/app/production/project-flock/detail/edit/page.tsx @@ -1,46 +1,51 @@ -'use client' +'use client'; - -import ProjectFlockForm from "@/components/pages/production/project-flock/form/ProjectFlockForm"; -import { isResponseError, isResponseSuccess } from "@/lib/api-helper"; -import { ProjectFlockApi } from "@/services/api/production"; -import { useRouter, useSearchParams } from "next/navigation"; -import useSWR from "swr"; +import ProjectFlockForm from '@/components/pages/production/project-flock/form/ProjectFlockForm'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { ProjectFlockApi } from '@/services/api/production/project-flock'; +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; const ProjectFlockEdit = () => { const router = useRouter(); const searchParams = useSearchParams(); - const projectFlockId = searchParams.get("projectFlockId"); + const projectFlockId = searchParams.get('projectFlockId'); - const { data: projectFlock, isLoading: isLoadingCostumer } = useSWR( - projectFlockId, - (id: number) => ProjectFlockApi.getSingle(id) - ); + const { + data: projectFlock, + isLoading: isLoadingProjectFlock, + mutate: refreshProjectFlocks, + } = useSWR(projectFlockId, (id: number) => ProjectFlockApi.getSingle(id)); - if(!projectFlockId){ + if (!projectFlockId) { router.back(); return ( -
- +
+
); } - if(!isLoadingCostumer && (!projectFlock || isResponseError(projectFlock))){ - router.replace("/404"); + if ( + !isLoadingProjectFlock && + (!projectFlock || isResponseError(projectFlock)) + ) { + router.replace('/404'); return; } return ( -
- {isLoadingCostumer && } - {!isLoadingCostumer && isResponseSuccess(projectFlock) && ( - +
+ {isLoadingProjectFlock && ( + + )} + {!isLoadingProjectFlock && isResponseSuccess(projectFlock) && ( + )}
- ) -} + ); +}; -export default ProjectFlockEdit; \ No newline at end of file +export default ProjectFlockEdit; diff --git a/src/app/production/project-flock/detail/layout.tsx b/src/app/production/project-flock/detail/layout.tsx index b41c70f9..7220dfa1 100644 --- a/src/app/production/project-flock/detail/layout.tsx +++ b/src/app/production/project-flock/detail/layout.tsx @@ -1,11 +1,11 @@ -import SuspenseHelper from "@/components/helper/SuspenseHelper" +import SuspenseHelper from '@/components/helper/SuspenseHelper'; const Layout = ({ - children + children, }: Readonly<{ - children: React.ReactNode + children: React.ReactNode; }>) => { - return {children} -} + return {children}; +}; -export default Layout; \ No newline at end of file +export default Layout; diff --git a/src/app/production/project-flock/detail/page.tsx b/src/app/production/project-flock/detail/page.tsx index bea96b84..91d4dfd5 100644 --- a/src/app/production/project-flock/detail/page.tsx +++ b/src/app/production/project-flock/detail/page.tsx @@ -2,7 +2,7 @@ import ProjectFlockForm from '@/components/pages/production/project-flock/form/ProjectFlockForm'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; -import { ProjectFlockApi } from '@/services/api/production'; +import { ProjectFlockApi } from '@/services/api/production/project-flock'; import { useRouter, useSearchParams } from 'next/navigation'; import useSWR from 'swr'; @@ -37,12 +37,16 @@ const ProjectFlockDetail = () => { } return ( -
+
{isLoadingProjectFlock && ( )} - {!isLoadingProjectFlock && isResponseSuccess(projectFlock) && ( - + {isResponseSuccess(projectFlock) && ( + )}
); diff --git a/src/app/production/project-flock/page.tsx b/src/app/production/project-flock/page.tsx index d264d9e4..79feb41f 100644 --- a/src/app/production/project-flock/page.tsx +++ b/src/app/production/project-flock/page.tsx @@ -1,11 +1,11 @@ -import ProjectFlockTable from "@/components/pages/production/project-flock/ProjectFlockTable"; +import ProjectFlockTable from '@/components/pages/production/project-flock/ProjectFlockTable'; const ProjectFlock = () => { return ( -
- +
+
); -} +}; export default ProjectFlock; diff --git a/src/app/production/recording/add/layout.tsx b/src/app/production/recording/add/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/production/recording/add/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from '@/components/helper/SuspenseHelper'; + +const Layout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return {children}; +}; + +export default Layout; diff --git a/src/app/production/recording/grading/add/page.tsx b/src/app/production/recording/grading/add/page.tsx new file mode 100644 index 00000000..9b918d98 --- /dev/null +++ b/src/app/production/recording/grading/add/page.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; +import GradingForm from '@/components/pages/production/recording/grading/form/GradingForm'; +import { RecordingApi } from '@/services/api/production'; +import { isResponseSuccess } from '@/lib/api-helper'; + +const AddGrading = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const recordingId = searchParams.get('recording_id'); + + const { data: recording, isLoading: isLoadingRecording } = useSWR( + recordingId && recordingId !== 'new' ? [recordingId] : null, + ([id]) => RecordingApi.getSingle(parseInt(id)) + ); + + if ( + recordingId && + recordingId !== 'new' && + !isLoadingRecording && + (!recording || !isResponseSuccess(recording)) + ) { + router.replace('/404'); + return; + } + + return ( +
+ {recordingId && recordingId !== 'new' && isLoadingRecording && ( + + )} + {(!recordingId || + recordingId === 'new' || + (!isLoadingRecording && recording && isResponseSuccess(recording))) && ( + + )} +
+ ); +}; + +export default AddGrading; diff --git a/src/app/production/recording/grading/detail/edit/page.tsx b/src/app/production/recording/grading/detail/edit/page.tsx new file mode 100644 index 00000000..0a65f528 --- /dev/null +++ b/src/app/production/recording/grading/detail/edit/page.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; +import GradingForm from '@/components/pages/production/recording/grading/form/GradingForm'; +import { RecordingApi } from '@/services/api/production'; +import { isResponseSuccess } from '@/lib/api-helper'; + +const EditGrading = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const recordingId = searchParams.get('recordingId'); + const gradingId = searchParams.get('gradingId'); + + const { data: recording, isLoading: isLoadingRecording } = useSWR( + recordingId ? [recordingId] : null, + ([id]) => RecordingApi.getSingle(parseInt(id)) + ); + + if (!recordingId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingRecording && (!recording || !isResponseSuccess(recording))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingRecording && ( + + )} + {!isLoadingRecording && recording && isResponseSuccess(recording) && ( + egg.id === parseInt(gradingId || '0') + )} + /> + )} +
+ ); +}; + +export default EditGrading; diff --git a/src/app/production/recording/grading/detail/page.tsx b/src/app/production/recording/grading/detail/page.tsx new file mode 100644 index 00000000..6a5fbcba --- /dev/null +++ b/src/app/production/recording/grading/detail/page.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; +import GradingForm from '@/components/pages/production/recording/grading/form/GradingForm'; +import { RecordingApi } from '@/services/api/production'; +import { isResponseSuccess } from '@/lib/api-helper'; + +const DetailGrading = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const gradingId = searchParams.get('gradingId'); + + const { data: grading, isLoading: isLoadingGrading } = useSWR( + gradingId ? [gradingId] : null, + ([id]) => RecordingApi.getSingle(parseInt(id)) + ); + + if (!gradingId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingGrading && (!grading || !isResponseSuccess(grading))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingGrading && ( + + )} + {!isLoadingGrading && grading && isResponseSuccess(grading) && ( + egg.id === parseInt(gradingId) + )} + /> + )} +
+ ); +}; + +export default DetailGrading; diff --git a/src/app/production/recording/grading/layout.tsx b/src/app/production/recording/grading/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/production/recording/grading/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from '@/components/helper/SuspenseHelper'; + +const Layout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return {children}; +}; + +export default Layout; diff --git a/src/app/production/transfer-to-laying/detail/edit/page.tsx b/src/app/production/transfer-to-laying/detail/edit/page.tsx index 9003dbba..d5498e08 100644 --- a/src/app/production/transfer-to-laying/detail/edit/page.tsx +++ b/src/app/production/transfer-to-laying/detail/edit/page.tsx @@ -8,91 +8,6 @@ import TransferToLayingForm from '@/components/pages/production/transfer-to-layi import { TransferToLayingApi } from '@/services/api/production/transfer-to-laying'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; -import { TransferToLaying } from '@/types/api/production/transfer-to-laying'; - -// TODO: delete dummy data -const DUMMY_TRANSFER_TO_LAYING_EDIT: TransferToLaying = { - id: 1, - transfer_date: '2025-10-14', - flock_source: { - id: 1, - name: 'Flock asal test', - }, - flock_destination: { - id: 2, - name: 'Flock tujuan destination', - }, - quantity: 10, - kandangs: [ - { - kandang: { - id: 1, - name: 'Kandang test', - status: 'ACTIVE', - location: { - id: 1, - name: 'test location', - address: 'test address 1', - area: { id: 1, name: 'test area 1' }, - }, - pic: { - id: 1, - id_user: 2, - email: 'test@gmail.com', - name: 'test', - }, - created_user: { - id: 1, - id_user: 2, - email: 'test@gmail.com', - name: 'test', - }, - created_at: '14-10-2025', - updated_at: '14-10-2025', - }, - quantity: 8, - }, - { - kandang: { - id: 1, - name: 'Kandang test 2', - status: 'ACTIVE', - location: { - id: 1, - name: 'test location', - address: 'test address 1', - area: { id: 1, name: 'test area 1' }, - }, - pic: { - id: 1, - id_user: 2, - email: 'test@gmail.com', - name: 'test', - }, - created_user: { - id: 1, - id_user: 2, - email: 'test@gmail.com', - name: 'test', - }, - created_at: '14-10-2025', - updated_at: '14-10-2025', - }, - quantity: 2, - }, - ], - reason: 'Test alasan', - - created_user: { - id: 1, - id_user: 2, - email: 'test@gmail.com', - name: 'test', - }, - created_at: '14-10-2025', - updated_at: '14-10-2025', -}; - const TransferToLayingEdit = () => { const router = useRouter(); const searchParams = useSearchParams(); @@ -114,33 +29,33 @@ const TransferToLayingEdit = () => { ); } - // TODO: remove dummy data and integrate with real API if ( !isLoadingTransferToLaying && - (!transferToLaying || - (isResponseError(transferToLaying) && !DUMMY_TRANSFER_TO_LAYING_EDIT)) + (!transferToLaying || isResponseError(transferToLaying)) ) { router.replace('/404'); return; } + if ( + isResponseSuccess(transferToLaying) && + transferToLaying.data.approval.step_number === 2 + ) { + router.replace('/production/transfer-to-laying'); + return; + } + return (
{isLoadingTransferToLaying && ( )} - {/* {!isLoadingTransferToLaying && isResponseSuccess(transferToLaying) && ( + {!isLoadingTransferToLaying && isResponseSuccess(transferToLaying) && ( - )} */} - - {/* TODO: remove this dummy data and integrate to real API */} - + )}
); }; diff --git a/src/app/production/transfer-to-laying/detail/page.tsx b/src/app/production/transfer-to-laying/detail/page.tsx index de5426c8..9ff6ed5e 100644 --- a/src/app/production/transfer-to-laying/detail/page.tsx +++ b/src/app/production/transfer-to-laying/detail/page.tsx @@ -8,91 +8,6 @@ import TransferToLayingForm from '@/components/pages/production/transfer-to-layi import { TransferToLayingApi } from '@/services/api/production/transfer-to-laying'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; -import { TransferToLaying } from '@/types/api/production/transfer-to-laying'; - -// TODO: delete dummy data -const DUMMY_TRANSFER_TO_LAYING_DETAIL: TransferToLaying = { - id: 1, - transfer_date: '2025-10-14', - flock_source: { - id: 1, - name: 'Flock asal test', - }, - flock_destination: { - id: 2, - name: 'Flock tujuan destination', - }, - quantity: 10, - kandangs: [ - { - kandang: { - id: 1, - name: 'Kandang test', - status: 'ACTIVE', - location: { - id: 1, - name: 'test location', - address: 'test address 1', - area: { id: 1, name: 'test area 1' }, - }, - pic: { - id: 1, - id_user: 2, - email: 'test@gmail.com', - name: 'test', - }, - created_user: { - id: 1, - id_user: 2, - email: 'test@gmail.com', - name: 'test', - }, - created_at: '14-10-2025', - updated_at: '14-10-2025', - }, - quantity: 8, - }, - { - kandang: { - id: 1, - name: 'Kandang test 2', - status: 'ACTIVE', - location: { - id: 1, - name: 'test location', - address: 'test address 1', - area: { id: 1, name: 'test area 1' }, - }, - pic: { - id: 1, - id_user: 2, - email: 'test@gmail.com', - name: 'test', - }, - created_user: { - id: 1, - id_user: 2, - email: 'test@gmail.com', - name: 'test', - }, - created_at: '14-10-2025', - updated_at: '14-10-2025', - }, - quantity: 2, - }, - ], - reason: 'Test alasan', - - created_user: { - id: 1, - id_user: 2, - email: 'test@gmail.com', - name: 'test', - }, - created_at: '14-10-2025', - updated_at: '14-10-2025', -}; - const TransferToLayingDetail = () => { const router = useRouter(); const searchParams = useSearchParams(); @@ -114,11 +29,9 @@ const TransferToLayingDetail = () => { ); } - // TODO: remove dummy data and integrate with real API if ( !isLoadingTransferToLaying && - (!transferToLaying || - (isResponseError(transferToLaying) && !DUMMY_TRANSFER_TO_LAYING_DETAIL)) + (!transferToLaying || isResponseError(transferToLaying)) ) { router.replace('/404'); return; @@ -129,18 +42,13 @@ const TransferToLayingDetail = () => { {isLoadingTransferToLaying && ( )} - {/* {!isLoadingTransferToLaying && isResponseSuccess(transferToLaying) && ( + + {!isLoadingTransferToLaying && isResponseSuccess(transferToLaying) && ( - )} */} - - {/* TODO: remove this dummy data and integrate to real API */} - + )}
); }; diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 7cad5b58..2f209ece 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -3,7 +3,7 @@ import Link from 'next/link'; import { cn } from '@/lib/helper'; import { Color } from '@/types/theme'; -interface ButtonProps extends react.ComponentProps<'button'> { +export interface ButtonProps extends react.ComponentProps<'button'> { variant?: 'soft' | 'outline' | 'dash' | 'ghost' | 'link' | 'active'; color?: Color; href?: string; diff --git a/src/components/Card.tsx b/src/components/Card.tsx index 06438390..7b022971 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -1,13 +1,12 @@ 'use client'; -import { - HTMLAttributes, - ReactNode, -} from 'react'; +import { HTMLAttributes, ReactNode } from 'react'; import { cn } from '@/lib/helper'; +import Image from 'next/image'; -export interface CardProps extends Omit, 'className'> { +export interface CardProps + extends Omit, 'className'> { title?: string; subtitle?: string; image?: string; @@ -44,17 +43,17 @@ const Card = ({ const baseClasses = 'card bg-base-100'; const variantClasses = { - 'default': '', - 'compact': 'card-compact', - 'bordered': 'border border-base-300', - 'shadow': 'shadow-xl', + default: '', + compact: 'card-compact', + bordered: 'border border-base-300', + shadow: 'shadow-xl', 'image-full': 'card-side card-compact shadow-xl', }; const sizeClasses = { - 'sm': 'w-64', - 'md': 'w-96', - 'lg': 'w-[28rem]', + sm: 'w-64', + md: 'w-96', + lg: 'w-[28rem]', }; return cn( @@ -84,9 +83,9 @@ const Card = ({ const getTitleClasses = () => { const sizeClasses = { - 'sm': 'text-lg', - 'md': 'text-xl', - 'lg': 'text-2xl', + sm: 'text-lg', + md: 'text-xl', + lg: 'text-2xl', }; return cn('card-title font-bold', sizeClasses[size], className?.title); @@ -108,7 +107,7 @@ const Card = ({ return (
- {imageAlt {image && (
- {imageAlt { @@ -8,31 +15,34 @@ export const useModal = () => { const [open, setOpen] = useState(false); const openModal = useCallback(() => { + if (!ref.current) return; + ref.current.show(); setOpen(true); - - ref.current?.showModal(); }, []); const closeModal = useCallback(() => { + if (!ref.current) return; + ref.current.close(); setOpen(false); - ref.current?.close(); }, []); const toggle = useCallback(() => { - if (open) { - closeModal(); - } else { - openModal(); - } + open ? closeModal() : openModal(); }, [open, closeModal, openModal]); - if (ref.current) { - ref.current.addEventListener('close', () => { - closeModal(); - }); - } + useEffect(() => { + const dialog = ref.current; + if (!dialog) return; - return { ref, open, setOpen, openModal, closeModal, toggle } as const; + const handleClose = () => setOpen(false); + dialog.addEventListener('close', handleClose); + + return () => { + dialog.removeEventListener('close', handleClose); + }; + }, []); + + return { ref, open, openModal, closeModal, toggle } as const; }; interface ModalProps { @@ -46,15 +56,19 @@ interface ModalProps { } const Modal = ({ ref, children, closeOnBackdrop, className }: ModalProps) => { - return ( - -
{children}
+ const handleBackdropClick = (e: React.MouseEvent) => { + if (closeOnBackdrop && e.target === ref.current) { + ref.current?.close(); + } + }; - {closeOnBackdrop && ( -
- -
- )} + return ( + +
{children}
); }; diff --git a/src/components/Pagination.tsx b/src/components/Pagination.tsx index 86d3a67a..e47e480d 100644 --- a/src/components/Pagination.tsx +++ b/src/components/Pagination.tsx @@ -185,17 +185,17 @@ const Pagination = ({ currentPage <= 2 ? currentPage + 2 : currentPage === totalPages - 2 - ? 3 - : currentPage >= totalPages - 1 - ? 4 - : 1 + ? 3 + : currentPage >= totalPages - 1 + ? 4 + : 1 } endPage={ currentPage <= 2 || currentPage >= totalPages - 1 ? totalPages - 3 : currentPage === totalPages - 2 - ? totalPages - 4 - : 2 + ? totalPages - 4 + : 2 } onPageItemClick={pageChangeHandler} /> @@ -242,15 +242,15 @@ const Pagination = ({ currentPage <= 3 ? currentPage + 2 : currentPage >= 4 - ? currentPage + 2 - : 1 + ? currentPage + 2 + : 1 } endPage={ currentPage <= 3 ? totalPages - 2 : currentPage >= 4 - ? totalPages - 1 - : 0 + ? totalPages - 1 + : 0 } onPageItemClick={pageChangeHandler} /> diff --git a/src/components/Table.tsx b/src/components/Table.tsx index d3498e33..b02dd3b5 100644 --- a/src/components/Table.tsx +++ b/src/components/Table.tsx @@ -13,6 +13,7 @@ import { FilterFn, SortingState, OnChangeFn, + Row, } from '@tanstack/react-table'; import { rankItem } from '@tanstack/match-sorter-utils'; import { Icon } from '@iconify/react'; @@ -50,6 +51,7 @@ export interface TableProps { manualSorting?: boolean; rowSelection?: Record; setRowSelection?: OnChangeFn>; + enableRowSelection?: boolean | ((row: Row) => boolean); } const DUMMY_SKELETON_DATA = [{}, {}, {}, {}, {}]; @@ -90,6 +92,7 @@ const Table = ({ manualSorting = false, rowSelection, setRowSelection, + enableRowSelection, }: TableProps) => { const isServerSideTable = totalItems !== undefined && @@ -150,6 +153,10 @@ const Table = ({ tableOptions.getRowId = (row) => (row as { id: string }).id; } + if (enableRowSelection !== undefined) { + tableOptions.enableRowSelection = enableRowSelection; + } + const table = useReactTable(tableOptions); const { setPageSize } = table; diff --git a/src/components/Tabs.tsx b/src/components/Tabs.tsx new file mode 100644 index 00000000..2ad2477d --- /dev/null +++ b/src/components/Tabs.tsx @@ -0,0 +1,129 @@ +import { HTMLAttributes, ReactNode, useEffect, useState } from 'react'; +import { cn } from '@/lib/helper'; + +export interface TabItem { + id: string; + label: ReactNode; + content?: ReactNode; + disabled?: boolean; +} + +export interface TabsProps + extends Omit, 'className'> { + tabs: TabItem[]; + variant?: 'bordered' | 'lifted' | 'boxed'; + size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + placement?: 'top' | 'bottom'; + /** Tab yang aktif secara default (uncontrolled mode) */ + defaultActiveId?: string; + /** Tab yang aktif (controlled mode, dikontrol parent) */ + activeTabId?: string; + className?: + | string + | { + wrapper?: string; + tab?: string; + content?: string; + }; + onTabChange?: (tabId: string) => void; +} + +const Tabs = ({ + tabs, + variant, + size = 'md', + placement = 'top', + defaultActiveId, + activeTabId: controlledActiveId, + className, + onTabChange, + ...props +}: TabsProps) => { + // State internal hanya dipakai kalau `activeTabId` (controlled) tidak diset + const [uncontrolledActiveId, setUncontrolledActiveId] = useState( + defaultActiveId || tabs[0]?.id || '' + ); + + const isControlled = controlledActiveId !== undefined; + const activeTabId = isControlled ? controlledActiveId : uncontrolledActiveId; + + const handleTabChange = (tabId: string) => { + if (tabId === activeTabId) return; + if (!isControlled) setUncontrolledActiveId(tabId); + onTabChange?.(tabId); + }; + + const { wrapper: wrapperClassName, tab: tabClassName } = + typeof className === 'object' + ? className + : { wrapper: className, tab: undefined }; + + const getTabsClasses = () => { + const variantClasses: Record = { + bordered: 'tabs-bordered', + lifted: 'tabs-lift', + boxed: 'tabs-box', + }; + + const sizeClasses: Record = { + xs: 'tabs-xs', + sm: 'tabs-sm', + md: '', + lg: 'tabs-lg', + xl: 'tabs-xl', + }; + + const placementClasses: Record = { + top: '', + bottom: 'tabs-bottom', + }; + + return cn( + 'tabs', + variant && variantClasses[variant], + sizeClasses[size], + placementClasses[placement], + wrapperClassName + ); + }; + + const getTabClasses = (isActive: boolean, isDisabled?: boolean) => + cn( + 'tab', + { + 'tab-active': isActive, + 'tab-disabled': isDisabled, + }, + tabClassName + ); + + const activeContent = tabs.find((tab) => tab.id === activeTabId)?.content; + + return ( +
+
+ {tabs.map(({ id, label, disabled }) => ( + + ))} +
+ + {activeContent &&
{activeContent}
} +
+ ); +}; + +export default Tabs; diff --git a/src/components/helper/form/FormActions.tsx b/src/components/helper/form/FormActions.tsx index 92c2a92c..4968f93e 100644 --- a/src/components/helper/form/FormActions.tsx +++ b/src/components/helper/form/FormActions.tsx @@ -9,6 +9,11 @@ interface FormActionsProps { editUrl?: string; onDelete?: () => void; disableSubmit?: boolean; + onApprove?: () => void; + onReject?: () => void; + isApproveLoading?: boolean; + isRejectLoading?: boolean; + showApproveReject?: boolean; } export const FormActions = ({ @@ -17,25 +22,32 @@ export const FormActions = ({ editUrl, onDelete, disableSubmit = false, + onApprove, + onReject, + isApproveLoading = false, + isRejectLoading = false, + showApproveReject = false, }: FormActionsProps) => { return (
- {type !== 'add' && onDelete && ( + {type !== 'add' && (
- + {onDelete && ( + + )} {type !== 'edit' && editUrl && ( )} + {type === 'detail' && + showApproveReject && + (onApprove || onReject) && ( + <> + {onApprove && ( + + )} + {onReject && ( + + )} + + )}
)} {type !== 'detail' && ( diff --git a/src/components/helper/form/FormHeader.tsx b/src/components/helper/form/FormHeader.tsx index ebc1d7ae..de7ec882 100644 --- a/src/components/helper/form/FormHeader.tsx +++ b/src/components/helper/form/FormHeader.tsx @@ -2,15 +2,27 @@ import Button from '@/components/Button'; import { Icon } from '@iconify/react'; interface FormHeaderProps { - type: 'add' | 'edit' | 'detail'; + type?: 'add' | 'edit' | 'detail'; title: string; - backUrl: string; + backUrl?: string; + onBackClick?: () => void; } -export const FormHeader = ({ type, title, backUrl }: FormHeaderProps) => { +export const FormHeader = ({ + type, + title, + backUrl, + onBackClick, +}: FormHeaderProps) => { return (
- @@ -18,6 +30,7 @@ export const FormHeader = ({ type, title, backUrl }: FormHeaderProps) => { {type === 'add' && `Tambah ${title}`} {type === 'edit' && `Edit ${title}`} {type === 'detail' && `Detail ${title}`} + {!type && title}
); diff --git a/src/components/input/DateInput.tsx b/src/components/input/DateInput.tsx index be485b75..a85c1f10 100644 --- a/src/components/input/DateInput.tsx +++ b/src/components/input/DateInput.tsx @@ -3,16 +3,21 @@ import { ChangeEventHandler, FocusEventHandler, - ReactNode, + useEffect, + useState, } from 'react'; - -import { cn } from '@/lib/helper'; +import { cn, formatDate } from '@/lib/helper'; +import Modal, { useModal } from '@/components/Modal'; +import { DateRange, DayPicker, Matcher } from 'react-day-picker'; +import 'react-day-picker/dist/style.css'; +import Button from '@/components/Button'; +import { Icon } from '@iconify/react'; export interface DateInputProps { label?: string; bottomLabel?: string; name: string; - value?: string; + value?: string | { from?: string; to?: string }; placeholder?: string; min?: string; max?: string; @@ -28,9 +33,8 @@ export interface DateInputProps { readOnly?: boolean; required?: boolean; isLoading?: boolean; + isRange?: boolean; errorMessage?: string; - startAdornment?: ReactNode; - endAdornment?: ReactNode; onChange?: ChangeEventHandler; onBlur?: FocusEventHandler; } @@ -40,22 +44,144 @@ const DateInput = ({ bottomLabel, name, value, - placeholder, + placeholder = 'dd/mm/yyyy', min, max, className, - isError, - isValid, - errorMessage, - startAdornment, - endAdornment, + isError: externalError, + isValid: externalValid, + errorMessage: externalErrorMessage, disabled = false, required = false, onChange, onBlur, readOnly = false, isLoading = false, + isRange = false, }: DateInputProps) => { + const [internalError, setInternalError] = useState(null); + const [selected, setSelected] = useState(); + const [selectedRange, setSelectedRange] = useState<{ + from?: Date; + to?: Date; + }>({}); + const [displayValue, setDisplayValue] = useState(''); + + const minDate = min + ? new Date(min.split('/').reverse().join('-')) + : undefined; + const maxDate = max + ? new Date(max.split('/').reverse().join('-')) + : undefined; + + const calendarModal = useModal(); + + // --- Sync value props --- + useEffect(() => { + if (!value) return; + if (isRange && typeof value === 'object') { + const from = value.from ? new Date(value.from) : undefined; + const to = value.to ? new Date(value.to) : undefined; + setSelectedRange({ from, to }); + setDisplayValue( + `${from ? formatDate(from, 'DD/MM/YYYY') : ''} ${ + to ? '- ' + formatDate(to, 'DD/MM/YYYY') : '' + }` + ); + } else if (typeof value === 'string') { + const iso = value.includes('/') + ? value.split('/').reverse().join('-') + : value; + const date = new Date(iso); + setSelected(date); + setDisplayValue(formatDate(iso, 'DD/MM/YYYY')); + } + }, [value, isRange]); + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + if (!disabled && !readOnly) calendarModal.openModal(); + }; + + const handleBlur: FocusEventHandler = (e) => { + onBlur?.(e); + }; + + const handleSelectSingle = (selectedDate?: Date) => { + if (!selectedDate) return; + if (minDate && selectedDate < minDate) { + setInternalError(`Tanggal tidak boleh sebelum ${min}`); + return; + } + if (maxDate && selectedDate > maxDate) { + setInternalError(`Tanggal tidak boleh setelah ${max}`); + return; + } + setInternalError(null); + setSelected(selectedDate); + const formattedDisplay = formatDate(selectedDate, 'DD/MM/YYYY'); + const formattedISO = formatDate(selectedDate, 'YYYY-MM-DD'); + setDisplayValue(formattedDisplay); + + const syntheticEvent = { + target: { name, value: formattedISO }, + } as unknown as React.ChangeEvent; + onChange?.(syntheticEvent); + calendarModal.closeModal(); + }; + + const handleSelectRange = (range?: { from?: Date; to?: Date }) => { + if (!range) return; + setSelectedRange(range); + + const fromStr = range.from ? formatDate(range.from, 'DD/MM/YYYY') : ''; + const toStr = range.to ? formatDate(range.to, 'DD/MM/YYYY') : ''; + setDisplayValue(`${fromStr}${toStr ? ' - ' + toStr : ''}`); + + // Jika kedua tanggal sudah terpilih + if (range.from && range.to) { + if (minDate && range.from < minDate) { + setInternalError(`Tanggal mulai tidak boleh sebelum ${min}`); + return; + } + if (maxDate && range.to > maxDate) { + setInternalError(`Tanggal akhir tidak boleh setelah ${max}`); + return; + } + + setInternalError(null); + const syntheticEvent = { + target: { + name, + value: { + from: formatDate(range.from, 'YYYY-MM-DD'), + to: formatDate(range.to, 'YYYY-MM-DD'), + }, + }, + } as unknown as React.ChangeEvent; + onChange?.(syntheticEvent); + } + }; + + const handleResetDate = () => { + setSelected(undefined); + setSelectedRange({}); + setDisplayValue(''); + const syntheticEvent = { + target: { name, value: isRange ? { from: '', to: '' } : '' }, + } as unknown as React.ChangeEvent; + onChange?.(syntheticEvent); + calendarModal.closeModal(); + }; + + const handleSaveDate = () => { + if (internalError) return; + calendarModal.closeModal(); + }; + + const finalIsError = externalError || !!internalError; + const finalErrorMessage = internalError || externalErrorMessage; + return (
{label} {required && ( - <> + {' '} - - * - - + * + )} )}
- {startAdornment && startAdornment} - - {(isLoading || endAdornment) && ( + {isLoading && (
- {isLoading && } - {endAdornment && endAdornment} +
)} + + handleClick(e as unknown as React.MouseEvent) + } + />
- {!isError && bottomLabel && ( + {!finalIsError && bottomLabel && (

{bottomLabel}

)} - {isError && errorMessage && ( -

{errorMessage}

+ {finalIsError && finalErrorMessage && ( +

{finalErrorMessage}

)} + + + {isRange ? ( + {displayValue}
} + disabled={ + [ + minDate ? { before: minDate } : undefined, + maxDate ? { after: maxDate } : undefined, + ].filter(Boolean) as Matcher[] + } + /> + ) : ( + + )} +
+ {isRange && ( + + Tekan dua kali untuk memilih tanggal awal + + )} + +
+ + {isRange && ( + + )} +
+
+
); }; diff --git a/src/components/input/DropFileInput.tsx b/src/components/input/DropFileInput.tsx new file mode 100644 index 00000000..e146a994 --- /dev/null +++ b/src/components/input/DropFileInput.tsx @@ -0,0 +1,194 @@ +import { useEffect } from 'react'; +import { useDropzone, type Accept } from 'react-dropzone'; + +import { Icon } from '@iconify/react'; +import Button from '@/components/Button'; + +import { cn } from '@/lib/helper'; + +interface DropFileInputProps { + name: string; + label?: string; + bottomLabel?: string; + caption?: string; + values?: File[]; + accept?: Accept; + required?: boolean; + maxFiles?: number; // defaults to 1 + maxSize?: number; // defaults to 2097152 (2 MB) + isError?: boolean; + errorMessage?: string; + disabled?: boolean; + onChange?: (files: File[]) => void; + onDelete?: (index: number) => void; + className?: { + wrapper?: string; + inputContainer?: string; + label?: string; + inputWrapper?: string; + caption?: string; + bottomLabel?: string; + errorMessage?: string; + fileItemContainer?: string; + }; +} + +const DropFileInput: React.FC = ({ + name, + label, + bottomLabel, + caption = 'Seret atau Pilih Dokumen', + values, + accept, + required, + maxFiles = Infinity, + maxSize, + isError, + errorMessage, + disabled, + onChange, + onDelete, + className, +}) => { + const isDisabled = + Boolean(values && maxFiles && values.length >= maxFiles) || disabled; + + const { + acceptedFiles, + getRootProps, + getInputProps, + isFocused, + isDragAccept, + isDragReject, + } = useDropzone({ + maxSize, + maxFiles, + accept: accept, + disabled: isDisabled, + }); + + useEffect(() => { + if (values && maxFiles && values.length <= maxFiles) { + onChange?.([...values, ...acceptedFiles]); + } + }, [acceptedFiles]); + + return ( +
+
+ {label && ( + + )} + +
+ + {caption && ( +

+ {caption} +

+ )} +
+ + {!isError && bottomLabel && ( +

+ {bottomLabel} +

+ )} + {isError && ( +

+ {errorMessage} +

+ )} +
+ + {values && values.length > 0 && ( +
+ {values.map((file, idx) => ( +
+
+ +
+ +
+

{file.name}

+
+ + +
+ ))} +
+ )} +
+ ); +}; + +export default DropFileInput; diff --git a/src/components/input/FileInput.tsx b/src/components/input/FileInput.tsx index 86100e40..aee7cb78 100644 --- a/src/components/input/FileInput.tsx +++ b/src/components/input/FileInput.tsx @@ -69,10 +69,7 @@ const FileInput = ({ onChange={onChange} onBlur={onBlur} disabled={disabled} - className={cn( - 'grow file-input w-full h-12 rounded', - className?.input - )} + className={cn('grow file-input w-full h-12 rounded', className?.input)} readOnly={readOnly} /> diff --git a/src/components/input/NumberInput.tsx b/src/components/input/NumberInput.tsx index 89b02845..e6e0e773 100644 --- a/src/components/input/NumberInput.tsx +++ b/src/components/input/NumberInput.tsx @@ -1,10 +1,10 @@ -"use client"; +'use client'; -import { ChangeEvent, ReactNode } from "react"; -import { NumericFormat, OnValueChange } from "react-number-format"; -import TextInput, { TextInputProps } from "@/components/input/TextInput"; +import { ChangeEvent, ReactNode } from 'react'; +import { NumericFormat, OnValueChange } from 'react-number-format'; +import TextInput, { TextInputProps } from '@/components/input/TextInput'; -interface NumberInputProps extends Omit { +interface NumberInputProps extends Omit { thousandSeparator?: string; decimalSeparator?: string; decimalScale?: number; @@ -17,8 +17,8 @@ interface NumberInputProps extends Omit { } const NumberInput = ({ - thousandSeparator = ",", - decimalSeparator = ".", + thousandSeparator = ',', + decimalSeparator = '.', decimalScale = 5, allowNegative = true, onChange, @@ -28,7 +28,7 @@ const NumberInput = ({ }: NumberInputProps) => { const valueChangeHandler: OnValueChange = ( numberFormatValues, - sourceInfo, + sourceInfo ) => { const newChangeEvent = sourceInfo.event as | ChangeEvent @@ -49,8 +49,8 @@ const NumberInput = ({ onValueChange={valueChangeHandler} decimalScale={decimalScale} allowNegative={allowNegative} - startAdornment={inputPrefix} - endAdornment={inputSuffix} + inputPrefix={inputPrefix} + inputSuffix={inputSuffix} {...restProps} /> ); diff --git a/src/components/input/PatternInput.tsx b/src/components/input/PatternInput.tsx new file mode 100644 index 00000000..9af1b68e --- /dev/null +++ b/src/components/input/PatternInput.tsx @@ -0,0 +1,90 @@ +'use client'; + +import { ChangeEvent } from 'react'; +import { + PatternFormat, + NumberFormatBase, + NumberFormatBaseProps, + OnValueChange, +} from 'react-number-format'; +import TextInput, { TextInputProps } from '@/components/input/TextInput'; + +interface PatternInputProps extends Omit { + /** + * Format pattern, contoh: "##/##/####", "(###) ###-####", "####-####-####" + */ + format: string; + /** Mask karakter kosong, misal "_" */ + mask?: string; + /** Menampilkan mask walau value kosong */ + allowEmptyFormatting?: boolean; + /** Placeholder karakter format, default: "#" */ + patternChar?: string; + /** Jika true, izinkan huruf (A-Z) selain angka */ + inputVehicleNumber?: boolean; + type?: 'text' | 'password' | 'tel'; +} + +/** + * PatternInput – tetap backward-compatible dengan Storybook + * tapi bisa menerima huruf jika `allowCharacters={true}` + */ +const PatternInput = ({ + type = 'text', + format, + mask = '_', + allowEmptyFormatting = false, + patternChar = '#', + inputVehicleNumber = false, + onChange, + ...restProps +}: PatternInputProps) => { + const handleValueChange: OnValueChange = (values, { event }) => { + const newEvent = event as ChangeEvent | undefined; + if (newEvent) { + newEvent.target.value = values.value.toUpperCase(); + onChange?.(newEvent); + } + }; + + if (inputVehicleNumber) { + return ( + { + const clean = value.replace(/[^a-z0-9]/gi, '').toUpperCase(); + + const match = clean.match(/^([A-Z]{0,2})(\d{0,4})([A-Z]{0,3})$/); + if (!match) return clean; + const [, prefix, number, suffix] = match; + return [prefix, number, suffix].filter(Boolean).join(' '); + }} + removeFormatting={(val) => val.replace(/\s+/g, '')} + isValidInputCharacter={(char) => /^[a-z0-9]$/i.test(char)} + getCaretBoundary={(val) => + Array(val.length + 1) + .fill(true) + .map(Boolean) + } + onValueChange={handleValueChange} + /> + ); + } + + return ( + + ); +}; + +export default PatternInput; diff --git a/src/components/input/SelectInput.tsx b/src/components/input/SelectInput.tsx index 6a8d0ac8..d35e7589 100644 --- a/src/components/input/SelectInput.tsx +++ b/src/components/input/SelectInput.tsx @@ -1,22 +1,23 @@ 'use client'; import { ComponentType, ReactNode, useEffect, useMemo, useState } from 'react'; -import useSWR from 'swr'; - import Select, { OptionProps, GroupBase, InputActionMeta, MultiValue, SingleValue, + components as ReactSelectComponents, + ControlProps, } from 'react-select'; import CreatableSelect from 'react-select/creatable'; import makeAnimated from 'react-select/animated'; import { useDebounce } from 'use-debounce'; import { cn, getByPath } from '@/lib/helper'; +import useSWR from 'swr'; import { httpClientFetcher } from '@/services/http/client'; -import { isResponseSuccess } from '@/lib/api-helper'; import { BaseApiResponse } from '@/types/api/api-general'; +import { isResponseSuccess } from '@/lib/api-helper'; export interface OptionType { value: string | number; @@ -53,6 +54,8 @@ interface SelectInputBaseProps { openMenu?: boolean; delay?: number; onInputChange?: (search: string) => void; + startAdornment?: ReactNode; + menuPortalTarget?: HTMLElement | null; } interface SelectInputProps extends SelectInputBaseProps { @@ -63,6 +66,33 @@ interface SelectInputProps extends SelectInputBaseProps { const animatedComponents = makeAnimated(); +const CustomControl = < + Option, + IsMulti extends boolean, + Group extends GroupBase