Update seller product actions and stock price flow
This commit is contained in:
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Suspense, useEffect, useState } from "react";
|
import { Suspense, useEffect, useRef, useState } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useLanguage } from "@/lib/i18n-context";
|
import { useLanguage } from "@/lib/i18n-context";
|
||||||
|
|
||||||
@ -32,6 +32,46 @@ interface ProductRow {
|
|||||||
|
|
||||||
type ProductState = "UNPUBLISHED" | "DELETED_BY_SELLER" | "DELETED_BY_ADMIN" | string;
|
type ProductState = "UNPUBLISHED" | "DELETED_BY_SELLER" | "DELETED_BY_ADMIN" | string;
|
||||||
|
|
||||||
|
interface ProductWarehouseRef {
|
||||||
|
id?: string | number | null;
|
||||||
|
warehouseId?: string | number | null;
|
||||||
|
stock?: string | number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductMeasurementRef {
|
||||||
|
id?: string | number | null;
|
||||||
|
productMeasurementId?: string | number | null;
|
||||||
|
price?: string | number | null;
|
||||||
|
warehouses?: ProductWarehouseRef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductModelRef {
|
||||||
|
id?: string | number | null;
|
||||||
|
productModelId?: string | number | null;
|
||||||
|
isMeasurement?: boolean | null;
|
||||||
|
price?: string | number | null;
|
||||||
|
warehouses?: ProductWarehouseRef[];
|
||||||
|
productMeasurements?: ProductMeasurementRef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductDetailRef {
|
||||||
|
productModels?: ProductModelRef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StockPriceTarget {
|
||||||
|
product: ProductRow;
|
||||||
|
currentPrice: number;
|
||||||
|
currentStock: number;
|
||||||
|
nextPrice: string;
|
||||||
|
nextStock: string;
|
||||||
|
productModelId: string;
|
||||||
|
productMeasurementId: string;
|
||||||
|
warehouseId: string;
|
||||||
|
loading: boolean;
|
||||||
|
submitting: boolean;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
function getToken() {
|
function getToken() {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return "";
|
return "";
|
||||||
@ -60,6 +100,83 @@ function marketClasses(market: string) {
|
|||||||
: "bg-tertiary-fixed text-on-tertiary-fixed";
|
: "bg-tertiary-fixed text-on-tertiary-fixed";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toNumber(value: string | number | null | undefined) {
|
||||||
|
const parsed = Number(value ?? 0);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toId(value: string | number | null | undefined) {
|
||||||
|
if (value === null || value === undefined) return "";
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrencyValue(value: number) {
|
||||||
|
return `Rp ${new Intl.NumberFormat("id-ID").format(value)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveStockPriceFields(product: ProductDetailRef) {
|
||||||
|
const models = Array.isArray(product.productModels) ? product.productModels : [];
|
||||||
|
const model = models[0];
|
||||||
|
|
||||||
|
if (!model) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const productModelId = toId(model.id ?? model.productModelId);
|
||||||
|
const modelWarehouses = Array.isArray(model.warehouses) ? model.warehouses : [];
|
||||||
|
const modelWarehouse = modelWarehouses[0];
|
||||||
|
const modelWarehouseId = toId(modelWarehouse?.id ?? modelWarehouse?.warehouseId);
|
||||||
|
|
||||||
|
if (!productModelId || !modelWarehouseId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.isMeasurement === false) {
|
||||||
|
return {
|
||||||
|
productModelId,
|
||||||
|
productMeasurementId: "",
|
||||||
|
warehouseId: modelWarehouseId,
|
||||||
|
currentPrice: toNumber(model.price),
|
||||||
|
currentStock: toNumber(modelWarehouse?.stock),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const measurements = Array.isArray(model.productMeasurements)
|
||||||
|
? model.productMeasurements
|
||||||
|
: [];
|
||||||
|
const measurement = measurements[0];
|
||||||
|
|
||||||
|
if (!measurement) {
|
||||||
|
return {
|
||||||
|
productModelId,
|
||||||
|
productMeasurementId: "",
|
||||||
|
warehouseId: modelWarehouseId,
|
||||||
|
currentPrice: toNumber(model.price),
|
||||||
|
currentStock: toNumber(modelWarehouse?.stock),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const measurementWarehouses = Array.isArray(measurement.warehouses)
|
||||||
|
? measurement.warehouses
|
||||||
|
: [];
|
||||||
|
const warehouse = measurementWarehouses[0] || modelWarehouse;
|
||||||
|
const warehouseId = toId(warehouse?.id ?? warehouse?.warehouseId);
|
||||||
|
|
||||||
|
if (!warehouseId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
productModelId,
|
||||||
|
productMeasurementId: toId(
|
||||||
|
measurement.id ?? measurement.productMeasurementId
|
||||||
|
),
|
||||||
|
warehouseId,
|
||||||
|
currentPrice: toNumber(measurement.price ?? model.price),
|
||||||
|
currentStock: toNumber(warehouse?.stock),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function tabFromQuery(tab: string | null): TabLabel {
|
function tabFromQuery(tab: string | null): TabLabel {
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case "draft":
|
case "draft":
|
||||||
@ -163,6 +280,134 @@ function ConfirmActionModal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StockPriceModal({
|
||||||
|
state,
|
||||||
|
d,
|
||||||
|
onCancel,
|
||||||
|
onPriceChange,
|
||||||
|
onStockChange,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
state: StockPriceTarget;
|
||||||
|
d: {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
productLabel: string;
|
||||||
|
currentPrice: string;
|
||||||
|
currentStock: string;
|
||||||
|
newPrice: string;
|
||||||
|
newStock: string;
|
||||||
|
cancel: string;
|
||||||
|
confirm: string;
|
||||||
|
processing: string;
|
||||||
|
};
|
||||||
|
onCancel: () => void;
|
||||||
|
onPriceChange: (value: string) => void;
|
||||||
|
onStockChange: (value: string) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onCancel} />
|
||||||
|
<div className="relative w-full max-w-lg rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-2xl">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-secondary-container text-on-secondary-container">
|
||||||
|
<span className="material-symbols-outlined text-2xl">price_change</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-black text-on-surface font-headline">{d.title}</h2>
|
||||||
|
<p className="mt-1 text-sm text-on-surface-variant">{d.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 rounded-xl bg-surface-container-low p-4">
|
||||||
|
<p className="mb-1 text-[10px] font-black uppercase tracking-widest text-outline">{d.productLabel}</p>
|
||||||
|
<p className="text-sm font-bold text-on-surface">{state.product.name}</p>
|
||||||
|
<p className="mt-0.5 text-xs text-outline">ID: {state.product.id}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="rounded-xl border border-outline-variant/15 bg-surface-container-low p-4">
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-widest text-outline">{d.currentPrice}</p>
|
||||||
|
<p className="mt-2 text-lg font-black text-on-surface">
|
||||||
|
{state.loading ? d.processing : formatCurrencyValue(state.currentPrice)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-outline-variant/15 bg-surface-container-low p-4">
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-widest text-outline">{d.currentStock}</p>
|
||||||
|
<p className="mt-2 text-lg font-black text-on-surface">
|
||||||
|
{state.loading ? d.processing : state.currentStock}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-black uppercase tracking-[0.18em] text-outline">
|
||||||
|
{d.newPrice}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={state.nextPrice}
|
||||||
|
onChange={(event) => onPriceChange(event.target.value)}
|
||||||
|
disabled={state.loading || state.submitting}
|
||||||
|
className="w-full rounded-xl border border-outline-variant/20 bg-surface px-4 py-3 text-sm font-semibold text-on-surface outline-none transition-colors focus:border-primary disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-black uppercase tracking-[0.18em] text-outline">
|
||||||
|
{d.newStock}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
value={state.nextStock}
|
||||||
|
onChange={(event) => onStockChange(event.target.value)}
|
||||||
|
disabled={state.loading || state.submitting}
|
||||||
|
className="w-full rounded-xl border border-outline-variant/20 bg-surface px-4 py-3 text-sm font-semibold text-on-surface outline-none transition-colors focus:border-primary disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.error ? (
|
||||||
|
<div className="mt-4 rounded-xl border border-error/20 bg-error-container px-4 py-3 text-sm font-semibold text-on-error-container">
|
||||||
|
{state.error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-6 flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={state.submitting}
|
||||||
|
className="flex-1 rounded-xl border border-outline-variant/30 px-4 py-3 text-sm font-black text-on-surface transition-colors hover:bg-surface-container-low disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{d.cancel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={state.loading || state.submitting}
|
||||||
|
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-primary px-4 py-3 text-sm font-black text-white transition-colors hover:bg-primary/90 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{state.submitting ? (
|
||||||
|
<>
|
||||||
|
<span className="material-symbols-outlined animate-spin text-base">progress_activity</span>
|
||||||
|
{d.processing}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
d.confirm
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ProductsPageInner() {
|
function ProductsPageInner() {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const p = t.dashboard.products;
|
const p = t.dashboard.products;
|
||||||
@ -188,12 +433,42 @@ function ProductsPageInner() {
|
|||||||
const [unpublishing, setUnpublishing] = useState(false);
|
const [unpublishing, setUnpublishing] = useState(false);
|
||||||
const [publishingId, setPublishingId] = useState<string | null>(null);
|
const [publishingId, setPublishingId] = useState<string | null>(null);
|
||||||
const [restoringId, setRestoringId] = useState<string | null>(null);
|
const [restoringId, setRestoringId] = useState<string | null>(null);
|
||||||
|
const [stockPriceTarget, setStockPriceTarget] = useState<StockPriceTarget | null>(null);
|
||||||
|
const [openActionMenuId, setOpenActionMenuId] = useState<string | null>(null);
|
||||||
|
const actionMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// Reset to page 1 when tab changes
|
// Reset to page 1 when tab changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}, [tab]);
|
}, [tab]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOpenActionMenuId(null);
|
||||||
|
}, [tab, page]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handlePointerDown(event: MouseEvent) {
|
||||||
|
if (!actionMenuRef.current) return;
|
||||||
|
if (!actionMenuRef.current.contains(event.target as Node)) {
|
||||||
|
setOpenActionMenuId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEscape(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
setOpenActionMenuId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handlePointerDown);
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handlePointerDown);
|
||||||
|
document.removeEventListener("keydown", handleEscape);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadProducts() {
|
async function loadProducts() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -232,7 +507,19 @@ function ProductsPageInner() {
|
|||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
const isDraft = tab === "draft";
|
const isDraft = tab === "draft";
|
||||||
const url = `/api/products/${deleteTarget.id}${isDraft ? "?draft=1" : ""}`;
|
const isInReview = tab === "in-review";
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
|
if (isDraft) {
|
||||||
|
searchParams.set("draft", "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInReview) {
|
||||||
|
searchParams.set("review", "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = searchParams.toString();
|
||||||
|
const url = `/api/products/${deleteTarget.id}${query ? `?${query}` : ""}`;
|
||||||
await fetch(url, { method: "DELETE", headers: { "x-auth-token": getToken() } });
|
await fetch(url, { method: "DELETE", headers: { "x-auth-token": getToken() } });
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
@ -267,6 +554,7 @@ function ProductsPageInner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleRestore(productId: string) {
|
async function handleRestore(productId: string) {
|
||||||
|
setOpenActionMenuId(null);
|
||||||
setRestoringId(productId);
|
setRestoringId(productId);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/products/${productId}?action=restore`, {
|
const res = await fetch(`/api/products/${productId}?action=restore`, {
|
||||||
@ -288,6 +576,7 @@ function ProductsPageInner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handlePublish(productId: string) {
|
async function handlePublish(productId: string) {
|
||||||
|
setOpenActionMenuId(null);
|
||||||
setPublishingId(productId);
|
setPublishingId(productId);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/products/${productId}?action=publish`, {
|
const res = await fetch(`/api/products/${productId}?action=publish`, {
|
||||||
@ -309,6 +598,143 @@ function ProductsPageInner() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openStockPriceModal(product: ProductRow) {
|
||||||
|
setOpenActionMenuId(null);
|
||||||
|
const isDraft = tab === "draft";
|
||||||
|
const isInReview = tab === "in-review";
|
||||||
|
const requestParams = new URLSearchParams();
|
||||||
|
if (isDraft) requestParams.set("draft", "1");
|
||||||
|
if (isInReview) requestParams.set("review", "1");
|
||||||
|
const query = requestParams.toString();
|
||||||
|
|
||||||
|
setStockPriceTarget({
|
||||||
|
product,
|
||||||
|
currentPrice: product.minPrice || 0,
|
||||||
|
currentStock: product.totalStock || 0,
|
||||||
|
nextPrice: String(product.minPrice || 0),
|
||||||
|
nextStock: String(product.totalStock || 0),
|
||||||
|
productModelId: "",
|
||||||
|
productMeasurementId: "",
|
||||||
|
warehouseId: "",
|
||||||
|
loading: true,
|
||||||
|
submitting: false,
|
||||||
|
error: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/products/${product.id}${query ? `?${query}` : ""}`,
|
||||||
|
{ headers: { "x-auth-token": getToken() } }
|
||||||
|
);
|
||||||
|
const result = await res.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result?.responseDesc || p.stockPriceDialog.loadError);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = resolveStockPriceFields(result?.data || result);
|
||||||
|
|
||||||
|
if (!resolved) {
|
||||||
|
throw new Error(p.stockPriceDialog.targetError);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStockPriceTarget((current) =>
|
||||||
|
current && current.product.id === product.id
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
...resolved,
|
||||||
|
nextPrice: String(resolved.currentPrice),
|
||||||
|
nextStock: String(resolved.currentStock),
|
||||||
|
loading: false,
|
||||||
|
}
|
||||||
|
: current
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
setStockPriceTarget((current) =>
|
||||||
|
current && current.product.id === product.id
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
loading: false,
|
||||||
|
error:
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: p.stockPriceDialog.loadError,
|
||||||
|
}
|
||||||
|
: current
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmitStockPrice() {
|
||||||
|
if (!stockPriceTarget) return;
|
||||||
|
|
||||||
|
const price = Number(stockPriceTarget.nextPrice);
|
||||||
|
const stock = Number(stockPriceTarget.nextStock);
|
||||||
|
|
||||||
|
if (!Number.isFinite(price) || price < 0 || !Number.isFinite(stock) || stock < 0) {
|
||||||
|
setStockPriceTarget((current) =>
|
||||||
|
current
|
||||||
|
? { ...current, error: p.stockPriceDialog.invalidValueError }
|
||||||
|
: current
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!stockPriceTarget.productModelId ||
|
||||||
|
!stockPriceTarget.warehouseId
|
||||||
|
) {
|
||||||
|
setStockPriceTarget((current) =>
|
||||||
|
current
|
||||||
|
? { ...current, error: p.stockPriceDialog.targetError }
|
||||||
|
: current
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStockPriceTarget((current) =>
|
||||||
|
current ? { ...current, submitting: true, error: "" } : current
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/products/stock-price", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-auth-token": getToken(),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
productModelId: stockPriceTarget.productModelId,
|
||||||
|
productMeasurementId: stockPriceTarget.productMeasurementId || null,
|
||||||
|
warehouseId: stockPriceTarget.warehouseId,
|
||||||
|
price,
|
||||||
|
stock,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const result = await res.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result?.responseDesc || p.stockPriceDialog.updateError);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStockPriceTarget(null);
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
setStockPriceTarget((current) =>
|
||||||
|
current
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
submitting: false,
|
||||||
|
error:
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: p.stockPriceDialog.updateError,
|
||||||
|
}
|
||||||
|
: current
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getProductState(product: ProductRow): ProductState {
|
function getProductState(product: ProductRow): ProductState {
|
||||||
return (product.state || product.status || product.reviewStatus || "").toUpperCase();
|
return (product.state || product.status || product.reviewStatus || "").toUpperCase();
|
||||||
}
|
}
|
||||||
@ -548,76 +974,188 @@ function ProductsPageInner() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-center">
|
<td className="px-6 py-4 text-center">
|
||||||
<div className="flex items-center justify-center gap-3">
|
<div className="flex items-center justify-center">
|
||||||
{isDeletedTab ? (
|
{isDeletedTab ? (
|
||||||
productState === "DELETED_BY_ADMIN" ? (
|
productState === "DELETED_BY_ADMIN" ? (
|
||||||
<span className="rounded-full bg-error-container px-3 py-1 text-[10px] font-bold text-on-error-container">
|
<span className="rounded-full bg-error-container px-3 py-1 text-[10px] font-bold text-on-error-container">
|
||||||
{p.deletedByAdmin}
|
{p.deletedByAdmin}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<div className="relative" ref={openActionMenuId === product.id ? actionMenuRef : null}>
|
||||||
type="button"
|
|
||||||
onClick={() => handleRestore(product.id)}
|
|
||||||
disabled={restoringId === product.id}
|
|
||||||
className="rounded-lg bg-tertiary px-3 py-1 text-[10px] font-bold text-white transition-colors hover:bg-tertiary/90 disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{restoringId === product.id ? p.publishing : p.restore}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
) : productState === "UNPUBLISHED" ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handlePublish(product.id)}
|
|
||||||
disabled={publishingId === product.id}
|
|
||||||
className="rounded-lg bg-tertiary px-3 py-1 text-[10px] font-bold text-white transition-colors hover:bg-tertiary/90 disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{publishingId === product.id ? p.publishing : p.publish}
|
|
||||||
</button>
|
|
||||||
) : productState === "DELETED_BY_SELLER" ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleRestore(product.id)}
|
|
||||||
disabled={restoringId === product.id}
|
|
||||||
className="rounded-lg bg-tertiary px-3 py-1 text-[10px] font-bold text-white transition-colors hover:bg-tertiary/90 disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{restoringId === product.id ? p.publishing : p.restore}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{!isInReviewTab ? (
|
|
||||||
!isDeletedTab ? (
|
|
||||||
<Link
|
|
||||||
href={`/products/${product.id}/edit${activeTab === "Draft" ? "?draft=1" : ""}`}
|
|
||||||
className="rounded-lg bg-primary px-3 py-1 text-[10px] font-bold text-white transition-colors hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
{p.edit}
|
|
||||||
</Link>
|
|
||||||
) : null
|
|
||||||
) : null}
|
|
||||||
<Link
|
|
||||||
href={`/products/${product.id}/detail${activeTab === "Draft" ? "?draft=1" : isInReviewTab ? "?review=1" : ""}`}
|
|
||||||
className="text-[10px] font-bold text-primary transition-colors hover:underline"
|
|
||||||
>
|
|
||||||
{p.detail}
|
|
||||||
</Link>
|
|
||||||
{canUnpublish ? (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setUnpublishTarget(product)}
|
onClick={() =>
|
||||||
className="rounded-lg bg-secondary px-3 py-1 text-[10px] font-bold text-white transition-colors hover:bg-secondary/90"
|
setOpenActionMenuId((current) =>
|
||||||
|
current === product.id ? null : product.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-xl border border-outline-variant/20 bg-surface-container-low text-on-surface transition-colors hover:border-primary/30 hover:text-primary"
|
||||||
|
aria-label={`Open actions for ${product.name}`}
|
||||||
|
aria-expanded={openActionMenuId === product.id}
|
||||||
>
|
>
|
||||||
{p.unpublish}
|
<span className="material-symbols-outlined text-xl">more_vert</span>
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
{openActionMenuId === product.id ? (
|
||||||
|
<div className="absolute right-0 top-11 z-20 w-48 overflow-hidden rounded-2xl border border-outline-variant/15 bg-surface-container-lowest py-2 shadow-2xl">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRestore(product.id)}
|
||||||
|
disabled={restoringId === product.id}
|
||||||
|
className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm font-semibold text-on-surface transition-colors hover:bg-surface-container-low disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-lg text-tertiary">settings_backup_restore</span>
|
||||||
|
{restoringId === product.id ? p.publishing : p.restore}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : productState === "UNPUBLISHED" ? (
|
||||||
|
<div className="relative" ref={openActionMenuId === product.id ? actionMenuRef : null}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDeleteTarget(product)}
|
onClick={() =>
|
||||||
className="w-7 h-7 flex items-center justify-center rounded-lg text-error hover:bg-error-container transition-colors"
|
setOpenActionMenuId((current) =>
|
||||||
title="Hapus"
|
current === product.id ? null : product.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-xl border border-outline-variant/20 bg-surface-container-low text-on-surface transition-colors hover:border-primary/30 hover:text-primary"
|
||||||
|
aria-label={`Open actions for ${product.name}`}
|
||||||
|
aria-expanded={openActionMenuId === product.id}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-base">delete</span>
|
<span className="material-symbols-outlined text-xl">more_vert</span>
|
||||||
</button>
|
</button>
|
||||||
</>
|
{openActionMenuId === product.id ? (
|
||||||
|
<div className="absolute right-0 top-11 z-20 w-48 overflow-hidden rounded-2xl border border-outline-variant/15 bg-surface-container-lowest py-2 shadow-2xl">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handlePublish(product.id)}
|
||||||
|
disabled={publishingId === product.id}
|
||||||
|
className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm font-semibold text-on-surface transition-colors hover:bg-surface-container-low disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-lg text-tertiary">publish</span>
|
||||||
|
{publishingId === product.id ? p.publishing : p.publish}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openStockPriceModal(product)}
|
||||||
|
className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm font-semibold text-on-surface transition-colors hover:bg-surface-container-low"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-lg text-secondary">price_change</span>
|
||||||
|
{p.editStockPrice}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : productState === "DELETED_BY_SELLER" ? (
|
||||||
|
<div className="relative" ref={openActionMenuId === product.id ? actionMenuRef : null}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setOpenActionMenuId((current) =>
|
||||||
|
current === product.id ? null : product.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-xl border border-outline-variant/20 bg-surface-container-low text-on-surface transition-colors hover:border-primary/30 hover:text-primary"
|
||||||
|
aria-label={`Open actions for ${product.name}`}
|
||||||
|
aria-expanded={openActionMenuId === product.id}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-xl">more_vert</span>
|
||||||
|
</button>
|
||||||
|
{openActionMenuId === product.id ? (
|
||||||
|
<div className="absolute right-0 top-11 z-20 w-48 overflow-hidden rounded-2xl border border-outline-variant/15 bg-surface-container-lowest py-2 shadow-2xl">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRestore(product.id)}
|
||||||
|
disabled={restoringId === product.id}
|
||||||
|
className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm font-semibold text-on-surface transition-colors hover:bg-surface-container-low disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-lg text-tertiary">settings_backup_restore</span>
|
||||||
|
{restoringId === product.id ? p.publishing : p.restore}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openStockPriceModal(product)}
|
||||||
|
className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm font-semibold text-on-surface transition-colors hover:bg-surface-container-low"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-lg text-secondary">price_change</span>
|
||||||
|
{p.editStockPrice}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative" ref={openActionMenuId === product.id ? actionMenuRef : null}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setOpenActionMenuId((current) =>
|
||||||
|
current === product.id ? null : product.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-xl border border-outline-variant/20 bg-surface-container-low text-on-surface transition-colors hover:border-primary/30 hover:text-primary"
|
||||||
|
aria-label={`Open actions for ${product.name}`}
|
||||||
|
aria-expanded={openActionMenuId === product.id}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-xl">more_vert</span>
|
||||||
|
</button>
|
||||||
|
{openActionMenuId === product.id ? (
|
||||||
|
<div className="absolute right-0 top-11 z-20 w-48 overflow-hidden rounded-2xl border border-outline-variant/15 bg-surface-container-lowest py-2 shadow-2xl">
|
||||||
|
{!isInReviewTab ? (
|
||||||
|
!isDeletedTab ? (
|
||||||
|
<Link
|
||||||
|
href={`/products/${product.id}/edit${activeTab === "Draft" ? "?draft=1" : ""}`}
|
||||||
|
onClick={() => setOpenActionMenuId(null)}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 text-sm font-semibold text-on-surface transition-colors hover:bg-surface-container-low"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-lg text-primary">edit</span>
|
||||||
|
{p.edit}
|
||||||
|
</Link>
|
||||||
|
) : null
|
||||||
|
) : null}
|
||||||
|
<Link
|
||||||
|
href={`/products/${product.id}/detail${activeTab === "Draft" ? "?draft=1" : isInReviewTab ? "?review=1" : ""}`}
|
||||||
|
onClick={() => setOpenActionMenuId(null)}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 text-sm font-semibold text-on-surface transition-colors hover:bg-surface-container-low"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-lg text-primary">visibility</span>
|
||||||
|
{p.detail}
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openStockPriceModal(product)}
|
||||||
|
className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm font-semibold text-on-surface transition-colors hover:bg-surface-container-low"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-lg text-secondary">price_change</span>
|
||||||
|
{p.editStockPrice}
|
||||||
|
</button>
|
||||||
|
{canUnpublish ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setOpenActionMenuId(null);
|
||||||
|
setUnpublishTarget(product);
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm font-semibold text-on-surface transition-colors hover:bg-surface-container-low"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-lg text-secondary">visibility_off</span>
|
||||||
|
{p.unpublish}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setOpenActionMenuId(null);
|
||||||
|
setDeleteTarget(product);
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm font-semibold text-error transition-colors hover:bg-error-container/40"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-lg">delete</span>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@ -683,6 +1221,29 @@ function ProductsPageInner() {
|
|||||||
confirmToneClass="bg-secondary hover:bg-secondary/90"
|
confirmToneClass="bg-secondary hover:bg-secondary/90"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{stockPriceTarget && (
|
||||||
|
<StockPriceModal
|
||||||
|
state={stockPriceTarget}
|
||||||
|
d={p.stockPriceDialog}
|
||||||
|
onCancel={() => {
|
||||||
|
if (!stockPriceTarget.submitting) {
|
||||||
|
setStockPriceTarget(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPriceChange={(value) =>
|
||||||
|
setStockPriceTarget((current) =>
|
||||||
|
current ? { ...current, nextPrice: value, error: "" } : current
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onStockChange={(value) =>
|
||||||
|
setStockPriceTarget((current) =>
|
||||||
|
current ? { ...current, nextStock: value, error: "" } : current
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onSubmit={handleSubmitStockPrice}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -131,10 +131,13 @@ export async function DELETE(
|
|||||||
const token = normalizeBearerToken(req.headers.get("x-auth-token") || "");
|
const token = normalizeBearerToken(req.headers.get("x-auth-token") || "");
|
||||||
const { productId } = await context.params;
|
const { productId } = await context.params;
|
||||||
const isDraft = req.nextUrl.searchParams.get("draft") === "1";
|
const isDraft = req.nextUrl.searchParams.get("draft") === "1";
|
||||||
|
const isReview = req.nextUrl.searchParams.get("review") === "1";
|
||||||
|
|
||||||
const endpoint = isDraft
|
const endpoint = isDraft
|
||||||
? `${API_URL}/api/v1.0/product/draft/${productId}`
|
? `${API_URL}/api/v1.0/product/draft/${productId}`
|
||||||
: `${API_URL}/api/v1.0/product/${productId}`;
|
: isReview
|
||||||
|
? `${API_URL}/api/v1.0/seller/product/${productId}?state=REVIEW`
|
||||||
|
: `${API_URL}/api/v1.0/product/${productId}`;
|
||||||
|
|
||||||
const res = await fetch(endpoint, {
|
const res = await fetch(endpoint, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
|
|||||||
@ -4,7 +4,8 @@ import { API_URL, makeHeaders } from "@/lib/api";
|
|||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const token = req.headers.get("x-auth-token") || "";
|
const token = req.headers.get("x-auth-token") || "";
|
||||||
const searchParams = req.nextUrl.searchParams;
|
const searchParams = req.nextUrl.searchParams;
|
||||||
const tab = searchParams.get("tab");
|
const rawTab = searchParams.get("tab");
|
||||||
|
const tab = rawTab === "all" ? "" : rawTab;
|
||||||
|
|
||||||
const endpointMap: Record<string, string> = {
|
const endpointMap: Record<string, string> = {
|
||||||
draft: "/api/v1.0/seller/draft/product",
|
draft: "/api/v1.0/seller/draft/product",
|
||||||
|
|||||||
31
src/app/api/products/stock-price/route.ts
Normal file
31
src/app/api/products/stock-price/route.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { API_URL, makeHeaders } from "@/lib/api";
|
||||||
|
|
||||||
|
function normalizeBearerToken(rawToken: string) {
|
||||||
|
if (!rawToken) return "";
|
||||||
|
return rawToken.startsWith("Bearer ") ? rawToken : `Bearer ${rawToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const token = normalizeBearerToken(req.headers.get("x-auth-token") || "");
|
||||||
|
const body = await req.json();
|
||||||
|
|
||||||
|
const res = await fetch(`${API_URL}/api/v1.0/product/stock-price`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: makeHeaders(token),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
return NextResponse.json(data, { status: res.status });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
responseDesc:
|
||||||
|
error instanceof Error ? error.message : "Unknown proxy error",
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,7 +12,8 @@ const adminProductSubmenu = [
|
|||||||
function AdminProductSubmenuNavInner() {
|
function AdminProductSubmenuNavInner() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const currentTab = searchParams.get("tab") ?? "";
|
const rawTab = searchParams.get("tab") ?? "";
|
||||||
|
const currentTab = rawTab === "all" ? "" : rawTab;
|
||||||
|
|
||||||
if (pathname !== "/admin/products" && !pathname.startsWith("/admin/products/")) {
|
if (pathname !== "/admin/products" && !pathname.startsWith("/admin/products/")) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@ -18,7 +18,8 @@ const productSubmenu = [
|
|||||||
function ProductSubmenuNavInner() {
|
function ProductSubmenuNavInner() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const currentTab = searchParams.get("tab") ?? "";
|
const rawTab = searchParams.get("tab") ?? "";
|
||||||
|
const currentTab = rawTab === "all" ? "" : rawTab;
|
||||||
|
|
||||||
if (pathname !== "/products" && !pathname.startsWith("/products/")) return null;
|
if (pathname !== "/products" && !pathname.startsWith("/products/")) return null;
|
||||||
|
|
||||||
|
|||||||
@ -357,6 +357,7 @@ export const en = {
|
|||||||
loading: "Loading products...",
|
loading: "Loading products...",
|
||||||
empty: "No products found.",
|
empty: "No products found.",
|
||||||
edit: "Edit",
|
edit: "Edit",
|
||||||
|
editStockPrice: "Edit Stock & Price",
|
||||||
detail: "Detail",
|
detail: "Detail",
|
||||||
publish: "Publish",
|
publish: "Publish",
|
||||||
publishing: "Processing...",
|
publishing: "Processing...",
|
||||||
@ -391,6 +392,22 @@ export const en = {
|
|||||||
processing: "Processing...",
|
processing: "Processing...",
|
||||||
errorGeneric: "Failed to unpublish product",
|
errorGeneric: "Failed to unpublish product",
|
||||||
},
|
},
|
||||||
|
stockPriceDialog: {
|
||||||
|
title: "Edit Stock and Price",
|
||||||
|
message: "Update the stock and price for the selected product.",
|
||||||
|
productLabel: "Selected product",
|
||||||
|
currentPrice: "Current Price",
|
||||||
|
currentStock: "Current Stock",
|
||||||
|
newPrice: "New Price",
|
||||||
|
newStock: "New Stock",
|
||||||
|
cancel: "Cancel",
|
||||||
|
confirm: "Submit",
|
||||||
|
processing: "Processing...",
|
||||||
|
loadError: "Failed to load product details",
|
||||||
|
targetError: "Product model or warehouse identifier was not found",
|
||||||
|
invalidValueError: "Price and stock must be 0 or greater",
|
||||||
|
updateError: "Failed to update product stock and price",
|
||||||
|
},
|
||||||
restoreError: "Failed to restore product",
|
restoreError: "Failed to restore product",
|
||||||
publishError: "Failed to publish product",
|
publishError: "Failed to publish product",
|
||||||
tabs: {
|
tabs: {
|
||||||
|
|||||||
@ -358,6 +358,7 @@ export const id = {
|
|||||||
loading: "Memuat produk...",
|
loading: "Memuat produk...",
|
||||||
empty: "Tidak ada produk ditemukan.",
|
empty: "Tidak ada produk ditemukan.",
|
||||||
edit: "Edit",
|
edit: "Edit",
|
||||||
|
editStockPrice: "Edit Stok & Harga",
|
||||||
detail: "Detail",
|
detail: "Detail",
|
||||||
publish: "Publish",
|
publish: "Publish",
|
||||||
publishing: "Memproses...",
|
publishing: "Memproses...",
|
||||||
@ -392,6 +393,22 @@ export const id = {
|
|||||||
processing: "Memproses...",
|
processing: "Memproses...",
|
||||||
errorGeneric: "Gagal unpublish produk",
|
errorGeneric: "Gagal unpublish produk",
|
||||||
},
|
},
|
||||||
|
stockPriceDialog: {
|
||||||
|
title: "Edit Stok dan Harga",
|
||||||
|
message: "Perbarui stok dan harga produk yang dipilih.",
|
||||||
|
productLabel: "Produk terpilih",
|
||||||
|
currentPrice: "Harga Sekarang",
|
||||||
|
currentStock: "Stok Sekarang",
|
||||||
|
newPrice: "Harga Baru",
|
||||||
|
newStock: "Stok Baru",
|
||||||
|
cancel: "Batal",
|
||||||
|
confirm: "Submit",
|
||||||
|
processing: "Memproses...",
|
||||||
|
loadError: "Gagal memuat detail produk",
|
||||||
|
targetError: "Identitas model atau warehouse produk tidak ditemukan",
|
||||||
|
invalidValueError: "Harga dan stok harus bernilai 0 atau lebih",
|
||||||
|
updateError: "Gagal memperbarui stok dan harga produk",
|
||||||
|
},
|
||||||
restoreError: "Gagal restore produk",
|
restoreError: "Gagal restore produk",
|
||||||
publishError: "Gagal publish produk",
|
publishError: "Gagal publish produk",
|
||||||
tabs: {
|
tabs: {
|
||||||
|
|||||||
Reference in New Issue
Block a user