FPSMS-frontend
Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.
 
 

1137 wiersze
32 KiB

  1. "use client";
  2. import React, { memo, useCallback, useRef, useState } from "react";
  3. import {
  4. Box,
  5. Button,
  6. Chip,
  7. Dialog,
  8. DialogActions,
  9. DialogContent,
  10. DialogTitle,
  11. IconButton,
  12. Paper,
  13. Stack,
  14. TextField,
  15. Tooltip,
  16. Typography,
  17. } from "@mui/material";
  18. import { alpha } from "@mui/material/styles";
  19. import { MapPin, Move, Pencil, Plus, Trash2, Undo2, X } from "lucide-react";
  20. import { useTranslation } from "react-i18next";
  21. import type {
  22. PlannedLane,
  23. PlannedShop,
  24. ScheduleDragWorkspaceState,
  25. } from "@/components/Shop/scheduleDragWorkspace";
  26. import type { ScheduleLaneOption } from "@/components/Shop/ScheduleChangeModal";
  27. import {
  28. buildLaneDistrictSections,
  29. districtDisplayExistsInShops,
  30. formatShopCardSubtitle,
  31. } from "@/components/Shop/routeBoardDisplayOrder";
  32. type Props = {
  33. lanes: ScheduleLaneOption[];
  34. leftLaneId: string;
  35. rightLaneId: string;
  36. plannedLanes: ScheduleDragWorkspaceState;
  37. pendingEmptyDistrictsByLane: Record<string, string[]>;
  38. onLeftLaneChange: (laneId: string) => void;
  39. onRightLaneChange: (laneId: string) => void;
  40. onMoveShop: (
  41. shopId: number,
  42. fromLaneId: string,
  43. toLaneId: string,
  44. beforeShopId: number | null,
  45. targetDistrict?: string | null,
  46. ) => void;
  47. onRevertShop: (shopId: number, currentLaneId: string) => void;
  48. onSetLoadingSequence: (
  49. laneId: string,
  50. shopId: number,
  51. loadingSequence: number,
  52. ) => void;
  53. onAddEmptyDistrict: (laneId: string, display: string) => void;
  54. onRemoveEmptyDistrict: (laneId: string, display: string) => void;
  55. onDeleteShop: (shopId: number, laneId: string) => void;
  56. onAddShop: (laneId: string) => void;
  57. onSetLaneDepartureTime: (laneId: string, departureTime: string) => boolean;
  58. };
  59. function toTimeInputValue(t: string | undefined): string {
  60. const s = String(t ?? "").trim();
  61. if (!s) return "00:00";
  62. const m = s.match(/^(\d{1,2}):(\d{2})(?::\d{2})?/);
  63. if (m) return `${m[1].padStart(2, "0")}:${m[2]}`;
  64. return "00:00";
  65. }
  66. function formatDepartureChip(t: string): string {
  67. const m = String(t ?? "").trim().match(/^(\d{1,2}):(\d{2})/);
  68. return m ? `${m[1].padStart(2, "0")}:${m[2]}` : "-";
  69. }
  70. function getBeforeShopIdByPointer(
  71. laneId: string,
  72. clientY: number,
  73. ): number | null {
  74. const laneEl = document.querySelector<HTMLElement>(
  75. `[data-schedule-lane-id="${CSS.escape(laneId)}"]`,
  76. );
  77. if (!laneEl) return null;
  78. const cards = Array.from(
  79. laneEl.querySelectorAll<HTMLElement>("[data-schedule-shop-id]"),
  80. );
  81. for (const cardEl of cards) {
  82. const rect = cardEl.getBoundingClientRect();
  83. const midY = rect.top + rect.height / 2;
  84. if (clientY < midY) {
  85. const idStr = cardEl.getAttribute("data-schedule-shop-id");
  86. const id = idStr ? Number(idStr) : NaN;
  87. return Number.isFinite(id) ? id : null;
  88. }
  89. }
  90. return null;
  91. }
  92. const nativeSelectSx = {
  93. fontSize: "0.75rem",
  94. fontWeight: 600,
  95. py: 0.5,
  96. px: 1,
  97. borderRadius: 1,
  98. border: "1px solid",
  99. borderColor: "divider",
  100. bgcolor: "background.paper",
  101. flex: 1,
  102. minWidth: 0,
  103. width: "100%",
  104. maxWidth: "100%",
  105. } as const;
  106. const laneSelectorLabelSx = {
  107. fontWeight: 700,
  108. color: "text.secondary",
  109. flexShrink: 0,
  110. minWidth: "4.5em",
  111. whiteSpace: "nowrap",
  112. } as const;
  113. type SeqEditTarget = {
  114. laneId: string;
  115. shopId: number;
  116. draft: string;
  117. };
  118. const LoadingSequenceDisplay = memo(function LoadingSequenceDisplay({
  119. loadingSequence,
  120. seqLabel,
  121. editAriaLabel,
  122. onEdit,
  123. }: {
  124. loadingSequence: number;
  125. seqLabel: (seq: number) => string;
  126. editAriaLabel: string;
  127. onEdit: () => void;
  128. }) {
  129. return (
  130. <Stack direction="row" spacing={0.25} alignItems="center">
  131. <Chip
  132. size="small"
  133. label={seqLabel(loadingSequence)}
  134. sx={{
  135. height: 22,
  136. fontSize: "0.65rem",
  137. fontWeight: 800,
  138. bgcolor: "primary.50",
  139. color: "primary.dark",
  140. }}
  141. />
  142. <IconButton
  143. size="small"
  144. aria-label={editAriaLabel}
  145. onMouseDown={(e) => e.stopPropagation()}
  146. onClick={(e) => {
  147. e.stopPropagation();
  148. onEdit();
  149. }}
  150. sx={{
  151. width: 22,
  152. height: 22,
  153. color: "primary.main",
  154. flexShrink: 0,
  155. }}
  156. >
  157. <Pencil size={12} />
  158. </IconButton>
  159. </Stack>
  160. );
  161. });
  162. const DistrictSectionHeader = memo(function DistrictSectionHeader({
  163. district,
  164. count,
  165. isPendingEmpty,
  166. editAriaLabel,
  167. removeAriaLabel,
  168. onRemove,
  169. }: {
  170. district: string;
  171. count: number;
  172. isPendingEmpty?: boolean;
  173. editAriaLabel?: string;
  174. removeAriaLabel?: string;
  175. onRemove?: () => void;
  176. }) {
  177. return (
  178. <Stack
  179. direction="row"
  180. spacing={0.5}
  181. alignItems="center"
  182. sx={{ px: 0.5, mb: 1, minWidth: 0 }}
  183. >
  184. <MapPin size={14} />
  185. <Typography
  186. variant="caption"
  187. sx={{ fontWeight: 900, color: "text.secondary", minWidth: 0 }}
  188. noWrap
  189. >
  190. {district}
  191. </Typography>
  192. <Box sx={{ flex: 1, height: 1, bgcolor: "grey.200", mx: 0.5 }} />
  193. <Typography variant="caption" color="text.secondary">
  194. {count}
  195. </Typography>
  196. {isPendingEmpty && onRemove && (
  197. <IconButton
  198. size="small"
  199. sx={{ p: 0.25 }}
  200. onMouseDown={(e) => e.stopPropagation()}
  201. onClick={(e) => {
  202. e.stopPropagation();
  203. onRemove();
  204. }}
  205. aria-label={removeAriaLabel ?? editAriaLabel}
  206. >
  207. <X size={14} />
  208. </IconButton>
  209. )}
  210. </Stack>
  211. );
  212. });
  213. const PlannedShopCard = memo(function PlannedShopCard({
  214. shop,
  215. laneId,
  216. isMoved,
  217. isHovered,
  218. hoveredPosition,
  219. movedBadgeLabel,
  220. seqLabel,
  221. seqEditAriaLabel,
  222. onDragStartShop,
  223. onDragOverShop,
  224. onDropShop,
  225. onRevertShop,
  226. onDeleteShop,
  227. removeFromLaneTooltip,
  228. onStartSeqEdit,
  229. }: {
  230. shop: PlannedShop;
  231. laneId: string;
  232. isMoved: boolean;
  233. isHovered: boolean;
  234. hoveredPosition: "before" | "after";
  235. movedBadgeLabel: string;
  236. seqLabel: (seq: number) => string;
  237. seqEditAriaLabel: string;
  238. removeFromLaneTooltip: string;
  239. onDragStartShop: (shopId: number) => void;
  240. onDragOverShop: (e: React.DragEvent, shopId: number) => void;
  241. onDropShop: (e: React.DragEvent, shopId: number) => void;
  242. onRevertShop: (shopId: number) => void;
  243. onDeleteShop: (shopId: number, laneId: string) => void;
  244. onStartSeqEdit: (laneId: string, shopId: number, current: number) => void;
  245. }) {
  246. return (
  247. <Box sx={{ position: "relative" }}>
  248. {isHovered && hoveredPosition === "before" && (
  249. <Box
  250. sx={{
  251. height: 4,
  252. bgcolor: "primary.main",
  253. borderRadius: 1,
  254. mb: 0.5,
  255. }}
  256. />
  257. )}
  258. <Paper
  259. variant="outlined"
  260. draggable
  261. data-schedule-shop-id={shop.truckRowId}
  262. onDragStart={() => onDragStartShop(shop.truckRowId)}
  263. onDragOver={(e) => onDragOverShop(e, shop.truckRowId)}
  264. onDrop={(e) => onDropShop(e, shop.truckRowId)}
  265. sx={{
  266. p: 1.25,
  267. cursor: "grab",
  268. bgcolor: isMoved ? alpha("#ed6c02", 0.06) : "background.paper",
  269. borderColor: isHovered
  270. ? "primary.main"
  271. : isMoved
  272. ? "warning.light"
  273. : "divider",
  274. borderStyle: isHovered ? "dashed" : "solid",
  275. "&:active": { cursor: "grabbing" },
  276. }}
  277. >
  278. <Stack
  279. direction="row"
  280. justifyContent="space-between"
  281. alignItems="flex-start"
  282. spacing={1}
  283. >
  284. <Box sx={{ minWidth: 0 }}>
  285. <Typography variant="subtitle2" sx={{ fontWeight: 900 }} noWrap>
  286. {shop.displayName}
  287. </Typography>
  288. <Typography
  289. variant="caption"
  290. color="text.secondary"
  291. display="block"
  292. noWrap
  293. >
  294. {formatShopCardSubtitle(shop)}
  295. </Typography>
  296. </Box>
  297. <Stack direction="row" spacing={0.25} alignItems="center" sx={{ flexShrink: 0 }}>
  298. {isMoved && (
  299. <IconButton
  300. size="small"
  301. onMouseDown={(e) => e.stopPropagation()}
  302. onClick={(e) => {
  303. e.stopPropagation();
  304. onRevertShop(shop.truckRowId);
  305. }}
  306. sx={{ color: "warning.dark" }}
  307. aria-label="revert"
  308. >
  309. <Undo2 size={14} />
  310. </IconButton>
  311. )}
  312. <Tooltip title={removeFromLaneTooltip}>
  313. <span>
  314. <IconButton
  315. size="small"
  316. onMouseDown={(e) => e.stopPropagation()}
  317. onClick={(e) => {
  318. e.stopPropagation();
  319. onDeleteShop(shop.truckRowId, laneId);
  320. }}
  321. sx={{ color: "error.main" }}
  322. aria-label={removeFromLaneTooltip}
  323. >
  324. <Trash2 size={14} />
  325. </IconButton>
  326. </span>
  327. </Tooltip>
  328. </Stack>
  329. </Stack>
  330. <Stack
  331. direction="row"
  332. justifyContent="space-between"
  333. alignItems="center"
  334. sx={{ mt: 1 }}
  335. >
  336. <LoadingSequenceDisplay
  337. loadingSequence={shop.loadingSequence}
  338. seqLabel={seqLabel}
  339. editAriaLabel={seqEditAriaLabel}
  340. onEdit={() =>
  341. onStartSeqEdit(laneId, shop.truckRowId, shop.loadingSequence)
  342. }
  343. />
  344. {isMoved && (
  345. <Chip
  346. size="small"
  347. label={movedBadgeLabel}
  348. color="warning"
  349. sx={{ height: 20, fontSize: "0.6rem", fontWeight: 800 }}
  350. />
  351. )}
  352. </Stack>
  353. </Paper>
  354. {isHovered && hoveredPosition === "after" && (
  355. <Box
  356. sx={{
  357. height: 4,
  358. bgcolor: "primary.main",
  359. borderRadius: 1,
  360. mt: 0.5,
  361. }}
  362. />
  363. )}
  364. </Box>
  365. );
  366. });
  367. const LaneColumn = memo(function LaneColumn({
  368. lane,
  369. pendingEmptyDistricts,
  370. isOver,
  371. onDragOverColumn,
  372. onDragLeaveColumn,
  373. onDropColumn,
  374. onDropDistrict,
  375. onDragStartShop,
  376. onDragOverShop,
  377. onDropShop,
  378. onRevertShop,
  379. onDeleteShop,
  380. onAddDistrict,
  381. onRemoveEmptyDistrict,
  382. hoveredShopId,
  383. hoveredPosition,
  384. movedBadgeLabel,
  385. seqLabel,
  386. seqEditAriaLabel,
  387. removeFromLaneTooltip,
  388. removeEmptyDistrictAriaLabel,
  389. onStartSeqEdit,
  390. dropHint,
  391. addDistrictLabel,
  392. addShopLabel,
  393. onAddShop,
  394. departureLabel,
  395. departureEditAriaLabel,
  396. onStartDepartureEdit,
  397. }: {
  398. lane: PlannedLane;
  399. pendingEmptyDistricts: string[];
  400. isOver: boolean;
  401. onDragOverColumn: (e: React.DragEvent) => void;
  402. onDragLeaveColumn: () => void;
  403. onDropColumn: (e: React.DragEvent) => void;
  404. onDropDistrict: (e: React.DragEvent, district: string) => void;
  405. onDragStartShop: (shopId: number) => void;
  406. onDragOverShop: (e: React.DragEvent, shopId: number) => void;
  407. onDropShop: (e: React.DragEvent, shopId: number) => void;
  408. onRevertShop: (shopId: number) => void;
  409. onDeleteShop: (shopId: number, laneId: string) => void;
  410. onAddDistrict: () => void;
  411. onRemoveEmptyDistrict: (district: string) => void;
  412. removeFromLaneTooltip: string;
  413. hoveredShopId: number | null;
  414. hoveredPosition: "before" | "after";
  415. movedBadgeLabel: string;
  416. seqLabel: (seq: number) => string;
  417. seqEditAriaLabel: string;
  418. removeEmptyDistrictAriaLabel: string;
  419. onStartSeqEdit: (laneId: string, shopId: number, current: number) => void;
  420. dropHint: string;
  421. addDistrictLabel: string;
  422. addShopLabel: string;
  423. onAddShop: () => void;
  424. departureLabel: string;
  425. departureEditAriaLabel: string;
  426. onStartDepartureEdit: (laneId: string, current: string) => void;
  427. }) {
  428. const districtSections = buildLaneDistrictSections(
  429. lane.shops,
  430. pendingEmptyDistricts,
  431. );
  432. return (
  433. <Paper
  434. variant="outlined"
  435. data-schedule-lane-id={lane.id}
  436. onDragOver={onDragOverColumn}
  437. onDragLeave={onDragLeaveColumn}
  438. onDrop={onDropColumn}
  439. sx={{
  440. display: "flex",
  441. flexDirection: "column",
  442. minHeight: 0,
  443. height: "100%",
  444. overflow: "hidden",
  445. bgcolor: isOver ? alpha("#1976d2", 0.04) : "grey.50",
  446. borderColor: isOver ? "primary.main" : "divider",
  447. boxShadow: isOver ? `0 0 0 2px ${alpha("#1976d2", 0.12)}` : undefined,
  448. transition: "border-color 0.15s, background-color 0.15s",
  449. }}
  450. >
  451. <Box
  452. sx={{
  453. px: 1.5,
  454. py: 1.25,
  455. borderBottom: 1,
  456. borderColor: "divider",
  457. bgcolor: "background.paper",
  458. display: "flex",
  459. alignItems: "center",
  460. justifyContent: "space-between",
  461. flexShrink: 0,
  462. }}
  463. >
  464. <Box sx={{ minWidth: 0, flex: 1 }}>
  465. <Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap">
  466. <Typography variant="body2" sx={{ fontWeight: 800 }} noWrap>
  467. {lane.label}
  468. </Typography>
  469. <Chip
  470. label={lane.truckLanceCode}
  471. size="small"
  472. sx={{ height: 18, fontSize: "0.65rem", fontFamily: "monospace" }}
  473. />
  474. </Stack>
  475. <Stack direction="row" alignItems="center" spacing={0.25} sx={{ mt: 0.35 }}>
  476. <Typography
  477. variant="caption"
  478. sx={{
  479. fontWeight: 700,
  480. bgcolor: "grey.100",
  481. px: 0.75,
  482. py: 0.2,
  483. borderRadius: 1,
  484. whiteSpace: "nowrap",
  485. }}
  486. >
  487. {departureLabel}: {formatDepartureChip(lane.departureTime)}
  488. </Typography>
  489. <Tooltip title={departureEditAriaLabel}>
  490. <IconButton
  491. size="small"
  492. onClick={(e) => {
  493. e.stopPropagation();
  494. onStartDepartureEdit(lane.id, lane.departureTime);
  495. }}
  496. aria-label={departureEditAriaLabel}
  497. >
  498. <Pencil size={14} />
  499. </IconButton>
  500. </Tooltip>
  501. </Stack>
  502. </Box>
  503. <Chip
  504. size="small"
  505. label={lane.shops.length}
  506. sx={{ fontWeight: 800, flexShrink: 0 }}
  507. />
  508. </Box>
  509. <Box sx={{ flex: 1, overflow: "auto", p: 1.5 }}>
  510. {lane.shops.length === 0 && districtSections.length === 0 ? (
  511. <Stack spacing={1.5}>
  512. <Box
  513. sx={{
  514. minHeight: 120,
  515. display: "flex",
  516. flexDirection: "column",
  517. alignItems: "center",
  518. justifyContent: "center",
  519. border: 2,
  520. borderStyle: "dashed",
  521. borderColor: "divider",
  522. borderRadius: 2,
  523. color: "text.secondary",
  524. bgcolor: alpha("#fff", 0.6),
  525. }}
  526. >
  527. <Move size={20} strokeWidth={1.25} />
  528. <Typography variant="caption" sx={{ mt: 0.5, textAlign: "center" }}>
  529. {dropHint}
  530. </Typography>
  531. </Box>
  532. <Button
  533. size="small"
  534. variant="text"
  535. startIcon={<Plus size={14} />}
  536. onClick={onAddDistrict}
  537. sx={{ alignSelf: "flex-start" }}
  538. >
  539. {addDistrictLabel}
  540. </Button>
  541. </Stack>
  542. ) : (
  543. <Stack spacing={2}>
  544. {districtSections.map(({ district, shops, isPendingEmpty }) => (
  545. <Box
  546. key={`${lane.id}::${district}`}
  547. onDragOver={(e) => {
  548. e.preventDefault();
  549. onDragOverColumn(e);
  550. }}
  551. onDrop={(e) => onDropDistrict(e, district)}
  552. >
  553. <DistrictSectionHeader
  554. district={district}
  555. count={shops.length}
  556. isPendingEmpty={isPendingEmpty}
  557. removeAriaLabel={removeEmptyDistrictAriaLabel}
  558. onRemove={
  559. isPendingEmpty
  560. ? () => onRemoveEmptyDistrict(district)
  561. : undefined
  562. }
  563. />
  564. <Stack spacing={1}>
  565. {shops.map((shop) => (
  566. <PlannedShopCard
  567. key={shop.truckRowId}
  568. shop={shop}
  569. laneId={lane.id}
  570. isMoved={shop.originalLaneId !== lane.id}
  571. isHovered={hoveredShopId === shop.truckRowId}
  572. hoveredPosition={hoveredPosition}
  573. movedBadgeLabel={movedBadgeLabel}
  574. seqLabel={seqLabel}
  575. seqEditAriaLabel={seqEditAriaLabel}
  576. onDragStartShop={onDragStartShop}
  577. onDragOverShop={onDragOverShop}
  578. onDropShop={onDropShop}
  579. onRevertShop={onRevertShop}
  580. onDeleteShop={onDeleteShop}
  581. removeFromLaneTooltip={removeFromLaneTooltip}
  582. onStartSeqEdit={onStartSeqEdit}
  583. />
  584. ))}
  585. </Stack>
  586. </Box>
  587. ))}
  588. <Button
  589. size="small"
  590. variant="text"
  591. startIcon={<Plus size={14} />}
  592. onClick={onAddDistrict}
  593. sx={{ alignSelf: "flex-start" }}
  594. >
  595. {addDistrictLabel}
  596. </Button>
  597. </Stack>
  598. )}
  599. </Box>
  600. <Box
  601. sx={{
  602. px: 1.5,
  603. py: 1,
  604. borderTop: 1,
  605. borderColor: "divider",
  606. bgcolor: "background.paper",
  607. flexShrink: 0,
  608. }}
  609. >
  610. <Button
  611. fullWidth
  612. size="small"
  613. variant="outlined"
  614. startIcon={<Plus size={14} />}
  615. onClick={onAddShop}
  616. sx={{ textTransform: "none", fontWeight: 700 }}
  617. >
  618. {addShopLabel}
  619. </Button>
  620. </Box>
  621. </Paper>
  622. );
  623. });
  624. const ScheduleDragWorkspacePane: React.FC<Props> = ({
  625. lanes,
  626. leftLaneId,
  627. rightLaneId,
  628. plannedLanes,
  629. pendingEmptyDistrictsByLane,
  630. onLeftLaneChange,
  631. onRightLaneChange,
  632. onMoveShop,
  633. onRevertShop,
  634. onSetLoadingSequence,
  635. onAddEmptyDistrict,
  636. onRemoveEmptyDistrict,
  637. onDeleteShop,
  638. onAddShop,
  639. onSetLaneDepartureTime,
  640. }) => {
  641. const { t } = useTranslation("shop");
  642. const draggedRef = useRef<{ shopId: number; fromLaneId: string } | null>(
  643. null,
  644. );
  645. const [isOverColumnId, setIsOverColumnId] = useState<string | null>(null);
  646. const [hoveredShopId, setHoveredShopId] = useState<number | null>(null);
  647. const [hoveredPosition, setHoveredPosition] = useState<"before" | "after">(
  648. "after",
  649. );
  650. const [seqEditTarget, setSeqEditTarget] = useState<SeqEditTarget | null>(
  651. null,
  652. );
  653. const [districtAddLaneId, setDistrictAddLaneId] = useState<string | null>(
  654. null,
  655. );
  656. const [districtAddDraft, setDistrictAddDraft] = useState("");
  657. const [districtAddError, setDistrictAddError] = useState<string | null>(
  658. null,
  659. );
  660. const [departureEditTarget, setDepartureEditTarget] = useState<{
  661. laneId: string;
  662. draft: string;
  663. } | null>(null);
  664. const [departureEditError, setDepartureEditError] = useState<string | null>(
  665. null,
  666. );
  667. const applyDepartureEdit = useCallback(() => {
  668. if (!departureEditTarget) return;
  669. const ok = onSetLaneDepartureTime(
  670. departureEditTarget.laneId,
  671. departureEditTarget.draft,
  672. );
  673. if (!ok) {
  674. setDepartureEditError(t("route_err_departure"));
  675. return;
  676. }
  677. setDepartureEditError(null);
  678. setDepartureEditTarget(null);
  679. }, [departureEditTarget, onSetLaneDepartureTime, t]);
  680. const leftLane = plannedLanes.find((l) => l.id === leftLaneId);
  681. const rightLane = plannedLanes.find((l) => l.id === rightLaneId);
  682. const clearDrag = useCallback(() => {
  683. draggedRef.current = null;
  684. setIsOverColumnId(null);
  685. setHoveredShopId(null);
  686. }, []);
  687. const handleDragStart = (shopId: number, fromLaneId: string) => {
  688. draggedRef.current = { shopId, fromLaneId };
  689. };
  690. const handleDragOverShop = (e: React.DragEvent, shopId: number) => {
  691. e.preventDefault();
  692. e.stopPropagation();
  693. const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
  694. const offset = e.clientY - rect.top;
  695. setHoveredShopId(shopId);
  696. setHoveredPosition(offset < rect.height / 2 ? "before" : "after");
  697. };
  698. const handleDropOnShop = (
  699. e: React.DragEvent,
  700. targetLaneId: string,
  701. targetShopId: number,
  702. ) => {
  703. e.preventDefault();
  704. e.stopPropagation();
  705. const dragged = draggedRef.current;
  706. if (!dragged) return;
  707. const lane = plannedLanes.find((l) => l.id === targetLaneId);
  708. const flat = lane?.shops ?? [];
  709. let beforeShopId: number | null;
  710. if (hoveredShopId === targetShopId && hoveredPosition === "before") {
  711. beforeShopId = targetShopId;
  712. } else if (hoveredShopId === targetShopId && hoveredPosition === "after") {
  713. const idx = flat.findIndex((s) => s.truckRowId === targetShopId);
  714. beforeShopId =
  715. idx >= 0 && idx < flat.length - 1
  716. ? (flat[idx + 1]?.truckRowId ?? null)
  717. : null;
  718. } else {
  719. beforeShopId = targetShopId;
  720. }
  721. onMoveShop(
  722. dragged.shopId,
  723. dragged.fromLaneId,
  724. targetLaneId,
  725. beforeShopId,
  726. );
  727. clearDrag();
  728. };
  729. const handleDropOnColumn = (e: React.DragEvent, targetLaneId: string) => {
  730. e.preventDefault();
  731. const dragged = draggedRef.current;
  732. if (!dragged) return;
  733. const beforeShopId = getBeforeShopIdByPointer(targetLaneId, e.clientY);
  734. onMoveShop(
  735. dragged.shopId,
  736. dragged.fromLaneId,
  737. targetLaneId,
  738. beforeShopId,
  739. );
  740. clearDrag();
  741. };
  742. const handleDropOnDistrict = useCallback(
  743. (e: React.DragEvent, targetLaneId: string, district: string) => {
  744. e.preventDefault();
  745. e.stopPropagation();
  746. const dragged = draggedRef.current;
  747. if (!dragged) return;
  748. onMoveShop(
  749. dragged.shopId,
  750. dragged.fromLaneId,
  751. targetLaneId,
  752. null,
  753. district,
  754. );
  755. clearDrag();
  756. },
  757. [onMoveShop, clearDrag],
  758. );
  759. const renderLaneSelectors = (
  760. <Box
  761. sx={{
  762. p: 1,
  763. bgcolor: "grey.100",
  764. borderRadius: 2,
  765. flexShrink: 0,
  766. display: "grid",
  767. gridTemplateColumns: "1fr 1fr",
  768. columnGap: 1.5,
  769. alignItems: "center",
  770. }}
  771. >
  772. <Stack
  773. direction="row"
  774. spacing={0.75}
  775. alignItems="center"
  776. sx={{ minWidth: 0 }}
  777. >
  778. <Typography variant="caption" sx={laneSelectorLabelSx}>
  779. {t("schedule_lane_left")}
  780. </Typography>
  781. <Box
  782. component="select"
  783. value={leftLaneId}
  784. onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
  785. onLeftLaneChange(e.target.value)
  786. }
  787. sx={nativeSelectSx}
  788. >
  789. {lanes.map((l) => (
  790. <option key={l.id} value={l.id} disabled={l.id === rightLaneId}>
  791. {l.label}
  792. </option>
  793. ))}
  794. </Box>
  795. </Stack>
  796. <Stack
  797. direction="row"
  798. spacing={0.75}
  799. alignItems="center"
  800. sx={{ minWidth: 0 }}
  801. >
  802. <Typography variant="caption" sx={laneSelectorLabelSx}>
  803. {t("schedule_lane_right")}
  804. </Typography>
  805. <Box
  806. component="select"
  807. value={rightLaneId}
  808. onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
  809. onRightLaneChange(e.target.value)
  810. }
  811. sx={nativeSelectSx}
  812. >
  813. {lanes.map((l) => (
  814. <option key={l.id} value={l.id} disabled={l.id === leftLaneId}>
  815. {l.label}
  816. </option>
  817. ))}
  818. </Box>
  819. </Stack>
  820. </Box>
  821. );
  822. const seqLabel = (seq: number) =>
  823. t("schedule_drag_seq", { seq: String(seq) });
  824. const handleStartSeqEdit = useCallback(
  825. (laneId: string, shopId: number, current: number) => {
  826. setSeqEditTarget({
  827. laneId,
  828. shopId,
  829. draft: String(current),
  830. });
  831. },
  832. [],
  833. );
  834. const handleCommitSeqEdit = useCallback(() => {
  835. setSeqEditTarget((prev) => {
  836. if (!prev) return null;
  837. const n = Number(prev.draft);
  838. const seq = Number.isFinite(n) ? Math.max(0, Math.trunc(n)) : 0;
  839. onSetLoadingSequence(prev.laneId, prev.shopId, seq);
  840. return null;
  841. });
  842. }, [onSetLoadingSequence]);
  843. const handleCancelSeqEdit = useCallback(() => {
  844. setSeqEditTarget(null);
  845. }, []);
  846. const openDistrictAdd = useCallback((laneId: string) => {
  847. setDistrictAddLaneId(laneId);
  848. setDistrictAddDraft("");
  849. setDistrictAddError(null);
  850. }, []);
  851. const closeDistrictAdd = useCallback(() => {
  852. setDistrictAddLaneId(null);
  853. setDistrictAddDraft("");
  854. setDistrictAddError(null);
  855. }, []);
  856. const applyDistrictAdd = useCallback(() => {
  857. if (!districtAddLaneId) return;
  858. const lane = plannedLanes.find((l) => l.id === districtAddLaneId);
  859. if (!lane) {
  860. closeDistrictAdd();
  861. return;
  862. }
  863. const trimmed = districtAddDraft.trim();
  864. if (!trimmed) {
  865. setDistrictAddError(t("district_err_name"));
  866. return;
  867. }
  868. if (trimmed === "未分類") {
  869. setDistrictAddError(t("district_err_reserved"));
  870. return;
  871. }
  872. const pendingExtra = pendingEmptyDistrictsByLane[districtAddLaneId] ?? [];
  873. if (districtDisplayExistsInShops(lane.shops, pendingExtra, trimmed)) {
  874. setDistrictAddError(t("district_err_exists"));
  875. return;
  876. }
  877. onAddEmptyDistrict(districtAddLaneId, trimmed);
  878. closeDistrictAdd();
  879. }, [
  880. closeDistrictAdd,
  881. districtAddDraft,
  882. districtAddLaneId,
  883. onAddEmptyDistrict,
  884. pendingEmptyDistrictsByLane,
  885. plannedLanes,
  886. t,
  887. ]);
  888. if (!leftLane || !rightLane) {
  889. return (
  890. <Stack spacing={1.5} sx={{ flex: 1, minHeight: 0 }}>
  891. {renderLaneSelectors}
  892. <Typography variant="body2" color="text.secondary" sx={{ p: 2 }}>
  893. {t("schedule_no_shops")}
  894. </Typography>
  895. </Stack>
  896. );
  897. }
  898. const columnProps = (lane: PlannedLane) => ({
  899. lane,
  900. pendingEmptyDistricts: pendingEmptyDistrictsByLane[lane.id] ?? [],
  901. isOver: isOverColumnId === lane.id,
  902. onDragOverColumn: (e: React.DragEvent) => {
  903. e.preventDefault();
  904. setIsOverColumnId(lane.id);
  905. },
  906. onDragLeaveColumn: () => setIsOverColumnId(null),
  907. onDropColumn: (e: React.DragEvent) => handleDropOnColumn(e, lane.id),
  908. onDropDistrict: (e: React.DragEvent, district: string) =>
  909. handleDropOnDistrict(e, lane.id, district),
  910. onDragStartShop: (shopId: number) => handleDragStart(shopId, lane.id),
  911. onDragOverShop: handleDragOverShop,
  912. onDropShop: (e: React.DragEvent, shopId: number) =>
  913. handleDropOnShop(e, lane.id, shopId),
  914. onRevertShop: (shopId: number) => onRevertShop(shopId, lane.id),
  915. onDeleteShop,
  916. removeFromLaneTooltip: t("tooltip_removeFromLane"),
  917. onAddDistrict: () => openDistrictAdd(lane.id),
  918. onRemoveEmptyDistrict: (district: string) =>
  919. onRemoveEmptyDistrict(lane.id, district),
  920. hoveredShopId,
  921. hoveredPosition,
  922. movedBadgeLabel: t("schedule_moved_badge"),
  923. seqLabel,
  924. seqEditAriaLabel: t("schedule_seq_edit_btn"),
  925. removeEmptyDistrictAriaLabel: t("aria_removeEmptyDistrict"),
  926. onStartSeqEdit: handleStartSeqEdit,
  927. dropHint: t("schedule_drop_hint"),
  928. addDistrictLabel: t("btn_addDistrict"),
  929. addShopLabel: t("btn_addShopToLane"),
  930. onAddShop: () => onAddShop(lane.id),
  931. departureLabel: t("Departure"),
  932. departureEditAriaLabel: t("departureTooltipEditSave"),
  933. onStartDepartureEdit: (laneId: string, current: string) => {
  934. setDepartureEditError(null);
  935. setDepartureEditTarget({
  936. laneId,
  937. draft: toTimeInputValue(current),
  938. });
  939. },
  940. });
  941. return (
  942. <>
  943. <Stack spacing={1.5} sx={{ flex: 1, minHeight: 0 }}>
  944. {renderLaneSelectors}
  945. <Box
  946. sx={{
  947. flex: 1,
  948. minHeight: 0,
  949. display: "grid",
  950. gridTemplateColumns: "1fr 1fr",
  951. gap: 1.5,
  952. }}
  953. >
  954. <LaneColumn {...columnProps(leftLane)} />
  955. <LaneColumn {...columnProps(rightLane)} />
  956. </Box>
  957. </Stack>
  958. <Dialog
  959. open={departureEditTarget != null}
  960. onClose={() => {
  961. setDepartureEditError(null);
  962. setDepartureEditTarget(null);
  963. }}
  964. maxWidth="xs"
  965. fullWidth
  966. >
  967. <DialogTitle>{t("departureDialog_title")}</DialogTitle>
  968. <DialogContent>
  969. <TextField
  970. margin="dense"
  971. fullWidth
  972. autoFocus
  973. type="time"
  974. label={t("seq_edit_departureLabel")}
  975. value={departureEditTarget?.draft ?? ""}
  976. error={departureEditError != null}
  977. helperText={departureEditError ?? undefined}
  978. onChange={(e) => {
  979. setDepartureEditError(null);
  980. setDepartureEditTarget((prev) =>
  981. prev ? { ...prev, draft: e.target.value } : prev,
  982. );
  983. }}
  984. onKeyDown={(e) => {
  985. if (e.key === "Enter" && departureEditTarget) {
  986. e.preventDefault();
  987. applyDepartureEdit();
  988. }
  989. }}
  990. InputLabelProps={{ shrink: true }}
  991. sx={{ mt: 1 }}
  992. />
  993. <Typography
  994. variant="caption"
  995. color="text.secondary"
  996. sx={{ mt: 1, display: "block" }}
  997. >
  998. {t("departureDialog_hint")}
  999. </Typography>
  1000. </DialogContent>
  1001. <DialogActions>
  1002. <Button
  1003. onClick={() => {
  1004. setDepartureEditError(null);
  1005. setDepartureEditTarget(null);
  1006. }}
  1007. >
  1008. {t("cancel")}
  1009. </Button>
  1010. <Button variant="contained" onClick={applyDepartureEdit}>
  1011. {t("btn_apply")}
  1012. </Button>
  1013. </DialogActions>
  1014. </Dialog>
  1015. <Dialog
  1016. open={seqEditTarget != null}
  1017. onClose={handleCancelSeqEdit}
  1018. maxWidth="xs"
  1019. fullWidth
  1020. >
  1021. <DialogTitle>{t("seqDialog_title")}</DialogTitle>
  1022. <DialogContent>
  1023. <TextField
  1024. margin="dense"
  1025. fullWidth
  1026. autoFocus
  1027. type="number"
  1028. label={t("seq_edit_seqLabel")}
  1029. value={seqEditTarget?.draft ?? ""}
  1030. onChange={(e) =>
  1031. setSeqEditTarget((prev) =>
  1032. prev ? { ...prev, draft: e.target.value } : prev,
  1033. )
  1034. }
  1035. onKeyDown={(e) => {
  1036. if (e.key === "Enter") {
  1037. e.preventDefault();
  1038. handleCommitSeqEdit();
  1039. }
  1040. }}
  1041. inputProps={{ step: 1, min: 0 }}
  1042. sx={{ mt: 1 }}
  1043. />
  1044. <Typography
  1045. variant="caption"
  1046. color="text.secondary"
  1047. sx={{ mt: 1, display: "block" }}
  1048. >
  1049. {t("schedule_seq_dialog_hint")}
  1050. </Typography>
  1051. </DialogContent>
  1052. <DialogActions>
  1053. <Button onClick={handleCancelSeqEdit}>{t("cancel")}</Button>
  1054. <Button variant="contained" onClick={handleCommitSeqEdit}>
  1055. {t("filter_apply")}
  1056. </Button>
  1057. </DialogActions>
  1058. </Dialog>
  1059. <Dialog
  1060. open={districtAddLaneId != null}
  1061. onClose={closeDistrictAdd}
  1062. maxWidth="xs"
  1063. fullWidth
  1064. >
  1065. <DialogTitle>{t("district_dialog_add")}</DialogTitle>
  1066. <DialogContent>
  1067. <TextField
  1068. margin="dense"
  1069. fullWidth
  1070. autoFocus
  1071. label={t("district_dialog_add")}
  1072. value={districtAddDraft}
  1073. onChange={(e) => {
  1074. setDistrictAddDraft(e.target.value);
  1075. setDistrictAddError(null);
  1076. }}
  1077. error={Boolean(districtAddError)}
  1078. helperText={districtAddError ?? undefined}
  1079. onKeyDown={(e) => {
  1080. if (e.key === "Enter") {
  1081. e.preventDefault();
  1082. applyDistrictAdd();
  1083. }
  1084. }}
  1085. sx={{ mt: 1 }}
  1086. />
  1087. </DialogContent>
  1088. <DialogActions>
  1089. <Button onClick={closeDistrictAdd}>{t("cancel")}</Button>
  1090. <Button variant="contained" onClick={applyDistrictAdd}>
  1091. {t("filter_apply")}
  1092. </Button>
  1093. </DialogActions>
  1094. </Dialog>
  1095. </>
  1096. );
  1097. };
  1098. export default memo(ScheduleDragWorkspacePane);