Update mobile purchase flow and locale formatting
This commit is contained in:
@ -11,7 +11,6 @@ export async function GET(request: Request) {
|
||||
OWNER: [
|
||||
"dashboard",
|
||||
"lots",
|
||||
"receipts",
|
||||
"washing",
|
||||
"stock_adjustments",
|
||||
"lot_transformations",
|
||||
@ -33,7 +32,6 @@ export async function GET(request: Request) {
|
||||
WAREHOUSE: [
|
||||
"dashboard",
|
||||
"lots",
|
||||
"receipts",
|
||||
"washing",
|
||||
"stock_adjustments"
|
||||
],
|
||||
@ -54,7 +52,6 @@ export async function GET(request: Request) {
|
||||
ADMIN: [
|
||||
"dashboard",
|
||||
"lots",
|
||||
"receipts",
|
||||
"washing",
|
||||
"stock_adjustments",
|
||||
"lot_transformations",
|
||||
@ -69,7 +66,6 @@ export async function GET(request: Request) {
|
||||
SYSTEM_ADMIN: [
|
||||
"dashboard",
|
||||
"lots",
|
||||
"receipts",
|
||||
"washing",
|
||||
"stock_adjustments",
|
||||
"lot_transformations",
|
||||
|
||||
@ -7,6 +7,7 @@ import { requireApiAccess } from "@/lib/authorization";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { recalculatePurchaseRealizationSummary } from "@/features/purchase-realization/lib/recalculate-purchase-realization-summary";
|
||||
import { generateLotCode } from "@/features/receipts/lib/generate-lot-code";
|
||||
import { generateReceiptNo } from "@/features/receipts/lib/generate-receipt-no";
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
type SubmitTx = Prisma.TransactionClient & {
|
||||
@ -36,6 +37,12 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
include: {
|
||||
agent: { select: { id: true, name: true } },
|
||||
profitShareScheme: { select: { id: true, shareAgent: true } },
|
||||
receipts: {
|
||||
select: {
|
||||
id: true,
|
||||
receiptNo: true
|
||||
}
|
||||
},
|
||||
lines: {
|
||||
include: {
|
||||
grade: true,
|
||||
@ -52,6 +59,13 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
return NextResponse.json({ message: "Purchase sudah disubmit" }, { status: 409 });
|
||||
}
|
||||
|
||||
if (purchase.receipts.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ message: `Purchase sudah memiliki receipt ${purchase.receipts[0]?.receiptNo ?? ""}`.trim() },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!purchase.receivedByEmployeeId) {
|
||||
return NextResponse.json({ message: "Penerima belum dipilih" }, { status: 400 });
|
||||
}
|
||||
@ -61,6 +75,8 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
}
|
||||
|
||||
const receivedAt = purchase.receivedAt;
|
||||
const receiptDate = new Date(receivedAt.toISOString().slice(0, 10));
|
||||
const receiptNo = await generateReceiptNo(receiptDate);
|
||||
const sourceCode = purchase.agent?.name || purchase.purchaseNo;
|
||||
const enteredQtyLines = purchase.lines.filter((line) => Number(line.qtyAccepted.toNumber()) > 0);
|
||||
if (enteredQtyLines.length === 0) {
|
||||
@ -92,8 +108,45 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
throw new Error("Lot untuk purchase ini sudah pernah digenerate");
|
||||
}
|
||||
|
||||
const existingReceiptCount = await tx.receipt.count({ where: { purchaseId } });
|
||||
if (existingReceiptCount > 0) {
|
||||
throw new Error("Receipt untuk purchase ini sudah pernah dibuat");
|
||||
}
|
||||
|
||||
const receipt = await tx.receipt.create({
|
||||
data: {
|
||||
receiptNo,
|
||||
purchaseId: purchase.id,
|
||||
receiptDate,
|
||||
status: "FINALIZED",
|
||||
notes: purchase.notes || null,
|
||||
receivedById: BigInt(auth.user.id)
|
||||
}
|
||||
});
|
||||
|
||||
await tx.receiptLine.createMany({
|
||||
data: enteredQtyLines.map((line) => ({
|
||||
receiptId: receipt.id,
|
||||
purchaseLineId: line.id,
|
||||
gradeId: line.gradeId,
|
||||
qtyReceived: line.qtyReceived,
|
||||
qtyAccepted: line.qtyAccepted,
|
||||
qtyRejected: line.qtyRejected,
|
||||
unitId: line.unitId,
|
||||
unitCost: line.unitCost,
|
||||
warehouseId: line.warehouseId!,
|
||||
warehouseLocationId: line.warehouseLocationId,
|
||||
notes: line.notes || null
|
||||
}))
|
||||
});
|
||||
|
||||
const receiptLines = await tx.receiptLine.findMany({
|
||||
where: { receiptId: receipt.id },
|
||||
orderBy: { id: "asc" }
|
||||
});
|
||||
|
||||
const lots = [];
|
||||
for (const line of enteredQtyLines) {
|
||||
for (const line of receiptLines) {
|
||||
if (!line.warehouseId) {
|
||||
throw new Error("Warehouse belum diisi pada salah satu baris pembelian");
|
||||
}
|
||||
@ -107,7 +160,9 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
sourceType: "PURCHASE",
|
||||
sourceRefId: purchaseId,
|
||||
purchaseId: purchase.id,
|
||||
purchaseLineId: line.id,
|
||||
purchaseLineId: line.purchaseLineId,
|
||||
receiptId: receipt.id,
|
||||
receiptLineId: line.id,
|
||||
gradeId: line.gradeId,
|
||||
warehouseId: line.warehouseId,
|
||||
warehouseLocationId: line.warehouseLocationId,
|
||||
@ -158,7 +213,7 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
amountProfit: new Prisma.Decimal(0),
|
||||
agentSharePercentSnapshot: purchase.profitShareScheme?.shareAgent ?? null,
|
||||
agentAmount: new Prisma.Decimal(0),
|
||||
notes: `Opening realization dari purchase line ${line.id.toString()}`
|
||||
notes: `Opening realization dari purchase line ${line.purchaseLineId.toString()}`
|
||||
}
|
||||
});
|
||||
|
||||
@ -181,6 +236,10 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
|
||||
return {
|
||||
purchase: updated,
|
||||
receipt: {
|
||||
id: receipt.id.toString(),
|
||||
receipt_no: receipt.receiptNo
|
||||
},
|
||||
lots
|
||||
};
|
||||
});
|
||||
@ -193,10 +252,14 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
method: request.method,
|
||||
pathname: new URL(request.url).pathname,
|
||||
statusCode: 200,
|
||||
summary: `Purchase ${purchase.purchaseNo} disubmit + lot dibuat`,
|
||||
summary: `Purchase ${purchase.purchaseNo} disubmit + receipt + lot dibuat`,
|
||||
metadata: buildAuditChangeMetadata(
|
||||
{ status: purchase.status, lot_count: 0 },
|
||||
{ status: generated.purchase.status, lot_count: generated.lots.length }
|
||||
{ status: purchase.status, lot_count: 0, receipt_no: null },
|
||||
{
|
||||
status: generated.purchase.status,
|
||||
lot_count: generated.lots.length,
|
||||
receipt_no: generated.receipt.receipt_no
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
@ -208,7 +271,8 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
{
|
||||
success: true,
|
||||
status: generated.purchase.status,
|
||||
lot_count: generated.lots.length
|
||||
lot_count: generated.lots.length,
|
||||
receipt: generated.receipt
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
import { AppShell } from "@/components/layout/app-shell";
|
||||
import { LotMixingClient } from "@/features/lot-transformations/components/lot-mixing-client";
|
||||
import { getAppSettings } from "@/lib/app-settings";
|
||||
|
||||
export default async function SortingPage() {
|
||||
const settings = await getAppSettings();
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
pathname="/sorting"
|
||||
title="Mixing Lot / Ubah Grade"
|
||||
description="Campurkan beberapa lot aktif menjadi lot hasil baru dengan grade baru, lalu simpan jejak input-output secara penuh."
|
||||
>
|
||||
<LotMixingClient />
|
||||
<LotMixingClient currencyCode={settings.currency_code} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
import { AppShell } from "@/components/layout/app-shell";
|
||||
import { WashingClient } from "@/features/washing/components/washing-client";
|
||||
import { getAppSettings } from "@/lib/app-settings";
|
||||
|
||||
export default async function WashingPage() {
|
||||
const settings = await getAppSettings();
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
pathname="/washing"
|
||||
title="Pencucian"
|
||||
description="Kirim lot ke tempat cuci, simpan biaya dan resi, lalu selesaikan hasil cuci dengan pembaruan berat, grade, dan lokasi gudang."
|
||||
>
|
||||
<WashingClient />
|
||||
<WashingClient currencyCode={settings.currency_code} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useLocale } from "@/components/providers/locale-provider";
|
||||
import { SearchableSelectField } from "@/components/shared/searchable-select-field";
|
||||
import { formatCurrencyAmount, formatDecimal, formatKilogram } from "@/lib/formatters";
|
||||
import type { ApiErrorResponse, DetailResponse } from "@/types/api";
|
||||
import type {
|
||||
GradeRecord,
|
||||
@ -65,7 +66,7 @@ const createEmptyForm = (): TransformationForm => ({
|
||||
outputs: [createEmptyOutput()]
|
||||
});
|
||||
|
||||
export function LotMixingClient() {
|
||||
export function LotMixingClient({ currencyCode }: { currencyCode: string }) {
|
||||
const { locale } = useLocale();
|
||||
const [lots, setLots] = useState<LotListItem[]>([]);
|
||||
const [transformations, setTransformations] = useState<LotTransformationListItem[]>([]);
|
||||
@ -478,8 +479,8 @@ export function LotMixingClient() {
|
||||
<p className="text-sm font-semibold text-ink">{locale === "id" ? "Penanganan sisa regrade" : "Regrade remainder handling"}</p>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
{locale === "id"
|
||||
? <>Masih ada sisa <span className="font-semibold text-ink">{unusedSourceRemainderQty.toFixed(3)} kg</span> dari lot sumber yang tidak dipakai untuk regrade. Pilih apakah sisa tetap berada di grade asal atau dicatat sebagai susut.</>
|
||||
: <>There is still <span className="font-semibold text-ink">{unusedSourceRemainderQty.toFixed(3)} kg</span> remaining from the source lot that was not used for regrade. Choose whether the remainder stays in the original grade or is recorded as shrinkage.</>}
|
||||
? <>Masih ada sisa <span className="font-semibold text-ink">{formatKilogram(unusedSourceRemainderQty, locale)}</span> dari lot sumber yang tidak dipakai untuk regrade. Pilih apakah sisa tetap berada di grade asal atau dicatat sebagai susut.</>
|
||||
: <>There is still <span className="font-semibold text-ink">{formatKilogram(unusedSourceRemainderQty, locale)}</span> remaining from the source lot that was not used for regrade. Choose whether the remainder stays in the original grade or is recorded as shrinkage.</>}
|
||||
</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
<label className="flex items-start gap-3 rounded border border-line/70 bg-white px-4 py-3">
|
||||
@ -499,8 +500,8 @@ export function LotMixingClient() {
|
||||
<div className="font-medium text-ink">{locale === "id" ? "Tetap di grade sebelumnya" : "Keep in previous grade"}</div>
|
||||
<div className="mt-1">
|
||||
{locale === "id"
|
||||
? `Sisa ${unusedSourceRemainderQty.toFixed(3)} kg akan tetap berada di lot asal${selectedRegradeSourceLot ? ` (${selectedRegradeSourceLot.grade})` : ""}.`
|
||||
: `The remaining ${unusedSourceRemainderQty.toFixed(3)} kg will stay in the source lot${selectedRegradeSourceLot ? ` (${selectedRegradeSourceLot.grade})` : ""}.`}
|
||||
? `Sisa ${formatKilogram(unusedSourceRemainderQty, locale)} akan tetap berada di lot asal${selectedRegradeSourceLot ? ` (${selectedRegradeSourceLot.grade})` : ""}.`
|
||||
: `The remaining ${formatKilogram(unusedSourceRemainderQty, locale)} will stay in the source lot${selectedRegradeSourceLot ? ` (${selectedRegradeSourceLot.grade})` : ""}.`}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
@ -521,8 +522,8 @@ export function LotMixingClient() {
|
||||
<div className="font-medium text-ink">{locale === "id" ? "Catat sebagai susut" : "Record as shrinkage"}</div>
|
||||
<div className="mt-1">
|
||||
{locale === "id"
|
||||
? `Sisa ${unusedSourceRemainderQty.toFixed(3)} kg akan ditambahkan ke shrinkage lot sumber.`
|
||||
: `The remaining ${unusedSourceRemainderQty.toFixed(3)} kg will be added to source-lot shrinkage.`}
|
||||
? `Sisa ${formatKilogram(unusedSourceRemainderQty, locale)} akan ditambahkan ke shrinkage lot sumber.`
|
||||
: `The remaining ${formatKilogram(unusedSourceRemainderQty, locale)} will be added to source-lot shrinkage.`}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
@ -615,8 +616,8 @@ export function LotMixingClient() {
|
||||
<p className="text-sm font-semibold text-ink">{locale === "id" ? "Penanganan selisih hasil ubah grade" : "Regrade output difference handling"}</p>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
{locale === "id"
|
||||
? <>Total output masih kurang <span className="font-semibold text-ink">{outputLossQty.toFixed(3)} kg</span> dari qty yang dipakai untuk regrade. Anda tidak bisa menyimpan sebelum selisih ini ditangani.</>
|
||||
: <>Total output is still short by <span className="font-semibold text-ink">{outputLossQty.toFixed(3)} kg</span> compared with the qty used for regrade. You cannot save before this difference is handled.</>}
|
||||
? <>Total output masih kurang <span className="font-semibold text-ink">{formatKilogram(outputLossQty, locale)}</span> dari qty yang dipakai untuk regrade. Anda tidak bisa menyimpan sebelum selisih ini ditangani.</>
|
||||
: <>Total output is still short by <span className="font-semibold text-ink">{formatKilogram(outputLossQty, locale)}</span> compared with the qty used for regrade. You cannot save before this difference is handled.</>}
|
||||
</p>
|
||||
<ul className="mt-3 list-disc space-y-1 pl-5 text-sm text-slate-600">
|
||||
<li>{locale === "id" ? "tambahkan lot hasil baru agar total output pas, atau" : "add a new output lot so total output matches, or"}</li>
|
||||
@ -640,8 +641,8 @@ export function LotMixingClient() {
|
||||
<div className="font-medium text-ink">{locale === "id" ? "Catat sebagai susut/lost" : "Record as shrinkage/loss"}</div>
|
||||
<div className="mt-1">
|
||||
{locale === "id"
|
||||
? `Selisih ${outputLossQty.toFixed(3)} kg akan dicatat sebagai shrinkage pada lot sumber.`
|
||||
: `The ${outputLossQty.toFixed(3)} kg difference will be recorded as shrinkage on the source lot.`}
|
||||
? `Selisih ${formatKilogram(outputLossQty, locale)} akan dicatat sebagai shrinkage pada lot sumber.`
|
||||
: `The ${formatKilogram(outputLossQty, locale)} difference will be recorded as shrinkage on the source lot.`}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
@ -650,9 +651,9 @@ export function LotMixingClient() {
|
||||
) : null}
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-3">
|
||||
<SummaryMetric label={locale === "id" ? "Total Qty Input" : "Total Input Qty"} value={inputTotal.toFixed(3)} />
|
||||
<SummaryMetric label={locale === "id" ? "Total Qty Output" : "Total Output Qty"} value={outputTotal.toFixed(3)} />
|
||||
<SummaryMetric label={locale === "id" ? "Selisih / Shrinkage" : "Difference / Shrinkage"} value={outputLossQty.toFixed(3)} />
|
||||
<SummaryMetric label={locale === "id" ? "Total Qty Input" : "Total Input Qty"} value={formatDecimal(inputTotal, locale)} />
|
||||
<SummaryMetric label={locale === "id" ? "Total Qty Output" : "Total Output Qty"} value={formatDecimal(outputTotal, locale)} />
|
||||
<SummaryMetric label={locale === "id" ? "Selisih / Shrinkage" : "Difference / Shrinkage"} value={formatDecimal(outputLossQty, locale)} />
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
@ -704,8 +705,8 @@ export function LotMixingClient() {
|
||||
<tr key={item.id}>
|
||||
<td className="font-semibold text-ink">{item.transformation_no}</td>
|
||||
<td className="text-slate-600">{item.transformation_type}</td>
|
||||
<td className="text-slate-600">{item.total_input_qty.toFixed(3)}</td>
|
||||
<td className="text-slate-600">{item.total_output_qty.toFixed(3)}</td>
|
||||
<td className="text-slate-600">{formatDecimal(item.total_input_qty, locale)}</td>
|
||||
<td className="text-slate-600">{formatDecimal(item.total_output_qty, locale)}</td>
|
||||
<td>
|
||||
<span className="ops-chip-active">{item.status}</span>
|
||||
</td>
|
||||
@ -744,7 +745,7 @@ export function LotMixingClient() {
|
||||
</div>
|
||||
{selectedTransformation.remainder_qty ? (
|
||||
<div className="mt-2 text-sm text-slate-600">
|
||||
{locale === "id" ? "Sisa" : "Remainder"}: {selectedTransformation.remainder_qty.toFixed(3)} kg ·{" "}
|
||||
{locale === "id" ? "Sisa" : "Remainder"}: {formatKilogram(selectedTransformation.remainder_qty, locale)} ·{" "}
|
||||
{selectedTransformation.remainder_mode === "KEEP_SOURCE_GRADE"
|
||||
? locale === "id" ? "tetap di grade asal" : "kept in original grade"
|
||||
: selectedTransformation.remainder_mode === "SHRINKAGE"
|
||||
@ -754,7 +755,7 @@ export function LotMixingClient() {
|
||||
) : null}
|
||||
{selectedTransformation.processing_loss_qty ? (
|
||||
<div className="mt-2 text-sm text-slate-600">
|
||||
{locale === "id" ? "Selisih output" : "Output difference"}: {selectedTransformation.processing_loss_qty.toFixed(3)} kg ·{" "}
|
||||
{locale === "id" ? "Selisih output" : "Output difference"}: {formatKilogram(selectedTransformation.processing_loss_qty, locale)} ·{" "}
|
||||
{selectedTransformation.processing_loss_mode === "SHRINKAGE"
|
||||
? locale === "id" ? "dicatat sebagai susut/lost" : "recorded as shrinkage/loss"
|
||||
: "-"}
|
||||
@ -817,7 +818,7 @@ export function LotMixingClient() {
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-slate-600">
|
||||
{output.result_lot.grade} ·{" "}
|
||||
{locale === "id" ? "Biaya Rp" : "Cost Rp"} {output.unit_cost.toLocaleString(locale === "id" ? "id-ID" : "en-US")}
|
||||
{locale === "id" ? "Biaya" : "Cost"} {formatCurrencyAmount(output.unit_cost, locale, currencyCode, 0)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -7,6 +7,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useLocale } from "@/components/providers/locale-provider";
|
||||
import { SearchableSelectField } from "@/components/shared/searchable-select-field";
|
||||
import { formatCurrencyAmount } from "@/lib/formatters";
|
||||
import type { ApiErrorResponse, DetailResponse } from "@/types/api";
|
||||
import type { LotListItem } from "@/types/lot";
|
||||
import type {
|
||||
@ -61,7 +62,7 @@ async function parseJsonResponse<T>(response: Response, context: string): Promis
|
||||
}
|
||||
}
|
||||
|
||||
export function WashingClient() {
|
||||
export function WashingClient({ currencyCode }: { currencyCode: string }) {
|
||||
const { dict, locale } = useLocale();
|
||||
const searchParams = useSearchParams();
|
||||
const requestedId = searchParams.get("id");
|
||||
@ -540,7 +541,7 @@ export function WashingClient() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="text-slate-600">
|
||||
{new Intl.NumberFormat(locale === "id" ? "id-ID" : "en-US").format(item.washing_cost)}
|
||||
{formatCurrencyAmount(item.washing_cost, locale, currencyCode, 0)}
|
||||
</td>
|
||||
<td>
|
||||
<span className={item.status === "DONE" ? "ops-chip-active" : "ops-chip-muted"}>
|
||||
|
||||
@ -19,6 +19,10 @@ export function formatQuantity(
|
||||
return unitCode ? `${formatted} ${unitCode}` : formatted;
|
||||
}
|
||||
|
||||
export function formatKilogram(value: number, locale: string, maximumFractionDigits = 3) {
|
||||
return formatQuantity(value, locale, "kg", maximumFractionDigits);
|
||||
}
|
||||
|
||||
export function formatCurrencyAmount(
|
||||
value: number,
|
||||
locale: string,
|
||||
|
||||
Reference in New Issue
Block a user