Parcourir la source

Update for the shop and truck function

master
B.E.N.S.O.N il y a 19 heures
Parent
révision
c146944a19
9 fichiers modifiés avec 1243 ajouts et 1 suppressions
  1. +15
    -0
      src/app/(main)/settings/shop/detail/page.tsx
  2. +19
    -0
      src/app/(main)/settings/shop/page.tsx
  3. +136
    -0
      src/app/api/shop/actions.ts
  4. +35
    -0
      src/app/api/shop/client.ts
  5. +4
    -1
      src/app/api/shop/index.ts
  6. +257
    -0
      src/components/Shop/Shop.tsx
  7. +761
    -0
      src/components/Shop/ShopDetail.tsx
  8. +15
    -0
      src/components/Shop/ShopWrapper.tsx
  9. +1
    -0
      src/components/Shop/index.ts

+ 15
- 0
src/app/(main)/settings/shop/detail/page.tsx Voir le fichier

@@ -0,0 +1,15 @@
import { Suspense } from "react";
import ShopDetail from "@/components/Shop/ShopDetail";
import { I18nProvider, getServerI18n } from "@/i18n";
import GeneralLoading from "@/components/General/GeneralLoading";

export default async function ShopDetailPage() {
const { t } = await getServerI18n("shop");
return (
<I18nProvider namespaces={["shop"]}>
<Suspense fallback={<GeneralLoading />}>
<ShopDetail />
</Suspense>
</I18nProvider>
);
}

+ 19
- 0
src/app/(main)/settings/shop/page.tsx Voir le fichier

@@ -0,0 +1,19 @@
import { Suspense } from "react";
import ShopWrapper from "@/components/Shop";
import { I18nProvider, getServerI18n } from "@/i18n";
import { Typography } from "@mui/material";
import { isArray } from "lodash";
import { Metadata } from "next";
import { notFound } from "next/navigation";


export default async function ShopPage() {
const { t } = await getServerI18n("shop");
return (
<I18nProvider namespaces={["shop"]}>
<Suspense fallback={<ShopWrapper.Loading />}>
<ShopWrapper />
</Suspense>
</I18nProvider>
);
}

+ 136
- 0
src/app/api/shop/actions.ts Voir le fichier

@@ -0,0 +1,136 @@
"use server";

// import { serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil";
// import { BASE_API_URL } from "@/config/api";
import {
serverFetchJson,
serverFetchWithNoContent,
} from "../../utils/fetchUtil";
import { BASE_API_URL } from "../../../config/api";
import { revalidateTag } from "next/cache";
import { cache } from "react";


export interface ShopAndTruck{
id: number;
name: String;
code: String;
addr1: String;
addr2: String;
addr3: String;
contactNo: number;
type: String;
contactEmail: String;
contactName: String;
truckLanceCode: String;
DepartureTime: String;
LoadingSequence: number;
districtReference: Number;
Store_id: Number
}

export interface Shop{
id: number;
name: String;
code: String;
addr3: String;
}

export interface Truck{
id?: number;
truckLanceCode: String;
departureTime: String | number[];
loadingSequence: number;
districtReference: Number;
storeId: Number;
}

export interface SaveTruckLane {
id: number;
truckLanceCode: string;
departureTime: string;
loadingSequence: number;
districtReference: number;
}

export interface DeleteTruckLane {
id: number;
}

export interface SaveTruckRequest {
id?: number | null;
store_id: number;
truckLanceCode: string;
departureTime: string;
shopId: number;
shopName: string;
shopCode: string;
loadingSequence: number;
districtReference?: number | null;
}

export interface MessageResponse {
id: number | null;
name: string | null;
code: string | null;
type: string;
message: string;
errorPosition: string | null;
entity: Truck | null;
}

export const fetchAllShopsAction = cache(async (params?: Record<string, string | number | null>) => {
const endpoint = `${BASE_API_URL}/shop/combo/allShop`;
const qs = params
? Object.entries(params)
.filter(([, v]) => v !== null && v !== undefined && String(v).trim() !== "")
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
.join("&")
: "";

const url = qs ? `${endpoint}?${qs}` : endpoint;

return serverFetchJson<ShopAndTruck[]>(url, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
});

export const findTruckLaneByShopIdAction = cache(async (shopId: number | string) => {
const endpoint = `${BASE_API_URL}/truck/findTruckLane/${shopId}`;
return serverFetchJson<Truck[]>(endpoint, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
});

export const updateTruckLaneAction = async (data: SaveTruckLane) => {
const endpoint = `${BASE_API_URL}/truck/updateTruckLane`;
return serverFetchJson<MessageResponse>(endpoint, {
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
});
};

export const deleteTruckLaneAction = async (data: DeleteTruckLane) => {
const endpoint = `${BASE_API_URL}/truck/deleteTruckLane`;
return serverFetchJson<MessageResponse>(endpoint, {
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
});
};

export const createTruckAction = async (data: SaveTruckRequest) => {
const endpoint = `${BASE_API_URL}/truck/create`;
return serverFetchJson<MessageResponse>(endpoint, {
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
});
};

+ 35
- 0
src/app/api/shop/client.ts Voir le fichier

@@ -0,0 +1,35 @@
"use client";

import {
fetchAllShopsAction,
findTruckLaneByShopIdAction,
updateTruckLaneAction,
deleteTruckLaneAction,
createTruckAction,
type SaveTruckLane,
type DeleteTruckLane,
type SaveTruckRequest,
type MessageResponse
} from "./actions";

export const fetchAllShopsClient = async (params?: Record<string, string | number | null>) => {
return await fetchAllShopsAction(params);
};

export const findTruckLaneByShopIdClient = async (shopId: number | string) => {
return await findTruckLaneByShopIdAction(shopId);
};

export const updateTruckLaneClient = async (data: SaveTruckLane): Promise<MessageResponse> => {
return await updateTruckLaneAction(data);
};

export const deleteTruckLaneClient = async (data: DeleteTruckLane): Promise<MessageResponse> => {
return await deleteTruckLaneAction(data);
};

export const createTruckClient = async (data: SaveTruckRequest): Promise<MessageResponse> => {
return await createTruckAction(data);
};

export default fetchAllShopsClient;

+ 4
- 1
src/app/api/shop/index.ts Voir le fichier

@@ -9,6 +9,8 @@ export interface ShopCombo {
label: string;
}



export const fetchSupplierCombo = cache(async() => {
return serverFetchJson<ShopCombo[]>(`${BASE_API_URL}/shop/combo/supplier`, {
method: "GET",
@@ -17,4 +19,5 @@ export const fetchSupplierCombo = cache(async() => {
tags: ["supplierCombo"]
}
})
})
})


+ 257
- 0
src/components/Shop/Shop.tsx Voir le fichier

@@ -0,0 +1,257 @@
"use client";

import {
Box,
Button,
Card,
CardContent,
Stack,
Typography,
Alert,
CircularProgress,
Chip,
} from "@mui/material";
import { useState, useMemo, useCallback, useEffect } from "react";
import { useRouter } from "next/navigation";
import SearchBox, { Criterion } from "../SearchBox";
import SearchResults, { Column } from "../SearchResults";
import { defaultPagingController } from "../SearchResults/SearchResults";
import { fetchAllShopsClient } from "@/app/api/shop/client";
import type { Shop, ShopAndTruck } from "@/app/api/shop/actions";

type ShopRow = Shop & {
actions?: string;
truckLanceStatus?: "complete" | "missing" | "no-truck";
};

type SearchQuery = {
id: string;
name: string;
code: string;
};

type SearchParamNames = keyof SearchQuery;

const Shop: React.FC = () => {
const router = useRouter();
const [rows, setRows] = useState<ShopRow[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [filters, setFilters] = useState<Record<string, string>>({});
const [pagingController, setPagingController] = useState(defaultPagingController);

// client-side filtered rows (contains-matching)
const filteredRows = useMemo(() => {
const fKeys = Object.keys(filters || {}).filter((k) => String((filters as any)[k]).trim() !== "");
const normalized = (rows || []).filter((r) => {
// apply contains matching for each active filter
for (const k of fKeys) {
const v = String((filters as any)[k] ?? "").trim();
const rv = String((r as any)[k] ?? "").trim();
// Use exact matching for id field, contains matching for others
if (k === "id") {
const numValue = Number(v);
const rvNum = Number(rv);
if (!isNaN(numValue) && !isNaN(rvNum)) {
if (numValue !== rvNum) return false;
} else {
if (v !== rv) return false;
}
} else {
if (!rv.toLowerCase().includes(v.toLowerCase())) return false;
}
}
return true;
});
return normalized;
}, [rows, filters]);

// Check if a shop has missing truckLanceCode data
const checkTruckLanceStatus = useCallback((shopTrucks: ShopAndTruck[]): "complete" | "missing" | "no-truck" => {
if (!shopTrucks || shopTrucks.length === 0) {
return "no-truck";
}

// Check each truckLanceCode entry for missing data
for (const truck of shopTrucks) {
const hasTruckLanceCode = truck.truckLanceCode && String(truck.truckLanceCode).trim() !== "";
const hasDepartureTime = truck.DepartureTime && String(truck.DepartureTime).trim() !== "";
const hasLoadingSequence = truck.LoadingSequence !== null && truck.LoadingSequence !== undefined;
const hasDistrictReference = truck.districtReference !== null && truck.districtReference !== undefined;
const hasStoreId = truck.Store_id !== null && truck.Store_id !== undefined;

// If any required field is missing, return "missing"
if (!hasTruckLanceCode || !hasDepartureTime || !hasLoadingSequence || !hasDistrictReference || !hasStoreId) {
return "missing";
}
}

return "complete";
}, []);

const fetchAllShops = async (params?: Record<string, string>) => {
setLoading(true);
setError(null);
try {
const data = await fetchAllShopsClient(params) as ShopAndTruck[];
console.log("Fetched shops data:", data);
// Group data by shop ID (one shop can have multiple TruckLanceCode entries)
const shopMap = new Map<number, { shop: Shop; trucks: ShopAndTruck[] }>();
(data || []).forEach((item: ShopAndTruck) => {
const shopId = item.id;
if (!shopMap.has(shopId)) {
shopMap.set(shopId, {
shop: {
id: item.id,
name: item.name,
code: item.code,
addr3: item.addr3 ?? "",
},
trucks: [],
});
}
shopMap.get(shopId)!.trucks.push(item);
});

// Convert to ShopRow array with truckLanceStatus
const mapped: ShopRow[] = Array.from(shopMap.values()).map(({ shop, trucks }) => ({
...shop,
truckLanceStatus: checkTruckLanceStatus(trucks),
}));

setRows(mapped);
} catch (err: any) {
console.error("Failed to load shops:", err);
setError(err?.message ?? String(err));
} finally {
setLoading(false);
}
};

// SearchBox onSearch will call this
const handleSearch = (inputs: Record<string, string>) => {
setFilters(inputs);
const params: Record<string, string> = {};
Object.entries(inputs || {}).forEach(([k, v]) => {
if (v != null && String(v).trim() !== "") params[k] = String(v).trim();
});
if (Object.keys(params).length === 0) fetchAllShops();
else fetchAllShops(params);
};

const handleViewDetail = useCallback(
(shop: ShopRow) => {
router.push(`/settings/shop/detail?id=${shop.id}`);
},
[router]
);

const criteria: Criterion<SearchParamNames>[] = [
{ type: "text", label: "id", paramName: "id" },
{ type: "text", label: "code", paramName: "code" },
{ type: "text", label: "name", paramName: "name" },
];

const columns: Column<ShopRow>[] = [
{
name: "id",
label: "Id",
type: "integer",
renderCell: (item) => String(item.id ?? ""),
},
{
name: "code",
label: "Code",
renderCell: (item) => String(item.code ?? ""),
},
{
name: "name",
label: "Name",
renderCell: (item) => String(item.name ?? ""),
},
{
name: "addr3",
label: "Addr3",
renderCell: (item) => String((item as any).addr3 ?? ""),
},
{
name: "truckLanceStatus",
label: "TruckLance Status",
renderCell: (item) => {
const status = item.truckLanceStatus;
if (status === "complete") {
return <Chip label="Complete" color="success" size="small" />;
} else if (status === "missing") {
return <Chip label="Missing Data" color="warning" size="small" />;
} else {
return <Chip label="No TruckLance" color="error" size="small" />;
}
},
},
{
name: "actions",
label: "Actions",
headerAlign: "right",
renderCell: (item) => (
<Button
size="small"
variant="outlined"
onClick={() => handleViewDetail(item)}
>
View Detail
</Button>
),
},
];

useEffect(() => {
fetchAllShops();
}, []);

return (
<Box>
<Card sx={{ mb: 2 }}>
<CardContent>
<SearchBox
criteria={criteria as Criterion<string>[]}
onSearch={handleSearch}
onReset={() => {
setRows([]);
setFilters({});
}}
/>
</CardContent>
</Card>

<Card>
<CardContent>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
<Typography variant="h6">Shop</Typography>
</Stack>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}

{loading ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
<CircularProgress />
</Box>
) : (
<SearchResults
items={filteredRows}
columns={columns}
pagingController={pagingController}
setPagingController={setPagingController}
/>
)}
</CardContent>
</Card>
</Box>
);
};

export default Shop;

+ 761
- 0
src/components/Shop/ShopDetail.tsx Voir le fichier

@@ -0,0 +1,761 @@
"use client";

import {
Box,
Card,
CardContent,
Typography,
CircularProgress,
Alert,
Button,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
TextField,
Stack,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Grid,
Snackbar,
} from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import EditIcon from "@mui/icons-material/Edit";
import SaveIcon from "@mui/icons-material/Save";
import CancelIcon from "@mui/icons-material/Cancel";
import AddIcon from "@mui/icons-material/Add";
import { useRouter, useSearchParams } from "next/navigation";
import { useState, useEffect } from "react";
import { useSession } from "next-auth/react";
import type { Shop, ShopAndTruck, Truck } from "@/app/api/shop/actions";
import {
fetchAllShopsClient,
findTruckLaneByShopIdClient,
updateTruckLaneClient,
deleteTruckLaneClient,
createTruckClient
} from "@/app/api/shop/client";
import type { SessionWithTokens } from "@/config/authConfig";

type ShopDetailData = {
id: number;
name: String;
code: String;
addr1: String;
addr2: String;
addr3: String;
contactNo: number;
type: String;
contactEmail: String;
contactName: String;
};

// Utility function to format departureTime to HH:mm format
const formatDepartureTime = (time: string | number[] | null | undefined): string => {
if (!time) return "-";
// Handle array format [hours, minutes] from API
if (Array.isArray(time) && time.length >= 2) {
const hours = time[0];
const minutes = time[1];
if (typeof hours === 'number' && typeof minutes === 'number' &&
hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) {
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
}
}
const timeStr = String(time).trim();
if (!timeStr || timeStr === "-") return "-";
// If already in HH:mm format, return as is
if (/^\d{1,2}:\d{2}$/.test(timeStr)) {
const [hours, minutes] = timeStr.split(":");
return `${hours.padStart(2, "0")}:${minutes.padStart(2, "0")}`;
}
// Handle decimal format (e.g., "17,0" or "17.0" representing hours)
const decimalMatch = timeStr.match(/^(\d+)[,.](\d+)$/);
if (decimalMatch) {
const hours = parseInt(decimalMatch[1], 10);
const minutes = Math.round(parseFloat(`0.${decimalMatch[2]}`) * 60);
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
}
// Handle single number as hours (e.g., "17" -> "17:00")
const hoursOnly = parseInt(timeStr, 10);
if (!isNaN(hoursOnly) && hoursOnly >= 0 && hoursOnly <= 23) {
return `${hoursOnly.toString().padStart(2, "0")}:00`;
}
// Try to parse as ISO time string or other formats
try {
// If it's already a valid time string, try to extract hours and minutes
const parts = timeStr.split(/[:,\s]/);
if (parts.length >= 2) {
const h = parseInt(parts[0], 10);
const m = parseInt(parts[1], 10);
if (!isNaN(h) && !isNaN(m) && h >= 0 && h <= 23 && m >= 0 && m <= 59) {
return `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`;
}
}
} catch (e) {
// If parsing fails, return original string
}
return timeStr;
};

// Utility function to convert HH:mm format to the format expected by backend
const parseDepartureTimeForBackend = (time: string): string => {
if (!time) return "";
const timeStr = String(time).trim();
// If already in HH:mm format, return as is
if (/^\d{1,2}:\d{2}$/.test(timeStr)) {
return timeStr;
}
// Try to format it
return formatDepartureTime(timeStr);
};

const ShopDetail: React.FC = () => {
const router = useRouter();
const searchParams = useSearchParams();
const shopId = searchParams.get("id");
const { data: session, status: sessionStatus } = useSession() as { data: SessionWithTokens | null; status: string };
const [shopDetail, setShopDetail] = useState<ShopDetailData | null>(null);
const [truckData, setTruckData] = useState<Truck[]>([]);
const [editedTruckData, setEditedTruckData] = useState<Truck[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [editingRowIndex, setEditingRowIndex] = useState<number | null>(null);
const [saving, setSaving] = useState<boolean>(false);
const [addDialogOpen, setAddDialogOpen] = useState<boolean>(false);
const [newTruck, setNewTruck] = useState({
truckLanceCode: "",
departureTime: "",
loadingSequence: 0,
districtReference: 0,
storeId: 2,
});
const [snackbarOpen, setSnackbarOpen] = useState<boolean>(false);
const [snackbarMessage, setSnackbarMessage] = useState<string>("");

useEffect(() => {
// Wait for session to be ready before making API calls
if (sessionStatus === "loading") {
return; // Still loading session
}

// If session is unauthenticated, don't make API calls (middleware will handle redirect)
if (sessionStatus === "unauthenticated" || !session) {
setError("Please log in to view shop details");
setLoading(false);
return;
}

const fetchShopDetail = async () => {
if (!shopId) {
setError("Shop ID is required");
setLoading(false);
return;
}

// Convert shopId to number for proper filtering
const shopIdNum = parseInt(shopId, 10);
if (isNaN(shopIdNum)) {
setError("Invalid Shop ID");
setLoading(false);
return;
}

setLoading(true);
setError(null);
try {
// Fetch shop information - try with ID parameter first, then filter if needed
let shopDataResponse = await fetchAllShopsClient({ id: shopIdNum }) as ShopAndTruck[];
// If no results with ID parameter, fetch all and filter client-side
if (!shopDataResponse || shopDataResponse.length === 0) {
shopDataResponse = await fetchAllShopsClient() as ShopAndTruck[];
}
// Filter to find the shop with matching ID (in case API doesn't filter properly)
const shopData = shopDataResponse?.find((item) => item.id === shopIdNum);
if (shopData) {
// Set shop detail info
setShopDetail({
id: shopData.id ?? 0,
name: shopData.name ?? "",
code: shopData.code ?? "",
addr1: shopData.addr1 ?? "",
addr2: shopData.addr2 ?? "",
addr3: shopData.addr3 ?? "",
contactNo: shopData.contactNo ?? 0,
type: shopData.type ?? "",
contactEmail: shopData.contactEmail ?? "",
contactName: shopData.contactName ?? "",
});
} else {
setError("Shop not found");
setLoading(false);
return;
}

// Fetch truck information using the Truck interface with numeric ID
const trucks = await findTruckLaneByShopIdClient(shopIdNum) as Truck[];
setTruckData(trucks || []);
setEditedTruckData(trucks || []);
setEditingRowIndex(null);
} catch (err: any) {
console.error("Failed to load shop detail:", err);
// Handle errors gracefully - don't trigger auto-logout
const errorMessage = err?.message ?? String(err) ?? "Failed to load shop details";
setError(errorMessage);
} finally {
setLoading(false);
}
};

fetchShopDetail();
}, [shopId, sessionStatus, session]);

const handleEdit = (index: number) => {
setEditingRowIndex(index);
const updated = [...truckData];
updated[index] = { ...updated[index] };
setEditedTruckData(updated);
setError(null);
};

const handleCancel = (index: number) => {
setEditingRowIndex(null);
setEditedTruckData([...truckData]);
setError(null);
};

const handleSave = async (index: number) => {
if (!shopId) {
setError("Shop ID is required");
return;
}

const truck = editedTruckData[index];
if (!truck || !truck.id) {
setError("Invalid truck data");
return;
}

setSaving(true);
setError(null);
try {
// Convert departureTime to proper format if needed
const departureTime = parseDepartureTimeForBackend(String(truck.departureTime || ""));
await updateTruckLaneClient({
id: truck.id,
truckLanceCode: String(truck.truckLanceCode || ""),
departureTime: departureTime,
loadingSequence: Number(truck.loadingSequence) || 0,
districtReference: Number(truck.districtReference) || 0,
});
// Refresh truck data after update
const shopIdNum = parseInt(shopId, 10);
const trucks = await findTruckLaneByShopIdClient(shopIdNum) as Truck[];
setTruckData(trucks || []);
setEditedTruckData(trucks || []);
setEditingRowIndex(null);
} catch (err: any) {
console.error("Failed to save truck data:", err);
setError(err?.message ?? String(err) ?? "Failed to save truck data");
} finally {
setSaving(false);
}
};

const handleTruckFieldChange = (index: number, field: keyof Truck, value: string | number) => {
const updated = [...editedTruckData];
updated[index] = {
...updated[index],
[field]: value,
};
setEditedTruckData(updated);
};

const handleDelete = async (truckId: number) => {
if (!window.confirm("Are you sure you want to delete this truck lane?")) {
return;
}

if (!shopId) {
setError("Shop ID is required");
return;
}

setSaving(true);
setError(null);
try {
await deleteTruckLaneClient({ id: truckId });
// Refresh truck data after delete
const shopIdNum = parseInt(shopId, 10);
const trucks = await findTruckLaneByShopIdClient(shopIdNum) as Truck[];
setTruckData(trucks || []);
setEditedTruckData(trucks || []);
setEditingRowIndex(null);
} catch (err: any) {
console.error("Failed to delete truck lane:", err);
setError(err?.message ?? String(err) ?? "Failed to delete truck lane");
} finally {
setSaving(false);
}
};

const handleOpenAddDialog = () => {
setNewTruck({
truckLanceCode: "",
departureTime: "",
loadingSequence: 0,
districtReference: 0,
storeId: 2,
});
setAddDialogOpen(true);
setError(null);
};

const handleCloseAddDialog = () => {
setAddDialogOpen(false);
setNewTruck({
truckLanceCode: "",
departureTime: "",
loadingSequence: 0,
districtReference: 0,
storeId: 2,
});
};

const handleCreateTruck = async () => {
// Validate all required fields
const missingFields: string[] = [];

if (!shopId || !shopDetail) {
missingFields.push("Shop information");
}

if (!newTruck.truckLanceCode.trim()) {
missingFields.push("TruckLance Code");
}

if (!newTruck.departureTime) {
missingFields.push("Departure Time");
}

if (missingFields.length > 0) {
const message = `Please fill in the following required fields: ${missingFields.join(", ")}`;
setSnackbarMessage(message);
setSnackbarOpen(true);
return;
}

setSaving(true);
setError(null);
try {
const departureTime = parseDepartureTimeForBackend(newTruck.departureTime);
await createTruckClient({
store_id: newTruck.storeId,
truckLanceCode: newTruck.truckLanceCode.trim(),
departureTime: departureTime,
shopId: shopDetail!.id,
shopName: String(shopDetail!.name),
shopCode: String(shopDetail!.code),
loadingSequence: newTruck.loadingSequence,
districtReference: newTruck.districtReference,
});
// Refresh truck data after create
const shopIdNum = parseInt(shopId || "0", 10);
const trucks = await findTruckLaneByShopIdClient(shopIdNum) as Truck[];
setTruckData(trucks || []);
setEditedTruckData(trucks || []);
handleCloseAddDialog();
} catch (err: any) {
console.error("Failed to create truck:", err);
setError(err?.message ?? String(err) ?? "Failed to create truck");
} finally {
setSaving(false);
}
};

if (loading) {
return (
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Box>
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
<Button onClick={() => router.back()}>Go Back</Button>
</Box>
);
}

if (!shopDetail) {
return (
<Box>
<Alert severity="warning" sx={{ mb: 2 }}>
Shop not found
</Alert>
<Button onClick={() => router.back()}>Go Back</Button>
</Box>
);
}

return (
<Box>
<Card sx={{ mb: 2 }}>
<CardContent>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}>
<Typography variant="h6">Shop Information</Typography>
<Button onClick={() => router.back()}>Back</Button>
</Box>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box>
<Typography variant="subtitle2" color="text.secondary" fontWeight="bold">Shop ID</Typography>
<Typography variant="body1" fontWeight="medium">{shopDetail.id}</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Name</Typography>
<Typography variant="body1">{shopDetail.name}</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Code</Typography>
<Typography variant="body1">{shopDetail.code}</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Addr1</Typography>
<Typography variant="body1">{shopDetail.addr1 || "-"}</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Addr2</Typography>
<Typography variant="body1">{shopDetail.addr2 || "-"}</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Addr3</Typography>
<Typography variant="body1">{shopDetail.addr3 || "-"}</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Contact No</Typography>
<Typography variant="body1">{shopDetail.contactNo || "-"}</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Type</Typography>
<Typography variant="body1">{shopDetail.type || "-"}</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Contact Email</Typography>
<Typography variant="body1">{shopDetail.contactEmail || "-"}</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Contact Name</Typography>
<Typography variant="body1">{shopDetail.contactName || "-"}</Typography>
</Box>
</Box>
</CardContent>
</Card>

<Card>
<CardContent>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}>
<Typography variant="h6">Truck Information</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={handleOpenAddDialog}
disabled={editingRowIndex !== null || saving}
>
Add Truck Lane
</Button>
</Box>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>TruckLance Code</TableCell>
<TableCell>Departure Time</TableCell>
<TableCell>Loading Sequence</TableCell>
<TableCell>District Reference</TableCell>
<TableCell>Store ID</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{truckData.length === 0 ? (
<TableRow>
<TableCell colSpan={6} align="center">
<Typography variant="body2" color="text.secondary">
No Truck data available
</Typography>
</TableCell>
</TableRow>
) : (
truckData.map((truck, index) => {
const isEditing = editingRowIndex === index;
const displayTruck = isEditing ? editedTruckData[index] : truck;
return (
<TableRow key={truck.id ?? `truck-${index}`}>
<TableCell>
{isEditing ? (
<TextField
size="small"
value={String(displayTruck?.truckLanceCode || "")}
onChange={(e) => handleTruckFieldChange(index, "truckLanceCode", e.target.value)}
fullWidth
/>
) : (
String(truck.truckLanceCode || "-")
)}
</TableCell>
<TableCell>
{isEditing ? (
<TextField
size="small"
type="time"
value={(() => {
const timeValue = displayTruck?.departureTime;
const formatted = formatDepartureTime(
Array.isArray(timeValue) ? timeValue : (timeValue ? String(timeValue) : null)
);
return formatted !== "-" ? formatted : "";
})()}
onChange={(e) => handleTruckFieldChange(index, "departureTime", e.target.value)}
fullWidth
InputLabelProps={{
shrink: true,
}}
inputProps={{
step: 300, // 5 minutes
}}
/>
) : (
formatDepartureTime(
Array.isArray(truck.departureTime) ? truck.departureTime : (truck.departureTime ? String(truck.departureTime) : null)
)
)}
</TableCell>
<TableCell>
{isEditing ? (
<TextField
size="small"
type="number"
value={displayTruck?.loadingSequence ?? 0}
onChange={(e) => handleTruckFieldChange(index, "loadingSequence", parseInt(e.target.value) || 0)}
fullWidth
/>
) : (
truck.loadingSequence !== null && truck.loadingSequence !== undefined ? String(truck.loadingSequence) : "-"
)}
</TableCell>
<TableCell>
{isEditing ? (
<TextField
size="small"
type="number"
value={displayTruck?.districtReference ?? 0}
onChange={(e) => handleTruckFieldChange(index, "districtReference", parseInt(e.target.value) || 0)}
fullWidth
/>
) : (
truck.districtReference !== null && truck.districtReference !== undefined ? String(truck.districtReference) : "-"
)}
</TableCell>
<TableCell>
{isEditing ? (
<TextField
size="small"
type="number"
value={displayTruck?.storeId ?? 0}
onChange={(e) => handleTruckFieldChange(index, "storeId", parseInt(e.target.value) || 0)}
fullWidth
/>
) : (
truck.storeId !== null && truck.storeId !== undefined ? String(truck.storeId) : "-"
)}
</TableCell>
<TableCell>
<Stack direction="row" spacing={0.5}>
{isEditing ? (
<>
<IconButton
color="primary"
size="small"
onClick={() => handleSave(index)}
disabled={saving}
title="Save changes"
>
<SaveIcon />
</IconButton>
<IconButton
color="default"
size="small"
onClick={() => handleCancel(index)}
disabled={saving}
title="Cancel editing"
>
<CancelIcon />
</IconButton>
</>
) : (
<>
<IconButton
color="primary"
size="small"
onClick={() => handleEdit(index)}
disabled={editingRowIndex !== null}
title="Edit truck lane"
>
<EditIcon />
</IconButton>
{truck.id && (
<IconButton
color="error"
size="small"
onClick={() => handleDelete(truck.id!)}
disabled={saving || editingRowIndex !== null}
title="Delete truck lane"
>
<DeleteIcon />
</IconButton>
)}
</>
)}
</Stack>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>

{/* Add Truck Dialog */}
<Dialog open={addDialogOpen} onClose={handleCloseAddDialog} maxWidth="sm" fullWidth>
<DialogTitle>Add New Truck Lane</DialogTitle>
<DialogContent>
<Box sx={{ pt: 2 }}>
<Grid container spacing={2}>
<Grid item xs={12}>
<TextField
label="TruckLance Code"
fullWidth
required
value={newTruck.truckLanceCode}
onChange={(e) => setNewTruck({ ...newTruck, truckLanceCode: e.target.value })}
disabled={saving}
/>
</Grid>
<Grid item xs={12}>
<TextField
label="Departure Time"
type="time"
fullWidth
required
value={newTruck.departureTime}
onChange={(e) => setNewTruck({ ...newTruck, departureTime: e.target.value })}
disabled={saving}
InputLabelProps={{
shrink: true,
}}
inputProps={{
step: 300, // 5 minutes
}}
/>
</Grid>
<Grid item xs={6}>
<TextField
label="Loading Sequence"
type="number"
fullWidth
value={newTruck.loadingSequence}
onChange={(e) => setNewTruck({ ...newTruck, loadingSequence: parseInt(e.target.value) || 0 })}
disabled={saving}
/>
</Grid>
<Grid item xs={6}>
<TextField
label="District Reference"
type="number"
fullWidth
value={newTruck.districtReference}
onChange={(e) => setNewTruck({ ...newTruck, districtReference: parseInt(e.target.value) || 0 })}
disabled={saving}
/>
</Grid>
<Grid item xs={6}>
<TextField
label="Store ID"
type="number"
fullWidth
value={newTruck.storeId}
onChange={(e) => setNewTruck({ ...newTruck, storeId: parseInt(e.target.value) || 2 })}
disabled={saving}
/>
</Grid>
</Grid>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseAddDialog} disabled={saving}>
Cancel
</Button>
<Button
onClick={handleCreateTruck}
variant="contained"
startIcon={<SaveIcon />}
disabled={saving}
>
{saving ? "Saving..." : "Save"}
</Button>
</DialogActions>
</Dialog>

{/* Snackbar for notifications */}
<Snackbar
open={snackbarOpen}
autoHideDuration={6000}
onClose={() => setSnackbarOpen(false)}
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
>
<Alert
onClose={() => setSnackbarOpen(false)}
severity="warning"
sx={{ width: '100%' }}
>
{snackbarMessage}
</Alert>
</Snackbar>
</Box>
);
};

export default ShopDetail;


+ 15
- 0
src/components/Shop/ShopWrapper.tsx Voir le fichier

@@ -0,0 +1,15 @@
import React from "react";
import GeneralLoading from "../General/GeneralLoading";
import Shop from "./Shop";

interface SubComponents {
Loading: typeof GeneralLoading;
}

const ShopWrapper: React.FC & SubComponents = async () => {
return <Shop />;
};

ShopWrapper.Loading = GeneralLoading;

export default ShopWrapper;

+ 1
- 0
src/components/Shop/index.ts Voir le fichier

@@ -0,0 +1 @@
export { default } from "./ShopWrapper";

Chargement…
Annuler
Enregistrer