FPSMS-frontend
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

Shop.tsx 13 KiB

4 weeks ago
4 weeks ago
4 weeks ago
4 weeks ago
4 weeks ago
4 weeks ago
4 weeks ago
4 weeks ago
4 weeks ago
4 weeks ago
4 weeks ago
4 weeks ago
4 weeks ago
4 weeks ago
4 weeks ago
4 weeks ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  1. "use client";
  2. import {
  3. Box,
  4. Button,
  5. Card,
  6. CardContent,
  7. Stack,
  8. Typography,
  9. Alert,
  10. CircularProgress,
  11. Chip,
  12. Tabs,
  13. Tab,
  14. Select,
  15. MenuItem,
  16. FormControl,
  17. InputLabel,
  18. } from "@mui/material";
  19. import { useState, useMemo, useCallback, useEffect } from "react";
  20. import { useRouter, useSearchParams } from "next/navigation";
  21. import { useTranslation } from "react-i18next";
  22. import SearchBox, { Criterion } from "../SearchBox";
  23. import SearchResults, { Column } from "../SearchResults";
  24. import { defaultPagingController } from "../SearchResults/SearchResults";
  25. import { fetchAllShopsClient } from "@/app/api/shop/client";
  26. import type { Shop, ShopAndTruck } from "@/app/api/shop/actions";
  27. import TruckLane from "./TruckLane";
  28. type ShopRow = Shop & {
  29. actions?: string;
  30. truckLanceStatus?: "complete" | "missing" | "no-truck";
  31. };
  32. type SearchQuery = {
  33. id: string;
  34. name: string;
  35. code: string;
  36. };
  37. type SearchParamNames = keyof SearchQuery;
  38. const Shop: React.FC = () => {
  39. const { t } = useTranslation("common");
  40. const router = useRouter();
  41. const searchParams = useSearchParams();
  42. const [activeTab, setActiveTab] = useState<number>(0);
  43. const [rows, setRows] = useState<ShopRow[]>([]);
  44. const [loading, setLoading] = useState<boolean>(false);
  45. const [error, setError] = useState<string | null>(null);
  46. const [filters, setFilters] = useState<Record<string, string>>({});
  47. const [statusFilter, setStatusFilter] = useState<string>("all");
  48. const [pagingController, setPagingController] = useState(defaultPagingController);
  49. // client-side filtered rows (contains-matching + status filter)
  50. const filteredRows = useMemo(() => {
  51. const fKeys = Object.keys(filters || {}).filter((k) => String((filters as any)[k]).trim() !== "");
  52. let normalized = (rows || []).filter((r) => {
  53. // apply contains matching for each active filter
  54. for (const k of fKeys) {
  55. const v = String((filters as any)[k] ?? "").trim();
  56. const rv = String((r as any)[k] ?? "").trim();
  57. // Use exact matching for id field, contains matching for others
  58. if (k === "id") {
  59. const numValue = Number(v);
  60. const rvNum = Number(rv);
  61. if (!isNaN(numValue) && !isNaN(rvNum)) {
  62. if (numValue !== rvNum) return false;
  63. } else {
  64. if (v !== rv) return false;
  65. }
  66. } else {
  67. if (!rv.toLowerCase().includes(v.toLowerCase())) return false;
  68. }
  69. }
  70. return true;
  71. });
  72. // Apply status filter
  73. if (statusFilter !== "all") {
  74. normalized = normalized.filter((r) => {
  75. return r.truckLanceStatus === statusFilter;
  76. });
  77. }
  78. return normalized;
  79. }, [rows, filters, statusFilter]);
  80. // Check if a shop has missing truckLanceCode data
  81. const checkTruckLanceStatus = useCallback((shopTrucks: ShopAndTruck[]): "complete" | "missing" | "no-truck" => {
  82. if (!shopTrucks || shopTrucks.length === 0) {
  83. return "no-truck";
  84. }
  85. // Check if shop has any actual truck lanes (not just null entries from LEFT JOIN)
  86. // A shop with no trucks will have entries with null truckLanceCode
  87. const hasAnyTruckLane = shopTrucks.some((truck) => {
  88. const truckLanceCode = (truck as any).truckLanceCode;
  89. return truckLanceCode != null && String(truckLanceCode).trim() !== "";
  90. });
  91. if (!hasAnyTruckLane) {
  92. return "no-truck";
  93. }
  94. // Check each truckLanceCode entry for missing data
  95. for (const truck of shopTrucks) {
  96. // Skip entries without truckLanceCode (they're from LEFT JOIN when no trucks exist)
  97. const truckLanceCode = (truck as any).truckLanceCode;
  98. if (!truckLanceCode || String(truckLanceCode).trim() === "") {
  99. continue; // Skip this entry, it's not a real truck lane
  100. }
  101. // Check truckLanceCode: must exist and not be empty (already validated above)
  102. const hasTruckLanceCode = truckLanceCode != null && String(truckLanceCode).trim() !== "";
  103. // Check departureTime: must exist and not be empty
  104. // Can be array format [hours, minutes] or string format
  105. const departureTime = (truck as any).departureTime || (truck as any).DepartureTime;
  106. let hasDepartureTime = false;
  107. if (departureTime != null) {
  108. if (Array.isArray(departureTime) && departureTime.length >= 2) {
  109. // Array format [hours, minutes]
  110. hasDepartureTime = true;
  111. } else {
  112. // String format
  113. const timeStr = String(departureTime).trim();
  114. hasDepartureTime = timeStr !== "" && timeStr !== "-";
  115. }
  116. }
  117. // Check loadingSequence: must exist and not be 0
  118. const loadingSeq = (truck as any).loadingSequence || (truck as any).LoadingSequence;
  119. const loadingSeqNum = loadingSeq != null && loadingSeq !== undefined ? Number(loadingSeq) : null;
  120. const hasLoadingSequence = loadingSeqNum !== null && !isNaN(loadingSeqNum) && loadingSeqNum !== 0;
  121. // Check districtReference: must exist and not be 0
  122. const districtRef = (truck as any).districtReference;
  123. const districtRefNum = districtRef != null && districtRef !== undefined ? Number(districtRef) : null;
  124. const hasDistrictReference = districtRefNum !== null && !isNaN(districtRefNum) && districtRefNum !== 0;
  125. // Check storeId: must exist and not be 0 (can be string "2F"/"4F" or number)
  126. // Actual field name in JSON is store_id (underscore, lowercase)
  127. const storeId = (truck as any).store_id || (truck as any).storeId || (truck as any).Store_id;
  128. let storeIdValid = false;
  129. if (storeId != null && storeId !== undefined && storeId !== "") {
  130. const storeIdStr = String(storeId).trim();
  131. // If it's "2F" or "4F", it's valid (not 0)
  132. if (storeIdStr === "2F" || storeIdStr === "4F") {
  133. storeIdValid = true;
  134. } else {
  135. const storeIdNum = Number(storeId);
  136. // If it's a valid number and not 0, it's valid
  137. if (!isNaN(storeIdNum) && storeIdNum !== 0) {
  138. storeIdValid = true;
  139. }
  140. }
  141. }
  142. // If any required field is missing or equals 0, return "missing"
  143. if (!hasTruckLanceCode || !hasDepartureTime || !hasLoadingSequence || !hasDistrictReference || !storeIdValid) {
  144. return "missing";
  145. }
  146. }
  147. return "complete";
  148. }, []);
  149. const fetchAllShops = async (params?: Record<string, string>) => {
  150. setLoading(true);
  151. setError(null);
  152. try {
  153. const data = await fetchAllShopsClient(params) as ShopAndTruck[];
  154. console.log("Fetched shops data:", data);
  155. // Group data by shop ID (one shop can have multiple TruckLanceCode entries)
  156. const shopMap = new Map<number, { shop: Shop; trucks: ShopAndTruck[] }>();
  157. (data || []).forEach((item: ShopAndTruck) => {
  158. const shopId = item.id;
  159. if (!shopMap.has(shopId)) {
  160. shopMap.set(shopId, {
  161. shop: {
  162. id: item.id,
  163. name: item.name,
  164. code: item.code,
  165. addr3: item.addr3 ?? "",
  166. },
  167. trucks: [],
  168. });
  169. }
  170. shopMap.get(shopId)!.trucks.push(item);
  171. });
  172. // Convert to ShopRow array with truckLanceStatus
  173. const mapped: ShopRow[] = Array.from(shopMap.values()).map(({ shop, trucks }) => ({
  174. ...shop,
  175. truckLanceStatus: checkTruckLanceStatus(trucks),
  176. }));
  177. setRows(mapped);
  178. } catch (err: any) {
  179. console.error("Failed to load shops:", err);
  180. setError(err?.message ?? String(err));
  181. } finally {
  182. setLoading(false);
  183. }
  184. };
  185. // SearchBox onSearch will call this
  186. const handleSearch = (inputs: Record<string, string>) => {
  187. setFilters(inputs);
  188. const params: Record<string, string> = {};
  189. Object.entries(inputs || {}).forEach(([k, v]) => {
  190. if (v != null && String(v).trim() !== "") params[k] = String(v).trim();
  191. });
  192. if (Object.keys(params).length === 0) fetchAllShops();
  193. else fetchAllShops(params);
  194. };
  195. const handleViewDetail = useCallback(
  196. (shop: ShopRow) => {
  197. router.push(`/settings/shop/detail?id=${shop.id}`);
  198. },
  199. [router]
  200. );
  201. const criteria: Criterion<SearchParamNames>[] = [
  202. { type: "text", label: t("id"), paramName: "id" },
  203. { type: "text", label: t("code"), paramName: "code" },
  204. { type: "text", label: t("Shop Name"), paramName: "name" },
  205. ];
  206. const columns: Column<ShopRow>[] = [
  207. {
  208. name: "id",
  209. label: t("id"),
  210. type: "integer",
  211. sx: { width: "100px", minWidth: "100px", maxWidth: "100px" },
  212. renderCell: (item) => String(item.id ?? ""),
  213. },
  214. {
  215. name: "code",
  216. label: t("Code"),
  217. sx: { width: "150px", minWidth: "150px", maxWidth: "150px" },
  218. renderCell: (item) => String(item.code ?? ""),
  219. },
  220. {
  221. name: "name",
  222. label: t("Name"),
  223. sx: { width: "200px", minWidth: "200px", maxWidth: "200px" },
  224. renderCell: (item) => String(item.name ?? ""),
  225. },
  226. {
  227. name: "addr3",
  228. label: t("Addr3"),
  229. sx: { width: "200px", minWidth: "200px", maxWidth: "200px" },
  230. renderCell: (item) => String((item as any).addr3 ?? ""),
  231. },
  232. {
  233. name: "truckLanceStatus",
  234. label: t("TruckLance Status"),
  235. align: "center",
  236. headerAlign: "center",
  237. sx: { width: "150px", minWidth: "150px", maxWidth: "150px" },
  238. renderCell: (item) => {
  239. const status = item.truckLanceStatus;
  240. if (status === "complete") {
  241. return <Chip label={t("Complete")} color="success" size="small" />;
  242. } else if (status === "missing") {
  243. return <Chip label={t("Missing Data")} color="warning" size="small" />;
  244. } else {
  245. return <Chip label={t("No TruckLance")} color="error" size="small" />;
  246. }
  247. },
  248. },
  249. {
  250. name: "actions",
  251. label: t("Actions"),
  252. align: "right",
  253. headerAlign: "right",
  254. sx: { width: "150px", minWidth: "150px", maxWidth: "150px" },
  255. renderCell: (item) => (
  256. <Button
  257. size="small"
  258. variant="outlined"
  259. onClick={() => handleViewDetail(item)}
  260. >
  261. {t("View Detail")}
  262. </Button>
  263. ),
  264. },
  265. ];
  266. // Initialize activeTab from URL parameter
  267. useEffect(() => {
  268. const tabParam = searchParams.get("tab");
  269. if (tabParam !== null) {
  270. const tabIndex = parseInt(tabParam, 10);
  271. if (!isNaN(tabIndex) && (tabIndex === 0 || tabIndex === 1)) {
  272. setActiveTab(tabIndex);
  273. }
  274. }
  275. }, [searchParams]);
  276. const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
  277. setActiveTab(newValue);
  278. // Update URL to reflect the selected tab
  279. const url = new URL(window.location.href);
  280. url.searchParams.set("tab", String(newValue));
  281. router.push(url.pathname + url.search);
  282. };
  283. return (
  284. <Box>
  285. {/* Header section with title */}
  286. <Box sx={{
  287. p: 2,
  288. borderBottom: '1px solid #e0e0e0'
  289. }}>
  290. <Typography variant="h4">
  291. 店鋪路線管理
  292. </Typography>
  293. </Box>
  294. {/* Tabs section */}
  295. <Box sx={{
  296. borderBottom: '1px solid #e0e0e0'
  297. }}>
  298. <Tabs
  299. value={activeTab}
  300. onChange={handleTabChange}
  301. >
  302. <Tab label={t("Shop")} />
  303. <Tab label={t("Truck Lane")} />
  304. </Tabs>
  305. </Box>
  306. {/* Content section */}
  307. <Box sx={{ p: 2 }}>
  308. {activeTab === 0 && (
  309. <>
  310. <Card sx={{ mb: 2 }}>
  311. <CardContent>
  312. <SearchBox
  313. criteria={criteria as Criterion<string>[]}
  314. onSearch={handleSearch}
  315. onReset={() => {
  316. setRows([]);
  317. setFilters({});
  318. }}
  319. />
  320. </CardContent>
  321. </Card>
  322. <Card>
  323. <CardContent>
  324. <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
  325. <Typography variant="h6">{t("Shop")}</Typography>
  326. <FormControl size="small" sx={{ minWidth: 200 }}>
  327. <InputLabel>{t("Filter by Status")}</InputLabel>
  328. <Select
  329. value={statusFilter}
  330. label={t("Filter by Status")}
  331. onChange={(e) => setStatusFilter(e.target.value)}
  332. >
  333. <MenuItem value="all">{t("All")}</MenuItem>
  334. <MenuItem value="complete">{t("Complete")}</MenuItem>
  335. <MenuItem value="missing">{t("Missing Data")}</MenuItem>
  336. <MenuItem value="no-truck">{t("No TruckLance")}</MenuItem>
  337. </Select>
  338. </FormControl>
  339. </Stack>
  340. {error && (
  341. <Alert severity="error" sx={{ mb: 2 }}>
  342. {error}
  343. </Alert>
  344. )}
  345. {loading ? (
  346. <Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
  347. <CircularProgress />
  348. </Box>
  349. ) : (
  350. <SearchResults
  351. items={filteredRows}
  352. columns={columns}
  353. pagingController={pagingController}
  354. setPagingController={setPagingController}
  355. />
  356. )}
  357. </CardContent>
  358. </Card>
  359. </>
  360. )}
  361. {activeTab === 1 && (
  362. <TruckLane />
  363. )}
  364. </Box>
  365. </Box>
  366. );
  367. };
  368. export default Shop;