feat: add Ina Trading portal flows and API integration
This commit is contained in:
381
src/app/(dashboard)/dashboard/warehouse/WarehouseForm.tsx
Normal file
381
src/app/(dashboard)/dashboard/warehouse/WarehouseForm.tsx
Normal file
@ -0,0 +1,381 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { COUNTRIES } from "@/lib/countries";
|
||||
|
||||
export interface WarehouseFormState {
|
||||
name: string;
|
||||
address: string;
|
||||
country: string;
|
||||
province: string;
|
||||
provinceId: string;
|
||||
city: string;
|
||||
cityId: string;
|
||||
postalCode: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
warehouseType: string;
|
||||
}
|
||||
|
||||
export const defaultWarehouseForm: WarehouseFormState = {
|
||||
name: "",
|
||||
address: "",
|
||||
country: "Indonesia",
|
||||
province: "",
|
||||
provinceId: "",
|
||||
city: "",
|
||||
cityId: "",
|
||||
postalCode: "",
|
||||
latitude: "",
|
||||
longitude: "",
|
||||
warehouseType: "INA",
|
||||
};
|
||||
|
||||
interface Province { id: string; name: string; }
|
||||
interface City { id: string; name: string; }
|
||||
|
||||
function getToken() {
|
||||
if (typeof window === "undefined") return "";
|
||||
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
|
||||
}
|
||||
|
||||
function normalizeToken(t: string) {
|
||||
if (!t) return "";
|
||||
return t.startsWith("Bearer ") ? t : `Bearer ${t}`;
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
"w-full bg-surface-container-low border-b-2 border-outline/30 focus:border-primary border-t-0 border-x-0 rounded-t-lg px-4 py-3 text-sm font-medium text-on-surface placeholder:text-slate-400 focus:ring-0 focus:outline-none transition-all";
|
||||
|
||||
const labelCls = "block text-xs font-black uppercase tracking-[0.15em] text-slate-500 mb-2";
|
||||
|
||||
interface WarehouseFormProps {
|
||||
initialData?: WarehouseFormState;
|
||||
pageTitle: string;
|
||||
pageSubtitle: string;
|
||||
submitLabel: string;
|
||||
submittingLabel: string;
|
||||
successMessage: string;
|
||||
apiMethod: "POST" | "PUT";
|
||||
apiUrl: string;
|
||||
}
|
||||
|
||||
export function WarehouseForm({
|
||||
initialData,
|
||||
pageTitle,
|
||||
pageSubtitle,
|
||||
submitLabel,
|
||||
submittingLabel,
|
||||
successMessage,
|
||||
apiMethod,
|
||||
apiUrl,
|
||||
}: WarehouseFormProps) {
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState<WarehouseFormState>(initialData ?? defaultWarehouseForm);
|
||||
const [provinces, setProvinces] = useState<Province[]>([]);
|
||||
const [cities, setCities] = useState<City[]>([]);
|
||||
const [loadingProvinces, setLoadingProvinces] = useState(false);
|
||||
const [loadingCities, setLoadingCities] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savingPhase, setSavingPhase] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const isIndonesia = form.country === "Indonesia";
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) setForm(initialData);
|
||||
}, [initialData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isIndonesia) {
|
||||
setProvinces([]);
|
||||
setCities([]);
|
||||
return;
|
||||
}
|
||||
setLoadingProvinces(true);
|
||||
fetch("/api/locations/provinces", { headers: { "x-auth-token": normalizeToken(getToken()) } })
|
||||
.then((r) => r.json())
|
||||
.then((d) => setProvinces(Array.isArray(d?.rows) ? d.rows : []))
|
||||
.catch(() => setProvinces([]))
|
||||
.finally(() => setLoadingProvinces(false));
|
||||
}, [isIndonesia]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isIndonesia || !form.provinceId) {
|
||||
setCities([]);
|
||||
return;
|
||||
}
|
||||
setLoadingCities(true);
|
||||
fetch(`/api/locations/cities?provinceId=${form.provinceId}`, {
|
||||
headers: { "x-auth-token": normalizeToken(getToken()) },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((d) => setCities(Array.isArray(d?.rows) ? d.rows : []))
|
||||
.catch(() => setCities([]))
|
||||
.finally(() => setLoadingCities(false));
|
||||
}, [isIndonesia, form.provinceId]);
|
||||
|
||||
function update(patch: Partial<WarehouseFormState>) {
|
||||
setForm((prev) => ({ ...prev, ...patch }));
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.address.trim()) { setError("Alamat wajib diisi"); return; }
|
||||
|
||||
setSaving(true);
|
||||
setSavingPhase("Menyimpan perubahan...");
|
||||
setError("");
|
||||
|
||||
const payload: Record<string, string | number | null> = {
|
||||
name: form.name || null,
|
||||
address: form.address,
|
||||
country: form.country || null,
|
||||
province: form.province || null,
|
||||
city: form.city || null,
|
||||
postalCode: form.postalCode || null,
|
||||
latitude: form.latitude ? parseFloat(form.latitude) : null,
|
||||
longitude: form.longitude ? parseFloat(form.longitude) : null,
|
||||
warehouseType: form.warehouseType || null,
|
||||
};
|
||||
|
||||
try {
|
||||
const token = normalizeToken(getToken());
|
||||
const res = await fetch(apiUrl, {
|
||||
method: apiMethod,
|
||||
headers: { "Content-Type": "application/json", "x-auth-token": token },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data?.responseDesc || data?.error || "Gagal menyimpan warehouse");
|
||||
return;
|
||||
}
|
||||
setSuccess(true);
|
||||
setTimeout(() => router.push("/dashboard/warehouse"), 1500);
|
||||
} catch {
|
||||
setError("Gagal terhubung ke server");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setSavingPhase("");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 min-h-screen">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<span className="text-primary font-bold tracking-widest text-xs uppercase">Management Dashboard</span>
|
||||
<h1 className="text-4xl font-extrabold tracking-tight text-on-surface mt-1">{pageTitle}</h1>
|
||||
<p className="text-slate-500 text-sm mt-1">{pageSubtitle}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="flex items-center gap-2 text-sm font-semibold text-slate-500 hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-base">arrow_back</span>
|
||||
Kembali
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form Card */}
|
||||
<div className="bg-white rounded-2xl border-l-4 border-primary shadow-sm p-8">
|
||||
<h2 className="text-2xl font-bold tracking-tight mb-8">Facility Details</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Name */}
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelCls}>Nama Warehouse</label>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => update({ name: e.target.value })}
|
||||
placeholder="Contoh: Gudang Utama Jakarta..."
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelCls}>Alamat *</label>
|
||||
<input
|
||||
value={form.address}
|
||||
onChange={(e) => update({ address: e.target.value })}
|
||||
placeholder="Jl. Nama Jalan No. ..."
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Country */}
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelCls}>Negara</label>
|
||||
<select
|
||||
value={form.country}
|
||||
onChange={(e) =>
|
||||
update({ country: e.target.value, province: "", provinceId: "", city: "", cityId: "" })
|
||||
}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="">Pilih negara...</option>
|
||||
{COUNTRIES.map((c) => (
|
||||
<option key={c.code} value={c.name}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Province */}
|
||||
<div>
|
||||
<label className={labelCls}>Provinsi / State</label>
|
||||
{isIndonesia ? (
|
||||
<select
|
||||
value={form.provinceId}
|
||||
onChange={(e) => {
|
||||
const selected = provinces.find((p) => p.id === e.target.value);
|
||||
update({ provinceId: e.target.value, province: selected?.name || "", city: "", cityId: "" });
|
||||
}}
|
||||
disabled={loadingProvinces}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="">
|
||||
{loadingProvinces ? "Memuat provinsi..." : "Pilih provinsi..."}
|
||||
</option>
|
||||
{provinces.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
value={form.province}
|
||||
onChange={(e) => update({ province: e.target.value })}
|
||||
placeholder="Nama provinsi / state..."
|
||||
className={inputCls}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* City */}
|
||||
<div>
|
||||
<label className={labelCls}>Kota</label>
|
||||
{isIndonesia ? (
|
||||
<select
|
||||
value={form.cityId}
|
||||
onChange={(e) => {
|
||||
const selected = cities.find((c) => c.id === e.target.value);
|
||||
update({ cityId: e.target.value, city: selected?.name || "" });
|
||||
}}
|
||||
disabled={!form.provinceId || loadingCities}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="">
|
||||
{!form.provinceId ? "Pilih provinsi dulu..." : loadingCities ? "Memuat kota..." : "Pilih kota..."}
|
||||
</option>
|
||||
{cities.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
value={form.city}
|
||||
onChange={(e) => update({ city: e.target.value })}
|
||||
placeholder="Nama kota..."
|
||||
className={inputCls}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Postal Code */}
|
||||
<div>
|
||||
<label className={labelCls}>Kode Pos</label>
|
||||
<input
|
||||
value={form.postalCode}
|
||||
onChange={(e) => update({ postalCode: e.target.value })}
|
||||
placeholder="Contoh: 13920"
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Warehouse Type */}
|
||||
<div>
|
||||
<label className={labelCls}>Tipe Warehouse</label>
|
||||
<select
|
||||
value={form.warehouseType}
|
||||
onChange={(e) => update({ warehouseType: e.target.value })}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="INA">INA</option>
|
||||
<option value="Other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Lat / Lng */}
|
||||
<div>
|
||||
<label className={labelCls}>Latitude</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={form.latitude}
|
||||
onChange={(e) => update({ latitude: e.target.value })}
|
||||
placeholder="Contoh: -6.1891"
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Longitude</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={form.longitude}
|
||||
onChange={(e) => update({ longitude: e.target.value })}
|
||||
placeholder="Contoh: 106.9247"
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status messages + buttons */}
|
||||
<div className="mt-10 space-y-4">
|
||||
{success && (
|
||||
<div className="p-4 bg-tertiary-fixed text-on-tertiary-fixed-variant rounded-xl flex items-center gap-3 font-semibold text-sm">
|
||||
<span className="material-symbols-outlined text-tertiary">check_circle</span>
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="p-4 bg-error-container text-on-error-container rounded-xl flex items-center gap-3 font-semibold text-sm">
|
||||
<span className="material-symbols-outlined">error</span>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{savingPhase && (
|
||||
<p className="text-xs text-slate-400 flex items-center gap-1.5">
|
||||
<span className="material-symbols-outlined text-sm animate-spin">progress_activity</span>
|
||||
{savingPhase}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
disabled={saving}
|
||||
className="px-6 py-3 rounded-xl border border-outline-variant/30 text-on-surface font-black text-sm uppercase tracking-[0.15em] hover:bg-surface-container-low transition-colors disabled:opacity-40"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || success}
|
||||
className="bg-gradient-to-br from-primary to-primary-container text-white px-10 py-3.5 rounded-xl font-black text-sm uppercase tracking-[0.15em] shadow-xl shadow-primary/20 hover:scale-105 active:scale-95 transition-all disabled:opacity-60 disabled:hover:scale-100"
|
||||
>
|
||||
{saving ? (savingPhase || submittingLabel) : submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { WarehouseForm, WarehouseFormState } from "../../WarehouseForm";
|
||||
|
||||
function getCachedWarehouse(warehouseId: string): WarehouseFormState | null {
|
||||
if (typeof window === "undefined" || !warehouseId) return null;
|
||||
const cached = sessionStorage.getItem("editWarehouseCache");
|
||||
if (!cached) return null;
|
||||
|
||||
try {
|
||||
const raw = JSON.parse(cached) as Record<string, unknown>;
|
||||
if (raw.id !== warehouseId) return null;
|
||||
sessionStorage.removeItem("editWarehouseCache");
|
||||
|
||||
return {
|
||||
name: typeof raw.name === "string" ? raw.name : "",
|
||||
address: typeof raw.address === "string" ? raw.address : "",
|
||||
country: typeof raw.country === "string" ? raw.country : "Indonesia",
|
||||
province: typeof raw.province === "string" ? raw.province : "",
|
||||
provinceId: "",
|
||||
city: typeof raw.city === "string" ? raw.city : "",
|
||||
cityId: "",
|
||||
postalCode: typeof raw.postalCode === "string" ? raw.postalCode : "",
|
||||
latitude: raw.latitude != null ? String(raw.latitude) : "",
|
||||
longitude: raw.longitude != null ? String(raw.longitude) : "",
|
||||
warehouseType:
|
||||
typeof raw.warehouseType === "string" ? raw.warehouseType : "INA",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function EditWarehousePage() {
|
||||
const params = useParams<{ warehouseId: string }>();
|
||||
const router = useRouter();
|
||||
const [initialData] = useState<WarehouseFormState | null>(() =>
|
||||
getCachedWarehouse(params.warehouseId)
|
||||
);
|
||||
|
||||
const loadError = initialData
|
||||
? ""
|
||||
: "Data tidak tersedia. Kembali ke daftar warehouse dan klik Edit lagi.";
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="p-8 flex flex-col items-center justify-center h-64 gap-4">
|
||||
<span className="material-symbols-outlined text-4xl text-error">error</span>
|
||||
<p className="text-sm font-semibold text-error">{loadError}</p>
|
||||
<button onClick={() => router.back()} className="text-sm font-bold text-primary hover:underline">Kembali</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!initialData) {
|
||||
return (
|
||||
<div className="p-8 flex items-center justify-center h-64">
|
||||
<span className="material-symbols-outlined text-4xl text-slate-300 animate-spin">progress_activity</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<WarehouseForm
|
||||
initialData={initialData}
|
||||
pageTitle="Edit Warehouse"
|
||||
pageSubtitle="Perbarui informasi gudang"
|
||||
submitLabel="Simpan Perubahan"
|
||||
submittingLabel="Menyimpan..."
|
||||
successMessage="Warehouse berhasil diperbarui! Mengalihkan..."
|
||||
apiMethod="PUT"
|
||||
apiUrl={`/api/warehouses/${params.warehouseId}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
17
src/app/(dashboard)/dashboard/warehouse/new/page.tsx
Normal file
17
src/app/(dashboard)/dashboard/warehouse/new/page.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { WarehouseForm } from "../WarehouseForm";
|
||||
|
||||
export default function NewWarehousePage() {
|
||||
return (
|
||||
<WarehouseForm
|
||||
pageTitle="Tambah Warehouse"
|
||||
pageSubtitle="Daftarkan gudang baru ke dalam sistem"
|
||||
submitLabel="Simpan Warehouse"
|
||||
submittingLabel="Menyimpan..."
|
||||
successMessage="Warehouse berhasil ditambahkan! Mengalihkan..."
|
||||
apiMethod="POST"
|
||||
apiUrl="/api/warehouses"
|
||||
/>
|
||||
);
|
||||
}
|
||||
311
src/app/(dashboard)/dashboard/warehouse/page.tsx
Normal file
311
src/app/(dashboard)/dashboard/warehouse/page.tsx
Normal file
@ -0,0 +1,311 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface WarehouseRow {
|
||||
id: string;
|
||||
name: string | null;
|
||||
address: string | null;
|
||||
city: string | null;
|
||||
province: string | null;
|
||||
country: string | null;
|
||||
postalCode: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
warehouseType: string | null;
|
||||
}
|
||||
|
||||
function getToken() {
|
||||
if (typeof window === "undefined") return "";
|
||||
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
|
||||
}
|
||||
|
||||
function normalizeToken(t: string) {
|
||||
if (!t) return "";
|
||||
return t.startsWith("Bearer ") ? t : `Bearer ${t}`;
|
||||
}
|
||||
|
||||
function typeBadge(type: string | null) {
|
||||
if (type === "INA") return "bg-primary-fixed text-on-primary-fixed-variant";
|
||||
if (type === "Other") return "bg-secondary-fixed text-on-secondary-fixed-variant";
|
||||
return "bg-slate-100 text-slate-500";
|
||||
}
|
||||
|
||||
export default function WarehousePage() {
|
||||
const router = useRouter();
|
||||
const [rows, setRows] = useState<WarehouseRow[]>([]);
|
||||
const [filtered, setFiltered] = useState<WarehouseRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const [totalItem, setTotalItem] = useState(0);
|
||||
const [totalPage, setTotalPage] = useState(1);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const pageSize = 20;
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page]);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const token = normalizeToken(getToken());
|
||||
const res = await fetch(`/api/products/warehouses?page=${page + 1}&size=${pageSize}`, {
|
||||
headers: { "x-auth-token": token },
|
||||
});
|
||||
const data = await res.json();
|
||||
const list: WarehouseRow[] = Array.isArray(data?.rows) ? data.rows : [];
|
||||
setRows(list);
|
||||
setFiltered(list);
|
||||
setTotalItem(data?.totalItem ?? 0);
|
||||
setTotalPage(data?.totalPage ?? 1);
|
||||
} catch {
|
||||
setError("Gagal memuat data warehouse");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const q = search.toLowerCase();
|
||||
setFiltered(
|
||||
rows.filter(
|
||||
(r) =>
|
||||
(r.name || "").toLowerCase().includes(q) ||
|
||||
(r.address || "").toLowerCase().includes(q) ||
|
||||
(r.city || "").toLowerCase().includes(q) ||
|
||||
(r.province || "").toLowerCase().includes(q) ||
|
||||
(r.country || "").toLowerCase().includes(q)
|
||||
)
|
||||
);
|
||||
}, [search, rows]);
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
setDeleting(true);
|
||||
try {
|
||||
const token = normalizeToken(getToken());
|
||||
const res = await fetch(`/api/warehouses/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { "x-auth-token": token },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
alert(d?.responseDesc || "Gagal menghapus warehouse");
|
||||
return;
|
||||
}
|
||||
setDeleteId(null);
|
||||
load();
|
||||
} catch {
|
||||
alert("Gagal terhubung ke server");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const startEntry = page * pageSize + 1;
|
||||
const endEntry = Math.min((page + 1) * pageSize, totalItem);
|
||||
|
||||
return (
|
||||
<div className="p-8 min-h-screen">
|
||||
{/* Delete Confirmation Modal */}
|
||||
{deleteId && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl shadow-2xl p-8 max-w-sm w-full mx-4">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-full bg-error-container flex items-center justify-center flex-shrink-0">
|
||||
<span className="material-symbols-outlined text-error">delete</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-black text-on-surface">Hapus Warehouse?</h3>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mb-6">
|
||||
Tindakan ini tidak bisa dibatalkan. Warehouse akan dihapus secara permanen.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => setDeleteId(null)}
|
||||
disabled={deleting}
|
||||
className="px-5 py-2.5 rounded-xl border border-outline-variant/30 text-sm font-black text-on-surface hover:bg-surface-container-low transition-colors disabled:opacity-40"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(deleteId)}
|
||||
disabled={deleting}
|
||||
className="px-5 py-2.5 rounded-xl bg-error text-white text-sm font-black hover:opacity-90 transition-opacity disabled:opacity-60"
|
||||
>
|
||||
{deleting ? "Menghapus..." : "Hapus"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||
<div>
|
||||
<span className="text-primary font-bold tracking-widest text-xs uppercase">Management Dashboard</span>
|
||||
<h1 className="text-4xl font-extrabold tracking-tight text-on-surface mt-1">Warehouse</h1>
|
||||
<p className="text-slate-500 font-semibold mt-1">Kelola gudang dan lokasi penyimpanan produk</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-white border border-zinc-100 h-12 px-4 flex items-center rounded-xl shadow-sm">
|
||||
<span className="material-symbols-outlined text-slate-400 mr-2 text-lg">search</span>
|
||||
<input
|
||||
className="bg-transparent border-none focus:ring-0 text-sm font-medium w-48 lg:w-64 placeholder:text-slate-300 outline-none"
|
||||
placeholder="Cari berdasarkan lokasi..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/warehouse/new"
|
||||
className="h-12 px-6 flex items-center gap-2 bg-gradient-to-r from-primary to-primary-container text-white rounded-xl font-bold text-sm hover:opacity-90 transition-opacity shadow-lg shadow-primary/20"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">add</span>
|
||||
<span>Tambah Warehouse</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white rounded-2xl overflow-hidden border border-zinc-100 shadow-sm">
|
||||
{loading ? (
|
||||
<div className="p-16 text-center text-slate-400">
|
||||
<span className="material-symbols-outlined text-4xl mb-4 block animate-spin">progress_activity</span>
|
||||
<p className="text-sm font-medium">Memuat data...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-16 text-center text-error">
|
||||
<span className="material-symbols-outlined text-4xl mb-4 block">error</span>
|
||||
<p className="text-sm font-medium">{error}</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="p-16 text-center text-slate-400">
|
||||
<span className="material-symbols-outlined text-4xl mb-4 block">warehouse</span>
|
||||
<p className="text-sm font-medium">Belum ada warehouse</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-zinc-50/50 border-b border-zinc-100">
|
||||
<th className="px-8 py-5 text-[11px] font-black uppercase tracking-widest text-slate-400">Warehouse</th>
|
||||
<th className="px-8 py-5 text-[11px] font-black uppercase tracking-widest text-slate-400">Alamat</th>
|
||||
<th className="px-8 py-5 text-[11px] font-black uppercase tracking-widest text-slate-400">Kode Pos</th>
|
||||
<th className="px-8 py-5 text-[11px] font-black uppercase tracking-widest text-slate-400">Tipe</th>
|
||||
<th className="px-8 py-5 text-[11px] font-black uppercase tracking-widest text-slate-400 text-right">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-50">
|
||||
{filtered.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-zinc-50/40 transition-colors group">
|
||||
{/* Warehouse name */}
|
||||
<td className="px-8 py-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<span className="material-symbols-outlined text-primary">warehouse</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-black text-on-surface tracking-tight">
|
||||
{item.name || <span className="text-slate-400 font-medium italic">Tanpa nama</span>}
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-widest mt-0.5">
|
||||
{item.city && item.province ? `${item.city}, ${item.province}` : item.city || item.province || "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Address */}
|
||||
<td className="px-8 py-6">
|
||||
<p className="text-sm font-semibold text-on-surface">{item.address || "—"}</p>
|
||||
<p className="text-[11px] font-bold text-slate-400 uppercase tracking-widest mt-0.5">{item.country || ""}</p>
|
||||
</td>
|
||||
|
||||
{/* Postal code */}
|
||||
<td className="px-8 py-6">
|
||||
<span className="text-sm font-semibold text-on-surface">{item.postalCode || "—"}</span>
|
||||
</td>
|
||||
|
||||
{/* Type */}
|
||||
<td className="px-8 py-6">
|
||||
<span className={`px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest ${typeBadge(item.warehouseType)}`}>
|
||||
{item.warehouseType || "—"}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="px-8 py-6 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
sessionStorage.setItem("editWarehouseCache", JSON.stringify(item));
|
||||
router.push(`/dashboard/warehouse/${item.id}/edit`);
|
||||
}}
|
||||
className="w-10 h-10 flex items-center justify-center text-slate-400 hover:text-on-surface hover:bg-zinc-100 rounded-xl transition-all"
|
||||
title="Edit"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">edit</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteId(item.id)}
|
||||
className="w-10 h-10 flex items-center justify-center text-red-300 hover:text-primary hover:bg-red-50 rounded-xl transition-all"
|
||||
title="Hapus"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{!loading && totalItem > 0 && (
|
||||
<div className="px-8 py-5 bg-zinc-50/50 flex items-center justify-between border-t border-zinc-50">
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">
|
||||
Menampilkan {startEntry}–{endEntry} dari {totalItem} warehouse
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="w-10 h-10 flex items-center justify-center border border-zinc-200 rounded-xl text-slate-400 hover:bg-white transition-all disabled:opacity-40"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">chevron_left</span>
|
||||
</button>
|
||||
{Array.from({ length: Math.min(totalPage, 5) }, (_, i) => i).map((i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setPage(i)}
|
||||
className={`w-10 h-10 flex items-center justify-center rounded-xl text-xs font-black transition-all ${
|
||||
i === page ? "bg-primary text-white shadow-lg shadow-primary/20" : "border border-zinc-200 text-on-surface hover:bg-white"
|
||||
}`}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPage - 1, p + 1))}
|
||||
disabled={page >= totalPage - 1}
|
||||
className="w-10 h-10 flex items-center justify-center border border-zinc-200 rounded-xl text-slate-400 hover:bg-white transition-all disabled:opacity-40"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user