From d015cb0dda05436da14cdfe9ac2231a5c21e7fa5 Mon Sep 17 00:00:00 2001 From: Wira Irawan Date: Thu, 21 May 2026 07:49:35 +0700 Subject: [PATCH] Add public auth pages, global search, and fixed units --- deploy/scripts/build-linux-release.sh | 20 +- docs/codex-handoff-2026-05-21.md | 176 ++++++++++++++++++ middleware.ts | 10 +- prisma/schema.prisma | 3 +- src/app/api/v1/global-search/route.ts | 94 ++++++++++ src/app/api/v1/public/contact-admin/route.ts | 70 +++++++ src/app/api/v1/units/[id]/route.ts | 103 +--------- src/app/api/v1/units/route.ts | 58 +----- src/app/contact-admin/page.tsx | 13 ++ src/app/help-public/page.tsx | 58 ++++++ src/app/privacy-policy/page.tsx | 56 ++++++ src/app/terms-and-conditions/page.tsx | 56 ++++++ src/components/auth/public-info-page.tsx | 64 +++++++ src/components/layout/topbar-search.tsx | 162 ++++++++++++++++ src/components/layout/topbar.tsx | 18 +- src/components/master-data/units-client.tsx | 131 ++----------- src/config/navigation.ts | 9 - .../auth/components/contact-admin-client.tsx | 172 +++++++++++++++++ src/features/auth/components/login-client.tsx | 21 ++- src/features/help/components/help-client.tsx | 4 +- src/features/lots/components/lots-client.tsx | 8 +- src/features/units/lib/fixed-units.ts | 52 ++++++ src/lib/dashboard.ts | 84 +++++++-- src/lib/public-captcha.ts | 56 ++++++ 24 files changed, 1183 insertions(+), 315 deletions(-) create mode 100644 docs/codex-handoff-2026-05-21.md create mode 100644 src/app/api/v1/global-search/route.ts create mode 100644 src/app/api/v1/public/contact-admin/route.ts create mode 100644 src/app/contact-admin/page.tsx create mode 100644 src/app/help-public/page.tsx create mode 100644 src/app/privacy-policy/page.tsx create mode 100644 src/app/terms-and-conditions/page.tsx create mode 100644 src/components/auth/public-info-page.tsx create mode 100644 src/components/layout/topbar-search.tsx create mode 100644 src/features/auth/components/contact-admin-client.tsx create mode 100644 src/features/units/lib/fixed-units.ts create mode 100644 src/lib/public-captcha.ts diff --git a/deploy/scripts/build-linux-release.sh b/deploy/scripts/build-linux-release.sh index 30b42d6..94ce0de 100755 --- a/deploy/scripts/build-linux-release.sh +++ b/deploy/scripts/build-linux-release.sh @@ -2,20 +2,29 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -IMAGE="${DOCKER_IMAGE:-node:20}" +IMAGE="${DOCKER_IMAGE:-node:20-bookworm-slim}" ARTIFACT_NAME="${ARTIFACT_NAME:-abelbirdnest-release.tar.gz}" +NODE_MEMORY_MB="${NODE_MEMORY_MB:-4096}" cd "$ROOT_DIR" docker run --rm \ - -v "$ROOT_DIR":/app \ - -w /app \ + -v "$ROOT_DIR":/src:ro \ + -v "$ROOT_DIR":/out \ + -w / \ "$IMAGE" \ bash -lc ' set -euo pipefail + apt-get update >/dev/null + apt-get install -y --no-install-recommends openssl >/dev/null + rm -rf /work + mkdir -p /work + cp -R /src/. /work/ + chmod -R u+w /work + cd /work npm ci npx prisma generate - npm run build + NODE_OPTIONS="--max-old-space-size='"$NODE_MEMORY_MB"'" npm run build rm -rf .deploy-release mkdir -p .deploy-release cp -R .next/standalone/. .deploy-release/ @@ -23,8 +32,7 @@ docker run --rm \ cp -R .next/static .deploy-release/.next/static cp -R public .deploy-release/public cp -R prisma .deploy-release/prisma - [ -f .env.production ] && cp .env.production .deploy-release/.env.production || true - tar -czf '"$ARTIFACT_NAME"' -C .deploy-release . + tar -czf /out/'"$ARTIFACT_NAME"' -C .deploy-release . ' echo "Artifact created at $ROOT_DIR/$ARTIFACT_NAME" diff --git a/docs/codex-handoff-2026-05-21.md b/docs/codex-handoff-2026-05-21.md new file mode 100644 index 0000000..98aaa86 --- /dev/null +++ b/docs/codex-handoff-2026-05-21.md @@ -0,0 +1,176 @@ +# Codex Handoff - 2026-05-21 + +Dokumen ini menyimpan konteks kerja terbaru setelah rangkaian patch mobile, login publik, global search, locking satuan, dan workflow deploy artifact Linux. + +## Status Umum + +- App aktif dikembangkan di `Next.js + Prisma + PostgreSQL`. +- Branch aktif: + `main` +- Repo remote utama: + `https://git.iptek.co/wirabasalamah/AbelBirdNest-Stock.git` +- Server target production saat ini: + `/var/www/abelbirdnest-web/AbelBirdNest-Stock` + +## Ringkasan Perubahan Terbaru + +### 1. Flow Mobile Purchase + +- Menu `Receipt` di mobile sudah dikeluarkan dari bootstrap mobile. +- Submit purchase sekarang langsung: + - membuat `receipt` + - membuat `receipt_lines` + - membuat `lot` + - membuat alokasi realization terkait +- Endpoint utama: + - [src/app/api/v1/purchases/[id]/submit/route.ts](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/src/app/api/v1/purchases/[id]/submit/route.ts) + - [src/app/api/v1/mobile/bootstrap/route.ts](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/src/app/api/v1/mobile/bootstrap/route.ts) +- Dokumen mobile sudah disesuaikan: + - [docs/mobile-api-blueprint.md](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/docs/mobile-api-blueprint.md) + +### 2. Formatting Qty dan Currency + +- Formatting `kg` sekarang locale-aware. +- Angka hasil konversi gram ke kilogram tampil dengan format Indonesia yang benar, misalnya `10,5 kg`. +- Currency di beberapa modul tidak lagi hardcoded dan mengikuti `currency_code` sistem. +- File sentral: + - [src/lib/formatters.ts](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/src/lib/formatters.ts) + +### 3. Dashboard Qty Fix + +- Dashboard sebelumnya salah menampilkan angka seperti `94.290 kg` untuk data gram yang seharusnya dibaca sebagai `94,29 kg`. +- Perbaikan dilakukan dengan konversi qty berbasis `unit.code` sebelum agregasi. +- File utama: + - [src/lib/dashboard.ts](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/src/lib/dashboard.ts) + +### 4. Login Publik, Contact Admin, dan Halaman Legal + +- Login sekarang punya link publik yang benar-benar aktif: + - `Hubungi Admin` + - `Bantuan` + - `Kebijakan Privasi` + - `Syarat & Ketentuan` +- `Hubungi Admin` sekarang menuju halaman publik dengan form kirim email. +- Form ini memakai: + - CAPTCHA aritmatika server-side + - honeypot anti-spam +- Halaman publik legal/help tersedia tanpa login: + - `/contact-admin` + - `/help-public` + - `/privacy-policy` + - `/terms-and-conditions` +- Semua halaman publik ini sudah dua bahasa `ID/EN`. +- Email admin tidak lagi ditampilkan langsung di halaman publik; user diarahkan memakai form kontak admin. +- File utama: + - [src/features/auth/components/login-client.tsx](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/src/features/auth/components/login-client.tsx) + - [src/features/auth/components/contact-admin-client.tsx](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/src/features/auth/components/contact-admin-client.tsx) + - [src/app/api/v1/public/contact-admin/route.ts](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/src/app/api/v1/public/contact-admin/route.ts) + - [src/lib/public-captcha.ts](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/src/lib/public-captcha.ts) + - [middleware.ts](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/middleware.ts) + +### 5. Global Search Header + +- Search di topbar sebelumnya hanya placeholder. +- Sekarang search desktop: + - full width sampai mendekati switch bahasa + - real-time + - menampilkan dropdown hasil + - bisa klik hasil + - bisa Enter untuk fallback ke halaman lot +- Search lintas: + - `lot` + - `purchase` + - supplier/agent terkait +- Endpoint baru: + - [src/app/api/v1/global-search/route.ts](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/src/app/api/v1/global-search/route.ts) +- Komponen utama: + - [src/components/layout/topbar-search.tsx](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/src/components/layout/topbar-search.tsx) + - [src/components/layout/topbar.tsx](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/src/components/layout/topbar.tsx) +- Halaman lot sudah membaca query URL `?search=` agar fallback search juga benar-benar terpakai. + +### 6. Master Satuan Dikunci + +- Master `Satuan` sekarang dibuat fixed dan tidak lagi editable. +- Hanya dua satuan yang diizinkan: + - `gr` + - `kg` +- `GET /api/v1/units` akan memastikan dua unit itu selalu ada. +- `POST`, `PUT`, dan `DELETE` unit sekarang ditolak `403`. +- Submenu `Satuan` disembunyikan dari sidebar. +- Kalau halaman `/units` dibuka langsung, tampilannya read-only. +- File utama: + - [src/features/units/lib/fixed-units.ts](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/src/features/units/lib/fixed-units.ts) + - [src/app/api/v1/units/route.ts](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/src/app/api/v1/units/route.ts) + - [src/app/api/v1/units/[id]/route.ts](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/src/app/api/v1/units/[id]/route.ts) + - [src/components/master-data/units-client.tsx](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/src/components/master-data/units-client.tsx) + - [src/config/navigation.ts](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/src/config/navigation.ts) + +## Prisma dan Build Cross-Platform + +- Local Mac sempat rusak karena build Linux menimpa hasil `prisma generate`. +- Generator Prisma sekarang mencakup: + - `native` + - `darwin-arm64` + - `debian-openssl-3.0.x` +- File: + - [prisma/schema.prisma](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/prisma/schema.prisma) + +## Workflow Deploy Artifact Linux + +- Build di server sempat berat dan tidak stabil. +- Sekarang ada workflow artifact standalone Linux. +- Script: + - [deploy/scripts/build-linux-release.sh](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/deploy/scripts/build-linux-release.sh) +- Karakteristik workflow ini: + - build di Docker `linux/amd64` + - install `openssl` di container + - pakai `NODE_OPTIONS=--max-old-space-size=4096` + - copy source ke `/work` agar tidak menimpa `node_modules` lokal Mac + - artifact final: + `abelbirdnest-release.tar.gz` +- Artifact final tidak membawa `.env.production` lokal. + +## Catatan Deploy Server Saat Ini + +- Path app di server: + `/var/www/abelbirdnest-web/AbelBirdNest-Stock` +- Struktur release yang dipakai sekarang diasumsikan: + - upload artifact ke root repo server + - extract ke `releases/` + - symlink `current` diarahkan ke release aktif +- `systemd` yang benar untuk mode artifact: + - `WorkingDirectory=/var/www/abelbirdnest-web/AbelBirdNest-Stock/current` + - `EnvironmentFile=/var/www/abelbirdnest-web/AbelBirdNest-Stock/.env.production` + - `ExecStart=/usr/bin/node server.js` + +## Verifikasi yang Sudah Dilakukan + +- `npx tsc --noEmit` lolos setelah patch terbaru. +- Global search endpoint mengembalikan hasil yang benar untuk sample data lokal. +- `GET /api/v1/units` sekarang hanya mengembalikan `gr` dan `kg`. +- `POST /api/v1/units` sekarang ditolak dengan pesan lock master. + +## Risiko / Catatan yang Masih Relevan + +- Build standalone lokal tetap menampilkan warning Prisma saat static generation jika DB lokal tidak aktif; warning ini tidak menggagalkan artifact. +- Production deploy sekarang punya dua mode historis: + - mode lama `repo root + npm run start` + - mode baru `artifact standalone + node server.js` + Jangan campur keduanya pada instruksi deploy. +- Beberapa endpoint `mobile/receipts/**` masih ada untuk kompatibilitas lama, walaupun flow mobile baru tidak lagi menampilkan menu receipt. + +## Langkah Lanjutan Paling Masuk Akal + +1. Deploy artifact terbaru ke server production. +2. Verifikasi: + - search header aktif + - halaman publik login aktif + - `Satuan` tidak muncul di menu + - `GET /api/v1/units` hanya `gr` dan `kg` +3. Jika perlu, lanjut bersihkan endpoint legacy yang sudah tidak dipakai lagi, terutama sekitar receipt mobile lama. + +## Catatan Penutup + +- Handoff sebelumnya: + - [docs/codex-handoff-2026-05-19.md](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/docs/codex-handoff-2026-05-19.md) +- Handoff `2026-05-21` ini adalah snapshot terbaru dan seharusnya dipakai sebagai konteks utama lanjutan. diff --git a/middleware.ts b/middleware.ts index 2c97fd8..6803b8e 100644 --- a/middleware.ts +++ b/middleware.ts @@ -3,7 +3,15 @@ import type { NextRequest } from "next/server"; import { AUTH_COOKIE_NAME } from "@/lib/auth"; -const publicPaths = ["/login", "/reset-password", "/verify-email"]; +const publicPaths = [ + "/login", + "/reset-password", + "/verify-email", + "/contact-admin", + "/help-public", + "/privacy-policy", + "/terms-and-conditions" +]; export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7741ee7..f4ee44a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,5 +1,6 @@ generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" + binaryTargets = ["native", "darwin-arm64", "debian-openssl-3.0.x"] } datasource db { diff --git a/src/app/api/v1/global-search/route.ts b/src/app/api/v1/global-search/route.ts new file mode 100644 index 0000000..51e2f2c --- /dev/null +++ b/src/app/api/v1/global-search/route.ts @@ -0,0 +1,94 @@ +import { NextResponse } from "next/server"; + +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +type SearchItem = { + id: string; + type: "LOT" | "PURCHASE"; + title: string; + subtitle: string; + href: string; +}; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const { searchParams } = new URL(request.url); + const query = searchParams.get("q")?.trim() ?? ""; + + if (query.length < 2) { + return NextResponse.json({ data: [] satisfies SearchItem[] }); + } + + const [lots, purchases] = await Promise.all([ + prisma.inventoryLot.findMany({ + where: { + OR: [ + { lotCode: { contains: query, mode: "insensitive" } }, + { purchase: { agent: { name: { contains: query, mode: "insensitive" } } } }, + { grade: { name: { contains: query, mode: "insensitive" } } }, + { warehouse: { name: { contains: query, mode: "insensitive" } } } + ] + }, + include: { + purchase: { + select: { + agent: { + select: { + name: true + } + } + } + }, + grade: { + select: { + name: true + } + } + }, + orderBy: [{ createdAt: "desc" }], + take: 5 + }), + prisma.purchase.findMany({ + where: { + purchaseType: "REGULAR", + OR: [ + { purchaseNo: { contains: query, mode: "insensitive" } }, + { agent: { name: { contains: query, mode: "insensitive" } } } + ] + }, + include: { + agent: { + select: { + name: true + } + } + }, + orderBy: [{ createdAt: "desc" }], + take: 5 + }) + ]); + + const data: SearchItem[] = [ + ...lots.map((lot) => ({ + id: lot.id.toString(), + type: "LOT" as const, + title: lot.lotCode, + subtitle: [lot.purchase?.agent?.name ?? "Pembelian bebas", lot.grade?.name ?? "-", lot.warehouseId ? "Lot persediaan" : null] + .filter(Boolean) + .join(" · "), + href: `/lots/${lot.id.toString()}` + })), + ...purchases.map((purchase) => ({ + id: purchase.id.toString(), + type: "PURCHASE" as const, + title: purchase.purchaseNo, + subtitle: [purchase.agent?.name ?? "Pembelian bebas", purchase.status].join(" · "), + href: `/purchases?id=${purchase.id.toString()}` + })) + ]; + + return NextResponse.json({ data }); +} diff --git a/src/app/api/v1/public/contact-admin/route.ts b/src/app/api/v1/public/contact-admin/route.ts new file mode 100644 index 0000000..3b37359 --- /dev/null +++ b/src/app/api/v1/public/contact-admin/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { sendMail } from "@/lib/mailer"; +import { verifyPublicCaptcha } from "@/lib/public-captcha"; + +const contactAdminSchema = z.object({ + name: z.string().trim().min(2, "Nama wajib diisi"), + email: z.string().trim().email("Email tidak valid"), + message: z.string().trim().min(10, "Pesan minimal 10 karakter"), + captcha_token: z.string().trim().min(1, "CAPTCHA token tidak valid"), + captcha_answer: z.coerce.number(), + website: z.string().optional() +}); + +export async function POST(request: Request) { + try { + const parsed = contactAdminSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { + message: "Validasi gagal", + errors: parsed.error.flatten().fieldErrors + }, + { status: 422 } + ); + } + + if (parsed.data.website && parsed.data.website.trim() !== "") { + return NextResponse.json({ message: "Pesan berhasil dikirim." }); + } + + const captchaOk = verifyPublicCaptcha( + parsed.data.captcha_token, + parsed.data.captcha_answer + ); + + if (!captchaOk) { + return NextResponse.json( + { + message: "Verifikasi CAPTCHA gagal. Muat ulang halaman dan coba lagi." + }, + { status: 400 } + ); + } + + await sendMail({ + to: "wirabasalamah@gmail.com", + subject: `[AbelBirdnest] Permintaan bantuan login dari ${parsed.data.name}`, + text: `Nama: ${parsed.data.name}\nEmail: ${parsed.data.email}\n\nPesan:\n${parsed.data.message}`, + html: ` +

Nama: ${parsed.data.name}

+

Email: ${parsed.data.email}

+

Pesan:

+

${parsed.data.message.replace(/\n/g, "
")}

+ ` + }); + + return NextResponse.json({ + message: "Pesan berhasil dikirim ke admin." + }); + } catch (error) { + return NextResponse.json( + { + message: error instanceof Error ? error.message : "Gagal mengirim pesan ke admin" + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/v1/units/[id]/route.ts b/src/app/api/v1/units/[id]/route.ts index 302fd2d..0adcd99 100644 --- a/src/app/api/v1/units/[id]/route.ts +++ b/src/app/api/v1/units/[id]/route.ts @@ -1,12 +1,8 @@ -import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; import { NextResponse } from "next/server"; +import { ensureFixedUnits, getFixedUnitLockMessage } from "@/features/units/lib/fixed-units"; import { serializeUnit } from "@/features/units/lib/serialize-unit"; -import { unitInputSchema } from "@/features/units/schemas/unit.schema"; -import { createAuditTrailSafe } from "@/lib/audit-trail"; -import { buildAuditChangeMetadata } from "@/lib/audit-trail-diff"; import { requireApiAccess } from "@/lib/authorization"; -import { resolveMasterCode } from "@/lib/master-code"; import { prisma } from "@/lib/prisma"; type RouteContext = { params: Promise<{ id: string }> }; @@ -15,6 +11,7 @@ const parseId = (id: string) => { try { return BigInt(id); } catch { return null export async function GET(request: Request, context: RouteContext) { const auth = requireApiAccess(request); if (!auth.ok) return auth.response; + await ensureFixedUnits(); const parsedId = parseId((await context.params).id); if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); @@ -26,101 +23,13 @@ export async function GET(request: Request, context: RouteContext) { export async function PUT(request: Request, context: RouteContext) { const auth = requireApiAccess(request); if (!auth.ok) return auth.response; - - const parsedId = parseId((await context.params).id); - if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); - const parsed = unitInputSchema.safeParse(await request.json()); - if (!parsed.success) { - return NextResponse.json( - { message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors }, - { status: 400 } - ); - } - try { - const existing = await prisma.unit.findUnique({ where: { id: parsedId } }); - if (!existing) return NextResponse.json({ message: "Unit not found" }, { status: 404 }); - const resolvedCode = await resolveMasterCode({ - role: auth.user.role, - prefix: "UNT", - requestedCode: parsed.data.code, - existingCode: existing.code, - countExisting: () => - prisma.unit.count({ where: { code: { startsWith: "UNT" } } }), - exists: async (code) => - (await prisma.unit.count({ where: { code, id: { not: parsedId } } })) > 0 - }); - if (!resolvedCode.ok) { - return NextResponse.json( - { message: "Validasi gagal", errors: { code: [resolvedCode.message] } }, - { status: 400 } - ); - } - const unit = await prisma.unit.update({ - where: { id: parsedId }, - data: { - code: resolvedCode.code, - name: parsed.data.name - } - }); - await createAuditTrailSafe({ - userId: auth.user.id, - action: "UNIT_UPDATED", - entityType: "UNIT", - entityId: unit.id, - method: request.method, - pathname: new URL(request.url).pathname, - statusCode: 200, - summary: `Unit ${unit.code} diubah`, - metadata: buildAuditChangeMetadata( - { - code: existing.code, - name: existing.name - }, - { - code: unit.code, - name: unit.name - } - ) - }); - return NextResponse.json({ data: serializeUnit(unit) }); - } catch (error) { - if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") { - return NextResponse.json({ message: "Unit not found" }, { status: 404 }); - } - if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") { - return NextResponse.json( - { message: "Validasi gagal", errors: { code: ["Kode unit sudah dipakai"] } }, - { status: 409 } - ); - } - throw error; - } + void context; + return NextResponse.json({ message: getFixedUnitLockMessage() }, { status: 403 }); } export async function DELETE(request: Request, context: RouteContext) { const auth = requireApiAccess(request); if (!auth.ok) return auth.response; - - const parsedId = parseId((await context.params).id); - if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); - try { - const existing = await prisma.unit.findUnique({ where: { id: parsedId } }); - await prisma.unit.delete({ where: { id: parsedId } }); - await createAuditTrailSafe({ - userId: auth.user.id, - action: "UNIT_DELETED", - entityType: "UNIT", - entityId: parsedId, - method: request.method, - pathname: new URL(request.url).pathname, - statusCode: 200, - summary: `Unit ${existing?.code ?? parsedId.toString()} dihapus` - }); - return NextResponse.json({ success: true }); - } catch (error) { - if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") { - return NextResponse.json({ message: "Unit not found" }, { status: 404 }); - } - throw error; - } + void context; + return NextResponse.json({ message: getFixedUnitLockMessage() }, { status: 403 }); } diff --git a/src/app/api/v1/units/route.ts b/src/app/api/v1/units/route.ts index 610d46a..fa6f2d4 100644 --- a/src/app/api/v1/units/route.ts +++ b/src/app/api/v1/units/route.ts @@ -1,70 +1,18 @@ -import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; import { NextResponse } from "next/server"; import { serializeUnit } from "@/features/units/lib/serialize-unit"; -import { unitInputSchema } from "@/features/units/schemas/unit.schema"; -import { createAuditTrailSafe } from "@/lib/audit-trail"; -import { resolveMasterCode } from "@/lib/master-code"; -import { prisma } from "@/lib/prisma"; +import { ensureFixedUnits, getFixedUnitLockMessage } from "@/features/units/lib/fixed-units"; import { requireApiAccess } from "@/lib/authorization"; export async function GET(request: Request) { const auth = requireApiAccess(request); if (!auth.ok) return auth.response; - const data = await prisma.unit.findMany({ orderBy: [{ createdAt: "desc" }] }); + const data = await ensureFixedUnits(); return NextResponse.json({ data: data.map(serializeUnit) }); } export async function POST(request: Request) { const auth = requireApiAccess(request); if (!auth.ok) return auth.response; - const parsed = unitInputSchema.safeParse(await request.json()); - if (!parsed.success) { - return NextResponse.json( - { message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors }, - { status: 400 } - ); - } - try { - const resolvedCode = await resolveMasterCode({ - role: auth.user.role, - prefix: "UNT", - requestedCode: parsed.data.code, - countExisting: () => - prisma.unit.count({ where: { code: { startsWith: "UNT" } } }), - exists: async (code) => - (await prisma.unit.count({ where: { code } })) > 0 - }); - if (!resolvedCode.ok) { - return NextResponse.json( - { message: "Validasi gagal", errors: { code: [resolvedCode.message] } }, - { status: 400 } - ); - } - const unit = await prisma.unit.create({ - data: { - code: resolvedCode.code, - name: parsed.data.name - } - }); - await createAuditTrailSafe({ - userId: auth.user.id, - action: "UNIT_CREATED", - entityType: "UNIT", - entityId: unit.id, - method: request.method, - pathname: new URL(request.url).pathname, - statusCode: 201, - summary: `Unit ${unit.code} dibuat` - }); - return NextResponse.json({ data: serializeUnit(unit) }, { status: 201 }); - } catch (error) { - if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") { - return NextResponse.json( - { message: "Validasi gagal", errors: { code: ["Kode unit sudah dipakai"] } }, - { status: 409 } - ); - } - throw error; - } + return NextResponse.json({ message: getFixedUnitLockMessage() }, { status: 403 }); } diff --git a/src/app/contact-admin/page.tsx b/src/app/contact-admin/page.tsx new file mode 100644 index 0000000..4344624 --- /dev/null +++ b/src/app/contact-admin/page.tsx @@ -0,0 +1,13 @@ +import { ContactAdminClient } from "@/features/auth/components/contact-admin-client"; +import { createPublicCaptcha } from "@/lib/public-captcha"; + +export default function ContactAdminPage() { + const captcha = createPublicCaptcha(); + + return ( + + ); +} diff --git a/src/app/help-public/page.tsx b/src/app/help-public/page.tsx new file mode 100644 index 0000000..0518406 --- /dev/null +++ b/src/app/help-public/page.tsx @@ -0,0 +1,58 @@ +import { PublicInfoPage } from "@/components/auth/public-info-page"; +import { getCurrentLocale } from "@/lib/i18n-server"; + +export default async function HelpPublicPage() { + const locale = await getCurrentLocale(); + + return ( + + ); +} diff --git a/src/app/privacy-policy/page.tsx b/src/app/privacy-policy/page.tsx new file mode 100644 index 0000000..38804ff --- /dev/null +++ b/src/app/privacy-policy/page.tsx @@ -0,0 +1,56 @@ +import { PublicInfoPage } from "@/components/auth/public-info-page"; +import { getCurrentLocale } from "@/lib/i18n-server"; + +export default async function PrivacyPolicyPage() { + const locale = await getCurrentLocale(); + + return ( + + ); +} diff --git a/src/app/terms-and-conditions/page.tsx b/src/app/terms-and-conditions/page.tsx new file mode 100644 index 0000000..639345e --- /dev/null +++ b/src/app/terms-and-conditions/page.tsx @@ -0,0 +1,56 @@ +import { PublicInfoPage } from "@/components/auth/public-info-page"; +import { getCurrentLocale } from "@/lib/i18n-server"; + +export default async function TermsAndConditionsPage() { + const locale = await getCurrentLocale(); + + return ( + + ); +} diff --git a/src/components/auth/public-info-page.tsx b/src/components/auth/public-info-page.tsx new file mode 100644 index 0000000..d15b5c5 --- /dev/null +++ b/src/components/auth/public-info-page.tsx @@ -0,0 +1,64 @@ +"use client"; + +import Link from "next/link"; + +import { AppLogo } from "@/components/branding/app-logo"; +import { LanguageSwitcher } from "@/components/layout/language-switcher"; +import { useLocale } from "@/components/providers/locale-provider"; + +type PublicInfoPageProps = { + title: string; + description: string; + sections: Array<{ + title: string; + paragraphs: string[]; + }>; +}; + +export function PublicInfoPage({ title, description, sections }: PublicInfoPageProps) { + const { dict } = useLocale(); + + return ( +
+
+
+
+
+
+ +
+

+ AbelBirdnest Stock +

+

{title}

+
+
+ +
+

{description}

+
+ +
+ {sections.map((section) => ( +
+

{section.title}

+
+ {section.paragraphs.map((paragraph) => ( +

{paragraph}

+ ))} +
+
+ ))} +
+ +
+ © 2026 AbelBirdnest + + {dict.common.backToLogin} + +
+
+
+
+ ); +} diff --git a/src/components/layout/topbar-search.tsx b/src/components/layout/topbar-search.tsx new file mode 100644 index 0000000..e1e85f1 --- /dev/null +++ b/src/components/layout/topbar-search.tsx @@ -0,0 +1,162 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { Search } from "lucide-react"; +import { useDeferredValue, useEffect, useRef, useState } from "react"; + +import { useLocale } from "@/components/providers/locale-provider"; + +type SearchItem = { + id: string; + type: "LOT" | "PURCHASE"; + title: string; + subtitle: string; + href: string; +}; + +type TopbarSearchProps = { + placeholder: string; +}; + +export function TopbarSearch({ placeholder }: TopbarSearchProps) { + const router = useRouter(); + const { locale } = useLocale(); + const rootRef = useRef(null); + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const deferredQuery = useDeferredValue(query.trim()); + + useEffect(() => { + function handlePointerDown(event: MouseEvent) { + if (!rootRef.current?.contains(event.target as Node)) { + setOpen(false); + } + } + + document.addEventListener("mousedown", handlePointerDown); + return () => document.removeEventListener("mousedown", handlePointerDown); + }, []); + + useEffect(() => { + let cancelled = false; + + async function loadResults() { + if (deferredQuery.length < 2) { + setResults([]); + setLoading(false); + return; + } + + setLoading(true); + try { + const response = await fetch(`/api/v1/global-search?q=${encodeURIComponent(deferredQuery)}`, { + cache: "no-store" + }); + const payload = (await response.json()) as { data: SearchItem[] }; + if (!cancelled) { + setResults(response.ok ? payload.data : []); + } + } catch { + if (!cancelled) { + setResults([]); + } + } finally { + if (!cancelled) { + setLoading(false); + setOpen(true); + } + } + } + + void loadResults(); + + return () => { + cancelled = true; + }; + }, [deferredQuery]); + + function openSearchTarget(href: string) { + setOpen(false); + router.push(href); + } + + function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + + if (results[0]) { + openSearchTarget(results[0].href); + return; + } + + const trimmed = query.trim(); + if (!trimmed) return; + openSearchTarget(`/lots?search=${encodeURIComponent(trimmed)}`); + } + + const helperText = + locale === "id" + ? { + searching: "Mencari...", + empty: "Tidak ada hasil yang cocok.", + hint: "Ketik minimal 2 karakter", + lot: "Lot", + purchase: "Pembelian" + } + : { + searching: "Searching...", + empty: "No matching results.", + hint: "Type at least 2 characters", + lot: "Lot", + purchase: "Purchase" + }; + + return ( +
+
+ + setQuery(event.target.value)} + onFocus={() => setOpen(true)} + className="w-full rounded-xl border border-line/70 bg-slate-50 py-2 pl-10 pr-4 text-sm outline-none transition focus:border-moss/30 focus:bg-white focus:ring-2 focus:ring-moss/15" + placeholder={placeholder} + type="text" + /> + + + {open ? ( +
+ {query.trim().length < 2 ? ( +
{helperText.hint}
+ ) : loading ? ( +
{helperText.searching}
+ ) : results.length === 0 ? ( +
{helperText.empty}
+ ) : ( +
+ {results.map((item) => ( + setOpen(false)} + className="flex items-start justify-between gap-4 px-4 py-3 transition hover:bg-slate-50" + > +
+

{item.title}

+

{item.subtitle}

+
+ + {item.type === "LOT" ? helperText.lot : helperText.purchase} + + + ))} +
+ )} +
+ ) : null} +
+ ); +} diff --git a/src/components/layout/topbar.tsx b/src/components/layout/topbar.tsx index fa6a91c..0480bd6 100644 --- a/src/components/layout/topbar.tsx +++ b/src/components/layout/topbar.tsx @@ -1,10 +1,11 @@ "use client"; import Link from "next/link"; -import { CircleHelp, LogOut, Search } from "lucide-react"; +import { CircleHelp, LogOut } from "lucide-react"; import { LogoutButton } from "@/components/auth/logout-button"; import { LanguageSwitcher } from "@/components/layout/language-switcher"; +import { TopbarSearch } from "@/components/layout/topbar-search"; import { useLocale } from "@/components/providers/locale-provider"; import type { SessionUser } from "@/lib/auth"; @@ -19,18 +20,11 @@ export function Topbar({ title, description, user }: TopbarProps) { return ( <> -
-
-
- - -
+
+
+
-
+
([]); const [search, setSearch] = useState(""); - const [form, setForm] = useState(emptyForm); - const [editingId, setEditingId] = useState(null); const [loading, setLoading] = useState(true); - const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); - const { canEditCode } = useCurrentUser(); const filteredItems = useMemo(() => { const keyword = search.trim().toLowerCase(); if (!keyword) return items; @@ -49,94 +41,37 @@ export function UnitsClient() { void loadItems(); }, []); - function resetForm() { - setEditingId(null); - setForm(emptyForm); - setError(null); - } - - async function handleSubmit(event: FormEvent) { - event.preventDefault(); - setSubmitting(true); - setError(null); - try { - const response = await fetch(editingId ? `/api/v1/units/${editingId}` : "/api/v1/units", { - method: editingId ? "PUT" : "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(form) - }); - const payload = (await response.json()) as DetailResponse | ApiErrorResponse; - if (!response.ok) { - if ("errors" in payload && payload.errors) { - const firstError = Object.values(payload.errors)[0]?.[0]; - throw new Error(firstError ?? payload.message ?? dict.common.requestFailed); - } - throw new Error("message" in payload ? payload.message : dict.common.requestFailed); - } - resetForm(); - await loadItems(); - } catch (err) { - setError(err instanceof Error ? err.message : dict.master.saveError); - } finally { - setSubmitting(false); - } - } - - function startEdit(item: UnitRecord) { - setEditingId(item.id); - setForm({ code: item.code, name: item.name }); - } - - async function handleDelete(id: string) { - if (!window.confirm(`${dict.common.delete} unit ini?`)) return; - try { - const response = await fetch(`/api/v1/units/${id}`, { method: "DELETE" }); - if (!response.ok) { - const payload = (await response.json()) as ApiErrorResponse; - throw new Error(payload.message); - } - if (editingId === id) resetForm(); - await loadItems(); - } catch (err) { - setError(err instanceof Error ? err.message : dict.master.deleteError); - } - } - return ( -
+

{dict.master.overline}

- {editingId ? `${dict.common.edit} Satuan` : `${dict.master.add} Satuan`} + {dict.master.list} Satuan Tetap

- Satuan ini dipakai pada pembelian, penerimaan, penjualan, dan lot. + Satuan dikunci sistem dan hanya terdiri dari dua pilihan: gram dan kilogram. Master ini dipakai pada pembelian, receipt, lot, dan perhitungan analitik qty.

- {editingId ? : null}
-
- setForm((current) => ({ ...current, code: value }))} - /> - setForm((current) => ({ ...current, name: value }))} /> - {error ?
{error}
: null} - - +
+
+

gr

+

Gram

+
+
+

kg

+

Kilogram

+
+
+ {error ?
{error}
: null}

{dict.master.list} Satuan

-

Master satuan untuk transaksi operasional.

+

Read-only. Tidak dapat ditambah, diubah, atau dihapus dari aplikasi.

{items.length} {dict.master.dataCount}
@@ -161,7 +96,6 @@ export function UnitsClient() { {dict.master.code} {dict.master.name} - {dict.common.actions} @@ -169,12 +103,6 @@ export function UnitsClient() { {item.code} {item.name} - -
- - -
- ))} @@ -196,30 +124,3 @@ export function UnitsClient() {
); } - -function Field({ - label, - value, - onChange, - readOnly = false, - placeholder -}: { - label: string; - value: string; - onChange: (value: string) => void; - readOnly?: boolean; - placeholder?: string; -}) { - return ( - - ); -} diff --git a/src/config/navigation.ts b/src/config/navigation.ts index 4c354f3..0bd4abc 100644 --- a/src/config/navigation.ts +++ b/src/config/navigation.ts @@ -19,7 +19,6 @@ import { Store, Waves, Truck, - Ruler, Users } from "lucide-react"; @@ -174,14 +173,6 @@ export const primaryNavigation: NavEntry[] = [ description: "Master skema bagi hasil agen dan perusahaan.", icon: Percent, roles: ["ADMIN", "SYSTEM_ADMIN", "OWNER"] - }, - { - type: "link", - href: "/units", - label: "Satuan", - description: "Master satuan transaksi dan stok.", - icon: Ruler, - roles: ["ADMIN", "SYSTEM_ADMIN", "OWNER"] } ] }, diff --git a/src/features/auth/components/contact-admin-client.tsx b/src/features/auth/components/contact-admin-client.tsx new file mode 100644 index 0000000..70ffe3c --- /dev/null +++ b/src/features/auth/components/contact-admin-client.tsx @@ -0,0 +1,172 @@ +"use client"; + +import Link from "next/link"; +import { FormEvent, useState } from "react"; + +import { AppLogo } from "@/components/branding/app-logo"; +import { LanguageSwitcher } from "@/components/layout/language-switcher"; +import { useLocale } from "@/components/providers/locale-provider"; + +type ContactAdminClientProps = { + captchaQuestion: string; + captchaToken: string; +}; + +export function ContactAdminClient({ + captchaQuestion, + captchaToken +}: ContactAdminClientProps) { + const { locale, dict } = useLocale(); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [message, setMessage] = useState(""); + const [captchaAnswer, setCaptchaAnswer] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setSubmitting(true); + setError(null); + setSuccess(null); + + try { + const response = await fetch("/api/v1/public/contact-admin", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + name, + email, + message, + captcha_token: captchaToken, + captcha_answer: captchaAnswer, + website: "" + }) + }); + + const payload = (await response.json()) as { + message?: string; + errors?: Record; + }; + + if (!response.ok) { + const firstError = payload.errors ? Object.values(payload.errors)[0]?.[0] : undefined; + throw new Error(firstError ?? payload.message ?? "Gagal mengirim pesan."); + } + + setSuccess(payload.message ?? "Pesan berhasil dikirim."); + setName(""); + setEmail(""); + setMessage(""); + setCaptchaAnswer(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Gagal mengirim pesan."); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+
+
+
+
+ +
+

+ AbelBirdnest Stock +

+

+ {locale === "id" ? "Hubungi Admin" : "Contact Admin"} +

+
+
+ +
+

+ {locale === "id" + ? "Gunakan form ini untuk permintaan akun operasional, bantuan akses, atau kendala login. Form ini dilindungi CAPTCHA untuk mengurangi spam." + : "Use this form for operational account requests, access assistance, or login issues. This form is protected by CAPTCHA to reduce spam."} +

+
+ +
+ + + + +