Add public auth pages, global search, and fixed units
This commit is contained in:
@ -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"
|
||||
|
||||
176
docs/codex-handoff-2026-05-21.md
Normal file
176
docs/codex-handoff-2026-05-21.md
Normal 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.
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
94
src/app/api/v1/global-search/route.ts
Normal file
94
src/app/api/v1/global-search/route.ts
Normal 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 });
|
||||
}
|
||||
70
src/app/api/v1/public/contact-admin/route.ts
Normal file
70
src/app/api/v1/public/contact-admin/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
13
src/app/contact-admin/page.tsx
Normal file
13
src/app/contact-admin/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
58
src/app/help-public/page.tsx
Normal file
58
src/app/help-public/page.tsx
Normal 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."
|
||||
]
|
||||
}
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
56
src/app/privacy-policy/page.tsx
Normal file
56
src/app/privacy-policy/page.tsx
Normal 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."
|
||||
]
|
||||
}
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
56
src/app/terms-and-conditions/page.tsx
Normal file
56
src/app/terms-and-conditions/page.tsx
Normal 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."
|
||||
]
|
||||
}
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
64
src/components/auth/public-info-page.tsx
Normal file
64
src/components/auth/public-info-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
162
src/components/layout/topbar-search.tsx
Normal file
162
src/components/layout/topbar-search.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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"]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
172
src/features/auth/components/contact-admin-client.tsx
Normal file
172
src/features/auth/components/contact-admin-client.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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);
|
||||
|
||||
52
src/features/units/lib/fixed-units.ts
Normal file
52
src/features/units/lib/fixed-units.ts
Normal 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.";
|
||||
}
|
||||
@ -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
56
src/lib/public-captcha.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user