From 1f56e1b5bd3b16db7dc8a1f7b13ca496146d81f1 Mon Sep 17 00:00:00 2001 From: "Tommy\\2Fi-Staff" Date: Fri, 20 Mar 2026 12:17:37 +0800 Subject: [PATCH] update shop and truck --- src/components/Shop/Shop.tsx | 7 +- src/components/Shop/ShopDetail.tsx | 597 +++++-------------- src/components/Shop/TruckLaneDetail.tsx | 724 +++++++++++++----------- src/i18n/en/common.json | 17 +- src/i18n/zh/common.json | 10 + 5 files changed, 577 insertions(+), 778 deletions(-) diff --git a/src/components/Shop/Shop.tsx b/src/components/Shop/Shop.tsx index 85a6601..60b695f 100644 --- a/src/components/Shop/Shop.tsx +++ b/src/components/Shop/Shop.tsx @@ -134,11 +134,6 @@ const Shop: React.FC = () => { const loadingSeqNum = loadingSeq != null && loadingSeq !== undefined ? Number(loadingSeq) : null; const hasLoadingSequence = loadingSeqNum !== null && !isNaN(loadingSeqNum) && loadingSeqNum !== 0; - // Check districtReference: must exist and not be 0 - const districtRef = (truck as any).districtReference; - const districtRefNum = districtRef != null && districtRef !== undefined ? Number(districtRef) : null; - const hasDistrictReference = districtRefNum !== null && !isNaN(districtRefNum) && districtRefNum !== 0; - // Check storeId: must exist and not be 0 (can be string "2F"/"4F" or number) // Actual field name in JSON is store_id (underscore, lowercase) const storeId = (truck as any).store_id || (truck as any).storeId || (truck as any).Store_id; @@ -158,7 +153,7 @@ const Shop: React.FC = () => { } // If any required field is missing or equals 0, return "missing" - if (!hasTruckLanceCode || !hasDepartureTime || !hasLoadingSequence || !hasDistrictReference || !storeIdValid) { + if (!hasTruckLanceCode || !hasDepartureTime || !hasLoadingSequence || !storeIdValid) { return "missing"; } } diff --git a/src/components/Shop/ShopDetail.tsx b/src/components/Shop/ShopDetail.tsx index 5442d16..ea6d807 100644 --- a/src/components/Shop/ShopDetail.tsx +++ b/src/components/Shop/ShopDetail.tsx @@ -24,28 +24,24 @@ import { DialogActions, Grid, Snackbar, - Select, - MenuItem, - FormControl, - InputLabel, Autocomplete, } 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 CheckIcon from "@mui/icons-material/Check"; +import CloseIcon from "@mui/icons-material/Close"; import { useRouter, useSearchParams } from "next/navigation"; import { useState, useEffect } from "react"; import { useSession } from "next-auth/react"; import { useTranslation } from "react-i18next"; -import type { Shop, ShopAndTruck, Truck } from "@/app/api/shop/actions"; +import type { ShopAndTruck, Truck } from "@/app/api/shop/actions"; import { fetchAllShopsClient, findTruckLaneByShopIdClient, - updateTruckLaneClient, deleteTruckLaneClient, - createTruckClient + createTruckClient, + findAllUniqueTruckLaneCombinationsClient, } from "@/app/api/shop/client"; import type { SessionWithTokens } from "@/config/authConfig"; import { formatDepartureTime, normalizeStoreId } from "@/app/utils/formatUtil"; @@ -77,6 +73,32 @@ const parseDepartureTimeForBackend = (time: string): string => { return formatDepartureTime(timeStr); }; +/** Label for truck lane picker: code + optional remark (unique combo from API). */ +const getTruckLaneOptionLabel = (lane: Truck): string => { + const code = String(lane.truckLanceCode ?? "").trim(); + const remark = + lane.remark != null && String(lane.remark).trim() !== "" ? String(lane.remark).trim() : null; + return remark ? `${code} — ${remark}` : code || "-"; +}; + +const isSameTruckLaneOption = (a: Truck | null, b: Truck | null): boolean => { + if (a === b) return true; + if (!a || !b) return false; + const sameCode = String(a.truckLanceCode ?? "") === String(b.truckLanceCode ?? ""); + const ra = a.remark != null ? String(a.remark) : ""; + const rb = b.remark != null ? String(b.remark) : ""; + return sameCode && ra === rb; +}; + +/** Build HH:mm string from API departureTime for backend parsing. */ +const departureTimeToStringForSave = (timeValue: Truck["departureTime"]): string => { + const formatted = formatDepartureTime( + Array.isArray(timeValue) ? timeValue : timeValue ? String(timeValue) : null + ); + if (!formatted || formatted === "-") return ""; + return parseDepartureTimeForBackend(formatted); +}; + const ShopDetail: React.FC = () => { const { t } = useTranslation("common"); const router = useRouter(); @@ -85,21 +107,15 @@ const ShopDetail: React.FC = () => { const { data: session, status: sessionStatus } = useSession() as { data: SessionWithTokens | null; status: string }; const [shopDetail, setShopDetail] = useState(null); const [truckData, setTruckData] = useState([]); - const [editedTruckData, setEditedTruckData] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [editingRowIndex, setEditingRowIndex] = useState(null); const [saving, setSaving] = useState(false); + const [confirmingDeleteIndex, setConfirmingDeleteIndex] = useState(null); const [addDialogOpen, setAddDialogOpen] = useState(false); - const [newTruck, setNewTruck] = useState({ - truckLanceCode: "", - departureTime: "", - loadingSequence: 0, - districtReference: 0, - storeId: "2F", - remark: "", - }); - const [uniqueRemarks, setUniqueRemarks] = useState([]); + const [availableTruckLanes, setAvailableTruckLanes] = useState([]); + const [selectedTruckLane, setSelectedTruckLane] = useState(null); + const [addLoadingSequence, setAddLoadingSequence] = useState(0); + const [loadingTruckLanes, setLoadingTruckLanes] = useState(false); const [snackbarOpen, setSnackbarOpen] = useState(false); const [snackbarMessage, setSnackbarMessage] = useState(""); @@ -168,16 +184,6 @@ const ShopDetail: React.FC = () => { // Fetch truck information using the Truck interface with numeric ID const trucks = await findTruckLaneByShopIdClient(shopIdNum) as Truck[]; setTruckData(trucks || []); - setEditedTruckData(trucks || []); - setEditingRowIndex(null); - - // Extract unique remarks from trucks for this shop - const remarks = trucks - ?.map(t => t.remark) - .filter((remark): remark is string => remark != null && String(remark).trim() !== "") - .map(r => String(r).trim()) - .filter((value, index, self) => self.indexOf(value) === index) || []; - setUniqueRemarks(remarks); } catch (err: any) { console.error("Failed to load shop detail:", err); // Handle errors gracefully - don't trigger auto-logout @@ -191,118 +197,7 @@ const ShopDetail: React.FC = () => { fetchShopDetail(); }, [shopId, sessionStatus, session]); - const handleEdit = (index: number) => { - setEditingRowIndex(index); - const updated = [...truckData]; - updated[index] = { ...updated[index] }; - // Normalize departureTime to HH:mm format for editing - if (updated[index].departureTime) { - const timeValue = updated[index].departureTime; - const formatted = formatDepartureTime( - Array.isArray(timeValue) ? timeValue : (timeValue ? String(timeValue) : null) - ); - if (formatted !== "-") { - updated[index].departureTime = formatted; - } - } - // Ensure remark is initialized as string (not null/undefined) - if (updated[index].remark == null) { - updated[index].remark = ""; - } - setEditedTruckData(updated); - setError(null); - }; - - const handleCancel = (index: number) => { - setEditingRowIndex(null); - setEditedTruckData([...truckData]); - setError(null); - }; - - const handleSave = async (index: number) => { - if (!shopId) { - setError(t("Shop ID is required")); - return; - } - - const truck = editedTruckData[index]; - if (!truck || !truck.id) { - setError(t("Invalid shop data")); - return; - } - - setSaving(true); - setError(null); - try { - // Use the departureTime from editedTruckData which is already in HH:mm format from the input field - // If it's already a valid HH:mm string, use it directly; otherwise format it - let departureTime = String(truck.departureTime || "").trim(); - if (!departureTime || departureTime === "-") { - departureTime = ""; - } else if (!/^\d{1,2}:\d{2}$/.test(departureTime)) { - // Only convert if it's not already in HH:mm format - departureTime = parseDepartureTimeForBackend(departureTime); - } - - // Convert storeId to string format (2F or 4F) - const storeIdStr = normalizeStoreId(truck.storeId) || "2F"; - - // Get remark value - use the remark from editedTruckData (user input) - // Only send remark if storeId is "4F", otherwise send null - let remarkValue: string | null = null; - if (storeIdStr === "4F") { - const remark = truck.remark; - if (remark != null && String(remark).trim() !== "") { - remarkValue = String(remark).trim(); - } - } - - await updateTruckLaneClient({ - id: truck.id, - truckLanceCode: String(truck.truckLanceCode || ""), - departureTime: departureTime, - loadingSequence: Number(truck.loadingSequence) || 0, - districtReference: Number(truck.districtReference) || 0, - storeId: storeIdStr, - remark: remarkValue, - }); - - // Refresh truck data after update - const shopIdNum = parseInt(shopId, 10); - const trucks = await findTruckLaneByShopIdClient(shopIdNum) as Truck[]; - setTruckData(trucks || []); - setEditedTruckData(trucks || []); - setEditingRowIndex(null); - - // Update unique remarks - const remarks = trucks - ?.map(t => t.remark) - .filter((remark): remark is string => remark != null && String(remark).trim() !== "") - .map(r => String(r).trim()) - .filter((value, index, self) => self.indexOf(value) === index) || []; - setUniqueRemarks(remarks); - } catch (err: any) { - console.error("Failed to save truck data:", err); - setError(err?.message ?? String(err) ?? t("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(t("Are you sure you want to delete this truck lane?"))) { - return; - } - if (!shopId) { setError(t("Shop ID is required")); return; @@ -312,13 +207,11 @@ const ShopDetail: React.FC = () => { setError(null); try { await deleteTruckLaneClient({ id: truckId }); - - // Refresh truck data after delete + setConfirmingDeleteIndex(null); + 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) ?? t("Failed to delete truck lane")); @@ -327,44 +220,49 @@ const ShopDetail: React.FC = () => { } }; - const handleOpenAddDialog = () => { - setNewTruck({ - truckLanceCode: "", - departureTime: "", - loadingSequence: 0, - districtReference: 0, - storeId: "2F", - remark: "", - }); + const handleOpenAddDialog = async () => { + setSelectedTruckLane(null); + setAddLoadingSequence(0); setAddDialogOpen(true); setError(null); + setLoadingTruckLanes(true); + try { + const lanes = (await findAllUniqueTruckLaneCombinationsClient()) as Truck[]; + setAvailableTruckLanes(lanes || []); + } catch (err: any) { + console.error("Failed to load truck lanes:", err); + setAvailableTruckLanes([]); + setSnackbarMessage( + err?.message ?? String(err) ?? t("Failed to load truck lanes") + ); + setSnackbarOpen(true); + } finally { + setLoadingTruckLanes(false); + } }; const handleCloseAddDialog = () => { setAddDialogOpen(false); - setNewTruck({ - truckLanceCode: "", - departureTime: "", - loadingSequence: 0, - districtReference: 0, - storeId: "2F", - remark: "", - }); + setSelectedTruckLane(null); + setAddLoadingSequence(0); + setAvailableTruckLanes([]); }; const handleCreateTruck = async () => { - // Validate all required fields const missingFields: string[] = []; if (!shopId || !shopDetail) { missingFields.push(t("Shop Information")); } - if (!newTruck.truckLanceCode.trim()) { + if (!selectedTruckLane) { missingFields.push(t("TruckLance Code")); } - if (!newTruck.departureTime) { + const departureTime = selectedTruckLane + ? departureTimeToStringForSave(selectedTruckLane.departureTime) + : ""; + if (!departureTime) { missingFields.push(t("Departure Time")); } @@ -375,36 +273,32 @@ const ShopDetail: React.FC = () => { return; } + const lane = selectedTruckLane!; + const storeIdStr = normalizeStoreId(lane.storeId) || "2F"; + const remarkValue = + storeIdStr === "4F" && lane.remark != null && String(lane.remark).trim() !== "" + ? String(lane.remark).trim() + : null; + setSaving(true); setError(null); try { - const departureTime = parseDepartureTimeForBackend(newTruck.departureTime); - await createTruckClient({ - store_id: newTruck.storeId, - truckLanceCode: newTruck.truckLanceCode.trim(), + store_id: storeIdStr, + truckLanceCode: String(lane.truckLanceCode || "").trim(), departureTime: departureTime, shopId: shopDetail!.id, shopName: String(shopDetail!.name), shopCode: String(shopDetail!.code), - loadingSequence: newTruck.loadingSequence, - districtReference: newTruck.districtReference, - remark: newTruck.storeId === "4F" ? (newTruck.remark?.trim() || null) : null, + loadingSequence: addLoadingSequence, + districtReference: Number(lane.districtReference) || 0, + remark: remarkValue, }); // Refresh truck data after create const shopIdNum = parseInt(shopId || "0", 10); const trucks = await findTruckLaneByShopIdClient(shopIdNum) as Truck[]; setTruckData(trucks || []); - setEditedTruckData(trucks || []); - - // Update unique remarks - const remarks = trucks - ?.map(t => t.remark) - .filter((remark): remark is string => remark != null && String(remark).trim() !== "") - .map(r => String(r).trim()) - .filter((value, index, self) => self.indexOf(value) === index) || []; - setUniqueRemarks(remarks); handleCloseAddDialog(); } catch (err: any) { @@ -502,7 +396,7 @@ const ShopDetail: React.FC = () => { variant="contained" startIcon={} onClick={handleOpenAddDialog} - disabled={editingRowIndex !== null || saving} + disabled={saving} > {t("Add Truck Lane")} @@ -517,7 +411,7 @@ const ShopDetail: React.FC = () => { {t("District Reference")} {t("Store ID")} {t("Remark")} - {t("Actions")} + {t("Actions")} @@ -530,199 +424,70 @@ const ShopDetail: React.FC = () => { ) : ( - truckData.map((truck, index) => { - const isEditing = editingRowIndex === index; - const displayTruck = isEditing ? editedTruckData[index] : truck; - - return ( - - - {isEditing ? ( - handleTruckFieldChange(index, "truckLanceCode", e.target.value)} - fullWidth - /> - ) : ( - String(truck.truckLanceCode || "-") - )} - - - {isEditing ? ( - { - 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) - ) - )} - - - {isEditing ? ( - handleTruckFieldChange(index, "loadingSequence", parseInt(e.target.value) || 0)} - fullWidth - /> - ) : ( - truck.loadingSequence !== null && truck.loadingSequence !== undefined ? String(truck.loadingSequence) : "-" - )} - - - {isEditing ? ( - handleTruckFieldChange(index, "districtReference", parseInt(e.target.value) || 0)} - fullWidth - /> - ) : ( - truck.districtReference !== null && truck.districtReference !== undefined ? String(truck.districtReference) : "-" - )} - - - {isEditing ? ( - - - - ) : ( - normalizeStoreId(truck.storeId) - )} - - - {isEditing ? ( - (() => { - const storeIdStr = normalizeStoreId(displayTruck?.storeId) || "2F"; - const isEditable = storeIdStr === "4F"; - - return ( - { - if (isEditable) { - const remarkValue = typeof newValue === 'string' ? newValue : (newValue || ""); - handleTruckFieldChange(index, "remark", remarkValue); - } - }} - onInputChange={(event, newInputValue, reason) => { - // Only update on user input, not when clearing or selecting - if (isEditable && (reason === 'input' || reason === 'clear')) { - handleTruckFieldChange(index, "remark", newInputValue); - } - }} - renderInput={(params) => ( - - )} - /> - ); - })() - ) : ( - String(truck.remark || "-") - )} - - - - {isEditing ? ( + truckData.map((truck, index) => ( + + {String(truck.truckLanceCode || "-")} + + {formatDepartureTime( + Array.isArray(truck.departureTime) + ? truck.departureTime + : truck.departureTime + ? String(truck.departureTime) + : null + )} + + + {truck.loadingSequence !== null && truck.loadingSequence !== undefined + ? String(truck.loadingSequence) + : "-"} + + + {truck.districtReference !== null && truck.districtReference !== undefined + ? String(truck.districtReference) + : "-"} + + {normalizeStoreId(truck.storeId)} + {String(truck.remark || "-")} + + + {truck.id && ( + confirmingDeleteIndex === index ? ( <> handleSave(index)} + color="error" + onClick={() => handleDelete(truck.id!)} disabled={saving} - title={t("Save changes")} + title={t("Confirm delete")} > - + handleCancel(index)} + color="default" + onClick={() => setConfirmingDeleteIndex(null)} disabled={saving} - title={t("Cancel editing")} + title={t("Cancel")} > - + ) : ( - <> - handleEdit(index)} - disabled={editingRowIndex !== null} - title={t("Edit truck lane")} - > - - - {truck.id && ( - handleDelete(truck.id!)} - disabled={saving || editingRowIndex !== null} - title={t("Delete truck lane")} - > - - - )} - - )} - - - - ); - }) + setConfirmingDeleteIndex(index)} + disabled={saving || confirmingDeleteIndex !== null} + title={t("Delete truck lane")} + > + + + ) + )} + + + + )) )} @@ -737,97 +502,41 @@ const ShopDetail: React.FC = () => { - setNewTruck({ ...newTruck, truckLanceCode: e.target.value })} - disabled={saving} - /> - - - setNewTruck({ ...newTruck, departureTime: e.target.value })} - disabled={saving} - InputLabelProps={{ - shrink: true, - }} - inputProps={{ - step: 300, // 5 minutes + { + setSelectedTruckLane(newValue); }} + getOptionLabel={(option) => getTruckLaneOptionLabel(option)} + isOptionEqualToValue={(option, value) => isSameTruckLaneOption(option, value)} + disabled={saving || loadingTruckLanes} + renderInput={(params) => ( + + )} /> - + setNewTruck({ ...newTruck, loadingSequence: parseInt(e.target.value) || 0 })} + value={addLoadingSequence} + onChange={(e) => setAddLoadingSequence(parseInt(e.target.value, 10) || 0)} disabled={saving} /> - - setNewTruck({ ...newTruck, districtReference: parseInt(e.target.value) || 0 })} - disabled={saving} - /> - - - - {t("Store ID")} - - - - {newTruck.storeId === "4F" && ( - - { - setNewTruck({ ...newTruck, remark: newValue || "" }); - }} - onInputChange={(event, newInputValue) => { - setNewTruck({ ...newTruck, remark: newInputValue }); - }} - renderInput={(params) => ( - - )} - /> - - )} diff --git a/src/components/Shop/TruckLaneDetail.tsx b/src/components/Shop/TruckLaneDetail.tsx index e8eef0f..30fa394 100644 --- a/src/components/Shop/TruckLaneDetail.tsx +++ b/src/components/Shop/TruckLaneDetail.tsx @@ -9,6 +9,7 @@ import { CircularProgress, Alert, Grid, + Stack, Paper, Table, TableBody, @@ -30,6 +31,8 @@ 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 CheckIcon from "@mui/icons-material/Check"; +import CloseIcon from "@mui/icons-material/Close"; import { useState, useEffect } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useTranslation } from "react-i18next"; @@ -45,10 +48,28 @@ import { findAllUniqueShopNamesFromTrucksClient, createTruckClient, findAllByTruckLanceCodeAndDeletedFalseClient, + updateTruckLaneClient, } from "@/app/api/shop/client"; import type { Truck, ShopAndTruck, Shop } from "@/app/api/shop/actions"; import { formatDepartureTime, normalizeStoreId } from "@/app/utils/formatUtil"; +const parseDepartureTimeForBackend = (time: string): string => { + if (!time) return ""; + const timeStr = String(time).trim(); + if (/^\d{1,2}:\d{2}$/.test(timeStr)) { + return timeStr; + } + return formatDepartureTime(timeStr); +}; + +/** HH:mm for HTML time input from API truck departureTime. */ +const truckDepartureTimeToInputValue = (timeValue: Truck["departureTime"]): string => { + const formatted = formatDepartureTime( + Array.isArray(timeValue) ? timeValue : timeValue ? String(timeValue) : null + ); + return formatted !== "-" ? formatted : ""; +}; + const TruckLaneDetail: React.FC = () => { const { t } = useTranslation("common"); const router = useRouter(); @@ -61,19 +82,24 @@ const TruckLaneDetail: React.FC = () => { const [shopsData, setShopsData] = useState([]); const [editedShopsData, setEditedShopsData] = useState([]); const [editingRowIndex, setEditingRowIndex] = useState(null); + const [massEditing, setMassEditing] = useState(false); + const [confirmingDeleteIndex, setConfirmingDeleteIndex] = useState(null); const [loading, setLoading] = useState(true); const [shopsLoading, setShopsLoading] = useState(false); const [saving, setSaving] = useState(false); + const [savingDepartureTime, setSavingDepartureTime] = useState(false); + const [editingDepartureTime, setEditingDepartureTime] = useState(false); + const [departureTimeEdit, setDepartureTimeEdit] = useState(""); const [error, setError] = useState(null); const [allShops, setAllShops] = useState([]); + const [shopTableList, setShopTableList] = useState([]); const [uniqueRemarks, setUniqueRemarks] = useState([]); - const [uniqueShopCodes, setUniqueShopCodes] = useState([]); - const [uniqueShopNames, setUniqueShopNames] = useState([]); const [shopNameByCodeMap, setShopNameByCodeMap] = useState>(new Map()); const [addShopDialogOpen, setAddShopDialogOpen] = useState(false); const [newShop, setNewShop] = useState({ shopName: "", shopCode: "", + shopBranch: "", loadingSequence: 0, remark: "", }); @@ -87,7 +113,7 @@ const TruckLaneDetail: React.FC = () => { useEffect(() => { const fetchAutocompleteData = async () => { try { - const [shopData, remarks, codes, names, allShopsFromShopTable] = await Promise.all([ + const [shopData, remarks, , , allShopsFromShopTable] = await Promise.all([ findAllUniqueShopNamesAndCodesFromTrucksClient() as Promise>, findAllUniqueRemarksFromTrucksClient() as Promise, findAllUniqueShopCodesFromTrucksClient() as Promise, @@ -105,8 +131,6 @@ const TruckLaneDetail: React.FC = () => { setAllShops(shopList); setUniqueRemarks(remarks || []); - setUniqueShopCodes(codes || []); - setUniqueShopNames(names || []); // Create lookup map: shopCode -> shopName from shop table const shopNameMap = new Map(); @@ -116,6 +140,16 @@ const TruckLaneDetail: React.FC = () => { } }); setShopNameByCodeMap(shopNameMap); + + // Deduplicate shop table entries by code + const seen = new Set(); + const uniqueShops = (allShopsFromShopTable || []).filter((shop) => { + const code = String(shop.code || "").trim().toLowerCase(); + if (!code || seen.has(code)) return false; + seen.add(code); + return true; + }); + setShopTableList(uniqueShops); } catch (err) { console.error("Failed to load autocomplete data:", err); } @@ -124,6 +158,16 @@ const TruckLaneDetail: React.FC = () => { fetchAutocompleteData(); }, []); + useEffect(() => { + if (!truckData) { + setDepartureTimeEdit(""); + return; + } + if (!editingDepartureTime) { + setDepartureTimeEdit(truckDepartureTimeToInputValue(truckData.departureTime)); + } + }, [truckData, editingDepartureTime]); + useEffect(() => { // Wait a bit to ensure searchParams are fully available if (!truckLanceCodeParam) { @@ -203,6 +247,99 @@ const TruckLaneDetail: React.FC = () => { } }; + const handleSaveDepartureTimeForAllShops = async () => { + if (!truckLanceCode || !truckData) { + setSnackbar({ + open: true, + message: t("Truck lane information is required"), + severity: "error", + }); + return; + } + + let departureTimeStr = String(departureTimeEdit || "").trim(); + if (!departureTimeStr) { + setSnackbar({ + open: true, + message: `${t("Please fill in the following required fields:")} ${t("Departure Time")}`, + severity: "error", + }); + return; + } + departureTimeStr = parseDepartureTimeForBackend(departureTimeStr); + if (!departureTimeStr) { + setSnackbar({ + open: true, + message: t("Invalid departure time"), + severity: "error", + }); + return; + } + + setSavingDepartureTime(true); + try { + const rows = (await findAllByTruckLanceCodeAndDeletedFalseClient(truckLanceCode)) as Truck[]; + const withIds = (rows || []).filter((r) => r.id != null); + if (withIds.length === 0) { + setSnackbar({ + open: true, + message: t("No trucks found for this truck lane"), + severity: "error", + }); + return; + } + + await Promise.all( + withIds.map((row) => { + const storeIdStr = normalizeStoreId(row.storeId) || "2F"; + let remarkValue: string | null = null; + if (storeIdStr === "4F" && row.remark != null && String(row.remark).trim() !== "") { + remarkValue = String(row.remark).trim(); + } + return updateTruckLaneClient({ + id: row.id!, + truckLanceCode: String(row.truckLanceCode || ""), + departureTime: departureTimeStr, + loadingSequence: Number(row.loadingSequence) || 0, + districtReference: Number(row.districtReference) || 0, + storeId: storeIdStr, + remark: remarkValue, + }); + }) + ); + + setSnackbar({ + open: true, + message: t("Departure time updated for all shops on this truck lane"), + severity: "success", + }); + + const data = (await findAllUniqueTruckLaneCombinationsClient()) as Truck[]; + const truck = data.find((t) => String(t.truckLanceCode || "").trim() === truckLanceCode.trim()); + if (truck) { + setTruckData(truck); + } + await fetchShopsByTruckLane(truckLanceCode); + setEditingDepartureTime(false); + } catch (err: any) { + console.error("Failed to update departure time for truck lane:", err); + setSnackbar({ + open: true, + message: err?.message ?? String(err) ?? t("Failed to save truck data"), + severity: "error", + }); + } finally { + setSavingDepartureTime(false); + } + }; + + const handleCancelDepartureTimeEdit = () => { + if (truckData) { + setDepartureTimeEdit(truckDepartureTimeToInputValue(truckData.departureTime)); + } + setEditingDepartureTime(false); + }; + const handleEdit = (index: number) => { setEditingRowIndex(index); const updated = [...shopsData]; @@ -217,77 +354,89 @@ const TruckLaneDetail: React.FC = () => { const handleSave = async (index: number) => { const shop = editedShopsData[index]; - if (!shop || !shop.truckId) { - setSnackbar({ - open: true, - message: t("Invalid shop data"), - severity: "error", - }); - return; - } + const original = shopsData[index]; + if (!shop || !shop.truckId) { + setSnackbar({ open: true, message: t("Invalid shop data"), severity: "error" }); + return; + } setSaving(true); setError(null); try { - // Get values from edited data - const editedShop = editedShopsData[index]; - const loadingSeq = (editedShop as any)?.LoadingSequence ?? (editedShop as any)?.loadingSequence; + const loadingSeq = (shop as any)?.LoadingSequence ?? (shop as any)?.loadingSequence; const loadingSequenceValue = (loadingSeq !== null && loadingSeq !== undefined) ? Number(loadingSeq) : 0; - - // Get shopName and shopCode from edited data - const shopNameValue = editedShop.name ? String(editedShop.name).trim() : null; - const shopCodeValue = editedShop.code ? String(editedShop.code).trim() : null; - const remarkValue = editedShop.remark ? String(editedShop.remark).trim() : null; - - // Get shopId from editedShop.id (which was set when shopName or shopCode was selected) - // If not found, try to find it from shop table by shopCode - let shopIdValue: number | null = null; - if (editedShop.id && editedShop.id > 0) { - shopIdValue = editedShop.id; - } else if (shopCodeValue) { - // If shopId is 0 (from truck table), try to find it from shop table - try { - const allShopsFromShopTable = await fetchAllShopsClient() as ShopAndTruck[]; - const foundShop = allShopsFromShopTable.find(s => String(s.code).trim() === shopCodeValue); - if (foundShop) { - shopIdValue = foundShop.id; - } - } catch (err) { - console.error("Failed to lookup shopId:", err); - } - } - - if (!shop.truckId) { - setSnackbar({ - open: true, - message: t("Truck ID is required"), - severity: "error", - }); - return; - } + const remarkValue = shop.remark ? String(shop.remark).trim() : null; await updateTruckShopDetailsClient({ id: shop.truckId, - shopId: shopIdValue, - shopName: shopNameValue, - shopCode: shopCodeValue, + shopId: original.id && original.id > 0 ? original.id : null, + shopName: original.name ? String(original.name).trim() : null, + shopCode: original.code ? String(original.code).trim() : null, loadingSequence: loadingSequenceValue, - remark: remarkValue || null, + remark: remarkValue, }); + setSnackbar({ open: true, message: t("Truck shop details updated successfully"), severity: "success" }); + + if (truckLanceCode) { + await fetchShopsByTruckLane(truckLanceCode); + } + setEditingRowIndex(null); + } catch (err: any) { + console.error("Failed to save truck shop details:", err); setSnackbar({ open: true, - message: t("Truck shop details updated successfully"), - severity: "success", + message: err?.message ?? String(err) ?? t("Failed to save truck shop details"), + severity: "error", }); + } finally { + setSaving(false); + } + }; - // Refresh the shops list + const handleStartMassEdit = () => { + setEditedShopsData([...shopsData].map((s) => ({ ...s }))); + setMassEditing(true); + setEditingRowIndex(null); + }; + + const handleCancelMassEdit = () => { + setMassEditing(false); + setEditedShopsData([...shopsData]); + }; + + const handleSaveMassEdit = async () => { + const rowsToSave = editedShopsData.filter((s) => s.truckId != null); + if (rowsToSave.length === 0) return; + + setSaving(true); + setError(null); + try { + await Promise.all( + rowsToSave.map((shop, idx) => { + const original = shopsData[idx]; + const loadingSeq = (shop as any)?.LoadingSequence ?? (shop as any)?.loadingSequence; + const loadingSequenceValue = (loadingSeq !== null && loadingSeq !== undefined) ? Number(loadingSeq) : 0; + const remarkValue = shop.remark ? String(shop.remark).trim() : null; + + return updateTruckShopDetailsClient({ + id: shop.truckId!, + shopId: original.id && original.id > 0 ? original.id : null, + shopName: original.name ? String(original.name).trim() : null, + shopCode: original.code ? String(original.code).trim() : null, + loadingSequence: loadingSequenceValue, + remark: remarkValue, + }); + }) + ); + + setSnackbar({ open: true, message: t("All shops updated successfully"), severity: "success" }); + setMassEditing(false); if (truckLanceCode) { await fetchShopsByTruckLane(truckLanceCode); } - setEditingRowIndex(null); } catch (err: any) { - console.error("Failed to save truck shop details:", err); + console.error("Failed to mass save:", err); setSnackbar({ open: true, message: err?.message ?? String(err) ?? t("Failed to save truck shop details"), @@ -308,44 +457,6 @@ const TruckLaneDetail: React.FC = () => { setEditedShopsData(updated); }; - const handleShopNameChange = (index: number, shop: Shop | null) => { - const updated = [...editedShopsData]; - if (shop) { - updated[index] = { - ...updated[index], - name: shop.name, - code: shop.code, - id: shop.id, // Store shopId for later use - }; - } else { - updated[index] = { - ...updated[index], - name: "", - code: "", - }; - } - setEditedShopsData(updated); - }; - - const handleShopCodeChange = (index: number, shop: Shop | null) => { - const updated = [...editedShopsData]; - if (shop) { - updated[index] = { - ...updated[index], - name: shop.name, - code: shop.code, - id: shop.id, // Store shopId for later use - }; - } else { - updated[index] = { - ...updated[index], - name: "", - code: "", - }; - } - setEditedShopsData(updated); - }; - const handleRemarkChange = (index: number, value: string) => { const updated = [...editedShopsData]; updated[index] = { @@ -356,10 +467,6 @@ const TruckLaneDetail: React.FC = () => { }; const handleDelete = async (truckIdToDelete: number) => { - if (!window.confirm(t("Are you sure you want to delete this truck lane?"))) { - return; - } - try { await deleteTruckLaneClient({ id: truckIdToDelete }); setSnackbar({ @@ -367,8 +474,8 @@ const TruckLaneDetail: React.FC = () => { message: t("Truck lane deleted successfully"), severity: "success", }); - - // Refresh the shops list + setConfirmingDeleteIndex(null); + if (truckLanceCode) { await fetchShopsByTruckLane(truckLanceCode); } @@ -390,6 +497,7 @@ const TruckLaneDetail: React.FC = () => { setNewShop({ shopName: "", shopCode: "", + shopBranch: "", loadingSequence: 0, remark: "", }); @@ -402,76 +510,49 @@ const TruckLaneDetail: React.FC = () => { setNewShop({ shopName: "", shopCode: "", + shopBranch: "", loadingSequence: 0, remark: "", }); }; - const handleNewShopNameChange = (newValue: string | null) => { - if (newValue && typeof newValue === 'string') { - // When a name is selected, try to find matching shop code - const matchingShop = allShops.find(s => String(s.name) === newValue); - if (matchingShop) { - setNewShop({ - ...newShop, - shopName: newValue, - shopCode: String(matchingShop.code || ""), - }); - } else { - // If no matching shop found, allow free text input for shop name - setNewShop({ - ...newShop, - shopName: newValue, - }); - } - } else { - // Clear shop name when selection is cleared (but keep shop code if it exists) + const handleNewShopNameChange = (selectedShop: ShopAndTruck | null) => { + if (selectedShop) { + const shopCode = String(selectedShop.code || "").trim(); + const shopTableName = String(selectedShop.name || "").trim(); + + // Auto-fill branch: use the first existing branch name from the + // truck table for this shopCode, falling back to the shop table name. + const existingBranch = allShops.find( + (s) => + String(s.code).trim().toLowerCase() === shopCode.toLowerCase() && + String(s.name || "").trim() !== "" && + String(s.name || "").trim().toLowerCase() !== "unassign" + ); + setNewShop({ ...newShop, - shopName: "", + shopName: shopTableName, + shopCode: shopCode, + shopBranch: existingBranch ? String(existingBranch.name).trim() : shopTableName, }); - } - }; - - const handleNewShopCodeChange = (newValue: string | null) => { - if (newValue && typeof newValue === 'string') { - // When a code is selected, try to find matching shop name - const matchingShop = allShops.find(s => String(s.code) === newValue); - if (matchingShop) { - setNewShop({ - ...newShop, - shopCode: newValue, - shopName: String(matchingShop.name || ""), - }); - } else { - // If no matching shop found, still set the code (shouldn't happen with restricted selection) - setNewShop({ - ...newShop, - shopCode: newValue, - }); - } } else { - // Clear both fields when selection is cleared setNewShop({ ...newShop, shopName: "", shopCode: "", + shopBranch: "", }); } }; const handleCreateShop = async () => { - // Validate required fields const missingFields: string[] = []; if (!newShop.shopName.trim()) { missingFields.push(t("Shop Name")); } - if (!newShop.shopCode.trim()) { - missingFields.push(t("Shop Code")); - } - if (missingFields.length > 0) { setSnackbar({ open: true, @@ -493,13 +574,11 @@ const TruckLaneDetail: React.FC = () => { setSaving(true); setError(null); try { - // Get storeId from truckData const storeIdValue = truckData.storeId ? (typeof truckData.storeId === 'string' || truckData.storeId instanceof String ? String(truckData.storeId) : String(truckData.storeId)) : "2F"; const displayStoreId = normalizeStoreId(storeIdValue) || "2F"; - // Get departureTime from truckData let departureTimeStr = ""; if (truckData.departureTime) { if (Array.isArray(truckData.departureTime)) { @@ -510,17 +589,10 @@ const TruckLaneDetail: React.FC = () => { } } - // Look up shopId from shop table by shopCode - let shopIdValue: number | null = null; - try { - const allShopsFromShopTable = await fetchAllShopsClient() as ShopAndTruck[]; - const foundShop = allShopsFromShopTable.find(s => String(s.code).trim() === newShop.shopCode.trim()); - if (foundShop) { - shopIdValue = foundShop.id; - } - } catch (err) { - console.error("Failed to lookup shopId:", err); - } + const foundShop = shopTableList.find( + (s) => String(s.code).trim() === newShop.shopCode.trim() + ); + const shopIdValue = foundShop ? foundShop.id : null; // Get remark - only if storeId is "4F" const remarkValue = displayStoreId === "4F" ? (newShop.remark?.trim() || null) : null; @@ -542,7 +614,7 @@ const TruckLaneDetail: React.FC = () => { await updateTruckShopDetailsClient({ id: unassignTruck.id, shopId: shopIdValue || null, - shopName: newShop.shopName.trim(), + shopName: newShop.shopBranch.trim(), shopCode: newShop.shopCode.trim(), loadingSequence: newShop.loadingSequence, remark: remarkValue, @@ -560,7 +632,7 @@ const TruckLaneDetail: React.FC = () => { truckLanceCode: String(truckData.truckLanceCode || ""), departureTime: departureTimeStr, shopId: shopIdValue || 0, - shopName: newShop.shopName.trim(), + shopName: newShop.shopBranch.trim(), shopCode: newShop.shopCode.trim(), loadingSequence: newShop.loadingSequence, remark: remarkValue, @@ -659,16 +731,74 @@ const TruckLaneDetail: React.FC = () => { - + {t("Departure Time")} - - {formatDepartureTime( - Array.isArray(truckData.departureTime) - ? truckData.departureTime - : (truckData.departureTime ? String(truckData.departureTime) : null) - )} - + {!editingDepartureTime ? ( + + + {formatDepartureTime( + Array.isArray(truckData.departureTime) + ? truckData.departureTime + : truckData.departureTime + ? String(truckData.departureTime) + : null + )} + + { + setDepartureTimeEdit(truckDepartureTimeToInputValue(truckData.departureTime)); + setEditingDepartureTime(true); + }} + disabled={savingDepartureTime || saving} + title={t("Edit departure time")} + aria-label={t("Edit departure time")} + > + + + + ) : ( + + setDepartureTimeEdit(e.target.value)} + disabled={savingDepartureTime} + InputLabelProps={{ shrink: true }} + inputProps={{ step: 300 }} + sx={{ minWidth: 140 }} + /> + + + + + + + + )} @@ -691,14 +821,47 @@ const TruckLaneDetail: React.FC = () => { {t("Shops Using This Truck Lane")} - + + {massEditing ? ( + <> + + + + ) : ( + <> + + + + )} + {shopsLoading ? ( @@ -737,113 +900,12 @@ const TruckLaneDetail: React.FC = () => { return shopNameByCodeMap.get(shopCode) || "-"; })()} + {String(shop.name || "-")} + {String(shop.code || "-")} - {/* Shop Branch from truck table (editable) */} - {editingRowIndex === index ? ( - { - if (newValue && typeof newValue === 'string') { - // When a name is selected, try to find matching shop code - const matchingShop = allShops.find(s => String(s.name) === newValue); - if (matchingShop) { - handleShopNameChange(index, matchingShop); - } else { - // If no matching shop found, just update the name - const updated = [...editedShopsData]; - updated[index] = { - ...updated[index], - name: newValue, - }; - setEditedShopsData(updated); - } - } else if (newValue === null) { - handleShopNameChange(index, null); - } - }} - onInputChange={(event, newInputValue, reason) => { - if (reason === 'input') { - // Allow free text input - const updated = [...editedShopsData]; - updated[index] = { - ...updated[index], - name: newInputValue, - }; - setEditedShopsData(updated); - } - }} - renderInput={(params) => ( - - )} - /> - ) : ( - String(shop.name || "-") - )} - - - {editingRowIndex === index ? ( - { - if (newValue && typeof newValue === 'string') { - // When a code is selected, try to find matching shop name - const matchingShop = allShops.find(s => String(s.code) === newValue); - if (matchingShop) { - handleShopCodeChange(index, matchingShop); - } else { - // If no matching shop found, just update the code - const updated = [...editedShopsData]; - updated[index] = { - ...updated[index], - code: newValue, - }; - setEditedShopsData(updated); - } - } else if (newValue === null) { - handleShopCodeChange(index, null); - } - }} - onInputChange={(event, newInputValue, reason) => { - if (reason === 'input') { - // Allow free text input - const updated = [...editedShopsData]; - updated[index] = { - ...updated[index], - code: newInputValue, - }; - setEditedShopsData(updated); - } - }} - renderInput={(params) => ( - - )} - /> - ) : ( - String(shop.code || "-") - )} - - - {editingRowIndex === index ? ( + {(editingRowIndex === index || massEditing) ? ( (() => { - const storeIdValue = truckData.storeId ? (typeof truckData.storeId === 'string' || truckData.storeId instanceof String - ? String(truckData.storeId) - : String(truckData.storeId)) : null; + const storeIdValue = truckData.storeId ? String(truckData.storeId) : null; const isEditable = normalizeStoreId(storeIdValue) === "4F"; return ( { size="small" options={uniqueRemarks} value={String(editedShopsData[index]?.remark || "")} - onChange={(event, newValue) => { + onChange={(_event, newValue) => { if (isEditable) { const remarkValue = typeof newValue === 'string' ? newValue : (newValue || ""); handleRemarkChange(index, remarkValue); } }} - onInputChange={(event, newInputValue, reason) => { + onInputChange={(_event, newInputValue, reason) => { if (isEditable && reason === 'input') { handleRemarkChange(index, newInputValue); } @@ -879,7 +941,7 @@ const TruckLaneDetail: React.FC = () => { )} - {editingRowIndex === index ? ( + {(editingRowIndex === index || massEditing) ? ( { /> ) : ( (() => { - // Handle both PascalCase and camelCase, and check for 0 as valid value const loadingSeq = (shop as any).LoadingSequence ?? (shop as any).loadingSequence; - return (loadingSeq !== null && loadingSeq !== undefined) - ? String(loadingSeq) + return (loadingSeq !== null && loadingSeq !== undefined) + ? String(loadingSeq) : "-"; })() )} - {editingRowIndex === index ? ( + {editingRowIndex === index && !massEditing ? ( <> { size="small" color="primary" onClick={() => handleEdit(index)} + disabled={massEditing || saving || confirmingDeleteIndex !== null} title={t("Edit shop details")} > {shop.truckId && ( - handleDelete(shop.truckId!)} - title={t("Delete truck lane")} - > - - + confirmingDeleteIndex === index ? ( + <> + handleDelete(shop.truckId!)} + disabled={saving} + title={t("Confirm delete")} + > + + + setConfirmingDeleteIndex(null)} + disabled={saving} + title={t("Cancel")} + > + + + + ) : ( + setConfirmingDeleteIndex(index)} + disabled={massEditing || saving || confirmingDeleteIndex !== null} + title={t("Delete truck lane")} + > + + + ) )} )} @@ -966,18 +1052,21 @@ const TruckLaneDetail: React.FC = () => { { + options={shopTableList} + getOptionLabel={(option) => String(option.name || "")} + value={ + shopTableList.find( + (s) => + String(s.name || "").trim() === newShop.shopName && + String(s.code || "").trim() === newShop.shopCode + ) ?? null + } + onChange={(_event, newValue) => { handleNewShopNameChange(newValue); }} - onInputChange={(event, newInputValue, reason) => { - if (reason === 'input') { - // Allow free text input for shop name - setNewShop({ ...newShop, shopName: newInputValue }); - } - }} + isOptionEqualToValue={(option, value) => + String(option.code || "").trim() === String(value.code || "").trim() + } renderInput={(params) => ( { )} /> - - { - handleNewShopCodeChange(newValue); - }} - renderInput={(params) => ( - - )} - /> -