"use client"; import React, { memo, useCallback, useRef, useState } from "react"; import { Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Paper, Stack, TextField, Tooltip, Typography, } from "@mui/material"; import { alpha } from "@mui/material/styles"; import { MapPin, Move, Pencil, Plus, Trash2, Undo2, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { PlannedLane, PlannedShop, ScheduleDragWorkspaceState, } from "@/components/Shop/scheduleDragWorkspace"; import type { ScheduleLaneOption } from "@/components/Shop/ScheduleChangeModal"; import { buildLaneDistrictSections, districtDisplayExistsInShops, formatShopCardSubtitle, } from "@/components/Shop/routeBoardDisplayOrder"; type Props = { lanes: ScheduleLaneOption[]; leftLaneId: string; rightLaneId: string; plannedLanes: ScheduleDragWorkspaceState; pendingEmptyDistrictsByLane: Record; onLeftLaneChange: (laneId: string) => void; onRightLaneChange: (laneId: string) => void; onMoveShop: ( shopId: number, fromLaneId: string, toLaneId: string, beforeShopId: number | null, targetDistrict?: string | null, ) => void; onRevertShop: (shopId: number, currentLaneId: string) => void; onSetLoadingSequence: ( laneId: string, shopId: number, loadingSequence: number, ) => void; onAddEmptyDistrict: (laneId: string, display: string) => void; onRemoveEmptyDistrict: (laneId: string, display: string) => void; onDeleteShop: (shopId: number, laneId: string) => void; onAddShop: (laneId: string) => void; onSetLaneDepartureTime: (laneId: string, departureTime: string) => boolean; }; function toTimeInputValue(t: string | undefined): string { const s = String(t ?? "").trim(); if (!s) return "00:00"; const m = s.match(/^(\d{1,2}):(\d{2})(?::\d{2})?/); if (m) return `${m[1].padStart(2, "0")}:${m[2]}`; return "00:00"; } function formatDepartureChip(t: string): string { const m = String(t ?? "").trim().match(/^(\d{1,2}):(\d{2})/); return m ? `${m[1].padStart(2, "0")}:${m[2]}` : "-"; } function getBeforeShopIdByPointer( laneId: string, clientY: number, ): number | null { const laneEl = document.querySelector( `[data-schedule-lane-id="${CSS.escape(laneId)}"]`, ); if (!laneEl) return null; const cards = Array.from( laneEl.querySelectorAll("[data-schedule-shop-id]"), ); for (const cardEl of cards) { const rect = cardEl.getBoundingClientRect(); const midY = rect.top + rect.height / 2; if (clientY < midY) { const idStr = cardEl.getAttribute("data-schedule-shop-id"); const id = idStr ? Number(idStr) : NaN; return Number.isFinite(id) ? id : null; } } return null; } const nativeSelectSx = { fontSize: "0.75rem", fontWeight: 600, py: 0.5, px: 1, borderRadius: 1, border: "1px solid", borderColor: "divider", bgcolor: "background.paper", flex: 1, minWidth: 0, width: "100%", maxWidth: "100%", } as const; const laneSelectorLabelSx = { fontWeight: 700, color: "text.secondary", flexShrink: 0, minWidth: "4.5em", whiteSpace: "nowrap", } as const; type SeqEditTarget = { laneId: string; shopId: number; draft: string; }; const LoadingSequenceDisplay = memo(function LoadingSequenceDisplay({ loadingSequence, seqLabel, editAriaLabel, onEdit, }: { loadingSequence: number; seqLabel: (seq: number) => string; editAriaLabel: string; onEdit: () => void; }) { return ( e.stopPropagation()} onClick={(e) => { e.stopPropagation(); onEdit(); }} sx={{ width: 22, height: 22, color: "primary.main", flexShrink: 0, }} > ); }); const DistrictSectionHeader = memo(function DistrictSectionHeader({ district, count, isPendingEmpty, editAriaLabel, removeAriaLabel, onRemove, }: { district: string; count: number; isPendingEmpty?: boolean; editAriaLabel?: string; removeAriaLabel?: string; onRemove?: () => void; }) { return ( {district} {count} {isPendingEmpty && onRemove && ( e.stopPropagation()} onClick={(e) => { e.stopPropagation(); onRemove(); }} aria-label={removeAriaLabel ?? editAriaLabel} > )} ); }); const PlannedShopCard = memo(function PlannedShopCard({ shop, laneId, isMoved, isHovered, hoveredPosition, movedBadgeLabel, seqLabel, seqEditAriaLabel, onDragStartShop, onDragOverShop, onDropShop, onRevertShop, onDeleteShop, removeFromLaneTooltip, onStartSeqEdit, }: { shop: PlannedShop; laneId: string; isMoved: boolean; isHovered: boolean; hoveredPosition: "before" | "after"; movedBadgeLabel: string; seqLabel: (seq: number) => string; seqEditAriaLabel: string; removeFromLaneTooltip: string; onDragStartShop: (shopId: number) => void; onDragOverShop: (e: React.DragEvent, shopId: number) => void; onDropShop: (e: React.DragEvent, shopId: number) => void; onRevertShop: (shopId: number) => void; onDeleteShop: (shopId: number, laneId: string) => void; onStartSeqEdit: (laneId: string, shopId: number, current: number) => void; }) { return ( {isHovered && hoveredPosition === "before" && ( )} onDragStartShop(shop.truckRowId)} onDragOver={(e) => onDragOverShop(e, shop.truckRowId)} onDrop={(e) => onDropShop(e, shop.truckRowId)} sx={{ p: 1.25, cursor: "grab", bgcolor: isMoved ? alpha("#ed6c02", 0.06) : "background.paper", borderColor: isHovered ? "primary.main" : isMoved ? "warning.light" : "divider", borderStyle: isHovered ? "dashed" : "solid", "&:active": { cursor: "grabbing" }, }} > {shop.displayName} {formatShopCardSubtitle(shop)} {isMoved && ( e.stopPropagation()} onClick={(e) => { e.stopPropagation(); onRevertShop(shop.truckRowId); }} sx={{ color: "warning.dark" }} aria-label="revert" > )} e.stopPropagation()} onClick={(e) => { e.stopPropagation(); onDeleteShop(shop.truckRowId, laneId); }} sx={{ color: "error.main" }} aria-label={removeFromLaneTooltip} > onStartSeqEdit(laneId, shop.truckRowId, shop.loadingSequence) } /> {isMoved && ( )} {isHovered && hoveredPosition === "after" && ( )} ); }); const LaneColumn = memo(function LaneColumn({ lane, pendingEmptyDistricts, isOver, onDragOverColumn, onDragLeaveColumn, onDropColumn, onDropDistrict, onDragStartShop, onDragOverShop, onDropShop, onRevertShop, onDeleteShop, onAddDistrict, onRemoveEmptyDistrict, hoveredShopId, hoveredPosition, movedBadgeLabel, seqLabel, seqEditAriaLabel, removeFromLaneTooltip, removeEmptyDistrictAriaLabel, onStartSeqEdit, dropHint, addDistrictLabel, addShopLabel, onAddShop, departureLabel, departureEditAriaLabel, onStartDepartureEdit, }: { lane: PlannedLane; pendingEmptyDistricts: string[]; isOver: boolean; onDragOverColumn: (e: React.DragEvent) => void; onDragLeaveColumn: () => void; onDropColumn: (e: React.DragEvent) => void; onDropDistrict: (e: React.DragEvent, district: string) => void; onDragStartShop: (shopId: number) => void; onDragOverShop: (e: React.DragEvent, shopId: number) => void; onDropShop: (e: React.DragEvent, shopId: number) => void; onRevertShop: (shopId: number) => void; onDeleteShop: (shopId: number, laneId: string) => void; onAddDistrict: () => void; onRemoveEmptyDistrict: (district: string) => void; removeFromLaneTooltip: string; hoveredShopId: number | null; hoveredPosition: "before" | "after"; movedBadgeLabel: string; seqLabel: (seq: number) => string; seqEditAriaLabel: string; removeEmptyDistrictAriaLabel: string; onStartSeqEdit: (laneId: string, shopId: number, current: number) => void; dropHint: string; addDistrictLabel: string; addShopLabel: string; onAddShop: () => void; departureLabel: string; departureEditAriaLabel: string; onStartDepartureEdit: (laneId: string, current: string) => void; }) { const districtSections = buildLaneDistrictSections( lane.shops, pendingEmptyDistricts, ); return ( {lane.label} {departureLabel}: {formatDepartureChip(lane.departureTime)} { e.stopPropagation(); onStartDepartureEdit(lane.id, lane.departureTime); }} aria-label={departureEditAriaLabel} > {lane.shops.length === 0 && districtSections.length === 0 ? ( {dropHint} ) : ( {districtSections.map(({ district, shops, isPendingEmpty }) => ( { e.preventDefault(); onDragOverColumn(e); }} onDrop={(e) => onDropDistrict(e, district)} > onRemoveEmptyDistrict(district) : undefined } /> {shops.map((shop) => ( ))} ))} )} ); }); const ScheduleDragWorkspacePane: React.FC = ({ lanes, leftLaneId, rightLaneId, plannedLanes, pendingEmptyDistrictsByLane, onLeftLaneChange, onRightLaneChange, onMoveShop, onRevertShop, onSetLoadingSequence, onAddEmptyDistrict, onRemoveEmptyDistrict, onDeleteShop, onAddShop, onSetLaneDepartureTime, }) => { const { t } = useTranslation("shop"); const draggedRef = useRef<{ shopId: number; fromLaneId: string } | null>( null, ); const [isOverColumnId, setIsOverColumnId] = useState(null); const [hoveredShopId, setHoveredShopId] = useState(null); const [hoveredPosition, setHoveredPosition] = useState<"before" | "after">( "after", ); const [seqEditTarget, setSeqEditTarget] = useState( null, ); const [districtAddLaneId, setDistrictAddLaneId] = useState( null, ); const [districtAddDraft, setDistrictAddDraft] = useState(""); const [districtAddError, setDistrictAddError] = useState( null, ); const [departureEditTarget, setDepartureEditTarget] = useState<{ laneId: string; draft: string; } | null>(null); const [departureEditError, setDepartureEditError] = useState( null, ); const applyDepartureEdit = useCallback(() => { if (!departureEditTarget) return; const ok = onSetLaneDepartureTime( departureEditTarget.laneId, departureEditTarget.draft, ); if (!ok) { setDepartureEditError(t("route_err_departure")); return; } setDepartureEditError(null); setDepartureEditTarget(null); }, [departureEditTarget, onSetLaneDepartureTime, t]); const leftLane = plannedLanes.find((l) => l.id === leftLaneId); const rightLane = plannedLanes.find((l) => l.id === rightLaneId); const clearDrag = useCallback(() => { draggedRef.current = null; setIsOverColumnId(null); setHoveredShopId(null); }, []); const handleDragStart = (shopId: number, fromLaneId: string) => { draggedRef.current = { shopId, fromLaneId }; }; const handleDragOverShop = (e: React.DragEvent, shopId: number) => { e.preventDefault(); e.stopPropagation(); const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const offset = e.clientY - rect.top; setHoveredShopId(shopId); setHoveredPosition(offset < rect.height / 2 ? "before" : "after"); }; const handleDropOnShop = ( e: React.DragEvent, targetLaneId: string, targetShopId: number, ) => { e.preventDefault(); e.stopPropagation(); const dragged = draggedRef.current; if (!dragged) return; const lane = plannedLanes.find((l) => l.id === targetLaneId); const flat = lane?.shops ?? []; let beforeShopId: number | null; if (hoveredShopId === targetShopId && hoveredPosition === "before") { beforeShopId = targetShopId; } else if (hoveredShopId === targetShopId && hoveredPosition === "after") { const idx = flat.findIndex((s) => s.truckRowId === targetShopId); beforeShopId = idx >= 0 && idx < flat.length - 1 ? (flat[idx + 1]?.truckRowId ?? null) : null; } else { beforeShopId = targetShopId; } onMoveShop( dragged.shopId, dragged.fromLaneId, targetLaneId, beforeShopId, ); clearDrag(); }; const handleDropOnColumn = (e: React.DragEvent, targetLaneId: string) => { e.preventDefault(); const dragged = draggedRef.current; if (!dragged) return; const beforeShopId = getBeforeShopIdByPointer(targetLaneId, e.clientY); onMoveShop( dragged.shopId, dragged.fromLaneId, targetLaneId, beforeShopId, ); clearDrag(); }; const handleDropOnDistrict = useCallback( (e: React.DragEvent, targetLaneId: string, district: string) => { e.preventDefault(); e.stopPropagation(); const dragged = draggedRef.current; if (!dragged) return; onMoveShop( dragged.shopId, dragged.fromLaneId, targetLaneId, null, district, ); clearDrag(); }, [onMoveShop, clearDrag], ); const renderLaneSelectors = ( {t("schedule_lane_left")} ) => onLeftLaneChange(e.target.value) } sx={nativeSelectSx} > {lanes.map((l) => ( ))} {t("schedule_lane_right")} ) => onRightLaneChange(e.target.value) } sx={nativeSelectSx} > {lanes.map((l) => ( ))} ); const seqLabel = (seq: number) => t("schedule_drag_seq", { seq: String(seq) }); const handleStartSeqEdit = useCallback( (laneId: string, shopId: number, current: number) => { setSeqEditTarget({ laneId, shopId, draft: String(current), }); }, [], ); const handleCommitSeqEdit = useCallback(() => { setSeqEditTarget((prev) => { if (!prev) return null; const n = Number(prev.draft); const seq = Number.isFinite(n) ? Math.max(0, Math.trunc(n)) : 0; onSetLoadingSequence(prev.laneId, prev.shopId, seq); return null; }); }, [onSetLoadingSequence]); const handleCancelSeqEdit = useCallback(() => { setSeqEditTarget(null); }, []); const openDistrictAdd = useCallback((laneId: string) => { setDistrictAddLaneId(laneId); setDistrictAddDraft(""); setDistrictAddError(null); }, []); const closeDistrictAdd = useCallback(() => { setDistrictAddLaneId(null); setDistrictAddDraft(""); setDistrictAddError(null); }, []); const applyDistrictAdd = useCallback(() => { if (!districtAddLaneId) return; const lane = plannedLanes.find((l) => l.id === districtAddLaneId); if (!lane) { closeDistrictAdd(); return; } const trimmed = districtAddDraft.trim(); if (!trimmed) { setDistrictAddError(t("district_err_name")); return; } if (trimmed === "未分類") { setDistrictAddError(t("district_err_reserved")); return; } const pendingExtra = pendingEmptyDistrictsByLane[districtAddLaneId] ?? []; if (districtDisplayExistsInShops(lane.shops, pendingExtra, trimmed)) { setDistrictAddError(t("district_err_exists")); return; } onAddEmptyDistrict(districtAddLaneId, trimmed); closeDistrictAdd(); }, [ closeDistrictAdd, districtAddDraft, districtAddLaneId, onAddEmptyDistrict, pendingEmptyDistrictsByLane, plannedLanes, t, ]); if (!leftLane || !rightLane) { return ( {renderLaneSelectors} {t("schedule_no_shops")} ); } const columnProps = (lane: PlannedLane) => ({ lane, pendingEmptyDistricts: pendingEmptyDistrictsByLane[lane.id] ?? [], isOver: isOverColumnId === lane.id, onDragOverColumn: (e: React.DragEvent) => { e.preventDefault(); setIsOverColumnId(lane.id); }, onDragLeaveColumn: () => setIsOverColumnId(null), onDropColumn: (e: React.DragEvent) => handleDropOnColumn(e, lane.id), onDropDistrict: (e: React.DragEvent, district: string) => handleDropOnDistrict(e, lane.id, district), onDragStartShop: (shopId: number) => handleDragStart(shopId, lane.id), onDragOverShop: handleDragOverShop, onDropShop: (e: React.DragEvent, shopId: number) => handleDropOnShop(e, lane.id, shopId), onRevertShop: (shopId: number) => onRevertShop(shopId, lane.id), onDeleteShop, removeFromLaneTooltip: t("tooltip_removeFromLane"), onAddDistrict: () => openDistrictAdd(lane.id), onRemoveEmptyDistrict: (district: string) => onRemoveEmptyDistrict(lane.id, district), hoveredShopId, hoveredPosition, movedBadgeLabel: t("schedule_moved_badge"), seqLabel, seqEditAriaLabel: t("schedule_seq_edit_btn"), removeEmptyDistrictAriaLabel: t("aria_removeEmptyDistrict"), onStartSeqEdit: handleStartSeqEdit, dropHint: t("schedule_drop_hint"), addDistrictLabel: t("btn_addDistrict"), addShopLabel: t("btn_addShopToLane"), onAddShop: () => onAddShop(lane.id), departureLabel: t("Departure"), departureEditAriaLabel: t("departureTooltipEditSave"), onStartDepartureEdit: (laneId: string, current: string) => { setDepartureEditError(null); setDepartureEditTarget({ laneId, draft: toTimeInputValue(current), }); }, }); return ( <> {renderLaneSelectors} { setDepartureEditError(null); setDepartureEditTarget(null); }} maxWidth="xs" fullWidth > {t("departureDialog_title")} { setDepartureEditError(null); setDepartureEditTarget((prev) => prev ? { ...prev, draft: e.target.value } : prev, ); }} onKeyDown={(e) => { if (e.key === "Enter" && departureEditTarget) { e.preventDefault(); applyDepartureEdit(); } }} InputLabelProps={{ shrink: true }} sx={{ mt: 1 }} /> {t("departureDialog_hint")} {t("seqDialog_title")} setSeqEditTarget((prev) => prev ? { ...prev, draft: e.target.value } : prev, ) } onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleCommitSeqEdit(); } }} inputProps={{ step: 1, min: 0 }} sx={{ mt: 1 }} /> {t("schedule_seq_dialog_hint")} {t("district_dialog_add")} { setDistrictAddDraft(e.target.value); setDistrictAddError(null); }} error={Boolean(districtAddError)} helperText={districtAddError ?? undefined} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); applyDistrictAdd(); } }} sx={{ mt: 1 }} /> ); }; export default memo(ScheduleDragWorkspacePane);