Add public auth pages, global search, and fixed units
This commit is contained in:
@ -2,20 +2,29 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
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}"
|
ARTIFACT_NAME="${ARTIFACT_NAME:-abelbirdnest-release.tar.gz}"
|
||||||
|
NODE_MEMORY_MB="${NODE_MEMORY_MB:-4096}"
|
||||||
|
|
||||||
cd "$ROOT_DIR"
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-v "$ROOT_DIR":/app \
|
-v "$ROOT_DIR":/src:ro \
|
||||||
-w /app \
|
-v "$ROOT_DIR":/out \
|
||||||
|
-w / \
|
||||||
"$IMAGE" \
|
"$IMAGE" \
|
||||||
bash -lc '
|
bash -lc '
|
||||||
set -euo pipefail
|
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
|
npm ci
|
||||||
npx prisma generate
|
npx prisma generate
|
||||||
npm run build
|
NODE_OPTIONS="--max-old-space-size='"$NODE_MEMORY_MB"'" npm run build
|
||||||
rm -rf .deploy-release
|
rm -rf .deploy-release
|
||||||
mkdir -p .deploy-release
|
mkdir -p .deploy-release
|
||||||
cp -R .next/standalone/. .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 .next/static .deploy-release/.next/static
|
||||||
cp -R public .deploy-release/public
|
cp -R public .deploy-release/public
|
||||||
cp -R prisma .deploy-release/prisma
|
cp -R prisma .deploy-release/prisma
|
||||||
[ -f .env.production ] && cp .env.production .deploy-release/.env.production || true
|
tar -czf /out/'"$ARTIFACT_NAME"' -C .deploy-release .
|
||||||
tar -czf '"$ARTIFACT_NAME"' -C .deploy-release .
|
|
||||||
'
|
'
|
||||||
|
|
||||||
echo "Artifact created at $ROOT_DIR/$ARTIFACT_NAME"
|
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";
|
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) {
|
export function middleware(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
|
binaryTargets = ["native", "darwin-arm64", "debian-openssl-3.0.x"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
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 { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { ensureFixedUnits, getFixedUnitLockMessage } from "@/features/units/lib/fixed-units";
|
||||||
import { serializeUnit } from "@/features/units/lib/serialize-unit";
|
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 { requireApiAccess } from "@/lib/authorization";
|
||||||
import { resolveMasterCode } from "@/lib/master-code";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
type RouteContext = { params: Promise<{ id: string }> };
|
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) {
|
export async function GET(request: Request, context: RouteContext) {
|
||||||
const auth = requireApiAccess(request);
|
const auth = requireApiAccess(request);
|
||||||
if (!auth.ok) return auth.response;
|
if (!auth.ok) return auth.response;
|
||||||
|
await ensureFixedUnits();
|
||||||
|
|
||||||
const parsedId = parseId((await context.params).id);
|
const parsedId = parseId((await context.params).id);
|
||||||
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
|
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) {
|
export async function PUT(request: Request, context: RouteContext) {
|
||||||
const auth = requireApiAccess(request);
|
const auth = requireApiAccess(request);
|
||||||
if (!auth.ok) return auth.response;
|
if (!auth.ok) return auth.response;
|
||||||
|
void context;
|
||||||
const parsedId = parseId((await context.params).id);
|
return NextResponse.json({ message: getFixedUnitLockMessage() }, { status: 403 });
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(request: Request, context: RouteContext) {
|
export async function DELETE(request: Request, context: RouteContext) {
|
||||||
const auth = requireApiAccess(request);
|
const auth = requireApiAccess(request);
|
||||||
if (!auth.ok) return auth.response;
|
if (!auth.ok) return auth.response;
|
||||||
|
void context;
|
||||||
const parsedId = parseId((await context.params).id);
|
return NextResponse.json({ message: getFixedUnitLockMessage() }, { status: 403 });
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,70 +1,18 @@
|
|||||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { serializeUnit } from "@/features/units/lib/serialize-unit";
|
import { serializeUnit } from "@/features/units/lib/serialize-unit";
|
||||||
import { unitInputSchema } from "@/features/units/schemas/unit.schema";
|
import { ensureFixedUnits, getFixedUnitLockMessage } from "@/features/units/lib/fixed-units";
|
||||||
import { createAuditTrailSafe } from "@/lib/audit-trail";
|
|
||||||
import { resolveMasterCode } from "@/lib/master-code";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { requireApiAccess } from "@/lib/authorization";
|
import { requireApiAccess } from "@/lib/authorization";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const auth = requireApiAccess(request);
|
const auth = requireApiAccess(request);
|
||||||
if (!auth.ok) return auth.response;
|
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) });
|
return NextResponse.json({ data: data.map(serializeUnit) });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const auth = requireApiAccess(request);
|
const auth = requireApiAccess(request);
|
||||||
if (!auth.ok) return auth.response;
|
if (!auth.ok) return auth.response;
|
||||||
const parsed = unitInputSchema.safeParse(await request.json());
|
return NextResponse.json({ message: getFixedUnitLockMessage() }, { status: 403 });
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
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";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
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 { LogoutButton } from "@/components/auth/logout-button";
|
||||||
import { LanguageSwitcher } from "@/components/layout/language-switcher";
|
import { LanguageSwitcher } from "@/components/layout/language-switcher";
|
||||||
|
import { TopbarSearch } from "@/components/layout/topbar-search";
|
||||||
import { useLocale } from "@/components/providers/locale-provider";
|
import { useLocale } from "@/components/providers/locale-provider";
|
||||||
import type { SessionUser } from "@/lib/auth";
|
import type { SessionUser } from "@/lib/auth";
|
||||||
|
|
||||||
@ -19,18 +20,11 @@ export function Topbar({ title, description, user }: TopbarProps) {
|
|||||||
|
|
||||||
return (
|
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">
|
<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="flex flex-1 items-center">
|
<div className="hidden min-w-0 flex-1 lg:block">
|
||||||
<div className="relative hidden w-72 lg:block">
|
<TopbarSearch placeholder={dict.shell.searchPlaceholder} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="ml-auto flex items-center gap-4">
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
<Link
|
<Link
|
||||||
href="/help"
|
href="/help"
|
||||||
|
|||||||
@ -1,26 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { useLocale } from "@/components/providers/locale-provider";
|
import { useLocale } from "@/components/providers/locale-provider";
|
||||||
import { PaginationFooter } from "@/components/shared/pagination-footer";
|
import { PaginationFooter } from "@/components/shared/pagination-footer";
|
||||||
import { useCurrentUser } from "@/hooks/use-current-user";
|
|
||||||
import { usePagination } from "@/hooks/use-pagination";
|
import { usePagination } from "@/hooks/use-pagination";
|
||||||
import type { ApiErrorResponse, DetailResponse } from "@/types/api";
|
|
||||||
import type { UnitRecord } from "@/types/master-data";
|
import type { UnitRecord } from "@/types/master-data";
|
||||||
|
|
||||||
const emptyForm = { code: "", name: "" };
|
|
||||||
|
|
||||||
export function UnitsClient() {
|
export function UnitsClient() {
|
||||||
const { dict } = useLocale();
|
const { dict } = useLocale();
|
||||||
const [items, setItems] = useState<UnitRecord[]>([]);
|
const [items, setItems] = useState<UnitRecord[]>([]);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [form, setForm] = useState(emptyForm);
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { canEditCode } = useCurrentUser();
|
|
||||||
const filteredItems = useMemo(() => {
|
const filteredItems = useMemo(() => {
|
||||||
const keyword = search.trim().toLowerCase();
|
const keyword = search.trim().toLowerCase();
|
||||||
if (!keyword) return items;
|
if (!keyword) return items;
|
||||||
@ -49,94 +41,37 @@ export function UnitsClient() {
|
|||||||
void loadItems();
|
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 (
|
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="ops-card p-6">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="ops-overline">{dict.master.overline}</p>
|
<p className="ops-overline">{dict.master.overline}</p>
|
||||||
<h2 className="mt-2 text-[28px] font-semibold tracking-tight text-ink">
|
<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>
|
</h2>
|
||||||
<p className="mt-3 text-[13px] leading-6 text-slate-500">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{editingId ? <button type="button" onClick={resetForm} className="ops-btn-secondary">{dict.common.cancelEdit}</button> : null}
|
|
||||||
</div>
|
</div>
|
||||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
<div className="mt-6 grid gap-3 md:grid-cols-2">
|
||||||
<Field
|
<div className="rounded-xl border border-line/70 bg-slate-50/70 p-4">
|
||||||
label={dict.master.code}
|
<p className="text-xs font-bold uppercase tracking-[0.08em] text-slate-500">gr</p>
|
||||||
value={form.code}
|
<p className="mt-2 text-lg font-semibold text-ink">Gram</p>
|
||||||
readOnly={!canEditCode}
|
</div>
|
||||||
placeholder={canEditCode ? "UNT00001" : "Auto"}
|
<div className="rounded-xl border border-line/70 bg-slate-50/70 p-4">
|
||||||
onChange={(value) => setForm((current) => ({ ...current, code: value }))}
|
<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>
|
||||||
<Field label={dict.master.name} value={form.name} onChange={(value) => setForm((current) => ({ ...current, name: value }))} />
|
</div>
|
||||||
{error ? <div className="rounded border border-ember/30 bg-ember/10 px-4 py-3 text-sm text-ember">{error}</div> : null}
|
</div>
|
||||||
<button type="submit" disabled={submitting} className="ops-btn-primary">
|
{error ? <div className="mt-5 rounded border border-ember/30 bg-ember/10 px-4 py-3 text-sm text-ember">{error}</div> : null}
|
||||||
{submitting ? dict.common.processing : editingId ? `${dict.master.update} unit` : `${dict.master.add} unit`}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="ops-table-shell">
|
<div className="ops-table-shell">
|
||||||
<div className="ops-section-head">
|
<div className="ops-section-head">
|
||||||
<div>
|
<div>
|
||||||
<p className="ops-title">{dict.master.list} Satuan</p>
|
<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>
|
||||||
<div className="ops-chip-muted">{items.length} {dict.master.dataCount}</div>
|
<div className="ops-chip-muted">{items.length} {dict.master.dataCount}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -161,7 +96,6 @@ export function UnitsClient() {
|
|||||||
<tr>
|
<tr>
|
||||||
<th>{dict.master.code}</th>
|
<th>{dict.master.code}</th>
|
||||||
<th>{dict.master.name}</th>
|
<th>{dict.master.name}</th>
|
||||||
<th>{dict.common.actions}</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -169,12 +103,6 @@ export function UnitsClient() {
|
|||||||
<tr key={item.id}>
|
<tr key={item.id}>
|
||||||
<td className="font-semibold text-ink">{item.code}</td>
|
<td className="font-semibold text-ink">{item.code}</td>
|
||||||
<td>{item.name}</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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -196,30 +124,3 @@ export function UnitsClient() {
|
|||||||
</section>
|
</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,
|
Store,
|
||||||
Waves,
|
Waves,
|
||||||
Truck,
|
Truck,
|
||||||
Ruler,
|
|
||||||
Users
|
Users
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@ -174,14 +173,6 @@ export const primaryNavigation: NavEntry[] = [
|
|||||||
description: "Master skema bagi hasil agen dan perusahaan.",
|
description: "Master skema bagi hasil agen dan perusahaan.",
|
||||||
icon: Percent,
|
icon: Percent,
|
||||||
roles: ["ADMIN", "SYSTEM_ADMIN", "OWNER"]
|
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";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { Eye, EyeOff, Lock, LogIn, User } from "lucide-react";
|
import { Eye, EyeOff, Lock, LogIn, User } from "lucide-react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useState } from "react";
|
||||||
|
|
||||||
import { AppLogo } from "@/components/branding/app-logo";
|
import { AppLogo } from "@/components/branding/app-logo";
|
||||||
|
import { LanguageSwitcher } from "@/components/layout/language-switcher";
|
||||||
import { useLocale } from "@/components/providers/locale-provider";
|
import { useLocale } from "@/components/providers/locale-provider";
|
||||||
|
|
||||||
export function LoginClient() {
|
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">
|
<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="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">
|
<header className="mb-10">
|
||||||
<h1 className="text-[40px] font-semibold tracking-tight text-ink">
|
<h1 className="text-[40px] font-semibold tracking-tight text-ink">
|
||||||
{dict.login.title}
|
{dict.login.title}
|
||||||
@ -231,12 +236,20 @@ export function LoginClient() {
|
|||||||
|
|
||||||
<p className="text-[15px] text-slate-600">
|
<p className="text-[15px] text-slate-600">
|
||||||
{dict.login.noAccount}{" "}
|
{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>
|
</p>
|
||||||
<div className="mt-8 flex flex-wrap justify-center gap-6 text-[12px] font-bold uppercase tracking-[0.08em] text-slate-500">
|
<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>
|
<Link href="/help-public" className="hover:text-moss hover:underline">
|
||||||
<span>{dict.login.privacy}</span>
|
{dict.login.support}
|
||||||
<span>{dict.login.terms}</span>
|
</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>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -43,7 +43,9 @@ export function HelpClient() {
|
|||||||
|
|
||||||
<div className="mt-6 rounded border border-line/70 bg-slate-50 p-4">
|
<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="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>
|
||||||
|
|
||||||
<div className="mt-4 rounded border border-amber-200 bg-amber-50 p-4">
|
<div className="mt-4 rounded border border-amber-200 bg-amber-50 p-4">
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
import { useLocale } from "@/components/providers/locale-provider";
|
import { useLocale } from "@/components/providers/locale-provider";
|
||||||
import { formatCurrencyAmount, formatQuantity } from "@/lib/formatters";
|
import { formatCurrencyAmount, formatQuantity } from "@/lib/formatters";
|
||||||
@ -14,10 +15,15 @@ type LotsClientProps = {
|
|||||||
|
|
||||||
export function LotsClient({ currencyCode }: LotsClientProps) {
|
export function LotsClient({ currencyCode }: LotsClientProps) {
|
||||||
const { dict, locale } = useLocale();
|
const { dict, locale } = useLocale();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const [lots, setLots] = useState<LotListItem[]>([]);
|
const [lots, setLots] = useState<LotListItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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() {
|
async function loadLots() {
|
||||||
setLoading(true);
|
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`;
|
}).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) {
|
function formatCurrency(value: number, locale: AppLocale, currencyCode: string) {
|
||||||
return formatCurrencyAmount(value, locale, currencyCode, 0);
|
return formatCurrencyAmount(value, locale, currencyCode, 0);
|
||||||
}
|
}
|
||||||
@ -53,7 +65,8 @@ export async function getDashboardData(locale: AppLocale): Promise<DashboardData
|
|||||||
include: {
|
include: {
|
||||||
purchase: { select: { agent: { select: { name: true } } } },
|
purchase: { select: { agent: { select: { name: true } } } },
|
||||||
grade: { select: { name: true } },
|
grade: { select: { name: true } },
|
||||||
warehouse: { select: { name: true } }
|
warehouse: { select: { name: true } },
|
||||||
|
unit: { select: { code: true } }
|
||||||
},
|
},
|
||||||
orderBy: [{ receivedAt: "desc" }]
|
orderBy: [{ receivedAt: "desc" }]
|
||||||
}),
|
}),
|
||||||
@ -77,7 +90,16 @@ export async function getDashboardData(locale: AppLocale): Promise<DashboardData
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
adjustmentReason: { select: { category: true } }
|
adjustmentReason: { select: { category: true } },
|
||||||
|
lot: {
|
||||||
|
select: {
|
||||||
|
unit: {
|
||||||
|
select: {
|
||||||
|
code: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
prisma.purchaseLine.findMany({
|
prisma.purchaseLine.findMany({
|
||||||
@ -90,6 +112,11 @@ export async function getDashboardData(locale: AppLocale): Promise<DashboardData
|
|||||||
select: {
|
select: {
|
||||||
qtyOrdered: true,
|
qtyOrdered: true,
|
||||||
subtotal: true,
|
subtotal: true,
|
||||||
|
unit: {
|
||||||
|
select: {
|
||||||
|
code: true
|
||||||
|
}
|
||||||
|
},
|
||||||
purchase: {
|
purchase: {
|
||||||
select: { purchaseDate: true }
|
select: { purchaseDate: true }
|
||||||
}
|
}
|
||||||
@ -103,6 +130,15 @@ export async function getDashboardData(locale: AppLocale): Promise<DashboardData
|
|||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
qtyActualSold: true,
|
qtyActualSold: true,
|
||||||
|
lot: {
|
||||||
|
select: {
|
||||||
|
unit: {
|
||||||
|
select: {
|
||||||
|
code: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
regularSale: {
|
regularSale: {
|
||||||
select: {
|
select: {
|
||||||
saleDate: true,
|
saleDate: true,
|
||||||
@ -117,6 +153,15 @@ export async function getDashboardData(locale: AppLocale): Promise<DashboardData
|
|||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
qtySold: true,
|
qtySold: true,
|
||||||
|
lot: {
|
||||||
|
select: {
|
||||||
|
unit: {
|
||||||
|
select: {
|
||||||
|
code: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
closeDate: true
|
closeDate: true
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@ -140,24 +185,36 @@ export async function getDashboardData(locale: AppLocale): Promise<DashboardData
|
|||||||
|
|
||||||
const activeLots = lots.filter((lot) => lot.status === "ACTIVE");
|
const activeLots = lots.filter((lot) => lot.status === "ACTIVE");
|
||||||
const warehouseCount = new Set(lots.map((lot) => lot.warehouse.name)).size;
|
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
|
const currentMonthPurchaseValue = purchaseLines
|
||||||
.filter((line) => line.purchase.purchaseDate >= currentMonthStart)
|
.filter((line) => line.purchase.purchaseDate >= currentMonthStart)
|
||||||
.reduce((sum, line) => sum + line.subtotal.toNumber(), 0);
|
.reduce((sum, line) => sum + line.subtotal.toNumber(), 0);
|
||||||
const currentMonthPurchaseQty = purchaseLines
|
const currentMonthPurchaseQty = purchaseLines
|
||||||
.filter((line) => line.purchase.purchaseDate >= currentMonthStart)
|
.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(
|
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
|
0
|
||||||
);
|
);
|
||||||
const totalPurchaseQty = purchaseLines.reduce((sum, line) => sum + line.qtyOrdered.toNumber(), 0);
|
|
||||||
const totalRegularSalesQty = regularSaleLines.reduce(
|
const totalRegularSalesQty = regularSaleLines.reduce(
|
||||||
(sum, line) => sum + (line.qtyActualSold?.toNumber() ?? 0),
|
(sum, line) =>
|
||||||
|
sum + convertQtyToKg(line.qtyActualSold?.toNumber() ?? 0, line.lot.unit.code),
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
const totalConsignmentQty = consignmentLines.reduce(
|
const totalConsignmentQty = consignmentLines.reduce(
|
||||||
(sum, line) => sum + line.qtySold.toNumber(),
|
(sum, line) => sum + convertQtyToKg(line.qtySold.toNumber(), line.lot.unit.code),
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
const totalJitSalesQty = jitSaleLines.reduce(
|
const totalJitSalesQty = jitSaleLines.reduce(
|
||||||
@ -217,15 +274,15 @@ export async function getDashboardData(locale: AppLocale): Promise<DashboardData
|
|||||||
? `Umur lot ${agingDays} hari, perlu evaluasi rotasi stok.`
|
? `Umur lot ${agingDays} hari, perlu evaluasi rotasi stok.`
|
||||||
: `Lot age is ${agingDays} days and needs stock rotation review.`
|
: `Lot age is ${agingDays} days and needs stock rotation review.`
|
||||||
: locale === "id"
|
: locale === "id"
|
||||||
? `Stok tersedia tinggal ${formatQty(lot.availableQty.toNumber(), locale)}.`
|
? `Stok tersedia tinggal ${formatQty(convertQtyToKg(lot.availableQty.toNumber(), lot.unit.code), locale)}.`
|
||||||
: `Available stock is only ${formatQty(lot.availableQty.toNumber(), locale)}.`;
|
: `Available stock is only ${formatQty(convertQtyToKg(lot.availableQty.toNumber(), lot.unit.code), locale)}.`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: lot.id.toString(),
|
id: lot.id.toString(),
|
||||||
lotCode: lot.lotCode,
|
lotCode: lot.lotCode,
|
||||||
supplier: lot.purchase?.agent?.name ?? "Pembelian bebas",
|
supplier: lot.purchase?.agent?.name ?? "Pembelian bebas",
|
||||||
item: splitGradeName(lot.grade?.name).gradeName,
|
item: splitGradeName(lot.grade?.name).gradeName,
|
||||||
availableQty: formatQty(lot.availableQty.toNumber(), locale),
|
availableQty: formatQty(convertQtyToKg(lot.availableQty.toNumber(), lot.unit.code), locale),
|
||||||
lotStatus: derivedLotStatus,
|
lotStatus: derivedLotStatus,
|
||||||
attentionStatus,
|
attentionStatus,
|
||||||
reason,
|
reason,
|
||||||
@ -246,8 +303,9 @@ export async function getDashboardData(locale: AppLocale): Promise<DashboardData
|
|||||||
const name = lot.purchase?.agent?.name ?? "Pembelian bebas";
|
const name = lot.purchase?.agent?.name ?? "Pembelian bebas";
|
||||||
const current = partnerMap.get(name) ?? { lotCount: 0, qty: 0, inventoryValue: 0 };
|
const current = partnerMap.get(name) ?? { lotCount: 0, qty: 0, inventoryValue: 0 };
|
||||||
current.lotCount += 1;
|
current.lotCount += 1;
|
||||||
current.qty += lot.availableQty.toNumber();
|
const availableQtyKg = convertQtyToKg(lot.availableQty.toNumber(), lot.unit.code);
|
||||||
current.inventoryValue += lot.availableQty.toNumber() * lot.unitCost.toNumber();
|
current.qty += availableQtyKg;
|
||||||
|
current.inventoryValue += availableQtyKg * lot.unitCost.toNumber();
|
||||||
partnerMap.set(name, current);
|
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