| @@ -4,9 +4,9 @@ import { I18nProvider, getServerI18n } from "@/i18n"; | |||
| import GeneralLoading from "@/components/General/GeneralLoading"; | |||
| export default async function ShopDetailPage() { | |||
| const { t } = await getServerI18n("shop"); | |||
| const { t } = await getServerI18n("shop", "common"); | |||
| return ( | |||
| <I18nProvider namespaces={["shop"]}> | |||
| <I18nProvider namespaces={["shop", "common"]}> | |||
| <Suspense fallback={<GeneralLoading />}> | |||
| <ShopDetail /> | |||
| </Suspense> | |||
| @@ -8,9 +8,9 @@ import { notFound } from "next/navigation"; | |||
| export default async function ShopPage() { | |||
| const { t } = await getServerI18n("shop"); | |||
| const { t } = await getServerI18n("shop", "common"); | |||
| return ( | |||
| <I18nProvider namespaces={["shop"]}> | |||
| <I18nProvider namespaces={["shop", "common"]}> | |||
| <Suspense fallback={<ShopWrapper.Loading />}> | |||
| <ShopWrapper /> | |||
| </Suspense> | |||
| @@ -0,0 +1,16 @@ | |||
| import { Suspense } from "react"; | |||
| import TruckLaneDetail from "@/components/Shop/TruckLaneDetail"; | |||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||
| import GeneralLoading from "@/components/General/GeneralLoading"; | |||
| export default async function TruckLaneDetailPage() { | |||
| const { t } = await getServerI18n("shop", "common"); | |||
| return ( | |||
| <I18nProvider namespaces={["shop", "common"]}> | |||
| <Suspense fallback={<GeneralLoading />}> | |||
| <TruckLaneDetail /> | |||
| </Suspense> | |||
| </I18nProvider> | |||
| ); | |||
| } | |||
| @@ -24,9 +24,11 @@ export interface ShopAndTruck{ | |||
| contactName: String; | |||
| truckLanceCode: String; | |||
| DepartureTime: String; | |||
| LoadingSequence: number; | |||
| LoadingSequence?: number | null; | |||
| districtReference: Number; | |||
| Store_id: Number | |||
| Store_id: Number; | |||
| remark?: String | null; | |||
| truckId?: number; | |||
| } | |||
| export interface Shop{ | |||
| @@ -60,6 +62,11 @@ export interface DeleteTruckLane { | |||
| id: number; | |||
| } | |||
| export interface UpdateLoadingSequenceRequest { | |||
| id: number; | |||
| loadingSequence: number; | |||
| } | |||
| export interface SaveTruckRequest { | |||
| id?: number | null; | |||
| store_id: string; | |||
| @@ -132,6 +139,35 @@ export const deleteTruckLaneAction = async (data: DeleteTruckLane) => { | |||
| 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" }, | |||
| }); | |||
| }; | |||
| export const findAllUniqueTruckLaneCombinationsAction = cache(async () => { | |||
| const endpoint = `${BASE_API_URL}/truck/findAllUniqueTruckLanceCodeAndRemarkCombinations`; | |||
| return serverFetchJson<Truck[]>(endpoint, { | |||
| method: "GET", | |||
| headers: { "Content-Type": "application/json" }, | |||
| }); | |||
| }); | |||
| export const findAllShopsByTruckLanceCodeAndRemarkAction = cache(async (truckLanceCode: string, remark: string) => { | |||
| const endpoint = `${BASE_API_URL}/truck/findAllFromShopAndTruckByTruckLanceCodeAndRemarkAndDeletedFalse`; | |||
| const url = `${endpoint}?truckLanceCode=${encodeURIComponent(truckLanceCode)}&remark=${encodeURIComponent(remark)}`; | |||
| return serverFetchJson<ShopAndTruck[]>(url, { | |||
| method: "GET", | |||
| headers: { "Content-Type": "application/json" }, | |||
| }); | |||
| }); | |||
| export const updateLoadingSequenceAction = async (data: UpdateLoadingSequenceRequest) => { | |||
| const endpoint = `${BASE_API_URL}/truck/updateLoadingSequence`; | |||
| return serverFetchJson<MessageResponse>(endpoint, { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| @@ -6,9 +6,13 @@ import { | |||
| updateTruckLaneAction, | |||
| deleteTruckLaneAction, | |||
| createTruckAction, | |||
| findAllUniqueTruckLaneCombinationsAction, | |||
| findAllShopsByTruckLanceCodeAndRemarkAction, | |||
| updateLoadingSequenceAction, | |||
| type SaveTruckLane, | |||
| type DeleteTruckLane, | |||
| type SaveTruckRequest, | |||
| type UpdateLoadingSequenceRequest, | |||
| type MessageResponse | |||
| } from "./actions"; | |||
| @@ -32,4 +36,16 @@ export const createTruckClient = async (data: SaveTruckRequest): Promise<Message | |||
| return await createTruckAction(data); | |||
| }; | |||
| export const findAllUniqueTruckLaneCombinationsClient = async () => { | |||
| return await findAllUniqueTruckLaneCombinationsAction(); | |||
| }; | |||
| export const findAllShopsByTruckLanceCodeAndRemarkClient = async (truckLanceCode: string, remark: string) => { | |||
| return await findAllShopsByTruckLanceCodeAndRemarkAction(truckLanceCode, remark); | |||
| }; | |||
| export const updateLoadingSequenceClient = async (data: UpdateLoadingSequenceRequest): Promise<MessageResponse> => { | |||
| return await updateLoadingSequenceAction(data); | |||
| }; | |||
| export default fetchAllShopsClient; | |||
| @@ -16,7 +16,10 @@ const pathToLabelMap: { [path: string]: string } = { | |||
| "/settings/qcItem": "Qc Item", | |||
| "/settings/qrCodeHandle": "QR Code Handle", | |||
| "/settings/rss": "Demand Forecast Setting", | |||
| "/settings/equipment": "Equipment", | |||
| "/settings/equipment": "Equipment", | |||
| "/settings/shop": "Shop", | |||
| "/settings/shop/detail": "Shop Detail", | |||
| "/settings/shop/truckdetail": "Truck Lane Detail", | |||
| "/scheduling/rough": "Demand Forecast", | |||
| "/scheduling/rough/edit": "FG & Material Demand Forecast Detail", | |||
| "/scheduling/detailed": "Detail Scheduling", | |||
| @@ -10,14 +10,22 @@ import { | |||
| Alert, | |||
| CircularProgress, | |||
| Chip, | |||
| Tabs, | |||
| Tab, | |||
| Select, | |||
| MenuItem, | |||
| FormControl, | |||
| InputLabel, | |||
| } from "@mui/material"; | |||
| import { useState, useMemo, useCallback, useEffect } from "react"; | |||
| import { useRouter } from "next/navigation"; | |||
| import { useTranslation } from "react-i18next"; | |||
| 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"; | |||
| import TruckLane from "./TruckLane"; | |||
| type ShopRow = Shop & { | |||
| actions?: string; | |||
| @@ -33,17 +41,20 @@ type SearchQuery = { | |||
| type SearchParamNames = keyof SearchQuery; | |||
| const Shop: React.FC = () => { | |||
| const { t } = useTranslation("common"); | |||
| const router = useRouter(); | |||
| const [activeTab, setActiveTab] = useState<number>(0); | |||
| 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 [statusFilter, setStatusFilter] = useState<string>("all"); | |||
| const [pagingController, setPagingController] = useState(defaultPagingController); | |||
| // client-side filtered rows (contains-matching) | |||
| // client-side filtered rows (contains-matching + status filter) | |||
| const filteredRows = useMemo(() => { | |||
| const fKeys = Object.keys(filters || {}).filter((k) => String((filters as any)[k]).trim() !== ""); | |||
| const normalized = (rows || []).filter((r) => { | |||
| let normalized = (rows || []).filter((r) => { | |||
| // apply contains matching for each active filter | |||
| for (const k of fKeys) { | |||
| const v = String((filters as any)[k] ?? "").trim(); | |||
| @@ -63,8 +74,16 @@ const Shop: React.FC = () => { | |||
| } | |||
| return true; | |||
| }); | |||
| // Apply status filter | |||
| if (statusFilter !== "all") { | |||
| normalized = normalized.filter((r) => { | |||
| return r.truckLanceStatus === statusFilter; | |||
| }); | |||
| } | |||
| return normalized; | |||
| }, [rows, filters]); | |||
| }, [rows, filters, statusFilter]); | |||
| // Check if a shop has missing truckLanceCode data | |||
| const checkTruckLanceStatus = useCallback((shopTrucks: ShopAndTruck[]): "complete" | "missing" | "no-truck" => { | |||
| @@ -72,16 +91,73 @@ const Shop: React.FC = () => { | |||
| return "no-truck"; | |||
| } | |||
| // Check if shop has any actual truck lanes (not just null entries from LEFT JOIN) | |||
| // A shop with no trucks will have entries with null truckLanceCode | |||
| const hasAnyTruckLane = shopTrucks.some((truck) => { | |||
| const truckLanceCode = (truck as any).truckLanceCode; | |||
| return truckLanceCode != null && String(truckLanceCode).trim() !== ""; | |||
| }); | |||
| if (!hasAnyTruckLane) { | |||
| 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; | |||
| // Skip entries without truckLanceCode (they're from LEFT JOIN when no trucks exist) | |||
| const truckLanceCode = (truck as any).truckLanceCode; | |||
| if (!truckLanceCode || String(truckLanceCode).trim() === "") { | |||
| continue; // Skip this entry, it's not a real truck lane | |||
| } | |||
| // Check truckLanceCode: must exist and not be empty (already validated above) | |||
| const hasTruckLanceCode = truckLanceCode != null && String(truckLanceCode).trim() !== ""; | |||
| // Check departureTime: must exist and not be empty | |||
| // Can be array format [hours, minutes] or string format | |||
| const departureTime = (truck as any).departureTime || (truck as any).DepartureTime; | |||
| let hasDepartureTime = false; | |||
| if (departureTime != null) { | |||
| if (Array.isArray(departureTime) && departureTime.length >= 2) { | |||
| // Array format [hours, minutes] | |||
| hasDepartureTime = true; | |||
| } else { | |||
| // String format | |||
| const timeStr = String(departureTime).trim(); | |||
| hasDepartureTime = timeStr !== "" && timeStr !== "-"; | |||
| } | |||
| } | |||
| // Check loadingSequence: must exist and not be 0 | |||
| const loadingSeq = (truck as any).loadingSequence || (truck as any).LoadingSequence; | |||
| 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; | |||
| let storeIdValid = false; | |||
| if (storeId != null && storeId !== undefined && storeId !== "") { | |||
| const storeIdStr = String(storeId).trim(); | |||
| // If it's "2F" or "4F", it's valid (not 0) | |||
| if (storeIdStr === "2F" || storeIdStr === "4F") { | |||
| storeIdValid = true; | |||
| } else { | |||
| const storeIdNum = Number(storeId); | |||
| // If it's a valid number and not 0, it's valid | |||
| if (!isNaN(storeIdNum) && storeIdNum !== 0) { | |||
| storeIdValid = true; | |||
| } | |||
| } | |||
| } | |||
| // If any required field is missing, return "missing" | |||
| if (!hasTruckLanceCode || !hasDepartureTime || !hasLoadingSequence || !hasDistrictReference || !hasStoreId) { | |||
| // If any required field is missing or equals 0, return "missing" | |||
| if (!hasTruckLanceCode || !hasDepartureTime || !hasLoadingSequence || !hasDistrictReference || !storeIdValid) { | |||
| return "missing"; | |||
| } | |||
| } | |||
| @@ -149,50 +225,50 @@ const Shop: React.FC = () => { | |||
| ); | |||
| const criteria: Criterion<SearchParamNames>[] = [ | |||
| { type: "text", label: "id", paramName: "id" }, | |||
| { type: "text", label: "code", paramName: "code" }, | |||
| { type: "text", label: "name", paramName: "name" }, | |||
| { type: "text", label: t("id"), paramName: "id" }, | |||
| { type: "text", label: t("code"), paramName: "code" }, | |||
| { type: "text", label: t("name"), paramName: "name" }, | |||
| ]; | |||
| const columns: Column<ShopRow>[] = [ | |||
| { | |||
| name: "id", | |||
| label: "Id", | |||
| label: t("id"), | |||
| type: "integer", | |||
| renderCell: (item) => String(item.id ?? ""), | |||
| }, | |||
| { | |||
| name: "code", | |||
| label: "Code", | |||
| label: t("Code"), | |||
| renderCell: (item) => String(item.code ?? ""), | |||
| }, | |||
| { | |||
| name: "name", | |||
| label: "Name", | |||
| label: t("Name"), | |||
| renderCell: (item) => String(item.name ?? ""), | |||
| }, | |||
| { | |||
| name: "addr3", | |||
| label: "Addr3", | |||
| label: t("Addr3"), | |||
| renderCell: (item) => String((item as any).addr3 ?? ""), | |||
| }, | |||
| { | |||
| name: "truckLanceStatus", | |||
| label: "TruckLance Status", | |||
| label: t("TruckLance Status"), | |||
| renderCell: (item) => { | |||
| const status = item.truckLanceStatus; | |||
| if (status === "complete") { | |||
| return <Chip label="Complete" color="success" size="small" />; | |||
| return <Chip label={t("Complete")} color="success" size="small" />; | |||
| } else if (status === "missing") { | |||
| return <Chip label="Missing Data" color="warning" size="small" />; | |||
| return <Chip label={t("Missing Data")} color="warning" size="small" />; | |||
| } else { | |||
| return <Chip label="No TruckLance" color="error" size="small" />; | |||
| return <Chip label={t("No TruckLance")} color="error" size="small" />; | |||
| } | |||
| }, | |||
| }, | |||
| { | |||
| name: "actions", | |||
| label: "Actions", | |||
| label: t("Actions"), | |||
| headerAlign: "right", | |||
| renderCell: (item) => ( | |||
| <Button | |||
| @@ -200,56 +276,96 @@ const Shop: React.FC = () => { | |||
| variant="outlined" | |||
| onClick={() => handleViewDetail(item)} | |||
| > | |||
| View Detail | |||
| {t("View Detail")} | |||
| </Button> | |||
| ), | |||
| }, | |||
| ]; | |||
| useEffect(() => { | |||
| fetchAllShops(); | |||
| }, []); | |||
| if (activeTab === 0) { | |||
| fetchAllShops(); | |||
| } | |||
| }, [activeTab]); | |||
| const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { | |||
| setActiveTab(newValue); | |||
| }; | |||
| return ( | |||
| <Box> | |||
| <Card sx={{ mb: 2 }}> | |||
| <CardContent> | |||
| <SearchBox | |||
| criteria={criteria as Criterion<string>[]} | |||
| onSearch={handleSearch} | |||
| onReset={() => { | |||
| setRows([]); | |||
| setFilters({}); | |||
| <Tabs | |||
| value={activeTab} | |||
| onChange={handleTabChange} | |||
| sx={{ | |||
| mb: 3, | |||
| borderBottom: 1, | |||
| borderColor: 'divider' | |||
| }} | |||
| /> | |||
| </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> | |||
| )} | |||
| > | |||
| <Tab label={t("Shop")} /> | |||
| <Tab label={t("Truck Lane")} /> | |||
| </Tabs> | |||
| {loading ? ( | |||
| <Box sx={{ display: "flex", justifyContent: "center", p: 4 }}> | |||
| <CircularProgress /> | |||
| </Box> | |||
| ) : ( | |||
| <SearchResults | |||
| items={filteredRows} | |||
| columns={columns} | |||
| pagingController={pagingController} | |||
| setPagingController={setPagingController} | |||
| {activeTab === 0 && ( | |||
| <SearchBox | |||
| criteria={criteria as Criterion<string>[]} | |||
| onSearch={handleSearch} | |||
| onReset={() => { | |||
| setRows([]); | |||
| setFilters({}); | |||
| }} | |||
| /> | |||
| )} | |||
| </CardContent> | |||
| </Card> | |||
| {activeTab === 0 && ( | |||
| <Card> | |||
| <CardContent> | |||
| <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}> | |||
| <Typography variant="h6">{t("Shop")}</Typography> | |||
| <FormControl size="small" sx={{ minWidth: 200 }}> | |||
| <InputLabel>{t("Filter by Status")}</InputLabel> | |||
| <Select | |||
| value={statusFilter} | |||
| label={t("Filter by Status")} | |||
| onChange={(e) => setStatusFilter(e.target.value)} | |||
| > | |||
| <MenuItem value="all">{t("All")}</MenuItem> | |||
| <MenuItem value="complete">{t("Complete")}</MenuItem> | |||
| <MenuItem value="missing">{t("Missing Data")}</MenuItem> | |||
| <MenuItem value="no-truck">{t("No TruckLance")}</MenuItem> | |||
| </Select> | |||
| </FormControl> | |||
| </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> | |||
| )} | |||
| {activeTab === 1 && ( | |||
| <TruckLane /> | |||
| )} | |||
| </Box> | |||
| ); | |||
| }; | |||
| @@ -38,6 +38,7 @@ 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 { useTranslation } from "react-i18next"; | |||
| import type { Shop, ShopAndTruck, Truck } from "@/app/api/shop/actions"; | |||
| import { | |||
| fetchAllShopsClient, | |||
| @@ -131,6 +132,7 @@ const parseDepartureTimeForBackend = (time: string): string => { | |||
| }; | |||
| const ShopDetail: React.FC = () => { | |||
| const { t } = useTranslation("common"); | |||
| const router = useRouter(); | |||
| const searchParams = useSearchParams(); | |||
| const shopId = searchParams.get("id"); | |||
| @@ -163,14 +165,14 @@ const ShopDetail: React.FC = () => { | |||
| // 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"); | |||
| setError(t("Please log in to view shop details")); | |||
| setLoading(false); | |||
| return; | |||
| } | |||
| const fetchShopDetail = async () => { | |||
| if (!shopId) { | |||
| setError("Shop ID is required"); | |||
| setError(t("Shop ID is required")); | |||
| setLoading(false); | |||
| return; | |||
| } | |||
| @@ -178,7 +180,7 @@ const ShopDetail: React.FC = () => { | |||
| // Convert shopId to number for proper filtering | |||
| const shopIdNum = parseInt(shopId, 10); | |||
| if (isNaN(shopIdNum)) { | |||
| setError("Invalid Shop ID"); | |||
| setError(t("Invalid Shop ID")); | |||
| setLoading(false); | |||
| return; | |||
| } | |||
| @@ -212,7 +214,7 @@ const ShopDetail: React.FC = () => { | |||
| contactName: shopData.contactName ?? "", | |||
| }); | |||
| } else { | |||
| setError("Shop not found"); | |||
| setError(t("Shop not found")); | |||
| setLoading(false); | |||
| return; | |||
| } | |||
| @@ -233,7 +235,7 @@ const ShopDetail: React.FC = () => { | |||
| } 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"; | |||
| const errorMessage = err?.message ?? String(err) ?? t("Failed to load shop details"); | |||
| setError(errorMessage); | |||
| } finally { | |||
| setLoading(false); | |||
| @@ -273,13 +275,13 @@ const ShopDetail: React.FC = () => { | |||
| const handleSave = async (index: number) => { | |||
| if (!shopId) { | |||
| setError("Shop ID is required"); | |||
| setError(t("Shop ID is required")); | |||
| return; | |||
| } | |||
| const truck = editedTruckData[index]; | |||
| if (!truck || !truck.id) { | |||
| setError("Invalid truck data"); | |||
| setError(t("Invalid shop data")); | |||
| return; | |||
| } | |||
| @@ -335,7 +337,7 @@ const ShopDetail: React.FC = () => { | |||
| setUniqueRemarks(remarks); | |||
| } catch (err: any) { | |||
| console.error("Failed to save truck data:", err); | |||
| setError(err?.message ?? String(err) ?? "Failed to save truck data"); | |||
| setError(err?.message ?? String(err) ?? t("Failed to save truck data")); | |||
| } finally { | |||
| setSaving(false); | |||
| } | |||
| @@ -351,12 +353,12 @@ const ShopDetail: React.FC = () => { | |||
| }; | |||
| const handleDelete = async (truckId: number) => { | |||
| if (!window.confirm("Are you sure you want to delete this truck lane?")) { | |||
| if (!window.confirm(t("Are you sure you want to delete this truck lane?"))) { | |||
| return; | |||
| } | |||
| if (!shopId) { | |||
| setError("Shop ID is required"); | |||
| setError(t("Shop ID is required")); | |||
| return; | |||
| } | |||
| @@ -373,7 +375,7 @@ const ShopDetail: React.FC = () => { | |||
| setEditingRowIndex(null); | |||
| } catch (err: any) { | |||
| console.error("Failed to delete truck lane:", err); | |||
| setError(err?.message ?? String(err) ?? "Failed to delete truck lane"); | |||
| setError(err?.message ?? String(err) ?? t("Failed to delete truck lane")); | |||
| } finally { | |||
| setSaving(false); | |||
| } | |||
| @@ -409,19 +411,19 @@ const ShopDetail: React.FC = () => { | |||
| const missingFields: string[] = []; | |||
| if (!shopId || !shopDetail) { | |||
| missingFields.push("Shop information"); | |||
| missingFields.push(t("Shop Information")); | |||
| } | |||
| if (!newTruck.truckLanceCode.trim()) { | |||
| missingFields.push("TruckLance Code"); | |||
| missingFields.push(t("TruckLance Code")); | |||
| } | |||
| if (!newTruck.departureTime) { | |||
| missingFields.push("Departure Time"); | |||
| missingFields.push(t("Departure Time")); | |||
| } | |||
| if (missingFields.length > 0) { | |||
| const message = `Please fill in the following required fields: ${missingFields.join(", ")}`; | |||
| const message = `${t("Please fill in the following required fields:")} ${missingFields.join(", ")}`; | |||
| setSnackbarMessage(message); | |||
| setSnackbarOpen(true); | |||
| return; | |||
| @@ -461,7 +463,7 @@ const ShopDetail: React.FC = () => { | |||
| handleCloseAddDialog(); | |||
| } catch (err: any) { | |||
| console.error("Failed to create truck:", err); | |||
| setError(err?.message ?? String(err) ?? "Failed to create truck"); | |||
| setError(err?.message ?? String(err) ?? t("Failed to create truck")); | |||
| } finally { | |||
| setSaving(false); | |||
| } | |||
| @@ -475,12 +477,12 @@ const ShopDetail: React.FC = () => { | |||
| ); | |||
| } | |||
| if (error) { | |||
| return ( | |||
| return ( | |||
| <Box> | |||
| <Alert severity="error" sx={{ mb: 2 }}> | |||
| {error} | |||
| </Alert> | |||
| <Button onClick={() => router.back()}>Go Back</Button> | |||
| <Button onClick={() => router.back()}>{t("Back")}</Button> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -489,9 +491,9 @@ const ShopDetail: React.FC = () => { | |||
| return ( | |||
| <Box> | |||
| <Alert severity="warning" sx={{ mb: 2 }}> | |||
| Shop not found | |||
| {t("Shop not found")} | |||
| </Alert> | |||
| <Button onClick={() => router.back()}>Go Back</Button> | |||
| <Button onClick={() => router.back()}>{t("Back")}</Button> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -501,49 +503,45 @@ const ShopDetail: React.FC = () => { | |||
| <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> | |||
| <Typography variant="h6">{t("Shop Information")}</Typography> | |||
| <Button onClick={() => router.back()}>{t("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="subtitle2" color="text.secondary" fontWeight="bold">{t("Shop ID")}</Typography> | |||
| <Typography variant="body1" fontWeight="medium">{shopDetail.id}</Typography> | |||
| </Box> | |||
| <Box> | |||
| <Typography variant="subtitle2" color="text.secondary">Name</Typography> | |||
| <Typography variant="subtitle2" color="text.secondary">{t("Name")}</Typography> | |||
| <Typography variant="body1">{shopDetail.name}</Typography> | |||
| </Box> | |||
| <Box> | |||
| <Typography variant="subtitle2" color="text.secondary">Code</Typography> | |||
| <Typography variant="subtitle2" color="text.secondary">{t("Code")}</Typography> | |||
| <Typography variant="body1">{shopDetail.code}</Typography> | |||
| </Box> | |||
| <Box> | |||
| <Typography variant="subtitle2" color="text.secondary">Addr1</Typography> | |||
| <Typography variant="subtitle2" color="text.secondary">{t("Addr1")}</Typography> | |||
| <Typography variant="body1">{shopDetail.addr1 || "-"}</Typography> | |||
| </Box> | |||
| <Box> | |||
| <Typography variant="subtitle2" color="text.secondary">Addr2</Typography> | |||
| <Typography variant="subtitle2" color="text.secondary">{t("Addr2")}</Typography> | |||
| <Typography variant="body1">{shopDetail.addr2 || "-"}</Typography> | |||
| </Box> | |||
| <Box> | |||
| <Typography variant="subtitle2" color="text.secondary">Addr3</Typography> | |||
| <Typography variant="subtitle2" color="text.secondary">{t("Addr3")}</Typography> | |||
| <Typography variant="body1">{shopDetail.addr3 || "-"}</Typography> | |||
| </Box> | |||
| <Box> | |||
| <Typography variant="subtitle2" color="text.secondary">Contact No</Typography> | |||
| <Typography variant="subtitle2" color="text.secondary">{t("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="subtitle2" color="text.secondary">{t("Contact Email")}</Typography> | |||
| <Typography variant="body1">{shopDetail.contactEmail || "-"}</Typography> | |||
| </Box> | |||
| <Box> | |||
| <Typography variant="subtitle2" color="text.secondary">Contact Name</Typography> | |||
| <Typography variant="subtitle2" color="text.secondary">{t("Contact Name")}</Typography> | |||
| <Typography variant="body1">{shopDetail.contactName || "-"}</Typography> | |||
| </Box> | |||
| </Box> | |||
| @@ -553,27 +551,27 @@ const ShopDetail: React.FC = () => { | |||
| <Card> | |||
| <CardContent> | |||
| <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}> | |||
| <Typography variant="h6">Truck Information</Typography> | |||
| <Typography variant="h6">{t("Truck Information")}</Typography> | |||
| <Button | |||
| variant="contained" | |||
| startIcon={<AddIcon />} | |||
| onClick={handleOpenAddDialog} | |||
| disabled={editingRowIndex !== null || saving} | |||
| > | |||
| Add Truck Lane | |||
| {t("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>Remark</TableCell> | |||
| <TableCell>Actions</TableCell> | |||
| <TableCell>{t("TruckLance Code")}</TableCell> | |||
| <TableCell>{t("Departure Time")}</TableCell> | |||
| <TableCell>{t("Loading Sequence")}</TableCell> | |||
| <TableCell>{t("District Reference")}</TableCell> | |||
| <TableCell>{t("Store ID")}</TableCell> | |||
| <TableCell>{t("Remark")}</TableCell> | |||
| <TableCell>{t("Actions")}</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| @@ -581,7 +579,7 @@ const ShopDetail: React.FC = () => { | |||
| <TableRow> | |||
| <TableCell colSpan={7} align="center"> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| No Truck data available | |||
| {t("No Truck data available")} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| @@ -725,7 +723,7 @@ const ShopDetail: React.FC = () => { | |||
| <TextField | |||
| {...params} | |||
| fullWidth | |||
| placeholder={isEditable ? "Enter or select remark" : "Not editable for this Store ID"} | |||
| placeholder={isEditable ? t("Enter or select remark") : t("Not editable for this Store ID")} | |||
| disabled={!isEditable} | |||
| /> | |||
| )} | |||
| @@ -745,7 +743,7 @@ const ShopDetail: React.FC = () => { | |||
| size="small" | |||
| onClick={() => handleSave(index)} | |||
| disabled={saving} | |||
| title="Save changes" | |||
| title={t("Save changes")} | |||
| > | |||
| <SaveIcon /> | |||
| </IconButton> | |||
| @@ -754,7 +752,7 @@ const ShopDetail: React.FC = () => { | |||
| size="small" | |||
| onClick={() => handleCancel(index)} | |||
| disabled={saving} | |||
| title="Cancel editing" | |||
| title={t("Cancel editing")} | |||
| > | |||
| <CancelIcon /> | |||
| </IconButton> | |||
| @@ -766,7 +764,7 @@ const ShopDetail: React.FC = () => { | |||
| size="small" | |||
| onClick={() => handleEdit(index)} | |||
| disabled={editingRowIndex !== null} | |||
| title="Edit truck lane" | |||
| title={t("Edit truck lane")} | |||
| > | |||
| <EditIcon /> | |||
| </IconButton> | |||
| @@ -776,7 +774,7 @@ const ShopDetail: React.FC = () => { | |||
| size="small" | |||
| onClick={() => handleDelete(truck.id!)} | |||
| disabled={saving || editingRowIndex !== null} | |||
| title="Delete truck lane" | |||
| title={t("Delete truck lane")} | |||
| > | |||
| <DeleteIcon /> | |||
| </IconButton> | |||
| @@ -797,13 +795,13 @@ const ShopDetail: React.FC = () => { | |||
| {/* Add Truck Dialog */} | |||
| <Dialog open={addDialogOpen} onClose={handleCloseAddDialog} maxWidth="sm" fullWidth> | |||
| <DialogTitle>Add New Truck Lane</DialogTitle> | |||
| <DialogTitle>{t("Add New Truck Lane")}</DialogTitle> | |||
| <DialogContent> | |||
| <Box sx={{ pt: 2 }}> | |||
| <Grid container spacing={2}> | |||
| <Grid item xs={12}> | |||
| <TextField | |||
| label="TruckLance Code" | |||
| label={t("TruckLance Code")} | |||
| fullWidth | |||
| required | |||
| value={newTruck.truckLanceCode} | |||
| @@ -813,7 +811,7 @@ const ShopDetail: React.FC = () => { | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <TextField | |||
| label="Departure Time" | |||
| label={t("Departure Time")} | |||
| type="time" | |||
| fullWidth | |||
| required | |||
| @@ -830,7 +828,7 @@ const ShopDetail: React.FC = () => { | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label="Loading Sequence" | |||
| label={t("Loading Sequence")} | |||
| type="number" | |||
| fullWidth | |||
| value={newTruck.loadingSequence} | |||
| @@ -840,7 +838,7 @@ const ShopDetail: React.FC = () => { | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label="District Reference" | |||
| label={t("District Reference")} | |||
| type="number" | |||
| fullWidth | |||
| value={newTruck.districtReference} | |||
| @@ -850,10 +848,10 @@ const ShopDetail: React.FC = () => { | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <FormControl fullWidth> | |||
| <InputLabel>Store ID</InputLabel> | |||
| <InputLabel>{t("Store ID")}</InputLabel> | |||
| <Select | |||
| value={newTruck.storeId} | |||
| label="Store ID" | |||
| label={t("Store ID")} | |||
| onChange={(e) => { | |||
| const newStoreId = e.target.value; | |||
| setNewTruck({ | |||
| @@ -884,9 +882,9 @@ const ShopDetail: React.FC = () => { | |||
| renderInput={(params) => ( | |||
| <TextField | |||
| {...params} | |||
| label="Remark" | |||
| label={t("Remark")} | |||
| fullWidth | |||
| placeholder="Enter or select remark" | |||
| placeholder={t("Enter or select remark")} | |||
| disabled={saving} | |||
| /> | |||
| )} | |||
| @@ -898,7 +896,7 @@ const ShopDetail: React.FC = () => { | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={handleCloseAddDialog} disabled={saving}> | |||
| Cancel | |||
| {t("Cancel")} | |||
| </Button> | |||
| <Button | |||
| onClick={handleCreateTruck} | |||
| @@ -906,7 +904,7 @@ const ShopDetail: React.FC = () => { | |||
| startIcon={<SaveIcon />} | |||
| disabled={saving} | |||
| > | |||
| {saving ? "Saving..." : "Save"} | |||
| {saving ? t("Submitting...") : t("Save")} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| @@ -0,0 +1,269 @@ | |||
| "use client"; | |||
| import { | |||
| Box, | |||
| Card, | |||
| CardContent, | |||
| Typography, | |||
| Table, | |||
| TableBody, | |||
| TableCell, | |||
| TableContainer, | |||
| TableHead, | |||
| TablePagination, | |||
| TableRow, | |||
| Paper, | |||
| Button, | |||
| CircularProgress, | |||
| Alert, | |||
| } from "@mui/material"; | |||
| import { useState, useEffect, useMemo } from "react"; | |||
| import { useRouter } from "next/navigation"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { findAllUniqueTruckLaneCombinationsClient } from "@/app/api/shop/client"; | |||
| import type { Truck } from "@/app/api/shop/actions"; | |||
| import SearchBox, { Criterion } from "../SearchBox"; | |||
| // 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")}`; | |||
| } | |||
| return timeStr; | |||
| }; | |||
| type SearchQuery = { | |||
| truckLanceCode: string; | |||
| departureTime: string; | |||
| storeId: string; | |||
| }; | |||
| type SearchParamNames = keyof SearchQuery; | |||
| const TruckLane: React.FC = () => { | |||
| const { t } = useTranslation("common"); | |||
| const router = useRouter(); | |||
| const [truckData, setTruckData] = useState<Truck[]>([]); | |||
| const [loading, setLoading] = useState<boolean>(true); | |||
| const [error, setError] = useState<string | null>(null); | |||
| const [filters, setFilters] = useState<Record<string, string>>({}); | |||
| const [page, setPage] = useState(0); | |||
| const [rowsPerPage, setRowsPerPage] = useState(10); | |||
| useEffect(() => { | |||
| const fetchTruckLanes = async () => { | |||
| setLoading(true); | |||
| setError(null); | |||
| try { | |||
| const data = await findAllUniqueTruckLaneCombinationsClient() as Truck[]; | |||
| setTruckData(data || []); | |||
| } catch (err: any) { | |||
| console.error("Failed to load truck lanes:", err); | |||
| setError(err?.message ?? String(err) ?? t("Failed to load truck lanes")); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }; | |||
| fetchTruckLanes(); | |||
| }, []); | |||
| // Client-side filtered rows (contains-matching) | |||
| const filteredRows = useMemo(() => { | |||
| const fKeys = Object.keys(filters || {}).filter((k) => String((filters as any)[k]).trim() !== ""); | |||
| const normalized = (truckData || []).filter((r) => { | |||
| // Apply contains matching for each active filter | |||
| for (const k of fKeys) { | |||
| const v = String((filters as any)[k] ?? "").trim(); | |||
| if (k === "truckLanceCode") { | |||
| const rv = String((r as any).truckLanceCode ?? "").trim(); | |||
| if (!rv.toLowerCase().includes(v.toLowerCase())) return false; | |||
| } else if (k === "departureTime") { | |||
| const formattedTime = formatDepartureTime( | |||
| Array.isArray(r.departureTime) | |||
| ? r.departureTime | |||
| : (r.departureTime ? String(r.departureTime) : null) | |||
| ); | |||
| if (!formattedTime.toLowerCase().includes(v.toLowerCase())) return false; | |||
| } else if (k === "storeId") { | |||
| const rv = String((r as any).storeId ?? "").trim(); | |||
| const storeIdStr = typeof rv === 'string' ? rv : String(rv); | |||
| // Convert numeric values to display format for comparison | |||
| let displayStoreId = storeIdStr; | |||
| if (storeIdStr === "2" || storeIdStr === "2F") displayStoreId = "2F"; | |||
| if (storeIdStr === "4" || storeIdStr === "4F") displayStoreId = "4F"; | |||
| if (!displayStoreId.toLowerCase().includes(v.toLowerCase())) return false; | |||
| } | |||
| } | |||
| return true; | |||
| }); | |||
| return normalized; | |||
| }, [truckData, filters]); | |||
| // Paginated rows | |||
| const paginatedRows = useMemo(() => { | |||
| const startIndex = page * rowsPerPage; | |||
| return filteredRows.slice(startIndex, startIndex + rowsPerPage); | |||
| }, [filteredRows, page, rowsPerPage]); | |||
| const handleSearch = (inputs: Record<string, string>) => { | |||
| setFilters(inputs); | |||
| setPage(0); // Reset to first page when searching | |||
| }; | |||
| const handlePageChange = (event: unknown, newPage: number) => { | |||
| setPage(newPage); | |||
| }; | |||
| const handleRowsPerPageChange = (event: React.ChangeEvent<HTMLInputElement>) => { | |||
| setRowsPerPage(parseInt(event.target.value, 10)); | |||
| setPage(0); // Reset to first page when changing rows per page | |||
| }; | |||
| const handleViewDetail = (truck: Truck) => { | |||
| // Navigate to truck lane detail page | |||
| if (truck.id) { | |||
| router.push(`/settings/shop/truckdetail?id=${truck.id}`); | |||
| } | |||
| }; | |||
| 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> | |||
| </Box> | |||
| ); | |||
| } | |||
| const criteria: Criterion<SearchParamNames>[] = [ | |||
| { type: "text", label: t("TruckLance Code"), paramName: "truckLanceCode" }, | |||
| { type: "text", label: t("Departure Time"), paramName: "departureTime" }, | |||
| { type: "text", label: t("Store ID"), paramName: "storeId" }, | |||
| ]; | |||
| return ( | |||
| <Box> | |||
| <Card sx={{ mb: 2 }}> | |||
| <CardContent> | |||
| <SearchBox | |||
| criteria={criteria as Criterion<string>[]} | |||
| onSearch={handleSearch} | |||
| onReset={() => { | |||
| setFilters({}); | |||
| }} | |||
| /> | |||
| </CardContent> | |||
| </Card> | |||
| <Card> | |||
| <CardContent> | |||
| <Typography variant="h6" sx={{ mb: 2 }}>{t("Truck Lane")}</Typography> | |||
| <TableContainer component={Paper}> | |||
| <Table> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>{t("TruckLance Code")}</TableCell> | |||
| <TableCell>{t("Departure Time")}</TableCell> | |||
| <TableCell>{t("Store ID")}</TableCell> | |||
| <TableCell>{t("Remark")}</TableCell> | |||
| <TableCell align="right">{t("Actions")}</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {paginatedRows.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={5} align="center"> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("No Truck Lane data available")} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| paginatedRows.map((truck, index) => { | |||
| const storeId = truck.storeId; | |||
| const storeIdStr = storeId ? (typeof storeId === 'string' ? storeId : String(storeId)) : "-"; | |||
| const displayStoreId = storeIdStr === "2" || storeIdStr === "2F" ? "2F" | |||
| : storeIdStr === "4" || storeIdStr === "4F" ? "4F" | |||
| : storeIdStr; | |||
| return ( | |||
| <TableRow key={truck.id ?? `truck-${index}`}> | |||
| <TableCell> | |||
| {String(truck.truckLanceCode || "-")} | |||
| </TableCell> | |||
| <TableCell> | |||
| {formatDepartureTime( | |||
| Array.isArray(truck.departureTime) | |||
| ? truck.departureTime | |||
| : (truck.departureTime ? String(truck.departureTime) : null) | |||
| )} | |||
| </TableCell> | |||
| <TableCell> | |||
| {displayStoreId} | |||
| </TableCell> | |||
| <TableCell> | |||
| {String(truck.remark || "-")} | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| <Button | |||
| size="small" | |||
| variant="outlined" | |||
| onClick={() => handleViewDetail(truck)} | |||
| > | |||
| {t("View Detail")} | |||
| </Button> | |||
| </TableCell> | |||
| </TableRow> | |||
| ); | |||
| }) | |||
| )} | |||
| </TableBody> | |||
| </Table> | |||
| <TablePagination | |||
| component="div" | |||
| count={filteredRows.length} | |||
| page={page} | |||
| onPageChange={handlePageChange} | |||
| rowsPerPage={rowsPerPage} | |||
| onRowsPerPageChange={handleRowsPerPageChange} | |||
| rowsPerPageOptions={[5, 10, 25, 50]} | |||
| /> | |||
| </TableContainer> | |||
| </CardContent> | |||
| </Card> | |||
| </Box> | |||
| ); | |||
| }; | |||
| export default TruckLane; | |||
| @@ -0,0 +1,474 @@ | |||
| "use client"; | |||
| import { | |||
| Box, | |||
| Card, | |||
| CardContent, | |||
| Typography, | |||
| Button, | |||
| CircularProgress, | |||
| Alert, | |||
| Grid, | |||
| Paper, | |||
| Table, | |||
| TableBody, | |||
| TableCell, | |||
| TableContainer, | |||
| TableHead, | |||
| TableRow, | |||
| IconButton, | |||
| Snackbar, | |||
| TextField, | |||
| } 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 { useState, useEffect } from "react"; | |||
| import { useRouter, useSearchParams } from "next/navigation"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { findAllUniqueTruckLaneCombinationsClient, findAllShopsByTruckLanceCodeAndRemarkClient, deleteTruckLaneClient, updateLoadingSequenceClient } from "@/app/api/shop/client"; | |||
| import type { Truck, ShopAndTruck } from "@/app/api/shop/actions"; | |||
| // 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")}`; | |||
| } | |||
| return timeStr; | |||
| }; | |||
| const TruckLaneDetail: React.FC = () => { | |||
| const { t } = useTranslation("common"); | |||
| const router = useRouter(); | |||
| const searchParams = useSearchParams(); | |||
| const truckId = searchParams.get("id"); | |||
| const [truckData, setTruckData] = useState<Truck | null>(null); | |||
| const [shopsData, setShopsData] = useState<ShopAndTruck[]>([]); | |||
| const [editedShopsData, setEditedShopsData] = useState<ShopAndTruck[]>([]); | |||
| const [editingRowIndex, setEditingRowIndex] = useState<number | null>(null); | |||
| const [loading, setLoading] = useState<boolean>(true); | |||
| const [shopsLoading, setShopsLoading] = useState<boolean>(false); | |||
| const [saving, setSaving] = useState<boolean>(false); | |||
| const [error, setError] = useState<string | null>(null); | |||
| const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: "success" | "error" }>({ | |||
| open: false, | |||
| message: "", | |||
| severity: "success", | |||
| }); | |||
| useEffect(() => { | |||
| const fetchTruckLaneDetail = async () => { | |||
| if (!truckId) { | |||
| setError(t("Truck ID is required")); | |||
| setLoading(false); | |||
| return; | |||
| } | |||
| setLoading(true); | |||
| setError(null); | |||
| try { | |||
| const data = await findAllUniqueTruckLaneCombinationsClient() as Truck[]; | |||
| const truck = data.find((t) => t.id?.toString() === truckId); | |||
| if (truck) { | |||
| setTruckData(truck); | |||
| // Fetch shops using this truck lane | |||
| await fetchShopsByTruckLane(String(truck.truckLanceCode || ""), String(truck.remark || "")); | |||
| } else { | |||
| setError(t("Truck lane not found")); | |||
| } | |||
| } catch (err: any) { | |||
| console.error("Failed to load truck lane detail:", err); | |||
| setError(err?.message ?? String(err) ?? t("Failed to load truck lane detail")); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }; | |||
| fetchTruckLaneDetail(); | |||
| }, [truckId]); | |||
| const fetchShopsByTruckLane = async (truckLanceCode: string, remark: string) => { | |||
| setShopsLoading(true); | |||
| try { | |||
| const shops = await findAllShopsByTruckLanceCodeAndRemarkClient(truckLanceCode, remark || ""); | |||
| setShopsData(shops || []); | |||
| setEditedShopsData(shops || []); | |||
| } catch (err: any) { | |||
| console.error("Failed to load shops:", err); | |||
| setSnackbar({ | |||
| open: true, | |||
| message: err?.message ?? String(err) ?? t("Failed to load shops"), | |||
| severity: "error", | |||
| }); | |||
| } finally { | |||
| setShopsLoading(false); | |||
| } | |||
| }; | |||
| const handleEdit = (index: number) => { | |||
| setEditingRowIndex(index); | |||
| const updated = [...shopsData]; | |||
| updated[index] = { ...updated[index] }; | |||
| setEditedShopsData(updated); | |||
| }; | |||
| const handleCancel = (index: number) => { | |||
| setEditingRowIndex(null); | |||
| setEditedShopsData([...shopsData]); | |||
| }; | |||
| const handleSave = async (index: number) => { | |||
| const shop = editedShopsData[index]; | |||
| if (!shop || !shop.truckId) { | |||
| setSnackbar({ | |||
| open: true, | |||
| message: t("Invalid shop data"), | |||
| severity: "error", | |||
| }); | |||
| return; | |||
| } | |||
| setSaving(true); | |||
| setError(null); | |||
| try { | |||
| // Get LoadingSequence from edited data - handle both PascalCase and camelCase | |||
| const editedShop = editedShopsData[index]; | |||
| const loadingSeq = (editedShop as any)?.LoadingSequence ?? (editedShop as any)?.loadingSequence; | |||
| const loadingSequenceValue = (loadingSeq !== null && loadingSeq !== undefined) ? Number(loadingSeq) : 0; | |||
| if (!shop.truckId) { | |||
| setSnackbar({ | |||
| open: true, | |||
| message: "Truck ID is required", | |||
| severity: "error", | |||
| }); | |||
| return; | |||
| } | |||
| await updateLoadingSequenceClient({ | |||
| id: shop.truckId, | |||
| loadingSequence: loadingSequenceValue, | |||
| }); | |||
| setSnackbar({ | |||
| open: true, | |||
| message: t("Loading sequence updated successfully"), | |||
| severity: "success", | |||
| }); | |||
| // Refresh the shops list | |||
| if (truckData) { | |||
| await fetchShopsByTruckLane(String(truckData.truckLanceCode || ""), String(truckData.remark || "")); | |||
| } | |||
| setEditingRowIndex(null); | |||
| } catch (err: any) { | |||
| console.error("Failed to save loading sequence:", err); | |||
| setSnackbar({ | |||
| open: true, | |||
| message: err?.message ?? String(err) ?? t("Failed to save loading sequence"), | |||
| severity: "error", | |||
| }); | |||
| } finally { | |||
| setSaving(false); | |||
| } | |||
| }; | |||
| const handleLoadingSequenceChange = (index: number, value: string) => { | |||
| const updated = [...editedShopsData]; | |||
| const numValue = parseInt(value, 10); | |||
| updated[index] = { | |||
| ...updated[index], | |||
| LoadingSequence: isNaN(numValue) ? 0 : numValue, | |||
| }; | |||
| setEditedShopsData(updated); | |||
| }; | |||
| 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({ | |||
| open: true, | |||
| message: t("Truck lane deleted successfully"), | |||
| severity: "success", | |||
| }); | |||
| // Refresh the shops list | |||
| if (truckData) { | |||
| await fetchShopsByTruckLane(String(truckData.truckLanceCode || ""), String(truckData.remark || "")); | |||
| } | |||
| } catch (err: any) { | |||
| console.error("Failed to delete truck lane:", err); | |||
| setSnackbar({ | |||
| open: true, | |||
| message: err?.message ?? String(err) ?? t("Failed to delete truck lane"), | |||
| severity: "error", | |||
| }); | |||
| } | |||
| }; | |||
| const handleBack = () => { | |||
| router.push("/settings/shop"); | |||
| }; | |||
| 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 variant="contained" onClick={handleBack}> | |||
| {t("Back to Truck Lane List")} | |||
| </Button> | |||
| </Box> | |||
| ); | |||
| } | |||
| if (!truckData) { | |||
| return ( | |||
| <Box> | |||
| <Alert severity="warning" sx={{ mb: 2 }}> | |||
| {t("No truck lane data available")} | |||
| </Alert> | |||
| <Button variant="contained" onClick={handleBack}> | |||
| {t("Back to Truck Lane List")} | |||
| </Button> | |||
| </Box> | |||
| ); | |||
| } | |||
| const storeId = truckData.storeId; | |||
| const storeIdStr = storeId ? (typeof storeId === 'string' ? storeId : String(storeId)) : "-"; | |||
| const displayStoreId = storeIdStr === "2" || storeIdStr === "2F" ? "2F" | |||
| : storeIdStr === "4" || storeIdStr === "4F" ? "4F" | |||
| : storeIdStr; | |||
| return ( | |||
| <Box> | |||
| <Card sx={{ mb: 2 }}> | |||
| <CardContent> | |||
| <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}> | |||
| <Typography variant="h5">{t("Truck Lane Detail")}</Typography> | |||
| <Button variant="outlined" onClick={handleBack}> | |||
| {t("Back to Truck Lane List")} | |||
| </Button> | |||
| </Box> | |||
| </CardContent> | |||
| </Card> | |||
| <Card> | |||
| <CardContent> | |||
| <Paper sx={{ p: 3 }}> | |||
| <Grid container spacing={3}> | |||
| <Grid item xs={12} sm={6}> | |||
| <Typography variant="subtitle2" color="text.secondary"> | |||
| {t("TruckLance Code")} | |||
| </Typography> | |||
| <Typography variant="body1" sx={{ mt: 1 }}> | |||
| {String(truckData.truckLanceCode || "-")} | |||
| </Typography> | |||
| </Grid> | |||
| <Grid item xs={12} sm={6}> | |||
| <Typography variant="subtitle2" color="text.secondary"> | |||
| {t("Departure Time")} | |||
| </Typography> | |||
| <Typography variant="body1" sx={{ mt: 1 }}> | |||
| {formatDepartureTime( | |||
| Array.isArray(truckData.departureTime) | |||
| ? truckData.departureTime | |||
| : (truckData.departureTime ? String(truckData.departureTime) : null) | |||
| )} | |||
| </Typography> | |||
| </Grid> | |||
| <Grid item xs={12} sm={6}> | |||
| <Typography variant="subtitle2" color="text.secondary"> | |||
| {t("Store ID")} | |||
| </Typography> | |||
| <Typography variant="body1" sx={{ mt: 1 }}> | |||
| {displayStoreId} | |||
| </Typography> | |||
| </Grid> | |||
| <Grid item xs={12} sm={6}> | |||
| <Typography variant="subtitle2" color="text.secondary"> | |||
| {t("Remark")} | |||
| </Typography> | |||
| <Typography variant="body1" sx={{ mt: 1 }}> | |||
| {String(truckData.remark || "-")} | |||
| </Typography> | |||
| </Grid> | |||
| </Grid> | |||
| </Paper> | |||
| </CardContent> | |||
| </Card> | |||
| <Card sx={{ mt: 2 }}> | |||
| <CardContent> | |||
| <Typography variant="h6" sx={{ mb: 2 }}> | |||
| {t("Shops Using This Truck Lane")} | |||
| </Typography> | |||
| {shopsLoading ? ( | |||
| <Box sx={{ display: "flex", justifyContent: "center", p: 4 }}> | |||
| <CircularProgress /> | |||
| </Box> | |||
| ) : ( | |||
| <TableContainer component={Paper}> | |||
| <Table> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>{t("Shop Name")}</TableCell> | |||
| <TableCell>{t("Shop Code")}</TableCell> | |||
| <TableCell>{t("Loading Sequence")}</TableCell> | |||
| <TableCell>{t("Remark")}</TableCell> | |||
| <TableCell align="right">{t("Actions")}</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {shopsData.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={5} align="center"> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("No shops found using this truck lane")} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| shopsData.map((shop, index) => ( | |||
| <TableRow key={shop.id ?? `shop-${index}`}> | |||
| <TableCell> | |||
| {String(shop.name || "-")} | |||
| </TableCell> | |||
| <TableCell> | |||
| {String(shop.code || "-")} | |||
| </TableCell> | |||
| <TableCell> | |||
| {editingRowIndex === index ? ( | |||
| <TextField | |||
| size="small" | |||
| type="number" | |||
| value={(() => { | |||
| const editedShop = editedShopsData[index]; | |||
| return (editedShop as any)?.LoadingSequence ?? (editedShop as any)?.loadingSequence ?? 0; | |||
| })()} | |||
| onChange={(e) => handleLoadingSequenceChange(index, e.target.value)} | |||
| disabled={saving} | |||
| sx={{ width: 100 }} | |||
| /> | |||
| ) : ( | |||
| (() => { | |||
| // 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) | |||
| : "-"; | |||
| })() | |||
| )} | |||
| </TableCell> | |||
| <TableCell> | |||
| {String(shop.remark || "-")} | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| <Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1 }}> | |||
| {editingRowIndex === index ? ( | |||
| <> | |||
| <IconButton | |||
| size="small" | |||
| color="primary" | |||
| onClick={() => handleSave(index)} | |||
| disabled={saving} | |||
| title={t("Save changes")} | |||
| > | |||
| <SaveIcon /> | |||
| </IconButton> | |||
| <IconButton | |||
| size="small" | |||
| color="default" | |||
| onClick={() => handleCancel(index)} | |||
| disabled={saving} | |||
| title={t("Cancel editing")} | |||
| > | |||
| <CancelIcon /> | |||
| </IconButton> | |||
| </> | |||
| ) : ( | |||
| <> | |||
| <IconButton | |||
| size="small" | |||
| color="primary" | |||
| onClick={() => handleEdit(index)} | |||
| title={t("Edit loading sequence")} | |||
| > | |||
| <EditIcon /> | |||
| </IconButton> | |||
| {shop.truckId && ( | |||
| <IconButton | |||
| size="small" | |||
| color="error" | |||
| onClick={() => handleDelete(shop.truckId!)} | |||
| title={t("Delete truck lane")} | |||
| > | |||
| <DeleteIcon /> | |||
| </IconButton> | |||
| )} | |||
| </> | |||
| )} | |||
| </Box> | |||
| </TableCell> | |||
| </TableRow> | |||
| )) | |||
| )} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| )} | |||
| </CardContent> | |||
| </Card> | |||
| <Snackbar | |||
| open={snackbar.open} | |||
| autoHideDuration={6000} | |||
| onClose={() => setSnackbar({ ...snackbar, open: false })} | |||
| message={snackbar.message} | |||
| /> | |||
| </Box> | |||
| ); | |||
| }; | |||
| export default TruckLaneDetail; | |||
| @@ -296,5 +296,75 @@ | |||
| "Total lines: ": "總數量:", | |||
| "Balance": "可用數量", | |||
| "Submitting...": "提交中...", | |||
| "Batch Count": "批數" | |||
| "Batch Count": "批數", | |||
| "Shop": "店鋪", | |||
| "Shop Information": "店鋪資訊", | |||
| "Shop Name": "店鋪名稱", | |||
| "Shop Code": "店鋪編號", | |||
| "Truck Lane": "卡車路線", | |||
| "Truck Lane Detail": "卡車路線詳情", | |||
| "TruckLance Code": "卡車路線編號", | |||
| "TruckLance Status": "卡車路線狀態", | |||
| "Departure Time": "出發時間", | |||
| "Loading Sequence": "裝載順序", | |||
| "District Reference": "區域參考", | |||
| "Store ID": "店鋪ID", | |||
| "Remark": "備註", | |||
| "Actions": "操作", | |||
| "View Detail": "查看詳情", | |||
| "Back": "返回", | |||
| "Back to Truck Lane List": "返回卡車路線列表", | |||
| "Back to List": "返回列表", | |||
| "Add Truck Lane": "新增卡車路線", | |||
| "Add New Truck Lane": "新增卡車路線", | |||
| "Truck Information": "卡車資訊", | |||
| "No Truck data available": "沒有卡車資料", | |||
| "No shops found using this truck lane": "沒有找到使用此卡車路線的店鋪", | |||
| "Shops Using This Truck Lane": "使用此卡車路線的店鋪", | |||
| "Complete": "完成", | |||
| "Missing Data": "缺少資料", | |||
| "No TruckLance": "無卡車路線", | |||
| "Edit shop truck lane": "編輯店鋪卡車路線", | |||
| "Delete truck lane": "刪除卡車路線", | |||
| "Edit loading sequence": "編輯裝載順序", | |||
| "Save changes": "儲存變更", | |||
| "Cancel editing": "取消編輯", | |||
| "Edit truck lane": "編輯卡車路線", | |||
| "Truck ID is required": "需要卡車ID", | |||
| "Truck lane not found": "找不到卡車路線", | |||
| "No truck lane data available": "沒有卡車路線資料", | |||
| "Failed to load truck lanes": "載入卡車路線失敗", | |||
| "Failed to load shops": "載入店鋪失敗", | |||
| "Loading sequence updated successfully": "裝載順序更新成功", | |||
| "Failed to save loading sequence": "儲存裝載順序失敗", | |||
| "Truck lane deleted successfully": "卡車路線刪除成功", | |||
| "Failed to delete truck lane": "刪除卡車路線失敗", | |||
| "Are you sure you want to delete this truck lane?": "您確定要刪除此卡車路線嗎?", | |||
| "Invalid shop data": "無效的店鋪資料", | |||
| "Contact No": "聯絡電話", | |||
| "Contact Email": "聯絡郵箱", | |||
| "Contact Name": "聯絡人", | |||
| "Addr1": "地址1", | |||
| "Addr2": "地址2", | |||
| "Addr3": "地址3", | |||
| "Shop not found": "找不到店鋪", | |||
| "Shop ID is required": "需要店鋪ID", | |||
| "Invalid Shop ID": "無效的店鋪ID", | |||
| "Failed to load shop detail": "載入店鋪詳情失敗", | |||
| "Failed to load shop details": "載入店鋪詳情失敗", | |||
| "Failed to save truck data": "儲存卡車資料失敗", | |||
| "Failed to delete truck lane": "刪除卡車路線失敗", | |||
| "Failed to create truck": "建立卡車失敗", | |||
| "Please fill in the following required fields:": "請填寫以下必填欄位:", | |||
| "TruckLance Code": "卡車路線編號", | |||
| "Enter or select remark": "輸入或選擇備註", | |||
| "Not editable for this Store ID": "此店鋪ID不可編輯", | |||
| "No Truck Lane data available": "沒有卡車路線資料", | |||
| "Please log in to view shop details": "請登入以查看店鋪詳情", | |||
| "Invalid truck data": "無效的卡車資料", | |||
| "Failed to load truck lane detail": "載入卡車路線詳情失敗", | |||
| "Shop Detail": "店鋪詳情", | |||
| "Truck Lane Detail": "卡車路線詳情", | |||
| "Filter by Status": "按狀態篩選", | |||
| "All": "全部" | |||
| } | |||