Add public auth pages, global search, and fixed units

This commit is contained in:
2026-05-21 07:49:35 +07:00
parent 3002ef9b8c
commit d015cb0dda
24 changed files with 1183 additions and 315 deletions

View File

@ -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"

View File

@ -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/<timestamp>`
- 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.

View File

@ -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;

View File

@ -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 {

View File

@ -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 });
}

View File

@ -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: `
<p><strong>Nama:</strong> ${parsed.data.name}</p>
<p><strong>Email:</strong> ${parsed.data.email}</p>
<p><strong>Pesan:</strong></p>
<p>${parsed.data.message.replace(/\n/g, "<br />")}</p>
`
});
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 }
);
}
}

View File

@ -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 });
}

View File

@ -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 });
}

View File

@ -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 (
<ContactAdminClient
captchaQuestion={captcha.question}
captchaToken={captcha.token}
/>
);
}

View File

@ -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 (
<PublicInfoPage
title={locale === "id" ? "Bantuan" : "Help"}
description={
locale === "id"
? "Panduan singkat ini membantu pengguna yang belum bisa login, perlu reset password, atau membutuhkan bantuan operasional dari tim admin."
: "This quick guide helps users who cannot sign in yet, need a password reset, or require operational assistance from the admin."
}
sections={[
{
title: locale === "id" ? "Reset password" : "Reset password",
paragraphs:
locale === "id"
? [
"Gunakan form bantuan login di halaman masuk untuk mengirim link reset password ke email yang sudah terdaftar.",
"Pastikan alamat email yang dimasukkan sama dengan email akun operasional Anda."
]
: [
"Use the login support form on the sign-in page to send a password reset link to the registered email.",
"Make sure the email address matches the operational account email."
]
},
{
title: locale === "id" ? "Verifikasi email" : "Email verification",
paragraphs:
locale === "id"
? [
"Jika akun baru belum bisa dipakai login, kirim ulang verifikasi email dari halaman login.",
"Setelah email diverifikasi, login dapat dilakukan dengan email atau username yang aktif."
]
: [
"If a new account cannot be used yet, resend the verification email from the login page.",
"After the email is verified, users can sign in with the active email or username."
]
},
{
title: locale === "id" ? "Hubungi admin" : "Contact admin",
paragraphs:
locale === "id"
? [
"Untuk masalah akses role, pembuatan akun baru, atau force reset password, gunakan form Hubungi Admin yang tersedia di halaman login.",
"Jangan kirim password aktif melalui email atau pesan teks."
]
: [
"For role access issues, new account requests, or force password reset requests, use the Contact Admin form available on the login page.",
"Do not send active passwords by email or text message."
]
}
]}
/>
);
}

View File

@ -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 (
<PublicInfoPage
title={locale === "id" ? "Kebijakan Privasi" : "Privacy Policy"}
description={
locale === "id"
? "Halaman ini menjelaskan bagaimana data akun dan data operasional dipakai di dalam aplikasi AbelBirdnest Stock."
: "This page explains how account data and operational data are used within the AbelBirdnest Stock application."
}
sections={[
{
title: locale === "id" ? "Data yang digunakan" : "Data in use",
paragraphs:
locale === "id"
? [
"Aplikasi menyimpan data akun, email, role, serta jejak transaksi yang dibutuhkan untuk operasional inventory dan audit internal.",
"Data yang dimasukkan pengguna seperti purchase, receipt, lot, penjualan, dan adjustment dipakai untuk pelacakan stok dan costing."
]
: [
"The application stores account data, email, roles, and transaction logs required for inventory operations and internal audits.",
"User-entered data such as purchases, receipts, lots, sales, and adjustments are used for stock traceability and costing."
]
},
{
title: locale === "id" ? "Keamanan akses" : "Access security",
paragraphs:
locale === "id"
? [
"Hak akses ditentukan berdasarkan role user dan setiap perubahan penting dapat tercatat di audit trail sistem.",
"Pengguna bertanggung jawab menjaga kerahasiaan password dan tidak membagikan sesi login kepada pihak lain."
]
: [
"Access rights are determined by user role, and important changes may be recorded in the system audit trail.",
"Users are responsible for keeping passwords confidential and must not share active login sessions with others."
]
},
{
title: locale === "id" ? "Kontak bantuan" : "Support contact",
paragraphs:
locale === "id"
? [
"Jika ada pertanyaan tentang akses data atau bantuan akun, gunakan form Hubungi Admin yang tersedia di halaman login."
]
: [
"If you have questions about data access or account assistance, use the Contact Admin form available on the login page."
]
}
]}
/>
);
}

View File

@ -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 (
<PublicInfoPage
title={locale === "id" ? "Syarat & Ketentuan" : "Terms & Conditions"}
description={
locale === "id"
? "Dengan memakai aplikasi AbelBirdnest Stock, pengguna menyetujui penggunaan sistem sesuai role, kebijakan keamanan, dan prosedur operasional perusahaan."
: "By using the AbelBirdnest Stock application, users agree to use the system in line with their assigned role, security policy, and company operating procedures."
}
sections={[
{
title: locale === "id" ? "Penggunaan akun" : "Account usage",
paragraphs:
locale === "id"
? [
"Akun hanya boleh digunakan oleh personel yang diberi wewenang oleh perusahaan.",
"Pengguna wajib memastikan data transaksi yang dimasukkan benar dan dapat dipertanggungjawabkan."
]
: [
"Accounts may only be used by personnel authorized by the company.",
"Users must ensure that submitted transaction data is accurate and accountable."
]
},
{
title: locale === "id" ? "Tanggung jawab operasional" : "Operational responsibility",
paragraphs:
locale === "id"
? [
"Setiap input purchase, receipt, lot, penjualan, washing, atau adjustment harus mengikuti proses bisnis internal yang berlaku.",
"Penyalahgunaan akses, manipulasi data, atau penggunaan akun bersama tanpa izin dapat menyebabkan pembatasan akses."
]
: [
"Every purchase, receipt, lot, sales, washing, or adjustment entry must follow the applicable internal business process.",
"Access abuse, data manipulation, or unauthorized shared-account usage may result in access restrictions."
]
},
{
title: locale === "id" ? "Keamanan dan bantuan" : "Security and support",
paragraphs:
locale === "id"
? [
"Untuk kendala login, reset password, atau pembaruan hak akses, gunakan kanal bantuan resmi melalui form Hubungi Admin di halaman login."
]
: [
"For login issues, password reset requests, or access-right updates, use the official support channel through the Contact Admin form on the login page."
]
}
]}
/>
);
}

View File

@ -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 (
<main className="min-h-screen bg-canvas px-6 py-10 lg:px-0">
<div className="mx-auto w-full max-w-4xl">
<div className="rounded-2xl border border-line/70 bg-white shadow-panel">
<header className="border-b border-line/70 px-8 py-8 sm:px-10">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-4">
<AppLogo size={64} className="h-16 w-16 rounded-2xl object-contain" priority />
<div>
<p className="text-sm font-semibold uppercase tracking-[0.12em] text-slate-500">
AbelBirdnest Stock
</p>
<h1 className="mt-1 text-3xl font-semibold tracking-tight text-ink">{title}</h1>
</div>
</div>
<LanguageSwitcher />
</div>
<p className="mt-5 max-w-3xl text-[15px] leading-7 text-slate-600">{description}</p>
</header>
<div className="space-y-8 px-8 py-8 sm:px-10">
{sections.map((section) => (
<section key={section.title}>
<h2 className="text-lg font-semibold text-ink">{section.title}</h2>
<div className="mt-3 space-y-3 text-[14px] leading-7 text-slate-600">
{section.paragraphs.map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
))}
</div>
</section>
))}
</div>
<footer className="flex flex-col gap-3 border-t border-line/70 px-8 py-6 text-sm text-slate-500 sm:flex-row sm:items-center sm:justify-between sm:px-10">
<span>© 2026 AbelBirdnest</span>
<Link href="/login" className="font-semibold text-moss hover:underline">
{dict.common.backToLogin}
</Link>
</footer>
</div>
</div>
</main>
);
}

View File

@ -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<HTMLDivElement | null>(null);
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchItem[]>([]);
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<HTMLFormElement>) {
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 (
<div ref={rootRef} className="relative w-full">
<form onSubmit={handleSubmit}>
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
value={query}
onChange={(event) => 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"
/>
</form>
{open ? (
<div className="absolute left-0 right-0 top-[calc(100%+0.5rem)] overflow-hidden rounded-xl border border-line/70 bg-white shadow-panel">
{query.trim().length < 2 ? (
<div className="px-4 py-3 text-sm text-slate-500">{helperText.hint}</div>
) : loading ? (
<div className="px-4 py-3 text-sm text-slate-500">{helperText.searching}</div>
) : results.length === 0 ? (
<div className="px-4 py-3 text-sm text-slate-500">{helperText.empty}</div>
) : (
<div className="py-2">
{results.map((item) => (
<Link
key={`${item.type}-${item.id}`}
href={item.href}
onClick={() => setOpen(false)}
className="flex items-start justify-between gap-4 px-4 py-3 transition hover:bg-slate-50"
>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-ink">{item.title}</p>
<p className="truncate text-xs text-slate-500">{item.subtitle}</p>
</div>
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.06em] text-slate-600">
{item.type === "LOT" ? helperText.lot : helperText.purchase}
</span>
</Link>
))}
</div>
)}
</div>
) : null}
</div>
);
}

View File

@ -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 (
<>
<header className="sticky top-0 z-40 flex h-14 items-center justify-between border-b border-line/70 bg-white px-6 shadow-panel">
<div className="flex flex-1 items-center">
<div className="relative hidden w-72 lg:block">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
className="w-full rounded-lg border-none bg-slate-50 py-1.5 pl-9 pr-3 text-sm outline-none ring-0 focus:ring-2 focus:ring-moss/20"
placeholder={dict.shell.searchPlaceholder}
type="text"
/>
</div>
<header className="sticky top-0 z-40 flex h-14 items-center gap-4 border-b border-line/70 bg-white px-6 shadow-panel">
<div className="hidden min-w-0 flex-1 lg:block">
<TopbarSearch placeholder={dict.shell.searchPlaceholder} />
</div>
<div className="flex items-center gap-4">
<div className="ml-auto flex items-center gap-4">
<LanguageSwitcher />
<Link
href="/help"

View File

@ -1,26 +1,18 @@
"use client";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useLocale } from "@/components/providers/locale-provider";
import { PaginationFooter } from "@/components/shared/pagination-footer";
import { useCurrentUser } from "@/hooks/use-current-user";
import { usePagination } from "@/hooks/use-pagination";
import type { ApiErrorResponse, DetailResponse } from "@/types/api";
import type { UnitRecord } from "@/types/master-data";
const emptyForm = { code: "", name: "" };
export function UnitsClient() {
const { dict } = useLocale();
const [items, setItems] = useState<UnitRecord[]>([]);
const [search, setSearch] = useState("");
const [form, setForm] = useState(emptyForm);
const [editingId, setEditingId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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<HTMLFormElement>) {
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<UnitRecord> | 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 (
<section className="grid gap-6 xl:grid-cols-[0.92fr_1.08fr]">
<section className="grid gap-6">
<div className="ops-card p-6">
<div className="flex items-start justify-between gap-4">
<div>
<p className="ops-overline">{dict.master.overline}</p>
<h2 className="mt-2 text-[28px] font-semibold tracking-tight text-ink">
{editingId ? `${dict.common.edit} Satuan` : `${dict.master.add} Satuan`}
{dict.master.list} Satuan Tetap
</h2>
<p className="mt-3 text-[13px] leading-6 text-slate-500">
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.
</p>
</div>
{editingId ? <button type="button" onClick={resetForm} className="ops-btn-secondary">{dict.common.cancelEdit}</button> : null}
</div>
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
<Field
label={dict.master.code}
value={form.code}
readOnly={!canEditCode}
placeholder={canEditCode ? "UNT00001" : "Auto"}
onChange={(value) => setForm((current) => ({ ...current, code: value }))}
/>
<Field label={dict.master.name} value={form.name} onChange={(value) => setForm((current) => ({ ...current, name: value }))} />
{error ? <div className="rounded border border-ember/30 bg-ember/10 px-4 py-3 text-sm text-ember">{error}</div> : null}
<button type="submit" disabled={submitting} className="ops-btn-primary">
{submitting ? dict.common.processing : editingId ? `${dict.master.update} unit` : `${dict.master.add} unit`}
</button>
</form>
<div className="mt-6 grid gap-3 md:grid-cols-2">
<div className="rounded-xl border border-line/70 bg-slate-50/70 p-4">
<p className="text-xs font-bold uppercase tracking-[0.08em] text-slate-500">gr</p>
<p className="mt-2 text-lg font-semibold text-ink">Gram</p>
</div>
<div className="rounded-xl border border-line/70 bg-slate-50/70 p-4">
<p className="text-xs font-bold uppercase tracking-[0.08em] text-slate-500">kg</p>
<p className="mt-2 text-lg font-semibold text-ink">Kilogram</p>
</div>
</div>
{error ? <div className="mt-5 rounded border border-ember/30 bg-ember/10 px-4 py-3 text-sm text-ember">{error}</div> : null}
</div>
<div className="ops-table-shell">
<div className="ops-section-head">
<div>
<p className="ops-title">{dict.master.list} Satuan</p>
<p className="ops-copy">Master satuan untuk transaksi operasional.</p>
<p className="ops-copy">Read-only. Tidak dapat ditambah, diubah, atau dihapus dari aplikasi.</p>
</div>
<div className="ops-chip-muted">{items.length} {dict.master.dataCount}</div>
</div>
@ -161,7 +96,6 @@ export function UnitsClient() {
<tr>
<th>{dict.master.code}</th>
<th>{dict.master.name}</th>
<th>{dict.common.actions}</th>
</tr>
</thead>
<tbody>
@ -169,12 +103,6 @@ export function UnitsClient() {
<tr key={item.id}>
<td className="font-semibold text-ink">{item.code}</td>
<td>{item.name}</td>
<td>
<div className="flex gap-2">
<button type="button" onClick={() => startEdit(item)} className="ops-btn-secondary">{dict.common.edit}</button>
<button type="button" onClick={() => void handleDelete(item.id)} className="ops-btn-danger">{dict.common.delete}</button>
</div>
</td>
</tr>
))}
</tbody>
@ -196,30 +124,3 @@ export function UnitsClient() {
</section>
);
}
function Field({
label,
value,
onChange,
readOnly = false,
placeholder
}: {
label: string;
value: string;
onChange: (value: string) => void;
readOnly?: boolean;
placeholder?: string;
}) {
return (
<label className="block">
<span className="ops-label">{label}</span>
<input
value={value}
readOnly={readOnly}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className="ops-input"
/>
</label>
);
}

View File

@ -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"]
}
]
},

View File

@ -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<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
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<string, string[]>;
};
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 (
<main className="min-h-screen bg-canvas px-6 py-10 lg:px-0">
<div className="mx-auto w-full max-w-3xl">
<div className="rounded-2xl border border-line/70 bg-white shadow-panel">
<header className="border-b border-line/70 px-8 py-8 sm:px-10">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-4">
<AppLogo size={64} className="h-16 w-16 rounded-2xl object-contain" priority />
<div>
<p className="text-sm font-semibold uppercase tracking-[0.12em] text-slate-500">
AbelBirdnest Stock
</p>
<h1 className="mt-1 text-3xl font-semibold tracking-tight text-ink">
{locale === "id" ? "Hubungi Admin" : "Contact Admin"}
</h1>
</div>
</div>
<LanguageSwitcher />
</div>
<p className="mt-5 max-w-2xl text-[15px] leading-7 text-slate-600">
{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."}
</p>
</header>
<form onSubmit={handleSubmit} className="space-y-6 px-8 py-8 sm:px-10">
<label className="block">
<span className="ops-label">{locale === "id" ? "Nama" : "Name"}</span>
<input value={name} onChange={(event) => setName(event.target.value)} className="ops-input" />
</label>
<label className="block">
<span className="ops-label">Email</span>
<input
value={email}
onChange={(event) => setEmail(event.target.value)}
className="ops-input"
placeholder="name@company.com"
/>
</label>
<label className="block">
<span className="ops-label">{locale === "id" ? "Pesan" : "Message"}</span>
<textarea
value={message}
onChange={(event) => setMessage(event.target.value)}
className="ops-input min-h-32"
placeholder={
locale === "id"
? "Jelaskan kebutuhan akun atau kendala yang Anda alami."
: "Describe your account request or issue."
}
/>
</label>
<div className="rounded-lg border border-line/70 bg-slate-50 p-4">
<p className="ops-label mb-2">{locale === "id" ? "Verifikasi CAPTCHA" : "CAPTCHA Verification"}</p>
<p className="text-sm text-slate-600">
{locale === "id" ? "Berapa hasil dari" : "What is"} <span className="font-semibold text-ink">{captchaQuestion}</span>?
</p>
<input
value={captchaAnswer}
onChange={(event) => setCaptchaAnswer(event.target.value)}
className="ops-input mt-3 max-w-40"
inputMode="numeric"
/>
</div>
{error ? (
<div className="rounded border border-ember/30 bg-ember/10 px-4 py-3 text-sm text-ember">
{error}
</div>
) : null}
{success ? (
<div className="rounded border border-moss/30 bg-moss/10 px-4 py-3 text-sm text-moss">
{success}
</div>
) : null}
<div className="flex flex-col gap-3 sm:flex-row">
<button type="submit" disabled={submitting} className="ops-btn-primary">
{submitting
? locale === "id"
? "Mengirim..."
: "Sending..."
: locale === "id"
? "Kirim ke Admin"
: "Send to Admin"}
</button>
<Link href="/login" className="ops-btn-secondary">
{dict.common.backToLogin}
</Link>
</div>
</form>
</div>
</div>
</main>
);
}

View File

@ -1,10 +1,12 @@
"use client";
import Link from "next/link";
import { Eye, EyeOff, Lock, LogIn, User } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
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";
export function LoginClient() {
@ -125,6 +127,9 @@ export function LoginClient() {
<section className="flex items-start justify-center lg:h-screen lg:overflow-y-auto lg:border-l lg:border-line/70 lg:bg-white/60 lg:px-10 lg:py-14 xl:px-14">
<div className="w-full max-w-[560px] rounded-lg border border-line/70 bg-white p-8 shadow-panel sm:p-12">
<div className="mb-8 flex justify-end">
<LanguageSwitcher />
</div>
<header className="mb-10">
<h1 className="text-[40px] font-semibold tracking-tight text-ink">
{dict.login.title}
@ -231,12 +236,20 @@ export function LoginClient() {
<p className="text-[15px] text-slate-600">
{dict.login.noAccount}{" "}
<span className="font-semibold text-moss">{dict.login.contactAdmin}</span>
<Link href="/contact-admin" className="font-semibold text-moss hover:underline">
{dict.login.contactAdmin}
</Link>
</p>
<div className="mt-8 flex flex-wrap justify-center gap-6 text-[12px] font-bold uppercase tracking-[0.08em] text-slate-500">
<span>{dict.login.support}</span>
<span>{dict.login.privacy}</span>
<span>{dict.login.terms}</span>
<Link href="/help-public" className="hover:text-moss hover:underline">
{dict.login.support}
</Link>
<Link href="/privacy-policy" className="hover:text-moss hover:underline">
{dict.login.privacy}
</Link>
<Link href="/terms-and-conditions" className="hover:text-moss hover:underline">
{dict.login.terms}
</Link>
</div>
</footer>
</div>

View File

@ -43,7 +43,9 @@ export function HelpClient() {
<div className="mt-6 rounded border border-line/70 bg-slate-50 p-4">
<p className="text-sm font-semibold text-ink">{dict.help.contactLabel}</p>
<p className="mt-2 text-[14px] font-medium text-moss">mailer@unified.co.id</p>
<p className="mt-2 text-[14px] font-medium text-moss">
Gunakan form Hubungi Admin di halaman login.
</p>
</div>
<div className="mt-4 rounded border border-amber-200 bg-amber-50 p-4">

View File

@ -2,6 +2,7 @@
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useLocale } from "@/components/providers/locale-provider";
import { formatCurrencyAmount, formatQuantity } from "@/lib/formatters";
@ -14,10 +15,15 @@ type LotsClientProps = {
export function LotsClient({ currencyCode }: LotsClientProps) {
const { dict, locale } = useLocale();
const searchParams = useSearchParams();
const [lots, setLots] = useState<LotListItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [query, setQuery] = useState(searchParams.get("search") ?? "");
useEffect(() => {
setQuery(searchParams.get("search") ?? "");
}, [searchParams]);
async function loadLots() {
setLoading(true);

View File

@ -0,0 +1,52 @@
import { prisma } from "@/lib/prisma";
const FIXED_UNITS = [
{ code: "gr", name: "Gram" },
{ code: "kg", name: "Kilogram" }
] as const;
export async function ensureFixedUnits() {
return prisma.$transaction(async (tx) => {
const ensured = [];
for (const fixedUnit of FIXED_UNITS) {
const existing =
(await tx.unit.findFirst({
where: {
OR: [
{ code: { equals: fixedUnit.code, mode: "insensitive" } },
{ name: { equals: fixedUnit.name, mode: "insensitive" } }
]
}
})) ?? null;
if (existing) {
ensured.push(
await tx.unit.update({
where: { id: existing.id },
data: {
code: fixedUnit.code,
name: fixedUnit.name
}
})
);
continue;
}
ensured.push(
await tx.unit.create({
data: fixedUnit
})
);
}
return ensured.sort((left, right) => {
const order = ["gr", "kg"];
return order.indexOf(left.code) - order.indexOf(right.code);
});
});
}
export function getFixedUnitLockMessage() {
return "Master satuan dikunci sistem. Hanya gram dan kilogram yang diperbolehkan.";
}

View File

@ -13,6 +13,18 @@ function formatQty(value: number, locale: AppLocale) {
}).format(value)} kg`;
}
function normalizeUnitCode(unitCode: string | null | undefined) {
return (unitCode ?? "").trim().toLowerCase();
}
function convertQtyToKg(value: number, unitCode: string | null | undefined) {
const normalizedUnit = normalizeUnitCode(unitCode);
if (normalizedUnit === "g" || normalizedUnit === "gr" || normalizedUnit === "gram" || normalizedUnit === "grams") {
return value / 1000;
}
return value;
}
function formatCurrency(value: number, locale: AppLocale, currencyCode: string) {
return formatCurrencyAmount(value, locale, currencyCode, 0);
}
@ -53,7 +65,8 @@ export async function getDashboardData(locale: AppLocale): Promise<DashboardData
include: {
purchase: { select: { agent: { select: { name: true } } } },
grade: { select: { name: true } },
warehouse: { select: { name: true } }
warehouse: { select: { name: true } },
unit: { select: { code: true } }
},
orderBy: [{ receivedAt: "desc" }]
}),
@ -77,7 +90,16 @@ export async function getDashboardData(locale: AppLocale): Promise<DashboardData
}
},
include: {
adjustmentReason: { select: { category: true } }
adjustmentReason: { select: { category: true } },
lot: {
select: {
unit: {
select: {
code: true
}
}
}
}
}
}),
prisma.purchaseLine.findMany({
@ -90,6 +112,11 @@ export async function getDashboardData(locale: AppLocale): Promise<DashboardData
select: {
qtyOrdered: true,
subtotal: true,
unit: {
select: {
code: true
}
},
purchase: {
select: { purchaseDate: true }
}
@ -103,6 +130,15 @@ export async function getDashboardData(locale: AppLocale): Promise<DashboardData
},
select: {
qtyActualSold: true,
lot: {
select: {
unit: {
select: {
code: true
}
}
}
},
regularSale: {
select: {
saleDate: true,
@ -117,6 +153,15 @@ export async function getDashboardData(locale: AppLocale): Promise<DashboardData
},
select: {
qtySold: true,
lot: {
select: {
unit: {
select: {
code: true
}
}
}
},
closeDate: true
}
}),
@ -140,24 +185,36 @@ export async function getDashboardData(locale: AppLocale): Promise<DashboardData
const activeLots = lots.filter((lot) => lot.status === "ACTIVE");
const warehouseCount = new Set(lots.map((lot) => lot.warehouse.name)).size;
const activeStockQty = activeLots.reduce((sum, lot) => sum + lot.availableQty.toNumber(), 0);
const activeStockQty = activeLots.reduce(
(sum, lot) => sum + convertQtyToKg(lot.availableQty.toNumber(), lot.unit.code),
0
);
const currentMonthPurchaseValue = purchaseLines
.filter((line) => line.purchase.purchaseDate >= currentMonthStart)
.reduce((sum, line) => sum + line.subtotal.toNumber(), 0);
const currentMonthPurchaseQty = purchaseLines
.filter((line) => line.purchase.purchaseDate >= currentMonthStart)
.reduce((sum, line) => sum + line.qtyOrdered.toNumber(), 0);
.reduce((sum, line) => sum + convertQtyToKg(line.qtyOrdered.toNumber(), line.unit.code), 0);
const shrinkageQty30Days = stockAdjustments.reduce(
(sum, adjustment) => sum + Math.abs(adjustment.qtyChange.toNumber()),
(sum, adjustment) =>
sum +
convertQtyToKg(
Math.abs(adjustment.qtyChange.toNumber()),
adjustment.lot.unit.code
),
0
);
const totalPurchaseQty = purchaseLines.reduce(
(sum, line) => sum + convertQtyToKg(line.qtyOrdered.toNumber(), line.unit.code),
0
);
const totalPurchaseQty = purchaseLines.reduce((sum, line) => sum + line.qtyOrdered.toNumber(), 0);
const totalRegularSalesQty = regularSaleLines.reduce(
(sum, line) => sum + (line.qtyActualSold?.toNumber() ?? 0),
(sum, line) =>
sum + convertQtyToKg(line.qtyActualSold?.toNumber() ?? 0, line.lot.unit.code),
0
);
const totalConsignmentQty = consignmentLines.reduce(
(sum, line) => sum + line.qtySold.toNumber(),
(sum, line) => sum + convertQtyToKg(line.qtySold.toNumber(), line.lot.unit.code),
0
);
const totalJitSalesQty = jitSaleLines.reduce(
@ -217,15 +274,15 @@ export async function getDashboardData(locale: AppLocale): Promise<DashboardData
? `Umur lot ${agingDays} hari, perlu evaluasi rotasi stok.`
: `Lot age is ${agingDays} days and needs stock rotation review.`
: locale === "id"
? `Stok tersedia tinggal ${formatQty(lot.availableQty.toNumber(), locale)}.`
: `Available stock is only ${formatQty(lot.availableQty.toNumber(), locale)}.`;
? `Stok tersedia tinggal ${formatQty(convertQtyToKg(lot.availableQty.toNumber(), lot.unit.code), locale)}.`
: `Available stock is only ${formatQty(convertQtyToKg(lot.availableQty.toNumber(), lot.unit.code), locale)}.`;
return {
id: lot.id.toString(),
lotCode: lot.lotCode,
supplier: lot.purchase?.agent?.name ?? "Pembelian bebas",
item: splitGradeName(lot.grade?.name).gradeName,
availableQty: formatQty(lot.availableQty.toNumber(), locale),
availableQty: formatQty(convertQtyToKg(lot.availableQty.toNumber(), lot.unit.code), locale),
lotStatus: derivedLotStatus,
attentionStatus,
reason,
@ -246,8 +303,9 @@ export async function getDashboardData(locale: AppLocale): Promise<DashboardData
const name = lot.purchase?.agent?.name ?? "Pembelian bebas";
const current = partnerMap.get(name) ?? { lotCount: 0, qty: 0, inventoryValue: 0 };
current.lotCount += 1;
current.qty += lot.availableQty.toNumber();
current.inventoryValue += lot.availableQty.toNumber() * lot.unitCost.toNumber();
const availableQtyKg = convertQtyToKg(lot.availableQty.toNumber(), lot.unit.code);
current.qty += availableQtyKg;
current.inventoryValue += availableQtyKg * lot.unitCost.toNumber();
partnerMap.set(name, current);
});

56
src/lib/public-captcha.ts Normal file
View File

@ -0,0 +1,56 @@
import { createHmac, timingSafeEqual } from "crypto";
import { getAuthSecretOrThrow } from "@/lib/runtime-env";
type CaptchaPayload = {
answer: number;
exp: number;
};
function encodePayload(payload: CaptchaPayload) {
return Buffer.from(JSON.stringify(payload)).toString("base64url");
}
function decodePayload(token: string): CaptchaPayload | null {
try {
return JSON.parse(Buffer.from(token, "base64url").toString("utf8")) as CaptchaPayload;
} catch {
return null;
}
}
function signToken(encodedPayload: string) {
return createHmac("sha256", getAuthSecretOrThrow())
.update(encodedPayload)
.digest("base64url");
}
export function createPublicCaptcha() {
const left = Math.floor(Math.random() * 8) + 2;
const right = Math.floor(Math.random() * 8) + 2;
const payload = encodePayload({
answer: left + right,
exp: Math.floor(Date.now() / 1000) + 60 * 15
});
const signature = signToken(payload);
return {
question: `${left} + ${right}`,
token: `${payload}.${signature}`
};
}
export function verifyPublicCaptcha(token: string, rawAnswer: number) {
const [payload, signature] = token.split(".");
if (!payload || !signature) return false;
const expectedSignature = signToken(payload);
if (expectedSignature.length !== signature.length) return false;
if (!timingSafeEqual(Buffer.from(expectedSignature), Buffer.from(signature))) return false;
const decoded = decodePayload(payload);
if (!decoded) return false;
if (decoded.exp < Math.floor(Date.now() / 1000)) return false;
return decoded.answer === rawAnswer;
}