FPSMS-frontend
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 

582 строки
20 KiB

  1. "use client";
  2. import {
  3. Box,
  4. Card,
  5. CardContent,
  6. Typography,
  7. CircularProgress,
  8. Alert,
  9. Button,
  10. Table,
  11. TableBody,
  12. TableCell,
  13. TableContainer,
  14. TableHead,
  15. TableRow,
  16. Paper,
  17. TextField,
  18. Stack,
  19. IconButton,
  20. Dialog,
  21. DialogTitle,
  22. DialogContent,
  23. DialogActions,
  24. Grid,
  25. Snackbar,
  26. Autocomplete,
  27. } from "@mui/material";
  28. import DeleteIcon from "@mui/icons-material/Delete";
  29. import SaveIcon from "@mui/icons-material/Save";
  30. import AddIcon from "@mui/icons-material/Add";
  31. import CheckIcon from "@mui/icons-material/Check";
  32. import CloseIcon from "@mui/icons-material/Close";
  33. import { useRouter, useSearchParams } from "next/navigation";
  34. import { useState, useEffect } from "react";
  35. import { useSession } from "next-auth/react";
  36. import { useTranslation } from "react-i18next";
  37. import type { ShopAndTruck, Truck } from "@/app/api/shop/actions";
  38. import {
  39. fetchAllShopsClient,
  40. findTruckLaneByShopIdClient,
  41. deleteTruckLaneClient,
  42. createTruckClient,
  43. findAllUniqueTruckLaneCombinationsClient,
  44. } from "@/app/api/shop/client";
  45. import type { SessionWithTokens } from "@/config/authConfig";
  46. import { formatDepartureTime, normalizeStoreId } from "@/app/utils/formatUtil";
  47. type ShopDetailData = {
  48. id: number;
  49. name: String;
  50. code: String;
  51. addr1: String;
  52. addr2: String;
  53. addr3: String;
  54. contactNo: number;
  55. type: String;
  56. contactEmail: String;
  57. contactName: String;
  58. };
  59. // Utility function to convert HH:mm format to the format expected by backend
  60. const parseDepartureTimeForBackend = (time: string): string => {
  61. if (!time) return "";
  62. const timeStr = String(time).trim();
  63. // If already in HH:mm format, return as is
  64. if (/^\d{1,2}:\d{2}$/.test(timeStr)) {
  65. return timeStr;
  66. }
  67. // Try to format it
  68. return formatDepartureTime(timeStr);
  69. };
  70. /** Label for truck lane picker: code + optional remark (unique combo from API). */
  71. const getTruckLaneOptionLabel = (lane: Truck): string => {
  72. const code = String(lane.truckLanceCode ?? "").trim();
  73. const remark =
  74. lane.remark != null && String(lane.remark).trim() !== "" ? String(lane.remark).trim() : null;
  75. return remark ? `${code} — ${remark}` : code || "-";
  76. };
  77. const isSameTruckLaneOption = (a: Truck | null, b: Truck | null): boolean => {
  78. if (a === b) return true;
  79. if (!a || !b) return false;
  80. const sameCode = String(a.truckLanceCode ?? "") === String(b.truckLanceCode ?? "");
  81. const ra = a.remark != null ? String(a.remark) : "";
  82. const rb = b.remark != null ? String(b.remark) : "";
  83. return sameCode && ra === rb;
  84. };
  85. /** Build HH:mm string from API departureTime for backend parsing. */
  86. const departureTimeToStringForSave = (timeValue: Truck["departureTime"]): string => {
  87. const formatted = formatDepartureTime(
  88. Array.isArray(timeValue) ? timeValue : timeValue ? String(timeValue) : null
  89. );
  90. if (!formatted || formatted === "-") return "";
  91. return parseDepartureTimeForBackend(formatted);
  92. };
  93. const ShopDetail: React.FC = () => {
  94. const { t } = useTranslation("common");
  95. const router = useRouter();
  96. const searchParams = useSearchParams();
  97. const shopId = searchParams.get("id");
  98. const { data: session, status: sessionStatus } = useSession() as { data: SessionWithTokens | null; status: string };
  99. const [shopDetail, setShopDetail] = useState<ShopDetailData | null>(null);
  100. const [truckData, setTruckData] = useState<Truck[]>([]);
  101. const [loading, setLoading] = useState<boolean>(true);
  102. const [error, setError] = useState<string | null>(null);
  103. const [saving, setSaving] = useState<boolean>(false);
  104. const [confirmingDeleteIndex, setConfirmingDeleteIndex] = useState<number | null>(null);
  105. const [addDialogOpen, setAddDialogOpen] = useState<boolean>(false);
  106. const [availableTruckLanes, setAvailableTruckLanes] = useState<Truck[]>([]);
  107. const [selectedTruckLane, setSelectedTruckLane] = useState<Truck | null>(null);
  108. const [addLoadingSequence, setAddLoadingSequence] = useState<number>(0);
  109. const [loadingTruckLanes, setLoadingTruckLanes] = useState<boolean>(false);
  110. const [snackbarOpen, setSnackbarOpen] = useState<boolean>(false);
  111. const [snackbarMessage, setSnackbarMessage] = useState<string>("");
  112. useEffect(() => {
  113. // Wait for session to be ready before making API calls
  114. if (sessionStatus === "loading") {
  115. return; // Still loading session
  116. }
  117. // If session is unauthenticated, don't make API calls (middleware will handle redirect)
  118. if (sessionStatus === "unauthenticated" || !session) {
  119. setError(t("Please log in to view shop details"));
  120. setLoading(false);
  121. return;
  122. }
  123. const fetchShopDetail = async () => {
  124. if (!shopId) {
  125. setError(t("Shop ID is required"));
  126. setLoading(false);
  127. return;
  128. }
  129. // Convert shopId to number for proper filtering
  130. const shopIdNum = parseInt(shopId, 10);
  131. if (isNaN(shopIdNum)) {
  132. setError(t("Invalid Shop ID"));
  133. setLoading(false);
  134. return;
  135. }
  136. setLoading(true);
  137. setError(null);
  138. try {
  139. // Fetch shop information - try with ID parameter first, then filter if needed
  140. let shopDataResponse = await fetchAllShopsClient({ id: shopIdNum }) as ShopAndTruck[];
  141. // If no results with ID parameter, fetch all and filter client-side
  142. if (!shopDataResponse || shopDataResponse.length === 0) {
  143. shopDataResponse = await fetchAllShopsClient() as ShopAndTruck[];
  144. }
  145. // Filter to find the shop with matching ID (in case API doesn't filter properly)
  146. const shopData = shopDataResponse?.find((item) => item.id === shopIdNum);
  147. if (shopData) {
  148. // Set shop detail info
  149. setShopDetail({
  150. id: shopData.id ?? 0,
  151. name: shopData.name ?? "",
  152. code: shopData.code ?? "",
  153. addr1: shopData.addr1 ?? "",
  154. addr2: shopData.addr2 ?? "",
  155. addr3: shopData.addr3 ?? "",
  156. contactNo: shopData.contactNo ?? 0,
  157. type: shopData.type ?? "",
  158. contactEmail: shopData.contactEmail ?? "",
  159. contactName: shopData.contactName ?? "",
  160. });
  161. } else {
  162. setError(t("Shop not found"));
  163. setLoading(false);
  164. return;
  165. }
  166. // Fetch truck information using the Truck interface with numeric ID
  167. const trucks = await findTruckLaneByShopIdClient(shopIdNum) as Truck[];
  168. setTruckData(trucks || []);
  169. } catch (err: any) {
  170. console.error("Failed to load shop detail:", err);
  171. // Handle errors gracefully - don't trigger auto-logout
  172. const errorMessage = err?.message ?? String(err) ?? t("Failed to load shop details");
  173. setError(errorMessage);
  174. } finally {
  175. setLoading(false);
  176. }
  177. };
  178. fetchShopDetail();
  179. }, [shopId, sessionStatus, session]);
  180. const handleDelete = async (truckId: number) => {
  181. if (!shopId) {
  182. setError(t("Shop ID is required"));
  183. return;
  184. }
  185. setSaving(true);
  186. setError(null);
  187. try {
  188. await deleteTruckLaneClient({ id: truckId });
  189. setConfirmingDeleteIndex(null);
  190. const shopIdNum = parseInt(shopId, 10);
  191. const trucks = await findTruckLaneByShopIdClient(shopIdNum) as Truck[];
  192. setTruckData(trucks || []);
  193. } catch (err: any) {
  194. console.error("Failed to delete truck lane:", err);
  195. setError(err?.message ?? String(err) ?? t("Failed to delete truck lane"));
  196. } finally {
  197. setSaving(false);
  198. }
  199. };
  200. const handleOpenAddDialog = async () => {
  201. setSelectedTruckLane(null);
  202. setAddLoadingSequence(0);
  203. setAddDialogOpen(true);
  204. setError(null);
  205. setLoadingTruckLanes(true);
  206. try {
  207. const lanes = (await findAllUniqueTruckLaneCombinationsClient()) as Truck[];
  208. setAvailableTruckLanes(lanes || []);
  209. } catch (err: any) {
  210. console.error("Failed to load truck lanes:", err);
  211. setAvailableTruckLanes([]);
  212. setSnackbarMessage(
  213. err?.message ?? String(err) ?? t("Failed to load truck lanes")
  214. );
  215. setSnackbarOpen(true);
  216. } finally {
  217. setLoadingTruckLanes(false);
  218. }
  219. };
  220. const handleCloseAddDialog = () => {
  221. setAddDialogOpen(false);
  222. setSelectedTruckLane(null);
  223. setAddLoadingSequence(0);
  224. setAvailableTruckLanes([]);
  225. };
  226. const handleCreateTruck = async () => {
  227. const missingFields: string[] = [];
  228. if (!shopId || !shopDetail) {
  229. missingFields.push(t("Shop Information"));
  230. }
  231. if (!selectedTruckLane) {
  232. missingFields.push(t("TruckLance Code"));
  233. }
  234. const departureTime = selectedTruckLane
  235. ? departureTimeToStringForSave(selectedTruckLane.departureTime)
  236. : "";
  237. if (!departureTime) {
  238. missingFields.push(t("Departure Time"));
  239. }
  240. if (missingFields.length > 0) {
  241. const message = `${t("Please fill in the following required fields:")} ${missingFields.join(", ")}`;
  242. setSnackbarMessage(message);
  243. setSnackbarOpen(true);
  244. return;
  245. }
  246. const lane = selectedTruckLane!;
  247. const storeIdStr = normalizeStoreId(lane.storeId) || "2F";
  248. const remarkValue =
  249. storeIdStr === "4F" && lane.remark != null && String(lane.remark).trim() !== ""
  250. ? String(lane.remark).trim()
  251. : null;
  252. setSaving(true);
  253. setError(null);
  254. try {
  255. await createTruckClient({
  256. store_id: storeIdStr,
  257. truckLanceCode: String(lane.truckLanceCode || "").trim(),
  258. departureTime: departureTime,
  259. shopId: shopDetail!.id,
  260. shopName: String(shopDetail!.name),
  261. shopCode: String(shopDetail!.code),
  262. loadingSequence: addLoadingSequence,
  263. districtReference:
  264. lane.districtReference != null && String(lane.districtReference).trim() !== ""
  265. ? String(lane.districtReference)
  266. : null,
  267. remark: remarkValue,
  268. });
  269. // Refresh truck data after create
  270. const shopIdNum = parseInt(shopId || "0", 10);
  271. const trucks = await findTruckLaneByShopIdClient(shopIdNum) as Truck[];
  272. setTruckData(trucks || []);
  273. handleCloseAddDialog();
  274. } catch (err: any) {
  275. console.error("Failed to create truck:", err);
  276. setError(err?.message ?? String(err) ?? t("Failed to create truck"));
  277. } finally {
  278. setSaving(false);
  279. }
  280. };
  281. if (loading) {
  282. return (
  283. <Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
  284. <CircularProgress />
  285. </Box>
  286. );
  287. }
  288. if (error) {
  289. return (
  290. <Box>
  291. <Alert severity="error" sx={{ mb: 2 }}>
  292. {error}
  293. </Alert>
  294. <Button onClick={() => router.push("/settings/shop?tab=0")}>{t("Back")}</Button>
  295. </Box>
  296. );
  297. }
  298. if (!shopDetail) {
  299. return (
  300. <Box>
  301. <Alert severity="warning" sx={{ mb: 2 }}>
  302. {t("Shop not found")}
  303. </Alert>
  304. <Button onClick={() => router.push("/settings/shop?tab=0")}>{t("Back")}</Button>
  305. </Box>
  306. );
  307. }
  308. return (
  309. <Box>
  310. <Card sx={{ mb: 2 }}>
  311. <CardContent>
  312. <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}>
  313. <Typography variant="h6">{t("Shop Information")}</Typography>
  314. <Button onClick={() => router.push("/settings/shop?tab=0")}>{t("Back")}</Button>
  315. </Box>
  316. <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
  317. <Box>
  318. <Typography variant="subtitle2" color="text.secondary" fontWeight="bold">{t("Shop ID")}</Typography>
  319. <Typography variant="body1" fontWeight="medium">{shopDetail.id}</Typography>
  320. </Box>
  321. <Box>
  322. <Typography variant="subtitle2" color="text.secondary">{t("Name")}</Typography>
  323. <Typography variant="body1">{shopDetail.name}</Typography>
  324. </Box>
  325. <Box>
  326. <Typography variant="subtitle2" color="text.secondary">{t("Code")}</Typography>
  327. <Typography variant="body1">{shopDetail.code}</Typography>
  328. </Box>
  329. <Box>
  330. <Typography variant="subtitle2" color="text.secondary">{t("Addr1")}</Typography>
  331. <Typography variant="body1">{shopDetail.addr1 || "-"}</Typography>
  332. </Box>
  333. <Box>
  334. <Typography variant="subtitle2" color="text.secondary">{t("Addr2")}</Typography>
  335. <Typography variant="body1">{shopDetail.addr2 || "-"}</Typography>
  336. </Box>
  337. <Box>
  338. <Typography variant="subtitle2" color="text.secondary">{t("Addr3")}</Typography>
  339. <Typography variant="body1">{shopDetail.addr3 || "-"}</Typography>
  340. </Box>
  341. <Box>
  342. <Typography variant="subtitle2" color="text.secondary">{t("Contact No")}</Typography>
  343. <Typography variant="body1">{shopDetail.contactNo || "-"}</Typography>
  344. </Box>
  345. <Box>
  346. <Typography variant="subtitle2" color="text.secondary">{t("Contact Email")}</Typography>
  347. <Typography variant="body1">{shopDetail.contactEmail || "-"}</Typography>
  348. </Box>
  349. <Box>
  350. <Typography variant="subtitle2" color="text.secondary">{t("Contact Name")}</Typography>
  351. <Typography variant="body1">{shopDetail.contactName || "-"}</Typography>
  352. </Box>
  353. </Box>
  354. </CardContent>
  355. </Card>
  356. <Card>
  357. <CardContent>
  358. <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}>
  359. <Typography variant="h6">{t("Truck Information")}</Typography>
  360. <Button
  361. variant="contained"
  362. startIcon={<AddIcon />}
  363. onClick={handleOpenAddDialog}
  364. disabled={saving}
  365. >
  366. {t("Add Truck Lane")}
  367. </Button>
  368. </Box>
  369. <TableContainer component={Paper}>
  370. <Table>
  371. <TableHead>
  372. <TableRow>
  373. <TableCell>{t("TruckLance Code")}</TableCell>
  374. <TableCell>{t("Departure Time")}</TableCell>
  375. <TableCell>{t("Loading Sequence")}</TableCell>
  376. <TableCell>{t("District Reference")}</TableCell>
  377. <TableCell>{t("Store ID")}</TableCell>
  378. <TableCell>{t("Remark")}</TableCell>
  379. <TableCell align="right">{t("Actions")}</TableCell>
  380. </TableRow>
  381. </TableHead>
  382. <TableBody>
  383. {truckData.length === 0 ? (
  384. <TableRow>
  385. <TableCell colSpan={7} align="center">
  386. <Typography variant="body2" color="text.secondary">
  387. {t("No Truck data available")}
  388. </Typography>
  389. </TableCell>
  390. </TableRow>
  391. ) : (
  392. truckData.map((truck, index) => (
  393. <TableRow key={truck.id ?? `truck-${index}`}>
  394. <TableCell>{String(truck.truckLanceCode || "-")}</TableCell>
  395. <TableCell>
  396. {formatDepartureTime(
  397. Array.isArray(truck.departureTime)
  398. ? truck.departureTime
  399. : truck.departureTime
  400. ? String(truck.departureTime)
  401. : null
  402. )}
  403. </TableCell>
  404. <TableCell>
  405. {truck.loadingSequence !== null && truck.loadingSequence !== undefined
  406. ? String(truck.loadingSequence)
  407. : "-"}
  408. </TableCell>
  409. <TableCell>
  410. {truck.districtReference !== null && truck.districtReference !== undefined
  411. ? String(truck.districtReference)
  412. : "-"}
  413. </TableCell>
  414. <TableCell>{normalizeStoreId(truck.storeId)}</TableCell>
  415. <TableCell>{String(truck.remark || "-")}</TableCell>
  416. <TableCell align="right">
  417. <Stack direction="row" spacing={0.5} justifyContent="flex-end">
  418. {truck.id && (
  419. confirmingDeleteIndex === index ? (
  420. <>
  421. <IconButton
  422. size="small"
  423. color="error"
  424. onClick={() => handleDelete(truck.id!)}
  425. disabled={saving}
  426. title={t("Confirm delete")}
  427. >
  428. <CheckIcon />
  429. </IconButton>
  430. <IconButton
  431. size="small"
  432. color="default"
  433. onClick={() => setConfirmingDeleteIndex(null)}
  434. disabled={saving}
  435. title={t("Cancel")}
  436. >
  437. <CloseIcon />
  438. </IconButton>
  439. </>
  440. ) : (
  441. <IconButton
  442. color="error"
  443. size="small"
  444. onClick={() => setConfirmingDeleteIndex(index)}
  445. disabled={saving || confirmingDeleteIndex !== null}
  446. title={t("Delete truck lane")}
  447. >
  448. <DeleteIcon />
  449. </IconButton>
  450. )
  451. )}
  452. </Stack>
  453. </TableCell>
  454. </TableRow>
  455. ))
  456. )}
  457. </TableBody>
  458. </Table>
  459. </TableContainer>
  460. </CardContent>
  461. </Card>
  462. {/* Add Truck Dialog */}
  463. <Dialog open={addDialogOpen} onClose={handleCloseAddDialog} maxWidth="sm" fullWidth>
  464. <DialogTitle>{t("Add New Truck Lane")}</DialogTitle>
  465. <DialogContent>
  466. <Box sx={{ pt: 2 }}>
  467. <Grid container spacing={2}>
  468. <Grid item xs={12}>
  469. <Autocomplete
  470. options={availableTruckLanes}
  471. loading={loadingTruckLanes}
  472. value={selectedTruckLane}
  473. onChange={(_event, newValue) => {
  474. setSelectedTruckLane(newValue);
  475. }}
  476. getOptionLabel={(option) => getTruckLaneOptionLabel(option)}
  477. isOptionEqualToValue={(option, value) => isSameTruckLaneOption(option, value)}
  478. disabled={saving || loadingTruckLanes}
  479. renderInput={(params) => (
  480. <TextField
  481. {...params}
  482. label={t("TruckLance Code")}
  483. required
  484. placeholder={t("Select a truck lane")}
  485. helperText={
  486. !loadingTruckLanes && availableTruckLanes.length === 0
  487. ? t("No truck lanes available")
  488. : undefined
  489. }
  490. />
  491. )}
  492. />
  493. </Grid>
  494. <Grid item xs={12}>
  495. <TextField
  496. label={t("Loading Sequence")}
  497. type="number"
  498. fullWidth
  499. value={addLoadingSequence}
  500. onChange={(e) => setAddLoadingSequence(parseInt(e.target.value, 10) || 0)}
  501. disabled={saving}
  502. />
  503. </Grid>
  504. </Grid>
  505. </Box>
  506. </DialogContent>
  507. <DialogActions>
  508. <Button onClick={handleCloseAddDialog} disabled={saving}>
  509. {t("Cancel")}
  510. </Button>
  511. <Button
  512. onClick={handleCreateTruck}
  513. variant="contained"
  514. startIcon={<SaveIcon />}
  515. disabled={saving}
  516. >
  517. {saving ? t("Submitting...") : t("Save")}
  518. </Button>
  519. </DialogActions>
  520. </Dialog>
  521. {/* Snackbar for notifications */}
  522. <Snackbar
  523. open={snackbarOpen}
  524. autoHideDuration={6000}
  525. onClose={() => setSnackbarOpen(false)}
  526. anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
  527. >
  528. <Alert
  529. onClose={() => setSnackbarOpen(false)}
  530. severity="warning"
  531. sx={{ width: '100%' }}
  532. >
  533. {snackbarMessage}
  534. </Alert>
  535. </Snackbar>
  536. </Box>
  537. );
  538. };
  539. export default ShopDetail;