Compare commits

...

29 Commits

Author SHA1 Message Date
Naveen cb6b92d221 Add backup images for login and background removal
- Added loginerps.png.bak (1134 KB) as a backup for the login image.
- Added whithoutbackground.png.bak (3455 KB) as a backup for the image without background.
2026-09-19 00:12:41 +05:30
ImanThiyanga 3f10ae3bca changes fix for production 2026-09-19 00:00:42 +05:30
ImanThiyanga 61fe1a0e0c Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Successful in 31s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 13m12s
2026-09-18 18:23:35 +00:00
ImanThiyanga 941f20d4c5 migrations fix 2026-09-18 23:52:26 +05:30
kalana_Thilakarathna b06d20e649 Merge pull request 'test to yml' (#71) from Dev into production
Reviewed-on: #71
2026-09-18 05:23:55 +00:00
Kalana B. Thilakarathna 445d323bed test to yml
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 14m24s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 14m59s
2026-09-18 10:52:17 +05:30
kalana_Thilakarathna 9028255800 Merge pull request 'workflowupdate' (#70) from Dev into production
Reviewed-on: #70
2026-09-18 04:51:58 +00:00
Kalana B. Thilakarathna 1b2f244853 workflowupdate
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 11m21s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 14m6s
2026-09-18 10:21:36 +05:30
kalana_Thilakarathna 475bcfec1a Merge pull request 'pipeline update' (#69) from Dev into production
Reviewed-on: #69
2026-09-18 04:48:49 +00:00
Kalana B. Thilakarathna 85db26ec82 pipeline update
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 14m30s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 14m58s
2026-09-18 10:18:13 +05:30
kalana_Thilakarathna baee987d26 Merge pull request 'test' (#68) from Dev into production
Reviewed-on: #68
2026-09-17 08:25:31 +00:00
Kalana B. Thilakarathna f45a59f20d test
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 12m49s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 14m57s
2026-09-17 13:55:06 +05:30
kalana_Thilakarathna 32304cbaff Merge pull request 'user fix at workflow' (#67) from Dev into production
Reviewed-on: #67
2026-09-17 07:35:02 +00:00
Kalana B. Thilakarathna 2b64bb0ebc user fix at workflow
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 13m17s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Has been cancelled
2026-09-17 13:04:15 +05:30
kalana_Thilakarathna ffe5251236 Merge pull request 'workflow fix' (#66) from Dev into production
Reviewed-on: #66
2026-09-17 07:26:14 +00:00
Kalana B. Thilakarathna d04a7fb3bc workflow fix
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Has been cancelled
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Has been cancelled
2026-09-17 12:55:38 +05:30
kalana_Thilakarathna d76595085e Merge pull request 'workflow fix' (#65) from Dev into production
Reviewed-on: #65
2026-09-17 07:08:22 +00:00
Kalana B. Thilakarathna bad33a8492 workflow fix
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 13m57s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Has been cancelled
2026-09-17 12:38:05 +05:30
kalana_Thilakarathna b0216fca30 Merge pull request 'triger erpo core workflow' (#64) from Dev into production
Reviewed-on: #64
2026-09-17 07:07:24 +00:00
Kalana B. Thilakarathna 49c112d188 triger erpo core workflow 2026-09-17 12:37:04 +05:30
kalana_Thilakarathna c836b57d54 Merge pull request 'gitea work flow update' (#63) from Dev into production
Reviewed-on: #63
2026-09-17 07:06:02 +00:00
Kalana B. Thilakarathna 3a583f80e5 gitea work flow update 2026-09-17 12:28:31 +05:30
ImanThiyanga 83e0335504 Merge pull request 'Dev' (#62) from Dev into production
Reviewed-on: #62
2026-09-17 05:58:25 +00:00
ImanThiyanga 513343b62a Merge pull request 'Revert/ci cd merges' (#61) from revert/ci-cd-merges into Dev
Reviewed-on: #61
2026-09-17 05:57:56 +00:00
ImanThiyanga 256e39ccff Revert "Merge pull request 'Dev' (#58) from Dev into production"
This reverts commit 821e96e011, reversing
changes made to 0ff39e9e58.
2026-09-17 11:25:33 +05:30
ImanThiyanga efb37fce7a Revert "Merge pull request 'Dev' (#60) from Dev into production"
This reverts commit 01fc2582b6, reversing
changes made to 821e96e011.
2026-09-17 11:25:33 +05:30
ImanThiyanga 01fc2582b6 Merge pull request 'Dev' (#60) from Dev into production
Reviewed-on: #60
2026-09-16 18:47:15 +00:00
ImanThiyanga 39a2dc60be Merge pull request 'Feat/ci cd implementation' (#59) from feat/ci-cd-implementation into Dev
CI/CD - ERP Core / Build & Deploy (pull_request) Failing after 13m56s
CI/CD - ERP Core / detect (pull_request) Failing after 14m48s
CI/CD - ERP Core / scripts (pull_request) Failing after 12m24s
CI/CD - ERP Core / e2e-typecheck (pull_request) Failing after 13m15s
CI/CD - ERP Core / frontend (pull_request) Failing after 14m7s
CI/CD - ERP Core / backend (pull_request) Failing after 14m59s
CI/CD - ERP Core / ci-ok (pull_request) Failing after 14m58s
Reviewed-on: #59
2026-09-16 18:30:42 +00:00
ImanThiyanga 821e96e011 Merge pull request 'Dev' (#58) from Dev into production
Reviewed-on: #58
2026-09-16 06:37:25 +00:00
129 changed files with 17379 additions and 3314 deletions
-10
View File
@@ -1,10 +0,0 @@
# Deployment assets execute on the Linux runner / VPS. Force LF no matter which OS commits them:
# a CRLF script fails with "/usr/bin/env: 'bash\r': No such file or directory".
*.sh text eol=lf
*.service text eol=lf
*.conf text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
Dockerfile text eol=lf
.dockerignore text eol=lf
*.env.example text eol=lf
+73 -476
View File
@@ -1,509 +1,106 @@
# CI/CD — ERP Core. One workflow for pull-request checks and the production deploy
# (https://erp.hexdive.com). Runbook: docs/40-DEPLOYMENT.md, "Active".
#
# Which jobs run for which event:
# PR opened / updated into Dev or production → checks: detect → backend / frontend / e2e-typecheck /
# scripts (only the sides the PR touches) → ci-ok
# PR merged into production → build-and-deploy (checks are skipped)
# PR merged into Dev, or closed unmerged → nothing (every job skipped)
# Run workflow (manual) → build-and-deploy with the deploy_backend / deploy_frontend boxes
#
# Checks: `ci-ok` is the single required status check for branch protection. It passes when every
# check passed or was skipped, and rejects PRs into production that don't come from Dev or hotfix/*.
#
# Deploy: same shape as the Fitness workflows (publish on the host → stop → copy → fix permissions →
# start → verify), plus: only the side whose folder changed in the PR is deployed, the database is
# backed up when the PR changes migrations, and the verify steps FAIL the job (no automatic rollback).
# Detection only sees the PR being merged: after a failed deploy, fix it and use "Run workflow".
# The test environment is on hold (deploy/on-hold/).
#
# Security model: the dotnet/npm/tsc/eslint check work runs in capped containers without secrets, but
# the deploy/scripts/*.sh steps come from the PR checkout and run on the host runner as root, some with
# ERPSECRET in their environment. Acceptable only while the repo is private and every PR author is a
# trusted team member.
name: CI/CD - ERP Core
# test
name: CI/CD - ERP Core Monorepo
on:
pull_request:
branches:
- Dev
- production
types:
- opened
- synchronize
- reopened
- closed
workflow_dispatch:
inputs:
deploy_backend:
description: "Deploy the backend (after changing the config in this file's env: block, or after a failed deploy)"
type: boolean
default: false
deploy_frontend:
description: "Deploy the frontend"
type: boolean
default: false
# One group for the whole file, never cancelled: a deploy (or its migration) must not be interrupted
# by a PR push. Superseded check runs therefore queue instead of being cancelled; with the runner at
# capacity 1 they would run one after another anyway.
concurrency:
group: erp-core-cicd
cancel-in-progress: false
jobs:
# ════════════════════════════ Pull-request checks ════════════════════════════
detect:
name: detect
if: github.event_name == 'pull_request' && github.event.action != 'closed'
backend-build-and-deploy:
name: Build & Deploy ERP Core Backend
runs-on: linux
outputs:
backend: ${{ steps.detect.outputs.backend }}
frontend: ${{ steps.detect.outputs.frontend }}
e2e: ${{ steps.detect.outputs.e2e }}
scripts: ${{ steps.detect.outputs.scripts }}
steps:
- name: Checkout PR head
env:
ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }}
SHA: ${{ github.event.pull_request.head.sha }}
REF: refs/pull/${{ github.event.pull_request.number }}/head
run: |
basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')"
url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git"
git init -q .
git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$SHA" \
|| git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 50 "$url" "$REF"
git checkout -qf "$SHA" && git clean -ffdxq
git log -1 --format='checked out %h %s'
- name: Detect changed sides
id: detect
env:
ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }}
BASE_REF: ${{ github.base_ref }}
run: bash deploy/scripts/ci-detect.sh
backend:
name: backend
needs: detect
if: needs.detect.outputs.backend == 'true'
runs-on: linux
steps:
- name: Checkout PR head
env:
ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }}
SHA: ${{ github.event.pull_request.head.sha }}
REF: refs/pull/${{ github.event.pull_request.number }}/head
run: |
basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')"
url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git"
git init -q .
git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$SHA" \
|| git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 50 "$url" "$REF"
git checkout -qf "$SHA" && git clean -ffdxq
- name: Build, test, pending model changes
run: bash deploy/scripts/ci-backend.sh
- name: Destructive migration scan
env:
ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }}
BASE_REF: ${{ github.base_ref }}
PR_TITLE: ${{ github.event.pull_request.title }}
run: bash deploy/scripts/migration-scan.sh
frontend:
name: frontend
needs: detect
if: needs.detect.outputs.frontend == 'true'
runs-on: linux
steps:
- name: Checkout PR head
env:
ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }}
SHA: ${{ github.event.pull_request.head.sha }}
REF: refs/pull/${{ github.event.pull_request.number }}/head
run: |
basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')"
url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git"
git init -q .
git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$SHA" \
|| git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 50 "$url" "$REF"
git checkout -qf "$SHA" && git clean -ffdxq
# Checks only (no image): build-and-deploy builds its own image with `docker build` after the merge.
- name: Type-check, lint, build
run: |
z=""; if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then z=":z"; fi
docker run --rm --cpus=2 --memory=2500m \
-e CI=true -e NEXT_TELEMETRY_DISABLED=1 -e NODE_OPTIONS=--max-old-space-size=2048 \
-v "$PWD/Frontend/erp-system:/app$z" -v erp-npm-cache:/root/.npm -w /app node:24-alpine \
sh -ec 'npm ci --no-audit --no-fund && npx tsc --noEmit && npx eslint . && npm run build'
e2e-typecheck:
name: e2e-typecheck
needs: detect
if: needs.detect.outputs.e2e == 'true'
runs-on: linux
steps:
- name: Checkout PR head
env:
ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }}
SHA: ${{ github.event.pull_request.head.sha }}
REF: refs/pull/${{ github.event.pull_request.number }}/head
run: |
basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')"
url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git"
git init -q .
git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$SHA" \
|| git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 50 "$url" "$REF"
git checkout -qf "$SHA" && git clean -ffdxq
- name: Type-check Playwright specs
run: |
z=""; if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then z=":z"; fi
docker run --rm --cpus=1 --memory=768m \
-v "$PWD/Testing/e2e:/e2e$z" -v erp-npm-cache:/root/.npm -w /e2e node:24-alpine \
sh -ec 'npm ci --no-audit --no-fund && npx tsc --noEmit'
scripts:
name: scripts
needs: detect
if: needs.detect.outputs.scripts == 'true'
runs-on: linux
steps:
- name: Checkout PR head
env:
ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }}
SHA: ${{ github.event.pull_request.head.sha }}
REF: refs/pull/${{ github.event.pull_request.number }}/head
run: |
basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')"
url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git"
git init -q .
git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$SHA" \
|| git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 50 "$url" "$REF"
git checkout -qf "$SHA" && git clean -ffdxq
- name: bash -n + shellcheck
run: |
for f in deploy/scripts/*.sh; do bash -n "$f"; done
z=""; if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then z=":z"; fi
files=""; for f in deploy/scripts/*.sh; do files="$files /mnt/${f#deploy/}"; done
# shellcheck disable=SC2086
docker run --rm -v "$PWD/deploy:/mnt$z" koalaman/shellcheck:stable \
--severity=warning --external-sources --source-path=SCRIPTDIR $files
ci-ok:
name: ci-ok
needs: [detect, backend, frontend, e2e-typecheck, scripts]
if: always() && github.event_name == 'pull_request' && github.event.action != 'closed'
runs-on: linux
steps:
- name: All required checks passed or were skipped
env:
RESULTS: >-
detect=${{ needs.detect.result }}
backend=${{ needs.backend.result }}
frontend=${{ needs.frontend.result }}
e2e-typecheck=${{ needs.e2e-typecheck.result }}
scripts=${{ needs.scripts.result }}
BASE_REF: ${{ github.base_ref }}
HEAD_REF: ${{ github.head_ref }}
run: |
failed=0
for r in $RESULTS; do
case "${r#*=}" in
success|skipped) echo "ok $r" ;;
*) echo "FAILED $r"; failed=1 ;;
esac
done
if [ "$BASE_REF" = production ]; then
case "$HEAD_REF" in
Dev|hotfix/*) ;;
*) echo "PRs into production must come from Dev or hotfix/* (this one is from '$HEAD_REF')"; failed=1 ;;
esac
fi
exit "$failed"
# ════════════════════════════ Production deploy ════════════════════════════
# No `needs: ci-ok`: that job belongs to the PR-open events and is skipped on a merge, so the
# deploy would never run. The pre-merge gate is branch protection requiring ci-ok.
build-and-deploy:
name: Build & Deploy
runs-on: linux
# base.ref matters: without it a PR merged into Dev would deploy production.
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.pull_request.merged == true &&
github.event.pull_request.base.ref == 'production')
env:
PUBLISH_DIR: /home/erp/core/publish
BUILD_DIR: /tmp/erp-deploy
SERVICE: erpcore.service
SERVICE_USER: erpuser
BACKEND_PORT: "5220"
DB_NAME: ERPCore
DB_USER: erpcore
BACKUP_DIR: /var/backups/erpcore
WEB_IMAGE: erp-web:latest
WEB_CONTAINER: erp-web
WEB_PORT: "3020"
# ── Backend runtime config ────────────────────────────────────────────────────────────
# There is no /etc/erpcore/erpcore.env. These values are the single source of truth: the
# "Install unit + write config" step writes them into a systemd drop-in on every backend
# deploy. Change a setting here, then merge or use "Run workflow" with deploy_backend.
# The two secrets (DB password, GL API key) come from Gitea secrets, never from this file.
AUTHHEX_BASE_URL: "http://127.0.0.1:5433"
GL_BASE_URL: "http://127.0.0.1:5433/api/v1/"
STORAGE_ROOT: /var/lib/erpcore/hr-documents
LOG_PATH: /var/log/erpcore/erpcore-.log
SEED_DEMO_DATA: "false"
# Door policy: blank = any valid AuthHex token is admitted. Set the real AuthHex codes to
# restrict ERP to one user type / role (Infra/Auth/JwtAuthExtensions.cs).
AUTH_REQUIRED_USER_TYPE: ""
AUTH_REQUIRED_ROLE: ""
if: github.event.pull_request.merged == true
steps:
- name: Checkout code
env:
ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }}
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')"
url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git"
fetch() { git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$1"; }
git init -q .
# The exact merge commit, not the branch tip (a second merge may already have landed).
if [ -n "$MERGE_SHA" ] && fetch "$MERGE_SHA"; then :; else fetch production; fi
git checkout -qf FETCH_HEAD && git clean -ffdxq
# production as it was before this PR — the detect step compares against it.
if [ -n "$BASE_SHA" ]; then fetch "$BASE_SHA" || echo "could not fetch base $BASE_SHA — both sides will deploy"; fi
git log -1 --format='Deploying %h %s'
git clone \
--depth 1 \
--branch production \
https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
.
- name: Detect changes
id: changes
env:
EVENT: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
INPUT_BACKEND: ${{ inputs.deploy_backend }}
INPUT_FRONTEND: ${{ inputs.deploy_frontend }}
- name: Restore backend
working-directory: .gitea/workflows
run: dotnet restore ../../Backend/ERPCore.sln
- name: Publish backend
working-directory: .gitea/workflows
run: |
# A folder's git tree hash changes only when something inside it changed, so comparing
# production-before-the-PR with the merge result works for merge, squash and rebase merges.
changed() {
if [ -z "$BASE_SHA" ] || ! git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then echo true; return; fi
now="$(git rev-parse -q --verify "HEAD:$1" || true)"
before="$(git rev-parse -q --verify "$BASE_SHA:$1" || true)"
if [ "$now" != "$before" ]; then echo true; else echo false; fi
}
if [ "$EVENT" = workflow_dispatch ]; then
backend="${INPUT_BACKEND:-false}"
frontend="${INPUT_FRONTEND:-false}"
migrations="$backend" # unknown on a manual run → take the backup
else
backend="$(changed Backend/ERPCore)"
frontend="$(changed Frontend/erp-system)"
migrations="$(changed Backend/ERPCore/Migrations)"
fi
{
echo "backend=$backend"
echo "frontend=$frontend"
echo "migrations=$migrations"
} >> "$GITHUB_OUTPUT"
echo "backend=$backend frontend=$frontend migrations=$migrations"
if [ "$backend" != true ] && [ "$frontend" != true ]; then echo "Nothing to deploy."; fi
# ── Backend ──────────────────────────────────────────────────────────────────────────────
- name: Restore
if: steps.changes.outputs.backend == 'true'
run: dotnet restore Backend/ERPCore/ERPCore.csproj -r linux-x64
- name: Publish
if: steps.changes.outputs.backend == 'true'
run: |
rm -rf "$BUILD_DIR"
mkdir -p "$BUILD_DIR"
dotnet publish Backend/ERPCore/ERPCore.csproj \
rm -rf /tmp/erpcore-deploy
mkdir -p /tmp/erpcore-deploy
dotnet publish ../../Backend/ERPCore/ERPCore.csproj \
-c Release \
-r linux-x64 \
--self-contained true \
--no-restore \
-o "$BUILD_DIR"
-o /tmp/erpcore-deploy
- name: Backup database
if: steps.changes.outputs.backend == 'true' && steps.changes.outputs.migrations == 'true'
- name: Stop backend service
run: systemctl stop erpcore.service
- name: Copy backend to deploy folder
run: |
mkdir -p "$BACKUP_DIR"
pg_dump="$(command -v pg_dump || echo /usr/pgsql-16/bin/pg_dump)"
file="$BACKUP_DIR/$DB_NAME-$(date -u +%Y%m%dT%H%M%SZ).dump"
runuser -u postgres -- "$pg_dump" -Fc "$DB_NAME" > "$file.partial"
mv "$file.partial" "$file"
echo "Backup: $file"
find "$BACKUP_DIR" -maxdepth 1 -name '*.dump' -mtime +30 -delete
mkdir -p /home/erp/core/publish
rm -rf /home/erp/core/publish/*
cp -r /tmp/erpcore-deploy/* /home/erp/core/publish/
- name: Install unit + write config
if: steps.changes.outputs.backend == 'true'
env:
DB_PASSWORD: ${{ secrets.ERP_DB_PASSWORD }}
GL_API_KEY: ${{ secrets.ERP_GL_API_KEY }}
- name: Fix backend ownership and permissions
run: |
# Config lives in this workflow's env: block, not in a file on the server. It is written
# to a systemd drop-in here, before the service is stopped, so a bad value fails the job
# while the OLD release is still serving.
case "$AUTHHEX_BASE_URL$GL_BASE_URL" in
*CHANGE_ME*) echo "❌ AUTHHEX_BASE_URL / GL_BASE_URL still hold CHANGE_ME — set the real ports in ci-cd.yml (run 'ss -ltnp' on the VPS)"; exit 1 ;;
esac
[ -n "$DB_PASSWORD" ] || { echo "❌ Gitea secret ERP_DB_PASSWORD is not set"; exit 1; }
[ -n "$GL_API_KEY" ] || { echo "❌ Gitea secret ERP_GL_API_KEY is not set"; exit 1; }
case "$DB_PASSWORD" in
*";"*) echo "❌ ERP_DB_PASSWORD contains ';' — Npgsql would truncate the connection string there. Use a password without ';'"; exit 1 ;;
esac
chown -R erpuser:erpuser /home/erp/core/publish
find /home/erp/core/publish -type d -exec chmod 755 {} +
find /home/erp/core/publish -type f -exec chmod 644 {} +
chmod +x /home/erp/core/publish/ERPCore
# "Copy to deploy folder" empties the publish folder, so uploads and logs must live elsewhere.
publish="$(realpath -m "$PUBLISH_DIR")"
for path in "$STORAGE_ROOT" "$LOG_PATH"; do
case "$path" in
/*) ;;
*) echo "❌ $path is relative — it would resolve inside $PUBLISH_DIR"; exit 1 ;;
esac
case "$(realpath -m "$path")/" in
"$publish"/*) echo "❌ $path is inside $PUBLISH_DIR, which every deploy wipes"; exit 1 ;;
esac
echo "ok $path"
done
# The unit is installed from the repo, so editing deploy/systemd/erpcore.service and
# deploying is enough — no manual copy step on the server.
install -m 644 deploy/systemd/erpcore.service /etc/systemd/system/erpcore.service
install -d -m 755 /etc/systemd/system/erpcore.service.d
conf=/etc/systemd/system/erpcore.service.d/10-erpcore-env.conf
# Values are quoted: systemd would otherwise split a password containing spaces.
umask 077
# Only the listening URL. Everything else is app configuration and lands in
# appsettings.Production.json (written after the copy step) — ASPNETCORE_URLS is host
# configuration, the one setting appsettings cannot reliably provide.
cat > "$conf" <<EOF
[Service]
Environment="ASPNETCORE_URLS=http://127.0.0.1:$BACKEND_PORT"
EOF
chmod 600 "$conf"
systemctl daemon-reload
# Idempotent: creates the multi-user.target.wants symlink so the app survives a reboot.
systemctl enable "$SERVICE"
echo "✅ unit + drop-in installed ($(grep -c '^Environment=' "$conf") settings; values not printed)"
# Data dirs must exist and belong to the service user (Serilog fails silently otherwise).
install -d -m 750 -o "$SERVICE_USER" -g "$SERVICE_USER" "$STORAGE_ROOT" "$(dirname "$LOG_PATH")"
- name: Stop service
if: steps.changes.outputs.backend == 'true'
run: systemctl stop "$SERVICE" || true
- name: Copy to deploy folder
if: steps.changes.outputs.backend == 'true'
run: |
mkdir -p "$PUBLISH_DIR"
rm -rf "${PUBLISH_DIR:?}"/*
cp -r "$BUILD_DIR"/. "$PUBLISH_DIR"/
- name: Fix ownership and permissions
if: steps.changes.outputs.backend == 'true'
run: |
chown -R "$SERVICE_USER:$SERVICE_USER" "$PUBLISH_DIR"
find "$PUBLISH_DIR" -type d -exec chmod 755 {} +
find "$PUBLISH_DIR" -type f -exec chmod 644 {} +
chmod +x "$PUBLISH_DIR/ERPCore"
# Written AFTER "Fix ownership" so it keeps mode 640 (root:erpuser) instead of being
# flattened to 644 — it holds the DB password and the GL API key.
- name: Write appsettings.Production.json
if: steps.changes.outputs.backend == 'true'
env:
DB_PASSWORD: ${{ secrets.ERP_DB_PASSWORD }}
GL_API_KEY: ${{ secrets.ERP_GL_API_KEY }}
run: |
# Deployed config lives in appsettings.Production.json, generated here from this
# workflow's env: block + the Gitea secrets. It is NOT in git (.gitignore ignores
# appsettings.*.json) and is rewritten on every backend deploy, which is why the
# publish folder being wiped does not matter. It MERGES over appsettings.json, so
# Auth:RsaPublicKeyXml, Issuer, Audience and FileStorage:MaxSizeBytes still come
# from the committed base file.
conf="$PUBLISH_DIR/appsettings.Production.json"
# python3 emits the JSON so a password containing " or \ is escaped correctly.
python3 - > "$conf" <<'PY'
import json, os
e = os.environ
print(json.dumps({
"ConnectionStrings": {
"DefaultConnection":
"Host=127.0.0.1;Port=5432;Database=%s;Username=%s;Password=%s"
% (e["DB_NAME"], e["DB_USER"], e["DB_PASSWORD"]),
},
"AuthHex": {"BaseUrl": e["AUTHHEX_BASE_URL"]},
"GeneralLedgerService": {"BaseUrl": e["GL_BASE_URL"], "ApiKey": e["GL_API_KEY"]},
"Auth": {
"RequiredUserTypeCode": e["AUTH_REQUIRED_USER_TYPE"],
"RequiredRoleCode": e["AUTH_REQUIRED_ROLE"],
},
"FileStorage": {"RootPath": e["STORAGE_ROOT"]},
"LogFile": {"Path": e["LOG_PATH"]},
"Seed": {"DemoData": e["SEED_DEMO_DATA"] == "true"},
}, indent=2))
PY
chown "root:$SERVICE_USER" "$conf"
chmod 640 "$conf"
python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$conf"
echo "✅ appsettings.Production.json written ($(wc -c < "$conf") bytes, mode 640, values not printed)"
- name: Start service
if: steps.changes.outputs.backend == 'true'
run: |
systemctl reset-failed "$SERVICE" 2>/dev/null || true
systemctl start "$SERVICE" || echo "systemctl start returned an error — the verify step shows why"
# Baseline for the crash check in the verify step.
echo "RESTARTS_AT_START=$(systemctl show -p NRestarts --value "$SERVICE")" >> "$GITHUB_ENV"
- name: Restart backend service
run: systemctl restart erpcore.service
- name: Verify backend
if: steps.changes.outputs.backend == 'true'
env:
HEALTH_TIMEOUT: "300" # migrations run at startup; a crash still fails within seconds
run: bash deploy/scripts/wait-healthy.sh
# ── Frontend (after the backend: a failed backend step stops the job here) ─────────────
- name: Build frontend image
if: steps.changes.outputs.frontend == 'true'
run: docker build -t "$WEB_IMAGE" Frontend/erp-system
- name: Stop & remove existing container
if: steps.changes.outputs.frontend == 'true'
run: |
docker stop "$WEB_CONTAINER" 2>/dev/null || true
docker rm "$WEB_CONTAINER" 2>/dev/null || true
systemctl is-active erpcore.service && echo "Backend service running" || echo "Backend service failed"
- name: Start container
if: steps.changes.outputs.frontend == 'true'
frontend-build-and-deploy:
name: Build & Deploy ERP Core Frontend
runs-on: linux
needs: backend-build-and-deploy
if: github.event.pull_request.merged == true
steps:
- name: Checkout code
run: |
git clone \
--depth 1 \
--branch Production \
https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
.
- name: Build frontend Docker image
working-directory: .gitea/workflows
run: docker build -t erpcore-frontend:latest ../../Frontend/erp-system
- name: Stop & remove existing frontend container
run: |
docker stop erpcore-frontend 2>/dev/null || true
docker rm erpcore-frontend 2>/dev/null || true
- name: Start frontend container
run: |
docker run -d \
--name "$WEB_CONTAINER" \
-p "127.0.0.1:$WEB_PORT:3000" \
--memory=512m \
--name erpcore-frontend \
-p 6702:6702 \
--add-host=host.docker.internal:host-gateway \
-e BACKEND_ORIGIN=http://host.docker.internal:6701 \
--restart unless-stopped \
"$WEB_IMAGE"
--memory="512m" \
--cpus="1.0" \
--cap-drop ALL \
--security-opt no-new-privileges \
erpcore-frontend:latest
- name: Remove old frontend images
run: docker image prune -f
- name: Verify frontend
if: steps.changes.outputs.frontend == 'true'
run: |
for _ in $(seq 1 15); do
if curl -fsS -o /dev/null "http://127.0.0.1:$WEB_PORT/login"; then
echo "✅ Frontend serving on 127.0.0.1:$WEB_PORT"
exit 0
fi
sleep 2
done
echo "❌ Frontend did not serve /login within 30s"
docker ps -a --filter "name=$WEB_CONTAINER" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
docker logs --tail 50 "$WEB_CONTAINER" || true
exit 1
- name: Remove old images
if: steps.changes.outputs.frontend == 'true'
run: |
docker image prune -f
docker builder prune -f --filter until=168h
docker ps --filter name=erpcore-frontend --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" \
| grep erpcore-frontend && echo "Frontend container running" || echo "Frontend container failed"
-4
View File
@@ -36,8 +36,4 @@ Testing/e2e/test-results/
Testing/e2e/.auth/
Testing/e2e/blob-report/
# ── Python (Backend/smoke) ────────────────────────────────────────────
__pycache__/
*.pyc
@@ -11,8 +11,6 @@ internal sealed class TestCurrentUser : ICurrentUser
public string UserId => "system";
public int AuditUserId => User.SystemUserId;
public bool IsAuthenticated => false;
public string? RoleCode => null;
public bool IsAdmin => false;
}
internal sealed class ThrowingUnitOfWork : ERPCore.Infra.UoW.IUnitOfWork
-14
View File
@@ -5,8 +5,6 @@ VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ERPCore", "ERPCore\ERPCore.csproj", "{B38203ED-80EA-41C6-8B7D-B664451299E3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ERPCore.Tests", "ERPCore.Tests\ERPCore.Tests.csproj", "{E451472F-550F-40A0-AE82-E24ECD6C647A}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -29,18 +27,6 @@ Global
{B38203ED-80EA-41C6-8B7D-B664451299E3}.Release|x64.Build.0 = Release|Any CPU
{B38203ED-80EA-41C6-8B7D-B664451299E3}.Release|x86.ActiveCfg = Release|Any CPU
{B38203ED-80EA-41C6-8B7D-B664451299E3}.Release|x86.Build.0 = Release|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Debug|x64.ActiveCfg = Debug|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Debug|x64.Build.0 = Debug|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Debug|x86.ActiveCfg = Debug|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Debug|x86.Build.0 = Debug|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Release|Any CPU.Build.0 = Release|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Release|x64.ActiveCfg = Release|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Release|x64.Build.0 = Release|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Release|x86.ActiveCfg = Release|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
+14 -2
View File
@@ -1,3 +1,4 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Uoms;
using ERPCore.Services.Interfaces;
@@ -15,8 +16,9 @@ public sealed class UomsController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<UomDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<UomDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _uoms.ListAsync(query, ct));
public async Task<ActionResult<PagedResponse<UomDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _uoms.ListAsync(query, status, ct));
[HttpPost]
[ProducesResponseType(typeof(UomDto), StatusCodes.Status201Created)]
@@ -26,4 +28,14 @@ public sealed class UomsController : ApiControllerBase
var dto = await _uoms.CreateAsync(request, ct);
return Created($"/api/v1/uoms/{dto.UomId}", dto);
}
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
[HttpPatch("{uomId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int uomId, [FromBody] UpdateUomStatusRequest request, CancellationToken ct)
{
await _uoms.SetStatusAsync(uomId, request.Status, ct);
return NoContent();
}
}
+6
View File
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
@@ -10,4 +12,8 @@ public class Uom
{
public int UomId { get; set; }
public string Name { get; set; } = string.Empty;
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
}
+7 -1
View File
@@ -1,11 +1,17 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Uoms;
/// <summary>UOM resource (docs/11-BACKEND-PHASE1.md §2.2).</summary>
public sealed record UomDto(int UomId, string Name);
public sealed record UomDto(int UomId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateUomRequest
{
[Required, StringLength(50)] public string Name { get; set; } = string.Empty;
}
public sealed class UpdateUomStatusRequest
{
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
}
@@ -18,11 +18,6 @@ public sealed class GeneralLedgerClient(HttpClient http, IConfiguration configur
public async Task<GeneralLedgerResponse> SendAsync(
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
{
// The base appsettings.json ships the key blank; name it here rather than forward GL's 401
// for an empty X-Api-Key (deployed environments already refuse to start without it).
if (string.IsNullOrWhiteSpace(_apiKey))
throw new DomainException(ErrorCodes.GlServiceUnavailable, "GeneralLedgerService:ApiKey is not configured.", 503);
var relativeUri = path.TrimStart('/') + queryString;
using var request = new HttpRequestMessage(method, relativeUri);
request.Headers.TryAddWithoutValidation("X-Api-Key", _apiKey);
@@ -1,4 +1,5 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
@@ -13,5 +14,13 @@ public sealed class UomConfiguration : IEntityTypeConfiguration<Uom>
builder.Property(u => u.Name).IsRequired().HasMaxLength(50);
builder.HasIndex(u => u.Name).IsUnique();
builder.Property(u => u.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(u => u.CreatedAt).IsRequired();
builder.HasIndex(u => u.Status);
}
}
@@ -39,29 +39,17 @@ public static class DataSeeder
("PRD-REWORK-LOSS", "Production Rework Loss", ReasonContext.Production),
];
/// <param name="includeDemoData">
/// Also seed demo rows — sample warehouses/UOMs/category/items, an on-hand stock layer,
/// customers, bundle and draft sales documents (<c>Seed:DemoData</c>). On for Development
/// and the test deployment (the E2E suite relies on MAIN/SHOP, PCS/BOX, General Goods);
/// never for release, which gets reference data only (docs/40-DEPLOYMENT.md).
/// </param>
public static async Task SeedAsync(ErpDbContext db, bool includeDemoData, CancellationToken ct = default)
public static async Task SeedAsync(ErpDbContext db, CancellationToken ct = default)
{
// Reference data — every environment.
var dirty = await SeedReasonCodesAsync(db, ct);
dirty |= await SeedItemTypesAsync(db, ct);
// dirty |= await SeedCompanyProfileAsync(db, ct);
dirty |= await SeedProductConfigAsync(db, ct);
dirty |= await SeedBranchConfigAsync(db, ct);
dirty |= await SeedNotificationConfigAsync(db, ct);
dirty |= await SeedSalesSequencesAsync(db, ct);
if (includeDemoData)
{
dirty |= await SeedSalesMastersAsync(db, ct);
dirty |= await SeedSalesStockAsync(db, ct);
dirty |= await SeedSalesAsync(db, ct);
}
dirty |= await SeedSalesMastersAsync(db, ct);
dirty |= await SeedSalesStockAsync(db, ct);
dirty |= await SeedSalesAsync(db, ct);
if (dirty) await db.SaveChangesAsync(ct);
}
@@ -356,9 +344,9 @@ public static class DataSeeder
}
/// <summary>
/// Seeds the demo sales data used for UI/backend development: a couple of customer
/// rows and a few draft invoice/slip samples when the required master data already
/// exists. (Current-year document counters are reference data, seeded in SeedAsync.)
/// Seeds the minimum sales bootstrap data needed for UI/backend development:
/// a couple of customer rows, current-year document counters, and a few draft
/// invoice/slip samples when the required master data already exists.
/// This intentionally never clears or rewrites any existing rows.
/// </summary>
private static async Task<bool> SeedSalesAsync(ErpDbContext db, CancellationToken ct)
@@ -366,6 +354,7 @@ public static class DataSeeder
var dirty = false;
dirty |= await SeedSalesCustomersAsync(db, ct);
dirty |= await SeedSalesSequencesAsync(db, ct);
dirty |= await SeedSampleSalesDocsAsync(db, ct);
try
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddUomLifecycleColumnsLocal : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "CreatedAt",
table: "uoms",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.AddColumn<string>(
name: "Status",
table: "uoms",
type: "character varying(20)",
maxLength: 20,
nullable: false,
defaultValue: "Active");
migrationBuilder.AddColumn<DateTime>(
name: "UpdatedAt",
table: "uoms",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_uoms_Status",
table: "uoms",
column: "Status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_uoms_Status",
table: "uoms");
migrationBuilder.DropColumn(
name: "CreatedAt",
table: "uoms");
migrationBuilder.DropColumn(
name: "Status",
table: "uoms");
migrationBuilder.DropColumn(
name: "UpdatedAt",
table: "uoms");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class changeMismatch : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -6000,16 +6000,31 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("UomId"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Active");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("UomId");
b.HasIndex("Name")
.IsUnique();
b.HasIndex("Status");
b.ToTable("uoms", (string)null);
});
+8 -46
View File
@@ -15,7 +15,6 @@ using ERPCore.Services.Production;
using ERPCore.Services.Stock;
using ERPCore.System.Errors;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.OpenApi;
@@ -24,32 +23,10 @@ using Serilog;
var builder = WebApplication.CreateBuilder(args);
// Fail fast on missing deployment config. Outside Development these arrive as environment
// variables (docs/40-DEPLOYMENT.md §5); a blank value would otherwise surface later as an
// unrelated crash (e.g. `new Uri("")` for AuthHex:BaseUrl) instead of naming the key.
if (!builder.Environment.IsDevelopment())
{
string[] requiredKeys =
[
"ConnectionStrings:DefaultConnection",
"AuthHex:BaseUrl",
"GeneralLedgerService:BaseUrl",
"GeneralLedgerService:ApiKey",
"Auth:RsaPublicKeyXml",
];
var missing = requiredKeys.Where(k => string.IsNullOrWhiteSpace(builder.Configuration[k])).ToArray();
if (missing.Length > 0)
throw new InvalidOperationException(
$"Missing required configuration for environment '{builder.Environment.EnvironmentName}': " +
$"{string.Join(", ", missing)}. Set them as environment variables (':' becomes '__', " +
"e.g. ConnectionStrings__DefaultConnection).");
}
// Serilog (file sink) — reads levels from configuration, plus a rolling daily file. The path is
// configurable so deployed instances log outside their release folder (LogFile:Path).
// Serilog (file sink) — reads levels from configuration, plus a rolling daily file.
builder.Host.UseSerilog((ctx, cfg) => cfg
.ReadFrom.Configuration(ctx.Configuration)
.WriteTo.File(ctx.Configuration["LogFile:Path"] ?? "logs/erpcore-.log", rollingInterval: RollingInterval.Day));
.WriteTo.File("logs/erpcore-.log", rollingInterval: RollingInterval.Day));
// Controllers + JSON: serialize enums as their string names (docs/11 §8, camelCase).
builder.Services.AddControllers()
@@ -72,10 +49,8 @@ builder.Services.AddErpJwtAuth(builder.Configuration);
// AuthController proxy → AuthHex (docs/11 §2.0)
builder.Services.AddHttpClient<IAuthHexClient, AuthHexClient>(c =>
{
// Blank counts as missing: the base appsettings.json ships these keys empty on purpose.
var baseUrl = builder.Configuration["AuthHex:BaseUrl"];
if (string.IsNullOrWhiteSpace(baseUrl))
throw new InvalidOperationException("AuthHex:BaseUrl is not configured (set it in appsettings.Development.json locally).");
var baseUrl = builder.Configuration["AuthHex:BaseUrl"]
?? throw new InvalidOperationException("AuthHex:BaseUrl is not configured.");
c.BaseAddress = new Uri(baseUrl);
});
builder.Services.AddScoped<IAuthUserService, AuthUserService>();
@@ -85,9 +60,8 @@ builder.Services.AddScoped<IAuthAltService, AuthAltService>();
// General Ledger service proxy → external GL microservice (docs/12-GENERAL-LEDGER-INTEGRATION.md)
builder.Services.AddHttpClient<IGeneralLedgerClient, GeneralLedgerClient>(c =>
{
var baseUrl = builder.Configuration["GeneralLedgerService:BaseUrl"];
if (string.IsNullOrWhiteSpace(baseUrl))
throw new InvalidOperationException("GeneralLedgerService:BaseUrl is not configured (set it in appsettings.Development.json locally).");
var baseUrl = builder.Configuration["GeneralLedgerService:BaseUrl"]
?? throw new InvalidOperationException("GeneralLedgerService:BaseUrl is not configured.");
c.BaseAddress = new Uri(baseUrl);
});
builder.Services.AddScoped<IGeneralLedgerService, GeneralLedgerService>();
@@ -231,10 +205,7 @@ using (var scope = app.Services.CreateScope())
await db.Database.MigrateAsync();
try
{
// Demo rows (sample catalog, stock, customers, sales docs) default on only in Development;
// the test deployment opts in with Seed__DemoData=true, release never does.
var includeDemoData = app.Configuration.GetValue("Seed:DemoData", app.Environment.IsDevelopment());
await DataSeeder.SeedAsync(db, includeDemoData);
await DataSeeder.SeedAsync(db);
}
catch (Exception ex)
{
@@ -242,18 +213,9 @@ using (var scope = app.Services.CreateScope())
}
}
// Behind the co-located Apache reverse proxy (docs/40-DEPLOYMENT.md): take the client IP and
// scheme from X-Forwarded-*. Only loopback proxies are trusted by default, which is exactly it.
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});
app.UseSerilogRequestLogging();
// Off in Production only. The test deployment (Staging) keeps it; it is still not public there
// because Apache proxies only /api/ — reach it through an SSH tunnel to the backend port.
if (!app.Environment.IsProduction())
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
@@ -6,7 +6,7 @@
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5224",
"applicationUrl": "http://localhost:6701",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
@@ -16,7 +16,7 @@
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7112;http://localhost:5224",
"applicationUrl": "https://localhost:7112;http://localhost:6701",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
@@ -1,3 +1,4 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Uoms;
@@ -6,6 +7,7 @@ namespace ERPCore.Services.Interfaces;
/// <summary>UOM master business logic (docs/11-BACKEND-PHASE1.md §2.2).</summary>
public interface IUomService
{
Task<PagedResponse<UomDto>> ListAsync(PageQuery query, CancellationToken ct = default);
Task<PagedResponse<UomDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
Task<UomDto> CreateAsync(CreateUomRequest request, CancellationToken ct = default);
Task SetStatusAsync(int uomId, EntityStatus status, CancellationToken ct = default);
}
+16 -4
View File
@@ -1,4 +1,5 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Uoms;
using ERPCore.Infra.UoW;
@@ -20,7 +21,7 @@ public sealed class UomService : IUomService
_uow = uow;
}
public async Task<PagedResponse<UomDto>> ListAsync(PageQuery query, CancellationToken ct = default)
public async Task<PagedResponse<UomDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
{
var q = _uoms.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
@@ -28,11 +29,12 @@ public sealed class UomService : IUomService
var term = query.Q.Trim();
q = q.Where(u => EF.Functions.ILike(u.Name, $"%{term}%"));
}
if (status is not null) q = q.Where(u => u.Status == status);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(u => u.Name)
.Skip(query.Skip).Take(query.PageSize)
.Select(u => new UomDto(u.UomId, u.Name))
.Select(u => new UomDto(u.UomId, u.Name, u.Status, u.CreatedAt, u.UpdatedAt))
.ToListAsync(ct);
return PagedResponse<UomDto>.Create(rows, query.Page, query.PageSize, total);
@@ -44,10 +46,20 @@ public sealed class UomService : IUomService
if (await _uoms.Query().AnyAsync(u => u.Name == name, ct))
throw new ConflictException($"A UOM named '{name}' already exists.");
var uom = new Uom { Name = name };
var uom = new Uom { Name = name, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow };
await _uoms.AddAsync(uom, ct);
await _uow.SaveChangesAsync(ct);
return new UomDto(uom.UomId, uom.Name);
return new UomDto(uom.UomId, uom.Name, uom.Status, uom.CreatedAt, uom.UpdatedAt);
}
public async Task SetStatusAsync(int uomId, EntityStatus status, CancellationToken ct = default)
{
var uom = await _uoms.GetByIdAsync(uomId, ct)
?? throw new NotFoundException($"UOM {uomId} was not found.");
uom.Status = status;
uom.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
}
}
@@ -0,0 +1,14 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5433;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
},
"AuthHex": {
"BaseUrl": "http://localhost:5011"
}
}
@@ -1,21 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"DefaultConnection": "Host=127.0.0.1;Port=5432;Database=ERPCore;Username=postgres;Password=CHANGE_ME"
},
"AuthHex": {
"BaseUrl": "http://localhost:5011"
},
"GeneralLedgerService": {
"BaseUrl": "https://localhost:7024/api/v1/",
"ApiKey": "ask-the-team-for-the-dev-key"
},
"Seed": {
"DemoData": true
}
}
@@ -0,0 +1,8 @@
{
"Jwt": {
"SigningKey": ""
},
"AuthHex": {
"BaseUrl": ""
}
}
+5 -7
View File
@@ -6,8 +6,9 @@
}
},
"ConnectionStrings": {
"DefaultConnection": ""
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
},
"Urls": "http://0.0.0.0:6701",
"Auth": {
"Issuer": "AuthHex",
"Audience": "AuthHexClient",
@@ -16,18 +17,15 @@
"RequiredRoleCode": ""
},
"AuthHex": {
"BaseUrl": ""
"BaseUrl": "http://localhost:6703"
},
"FileStorage": {
"RootPath": "App_Data/hr-documents",
"MaxSizeBytes": 10485760
},
"LogFile": {
"Path": "logs/erpcore-.log"
},
"GeneralLedgerService": {
"BaseUrl": "",
"ApiKey": ""
"BaseUrl": "https://localhost:7024/api/v1/",
"ApiKey": "1A5FE0E389C029B1FBCD2A94650CEBF0656083BF06FF48E5E11987040ABA262D"
},
"Grn": {
"GlInventoryAccountCode": "1100",
-28
View File
@@ -4,34 +4,6 @@ Legend: `[ ]` not started · `[~]` in progress · `[x]` done
Spec: `docs/10-BACKEND-PHASE1.md` (model + rules) · `docs/11-BACKEND-PHASE1.md` (API)
Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** as the code. When ticking `[x]`, append a short note + any deviation.
## Deployment readiness + CI/CD (2026-09-14) — `docs/40-DEPLOYMENT.md`
- [x] **Secrets out of git.** `appsettings.Development.json` untracked (template: `appsettings.Development.json.example`); `appsettings.Production.json` deleted (its `Jwt:SigningKey` was unused); base `appsettings.json` now blanks the connection string, `AuthHex:BaseUrl` and `GeneralLedgerService:BaseUrl/ApiKey`; `Backend/smoke/smoke_common.py` has no default password (`ERP_SMOKE_PASSWORD`). Values committed earlier still need rotating (`docs/02-SECURITY.md` AR-06).
- [x] **Fail-fast config check** (`Program.cs`): outside Development, startup throws one `InvalidOperationException` naming every blank required key. Verified: `ASPNETCORE_ENVIRONMENT=Production` with no env vars → lists the four blank keys and exits. In Development (where that check is skipped) the `AuthHex`/`GeneralLedgerService` HTTP-client registrations now treat a **blank** `BaseUrl` as missing — they only caught null, so the newly blank base values would have surfaced as an unnamed `UriFormatException` — and `GeneralLedgerClient` returns `GL_SERVICE_UNAVAILABLE` naming `GeneralLedgerService:ApiKey` instead of forwarding GL's 401 for an empty `X-Api-Key`.
- [x] **`LogFile:Path`** drives the Serilog file sink (default unchanged); **Swagger** is now off only in Production (test/Staging keeps it; Apache does not proxy it); **`UseForwardedHeaders`** for X-Forwarded-For/Proto from the co-located Apache.
- [x] **Seeder split** — `DataSeeder.SeedAsync(db, includeDemoData)`: reference data (reason codes, item types, product/branch/notification config, sales document counters) always; demo data (MAIN/SHOP warehouses, PCS/BOX UOMs, General Goods, items, stock layers, customers, bundles, sample docs) only with `Seed:DemoData` — default on in Development, `Seed__DemoData=true` on test, never on release. **Deviation from the plan:** no `Seed` key in base `appsettings.json`, because it would override the Development default.
- [x] **`ERPCore.Tests` added to `ERPCore.sln`.** It was outside the solution and had **stopped compiling** (`TestCurrentUser` lacked `ICurrentUser.RoleCode`/`IsAdmin`, added in `f664961`) — fixed, so CI can run it.
- [x] `dotnet ef migrations has-pending-model-changes` verified with a blank connection string (what CI has) → "No changes have been made to the model since the last migration."
- Pipeline assets: `.gitea/workflows/{ci,deploy}.yml`, `deploy/scripts/*.sh`, `deploy/{systemd,apache,env}/`. `detect.sh` exercised locally against a scratch state dir (first deploy / no-op / frontend-only / forced backend / rollback / non-deployable branch — all as designed); `migration-scan.sh`'s awk ignores `Down()` and flags real `Up()` drops (e.g. `20260831060800_AddPurchaseInvoices`); shellcheck clean at `--severity=warning`. **Not yet exercised:** anything needing Docker or the VPS (container builds, systemd, Apache, the Gitea runner).
- [x] **2026-09-15 — switched to a production-only, Fitness-style deployment** (only `erp.hexdive.com` exists; `docs/40-DEPLOYMENT.md` "Active").
- A PR merged into `production` runs one job:
- detect which side changed (tree hashes, `base.sha` vs merge commit);
- host `dotnet publish`;
- `pg_dump` when migrations changed (30-day retention);
- guard that uploads and logs are outside the publish folder;
- stop `erpcore.service`, wipe and copy `/home/erp/core/publish`, `chown erp`, start;
- `deploy/scripts/wait-healthy.sh`, which fails fast on a crash and allows up to 300 s for migrations.
- No auto-rollback.
- The two-environment pipeline, test Apache vhost, `erpcore@.service` and the hash/release/E2E/prune scripts moved to `deploy/on-hold/` (README explains restoring); the `test` branch in `common.sh` is commented out.
- Verified locally: both workflows are valid YAML; shellcheck is clean on the active scripts and on every inline `run:` block; the detect step gives the right flags on real merges (`8835ba9` frontend-only, `ee9fa40` backend+migrations, `8555702` neither, `eb36e0a` both), on a manual run and when the base commit is unavailable (both sides); the guard step rejects paths inside the publish folder (including the folder itself and the log path) and accepts `..`-escapes, sibling prefixes and quoted values.
- **Not exercised:** the real Gitea run, `erpcore.service` under `/home` (SELinux), `pg_dump` via `runuser`.
- [x] **2026-09-15 — `ci.yml` + `deploy.yml` merged into one `.gitea/workflows/ci-cd.yml`.**
- Check jobs run on PR open / update / reopen into `Dev` or `production`; `Build & Deploy` runs only when a PR is merged into `production` (the job checks `pull_request.base.ref` itself, so a merge into `Dev` deploys nothing) or on a manual run.
- One concurrency group for the file, never cancelled: a PR push can't interrupt a deploy, but superseded check runs now queue.
- The deploy's env vars moved from workflow level to the deploy job, so they don't leak into the check jobs.
- Verified locally: valid YAML, and every job `if` evaluated against six event shapes (PR opened into Dev, updated into production, merged into Dev, merged into production, closed unmerged, manual run) gives the intended jobs.
- Once branch protection is set up, the required check appears as `CI/CD - ERP Core / ci-ok`.
## Per-variant content size (2026-08-11) — follow-up to the UOM re-model below
The UOM re-model put `ContentQty`/`ContentUnit` on `Item` correctly, but the item **create page
+2 -6
View File
@@ -38,8 +38,7 @@ DEFAULTS = {
"api": "http://localhost:5224/api/v1",
"auth": "http://localhost:5602",
"user": "admin@gmail.com",
# Deliberately no default: credentials never live in the repo (docs/40-DEPLOYMENT.md §5).
"password": None,
"password": "Naveen@99",
}
@@ -47,10 +46,7 @@ def parse_args(description: str) -> argparse.Namespace:
p = argparse.ArgumentParser(description=description)
for key, default in DEFAULTS.items():
p.add_argument(f"--{key}", default=os.environ.get(f"ERP_SMOKE_{key.upper()}", default))
args = p.parse_args()
if not args.password:
p.error("no password given: set ERP_SMOKE_PASSWORD or pass --password")
return args
return p.parse_args()
class Response:
-7
View File
@@ -345,10 +345,3 @@ Spec: `docs/21-FRONTEND-PHASE2.md` (flows/screens) · contract: `docs/30-BACKEND
5. **Inline "add variant category":** a "+" icon button next to the checkboxes opens an inline name field that calls `variantCategoriesApi.create()` directly from `/new`, appends the result to the in-memory list, and auto-checks it — so a brand-new dimension (e.g. "Material") can be added without leaving the Item form, and it also then appears on `/dashboard/products/variants`.
6. **Color gets a real color picker:** for whichever checked category is literally named "Color" (case-insensitive), the free-text input is replaced with a native `<input type="color">` swatch picker *plus* a required "Color name" text field — picking red alone isn't enough, a name is mandatory too. The pair is encoded as a single string `"<name>|<hex>"` in `valuesByCategory` (helpers `encodeColorValue`/`decodeColorValue`/`partLabel` in `new/page.tsx`) so the existing generic value-list plumbing didn't need a parallel data shape; every place that displays or SKU-generates from a color value decodes it back to just the name (the hex only ever drives the swatch dot next to chips and table cells) — so SKUs read `HAR-CRI` (from "Crimson"), never `HAR-EF4`.
- Verified: `tsc --noEmit` clean after every step (same pre-existing `login/page.tsx` error only, confirmed unchanged throughout). Each change was driven end-to-end through a headless Playwright session against the dev server and screenshotted — field removal, subcategory always-visible + disabled state, checkbox show/hide of category builders, cartesian flat table with 2+ active categories, inline category creation followed by its builder appearing immediately, and the color picker + name → chip swatch → table swatch → final SKU/item name chain — with `console --errors` clean at every step and at least one full create-and-redirect-to-Items-list confirmed per major change.
## Deployment readiness (2026-09-14) — `docs/40-DEPLOYMENT.md`
- [x] **Standalone build + runtime image.** `next.config.ts` → `output: "standalone"`; runtime-only `Dockerfile` (`node:24-alpine`, non-root, copies `.next/standalone` + `.next/static` + `public`) and `.dockerignore` (keeps `.next`). The `/api` rewrite is resolved at build time and only matters locally — Apache sends `/api/` straight to ERPCore in deployed environments, so one image serves both test and release. **Deviation from the plan:** Node 24 instead of 20 (Node 20 is past end-of-life). Verified: `npm run build` produced `.next/standalone/server.js`; started on :3999 → `GET /login` 200, `GET /dashboard` without a session cookie → 307 to `/login`.
- [x] **Lint can gate CI.** `react-hooks/set-state-in-effect` temporarily `"warn"` (React Compiler rule from eslint-config-next 16, ~80 existing hits). Fixed the other 12 errors: 7 unescaped apostrophes (`sales/day-end`, `settings/invoice-template`, `login/forgot`, `login/forgot/otp`, `login/forgot/reset`); `PasswordRequirement` hoisted to module scope in `login/forgot/reset` (a component created during render remounts on every keystroke); the `visibleGrns` memo moved above `toggleAll` in `procurement/purchase-invoices/new` (it was read before its declaration, so the compiler could not preserve the memo). Now `eslint .` → 0 errors / 114 warnings; `tsc --noEmit` clean.
- [ ] Clear the `set-state-in-effect` backlog, then set the rule back to `"error"`.
- [x] **2026-09-15 — production-only deploy (`docs/40-DEPLOYMENT.md` "Active").** Supersedes the runtime-only image above: `Dockerfile` is now multi-stage (deps → builder → runner on `node:24-alpine`, the Client_New_Fe pattern), so `next build` runs inside `docker build` on the server, and `.dockerignore` excludes `.next` and `node_modules`. Still `output: "standalone"` and no build args. The deploy job replaces the `erp-web` container on `127.0.0.1:3020` only when `Frontend/erp-system` changed and fails the job if `/login` doesn't answer within 30 s. PR checks now run `tsc` + `eslint` + `next build` in a capped container without producing an image. The multi-stage `docker build` has not been run yet (no Docker locally).
- [x] **2026-09-15 — one workflow file.** `.gitea/workflows/ci.yml` and `deploy.yml` merged into `.gitea/workflows/ci-cd.yml`: the frontend PR check (`tsc` + `eslint` + `next build`) runs while a PR is open, and the `Build & Deploy` job (`docker build` → replace `erp-web` → `/login` check) runs only when a PR is merged into `production` or on a manual run. No change to the Dockerfile or the app.
+13 -5
View File
@@ -1,10 +1,18 @@
# `next build` runs inside the image (see Dockerfile), so local build output and dependencies must
# not be sent as build context: they are large, and a Windows node_modules would break the Linux build.
node_modules
.next
.git
.env*
*.tsbuildinfo
out
build
coverage
.vercel
.env
.env.*
!.env.local.example
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
Dockerfile
.dockerignore
+1 -1
View File
@@ -4,4 +4,4 @@
#
# Use the backend's HTTP port: its HTTPS port serves a self-signed dev cert that the
# proxy will refuse.
BACKEND_ORIGIN=http://localhost:5224
BACKEND_ORIGIN=http://localhost:6701
+19 -25
View File
@@ -1,38 +1,32 @@
# ERP frontend image — built on the server by .gitea/workflows/ci-cd.yml (`docker build`), using the
# same pattern as Client_New_Fe. There are no build args: the app only calls same-origin /api/v1 and
# Apache routes /api/ straight to ERPCore (docs/40-DEPLOYMENT.md, "Active").
FROM node:24-alpine AS deps
FROM node:22-alpine AS deps
WORKDIR /app
RUN apk add --no-cache libc6-compat
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
RUN npm ci
FROM node:24-alpine AS builder
FROM node:22-alpine AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Shared 8 GB VPS: keep the build's heap bounded (docker build has no --memory under BuildKit).
ENV NEXT_TELEMETRY_DISABLED=1 \
NODE_ENV=production \
NODE_OPTIONS=--max-old-space-size=2048
RUN npm run build
FROM node:24-alpine AS runner
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=0.0.0.0
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=6702
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
USER nextjs
EXPOSE 3000
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/next.config.ts ./next.config.ts
CMD ["node", "server.js"]
EXPOSE 6702
CMD ["npm", "run", "start"]
@@ -161,11 +161,11 @@ export default function BankAccountsPage() {
<TableCell className="px-3 py-3.5 font-medium">{a.accountName}</TableCell>
<TableCell className="px-3 py-3.5">{a.bankName ?? a.cashAccountTypeName ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{a.accountNumber ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">
<TableCell className="px-3 py-3.5">
{gl ? `${gl.accountCode} — ${gl.accountName}` : `#${a.glAccountId}`}
</TableCell>
<TableCell className="px-3 py-3.5">{a.currencyCode}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(a.createdAt)}</TableCell>
<TableCell className="px-3 py-3.5">{formatReportDate(a.createdAt)}</TableCell>
<TableCell className="px-3 py-3.5">
<Tooltip>
<TooltipTrigger
@@ -148,7 +148,7 @@ export default function ChequeBookDetailPage() {
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">{p.payeeName ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(p.issueDate)}</TableCell>
<TableCell className="px-3 py-3.5">{formatReportDate(p.issueDate)}</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">
{p.amount !== null ? formatAmount(p.amount) : "—"}
</TableCell>
@@ -142,15 +142,15 @@ export default function ChequeBooksPage() {
onClick={() => router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)}
>
<TableCell className="px-3 py-3.5 font-medium">{b.chequeBookNo}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">
<TableCell className="px-3 py-3.5">
{bank ? bank.accountName : `#${b.bankAccountId}`}
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{branchesById.get(b.branchId)?.name ?? `#${b.branchId}`}</TableCell>
<TableCell className="px-3 py-3.5">{branchesById.get(b.branchId)?.name ?? `#${b.branchId}`}</TableCell>
<TableCell className="px-3 py-3.5">
{b.startChequeNo} – {b.endChequeNo}
</TableCell>
<TableCell className="px-3 py-3.5">{b.totalLeaves}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(b.receivedDate)}</TableCell>
<TableCell className="px-3 py-3.5">{formatReportDate(b.receivedDate)}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[b.status])}>
{b.status}
@@ -146,12 +146,12 @@ export default function ReceivedChequesPage() {
>
<TableCell className="px-3 py-3.5 font-medium">{c.chequeNo}</TableCell>
<TableCell className="px-3 py-3.5">{c.receivedFromName}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{c.receivedFromType}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">
<TableCell className="px-3 py-3.5">{c.receivedFromType}</TableCell>
<TableCell className="px-3 py-3.5">
{c.branchId ? (branchesById.get(c.branchId)?.name ?? `#${c.branchId}`) : "—"}
</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">{formatAmount(c.amount)}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(c.receivedDate)}</TableCell>
<TableCell className="px-3 py-3.5">{formatReportDate(c.receivedDate)}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[c.status])}>
{c.status}
@@ -15,11 +15,12 @@ import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const PAGE_SIZE = 10
const PAGE_SIZE = 25
const STATUS_STYLE: Record<AttendanceUploadBatch["status"], string> = {
Draft: "bg-muted text-muted-foreground",
@@ -137,16 +138,7 @@ export default function AttendanceBatchesPage() {
</TableBody>
</Table>
)}
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
</div>
</div>
)}
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
</div>
)
}
@@ -15,6 +15,10 @@ import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/component
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { StatCard } from "@/components/ui/stat-card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -16,6 +16,7 @@ import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -15,6 +15,7 @@ import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -16,6 +16,7 @@ import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -16,6 +16,7 @@ import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
@@ -16,6 +16,7 @@ import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -16,6 +16,7 @@ import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
@@ -128,7 +128,7 @@ export default function GeneralLedgerReportPage() {
<TableCell className="px-3 py-2.5 font-medium">{row.accountCode}</TableCell>
<TableCell className="px-3 py-2.5">{row.accountName}</TableCell>
<TableCell className="px-3 py-2.5">{formatReportDate(row.entryDate)}</TableCell>
<TableCell className="px-3 py-2.5 text-muted-foreground">{row.narration ?? "—"}</TableCell>
<TableCell className="px-3 py-2.5">{row.narration ?? "—"}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debitAmount, true)}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.creditAmount, true)}</TableCell>
+57 -59
View File
@@ -6,7 +6,6 @@ import {
AlertTriangle,
BadgeDollarSign,
Boxes,
Clock,
ClipboardList,
ListChecks,
PackageCheck,
@@ -128,7 +127,7 @@ export default function DashboardPage() {
</div>
)}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-3 lg:grid-cols-4">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 lg:grid-cols-6">
{loaded ? (
<>
<Link href="/dashboard/stock/reorder-alerts">
@@ -137,9 +136,6 @@ export default function DashboardPage() {
<Link href="/dashboard/stock/enquiry">
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} />
</Link>
<Link href="/dashboard/procurement/purchase-orders">
<StatCard label="Pending Approval POs" value={stats.pendingApprovalPurchaseOrders} icon={Clock} />
</Link>
<Link href="/dashboard/receiving/grn">
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} />
</Link>
@@ -154,66 +150,68 @@ export default function DashboardPage() {
</Link>
</>
) : (
!error && Array.from({ length: 7 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
!error && Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
)}
</div>
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
<div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
<h2 className="flex items-center gap-2 text-sm font-bold tracking-tight text-foreground sm:text-base">
<BadgeDollarSign className="size-4 shrink-0 text-primary" />
Stock Valuation by Warehouse
</h2>
<Link href="/dashboard/stock/valuation" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
View details
</Link>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
<div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
<h2 className="flex items-center gap-2 text-sm font-bold tracking-tight text-foreground sm:text-base">
<BadgeDollarSign className="size-4 shrink-0 text-primary" />
Stock Valuation by Warehouse
</h2>
<Link href="/dashboard/stock/valuation" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
View details
</Link>
</div>
{loaded ? (
(stats.stockValuationByWarehouse ?? []).length > 0 ? (
<div className="h-52 sm:h-64">
<BarChart
labels={stats.stockValuationByWarehouse.map(
(w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`
)}
datasets={[
{
label: "Stock Value (LKR)",
data: stats.stockValuationByWarehouse.map((w) => w.total),
backgroundColor: "#E11D48",
},
]}
/>
</div>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">No stock on hand yet.</p>
)
) : (
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
)}
</div>
{loaded ? (
(stats.stockValuationByWarehouse ?? []).length > 0 ? (
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
<div className="mb-4 flex items-center gap-2">
<ScrollText className="size-4 shrink-0 text-primary" />
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">
Stock Movement Trend (last {TREND_DAYS} days)
</h2>
</div>
{loaded ? (
<div className="h-52 sm:h-64">
<BarChart
labels={stats.stockValuationByWarehouse.map(
(w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`
)}
<LineChart
labels={movementTrend.labels}
datasets={[
{
label: "Stock Value (LKR)",
data: stats.stockValuationByWarehouse.map((w) => w.total),
backgroundColor: "#6366f1",
},
{ label: "In", data: movementTrend.inData, borderColor: "#22c55e", backgroundColor: "rgba(34, 197, 94, 0.12)" },
{ label: "Out", data: movementTrend.outData, borderColor: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.12)" },
]}
/>
</div>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">No stock on hand yet.</p>
)
) : (
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
)}
</div>
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
<div className="mb-4 flex items-center gap-2">
<ScrollText className="size-4 shrink-0 text-primary" />
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">
Stock Movement Trend (last {TREND_DAYS} days)
</h2>
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
)}
</div>
{loaded ? (
<div className="h-52 sm:h-64">
<LineChart
labels={movementTrend.labels}
datasets={[
{ label: "In", data: movementTrend.inData, borderColor: "#22c55e", backgroundColor: "rgba(34, 197, 94, 0.12)" },
{ label: "Out", data: movementTrend.outData, borderColor: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.12)" },
]}
/>
</div>
) : (
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
)}
</div>
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
@@ -243,9 +241,9 @@ export default function DashboardPage() {
<TableBody>
{movements.map((entry) => (
<TableRow key={entry.ledgerId}>
<TableCell className="font-medium text-foreground">#{entry.itemId}</TableCell>
<TableCell className="text-muted-foreground">
{warehousesById.get(entry.warehouseId)?.code ?? `#${entry.warehouseId}`}
<TableCell className="font-medium text-foreground">{entry.itemId}</TableCell>
<TableCell>
{warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId}
</TableCell>
<TableCell>
<Badge
@@ -260,11 +258,11 @@ export default function DashboardPage() {
{entry.direction}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{entry.qtyBase}</TableCell>
<TableCell className="text-muted-foreground">
<TableCell>{entry.qtyBase}</TableCell>
<TableCell>
{entry.sourceDocType} #{entry.sourceDocId}
</TableCell>
<TableCell className="text-right text-muted-foreground">
<TableCell className="text-right">
{new Date(entry.createdAt).toLocaleDateString()}
</TableCell>
</TableRow>
@@ -224,7 +224,7 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
{line.grnDocNo}
</Link>
</TableCell>
<TableCell className="px-4 py-3 text-muted-foreground">{new Date(line.grnPostedAt).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3">{new Date(line.grnPostedAt).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3 text-right tabular-nums">{formatAmount(line.subtotal)}</TableCell>
<TableCell className="px-4 py-3 text-right tabular-nums">{formatAmount(line.vatTotal)}</TableCell>
<TableCell className="px-4 py-3 text-right font-medium tabular-nums">{formatAmount(line.lineTotal)}</TableCell>
@@ -112,17 +112,6 @@ function NewPurchaseInvoiceContent() {
[vendors]
)
// Deep-linked from a GRN detail page's "Create Purchase Invoice" prompt — that GRN is the
// whole reason this page was opened, so show only it rather than the vendor's full eligible
// list (which could pull in unrelated GRNs the user never meant to consolidate here).
// Declared before toggleAll, which reads it: referencing it ahead of its declaration stopped
// the React Compiler from preserving this memo (react-hooks/preserve-manual-memoization).
const deepLinkedGrnId = grnIdFromQuery ? Number(grnIdFromQuery) : null
const visibleGrns = useMemo(
() => (deepLinkedGrnId === null ? (eligibleGrns ?? []) : (eligibleGrns ?? []).filter((g) => g.grnId === deepLinkedGrnId)),
[eligibleGrns, deepLinkedGrnId]
)
function toggleGrn(grnId: number, checked: boolean) {
setSelectedGrnIds((prev) => {
const next = new Set(prev)
@@ -136,6 +125,15 @@ function NewPurchaseInvoiceContent() {
setSelectedGrnIds(checked ? new Set(visibleGrns.map((g) => g.grnId)) : new Set())
}
// Deep-linked from a GRN detail page's "Create Purchase Invoice" prompt — that GRN is the
// whole reason this page was opened, so show only it rather than the vendor's full eligible
// list (which could pull in unrelated GRNs the user never meant to consolidate here).
const deepLinkedGrnId = grnIdFromQuery ? Number(grnIdFromQuery) : null
const visibleGrns = useMemo(
() => (deepLinkedGrnId === null ? (eligibleGrns ?? []) : (eligibleGrns ?? []).filter((g) => g.grnId === deepLinkedGrnId)),
[eligibleGrns, deepLinkedGrnId]
)
const selectedGrns = useMemo(
() => (eligibleGrns ?? []).filter((g) => selectedGrnIds.has(g.grnId)),
[eligibleGrns, selectedGrnIds]
@@ -288,7 +286,7 @@ function NewPurchaseInvoiceContent() {
/>
</TableCell>
<TableCell className="px-3 py-3 font-medium">{g.docNo}</TableCell>
<TableCell className="px-3 py-3 text-muted-foreground">{new Date(g.postedAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3">{new Date(g.postedAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3 text-right tabular-nums">{g.lineCount}</TableCell>
<TableCell className="px-3 py-3 text-right tabular-nums">{lkr.format(g.subtotal)}</TableCell>
<TableCell className="px-3 py-3 text-right tabular-nums">{lkr.format(g.vatTotal)}</TableCell>
@@ -2,12 +2,13 @@
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, Eye, Filter, Plus, Printer, ReceiptText } from "lucide-react"
import { CheckCircle2, CircleDollarSign, Eye, Filter, Plus, Printer, ReceiptText } from "lucide-react"
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
import { errorMessage } from "@/lib/error-map"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
@@ -19,7 +20,7 @@ import { cn } from "@/lib/utils"
type StatusFilter = PurchaseInvoiceStatus | "All"
const PAGE_SIZE = 10
const PAGE_SIZE = 25
const tabs: { value: StatusFilter; label: string }[] = [
{ value: "All", label: "All" },
{ value: "Draft", label: "Draft" },
@@ -198,10 +199,10 @@ export default function PurchaseInvoicesPage() {
</TableCell>
<TableCell className="px-4 py-3.5">{row.vendorInvoiceNo}</TableCell>
<TableCell className="px-4 py-3.5">{row.vendorSnapshotName}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(row.vendorInvoiceDate).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5">{new Date(row.vendorInvoiceDate).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.grnCount}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums text-muted-foreground">{row.totals.vatTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.vatTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5">
<div className="flex flex-col items-start gap-1">
@@ -271,22 +272,9 @@ export default function PurchaseInvoicesPage() {
</div>
</div>
{pagination && pagination.totalPages > 1 && (
<div className="flex flex-col gap-3 border-t px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}-{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
<ChevronLeft />
Previous
</Button>
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>
Next
<ChevronRight />
</Button>
</div>
{pagination && (
<div className="border-t px-4 py-4">
<PaginationFooter pagination={pagination} onPageChange={setPage} />
</div>
)}
</>
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { Check, ChevronLeft, ChevronRight, Eye, Pencil, Plus, ShoppingCart, Trash2 } from "lucide-react"
import { Check, Eye, Pencil, Plus, ShoppingCart, Trash2 } from "lucide-react"
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { vendorsApi } from "@/lib/api/vendors"
@@ -14,6 +14,7 @@ import { cn } from "@/lib/utils"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -22,7 +23,7 @@ import { PoStatusBadge } from "@/components/procurement/status-badges"
type StatusFilter = PurchaseOrderStatus | "All"
const PAGE_SIZE = 10
const PAGE_SIZE = 25
export default function PurchaseOrdersListPage() {
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
@@ -260,31 +261,7 @@ export default function PurchaseOrdersListPage() {
</TableBody>
</Table>
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
<ChevronLeft />
Previous
</Button>
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight />
</Button>
</div>
</div>
)}
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
</>
)}
</div>
@@ -107,7 +107,7 @@ export default function PurchaseReturnsListPage() {
{r.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{r.glJournalNo ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{r.glJournalNo ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
</TableRow>
))}
@@ -9,7 +9,8 @@ import { errorMessage } from "@/lib/error-map"
import { RequisitionStatus, RequisitionSummary } from "@/types/procurement"
import { PaginationMeta } from "@/types/common"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
import { buttonVariants } from "@/components/ui/button"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -17,7 +18,7 @@ import { RequisitionStatusBadge } from "@/components/procurement/status-badges"
type StatusFilter = RequisitionStatus | "All"
const PAGE_SIZE = 10
const PAGE_SIZE = 25
export default function RequisitionsListPage() {
const [requisitions, setRequisitions] = useState<RequisitionSummary[] | null>(null)
@@ -114,36 +115,14 @@ export default function RequisitionsListPage() {
<RequisitionStatusBadge status={r.status} />
</TableCell>
<TableCell className="px-3 py-3.5">{r.lineCount}</TableCell>
<TableCell className="px-3 py-3.5">#{r.requestedBy}</TableCell>
<TableCell className="px-3 py-3.5">{r.requestedBy}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
Previous
</Button>
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
</Button>
</div>
</div>
)}
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
</>
)}
</div>
@@ -213,7 +213,7 @@ export default function ProducedItemsPage() {
<TableCell className="px-3 py-3.5 text-right">{line.goodQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right">{formatCost(line.unitCost)}</TableCell>
<TableCell className="px-3 py-3.5 text-right font-medium">{formatCost(line.totalCost)}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">
<TableCell className="px-3 py-3.5">
{line.completedAt ? new Date(line.completedAt).toLocaleDateString() : "—"}
</TableCell>
</TableRow>
@@ -19,6 +19,7 @@ import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
@@ -425,22 +426,7 @@ export default function ProductionRunsPage() {
})}
</div>
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-base text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setPage((p) => p - 1)} disabled={pagination.page <= 1}>
Previous
</Button>
<Button variant="outline" onClick={() => setPage((p) => p + 1)} disabled={pagination.page >= pagination.totalPages}>
Next
</Button>
</div>
</div>
)}
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
</>
)}
</div>
@@ -22,6 +22,7 @@ import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
@@ -286,26 +287,7 @@ export default function ProductionTemplatesPage() {
)}
</div>
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-base text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setPage((p) => p - 1)} disabled={pagination.page <= 1}>
Previous
</Button>
<Button
variant="outline"
onClick={() => setPage((p) => p + 1)}
disabled={pagination.page >= pagination.totalPages}
>
Next
</Button>
</div>
</div>
)}
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
</>
)}
</div>
@@ -1,223 +1,193 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react"
import { useRouter, useSearchParams } from "next/navigation"
import { Plus, Ruler, Tag } from "lucide-react"
import { brandsApi } from "@/lib/api/brands"
import { errorMessage } from "@/lib/error-map"
import { validateBrandName } from "@/lib/validations/master-data"
import { uomsApi } from "@/lib/api/uoms"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateBrandName, validateUomName } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { EntityStatus, PaginationMeta } from "@/types/common"
import { Brand } from "@/types/master-data"
import { Brand, Uom } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
type SortOrder = "asc" | "desc"
type SortKey = "brandId" | "name" | "status" | "createdAt"
type StatusFilter = EntityStatus | "All"
type Tab = "brand" | "uom"
const PAGE_SIZE = 5
// Large enough to show "all brands" in one grid — the chip layout replaces the
// old table's pagination, so we just ask for everything in one page.
const ALL_ITEMS_PAGE_SIZE = 500
export default function BrandsPage() {
const router = useRouter()
const searchParams = useSearchParams()
const initialTab: Tab = searchParams.get("tab") === "uom" ? "uom" : "brand"
const [tab, setTab] = useState<Tab>(initialTab)
function selectTab(next: Tab) {
setTab(next)
router.replace(next === "uom" ? "/dashboard/products/brands?tab=uom" : "/dashboard/products/brands", { scroll: false })
}
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Brand/UOM</h1>
<p className="text-base text-muted-foreground">Manage product brands and units of measure.</p>
</div>
<div className="inline-flex w-fit items-center gap-1 rounded-full bg-muted p-1">
<button
type="button"
onClick={() => selectTab("brand")}
className={cn(
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
tab === "brand" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
)}
>
Brand
</button>
<button
type="button"
onClick={() => selectTab("uom")}
className={cn(
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
tab === "uom" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
)}
>
UOM
</button>
</div>
{tab === "brand" ? <BrandPanel /> : <UomPanel />}
</div>
)
}
function BrandPanel() {
const [brands, setBrands] = useState<Brand[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [searchInput, setSearchInput] = useState("")
const [search, setSearch] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
// Sorts only the currently loaded page client-side — the backend ignores `sort` and
// always returns Name ascending, so this doesn't hold across page turns or other columns.
const [sortKey, setSortKey] = useState<SortKey>("name")
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<Brand | null>(null)
const [name, setName] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [togglingId, setTogglingId] = useState<number | null>(null)
useEffect(() => {
const timeout = setTimeout(() => setSearch(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => {
setPage(1)
}, [search, status])
const [editOpen, setEditOpen] = useState(false)
const [editing, setEditing] = useState<Brand | null>(null)
const [editName, setEditName] = useState("")
const [editErrors, setEditErrors] = useState<Record<string, string>>({})
const [editSubmitting, setEditSubmitting] = useState(false)
function load() {
setError(null)
brandsApi
.list({ q: search || undefined, status: status === "All" ? undefined : status, sort: "name", page, pageSize: PAGE_SIZE })
.then((res) => {
setBrands(res.items)
setPagination(res.pagination)
})
.list({ sort: "name", page: 1, pageSize: ALL_ITEMS_PAGE_SIZE })
.then((res) => setBrands(res.items))
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [search, status, page])
useEffect(load, [])
const hasFilters = search.trim().length > 0 || status !== "All"
const sortedBrands = brands
? [...brands].sort((a, b) => {
const cmp =
sortKey === "brandId"
? a.brandId - b.brandId
: sortKey === "createdAt"
? new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
: a[sortKey].localeCompare(b[sortKey])
return sortOrder === "asc" ? cmp : -cmp
})
: null
function toggleSort(key: SortKey) {
if (key === sortKey) {
setSortOrder((o) => (o === "asc" ? "desc" : "asc"))
} else {
setSortKey(key)
setSortOrder("asc")
}
}
function openCreateDialog() {
setEditing(null)
setName("")
setErrors({})
setOpen(true)
}
function openEditDialog(brand: Brand) {
setEditing(brand)
setName(brand.name)
setErrors({})
setOpen(true)
}
async function handleSubmit() {
async function handleAdd() {
const nextErrors = validateBrandName(name)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
let result
if (editing) {
// The list response carries no ETag, so re-read to get a fresh If-Match token
// rather than guessing one. A concurrent edit surfaces as 412 from the server.
const current = await brandsApi.get(editing.brandId)
result = await brandsApi.update(editing.brandId, { name }, current.etag ?? "")
} else {
result = await brandsApi.create({ name })
}
toast.success(editing ? "Brand updated" : "Brand created", result.data.name)
setOpen(false)
const result = await brandsApi.create({ name })
toast.success("Brand created", result.data.name)
setName("")
setEditing(null)
setErrors({})
load()
} catch (err) {
setErrors({ name: errorMessage(err) })
toast.error(editing ? "Could not update brand" : "Could not create brand", errorMessage(err))
toast.error("Could not create brand", errorMessage(err))
} finally {
setSubmitting(false)
}
}
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
async function handleToggleStatus(brand: Brand) {
async function handleToggleActive(brand: Brand) {
const next = brand.status === "Active" ? "Inactive" : "Active"
setDeletingId(brand.brandId)
setTogglingId(brand.brandId)
try {
await brandsApi.updateStatus(brand.brandId, next)
toast.success(next === "Inactive" ? "Brand deactivated" : "Brand activated", brand.name)
load()
} catch (err) {
toast.error("Could not update brand status", errorMessage(err))
} finally {
setDeletingId(null)
setTogglingId(null)
}
}
function openEdit(brand: Brand) {
setEditing(brand)
setEditName(brand.name)
setEditErrors({})
setEditOpen(true)
}
async function handleEditSubmit() {
if (!editing) return
const nextErrors = validateBrandName(editName)
setEditErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setEditSubmitting(true)
try {
// The list response carries no ETag, so re-read to get a fresh If-Match token
// rather than guessing one. A concurrent edit surfaces as 412 from the server.
const current = await brandsApi.get(editing.brandId)
const result = await brandsApi.update(editing.brandId, { name: editName }, current.etag ?? "")
toast.success("Brand updated", result.data.name)
setEditOpen(false)
setEditing(null)
load()
} catch (err) {
setEditErrors({ name: errorMessage(err) })
toast.error("Could not update brand", errorMessage(err))
} finally {
setEditSubmitting(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Brands</h1>
<p className="text-base text-muted-foreground">Manage product brands.</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />Add Brand</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>{editing ? "Edit brand" : "New brand"}</DialogTitle>
<DialogDescription>Give the brand a name.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="brand-name">Name</FieldLabel>
<Input id="brand-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Bosch" aria-invalid={!!errors.name} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleSubmit} disabled={submitting}>
{submitting ? (editing ? "Saving…" : "Creating…") : editing ? "Save" : "Create"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 basis-0">
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search brands…"
className="h-14 w-full pl-11 text-base"
aria-label="Search brands"
/>
</div>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-14! w-full sm:w-48 text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="Active" className="text-base">Active</SelectItem>
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
</SelectContent>
</Select>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="New brand name, e.g. Bosch"
className="h-12 flex-1 text-base"
aria-label="New brand name"
aria-invalid={!!errors.name}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
/>
<Button size="lg" onClick={handleAdd} disabled={submitting}>
<Plus className="size-5" />
Add Brand
</Button>
</div>
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && brands === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)}
@@ -225,162 +195,171 @@ export default function BrandsPage() {
{!error && brands !== null && brands.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Tag className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">
{hasFilters ? "No brands match your search/filter." : "No brands yet."}
</p>
<p className="text-base text-muted-foreground">No brands yet.</p>
</div>
)}
{!error && brands !== null && brands.length > 0 && (
<>
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="ID" active={sortKey === "brandId"} order={sortOrder} onClick={() => toggleSort("brandId")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedBrands!.map((b) => (
<TableRow key={b.brandId}>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{b.brandId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{b.name}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge
variant="outline"
className={cn(
"border-transparent",
b.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{b.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(b.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${b.name}`}
onClick={() => openEditDialog(b)}
>
<Pencil className="size-4" />
</Button>
{/* Deactivate, not delete: the API has no DELETE for any master
(FR-MD-08) — records referenced by transactions must survive. */}
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className={b.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${b.status === "Active" ? "Deactivate" : "Activate"} ${b.name}`}
disabled={deletingId === b.brandId}
/>
}
>
{b.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant={b.status === "Active" ? "destructive" : "success"}
title={`${b.status === "Active" ? "Deactivate" : "Activate"} ${b.name}?`}
description={
b.status === "Active"
? "The brand stays on existing items but cannot be assigned to new ones."
: "The brand becomes selectable again."
}
confirmLabel={b.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(b)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight />
</Button>
</div>
</div>
)}
</>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{brands.map((b) => (
<label
key={b.brandId}
onDoubleClick={() => openEdit(b)}
title="Tick to activate/deactivate — double-click to rename"
className="flex items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50"
>
<Checkbox
checked={b.status === "Active"}
onCheckedChange={() => handleToggleActive(b)}
disabled={togglingId === b.brandId}
/>
<span className="font-medium">{b.name}</span>
</label>
))}
</div>
)}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>Edit brand</DialogTitle>
<DialogDescription>Give the brand a name.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!editErrors.name}>
<FieldLabel htmlFor="brand-edit-name">Name</FieldLabel>
<Input
id="brand-edit-name"
value={editName}
onChange={(e) => setEditName(e.target.value)}
aria-invalid={!!editErrors.name}
/>
<FieldError errors={[editErrors.name ? { message: editErrors.name } : undefined]} />
</Field>
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setEditOpen(false)} disabled={editSubmitting}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleEditSubmit} disabled={editSubmitting}>
{editSubmitting ? "Saving…" : "Save"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}
function SortableHeader({
label,
active,
order,
onClick,
}: {
label: string
active: boolean
order: SortOrder
onClick: () => void
}) {
function UomPanel() {
const [uoms, setUoms] = useState<Uom[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [name, setName] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null)
function load() {
uomsApi
.list({ page: 1, pageSize: ALL_ITEMS_PAGE_SIZE })
.then((res) => setUoms(res.items))
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
async function handleToggleActive(uom: Uom) {
const next = uom.status === "Active" ? "Inactive" : "Active"
setTogglingId(uom.uomId)
try {
await uomsApi.updateStatus(uom.uomId, next)
load()
} catch (err) {
toast.error("Could not update UOM status", errorMessage(err))
} finally {
setTogglingId(null)
}
}
async function handleAdd() {
const nextErrors = validateUomName(name)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const uom = await uomsApi.create({ name })
toast.success("UOM created", uom.name)
setName("")
setErrors({})
load()
} catch (err) {
const fe = fieldErrors(err)
if (fe?.name) setErrors({ name: fe.name })
toast.error("Could not create UOM", errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<button
type="button"
className="flex items-center gap-1 hover:text-foreground"
onClick={onClick}
aria-label={`Sort by ${label}, ${active && order === "asc" ? "descending" : "ascending"}`}
>
{label}
{active ? (
order === "asc" ? (
<ArrowUp className="size-3.5" />
) : (
<ArrowDown className="size-3.5" />
)
) : (
<ArrowUpDown className="size-3.5 text-muted-foreground/50" />
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="New UOM name, e.g. Box-12"
className="h-12 flex-1 text-base"
aria-label="New UOM name"
aria-invalid={!!errors.name}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
/>
<Button size="lg" onClick={handleAdd} disabled={submitting}>
<Plus className="size-5" />
Add UOM
</Button>
</div>
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
</button>
{!error && uoms === null && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)}
{!error && uoms !== null && uoms.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Ruler className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No UOMs yet.</p>
</div>
)}
{!error && uoms !== null && uoms.length > 0 && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{uoms.map((u) => (
<label
key={u.uomId}
title="Tick to activate/deactivate"
className="flex items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50"
>
<Checkbox
checked={u.status === "Active"}
onCheckedChange={() => handleToggleActive(u)}
disabled={togglingId === u.uomId}
/>
<span className="font-medium">{u.name}</span>
</label>
))}
</div>
)}
</div>
)
}
@@ -179,7 +179,7 @@ export default function CategorySubCategoriesPage() {
<TableBody>
{subCategories.map((s) => (
<TableRow key={s.subCategoryId}>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{s.subCategoryId}</TableCell>
<TableCell className="px-3 py-3.5">{s.subCategoryId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{s.name}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge
@@ -192,7 +192,7 @@ export default function CategorySubCategoriesPage() {
{s.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(s.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(s.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${s.name}`} onClick={() => openEditDialog(s)}>
@@ -2,14 +2,15 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, ListTree, Network, Pencil, Plus, Search } from "lucide-react"
import { ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, ListTree, Network, Pencil, Plus, Search } from "lucide-react"
import { categoriesApi } from "@/lib/api/categories"
import { productConfig } from "@/lib/api/product-config"
import { errorMessage } from "@/lib/error-map"
import { validateCategoryName } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { EntityStatus, PaginationMeta } from "@/types/common"
import { Category } from "@/types/master-data"
import { Category, ProductConfig } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
@@ -17,6 +18,7 @@ import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -26,7 +28,7 @@ type SortOrder = "asc" | "desc"
type SortKey = "categoryId" | "name" | "status" | "createdAt"
type StatusFilter = EntityStatus | "All"
const PAGE_SIZE = 5
const PAGE_SIZE = 25
export default function CategoriesPage() {
const [categories, setCategories] = useState<Category[] | null>(null)
@@ -48,6 +50,36 @@ export default function CategoriesPage() {
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [config, setConfig] = useState<ProductConfig | null>(null)
const [subCategoryCounts, setSubCategoryCounts] = useState<Record<number, number>>({})
useEffect(() => {
productConfig().then(setConfig).catch(() => setConfig(null))
}, [])
// Subcategories are their own resource — there's no count on the Category DTO itself,
// so fetch each visible row's total separately (pageSize 1: only `pagination.totalItems`
// is needed) once the feature is on and the page's categories are known.
useEffect(() => {
if (!config?.subcategoriesEnabled || !categories || categories.length === 0) {
setSubCategoryCounts({})
return
}
let cancelled = false
Promise.all(
categories.map((c) =>
categoriesApi
.listSubCategories(c.categoryId, { pageSize: 1 })
.then((res) => [c.categoryId, res.pagination.totalItems] as const)
.catch(() => [c.categoryId, 0] as const)
)
).then((entries) => {
if (!cancelled) setSubCategoryCounts(Object.fromEntries(entries))
})
return () => {
cancelled = true
}
}, [config?.subcategoriesEnabled, categories])
useEffect(() => {
const timeout = setTimeout(() => setSearch(searchInput.trim()), 300)
@@ -247,13 +279,14 @@ export default function CategoriesPage() {
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} />
</TableHead>
{config?.subcategoriesEnabled && <TableHead className="h-12 px-3 text-sm">Subcategories</TableHead>}
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedCategories!.map((c) => (
<TableRow key={c.categoryId}>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{c.categoryId}</TableCell>
<TableCell className="px-3 py-3.5">{c.categoryId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge
@@ -266,17 +299,23 @@ export default function CategoriesPage() {
{c.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
{config?.subcategoriesEnabled && (
<TableCell className="px-3 py-3.5">{subCategoryCounts[c.categoryId] ?? "—"}</TableCell>
)}
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
{/* Subcategories are their own resource now, not a nested tree. */}
<Link
href={`/dashboard/products/categories/${c.categoryId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Manage subcategories of ${c.name}`}
>
<Network className="size-4" />
</Link>
{/* Subcategories are their own resource now, not a nested tree.
Hidden while the feature is switched off in Product Configuration. */}
{config?.subcategoriesEnabled && (
<Link
href={`/dashboard/products/categories/${c.categoryId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Manage subcategories of ${c.name}`}
>
<Network className="size-4" />
</Link>
)}
<Button
variant="ghost"
@@ -321,39 +360,7 @@ export default function CategoriesPage() {
</TableBody>
</Table>
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight />
</Button>
</div>
</div>
)}
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
</>
)}
</div>
@@ -207,7 +207,7 @@ export default function ItemTypesPage() {
<TableBody>
{itemTypes.map((t) => (
<TableRow key={t.itemTypeId}>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{t.itemTypeId}</TableCell>
<TableCell className="px-3 py-3.5">{t.itemTypeId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{t.name}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline">{t.isMeasurable ? "Measurement" : "Text"}</Badge>
@@ -215,7 +215,7 @@ export default function ItemTypesPage() {
<TableCell className="px-3 py-3.5">
<Badge variant={t.status === "Active" ? "default" : "secondary"}>{t.status}</Badge>
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(t.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(t.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${t.name}`} onClick={() => openEditDialog(t)}>
@@ -42,6 +42,7 @@ import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { FieldError } from "@/components/ui/field"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -720,136 +721,122 @@ export default function NewItemPage() {
</div>
</div>
{config?.sellingPricesEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div className="flex flex-col gap-2 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground">Variant selling prices</h2>
<p className="text-sm text-muted-foreground">
Edit the min, max, and flat price per generated variant. The bulk default only fills the flat column.
</p>
</div>
<div className="inline-flex w-fit rounded-lg border p-1">
<button
type="button"
onClick={() => setPriceMode("stock")}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
priceMode === "stock" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Use stock value
</button>
<button
type="button"
onClick={() => setPriceMode("fixed")}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
priceMode === "fixed" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Fixed price
</button>
</div>
</div>
{priceMode === "fixed" && (
<div className="flex max-w-xl flex-wrap items-end gap-3">
<div className="flex min-w-56 flex-1 flex-col gap-2">
<Label className="text-base">Bulk default flat price</Label>
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={defaultFlatPrice}
onChange={(e) => setDefaultFlatPrice(e.target.value)}
placeholder="0.00"
className="h-11 text-base"
/>
{(config?.sellingPricesEnabled || config?.warrantyPeriodsEnabled) && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{config?.sellingPricesEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Variant selling prices</h2>
<p className="text-sm text-muted-foreground">
Edit the min, max, and flat price per generated variant. The bulk default only fills the flat column.
</p>
</div>
<Button
type="button"
variant="outline"
onClick={() => {
const raw = defaultFlatPrice.trim()
if (!raw) return
setPricesByKey((prev) => {
const next = { ...prev }
for (const variant of variants) {
if (next[variant.key] == null || next[variant.key].trim() === "") {
next[variant.key] = raw
}
}
return next
})
}}
<RadioGroup
value={priceMode}
onValueChange={(v) => setPriceMode(v as typeof priceMode)}
className="flex flex-col gap-3"
>
Fill blank flat prices
</Button>
<p className="text-sm text-muted-foreground">
This copies the same value into blank flat-price cells only.
</p>
<label className="flex items-center gap-2.5">
<RadioGroupItem value="stock" />
<span className="text-base font-medium">Use stock value</span>
</label>
<label className="flex items-center gap-2.5">
<RadioGroupItem value="fixed" />
<span className="text-base font-medium">Fixed price</span>
</label>
</RadioGroup>
{priceMode === "fixed" && (
<div className="flex flex-wrap items-end gap-3">
<div className="flex min-w-56 flex-1 flex-col gap-2">
<Label className="text-base">Bulk default flat price</Label>
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={defaultFlatPrice}
onChange={(e) => setDefaultFlatPrice(e.target.value)}
placeholder="0.00"
className="h-11 text-base"
/>
</div>
<Button
type="button"
variant="outline"
onClick={() => {
const raw = defaultFlatPrice.trim()
if (!raw) return
setPricesByKey((prev) => {
const next = { ...prev }
for (const variant of variants) {
if (next[variant.key] == null || next[variant.key].trim() === "") {
next[variant.key] = raw
}
}
return next
})
}}
>
Fill blank flat prices
</Button>
<p className="text-sm text-muted-foreground">
This copies the same value into blank flat-price cells only.
</p>
</div>
)}
</div>
)}
</div>
)}
{config?.warrantyPeriodsEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Warranty</h2>
<p className="text-sm text-muted-foreground">
Mark whether every generated variant is sold under warranty.
</p>
</div>
{config?.warrantyPeriodsEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Warranty</h2>
<p className="text-sm text-muted-foreground">
Mark whether every generated variant is sold under warranty.
</p>
</div>
<div className="inline-flex w-fit rounded-lg border p-1">
<button
type="button"
onClick={() => {
setWarranty("NonWarranty")
setWarrantyPeriodMonths(null)
}}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
warranty === "NonWarranty" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Non-Warranty
</button>
<button
type="button"
onClick={() => setWarranty("Warranty")}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
warranty === "Warranty" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Warranty
</button>
</div>
{warranty === "Warranty" && (
<div className="flex flex-col gap-1.5">
<Label>Warranty period</Label>
<Select<WarrantyPeriodMonths>
value={warrantyPeriodMonths}
onValueChange={(v) => v && setWarrantyPeriodMonths(v)}
<RadioGroup
value={warranty}
onValueChange={(v) => {
setWarranty(v as typeof warranty)
if (v === "NonWarranty") setWarrantyPeriodMonths(null)
}}
className="flex flex-col gap-3"
>
<SelectTrigger className="h-11! w-48 text-base" aria-invalid={!!errors.warrantyPeriodMonths}>
<SelectValue placeholder="Select period" />
</SelectTrigger>
<SelectContent>
{WARRANTY_PERIOD_MONTHS_OPTIONS.map((months) => (
<SelectItem key={months} value={months} className="text-base">
{months} months
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.warrantyPeriodMonths ? { message: errors.warrantyPeriodMonths } : undefined]} />
<label className="flex items-center gap-2.5">
<RadioGroupItem value="NonWarranty" />
<span className="text-base font-medium">Non-Warranty</span>
</label>
<label className="flex items-center gap-2.5">
<RadioGroupItem value="Warranty" />
<span className="text-base font-medium">Warranty</span>
</label>
</RadioGroup>
{warranty === "Warranty" && (
<div className="mt-2 flex flex-col gap-1.5">
<Label>Warranty period +</Label>
<Select<WarrantyPeriodMonths>
value={warrantyPeriodMonths}
onValueChange={(v) => v && setWarrantyPeriodMonths(v)}
>
<SelectTrigger className="h-11! w-48 text-base" aria-invalid={!!errors.warrantyPeriodMonths}>
<SelectValue placeholder="Select period" />
</SelectTrigger>
<SelectContent>
{WARRANTY_PERIOD_MONTHS_OPTIONS.map((months) => (
<SelectItem key={months} value={months} className="text-base">
{months} months
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.warrantyPeriodMonths ? { message: errors.warrantyPeriodMonths } : undefined]} />
</div>
)}
</div>
)}
</div>
@@ -888,7 +875,7 @@ export default function NewItemPage() {
key={t.itemTypeId}
title={blocked ? "Only one measurement dimension can be used at a time." : undefined}
className={cn(
"flex items-center gap-2.5 rounded-lg border px-3 py-2",
"flex min-w-32 items-center gap-2.5 rounded-lg border px-3 py-2",
blocked ? "opacity-50" : "hover:bg-muted/50"
)}
>
@@ -1047,7 +1034,7 @@ export default function NewItemPage() {
<TableCell className="py-2.5 pr-1 pl-3 font-medium">{variant.sku}</TableCell>
{/* Read-only: content is derived, so an editable cell here could only
disagree with the value that produced it. */}
<TableCell className="px-3 py-2.5 text-muted-foreground">
<TableCell className="px-3 py-2.5">
{contentLabelFor(variant)}
</TableCell>
{priceMode === "fixed" && (
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight, ListTree, Package, Pencil, Plus, Ruler, Search } from "lucide-react"
import { ListTree, Package, Pencil, Plus, Ruler, Search } from "lucide-react"
import { itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories"
@@ -14,8 +14,9 @@ import { EntityStatus, PaginationMeta } from "@/types/common"
import { Brand, Category, ItemListItem, ProductConfig, SubCategory, TrackingMode } from "@/types/master-data"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -23,7 +24,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
type StatusFilter = EntityStatus | "All"
type TrackingFilter = TrackingMode | "All"
const PAGE_SIZE = 10
const PAGE_SIZE = 25
export default function ItemsPage() {
const [items, setItems] = useState<ItemListItem[] | null>(null)
@@ -103,7 +104,6 @@ export default function ItemsPage() {
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Items</h1>
<p className="text-base text-muted-foreground">Item master — SKU, tracking mode, category, default vendor (FR-MD-01).</p>
</div>
<div className="flex items-center gap-2">
<Link href="/dashboard/products/uoms" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
@@ -208,7 +208,6 @@ export default function ItemsPage() {
{config?.subcategoriesEnabled && <TableHead className="h-12 px-3 text-sm">Subcategory</TableHead>}
{config?.brandsEnabled && <TableHead className="h-12 px-3 text-sm">Brand</TableHead>}
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
<TableHead className="h-12 px-3 text-sm">Tracking</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
@@ -224,17 +223,16 @@ export default function ItemsPage() {
<TableCell className="px-3 py-3.5">{item.name}</TableCell>
<TableCell className="px-3 py-3.5">{categoryName(item.categoryId)}</TableCell>
{config?.subcategoriesEnabled && (
<TableCell className="px-3 py-3.5 text-muted-foreground">
<TableCell className="px-3 py-3.5">
{item.subCategoryId !== null ? subCategoryName(item.subCategoryId) : "—"}
</TableCell>
)}
{config?.brandsEnabled && (
<TableCell className="px-3 py-3.5 text-muted-foreground">
<TableCell className="px-3 py-3.5">
{item.brandId !== null ? brandName(item.brandId) : "—"}
</TableCell>
)}
<TableCell className="px-3 py-3.5">{item.stockNature}</TableCell>
<TableCell className="px-3 py-3.5">{item.trackingMode}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge
variant="outline"
@@ -260,31 +258,7 @@ export default function ItemsPage() {
</TableBody>
</Table>
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
<ChevronLeft />
Previous
</Button>
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight />
</Button>
</div>
</div>
)}
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
</>
)}
</div>
@@ -1,129 +1,16 @@
"use client"
import { useEffect, useState } from "react"
import { Plus, Ruler } from "lucide-react"
import { useEffect } from "react"
import { useRouter } from "next/navigation"
import { uomsApi } from "@/lib/api/uoms"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateUomName } from "@/lib/validations/master-data"
import { Uom } from "@/types/master-data"
// UOM now lives as a tab on the merged Brand/UOM page — this route stays only
// to keep old links/bookmarks working.
export default function UomsRedirectPage() {
const router = useRouter()
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
useEffect(() => {
router.replace("/dashboard/products/brands?tab=uom")
}, [router])
export default function UomsPage() {
const [uoms, setUoms] = useState<Uom[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [name, setName] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
function load() {
uomsApi.list().then((res) => setUoms(res.items)).catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
async function handleCreate() {
const nextErrors = validateUomName(name)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const uom = await uomsApi.create({ name })
toast.success("UOM created", uom.name)
setOpen(false)
setName("")
setErrors({})
load()
} catch (err) {
const fe = fieldErrors(err)
if (fe?.name) setErrors({ name: fe.name })
toast.error("Could not create UOM", errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<div>
<h1 className="text-2xl font-bold text-foreground">Units of Measure</h1>
<p className="text-base text-muted-foreground">Flat UOM master, used as item base UOMs and as work-in-progress labels in production (FR-MD-02).</p>
</div>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New UOM</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>New UOM</DialogTitle>
<DialogDescription>e.g. EA, KG, Box-12.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="uom-name">Name</FieldLabel>
<Input id="uom-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Box-12" aria-invalid={!!errors.name} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
{submitting ? "Creating…" : "Create"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && uoms === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && uoms !== null && uoms.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Ruler className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No UOMs yet.</p>
</div>
)}
{!error && uoms !== null && uoms.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{uoms.map((u) => (
<TableRow key={u.uomId}>
<TableCell className="px-3 py-3.5 font-medium">{u.name}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
)
return null
}
@@ -1035,7 +1035,7 @@ export default function NewGrnPage() {
<TableBody>
{Array.from({ length: units }, (_, i) => (
<TableRow key={`${line.key}-${i}`}>
<TableCell className="px-3 py-3 align-top text-sm text-muted-foreground">
<TableCell className="px-3 py-3 align-top text-sm">
{i + 1} of {units}
</TableCell>
<TableCell className="px-3 py-3 align-top">
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight, Eye, PackageSearch, Plus, Search } from "lucide-react"
import { Eye, PackageSearch, Plus, Search } from "lucide-react"
import { grnsApi } from "@/lib/api/grns"
import { errorMessage } from "@/lib/error-map"
@@ -12,6 +12,7 @@ import { cn } from "@/lib/utils"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import {
@@ -27,7 +28,7 @@ import { GrnStatusBadge } from "@/components/receiving/status-badges"
type StatusFilter = GrnStatus | "All"
const PAGE_SIZE = 5
const PAGE_SIZE = 25
export default function GrnListPage() {
const { activeBranchId, branchingEnabled } = useAuth()
@@ -234,39 +235,7 @@ export default function GrnListPage() {
</TableBody>
</Table>
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight />
</Button>
</div>
</div>
)}
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
</>
)}
</div>
@@ -222,7 +222,7 @@ export default function BundleTemplatesPage() {
placeholder="Search item…"
/>
</TableCell>
<TableCell className="px-4 py-2 min-w-40 text-sm text-muted-foreground">
<TableCell className="px-4 py-2 min-w-40 text-sm">
{baseUomLabel(items, uoms, line.itemId)}
</TableCell>
<TableCell className="px-4 py-2">
@@ -288,7 +288,7 @@ export default function BundleTemplatesPage() {
<div className="font-medium">{t.templateName}</div>
<div className="text-xs text-muted-foreground">{t.templateCode}</div>
</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{t.description ?? "—"}</TableCell>
<TableCell className="px-4 py-3.5">{t.description ?? "—"}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{t.lineCount}</TableCell>
<TableCell className="px-4 py-3.5">{t.status}</TableCell>
<TableCell className="px-4 py-3.5 text-right">
@@ -88,7 +88,7 @@ export default function SalesDayEndPage() {
<div>
<h1 className="text-2xl font-bold text-foreground">Day End</h1>
<p className="text-base text-muted-foreground">
Close a cashier&apos;s business day — locks their posted sales slips and bundle sales, and posts one consolidated journal entry to the ledger.
Close a cashier's business day — locks their posted sales slips and bundle sales, and posts one consolidated journal entry to the ledger.
</p>
</div>
</div>
@@ -306,7 +306,7 @@ export default function NewFreeIssuePage() {
<TableBody>
{activeLines.map((line, idx) => (
<TableRow key={line.key} className="align-middle">
<TableCell className="px-4 py-2 text-xs text-muted-foreground">{idx + 1}</TableCell>
<TableCell className="px-4 py-2 text-xs">{idx + 1}</TableCell>
<TableCell className="px-4 py-2 min-w-80">
<Combobox
items={itemOptions}
@@ -315,7 +315,7 @@ export default function NewFreeIssuePage() {
placeholder="Search item…"
/>
</TableCell>
<TableCell className="px-4 py-2 min-w-44 text-sm text-muted-foreground">
<TableCell className="px-4 py-2 min-w-44 text-sm">
{baseUomLabel(items, uoms, line.itemId)}
</TableCell>
<TableCell className="px-4 py-2">
@@ -324,7 +324,7 @@ export default function NewFreeIssuePage() {
<TableCell className="px-4 py-2">
<Input type="number" min="0" step="1" value={line.freeQty} onChange={(e) => updateEditingLine(line.key, { freeQty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" />
</TableCell>
<TableCell className="px-4 py-2 text-right text-xs text-muted-foreground">editing</TableCell>
<TableCell className="px-4 py-2 text-right text-xs">editing</TableCell>
</TableRow>
))}
</TableBody>
@@ -365,7 +365,7 @@ export default function NewFreeIssuePage() {
<TableBody>
{lines.map((line, idx) => (
<TableRow key={line.key} className="align-middle">
<TableCell className="px-4 py-2 text-xs text-muted-foreground">{idx + 1}</TableCell>
<TableCell className="px-4 py-2 text-xs">{idx + 1}</TableCell>
<TableCell className="px-4 py-2 min-w-80">
<Combobox
items={itemOptions}
@@ -544,7 +544,7 @@ function NewSalesInvoiceContent() {
const bundleQty = row.lines[0]?.bundleQty ?? 1
return (
<TableRow key={row.groupId}>
<TableCell className="px-3 py-3 align-top text-sm text-muted-foreground">{idx + 1}</TableCell>
<TableCell className="px-3 py-3 align-top text-sm">{idx + 1}</TableCell>
<TableCell className="px-3 py-3 align-top">
<div className="font-medium">Bundle: {row.bundleName}</div>
<ul className="mt-1 space-y-0.5 text-xs text-muted-foreground">
@@ -555,7 +555,7 @@ function NewSalesInvoiceContent() {
))}
</ul>
</TableCell>
<TableCell className="px-3 py-3 align-top text-sm text-muted-foreground">—</TableCell>
<TableCell className="px-3 py-3 align-top text-sm">—</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
@@ -568,11 +568,11 @@ function NewSalesInvoiceContent() {
/>
<p className="mt-1 text-xs text-muted-foreground">bundles</p>
</TableCell>
<TableCell className="px-3 py-3 align-top text-right text-sm text-muted-foreground">
<TableCell className="px-3 py-3 align-top text-right text-sm">
{bundleFreeQty || "—"}
</TableCell>
<TableCell className="px-3 py-3 align-top text-right text-sm text-muted-foreground">—</TableCell>
<TableCell className="px-3 py-3 align-top text-right text-sm text-muted-foreground">—</TableCell>
<TableCell className="px-3 py-3 align-top text-right text-sm">—</TableCell>
<TableCell className="px-3 py-3 align-top text-right text-sm">—</TableCell>
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
<div className="flex h-11 items-center justify-end font-semibold">{lkr.format(bundleTotal)}</div>
</TableCell>
@@ -598,7 +598,7 @@ function NewSalesInvoiceContent() {
return (
<TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top text-sm text-muted-foreground">{idx + 1}</TableCell>
<TableCell className="px-3 py-3 align-top text-sm">{idx + 1}</TableCell>
<TableCell className="px-3 py-3 align-top">
<Combobox
items={lineComboOptions}
@@ -2,13 +2,14 @@
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, Eye, Filter, FileText, Plus, Printer } from "lucide-react"
import { CheckCircle2, CircleDollarSign, Eye, Filter, FileText, Plus, Printer } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { errorMessage } from "@/lib/error-map"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
@@ -20,7 +21,7 @@ import { cn } from "@/lib/utils"
type StatusFilter = SalesInvoiceStatus | "All"
const PAGE_SIZE = 10
const PAGE_SIZE = 25
const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
{ value: "All", label: "All" },
{ value: "Draft", label: "Draft" },
@@ -205,11 +206,11 @@ export default function SalesInvoicesPage() {
</Link>
</TableCell>
<TableCell className="px-4 py-3.5">{row.customerSnapshotName}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(row.invoiceDate).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5">{new Date(row.invoiceDate).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.freeQtyTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums text-muted-foreground">-{row.totals.discountTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">-{row.totals.discountTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5">
<div className="flex flex-col items-start gap-1">
@@ -279,22 +280,9 @@ export default function SalesInvoicesPage() {
</div>
</div>
{pagination && pagination.totalPages > 1 && (
<div className="flex flex-col gap-3 border-t px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}-{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
<ChevronLeft />
Previous
</Button>
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>
Next
<ChevronRight />
</Button>
</div>
{pagination && (
<div className="border-t px-4 py-4">
<PaginationFooter pagination={pagination} onPageChange={setPage} />
</div>
)}
</>
@@ -111,7 +111,7 @@ export default function SalesReturnsListPage() {
{r.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{r.glJournalNo ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{r.glJournalNo ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
</TableRow>
))}
@@ -149,14 +149,14 @@ export default function InvoiceTemplatePage() {
<Switch checked={showBankDetails} onCheckedChange={setShowBankDetails} className="mt-1 shrink-0" aria-label="Show bank details" />
<div className="flex flex-col gap-1">
<span className="text-base font-medium text-foreground">Show bank details</span>
<span className="text-sm text-muted-foreground">Prints the company&apos;s bank name, account and SWIFT code on invoices.</span>
<span className="text-sm text-muted-foreground">Prints the company's bank name, account and SWIFT code on invoices.</span>
</div>
</div>
<div className="flex items-start gap-4 py-4">
<Switch checked={showTaxRegNo} onCheckedChange={setShowTaxRegNo} className="mt-1 shrink-0" aria-label="Show tax registration no." />
<div className="flex flex-col gap-1">
<span className="text-base font-medium text-foreground">Show tax registration no.</span>
<span className="text-sm text-muted-foreground">Prints the company&apos;s tax registration number in the invoice header.</span>
<span className="text-sm text-muted-foreground">Prints the company's tax registration number in the invoice header.</span>
</div>
</div>
</div>
@@ -14,6 +14,16 @@ import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/component
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
@@ -13,6 +13,17 @@ import { ManagedUser } from "@/types/users"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { StatCard } from "@/components/ui/stat-card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -101,7 +101,7 @@ export default function AdjustmentsListPage() {
<TableCell className="px-3 py-3.5">
<AdjustmentStatusBadge status={a.status} />
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{a.glJournalNo ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{a.glJournalNo ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(a.createdAt).toLocaleString()}</TableCell>
</TableRow>
))}
@@ -142,7 +142,7 @@ export default function CountDetailPage() {
<TableBody>
{count.lines.map((line) => (
<TableRow key={line.countLineId}>
<TableCell className="px-3 py-3.5">#{line.itemId}</TableCell>
<TableCell className="px-3 py-3.5">{line.itemId}</TableCell>
<TableCell className="px-3 py-3.5">{line.systemQty}</TableCell>
<TableCell className="px-3 py-3.5">
{count.status === "Draft" ? (
@@ -1,7 +1,7 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { ChevronLeft, ChevronRight, ScrollText } from "lucide-react"
import { ScrollText } from "lucide-react"
import { stockApi } from "@/lib/api/stock"
import { itemsApi } from "@/lib/api/items"
@@ -13,15 +13,15 @@ import { PaginationMeta } from "@/types/common"
import { ItemListItem, Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
const PAGE_SIZE = 10
const PAGE_SIZE = 25
export default function StockLedgerPage() {
const { activeBranchId, branchingEnabled } = useAuth()
@@ -197,7 +197,7 @@ export default function StockLedgerPage() {
<TableCell className="px-3 py-3.5">{entry.unitCost.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5">{entry.value.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{entry.runningBalance}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">
<TableCell className="px-3 py-3.5">
{entry.sourceDocType} #{entry.sourceDocId}
</TableCell>
</TableRow>
@@ -206,33 +206,7 @@ export default function StockLedgerPage() {
</TableBody>
</Table>
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
<ChevronLeft />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight />
</Button>
</div>
</div>
)}
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
</>
)}
</div>
@@ -152,7 +152,7 @@ export default function TransferDetailPage() {
<TableBody>
{transfer.lines.map((line) => (
<TableRow key={line.transferLineId}>
<TableCell className="px-3 py-3.5">#{line.itemId}</TableCell>
<TableCell className="px-3 py-3.5">{line.itemId}</TableCell>
<TableCell className="px-3 py-3.5">{line.srcBinId ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{line.destBinId ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
@@ -155,7 +155,7 @@ function ValuationContent() {
<TableBody>
{valuation.layers.map((layer) => (
<TableRow key={layer.layerId}>
<TableCell className="px-3 py-3.5">#{layer.layerId}</TableCell>
<TableCell className="px-3 py-3.5">{layer.layerId}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(layer.receiptDate).toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5">{layer.qtyRemaining}</TableCell>
<TableCell className="px-3 py-3.5">{layer.unitCost.toFixed(2)}</TableCell>
+4 -35
View File
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight, Eye, Pencil, Plus, Search, Trash2, Truck } from "lucide-react"
import { Eye, Pencil, Plus, Search, Trash2, Truck } from "lucide-react"
import { vendorsApi } from "@/lib/api/vendors"
import { errorMessage } from "@/lib/error-map"
@@ -25,6 +25,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -32,7 +33,7 @@ import { toast } from "@/components/ui/toast"
type StatusFilter = EntityStatus | "All"
const PAGE_SIZE = 5
const PAGE_SIZE = 25
export default function VendorsPage() {
const [vendors, setVendors] = useState<Vendor[] | null>(null)
@@ -353,39 +354,7 @@ export default function VendorsPage() {
</TableBody>
</Table>
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight />
</Button>
</div>
</div>
)}
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
</>
)}
</div>
+24 -23
View File
@@ -59,10 +59,11 @@
--card-foreground: oklch(0.15 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.15 0 0);
--primary: oklch(0.463 0.258 262.881);
--primary: oklch(0.586 0.222 17.585);
--primary-foreground: oklch(0.98 0 0);
--secondary: oklch(0.92 0.055 264.901);
--secondary-foreground: oklch(0.463 0.258 262.881);
--secondary: oklch(0.969 0.015 12.422);
--secondary-foreground: oklch(0.514 0.198 16.935);
--muted: oklch(0.93 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.55 0.15 205.5);
@@ -70,12 +71,12 @@
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.925 0.01 0);
--input: oklch(0.945 0.01 0);
--ring: oklch(0.463 0.258 262.881);
--ring: oklch(0.586 0.222 17.585);
--success: oklch(0.582 0.183 142.495);
--warning: oklch(0.787 0.141 72.031);
--error: oklch(0.577 0.245 27.325);
--info: oklch(0.631 0.215 259.262);
--chart-1: oklch(0.463 0.258 262.881);
--chart-1: oklch(0.586 0.222 17.585);
--chart-2: oklch(0.631 0.215 259.262);
--chart-3: oklch(0.582 0.183 142.495);
--chart-4: oklch(0.787 0.141 72.031);
@@ -83,12 +84,12 @@
--radius: 0.5rem;
--sidebar: oklch(0.98 0 0);
--sidebar-foreground: oklch(0.15 0 0);
--sidebar-primary: oklch(0.463 0.258 262.881);
--sidebar-primary: oklch(0.586 0.222 17.585);
--sidebar-primary-foreground: oklch(0.98 0 0);
--sidebar-accent: oklch(0.92 0.055 264.901);
--sidebar-accent-foreground: oklch(0.463 0.258 262.881);
--sidebar-accent: oklch(0.969 0.015 12.422);
--sidebar-accent-foreground: oklch(0.514 0.198 16.935);
--sidebar-border: oklch(0.925 0.01 0);
--sidebar-ring: oklch(0.463 0.258 262.881);
--sidebar-ring: oklch(0.586 0.222 17.585);
}
.dark {
@@ -98,9 +99,9 @@
--card-foreground: oklch(0.95 0 0);
--popover: oklch(0.175 0 0);
--popover-foreground: oklch(0.95 0 0);
--primary: oklch(0.68 0.186 265.215);
--primary: oklch(0.645 0.215 16.439);
--primary-foreground: oklch(0.125 0 0);
--secondary: oklch(0.28 0.04 264.901);
--secondary: oklch(0.28 0.06 16.439);
--secondary-foreground: oklch(0.95 0 0);
--muted: oklch(0.275 0.02 0);
--muted-foreground: oklch(0.708 0 0);
@@ -109,24 +110,24 @@
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(0.25 0.01 0);
--input: oklch(0.2 0.02 0);
--ring: oklch(0.68 0.186 265.215);
--ring: oklch(0.645 0.215 16.439);
--success: oklch(0.65 0.15 142.495);
--warning: oklch(0.85 0.12 72.031);
--error: oklch(0.704 0.191 22.216);
--info: oklch(0.75 0.18 259.262);
--chart-1: oklch(0.68 0.186 265.215);
--chart-1: oklch(0.645 0.215 16.439);
--chart-2: oklch(0.75 0.18 259.262);
--chart-3: oklch(0.65 0.15 142.495);
--chart-4: oklch(0.85 0.12 72.031);
--chart-5: oklch(0.704 0.191 22.216);
--sidebar: oklch(0.125 0 0);
--sidebar-foreground: oklch(0.95 0 0);
--sidebar-primary: oklch(0.68 0.186 265.215);
--sidebar-primary: oklch(0.645 0.215 16.439);
--sidebar-primary-foreground: oklch(0.95 0 0);
--sidebar-accent: oklch(0.28 0.04 264.901);
--sidebar-accent: oklch(0.28 0.06 16.439);
--sidebar-accent-foreground: oklch(0.95 0 0);
--sidebar-border: oklch(0.25 0.01 0);
--sidebar-ring: oklch(0.68 0.186 265.215);
--sidebar-ring: oklch(0.645 0.215 16.439);
}
/* Vibrant — the "System" toggle option. Light content area (background,
@@ -141,10 +142,10 @@
--card-foreground: oklch(0.2 0.02 265);
--popover: oklch(0.995 0.002 265);
--popover-foreground: oklch(0.2 0.02 265);
--primary: oklch(0.55 0.2 275);
--primary: oklch(0.586 0.222 17.585);
--primary-foreground: oklch(0.98 0 0);
--secondary: oklch(0.93 0.02 275);
--secondary-foreground: oklch(0.32 0.15 275);
--secondary: oklch(0.955 0.02 17.585);
--secondary-foreground: oklch(0.514 0.198 16.935);
--muted: oklch(0.94 0.006 265);
--muted-foreground: oklch(0.48 0.02 265);
--accent: oklch(0.55 0.14 210);
@@ -152,24 +153,24 @@
--destructive: oklch(0.58 0.22 25);
--border: oklch(0.88 0.012 265);
--input: oklch(0.92 0.01 265);
--ring: oklch(0.55 0.2 275);
--ring: oklch(0.586 0.222 17.585);
--success: oklch(0.55 0.15 150);
--warning: oklch(0.72 0.15 80);
--error: oklch(0.58 0.22 25);
--info: oklch(0.55 0.14 210);
--chart-1: oklch(0.55 0.2 275);
--chart-1: oklch(0.586 0.222 17.585);
--chart-2: oklch(0.55 0.14 210);
--chart-3: oklch(0.55 0.15 150);
--chart-4: oklch(0.72 0.15 80);
--chart-5: oklch(0.58 0.22 25);
--sidebar: oklch(0.18 0.02 265);
--sidebar-foreground: oklch(0.96 0.005 265);
--sidebar-primary: oklch(0.64 0.19 275);
--sidebar-primary: oklch(0.645 0.215 16.439);
--sidebar-primary-foreground: oklch(0.98 0 0);
--sidebar-accent: oklch(0.28 0.03 265);
--sidebar-accent-foreground: oklch(0.96 0.005 265);
--sidebar-border: oklch(0.26 0.025 265);
--sidebar-ring: oklch(0.64 0.19 275);
--sidebar-ring: oklch(0.645 0.215 16.439);
}
/* Re-points the shared tokens (--card, --foreground, --muted*, --primary...)
@@ -32,7 +32,7 @@ export default function ForgotOtpPage() {
</div>
<h1 className="text-2xl font-bold text-foreground text-center">Verify your email</h1>
</div>
<p className="text-sm text-muted-foreground text-center mb-8">We&apos;ve sent a verification code to your email address. Enter it below to continue.</p>
<p className="text-sm text-muted-foreground text-center mb-8">We've sent a verification code to your email address. Enter it below to continue.</p>
<div className="space-y-6">
<div className="flex justify-center">
@@ -48,7 +48,7 @@ export default function ForgotOtpPage() {
}}
className="text-sm font-medium text-foreground hover:text-foreground/80 transition-colors"
>
Didn&apos;t receive the code? Resend
Didn't receive the code? Resend
</button>
) : (
<p className="text-sm text-muted-foreground">Resend code available in <span className="font-semibold text-foreground">{Math.max(0, secondsLeft)}s</span></p>
@@ -22,7 +22,7 @@ export default function ForgotEmailPage() {
<div className="w-full max-w-2xl rounded-xl bg-card p-8 pl-10 shadow-lg border border-border">
<div className="mb-8 text-center">
<h1 className="text-2xl font-bold text-foreground">Reset your password</h1>
<p className="mt-2 text-sm text-muted-foreground">Enter the email address associated with your account and we&apos;ll send you a one-time code to verify your identity.</p>
<p className="mt-2 text-sm text-muted-foreground">Enter the email address associated with your account and we'll send you a one-time code to verify your identity.</p>
</div>
<div className="space-y-6">
@@ -13,19 +13,6 @@ import {
} from "@/components/ui/dialog"
import { Check, Eye, EyeOff } from "lucide-react"
// Declared at module scope: a component defined inside the page body is a new type on every
// render, so React would remount (and reset) each requirement row on each keystroke.
function PasswordRequirement({ met, label }: { met: boolean; label: string }) {
return (
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center w-5 h-5 rounded-full shrink-0 text-sm font-semibold ${met ? 'bg-success/10 text-success' : 'bg-muted text-muted-foreground'}`}>
{met ? <Check className="size-4" /> : <span>○</span>}
</div>
<span className={`text-sm ${met ? 'text-foreground font-medium' : 'text-muted-foreground'}`}>{label}</span>
</div>
)
}
export default function ForgotResetPage() {
const router = useRouter()
const [password, setPassword] = useState("")
@@ -41,6 +28,15 @@ export default function ForgotResetPage() {
const passwordsMatch = password === confirm && password.length > 0
const allValid = meetsLength && hasUpper && hasNumber && hasSpecial && passwordsMatch
const PasswordRequirement = ({ met, label }: { met: boolean; label: string }) => (
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center w-5 h-5 rounded-full shrink-0 text-sm font-semibold ${met ? 'bg-success/10 text-success' : 'bg-muted text-muted-foreground'}`}>
{met ? <Check className="size-4" /> : <span>○</span>}
</div>
<span className={`text-sm ${met ? 'text-foreground font-medium' : 'text-muted-foreground'}`}>{label}</span>
</div>
)
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-background/95 p-4">
<div className="w-full max-w-xl rounded-xl bg-card p-8 shadow-lg border border-border">
@@ -101,7 +97,7 @@ export default function ForgotResetPage() {
</div>
{!passwordsMatch && confirm.length > 0 && (
<p className="text-sm text-error font-medium">Passwords don&apos;t match.</p>
<p className="text-sm text-error font-medium">Passwords don't match.</p>
)}
<div className="flex gap-3">
+5 -4
View File
@@ -85,7 +85,8 @@ function LoginForm() {
</div>
<div className="text-center">
<h1 className="text-3xl font-bold text-foreground">Sign in to your account</h1>
<p className="text-sm font-semibold tracking-wide text-primary uppercase">Sign in</p>
<h1 className="mt-1 text-3xl font-bold text-foreground">Welcome back</h1>
<p className="mt-3 text-base text-muted-foreground">Access your ERP dashboard and manage your business</p>
{sessionNotice ? (
<div className="mt-4 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900">
@@ -146,7 +147,7 @@ function LoginForm() {
</div>
<FieldError errors={[form.formState.errors.password]} />
<div className="flex justify-end mt-1">
<Link href="/login/forgot" className="text-sm font-medium text-foreground hover:text-foreground/70 transition-colors">
<Link href="/login/forgot" className="text-sm font-medium text-primary hover:text-primary/80 transition-colors">
Forgot password?
</Link>
</div>
@@ -161,7 +162,7 @@ function LoginForm() {
<Button
type="submit"
className="w-1/2 mx-auto mt-8 h-12 text-sm font-medium rounded-full bg-foreground text-background hover:bg-foreground/90"
className="w-full mt-8 h-12 text-base font-semibold rounded-full"
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? "Signing in…" : "Sign in"}
@@ -176,7 +177,7 @@ function LoginForm() {
<div className="mt-12 text-center">
<p className="text-sm text-muted-foreground">
Don&apos;t have an account? <Link href="#" className="font-semibold text-foreground hover:text-foreground/80 transition-colors">Create an account</Link>
Don&apos;t have an account? <Link href="#" className="font-semibold text-primary hover:text-primary/80 transition-colors">Create an account</Link>
</p>
</div>
</main>
@@ -42,7 +42,6 @@ import {
PieChart,
Receipt,
ReceiptText,
Ruler,
Scale,
ScrollText,
Settings,
@@ -77,7 +76,15 @@ const navItems: {
expandOnly?: boolean
icon: LucideIcon
chevron?: boolean
children?: { title: string; code: string; href: string; icon: LucideIcon }[]
children?: {
title: string
code: string
href: string
icon: LucideIcon
/** Extra codes that also grant visibility for this row (e.g. a merged Brand/UOM
* tab page stays visible to a role that only has the UOM permission). */
altCodes?: string[]
}[]
}[] = [
{ title: "Dashboard", code: "dashboard", href: "/dashboard", icon: LayoutGrid },
{
@@ -89,8 +96,7 @@ const navItems: {
children: [
{ title: "Item", code: "products.item", href: "/dashboard/products", icon: Boxes },
{ title: "Category", code: "products.category", href: "/dashboard/products/categories", icon: ListTree },
{ title: "Brand", code: "products.brand", href: "/dashboard/products/brands", icon: Tag },
{ title: "UOM", code: "products.uom", href: "/dashboard/products/uoms", icon: Ruler },
{ title: "Brand/UOM", code: "products.brand", href: "/dashboard/products/brands", icon: Tag, altCodes: ["products.uom"] },
],
},
{
@@ -444,10 +450,17 @@ export function AppSidebar() {
const visibleItems = loading
? []
: navItems
.filter((item) => bypassCodes.has(item.code) || navCodes.includes(item.code) || item.children?.some((c) => navCodes.includes(c.code)))
.filter(
(item) =>
bypassCodes.has(item.code) ||
navCodes.includes(item.code) ||
item.children?.some((c) => navCodes.includes(c.code) || c.altCodes?.some((ac) => navCodes.includes(ac)))
)
.map((item) => ({
...item,
children: bypassCodes.has(item.code) ? item.children : item.children?.filter((c) => navCodes.includes(c.code)),
children: bypassCodes.has(item.code)
? item.children
: item.children?.filter((c) => navCodes.includes(c.code) || c.altCodes?.some((ac) => navCodes.includes(ac))),
}))
// Close mobile menu on route change
@@ -112,10 +112,9 @@ function titleFromPath(pathname: string) {
if (pathname === "/dashboard/products") return "Items"
if (pathname === "/dashboard/products/new") return "New Item"
if (pathname === "/dashboard/products/uoms") return "Units of Measure"
if (pathname === "/dashboard/products/categories") return "Categories"
if (pathname.startsWith("/dashboard/products/categories/")) return "Subcategories"
if (pathname === "/dashboard/products/brands") return "Brands"
if (pathname === "/dashboard/products/brands") return "Brand/UOM"
if (pathname === "/dashboard/products/item-types") return "Item Types"
if (pathname === "/dashboard/products/settings") return "Product Configuration"
if (pathname === "/dashboard/settings/notification-config") return "Notification Config"
@@ -48,7 +48,7 @@ export function ThemeToggle({ className }: { className?: string }) {
"flex size-8 shrink-0 items-center justify-center rounded-full transition-colors",
isActive
? isVibrant
? "bg-linear-to-br from-indigo-500 to-violet-600 text-white shadow-sm"
? "bg-linear-to-br from-sidebar-primary to-primary text-primary-foreground shadow-sm"
: "bg-card text-primary shadow-sm ring-1 ring-foreground/10"
: "text-muted-foreground hover:text-foreground"
)}
+2 -2
View File
@@ -8,9 +8,9 @@ const buttonVariants = cva(
{
variants: {
variant: {
default: "bg-black text-white hover:bg-black/90 active:bg-black",
default: "bg-primary text-primary-foreground hover:bg-primary/90 active:bg-primary",
outline:
"border-black bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/20 dark:hover:bg-input/40",
"border-primary bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/20 dark:hover:bg-input/40",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_8%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
@@ -78,11 +78,11 @@ export function Combobox<Value>({
<ComboboxPrimitive.Item
key={String(item.value)}
value={item.value}
className="relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1.5 pr-8 pl-2.5 text-sm outline-hidden select-none data-highlighted:bg-violet-100 data-highlighted:text-violet-900 dark:data-highlighted:bg-violet-500/25 dark:data-highlighted:text-violet-200"
className="relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1.5 pr-8 pl-2.5 text-sm outline-hidden select-none data-highlighted:bg-secondary data-highlighted:text-secondary-foreground"
>
{item.label}
<ComboboxPrimitive.ItemIndicator className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<CheckIcon className="size-4 text-violet-600 dark:text-violet-300" />
<CheckIcon className="size-4 text-primary" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
)}
@@ -88,7 +88,7 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-secondary focus:text-secondary-foreground not-data-[variant=destructive]:focus:**:text-secondary-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
@@ -113,7 +113,7 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-secondary focus:text-secondary-foreground not-data-[variant=destructive]:focus:**:text-secondary-foreground data-inset:pl-7 data-popup-open:bg-secondary data-popup-open:text-secondary-foreground data-open:bg-secondary data-open:text-secondary-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
@@ -159,7 +159,7 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-secondary focus:text-secondary-foreground focus:**:text-secondary-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
@@ -201,7 +201,7 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-secondary focus:text-secondary-foreground focus:**:text-secondary-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
@@ -0,0 +1,89 @@
"use client"
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination"
import { PaginationMeta } from "@/types/common"
interface PaginationFooterProps {
pagination: PaginationMeta
onPageChange: (page: number) => void
}
/** First, last, current ±1, and ellipses for the gaps — keeps the row from overflowing
* on tables with many pages while still surfacing the ends. */
function pageItems(current: number, total: number): (number | "ellipsis")[] {
const items: (number | "ellipsis")[] = [1]
if (current - 1 > 2) items.push("ellipsis")
for (let p = Math.max(2, current - 1); p <= Math.min(total - 1, current + 1); p++) items.push(p)
if (current + 1 < total - 1) items.push("ellipsis")
if (total > 1) items.push(total)
return items
}
/** Server-paginated table footer: "X – Y of Z" plus numbered page links, built on the
* shadcn-style pagination primitives. Use for any list page driven by a `PaginationMeta`
* from the API (as opposed to `DataTable`'s own client-side pagination). */
export function PaginationFooter({ pagination, onPageChange }: PaginationFooterProps) {
const { page, pageSize, totalItems, totalPages } = pagination
if (totalPages <= 1) return null
const from = (page - 1) * pageSize + 1
const to = Math.min(page * pageSize, totalItems)
function go(e: React.MouseEvent, target: number) {
e.preventDefault()
if (target >= 1 && target <= totalPages) onPageChange(target)
}
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
{from} – {to} of {totalItems}
</p>
<Pagination className="mx-0 w-auto justify-end">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
text=""
onClick={(e) => go(e, page - 1)}
aria-disabled={page === 1}
className={page === 1 ? "pointer-events-none opacity-50" : undefined}
/>
</PaginationItem>
{pageItems(page, totalPages).map((item, i) =>
item === "ellipsis" ? (
<PaginationItem key={`ellipsis-${i}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={item}>
<PaginationLink href="#" isActive={item === page} onClick={(e) => go(e, item)}>
{item}
</PaginationLink>
</PaginationItem>
)
)}
<PaginationItem>
<PaginationNext
href="#"
text=""
onClick={(e) => go(e, page + 1)}
aria-disabled={page === totalPages}
className={page === totalPages ? "pointer-events-none opacity-50" : undefined}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)
}
@@ -0,0 +1,36 @@
"use client"
import { Radio as RadioPrimitive } from "@base-ui/react/radio"
import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"
import { cn } from "@/lib/utils"
function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props<unknown>) {
return (
<RadioGroupPrimitive
data-slot="radio-group"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props<unknown>) {
return (
<RadioPrimitive.Root
data-slot="radio-group-item"
className={cn(
"relative flex size-4 shrink-0 items-center justify-center rounded-full border border-input transition-colors outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:bg-input/30 data-checked:border-primary",
className
)}
{...props}
>
<RadioPrimitive.Indicator
data-slot="radio-group-item-indicator"
className="flex items-center justify-center after:size-2 after:rounded-full after:bg-primary"
/>
</RadioPrimitive.Root>
)
}
export { RadioGroup, RadioGroupItem }
+2 -2
View File
@@ -150,7 +150,7 @@ function SelectItem({
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-violet-100 focus:text-violet-900 dark:focus:bg-violet-500/25 dark:focus:text-violet-200 not-data-[variant=destructive]:focus:**:text-violet-900 dark:not-data-[variant=destructive]:focus:**:text-violet-200 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-secondary focus:text-secondary-foreground not-data-[variant=destructive]:focus:**:text-secondary-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
@@ -163,7 +163,7 @@ function SelectItem({
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none text-violet-600 dark:text-violet-300" />
<CheckIcon className="pointer-events-none text-primary" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
@@ -76,10 +76,11 @@ export function StatCard({
)}
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-muted-foreground">{label}</p>
<p className="text-sm font-medium text-muted-foreground">{label}</p>
{Icon && (
<div className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="size-4 text-primary" />
<Icon className="size-3.5 text-primary" />
</div>
)}
</div>
@@ -90,7 +91,7 @@ export function StatCard({
</div>
{hasDelta && (
<div className="flex items-center gap-1 text-sm">
<div className="flex items-center gap-1 text-xs">
<span
className={cn(
"font-semibold",
-8
View File
@@ -5,14 +5,6 @@ import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
{
rules: {
// Temporarily a warning: ~80 pre-existing violations from the React Compiler rules that
// eslint-config-next 16 enabled. Everything else stays an error so CI can gate on lint
// now; flip this back to "error" once the backlog is cleared (docs/40-DEPLOYMENT.md §7).
"react-hooks/set-state-in-effect": "warn",
},
},
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
+7 -1
View File
@@ -1,13 +1,14 @@
// One typed client method per UOM endpoint (docs/11-BACKEND-PHASE1.md §2.2, FR-MD-02).
// `list` also backs the GRN/PO line UOM picker.
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import { EntityStatus, PagedResponse } from "@/types/common"
import { CreateUomRequest, Uom } from "@/types/master-data"
export interface ListUomsParams {
page?: number
pageSize?: number
q?: string
status?: EntityStatus
sort?: string
}
@@ -19,4 +20,9 @@ export const uomsApi = {
create(request: CreateUomRequest): Promise<Uom> {
return apiRequest<Uom>("/uoms", { method: "POST", body: request })
},
/** Deactivate/reactivate. There is no DELETE — masters are never hard-deleted. */
updateStatus(uomId: number, status: EntityStatus): Promise<void> {
return apiRequest<void>(`/uoms/${uomId}/status`, { method: "PATCH", body: { status } })
},
}
+1 -8
View File
@@ -8,16 +8,9 @@ import path from "path"
//
// Defaults to the backend's HTTP port: the HTTPS port uses a self-signed dev cert that
// this proxy would reject.
//
// Rewrites are resolved at `next build`, so this is effectively a local-dev setting. In the
// deployed environments Apache sends /api/ straight to ERPCore before Next ever sees it, which
// keeps one built image valid for both test and release (docs/40-DEPLOYMENT.md).
const BACKEND_ORIGIN = process.env.BACKEND_ORIGIN ?? "http://localhost:5224"
const BACKEND_ORIGIN = process.env.BACKEND_ORIGIN ?? "http://localhost:6701"
const nextConfig: NextConfig = {
// Self-contained server bundle (.next/standalone) for the runtime-only Docker image
// (Dockerfile in this folder). Has no effect on `next dev`.
output: "standalone",
turbopack: {
root: path.join(__dirname),
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 829 KiB

After

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 829 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

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