|
- "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<string, string[]>;
- 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<HTMLElement>(
- `[data-schedule-lane-id="${CSS.escape(laneId)}"]`,
- );
- if (!laneEl) return null;
- const cards = Array.from(
- laneEl.querySelectorAll<HTMLElement>("[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 (
- <Stack direction="row" spacing={0.25} alignItems="center">
- <Chip
- size="small"
- label={seqLabel(loadingSequence)}
- sx={{
- height: 22,
- fontSize: "0.65rem",
- fontWeight: 800,
- bgcolor: "primary.50",
- color: "primary.dark",
- }}
- />
- <IconButton
- size="small"
- aria-label={editAriaLabel}
- onMouseDown={(e) => e.stopPropagation()}
- onClick={(e) => {
- e.stopPropagation();
- onEdit();
- }}
- sx={{
- width: 22,
- height: 22,
- color: "primary.main",
- flexShrink: 0,
- }}
- >
- <Pencil size={12} />
- </IconButton>
- </Stack>
- );
- });
-
- const DistrictSectionHeader = memo(function DistrictSectionHeader({
- district,
- count,
- isPendingEmpty,
- editAriaLabel,
- removeAriaLabel,
- onRemove,
- }: {
- district: string;
- count: number;
- isPendingEmpty?: boolean;
- editAriaLabel?: string;
- removeAriaLabel?: string;
- onRemove?: () => void;
- }) {
- return (
- <Stack
- direction="row"
- spacing={0.5}
- alignItems="center"
- sx={{ px: 0.5, mb: 1, minWidth: 0 }}
- >
- <MapPin size={14} />
- <Typography
- variant="caption"
- sx={{ fontWeight: 900, color: "text.secondary", minWidth: 0 }}
- noWrap
- >
- {district}
- </Typography>
- <Box sx={{ flex: 1, height: 1, bgcolor: "grey.200", mx: 0.5 }} />
- <Typography variant="caption" color="text.secondary">
- {count}
- </Typography>
- {isPendingEmpty && onRemove && (
- <IconButton
- size="small"
- sx={{ p: 0.25 }}
- onMouseDown={(e) => e.stopPropagation()}
- onClick={(e) => {
- e.stopPropagation();
- onRemove();
- }}
- aria-label={removeAriaLabel ?? editAriaLabel}
- >
- <X size={14} />
- </IconButton>
- )}
- </Stack>
- );
- });
-
- 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 (
- <Box sx={{ position: "relative" }}>
- {isHovered && hoveredPosition === "before" && (
- <Box
- sx={{
- height: 4,
- bgcolor: "primary.main",
- borderRadius: 1,
- mb: 0.5,
- }}
- />
- )}
- <Paper
- variant="outlined"
- draggable
- data-schedule-shop-id={shop.truckRowId}
- onDragStart={() => 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" },
- }}
- >
- <Stack
- direction="row"
- justifyContent="space-between"
- alignItems="flex-start"
- spacing={1}
- >
- <Box sx={{ minWidth: 0 }}>
- <Typography variant="subtitle2" sx={{ fontWeight: 900 }} noWrap>
- {shop.displayName}
- </Typography>
- <Typography
- variant="caption"
- color="text.secondary"
- display="block"
- noWrap
- >
- {formatShopCardSubtitle(shop)}
- </Typography>
- </Box>
- <Stack direction="row" spacing={0.25} alignItems="center" sx={{ flexShrink: 0 }}>
- {isMoved && (
- <IconButton
- size="small"
- onMouseDown={(e) => e.stopPropagation()}
- onClick={(e) => {
- e.stopPropagation();
- onRevertShop(shop.truckRowId);
- }}
- sx={{ color: "warning.dark" }}
- aria-label="revert"
- >
- <Undo2 size={14} />
- </IconButton>
- )}
- <Tooltip title={removeFromLaneTooltip}>
- <span>
- <IconButton
- size="small"
- onMouseDown={(e) => e.stopPropagation()}
- onClick={(e) => {
- e.stopPropagation();
- onDeleteShop(shop.truckRowId, laneId);
- }}
- sx={{ color: "error.main" }}
- aria-label={removeFromLaneTooltip}
- >
- <Trash2 size={14} />
- </IconButton>
- </span>
- </Tooltip>
- </Stack>
- </Stack>
- <Stack
- direction="row"
- justifyContent="space-between"
- alignItems="center"
- sx={{ mt: 1 }}
- >
- <LoadingSequenceDisplay
- loadingSequence={shop.loadingSequence}
- seqLabel={seqLabel}
- editAriaLabel={seqEditAriaLabel}
- onEdit={() =>
- onStartSeqEdit(laneId, shop.truckRowId, shop.loadingSequence)
- }
- />
- {isMoved && (
- <Chip
- size="small"
- label={movedBadgeLabel}
- color="warning"
- sx={{ height: 20, fontSize: "0.6rem", fontWeight: 800 }}
- />
- )}
- </Stack>
- </Paper>
- {isHovered && hoveredPosition === "after" && (
- <Box
- sx={{
- height: 4,
- bgcolor: "primary.main",
- borderRadius: 1,
- mt: 0.5,
- }}
- />
- )}
- </Box>
- );
- });
-
- 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 (
- <Paper
- variant="outlined"
- data-schedule-lane-id={lane.id}
- onDragOver={onDragOverColumn}
- onDragLeave={onDragLeaveColumn}
- onDrop={onDropColumn}
- sx={{
- display: "flex",
- flexDirection: "column",
- minHeight: 0,
- height: "100%",
- overflow: "hidden",
- bgcolor: isOver ? alpha("#1976d2", 0.04) : "grey.50",
- borderColor: isOver ? "primary.main" : "divider",
- boxShadow: isOver ? `0 0 0 2px ${alpha("#1976d2", 0.12)}` : undefined,
- transition: "border-color 0.15s, background-color 0.15s",
- }}
- >
- <Box
- sx={{
- px: 1.5,
- py: 1.25,
- borderBottom: 1,
- borderColor: "divider",
- bgcolor: "background.paper",
- display: "flex",
- alignItems: "center",
- justifyContent: "space-between",
- flexShrink: 0,
- }}
- >
- <Box sx={{ minWidth: 0, flex: 1 }}>
- <Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap">
- <Typography variant="body2" sx={{ fontWeight: 800 }} noWrap>
- {lane.label}
- </Typography>
- <Chip
- label={lane.truckLanceCode}
- size="small"
- sx={{ height: 18, fontSize: "0.65rem", fontFamily: "monospace" }}
- />
- </Stack>
- <Stack direction="row" alignItems="center" spacing={0.25} sx={{ mt: 0.35 }}>
- <Typography
- variant="caption"
- sx={{
- fontWeight: 700,
- bgcolor: "grey.100",
- px: 0.75,
- py: 0.2,
- borderRadius: 1,
- whiteSpace: "nowrap",
- }}
- >
- {departureLabel}: {formatDepartureChip(lane.departureTime)}
- </Typography>
- <Tooltip title={departureEditAriaLabel}>
- <IconButton
- size="small"
- onClick={(e) => {
- e.stopPropagation();
- onStartDepartureEdit(lane.id, lane.departureTime);
- }}
- aria-label={departureEditAriaLabel}
- >
- <Pencil size={14} />
- </IconButton>
- </Tooltip>
- </Stack>
- </Box>
- <Chip
- size="small"
- label={lane.shops.length}
- sx={{ fontWeight: 800, flexShrink: 0 }}
- />
- </Box>
-
- <Box sx={{ flex: 1, overflow: "auto", p: 1.5 }}>
- {lane.shops.length === 0 && districtSections.length === 0 ? (
- <Stack spacing={1.5}>
- <Box
- sx={{
- minHeight: 120,
- display: "flex",
- flexDirection: "column",
- alignItems: "center",
- justifyContent: "center",
- border: 2,
- borderStyle: "dashed",
- borderColor: "divider",
- borderRadius: 2,
- color: "text.secondary",
- bgcolor: alpha("#fff", 0.6),
- }}
- >
- <Move size={20} strokeWidth={1.25} />
- <Typography variant="caption" sx={{ mt: 0.5, textAlign: "center" }}>
- {dropHint}
- </Typography>
- </Box>
- <Button
- size="small"
- variant="text"
- startIcon={<Plus size={14} />}
- onClick={onAddDistrict}
- sx={{ alignSelf: "flex-start" }}
- >
- {addDistrictLabel}
- </Button>
- </Stack>
- ) : (
- <Stack spacing={2}>
- {districtSections.map(({ district, shops, isPendingEmpty }) => (
- <Box
- key={`${lane.id}::${district}`}
- onDragOver={(e) => {
- e.preventDefault();
- onDragOverColumn(e);
- }}
- onDrop={(e) => onDropDistrict(e, district)}
- >
- <DistrictSectionHeader
- district={district}
- count={shops.length}
- isPendingEmpty={isPendingEmpty}
- removeAriaLabel={removeEmptyDistrictAriaLabel}
- onRemove={
- isPendingEmpty
- ? () => onRemoveEmptyDistrict(district)
- : undefined
- }
- />
- <Stack spacing={1}>
- {shops.map((shop) => (
- <PlannedShopCard
- key={shop.truckRowId}
- shop={shop}
- laneId={lane.id}
- isMoved={shop.originalLaneId !== lane.id}
- isHovered={hoveredShopId === shop.truckRowId}
- hoveredPosition={hoveredPosition}
- movedBadgeLabel={movedBadgeLabel}
- seqLabel={seqLabel}
- seqEditAriaLabel={seqEditAriaLabel}
- onDragStartShop={onDragStartShop}
- onDragOverShop={onDragOverShop}
- onDropShop={onDropShop}
- onRevertShop={onRevertShop}
- onDeleteShop={onDeleteShop}
- removeFromLaneTooltip={removeFromLaneTooltip}
- onStartSeqEdit={onStartSeqEdit}
- />
- ))}
- </Stack>
- </Box>
- ))}
- <Button
- size="small"
- variant="text"
- startIcon={<Plus size={14} />}
- onClick={onAddDistrict}
- sx={{ alignSelf: "flex-start" }}
- >
- {addDistrictLabel}
- </Button>
- </Stack>
- )}
- </Box>
-
- <Box
- sx={{
- px: 1.5,
- py: 1,
- borderTop: 1,
- borderColor: "divider",
- bgcolor: "background.paper",
- flexShrink: 0,
- }}
- >
- <Button
- fullWidth
- size="small"
- variant="outlined"
- startIcon={<Plus size={14} />}
- onClick={onAddShop}
- sx={{ textTransform: "none", fontWeight: 700 }}
- >
- {addShopLabel}
- </Button>
- </Box>
- </Paper>
- );
- });
-
- const ScheduleDragWorkspacePane: React.FC<Props> = ({
- 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<string | null>(null);
- const [hoveredShopId, setHoveredShopId] = useState<number | null>(null);
- const [hoveredPosition, setHoveredPosition] = useState<"before" | "after">(
- "after",
- );
- const [seqEditTarget, setSeqEditTarget] = useState<SeqEditTarget | null>(
- null,
- );
- const [districtAddLaneId, setDistrictAddLaneId] = useState<string | null>(
- null,
- );
- const [districtAddDraft, setDistrictAddDraft] = useState("");
- const [districtAddError, setDistrictAddError] = useState<string | null>(
- null,
- );
- const [departureEditTarget, setDepartureEditTarget] = useState<{
- laneId: string;
- draft: string;
- } | null>(null);
- const [departureEditError, setDepartureEditError] = useState<string | null>(
- 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 = (
- <Box
- sx={{
- p: 1,
- bgcolor: "grey.100",
- borderRadius: 2,
- flexShrink: 0,
- display: "grid",
- gridTemplateColumns: "1fr 1fr",
- columnGap: 1.5,
- alignItems: "center",
- }}
- >
- <Stack
- direction="row"
- spacing={0.75}
- alignItems="center"
- sx={{ minWidth: 0 }}
- >
- <Typography variant="caption" sx={laneSelectorLabelSx}>
- {t("schedule_lane_left")}
- </Typography>
- <Box
- component="select"
- value={leftLaneId}
- onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
- onLeftLaneChange(e.target.value)
- }
- sx={nativeSelectSx}
- >
- {lanes.map((l) => (
- <option key={l.id} value={l.id} disabled={l.id === rightLaneId}>
- {l.label}
- </option>
- ))}
- </Box>
- </Stack>
- <Stack
- direction="row"
- spacing={0.75}
- alignItems="center"
- sx={{ minWidth: 0 }}
- >
- <Typography variant="caption" sx={laneSelectorLabelSx}>
- {t("schedule_lane_right")}
- </Typography>
- <Box
- component="select"
- value={rightLaneId}
- onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
- onRightLaneChange(e.target.value)
- }
- sx={nativeSelectSx}
- >
- {lanes.map((l) => (
- <option key={l.id} value={l.id} disabled={l.id === leftLaneId}>
- {l.label}
- </option>
- ))}
- </Box>
- </Stack>
- </Box>
- );
-
- 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 (
- <Stack spacing={1.5} sx={{ flex: 1, minHeight: 0 }}>
- {renderLaneSelectors}
- <Typography variant="body2" color="text.secondary" sx={{ p: 2 }}>
- {t("schedule_no_shops")}
- </Typography>
- </Stack>
- );
- }
-
- 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 (
- <>
- <Stack spacing={1.5} sx={{ flex: 1, minHeight: 0 }}>
- {renderLaneSelectors}
- <Box
- sx={{
- flex: 1,
- minHeight: 0,
- display: "grid",
- gridTemplateColumns: "1fr 1fr",
- gap: 1.5,
- }}
- >
- <LaneColumn {...columnProps(leftLane)} />
- <LaneColumn {...columnProps(rightLane)} />
- </Box>
- </Stack>
-
- <Dialog
- open={departureEditTarget != null}
- onClose={() => {
- setDepartureEditError(null);
- setDepartureEditTarget(null);
- }}
- maxWidth="xs"
- fullWidth
- >
- <DialogTitle>{t("departureDialog_title")}</DialogTitle>
- <DialogContent>
- <TextField
- margin="dense"
- fullWidth
- autoFocus
- type="time"
- label={t("seq_edit_departureLabel")}
- value={departureEditTarget?.draft ?? ""}
- error={departureEditError != null}
- helperText={departureEditError ?? undefined}
- onChange={(e) => {
- 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 }}
- />
- <Typography
- variant="caption"
- color="text.secondary"
- sx={{ mt: 1, display: "block" }}
- >
- {t("departureDialog_hint")}
- </Typography>
- </DialogContent>
- <DialogActions>
- <Button
- onClick={() => {
- setDepartureEditError(null);
- setDepartureEditTarget(null);
- }}
- >
- {t("cancel")}
- </Button>
- <Button variant="contained" onClick={applyDepartureEdit}>
- {t("btn_apply")}
- </Button>
- </DialogActions>
- </Dialog>
-
- <Dialog
- open={seqEditTarget != null}
- onClose={handleCancelSeqEdit}
- maxWidth="xs"
- fullWidth
- >
- <DialogTitle>{t("seqDialog_title")}</DialogTitle>
- <DialogContent>
- <TextField
- margin="dense"
- fullWidth
- autoFocus
- type="number"
- label={t("seq_edit_seqLabel")}
- value={seqEditTarget?.draft ?? ""}
- onChange={(e) =>
- 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 }}
- />
- <Typography
- variant="caption"
- color="text.secondary"
- sx={{ mt: 1, display: "block" }}
- >
- {t("schedule_seq_dialog_hint")}
- </Typography>
- </DialogContent>
- <DialogActions>
- <Button onClick={handleCancelSeqEdit}>{t("cancel")}</Button>
- <Button variant="contained" onClick={handleCommitSeqEdit}>
- {t("filter_apply")}
- </Button>
- </DialogActions>
- </Dialog>
-
- <Dialog
- open={districtAddLaneId != null}
- onClose={closeDistrictAdd}
- maxWidth="xs"
- fullWidth
- >
- <DialogTitle>{t("district_dialog_add")}</DialogTitle>
- <DialogContent>
- <TextField
- margin="dense"
- fullWidth
- autoFocus
- label={t("district_dialog_add")}
- value={districtAddDraft}
- onChange={(e) => {
- setDistrictAddDraft(e.target.value);
- setDistrictAddError(null);
- }}
- error={Boolean(districtAddError)}
- helperText={districtAddError ?? undefined}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- e.preventDefault();
- applyDistrictAdd();
- }
- }}
- sx={{ mt: 1 }}
- />
- </DialogContent>
- <DialogActions>
- <Button onClick={closeDistrictAdd}>{t("cancel")}</Button>
- <Button variant="contained" onClick={applyDistrictAdd}>
- {t("filter_apply")}
- </Button>
- </DialogActions>
- </Dialog>
- </>
- );
- };
-
- export default memo(ScheduleDragWorkspacePane);
|